How a Compiler Actually Works
16.0 What this chapter gives you#
- You will be able to name every stage between the text you type and the electricity that runs, in the right order, and say what each one does.
- You will be able to take one five-word C function and follow it through preprocessing, tokens, tree, symbol table, intermediate code, optimization, assembly, object file, executable and running process.
- You will be able to read real assembly output and explain every single line.
- You will be able to say why compilers use an intermediate language in the middle instead of going straight to machine instructions.
- You will be able to explain why
-O2turned our program into three instructions, and why that is legal. - You will be able to explain what a linker does, why “undefined reference” happens, and why the order of libraries on the command line matters.
- You will be able to explain what happens at the moment you press Enter on a program, including the dynamic linker, the GOT and the PLT.
- You will be able to compare compiled, bytecode and interpreted execution with real numbers, and explain why JavaScript got fast.
- You will be able to explain the bootstrap problem and Ken Thompson’s 1984 trusting-trust attack to somebody who has never programmed.
- You will be able to run the commands yourself and see the same output.
Our one running example, for the whole chapter, is this file. We will call it add.c. Everything below is that file at a different moment of its life.
int add(int a, int b) { return a + b; }
int main(void) { return add(2, 3); }
Every command shown was run on Ubuntu 24.04 on an x86-64 machine, with GCC 13.3.0 and Clang 18.1.3. The outputs pasted here are the real outputs.
16.1 The whole pipeline in one view#
PLAIN16.1.1 in simple words#
- A computer chip does not understand the words you type. It understands numbers, and only a small fixed set of them.
- A compiler is a program that reads your text and writes those numbers.
- It does not do this in one jump. It does it in a chain of small steps.
- Each step takes one shape of information and turns it into a simpler shape.
- First the text is cleaned up and glued together with other files.
- Then it is chopped into small pieces, like words in a sentence.
- Then those pieces are arranged into a tree, the way a sentence has a subject and an object.
- Then the meaning is checked: do these names exist, do the types match.
- Then it is rewritten in a simple made-up language that is easy to improve.
- Then it is improved: shortened, simplified, things that do nothing removed.
- Then it is written as instructions for one particular chip.
- Then those instructions become raw bytes in a file.
- Then several such files are glued into one program.
- Then the operating system loads it into memory and starts it.
PLAIN16.1.2 a picture in your head#
- Think of a factory line that turns a handwritten recipe into a packed meal.
- Station one gathers all the recipe pages that the main page refers to.
- Station two reads the pages aloud, one word at a time.
- Station three works out the grammar: which words are the ingredients, which are the actions, what belongs inside what.
- Station four checks it makes sense: you cannot fry a number.
- Station five rewrites the whole thing as a plain numbered work order.
- Station six removes wasted steps: no need to boil water you never use.
- Station seven writes the work order in the exact words this kitchen’s machines accept.
- Station eight packs it in a box. Station nine puts several boxes in a crate.
- Station ten unpacks the crate onto a table and starts cooking.
Where this comparison breaks: a factory line moves one item forward and never looks back. A real compiler often loops. The optimizer runs dozens of passes over the same code, some of them many times, and a later pass can undo an earlier one. Also, several stations can be the same piece of code in memory, not separate programs. The clean chain is a teaching model, not the shape of the source code inside GCC or Clang.
PLAIN16.1.3 a worked example#
Here is the chain, drawn for add.c.
add.c (the text you typed)
|
v
[ preprocessor ] glue in #include, expand #define
|
v
[ lexer ] text -> tokens: int, add, (, int, a, ...
|
v
[ parser ] tokens -> a tree of the program
|
v
[ semantic ] names, scopes, types, symbol table
|
v
[ IR builder ] tree -> simple three-address code
|
v
[ optimizer ] fold, propagate, inline, delete
|
v
[ code generator ] IR -> assembly text for one CPU
|
v
[ assembler ] assembly -> object file add.o (bytes)
|
v
[ linker ] add.o + C library -> one executable
|
v
[ loader ] exec: map into memory, bind symbols
|
v
a running process
One line for each stage, for add.c:
- Preprocessor: nothing to do here, there are no
#lines. Output is the same two lines of text. - Lexer: produces 32 tokens plus an end marker.
- Parser: produces a tree with two function declarations at the top.
- Semantic analysis: records that
addtakes twointand returnsint, and that the call inmainmatches. - IR: produces
_3 = a + b; return _3;foradd. - Optimizer at
-O2: noticesadd(2,3)is always 5. - Code generator: writes
leal (%rdi,%rsi), %eaxforadd. - Assembler: writes the bytes
8d 04 37for that instruction. - Linker: fills in the address of
addinsidemain’s call instruction. - Loader: maps the file into memory and jumps to the entry point.
- The process returns 5.
echo $?prints5.
PLAIN16.1.4 what is really happening inside#
- Most of these “stages” are functions inside one program, passing data structures to each other, not separate processes.
- But some really are separate programs. On Linux,
gccis only a driver: it runs other programs and passes files between them. - Running
gcc -v add.cshows the driver’s real children. The three that matter arecc1,asandcollect2. cc1is the actual C compiler. It readsadd.cand writes assembly text to a temporary file.asis the assembler. It reads that text and writes a temporary.ofile.collect2is a wrapper aroundld, the linker. It writes the executable.- So on a normal Linux box, compiling one C file starts three extra programs.
- Each stage narrows what is possible. After lexing, you can no longer ask about spaces. After parsing, you can no longer ask about brackets. After code generation, you can no longer ask about variable names.
- That narrowing is the point. Every stage throws away information the next stage does not need, so the next stage can be simpler.
TECHNICAL16.1.5 the engineer’s version#
- The classical decomposition is front end, middle end, back end. The front end is language specific, the back end is target specific, and the middle end is neither.
- The front end covers preprocessing, lexical analysis, syntax analysis and semantic analysis, and emits an intermediate representation (IR).
- The middle end runs target-independent optimization passes on the IR.
- The back end performs instruction selection, instruction scheduling and register allocation, and emits assembly or machine code directly.
- The canonical reference is Compilers: Principles, Techniques, and Tools by Alfred Aho, Ravi Sethi and Jeffrey Ullman, 1986, known as the Dragon Book. The 2006 second edition adds Monica Lam.
- The first true optimizing compiler was the IBM FORTRAN compiler for the IBM 704, delivered in April 1957 under John Backus. It took about 18 staff-years and its output had to beat hand-written assembly to be accepted.
- Grace Hopper’s A-0 system of 1952 predates it but was closer to a linking loader than to a modern compiler.
- GCC 1.0 was released by Richard Stallman in March 1987. LLVM began as Chris Lattner’s work at the University of Illinois, with LLVM 1.0 in 2003.
Observing the pipeline with real commands:
gcc -v add.c -o prog # show cc1, as, collect2
gcc -E add.c # stop after the preprocessor
clang -Xclang -dump-tokens -fsyntax-only add.c
clang -Xclang -ast-dump -fsyntax-only add.c
clang -S -emit-llvm add.c -o add.ll
gcc -S add.c -o add.s # stop after code generation
gcc -c add.c -o add.o # stop after the assembler
| Stage | GCC name | Clang / LLVM name |
|---|---|---|
| Preprocess | cpp inside cc1 | clang -E |
| Parse + types | cc1 front end | clang front end |
| Middle IR | GIMPLE, then RTL | LLVM IR |
| Optimize | tree and RTL passes | LLVM pass manager |
| Emit assembly | cc1 back end | LLVM target backend |
| Assemble | GNU as | integrated assembler |
| Link | ld via collect2 | lld or ld |
WORDS16.1.6 remember these#
Compiler — a program that turns your text into machine numbers — a translator from a source language to a target language, usually with static checking. Pass — one walk over the program — a single traversal of the IR performing one analysis or transformation. Front end — the part that understands your language — lexing, parsing, semantic analysis, IR generation. Back end — the part that knows the chip — instruction selection, scheduling, register allocation, code emission. Driver — the program you actually type — gcc or clang, which orchestrates the real compiler, assembler and linker as child processes.
16.2 The preprocessor#
PLAIN16.2.1 in simple words#
- Before the compiler looks at your program, another tool edits the text.
- That tool is the preprocessor. It only understands lines starting
#. #include "file.h"means: delete this line and paste that whole file here.#define TWO 2means: everywhere the wordTWOappears later, write2.#ifand#ifdefmean: keep this block of text or throw it away.- That is all it does. It moves text around. It does not know what C is.
- It does not know types. It does not know functions. It cannot count.
- The compiler never sees your
#includelines. It sees the result.
PLAIN16.2.2 a picture in your head#
- Imagine a school essay where you are told to write
see page 40instead of copying a long definition. - Before the teacher marks it, a helper goes through and physically pastes page 40 in place of every
see page 40. - The helper also has a list of shorthands: wherever you wrote
WHO, writeWorld Health Organization. - The helper does not read for meaning. If you wrote
see page 40inside a joke, page 40 gets pasted into the joke. - The teacher then marks one long essay with no shortcuts left in it.
Where this comparison breaks: the helper in this story is careful. The C preprocessor is not. It will happily paste text that produces nonsense, and it will report the error at the pasted line, not at your line. Macro expansion also happens repeatedly until nothing changes, which no human helper would do.
PLAIN16.2.3 a worked example#
Split our example into a header and a source file.
/* addh.h */
#ifndef ADD_H
#define ADD_H
#define TWO 2
#define THREE 3
int add(int a, int b);
#endif
/* main2.c */
#include "addh.h"
int main(void) { return add(TWO, THREE); }
Now run gcc -E main2.c. The real tail of the output is:
# 1 "main2.c"
# 1 "addh.h" 1
int add(int a, int b);
# 2 "main2.c" 2
int main(void) { return add(2, 3); }
- The
#includeline has gone. The declaration from the header sits in its place. TWOandTHREEhave become2and3.- The
#ifndefguard lines have gone. They produced no text. - The lines beginning
#with numbers are line markers. They tell the compiler “the next line really came from file X, line N”, so error messages point at your file and not at the merged text. - The blank lines are the preprocessor keeping line numbers lined up.
Now a real size measurement, on the same machine:
$ printf '#include <stdio.h>\nint main(void){return 0;}\n' > hello.c
$ wc -l < hello.c
2
$ gcc -E hello.c | wc -l
815
$ gcc -E hello.c | wc -c
21292
- Two lines of yours became 815 lines and about 21 kB of text.
- It pulled in 29 distinct files.
- In C++ it is far worse.
#include <iostream>with GCC 13.3 on this machine expanded to 36,584 lines.
PLAIN16.2.4 what is really happening inside#
- The preprocessor runs in phases defined by the C standard. The important ones, in order, are: join lines ending in a backslash, replace comments with a single space, then execute the
#directives, then join adjacent string literals. - The header guard trick,
#ifndef ADD_H/#define ADD_H/#endif, exists because a header may be included twice through different paths. - Without a guard, the second include pastes the same declarations again, which is an error for anything defined rather than merely declared.
- With the guard, the second visit finds
ADD_Halready defined and skips the whole file, producing no text. #pragma oncedoes the same job in one line. It is not in the C or C++ standard, but every major compiler supports it. That makes it a convention, not a standard.- Conditional compilation,
#if defined(_WIN32), is how one source file serves several operating systems. The text for the other systems never reaches the compiler at all. - Macros are textual, so they have famous traps.
#define SQ(x) x*xthenSQ(1+2)becomes1+2*1+2, which is 5, not 9. Parentheses fix it:#define SQ(x) ((x)*(x)).
TECHNICAL16.2.5 the engineer’s version#
- The C preprocessor is specified in the C standard, clause 6.10. It is a standard, not an implementation detail.
- Translation phases 1 to 4 of the standard cover trigraph mapping, line splicing, comment removal and directive execution.
- Predefined macros include
__FILE__,__LINE__,__DATE__,__TIME__,__STDC__and__STDC_VERSION__.__STDC_VERSION__is199901Lfor C99,201112Lfor C11,201710Lfor C17 and202311Lfor C23. #include <x.h>searches the system include path.#include "x.h"searches the current directory first, then the system path.gcc -I dirprepends a directory.gcc -E -vprints the search list actually used.- Macro expansion is not recursive on the macro currently being expanded, which is what stops
#define X Xfrom looping forever. #in a function-like macro stringizes its argument.##pastes tokens together. Both are standard.- Preprocessing cost is real. Header expansion is the main reason C++ builds are slow, and is the motivation for precompiled headers and for C++20 modules, standardized in ISO C++20 and still only partially implemented in toolchains as of 2026.
| Command | What it shows |
|---|---|
gcc -E f.c |
Preprocessed text |
gcc -E -P f.c |
Same, without line markers |
gcc -dM -E f.c |
Every macro defined |
gcc -H -c f.c |
Header include tree |
gcc -MMD -c f.c |
Write a .d dependency file |
The honest version: saying “the preprocessor is a separate program” is a useful lie. It once was, as /lib/cpp. In GCC today it is a library linked into cc1, and Clang implements it as a token source feeding the lexer, so text is never fully materialized. You get the same answer either way, which is why the lie is safe.
WORDS16.2.6 remember these#
Preprocessor — the text editor that runs before the compiler — the phase that executes # directives, per C standard clause 6.10. Macro — a shorthand replaced by text — an object-like or function-like replacement list expanded during translation phase 4. Header guard — a trick to stop a file being pasted twice — an #ifndef / #define / #endif idiom giving idempotent inclusion. Translation unit — one source file plus everything it pasted in — the input the compiler proper actually sees. Conditional compilation — keeping or dropping blocks of text — #if, #ifdef, #elif, #else, #endif evaluated on preprocessing tokens.
16.3 Lexing: characters into tokens#
PLAIN16.3.1 in simple words#
- After the text is glued together, it is still just a row of characters.
- The lexer reads that row left to right and groups characters into words.
- Each word it produces is a token: a small labelled piece.
intbecomes a keyword token.addbecomes an identifier token.2becomes a number token.+becomes an operator token.- Spaces, tabs, newlines and comments produce no tokens at all. They only separate things.
- The lexer does not care about order.
+ + + )is fine by the lexer. It is the next stage that objects. - The lexer’s only real skill is to always take the longest match. In
a+++bit producesa,++,+,b, nota,+,+,+,b.
PLAIN16.3.2 a picture in your head#
- Imagine a long strip of paper with no spaces:
THECATSATONTHEMAT. - Your job is to cut it into words and label each one: noun, verb, article.
- You have a list of legal words. You scan from the left, and at each point you take the longest legal word you can.
- You never look ahead at the sentence’s meaning. You never ask if the sentence is sensible. You just cut and label.
- You hand the next person a stack of labelled cards, in order.
Where this comparison breaks: English needs meaning to cut correctly. C is designed so that cutting never needs meaning. That was a deliberate language design decision, and languages that break it, such as C++ with its >> in nested templates, cause real trouble for their own compilers.
PLAIN16.3.3 a worked example#
This is the real token stream for our file, from clang -Xclang -dump-tokens -fsyntax-only add.c, trimmed to the token and the text:
int 'int' identifier 'main'
identifier 'add' l_paren '('
l_paren '(' void 'void'
int 'int' r_paren ')'
identifier 'a' l_brace '{'
comma ',' return 'return'
int 'int' identifier 'add'
identifier 'b' l_paren '('
r_paren ')' numeric_constant '2'
l_brace '{' comma ','
return 'return' numeric_constant '3'
identifier 'a' r_paren ')'
plus '+' semi ';'
identifier 'b' r_brace '}'
semi ';' eof ''
r_brace '}'
- Read the left column top to bottom, then the right column. That is the whole file: 32 tokens, then an end-of-file marker.
- The keywords
int,voidandreturnget their own token kinds. add,main,aandbare allidentifier. The lexer does not know thataddis a function andais a parameter. It has no idea what they are.2and3arenumeric_constant. The text is kept, not the value. Working out that2means the number two happens later.- Every space and every newline vanished.
- Clang also records the exact line and column of every token. That is why its error messages can point a caret at one character.
PLAIN16.3.4 what is really happening inside#
- A lexer is a machine with a small memory: a finite automaton.
- It has a current state, it reads one character, and that character plus the state decides the next state. That is all the memory it has.
- Start in state “nothing”. See a letter, go to state “reading an identifier”. Keep reading letters and digits. See something else, stop, and emit an identifier token.
- Start in state “nothing”. See a digit, go to state “reading a number”. Keep reading digits. Stop at the first non-digit.
- Because the memory is one state and nothing else, a lexer cannot count. It cannot check that brackets match. That job belongs to the parser.
- After building an identifier, the lexer looks the text up in a fixed table of keywords. If
intis in the table, it is a keyword. Ifaddis not, it is an identifier. This is the standard trick. - Handling comments, string literals with escapes, and numeric suffixes such as
10ULare all extra states in the same machine.
TECHNICAL16.3.5 the engineer’s version#
- Lexical structure is specified with regular expressions. Regular expressions and finite automata describe exactly the same class of languages, a result from Stephen Kleene’s work in 1951 and 1956.
- The standard mechanical route is: regular expression -> nondeterministic finite automaton (NFA) by Thompson’s construction, 1968 -> deterministic finite automaton (DFA) by subset construction -> minimized DFA -> a table.
- That construction is why generated lexers are fast. Each input character costs one table lookup, so lexing is linear in the input size.
- Lex was written by Mike Lesk and Eric Schmidt at Bell Labs, described in a 1975 technical report. Flex, the free rewrite, was written by Vern Paxson starting in 1987 and is what most Unix systems ship today.
- The rules in a
.lfile are pattern-action pairs. Flex resolves conflicts by two fixed rules: longest match wins, and among equal-length matches, the rule written earliest wins. - Production compilers usually hand-write the lexer instead. Clang’s
Lexer.cppand GCC’slibcppare hand-written, because hand-written code handles error recovery, source locations and preprocessor interaction better. - The maximal munch rule is in the C standard: the next token is the longest character sequence that could form one. It is why C++11 needed a special case so
>>can close two templates.
| Token class | Example text | C standard term |
|---|---|---|
| Keyword | int, return |
keyword |
| Identifier | add, a |
identifier |
| Number | 2, 0x1f, 3.5f |
constant |
| String | "three" |
string literal |
| Operator | +, ==, -> |
punctuator |
WORDS16.3.6 remember these#
Token — one labelled word of the program — the smallest unit the parser consumes, carrying a kind, spelling and source location. Lexer — the tool that cuts text into tokens — the scanner implementing the lexical grammar, usually as a DFA. Regular expression — a pattern for matching text shapes — a formula denoting a regular language, equivalent in power to a finite automaton. Finite automaton — a machine whose whole memory is which state it is in — a five-tuple of states, alphabet, transition function, start state and accepting states. Maximal munch — always take the longest legal word — the longest-match rule mandated by the C and C++ lexical grammars.
16.4 Parsing: tokens into a tree#
PLAIN16.4.1 in simple words#
- A row of tokens has no shape.
return a + b ;is a flat list. - The parser gives it shape by building a tree.
- In the tree,
+sits aboveaandb, because+is the thing being done andaandbare the things it is done to. returnsits above the+, because returning is done to the result of the addition.- The tree is called an abstract syntax tree, or AST.
- It is called abstract because the brackets and semicolons disappear. Their only job was to tell the parser the shape, and now the shape is the tree.
- A syntax error is the parser reaching a token that cannot legally follow what it has already seen.
- That is all a syntax error is. It is not about meaning. It is about shape.
PLAIN16.4.2 a picture in your head#
- Think of the sentence “the cat sat on the mat”.
- A flat list of six words tells you nothing about who did what.
- A tree tells you: the action is “sat”, the doer is “the cat”, the place is “on the mat”.
- Now a rule set: a sentence is a noun phrase followed by a verb phrase. A noun phrase is an article followed by a noun. Follow the rules and the tree assembles itself.
- A syntax error is “the cat sat on the”: you have run out of sentence while a rule still needs a noun.
Where this comparison breaks: English is ambiguous, so the same sentence can have several correct trees. “I saw the man with the telescope” has two. Real programming language grammars are designed hard to avoid this, and where it creeps in, the language adds a written tie-breaking rule, such as C’s “an else belongs to the nearest unmatched if”.
PLAIN16.4.3 a worked example#
The rules of a language are written in Backus-Naur Form. Here is a tiny slice, enough for our function:
function ::= type identifier "(" params ")" block
block ::= "{" statement* "}"
statement ::= "return" expression ";"
expression ::= expression "+" term | term
term ::= identifier | number | call
call ::= identifier "(" arglist ")"
Read ::= as “is made of”, | as “or”, * as “zero or more”. Now the real AST for our file. This is a redrawing of the actual output of clang -Xclang -ast-dump -fsyntax-only add.c:
TranslationUnit
|
+- FunctionDecl add : int (int, int)
| +- ParmVarDecl a : int
| +- ParmVarDecl b : int
| +- CompoundStmt
| +- ReturnStmt
| +- BinaryOperator '+' : int
| +- DeclRefExpr a : int
| +- DeclRefExpr b : int
|
+- FunctionDecl main : int (void)
+- CompoundStmt
+- ReturnStmt
+- CallExpr : int
+- DeclRefExpr add : int (int, int)
+- IntegerLiteral 2 : int
+- IntegerLiteral 3 : int
TranslationUnitis the whole file.- Under it are exactly two things, our two functions.
CompoundStmtis the{ ... }block. The braces themselves are gone.BinaryOperator '+'has two children. That is the whole meaning ofa + b.CallExprhas three children: what to call, then each argument in order.- The semicolons are gone. The parentheses are gone. Nothing is lost, because the tree already says what they said.
And a real syntax error, from gcc -c synerr.c:
synerr.c:1:36: error: expected expression before ';' token
1 | int add(int a, int b) { return a + ; }
| ^
The parser had read a and +, so its rule demanded another expression. It got ;. No rule allows that, so it stopped and reported the column.
PLAIN16.4.4 what is really happening inside#
- There are two main families of parser: top-down and bottom-up.
- Top-down starts from “this should be a function” and tries to prove it by matching smaller and smaller pieces.
- Recursive descent is top-down written by hand. You write one function per grammar rule.
parseStatementcallsparseExpression, which callsparseTerm. The call stack of the compiler mirrors the tree being built. - Bottom-up does the opposite. It pushes tokens onto a stack, and whenever the top of the stack matches the right-hand side of a rule, it replaces those items with the left-hand side. That is called a reduce.
- Bottom-up parsers are usually generated by a tool from a grammar file, because the tables are far too tedious to write by hand.
- Precedence is the rule that
2 + 3 * 4is 14, not 20. A hand-written parser gets it by layering: the+function calls the*function, so*binds tighter and sits deeper in the tree. - Associativity is the rule that
a - b - cmeans(a - b) - c. It decides whether the tree leans left or right.
TECHNICAL16.4.5 the engineer’s version#
- Programming language syntax is described by a context-free grammar (CFG), level 2 in the Chomsky hierarchy from Noam Chomsky’s 1956 classification.
- Backus-Naur Form came from John Backus in 1959 and Peter Naur’s editing of the ALGOL 60 report in 1960. Donald Knuth proposed the name in 1964. Extended BNF adds
*,+,?and grouping. - LL(k) parsers read Left to right and build a Leftmost derivation, with k tokens of lookahead. Recursive descent is the hand-written form of LL(1).
- LR(k) parsers read Left to right and build a Rightmost derivation in reverse. LALR(1) is the practical variant used by Yacc and Bison. LR grammars are strictly more powerful than LL grammars.
- Yacc was written by Stephen C. Johnson at Bell Labs, published as a 1975 technical report. GNU Bison is the free replacement, from Robert Corbett in the mid-1980s. ANTLR, by Terence Parr from 1989, generates adaptive ALL(*) parsers.
- Real compilers have moved back to hand-written recursive descent. GCC replaced its Bison C++ parser in GCC 3.4, 2004, and its C parser in GCC 4.1, 2006. Clang was recursive descent from the start. The reason is error messages and recovery, not speed.
- Two classic grammar problems. The dangling else is settled by a rule in the C standard. The C typedef ambiguity makes
(A)*ba cast or a multiplication depending on whetherAis a type.- C parsers settle the second by feeding symbol table facts back into the parser. That breaks the clean stage separation, and is called the lexer hack.
- Operator precedence in C has 15 levels. Precedence climbing, also called Pratt parsing after Vaughan Pratt’s 1973 paper, handles all of them in one compact function instead of 15 nested ones.
| Parser style | Reads | Typical tool |
|---|---|---|
| Recursive descent | LL(1), hand | none, written by hand |
| LALR(1) table | bottom-up | Yacc, Bison |
| GLR | ambiguous CFG | Bison in GLR mode |
| PEG / packrat | ordered choice | pegjs, Rust pest |
| ALL(*) | adaptive LL | ANTLR 4 |
WORDS16.4.6 remember these#
Parser — the part that finds the shape of the program — the syntax analyser that builds a parse tree or AST from a token stream. Abstract syntax tree — a tree of what the code does — a tree of language constructs with punctuation removed, the front end’s main data structure. Grammar — the written rules of the language’s shape — a context-free grammar, usually given in BNF or EBNF. Recursive descent — one function per grammar rule — a hand-written predictive top-down parser, normally LL(1) with local backtracking. Syntax error — the shape is wrong — no production allows the current token given the parser state; reported with the source location of that token.
16.5 Semantic analysis: does it make sense#
PLAIN16.5.1 in simple words#
- A sentence can have correct shape and still be nonsense. “The number ate the Tuesday” is grammatical and meaningless.
- Semantic analysis is the stage that catches the meaningless ones.
- It asks: does this name exist here? Have you used it before declaring it? Have you declared it twice?
- It asks: do the types fit? Can you add these two things? Can you pass this thing where that thing is expected?
- It builds a symbol table: a list of every name, what it is, what type it has, and where it is visible.
- Most of the errors a working programmer sees come from this stage, not from the parser.
PLAIN16.5.2 a picture in your head#
- Imagine a hotel with a guest register at the front desk.
- When somebody checks in, you write their name and room in the register.
- When a letter arrives for “Mr Smith”, you look the name up. If he is not in the register, the letter cannot be delivered.
- The hotel has floors. Each floor has its own small register. When looking up a name you check the floor you are on, then the floor below, then the ground floor register.
- When a floor closes for the night, its small register is thrown away, and those names stop meaning anything.
Where this comparison breaks: a hotel register is one flat list per floor. A real symbol table also records types, storage class, linkage, whether the thing is a function or a variable, and how the compiler will refer to it later. And hotel names are unique; C deliberately lets an inner name hide an outer one.
PLAIN16.5.3 a worked example#
The symbol table for our file, after analysing it, holds these entries:
| Name | Kind | Type | Scope |
|---|---|---|---|
| add | function | int (int, int) | file |
| a | parameter | int | body of add |
| b | parameter | int | body of add |
| main | function | int (void) | file |
- When the parser reaches
a + binsideadd, semantic analysis looks upa. It finds the parameter, whose type isint. - It looks up
b. Alsoint. - It checks that
+accepts twointvalues. It does. The result type of the whole expression is recorded asint. - It checks that the type being returned matches the function’s declared return type.
intandint. Fine. - In
main, it looks upadd, finds a function of typeint (int, int), checks the call has two arguments, and checks each argument’s type.
Now break it on purpose. Change the call to add(2, "three"). Real output from clang -c typeerr.c:
typeerr.c:2:32: error: incompatible pointer to integer
conversion passing 'char[6]' to parameter of type 'int'
2 | int main(void) { return add(2, "three"); }
| ^~~~~~~
typeerr.c:1:20: note: passing argument to parameter 'b' here
1 | int add(int a, int b) { return a + b; }
| ^
1 error generated.
- The shape of this program is perfectly legal. The parser was happy.
- Only the type checker complains, and it can quote two places: the call and the declaration. It could only do that because the symbol table remembered where
addwas declared. - GCC 13.3 makes this a warning by default and still compiles. Clang 18 makes it an error. Experts disagree on default strictness; GCC 14 turned several such warnings into errors in 2024. Use
-Werrorto be strict.
PLAIN16.5.4 what is really happening inside#
- The symbol table is usually a stack of hash tables, one per scope.
- Entering a
{pushes a new table. Leaving}pops it. - Looking up a name searches the top table, then the one below, and so on. The first hit wins. That is exactly why an inner variable hides an outer one.
- Type checking walks the AST from the leaves upward. Each node computes its own type from its children’s types.
IntegerLiteral 2says “I am int”.DeclRefExpr aasks the symbol table and says “I am int”.BinaryOperator +sees two ints and says “I am int”.- Where types nearly match, C inserts a silent conversion. The Clang AST for our file shows
ImplicitCastExpr <LValueToRValue>aroundaandb. That node means “read the value out of the variable”. The compiler added it; you did not write it. - Type inference is the same walk in reverse: the compiler works out a type instead of checking a written one. In C++
auto x = 2 + 3;the type ofxis deduced from the right-hand side.
TECHNICAL16.5.5 the engineer’s version#
- Semantic analysis covers name resolution, scope and lifetime rules, type checking, implicit conversion insertion, constant expression evaluation, and language-specific rules such as C’s requirement that a
caselabel be a constant. - C has four scopes in the standard: file, block, function prototype and function (labels only). Linkage is separate from scope:
staticat file level means internal linkage, nostaticmeans external linkage. - Clang stores this in a
Semaobject with anIdentifierResolverand a chain ofDeclContexts. GCC usesbinding_levelstructures. Both amount to a scoped hash table. - Full Hindley-Milner inference, from J. Roger Hindley in 1969 and Robin Milner in 1978, infers types for a whole program without annotations. ML, OCaml and Haskell use it. Rust and C++ use weaker local inference, so errors stay near the mistake.
- The dividing line matters for error quality. Missing semicolon is a parse error. Undeclared identifier, wrong argument count, wrong argument type, assigning to a
const, duplicate definition and returning the wrong type are all semantic errors. - Borrow checking in Rust and lifetime analysis are semantic analysis too, done on a control-flow graph after the AST. They are the reason
rustcis slower than a C front end. - Useful flags:
gcc -Wall -Wextra -Werror,gcc -fsyntax-onlyto stop after this stage,clang -Xclang -ast-dumpto see the typed tree.
| Error | Stage that catches it |
|---|---|
Missing ; |
Parser |
Unbalanced { |
Parser |
| Undeclared variable | Semantic |
| Wrong argument type | Semantic |
| Wrong number of args | Semantic |
| Missing function body | Linker |
| Divide by zero at run time | Nobody, it crashes |
WORDS16.5.6 remember these#
Symbol table — the list of every name and what it means — a scoped mapping from identifier to declaration, type, storage class and linkage. Scope — the region where a name is visible — the block, file, prototype or function region defined by the language standard. Type checking — making sure the pieces fit — verifying every expression’s type against the operator or context that consumes it. Implicit conversion — a change the compiler adds silently — a cast node inserted into the AST, such as C’s integer promotions and lvalue-to-rvalue conversion. Name resolution — deciding which declaration a name refers to — lookup through the scope chain, obeying shadowing and linkage rules.
16.6 Intermediate representation#
PLAIN16.6.1 in simple words#
- After the tree is checked, the compiler could write chip instructions straight away. Almost none do.
- Instead they rewrite the program in a simple made-up language of their own. That is the intermediate representation, or IR.
- The IR is deliberately boring. Every line does one tiny thing. No nesting, no shortcuts, no cleverness.
- Boring is useful. It is much easier to spot a wasted step in a flat list of tiny operations than in a tree full of nested expressions.
- It is also useful because the IR does not belong to any chip. The same IR can later be turned into instructions for many different chips.
- And it does not belong to any language either. C, C++, Rust and Swift can all produce the same IR, and then share one optimizer and one code generator.
PLAIN16.6.2 a picture in your head#
- Think of an international airport with one central hub.
- There are many airlines flying in from many countries, and many destinations flying out.
- Without a hub you would need a direct route from every origin to every destination. Ten origins and ten destinations means a hundred routes.
- With a hub, every origin flies to the hub, and the hub flies to every destination. Ten plus ten. Twenty routes instead of a hundred.
- The IR is the hub. Languages fly in, chips fly out.
Where this comparison breaks: a passenger arrives at the hub unchanged. A program does not. Most of the real work, and most of the compiler’s total running time, happens while the program is sitting in the hub being rewritten.
PLAIN16.6.3 a worked example#
Here is our add function in GCC’s IR, called GIMPLE, from gcc -O1 -fdump-tree-gimple=/dev/stdout -S add.c:
int add (int a, int b)
{
int D.2746;
D.2746 = a + b;
return D.2746;
}
int main ()
{
int D.2748;
D.2748 = add (2, 3);
return D.2748;
}
- Notice the temporary name
D.2746. The compiler invented it. - Every statement has at most one operator and at most three names: one destination and two sources. That is why this style is called three-address code.
- Now the same thing in Static Single Assignment form, from
gcc -O1 -fdump-tree-ssa=/dev/stdout -S add.c:
int add (int a, int b)
{
int _3;
<bb 2> :
_3 = a_1(D) + b_2(D);
return _3;
}
- Every name now has a version number.
a_1,b_2,_3. <bb 2>is a basic block: a run of instructions with no branch in the middle. Control enters at the top and leaves at the bottom.- And here is our whole file in LLVM IR, real output from
clang -O0 -S -emit-llvm add.c, with the attribute lines removed:
define dso_local i32 @add(i32 noundef %0, i32 noundef %1) {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, ptr %3, align 4
store i32 %1, ptr %4, align 4
%5 = load i32, ptr %3, align 4
%6 = load i32, ptr %4, align 4
%7 = add nsw i32 %5, %6
ret i32 %7
}
define dso_local i32 @main() {
%1 = alloca i32, align 4
store i32 0, ptr %1, align 4
%2 = call i32 @add(i32 noundef 2, i32 noundef 3)
ret i32 %2
}
i32means a 32-bit integer.%0and%1are the two parameters.allocareserves space on the stack. At-O0the compiler dutifully stores both parameters to the stack and loads them straight back. That is pointless, and the optimizer will delete it.add nswmeans add with “no signed wrap”: the compiler is allowed to assume signed overflow never happens. Hold on to that; it comes back in 16.7.
PLAIN16.6.4 what is really happening inside#
- Static Single Assignment, or SSA, means every name is written exactly once in the whole function.
- If your source assigns
xthree times, the IR hasx_1,x_2andx_3. - Why bother? Because now, if you see a use of
x_2, there is exactly one place it could have come from. No searching. No guessing. - That single property makes constant propagation, dead code removal and value numbering enormously simpler and faster.
- Branches create a problem: after an
if, which version ofxis live? SSA solves it with a phi node, writtenPHI, which says “take the version from whichever block we came from”. - Here is a real phi from a loop,
for (i = 0; i < n; i++) s += i;, dumped by GCC with-fdump-tree-ssa:
<bb 4> :
# s_1 = PHI <s_3(2), s_8(3)>
# i_2 = PHI <i_4(2), i_9(3)>
if (i_2 < n_5(D))
goto <bb 3>;
else
goto <bb 5>;
- Read
s_1 = PHI <s_3(2), s_8(3)>as: if we arrived from block 2,s_1iss_3; if from block 3, it iss_8. - Phi nodes are not real instructions. Late in the back end they are removed by inserting copies into the predecessor blocks.
TECHNICAL16.6.5 the engineer’s version#
- SSA was formalized by Ron Cytron, Jeanne Ferrante, Barry Rosen, Mark Wegman and Kenneth Zadeck in Efficiently computing static single assignment form and the control dependence graph, ACM TOPLAS, October 1991.
- Placing phi nodes minimally requires dominance frontiers, computed by the Lengauer-Tarjan dominator algorithm of 1979 or Cooper, Harvey and Kennedy’s simpler 2001 method.
- GCC uses two IRs in sequence. GIMPLE is a three-address, SSA-capable, tree-ish IR added in GCC 4.0, 2005. RTL, Register Transfer Language, is the older low-level IR used by the back end.
- LLVM uses a single strongly typed SSA IR with three equivalent forms: an in-memory form, a human-readable
.lltext form, and a compact.bcbitcode form.llvm-asandllvm-disconvert between text and bitcode. - LLVM IR is not a portable virtual machine. It embeds the target data layout and pointer sizes, visible in our dump as
target triple = "x86_64-pc-linux-gnu". Shipping.bcand expecting it to run anywhere is a common misunderstanding. - The front-end / middle-end / back-end split is what makes the ecosystem work. Clang, Rust, Swift, Julia, Zig and
flangall emit LLVM IR. LLVM 18 registers over 30 target backends, from x86 and AArch64 to RISC-V, WebAssembly, NVPTX and AVR. - Other IR designs exist. Java bytecode and CPython bytecode are stack machines, not three-address. WebAssembly is a structured stack machine. Cranelift, used by Wasmtime, uses its own SSA IR built for compile speed rather than peak output quality.
| IR | Owner | Style |
|---|---|---|
| GIMPLE | GCC | 3-address, SSA |
| RTL | GCC back end | low-level, registers |
| LLVM IR | LLVM | typed SSA |
| Java bytecode | JVM | stack machine |
| CPython bytecode | CPython | stack machine |
| Wasm | W3C | structured stack |
WORDS16.6.6 remember these#
Intermediate representation — the compiler’s own simple language — a program form between source and machine code, target and language neutral in the middle end. Three-address code — one operation, one result, two inputs per line — a linearized IR form of the shape t1 = a op b. SSA — every name written exactly once — static single assignment form, with phi functions merging values at control-flow joins. Basic block — a straight run with no branches inside — a maximal instruction sequence with a single entry and a single exit. Phi node — “take whichever value we came in with” — a pseudo-instruction at a join point selecting a value by predecessor block.
16.7 Optimization#
PLAIN16.7.1 in simple words#
- The optimizer rewrites your program so it does less work but produces the same visible result.
- It is allowed to change anything you cannot observe. It is not allowed to change what you can observe.
- If the answer is always the same number, compute it now and store the number.
- If a value never changes, replace the name with the value.
- If a result is never used, delete the code that made it.
- If the same sum is computed twice, compute it once.
- If a function is tiny, paste its body into the caller instead of calling it.
- Do all of these repeatedly, because each one exposes new chances for the others.
PLAIN16.7.2 a picture in your head#
- Imagine editing a set of instructions for making tea for a guest.
- Step 3 says “boil water”. Step 9 says “boil water” again. Delete step 9 and reuse the first pot. That is common subexpression elimination.
- Step 5 says “count the sugar packets in the drawer” every time round the stirring loop, but nobody adds packets. Move it outside the loop. That is loop-invariant code motion.
- Step 7 says “fetch a lemon” and no later step uses the lemon. Delete step 7. That is dead code elimination.
- Step 2 says “look up the boiling point of water in the book”. You know it is
- Write 100. That is constant folding.
- Step 8 says “consult the separate one-line card called stir”. Just write “stir” here. That is inlining.
Where this comparison breaks: you may delete “fetch a lemon” only if fetching lemons has no other effect. In a real program, a “useless” call might print something, write a file or set a flag another thread reads. Deciding whether a step is observable is the hard part, and it is most of what an optimizer does.
PLAIN16.7.3 a worked example#
This is the heart of the chapter. Compile our unchanged file two ways.
gcc -O0 -S add.c, the whole add function, with directives removed:
add:
endbr64
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl $3, %esi
movl $2, %edi
call add
popq %rbp
ret
gcc -O2 -S add.c, the same file:
add:
endbr64
leal (%rdi,%rsi), %eax
ret
main:
endbr64
movl $5, %eax
ret
- Seventeen instructions became five.
- Why is
addnow one instruction? At-O0the compiler stores both arguments to the stack and reloads them, because-O0keeps every variable in memory for the debugger. At-O2that is deleted. leal (%rdi,%rsi), %eaxcomputesrdi + rsiand puts it ineax.leameans load effective address: it does the address arithmetic but does not touch memory. It is used here as a free three-operand add.- Why is
mainnowmovl $5, %eax? Three optimizations in sequence.- Inlining:
addis tiny, so its body is pasted intomain, givingreturn 2 + 3;. - Constant folding:
2 + 3is computed at compile time, givingreturn 5;. - Dead code elimination: nothing else in
mainis needed, so the frame setup, the call, and the stack work all disappear.
- Inlining:
addstill exists as a separate function because it has external linkage: another file might call it. Mark itstaticand the whole function vanishes from the output.- The program still returns 5.
./a.out; echo $?prints5. That is the only thing that had to stay true.
Other optimizations, each verified on this machine with GCC 13.3 at -O2:
| Source | Output at -O2 |
|---|---|
3 * 4 + 10 / 2 |
movl $17, %eax |
x * 8 |
leal 0(,%rdi,8), %eax |
int u = a*999; return a+1; |
leal 1(%rdi), %eax |
a*b+1 and a*b+2 summed |
one imull, then leal |
- Row one is constant folding: the whole expression is a compile-time constant.
- Row two is strength reduction: multiply by 8 became a scaled address computation, which is cheaper than a general multiply.
- Row three is dead code elimination: the multiply by 999 is gone.
- Row four is common subexpression elimination:
a*bwas written twice and is computed once.
PLAIN16.7.4 what is really happening inside#
- Loop unrolling copies a loop body several times so the loop test runs less often. Our recursive
tail(n, acc)function was turned by GCC into a loop and then unrolled by a factor of two. - Tail call elimination is what made that possible. When the last thing a function does is call something and return its result, the call can reuse the current stack frame. A self tail call then becomes a jump, that is, a loop.
- Even across separate files, GCC at
-O2turnedcall add; retinto a plainjmp add. That is a tail call:add’s ownretreturns straight tomain’s caller. - Vectorization does several elements at once. For
for (i=0;i<n;i++) out[i] = in[i] * 2.0f;GCC at-O3emittedmovupsandaddps.addpsis “add packed single”, four 32-bit floats in one instruction, using the 128-bit SSE registers. - Register allocation decides which values live in registers. x86-64 has 16 general purpose registers, a few reserved, so roughly 12 to 14 are usable. Values that do not fit must be spilled to the stack.
- Chaitin’s method models this as graph colouring. Each value is a node, and two nodes are joined if they are live at the same moment. Colouring with k colours, k being the register count, is exactly a valid allocation.
- Graph colouring is NP-complete in general, so compilers use heuristics: repeatedly remove any node with fewer than k neighbours, and if none exists, pick a node to spill and try again.
TECHNICAL16.7.5 the engineer’s version#
- The legal limit on all of this is the as-if rule. An implementation may do anything, provided observable behaviour matches the abstract machine. The C standard defines that as volatile accesses, input and output, and the state at termination.
- GCC optimization levels, as documented for GCC 13:
-O0none,-O1about 47 passes,-O2most non-size-increasing passes,-O3adds vectorization and heavier inlining,-Osoptimizes for size,-Ofastis-O3plus-ffast-math,-Ogkeeps debugging usable. - Floating point is not associative. IEEE 754 arithmetic rounds each operation, so
(a+b)+canda+(b+c)can differ. Real run on this machine witha = 1e20f,b = -1e20f,c = 1.0f:
(a+b)+c = 1
a+(b+c) = 0
- That is why compilers must not reassociate floating point by default.
-ffast-mathgrants permission to reassociate, to assume no NaN or infinity, and to flush denormals. On this machine, adding0.1ften times prints1.00000012, not1. - Undefined behaviour lets the compiler assume the impossible never happens. Signed integer overflow is undefined in C. Real GCC 13.3 output at
-O2:
int check(int x) { return x + 1 > x; }
check:
movl $1, %eax # always true, folded
ret
unsigned check_u(unsigned x) { return x + 1 > x; }
check_u:
xorl %eax, %eax # real comparison kept
cmpl $-1, %edi
setne %al
ret
- The signed version was folded to a constant
1. The unsigned version was not, because unsigned overflow is defined to wrap, sox + 1 > xreally is false whenxisUINT_MAX. - Compiling the signed version with
-fwrapvrestores the comparison, against2147483647.-fwrapvis a GCC and Clang extension defining signed overflow as wrapping. It is an implementation choice, not the C standard. - Undefined behaviour also erases null checks after a dereference, deletes infinite loops with no side effects, and assumes strict aliasing. Tools:
-fsanitize=undefined,-fsanitize=address, and Clang’s-Rpass=inlineto see what was inlined.
| Optimization | What it removes |
|---|---|
| Constant folding | Compile-time arithmetic |
| Constant propagation | Reads of known values |
| Dead code elimination | Unused computations |
| Common subexpr elim | Repeated identical work |
| Strength reduction | Expensive operators |
| Loop-invariant motion | Repeated loop work |
| Inlining | Call overhead |
| Tail call elimination | Stack frame growth |
WORDS16.7.6 remember these#
Constant folding — do the sums now, not later — evaluating constant expressions at compile time. Inlining — paste the function body in — replacing a call with the callee’s body, enabling further optimization at the call site. Dead code elimination — delete what nobody uses — removing instructions whose results are not live and have no side effects. Spilling — running out of registers and using memory — storing a live value to the stack frame because the allocator could not colour it. As-if rule — you may change anything nobody can see — the standard’s permission to transform a program while preserving observable behaviour. Undefined behaviour — the standard gives no rules for this — a construct for which the standard imposes no requirements, letting the optimizer assume it never occurs.
16.8 Code generation#
PLAIN16.8.1 in simple words#
- Now the compiler must write instructions for one actual chip.
- Three jobs happen here, tangled together.
- Instruction selection: pick which real instructions do what the IR said. The IR said “add”. The chip may offer
add,leaorinc. - Instruction scheduling: pick the order. Some instructions wait for results; put unrelated work in the gap.
- Register allocation: pick which of the chip’s few fast slots holds which value, and put the rest in memory.
- There is also a rule book that everybody must obey: where arguments go, where the answer comes back, who is allowed to break which register.
- That rule book is the calling convention. It is what lets a function compiled today call a library compiled ten years ago.
PLAIN16.8.2 a picture in your head#
- Think of handing work to a colleague through a hatch with numbered trays.
- Everyone in the building has agreed: the first thing you are passing goes in tray 1, the second in tray 2, and the answer comes back in tray 0.
- Some trays are yours to scribble on; the colleague may empty them. Other trays they must hand back exactly as they found them.
- If you both follow the agreement, neither of you needs to know anything about how the other works inside.
- If you disagree about which tray is which, everything breaks in ways that look like random corruption.
Where this comparison breaks: real conventions also cover stack alignment, variable argument lists, structures too big for a register, floating point in separate registers, and where the return address lives. And there is no single agreement: Linux, Windows and ARM each chose different trays.
PLAIN16.8.3 a worked example#
Here is our -O0 add function again, and this time every line is annotated.
add:
endbr64 # landing pad for indirect jumps (CET)
pushq %rbp # save caller's frame pointer
movq %rsp, %rbp # this function's frame starts here
movl %edi, -4(%rbp) # store arg 1 (a) into the frame
movl %esi, -8(%rbp) # store arg 2 (b) into the frame
movl -4(%rbp), %edx # load a back into edx
movl -8(%rbp), %eax # load b back into eax
addl %edx, %eax # eax = a + b
popq %rbp # restore caller's frame pointer
ret # jump back to the return address
%ediand%esiare the low 32 bits ofrdiandrsi. On Linux those are the first two integer argument registers.%eaxholds the return value. The result of the addition is already there, so no extra move is needed.- The two stores and two loads are pure
-O0overhead, as explained in 16.7. pushq %rbpandmovq %rsp, %rbpare the prologue.popq %rbpandretare the epilogue.endbr64is not part of the calling convention. It is Intel Control-flow Enforcement Technology, enabled by default on Ubuntu with-fcf-protection=full. It marks a legal target for an indirect jump.
And main:
main:
endbr64
pushq %rbp # prologue
movq %rsp, %rbp
movl $3, %esi # second argument = 3
movl $2, %edi # first argument = 2
call add # push return address, jump to add
popq %rbp # epilogue; eax already holds 5
ret # return to the C runtime
- Arguments are loaded second-first here. That order is the compiler’s choice, not a rule. Only which register holds which argument is fixed.
callpushes the address of the next instruction onto the stack and jumps.- Nothing copies the result.
addleft it ineaxandmainreturns it fromeax.
The stack while add is running:
higher addresses
+-----------------------------+
| main's frame |
+-----------------------------+
| return address into main | <- pushed by call
+-----------------------------+
| saved rbp | <- pushed by prologue
+-----------------------------+ <- rbp now points here
| -4(%rbp) copy of a |
| -8(%rbp) copy of b |
+-----------------------------+ <- rsp
| 128-byte red zone | usable without moving rsp
+-----------------------------+
lower addresses
PLAIN16.8.4 what is really happening inside#
- Instruction selection is usually done by tree pattern matching. The IR is covered with the cheapest set of instruction patterns that fits.
- The cost model matters.
imulby 8 costs about 3 cycles on many x86 cores;leawith a scale of 8 costs 1. That is why GCC choselea. - Instruction scheduling matters most on machines that issue several instructions per cycle. Modern out-of-order x86 chips reorder anyway, so scheduling is worth less there than on an in-order core.
- The same source compiled for AArch64 with
clang --target=aarch64-linux-gnu -O2gives:
add:
add w0, w1, w0
ret
main:
mov w0, #5
ret
- Same structure, different names.
w0andw1are the low 32 bits ofx0andx1, the first two AArch64 argument registers, andx0is also the return register. - The same source targeting Windows with
clang --target=x86_64-pc-windows-msvc -O0stores from%ecxand%edxinstead of%ediand%esi, because Windows chose different argument registers. - One source file, three sets of rules, all correct.
TECHNICAL16.8.5 the engineer’s version#
- The three conventions side by side. All three are written specifications, not conventions in the loose sense: System V AMD64 ABI, Microsoft x64 calling convention, and Arm’s AAPCS64.
| Role | SysV AMD64 | Win x64 | AArch64 |
|---|---|---|---|
| Int arg 1 | rdi | rcx | x0 |
| Int arg 2 | rsi | rdx | x1 |
| Int arg 3 | rdx | r8 | x2 |
| Int arg 4 | rcx | r9 | x3 |
| Int args 5-6 | r8, r9 | stack | x4, x5 |
| Float args | xmm0-xmm7 | xmm0-xmm3 | v0-v7 |
| Return | rax (rdx:rax) | rax | x0 (x1 too) |
| Shadow space | none | 32 bytes | none |
| Red zone | 128 bytes | none | none |
| Frame pointer | rbp | rbp | x29 |
| Return address | on stack | on stack | x30 (lr) |
- Callee-saved registers on System V AMD64 are rbx, rbp, r12, r13, r14, r15. A function that uses them must restore them. Everything else is caller-saved: if you need it after a call, save it yourself.
- Stack alignment: System V AMD64 requires
rsp + 8to be 16-byte aligned at acall, sorspis 16-byte aligned on entry after the return address is pushed. AAPCS64 requiressp16-byte aligned at all public interfaces. - The red zone is 128 bytes below
rspthat a leaf function may use without adjustingrsp. Signal handlers must not clobber it. Kernel code is compiled with-mno-red-zonefor exactly this reason. - Structures larger than 16 bytes are passed in memory on System V AMD64; smaller ones are classified field by field into INTEGER and SSE classes. The classification algorithm in the ABI document is one of the fiddliest parts of the whole convention.
- Register allocation in production: GCC uses IRA, an integrated regional allocator, plus LRA for reload, replacing the old
reloadpass in GCC 4.8,- LLVM’s default is a greedy allocator based on priority and live range splitting, not classical Chaitin colouring. Linear scan allocation, from Massimiliano Poletto and Vivek Sarkar in 1999, is preferred by JITs because it is far faster to run.
- Useful commands:
gcc -S -masm=intelfor Intel syntax,objdump -d --no-show-raw-insn,perf annotateto see which instruction is hot, andllvm-mcato model instruction throughput on a named CPU.
WORDS16.8.6 remember these#
Calling convention — the shared rules about who puts what where — the ABI specification of argument registers, return registers, stack alignment and register preservation. Prologue — the few instructions that set up a function — saving the frame pointer and reserving stack space on entry. Epilogue — the few instructions that clean up — restoring saved registers and returning. Stack frame — one function’s private scratch area — the region between the frame pointer and the stack pointer holding locals, spills and saved registers. Callee-saved register — one you must hand back unchanged — a register the ABI requires the called function to preserve across the call. Instruction selection — choosing which real instructions to use — covering the IR with target patterns under a cost model.
16.9 The assembler#
PLAIN16.9.1 in simple words#
- The compiler’s output is still text.
addl %edx, %eaxis nine characters. - The assembler turns each such line into the bytes the chip actually reads.
- It is a much simpler program than the compiler. Mostly it is a lookup table plus arithmetic on addresses.
- It also collects your program into named piles called sections.
- Code goes in one pile. Numbers you gave starting values go in another. Numbers that start at zero go in a third that takes no file space at all.
- It records every name you defined and every name you used but did not define.
- Where an address is not yet known, it writes zeros and leaves a note. Those notes are called relocations.
- Its output is an object file, ending
.oon Unix and.objon Windows.
PLAIN16.9.2 a picture in your head#
- Think of typesetting a book chapter that refers to other chapters.
- You can set every word of your own chapter into metal type immediately.
- But “see chapter 9, page …” cannot be set, because you do not know the page number yet. Other people are still setting chapter 9.
- So you leave a gap of the right size and write a note in the margin: “fill this gap with the start page of chapter 9”.
- You hand the printer your typeset pages plus your list of margin notes.
- Later, somebody assembling the whole book fills every gap.
Where this comparison breaks: some relocations are not simple substitutions. They store a difference between two addresses, or an offset from the current position, or an index into a table filled at program start. The note in the margin has a type, and there are dozens of types.
PLAIN16.9.3 a worked example#
Run gcc -c add.c -o add.o, then objdump -d add.o:
0000000000000000 <add>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 5d pop %rbp
17: c3 ret
0000000000000018 <main>:
18: f3 0f 1e fa endbr64
1c: 55 push %rbp
1d: 48 89 e5 mov %rsp,%rbp
20: be 03 00 00 00 mov $0x3,%esi
25: bf 02 00 00 00 mov $0x2,%edi
2a: e8 00 00 00 00 call 2f <main+0x17>
2f: 5d pop %rbp
30: c3 ret
- The left column is the offset inside the section. The middle column is the actual bytes. The right column is the text form.
01 d0is two bytes. That is the entireadd %edx,%eaxinstruction.c3is one byte:ret.- Look at offset
2a. Thecallise8followed by00 00 00 00. The assembler did not know whereaddwould end up, so it wrote four zero bytes. objdumpguesses the target as2f, which is simply “the next instruction”, because the offset is zero. That is not the real target. It is a hole.- Now the note in the margin, from
readelf -r add.o:
Relocation section '.rela.text':
Offset Type Sym. Name + Addend
00000000002b R_X86_64_PLT32 add - 4
- Read it as: at byte offset
0x2b, which is the four zero bytes, write the distance from here toadd, minus 4. nm add.oshows the symbols:0000000000000000 T addand0000000000000018 T main.Tmeans defined in the text section.
PLAIN16.9.4 what is really happening inside#
- The assembler makes two passes. Pass one measures every instruction and works out where each label lands. Pass two writes the bytes, using the label addresses from pass one.
- Two passes are needed because of forward references: a jump to a label further down the file cannot be encoded until that label’s position is known.
- Sections keep unlike things apart so the operating system can protect them differently:
.textis machine code. It ends up readable and executable, not writable..rodatais constants, such as string literals. Readable, not writable..datais variables with a non-zero starting value. Readable and writable, and its contents occupy space in the file..bssis variables starting at zero. Readable and writable, and it takes no file space at all: only a size is recorded.
readelf -S add.oon our file shows.textat 0x31 bytes and both.dataand.bssat zero bytes, because our program has no global variables.- That is why a program declaring a 10 MB zeroed array does not have a 10 MB executable. The
.bssentry says “10 MB of zeros” in a few bytes.
TECHNICAL16.9.5 the engineer’s version#
- The object file format on Linux and most Unix systems is ELF, Executable and Linkable Format, introduced with UNIX System V Release 4 around 1988 and adopted by Linux in the mid-1990s, replacing a.out. macOS uses Mach-O, from NeXTSTEP. Windows uses PE/COFF.
- Our
add.ocontains 13 section headers. The important ones are.text,.rela.text,.data,.bss,.symtab,.strtab,.shstrtaband.eh_frame. .symtabis the symbol table,.strtabholds the symbol name strings, and.shstrtabholds the section name strings. Names are stored once and referenced by offset..eh_frameholds unwind information generated from the.cfi_*directives in the assembly. It is what lets a debugger produce a backtrace and what lets C++ exceptions unwind the stack. It is generated even for C.- Common x86-64 relocation types:
R_X86_64_64absolute 64-bit,R_X86_64_PC3232-bit program-counter relative,R_X86_64_PLT32call through the procedure linkage table,R_X86_64_GOTPCRELload through the global offset table.R_X86_64_RELATIVEis applied at load time for position-independent executables. - Symbol binding is
LOCAL,GLOBALorWEAK.nmprintsTfor a global text symbol,tfor local,Ufor undefined,Bfor.bss,Dfor.data,Wfor weak. - Our whole object file is 169 bytes of text, data and bss combined, per
size add.o. The linked executable reports 1302 bytes of text, because the C runtime start-up code has been added.
| Command | What it shows |
|---|---|
objdump -d f.o |
Disassembled code |
objdump -h f.o |
Section headers, sizes |
readelf -S f.o |
Full section table |
readelf -r f.o |
Relocation entries |
readelf -s f.o |
Symbol table |
nm f.o |
Symbols, short form |
size f.o |
text, data, bss totals |
WORDS16.9.6 remember these#
Assembler — turns instruction text into bytes — a two-pass translator from assembly mnemonics to encoded machine instructions plus metadata. Object file — a half-finished program with holes — a relocatable ELF, Mach-O or COFF file containing sections, symbols and relocations. Section — one named pile of similar bytes — a named region such as .text, .data, .bss or .rodata with its own permissions. Relocation — a note saying “fill this hole later” — a record naming an offset, a symbol and a formula for computing the value to patch in. Symbol — a name the outside world can see — a named entry binding an identifier to a section and offset, with a binding and a visibility.
16.10 The linker#
PLAIN16.10.1 in simple words#
- Real programs are many files. Each is compiled separately into an object file full of holes.
- The linker puts them all together and fills every hole.
- It stacks all the
.textsections into one.text, all the.datainto one.data, and so on. - Now every function has a final address, so every hole can be filled with a real number.
- Then it checks that every name that was used somewhere is defined somewhere.
- If a name is used and never defined, it stops and says undefined reference. That is the most famous error message in C.
- It also pulls in code from libraries: bundles of ready-made object files.
PLAIN16.10.2 a picture in your head#
- Think of assembling one book from chapters written by different authors.
- Each author numbered their pages from 1. You must renumber so the whole book runs from 1 to 400.
- Every “see page 12” inside chapter 3 must be adjusted by however much chapter 3 moved.
- Every “see the chapter on rivers” must be turned into a real page number by looking up which chapter that is.
- If somebody wrote “see the chapter on volcanoes” and no such chapter exists, the book cannot be finished. That is undefined reference.
- A static library is a shelf of spare chapters. You copy in only the ones somebody referred to.
- A dynamic library is a separate book that stays separate. Your book says “look this up in the other book”, and the reader must own that book.
Where this comparison breaks: the linker does more than renumber. It merges duplicate definitions of inline functions and templates, discards unused sections, can reorder functions for cache locality, and with link-time optimization can re-run the whole optimizer across chapter boundaries.
PLAIN16.10.3 a worked example#
Split our example across two files: main2.c calling add, and addonly.c defining it. Compile main2.c alone and try to link:
$ gcc main2.c -o prog2
/usr/bin/ld: /tmp/cckot5dw.o: in function `main':
main2.c:(.text+0x13): undefined reference to `add'
collect2: error: ld returned 1 exit status
- The compiler was perfectly happy: the header declared
add, so the call type checked. - The linker was not, because no object file defines
add. - Now link both:
gcc main2.o addonly.o -o prog2. It works, and returns 5.
Look at what relocation actually did. In add.o the call was e8 00 00 00 00. In the linked executable, objdump -d:
0000000000001129 <add>:
1129: f3 0f 1e fa endbr64
...
0000000000001141 <main>:
1141: f3 0f 1e fa endbr64
...
1153: e8 d1 ff ff ff call 1129 <add>
- The four zero bytes became
d1 ff ff ff, which as a signed 32-bit little-endian number is-47. - The instruction after the call starts at
0x1158. And0x1158 - 47 = 0x1129, which is exactly whereaddended up. - That is the whole of relocation, in one number.
Now libraries. Real commands and real sizes on this machine:
$ ar rcs libadd.a addonly.o # static library
$ gcc main2.c -L. -ladd -o prog_static
$ gcc -fPIC -shared addonly.c -o libadd.so # shared library
$ gcc main2.c -L. -ladd -o prog_dyn -Wl,-rpath,'$ORIGIN'
| File | Size in bytes |
|---|---|
| libadd.a | 1372 |
| libadd.so | 15112 |
| prog_static | 15840 |
| prog_dyn | 15944 |
Both programs return 5. In prog_static the add code is inside the executable. In prog_dyn it is not, and ldd prog_dyn lists libadd.so => /tmp/kb/./libadd.so.
PLAIN16.10.4 what is really happening inside#
- A static library
.afile is just an archive of object files, made byar.ar -t libadd.aprintsaddonly.o. - The linker treats an archive specially: it does not include everything. It scans the archive and pulls in only the members that resolve a symbol still undefined at that moment.
- That is why link order matters. The linker walks the command line left to right, keeping a set of currently undefined symbols.
- Real proof on this machine.
gcc -L. -ladd main2.cfails with “undefined reference toadd”, whilegcc main2.c -L. -laddsucceeds. Same files, different order. - The reason: when the library was scanned, nothing needed
addyet, so no member was pulled in.main2.ccame later and asked for a symbol nobody was still looking for. Put objects first and libraries last. - In C++ the situation is harder, because C++ allows several functions with the same name. The linker only handles plain names, so the compiler encodes the full signature into the name. That is name mangling.
- Real output from
nmon a C++ file, alongsidenm -Cwhich demangles:
_Z3addii -> add(int, int)
_Z3adddd -> add(double, double)
_ZN2kb1T3addEii -> kb::T::add(int, int)
add_c -> add_c
- Read
_Z3addiias: mangled name, 3-letter nameadd, argumentsi,i. The last one isextern "C", which turns mangling off, which is exactly how C and C++ call each other.
TECHNICAL16.10.5 the engineer’s version#
- Linker phases: read inputs, resolve symbols, assign section addresses via a layout script, apply relocations, write output, optionally strip.
- Library naming, all conventions rather than standards:
.astatic and.soshared on Linux,.libstatic and.dlldynamic on Windows,.astatic and.dylibdynamic on macOS. Windows also uses an import.libthat describes a.dllwithout containing its code. - Symbol visibility controls what a shared library exports. GCC and Clang accept
-fvisibility=hiddenplus__attribute__((visibility("default")))on the few symbols you mean to export. Smaller export tables mean faster loading and better optimization. Windows uses__declspec(dllexport)and__declspec(dllimport). - Shared library code must be position independent, built with
-fPIC, because it can be mapped at different addresses in different processes. Most Linux distributions have built executables as position independent (PIE) by default since around 2017. - Link-time optimization keeps the IR in the object file instead of only machine code, so the optimizer can run across file boundaries at link time. Real measurement on this machine, with
addin a separate file frommain:gcc -O2 main2.c addonly.cgavemaina tail call:mov $3,%esi; mov $2,%edi; jmp 1150 <add>.gcc -O2 -flto main2.c addonly.cgavemainexactlymov $0x5,%eax; ret.
- That is the same inline-then-fold sequence from 16.7, now working across translation units, which plain
-O2cannot do. GCC’s LTO arrived in GCC 4.5,- LLVM offers full LTO and ThinLTO, the latter published by Teresa Johnson and colleagues in 2017 for much better build parallelism.
- Linker choices as of 2026: GNU
ld, the original;gold, 2008, ELF only and dropped from recent binutils; LLVMlld, the default in many toolchains; andmoldby Rui Ueyama, 1.0 in 2022, the fastest widely used linker for large C++ builds. - Weak symbols let a definition be overridden.
__attribute__((weak))is how a library provides a default that a program may replace.
| Symptom | Usual cause |
|---|---|
| undefined reference to X | Object or library missing |
| undefined reference in C++ | Missing extern "C", mangling |
| multiple definition of X | Definition in a header |
| library before object fails | Wrong command-line order |
| runs, then “cannot open .so” | Library not on the run path |
WORDS16.10.6 remember these#
Linker — the tool that glues object files into a program — the program that merges sections, resolves symbols and applies relocations. Static library — a shelf of object files you copy from — an ar archive whose members are pulled in only to satisfy undefined symbols. Shared library — a separate file loaded at run time — a .so, .dll or .dylib mapped into the process by the dynamic linker. Name mangling — encoding the full signature into the symbol name — the compiler scheme that gives overloaded and namespaced C++ entities distinct linker names. Link-time optimization — optimizing across file boundaries — keeping IR in object files so the optimizer runs at link time. Undefined reference — somebody used a name nobody defined — an unresolved symbol remaining after all inputs have been scanned.
16.11 The loader and dynamic linking#
PLAIN16.11.1 in simple words#
- A finished executable is a file on disk. It is not yet a running program.
- When you type its name and press Enter, the operating system creates a process and puts the file’s contents into that process’s memory.
- The part of the operating system that does this is the loader.
- It does not copy the whole file. It maps it, meaning it says “this range of memory corresponds to this part of this file”, and lets the pages arrive when they are first touched.
- If the program uses shared libraries, they are not inside it, so somebody must find them, load them too, and connect the calls.
- That job belongs to a small program called the dynamic linker, which the loader starts first.
- Only after all that does your
mainactually run.
PLAIN16.11.2 a picture in your head#
- Think of a play. The script is the executable file. The performance is the process.
- The stage manager, the loader, sets out the scenery from the script.
- The script says “at this point, the orchestra plays the theme”. The orchestra is a shared library and is not in your script.
- So before the curtain, an assistant goes and finds the orchestra, checks they have the right music, and writes their seat numbers into a small card at the side of the stage.
- Every time the script says “orchestra”, an actor glances at the card to see where to look.
- If the assistant is lazy, the card starts blank, and the first time the actor glances at it, the assistant is fetched to fill in that one entry.
Where this comparison breaks: the card, the global offset table, is per process, not per library, and it is normally made read-only after start-up for security. And the assistant is itself a shared library, loaded by a special path that does not need an assistant.
PLAIN16.11.3 a worked example#
readelf -l on our executable shows what the loader is told to do:
Elf file type is DYN (Position-Independent Executable)
Entry point 0x1040
Type VirtAddr FileSiz MemSiz Flg Align
INTERP 0x00000318 0x00001c 0x00001c R 0x1
[Requesting interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x00000000 0x0005f0 0x0005f0 R 0x1000
LOAD 0x00001000 0x000169 0x000169 R E 0x1000
LOAD 0x00002000 0x0000ec 0x0000ec R 0x1000
LOAD 0x00003df0 0x000220 0x000228 RW 0x1000
- Four
LOADentries, four memory regions with different permissions. - The second is
R E, readable and executable: that is your code. - The last is
RWand itsMemSiz(0x228) is larger than itsFileSiz(0x220). The extra 8 bytes are.bss, zero-filled at load and stored in no file bytes at all. INTERPnames the dynamic linker. The kernel loads that, not your program.
Now watch it happen. strace -e trace=execve,openat,mmap ./prog_dyn, trimmed:
execve("./prog_dyn", ["./prog_dyn"], ...) = 0
openat(".../glibc-hwcaps/x86-64-v4/libadd.so") = -1 ENOENT
openat(".../glibc-hwcaps/x86-64-v3/libadd.so") = -1 ENOENT
openat("/tmp/kb/libadd.so", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 16400, PROT_READ, ...) = 0x7f88..
mmap(0x..1c, 4096, PROT_READ|PROT_EXEC, ...)
mmap(0x..1d, 4096, PROT_READ, ...)
mmap(0x..1e, 8192, PROT_READ|PROT_WRITE, ...)
openat("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat("/lib/x86_64-linux-gnu/libc.so.6", ...) = 3
+++ exited with 5 +++
- It searched several CPU-feature-specific directories first, then found the library, then mapped it in four pieces with different permissions.
- It consulted
/etc/ld.so.cache, a prebuilt index of every library on the system, before touching the real directories, because scanning directories for every library on every program start would be slow.
PLAIN16.11.4 what is really happening inside#
- Calls into a shared library do not jump directly. They jump to a small local stub. Real disassembly of
maininprog_dyn:
115b: e8 f0 fe ff ff call 1050 <add@plt>
0000000000001050 <add@plt>:
1050: f3 0f 1e fa endbr64
1054: ff 25 76 2f 00 00 jmp *0x2f76(%rip) # 3fd0
maincalls a stub calledadd@plt. That stub jumps to whatever address is stored at0x3fd0.- The table of such addresses is the global offset table, the GOT. The table of stubs is the procedure linkage table, the PLT.
readelf -r prog_dynshows the note that fills slot0x3fd0:
Relocation section '.rela.plt' contains 1 entry:
Offset Type Sym. Name
000000003fd0 R_X86_64_JUMP_SLO add + 0
- Lazy binding: by default, the GOT slot initially points back into the PLT, which calls the dynamic linker to resolve the symbol, patch the slot, and jump on. Every later call goes straight through.
- Setting
LD_BIND_NOW=1resolves everything up front. On this machine,LD_DEBUG=statistics LD_BIND_NOW=1 ./prog_dynreported 100 relocations and about 155,000 cycles of total dynamic loader start-up time. - Lazy binding trades a slightly slower first call for a faster start. Hardened builds turn it off, because a writable GOT is an attack target;
-z nowplus-z relromakes the GOT read-only after start-up.
TECHNICAL16.11.5 the engineer’s version#
- Search order for a shared library on glibc:
DT_RPATHif there is noDT_RUNPATH, thenLD_LIBRARY_PATH, thenDT_RUNPATH, then/etc/ld.so.cache, then the default directories/liband/usr/libwith their architecture subdirectories. DT_RPATHis deprecated in favour ofDT_RUNPATH, becauseRUNPATHis overridable byLD_LIBRARY_PATHandRPATHis not. Our example used-Wl,-rpath,'$ORIGIN'andreadelf -dshowsRUNPATH Library runpath: [$ORIGIN].$ORIGINexpands to the directory of the executable, which is how self-contained application bundles work.- Setting
LD_LIBRARY_PATHglobally in a shell profile is a well-known source of confusing bugs, because it affects every program you start. PreferRUNPATHbaked into the binary. LD_PRELOADloads a library before all others, letting you interpose on any function. It is the mechanism behind many profilers and sanitizer shims. For safety, it is ignored for set-user-ID programs.- Versioned sonames are how Linux avoids most DLL hell. A library records a
SONAMEsuch aslibc.so.6, and glibc additionally versions individual symbols, so__libc_start_main@GLIBC_2.34and an older version can coexist in one file. Our binary’s relocation table shows exactly that symbol. - Dependency hell is when two of your dependencies need incompatible versions of a third. DLL hell was the Windows form, where an installer overwrote a shared
system32DLL and broke other programs. Windows answered with side-by-side assemblies from Windows XP, 2001. - Static linking avoids all of it and costs disk space and the loss of shared library security updates. Go links statically by default; Rust links its own standard library statically but the system C library dynamically unless you choose a musl target.
- This chapter has treated ELF, Mach-O and PE only as far as needed to explain linking and loading. Chapter 20 covers the file formats themselves in detail.
| Tool | Purpose |
|---|---|
ldd prog |
List shared library deps |
readelf -d prog |
NEEDED, SONAME, RUNPATH |
LD_DEBUG=all ./prog |
Full dynamic linker trace |
LD_DEBUG=bindings |
Which symbol bound where |
ldconfig -p |
Contents of ld.so.cache |
otool -L prog |
Same as ldd, on macOS |
WORDS16.11.6 remember these#
Loader — the part of the OS that turns a file into a process — the execve path that maps PT_LOAD segments and transfers control to the entry point. Dynamic linker — the helper that finds and connects libraries — ld.so, named in the PT_INTERP segment, which maps dependencies and applies relocations. GOT — the little table of “where is it really” — the global offset table, an array of addresses patched at load or first use. PLT — the little stubs that read that table — the procedure linkage table, one trampoline per imported function. Lazy binding — only look it up the first time it is called — resolving PLT entries on first call rather than at start-up; disabled by -z now. RUNPATH — a search path baked into the program — the DT_RUNPATH dynamic entry, searched after LD_LIBRARY_PATH.
16.12 Interpreters and virtual machines#
PLAIN16.12.1 in simple words#
- Compiling is not the only way to run a program.
- An interpreter reads your program and does what it says, immediately, without ever producing machine code.
- The simplest kind walks the syntax tree from 16.4. At a
+node it evaluates the left child, evaluates the right child, and adds. - That is easy to write and slow to run, because it re-inspects the tree every single time round a loop.
- So most real “interpreted” languages do something in between. They compile your program once into a simple made-up instruction set, then run those.
- Those instructions are bytecode, and the program that runs them is a virtual machine.
- Bytecode is not for any real chip. It is designed to be easy to produce and quick to run in a loop.
PLAIN16.12.2 a picture in your head#
- Imagine a recipe in a foreign language and a cook who does not read it.
- Option one: a translator stands beside the cook and translates each line aloud, every time, including every time round a repeated step. That is a tree interpreter.
- Option two: the translator writes out the whole recipe in simple numbered steps first, and the cook then follows the numbered steps. Translation happens once. That is bytecode.
- Option three: the translator rewrites the recipe into this exact kitchen’s own house notation, tuned for these exact pans, before anyone starts. That is ahead-of-time compilation.
Where this comparison breaks: option two’s numbered steps are still not what the kitchen’s machines understand. Somebody, the virtual machine, is still reading each numbered step and deciding what to do. That reading is the cost that a JIT, covered in 16.13, removes.
PLAIN16.12.3 a worked example#
Our example in Java, compiled with javac and disassembled with javap -c:
static int add(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: ireturn
public static void main(java.lang.String[]);
Code:
0: iconst_2
1: iconst_3
2: invokestatic #7 // Method add:(II)I
5: invokestatic #13 // Method System.exit:(I)V
8: return
- This is a stack machine.
iload_0pushes local variable 0.iload_1pushes local variable 1.iaddpops two and pushes their sum.ireturnpops and returns. - Every opcode is one byte, which is where the name bytecode comes from.
- No registers are named. That is deliberate: the JVM does not know what chip it is on.
- The first eight bytes of the
.classfile, fromod -An -tx1 -N 8, areca fe ba be 00 00 00 41. The first four are the magic number 0xCAFEBABE, chosen at Sun in the early 1990s.0x41is 65: Java 21.
And the same idea in CPython 3.11, from the dis module:
def add(a, b): return a + b
LOAD_FAST 0 (a)
LOAD_FAST 1 (b)
BINARY_OP 0 (+)
RETURN_VALUE
def main(): return add(2, 3)
LOAD_GLOBAL 1 (NULL + add)
LOAD_CONST 1 (2)
LOAD_CONST 2 (3)
PRECALL 2
CALL 2
RETURN_VALUE
- Same shape: push, push, operate, return.
- Python caches this in
__pycache__/m.cpython-311.pyc, whose first four bytes here area7 0d 0d 0a: a version magic, then a carriage return and newline chosen so that a text-mode file transfer corrupts the magic and the file is rejected rather than misread. - The huge difference is what the opcodes mean.
iaddin Java adds two 32-bit integers, full stop.BINARY_OP +in Python must inspect both objects’ types at run time and find the right method. That is most of the speed gap.
PLAIN16.12.4 what is really happening inside#
- A bytecode virtual machine is a loop: fetch the next opcode, jump to the code for it, do it, repeat.
- The jump is usually a computed jump into a table of handler addresses. The cost per opcode is that jump plus the work.
- The jump is hard for the CPU’s branch predictor, because the next opcode is essentially unpredictable. That mispredict cost is the main tax of interpretation.
- Threaded code reduces it. Instead of one jump at the top of the loop, each handler ends with its own jump to the next handler, giving the predictor more context. CPython uses computed gotos on GCC and Clang for this reason.
- Bytecode is portable because it names no registers, no addresses and no instruction encodings. One
.classfile runs on x86, AArch64 and anything else with a JVM. - It also starts fast, because there is nothing to compile at start-up if the bytecode is already cached, which is exactly what
.pycfiles are for.
TECHNICAL16.12.5 the engineer’s version#
- Java was released by Sun Microsystems in January 1996. The JVM specification defines the class file format, the instruction set, verification rules and the memory model. It is a written standard.
- The JVM has about 200 defined opcodes out of 256, leaving room reserved for internal use. Class file major versions map to releases: 52 is Java 8, 55 is Java 11, 61 is Java 17, 65 is Java 21.
- Bytecode verification happens at class load. The verifier proves type safety and stack discipline before any code runs. That is a real security property, and it is why JVM bytecode is not simply machine code in disguise.
- CPython compiles to bytecode at import and caches it.
__pycache__and the.pycnaming scheme were introduced in Python 3.2 by PEP 3147, 2010. The bytecode format is explicitly unstable between minor releases, which is why the file name includescpython-311. - Python 3.11, October 2022, added a specializing adaptive interpreter, PEP 659, which rewrites hot opcodes into type-specialized versions at run time. The CPython team reported roughly 1.25 times faster on their benchmark suite versus 3.10.
- Rough figures for an arithmetic loop, order of magnitude only, because the real ratio depends heavily on the workload. Treat these as approximate.
- A tree-walking interpreter: roughly 100 to 1000 times slower than C.
- A bytecode VM with no JIT: roughly 10 to 100 times slower.
- A good JIT: within about 1 to 3 times.
| Trait | Compiled AOT | Bytecode VM | Tree walker |
|---|---|---|---|
| Start-up | Fastest | Medium | Fast |
| Peak speed | Fastest | Near AOT, if JIT | Slowest |
| Portability | Rebuild per CPU | One artifact | One artifact |
| Debug info | Needs symbols | Built in | Built in |
| Examples | C, Rust, Go | Java, C#, Python | Early Ruby, bash |
WORDS16.12.6 remember these#
Interpreter — a program that carries out your program directly — an evaluator that executes source or IR without emitting native code. Bytecode — compact instructions for a made-up machine — a serialized instruction set for a virtual machine, typically one byte per opcode. Virtual machine — the program that runs bytecode — an abstract machine specification plus its implementation, such as the JVM or CPython’s ceval loop. Stack machine — a machine with no named registers — an architecture where operands are pushed and popped on an operand stack. Dispatch — choosing which handler runs next — the fetch-decode-jump step of an interpreter loop, often implemented with computed gotos.
16.13 Just-in-time compilation#
PLAIN16.13.1 in simple words#
- A just-in-time compiler, or JIT, compiles your program while it is already running.
- It does not compile everything. Most code runs once or twice and is not worth the effort.
- So the system counts. Every time a function is entered or a loop goes round, a counter goes up.
- When a counter crosses a threshold, that code is hot, and the JIT compiles it to real machine instructions.
- The next time it is reached, the fast version runs instead.
- A JIT knows something an ordinary compiler cannot: what actually happened. It knows which types really turned up, which branch really got taken, and which function really got called.
- So it compiles a specialized version based on those observations, and adds a cheap check that the observation still holds.
- If the check ever fails, it throws that version away and goes back to the slow safe path. That is called deoptimization.
PLAIN16.13.2 a picture in your head#
- Imagine a new receptionist at a large office. On day one they look up every visitor’s destination in the directory.
- After a week they notice that nearly everyone who arrives asks for the third floor, so they start saying “third floor” before checking.
- But they keep a quick glance at the visitor’s badge, in case somebody different turns up.
- If a badge does not match, they stop guessing, go back to the directory, and look it up properly for that visitor.
- Guessing is a huge win only because it is nearly always right and because being wrong is safe.
Where this comparison breaks: the receptionist keeps their memory. A real JIT must be able to abandon an optimized version mid-execution, reconstruct the exact state the slow interpreter would have had at that point, and resume. Getting that reconstruction correct is one of the hardest parts of JIT engineering.
PLAIN16.13.3 a worked example#
Take our add, but in a dynamic language, where a + b could mean anything.
- First call: the interpreter runs
BINARY_OP +. It looks at the type ofa, looks at the type ofb, finds both are whole numbers, and adds them. - The system records what it saw: two whole numbers, at this exact spot.
- Calls two to about a thousand: the same thing, and the recording is confirmed each time.
- The counter crosses the threshold. The JIT compiles this function assuming both arguments are whole numbers.
- It emits something close to our
-O2output: a check, then oneaddinstruction, then a return. - The check is a guard: “is
areally a whole number, isb?” A few instructions, usually predicted correctly, essentially free. - Call 100,000: someone passes a string. The guard fails. The compiled version is abandoned for that call, the state is rebuilt, and the interpreter takes over from that exact point.
- If it keeps failing, the compiled version is discarded and recompiled with the new information.
Real tiering in the HotSpot JVM, which is a written design, not a guess:
| Tier | What runs | Roughly when |
|---|---|---|
| 0 | Interpreter | Always at first |
| 1-3 | C1 compiler | After ~1,500 invocations |
| 4 | C2 compiler | After ~10,000 |
The exact thresholds are controlled by -XX:CompileThreshold and related flags and vary between JVM versions; treat the numbers as typical defaults rather than fixed values.
PLAIN16.13.4 what is really happening inside#
- Compilation happens on a background thread, so the program keeps running on the old version while the new one is prepared.
- On-stack replacement, or OSR, handles the awkward case of a loop that is already running and will not be entered again. The system swaps the running frame over to compiled code partway through.
- Inline caching, invented for Smalltalk by L. Peter Deutsch and Allan Schiffman in 1984, remembers at each call site which method was called last time, so the lookup is skipped.
- A polymorphic inline cache, from Urs Hölzle, Craig Chambers and David Ungar’s work on Self in 1991, remembers several possibilities at one site.
- Hidden classes, also from Self, give dynamically shaped objects a fixed internal layout, so a field access can become a single load at a fixed offset instead of a dictionary lookup.
- Those three ideas together are the reason JavaScript became fast. They were research results from the 1980s and 1990s that were finally applied to the web in the 2008 to 2012 period.
TECHNICAL16.13.5 the engineer’s version#
- HotSpot came to Sun through the 1997 acquisition of Animorphic and shipped in
- It introduced tiered compilation with the C1 client compiler and the C2 server compiler, plus deoptimization back to the interpreter.
- Google’s V8 shipped with Chrome in September 2008, developed in Aarhus under Lars Bak, who had worked on Self and HotSpot. The lineage is direct.
- V8’s pipeline as of 2026 is Ignition, a bytecode interpreter, then Sparkplug, a baseline compiler added in 2021, then Maglev, a mid-tier compiler added in 2023, then TurboFan. Ignition plus TurboFan replaced Crankshaft in Chrome 59, 2017.
- Mozilla’s SpiderMonkey, the original JavaScript engine written by Brendan Eich in 1995, went through TraceMonkey in 2008, and now uses Baseline plus IonMonkey with WarpBuilder.
- Deoptimization needs a precise mapping from optimized machine state back to interpreter state at every safepoint. This is stored as side tables and is a large part of why JIT compilers are hard to write correctly.
- The trade against ahead-of-time compilation:
- JIT wins on peak speed for dynamic languages, because it can specialize on observed types and inline through virtual calls speculatively.
- AOT wins on start-up time, on memory, on predictable latency, and on platforms that forbid writable-and-executable memory, such as iOS.
- JIT costs memory for the compiler, the profiling data and the code cache.
- JIT hurts tail latency, because compilation and deoptimization happen at unpredictable moments. This matters for trading systems and for games.
- Hybrid approaches now dominate. Java has AppCDS class data sharing and GraalVM native-image ahead-of-time compilation, first released 2019. Android moved from Dalvik’s pure JIT to ahead-of-time ART in Android 5.0, 2014, then to a profile-guided mix in Android 7.0, 2016.
- Established fact: JIT reliably beats plain interpretation for long-running dynamic code. Active research: cheap start-up, predictable latency, and sharing profiles across runs. Marketing claim: any flat statement that a JIT language is “as fast as C”.
WORDS16.13.6 remember these#
JIT — compiling while the program runs — dynamic translation of hot code to native instructions, guided by run-time profiles. Hot path — the code that runs most — a method or loop whose execution counter has crossed a compilation threshold. Tiered compilation — several compilers of increasing quality — a pipeline from interpreter to baseline to optimizing compiler, per method. Deoptimization — abandoning fast code when a guess turns out wrong — reconstructing interpreter state at a safepoint and resuming in the slower tier. Inline cache — remembering what was called here last time — a per-call-site cache of receiver type to target method, monomorphic or polymorphic. On-stack replacement — swapping code under a running loop — transferring an active frame from interpreted to compiled code, or back.
16.14 Modern build reality#
PLAIN16.14.1 in simple words#
- In real projects, nobody types
gccby hand. - A build system works out what needs rebuilding and runs the commands.
- It does this by comparing times: if the source is newer than the output, the output is stale and must be rebuilt.
- Anything that depended on that output is stale too, and so on up the chain.
- Rebuilding only what changed is an incremental build, and it is the difference between a two-second edit-run cycle and a twenty-minute one.
- A cross-compiler runs on one kind of machine and produces code for another. That is how phone apps are built on laptops.
- A transpiler compiles from one high-level language to another, rather than to machine code.
- A toolchain is the whole matched set: compiler, assembler, linker, standard library and headers for one target.
PLAIN16.14.2 a picture in your head#
- Think of a kitchen preparing a large set menu.
- Some dishes depend on a stock that takes an hour. Some depend on that stock’s sauce. Some depend on nothing.
- A good kitchen keeps a chart of what depends on what.
- If the stock is remade, everything downstream of it must be remade. If only a garnish changed, only the garnish is redone.
- A busy kitchen also keeps a fridge of finished components labelled with exactly which ingredients went into them. If someone orders the same component again with identical ingredients, it comes out of the fridge. That is a build cache.
Where this comparison breaks: a kitchen can smell that the stock has gone off. A build system has only timestamps and hashes. If a timestamp lies, for example because clocks differ between machines or a file was restored from backup, the build system will confidently reuse something wrong. This is a real and common class of bug.
PLAIN16.14.3 a worked example#
Here is our two-file version, driven by make.
prog: main2.o addonly.o
gcc main2.o addonly.o -o prog
main2.o: main2.c addh.h
gcc -c main2.c -o main2.o
addonly.o: addonly.c
gcc -c addonly.c -o addonly.o
Real session on this machine:
$ make
gcc -c main2.c -o main2.o
gcc -c addonly.c -o addonly.o
gcc main2.o addonly.o -o prog
$ make
make: 'prog' is up to date.
$ touch addonly.c && make
gcc -c addonly.c -o addonly.o
gcc main2.o addonly.o -o prog
$ touch addh.h && make
gcc -c main2.c -o main2.o
gcc main2.o addonly.o -o prog
- The first build compiles both files.
- The second build does nothing, because nothing is newer than its output.
- Touching
addonly.crebuilds only that object, then relinks. - Touching the header rebuilds
main2.o, because the rule listsaddh.has a dependency. That listing is the whole trick, and forgetting it is the classic cause of “it did not pick up my header change”. Generate it automatically withgcc -MMD.
PLAIN16.14.4 what is really happening inside#
- Make builds a graph of files and rules, sorts it so dependencies come first, and runs any rule whose output is older than any of its inputs.
- Modern build systems replace timestamps with content hashes, which is more reliable: the same input bytes plus the same command always give the same output.
- That is what makes a build cache possible. Hash the compiler version, the flags and the preprocessed source; if that hash was seen before, reuse the stored object file.
ccachedoes exactly this locally, and Bazel and Gradle do it across a whole team. - Cross-compilation works because the pipeline in 16.1 is already split. The front end does not care about the target. Only the back end, the assembler, the linker and the libraries do.
- Our chapter already did it.
clang --target=aarch64-linux-gnu -O2 -S add.cproduced ARM instructions on an x86 machine, with no special install, because LLVM ships every backend in one binary. - To produce a runnable ARM binary you also need ARM headers and libraries. That is the difference between a compiler and a toolchain, and it is why cross-toolchain setup is more annoying than cross-compiling itself.
TECHNICAL16.14.5 the engineer’s version#
makewas written by Stuart Feldman at Bell Labs in 1976, reportedly after losing a morning to a program that had failed to relink. Its tab-indentation rule has never been fixed for compatibility reasons.CMakewas created at Kitware around 2000 for the Insight Toolkit, funded by the US National Library of Medicine. It is a build generator: it does not build, it writes Makefiles, Ninja files or Visual Studio projects.Ninja, written by Evan Martin for the Chrome build in the early 2010s, is deliberately not human-authored. Its input is machine-generated, its dependency handling is fast, and it is the usual back end for CMake and for Meson on large projects.Gradletargets the JVM world, uses a Groovy or Kotlin domain-specific language, supports incremental and cached tasks, and reached 1.0 in 2012.Bazelis the open-sourced version of Google’s Blaze, released in 2015, and is built around hermetic, hash-keyed, remotely cacheable actions.ccache, from Andrew Tridgell in the early 2000s, anddistcc, from Martin Pool around the same time, remain the cheapest large wins for C and C++ projects that cannot adopt Bazel.- Transpilers: TypeScript, announced by Microsoft in October 2012 under Anders Hejlsberg, compiles to JavaScript. Babel, started as 6to5 by Sebastian McKenzie in 2014, compiles newer JavaScript to older JavaScript. The very first C++ implementation, Bjarne Stroustrup’s Cfront from 1983, was a transpiler to C.
- WebAssembly is a portable compilation target with a formal specification. Its Core Specification 1.0 became a W3C Recommendation in December 2019.
- Our example built with
clang --target=wasm32 -O2 -nostdlib -c add.cgave a 333-byte module. - Its first eight bytes are
00 61 73 6d 01 00 00 00, that is\0asmfollowed by version 1.
- Our example built with
- Why build times matter: they set the length of the edit-compile-test loop, which sets how often a developer tries an idea. As a scale marker, a full Chromium build is commonly reported in hours on one workstation and minutes with a large distributed cache.
| Tool | Year | Kind |
|---|---|---|
| make | 1976 | Build executor |
| Cfront | 1983 | C++ to C transpiler |
| CMake | 2000 | Build generator |
| ccache | early 2000s | Compiler cache |
| Ninja | early 2010s | Build executor |
| Gradle 1.0 | 2012 | Build system, JVM |
| TypeScript | 2012 | Transpiler |
| Bazel | 2015 | Build system, cached |
| Wasm 1.0 | 2019 | Portable target |
WORDS16.14.6 remember these#
Build system — the thing that decides what to recompile — a dependency graph executor keyed on timestamps or content hashes. Incremental build — rebuild only what changed — recomputing the minimal set of stale targets from the dependency graph. Build cache — reuse a result somebody already computed — a content-addressed store keyed on inputs, tool version and flags. Cross-compilation — building on one machine for another — using a toolchain whose target triple differs from the host triple. Transpiler — a compiler whose output is another language — a source-to-source translator, such as TypeScript to JavaScript. Toolchain — the whole matched set of tools — compiler, assembler, linker, headers and runtime libraries for one target.
16.15 The bootstrap problem and trusting trust#
PLAIN16.15.1 in simple words#
- A C compiler is a program. Programs must be compiled. So what compiled the first C compiler?
- This is the bootstrap problem, and there are only three honest answers.
- One: write the first version in something else, such as assembly or another existing language.
- Two: write a small, limited version by hand, then use it to compile a bigger version, then use that to compile a bigger one still.
- Three: use a compiler on a different machine to produce code for yours. That is cross-compilation from 16.14.
- A compiler written in the language it compiles is self-hosting. Almost every serious compiler ends up this way.
- Self-hosting is a strong test: the compiler is its own largest and most demanding user.
PLAIN16.15.2 a picture in your head#
- Imagine you want a workshop that can build any tool, including its own tools.
- You cannot start there. You start with a rough hammer made by hand.
- With the rough hammer you make a better hammer. With the better hammer you make a decent lathe. With the lathe you make precise tools.
- Eventually the workshop can make every tool in it, including exact copies of its own machines.
- The first rough hammer is then thrown away, and nobody alive has seen it.
Where this comparison breaks: a hammer’s shape is visible. A compiler can carry an invisible instruction inside it that is not written in any source file anybody keeps, and that instruction can copy itself into every tool the workshop makes. That is the whole point of the next block.
PLAIN16.15.3 a worked example#
- Start with our own file.
gcc add.cworked because agccbinary already existed on this machine. That binary is itself written in C and C++, so some earlier compiler must have built it. Follow that chain back far enough and you reach the question of where the first one came from. - Around 1972 to 1973, Dennis Ritchie’s C compiler at Bell Labs was written in an earlier language, first B and then an intermediate step usually called NB or “new B”, and grew into C by stages, each version compiling the next.
- In 1973 the Unix kernel was rewritten in C for Version 4 Unix, which is the moment portable operating systems become possible.
- Corrado Böhm’s 1951 PhD thesis at ETH Zurich described the first compiler for a language written in that same language, so the idea is older than C by two decades.
- Tim Hart and Mike Levin wrote a Lisp compiler in Lisp at MIT in 1962, then ran it through the Lisp interpreter to compile itself.
- Modern examples: the Rust compiler became self-hosted in April 2011. Go removed the last of its C compiler in Go 1.5, August 2015, and has been written in Go since.
- Today’s route for a brand-new C compiler is easier. Compile version one with GCC, compile version one with itself, compile once more, and check the last two outputs are byte-identical. That is a three-stage bootstrap, and GCC does it in its own build.
PLAIN16.15.4 what is really happening inside#
- Now Ken Thompson’s argument, in plain words. He gave it in his 1984 Turing Award lecture, Reflections on Trusting Trust, printed in Communications of the ACM in August 1984. He and Dennis Ritchie shared the 1983 Turing Award.
- Step one. Suppose you modify a C compiler so that when it notices it is compiling the login program, it silently adds a back door accepting a secret password.
- That is easy but obvious: the extra code is right there in the compiler’s source, and any reviewer would see it.
- Step two. So add a second rule: when the compiler notices it is compiling a C compiler, it silently inserts both rules into the output.
- Now compile the compiler with the modified compiler. The new binary contains both rules.
- Step three. Remove all of it from the source. Delete every line. The source is clean and reviewable and contains nothing suspicious.
- But the compiled compiler still carries both rules, because it was built by a compiler that inserts them. Compile the clean source with that binary, and the new binary has them again.
- The attack now lives only in the binary, reproducing itself forever, with no trace in any source file anybody reads.
- Thompson’s conclusion: you cannot trust code you did not totally write yourself, and “totally” reaches all the way down through the compiler, the assembler, the linker, the loader, the operating system and the hardware.
- He also noted that no amount of source-level inspection protects you, because the source is genuinely clean.
TECHNICAL16.15.5 the engineer’s version#
- The paper’s technique is a quine-like self-reproducing program combined with two pattern-matching triggers. Thompson stated that he had actually built a working version, though it was never released.
- The practical defence is diverse double-compiling, from David A. Wheeler’s 2005 paper and 2009 PhD dissertation. Compile the suspect compiler’s source with a second, independently written compiler, then use both binaries to compile that source again. Identical final binaries mean neither carries the attack.
- This only works if the compiler is deterministic: the same source and flags must always give byte-identical output. That requirement is precisely the goal of the Reproducible Builds project, which began within Debian around 2013.
- Sources of non-determinism that have to be removed: embedded build timestamps, build paths in debug information, file ordering from directory reads, random hash seeds and thread scheduling. GCC and Clang both accept
SOURCE_DATE_EPOCHand-ffile-prefix-mapto help. - As of 2026, Debian reports that the large majority of its source packages build reproducibly, and several distributions publish rebuild verification. Exact percentages move month to month, so treat any single figure as dated.
- The related project Bootstrappable Builds attacks the other end: shrinking the trusted seed binary to something a human can audit. Its chain starts from a few hundred bytes of hand-checkable machine code and climbs through stage0, M2-Planet, TinyCC and GCC.
- Established fact: the attack works and is well understood. Active research: verifying the whole chain from hardware upward, including proved compilers such as CompCert, machine-checked in Coq since 2008. Marketing claim: any product said to have solved supply-chain trust.
- The honest version: reproducible builds and diverse double-compiling reduce the trusted base, they do not eliminate it. You still trust the CPU microcode, the firmware and the fabrication process. Thompson’s point survives; we have only made the untrusted part smaller.
WORDS16.15.6 remember these#
Bootstrapping — getting a compiler off the ground with no compiler — building a language’s compiler through successive stages from an existing toolchain. Self-hosting — a compiler written in its own language — a compiler that can compile its own source, usually verified by a multi-stage build. Three-stage bootstrap — build it three times and compare — stage 2 and stage 3 outputs must be byte-identical, a standard part of the GCC build. Trusting trust — the compiler can lie and the source will not show it — Thompson’s 1984 self-reproducing compiler back door. Reproducible build — same source in, same bytes out, every time — a build whose output is a deterministic function of its declared inputs. Diverse double-compiling — check one compiler using a different one — Wheeler’s method for detecting a trusting-trust attack in a deterministic compiler.
16.98 Common wrong ideas#
Wrong: the compiler translates your code line by line into machine code. Right: it goes through the whole chain of 16.1, and the optimizer may delete, merge, reorder and duplicate your lines. Our two-line file became three instructions with no call in it.
Wrong:
#includeimports a module the way Python’simportdoes. Right: it pastes the file’s text at that point. Two lines of C with#include <stdio.h>became 815 lines of text on this machine.Wrong: a syntax error means the compiler does not understand what you meant. Right: a syntax error means no grammar rule allows the token it just saw. Meaning is checked later, in semantic analysis, and produces different errors.
Wrong: undefined reference is a compiler error. Right: it is a linker error. The compiler was satisfied by the declaration. The linker could not find a definition. That is why the fix is usually an extra object file or library, not a code change.
Wrong:
-O2makes your program do the same steps, faster. Right: it makes the program produce the same observable result by doing different steps. Ourmainnever callsaddat all after-O2.Wrong: undefined behaviour means the program does something unpredictable at that point. Right: it means the compiler may assume it never happens, which can change code far away from the mistake.
x + 1 > xfolded to a constant1at-O2.Wrong: floating point differences between
-O0and-O2are compiler bugs. Right: they can be legitimate, especially with-ffast-math, because IEEE 754 addition is not associative. On this machine(a+b)+cgave 1 anda+(b+c)gave 0 for the same three values.Wrong: interpreted languages are slow because interpreting is slow. Right: mostly they are slow because operations must decide their meaning at run time. Java bytecode is interpreted too and is far faster, because
iaddalready knows it is adding two 32-bit integers.Wrong: a JIT is always faster than ahead-of-time compilation. Right: it wins on peak speed for dynamic code and loses on start-up time, memory use and latency predictability. That is why iOS forbids it and why GraalVM native-image exists.
Wrong: reading a compiler’s source proves it has no back door. Right: Thompson showed in 1984 that a compiler binary can carry an attack that appears in no source file. Diverse double-compiling, not reading, is the defence.
16.99 Chapter summary in 20 lines#
- A compiler is a chain of stages, each turning one shape of information into a simpler one, from text to running process.
- The preprocessor is a text editor: it pastes files in and substitutes macros, and it knows nothing about C.
- The lexer cuts characters into labelled tokens using a finite automaton, and always takes the longest legal match.
- The parser arranges tokens into an abstract syntax tree, guided by a context-free grammar written in Backus-Naur Form.
- A syntax error means no grammar rule allows the current token. It is about shape, not meaning.
- Semantic analysis builds a scoped symbol table, resolves names, checks types and inserts implicit conversions.
- Compilers then rewrite the program into an intermediate representation: flat three-address code, usually in static single assignment form.
- SSA gives every value exactly one definition, with phi nodes at joins, which makes most optimizations simple and fast.
- The front-end, middle-end, back-end split lets many languages share one optimizer and one set of processor backends.
- The optimizer may do anything that preserves observable behaviour: fold, propagate, delete, inline, unroll, vectorize, and eliminate tail calls.
- Our
int main(void) { return add(2,3); }becamemovl $5, %eax; retthrough inlining, then constant folding, then dead code elimination. - Undefined behaviour lets the optimizer assume the impossible never happens, which is why
x + 1 > xfolds to1for signed integers but not unsigned. - Code generation performs instruction selection, scheduling and register allocation, and must obey a calling convention.
- System V AMD64 passes the first two integers in rdi and rsi, Windows x64 uses rcx and rdx, AArch64 uses x0 and x1. All return in the first of those.
- The assembler turns instruction text into bytes, groups them into sections, records symbols, and leaves relocations where addresses are unknown.
- The linker merges sections, resolves symbols and patches relocations. Our
e8 00 00 00 00becamee8 d1 ff ff ff, a relative jump of minus 47 bytes. - Library order on the command line matters, because archives are scanned once against the symbols still undefined at that moment.
- At exec time the kernel maps segments and starts the dynamic linker, which loads shared libraries and fills the GOT, lazily by default.
- Bytecode virtual machines trade speed for portability, and a JIT wins it back by compiling hot paths using types observed at run time, with deoptimization as the safety net.
- Compilers bootstrap themselves, and Ken Thompson showed in 1984 that a compiler binary can hide a back door that no source file reveals, which is why reproducible and diverse builds matter.