fpga-implementacion-riscv-femtorv-neorv32

RISC-V en FPGA: cómo integrar un SoC básico

  • 6 min

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:

  1. CPU RISC-V: El cerebro que lee instrucciones.
  2. Memoria (BRAM): Donde guardaremos el programa (instrucciones) y las variables (datos).
  3. Decodificador de Direcciones: El “policía de tráfico” que decide si la CPU está intentando leer memoria o escribir en un LED.
  4. 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

endmodule
Copied!

Fí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;
}
Copied!

¡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.

  1. 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
  2. Extraer binario: Sacamos las instrucciones crudas. riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin
  3. 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 .bin a .hex.

Finalmente, en nuestro módulo de RAM en Verilog, cargamos este fichero:

initial begin
    $readmemh("firmware.hex", memoria_array);
end
Copied!

Al 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 make dependen 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.