Part VIII · Chapter 23
Compilers and Linkers
Writing assembly by hand is slow, easy to get wrong, and ties a program to one kind of processor. So we write in languages like C, and let a program translate for us. This chapter opens up that translator, the compiler, and its partner, the linker, which joins the pieces into a program you can run.
A compiler reads a program written in a high-level language, one designed for people, and writes out the same program in machine code, designed for the processor. One line of C like total += a[i]; might become three or four instructions, and the compiler picks registers, works out addresses and follows the calling convention of Chapter 22 for us. The same C source can be compiled for x86-64, for RISC-V, or for almost any other processor.
From source to program
When you type gcc -o hello hello.c, the command gcc is really a manager, a driver, that runs four separate programs one after another:
- The preprocessor handles the lines starting with
#.#include <stdio.h>pastes in the whole filestdio.h, which describesprintfand many other functions. - The compiler proper (GCC's is called
cc1) turns the C into assembly language. - The assembler (
as) turns the assembly into machine code, in an object file ending in.o. - The linker (
ld) joins the object files and libraries into one executable program.
Here's the classic first program, with every step's output kept, on the computer this book was written on:
#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
return 0;
}
$ gcc -save-temps -o hello hello.c $ ls -l (sizes in bytes) hello.c 84 ← 6 lines of C hello.i 21316 ← after the preprocessor: 819 lines hello.s 668 ← assembly hello.o 1504 ← object file: machine code with holes hello 15960 ← the finished program
Reading the program
A compiler works in stages, much like you'd read a sentence in a foreign language:
- Lexing splits the text into tokens, the "words": names, numbers, operators and brackets. Spaces and comments are thrown away.
- Parsing arranges the tokens into a syntax tree that follows the grammar of the language. The tree captures the meaning: in
a + b * c, the multiplication sits lower in the tree, so it happens first. - Checking makes sure the program makes sense: every name is declared, and types match. This is where most compiler error messages come from.
- Code generation walks the tree and writes out instructions for each part.
Here's a real compiler, a very small one, that does all of that for a single expression. Type your own, using the variables a, b and c, numbers, +, -, * and brackets:
Real compilers add one important idea. Between the tree and the machine code, they translate the program into an intermediate representation (IR): a simple, made-up assembly language that belongs to no real processor. The optimiser works on the IR, and only at the very end is it turned into x86-64, RISC-V or anything else. That split lets one compiler support many languages and many processors. The LLVM project is built this way: Clang (for C and C++), Rust and Swift all produce LLVM IR, and LLVM turns it into code for dozens of processors.
Making it fast
A compiler that translated each line of C on its own would produce correct but slow code. The optimiser looks at the whole function and rewrites it to do the same job with less work. You choose how hard it tries with a flag: -O0 means "don't optimise" and -O2 means "optimise well". Some common tricks:
- Constant folding: work out
2 * 3while compiling, so the program never has to. - Register allocation: keep variables in registers instead of memory, which is far faster (Chapter 9).
- Strength reduction: swap an expensive operation for a cheaper one, like multiplying by 8 with a shift.
- Dead code elimination: remove calculations whose results are never used.
- Inlining: copy a small function's body into the place it's called from, saving the call and return.
- Loop optimisations: unroll loops to do more per trip, or use instructions that handle several numbers at once.
Here's a simple function, compiled by the two big free C compilers, GCC and Clang, at different levels. The assembly and the times are real, measured on the computer this book was written on:
Optimising can surprise you. In Chapter 22, our recursive factorial in C, compiled by Clang with -O1, came out with no recursion at all: the compiler turned it into a simple loop. The program must behave exactly as written, but how it gets there is up to the compiler. That's also why you debug with -O0: the code still matches your source line for line.
Linking
Big programs are split into many source files, and each one is compiled on its own into an object file. That saves time, because only the files you change need compiling again. But it leaves a puzzle: when main.c calls a function in square.c, the compiler has no idea where that function will end up. So it leaves a hole in the machine code, and a note, called a relocation, saying what should go there.
Each object file also has a symbol table: a list of the names it defines, and the names it needs from somewhere else. The linker's job is to match every needed name with a definition, decide where everything goes in memory, and then fill in every hole. If a name is needed but never defined, you get the famous "undefined reference" error.
Most programs also use libraries: collections of ready-made functions, like the C library with printf. A library can be linked in two ways:
- Statically: the linker copies the library code into the program. The program stands alone, but it's bigger, and every program carries its own copy.
- Dynamically: the program only records the library's name. When it starts, a small program called the dynamic linker loads the shared library into memory and fills in the remaining holes. One copy of the library on disk, and in memory too, serves every program, and a security fix to the library fixes them all.
Meanwhile, inside a real CPU
The -v flag makes gcc show the programs it runs: cc1 (the compiler), as (the assembler) and collect2, which runs the linker ld. Here's the compiler's output for hello.c, with a few lines of bookkeeping removed. Notice that the compiler quietly replaced printf with puts, a simpler function that does the same job when there's nothing to format, even without optimisation:
.LC0:
.string "Hello, world!"
main:
endbr64 ← marks a valid jump target (Intel CET)
pushq %rbp
movq %rsp, %rbp
leaq .LC0(%rip), %rax
movq %rax, %rdi ← first argument: the text
call puts@PLT
movl $0, %eax ← return 0
popq %rbp
ret
That's GCC's default AT&T syntax from Chapter 21. The demo's two files, as C:
/* main.c */ /* square.c */ #include <stdio.h> int calls = 0; int square(int x); /* somewhere else */ int square(int x) { extern int calls; /* this too */ calls++; return x * x; int main(void) { } printf("%d\n", square(7)); printf("calls: %d\n", calls); return 0; }
The nm tool lists an object file's symbol table: T for code defined here, B for a variable defined here, and U for undefined, needed from elsewhere. (Ubuntu's compiler swaps printf for __printf_chk, a version that checks for some mistakes as it runs.)
$ nm main.o $ nm square.o
U calls 0000000000000000 B calls
0000000000000000 T main 0000000000000000 T square
U __printf_chk
U square
objdump -dr shows the holes and their relocation notes. The -0x4 accounts for the 4 bytes of the hole itself, since distances are measured from the end of the instruction:
9: e8 00 00 00 00 call e <main+0xe>
a: R_X86_64_PLT32 square-0x4
26: 8b 15 00 00 00 00 mov edx,DWORD PTR [rip+0x0]
28: R_X86_64_PC32 calls-0x4
And after linking, the same instructions with the holes filled:
1142: e8 3e 00 00 00 call 1185 <square> 115f: 8b 15 af 2e 00 00 mov edx,DWORD PTR [rip+0x2eaf] # 4014 <calls>
The linker even saved a few bytes: the text "%d\n" is the end of "calls: %d\n", so it stored the longer one only, at 0x2004, and pointed the shorter one at 0x200b, 7 bytes in. Running the program, and asking which shared libraries it needs:
$ gcc -O1 -o prog main.o square.o
$ ./prog
49
calls: 1
$ ldd prog
linux-vdso.so.1 (0x00007ffe2db56000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000784bf2600000)
/lib64/ld-linux-x86-64.so.2 (0x0000784bf29a0000) ← the dynamic linker
Linked dynamically, the program is 15,992 bytes. Linked statically, with its own copy of everything it uses from the C library, it's 785,416 bytes: almost 50 times bigger, to print two lines.
Finally, Clang's -O1 version of sum_to for RISC-V. It found the same formula as on x86-64, built from RISC-V's simpler instructions. mulhu gives the top 64 bits of the 128-bit product, which x86's mul put in rdx:
sum_to:
blez a0, .LBB0_2 # n ≤ 0: the answer is 0
slli a1, a0, 1 # a1 = 2n
addi a2, a0, -1 # a2 = n − 1
addi a0, a0, -2 # a0 = n − 2
mul a3, a2, a0 # (n−1)(n−2): low 64 bits
mulhu a0, a2, a0 # high 64 bits
slli a0, a0, 63
srli a3, a3, 1
or a0, a0, a3 # … ÷ 2
add a0, a0, a1 # … + 2n
addi a0, a0, -1 # … − 1 = n(n+1)/2
ret
.LBB0_2:
li a0, 0
ret
Try it yourself
-
How many tokens are there in
total = total + 42;?Show answer
Six:total,=,total,+,42and;. The spaces aren't tokens; they just separate them. -
In the syntax tree for
(a + b) * c, which operator is at the top, and why?Show answer
The ×. The brackets make the addition happen first, so it sits lower in the tree, as the left branch of the multiplication. The top of the tree is always the operation done last. -
What can an optimiser do with
a * (2 + 6)?Show answer
Fold the constants intoa * 8, then swap the multiplication for a shift left by 3, since 8 = 2³. Try it in the tiny compiler. -
In the linker demo,
call squareis at0x1142, is 5 bytes long, andsquareends up at0x1185. Which 4 bytes fill the hole?Show answer
The next instruction starts at0x1142+ 5 =0x1147. The distance is0x1185−0x1147=0x3e, stored lowest byte first:3e 00 00 00. -
Why is a statically linked program so much bigger, and why might you still want one?
Show answer
It contains its own copy of all the library code it uses, instead of sharing the one copy on the system. But it needs nothing else to run, so you can copy it to another Linux computer and it works even if that computer has different library versions.
What we learned
- Building a C program runs four steps: preprocessor, compiler, assembler and linker.
- A compiler splits the source into tokens, parses them into a syntax tree, checks it, and generates code, usually through an intermediate representation.
- The optimiser folds constants, keeps values in registers, replaces slow operations and can even replace a whole loop with a formula.
- Object files have symbol tables and holes with relocations; the linker places everything and fills the holes.
- Libraries are linked statically (copied in) or dynamically (shared, loaded when the program starts).
Compiling everything to machine code ahead of time is one way to run a program. Python, JavaScript and Java take other routes. Next: interpreters, virtual machines and garbage collection.