Learn Zig Series (#181) - Cross-Compiling for ARM Cortex-M
What will I learn
- What a Cortex-M microcontroller actually is, why "bare metal" means no operating system underneath you at all, and what the
freestandingtarget changes about everything you have learned so far; - How to point the compiler at a real chip with a target triple plus a
-mcpu, and why Zig can do this out of the box while every other toolchain wants you to download half a gigabyte first; - How to write a
build.zigthat produces an actual firmware binary, a reset vector, and ano_stdentry point with your own panic handler; - How to talk to hardware through volatile memory-mapped I/O and packed register structs (the payoff for episodes 17 and 31), and how comptime lets one codebase target several different chips;
- How to test firmware you physically cannot run on your laptop, by splitting a pure, host-testable core from the tiny hardware shell;
- How to shrink a binary to fit in a few kilobytes of flash with ReleaseSmall, and where C, Rust and Go land on the same bare-metal problem.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org) -- the snippets here are written against Zig 0.16;
- Episode 35 (Cross-Compilation and Target Triples) is the direct prerequisite -- today we take that idea all the way down to a chip with no OS;
- Episodes 17 (Packed Structs and Bit Manipulation) and 31 (Memory-Mapped I/O) will make the register code feel like an old friend;
- The ambition to learn Zig programming. No physical hardware is required to follow along -- we render, reason and test everything on the host.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design
- Learn Zig Series (#136) - Stack-Based Virtual Machine
- Learn Zig Series (#137) - Closures and Upvalues
- Learn Zig Series (#138) - Garbage Collection: Mark and Sweep
- Learn Zig Series (#139) - Garbage Collection: Generational
- Learn Zig Series (#140) - JIT Compilation Basics
- Learn Zig Series (#141) - Regex: Thompson NFA
- Learn Zig Series (#142) - Regex: NFA to DFA
- Learn Zig Series (#143) - Regex: Matching Engine
- Learn Zig Series (#144) - Code Generation: AST to Machine Code
- Learn Zig Series (#145) - Register Allocation
- Learn Zig Series (#146) - Mini Project: Calculator - Lexer/Parser
- Learn Zig Series (#147) - Mini Project: Calculator - Interpreter
- Learn Zig Series (#148) - Mini Project: Calculator - Bytecode Compiler
- Learn Zig Series (#149) - Mini Project: Calculator - VM with Debugger
- Learn Zig Series (#150) - Mini Project: Lisp - Reader
- Learn Zig Series (#151) - Mini Project: Lisp - Evaluator
- Learn Zig Series (#152) - Mini Project: Lisp - Special Forms and Macros
- Learn Zig Series (#153) - Mini Project: Lisp - Standard Library
- Learn Zig Series (#154) - Mini Project: Regex Engine - NFA
- Learn Zig Series (#155) - Mini Project: Regex Engine - Matching
- Learn Zig Series (#156) - Framebuffer Basics
- Learn Zig Series (#157) - Line Drawing: Bresenham
- Learn Zig Series (#158) - Circle and Ellipse Rasterization
- Learn Zig Series (#159) - Polygon Filling: Scanline
- Learn Zig Series (#160) - 2D Transform Matrices
- Learn Zig Series (#161) - Double Buffering and Vsync
- Learn Zig Series (#162) - Sprite Rendering and Tile Maps
- Learn Zig Series (#163) - Bitmap Font Rendering
- Learn Zig Series (#164) - TrueType Parsing
- Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB
- Learn Zig Series (#166) - Alpha Blending and Compositing
- Learn Zig Series (#167) - PNG Decoder in Zig
- Learn Zig Series (#168) - JPEG Decoder Basics
- Learn Zig Series (#169) - Audio Fundamentals: PCM and Buffers
- Learn Zig Series (#170) - Audio Output via C Interop
- Learn Zig Series (#171) - Synthesis: Oscillators
- Learn Zig Series (#172) - Synthesis: Envelopes and Filters
- Learn Zig Series (#173) - Audio Mixing
- Learn Zig Series (#174) - MIDI Parsing and Generation
- Learn Zig Series (#175) - Mini Project: Pixel Art Editor - Part 1
- Learn Zig Series (#176) - Mini Project: Pixel Art Editor - Part 2
- Learn Zig Series (#177) - Mini Project: Pixel Art Editor - Part 3
- Learn Zig Series (#178) - Mini Project: Synth Engine - Part 1
- Learn Zig Series (#179) - Mini Project: Synth Engine - Part 2
- Learn Zig Series (#180) - Mini Project: Synth Engine - Part 3
- Learn Zig Series (#181) - Cross-Compiling for ARM Cortex-M (this post)
Learn Zig Series (#181) - Cross-Compiling for ARM Cortex-M
At the end of the synth engine I promised we would leave the comfortable world behind -- the hosted operating system, its allocators, its threads, its friendly std -- and go down to the bare metal, where code runs with no OS underneath it at all. Today we take the first step, and it is a big one. We are going to teach the compiler to produce a binary that does not run on your laptop, has no main in the sense you are used to, cannot call println, and boots on a chip that might have four kilobytes of RAM to its name. Here we go!
This is the natural culmination of a thread that has run through the whole series. Back in episode 35 we learned about target triples and how Zig treats cross-compilation as a first-class, everyday thing rather than an exotic ritual. In episode 17 we packed bits into structs, and in episode 31 we poked at memory-mapped I/O. Every one of those pieces was, secretly, preparation for this. A microcontroller is where they all come together at once.
What a Cortex-M actually is, and why "freestanding" changes everything
An ARM Cortex-M is a family of small, cheap, low-power CPU cores that live inside microcontrollers -- the chips in your thermostat, your earbuds, your washing machine, a drone flight controller, a thousand IoT gadgets. Common members: the Cortex-M0+ (tiny, ultra-low-power), the M3 and M4 (the workhorses, the M4 adds a DSP instruction set and sometimes a floating-point unit), the M7 (fast). They run the Thumb instruction set -- a compact 16/32-bit encoding designed to squeeze code into scarce flash.
Here is the mental shift that matters. On your laptop there is an operating system. When your program starts, the OS has already set up memory, given you a stack, opened stdin/stdout, and it stands ready to answer read, write, mmap and the rest. Your main is called by C runtime glue that the OS cooperated to load. On a Cortex-M there is none of that. When the chip powers on, it reads an address out of a fixed location in flash and jumps there. That is the whole ceremony. No loader, no scheduler, no heap, no files, no write. You are the operating system now.
In Zig, that world is called freestanding: the os_tag is freestanding, meaning "assume no OS services exist". Almost all of std that touches the OS -- files, sockets, threads, the general-purpose allocator -- simply is not available, because there is nothing underneath to implement it. What does still work is the enormous, genuinely useful part of std that is pure computation: std.mem, std.fmt, std.math, data structures over fixed buffers, comptime, everything we built the interpreter and the synth out of. That is the good news, and it is a big part of why bare-metal Zig is such a pleasure.
The target triple for bare metal
Let us make it concrete. On the command line, building for a Cortex-M4 is a single invocation -- no toolchain to install, no cross-gcc to hunt down, because Zig ships LLVM and every target's libraries inside the one download you already have:
// Build a freestanding Thumb binary for a Cortex-M4, optimised for small size.
//
// zig build-exe firmware.zig \
// -target thumb-freestanding-eabi \
// -mcpu cortex_m4 \
// -O ReleaseSmall \
// -fno-entry \
// --script link.ld
//
// -target : architecture-os-abi (Thumb, no OS, embedded ABI)
// -mcpu : the exact core, so LLVM emits only instructions it has
// -fno-entry: there is no libc _start; our own reset handler is the entry
// --script : the linker script that places code in flash and data in RAM
The triple thumb-freestanding-eabi reads exactly as episode 35 taught: architecture is thumb, operating system is freestanding, ABI is eabi (the ARM Embedded Application Binary Interface). The -mcpu cortex_m4 is the crucial refinement -- it tells the backend precisely which core it is compiling for, so it never emits an instruction the chip does not have (an M0+ has no hardware divide; an M4 does), and so it can pick the right calling convention for the floating-point unit if one exists.
Inside the code, you can inspect the very target you were compiled for through the builtin module, which is resolved entirely at comptime:
const builtin = @import("builtin");
// All of this is known at compile time -- it costs zero bytes at runtime,
// it just steers which branches get compiled in at all.
comptime {
if (builtin.cpu.arch != .thumb)
@compileError("this firmware is for Thumb (Cortex-M) targets only");
}
pub const is_bare_metal = builtin.os.tag == .freestanding;
pub const has_fpu = std.Target.arm.featureSetHas(builtin.cpu.features, .vfp4d16);
That @compileError is not decoration -- it is a guard rail. If someone accidentally tries to build this file for their host machine, they get a clear message at compile time in stead of a mystifying crash at runtime. This is the whole Zig philosophy in one line: push the failure as early as possible, ideally before the binary even exists.
A build.zig that targets a real chip
The command line is fine for a demo, but real firmware lives in a build.zig (episode 15). The modern build API takes a resolved target, and we build one from a query that spells out the chip:
const std = @import("std");
pub fn build(b: *std.Build) void {
// Describe the exact silicon. No host detection here -- we want the same
// artifact on every developer's machine and in CI.
const query: std.Target.Query = .{
.cpu_arch = .thumb,
.os_tag = .freestanding,
.abi = .eabi,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m4 },
};
const target = b.resolveTargetQuery(query);
const firmware = b.addExecutable(.{
.name = "blink.elf",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = .ReleaseSmall, // flash is precious
});
// No libc _start on a microcontroller: our reset handler is the entry.
firmware.entry = .disabled;
firmware.setLinkerScript(b.path("link.ld"));
b.installArtifact(firmware);
}
Notice what is not here: no b.standardTargetOptions(.{}). On a hosted CLI tool we let the user pick their target; on firmware there is exactly one right answer, so we hard-code it. Notice also that the same build.zig produces a byte-identical firmware image on Linux, macOS or Windows. That reproducibility is not a nice-to-have in embedded work -- when you are chasing a bug that only shows up on the physical device, "it builds the same everywhere" removes an entire category of doubt.
The reset vector and a minimal freestanding entry
So what does the chip actually jump to? At the very start of flash sits the vector table -- an array of addresses. Entry zero is the initial stack pointer; entry one is the reset handler, the address the core jumps to on power-up. We hand-place that table into a named section and let the linker script drop it at the right spot:
const std = @import("std");
// Provided by the linker script: where RAM starts/ends and where the
// initialised-data image lives in flash. `extern` symbols, no storage of their own.
extern var _stack_top: anyopaque;
extern var _data_start: u8;
extern var _data_end: u8;
extern var _data_load: u8;
extern var _bss_start: u8;
extern var _bss_end: u8;
// The reset handler: the first Zig code that runs on the chip.
fn resetHandler() callconv(.c) noreturn {
// 1. copy .data (initialised globals) from flash into RAM
const data_len = @intFromPtr(&_data_end) - @intFromPtr(&_data_start);
@memcpy(@as([*]u8, @ptrCast(&_data_start))[0..data_len],
@as([*]const u8, @ptrCast(&_data_load))[0..data_len]);
// 2. zero .bss (uninitialised globals) -- the C runtime normally does this
const bss_len = @intFromPtr(&_bss_end) - @intFromPtr(&_bss_start);
@memset(@as([*]u8, @ptrCast(&_bss_start))[0..bss_len], 0);
// 3. hand over to our real program, which must never return
main();
while (true) {} // if main ever returns, spin forever rather than run garbage
}
// The vector table: stack pointer, then reset handler, placed at flash start.
export const vector_table linksection(".vector_table") = [_]usize{
@intFromPtr(&_stack_top),
@intFromPtr(&resetHandler),
// ... real firmware lists NMI, HardFault and the rest here ...
};
Two things here are pure bare-metal reality that a hosted program never sees. First, you copy initialised globals from flash to RAM and you zero the BSS -- there is no C runtime doing it for you, so if you skip it your global variables contain garbage. Second, resetHandler is noreturn: there is nowhere to return to. The while (true) {} at the end is the honest expression of that -- if control somehow falls out of main, spinning is infinitely safer than executing whatever bytes happen to sit next in flash.
Because there is no std panic path either, a freestanding build wants us to supply our own panic handler. Zig looks for one in the root source file:
// With no OS and no stderr, a panic can only do something hardware-local:
// stop interrupts, maybe flash an LED, and freeze so a debugger can inspect us.
pub fn panic(msg: []const u8, _: ?*std.builtin.StackTrace, _: ?usize) noreturn {
_ = msg;
asm volatile ("cpsid i"); // disable interrupts (episode 29's inline asm)
while (true) {
asm volatile ("bkpt 0"); // breakpoint: a connected debugger halts here
}
}
That cpsid i and bkpt 0 are Thumb instructions dropped in with the inline assembly we met in episode 29 -- one disables interrupts so nothing else runs, the other traps to an attached debugger. A panic on a microcontroller cannot print a stack trace to a terminal that does not exist; the most useful thing it can do is stop cleanly and be findable.
Talking to hardware: volatile memory-mapped I/O and packed registers
Now the fun part, and the moment episodes 17 and 31 pay off. Peripherals -- GPIO pins, timers, UARTs -- are controlled by reading and writing registers that live at fixed memory addresses. Writing a 1 to a particular bit at a particular address might switch on an LED. That is memory-mapped I/O, and the one non-negotiable rule is that these accesses must be volatile: the compiler must never cache them, reorder them, or optimise them away, because the "memory" is actually a wire to the outside world.
We model a register with a packed struct so each named field is a bit, exactly as in episode 17, and reach it through a volatile pointer at its known address:
/// A GPIO port's output-data register, laid out bit-for-bit as the hardware sees it.
/// A packed struct backed by u32 means field access compiles to a bit mask -- no
/// magic numbers scattered through the code.
const GpioOutput = packed struct(u32) {
pin0: bool,
pin1: bool,
pin2: bool,
pin3: bool,
_reserved: u28 = 0,
};
// The address is chip-specific; it comes from the datasheet, not from thin air.
const gpio_output: *volatile GpioOutput = @ptrFromInt(0x4002_0014);
fn setLed(on: bool) void {
// Read-modify-write a single field. `volatile` guarantees the write actually
// reaches the pin and is not "optimised" into oblivion.
var reg = gpio_output.*;
reg.pin3 = on;
gpio_output.* = reg;
}
Compare that to the C tradition of *(volatile uint32_t*)0x40020014 |= (1 << 3); -- a magic address, a magic shift, and a comment praying you got the bit number right. The packed struct turns the datasheet into types. reg.pin3 = on cannot accidentally hit the wrong bit, the reserved bits are named and defaulted, and if you get the width wrong Zig tells you at compile time because packed struct(u32) asserts the total is exactly 32 bits. This is memory-mapped I/O with the sharp edges filed off.
A blinking LED -- the "hello world" of embedded -- is then almost anticlimactic:
fn main() void {
// In real firmware you would first enable the GPIO clock and set pin3 to
// output mode via other registers; omitted here to keep the focus on the loop.
while (true) {
setLed(true);
delay(500_000);
setLed(false);
delay(500_000);
}
}
fn delay(count: u32) void {
var i: u32 = 0;
while (i < count) : (i += 1) {
asm volatile ("nop"); // volatile: don't let the optimiser delete the loop
}
}
The asm volatile ("nop") inside delay is a small but instructive trick: without it, an optimising build would notice the loop does nothing observable and delete it entirely, and your LED would blink faster than the eye can see. The volatile keyword there says "this instruction has side effects you cannot see, keep it". (In production you would use a hardware timer in stead of a busy-loop, but that is a story for another episode.)
Comptime target selection: one codebase, many chips
Here is where Zig genuinely pulls ahead of the pack. Different chips put their GPIO registers at different addresses. In C this is the land of #ifdef STM32F4 sprinkled through every file -- a preprocessor maze. In Zig it is just data and comptime:
const Chip = enum { stm32f4, rp2040, nrf52 };
// Pick the chip at build time -- e.g. from a build option threaded into a
// config module. One binary per chip, chosen with plain Zig, no preprocessor.
const chip: Chip = @import("config").chip;
const gpio_base: usize = switch (chip) {
.stm32f4 => 0x4002_0000,
.rp2040 => 0x4001_4000,
.nrf52 => 0x5000_0000,
};
const gpio_output: *volatile GpioOutput = @ptrFromInt(gpio_base + 0x14);
Because chip is comptime-known, the switch collapses to a single constant at compile time; the branches you did not pick generate no code at all. You get the readability of ordinary Zig -- a real enum you can exhaustively switch on, a real error if you add a chip and forget a case -- with the zero runtime cost of the C preprocessor and none of its ugliness. This is comptime (episode 9) doing exactly the job it was born for: configuration that vanishes before the binary exists.
Testing firmware you can't run on your laptop
"But how do I test code I can't even run?" -- the eternal, sensible embedded question. The answer is the same discipline the whole series has hammered on: separate a pure core from the hardware shell. The core is ordinary Zig that computes values; the shell is the thin layer that pushes those values at registers. The core you can test on your host at full speed; the shell you verify on the device.
Say your firmware controls an LED brightness with PWM. The computation of the duty cycle from a brightness percentage is pure arithmetic -- no hardware anywhere -- so it runs and tests natively:
const std = @import("std");
/// Pure: given a brightness 0..100 and a timer period, compute the PWM compare
/// value. No registers, no volatile -- just math. This is the testable core.
pub fn dutyForBrightness(brightness: u8, period: u16) u16 {
const clamped: u32 = @min(brightness, 100);
return @intCast(@as(u32, period) * clamped / 100);
}
test "duty cycle maps brightness across the full period" {
try std.testing.expectEqual(@as(u16, 0), dutyForBrightness(0, 1000));
try std.testing.expectEqual(@as(u16, 500), dutyForBrightness(50, 1000));
try std.testing.expectEqual(@as(u16, 1000), dutyForBrightness(100, 1000));
// over-bright is clamped, never wraps
try std.testing.expectEqual(@as(u16, 1000), dutyForBrightness(200, 1000));
}
Run zig test on your development machine and this executes in milliseconds, on native x86, with the full test runner and even a debugger if you want one. The register write -- timer.compare = dutyForBrightness(brightness, period); -- is a one-liner in the shell that you eyeball once and trust. The lesson generalises hard: the more logic you push into the pure core, the more of your firmware you can test without ever touching hardware. For the parts that genuinely need the chip, QEMU can emulate common Cortex-M boards, and semihosting lets the emulator or debugger print on your behalf -- but the first and cheapest line of defence is always the host-run unit test.
Optimizing for flash and RAM: ReleaseSmall and friends
On a desktop we reach for ReleaseFast almost reflexively. On a microcontroller the binding constraint is usually not speed but size -- a chip might have 32 KB of flash total, and your entire program, vector table and constants have to fit. That is why every build above used -O ReleaseSmall, which tells LLVM to optimise for code size rather than raw throughput:
// The four optimisation modes, and what they mean for firmware:
//
// Debug -> big, slow, full safety checks + panics. Great on QEMU.
// ReleaseSafe -> optimised but keeps safety checks (bounds, overflow).
// ReleaseFast -> maximum speed, no safety checks. Rarely what an MCU needs.
// ReleaseSmall -> minimum size, no safety checks. The usual firmware default.
//
// Set once in build.zig: .optimize = .ReleaseSmall
Three habits keep bare-metal Zig small and predictable, all of which the series has quietly trained already. First, no heap: with no allocator, you lean on fixed buffers and the pool/ring-buffer patterns from episodes 113 and 114 -- static memory whose size you can count at compile time. Second, no hidden work: Zig has no garbage collector and no runtime, so there is nothing sneaking in behind your back, which is exactly what you want when 32 KB is the whole budget. Third, let comptime delete code: the chip-selection switch above compiles only the branch you use, so unused peripheral support costs literally zero bytes. You can inspect the result with a size tool -- llvm-size firmware.elf or zig's own output -- and watch a ReleaseSmall build come in a fraction of the ReleaseFast one.
The same job in C, Rust, and Go
Bare-metal Cortex-M is C's ancestral home, so this comparison is more pointed than usual.
In C, this is the established, battle-tested path -- but the toolchain story is a slog. You install arm-none-eabi-gcc (a separate, chip-family-specific cross-compiler, often hundreds of megabytes), a vendor SDK, a linker script generator, and you wire them together with a Makefile that every embedded engineer has cursed at. The C code is fine; the friction is all in the setup and the preprocessor #ifdef maze for multi-chip support. Zig's headline advantage over C here is stark: zig build-exe -target thumb-freestanding-eabi -mcpu cortex_m4 needs nothing installed beyond Zig itself. Cross-compilation that is a weekend project in C is a single flag in Zig.
In Rust, the experience is genuinely excellent and the closest philosophical cousin. You add a target like thumbv7em-none-eabihf via rustup, lean on the superb embedded-hal ecosystem and tools like probe-rs, and get real memory safety on the device. The trade is complexity: the cortex-m-rt crate, the type-state HAL patterns, and a heavier learning curve. Zig gives you a leaner, more transparent path -- you can see the vector table and the reset handler as plain code, which is wonderful for learning -- at the cost of Rust's stronger safety guarantees.
In Go, plain Go is essentially a non-starter here: it needs a runtime and a garbage collector, neither of which exists on bare metal, and its smallest binaries dwarf a microcontroller's flash. The TinyGo project heroically compiles a Go subset to these chips via LLVM, and it is impressive, but it is a different compiler with real language restrictions. Standard Go is simply the wrong tool for a 32 KB target -- which is fine, because that was never the job it was designed for. Zig, meanwhile, lands where it always does: C's directness and total control, modern ergonomics like packed structs and comptime and optionals, and no runtime tax at all.
Where this leaves us, and where we go next
Step back and look at what just happened. We took a compiler that normally builds programs for your laptop and, with a target triple and a -mcpu, turned it into a firmware toolchain for a chip that has no operating system, no heap, and a few kilobytes of RAM -- with nothing to install. We wrote the reset vector by hand, supplied our own panic handler, modelled hardware registers as packed structs reached through volatile pointers, used comptime to support several chips from one clean codebase, and -- the part I care about most -- kept the logic in a pure core we can test at full speed on the host. That last habit is not a bare-metal trick; it is the same separation of a testable core from a noisy shell that carried the synth engine, and it is the single most valuable thing this whole embedded arc will keep leaning on.
We have the binary now, and we understand how it boots. But we have been a little hand-wavy about one thing: that linker script, the link.ld we kept referencing, the map that tells the linker where flash lives, where RAM lives, and where every section lands. That map is what makes a freestanding binary actually runnable on a specific chip, and it deserves its own careful look. From there we go deeper into the metal still -- driving real pins, real peripherals, real buses. The constraints only get tighter from here, and honestly, that is my favourite kind of Zig ;-)
Having said that -- do not let the "no hardware required" of today lull you. The exercises below are all host-buildable and host-testable, exactly as promised, so grab your editor and get the compiler to prove you understood.
Exercises
Guard your target. Write a small Zig file that reads
@import("builtin")and, at comptime, emits a@compileErrorunless the target isthumb+freestanding. Then add a publicpub const has_hardware_dividethat istrueonly for cores that support it (hint: a Cortex-M0+ does not, an M4 does -- gate it on the CPU model or feature set). Build it once for a Cortex-M4 and once for your host, and confirm the host build fails with your message.Type your registers. Model a UART status register as a
packed struct(u32)with named single-bit fields fortx_empty,rx_full,overrun_errorand a reserved remainder that pads to exactly 32 bits. Write a pure functionfn canSend(status: u32) boolthat reinterprets the raw word as your struct (via@bitCast) and returns whethertx_emptyis set, then unit-test it on the host with a handful of raw values. No real hardware -- just prove the bit maths.Push logic into the testable core. Pick a peripheral computation -- say, converting a baud rate and a peripheral clock frequency into a UART divisor (
divisor = clock / baud), with correct rounding. Write it as a pure function and cover it withzig testcases including an exact division, a rounding case, and a guard for a baud rate of zero (return an error, do not divide by zero). This is the "core you can test without the chip" discipline in miniature.
De groeten, en tot de volgende keer! ;-)