Skip to content
ALB Wiki

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

SymptomCause
undefined reference to 'foo'Library not linked — -lfoo or missing .o
cannot find -lfooLibrary dir missing — -L/path
foo.h: no such fileHeader dir missing — -I/path
Works on my machine, libfoo.so missing elsewhereRuntime path — ldconfig, LD_LIBRARY_PATH, rpath
Warnings-as-errors in old code-Werror vs a newer gcc — -Wno-x or fix
make rebuilds nothingTimestamps 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

  1. gcc -save-temps hello.c — open .i, .s, .o — read each stage's artifact.
  2. nm a .o — find the T/U symbols; link a second .o resolving a U.
  3. Build a .a, a .so, and a program using each — ldd them.
  4. objdump -d your hello — find main in the disassembly.
  5. make -n a real Makefile — the dry-run plan.
  6. Crash a program on purpose (*(int*)0=0); gdb the core.

See also

Categories: C · Compilers · Handbook · Linux · Linux Handbook
This page was last edited 2026-10-26 13:32:06 UTC by ALB Team. What links here