Files
zig_tutorial/1_zig_foundations.md
Priec 34082dd908 1
2026-03-06 22:56:59 +01:00

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 @cImport a 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

  1. const = immutable. This is the default — prefer it.
  2. var = mutable. Must be explicitly typed OR assigned from a typed value.
  3. Unused variables are a compile error. Use _ = x; to suppress.
  4. Undefined behavior on use of undefined values — Zig has undefined as 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 const with an appropriate type.
  • Use a single std.debug.print call 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) i32
  • sub(a: i32, b: i32) i32
  • mul(a: i32, b: i32) i32
  • divFloor(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 — no switch yet (that's Chapter 03).
  • Use @mod(i, 3) for modulo (not % — in Zig, % is the remainder operator and @mod is 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, and zig 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.