EC-10.2 From C to Assembly and Back
en
What this is and why it exists
Reading compiler output is the cheapest way to make the hardware and software boundary concrete.
It is also a skill that keeps paying. It is how performance arguments get settled instead of debated, and how a startup failure on a new board gets diagnosed. It is also how undefined behaviour stops being an abstraction.
No assembly is written in this topic. Only read.
The vocabulary
- Assembly listing — the human-readable machine code a compiler produced.
- Label — a name for an address, used as a target for a jump.
- Stack frame — the region of stack belonging to one active function call.
- Frame pointer — a register holding a fixed reference point within the current frame.
- Prologue and epilogue — the instructions at the start and end of a function that set up and tear down its frame.
- Scaled offset — an address formed from a base plus an index multiplied by the element size.
- Optimisation level — a setting deciding how aggressively the compiler rewrites your code.
- Undefined behaviour — a program action the language does not define, allowing any result.
The mental model
Every toolchain can be asked for the assembly instead of the finished binary. That one flag is the whole entry cost to this topic.
Start with a function of about five lines and compare it with its output. Do this a dozen times and the patterns become obvious. Almost everything you will read consists of three shapes.
A loop is a comparison and a jump backwards. A conditional is a comparison and a jump forwards over the part being skipped. A function call saves a return address somewhere and jumps, and the return jumps back to it.
Nested structures are those three shapes inside each other. A switch is either a chain of comparisons or a table of addresses, depending on how many cases there are.
The stack frame is the next idea and it is worth drawing by hand once. Each active call owns a region of stack holding its return address, the registers it must preserve, and its local variables.
The prologue sets that region up and the epilogue takes it down. Drawing the stack for a function that calls another, which calls a third, makes several things concrete at once. Why recursion works. Why a local variable's address stops being valid after the function returns. And what stack overflow physically is.
Pointers and arrays turn out to be the same thing from below. An array index becomes an address plus an index scaled by the element size. A pointer is that address held in a register.
Seeing the arithmetic explains why the two notations compile to identical instructions. It also explains why pointer arithmetic advances by the size of the type rather than by one. It also explains why an out-of-range index is not detected: the instruction is an addition, and additions do not fail.
Optimisation is where reading the output stops being an exercise and becomes essential. A compiler may remove variables, reorder work, unroll a loop, inline a whole function, or delete a calculation whose result is never used.
The output need not resemble your source in any way. Its only obligation is to produce the same observable behaviour. Comparing the same function at two optimisation levels is the fastest possible lesson in what that obligation does and does not require.
That leads to undefined behaviour, seen from below, which is where the topic earns its place. When a program does something the language does not define, the compiler is entitled to assume it never happens.
The consequences are alarming when first seen. A check written after a pointer has already been used may be deleted, because the earlier use means the pointer cannot have been null. A loop that overflows a signed counter may be assumed not to terminate, or assumed to terminate, whichever helps.
None of this is the compiler being malicious. It is the compiler optimising on an assumption the language gave it permission to make. Reading the output is how you see that happen, and once seen it is never again mysterious.
What you should now be able to explain or do
- Obtain an assembly listing from your toolchain and orient yourself in it.
- Identify a loop, a conditional and a function call in machine code.
- Draw the stack frames for a nested call and explain recursion and stack overflow from the drawing.
- Explain why array indexing and pointer arithmetic compile to the same instructions.
- Compare a function at two optimisation levels and describe what the compiler changed.
- Explain how undefined behaviour lets a compiler delete a check you wrote.
Check yourself
What three shapes account for most of the assembly you will read?
A loop, which is a comparison and a backward jump. A conditional, which is a comparison and a forward jump. A call, which saves a return address and jumps.
Why does a local variable's address stop being valid after its function returns?
Because it lived in that call's stack frame, and the epilogue released the frame. The memory is still there and will be reused by the next call.
Your source has a variable that never appears in the output. Is that a bug?
No. The compiler only has to reproduce observable behaviour, so a variable whose value is never observed can be kept in a register or removed entirely.
How can a compiler delete a null check you wrote?
If the pointer was already used before the check, the language says the program would have been undefined had it been null. The compiler may therefore assume it is not, and remove the test.
Go deeper
We haven't checked most of these for screen reader use yet.
Back to From C to Assembly and Back: work through the checklist