Zig Embedded Bare-Metal: 5 Core Patterns for Building Safe Firmware Without an OS
Zig Embedded: The C Replacement and New Choice for Bare-Metal Development
Embedded development has long relied on C, but frequent memory safety vulnerabilities, cumbersome build systems, and complex cross-compilation configurations persist. Zig, as a systems programming language, is becoming a new choice for embedded bare-metal development with three key features: comptime metaprogramming, no implicit behavior, and built-in cross-compilation. In 2026, Zig embedded has demonstrated strong productivity on ARM Cortex-M, RISC-V, and other platforms.
This article covers 5 core patterns, guiding you through the full-chain implementation of cross-compilation → bare-metal startup → peripheral drivers → interrupt handling → firmware release.
Core Concepts
| Concept | Description |
|---|---|
| Zig | Systems programming language, C replacement |
| Bare-metal | Development mode running directly on hardware without an OS |
| comptime | Zig compile-time computation, core of zero-cost abstraction |
| Cross-compilation | Compiling code for one platform on another |
| no-std | Development mode without standard library dependencies |
| Peripheral driver | Driver code that directly operates hardware registers |
| Linker script | LD file controlling memory layout |
| Interrupt vector | Entry table for processor exceptions and interrupts |
Problem Analysis: 5 Major Zig Embedded Challenges
- Immature Zig ecosystem: Far fewer embedded HAL libraries and drivers than C/Rust
- Difficult bare-metal debugging: Lacking OS-supported debugging infrastructure
- comptime learning curve: Compile-time metaprogramming patterns require adaptation
- Missing hardware abstraction layer: Manual register mapping and driver implementation required
- Toolchain stability: Zig is still rapidly iterating, APIs may change
Step-by-Step: 5 Zig Embedded Patterns
Pattern 1: Zig Cross-Compilation and Project Structure
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.resolveTargetQuery(.{
.cpu_arch = .thumb,
.os_tag = .freestanding,
.abi = .eabi,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m4 },
});
const optimize = b.standardOptimizeOption(.{});
const firmware = b.addExecutable(.{
.name = "firmware.elf",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
firmware.setLinkerScript(b.path("linker.ld"));
firmware.addObjectFile(b.path("src/startup.o"));
const install = b.addInstallArtifact(firmware, .{});
b.getInstallStep().dependOn(&install.step);
const objcopy = b.addSystemCommand(&.{
"arm-none-eabi-objcopy",
"-O", "binary",
"zig-out/bin/firmware.elf",
"zig-out/bin/firmware.bin",
});
objcopy.step.dependOn(&install.step);
b.getInstallStep().dependOn(&objcopy.step);
}
Pattern 2: Bare-Metal Startup and Linker Script
// src/startup.zig
const std = @import("std");
export fn _start() callconv(.Naked) noreturn {
@as(*volatile u32, @ptrFromInt(0x2000_0000)).* = 0x2000_4000; // SP
asm volatile ("bl reset_handler");
while (true) {}
}
export fn reset_handler() void {
init_bss();
init_data();
main();
while (true) {}
}
fn init_bss() void {
const bss_start: [*]u8 = @ptrFromInt(@intFromPtr(&bss_start_addr));
const bss_end: [*]u8 = @ptrFromInt(@intFromPtr(&bss_end_addr));
@memset(bss_start[0 .. @intFromPtr(bss_end) - @intFromPtr(bss_start)], 0);
}
extern var bss_start_addr: u8;
extern var bss_end_addr: u8;
extern var data_start_addr: u8;
extern var data_end_addr: u8;
extern var data_load_addr: u8;
fn init_data() void {
const src: [*]const u8 = @ptrFromInt(@intFromPtr(&data_load_addr));
const dst: [*]u8 = @ptrFromInt(@intFromPtr(&data_start_addr));
const len = @intFromPtr(&data_end_addr) - @intFromPtr(&data_start_addr);
@memcpy(dst[0..len], src[0..len]);
}
Pattern 3: Register Mapping and Peripheral Drivers
// src/regs.zig
pub const GPIOA = struct {
pub const base: u32 = 0x4002_0000;
pub const MODER = @as(*volatile u32, @ptrFromInt(base + 0x00));
pub const ODR = @as(*volatile u32, @ptrFromInt(base + 0x14));
pub const BSRR = @as(*volatile u32, @ptrFromInt(base + 0x18));
pub fn setPin(pin: u4, mode: u2) void {
const shift: u5 = @intCast(@as(u5, pin) * 2);
const mask: u32 = ~(@as(u32, 0b11) << shift);
MODER.* = (MODER.* & mask) | (@as(u32, mode) << shift);
}
pub fn writePin(pin: u4, val: bool) void {
if (val) {
BSRR.* = @as(u32, 1) << pin;
} else {
BSRR.* = @as(u32, 1) << (pin + 16);
}
}
};
Pattern 4: Interrupt Handling and Vector Table
// src/interrupt.zig
const regs = @import("regs.zig");
var tick_count: u32 = 0;
export fn SysTick_Handler() void {
tick_count += 1;
if (tick_count % 500 == 0) {
regs.GPIOA.writePin(5, tick_count % 1000 == 0);
}
}
export fn EXTI0_Handler() void {
regs.GPIOA.writePin(5, true);
const exti_pr: *volatile u32 = @ptrFromInt(0x4001_0414);
exti_pr.* = 0x0001;
}
pub fn setup_systick() void {
const systick_ctrl: *volatile u32 = @ptrFromInt(0xE000_E010);
const systick_load: *volatile u32 = @ptrFromInt(0xE000_E014);
systick_load.* = 16_000; // 1ms at 16MHz
systick_ctrl.* = 0b111; // ENABLE + TICKINT + CLKSOURCE
}
Pattern 5: Firmware Flashing and Debugging
# Flash firmware
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
-c "program zig-out/bin/firmware.elf verify reset exit"
# GDB debugging
arm-none-eabi-gdb zig-out/bin/firmware.elf \
-ex "target remote :3333" \
-ex "monitor reset halt" \
-ex "break main" \
-ex "continue"
Pitfall Guide
Pitfall 1: Forgetting volatile on registers
// ❌ Wrong: compiler may optimize away register access
const reg: *u32 = @ptrFromInt(0x4002_0000);
reg.* = 1;
// ✅ Correct: use volatile
const reg: *volatile u32 = @ptrFromInt(0x4002_0000);
reg.* = 1;
Pitfall 2: Wrong interrupt handler signature
// ❌ Wrong: interrupt handler with return value
export fn SysTick_Handler() void { ... }
// ✅ Correct: bare-metal interrupts need Naked calling convention (some platforms)
export fn HardFault_Handler() callconv(.Naked) noreturn {
while (true) {}
}
Pitfall 3: Insufficient stack size
/* ❌ Wrong: not reserving enough stack space */
_stack_top = ORIGIN(RAM) + LENGTH(RAM);
/* ✅ Correct: explicitly define stack size */
_stack_size = 4K;
_stack_start = ORIGIN(RAM) + LENGTH(RAM) - _stack_size;
_stack_top = _stack_start + _stack_size;
Pitfall 4: BSS section not zeroed
// ❌ Wrong: skipping BSS initialization, global variable values undefined
export fn reset_handler() void {
main();
}
// ✅ Correct: zero BSS section at startup
fn init_bss() void {
@memset(@as([*]u8, @ptrFromInt(@intFromPtr(&bss_start)))[0..bss_len], 0);
}
Pitfall 5: Not handling compiler alignment
// ❌ Wrong: DMA buffer not aligned
var dma_buffer: [256]u8 = undefined;
// ✅ Correct: force alignment to cache line
var dma_buffer: [256]u8 align(32) = undefined;
Error Troubleshooting
| # | Error | Cause | Solution |
|---|---|---|---|
| 1 | error: undefined symbol: __aeabi_memcpy |
Missing compiler built-in functions | Add compiler_rt or implement manually |
| 2 | error: unable to create compilation |
Cross-compilation target not supported | Check target query cpu_arch and os_tag |
| 3 | Segmentation fault runtime |
Stack overflow or invalid memory access | Increase stack size, check pointer validity |
| 4 | linker script error |
LD file syntax error | Check MEMORY and SECTIONS syntax |
| 5 | error: volatile store |
Volatile type mismatch | Ensure register pointers use *volatile |
| 6 | HardFault |
Hardware exception (null pointer/unaligned) | Add HardFault_Handler to print debug info |
| 7 | openocd connection failed |
Debugger not connected | Check ST-Link driver and USB connection |
| 8 | flash write failed |
Chip write protection | Remove read protection via openocd |
| 9 | error: comptime unable to evaluate |
Compile-time computation failure | Check if comptime expression is statically evaluable |
| 10 | error: alignment mismatch |
Memory alignment not satisfied | Use align(N) to specify alignment |
Advanced Optimization
- comptime register generation: Auto-generate register structs with comptime, eliminating manual errors
- Zero-copy DMA transfer: Use align and volatile for zero-copy peripheral data transfer
- Interrupt priority grouping: Configure NVIC priority grouping to ensure critical interrupts aren't preempted
- Low-power modes: WFI instruction with peripheral clock gating reduces standby power to μA level
- OTA firmware upgrade: Dual-bank Flash design for safe wireless firmware updates
Comparison
| Dimension | Zig | C | Rust (no_std) | Assembly |
|---|---|---|---|---|
| Memory Safety | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Cross-Compilation | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| Code Size | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Ecosystem | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| Compile Speed | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Learning Curve | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
Summary: Zig embedded bare-metal development surpasses traditional C development in safety and efficiency with comptime metaprogramming and built-in cross-compilation. Zig suits embedded teams seeking memory safety without Rust's learning cost. While Zig's embedded ecosystem is still growing in 2026, its core language features are sufficient for production-grade firmware development.
Online Tools
- JSON Formatter: /en/json/format
- Hash Calculator: /en/encode/hash
- cURL to Code: /en/dev/curl-to-code
Try these browser-local tools — no sign-up required →