esp32-baremetal-zig
esp32-baremetal-zig
A bare-metal hardware abstraction layer for the Xtensa ESP32 family, written in pure Zig — no vendor SDK, no RTOS, no libc, no C. Firmware boots directly on ESP32, ESP32-S2 and ESP32-S3 from a generated linker script, reaches the silicon through SVD-generated register definitions, and is exercised in the Espressif QEMU fork on every CI run.
Three reference applications anchor the HAL: the ESP32 verifies its hardware
SHA-1/256 and AES-128/256 engines against std.crypto live in QEMU, the ESP32-S2
runs a fixed-point FFT spectrum analyzer, and the ESP32-S3 drives its PIE/SIMD
vector unit.
Highlights
- Generated register access.
tools/svd2zig.zigcompiles vendored CMSIS-SVD into a typed@import("regs")at build time —regs.GPIO.OUT_W1TS, never a hardcoded address. - A comptime register HAL. Every peripheral driver is parameterized on its
register addresses, so each MMIO access is provably aligned and non-null and
emits no panic path on this backend. Bit fields are named via
src/reg.zig, not hand-shifted. - Fixed-point DSP. Saturating add, dot product, FIR and a radix-2 Q15 FFT, with ESP32-S3 PIE vector kernels selected at comptime and a scalar fallback.
- Freestanding-safe runtime. Watchdogs are cleared at boot; a custom panic
handler and a
std.logbackend render over UART without pulling instd.fmt(whose formatter will not link on this backend). - Consumable as a package.
zig fetchthe repository and import the modules; every example underexamples/is a standalone package that does exactly that. - Continuously tested. A Linux/macOS/Windows CI matrix builds every target and boots the QEMU-capable chips on each push.
The flash and QEMU linker scripts are generated in build.zig — there are no
.ld files to hand-edit.
Contents
- Toolchain requirement
- How to build
- Use it as a dependency (
zig fetch) - Documentation
- QEMU testing
- Flashing to hardware
- References
- License
Toolchain requirement
This project requires the Espressif LLVM fork of Zig (zig-espressif-bootstrap).
Upstream Zig does not expose esp32 / esp32s2 / esp32s3 CPU models in
std.Target.xtensa.cpu.
| Item | Value |
|---|---|
| Toolchain | zig-espressif-bootstrap prebuilt, tag 0.16.0-xtensa (reports zig version → 0.16.0) |
| Download | https://github.com/kassane/zig-espressif-bootstrap/releases |
Unpack it anywhere and put its directory on PATH:
curl -L -o zig.tar.xz \
https://github.com/kassane/zig-espressif-bootstrap/releases/download/0.16.0-xtensa/zig-relsafe-x86_64-linux-musl-baseline.tar.xz
tar -xJf zig.tar.xz
export PATH="$PWD/zig-relsafe-x86_64-linux-musl-baseline:$PATH"
Everything else is plain zig build — there is no build script and no
hand-maintained linker script (both the flash and QEMU .ld files are
generated in build.zig via b.addWriteFiles).
How to build
# Build all chips (default) → zig-out/bin/
zig build --summary all
# Build a single chip
zig build esp32
zig build esp32s2
zig build esp32s3
# Release build
zig build -Doptimize=ReleaseSmall
# Build-time config knobs (see docs/getting-started.md#build-time-configuration)
zig build esp32 -Dlog-level=debug -Dpanic-trace=false
Per chip this installs an <chip>_baremetal_zig ELF plus a raw
<chip>_baremetal_zig.bin (see the flashing note below about its size).
Every example under examples/ is also a standalone package: its
build.zig consumes the repo root (esp32_hal) as a local path dependency for
the shared modules, generated registers and linker scripts, so you can build one
example on its own:
cd examples/esp32 && zig build # → zig-out/bin/esp32_baremetal_zig(.bin)
cd examples/esp32 && zig build run # launch it in QEMU (esp32, esp32s3)
cd examples/esp32 && zig build smoke # non-interactive boot test (esp32, esp32s3)
| Source | Chip | CPU | LED | Demo |
|---|---|---|---|---|
examples/esp32/main.zig |
ESP32 | Xtensa LX6 | GPIO2 | hardware SHA-1/256 + AES-128/256 (vs std.crypto) + RNG |
examples/esp32s2/main.zig |
ESP32-S2 | Xtensa LX7 | GPIO18 | fixed-point FFT spectrum + TIMG timer |
examples/esp32s3/main.zig |
ESP32-S3 | Xtensa LX7 | GPIO48 | PIE/SIMD vector kernels |
Single-feature programs live alongside them, each its own package you build with
cd examples/<name> && zig build:
blink(GPIO + Delay),button(GPIO in→out),efuse(factory MAC) on ESP32- Run in QEMU (
zig build demo):efuse(ESP32 — factory MAC over UART),rtc_store(ESP32 — RTC scratch round-trip),rsa(ESP32 — known-answer modexp on the RSA accelerator),heap(ESP32 — typed bump-arena allocation),rtc_time(ESP32 — RTC main-timer uptime),critical(ESP32 — interrupt-masking critical section) andsystimer(ESP32-S3 — system-timer uptime over UART) - Build-only:
pwm(LEDC) on ESP32-S2;i2c(I2C master),spi(SPI master),rmt(IR remote transmit),ws2812(addressable RGB over RMT),twai(CAN 2.0 transmit),mcpwm(motor-control PWM),i2s(I2S master TX),dac(analog output),adc(analog input),iomux(pad pull/drive config),gpio_matrix(signal↔pad routing),watchdog(TIMG WDT),reset_reason(reset cause),sw_reset(software reset),gpio_edge(poll-based edge detection),clock_gate(peripheral clock gating),brownout(supply brownout detector),touch(capacitive touch sensor),deep_sleep(timer-wakeup deep sleep),pcnt(pulse counter) andflash(SPI-flash read via ROM) on ESP32;usb_serial(USB CDC-ACM console),tsens(temperature sensor),hmac(HMAC-SHA256 accelerator) andstack_monitor(ASSIST_DEBUG stack-overflow monitor) on ESP32-S3 - ULP coprocessor (RISC-V, build-only):
ulp_s2— an ESP32-S2 ULP program built forriscv32imcthat drives an RTC GPIO through the generated ULP registers (svd/esp32s2-ulp.svd) and heartbeats the main core via shared RTC memory. A separate firmware the main core loads and starts (loader out of scope).
Shared register/timing helpers live in src/mmio.zig (imported as mmio).
Use it as a dependency (zig fetch)
The repo root is itself a Zig package (esp32_hal) that publishes its
building blocks — so you can pull them into your own firmware instead of copying
files around. Add it:
zig fetch --save=esp32_hal git+https://github.com/kassane/esp32-baremetal-zig
--save=esp32_hal pins the dependency key so b.dependency("esp32_hal", .{})
below resolves regardless of the repo’s URL basename.
Then wire the modules into your build.zig:
const esp = b.dependency("esp32_hal", .{});
const fw = b.addExecutable(.{ .name = "fw", .root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = b.resolveTargetQuery(.{
.cpu_arch = .xtensa,
.os_tag = .esp32,
.abi = .none,
}),
}) });
fw.root_module.addImport("mmio", esp.module("mmio")); // MMIO + UART + memcpy
fw.root_module.addImport("hal", esp.module("hal")); // Output / Input / Delay
fw.root_module.addImport("dsp", esp.module("dsp")); // FFT / FIR / SIMD kernels
fw.root_module.addImport("heap", esp.module("heap")); // typed bump arena
fw.root_module.addImport("init", esp.module("init")); // watchdog disable
fw.root_module.addImport("panic", esp.module("panic")); // freestanding panic
fw.root_module.addImport("startup", esp.module("startup")); // shared reset vector
fw.root_module.addImport("regs", esp.module("esp32_regs")); // or esp32s2_regs / esp32s3_regs
fw.setLinkerScript(esp.namedLazyPath("esp32.ld")); // flash; or "esp32-qemu.ld"
fw.bundle_compiler_rt = false;
The packages under examples/ are this pattern in miniature — they consume the
root over a local .path dependency, so copy one as a working starting point.
Documentation
Guides and implementation notes live under docs/:
- docs/getting-started.md — from a fresh checkout to firmware running in QEMU and on hardware, plus a minimal-firmware skeleton.
- docs/hal.md — the
haldriver reference: every peripheral driver, what it does, and which run in QEMU vs. are build-only — plus the connectivity / wireless boundary. - docs/internals.md — generated register access
(
svd2zig), boot/startup, and the freestanding panic +std.logshim. - docs/dsp.md — the fixed-point DSP kernels and the ESP32-S3 PIE/SIMD vector path.
- docs/heap.md — the bare-metal typed bump arena, and why the std allocator interface doesn’t lower on this backend.
QEMU testing
ESP32 and ESP32-S3 have machine models in the Espressif QEMU fork; ESP32-S2
does not, so it is build-only. QEMU firmware places all code in IRAM so
qemu-system-xtensa -kernel <elf> runs without the ROM bootloader initialising
the flash cache.
# Build the QEMU ELFs (IRAM-only) → zig-out/bin/esp32_qemu, esp32s3_qemu
zig build qemu
# Build + launch QEMU interactively
zig build run-esp32
zig build run-esp32s3
# Non-interactive boot test: boot each QEMU-capable chip and assert no CPU
# faults (this is what CI runs).
zig build smoke
zig build smoke -Dsmoke-seconds=10
# Show the example's UART output (captured from QEMU via `-serial file:`):
zig build demo # all QEMU-capable chips
zig build demo-esp32 # just the ESP32 crypto example
The firmware writes to UART0 (regs.UART0.FIFO); demo routes that to a file
and prints it. esp32 is the crypto demo — it runs SHA-1/256 and AES-128/256-ECB
on the hardware accelerators and checks each against std.crypto’s comptime
reference (esp32s3 is the PIE/SIMD example and drives the LED rather than
printing):
ESP32 bare-metal Zig — hardware crypto demo
[info] SHA-1("abc") HW vs std.crypto: OK
[info] SHA-256("abc") HW vs std.crypto: OK
[info] AES-128-ECB HW vs std.crypto: OK
[info] AES-256-ECB HW vs std.crypto: OK
[info] rng sample 3160650498, GPIO0 low
build.zig finds qemu-system-xtensa on PATH; override it with
-Dqemu=/path/to/qemu-system-xtensa. The smoke test boots each chip for
-Dsmoke-seconds (default 5) and fails if the CPU raises a fault or QEMU exits
before the timeout — the blink loop never returns, so “still running at the
timeout” is the pass condition.
Install the emulator from the Espressif QEMU releases
(https://github.com/espressif/qemu/releases); on Linux it also needs
libsdl2 and libslirp at runtime.
Memory layout (QEMU, IRAM-only)
| Chip | IRAM origin | DRAM origin |
|---|---|---|
| ESP32 | 0x40080000, 1 MB |
0x3FFB0000, 176 KB |
| ESP32-S3 | 0x40370000, 1 MB |
0x3FC88000, 300 KB |
IRAM is extended to 1 MB (real hw: 128 KB / 400 KB) to accommodate Debug builds.
Stack addresses used in startup prologue
| Chip | DRAM top | Computation |
|---|---|---|
| ESP32 | 0x3FFDC200 |
0x40000000 − 0x23E00 (0x23E « 8) |
| ESP32-S2 | 0x3FFDE000 |
0x40000000 − 0x22000 (0x220 « 8) |
| ESP32-S3 | 0x3FCD3000 |
0x40000000 − 0x32D000 (0x32D « 12) |
Each value is within the valid DRAM range on real hardware, so the same source file works for both hardware and QEMU builds without conditional compilation.
Flashing to hardware
Note: The flat
.binproduced byzig buildviaobjcopyis large (tens of MB) because objcopy zero-fills the gap between the DROM and IROM segments. Use one of the methods below instead.
Hardware flashing requires the standard second-stage bootloader and
partition table to already be present on flash (they initialise the
flash-cache MMU so the app’s irom_seg becomes accessible). Take them from any
build of the vendor SDK:
bootloader.bin → flash offset 0x0
partition-table.bin → flash offset 0x8000
espflash (alternative 1)
espflash is a Rust CLI that works directly with ELF files and avoids the large-binary problem.
cargo install espflash
# Flash application only (bootloader + partition-table already on device):
espflash flash --chip esp32s3 --baud 460800 zig-out/bin/esp32s3_baremetal_zig
# Serial monitor:
espflash monitor --chip esp32s3
esptool.py (alternative 2)
# Convert ELF → correct-sized image (reads load segments, no zero-fill):
esptool.py --chip esp32s3 elf2image \
--flash_mode dio --flash_size 8MB \
--output firmware.bin zig-out/bin/esp32s3_baremetal_zig
# Flash (bootloader + partition-table must already be on device):
esptool.py --chip esp32s3 write_flash 0x10000 firmware.bin
# ESP32 / ESP32-S2 (same flow, different chip flag):
esptool.py --chip esp32 elf2image --output firmware.bin zig-out/bin/esp32_baremetal_zig
esptool.py --chip esp32s2 elf2image --output firmware.bin zig-out/bin/esp32s2_baremetal_zig
References
- zig-espressif-bootstrap — the Zig toolchain (Espressif LLVM fork) this project builds with
- esp-rs/esp-pacs — upstream of the vendored
svd/*.svd; register access is generated from these bytools/svd2zig.zig - espressif/esp-dsp — reference for the fixed-point FFT/DSP algorithms ported into
src/dsp.zig - esp-rs/espflash — ELF-aware flashing tool used in the hardware-flashing instructions above
- esp-rs/esp-hal — the Rust HAL whose register sequences (touch, RTC sleep/timer, ULP, bootloader app-descriptor) this project’s drivers are cross-checked against
License
Licensed under the Apache License, Version 2.0.