13 KiB
Chapter 01 — Zig Foundations
Background assumed: You write embedded Rust. You know what a stack frame is. You've fought the borrow checker. You're not afraid of unsafe.
Goal: Get Zig installed, understand the project structure, read and write basic Zig, and map it to what you already know from Rust.
1.1 What Is Zig and Why Does It Matter to You
Zig is a systems language with three core design goals:
- No hidden control flow. No operator overloading, no destructors, no implicit anything.
- Comptime instead of macros/generics. One powerful mechanism replaces Rust's trait system, macros, and const generics.
- First-class C interop. You can
@cImporta C header and call it directly, no binding generator needed.
For embedded developers specifically: Zig produces freestanding binaries, has zero runtime, compiles to any LLVM target (including bare-metal ARM, RISC-V, AVR), and its build.zig can replace your entire Makefile + linker script toolchain.
vs Rust — the key philosophical difference
| Topic | Rust | Zig |
|---|---|---|
| Safety model | Compiler enforces memory safety | You are responsible; tools help |
| Generics | Traits + monomorphization | comptime — run code at compile time |
| Error handling | Result<T, E>, ? operator |
Error unions !T, try |
| Allocations | Often implicit via Box, Vec |
Always explicit, always passed in |
| Build system | Cargo | build.zig — it's just Zig code |
| C interop | bindgen + unsafe block |
@cImport — done |
| Hidden control flow | Destructors run on drop | Nothing runs unless you write it |
1.2 Installing Zig
Zig releases frequently. Always use the latest stable or nightly (master) depending on your needs. For learning, use stable.
# Option 1: Official binary (recommended)
# Download from https://ziglang.org/download/
# Extract and add to PATH
# Option 2: zigup (version manager, like rustup)
# https://github.com/marler8997/zigup
zigup 0.14.0
# Verify
zig version
# zig 0.14.0 (or similar)
Editor support
# ZLS — Zig Language Server (equivalent to rust-analyzer)
# https://github.com/zigtools/zls
# VS Code: install the "Zig Language" extension, it auto-downloads ZLS
# Neovim: use mason.nvim or build ZLS manually
1.3 Project Structure
Hello World from scratch
mkdir zig-learn && cd zig-learn
zig init
This generates:
zig-learn/
├── build.zig ← Build script (written in Zig)
├── build.zig.zon ← Package manifest (like Cargo.toml)
└── src/
├── main.zig ← Executable entry point
└── root.zig ← Library entry point
build.zig.zon — the manifest
.{
.name = "zig-learn",
.version = "0.0.1",
.dependencies = .{},
.paths = .{""},
}
This is a Zig data structure, not TOML/JSON. The .field = value syntax is called a struct literal — you'll see it everywhere.
build.zig — the build script
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "zig-learn",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
}
Think of build.zig as a Rust build.rs that also replaces Cargo.toml's [profile] and [[bin]] sections — but it's a full programming language, not a config file.
Common build commands
zig build # Build (output in zig-out/)
zig build run # Build and run
zig build test # Run tests
zig build -Doptimize=ReleaseSafe # Release with safety checks
zig build -Doptimize=ReleaseFast # Release, max speed
zig build -Doptimize=ReleaseSmall # Release, min size (embedded!)
zig build -Dtarget=thumbv7m-freestanding-none # Cross-compile
ReleaseSafe is Zig's equivalent of Rust's release mode — runtime panics still fire on overflow and out-of-bounds. ReleaseFast turns them off (like unsafe everywhere). ReleaseSmall is your friend for microcontrollers.
1.4 Your First Zig File
// src/main.zig
const std = @import("std"); // Import standard library
pub fn main() void {
std.debug.print("Hello, Zig!\n", .{});
}
Breaking this down
| Piece | What it is |
|---|---|
const std = @import("std") |
Import a module. @import is a builtin function (all builtins start with @) |
pub fn main() void |
Public function, no return value. pub = visible to other files |
std.debug.print(...) |
Print to stderr. Takes a format string and a tuple of args |
.{} |
Anonymous struct literal (empty tuple here) |
Format strings work like this:
const x: u32 = 42;
const name = "world";
std.debug.print("x={d}, name={s}\n", .{ x, name });
// {d} = decimal integer
// {s} = string
// {any} = print anything (debug format)
// {x} = hex
// {b} = binary
This is similar to println!("{x}={}", name, x) in Rust, but the args are a tuple, not variadic macros.
1.5 Variables and Constants
const x = 42; // Immutable, type inferred as comptime_int
var y: u32 = 10; // Mutable, explicit type
y += 1; // OK
const z: f32 = 3.14; // Immutable float
Key rules
const= immutable. This is the default — prefer it.var= mutable. Must be explicitly typed OR assigned from a typed value.- Unused variables are a compile error. Use
_ = x;to suppress. - Undefined behavior on use of undefined values — Zig has
undefinedas a value but using it is UB.
var buf: [64]u8 = undefined; // Declared but not initialized
// In debug builds, Zig fills this with 0xAA to catch bugs early
This is like Rust's MaybeUninit<[u8; 64]>, but less ceremonial.
Types at a glance
// Integers
u8, u16, u32, u64, u128 // unsigned
i8, i16, i32, i64, i128 // signed
usize, isize // pointer-sized (like Rust's usize/isize)
u1, u7, u21 // arbitrary bit widths! great for embedded
// Floats
f16, f32, f64, f80, f128
// Boolean
bool // true / false
// Special comptime types (only exist at compile time)
comptime_int // untyped integer literal
comptime_float // untyped float literal
type // a type IS a value at comptime
// No-value
void // like Rust's ()
noreturn // function never returns (like Rust's !)
1.6 Functions
fn add(a: u32, b: u32) u32 {
return a + b;
}
pub fn main() void {
const result = add(3, 4);
std.debug.print("result={d}\n", .{result});
}
Differences from Rust
// Rust: last expression is the return value
fn add(a: u32, b: u32) -> u32 {
a + b // implicit return
}
// Zig: explicit return always
fn add(a: u32, b: u32) u32 {
return a + b; // required
}
Zig does allow block expressions that evaluate to a value, but functions always need return.
Function parameters are immutable
fn increment(x: u32) u32 {
x += 1; // COMPILE ERROR: cannot assign to constant
return x + 1; // do this instead
}
If you need to mutate, copy into a local var:
fn process(data: u32) u32 {
var local = data;
local += 1;
return local;
}
1.7 Control Flow Overview
Full detail in Chapter 03, but here's enough to write exercises:
// if / else
const x: i32 = 10;
if (x > 5) {
std.debug.print("big\n", .{});
} else {
std.debug.print("small\n", .{});
}
// if as expression
const label = if (x > 5) "big" else "small";
// while
var i: u32 = 0;
while (i < 5) : (i += 1) {
std.debug.print("{d}\n", .{i});
}
// The `: (i += 1)` is the "continue expression" — runs after each iteration
// for over a range
for (0..5) |i| {
std.debug.print("{d}\n", .{i});
}
// for over a slice
const arr = [_]u32{ 10, 20, 30 };
for (arr) |val| {
std.debug.print("{d}\n", .{val});
}
1.8 The @import System and Multiple Files
// src/math.zig
pub fn square(x: u32) u32 {
return x * x;
}
// src/main.zig
const std = @import("std");
const math = @import("math.zig"); // relative path
pub fn main() void {
std.debug.print("{d}\n", .{math.square(5)});
}
@import returns a struct type containing all pub declarations of the file. This is how Zig does modules — no mod keyword, no use paths, just files and imports.
1.9 Panic and Safety
Zig has runtime safety checks in Debug and ReleaseSafe modes:
- Integer overflow → panic
- Out-of-bounds slice access → panic
- Null pointer dereference → panic
- Unreachable code reached → panic
var x: u8 = 255;
x += 1; // panic: integer overflow (in Debug/ReleaseSafe)
// wraps silently in ReleaseFast/ReleaseSmall
For embedded, you can define a custom panic handler:
pub fn panic(msg: []const u8, ...) noreturn {
// blink LED, log to UART, halt
while (true) {}
}
This is the equivalent of Rust's #[panic_handler] — and it works the same way.
1.10 Zig vs Rust — Quick Syntax Cheatsheet
| Concept | Rust | Zig |
|---|---|---|
| Immutable binding | let x = 5; |
const x = 5; |
| Mutable binding | let mut x = 5; |
var x: u32 = 5; |
| Function | fn foo(a: u32) -> u32 {} |
fn foo(a: u32) u32 {} |
| Import | use std::fmt; |
const fmt = @import("std").fmt; |
| Struct | struct Foo { x: u32 } |
const Foo = struct { x: u32 }; |
| Enum | enum Color { Red, Green } |
const Color = enum { red, green }; |
| Print debug | println!("{:?}", x) |
std.debug.print("{any}\n", .{x}) |
| Assert | assert!(x == 1) |
std.debug.assert(x == 1) |
| Unreachable | unreachable!() |
unreachable |
| Cast | x as u32 |
@intCast(x) or @as(u32, x) |
| Block return | Implicit last expr | break :blk value |
| Undefined | MaybeUninit |
undefined |
| Panic | panic!("msg") |
@panic("msg") |
Exercises
These exercises are meant to be written from scratch in a fresh zig init project. Resist looking up anything beyond what's in this chapter.
Exercise 1 — Hello, Details
Modify the generated main.zig to print the following exactly:
Name: HAL-9000
Version: 0.1.0
Debug: true
Requirements:
- Store each value in a
constwith an appropriate type. - Use a single
std.debug.printcall with one format string and a tuple of all three values. - Use
{s}for strings,{d}for the version numbers (print major and minor separately as integers),{any}for the boolean.
Exercise 2 — Basic Arithmetic Functions
Create a file src/calc.zig with the following four functions, all pub:
add(a: i32, b: i32) i32sub(a: i32, b: i32) i32mul(a: i32, b: i32) i32divFloor(a: i32, b: i32) i32— integer division, truncating toward zero
In main.zig, import calc.zig and print the results of:
add(100, -42)sub(0, 99)mul(-3, 7)divFloor(17, 5)
Expected output:
add: 58
sub: -99
mul: -21
div: 3
Exercise 3 — Loops and Accumulation
Write a function sumUpTo(n: u32) u64 that returns the sum of all integers from 0 to n inclusive, using a while loop with a continue expression.
Then write a second version sumUpToFor(n: u32) u64 using a for loop over a range.
Call both with n = 100 and print the results. They should both print 5050.
Bonus: Add a comptime assert inside main that the value for n = 10 equals 55. (Hint: call the function with a comptime argument — if the function can be evaluated at comptime, Zig will do it.)
Exercise 4 — FizzBuzz, Zig Style
Print FizzBuzz from 1 to 30:
- Divisible by 15 → print
FizzBuzz - Divisible by 3 → print
Fizz - Divisible by 5 → print
Buzz - Otherwise → print the number
Requirements:
- Use a
for (1..31)range loop. - Use
if/else if/else— noswitchyet (that's Chapter 03). - Use
@mod(i, 3)for modulo (not%— in Zig,%is the remainder operator and@modis true modulo for signed integers).
Exercise 5 — Separate Build Target
In your build.zig, add a second executable called "calc-runner" that compiles src/calc.zig as a standalone binary with its own main function (you'll need to add a pub fn main() to calc.zig temporarily, or create src/calc_main.zig).
Add a build step called "calc" that runs it:
zig build calc # should run your second binary
This exercise is about getting comfortable with build.zig as code, not config.
Summary
By the end of this chapter you should be able to:
- Install Zig and set up ZLS in your editor
- Use
zig init,zig build, andzig build run - Read and write basic Zig syntax — variables, functions, loops
- Import across multiple files with
@import - Understand the difference between
Debug,ReleaseSafe,ReleaseFast,ReleaseSmall - Map core Rust concepts to their Zig equivalents
- Add a second build target to
build.zig
Next: Chapter 02 — Types & Variables — where we go deep on Zig's type system, arbitrary-width integers, optionals, type coercion rules, and comptime_int vs concrete types.