A Verilator harness for the bio_bdma block. The host CPU is not modelled;
everything it would do (load code, poke control registers, start cores) is
reduced to APB transactions on the block's slave ports, driven from C++. The
original ASIC RTL is used unmodified and built with +define+SIM.
The basic development loop is:
- Write BIO program in sw/<my-prog>/main.c
- Build code with
python3 -m ziglang build "-Dmodule=<my-prog>"insidesw/. See Building for more. - Configure simulation environment with jsonc file in configs/<my-prog>.jsonc See Config syntax for more.
- Simulate with
./container-run configs/<my-prog>.jsonc(see containers) or./simulate configs/<my-prog>.jsonc(see locally built verilator) - (Optional) View waveforms with
python3 ./biowave.py <my-prog>: requires a custom-builtgtkwave - Repeat steps 1, 2 & 4, and reload waveform in 5.
- Load your code onto actual hardware. For
Dabao, see bio-loader; requires the latestdabao-consoleversion.
The instructions here default to using podman but they should work identically with
docker. podman is preferred here as nothing in this repo requires root privileges.
Build the container locally: podman build -t bio-sim .
or
Install the container:
- from GHCR:
podman pull ghcr.io/baochip/bio-sim:latest - from Baochip self-hosted:
curl -fsSL https://baochip.com/cdn/bio-sim-latest-x86_64.tar.gz | podman load
Run the container: ./container-run configs/smoke.jsonc
--rebuildwill rebuild the container--portdefines the port for connecting to clients
To build a container for the baochip CDN, run:
podman save bio-sim:latest | gzip > bio-sim-latest.tar.gz
make build # fetches json.hpp, runs verilator, compiles
make run CONFIG=configs/smoke.jsonc
# or:
./simulate configs/smoke.jsoncmake build depends on the single-header nlohmann/json into sim/json.hpp
(one-time, needs network). If the build host is offline, fetch it manually:
curl -fsSL https://raw.githubusercontent.com/nlohmann/json/v3.11.3/single_include/nlohmann/json.hpp -o sim/json.hppbio-sim/
├── rtl/ # bio-bdma RTL tree, taken from baochip-1x repo
│ ├── bio_bdma.sv
│ ├── bio_bdma_wrapper.sv # <-- DUT (flattened Verilog ports)
│ ├── picorv32.v ...
│ └── lib/
│ ├── template.sv # Various libs and configs
│ └── ...
├── sim/
│ └── sim_main.cpp # the harness (clocks, APB transactor, loader, trace)
├── configs/
│ ├── smoke.jsonc # no firmware; just the cfginfo self-test
│ └── hello-world.jsonc # example of toggle-a-pin
├── rtl.f # Verilator file list
├── Makefile
├── Dockerfile
├── simulate # Local build & run (not containerized)
├── container-run # Runs the simulation from a container
├── clients/ # Simulation clients (real-time interaction with simulator)
├── gtkw/ # GTKW "views"
│ └── bio.gtkw # A starter view
├── waveform/ # Simulation output directory for `.fst` files
├── sw/
│ ├── build.zig # Script for building BIO binaries from C code
│ ├── clang2rustasm.py # Script that creates Rust assembly and patches BIO erratum
│ ├── include/ # Header files with starter libraries
│ │ ├── bio.h # This is the only .h file you must include in every program
│ │ └── ...
│ ├── blink/
│ │ ├── blink.bin # Generated artifact, only available after running the build command
│ │ ├── blink.rs # A Rust macro suitable for including in Xous builds
│ │ └── main.c # The C source for the `blink` demo
│ └── ...
├── docs/ # Documentation-related files
└── README.md
The containerized version contains a pre-build of the BIO model as an executable.
configs/smoke.jsonc runs no firmware. After reset the harness reads
sfr_cfginfo at offset 0x04 over the main APB port and checks it equals
0x10000408 (the register hardwires {16'd4096, 8'd4, 8'd8}). A PASS
confirms that the build resolved, the clocking/reset are sane, and the APB transactor
completes a real transaction.
Example of successful smoke test run:
./container-run configs/smoke.jsonc
>> reusing image 'bio-sim' (use --rebuild after editing rtl/ or sim/)
[trace] writing waveform/trace.fst
[cfg] fclk=700.000 MHz pclk=fclk/16 (43.750 MHz)
Addressing configuration for axil_crossbar_addr instance TOP.bio_bdma_wrapper.bio_bdma.axil_demux.axil_crossbar_wr_inst.s_ifaces[0].addr_inst
0 ( 0): 40000000 / 29 -- 40000000-5fffffff
1 ( 0): 60000000 / 29 -- 60000000-7fffffff
Addressing configuration for axil_crossbar_addr instance TOP.bio_bdma_wrapper.bio_bdma.axil_demux.axil_crossbar_rd_inst.s_ifaces[0].addr_inst
0 ( 0): 40000000 / 29 -- 40000000-5fffffff
1 ( 0): 60000000 / 29 -- 60000000-7fffffff
[selftest] sfr_cfginfo @0x04 = 0x10000408 (expect 0x10000408) -> PASS
[run] up to 0 cycles
[done] sim_time = 902496 ps (0 cycles)
The blink demo is a simple program that toggles a GPIO on and off. This section walks through all the stages of building, simulating, and viewing waveforms.
The C build system relies on Zig. You can get this via Python's pip, requiring Python >= 3.9:
python3 -m pip install ziglang
Change to the sw directory, and run the build script:
cd sw
python3 -m ziglang build "-Dmodule=blink"
Example output:
Label mapping:
_start -> 20: (line 0)
main -> 21: (line 6)
.LBB1_1 -> 22: (line 20)
.LBB1_3 -> 23: (line 29)
Wrote blink\blink.rs
input: zig-out\blink.s
fn: blink_bio_code()
labels: BM_BLINK_BIO_START / BM_BLINK_BIO_END
instructions: 21
functions found: 2 (_start, main)
binary: blink\blink.bin (44 bytes / 11 words)
listing: blink\blink.dis (via riscv-none-elf-objdump)
Change back to the root directory, and run the simulation command:
./container-run configs/blink.jsonc
Example output:
>> reusing image 'bio-sim' (use --rebuild after editing rtl/ or sim/)
>> reusing image 'bio-sim' (use --rebuild after editing rtl/ or sim/)
[run] [##############################] 100% 100000/100000 cyc 139256 cyc/s ETA 0s
[trace] writing waveform/blink.fst
[cfg] fclk=350.000 MHz pclk=fclk/8 (43.750 MHz)
Addressing configuration for axil_crossbar_addr instance TOP.bio_bdma_wrapper.bio_bdma.axil_demux.axil_crossbar_wr_inst.s_ifaces[0].addr_inst
0 ( 0): 40000000 / 29 -- 40000000-5fffffff
1 ( 0): 60000000 / 29 -- 60000000-7fffffff
Addressing configuration for axil_crossbar_addr instance TOP.bio_bdma_wrapper.bio_bdma.axil_demux.axil_crossbar_rd_inst.s_ifaces[0].addr_inst
0 ( 0): 40000000 / 29 -- 40000000-5fffffff
1 ( 0): 60000000 / 29 -- 60000000-7fffffff
[selftest] sfr_cfginfo @0x04 = 0x10000408 (expect 0x10000408) -> PASS
[mon] watching gpio_out bit 21
[mon] watching gpio_dir bit 21
[mon] watching irq
[load] sw/blink/blink.bin -> core 0 (11 words)
[poke] sfr @0x008 <= 0x00000000
[poke] sfr @0x050 <= 0x00010000
[poke] sfr @0x06c <= 0x00000000
[start] sfr_ctrl @0x00 <= 0x111 (en=0x1 restart=0x1 clkdiv=0x1)
[run] up to 100000 cycles (stop on trap)
[mon] cyc=47 (2317838 ps) gpio_dir[21]: 0 -> 1
[mon] cyc=59 (2352134 ps) gpio_out[21]: 0 -> 1
[mon] cyc=33551 (99535566 ps) gpio_out[21]: 1 -> 0
[mon] cyc=67004 (196698992 ps) gpio_out[21]: 0 -> 1
[done] sim_time = 292739224 ps (100000 cycles)
This example causes GPIO 21 to wiggle up and down, which you can see with the 0->1, 1->0 transition in the monitor output.
Adding the "-Demit-listing=true" argument will cause the build script to search for a viable copy of objdump for the riscv32 architecture on your local machine so that it can create a disassembly listing suitable for use with codezoom. You can use codezoom in two ways:
- Web-based surfer interface
gtkwavedesktop interface
The web-based surfer interface is convenient and low-friction, but it has some limitations, such as an inability to expand busses into individual signals.
You can view the blink.fst waveform using the web-based surfer viewer. The version on baochip.com supports code zooming. The downside of the surfer viewer is that it does not have support for some features such as expanding busses into bit-vector waveforms.
A custom-built gtkwave enables you to hover-zoom over the dbg_pc trace and correlate waveform position to assembly code in real time. Build our fork from source, or you can try one of our releases if you're on Linux or Windows.
Start the viewer with python3 ./biowave.py blink; if you need to specify a path to the stand-alone binaries in the releases, use python3 ./biowave.py blink --gtkwave-bin /path/to/gtkwave-x86_64.AppImage (or /path/to/gtkwave/bin/gtkwave.exe for Windows).
This will cause the terminal to run the codezoom.py script, and pop open the gtkwave viewer, like this:
Left-clicking on the dbg_pc trace will cause the terminal to highlight the line of assembly code that corresponds to the current cursor position. Right-click drag will allow you to zoom in. Right click on a signal name and select "Open Scope" to find the location in the RTL hierarchy that corresponds to that signal.
From there, you can search for more signals to view, and drag them into the waveform viewer if you need additional visibility into the machine state.
This demo works identically to blink, just replace blink with test-ws2812. This is a slightly more complicated program that simulates driving a WS2812 LED chain.
Build the invert program:
cd sw
python3 -m ziglang build "-Dmodule=invert"
Start the server demo:
./container-run configs/server-demo.jsonc
This will result in an output that looks like this:
>> reusing image 'bio-sim' (use --rebuild after editing rtl/ or sim/)
>> auto-publishing serve port 5555 (from configs/server-demo.jsonc; override with --port)
[trace] writing waveform/serve.fst
[cfg] fclk=700.000 MHz pclk=fclk/16 (43.750 MHz)
Addressing configuration for axil_crossbar_addr instance TOP.bio_bdma_wrapper.bio_bdma.axil_demux.axil_crossbar_wr_inst.s_ifaces[0].addr_inst
0 ( 0): 40000000 / 29 -- 40000000-5fffffff
1 ( 0): 60000000 / 29 -- 60000000-7fffffff
Addressing configuration for axil_crossbar_addr instance TOP.bio_bdma_wrapper.bio_bdma.axil_demux.axil_crossbar_rd_inst.s_ifaces[0].addr_inst
0 ( 0): 40000000 / 29 -- 40000000-5fffffff
1 ( 0): 60000000 / 29 -- 60000000-7fffffff
[selftest] sfr_cfginfo @0x04 = 0x10000408 (expect 0x10000408) -> PASS
[load] sw/invert/invert.bin -> core 0 (9 words)
[clock] core 0 <= 10000000 Hz (frac) div_int=70 div_frac=0 (qdiv=0x00460000) actual=10000000 Hz (+0 ppm)
[mon] watching gpio_out bit 1
[mon] watching gpio_out bit 2
[start] sfr_ctrl @0x00 <= 0x111 (en=0x1 restart=0x1 clkdiv=0x1)
[serve] listening on 0.0.0.0:5555 (fclk=700000000 Hz)
At this point, the simulation is paused, waiting for a client to join.
In another terminal, run the client program:
python3 clients/interactive.py
This will start an interaction that looks like the below. Use the space bar to toggle the pin, and control-C to exit the interaction:
connected to 127.0.0.1:5555, driving gpio_in[0]
SPACE = toggle pin, Ctrl-C = exit
<- # bio-sim ready
<- output: evt 87 gpio_out 1 1
-> set gpio_in[0] = 1
<- output: evt 216486 gpio_out 1 0
-> set gpio_in[0] = 0
<- output: evt 414219 gpio_out 1 1
-> set gpio_in[0] = 1
<- output: evt 562902 gpio_out 1 0
You'll see the server responding with:
[mon] cyc=87 (2123436 ps) gpio_out[1]: 0 -> 1
[mon] cyc=216486 (311141208 ps) gpio_out[1]: 1 -> 0
[mon] cyc=414219 (593503932 ps) gpio_out[1]: 0 -> 1
What's happening here is the space bar is injection simulation events into verilator, in "wall-clock time", and responding to it at the rate that the server can simulate (in this demo, it's running at about 10kHz).
This mode is useful for stimulating truly asynchronous test cases.
Config files in configs/ are JSONC - standard JSON plus // line and /* */ block comments (parsed with ignore_comments=true). The harness (sim/sim_main.cpp) reduces every config to an ordered command list run against the DUT. After reset it always runs a sfr_cfginfo self-test before executing commands.
Number format: any numeric field accepts a JSON number or a string parsed base-0, so 41, "0x29", "0o51" are all valid.
| Key | Meaning |
|---|---|
fclk_mhz |
Fast clock in MHz. Default 700. |
trace |
{ file } - enable FST waveform output. file default "trace.fst". (A format field is accepted but ignored; output is always FST.) |
sw |
Shorthand name <n>: auto-loads sw/<n>/<n>.bin and traces to <n>.fst (unless trace is given). |
load_core |
Core for the auto-load. Default 0. |
commands |
Explicit ordered array of command objects (modern schema, below). |
If commands is absent, the legacy flat keys are desugared into commands in this order: monitor → load (from firmware/sw) → poke (from registers) → start → run. With commands present, an sw/firmware load is prepended unless the array already contains a load.
Legacy flat keys: firmware (path string → load), registers (array → poke each), start (object → start), monitor (array → monitor each), run (object → run).
Each entry in commands is an object with a cmd field. Unknown cmd values are warned and skipped.
Firmware & registers
load {core?, bin}- load a.bininto a core's IMEM.coredefault0; bytes zero-padded to a word.poke {name|offset, value, port?}- APB register write. Give registername(from the SFR map) and/or rawoffset.portdefault"sfr".peek {name|offset, port?}- APB register read, logged.
FIFOs
fifo_write {bank, data:[…]|value, via?}- push word(s) to a TX FIFObank.viais"sfr"(default, main port) or"alias"(per-bank page).fifo_read {bank, count?, via?}- pop word(s) from an RX FIFO.countdefault1.fifo_drain {bank, max?, via?}- pop all available words (or up tomax); prints each as hex and signed-16.
Cores & clocking
start {cores:[…], restart?, clkdiv_restart?}- enable the listed cores viasfr_ctrl.restartandclkdiv_restartdefaulttrue.clock {core, style?, …}- set a core's clock divider.coreis0..3.style:"frac"(default) /"int"- needsfreq_hz(target Hz;intforbids the fractional part)."fixed"- needsdiv_int, optionaldiv_frac(eighths of /256)."external"- needspin(drive the core clock from a GPIO).
IO / events / interrupts
io_config {mode?, i_inv?, o_inv?, oe_inv?, sync_bypass?, snap_inputs?, snap_outputs?}- only the fields you name are written.modeis"overwrite"(default),"set", or"clear".snap_*take a core index0..3. (mappedis accepted but ignored - it targets the external IOX mux, not this DUT.)fifo_event {fifo, slot?, level, less_than?, greater_than?, equal_to?}- FIFO level-crossing trigger.fifo0..3,slot0..1(default0); comparison flags defaultfalse.irq {which, mask, edge_triggered?}- set IRQ linewhich(0..3) to a raw 32-bitmask.edge_triggereddefaultfalse(else level).
Stimulus & run control
inject {events:[{cycle, pin, value}, …]}- schedule timestampedgpio_inedges (driven-mode style).monitor {signal, bit?}- watch a top-level signal for edges; omitbitto watch the whole bus.serve {port?, mode?, wait_for_client?, max_cycles?, min_dwell?}- open a TCP server for live socket interaction.portdefault5555;wait_for_clientdefaulttrue.modeis"realtime"(default;max_cyclesdefault0=unbounded,min_dwelldefault2000) or"driven"(lock-step, deterministic;max_cycles/min_dwellignored).run {cycles?|max_cycles?, stop_on_trap?}- advance N fclk cycles (default1000000).stop_on_trapdefaulttrue.delay {cycles?}- same asrunbut never stops on trap.
name resolves against the SFR map; common entries: sfr_ctrl 0x00, sfr_cfginfo 0x04 (RO), sfr_config 0x08, sfr_flevel 0x0C (RO), sfr_txf0..3 0x10..0x1C, sfr_rxf0..3 0x20..0x2C (RO), sfr_qdiv0..3 0x50..0x5C, sfr_extclock 0x44, sfr_irqmask_0..3 0x70..0x7C. Writing a read-only register warns and is ignored by the RTL; a name/offset mismatch is an error.
Equivalent using the modern commands array (with a clock and a live socket):
{
"fclk_mhz": 350,
"sw": "blink", // auto-loads sw/blink/blink.bin
"commands": [
{ "cmd": "clock", "core": 0, "style": "frac", "freq_hz": 1000000 },
{ "cmd": "monitor", "signal": "gpio_out", "bit": 21 },
{ "cmd": "start", "cores": [0] },
{ "cmd": "serve", "port": 5555, "mode": "driven", "wait_for_client": true }
]
}The serve command turns the running simulation into a TCP server so an
external program (in any language) can drive inputs and read results over a
socket. The sim is always the server; your script is the client that
connects. The sim only starts listening once it reaches the serve command,
i.e. after the preceding load / clock / start setup has run.
There are two modes, chosen by the "mode" field. They exist because there
are two fundamentally different things you might want, and they cannot be the
same loop.
The sim runs continuously, as fast as the host allows. Inputs you send take effect at the next cycle boundary, and monitored output transitions stream back as they happen. This is the mode behind the keyboard/toggle demos.
Because the sim advances on wall-clock time (not on your commands), simulated time and real time are not proportional and the run is not reproducible. That's fine for "does my pin react" interaction, but it is wrong for anything whose correctness depends on exact cycle timing.
{ "cmd": "serve", "port": 5555, "mode": "realtime",
"wait_for_client": true, // block until a client connects before advancing
"max_cycles": 0, // 0 = run until stop/Ctrl-C; otherwise stop after N
"min_dwell": 2000 } // min cycles an input is held before the next `set`min_dwell matters: if a client sends sets faster than the sim samples them,
they would collapse into one net change at a single simulated instant. Holding
each input at least min_dwell cycles lets the program actually observe each
one. Tune it up toward your program's input-to-reaction latency if fast inputs
get dropped.
| Client → sim | Sim → client |
|---|---|
set <pin> <val> - drive a gpio_in bit (paced) |
# bio-sim ready (banner) |
get <signal> - query a signal |
evt <cycle> <signal> <bit> <val> (per transition) |
stop - end the session |
val <signal> 0x........ (reply to get) |
# bye (on stop) |
The sim advances only when you tell it to with run. You schedule
timestamped input edges with inject, advance a controlled number of cycles,
then read results back. Because nothing happens except on your commands, the
result is identical every run, regardless of how fast or jittery the socket
is. This is the mode for protocol bring-up (I2S, SPI, UART, …), where stimulus
timing is defined in clock cycles and you need reproducibility.
{ "cmd": "serve", "port": 5555, "mode": "driven",
"wait_for_client": true }(max_cycles / min_dwell do not apply here - advancing is explicit.)
| Client → sim | Sim → client |
|---|---|
inject <relcycle> <pin> <val> - schedule a pin edge (no reply) |
# bio-sim ready (driven) (banner) |
run <n> - advance n cycles |
ran <n> (after the run completes) |
fifo_drain <bank> - read all available words |
drain <bank> <count>, then <count> × sample 0x........ |
fifo_read <bank> <count> - read exactly count |
(same shape as drain) |
set <pin> <val> - input applied on next run |
val <signal> 0x........ (reply to get) |
get <signal> - query a signal |
# bye (on stop) |
stop - end the session |
relcycle is relative to the sim's current cycle when the inject is received,
so a client typically ships all of a waveform's edges up front, then runs
through them in chunks, draining the FIFO between chunks. Unlike realtime,
driven mode does not stream evt transitions - the channel stays a clean
request/response, and the FST captures every transition for waveform viewing.
FIFO ops use the main SFR port only.
realtime- interactive demos, "watch my output react to an input I'm wiggling", when your test case is better with some non-determinism, etc. Wall-clock coupled, not reproducible.driven- automated and protocol-accurate testing where edge timing is defined in cycles and results must be deterministic. Primarily useful for language-agnostic test vector generation. Examples are in Python but the use of a socket allows any language to generate and run the simulator.
The sim listens inside the container, so the port must be published to the
host. container-run auto-detects the "port" from the config and publishes
it; pass --port N to override. Confirm the right binary is running by the
banner: realtime greets with # bio-sim ready, driven with
# bio-sim ready (driven).
This tool was developed with a lot of assistance from Claude Opus 4.8 High
{ "fclk_mhz": 350, "firmware": "sw/blink/blink.bin", "load_core": 0, "registers": [ { "name": "sfr_config", "value": "0x000" }, { "name": "sfr_qdiv0", "value": "0x00010000" }, { "offset": "0x6C", "value": "0x0", "port": "sfr" } // sfr_io_i_inv ], "start": { "cores": [0], "restart": true, "clkdiv_restart": true }, "monitor": [ { "signal": "gpio_out", "bit": 21 }, { "signal": "irq" } ], "run": { "max_cycles": 100000, "stop_on_trap": true }, "trace": { "file": "hello-world.fst" } }