05 · Reading C as Machine Code¶
Chapter header
| Target | xv6 — the teaching kernel under QEMU |
| Prerequisites | ch04 |
| What it measures | What the compiler emits for each construct: bench/results/addresses-riscv64.json |
The question¶
Which parts of C are really about addresses, and how do I read them without flinching?
This is not a C tutorial and it does not cover the language. It covers the subset that becomes
visible in machine code, sorted by a single question: has the machine heard of this? Some of
C’s most-argued-about constructs are instructions to the compiler that leave no trace at all.
volatile is a prohibition that survives into every single load. Telling those apart makes kernel
source readable, because a kernel is mostly the second kind.
The material¶
Everything here is an address¶
A pointer is an integer that happens to be the number of a byte, carrying a type that says how many bytes to take from there and how to read them. That is the whole idea, and almost every difficulty with pointers is really a difficulty about one of the questions below. ch03 asked three questions of any declaration — where does this live, how long does it stay there, how big is it. A kernel needs a fourth, and it runs through this chapter: who else can reach it, and what the language lets you say about that.
volatile — the one qualifier the machine has heard of¶
Two functions, identical but for one word — a qualifier, one of the words C lets you attach to a type — each adding up four reads of the same address:
int sysfs_read_four(const int *slot) {
int total = 0;
for (int index = 0; index < READS; index++) {
total += *slot;
}
return total;
}
int sysfs_read_four_volatile(const volatile int *slot) {
int total = 0;
for (int index = 0; index < READS; index++) {
total += *slot;
}
return total;
}
/* `const int values[4]` looks like it promises four. It does not: an array parameter is a
* pointer, the 4 is discarded, and sizeof inside this function measures a pointer. The proof is
* that this and the function below it compile to the same instructions. */The plain one:
sysfs_read_four compiled for riscv64 at -O2 — the xv6 target’s instruction set, 3 instructions.
0000000000000000 <sysfs_read_four>:
0: lw a0,0(a0)
2: slliw a0,a0,0x2
6: retConditions: target xv6 · riscv64 cross compiler, any machine — nothing here was executed · riscv64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -march=rv64gc -mabi=lp64d -c · 2026-09-17 · Source: bench/results/addresses-riscv64.json, code hash 6ea4cbd2185fdb5b.
One load. The compiler reasoned that nothing between the reads could change what is at that address, so it read once and multiplied — and it was right, under the rules it is given.
The volatile one:
sysfs_read_four_volatile compiled for riscv64 at -O2 — the xv6 target’s instruction set, 8 instructions.
0000000000000008 <sysfs_read_four_volatile>:
8: lw a5,0(a0)
a: lw a3,0(a0)
c: lw a4,0(a0)
e: lw a0,0(a0)
10: addw a5,a5,a3
12: addw a5,a5,a4
14: addw a0,a0,a5
16: retConditions: target xv6 · riscv64 cross compiler, any machine — nothing here was executed · riscv64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -march=rv64gc -mabi=lp64d -c · 2026-09-17 · Source: bench/results/addresses-riscv64.json, code hash 6ea4cbd2185fdb5b.
Four loads, in order, none of them removed.
volatile means one thing: every access in the source must appear in the output, and in this
order. It does not mean shared, it does not mean atomic, and it is not a
threading primitive — ch20 is emphatic about that.
It matters when reading an address is not merely reading memory. A device register that returns the next byte of a serial port gives a different answer each time it is read, and a compiler that reads it once and reuses the value has turned your driver into a program that receives one character for ever. ch19 writes that driver.
If you arrived from Java, this is the trap. Java’s volatile is a threading primitive: it
orders accesses between threads, and the language spells out what that guarantees. C’s does none
of that. It constrains the compiler and says nothing whatever to the hardware, so two harts can
still see one another’s writes in a different order from the one they were made in. The keyword
is spelled the same and does a different job, and the instructions that do the other job are
ch20’s.
An array parameter is a promise nobody keeps¶
/* `const int values[4]` looks like it promises four. It does not: an array parameter is a
* pointer, the 4 is discarded, and sizeof inside this function measures a pointer. The proof is
* that this and the function below it compile to the same instructions. */
int sysfs_sum_array(const int values[4]) {
int total = 0;
for (int index = 0; index < READS; index++) {
total += values[index];
}
return total;
}
The declaration says four. The compiler discards that: an array parameter is a pointer, the
size in the brackets is documentation, and sizeof — the operator that gives a thing’s size in
bytes — measures a pointer inside the function rather than an array. The proof is next to it in
the same file — sysfs_sum_pointer takes a plain pointer, does the same work, and compiles to
the same instructions:
sysfs_sum_array compiled for riscv64 at -O2 — the xv6 target’s instruction set, 8 instructions.
0000000000000018 <sysfs_sum_array>:
18: lw a3,0(a0)
1a: lw a5,4(a0)
1c: lw a4,8(a0)
1e: lw a0,12(a0)
20: addw a5,a5,a3
22: addw a5,a5,a4
24: addw a0,a0,a5
26: retConditions: target xv6 · riscv64 cross compiler, any machine — nothing here was executed · riscv64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -march=rv64gc -mabi=lp64d -c · 2026-09-17 · Source: bench/results/addresses-riscv64.json, code hash 6ea4cbd2185fdb5b.
A test in this repository asserts that the two are identical instruction for instruction, so if a compiler ever stops agreeing, the claim fails rather than the sentence quietly becoming false.
An array is not a pointer, but it decays into one nearly everywhere: pass it, and what arrives is an address. That is why kernel functions take a length alongside every buffer, and why ch13’s bounds-check problem is a real bug pattern rather than an invention.
static — a word about who may know¶
/* Nothing outside this file can refer to this, which is what `static` means here. The compiler
* therefore knows every call site, and is free to do what it likes with it. */
static int add_one(int value) { return value + 1; }
int sysfs_call_by_name(int value) { return add_one(value) * 2; }
int sysfs_uses_private(int value) { return add_one(value) * 2; }
static on a function means nothing outside this file may name this. That is a statement to
the linker — the tool that joins compiled files into one program, met properly in
ch15 — and it has a consequence the compiler is quick to take advantage
of: if nobody outside can call it, then the compiler knows every call site, and may do whatever
it likes with them. Here is what it did:
sysfs_uses_private compiled for riscv64 at -O2 — the xv6 target’s instruction set, 3 instructions.
0000000000000040 <sysfs_uses_private>:
40: addiw a0,a0,1
42: slliw a0,a0,0x1
46: retConditions: target xv6 · riscv64 cross compiler, any machine — nothing here was executed · riscv64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -march=rv64gc -mabi=lp64d -c · 2026-09-17 · Source: bench/results/addresses-riscv64.json, code hash 6ea4cbd2185fdb5b.
There is no call. The helper was inlined — its body copied into the caller in place of a call — and then deleted, and if you ask the object file for its symbols the helper is not among them. It has stopped existing as a separate thing.
The keyword is overloaded and the two meanings are unrelated, which is a genuine wart. On a
function or a variable declared outside any function, static restricts linkage — who may
refer to it. On a variable inside a function, it changes storage duration — the variable
outlives the call and there is exactly one of it, for ever, shared by every caller. A kernel uses
both constantly and means something different each time.
Calling through a variable¶
The last function. Instead of naming the one it calls, it takes it as an argument:
int sysfs_call_through(int (*operation)(int), int value) { return operation(value) * 2; }
sysfs_call_through compiled for riscv64 at -O2 — the xv6 target’s instruction set, 9 instructions.
0000000000000048 <sysfs_call_through>:
48: addi sp,sp,-16
4a: sd ra,8(sp)
4c: mv a5,a0
4e: mv a0,a1
50: jalr a5
52: ld ra,8(sp)
54: slliw a0,a0,0x1
58: addi sp,sp,16
5a: retConditions: target xv6 · riscv64 cross compiler, any machine — nothing here was executed · riscv64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 · -O2 -Wall -Wextra -march=rv64gc -mabi=lp64d -c · 2026-09-17 · Source: bench/results/addresses-riscv64.json, code hash 6ea4cbd2185fdb5b.
The jump goes to a register. The target is not in the instruction; it was loaded, and the CPU cannot know where this is going until that load has completed. That is the difference between a direct and an indirect call, and it is the mechanism behind the pattern every operating system uses to talk to hardware it has never heard of:
A table of function pointers. The kernel indexes it; the device supplies it.
A filesystem, a device driver and a network protocol all want the kernel to call their code
when something happens. None of them can be named in the kernel’s source, because they may be
loaded later. So the kernel declares the shape of the table and each of them fills one in, which
is how one read() reaches a disk, a socket and a console.
ch19 uses this for real. ch27 comes back to it with a cost attached, because a branch predictor faced with an indirect call has a much harder problem than one faced with a direct one.
Where it lives, and how long it stays¶
The three storage durations are worth knowing by their consequences rather than their names.
Automatic — an ordinary local. It lives in the current call’s region of memory — its stack frame, which ch14 shows you — and it ceases to exist the moment the function returns. A pointer to it, returned, is a pointer into space that the next call is about to use for something else.
Static — a variable outside any function, or a local marked static. It exists for the whole
run of the program, there is exactly one, and everybody shares it. This is the one that produces
bugs that work perfectly until the second caller arrives, which is what this chapter’s second
problem is.
Allocated — from malloc, or in a kernel from whatever the kernel has instead. It lives until
somebody says otherwise, and deciding who that somebody is has consumed more engineering time
than almost any other question this chapter raises.
xv6’s kernel uses all three within a few hundred lines of each other, and reading it is much easier once you are asking which of these three is this rather than what does this pointer mean.
What we measured¶
Nothing here is a cost. Every listing above is what one compiler emitted for one file, captured by the machinery ch12 explains and regenerated by CI on each push.
Each one turns an assertion into something you can check: that a plain read may
be removed and a volatile one may not; that an array parameter and a pointer parameter produce
identical code; that a static helper can vanish entirely; and that calling through a variable
produces a different instruction from calling by name.
Those are facts about what the compiler did, and this chapter makes two other kinds of statement
as well. Claims about what C permits — that volatile forbids removing an access, that an
array parameter is adjusted to a pointer — are claims about the standard
ISO/IEC JTC1/SC22/WG14 (2018) and would remain true on a compiler that produced entirely different output. Claims about
what any of it costs are not made at all.
What this cannot tell you¶
Whether an indirect call is expensive. It is a different instruction, and this chapter shows that much. Whether it costs more depends on whether the predictor can guess the target, which depends on how many different functions actually flow through that call site at run time. ch27 measures it; a chapter with no clock in it cannot.
Whether volatile is what you want. This chapter shows what it does — no access removed, no
access reordered with respect to another volatile access. It says nothing about whether that is
sufficient for your problem, and for anything involving two harts it is not. ch20 is
where that gets settled, and the answer involves instructions this chapter has not mentioned.
What the optimiser will do with static next time. The helper above was inlined because this
compiler judged it worthwhile. A larger one would not be, and the same source would then contain
a real call. “Static functions are free” is not a rule; “the compiler has more freedom with
static functions” is.
How to write C. Deliberately. This chapter covers what you need to read a kernel and stops there — no style guidance, no idiom catalogue, and nothing about the parts of the language xv6 does not use.
Problems¶
Three, and all three are about the loop this chapter is really teaching: change the source, look at the output.
5.1 — Which C produced this? Four listings and four candidate functions, matched up. The listings are not stored in the repository: the test compiles each candidate with your toolchain and hands you what came out, so the puzzle is generated rather than transcribed. Solving it by eye is possible. Solving it by compiling each candidate and comparing is the point.
python3 -m pytest tests/c_for_people_who_will_read_a_kernel/test_problem_1_which_c.py5.2 — A function that works until it is called twice.
tests/c_for_people_who_will_read_a_kernel/scratchpad.c turns a number into text and is correct in isolation. Called twice
before either answer is used, it is not. Fix it so both answers survive, changing anything you
like about the function and nothing about main.
The C library did exactly this for years, and some of it still does — which is why several
standard functions have an _r on the end.
python3 -m pytest tests/c_for_people_who_will_read_a_kernel/test_problem_2_scratchpad.py5.3 — Build the table.
Four device operations and four functions that implement them. Put each function in the right
slot; the test builds a dispatch table from your answer, calls through it, and checks the
results. Read what the functions in tests/c_for_people_who_will_read_a_kernel/devices.c do rather than what they are called,
because one of the names is a trap.
Getting the declaration of a table of function pointers right is most of the exercise, and it reads badly the first several times. That is not you.
python3 -m pytest tests/c_for_people_who_will_read_a_kernel/test_problem_3_dispatch.pyWhere to go next¶
The C standard ISO/IEC JTC1/SC22/WG14 (2018) is the authority on which of this chapter’s claims are about the language
rather than about a compiler — in particular §6.7.6.3 on the adjustment of array parameters, and
§6.7.3 on what volatile requires.
xv6’s own source Cox et al. (n.d.) is now worth opening, and this is the chapter that makes it
possible. Its own commentary Cox et al. (n.d.), written by the people who wrote the kernel and given away
by MIT, is the best explanation of what that code does; Appendix G maps its topics
onto this book’s chapters, and traces one system call through every layer both of them describe.
Start with kernel/uart.c, which is short and contains a volatile device register, a static
buffer with exactly the sharing problem this chapter’s second problem describes, and a lock. You will not understand the lock yet. Read it anyway and come back after
ch20.
Part II is next, and it leaves the kernel behind: six chapters on a machine with nothing on it, each building one mechanism — a trap, an interrupt, a page table — from the hardware alone, before any kernel is read. Reading C as machine code resumes in ch14, which stops reading C and starts reading what it became: registers, the calling convention, and a stack frame you can walk by hand.
- ISO/IEC JTC1/SC22/WG14. (2018). ISO/IEC 9899:2018, Programming languages — C. https://www.iso.org/standard/74528.html
- Cox, R., Kaashoek, M. F., & Morris, R. (n.d.). xv6-riscv. MIT PDOS. https://github.com/mit-pdos/xv6-riscv
- Cox, R., Kaashoek, M. F., & Morris, R. (n.d.). xv6: a simple, Unix-like teaching operating system. MIT. https://pdos.csail.mit.edu/6.1810/