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

  1. 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.
  2. 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.
  3. 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.
  4. 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

ToolNotes
Zig 0.15.2Required compiler for native tests and cross builds.
Renode ≥ 1.16Provides the simulation harness; CLI tools must be on PATH.
mise (optional)Convenience task runner used by the project and CI.
mdBookGenerates 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 vfox dotnet 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.403 or the MSI) manually and run mise with MISE_DISABLE_TOOLS=dotnet until 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.zig exposes 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_options module injects the board name so that src/board/console.zig can select the appropriate backend at comptime, 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.zig defines 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.zig supplies a recording test double used by unit tests to assert exact byte sequences without hardware.
  • src/board/pin.zig models digital output pins (set/clear/toggle) with a companion test double.
  • src/drivers/st7789.zig builds 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:

  1. Cross-builds the firmware ELF with zig build -Dboard=....
  2. Writes a temporary .resc script that loads our vendored .repl descriptions.
  3. Starts Renode in console mode and captures UART output to build/renode/<board>-uart.log.

Vendored Platform Files

  • renode/platforms/cpus/*.repl describe 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/*.repl assemble 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=1 before mise run sim-bluepill-display; Renode will open the framebuffer analyzer window.
  • You can still override RENODE_OPTS to tweak Renode flags (e.g. "" to allow windows while keeping the analyzer).
  • Need more time to poke around? Set RENODE_NO_QUIT=1 so the .resc script skips issuing quit and 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:

  1. Vendor (or author) the CPU and board .repl files under renode/platforms/.
  2. Update .mise.toml with build-<board> and sim-<board> tasks.
  3. Extend build.zig with the new linker script, CPU model, and board banner.
  4. 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:

  1. Compiles the C# plugin (build-st7789-visual).
  2. Cross-builds the Blue Pill firmware (build-bluepill).
  3. Generates a .resc that loads the DLL, scans it via Renode’s TypeManager, instantiates the SPI peripheral, and launches the analyser window if requested.
  4. Pipes the entire console/monitor log to build/renode/st7789.log.
  5. Runs parse_spi_log.py to 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.zig so both host tests and firmware share the same API (@import("board_spi"), etc.).
  • Update src/firmware/main.zig to gate the display demo on console.boardName()—other boards remain unaffected.

2. Keep host unit tests first-class

  • All inline tests live under tests/; zig build test exercises the SPI contract, pin double, and driver semantics.
  • Additional parser/visualisation utilities live in renode/scripts/parse_spi_log.py and st7789_visualize.py.

3. Teach Renode about the peripheral

  • Implement the ST7789 as a plugin in renode/plugins/ST7789Visual.cs. The class derives from AutoRepaintingVideo (for framebuffers) and ISPIPeripheral (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 .resc launcher (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.

4. Automate with mise tasks

Key tasks in .mise.toml:

TaskPurpose
build-st7789-visualdotnet build the plugin using the pinned SDK (tools.dotnet).
sim-bluepill-displayCompile firmware, generate .resc, honour RENODE_OPTS, RENODE_VISUAL, RENODE_NO_QUIT, run Renode, tee output.
test-display-renodeDepends 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 — inject showAnalyzer sysbus.spi1.display and keep the analyser window alive.
  • RENODE_NO_QUIT=1 — comment out quit in the .resc so Renode keeps running for manual inspection.

5. Inspecting and capturing output

  • build/renode/st7789.log contains 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-display followed by RENODE_VISUAL=1 surfaces 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, …):

  1. Model the firmware contract in src/bsp/<board>/… and expose it through src/root.zig.
  2. Keep Zig tests adjacent to the implementation; zig build test remains the first line of defence.
  3. Emit simulator artefacts in build/renode/ and parse them with a small helper script.
  4. 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.
  5. 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

OperationExpected Range (cycles)
Fixed multiply2–6
Sin lookup4–10
Vec3 normalize50–100
Mat4 multiply200–350
Mat4 transform30–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
  • .mise tasks 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 gpioPortA writes 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.zig and 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

  1. Create a linker script under src/bsp/<board>/memory.ld.
  2. Implement a console backend that exposes banner, init, writeByte.
  3. Extend build.zig with the new board metadata (exe_name, CPU model, linker path).
  4. Vendor appropriate Renode .repl files and add build/sim tasks to .mise.toml.
  5. Update the GitHub Actions workflow matrix so CI exercises the new target.
  6. Document the board here (flash/RAM, console details, special considerations).
  7. 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

  1. Branch from master — keep your changes focused and rebase before submitting.
  2. Format Zig sourcesmise run fmt (mirrors CI).
  3. Run host testszig build test -Doptimize=Debug and -Doptimize=ReleaseFast (or simply mise run test-tiger). Add new tests under tests/ and import them through tests/all.zig so the src/ tree stays free of inline tests.
  4. Cross build affected boards — e.g. zig build -Dboard=stm32f103-bluepill ....
  5. Simulate each boardmise run sim-<board> and inspect build/renode/<board>-uart.log. No warnings is the standard.
  6. Update docs — extend the mdBook when you touch behaviour or add hardware. “Done” includes documentation.
  7. Leave nothing half-finished — remove TODOs, resolve edge cases, and ensure defaults are explicit.
  8. 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:

  1. Estimate inputs. e.g., 1 000 RPS, 1 KB per log entry.
  2. Scale in time. 1 000 × 86 400 = ~86 GB per day.
  3. 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.

If you notice gaps, open an issue or PR—documentation is part of the definition of done.