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

-E

.i (text)

Your source with all #includes pasted in and macros expanded. Usually 500-800 lines even for hello world because <stdio.h> pulls half of libc.

Compile

-S

.s (text)

Assembly for your target arch (arm64 on M-series, x86_64 on Zoho servers).

Assemble

-c

.o (binary)

Object file: machine code + symbol table + relocations. Not runnable.

Link

(default)

executable

.o files + libc + startup code resolved into an a.out / mach-o / ELF.

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 cc/clang

Default on most Linux

Yes

Available (apt install clang)

Error messages

Improved a lot in GCC 13/14, still slightly denser

Historically better, still slightly better

Sanitizers

-fsanitize=address,undefined,thread,leak

Same + -fsanitize=memory (MSan; clang-only), CFI

-fanalyzer static analysis

Yes (GCC 10+, mature by 13)

Use clang --analyze / scan-build

C23 support

Reasonable in GCC 14, use -std=c23 or -std=gnu23

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=c23 in Phase 1 once you’ve read 06_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 -Wall doesn’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.

  • -WerrorWarnings 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 -Werror you 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., int to char). Noisy but educational; you can drop it after Phase 1 if it fights you too much.

  • -Wstrict-prototypes — Reject old K&R int foo() (no args declared) style. Force int 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, so gdb print x works. For release builds swap to -O2 (see below).

  • -fsanitize=address,undefinedThe 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/x29 as 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. -O3 is not always faster and can hide latent UB. -Os optimizes for size.

  • -DNDEBUG — Disables assert(). 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?

-fsanitize=address

heap/stack/global OOB, UAF, double-free

2× CPU, 3× RAM

-fsanitize=undefined

signed overflow, null deref, misaligned, shift OOB

~1.2×

yes

-fsanitize=thread

data races

5-15×

no

-fsanitize=memory (Clang only)

reads of uninit memory

no

-fsanitize=leak

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