C Toolchain
C is Linux's native language — the kernel is C, Glibc is C, most of the userland is C, and every syscall boundary is a C interface. The toolchain that turns C into binaries — the preprocessor, compiler, assembler, linker, plus make and the debugger — is the deepest instrument in the kit. This chapter covers the pipeline end to end: what each stage does, what each artifact is, and how to read the results.
The four-stage pipeline
gcc hello.c -o hello # all four stages at once — but see them separately:
gcc -E hello.c -o hello.i # 1. PREPROCESS — #include expanded, macros resolved
gcc -S hello.i -o hello.s # 2. COMPILE — C → assembly text
gcc -c hello.s -o hello.o # 3. ASSEMBLE — asm → object file (ELF relocatable)
gcc hello.o -o hello # 4. LINK — objects + libraries → executable
Each stage's output is inspectable: hello.i is text, hello.s is real assembly, hello.o is an ELF object (file hello.o → "relocatable"), hello is "executable". The -save-temps flag keeps all intermediates — read them once and the magic ends forever.
gcc — the flags that matter
gcc -Wall -Wextra hello.c # all warnings — the baseline
gcc -Werror hello.c # warnings are errors — CI mode
gcc -O0/-O1/-O2/-O3 hello.c # optimization levels
gcc -g hello.c # debug info — for gdb
gcc -std=c17 hello.c # the C standard
gcc -I./include hello.c # extra header dirs
gcc -L./lib -lmylib hello.c # library dirs + link a lib
gcc -static hello.c # fully static binary
gcc -shared -fPIC lib.c -o lib.so # shared library
gcc -DNAME=value hello.c # define a macro on the command line
gcc -fsyntax-only hello.c # check without compiling
gcc --version; gcc -v hello.c # version; verbose — the include/link paths
-Wall -Wextra -Werror is the discipline; -g pairs with -O0 for debugging (optimization reorders code confusingly); -static produces a binary with no shared dependencies — the LFS bootstrap binaries lean on it.
Object files and symbols — the anatomy
nm hello.o # the symbol table — functions/vars, defined/undefined
objdump -d hello # disassemble — the actual machine code
objdump -h hello # section headers — .text .data .bss
readelf -h hello # the ELF header — type, arch, entry point
readelf -S hello # sections
readelf -d hello # dynamic section — NEEDED libs
readelf -s hello # symbol table
ldd hello # which shared libs it loads
file hello # the one-line verdict
nm output letters: T = defined text (function), D/B = data/uninit, U = undefined (to be resolved at link). The linker's whole job: resolve every U against other objects' T/D.
Static vs shared libraries
ar rcs libmy.a obj1.o obj2.o # STATIC archive — a bundle of .o files
gcc main.c libmy.a -o prog # link it — code copies INTO the binary
gcc -shared -fPIC -o libmy.so my.c # SHARED — a single .so, loaded at runtime
gcc main.c -L. -lmy -o prog # links against libmy.so or .a
LD_LIBRARY_PATH=. ./prog # runtime search for .so
ldconfig -p | grep my # the system's .so registry
Static bakes the library in — no runtime deps, bigger binary, no update path. Shared (.so) loads at runtime via ld.so — small binaries, shared memory, updates propagate — but the .so must be found: ldd, LD_LIBRARY_PATH, /etc/ld.so.conf + ldconfig govern the search.
soname versioning — libfoo.so.1 is the ABI version; libfoo.so → libfoo.so.1.2.3 symlinks — is how apt upgrade replaces a library under running programs.
make — the build automator
# Makefile — targets: prerequisites + recipe
hello: hello.o
gcc hello.o -o hello
hello.o: hello.c
gcc -c hello.c -o hello.o
clean:
rm -f hello hello.o
make # first target
make hello # named target
make -j$(nproc) # parallel — the LFS speedup
make -C dir # enter dir first
make -n # dry run — what WOULD happen
make clean # convention target
make install # convention target — DESTDIR/prefix matter
Make's real power: dependency tracking — rebuild only what changed. make reads timestamps; a .o newer than its .c isn't rebuilt. The tab-indent requirement is make's famous sharp edge — recipe lines must be literal tabs.
The preprocessor and the header world
gcc -E hello.c | less # watch #include explode
gcc -M hello.c # the header dependency list — make uses this
echo | gcc -E -v - # the built-in include search path
Headers: <stdio.h> = system (/usr/include); "my.h" = local. The -I dirs prepend the search. pkg-config --cflags --libs gtk returns the right flags for installed libraries — the bridge between "installed" and "found".
The preprocessor's own powers
gcc -E hello.c | less # the expanded source
gcc -dM -E - < /dev/null # every predefined macro
gcc -E -dD hello.c # expand but keep #defines
gcc -M hello.c # header deps — makefile format
gcc -MM hello.c # deps sans system headers
echo | gcc -x c -E -v - # the include search paths
gcc -DFOO=1 hello.c # define on the line
gcc -UFOO hello.c # undefine
#ifdef/#ifndef/#if/#elif/#else/#endif/#define/#undef/#pragma/#error
The dep output -M is how Makefiles auto-generate #include tracking — gcc -MMD -MP emits .d files make consumes.
The linker and the object formats
ld --verbose | grep SEARCH_DIR # the .so search order
ldd binary # runtime deps resolved
readelf -d binary | grep NEEDED # the recorded deps
readelf -r binary # relocations
objcopy --strip-debug binary # strip sections
strip --strip-unneeded binary # shrink it
ar t lib.a # list archive contents
ar x lib.a # extract
ranlib lib.a # rebuild the index
ldconfig -p | grep libfoo # the registry
ELF sections: .text code, .data initialized data, .bss uninitialized, .rodata constants, .init/.fini constructors, .interp the dynamic linker path, .dynamic the runtime linking table, .symtab/.strtab symbols, .debug_* debug info, .comment compiler version. readelf -S shows yours.
Shared library versioning — the soname layer
libfoo.so -> libfoo.so.1 -> libfoo.so.1.2.3 # the three-name chain
readelf -d libfoo.so | grep SONAME # the ABI version
ldconfig -p | grep libfoo # the registry resolution
LD_PRELOAD=./my.so prog # inject a .so — the debug/hook
LD_LIBRARY_PATH=/path prog # extra search dirs
ldconfig -n /path # rescan a dir
SONAME is the ABI contract — libfoo.so.1 promises compatibility; libfoo.so.2 breaks it. The libfoo.so dev symlink → the versioned file is how -lfoo finds it. ldconfig rebuilds /etc/ld.so.cache from ld.so.conf.d/ paths.
make — the deeper grammar
CC = gcc
CFLAGS = -Wall -O2
OBJS = main.o util.o
program: $(OBJS) # target: prereqs
$(CC) $(CFLAGS) -o $@ $(OBJS) # $@ = target, $< = first prereq, $^ = all
%.o: %.c # pattern rule
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean all install
clean:
rm -f $(OBJS) program
all: program
install: program
install -m755 program /usr/local/bin/
Automatic vars: $@ target, $< first prereq, $^ all prereqs, $? newer prereqs, $* stem. make -pn dumps the whole rule+variable database; make -d debug-traces decisions; make -B forces; make -k continues on failure.
gdb — the debugger
gcc -g hello.c -o hello # -g embeds debug info
gdb ./hello
(gdb) break main # breakpoint
(gdb) run # start
(gdb) next / step # over / into
(gdb) print var # inspect
(gdb) backtrace # the call stack — a crash's map
(gdb) list # source around current line
(gdb) continue # resume
For crashes without gdb: ulimit -c unlimited + the program dumps core → gdb ./prog core post-mortems it.
Compiler family — clang, cc, and friends
cc → gcc is the POSIX convention; clang/llvm is the modern alternative — same flags mostly, better diagnostics, faster in places, and the LLVM infrastructure (lld, the IR, sanitizers). clang-format, -fsanitize=address/undefined (in both compilers now) — AddressSanitizer and UBSan — turn silent memory bugs into loud crashes. Every serious C project compiles with -fsanitize in test builds.
The toolchain in context — the LFS payoff
On an LFS system, gcc --version reports the compiler you compiled — the cross-toolchain of chapter 3 grown into the native toolchain of chapter 5. ldd /usr/bin/gcc shows it linking the glibc you built. The toolchain is the system's reproductive organ — the one package that can build all the others, including a newer version of itself.
Where compilation goes wrong
| Symptom | Cause |
|---|---|
undefined reference to 'foo' | Library not linked — -lfoo or missing .o |
cannot find -lfoo | Library dir missing — -L/path |
foo.h: no such file | Header dir missing — -I/path |
Works on my machine, libfoo.so missing elsewhere | Runtime path — ldconfig, LD_LIBRARY_PATH, rpath |
| Warnings-as-errors in old code | -Werror vs a newer gcc — -Wno-x or fix |
make rebuilds nothing | Timestamps confused — touch the sources |
| "relocation against ... recompile with -fPIC" | Static code in a shared lib — -fPIC |
| Different binary each build | -O/-g nondeterminism — -frandom-seed, or check flags |
Exercises
gcc -save-temps hello.c— open.i,.s,.o— read each stage's artifact.nma .o — find theT/Usymbols; link a second .o resolving aU.- Build a
.a, a.so, and a program using each —lddthem. objdump -dyour hello — findmainin the disassembly.make -na real Makefile — the dry-run plan.- Crash a program on purpose (
*(int*)0=0);gdbthe core.