Learn Zig Series (#181) - Cross-Compiling for ARM Cortex-M

Words
4765
Reading
22 min
Listen
Play
38m

Learn Zig Series (#181) - Cross-Compiling for ARM Cortex-M

zig.png

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 freestanding target 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.zig that produces an actual firmware binary, a reset vector, and a no_std entry 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):

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

  1. Guard your target. Write a small Zig file that reads @import("builtin") and, at comptime, emits a @compileError unless the target is thumb + freestanding. Then add a public pub const has_hardware_divide that is true only 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.

  2. Type your registers. Model a UART status register as a packed struct(u32) with named single-bit fields for tx_empty, rx_full, overrun_error and a reserved remainder that pads to exactly 32 bits. Write a pure function fn canSend(status: u32) bool that reinterprets the raw word as your struct (via @bitCast) and returns whether tx_empty is set, then unit-test it on the host with a handful of raw values. No real hardware -- just prove the bit maths.

  3. 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 with zig test cases 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! ;-)

scipio@scipio