2. The toolchain: the four-stage pipeline, and your first program
2.1 The four stages, made visible
"Compiling" a C file is actually four separate steps, and understanding them explains almost every confusing compiler error you'll ever see.
cat > hello.c <<'EOF'
#include <stdio.h>
int main(void) {
printf("hello, c\n");
return 0;
}
EOF
# 1. Preprocessing: expand #include, #define, #if — pure text substitution
clang -E hello.c -o hello.i
wc -l hello.i # thousands of lines, all from stdio.h's chain of includes
# 2. Compilation: C source -> assembly for the target architecture
clang -S hello.i -o hello.s
head -20 hello.s
# 3. Assembly: assembly -> machine code, produces an object file
clang -c hello.s -o hello.o
file hello.o # Mach-O 64-bit object arm64 (or ELF 64-bit on Linux)
# 4. Linking: resolve symbols (printf lives in libc, not your object file)
clang hello.o -o hello
./hello # hello, c
# In practice you just run:
clang -std=c17 -Wall -Wextra -o hello hello.c
Real-world example. "Undefined reference to foo" is always a
linking error — the compiler understood your code fine, but no object
file or library provides a symbol named foo. "Implicit declaration of
function foo" is a compilation warning from stage 2 — you called
something the compiler hasn't seen a prototype for (usually a missing
#include). Knowing which stage produced an error tells you where to
look: linker errors mean check your -l/-L flags and whether you
compiled every .c file; compiler errors mean check your headers and
syntax.
2.2 The flags you should always use
clang -std=c17 -Wall -Wextra -Wpedantic -Werror \
-fsanitize=address,undefined -g -O0 \
-o prog prog.c
| Flag | Why |
|---|---|
-std=c17 (or -std=c23) |
Pin the language version; don't rely on "whatever GNU extensions the compiler defaults to" |
-Wall -Wextra |
Turns on the warnings that catch real bugs: uninitialized reads, signed/unsigned comparison, unused variables |
-Wpedantic |
Warns on non-standard extensions — important if you need portability |
-Werror |
Warnings fail the build. Excellent for CI and most project code; you may relax it for exploratory/compiler-portability builds |
-fsanitize=address,undefined |
Catches buffer overflows, use-after-free, and UB at runtime — Part V covers this in depth |
-g -O0 |
Debug symbols, no optimization, so a debugger's line numbers match your source |
A portability trap -std=c17 sets for you on Linux. Strict -std=c17
defines __STRICT_ANSI__, and on glibc that hides POSIX declarations
(strdup, fork, execvp, getopt, pthread_*'s POSIX-only helpers,
pipe, dup2, BSD socket calls) unless a feature-test macro is set —
so code that calls them fails with "implicit declaration of function"
under -Werror, even though the exact same command compiles cleanly on
macOS (Apple's libc doesn't gate these the same way, which is why every
snippet in this guide compiled clean on the machine it was tested on).
Any time a later section's code touches POSIX (Part III's threads and
sockets, Part IV's fork/termios/getopt), compile it with either
-std=gnu17 (GNU's C17, POSIX/BSD extensions visible by default) or
-std=c17 -D_POSIX_C_SOURCE=200809L -D_DEFAULT_SOURCE on Linux. This
guide keeps -std=c17 in the command lines for the pure-ISO-C sections
(Parts I–II) and switches to -std=gnu17 from Part III onward for
exactly this reason.
2.3 make, at the level you actually need on day one
CC = clang
CFLAGS = -std=c17 -Wall -Wextra -Wpedantic -Werror -g
SRC = main.c
BIN = prog
$(BIN): $(SRC)
$(CC) $(CFLAGS) -o $(BIN) $(SRC)
clean:
rm -f $(BIN)
.PHONY: clean
Section 13 goes deep on multi-file make; this is enough to stop typing
the full clang command by hand.