Embedded development means writing software for small computers inside devices.
Embedded development means writing software for small computers inside devices.
These devices may be:
microcontrollers
sensors
robots
keyboards
drones
medical devices
industrial controllers
network appliances
small displays
motor controllersAn embedded program often runs close to the hardware. It may not have an operating system. It may not have a filesystem. It may not have dynamic memory. It may only have a few kilobytes of RAM.
Zig is a strong fit for this kind of work because it gives direct control over memory, layout, linking, and machine instructions.
What Makes Embedded Different
On a normal desktop program, you often assume many things are available:
heap allocation
threads
files
environment variables
networking
standard input and output
large memory
operating system servicesIn embedded programming, many of these may be missing.
A microcontroller program may start directly from a reset vector. It may write to hardware registers. It may never return from main.
The machine is smaller, but the rules are stricter.
Bare Metal
Bare metal means your program runs without an operating system.
There is no Linux, Windows, or macOS underneath your code.
Your program is responsible for low-level setup, such as:
startup code
stack pointer
interrupt table
memory sections
clock setup
device initializationZig can target bare-metal systems, but you need to understand the target platform. The compiler can generate code, but it cannot magically know how your board is wired.
Memory-Mapped Registers
Embedded programs often control hardware through memory-mapped registers.
That means a hardware device appears at a fixed memory address. Reading or writing that address talks to the device.
Example idea:
const gpio_addr: usize = 0x4002_0000;
const gpio = @as(*volatile u32, @ptrFromInt(gpio_addr));
gpio.* = 1;The exact address depends on the chip.
The important part is volatile.
*volatile u32This tells Zig that the value can change outside normal program flow. Hardware may change it. The compiler must not optimize the access away.
Without volatile, the compiler may assume repeated reads or writes are ordinary memory operations. That assumption is wrong for hardware registers.
Register Layouts with Packed Structs
Hardware registers often contain bit fields.
For example, one 32-bit register may contain several flags:
bit 0: enable
bit 1: interrupt enabled
bits 2..3: mode
bits 4..31: reservedZig can model this with packed structs:
const ControlRegister = packed struct {
enable: bool,
interrupt_enable: bool,
mode: u2,
reserved: u28,
};This gives names to individual bits.
A register pointer might look like:
const control_addr: usize = 0x4002_0000;
const control = @as(*volatile ControlRegister, @ptrFromInt(control_addr));Then code can write:
control.enable = true;
control.mode = 2;This is clearer than manually shifting and masking integers everywhere.
Still, be careful. Hardware layout must match the chip documentation exactly.
No Hidden Allocation
Embedded systems often avoid heap allocation.
A heap requires memory management. On a small device, that can introduce fragmentation, failure cases, and timing uncertainty.
Zig helps because allocation is explicit.
If a function needs an allocator, you see it:
fn buildMessage(allocator: std.mem.Allocator) ![]u8 {
return try allocator.alloc(u8, 64);
}In embedded code, you can avoid such functions or use a fixed buffer allocator.
Example:
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();This allocator can only use the provided buffer. It never asks an operating system for more memory.
That is useful when memory must be bounded.
Fixed Buffers
Fixed buffers are common in embedded code.
Instead of this:
var list = std.ArrayList(u8).init(allocator);you may use this:
var buffer: [256]u8 = undefined;
var len: usize = 0;Then you carefully write into the array.
fn appendByte(buffer: []u8, len: *usize, byte: u8) !void {
if (len.* >= buffer.len) {
return error.BufferFull;
}
buffer[len.*] = byte;
len.* += 1;
}This looks manual, but it is predictable. The memory usage is visible and bounded.
Interrupts
Many embedded programs respond to interrupts.
An interrupt is a hardware event that temporarily stops normal code and runs a special function.
Examples:
timer fired
button pressed
data arrived on UART
ADC conversion finished
network packet receivedInterrupt handlers must be small and careful.
They should usually:
read or clear the hardware status
store minimal data
set a flag
return quicklyThey should not usually:
allocate memory
do long computations
print large logs
block waiting for another eventIn Zig, interrupt setup depends heavily on the target architecture and board support code.
Volatile Is Not Atomic
This distinction matters.
volatile tells the compiler not to remove or reorder a memory access in ways that break hardware interaction.
atomic is about safe communication between threads or interrupt contexts.
If normal code and an interrupt handler share data, you may need atomics or careful interrupt masking.
Example concept:
const std = @import("std");
var flag = std.atomic.Value(bool).init(false);Then one context can set the flag and another can read it.
The exact design depends on the target and concurrency model.
Linker Scripts
Embedded programs need precise memory layout.
A chip may have:
flash memory at one address
RAM at another address
special boot memory
interrupt vector table location
memory-mapped peripheral regionsThe linker decides where code and data go.
For embedded work, you often need a linker script.
A linker script describes memory regions and places program sections into them.
Conceptually:
.text goes into flash
.rodata goes into flash
.data is stored in flash but copied to RAM
.bss is zeroed in RAM
stack goes into RAMZig can work with linker scripts, but the script must match the chip.
Startup Code
Before normal Zig code runs, the system may need startup code.
Startup code may:
set the stack pointer
copy initialized data from flash to RAM
zero the BSS section
configure clocks
install interrupt vectors
call mainOn a desktop operating system, this is handled for you.
On bare metal, you may need to provide it yourself or use existing board support code.
Panic Behavior
On a desktop, a panic may print a stack trace.
On embedded hardware, there may be nowhere to print.
You may define a panic handler that:
turns on an LED
sends a message over UART
halts the CPU
resets the board
enters an infinite loopThe simplest panic behavior is often:
pub fn panic(msg: []const u8, stack_trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn {
_ = msg;
_ = stack_trace;
_ = ret_addr;
while (true) {}
}The exact signature may vary with Zig version, but the idea is stable: embedded programs often provide their own panic behavior.
Cross Compilation
Embedded development usually means cross compilation.
You compile on your laptop, but the program runs on another CPU.
For example:
build machine: x86_64 laptop
target machine: ARM Cortex-M microcontrollerZig is designed to cross-compile. You choose a target when building.
Conceptually:
zig build-exe src/main.zig -target thumb-freestanding-eabiThe exact target triple depends on your chip.
Common embedded targets include ARM, RISC-V, and freestanding variants.
Freestanding Targets
A freestanding target means there is no assumed operating system.
In Zig target names, this often appears as:
freestandingA hosted target uses an operating system ABI.
A freestanding target does not.
This affects what parts of the standard library are available. File APIs, process APIs, and networking APIs usually do not make sense on bare metal.
The Standard Library in Embedded Code
Zig’s standard library is modular, but not every part applies to embedded systems.
Useful parts may include:
integer utilities
memory utilities
fixed buffer allocators
formatting into buffers
atomics
math helpers
testing on host buildsLess relevant parts may include:
filesystem APIs
process APIs
environment variables
OS networking APIs
thread APIsEmbedded Zig code often uses a small subset of std.
Testing Embedded Logic
You should separate hardware-independent logic from hardware-specific code.
Hardware-independent code can be tested on your normal computer.
Example:
fn checksum(bytes: []const u8) u8 {
var sum: u8 = 0;
for (bytes) |byte| {
sum +%= byte;
}
return sum;
}
test "checksum" {
const data = [_]u8{ 1, 2, 3 };
try std.testing.expectEqual(@as(u8, 6), checksum(&data));
}This test does not need the microcontroller.
Only the hardware access layer needs the actual device.
This design saves time.
Hardware Abstraction
A good embedded program separates:
application logic
driver code
raw register access
board-specific setupFor example:
app.zig
drivers/uart.zig
drivers/gpio.zig
boards/my_board.zig
startup.zigThe application should not be full of raw addresses.
Instead of this everywhere:
const reg = @as(*volatile u32, @ptrFromInt(0x4002_0000));
reg.* = 1;prefer a small driver API:
gpio.setHigh(.led);The raw address still exists, but it is isolated.
Timing and Determinism
Embedded programs often care about timing.
A motor controller, sensor reader, or communication protocol may need predictable timing.
Avoid hidden costs:
unexpected allocation
large formatting operations
unbounded loops
blocking I/O
implicit dynamic dispatch
uncontrolled recursionZig helps by making many costs visible, but you still need discipline.
For real-time systems, you must know the worst-case behavior of critical code.
Embedded Logging
Logging is useful, but embedded logging must be controlled.
Possible outputs:
UART
semihosting
RTT
USB serial
memory ring buffer
LED blink codesDo not assume std.debug.print is available or appropriate.
A logging system for embedded code should be:
small
optional
bounded
safe in failure paths
safe around interruptsIn release builds, you may disable most logs.
Common Embedded Mistakes
A common mistake is treating bare metal like a small desktop.
There may be no files, no heap, no console, and no operating system.
Another mistake is ignoring volatile for hardware registers.
A third mistake is putting too much work inside interrupt handlers.
A fourth mistake is spreading raw register addresses throughout the codebase.
A fifth mistake is failing to define memory ownership. Even without heap allocation, ownership matters for buffers and shared state.
A Practical Embedded Zig Style
For small embedded projects, use this style:
keep memory static or bounded
use fixed buffers
isolate raw registers
make hardware drivers small
test pure logic on the host
avoid allocation in interrupts
avoid complex formatting in critical paths
write explicit startup and panic behavior
version-control linker scripts and board definitionsThis keeps the system understandable.
The Main Idea
Embedded Zig is about control.
You control memory. You control layout. You control startup. You control hardware access. You control what parts of the standard library you use.
That control has a cost: you must understand the target machine.
Zig does not hide the hardware. It gives you a clear language for working with it.