The Compiler Toolchain¶
A C compiler is not one program. It’s a pipeline: preprocessor → compiler → assembler → linker. Most developers write C for years without knowing where each stage’s output lives, and then when a linker error says undefined reference to symbol X, they panic. This file makes you fluent in the pipeline, then locks in the flag set you will use for the next 13 months.
On your machine (Apple Silicon Mac) cc symlinks to Apple’s Clang. On a Linux server or a Zoho AppSail container it will usually be GCC. You need to be comfortable with both. They agree on 95% of flags; the differences bite you at exactly the wrong moment.
The Four Stages, With Inspection¶
Given hello.c:
#include <stdio.h>
int main(void) { printf("hello\n"); return 0; }
Stage |
Flag |
Output |
What it contains |
|---|---|---|---|
Preprocess |
|
|
Your source with all |
Compile |
|
|
Assembly for your target arch (arm64 on M-series, x86_64 on Zoho servers). |
Assemble |
|
|
Object file: machine code + symbol table + relocations. Not runnable. |
Link |
(default) |
executable |
|
Run each yourself once, this week, and read the output:
cc -E hello.c -o hello.i # look at the top: see stdio.h expanded
cc -S hello.c -o hello.s # look for _main label, bl _printf call
cc -c hello.c -o hello.o # nm hello.o to see symbols
cc hello.o -o hello # link. otool -L hello on mac to see libs.
If you skip this exercise you will forever confuse compile errors (stage 2) with linker errors (stage 4), which is the #1 friction point for beginners returning to C.
GCC vs Clang: Practical Differences¶
As of mid-2026, on a modern box, either is a fine default. The relevant differences for you:
Concern |
GCC 14+ |
Clang 18+ |
|---|---|---|
Default on macOS |
Not installed (brew) |
Yes, as |
Default on most Linux |
Yes |
Available ( |
Error messages |
Improved a lot in GCC 13/14, still slightly denser |
Historically better, still slightly better |
Sanitizers |
|
Same + |
|
Yes (GCC 10+, mature by 13) |
Use |
C23 support |
Reasonable in GCC 14, use |
Reasonable in Clang 18 |
Apple Silicon native |
Via Homebrew |
Yes, first-class |
Practical rule: use Clang on your Mac for development, test with GCC in CI or on Linux. Compiling with both is the single cheapest portability check.
The Flag Set You Will Use For 13 Months¶
Memorize this. Put it in a CFLAGS variable in every Makefile.
CFLAGS = -std=c17 -Wall -Wextra -Wpedantic -Werror \
-Wshadow -Wconversion -Wstrict-prototypes \
-g -O0 -fsanitize=address,undefined \
-fno-omit-frame-pointer
Why each flag:
-std=c17— A concrete standard. Never rely on the compiler’s default (which drifts between versions). C17 is a bugfix release of C11; well-supported everywhere. Switch to-std=c23in Phase 1 once you’ve read06_c_standards_landscape.md.-Wall— Not “all warnings”. A curated set of the most useful ones. Misnamed for 30 years, we’re not changing it now.-Wextra— Adds warnings-Walldoesn’t include (unused params, sign-compare, missing field initializers). Every C project should use it.-Wpedantic— Warn on non-standard extensions. Catches you accidentally relying on a GCC-ism that won’t compile on Clang.-Werror— Warnings are errors. This is the single most valuable habit. If you don’t do this, warnings pile up, you tune them out, and one of them was the bug. Chris Wellons put it plainly in his 2023 nullprogram post (widely cited on HN/Reddit): high-hitting warnings should be forced to be addressed. With-Werroryou cannot commit code that warns.-Wshadow— Warn when a local shadows an outer variable. Catches a real bug class.-Wconversion— Warn on implicit narrowing (e.g.,inttochar). Noisy but educational; you can drop it after Phase 1 if it fights you too much.-Wstrict-prototypes— Reject old K&Rint foo()(no args declared) style. Forceint foo(void). This one is C-only; the empty-parens meaning changed in C23.-g— Emit debug info for gdb/lldb. Non-negotiable for dev builds.-O0— No optimization. Variables live where source says they live, sogdbprint xworks. For release builds swap to-O2(see below).-fsanitize=address,undefined— The two sanitizers you always run in dev. ASan catches heap/stack/global out-of-bounds, use-after-free, use-after-return, double-free. UBSan catches signed overflow, misaligned access, null deref, shifts out of range. Overhead is ~2× CPU and ~3× RAM — acceptable in dev, never ship them.-fno-omit-frame-pointer— Keeps%rbp/x29as a frame pointer so profilers and sanitizers get sane stack traces.
Release/Perf Build¶
RELEASE_CFLAGS = -std=c17 -Wall -Wextra -Werror -O2 -DNDEBUG -flto
-O2— The industry default.-O3is not always faster and can hide latent UB.-Osoptimizes for size.-DNDEBUG— Disablesassert(). Keep asserts in dev, strip in release, but know you’re doing it.-flto— Link-time optimization. Free 5-15% speed on many programs; slower link.
Sanitizers You Can Add Later¶
Flag |
What it finds |
Cost |
Combinable with ASan? |
|---|---|---|---|
|
heap/stack/global OOB, UAF, double-free |
2× CPU, 3× RAM |
— |
|
signed overflow, null deref, misaligned, shift OOB |
~1.2× |
yes |
|
data races |
5-15× |
no |
|
reads of uninit memory |
3× |
no |
|
memory leaks at exit |
small |
included in ASan |
Thread Sanitizer (TSan) is your friend in Phase 4 (threading). Address and Thread sanitizers cannot be combined — build twice.
What Most People Get Wrong About This¶
They enable warnings but not -Werror, so the warnings accumulate, they stop reading them, and eventually a real bug hides in the noise. Or they use -O2 for dev builds and then wonder why gdb shows <optimized out> for every variable. The fix is culturally simple and mechanically strict: -Werror on for dev, -O0 on for dev, sanitizers on for dev. Flip these off deliberately, one at a time, only when you have a specific reason.
The second most common mistake is enabling -fsanitize=address in production. The OpenSSF hardening guide is explicit: ASan is a dev-time tool. It disables ASLR partially, exposes runtime options via env vars, and expands attack surface. Ship with -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong instead.
Quick Reference Card¶
# Inspect any stage
cc -E file.c # preprocessed to stdout
cc -S file.c # emits file.s
cc -c file.c # emits file.o
nm file.o # list symbols in an object
objdump -d file.o # disassemble object (Linux)
otool -tvV file.o # disassemble object (macOS)
otool -L a.out # list dynamic libraries (macOS ldd equivalent)
file a.out # arch, format, stripped/not
# Debug build
cc -std=c17 -Wall -Wextra -Wpedantic -Werror -g -O0 \
-fsanitize=address,undefined file.c -o file
# Release build
cc -std=c17 -Wall -Wextra -Werror -O2 -DNDEBUG -flto file.c -o file
Return to README.md · Next: 02_build_systems.md