Skip to content

Tutorial 3 - A Custom Peripheral on the Wishbone Bus

Goal

By the end of this tutorial, you should be able to: write a small peripheral in SystemVerilog, wire it into a LiteX SoC as a Wishbone-mapped device, write a C driver for it, and confirm it works both in simulation and on your Tang Nano 9K.

This is the closest LiteX gets to what Project 2 asks you to do by hand: attach a memory-mapped peripheral to a bus and drive it from software. The difference is the bus and the CPU already exist - you are extending a working system, not building the whole interface from scratch.

Background: how LiteX takes external SystemVerilog

LiteX's SoCs are described in Migen (Python), but Migen can instantiate raw Verilog/SystemVerilog modules as black boxes and wire their ports to signals Migen controls - this is exactly how you attach hand-written RTL to a LiteX bus. The pattern has two parts:

  1. The peripheral itself, in plain SystemVerilog - register-mapped, in the same style as timer.sv/gpio.sv from Project 2 (a sel/addr/we/wdata/rdata style port list is not required here - a Wishbone slave has a standardized port set instead, see below).
  2. A Migen wrapper class that instantiates the SystemVerilog module and exposes it as a Wishbone slave, so LiteX's bus can route CPU accesses to it - via self.bus.add_slave(...).

1. Design the smallest useful peripheral

Keep it as simple as possible - the goal is to see the whole interface working end to end, not to build something sophisticated. A reasonable minimal choice, mirroring Project 2's timer: a single 32-bit read/write register that increments by 1 every time it is read (a trivial, observable side effect - see M03A03's "registers with side effects on read" if this sounds familiar).

my_periph.sv:

module my_periph (
    input  logic        clk,
    input  logic        rst,
    input  logic [31:0] wb_adr,
    input  logic [31:0] wb_dat_w,
    output logic [31:0] wb_dat_r,
    input  logic         wb_we,
    input  logic         wb_cyc,
    input  logic         wb_stb,
    output logic         wb_ack
);
    logic [31:0] counter;

    always_ff @(posedge clk) begin
        if (rst) begin
            counter  <= 32'd0;
            wb_dat_r <= 32'd0;
            wb_ack   <= 1'b0;
        end else begin
            wb_ack <= 1'b0;
            if (wb_cyc && wb_stb && !wb_ack) begin
                if (wb_we) begin
                    counter <= wb_dat_w;          // write: load a new value
                end else begin
                    wb_dat_r <= counter;           // read: return the current value...
                    counter  <= counter + 32'd1;   // ...then increment (side effect on read)
                end
                wb_ack <= 1'b1;
            end
        end
    end
endmodule

This is a classic Wishbone (B4-style) slave port: cyc/stb indicate a valid access, we selects read vs. write, ack is the slave's one-cycle "done" pulse. Compare this port list against Project 2's own sel/re/we convention - same idea, standardized naming.

Why one always_ff block, not two. An earlier version of this module split the write path and the read-and-increment path into two separate always_ff blocks, both driving counter. Verilator only warns about this (MULTIDRIVEN) - its simulation model tolerates it because the two blocks' trigger conditions never overlap at runtime. Yosys's Gowin backend rejects it outright as a hard synthesis error (Net 'my_periph.counter[31]' is multiply driven), regardless of runtime mutual exclusivity - so code that simulates perfectly can still fail to reach real hardware at all. This is exactly the "passes verification, fails synthesis" pitfall Project 1's own methodology already warns about, showing up here in code this tutorial hands you directly. If you see a multiply driven error from Yosys anywhere in your own designs, a stray second always_ff writing the same signal is the first thing to check.

2. Wire it in with a Migen wrapper

In your board target's Python file (extending Tutorial 2's SoC), add a wrapper module:

from migen import *
from litex.soc.interconnect import wishbone

class MyPeriph(Module):
    def __init__(self, platform):
        self.bus = wishbone.Interface()

        self.specials += Instance("my_periph",
            i_clk      = ClockSignal(),
            i_rst      = ResetSignal(),
            i_wb_adr   = self.bus.adr,
            i_wb_dat_w = self.bus.dat_w,
            o_wb_dat_r = self.bus.dat_r,
            i_wb_we    = self.bus.we,
            i_wb_cyc   = self.bus.cyc,
            i_wb_stb   = self.bus.stb,
            o_wb_ack   = self.bus.ack,
        )
        platform.add_source("my_periph.sv")

Then register it on the SoC's bus, at a free address region:

1
2
3
self.submodules.my_periph = MyPeriph(platform)
self.bus.add_slave("my_periph", self.my_periph.bus,
                    SoCRegion(origin=0x30000000, size=0x1000))

(Confirm 0x30000000 doesn't collide with an existing region in the generated memory map before picking it - LiteX prints the full map at build time.)

3. Verify in simulation first

Rebuild the simulation target from Tutorial 1 with this peripheral included. Confirm Verilator picks up my_periph.sv (check the build log for it being compiled alongside the generated LiteX Verilog) and that the SoC still boots to the BIOS prompt without errors.

4. Write a C driver and demo program

LiteX's build regenerates csr.h/the memory map header to include your new region. Write a small driver, same style as Project 2's volatile-pointer drivers:

#define MY_PERIPH_BASE 0x30000000
#define MY_PERIPH_REG  (*(volatile uint32_t *)(MY_PERIPH_BASE))

int main(void) {
    for (int i = 0; i < 5; i++) {
        uint32_t v = MY_PERIPH_REG;   // each read increments the register
        printf("my_periph read #%d = %lu\n", i, v);
    }
    while (1) {}
    return 0;
}

(%lu, not %u: uint32_t is long unsigned int on this RV32 toolchain/ABI - using %u compiles but triggers a real, reproducible -Wformat warning.)

Expected output: 0, 1, 2, 3, 4 - confirming each read is reaching your SystemVerilog module through the Wishbone bus, not being served by a cache or a stale value.

5. Confirm on real hardware

Repeat Tutorial 2's build-and-load flow with this peripheral included, and confirm the same read sequence over the real serial connection.

Checklist

  • my_periph.sv written as a Wishbone slave (or an equivalent minimal register-mapped peripheral).
  • Migen wrapper instantiates it and registers it on the SoC's bus at a free address.
  • SoC (with the new peripheral) boots correctly in litex_sim.
  • C driver correctly reads back the expected incrementing sequence, in simulation.
  • Same behavior confirmed on the Tang Nano 9K over the real serial connection.

Next (optional)

Tutorial 4 is a further-optional add-on: a second way to add a custom instruction (like Project 2's CMAC), this time via VexRiscv's built-in CFU port. Skip it freely if you're short on time.