Llegamos al final del camino. Hemos empezado encendiendo un LED con puertas lógicas y hemos acabado diseñando controladores de video y memorias.
Hoy vamos a dar el paso definitivo. Vamos a dejar de “diseñar circuitos” para pasar a “diseñar sistemas”.
Vamos a coger un núcleo RISC-V Open Source, lo meteremos en nuestra FPGA, le conectaremos memoria y periféricos, y escribiremos un programa en C para controlarlo. Vamos a crear un SoC (System on Chip).
Para este tutorial, nos basaremos en la filosofía de núcleos ligeros como FemtoRV o PicoRV32, ideales para FPGAs pequeñas como la iCE40.
Esquema del SoC
Antes de pegar código, necesitamos entender qué vamos a construir. No basta con la CPU; necesitamos un ecosistema.
Nuestro sistema mínimo constará de:
- CPU RISC-V: El cerebro que lee instrucciones.
- Memoria (BRAM): Donde guardaremos el programa (instrucciones) y las variables (datos).
- Decodificador de Direcciones: El “policía de tráfico” que decide si la CPU está intentando leer memoria o escribir en un LED.
- Periférico (LEDs): El hardware que queremos controlar.
Hardware en Verilog
Vamos a imaginar una implementación simplificada de un núcleo RISC-V. Normalmente, estos núcleos tienen una interfaz de memoria muy sencilla:
mem_addr: Qué dirección queremos tocar.mem_rdata: Dato que leemos.mem_wdata: Dato que queremos escribir.mem_wen: ¿Queremos escribir? (Write Enable).
Top-level del SoC
Este fragmento muestra la arquitectura conceptual que convierte una FPGA en un pequeño microcontrolador:
riscv_core y ram_memory son nombres ilustrativos, no módulos listos para compilar. Cada núcleo define puertos, señales de espera y latencias de memoria diferentes; adapta el wrapper a su interfaz real.
module mi_soc_riscv (
input wire clk,
input wire rst,
output reg [3:0] leds // Periférico físico
);
// --- CABLES DE INTERCONEXIÓN ---
wire [31:0] cpu_addr;
wire [31:0] cpu_wdata;
wire [31:0] cpu_rdata;
wire cpu_wen;
wire [31:0] ram_rdata;
// --- 1. INSTANCIA DEL NÚCLEO RISC-V ---
// Usamos un core genérico (ej: FemtoRV o PicoRV32)
riscv_core cpu (
.clk(clk),
.rst(rst),
.mem_addr(cpu_addr),
.mem_wdata(cpu_wdata),
.mem_wen(cpu_wen),
.mem_rdata(cpu_rdata) // El dato que entra a la CPU
);
// --- 2. INSTANCIA DE LA MEMORIA (RAM/ROM) ---
// Guardamos 1024 instrucciones (4KB)
ram_memory #( .SIZE(1024) ) ram (
.clk(clk),
.addr(cpu_addr[11:2]), // Alineación a 32 bits (Word addressing)
.wdata(cpu_wdata),
.wen(cpu_wen && (cpu_addr < 32'h00001000)), // 4 KiB de RAM
.rdata(ram_rdata)
);
// --- 3. MAPA DE MEMORIA (Address Decoder) ---
// Esto es memoria mapeada a periféricos (Mapped I/O).
// Decidimos qué dato enviamos a la CPU según la dirección que pida.
assign cpu_rdata = ram_rdata; // Por defecto leemos RAM
// (En un sistema complejo, aquí habría un Mux gigante:
// if addr == X -> leer RAM, if addr == Y -> leer UART...)
// --- 4. PERIFÉRICO LED (Write Only) ---
// Si la CPU escribe en la dirección 0x8000, actualizamos los LEDs.
always @(posedge clk) begin
if (rst) begin
leds <= 0;
end else begin
// Si hay escritura Y la dirección es la de los LEDs
if (cpu_wen && (cpu_addr == 32'h00008000)) begin
leds <= cpu_wdata[3:0]; // Escribimos los 4 bits bajos
end
end
end
endmoduleFíjate en la simplicidad: Escribir en un LED es simplemente detectar una escritura en una dirección específica (0x8000). No hay instrucciones especiales en la CPU para encender luces. Todo es memoria.
Software en C
Ahora nos quitamos el sombrero de ingeniero electrónico y nos ponemos el de programador. ¿Cómo le decimos a este procesador que encienda los LEDs?
Necesitamos saber la dirección de memoria que hemos inventado en el Verilog (0x00008000).
// main.c
#include <stdint.h>
// volatile evita que el compilador elimine o agrupe accesos al periférico
#define LEDS_REG (*(volatile uint32_t *)0x00008000u)
void delay(int ciclos) {
for (int i = 0; i < ciclos; i++) {
__asm__ volatile ("nop"); // Retardo didáctico, no temporización precisa
}
}
int main() {
int contador = 0;
while (1) {
// Escribir en esta variable...
// ...¡es físicamente escribir en el registro 'leds' de la FPGA!
LEDS_REG = contador;
contador++;
delay(100000);
}
return 0;
}¡Es alucinante! Una simple asignación de variable en C (LEDS_REG = ...) viaja por el bus del sistema, llega al Address Decoder del Verilog, activa la señal de escritura y actualiza los Flip-Flops de los pines de salida.
Compilación y carga
Para que esto funcione, necesitamos traducir ese C a ceros y unos que la BRAM de la FPGA entienda.
- Compilar y enlazar: Usamos el compilador de RISC-V con la ISA y el ABI del núcleo, un código de arranque y un linker script que describa el mapa de memoria.
riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -nostdlib -T linker.ld startup.S main.c -o firmware.elf - Extraer binario: Sacamos las instrucciones crudas.
riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin - Convertir a Hex: Necesitamos un formato legible por Verilog (
$readmemh). Usamos una pequeña utilidad (suele venir con el core RISC-V) para pasar de.bina.hex.
Finalmente, en nuestro módulo de RAM en Verilog, cargamos este fichero:
initial begin
$readmemh("firmware.hex", memoria_array);
endAl sintetizar y cargar el bitstream, la BRAM contendrá el firmware. Al soltar el reset, la CPU salta a su vector de arranque; el código de startup.S prepara el stack y el entorno mínimo antes de llamar a main().
FemtoRV y NEORV32 como soluciones existentes
Lo que hemos visto arriba es conceptual. En la vida real, usaríamos núcleos ya empaquetados que nos facilitan la vida.
FemtoRV
Ideal para empezar con Lattice iCE40.
- Incluye ejemplos, scripts y toolchain para varias placas. Los objetivos concretos de
makedependen del directorio y de la placa elegida. - Ocupa muy poco espacio.
- Tiene drivers listos para pantallas OLED, HDMI, UART, etc.
NEORV32
Ideal para proyectos profesionales o FPGAs más grandes (Xilinx Artix, Intel Cyclone).
- Escrito en VHDL muy limpio y legible.
- Es un SoC completo: ya trae UART, SPI, I2C, Timers, Watchdog y Bootloader integrados.
- Incluye un SoC configurable con UART, SPI, interfaces I2C, temporizadores, watchdog, GPIO, PWM, bootloader y depuración opcional.