Saturday, January 10, 2026

Building a virtio-serial FPGA device (Part 6): Writing the RISC-V firmware

This is the final post in a series about building a virtio-serial device in Verilog for an FPGA development board. This time we'll look at the firmware running on the PicoRV32 RISC-V soft-core in the FPGA.

Series table of contents

  1. Part 1: Overview
  2. Part 2 - MMIO registers, DMA, and interrupts
  3. Part 3 - virtio-serial device design
  4. Part 4 - Virtqueue processing
  5. Part 5 - UART receiver and transmitter
  6. Part 6 - Writing the RISC-V firmware (you are here)

The code is available at https://gitlab.com/stefanha/virtio-serial-fpga.

The PicoRV32 RISC-V soft-core boots up executing code from flash memory at 0x10000000. Since RISC-V is supported by LLVM and gcc, it is possible to write the firmware in several languages. For this project I wanted to use Rust and was aware of several existing crates that already provide APIs for things that would be needed.

I used a Rust no_std environment, which means that the standard library (std) is not available and only the core library (core) is available. Crates written for embedded systems and low-level programming often support no_std, but most other crates rely on the standard library and an operating system. no_std is a niche in the Rust ecosystem but it works pretty well.

The following crates came in handy:

  • riscv-rt provides the basic startup code for bare metal on RISC-V. It has the linker script, assembly pre-Rust startup code, and provides things that Rust's runtime needs.
  • safe-mmio is an API for MMIO device register access. This was helpful for low-level testing of device registers during the early phases of the project.
  • virtio-drivers has a virtio-serial driver! I didn't need to implement virtqueues, the VIRTIO MMIO Transport, or the virtio-serial driver software myself.

Initially I thought I could get away without a memory allocator since no_std does not have one by default and it would be extra work to set one up. However, virtio-drivers needed one for the virtio-serial device (I don't think it is really necessary, but the code is written that way). Luckily the embedded-alloc has memory allocators that are easy to set up and just need a piece of memory to operate in.

Aside from the setup code, the firmware is trivial. The CPU just sends a hello world message and then echoes back bytes received from the virtio-serial device.

#[riscv_rt::entry]
fn main() -> ! {
    unsafe {
        extern "C" {
            static _heap_size: u8;
        }
        let heap_bottom = riscv_rt::heap_start() as usize;
        let heap_size = &_heap_size as *const u8 as usize;
        HEAP.init(heap_bottom, heap_size);
    }

    // Point virtio-drivers at the MMIO device registers
    let header = NonNull::new(0x04000000u32 as *mut VirtIOHeader).unwrap();
    let transport = unsafe { MmioTransport::new(header, 0x1000) }.unwrap();

    // Put the string on the stack so the device can DMA (it cannot DMA flash memory)
    let mut buf: [u8; 13] = *b"Hello world\r\n";

    if transport.device_type() == DeviceType::Console {
        let mut console = VirtIOConsole::::new(transport).unwrap();
        console.send_bytes(&buf).unwrap();
        loop {
            if let Ok(Some(ch)) = console.recv(true) {
                buf[0] = ch;
                console.send_bytes(&buf[0..1]).unwrap();
            }
        }
    }
    loop {}
}

In the early phases I ran tests on the iCESugar board that lit up an LED to indicate the test result. As things became more complex I switched over to Verilog simulation. I wrote testbenches that exercise the Verilog modules I had written. This is similar to unit testing software.

In the later stages of the project, I changed the approach once more in order to do integration testing and debugging. To get more visibility into what was happening in the full design with a CPU and virtio-serial device, I used GTKWave to view the VCD files that Icarus Verilog can write during simulation. You can see every cycle and every value in each register or wire in the entire design, including the PicoRV32 RISC-V CPU, virtio-serial device, etc.

This allowed very powerful debugging since the CPU activity is visible (see the program counter in the reg_pc register in the screenshot) alongside the virtio-serial device's internal state. It is possible to look up the program counter in the firmware disassembly to follow the program flow and see where things went wrong.

Conclusion

The firmware is a small Rust codebase that uses existing crates, including riscv-rt and virtio-drivers. Throughout the project I used several debugging and simulation approaches, depending on the level of complexity. Thanks to the open source code and tools available, it was possible to complete this project using fairly convenient and powerful tools and without spending a lot of time reinventing the wheel. Or at least without reinventing the wheels I didn't want to reinvent :).

Let me know if you enjoy FPGAs and projects you've done!