Introduction
zenbedded is a statement that firmware can be engineered with the same discipline we expect from critical backend systems: the code is safe by construction, its performance characteristics are intentional, and the developer experience invites collaboration rather than fear. Zig gives us the tools—compile-time composition, explicit control flow, and zero-cost abstractions—while Renode keeps us honest with deterministic simulation before hardware enters the loop. This book is the canonical reference for maintainers, contributors, and curious engineers who want to internalize the mindset and mechanics behind the project.
Goals
- Safety — Control flow stays explicit, memory is bounded, assertions defend invariants, and errors are never ignored. Firmware must behave the same way on every machine.
- Performance — We plan with napkin math, batch expensive operations, and design for predictability so that hot paths stay cache-friendly and branch-predictable without relying on compiler luck.
- Developer experience — Names model the domain, documentation records intent, and tooling is consistent across platforms so that the codebase is a place you want to work in.
- Zero debt — Every change is expected to be permanent. We would rather slow down now than apologize later.
Audience
This guide assumes familiarity with Zig or similar systems languages and a desire to apply rigorous software practices to embedded projects. It also acts as the source of truth for our CI workflows, conventions, and roadmap so that intent remains obvious long after the original author moves on.
Getting Started
This chapter walks you from a clean workstation to a fully simulated firmware build while reinforcing the habits we expect from every contributor: explicit setups, predictable builds, and clean logs.
1. Install Dependencies
| Tool | Notes |
|---|---|
| Zig 0.15.2 | Required compiler for native tests and cross builds. |
| Renode ≥ 1.16 | Provides the simulation harness; CLI tools must be on PATH. |
| mise (optional) | Convenience task runner used by the project and CI. |
| mdBook | Generates the documentation portal under book/. |
On macOS using Homebrew:
brew install zig mdbook mise
brew install --cask renode
On Linux, download the official Zig tarball, install Renode via the package or tar release, and place mdbook + mise binaries somewhere on your PATH.
Windows note: the upstream
vfoxdotnet plugin bundled with mise currently fails when a specific SDK build is requested (jdx/mise#4738). Our CI disables the dotnet tool on Windows so that the pre-installed SDK from the GitHub runner is used instead. If you need to build the Renode C# plugin on a Windows workstation, install the .NET 8 SDK (dotnet-install.ps1 --version 8.0.403or the MSI) manually and runmisewithMISE_DISABLE_TOOLS=dotnetuntil the upstream bug is fixed.
2. Clone the repository
git clone https://github.com/mattneel/zenbedded.git
cd zenbedded
3. Validate the workspace
# Run all host-side unit tests
zig build test
# Build each shipping board artefact (ReleaseSafe by default, but try Debug + ReleaseFast locally)
zig build -Dboard=stm32f4-discovery -Dtarget=thumb-freestanding-none -Dcpu=cortex_m4 -Doptimize=ReleaseSafe
zig build -Dboard=stm32f103-bluepill -Dtarget=thumb-freestanding-none -Dcpu=cortex_m3 -Doptimize=ReleaseSafe
zig build -Dboard=nrf52840-dk -Dtarget=thumb-freestanding-none -Dcpu=cortex_m33 -Doptimize=ReleaseSafe
# Boot the firmware inside Renode
mise run sim-stm32f4
cat build/renode/stm32f4_discovery-uart.log
If you prefer not to use mise, inspect .mise.toml for the underlying commands. Whatever path you choose, keep formatting, linting, and simulations exactly aligned with CI.
4. Explore the code
src/hal/cortex_m/startup.zig— vector table and reset handler shared by all Cortex-M boards, with assertions and explicit memory stitching.src/board/console/*.zig— per-board console drivers that write banner text to UART using deterministic register programming.renode/platforms/**/*— vendored CPU + board descriptions that Renode loads during simulation; warnings should be zero.book/— mdBook sources you are currently reading, kept in sync with the implementation.
With the environment ready, the next chapter dives into architecture-specific details.
Architecture
Zenbedded is intentionally small, but the pieces are layered to make the firmware portable across boards while honouring our core promises: control flow is explicit, performance decisions are justified, and the developer experience stays welcoming.
Compile-Time Assembly
src/firmware/main.zig
│
├── imports console backend based on build_options.board
├── pulls in the Cortex-M startup module for reset handling
└── emits a freestanding binary tailored to the selected board
build.zigexposes a-Dboard=<name>switch. During compilation we resolve the target triple, CPU model, and linker script for that board and wire in build options explicitly.- The
build_optionsmodule injects the board name so thatsrc/board/console.zigcan select the appropriate backend atcomptime, keeping branching centralized.
Startup & Linker Scripts
Every board reuses the same resetHandler defined in src/hal/cortex_m/startup.zig. Per-board customization lives in the linker script under src/bsp/<board>/memory.ld. Those files:
- place the vector table at the correct flash offset,
- define
.data,.bss, and stack segments with explicit bounds, - expose
_stack_top,_data_start, etc., which the startup routine references.
Because the handler is written in Zig, we can reason about it with the same tools we use elsewhere, add assertions to guard invariants, and avoid inline assembly unless absolutely necessary.
Board Console Abstraction
The console interface is a thin layer that exposes init, write, and idle. Each backend configures clocks, GPIO, and UART registers using volatile pointers but hides those differences behind a uniform API. The parent module orchestrates control flow; the leaf modules remain pure computations or side-effect blocks. This structure is the seed for broader driver categories (timers, GPIO, storage) that will follow the same pattern.
Peripherals & Drivers
src/board/spi.zigdefines an explicit SPI contract: configuration validation, bounded transfers (MAX_TRANSFER_SIZE), and a vtable-based implementation so boards can provide their own transport.src/board/spi/test_double.zigsupplies a recording test double used by unit tests to assert exact byte sequences without hardware.src/board/pin.zigmodels digital output pins (set/clear/toggle) with a companion test double.src/drivers/st7789.zigbuilds on those contracts to implement a display driver. Command tables are compile-time constants, the state machine is explicit, and drawing routines honour the SPI transfer bounds.
Renode Integration
Instead of depending on the global Renode installation for platform descriptions, the repository vendors minimal .repl files under renode/platforms/. This gives us complete control over available peripherals and allows CI to run Renode with deterministic behaviour. The .mise scripts load these local descriptions, apply any stubs needed to silence warnings, and write UART output to build/renode/*.log, which our GitHub Actions workflow archives for inspection.
Documentation & Tooling
The mdBook you are reading mirrors the source tree and refers back to the specification in SPEC.md. It is part of the definition of done: update it whenever behaviour changes. We ship the Ayu theme by default to match the Zig aesthetic and keep the documentation pleasant to browse in both light and dark modes.
Simulation Workflow
Renode is a core part of zenbedded's developer loop. Every Pull Request must pass our Renode smoke tests before it is merged, and the logs must be silent—warnings are bugs.
Task Wrappers
.mise.toml defines tasks for each board:
mise run sim-stm32f4
mise run sim-bluepill
mise run sim-nrf52840
mise run sim-bluepill-display # ST7789 SPI analyzer harness
All tasks default to renode --disable-xwt --console …. Set RENODE_OPTS if you need different flags (e.g. RENODE_OPTS="" to pop analyzer windows) or RENODE to point at an alternate binary.
Each task performs the following steps:
- Cross-builds the firmware ELF with
zig build -Dboard=.... - Writes a temporary
.rescscript that loads our vendored.repldescriptions. - Starts Renode in console mode and captures UART output to
build/renode/<board>-uart.log.
Vendored Platform Files
renode/platforms/cpus/*.repldescribe CPU cores, interrupt controllers, and memory maps. We include lightweight stubs (such as a dummy RCC region for STM32F1) to silence Renode warnings.renode/platforms/boards/*.replassemble the CPU description with board-level peripherals (LEDs, buttons) for visibility when debugging.
By committing these files, we guarantee that CI and local developers exercise the same virtual hardware.
Clean logs
A key quality metric is “no warnings in Renode logs.” When warnings do appear, we either stub the missing peripheral or adjust our register writes until Renode can acknowledge them. This makes genuine regressions easy to spot in CI artifacts and keeps the simulation environment predictable.
Display bring-up (Blue Pill + ST7789)
mise run test-display-renode builds the Blue Pill firmware, launches Renode with a logging SPI slave, and verifies the exact initialization sequence emitted by the ST7789 driver. The analyzer writes a detailed trace to build/renode/st7789.log; the Python parser inspects the MOSI stream and fails if any required command is missing or reordered.
The display harness stores the UART and SPI logs as GitHub Actions artifacts so reviewers can audit the byte stream when regressions surface. For visual debugging we ship a tiny C# plugin (renode/plugins/ST7789Visual.cs) that exposes the framebuffer via Renode’s VideoDevice. The helper task mise run build-st7789-visual compiles the plugin with the pinned .NET SDK before each simulation.
- The default console run verifies the SPI byte-stream deterministically.
- Opt-in visualization by exporting
RENODE_VISUAL=1beforemise run sim-bluepill-display; Renode will open the framebuffer analyzer window. - You can still override
RENODE_OPTSto tweak Renode flags (e.g.""to allow windows while keeping the analyzer). - Need more time to poke around? Set
RENODE_NO_QUIT=1so the.rescscript skips issuingquitand leaves Renode running until you close it manually.
Note: Renode's STM32F1 GPIO/SPI models still warn when we tweak CNF bits or enable the SPI master (the emulator doesn't implement those fields yet). We track these warnings and treat them as expected noise until upstream support lands; all other warnings remain blockers.
Extending Simulations
To add a new board:
- Vendor (or author) the CPU and board
.replfiles underrenode/platforms/. - Update
.mise.tomlwithbuild-<board>andsim-<board>tasks. - Extend
build.zigwith the new linker script, CPU model, and board banner. - Add the board to the GitHub Actions matrix so cross builds run on every commit.
Display Stack Validation Pipeline
The ST7789 display stack is more than a firmware driver—it is a renode-first validation workflow that proves the entire graphics path before any bytes touch flash. This document codifies the process so the next time we add a peripheral (display or otherwise) we can repeat the pattern with confidence.
Why simulate first?
- Zero hardware dependency: iterate when the target board is on the other side of the planet (or occupied by CI).
- Deterministic repros: simulator logs capture every byte; bugs are no longer “flaky hardware”.
- Tight driver feedback loop: firmware changes and peripheral emulation evolve together.
- Screenshot/video capture: documentation and demos without a camera pointed at a dev board.
Architecture overview
┌────────────────────────────────────────────────────────────┐
│ Zig firmware (src/firmware, src/drivers/st7789.zig) │
│ └─ Board-native SPI/pin contract (src/bsp/stm32f103_…) │
│ └─ Driver exercises display API │
│ │
│ Renode environment │
│ ├─ Platform (.repl) instantiates ST7789Visual │
│ ├─ C# plugin (renode/plugins/ST7789Visual.cs) │
│ ├─ Validation script (renode/scripts/test_st7789.resc) │
│ └─ mise tasks build/load everything │
│ │
│ Tooling │
│ ├─ parse_spi_log.py → verifies byte stream │
│ └─ st7789_visualize.py → renders captured framebuffers │
└────────────────────────────────────────────────────────────┘
When mise run test-display-renode executes, the pipeline:
- Compiles the C# plugin (
build-st7789-visual). - Cross-builds the Blue Pill firmware (
build-bluepill). - Generates a
.rescthat loads the DLL, scans it via Renode’sTypeManager, instantiates the SPI peripheral, and launches the analyser window if requested. - Pipes the entire console/monitor log to
build/renode/st7789.log. - Runs
parse_spi_log.pyto assert the init sequence matches the datasheet.
The net effect is a fully automated loop capable of catching regressions long before they hit hardware.
Step-by-step workflow
1. Expose firmware primitives
- Add board-specific SPI and pin backends (
src/bsp/stm32f103_bluepill/ spi1.zig,gpio.zig,display_bus.zig). - Re-export the abstractions from
src/root.zigso both host tests and firmware share the same API (@import("board_spi"), etc.). - Update
src/firmware/main.zigto gate the display demo onconsole.boardName()—other boards remain unaffected.
2. Keep host unit tests first-class
- All inline tests live under
tests/;zig build testexercises the SPI contract, pin double, and driver semantics. - Additional parser/visualisation utilities live in
renode/scripts/parse_spi_log.pyandst7789_visualize.py.
3. Teach Renode about the peripheral
- Implement the ST7789 as a plugin in
renode/plugins/ST7789Visual.cs. The class derives fromAutoRepaintingVideo(for framebuffers) andISPIPeripheral(for SPI traffic). It decodes CASET/RASET/RAMWR commands, tracks a window, converts RGB565 → RGB888, and writes into the simulator’s video buffer. - Describe the board in
renode/platforms/boards/bluepill-st7789.repl: instantiate the plugin, wire GPIO inputs for DC/CS/RESET, and leave the LED/button wiring intact for visibility. - Extend the
.resclauncher (renode/scripts/test_st7789.resc) to:- load the plugin (
clr.AddReferenceToFileAndPath), - register it with
TypeManager.Instance.ScanFile, - optionally open the framebuffer (
@@SHOW_ANALYZER@@placeholder), - quit or remain running based on
RENODE_NO_QUIT.
- load the plugin (
4. Automate with mise tasks
Key tasks in .mise.toml:
| Task | Purpose |
|---|---|
build-st7789-visual | dotnet build the plugin using the pinned SDK (tools.dotnet). |
sim-bluepill-display | Compile firmware, generate .resc, honour RENODE_OPTS, RENODE_VISUAL, RENODE_NO_QUIT, run Renode, tee output. |
test-display-renode | Depends on simulation, then runs parse_spi_log.py to assert the init sequence. |
Environment toggles:
RENODE_OPTS— override Renode flags. Unset →--disable-xwt --console; set to empty string → GUI mode; custom flags respected verbatim.RENODE_VISUAL=1— injectshowAnalyzer sysbus.spi1.displayand keep the analyser window alive.RENODE_NO_QUIT=1— comment outquitin the.rescso Renode keeps running for manual inspection.
5. Inspecting and capturing output
build/renode/st7789.logcontains the full console log. The parser looks for known command/data sequences and fails the task when anything is missing or reordered.mise run sim-bluepill-displayfollowed byRENODE_VISUAL=1surfaces the framebuffer window.- From the Renode monitor:
(bluepill) sysbus.spi1.display SaveScreenshot @output.png - For post-hoc rendering without a GUI, run
python3 renode/scripts/st7789_visualize.py --log build/renode/st7789.log --output frame.png.
6. Moving to hardware
Once the simulation is green we only need to flash the firmware:
zig build -Dboard=stm32f103-bluepill -Dtarget=thumb-freestanding-none -Dcpu=cortex_m3 -Doptimize=ReleaseSafe
Because the firmware/pin/SPI contracts are shared between host tests, firmware build, and the Renode platform, the behaviour observed in simulation is exactly what hits the MCU.
Reapplying the pattern
When adding a new peripheral (display, sensor, codec, …):
- Model the firmware contract in
src/bsp/<board>/…and expose it throughsrc/root.zig. - Keep Zig tests adjacent to the implementation;
zig build testremains the first line of defence. - Emit simulator artefacts in
build/renode/and parse them with a small helper script. - Write a Renode peripheral (C# or Python for prototyping) and load
it via
.resc. If the existing namespaces are insufficient, verify the type is auto-loaded and available to the REPL. - Document the knobs: how to enable the visualiser, how to keep the simulator alive, how to take screenshots.
Following these steps keeps the validation pipeline reproducible for the next maintainer—and guarantees we ship firmware that has already proven itself under simulation before the first flash attempt.
Performance Benchmarks
The Blue Pill build now emits cycle-accurate benchmark data before the LCD demo
starts. The numbers below are recorded from the Renode model of STM32F103 while
running a ReleaseFast build (OPTIMIZE=ReleaseFast). They provide a baseline for
future optimisations and a regression guard for the fixed-point toolkit.
Cycle Counts
| Operation | Expected Range (cycles) |
|---|---|
| Fixed multiply | 2–6 |
| Sin lookup | 4–10 |
| Vec3 normalize | 50–100 |
| Mat4 multiply | 200–350 |
| Mat4 transform | 30–60 |
Use the validation task to confirm the latest firmware stays within these thresholds:
OPTIMIZE=ReleaseFast RENODE_NO_QUIT=1 mise run validate-bench
The command will boot Renode, capture the UART log, and assert each benchmark falls inside the expected range.
Binary Size Snapshot
$ arm-none-eabi-size zig-out/firmware/stm32f103_bluepill.elf
text data bss dec hex filename
Run the above after a ReleaseFast build to keep flash/RAM budgets under watch.
Notes
- Benchmarks rely on the Cortex-M3 DWT cycle counter. They automatically fall back to a host-friendly message when executed on non-Thumb architectures.
- The validation script accepts any UART log and can be used in CI to ensure optimisations do not regress.
Board Support
Zenbedded currently ships with three Tier-1 targets. Each board provides:
- a linker script in
src/bsp/<board>/memory.ld - a console backend under
src/board/console/<board>.zig .misetasks for cross builds and Renode simulations
STM32F4 Discovery (stm32f4-discovery)
- MCU: STM32F407VG (Cortex-M4F @ 168 MHz)
- Flash: 1 MB, RAM: 192 KB
- Console: USART1 routed to the ST-Link VCOM bridge
- Notes: Linker script places the vector table at
0x08000000; Renode description lives upstream so we reuse the bundled board file.
STM32F103 “Blue Pill” (stm32f103-bluepill)
- MCU: STM32F103C8T6 (Cortex-M3 @ 72 MHz)
- Flash: 64 KB, RAM: 20 KB
- Console: USART1 via GPIO A9/A10; requires an external USB-TTL or ST-Link
- Notes: Our
gpioPortAwrites only touch MODE bits to keep Renode logs clean.
nRF52840 DK (nrf52840-dk)
- MCU: nRF52840 (Cortex-M4F @ 64 MHz)
- Flash: 1 MB, RAM: 256 KB
- Console: UART0 mapped to P0.06 (USB CDC on the DK)
- Notes: EasyDMA is disabled in the Renode CPU description to avoid unimplemented register warnings.
ST7789 Display Peripheral
- Driver lives in
src/drivers/st7789.zigand uses the common SPI/pin contracts. - All SPI transfers are bounded (
MAX_TRANSFER_SIZE) and command sequences are declared at compile time. - Tests rely on the SPI/pin test doubles to verify exact byte streams without hardware.
Adding New Boards
- Create a linker script under
src/bsp/<board>/memory.ld. - Implement a console backend that exposes
banner,init,writeByte. - Extend
build.zigwith the new board metadata (exe_name, CPU model, linker path). - Vendor appropriate Renode
.replfiles and add build/sim tasks to.mise.toml. - Update the GitHub Actions workflow matrix so CI exercises the new target.
- Document the board here (flash/RAM, console details, special considerations).
- Keep Renode logs warning-free; if the simulator surfaces noise, stub or adjust until silence is restored.
Contributing
We welcome PRs that improve tooling, documentation, or hardware support—but every change must respect our pillars: safety, performance, developer experience, and zero debt. Follow this checklist to keep the review loop quick.
Development Workflow
- Branch from
master— keep your changes focused and rebase before submitting. - Format Zig sources —
mise run fmt(mirrors CI). - Run host tests —
zig build test -Doptimize=Debugand-Doptimize=ReleaseFast(or simplymise run test-tiger). Add new tests undertests/and import them throughtests/all.zigso thesrc/tree stays free of inline tests. - Cross build affected boards — e.g.
zig build -Dboard=stm32f103-bluepill .... - Simulate each board —
mise run sim-<board>and inspectbuild/renode/<board>-uart.log. No warnings is the standard. - Update docs — extend the mdBook when you touch behaviour or add hardware. “Done” includes documentation.
- Leave nothing half-finished — remove TODOs, resolve edge cases, and ensure defaults are explicit.
- Follow commit guidelines — use
type(scope): description(e.g.,feat(drivers): add spi backend).
Pull Request Expectations
- Include a summary of changes and the commands you executed.
- Mention any gaps (e.g., missing Renode support for a new peripheral) so reviewers can help plan follow-up work.
- Keep the mdBook and README in sync with new features.
Reporting Issues
When filing a bug, include:
- Zig version (
zig version) - Renode version (
renode --version) - Board(s) affected
- Reproduction steps (commands + logs)
Screenshots of UART output or Renode monitors are encouraged when the behaviour is visual.
Roadmap
The high-level roadmap lives in SPEC.md. Feel free to propose additions via issues or discussions—especially for new board support or driver categories.
Addendum
Zero technical debt
The codebase operates under a zero-debt policy. Every change is expected to stick. That means:
- Do it right the first time. Take the time to design and implement cleanly. Rushed features create future refactors that slow everyone down.
- Be proactive. Anticipate failure modes and assert invariants early. If you see an issue forming, fix it before it grows roots.
- Build momentum. Shipping reliable code compounds confidence and allows us to move faster without cutting corners.
When planning work, ask: “Will this still look good in a year?” If the answer is “no,” keep iterating.
Performance estimation
Design begins with rough math. Use napkin estimates to reason about throughput, memory pressure, and budget:
- Estimate inputs. e.g., 1 000 RPS, 1 KB per log entry.
- Scale in time. 1 000 × 86 400 = ~86 GB per day.
- Translate to cost/resources. ~2.6 TB/month → ~$50 @ $0.02/GB.
Staying within an order of magnitude is enough to catch design mistakes early. Revisit after you have real measurements.
Colophon
This handbook is a remix of proven engineering idioms combined with the lessons we learn while building zenbedded. It is maintained in-tree so the documentation evolves alongside the code and remains trustworthy.
- Maintainers: Zenbedded team
- Version: 0.1-dev
- Last updated: January 2025
- License: CC BY 4.0
- Source: https://github.com/mattneel/zenbedded
If you notice gaps, open an issue or PR—documentation is part of the definition of done.