- Comprende RV32I: registros, ABI y control de flujo con ecall.
- Practica con Jupiter y ejercicios: negativo, factores, cadenas, recursión.
- Domina toolchain cruzada, script de enlace y depuración con objdump.
Si te pica la curiosidad por el ensamblador y sientes que RISC-V es el camino a seguir, estás en el lugar adecuado. Arrancar con ASM en RISC-V es más asequible de lo que parece si entiendes las herramientas, el modelo de programación y algunas reglas clave de la arquitectura.
En las líneas que siguen he unido lo mejor de varias fuentes: prácticas con simuladores tipo Jupiter, convenciones del repertorio base RV32I, ejemplos de bucles y recursión, llamadas al sistema y hasta una mirada al diseño de una CPU RISC-V en VHDL (con ALU, control de memoria y máquina de estados), además de un repaso a la toolchain cruzada y scripts de enlace.
Qué es el ensamblador RISC-V y cómo se diferencia del lenguaje máquina
Aunque ambos están pegados al hardware, el lenguaje máquina es binario puro (unos y ceros) que la CPU interpreta directamente, mientras que el ensamblador utiliza mnemónicos y símbolos más legibles que luego un ensamblador traduce a binario.
RISC-V define una ISA abierta con un repertorio base muy limpio. El perfil RV32I (32 bits) incluye 39 instrucciones de usuario con una ortogonalidad notable, separando acceso a memoria de cómputo puro, y con soporte excelente en GCC/LLVM.
Registros, convenios y punto de entrada
En RV32I tienes 32 registros de propósito general (x0–x31) de 32 bits; x0 siempre lee como 0 y no se puede escribir. Además, se emplean alias como a0–a7 (argumentos), t0–t6 (temporales) o s0–s11 (salvados), útiles para seguir el ABI.
Según el entorno o simulador, el programa puede comenzar en una etiqueta concreta. En Jupiter los programas arrancan en la etiqueta global __start, que debes declarar como visible (por ejemplo, con .globl) para marcar el punto de entrada.
Las etiquetas terminan en dos puntos, solo puedes poner una instrucción por línea y los comentarios pueden iniciarse con # o ; para que el ensamblador los ignore.
Herramientas y simuladores: Jupiter y flujo de trabajo básico
Para practicar sin complicaciones, tienes el simulador/ensamblador Jupiter, una herramienta gráfica inspirada en SPIM/MARS/VENUS que facilita edición, ensamblado y ejecución en un solo entorno.
En Jupiter puedes crear, editar y borrar archivos en la pestaña Editor. Tras guardar, ensamblas con F3 y ejecutas para depurar el flujo instrucción a instrucción, usando las vistas de registros y memoria para entender el estado de la máquina.
Los programas deben finalizar con una llamada al entorno: ecall de salida configurando a0 con el código 10 (exit). En RISC-V las ecall son equivalentes a system calls o trampas al entorno/sistema.
Estructura mínima de un programa y llamadas al sistema
La estructura típica en ejemplos académicos define un punto de inicio, realiza el trabajo y termina con ecall. Los argumentos de ecall suelen viajar en a0–a2 y el selector de servicio en a7, dependiendo del entorno.
En un Linux RISC-V, por ejemplo, puedes imprimir con la syscall de escritura y salir con el código adecuado. Para write se utilizan a0 (fd), a1 (buffer), a2 (longitud) y a7 con el número de servicio. Para terminar, se prepara a0 con el código de retorno y a7 con el número de exit.
# Ejemplo mínimo (Linux RISC-V) para escribir y salir
.global _start
_start:
addi a0, x0, 1 # fd = 1 (stdout)
la a1, msg # a1 = &msg
addi a2, x0, 12 # a2 = longitud
addi a7, x0, 64 # write
ecall
addi a0, x0, 0 # return code
addi a7, x0, 93 # exit
ecall
.data
msg: .ascii "Hola mundo\n"
Si trabajas fuera de Linux, como en simuladores educativos con servicio de IO propio, cambia el número de ecall y los registros según documentación del entorno.
Ejercicios iniciales para soltarte con condicionales, ciclos y memoria
Un calentamiento típico es detectar si un entero es negativo. Puedes devolver 0 si es positivo y 1 si es negativo; con RV32I, una comparación con 0 y un set-on-less-than resuelve la papeleta en una sola instrucción bien pensada.
Otro ejercicio muy útil es listar factores de un número: recorre desde 1 hasta n, imprime los divisores y devuelve cuántos hubo. Practicarás ramas condicionales, divisiones (o restas repetidas), y bucles con sumas y comparaciones.
Trabajar con cadenas te obliga a manejar memoria: visitar cada carácter de un string en memoria e in-place convertir minúsculas a mayúsculas si encajan en el rango ASCII. Al finalizar, devuelve la dirección original del string.
Bucles, funciones y recursión: factorial, fibonacci y torres de Hanoi
Al diseñar bucles, piensa en tres bloques: condición, cuerpo y paso. Con beq/bne/bge y saltos incondicionales jal/j se construyen while/for sin misterio, apoyándote en addi y comparaciones.
.text
.globl __start
__start:
li t0, 0 # i
li t1, 10 # max
cond:
bge t0, t1, end # si i >= max, salta
# cuerpo: usar a0/a1 segun servicio IO del entorno
addi t0, t0, 1 # i++
j cond
end:
li a0, 10
ecall
En llamadas a función, respeta el ABI: guarda ra si vas a encadenar más llamadas, preserva s0–s11 si los modificas y usa el stack con sp moviéndose en múltiplos de palabra.
Factorial es el clásico de recursión: caso base n==0 devuelve 1; en otro caso, llama a factorial(n-1) y multiplica por n. Protege ra y registros salvados en el stack antes de la llamada y restáuralos al volver.
factorial:
beq a0, x0, base
addi sp, sp, -8
sw ra, 4(sp)
sw s0, 0(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
base:
li a0, 1
jr ra
Fibonacci sirve para practicar tanto recursión con dos llamadas como una versión iterativa eficiente con variables acumuladoras. Y si quieres un reto de control de flujo y parámetros, traduce a ensamblador una solución de torres de Hanoi con cuatro argumentos: discos, torre origen, destino y auxiliar; respeta el orden de llamadas y muestra cada movimiento.
Acceso a memoria, arrays y manipulación de strings
En RISC-V el acceso a memoria se hace con load/store: lw/sw para palabras, lh/sh para medias palabras y lb/sb para bytes, con variantes con signo o sin él en las cargas (lb vs lbu, lh vs lhu).
Para recorrer arrays de enteros usa desplazamientos de 4 bytes por índice; en cadenas de texto, avanza de byte en byte hasta encontrar el terminador si el convenio lo requiere (p. ej., \0). Recuerda guardar direcciones base y manejar punteros con addi/auipc/la según el caso.
Diseñar una CPU RV32I desde cero: visión de alto nivel
Si te apetece bajar al silicio, un proyecto didáctico construye una CPU RV32I en VHDL, sintetizable en FPGA de gama media-baja. Incluye ROM de programa, RAM de datos y un GPIO sencillo para encender un LED.
El núcleo implementa el repertorio base (sin extensiones M/A/C ni CSRs), usa un bus de direcciones de 32 bits y permite accesos de 8/16/32 bits a memoria con extensión de signo cuando procede. El diseño separa claramente registros, ALU, controlador de memoria y máquina de estados.
ALU, desplazamientos e idea de «carga retrasada»
La ALU se describe de forma combinacional con operaciones como suma, resta, XOR, OR, AND, comparaciones (signed y unsigned) y desplazamientos lógicos/ariméticos.
Para ahorrar LUTs en FPGA, los desplazamientos multi-bit se implementan iterando desplazamientos de 1 bit controlados por la máquina de estados: consumes varios ciclos, pero reduces recursos lógicos.
En circuitos síncronos, los cambios se observan en flancos de reloj. El concepto de «delayed load» recuerda que lo seleccionado por un multiplexor impacta en el registro en el siguiente ciclo, aspecto clave al diseñar la máquina de estados de fetch-decode-execute.
Controlador de memoria y mapa: ROM, RAM y GPIO
Un bloque de memoria integra ROM y RAM en un espacio contiguo, simplificando la interfaz del procesador. El controlador recibe AddressIn (32 bits), DataIn, el ancho (byte/half/word), la señal de extensión de signo, WE (lectura/escritura) y Start para iniciar transacciones.
Cuando la operación termina, ReadyOut se pone a 1 y, si ha sido lectura, DataOut contiene el dato (extendido con signo cuando se pide). Si ha sido escritura, el dato queda alojado en la RAM.
Un ejemplo de mapa práctico: ROM de 0x0000 a 0x0FFF, un byte GPIO en 0x1000 (bit 0 a un pin) y RAM de 0x1001 a 0x1FFF. Con esto puedes hacer un «blinker» escribiendo y alternando el bit de salida.
Registros, multiplexores y máquina de estados
La CPU define 32 registros de propósito general instanciados con arrays en VHDL, con un decodificador para seleccionar el destino de escritura desde la ALU y conservar el resto.
Multiplexores gobiernan entradas de la ALU (operandos y operación), las señales hacia el controlador de memoria (anchos, dirección, control de inicio y lectura/escritura) y los registros especiales: PC, IR y un contador auxiliar para desplazamientos iterativos.
La máquina de estados arranca con reset, hace fetch de la instrucción apuntada por PC (lectura de 4 bytes), la carga en IR al estar lista y pasa a los nodos de ejecución: ALU (una instrucción en 1 ciclo salvo desplazamientos), carga/almacenamiento, bifurcaciones y saltos, además de instrucciones especiales como ebreak.
Toolchain cruzada, enlazado y depuración
Para generar binarios RV32I, usa un GCC cruzado (target riscv32-none-elf). Compilas fuentes C/C++/ASM, enlazas con un script que define el mapa de memoria y conviertes la salida a la forma que tu ROM/FPGA espera.
Un script de enlace sencillo puede situar .text en ROM desde 0x0000 y .data en RAM desde 0x1004 (si 0x1000–0x1003 lo ocupan registros GPIO). La rutina de inicio puede ser «naked» y colocar el stack pointer en el final de RAM (p. ej., 0x1FFC) antes de llamar a main.
/* Mapa simple
* ROM: 0x00000000 - 0x00000FFF
* GPIO: 0x00001000 - 0x00001003
* RAM: 0x00001004 - 0x00001FFF
*/
SECTIONS {
. = 0x00000000;
.text : { *(.startup) *(.text) *(.text.*) *(.rodata*) }
. = 0x00001004;
.data : { *(.data) *(.data.*) }
}
Con riscv32-none-elf-objdump puedes desensamblar el ELF y comprobar direcciones; por ejemplo, verás el arranque en 0x00000000 con instrucciones como lui/addi/jal y la transición a tu main. Para simulación VHDL, GHDL genera trazas que puedes abrir con GtkWave.
Después de verificar en simulación, lleva el diseño a una FPGA (Quartus u otra toolchain). Si la RAM se infiere como bloques internos y el código es RTL claro, deberías sintetizar sin sorpresas, incluso en dispositivos veteranos.
Recordatorios prácticos y errores típicos al empezar
No olvides que x0 siempre es cero; escribir ahí no tiene efecto y leerlo devuelve 0. Úsalo a tu favor en adiciones, comparaciones y limpiezas de registro.
Cuando implementes funciones, salva ra y los registros sN que modifiques, y gestiona el stack con sumas/restas a sp alineadas a palabra. Al volver, restaura en el orden inverso y salta con jr ra.
En simuladores como Jupiter, comprueba que __start es global y que terminas con ecall correcto (a0=10 para salir). Si algo no arranca, revisa la etiqueta, la globalidad y recompila (F3).
En ejercicios con IO, respeta el protocolo del entorno: qué registros llevan parámetros, el número de servicio y si se espera dirección o valor inmediato. Usa la documentación del simulador o del sistema operativo.
Con una base clara de ISA (RV32I, registros y ABI), un simulador cómodo como Jupiter, y ejemplos crecientes (negativo, factores, mayúsculas, bucles, factorial, fibonacci y Hanoi), el ensamblador RISC-V deja de ser un muro y se convierte en un terreno jugoso para entender cómo piensa una CPU. Y si te animas a bajar a VHDL, verás cómo encajan ALU, memoria y control: desde el fetch de instrucciones y la carga diferida hasta la interfaz de memoria y un mapa con ROM, RAM y GPIO que te deja hacer parpadear un LED con tu propio procesador.
Redactor apasionado del mundo de los bytes y la tecnología en general. Me encanta compartir mis conocimientos a través de la escritura, y eso es lo que haré en este blog, mostrarte todo lo más interesante sobre gadgets, software, hardware, tendencias tecnológicas, y más. Mi objetivo es ayudarte a navegar por el mundo digital de forma sencilla y entretenida.