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:
- The peripheral itself, in plain SystemVerilog - register-mapped, in the same style as
timer.sv/gpio.svfrom Project 2 (asel/addr/we/wdata/rdatastyle port list is not required here - a Wishbone slave has a standardized port set instead, see below). - 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:
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:
Then register it on the SoC's bus, at a free address region:
(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:
(%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.svwritten 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.