We’ve reached the end of the road. We started by turning on an LED with logic gates and ended up designing video controllers and memories.
Today we take the definitive step. We go from “designing circuits” to “designing systems”.
We’ll grab an Open Source RISC-V core, place it in our FPGA, connect it to memory and peripherals, and write a C program to control it. We are creating an SoC (System on Chip).
For this tutorial, we will base ourselves on the philosophy of lightweight cores like FemtoRV or PicoRV32, ideal for small FPGAs like the iCE40.
SoC Diagram
Before pasting code, we need to understand what we are building. The CPU alone is not enough; we need an ecosystem.
Our minimal system will consist of:
- RISC-V CPU: The brain that reads instructions.
- Memory (BRAM): Where we store the program (instructions) and variables (data).
- Address Decoder: The “traffic cop” that decides whether the CPU is trying to read memory or write to an LED.
- Peripheral (LEDs): The hardware we want to control.
Hardware in Verilog
Let’s imagine a simplified implementation of a RISC-V core. Typically, these cores have a very simple memory interface:
mem_addr: Which address we want to access.mem_rdata: Data we read.mem_wdata: Data we want to write.mem_wen: Do we want to write? (Write Enable).
SoC Top-level
This snippet shows the conceptual architecture that turns an FPGA into a small microcontroller:
riscv_core and ram_memory are illustrative names, not modules ready to compile. Each core defines different ports, wait signals, and memory latencies; adapt the wrapper to its actual interface.
module mi_soc_riscv (
input wire clk,
input wire rst,
output reg [3:0] leds // Physical peripheral
);
// --- INTERCONNECTION WIRES ---
wire [31:0] cpu_addr;
wire [31:0] cpu_wdata;
wire [31:0] cpu_rdata;
wire cpu_wen;
wire [31:0] ram_rdata;
// --- 1. INSTANTIATE RISC-V CORE ---
// Using a generic core (e.g., FemtoRV or PicoRV32)
riscv_core cpu (
.clk(clk),
.rst(rst),
.mem_addr(cpu_addr),
.mem_wdata(cpu_wdata),
.mem_wen(cpu_wen),
.mem_rdata(cpu_rdata) // Data entering the CPU
);
// --- 2. INSTANTIATE MEMORY (RAM/ROM) ---
// Store 1024 instructions (4KB)
ram_memory #( .SIZE(1024) ) ram (
.clk(clk),
.addr(cpu_addr[11:2]), // 32-bit alignment (Word addressing)
.wdata(cpu_wdata),
.wen(cpu_wen && (cpu_addr < 32'h00001000)), // 4 KiB of RAM
.rdata(ram_rdata)
);
// --- 3. MEMORY MAP (Address Decoder) ---
// This is memory-mapped I/O.
// We decide what data to send to the CPU based on the address it requests.
assign cpu_rdata = ram_rdata; // Default: read from RAM
// (In a complex system, this would be a giant Mux:
// if addr == X -> read RAM, if addr == Y -> read UART...)
// --- 4. LED PERIPHERAL (Write Only) ---
// If the CPU writes to address 0x8000, we update the LEDs.
always @(posedge clk) begin
if (rst) begin
leds <= 0;
end else begin
// If writing AND the address is the LEDs' address
if (cpu_wen && (cpu_addr == 32'h00008000)) begin
leds <= cpu_wdata[3:0]; // Write the lower 4 bits
end
end
end
endmoduleNotice the simplicity: Writing to an LED is simply detecting a write to a specific address (0x8000). There are no special CPU instructions to turn on lights. Everything is memory.
Software in C
Now we take off our electronic engineer hat and put on our programmer hat. How do we tell this processor to turn on the LEDs?
We need to know the memory address we invented in the Verilog (0x00008000).
// main.c
#include <stdint.h>
// volatile prevents the compiler from eliminating or grouping peripheral accesses
#define LEDS_REG (*(volatile uint32_t *)0x00008000u)
void delay(int cycles) {
for (int i = 0; i < cycles; i++) {
__asm__ volatile ("nop"); // Educational delay, not precise timing
}
}
int main() {
int counter = 0;
while (1) {
// Writing to this variable...
// ...is physically writing to the 'leds' register in the FPGA!
LEDS_REG = counter;
counter++;
delay(100000);
}
return 0;
}It’s amazing! A simple C variable assignment (LEDS_REG = ...) travels across the system bus, reaches the Address Decoder in the Verilog, activates the write signal, and updates the Flip-Flops of the output pins.
Compilation and Loading
For this to work, we need to translate that C into zeros and ones that the FPGA’s BRAM can understand.
- Compile and Link: We use the RISC-V compiler with the core’s ISA and ABI, a startup code, and a linker script that describes the memory map.
riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -nostdlib -T linker.ld startup.S main.c -o firmware.elf - Extract Binary: Get the raw instructions.
riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin - Convert to Hex: We need a format readable by Verilog (
$readmemh). We use a small utility (usually provided with the RISC-V core) to convert from.binto.hex.
Finally, in our Verilog RAM module, we load this file:
initial begin
$readmemh("firmware.hex", memoria_array);
endWhen synthesizing and loading the bitstream, the BRAM will contain the firmware. Upon releasing the reset, the CPU jumps to its reset vector; the startup.S code prepares the stack and the minimal environment before calling main().
FemtoRV and NEORV32 as Existing Solutions
What we saw above is conceptual. In real life, we would use already packaged cores that make our lives easier.
FemtoRV
Ideal for getting started with Lattice iCE40.
- Includes examples, scripts, and toolchains for various boards. The specific
maketargets depend on the directory and chosen board. - Occupies very little space.
- Has ready-to-use drivers for OLED screens, HDMI, UART, etc.
NEORV32
Ideal for professional projects or larger FPGAs (Xilinx Artix, Intel Cyclone).
- Written in very clean and readable VHDL.
- It is a complete SoC: already includes UART, SPI, I2C, Timers, Watchdog, and integrated Bootloader.
- Includes a configurable SoC with UART, SPI, I2C interfaces, timers, watchdog, GPIO, PWM, bootloader, and optional debugging.