02 - Compiladores


Un poco de historia

Un computador funciona gracias a instrucciones ejecutadas por el procesador. Estas instrucciones están escritas en binario, ya que es el único “lenguaje” que el procesador entiende, basado en señales de voltaje.

En los primeros días de la informática, los programadores escribían directamente en binario o hexadecimal. Esto era propenso a errores, difícil de mantener y muy agotador, por eso surgió el lenguaje ensamblador, una forma más legible de escribir instrucciones, aunque seguía siendo específico para cada tipo de procesador. Con el tiempo, aparecieron los lenguajes de programación de alto nivel, que facilitaron la escritura de programas y mejoraron la portabilidad entre diferentes sistemas. Pero esto llevó a una pregunta clave: si el procesador solo entiende binario, ¿cómo se ejecuta un código escrito en lenguaje humano?

¿Qué es compilar un programa?

Tras la problemática anterior nacen los compiladores. Dejando de lado tanta cháchara, vamos al grano: ¿qué significa compilar un programa? Primero, no todos los lenguajes se compilan. Por ejemplo, Python y JavaScript son lenguajes interpretados. En cambio, lenguajes como C/C++, Rust o Go sí se compilan. Cuando hablamos de “compilar”, nos referimos al proceso de traducir el código fuente a un archivo ejecutable que el procesador pueda entender, es decir, código binario.

02-traductor

Este proceso no es tan simple como parece. El compilador realiza varios pasos complejos, comenzando con el análisis léxico y semántico. Usando teoría matemática y autómatas, verifica que el código tenga una sintaxis correcta y que tenga sentido dentro del contexto del lenguaje.

02-analisis

Los siguientes pasos consisten en la generación del código intermedio (IR) y su optimización. Este código intermedio es mucho más digerible para el compilador que el original, lo que permite aplicar optimizaciones que hacen que el ejecutable final sea más eficiente y liviano. Algunas de estas optimizaciones incluyen la eliminación de código muerto, advertencias sobre variables no utilizadas, entre otras, además, este código es portable y está a un solo paso de convertirse en código ensamblador.

Luego, el código intermedio se transforma en código ensamblador para la arquitectura objetivo. Con esto se llega a una de las últimas etapas del proceso: convertir ese ensamblador en un archivo objeto. Este archivo contiene instrucciones parciales de ejecución, ya que aún faltan por resolver algunas dependencias externas y las direcciones de memoria donde se almacenarán los recursos del programa.

Advertencia:
Esta tabla es una pseudorrepresentación general de cómo podría lucir la estructura de un archivo objeto. Digo "pseudorrepresentación" porque esta estructura varía según el sistema operativo, aunque el concepto general se mantiene. Como puedes ver, el archivo está casi listo para convertirse en un ejecutable.
02-objeto

Finalmente, ¡llegamos al último paso! Este consiste en enlazar (linkear) el archivo objeto con sus dependencias y otros archivos objeto. En esta etapa también se resuelven las direcciones de memoria donde se almacenarán las funciones, variables y demás recursos necesarios para la ejecución del programa.

02-link

Instalando nuestro primer compilador

Bien, vamos a empezar con la parte didáctica: ¡vamos a instalar nuestro primer compilador! En este caso, instalaremos el compilador de C, llamado GCC. En Linux, este ya viene instalado por defecto; en cambio, en Windows debemos instalarlo manualmente. Este viene incluido en el paquete MinGW, el cual podemos encontrar en SourceForge: Descargar MinGW desde SourceForge. También puedes tomar este tutorial como referencia: Instalación de MinGW para compilar en C/C++ 👨🏻‍💻.

Una vez instalado, abriremos nuestro editor de código favorito y ¡manos a la obra! Para empezar, vamos a probar una compilación normal. Para ello, escribiremos un programa “Hola mundo” y verificaremos cómo se compila:

#include <stdio.h>

int main() {
    printf("Hola mundo!\n");
    return 0;
}

Tras terminar de escribir nuestro código, vamos a guardarlo y abrir nuestra terminal favorita. En mi caso, utilizo Kitty en Linux; si usas Windows, puedes usar PowerShell, WezTerm u otra terminal de tu preferencia. Para compilar nuestro programa, escribiremos el siguiente comando:

❯ gcc <nombre>.c -o <nombre del ejecutable> 

Si todo salió correcto, obtendremos un archivo ejecutable con el nombre que le asignamos. El tipo de archivo ejecutable dependerá del sistema operativo, pero este tema lo abordaremos en una próxima clase. Ahora, vamos a ejecutar nuestro “Hola mundo” para verificar qué nos devuelve:

❯ gcc main.c -o ejecutable
❯ ./ejecutable
Hola mundo!

¡Todo funcionando! Pero, ¿por qué obtenemos el ejecutable directamente? ¿Acaso se saltó los pasos anteriores? ¡No! Simplemente realiza estos procesos sin mostrárselos al usuario. Aun así, nosotros queremos ver el proceso. Bien, en primera instancia vamos a obtener el código en ensamblador, para esto, asignamos el parámetro -S antes del nombre de nuestro archivo .c:

❯ gcc -S main.c -o ensamblador.asm
	.file	"main.c"
	.text
	.section	.rodata
.LC0:
	.string	"Hola mundo!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
    ; ...

No se preocupen si no entienden ensamblador por ahora, solo estamos evidenciando el proceso. Ahora vamos a generar nuestro archivo objeto. Para esto, escribiremos el parámetro -c antes del archivo .c:

❯ gcc -c main.c -o objeto.o

El archivo objeto no lo podemos ver como tal. Para esto, debemos usar una herramienta especial llamada objdump. Esta viene instalada por defecto en Linux, y en Windows se incluye con MinGW. A través de esta herramienta, podemos inspeccionar las secciones de un archivo objeto. Por ejemplo, vamos a verificar la “Section Header Table” que evidenciamos en la tabla, para esto, escribiremos el parámetro -s antes de nuestro archivo objeto .o:

❯ objdump -s objeto.o
objeto.o:     file format elf64-x86-64

Contents of section .text:
 0000 554889e5 488d0500 00000048 89c7e800  UH..H......H....
 0010 000000b8 00000000 5dc3               ........].      
Contents of section .rodata:
 0000 486f6c61 206d756e 646f2100           Hola mundo!.    
Contents of section .comment:
 0000 00474343 3a202847 4e552920 31342e32  .GCC: (GNU) 14.2
 0010 2e312032 30323530 32303700           .1 20250207.    
Contents of section .note.gnu.property:
 0000 04000000 20000000 05000000 474e5500  .... .......GNU.
 0010 020001c0 04000000 01000000 00000000  ................
 0020 010001c0 04000000 01000000 00000000  ................
Contents of section .eh_frame:
 0000 14000000 00000000 017a5200 01781001  .........zR..x..
 0010 1b0c0708 90010000 1c000000 1c000000  ................
 0020 00000000 1a000000 00410e10 8602430d  .........A....C.
 0030 06550c07 08000000                    .U......        

Esto lo podemos hacer con cada sección del archivo objeto; basta con consultar los posibles parámetros con -h. Procedamos con el último paso de nuestro ejemplo didáctico: linkear el archivo objeto. Este proceso es simple; es como compilar de manera tradicional, pero en vez del archivo .c, pasaremos el archivo objeto.

❯ gcc objeto.o -o ejecutable 
❯ ./ejecutable
Hola mundo!

Diferencias entre compilador e intérprete

Para finalizar con esta entrada del blog, ya sabemos qué es un compilador, pero ahora bien, ¿qué es un intérprete? La diferencia entre estos radica en cómo se traducen las instrucciones al procesador. Mientras que un compilador genera un archivo ejecutable a partir del bloque de código completo, un intérprete no genera un archivo ejecutable; más bien, traduce a código máquina línea por línea, por lo tanto, se ejecuta inmediatamente.

Esto tiene tanto desventajas como ventajas. Una de las más grandes es el tiempo de ejecución, puede sonar contradictorio, pero un lenguaje compilado es más rápido que uno interpretado debido a que el trabajo pesado ya se hizo antes de que se ejecutara, mientras que un intérprete tiene que ejecutar y traducir a la vez. Sin embargo, un lenguaje interpretado puede llegar a ser más flexible, ya que se puede modificar incluso en runtime.

LenguajeTareaTiempo
CImprimir “hola mundo”2.45 millis
PythonImprimir “hola mundo”21.67 millis
CContar hasta mil6.38 millis
PythonContar hasta mil27.72 millis