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

Bits, bytes & integers

Everything is a pattern of bits. The type decides what the pattern means.

Start here: what a computer actually stores

A computer’s memory is made of billions of tiny switches, each either off or on. We write off as 0 and on as 1, and call one switch a bit (short for binary digit). That’s all the hardware stores. Text, images, numbers, your program’s own code: all of it is patterns of bits, and some piece of software has agreed on what each pattern means. Bits are grouped in eights. A group of 8 bits is a byte, and it’s the smallest piece of memory a CPU can point to and read on its own. When a file is “2 KB”, it holds about 2,000 bytes.

Counting in binary

You count in base 10: each digit is worth 10 times the one to its right (ones, tens, hundreds). Binary is the same idea in base 2: each bit is worth twice the one to its right. In a byte, numbering bits from the right starting at 0: | Bit position | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | Worth | 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 To read a byte, add up the worths of the bits that are 1. Take 0000_1101:

  1. Bit 0 is 1, worth 1.
  2. Bit 2 is 1, worth 4.
  3. Bit 3 is 1, worth 8.
  4. Total: 1 + 4 + 8 = 13. The underscore is only there to make it readable; Zig allows it inside number literals too. With all 8 bits on you get 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255, so one byte holds 0 through 255: 256 different values. In general, n bits give 2ⁿ different values. That’s why powers of two show up everywhere in computing: 256, 1024, 65,536. To go the other way, from decimal to binary, repeatedly take the biggest worth that still fits. 200 = 128 + 64 + 8, so bits 7, 6 and 3 are on: 1100_1000.

One byte, many meanings

This is the most important idea in the lesson: bits don’t know what they mean. The byte 1111_1011 could be the number 251, the number −5, part of a colour, or one quarter of a bigger number. The meaning comes from the type your program uses when it reads the byte. In Zig, u8 means “read this byte as an unsigned number from 0 to 255” and i8 means “read it as a signed number from −128 to 127”. Same byte, different answer. Click the bits below to flip them and watch one pattern read three ways at once.

One byte · click bits to flip them
binary
1111_1011
hex
0xFB
as u8
251
as i8
-5

Hexadecimal: shorthand for bits

Long rows of 0s and 1s are hard to read, so programmers write bits in hexadecimal (base 16). The hex digits are 0–9 and then A–F, where A = 10, B = 11, and so on up to F = 15. The reason hex is so popular: one hex digit is exactly 4 bits, because 4 bits have 2⁴ = 16 possible values. So you can convert by splitting a byte into two halves of 4 bits (each half is called a nibble):

binary:   1111 1011
hex:        F    B       → 0xFB
decimal:  15 × 16 + 11 = 251

The 0x prefix means “hex number”, just as 0b means “binary number”. You’ll see hex in memory addresses (0x7ffd5e3c), colours (#FF8800) and debugger output, because a programmer can read the bits straight off it without any arithmetic.

Negative numbers: two’s complement

Memory only holds bits, so there’s nowhere to put a minus sign. Computers solve this with a scheme called two’s complement, and the rule is simpler than the name: in a signed byte, the top bit (bit 7) is worth −128 instead of +128. Every other bit keeps its normal worth. Read 1111_1011 as a signed number:

  1. Bit 7 is on: −128.
  2. Bits 6, 5, 4, 3, 1 and 0 are on: 64 + 32 + 16 + 8 + 2 + 1 = 123.
  3. Total: −128 + 123 = −5. In the same way, 1111_1111 is −128 + 127 = −1. Whenever the top bit is 1 the result is negative, which is why it’s called the sign bit. To negate a number by hand: flip every bit, then add 1.
  5   = 0000_0101
flip  = 1111_1010
 + 1  = 1111_1011   = −5

Why this scheme and not something more obvious? Because it keeps the hardware simple:

  • There’s exactly one zero (0000_0000). Older schemes had both +0 and −0.
  • Addition needs no special cases. Add 5 and −5: 0000_0101 + 1111_1011 = 1_0000_0000. The 9th bit doesn’t fit in a byte and falls off, leaving 0, which is the right answer. The same adding circuit works for signed and unsigned numbers.
  • An n-bit signed number ranges from −2ⁿ⁻¹ to 2ⁿ⁻¹ − 1. For 8 bits that’s −128 to 127: one more negative than positive, because zero takes up one of the non-negative patterns.

When a number doesn’t fit: overflow

A u8 can’t hold 256. If you add 1 to 255, the true answer needs 9 bits (1_0000_0000), and the 9th bit has nowhere to go. In hardware it simply falls off and you’re left with 0. This is called overflow, or wrapping around, like a car’s odometer rolling from 999999 back to 000000. In C, overflow silently wraps (or for signed numbers, is “undefined behaviour”), and that has caused countless bugs. Zig treats overflow as a mistake unless you say you want it:

  • Plain + that overflows: in Debug and ReleaseSafe builds the program stops with an “integer overflow” panic pointing at the line. In ReleaseFast it’s illegal behaviour that the optimizer assumes never happens.
  • +% means “wrap around on purpose”. Useful for hashes, checksums and counters that are meant to roll over.
  • +| means “saturate”: stick at the maximum or minimum instead of wrapping.

Widening: zero extension vs sign extension

Sometimes you move a value into a bigger type, say from 8 bits to 16. The new high bits have to be filled with something, and what’s correct depends on signedness:

u8   200 = 1100_1000  →  u16: 0000_0000_1100_1000   zero extension: fill with 0
i8    −5 = 1111_1011  →  i16: 1111_1111_1111_1011   sign extension: copy the sign bit

If you filled −5 with zeros you’d get 251, which is wrong. Copying the sign bit into the new bits keeps it −5. Zig does the right one automatically when you assign a smaller integer to a bigger type.

Integers in Zig

Zig names integer types by signedness and width: u8 is unsigned 8-bit, i32 is signed 32-bit. Unlike most languages, any width works: u1, u3, i7, u128. usize is the size of a pointer (64 bits on a 64-bit machine) and is what Zig uses for lengths and indexes. Zig never shrinks a number behind your back. Going from a bigger type to a smaller one needs a builtin that says what you mean:

  • @intCast: “this value fits, convert it”. Safe builds check, and panic if it doesn’t fit.
  • @truncate: “keep only the low bits, throw the rest away”.
  • @bitCast: “don’t change a single bit, just read them as another type of the same size”.
const std = @import("std");

pub fn main() void {
    var x: u8 = 250;
    x +%= 10; // wrapping add: 260 doesn't fit, wraps to 4
    std.debug.print("wrapped: {d}\n", .{x});

    var y: u8 = 250;
    y +|= 10; // saturating add: stops at 255
    std.debug.print("saturated: {d}\n", .{y});

    const neg: i8 = -1;
    const bits: u8 = @bitCast(neg); // same bits 1111_1111, read as u8: 255
    const wide: i32 = neg; // sign-extended automatically, still -1
    const low: u8 = @truncate(@as(u32, 0x1234)); // keeps the low byte, 0x34
    std.debug.print("{d} {d} 0x{x}\n", .{ bits, wide, low });
}

Recap

  • A bit is 0 or 1, a byte is 8 bits, and n bits give 2ⁿ values.
  • Bits have no meaning by themselves; the type decides how they’re read.
  • One hex digit is 4 bits, so a byte is always two hex digits.
  • Two’s complement: the top bit is worth −2ⁿ⁻¹. Negate by flipping all bits and adding 1.
  • Overflow panics in Zig’s safe builds. Use +% to wrap or +| to saturate when that’s what you mean.
Try it

Predict @as(i8, @bitCast(@as(u8, 200))) before running it. (200 − 256 = −56.) Then set the byte above to 200 and check.

15 flashcards for this lesson

not started
See the questions
  1. How many distinct values can n bits represent?
  2. Range of u8 and of i8?
  3. The byte 0xFF read as u8 and as i8?
  4. How do you negate a two's complement number by hand?
  5. Why do CPUs use two's complement for signed integers?
  6. How many bits does one hex digit represent?
  7. Zero extension vs sign extension?
  8. In Zig, what happens when a u8 holding 255 gets + 1 at runtime?
  9. @intCast vs @truncate vs @bitCast?
  10. Convert 0000_1101 to decimal.
  11. Convert 200 to binary.
  12. What is the sign bit?
  13. Convert 0000_1101 to decimal.
  14. Convert 200 to binary.
  15. What is the sign bit?