- Blog
- From a C File to a Running Process: Compiling, Linking and Loading on Linux
From a C File to a Running Process: Compiling, Linking and Loading on Linux
Ref
Computer Systems: A Programmer's Perspective (CS:APP), Chapter 7 — Linking
The Linux Programming Interface, Chapters 41–42 — Shared Libraries
Ulrich Drepper — How To Write Shared Libraries
Ian Lance Taylor — Linkers (20-part blog series)
Intro
You type gcc hello.c && ./a.out and a line of text appears. Between those two commands, four separate programs get involved: the compiler, the linker, the kernel and the dynamic linker. Each one solves a different problem, and each leaves fingerprints you can inspect with standard tools.
This post follows one small C program from source file to running process and stops at every stage to look at the real artefacts: symbol tables, relocations, ELF headers, PLT stubs and the system calls made during startup. Everything below is real output from GCC 13.3 on Ubuntu 24.04 (x86-64). Nothing is reconstructed from memory.
This is the first stop in a series on Linux user space. Later posts on file I/O, processes, virtual memory and threads all assume you know how a process comes into existence, so this is where we start.
The Three Problems
A CPU only executes machine code. To turn your source into something it can run, three problems have to be solved:
- Your source has to become machine code. That is compilation.
- Your code calls code you didn't write.
printfisn't in your file. Something has to stitch the pieces together, and that is linking. - The stitched-together result has to end up in memory and start running. That is loading.
Here is the program we'll follow. It is deliberately small, but it touches every kind of data a real program has:
#include <stdio.h>
int counter = 42; /* .data */
int zeroed; /* .bss */
const char *greeting = "hello, world"; /* string in .rodata */
__attribute__((constructor))
static void before_main(void) {
puts("before main");
}
int main(void) {
printf("%s (%d)\n", greeting, counter + zeroed);
return 0;
}
$ ./hello
before main
hello, world (42)
The first line is already a clue: some of your code runs before main. We'll see exactly who calls it.
Step 1: Compilation — Source to Object File
gcc is a driver that runs four tools in sequence, and you can stop it after any of them:
gcc -E hello.c # preprocess: expand #include and macros
gcc -S hello.c # compile: produce assembly (hello.s)
gcc -c hello.c # assemble: produce an object file (hello.o)
gcc hello.o # link: produce an executable
The object file hello.o already contains machine code, but it can't run. Look at its symbol table:
$ nm hello.o
0000000000000000 r .LC0
000000000000000c r .LC1
U __printf_chk
0000000000000000 t before_main
0000000000000000 D counter
0000000000000000 D greeting
0000000000000010 T main
U puts
0000000000000000 B zeroed
The letters tell you where each symbol lives: T/t is code (.text), D is initialised data (.data), B is zero-initialised data (.bss), r is read-only data. Lower case means the symbol is local to this file, which is why the static function before_main shows up as t.
The interesting lines are the two marked U — undefined. This file calls puts and __printf_chk but has no idea where they are.
(Why __printf_chk and not printf? Ubuntu builds with _FORTIFY_SOURCE by default, so at -O2 GCC swaps in the bounds-checked variant. It's the same idea, just with an extra check.)
So how does the compiler emit a call instruction to a function whose address it doesn't know? It leaves a hole and writes down a note saying what belongs there. Those notes are relocations:
$ readelf -r hello.o
Relocation section '.rela.text.startup' at offset 0x2e8 contains 7 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000007 000500000002 R_X86_64_PC32 0000000000000000 .LC0 - 4
00000000001b 000900000002 R_X86_64_PC32 0000000000000000 greeting - 4
000000000028 000a00000002 R_X86_64_PC32 0000000000000000 zeroed - 4
00000000002f 000600000002 R_X86_64_PC32 000000000000000c .LC1 - 4
000000000035 000b00000002 R_X86_64_PC32 0000000000000000 counter - 4
00000000003a 000c00000004 R_X86_64_PLT32 0000000000000000 __printf_chk - 4
00000000000c 000700000004 R_X86_64_PLT32 0000000000000000 puts - 4
Read each row as "at byte Offset, patch in the address of Sym. Name". Even references to this file's own globals (counter, zeroed) are holes, because the compiler doesn't know where the final executable will place .data. An object file is machine code plus a to-do list.
Step 2: Linking — Filling in the Holes
The linker's job is to work through that to-do list. There are two strategies.
Static linking copies the code you use out of .a archives straight into your executable. The result is self-contained, but large, and every program that uses a library carries its own private copy. Fixing a bug in the library means rebuilding every program that uses it.
Dynamic linking (the default) does almost nothing at link time. The executable just records "I need libc.so.6" and leaves the real stitching for run time. Every process then shares one copy of libc in physical memory, and libc can be upgraded without rebuilding anything.
The size difference is dramatic:
$ gcc -O2 hello.c -o hello
$ gcc -O2 -static hello.c -o hello_static
$ ls -l hello hello_static
-rwxr-xr-x 1 root root 16152 Nov 6 17:45 hello
-rwxr-xr-x 1 root root 785576 Nov 6 17:46 hello_static
The output of either is an ELF file, and the most useful thing to understand about ELF is that it describes itself in two different ways for two different readers:
- Sections (
.text,.rodata,.data,.bss, …) are for the linker. They are fine-grained and grouped by purpose. View them withreadelf -S. - Segments (program headers, mostly
PT_LOAD) are for the loader. They are coarse-grained and grouped by memory permissions. Each one says "map this byte range of the file to this address with these permissions". View them withreadelf -l.
$ readelf -lW hello
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x10d0
Program Headers:
Type Offset VirtAddr FileSiz MemSiz Flg Align
INTERP 0x000318 0x0000000000000318 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x0006b0 0x0006b0 R 0x1000
LOAD 0x001000 0x0000000000001000 0x0001c9 0x0001c9 R E 0x1000
LOAD 0x002000 0x0000000000002000 0x000120 0x000120 R 0x1000
LOAD 0x002da8 0x0000000000003da8 0x000278 0x000280 RW 0x1000
...
Section to Segment mapping:
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
(The PhysAddr column and some non-LOAD headers are trimmed for width.)
Three things worth noticing:
- Permissions group the sections. Code is
R E(read and execute, never write). Constants areR. Writable data isRW. Nothing is both writable and executable, which defends against injected code. .bsscosts nothing on disk. Look at the lastLOAD:FileSizis0x278butMemSizis0x280. Those extra 8 bytes are.bss, ourzeroedvariable. The file doesn't store zeros. It just tells the loader "make the memory region 8 bytes longer than what's in the file, and fill the extra with zeros."INTERPnames a second program. This executable can't start on its own. It needs/lib64/ld-linux-x86-64.so.2to finish the linking job at run time. Keep this in mind for Step 4.
The addresses start near zero because this is a PIE (position-independent executable). The kernel will choose a random base address at load time (ASLR), and all addresses are offsets from it.
Step 3: Loading — What execve Does
When the shell runs ./hello, it ends up calling the execve system call. The kernel then:
- Reads the ELF header and maps each
PT_LOADsegment into a fresh address space. Mapping doesn't read the file. It only records "this range of addresses is backed by this part of this file". Pages are actually read in on first touch, through page faults. - Builds the initial stack containing
argc,argv,envpand the auxiliary vector (a list of facts the loader will need, such as where the program headers are). - Sees the
INTERPheader, maps the dynamic linker as well, and jumps to the dynamic linker's entry point, not yours.
strace shows everything that happens after that, and it's a complete trace of the loading process:
$ strace ./hello
execve("./hello", ["./hello"], 0x7ffca98b23d0 /* 218 vars */) = 0
brk(NULL) = 0x557847a10000
...
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
...
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\243\2\0\0\0\0\0"..., 832) = 832
...
mmap(NULL, 2174352, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fa5c7200000
mmap(0x7fa5c7228000, 1609728, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x7fa5c7228000
mmap(0x7fa5c73b1000, 323584, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1b1000) = 0x7fa5c73b1000
mmap(0x7fa5c7400000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1ff000) = 0x7fa5c7400000
mmap(0x7fa5c7406000, 52624, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fa5c7406000
close(3) = 0
...
arch_prctl(ARCH_SET_FS, 0x7fa5c7518740) = 0
...
mprotect(0x7fa5c7400000, 16384, PROT_READ) = 0
mprotect(0x557840915000, 4096, PROT_READ) = 0
...
fstat(1, {st_mode=S_IFIFO|0600, st_size=0, ...}) = 0
...
write(1, "before main\nhello, world (42)\n", 30) = 30
exit_group(0) = ?
Reading it top to bottom:
- No
mmapfor./helloitself. The kernel already mapped it insideexecve. Everything after that line is the dynamic linker working in user space. - Finding libc.
ld.soconsults/etc/ld.so.cache(a prebuilt index of library paths, maintained byldconfig), then opens libc and reads its ELF header. - Mapping libc segment by segment. First one big read-only reservation, then
MAP_FIXEDoverlays at the right offsets: codePROT_READ|PROT_EXEC, constantsPROT_READ, dataPROT_READ|PROT_WRITE. Then an anonymous mapping, which is libc's own.bss, the sameMemSiz > FileSiztrick we saw above. arch_prctl(ARCH_SET_FS, …)sets up thread-local storage. This is whereerrnolives, among other things.mprotect(…, PROT_READ)is RELRO (relocation read-only). Once relocation is finished, the tables that were patched are made read-only so a memory-corruption bug can't overwrite them later. The second call, at a0x5578…address, is our own PIE executable getting the same treatment.- Then the program runs. Notice only one
writefor two lines of output. We'll come back to that.
Step 4: The Dynamic Linker, the PLT and the GOT
ldd lists what the dynamic linker will load:
$ ldd hello
linux-vdso.so.1 (0x00007fa24f33c000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fa24f000000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa24f33e000)
(linux-vdso.so.1 has no file at all. It's a tiny library the kernel maps into every process so that calls like clock_gettime can run without entering the kernel. More on that in a later post.)
Libc is mapped at a random address each run, so our code can't contain libc addresses directly. Instead, the call goes through two small tables:
- The GOT (Global Offset Table) is a writable array of addresses in the data segment. The dynamic linker fills it in at run time.
- The PLT (Procedure Linkage Table) is a set of tiny code stubs, one per external function, each of which jumps through its GOT slot.
Here's main calling printf (as __printf_chk) and the puts stub it would use:
0000000000001090 <main>:
...
10b9: call 1070 <__printf_chk@plt>
...
0000000000001060 <puts@plt>:
1060: endbr64
1064: jmp QWORD PTR [rip+0x2f96] # 4000 <puts@GLIBC_2.2.5>
The code never changes. Only the 8-byte GOT entry at 0x4000 does. That's the whole trick: code pages stay read-only and shared between processes, and the per-process patching happens in a small writable table.
Lazy binding vs immediate binding
When does the dynamic linker fill in the GOT? There are two options:
- Lazy binding: leave each slot pointing at a resolver and look up the real address the first time the function is called. Startup is faster, because functions you never call are never looked up.
- Immediate binding (
-z now, or the environment variableLD_BIND_NOW=1): resolve everything before the program starts.
Lazy binding is the historical default in upstream binutils. But look at the binary we just built:
$ readelf -d hello | grep FLAGS
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
Ubuntu's toolchain links with -z now by default, alongside full RELRO, as a hardening measure. Don't assume which mode your binary uses. Check with readelf -d. To see lazy binding in action, ask for it explicitly with -Wl,-z,lazy. Before the program runs, the GOT slot for puts holds:
$ objdump -s -j .got.plt hello_lazy
Contents of section .got.plt:
3fe8 e03d0000 00000000 00000000 00000000 .=..............
3ff8 00000000 00000000 30100000 00000000 ........0.......
4008 40100000 00000000 @.......
Slot 0x4000 contains 0x1030 (little-endian). That isn't puts. It points back into the .plt section:
0000000000001020 <.plt>:
1020: push QWORD PTR [rip+0x2fca] # GOT+0x8: which library is this?
1026: jmp QWORD PTR [rip+0x2fcc] # GOT+0x10: the resolver in ld.so
...
1030: endbr64
1034: push 0x0 # relocation index 0 = puts
1039: jmp 1020
So the first call to puts goes puts@plt → GOT → 0x1030 → push "I'm relocation 0" → jump to the resolver in ld.so. The resolver looks up puts in libc, overwrites slot 0x4000 with the real address, and jumps to it. Every later call goes puts@plt → GOT → puts directly.
LD_DEBUG=bindings makes the dynamic linker report each binding as it happens. Using stdbuf -o0 so stdout isn't buffered and the lines interleave in true order (output filtered to our two functions):
$ LD_DEBUG=bindings stdbuf -o0 ./hello_lazy
binding file ./hello_lazy [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `puts' [GLIBC_2.2.5]
before main
binding file ./hello_lazy [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `__printf_chk' [GLIBC_2.3.4]
hello, world (42)
$ LD_DEBUG=bindings stdbuf -o0 ./hello
binding file ./hello [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `puts' [GLIBC_2.2.5]
binding file ./hello [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `__printf_chk' [GLIBC_2.3.4]
before main
hello, world (42)
In the lazy build, __printf_chk is resolved in the middle of the program, at the moment main first calls it. In the default build, every symbol is resolved before any of our code runs.
Before and After main
When ld.so has finished, it jumps to the ELF entry point (0x10d0 in the header above). That isn't main. It's _start, a small piece of assembly from crt1.o that GCC links into every program:
00000000000010d0 <_start>:
10d0: endbr64
10d4: xor ebp,ebp ; mark the outermost stack frame
10d6: mov r9,rdx ; ld.so's cleanup function
10d9: pop rsi ; argc
10da: mov rdx,rsp ; argv
10dd: and rsp,0xfffffffffffffff0 ; align the stack to 16 bytes
...
10e8: lea rdi,[rip+0xffffffffffffffa1] # 1090 <main>
10ef: call QWORD PTR [rip+0x2ee3] # __libc_start_main
10f5: hlt ; never reached
_start passes the address of main to __libc_start_main, which sets up the rest of libc, runs every function listed in .init_array (our before_main is there, along with C++ global constructors) and only then calls main. The full chain is:
execve → ld.so → _start → __libc_start_main → .init_array (constructors) → main
main returns → exit() → atexit handlers + destructors → flush stdio buffers → exit_group syscall
Two practical consequences:
- A crash in a global constructor happens before
main. If a C++ program dies before the first line ofmainruns, look at static objects. exit()and_exit()are different.exit()is the libc function that runs the cleanup steps above._exit()goes straight to the kernel and skips them, including flushing stdio.
That second point explains the single write in the strace output. Stdout was a pipe (see the fstat(1, … S_IFIFO …) line), so stdio used full buffering: both lines sat in a user-space buffer and were written out in one system call during exit(). If you skip exit(), the buffered output is lost:
int main(int argc, char **argv) {
printf("buffered line\n");
if (argc > 1) _exit(0); /* skip libc cleanup */
exit(0);
}
$ ./exitdemo | cat
buffered line
$ ./exitdemo x | cat
$
On a terminal, stdout is line-buffered, so the bug stays hidden until someone pipes your program into something.
Why This Matters for Low Latency
In a trading system, these startup mechanics turn into run-time latency spikes:
- Lazy binding puts a symbol lookup on the hot path. The first call to every library function does a hash-table lookup in
ld.so, which takes microseconds. If that first call is the first order of the day, you pay for it then. Link with-Wl,-z,now(or run withLD_BIND_NOW=1) so all lookups happen at startup. Check the result withreadelf -drather than assuming your distro does it for you. - Mapped is not loaded. Code pages are read in by page faults the first time they execute. A path that has never run, such as an error branch or a rarely traded instrument, can fault on its first use. Warm-up runs at startup and
mlockallexist for exactly this reason. LD_PRELOADis the same mechanism, turned into a feature. Because calls go through the PLT and symbols are looked up by name, a library loaded first can supply its ownsend,recvandepoll_wait. That's how kernel-bypass stacks like OpenOnload accelerate an unmodified binary.
Try It Yourself
Every command in this post works on a stock Ubuntu machine:
gcc -c hello.c && nm hello.o && readelf -r hello.o— look for theUsymbols and the relocations that refer to them.readelf -lW ./a.out— findINTERP, theLOADpermissions, andFileSizvsMemSiz.readelf -d ./a.out | grep FLAGS— is your binary lazy orBIND_NOW?strace ./a.out— watchld.soopen and map libc.LD_DEBUG=bindings stdbuf -o0 ./a.out(tryLD_DEBUG=helpfor more options) — watch symbols get resolved.cat /proc/self/maps— see the result of all of this for thecatprocess itself.
Cheat Sheet
| Stage | Done by | Input → Output | Tool to inspect |
|---|---|---|---|
| Preprocess | cpp | .c → expanded .c | gcc -E |
| Compile | cc1 | .c → .s | gcc -S |
| Assemble | as | .s → .o (code + symbols + relocations) | nm, readelf -r, objdump -d |
| Link | ld | .o + libs → ELF executable | readelf -S (sections), readelf -l (segments) |
| Load | kernel (execve) | ELF → address space + stack | strace, /proc/<pid>/maps |
| Dynamic link | ld.so | maps .so files, fills the GOT | ldd, LD_DEBUG, readelf -d |
| Start-up | _start, __libc_start_main | runs constructors, calls main | objdump -d, gdb |
| Shutdown | exit() | destructors, flushes stdio, exit_group | strace |
Wrapping Up
Getting from hello.c to "hello, world" takes four programs, and each one handles a single problem:
- The compiler produces machine code with holes in it, plus a list of relocations describing how to fill them.
- The linker fills the holes it can and writes an ELF file that describes itself twice: sections for linkers, segments for loaders.
- The kernel maps segments into a fresh address space without reading them, then hands control to the dynamic linker named in
INTERP. ld.somaps the shared libraries, fills the GOT (immediately or lazily), locks it down with RELRO, and jumps to_start, which goes through__libc_start_mainand the constructors before reachingmain.
The next post moves from how a process starts to how it talks to the outside world: file descriptors, and the reason printf and write don't behave the same way.