zig / under the hood
DUE 000 DECK 000 STREAK 0d LESSONS 0/14 saved in this browser only
0x01
Part I · The machine

Memory, addresses & layout

Memory is one long array of bytes. Endianness, alignment and padding decide where yours go.

The big picture: one long row of boxes

Picture memory as a very long street of mailboxes. Each mailbox holds exactly one byte, and each has a number painted on it: its address. The first is 0, the next is 1, and so on into the billions. That’s the whole model your program sees. When your code says x = 42, the compiler has picked a mailbox (or a few neighbouring ones) for x, and the CPU writes the bits of 42 into it. When you read x, the CPU goes to that address and reads the bits back.

Pointers are just addresses

A pointer is a variable whose value is an address. If x lives at address 0x1000, a pointer to x simply holds the number 0x1000. Following the pointer to get at the value (called dereferencing) means going to that address and reading what’s there.

var x: u32 = 42;
const p: *u32 = &x; // &x means "the address of x"
p.* = 7; // p.* means "the value p points to"
// x is now 7

On a 64-bit computer an address is 64 bits, so every pointer is 8 bytes no matter what it points to. A pointer to one byte and a pointer to a huge struct are the same size. Why do pointers matter? They let different parts of a program refer to the same data without copying it, and they’re how you build anything that grows or changes shape at runtime: lists, trees, text read from a file.

Bigger values span several boxes: endianness

One byte holds 0 to 255. A u32 needs 4 bytes, so it occupies 4 neighbouring addresses, and the variable’s address is the first of them. But which part of the number goes in the first box? Take 0x11223344. Its most significant byte (the “big end”) is 0x11 and its least significant byte (the “little end”) is 0x44. There are two conventions:

value 0x11223344 stored at address 0x1000

address:        0x1000  0x1001  0x1002  0x1003
little-endian:    44      33      22      11      little end first
big-endian:       11      22      33      44      big end first

Little-endian puts the least significant byte first. It’s what x86-64 (Intel, AMD) and ARM (phones, Apple Silicon) use, so it’s what your machine does. Big-endian puts the most significant byte first, which matches how we write numbers on paper. Many network protocols use it, so it’s also called “network byte order”. Most of the time you never notice, because the CPU loads and stores a whole u32 for you and handles the order. It matters when you look at raw bytes: reading a file format, parsing a network packet, or staring at a hex dump and wondering why your number looks backwards. When it matters, Zig makes the order explicit: std.mem.readInt(u32, bytes, .big) reads 4 bytes as a big-endian number on any machine.

Alignment: values like to start on round addresses

The CPU doesn’t really fetch memory one byte at a time. It fetches chunks (8, 16 or 64 bytes), and each chunk starts at an address that’s a multiple of its size. A value that fits entirely inside one chunk can be read in one go. A value that straddles two chunks may need two reads plus stitching together, and some CPUs refuse to do that at all and crash. So every type has an alignment: its address should be a multiple of that number. For simple types the alignment equals the size: | Type | Size | Alignment | Allowed addresses | u8 | 1 | 1 | any | u16 | 2 | 2 | 0, 2, 4, 6… | u32 | 4 | 4 | 0, 4, 8, 12… | u64 | 8 | 8 | 0, 8, 16, 24… The compiler handles alignment for you. You see its effect in one place: padding inside structs.

Padding: the hidden bytes in your structs

A struct stores its fields one after another. Suppose you write a C-style struct with a u8, then a u32, then another u8, and follow the compiler field by field:

  1. a: u8 goes at offset 0 and takes 1 byte.
  2. b: u32 needs an offset that’s a multiple of 4. Offset 1 isn’t, so the compiler skips 3 bytes of padding and puts b at offsets 4 to 7.
  3. c: u8 goes at offset 8.
  4. The struct’s own alignment is the biggest of its fields’ (4), and its size must be a multiple of that. So 3 more bytes of tail padding bring the size to 12. That’s 12 bytes to store 6 bytes of data. Put the big field first and most of the padding disappears:
Byte offsets · striped cells are padding
extern struct { a: u8, b: u32, c: u8 } → 12 bytes
a
pad
pad
pad
b
b
b
b
c
pad
pad
pad
01234567891011
extern struct { b: u32, a: u8, c: u8 } → 8 bytes
b
b
b
b
a
c
pad
pad
01234567891011

Why does the tail padding exist? Picture an array of these structs. Element 1 starts right where element 0 ends. If the size were 9, element 1’s b would sit at 9 + 4 = 13, which isn’t a multiple of 4. Rounding the size up to 12 keeps every element in the array aligned. Zig has three kinds of struct, and the difference between them is exactly this layout question:

  • struct: Zig decides the layout and may reorder fields to remove padding. You don’t have to think about it, but you can’t rely on any particular order in memory.
  • extern struct: fields go in the order you wrote them, using C’s rules. Use it when the layout has to match something outside your program: a C library, a file format, a hardware spec.
  • packed struct: no padding at all; fields are squeezed together bit by bit. That’s in the C interop lesson. You can check any layout yourself with three builtins:
const std = @import("std");

const Bad = extern struct { a: u8, b: u32, c: u8 };
const Good = extern struct { b: u32, a: u8, c: u8 };

pub fn main() void {
    std.debug.print("Bad:  size {d}, align {d}\n", .{ @sizeOf(Bad), @alignOf(Bad) });
    std.debug.print("Good: size {d}, align {d}\n", .{ @sizeOf(Good), @alignOf(Good) });
    std.debug.print("offset of b in Bad: {d}\n", .{@offsetOf(Bad, "b")});
}
// Bad:  size 12, align 4
// Good: size 8, align 4
// offset of b in Bad: 4

Recap

  • Memory is a long row of bytes, each with an address. A pointer is a variable holding an address.
  • Values bigger than a byte span several addresses. Your machine is little-endian: lowest byte first.
  • Each type has an alignment, and values should sit at addresses that are a multiple of it.
  • The compiler adds padding to keep fields aligned, so field order changes the size of an extern struct.
  • @sizeOf, @alignOf and @offsetOf show you the real layout.
Try it

Print the addresses of a few local variables with std.debug.print("{*}\n", .{&x}). Are they next to each other? Which direction do they go? Do the same for a u8, a u64 and a u16 declared in that order and look for gaps.

14 flashcards for this lesson

not started
See the questions
  1. At the machine level, what is a pointer?
  2. Bytes of the u32 0x11223344 in memory, lowest address first, on x86-64?
  3. What is network byte order?
  4. What does an alignment of N require?
  5. Size of extern struct { a: u8, b: u32, c: u8 }, and why?
  6. Why do structs get tail padding?
  7. Zig struct vs extern struct vs packed struct?
  8. Which builtins inspect a type's layout in Zig?
  9. What do &x and p.* mean in Zig?
  10. Why are all pointers on a 64-bit machine the same size?
  11. Why can a misaligned value be slow, or crash?
  12. What do &x and p.* mean in Zig?
  13. Why are all pointers on a 64-bit machine the same size?
  14. Why can a misaligned value be slow, or crash?