Cuando hablamos de compiladores, solemos pensar en el proceso de convertir código fuente en algo que la máquina pueda ejecutar directamente. En el caso del lenguaje Zig, este proceso se divide en varias etapas, y una de las más cruciales es el backend del compilador.
¿Qué es un compilador?
Un compilador es una herramienta que traduce programas escritos en un lenguaje de alto nivel (como Zig) a código de bajo nivel (assembly o código binario). El trabajo del compilador se suele dividir en:
- Fase de análisis léxico (tokenización).
- Fase de análisis sintáctico (parsing).
- Fase de análisis semántico (tipo-checking, inferencia, etc.).
- Fase de optimización (transformaciones del árbol de sintaxis).
- Fase de generación de código (backend).
La arquitectura de Zig
Zig se diseñó para ser un lenguaje de sistemas con un compilador muy modular. Su frontend se encarga de analizar el código Zig y producir un árbol de sintaxis abstracta (AST). El backend, por su parte, toma ese AST optimizado y lo convierte en código de máquina. Zig permite que el backend se construya sobre distintas bibliotecas de generación de código, y actualmente se apoya fuertemente en LLVM.
¿Qué hace el backend del compilador Zig?
El backend es responsable de los siguientes pasos:
- Recibir el AST optimizado y las metas de target (arquitectura, OS, ABI).
- Traducir las estructuras del AST en IR (Intermediate Representation) de LLVM.
- Aplicar optimizaciones de LLVM (inlining, eliminación de código muerto, etc.).
- Generar código assembly o binario final para la arquitectura objetivo.
- Generar metadata adicional: tablas de símbolos, debug info, relocations.
Ejemplo práctico: de Zig a ensamblador
Veamos un pequeño programa en Zig que suma dos números y devuelve el resultado. Luego veremos el ensamblador que produce el backend de Zig y explicaremos cada línea.
Programa Zig
const std = @import("std");
pub fn main() void {
const sum = add(5, 7);
std.debug.print("Suma: {}\n", .{ sum });
}
fn add(a: i32, b: i32) i32 {
return a + b;
}
Si compilamos con:
$ zig build-exe sum.zig
$ ./sum
Suma: 12
Para observar la salida de ensamblador:
$ zig build-exe -fno-emit-bin -fno-emit-llvm -fno-emit-hdr -fno-emit-asm -fno-emit-llvm-bc -S sum.zig
Salida de ensamblador (x86‑64, Linux)
El siguiente fragmento es una simplificación de la salida real, pero contiene las líneas más relevantes para entender lo que hace el backend.
# sum.s
.section .rodata
.LC0:
.string "Suma: %d\n"
.text
.globl main
.type main, @function
main:
push rbp
mov rbp, rsp
sub rsp, 16 # Reservar espacio en stack
mov edi, 5 # Primer argumento de add
mov esi, 7 # Segundo argumento de add
call add # Llamada a add
mov edx, eax # Mover resultado a edx para printf
lea rsi, [rip + .LC0] # Cargar dirección del string
mov edi, edx # Primer argumento de printf
xor eax, eax # Limpia eax según convención de variadic
call printf # Llamada a la función estándar
mov eax, 0 # Código de retorno de main
leave
ret
add:
push rbp
mov rbp, rsp
mov eax, edi
add eax, esi
pop rbp
ret
Detalle línea a línea
.section .rodata: Crea una sección de datos de solo lectura para almacenar cadenas constantes..LC0:: Etiqueta que señala la ubicación de la cadena “Suma: %d\n”..string "Suma: %d\n": Define la cadena en memoria..text: Cambia a la sección de código ejecutable..globl main: Declaramaincomo símbolo global accesible desde otros módulos..type main, @function: Indica quemaines una función.push rbp; mov rbp, rsp: Guarda el marco de pila anterior y establece un nuevo marco.sub rsp, 16: Reserva 16 bytes de espacio en el stack para variables locales.mov edi, 5: Carga el primer argumento deadd(registraedise usa para el primer entero).mov esi, 7: Carga el segundo argumento deadd(
