TEMARIO DE CLASE
COMPILADORES 2 • GENERACIÓN DE CÓDIGO FINAL

Código RISC-V

Ing. Mario Ramírez Tobar
De arreglos simulados a memoria real con el Frame Pointer (fp / s0)

¿Cuál es nuestro objetivo?

  • En la fase anterior generamos Código de Tres Direcciones (C3D) y Cuádruplas usando estructuras simuladas en C++ (arreglos stack[] y heap[]).
  • En un compilador para proyectos grandes, las variables no se guardan en registros mágicos: se asignan en la memoria de la pila usando desplazamientos negativos respecto al Frame Pointer: -offset(fp).
  • Aprenderemos cómo emitir código robusto que no sufra por límites de registros ni por desplazamientos móviles de sp.

Assembler

EL PUENTE ENTRE C3D Y EL HARDWARE

¿Qué es Assembler para el Compilador?

  • Es la traducción final de nuestras cuádruplas a instrucciones que entiende el silicio.
  • En código máquina no existen tipos, variables con nombre, ni funciones.
  • Todo se transforma en dos conceptos primitivos:
    • 1. Operaciones y Transferencias: Cargar de memoria a registros, operar y volver a guardar.
    • 2. Flujo de Control: Comparaciones numéricas, etiquetas (direcciones de salto) y goto (branches).

RISC-V

GLOSARIO Y REGISTROS ABI (RV32I)

Glosario de Registros ABI

Abreviación Nombre Completo Significado en Español Uso en Compiladores
zero (x0) Zero Cero cableado Siempre vale 0 por hardware.
ra (x1) Return Address Dirección de Retorno Guarda a dónde volver tras un subprograma.
sp (x2) Stack Pointer Puntero de Pila Apunta al fondo actual móvil de la pila.
fp / s0 (x8) Frame Pointer Puntero de Marco Base fija e inamovible de la función activa.
gp (x3) / tp (x4) Global / Thread Ptr Puntero Global / Hilo Acceso a globales y contexto de hilos.
t0 - t6 Temporaries Temporales Registros rápidos y volátiles para evaluar expresiones.
s1 - s11 Saved Registers Registros Guardados Variables de larga vida (Callee-saved).
a0 - a7 Arguments Argumentos / Retorno Primeros 8 parámetros. a0-a1 reciben retornos.

Glosario de Mnemónicos RISC-V

Aritmética & Lógica
• add / addi: Add / Add Immediate (suma)
• sub: Subtract (resta)
• mul / div / rem: Multiply / Divide / Remainder (%)
• neg: Negate (0 - rs)
• and / or / xor: Operaciones bitwise
• sll / slli: Shift Left (multiplicar x 2^n)
• srl / sra: Shift Right Lógico / Aritmético
Memoria & Control
• lw / sw: Load / Store Word (4 bytes)
• lb / sb: Load / Store Byte (1 byte)
• slt / slti: Set on Less Than (comparación <)
• beq / bne: Branch Equal / Not Equal
• blt / bge: Branch Less Than / Greater Equal
• j: Jump incondicional (goto)
• jal / ret: Jump and Link / Return
💡 Regla nemotécnica: Si termina en i = Inmediato (constante). Si empieza con b = Branch (salto condicional). Si empieza con l = Load (de memoria a CPU). Si empieza con s = Store (de CPU a memoria).

Memoria

DIRECCIONAMIENTO FIJO CON -offset(fp)

¿Por qué las variables locales empiezan en -12(fp)?

Los primeros bytes de la cabecera del marco están estrictamente reservados por hardware y convención:

Nota: No siempre empiezan en -12(fp); pueden iniciar en -8(fp) si no hay parámetros ni this (solo s0 y ra), o después (-16(fp), -20(fp)...) si se respaldan más parámetros.

DISTRIBUCIÓN DEL MARCO DE PILA
0(fp) --> Antiguo Frame Pointer (s0) -4(fp) --> Dirección de Retorno (ra) -8(fp) --> Primer parámetro o 'this' -12(fp) --> ¡PRIMERA VARIABLE LOCAL! (i, contador...) -16(fp) --> Segunda variable local (x, total...) -20(fp) --> Temporales del C3D (t1, t2...)
JUSTIFICACIÓN TÉCNICA
1. 0(fp) y -4(fp): Si no respaldamos ra y el s0 previo al entrar, la función destruirá el camino de retorno y el programa colapsará en bucle infinito. 2. -8(fp): Reservado para el puntero this o el primer parámetro que deba preservarse. 3. -12(fp): Es el primer byte seguro y disponible para variables del usuario.

Aritmética

DE CUÁDRUPLAS A INSTRUCCIONES CON FP

Ejemplo: x = 25 + 12 + a; z = -x * 5 + 2;

CÓDIGO 3 DIRECCIONES (C3D)
t1 = 25 + 12 t2 = t1 + a x = t2 t3 = -x t4 = t3 * 5 t5 = t4 + 2 z = t5
RISC-V GENERADO (CON FP)
# Ranuras: a en -12(fp), x en -16(fp), z en -20(fp) li t0, 37 lw t1, -12(fp) # Cargar 'a' add t2, t0, t1 # t2 = 37 + a sw t2, -16(fp) # x = t2 lw t0, -16(fp) # Cargar 'x' neg t1, t0 # t1 = -x li t2, 5 mul t3, t1, t2 # t3 = -x * 5 addi t4, t3, 2 # t4 = t3 + 2 sw t4, -20(fp) # z = t4

Booleanas

CORTOCIRCUITO Y ASIGNACIÓN

Ejemplo: boolean x = b > 25;

C3D CON ETIQUETAS
x = 0 if b > 25 goto et1 goto et2 et1: x = 1 goto et3 et2: x = 0 et3:
RISC-V (b en -12(fp), x en -16(fp))
lw t0, -12(fp) # Cargar 'b' li t1, 25 bgt t0, t1, et1 # if (b > 25) goto et1 j et2 et1: li t2, 1 sw t2, -16(fp) # x = 1 j et3 et2: li t2, 0 sw t2, -16(fp) # x = 0 et3: # Continuar flujo

Condicionales

ESTRUCTURAS IF-ELSE Y SWITCH

Ejemplo: If - Else con fp

CÓDIGO 3 DIRECCIONES (C3D)
if x > z goto et1 goto et2 et1: t6 = z + 25 t7 = t6 + 1 y = t7 goto et3 et2: a = z + 1 * x et3:
RISC-V (VARIABLES EN PILA)
# x: -12(fp), z: -16(fp), y: -20(fp), a: -24(fp) lw t0, -12(fp) # t0 = x lw t1, -16(fp) # t1 = z bgt t0, t1, et1 j et2 et1: addi t2, t1, 26 # z + 26 sw t2, -20(fp) # y = t2 j et3 et2: add t3, t1, t0 # a = z + x sw t3, -24(fp) # guardar a et3:

Ciclos

BUCLES: WHILE, DO-WHILE Y FOR

Ciclo While: Condición al Inicio

Fuente: while (contador < limite) { total = total + contador; contador++; }
CÓDIGO 3 DIRECCIONES
L_cond: if contador < limite goto L_body goto L_end L_body: t1 = total + contador total = t1 t2 = contador + 1 contador = t2 goto L_cond L_end:
RISC-V (contador: -12(fp), limite: -16(fp), total: -20(fp))
L_cond: lw t0, -12(fp) # contador lw t1, -16(fp) # limite bge t0, t1, L_end # if (contador >= limite) salir L_body: lw t2, -20(fp) # total add t2, t2, t0 # total + contador sw t2, -20(fp) # guardar total addi t0, t0, 1 # contador++ sw t0, -12(fp) # guardar contador j L_cond # volver a verificar L_end:

Bucle For: Inicialización y Paso

Fuente: for (i = 0; i < 10; i = i * 2) { a++; }
CÓDIGO 3 DIRECCIONES
i = 0; et1: if i < 10 goto et2 goto et3 et2: // cuerpo t1 = a + 1 a = t1 // paso incremental t2 = i * 2 i = t2 goto et1 et3:
RISC-V (i en -12(fp), a en -16(fp))
sw zero, -12(fp) # i = 0 et1: lw t0, -12(fp) # t0 = i li t1, 10 bge t0, t1, et3 # if (i >= 10) goto et3 et2: # cuerpo lw t2, -16(fp) # t2 = a addi t2, t2, 1 # a++ sw t2, -16(fp) # incremento slli t0, t0, 1 # i = i * 2 sw t0, -12(fp) j et1 et3:

El Frame Pointer

¿POR QUÉ USAR FP Y NO SP EN FUNCIONES COMPLEJAS?

¿Por qué NO usar sp directamente?

  • El puntero sp es VOLÁTIL y MÓVIL: Cada vez que preparas argumentos para llamar a otra función, evalúas expresiones complejas en stack o usas memoria dinámica local, sp cambia su valor (addi sp, sp, -16).
  • El caos de los desplazamientos cambiantes: Si la variable x estaba en 4(sp), y luego empujas 3 argumentos a la pila para llamar a otra función, ¡ahora la variable x está en 16(sp)! El compilador tendría que recalcular los offsets de todas las variables locales instrucción por instrucción.
  • La solución perfecta con fp (Frame Pointer / s0): fp es un ancla estática. Se fija al entrar a la función y NUNCA se mueve. La variable x siempre será -12(fp) desde la primera línea hasta el return, sin importar cuánto se mueva sp.

jal vs j: Dos Formas de Saltar

j etiqueta (JUMP SIMPLE)
• ¿Qué hace? Salta incondicionalmente a una etiqueta. • ¿Modifica la pila? NO. • ¿Modifica ra? NO toca el registro ra. • Uso: Exclusivo para estructuras de control dentro del mismo contexto (if, else, while, for, switch). • Equivalente: goto etiqueta
jal ra, funcion (JUMP AND LINK)
• ¿Qué hace? Salta y guarda la dirección de retorno (PC + 4) en ra. • ¿Modifica la pila? Por convención, la función invocada ejecutará su prólogo y creará un nuevo marco. • Uso: Exclusivo para llamadas a subprogramas y funciones. • Retorno: La función hija saldrá con ret (jr ra).

¿Qué pasa si hay más de 8 parámetros?

  • RISC-V provee 8 registros dedicados a argumentos: a0 hasta a7.
  • Si una función recibe 10 argumentos:
    • • Arg 1 a Arg 8: Se pasan en a0, a1 ... a7 (súper rápido).
    • • Arg 9 y Arg 10: El llamador (*caller*) los empuja a la pila antes del jal:
LLAMADOR (CALLER)
# Cargar a0..a7 normalmente li t0, 99 # 9no argumento sw t0, 0(sp) # Empujar a la pila li t1, 100 # 10mo argumento sw t1, 4(sp) jal ra, funcionDiezParams
RECEPTOR (CALLEE)
# Dentro de la función, están en offsets positivos de fp! funcionDiezParams: addi sp, sp, -32 sw s0, 28(sp) # 0(fp): antiguo s0 sw ra, 24(sp) # -4(fp): retorno ra addi s0, sp, 28 # fp = sp + 28 lw t0, 4(s0) # Arg 9 (+4 arriba de fp) lw t1, 8(s0) # Arg 10 (+8 arriba de fp)

Memoria Dinámica en Pila vs FPO

  • ¿Qué pasaría con reserva dinámica (ej. alloca o int arr[n])? Si el tamaño del arreglo depende de n en tiempo de ejecución, el compilador debe hacer sub sp, sp, t0. En ese escenario, sin fp es imposible compilar, porque el desplazamiento respecto a sp es desconocido en compilación.
  • En el proyecto de Compiladores 2: La reserva es 100% estática calculada en el prólogo para el peor caso: addi sp, sp, -FRAMESIZE.
  • ¿Por qué GCC a veces no usa fp (FPO - Frame Pointer Omission)? En niveles de optimización altos (`-O2`), el compilador calcula offsets variables respecto a sp con matemáticas complejas para liberar el registro `s0` y ganar velocidad. Para los alumnos, usar fp simplifica el proyecto y evita semanas de depuración.

Llamadas Encadenadas: main ➔ A ➔ B ➔ C

Cada función reserva su propio tamaño exacto con tipos mezclados (int, double, char, boolean) y resguarda su ra:

TIPOS MEZCLADOS Y ALINEACIÓN
• Función A: - int id (4B, offset -8(fp)) - double saldo (8B, offsets -12(fp) y -16(fp)) - char grado (1B en slot -20(fp)) - boolean activo (1B en slot -24(fp)) - Tamaño marco: 32 Bytes (múltiplo de 16) • Función B: 24 Bytes (int x, int y, resC, ra, fp) • Función C: Función hoja (16 Bytes)
DESENROLLADO DE LA PILA
1. main llama a A: A guarda ra_main en -4(s0). 2. A llama a B: B guarda ra_A en -4(s0). 3. B llama a C: C coloca 99 en a0 y retorna a B con ret. 4. B suma x + 99 = 109, recupera ra_A con lw ra, -4(s0) y retorna a A. 5. A recupera ra_main con lw ra, -4(s0) y retorna 109 a main.

Caso Completo

DE CÓDIGO JAVA A RISC-V CON FRAME POINTER

Código Java Fuente y Asignación de Marco

MÉTODO EN JAVA
public int procesar(int a, int b) { int total = 0; int factor = a + b * 2; while (factor > 0) { total = total + factor; factor = factor - 1; } return total; }
ASIGNACIÓN ESTÁTICA EN FP
Tamaño del Marco: 32 Bytes 0(fp) --> Antiguo s0 (fp viejo) -4(fp) --> Dirección retorno ra -8(fp) --> Parámetro 'a' -12(fp) --> Parámetro 'b' -16(fp) --> Variable 'total' -20(fp) --> Variable 'factor' -24(fp) --> Temporal C3D 't1' (b * 2)

RISC-V Emitido para el Método Java

PRÓLOGO E INICIALIZACIÓN
procesar: addi sp, sp, -32 sw s0, 28(sp) # 0(fp): antiguo s0 sw ra, 24(sp) # -4(fp): retorno ra addi s0, sp, 28 # fp fijado en base! # Guardar argumentos de a0 y a1 en la pila sw a0, -8(s0) # guardar 'a' sw a1, -12(s0) # guardar 'b' # total = 0 sw zero, -16(s0) # factor = a + b * 2 lw t0, -12(s0) # t0 = b slli t1, t0, 1 # t1 = b * 2 lw t2, -8(s0) # t2 = a add t3, t2, t1 # t3 = a + (b * 2) sw t3, -20(s0) # factor = t3
BUCLE WHILE Y EPÍLOGO
L_while_cond: lw t0, -20(s0) # t0 = factor ble t0, zero, L_while_end # total = total + factor lw t1, -16(s0) # total add t1, t1, t0 sw t1, -16(s0) # factor = factor - 1 addi t0, t0, -1 sw t0, -20(s0) j L_while_cond L_while_end: # return total (cargar en a0) lw a0, -16(s0) # Epílogo lw ra, -4(s0) lw s0, 0(s0) addi sp, sp, 32 ret

POO en Assembler

EL HEAP, PUNTERO THIS Y FUNCIONES RECURSIVAS

¿Qué pasa con las 20 variables de clase en Recursión?

  • ¡Las variables de clase NO se duplican en la Pila! Viven en una única ubicación fija en el Heap. Aunque la función recursiva se llame 1,000 veces y genere 1,000 marcos con fp, los 80 bytes de atributos residen una sola vez en memoria.
  • El rol del puntero this: Entra en a0, se respalda en el marco local (ej. -8(fp)) y se vuelve a reenviar en a0 en cada llamada hija:
ACCESO A ATRIBUTOS EN RECURSIÓN
# 1. Recuperar 'this' del marco actual lw t0, -8(s0) # 2. Leer atributo 1 (offset 0) lw t1, 0(t0) # 3. Leer atributo 20 (offset 76) lw t2, 76(t0)
LLAMADA RECURSIVA CON THIS
# Pasar el mismo 'this' a la siguiente llamada lw a0, -8(s0) # a0 = this addi a1, s1, -1 # a1 = n - 1 jal ra, factorialRecursivo

Arquitectura Visual: Heap vs Stack

MONTÍCULO / HEAP (0x1000 - Crecimiento Hacia Arriba)
Objeto Instanciado 'Calculadora'
• [0x1000] offset 0: id (4 bytes int)
• [0x1004] offset 4: saldo (8 bytes double)
• [0x100C] offset 12: estado (1 byte bool)
• [0x1010] ... hasta offset 76 (20 atributos)
💡 Permanece en el Heap durante toda la vida del programa.
PILA / STACK (0x7FFF - Crecimiento Hacia Abajo)
Marco factorial(3) [fp: 0x7FF0]
• ra retorno a main | this = 0x1000 | n = 3
Marco factorial(2) [fp: 0x7FD0]
• ra retorno a fact(3) | this = 0x1000 | n = 2
Marco factorial(1) [fp: 0x7FB0]
• ra retorno a fact(2) | this = 0x1000 | n = 1
💡 Los marcos se crean y destruyen; todos apuntan a 0x1000.

Cuádruplas

MATRIZ DE TRADUCCIÓN A RISC-V

Equivalencias Directas (Stack-Based)

Cuádrupla C3D Instrucciones RISC-V Estrategia en el Compilador
(+ , a , b , t) lw t0, offA(fp); lw t1, offB(fp); add t2, t0, t1; sw t2, offT(fp) Cargar operandos, sumar y guardar resultado en ranura
(if> , a , b , L1) lw t0, offA(fp); lw t1, offB(fp); bgt t0, t1, L1 Cargar valores y emitir branch condicional
(goto , _ , _ , L1) j L1 Salto incondicional directo (sin tocar ra)
([]= , heap , H , val) sw t0, offset(a0) Almacenamiento de atributo en dirección Heap
(call , func , 2 , t0) jal ra, func; sw a0, offT(fp) Llamada con enlace y captura de retorno de a0
SIMULADOR RISC-V: JAVA ➔ C3D ➔ ENSAMBLADOR ➔ PILA (FP & SP)
☕ Java: public int factorial(int n) ▼
LLAMADAS: main() ➔ fact(4)
|
FASE: Inicialización
📄 factorial_recursivo.s Paso 0/0 PC: 0x0000
fp / s0
0x0154
sp
0x0138
ra
0x0004
a0 (ret)
0
t0
0
t1
0
t2
0
t3
0
PILA DE MEMORIA REAL (CRECE ⬇) fp: 0x0154 | sp: 0x0138
PTR DIR OFFSET MARCO & ROL VALOR
💡 EXPLICACIÓN: Listo para la demostración en clase. Toca "AVANZAR PASO" o "REGRESAR PASO".

☕ Análisis de Alto Nivel: Código Java y Traducción C3D

Factorial de 4: ¿Dónde está el Retorno en Cada Nivel?

FASE 1: CRECIMIENTO Y ENLACES (CRECE ⬇)
• main: invoca fact(4); el hardware fija ra = 0x0018 (PC + 4). • fact(4) [fp = 0x013C]: - Guarda fp_prev = 0x0154 (enlace a main). - Guarda ra = 0x0018 en -4(fp). - Guarda n = 4 en -8(fp). Llama a fact(3). • fact(3) [fp = 0x0124]: - Guarda fp_prev = 0x013C, ra = 0x0060, n = 3. Llama a fact(2). • fact(2) [fp = 0x010C]: - Guarda fp_prev = 0x0124, ra = 0x0060, n = 2. Llama a fact(1). • fact(1) [fp = 0x00F4] (CASO BASE): - 1 <= 1 es VERDADERO ➔ Coloca 1 en a0. - Restaura ra y fp, libera 24B y ejecuta ret.
FASE 2: DESENROLLADO Y CAPTURA DEL RETORNO
• Al volver a fact(2) [PC = 0x0060]: 1. Recibe a0 = 1. 2. Inmediatamente lo guarda en -12(fp): sw a0, -12(s0). 3. Carga n=2 de -8(fp) y ret=1 de -12(fp). 4. mul a0, t0, t1 (2 * 1 = 2) ➔ Deja 2 en a0 y retorna. • Al volver a fact(3) [PC = 0x0060]: 1. Recibe a0 = 2 y lo guarda en -12(fp) de su marco. 2. Multiplica n=3 * ret=2 = 6 ➔ Deja 6 en a0 y retorna. • Al volver a fact(4) [PC = 0x0060]: 1. Recibe a0 = 6 y lo guarda en -12(fp) de su marco. 2. Multiplica n=4 * ret=6 = 24 ➔ Deja 24 en a0 y retorna. • main [PC = 0x0018] recibe 24 en a0 y lo guarda en su variable.
Principio Vital para Alumnos: El registro a0 es un canal de paso. Si el invocador (caller) no respaldara a0 inmediatamente en su pila local (-12(fp)), cualquier llamada subsiguiente destruiría el valor devuelto.

Cadena A ➔ B ➔ C: Tipos Mixtos y Alineación

EMPAQUETADO EN MEMORIA (MARCO DE A: 32B)
• 0(fp): s0_viejo (4 bytes) ➔ Enlace dinámico a main • -4(fp): ra (4 bytes) ➔ Dirección de retorno a main • -8(fp): int id = 10 (4 bytes, alineado a 4B) • -12(fp) y -16(fp): double saldo = 3.14 (8 bytes): - Ocupa dos palabras contiguas de 32 bits en RV32 - Almacenado con dos sw (alta y baja) o fsd • -20(fp): char grado = 'Z' (1 byte con sb en ranura de 4B) • -24(fp): bool activo = true (1 byte con sb en ranura de 4B) • -28(fp): int resB = 109 (retorno de B, cierra múltiplo de 16B).
PRESERVACIÓN DE RA EN MÚLTIPLES NIVELES
• main salta a A con jal ra, func_A (en 0x0014): ra adquiere la dirección 0x0018 (PC + 4 en main). • A DEBE salvar ra en -4(fp) inmediatamente. • Cuando A llama a B con jal ra, func_B (en 0x004C): ra es sobreescrito por el hardware con 0x0050 (en A). • B salva ra (0x0050) en su propio marco (-4(fp)). • Cuando B llama a C con jal ra, func_C (en 0x0080): ra adquiere 0x0084 (en B). • Desenrollado: C retorna 99 a B (0x0084), B suma 10 + 99 = 109 y retorna a A (0x0050), y A retorna 109 a main (0x0018).
Regla de Oro del Compilador: Si una función no es hoja (invoca a otra función), ra SIEMPRE debe guardarse en el prólogo y restaurarse en el epílogo.

QuickSort (10 Elementos): Heap vs Stack

MONTÍCULO / HEAP (0x1000) - DATOS REALES
Arreglo de 10 enteros: int arr[10]
• 0x1000: 33 | 0x1004: 12 | 0x1008: 55 | 0x100C: 8
• 0x1010: 91 | 0x1014: 2 | 0x1018: 44 | 0x101C: 19
• 0x1020: 70 | 0x1024: 5
💡 El particionamiento permuta los valores in-situ dentro del Heap usando sw y lw sobre 0x1000 + i*4.
PILA / STACK - CONTROL RECURSIVO LIVIANO
Los marcos NO copian los 10 elementos:
• Marco qsort(arr, 0, 9): low=0, high=9, piv=4, ra, fp
• Marco qsort(arr, 0, 3): low=0, high=3, piv=1, ra, fp
• Marco qsort(arr, 0, 0): Caso base (0 >= 0) ➔ ret inmediato
• Marco qsort(arr, 2, 3): sub-rango derecho...
💡 La pila sólo almacena 24 bytes por nivel de recursión, garantizando que no ocurra un Stack Overflow.
Conexión con C3D: El arreglo vive en heap[H]. La llamada emite param 0x1000, param low, param high. El código RISC-V carga a0 = 0x1000 y usa direccionamiento base + desplazamiento para mutar la memoria global.

Conclusiones

GENERACIÓN DE CÓDIGO FINAL ROBUSTA Y COMPLETA

Con el uso del Frame Pointer (fp), el respaldo sistemático de ra y la separación entre Heap y Stack, tus alumnos tienen la base sólida para construir el compilador completo.