Learn Zig Series (#182) - Bare Metal: No OS, No std
What will I learn
- What "no OS, no std" actually means -- that
freestandingis a property of the target, not a special dialect of Zig, and why that one idea unlocks kernels, firmware and WebAssembly modules alike; - Which large, genuinely useful chunks of the standard library keep working with no operating system underneath (spoiler: most of the good bits), and which handful vanish and why;
- How to allocate without a heap using a FixedBufferAllocator, and run
ArrayListand a hash map on top of memory you counted at compile time; - How to format text with
std.fmt.bufPrintand push it out through a Writer you wrote yourself, when there is nostdoutto print to; - How to keep the whole thing testable on your laptop by splitting a pure core from a paper-thin I/O shell;
- Where C's
-ffreestanding, Rust's#![no_std]and Go's runtime land on the exact same 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 181 (Cross-Compiling for ARM Cortex-M) is the direct prerequisite -- there we built the freestanding binary; here we learn to program comfortably inside one;
- Episodes 7 (Memory Management and Allocators) and 26 (Writing a Custom Allocator) will make the no-heap section feel familiar;
- The ambition to learn Zig programming. No physical hardware is required -- everything here builds and tests 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
- Learn Zig Series (#182) - Bare Metal: No OS, No std (this post)
Learn Zig Series (#182) - Bare Metal: No OS, No std
Last episode we did something that still feels a little illicit: we pointed the compiler at a chip with no operating system and got a real firmware binary out the other side, with nothing installed beyond Zig itself. We wrote the reset vector, a panic handler, poked at registers through volatile pointers. But I skipped past the question that trips up almost everyone the first time they go bare metal, and it deserves an episode of its own: if there is no OS, what happens to std? Can I still @import("std")? Which parts still work? What does "no std" even mean when the whole language ships one? Here we go!
The short version, and the thing I wish someone had told me years ago: "no std" is a lie of convenience. You do not lose the standard library on bare metal. You lose the operating system, and with it the slice of std that needs an OS to function. The rest -- and it is the large, genuinely delightful majority -- keeps working exactly as it did on your laptop. Today is about drawing that line precisely, and learning to live comfortably on the freestanding side of it.
Solutions to Episode 181 Exercises
Before we dive in, here are the worked solutions to last episode's three exercises. All of them build and test on the host, as promised.
Exercise 1: Guard your target. We read the builtin module at comptime, refuse to compile for anything that is not Thumb + freestanding, and expose a has_hardware_divide constant gated on the CPU model:
const std = @import("std");
const builtin = @import("builtin");
comptime {
if (builtin.cpu.arch != .thumb)
@compileError("this firmware targets Thumb (Cortex-M) only");
if (builtin.os.tag != .freestanding)
@compileError("this firmware must be built for a freestanding target");
}
// An M0/M0+ has no hardware divide instruction; an M3/M4/M7 does. We detect it
// from the CPU feature set rather than hard-coding a model name.
pub const has_hardware_divide: bool =
std.Target.arm.featureSetHas(builtin.cpu.features, .has_v7);
test "divide capability is a plain comptime bool" {
// On the host this compiles only because the guards above are skipped in
// test builds; the point is that has_hardware_divide is knowable at comptime.
try std.testing.expect(@TypeOf(has_hardware_divide) == bool);
}
The key insight: @compileError inside a comptime block turns a whole class of "wrong build" mistakes into a clear message before a binary exists, and feature detection means one source file adapts to several cores without a single runtime branch.
Exercise 2: Type your registers. A UART status register modelled as a packed struct(u32), with a pure canSend that reinterprets a raw word via @bitCast:
const std = @import("std");
const UartStatus = packed struct(u32) {
tx_empty: bool,
rx_full: bool,
overrun_error: bool,
_reserved: u29 = 0,
};
/// Pure: is the transmit buffer free? No hardware -- just bit maths on a word.
fn canSend(status: u32) bool {
const s: UartStatus = @bitCast(status);
return s.tx_empty;
}
test "canSend reads the tx_empty bit and nothing else" {
try std.testing.expect(canSend(0b001)); // tx_empty set
try std.testing.expect(!canSend(0b000)); // nothing set
try std.testing.expect(canSend(0b101)); // tx_empty + overrun: still sendable
try std.testing.expect(!canSend(0b110)); // rx_full + overrun, tx not empty
}
The insight: packed struct(u32) asserts the layout is exactly 32 bits at compile time, so the reserved field is not decoration -- if the widths do not add up, the code does not compile. @bitCast then reinterprets the raw register word with zero runtime cost.
Exercise 3: Push logic into the testable core. A pure baud-rate-to-divisor calculation with correct rounding and a divide-by-zero guard that returns an error in stead of crashing:
const std = @import("std");
const BaudError = error{ ZeroBaud };
/// divisor = round(clock / baud). Pure arithmetic, host-testable, no chip.
fn uartDivisor(clock_hz: u32, baud: u32) BaudError!u32 {
if (baud == 0) return BaudError.ZeroBaud;
// Add half the divisor before dividing to round to nearest instead of down.
return (clock_hz + baud / 2) / baud;
}
test "divisor rounds to nearest and guards zero baud" {
try std.testing.expectEqual(@as(u32, 139), try uartDivisor(16_000_000, 115_200));
try std.testing.expectEqual(@as(u32, 10), try uartDivisor(1_000_000, 100_000)); // exact
try std.testing.expectError(BaudError.ZeroBaud, uartDivisor(16_000_000, 0));
}
The insight is the one the whole embedded arc leans on: a divide-by-zero on a microcontroller is a fault with no friendly stack trace, so we turn it into a Zig error at the boundary and keep the maths in a function zig test can hammer on the host.
Learn Zig Series (#182) - Bare Metal: No OS, No std
What "freestanding" really is: a target, not a dialect
Let us kill the biggest misconception first. When embedded folks say "we write no-std code", it sounds like they switched to some stripped-down subset of the language. They did not. The code is the same Zig. What changed is a single field in the target: os_tag. Set it to freestanding and you are telling the compiler "assume no operating system exists to answer your calls". That is the entire difference. As we saw in episode 35, the target is architecture-os-abi; bare metal simply chooses freestanding for the middle slot.
This is why the exact same idea covers wildly different worlds. A Cortex-M firmware image, an x86-64 kernel that boots on real hardware, a WebAssembly module with no host imports -- all three are freestanding builds. None of them has files, threads, or a general-purpose allocator handed to them for free, because in all three there is no OS underneath to provide those things. Learn the freestanding mindset once and it transfers everywhere.
const builtin = @import("builtin");
const std = @import("std");
// This same source is meaningful on a hosted OS and on bare metal --
// only the branch that gets compiled in changes, and it is chosen at comptime.
pub const running_bare_metal = builtin.os.tag == .freestanding;
comptime {
if (running_bare_metal) {
// No OS: we must supply our own entry, panic handler and memory story.
} else {
// Hosted: std talks to Linux/macOS/Windows for us.
}
}
Notice there is no runtime if here -- builtin.os.tag is comptime-known, so the compiler bakes in exactly one world and the other generates no code. That is the recurring theme of bare-metal Zig: decisions that other languages make with a preprocessor or a build-time hack, Zig makes with ordinary comptime, and they simply vanish from the binary.
The map: what survives, what disappears
So where exactly does the line fall? Here is the mental model I use. Picture std as two layers stacked on top of each other. The bottom layer is everything that eventually calls the operating system -- opening a file, binding a socket, spawning a thread, asking the OS for a page of memory. The top layer is pure computation that never needs anyone's permission: algorithms, data structure logic, formatting, maths, comptime reflection. Freestanding removes the bottom layer and leaves the top layer completely intact.
// What KEEPS working on freestanding (pure, no OS calls):
// std.mem - copy, compare, tokenize, byte twiddling
// std.fmt - formatting values INTO a buffer you provide
// std.math - min/max, pow, trig, saturating and wrapping ops
// std.meta - comptime reflection over your own types
// std.sort - sorting slices you already hold
// std.ArrayList / AutoHashMap - IF you give them an allocator you control
//
// What DISAPPEARS (needs an OS to mean anything):
// std.fs - files: there is no filesystem
// std.net - sockets: there is no network stack
// std.Thread - threads: there is no scheduler
// std.process - argv/env/exit: there is no process model
// std.heap.page_allocator / GeneralPurposeAllocator - no OS to hand out pages
// std.debug.print / std.io.getStdOut - there is no stdout to print to
The happy surprise is how much lands in the "keeps working" column. Every parser, every state machine, every bit of arithmetic we built across this series -- the interpreter, the regex engine, the synth's oscillators -- is pure top-layer code. It does not care whether an OS exists. That is not luck; it is the payoff of a habit the series has drilled since episode 7: keep logic independent of where its bytes come from, and it goes anywhere.
The entry point: there is no main() waiting for you
On a hosted OS, std provides a hidden _start that libc's runtime jumps to; it sets up the stack, prepares argc/argv, and eventually calls your main. On freestanding that glue does not exist. You are the first code that runs, and you must say so explicitly. We met the reset handler on Cortex-M last episode; here is the more general shape, the freestanding entry symbol that any bare-metal target expects:
const std = @import("std");
// `export` gives the symbol a stable, un-mangled name the linker can find and
// the hardware/bootloader can jump to. This is our whole "runtime".
export fn _start() callconv(.c) noreturn {
kernelMain() catch {
// No OS to report an error to -- the honest response is to halt.
hang();
};
hang();
}
fn kernelMain() !void {
// ... the real program lives here ...
}
fn hang() noreturn {
while (true) {
// On real hardware you would issue a low-power wait instruction here.
}
}
Two details carry the whole idea. First, _start is noreturn: on bare metal there is nowhere to return to -- no caller, no shell, no OS to reclaim control. If your top-level function falls through, the only safe thing is to spin, because the alternative is executing whatever random bytes sit next in memory. Second, we mark it export ... callconv(.c) so the symbol name is stable and uses the C calling convention the linker and bootloader agree on. That is the entire "runtime" of a freestanding program: a function you named, that never returns.
No heap: allocating from a buffer you own
Here is the part that scares newcomers most and turns out to be the most fun. There is no malloc. There is no GeneralPurposeAllocator, no page_allocator, because all of those ultimately ask the OS for memory and there is no OS. So... do we just give up on ArrayList and hash maps? Not at all. This is exactly why Zig makes the allocator an explicit parameter (episode 7) in stead of a global -- you hand containers an allocator of your choosing, and on bare metal that allocator hands out slices of a fixed buffer you declared.
const std = @import("std");
// A static, compile-time-sized arena. On real firmware this lives in the .bss
// section the linker reserved for us -- memory we counted before the binary shipped.
var heap_bytes: [16 * 1024]u8 = undefined;
pub fn bareMetalHeapDemo() !void {
var fba = std.heap.FixedBufferAllocator.init(&heap_bytes);
const gpa = fba.allocator();
// ArrayList doesn't know or care that its backing store is a 16 KB static
// buffer instead of the OS heap. Same API, zero OS involvement.
var list = std.ArrayList(u32).init(gpa);
defer list.deinit();
var i: u32 = 0;
while (i < 8) : (i += 1) {
try list.append(i * i); // returns error.OutOfMemory when the buffer fills
}
std.debug.assert(list.items.len == 8);
std.debug.assert(list.items[7] == 49);
}
The FixedBufferAllocator is the quiet hero of embedded Zig. It is a bump allocator over the array you gave it: each alloc moves a cursor forward, and when the buffer is full you get a clean error.OutOfMemory in stead of a mysterious crash. Because the buffer's size is fixed at compile time, your program's memory ceiling is knowable by looking at the source -- no fragmentation, no surprise growth, no OOM killer wandering in at 3am. This is the same trade the ring buffers and memory pools of episodes 113 and 114 made, and on bare metal it is not a niche optimisation -- it is simply how memory works.
If even a bump allocator is more than you need, you can go further and refuse to allocate at all: a std.BoundedArray stores its elements inline, up to a comptime maximum, with no allocator anywhere:
const std = @import("std");
// A stack-like list with a hard capacity and no allocator at all -- the storage
// lives inside the value. Perfect for a fixed-size event queue on an MCU.
pub fn bounded() !void {
var events = try std.BoundedArray(u8, 32).init(0);
try events.append(0xAA);
try events.append(0xBB);
std.debug.assert(events.len == 2);
// append past 32 returns error.Overflow -- again, a value, not a crash.
}
I reach for BoundedArray constantly on constrained targets. It says, right there in the type, "this never exceeds 32 items", the compiler reserves exactly that much space, and the failure mode is an error you handle rather than undefined behaviour you debug with a logic analyzer.
Output when there is no stdout: format into a buffer, push through a Writer
std.debug.print is gone -- it writes to a file descriptor that does not exist. But formatting itself is pure top-layer code, so std.fmt works perfectly; you just have to give it somewhere to put the bytes and then decide where those bytes physically go. The pattern is: format into a fixed buffer, then feed the result to a hardware sink (a UART, a memory-mapped console, a debugger channel).
const std = @import("std");
var log_scratch: [128]u8 = undefined;
// Format into our own buffer -- no OS, no allocation, no stdout.
fn formatTemp(celsius: i32) []const u8 {
return std.fmt.bufPrint(&log_scratch, "temp = {d} C\n", .{celsius}) catch "??\n";
}
test "bufPrint formats without an OS or an allocator" {
const line = formatTemp(21);
try std.testing.expectEqualStrings("temp = 21 C\n", line);
}
std.fmt.bufPrint gives you the full power of Zig's format strings -- {d}, {x}, {s}, padding, the lot -- writing into a buffer you own, returning a slice of exactly the bytes it used, and erroring cleanly if the buffer is too small. Notice we can test the formatting on the host because it touches no hardware at all. Now the shell: we implement a tiny Writer that sends each byte to a peripheral, so the whole std.fmt machinery can print through hardware it never knew about.
const std = @import("std");
// A byte sink over an imaginary memory-mapped UART data register. On real
// silicon the address and the "wait until ready" bit come from the datasheet.
const UartWriter = struct {
const Error = error{};
const Writer = std.io.Writer(*UartWriter, Error, write);
fn writer(self: *UartWriter) Writer {
return .{ .context = self };
}
fn write(self: *UartWriter, bytes: []const u8) Error!usize {
_ = self;
for (bytes) |b| {
// volatile store to the UART data register would go here:
// uart_data.* = b; (see the peripheral episodes ahead)
_ = b;
}
return bytes.len;
}
};
fn logThroughUart(uart: *UartWriter, value: u32) !void {
// std.fmt.format drives OUR writer -- the same formatting code the hosted
// std.debug.print uses, now flowing out a wire instead of to a terminal.
try std.fmt.format(uart.writer(), "value = 0x{X}\n", .{value});
}
This is the whole trick of I/O on bare metal, and it is beautifully anticlimactic once you see it: std's formatting does not know or care where bytes end up. It writes to an interface (std.io.Writer), and you decide that the interface's back end is a UART, a semihosting channel, or -- during host tests -- an in-memory buffer. Same formatting code, three destinations. That interface-over-implementation pattern is exactly the type erasure we built by hand in episode 13, now paying rent on hardware.
Testing firmware you can't run, again
I keep hammering this because it is the single habit that makes embedded work bearable: split a pure core from a paper-thin shell. Everything above obeys it. The divisor maths, the bit decoding, the formatting -- all pure, all host-testable in milliseconds with the full zig test runner. Only the final "store this byte at this volatile address" lives in the shell, and that you eyeball once. During tests you swap the UART sink for a buffer:
const std = @import("std");
// In a host test we point the same log function at an ArrayList-backed writer,
// so we can assert on the EXACT bytes the firmware would have transmitted.
test "log output is exactly the bytes we expect" {
var buf: [64]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try std.fmt.format(stream.writer(), "value = 0x{X}\n", .{255});
try std.testing.expectEqualStrings("value = 0xFF\n", stream.getWritten());
}
The firmware ships pushing bytes at a register; the test captures those same bytes in RAM and checks them. Nothing about the logic differs between the two -- only the last inch of plumbing. The more you push into the pure core, the more of your firmware you cover without ever flashing a chip. For the parts that truly need the metal, QEMU can emulate common boards and semihosting can print on your behalf, but the host-run unit test is always the first and cheapest line of defence.
The same job in C, Rust, and Go
Freestanding is C's home turf, so the comparison is sharp. In C, "no std" is -ffreestanding, and you lose libc's OS-backed functions but keep the language and a tiny "freestanding" header set (<stddef.h>, <stdint.h>, <stdarg.h>). The catch is that C gives you almost nothing on top of that -- no ArrayList, no formatter that is safe to hand a buffer without counting bytes yourself, no BoundedArray. You rebuild those from scratch or pull in a third-party library, and sprintf into a fixed buffer is a classic overflow footgun. Zig hands you the whole pure-computation half of std for free and makes the buffer bounds explicit.
In Rust, #![no_std] is the direct analogue and a genuinely excellent story. You drop std, keep core (the pure layer) and opt into alloc if you provide a global allocator -- almost exactly Zig's split, just spelled with attributes and traits. The difference is ergonomic: Rust's allocator is a global you register, while Zig threads it as an ordinary parameter, so "which allocator does this container use" is answered by reading the call, not by hunting for a #[global_allocator] somewhere in the crate graph. Both give you real containers on bare metal; Zig's version is more explicit and, I would argue, easier to reason about.
In Go, the answer is blunt: standard Go cannot do this. The language is welded to a runtime and a garbage collector that assume an OS with threads and virtual memory, and there is no no_std switch to flip. TinyGo compiles a Go subset to microcontrollers via LLVM and is an impressive piece of engineering, but it is a different compiler with real language restrictions. Zig, by contrast, needs no separate mode and no separate toolchain -- the same zig that builds your web server builds your kernel, and the only thing that changed was one word in the target triple. That, to me, is the quiet superpower of this whole arc.
Where this leaves us, and where we go next
Step back and look at what we untangled. "No OS, no std" is not a diet version of Zig -- it is the ordinary language aimed at a freestanding target, where the OS-backed slice of std falls away and the large pure-computation half keeps working untouched. We wrote our own entry point (a noreturn function that is the runtime), allocated from a fixed buffer with FixedBufferAllocator and refused to allocate at all with BoundedArray, formatted text with std.fmt and pushed it out through a Writer we built ourselves, and -- the part I care about most -- kept nearly all of it testable on the host by keeping the logic pure and the hardware shell paper-thin.
We now have a freestanding binary and we can program comfortably inside it. But we have been leaning on a mystery all along, one I waved at last episode too: the linker script, the map that decides where our .data, our .bss, our static heap buffer and that hand-written entry symbol actually land in flash and RAM. Until we understand that map, "a 16 KB static buffer" is just a hopeful comment. That map is next, and it is what turns a pile of sections into something a specific chip can actually boot. The metal only gets closer from here ;-)
Having said that -- do not just read this one. Everything below builds and tests on your laptop, no chip required, so open your editor and make the compiler prove you followed along.
Exercises
Draw the line yourself. Write a comptime function
fn stdIsHosted() boolthat returnsbuiltin.os.tag != .freestanding, then write a small module that exposes alogfunction which, when hosted, formats into a buffer and (pretend-)prints it, and when freestanding, formats into a buffer and returns the slice. Prove with a hostzig testthat the formatting path produces identical bytes either way -- the only thing that should differ is the final destination.Live within your means. Take the
FixedBufferAllocatordemo and give it only 64 bytes of backing storage. Appendu64values to anArrayList(u64)in a loop until you geterror.OutOfMemory, catch it, and report how many fit. Then repeat with aBoundedArray(u64, N)and compare: which one tells you its capacity at compile time, and which one only finds out at runtime? Write it up in a comment.Bring your own output. Implement a
RingWriterthat satisfiesstd.io.Writerand writes bytes into a fixed 32-byte ring buffer (wrapping when full, exactly the structure from episode 113). Thenstd.fmt.formata few messages through it and, in a host test, read the ring back out and assert on the last 32 bytes that survived. This is a realistic embedded logger: bounded memory, newest-wins, no OS in sight.
Bedankt voor het lezen, en tot de volgende keer! ;-)