Written for someone who already knows what a translation unit is, and wants the toolchain rather than the
language — the language is on its own sheet. gcc is not a compiler; it is a driver that runs
cc1, as and ld, and knowing which of the three produced a message is most of
knowing what to do about it. Cards 1–20 are the working guide: the collection and what is really in it,
the four phases, the gnu23 default that GCC 15 changed under you, the preprocessor's four search lists,
warnings as a taxonomy rather than a wall, what each -O level actually turns on, LTO and PGO, debug info
and gdb, the sanitizers, -fanalyzer, static and shared linking, choosing a linker, reading a binary with
binutils, profiling and coverage, make, CMake and Ninja, the GNU extension surface, cross-compiling for the bench, and
how to get a real GCC onto a Mac. The remaining 19 cards index the flags, attributes, builtins, predefined macros,
pragmas, gdb commands and tools. 627 of the 683 entries also work in Clang; the 49 marked in blue are
GNU-only, and the 7 marked in orange are spelled the same there and mean something else.
gcc is not a compiler. It is a driver — an argument-shuffling front end that decides which real
programs to run, in what order, with what arguments. Knowing the four it runs is most of knowing why a build failed.
| Program | Is | Comes from |
|---|---|---|
gcc | the driver; runs the others | gcc |
cc1 | the C compiler proper — parses, optimises, emits assembly | gcc (not on PATH; a libexec program) |
as | the assembler | binutils |
collect2 → ld | the linker | binutils |
cpp | the preprocessor — built into cc1 since GCC 3, kept as a program | gcc |
So half the errors people call "gcc errors" come out of binutils. undefined reference to is
ld. Error: no such instruction is as. Read the first word of the message.
__GNUC__ does not mean GCC. Clang, ICC and several vendor compilers define it too, to claim
GCC compatibility — Clang has said __GNUC__ 4, __GNUC_MINOR__ 2 for fifteen years and will
not move. To test for real GCC you need both halves:
#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTEL_COMPILER).cc really GCC?On macOS /usr/bin/gcc is a Clang shim and always has been. Real GCC from Homebrew installs
version-suffixed — gcc-15, g++-15 — precisely so it cannot be mistaken for it.
Every invocation runs some prefix of the same four stages. The flag that stops it names the stage you want.
-fsyntax-only does not run the optimiser, and GCC's dataflow warnings
(-Wuninitialized, -Wmaybe-uninitialized, most of -Wanalyzer-*) are computed
during optimisation. A file that is clean under -fsyntax-only can be filthy under -O2 -Wall.-### is the honest one: exactly what would run, without the noise of running it. Reach for it the
moment a command works by hand and fails under the build system — the difference is always visible in the argv.
| Flag | Goes to |
|---|---|
-Wp,-opt | the preprocessor |
-Wa,-opt | as — e.g. -Wa,-march=armv8.2-a |
-Wl,-opt | ld — commas become argument separators |
-Xlinker arg | the linker, one argument verbatim (use when the argument contains a comma) |
-Xassembler arg | the assembler, one argument verbatim |
--param name=v | a numeric tuning knob of the optimiser; --help=params lists them |
-fplugin=x.so | a GIMPLE pass plugin GCC only |
Uppercase .S goes through the preprocessor; lowercase .s does not. This is why half of
all hand-written assembly builds fail on the first try.
-O0 -O2 is
-O2); -Wno-x only cancels warnings enabled to its left; and objects and libraries are
resolved strictly in the order given on the link line. A build system that appends CFLAGS after its own is
telling you it expects to be overridden. One that prepends them is telling you it does not.GCC 15 moved its default from -std=gnu17 to -std=gnu23. That is the largest default-dialect
change since C99, and it breaks working code silently — the same source still compiles under GCC 14, under Clang,
and on the build machine that has not been upgraded yet.
bool, true, false,
nullptr, static_assert, thread_local and alignas became keywords, so
typedef enum { false, true } bool; is now a syntax error. foo() in a declaration now means
foo(void), not "unspecified arguments", so a call with the wrong count is an error rather than undefined
behaviour. K&R definitions are gone. And an enum may now have an underlying type wider than
int. State -std= explicitly in every Makefile and this whole class of surprise disappears.The gnu variants are not "looser C". They are the same strict dialect plus statement
expressions, typeof, nested functions GCC only, case ranges, computed goto,
__attribute__, binary literals before C23 standardised them, and several hundred builtins. See the extension
card.
-pedantic is not conformance. It emits the diagnostics the standard requires; it
does not disable the extensions, and it will still happily accept __attribute__ and
__builtin_expect. A program clean under -pedantic-errors can still be full of undefined
behaviour — that is what the sanitizers are for.-fno-builtin and -ffreestanding change code generation, not just the headers.
A kernel or firmware build that omits them will find GCC turning a hand-written loop into a call to a memcpy
that does not exist at that link stage.The preprocessor's whole job is textual, and almost every mystery in it is a search-order question. GCC searches four
lists, in this order, and -v prints them.
| Directory list | Searched for | Added by |
|---|---|---|
| the including file's own directory | "quoted" only | implicit |
the -iquote list | "quoted" only | -iquote dir |
the -I list | both forms | -I dir |
| the system list | both forms | -isystem dir, then the built-ins |
-isystem for third-party headers, not -I. Headers found through the
system list have their warnings suppressed, so somebody else's -Wsign-compare noise stops drowning yours.
That is the entire difference, and it is worth having.-MP is the one people leave out. It emits a phony target for every header. Without it,
deleting or renaming a header makes make stop dead with "No rule to make target 'old.h'" until you wipe
the .d files by hand. With it, make just rebuilds. Cost: nothing.GCC ships hundreds of warnings and enables perhaps a fifth of them. Treating them as a list to switch on is how people
end up with -Wno-everything in a Makefile. Treat them as four groups instead.
| Group | What it means | Policy |
|---|---|---|
| Almost always a bug | -Wreturn-type, -Wuninitialized, -Wformat, -Wimplicit | error, no exceptions |
| Usually a bug | -Wshadow, -Wsign-compare, -Wnull-dereference | error in new code |
| House style | -Wpadded, -Wswitch-enum, -Wconversion | opt in per project |
| Noise | -Wunused-parameter in callback-heavy code | suppress precisely, never globally |
-Werror belongs in CI, not in the tarball. It makes your release unbuildable on the next
compiler version, which will invent warnings you have never seen. Ship warnings on and -Werror off; run CI
with -Werror so nobody can merge past it.A #pragma GCC diagnostic without a matching pop leaks to the end of the translation
unit — and because it is textual, to every file that includes the header it sits in. The two commonest ways a project
"loses" a warning are an unpaired pragma and an over-broad -Wno- in CFLAGS.
-O levels are not a speed dial. Each is a named set of passes, and gcc -Q --help=optimizers -O2
prints exactly which. The useful mental model is what each level costs you.
| Level | Turns on | Costs |
|---|---|---|
-O0 | nothing; every variable lives in memory | speed. The default |
-Og | optimisations that do not confuse the debugger | almost nothing — the right default for development |
-O1 | ~50 passes; no big compile-time gambles | a little debuggability |
-O2 | ~90 passes: inlining, vectorisation (GCC 12+), scheduling | debuggability. The release default |
-O3 | aggressive inlining and loop transforms | code size; sometimes speed, via icache |
-Os | -O2 minus what grows the binary | a few per cent of speed |
-Oz | size at any cost | real speed. Firmware only |
-Ofast | -O3 plus -ffast-math plus non-conforming behaviour | correctness — see below |
-Ofast. It implies -ffast-math, which sets FTZ/DAZ in the
floating-point control word process-wide — including for libraries that never asked for it — and permits
reassociation that changes results. NaN and infinity handling stop being guaranteed. If you need fast maths, enable the
specific piece you can defend (-fno-math-errno, -fassociative-math) and say why in a comment.-O0 and load-bearing at -O2.
When a bug appears only in the release build, the answer is almost never "a compiler bug" — build the same source with
-fsanitize=undefined -O2 and it usually names itself.Both trade build time for run time, and both are switched on with a pair of flags most people get half right.
gcc-ar, gcc-nm and gcc-ranlib. A plain
ar writes an archive index that cannot see inside GIMPLE objects, so the link silently drops symbols and you
get undefined reference to a function that is demonstrably in the archive. The gcc- wrappers
load the LTO plugin; ar --plugin $(gcc -print-file-name=liblto_plugin.so) is the long way round.-Wlto-type-mismatch.
That warning is not LTO breaking your program — it is LTO finding a bug that was always undefined behaviour.Real gains from PGO are 5–15% on branch-heavy code — interpreters, parsers, compression — and close to nothing on numerical kernels, where the branches were predictable anyway. Measure before adopting the workflow; it is a permanent tax on your build.
Debug info is a separate axis from optimisation. -O2 -g is legal, normal, and what you should ship for
anything you might have to debug in the field.
value optimized out is not a gdb failure — it is -O2 telling the truth: that
variable never had a home in memory. Rebuild that one file at -Og rather than fighting it.
Sanitizers are compile-time instrumentation plus a runtime library. They do not replace warnings or the analyzer — they catch the things only visible while the program runs, and they cost 2–20× in speed, so they belong in the test suite rather than production.
| Flag | Catches | Cost |
|---|---|---|
-fsanitize=address | overflow, use-after-free, double free, leaks (Linux) | ~2×, ~3× memory |
-fsanitize=undefined | signed overflow, bad shifts, null deref, misaligned access, bad enum | ~1.2× |
-fsanitize=thread | data races — real ones, not "suspicious" | ~10×, ~7× memory |
-fsanitize=leak | leaks alone, without the rest of ASan | small |
-fsanitize=pointer-compare | comparing pointers into different objects | with ASan |
-fsanitize=bounds-strict | every array index, including trailing flexible members | small |
__asan_*.-fsanitize=memory
only, and it requires every library in the process to be instrumented too. Under GCC the nearest equivalents are
-Wmaybe-uninitialized -O2, -ftrivial-auto-var-init=pattern and valgrind's memcheck.-fhardened is the flag to reach for first on a new project; it turns on
_FORTIFY_SOURCE=3, -fstack-protector-strong, -fstack-clash-protection,
-fcf-protection=full, PIE and full RELRO, and it will keep tracking the consensus as it moves. It is not
retroactive: it does not touch -D_FORTIFY_SOURCE if you already set it.
-fanalyzerGCC's static analyzer runs a path-sensitive symbolic execution over the whole function, and it is the only part of GCC that can tell you how a bug happens rather than where. It arrived usable in GCC 11 and is genuinely good by GCC 15. It is C-only.
-Wall -Wextra does not-fanalyzer -fanalyzer-checker=taint marks data that came
from outside the program — a read, a socket, getenv — and follows it into array indices, allocation
sizes, divisors and loop bounds. Annotate your own entry points with
__attribute__((access(read_only, 1, 2))) and it follows those too.| Tool | Finds | Notes |
|---|---|---|
-fanalyzer | memory and resource lifetimes, along paths | in the compiler; no separate build |
cppcheck --enable=all | a wide, shallow net; style and portability | parses without a full build; noisy but cheap |
valgrind --tool=memcheck | uninitialised reads, invalid access | no rebuild at all; ~20×, but finds what ASan misses |
clang-tidy | modernisation, bugprone patterns, CERT rules | needs compile_commands.json; works fine on a GCC project |
frama-c | proof, given ACSL contracts | a different activity, not a linter |
The analyzer and the sanitizers are complementary, not alternatives: the analyzer reasons about paths it can see statically and reports things that may never happen; the sanitizers report only what actually happened, but they are never wrong about it.
An archive is a container with an index, not a library in any deeper sense. ld walks the link line
left to right, keeping a set of still-undefined symbols, and pulls a member out of an archive only if that member
defines something currently undefined. This one rule explains nearly every static-link failure.
gcc -lx main.o silently links nothing from libx. At the point
ld reads the archive, no symbol is undefined yet, so no member is pulled in; then main.o arrives
and needs one. Libraries go last, and a library that needs another goes before it. Circular dependencies need
-Wl,--start-group … -Wl,--end-group, which rescans until nothing changes.getaddrinfo, getpwnam and the rest
of NSS dlopen their backends at run time, so a -static binary either fails or silently behaves
differently on a machine whose /etc/nsswitch.conf differs. If you want a genuinely static binary, build against
musl. On macOS, static linking of libSystem is not supported at all.A shared object is loaded at an address nobody chose in advance, so every object in it must be position-independent, and every symbol it exports is a permanent interface.
| Name | Called | Used at |
|---|---|---|
libx.so | the linker name | link time, by -lx |
libx.so.1 | the soname | run time, by the loader |
libx.so.1.2.3 | the real name | on disk |
A default-visibility library exports every non-static function, which means faster growth in load time, a
larger dynamic symbol table, real risk of colliding with another library's init(), and an ABI you never agreed
to. Hiding by default typically cuts the exported table by 90% and speeds up startup measurably.
GCC forks whatever ld it finds. On a large C++ or LTO link that choice is the difference between a
one-second and a forty-second edit cycle; on an ordinary C program it barely matters.
| Linker | Speed | Notes |
|---|---|---|
ld.bfd | 1× (the baseline) | binutils. Handles every format and every linker script. The default |
ld.gold | ~2× | ELF only; deprecated and unmaintained since binutils 2.44 |
ld.lld | ~4× | LLVM's; ELF, COFF and Mach-O. Works fine driving GCC |
mold | ~10× | ELF (and Mach-O in sold). Drop-in; the choice for a big link |
MEMORY/SECTIONS scripts that lld implements partially and mold not at all. If your build has a
.ld file, stay on ld.bfd and take the seconds.Typical saving on an embedded build: 20–40% of flash. On a hosted program it is usually noise, because the libc you link against was not built that way.
Every question about a built artefact — why is it this size, what does it need, which compiler made it, why does it not start — is answered by four binutils programs.
ldd on a binary you do not trust. On glibc it works by running the program
with a special loader environment — it is not a passive inspection. readelf -d and
objdump -p answer the same question without executing anything.| Column | Values | Means |
|---|---|---|
| type | T t / D d / B b / U / W | text / data / bss / undefined / weak. Lowercase = local |
| binding | GLOBAL, LOCAL, WEAK | whether another object may resolve to it |
| visibility | DEFAULT, HIDDEN, PROTECTED | whether it is exported from a shared object at all |
A symbol that is GLOBAL DEFAULT in readelf -Ws but missing from
nm -D was hidden by a version script or by -fvisibility=hidden; a symbol that is
U in every object on the link line is the one ld is about to complain about.
Three different questions, three different tools. Confusing them wastes an afternoon.
| Question | Tool | Rebuild? |
|---|---|---|
| Which functions burn the time? | perf record | no — just -g -fno-omit-frame-pointer |
| Which call paths burn it? | perf record -g, flame graph | no |
| Which lines did the tests reach? | gcov / lcov | yes, --coverage |
| Why is it slow at the microarchitecture level? | perf stat, valgrind --tool=cachegrind | no |
-pg instrumentation distorts what it measures — it adds a call to
mcount to every function, so small hot functions look far more expensive than they are, and it does not see
into shared libraries at all. Use perf unless you are on a platform that has no perf events.
-O0. At -O2 the optimiser merges, splits and deletes
lines, and the line numbers gcov reports stop corresponding to anything you can point at. Coverage and optimisation are
answering different questions; do not try to ask both at once.make is a language for one idea: this file is stale if it is older than the files it is built from. Everything awkward about it follows from being a declarative dependency graph with a macro processor bolted on.
| Form | Expanded | Use for |
|---|---|---|
= | every time it is used (recursive) | a value that must see later definitions |
:= | once, at the point of definition | almost everything. The safe default |
?= | once, only if not already set | letting the environment override |
+= | append, inheriting the flavour of the original | adding to CFLAGS |
!= | run a shell command, once | GITREV != git rev-parse HEAD |
CFLAGS = -O2 $(EXTRA) with = re-runs every shell command inside it on every
use. A recursive variable holding $(shell pkg-config --cflags gtk4) will fork pkg-config once per
compilation. Use := and it forks once.The leading - on -include is what makes the first build work: the .d
files do not exist yet, and without it make stops. That five-line dependency setup is the difference between a Makefile
that rebuilds correctly and one that needs make clean after every header edit.
CMake is a build-system generator: it produces Makefiles, Ninja files or an Xcode project from one description. It is worth adopting at the point where you need a second platform, a config-time feature test, or an installable library.
CMAKE_BUILD_TYPE | Flags |
|---|---|
Debug | -g |
Release | -O3 -DNDEBUG |
RelWithDebInfo | -O2 -g -DNDEBUG |
MinSizeRel | -Os -DNDEBUG |
| unset | nothing — no optimisation at all |
CMAKE_BUILD_TYPE means no -O flag whatsoever with the Makefile
and Ninja generators. Benchmarks published from a default CMake build are, more often than anyone admits, measuring
-O0. Set it, or set CMAKE_BUILD_TYPE to RelWithDebInfo in your presets.Modern CMake is entirely about target_* commands and the
PRIVATE/PUBLIC/INTERFACE keywords. The old directory-scoped
include_directories() and add_definitions() apply to everything below them and are the reason
large CMake builds become unpredictable. If a tutorial uses them, it is pre-2015.
gnu23 buys youThe gnu dialects add a large, stable, well-documented set of extensions. Most have been copied by Clang;
a few have not, and those are marked. All of them are non-ISO, so a project that must stay portable should reach for them
behind a macro.
__attribute__((packed)) does more than remove padding — it makes every member
potentially unaligned, so taking a pointer to one and dereferencing it is undefined behaviour on strict-alignment targets
and generates byte-at-a-time code on x86. Use it for wire formats you memcpy out of, never as a way to make a
struct smaller.Guard the ones you use: #if defined(__GNUC__) for the family, or better,
#ifdef __has_attribute and then #if __has_attribute(cleanup) — a feature test that works
under any compiler that has adopted the extension, which is the point.
A cross toolchain is named by its target triple and prefixed with it. Once you have the prefix, everything else is the ordinary toolchain with a longer name.
| Triple | Targets | Comes from |
|---|---|---|
arm-none-eabi- | bare-metal Cortex-M — no OS, no libc syscalls | Arm GNU Toolchain |
aarch64-linux-gnu- | Raspberry Pi 5, most Arm SBCs | distribution packages |
arm-linux-gnueabihf- | 32-bit Pi, hard-float | distribution packages |
riscv64-unknown-elf- | bare-metal RISC-V | riscv-gnu-toolchain |
avr- | ATmega, ATtiny | avr-gcc |
xtensa-esp32-elf- | ESP32 | ESP-IDF |
.so
usually links, and then crashes on the target in a way that looks like a hardware fault. Set --sysroot, set
PKG_CONFIG_LIBDIR, and check with readelf -A that the float ABI matches — a soft-float
object linked into a hard-float program is the classic Arm version of this.On Linux GCC is the system compiler and the only question is which version. On macOS it is not present at all,
and everything named gcc is a Clang shim.
Worth doing exactly twice: when you need a version your distribution does not carry, and when you want to bisect a compiler bug. Otherwise take the package.
/