Assembly, from Zero: x86-64 and ARM64
A guidebook for software engineers. Third in the series, after the Rust performance guidebook and the SIMD guidebook.
By the end of this guidebook you will be able to do eight things:
- Read the assembly that compilers, debuggers, profilers and crash dumps show you, and know what each line is doing.
- Write functions by hand on x86-64 and ARM64 that obey the calling convention, run, and can be called from C or Rust.
- Decode instruction bytes by hand, because you will know how both machines pack an instruction into bytes.
- Predict what a loop costs, because you will know how a CPU really runs instructions (not just how the manual says it does).
- Reason about atomics and memory ordering on both a strong machine (x86-64) and a weak one (ARM64).
- Embed assembly in Rust with
asm!,global_asm!and naked functions, and know when not to. - Generate assembly from your own compiler, and build a dispatch loop and a tiny JIT for your own interpreter.
- Teach all of it, from a five-minute explanation to a talk for 500 people.
If you finish the capstones, you will not feel "I read about assembly." You will feel "I can sit in front of a machine I have never seen, and it will not scare me."
A note on "I". This guidebook was written by an AI (Claude), working in a sandbox with real tools. When the text says "I ran this," "I measured that" or "I could not check this," it means exactly that: the command was run, or the check was not possible, during the writing. Nothing in it is a story about a human author's past. If you publish this under your own name, change the "I" where it feels wrong to you, but keep the honesty: say what you ran yourself and what you did not.
How to use this guidebook
Every chapter has the same shape, and every shape answers three questions:
| Layer | Question | What it gives you |
|---|---|---|
| The model | What is this? | You can define it, explain it, teach it. |
| The build | How do I make it work? | You can write it, run it, debug it, measure it. |
| The reason | Why is it this way? | You can improve it, and invent new ideas. |
Each chapter walks through these in order: The problem first, The model, Build it, Why it is this way, Common mistakes, Say it simply, and Check yourself.
Do the exercises. Reading alone is half the learning. Each chapter ends with exercises, and each is marked with a level:
- Warm-up: do it right after reading. It checks that the words and the picture landed.
- Core: needs the chapter's main ideas used together. Do all of these.
- Stretch: needs extra thinking or a longer build. For when you want more.
Many exercises start with Predict, then check. Write your prediction down on paper first. The gap between what you predicted and what you saw is where the learning happens.
How code is marked. Every code block says which machine it is for. If I ran it while writing this guidebook, the output below it is the real output. If I could not run it, it says Not run: check this on your machine. I never show made-up output as real. Where a number is only an illustration, it is labeled as one.
A note on voice. This guidebook says "we" when we do a step together, "you" when I talk to you, and "I" only for honest opinions and honest limits, like "I could not check this on real hardware."
What you need before you start
Skills. You can program in some language (Rust or C is best). You are fine with a terminal. You know what hex is (0xFF is 255). That's all. We build everything else.
Default platform. Linux on x86-64, and ARM64 side by side. Where macOS or Windows differ, a box says so.
Tools. On Ubuntu or Debian (or WSL2 on Windows):
sudo apt install binutils gcc clang llvm gdb strace xxd qemu-user \
binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu
# Rust: install with rustup (https://rustup.rs). Use 1.88 or newer for
# naked functions (Chapter 35). Everything else needs only 1.59+. If you are on Apple silicon, you already have an ARM64 machine. Use Docker or a Linux VM for the x86-64 examples. If you have no ARM64 machine, qemu-aarch64 runs ARM64 Linux programs on your x86-64 laptop.
Versions used to write and check this guidebook (the sandbox was Ubuntu 24.04, checked in October 2026):
| Tool | Version |
|---|---|
GNU binutils (as, ld, objdump) | 2.42 |
| gcc | 13.3.0 |
| clang / llvm-mca | 18.1.3 |
| gdb | 15.1 |
| qemu-user (ARM64 on x86-64) | 8.2.2 |
| strace | 6.8 |
| rustc | 1.75.0 (the sandbox's; newer is fine, and needed for Chapter 35) |
Newer tools behave the same for everything we use.
The machine behind the measurements. An Intel Xeon @ 2.80 GHz, one core, x86-64, in a rented virtual machine. Your numbers will differ. The shapes will not: a branch miss will still hurt, a cache miss will still hurt more.
One more thing, and I want to be straight about it. While I was writing, the hardware under my virtual machine changed. The CPU flags it reported were different in different sessions, and some measurements moved by up to 2 times (others, such as the cost of a dependent add or the 9-times penalty for a random branch, did not move at all). So I took the numbers that are meant to be compared with each other back to back in one session, I repeated the important ones, and wherever two sessions disagreed I tell you both. Read the note at the start of Part 8, and treat every absolute number in this guidebook as "an example from one machine," and every ratio as the thing to trust.
What I could not check. I want to be honest about this up front:
- ARM64 code ran under
qemu-user. That is an emulator. It gives correct results, but not real ARM64 timing and not real weak-memory behavior. - The sandbox had one core. So I could not catch a real memory-ordering reorder, or show false sharing. Chapters 27 and 31 explain this and give you the tests to run on your own multi-core machine.
- The virtual machine did not expose hardware performance counters (
perf statprinted<not supported>), so everyperfcommand in this guidebook is marked as not run. - Naked functions (Rust 1.88+) were not run, because the sandbox compiler was 1.75.
Study paths
"I have a weekend." Part 1 (Chapters 1 to 4), then Chapter 5, Chapter 10 to 12, and Chapter 14 to 15. You will be able to read disassembly and write a tiny function.
"I want to read compiler output well." Part 1, Part 2 (Chapters 6 and 7 especially), Part 3, Chapter 14 to 16, Chapter 19, then Part 8 and Chapter 33.
"I want to write a compiler backend or a JIT." Parts 1 to 4, Chapters 18 to 20, Chapter 24, then all of Part 12.
"I want to give a talk." Part 1, Chapter 12, Chapter 15, Chapter 26, Chapter 31, then the Teaching Kit.
"I want to master it." Everything in order. Plan about 13 weeks, with exercises. The Teaching Kit has a schedule.
Table of contents
- Chapter 1. What assembly is, and why you would learn it
- Chapter 2. The machine: one loop, a few registers, one big array
- Chapter 3. Two machines: x86-64 and ARM64
- Chapter 4. Your toolchain and your first two programs
- Chapter 5. Reading assembly: the vocabulary and the method
Part 2: Memory and Data Movement
- Chapter 6. Moving data: memory,
mov, widths and constants - Chapter 7. Addressing modes: how an address is built
- Chapter 8. The stack is just memory with a register in charge
- Chapter 9. Data in memory: sections, structs and padding
Part 3: Control Flow, Flags and Branches
- Chapter 10. Flags: one-bit answers to comparison questions
- Chapter 11. Branches and loops
- Chapter 12. Branch prediction and branchless code
- Chapter 13. Jump tables and indirect branches
Part 4: Functions and the Calling Convention
- Chapter 14. The calling convention: a contract between strangers
- Chapter 15. The frame: prologue, epilogue, alignment and unwinding
- Chapter 16. Writing real functions by hand
- Chapter 17. The ABI zoo: other systems, other rules
- Chapter 18. Integers: add, subtract, carry and overflow
- Chapter 19. Multiply and divide: the expensive operations
- Chapter 20. Shifts, rotates and bit tricks
- Chapter 21. Floating point: the other register file
- Chapter 22. Strings and memory:
strlen,memcpyand the speed ladder - Chapter 23. Talking to the kernel: system calls, the start-up stack and a
catclone
Part 7: How Instructions Become Bytes
- Chapter 24. x86-64 encoding: prefix, opcode, ModRM, SIB
- Chapter 25. ARM64 encoding: 32 bits, fields and fit
Part 8: How the CPU Really Runs Your Code
- Chapter 26. Out-of-order execution: latency, throughput and the dependency chain
- Chapter 27. The memory hierarchy: caches, parallel misses and what the prefetcher does
- Chapter 28. Measuring honestly: benchmarks, models and the tools
Part 9: SIMD from the Assembly Side
Part 10: Concurrency at the Instruction Level
- Chapter 30. Atomic read-modify-write:
lock, load-exclusive and LSE - Chapter 31. Memory ordering: the strong machine and the weak machine
Part 11: The Toolchain, Compilers and Rust
- Chapter 32. Objects, relocations and linking
- Chapter 33. What compilers do: reading optimized output
- Chapter 34. Finding the instruction behind a crash
- Chapter 35. Assembly inside Rust:
asm!,global_asm!and naked functions
Part 12: Assembly for Language Builders
- Chapter 36. A compiler backend in 130 lines: from expressions to assembly on two machines
- Chapter 37. Interpreter dispatch:
switch, computed goto and tail calls - Chapter 38. A tiny JIT: writing machine code into memory and calling it
Part 13: Putting It All Together
Part 1: The Machine
After this Part you can read a small compiled function, run and break a program on both machines, and say what a CPU does in one loop. This is the foundation. Every later Part stands on it.
Chapter 1. What assembly is, and why you would learn it
In this chapter: You will see the problem assembly was invented to solve. You will learn what an assembler really does, and why this skill is still useful when compilers are so good. You will also learn what "assembly" does not mean: it is not one language.
You need first: Nothing.
The problem first
A CPU only understands numbers. A program, at the lowest level, is a long list of numbers in memory. Each number (or small group of numbers) tells the CPU to do one small thing.
Picture a programmer who writes those numbers by hand. Here is a tiny real program. It jumps over two do-nothing instructions and returns:
address bytes what it means
0 eb 02 jump forward 2 bytes
2 90 nop (do nothing)
3 90 nop (do nothing)
4 c3 ret <-- the jump lands here Now you decide you need one more nop. Add it, and look what happens:
address bytes what it means
0 eb 03 jump forward 3 bytes <-- you had to change this
2 90 nop
3 90 nop
4 90 nop
5 c3 ret The 02 had to become 03. Those are real bytes from the GNU assembler. By hand, you would have to find every jump and every data reference that crosses your edit, and fix each one. Miss one, and the program jumps into the middle of an instruction.
The hard part was never the numbers. The hard part was that one small edit moved everything after it.
The fix is old and simple. Give each place a name, and let a tool count the bytes. The names are called labels. The tool is called an assembler. You write jmp done, and the assembler works out eb 02 or eb 03 for you.
The model (the "what")
Machine code is the numbers the CPU reads. Assembly language is the same thing, written with names instead of numbers. An assembler is the program that turns the names back into numbers.
you write the assembler produces the CPU sees
--------- ---------------------- ------------
add rax, rbx --> 48 01 d8 --> "add the register rbx into rax" Those bytes are real: I assembled this line and looked at the result with objdump. Notice what the assembler did not do. It did not optimize. It did not check your logic. One line becomes (mostly) one instruction. The line between "assembly" and "machine code" is a text editor.
Three things change from one machine to the next, and you must keep them apart:
| What changes | What it means | Examples |
|---|---|---|
| ISA (instruction set architecture) | The list of instructions and registers the CPU understands. | x86-64, ARM64, RISC-V |
| Syntax | How a human spells those instructions. | Intel syntax, AT&T syntax |
| OS convention | How a program talks to the operating system and to other code. | Linux, macOS, Windows |
So "assembly" is not one language. It is a family. This guidebook teaches the two ISAs that run almost everything today: x86-64 (most servers, desktops and laptops) and ARM64 (phones, Apple silicon, many cloud chips). Holding both in your head at once is the trick. You will see what is true of every CPU, and what is just one company's taste.
Why learn it at all? Compilers are very good. So why? There are four reasons that last.
- To read. Profilers, debuggers, crash dumps and Compiler Explorer all end in assembly. If you cannot read it, those tools go silent exactly when the problem gets hard. This is the most common use, and the first thing we build.
- To know what the machine can do. A compiler can only use instructions that exist. If you do not know that the CPU has an instruction that adds eight numbers at once, you cannot ask for it.
- To write where it matters. Some code cannot be written in a high-level language. The first instructions that run when the kernel is entered, the code that switches between threads, and parts of crypto libraries are written by hand.
- To debug what only exists down there. A stack that is not aligned. A function that breaks a calling convention. A value that lost its upper bits. You cannot see these in source code. In assembly they are obvious.
Build it (the "how")
Let's do the single most useful habit in this whole guidebook: predict, then look.
Take this C function. Before reading further, write down what you think the x86-64 assembly will be.
int add(int a, int b) { return a + b; } Now here is what gcc -O2 produced on this guidebook's machine, for both ISAs:
# x86-64, gcc 13.3 -O2 (Intel syntax)
add:
lea eax, [rdi+rsi]
ret // ARM64, gcc 13.3 -O2
add:
add w0, w0, w1
ret Did you expect add eax, ... on x86-64? Most people do. The compiler used lea, an instruction whose name means "load effective address." It is an address calculator that the compiler borrows to do addition, because it can write to a different register than it reads. We meet it in Chapter 7. For now, the lesson is: the compiler knows tricks you do not yet know. Let's try two more.
long mul3(long x) { return x * 3; }
long mul10(long x) { return x * 10; } # x86-64
mul3: lea rax, [rdi+rdi*2] # x + x*2
ret
mul10: lea rax, [rdi+rdi*4] # x + x*4 = x*5
add rax, rax # x*5 * 2 = x*10
ret // ARM64
mul3: add x0, x0, x0, lsl 1 // x + (x << 1)
ret
mul10: add x0, x0, x0, lsl 2 // x + (x << 2) = x*5
lsl x0, x0, 1 // *2 = x*10
ret Neither machine used a multiply instruction. A multiply is slower than a shift and an add, so the compiler spelled x * 10 as (x + x*4) * 2. Both machines got the same idea in different words. This is the kind of thing you will start to see once you can read.
Why it is this way (the "why")
Why did anyone invent assemblers? Because of the problem at the top: moving addresses. Everything beyond labels (directives, macros, relocations, linkers) exists to keep one promise: the human writes names, and the tool computes numbers. You will meet this promise again in Chapter 32 when we open the linker.
Here is a claim you will hear: "in assembly, the machine does exactly what you wrote." It is half true, so let's be careful. The compiler no longer moves your work around. That part is true. But the CPU still does. A modern CPU runs many instructions at once, out of order, and guesses which way branches will go. It is only required to make the result look as if it ran your instructions one by one. So assembly removes the compiler's tricks. It does not remove the CPU's tricks. Part 8 is about those.
What would have to be true for "assembly is obsolete" to be correct? It would have to be true that no bug and no slowdown ever crosses the line between your source code and the machine. But they do, all the time. A function that reads a long as an int. A struct with padding you did not expect. A loop that never got vectorized. Each of these is easy to see in assembly and invisible in source.
Common mistakes
- Thinking assembly is one language. It is not. An x86-64 program is noise to an ARM64 chip. What transfers is the mental model: registers, memory, flags, the stack, the calling convention.
- Thinking hand-written assembly beats the compiler. For general code it usually loses. A compiler tries thousands of tricks per second and never gets tired. Hand-written assembly wins only when you know something the compiler cannot (a special instruction, a calling convention it does not know, a timing rule).
- Thinking you must write big programs. You will write small ones. The real prize is reading big ones.
Say it simply
Assembly is machine code with names. An assembler is a tool that turns the names back into numbers and counts the addresses for you. Each kind of CPU has its own assembly, so it is a family and not one language. You learn it mostly to read what compilers and debuggers show you, and to understand what the machine can really do.
Check yourself
Exercise 1.1 (Warm-up): Explain. In five sentences, explain to a friend what an assembler does. Use one analogy and say where it breaks. What a strong answer includes: it turns names into numbers; it counts addresses so edits do not break jumps; it does not optimize; one line is roughly one instruction; an analogy (a phone's contact list turns names into numbers) with a limit (a contact list does not compute distances).
Exercise 1.2 (Warm-up): Predict, then check. Write a file with jmp done, then five nop lines, then done: ret. Predict the bytes of the jmp. Then assemble and check. Now put 200 nops in the middle (use .rept 200 and .endr). Predict again.
Setup: as -o j.o j.s && objdump -d -M intel j.o, with .intel_syntax noprefix at the top. A correct result: the first jump is 2 bytes (eb 05). The second one is longer, because a 1-byte distance cannot reach 200. Hint: look at the first byte.
Exercise 1.3 (Core): Predict, then check. Predict the x86-64 -O2 output for each of these, then check on Compiler Explorer or with gcc -O2 -S -masm=intel: long mul5(long x), unsigned long div4(unsigned long x), unsigned long mod8(unsigned long x). A correct result: no imul, div or idiv appears in any of them. (On this guidebook's machine: lea, then shr, then and.) Write one sentence for each saying why the compiler's trick is valid.
Exercise 1.4 (Core): Compare the machines. Compile long f(long a, long b) { return a*2 + b; } for x86-64 and for ARM64 (aarch64-linux-gnu-gcc -O2 -S). Both fit in two instructions. Without looking anything up, say what the one line of each does. A correct result: x86-64 uses lea to do a scaled add; ARM64 uses add with a shifted operand (lsl 1). Both fold the "times two" into the add.
Exercise 1.5 (Stretch): Design and defend. A manager says: "Assembly is obsolete. We will remove it from interviews." Write a short, fair answer that agrees with what is true in this and says what is lost. What a strong answer includes: writing it by hand is rare and shrinking; reading it is needed more each year (profilers, crash dumps, closed-source binaries, compiler output); a person who can read it debugs faster; the cost of not knowing it shows up only on hard bugs.
Chapter 2. The machine: one loop, a few registers, one big array
In this chapter: You will learn the one mental model that every later chapter uses. A CPU is a small box with a few storage cells, connected to a very large memory, running one loop. You will build a toy CPU in Rust (about 40 lines) so the loop is not abstract.
You need first: Chapter 1.
The problem first
A modern CPU has billions of transistors. You cannot reason about that. Yet programmers reason about programs on it every day, and are right. How?
Because there is a contract: a small, simple picture of the machine. If the hardware keeps the contract, you never need to think about the transistors. This chapter is about that picture.
The model (the "what")
Here is the whole picture. A CPU has:
- a few storage cells inside it, called registers;
- a connection to a very large memory, which is a long row of bytes;
- one special register, the program counter, that says which instruction is next (it is called
ripon x86-64 andpcon ARM64); - a few flags: single bits that remember facts about the last operation, such as "the result was zero."
The program itself lives in memory as ordinary bytes. This is called the stored-program idea: the instructions and the data live in the same memory.
The CPU runs one loop, forever:
+----------------------------------------------+
| |
v |
+---------+ +---------+ +----------+ +--+------+
| FETCH |---->| DECODE |---->| EXECUTE |---->| ADVANCE |
+---------+ +---------+ +----------+ +---------+
read the work out do it: compute, move the pc
bytes at pc what they mean or read/write to the next
memory instruction Look at the diagram and find the one place where control flow lives. It is the last box. Normally "advance" moves the program counter to the next instruction. A branch (or jump) is just an instruction that sets the program counter to somewhere else instead. That is the only way a program ever changes what it does next. Loops, if, function calls, exceptions, even the operating system switching between programs: all of them come down to "something set the program counter."
Now the cells themselves. Registers are the fastest storage there is. The next instruction can use a register value almost immediately. But they are few:
| x86-64 | ARM64 | |
|---|---|---|
| General-purpose integer registers | 16 (rax, rbx, ... r15) | 31 (x0 to x30), plus a stack pointer and a zero register |
| Vector / floating-point registers | 16 (xmm0-xmm15), 32 with AVX-512 | 32 (v0-v31) |
Why so few? Because every instruction has to name its registers, and names cost bits. Sixteen registers need 4 bits per name. Thirty-one need 5. That is why a 32-bit instruction can still name three registers on ARM64 (Chapter 25). A machine with four hundred registers would make every instruction bigger, for a benefit that most code cannot use. When a program needs more values than there are registers, the compiler spills some into memory and loads them back later. You will see spill code in every large function you read.
Memory is the opposite: huge and slow. Here are numbers measured on this guidebook's machine, by chasing a chain of pointers so each load must wait for the one before it:
| Working set | Time per dependent load |
|---|---|
| 16 KiB (fits in the first cache) | about 1.7 ns |
| 256 KiB | about 5 ns |
| 8 MiB | 37 ns in one session, 107 to 137 ns in others (see the note below) |
| 256 MiB | about 150 to 190 ns |
A register is available in less than one nanosecond. Memory that missed every cache took about 100 times longer. (About the 8 MiB row: my first run read 121 ns. A second run read 37 ns, and I wrongly called the first one noise. A third session read about 137 ns again. The real cause was that my rented virtual machine had been moved between different physical hosts, which have different shared caches. Part 8 tells that story, because it is a lesson in itself. For now, take the lesson to be the two ends of the table: a first-level hit is under 2 ns, and main memory is 100 times slower.) The gap between those two is where most of performance engineering lives. Chapter 27 returns to it.
One rule follows: to compute on a value, it must be in a register. (x86-64 bends this a little. Some instructions can take one operand directly from memory, but the CPU still loads it first.)
Three small facts will save you pain later.
- Instructions are data. A program can read its own bytes. A program can also write bytes and then run them. That is exactly what a JIT compiler does (Chapter 38). Modern systems restrict this with page permissions, but the idea underneath is that code is just bytes.
- The loop is a contract, not the silicon. Real CPUs run many instructions at once and out of order. The loop above is what they promise looks true from outside. Part 8 shows what is really inside.
- The state is small. At any moment, everything the machine "knows" is: all the registers, all the flags, all of memory, and the program counter. A debugger shows you exactly this. A bug is a gap between the state you believe the machine is in and the state it is really in. Assembly gives you no place to hide a wrong belief.
An analogy that helps: think of a cook at a kitchen counter. The counter is the registers: small, but everything on it is within reach. The pantry is memory: big, but each trip takes time. The cook's finger on the recipe line is the program counter. Now the limits of the analogy. In the kitchen the recipe is on paper, separate from the food. Here the recipe is stored in the pantry too, and the cook can edit it. Also a real cook can read ahead and prepare the next steps. A CPU does exactly that, and has to hide it so the result looks as if no one read ahead.
Build it (the "how")
The best way to understand a loop is to build it. Here is a toy CPU in Rust. It has 4 registers, 256 bytes of memory, a program counter, one flag, and 5 instructions. The program sums the numbers 1 to 10. Notice that the program is stored in the same memory array as everything else.
// A toy CPU: 4 registers, 256 bytes of memory, 5 instructions.
const HALT: u8 = 0; // HALT (1 byte)
const LOADI: u8 = 1; // LOADI reg, number (3 bytes)
const ADD: u8 = 2; // ADD reg, reg (3 bytes)
const DEC: u8 = 3; // DEC reg (2 bytes) sets the zero flag
const JNZ: u8 = 4; // JNZ address (2 bytes) jump if zero flag is 0
fn main() {
let mut mem = [0u8; 256];
let program: [u8; 14] = [
1, 0, 0, // 0: LOADI r0, 0 sum = 0
1, 1, 10, // 3: LOADI r1, 10 i = 10
2, 0, 1, // 6: ADD r0, r1 sum += i <- loop starts here
3, 1, // 9: DEC r1 i -= 1
4, 6, // 11: JNZ 6 if i != 0, go back to address 6
0, // 13: HALT
];
mem[..program.len()].copy_from_slice(&program); // the program lives in memory
let mut reg = [0u8; 4];
let mut pc: usize = 0; // program counter
let mut zero = false; // the one flag
let mut steps = 0;
loop {
let opcode = mem[pc]; // FETCH
steps += 1;
match opcode { // DECODE + EXECUTE
LOADI => { reg[mem[pc + 1] as usize] = mem[pc + 2]; pc += 3; }
ADD => { let (d, s) = (mem[pc + 1] as usize, mem[pc + 2] as usize);
reg[d] = reg[d].wrapping_add(reg[s]); pc += 3; }
DEC => { let r = mem[pc + 1] as usize;
reg[r] = reg[r].wrapping_sub(1); zero = reg[r] == 0; pc += 2; }
JNZ => { if !zero { pc = mem[pc + 1] as usize; } else { pc += 2; } }
HALT => break,
_ => panic!("bad opcode {} at {}", opcode, pc),
}
if steps <= 7 {
println!("step {steps}: after it, pc={pc:2} r0={:3} r1={:2} zero={zero}", reg[0], reg[1]);
}
}
println!("halted after {steps} steps: r0 = {} (sum of 1..=10 is 55)", reg[0]);
} Compile with rustc -O -o toy toy.rs and run it. This is the real output from this guidebook's machine:
step 1: after it, pc= 3 r0= 0 r1= 0 zero=false
step 2: after it, pc= 6 r0= 0 r1=10 zero=false
step 3: after it, pc= 9 r0= 10 r1=10 zero=false
step 4: after it, pc=11 r0= 10 r1= 9 zero=false
step 5: after it, pc= 6 r0= 10 r1= 9 zero=false
step 6: after it, pc= 9 r0= 19 r1= 9 zero=false
step 7: after it, pc=11 r0= 19 r1= 8 zero=false
halted after 33 steps: r0 = 55 (sum of 1..=10 is 55) Look at step 5. After the JNZ at address 11, the program counter went back to 6. That is the loop. Nothing else makes a loop. And the count adds up: 2 setup steps, then 10 trips through 3 instructions, then 1 HALT: 2 + 30 + 1 = 33.
This toy is missing almost everything a real CPU has. But it has the shape. Every machine in this guidebook is this loop with more instructions, wider registers, and a lot of cleverness hidden underneath.
Why it is this way (the "why")
Why store the program in memory with the data? Before the late 1940s, many machines were re-wired or re-plugged by hand for each new program. The stored-program idea, discussed in the mid-1940s and working in machines like the Manchester "Baby" in 1948, made a program just another thing you could load, copy and change. That one decision is why software exists as a separate thing from hardware.
The price is that code and data are the same stuff. So a bug that writes to the wrong address can rewrite a program, and an attacker who can write data can try to run it. Almost every modern security rule about memory (non-executable pages, write-protected code) exists to bring back a separation that the stored-program idea gave away.
Why is "set the program counter" the only control mechanism? Because it is the smallest possible one. With it you can build every kind of control flow. A function call is "save where to come back to, then set the pc." An interrupt is "save the state, then set the pc to a fixed handler." A context switch between two programs is "save all state of one, load all state of the other, set the pc." One small idea, used many times.
Common mistakes
- Thinking registers have types. A register is just 64 bits. Whether those bits mean a signed number, an unsigned number, an address or a float is decided by the instruction that uses them, not by the register.
- Thinking memory is fast. A cache hit is, and a miss is not. Chapter 27 has the numbers.
- Thinking the loop is literally what happens. It is the promise. What happens is cleverer.
Say it simply
A CPU is a few fast storage cells (registers) next to a huge slow memory. The program is stored in that memory as bytes. The CPU repeats one loop: fetch the instruction at the program counter, decode it, execute it, and move the counter on. A jump is just an instruction that sets the counter somewhere else. That is the only way control ever changes.
Check yourself
Exercise 2.1 (Warm-up): Trace by hand. In the toy CPU, change LOADI r1, 10 to LOADI r1, 5. Predict how many steps the machine takes and what r0 ends as. Then run it. A correct result: 2 + 5×3 + 1 = 18 steps, and r0 = 15.
Exercise 2.2 (Warm-up): Explain. In five sentences, explain the stored-program idea to a friend. Say why "instructions are data" is both useful and dangerous. Give one analogy and where it breaks. What a strong answer includes: code and data share memory; that lets programs load and generate programs (compilers, JITs); it also lets a bad write change code, so systems add permissions; the cook-and-recipe analogy fails because a real recipe is not stored in the pantry.
Exercise 2.3 (Core): Break it on purpose. In the toy CPU, move the program so that after HALT at address 13 you put the byte values 1, 0, 99 (a LOADI r0, 99 instruction), and change the HALT into a NOP-like instruction of your own that just advances pc by 1. What does the machine do when it reaches the new bytes? What did you just show about code and data? A correct result: it runs the extra bytes as an instruction, because nothing marks those bytes as "data."
Exercise 2.4 (Core): Implement. Add two instructions to the toy CPU: LOAD reg, address and STORE reg, address. Write a program that adds four numbers stored at addresses 100 to 103 and stores the total at address 104. Run it. Done when: the total is in memory at address 104, and you have printed the bytes of memory from 100 to 104.
Exercise 2.5 (Core): Predict, then check. Using the latency table above, a loop does one dependent load per step from a 256 MiB working set, and 100 million steps. Predict its run time. Then think: how long would it take if the same data fit in the first cache? A correct result: roughly 15 to 19 seconds versus roughly 0.17 seconds, a gap of about 100 times. (Your machine will differ. The shape will not.)
Exercise 2.6 (Stretch): Design and defend. The toy CPU has 4 registers. Add a fifth and a sixth. What must change in the instruction format? When would more registers stop paying off? What a strong answer includes: register numbers are stored in the instruction, so more registers need more bits; our toy uses a whole byte, so it costs nothing here, but in a real encoding every bit counts; the benefit shrinks because few programs need many live values at once.
Chapter 3. Two machines: x86-64 and ARM64
In this chapter: You will learn the honest comparison of the two instruction sets we use. You will see real numbers that contradict a common story. And you will learn the syntax dialects, so you never again lose an hour to "why are the operands backwards?"
You need first: Chapter 2.
The problem first
Why learn two machines? Because learning one makes you think "this is how a CPU works." Learning two shows you which parts are the idea of a CPU and which are one company's taste. A reader who has seen both can pick up a third in a weekend.
The model (the "what")
x86-64 descends from the 8086 chip of 1978, through an unbroken chain of backward compatibility. It grew in steps: 16 bits, then 32, then 64 (AMD designed the 64-bit step, and Intel adopted it). It is called a CISC (complex instruction set computer) design. Instructions vary in length. One instruction can do several things.
ARM64 (also called AArch64; its instruction set is called A64) was announced in 2011 as a deliberate restart. ARM dropped most of its own 32-bit past. It is a RISC (reduced instruction set computer) design. Every instruction is exactly 4 bytes. Most do one thing. Memory is touched only by dedicated load and store instructions.
Here is a side-by-side view. The numbers in the last row come from real measurement, which I'll explain below.
| x86-64 | ARM64 | |
|---|---|---|
| Instruction length | 1 to 15 bytes | exactly 4 bytes |
| Arithmetic can read memory? | yes, one operand | no, load first |
| Integer registers | 16 | 31, plus sp and a zero register |
| Zero register | none | xzr (reads 0, writes vanish) |
| Flags | FLAGS register (CF, ZF, SF, OF...) | NZCV (N, Z, C, V) |
| Conditional jump | jcc | b.cond, and compare-and-branch (cbz) |
| Syscall instruction | syscall | svc #0 |
| Average instruction length in a real library | 4.16 bytes | 4.00 bytes |
That last row surprised me, and it will probably surprise you. The common story says x86-64 code is denser because its instructions are variable length. So I measured it. I disassembled the C library (libc.so.6) on this guidebook's machine and measured the length of all 385,673 x86-64 instructions in it. The average was 4.16 bytes. ARM64's average is 4 by definition. The variable-length design did not buy density here. It bought a long tail: about 4% of instructions are 1 byte, and a few are 11 bytes or more, but most sit between 2 and 7.
(One honest note. The x86-64 and ARM64 libraries come from different builds, so their instruction counts are not a fair comparison. The average length of x86-64 instructions is a fair measurement of that one library.)
Syntax dialects. Each machine has more than one way to spell instructions. For x86-64, you will constantly meet two:
- Intel syntax:
mov rax, rbx. The destination is on the left. Used by Intel's manuals, and by this guidebook. - AT&T syntax:
mov %rbx, %rax. The source is on the left, registers start with%, constants start with$. This is the default ofobjdumpandgdbon Linux.
They are the same machine. Here is the real output of the same bytes in both:
$ objdump -d -M intel x.o $ objdump -d x.o
0: 48 01 d8 add rax,rbx 0: 48 01 d8 add %rbx,%rax A big share of beginner confusion in disassembly is nothing more than this flip.
We will write Intel syntax, and tell the GNU assembler so with .intel_syntax noprefix at the top of every file. There is one catch: the GNU assembler wants the word ptr when you give a size. NASM writes byte [rsi]. The GNU assembler in Intel mode wants byte ptr [rsi]. Without it you get this real error:
Error: ambiguous operand size for `cmp' For ARM64 there is effectively one syntax. Mnemonics are lowercase in output (add) and uppercase in manuals (ADD). They are identical.
A third machine, in one box. RISC-V is an open instruction set that you will meet more and more. It has 32 registers where x0 is always zero, instructions that are 32 bits (with an optional 16-bit short form), and no flags register at all: a branch compares two registers directly. Knowing x86-64 and ARM64 makes RISC-V a weekend of reading. We do not teach it here.
Build it (the "how")
Let's watch the two machines solve the same two tiny problems. First, a*2 + b:
# x86-64 (real gcc 13.3 -O2 output)
f: lea rax, [rsi+rdi*2] # rax = rsi + rdi*2 (one instruction)
ret // ARM64 (real gcc 13.3 -O2 output)
f: add x0, x1, x0, lsl 1 // x0 = x1 + (x0 << 1) (one instruction)
ret Now the one that shows the real difference: add 1 to a number in memory. In C that is (*p)++.
# x86-64
inc: add QWORD PTR [rdi], 1 # one instruction: load, add, store
ret // ARM64
inc: ldr x1, [x0] // load
add x1, x1, 1 // add
str x1, [x0] // store
ret x86-64 does it in one instruction and ARM64 in three. That is the "arithmetic can read memory" row in the table, made visible. Here is the catch, and it is a good thing to hold onto: how an ISA is spelled is not how it runs. Inside a modern x86-64 core, that one instruction is split into smaller steps (a load, an add and a store) that look a lot like the three ARM64 ones. Chapter 26 shows how. So "one instruction" does not mean "one unit of work."
Why it is this way (the "why")
Both designs are answers to the same question: what do we make cheap, and what do we push somewhere else?
x86-64 made compatibility cheap. Code from 1985 still runs. The price was a complicated encoding. Instruction boundaries are ambiguous (a disassembler that starts in the wrong place produces plausible nonsense; Chapter 24 shows it). The decoder is a hard piece of hardware.
ARM64 made decoding cheap. Because every instruction is 4 bytes, finding where the next one starts is trivial, and a core can look at many instructions at once. The price was flexibility. An instruction cannot hold a big constant, or a long jump distance. So loading a 64-bit constant takes up to four instructions, and building an address takes two (Chapters 6 and 7).
Neither design is "better." Each pays in a different currency. A fair way to say it: the engineers made a bet about which costs would matter in twenty years, and both bets have worked out well enough to run the world.
One more idea to carry forward. ARM64 has a zero register: reading xzr gives 0, and writing to it throws the value away. That sounds small. But code needs zero constantly: to clear a value, to compare against zero, to discard a result. On x86-64 you spend a real instruction on it (xor eax, eax). On ARM64 it is free. A small choice, repeated in every program, is what an ISA is.
Common mistakes
- Reading AT&T and Intel as different machines. If the operands look backwards, check the syntax first.
- Believing "CISC means dense code." Measure before you believe: 4.16 bytes on average here.
- Believing "one instruction means one cheap step." It does not. See Chapter 26.
Say it simply
x86-64 is the old, compatible, variable-length machine. ARM64 is the newer, regular, fixed-length machine. They solve the same problem with different trade-offs, and neither is simply better. They also have different spellings (Intel and AT&T for x86-64), so the same bytes can look backwards. Learn both and you can see which ideas belong to every CPU.
Check yourself
Exercise 3.1 (Warm-up): Translate. Convert these Intel-syntax lines to AT&T by hand, then check with as and objdump: mov rax, rbx, add rax, 16, mov rax, [rbx], lea rsi, [rdi+rcx*4]. A correct result: operands swap; registers get %; numbers get $; memory like [rbx] becomes (%rbx); and [rdi+rcx*4] becomes (%rdi,%rcx,4).
Exercise 3.2 (Warm-up): Explain. In five sentences, explain why (*p)++ is one instruction on x86-64 and three on ARM64, and then explain why "one instruction" is misleading. What a strong answer includes: x86-64 allows a memory operand in arithmetic; ARM64 allows memory only in loads and stores; the x86-64 core internally breaks it into smaller steps; so the work is the same, and the difference is in how the instruction set packs it.
Exercise 3.3 (Core): Break it and fix it. Write the line cmp [rsi], 0 in a GNU assembler Intel-syntax file. Read the error. Fix it two ways (byte ptr and dword ptr) and look at the bytes of each. A correct result: the error says the operand size is ambiguous. The two fixes give different instructions (a different opcode and a different immediate size), because the assembler cannot guess how many bytes you meant to compare.
Exercise 3.4 (Core): Measure code density. Take a C file of at least a few hundred lines (any one you have). Compile it for x86-64 and for ARM64 with -O2 -c, and compare the .text sizes with size. Then write down what you expected before you ran it. A correct result: the two sizes are within a modest percentage of each other. A big gap means something else differs (different inlining, a different unroll). If you see one, find out why.
Exercise 3.5 (Stretch): Design and defend. Suppose you are designing a new ISA. Pick one: (a) 64 registers and 5-byte instructions, or (b) 16 registers and 3-byte instructions. For a CPU that runs mostly big loops over arrays, defend your pick against the other. What a strong answer includes: the cost of register names in bits; spills versus encoding size; decode width; instruction cache pressure; and a statement of which workload you assumed.
Chapter 4. Your toolchain and your first two programs
In this chapter: You will assemble, link, run, break, trace and debug a real program on both machines. You will learn how a program talks to the operating system. And you will see what the OS did before your first instruction ran.
You need first: Chapters 2 and 3.
The problem first
Your program only knows how to compute. It cannot touch the screen, a file, or the network. Those belong to hardware, and your code is not allowed near hardware. So how does hello, world ever reach a terminal?
The answer is that you ask the operating system, politely, with one special instruction. Everything else in this chapter is the detail of how.
The model (the "what")
A system call (syscall) is a function call into the kernel. You put a small number in a register that says which service you want. You put the arguments in other registers. Then you run one special instruction, and the kernel does the work for you.
Two syscalls are enough to build the whole first part of assembly programming: write (put bytes on a file descriptor; 1 means standard output) and exit (tell the OS you are done, with a status number).
Here is the contract on Linux. This is the real convention, from the kernel:
| x86-64 | ARM64 | |
|---|---|---|
| Instruction that enters the kernel | syscall | svc #0 |
| Syscall number goes in | rax | x8 |
| Arguments go in | rdi, rsi, rdx, r10, r8, r9 | x0, x1, x2, x3, x4, x5 |
| Result comes back in | rax | x0 |
read / write / exit numbers | 0 / 1 / 60 | 63 / 64 / 93 |
| Registers the kernel destroys | rcx, r11, and rax | only x0 |
(An error comes back as a small negative number, which is -errno.)
Read that table twice, especially the last row. On x86-64 the syscall instruction itself uses rcx and r11 to save where to return. So both are destroyed. This will bite you in a minute. Also notice that the fourth argument is r10, not rcx.
The numbers differ because ARM64 uses a newer, tidier table, while x86-64 keeps the order it grew into. Neither is "right." They are just different histories.
A box for other systems: on macOS, you normally do not make syscalls directly. You call functions in the system library. (The raw ARM64 macOS door is svc #0x80 with the number in x16, and the numbers differ from Linux.) On Windows, there is no stable public syscall number at all. You call functions in kernel32.dll and ntdll.dll. The Linux way is the cleanest one to learn on.
Build it (the "how")
Three tools make up the whole workflow, and all three exist on any Linux system:
- The assembler (
as): a text file in, an object file out. The object file has your instructions plus notes saying "patch this address once we know where things live." - The linker (
ld): joins object files, picks final addresses, applies those notes. It makes an executable. - The disassembler (
objdump -d): machine code back to text. Use it constantly. The fastest way to learn what compilers do is to read what they produce.
Here is the complete x86-64 program. Print a message, then exit.
# hello_x86.s -- Linux x86-64, Intel syntax, no libc
.intel_syntax noprefix
.global _start # the name the linker uses as the entry point
.section .rodata # read-only data
msg:
.ascii "hello, machine\n"
msg_len = . - msg # "." means "here", so this is the byte count
.section .text # code
_start:
# write(1, msg, msg_len)
mov rax, 1 # syscall number: write
mov rdi, 1 # file descriptor 1 = standard output
lea rsi, [rip+msg] # address of the message
mov rdx, msg_len # how many bytes
syscall
# exit(0)
mov rax, 60 # syscall number: exit
xor edi, edi # status 0
syscall And the same program on ARM64:
// hello_arm.s -- Linux ARM64, no libc
.global _start
.section .rodata
msg:
.ascii "hello, machine\n"
msg_len = . - msg
.section .text
_start:
// write(1, msg, msg_len)
mov x0, 1 // file descriptor 1 = standard output
adrp x1, msg // the page that holds msg
add x1, x1, :lo12:msg // plus the offset inside that page
mov x2, msg_len // how many bytes
mov x8, 64 // syscall number: write
svc #0
// exit(0)
mov x0, 0 // status 0
mov x8, 93 // syscall number: exit
svc #0 Assemble, link, run:
as -o hello_x86.o hello_x86.s && ld -o hello_x86 hello_x86.o
./hello_x86 ; echo "exit status: $?"
aarch64-linux-gnu-as -o hello_arm.o hello_arm.s
aarch64-linux-gnu-ld -o hello_arm hello_arm.o
qemu-aarch64 ./hello_arm # or just ./hello_arm on a native ARM64 machine Real output of both on this guidebook's machine:
hello, machine
exit status: 0
hello, machine Now let's read the x86-64 program one line at a time, because each line teaches something you will use for the rest of your life.
mov rax, 1copies the number 1 into the registerrax. The namemovis a lie. Nothing moves. The value is copied, and the source stays.lea rsi, [rip+msg]computes an address instead of loading from one.leameans "load effective address." The form[rip+msg]means "the address ofmsg, measured from where this instruction itself lives." Read it asrsi = &msg.mov rdx, msg_lencopies the length. The assembler computed it for us:.means "the current position," so. - msgis the distance from the start of the string to here, which is the string's length. That is 15, and it matters. (Counthello, machineplus the newline: 14 characters and one newline make 15.)syscallhands control to the kernel. The kernel uses your registers as its arguments. You decide what you want, not how the bytes get written.xor edi, edimakesedizero. This is the standard way to write zero on x86-64. It is 2 bytes. We will see whyediand notrdiin Chapter 6.
Now the real bytes. Here is what objdump -d -M intel shows for the program we just built:
401000: 48 c7 c0 01 00 00 00 mov rax,0x1
401007: 48 c7 c7 01 00 00 00 mov rdi,0x1
40100e: 48 8d 35 eb 0f 00 00 lea rsi,[rip+0xfeb] # 402000 <msg>
401015: 48 c7 c2 0f 00 00 00 mov rdx,0xf
40101c: 0f 05 syscall
40101e: 48 c7 c0 3c 00 00 00 mov rax,0x3c
401025: 31 ff xor edi,edi
401027: 0f 05 syscall Three things to see here. The mov rdx, 0xf shows the assembler computed the length as 15 (0xf). The lea is a 7-byte instruction. And check the address math: the instruction after the lea is at 0x401015. Add 0xfeb, and you get 0x402000, which is msg. A rip-relative distance is always measured from the end of the instruction. Also notice that mov rax, 1 took 7 bytes. Chapter 6 shows why that is wasteful.
On ARM64 the same exercise gives (real output):
400078: d2800020 mov x0, #0x1
40007c: 90000001 adrp x1, 400000
400080: 91027021 add x1, x1, #0x9c
400084: d28001e2 mov x2, #0xf
400088: d2800808 mov x8, #0x40
40008c: d4000001 svc #0x0
400090: d2800000 mov x0, #0x0
400094: d2800ba8 mov x8, #0x5d
400098: d4000001 svc #0x0 Every instruction is exactly 4 bytes. The adrp and add pair builds the address of msg in two steps, because one 4-byte instruction cannot hold a full address. Chapter 7 explains.
Break it on purpose
Now the most valuable part. Change one thing at a time, and watch what happens. These are real results from this machine.
| Change | What happened |
|---|---|
mov rax, 1 becomes mov rax, 2 (syscall 2 is open, not write) | No output. Exit status 0. The kernel tried to open a file and failed, and we never checked. |
mov rax, 60 becomes mov rax, 39 (getpid) | Message prints. Then the program runs off the end of our code into zero bytes and dies with SIGSEGV (status 139). strace shows getpid() = 1311 and then the crash. |
mov rdi, 300 before exit | The shell sees status 44. A status is one byte, so 300 becomes 300 mod 256. |
Add ud2 (a defined "undefined instruction") | SIGILL, status 132. |
jmp to an address in .data | SIGSEGV, status 139. Data pages are not executable. |
(Shells report a death by signal as 128 plus the signal number: 128+11 is 139, and 128+4 is 132.)
The lesson from the first row is the big one. The kernel trusts your register contract completely. There is no type check. If the number in rax is wrong, you get a different service, not an error message. That is the world you now program in. (I deliberately did not suggest changing 60 to 93 on x86-64. Syscall 93 there is fchown, which changes the owner of a file descriptor, and that is not something to run casually.)
Let's use two more tools to see what the machine did. First strace, which prints every syscall a program makes:
$ strace ./helloC
execve("./helloC", ["./helloC"], ...) = 0
write(1, "hello, machine\n", 15hello, machine
) = 15
getpid() = 1311
+++ killed by SIGSEGV +++ Then gdb, which shows the machine state at any moment:
$ gdb -q -batch -ex 'break _start' -ex run -ex 'info registers rip rsp' \
-ex 'x/4i $pc' -ex stepi -ex stepi -ex 'info registers rax rdi' \
-ex 'x/s &msg' ./hello_x86
Breakpoint 1, 0x0000000000401000 in _start ()
rip 0x401000 0x401000 <_start>
rsp 0x7fffffffea40 0x7fffffffea40
=> 0x401000 <_start>: mov $0x1,%rax
0x401007 <_start+7>: mov $0x1,%rdi
0x40100e <_start+14>: lea 0xfeb(%rip),%rsi # 0x402000
0x401015 <_start+21>: mov $0xf,%rdx
rax 0x1 1
rdi 0x1 1
0x402000: "hello, machine\n" Notice gdb prints AT&T syntax by default (mov $0x1,%rax). To flip it, run set disassembly-flavor intel once, or put it in ~/.gdbinit. That is Chapter 3's warning, alive.
The trap
Here is a bug nearly everyone writes once. We want to print hi three times, so we keep the count in rcx:
mov rcx, 3 # counter
again:
mov eax, 1 # write
mov edi, 1
lea rsi, [rip+msg]
mov edx, 3
syscall # <-- this destroys rcx
dec rcx
jnz again How many lines should it print? Three. What did it print? In one second, 2,467,086 lines. Why? Because the syscall instruction wrote the return address into rcx. After the call, rcx held a huge address. We subtracted 1 and it was still not zero, so we looped again, billions of times. The fix is to keep the counter in a register the kernel does not touch, like r12. With that one change it printed hi exactly three times. And the ARM64 version, with the counter in x19, also printed exactly three times. On ARM64, only x0 is destroyed, so this trap is rarer there.
(That is the "first instinct, then it breaks" path. The first instinct is "any register will do." It will not, and the table above tells you which are safe.)
What the loader did before _start
The program we built is an ELF file (Linux's executable format). Before your first instruction ran, the kernel did this:
- It read the ELF headers and created a private address space for the process.
- It mapped
.textas readable and executable, and.rodataas read-only. - It built a stack, and put
argc, theargvstrings and the environment on it. - It pointed the stack register (
rsporsp) at the top of that stack. On x86-64 it is 16-byte aligned: gdb above shows0x7fffffffea40, which ends in0. - It set the program counter to
_start.
That is the entire ceremony. Everything after that is your program. Chapter 23 reads the argc and argv that the kernel left on the stack.
Why it is this way (the "why")
Why can't your code just call the kernel like any function? Because the kernel runs with more privilege than you. The CPU has modes. Your code runs in a restricted one, and the kernel runs in a trusted one. An ordinary call would let you jump into the middle of kernel code at any address. The syscall instruction is a controlled door: the CPU switches modes and jumps to exactly one address the kernel chose. You can ask. You cannot wander.
Why does the kernel trust the register contract? Because there is nothing else to trust. At this level there are no types and no function signatures, just registers. The "standard library" of the machine is the list of syscall numbers and their register rules. That list is an ABI (application binary interface): a contract about bytes and registers, not about source code. Chapter 14 builds on this idea for ordinary function calls.
Common mistakes
- Forgetting
.global _start. The linker then complains it cannot find the entry point (or warns and guesses). - Forgetting to exit. The CPU keeps going past your last instruction into whatever bytes come next. That is the
SIGSEGVabove. - A wrong length. Too short cuts your message. Too long prints garbage after it. Let the assembler count with
. - msg. - Trusting
rcxandr11across asyscallon x86-64. They are destroyed. - Writing to
.rodata. It is read-only, so you getSIGSEGV.
Say it simply
To talk to the outside world, a program asks the kernel by running one special instruction after putting a service number and arguments in agreed registers. Two services, write and exit, are enough for a first program. The kernel believes your registers completely, so a wrong number runs a different service and no one stops you. The tools to see all of this are as, ld, objdump, strace and gdb.
Check yourself
Exercise 4.1 (Warm-up): Predict, then check. Write exit(300) and predict the status the shell reports. Check with echo $?. A correct result: 44. Say why in one sentence.
Exercise 4.2 (Warm-up): Explain. In five sentences, explain to a friend why your program cannot write to the screen directly, and what a system call is. Use one analogy and say where it breaks. What a strong answer includes: privilege levels; the kernel owns hardware; a syscall is a controlled door with one fixed entry point; an analogy such as a bank teller window (you cannot walk into the vault, you ask at the window); the limit of the analogy (a teller can refuse and check your ID, while the CPU door trusts the registers you give).
Exercise 4.3 (Core): Break it and fix it. Build the rcx loop above. Run it with timeout 1 ./prog | wc -l. Explain the number you see. Fix it two different ways (one with a different register, one by saving and restoring rcx around the syscall). Check that you now get exactly 3 lines. Hint: look at the table of destroyed registers.
Exercise 4.4 (Core): Implement. Write a program for both ISAs that reads one byte from standard input (read: number 0 on x86-64, 63 on ARM64) into a buffer in .bss, then writes that byte back out. Test with echo Xyz | ./prog. Done when: it prints X, and you can say what the read syscall returned in rax or x0.
Exercise 4.5 (Core): Use the tools. Run strace ./hello_x86 and strace -c ./hello_x86. Then run strace on /bin/true. Count the syscalls in each. What does the difference tell you about what a normal program does before main? A correct result: your handwritten program makes two syscalls (plus execve); a normal dynamically linked program makes dozens before it reaches main (loading libraries and setting up memory).
Exercise 4.6 (Stretch): Design and defend. The kernel does validate some things (a bad pointer gives an error, not a crash of the kernel). Describe what kinds of checking an OS interface could add (types, argument counts, ownership), and what each one would cost per call. What a strong answer includes: the interface runs on every call, so cost matters; checks that are free (is this pointer in the process's memory?) versus costly (verifying types, which registers cannot carry); and a view on where the checking should live (in the library on top, not in the door).
Chapter 5. Reading assembly: the vocabulary and the method
In this chapter: You will learn the small vocabulary that covers almost everything you will read, using real data. You will learn a method for reading any function. And you will read your first piece of compiler output on both machines.
You need first: Chapter 4.
The problem first
You open a disassembly of a function. It is two hundred lines. You read for twenty minutes and understand nothing. Why?
Because you are trying to read it like prose, from the top, one word at a time. Experienced readers do not. They look for landmarks first (calls, loops, decisions), then fill in the detail. And they know the small vocabulary that almost everything is made from.
The model (the "what")
How big is that vocabulary really? An instruction set has thousands of instructions. But they are not used equally. I counted every instruction in the C library (libc.so.6) on this machine, for both ISAs. These are static counts: the instructions that exist in the file, not the ones executed.
| x86-64 (385,673 instructions, 281 different) | ARM64 (289,361 instructions, 196 different) | ||
|---|---|---|---|
mov | 29.6% | mov | 18.2% |
cmp | 5.9% | ldr | 11.7% |
lea | 5.3% | b | 10.6% |
je | 4.7% | add | 7.7% |
test | 4.6% | str | 6.5% |
nop | 4.2% | cmp | 5.3% |
jmp | 4.2% | bl | 4.8% |
call | 3.8% | ldp | 4.6% |
jne | 3.7% | stp | 3.8% |
add | 2.9% | sub | 3.3% |
sub | 2.9% | adrp | 3.2% |
pop | 2.6% | cbz | 2.4% |
xor | 2.5% | nop | 2.2% |
push | 2.4% | cbnz | 1.4% |
ret | 1.6% | ret | 1.4% |
The top 10 mnemonics are 69% of all x86-64 instructions and 77% of ARM64. The top 20 are 86% and 92%. The top 40 are 93.5% and 98%. So an older claim in this guidebook, that "about twenty instructions are the overwhelming majority," holds up. And another old claim, that mov is "30 to 50%" of all code, was too high: it is 29.6% on x86-64 and 18.2% on ARM64 here.
Some of this table needs a word. nop is 4% because compilers insert do-nothing instructions as padding, to line up the start of functions and loops on nice addresses. endbr64, which is another 1% of x86-64, is a marker that real Linux builds put at the start of functions (Chapter 17). On ARM64, ldp and stp (load and store a pair) are the standard way to save and restore two registers at once. You will meet them in Chapter 8.
The method. Here is how experienced readers approach a function. Ask these seven questions, in this order. Do not read line by line yet.
- Where does it start and end? A function ends at
ret. There may be more than oneret. - What are its inputs? Look at which argument registers it reads before writing them (
rdi, rsi, rdx, rcxon x86-64;x0tox7on ARM64). We prove this in Chapter 14. - Where are the loops? A backward jump (a jump to an address before the jump) is a loop.
- Where are the decisions? A
cmportestfollowed by a conditional jump is anif. - Where does it call other code? Look for
callandbl. - What memory does it touch, and how wide? Brackets mean memory. The width tells you the type: 8 bytes is a
longor a pointer, 4 bytes anint, 1 byte achar. - What does it return? Look at what is in
rax(orx0) right beforeret.
Only after those seven, read the lines.
Build it (the "how")
Let's apply the method to a real function. Here is the C:
long sum(const long *a, long n) {
long s = 0;
for (long i = 0; i < n; i++) s += a[i];
return s;
} Here is the real gcc 13.3 -O2 output (with vectorization turned off, so it stays small):
# x86-64
sum:
test rsi, rsi # is n == 0 or negative?
jle .L4 # yes: skip to the "return 0" path
lea rdx, [rdi+rsi*8] # rdx = a + n*8 : the END of the array
xor eax, eax # s = 0
.L3:
add rax, QWORD PTR [rdi] # s += *p (memory operand, 8 bytes)
add rdi, 8 # p++ (advance 8 bytes)
cmp rdi, rdx # p == end ?
jne .L3 # no: loop (backward jump)
ret
.L4:
xor eax, eax # return 0
ret // ARM64
sum:
cmp x1, 0 // n <= 0 ?
ble .L4 // yes: return 0
mov x2, x0 // p = a
mov x0, 0 // s = 0 (x0 is also the return register)
add x3, x2, x1, lsl 3 // end = a + n*8
.L3:
ldr x1, [x2], 8 // x1 = *p, then p += 8 (post-index: load AND advance)
add x0, x0, x1 // s += x1
cmp x2, x3 // p == end ?
bne .L3 // no: loop
ret
.L4:
mov x0, 0
ret Let's apply the seven questions to the x86-64 one.
- Start and end:
sum:to the tworets. - Inputs:
rdiandrsiare read before being written. So the function takes two arguments (a pointer and a count). - Loops:
jne .L3jumps back to.L3. One loop. - Decisions:
test rsi, rsi; jleis a guard forn <= 0.cmp rdi, rdx; jneis the loop test. - Calls: none. This is a leaf function (it calls nothing).
- Memory: one
QWORD PTR [rdi]read, 8 bytes at a time. So it readslongs. - Return:
raxholdss.
Now look at what the compiler did. The C says "a[i] with a counter i." The assembly has no i. It has a pointer that walks forward by 8 each time, and a precomputed end pointer. The loop stops when the pointer reaches the end. The compiler rewrote your loop. You will often see this. The disassembly is a translation of your idea, not a copy of it.
Also compare the two machines. The x86-64 loop adds directly from memory (add rax, [rdi]). ARM64 must load first (ldr), and its post-indexed ldr x1, [x2], 8 loads and advances the pointer in one instruction, doing the job of x86-64's separate add rdi, 8. The loop body is four instructions on each machine. Same shape, different spelling.
Let's see why -O2 matters. Here is the same function at -O0 (no optimization). The inner loop is 10 instructions instead of 4, and almost every one touches memory, because every variable lives on the stack:
# x86-64, gcc -O0 (the loop part)
.L3:
mov rax, QWORD PTR -8[rbp] # load i
lea rdx, 0[0+rax*8]
mov rax, QWORD PTR -24[rbp] # load a
add rax, rdx
mov rax, QWORD PTR [rax]
add QWORD PTR -16[rbp], rax # s += ... (read, add, write memory)
add QWORD PTR -8[rbp], 1 # i++ (read, add, write memory)
.L2:
mov rax, QWORD PTR -8[rbp]
cmp rax, QWORD PTR -32[rbp]
jl .L3 Read -O0 once, to see the naive translation of each C construct. Then never study it again. Real programs, and real profiles, are -O2 or -O3.
Your habit for life: open Compiler Explorer (godbolt.org) in a tab and leave it open. Paste a function, pick the compiler and flags (-O2, and --target=aarch64-linux-gnu for ARM64), and read. Then predict what changes if you edit the C. This one loop, predict then look, is how the vocabulary becomes intuition.
Why it is this way (the "why")
Why is the vocabulary so small? Because of how programs are shaped. Most code moves data (mov, ldr, str), makes decisions (cmp, test, jumps), and calls other code (call, bl, ret). Arithmetic is a small part. This is a fact about software, not about CPUs. It also tells you where the machine's design effort should go: making moves and branches fast. And it is true on both machines, even though they look different.
Why did the compiler turn the index loop into a pointer loop? Two reasons. A pointer that advances by 8 needs one instruction (add rdi, 8); an index needs a multiply-by-8 (folded into the address) and a separate counter. And the compare can be against the end pointer, so the loop needs only one live variable, not two. Fewer instructions per trip, and fewer registers busy. The compiler calls this strength reduction: replacing something costly with something cheaper that gives the same result.
What would have to be true for this to be a bad idea? If you needed the index i itself (to print it, or to index a second array), the compiler would keep it. And if the loop is vectorized (Chapter 29), the shape changes again. So "the compiler rewrote my loop" is normal, and what it rewrote it into depends on what you wrote.
Common mistakes
- Reading
-O0output and thinking it is what ships. It is not. - Reading line by line from the top. Landmarks first.
- Ignoring widths.
DWORD PTRversusQWORD PTRis the difference betweenintandlong. - Forgetting the output is a translation. The loop you see may not be the loop you wrote.
- Mixing up AT&T and Intel. If operands look backwards, check the flavor.
Say it simply
Most instructions in real code are moves, compares, jumps and calls. About twenty different instructions cover nine tenths of what you will read. To read a function, look for landmarks first: where it starts and ends, its loops (backward jumps), its decisions (compare then jump), its calls, and its memory accesses. The compiler rewrites your loops, so you are reading a translation of your idea.
Check yourself
Exercise 5.1 (Warm-up): Read it. Here is real gcc -O1 output for a function. Before looking at the C, say what it does and what its argument is.
# x86-64
count_a:
movzx eax, BYTE PTR [rdi]
test al, al
je .L4
mov edx, 0
.L3:
cmp al, 97
sete al
movzx eax, al
add rdx, rax
add rdi, 1
movzx eax, BYTE PTR [rdi]
test al, al
jne .L3
.L1:
mov rax, rdx
ret
.L4:
mov edx, 0
jmp .L1 // ARM64
count_a:
mov x2, x0
ldrb w1, [x0]
cbz w1, .L4
mov x0, 0
.L3:
cmp w1, 97
cinc x0, x0, eq
ldrb w1, [x2, 1]!
cbnz w1, .L3
.L1:
ret
.L4:
mov x0, 0
b .L1 Hint: 97 is the code for a letter. A byte read that stops at zero is a C string. A correct result: it counts how many times the letter a appears in a C string. Your answer should mention the stop-at-zero test, the width (1 byte), and what sete (x86-64) and cinc (ARM64) each do.
Exercise 5.2 (Warm-up): Explain. In five sentences, explain to a friend the seven-question method, using the sum function. What a strong answer includes: landmarks before lines; a backward jump means a loop; compare then jump means a decision; brackets mean memory and the width gives the type; the compiler rewrote the index loop into a pointer loop.
Exercise 5.3 (Core): Measure your own binary. Count the instructions in any program on your machine:
objdump -d --no-show-raw-insn -M intel /usr/bin/ls \
| awk -F'\t' 'NF>=2 {split($2,a," "); print a[1]}' \
| sort | uniq -c | sort -rn | head -15 Write down the top five and compare them to the table above. Explain every difference you see. A correct result: mov first, then a mix of cmp, jmp, call, je, lea. Differences come from the binary being small, from stub code, or from different compilers.
Exercise 5.4 (Core): Compare optimization levels. Compile sum with -O0, -O1, -O2 and -O3 (add -fno-tree-vectorize for one run and leave it off for another). For each, count the instructions and count how many of them touch memory in the loop body. Predict the numbers first. A correct result: -O0 has about 20 instructions in total and 10 in the loop, with many memory touches; -O2 has 13 in total and 4 in the loop, with one. Without -fno-tree-vectorize at -O3, you will see vector registers (Chapter 29).
Exercise 5.5 (Core): Predict, then check. Predict how gcc -O2 compiles int sum32(const int *a, long n) (4-byte elements). What changes from sum: the scale, the register names, the width of the load? Check. A correct result: the end pointer uses scale 4 (or a shift by 2), the add uses a 32-bit register (eax or w0), and on x86-64 the memory operand is DWORD PTR.
Exercise 5.6 (Stretch): Design and defend. The compiler rewrote an index loop into a pointer loop. Name two reasons that is better. Then give one situation where the index form would be better to keep, and say what the compiler does about it. What a strong answer includes: fewer instructions per iteration; fewer live registers; when you need the index (second array, printing), or for vectorization the compiler may keep an index and compute addresses from it.
Part 1 wrap-up
What you can now do:
- Say what a CPU does in one loop, and name its four kinds of state.
- Assemble, link, run, trace and debug a program on x86-64 and ARM64.
- Make a Linux system call on both machines, and name which registers it destroys.
- Read Intel and AT&T syntax without confusion.
- Read a small compiled function with the seven-question method.
- Say why a measured number beats a belief (the instruction length and frequency data).
Mini project: the machine in your hands. Time: 2 to 4 hours.
Write one program, on both ISAs, that does all of this:
- Puts the number
5in a register, turns it into the character'5'(add 48), stores that byte in memory, and prints it withwrite. (Hint: you need an address forwrite. A byte on the stack, or in.bss, will do.) - Crashes on purpose, twice: once with
ud2(x86-64) orudf #0(ARM64), and once by jumping to an address in.data. - For each crash, captures the state in
gdband writes down the exact value of the program counter and the register that held the bad address.
Done when: someone else can build and run it from your README alone. The README shows the objdump -d output with each line explained in your own words, and the two crash captures.
Write-up (half a page): What did you expect to see? What did you see? What surprised you?
Reading list for Part 1:
- Linux
syscall(2)man page (man 2 syscall). Lists the exact register conventions for every architecture. Start here to check Chapter 4's table. - Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 1 (free PDF). The basic execution environment chapter is the official picture of Chapter 2's registers and flags.
- Arm Architecture Reference Manual for A-profile (free with registration). The programmer's model: the official ARM64 register list.
- Computer Systems: A Programmer's Perspective (Bryant and O'Hallaron). The machine-level chapters are the classic companion for x86-64.
- Matt Godbolt, "What Has My Compiler Done for Me Lately? Unbolting the Compiler's Lid" (CppCon 2017). The best 60-minute introduction to reading compiler output. Watch it after Chapter 5.
Teach it back. In five sentences, as if to a friend who has never seen assembly, explain what a CPU does, how a program asks the OS for help, and how to start reading a function. Use the toy CPU as your example.
Part 2: Memory and Data Movement
After this Part you can move data around on both machines, build any address you need, use the stack, and lay out structs by hand. These are the things most instructions in real code do (remember: mov, ldr and str were the top of our frequency table).
Chapter 6. Moving data: memory, mov, widths and constants
In this chapter: You will learn what memory looks like to the CPU, how bytes are ordered, and how mov works on both machines. You will learn the width rules that cause silent bugs. And you will learn how each machine builds a big constant.
You need first: Chapters 2 and 4.
The problem first
You have a value in a register. You want it in memory. Or the other way round. Easy, until the questions start. How many bytes? Which end goes first? What happens to the other bits of the register? And what if the number you want to load is too big to fit inside the instruction?
Each of these has an answer, and each answer is a trap if you guess.
The model (the "what")
Memory is one long array of bytes. Address 0 is the first byte, address 1 the second, and so on. A pointer is just a number that is an index into that array. A data structure is only an agreement about what the numbers at which offsets mean.
(Your process does not own all of that array. The operating system gives each process a private view, and if you touch an address you were not given, the CPU raises an exception and your process dies with a segfault. Memory safety, at the instruction level, is one sentence long.)
Byte order. Say you store the 8-byte number 0x0102030405060708 at some address. In what order do the bytes land? Both of our machines are little-endian: the low byte goes at the lowest address. Here is a real run. This program writes those 8 bytes to the terminal, and xxd shows them:
$ ./endian | xxd
00000000: 0807 0605 0403 0201 The bytes look backwards. That is little-endian. You will see this every time you read a hex dump. (ARM64 hardware can do big-endian too, but Linux always runs it little-endian, so treat the two machines as the same here.)
Alignment. An address is aligned for a size if it is a multiple of that size. Address 16 is aligned for 8 bytes; address 17 is not. A common claim is that ARM64 faults on misaligned access, so let me be careful.
- On x86-64, misaligned ordinary loads and stores work.
- On ARM64, misaligned ordinary loads and stores also work on normal memory. I tested this: an 8-byte
ldrat an odd address ran fine underqemu-userand returned the right bytes. - What is not allowed on ARM64: atomic and acquire/release instructions (
ldxr,ldar, and friends) need natural alignment. The manual says they fault otherwise. (Underqemu-userI could not make this fault happen, so check it on real hardware before you rely on it.)
So alignment is not a correctness rule for plain loads on either machine. It is a speed rule, and I measured it. A loop of 8-byte loads on this machine took about 0.64 ns each when the load sat inside one 64-byte cache line, and about 0.93 ns each when it crossed a line boundary, which is about 1.5 times slower. (In an earlier session, on what was evidently different hardware, I measured 0.43 ns against 0.82 ns, nearly double. Part 8 explains why my numbers moved.) Crossing a cache line costs more because the CPU must touch two lines.
The mov family. Both machines have three kinds of operand: an immediate (a constant inside the instruction), a register, and memory. On x86-64, mov handles all combinations except memory-to-memory. On ARM64, mov is registers and immediates only. Memory has its own instructions: ldr (load register) and str (store register).
# x86-64: the mov matrix (Intel syntax; the size word needs "ptr")
mov rax, 42 # immediate -> register
mov rax, rbx # register -> register
mov rax, [rbx] # memory -> register (a load, 8 bytes)
mov [rbx], rax # register -> memory (a store)
mov byte ptr [rbx], 65 # immediate -> memory, 1 byte (we MUST say the size)
mov qword ptr [rbx], 7 # immediate -> memory, 8 bytes // ARM64: the same matrix, with ldr and str for memory
mov x0, #42 // immediate -> register
mov x1, x0 // register -> register
ldr x0, [x1] // memory -> register (a load, 8 bytes)
str x0, [x1] // register -> memory (a store)
mov w2, #65
strb w2, [x1] // store 1 byte. The suffix picks the width:
// strb = 1, strh = 2, str w = 4, str x = 8 Width. x86-64 puts the width in the register name: al is 1 byte, ax is 2, eax is 4, rax is 8. ARM64 uses w0 for the low 32 bits of x0, and puts the width of memory accesses in the instruction (ldrb, ldrh, ldr).
Now the two rules that bite everyone once.
Rule 1: writing a 32-bit register zeroes the upper half. Writing 8 or 16 bits does not. Here is a real run. We set every bit of a register, then write a smaller piece:
mov rax, -1 # rax = 0xffffffffffffffff
mov eax, 5 # write the 32-bit half
mov rbx, -1
mov bl, 5 # write the 8-bit part
mov rcx, -1
mov cx, 5 # write the 16-bit part rax 0x5
rbx 0xffffffffffffff05
rcx 0xffffffffffff0005 Writing eax wiped the top 32 bits of rax. Writing bl and cx left the upper bits alone. ARM64 does the same for its 32-bit w registers: writing w0 zeroes the top of x0.
Rule 2: narrow loads must say how to fill the rest. If you load a byte into a 64-bit register, what goes in the other 56 bits? Zeros (right for unsigned) or copies of the sign bit (right for signed). The machines spell this differently. Real encodings:
x86-64 ARM64
movzx eax, byte ptr [rdi] (0f b6 07) ldrb w0, [x1] (zero-extend byte)
movsx rax, byte ptr [rdi] (48 0f be 07) ldrsb x0, [x1] (sign-extend byte)
movsxd rax, dword ptr [rdi] (48 63 07) ldrsw x0, [x1] (sign-extend 32 -> 64) The sign-extending forms matter whenever a signed int is used as a 64-bit index. You will see movsxd and ldrsw in output whenever C code indexes an array with an int.
Big constants. An instruction has only so many bits, so it can hold only so big a constant.
On x86-64, mov eax, 5 is the 5-byte instruction b8 05 00 00 00. The constant is a 32-bit field. But watch what the assembler did for mov rax, 5:
b8 05 00 00 00 mov eax,0x5 5 bytes
48 c7 c0 05 00 00 00 mov rax,0x5 7 bytes
48 b8 f0 de bc 9a 78 56 34 12 movabs rax,0x123456789abcdef0 10 bytes
31 c0 xor eax,eax 2 bytes
48 31 c0 xor rax,rax 3 bytes Look at how many bytes the same idea costs. mov eax, 5 is shorter than mov rax, 5, and because of Rule 1 it does the same job (the top half becomes zero). So compilers write mov eax, 5 whenever the value fits. And that is why xor edi, edi appears in our hello program: it is 2 bytes, while xor rdi, rdi is 3. Both forms clear the upper bits, so it is not about correctness: xor reg, reg wins because it is shorter, and because the CPU recognizes it as a zeroing idiom that does not wait on the old value of the register. Only movabs can carry a full 64-bit constant, and it is 10 bytes.
ARM64 cannot hold a 64-bit constant in 4 bytes, so it builds one in pieces of 16 bits:
movz x0, #0x1234, lsl #48 // set one 16-bit slice, zero the rest
movk x0, #0x5678, lsl #32 // "move, keep": overwrite one slice, keep the rest
movk x0, #0x9abc, lsl #16
movk x0, #0xdef0
// x0 = 0x123456789abcdef0, in four instructions When you read four movk-style instructions in a row in a disassembly, you are watching a constant being built, slice by slice. A compiler may also put the constant in a small table next to the code (a literal pool) and load it with one ldr. And ARM64 has one more trick for logic instructions: certain repeating bit patterns, like 0xff or 0x00ff00ff00ff00ff, fit directly inside and, orr and eor. A random constant like 0xf0f0f0f1 does not, and the assembler tells you so:
Error: immediate out of range at operand 3 -- `and x2,x3,#0xf0f0f0f1' That is the message every ARM64 beginner meets. Chapter 25 shows exactly why.
Build it (the "how")
Let's put together the data-movement pieces in a small program. It stores a quad word, reads it back one byte at a time, and shows how the pieces differ.
# x86-64: store, then read back with different widths
.intel_syntax noprefix
.global _start
.bss
buf: .space 8
.text
_start:
lea rbx, [rip+buf]
mov rax, 0x1122334455667788
mov [rbx], rax # store all 8 bytes
movzx ecx, byte ptr [rbx] # the lowest-address byte: 0x88
movzx edx, byte ptr [rbx+7] # the highest-address byte: 0x11
mov edi, ecx # exit status = low byte read
mov eax, 60
syscall Predict the exit status before you run it. Little-endian says the byte at the lowest address is the low byte of the number, 0x88, which is 136. When I ran it, the status was 136.
Here is the first instinct that breaks. Most people, seeing 0x1122334455667788, expect the first byte in memory to be 0x11. That is how we write numbers on paper, and big-endian machines do store it that way. On our machines it is the other way around. The only reliable habit is: whenever you look at memory bytes, ask "which direction is low?"
Why it is this way (the "why")
Why does a 32-bit write zero the top half? The old 32-bit x86 code used eax, and when AMD extended it to 64 bits, they made 32-bit writes zero the top. The reason is speed. If a write to eax kept the old top half of rax, the CPU would have to wait for the old value of rax before finishing, even though you never use it. Zeroing breaks that false wait. For 8-bit and 16-bit writes, the old behavior was already fixed in history, so they merge. This is a design choice, and it is also why 8-bit and 16-bit operations are sometimes slower than 32-bit ones.
Why is the constant so small? Because the immediate is stored inside the instruction, and an instruction is short. A 64-bit constant would make every instruction longer, and almost every constant a program uses is small (0, 1, -1, 8, a mask). So both machines make the common case cheap and the rare case a multi-step process.
The trade-off. x86-64 spends bytes on flexibility (a memory operand, a long constant when you need one). ARM64 spends instructions on regularity (load, then compute; build constants in slices). Neither is faster by itself. Which one wins depends on what the code does, and a later chapter shows that the CPU can hide much of the difference.
What would make this design wrong? If most programs used big constants and wide values, the cost of movabs and movk ladders would dominate. The reason that is not the case is a fact about software, and it is the same fact we met in Chapter 5: code mostly moves data and compares small things.
Common mistakes
- Using
mov rax, 5instead ofmov eax, 5. It works, but costs 2 more bytes. - Forgetting the size word.
mov [rbx], 7is ambiguous. Sayqword ptrordword ptr. - Assuming a byte write clears the register. It does not. Use
movzx. - Reading a hex dump left to right as a number. It is little-endian. The first byte is the lowest.
- Using a signed load for unsigned data.
0xffloaded withldrsbis -1, not 255.
Say it simply
Memory is one long array of bytes, and on both our machines the low byte of a number goes at the lowest address. A 32-bit register write clears the top half of the 64-bit register, but an 8-bit or 16-bit write does not. Narrow loads must say whether to fill with zeros or sign bits. Constants live inside instructions, so big ones take extra steps: one long instruction on x86-64, and a ladder of 16-bit pieces on ARM64.
Check yourself
Exercise 6.1 (Warm-up): Predict, then check. Store 0x0102030405060708 in memory and read it back with movzx from offsets 0, 1 and 7. Predict the three bytes. Then run it and print each as an exit status. A correct result: 0x08, 0x07, 0x01.
Exercise 6.2 (Warm-up): Explain. In five sentences, explain to a friend why a uint32_t write in C is "free" on x86-64 while a uint8_t write can cost extra. Give an analogy and say where it breaks. What a strong answer includes: a 32-bit write replaces the whole register so there is no dependence on the old value; an 8-bit write keeps the other bits so it must wait for the old value; analogy: replacing a whole page of a notebook versus editing one word on it (you must read the page first); where it breaks: the CPU renames registers to hide some of this.
Exercise 6.3 (Core): Break it and fix it. Assemble mov [rbx], 7 and read the error. Then fix it three ways (byte, dword, qword) and compare the encoded lengths with objdump. A correct result: the error says ambiguous operand size; the three fixes differ in opcode and in how many immediate bytes follow.
Exercise 6.4 (Core): Implement. On both ISAs, write a function-free program that loads the value 0xdeadbeefcafef00d into a register, then stores it to .bss and reads back its second byte using both a zero-extending and a sign-extending load. Exit with each result in turn and compare. Done when: the zero-extended result is 0xf0 (240) and the sign-extended low byte of the exit code shows the same bits, and you can explain why the printed number changes only if you look at the full register. Hint: look at what movsx puts in the upper 56 bits, and check with gdb.
Exercise 6.5 (Core): Change one thing. Run the cache-line benchmark idea yourself: write a C loop that does 8-byte loads at offset 0, then at offset 61 inside each 64-byte line (use memcpy into a local to avoid a C alignment problem). Measure both. A correct result: the crossing case is noticeably slower, around 1.5 to 2 times on common x86-64 chips. Your ratio may differ. Explain it in terms of cache lines.
Exercise 6.6 (Stretch): Design and defend. Suppose you could add one new addressing or constant feature to ARM64 to avoid movk ladders without making instructions longer. Propose one, and state what it would cost in decode complexity or in space. What a strong answer includes: a clear mechanism (for example a literal pool with a PC-relative load, which ARM64 already has); an honest cost (a memory access, cache pressure, or a wider encoding); and a statement of which constants are common enough to matter.
Chapter 7. Addressing modes: how an address is built
In this chapter: You will learn the ways each machine builds a memory address, and the instruction lea, which builds an address without touching memory. You will learn how both machines reach a variable in your program's data.
You need first: Chapter 6.
The problem first
Say you have an array of 8-byte numbers and you want element i. The address is start + i*8. You could compute that in three instructions (multiply, add, load). But array indexing happens so often that both machines build the arithmetic into the load itself. That is what an addressing mode is: a recipe for building an address inside one instruction.
The model (the "what")
x86-64 has one general recipe. The address is:
base + index * scale + displacement All parts are optional. The base and index are registers, the scale is 1, 2, 4 or 8, and the displacement is a constant number of bytes. Here are real encodings of some forms (checked with the GNU assembler):
mov rax, [rbx] 48 8b 03
mov rax, [rsp] 48 8b 04 24 <- notice the extra 24 byte
mov rax, [rbx+16] 48 8b 43 10
mov rax, [rbx+rcx*4] 48 8b 04 8b
mov rax, [rbx+rcx*4+16] 48 8b 44 8b 10
mov rax, [rbx+rcx*3] ERROR: not a valid base/index expression The last line is the point. The scale can only be 1, 2, 4 or 8. Those are the sizes of the common types (char, short, int, long or pointer). Anything else, like times 3, needs a separate instruction. (The extra 24 byte in the [rsp] form is a special case we meet in Chapter 24.)
ARM64 has a narrower set. The address is a base register, plus one of:
ldr x0, [x1] // base only
ldr x0, [x1, #16] // base + constant (a multiple of 8, 0..32760)
ldr x0, [x1, x2] // base + register
ldr x0, [x1, x2, lsl #3] // base + register << 3 (scaled index)
ldr x0, [x1, w2, sxtw #3] // base + (sign-extended 32-bit index) << 3
ldr x0, [x1, #16]! // pre-index: x1 += 16 first, then load
ldr x0, [x1], #16 // post-index: load first, then x1 += 16 The ranges are easy to get backwards, so here are the real rules, checked with the assembler:
- A scaled unsigned constant: 0 to 32,760 for an 8-byte
ldr(that is 4095 × 8). It must be a multiple of the access size. - An unscaled signed constant: -256 to +255, with any alignment. The assembler picks this form (
ldur) on its own when you writeldr x0, [x1, #3]or#-256. - Anything outside those ranges is rejected:
ldr x0, [x1, #32768]andldr x0, [x1, #-257]both give "immediate offset out of range."
The pre-index and post-index modes are the unusual part. They change the base register as part of the load. Post-index [x1], #16 means "use x1, then add 16 to it." This is the machine's array-walking mode: one instruction loads and moves the pointer. You saw it in Chapter 5: ldr x1, [x2], 8.
And sxtw ("sign-extend word") appears constantly in compiled ARM64 code, for the same reason movsxd does on x86-64. A C int index is 32 bits and signed, but an address is 64 bits. [x0, w1, sxtw] in output is a sign that the source indexed with an int.
lea: an address calculator. x86-64's lea ("load effective address") computes the address recipe but does not touch memory. It just hands you the number.
lea rax, [rbx+rcx*4+16] # rax = rbx + rcx*4 + 16 (no memory access)
lea rax, [rdi+rdi*2] # rax = rdi * 3 (just arithmetic) The second line is the compiler's famous trick, and we saw it in Chapter 1: x * 3 becomes lea rax, [rdi+rdi*2]. Why use an address calculator for arithmetic? Three reasons: it takes three inputs and writes a separate output register (so it does not destroy a source), it combines shift and add in one step, and, critically, lea does not change the flags. I checked the encoding of cmp rax, rbx; lea rcx, [rax+rax*2+5]; je done. A flag set by cmp survives the lea. A compiler uses this to compute something in between a compare and the jump that depends on it. ARM64 does the same job with a shifted operand: add x0, x0, x0, lsl 1.
Reaching a variable in your data. A very common job is getting the address of a global variable or a string. Both machines use the program counter as the base, so the address is a distance from the current instruction. That keeps code position-independent: the whole program can load at a different place, and the distance stays the same.
On x86-64 it is one instruction (we used it already):
lea rdi, [rip+msg] # rdi = address of msg
mov rax, [rip+counter] # load the value stored AT counter (Note for the GNU assembler: write [rip+msg]. The form [rel msg] is NASM syntax, and the GNU assembler rejects it with "bad expression.")
On ARM64 a 4-byte instruction cannot hold a full distance, so the job takes two steps:
adrp x0, msg // x0 = the 4 KiB page that holds msg
add x0, x0, :lo12:msg // + the offset of msg inside that page
ldr x1, [x0] // use it adrp reaches any page within plus or minus 4 GiB of the instruction. The :lo12: names the low 12 bits of the address. Why 12? Because 2 to the power 12 is 4096, a page, and the low 12 bits of an address say where inside the page you are. So adrp finds the page, and add finds the spot. When you see adrp over and over in ARM64 output (it was 3.2% of all instructions in our libc count), you are watching the machine rebuild addresses each time rather than keep them in registers.
Build it (the "how")
Let's write array indexing on both machines. We sum the 4 numbers 10, 20, 30, 40 using the index form of each addressing mode.
# x86-64: sum arr[0..4) with base + index*8
.intel_syntax noprefix
.global _start
.data
arr: .quad 10, 20, 30, 40
.text
_start:
lea rbx, [rip+arr] # base
xor eax, eax # sum = 0
xor ecx, ecx # i = 0
again:
add rax, [rbx + rcx*8] # sum += arr[i] (scale 8 = size of a quad)
inc rcx
cmp rcx, 4
jl again
mov rdi, rax # exit status = sum = 100
mov eax, 60
syscall // ARM64: the same, with a scaled register index
.global _start
.data
arr: .quad 10, 20, 30, 40
.text
_start:
adrp x1, arr
add x1, x1, :lo12:arr // base
mov x0, 0 // sum
mov x2, 0 // i
again:
ldr x3, [x1, x2, lsl #3] // x3 = arr[i]
add x0, x0, x3
add x2, x2, 1
cmp x2, 4
b.lt again
mov x8, 93 // x0 already holds the sum = 100
svc #0 Both exit with status 100. The two loops have the same shape, and the interesting line is the load: [rbx + rcx*8] versus [x1, x2, lsl #3]. Each folds the "times 8" into the address.
Why it is this way (the "why")
Why can x86-64 only scale by 1, 2, 4 or 8? The scale is stored in 2 bits of the instruction (Chapter 24). Two bits give four choices, and the four sizes of the basic types are the four useful ones. The address adder in the CPU has a tiny fixed shifter in front of it for exactly this. Multiplying by 3 would need a real multiplier, which is much bigger and slower, so the design leaves it out.
Why does ARM64 split an address into adrp plus add? Because a 4-byte instruction has no room. After you spend 5 bits on the destination register and a few more on the opcode, you have about 21 bits left for a distance. That reaches 4 GiB when counted in 4 KiB pages, which is plenty for one program. Instead of making instructions longer, ARM64 chose two instructions. It is the same bet as before: spend instructions to keep decoding simple.
What would make lea-as-arithmetic a bad idea? It would be a bad idea if lea were slow. On some older chips, a lea with three parts took extra cycles, and compilers must know which. This is a place where the instruction's meaning and its speed are separate questions, and Part 8 teaches you where to look the speed up.
Common mistakes
- Writing
[rel msg]or[msg]in GNU Intel syntax. Use[rip+msg]. - Using scale 3, 5, 6 or 10. Not allowed. Use
leaor shifts. - Expecting
ldr x0, [x1, #3]to be an error on ARM64. The assembler quietly picks the unscaled form. - Forgetting that
leadoes not read memory.lea rax, [rbx]is justmov rax, rbx. - Using a 32-bit index without
sxtw. A negativeintindex would become a huge positive number.
Say it simply
An addressing mode is a recipe for an address, built inside one instruction. x86-64 offers base plus index times 1, 2, 4 or 8 plus a constant. ARM64 offers base plus a constant, or plus one shifted register, with optional update of the base afterwards. lea computes an address without loading anything, and compilers borrow it for arithmetic because it leaves the flags alone. To reach a global variable, both machines measure from the program counter, ARM64 in two steps (adrp and add).
Check yourself
Exercise 7.1 (Warm-up): Decode by hand. What address does [rbx + rcx*4 + 16] compute when rbx = 0x1000 and rcx = 5? What does ldr x0, [x1, x2, lsl #3] load from when x1 = 0x2000 and x2 = 3? A correct result: 0x1000 + 20 + 16 = 0x1024, and 0x2000 + 24 = 0x2018.
Exercise 7.2 (Warm-up): Explain. In five sentences, explain why a compiler writes x*3 as lea rax, [rdi+rdi*2]. Give an analogy and say where it breaks. What a strong answer includes: lea is an address calculation borrowed for arithmetic; scale 2 plus the base gives times 3; it writes a separate output and does not change flags; analogy: using a tape measure (made for distances) to also add two numbers; it breaks because lea is limited to the shapes the address recipe allows.
Exercise 7.3 (Core): Predict, then check. Predict the bytes of mov rax, [rbx+rcx*8+0x100]. Then assemble and check. Which part tells the CPU the scale? A correct result: it uses a ModRM byte and a SIB byte followed by a 4-byte displacement (since 0x100 does not fit one signed byte). Hint: compare with the 1-byte displacement in [rbx+16].
Exercise 7.4 (Core): Implement. Write both programs above and then change them to sum an array of 4-byte ints (.long) instead of quad words. On ARM64 you will need a 32-bit load; on x86-64 a 32-bit register. Make the status come out right when one element is negative. Done when: an array 10, -20, 30, 40 gives the sum 60, and you can explain whether you needed movsxd/ldrsw.
Exercise 7.5 (Core): Break it. On ARM64, write ldr x0, [x1, #4] and ldr x0, [x1, #32768]. Read what the assembler does with each, then check the first one's encoding with objdump. A correct result: the first assembles as ldur (unscaled form); the second is rejected as out of range. Explain why #4 is not a multiple of 8.
Exercise 7.6 (Stretch): Design and defend. The x86-64 scale is 2 bits. Propose a 3-bit scale field. Which new values would you offer, and what would it cost the hardware and the instruction length? What a strong answer includes: which sizes matter (for example 16 for 16-byte structs), a clear cost in encoding space or a bigger shifter, and the evidence that the sizes are needed (compilers must otherwise emit a shift or lea, as we saw with a 16-byte struct in Chapter 9).
Chapter 8. The stack is just memory with a register in charge
In this chapter: You will learn what the stack really is, how push and pop work on x86-64, and how ARM64 does the same job without them. You will learn why the stack grows downward and why alignment matters.
You need first: Chapters 6 and 7.
The problem first
Your function needs somewhere to keep a value while it calls another function. A register will not do, because the other function might use that register. Memory would do, but where in memory? And how do you find it again, and how do you free it? You want a place that grows when you need it and shrinks when you are done, in strict last-in, first-out order.
That place is the stack.
The model (the "what")
The stack is a region of ordinary memory. A single register, the stack pointer (rsp on x86-64, sp on ARM64), holds the address of the "top." Both machines grow the stack downward: the top moves to a lower address as you add things.
high addresses
+-----------------+
| older data |
+-----------------+
| value A | <- pushed first
+-----------------+
| value B | <- pushed second <- rsp points here (the top)
+-----------------+
| (free space) |
low addresses Look at where rsp points: at the last thing pushed. Pushing moves it down; popping moves it back up.
x86-64 has real instructions for this:
push rax # rsp = rsp - 8; [rsp] = rax
pop rbx # rbx = [rsp]; rsp = rsp + 8 The real bytes for push qword ptr [rbx+rcx*8] are ff 34 cb. And push rax alone is one byte (50).
ARM64 has no push or pop. It uses the store and load instructions with the pre-index and post-index modes from Chapter 7, and it prefers to move two registers at a time with stp ("store pair") and ldp ("load pair"):
stp x0, x1, [sp, #-16]! // sp -= 16, then store x0 at [sp] and x1 at [sp+8]
ldp x0, x1, [sp], #16 // load x0 and x1 from [sp], then sp += 16 Here is a real run. Four numbers 1, 2, 3, 4 loaded into x2 to x5. We push them as two pairs and pop them as two pairs. The real encodings:
a9bf0fe2 stp x2, x3, [sp, #-16]!
a9bf17e4 stp x4, x5, [sp, #-16]!
a8c11fe6 ldp x6, x7, [sp], #16
a8c127e8 ldp x8, x9, [sp], #16 After this, x6 and x7 hold 3 and 4 (the last pair pushed comes out first), and x8 and x9 hold 1 and 2. That reversal is the whole idea of a stack.
Alignment. Both machines expect the stack pointer to be a multiple of 16 at the right moments. On ARM64 the hardware can check this on every stack access, so a misaligned sp can crash. On x86-64 nothing checks rsp, but the calling convention promises 16-byte alignment, and instructions like movaps do fault on a misaligned address. We saw the kernel hand us an aligned rsp at _start: gdb printed 0x7fffffffea50, which ends in 0. Chapter 15 says exactly where the promise applies.
A stack is memory, not magic. There is no special hardware stack in either machine. push is defined as "subtract from rsp, then store." ARM64 has no push at all and just does the same thing with sp. When a debugger prints a "stack trace," it is walking a chain of saved values that the calling convention placed in known positions. If a bug overwrites that chain, the debugger shows fiction.
Build it (the "how")
Let's reverse an array in place, using the stack as the helper. First x86-64: push all five elements, then pop them back into the array. Pops come out in the opposite order, so the array is reversed.
# x86-64: reverse arr = [1,2,3,4,5] using push and pop
.intel_syntax noprefix
.global _start
.data
arr: .quad 1, 2, 3, 4, 5
.text
_start:
lea rbx, [rip+arr]
xor ecx, ecx
pushing:
push qword ptr [rbx + rcx*8] # push arr[i] (memory to stack in one instruction)
inc rcx
cmp rcx, 5
jl pushing
xor ecx, ecx
popping:
pop qword ptr [rbx + rcx*8] # pop into arr[i]
inc rcx
cmp rcx, 5
jl popping
mov rdi, [rbx] # arr[0] is now 5
shl rdi, 4
add rdi, [rbx+32] # arr[4] is now 1 -> status = 5*16 + 1
mov eax, 60
syscall I predicted 5*16 + 1 = 81, ran it, and the status was 81.
Now ARM64, with pairs. This is the program from the encodings above, finished: it pushes (1,2) then (3,4), pops them back, and computes a status from the result.
// ARM64: push and pop pairs
.global _start
.data
.balign 16
arr: .quad 1, 2, 3, 4
.text
_start:
adrp x10, arr
add x10, x10, :lo12:arr
ldp x2, x3, [x10] // x2=1 x3=2
ldp x4, x5, [x10, #16] // x4=3 x5=4
stp x2, x3, [sp, #-16]! // push pair
stp x4, x5, [sp, #-16]! // push second pair
ldp x6, x7, [sp], #16 // pop: x6=3 x7=4
ldp x8, x9, [sp], #16 // pop: x8=1 x9=2
lsl x0, x6, #4 // status = x6*16 + x9 = 3*16 + 2
add x0, x0, x9
mov x8, #93
svc #0 The status was 50, as predicted.
Why it is this way (the "why")
Why does the stack grow downward? It is an historical choice. Programs used to have code at the bottom of memory, the heap growing up from just above it, and the stack starting at the top and growing down. The two grow toward each other, and the free space between them is shared. On a modern system the layout is more complicated, but the direction stuck. It is not better or worse, only agreed upon, and everything (debuggers, unwinders, the convention) now assumes it.
Why does ARM64 prefer pairs? Because a function almost always saves registers in pairs: the frame pointer and the link register, then two callee-saved registers, and so on. A single pair instruction does the work of two stores and one stack adjustment. It also keeps sp a multiple of 16 for free. This is a design that matches how real code behaves, the same lesson as the frequency table: ldp and stp were 4.6% and 3.8% of all ARM64 instructions in libc.
What is the limit of a stack? Its size. The OS gives each thread a fixed amount (commonly 8 MiB for the main thread on Linux), with an unmapped guard page below it. Recursion that goes too deep walks down into the guard page and the program dies with a segfault. That is a stack overflow, and it is the guard working as designed.
Common mistakes
- Unbalanced push and pop. If you push three times and pop twice,
rspis wrong when you return, and the return address is read from the wrong place. - Forgetting that the stack grows down. A local at
[rsp+8]is above[rsp], so it is something pushed earlier. - Breaking 16-byte alignment on ARM64.
sub sp, sp, #8followed by a stack access can fault. Movespin multiples of 16. - Using stack memory after it is freed. After
popor after adding back tosp, the bytes are still there but are fair game for the next push.
Say it simply
The stack is ordinary memory, managed by one register that points at the top and moves downward as you add things. x86-64 has push and pop. ARM64 has no such instructions and uses store and load with the base-updating addressing modes, usually a pair at a time. Everything stack-related (frames, saved registers, return addresses) is a convention built on that one register.
Check yourself
Exercise 8.1 (Warm-up): Trace by hand. Start with rsp = 0x7000. What are rsp and the memory contents after push 1; push 2; push 3? And after one pop? A correct result: rsp is 0x6fe8 after the three pushes; the value 3 is at 0x6fe8, 2 at 0x6ff0, 1 at 0x6ff8. After the pop, rsp is 0x6ff0 and the popped value is 3.
Exercise 8.2 (Warm-up): Explain. In five sentences, explain to a friend why ARM64 does not need push and pop. Use an analogy and say where it breaks. What a strong answer includes: pre- and post-indexed addressing already do "move the pointer and store"; pairs make it efficient; analogy: a stack of plates where the same tool both adds the plate and shifts the pile; where it breaks: real plates cannot be reached at an offset, while the CPU can read at [sp+8] without popping.
Exercise 8.3 (Core): Break it. In the x86-64 reverse program, remove the last pop from the loop so you pop one fewer value, or change the loop count to 4. Predict the result, run it, and use gdb to look at rsp at exit. A correct result: the array is wrong in its last element, and rsp is not where it began.
Exercise 8.4 (Core): Implement. On both ISAs, write a loop that pushes the numbers 1 to 8, then pops them and adds them up. On ARM64, use stp/ldp pairs and keep sp aligned. Done when: the sum is 36, the stack pointer is back at its starting value at the end, and you printed sp before and after in gdb to prove it.
Exercise 8.5 (Core): Predict, then check. What would happen if you pushed in an endless loop with no pops? Predict how many pushes it takes, using an 8 MiB stack. Then run it and read the signal and the address with gdb. A correct result: it keeps going for a long while (each push is 8 bytes, so you need about a million pushes to use 8 MiB) and then dies with SIGSEGV near the guard page. Say what the faulting address is relative to rsp.
Exercise 8.6 (Stretch): Design and defend. Some designs keep return addresses on a separate stack from data. Give one reason that is a good idea and one cost. What a strong answer includes: a bug that overwrites data cannot reach return addresses, which blocks a class of attacks; the cost is a second register and memory area to manage, and an extra place to switch on every thread and every context switch.
Chapter 9. Data in memory: sections, structs and padding
In this chapter: You will learn how to declare data in an assembly file, how structs become offsets, and how padding changes the size of a struct. You will fix a stride bug that is easy to write.
You need first: Chapters 6 to 8.
The problem first
Assembly has no types and no structs. It has bytes and offsets. If you want a struct point { long x; long y; }, you have to say in your own words that "x is at offset 0 and y is at offset 8," and then write those numbers every time you touch a field. Write them by hand, and the day you add a field, every number is wrong. We want the assembler to count for us, the same lesson as Chapter 1 about labels, one level up.
The model (the "what")
An assembly file is divided into sections. The three you need:
| Section | What lives there | Written by the program? | Stored in the file? |
|---|---|---|---|
.text | Code | no (read and execute) | yes |
.rodata | Constants, strings | no (read only) | yes |
.data | Initialized variables | yes | yes |
.bss | Zero-initialized variables | yes | no |
The last row is a clever trick. .bss bytes are not stored in your file at all. The loader allocates them and fills them with zeros when the program starts. I built two programs that each reserve one megabyte, one in .data and one in .bss. The real file sizes:
.data version: 1,057,392 bytes
.bss version: 4,736 bytes Both programs use one megabyte of memory when they run. One of them costs a megabyte of disk.
Data directives say how many bytes each item takes:
.section .data
counter: .quad 0 # 8 bytes
table: .quad 1, 1, 2, 3, 5, 8 # six 8-byte values
tiny: .byte 0x41 # 1 byte ('A')
mix: .short 1, 2, 3 # three 2-byte values
ratio: .long 0x3F800000 # 4 bytes (that bit pattern is the float 1.0)
.section .rodata
digits: .ascii "0123456789" # no terminator
greeting: .asciz "hi\n" # asciz adds a 0 byte at the end Two traps live here. First, .word means different sizes on the two machines. I assembled the same file for both. On x86-64, .word is 2 bytes. On ARM64 it is 4. Do not assume .word is 2 bytes. Use .short (2) and .long (4), which mean the same everywhere. Second, .align means different things. On x86-64, .align 4 means "align to a multiple of 4 bytes." On ARM64, .align 4 means "align to 2 to the power 4, which is 16 bytes." I tested both: after a single byte, .align 4 put the next label at offset 4 on x86-64 and at offset 16 on ARM64. To avoid the whole confusion, use .balign 16, which always means 16 bytes.
Structs are offsets. Let the assembler name them:
.set point.x, 0 # named offsets, computed by the assembler
.set point.y, 8
.set point.size, 16 An array of structs is base + i * size + field_offset. Here is where a real mistake hides, and it is worth looking at slowly.
Build it (the "how")
We have three points, (1,2), (3,4), (5,6), laid out one after another in memory: 1, 2, 3, 4, 5, 6. Each point is 16 bytes. We want the sum of the y fields: 2 + 4 + 6 = 12.
The first instinct: you know the scale is 8 (the size of a long), so write [rcx + rdx*8 + point.y]. Here is why that breaks. Index i must step 16 bytes per point, not 8. With scale 8, index 1 reads the second quad in the array, which is y of point 0 or x of point 1 depending on the field offset. I ran this arithmetic: reading with a stride of 8 returns [3, 4] for indices 1 and 2, but the correct y values for those points are [4, 6]. The code looks right and gives the wrong answer. It is an easy bug to write.
The scale is limited to 1, 2, 4 and 8, and 16 is not allowed. So we have to multiply the index ourselves (a shift by 4 gives times 16), or walk a pointer that advances by 16.
# x86-64: sum of the y fields of 3 points
.intel_syntax noprefix
.global _start
.set point.x, 0
.set point.y, 8
.set point.size, 16
.data
points: .quad 1, 2, 3, 4, 5, 6 # (1,2) (3,4) (5,6)
N = 3
.text
_start:
lea rcx, [rip+points]
xor eax, eax # sum = 0
xor edx, edx # i = 0
next:
mov rsi, rdx
shl rsi, 4 # i * 16 : the scale alone cannot do this
add rax, [rcx + rsi + point.y] # base + (i*16) + field offset
inc rdx
cmp rdx, N
jl next
mov rdi, rax # status = 12
mov eax, 60
syscall // ARM64: the same, walking a pointer by the struct size
.global _start
.equ point_y, 8
.data
points: .quad 1, 2, 3, 4, 5, 6
.text
_start:
adrp x1, points
add x1, x1, :lo12:points
mov x0, 0 // sum
mov x2, 3 // count
next:
ldr x3, [x1, #point_y] // p->y
add x0, x0, x3
add x1, x1, #16 // next struct: p += sizeof(struct point)
subs x2, x2, 1
b.ne next
mov x8, 93
svc #0 Both exit with status 12, as predicted. (Compilers on both machines usually pick the pointer-walking shape, as you saw in Chapter 5, so this is also how you will see it in real output.)
Padding. A compiler inserts invisible gaps so every field sits at an address that suits its size. Look at this real run, from C compiled and run on this machine:
struct a { char a; long b; char c; }; // fields in this order
struct b { long b; char a; char c; }; // same fields, reordered struct{char,long,char}: size=24 offsets a=0 b=8 c=16
struct{long,char,char}: size=16 offsets b=0 a=8 c=9 The same three fields take 24 bytes in one order and 16 in the other, which is a third smaller. The rule: each field's offset is a multiple of its own size, and the whole struct's size is rounded up to its largest field's alignment. The first struct has 7 bytes of padding after a, and 7 more after c.
C never reorders your fields. Rust does. I ran this too, with rustc 1.75:
Rust default repr: size=16 align=8
Rust repr(C) : size=24 align=8 A Rust struct with the fields u8, u64, u8 is 16 bytes by default, because the compiler is free to put the big field first. With #[repr(C)] (the C layout), it is 24. This matters the moment you write assembly that touches a Rust struct: you must use #[repr(C)] or you cannot know the offsets. Chapter 35 returns to this.
Why it is this way (the "why")
Why do alignment and padding exist? Because the hardware likes it. A field that sits inside one cache line and one word is read in one step (we measured the cost of crossing a line in Chapter 6). Padding spends bytes to save time. It also means that struct order is a real performance lever: for hot data, put the largest fields first.
Why does .bss exist? Because most big buffers start as zeros, and storing a megabyte of zeros in a file is waste. The trick turns "initialized to nothing" into "nothing stored."
What is the principle underneath? The same one as labels in Chapter 1: the human names things and the tool computes the numbers. Field offsets are labels for distances inside a struct.
Common mistakes
- The wrong stride for an array of structs. Use the struct's size, not the size of one field.
- Using
.wordand.alignacross machines. Use.short,.longand.balign. - Forgetting the NUL.
.asciihas no terminator..ascizdoes. - Assuming a Rust struct has C layout. It does not, unless you say
#[repr(C)]. - Writing to
.rodata. It faults.
Say it simply
An assembly file has sections: code, read-only data, initialized data, and zero-filled data that costs no disk space. Structs do not exist at this level. Fields are just offsets, and you should let the assembler name them. An array of structs steps by the whole struct's size, and compilers add padding so fields land on friendly addresses. Rust reorders fields unless you use #[repr(C)].
Check yourself
Exercise 9.1 (Warm-up): Predict, then check. What is sizeof for struct { int a; char b; int c; char d; }? What size do you get if you reorder it to put the ints first? Predict, then check with a C program and offsetof. A correct result: 16 bytes in the first order; 12 after reordering (4 + 4 + 1 + 1 + 2 padding).
Exercise 9.2 (Warm-up): Explain. In five sentences, explain .bss to a friend. Give an analogy and say where it breaks. What a strong answer includes: zero-initialized memory costs no file space because the loader creates and zeroes it; analogy: buying an empty lot instead of a lot with a pile of nothing already delivered; where it breaks: the memory is real once the program runs.
Exercise 9.3 (Core): Break it and fix it. In the x86-64 struct program, change shl rsi, 4 to a use of scale 8 (the buggy way). What does the program return? Explain the number. Then fix it. A correct result: the status is not 12 (it adds the wrong elements). Explain which quad words it summed.
Exercise 9.4 (Core): Implement. Define struct employee { long id; char name[16]; long salary; } in an assembly file with named offsets. Write find_max_salary(records, n) on x86-64 and ARM64. Use the assembler to compute the struct size from the offsets, and prove it by printing . - label between two consecutive records. Done when: it finds the right maximum for a 5-record array, and it also works for n = 1 and n = 0 (decide what n = 0 should return and say why).
Exercise 9.5 (Core): Rust layout. In Rust, write a struct with fields u8, u32, u16, u8 and print its size with and without #[repr(C)]. Use std::mem::offset_of! or manual pointer arithmetic to print each field's offset. Predict first. A correct result: the default layout is smaller or equal. Explain each gap in the repr(C) version.
Exercise 9.6 (Stretch): Design and defend. A hot loop reads only the y field of a million points. Compare the array-of-structs layout {x, y} with a struct-of-arrays layout (all x in one array, all y in another). Which is better for this loop and why? Name one loop where the other is better. What a strong answer includes: cache lines are full of useful data in the struct-of-arrays layout when only y is read; vector loads are straightforward for it; the array-of-structs layout is better when each iteration needs both fields of the same point.
Answer notes for Part 2
Read these only after you have tried the problems.
- 6.4: The sign-extended load of a byte whose top bit is 1 fills the register with 1s. The exit status only shows the low 8 bits, so both loads print the same status. To see the difference, print the whole register in
gdb(p/x $rax), or compare the two results with acmp. - 7.3:
48 8b 84 cb 00 01 00 00.48is the 64-bit prefix,8bis the load,84selects "SIB byte plus 4-byte displacement," andcbis the SIB byte holding scale 8 (top two bits11), indexrcxand baserbx. - 8.5: With an 8 MiB stack and 8 bytes per push, you need about a million pushes before the stack is gone. The fault address is just below the lowest mapped stack page.
- 9.1:
int aat 0,char bat 4, 3 bytes padding,int cat 8,char dat 12, 3 bytes tail padding gives 16. Reordering givesa0,c4,b8,d9, with 2 bytes of tail padding: 12.
Part 2 wrap-up
What you can now do:
- Say which byte of a number is first in memory, and prove it with a hex dump.
- Use
mov,ldrandstrwith the right widths, and say what each leaves in the upper bits. - Write any address you need: base, index, scale, offset, and PC-relative.
- Use
leaas arithmetic, and say why it leaves flags alone. - Use the stack with
push/popon x86-64 andstp/ldpon ARM64. - Lay out a struct by hand, and say why padding exists.
Mini project: the memory laboratory. Time: 3 to 5 hours.
On both ISAs, build a program that:
- Defines an array of 8 quad words in
.dataand a string in.rodata. - Sums the array three ways: base-plus-displacement, base-plus-scaled-index, and a walking pointer (post-index on ARM64).
- Reverses the array in place using the stack.
- Prints each result.
- Dumps its own array as hex bytes before and after, using a small loop, so little-endian layout is visible.
Done when: all three sums are equal; your dump matches xxd of the same memory in gdb (x/64xb &arr); and you wrote one paragraph explaining why the reverse worked without a second array.
Write-up (half a page): What did you expect, what did you see, what did you learn?
Break it: remove one .balign, store to .rodata on purpose, and push without popping. Write down the exact fault each time.
Reading list for Part 2:
- Intel SDM Vol. 1, chapter on data types and addressing modes. The official definition of base, index, scale and displacement.
- Arm Architecture Reference Manual, load/store addressing modes. The exact offset ranges and the pre/post-index rules.
- Ulrich Drepper, "What Every Programmer Should Know About Memory" (2007). The long-standing long-form explanation of caches and memory behavior. Read the early sections on alignment and cache lines.
- Eli Bendersky's blog post "Stack frame layout on x86-64". A clear picture of how the stack is used by compiled code. (We do this properly in Part 4.)
- The GNU assembler manual (
info as), "Machine Dependencies" for each target. This is where the.alignand.worddifferences are documented.
Teach it back. In five sentences, as if to a friend, explain how a CPU finds element i of an array, and why the same instruction does not work for an array of 16-byte structs on x86-64.
Part 3: Control Flow, Flags and Branches
After this Part you can write any if, loop or switch by hand on both machines. You will know why a branch can cost ten times what its neighbors cost, and when removing it is worth it.
Chapter 10. Flags: one-bit answers to comparison questions
In this chapter: You will learn what flags are, how cmp uses them, and how every conditional jump is a question about those flags. You will learn one difference between the machines (the carry flag) that older guides get wrong.
You need first: Chapters 2 and 6.
The problem first
A CPU has no if. It has a program counter and instructions that change it. So how does it decide? It needs a place to remember the result of a comparison, so that a later instruction can ask about it. That place is the flags.
The model (the "what")
Flags are single bits that arithmetic instructions set to describe what just happened. On x86-64 they live in the FLAGS register. On ARM64 they are the four bits of NZCV. Four of them do nearly all the work:
| Meaning | x86-64 | ARM64 |
|---|---|---|
| The result was zero | ZF | Z |
| The result's top bit is 1 (it looks negative) | SF | N |
| An unsigned result did not fit (carry or borrow) | CF | C |
| A signed result did not fit (overflow) | OF | V |
The deepest idea of this chapter is this: flags hold raw facts, not opinions. After a - b, the flags say whether the result was zero, whether its top bit is set, whether the subtraction ran out of bits unsigned, and whether it ran out of bits signed. They do not say "a is less than b." Whether that is true depends on whether you meant a and b as signed or unsigned numbers. The flags do not know. You say which, afterwards, by the jump you choose.
cmp a, b is not a special comparator. It is a subtraction a - b whose result is thrown away. Only the flags are kept. Every conditional jump is a question about those flags.
Here is the table of questions. Be careful with the unsigned rows for ARM64. Look at the carry column:
After cmp a, b, is... | Flags consulted (x86-64) | x86-64 jump | Flags consulted (ARM64) | ARM64 branch |
|---|---|---|---|---|
a == b | ZF=1 | je | Z=1 | b.eq |
a != b | ZF=0 | jne | Z=0 | b.ne |
signed a < b | SF≠OF | jl | N≠V | b.lt |
signed a <= b | ZF=1 or SF≠OF | jle | Z=1 or N≠V | b.le |
signed a > b | ZF=0 and SF=OF | jg | Z=0 and N=V | b.gt |
signed a >= b | SF=OF | jge | N=V | b.ge |
unsigned a < b | CF=1 | jb | C=0 | b.lo |
unsigned a <= b | CF=1 or ZF=1 | jbe | C=0 or Z=1 | b.ls |
unsigned a > b | CF=0 and ZF=0 | ja | C=1 and Z=0 | b.hi |
unsigned a >= b | CF=0 | jae | C=1 | b.hs |
Look at the unsigned rows. x86-64 says "less than" means CF=1. ARM64 says "less than" means C=0. The carry flag is inverted for subtraction between the two machines. I did not want to take this on trust, so I measured it. Here are real flag values after four operations, read from the actual hardware flags on x86-64 and from NZCV under qemu-user on ARM64:
| Operation | x86-64 flags | ARM64 flags |
|---|---|---|
INT64_MAX + 1 | CF=0 ZF=0 SF=1 OF=1 | N=1 Z=0 C=0 V=1 |
0 - 1 | CF=1 ZF=0 SF=1 OF=0 | N=1 Z=0 C=0 V=0 |
5 cmp 3 (no borrow) | CF=0 ZF=0 SF=0 OF=0 | N=0 Z=0 C=1 V=0 |
5 cmp 7 (borrow) | CF=1 ZF=0 SF=1 OF=0 | N=1 Z=0 C=0 V=0 |
-1 + 1 (unsigned wrap) | CF=1 ZF=1 SF=0 OF=0 | N=0 Z=1 C=1 V=0 |
For addition, the two machines agree: carry out means 1. For subtraction, x86-64 sets CF when there is a borrow, and ARM64 sets C when there is no borrow. That is exactly what the table says. If you ever translate code from one machine to the other, this is the line that bites.
Other flag facts worth knowing:
- Signed overflow happens when two numbers with the same sign give a result with the other sign.
INT64_MAX + 1shows it: OF=1. - Unsigned carry happens when a bit falls out of the top.
0xFFFFFFFFFFFFFFFF + 1shows it: CF=1, and the result is zero (ZF=1). - On x86-64,
incanddecchange every flag exceptCF. I tested it: afterstc(set carry) andinc rax,CFwas still 1. - On x86-64, most arithmetic sets flags every time. On ARM64, only instructions with an
ssuffix (adds,subs) and the compare instructions (cmp,cmn,tst) set them. When you read ARM64 code, thestells you flags matter here.
Build it (the "how")
Let's see the "raw facts" idea fail on purpose. We compare -1 with 1, as bits 0xFFFFFFFFFFFFFFFF against 1. The flags are the same either way. Only the jump differs:
# x86-64
mov rax, -1 # all bits set
mov rbx, 1
cmp rax, rbx
jl yes # signed less-than (swap this for jb to test unsigned) I ran both. With jl the program took the branch (the answer was "yes, -1 is less than 1"). With jb it did not (as an unsigned number, 0xFFFFFFFFFFFFFFFF is the biggest value there is). Same cmp, same flags, two different truths, chosen by your jump.
That is a very common bug. A C int compared with an unsigned quietly becomes an unsigned comparison, and the compiler emits jb or ja. In assembly you can see the choice. In C you cannot.
Now the other flag tool, test. test a, b does a AND b, throws away the result, and keeps the flags. The famous form is test rax, rax: it ANDs a register with itself, so ZF tells you whether it is zero and SF tells you whether it is negative. You saw it in Chapter 5 guarding n <= 0.
Why it is this way (the "why")
Why raw facts, not opinions? Because it is cheap and general. The subtraction already computes everything in one pass. Reading four bits out of it costs nothing. And the same flags serve signed and unsigned code, so the hardware needs only one compare path.
Why does ARM64 invert the carry? Because ARM defines subtraction as an addition: a - b is computed as a + (NOT b) + 1. The carry out of that addition means "no borrow." The designers kept the adder's carry as it is, one meaning for add and sub. x86-64 went the other way: it flips the carry out of a subtraction so that CF means "borrow." Neither choice is wrong. They are two ways to spend the same single bit.
What would make flags a bad idea? They are a hidden shared state. Every instruction that sets flags is a write to a place that every later conditional reads. That creates a chain the CPU must track. RISC-V avoids the problem by having no flags at all: beq and blt compare two registers inside the branch itself. It is a real trade-off, and the next two chapters show what the flag chain costs.
Common mistakes
- Choosing the wrong jump for the type.
jlversusjb. Use the table. - Assuming CF means the same thing on ARM64. For subtraction it is inverted.
- Putting an instruction that changes flags between the
cmpand its jump. The flags belong to the most recent flag-setting instruction. - Forgetting that
leaandmovdo not change flags (good to know), butadddoes (bad to forget). - Expecting
incto changeCF. It does not on x86-64.
Say it simply
Flags are single bits that remember facts about the last operation: zero, negative, carry or borrow, and overflow. A compare is a subtraction that keeps only the flags. A conditional jump then asks a question about those flags, and you choose whether to treat the numbers as signed or unsigned by choosing the jump. On subtraction the carry flag means "borrow" on x86-64 and "no borrow" on ARM64.
Check yourself
Exercise 10.1 (Warm-up): Predict, then check. Predict CF, ZF, SF and OF after mov al, 127; add al, 1 and after mov al, 255; add al, 1 (8-bit registers). Then check using pushfq and gdb, or with the helper program idea from this chapter. A correct result: the first gives OF=1, SF=1, CF=0, ZF=0 (a signed overflow). The second gives CF=1, ZF=1, SF=0, OF=0 (an unsigned wrap).
Exercise 10.2 (Warm-up): Explain. In five sentences, explain to a friend why the same cmp can mean "less" or "not less." Use an analogy and say where it breaks. What a strong answer includes: flags are raw facts about a subtraction; signed and unsigned are two readings of the same bits; analogy: a thermometer reading of 30 can mean hot in Celsius and cold in Fahrenheit (the number is the same; the scale is chosen later); it breaks because in the CPU the "scale" is chosen by an instruction, not by a label on the number.
Exercise 10.3 (Core): Break it. Write the -1 versus 1 program above on x86-64 with jl, then with jb. Predict both results first. Then rewrite it for ARM64 with b.lt and b.lo. Do the results match? A correct result: jl and b.lt say "yes"; jb and b.lo say "no". The answers match even though the carry values are opposite.
Exercise 10.4 (Core): Measure it yourself. Write an ARM64 program that does cmp x0, x1 for (5, 3) and (5, 7), reads nzcv with mrs x0, nzcv, shifts it right by 28, and exits with the result. Do the same on x86-64 with pushfq and pop rax. Make a table like the one above. A correct result: your C and CF columns are opposite for the subtraction cases.
Exercise 10.5 (Core): Trace by hand. For mov rax, 5; mov rbx, 7; cmp rax, rbx, work out by hand the result of 5 - 7 as a 64-bit value, and the four flags. Then say which of jl, jb, jg, ja, je are taken. A correct result: the result is 0xFFFFFFFFFFFFFFFE; SF=1, CF=1 (borrow), ZF=0, OF=0; jl, jb and jne are taken; jg, ja and je are not.
Exercise 10.6 (Stretch): Design and defend. RISC-V has no flags: a branch compares two registers directly. Give two advantages and two disadvantages of that design. Then say which one you would choose for a CPU that runs 8 instructions at a time. What a strong answer includes: advantages: no hidden shared state, branches are independent so the hardware tracks fewer dependencies; disadvantages: compare-and-branch is a bigger instruction, and multi-word arithmetic (carry chains) needs extra instructions to recover the carry; plus a reasoned choice for a wide CPU.
Chapter 11. Branches and loops
In this chapter: You will learn how each machine jumps, how loops are built from jumps, and the three branch instructions that save a compare (test, cbz, tbz). You will see why compilers write loops the way they do.
You need first: Chapter 10.
The problem first
You have a flag question, and you want the program to act on it. A branch is an instruction that says: if the question is true, set the program counter to this label. Otherwise, fall through to the next instruction. A jump with no question (always taken) is an unconditional branch.
The model (the "what")
# x86-64: the branch family
cmp rax, rbx
je equal # taken if ZF=1
jne not_equal
jl less # signed
jae above_or_equal # unsigned
jmp always # no question asked // ARM64: the same family
cmp x0, x1
b.eq equal
b.ne not_equal
b.lt less // signed
b.hs above_or_equal // unsigned
b always How far can a branch reach? A branch stores its distance inside the instruction, so the distance has a limit. This is a real constraint when a function gets large. On x86-64 you can see two sizes for the same je, with real encodings:
74 01 je short_target (1-byte distance: reaches about +/-127 bytes)
0f 84 c8 00 00 00 je near_target (4-byte distance: reaches about +/-2 GiB) The assembler picks the short form when the target is close and the long form when it is not. On ARM64 the reach is fixed by the instruction:
| ARM64 instruction | Reach |
|---|---|
b, bl | about ±128 MiB |
b.cond, cbz, cbnz | about ±1 MiB |
tbz, tbnz | about ±32 KiB |
(tbz has less room than cbz, because the instruction also holds a bit number.)
Three instructions that save a compare.
test(x86-64).test rax, raxsets flags fromrax AND rax. It is the idiom for "is this zero, or negative?" Compilers usetest/jefor almost every null-pointer check. You can also test a mask:test al, 8checks whether bit 3 is set.cbzandcbnz(ARM64). "Compare and branch if zero / if not zero." One instruction does the compare and the branch:cbz x0, done. It does not touch the flags.tbzandtbnz(ARM64). "Test bit and branch."tbnz x0, #3, setbranches if bit 3 ofx0is 1. It is the hardware way to writeif (flags & 8).
x86-64 has no instruction that fuses a compare and a branch into one encoded instruction. But modern x86-64 cores do macro-fusion: they spot a cmp or test followed immediately by a conditional jump and run the pair as one internal operation. So two instructions on the page can cost the same as one.
Loops. Every loop is these two shapes. A while loop tests at the top. A do-while loop tests at the bottom. Compilers prefer the do-while shape for counted loops, because it needs one branch per trip instead of two. They guard it once with an if before the loop begins. You saw exactly this in Chapter 5: test rsi, rsi; jle .L4 (the guard), then the loop body, then cmp; jne at the bottom.
Counting down removes the compare entirely, because an instruction that decrements already sets the zero flag.
Build it (the "how")
Let's sum the numbers 1 to 20. The answer is 210. First counting up on x86-64:
# x86-64: do-while shape, counting up
.intel_syntax noprefix
.global _start
.text
_start:
xor eax, eax # sum = 0
mov ecx, 1 # i = 1
top:
add eax, ecx # body
inc ecx # update
cmp ecx, 20 # test
jle top # signed i <= 20 -> go again
mov edi, eax # exit status = sum = 210
mov eax, 60
syscall Now counting down. The cmp disappears:
# x86-64: counting down. dec sets ZF, so no compare is needed.
xor eax, eax
mov ecx, 20
top:
add eax, ecx
dec ecx # ecx -= 1, and ZF = (ecx == 0)
jnz top # repeat while it is not zero
... // ARM64: counting down. subs sets the flags.
mov w0, #0 // sum
mov w1, #20
top:
add w0, w0, w1
subs w1, w1, #1 // the "s" suffix: set flags
b.ne top All three exit with status 210. The count-down versions are one instruction shorter in the loop. That is why compiled loops so often count down, even when your source counts up (when the order does not matter to the result).
One more tip: x86-64 has a loop instruction ("decrement rcx and jump if not zero"). Do not use it. It exists for history, and on modern chips it is slower than dec plus jnz. This is a good example of a rule from Chapter 5's vocabulary: not every instruction in the manual is one you want.
Why it is this way (the "why")
Why does the loop test go at the bottom? Count the branches per trip. A top-tested loop needs a conditional branch at the top (to leave) and an unconditional jmp at the bottom (to return). A bottom-tested loop needs only one conditional branch per trip. Over a million trips, that is a million fewer instructions.
Why do branches have limited reach? Because the distance is stored inside the instruction, and an instruction is small. tbz has a small reach because it also stores a register and a bit number. When a target is too far, the compiler (or the linker) reverses the test and jumps over an unconditional b, which has a much bigger reach. When you see an inverted jump over a lone b in real output, now you know the cause.
What would make this bad? The conditional branch is also the place where the CPU has to guess. Which way it goes decides what the CPU fetches next, before the answer is known. That is the subject of the next chapter, and it is where branches get expensive.
Common mistakes
- Using
jlewhen the counter is unsigned. Usejbe. - A loop that never ends because the counter is changed inside the body. (Or because a syscall destroyed it. See Chapter 4.)
- Off-by-one:
jlversusjle. Write the first and last value on paper. - Using
loop. Slower thandecandjnz. - Forgetting the guard. A do-while loop runs at least once. If
ncan be zero, you need theifbefore it.
Say it simply
A branch is an instruction that sets the program counter if a flag condition holds. A loop is a branch that goes backward. Compilers put the test at the bottom so each trip costs one branch, and they count down when they can, because decrementing sets the zero flag for free. test, cbz and tbz save a compare when you only need to ask about zero or a single bit.
Check yourself
Exercise 11.1 (Warm-up): Predict, then check. Without running anything, say how many times the body runs for mov ecx, 5; top: ...; dec ecx; jnz top and for the same loop with mov ecx, 0 at the start. Then run both with a counter. A correct result: 5 times, and then... a huge number of times (4 billion), because dec of 0 wraps around to 0xFFFFFFFF, which is not zero. That is the "forgot the guard" bug.
Exercise 11.2 (Warm-up): Explain. In five sentences, explain why compilers put the loop test at the bottom. Include an analogy and where it breaks. What a strong answer includes: one conditional branch per trip instead of a conditional plus an unconditional jump; the guard before the loop covers the zero-trip case; analogy: checking the oven at the end of each stir rather than walking back to the door; where it breaks: the CPU predicts the branch, so the real cost is mispredictions and not instruction count alone.
Exercise 11.3 (Core): Implement. Write count_set_bits(x) on both ISAs with a loop that tests the low bit and shifts right (do not use popcnt or cnt). Use test al, 1 or tbnz x0, #0 for the test. Done when: it returns 0 for 0, 1 for 1, 8 for 0xFF, and 64 for 0xFFFFFFFFFFFFFFFF, and you can say how many loop trips each case needs.
Exercise 11.4 (Core): Break it. Write a long jump on x86-64: je far with .rept 200 of nop between the jump and its target. Look at the encoded bytes. Then do the same on ARM64 with a cbz and .rept 300000 of nop, and read what the assembler says. A correct result: x86-64 switches to the 6-byte near form. ARM64's cbz cannot reach about 1.2 MB of padding: I got Error: conditional branch out of range. With 200,000 nops (800 KB) it assembled and linked fine.
Exercise 11.5 (Core): Predict, then check. Compile long count(const long *a, long n) { long c = 0; for (long i = 0; i < n; i++) c += (a[i] == 0); return c; } at -O2 for both machines. Predict whether each compiler counts up or down and whether it uses a pointer or an index. Read the output. A correct result: it depends on the compiler version. The point is to see whether your prediction was right, and to name every transformation you spot.
Exercise 11.6 (Stretch): Design and defend. cbz and tbz exist on ARM64 and cost encoding space. Make the case for removing them in favor of cmp plus b.cond, or for adding more fused-compare branches. Use the frequency data from Chapter 5 (cbz was 2.4% of ARM64 instructions, cbnz 1.4%, tbnz 0.7%). What a strong answer includes: the data shows they are common enough to justify; the saving is one instruction and no flag write; the cost is encoding space and a smaller reach; and a reasoned position.
Chapter 12. Branch prediction and branchless code
In this chapter: You will learn why a branch can be slow even when it is only one instruction. You will measure the cost on a real machine. You will learn the instructions that choose a value without branching, and the cases where removing a branch makes code slower.
You need first: Chapters 10 and 11.
The problem first
The CPU in Chapter 2 runs one instruction at a time. A real CPU does not. It starts the next instructions before the current ones finish, like a factory line, so that many instructions are in progress at once. This line is called the pipeline.
A branch is a problem for a pipeline. To keep it full, the CPU must fetch the instructions after the branch. But which ones? Until the flags are known, the CPU does not know whether the branch will jump. Waiting would leave the line empty. So the CPU guesses.
The model (the "what")
This guess is called branch prediction. The CPU keeps a record of how each branch behaved before, and bets on the same again. It starts running the guessed path right away. This is speculative execution. If the guess was right, no time was lost. If it was wrong, the CPU has to throw away everything it started on the wrong path and begin again from the correct place. That is a misprediction.
time -->
guessed right: [fetch A][fetch B][fetch C]... keeps going
guessed wrong: [fetch A][fetch B (wrong!)] ...flush... [fetch X][fetch Y]
^^^^^^^^^^^^^
this gap is the cost How much does a miss cost? Typical numbers are around 15 to 20 cycles on a modern desktop or server core. The exact number depends on the chip. Agner Fog's microarchitecture manuals list it for each one. Rather than quote a figure, let me measure it. This is the experiment, in C. The loop adds up the elements of an array that are at least 128. The array holds random numbers from 0 to 255, so the branch is true half the time, with no pattern.
// branchy: a real conditional branch (compiled with if-conversion off)
long branchy(const int* d) {
long s = 0;
for (int i = 0; i < N; i++) { if (d[i] >= 128) s += d[i]; }
return s;
}
// branchless: arithmetic only. m is all-ones if bit 7 is set, else zero.
long branchless(const int* d) {
long s = 0;
for (int i = 0; i < N; i++) { int v = d[i]; int m = -((v >> 7) & 1); s += v & m; }
return s;
} Each is run on the random array, and then on the same array sorted. When it is sorted, the branch is false for the first half and true for the second half, so it becomes easy to predict. I confirmed in the disassembly that the first function really has a conditional jump (jle) and the second does not. Real results on this machine (Intel Xeon @ 2.80 GHz, one core, in a VM), best of five runs:
| random data | sorted data | |
|---|---|---|
| branchy | 4.02 ns per element | 0.44 ns per element |
| branchless | 0.80 ns per element | 0.80 ns per element |
Read this table slowly, because it holds three lessons.
- The branch costs 9 times more on random data (4.02 vs 0.44). The work is identical. Only the predictability changed. (The size of the ratio depends on how much other work is in the loop; a tiny loop like this one shows a large ratio. 9 times is what I measured, and I repeated the experiment in two more sessions and got 9.8 and 9.0 times, so this ratio is stable even though other numbers on this machine were not. See the note at the start of Part 8.)
- The branchless version is flat: 0.80 ns either way. It does not care about the data. On random data it is 5 times faster than the branchy loop.
- On sorted data the branchless version is slower: 0.80 versus 0.44. When the branch is easy to predict, the branch is nearly free, and the branchless code does extra arithmetic for nothing. So "branchless is faster" is not a rule. It is a rule for unpredictable data.
Let's work out the cost of one miss. Half the elements miss. The extra time per element is about 4.02 minus 0.44, which is 3.58 ns. Divide by one half, and one miss costs about 7 ns. If this core ran near 2.8 GHz, that is about 20 cycles. (I did not measure the clock, so treat "about 20 cycles" as the right ballpark and not an exact figure.)
The tools for choosing without branching.
x86-64 has cmovcc, the conditional move: copy the source to the destination if the flag condition holds, otherwise do nothing. And setcc turns a flag into a 0 or 1 in a register.
# x86-64: max(a, b) without a branch
mov rax, -5 # a
mov rcx, 3 # b
cmp rax, rcx
cmovl rax, rcx # if a < b (signed), rax = b. Otherwise leave rax alone.
# rax = 3. No branch exists, so nothing can be mispredicted. ARM64 has a richer family: csel (select), cset (set to 1 or 0), cinc, csinc, csneg:
// ARM64: max(a, b)
mov x0, #-5
mov x1, #3
cmp x0, x1
csel x0, x0, x1, ge // x0 = (x0 >= x1) ? x0 : x1 -> 3 I ran both, and both returned 3. Now the family, which a lot of compiler output uses:
csel xd, xn, xm, condpicksxnif the condition holds, elsexm.cset xd, condsetsxdto 1 if the condition holds, else 0.csinc xd, xn, xm, condpicksxnif the condition holds, otherwisexm + 1.
csinc is easy to read backwards. I tested csinc x0, xzr, x0, eq, which is not "x0 + 1 if equal, else 0." With the condition true, the result was xzr (0), and with the condition false, it was x0 + 1 (11 when x0 was 10). The pattern is "first source if true, second source plus one if false." And cset is defined as csinc xd, xzr, xzr, <opposite condition>.
ARM64 has one more tool that has no x86-64 twin: ccmp, a conditional compare. It compares only if the previous condition held, and otherwise forces the flags to a fixed value you choose. That builds || and && chains without branches:
// ARM64: z = (x == 0 || y == 0), with no branch
cmp x0, #0
ccmp x1, #0, #4, ne // if x != 0: compare y with 0. else: set flags to 0b0100 (Z=1)
cset x2, eq // Z is set if either was zero I tested it with (x=5, y=0) and got 1, and with (x=5, y=6) and got 0. The third operand, #4, is the flag pattern to install when the condition fails, and 4 means Z=1.
Build it (the "how")
Let's see how the compiler wrote the branchless loop from the experiment. It did not use cmov at all. It did this, real output from gcc -O1:
mov edx, DWORD PTR [rdi] # v
mov eax, edx
shl eax, 24
sar eax, 31 # arithmetic shift: smear one bit across the whole register
and eax, edx # v & mask
cdqe
add rcx, rax # s += ...
add rdi, 4
cmp rdi, rsi
jne (top) There is still one branch, the loop test at the bottom, but it is almost always taken, so it predicts perfectly. The unpredictable data-dependent branch is gone, replaced by a shift that turns one bit into a mask. This is the mask trick: if the condition is a bit, smear it into all-ones or all-zeros and and it with the value.
Why it is this way (the "why")
The idea underneath is data flow over control flow. A branch asks the CPU to choose what to do. A cmov asks it to choose which value, after computing both. The first needs a guess. The second needs none, because there is nothing to guess; both paths' values are computed (cheaply, predictably) and one is picked. You trade a possible 20-cycle stall for a few extra, certain instructions. That trade wins when the branch is unpredictable and loses when it is not, exactly as the table showed.
Where branchless cannot go. A cmov picks between values. It cannot skip an operation. If one path dereferences a pointer that might be null, a branchless version would still dereference it, and fault. This is why a compiler keeps a branch for p ? *p : 0. I compiled that, and gcc -O2 produced test rdi, rdi; je .L1; mov eax, [rdi]: a real branch that guards the load. The same goes for division (a divide by zero) and anything with a side effect. When you see a branch where you expected a cmov, this is usually why.
The cost of speculation. A CPU that runs ahead on a guess does work that might be thrown away. In 2018 researchers showed that the leftover effects of such discarded work (in the cache) could leak secrets. The attacks are called Spectre and Meltdown. This is why some software uses extra instructions to stop speculation. It is a good reminder that "the CPU guesses" is not only a performance fact.
Common mistakes
- Assuming branchless is always faster. It lost on sorted data above. Measure on your data.
- Assuming a compiler
cmovis always a win. Compilers sometimes make a branch into acmovand make code slower, when the branch was predictable. - Using
cmovwhere one path must not run. It still computes both. - Benchmarking only sorted or only random data. Test both, and the real data.
- Ignoring the loop branch. It is usually predictable, so it is rarely the problem.
Say it simply
A CPU runs ahead of the branch by guessing which way it will go. A right guess is free. A wrong guess costs around 15 to 20 cycles. On random data a branch inside a loop can make that loop several times slower, and on this machine it was 9 times. Instructions like cmov and csel pick a value without branching, and win when the branch is unpredictable, but lose when it is easy, and cannot skip work that must not run.
Check yourself
Exercise 12.1 (Warm-up): Predict, then check. Before running the benchmark, predict the four numbers in the table in your own words (which are fast, which slow, and by roughly how much). Then compile br.c as shown and run it. Compare. A correct result: branchy-random is the slowest by a wide factor; branchless is flat; branchy-sorted is the fastest. Your absolute numbers will differ.
Exercise 12.2 (Warm-up): Explain. In five sentences, explain to a friend why one branch can cost 20 times a simple instruction. Use an analogy and say where it breaks. What a strong answer includes: the CPU runs ahead on a guess; a wrong guess throws away the work; analogy: a driver who takes a turn before the GPS says, and has to turn back; where it breaks: the CPU has a record of past behavior and is right most of the time.
Exercise 12.3 (Core): Implement. On both ISAs, write abs(x) and clamp(x, lo, hi) with no branches. Use cmov/csel or cneg on ARM64 (hint: cneg negates if a condition holds). Done when: both are correct for 0, -1, INT64_MIN and INT64_MAX. For abs(INT64_MIN), say what your code returns and what a correct abs should return (there is no positive value that fits).
Exercise 12.4 (Core): Break it. Change the benchmark so the branch is predictable in a different way: use data that repeats a pattern like 0,255,0,255,.... Predict the branchy time. Run it. A correct result: modern predictors learn short patterns, so the branchy loop is fast again, close to the sorted time. That shows what the CPU actually predicts: not "sorted," but "a pattern it can learn."
Exercise 12.5 (Core): Predict, then check. Compile int f(int *p) { return p ? *p : 0; } and int g(int a, int b) { return a < b ? a : b; } at -O2 for both ISAs. Predict which one uses cmov or csel and which one keeps a branch. Check. A correct result: g is branchless (cmov/csel); f keeps a branch to guard the load. Say why for each.
Exercise 12.6 (Stretch): Design and defend. A colleague says: "always compile with a flag that turns every branch into a cmov." Write a reply that is fair. Say when it would help, when it would hurt, and how you would settle it with data. What a strong answer includes: unpredictable branches benefit; predictable ones lose (the sorted case); some paths cannot be speculated (loads, divides, side effects); the test is a benchmark on real data, and a profile (such as branch-miss counters) to find which branches miss.
Chapter 13. Jump tables and indirect branches
In this chapter: You will learn how a switch becomes a table, how a jump through a table works on both machines, and what an indirect branch costs. This is the base for the interpreter loop you will build in Chapter 37.
You need first: Chapters 7, 11 and 12.
The problem first
You have a switch with many cases. A chain of cmp and je would test them one after another: slow for the last case. If the cases are the numbers 0, 1, 2, 3, ..., there is a better way. Use the number to index a table of addresses, and jump straight there. One lookup, no matter how many cases.
The model (the "what")
A normal branch has its target written inside the instruction. An indirect branch takes its target from a register or from memory. That is what lets the target be chosen at run time. The table of addresses is a jump table.
# x86-64: a jump table by hand
.section .rodata
.balign 8
table:
.quad case0, case1, case2, case3 # the addresses of four labels
.text
mov eax, 2 # the selector
cmp eax, 3
ja default # outside 0..3? (unsigned: also catches negatives)
lea rdx, [rip+table]
jmp qword ptr [rdx + rax*8] # jump to the address stored at table[selector] // ARM64: the same idea
.section .rodata
.balign 8
table:
.quad case0, case1, case2, case3
.text
mov x0, #2
cmp x0, #3
b.hi default // unsigned
adrp x1, table
add x1, x1, :lo12:table
ldr x2, [x1, x0, lsl #3] // x2 = table[selector]
br x2 // indirect branch I ran both with the selector set to 2, and each landed on case2 and exited with status 30, as expected. One detail worth a second look: the single cmp and ja handles both "too big" and "negative." A negative number, read as unsigned, is enormous, so it is above 3. This is a favorite compiler trick: one unsigned compare checks both ends of a range.
What the compiler really does. Here is something that surprised me, so run this yourself. Take a switch where every case just returns a constant:
int f(int x){ switch(x){ case 0: return 10; case 1: return 20; case 2: return 37;
case 3: return 41; case 4: return 5; case 5: return 77; default: return -1; } } gcc -O2 did not make a jump table. It made a table of the answers: a range check (cmp edi, 5; ja), and then one load, mov eax, [table + rdi*4]. There was no indirect jump at all. A jump table appears only when the cases do different work. So here is a switch whose cases call different functions, and the real gcc -O2 output.
On x86-64, built as a non-PIE program, the table holds absolute addresses:
g:
cmp edi, 4
ja .L2
mov edi, edi # zero the upper half of rdi (a 32-bit write)
jmp [QWORD PTR .L4[0+rdi*8]] # jump through the table
.L4:
.quad .L8, .L7, .L6, .L5, .L3 # one address per case Built the default way (a position-independent program), the table holds distances instead of addresses, so it works wherever the program loads:
g:
cmp edi, 4
ja .L2
lea rdx, .L4[rip] # the table's address
mov edi, edi
movsx rax, DWORD PTR [rdx+rdi*4] # load a 4-byte offset, sign-extended
add rax, rdx # table address + offset = the real target
jmp rax
.L4:
.long .L8-.L4, .L7-.L4, .L6-.L4, .L5-.L4, .L3-.L4 On ARM64 the compiler did something different for the same five cases: it used a short chain of compares (cmp w0, 2; beq ...; cmp w0, 3 ...) with no table at all. With 12 cases, it built a table of one-byte offsets:
h: cmp w0, 11
bls .L17
mov w0, -1
ret
.L17:
adrp x1, .L4
add x1, x1, :lo12:.L4
ldrb w1, [x1, w0, uxtw] // one BYTE per case
adr x0, .Lrtx4
add x1, x0, w1, sxtb #2 // base + (byte * 4)
br x1
.L4:
.byte (.L15 - .Lrtx4) / 4, (.L14 - .Lrtx4) / 4, ... Why one byte, and why divide by 4? Every ARM64 instruction is 4 bytes, so a target is always a multiple of 4. The table stores the distance in instructions, so one byte can reach 128 instructions either way. That is a neat use of the fixed instruction width, and it makes the table 8 times smaller than a table of 8-byte addresses.
Computed goto. GNU C has a feature, goto *ptr, that compiles to an indirect jump. It is how many fast interpreters are written. We use it in Chapter 37.
Build it (the "how")
The two programs above are complete once you add the case labels. Here is the x86-64 one in full:
.intel_syntax noprefix
.global _start
.section .rodata
.balign 8
table:
.quad case0, case1, case2, case3
.text
_start:
mov eax, 2 # try 0..3 and 9
cmp eax, 3
ja default
lea rdx, [rip+table]
jmp qword ptr [rdx + rax*8]
case0: mov edi, 10
jmp done
case1: mov edi, 20
jmp done
case2: mov edi, 30
jmp done
case3: mov edi, 40
jmp done
default: mov edi, 99
done: mov eax, 60
syscall Change mov eax, 2 to each of 0, 1, 2, 3 and 9. You should get 10, 20, 30, 40 and 99.
Why it is this way (the "why")
Why is an indirect branch harder to predict? A conditional branch has two possible outcomes. An indirect branch can have as many targets as there are table entries. The CPU must guess where to go, not just whether. So the predictor holds a record of past targets, and an unpredictable selector (say, the opcode in an interpreter) can miss often. This is why interpreter dispatch is a performance topic, and why Chapter 37 measures it.
Why do position-independent programs use offsets? The address of a table entry's target changes when the program loads at a new address. An absolute address in the table would need patching by the loader on every run. An offset from the table itself never changes. This is the same "everything is a distance" idea as rip-relative addressing and adrp.
The security angle. Indirect branches are a target for attacks (an attacker who can change a table entry controls where the program goes). Modern systems add checks. On x86-64 Linux, compilers often emit notrack jmp and every indirect-jump target starts with endbr64 (Chapter 17). On ARM64 there is a similar feature called BTI.
What would make a jump table the wrong choice? If there are only two or three cases, a compare chain is smaller and well predicted. If the cases are sparse (say, 1, 1000, 1000000), a table would be mostly empty, so compilers use a binary search of compares instead. The compiler chooses by counting.
Common mistakes
- Not range-checking the selector. A bad index jumps to garbage.
- Using a signed compare for the range check. Use unsigned (
ja,b.hi), so negatives are rejected too. - A table in
.datawhen it can be.rodata. Read-only is safer. - Absolute addresses in position-independent code. Use offsets.
- Expecting a
switchto always become a table. It may become a value table, a compare chain, or a tree.
Say it simply
A jump table is an array of addresses. You use the selector as an index, load the address, and jump to it, so any case takes the same time. One unsigned compare checks the range. Compilers build tables only when the cases do different work, and use offsets instead of addresses in position-independent code. An indirect branch has many possible targets, so it can be harder to predict than a normal branch.
Check yourself
Exercise 13.1 (Warm-up): Predict, then check. Using the x86-64 jump table, predict the exit status for selectors 0, 3 and 4. Run all three. A correct result: 10, 40, 99.
Exercise 13.2 (Warm-up): Explain. In five sentences, explain to a friend why one cmp and one ja rejects both too-large and negative selectors. What a strong answer includes: a negative number read as unsigned is a huge positive number; so "above 3, unsigned" covers both ends; this saves a second compare.
Exercise 13.3 (Core): Implement. Extend the jump-table program so that a table of 8 handlers each add a different amount to a running total, and loop over a short array of selectors. Do it on both ISAs. Make the ARM64 table use offsets in instructions (as the compiler did) rather than addresses. Done when: an array of selectors like [3, 0, 7, 2] gives the right total, and a selector of 8 or more is handled safely.
Exercise 13.4 (Core): Predict, then check. Compile your own switch with 4, 8, 20 and 100 cases, once with a different function call per case and once returning constants. For each of the 8 versions, predict: jump table, value table, or compare chain? Check on both ISAs. A correct result: the constant-returning ones become value tables (or arithmetic), and the others become jump tables beyond a handful of cases. Record where each compiler's threshold lies.
Exercise 13.5 (Core): Break it. Remove the range check from your jump table and call it with selector 9. What does the program do? Use gdb to find the address it jumped to. A correct result: it jumps to whatever bytes sit after the table, and crashes (SIGSEGV or SIGILL). Explain why the range check is not optional.
Exercise 13.6 (Stretch): Design and defend. An interpreter's main loop has 60 opcodes. Compare (a) one switch with one indirect jump at the top, and (b) a separate indirect jump at the end of every opcode handler (the "threaded" style). Which predicts better, and why? What a strong answer includes: in (a) all dispatches share one branch, so the predictor sees a mixed history; in (b) each handler's jump has its own history, which often follows patterns (an if is often followed by a jump); cost is code size and compiler support (computed goto). We measure this in Chapter 37.
Answer notes for Part 3
- 11.1:
dec ecxon 0 gives0xFFFFFFFF, which is not zero, so the loop runs about 4.29 billion times. The fix is a guard (test ecx, ecx; jz skip) before the loop. - 12.3:
abs(INT64_MIN)has no correct positive answer in 64 bits. Two's complement negation of the most negative number gives the same number back (INT64_MIN). Real code must decide: wrap, saturate, or trap. In C it is undefined behavior; in Rust,abspanics in debug builds andwrapping_absreturnsINT64_MIN. - 12.4: A repeating pattern like
0,255,0,255is learned by the predictor within a few iterations; expect the time to drop to near the sorted case. - 13.3 (hint): On ARM64, a table entry
(handler - table_base) / 4fits in a byte if all handlers are within 128 instructions. Compute with.byte (label - base)/4and decode withadd x1, base, w1, sxtb #2.
Part 3 wrap-up
What you can now do:
- Name the four flags and say which instructions set them on each machine.
- Choose the right conditional jump for signed and unsigned comparisons.
- Say why the carry flag is inverted between x86-64 and ARM64 for subtraction.
- Write loops in do-while shape and count-down shape.
- Say what a branch misprediction costs, and show it with a measurement.
- Write
max,absand a boolean chain without branches, and say when not to. - Build and read a jump table on both machines.
Mini project: the branch laboratory. Time: 4 to 6 hours.
On both ISAs, in assembly or C with inspected output:
- Classify an array of signed 64-bit numbers into counts of negative, zero and positive, first with branches, then without them (
setccorcset). - Write a binary search over a sorted 16-element array, once with branches and once with
cmov/cselpicking the next index. - Benchmark both pairs on random and sorted data and make a four-cell table like the one in Chapter 12.
Done when: the branchy and branchless versions give identical answers on INT64_MIN, INT64_MAX, and an array of all-equal values; and you can say, for each loop, whether its branch is predictable and why.
Write-up (half a page): For each of your four numbers, what did you predict and what did you see?
Reading list for Part 3:
- Agner Fog, "The microarchitecture of Intel, AMD and VIA CPUs" (agner.org/optimize). Misprediction penalties and predictor behavior for each chip. Read the branch prediction section for the chip you own.
- Daniel Lemire's blog on branch mispredictions and branchless code. Short, measured experiments in the same spirit as Chapter 12.
- The famous Stack Overflow question "Why is processing a sorted array faster than processing an unsorted array?" The experiment you reproduced, with its long answer. Read it after you have run your own.
- Arm Architecture Reference Manual: conditional select and conditional compare instructions. The exact definitions of
csel,csincandccmp. - Kocher et al., "Spectre Attacks: Exploiting Speculative Execution" (2018/2019). The paper that showed speculation has security consequences.
Teach it back. In five sentences, as if to a friend, explain what a CPU does when it meets a branch, and when removing the branch is a good idea.
Part 4: Functions and the Calling Convention
After this Part you can write functions that C and Rust can call, call functions that C and Rust wrote, and read any function prologue without guessing. This is the Part where assembly stops being a toy.
Chapter 14. The calling convention: a contract between strangers
In this chapter: You will learn the contract that lets code written by different people, in different languages, call each other. You will learn which registers carry arguments, which survive a call, and how both machines pass floats, big structs and extra arguments.
You need first: Chapters 6 to 8.
The problem first
You write a function. Someone else, with a different compiler and in a different language, writes the code that calls it. They have never seen your source. How does the caller know where to put the arguments? Where will the answer be? And which of their values will still be there when your function returns?
The hardware helps only a little. The call instruction saves a return address, and ret goes back to it. That is all. Everything else, every register role, is a promise people made. The promise is called the calling convention, and the larger agreement that includes it (sizes, layouts, names) is the ABI: the application binary interface.
The model (the "what")
On Linux, x86-64 follows the System V AMD64 ABI, and ARM64 follows the Arm 64-bit Procedure Call Standard (AAPCS64). Here is the contract for both, in one table. Notice the details that are easy to forget: r11 is caller-saved, x18 is a platform register, and the low halves of v8-v15 are callee-saved. Read each row.
| x86-64 (System V) | ARM64 (AAPCS64) | |
|---|---|---|
| Integer / pointer arguments, in order | rdi, rsi, rdx, rcx, r8, r9 | x0 to x7 |
| Float / vector arguments | xmm0 to xmm7 | v0 to v7 |
| Integer return value | rax (and rdx for a second half) | x0 (and x1) |
| Float return value | xmm0 | v0 |
| Hidden pointer for a big returned struct | passed in rdi | passed in x8 |
| Caller-saved (a call may destroy them) | rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11, all xmm | x0-x17, v0-v7, v16-v31 |
| Callee-saved (a function must restore them) | rbx, rbp, r12, r13, r14, r15 (and rsp) | x19-x28, x29 (frame pointer), sp, and the low 64 bits of v8-v15 |
| Link register | none (the return address is on the stack) | x30 (lr) |
| Special | rax = number of vector registers used, for variadic calls | x16, x17 scratch for the linker; x18 reserved on some platforms |
Look at three rows more closely.
Caller-saved versus callee-saved. This is the heart of the contract. A caller-saved register may be destroyed by any call. If the caller wants its value after the call, the caller must save it somewhere first. A callee-saved register must come back unchanged. If a function wants to use rbx, it must save the old value on entry and restore it before returning. The split exists because someone has to do the saving, and the contract divides the job so that neither side saves more than it needs. A function that only needs a few scratch values can use caller-saved registers for free.
The link register. On x86-64, call pushes the return address onto the stack, and ret pops it. On ARM64, bl ("branch with link") puts the return address in register x30, and ret jumps to it. Because x30 is overwritten by the next bl, a function that calls others must save x30 on the stack first. That is why you see stp x29, x30, [sp, #-16]! at the start of every non-leaf ARM64 function.
The hidden pointer. A function that returns a big struct has no register big enough. The caller reserves space and passes its address as a hidden first argument. On x86-64 that address goes in rdi, and the function returns it in rax. On ARM64 it goes in x8.
Let's check these claims against a real compiler, because this is an area where memory is not good enough. Here is a function call mixing integers and floats:
extern long f(long a, double b, long c, double d);
long call_mixed(void) { return f(1, 2.0, 3, 4.0); } Where do the four arguments go? Integers and floats use separate register sequences. So the first integer goes in rdi, the first float in xmm0, the second integer in rsi (not rdx), and the second float in xmm1. Here is the real gcc -O1 output:
# x86-64
mov edi, 1 # 1st integer argument -> rdi
movsd xmm0, QWORD PTR .LC0[rip] # 2.0 -> xmm0
mov esi, 3 # 2nd integer argument -> rsi (NOT rdx)
movsd xmm1, QWORD PTR .LC1[rip] # 4.0 -> xmm1
call f Note that the integer and float sequences count separately: the second integer is rsi, not rdx.
Now a struct that is too big for registers:
struct big { long a, b, c, d; }; // 32 bytes
struct big make_big(long x);
struct big pass_big(struct big s); On both machines the caller makes a temporary copy. But the two machines hand it to the function differently, and it is easy to mix them up. Here is what I saw in the real output:
- Returning
struct big: x86-64 put the address of the result space inrdi(mov rdi, rsp), and ARM64 put it inx8. - Passing
struct bigas an argument: x86-64 copied the 32 bytes onto the stack, as part of the stack arguments. ARM64 copied the struct to memory and passed a pointer to the copy inx0, an ordinary argument register. (Only a returned big struct usesx8. An argument does not.)
Stack arguments. When there are more arguments than registers, the rest go on the stack. x86-64 has 6 integer argument registers. ARM64 has 8. Here is a call with 10 integer arguments, real output from gcc -O1 with the callee defined elsewhere:
# x86-64: call10(x) calls f10(x, 2, 3, ..., 10)
call10:
sub rsp, 8 # padding, so rsp is 16-aligned at the call
push 10 # arguments 10, 9, 8, 7 go on the stack, last one first
push 9
push 8
push 7
mov r9d, 6 # arguments 6 down to 2 go in registers
mov r8d, 5 # (argument 1, x, is already in rdi)
mov ecx, 4
mov edx, 3
mov esi, 2
call f10@PLT
add rsp, 40 # the CALLER removes the stack arguments (and padding)
ret // ARM64: the same call. Only arguments 9 and 10 do not fit in x0..x7.
call10:
sub sp, sp, #32
stp x29, x30, [sp, 16] // save frame pointer and return address
add x29, sp, 16
mov x1, 10
str x1, [sp, 8] // argument 10 at [sp+8]
mov x1, 9
str x1, [sp] // argument 9 at [sp]
mov x7, 8 // arguments 8 down to 2 in registers
mov x6, 7
mov x5, 6
mov x4, 5
mov x3, 4
mov x2, 3
mov x1, 2
bl f10
... Notice how the callee on x86-64 reads its stack arguments: at [rsp+8], [rsp+16] and so on. Why +8, not +0? Because [rsp] holds the return address that call just pushed. The first stack argument sits right above it. On ARM64 the callee reads [sp] and [sp, 8] directly, because nothing was pushed on the stack by the bl.
Finally, variadic functions like printf. When you call a function that takes ..., x86-64 requires you to set al (the low byte of rax) to an upper bound on the number of vector registers you used for arguments. I compiled printf("%f\n", 1.5) and saw mov eax, 1 before the call. Forgetting this is a classic bug in hand-written assembly: a call to printf with a float prints garbage.
Build it (the "how")
The best way to learn this table is to read it from a debugger. Use this ritual on any function you do not understand: set a breakpoint at its first instruction, and print the argument registers.
(gdb) break sum3
(gdb) run
(gdb) info registers rdi rsi rdx # on x86-64: the first three arguments
(gdb) info registers x0 x1 x2 # on ARM64 If the values are the arguments you passed, the convention is working. If they are not, one of the two sides is not following it.
Why it is this way (the "why")
Why are there callee-saved registers at all? Imagine every register was caller-saved. Then every call would force the caller to save everything it cared about, even if the callee used just one register. Now imagine every register was callee-saved. Then every function would have to save every register it touched, even for tiny leaf functions that could have used scratch space. The split is a compromise. Roughly half and half is what both ABIs chose, and it works well because most functions need a few scratch registers and a few that last.
Why does the ABI pass arguments in registers? Because memory is slow (Chapter 2 measured the gap). An argument in a register is available at once. Early x86 ABIs passed everything on the stack. The 64-bit ABIs moved to registers when machines had enough of them.
What would make a different ABI better? Only measurements of the code that actually runs. The six-versus-eight argument-register difference is an example: ARM64 has more registers to spend, so more calls avoid the stack entirely.
The principle underneath: an ABI is a contract about bytes and registers, not about source languages. That is why C, Rust, Go and Swift can call each other when they agree to use the C ABI (extern "C" in Rust). Rust's own ABI is deliberately not stable. We return to this in Chapter 17.
Common mistakes
- Counting integer and float arguments together on x86-64. They use separate sequences.
- Using
rcxfor the 4th argument in a syscall. Syscalls user10. Function calls usercx. - Forgetting that
rdxis destroyed by a call. It is caller-saved. - Using
rbxwithout saving it. It is callee-saved; the caller trusts it. - Reading the first stack argument at
[rsp]on x86-64. That is the return address. It is at[rsp+8]. - Not setting
albefore calling a variadic function.
Say it simply
The calling convention says where arguments go, where the answer comes back, and which registers a function must leave alone. On x86-64 the first six integer arguments go in rdi, rsi, rdx, rcx, r8, r9 and floats in xmm0 to xmm7, counted separately. On ARM64, integers go in x0 to x7. Registers split into those a call may destroy (caller-saved) and those a function must restore (callee-saved). Extra arguments go on the stack.
Check yourself
Exercise 14.1 (Warm-up): Predict, then check. For long f(long a, double b, long c, double d, long e), say which register holds each argument on x86-64 and on ARM64. Check with a call compiled at -O1. A correct result: x86-64: rdi, xmm0, rsi, xmm1, rdx. ARM64: x0, d0, x1, d1, x2.
Exercise 14.2 (Warm-up): Explain. In five sentences, explain caller-saved versus callee-saved to a friend. Use an analogy and say where it breaks. What a strong answer includes: someone must preserve values across a call; scratch registers are free for anyone to use but die at a call; preserved registers must be restored; analogy: a shared kitchen where some counters you must clean before leaving and others you can leave messy; it breaks because registers do not have a "kitchen manager"; only the convention enforces it.
Exercise 14.3 (Core): Read it. Write a C function with 8 integer parameters that returns their sum, compile at -O2 for both machines, and mark in the output every place it reads a stack argument. Predict the offsets first. A correct result: x86-64 reads [rsp+8] and [rsp+16] (arguments 7 and 8); ARM64 reads none (8 arguments fit in x0-x7).
Exercise 14.4 (Core): Break it. Write a call_printf in assembly that prints a float with printf("%f\n", 1.5), once without setting al and once with mov eax, 1. What happens? A correct result: it may print garbage or crash without the right al, depending on the libc. Explain what printf does with al.
Exercise 14.5 (Core): Predict, then check. Compile struct s { long a; long b; }; struct s mk(long x); and struct s16 { long a, b; } pass and return by value. Where does a 16-byte struct go on each machine? Check. Does it need the hidden pointer? A correct result: on both machines a 16-byte struct of two longs fits in two registers (rax:rdx returned on x86-64, x0:x1 on ARM64). No hidden pointer. The hidden pointer appears only above 16 bytes.
Exercise 14.6 (Stretch): Design and defend. Propose a calling convention where all registers are callee-saved. What would be good, and what would be bad? For what kind of program might it win? What a strong answer includes: leaf functions would pay to save what they use, even when never needed; deep call chains would be cheap for the caller; a program of many small leaf functions would lose; one with long-running functions and few calls could win.
Chapter 15. The frame: prologue, epilogue, alignment and unwinding
In this chapter: You will learn what every function does at its start and end, why the stack must be aligned at a call, and how debuggers walk back through callers. You will see two failures on purpose: a crash from bad alignment, and a backtrace full of nonsense.
You need first: Chapter 14.
The problem first
A function needs a private place for its local variables and its saved registers. It must put that place on the stack, in a way that cannot collide with its caller's or its callee's. It must also leave behind enough information that a debugger can find the caller later. The shape that does all this is called a stack frame.
The model (the "what")
A function that makes calls (a non-leaf function) typically does this:
- Prologue (at the start): save what must be saved (the old frame pointer, the return address on ARM64, any callee-saved registers it will use), and reserve space for locals.
- Body: do the work.
- Epilogue (at the end): undo the prologue in reverse order, then return.
Here is the same small function on both machines, real gcc -O1 output with the frame pointer kept (-fno-omit-frame-pointer):
extern long g(long);
long h(long x) { long y = g(x) + 1; return y * 2; } # x86-64
h:
push rbp # save the caller's frame pointer
mov rbp, rsp # rbp now marks the base of this frame
call g@PLT # g(x): rdi is already x
lea rax, 2[rax+rax] # (g + 1) * 2, in one lea
pop rbp # restore the caller's frame pointer
ret // ARM64
h:
stp x29, x30, [sp, -16]! // save frame pointer AND return address, in one pair
mov x29, sp // x29 marks the base of this frame
bl g
add x0, x0, 1
lsl x0, x0, 1
ldp x29, x30, [sp], 16 // restore both
ret Read them side by side. The shapes match. On x86-64 call already stored the return address, so only rbp is pushed. On ARM64 bl put the return address in x30, which the next bl will overwrite, so the function saves both x29 and x30. The pair instruction does it in one step.
The frame pointer (rbp or x29) is a register that points at a known place in the frame. Following it, then following the saved one it points to, walks the chain of callers. Compilers often drop it at -O2 to free a register (-fomit-frame-pointer), and use rsp-relative offsets instead. When a profiler wants stack traces cheaply, people build with the frame pointer on.
Leaf functions. A function that calls nothing needs no saved return address. The ARM64 x30 stays put. And on x86-64 something clever is allowed: the red zone. The 128 bytes below rsp are reserved for the running function, and signal handlers will not touch them. So a leaf function can use locals there without even adjusting rsp. Here is a real leaf function, compiled with the default stack protector turned off:
# x86-64: leaf function with a local array, gcc -O1
leaf:
mov QWORD PTR -24[rsp], rdi # a[0] = x <-- below rsp: the red zone
add rdi, 1
mov QWORD PTR -16[rsp], rdi # a[1] = x + 1
mov rax, QWORD PTR -24[rsp]
mov rdx, QWORD PTR -16[rsp]
add rax, rdx
ret No sub rsp. The locals live at negative offsets. (Operating system kernels turn the red zone off, because an interrupt can arrive at any time and would use that memory. Windows x64 has no red zone either. Apple's ARM64 ABI has one of 128 bytes.)
With the default Ubuntu compiler flags, the same function looks different. The compiler adds a stack protector: it plants a secret value (a "canary") just above the locals, and checks it before returning:
leaf:
sub rsp, 40
mov rax, QWORD PTR fs:40 # load the canary from thread-local storage
mov QWORD PTR 24[rsp], rax # place it above the locals
...
mov rdx, QWORD PTR 24[rsp]
sub rdx, QWORD PTR fs:40 # is it unchanged?
jne .L4 # no: something overwrote it
add rsp, 40
ret
.L4:
call __stack_chk_fail@PLT # abort the program If a buffer overflow wrote past the array, it would crush the canary before reaching the return address, and the check catches it. When you read real distro binaries, you will see fs:0x28 (that is 40 in hex) all the time. Now you know what it is.
Alignment. The ABI says the stack pointer must be a multiple of 16 at the moment of a call. That means that on entry to a function, after call has pushed 8 bytes, rsp is 8 more than a multiple of 16. I measured it: in a small test program, rsp mod 16 was 0 in _start just before the call, and 8 at the first instruction of the called function. That is why the example in Chapter 14 had sub rsp, 8 and why many prologues push one extra register. It is padding to bring the stack back to a multiple of 16 before the next call.
Why does alignment matter? Some instructions require it. movaps stores 16 bytes and only works at a 16-byte aligned address. A compiler uses it freely for locals, expecting the ABI to have aligned the stack. I tested what happens if you break the rule:
movups xmmword ptr [rsp+8], xmm0 # unaligned store: works
movaps xmmword ptr [rsp+8], xmm0 # aligned-only store at a bad address The first ran. The second crashed with SIGSEGV (status 139), from a general-protection fault. This is a famous failure: a function works for years, then someone calls it from assembly with a misaligned stack, and it dies on a movaps deep inside printf. On ARM64 the rule is stricter: sp must be 16-aligned whenever it is used for a memory access, and the hardware can check it.
Unwinding. When you type bt in a debugger, or when an exception propagates, the runtime has to walk backward through the callers. With a frame-pointer chain, that is easy. Without one, the runtime needs a table that says, for every instruction address, "here is how big the frame is at this point." That table is CFI (call frame information), written by the compiler as .cfi_ directives and stored in the .eh_frame section. Hand-written assembly has none unless you write it. Let's see what that costs. Here is the factorial function (Chapter 16 explains it), hand-written with no unwinding information. I stopped it at the base case and asked gdb for a backtrace:
#0 0x0000000000401015 in base ()
#1 0x000000000040100f in fact ()
#2 0x0000000000000002 in ?? ()
#3 0x000000000040100f in fact ()
#4 0x0000000000000003 in ?? ()
#5 0x000000000040100f in fact () Look at frames 2 and 4. 0x2 and 0x3 are not addresses. They are the saved values of n that the function pushed. The debugger took them for return addresses, because without CFI it can only guess. Now the same function with CFI directives added:
fact:
.cfi_startproc
cmp rdi, 1
jle base
push rdi
.cfi_adjust_cfa_offset 8 # tell the unwinder: the stack just grew by 8
dec rdi
call fact
pop rdi
.cfi_adjust_cfa_offset -8 # and shrank by 8
imul rax, rdi
ret
base:
mov eax, 1
ret
.cfi_endproc Real backtrace after that change:
#0 fact () at fact_cfi.s:18
#1 0x000000000040100f in fact () at fact_cfi.s:12
#2 0x000000000040100f in fact () at fact_cfi.s:12
#3 0x000000000040100f in fact () at fact_cfi.s:12
#4 0x000000000040100f in fact () at fact_cfi.s:12
#5 0x0000000000401027 in _start () at fact_cfi.s:24 Four recursive frames and _start, exactly right. (Assemble with as -g to get the source line numbers.) The lesson: a function with no unwind information is invisible to debuggers, profilers and exception handlers. If you write assembly that others will call, add the .cfi_ directives.
A related gotcha from the same experiments: when I linked a hand-written .s file with gcc, the linker warned missing .note.GNU-stack section implies executable stack. Add this line at the end of every assembly file you link into a normal program, to say that it does not need an executable stack:
.section .note.GNU-stack,"",@progbits # x86-64 (on ARM64 write %progbits) Build it (the "how")
Let's write the standard prologue and epilogue for a non-leaf function that needs a local variable and a callee-saved register. We will follow the contract exactly.
# x86-64: a non-leaf function that keeps one value across a call
# long twice_plus_one(long x) { return g(x) * 2 + x; } // needs x after the call
twice_plus_one:
push rbx # we want to use rbx, which is callee-saved: save it
mov rbx, rdi # keep x in rbx. A call will not touch it
call g # entry rsp was 8 mod 16; push made it 0: aligned for the call
lea rax, [rbx+rax*2] # g(x) * 2 + x
pop rbx # restore the caller's rbx
ret I ran both versions with a g that adds 10, called with 5, and each printed 35 (that is 5 + (5+10)×2).
Check the alignment by counting. On entry rsp is 8 mod 16. After push rbx it is 0 mod 16, so the call is aligned. That one push did two jobs: it saved a register and fixed the alignment. A compiler arranges exactly this, and when you see an "extra" push in a prologue, this is often why.
On ARM64 the same function saves x30 (for the bl) and x19 (our callee-saved register), as a pair, which also keeps sp aligned:
// ARM64
twice_plus_one:
stp x29, x30, [sp, #-32]! // 32 bytes keeps sp a multiple of 16
mov x29, sp
str x19, [sp, #16] // save callee-saved x19
mov x19, x0 // keep x in x19
bl g
add x0, x19, x0, lsl 1 // x + g(x)*2
ldr x19, [sp, #16]
ldp x29, x30, [sp], #32
ret Why it is this way (the "why")
Why 16-byte alignment? The CPU's widest common vector register is 16 bytes (and wider ones want more). A stack that is always aligned means locals can be accessed with fast aligned instructions and without a check. The ABI pays a small padding cost to make that safe for everyone.
Why have a frame pointer at all, if rsp can be used? Because rsp moves as you push and pop, so offsets change within a function. A frame pointer stays still, so locals have fixed offsets, and debuggers can walk the chain without tables. The cost is one register. Both are reasonable. Some builds keep it for easier profiling.
Why does the unwind table exist? Because the chain is not always there. Exceptions (C++, Rust panics) must unwind through frames that were compiled without a frame pointer, and the table says how to do it without running any code.
What would be better? A design where hardware keeps a separate stack of return addresses only (a shadow stack) would remove a whole class of attacks. Newer chips have this (Intel's CET shadow stack, ARM's pointer authentication). Chapter 17 shows the extra instructions you will see for it.
Common mistakes
- Misaligned stack at a call. Count the bytes: on entry it is 8 off.
- Writing a prologue and forgetting the epilogue (or the reverse), so
rspis wrong atret. - Using the red zone in a non-leaf function. A
calloverwrites it. - Using the red zone in kernel code. It is not safe there.
- Hand-written functions with no CFI. Debuggers and profilers will show nonsense.
- Forgetting
.note.GNU-stackin assembly linked withgcc.
Say it simply
A function sets up a frame at its start and tears it down at its end, saving whatever the contract requires. The stack must be 16-byte aligned at every call, which means 8 off on function entry, and a missing push or pad is a common bug. Leaf functions on x86-64 may use a 128-byte red zone below rsp. Debuggers walk back through callers using frame pointers or unwind tables, and hand-written assembly needs .cfi_ directives to be visible to them.
Check yourself
Exercise 15.1 (Warm-up): Count by hand. A function is entered with rsp = 0x7ffe0008. It does push rbp, push rbx, and sub rsp, 24. What is rsp at the next call? Is it aligned? A correct result: 0x7ffe0008 - 0x28 = 0x7ffdffe0. It is a multiple of 16 (it ends in 0), so the call is aligned.
Exercise 15.2 (Warm-up): Explain. In five sentences, explain what the stack protector canary does. Use an analogy and say where it breaks. What a strong answer includes: a secret value placed between locals and the return address; checked before return; an overflow that reaches the return address must cross it; analogy: a tripwire in front of a door; where it breaks: an attacker who can read the canary first, or who writes only specific bytes, can step over it.
Exercise 15.3 (Core): Break it. In a program with a call from _start, make the called function use movaps on a local at [rsp] without fixing alignment. Observe the crash. Then fix it by adding one push or sub rsp, 8. A correct result: it crashes with SIGSEGV until rsp is a multiple of 16 at the movaps. Say how you computed the fix.
Exercise 15.4 (Core): Implement. Write twice_plus_one on both machines and a test g that returns its argument plus 10. Call it from _start with 5 and exit with the result. Done when: the exit status is 5 + (5+10)*2 = 35, you printed rsp at the call in gdb and showed it is a multiple of 16, and the ARM64 version restores x19 before returning.
Exercise 15.5 (Core): Predict, then check. Compile the same non-leaf function with -O0, -O1 and -O2, with and without -fno-omit-frame-pointer. Predict which versions use rbp/x29 as a frame pointer. Check, and count the prologue instructions. A correct result: -O0 always keeps it; -O2 drops it unless you ask. Write down the instruction counts.
Exercise 15.6 (Stretch): Design and defend. A separate shadow stack stores only return addresses. Give two things it protects against, and one thing it does not. What a strong answer includes: protects return-address overwrite (stack smashing) and return-oriented programming; does not protect against corruption of data, function pointers, or logic bugs; has a cost (a second stack, a check on each return).
Chapter 16. Writing real functions by hand
In this chapter: You will write four real functions on both machines: one the compiler can call, a number printer, a recursive function, and an iterative one. You will also break the contract on purpose and watch what happens. By the end you will have a print_u64 that we reuse in later parts.
You need first: Chapters 14 and 15.
The problem first
Reading about conventions is not the same as obeying them. The only way to be sure you understand a rule is to write code that depends on it and see it work, then break it and see it fail.
The model (the "what")
A function you write by hand has to do four things: read its arguments from the right places, keep the callee-saved registers safe, keep the stack aligned, and leave its result in the return register. Here are the four functions. All of the output below was produced on this guidebook's machine (the ARM64 programs under qemu-user).
Function 1: sum3, called from C
Here is the smallest useful one: add three numbers. It is a leaf function, so it needs no frame.
# sum3.s -- x86-64
.intel_syntax noprefix
.global sum3
.type sum3, @function
sum3: # long sum3(long a, long b, long c)
lea rax, [rdi+rsi] # a + b
add rax, rdx # + c
ret
.section .note.GNU-stack,"",@progbits // sum3.s -- ARM64
.global sum3
.type sum3, %function
sum3:
add x0, x0, x1
add x0, x0, x2
ret
.section .note.GNU-stack,"",%progbits /* main.c */
#include <stdio.h>
extern long sum3(long, long, long);
int main(void) { printf("sum3(2,3,4) = %ld\n", sum3(2, 3, 4)); return 0; } Build and run: gcc -o m3 main.c sum3.s && ./m3 on x86-64, and aarch64-linux-gnu-gcc -static -o m3a main.c sum3.s && qemu-aarch64 ./m3a for ARM64. Both printed sum3(2,3,4) = 9. C found your function by its name, passed the arguments where the ABI says, and read the answer from rax or x0. That is the whole contract working.
Function 2: print_u64, a number printer
We need to print numbers to test things from here on. The idea: divide by 10 again and again. Each remainder is a digit, and they come out last digit first, so we fill a buffer from the end backward.
# x86-64: print_u64(rdi = value). Prints decimal and a newline. A leaf function.
print_u64:
sub rsp, 40 # entry rsp is 8 mod 16, so this leaves it 16-aligned
lea rsi, [rsp+32] # rsi = one past the end of a 32-byte buffer
mov byte ptr [rsi-1], 10 # the newline goes last
dec rsi
mov rax, rdi # the number
mov ecx, 10
.digit:
xor edx, edx # div uses rdx:rax, so rdx must be zero first
div rcx # rax = rax / 10, rdx = rax % 10
add dl, 48 # remainder -> ASCII digit
dec rsi
mov [rsi], dl
test rax, rax
jnz .digit # do-while: the value 0 prints "0"
lea rdx, [rsp+32]
sub rdx, rsi # length = end - start
mov edi, 1
mov eax, 1
syscall # write(1, rsi, length)
add rsp, 40
ret // ARM64: print_u64(x0 = value). A leaf function.
print_u64:
sub sp, sp, #48 // keep sp 16-aligned
add x1, sp, #32 // one past the end of the buffer
mov w2, #10
strb w2, [x1, #-1]! // newline; the pre-index moves x1 back by one
mov x3, #10
digit:
udiv x4, x0, x3 // x4 = x0 / 10
msub x5, x4, x3, x0 // x5 = x0 - x4*10 (the remainder)
add w5, w5, #48
strb w5, [x1, #-1]!
mov x0, x4
cbnz x0, digit
add x2, sp, #32
sub x2, x2, x1 // length
mov x0, #1 // x1 already points at the first digit
mov x8, #64
svc #0
add sp, sp, #48
ret Notice the one real difference between the machines. x86-64 has a division instruction that gives the quotient and the remainder together (div puts them in rax and rdx). ARM64 has only udiv for the quotient, and gets the remainder by computing x0 - quotient*10 with msub ("multiply and subtract"). Chapter 19 returns to this.
I called each version with 12345, then 0, then the largest 64-bit value. Both printed exactly:
12345
0
18446744073709551615 (That 0 case works only because the loop is do-while. A loop that tested first would print nothing for zero. The do-while shape from Chapter 11 pays for itself.)
Function 3: fact, with recursion
Recursion needs the function to keep n safe across its own call. We push n, call ourselves for n-1, pop n, and multiply.
# x86-64: fact(rdi = n) returns n! in rax
fact:
cmp rdi, 1
jle base
push rdi # save my n (this also re-aligns rsp for the call)
dec rdi
call fact # rax = fact(n-1)
pop rdi # get my n back
imul rax, rdi
ret
base:
mov eax, 1
ret // ARM64: fact(x0 = n) returns n! in x0
fact:
stp x29, x30, [sp, #-16]! // save frame pointer and return address (we will call)
mov x29, sp
cmp x0, #1
b.le base
stp x0, xzr, [sp, #-16]! // save my n (a pair keeps sp aligned)
sub x0, x0, #1
bl fact // x0 = fact(n-1)
ldp x9, xzr, [sp], #16 // get my n back
mul x0, x0, x9
ldp x29, x30, [sp], #16
ret
base:
mov x0, #1
ldp x29, x30, [sp], #16
ret Both gave fact(5) = 120. Note on the ARM64 version: stp x0, xzr stores a second register, the zero register, just to keep the pair aligned. Reading xzr gives 0. That is a use of the zero register as a free filler.
Check the x86-64 alignment: entry is 8 mod 16, push rdi makes it 0 mod 16, and the call fact is aligned. And when fact is entered again, it is 8 mod 16 again. Every level obeys the rule.
Breaking the contract: the callee-saved trap
Let's break the contract and watch. The caller keeps a value in rbx, trusting the ABI. The callee uses rbx without saving it:
trash: mov rbx, 0 # uses rbx WITHOUT saving it
ret
_start: mov rbx, 7 # the caller trusts rbx to survive the call
call trash
mov rdi, rbx # expect 7
... The caller expected 7. It got 0. Nothing crashed, nothing warned. The program just silently computed with a wrong value. This is why a violated callee-saved rule is one of the hardest bugs to find: the damage appears far from the cause, in a different function, later. When a value "changes by itself" across a call, suspect a callee that forgot to save and restore.
Build it (the "how")
Let's apply all four to a short exercise: compute the 10th Fibonacci number with a loop and print it. Fibonacci is 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55. Use two registers, and add them in a loop.
# x86-64: fib(rdi = n), iterative. Returns fib(n) in rax. A leaf function.
fib:
xor eax, eax # a = fib(0) = 0
mov edx, 1 # b = fib(1) = 1
test rdi, rdi
jz .done # n == 0: return a
.next:
lea rcx, [rax+rdx] # next = a + b
mov rax, rdx # a = b
mov rdx, rcx # b = next
dec rdi
jnz .next
.done:
ret I called it from _start with 10 and passed the result to print_u64, on both machines. Both printed 55. Then I checked how far it goes. The 93rd Fibonacci number is 12,200,160,415,121,876,738, which fits in an unsigned 64-bit number (the maximum is about 18.4 quintillion) but not in a signed one (the maximum is about 9.2 quintillion). The 94th does not fit in either. When a value exceeds the register, it wraps silently: fib(94) mod 2 to the 64 is 1,293,530,146,158,671,551, and my run of fib(94) on both machines did print exactly that wrong answer, with no error. (So the boundary is at fib(92) for signed numbers, and fib(93) for unsigned.)
Why it is this way (the "why")
Why is a violated convention so dangerous? Because the machine does not know about it. Nothing in the hardware checks that rbx came back unchanged. Safety here lives in discipline and in tools (compilers, linters, tests). That is why this Part has you write violations on purpose: to see how quiet they are.
Why does print_u64 work backward? Because division by 10 gives the last digit first. We could reverse the digits at the end, but filling the buffer from the end is cheaper. This is a good example of choosing an algorithm to match the machine's natural direction.
Why does ARM64 have no remainder instruction? A remainder is a - (a/b)*b, so it can be built from the divide and a multiply-subtract. Providing msub as a single instruction does that in one step, and spares a whole division instruction. ARM64 reuses a general tool (multiply-subtract) where x86-64 spends a special-case behavior in div.
Common mistakes
- Forgetting to zero
rdxbeforediv.div rcxdivides the 128-bit numberrdx:rax. Garbage inrdxgives a wrong quotient or a crash. - Using
sub rsp, Nwith N not a multiple of 16 (counting the entry offset). - Not saving a callee-saved register you use. Silent corruption.
- Returning the answer in the wrong register.
- Forgetting
.note.GNU-stackwhen linking withgcc. - Assuming a function's argument registers still hold the arguments after a call. They are caller-saved.
Say it simply
To write a function by hand, read arguments from the convention's registers, save any callee-saved register you use, keep the stack aligned at calls, and return in rax or x0. Breaking these rules rarely crashes the program at once. It usually produces a wrong value somewhere else. A number printer divides by 10 repeatedly and writes digits from the end of a buffer.
Check yourself
Exercise 16.1 (Warm-up): Predict, then check. What does print_u64(100) print? What if you remove the dec rsi that follows the newline store? Predict, then try. A correct result: 100 normally. Without that dec, the first digit overwrites the newline, so the output is 100 with no newline (or the last character is wrong). Explain which byte was overwritten.
Exercise 16.2 (Warm-up): Explain. In five sentences, explain why recursion needs n saved across the call. Use an analogy and say where it breaks. What a strong answer includes: the callee reuses the argument register; the caller's value would be lost; so it goes on the stack; analogy: putting a bookmark in your book before lending it to someone who writes in it; where it breaks: the stack is also what holds the return addresses, so it must stay in order.
Exercise 16.3 (Core): Implement. Write fib(n) on both machines, and a _start that prints fib(0) through fib(10) using print_u64 in a loop. Keep the loop counter in a callee-saved register (rbx or x19), and save and restore it correctly. Done when: it prints 0 1 1 2 3 5 8 13 21 34 55, one per line, and a deliberate mistake (using rcx/x9 for the counter) makes it fail in a way you can explain.
Exercise 16.4 (Core): Break it. Make print_u64 use rbx without saving it, and call it from a loop that keeps its counter in rbx. Describe the symptom and find the exact instruction at fault with gdb. A correct result: the counter changes after the call; a watchpoint (watch $rbx) stops at the offending instruction.
Exercise 16.5 (Core): Call it from C. Write long max3(long a, long b, long c) in assembly for both machines (use cmov/csel), call it from a C main with 10 test cases including negatives, and compare with a C reference. Done when: all 10 cases match on both machines.
Exercise 16.6 (Stretch): Design and defend. fact can overflow. A function can return an error flag in a second register (rdx or x1), return a sentinel, or trap. Compare the three for a library that other people call. What a strong answer includes: a second return register is cheap and explicit; a sentinel can collide with a valid value; trapping is safest for bugs but cannot be recovered. A reasoned choice, given who calls it.
Chapter 17. The ABI zoo: other systems, other rules
In this chapter: You will learn how Windows and Apple platforms differ from Linux, what Rust's ABI promises (nothing), and what the extra instructions at the start of real functions (endbr64, paciasp) are for.
You need first: Chapters 14 to 16.
The problem first
Everything so far is for Linux. But your assembly will meet other systems. A function that follows the Linux rules and is called from a Windows program will fail, even on the same CPU. The same is true on a Mac. And the code you read in real binaries has odd extra instructions at the start of functions that this guidebook has not explained yet.
The model (the "what")
An ABI belongs to a platform (an operating system plus an architecture), not to the CPU alone. The CPU is the same. The contract changes.
Windows x64 uses a different convention from Linux x86-64. These facts come from Microsoft's documentation. I did not run Windows code in this guidebook's sandbox, so check them on a Windows machine:
| Linux x86-64 (System V) | Windows x64 | |
|---|---|---|
| Integer arguments | rdi, rsi, rdx, rcx, r8, r9 | rcx, rdx, r8, r9 |
| Float arguments | xmm0 to xmm7, counted separately | xmm0 to xmm3, by position with the integers |
| Stack arguments | after 6 integer registers | after 4 |
| Shadow space | none | the caller reserves 32 bytes above the return address |
| Callee-saved | rbx, rbp, r12-r15 | rbx, rbp, rdi, rsi, r12-r15, and xmm6-xmm15 |
| Red zone | 128 bytes | none |
On Windows, the position of an argument decides its register. The second argument is always rdx or xmm1, whatever its type. This is a different system from the Linux one in Chapter 14, where integers and floats count separately. The 32-byte shadow space is room the caller leaves for the callee to spill its four register arguments. Forgetting it is the Windows version of the alignment bug.
Apple ARM64 (macOS and iOS) follows AAPCS64 with a few changes. I checked these in Apple's documentation (the page "Writing ARM64 code for Apple platforms"):
- Register
x18is reserved by the platform. Do not use it. - Arguments to variadic functions are always passed on the stack, not in registers. That is different from standard ARM64 Linux, and it breaks naive hand-written calls to
printf. - Stack arguments may take less than 8 bytes each (they are packed by size, not rounded to 8).
- There is a 128-byte red zone below
sp. - Symbol names start with an underscore (
_main, notmain), and address-building syntax uses@PAGEand@PAGEOFFinstead of:lo12:. - Direct system calls are not a stable interface. Call the system library instead.
- Pages are 16 KiB on Apple silicon. (
adrpstill works in 4 KiB units.)
Rust's ABI. Rust has no stable ABI of its own. The compiler may pass arguments differently from one version to the next, may reorder struct fields (we saw this in Chapter 9), and may split or merge values across registers. So when assembly and Rust meet, you have to use the C ABI on both sides:
// Not run as a combined project here (Chapter 35 builds and runs one).
#[repr(C)] // the C layout, fixed offsets
pub struct Point { pub x: i64, pub y: i64 }
extern "C" { // "call this using the C ABI"
fn sum3(a: i64, b: i64, c: i64) -> i64;
}
#[no_mangle] // keep the name "scale", so assembly can find it
pub extern "C" fn scale(p: &Point, k: i64) -> i64 { (p.x + p.y) * k } extern "C" picks the calling convention. #[repr(C)] picks the struct layout. #[no_mangle] stops the compiler from rewriting the function's name. All three are needed. Chapter 35 builds a full Rust-and-assembly program.
The extra instruction at the start of functions. When you disassemble a real Ubuntu x86-64 binary, nearly every function begins with endbr64. In my count of the C library it was 1% of all instructions. It belongs to Intel's CET (control-flow enforcement technology). The idea: an indirect jump or call may only land on a spot that begins with endbr64. If an attacker redirects an indirect jump to the middle of a function, the CPU (when CET is on) stops the program. On older CPUs, endbr64 runs as a harmless no-op. That is how one binary works on both.
ARM64 has two matching features. BTI (branch target identification) puts a bti instruction at valid indirect-branch targets. PAC (pointer authentication) signs the return address when a function begins and checks the signature before it returns, so a corrupted return address is caught. I compiled the same function with -mbranch-protection=standard and got this real output:
h:
hint 25 // paciasp : sign the return address (in x30), using the stack pointer
stp x29, x30, [sp, -16]!
mov x29, sp
bl g
add x0, x0, 1
lsl x0, x0, 1
ldp x29, x30, [sp], 16
hint 29 // autiasp : check the signature; a bad one makes the next ret fault
ret Two instructions were added around the same body: paciasp at the start and autiasp before ret. They are encoded in a part of the instruction space (the hint space) that older chips ignore, so the same binary runs on both. When you read ARM64 output from Apple or Android, you will see these everywhere.
Build it (the "how")
Let's read a real function from an x86-64 distro binary and name every part. Use a small one, with no symbols stripped. Here is the one I would expect to see at the top of a function in a Ubuntu binary, as a template:
endbr64 # CET: valid indirect-branch landing pad
push rbp # save frame pointer (if the build kept it)
mov rbp, rsp
push rbx # callee-saved register this function uses
sub rsp, 24 # locals and alignment padding
mov rax, QWORD PTR fs:0x28 # the stack-protector canary
mov QWORD PTR [rbp-0x18], rax Every line is something from Parts 2 to 4: a CET marker, a frame, a saved register, locals, and the canary. Your job when reading a prologue is to name each line this way. A prologue that you can name line by line is a prologue you have understood.
Why it is this way (the "why")
Why do platforms differ at all? Because each was designed at a different time for a different goal. Windows x64 (2000s) chose "position decides the register" and a shadow space to simplify varargs and debugging. System V chose more registers and counted floats separately to save moves. Apple chose variadics-on-stack to make va_list simple, and reserved x18 to hold platform data.
Why is Rust's ABI unstable on purpose? So the compiler can improve it (pass more in registers, shrink structs) without breaking old code. The cost is that foreign code must use extern "C". The benefit is real performance. C chose stability and gave up the improvements.
Why are the security instructions encoded as no-ops? Compatibility. A new instruction that old chips treat as a nop lets one binary run everywhere and enforce protection only where it can. The same bet as Chapter 3: pay a small cost to keep old code working.
Common mistakes
- Using Linux register order on Windows (or the reverse).
- Forgetting the shadow space on Windows.
- Using
x18on Apple platforms. - Passing variadic arguments in registers on Apple ARM64.
- Calling Rust code without
extern "C"from assembly. - Using a
#[repr(Rust)]struct (the default) from assembly. - Deleting
endbr64orpaciaspby hand when patching a binary. A CET-enabled system may then stop the program.
Say it simply
An ABI belongs to a platform, not a CPU. Windows x64 and Linux x86-64 use different argument registers, and Apple's ARM64 changes a few rules of standard ARM64. Rust has no stable ABI, so assembly and Rust meet through extern "C", #[repr(C)] and #[no_mangle]. The extra instructions at the start of real functions (endbr64, paciasp) are security features designed to run as harmless no-ops on old chips.
Check yourself
Exercise 17.1 (Warm-up): Translate. Write down, in a table, where the arguments of f(long a, double b, long c, double d) go on Linux x86-64, Windows x64 and Linux ARM64. A correct result: Linux x86-64: rdi, xmm0, rsi, xmm1. Windows x64: rcx, xmm1, r8, xmm3 (by position). ARM64: x0, d0, x1, d1.
Exercise 17.2 (Warm-up): Explain. In five sentences, explain what endbr64 is for. Use an analogy and say where it breaks. What a strong answer includes: marks valid landing spots for indirect branches; a CPU with CET stops a jump to anywhere else; a no-op on old CPUs; analogy: a building with a door only at marked entrances; where it breaks: an attacker can still use any marked entrance, and it does not protect returns.
Exercise 17.3 (Core): Read it. Disassemble any function in a binary on your machine (objdump -d /usr/bin/ls | less) and name every instruction in its prologue and epilogue. For each, say which feature put it there (frame, callee-saved, canary, CET). A correct result: a labeled list of at least five lines. If a line puzzles you, find it in this Part or the manual.
Exercise 17.4 (Core): Implement. Write a Rust program with #[repr(C)] struct Point { x: i64, y: i64 }, and an assembly function point_sum(p: *const Point) -> i64 that returns x + y. Call it through extern "C". Use global_asm! (stable since Rust 1.59). Done when: it prints the right sum for several points, and you can show, by changing #[repr(C)] to nothing and adding a third field, that field offsets are what you must be able to rely on. Not run in this guidebook as a combined Rust and assembly project; Chapter 35 builds a version of it.
Exercise 17.5 (Core): Predict, then check. Compile a tiny function with -mbranch-protection=standard for ARM64 and with -fcf-protection=full for x86-64. Predict the added instructions. Check. A correct result: ARM64 adds the paciasp/autiasp pair (and bti c at indirect-branch targets); x86-64 adds endbr64 at the start.
Exercise 17.6 (Stretch): Design and defend. Windows x64 reserves 32 bytes of shadow space on every call, even when a function takes no arguments. Argue for and against that rule. What a strong answer includes: for: simple varargs and debugging, every callee has spill space; against: wasted stack and a few instructions on every call; and a view on whether the simplicity was worth it.
Answer notes for Part 4
- 15.1:
0x7ffe0008 - 8 - 8 - 24 = 0x7ffdffe0. A multiple of 16 (it ends in0), so the call is aligned. The entry address0x...8is 8 off, as expected. - 16.1: the
dec rsiafter the newline store makes room so the first digit goes before the newline. Without it, the first digit overwrites the newline. - 16.3:
rbxandx19are the registers the contract promises will surviveprint_u64, becauseprint_u64is a leaf function that uses only caller-saved registers (and the syscall destroysrcx/r11only on x86-64). That is whyrbx/x19are safe choices for the counter. - 17.1: On Windows x64 the second argument (
b) is inxmm1, because it is second, not because it is the first float.
Part 4 wrap-up
What you can now do:
- State the argument, return, caller-saved and callee-saved registers on both Linux ABIs.
- Pass floats, big structs and extra arguments correctly.
- Write a correct prologue and epilogue and keep the stack aligned.
- Write functions that C can call, and call C functions from assembly.
- Add CFI directives so debuggers and profilers see your function.
- Recognize the extra security instructions in real prologues.
- Say how Windows, Apple and Rust differ.
Mini project: the convention laboratory. Time: 5 to 8 hours.
On both ISAs, write a small library libtiny.s with five functions: sum3, max3, print_u64, fact and fib. Write a C test program that calls each with several inputs and compares them to C reference versions. Then:
- Add
.cfi_directives to the recursive function and prove, with agdbbacktrace, that frames are visible. - Break each of these on purpose and record the symptom: a clobbered callee-saved register, a misaligned stack at a
call, a missingrdxzero beforediv. - Run
objdump -don your library and label every prologue and epilogue.
Done when: all functions pass on both machines; your three deliberate breaks each have a one-line description of what you saw; and a stranger could follow your README to build and run the tests.
Write-up (half a page): Which break was hardest to diagnose, and what would have helped?
Reading list for Part 4:
- System V Application Binary Interface, AMD64 Architecture Processor Supplement. The official x86-64 ABI. Read the chapter on the function calling sequence.
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64). The official ARM64 rules, including the exact rules for structs and homogeneous float aggregates.
- Microsoft Learn, "x64 calling convention". The Windows rules, including the shadow space.
- Apple Developer documentation, "Writing ARM64 code for Apple platforms". The list of differences from standard ARM64.
- Eli Bendersky, "Stack frame layout on x86-64". A clear picture of frames.
- The Linux kernel's
Documentationon CET and the Arm documentation on pointer authentication. For the extra prologue instructions.
Teach it back. In five sentences, as if to a friend, explain what a calling convention is, which registers survive a call, and what goes wrong if a function ignores the rule.
Part 5: Arithmetic
After this Part you can add, multiply, divide, shift and count bits on both machines. You will know what each costs, which compiler tricks to expect, and what happens at the edges: overflow, divide by zero, and the numbers that do not fit.
Chapter 18. Integers: add, subtract, carry and overflow
In this chapter: You will learn how integer addition and subtraction work in bits, what carry and overflow mean, and how to add numbers bigger than a register. You will also see how compilers (C and Rust) check for overflow.
You need first: Chapters 6 and 10.
The problem first
A register holds 64 bits. What happens when a sum needs 65? The extra bit has nowhere to go. And a negative number: how is it stored in bits at all? Every integer instruction has to answer both questions, and both machines answer them the same way.
The model (the "what")
Two's complement is how both machines store signed numbers. The bits are the same as for an unsigned number. Only the meaning of the top bit changes: it counts as a large negative amount. In an 8-bit register, 11110010 is 242 if you read it as unsigned, and -14 if you read it as signed (242 - 256). To negate a number, flip all bits and add 1.
The beautiful part is that addition is the same operation either way. The adder does not know whether the numbers are signed. It adds bits and produces a result and two flags. In Chapter 10 we met them:
- Carry (
CForC) says the unsigned answer did not fit. - Overflow (
OForV) says the signed answer did not fit.
Both can be set, or only one, or neither. The numbers from Chapter 10 show it: INT64_MAX + 1 sets overflow but not carry (as unsigned it is just a big number plus one), and 0xFFFF...FF + 1 sets carry but not overflow (as signed, it is -1 + 1 = 0, which fits fine).
Subtraction a - b is built from the same adder: a + (NOT b) + 1. This is why ARM64's carry flag after a subtraction means "no borrow" (Chapter 10).
Numbers bigger than a register. To add two 128-bit numbers, add the low halves first. If that carried, add 1 to the high halves. Both machines have an instruction for "add with carry": adc on x86-64, and adc on ARM64 (paired with adds, which sets the carry). Here is the real compiler output for adding two 128-bit numbers:
unsigned __int128 add128(unsigned __int128 a, unsigned __int128 b) { return a + b; } # x86-64, gcc -O2: a is in rsi:rdi, b in rcx:rdx, result in rdx:rax
add128:
mov rax, rsi # (shuffling the argument pairs into place)
mov r8, rdi
mov rdi, rax
mov rax, rdx
mov rdx, rcx
add rax, r8 # add the LOW halves; the carry goes into CF
adc rdx, rdi # add the HIGH halves PLUS the carry
ret // ARM64, gcc -O2: a is in x1:x0, b in x3:x2, result in x1:x0
add128:
adds x0, x0, x2 // low halves; the 's' sets the carry flag
adc x1, x1, x3 // high halves plus carry
ret The ARM64 version is two instructions, because the 128-bit values already sit in the right registers. The x86-64 one shuffles registers first. The idea is identical: a chain of add-with-carry. Any width works the same way, with one adc per extra 64 bits. Big-number libraries are built on this chain.
I ran my own versions (the example below) on both machines. They added 0x0000000000000000_FFFFFFFFFFFFFFFF and 1 and printed the high half 1 and the low half 0. The carry crossed the halves correctly.
# x86-64: (rsi:rdi) + (rcx:rdx) -> (rdx:rax)
add128:
mov rax, rdi
add rax, rdx # low halves; carry into CF
mov rdx, rsi
adc rdx, rcx # high halves + CF
ret Checking for overflow. Languages differ on what to do when a sum does not fit. C says signed overflow is undefined, and the compiler may assume it never happens. Rust panics in debug builds and wraps in release builds, unless you ask for checked_add or wrapping_add. Here is what each looks like in real output. First, GCC's __builtin_add_overflow, which reports whether the add overflowed (signed):
# x86-64 // ARM64
add_ovf: add_ovf:
add rdi, rsi adds x1, x0, x1
seto al str x1, [x2]
mov QWORD PTR [rdx], rdi cset w0, vs
movzx eax, al ret
ret Both machines do the add and then read the overflow flag: seto (set byte if overflow) on x86-64, and cset ..., vs (set if the V flag is set) on ARM64. Rust's a.checked_add(b) for i64 compiles to nearly the same thing:
# x86-64, rustc -O: Option<i64> comes back as two registers
ck:
xor eax, eax # eax = 0 (the "None" tag, for now)
add rdi, rsi
setno al # al = 1 if NO overflow -> the tag is "Some"
mov rdx, rdi # the value goes in rdx
ret # returns (tag, value) in rax:rdx This is the answer to "does safe arithmetic cost a lot?" In this case, the check is one extra instruction (setno), and no branch at all. It is cheap because the adder already computed the overflow flag for free.
Build it (the "how")
Let's turn this into a habit. Whenever a number "should not" overflow, ask which kind of overflow you care about:
| If your numbers are... | Watch this flag | x86-64 | ARM64 |
|---|---|---|---|
| unsigned (sizes, indexes, counters) | carry on add | jc or setc | b.cs or cset ..., cs |
| signed (balances, differences) | overflow | jo or seto | b.vs or cset ..., vs |
And remember, from Chapter 10, that subtraction inverts the carry meaning between the two machines.
Sign extension. To widen a signed number, copy its sign bit into the new upper bits. movsxd rax, edi (x86-64) and sxtw x0, w0 (ARM64) turn a 32-bit signed value into a 64-bit one. For division, x86-64 has cqo, which spreads the sign of rax across rdx to make a 128-bit rdx:rax (Chapter 19 needs it).
Why it is this way (the "why")
Why two's complement? Because the adder does not change. With a sign-and-magnitude scheme, add and subtract would need different circuits. With two's complement, one adder serves signed, unsigned, add and subtract. The hardware pays nothing for negative numbers. That is why every general-purpose CPU uses it.
Why have both carry and overflow flags? Because they answer different questions about the same bits, and the hardware cannot know which question you meant. So it computes both, almost for free, and lets the next instruction pick.
Why does Rust wrap in release and panic in debug? It is a trade between speed and safety, picked by the build profile. Release builds drop the check; debug builds keep it. The data above says the check is cheap (one setno), but a branch on the result in a hot loop can block vectorization, which is the bigger cost.
Common mistakes
- Reading the wrong flag. Carry for unsigned, overflow for signed.
- Forgetting that
incanddecleave carry alone (x86-64), so a carry chain survives them. - Using
addinstead ofadcin the high half. The carry is silently dropped. - Putting an instruction that changes the flags between
addandadc. Acmpin between destroys the carry you wanted. - Treating a 32-bit
intoverflow as a 64-bit one. Use the 32-bit register (eax/w0) so the flags describe 32 bits.
Say it simply
Both machines store negative numbers in two's complement, so one adder works for signed and unsigned numbers. Carry says an unsigned result did not fit. Overflow says a signed one did not. To add numbers bigger than a register, add the low parts and then use add-with-carry on each higher part. Checking for overflow costs about one instruction, because the adder already made the flag.
Check yourself
Exercise 18.1 (Warm-up): Predict, then check. In 8 bits, predict CF and OF for 100 + 100 and for 200 + 100. Say what each flag means. Check with the helper from Chapter 10 or with gdb. A correct result: 100 + 100 = 200: no carry, but overflow (as signed, 200 does not fit in 8 bits). 200 + 100 = 300: carry set (unsigned does not fit), and overflow depends on the signed reading (200 is -56, so -56 + 100 = 44 fits: no overflow).
Exercise 18.2 (Warm-up): Explain. In five sentences, explain why one adder serves signed and unsigned. Use an analogy and say where it breaks. What a strong answer includes: two's complement makes the bit pattern of the sum the same either way; only the interpretation differs; analogy: an odometer that rolls over (the same wheel counts both small and "wrapped" numbers); where it breaks: the CPU has to also report which kind of rollover happened (two flags).
Exercise 18.3 (Core): Implement. Write add256 on both machines: it adds two 256-bit numbers stored as four 64-bit words each in memory (low word first), and writes the result to a third place. Use a loop or an unrolled adc chain. On x86-64, remember that inc/dec do not touch carry, so a loop counter is safe; on ARM64, think about how to keep the carry alive through a loop (hint: cmp/subs destroy it, so unroll). Done when: adding 2^256 - 1 and 1 gives all zeros plus a carry out, and you print the four result words with print_u64.
Exercise 18.4 (Core): Break it. Replace one adc with add in your 128-bit add and add 0xFFFFFFFFFFFFFFFF + 1. What do you get? Then put a cmp between the add and the adc. What do you get? A correct result: with add, the carry is lost (the high half is 0, not 1). With the cmp in between, the carry is overwritten by the compare's flags.
Exercise 18.5 (Core): Predict, then check. Compile i64::checked_add, wrapping_add and plain + (release) in Rust, and __builtin_add_overflow in C. Predict how many instructions each takes. Check the output. A correct result: wrapping_add and release + are one add (or lea); checked_add adds a flag read; a panic in debug adds a branch to a panic call.
Exercise 18.6 (Stretch): Design and defend. A language must choose between wrapping, trapping, and saturating on integer overflow for its default +. Pick one and defend it for a systems language, with the cost for the other two. What a strong answer includes: a clear choice; the hardware cost (wrapping is free; trapping needs a flag check and a branch; saturating needs extra instructions); the safety trade; and who is hurt by the choice.
Chapter 19. Multiply and divide: the expensive operations
In this chapter: You will learn the multiply and divide families on both machines, what each costs (measured), how division by a constant becomes a multiply (verified), and what happens at the edges: divide by zero and the one signed divide that does not fit.
You need first: Chapter 18.
The problem first
Addition costs one trip through the adder. Multiplication is a grid of additions, and division is a loop of subtractions. Nothing in a CPU is as costly per instruction as a divide. A compiler works hard to avoid it, so reading output well means knowing both what the instructions are, and what they cost.
The model (the "what")
Multiply. A product of two 64-bit numbers can need 128 bits. So both machines provide both halves.
# x86-64
imul rax, rbx # rax = low 64 bits of rax * rbx (signed or unsigned, same low half)
imul rax, rbx, 10 # rax = rbx * 10 (three-operand, constant)
mul rbx # rdx:rax = rax * rbx (the FULL 128-bit unsigned product)
imul rbx # rdx:rax = rax * rbx (the full signed product) // ARM64
mul x0, x1, x2 // x0 = low 64 bits of x1 * x2
umulh x0, x1, x2 // x0 = HIGH 64 bits of the unsigned product
smulh x0, x1, x2 // x0 = HIGH 64 bits of the signed product
smull x0, w1, w2 // x0 = the full 64-bit product of two 32-bit signed numbers
madd x0, x1, x2, x3 // x0 = x1 * x2 + x3 (multiply-add in one)
msub x0, x1, x2, x3 // x0 = x3 - x1 * x2 (multiply-subtract) Two details to get right. First, the high half of an x86-64 product comes from mul or one-operand imul, in rdx. (mulx is a newer BMI2 variant that also computes the high half but leaves the flags alone, so it is not the only way.) Second, smull multiplies two 32-bit numbers to a 64-bit result. It does not widen to 128. To get the high half of a 64-by-64 product, you need umulh or smulh.
Divide. x86-64's div divides the 128-bit number rdx:rax by an operand. It puts the quotient in rax and the remainder in rdx, both at once. The signed idiv does the same. You must set up rdx first: zero it for unsigned (xor edx, edx) or spread the sign with cqo for signed.
ARM64 has udiv and sdiv, which give only the quotient. For the remainder, you compute a - (a/b)*b with msub. Neither machine's divide updates the flags usefully.
# x86-64: 100 / 7 (unsigned) // ARM64: 100 / 7
mov rax, 100 mov x0, #100
xor edx, edx mov x1, #7
mov rcx, 7 udiv x2, x0, x1 // quotient 14
div rcx # rax=14, rdx=2 msub x3, x2, x1, x0 // remainder: 100 - 14*7 = 2 What they cost. I measured these on this guidebook's machine (Intel Xeon @ 2.80 GHz), using a chain of dependent operations so each must wait for the one before it. This measures latency: how long one operation takes from start to finish.
| Operation (dependent chain) | Time per operation | Roughly, in "adds" |
|---|---|---|
add | 0.37 ns | 1 |
imul | 1.04 ns | about 3 |
div (64-bit) | 4.59 ns | about 12 |
At around 2.8 GHz, that is roughly 1 cycle, 3 cycles and 13 cycles. (I did not measure the exact clock, so treat the cycle counts as close estimates.) You will often see a divide quoted as "20 to 40 or more cycles." On this machine it is about 13, and on older Intel designs published tables list much higher numbers for the 64-bit divide. The lesson is that this number depends on the chip generation, so look up your own in uops.info or Agner Fog's tables, or measure. The shape is stable: add is cheapest, multiply is a few times that, and divide is many times that. These three numbers did not change when I repeated them in later sessions (add 0.35 to 0.39 ns, imul 1.02 to 1.04 ns, div 4.55 to 4.63 ns), even though other measurements on this machine did move (see the note at the start of Part 8).
Division by a constant becomes a multiply. A compiler will almost never emit a divide when the divisor is a known number. Here is real gcc -O2 output:
unsigned long udiv10(unsigned long x) { return x / 10; } # x86-64
udiv10:
movabs rax, -3689348814741910323 # that is 0xCCCCCCCCCCCCCCCD
mul rdi # rdx:rax = x * 0xCCCCCCCCCCCCCCCD
mov rax, rdx # keep only the HIGH half (that is a >> 64)
shr rax, 3 # then shift right by 3 more
ret // ARM64
udiv10:
mov x1, -3689348814741910324
movk x1, 0xcccd, lsl 0 // x1 = 0xCCCCCCCCCCCCCCCD
umulh x0, x0, x1 // high half of x * magic
lsr x0, x0, 3
ret No divide anywhere: one multiply and one shift. Why does this work? We want x / 10. Let me explain the idea with one number to trust, and then I will show it holds.
Dividing by 10 is multiplying by 0.1. The computer cannot multiply by 0.1, but it can multiply by a big whole number and then shift. The number 0xCCCCCCCCCCCCCCCD is ceil(2^67 / 10). Multiplying x by it gives about x * 2^67 / 10. Dividing by 2^67 (taking the top half, which is >> 64, and then >> 3) leaves about x / 10. The rounding is chosen so the result is exactly right for every 64-bit x. I did not just trust this. I checked that ((x * 0xCCCCCCCCCCCCCCCD) >> 64) >> 3 equals x // 10 for 2,000,009 values, including 0, the largest 64-bit number, and random ones. There were zero mismatches. (The research paper behind this is Granlund and Montgomery, "Division by Invariant Integers using Multiplication," 1994, and Hacker's Delight has the full derivation.)
Not every divisor is so tidy. For 7, the multiplier ceil(2^67 / 7) is 0x12492492492492493, which needs 65 bits, one too many for a register. The compiler keeps the low 64 bits and fixes up the missing bit:
# x86-64: x / 7
udiv7:
movabs rax, 2635249153387078803 # 0x2492492492492493 (the low 64 bits of the magic)
mul rdi
sub rdi, rdx # the fixup: x - high
shr rdi # halve it
lea rax, [rdx+rdi] # add the high half back
shr rax, 2
ret I verified this fixed-up form too (2,000,006 values, no mismatches). Do not memorize it. Just recognize the shape: a movabs/movk of a strange constant, then mul/umulh, then a shift, means division by a constant. That is how you read it.
Dividing by a power of two is a shift, but signed numbers need a fix. -7 / 4 in C is -1 (rounds toward zero), but -7 >> 2 is -2 (rounds down). The compiler adds a bias to negative numbers first. Real output for long sdiv4(long x) { return x / 4; }:
# x86-64 // ARM64
sdiv4: sdiv4:
test rdi, rdi cmp x0, 0
lea rax, 3[rdi] add x1, x0, 3
cmovns rax, rdi csel x0, x1, x0, lt
sar rax, 2 asr x0, x0, 2
ret ret It computes x + 3 (the bias) and uses it only if x is negative (a cmov/csel, no branch), then shifts right. That is a nice use of what you learned in Chapter 12.
The edges. Two inputs to a divide are special, and the two machines differ:
| Input | x86-64 | ARM64 |
|---|---|---|
| Divide by zero | traps (SIGFPE) | returns 0, no trap |
INT64_MIN / -1 | traps (SIGFPE), the answer does not fit | returns INT64_MIN, no trap |
I ran all four. On x86-64, both cases killed the program with SIGFPE (status 136). On ARM64 (under qemu-user), 100 / 0 returned 0 and INT64_MIN / -1 returned INT64_MIN, with no trap. The ARM manual defines this behavior for real hardware too.
So who checks? Not the hardware, on ARM64. In a language like Rust, the compiler inserts the check. Look at what a / b for i64 compiles to in Rust, real output:
# x86-64, rustc -O
d:
push rax
test rsi, rsi # b == 0 ?
je .panic # yes: panic (never reach the divide)
movabs rax, -9223372036854775808 # INT64_MIN
cmp rdi, rax
jne .ok
cmp rsi, -1 # INT64_MIN / -1 ?
je .overflow_panic
.ok:
mov rax, rdi
or rax, rsi
shr rax, 32 # do BOTH values fit in 32 bits?
je .small # yes: use the cheaper 32-bit divide
mov rax, rdi
cqo
idiv rsi # the real 64-bit signed divide
...
.small:
mov eax, edi
xor edx, edx
div esi # 32-bit divide: faster on many chips Two things to notice. Rust guards the two bad inputs before the divide, so it never traps. And the compiler (LLVM) added a fast path: if both operands fit in 32 bits, use the cheaper 32-bit divide. That is the compiler acting on the cost table above, and you can see it only if you read the assembly. (Plain C does not insert the guards: dividing by zero is undefined behavior there.)
Build it (the "how")
Let's use the divide-by-constant idea by hand. We want to reduce a number modulo 10, which is what print_u64 did with a real div. Replace it with the multiply trick:
# x86-64: split rdi into quotient (rax) and remainder (rdx) by 10, with no div
movabs rax, 0xCCCCCCCCCCCCCCCD
mul rdi # rdx = high half of rdi * magic
shr rdx, 3 # rdx = rdi / 10 (the quotient)
lea rax, [rdx+rdx*4] # rax = q * 5
add rax, rax # rax = q * 10
sub rdi, rax # rdi = x - q*10 (the remainder) This is exactly what the compiler produced for x % 10, as the umod10 output shows (a mul, a shr, a lea, an add and a sub). Swapping this into print_u64 is Exercise 19.3, and you can measure the saving with Chapter 26's tools.
Why it is this way (the "why")
Why is division so slow? There is no simple parallel formula. Multiplication can be done in a grid of adders all at once. Division is inherently a series of decisions, each depending on the last. Hardware designers have improved it over the generations (our measured 13 cycles is far better than older chips), but it stays the slowest common integer operation.
Why does the multiply trick work? Because dividing by a known number is multiplying by its reciprocal, and a reciprocal can be precomputed. The cost moves to compile time. The compiler is spending a division at build time to save one at every run.
What would make it a bad idea? When the divisor is not known until run time, there is nothing to precompute, and the real divide stays. That is why interpreters and hash tables with a changing size are slow in a particular spot. Some libraries precompute the magic number once for a divisor they will use many times.
ARM64's choice. Giving only the quotient, and a msub for the remainder, keeps udiv simple. The cost is one extra instruction when you need the remainder. Note it, but it is rarely the bottleneck.
Common mistakes
- Forgetting to zero (or sign-extend)
rdxbeforedivoridiv. You divide a 128-bit number with garbage in the top half. - Using
divwhereidivis needed, or the reverse. Signed and unsigned give different answers for negatives. - Not checking the divisor. A divide by zero kills an x86-64 program and silently returns 0 on ARM64.
- Ignoring
INT_MIN / -1. It traps on x86-64. - Trusting a cycle number from a different chip. Measure on yours.
Say it simply
Multiply gives a full-width product in two registers; divide gives a quotient and (on x86-64) a remainder. On this machine an add takes about 1 cycle, a multiply about 3 and a divide about 13, and the divide gets much worse on older chips. Compilers turn division by a known number into a multiply by a magic constant plus a shift. Divide by zero traps on x86-64 and returns 0 on ARM64, so safe languages insert their own check.
Check yourself
Exercise 19.1 (Warm-up): Predict, then check. Predict the x86-64 gcc -O2 output shape for x / 3, x / 16 (unsigned) and x / 16 (signed). Which one has no multiply? Which has a cmov? A correct result: unsigned /16 is a single shr; signed /16 adds a bias with a cmov/csel then sar; /3 is a movabs + mul + shift.
Exercise 19.2 (Warm-up): Explain. In five sentences, explain how division by a constant becomes a multiply. Use an analogy and say where it breaks. What a strong answer includes: multiply by a precomputed reciprocal scaled by a power of two, then shift; the rounding is chosen to be exact for all inputs; analogy: dividing by 4 by multiplying by 0.25; where it breaks: the reciprocal of 10 or 7 is not exact, so the constant has to be chosen carefully (and sometimes needs a fixup).
Exercise 19.3 (Core): Implement. Replace the div rcx in print_u64 with the multiply-by-magic trick from this chapter. Check it against the original for 0, 9, 10, 99, 12345 and 0xFFFFFFFFFFFFFFFF. Done when: both versions print the same text for all six, and you have timed both on one million calls (see Chapter 26).
Exercise 19.4 (Core): Break it. Make an x86-64 program that does idiv by a runtime zero and one that does INT64_MIN / -1. Run both and read the signal. Then try the same on ARM64. A correct result: SIGFPE (status 136) on x86-64 for both; on ARM64, 0 and INT64_MIN with no signal.
Exercise 19.5 (Core): Verify it yourself. In any language, write a loop that checks ((x * M) >> 64) >> 3 == x / 10 for a million random 64-bit values, with M = 0xCCCCCCCCCCCCCCCD. Then change M by 1 and find the first x that fails. A correct result: the right M never fails; a wrong M fails quickly, often on a large x. Say why large x fail first.
Exercise 19.6 (Stretch): Design and defend. A language runtime divides many numbers by the same runtime divisor d (for example the size of a hash table). Describe how you could use the magic-number idea at run time, and what it costs. What a strong answer includes: compute the multiplier and shift once when d changes; store them; each divide becomes a multiply, a shift and possibly a fixup; the cost is the setup, so it pays only if d is reused many times.
Chapter 20. Shifts, rotates and bit tricks
In this chapter: You will learn the shift and rotate instructions, the instructions that count and find bits, and the bitfield tools on ARM64. You will correct a common mistake about arithmetic shifts, and see how each machine counts bits.
You need first: Chapters 6 and 18.
The problem first
A lot of real code is about bits: flags packed in one word, a mask, a hash, a bitboard, a compressed stream, a set. You want to test one bit, set one, pull out a field, count how many are on, or find the lowest one. Each of those has a fast instruction, and knowing them turns loops into single steps.
The model (the "what")
Shifts. A shift slides all the bits left or right, filling the empty slots.
- Left shift (
shl,lsl) fills with zeros. It multiplies by a power of two. - Logical right shift (
shr,lsr) fills with zeros. It divides an unsigned number by a power of two. - Arithmetic right shift (
sar,asr) fills with copies of the sign bit. It divides a signed number by a power of two, rounding toward negative infinity.
Let's see the difference with a real number. In 8 bits, -14 is 11110010. Shift it right by 2:
logical (shr): 11110010 -> 00111100 = 60 (wrong for a signed number)
arithmetic (sar): 11110010 -> 11111100 = -4 (floor(-14 / 4) = -4: correct) The sign bit is copied into the two new top positions, and the rest slide over. The result is 11111100, which is -4. Notice also that -4 is the floor of -3.5. C's / rounds toward zero and would give -3. That is the reason for the bias fix you saw in sdiv4 in Chapter 19.
Rotates are shifts where the bits that fall off one end come back in at the other. x86-64 has rol and ror. ARM64 has only ror, and a left rotate by n is a right rotate by 64 - n. I compiled (x << 13) | (x >> 51) and the compilers recognized the pattern on both machines: rol rax, 13 on x86-64, and ror x0, x0, 51 on ARM64. Hashes and ciphers use rotates constantly.
Counting bits. Three questions come up all the time: how many bits are set (population count), how many zeros are at the top (leading zeros), and at the bottom (trailing zeros). On this guidebook's machine, which supports the newer x86-64 extensions, I ran them:
# x86-64 (needs the POPCNT, BMI1 and LZCNT extensions)
mov rax, 0xF0F0
popcnt rbx, rax # 8 bits set
tzcnt rcx, rax # 4 trailing zeros
lzcnt rdx, rax # 48 leading zeros They returned 8, 4 and 48 (a program adding them exited with 60, as predicted). But there is a catch. These instructions are not part of the baseline x86-64, so a compiler that must run on every x86-64 chip cannot assume them. Here is what gcc -O2 does with __builtin_popcountl by default, and what it does when told it may use the new instructions:
# x86-64, default target # x86-64, with -mpopcnt
pop: pop:
sub rsp, 8 xor eax, eax
call __popcountdi2@PLT popcnt rax, rdi
add rsp, 8 ret
ret By default it calls a library function. With -mpopcnt it is a single instruction. This is a place where your build flags change the machine code a lot. (Rust has the same split: -C target-cpu=native or -C target-feature=+popcnt.)
ARM64 handles the same three questions differently. Counting leading zeros is a base instruction, clz. Trailing zeros is a reverse of the bits followed by clz. And population count, which ARM64's integer unit did not have in its base set, uses the vector unit. Real output:
// ARM64, gcc -O2
pop: ctz: clz:
fmov d0, x0 rbit x0, x0 clz x0, x0
cnt v0.8b, v0.8b clz x0, x0 ret
addv b0, v0.8b ret
fmov w0, s0
ret The popcount moves the value into a vector register, counts the bits in each byte (cnt), adds the byte counts (addv, which sums all lanes), and moves the result back. (Do not try to build a popcount out of rbit and clz: that pair gives trailing zeros. Newer ARM64 chips with the CSSC extension add integer cnt and ctz instructions, but you cannot count on them.)
Bitfields on ARM64. Pulling a field out of a word, or putting one in, is so common that ARM64 gives it single instructions. Real encodings:
ubfx x1, x0, #8, #4 extract 4 bits starting at bit 8, zero-extend (x >> 8) & 0xf
bfi x0, x2, #4, #4 insert the low 4 bits of x2 at bit 4 of x0
sbfiz x1, x0, #8, #32 sign-extend the low 32 bits, then shift left 8 I compiled (x >> 8) & 0xf, and ARM64 gave exactly one instruction, ubfx w0, w0, 8, 4. x86-64 needed three (mov, shr, and). The BMI extensions add pext and pdep (gather and scatter bits by a mask), andn, and the helpers blsr and blsi (clear or isolate the lowest set bit), which this guidebook's machine supports.
Build it (the "how")
Let's write the standard "iterate over the set bits of a number" loop, which is the building block for bitboards and bitsets. The idea: find the lowest set bit, handle it, clear it, repeat.
# x86-64: count the set bits of rdi with the "clear lowest bit" loop (no popcnt)
xor eax, eax
test rdi, rdi
jz .done
.loop:
lea rcx, [rdi-1]
and rdi, rcx # x &= x - 1 clears the lowest set bit
inc eax
test rdi, rdi
jnz .loop
.done: The trick x & (x - 1) removes exactly the lowest set bit. The loop runs once per set bit, not once per bit position. (blsr rdi, rdi does the same in one instruction when BMI1 is available.) On ARM64: sub x1, x0, #1 then and x0, x0, x1.
Why it is this way (the "why")
Why are the bit-counting instructions optional on x86-64? Because they were added later, and the baseline is frozen for compatibility with the first 64-bit chips. This is the same compatibility bet as in Chapter 3. Compilers handle it with feature flags, and libraries sometimes ship several versions and choose at start-up, by asking the CPU what it supports.
Why does ARM64 count bits in the vector unit? Because the base integer instruction set did not have a popcount, and the vector unit already had a per-byte count. Reusing it is cheaper than adding an instruction. It costs a round trip between register files. Newer extensions fix that.
What does the x & (x - 1) trick show? That a good bit trick often falls out of how binary subtraction works: subtracting 1 flips the lowest set bit and everything below it, so ANDing with the original leaves only the bits above. Look for tricks like this and they pay off.
Common mistakes
- Using logical shift on a signed value. Use
sar/asr. - Shifting by the register width or more. In C it is undefined. In hardware, x86-64 masks the count to 6 bits (so a shift by 64 is a shift by 0), and ARM64 does the same for variable shifts. Check before relying on it.
- Assuming
popcntexists. Check the target. - Confusing
bsfwithtzcnton zero input. The olderbsfleaves the result undefined when the input is zero;tzcntreturns the operand size. - Expecting
rbit+clzto be a popcount. It is trailing zeros.
Say it simply
Shifts slide bits. Logical right shift fills with zeros and is for unsigned numbers; arithmetic right shift copies the sign bit and is for signed ones. Both machines have instructions for rotating, counting bits, and finding the lowest or highest set bit, but x86-64 treats several as optional extensions and ARM64 counts bits using its vector unit. ARM64 has single instructions to extract and insert bit fields.
Check yourself
Exercise 20.1 (Warm-up): Predict, then check. In 8 bits, what are 10110010 >> 3 as logical and as arithmetic? And -14 >> 2 both ways? Check with a small program or gdb. A correct result: logical: 00010110; arithmetic: 11110110. And -14 >> 2 is 00111100 (60) logical and 11111100 (-4) arithmetic.
Exercise 20.2 (Warm-up): Explain. In five sentences, explain why an arithmetic right shift divides by a power of two but rounds a negative number the "wrong" way compared with C division. What a strong answer includes: it floors; C truncates toward zero; the difference is only for negative numbers that are not exact multiples; compilers add a bias before the shift to fix it.
Exercise 20.3 (Core): Implement. Write on both machines: is_pow2(x) (use x & (x-1)), lowest_set_bit(x) (use x & -x), and reverse_bits_in_byte(x) (a loop on x86-64; one instruction plus a shift on ARM64 with rbit). Done when: all three are right for 0, 1, 2, 3, 64, 96, and the largest 64-bit number. Say what each returns for 0 and why.
Exercise 20.4 (Core): Predict, then check. Compile __builtin_popcountl, __builtin_ctzl and __builtin_clzl for your own machine with default flags and then with -march=native. Predict the instructions. Check. A correct result: default x86-64 calls a helper for popcount; -march=native gives popcnt, tzcnt and lzcnt where supported.
Exercise 20.5 (Core): Break it. Write a loop that finds the lowest set bit of x with bsf, and call it with x = 0. Then do it with tzcnt. What do you observe? A correct result: bsf leaves its destination unchanged (the manual calls the result undefined) and sets ZF; tzcnt returns 64. Say how you would write portable code.
Exercise 20.6 (Stretch): Design and defend. A chess engine keeps the position as 64-bit "bitboards." Name three operations that become single instructions with the tools in this chapter, and the cost on a machine that lacks them. What a strong answer includes: counting pieces (popcount), finding the next piece (tzcnt) and removing it (blsr or x & (x-1)); on a machine without them, a loop or a table lookup costs several times as much, so engines ship multiple versions.
Chapter 21. Floating point: the other register file
In this chapter: You will learn how floating-point numbers are stored, how both machines compute with them, why a comparison can have three answers instead of two, and why the same C code can give slightly different results on the two machines.
You need first: Chapters 14 and 18.
The problem first
Integers cannot hold 0.1, or the square root of 2, or a number as big as 10 to the 300th power. Floating point can, by storing a number the way scientific notation does: a sign, a significand (the digits), and an exponent (the scale). A neural network, a physics simulation or a graphics shader runs almost entirely on these. (If you wrote the MNIST network in Rust, every weight and activation was one.)
The model (the "what")
Almost every machine follows IEEE 754. A 64-bit double has 1 sign bit, 11 exponent bits and 52 significand bits. A 32-bit float has 1, 8 and 23. Three special values matter: infinity, negative zero, and NaN (not a number, the result of things like 0.0/0.0).
Floating-point lives in a separate register file from the integers: xmm0-xmm15 on x86-64 (these are the same registers that SIMD uses, from Chapter 29) and v0-v31 on ARM64 (seen as d0 for a double and s0 for a float). The calling convention from Chapter 14 passes float arguments there.
(x86-64 also contains an older, stack-based floating-point unit called the x87. It is still there for compatibility, and 64-bit code does not use it for ordinary double math. Everything in this chapter uses the newer SSE registers.)
The instructions are what you would guess, with a suffix for the size. On x86-64: sd means scalar double (addsd, mulsd, sqrtsd), ss means scalar single (addss). On ARM64, the register name carries the size: fadd d0, d1, d2 (double) or fadd s0, s1, s2 (single). Here is real gcc -O2 output for four small functions:
double scale(double x, long n) { return x * n + 1.5; }
long trunc_to_int(double x) { return (long)x; }
int lt(double a, double b) { return a < b; } # x86-64
scale:
movapd xmm1, xmm0 # keep x
pxor xmm0, xmm0 # (break a false dependency; see Chapter 26)
cvtsi2sd xmm0, rdi # convert the integer n to a double
mulsd xmm0, xmm1 # n * x
addsd xmm0, QWORD PTR .LC0[rip] # + 1.5, loaded from a constant in memory
ret
trunc_to_int:
cvttsd2si rax, xmm0 # convert with truncation toward zero
ret
lt:
xor eax, eax
comisd xmm1, xmm0 # compare b with a (operands swapped!)
seta al # "above": CF=0 and ZF=0
ret // ARM64
scale:
scvtf d1, x0 // convert the integer n to a double
fmov d2, 1.5e+0 // 1.5 fits INSIDE the instruction (a small float immediate)
fmadd d0, d1, d0, d2 // d0 = n*x + 1.5, ONE fused instruction
ret
trunc_to_int:
fcvtzs x0, d0 // convert, rounding toward zero
ret
lt:
fcmpe d0, d1
cset w0, mi // "minus": the N flag
ret Three things to see here.
First, constants. x86-64 keeps 1.5 in memory and loads it (QWORD PTR .LC0[rip]). ARM64 has a floating-point immediate form, so common small values like 1.5 or 2.0 sit inside the instruction. Other values go in a literal pool.
Second, a fused multiply-add. ARM64's fmadd computes a*b + c as one instruction with one rounding. The baseline x86-64 here used a separate mulsd and addsd, with two roundings. (x86-64 has FMA instructions too, vfmadd231sd, but they need a newer extension, so a compiler targeting any x86-64 chip does not use them.) That means the same C code can produce slightly different answers on the two machines. The fused result is more accurate, but it is not identical. In the dot-product loop, gcc used fmadd on ARM64 and a separate mulsd/addsd on x86-64. If you ever debug "why does my model give a different last digit on a different machine," this is a first suspect.
Third, comparison has three outcomes. For integers, a < b is either true or false. For floats, there is a third case: unordered, when either side is NaN. A NaN is not less than, greater than, or equal to anything, even itself. I checked what this does in C:
NaN < 1 : 0
NaN >= 1 : 0
NaN == NaN: 0
NaN != NaN: 1 Both NaN < 1 and NaN >= 1 are false, and NaN != NaN is true. That is the whole rule. Now see how the machines make it work. x86-64's comisd sets ZF, PF (the parity flag) and CF. For unordered it sets all three to 1. So a < b is compiled as comisd b, a; seta, because seta needs CF=0 and ZF=0, which an unordered result never satisfies. The compiler swapped the operands so the check would come out false for NaN. ARM64's fcmpe sets NZCV to 0011 for unordered, so the mi (N set) test is false for NaN. When you want to test for NaN directly on x86-64, you use the parity flag: jp.
Build it (the "how")
Let's write a dot product by hand. This is the inner loop of every neural network layer. Compute a[0]*b[0] + a[1]*b[1] + ....
# x86-64: double dot(const double *a /*rdi*/, const double *b /*rsi*/, long n /*rdx*/) -> xmm0
dot:
xorpd xmm0, xmm0 # s = 0.0 (xor with itself zeroes the register)
xor eax, eax # i = 0
test rdx, rdx
jle .done
.loop:
movsd xmm1, [rdi + rax*8] # a[i]
mulsd xmm1, [rsi + rax*8] # * b[i] (a memory operand)
addsd xmm0, xmm1 # s += ...
inc rax
cmp rax, rdx
jl .loop
.done:
ret // ARM64: double dot(const double *a /*x0*/, const double *b /*x1*/, long n /*x2*/) -> d0
dot:
fmov d0, xzr // s = 0.0
cbz x2, done
loop:
ldr d1, [x0], #8 // a[i], then advance
ldr d2, [x1], #8 // b[i], then advance
fmadd d0, d1, d2, d0 // s = a*b + s (one fused instruction)
subs x2, x2, #1
b.ne loop
done:
ret I ran both on [1,2,3] . [4,5,6]. The answer is 1*4 + 2*5 + 3*6 = 32, and I converted the result to an integer (cvttsd2si on x86-64, fcvtzs on ARM64) and exited with it. Both exited with status 32.
Now look closely at the loop. The addsd xmm0, xmm1 depends on the previous addsd, because it reads and writes the same xmm0. Each addition must wait for the last to finish. So this loop runs at the speed of one floating-point add's latency per element, not at the speed of the hardware's many adders. That observation is the reason fast dot products use several accumulators or vector registers. Chapters 26 and 29 turn it into a measurement.
Why it is this way (the "why")
Why a separate register file? Floating-point values are processed by different circuits, and keeping the registers next to those circuits is fast. It also doubles the number of registers a program can use. The cost is that moving a value between the two files takes an instruction (movq, cvtsi2sd, fmov).
Why does NaN compare unordered? Because the IEEE designers wanted an operation like 0.0/0.0 to poison the results that depend on it, not to quietly give a plausible answer. A NaN that compared equal to something would hide the error.
Why can the two machines differ? IEEE 754 fixes the result of each single operation, but not how a compiler groups a chain of them. Fusing a multiply and an add is allowed and changes the rounding. Compilers differ in whether they do it by default. If you need bit-identical results across machines, turn contraction off (-ffp-contract=off) or use a language mode that forbids it.
What would make this a bad design? If programs rarely used floats, a separate file would waste silicon. But graphics, science and machine learning use them heavily, so it pays.
Common mistakes
- Using
==on floats and expecting it to work after arithmetic. - Forgetting the NaN case in a comparison. The "not less than" branch is not the same as "greater than or equal."
- Using an integer
movfor a float argument. Floats go inxmm/v. - Assuming the same C code gives identical floats on two machines. Fusing changes the last bits.
- Skipping the
pxor/xorpdbeforecvtsi2sd. It breaks a false dependency on the register's old value. Compilers add it for you; you will see it in their output. - Expecting
cvttsd2sito saturate. On x86-64 an out-of-range value gives the "integer indefinite" value0x8000.... On ARM64,fcvtzssaturates and turns NaN into 0.
Say it simply
Floating-point numbers have a sign, a scale and digits, and live in their own register file (xmm on x86-64, v/d/s on ARM64). Comparisons have a third result, unordered, when a NaN is involved, and both machines encode it in their flags. ARM64 fuses a multiply and an add into one instruction with one rounding, so the same code can differ in the last digits across the two machines. A running sum is a chain of dependent adds, which limits its speed.
Check yourself
Exercise 21.1 (Warm-up): Predict, then check. Predict whether 0.1 + 0.2 == 0.3 is true in C, and what printf("%.17g", 0.1 + 0.2) prints. Check. A correct result: false, and it prints 0.30000000000000004. Explain why 0.1 cannot be stored exactly in binary.
Exercise 21.2 (Warm-up): Explain. In five sentences, explain why NaN == NaN is false. Use an analogy and say where it breaks. What a strong answer includes: NaN means "no valid number," so there is nothing to equal; it prevents a bad result from looking valid; analogy: two "unknown" answers on a form are not known to be the same; where it breaks: a program often wants to treat two NaNs as the same value for sorting or hashing, so languages add a separate total-order function.
Exercise 21.3 (Core): Implement. Write on both machines: fabs-by-bit-trick (clear the sign bit with an integer and after moving the double into an integer register), max2(a, b) for doubles using maxsd (x86-64) and fmax (ARM64), and sqrt. Done when: all three work for -3.5, 0.0, -0.0 and a large number. Say what max2 returns when one input is NaN on each machine (look it up, then test).
Exercise 21.4 (Core): Predict, then check. Write the dot-product loop in C and compile with -O2 for x86-64 with -mfma and without, and for ARM64. Predict which compiles to a fused instruction. Then run the C on three arrays of random doubles and compare the results from a build with and without -ffp-contract=off. A correct result: fusing appears with -mfma or on ARM64; the answers can differ in the last digits when contraction is on.
Exercise 21.5 (Core): Break it. Convert 1e30 to a long with cvttsd2si on x86-64 and with fcvtzs on ARM64. Predict each result. Run it. A correct result: x86-64 gives 0x8000000000000000; ARM64 saturates to 0x7fffffffffffffff.
Exercise 21.6 (Stretch): Design and defend. A neural network training run must be reproducible bit-for-bit across two kinds of machine. List three things that could break it that come from this chapter, and how you would prevent each. What a strong answer includes: fused multiply-add differences; different summation order (vector width, number of accumulators); different libm functions; fixes: disable contraction, fix the reduction order, use the same math library, and test against a reference.
Answer notes for Part 5
- 18.1:
200 + 100: as 8-bit unsigned, 300 does not fit (carry). As signed, 200 is -56 and-56 + 100 = 44fits (no overflow). - 19.1: unsigned
x/16isshr rax, 4(no multiply, no bias); signedx/16islea/cmovns/sarlikesdiv4. - 19.5: A wrong
Mis off by a tiny relative amount, so it first fails for numbers large enough that the error, multiplied byx, crosses a rounding boundary. - 20.1:
10110010 >> 3is00010110logical and11110110arithmetic (the sign bit 1 is copied into the top three positions). - 21.3:
maxsdreturns the second operand if either is NaN;fmaxon ARM64 returns NaN if either is NaN (andfmaxnmreturns the number). They differ.
Part 5 wrap-up
What you can now do:
- Explain two's complement, carry and overflow, and pick the right flag.
- Add numbers wider than a register with a carry chain.
- Use the multiply and divide families, and say what each costs on a given machine.
- Recognize division by a constant in compiler output, and verify it.
- Handle the divide-by-zero and
INT_MIN / -1edges on both machines. - Choose the right shift, rotate and bit-counting instruction, and say when it needs a target flag.
- Write a scalar floating-point loop and explain NaN comparison and fused multiply-add.
Mini project: the arithmetic laboratory. Time: 5 to 8 hours.
On both ISAs, build a small library with: add256, umul128 (full 64x64 -> 128 product), udivmod10 (with and without the magic number), popcount (loop, table and instruction, as available), and dot (scalar). Then:
- Test each against a reference in C or Python, including edge cases: zero, the largest value, powers of two.
- Time
udivmod10both ways over ten million inputs and report the ratio. - Run
doton 1000 random doubles on both machines and report the difference in the last digits, with and without-ffp-contract=offfor the C reference.
Done when: every function matches its reference on at least ten edge cases; your timing table has real numbers from your machine; and you have a paragraph on why your dot product results differ (or do not).
Write-up (half a page): What surprised you about the cost of division?
Reading list for Part 5:
- Henry S. Warren, "Hacker's Delight" (2nd edition). The standard book on bit tricks and division by constants, with derivations.
- Granlund and Montgomery, "Division by Invariant Integers using Multiplication" (PLDI 1994). The paper behind the compiler's division trick.
- Agner Fog, "Instruction tables" (agner.org/optimize) and uops.info. Measured latencies and throughputs for
add,imulanddivon every chip. - David Goldberg, "What Every Computer Scientist Should Know About Floating-Point Arithmetic" (1991). The classic.
- IEEE 754-2019. The standard itself, for NaN and comparison rules.
- The Arm Architecture Reference Manual, floating-point and integer divide sections. For the exact divide-by-zero behavior.
Teach it back. In five sentences, as if to a friend, explain what happens when a program divides by ten, what a NaN is, and why two computers can print different last digits for the same sum.
Part 6: Real Programs
After this Part you can write the small routines every program leans on (string length, memory copy, a file copier), and you can read the stack the kernel hands you at start-up. You will also see a measured ladder of how much faster the same job gets when you use the machine well.
Chapter 22. Strings and memory: strlen, memcpy and the speed ladder
In this chapter: You will write strlen and memcpy four ways on x86-64, and measure all four. You will learn the hardware string instruction rep movsb, a trick for checking eight bytes at once, and why the C library's version is faster than anything you will write by hand in a loop.
You need first: Chapters 7, 11 and 19.
The problem first
strlen and memcpy are among the most-called functions in any C program. They are also tiny. That makes them a perfect lesson: the job is simple, so the only thing that changes the speed is how well you use the machine. The same job can run at 2 GB/s or at 100 GB/s. Why?
The model (the "what")
A C string is a run of bytes ending in a zero byte. strlen walks forward until it finds the zero. memcpy copies n bytes from a source to a destination. Here are four ways to copy, from the simplest to the fastest. All four are real code that I assembled, linked into a C test program, and timed.
# x86-64. Each follows the System V convention: rdi = dst, rsi = src, rdx = n. Returns dst.
# 1. One byte per trip.
copy_bytes:
mov rax, rdi
test rdx, rdx
jz .done
xor ecx, ecx
.next:
movzx r8d, byte ptr [rsi + rcx]
mov byte ptr [rdi + rcx], r8b
inc rcx
cmp rcx, rdx
jne .next
.done:
ret
# 2. Eight bytes per trip (n must be a multiple of 8 here).
copy_qwords:
mov rax, rdi
shr rdx, 3 # n / 8 trips
jz .done
xor ecx, ecx
.next:
mov r8, [rsi + rcx*8]
mov [rdi + rcx*8], r8
inc rcx
cmp rcx, rdx
jne .next
.done:
ret
# 3. The hardware string-copy instruction.
copy_rep:
mov rax, rdi
mov rcx, rdx # rcx = how many bytes
rep movsb # copy rcx bytes from [rsi] to [rdi]
ret The third one is the surprise. rep movsb is one instruction, with a rep prefix that means "repeat this rcx times." It copies byte after byte from [rsi] to [rdi], advancing both, and counting rcx down to zero. It looks like the slowest of all, because it says "byte." But the CPU does not run it as a byte loop. On modern chips it recognizes the whole thing and copies in large chunks, using a fast path built into the chip.
Whether that is fast depends on two features that your CPU reports: ERMS ("enhanced rep movsb") and, on newer chips, FSRM ("fast short rep movsb"). This guidebook's machine reports ERMS but not FSRM (I checked /proc/cpuinfo).
For comparison, the fourth contestant is the C library's own memcpy.
The same idea on ARM64 uses pairs. A load and a store of two registers at once moves 16 bytes per trip:
// ARM64: copy_pairs(dst x0, src x1, n x2). n must be a multiple of 16.
copy_pairs:
mov x3, x0
lsr x2, x2, #4 // n / 16 trips
cbz x2, done
next:
ldp x4, x5, [x1], #16 // load 16 bytes into two registers, advance the source
stp x4, x5, [x3], #16 // store them, advance the destination
subs x2, x2, #1
b.ne next
done:
ret I tested copy_pairs and a byte-at-a-time my_strlen for ARM64 under qemu-user. A 48-byte copy matched, and a 40-character string gave a length of 40. (qemu cannot give real ARM64 timing, so the numbers below are for x86-64 only.)
The results. Here are the real speeds on this guidebook's machine (an Intel Xeon @ 2.80 GHz, one core, in a virtual machine), in gigabytes per second. Each cell is the median of five runs, all taken back to back in one session. Caches matter here, so I tried four sizes. The machine reported a 32 KiB first-level cache, a 1 MiB second-level cache, and a 33 MiB third-level cache.
| Size | byte loop | 8 bytes/trip | rep movsb | libc memcpy |
|---|---|---|---|---|
| 256 bytes | 2.7 | 12.2 | 26.6 | 92.7 |
| 4 KiB | 2.9 | 12.8 | 147.8 | 161.7 |
| 256 KiB | 2.9 | 12.8 | 44.2 | 44.2 |
| 64 MiB | 2.6 | 5.0 | 6.1 | 10.9 |
Read this table by column, then by row.
- The byte loop is flat at about 2.7 to 2.9 GB/s at every size. It does one byte per trip, and each trip costs about the same. The loop's own overhead (increment, compare, branch) is the bottleneck, not memory.
- Eight bytes per trip is about 4 to 5 times faster (12 to 13 GB/s). Eight times fewer trips. But not eight times faster, because each trip still does the same fixed overhead.
rep movsbis strong for medium sizes: 148 GB/s at 4 KiB against libc's 162, and an exact tie at 256 KiB (44 each). That one instruction is about as good as the library's tuned vector code there. At 256 bytes it is far slower (27 against 93), because there is a start-up cost for the string instruction.- At 64 MiB, everything is slow (5 to 11 GB/s). The data no longer fits in any cache, so the speed is limited by main memory. Here libc was best (10.9), and the 8-byte loop and
rep movsbwere about half of that. Even the best is about 15 times slower than the same copy in cache.
A caution from the sandbox: in an earlier session on different hardware, at 256 KiB rep movsb (46 GB/s) beat libc (28 GB/s), and at 64 MiB all three tied at about 5 GB/s. The library picks a strategy by size using thresholds tuned for typical chips, and on a given machine its choice can be a miss. This is why people measure on the machine they ship on, and why memcpy is a topic that never ends.
A trick for strlen: check eight bytes at once. A byte loop for strlen runs at about 2 GB/s. The idea is to load 8 bytes into one 64-bit register and ask "does any byte in here equal zero?" with a handful of arithmetic instructions. This technique is called SWAR (SIMD within a register). It works like this, in C:
size_t swar_strlen(const char *s) {
const char *p = s;
while ((uintptr_t)p & 7) { if (!*p) return p - s; p++; } // 1. reach an 8-byte boundary
for (;;) {
uint64_t v = *(const uint64_t *)p;
uint64_t z = (v - 0x0101010101010101ULL) & ~v & 0x8080808080808080ULL; // 2. find zero bytes
if (z) return (p - s) + (__builtin_ctzll(z) >> 3); // 3. which byte was it?
p += 8;
}
} Step 2 is the clever one. Subtracting 0x01 from each byte makes a byte that was 0x00 become 0xFF, with its high bit set. & ~v removes bytes that already had the high bit set (so a byte like 0x80 is not mistaken for a zero), and & 0x8080... keeps just the high bits. So z is nonzero exactly when some byte of v was zero, and the lowest set high bit marks the first zero byte. (Higher markers can be false positives from borrows, which is why we take the lowest one with ctz.) Step 1 is not optional. Reading 8 bytes from an address that is not 8-aligned could cross into an unmapped page and crash, even if the string ends well before that. If you align first, a word read never crosses a page boundary.
I checked it against the C library's strlen on 200,000 random strings with random lengths and alignments: zero mismatches. Then I timed a 1 MiB string (median of five runs):
strlen method | Speed |
|---|---|
| byte loop | 2.1 GB/s |
| SWAR, 8 bytes per trip | 13.1 GB/s |
libc strlen | 95.3 GB/s |
SWAR is about 6 times faster than the byte loop. And libc is about 7 times faster than SWAR, because it uses the vector registers (16, 32 or even 64 bytes per step; Chapter 29) and picks the right version for the CPU at start-up.
Build it (the "how")
Let's put one of these into a complete test. The harness is a C program that calls the assembly functions and times them. The assembly is the file above, linked with gcc -O2 -o bench bench.c cp.s. The harness checks every result with memcmp and prints MISMATCH if one copy is wrong; none was.
Here is the pattern worth stealing for any assembly routine you write:
- Write a C reference (or use libc) and compare outputs on many inputs, including edge cases: zero length, length 1, lengths just below and above a power of two, and every alignment.
- Time with a loop and the best of several runs, so one noisy run does not mislead you.
- Compare to the library. If you cannot beat it, use it.
Why it is this way (the "why")
Why is the library so fast? Because it was written to use every tool: vector registers for wide loads and stores, special handling for small sizes, alignment tricks, and for huge copies, non-temporal stores that skip the cache. And it contains several versions and picks one when the program starts, based on what the CPU supports. Hand-written code rarely wins unless you know something the library cannot (a fixed size, a guaranteed alignment).
Why is rep movsb fast now, when it was slow for decades? For a long time, string instructions were microcoded and slower than a loop, and compilers avoided them. Chip makers then built a fast path for the common case and added the ERMS feature flag to say so. This is a good reminder that the speed of an instruction is not a property of the instruction. It is a property of the chip. The same bytes can be the best choice on one generation and the worst on another.
Why does everything slow down and bunch together at 64 MiB? Because the limit changed. For small sizes the limit is how fast the core can issue instructions. For huge sizes the limit is how fast the memory system can deliver bytes, and that is the same for every method. Chapter 27 returns to this. It is the single most important idea in performance work: first find which limit you are against.
Common mistakes
- Reading past the end of a string with a wide load. Align first, or stop before the last partial word.
- Using
rep movsbfor tiny copies on a chip without the fast-short feature. - Ignoring overlap.
memcpyrequires non-overlapping ranges.memmovehandles overlap, and a forwardrep movsbon overlapping data gives the wrong answer unless the direction is right. - Benchmarking only one size. The winner changes with size.
- Writing your own
memcpyfor production without measuring it against libc.
Say it simply
A byte-at-a-time copy is limited by the loop's overhead, about 3 GB/s here. Copying eight bytes per trip is about 4 times faster, and rep movsb is about as fast as the library at medium sizes on a chip that supports it. For huge copies the limit is main memory, and every method is slow. A trick called SWAR checks eight bytes at once for a zero byte, but the C library, using vector registers, is faster still.
Check yourself
Exercise 22.1 (Warm-up): Predict, then check. Before running anything, predict the order of the four copy methods at 4 KiB and at 64 MiB. Then run the benchmark. Were you right? A correct result: at 4 KiB, libc and rep movsb lead, then 8-byte, then bytes. At 64 MiB the top three are close together. Your numbers will differ, and the shape should hold.
Exercise 22.2 (Warm-up): Explain. In five sentences, explain why the differences between copy methods shrink for a huge buffer. Use an analogy and say where it breaks. What a strong answer includes: the limit moves from the core to memory bandwidth, so the methods converge; analogy: it does not matter how many cooks you have if the pantry has one narrow door; where it breaks: more cores or more memory channels can raise the limit.
Exercise 22.3 (Core): Implement. Write my_strlen on both machines using the SWAR idea (in assembly, not C). Handle the alignment head with a byte loop. On x86-64 use tzcnt (or bsf) to find the byte; on ARM64 use rbit and clz. Done when: it matches libc's strlen on 100,000 random strings with random alignments, including the empty string and a string that ends exactly at the end of a page (allocate with mmap and put the string so its NUL is the last byte of the page).
Exercise 22.4 (Core): Break it. Remove the alignment loop from the SWAR strlen. Test on a string whose NUL is the last byte before an unmapped page. What happens, and why does a test on ordinary malloc memory not catch it? A correct result: a crash (SIGSEGV) because the 8-byte read crosses into the unmapped page. malloc memory has slack after the string, so the bug hides until the string lands at a page end.
Exercise 22.5 (Core): Measure. Write memset three ways (byte loop, rep stosb, 8-byte stores) and time them at the same four sizes. Predict first. A correct result: a table like the one above. Explain any place where your prediction was wrong.
Exercise 22.6 (Stretch): Design and defend. You are writing a memcpy for an embedded chip with no vector unit and no rep movsb fast path. What strategy would you use for sizes 1, 7, 64 and 4096? What a strong answer includes: a small-size path with overlapping loads and stores (copy the first and last word); a loop of the widest register width for medium sizes; alignment of the destination; and a measured threshold between them.
Chapter 23. Talking to the kernel: system calls, the start-up stack and a cat clone
In this chapter: You will use more system calls (openat, read, close, mmap), handle their errors, and read the argc, argv, environment and auxiliary vector that the kernel leaves on the stack before your first instruction. You will build a working file-copy program on both machines, and measure what a system call really costs.
You need first: Chapters 4, 8 and 16.
The problem first
In Chapter 4 we used two system calls with fixed arguments. Real programs need more: open a named file, read until it ends, check for errors, ask for memory. And they need their inputs: the command-line arguments and the environment. Where do those come from? The kernel put them somewhere. You need to know where.
The model (the "what")
More system calls. The convention from Chapter 4 is the same; only the numbers and arguments change. I looked these up in the kernel's headers on this machine:
| Call | x86-64 | ARM64 |
|---|---|---|
read(fd, buf, n) | 0 | 63 |
write(fd, buf, n) | 1 | 64 |
openat(dirfd, path, flags) | 257 | 56 |
close(fd) | 3 | 57 |
mmap(...) | 9 | 222 |
munmap(addr, len) | 11 | 215 |
exit(status) | 60 | 93 |
exit_group(status) | 231 | 94 |
getpid() | 39 | 172 |
Why openat, and not open? ARM64 has no old-style open at all. Its table started fresh, with only the modern call. On x86-64 open (number 2) still exists, but openat with dirfd = AT_FDCWD (which is -100) does the same job, and it works on both.
Errors. A system call reports failure by returning a small negative number: the negative of the error code (-errno). On x86-64 any result in -4095..-1 is an error. That is why the mmap code checks cmp rax, -4096; ja fail (unsigned compare, so negatives look huge), and why ARM64 uses cmn x0, #4096; b.hi. A successful mmap can return an address that looks "negative" in the signed sense on some systems. Checking the specific range is the safe test.
The stack the kernel built for you. When your program starts, rsp (or sp) points at a block of memory that looks like this:
low addresses
[sp] argc (a count, 8 bytes)
[sp+8] argv[0] (pointer to the first argument string)
[sp+16] argv[1]
...
NULL (ends argv)
envp[0] (pointer to "NAME=value")
envp[1]
...
NULL (ends envp)
auxv: (type, value) pairs of 8-byte values
...
AT_NULL (0, 0) (ends the auxiliary vector)
... then the actual strings
high addresses I wrote a program to walk this. It skips argc + 1 pointers (the arguments and their NULL), then skips pointers until the next NULL (the environment), and then reads (type, value) pairs until the type is 0. I asked it for type 6, which is AT_PAGESZ, the page size. Both machines printed 4096. That is how a program learns the page size without asking: the kernel left it on the stack. The same vector also holds the address of the system's fast time-reading code (the vDSO; see below), a pointer to 16 random bytes, and which CPU features exist.
# x86-64: find AT_PAGESZ (type 6) in the auxiliary vector
_start:
mov rax, [rsp] # argc
lea rsi, [rsp + 8 + rax*8 + 8] # skip argc, the argv pointers, and the NULL after them
.env:
mov rdx, [rsi]
add rsi, 8
test rdx, rdx
jnz .env # skip the environment pointers up to their NULL
.aux:
mov rax, [rsi] # the type
test rax, rax
jz .notfound # AT_NULL ends the vector
cmp rax, 6 # AT_PAGESZ
je .found
add rsi, 16 # next (type, value) pair
jmp .aux
.found:
mov rdi, [rsi + 8] # the value: 4096
call print_u64 Build it (the "how")
Let's build cat: it opens the file named in argv[1], reads it in blocks, writes each block to standard output, and closes the file. This uses all the pieces: reading argv, openat, a read loop, error checks, write, close and exit.
# mycat.s -- x86-64
.intel_syntax noprefix
.global _start
.bss
buf: .space 4096
.text
_start:
mov rax, [rsp] # argc
cmp rax, 2
jl fail # no file name given
mov rsi, [rsp + 16] # argv[1]
mov eax, 257 # openat(AT_FDCWD, path, O_RDONLY)
mov rdi, -100 # AT_FDCWD
xor edx, edx # flags = O_RDONLY
syscall
test rax, rax
js fail # negative means -errno
mov r12, rax # the file descriptor, in r12 (it survives syscalls)
.next:
xor eax, eax # read(fd, buf, 4096)
mov rdi, r12
lea rsi, [rip+buf]
mov edx, 4096
syscall
test rax, rax
jle .done # 0 = end of file; negative = error
mov rdx, rax # write exactly as many bytes as we read
mov eax, 1
mov edi, 1
lea rsi, [rip+buf]
syscall
jmp .next
.done:
mov eax, 3 # close(fd)
mov rdi, r12
syscall
xor edi, edi
jmp .exit
fail:
mov edi, 1
.exit:
mov eax, 60
syscall The ARM64 version is the same shape, with the numbers from the table and the file descriptor kept in x19:
// mycat.s -- ARM64 (the key lines)
ldr x1, [sp, #16] // argv[1]
mov x0, #-100 // AT_FDCWD
mov x2, #0 // O_RDONLY
mov x8, #56 // openat
svc #0
cmp x0, #0
b.lt fail
mov x19, x0 // the fd, in a callee-saved register
next:
mov x0, x19
adrp x1, buf
add x1, x1, :lo12:buf
mov x2, #4096
mov x8, #63 // read
svc #0
cmp x0, #0
b.le done
mov x2, x0 // bytes read
mov x0, #1
adrp x1, buf
add x1, x1, :lo12:buf
mov x8, #64 // write
svc #0
b next I ran both on a two-line text file. Both printed line one and line two, and exited with status 0. With a missing file name they both exited with status 1. And strace on the x86-64 version showed exactly the sequence we wanted:
openat(AT_FDCWD, "sample.txt", O_RDONLY) = 3
read(3, "line one\nline two\n", 4096) = 18
write(1, "line one\nline two\n", 18) = 18
read(3, "", 4096) = 0
close(3) = 0 A final read of 0 bytes is how a file says "that is all." The two lines of the file arrived in one read because it is small. A big file would come in 4096-byte blocks.
Asking for memory. One more call, which Chapter 38 needs: mmap with MAP_ANONYMOUS gives you fresh pages that are not backed by a file.
# x86-64: get one page of read-write memory
mov eax, 9 # mmap
xor edi, edi # addr = NULL: let the kernel choose
mov esi, 4096 # length: one page
mov edx, 3 # PROT_READ | PROT_WRITE
mov r10d, 0x22 # MAP_PRIVATE | MAP_ANONYMOUS (the 4th argument is r10)
mov r8, -1 # fd = -1
xor r9d, r9d # offset = 0
syscall I wrote the byte 42 to the page, read it back, and exited with it. Both machines exited with 42. (The ARM64 version loads the same values into x0-x5 and uses number 222.)
What a system call really costs. A syscall is not an ordinary call. The CPU switches into the kernel and back. I measured it with a loop of one million getpid calls:
| Call | Time per call |
|---|---|
real system call (getpid) | about 100 ns (median of five runs, range 98 to 110) |
clock_gettime, through the vDSO | about 20 ns (range 19 to 22) |
That is about 5 times as much for the real syscall. (In an earlier session, on what was evidently different hardware or kernel settings, I measured 273 ns against 23 ns, about 12 times. A system call's cost depends a lot on the machine, the kernel version, and the security fixes for CPU speculation attacks, which add work to every kernel entry. This sandbox is also a virtual machine.) The vDSO is the reason the second one is so cheap. It is a small piece of kernel code that the kernel maps into every process, and the program calls it as an ordinary function, with no mode switch, for things that need only data the kernel can publish read-only (like the time). I confirmed that with strace: 1,000 calls to clock_gettime produced zero system calls. The address of the vDSO is one of those auxv entries.
Why it is this way (the "why")
Why does the kernel leave argc and argv on the stack, not in registers? Because there is no limit on how many arguments a program may have, and registers are few. The stack can hold any amount. The convention that [sp] is argc is part of the platform's ABI, and C's main receives argc and argv from a small piece of start-up code (crt1.o) that reads exactly this layout before calling main. Your _start is that code, done by hand.
Why is a system call so expensive? The CPU has to change privilege, switch to a kernel stack, save state, and later restore it all. After the discovery of the Spectre and Meltdown attacks, systems added extra steps on every kernel entry to prevent leaks. The cost is the price of protection. This is why programs batch their I/O: one read of 4096 bytes costs one syscall, while 4096 reads of one byte would cost 4096. It is also why buffered I/O libraries exist.
What would make a different design better? Interfaces that batch many operations in one entry (Linux's io_uring is an example) trade a more complex interface for fewer entries. When the per-call cost is on the order of 100 ns and the work is a few nanoseconds, batching wins.
Common mistakes
- Forgetting that
rcxandr11are destroyed bysyscall(Chapter 4). - Checking only
rax < 0for errors frommmap. Use the-4096range test. - Using the 4th syscall argument register
rcxinstead ofr10. - Writing
read's result count wrongly. Write what you read, not the buffer size. - Walking
argvwithout skipping the NULL that ends it (that is the+ 8in thelea). - Not closing the file. The process exit closes it for you, but a long-running program leaks descriptors.
- Reading one byte at a time from a file. One syscall per byte.
Say it simply
A system call number goes in a register, the arguments in others, and a special instruction enters the kernel, which returns a result or a negative error code. At start-up the kernel leaves argc, the argv pointers, the environment pointers and a vector of facts like the page size on the stack, in a fixed layout. A real system call costs on the order of a hundred nanoseconds or more, so programs batch their reads and writes, and some calls (like reading the time) avoid the kernel through a shared page called the vDSO.
Check yourself
Exercise 23.1 (Warm-up): Predict, then check. What does ./mycat print if you give it a file name that does not exist? What is its exit status? Run it on both machines. A correct result: it prints nothing and exits with status 1.
Exercise 23.2 (Warm-up): Explain. In five sentences, explain why the final read returns 0 and why that ends the loop. Give an analogy and say where it breaks. What a strong answer includes: end of file is signaled by a zero-byte read; analogy: a conveyor belt that stops delivering; where it breaks: for a pipe or terminal, a zero return means the other end closed, and a slow producer just makes read wait (block) rather than return 0.
Exercise 23.3 (Core): Implement. Extend mycat to print an error message to standard error (file descriptor 2) when the open fails, including the error number in decimal (use print_u64 with the negated return value, printed to fd 2). Test with a missing file, and with a directory name. Done when: a missing file prints a code of 2 (ENOENT), and a directory (which opens but fails to read) prints a different code on the read.
Exercise 23.4 (Core): Implement. Write a program that prints its own argc, and then every argument on its own line, on both machines. Test with spaces in an argument (./prog "a b" c). Done when: ./prog "a b" c prints 3, ./prog, a b, c (the first line is the count, then each argument).
Exercise 23.5 (Core): Predict, then check. Use strace -c on mycat reading a 1 MiB file with a 4096-byte buffer, then change the buffer to 64 KiB. Predict how the number of read calls changes. Check. A correct result: about 256 reads versus 16. Time both and compare, and say what part of the gain is the syscall cost.
Exercise 23.6 (Stretch): Design and defend. You must copy 10,000 small files. Compare a simple loop of open/read/write/close with a design that batches. Name the cost you are trying to reduce and what you would measure first. What a strong answer includes: the per-syscall cost (about 100 ns on this machine, and more on others) multiplied by roughly five calls per file; batching or asynchronous interfaces cut the number of kernel entries; but first measure whether the syscalls or the disk dominate.
Answer notes for Part 6
- 22.3: On x86-64, after the aligned loop finds a word with a marker,
tzcntof the marker word gives the bit position; divide by 8 for the byte index. On ARM64,rbitthenclzgives the same bit position. - 22.4: The test passes on ordinary heap memory because
mallocrounds sizes up and leaves readable bytes after the string. Usemmapfor a clean page boundary and place the string so its NUL is the last byte. - 23.4 (hint):
argv[i]is at[sp + 8 + 8*i]on entry. The count is at[sp]. - 23.5: The drop in reads is 16 times. The wall-clock gain is smaller than 16 times because the data copy itself still takes time. Subtract it to find the syscall share.
Part 6 wrap-up
What you can now do:
- Write
strlenandmemcpyseveral ways and measure all of them. - Use
rep movsband say when it is fast. - Check eight bytes at once with the SWAR trick, and say why alignment is required.
- Use
openat,read,write,closeandmmapon both machines, with correct error checks. - Walk the start-up stack for
argc,argv, the environment andauxv. - Say what a system call costs and why some calls avoid it.
Mini project: mini-coreutils. Time: 8 to 12 hours.
On both ISAs, with no libc, build three tools: mycat (several files), mywc (counts lines, words and bytes of a file), and myhead (first N lines, with N from argv). Use print_u64 for output.
Done when: each tool gives the same output as the real one (cat, wc, head) on a text file, an empty file, a file with no final newline, and a file bigger than your buffer; and your README has the strace -c counts for each, with one sentence on how you chose the buffer size.
Write-up (half a page): What was the hardest edge case, and how did you find it?
Reading list for Part 6:
- Linux man pages:
syscall(2),open(2),read(2),mmap(2),getauxval(3),vdso(7). The exact behavior, flags and error codes. - The Linux kernel source:
arch/x86/entry/syscalls/syscall_64.tblandinclude/uapi/asm-generic/unistd.h. The real syscall tables. - Sean Eron Anderson, "Bit Twiddling Hacks" (Stanford). The home of the zero-byte detection trick.
- Torbjörn Granlund and others on GMP's
memcpyand string work, and the glibc source forsysdeps/x86_64/multiarch. How a real library picks a version per CPU. - Intel Optimization Reference Manual, section on
rep movsb(ERMS and FSRM). When the string instructions are fast.
Teach it back. In five sentences, as if to a friend, explain why copying a big buffer a byte at a time is slow, what rep movsb does, and why every method ties when the data does not fit in the cache.
Part 7: How Instructions Become Bytes
After this Part you can look at raw instruction bytes and say what they do, on both machines. You will know why x86-64 instructions vary from 1 to 15 bytes, why every ARM64 instruction is 4, and what that costs and buys each machine.
Chapter 24. x86-64 encoding: prefix, opcode, ModRM, SIB
In this chapter: You will learn how one x86-64 instruction is built out of up to six kinds of byte. You will decode instructions by hand and check each one with the assembler. You will see why a disassembler that starts in the wrong place produces plausible nonsense, and how newer prefixes (VEX, EVEX and the new REX2) extend the format.
You need first: Chapters 6, 7 and 14.
The problem first
Why should you care how instructions are packed into bytes? Four reasons that come up in real work. You will read a hex dump (a crash, a patch, a firmware image) with no disassembler. You will want to know why one instruction is 3 bytes and another 10, because code size affects speed. You will write a JIT (Chapter 38) that must produce these bytes. And you will meet bugs where a disassembler lies, which only makes sense once you know how decoding works.
The model (the "what")
An x86-64 instruction is a short record. Every part except the opcode is optional:
[legacy prefixes] [REX] opcode [ModRM] [SIB] [displacement] [immediate]
0 to 4 bytes 0-1 1-3 0-1 0-1 0, 1, 2, 4 0, 1, 2, 4, 8
bytes byte byte or 8 bytes or 8 bytes The total is at most 15 bytes. Let's go through the parts, using real bytes that I produced with the GNU assembler and read back with objdump.
The opcode says what the instruction does. 89 is "move register to register or memory," 8b is the reverse direction, 01 is add, 50 through 57 are push rax through push rdi, c3 is ret, 90 is nop, 0f 05 is syscall. The 0f byte is an escape: it means "the real opcode is the next byte," and it opens a second table of instructions. (There are more escapes: 0f 38 and 0f 3a open two more tables.)
REX is a one-byte prefix, from 40 to 4f, that extends the original 32-bit design to 64 bits. Its low four bits are flags:
0100 W R X B
| | | |
| | | +-- B: adds a 4th bit to the register in the ModRM "rm" field or the SIB base
| | +---- X: adds a 4th bit to the SIB index register
| +------ R: adds a 4th bit to the ModRM "reg" field
+-------- W: 1 means 64-bit operand size W is why mov rax, rbx has a 48 in front and mov eax, ebx does not. R, X and B are why you can reach r8 to r15: the original design had only 3 bits for a register number (8 registers), and these flags supply a 4th bit. Real encodings:
89 d8 mov eax, ebx (no REX: 32-bit)
48 89 d8 mov rax, rbx (REX.W = 1: 64-bit)
49 89 d8 mov r8, rbx (REX.W and REX.B: the destination is r8)
45 89 c8 mov r8d, r9d (REX.R and REX.B: both registers are r8-r15)
66 89 d8 mov ax, bx (the 66 prefix: 16-bit operand size) ModRM is a byte that says which operands the instruction has. It is split into three fields:
bits 7-6 bits 5-3 bits 2-0
mod reg rm reg names one register operand. rm and mod together name the other operand: a register, or a memory address.
mod | Meaning of the rm operand |
|---|---|
11 | a register (just the register in rm) |
00 | memory at [rm] (no displacement) |
01 | memory at [rm + 8-bit displacement] |
10 | memory at [rm + 32-bit displacement] |
Let's decode one fully. Take 48 89 d8, which the assembler gave for mov rax, rbx:
48is REX withW=1: a 64-bit operation.89is the opcode "moveregtorm."d8is the ModRM byte:1101 1000, somod = 11(register direct),reg = 011(that isrbx),rm = 000(that israx).
So: move rbx into rax. The direction is in the opcode: 89 moves reg to rm, 8b moves rm to reg. That is why one mov has two valid encodings. (Do not confuse it with 48 01 d8, which is add rax, rbx: the opcode 01 differs from 89, but the ModRM byte d8 is identical, because both instructions use the same operand layout.)
SIB ("scale, index, base") is a second byte that appears when rm says it is needed (rm = 100 with mod not 11). It holds the full addressing recipe from Chapter 7:
bits 7-6 bits 5-3 bits 2-0
scale index base scale is a 2-bit number: 00, 01, 10, 11 mean multiply the index by 1, 2, 4 or 8. This is why the scale can only be those four values: two bits cannot say more. Let's decode a full memory operand, 48 8b 44 8b 10:
48REX.W.8bthe opcode "movermtoreg."44ModRM:01 000 100:mod = 01(an 8-bit displacement follows),reg = 000(the destination israx),rm = 100(a SIB byte follows).8bSIB:10 001 011: scale10(times 4), index001(rcx), base011(rbx).10the displacement: 16.
Together: mov rax, [rbx + rcx*4 + 0x10]. I assembled exactly that line and got exactly those five bytes.
Two special cases that trip everyone. They come from the way mod and rm share meaning.
[rsp]and[r12]need a SIB byte, even with no index. Becauserm = 100is already the signal for "a SIB follows," there is no spare code to say plain[rsp]. The assembler emits a SIB with "no index" and basersp. Real bytes:mov rax, [rsp]is48 8b 04 24. (The extra24that I pointed out in Chapter 7 is this SIB.)[rbp]and[r13]need a displacement, even a zero one. Becausemod = 00withrm = 101was reassigned to mean "RIP-relative with a 32-bit displacement," plain[rbp]has no encoding. The assembler usesmod = 01with an 8-bit displacement of zero. Real bytes:mov rax, [rbp]is48 8b 45 00. The disassembler prints it as[rbp+0x0].
Both gaps are historical. They are the price of fitting a 64-bit design into a 32-bit design's spare codes. A JIT author has to know them, or the bytes come out wrong.
The immediate is a constant at the end of the instruction. Its size depends on the instruction. Here you can see the assembler choosing between forms, with real output:
48 83 c0 01 add rax, 0x1 (opcode 83: an 8-bit immediate, sign-extended)
48 05 e8 03 00 00 add rax, 0x3e8 (opcode 05: a special short form, only for rax)
48 81 c3 e8 03 00 00 add rbx, 0x3e8 (opcode 81: a full 32-bit immediate) Look at the last two. They add the same number, but to different registers. Adding to rax has its own dedicated opcode (05), which skips the ModRM byte, so it is one byte shorter (6 bytes, not 7). There is a family of such shortcuts for the accumulator register. This is why the compiler prefers rax for some arithmetic: not because rax is faster, but because the encoding is smaller.
Legacy prefixes come before everything and change how an instruction behaves. 66 changes the operand size to 16 bits. f3 is the rep prefix (so rep movsb is f3 a4). f0 is lock (the atomic prefix from Chapter 30): lock add qword ptr [rax], 1 is f0 48 83 00 01. Segment overrides like 64 (for fs:) also live here. The same prefix byte can mean different things on different instructions. f3 is rep on a string instruction but selects a different instruction entirely on others. Part of what makes x86-64 hard to decode is that one byte's meaning depends on context.
Newer prefixes. The format kept growing by adding new multi-byte prefixes that carry more information. Here are the three you will meet, with real bytes from this machine's assembler.
c5 f5 58 c2 vaddpd ymm0, ymm1, ymm2 VEX (2-byte form): AVX, three operands, 256-bit
62 f1 f5 48 58 c2 vaddpd zmm0, zmm1, zmm2 EVEX (4-byte prefix): AVX-512, 512-bit, masks
d5 58 89 c8 mov r16, r17 REX2: Intel APX, 32 registers - VEX (
c5orc4) came with AVX. It lets an instruction name a third register and use 256-bit vectors. It replaces the legacy prefix and REX with one compact prefix. - EVEX (
62) came with AVX-512. It is a 4-byte prefix that adds 512-bit width, 32 vector registers, per-lane mask registers, and more. Chapter 29 uses it. - REX2 (
d5) belongs to APX, Intel's Advanced Performance Extensions. APX doubles the general-purpose registers from 16 to 32 (r16tor31). The last line above is real: this guidebook's assembler (binutils 2.42) acceptsmov r16, r17and producesd5 58 89 c8. Let's decode it. After thed5marker, the byte58is0101 1000. In the REX2 layout, its bits areM0 R4 X4 B4 W R3 X3 B3, which givesR4 = 1,B4 = 1,W = 1. Then the opcode89and ModRMc8(11 001 000): the sourceregfield is001withR4as its fifth bit, so register10001= 17; the destinationrmfield is000withB4as its fifth bit, so10000= 16. That ismov r16, r17, and it matches the assembler's output. APX also adds three-operand forms of many instructions andpush2/pop2. As I write this (October 2026), the specification and toolchain support exist; I could not confirm from my research which shipping chips support it, so check your CPU's feature flags before using it. (The fifth register bit is the whole point: 16 registers needed 4 bits, 32 need 5.)
Putting numbers on it. I measured the length of every instruction in the x86-64 C library on this machine (385,673 instructions):
| Length | Share |
|---|---|
| 1 byte | 4.4% |
| 2 bytes | 16.7% |
| 3 bytes | 22.5% |
| 4 bytes | 18.6% |
| 5 bytes | 13.3% |
| 6 bytes | 9.8% |
| 7 bytes | 10.8% |
| 8 to 13 bytes | 3.8% in total |
The average is 4.16 bytes, the longest I found was 13, and the format's hard limit is 15. Most instructions are 2 to 7 bytes. The long ones are almost always an instruction with a 32-bit displacement or immediate, a SIB byte and a prefix all at once.
Build it (the "how")
Let's see what a wrong start does. Here are the bytes of our hello program from Chapter 4, correctly decoded:
401000: 48 c7 c0 01 00 00 00 mov rax, 0x1
401007: 48 c7 c7 01 00 00 00 mov rdi, 0x1
40100e: 48 8d 35 eb 0f 00 00 lea rsi, [rip+0xfeb]
401015: 48 c7 c2 0f 00 00 00 mov rdx, 0xf
40101c: 0f 05 syscall Now suppose a disassembler is told to start in the middle of an instruction. I asked objdump to start at three different addresses. Here is what it printed:
start at 0x401002 (inside the first instruction):
401002: c0 01 00 rol BYTE PTR [rcx], 0x0 <-- nonsense
401005: 00 00 add BYTE PTR [rax], al <-- nonsense
401007: 48 c7 c7 ... mov rdi, 0x1 <-- back in step
start at 0x40100f (inside the lea):
40100f: 8d 35 eb 0f 00 00 lea esi, [rip+0xfeb] <-- the REX.W is gone: a 32-bit lea
start at 0x401010:
401010: 35 eb 0f 00 00 xor eax, 0xfeb <-- an instruction we never wrote Each of those is a perfectly valid decoding. A disassembler cannot tell it is wrong, because x86-64 bytes carry no marker saying "an instruction starts here." The result looks like code. It even drifts back into step after a few instructions (a trait called self-synchronization), which is why the damage often stays small. The second case is the most dangerous: dropping the 48 turned a 64-bit lea into a 32-bit one, and the output still looks right at a glance.
Two lessons. When a disassembly looks strange, check that it began at a real instruction boundary (a function symbol, or a known entry point). And this ambiguity is also a security issue: the same bytes can be read as different code depending on where you jump in, which is a trick attackers use to build "gadgets," and why the CET endbr64 marker (Chapter 17) was added.
Why it is this way (the "why")
Why is x86-64 so irregular? Because it grew by extension, not design. The 8086 (1978) had a clean 16-bit encoding. The 386 (1985) added 32-bit operands by reusing the 66 prefix as a toggle. AMD64 (2003) added 64-bit operands and 8 more registers by taking the one-byte increment/decrement opcodes (40-4f) and turning them into the REX prefix. Every step reused spare codes so that old programs would keep running. The price is a decoder that must look at several bytes to know where an instruction ends. This is the cost of the 40-year compatibility we met in Chapter 3.
Why are there so many tiny shortcut forms? Because in 1978 memory was expensive, and the designers gave the most common operations (add to the accumulator, push a register) the shortest encodings. They still exist. Modern compilers know which are shorter.
Why do the newest prefixes (VEX, EVEX, REX2) look so different? They were each designed on a clean sheet inside the escape space that the old format left. VEX reclaimed two old, rarely used opcodes. EVEX reclaimed another. REX2 reclaimed another (d5). Each new generation takes a corner of the old format that had gone unused in 64-bit mode.
What would make this a bad design? If decoding were the bottleneck. In fact it can be. A chip that reads x86-64 bytes must find instruction boundaries before it can start many at once. Modern x86-64 cores keep a cache of already-decoded instructions (the micro-op cache, Chapter 26) to avoid doing it twice. ARM64's fixed width makes this a non-problem. That is the real trade-off between the machines, and it is not about code size: our measurement showed the average sizes are nearly equal (4.16 versus 4.00 bytes).
Common mistakes
- Decoding
89and8bas the same direction.89movesregtorm;8bmovesrmtoreg. - Forgetting REX.W and getting a 32-bit instruction. That also silently zeroes the top half of the destination (Chapter 6).
- Starting a disassembly at the wrong address. Always start at a symbol.
- Forgetting the
[rsp]SIB and[rbp]displacement special cases when hand-assembling or in a JIT. - Assuming the same prefix byte means the same thing everywhere.
f3and66change meaning with context.
Say it simply
An x86-64 instruction is up to six kinds of byte: prefixes, an optional REX byte that supplies the 64-bit and extra-register bits, an opcode, a ModRM byte that names the operands, an optional SIB byte for the address recipe, and a displacement and immediate. They vary from 1 to 15 bytes, and on average about 4. Because nothing marks an instruction's start, a disassembler that begins in the wrong place produces plausible nonsense.
Check yourself
Exercise 24.1 (Warm-up): Decode by hand. Decode each of these byte strings into an instruction, then check with the assembler: 49 89 d8, 66 89 d8, 48 8b 44 8b 10. A correct result: mov r8, rbx; mov ax, bx; mov rax, [rbx+rcx*4+0x10]. For the third, name the mod, reg, rm, scale, index and base fields.
Exercise 24.2 (Warm-up): Explain. In five sentences, explain what REX.W does and why mov eax, ebx has no 48 in front. Use an analogy and say where it breaks. What a strong answer includes: REX.W selects 64-bit operand size; without it the default is 32 bits; REX was added later to extend the old format; analogy: a "bold" flag that changes the size of a word on a page; where it breaks: the flag also changes how much of the destination is written, and 32-bit writes zero the top half.
Exercise 24.3 (Core): Predict, then check. Predict the bytes of mov rax, [rbp], mov rax, [r13], mov rax, [rsp], and mov rax, [r12]. Which two need a SIB byte? Which two need a displacement byte? Check with the assembler. A correct result: [rsp] and [r12] need SIB (04 24); [rbp] and [r13] need a displacement of 0 (45 00).
Exercise 24.4 (Core): Measure. Measure the average instruction length in a binary of your own. Use objdump -d --insn-width=15 and count the hex bytes per line (join continuation lines if there are any). Compare to our 4.16. A correct result: a number between about 3.5 and 5, depending on what the binary is. A vector-heavy library will be higher, because VEX and EVEX prefixes add bytes.
Exercise 24.5 (Core): Break it. Take a function from a small binary of yours and disassemble it starting 1, 2 and 3 bytes late. For each, find the instruction where the decoding comes back into step with the real one. A correct result: a short list of misdecoded lines for each offset, and the address where they resynchronize. Say what could go wrong if a patch tool trusted a misdecoded line.
Exercise 24.6 (Stretch): Design and defend. Propose a change to x86-64 that makes instruction boundaries easy to find without breaking old programs. What would you give up? What a strong answer includes: a marker or alignment rule, such as requiring an indirect-branch target to begin with endbr64 (CET already does this); a cost in code size or a restriction on compilers; and an honest note on why a full fix would break existing code.
Chapter 25. ARM64 encoding: 32 bits, fields and fit
In this chapter: You will learn how every ARM64 instruction is laid out in exactly 32 bits, and decode instructions by hand. You will meet the rules that limit constants and distances (and now know where the error messages come from). You will build a tiny disassembler in Python and check it against objdump.
You need first: Chapters 7, 24.
The problem first
On ARM64 every instruction is exactly 32 bits. That is a promise, and a hard constraint. The instruction must fit an operation, up to three register names, and often a constant, all into 32 bits. Where do the bits go? The answer explains many things you have already met: why a 64-bit constant needs four instructions, why some constants are rejected, why adrp exists, and why a branch has a reach.
The model (the "what")
Because every instruction is the same size, the instruction at address a is always the 4 bytes at a, and the next is at a + 4. A decoder never has to guess a boundary. (This also fixes the problem from Chapter 24: you cannot start a disassembly "in the middle" of an ARM64 instruction if you start at a 4-byte boundary.)
Each instruction is a 32-bit word, written as a number. The CPU is little-endian, so the bytes in memory appear reversed. Let's take a real instruction and decode it completely. This is what the assembler produced for add x11, x0, x2:
word: 0x8b02000b
memory: 0b 00 02 8b (little-endian: the lowest byte comes first)
binary: 1000 1011 0000 0010 0000 0000 0000 1011 Split it into fields, from the top bit down. I produced this table by slicing the real word with a script:
| Bits | Field | Value | Meaning |
|---|---|---|---|
| 31 | sf | 1 | 64-bit operation (x registers) |
| 30 | op | 0 | add (1 would be subtract) |
| 29 | S | 0 | do not set flags (1 would make it adds) |
| 28 to 24 | fixed | 01011 | "add/subtract, shifted register" |
| 23 to 22 | shift | 00 | no shift on the second register |
| 21 | fixed | 0 | |
| 20 to 16 | Rm | 2 | second source: x2 |
| 15 to 10 | imm6 | 0 | the shift amount |
| 9 to 5 | Rn | 0 | first source: x0 |
| 4 to 0 | Rd | 11 | destination: x11 |
Notice that each register number takes 5 bits, which is 0 to 31. That is where "31 registers plus a special number 31" comes from (Chapter 2). The number 31 means the zero register xzr in most instructions and the stack pointer sp in a few.
Now sub x1, x2, x3, which the assembler gave as 0xcb030041. Compare it with the add above: only the op bit (bit 30) changed. 0x8b... became 0xcb.... That one bit is the difference between add and subtract.
The top-level map. How does the CPU know which layout a word uses? The top-level class lives in bits 28 to 25. This is the real table, and I checked it by classifying real instructions:
| Bits 28 to 25 | Class | Examples I checked |
|---|---|---|
100x | Data processing, immediate | mov x0, #1 (0xd2800020), movk |
101x | Branches, exceptions, system | b, bl, ret, svc, b.ne, cbz |
x1x0 | Loads and stores | ldr x0, [x1, #16] (0xf9400820) |
x101 | Data processing, register | add x11, x0, x2 (0x8b02000b) |
x111 | Scalar floating point and SIMD | fadd d0, d1, d2 (0x1e622820) |
The x means "either." (0000 and a few others are reserved or used by newer extensions such as SVE.) So the CPU looks at four bits, picks a class, and then reads the fields that class defines.
Constants and why they have limits. Let's decode mov x0, #1, which is 0xd2800020:
sf = 1 opc = 10 fixed = 100101 hw = 00 imm16 = 1 Rd = 0 opc = 10 means movz ("move wide with zero"). The constant gets 16 bits (imm16), and hw says which 16-bit slot of the 64-bit register it goes into (0, 16, 32 or 48). movk ("keep") is the same layout with a different opc, and it overwrites one slot and keeps the rest. So 16 bits plus a slot number is all a move-immediate can hold. That is why a 64-bit constant needs up to four instructions. It is not a quirk: it is arithmetic. A 32-bit word cannot contain a 64-bit constant plus the rest of the instruction.
Bitmask immediates. Logical instructions (and, orr, eor) have only 13 bits for a constant, but they use them in a clever way: they hold a pattern, not a number. The 13 bits describe an element size (2, 4, 8, 16, 32 or 64 bits), a run of ones inside it, and a rotation, and the element is then repeated to fill 64 bits. So the constant 0x00ff00ff00ff00ff is "an 8-bit run of ones in a 16-bit element, repeated." That fits. A random number does not.
How many 64-bit values can be written this way? I enumerated every combination of element size, run length and rotation:
distinct encodable bitmask immediates: 5,334 (out of 2^64 possible values) Only 5,334 values. A few you can test for yourself:
| Value | Encodable? |
|---|---|
0x00000000000000ff | yes |
0x00ff00ff00ff00ff | yes |
0xf0f0f0f0f0f0f0f0 | yes |
0xffff0000ffff0000 | yes |
0x7fffffffffffffff | yes |
0x0123456789abcdef | no |
0x00000000f0f0f0f1 | no |
0x1234 | no |
0 and 0xffffffffffffffff | no (all-zeros and all-ones are not representable) |
I asked the real assembler about six of these and it agreed with my enumeration every time. The "immediate out of range" error from Chapter 6 is this: the constant is not one of the 5,334. The fix is to load it into a register with movz/movk and use the register form of the instruction. Compilers do this without telling you.
Distances. Branches and address-building instructions store a signed distance, counted in instructions (4 bytes), not bytes. That is how a small field reaches far. Using the field sizes:
| Instruction | Distance field | Reach |
|---|---|---|
b, bl | 26 bits | about ±128 MiB |
b.cond, cbz, cbnz | 19 bits | about ±1 MiB |
tbz, tbnz | 14 bits | about ±32 KiB |
adr | 21 bits (in bytes) | about ±1 MiB |
adrp | 21 bits (in 4 KiB pages) | about ±4 GiB |
You can see the same limit in the real program above. In b _start at 0x400094 the word is 0x17fffff9: the low 26 bits are 0x3fffff9, which is -7 as a signed number. The target is 0x400094 + (-7 × 4) = 0x400078, the start of the program. And b.ne at 0x4000a4 is 0x54fffea1: the 19-bit distance is -11 instructions, which is -44 bytes, and 0x4000a4 - 44 = 0x400078. I decoded both by hand and they matched objdump.
The condition field. For b.cond, the condition is the low 4 bits. b.ne ends in 1 (0x...a1: the condition is 0001). The conditions are numbered: eq 0, ne 1, hs 2, lo 3, mi 4, pl 5, vs 6, vc 7, hi 8, ls 9, ge 10, lt 11, gt 12, le 13, al 14. The conditional-select instructions of Chapter 12 use the same four bits in a different position.
Build it (the "how")
The best way to understand a format is to write a decoder. Here is a small one, in Python, that handles the instruction forms we have met: add/subtract on registers, movz/movk, ldr/str with an offset, b/bl, ret and svc. Run it on real instruction words.
def reg(n, sf=1, sp=False):
if n == 31: return "sp" if sp else ("xzr" if sf else "wzr")
return ("x" if sf else "w") + str(n)
def decode(w, pc):
sf = w >> 31
if (w & 0x1F200000) == 0x0B000000 and not (w >> 29) & 1: # add/sub, shifted register
op = "sub" if (w >> 30) & 1 else "add"
rd, rn, rm, imm6 = w & 31, (w >> 5) & 31, (w >> 16) & 31, (w >> 10) & 63
return f"{op} {reg(rd,sf)}, {reg(rn,sf)}, {reg(rm,sf)}" + (f", lsl #{imm6}" if imm6 else "")
if (w & 0x1F800000) == 0x12800000: # movn / movz / movk
opc, hw, imm16, rd = (w >> 29) & 3, (w >> 21) & 3, (w >> 5) & 0xFFFF, w & 31
name = {0: "movn", 2: "movz", 3: "movk"}[opc]
return f"{name} {reg(rd,sf)}, #{imm16:#x}" + (f", lsl #{16*hw}" if hw else "")
if (w & 0xFFC00000) in (0xF9400000, 0xF9000000): # ldr / str x, unsigned offset
ld, rt, rn, imm12 = (w >> 22) & 1, w & 31, (w >> 5) & 31, (w >> 10) & 0xFFF
return f"{'ldr' if ld else 'str'} {reg(rt)}, [{reg(rn,1,True)}" + (f", #{imm12*8}" if imm12 else "") + "]"
if (w & 0x7C000000) == 0x14000000: # b / bl
imm26 = w & 0x3FFFFFF
if imm26 >> 25: imm26 -= 1 << 26 # sign-extend
return f"{'bl' if w >> 31 else 'b'} {pc + imm26*4:#x}"
if w == 0xD65F03C0: return "ret"
if (w & 0xFFE0001F) == 0xD4000001: return f"svc #{(w >> 5) & 0xFFFF:#x}"
return "(not handled)" I ran it over 15 real instruction words (from a program I assembled with the GNU assembler) and compared with objdump. It matched on every instruction it handles: add, sub, movz, movk, ldr, str, b, bl, ret and svc. The first time I ran it, it failed on sub. My mask for "add or subtract" required bit 30 to be zero, which is the add value, so every sub slipped through. I fixed the mask to ignore that bit. (One cosmetic difference is not an error: objdump prints mov x0, #0x1 where my decoder prints the underlying movz. mov is an alias.) That is a good illustration of why you test a decoder against a real tool. Notice each if tests a handful of fixed bits and then reads the variable fields by shifting and masking: that is all a decoder is.
The instructions it does not handle (b.ne, adrp, cbz and fadd) are left as exercises. Each has the same shape: a few fixed bits that identify it, and fields to slice out.
Why it is this way (the "why")
Why 32 bits and not more or less? It is a balance. Fewer bits would mean fewer registers and smaller constants. More would make code larger and use more instruction cache. 32 bits was enough for three register operands (15 bits) plus a useful opcode and immediate. Fixed width is a choice that gives up flexibility to make decoding trivial: a core can look at the next 8 or more instructions at once, because it knows exactly where each begins.
Why does every limit on constants and reach come from the same place? Because a field has a fixed number of bits. 16 bits for a movz constant, 12 for an add immediate, 13 for a bitmask, 19 or 26 for a branch distance. Each limit is forced by the arithmetic of fitting into 32. The ARM64 designers then picked the cheap tricks that cover most real constants: a shifted 12-bit add immediate (reaching 16 MiB), the bitmask patterns (covering masks and repeated patterns), and the move-wide ladder for everything else. Reading ARM64 is easier once you ask of each instruction: "which field is doing the work, and how many bits does it have?"
Why count distances in instructions? Because instructions are 4 bytes, the low 2 bits of every target are zero, so there is no reason to store them. That gives a branch two more bits of reach for free. Fixed width lets you compress the information.
What would make this design wrong? If real programs needed many large constants, the four-instruction ladder and the constant pools would cost more than a variable-length scheme. The measurement in Chapter 3 says they do not: the average x86-64 instruction is 4.16 bytes, so the "denser" format did not turn out smaller.
Common mistakes
- Reading the word as big-endian. The bytes in memory are reversed.
- Counting branch distance in bytes. It is in instructions.
- Assuming any constant can be an
andimmediate. Only the 5,334 bitmask patterns can. - Forgetting register 31 means
xzrin one place andspin another. The instruction decides. - Hand-patching a branch to a target beyond its reach. You need a veneer or a different instruction (Chapter 32).
Say it simply
Every ARM64 instruction is one 32-bit word. A few bits choose the instruction class and operation, and the rest are fields: 5-bit register numbers, and a constant or a distance of a fixed size. Because the sizes are fixed, the limits (16-bit move immediates, 5,334 logical-immediate patterns, a 1 MiB reach for a conditional branch) are forced by the arithmetic of fitting into 32 bits. A decoder just tests a few fixed bits and slices out the fields.
Check yourself
Exercise 25.1 (Warm-up): Decode by hand. Decode 0xd2800020, then say what changes to make it load 2 instead of 1, and 1 into x5 instead of x0. A correct result: movz x0, #1 (sf = 1, opc = 10, hw = 0, imm16 = 1, Rd = 0). For 2, set imm16 = 2 (0xd2800040). For x5, set Rd = 5 (0xd2800025).
Exercise 25.2 (Warm-up): Explain. In five sentences, explain why ARM64 needs up to four instructions to load a 64-bit constant. Use an analogy and say where it breaks. What a strong answer includes: each instruction is 32 bits and a move-immediate carries only 16 bits of constant; 4 slots of 16 bits fill 64; analogy: carrying a long number across a narrow bridge in four trips; where it breaks: some constants take fewer instructions (all-ones patterns, bitmask patterns, or a constant pool load).
Exercise 25.3 (Core): Predict, then check. Which of these can be the immediate of an and: 0xFFFF0000FFFF0000, 0x0000000100000001, 0x7fffffffffffffff, 0x0123456789abcdef, 0x00000000ffffff00? Predict, then ask the assembler. A correct result: all except 0x0123456789abcdef. For each yes, name the element size and the run of ones (for example 0x0000000100000001 is a single 1 bit in a 32-bit element, repeated twice).
Exercise 25.4 (Core): Implement. Extend the Python decoder to handle b.cond (use the condition table), cbz/cbnz, and adrp. Test against objdump on the sample program and on a real function from a library. Done when: it matches objdump on every instruction in a 200-instruction function, and for each instruction it cannot decode, it says so rather than guessing.
Exercise 25.5 (Core): Break it. Hand-encode a b whose target is 200 MiB away. Why can't you? What does the linker do about it? A correct result: the 26-bit field reaches only about 128 MiB; the linker inserts a veneer (a small stub that loads the address and uses br), or the assembler reports out of range. (Chapter 32 shows a veneer.)
Exercise 25.6 (Stretch): Design and defend. Suppose ARM added a second, 64-bit instruction size for instructions with big constants, while keeping 32-bit as the default. Name two benefits and two costs. What a strong answer includes: benefit: a 64-bit constant in one instruction, and a wider branch reach; cost: instruction boundaries are no longer trivial (the property that makes decoding easy), and code that is not aligned to 8 bytes may straddle a cache line; plus a view on whether the trade is worth it, noting what happened to other fixed-width designs that added a compressed form.
Answer notes for Part 7
- 24.1:
48 8b 44 8b 10: ModRM44=01 000 100(mod 01: 8-bit displacement; reg 000 =rax; rm 100 = SIB follows). SIB8b=10 001 011(scale ×4, indexrcx, baserbx). Displacement10= 16. - 24.3:
mov rax, [rsp]is48 8b 04 24;mov rax, [r12]is49 8b 04 24(the49has REX.B);mov rax, [rbp]is48 8b 45 00;mov rax, [r13]is49 8b 45 00. - 25.1: For
x5the low five bits become00101:0xd2800025. - 25.3:
0x0000000100000001: element size 32, one set bit, repeated.0x00000000ffffff00: element size 64, a run of 24 ones, rotated.
Part 7 wrap-up
What you can now do:
- Name the parts of an x86-64 instruction and decode one by hand.
- Explain REX, ModRM and SIB, and the two special cases for
[rsp]and[rbp]. - Say what VEX, EVEX and REX2 add.
- Explain why a disassembler can mislead if it starts at the wrong address.
- Slice an ARM64 word into fields and decode it by hand.
- Explain the limits on ARM64 constants and distances from the field sizes.
- Write a small decoder and check it against
objdump.
Mini project: the two-ISA mini-disassembler. Time: 8 to 12 hours.
Write a disassembler in any language for a subset of both ISAs: on ARM64, the forms in this chapter plus b.cond, cbz and adrp; on x86-64, mov (register and [base+index*scale+disp] memory forms), add, sub, cmp, push, pop, call, jmp, jcc, ret and syscall. Run it on the hello programs from Chapter 4 and on a small function from a real binary.
Done when: on every instruction in your subset, your output equals objdump's (up to spelling); on every instruction outside it, you print the raw bytes and a clear "unknown," never a guess; and you have a test file with at least 50 real instructions from each machine.
Write-up (half a page): Which instruction format was hardest to decode, and why?
Reading list for Part 7:
- Intel SDM Vol. 2, chapter 2, "Instruction Format." The official description of prefixes, REX, ModRM and SIB. The tables are the thing to study.
- Intel, "Advanced Performance Extensions (APX) Architecture Specification." The REX2 prefix and the new 32-register model.
- Arm Architecture Reference Manual, "A64 Instruction Set Encoding." The top-level encoding table and every instruction's field layout. The "Data Processing -- Immediate" section has the bitmask immediate decoding algorithm.
- Agner Fog, "Optimizing subroutines in assembly language," the chapter on instruction lengths. Practical rules for smaller code.
- Intel and AMD instruction reference sites such as
felixcloutier.com/x86anduops.info. Quick lookups of encodings and timing.
Teach it back. In five sentences, as if to a friend, explain why you can start disassembling ARM64 anywhere on a 4-byte boundary but not x86-64.
Part 8: How the CPU Really Runs Your Code
After this Part you can look at a loop and say what limits it: a dependency chain, an execution unit, the cache, or a branch. You will also know how to measure that claim without fooling yourself. This is the Part that turns "I can read assembly" into "I can predict its speed."
A note on the numbers in this Part
I need to tell you something about the measurements before you see them, because it is a lesson in itself.
I took all the numbers in this Part on a rented virtual machine. While I was working, the machine underneath changed. The CPU description it reported (the flags and the model number) was different at different times: at one point it listed AMX and AVX-512 FP16 features, and later it did not. A virtual machine can hide or rewrite what the real chip is, so I cannot tell you the exact CPU model for each number. I can tell you what I saw:
- Some numbers did not move at all between sessions. A dependent integer add took 0.37 ns every time. A random branch cost about 9 times a predictable one in every run (9.0 to 9.8).
- Other numbers moved a lot. A double-precision add took 0.69 ns in one session and 1.21 ns in another. A load from an 8 MiB working set took 37 ns in one session and 107 to 137 ns in another. A system call took 273 ns once and about 100 ns later.
So here is how to read every table in this Part. The shape is the lesson. The exact number is only an example from my machine. Where two sessions disagreed, I tell you both. All the tables that are meant to be compared with each other were taken back to back in one session, so comparisons inside a table are fair. Chapter 28 shows how to take measurements you can trust on your own machine.
Chapter 26. Out-of-order execution: latency, throughput and the dependency chain
In this chapter: You will learn what is really inside a modern CPU core: how it runs many instructions at once, and out of order, while keeping the promise from Chapter 2 that it all looks sequential. You will learn the two numbers that describe an instruction (latency and throughput), and the single idea that explains most loop speeds: the dependency chain.
You need first: Chapters 2, 12 and 19.
The problem first
In Chapter 2 we built a toy CPU with one loop: fetch, decode, execute, advance. On this machine, one add takes about 0.37 ns. But in Chapter 19 we saw something that does not fit the toy: eight independent adds finished in barely more time than one. A machine that did one instruction at a time could not do that. So what is inside?
The model (the "what")
A modern core is built on three ideas. All three exist to do the same thing: keep many execution units busy at once while the program, from the outside, still looks like it ran one instruction at a time.
Idea 1: a pipeline. Splitting each instruction into stages (fetch, decode, execute, write back) lets the core start the next instruction before the previous one has finished, like a car factory with stations. We met this in Chapter 12, where a wrong branch guess made the whole line empty.
Idea 2: many units at once ("superscalar"). The core has several execution units (several adders, a multiplier, load units, store units), and can start several instructions in the same cycle, as long as they use different units and do not depend on each other.
Idea 3: out of order. The core does not run instructions in the order you wrote them. It looks ahead in a window of waiting instructions, and runs any instruction whose inputs are ready. Then it retires them in the original order, so the program cannot tell. The promise holds, because the order of results is kept, even though the order of work is not.
Inside the core, each instruction is first broken into smaller steps called micro-ops (µops). A simple add rax, rbx is one µop. But an instruction with a memory operand is more than one. I asked the LLVM tool llvm-mca (Chapter 28) to analyze the dot-product loop from Chapter 21:
.loop:
movsd xmm1, [rdi + rax*8] # 1 load
mulsd xmm1, [rsi + rax*8] # a load AND a multiply: 2 micro-ops
addsd xmm0, xmm1
inc rax
cmp rax, rdx
jl .loop For 200 iterations, mca reported 1,200 instructions and 1,400 µops. Six instructions became seven µops, because mulsd with a memory operand splits into a load and a multiply. This is the thing I promised in Chapter 3: the number of instructions on the page is not the amount of work. An x86-64 instruction like add qword ptr [rdi], 1 is the same load, add and store as ARM64's three instructions. The core just does that splitting internally, so inside, the two machines look much more alike than their instruction sets do.
Two more tricks make this work.
Register renaming. Your program has 16 (or 31) register names. The core has far more physical registers, and it assigns a fresh one each time a name is written. So two instructions that happen to write the same name do not have to wait for each other. They are really writing different physical registers. This removes false dependencies. It cannot remove a true one.
A true dependency is when an instruction needs the result of an earlier one. No trick can start it before its input exists. A chain of these is the dependency chain, and it is the most important idea in this chapter.
Two numbers describe every instruction.
- Latency: how many cycles from when the inputs are ready until the result is ready. This is what matters along a dependency chain.
- Throughput: how many of the instruction the core can start per cycle, when they are all independent. This is what matters when there is plenty of independent work.
Let's measure both on this machine. I ran each operation in two ways. First, a chain, where each operation needs the result of the one before, so we measure latency. Second, eight independent chains in the same loop, where the core is free to overlap them, so we measure throughput. All of these came from one session, back to back:
| Operation | 1 chain (latency) | 8 chains, per op (throughput) | Speedup from independence |
|---|---|---|---|
add (integer) | 0.39 ns | 0.12 ns | 3.3× |
imul (integer) | 1.04 ns | 0.31 ns | 3.4× |
addsd (double add) | 1.21 ns | 0.17 ns | 7.3× |
div r64 | 4.55 ns | 3.04 ns | 1.5× |
Let's convert to cycles. A dependent add takes 1 cycle on every recent Intel core, so 0.37 ns is one cycle on this machine, which means it was running at roughly 2.7 GHz. Using that as the yardstick: an imul has a latency of about 3 cycles, a double addsd about 3 here, and a 64-bit div about 12. (The double add took only 0.69 ns, about 2 cycles, when my sandbox was on different hardware. Floating-point add latency really differs from chip to chip: look yours up.)
Read the last column. Integer add and multiply speed up about 3 times when independent. That is not 8 times, because there are not 8 units: the loop itself needs a branch and a counter, and an imul has only one multiplier. The double add gained the most (7.3 times), because on this chip an addsd has a long latency but can be started often. The divide gained least (1.5 times): the divider is mostly not pipelined, so even independent divides mostly queue up.
The dependency chain explains the dot product. Look at this loop again:
.loop:
movsd xmm1, [rdi + rax*8]
mulsd xmm1, [rsi + rax*8]
addsd xmm0, xmm1 # <-- reads AND writes xmm0: it depends on the previous trip's addsd
... The loads and multiplies do not depend on the previous trip, so the core runs them far ahead. But the addsd xmm0, xmm1 needs the previous xmm0. That is a loop-carried dependency: the loop cannot run faster than one addsd latency per element. Here is the fix: use four accumulators, so there are four independent chains.
movsd xmm4, [rdi + rax*8]
mulsd xmm4, [rsi + rax*8]
addsd xmm0, xmm4 # chain 0
movsd xmm5, [rdi + rax*8 + 8]
mulsd xmm5, [rsi + rax*8 + 8]
addsd xmm1, xmm5 # chain 1
movsd xmm6, [rdi + rax*8 + 16]
mulsd xmm6, [rsi + rax*8 + 16]
addsd xmm2, xmm6 # chain 2
movsd xmm7, [rdi + rax*8 + 24]
mulsd xmm7, [rsi + rax*8 + 24]
addsd xmm3, xmm7 # chain 3
add rax, 4
cmp rax, rdx
jl .loop
# after the loop: add the four partial sums together I ran both versions on 1,024 doubles that fit in the first cache, so memory is not the limit. Same instructions per element. Real results, in one session, run four times:
| Version | Time per element | Speedup |
|---|---|---|
| 1 accumulator | 1.18 ns (about 3.2 cycles) | |
| 4 accumulators | 0.31 ns (about 0.85 cycles) | 3.5× to 3.8× across four runs |
(On the earlier hardware the same experiment gave 0.67 ns and 0.28 ns, a 2.4× gain. Same shape, different size.) The compiler does not make this change for you for double sums, and for a good reason: adding in a different order changes the rounding, so the answer can differ in the last digits. A compiler is not allowed to do it unless you say you accept that (-ffast-math or -fassociative-math). You can, because you decide whether the last digits matter. Chapter 21 warned about this, and here it is in numbers.
False dependencies, and the compiler's pxor. Back in Chapter 21 I showed the compiler emitting pxor xmm0, xmm0 before cvtsi2sd xmm0, rdi, and said it "breaks a false dependency." Now we can measure it. The instruction cvtsi2sd writes only the low part of xmm0 and keeps the upper bits. So the core treats it as a read-modify-write of xmm0, which makes it depend on whatever last wrote xmm0, even though you do not care about the old value. I timed a loop of cvtsi2sd then sqrtsd on the same register:
| Loop | Time per trip |
|---|---|
without pxor first | 6.80 ns |
with pxor xmm0, xmm0 first | 1.83 ns |
Three and a half times faster, from one extra instruction that does no real work. xor eax, eax and pxor xmm0, xmm0 are special: the core knows the result is zero regardless of the old value, so it does not wait for it. These are called zeroing idioms. This is the true reason compilers write xor eax, eax instead of mov eax, 0. (Chapter 6 said "shorter and does not wait for the old value." Now you have a measured case.)
Store-to-load forwarding. Spilled values go through memory. If you store a value and load it back a moment later, the core can pass the stored data straight to the load without waiting for it to reach the cache. That is store forwarding, and it has a latency. I measured a chain where each trip stores a value and loads it back, in two forms:
| Loop | Time per trip |
|---|---|
| value stays in a register | 0.31 ns |
| through memory, address depends on the value | 4.06 ns (13×) |
| through memory, same fixed address | 2.28 ns (7×) |
So a spill-and-reload on a dependency chain costs about 7 to 13 times a register. This is why a function with too many live values slows down: the spills lengthen the chains. (On the earlier hardware, the fixed-address case ran as fast as the register, 0.31 ns. That chip evidently shortcut it. It is a good example of a feature that exists on some generations only.)
The front end. Before any of this, the core must fetch and decode. On x86-64, decoding variable-length instructions (Chapter 24) is hard work, so modern cores keep a cache of already-decoded µops (the µop cache) and reuse it in loops. On ARM64, the fixed width makes decode simple. Also, as I mentioned in Chapter 11, many x86-64 cores fuse a compare and the conditional jump after it into a single µop. I did not measure these two effects here. They are described in the vendors' optimization manuals.
Build it (the "how")
Here is the reasoning method this chapter gives you. For any loop, ask three questions in order:
- What is the longest dependency chain per iteration? Add up the latencies of the instructions that each need the result of the one before, going around the loop. That is a floor on the time per iteration. In the one-accumulator dot product it is one
addsd, about 3 cycles. - How many µops does an iteration take, and which units do they need? Divide by how many the core can start per cycle, per unit. That is a second floor.
- Is the data in a fast cache? If not, memory is the limit (Chapter 27).
The loop runs at the largest of these floors. In the one-accumulator loop, the dependency chain (3 to 4 cycles) is bigger than the unit floor (about 1 cycle), so the chain is the limit, and the fix is to shorten or split the chain. In the four-accumulator loop, the chain is gone, and the unit floor (about 1 cycle per element) is the limit, so the fix is something else (wider vectors, Chapter 29).
This is the whole game, and you can now play it with just a table of latencies and a pencil.
Why it is this way (the "why")
Why out of order? Because programs are full of waits: loads that take many cycles, divides, cache misses. A strictly in-order core stops at the first wait. An out-of-order core keeps going with other work that does not depend on it. The cost is a lot of hardware (the window, the renaming, the retirement logic) and the power it burns. That cost is the reason a small "efficiency" core is in-order or only lightly out of order, and a "performance" core is wide and deeply out of order.
Why does the dependency chain matter more than instruction count? Because instruction count tells you the work, and the chain tells you the time when work can overlap. Two loops with the same number of instructions can differ by 4 times (our dot products) because one has a chain and the other has four.
What would make this model wrong? It says nothing about the cache or branches, which have their own floors. And the numbers (3 cycles for an add on one chip, 2 on another) are chip-specific. The method is stable; the table you feed it is not. That is why Chapter 28 says to look the numbers up for your chip, or measure them.
Common mistakes
- Counting instructions instead of finding the chain. Same count, very different speed.
- Assuming an instruction has one speed. It has a latency and a throughput, and they differ, and they differ per chip.
- Forgetting that a memory operand is extra µops.
- Reusing one accumulator in a floating-point loop and expecting the compiler to split it (it will not, without permission).
- Writing
mov eax, 0andcvtsi2sdwithout clearing the register first on a hot path. - Assuming a number from one chip holds on another.
Say it simply
A modern core runs many instructions at once and out of order, while keeping the results in order so the program cannot tell. Each instruction has a latency (how long until its result is ready) and a throughput (how many can start per cycle). A chain of instructions that each need the previous result is limited by latency, so splitting one chain into several independent ones can make a loop three or four times faster. The compiler's xor eax, eax and pxor exist to cut false dependencies.
Check yourself
Exercise 26.1 (Warm-up): Predict, then check. In a loop that does add rax, rbx four times in a row (all on rax), versus four adds on rax, rcx, rdx and rsi, predict which is faster and by about how much. Then measure with the method from this chapter. A correct result: the four independent adds are much faster (about 3 times on this machine, limited by the number of integer units and the loop overhead); the chain of four runs at four add latencies per trip.
Exercise 26.2 (Warm-up): Explain. In five sentences, explain latency versus throughput to a friend. Give an analogy and say where it breaks. What a strong answer includes: latency is the time for one result; throughput is how many per cycle; analogy: a car wash (one car takes 10 minutes, but a car comes out every 2 minutes when the line is full); where it breaks: a dependency forces a car to wait for the car in front, so the line cannot be full.
Exercise 26.3 (Core): Measure. Write the one-accumulator and four-accumulator dot products (the asm from this chapter, or in C with -O2 and no fast-math) and time them on 1,024 doubles. Report both and the ratio. Try 2 and 8 accumulators too. A correct result: a table with 1, 2, 4 and 8 accumulators. The gain should grow and then flatten. Say where it flattens and why (the unit limit).
Exercise 26.4 (Core): Break it. Remove the pxor from a loop that uses cvtsi2sd and measure the change. Then add xorps xmm0, xmm0 instead. Is it the same as pxor? A correct result: about 3 to 4 times slower without it on this machine; xorps also breaks the dependency. Report your own ratio.
Exercise 26.5 (Core): Predict, then check. Take the dot-product loop and change addsd xmm0, xmm1 to addsd xmm1, xmm0 and fix the rest to match (so the add result is no longer carried around the loop). Predict what happens to the speed. Run it. A correct result: it speeds up a lot, but the answer is wrong (you broke the sum). That shows the speed came from removing the chain, and the point of the chain is the meaning of the sum. Say why the compiler cannot do this for you.
Exercise 26.6 (Stretch): Design and defend. You have a loop with a 3-cycle dependency chain per element and 1 cycle of unit work per element. You can unroll 4 times with 4 accumulators, or use a vector register to do 4 elements at once. Which would you choose, and when does the other win? What a strong answer includes: both break the chain; the vector version also cuts unit work and instruction count; but needs the data layout and no masks or tails; unrolling works on any loop and for any data. A reasoned pick with the case where the other wins.
Chapter 27. The memory hierarchy: caches, parallel misses and what the prefetcher does
In this chapter: You will learn how a core reaches memory through levels of caches, what each level costs on a real machine, and why independent loads are twelve times cheaper than dependent ones. You will see the hardware prefetcher at work, and learn why software prefetching did not help in my test.
You need first: Chapters 2, 6 and 26.
The problem first
In Chapter 2 we said registers are fast and memory is slow, with a gap of about 100 times. But "memory" is not one thing. If every load went to the main memory chips, programs would crawl. Between the core and the chips sit several layers of smaller, faster storage called caches. How they work decides how fast most real programs are.
The model (the "what")
A cache holds copies of recently used memory, in fixed-size blocks called cache lines. On x86-64 and ARM64 servers a line is almost always 64 bytes. When the core asks for one byte, the whole 64-byte line comes in. The levels, from nearest the core to farthest:
core -- L1 cache -- L2 cache -- L3 cache -- main memory (DRAM)
(small, fast) (large, shared) (huge, slow) On this guidebook's machine, the vendor tool lscpu reported a 32 KiB first-level data cache, a 1 MiB second-level cache, and a 33 MiB third-level cache (shared by many cores; a virtual machine may be allowed only a share of it).
What each level costs. I measured it with the pointer chase from Chapter 2: build a ring of pointers, one per cache line, in a random order, so every load depends on the one before and the hardware cannot guess ahead. Then walk the ring. The time per step is the latency of that level. In the session used for this table:
| Working set | Time per dependent load | Where it lives |
|---|---|---|
| 16 KiB | 1.65 ns | L1 |
| 32 KiB | 1.68 ns | L1 (just fits) |
| 256 KiB | 5.0 ns | L2 |
| 1 MiB | 14 ns | L2 (nearly full) |
| 8 MiB | 137 ns | past the useful cache in this session |
| 64 MiB | 142 ns | main memory |
| 256 MiB | 190 ns | main memory |
The L1 and L2 numbers hold from session to session. The L3 numbers do not. In an earlier session on different underlying hardware, the 4 to 16 MiB rows read a clear plateau of 35 to 50 ns (that is the third-level cache doing its job), and only above about 32 MiB did the time jump to about 125 ns. In this session the plateau is missing: after 2 MiB the time goes straight to main memory. The two machines have a different shared cache available to my virtual machine. This is a perfect example of why cloud measurements need care. The shape, though, is always a staircase: each level is bigger and slower than the one before, and the steps appear when the working set grows past a level's size.
(I also tried asking for huge pages with madvise, which should cut address-translation cost. It made a small difference at some sizes, but I could not confirm that the kernel actually granted them, so I cannot say how much of the cost is translation. Address translation, done through the TLB, is a real cost on large working sets.)
Parallel misses. Here is the most useful fact in this chapter. The pointer chase measures one miss at a time, because each load must wait for the one before. But if the loads are independent, the core can have many misses in flight at once. That is called memory-level parallelism. I measured four access patterns over the same 256 MiB region, back to back:
| Pattern | Time per load |
|---|---|
| dependent (pointer chase) | 177 ns |
| independent random (addresses known in advance) | 14 ns |
| sequential (one load per 64-byte line, in order) | 5.6 ns |
| independent random + software prefetch 16 ahead | 16.7 ns |
The first two rows touch the same memory in the same random order, and one is 12 times faster. The only difference is that in the second, the core knows the next addresses ahead of time and can start many loads together. In the first, it cannot start the next load until the last one returns. This is why linked lists and tree walks are slow (each step depends on the last) and arrays are fast (the addresses can be computed ahead). It is also why a hash-table lookup in a loop over many keys can be made faster by batching: look up eight keys together instead of one at a time, and the misses overlap.
(In other runs of the same test, I saw 138 to 212 ns for the dependent row and 11 to 16 ns for the independent row. The ratio of about 10 to 15 stayed.)
The prefetcher. The third row, sequential access, is faster again (5.6 ns per line, which is about 11 GB/s). The core has a hardware prefetcher that notices a pattern of sequential (or evenly strided) addresses and starts loading the next lines before you ask. That is why a plain loop over an array runs near the speed of the memory system, with no help from you.
Software prefetch did not help here. The fourth row tries to help the random-independent case by issuing a prefetch instruction 16 elements ahead. It made no improvement (16.7 ns versus 14.0 ns; slightly worse because of the extra instructions). The reason: with independent loads, the out-of-order window already has many in flight, so the core is already overlapping the misses. A software prefetch helps in a different situation: when a load's address is known early but the load would not otherwise be started early, for example in a loop with a dependency chain, or when the next address needs a long calculation. Do not add prefetches by habit. Measure.
Cache lines are the unit. Two more facts follow from the 64-byte line.
- A load that straddles two lines touches both. In Chapter 6 I measured a loop of 8-byte loads: about 0.64 ns each when inside a line, and about 0.93 ns when straddling (about 1.5 times slower; an earlier session saw 0.43 versus 0.82). The cost is real but not huge.
- When two cores write to different variables that share a line, the line bounces between their caches. That is false sharing, and it can slow a multi-threaded program many times over. I could not demonstrate it: this sandbox had one core. The test is simple (two threads each incrementing their own counter, with the counters 8 bytes apart versus 128 bytes apart), and your own multi-core machine will show it plainly. The Rust performance guidebook in this series covers it in detail.
Build it (the "how")
Here is the pointer chase in C, so you can measure your own machine. It is the single most informative measurement in this Part.
// Build a random cycle through all cache lines, then walk it.
size_t n = bytes / 64; // one pointer per 64-byte line
// ... fill perm[] with 0..n-1 and shuffle it ...
for (size_t i = 0; i < n; i++)
*(void **)(buf + (size_t)perm[i] * 64) = buf + (size_t)perm[(i + 1) % n] * 64;
void *p = buf;
double t = now();
for (long i = 0; i < steps; i++) p = *(void **)p; // each load needs the last result
t = now() - t; // ns per step = t / steps Run it for working sets from 4 KiB to 1 GiB, doubling each time, and plot time against size. You will see your machine's staircase, and the sizes where the steps fall are your cache sizes. Do it twice, and notice whether the numbers hold.
Why it is this way (the "why")
Why caches at all? Because fast memory is small and big memory is slow, and programs tend to use the same data repeatedly (temporal locality) and nearby data together (spatial locality). A small fast cache wins on both. That a 32 KiB cache gives a 1.65 ns load, while memory gives 150 ns or more, is the whole reason a program with a working set that fits in cache can be 100 times faster.
Why 64-byte lines? Because fetching a bigger block costs little extra (memory delivers data in bursts) and sequential access is common. A bigger line wastes bandwidth on data you do not use; a smaller one wastes the burst.
Why does the latency matter more than bandwidth, in some programs? Because a chain of dependent loads (a list, a tree, an interpreter walking a data structure) pays the full latency each step and cannot overlap them. The memory system could deliver 11 GB/s here, and the chain used a few MB/s of it.
What would make this model fail? On many-core chips the caches are shared and contended; a neighbor's workload can evict your data (which is probably what I saw with the 8 MiB row). On a machine you share, measure under load.
Common mistakes
- Treating "memory" as one speed. It is a staircase.
- Linked data structures in hot loops. Every step is a dependent miss.
- Striding through memory in the wrong order (down columns of a row-major matrix).
- Adding software prefetches without measuring.
- Measuring with a working set that fits in cache and assuming it holds for large data (or the reverse).
- Ignoring false sharing in multi-threaded code.
Say it simply
Between the core and main memory sit caches in levels: a tiny very fast one, a bigger slower one, and a large shared one. On this machine a first-level hit took about 1.7 ns and a main-memory access 150 ns or more. If loads are independent, the core overlaps many misses at once (12 times faster than dependent ones in my test), and a hardware prefetcher handles plain sequential access for you. Software prefetching did not help in my test, because the core was already overlapping the loads.
Check yourself
Exercise 27.1 (Warm-up): Predict, then check. Before running the pointer chase on your machine, predict the sizes at which the steps will be. Look up your CPU's cache sizes (lscpu or sysctl hw). Then run it and compare. A correct result: steps at about your L1, L2 and L3 sizes; a plot or a table showing them.
Exercise 27.2 (Warm-up): Explain. In five sentences, explain why a linked list is slower to walk than an array of the same size. Use an analogy and say where it breaks. What a strong answer includes: each node's address comes from the previous node, so loads cannot overlap; array addresses are computable ahead and the prefetcher sees the pattern; analogy: following a treasure map where each clue is in the last box versus having the whole map; where it breaks: a linked list allocated sequentially in memory can be as fast as an array.
Exercise 27.3 (Core): Measure. Reproduce the four-row table (dependent, independent, sequential, independent plus prefetch) on your machine. Report the ratios. A correct result: a table with dependent much slower than independent, and sequential the fastest. Say whether software prefetch helped on your machine.
Exercise 27.4 (Core): Break it. Walk a 2D array of 4,096 by 4,096 ints row by row and then column by column, and time both. Predict the ratio first. A correct result: column order is many times slower, because each access lands on a different cache line (and a different page). Explain it with the line size.
Exercise 27.5 (Core): Predict, then check. Write a loop that sums every element of an array of 100 million ints, and another that sums every 16th element (one per cache line). Predict whether the second is 16 times faster. Check. A correct result: it is not 16 times faster for large arrays, because both loops move the same number of cache lines from memory. Say what this shows about what the memory system moves.
Exercise 27.6 (Stretch): Design and defend. You must look up 10 million keys in a large hash table. Design a version that uses what you learned about parallel misses. State what you would batch and how many at a time, and how you would decide the number. What a strong answer includes: compute all the hash addresses first, issue the loads together (or prefetch them), then process; the batch size is bounded by the number of outstanding misses the core allows (often 10 to 20); decide by measurement, sweeping the batch size.
Chapter 28. Measuring honestly: benchmarks, models and the tools
In this chapter: You will learn how to take a measurement you can trust, and three ways a benchmark fools you. You will learn to use llvm-mca to predict a loop, and to check the prediction against reality. You will also see what I could and could not measure in my sandbox.
You need first: Chapters 26 and 27.
The problem first
Everything in this Part is a claim about speed. A claim about speed is only worth believing if it comes from a good measurement. And measurement is full of traps. I fell into two of them while writing this Part, and both are worth showing.
The model (the "what")
Trap 1: the compiler removes your benchmark. A compiler's job is to make the program fast, and one way is to delete work whose result is never used. I wrote a loop that sums the squares of the numbers up to a billion, timed it, and did not use the result:
static long sumsq(long n) { long s = 0; for (long i = 0; i < n; i++) s += i * i; return s; }
// ...
double t = now(); long r = sumsq(1000000000L); (void)r; t = now() - t; gcc -O2 printed this:
time: 0.000000868 seconds for 1000000000 elements = 0.000001 ns per element A billion iterations in under a microsecond is not a fast CPU. It is a loop that no longer exists. The compiler saw that nothing used r, and removed the whole thing.
Trap 2: the compiler replaces your loop with a formula. Even when I did use the result (printing it), clang -O2 ran the same program in about a microsecond. The output shows why:
sumsq:
test rdi, rdi
jle .LBB0_1
lea rax, [rdi - 1]
lea rcx, [rdi - 2]
mul rcx
...
movabs rax, 6148914691236517206 # a magic constant: 2^64 / 3, roughly
imul rax, rdx
...
ret There is no loop. Clang recognized the sum of squares as a polynomial in n and computed the closed form with a few multiplies. The answer printed was identical (3338615082255021824) to what gcc computed by looping. (The gcc version of the same program, with the result used, did loop, and took 0.52 ns per element.) Both are correct. Neither is a benchmark of a loop.
The fix for both is to put an optimization barrier where the compiler cannot see through, so it must produce the value. In C, GCC and Clang let you write an empty asm that claims to read and write the variable:
for (long i = 0; i < N; i++) { s += i * i; asm volatile("" : "+r"(s)); } // "s changes here, in a way you cannot see" With the barrier, the loop ran in 0.39 ns per element. In Rust, the standard library gives you std::hint::black_box, which does the same job:
let r = std::hint::black_box(sumsq(std::hint::black_box(1_000_000_000))); Use one every time you benchmark a small piece of code.
Trap 3: the machine is not the machine you think. This is the one that bit me, and it is the subject of the note at the start of this Part. My first run of the pointer chase said an 8 MiB working set took 121 ns. I ran it again and got 37 ns, and I assumed the first was noise, and I wrote that down. Later I found the real reason: my virtual machine had been moved to different hardware. The first reading was correct for that hardware, and the second correct for the other. The honest sentence is not "it was noise." It is "I changed machines and did not notice."
Four habits keep you out of these traps:
- Repeat, and report a median and a range. I ran each copy benchmark five times and quoted the median with the minimum and maximum. When the range is wide, the number is not telling you much.
- Record the machine. Write down the CPU model and flags (
lscpu,/proc/cpuinfo) next to every result, and check them again at the end of the session. If they changed, you have two experiments. - Compare things inside one run. A ratio taken within one program run (branchy over branchless, one accumulator over four) is much more stable than an absolute number compared across days. The branch ratio was 9.0 to 9.8 times in every session; the absolute times moved a lot.
- Measure the empty loop. Find out what your timing code itself costs.
Calibrate your clock cheaply. You will not always know the frequency. A dependent chain of integer adds takes exactly 1 cycle each on any recent Intel core, so 1 divided by the time per add is your clock rate. On this machine, 0.37 ns per add gave roughly 2.7 GHz. This is how I converted nanoseconds to cycles throughout this Part.
Predict with a model: llvm-mca. llvm-mca takes a short piece of assembly and a CPU name, and simulates how the core would run it. It does not run your code. It uses the compiler's tables of latencies and ports. Feed it a loop and it says how many cycles per iteration, which unit is the bottleneck, and which dependency is critical. Here is the dot-product loop (one accumulator) through it, using an Ice Lake server model:
llvm-mca -mcpu=icelake-server -iterations=200 dot1.s Iterations: 200
Instructions: 1200
Total Cycles: 812
Total uOps: 1400
Block RThroughput: 1.2 812 cycles for 200 iterations is about 4.06 cycles per element. And its --bottleneck-analysis flag pointed at the right place: it blamed "Register Dependencies" for 20% of cycles and drew the critical sequence through the addsd that reads and writes xmm0, with the line "loop carried." That is exactly the chain we found by hand.
For the four-accumulator loop, it said 4.08 cycles per trip of 4 elements: about 1.02 cycles per element. So the model predicts a speedup of about 4 times. What I measured in the same session: 3.2 cycles per element for one accumulator and 0.85 for four, a 3.8 times speedup. The model is within about 20 to 25% on the absolute numbers, and predicted the shape (a 4× gain, and the reason) correctly.
But a model is only as good as its table. When I asked mca for other chips, the answers changed: a Sapphire Rapids model said about 3.1 cycles per trip for either loop, and a Zen 4 model said 3.1 for the first and 4.1 for the second. Run on a chip the model does not match, it can be off by a factor of two. So the rule is: use mca to understand why a loop is limited, then confirm the number by running it.
Other tools to know about:
uops.info(a website) and Agner Fog's instruction tables list the latency, throughput and ports of nearly every instruction on nearly every chip, measured. Use them to fill in your own table.- The vendors' optimization manuals (Intel, AMD, Arm's per-core guides, and Apple's published notes) describe each core's width, window size and rules.
perfreads the CPU's own hardware counters: instructions retired, cycles, branch misses, cache misses. It is the real answer to "is this loop limited by branches or by cache?" Here is the command you would run:
perf stat -e instructions,cycles,branches,branch-misses,cache-misses ./prog
perf record ./prog && perf annotate # which instructions are hot Not run here. I tried it in my sandbox, and the virtual machine did not expose the hardware counters: every line came back <not supported>. On a normal Linux machine or a cloud instance with counters enabled, this is the first tool to reach for. A rule that works: the ratio of instructions to cycles (IPC) tells you a lot at a glance. An IPC near the core's width (4 to 6) means the loop is running fast; an IPC near 1 means something is stalling, and the next counter tells you what.
Build it (the "how")
Here is a small harness, in C, that avoids the traps above. Copy it for your own microbenchmarks.
#include <stdio.h>
#include <time.h>
static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec * 1e-9; }
#define ITER 50000000L
// BODY must update `x` so the compiler cannot delete it. We take the best of 5.
#define BENCH(name, body) do { \
double best = 1e9; \
for (int r = 0; r < 5; r++) { \
unsigned long x = 1; double t = now(); \
for (long i = 0; i < ITER; i++) { body; asm volatile("" : "+r"(x)); } \
t = now() - t; if (t < best) best = t; \
} \
printf("%-24s %6.2f ns per trip\n", name, best / ITER * 1e9); \
} while (0)
int main(void) {
BENCH("empty loop", (void)0);
BENCH("dependent add", x += 3);
BENCH("dependent imul", x *= 3);
} It keeps the value alive with an empty asm, takes the best of five, and measures the empty loop so you can see the cost of the loop itself. Run it three times, and if the numbers move, say so.
On ARM64. I could not time ARM64 code here: qemu-user emulates the instructions but not the timing, so any number from it would be about the emulator. On a real ARM64 machine (Apple silicon, an AWS Graviton, a Raspberry Pi) the same harness works unchanged with the asm adjusted. Not run in this guidebook. The chip-specific ARM64 numbers you need are in Arm's software optimization guides (for example, the ones for Neoverse cores) and in published reverse-engineering notes for Apple's cores.
Why it is this way (the "why")
Why does measurement need so much care? Because a modern compiler plus a modern CPU plus a shared machine is three layers that all change what happens, and none of them is required to tell you. A benchmark that works is one where you have checked each layer: looked at the assembly to make sure the work is there, looked at the machine to make sure it is the one you think, and repeated it to make sure it is stable.
Why prefer a model and a measurement? The model explains and the measurement confirms. A model alone can be confidently wrong (our mca runs for the wrong chip). A measurement alone gives a number with no reason, so you cannot tell what to change. Together, they give you "the loop is limited by this chain, and a 4× gain is predicted, and a 3.8× gain appeared."
What would make you stop trusting this advice? If you only need a rough answer. A 20% error does not matter when the question is "is this loop 10 times slower than it should be?" Save the careful method for the cases where the answer changes a decision.
Common mistakes
- Not using the result of the code you time.
- Timing a loop the compiler turned into a formula.
- Timing a cold run (the first run pays for page faults and cache fill).
- Reporting one number. Report a median and a range.
- Not recording the machine.
- Comparing numbers from different days or different machines.
- Trusting a model for the wrong chip.
- Timing with the clock inside the loop so that the timer costs more than the work.
Say it simply
To trust a speed measurement, make sure the compiler cannot delete or shortcut the work (use a barrier or black_box), repeat it and report a median and range, record the machine, and compare things within one run. A tool like llvm-mca can explain why a loop is limited, but it models a particular chip, so confirm its prediction by running the code. A hardware-counter tool like perf is the real way to tell which kind of stall you have, when the machine allows it.
Check yourself
Exercise 28.1 (Warm-up): Predict, then check. Write a C loop that sums an array, never uses the sum, and time it at -O2. Predict the time per element. Then add asm volatile("" : "+r"(s)) and time again. A correct result: the first is essentially zero (the loop is gone); the second is a real time. Look at the assembly to confirm.
Exercise 28.2 (Warm-up): Explain. In five sentences, explain why a measurement of 0.000001 ns per element should make you suspicious. Give an analogy and say where it breaks. What a strong answer includes: no CPU does a billion iterations in a microsecond; the compiler removed or replaced the work; analogy: a runner who "finishes a marathon in one second" has taken a shortcut; where it breaks: the compiler's shortcut is legal and gives the right answer, so the program is not wrong, only the benchmark.
Exercise 28.3 (Core): Implement. Use the harness above. Add the operations you care about, and measure their latency (a dependent chain) and throughput (8 independent chains). Fill a table for at least six instructions on your machine, and compare with uops.info. Done when: your latencies match the published ones within about 20%, and you can explain any that differ.
Exercise 28.4 (Core): Model versus reality. Run llvm-mca on the dot-product loops for the CPU you actually have (-mcpu=native if your LLVM supports it, or the closest name). Compare its cycles-per-element with your measurement. Where do they disagree, and why? A correct result: a table with predicted and measured numbers. Disagreement of 20% is normal; more than 2× means the model does not match the chip.
Exercise 28.5 (Core): Break it. Take a benchmark of yours and run it 10 times. Report the minimum, median and maximum. Then run it while another heavy program is running. How much does it move? A correct result: a table showing the spread, and a sentence on how you would report this result honestly.
Exercise 28.6 (Stretch): Design and defend. Your team wants a CI test that fails when a function gets more than 10% slower. Machines in CI are shared and change. Propose a design that does not give false alarms. What a strong answer includes: compare ratios within one run (new versus old code, same machine, same job) rather than against a stored absolute number; repeat and use medians; count instructions or use deterministic counters where possible; record the machine; and a tolerance sized to the measured noise.
Answer notes for Part 8
- 26.1: A chain of four adds on
raxruns at 4 cycles per trip. Four independent adds run in about 1 to 1.5 cycles (the loop overhead and unit limits set the floor). On this machine the ratio was about 3. - 26.5: Moving the accumulation off the carried register removes the chain, so the loop speeds up, but the result is wrong because you are no longer summing. The compiler cannot split a floating-point sum into independent chains by itself, because it would change the rounding.
- 27.4: A 4,096 by 4,096
intarray has rows of 16 KiB. Column order touches a new line (and often a new page) on every access, so it runs many times slower than row order. In my run it was 15.8 times slower (0.154 s versus 0.010 s). - 27.5: Both loops move almost the same number of 64-byte lines for large arrays (every 16th
intis one per line), so the memory system's work is nearly the same, and the second loop is not 16 times faster. In my run, summing every 16th element was only 1.7 times faster (0.034 s versus 0.058 s).
Part 8 wrap-up
What you can now do:
- Describe pipelining, out-of-order execution and renaming in a few sentences.
- Say the difference between latency and throughput, and measure both.
- Find the longest dependency chain in a loop, and split it.
- Explain why compilers use
xor eax, eaxandpxor, with a measured case. - Give the cost of each cache level on your machine, and say what a parallel miss is.
- Spot the three ways a benchmark lies, and avoid them.
- Use
llvm-mcato explain a loop, and check it against a run.
Mini project: the instruction cost table. Time: 6 to 10 hours.
On your own x86-64 machine (and ARM64 if you have real hardware), build a table of the latency and throughput of at least 12 instructions: integer add, multiply, divide (32 and 64-bit), shift, lea, load (L1), store plus reload, floating-point add, multiply, divide, square root and convert. Add the cache latencies for each level from a pointer chase.
Done when: every number is a median of at least five runs with a range; you recorded the CPU model; you compared at least six rows with uops.info or the vendor manual and explained any gap over 20%; and you used the table to predict the speed of two loops you had not measured, and then measured them.
Write-up (half a page): Which prediction was furthest off, and what had you missed?
Reading list for Part 8:
- Agner Fog, "The microarchitecture of Intel, AMD and VIA CPUs," and "Instruction tables" (agner.org/optimize). The standard references for what is inside each core, with measured numbers.
- Abel and Reineke, "uops.info: Characterizing Latency, Throughput, and Port Usage of Instructions on Intel Microarchitectures" (ASPLOS 2019), and the
uops.infowebsite. - Ulrich Drepper, "What Every Programmer Should Know About Memory" (2007). Still the best long explanation of caches, lines, and prefetching.
- Intel 64 and IA-32 Architectures Optimization Reference Manual, and AMD's Software Optimization Guides, and Arm's Software Optimization Guides for Neoverse and Cortex cores.
- Dougall Johnson's published notes on the Apple M1 microarchitecture (dougallj.github.io/applecpu). A detailed, measured look at a core other than x86.
- The LLVM documentation for
llvm-mca. How to read the report and the bottleneck analysis. - Matt Godbolt's talk and writings on how CPUs execute instructions, and his Compiler Explorer, which has
llvm-mcabuilt in.
Teach it back. In five sentences, as if to a friend, explain why one running sum can be four times slower than four running sums, and what you would do to check that claim.
Part 9: SIMD from the Assembly Side
This Part is short on purpose. The SIMD guidebook in this series teaches vector programming in depth, with Rust intrinsics. Here you get the other half: what the vector instructions look like in assembly, how to read them, and how to write a loop by hand on x86-64 (SSE2, AVX2, AVX-512) and ARM64 (NEON and SVE).
Chapter 29. Vector instructions in assembly: lanes, masks and vector-length-agnostic code
In this chapter: You will learn how to read vector assembly on both machines, write the same loop four ways on x86-64 and two ways on ARM64, and measure what each width buys you. You will also see the one idea that is new on ARM64: code that does not know how wide the vectors are, and is still correct on every chip.
You need first: Chapters 11, 21 and 26.
The problem first
In Chapter 26 we found that a loop adding one number at a time is limited by how fast one adder can go. A second way out, besides splitting the chain, is to add many numbers with one instruction. A vector register is a wide register (128, 256 or 512 bits) that holds several numbers side by side. One instruction operates on all of them at once. This is called SIMD: single instruction, multiple data.
The model (the "what")
Each number inside a vector register is a lane. A 128-bit register holds four 32-bit lanes, or eight 16-bit lanes, or sixteen 8-bit lanes. A lane-wise instruction does the same operation on each lane independently: lane 0 with lane 0, lane 1 with lane 1, and so on.
v0 = [ a0 | a1 | a2 | a3 ] (four 32-bit lanes)
v1 = [ b0 | b1 | b2 | b3 ]
add v0, v0, v1 --> [ a0+b0 | a1+b1 | a2+b2 | a3+b3 ] one instruction, four adds The two machines have these vector registers:
| x86-64 | ARM64 | |
|---|---|---|
| 128-bit | xmm0-xmm15 (SSE, in the baseline) | v0-v31 (NEON, in the baseline) |
| 256-bit | ymm0-ymm15 (AVX, AVX2) | none |
| 512-bit | zmm0-zmm31 (AVX-512) | none |
| Width not fixed | none | z0-z31 (SVE: 128 to 2048 bits, chosen by the chip) |
| Per-lane masks | k0-k7 (AVX-512) | predicate registers p0-p15 (SVE) |
The ymm registers are the same registers as the xmm ones, just wider: xmm0 is the low half of ymm0, which is the low half of zmm0. And on ARM64, d0 and s0 (the floating-point views from Chapter 21) are the low part of v0.
Which of these a chip has is not the same everywhere. SSE2 and NEON are part of the baseline for 64-bit x86 and ARM64 on Linux, so any compiler can use them. AVX2 and AVX-512 are optional extensions: a program must ask the CPU (with cpuid, or /proc/cpuinfo, or on ARM64 getauxval) before using them. Which chips have AVX-512 has changed over the years, so never assume. SVE is also optional on ARM64, and chips that have it differ in how wide it is.
Reading the names. Vector instructions look unfamiliar until you learn the spelling.
On x86-64, an SSE name is often operation + p (packed) + element type: paddd is packed add on dwords (32-bit lanes). paddw is words (16-bit), paddb bytes, paddq quadwords. For floats, addps is packed single (four floats), addpd packed double (two doubles), and addss/addsd are the scalar ones from Chapter 21 (one lane only). A leading v means the newer VEX encoding (Chapter 24): vpaddd ymm0, ymm1, ymm2 adds in 256-bit width with a separate destination.
On ARM64, the element type is written on the register: v0.4s is "four 32-bit lanes" (s for single-word), v0.8h is eight 16-bit lanes (h for half), v0.16b is sixteen bytes, v0.2d two 64-bit lanes. So add v0.4s, v1.4s, v2.4s is the same operation as vpaddd at 128 bits.
Two things people often get wrong about ARM64. NEON does have an instruction that adds across lanes: addv adds all the lanes of a vector into one value, and I use it below. But NEON has no predication (no per-lane on/off); predicates belong to SVE (below). The real speedups are in the next section.
Build it (the "how")
Let's write one task, summing an array of 32-bit integers, in six ways, and check each one for correctness and speed. The task is simple on purpose. The point is the shape of a vector loop: a main loop that handles a full vector at a time, a reduction that collapses the lanes into one number, and a way to handle the leftover elements when n is not a multiple of the width.
The scalar loop is the baseline: one add per element.
# x86-64
sum_scalar: # rdi = pointer, rsi = n -> eax
xor eax, eax
test rsi, rsi
jz .done
.loop:
add eax, [rdi]
add rdi, 4
dec rsi
jnz .loop
.done:
ret SSE2, 128 bits (4 lanes). Four additions per paddd. After the loop, the four lanes hold four partial sums, and we add them together (the reduction). x86-64 has no single instruction for that in SSE2, so we shuffle and add twice.
sum_sse2:
pxor xmm0, xmm0 # four partial sums, all zero
mov rcx, rsi
shr rcx, 2 # number of 4-element blocks
jz .reduce
.loop:
movdqu xmm1, [rdi] # 16 bytes; the "u" means unaligned is fine
paddd xmm0, xmm1 # 4 lane-wise adds
add rdi, 16
dec rcx
jnz .loop
.reduce:
pshufd xmm1, xmm0, 0x4e # swap the two 64-bit halves
paddd xmm0, xmm1 # now lanes 0 and 1 hold (a+c) and (b+d)
pshufd xmm1, xmm0, 0xb1 # swap neighbouring 32-bit lanes
paddd xmm0, xmm1 # lane 0 holds a+b+c+d
movd eax, xmm0
and rsi, 3 # 0 to 3 elements are left over...
jz .done
.tail:
add eax, [rdi] # ...handled one at a time: the "scalar tail"
add rdi, 4
dec rsi
jnz .tail
.done:
ret AVX2, 256 bits (8 lanes). Same shape. A VEX-encoded instruction can take an unaligned memory operand directly, so one vpaddd loads and adds. After the loop we fold the upper 128 bits onto the lower, then reduce as before. Notice vzeroupper: it clears the upper halves of the ymm registers so that older SSE code that runs next does not pay a state-transition penalty.
sum_avx2:
vpxor ymm0, ymm0, ymm0
mov rcx, rsi
shr rcx, 3 # blocks of 8
jz .reduce
.loop:
vpaddd ymm0, ymm0, [rdi] # load 32 bytes and add, unaligned OK
add rdi, 32
dec rcx
jnz .loop
.reduce:
vextracti128 xmm1, ymm0, 1 # the upper 128 bits -> xmm1
vpaddd xmm0, xmm0, xmm1
vpshufd xmm1, xmm0, 0x4e
vpaddd xmm0, xmm0, xmm1
vpshufd xmm1, xmm0, 0xb1
vpaddd xmm0, xmm0, xmm1
vmovd eax, xmm0
vzeroupper
and rsi, 7
jz .done
.tail:
... # same scalar tail as before AVX-512, 512 bits (16 lanes), with a mask for the tail. Here is something genuinely new. AVX-512 has mask registers (k0 to k7), one bit per lane. A masked load reads only the lanes whose bit is 1, and does not touch memory for the others, so it cannot fault past the end of the array. That means the tail needs no scalar loop at all: build a mask with the low n bits set, and do one more masked step.
sum_avx512:
vpxord zmm0, zmm0, zmm0
.loop:
cmp rsi, 16
jb .tail
vpaddd zmm0, zmm0, [rdi] # 16 lanes at once
add rdi, 64
sub rsi, 16
jmp .loop
.tail:
test rsi, rsi
jz .reduce
mov ecx, esi
mov eax, 1
shl eax, cl
dec eax # a mask with the low rsi bits set
kmovw k1, eax # move it into mask register k1
vmovdqu32 zmm1{k1}{z}, [rdi] # masked load; the off lanes are zeroed and NOT read
vpaddd zmm0, zmm0, zmm1
.reduce:
vextracti64x4 ymm1, zmm0, 1 # fold 512 -> 256 -> 128 -> 32 bits
vpaddd ymm0, ymm0, ymm1
vextracti128 xmm1, ymm0, 1
vpaddd xmm0, xmm0, xmm1
... (the same two shuffle-and-add steps as before)
vmovd eax, xmm0
vzeroupper
ret NEON, ARM64 128 bits (4 lanes). The reduction is one instruction, addv:
// ARM64: sum_neon(x0 = pointer, x1 = n) -> w0
sum_neon:
movi v0.4s, #0
lsr x2, x1, #2 // number of 4-element blocks
cbz x2, 2f
1: ldr q1, [x0], #16 // load 16 bytes into a vector register, advance
add v0.4s, v0.4s, v1.4s // 4 lane-wise adds
subs x2, x2, #1
b.ne 1b
2: addv s0, v0.4s // ONE instruction adds all four lanes
fmov w3, s0
and x1, x1, #3 // 0 to 3 elements left over
cbz x1, 4f
3: ldr w4, [x0], #4 // scalar tail
add w3, w3, w4
subs x1, x1, #1
b.ne 3b
4: mov w0, w3
ret SVE, ARM64 with unknown width. This is the idea that has no x86-64 twin. SVE does not say how wide a vector is. The chip decides (anywhere from 128 to 2048 bits), and the same machine code runs correctly on all of them. The tool that makes this work is the predicate: a register of per-lane on/off bits. The instruction whilelt p0.s, x2, x1 sets lane k of p0 to "on" while x2 + k < x1, which covers both the full-vector case and the tail with no special code.
// ARM64 SVE: sum_sve(x0 = pointer, x1 = n) -> w0. There is NO tail loop.
sum_sve:
mov x2, #0 // i = 0
mov z0.s, #0 // running sums, one per lane
whilelt p0.s, x2, x1 // lane k is on while (i + k) < n
1: ld1w {z1.s}, p0/z, [x0, x2, lsl #2] // masked load; off lanes are not read
add z0.s, p0/m, z0.s, z1.s // masked add: off lanes keep their old value
incw x2 // i += the number of 32-bit lanes THIS chip has
whilelt p0.s, x2, x1 // recompute the predicate
b.first 1b // loop while lane 0 is still on
ptrue p0.s // all lanes on
uaddv d0, p0, z0.s // add across ALL lanes, whatever their count
fmov x0, d0
ret The key line is incw x2: it adds "however many 32-bit lanes fit in a vector on this chip" to the index. The code never mentions 4 or 8 or 16. I ran this one loop under the emulator qemu-user three times, pretending the hardware vector length was 128, 256 and 512 bits. The same binary gave the right answer every time, for every length from 0 to 300 at four different alignments:
sve (vector length 16 bytes): correct
sve (vector length 32 bytes): correct
sve (vector length 64 bytes): correct (An emulator proves the logic is right at each length. It cannot tell you the speed. Real SVE chips exist in servers such as AWS Graviton 3 and in some other designs. Not every ARM64 chip has SVE, and Apple's chips, as I understand it, do not have SVE in the same form, so check.)
Correctness first, then speed. I tested every x86-64 version against a plain C loop for every length from 0 to 300 at four different starting alignments: all four were correct. The ARM64 NEON version passed the same test, and the SVE version passed at each of the three lengths above.
The speed. Here are the x86-64 results on a 256 KiB array (so it stays in the second-level cache), median of five runs, in GB/s of data summed, all taken back to back in one session:
| Version | GB/s | Compared to scalar |
|---|---|---|
| scalar | 9.4 | 1.0× |
| SSE2 (4 lanes) | 37.8 | 4.0× |
| AVX2 (8 lanes) | 72.0 | 7.7× |
| AVX-512 (16 lanes, masked tail) | 45.2 | 4.8× |
SSE2 is 4 times the scalar speed and AVX2 is 7.7 times. That matches the lane counts (4 and 8) closely. The surprise is the last row: AVX-512 with twice the lanes of AVX2 was slower than AVX2 on this machine. I did not find out why. I can name plausible causes (the chip may run 512-bit instructions on fewer units, or at a lower clock, or the working set may be limited by cache bandwidth before the vector width matters), but I did not test any of them, so I will not claim one. The practical lesson is the one from the start of this Part: wider is not automatically faster. Measure on the chip you will run on. On another chip the order could be reversed.
A string function in SSE2. The same tools improve the string ladder from Chapter 22. Here is strlen using 16-byte compares. pcmpeqb compares sixteen bytes against zero and gives 0xFF in every matching byte. pmovmskb then packs the top bit of each byte into an ordinary integer, so a normal bsf can find the first zero.
# size_t sse_strlen(const char *s) rdi = s
sse_strlen:
mov rax, rdi
and rax, -16 # round down to a 16-byte boundary
pxor xmm0, xmm0 # zero in every byte
movdqa xmm1, [rax] # ALIGNED load of the block holding the start
pcmpeqb xmm1, xmm0 # 0xFF where a byte is zero
pmovmskb edx, xmm1 # 16 bits, one per byte
mov ecx, edi
and ecx, 15 # how far into the block the string starts
shr edx, cl # drop the bytes before the string
test edx, edx
jz .next
bsf eax, edx # the first zero byte: this is the length
ret
.next:
add rax, 16
movdqa xmm1, [rax]
pcmpeqb xmm1, xmm0
pmovmskb edx, xmm1
test edx, edx
jz .next
bsf edx, edx
add rax, rdx
sub rax, rdi # (block address + index) - start
ret Every load here is aligned. That is what makes it safe: an aligned 16-byte block can never cross a page boundary, so if any byte of the block is part of the string, the whole block is mapped. (This is the same reasoning as the alignment step in the SWAR strlen of Chapter 22.) I tested exactly that case: a string whose zero byte was the last byte of a mapped page, with the next page unmapped. It did not crash. I also checked it against strlen on 300,000 random strings at random alignments: no mismatches. Then I added it to the ladder from Chapter 22 (1 MiB string, median of five runs, same session):
strlen method | GB/s |
|---|---|
| byte loop | 1.9 |
| SWAR, 8 bytes | 11.9 |
| SSE2, 16 bytes | 35.3 |
libc strlen | 92.5 |
Each step up the ladder buys about 3 to 6 times. And even our SSE2 version is about 2.6 times slower than the library, which uses wider vectors, unrolling, and a version picked for the CPU. NEON has no pmovmskb; the usual replacement is a shift-narrow instruction (shrn) that squeezes the compare result into a 64-bit integer. I did not write it here. The SIMD guidebook covers it.
Why it is this way (the "why")
Why does SIMD work at all? Because the hardware cost of adding eight numbers side by side is only a little more than adding one: the same decode, the same issue slot, wider data paths. So a vector instruction does 8 or 16 times the work for roughly the cost of one scalar instruction. That was the speedup we measured (4× and 7.7×).
Why did x86-64 add width again and again? Each new width (SSE, AVX, AVX-512) needed a new encoding (Chapter 24: VEX, EVEX), new registers, and new instructions. Programs had to be rewritten or recompiled to use them. That is the price of a fixed-width design: every doubling is a new instruction set, and the code cannot use the new width without being changed.
Why did ARM design SVE as width-agnostic? To avoid that treadmill. If a loop is written with predicates and incw, a chip with 512-bit vectors runs it faster than one with 128-bit vectors, with no change to the program. The cost is a more abstract programming model, and some algorithms (those that need to know the exact lane count, such as a fixed 16-byte shuffle) are harder to express.
What would make you choose a narrower width? Heavy use of the wide units can lower the clock on some chips, the working set may not be big enough to feed them, and a mixed workload may pay a penalty for switching. So the safe plan is: write the baseline (SSE2 or NEON) first, check that the compiler's autovectorizer is not already doing it, and add wider versions only when a measurement says they pay.
Common mistakes
- Using an aligned load (
movdqa) on a pointer that is not 16-byte aligned. I tested it: it crashes withSIGSEGV. Usemovdqu, or align first. - Forgetting the tail. The last
n mod widthelements need handling: a scalar loop, a mask, or a predicate. - Assuming AVX2 or AVX-512 exists. Ask the CPU, and fall back to SSE2.
- Mixing VEX and legacy SSE without
vzeroupper. It can cost a penalty on some chips. - Reading past the end with a wide load. Align first, or use a masked load.
- Assuming wider is faster. AVX-512 lost to AVX2 in my run.
- Overflow in narrow lanes. A 16-bit lane wraps at 65,536. Widen first or use saturating adds.
Say it simply
A vector register holds several numbers side by side, and one instruction acts on all of them. On x86-64 the widths grew over time (128, 256, 512 bits), and the wider ones are optional, so a program has to check. On ARM64, NEON is always 128 bits, and SVE lets one program run correctly on chips with any vector width, using predicates instead of tail loops. In my tests, four lanes were 4 times faster than scalar and eight lanes 7.7 times, but 16 lanes were slower on that chip, so measure.
Check yourself
Exercise 29.1 (Warm-up): Predict, then check. Predict the speedup of the SSE2 and AVX2 sums over the scalar sum for an array that fits in cache, and for one of 256 MiB. Then measure both. A correct result: in cache, close to the lane count (about 4 and 8 times, or a bit less). For 256 MiB, the speedups mostly vanish because the limit is main memory (Chapter 27). Say which you saw.
Exercise 29.2 (Warm-up): Explain. In five sentences, explain why SVE's whilelt and AVX-512's masks remove the scalar tail loop. Give an analogy and say where it breaks. What a strong answer includes: a mask turns lanes on or off per step, and masked loads do not read the off lanes; so the last partial vector is just a vector with fewer lanes on; analogy: a muffin tray where you fill only the last few cups; where it breaks: the masked step still costs a full vector's time.
Exercise 29.3 (Core): Implement. Write max_i32(p, n) for an int32_t array in SSE4.1 (pmaxsd) and NEON (smax for the main loop and smaxv to reduce). Handle the tail with a scalar loop. Done when: both match a C reference for every n from 0 to 300 at four alignments, including arrays of all negative numbers (what do you start the maximum at?), and an array whose maximum is in the tail.
Exercise 29.4 (Core): Break it. Change movdqu in sum_sse2 to movdqa. Run it with a pointer that is 4 bytes past a 16-byte boundary. What happens? A correct result: it crashes with SIGSEGV, because movdqa requires a 16-byte aligned address. A direct test at an address that is 4 mod 16 gave status 139.
Exercise 29.5 (Core): Implement. Write count_byte(p, n, c) on x86-64 with pcmpeqb, pmovmskb and popcnt, with an aligned head and a scalar tail. Then write the NEON version using cmeq and addv (hint: the compare gives 0xFF or 0 per byte, so subtract the result from a running byte counter, and reduce every 255 steps before it overflows). Done when: both match a C reference on random data, and you measured both against a scalar loop.
Exercise 29.6 (Stretch): Design and defend. You ship one binary to a fleet with a mix of x86-64 chips: some with AVX-512, some with AVX2 only, some old with SSE2 only. Design how the binary picks a code path, and say what it costs. What a strong answer includes: runtime dispatch (check CPU features once at start-up and store a function pointer, or use the loader's ifunc mechanism); an SSE2 baseline; a measured policy for which wider path to prefer (since AVX-512 may not win); the cost is code size, testing every path, and an indirect call.
Answer notes for Part 9
- 29.1: In my cache-sized test the speedups were 4.0 times (SSE2) and 7.7 times (AVX2).
- 29.3: Start the maximum at the first element, or at
INT32_MIN. Starting at 0 gives the wrong answer for an all-negative array. - 29.5 (hint):
pcmpeqbgives0xFF(which is -1 as a signed byte) for a match, sopsubbof the compare result from a byte counter adds 1 per match. A byte counter overflows after 255 additions, so widen (withpsadbwon x86-64, oruaddlv/uadalpon NEON) before that.
Part 9 wrap-up
What you can now do:
- Name the vector registers on both machines and the width of each.
- Read a vector mnemonic (
paddd,vpaddd,add v0.4s). - Write a vector loop with a reduction and a tail on x86-64 and ARM64.
- Use AVX-512 masks and SVE predicates to remove the tail.
- Explain why SVE code is correct on any vector length.
- Say why wider is not always faster, and how to find out.
Mini project: three-width library. Time: 6 to 10 hours.
Write sum_i32, max_i32 and count_byte in scalar, SSE2 (or NEON), and AVX2 (or SVE) forms. Add a runtime dispatcher that chooses a path using the CPU's feature bits.
Done when: every version matches a reference for lengths 0 to 300 at four alignments; you have a speed table for an in-cache array and a 256 MiB array; the dispatcher picks the fastest path that is safe on the CPU it runs on; and you wrote one paragraph on whether the widest path won on your machine.
Write-up (half a page): Where did the speedup stop matching the lane count, and why?
Reading list for Part 9:
- The SIMD guidebook in this series. The Rust intrinsics view of everything in this chapter, in depth.
- Intel Intrinsics Guide (online). Every x86 vector instruction with its latency and throughput by chip.
- Arm Architecture Reference Manual Supplement for SVE and SVE2, and the Arm NEON Programmer's Guide. The official definitions of predicates and
whilelt. - Daniel Lemire and Geoff Langdale, "Parsing Gigabytes of JSON per Second" (VLDB Journal, 2019). A famous example of building a parser out of the instructions in this chapter.
- Daniel Lemire's blog, for measured comparisons of SSE, AVX2, AVX-512 and NEON on real tasks.
- Intel APX and AVX10 documentation. Where x86-64 vectors and registers are going next.
Teach it back. In five sentences, as if to a friend, explain what a lane is, why summing with a vector needs a reduction at the end, and what an SVE predicate does that x86-64's SSE2 cannot.
Part 10: Concurrency at the Instruction Level
After this Part you can say exactly which instruction makes a counter, a lock or a flag safe between threads, on both machines. You will also know the one big difference in memory ordering between x86-64 and ARM64, and how to write a message-passing handoff that is correct on both.
Chapter 30. Atomic read-modify-write: lock, load-exclusive and LSE
In this chapter: You will learn why counter += 1 is not safe between threads, and the instructions each machine provides to make it safe. You will write a counter and a spinlock on both machines, read what compilers emit for atomics in C and Rust, and measure what atomic instructions cost.
You need first: Chapters 8, 11 and 26.
The problem first
Two threads each run counter += 1. In assembly, that is three steps:
mov rax, [counter] # 1. load
add rax, 1 # 2. add
mov [counter], rax # 3. store Suppose the counter is 5. Thread A runs step 1 and reads 5. Before A does step 3, thread B runs all three steps: it reads 5, adds 1, and writes 6. Then A finishes: it adds 1 to its 5 and writes 6. The counter is 6, but two increments happened. One was lost. The three steps are not atomic: another thread can slip in between them.
To fix it, we need one operation that does the load, the add and the store as one indivisible step. Both machines have that, spelled differently.
An honest note about my testing. I could not make the lost update happen. I ran the three-instruction loop on two threads, 20 million times each, and got exactly 40 million, every time. The reason is that my sandbox had one core: the two threads took turns, and for one to be interrupted inside a three-instruction window is rare. On a machine with several cores, two threads genuinely run at once, and the loss shows up quickly. So the failing case is something you should reproduce yourself on a multi-core machine. The code is in this chapter. Do not take "it passed on my laptop with one thread running at a time" as proof.
The model (the "what")
x86-64: the lock prefix. Put lock in front of a read-modify-write instruction on memory, and the whole thing becomes atomic. The core takes ownership of the cache line, does the load and store with nobody else able to touch it, and lets go.
# x86-64
lock add qword ptr [rdi], 1 # atomically: [rdi] += 1
lock xadd [rdi], rax # atomically: swap-and-add. rax gets the OLD value
lock cmpxchg [rdi], rcx # compare-and-swap (see below)
xchg [rdi], rax # swap. WITH a memory operand it is ALWAYS locked: no prefix needed I measured the cost of these on this machine, with one thread (so there is no contention), as one instruction repeated in a loop:
| Instruction | Time |
|---|---|
lock add qword ptr [mem], 1 | 4.9 ns |
xchg [mem], reg (implicitly locked) | 5.3 ns |
load, then lock cmpxchg | 9.3 ns |
mfence | 10.0 ns |
A dependent register add on this machine takes 0.37 ns, so a locked add is about 13 times as expensive, even with nobody else touching the line. (A plain add [mem], 1 in my loop measured only 0.31 ns, which is faster than I can believe for a dependent memory operation. That is faster than the store-forwarding cost I measured in Chapter 26 on the same machine, so I do not trust that one number and do not use it.) The atomics are far from free. When another core is also using the line, the cost climbs a lot more, because the line must travel between caches.
Compare-and-swap (CAS) is the general tool. lock cmpxchg [mem], new compares rax with [mem]. If they are equal, it stores new and sets the zero flag. If not, it loads the current value into rax and clears the flag. With CAS you can build any atomic update by looping: read the old value, compute the new one, try to swap, and retry if someone else changed it first.
ARM64: load-exclusive and store-exclusive (LL/SC). The original ARM64 way has no single atomic-add instruction. Instead it has a pair: ldxr loads a value and marks the address as "mine" in a hidden exclusive monitor. stxr stores only if the monitor is still marked, which means no other core wrote there in between. It writes a status register: 0 for success, 1 for failure. You loop until it succeeds.
// ARM64: atomic increment with load-exclusive / store-exclusive
inc_llsc:
1: ldxr x2, [x0] // load, and mark the address as ours
add x2, x2, #1
stxr w3, x2, [x0] // store only if still ours; w3 = 0 on success, 1 on failure
cbnz w3, 1b // failed: another core got in: retry from the load This is called load-linked / store-conditional (LL/SC). I ran this on two threads under qemu-user, 300,000 increments each, and it gave exactly 600,000.
ARM64: LSE (Large System Extensions). Since ARMv8.1, ARM64 also has single-instruction atomics, like x86-64's lock add:
ldadd x4, xzr, [x0] // atomically: [x0] += x4 (the old value is discarded into xzr)
ldaddal x0, x0, [x1] // add, returning the old value, with acquire AND release ordering
swp x0, x1, [x2] // atomic swap
cas x0, x1, [x2] // compare-and-swap The instruction names carry the ordering as a suffix: a = acquire, l = release, al = both (Chapter 31). I also ran the ldadd version on two threads under qemu-user, with the same exact answer.
What the compiler emits. Let's look at real gcc -O2 output for the same C atomics on both machines. The C:
atomic_long counter;
long inc(void) { return atomic_fetch_add(&counter, 1); } // sequentially consistent by default # x86-64
inc:
mov eax, 1
lock xadd QWORD PTR counter[rip], rax # rax = old value; [counter] += 1, atomically
ret // ARM64, built without LSE (the baseline ARMv8.0), with -mno-outline-atomics:
inc:
adrp x1, .LANCHOR0
add x1, x1, :lo12:.LANCHOR0
.L3:
ldaxr x0, [x1] // load-exclusive with ACQUIRE
add x2, x0, 1
stlxr w3, x2, [x1] // store-exclusive with RELEASE
cbnz w3, .L3
ret // ARM64, built for ARMv8.1 (-march=armv8.1-a): one instruction
inc:
adrp x1, .LANCHOR0
mov x0, 1
add x1, x1, :lo12:.LANCHOR0
ldaddal x0, x0, [x1] // atomic add, acquire + release
ret And a surprise from the default Ubuntu cross-compiler, which I want you to see because you will meet it in real binaries. With default flags, the ARM64 build did not emit either form. It emitted a call:
bl __aarch64_ldadd8_acq_rel This is outline atomics. The compiler cannot know whether the CPU that will run the program has LSE. So it calls a tiny helper, and the helper checks the CPU's features once and uses ldaddal if it can and an ldaxr/stlxr loop if not. It trades a function call for a binary that works on every ARM64 chip and is fast on new ones. Seeing __aarch64_ldadd8_acq_rel (or _cas8_, _swp4_, and so on) in a disassembly means "an atomic operation."
And Rust compiles the same way. Here is real rustc -O output for the x86-64 target, from AtomicU64::fetch_add(1, SeqCst):
rs_inc:
mov eax, 1
lock xadd qword ptr [rdi], rax
ret A spinlock. A lock built from these. The idea: a lock word holds 0 (free) or 1 (held). To take it, atomically swap in a 1 and look at the old value. If it was 0, you own the lock. Otherwise someone else does, and you spin until it becomes 0. To release it, store 0.
# x86-64: inc_spin(long *p /*rdi*/, long n /*rsi*/, int *lock /*rdx*/)
inc_spin:
.next:
.acquire:
mov eax, 1
xchg eax, [rdx] # atomically swap in 1; eax = the old value
test eax, eax
jz .got # old value was 0: the lock is ours
.wait:
pause # hint to the core: we are spinning
cmp dword ptr [rdx], 0 # spin on a PLAIN read first (test-and-test-and-set)
jne .wait
jmp .acquire
.got:
mov rax, [rdi] # the critical section: an ordinary, non-atomic increment
add rax, 1
mov [rdi], rax
mov dword ptr [rdx], 0 # release: on x86-64 a plain store is enough
dec rsi
jnz .next
ret // ARM64: the same lock
inc_spin:
mov w5, #1
0: ldaxr w3, [x2] // load-exclusive with ACQUIRE
cbnz w3, 0b // held: spin
stxr w4, w5, [x2] // try to take it
cbnz w4, 0b // lost the race: retry
ldr x6, [x0] // critical section
add x6, x6, #1
str x6, [x0]
stlr wzr, [x2] // release store: everything above becomes visible before the lock looks free
subs x1, x1, #1
b.ne 0b
ret Two details. The ARM64 acquire uses ldaxr (the a is acquire) and the release uses stlr (the l is release). Without those suffixes the lock would not protect the data. Chapter 31 is about why. And the x86-64 version uses pause while spinning. It tells the core "this is a wait loop," which saves power and avoids a stall when the loop ends. ARM64's matching hints are yield and wfe ("wait for event"). I ran both locks with two threads and a plain increment inside; both counted exactly right (4,000,000 on x86-64, 600,000 under the emulator).
Build it (the "how")
Here is the complete test harness for the x86-64 counter, in C. Run it on a multi-core machine and watch the first line.
extern void inc_plain(long*, long), inc_lock(long*, long); // the asm above
static long counter; static const long N = 20000000;
static void *w_plain(void *a) { inc_plain(&counter, N); return 0; }
static void *w_lock(void *a) { inc_lock(&counter, N); return 0; }
// run two threads on each, join, and print the counter. Expect 40,000,000. On a multi-core machine, inc_plain should print a number below 40,000,000 (updates lost), and inc_lock should print exactly 40,000,000. If inc_plain prints 40,000,000 on your machine, your two threads are not truly running at once (a single core, or the scheduler put them on the same core), or you got lucky. Run it again, and use taskset or a larger N.
Why it is this way (the "why")
Why two designs? x86-64 built atomicity into the memory instruction with a prefix: simple to use, and one instruction. ARM chose LL/SC because it fits a load/store machine: only ldxr and stxr touch memory, and the monitor tracks interference. The cost is that you write the retry loop yourself, and LL/SC can fail spuriously (the monitor can be cleared by an unrelated event), so the loop must always be ready to retry. ARM added LSE later because under heavy contention, the retry loops on many cores can make no progress, while a single atomic instruction handled by the cache system does.
Why does an atomic cost so much? Because the core must own the cache line exclusively and, on x86-64, wait for earlier stores to drain first. That is about 13 times a register add even when nobody competes. And when other cores compete, the line moves between caches (Chapter 27), which costs far more. Atomic instructions are not slow instructions; they are coordination instructions, and coordination is what costs.
What would make a lock-free design better? A CAS-based algorithm that never blocks avoids the problem of a thread holding a lock being descheduled. But CAS loops have their own pitfall, the ABA problem: if a value changes from A to B and back to A, a CAS that sees A cannot tell anything happened. Real lock-free structures need extra care (version counters or safe memory reclamation). I did not build one here.
Common mistakes
- Using plain load, add, store on shared data.
- Using
volatileas if it were atomic. In C and C++,volatiledoes not make an operation atomic or ordered between threads. It only stops the compiler from deleting accesses. - A lock with the wrong ordering on ARM64.
ldxr/stxrwithout acquire and release do not protect the data. - A CAS loop that does not reload the old value after failure.
- Spinning without
pause/yield. - Assuming a test that passed on one core proves anything.
- Putting two hot counters on the same cache line (false sharing, Chapter 27).
Say it simply
counter += 1 is three steps, and another thread can slip in between them, losing an update. x86-64 fixes it with a lock prefix, and a locked instruction is atomic. ARM64 uses a load-exclusive and store-exclusive pair in a retry loop, and newer chips have single-instruction atomics (LSE). Compilers emit these for C and Rust atomics, and the default ARM64 compiler may call a helper that picks the best form. An atomic costs about 13 plain adds on this machine, even with no competition.
Check yourself
Exercise 30.1 (Warm-up): Predict, then check. Predict the exact final value of a counter that two threads each increment 10 million times with inc_lock, and what you expect from inc_plain on a multi-core machine. Run both. A correct result: 20,000,000 for inc_lock. For inc_plain, something lower on a multi-core machine. Say how much was lost and run it three times to see the spread.
Exercise 30.2 (Warm-up): Explain. In five sentences, explain what stxr returning 1 means and why the loop retries. Give an analogy and say where it breaks. What a strong answer includes: the exclusive monitor was cleared because another core wrote to the address (or for another reason), so the store did not happen; retry reads the new value; analogy: signing a document only if no one else has signed since you read it; where it breaks: the monitor can be cleared for unrelated reasons, so a failure does not always mean a real conflict.
Exercise 30.3 (Core): Implement. Write fetch_add(long *p, long x) returning the old value, on x86-64 (lock xadd), on ARM64 with LL/SC, and on ARM64 with LSE (ldadd). Test each with 4 threads. Done when: the sum of all returned old values plus the final counter is consistent (the old values returned across threads are all distinct), and you printed a histogram showing no value was returned twice.
Exercise 30.4 (Core): Implement. Write a CAS-based atomic_max(long *p, long v): loop, read the current value, stop if it is already greater or equal, otherwise try to swap. Do it on both machines. Done when: 4 threads each calling it with 1 million random values leave the maximum of all of them in *p.
Exercise 30.5 (Core): Predict, then check. Compile atomic_fetch_add with relaxed, acquire-release and seq_cst ordering for x86-64 and for ARM64 (with and without -march=armv8.1-a). Predict which instructions differ. Check. A correct result: on x86-64 all three orderings give the same lock xadd (the instruction is already fully ordered). On ARM64 the suffixes differ (ldadd, ldadda/ldaddl, ldaddal).
Exercise 30.6 (Stretch): Design and defend. A counter is incremented by 64 threads, and read rarely. Compare one shared atomic counter with a design that gives each thread its own counter and adds them up on a read. Which is faster, and what do you give up? What a strong answer includes: per-thread counters avoid bouncing a cache line between cores, so increments are plain adds; the cost is a more expensive and slightly stale read, and the counters must sit on separate cache lines to avoid false sharing.
Chapter 31. Memory ordering: the strong machine and the weak machine
In this chapter: You will learn the second thing that goes wrong between threads: even if every access is atomic, one thread can see another thread's writes in a surprising order. You will learn the one reordering x86-64 allows and the four that ARM64 allows, release and acquire as the tools to control them, and how to write a message-passing handoff that is correct on both.
You need first: Chapters 26, 27 and 30.
The problem first
Here is the most common pattern in concurrent code: one thread prepares some data and then sets a flag. Another thread waits for the flag and then reads the data.
Thread A (producer): Thread B (consumer):
data = 42; while (flag == 0) { } // wait
flag = 1; use(data); // expect 42 It looks correct. If B sees flag == 1, then data was set before. But that depends on a hidden assumption: that B sees A's two writes in the order A made them. On some machines, that is not guaranteed. B can see flag == 1 and still read the old data.
The model (the "what")
Each core has a store buffer: a small queue where its writes wait before they reach the cache. A core can run ahead and execute later instructions while earlier stores wait in the buffer. And different cores may see each other's stores at different times. The rules for what orders are allowed to be seen are the machine's memory model.
x86-64 has a strong model, called TSO (total store order). Of the four possible pairs of operations, it allows only one reordering: a load may complete before an earlier store to a different address. That is the store buffer showing: the store is still waiting while the later load goes ahead. It never reorders load-then-load, load-then-store or store-then-store.
ARM64 has a weak model. It allows all four reorderings, unless there is a dependency between the two instructions or you ask for ordering:
| Earlier, then later | x86-64 may reorder? | ARM64 may reorder? |
|---|---|---|
| store, then load (different addresses) | yes | yes |
| load, then load | no | yes |
| load, then store | no | yes |
| store, then store | no | yes |
(A dependency is when the second instruction uses the result of the first, like loading a pointer and then loading through it. ARM64 respects such address dependencies. The compiler, separately, can also reorder, and it follows the language's rules, not the hardware's.)
The formal models behind this are published. Sewell and others wrote a precise model of x86's TSO ("x86-TSO: A Rigorous and Usable Programmer's Model for x86 Multiprocessors," Communications of the ACM, 2010). For ARM, Pulte, Flur and others ("Simplifying ARM Concurrency: Multicopy-Atomic Axiomatic and Operational Models for ARMv8," POPL 2018) describe the model that ARMv8 adopted, in which a write becomes visible to all other cores at the same moment. And Batty and others gave the first formal model of the C++11 memory model ("Mathematizing C++ Concurrency," POPL 2011), which is what C11 and Rust atomics follow. If you ever need a ruling on a hard case, those are the sources.
Release and acquire. To get the order we want, we ask for it on the operation. Two flavors do the work:
- A release store guarantees that every earlier memory operation in this thread is visible to other cores before the store itself is.
- An acquire load guarantees that every later memory operation in this thread happens after the load.
Pair them. If B's acquire load sees the value that A's release store wrote, then everything A did before the store is visible to B after the load. That is the handoff we wanted.
Thread A: Thread B:
data = 42; r = load_acquire(flag) // sees 1
store_release(flag, 1); use(data); // guaranteed to see 42 Here is what each machine emits. This is real gcc -O2 output, for exactly this producer and consumer:
int data; atomic_int flag;
void publish(int v) { data = v; atomic_store_explicit(&flag, 1, memory_order_release); }
int consume(void) { while (!atomic_load_explicit(&flag, memory_order_acquire)) ; return data; } # x86-64
publish:
mov DWORD PTR data[rip], edi
mov DWORD PTR flag[rip], 1 # a PLAIN store. TSO already orders store-then-store.
ret
consume:
.L6:
mov eax, DWORD PTR flag[rip] # a PLAIN load. TSO already orders load-then-load and load-then-store.
test eax, eax
je .L6
mov eax, DWORD PTR data[rip]
ret // ARM64
publish:
...
str w0, [x1, 8] // data = v (plain)
mov w0, 1
add x1, x1, 12
stlr w0, [x1] // flag = 1 RELEASE store
ret
consume:
...
.L8:
ldar w0, [x1] // load flag ACQUIRE load
cbz w0, .L8
ldr w0, [x2, 8] // data (plain, but ordered after the ldar)
ret This is the clearest comparison in this guidebook. The same C code. On x86-64, release and acquire cost nothing: they are ordinary movs, because the strong model already provides the order. On ARM64, they are special instructions, stlr (store-release) and ldar (load-acquire), and the ordinary versions str and ldr would not be enough. If you wrote the ARM64 version with two plain stores, the other core could see the flag before the data.
Rust compiles the same way. Real output for flag.store(true, Release) and flag.load(Acquire) on x86-64 is a plain mov byte ptr [rdi], 1 and a plain movzx eax, byte ptr [rdi].
Sequential consistency. The default in C and Rust is the strongest ordering, seq_cst, which makes all atomic operations appear in one single order that every thread agrees on. That one needs more. Real gcc -O2 output for a seq_cst store:
# x86-64: atomic_store(&flag, 1) (seq_cst)
mov eax, 1
xchg eax, DWORD PTR flag[rip] # NOT a plain mov: an xchg, which is a locked operation
// ARM64:
stlr w1, [x0] // stlr is enough: ARM64's stlr/ldar are defined to be sequentially consistent with each other Why does x86-64 need xchg here, when a release store was a plain mov? Because of the one reordering TSO allows, store then load. A seq_cst store must not let a later load pass it, and a plain mov would. The locked xchg drains the store buffer, which forbids that reordering. (Rust's output for a seq_cst store was the same: xchg byte ptr [rdi], al.) The explicit fence, mfence, also works, but I measured it as slower than the xchg (10.0 ns versus 5.3 ns), which is why compilers prefer xchg.
Fences. Sometimes you want an ordering that is not attached to one access. A fence (or barrier) is an instruction that orders surrounding memory operations:
| x86-64 | ARM64 | |
|---|---|---|
| Full barrier | mfence | dmb ish |
| Order earlier loads before later ones | (not needed under TSO) | dmb ishld |
| Order earlier stores before later ones | sfence (for special stores only) | dmb ishst |
| Wait for all earlier memory operations to complete | dsb ish | |
| Flush the instruction pipeline | isb |
On x86-64 an ordinary program needs mfence only in the rare case it needs store-then-load ordering and cannot use a locked instruction. On ARM64, dmb appears more often. (isb is used when you change code, as in a JIT; Chapter 38.)
The litmus test. The standard way to study a memory model is a tiny two-thread program called a litmus test. The most famous for x86-64 is store buffering (SB):
Thread A: Thread B:
x = 1 y = 1
r0 = y r1 = x
Start with x = y = 0. Can both r0 and r1 end up 0? If the machine ran every access in some single order, one thread's store would come first, and the other thread would see it, so at least one of r0, r1 would be 1. But on TSO, both stores can sit in their store buffers while both loads read the old 0. So (0, 0) is allowed on x86-64, and it is the one reordering we listed. Inserting an mfence between the store and the load in each thread forbids it.
I wrote this test as a runnable program (about 25 lines of C with two threads, a compiler barrier between the store and the load, and 20,000 trials). I compiled it, and looked at the code the compiler produced for thread A, which is exactly the pattern we want:
mov DWORD PTR x[rip], 1
mov eax, DWORD PTR y[rip] But I could not demonstrate the reordering. My sandbox had one core, so the threads never really ran at once. I got (0,0) = 0 with and without the fence. That is the expected result on one core. It says nothing about the hardware. To see (0,0) you need two cores, and then, on x86-64, you should see it appear a small but nonzero number of times with no fence and never with mfence. I am telling you what the model predicts, not what I observed.
The other famous test is message passing (MP), which is our data and flag example. On x86-64, with plain stores and loads, it is always correct (the model forbids the bad outcome). On ARM64 with plain accesses, the model allows the consumer to see flag == 1 and a stale data. A release store and an acquire load forbid it.
Build it (the "how")
Here is the message-passing handoff, written by hand and correct on both machines.
# x86-64: producer and consumer. data is at [rdi], flag at [rsi].
producer:
mov dword ptr [rdi], 42 # data = 42
mov dword ptr [rsi], 1 # flag = 1. On x86-64 this ordinary store is a release.
ret
consumer:
.wait:
mov eax, [rsi] # flag. On x86-64 this ordinary load is an acquire.
test eax, eax
jz .wait
mov eax, [rdi] # data: guaranteed to be 42
ret // ARM64: producer and consumer. data is at [x0], flag at [x1].
producer:
mov w2, #42
str w2, [x0] // data = 42
mov w3, #1
stlr w3, [x1] // flag = 1, RELEASE: the data store above is visible first
ret
consumer:
1: ldar w2, [x1] // load flag, ACQUIRE
cbz w2, 1b
ldr w0, [x0] // data: guaranteed to be 42
ret Notice what the x86-64 version cannot tell you. It is only correct because x86-64 is strong, and only if the compiler does not reorder the two stores. In C you would write the release and acquire explicitly (memory_order_release, memory_order_acquire), because the compiler can reorder, even though the hardware would not. In hand-written assembly, nothing reorders for you, so the order you write is the order you get (on x86-64). The portable habit is to always state the ordering you need, in assembly by choosing stlr/ldar and in C and Rust by choosing the Ordering, and let the compiler emit nothing extra where the machine does not need it.
Why it is this way (the "why")
Why is ARM64 weak? Because ordering costs. A strong model forces the core to keep stores in order, which limits how it can buffer and merge them, and forces extra tracking. A weak model lets the core and the memory system reorder freely, which is easier to make fast and low-power. The price is paid by the programmer: you must ask for ordering where you need it. ARM chose to give the hardware freedom. x86-64 inherited a strong model from history (older chips did not reorder much, and programs came to depend on it), and has kept it ever since. Both approaches work. They shift the work to different places.
Why does that matter when porting? A program that is correct on x86-64 only because TSO hides a missing release or acquire will break on ARM64. This is a famous source of bugs when software moves to ARM servers or Apple silicon. The program had a latent bug. The strong machine concealed it.
Why does ARM64 have ldar and stlr instead of just dmb? Because tying the ordering to the operation is cheaper than a full barrier: the hardware can order just this access without stopping everything else. A release store is more precise than "fence, then store."
What would make seq_cst a bad default? It is the most expensive. On x86-64 it turns a plain store into xchg. If you only need a one-way handoff, release and acquire are enough and cost less. The C and Rust defaults are safe and slow, and experts weaken them with care.
Common mistakes
- Assuming x86-64 behavior on ARM64.
- Plain stores for a flag on ARM64. Use
stlr(release) for the flag andldar(acquire) to read it. - Using
volatilefor synchronization (C and C++). - Relaxed atomics for a handoff. They give atomicity, not ordering.
- Forgetting that the compiler can reorder too. Hardware rules are not the language's rules.
- A fence in only one thread. Ordering needs both sides (a release paired with an acquire).
- Believing a test that passes on one machine. A weak-model bug may need many runs, or a different chip, to show.
Say it simply
Even when every access is atomic, one thread can see another's writes in a different order. x86-64 allows only one reordering (a later load passing an earlier store), so a plain store and plain load already give you a handoff. ARM64 allows all four, so you must use a release store (stlr) and an acquire load (ldar), or a fence. In C and Rust you state the ordering you need and the compiler emits the right instructions for each machine.
Check yourself
Exercise 31.1 (Warm-up): Predict, then check. For each of these, say whether the outcome "consumer sees flag = 1 but old data" is possible on x86-64 and on ARM64, for plain accesses and for release/acquire: (a) plain store, plain load; (b) release store, acquire load. A correct result: plain accesses: impossible on x86-64 (at the hardware level), possible on ARM64. Release and acquire: impossible on both. Mention that the compiler may reorder plain accesses on both.
Exercise 31.2 (Warm-up): Explain. In five sentences, explain what a store buffer is and why it makes store-then-load the one reordering x86-64 allows. Give an analogy and say where it breaks. What a strong answer includes: stores wait in a queue before reaching the cache; a later load can run while the store waits; analogy: dropping a letter in a mailbox and then checking the newspaper before the letter is picked up; where it breaks: the core can read its own pending stores from the buffer, so a core never sees its own writes out of order.
Exercise 31.3 (Core): Implement. Write the message-passing handoff on both ISAs as full programs (use two threads from a C harness). Then write a broken ARM64 version with plain str for the flag and plain ldr to read it, and explain what could go wrong. Done when: the correct versions never fail over millions of handoffs, and you can state in a sentence which instruction is the release and which is the acquire. (The broken version may pass on your machine; on a multi-core ARM64 machine it may not. Do not treat passing as proof.)
Exercise 31.4 (Core): Run the litmus test. Compile the store-buffering test from this chapter. Run it on a machine with at least two cores, with no fence and then with mfence. Count the (0,0) outcomes. A correct result: a nonzero count without the fence on x86-64 and zero with it. Report the machine and the count. If you see zero without the fence, say how many trials and whether the threads really ran on different cores (taskset).
Exercise 31.5 (Core): Predict, then check. Compile atomic_load and atomic_store with relaxed, acquire/release and seq_cst for x86-64 and ARM64. Predict which orderings give the same instructions on x86-64, and which differ on ARM64. Check. A correct result: on x86-64 everything is a plain mov except a seq_cst store (which is xchg or mov plus mfence). On ARM64, relaxed uses ldr/str, acquire uses ldar, release uses stlr, and seq_cst uses ldar/stlr.
Exercise 31.6 (Stretch): Design and defend. You are porting a lock-free queue from x86-64 to ARM64. List the three kinds of change you would look for, and how you would test that the port is correct. What a strong answer includes: every cross-thread handoff needs an explicit release/acquire (plain accesses that relied on TSO); fences where store-then-load order mattered; any use of volatile as synchronization; testing on real ARM64 hardware with stress tests (and a tool like a model checker or ThreadSanitizer), since a one-core or emulated run proves little.
Answer notes for Part 10
- 30.5: On ARM64 with LSE, the instruction names encode the ordering:
ldadd(relaxed),ldadda(acquire),ldaddl(release),ldaddal(acquire and release). On x86-64lock xaddis fully ordered, so all orderings compile the same. - 31.1: The compiler may reorder the two plain stores on any machine if you do not tell it not to. That is why you write
memory_order_releasein C even for code that only runs on x86-64. - 31.5: GCC uses
xchgfor a seq_cst store; other compilers may usemovplusmfence. Both forbid store-then-load reordering.
Part 10 wrap-up
What you can now do:
- Explain why
counter += 1is not atomic, and fix it on both machines. - Write a counter and a spinlock with
lock/xchgand withldxr/stxrand LSE. - Recognize atomics (including outline atomics) in compiler output.
- State the one reordering x86-64 allows and the four ARM64 allows.
- Write a message-passing handoff correct on both machines.
- Say what a seq_cst store costs on each machine, and why.
- Name the litmus tests and say what each shows.
Mini project: the concurrency laboratory. Time: 8 to 12 hours. Needs a machine with at least two cores.
On x86-64 (and ARM64 hardware if you have it), build: a shared counter in three versions (plain, lock, per-thread counters), a spinlock and a ticket lock, a single-producer single-consumer ring buffer using release and acquire, and the SB and MP litmus tests. Measure throughput with 1, 2, 4 and 8 threads.
Done when: the plain counter loses updates and the others do not; the ring buffer passes ten million handoffs; your litmus results are reported with the machine, core count and trial count; and you wrote one paragraph on which version scaled best and why.
Write-up (half a page): What did the hardware do that surprised you?
Reading list for Part 10:
- Sewell, Sarkar, Owens, Zappa Nardelli and Myreen, "x86-TSO: A Rigorous and Usable Programmer's Model for x86 Multiprocessors," Communications of the ACM, 2010. The precise x86 model.
- Pulte, Flur, Deacon, French, Sarkar and Sewell, "Simplifying ARM Concurrency: Multicopy-Atomic Axiomatic and Operational Models for ARMv8," POPL 2018. The ARMv8 model.
- Batty, Owens, Sarkar, Sewell and Weber, "Mathematizing C++ Concurrency," POPL 2011. The formal model behind C11 and Rust atomics.
- Paul E. McKenney, "Is Parallel Programming Hard, And, If So, What Can You Do About It?" (free). The long practical text on memory ordering from a Linux kernel maintainer.
- Jeff Preshing's blog series on lock-free programming and memory ordering (preshing.com). Clear explanations with diagrams.
- Arm Architecture Reference Manual, the sections on the memory model, exclusive monitors and LSE. The authoritative text.
- Intel SDM Vol. 3, the chapter on memory ordering. The authoritative text for x86.
- The Rustonomicon, chapter "Atomics," and the C++ reference page for
std::memory_order.
Teach it back. In five sentences, as if to a friend, explain what can go wrong when one thread sets a flag after writing some data, why x86-64 usually hides the bug and ARM64 does not, and which two instructions fix it on ARM64.
Part 11: The Toolchain, Compilers and Rust
After this Part you can follow a program from source file to running process, read what compilers and linkers did to it, find the instruction behind a crash, and write assembly inside a Rust program without lying to the compiler.
Chapter 32. Objects, relocations and linking
In this chapter: You will learn what an object file holds, what a relocation is, and what the linker does to join files into a program. You will watch a call instruction get patched, see a real ARM64 veneer, and follow a call through the dynamic-linking stub (the PLT and GOT).
You need first: Chapters 4, 7 and 24 to 25.
The problem first
In Chapter 1 we saw labels solve the problem of moving addresses inside one file. But real programs have many files. The code in main.s calls a function in greet.s. When the assembler turns main.s into bytes, it does not know where greet will end up. What number does it put in the call instruction?
It cannot know. So it writes a placeholder and leaves a note. A later tool, the linker, reads the notes and fills in the numbers. That note is called a relocation, and the whole mechanism is the same promise as before: the human writes names, and a tool computes the numbers.
The model (the "what")
An object file (.o) is the assembler's output. It contains:
- Sections of bytes:
.text(code),.data,.rodata,.bss(Chapter 9). - A symbol table: the names the file defines, and the names it needs from elsewhere.
- Relocations: notes saying "the bytes at this offset must be patched with the address of that symbol."
Here is the smallest example. Two files. main.s calls greet, which is defined in greet.s:
# main.s
.intel_syntax noprefix
.global _start
.text
_start:
call greet # greet lives in ANOTHER file
mov eax, 60
xor edi, edi
syscall I assembled main.s alone and looked at it with objdump -dr. The -r shows the relocations. Real output:
0000000000000000 <_start>:
0: e8 00 00 00 00 call 5 <_start+0x5>
1: R_X86_64_PLT32 greet-0x4
5: b8 3c 00 00 00 mov eax,0x3c Look at the call. Its opcode is e8, and its four-byte distance is 00 00 00 00: a placeholder. Under it is the relocation: "at offset 1, patch in the distance to greet (minus 4, because a distance is measured from the end of the instruction)." And the symbol table, from nm, says the same thing in another way:
main.o:
0000000000000000 T _start T = defined here, in the text section
U greet U = undefined: someone else must provide it
greet.o:
0000000000000000 T greet
0000000000000000 r msg r = a read-only data symbol (lowercase = local to this file) Now the linker's job, in four steps:
- Resolve symbols. Match every
Uto aTsomewhere. If someUhas no match, you get the famous "undefined reference" error. - Merge sections. All the
.textfrom all files go together, all the.datatogether, and so on. - Assign addresses. Decide where each merged section lives in memory.
- Apply relocations. For each note, work out the final number and write it into the bytes.
I linked the two files and disassembled the result:
0000000000401000 <_start>:
401000: e8 09 00 00 00 call 40100e <greet>
401005: b8 3c 00 00 00 mov eax,0x3c The placeholder is now 09 00 00 00. The call is at 0x401000 and is 5 bytes long, so it ends at 0x401005. Add 9 and you get 0x40100e, which is where greet landed. The linker did that arithmetic and patched the four bytes. That is the whole mechanism.
The linker also decided how the program is loaded. It grouped the sections into segments with different permissions. The three it made here, from readelf -l:
LOAD 0x400000 R (the ELF headers)
LOAD 0x401000 R E (code: readable and executable, not writable)
LOAD 0x402000 R (read-only data) The loader (Chapter 4) maps each segment with exactly those permissions. That is why a jump into .data crashed in Chapter 4: that segment has no E.
The same on ARM64. The relocations have different names and different shapes, because the instruction formats are different (Chapter 25). Here are the unlinked bytes of an ARM64 main.s that calls greet and builds the address of a variable value:
0: 94000000 bl 0 <greet>
0: R_AARCH64_CALL26 greet
4: 90000001 adrp x1, 0 <value>
4: R_AARCH64_ADR_PREL_PG_HI21 value
8: 91000021 add x1, x1, #0x0
8: R_AARCH64_ADD_ABS_LO12_NC value The bl has a zero in its 26-bit field. The adrp and add pair, which we met in Chapter 7, has two relocations: one patches the page number into adrp (HI21, the high 21 bits of the page address), and the other patches the low 12 bits into add (LO12). After linking:
4000b0: 94000006 bl 4000c8 <greet> the 26-bit field now holds 6 (instructions)
4000b4: 90000081 adrp x1, 410000
4000b8: 91033021 add x1, x1, #0xcc The bl field holds 6, and 6 instructions is 24 bytes: 0x4000b0 + 24 = 0x4000c8. The adrp's target is page 0x410000, and the add supplies the low part 0xcc, so x1 = 0x4100cc, the address of value. The linker patched bit fields inside instructions, not whole words. When the target is too far: veneers. A bl reaches only about ±128 MiB (Chapter 25). What if the callee is farther? I tested it. I put a function at address 0x20000000, 512 MiB away from the caller, and linked. The linker did not fail. It inserted a small stub of its own, called a veneer, near the caller, and pointed the bl at it:
00000000004000b0 <_start>:
4000b0: 94000006 bl 4000c8 <__far_func_veneer>
00000000004000c8 <__far_func_veneer>:
4000c8: 900fe010 adrp x16, 20000000 <far_func>
4000cc: 91000210 add x16, x16, #0x0
4000d0: d61f0200 br x16 The veneer builds the full address in x16 and jumps with an indirect br, which has no distance limit. Note the register: x16 is the scratch register that the calling convention in Chapter 14 reserved for exactly this ("x16, x17: scratch for the linker"). I ran the linked program under the emulator, and it worked. When you see a function named __something_veneer in a disassembly, now you know.
Dynamic linking: the PLT and GOT. Most programs do not contain a copy of the C library. They borrow it at run time from a shared library. I compiled the same two-line C program (it calls puts twice) both ways:
dynamically linked (default): 15,960 bytes
statically linked (-static): 785,384 bytes The dynamic file is about 49 times smaller, because puts is not in it. So how does the call find puts? Here is the call in the dynamic one:
1158: e8 f3 fe ff ff call 1050 <puts@plt> It calls a tiny stub in the PLT (procedure linkage table), not puts itself. Here is the stub:
0000000000001050 <puts@plt>:
1050: f3 0f 1e fa endbr64
1054: ff 25 76 2f 00 00 jmp QWORD PTR [rip+0x2f76] # 3fd0 <puts@GLIBC_2.2.5> The stub is an indirect jump through a slot at 0x3fd0, in a table called the GOT (global offset table). The dynamic loader fills that slot with the real address of puts when the program starts (a relocation of type R_X86_64_JUMP_SLOT tells it which symbol). The endbr64 at the top is the CET marker from Chapter 17. And readelf told me the program was linked with BIND_NOW: the loader resolves every slot at start-up, rather than lazily on first call, and then makes the table read-only (GNU_RELRO) so an attacker cannot overwrite it. That is the default on this Ubuntu toolchain.
So a call to a library function is: your call, then the stub's jmp through the GOT, then the library's code. It costs one extra indirect jump, which is well predicted.
Thread-local storage. One more relocation-driven feature you will see in real code is a variable that is separate for each thread (__thread in C, thread_local! in Rust). Compiled for the main executable, real output:
# x86-64: long get(void) { return counter; } where counter is __thread
get:
mov rax, QWORD PTR fs:counter@tpoff # fs: is the per-thread base; @tpoff is the offset (patched by the linker)
ret // ARM64
get:
mrs x0, tpidr_el0 // the thread pointer register
add x0, x0, #:tprel_hi12:.LANCHOR0, lsl #12
add x0, x0, #:tprel_lo12_nc:.LANCHOR0 // add the variable's offset in two pieces
ldr x0, [x0]
ret The same stack protector canary from Chapter 15 lived at fs:0x28: it is the same mechanism. On x86-64 the per-thread base is the fs segment register. On ARM64 it is the system register tpidr_el0.
Build it (the "how")
The tools you need are few, and all print real information:
| Question | Command |
|---|---|
| What symbols does this file define or need? | nm file.o |
| What patches will the linker make? | objdump -dr file.o or readelf -r file.o |
| What sections and segments does the program have? | readelf -SW prog, readelf -lW prog |
| What shared libraries does it need? | readelf -d prog \| grep NEEDED or ldd prog |
| Where did the linker put each thing? | ld -Map=prog.map ... |
Try this for yourself: link the two files with the order reversed (ld -o prog greet.o main.o). Does _start still work? Which address does greet get? The relocations are the same, and the numbers change.
Why it is this way (the "why")
Why separate compilation at all? So a change in one file does not force recompiling all of them, and so libraries can be shared. The price is that each file is assembled blind, with placeholders, and a final tool does the patching. That is exactly the cost of the "numbers move" problem from Chapter 1, paid at a larger scale.
Why shared libraries? One copy of the C library in memory serves every program, and a security fix to it fixes them all. The price is the PLT and GOT indirection, a slower start-up (resolving symbols), and a program that no longer runs if the library is missing or different. Static linking gives the opposite deal: a big self-contained file with no start-up lookups.
Why does the GOT become read-only after start-up? Because it holds the addresses of functions that will be called. If an attacker could overwrite a slot, they would control where puts goes. RELRO closes that hole, at the cost of resolving everything at the start (BIND_NOW) instead of lazily.
What would make this design wrong? If the patch could not be computed, because a target is out of range (the veneer case). Both ISAs solve it with a trampoline that has a longer reach. On x86-64 a call has a 32-bit reach (±2 GiB), which is rarely exceeded, so you seldom see veneers. On ARM64 the 26-bit reach is smaller, so they appear in large programs.
Common mistakes
- "Undefined reference" because of link order. For static libraries, the order of files on the command line can matter.
- Forgetting
.globalso the symbol is local and the linker cannot see it. - Mixing position-independent and absolute code (
[msg]instead of[rip+msg]) and getting a "relocation truncated" or "cannot be used when making a PIE" error. - Assuming addresses are fixed. With address-space layout randomization, a PIE program loads at a different base every run.
- Hand-patching a binary without checking relocations or
endbr64.
Say it simply
An assembler turns each file into bytes with placeholders and a list of relocations ("patch this spot with the address of that name"). The linker joins the files, picks final addresses, and applies each patch, sometimes patching just a bit field inside an instruction. When a call target is too far for the instruction, the linker inserts a stub (a veneer). Shared-library calls go through a small PLT stub that jumps through a table (the GOT) filled in by the loader.
Check yourself
Exercise 32.1 (Warm-up): Predict, then check. Assemble main.s and greet.s separately. Predict the bytes of the call before linking, and after. Then run objdump -dr on the object and on the linked program. A correct result: e8 00 00 00 00 before, with an R_X86_64_PLT32 relocation; e8 09 00 00 00 (or whatever distance your layout gives) after.
Exercise 32.2 (Warm-up): Explain. In five sentences, explain what a relocation is. Use an analogy and say where it breaks. What a strong answer includes: a note left by the assembler for the linker to fill in a value it could not know; analogy: a form with a blank for a street number that the post office fills in later; where it breaks: the blank sits inside the bits of an instruction and has a specific format, and some blanks have limited range.
Exercise 32.3 (Core): Implement. Write an ARM64 main.s that calls a function in greet.s and prints a message. Look at objdump -dr before linking. Then use --section-start to place greet more than 128 MiB away and find the veneer. Done when: you can point to the veneer's three instructions, say which register it uses, and why.
Exercise 32.4 (Core): Break it. Remove .global greet from greet.s. What does the linker say? Now add a second definition of greet in another file. What does it say? A correct result: "undefined reference to greet" for the first; "multiple definition of greet" for the second.
Exercise 32.5 (Core): Read it. Compile a C program that calls puts and printf. Find both PLT stubs, and for each find the GOT slot and the relocation that fills it. What changes if you build with -Wl,-z,lazy? A correct result: two stubs with jmp [rip+...], two R_X86_64_JUMP_SLOT entries; with lazy binding the first call goes through the dynamic loader's resolver, and the slot is filled on first use.
Exercise 32.6 (Stretch): Design and defend. A large program is built as one huge static binary on ARM64, and the linker inserts thousands of veneers. Why might that cost speed, and what could you do about it? What a strong answer includes: each far call pays an extra indirect branch and a few instructions, and veneers add code size; reorder functions so callers and callees are close (profile-guided function ordering), or use shared libraries or a larger-range scheme, or split the program.
Chapter 33. What compilers do: reading optimized output
In this chapter: You will learn what the optimization levels change, with a real function compiled four ways. You will see that gcc's vectorized loop is the loop you wrote by hand in Part 9. You will read Rust's output, including its bounds checks, and see why a compiler is allowed to turn your bug into an infinite loop.
You need first: Chapters 5, 26 and 29.
The problem first
In Chapter 5 you learned to read a small function. But real compiler output varies a lot with the flags. The same source can be 20 instructions or 44. And sometimes the output looks nothing like your code. To read it well, you need a picture of what the compiler is allowed to do, and what it likes to do.
The model (the "what")
A compiler follows the as-if rule: it may change anything about how your program runs, as long as the observable behavior is the same as the language says it should be. That one rule lets it delete code, reorder it, turn loops into formulas, and merge or split operations. It is also why undefined behavior is dangerous: the language says "anything may happen," so the compiler may do anything.
What the optimization levels change. Here is one C function, compiled by gcc 13.3 for x86-64 at each level. I counted the instructions:
int sum(const int *a, long n) { int s = 0; for (long i = 0; i < n; i++) s += a[i]; return s; } | Level | Instructions | What it is |
|---|---|---|
-O0 | 20 | Every variable lives on the stack; a naive translation (Chapter 5). |
-O1 | 13 | Variables in registers; basic clean-up. |
-O2 | 11 | The usual setting for shipped code. |
-O3 | 44 | Adds vectorization and unrolling: more instructions, faster on big arrays. |
More instructions is not worse. The -O3 version is longer because it contains a vector loop and a scalar tail. And here is the thing to see: the -O3 loop is exactly the loop from Chapter 29. Real gcc -O3 output, the inner part:
# x86-64, gcc -O3 (baseline SSE2)
.L4:
movdqu xmm2, XMMWORD PTR [rax] # load 4 ints
add rax, 16
paddd xmm0, xmm2 # 4 lane-wise adds
cmp rax, rdx
jne .L4
movdqa xmm1, xmm0 # the reduction
psrldq xmm1, 8
paddd xmm0, xmm1
movdqa xmm1, xmm0
psrldq xmm1, 4
paddd xmm0, xmm1
movd eax, xmm0
... # then a scalar tail for the last 0 to 3 elements // ARM64, gcc -O3 (NEON)
.L4:
ldr q1, [x2], 16 // load 4 ints, advance
add v0.4s, v0.4s, v1.4s // 4 lane-wise adds
cmp x2, x3
bne .L4
addv s0, v0.4s // the reduction, ONE instruction
... These are the loops I wrote by hand in Chapter 29: a movdqu, a paddd, a reduction, a tail; on ARM64 ldr q, add v0.4s, addv. The compiler wrote the same code, from a plain C loop. If you can read Chapter 29, you can read what the autovectorizer does. And because the baseline x86-64 target only has SSE2, it used 128-bit registers; a build with -march=native may use wider ones, if the chip has them.
What compilers like to do. These are the patterns to recognize:
- Strength reduction. Replace expensive operations with cheap ones:
x*10becameleaandadd(Chapter 1),x/10became a multiply (Chapter 19), an index became a pointer (Chapter 5). - Constant folding and closed forms. Compute at compile time what can be. In Chapter 28
clangturned a loop summing squares into a polynomial. - Dead-code elimination. Delete what has no effect. In Chapter 28 an unused loop vanished.
- Inlining. Replace a call with the callee's body, which then lets all the others apply.
- Vectorization and unrolling. As above.
- Loop rotation. The do-while shape with a guard (Chapter 11).
- If-conversion. Replace a branch with a
cmov/csel(Chapter 12) when it looks cheap and safe.
Undefined behavior, in one picture. Here is a function that walks a linked list. It has a bug: the loop never stops at the null pointer.
long sum_list(struct node *n) { long s = 0; while (1) { s += n->value; n = n->next; } return s; } At -O0 the program crashes with a segmentation fault when n becomes null (Chapter 34 shows the crash). But at -O1, the real output is:
sum_list:
.L2:
jmp .L2 An infinite loop, with no memory access at all. Why? Dereferencing a null pointer is undefined behavior in C. The compiler is allowed to assume it never happens, so it concluded that the loop can only be exited by a path that cannot occur, and that the loop does nothing observable. So it deleted the body and left the loop. My test program, which I expected to crash, hung until my command timed out. That is the as-if rule and undefined behavior together: the compiler did not break your program, it optimized a program that your source did not actually say.
Build it (the "how")
Rust. Rust compiles through LLVM, and the habits are the same. To see the output:
rustc -O --crate-type=lib --emit asm -C llvm-args=-x86-asm-syntax=intel -o out.s file.rs
# or, with Cargo: cargo rustc --release -- --emit asm
# and Compiler Explorer (godbolt.org) with the Rust compiler and flag -O Mark each function you want to see with #[no_mangle] pub fn, so its name is readable. (Rust mangles names: you will see things like _ZN4core9panicking18panic_bounds_check17h6879eeb39abdf99dE. That is core::panicking::panic_bounds_check in disguise. The tool rustfilt demangles them.)
Here is what Rust's safety checks look like, in real output from rustc 1.75 -O:
#[no_mangle] pub fn get_checked(a: &[i64], i: usize) -> i64 { a[i] }
#[no_mangle] pub fn get_unchecked(a: &[i64], i: usize) -> i64 { unsafe { *a.get_unchecked(i) } } get_checked:
cmp rdx, rsi # is i < a.len() ? (rsi = len, rdx = i)
jae .LBB2_2 # no: go to the panic path
mov rax, qword ptr [rdi + 8*rdx] # the actual load
ret
.LBB2_2:
...
call qword ptr [rip + _ZN4core9panicking18panic_bounds_check17h...E@GOTPCREL]
ud2 # never returns
get_unchecked:
mov rax, qword ptr [rdi + 8*rdx] # just the load
ret The cost of Rust's bounds check is two instructions: a cmp and a jae, which is a well-predicted branch that is almost never taken. This is the honest answer to "how much does safety cost?" for a single access. In a hot loop it can matter more, if it blocks vectorization. But look at what happens when the compiler can prove the index is in range:
#[no_mangle] pub fn sum_index(a: &[i64]) -> i64 { let mut s = 0; for i in 0..a.len() { s += a[i]; } s } I searched the whole compiled function for a call to panic_bounds_check: there is none. LLVM saw that i runs from 0 to a.len() and so is always in range, and removed the check. It also vectorized the loop, and it used two vector accumulators (paddq xmm0, xmm2 and paddq xmm1, xmm2 alternating), which is exactly the dependency-chain-splitting trick from Chapter 26, done by the compiler. (It can do this for integers because integer addition is exactly associative; for f64 sums it may not.)
Why it is this way (the "why")
Why does the compiler transform so much? Because the language gives it that freedom, and because machines reward it: the vector loop is 4 to 8 times faster than the scalar one (Chapter 29), and the compiler can do it without you writing a line of assembly. The as-if rule is the contract that makes this safe: same behavior, different machine code.
Why is undefined behavior part of the contract? Because it lets the compiler skip checks it would otherwise have to insert, like "is this pointer null?" on every dereference. The cost is the surprising outputs above. Rust's answer is to make the safe subset free of undefined behavior, so the compiler can optimize aggressively without that danger, and to make you write unsafe where you take on the burden yourself.
What would make you distrust the output? When the code you are reading is not the code that runs: a different build, a different flag, a different compiler version. Always check the flags on the line that matters, and check that the binary you profile is the binary you disassembled.
Common mistakes
- Reading
-O0and drawing conclusions about-O2. - Comparing instruction counts as if they were speed. The
-O3function is 4 times longer and faster. - Expecting your source line to map to assembly lines. After inlining and reordering it will not.
- Benchmarking code the compiler has transformed away (Chapter 28).
- Thinking a compiler bug when it is undefined behavior.
- Assuming
unsafeis faster. Often the safe version compiles to the same code.
Say it simply
A compiler may change anything as long as the observable behavior stays the same. That lets it strength-reduce, inline, vectorize, delete dead code and turn loops into formulas. The vectorized loop from -O3 is the one you wrote in Chapter 29. Rust's bounds check is a compare and a branch, and the compiler removes it when it can prove the index is in range. Undefined behavior lets the compiler assume impossible things, which can turn a crash into a hang.
Check yourself
Exercise 33.1 (Warm-up): Predict, then check. Predict whether the instruction count of sum goes up or down from -O2 to -O3, and why. Compile and count. A correct result: up (the vector loop and the tail add code), and the loop is faster for large arrays. Name the vector instruction you find.
Exercise 33.2 (Warm-up): Explain. In five sentences, explain the as-if rule. Use an analogy and say where it breaks. What a strong answer includes: any transformation is allowed if observable behavior is unchanged; analogy: a contractor who may use any method to build the house you specified; where it breaks: what counts as "observable" is defined by the language, so reading a variable another thread writes or a null pointer can be outside the contract.
Exercise 33.3 (Core): Read it. Write a Rust function that sums a slice with .iter().sum() and another with a manual index loop, and a third that uses get_unchecked. Compile all three. Which has a bounds check? Which is vectorized? Are the bodies the same? A correct result: a table with the three results. Mention any difference, and why.
Exercise 33.4 (Core): Break it. Write a C function that reads past the end of an array in a loop that the compiler can see through. Compile at -O0 and -O2. Predict what each does. Check. A correct result: the behavior differs (it may crash, return garbage, or be optimized away). Explain, in terms of undefined behavior, why there is no "right" answer.
Exercise 33.5 (Core): Predict, then check. Compile int f(int x) { return x * 100; }, int g(int x) { return x / 100; } and int h(int x) { return x % 100; } at -O2 for both ISAs. Predict which instructions you will see. Check. A correct result: f uses imul or lea+shl combinations; g and h use the multiply-by-magic-number pattern from Chapter 19, with no divide instruction.
Exercise 33.6 (Stretch): Design and defend. A team ships one binary built with -O3 -march=native on their build machine, and it crashes with "illegal instruction" on some customers' machines. Explain why, and how you would fix the build. What a strong answer includes: -march=native lets the compiler use instructions that the build machine has but customers' machines may lack; build for a baseline target, or use runtime dispatch for the optional paths (Chapter 29, Exercise 29.6).
Chapter 34. Finding the instruction behind a crash
In this chapter: You will learn a repeatable method for going from "segmentation fault" to the exact instruction, register and cause, using only gdb and objdump. You will read two real crashes: a null-pointer read, and a divide by zero.
You need first: Chapters 4, 5, 14 and 15.
The problem first
A program crashes. The terminal says Segmentation fault and nothing else. In a high-level language you might get a stack trace with a line number. Often you get nothing: no symbols, no source, an optimized build. You have an address and a signal. What now?
This is where reading assembly pays for itself. The CPU knows exactly which instruction failed, and what was in every register. You need to ask.
The model (the "what")
When a program does something the hardware forbids, the CPU raises an exception, and the kernel turns it into a signal to your process. The common ones:
| Signal | Number | Typical cause |
|---|---|---|
SIGSEGV | 11 | Access to memory you do not own, or a write to read-only memory, or an instruction fetch from a non-executable page |
SIGFPE | 8 | Integer divide by zero (x86-64), or INT_MIN / -1 |
SIGILL | 4 | An instruction the CPU does not recognize (a bad jump, or a feature the CPU lacks) |
SIGBUS | 7 | A misaligned or invalid access of certain kinds (for example, per the architecture, a misaligned atomic or acquire/release access on ARM64; I could not reproduce that one in the emulator) |
SIGABRT | 6 | The program called abort() (assertion failures, a failed stack protector check) |
The shell shows a death by signal as 128 + number, as in Chapter 4.
A debugger stops the program at the faulting instruction, with all registers intact. That is the whole trick. The method has five steps.
- Get the signal and the program counter. What happened, and where?
- Disassemble the faulting instruction. What was it trying to do?
- Read the registers it used. What value caused the problem?
- Walk back. Where did that value come from?
- Find the cause in source or logic. Why was it wrong?
Build it (the "how")
Crash 1: a null pointer. Here is a program that walks a linked list and forgets to stop at the end:
struct node { long value; struct node *next; };
long sum_list(struct node *n) { long s = 0; while (1) { s += n->value; n = n->next; } return s; }
int main(void) { struct node c = {3, 0}, b = {2, &c}, a = {1, &b}; printf("%ld\n", sum_list(&a)); return 0; } I built it with gcc -O0 -g (so the frame is easy to read) and ran it. The shell reported Segmentation fault, status 139. Then I ran it under gdb with a handful of commands (run, bt, x/i $pc, info registers, disassemble). Real output:
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555181 in sum_list (n=0x0) at crash.c:3
#0 0x0000555555555181 in sum_list (n=0x0) at crash.c:3
#1 0x00005555555551ee in main () at crash.c:4
=> 0x555555555181 <sum_list+24>: mov (%rax),%rax
rax 0x0 0
rip 0x555555555181 0x555555555181 <sum_list+24>
0x000055555555517d <+20>: mov -0x18(%rbp),%rax # load n from the stack
=> 0x0000555555555181 <+24>: mov (%rax),%rax # load n->value <-- FAULT
0x0000555555555184 <+27>: add %rax,-0x8(%rbp) # s += ...
0x0000555555555188 <+31>: mov -0x18(%rbp),%rax
0x000055555555518c <+35>: mov 0x8(%rax),%rax # n = n->next
0x0000555555555190 <+39>: mov %rax,-0x18(%rbp) Apply the five steps:
- Signal and PC:
SIGSEGVatsum_list+24. - The instruction:
mov (%rax),%rax. (This is AT&T syntax:gdbprints it by default. The parentheses mean memory, so this ismov rax, [rax]in the Intel syntax we use.) It is a load from the address inrax. - The register:
raxis0x0. It tried to load from address zero. That is a null pointer. - Walk back: the line before it loads
nfrom-0x18(%rbp), and the lines after it shown = n->next. Sonbecame null by following thenextfield of the last node. - Cause: the loop has no exit test. After the third node,
nextis null, and the next trip dereferences it.
Notice what you read and did not need: the source. Steps 1 to 4 came entirely from the machine state. That is the skill.
And notice the optimized story. At -O1, the same program does not crash. It hangs. I showed the reason in Chapter 33: the compiler turned the function into jmp .L2. If your crash disappears when you turn on optimization, suspect undefined behavior.
Crash 2: divide by zero. A function that divides, called with a zero divisor:
int div_it(int a, int b) { return a / b; }
int main(int argc, char **argv) { return div_it(100, argc - 1); } // argc is 1, so b is 0 Program received signal SIGFPE, Arithmetic exception.
=> 0x555555555144 <main+17>: idiv %edi
eax 0x64 100
ecx 0x55557df8 1431666168
edx 0x0 0 - Signal and PC:
SIGFPE, at anidiv. - The instruction:
idiv %edi, which dividesedx:eaxbyedi(Chapter 19). - The registers:
eaxis 100 (the dividend),edxis 0 (the high half), and the divisor is inedi. A signed divide faults on a divisor of zero. So printedi: it isargc - 1, which is 0. - Cause: the caller passed zero. This is the Chapter 19 rule: x86-64 traps on divide by zero. (On ARM64 the same code would not crash; it would quietly return 0, which is a worse bug to find. Chapter 19.)
The gdb commands worth knowing.
| Command | What it does |
|---|---|
run / bt | Run; show the call stack |
x/i $pc | Show the instruction at the program counter |
x/10i $pc | Show the next 10 instructions |
info registers / p $rax | Show the registers / one register |
x/8gx $rsp | Show 8 quad words of stack, in hex |
disassemble | Show the whole current function |
set disassembly-flavor intel | Switch to the syntax we use |
break *0x401000 / stepi / nexti | Break at an address; step one instruction |
watch $rbx / watch *(long*)0x7ffe... | Stop when a register or address changes |
(I did not test a core-dump workflow in the sandbox. On a normal machine, ulimit -c unlimited lets a crash write a core file, and gdb prog core gives the same registers and backtrace after the fact. Treat that as not run.)
When there are no symbols. A stripped binary has no function names, only addresses. The method still works. bt shows addresses, x/i still shows the instruction, and objdump -d lets you read the whole function. You can find where an address belongs with info symbol 0x... in gdb or addr2line if there is debug information. Often a stack trace on a stripped binary is still useful if the frame pointers are kept (Chapter 15) or the unwind tables are there. And as Chapter 15 showed, hand-written assembly without .cfi_ directives gives you a nonsense backtrace, so add them.
Why it is this way (the "why")
Why does the CPU stop at the faulting instruction? Because the hardware is designed so that an exception is precise: even though the core runs instructions out of order (Chapter 26), when a fault is reported, every earlier instruction has completed and none of the later ones has any visible effect. This is the same promise as Chapter 2's loop, now doing a job for you. Without it, debugging would be hopeless.
Why is the register value so informative? Because a crash is an instruction applied to a bad value, and the registers hold the value. In the first crash the value was 0, the cause was visible, and the rest was reading.
What would make this method fail? If the bad value was created long before it caused the crash (a corrupted pointer, a stack overwrite, a use after free). Then the faulting instruction is just where it surfaced. Step 4, walking back, becomes the work: watchpoints (watch) let the debugger stop when the bad value is written, which finds the real culprit.
Common mistakes
- Stopping at the line number. The instruction and the registers are more precise.
- Reading AT&T as Intel.
mov (%rax),%raxis a load, not a move into memory. - Debugging an optimized build as if it were
-O0. Variables may live in registers or not exist. - Trusting a backtrace through hand-written assembly with no CFI.
- Not checking whether the crash moves when you change optimization levels.
Say it simply
When a program crashes, the debugger stops at the exact instruction with all registers intact. Read the signal, the instruction, and the register it used, then walk back to find where that bad value came from. A load from address zero is a null pointer, an idiv is a divide by zero. If the bug disappears at higher optimization, suspect undefined behavior.
Check yourself
Exercise 34.1 (Warm-up): Predict, then check. For mov rax, [rbx+8] crashing with rbx = 0, what faulting address does the signal report? What is the faulting address for mov [rsp+rcx*8], rax with rcx huge? A correct result: address 8 (small addresses are the signature of a null-pointer-to-struct bug); for the second, an address far from the stack, which is the signature of a bad index.
Exercise 34.2 (Warm-up): Explain. In five sentences, explain what a precise exception is and why it matters. Give an analogy and say where it breaks. What a strong answer includes: all earlier instructions have completed, no later one has any visible effect; analogy: a fire alarm that stops a factory line with every item either finished or untouched; where it breaks: the cache and some timing effects of later instructions can remain (speculation leaves traces).
Exercise 34.3 (Core): Implement. Write a C program with each of these bugs: null dereference, stack overflow by infinite recursion, write to a string literal, and divide by zero. Crash each under gdb and fill a table: signal, faulting instruction, key register, one-line cause. Done when: all four rows are filled, and you did each one using only the five steps.
Exercise 34.4 (Core): Break it. Write the hand-written factorial from Chapter 15 without CFI, and get a backtrace at the base case. Then add the .cfi_ directives and compare. A correct result: the first shows nonsense frames (0x2 in ?? ()), the second shows each recursive frame correctly.
Exercise 34.5 (Core): Predict, then check. Take the null-walk list function and compile it at -O0, -O1 and -O2. Predict which crash, which hang, which do something else. Run each with a time limit. A correct result: -O0 crashes; at higher levels it may hang or behave differently. Report what you saw and look at the assembly of each.
Exercise 34.6 (Stretch): Design and defend. A crash happens once a week in production, in a stripped binary, with no core file. List three things you would add to the program or the environment to make the next crash diagnosable. What a strong answer includes: enable core dumps or a crash handler that logs the registers and a backtrace; keep frame pointers or unwind tables; build and store a matching debug symbol file for every release; and record the exact binary build id so you can match addresses to code.
Chapter 35. Assembly inside Rust: asm!, global_asm! and naked functions
In this chapter: You will learn the three ways to put assembly into a Rust program, how to describe your assembly to the compiler honestly (operands, clobbers, options), and when not to use it. You will run real examples that issue a system call, read the cycle counter, and call a hand-written function through the C ABI.
You need first: Chapters 14, 17 and 21.
The problem first
You are writing Rust, and you need something Rust cannot say: a particular instruction, a system call, a specific register sequence, or a function with an exact layout. You could write a separate .s file and link it, but that is awkward. Rust lets you write assembly inside your source file. The catch is that the Rust compiler is also using the registers. You have to tell it, truthfully, what your assembly reads, writes and destroys, or the program will quietly break.
The model (the "what")
There are three tools, all in core::arch (and re-exported in std::arch):
| Tool | What it is | Stable since |
|---|---|---|
asm! | A block of assembly inside a function. The compiler allocates registers for you. | Rust 1.59 |
global_asm! | Top-level assembly, outside any function: whole functions or data | Rust 1.59 |
#[unsafe(naked)] with naked_asm! | A whole function whose body is only your assembly, with no compiler-generated prologue or epilogue | Rust 1.88 (June 2025) |
(I checked the naked function stabilization in Rust's own release announcement for 1.88. My sandbox had Rust 1.75, so I ran the first two and could not run the third.)
asm! uses Intel syntax by default on x86-64. You do not write .intel_syntax. (When I did put it inside global_asm!, the compiler warned that it is the default, and recommended options(att_syntax) if you want the other one.)
Operands. You do not name registers directly. You describe what you need and let the compiler pick:
use std::arch::asm;
fn add_asm(a: u64, b: u64) -> u64 {
let mut r = a;
unsafe { asm!("add {0}, {1}", inout(reg) r, in(reg) b, options(pure, nomem, nostack)); }
r
} {0} and {1} are placeholders for the operands in order. inout(reg) r means "put r in a register, my assembly changes it, and take the new value back." in(reg) b means "put b in a register that I only read." The compiler chooses which registers. Here is what it generated, in real output:
add_asm:
mov rax, rdi # the compiler's setup: r = a, in rax
#APP
add rax, rsi # <-- OUR assembly. It chose rax for {0} and rsi for {1}.
#NO_APP
ret The markers #APP and #NO_APP delimit your block in the compiler's output. That is a handy trick for finding your code in a big listing.
The common operand classes:
| Syntax | Meaning |
|---|---|
in(reg) x | a register holding x, read only |
out(reg) y | a register your code writes; the compiler reads it back into y |
inout(reg) z | a register holding z in and the new value out |
inlateout(reg) z | like inout, but the register may be shared with an input (when you read all inputs before writing) |
lateout(reg) _ | declares a register as destroyed, without caring about its value (a clobber) |
in("rdi") x | a specific register |
in(xmm_reg) f | a vector or float register |
Options tell the compiler what your assembly does not do, so it can optimize around it:
| Option | You promise... |
|---|---|
pure | no side effects; the output depends only on the inputs (it may be removed or merged) |
nomem | it does not read or write memory |
readonly | it reads memory but does not write it |
preserves_flags | it leaves the flags unchanged |
nostack | it does not use the stack (so the compiler may use the red zone) |
noreturn | it never returns |
att_syntax | the text is in AT&T syntax |
If you do not give an option, the compiler assumes the worst (the assembly may read and write any memory and change the flags), which is safe but slower. If you give an option that is a lie, the program has undefined behavior.
Clobbers are the part that bites. Anything your assembly changes that you did not declare as an output must be declared as a clobber. Here is the system call example, which I ran. It must declare rcx and r11, which the syscall instruction destroys (Chapter 4):
// file: ra.rs (this is the exact program I compiled and ran)
use std::arch::asm;
fn add_asm(a: u64, b: u64) -> u64 {
let mut r = a;
unsafe { asm!("add {0}, {1}", inout(reg) r, in(reg) b, options(pure, nomem, nostack)); }
r
}
// A system call straight from Rust: write(1, buf, len). rcx and r11 are destroyed by syscall, so we declare them.
fn sys_write(buf: &[u8]) -> isize {
let ret: isize;
unsafe {
asm!("syscall",
inlateout("rax") 1isize => ret, // rax = syscall number in, result out
in("rdi") 1usize, in("rsi") buf.as_ptr(), in("rdx") buf.len(),
lateout("rcx") _, lateout("r11") _, // clobbered by the syscall instruction
options(nostack));
}
ret
}
fn rdtsc() -> u64 {
let (lo, hi): (u32, u32);
unsafe { asm!("rdtsc", out("eax") lo, out("edx") hi, options(nomem, nostack)); }
((hi as u64) << 32) | lo as u64
}
// A function written in assembly with global_asm!, called through the C ABI.
std::arch::global_asm!(
".global point_sum",
"point_sum:",
" mov rax, [rdi]", // p.x
" add rax, [rdi + 8]", // + p.y
" ret",
);
#[repr(C)] struct Point { x: i64, y: i64 }
extern "C" { fn point_sum(p: *const Point) -> i64; }
fn main() {
println!("add_asm(40, 2) = {}", add_asm(40, 2));
let n = sys_write(b"hello from asm! inside Rust\n");
println!("sys_write returned {}", n);
let (t0, t1) = (rdtsc(), rdtsc());
println!("two rdtsc reads are increasing: {}", t1 > t0);
let p = Point { x: 30, y: 12 };
println!("point_sum = {}", unsafe { point_sum(&p) });
} I compiled and ran this on rustc 1.75. Real output:
add_asm(40, 2) = 42
hello from asm! inside Rust
sys_write returned 28
two rdtsc reads are increasing: true
point_sum = 42 Every line deserves a comment.
add_asm: 40 + 2 = 42, through ouradd.sys_write: a raw Linuxwritesystem call from Rust. The syscall number goes inrax(inlateout("rax") 1isize => retmeans "put 1 inraxon the way in, and read the result fromraxon the way out"). The arguments go inrdi,rsiandrdx. Andlateout("rcx") _, lateout("r11") _declares the two registers the instruction destroys. It returned 28, the number of bytes in the message. This is the Chapter 4 program, written inside Rust.rdtsc: reads the CPU's time-stamp counter intoedx:eax. Two reads in a row were increasing, as they should be. (rdtsccounts at a fixed rate that is not always the core's clock rate, so use it with care. Chapter 28 prefersclock_gettime.)point_sum: a function written inglobal_asm!(it just loads[rdi]and adds[rdi+8]) and called throughextern "C"with a#[repr(C)]struct. 30 + 12 = 42. This is the exercise from Chapter 17. It needs exactly the three things from that chapter: the C ABI (extern "C"), the C layout (#[repr(C)]), and a symbol name that matches (.global point_sum).
Inline assembly is a barrier to the optimizer. This is the most important practical fact. The compiler treats your block as a black box. It cannot look inside to vectorize, unroll or reorder around it. Compare these two real compilations of a loop that adds up the elements of a slice:
# the plain Rust loop `for i in 0..a.len() { s += a[i]; }` -> a vectorized loop (Chapter 33)
movdqu xmm2, xmmword ptr [rdi + 8*rax]
paddq xmm0, xmm2
...
# the same loop with our asm!("add {0}, {1}") inside it -> one scalar add per element, in a loop
.LBB2_4:
mov rdx, qword ptr [rdi + rcx]
#APP
add rax, rdx
#NO_APP
add rcx, 8
cmp rsi, rcx
jne .LBB2_4 The version with asm! inside is a scalar loop, one element at a time. The compiler vectorized the first and could not vectorize the second. So asm! for something the compiler already does well makes the code slower, as well as harder to read. Use it only for what Rust cannot express.
Naked functions (Rust 1.88 and later) go one step further. In a normal function the compiler writes the prologue and epilogue (Chapter 15). A naked function has none: the body is only your assembly, so you control every instruction, including the ret. The syntax, from the stabilization announcement, looks like this. Not run here (my sandbox had Rust 1.75), so check the release notes for your version:
// Not run. Requires Rust 1.88 or newer.
use std::arch::naked_asm;
#[unsafe(naked)]
pub extern "C" fn add_naked(a: u64, b: u64) -> u64 {
naked_asm!(
"lea rax, [rdi + rsi]",
"ret",
)
} You must write the ret yourself, and you must follow the C ABI by hand: arguments arrive in rdi and rsi, and you return in rax. Naked functions are for things like context-switch routines and the entry points of an operating system kernel, where the exact instruction sequence matters.
ARM64. The same asm! syntax works on an ARM64 target, with ARM64 register classes (reg gives an x register, and you ask for the 32-bit w view with {0:w}). I did not run any of it: my sandbox's Rust had only the x86-64 target installed, and I would not show output I had not seen. The structure is the same: operands, options, and clobbers, with svc #0 instead of syscall and the x8 syscall register instead of rax.
Build it (the "how")
A short checklist for writing asm! correctly:
- Prefer an intrinsic if one exists.
core::arch::x86_64::_mm_add_epi32and theaarch64NEON intrinsics are checked by the compiler and optimizable. Useasm!only when there is no intrinsic or you need an exact sequence. - Declare every input and output. If your assembly reads or writes a register, it must appear as an operand.
- Declare every clobber (
lateout(reg) _), including flags if you change them and you did not saypreserves_flags. - Be honest in the options.
nomemonly if you really never touch memory;nostackonly if you never push or use stack space. - Mind the reserved registers. Rust does not let you use
rbxas an operand on x86-64 (LLVM uses it internally), and a few others. Pick another register, or save and restore it yourself. - Keep the block small. One instruction, or a short sequence. Put the logic in Rust.
- Test with
--emit asmand look for your#APPblock. Check the registers the compiler chose, and that it did not move anything surprising around your code.
For whole functions or data, global_asm! is simpler than inline blocks, with no operand machinery: you write a function like any assembly file, and declare it extern "C" in Rust. For larger assembly, many projects keep .s files and build them with a build.rs that uses the cc crate. (I did not run that here.)
Why it is this way (the "why")
Why does Rust make you describe your assembly? Because the compiler allocates registers and moves values around across your block, and it must know what your block does so that it does not break. In a hand-written .s file, you own all the registers, so no description is needed. Inside a function, you share them with the compiler. The operand list is the contract for sharing. It is the same kind of contract as the calling convention in Chapter 14, but at a finer grain.
Why is asm! unsafe? Because the compiler cannot check it. A wrong clobber, a lying option, or an instruction that writes to memory the compiler thinks is unchanged, can silently corrupt the program. The unsafe is an honest label that you are responsible.
Why are naked functions needed, when global_asm! already exists? Naked functions live in a Rust module with a Rust name and signature and can be generic over things like const operands (and sym references), and the compiler can check the signature and handle details like linking and visibility. They are a more integrated way to write a function that is all assembly.
What would make you choose a separate assembly file instead? Very large assembly (a crypto routine, a context switch with many variants), code shared with C projects, or when you want an assembler's macros and a debugger-friendly layout. For a handful of instructions, inline is easier.
Common mistakes
- Forgetting a clobber. The compiler keeps a value in that register across your block, and your assembly destroys it.
- A false
nomemorpure. The compiler removes or reorders the block. - Using
rbxas an operand. Not allowed. - Using
asm!for something the compiler vectorizes. - A
#[repr(Rust)]struct passed to assembly (Chapter 9, 17). - A naked function that forgets
ret, or does not follow the ABI. - Expecting an
asm!block to be portable. It is specific to one architecture.
Say it simply
Rust lets you write assembly inside your program with asm! (a block in a function), global_asm! (a whole function or data) and, since Rust 1.88, naked functions. Because the compiler shares the registers with you, you must tell it truthfully what your assembly reads, writes and destroys, and what it does not do. A block is a black box to the optimizer, so using it where the compiler would have vectorized makes the code slower. Prefer an intrinsic when one exists.
Check yourself
Exercise 35.1 (Warm-up): Predict, then check. In add_asm, which operand is in a register and which is changed? Compile with --emit asm and find the #APP block. Are the registers what you expected? A correct result: {0} is the inout register and {1} is the in register. Name the actual registers in your output.
Exercise 35.2 (Warm-up): Explain. In five sentences, explain what a clobber is and why declaring it matters. Give an analogy and say where it breaks. What a strong answer includes: a register your assembly changes that is not an output; the compiler might have kept a live value there; analogy: a guest who rearranges your furniture without telling you, so you cannot find things; where it breaks: the compiler can move the furniture elsewhere if you tell it in advance.
Exercise 35.3 (Core): Implement. Using asm!, write fn read_flags_after_add(a: u64, b: u64) -> (u64, bool) that does an add and returns the sum and the carry flag (use setc). Declare everything correctly. Done when: it returns (0, true) for u64::MAX + 1, and (5, false) for 2 + 3, and your options are as strict as is honestly possible.
Exercise 35.4 (Core): Break it. Remove lateout("rcx") _, lateout("r11") _ from sys_write. Does it still work? Look at the generated assembly around the call and find a case where the compiler could have been using rcx. Write a variant where it breaks. A correct result: it may work by luck, and the exercise is to build a case (a value kept in rcx across the call, in a loop) where it fails. Explain why "it works" is not evidence of correctness.
Exercise 35.5 (Core): Implement. Write point_sum for a #[repr(C)] struct Point3 { x: i64, y: i64, z: i64 } in global_asm!, and call it from Rust. Then change the struct to the default Rust layout with fields in a different order and show that the assembly no longer matches. A correct result: the correct sum with repr(C); a wrong or garbage sum without it. Print the field offsets with std::mem::offset_of! (or pointer arithmetic) to explain.
Exercise 35.6 (Stretch): Design and defend. A project needs a fast CRC32 using the CPU's CRC instruction. Compare (a) an intrinsic, (b) asm!, (c) a separate .s file. Which would you pick, and why? What a strong answer includes: the intrinsic, because the compiler can check and optimize it and it is portable across compiler versions; asm! only if there is no intrinsic; a separate file only for very large or shared code; plus runtime feature detection so the code does not crash on a chip without the instruction.
Answer notes for Part 11
- 32.1: The distance after linking depends on the final layout. In my program it was
09 00 00 00: the call is at0x401000, 5 bytes long, and the target is at0x40100e. - 33.1: The vector instruction in
-O3x86-64 baseline output ispaddd(SSE2) onxmmregisters; on ARM64 it isadd v0.4s, v0.4s, v1.4s. - 34.1: The reported fault address for
[rbx+8]withrbx = 0is 8. Small addresses mean "null plus a field offset." - 35.4: A reliable way to expose a missing clobber is to hold a value that the compiler wants in a caller-saved register across the
asm!block in a loop; the clobbered register will then corrupt it.
Part 11 wrap-up
What you can now do:
- Say what a relocation is and how the linker applies one on each ISA.
- Recognize a PLT stub, a GOT slot, a veneer and a TLS access.
- Read optimized compiler output and name the transformations.
- Explain why undefined behavior can turn a crash into a hang.
- Debug a crash from the faulting instruction and registers.
- Write
asm!andglobal_asm!in Rust with honest operands, clobbers and options. - Say when inline assembly makes code slower.
Mini project: the post-mortem kit. Time: 6 to 10 hours.
Write five small programs with five different crashes (null read, bad write, stack overflow, divide by zero, and one hang caused by undefined behavior). For each, write a one-page post-mortem: the signal, the faulting instruction, the registers, the walk back, and the cause. Then write a small Rust program with an asm! block, a global_asm! function and a hand-checked --emit asm listing.
Done when: each post-mortem names a specific instruction and register; your Rust asm has no lie in its options and clobbers (and you explain each); and a classmate could follow your steps to reproduce every crash.
Write-up (half a page): Which crash was hardest to read, and which step of the method saved you?
Reading list for Part 11:
- John R. Levine, "Linkers and Loaders." The classic book on object files, relocations and linking.
- Ulrich Drepper, "How To Write Shared Libraries." The PLT, GOT and symbol resolution in depth.
- The ELF specification and the processor supplements for x86-64 and for ARM64 ("ELF for the Arm 64-bit Architecture"). The official relocation types.
- The Rust Reference, "Inline assembly," and the Rust blog post "Stabilizing naked functions" (July 2025). The authoritative syntax and rules.
- Matt Godbolt, "The Bits Between the Bits: How We Get to main()" (talk). What happens from the loader to your first line.
- The GDB manual, the chapters on examining machine code and watchpoints.
- "Writing a C Compiler" by Nora Sandler. Compiler construction with x86-64 output (useful for Part 12).
Teach it back. In five sentences, as if to a friend, explain how a program that calls puts finds puts at run time, and how you would find the instruction behind a segmentation fault.
Part 12: Assembly for Language Builders
This Part is for the person who builds compilers, interpreters and runtimes. Everything so far taught you to read and write assembly. Now you will generate it from an AST, run a bytecode interpreter through three dispatch strategies, and emit machine code into memory and call it.
All three chapters use code I wrote, ran, and tested for this guidebook. The programs are included in full so you can run them.
Chapter 36. A compiler backend in 130 lines: from expressions to assembly on two machines
In this chapter: You will build a small compiler that turns arithmetic expressions into x86-64 and ARM64 assembly. You will start with the simplest correct strategy, improve it with constant folding and immediates, and prove it correct against an evaluator on 300 random expressions. The method (differential testing) is as important as the code.
You need first: Chapters 14 to 19.
The problem first
You have been reading what compilers produce. Now you build one. A compiler has a front end (parse the text into a tree) and a back end (turn the tree into machine instructions). You already know how to write the front end. This chapter is about the back end: what assembly should each node of the tree become? The answer has to be correct first, and then it can be made better.
The model (the "what")
We compile a tiny language: whole-number arithmetic with +, -, *, /, negation, parentheses, and one variable x, which is the function's argument. Each expression becomes a function long f(long x) that obeys the System V convention (Chapter 14): x arrives in rdi on x86-64 and in x0 on ARM64, and the result goes back in rax or x0.
The simplest correct strategy is a stack machine. To compile a + b:
- Compile
b, leaving its value in the result register (rax). - Push it onto the stack.
- Compile
a, leaving its value inrax. - Pop
binto a second register (rcx). - Emit the operation:
add rax, rcx.
The invariant is simple: compiling any expression leaves its value in rax and changes nothing else that matters. Because the sub-expressions save their intermediate results on the stack, it works for any shape of tree, however deep, with no need to count registers. Here is the output for a small expression, real output from the program in this chapter:
source: (x+3)*4 - x/2 + 10*10 (at x=5 this is Some(130))
-- x86-64, stack machine (26 instructions):
mov rax, 10
push rax
mov rax, 10
pop rcx
imul rax, rcx
push rax
mov rax, 2
push rax
mov rax, rdi
pop rcx
cqo
idiv rcx
... It is correct, and it is wasteful. 10*10 is computed at run time, and every constant goes through a push and a pop. Two easy improvements fix most of that.
Improvement 1: constant folding. If both operands are known numbers, compute the result now, at compile time, and emit a single constant. 10*10 becomes 100. This is the "spend work at build time to save it at every run" idea from Chapter 19, applied by you. One care: if the fold would divide by zero or overflow a division, leave it for run time, so the program behaves as the language says (our evaluator returns None for those cases).
Improvement 2: immediate operands. If the right operand is a small known number, put it right in the instruction: add rax, 3 instead of "load 3, push, load x, pop, add." On x86-64 the immediate must fit 32 bits (Chapter 24), so the code checks that. Real output after both improvements:
-- x86-64, folded + immediates (13 instructions):
mov rax, 2
push rax
mov rax, rdi
pop rcx
cqo
idiv rcx
push rax
mov rax, rdi
add rax, 3
imul rax, rax, 4
pop rcx
sub rax, rcx
add rax, 100 Twenty-six instructions became thirteen. The 10*10 is now the constant 100, and (x+3)*4 is add rax, 3 and imul rax, rax, 4 with no stack traffic. The division still pays the stack cost, because x/2 has a variable on the left and a constant on the right, but the code treats only +, - and * specially. Making / use an immediate is Exercise 36.3. (It cannot simply be idiv 2: the divisor of idiv must be in a register, so you would use mov rcx, 2 and skip the push and pop.)
The ARM64 backend follows the same plan with different spelling. The differences are instructive:
-- ARM64, folded, stack machine (21 instructions):
ldr x0, =100 // the assembler picks a mov or a literal-pool load
str x0, [sp, #-16]! // push: 16 bytes, so sp stays 16-aligned (Chapter 8)
ldr x0, =2
str x0, [sp, #-16]!
mov x0, x2 // x: copied into x2 by the function's first instruction
ldr x1, [sp], #16 // pop
sdiv x0, x0, x1
... Three things differ. A push or pop moves sp by 16, not 8, because ARM64 requires sp to be a multiple of 16 whenever it is used for memory access (Chapter 8). A constant is loaded with the pseudo-instruction ldr x0, =n, which the assembler turns into either a mov or a load from a nearby literal pool (Chapter 6), so we do not have to write the movz/movk ladder ourselves. And the argument arrives in x0, which is also our accumulator, so each function begins with mov x2, x0 to save it. (We could have used the zero-instruction trick of choosing a different accumulator; x2 is simply easy to explain.)
Build it (the "how")
Here is the entire program. It is about 130 lines of Rust with no dependencies, and I compiled and ran it with rustc 1.75. It has a parser, a reference evaluator, a constant folder, three code generators, and a random test generator.
// file: calc.rs (this is the exact program I compiled and ran)
// A tiny compiler: arithmetic expressions -> assembly text for x86-64 or ARM64.
// Grammar: expr = term (('+'|'-') term)* term = factor (('*'|'/') factor)* factor = NUMBER | '-' factor | '(' expr ')'
use std::fmt::Write as _;
#[derive(Clone, Debug)]
enum Expr { Num(i64), Var, Neg(Box<Expr>), Bin(char, Box<Expr>, Box<Expr>) }
// ---------- parser ----------
struct P<'a> { s: &'a [u8], i: usize }
impl<'a> P<'a> {
fn peek(&mut self) -> Option<u8> { while self.i < self.s.len() && self.s[self.i] == b' ' { self.i += 1; } self.s.get(self.i).copied() }
fn expr(&mut self) -> Expr { let mut l = self.term();
while let Some(c) = self.peek() { if c == b'+' || c == b'-' { self.i += 1; let r = self.term(); l = Expr::Bin(c as char, Box::new(l), Box::new(r)); } else { break; } } l }
fn term(&mut self) -> Expr { let mut l = self.factor();
while let Some(c) = self.peek() { if c == b'*' || c == b'/' { self.i += 1; let r = self.factor(); l = Expr::Bin(c as char, Box::new(l), Box::new(r)); } else { break; } } l }
fn factor(&mut self) -> Expr { match self.peek() {
Some(b'-') => { self.i += 1; Expr::Neg(Box::new(self.factor())) }
Some(b'(') => { self.i += 1; let e = self.expr(); self.peek(); self.i += 1; e }
Some(b'x') => { self.i += 1; Expr::Var }
_ => { let st = self.i; while self.i < self.s.len() && self.s[self.i].is_ascii_digit() { self.i += 1; }
Expr::Num(std::str::from_utf8(&self.s[st..self.i]).unwrap().parse().unwrap()) } } }
}
// ---------- reference evaluator (wrapping, like the machine) ----------
fn eval(e: &Expr, xv: i64) -> Option<i64> { Some(match e {
Expr::Num(n) => *n, Expr::Var => xv, Expr::Neg(a) => eval(a, xv)?.wrapping_neg(),
Expr::Bin(op, a, b) => { let (x, y) = (eval(a, xv)?, eval(b, xv)?); match op {
'+' => x.wrapping_add(y), '-' => x.wrapping_sub(y), '*' => x.wrapping_mul(y),
_ => { if y == 0 || (x == i64::MIN && y == -1) { return None; } x / y } } } }) }
// ---------- constant folding: do work at compile time ----------
fn fold(e: Expr) -> Expr { match e {
Expr::Neg(a) => match fold(*a) { Expr::Num(n) => Expr::Num(n.wrapping_neg()), a => Expr::Neg(Box::new(a)) },
Expr::Bin(op, a, b) => { let (a, b) = (fold(*a), fold(*b));
if let (Expr::Num(x), Expr::Num(y)) = (&a, &b) { let t = Expr::Bin(op, Box::new(Expr::Num(*x)), Box::new(Expr::Num(*y))); if let Some(v) = eval(&t, 0) { return Expr::Num(v); } }
Expr::Bin(op, Box::new(a), Box::new(b)) }
n => n } }
// ---------- x86-64 backend ----------
// Strategy 1 ("stack machine"): evaluate rhs, push; evaluate lhs; pop. Simple and always correct.
fn x86_stack(e: &Expr, o: &mut String) { match e {
Expr::Num(n) => { writeln!(o, " mov rax, {}", n).unwrap(); }
Expr::Var => { o.push_str(" mov rax, rdi\n"); }
Expr::Neg(a) => { x86_stack(a, o); o.push_str(" neg rax\n"); }
Expr::Bin(op, a, b) => { x86_stack(b, o); o.push_str(" push rax\n"); x86_stack(a, o); o.push_str(" pop rcx\n"); x86_op(*op, o); } } }
fn x86_op(op: char, o: &mut String) { o.push_str(match op {
'+' => " add rax, rcx\n", '-' => " sub rax, rcx\n", '*' => " imul rax, rcx\n", _ => " cqo\n idiv rcx\n" }); }
// Strategy 2: if the right operand is a small constant, use it as an immediate and skip push/pop.
fn fits32(n: i64) -> bool { n >= i32::MIN as i64 && n <= i32::MAX as i64 }
fn x86_imm(e: &Expr, o: &mut String) { match e {
Expr::Num(n) => { writeln!(o, " mov rax, {}", n).unwrap(); }
Expr::Var => { o.push_str(" mov rax, rdi\n"); }
Expr::Neg(a) => { x86_imm(a, o); o.push_str(" neg rax\n"); }
Expr::Bin(op, a, b) => match (&**b, op) {
(Expr::Num(n), '+') if fits32(*n) => { x86_imm(a, o); writeln!(o, " add rax, {}", n).unwrap(); }
(Expr::Num(n), '-') if fits32(*n) => { x86_imm(a, o); writeln!(o, " sub rax, {}", n).unwrap(); }
(Expr::Num(n), '*') if fits32(*n) => { x86_imm(a, o); writeln!(o, " imul rax, rax, {}", n).unwrap(); }
_ => { x86_imm(b, o); o.push_str(" push rax\n"); x86_imm(a, o); o.push_str(" pop rcx\n"); x86_op(*op, o); } } } }
// ---------- ARM64 backend (stack machine; sp must stay 16-aligned, so push/pop 16 bytes) ----------
fn arm(e: &Expr, o: &mut String) { match e {
Expr::Num(n) => { writeln!(o, " ldr x0, ={}", n).unwrap(); }
Expr::Var => { o.push_str(" mov x0, x2\n"); }
Expr::Neg(a) => { arm(a, o); o.push_str(" neg x0, x0\n"); }
Expr::Bin(op, a, b) => { arm(b, o); o.push_str(" str x0, [sp, #-16]!\n"); arm(a, o); o.push_str(" ldr x1, [sp], #16\n");
o.push_str(match op { '+' => " add x0, x0, x1\n", '-' => " sub x0, x0, x1\n", '*' => " mul x0, x0, x1\n", _ => " sdiv x0, x0, x1\n" }); } } }
// ---------- random expression generator (xorshift) ----------
struct R(u64);
impl R { fn next(&mut self) -> u64 { self.0 ^= self.0 << 13; self.0 ^= self.0 >> 7; self.0 ^= self.0 << 17; self.0 } }
fn gen(r: &mut R, d: u32) -> String {
if d == 0 || r.next() % 5 == 0 { return if r.next() % 5 < 2 { "x".to_string() } else { format!("{}", r.next() % 1000) }; }
match r.next() % 6 { 0 => format!("({}+{})", gen(r, d-1), gen(r, d-1)), 1 => format!("({}-{})", gen(r, d-1), gen(r, d-1)),
2 => format!("({}*{})", gen(r, d-1), gen(r, d-1)), 3 => format!("({}/{})", gen(r, d-1), gen(r, d-1)),
4 => format!("(-{})", gen(r, d-1)), _ => format!("({}+{})", gen(r, d-1), r.next() % 100) } }
fn count(s: &str) -> usize { s.lines().filter(|l| l.starts_with(" ")).count() }
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 && args[1] == "show" {
let src = args[2].as_str(); let e = { let mut p = P { s: src.as_bytes(), i: 0 }; p.expr() };
println!("source: {} (at x=5 this is {:?})\n", src, eval(&e, 5));
let mut a = String::new(); x86_stack(&e, &mut a); println!("-- x86-64, stack machine ({} instructions):\n{}", count(&a), a);
let f = fold(e.clone()); let mut b = String::new(); x86_imm(&f, &mut b); println!("-- x86-64, folded + immediates ({} instructions):\n{}", count(&b), b);
let mut c = String::new(); arm(&f, &mut c); println!("-- ARM64, folded, stack machine ({} instructions):\n{}", count(&c), c);
return; }
// batch mode: write f0..fN for BOTH backends and the expected results
let mut r = R(0x9E3779B97F4A7C15); let (mut sx, mut sx2, mut sa, mut exp) = (String::new(), String::new(), String::new(), String::new());
sx.push_str(".intel_syntax noprefix\n.text\n"); sx2.push_str(".intel_syntax noprefix\n.text\n"); sa.push_str(".text\n");
let mut n = 0; let (mut tot1, mut tot2) = (0, 0);
while n < 300 { let src = gen(&mut r, 5); let e = { let mut p = P { s: src.as_bytes(), i: 0 }; p.expr() };
let xs = [0i64, 1, -1, 7, -13, 100, 12345]; let vals: Vec<Option<i64>> = xs.iter().map(|&x| eval(&e, x)).collect(); if vals.iter().any(|v| v.is_none()) { continue; } let v = vals.iter().map(|v| v.unwrap().to_string()).collect::<Vec<_>>().join(" ");
let f = fold(e.clone()); let (mut a, mut b, mut c) = (String::new(), String::new(), String::new());
x86_stack(&e, &mut a); x86_imm(&f, &mut b); arm(&f, &mut c); tot1 += count(&a); tot2 += count(&b);
writeln!(sx, ".global f{n}\nf{n}:\n{a} ret").unwrap(); writeln!(sx2, ".global f{n}\nf{n}:\n{b} ret").unwrap(); writeln!(sa, ".global f{n}\nf{n}:\n mov x2, x0\n{c} ret").unwrap();
writeln!(exp, "{}", v).unwrap(); n += 1; }
std::fs::write("x86_stack.s", sx).unwrap(); std::fs::write("x86_imm.s", sx2).unwrap(); std::fs::write("arm.s", sa).unwrap(); std::fs::write("expected.txt", exp).unwrap();
println!("generated 300 random expressions; total instructions: stack machine {}, folded+immediates {} ({:.0}% fewer)", tot1, tot2, 100.0 * (1.0 - tot2 as f64 / tot1 as f64));
} Test it on one expression: ./calc show "(x+3)*4 - x/2 + 10*10" prints the output above.
The test that matters. A compiler that produces plausible assembly is worth nothing if the assembly is wrong. How do you know it is right? You do not stare at output. You compare it with something you trust. The Rust function eval computes what each expression should give. The batch mode of the program generates 300 random expressions (each up to 5 levels deep), and for each one:
- Evaluates it in Rust at seven values of
x(0, 1, -1, 7, -13, 100, 12345). Any expression that would divide by zero for one of those is thrown away. - Compiles it with all three backends into one assembly file per backend, with a function
f0tof299in each. - Assembles each file, links it with a small C program that calls every function at the seven values and prints the results, and runs it.
- Compares the printed results with Rust's, line by line.
The result:
generated 300 random expressions; total instructions: stack machine 6766, folded+immediates 3569 (47% fewer)
x86-64 x86_stack : all 300 x 7 results match the evaluator
x86-64 x86_imm : all 300 x 7 results match the evaluator
ARM64 (qemu) : all 300 x 7 results match the evaluator All 2,100 results matched on all three backends, and the two x86-64 strategies differed by 47% in instruction count (6,766 against 3,569) while giving identical answers. That is differential testing: run two implementations (here, a compiler and an interpreter) on the same random inputs and flag any disagreement. It finds bugs that no hand-written test would think of, and it let me change the code generator freely, because every change was checked against the same 2,100 results.
Why it is this way (the "why")
Why start with a stack machine? Because you can check it by eye, and correctness is hard to add later. Every sub-expression has the same contract (value in rax), so the pieces compose without any thought. A stack machine also never runs out of registers, which is the problem real compilers spend much of their effort on. Starting from a correct, slow version and improving it step by step, with the tests running at each step, is how most compilers get built.
Why is it slow? Because push and pop go through memory (the stack), and a chain of them creates a dependency through memory (Chapter 26 measured a store-and-reload at several times the cost of staying in a register). The immediate form avoids it.
What does a real compiler do beyond this? It keeps values in registers across many instructions, which means register allocation: deciding which values live in which registers, and what to spill to the stack when they do not all fit. Two well-known approaches are graph coloring and linear scan. It also chooses instructions with cleverness (lea for x*5, a shift for x*8, the magic-number trick for division by a constant), orders instructions to hide latency (Chapter 26), and rewrites the program before it ever reaches this stage. I did not build any of that here, and you can add each one as an exercise, testing it against the same 2,100 results.
What would you need for a real language? Variables beyond x; comparisons and branches (cmp and jcc, setcc, cmov, from Chapters 10 to 12); calls (obeying the ABI: arguments in rdi, rsi, ..., a 16-byte aligned stack at every call, and saving callee-saved registers; Chapters 14 to 16); and .cfi_ directives so a debugger can see your frames (Chapter 15). Every one of those is a chapter you have already read.
Common mistakes
- Trusting output by eye. Test against an evaluator.
- Folding division by zero at compile time. The program should behave as the language says (here, leave it for run time).
- Forgetting
cqobeforeidiv. Without it, the high half is garbage (Chapter 19). - An ARM64
pushof 8 bytes.spmust stay 16-aligned. - Immediates that do not fit. An x86-64
imuloraddimmediate is 32 bits. - Not testing the extremes. Zero, negative numbers, large numbers that wrap.
- Optimizing before it is correct.
Say it simply
A backend turns each node of the tree into instructions. The simplest correct way is a stack machine: compute the right side, push it, compute the left side, pop, and operate. Constant folding and immediate operands then cut the instruction count by about half. The way to know it is correct is to compile many random programs and compare their results against a simple evaluator.
Check yourself
Exercise 36.1 (Warm-up): Predict, then check. Predict the output of ./calc show "x*8 + x*8" for the folded x86-64 backend. How many instructions? Then check. A correct result: it uses the immediate form for each * 8 (an imul rax, rax, 8) and a push and pop for the sum of two non-constant operands. Name each instruction and count them.
Exercise 36.2 (Warm-up): Explain. In five sentences, explain why the stack machine is correct for any expression. Give an analogy and say where it breaks. What a strong answer includes: every sub-expression leaves its value in one register and restores the rest; intermediate values wait on the stack; analogy: a cook who writes each partial result on a card and stacks the cards; where it breaks: the stack lives in memory, which is slower than keeping the values on the counter (in registers).
Exercise 36.3 (Core): Implement. Make the x86-64 backend use an immediate for / when the divisor is a nonzero constant: mov rcx, n; cqo; idiv rcx with no push or pop. Then replace x * 2^k with shl rax, k. Re-run the 300-expression test. Done when: all results still match, and the instruction total drops further. Report both totals.
Exercise 36.4 (Core): Implement. Add a second variable y (the second argument: rsi on x86-64, x1 on ARM64, careful that your accumulator and scratch registers must not collide with it). Extend the parser, evaluator and both backends, and the test (two arguments). Done when: the 2,100-result style test passes with the new variable on both machines. Explain which registers you chose for the accumulator and scratch on ARM64, and why they avoid x0 and x1.
Exercise 36.5 (Core): Break it. Remove cqo from the division code. Run the test. Which results fail, and why? A correct result: negative dividends fail (a wrong high half gives an overflow trap or a wrong quotient); some positives may pass by luck, so the random test is what catches it.
Exercise 36.6 (Stretch): Design and defend. Add comparisons and if expressions (x < 3 ? a : b). Show the x86-64 code for both a branch version and a cmov version, and say which your compiler should choose and how it would decide. What a strong answer includes: the code shape for each (Chapter 12); the rule that cmov evaluates both arms, so it is only valid if both are safe to compute (no division by zero in the untaken arm); and a decision based on the arms' cost and the predictability of the condition, ideally from a profile.
Chapter 37. Interpreter dispatch: switch, computed goto and tail calls
In this chapter: You will build a bytecode interpreter's main loop three ways (a switch, computed goto, and tail calls) and time each on two programs. You will see why the textbook claim "computed goto is faster" is only half true, and learn to look at the indirect jumps in the assembly.
You need first: Chapters 12, 13 and 26.
The problem first
An interpreter's inner loop does one thing over and over: fetch the next bytecode, jump to the code that handles it. That jump is an indirect branch (Chapter 13): its target depends on data, the opcode. We saw in Chapter 12 that a mispredicted branch costs around 20 cycles, and an indirect branch with many targets is harder to predict than a two-way branch. In an interpreter, where each bytecode might do only a few cycles of real work, that dispatch jump can be most of the cost.
The model (the "what")
Here are the three common ways to write the dispatch.
1. A loop around a switch. The simplest. The compiler turns the switch into a jump table (Chapter 13). All opcodes go through one shared indirect jump at the top of the loop.
2. Computed goto ("threaded code"). A GNU extension: take the address of a label with &&label, and jump to it with goto *ptr. You put the whole dispatch (fetch the next opcode, goto *table[opcode]) at the end of every handler. So there are as many indirect jumps as handlers, one per opcode.
3. Tail calls. Make each handler a separate function that ends by calling the next handler as its last action, with the compiler guaranteed to turn that call into a plain jmp (no new stack frame). In Clang, this is the attribute __attribute__((musttail)). Each handler ends with its own dispatch, as in computed goto, but now each handler is an ordinary function with arguments in registers, which makes the compiler's job easier.
The idea behind choices 2 and 3 is about prediction. The CPU predicts each indirect jump using the history of where that jump went before. If all dispatches come from one jump instruction, its history is a blur of every opcode in the program. If each handler has its own jump, the history of "what follows an add" is its own, and patterns in real programs (an add is often followed by a particular next instruction) become easy to learn.
Here is the whole test program: a tiny register machine with seven opcodes (LOADI, ADD, MUL, XOR, DEC, JNZ, HALT), and the three dispatch loops. I compiled it with both gcc -O2 and clang -O2.
// file: vm2.c (this is the exact program I compiled and ran)
#include <stdio.h>
#include <stdint.h>
#include <time.h>
typedef struct { uint8_t op, a, b, pad; int32_t imm; } Ins;
enum { LOADI, ADD, MUL, XOR, DEC, JNZ, HALT, NOPS };
typedef uint64_t u64;
// ---------- 1. a loop around one big switch ----------
__attribute__((noinline)) u64 run_switch(const Ins *code, u64 *r) {
const Ins *pc = code;
for (;;) { Ins i = *pc++;
switch (i.op) {
case LOADI: r[i.a] = (u64)(int64_t)i.imm; break;
case ADD: r[i.a] += r[i.b]; break;
case MUL: r[i.a] *= r[i.b]; break;
case XOR: r[i.a] ^= r[i.b]; break;
case DEC: r[i.a] -= 1; break;
case JNZ: if (r[i.a]) pc = code + i.imm; break;
case HALT: return r[0]; } } }
// ---------- 2. computed goto ("threaded code"): a separate indirect jump at the end of EVERY handler ----------
__attribute__((noinline)) u64 run_goto(const Ins *code, u64 *r) {
static const void *T[NOPS] = { &&L_LOADI, &&L_ADD, &&L_MUL, &&L_XOR, &&L_DEC, &&L_JNZ, &&L_HALT };
const Ins *pc = code; Ins i;
#define NEXT do { i = *pc++; goto *T[i.op]; } while (0)
NEXT;
L_LOADI: r[i.a] = (u64)(int64_t)i.imm; NEXT;
L_ADD: r[i.a] += r[i.b]; NEXT;
L_MUL: r[i.a] *= r[i.b]; NEXT;
L_XOR: r[i.a] ^= r[i.b]; NEXT;
L_DEC: r[i.a] -= 1; NEXT;
L_JNZ: if (r[i.a]) pc = code + i.imm; NEXT;
L_HALT: return r[0];
}
#ifdef __clang__
// ---------- 3. tail calls: every handler is a function that jumps to the next one ----------
typedef u64 (*H)(const Ins *pc, u64 *r, const Ins *code);
static H TT[NOPS];
#define DISPATCH __attribute__((musttail)) return TT[pc->op](pc, r, code)
static u64 h_loadi(const Ins *pc, u64 *r, const Ins *code){ r[pc->a] = (u64)(int64_t)pc->imm; pc++; DISPATCH; }
static u64 h_add (const Ins *pc, u64 *r, const Ins *code){ r[pc->a] += r[pc->b]; pc++; DISPATCH; }
static u64 h_mul (const Ins *pc, u64 *r, const Ins *code){ r[pc->a] *= r[pc->b]; pc++; DISPATCH; }
static u64 h_xor (const Ins *pc, u64 *r, const Ins *code){ r[pc->a] ^= r[pc->b]; pc++; DISPATCH; }
static u64 h_dec (const Ins *pc, u64 *r, const Ins *code){ r[pc->a] -= 1; pc++; DISPATCH; }
static u64 h_jnz (const Ins *pc, u64 *r, const Ins *code){ pc = r[pc->a] ? code + pc->imm : pc + 1; DISPATCH; }
static u64 h_halt (const Ins *pc, u64 *r, const Ins *code){ return r[0]; }
__attribute__((noinline)) u64 run_tail(const Ins *code, u64 *r) {
TT[LOADI]=h_loadi; TT[ADD]=h_add; TT[MUL]=h_mul; TT[XOR]=h_xor; TT[DEC]=h_dec; TT[JNZ]=h_jnz; TT[HALT]=h_halt;
return TT[code->op](code, r, code); }
#endif
static double now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);return t.tv_sec+t.tv_nsec*1e-9;}
#include <stdlib.h>
static void bench(const char *title, Ins *prog, long dispatches){
const char *names[] = {"switch", "computed goto", "tail calls"};
u64 (*fns[])(const Ins*, u64*) = { run_switch, run_goto,
#ifdef __clang__
run_tail
#else
0
#endif
};
printf("%s\n", title); u64 ref = 0;
for (int k = 0; k < 3; k++) { if (!fns[k]) continue;
double best = 1e9; u64 res = 0;
for (int q = 0; q < 5; q++) { u64 r[8] = {0}; double t = now(); res = fns[k](prog, r); t = now() - t; if (t < best) best = t; }
if (k == 0) ref = res;
printf(" %-14s %5.2f ns per VM instruction %s\n", names[k], best / dispatches * 1e9, res==ref?"":"(DIFFERENT RESULT!)"); } }
int main(void){
long ITER = 20000000;
Ins p1[] = { {LOADI,1,0,0,(int32_t)ITER}, {LOADI,2,0,0,3}, {LOADI,0,0,0,1},
{ADD,0,2,0,0}, {MUL,0,2,0,0}, {XOR,0,1,0,0}, {DEC,1,0,0,0}, {JNZ,1,0,0,3}, {HALT,0,0,0,0} };
bench("A. periodic program: the same 5 opcodes in a loop", p1, 3 + ITER * 5 + 1);
// B. 1000 pseudo-random ops (ADD/MUL/XOR) in a straight line, then a loop counter: the opcode sequence is long and irregular
enum { BODY = 1000, OUTER = 100000 };
Ins *p2 = malloc((BODY + 8) * sizeof(Ins)); int n = 0; srand(1);
p2[n++] = (Ins){LOADI,1,0,0,OUTER}; p2[n++] = (Ins){LOADI,2,0,0,3}; p2[n++] = (Ins){LOADI,0,0,0,1}; int top = n;
for (int i = 0; i < BODY; i++) { int o = rand() % 3; p2[n++] = (Ins){o==0?ADD:o==1?MUL:XOR, 0, (uint8_t)(1 + rand()%2), 0, 0}; }
p2[n++] = (Ins){DEC,1,0,0,0}; p2[n++] = (Ins){JNZ,1,0,0,top}; p2[n++] = (Ins){HALT,0,0,0,0};
bench("B. irregular program: 1000 pseudo-random opcodes per loop trip", p2, 3 + (long)OUTER * (BODY + 2) + 1);
} I ran two different bytecode programs, because the result depends on which one:
- A. A periodic program: five opcodes (
ADD, MUL, XOR, DEC, JNZ) in a tight loop, 100 million VM instructions in all. The sequence is perfectly regular. - B. An irregular program: 1,000 pseudo-random opcodes (
ADD,MUL,XOR) in a line, looped 100,000 times. The sequence is long and has no short pattern.
All three versions produced identical results on both programs. I ran everything three times, with the best of five inside each run. The numbers were stable across runs. In nanoseconds per VM instruction:
gcc switch | gcc computed goto | clang switch | clang computed goto | clang tail calls | |
|---|---|---|---|---|---|
| A. periodic | 1.64 | 1.32 | 1.54 | 2.05 | 1.39 |
| B. irregular | 6.4 | 4.8 | 6.0 | 2.8 | 3.0 |
Read the table row by row.
Row B (irregular) shows the textbook effect, and it is large. With clang, switch took 6.0 ns per instruction, computed goto took 2.8 ns (2.2 times faster), and tail calls 3.0 ns (2 times faster). With gcc, computed goto was 1.3 times faster. Per-handler dispatch jumps predict far better than one shared jump, on a sequence the predictor cannot memorize.
Row A (periodic) tells a different story, and it is the reason to be careful with folk wisdom. On the regular program, gcc still shows a modest gain for computed goto (1.64 to 1.32). But with clang, computed goto was slower than switch (2.05 against 1.54), and the tail-call version only modestly better. A five-opcode loop is easy enough that even a shared indirect jump can be predicted well from history, so the prediction benefit mostly disappears, and what is left is the quality of the code each compiler generated.
Why the numbers differ between compilers: the indirect jumps. I counted the indirect jmp instructions inside each compiled function:
gcc run_switch : 1 indirect jmp
gcc run_goto : 3 indirect jmps <-- we wrote 7 handlers, each with its own dispatch; gcc kept 3
clang run_switch : 1
clang run_goto : 7 <-- clang kept all 7
clang run_tail : 1 in the run function (each handler function ends with its own jmp) Look at gcc: we wrote seven handlers, each ending in goto *T[i.op], but the compiled function has three indirect jumps. The compiler merged some of them back together, partly undoing what we wrote. clang kept all seven, yet it ran slower on program A, so the number of jump instructions is not the whole story: the rest of the code in each handler (extra moves, how the opcode fields are decoded) matters too. Here is the end of one handler in gcc's computed-goto build (real disassembly):
mov QWORD PTR [rsi+rdx*8], r10 # (the handler's own work: store its result)
movzx ecx, BYTE PTR [rax] # the next opcode
movzx edx, BYTE PTR [rax+0x1] # (decoding the next instruction's fields ahead of time)
add rax, 0x8 # pc += one instruction
movzx r9d, BYTE PTR [rax-0x6]
movsxd r10, DWORD PTR [rax-0x4]
mov rcx, QWORD PTR [r8+rcx*8] # look up the next handler's address in the table
jmp rcx # the indirect jump: repeated at the end of this handler And here is a whole tail-call handler from clang, the ADD instruction (real disassembly). It is a complete function: it does its work and then dispatches to the next handler by an indirect jump, with no stack frame at all.
h_add:
movzx eax, BYTE PTR [rdi+0x2] # the second register number
mov rax, QWORD PTR [rsi+rax*8] # r[b]
movzx ecx, BYTE PTR [rdi+0x1] # the first register number
add QWORD PTR [rsi+rcx*8], rax # r[a] += r[b]
movzx eax, BYTE PTR [rdi+0x8] # the NEXT opcode (at pc + 8)
add rdi, 0x8 # pc++
lea rcx, [rip+0x2c01] # the dispatch table (TT)
mov rax, QWORD PTR [rcx+rax*8] # the next handler's address
jmp rax # tail call: a plain jump, no new frame Count it: nine instructions, including the dispatch. This is close to the minimum for an interpreter, and it is the shape that fast modern interpreters aim for.
Build it (the "how")
What to do with all this when you build an interpreter:
- Start with the
switch. It is portable and simple, and often fast enough. - Measure on your real programs. Do not benchmark only a tight loop. A periodic benchmark hides the effect you are trying to improve. Use a representative mix of bytecode.
- If dispatch is the bottleneck, try computed goto (GCC and Clang) or tail calls (Clang), and look at the assembly. Check that the indirect jumps are really in each handler.
- Reduce the number of dispatches. Fewer, fatter bytecodes beat faster dispatch. Superinstructions (a single
DEC_JNZin place ofDECthenJNZ) cut the dispatch count directly. - Keep the state in registers. In the tail-call version, the VM state (
pc, the register file, the code base) arrives in argument registers and stays there across handlers. That is why it can be so compact.
Notice what the tail-call result needs from the compiler: a guarantee that the call becomes a jump. In C and C++ that is musttail (in Clang). As far as I know, stable Rust does not have an equivalent guarantee, and a Rust interpreter usually uses a match inside a loop, which compiles to the first style. I did not test Rust interpreter dispatch here, so check this against current Rust if it matters to you.
Why it is this way (the "why")
Why does a shared dispatch jump predict badly? The predictor sees one instruction jumping to many places in an order determined by the bytecode. The record of recent targets is the only clue. On an irregular program, that clue is nearly useless. A per-handler jump gets a separate record in which "what comes after ADD" is much more regular. It is the same reason a branch in a loop predicts well when the data is sorted (Chapter 12): the history carries information.
Why can the compiler undo your per-handler dispatch? Because merging jumps saves code size, and the compiler does not know the prediction cost. (The gcc result, 7 handlers and 3 jumps, is a case of that.) This is why the tail-call form is attractive: each handler is its own function, so the compiler cannot merge them.
What would make computed goto the wrong choice? If the compiler does not support it (MSVC does not have &&label), if the bytecode is regular enough that a shared jump predicts fine, or if your handlers are large and the dispatch is a small part of the cost.
The lesson about measurement. The same technique was a 2.2 times win, a modest win, and a 33% loss, depending on the compiler and the program. The only way to know is to run it. Chapter 28 told you how.
Common mistakes
- Believing "computed goto is faster" without measuring.
- Benchmarking only a regular loop.
- Not looking at the assembly to see how many indirect jumps the compiler kept.
- Optimizing dispatch when the handlers dominate the time.
- Using tail calls without a guarantee (without
musttail, the compiler may build a stack frame, and a deep program overflows the stack).
Say it simply
An interpreter's main loop is an indirect jump on the opcode. A switch gives every opcode one shared jump. Computed goto and tail calls give each handler its own jump, which helps the CPU predict. In my tests, on an irregular program that was 2.2 times faster with clang, but on a periodic one, clang's computed goto was slower than the switch. Count the indirect jumps in the assembly, and measure on realistic programs.
Check yourself
Exercise 37.1 (Warm-up): Predict, then check. Before running anything, predict which of the three styles wins on the irregular program and which on the periodic one. Then compile vm2.c with gcc and clang and run both. A correct result: the per-handler styles win on the irregular program. On the periodic one, results depend on the compiler. Report your numbers.
Exercise 37.2 (Warm-up): Explain. In five sentences, explain why one shared dispatch jump predicts worse than one per handler. Give an analogy and say where it breaks. What a strong answer includes: the predictor uses the jump's own history; one jump's history mixes all opcodes; per-handler history captures what follows each opcode; analogy: guessing a stranger's next word versus guessing the word after "thank"; where it breaks: a modern predictor also looks at the global branch history, so a shared jump is not hopeless on short patterns.
Exercise 37.3 (Core): Measure. Count the indirect jumps in each compiled interpreter with objdump. Then add a fourth, hand-tuned variant: write the switch so that the dispatch is replicated at the end of each case (a goto to the top is replaced by the dispatch code). Does the compiler keep it? Does it help? A correct result: a table of jump counts and times. Say whether your replicated version is merged back.
Exercise 37.4 (Core): Implement. Add a superinstruction DEC_JNZ a, target that decrements and branches in one bytecode. Rewrite program A to use it and measure each dispatch style again. Done when: the result is identical and the dispatch count per loop trip drops from 5 to 4. Report the new times and say how much of the saving came from fewer dispatches.
Exercise 37.5 (Core): Implement. Write the same VM in Rust with a loop { match op { ... } } and look at its assembly with --emit asm. How many indirect jumps are there? Time it against the C switch version. A correct result: one shared indirect jump, and a speed close to the C switch. Say what you learned about whether Rust has a computed goto.
Exercise 37.6 (Stretch): Design and defend. You are designing the bytecode for a new language, to run on both x86-64 and ARM64. Choose between a stack-based and a register-based bytecode, and between fixed-width and variable-width instructions. Defend each choice using dispatch cost and decoding cost. What a strong answer includes: register bytecode needs fewer dispatches (no push and pop instructions) but larger instructions; fixed width makes decoding trivial (the same argument as ARM64's encoding, Chapter 25); a measured comparison would settle it.
Chapter 38. A tiny JIT: writing machine code into memory and calling it
In this chapter: You will emit real machine code bytes into a page of memory and call them as a function, on both x86-64 and ARM64. You will learn the rules that make this legal (write XOR execute, and the instruction cache on ARM64), see what each mistake does, and measure what creating code at run time costs.
You need first: Chapters 23, 24, 25 and 36.
The problem first
An interpreter (Chapter 37) pays a dispatch for every bytecode. A compiler (Chapter 36) pays nothing at run time, but it must know the program ahead of time. A JIT (just-in-time compiler) sits between them: it compiles code while the program runs, when it knows what the program is actually doing, and then runs the result at full speed. To do this, you need to be able to write instructions into memory and jump to them. That is the idea from Chapter 2: instructions are just bytes.
The model (the "what")
The steps are always the same:
- Get a page of memory you are allowed to write (
mmap, Chapter 23). - Write machine code bytes into it.
- Change the page so it can be executed (
mprotect). - Cast its address to a function pointer and call it.
Here is the x86-64 version as a complete, runnable program. It emits the machine code for f(x) = x * 3 + 7 by hand, using the encodings from Chapter 24.
// file: jit_x86.c (this is the exact program I compiled and ran)
#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
typedef long (*Fn)(long);
// Emit machine code for f(x) = x * mul + add (System V: x arrives in rdi, result goes in rax)
static size_t emit(uint8_t *p, int32_t mul, int32_t add) {
size_t n = 0;
p[n++]=0x48; p[n++]=0x69; p[n++]=0xC7; memcpy(p+n,&mul,4); n+=4; // imul rax, rdi, mul
p[n++]=0x48; p[n++]=0x05; memcpy(p+n,&add,4); n+=4; // add rax, add
p[n++]=0xC3; // ret
return n; }
int main(void) {
// 1. get a page we may WRITE (not execute)
uint8_t *page = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
size_t len = emit(page, 3, 7);
// 2. flip it to EXECUTE (not write): "write XOR execute"
if (mprotect(page, 4096, PROT_READ | PROT_EXEC) != 0) { perror("mprotect"); return 1; }
Fn f = (Fn)page;
printf("jitted f(x) = x*3 + 7: f(10) = %ld, f(-4) = %ld (%zu bytes of code)\n", f(10), f(-4), len);
FILE *fp = fopen("jit_bytes.bin", "wb"); fwrite(page, 1, len, fp); fclose(fp);
// 3. what happens if we try to WRITE to the code now?
pid_t c = fork(); if (c == 0) { page[0] = 0x90; _exit(0); }
int st; waitpid(c, &st, 0); printf("writing to the now-executable page: %s\n", WIFSIGNALED(st) ? "killed by SIGSEGV (as it should be)" : "allowed (!)");
// 4. and what if we had never flipped it: try to EXECUTE the writable page
uint8_t *w = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); emit(w, 3, 7);
c = fork(); if (c == 0) { ((Fn)w)(1); _exit(0); }
waitpid(c, &st, 0); printf("running code on a page that was never made executable: %s\n", WIFSIGNALED(st) ? "killed by SIGSEGV" : "ran (!)");
return 0; } I ran it. Real output:
jitted f(x) = x*3 + 7: f(10) = 37, f(-4) = -5 (14 bytes of code)
writing to the now-executable page: killed by SIGSEGV (as it should be)
running code on a page that was never made executable: killed by SIGSEGV The function works: f(10) = 37 and f(-4) = -5. Let's check the bytes. I saved them to a file and asked objdump to decode them as raw x86-64:
0: 48 69 c7 03 00 00 00 imul rax,rdi,0x3
7: 48 05 07 00 00 00 add rax,0x7
d: c3 ret Those are the 14 bytes we wrote, and the disassembler reads them as exactly the three instructions we intended. In the code above, 0x48 0x69 0xC7 is REX.W, the opcode for imul with a 32-bit immediate, and a ModRM byte for rax and rdi. Then 0x48 0x05 is REX.W and the short add rax, imm32 form for rax (Chapter 24). Every byte came from a table in Chapter 24. The argument arrives in rdi and the result leaves in rax, the convention from Chapter 14.
The rules.
- Write XOR execute (W^X). Modern systems do not let a page be writable and executable at the same time. Write the code while the page is read-write, then flip it to read-execute. The second line of output shows the rule at work: after the flip, writing to the page killed the process.
- A page must be executable to run. The third line shows the other mistake: calling code on a page that was never flipped. It also dies. Both of these mistakes produce a
SIGSEGVwith no helpful message, so recognize them. - Some systems restrict this further. Security policies on some systems do not allow a page to become executable at all unless the program has special permission. On Apple's ARM64 platforms, as I understand it, JIT code requires a special memory flag and a call to toggle write protection for each thread. I did not test those platforms here, so read your platform's documentation.
ARM64 has one more rule: the instruction cache. On x86-64 the hardware keeps the instruction cache consistent with writes, so freshly written code is seen at once. On ARM64 it does not. The core may still hold old bytes in its instruction cache, and may run stale instructions. So after writing code, you must tell the system to make the instruction cache see it. In C, with GCC or Clang, that is __builtin___clear_cache(start, end), which emits the right cache-maintenance instructions.
Here is the ARM64 version. The four instruction words came from the assembler (I assembled mov x1, #3; mul x0, x0, x1; add x0, x0, #7; ret and read the encodings with objdump, so I did not write the bits by hand):
// file: jit_arm.c (this is the exact program I compiled and ran)
#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <sys/mman.h>
typedef long (*Fn)(long);
int main(void){
uint32_t code[] = { 0xd2800061 /* mov x1,#3 */, 0x9b017c00 /* mul x0,x0,x1 */, 0x91001c00 /* add x0,x0,#7 */, 0xd65f03c0 /* ret */ };
uint8_t *page = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
for (int i = 0; i < 4; i++) ((uint32_t*)page)[i] = code[i];
__builtin___clear_cache((char*)page, (char*)page + sizeof code); // REQUIRED on ARM64: make the instruction cache see the new bytes
mprotect(page, 4096, PROT_READ|PROT_EXEC);
Fn f = (Fn)page; printf("ARM64 jitted f(10) = %ld, f(-4) = %ld\n", f(10), f(-4)); return 0; } Real output, under the emulator qemu-user:
0: d2800061 mov x1, #0x3
4: 9b017c00 mul x0, x0, x1
8: 91001c00 add x0, x0, #0x7
c: d65f03c0 ret
ARM64 jitted f(10) = 37, f(-4) = -5 It works, with the same two answers. One honest limit: an emulator does not model the instruction cache the way real hardware does, so my run does not prove the __builtin___clear_cache call is sufficient on a real chip. It shows the logic is right. The requirement itself comes from the architecture, and on real ARM64 hardware, forgetting the call leads to intermittent crashes that vanish under a debugger, which is a miserable bug to find.
What does creating code cost? I measured the whole cycle for one tiny function: map a fresh page, emit, flip to executable, call once, unmap. I ran it three times:
create + emit + protect + call + free one JIT function: 8.3, 7.8 and 7.8 microseconds
an ordinary call through a function pointer: 1.98, 1.85 and 1.86 nanoseconds About 8 microseconds for the JIT cycle, against about 1.9 nanoseconds for a call. The JIT cycle costs as much as roughly 4,000 ordinary calls. Almost all of it is the three system calls (mmap, mprotect, munmap), which Chapter 23 measured at around 100 ns each or more, plus the page being brand new. A real JIT does not do this per function. It maps one large region once, emits many functions into it, and flips permissions rarely, so the per-function cost is much smaller. But the lesson stands: generating code is not free. A JIT pays off when the code it generates runs many times, so that the saving per run, multiplied by the number of runs, exceeds the cost of generating it. That is why JITs start by interpreting (Chapter 37) and compile only the hot code, the parts that run often.
Build it (the "how")
To turn the Chapter 36 compiler into a JIT, change the output from text to bytes. Where the compiler printed " add rax, rcx\n", the JIT appends the bytes 48 01 C8. You need a small table of encodings, which you now know how to build (Chapter 24):
| Instruction | Bytes |
|---|---|
mov rax, rdi | 48 89 F8 |
add rax, rcx | 48 01 C8 |
imul rax, rcx | 48 0F AF C1 |
push rax / pop rcx | 50 / 59 |
ret | C3 |
Check each one against the assembler before you trust it. (Assemble the instruction with as, and read it back with objdump, exactly as I did with the 14 bytes above.) And remember the two special cases from Chapter 24: [rsp] and [r12] need a SIB byte, [rbp] and [r13] need a displacement, so a JIT that emits memory operands must handle them.
A rule that serves every JIT author: keep an assembler or disassembler in your test loop. After emitting, dump the buffer to a file, run objdump -D -b binary -mi386:x86-64 -M intel on it, and read it. This is the only reliable way to find a wrong byte.
Why it is this way (the "why")
Why is writable-and-executable forbidden? Because it is the ideal condition for an attack: if an attacker can write bytes into a page that is also executable, they can run their own code. W^X makes the attacker's job much harder, and costs a JIT a flip of the permissions. It is the same trade as the read-only GOT in Chapter 32: a small cost for a large class of exploits closed.
Why is the ARM64 instruction cache not coherent with data writes? It saves hardware. Keeping the instruction cache automatically in step with every store costs power and area, and almost no program writes code. The few that do (JITs, the dynamic loader) pay with an explicit instruction. It is the same kind of trade as ARM64's weak memory model (Chapter 31): the hardware does less, and the programmer is responsible where it matters.
Why does a JIT pay off at all? Because a JIT knows things a static compiler does not: the actual types seen, the actual values of "constants" read from a configuration, which branch is almost always taken. It can generate code specialized to this run. It trades compile time for run time and bets that the program will run long enough.
What would make a JIT the wrong choice? A short-running program, where the compile time never pays back; a platform that forbids executable memory; or a security-sensitive environment where W^X flips are not allowed. An ahead-of-time compiler, or a good interpreter, can be the better answer.
Common mistakes
- Leaving the page writable and executable. Flip it.
- Forgetting to flip it at all.
SIGSEGVon the first call. - Forgetting the instruction cache flush on ARM64. Intermittent crashes.
- A wrong byte in the encoding, found only by crashes. Disassemble every buffer.
- Not respecting the calling convention in the code you emit (stack alignment, callee-saved registers).
- Mapping a page per function. Map a big region once.
- Never freeing code. A JIT needs a plan to reclaim code it no longer needs.
Say it simply
A JIT writes machine code bytes into a page of memory, flips the page from writable to executable, and calls it as a function. Both mistakes of forgetting the flip or writing after it crash the program. On ARM64 you must also flush the instruction cache. Creating one function this way cost about 8 microseconds in my test, thousands of times an ordinary call, so a JIT only pays for code that runs many times.
Check yourself
Exercise 38.1 (Warm-up): Predict, then check. Predict what f(0) and f(1000) return for the jitted x*3 + 7. Change the constants and re-run. Then disassemble the saved bytes. A correct result: 7 and 3007. The disassembly shows the new immediates in the bytes after 48 69 c7 and 48 05.
Exercise 38.2 (Warm-up): Explain. In five sentences, explain write-xor-execute. Give an analogy and say where it breaks. What a strong answer includes: a page is never both writable and executable at once; you write, then flip; this blocks code injection; analogy: a recipe card you can edit or cook from, but not both at the same time; where it breaks: a JIT needs both at different moments, so it flips repeatedly, and the flip costs system calls.
Exercise 38.3 (Core): Implement. Write a JIT for one function that sums an array: long sum(long *p, long n). Emit the loop by hand (use the bytes from the assembler for your Chapter 5 loop). Check with objdump, call it, and compare with a C loop. Done when: it gives the right answer for n = 0, 1 and 1000, and you disassembled the emitted bytes.
Exercise 38.4 (Core): Implement. Turn the Chapter 36 compiler into a JIT for x86-64: replace each emitted text line with its bytes (48 89 F8 for mov rax, rdi, and so on), emit into a mapped page, and call it. Test against the evaluator. Done when: all results match the 2,100-result test, and for one expression you compared your bytes against the assembler's output.
Exercise 38.5 (Core): Break it. Remove the mprotect call. Then call the function. Then put it back but remove the __builtin___clear_cache call in the ARM64 version and think about what you would see on real hardware. A correct result: the first dies with SIGSEGV. For the second, explain that stale instructions may run, why it may work in testing and fail in production, and why it is hard to debug.
Exercise 38.6 (Stretch): Design and defend. Design a tiering policy for a language runtime: interpret first, JIT later. What would you count, what threshold would you use, and how would you decide whether it was worth compiling a function? What a strong answer includes: count calls and loop trips; compile when the count crosses a threshold chosen from the measured compile cost (here, microseconds) against the measured saving per run; a cap on total code size; and a way to throw code away.
Part 12 wrap-up
What you can now do:
- Turn an AST into assembly for two ISAs with a stack-machine strategy.
- Improve the code with constant folding and immediate operands, and measure the gain.
- Verify a compiler with differential testing.
- Write three kinds of interpreter dispatch and read their indirect jumps.
- Say when computed goto and tail calls help, with measured numbers.
- Emit machine code into memory, flip permissions, and call it on both machines.
- Say what a JIT costs and when it pays off.
Mini project: your own language. Time: 20 to 40 hours. This is the biggest project in the guidebook.
Design a small language (variables, arithmetic, comparisons, if/while, functions). Implement it three ways on x86-64: (1) a bytecode interpreter with switch dispatch, (2) a compiler to assembly text that you assemble and link, and (3) a JIT that emits machine code into memory. Add ARM64 to at least the compiler.
Done when: all three implementations produce identical results on a suite of at least 50 test programs and 300 random expressions; you have a table of speeds for each on a loop-heavy program; and you wrote one page on where the interpreter, the compiler and the JIT each win.
Write-up (one page): Which of the three would you ship for your language, and what would change your mind?
Reading list for Part 12:
- Robert Nystrom, "Crafting Interpreters," the bytecode virtual machine part (clox). The standard introduction to the interpreter in this chapter.
- Nora Sandler, "Writing a C Compiler." A step-by-step compiler with x86-64 output.
- Andrew Appel, "Modern Compiler Implementation," and Cooper and Torczon, "Engineering a Compiler." Instruction selection and register allocation.
- Poletto and Sarkar, "Linear Scan Register Allocation" (1999), and Chaitin et al., "Register Allocation via Coloring" (1981). The two classic register allocation papers.
- Anton Ertl and David Gregg on threaded code and interpreter dispatch, including "The Structure and Performance of Efficient Interpreters" (2003). The research behind this chapter's measurements.
- The Clang documentation for
musttail, and the GCC manual on "Labels as Values." - Aycock, "A Brief History of Just-In-Time" (ACM Computing Surveys, 2003). Where JITs came from.
Teach it back. In five sentences, as if to a friend, explain the difference between an interpreter, a compiler and a JIT, and what you must do to make freshly written machine code safe to run.
Part 13: Putting It All Together
This last Part pulls the guidebook into one picture, then gives you the tools to teach it and the projects to prove you know it.
Chapter 39. The philosophy of the machine
In this chapter: You will step back and see the handful of ideas that explain almost everything in the last 38 chapters. You will get five questions you can ask of any machine, including one that has not been built yet. And you will see what is changing.
You need first: All of it, or at least Parts 1 to 4 and 8.
The problem first
You now know a lot of facts: 16 registers and 31, lock and ldxr, jb and b.lo. Facts fade. If you have to remember each one separately, you will forget most of them in a year. What you want is the small set of ideas from which the facts can be rebuilt. This chapter is that set.
The model (the "what")
Here are the ideas, each one a thing you saw over and over.
1. A machine is a contract, and the contract is small. The CPU promises: "I will run your instructions as if one at a time, in order, and the state is the registers, the flags, the memory and the program counter." Everything else (pipelines, caches, out-of-order cores, store buffers) is hidden behind that promise (Chapter 2, Chapter 26). When something surprising happens, ask which part of the contract was weaker than you assumed. The memory model (Chapter 31) is the clearest case: the contract about order is weaker on ARM64 than on x86-64.
2. The names stay and the numbers move. Labels, symbols, relocations, rip-relative addressing, position-independent code, the GOT: all of them exist so that a human writes a name and a tool computes the number (Chapters 1, 7, 32). Whenever you see an indirection, ask "which number would have to change if this were not here?"
3. Every limit comes from a number of bits. A move immediate holds 16 bits, so a 64-bit constant takes four instructions. A conditional branch has a 19-bit distance, so it reaches 1 MiB. A scale field has 2 bits, so the scale is 1, 2, 4 or 8. A register number is 5 bits, so there are 31 registers. Once you see this, every odd restriction on both machines stops being a mystery (Chapters 24 and 25). When you meet a strange rule, ask "which field is too small?"
4. Cost lives in two places: distance and coordination.
- Distance: the farther data is from the core, the slower it is. A register under 1 ns, the first cache about 1.7 ns, main memory 100 times that (Chapters 2 and 27).
- Coordination: two things that have to agree cost a lot. A branch that must be guessed (9 times in my test, Chapter 12), an atomic that must own a cache line (13 adds' worth, Chapter 30), a system call that crosses a privilege boundary (about 100 ns, Chapter 23), a fence.
- Dependency is a kind of coordination: an instruction that must wait for another's result cannot overlap with it. One running sum was 3.5 to 3.8 times slower than four (Chapter 26).
Most performance problems are a long chain, a mispredicted branch, or a cache miss. Learn to see these three and you can explain most slow loops.
5. Compatibility and regularity pull in opposite directions. x86-64 kept every old program running and paid with a hard-to-decode, irregular encoding (Chapter 24). ARM64 started over and paid with limits on constants and distances (Chapter 25). Neither is better. Each paid in a different currency, and the average instruction length came out the same: 4.16 bytes against 4.00 (Chapter 3). When you hear a confident claim that one design is simply superior, ask what it paid.
6. The same idea comes back at a new scale. A label and a relocation. A cache line and a page. A flag and a predicate. A call and a system call. A lock and a fence. A veneer and a PLT stub. A compiler's stack machine and a CPU's stack. You do not need to learn them one at a time. Learn the first and recognize the rest.
7. Measure, and measure honestly. Memory and speed are claims. They are only true if you checked, and the check is easy to get wrong: the compiler deleted the loop, or turned it into a formula, or the machine under you changed (Chapter 28, and the note at the start of Part 8). The habit that matters: look at the assembly to see that the work is really there, repeat the run, record the machine, and compare inside one run.
Build it (the "how")
Here are five questions to ask of any machine, even one you have never seen. If you can answer all five, you can read its manual in a weekend.
- What is the state? (Registers, how many and how wide; flags or no flags; the program counter; any special state like a link register.)
- How does an instruction get its operands? (Registers only, or memory too? Which addressing modes? How are big constants built?)
- How are instructions encoded? (Fixed or variable width? Which fields? What limits does that create?)
- What is the calling convention and the system interface? (Where do arguments go, which registers survive, how do you ask the OS for something?)
- What does the machine promise about order and atomicity, and what is it free to do behind your back? (Memory model; atomic instructions; what is cached; what runs out of order.)
Try this on RISC-V, which Chapter 3 mentioned in one box. Look at the answers it gives: state: 32 registers with x0 always zero, and no flags. Operands: load and store only for memory, like ARM64. Encoding: 32-bit instructions with an optional 16-bit short form. Convention: arguments in a0 to a7, a link register ra. Memory model: a weak one, like ARM64's. You can now guess a lot about how a RISC-V disassembly looks, because you know what question each line answers.
The same five questions apply to a GPU. Its "registers" are many and shared among many threads, it hides memory delay by running thousands of threads instead of by predicting branches, and its memory hierarchy has its own staircase. The vocabulary changes, but the questions (what is the state, where does the cost live, what is the order model) do not. If your next step is GPU systems or inference engines, you carry these questions with you.
Why it is this way (the "why")
Why does the field keep changing? Because the costs shift. Memory got slower relative to the core, so caches and out-of-order execution appeared. Cores stopped getting faster, so vectors and many cores appeared. Attacks on speculation (Spectre and Meltdown) appeared, so security instructions (endbr64, paciasp, shadow stacks) appeared. Each is a response to a cost.
What is changing now? Some of it I met while writing this guidebook, and some I can only tell you from published specifications, so treat these as pointers and check the details:
- More registers on x86-64. Intel's APX adds 16 more general registers (
r16tor31) through a new REX2 prefix, and three-operand forms. My assembler acceptedmov r16, r17and gaved5 58 89 c8(Chapter 24). Whether your CPU has it is a feature-flag question. - Scalable vectors. ARM's SVE lets one binary run on any vector width, and I ran my loop at three widths (Chapter 29). x86-64's newer vector efforts continue to evolve; check the current documentation.
- Security in the instruction set. CET and shadow stacks on x86-64, pointer authentication and branch target identification on ARM64 (Chapter 17).
- Systems languages taking assembly seriously. Rust's stabilization of naked functions in 1.88 (Chapter 35) is one small sign that systems languages are taking assembly seriously as a first-class tool.
What would make you wrong about all this? If a future machine abandoned the contract in idea 1, for example by exposing the out-of-order engine directly and asking the programmer to schedule it. Some designs have tried. The contract has won for 50 years because it lets old programs run on new chips, and that is worth a lot. But I do not know what the next 50 years hold.
Common mistakes
- Memorizing facts instead of the ideas under them.
- Believing one ISA is simply better.
- Trusting a speed claim without checking the assembly and the machine.
- Treating the contract as the hardware. Behind it is a lot of cleverness that sometimes leaks (timing, speculation, memory order).
- Stopping at "it works on my machine."
Say it simply
A CPU runs your instructions as if one at a time, but behind that promise it runs many at once. The cost of code is in distance (caches), coordination (branches, atomics, system calls) and dependency (waiting for a result). Every odd limit in an instruction set comes from too few bits in a field. To understand any machine, ask what its state is, how instructions get operands, how they are encoded, what the call and system interface is, and what it promises about order.
Check yourself
Exercise 39.1 (Warm-up): Explain. In five sentences, explain idea 3 (every limit comes from a number of bits) using one example from each ISA. Give an analogy and say where it breaks. What a strong answer includes: ARM64 movz holds 16 bits so a 64-bit constant needs four steps; x86-64's SIB scale has 2 bits so the scale is 1, 2, 4, 8; analogy: a form with a fixed number of boxes for a name; where it breaks: designers can reuse spare codes and add prefixes, so the limits are stretched but not removed.
Exercise 39.2 (Warm-up): Predict, then check. Using only the RISC-V box in this chapter, predict what a RISC-V add looks like (how many operands, which register is always zero), what a memory access looks like, and whether it has a flags register. Then check the real manual. A correct result: three register operands; x0 reads as zero; memory accessed only by load and store instructions; no flags register. Note anything you predicted wrongly.
Exercise 39.3 (Core): Apply it. Pick a CPU you have not studied (RISC-V, MIPS, a GPU's ISA, or the Apple core). Answer the five questions using its public manual. Where could you not find an answer, and what would you measure? A correct result: a one-page table with five rows and a "source" column, and a short list of things you would measure.
Exercise 39.4 (Core): Find the cost. Take any slow loop from your own work. Say whether it is limited by distance, coordination, or dependency, and give the evidence (an instruction count, a latency chain, a cache size, a branch count). A correct result: a one-paragraph diagnosis that names the limit and the number that proves it, and a prediction of what would fix it.
Exercise 39.5 (Stretch): Design and defend. Design the five-question answers for a new machine you would build for running interpreters well. What would you change from ARM64 or x86-64? What a strong answer includes: a specific feature (for example, a hardware dispatch-by-opcode instruction, or a larger indirect-branch predictor, or a tagged-value instruction), the cost in bits and in hardware, and an honest note about what it would hurt.
The Teaching Kit
You understand something when you can teach it. This section turns what you learned into things you can say out loud: a five-minute explanation, a 45-minute workshop, a talk for 500 people, and the ten questions a sharp audience will ask. Every number in the answers comes from a measurement or a fact in this guidebook.
The 5-minute explanation
Use this with a friend, a new teammate, or at the start of a meeting. Say it, don't read it.
"A computer chip only understands numbers. Assembly is just those numbers with names, so a human can read them. One line of assembly is about one instruction for the chip.
The chip is simpler than people think. It has a few tiny storage cells called registers, and a huge slow memory, and a finger that points at the next instruction. It does one thing in a loop: read the instruction, do it, move the finger. A jump just moves the finger somewhere else. That is how every loop, if, and function call works.
There is more than one kind of chip. Your laptop might be x86-64 and your phone is ARM64. They spell things differently, but they are the same idea. Learn one and the other is a weekend.
Why does it matter? Three reasons. First, when a program crashes, the chip tells you exactly which instruction failed and why, and you can read it. Second, you can see what the compiler really did with your code. For example, it turns x * 10 into a shift and two adds, and it turns a loop into a vector loop that does four adds at once. Third, you can see why a loop is slow. A loop that depends on its last result is limited by how fast one add can go. Split it into four independent sums and it ran 3.5 times faster in my test, with no change to the answer.
You do not have to write assembly every day. You have to be able to read it. That is the skill."
The 45-minute workshop
For six to twenty people with laptops. They need Linux (or WSL2) with binutils, gcc and gdb installed. Each block has a demo from this guidebook.
| Time | Block | What you do | What they do |
|---|---|---|---|
| 0:00 to 0:05 | Hook | Open Compiler Explorer. Paste int mul10(long x) { return x*10; }. Ask: "How many multiplies?" Show lea and add. | Guess, then see. |
| 0:05 to 0:15 | The machine | Draw the registers, memory and the fetch-execute loop on a board. Run the toy CPU from Chapter 2. | Change LOADI r1, 10 to 5 and predict the step count (18). |
| 0:15 to 0:30 | Hello, machine | Walk through the x86-64 hello program (Chapter 4). Assemble, link, run. Then break it: change mov rax, 60 to a syscall that is harmless (39, getpid) and watch it crash. | Type the program, run it, make one change, and describe what happened. |
| 0:30 to 0:38 | The trap | Show the rcx counter loop that prints 2 million lines instead of 3. Ask them why. Reveal: syscall destroys rcx. | Fix it with another register. |
| 0:38 to 0:43 | Read a function | Show the sum function at -O2. Teach the seven questions (Chapter 5). | Answer them for count_a (Exercise 5.1). |
| 0:43 to 0:45 | Wrap | Say the five-minute explanation's last paragraph. Hand out the cheat sheet (Appendix A). | Pick one exercise to do at home. |
If something goes wrong: have a pre-built hello_x86 binary and a text file of the program on a USB stick or a shared link. If a laptop has no as, pair people. If gdb is slow, skip it and use strace.
The talk outline for 500+ people
Title: "What Your CPU Does With Your Code." 35 minutes plus questions. The audience is software engineers, most of whom do not write assembly. The talk has four live demos. Each has a fallback slide in case the demo fails.
| Min | Slide / beat | What the audience gets |
|---|---|---|
| 0 to 2 | The question. "How many multiplies are in x * 10?" Show Compiler Explorer. None. | They want to know how. |
| 2 to 6 | The contract. The fetch-execute loop (Chapter 2) and the three kinds of cost: distance, coordination, dependency. One slide, three words. | A frame to hang everything on. |
| 6 to 12 | Demo 1: the branch. Run the branchy loop on random and sorted data. Show 3.9 ns against 0.4 ns: about 9 times. Then the branchless version, flat at about 0.8 ns, slower on sorted data. | A surprise with a number, and a nuance ("branchless is not always better"). |
| 12 to 19 | Demo 2: the chain. One running sum against four, 3.5 to 3.8 times faster from the same instructions. Draw the dependency chain as a picture. Then the pxor: 6.8 ns against 1.8 ns from one instruction that does no work. | The idea that instruction count is not time. |
| 19 to 25 | Demo 3: two machines. Show x/10 as a multiply by 0xCCCCCCCCCCCCCCCD on x86-64 and ARM64. Then the average instruction length: 4.16 bytes against 4.00. Pop the myth that CISC means dense. | They see two designs and one set of ideas. |
| 25 to 31 | Demo 4: the order. Show the message-passing handoff. On x86-64 the release store compiles to a plain mov. On ARM64 it is stlr. Explain the store buffer. Say honestly what you could and could not show. | The most important difference between the machines, with intellectual honesty. |
| 31 to 34 | How to start. Compiler Explorer, objdump, gdb. The five questions to ask of any machine (Chapter 39). | A path forward. |
| 34 to 35 | Close. "You do not have to write assembly. You have to be able to read it." | One sentence to remember. |
Fallbacks: record each demo's terminal output ahead of time and keep it as a slide. If the measurement machine behaves differently on stage, say so and show your recorded numbers. (This guidebook's own numbers drifted when the cloud machine changed, so a talk that expects exact figures will be fragile. Show the ratio and say "your machine will differ.")
What to put on the slides, and what not to: one idea per slide, a picture or a table, and no more than 12 lines of code. Put the long listings in a link. People remember the numbers and the surprises.
The ten questions a sharp audience will ask
1. "Isn't ARM RISC and x86 CISC? Doesn't that make ARM faster?" Not by itself. The names describe how the instruction set is spelled, not how the chip runs. Inside, an x86-64 core splits instructions into smaller micro-ops, so a add [mem], 1 becomes about the same steps as ARM64's three instructions (Chapter 26). And the "CISC is denser" story is not true either: the average x86-64 instruction in the C library I measured was 4.16 bytes, against ARM64's fixed 4. Speed and energy depend on the whole design of each chip (width, caches, predictors), not on the label.
2. "Is hand-written assembly faster than C?" Usually not, for general code. The compiler tries many tricks and does not get tired. In this guidebook the compiler's -O3 loop was the same vector loop I wrote by hand. Assembly wins when you know something the compiler cannot: a special instruction, an exact sequence, a calling convention it does not know, a timing rule. And inline assembly can make things slower: in Chapter 35, an asm! block inside a loop stopped the compiler from vectorizing it.
3. "Why does the compiler use lea to add numbers?" lea ("load effective address") is an address calculator that can add two registers and a small multiple of one, into a third register, in one instruction, and without touching the flags. So x*5 is lea rax, [rdi+rdi*4]. It is a useful arithmetic tool that happens to be spelled like an address (Chapter 7).
4. "Why is division slow, and how do compilers avoid it?" Division has no parallel formula, so it is a chain of dependent steps. On my machine a divide was about 12 adds' worth of time (4.6 ns against 0.37 ns), and it can be much worse on older chips. When the divisor is a known number, the compiler multiplies by a precomputed reciprocal and shifts. For x / 10 that is a multiply by 0xCCCCCCCCCCCCCCCD and a shift by 3, which I checked on two million values (Chapter 19).
5. "Why is my loop faster when I sort the data first?" Because of the branch inside it. A CPU guesses which way a branch goes and runs ahead. On random data it is wrong half the time, and each wrong guess throws away about 20 cycles of work. On sorted data it is almost always right. In my test the same loop took 3.9 ns per element on random data and 0.4 on sorted: about 9 times (Chapter 12).
6. "What's the difference between latency and throughput, and why four accumulators?" Latency is how long one result takes. Throughput is how many the chip can start per cycle. A running sum has a chain: each add needs the last result, so it runs at the add's latency. Four independent sums let the chip overlap them and run near its throughput. I measured 1.18 ns per element for one sum and 0.31 for four, 3.8 times faster (Chapter 26). For floating-point numbers the compiler will not do it for you, because it changes the rounding.
7. "Does Rust's bounds checking slow my code down?" A single check is two instructions, a compare and a branch that is almost never taken (Chapter 33). In a loop where the compiler can prove the index is in range, it removes the check entirely: my for i in 0..a.len() loop had no check and was even vectorized. It matters most when a check blocks vectorization. Measure that loop, not the general case.
8. "Why do I need memory fences on ARM but not on x86?" Because x86-64 only lets a later load pass an earlier store, while ARM64 may reorder all four combinations. So a plain store of a flag after data is already a correct release on x86-64, but on ARM64 you need stlr and ldar (Chapter 31). Be honest about the limit: I could not show a reordering on my one-core sandbox. Run the litmus test on a multi-core machine.
9. "My benchmark says 0 nanoseconds. Is my CPU amazing?" No. The compiler removed your loop (the result was unused) or replaced it with a formula. I got 0.000001 ns per element from a billion-iteration loop, and clang turned a sum of squares into a closed-form polynomial. Put an optimization barrier (an empty asm volatile or Rust's black_box) on the result, and look at the assembly (Chapter 28).
10. "Is a JIT always faster than an interpreter?" No. Creating one tiny function with mmap, mprotect and the rest cost about 8 microseconds in my test, about 4,000 ordinary calls. A JIT only pays for code that runs many times, which is why runtimes interpret first and compile only the hot parts (Chapter 38). And within interpreters, even the dispatch technique is not a sure win: computed goto was 2.2 times faster than a switch on an irregular program with clang, and slower on a periodic one (Chapter 37).
A 13-week study schedule
About 6 to 8 hours a week, with exercises.
| Week | Read | Do |
|---|---|---|
| 1 | Part 1 (Chapters 1 to 5) | The Part 1 mini project |
| 2 | Part 2 (Chapters 6 to 9) | The memory laboratory |
| 3 | Part 3 (Chapters 10 to 13) | The branch laboratory; Capstone 2 (start) |
| 4 | Part 4 (Chapters 14 to 17) | The convention laboratory |
| 5 | Part 5 (Chapters 18 to 21) | The arithmetic laboratory |
| 6 | Part 6 (Chapters 22 to 23) | mini-coreutils; Capstone 1 |
| 7 | Part 7 (Chapters 24 to 25) | The two-ISA mini-disassembler |
| 8 | Part 8 (Chapters 26 to 28) | The instruction cost table |
| 9 | Part 9 (Chapter 29) | The three-width library |
| 10 | Part 10 (Chapters 30 to 31) | The concurrency laboratory (needs two cores) |
| 11 | Part 11 (Chapters 32 to 35) | The post-mortem kit |
| 12 | Part 12 (Chapters 36 to 38) | Your own language (start) |
| 13 | Part 13 and a capstone of your choice | A capstone write-up, and the talk |
The Capstone Projects
A capstone tests the whole guidebook. There are no step-by-step recipes here: you get a goal, requirements and constraints, and you design the solution. You may use any hint in the chapters. Each one combines ideas from at least three Parts.
Coverage: every Part is used by at least two capstones. The kinds: build (1, 4, 5, 9), investigate and measure (2, 6, 12), design and defend (3, 7), break your own work (8), contribute (10), teach it (11), mastery (12). Capstones 4 and 5 build a small real system from nothing.
| # | Name | Kind | Difficulty | Time |
|---|---|---|---|---|
| 1 | Two-machine toolbox | Build | Medium | 1 to 2 weekends |
| 2 | Three slow loops | Investigate | Medium | 1 weekend |
| 3 | Choose the calling convention | Design and defend | Medium | 1 to 2 weekends |
| 4 | A two-ISA compiler | Build from nothing | Hard | 3 to 4 weekends |
| 5 | A bytecode VM with a JIT tier | Build from nothing | Hard | 3 to 4 weekends |
| 6 | The cost atlas | Reproduce and compare | Hard | 2 to 3 weekends |
| 7 | Port a lock-free queue | Design and defend | Hard | 2 weekends |
| 8 | Break your own tools | Break your own work | Hard | 2 weekends |
| 9 | One loop, three widths, two machines | Build | Very hard | 4 to 6 weekends |
| 10 | Find a missed optimization in real code | Contribute | Very hard | 3 to 5 weekends |
| 11 | Give the talk | Teach it | Hard | 2 to 3 weekends |
| 12 | Reverse and patch a stripped program | Mastery | Final | 4 to 6 weekends |
Capstone 1: Two-machine toolbox
What you make: Four small command-line tools, written in assembly with no C library, for both x86-64 and ARM64: mycat, mywc, myhead and mygrep (a fixed-string search). Why it matters: This is the smallest "real program" that touches the kernel, strings, numbers and arguments. It is what a minimal system image is made of. Ideas you will use: Parts 1 to 4 (syscalls, addressing, the stack, functions), Part 6 (strings, argv, system calls), Part 9 (a vectorized search), Part 5 (printing numbers).
Requirements
- Each tool reads
argv, opens files withopenat, reads in blocks, and exits with status 1 on any error and 0 on success. mywcprints lines, words and bytes, matchingwcon a text file, an empty file, a file with no final newline, and a file larger than your buffer.mygrepfinds a literal string (not a pattern) and prints each matching line. It must use a vector compare (SSE2 or NEON) to scan for the first byte, with a scalar check for the rest.- All four are implemented for both ISAs and tested against the real tools.
Constraints
- No libc; syscalls only.
- Handle a line longer than your buffer without a crash or a wrong answer (decide what "correct" means and say so).
- Use
print_u64from Chapter 16 for numbers.
Done when
- The output of each tool equals the real tool's on at least eight test inputs, including the edge cases above.
- You have a table of
strace -ccall counts for each tool and one sentence on how you chose the buffer size. - A speed table compares
mygrepagainstgrep -Fand a byte-loop version, on a 100 MB file. - A stranger can build and run everything from your README.
Make it great (stretch)
- Search several patterns at once.
- Add
-c(count) and-v(invert) options. - Make
mygrepbeatgrep -Fon one input class, and say why.
Write-up
- What did you choose for the buffer size and why? What did you decide for lines longer than the buffer? Where did the vector compare help, and where did it not?
What a strong result looks like
- Tests include the nasty cases (empty file, no final newline, a match split across two reads).
- The vector scan reads only aligned blocks or masked loads, and you tested a match at the end of a page.
- The report has real numbers with the machine recorded, and an honest "grep is faster here because..."
Capstone 2: Three slow loops
What you make: A root-cause report for three slow programs, each with evidence from assembly and measurement. Why it matters: The most common real task is "this is slower than it should be." This one trains you to find out why, not guess. Ideas you will use: Part 3 (branches), Part 5 (floating point), Part 8 (dependency chains and caches), Part 11 (reading compiler output), Part 2 (memory layout).
Here are the three programs. They are short on purpose. Compile each with gcc -O2 and run it.
// Program 1: why is this slow on one input and fast on another?
long f1(const int *d, long n) { long s = 0; for (long i = 0; i < n; i++) if (d[i] >= 128) s += d[i]; return s; }
// run it on 32768 random ints in 0..255, and again on the same data sorted
// Program 2: why can this not go faster, even though the loads are cheap?
double f2(const double *a, const double *b, long n) { double s = 0; for (long i = 0; i < n; i++) s += a[i] * b[i]; return s; }
// run it on 1024-element arrays so everything fits in the first cache
// Program 3: the same sum, two traversal orders over a 4096 x 4096 array of int
long f3_rows(const int *m, long N) { long s = 0; for (long i = 0; i < N; i++) for (long j = 0; j < N; j++) s += m[i*N + j]; return s; }
long f3_cols(const int *m, long N) { long s = 0; for (long j = 0; j < N; j++) for (long i = 0; i < N; i++) s += m[i*N + j]; return s; } Requirements
- For each program, measure the slow case and the fast case (or the baseline and an improved version), with a median and range from at least five runs, and record your machine.
- For each, show the assembly that proves your explanation (the
jle, theaddsdchain, the strided address). - For each, make a change that fixes it, and show the new measurement and the new assembly.
- For each, name which of the three costs from Chapter 39 is responsible: distance, coordination, or dependency.
Constraints
- Do not change the algorithm's result. Programs 1 and 3 must give the same answers before and after.
- For Program 2, your fix must be justified: say what happens to the rounding, and decide whether to accept it.
Done when
- A table of before and after times with ranges for all three.
- One annotated assembly excerpt per program.
- A one-paragraph explanation per program that a teammate could follow.
Make it great (stretch)
- Predict the speedups before measuring and report your errors.
- Use
llvm-mcaon Program 2 and compare its prediction with your measurement. - Find the working-set size where Program 3's two orders start to differ.
Write-up
- Which explanation did you get wrong first, and what evidence changed your mind?
What a strong result looks like
- Each explanation names a specific instruction and a specific number.
- The fix for Program 2 uses independent accumulators and states the rounding trade-off.
- The report says honestly if the machine's numbers surprised the prediction.
Capstone 3: Choose the calling convention
What you make: A design document and a prototype for the calling convention of a new language on both x86-64 and ARM64. Why it matters: Every language runtime decides how arguments, closures, errors and stack frames work. The ABI is a contract that costs speed or safety depending on how you write it. Ideas you will use: Part 4 (conventions and frames), Part 8 (what a call costs), Part 11 (unwinding, CFI), Part 12 (compiler backends).
Requirements
- Specify where the first N arguments go, what is callee-saved, how results come back (including two-word results), how closures carry their environment, and how many bytes of stack alignment are guaranteed.
- Specify how an error propagates (for example, a second return register, or unwinding), and how a debugger finds your frames.
- Build a prototype: a handful of hand-written functions in your convention, on both ISAs, called from a small test harness (in assembly or C).
- Measure the call and return overhead of two competing choices (for example, passing a closure's environment in a register versus on the stack) on real hardware.
Constraints
- It must be implementable by a compiler without whole-program knowledge.
- It must interoperate with C for at least a "foreign call" path.
- No more than two pages of specification.
Done when
- The specification states every rule in one sentence and has one worked example.
- The prototype passes a test that calls through at least three levels of your convention, including an error path.
- A
gdbbacktrace through your frames is correct (you wrote.cfi_directives, and you show the output). - A measurement compares two choices, with a median and a range.
Make it great (stretch)
- Add a tail-call rule and show it saves stack.
- Compare your design with the System V and AAPCS64 rules and list what you changed and what it costs.
Write-up
- Which rule was hardest to decide, which alternative did you reject, and what would change your mind?
What a strong result looks like
- Every register has a defined role, and the callee-saved set is justified, not copied.
- The unwinding story is complete, not "we'll use frame pointers" without a reason.
- The measurement is honest about noise, and the decision follows the evidence.
Capstone 4: A two-ISA compiler
What you make: A compiler for a small imperative language (integers, variables, if, while, functions with arguments and return values) that emits assembly for x86-64 and ARM64. Why it matters: This is the whole back end of Part 12 grown into a real tool. It is the best way to prove you understand the calling convention, the stack and instruction selection. Ideas you will use: Part 3 (branches), Part 4 (ABI and frames), Part 5 (arithmetic), Part 11 (CFI, assembling, linking), Part 12 (code generation).
Requirements
- Support local variables, arithmetic, comparisons,
if/else,while, and function calls, including recursion. - Obey the platform calling convention so that your functions can be called from C, and can call C's
putcharorprintf. - Emit correct prologues and epilogues, keep the stack aligned at every call, and add
.cfi_directives so a backtrace works. - Include at least two optimizations (for example constant folding and immediate operands, and strength reduction for powers of two) and measure their effect on instruction count and run time.
- Test by differential testing: write an interpreter for the same language, and compare it with the compiled program on at least 500 random programs, on both ISAs.
Constraints
- No code-generation libraries. You may use
as,ldandgccto assemble and link. - Randomly generated programs must not have undefined behavior (no division by zero, no unbounded loops); say how you guaranteed that.
Done when
- 500 random programs agree between interpreter and compiled code on both ISAs.
- A recursive
fiband a loop-based prime counter run, and you compare their speed with the same programs written in C at-O0and-O2. - A backtrace from inside a recursive call shows every frame.
- A short table shows instruction counts and times with and without each optimization.
Make it great (stretch)
- Add a simple register allocator (linear scan) in place of the stack machine, and re-run the tests.
- Add arrays and bounds checks.
- Make the same front end emit RISC-V.
Write-up
- Which design decision (where locals live, how you evaluate expressions) cost you the most time, and what would you do differently?
What a strong result looks like
- Differential testing found at least one real bug that your hand tests missed, and the report describes it.
- The generated code is checked against a disassembler for at least one function.
- The report has honest comparisons with
gcc, including wheregccis much better and why.
Capstone 5: A bytecode VM with a JIT tier
What you make: A bytecode virtual machine in C or Rust with at least two dispatch strategies, plus a simple x86-64 JIT that compiles hot loops. Why it matters: This is how real language runtimes get fast. Interpret first, find the hot spots, compile them. Ideas you will use: Part 3 (indirect branches and jump tables), Part 8 (prediction and cost), Part 7 (encoding the bytes you emit), Part 12 (dispatch and JIT), Part 6 (mmap).
Requirements
- A VM with a register-based bytecode of at least ten opcodes, including arithmetic, comparison, a conditional jump and a loop counter.
- Two dispatch strategies, one of which must be per-handler (computed goto or tail calls), and a test showing identical results.
- A profiler that counts how many times each loop back-edge runs, and a threshold above which the loop is JIT-compiled.
- A JIT that emits x86-64 machine code for the hot loop into a page, flips it to executable, and calls it. It must fall back to the interpreter for anything it does not support.
- A benchmark with a periodic program and an irregular program.
Constraints
- The JIT must obey write-xor-execute.
- Every emitted function must be disassembled with
objdump -D -b binaryin at least one test, and you must show the output.
Done when
- All three execution modes (two interpreters and the JIT) give identical results on at least 30 test programs.
- A table of speeds for each mode on each benchmark program, with ranges.
- You can state, with a number from your own run, the break-even point: how many loop trips before the JIT pays for itself.
Make it great (stretch)
- Add an ARM64 JIT (and the instruction-cache flush).
- Add a deoptimization path when an assumption fails.
- Add superinstructions and show how many dispatches they remove.
Write-up
- Which mode won on which benchmark, and why does the answer differ between the periodic and the irregular program?
What a strong result looks like
- The break-even number is measured and used to set the JIT threshold.
- The report notices and explains any case where a "faster" technique was slower.
- The JIT code is verified, not trusted.
Capstone 6: The cost atlas
What you make: A measured table of instruction costs and memory latencies on two different machines, with a comparison to published numbers. Why it matters: Everything in Part 8 depends on numbers like these. Building your own, and noticing where they differ, is the best way to stop trusting folklore. Ideas you will use: Part 8 (latency, throughput, caches, measuring), Part 3 (branches), Part 5 (multiply and divide), Part 10 (the cost of atomics), Part 11 (reading compiler output to check your loops).
Requirements
- Measure, for at least 15 operations, both latency (a dependent chain) and throughput (independent chains): integer add, multiply, divide, shift,
lea, floating-point add, multiply, divide, square root, convert, a load from the first cache,lock add,xchg,mfence, and a mispredicted branch. - Measure the cache staircase with a pointer chase from 4 KiB to at least 1 GiB.
- Run everything on two different machines (two physical computers, two cloud instance types, or one x86-64 and one ARM64). Record each machine's CPU model and flags.
- Compare your latencies with
uops.infoor the vendor manual for at least ten operations, and explain every gap over 20%. - Use
llvm-mcato predict two loops, and compare with your measurements.
Constraints
- Every number is a median of at least five runs with a range.
- Every loop's assembly must be checked, so the work is not removed (Chapter 28).
- Record the CPU model at the start and the end of each session.
Done when
- A table with a column for each machine, and a column for the published value.
- A plot or table of the cache staircase for both machines.
- A list of at least three places where the machines differ in shape, not just in size.
- A regression guard: a script that re-runs a subset and flags a change of more than a stated tolerance, with the tolerance justified by your measured noise.
Make it great (stretch)
- Add a third machine.
- Measure the instruction costs of the vector widths (SSE2, AVX2, AVX-512, NEON).
- Contribute your findings to a public repo.
Write-up
- Which published number did your machine disagree with most, and what do you think explains it?
What a strong result looks like
- Honest ranges, with the machine recorded each time.
- At least one case where the comparison with the manual taught something.
- A guard that would catch a real regression without crying wolf.
Capstone 7: Port a lock-free queue
What you make: A single-producer single-consumer ring buffer in assembly (or Rust with inline assembly) for x86-64, then a port to ARM64, proved correct with litmus tests. Why it matters: This is the classic bug when code moves from a strong machine to a weak one. A queue that works on x86-64 and loses messages on ARM64 is a real-world story. Ideas you will use: Part 10 (atomics and ordering), Part 4 (functions and ABI), Part 2 (memory layout, false sharing), Part 11 (inline assembly), Part 8 (cache lines).
Requirements
- A ring buffer where one thread pushes and one pops, with a head index and a tail index, and no locks.
- An x86-64 version using plain loads and stores where the TSO model permits.
- An ARM64 version with the minimal
ldar/stlr(or equivalent) needed for correctness, with a one-sentence justification for each. - A deliberately broken ARM64 version using only plain loads and stores, and a stress test that tries to expose it.
- A litmus test (message passing and store buffering) on real multi-core hardware for each ISA.
Constraints
- You must run on real ARM64 hardware (a cloud instance is fine) for the ARM64 results. An emulator cannot show reordering.
- Place the head and tail indices on separate cache lines, and measure the difference.
Done when
- The correct versions pass at least 100 million handoffs with no lost or duplicated message, on real hardware.
- The report states whether you were able to expose the broken version, how many trials it took, and on which machine. If you could not, say so honestly and say what that proves (very little).
- A table shows throughput with and without cache-line separation.
Make it great (stretch)
- Make it multi-producer using a compare-and-swap.
- Prove the ordering argument on paper for each
ldarandstlr. - Run a model checker or ThreadSanitizer on the C version.
Write-up
- For each acquire and release, which earlier or later operation does it order, and what breaks if you remove it?
What a strong result looks like
- The ordering argument is written for each instruction, not asserted.
- The report is honest about what testing can and cannot show.
- The false sharing effect is measured, with the machine recorded.
Capstone 8: Break your own tools
What you make: A fuzzing and differential-testing harness that attacks two tools you built earlier: your Part 7 mini-disassembler and your Part 12 compiler. Why it matters: The best way to know where a model stops being true is to attack it. A decoder and a compiler both have edge cases that hand-written tests never reach. Ideas you will use: Part 7 (instruction encoding), Part 12 (compilers), Part 11 (objdump, assembling), Part 1 (the basics), Part 5 (arithmetic edges).
Requirements
- Generate random valid instructions (by assembling random operands with
as), disassemble withobjdump, and compare with your decoder, for both ISAs. - Generate random programs for your compiler, and compare with an interpreter (as in Chapter 36), including adversarial inputs: the extreme integers, division by
-1, deep nesting. - Minimize every failure to the smallest input that still fails.
- Keep a log of every bug found: the input, the cause, and the fix.
Constraints
- The generator must be reproducible from a seed.
- The harness must run unattended for at least 10 minutes without human help.
Done when
- At least five distinct bugs found and fixed, or a stated, justified reason why fewer were found (for example, how many inputs you ran, and what coverage).
- Every bug has a minimized test saved in a regression suite.
- The harness reports a count of inputs, failures, and time.
Make it great (stretch)
- Use coverage information to guide generation.
- Fuzz
objdumpitself against your decoder and find a disagreement, and read the manual to say who is right.
Write-up
- Which bug taught you the most about the machine, and which part of your model was wrong?
What a strong result looks like
- A clear distinction between a decoder bug and an unimplemented instruction.
- Minimized failing cases that are easy to read.
- An honest account of what the fuzzer did not cover.
Capstone 9: One loop, three widths, two machines
What you make: A small library (sum, max, count_byte, memchr or strlen) in scalar, SSE2, AVX2, AVX-512 and NEON/SVE, with a runtime dispatcher, tested for safety at page edges. Why it matters: This is what library authors do. It requires everything from instruction choice to measurement to correctness at the edges. Ideas you will use: Part 9 (vectors, masks, SVE), Part 6 (string and memory routines), Part 8 (measuring and the memory hierarchy), Part 2 (alignment and pages), Part 11 (inline assembly or global_asm!, feature detection).
Requirements
- Implement each function in at least four forms: scalar, 128-bit, 256-bit, and either 512-bit with masks or SVE with predicates.
- Write a dispatcher that picks the best safe and measured-fastest path on the CPU it runs on, using feature bits.
- Test every function for every length from 0 to 300 at four alignments, plus a page-edge test (the data ends at the last byte of a mapped page, with an unmapped page after it).
- Measure each form on an in-cache array and on a 256 MiB array, and compare with the C library's version where one exists.
Constraints
- No loading past the end of the data, by aligned loads or by masked loads. Show how you guarantee it.
- The dispatch decision must be recorded in a table backed by your measurements, not by assumption.
Done when
- All tests pass on every available path.
- A speed table for each form and each size, with ranges.
- A statement of which path your dispatcher chooses on each machine and why (including any case where the widest path loses).
- A fallback path that works on a CPU with only the baseline vector set.
Make it great (stretch)
- Match or beat the C library on at least one size range, and show the evidence.
- Add a Rust wrapper with
#[target_feature]and safe feature detection. - Run the SVE version at several vector lengths under an emulator.
Write-up
- Where did wider vectors not help, and what was the limit (memory, a unit, the tail)?
What a strong result looks like
- Tests include the page-edge case and unaligned starts.
- The dispatcher's choice follows the measurements, and the report notes when the answer differs between machines.
- The write-up uses the cost vocabulary of Chapter 39.
Capstone 10: Find a missed optimization in real code
What you make: An evidence-based issue or patch for a real open-source project, based on reading its compiler output. Why it matters: Real code in real projects has hot loops, and real compilers miss things. Finding one and proving it is the best use of the skill. (If you contribute to an editor or a Rust library, that is a natural place to look.) Ideas you will use: Part 11 (reading compiler output), Part 5 (arithmetic), Part 8 (what limits a loop), Part 9 (vectorization), Part 4 (calling conventions and inlining).
Requirements
- Choose a real open-source project and a real hot path (use a profiler or the project's benchmarks).
- Read the compiled code for that path, and find at least one concrete inefficiency: a missed vectorization, a bounds check that could be removed, an unnecessary spill, a division by a constant that was not strength-reduced, a call that was not inlined.
- Prove it with a measurement and the assembly before and after a proposed change.
- Prepare either a patch with a benchmark or an issue with a minimal reproduction.
Constraints
- Follow the project's contribution rules and tone.
- Your claim must be reproducible by a maintainer on a different machine, so record the compiler version and flags.
Done when
- A minimal program that shows the problem, compiled output before and after, and benchmark numbers with ranges.
- A submitted issue or pull request, or a written reason it was not appropriate to submit.
- The write-up states what a maintainer should check on their own machine.
Make it great (stretch)
- Fix it in the compiler itself (LLVM), if the issue is a missed optimization there.
- Show that the same code behaves differently on ARM64.
Write-up
- Why did the compiler miss it, and is your change safe in every case the original code handles?
What a strong result looks like
- A small reproduction, not a big one.
- A clear statement of what is and is not proven.
- Humility about the possibility that the maintainers know something you do not.
Capstone 11: Give the talk
What you make: A 30-minute talk and a 45-minute hands-on workshop on one part of this guidebook, delivered to real people. Why it matters: If you can teach it, you understand it. This forces clarity, honesty about limits, and the ability to answer hard questions live. Ideas you will use: Parts 1, 3, 8, 10 or any other four Parts, and the Teaching Kit.
Requirements
- Choose one idea that a general software engineer would care about (for example, "branches and prediction," "latency and dependency chains," or "why memory ordering differs on ARM").
- Prepare the talk with at least three live demos, each with a recorded fallback, and every number sourced from your own measurement.
- Prepare the workshop with a timed plan, exercises, and a "something went wrong" plan.
- Write the ten questions you expect, and your answers.
- Deliver the talk or workshop to at least five people who are not already experts, and collect their feedback.
Constraints
- No slide with more than 12 lines of code.
- Every claim you could not verify must be labeled as such in the talk.
Done when
- Slides or notes, the demo scripts, and the recorded fallbacks.
- The question list with answers.
- Written feedback from at least five listeners, and a list of what you would change.
- A recording or a clear plan for the next delivery.
Make it great (stretch)
- Give it at a meetup or conference.
- Turn the demos into a repository others can run.
Write-up
- What did the audience misunderstand, and what does that tell you about how you explained it?
What a strong result looks like
- The audience can repeat the main idea in one sentence a week later.
- The demos use real numbers and say honestly where they might differ.
- The questions you did not expect are added to your list.
Capstone 12 (Final): Reverse and patch a stripped program
What you make: A full analysis of a small stripped binary you did not write, a safe patch, and a written "what I know, what I do not, and what I would learn next." Why it matters: This is the whole guidebook at once. It uses reading, the ABI, encoding, the toolchain and measurement, on a program with no source and no names. Ideas you will use: All Parts. In particular Parts 1 to 5 (reading), Part 7 (encoding, for the patch), Part 4 (the ABI, to find functions and arguments), Part 6 (system calls), Part 8 (what a hot function is doing), Part 11 (the tools).
Requirements
- Choose a small stripped executable from an open-source project you build yourself (so you have the source to check your conclusions afterwards), for example a small command-line tool. Strip it.
- Without the source, produce a function map: find
main, at least five other functions, and what each does, using onlyobjdump,gdb,straceand your reading. - For three functions, name the arguments and the return value from the calling convention, and the data structures from the memory accesses.
- Make a patch: change a string, a constant or a branch, by editing the bytes. Show the before and after disassembly, and prove the patched program behaves differently as you intended. Make sure you do not break a CET marker or an encoding (Chapters 17 and 24).
- Compare with the source at the end, and report what you got right and wrong.
Constraints
- Only use a program you are allowed to modify.
- Your patch must change the bytes and nothing else (check with a byte-level diff).
Done when
- The function map with evidence for each function (the instruction patterns and the syscalls).
- The patch and its proof, with the byte-level diff.
- A section comparing your analysis with the real source: right, wrong, and why.
- A one-page "what I know, what I do not, and what I would learn next."
Make it great (stretch)
- Do the same on an ARM64 build of the same program.
- Find a performance issue in a hot function and describe the fix.
Write-up
- Which of the five questions from Chapter 39 was hardest to answer from the binary alone?
What a strong result looks like
- Every claim in the function map is backed by a specific instruction or syscall.
- The patch is minimal and verified at the byte level.
- The final page is honest about gaps and names a next step.
Appendix A: Cheat sheets
A.1 Registers and calling conventions (Linux)
| x86-64 System V | ARM64 AAPCS64 | |
|---|---|---|
| Integer arguments | rdi, rsi, rdx, rcx, r8, r9 | x0 to x7 |
| Float / vector arguments | xmm0 to xmm7 | v0 to v7 |
| Return value | rax (rdx), xmm0 | x0 (x1), v0 |
| Hidden pointer for big return | rdi | x8 |
| Caller-saved | rax, rcx, rdx, rsi, rdi, r8 to r11, all xmm | x0-x17, v0-v7, v16-v31 |
| Callee-saved | rbx, rbp, r12-r15 | x19-x28, x29, low 64 bits of v8-v15 |
| Stack pointer | rsp (16-aligned at a call) | sp (16-aligned when used) |
| Link register | none | x30 |
| Linux syscall | syscall, number in rax, args rdi rsi rdx r10 r8 r9, destroys rcx r11 | svc #0, number in x8, args x0-x5 |
A.2 Conditions after cmp a, b
| Question | x86-64 | ARM64 |
|---|---|---|
== / != | je / jne | b.eq / b.ne |
signed <, <=, >, >= | jl jle jg jge | b.lt b.le b.gt b.ge |
unsigned <, <=, >, >= | jb jbe ja jae | b.lo b.ls b.hi b.hs |
| Carry after subtraction | CF = borrow | C = no borrow |
A.3 Linux syscall numbers
| Call | x86-64 | ARM64 |
|---|---|---|
read / write | 0 / 1 | 63 / 64 |
openat / close | 257 / 3 | 56 / 57 |
mmap / munmap | 9 / 11 | 222 / 215 |
exit / exit_group | 60 / 231 | 93 / 94 |
getpid | 39 | 172 |
A.4 Commands you will use most
| To do this | Run this |
|---|---|
| Assemble and link (x86-64) | as -o p.o p.s && ld -o p p.o |
| Assemble and link (ARM64) | aarch64-linux-gnu-as -o p.o p.s && aarch64-linux-gnu-ld -o p p.o |
| Run ARM64 on x86-64 | qemu-aarch64 ./p |
| Disassemble in Intel syntax | objdump -d -M intel p |
| See relocations | objdump -dr p.o |
| Debug | gdb ./p, then set disassembly-flavor intel |
| Trace syscalls | strace ./p |
| Compiler output (x86-64) | gcc -O2 -S -masm=intel f.c |
| Compiler output (ARM64) | aarch64-linux-gnu-gcc -O2 -S f.c |
| Rust output | rustc -O --crate-type=lib --emit asm -C llvm-args=-x86-asm-syntax=intel f.rs |
| Predict a loop | llvm-mca -mcpu=<cpu> loop.s |
A.5 The seven questions for reading a function
- Where does it start and end? 2. What are its inputs (which argument registers does it read)? 3. Where are the loops (backward jumps)? 4. Where are the decisions (compare, then jump)? 5. Where does it call out? 6. What memory does it touch, and how wide? 7. What does it return?
Glossary
ABI (application binary interface): the contract about bytes, registers and layouts that lets separately built code work together. Chapter 4, 14.
Accumulator: a register that holds a running result, like a sum. Chapter 26.
Acquire (load): a load that makes later memory operations in the same thread happen after it. Chapter 31.
Address: a number that names a byte in memory. Chapter 6.
Addressing mode: a recipe for building a memory address inside one instruction. Chapter 7.
Alignment: an address being a multiple of some size. Chapter 6.
Arithmetic shift: a right shift that copies the sign bit into the empty positions. Chapter 20.
as-if rule: a compiler may change anything about how a program runs if its observable behavior stays the same. Chapter 33.
Assembler: a program that turns assembly text into machine code. Chapter 1.
Assembly language: machine code written with names instead of numbers. Chapter 1.
Atomic: an operation that happens all at once, with no other thread able to see it half-done. Chapter 30.
Basic block: a straight run of instructions with one way in and one way out. Chapter 11.
Big-endian: storing the high byte of a number at the lowest address. Chapter 6.
Branch: an instruction that may set the program counter somewhere other than the next instruction. Chapter 11.
Branch prediction: the CPU's guess about which way a branch will go. Chapter 12.
.bss: a section of zero-filled data that takes no space in the file. Chapter 9.
Cache: small fast memory holding copies of recently used data. Chapter 27.
Cache line: the unit a cache moves, usually 64 bytes. Chapter 27.
Callee-saved register: a register a function must restore before returning. Chapter 14.
Caller-saved register: a register a call may destroy. Chapter 14.
Calling convention: the rules for where arguments and results go and which registers survive a call. Chapter 14.
Carry flag (CF, C): says an unsigned result did not fit (or, for ARM64 subtraction, that there was no borrow). Chapter 10.
CET: Intel's control-flow enforcement technology (endbr64 and shadow stacks). Chapter 17.
CFI (call frame information): tables that tell debuggers how to unwind the stack. Chapter 15.
CISC / RISC: complex and reduced instruction set designs. Chapter 3.
Clobber: a register or state that your assembly changes but did not declare as an output. Chapter 35.
Compare-and-swap (CAS): an atomic instruction that stores a new value only if the old value matches. Chapter 30.
Computed goto: jumping to an address held in a variable, used for fast interpreter dispatch. Chapter 37.
Critical section: code that only one thread may run at a time. Chapter 30.
Dependency chain: a series of instructions each needing the previous result. Chapter 26.
Dispatch: choosing which handler to run next in an interpreter. Chapter 37.
Displacement: a constant added to an address. Chapter 7.
Endianness: the order of a number's bytes in memory. Chapter 6.
ELF: the executable file format on Linux. Chapter 4.
Epilogue / prologue: the code that ends and starts a function's frame. Chapter 15.
Exclusive monitor: hidden ARM64 state that ldxr and stxr use to detect interference. Chapter 30.
Fence (barrier): an instruction that orders surrounding memory operations. Chapter 31.
Flags: single bits that remember facts about the last operation. Chapter 10.
Frame pointer: a register that marks the base of a stack frame. Chapter 15.
GOT / PLT: the global offset table and procedure linkage table used for calls into shared libraries. Chapter 32.
Immediate: a constant stored inside an instruction. Chapter 6.
Indirect branch: a branch whose target comes from a register or memory. Chapter 13.
Instruction: one command to the CPU. Chapter 1.
ISA (instruction set architecture): the list of instructions and registers a CPU understands. Chapter 1.
JIT: a compiler that runs while the program runs. Chapter 38.
Label: a name for an address. Chapter 1.
Lane: one number inside a vector register. Chapter 29.
Latency: the time from when an instruction's inputs are ready until its result is. Chapter 26.
Leaf function: a function that calls no other function. Chapter 5.
lea: x86-64's "load effective address" instruction, which computes an address without reading memory. Chapter 7.
Link register: ARM64's register (x30) that holds a return address. Chapter 14.
Linker: a program that joins object files and fixes their addresses. Chapter 32.
LL/SC (load-linked, store-conditional): ARM64's ldxr/stxr atomic pair. Chapter 30.
LSE: ARMv8.1's large system extensions, with single-instruction atomics. Chapter 30.
Machine code: the numbers the CPU reads. Chapter 1.
Mask register / predicate: a register of per-lane on/off bits (AVX-512 k, SVE p). Chapter 29.
Memory model: the rules for what order one thread can see another's memory operations. Chapter 31.
Memory-level parallelism: having many cache misses in flight at once. Chapter 27.
Micro-op (µop): a small internal step an instruction is split into. Chapter 26.
Misprediction: a wrong branch guess that wastes work. Chapter 12.
ModRM / SIB / REX: parts of an x86-64 instruction encoding. Chapter 24.
NaN: "not a number," a floating-point value that compares unordered. Chapter 21.
Non-leaf function: a function that makes calls. Chapter 15.
Object file: an assembler's output, with placeholders for the linker. Chapter 32.
Opcode: the part of an instruction that says what it does. Chapter 24.
Out-of-order execution: running instructions in any order that preserves the result. Chapter 26.
Overflow flag (OF, V): says a signed result did not fit. Chapter 10.
Pipeline: overlapping the stages of many instructions. Chapter 12.
Predicate: see mask register.
Prefix: a byte before an instruction's opcode that changes its meaning. Chapter 24.
Program counter (pc, rip): the register that holds the address of the next instruction. Chapter 2.
Red zone: 128 bytes below rsp a leaf function may use without adjusting rsp. Chapter 15.
Register: a small fast storage cell inside the CPU. Chapter 2.
Register renaming: giving each write a fresh physical register to remove false dependencies. Chapter 26.
Relocation: a note telling the linker to patch an address into a place. Chapter 32.
Release (store): a store that makes earlier memory operations visible first. Chapter 31.
Sequentially consistent: all atomic operations appear in one agreed order. Chapter 31.
Sign extension: widening a signed number by copying its sign bit. Chapter 6.
SIMD: single instruction, multiple data. Chapter 29.
Spill: saving a register's value to memory because there are too few registers. Chapter 2.
Stack: memory managed by the stack pointer, growing downward. Chapter 8.
Store buffer: a queue of writes waiting to reach the cache. Chapter 31.
Strength reduction: replacing an operation with a cheaper one. Chapter 5.
Superscalar: able to start several instructions in one cycle. Chapter 26.
SVE: ARM's scalable vector extension. Chapter 29.
SWAR: SIMD within a register. Chapter 22.
System call: a request to the kernel. Chapter 4.
Throughput: how many of an instruction the CPU can start per cycle. Chapter 26.
TLB: a cache of address translations. Chapter 27.
TSO: total store order, x86-64's memory model. Chapter 31.
Two's complement: how both machines store signed numbers. Chapter 18.
Undefined behavior: an action the language gives no meaning to, so the compiler may assume it never happens. Chapter 33.
Unwinding: walking back through the stack frames. Chapter 15.
vDSO: kernel code mapped into every process for cheap calls like reading the time. Chapter 23.
Veneer: a small linker-made stub that reaches a target too far for a branch. Chapter 32.
Vector register: a wide register holding several numbers. Chapter 29.
W^X (write xor execute): memory is never both writable and executable at once. Chapter 38.
Zeroing idiom: an instruction like xor eax, eax that the CPU knows gives zero without waiting for the old value. Chapter 26.
Reading map
Real books, papers, manuals and talks, grouped by topic. I only list things I am confident exist; check each for the current edition or location.
The machines themselves (read the manual for the chip you use)
- Intel 64 and IA-32 Architectures Software Developer's Manual (free PDF). The official definition of x86-64. Start with Volume 1 for the programming model and Volume 2 for each instruction.
- Intel Advanced Performance Extensions (APX) Architecture Specification. The 32-register extension and the REX2 prefix.
- Arm Architecture Reference Manual for A-profile. The official ARM64 definition. Use it for encodings, the memory model and LSE.
- The RISC-V Instruction Set Manual. The shortest complete ISA manual; read it as a third point of comparison.
- System V Application Binary Interface, AMD64 Processor Supplement, and Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64), and Microsoft's x64 calling convention documentation, and Apple's "Writing ARM64 code for Apple platforms." The calling conventions.
Learning assembly and systems
- Bryant and O'Hallaron, "Computer Systems: A Programmer's Perspective." The classic bridge from C to the machine.
- Hennessy and Patterson, "Computer Architecture: A Quantitative Approach." The standard text on how cores, caches and memory systems work.
- Henry S. Warren, "Hacker's Delight." Bit tricks and division by constants, with derivations.
- John R. Levine, "Linkers and Loaders." Object files and linking.
- Jonathan Bartlett, "Programming from the Ground Up." A gentle assembly introduction (older, 32-bit, AT&T syntax).
- Matt Godbolt's Compiler Explorer (godbolt.org) and his talks, especially "What Has My Compiler Done for Me Lately? Unbolting the Compiler's Lid" (CppCon 2017).
Performance and microarchitecture
- Agner Fog's optimization manuals and instruction tables (agner.org/optimize). The standard source for measured latencies and microarchitecture details.
- uops.info, and Abel and Reineke, "uops.info: Characterizing Latency, Throughput, and Port Usage of Instructions on Intel Microarchitectures" (ASPLOS 2019).
- Ulrich Drepper, "What Every Programmer Should Know About Memory" (2007). Caches, lines and prefetching.
- Dougall Johnson's notes on the Apple M1 microarchitecture. A measured look at a core other than x86.
- The LLVM
llvm-mcadocumentation. - Intel, AMD and Arm software optimization guides for the chips you target.
- Kocher et al., "Spectre Attacks: Exploiting Speculative Execution" (2018/2019). Why speculation has security costs.
Arithmetic
- Granlund and Montgomery, "Division by Invariant Integers using Multiplication" (PLDI 1994). The algorithm behind the compiler's divide-by-constant.
- David Goldberg, "What Every Computer Scientist Should Know About Floating-Point Arithmetic" (1991).
SIMD
- The SIMD guidebook in this series.
- Intel Intrinsics Guide (online), and Arm's NEON and SVE documentation.
- Lemire and Langdale, "Parsing Gigabytes of JSON per Second" (VLDB Journal, 2019), and Daniel Lemire's blog.
Concurrency and memory ordering
- Sewell, Sarkar, Owens, Zappa Nardelli and Myreen, "x86-TSO: A Rigorous and Usable Programmer's Model for x86 Multiprocessors" (CACM 2010).
- Pulte, Flur, Deacon, French, Sarkar and Sewell, "Simplifying ARM Concurrency: Multicopy-Atomic Axiomatic and Operational Models for ARMv8" (POPL 2018).
- Batty, Owens, Sarkar, Sewell and Weber, "Mathematizing C++ Concurrency" (POPL 2011).
- Paul E. McKenney, "Is Parallel Programming Hard, And, If So, What Can You Do About It?" (free).
- Jeff Preshing's blog (preshing.com) on lock-free programming.
Compilers, interpreters and JITs
- Robert Nystrom, "Crafting Interpreters." Especially the bytecode virtual machine part.
- Nora Sandler, "Writing a C Compiler."
- Appel, "Modern Compiler Implementation"; Cooper and Torczon, "Engineering a Compiler."
- Poletto and Sarkar, "Linear Scan Register Allocation" (1999); Chaitin et al., "Register Allocation via Coloring" (1981).
- Ertl and Gregg, "The Structure and Performance of Efficient Interpreters" (2003).
- Aycock, "A Brief History of Just-In-Time" (ACM Computing Surveys, 2003).
Rust and assembly
- The Rust Reference, "Inline assembly," and the Rust blog post "Stabilizing naked functions" (July 2025).
- The Rustonomicon, especially the chapters on atomics and on FFI.
- Rust release notes for the version you use (the
asm!and naked function stabilizations).
Tools
- The GNU assembler and binutils manuals (
info as,info ld). - The GDB manual.
- Linux man pages:
syscall(2),open(2),mmap(2),vdso(7),getauxval(3). - The Linux kernel source:
arch/x86/entry/syscalls/syscall_64.tblandinclude/uapi/asm-generic/unistd.h.
End of the guidebook.