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. Clang is a front end and LLVM is everything behind it, and that split
is why this toolchain has an opt and an llc you can run by hand, why clang-tidy,
clang-format and clangd exist at all, and why cross-compiling is a flag rather than a second
toolchain. Cards 1–17 are the working guide: the pipeline and what each stage will show you, the driver, the
gnu17 default Clang kept, the diagnostic engine that is the whole reason Clang was written, what each
-O level is, ThinLTO and both flavours of PGO, debug info and lldb, the full sanitizer set including the two
GCC has not got, clang-tidy and the analyzer, linking on ELF and Mach-O, the LLVM tools, source-based coverage, reading
the IR, cross-compiling, what is genuinely different on a Mac, and which of the three compilers called "clang" you
actually have. The remaining 19 cards index the flags, attributes, builtins, macros, lldb commands and tools.
601 of the 713 entries also work in GCC; the 105 marked in orange are Clang or LLVM only, and the
7 marked in blue are spelled the same in GCC and mean something else.
Clang is a front end; LLVM is everything behind it. The split is the whole design, and it is why the toolchain has an
opt and an llc you can run by hand, why clang-tidy and clangd exist,
and why every vendor with a GPU ships a fork.
| Stage | Produces | Inspect with |
|---|---|---|
| Clang parser | the Clang AST — full source fidelity, comments included | -Xclang -ast-dump |
| CodeGen | LLVM IR — SSA, typed, textual and serialisable | -S -emit-llvm |
| the middle end | optimised IR, target-independent | opt -O2 -S |
| ISel → MIR | machine IR, still SSA at first | llc -print-after-all |
| MC | object code — the assembler is inside clang | -fno-integrated-as to opt out |
One consequence you feel daily: Clang does not fork an assembler, so -S and -c are
a single process and error messages from bad inline assembly come out of clang itself, with a caret.
__clang_major__. Apple's version numbers and upstream's have
been unrelated for a decade, so a comparison that is right on Linux is wrong on a Mac. Feature-test instead —
__has_builtin, __has_attribute, __has_feature, __has_include —
which is exactly what Clang invented them for.Code that says #if __GNUC__ >= 5 to enable a feature loses it under Clang, forever, silently.
That single line is the most common portability bug in GNU-flavoured C.
cc Clang?The driver runs some prefix of the same four stages every C compiler does — but the assembler stage is inside the same binary, so there are fewer processes and fewer places to lose an argument.
-print-targets is the difference that matters. One clang binary contains every
backend LLVM was built with, so cross-compiling is a --target= flag rather than a second toolchain. There is
no aarch64-linux-gnu-clang, and there does not need to be.| Flag | Goes to |
|---|---|
-Wp,-opt | the preprocessor |
-Wa,-opt | the assembler (the integrated one, unless -fno-integrated-as) |
-Wl,-opt | the linker — commas become argument separators |
-Xlinker arg | the linker, one argument verbatim |
-Xclang arg | cc1 directly — an unstable internal interface |
-mllvm arg | the LLVM backend's own cl::opt flags — also unstable |
-Xarch_arm64 arg | only for that slice of a universal build |
-Xclang and -mllvm are not a public interface. They reach internal
options that are renamed, repurposed and removed between releases without notice. Every one of them in a Makefile is a
build that will break on the next compiler upgrade. If a Stack Overflow answer starts with -Xclang, look for
the driver flag that does the same job first.Uppercase .S goes through the preprocessor; lowercase .s does not — the same
rule everywhere, and the same first-try failure in every hand-written assembly build.
Clang 21 still defaults to -std=gnu17. GCC 15 moved to gnu23. Nothing else about the split
between the two compilers causes as much confusion, and the fix is one flag.
-std= explicitly in every build file. Then a C23 feature that works here works
there, code that assumes bool is a typedef keeps compiling, and the difference between the two default
dialects stops being your problem. It costs eleven characters.-Wgnu has no GCC equivalent and is genuinely useful: it names every extension your code depends
on, one warning per construct, so you can decide about each rather than discovering them on a different compiler.
#ifndef __has_attribute /
#define __has_attribute(x) 0 / #endif at the top of a header makes them safe under any
compiler, including one that predates them. Every portable C project does this; it is four lines.The same four search lists as every C compiler, searched in the same 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, the SDK, the resource dir |
-isystem, not -I, for third-party headers. Warnings are suppressed inside
the system list, so somebody else's -Wsign-compare noise stops burying yours. On macOS the SDK arrives this
way too — -isysroot $(xcrun --show-sdk-path).~/Library/Caches/clang/ModuleCache, or the path -fmodules-cache-path names) before you believe
any of it.Clang was written because GCC's error messages were bad. Everything about the diagnostic engine — source ranges, carets, fix-its, notes attached to the declaration you got wrong — is the product, not decoration. It is worth knowing how to drive it.
-Weverything is a discovery tool, not a policy. It includes mutually contradictory
warnings, warnings about C++ in a C build, and -Wpadded, which fires on nearly every struct. Run it once,
read the list, promote the six you agree with into your real flags, and turn it off. Shipping -Weverything
-Werror guarantees your project stops building on the next Clang release.-Wthread-safety has no GCC equivalent and is the strongest static tool in this toolchain.
Annotate a mutex with __attribute__((capability("mutex"))) and the data it protects with
__attribute__((guarded_by(mu))), and Clang checks every access at compile time — no run-time cost, no
false negatives from an untaken path. It is what LLVM and Chromium use on their own locks.An unpaired push leaks to the end of the translation unit — and, since it is textual, into
every file that includes the header it lives in. That and an over-broad -Wno- in CFLAGS are the
two ways a project quietly loses a warning it thought it had.
Each -O is a named pass pipeline. Unlike GCC's, you can print it, run it by hand, and diff it.
| Level | Is | Costs |
|---|---|---|
-O0 | nothing, plus optnone on every function | speed. The default |
-O1 | the cheap, always-profitable passes | a little debuggability |
-O2 | inlining, vectorisation, the full pipeline | debuggability. The release default |
-O3 | -O2 plus aggressive inlining and unrolling | size; sometimes speed, via icache |
-Os | -O2 tuned for size | a few per cent of speed |
-Oz | size above all — even calls to memcpy for small copies | real speed. Firmware |
-Ofast | -O3 -ffast-math and non-conforming behaviour | correctness. Deprecated in Clang 19+ |
-Og | an alias for -O1 here, not its own level | — |
-Ofast is deprecated and should not be in your build. It implies
-ffast-math, which links a startup object that sets FTZ/DAZ process-wide — including for shared
libraries that never asked for it — and licenses reassociation that changes results. Enable the specific relaxation
you can defend (-fno-math-errno, -fno-signed-zeros) and write down why.-opt-bisect-limit is the tool nobody knows about. It runs only the first N optimisation
passes and reports which one it stopped at. Binary-search N until the miscompilation appears and LLVM names the pass that
caused it — turning "the release build is broken" into a one-line bug report, in about ten minutes.LLVM's LTO comes in two shapes, and the interesting one is ThinLTO: it keeps a per-module summary rather than merging everything into one enormous module, so it parallelises and it caches. This is why LTO is a default on Apple platforms and an occasional heroic effort elsewhere.
ar cannot index a bitcode object. The archive builds, the link finds nothing in
it, and you get undefined symbol for a function that is demonstrably there. Use llvm-ar
(and llvm-ranlib) any time -flto is in CFLAGS.| Instrumented | Sampled | |
|---|---|---|
| Build | -fprofile-instr-generate | ordinary -O2 -g |
| Collect | run it; writes default.profraw | perf record -b, then create_llvm_prof |
| Merge | llvm-profdata merge -output=x.profdata *.profraw | same |
| Rebuild | -fprofile-instr-use=x.profdata | -fprofile-sample-use=x.prof |
| Cost | 2–4× slower while collecting | ~1% — can profile production |
Sampled PGO is the underrated one: no instrumented build, no separate binary to ship, and you can collect from the real workload in production. Google runs its whole fleet this way.
.dSYM question-O2 -g is the normal shape of a shippable build. What differs by platform is where the debug info
ends up.
.o
files and the linker records a debug map; lldb follows that map back to the objects. Delete the build
directory and your executable becomes undebuggable. dsymutil prog gathers it into a
prog.dSYM bundle you can archive — do that for every release, and keep it beside the binary.fr v beats p for looking around. frame variable reads the
variables out of the debug info directly; p compiles and runs an expression in the inferior, which can have
side effects, can fail, and is far slower. Use p when you need to compute something, fr v when
you just want to look.The sanitizers are LLVM's, and Clang has all of them — including two GCC does not implement. They are instrumentation plus a runtime, they cost 1.2–20×, and they belong in the test suite.
| Flag | Catches | Cost |
|---|---|---|
-fsanitize=address | overflow, use-after-free, double free, leaks | ~2×, ~3× memory |
-fsanitize=undefined | signed overflow, bad shifts, null deref, misalignment, bad enum | ~1.2× |
-fsanitize=thread | data races — real ones, with both stacks | ~10×, ~7× memory |
-fsanitize=memory | reads of uninitialised memory Clang only | ~3× |
-fsanitize=cfi | indirect calls through the wrong function type Clang only | small; needs LTO |
-fsanitize=safe-stack | stack-smashing, by splitting the stack Clang only | ~1% |
-fsanitize=fuzzer | — it is libFuzzer, the coverage-guided fuzzer Clang only | — |
Any function that takes a buffer and a length is a candidate. Parsers, decoders and format readers usually yield a crash within minutes the first time they are fuzzed — and libFuzzer minimises the input for you.
This is the part of the toolchain GCC has no answer to, because Clang is a library. All four tools read the same
compile_commands.json, so the first job on any project is to produce one.
The checks worth starting with in C: bugprone-*, cert-*,
clang-analyzer-* (the static analyzer, run as a tidy check), and misc-*. Skip
readability-* until the codebase is clean, or the signal drowns.
clangd is the same parser as the compiler, which is the whole point: go-to-definition,
find-references and rename are exact rather than heuristic, and the diagnostics in the editor are the ones the build will
produce. It is what every editor's C support is built on now, Zed and VS Code included.
Clang forks a linker; which one it forks decides both the speed and the spelling of half these flags. On Linux it is
ld.bfd unless you say otherwise; on macOS it is always Apple's ld64.
The linker walks the command line left to right holding a set of undefined symbols, and pulls a member out of an archive only if it defines something currently undefined.
| ELF (Linux) | Mach-O (macOS) | |
|---|---|---|
| build | -shared -fPIC | -dynamiclib (PIC is mandatory) |
| file | libx.so.1.2.3 | libx.1.dylib |
| recorded name | -Wl,-soname,libx.so.1 | -Wl,-install_name,@rpath/libx.1.dylib |
| search at run time | -Wl,-rpath,'$ORIGIN/../lib' | -Wl,-rpath,@loader_path/../lib |
| list dependencies | readelf -d | otool -L / dyld_info |
| no undefined symbols | -Wl,-z,defs | the default |
| drop dead code | -Wl,--gc-sections | -Wl,-dead_strip |
@rpath is not optional on macOS. A dylib records its own install name inside itself,
and every program that links it copies that string. Build one with a plain relative name and every consumer records a
path that only resolves from the directory you happened to build in. Set
-install_name @rpath/libx.1.dylib when you build the library, and -rpath when you link the
program.LLVM ships a drop-in for every binutils program and several with no counterpart. They read ELF, Mach-O, COFF and WebAssembly from one binary, which is the reason to prefer them: the same command works on every target you cross-compile for.
| LLVM | Replaces | Notes |
|---|---|---|
llvm-nm | nm | on macOS, nm already is this |
llvm-objdump | objdump | --x86-asm-syntax=intel for Intel syntax |
llvm-readobj / llvm-readelf | readelf | readelf mode takes the GNU flags |
llvm-strip, llvm-objcopy | strip, objcopy | same options, all formats |
llvm-ar, llvm-ranlib | ar, ranlib | required under LTO |
llvm-symbolizer | addr2line | what the sanitizers call to make a stack readable |
llvm-cxxfilt | c++filt | |
llvm-size, llvm-strings | size, strings | |
llvm-mca | — | a machine-code analyser: throughput and port pressure, statically |
llvm-bolt | — | post-link layout optimisation from a perf profile |
llvm-mca answers "why is this loop slow?" without running it, at instruction granularity. Paste
in the output of clang -S -O2 for one function and it tells you the bottleneck resource. Nothing in binutils
does this.
Clang's coverage is source-based rather than line-table-based, which means it is accurate at -O2 and it
counts regions — so it can tell you that a condition was evaluated 40 times and was true 40 of them.
llvm-cov show
--show-branches=count marks the branch conditions your tests never took the other way — which is where the
bugs are. A file at 100% line coverage routinely sits at 60% branch coverage.On Apple silicon perf does not exist. samply, sample and Instruments
are the replacements, and Instruments is the only one that sees the efficiency/performance core split.
LLVM IR is a real, documented, textual language, and every stage of the pipeline will hand it to you. This is the single biggest practical difference from GCC: when the optimiser does something surprising, you can watch it happen.
unreachable where you did not expect one is the signature of undefined behaviour.
It means the optimiser proved this path is only reached by a program that already had UB, so it deleted the path —
including, sometimes, your null check. Build the same file with -fsanitize=undefined and it will name what
it found.A single clang contains every backend LLVM was built with, so a cross build is a flag rather than a second
toolchain. What you still have to supply is the target's headers, libraries and linker.
--sysroot, use -fuse-ld=lld, and set
PKG_CONFIG_LIBDIR/PKG_CONFIG_SYSROOT_DIR so pkg-config stops answering with host paths —
those three cover almost every failure.On macOS the toolchain is Apple's fork, the headers come from an SDK that moves with Xcode, and several things you expect from Linux are simply absent. None of it is hard once the pieces are named.
-mmacosx-version-min sets what you promise to run on. Get the second wrong and your binary uses a symbol that
is missing on the machine it ships to, and dies at launch with Symbol not found. Use
__builtin_available(macOS 14, *) for anything newer than your minimum.| Linux | macOS |
|---|---|
ldd | otool -L, or dyld_info -dependents |
LD_LIBRARY_PATH | DYLD_LIBRARY_PATH — stripped by SIP for system binaries |
LD_PRELOAD | DYLD_INSERT_LIBRARIES — blocked by SIP and the hardened runtime |
$ORIGIN | @loader_path (and @executable_path, @rpath) |
--gc-sections | -dead_strip |
-soname | -install_name |
| debug info in the binary | a debug map into the .o files; dsymutil to collect |
| static libc | not supported at all — you must link libSystem dynamically |
perf | Instruments, sample, samply |
install_name_tool edit invalidates the code signature. On Apple silicon an
unsigned or badly signed binary will not run at all. Re-sign afterwards with codesign -f -s - prog (ad-hoc)
— and check with codesign -dv --verbose=4 prog."clang" on a given machine is one of three things, and they differ in ways that matter.
| Which | Get it with | Notes |
|---|---|---|
| Apple clang | xcode-select --install | the only one that fully knows the SDK, frameworks and availability attributes |
| Upstream LLVM, macOS | brew install llvm | keg-only; add $(brew --prefix llvm)/bin to PATH deliberately |
| Upstream LLVM, Linux | apt install clang-21, dnf install clang, apt.llvm.org | versioned binaries: clang-21, clang-tidy-21 |
clang-tidy, scan-build, opt,
llc or most llvm-* tools. That is the main practical reason to install Homebrew's LLVM
alongside it — not to replace the compiler, but to get the tooling. Use Apple's clang to build, and Homebrew's
tools to analyse.Building the same project under both compilers is worth the afternoon it costs: each finds real bugs the other misses, and the differences in their warnings are complementary rather than redundant. That is the actual argument for keeping both installed.
/