Entendiendo el Compilador de Go: De SSA a Código Máquina

📚 Entendiendo el Compilador de Go (7 of 7)
  1. 1. El Scanner
  2. 2. El Parser
  3. 3. El Comprobador de Tipos
  4. 4. El Formato Unified IR
  5. 5. El IR
  6. 6. La Fase SSA
  7. 7. De SSA a Código Máquina Estás aquí
De SSA a Código Máquina

En el artículo anterior , exploramos cómo el compiler transforma IR en SSA—una representación donde cada variable se asigna exactamente una vez. Vimos cómo el compiler construye SSA usando Values y Blocks, luego ejecuta más de 30 pases de optimización. Observamos cómo el pase de lowering convierte operaciones genéricas en instrucciones específicas de arquitectura como AMD64ADDQ y ARM64ADD.

Ahora estamos en la recta final. El compiler ha optimizado SSA con operaciones específicas de arquitectura. Lo único que queda es convertir esas operaciones en bytes de código máquina.

Pero aquí está la cosa: no vamos directamente de SSA a bytes de código máquina. Hay un paso intermedio—una representación que se sitúa entre SSA y los bytes puros. Se llama obj.Prog, y es esencialmente un lenguaje ensamblador abstracto.

Déjame mostrarte el viaje completo desde SSA hasta los bytes que terminan en tu ejecutable.

El Viaje en Tres Fases

La generación final de código ocurre en tres fases:

digraph CodeGen {
    rankdir=LR;
    node [shape=box, style=rounded];

    SSA [label="Optimized SSA\n(architecture-specific)"];
    Prog [label="obj.Prog\n(assembly instructions)"];
    Bytes [label="Machine Code\n(raw bytes)"];
    Object [label="Object File\n(.o file)"];

    SSA -> Prog [label="genssa()"];
    Prog -> Bytes [label="span6()"];
    Bytes -> Object [label="WriteObjFile()"];
}

Fase 1 (genssa): Convertir operaciones SSA en estructuras obj.Prog—una representación similar a ensamblador

Fase 2 (assembler): Codificar instrucciones obj.Prog en bytes de código máquina

Fase 3 (object writer): Empaquetar esos bytes en un archivo objeto para el linker

Empecemos con esa misteriosa representación intermedia.

¿Qué es obj.Prog?

La estructura obj.Prog representa una única instrucción de ensamblador. Déjame mostrarte cómo se ve esta estructura—está definida en src/cmd/internal/obj/link.go:304 :

type Prog struct {
    Ctxt     *Link         // Contexto del linker
    Link     *Prog         // Siguiente instrucción (lista enlazada)
    From     Addr          // Operando fuente
    To       Addr          // Operando destino
    RestArgs []AddrPos     // Operandos adicionales (para instrucciones de 3+ operandos)
    Pc       int64         // Program counter
    As       As            // Opcode (AMOVQ, AADDQ, etc.)
    Reg      int16         // Segundo registro fuente
    // ... más campos
}

Piensa en esto como ensamblador portable. La estructura es independiente de la arquitectura—el mismo tipo Prog representa instrucciones x86, instrucciones ARM, instrucciones RISC-V, todo. Lo que cambia es el opcode (el campo As) y cómo se interpretan los operandos.

Aquí hay un ejemplo simple. La operación SSA:

v1 = AMD64ADDQ <int> v2 v3 : AX

Se convierte en este obj.Prog:

&obj.Prog{
    As: x86.AADDQ,        // Instrucción ADDQ
    From: obj.Addr{       // Operando fuente
        Type: obj.TYPE_REG,
        Reg:  x86.REG_BX,
    },
    To: obj.Addr{         // Operando destino
        Type: obj.TYPE_REG,
        Reg:  x86.REG_AX,
    },
}

Esto representa la instrucción ensamblador ADDQ BX, AX (sumar BX a AX).

Pero fíjate—esto todavía no es código máquina. Sigue siendo simbólico. Los registros tienen nombre (BX, AX), no están codificados como valores binarios. El opcode tiene nombre (AADDQ), no es el byte de opcode real. Esta es una representación abstracta con la que el assembler puede trabajar.

Ahora veamos cómo el compiler genera estas estructuras desde SSA.

Fase 1: SSA a obj.Prog (genssa)

La función genssa (src/cmd/compile/internal/ssagen/ssa.go:6603 ) toma esos Blocks y Values de SSA que construimos antes y los convierte en instrucciones obj.Prog.

Aquí está la cosa: SSA organiza el código en Blocks con flechas mostrando cómo se conectan (como “si verdadero, ir al Block A, si no al Block B”). Pero tu CPU no entiende de bloques—simplemente ejecuta instrucciones una tras otra en línea recta. Así que genssa aplana todo. Recorre cada Block, convierte cada Value en una instrucción, y luego añade instrucciones de salto al final para llegar al siguiente Block.

Una cosa interesante es que cada arquitectura tiene su propia forma de generar Progs desde SSA Values. Por ejemplo, puedes ver cómo AMD64 lo hace en src/cmd/compile/internal/amd64/ssa.go:202 —es un switch gigante que maneja cada operación SSA específica de AMD64. ARM tiene su propia versión, RISC-V otra, y así sucesivamente.

Ahora, si revisas ese código, notarás que la mayoría de los values de SSA se traducen directamente a una única instrucción Prog—una operación SSA se convierte en una instrucción ensamblador. Ese es el caso común, y mantiene las cosas simples.

Pero no siempre. Algunas operaciones SSA son más complejas y necesitan múltiples instrucciones ensamblador para implementarse. Por ejemplo, mira ssa.OpAMD64AVGQU (src/cmd/compile/internal/amd64/ssa.go:466 )—calcula el promedio de dos enteros sin signo. Esa única operación SSA genera dos instrucciones ensamblador: un AADDQ (suma) seguido de un ARCRQ (rotación a la derecha con carry, efectivamente un shift para dividir por 2). El compiler descompone esa operación de “promedio” de alto nivel en las instrucciones primitivas que la CPU realmente tiene.

Déjame guiarte por un ejemplo específico para ver cómo funciona. Digamos que tenemos este Value de SSA después de todos los pases de optimización:

v4 = AMD64ADDQ <int> v2 v3 : AX

Esto representa una suma de 64 bits donde:

  • v2 está en el registro AX
  • v3 está en el registro BX
  • El resultado v4 debe ir en el registro AX

Aquí está más o menos cómo el generador de código AMD64 maneja este caso (simplificado para mostrar la idea clave—revisa la implementación real en el enlace de arriba):

case ssa.OpAMD64ADDQ:
    r := v.Reg()           // Registro destino (AX)
    r1 := v.Args[0].Reg()  // Registro primer operando (AX)
    r2 := v.Args[1].Reg()  // Registro segundo operando (BX)

    p := s.Prog(v.Op.Asm())  // Crear instrucción ADDQ
    p.From.Type = obj.TYPE_REG
    p.From.Reg = r2          // Fuente: BX
    p.To.Type = obj.TYPE_REG
    p.To.Reg = r             // Destino: AX

El resultado es un obj.Prog que representa ADDQ BX, AX. En x86, ADDQ es una instrucción de dos direcciones: ADDQ src, dst significa dst = dst + src. Así que esto suma BX a AX y guarda el resultado en AX. Fíjate que v2 y v4 comparten el mismo registro (AX)—eso es porque en x86, un operando fuente debe ser el mismo que el destino.

Pero generar instrucciones para values individuales es solo la mitad de la historia. También necesitamos manejar cómo se conectan los bloques entre sí.

Generando Control de Flujo

Recuerda que genssa recorre los Blocks secuencialmente. Primero genera instrucciones para todos los Values en el Block, luego genera la instrucción de control de flujo que termina el Block.

Aquí es donde se pone interesante—el compiler necesita decidir cómo saltar al siguiente bloque. La instrucción exacta depende de qué tipo de Block estamos terminando:

Los bloques de retorno son los más simples—simplemente emitir una instrucción RET y listo. La función retorna a quien la llamó.

Los bloques condicionales son donde se pone divertido. Digamos que tienes un bloque “menor que”—uno que comprueba si x < 10 y ramifica en consecuencia. El compiler genera una instrucción de salto como JLT (jump if less than). Si la condición es verdadera, salta al bloque “true”. Si no, continúa al bloque “false”.

Aquí está la parte inteligente: en esta etapa, no sabemos dónde estará realmente el Block B en el código máquina final. Piensa en ello como escribir “ve a la cocina” en unas direcciones—estás usando un nombre, no coordenadas GPS. El compiler registra “salta al Block B” usando el nombre del bloque. Más tarde, una vez que el assembler sabe exactamente dónde termina cada bloque (como “Block B empieza en la posición 150”), vuelve y rellena la ubicación real.

Puedes ver cómo AMD64 maneja esto en ssaGenBlock (src/cmd/compile/internal/amd64/ssa.go:1462 )—es un switch que maneja cada tipo de bloque (return, conditional branch, unconditional jump, etc.) y emite la instrucción de control de flujo correcta.

Después de que genssa termina, tenemos una secuencia completa de instrucciones obj.Prog—todavía simbólicas, todavía abstractas, pero listas para convertirse en bytes de código máquina reales.

Fase 2: obj.Prog a Código Máquina (Assembler)

El trabajo del assembler es codificar instrucciones obj.Prog en bytes de código máquina. Esto ocurre en la función span6 (src/cmd/internal/obj/x86/asm6.go:2057 ).

Aquí es donde se complica: las instrucciones de salto vienen en diferentes tamaños. En x86, si estás saltando a algo cercano, puedes usar un salto corto (2 bytes). Pero si estás saltando lejos, necesitas un salto largo (6 bytes). El assembler tiene que averiguar qué tamaño usar.

Esto crea un problema del huevo y la gallina. Para saber si un salto debe ser corto o largo, necesitas saber qué tan lejos está el objetivo. Para saber qué tan lejos está el objetivo, necesitas saber los tamaños de todas las instrucciones intermedias. ¡Pero algunas de esas instrucciones podrían ser saltos también, cuyos tamaños dependen de sus objetivos!

No puedes calcular distancias hasta que conozcas tamaños, pero no puedes elegir tamaños hasta que conozcas distancias.

¿La solución? Intentar, comprobar y reintentar si es necesario.

Mira lo que hace el assembler: hace un pase inicial intentando codificar cada salto como corto. Recorre todas las instrucciones, asigna offsets de bytes, y comprueba si algún salto está demasiado lejos para su codificación. Si un salto no encaja, lo marca para expansión e intenta de nuevo. El siguiente pase usa una codificación más larga para ese salto, desplaza todo lo que viene después, y comprueba de nuevo.

Esto típicamente converge en solo 1-2 iteraciones—la mayoría de funciones no tienen saltos profundamente anidados o que saltan muy lejos.

Con todos los offsets calculados, el assembler ahora puede convertir instrucciones simbólicas en bytes reales.

Codificando Instrucciones

Una vez que el assembler sabe dónde va todo, codifica cada instrucción en bytes. Estos bytes se escriben en un buffer que eventualmente se convertirá en la sección de código máquina del archivo objeto.

Déjame mostrarte qué pasa con nuestra instrucción ADDQ BX, AX—aquí es donde ocurre la magia de verdad.

El assembler busca ADDQ en la tabla de opcodes (src/cmd/internal/obj/x86/asm6.go:921 ) y encuentra que la suma registro-a-registro usa opcode 0x01. Luego codifica los operandos—qué registros están involucrados, qué modo de direccionamiento usar—en bytes adicionales siguiendo las reglas de codificación de x86. Para operaciones de 64 bits, también añade un byte de prefijo REX.

Puedes ver cómo ocurre esta codificación en la función doasm (src/cmd/internal/obj/x86/asm6.go:4249 ), que maneja todos los detalles de codificación de x86.

El resultado final para ADDQ BX, AX son tres bytes escritos en el buffer:

[0x48, 0x01, 0xD8]
 REX.W  ADDQ  ModR/M

Déjame desglosar qué hace cada byte. Ese primer byte (0x48) se llama prefijo REX—es la forma de x86 de decir “esta es una operación de 64 bits”. El segundo byte (0x01) es el opcode ADDQ real. El tercer byte (0xD8) se llama byte ModR/M—codifica qué registros usar (BX como fuente, AX como destino) y en qué modo de direccionamiento estamos.

La codificación x86 tiene toneladas de variaciones para diferentes situaciones—acceso a memoria, indexación de arrays, diferentes modos de direccionamiento, lo que sea. Pero el assembler maneja todo esto automáticamente siguiendo las reglas de codificación de la arquitectura.

¿Pero qué pasa con direcciones que no podemos conocer todavía?

Relocations

Aquí hay un detalle más que debes saber: ¿qué pasa si una instrucción referencia una variable global o llama a una función en otro archivo? El assembler no sabe dónde terminarán esos símbolos—ese es el trabajo del linker.

Así que en lugar de codificar una dirección real, el assembler emite bytes placeholder (normalmente ceros) y crea una entrada de relocation. La relocation dice: “Cuando enlaces este código, parchea estos bytes con la dirección del símbolo X”. El linker leerá estas relocations más tarde y rellenará las direcciones reales una vez que haya combinado todos los archivos objeto.

Esto pasa también para llamadas a funciones. Cuando llamas a una función de otro paquete, el assembler genera una instrucción CALL con una dirección placeholder y una relocation apuntando al símbolo de esa función. El linker lo resuelve al construir el ejecutable final.

¡Ahora tenemos bytes de código máquina! Pero todavía están en memoria. Necesitamos escribirlos en disco.

Fase 3: Escribiendo Archivos Objeto

Ahora tenemos bytes de código máquina en un buffer. El trabajo del escritor de archivos objeto es tomar todos esos bytes y empaquetarlos en un archivo .o que el linker pueda leer.

Antes de poder escribir esos bytes en disco, déjame mostrarte en qué estructura van.

El Formato del Archivo Objeto

Go usa un formato de archivo objeto personalizado con varias secciones:

┌──────────────────────────────────┐
│ Header                           │
│  - Magic: "\x00go120ld"          │
│  - Fingerprint                   │
│  - Offsets to blocks             │
├──────────────────────────────────┤
│ String Table                     │
│  - All symbol names              │
│  - File names                    │
├──────────────────────────────────┤
│ Symbol Definitions               │
│  - Name, type, size, alignment   │
├──────────────────────────────────┤
│ Relocations                      │
│  - Offset, type, symbol, addend  │
├──────────────────────────────────┤
│ Data Block ← MACHINE CODE HERE   │
│  - [48 01 D8 C3] for add func    │
└──────────────────────────────────┘

Aquí está lo que encontrarás dentro. El header arranca con un número mágico—así es como las herramientas saben que este es un archivo objeto de Go—más un fingerprint (básicamente un hash de la API exportada del paquete) y offsets que apuntan a dónde vive cada sección.

Luego viene la string table, que es un truco inteligente para ahorrar espacio. Cada cadena de texto—nombres de funciones, nombres de variables, rutas de archivos—se guarda aquí una vez. En lugar de repetir “main.add” cincuenta veces por todo el archivo, lo guardamos una vez y otras secciones simplemente lo referencian por índice.

Las definiciones de símbolos es donde encontrarás cada función y variable en este archivo. Cada entrada tiene un nombre (apuntando de vuelta a esa string table), un tipo, un tamaño, y cualquier otro metadato que el linker necesite.

Luego está la sección de relocations—¿recuerdas esos bytes placeholder de los que hablamos? Esta sección contiene todas esas entradas de relocation. Cada una dice “en el offset de byte X, parchea la dirección del símbolo Y”.

Y finalmente, el bloque de datos—¡aquí es donde ocurre la magia! Aquí es donde terminan esos bytes que generó el assembler. El código máquina de cada función se escribe secuencialmente, junto con cualquier dato constante.

La función WriteObjFile (src/cmd/internal/obj/objfile.go:32 ) se encarga de generar todas estas secciones basándose en los datos que ya tenemos—los bytes de código máquina del assembler, los símbolos que hemos definido, las relocations que hemos recopilado. Simplemente empaqueta todo en este formato estructurado que el linker sabe leer.

Pero hay una pieza más del rompecabezas—¿cómo se guarda este archivo objeto para la caché de compilación?

El Formato de Archivo

¿Recuerdas en el artículo de Unified IR cuando exploramos archivos archive? Cada paquete compilado crea un archive con dos archivos dentro: __.PKGDEF y _go_.o. Entonces nos centramos en __.PKGDEF—la representación Unified IR con todos los datos de exportación. Pero vimos ese segundo archivo, _go_.o, y no profundizamos realmente en él.

Bueno, eso es lo que hemos estado generando a lo largo de todo este artículo.

Una vez que el assembler termina su trabajo y tenemos el archivo objeto completo, el compiler necesita cachear ese trabajo para compilaciones futuras. Aquí es donde entra el archivo archive. Crear el archive no es parte de la generación de código máquina en sí—es lo que pasa después para guardar los resultados.

Entonces, ¿qué va en cada archivo? Por un lado, __.PKGDEF contiene la representación Unified IR—información de tipos, firmas de funciones, constantes, y esos cuerpos de función inlineables de los que hablamos. Otros paquetes leen esto cuando importan tu código. El compiler lo escribe usando dumpCompilerObj() (src/cmd/compile/internal/gc/obj.go:113-116 ).

Por otro lado, _go_.o es lo que hemos estado construyendo en este artículo—los bytes de código máquina del assembler, la tabla de símbolos, y todas esas relocations para el linker. Este es el resultado de nuestro viaje de tres fases: SSA a obj.Prog, obj.Prog a bytes, bytes empaquetados en un archivo objeto. El compiler escribe esto usando dumpLinkerObj() (src/cmd/compile/internal/gc/obj.go:133-149 ), que llama a la función WriteObjFile() que discutimos antes.

Cuando ejecutas go build, el compiler crea estos archives y los guarda en la caché de compilación. Más tarde, al chequear tipos en imports, el compiler lee __.PKGDEF. Al enlazar el ejecutable final, el linker lee _go_.o. Dos archivos, dos trabajos diferentes, todo empaquetado junto.

Ahora que entendemos cómo funciona todo, veámoslo en acción.

Pruébalo Tú Mismo

¿Quieres ver todo este proceso en acción? ¡Generemos algo de código máquina real y echemos un vistazo a esos bytes!

Primero, crea un archivo add.go:

package main

func add(x, y int) int {
    return x + y
}

Ahora puedes usar go tool compile con el flag -S:

go tool compile -S add.go

Esto muestra el código ensamblador generado para cada función. Busca las instrucciones ADDQ BX, AX y RET—esas son las instrucciones obj.Prog de las que hablamos. Al final del listado de la función, verás los bytes de código máquina reales:

0x0000 00000 (/path/to/add.go:4)    ADDQ    BX, AX
0x0003 00003 (/path/to/add.go:4)    RET
0x0000 48 01 d8 c3                  H...

¡Esos bytes 48 01 d8 c3 son los mismos que recorrimos codificando antes en el artículo!

Para una vista más clara, usa objdump en el archivo objeto:

go tool compile -o add.o add.go
go tool objdump add.o

Esto te da una salida más legible con los bytes de código máquina justo al lado de cada instrucción:

TEXT main.add(SB) /path/to/add.go
  add.go:4        0x6a0            4801d8            ADDQ BX, AX
  add.go:4        0x6a3            c3                RET

Aquí puedes ver claramente los bytes 48 01 d8 para la instrucción ADDQ y c3 para el RET.

Ahora recapitulemos lo que hemos aprendido sobre esta fase final de compilación.

Resumen

¡Acabamos de ver cómo SSA optimizado se transforma en bytes de código máquina reales!

Tres fases hacen que ocurra. Fase 1: Las operaciones SSA se convierten en estructuras obj.Prog—ensamblador portable con nombres simbólicos. Fase 2: El assembler resuelve un problema del huevo y la gallina (necesitas tamaños para calcular offsets, necesitas offsets para elegir tamaños) intentando y reintentando hasta que todo encaje. Luego codifica instrucciones a bytes—ADDQ BX, AX se convierte en 48 01 d8. Fase 3: Todo se empaqueta en un archivo objeto con todos los metadatos y relocations necesarios.

Después de estas tres fases, el archivo objeto se empaqueta en un archive junto con __.PKGDEF y se guarda en la caché de compilación.

¿La parte elegante? Abstracciones en capas todo el camino. SSA para análisis, obj.Prog para generación de código portable, bytes puros para la CPU. Cada capa sirve su propósito.

Pero espera—tenemos bytes de código máquina en archivos objeto. ¿Cómo se convierten en un programa ejecutable que realmente puedes ejecutar? Ese es el trabajo del linker, y es la pieza final del rompecabezas. En el siguiente artículo, exploraremos cómo el linker une todos estos archivos objeto, resuelve esas relocations de las que hablamos, y produce tu ejecutable final.