Si te pica el gusanillo del bajo nivel y quieres aprender a programar en ensamblador sobre arquitecturas modernas, RISC-V es una de las mejores puertas de entrada. Esta ISA abierta, con gran tracciĂłn en la industria y la academia, te permite practicar desde simuladores sencillos hasta ejecutarlo en una FPGA, pasando por toolchains completos para compilar C/C++ y examinar el ASM generado.
En esta guĂa práctica te cuento, de forma paso a paso y con un enfoque muy terrenal, quĂ© necesitas para empezar a programar en ensamblador RISC-V: las herramientas, el flujo de trabajo, ejemplos clave (condicionales, bucles, funciones, llamadas al sistema), ejercicios tĂpicos de laboratorio y, si te animas, un vistazo a cĂłmo se implementa una CPU RV32I y cĂłmo ejecutar tu propio binario en un nĂşcleo sintetizado en FPGA.
Qué es el ensamblador RISC-V y cómo se relaciona con el lenguaje máquina
RISC-V define una arquitectura de conjunto de instrucciones (ISA) abierta: el repertorio base RV32I incluye 39 instrucciones muy ortogonales y fáciles de implementar. El ensamblador (ASM) es un lenguaje de bajo nivel que utiliza mnemónicos como add, sub, lw, sw, jal, etc., alineados con esa ISA. El código máquina, por debajo, son los bits que entiende la CPU; el ensamblador es su representación legible, más cercana al hardware que cualquier lenguaje de alto nivel.
Si ya vienes de C, notarás que el ASM no se ejecuta tal cual: hay que ensamblarlo y enlazarlo para producir un binario. A cambio, te permite controlar registros, modos de direccionamiento y llamadas al sistema con precisiĂłn quirĂşrgica. Y si trabajas con un simulador docente, verás “ecall” como mecanismo de entrada/salida y finalizaciĂłn, con convenciones especĂficas segĂşn el entorno (p. ej., Jupiter frente a Linux).
Herramientas y entorno: simuladores, toolchain y FPGA
Para empezar rápido, el simulador gráfico Jupiter es ideal. Es un ensamblador/simulador pensado para docencia, inspirado en SPIM/MARS/VENUS y usado en asignaturas universitarias. Con él puedes escribir, ensamblar y ejecutar programas RV32I sin configurar todo un toolchain desde cero.
Si quieres ir un paso más allá, te interesa el toolchain bare-metal: riscv32-none-elf (GCC/LLVM) para compilar C/C++ a binarios RISC-V, y utilidades como objdump para desensamblar. Para simulación de hardware, GHDL te permite compilar VHDL, ejecutar y volcar señales en un fichero .ghw para inspeccionarlas con GtkWave. Y, si te animas a hardware real, puedes sintetizar una CPU RV32I en una FPGA con entornos del fabricante (p. ej., Quartus de Intel) o toolchains libres.
Primeros pasos con Jupiter: flujo básico y normas del ensamblador
Jupiter simplifica la curva de aprendizaje. Creas y editas archivos en la pestaña Editor, y todo programa comienza en la etiqueta global __start. AsegĂşrate de declararla con directiva .globl (sĂ, es .globl, no .global). Las etiquetas terminan con dos puntos y los comentarios pueden empezar con # o ;.
Un par de reglas Ăştiles del entorno: una sola instrucciĂłn por lĂnea, y cuando tengas listo el cĂłdigo, guarda y pulsa F3 para ensamblar y poder ejecutarlo. Los programas deben finalizar con una llamada ecall de salida; en Jupiter, poner 10 en a0 señala el fin del programa, de forma análoga a un “exit”.
Minimalmente, tu esqueleto ASM en Jupiter podrĂa verse asĂ, con el punto de entrada claro y la finalizaciĂłn por ecall: es la base del resto de ejercicios.
.text
.globl __start
__start:
li a0, 10 # cĂłdigo 10: terminar
ecall # finalizar programa
Convenio de llamadas (ABI) y manejo de la pila
Programar funciones en ensamblador requiere respetar el convenio: los argumentos suelen llegar en a0..a7, el resultado suele devolverse en a0, y las llamadas deben preservar direcciones de retorno (ra) y registros salvados (s0..s11). Para ello, el stack (sp) es tu aliado: reserva espacio al entrar y restaura al salir.
Algunas instrucciones que usarás todo el rato: li y la para cargar inmediatos y direcciones, add/addi para sumas, lw/sw para acceso a memoria, saltos incondicionales j/jal y retornos jr ra, además de condicionales como beq/bne/bge. AquĂ tienes un recordatorio rápido con ejemplos tĂpicos:
# cargar inmediato y una direcciĂłn
li t1, 5
la t1, foo
# aritmética y actualización de puntero de pila
add t3, t1, t2
addi sp, sp, -8 # reservar 8 bytes en stack
sw ra, 4(sp) # salvar ra
sw s0, 0(sp) # salvar s0
# acceso a memoria con base+desplazamiento
lw t1, 8(sp)
sw a0, 8(sp)
# saltos y comparaciones
beq t1, t2, etiqueta
j etiqueta
jal funcion
jr ra
Un bucle clásico en RISC-V puede estructurarse con claridad, separando condición, cuerpo y step. En Jupiter, además, puedes imprimir valores con ecall según el código que cargues en a0:
.text
.globl __start
__start:
li t0, 0 # i
li t1, 10 # max
cond:
bge t0, t1, endLoop
body:
mv a1, t0 # pasar i en a1
li a0, 1 # cĂłdigo ecall para imprimir entero
ecall
step:
addi t0, t0, 1
j cond
endLoop:
li a0, 10 # cĂłdigo ecall para salir
ecall
Para funciones recursivas, cuida el salvado/restaurado de registros y ra. Factorial es el ejemplo canĂłnico que te obliga a pensar en el stack frame y en devolver el control a la direcciĂłn correcta:
.text
.globl __start
__start:
li a0, 5 # factorial(5)
jal factorial
# ... aquĂ podrĂas imprimir a0 ...
li a0, 10
ecall
factorial:
# a0 trae n; ra tiene la direcciĂłn de retorno; sp apunta a tope de pila
bne a0, x0, notZero
li a0, 1 # factorial(0) = 1
jr ra
notZero:
addi sp, sp, -8
sw s0, 0(sp)
sw ra, 4(sp)
mv s0, a0
addi a0, a0, -1
jal factorial
mul a0, a0, s0
lw s0, 0(sp)
lw ra, 4(sp)
addi sp, sp, 8
jr ra
Entradas/salidas con ecall: diferencias entre Jupiter y Linux
La instrucción ecall sirve para invocar servicios del entorno. En Jupiter, códigos sencillos en a0 (p. ej., 1 imprimir entero, 4 imprimir cadena, 10 salir) controlan las operaciones disponibles. En Linux, en cambio, a0..a2 suelen contener parámetros, a7 el número de syscall, y la semántica corresponde a las llamadas del kernel (write, exit, etc.).
Este “Hola mundo” para Linux ilustra el patrĂłn: preparas los registros a0..a2 y a7 y lanzas ecall. FĂjate en la directiva .global y el punto de entrada _start:
# a0-a2: argumentos; a7: nĂşmero de syscall
.global _start
_start:
addi a0, x0, 1 # 1 = stdout
la a1, holamundo # puntero al mensaje
addi a2, x0, 11 # longitud
addi a7, x0, 64 # write
ecall
addi a0, x0, 0 # return code 0
addi a7, x0, 93 # exit
ecall
.data
holamundo: .ascii "Hola mundo\n"
Si tu objetivo es practicar lógica de control, memoria y funciones, Jupiter te da feedback instantáneo y, además, muchos laboratorios incluyen autograder para validar la solución. Si quieres practicar interacción con el sistema real, compilarás para Linux y utilizarás las syscalls del kernel.
Ejercicios de arranque: condicionales, ciclos y funciones
Un conjunto clásico de ejercicios para empezar en RISC-V ASM cubre tres pilares: condicionales, bucles y llamadas a función, con foco en el manejo correcto de registros y la pila:
- Negative: función que devuelve 0 si el número es positivo y 1 si es negativo. Recibe el argumento en a0 y devuelve en a0, sin destruir registros no volátiles.
- Factor: recorrer los divisores de un número, imprimiéndolos durante la ejecución y devolviendo la cantidad total. Practicarás ciclos, división/mod y llamadas a ecall para imprimir.
- Upper: dado el puntero a un string, recorrerlo y convertir minúsculas a mayúsculas in-place. Devolver la misma dirección; si mueves el puntero durante el bucle, restáuralo antes de retornar.
Para los tres, respeta el convenio de paso de parámetros y retorno, y termina el programa con ecall de salida cuando lo pruebes en Jupiter. Con esos ejercicios cubres flujo de control, memoria y funciones con “estado”.
Profundizando: de la ISA RV32I a una CPU sintetizable
RISC-V destaca por su apertura: cualquiera puede implementar un núcleo RV32I. Hay diseños educativos que demuestran paso a paso cómo construir una CPU base que ejecuta programas reales, compilados con GCC/LLVM para riscv32-none-elf. La experiencia enseña mucho sobre lo que ocurre «bajo el capó» cuando ejecutas tu ensamblador.
La implementaciĂłn tĂpica incluye un controlador de memoria que abstrae ROM y RAM, interconectado con el nĂşcleo. La interfaz de ese controlador suele tener:
- AddressIn (32 bits): direcciĂłn a acceder. Define el origen del acceso de instrucciĂłn o datos.
- DataIn (32 bits): dato a escribir. Para media palabra, solo se usan 16 bits LSB; para byte, 8 LSB. Se ignora en lectura.
- WidthIn: 0=byte, 1=media palabra (16 bits), 2 o 3=palabra (32 bits). Control de tamaño.
- ExtendSignIn: si al leer 8/16 bits se debe extender el signo en DataOut. Se ignora en escrituras.
- WEIn: 0=lectura, 1=escritura. DirecciĂłn del acceso.
- StartIn: flanco de inicio; al ponerlo a 1 se arranca la transacciĂłn, sincronizada al reloj.
Cuando ReadyOut=1, la operación ha concluido: en lectura, DataOut contiene el dato (con extensión de signo si procede); en escritura, el dato ya está en memoria. Esta capa permite intercambiar RAM interna de FPGA, SDRAM o PSRAM externa sin tocar el núcleo.
Una organización docente sencilla define tres fuentes VHDL: ROM.vhd (4 KB), RAM.vhd (4 KB) y Memory.vhd (8 KB) que integra ambas con un espacio contiguo (ROM en 0x0000..0x0FFF, RAM en 0x1001..0x1FFF) y un GPIO mapeado en 0x1000 (bit 0 a un pin). El controlador MemoryController.vhd instancia «Memory» y ofrece la interfaz al núcleo.
Sobre el núcleo: la CPU contiene 32 registros de 32 bits (x0..x31), con x0 atado a cero y no escribible. En VHDL es habitual modelarlos con arrays y bloques generate para evitar replicar lógica a mano, y un decodificador de 5 a 32 para seleccionar qué registro recibe la salida de la ALU.
La ALU se implementa combinacionalmente con un selector (ALUSel) para operaciones como suma, resta, XOR, OR, AND, desplazamientos (SLL, SRL, SRA) y comparaciones (LT, LTU, EQ, GE, GEU, NE). Para ahorrar LUTs en FPGA, una técnica popular es implementar desplazamientos de 1 bit y repetirlos N ciclos mediante la máquina de estados; se incrementa latencia, pero se reduce consumo de recursos.
El control se articula con multiplexores para entradas de la ALU (ALUIn1/2 y ALUSel), selección de registro destino (RegSelForALUOut), señales hacia el controlador de memoria (MCWidthIn, MCAddressIn, MCStartIn, MCWEIn, MCExtendSignIn, MCDataIn), y registros especiales PC, IR y un Counter para contar desplazamientos. Todo ello, dirigido por una máquina de estados con ~23 estados.
Un concepto clave en esa FSM es «carga retrasada»: el efecto de seleccionar una entrada de MUX se materializa al siguiente flanco de reloj. Por ejemplo, al cargar IR con la instrucción que llega de memoria, la secuencia pasa por estados de fetch (lanzar lectura en la dirección de PC), esperar ReadyOut, mover DataOut a IR y, ya en el siguiente ciclo, decodificar y ejecutar.
El camino de fetch tĂpico: en reset se fuerza PC=RESET_VECTOR (0x00000000), luego se configura el controlador para leer 4 bytes en la direcciĂłn de PC, se espera a ReadyOut y se carga IR. A partir de ahĂ, estados distintos gestionan ALU de un ciclo, desplazamientos multi-ciclo, cargas/almacenamientos, bifurcaciones, saltos y «especiales» (una implementaciĂłn docente puede hacer que ebreak detenga el procesador a propĂłsito).
Compilar cĂłdigo real y ejecutarlo en tu RISC-V
Una ruta de «prueba de concepto» muy didáctica es compilar un programa C/C++ con el compilador cruzado riscv32-none-elf, generar el binario y volcarlo a una ROM VHDL. Después simulas en GHDL y analizas señales en GtkWave; si todo va fino, sintetizas en una FPGA y ves el sistema funcionando en silicio.
Primero, un linker script adaptado a tu mapa: ROM de 0x00000000 a 0x00000FFF, GPIO en 0x00001000 y RAM de 0x00001001 a 0x00001FFF. Por simplicidad, puedes colocar .text (incluida una secciĂłn .startup) en ROM y .data en RAM, dejando la inicializaciĂłn de datos fuera si quieres acortar la primera versiĂłn.
Con ese mapa, una rutina de arranque minimalista coloca la pila al final de SRAM e invoca a main; marcada como «naked» y en la sección .startup para ubicarla en RESET_VECTOR. Tras compilar, objdump te permite ver el ASM real que ejecutará tu CPU (lui/addi para construir sp, jal a main, etc.).
Un ejemplo de «blinker» clásico consiste en alternar el bit 0 del GPIO mapeado: una espera corta para depurar en simulador (GHDL+GtkWave) y, en hardware real, aumentar el recuento para que el parpadeo sea perceptible. El Makefile puede producir un .bin y un script que convierta ese binario en inicialización de ROM.vhd; una vez integrado, compilas todo el VHDL, simulas y luego sintetizas.
Esta aproximación docente funciona incluso en FPGAs veteranas (p. ej., una Intel Cyclone II), donde la RAM interna se infiere con la plantilla recomendada y el diseño puede rondar un 66% de recursos. El beneficio pedagógico es enorme: ver cómo PC avanza, cómo se disparan lecturas (mcstartin), ReadyOut valida datos, IR captura instrucciones y cómo se propaga cada salto o jal a través de la FSM.
Lecturas, prácticas y autograder: una hoja de ruta
En entornos acadĂ©micos, lo habitual es que tengas objetivos claros: practicar condicionales y ciclos, escribir funciones respetando el convenio y manejar memoria. Las guĂas suelen aportar plantillas, un simulador (Jupiter), indicaciones de instalaciĂłn y un autograder para corregir.
Para preparar el entorno, acepta la asignaciĂłn en Github Classroom si asĂ te lo piden, clona el repositorio y abre Jupiter. Recuerda que __start debe ser global, que los comentarios pueden ser con # o ;, que hay una instrucciĂłn por lĂnea, y que debes finalizar con ecall (cĂłdigo 10 en a0). Compila con F3 y ejecuta pruebas. Si no arranca, el remedio clásico es reiniciar la máquina.
Sobre el formato esperado de cada ejercicio, muchas guĂas incluyen capturas y especifican: por ejemplo, Factor imprime los divisores separados por espacios y retorna la cuenta; Upper debe recorrer el string y transformar solo minĂşsculas a mayĂşsculas, sin tocar espacios, dĂgitos o signos de puntuaciĂłn, y devolver el puntero original.
La evaluación suele repartir puntos por serie (10/40/50) y puedes ejecutar un «check» para ver la nota del autograder. Cuando estés satisfecho, haz add/commit/push y sube la URL del repo donde te indiquen. Esta disciplina de ciclo de vida te acostumbra a validar y entregar con rigor.
Más ejercicios para afianzar: Fibonacci, Hanoi y lectura de teclado
Cuando controles lo básico, trabaja en tres clásicos adicionales: fibonacci.s, hanoi.s y syscall.s (u otra variante que lea del teclado y repita una cadena).
- Fibonacci: puedes hacerlo recursivo o iterativo; si lo haces recursivo, cuidado con el coste y con preservar ra/s0; iterativo te ejercita bucles y sumas.
- Hanoi: traducción de la función recursiva a ASM. Preserva contexto y argumentos entre llamadas: stack frame disciplinado. Imprime movimientos «origen → destino» con ecall.
- Lectura y repetición: lee un entero y una cadena, e imprime la cadena N veces. En Jupiter, usa los códigos ecall adecuados disponibles en tu práctica; en Linux, prepara a7 y a0..a2 para read/write.
Estos ejercicios consolidan paso de parámetros, bucles y E/S. Te obligan a pensar en la interfaz con el entorno (Jupiter vs Linux), y a estructurar el ASM para que sea legible y mantenible.
Detalles finos de implementaciĂłn: registros, ALU y estados
Volviendo al núcleo RV32I educativo, merece la pena repasar varios detalles finos que cuadran lo que ves al programar con cómo lo ejecuta el hardware: la tabla de operaciones de ALU seleccionada por ALUSel (ADD, SUB, XOR, OR, AND, SLL, SRL, SRA, comparaciones firmadas y sin signo), la «identidad» como caso por defecto, y el «truco» de usar un contador (Counter) para acumular desplazamientos multi-ciclo.
La lógica de registros con generate produce un decodificador de 5→32, y el caso RegSelForALUOut=00000 no hace nada (x0 no se puede escribir, siempre vale cero). El PC, IR y Counter tienen sus MUX propios, orquestados por la FSM: desde reset, fetch, decode/execute (ALU de un ciclo o bucles para shift), cargas/almacenamientos, ramas condicionales, jal/jalr y especiales como ebreak.
En acceso a memoria de datos, es fundamental la coordinación MUX→Controlador: MCWidthIn (8/16/32 bits), MCWEIn (R/W), MCAddressIn (desde registros o PC), MCExtendSignIn (para LB/LH firmados) y MCStartIn. Solo cuando ReadyOut=1 debes capturar DataOut y avanzar de estado. Esto alinea tu mentalidad de programador ASM con la realidad hardware temporal.
Todo esto conecta directamente con lo que observas en la simulación: cada vez que PC avanza, se dispara una lectura de instrucción, MCReadyOut indica que puedes cargar IR, y a partir de ahà la instrucción hace su efecto (p. ej., «lui x2,0x2» seguido de «addi x2,x2,-4» para preparar sp, «jal x1, …» para llamar a main). Verlo en GtkWave engancha mucho.
Recursos, dependencias y consejos finales
Para reproducir esta experiencia necesitas pocas dependencias: GHDL para compilar VHDL y GtkWave para analizar señales. Para el compilador cruzado, cualquier GCC riscv32-none-elf te sirve (puedes compilar el tuyo o instalar uno preconstruido). Y para llevar el núcleo a una FPGA, usa el entorno de tu fabricante (por ejemplo, Quartus en Intel/Altera) o toolchains libres compatibles con tu dispositivo.
Además, vale la pena leer guĂas y apuntes de RISC-V (por ejemplo, manuales prácticos y «green cards»), consultar libros de programaciĂłn, y practicar con laboratorios que incluyen Jupiter y autograder. MantĂ©n una rutina: planifica, implementa, prueba con casos lĂmite, y luego integra en proyectos mayores (como el blinker en FPGA).
Con todo este recorrido, ya tienes lo esencial para arrancar: el porqué del ensamblador frente al código máquina, el cómo montar entorno con Jupiter o Linux, los patrones de bucles, condicionales y funciones con manejo correcto de la pila, y una ventana a la implementación hardware para entender mejor lo que ocurre cuando ejecutas cada instrucción.
Si lo tuyo es aprender haciendo, empieza por Negative, Factor y Upper, sigue con Fibonacci/Hanoi y un programa con lectura de teclado, y cuando estés a gusto, compila un C++ sencillo, vuelca la ROM en VHDL, simula en GHDL y da el salto a FPGA. Es un viaje de menos a más en el que cada pieza encaja con la siguiente, y la satisfacción de ver tu propio código moviendo un GPIO o parpadeando un LED no tiene precio.