GCC & Clang: Compilation Deep Dive
Master GCC and Clang — from source text to optimized machine code — and understand exactly how the Linux kernel is built.
Overview
Every line of C you write is a lie. Not a bad lie — a useful one. You write a + b and the compiler turns it into a dance of registers, pipelines, and machine opcodes that your CPU can actually execute. The compiler is the translator between the world you think in and the world the hardware lives in. GCC (GNU Compiler Collection) and Clang (part of LLVM) are the two compilers that matter most in the Linux ecosystem, and understanding them deeply is not optional for kernel work — it is foundational.
This module is about developing an X-ray view of your compiler. You will learn what happens at each stage of compilation, why optimization levels exist and what they actually do, how to catch bugs before they become bugs using sanitizers and warnings, and how modern features like Link-Time Optimization and function attributes give you fine-grained control over code generation. Every concept is grounded in something you can run and observe yourself.
The Linux kernel's build system (Kbuild) is one of the most sophisticated Makefile systems ever written, and it uses both GCC and Clang (via the CC=clang path) to produce one of the most performance-sensitive and security-hardened C programs in existence. The kernel uses compiler features that most application programmers never encounter: __attribute__((noreturn)), __cold, __section, -fno-strict-aliasing, -fno-stack-protector in hot paths, and more. By the end of this module you will read a kernel Makefile flag and know exactly what it does and why it is there.
One warning upfront: compiler behavior is nuanced. The C standard leaves many behaviors undefined, and compilers exploit those holes aggressively under optimization. What you observe at -O0 can be completely different at -O2. This is not a bug — it is the intended design — and learning to reason about it is what separates systems programmers from everyone else.
When you submit a patch to the Linux kernel, you are not just writing code — you are writing code that must compile correctly under GCC and Clang, at multiple optimization levels, for multiple architectures (x86-64, ARM64, RISC-V, s390x, ...), with a specific set of warning flags that are stricter than almost any application project. Maintainers will reject patches that introduce new warnings. The kernel's CI (KernelCI, 0day robot) will catch -Wunused-variable or a missing __must_check annotation before the patch even reaches a maintainer's inbox. Understanding your compiler is not academic prep — it is the immediate practical requirement for contributing.
Beyond patch mechanics, the kernel's performance depends critically on the compiler's ability to optimize. The scheduler, the network stack, the memory allocator — these run millions of times per second on production machines. A missed optimization or a miscompilation (yes, compilers have bugs, and the kernel has workarounds for them) can mean the difference between a server handling 1M requests/second or 900K. Knowing how -O2 versus -Os affects code density and branch prediction, why the kernel uses -fno-strict-aliasing to protect against aggressive aliasing optimizations, and when __attribute__((optimize("O0"))) is a legitimate tool versus a code smell — this knowledge makes you a better kernel contributor and a far better systems programmer.
GCC/Clang Compilation Pipeline for the Linux Kernel
Each .c file in the kernel follows this pipeline. Kconfig-generated autoconf.h feeds into preprocessing, determining which code is compiled. With LTO enabled, the linker step also runs the optimizer across all translation units simultaneously.
The concepts
01The Four Stages of Compilation
Most people say 'the compiler turns C into an executable' as if it's one step. It's actually four distinct stages, each with its own input, output, and failure modes. Understanding this pipeline is the foundation for everything else.
Stage 1: Preprocessing (cpp or gcc -E). The C preprocessor is a text macro substitution engine. It processes #include, #define, #ifdef, and #pragma directives. The output is a single, flat .i file — pure C with all macros expanded and all includes inlined. The kernel's headers are so deeply nested that a single .c file can expand to 100,000+ lines of preprocessed C. You can observe this: gcc -E hello.c | wc -l.
Stage 2: Compilation (gcc -S). The compiler proper takes preprocessed C and produces assembly. This is where lexing, parsing, semantic analysis, IR generation, and optimization happen. The output is a .s file containing human-readable (well, human-learnable) assembly text.
Stage 3: Assembly (as). The assembler converts the .s text into a binary .o object file (ELF on Linux). Object files contain machine code with relocations — placeholder addresses for symbols (functions, globals) that aren't known yet because they might live in other .o files.
Stage 4: Linking (ld or gold or lld). The linker combines all .o files, resolves relocations (fills in the placeholder addresses), and produces the final executable. The kernel's linker script (arch/x86/kernel/vmlinux.lds.S) precisely controls the layout of every section in the final vmlinux image.
Knowing these stages means you know exactly which flag affects which stage, how to inspect intermediate outputs, and where to look when something goes wrong.
# See each stage for yourself:
gcc -E hello.c -o hello.i # preprocessing only
gcc -S hello.i -o hello.s # compile to assembly
gcc -c hello.s -o hello.o # assemble to object
gcc hello.o -o hello # link to executable
# Or do all four at once (normal usage):
gcc hello.c -o hello
# Inspect the ELF object:
objdump -d hello.o # disassemble
readelf -s hello.o # symbol table
02Optimization Levels: -O0 through -O3, -Os, -Oz, -Og
The -O flag is one of the most important flags you will use. It controls which optimization passes the compiler runs. Each level is a preset bundle of individual passes.
-O0 (default): No optimization. Every variable lives in memory, every operation is performed exactly as written. The generated code is slow but completely faithful to the source, making it ideal for debugging because every line in the debugger corresponds exactly to a line of C. The kernel is almost never compiled at -O0; some specific functions use __attribute__((optimize("O0"))) to force it.
-O1: A modest set of optimizations that don't require significant compile-time analysis. Includes dead code elimination, basic constant folding, and simple register allocation.
-O2: The standard production level. Adds instruction scheduling, loop optimizations, function inlining of small functions, common subexpression elimination, and many more passes. This is what the Linux kernel uses by default (-O2 is in KBUILD_CFLAGS).
-O3: Everything in -O2 plus aggressive auto-vectorization (using SIMD), more aggressive inlining, and loop transformations like unrolling. Can make code larger and occasionally slower due to cache effects. The kernel does not use -O3 globally.
-Os: Optimize for size — uses most of -O2 but disables passes that increase code size. Used in embedded and boot code. Kernel uses this in some size-sensitive paths.
-Oz: (Clang only) Even more aggressive size optimization than -Os.
-Og: Optimize for the debugging experience — enables optimizations that don't interfere with debugging (no variable elimination, no reordering of statements observable by a debugger). Better than -O0 for debugging real-world code.
The crucial insight: higher optimization levels allow the compiler to exploit undefined behavior. If your C code has UB, -O0 might 'accidentally' do the right thing while -O2 will silently produce a completely wrong result. This is not the compiler being mean — it is the compiler correctly assuming that UB never happens, as per the standard, and optimizing accordingly. Always fix UB, never rely on -O0 behavior.
/* demo: observe optimization differences */
/* compile with: gcc -O0 -S vs gcc -O2 -S */
int sum_to_n(int n) {
int s = 0;
for (int i = 1; i <= n; i++)
s += i;
return s;
}
/* At -O2, GCC recognizes the Gauss formula and may
reduce the entire loop to: return n*(n+1)/2 */
03Warnings: -Wall, -Wextra, and the Philosophy of Strictness
GCC and Clang can warn you about code that is syntactically valid C but almost certainly wrong. These warnings are your first line of defense against bugs. The kernel enables a large set of warnings and treats them as errors via -Werror (in some configs) — for good reason.
-Wall: Despite the name, this does NOT enable all warnings. It enables a curated set of warnings that the GCC maintainers consider almost always indicating a real bug: uninitialized variables, implicit function declarations, signed/unsigned comparison mismatches, unused results from functions marked __wur (warn_unused_result), and many others. In the kernel, this catches serious bugs.
-Wextra: Enables additional warnings that are useful but have slightly higher false-positive rates. Includes warnings about missing field initializers in structs, unused parameters, and comparison of enum values.
Selected individual warnings the kernel enables:
-Wshadow: Warns when a local variable shadows an outer-scope variable. Kernel code frequently has deep call chains; shadowing creates confusing bugs.-Wformat=2: Strict checking ofprintf/scanfformat strings against argument types. Format string bugs in kernel logging functions can cause serious issues.-Wimplicit-fallthrough: Warns when aswitchcase falls through without an explicit annotation. The kernel usesfallthrough;(a C23/GCC extension keyword) as the annotation.-Wunused-function,-Wunused-variable: Dead code is a maintenance hazard.-Wmissing-prototypes: Every non-static function must have a prototype visible before its definition. This prevents the C89 implicit-int horror.
-Werror: Turns ALL (or specific) warnings into hard errors. The kernel uses this selectively (-Werror=implicit-function-declaration) to make certain classes of mistakes fail the build immediately.
The kernel's approach: Look at scripts/Makefile.extrawarn in the kernel tree. There are three warning levels (W=1, W=2, W=3) that progressively enable more. Most contributors should at least build with W=1 before submitting a patch. The checkpatch.pl script also catches style issues that would be flagged by reviewers.
/* This code has several bugs warnings catch: */
#include <stdio.h>
int process(int *ptr) {
int result; /* -Wuninitialized if returned unset */
if (ptr)
result = *ptr * 2;
/* oops: result uninitialized if ptr==NULL */
return result;
}
void log_value(int x) {
printf("%s", x); /* -Wformat: %s expects char*, got int */
}
/* Compile: gcc -Wall -Wextra -Wformat=2 bugs.c */
/* Every one of these is caught before you even run the code */
04Sanitizers: -fsanitize and Runtime Bug Detection
Compiler warnings catch potential bugs at compile time. Sanitizers catch actual bugs at runtime by instrumenting your code with additional checks. They are indispensable for kernel development, both for kernel code (via KASan, KUBSan, etc.) and for userspace test harnesses.
AddressSanitizer (-fsanitize=address): Instruments every memory access with a shadow-memory check. Catches: heap buffer overflows, stack buffer overflows, use-after-free, use-after-return, double-free. Overhead: ~2x memory, ~2x slower. The kernel equivalent is KASAN (CONFIG_KASAN), which uses a similar shadow memory scheme adapted for kernel virtual address space.
UndefinedBehaviorSanitizer (-fsanitize=undefined): Inserts checks for the most common forms of undefined behavior: signed integer overflow, null pointer dereference, out-of-bounds array access (when size is statically known), misaligned memory access, invalid enum values. The kernel equivalent is KUBSAN (CONFIG_UBSAN). This is enormously valuable — UB that 'works' at -O0 may be silently exploited by the optimizer at -O2.
ThreadSanitizer (-fsanitize=thread): Instruments memory accesses and synchronization primitives to detect data races. Overhead: 5-10x. Cannot be combined with ASan.
MemorySanitizer (-fsanitize=memory): (Clang only) Detects reads of uninitialized memory. Extremely thorough, very high overhead. Cannot be combined with ASan.
LeakSanitizer (-fsanitize=leak): Detects memory leaks at program exit. Automatically included with ASan.
How they work (ASan example): The compiler inserts a check before every load/store:
// Original: *ptr = value;
// Instrumented (conceptually):
if (shadow_memory[ptr >> 3] != 0) asan_report_error(ptr);
*ptr = value;
Eight bytes of real memory correspond to one byte of shadow memory. A shadow value of 0 means all 8 bytes are accessible; non-zero encodes how many bytes are accessible and what kind of region this is.
In the kernel: You cannot use userspace sanitizers directly in kernel code — the kernel needs its own implementations because it manages its own memory. But for writing test programs that exercise kernel interfaces, or for kernel subsystems that have userspace test harnesses (like tools/testing/selftests/), the userspace sanitizers are directly applicable.
/* heap_bug.c — will be caught by ASan */
#include <stdlib.h>
#include <string.h>
int main(void) {
char *buf = malloc(10);
strcpy(buf, "hello");
/* Bug 1: write past end of buffer */
buf[10] = 'X'; /* off-by-one! */
free(buf);
/* Bug 2: use-after-free */
buf[0] = 'Y';
free(buf); /* Bug 3: double-free */
return 0;
}
/* gcc -fsanitize=address -g heap_bug.c -o heap_bug
./heap_bug
ASan prints: ERROR: AddressSanitizer: heap-buffer-overflow
with file+line of the exact bad access */
05Useful Diagnostic and Control Flags
Beyond -O and -W, GCC and Clang have hundreds of flags for controlling code generation. Here are the ones that matter most for kernel work and systems programming:
-g / -g3 / -gdwarf-5: Embed debug information (DWARF format) in the binary. The -g flag is additive with optimization — you can and should use -O2 -g together. The kernel uses -g so that crash dumps and oops messages have meaningful backtraces. -g3 includes macro definitions in the debug info, enabling print inspection of macros in GDB.
-fno-omit-frame-pointer: Keeps the frame pointer register (rbp on x86-64) intact. Without it, the compiler reuses rbp as a general-purpose register. The downside: the frame pointer is essential for stack unwinding — tools like perf, gdb, and the kernel's own stack tracer rely on it. The kernel uses -fno-omit-frame-pointer in debug configs and for profiling.
-fstack-protector / -fstack-protector-strong / -fstack-protector-all: Inserts a canary value on the stack before the return address. On function exit, checks that the canary is unchanged. If a stack buffer overflow overwrites the return address, it also overwrites the canary, and the program aborts. The kernel uses -fstack-protector-strong by default (CONFIG_STACKPROTECTOR_STRONG).
-fno-strict-aliasing: This is critical for the kernel. The C standard says you cannot access an object through a pointer of a different type (the strict aliasing rule). The compiler uses this rule to conclude that int *p and float *q cannot point to the same memory, enabling aggressive optimizations. The kernel deliberately violates this rule (e.g., casting between void * and typed pointers freely) and compiles with -fno-strict-aliasing to prevent the optimizer from miscompiling such code.
-fno-delete-null-pointer-checks: Another kernel necessity. The compiler can assume a null pointer dereference never happens (it's UB), so it may delete null checks that come after a dereference. In kernel space, address 0 might actually be mapped (on some architectures, or in some historical configs), and null-check deletion caused real security vulnerabilities. The kernel disables this optimization.
-march= / -mtune=: Control the target architecture and microarchitecture. -march=x86-64 means 'baseline x86-64, no extensions'; -march=native enables all extensions of the current CPU. The kernel uses CONFIG_GENERIC_CPU / CONFIG_MNATIVE_* to control this. -mtune= controls instruction scheduling for a specific CPU without changing which instructions are emitted.
-pipe: Use pipes instead of temp files between stages. Faster on machines with slow I/O.
# Observe stack protector in action:
cat > stack_test.c << 'EOF'
#include <string.h>
void vuln(const char *input) {
char buf[8];
strcpy(buf, input); /* no bounds check! */
}
int main(void) {
vuln("AAAAAAAAAAAAAAAAAAAAAAAAA"); /* overflow */
return 0;
}
EOF
gcc -fstack-protector-strong -o stack_test stack_test.c
./stack_test
# Output: *** stack smashing detected ***: terminated
# Aborted (core dumped)
# Kernel's actual key flags (from scripts/Makefile.build):
# -O2 -fno-strict-aliasing -fno-delete-null-pointer-checks
# -fstack-protector-strong -fno-omit-frame-pointer (debug builds)
06GCC Attributes: Annotating Functions and Variables
GCC (and Clang, which is broadly compatible) extends C with __attribute__((name)) annotations. These are not part of standard C but are pervasive in the Linux kernel — you cannot read kernel source without understanding them. They communicate intent to both the compiler and future readers, enabling optimizations and ensuring correctness.
__attribute__((noreturn)): This function never returns (e.g., panic(), exit()). The compiler can eliminate dead code after calls to it and avoids generating return-path cleanup code. In the kernel: void __noreturn panic(const char *fmt, ...) — the macro __noreturn expands to this attribute.
__attribute__((pure)) and __attribute__((const)): pure means the function has no side effects and its return value depends only on its arguments and global memory. const is stronger: depends only on arguments, not even on global memory. The compiler can hoist these calls out of loops and eliminate duplicate calls. Example: strlen() is pure; a math function like abs() is const.
__attribute__((noinline)): Prevents inlining. Used in the kernel when a function must appear in stack traces (for debugging), when a function is in a specific section that inlined code wouldn't be, or to prevent code bloat in error paths.
__attribute__((always_inline)): Forces inlining even when the optimizer would choose not to. Used for tiny hot-path functions where call overhead matters, or for functions that use __builtin_* intrinsics that only work inline.
__attribute__((cold)) and __attribute__((hot)): Marks rarely-executed (cold) or frequently-executed (hot) functions. The compiler will not inline cold functions into hot paths, will use branch prediction hints to prefer the non-cold path in if statements, and may group cold functions together in a .text.unlikely section to improve instruction cache locality. The kernel's error-handling functions are typically cold.
__attribute__((section("name"))): Places the function or variable in a named linker section. The kernel uses this extensively: __init expands to __attribute__((section(".init.text"))), meaning the function lives in the .init.text section, which the kernel frees after boot (you see 'Freeing unused kernel image memory' in dmesg). Other examples: __exit, __initdata, __read_mostly.
__attribute__((packed)): Removes padding from a struct. Important for network protocol headers, file system on-disk formats, and hardware register layouts where the struct layout must exactly match a specification. Be careful: accessing packed struct fields through pointers can cause unaligned access faults on architectures that require alignment (RISC-V without Zicsr, older ARM).
__attribute__((aligned(n))): Aligns a variable or struct to n bytes. Used for DMA buffers (must be cache-line aligned), SIMD data (must be 16/32/64-byte aligned), and lock-free data structures.
__attribute__((format(printf, m, n))): Marks a function as having printf-like format string semantics, enabling the compiler's format-string checking for your custom logging functions. The kernel uses this for printk.
/* Kernel-style attribute usage */
#include <stddef.h>
/* noreturn: compiler knows this path never returns */
void __attribute__((noreturn)) die(const char *msg) {
/* ... print msg, halt ... */
__builtin_unreachable();
}
/* cold: rarely called, keep out of hot icache */
void __attribute__((cold)) handle_oom(void) {
/* ... emergency memory reclaim ... */
}
/* section: place in .init.text, freed after boot */
void __attribute__((section(".init.text"))) setup_hw(void) {
/* ... one-time hardware initialization ... */
}
/* packed: exact wire layout for a network header */
struct __attribute__((packed)) eth_header {
unsigned char dst[6];
unsigned char src[6];
unsigned short ethertype;
}; /* sizeof = 14, no padding */
/* format: enable printf checking for our log fn */
void __attribute__((format(printf, 1, 2)))
my_log(const char *fmt, ...) {
/* ... */
}
07Link-Time Optimization (LTO)
Traditional compilation is per-translation-unit: each .c file is compiled to a .o independently. The compiler can only optimize code within a single .c file. If foo.c calls bar() from bar.c, the compiler cannot inline bar() into foo.c — it doesn't have the source at link time.
LTO changes this. With LTO, the 'object files' actually contain the compiler's Intermediate Representation (IR) rather than machine code. At link time, the linker invokes the compiler again with visibility into all translation units simultaneously, enabling cross-file optimizations:
- Cross-module inlining:
bar()can be inlined intofoo.c - Cross-module dead code elimination: functions only called from dead code are eliminated
- Cross-module constant propagation: if
foo.calways callsbar(42), the compiler can specializebarfor the value 42 - Whole-program devirtualization (C++)
Thin LTO (Clang's -flto=thin): A smarter variant that scales better for large codebases. Instead of loading all IR into one process, it creates a summary of each module's exported/imported symbols. Only the actually-needed cross-module optimizations are performed, in parallel. For a project the size of the Linux kernel, full LTO is slow; Thin LTO is practical.
The kernel and LTO: The Linux kernel added LTO support (both full LTO with GCC and Thin LTO with Clang) as part of the Control Flow Integrity (CFI) effort. CFI requires the compiler to see all code at once to enforce that function pointers are only called with the correct type. Without LTO, CFI cannot protect across translation unit boundaries. CONFIG_LTO_CLANG_THIN and CONFIG_LTO_CLANG_FULL enable this.
Trade-off: LTO increases build time significantly (the linker step now does compiler work) and increases memory consumption. It can also make debugging harder because the final code layout doesn't correspond neatly to source files. The kernel's build uses 'fat' LTO objects (containing both IR and machine code) so the same .o files can be used with or without LTO.
How to use it:
gcc -flto -O2 foo.c bar.c -o program
# Or with the thin variant (Clang):
clang -flto=thin -O2 foo.c bar.c -o program
The key is that all source files must be compiled and linked with the same -flto flag — you cannot mix LTO and non-LTO objects in a meaningful way.
/* Demonstrate LTO cross-file inlining */
/* --- utils.c --- */
int square(int x) { return x * x; }
/* --- main.c --- */
extern int square(int x);
int sum_of_squares(int a, int b) {
return square(a) + square(b);
}
/* Without LTO: square() is a real call at any -O level.
With LTO (-flto -O2): square() is inlined, then further
optimized. The call overhead disappears entirely. */
/* Build and inspect:
gcc -O2 utils.c main.c -o no_lto
gcc -O2 -flto utils.c main.c -o with_lto
objdump -d no_lto | grep -A5 'sum_of_squares'
objdump -d with_lto | grep -A5 'sum_of_squares'
Notice: with LTO, no 'call square' instruction appears */
08Compiler Explorer (Godbolt) as a Learning Tool
One of the most powerful ways to understand what your compiler actually does is to look at the assembly it generates. Compiler Explorer (godbolt.org) is a free web tool that shows you the assembly output of dozens of compilers (GCC 4.1 through trunk, Clang 3.0 through trunk, MSVC, ICC, ARM GCC, RISC-V GCC...) side by side with your C source, with color-coded correspondence between C lines and assembly instructions.
How to use it effectively:
- Write the simplest possible function that demonstrates what you want to observe. Remove
#includeandmain()if possible — just the function. - Enable 'Compile to binary' to see the actual hex bytes.
- Add
-O0in one pane and-O2in another to see the difference. - Use the 'Diff' view to highlight exactly which instructions changed.
- Try the same code on x86-64 GCC vs ARM64 GCC vs RISC-V GCC to understand architecture differences.
What to look for:
- Does your loop get vectorized? (Look for
xmm/ymm/zmmregisters on x86 orv0-v31on ARM NEON) - Is the function inlined? (Does the call site show a
callinstruction, or does the callee's code appear directly?) - Are constants folded? (Does
2 + 2appear as the literal 4, never computed at runtime?) - Is the Gaussian sum formula recognized? (Does
for(i=1;i<=n;i++) s+=iturn inton*(n+1)/2?) - Are branches eliminated? (Does the optimizer prove a branch is always taken and remove the other path?)
The Godbolt habit: For every non-obvious optimization or attribute discussed in this module, paste the code into Godbolt, flip between GCC and Clang, flip between -O0 and -O2, and read the assembly. This is how expert systems programmers develop intuition. You do not need to understand every instruction — you need to notice patterns: fewer instructions = more optimization, no call = inlined, presence of SIMD instructions = vectorized.
Reading x86-64 assembly basics: AT&T syntax (GCC default) vs Intel syntax (-masm=intel). Intel syntax is more readable: mov rax, 42 (destination first). AT&T: movq $42, %rax (source first, % prefix, size suffix). Use -masm=intel on Godbolt for readability.
/* Paste this into godbolt.org, try gcc/clang, -O0 vs -O2 */
/* Case 1: constant folding */
int constant_fold(void) {
return 6 * 7; /* -O0: mul instruction; -O1+: just 'mov eax,42' */
}
/* Case 2: dead code elimination */
int dead_code(int x) {
if (0) return x * 100; /* never taken, eliminated at any -O */
return x;
}
/* Case 3: strength reduction */
int by_8(int x) {
return x * 8; /* -O1+: 'shl eax, 3' (shift left by 3) not mul */
}
/* Case 4: does cold attr change code layout? */
void __attribute__((cold)) rarely_called(void) {}
void hot_path(int x) {
if (x < 0) rarely_called(); /* branch hint: rarely taken */
}
09How the Linux Kernel is Compiled: Kbuild
The Linux kernel does not use a normal Makefile. It uses Kbuild, a sophisticated multi-layer build system that orchestrates compilation of thousands of .c files across dozens of architectures. Understanding Kbuild is essential for kernel development.
The configuration layer (Kconfig): Before any compilation happens, you configure the kernel using make menuconfig (ncurses UI), make xconfig (Qt UI), or make defconfig (sensible defaults). The Kconfig language describes available options and their dependencies. The result is a .config file with thousands of CONFIG_* variables. These become #defines in the auto-generated include/generated/autoconf.h, which every kernel source file includes.
The build layer (Kbuild Makefiles): Each directory has a Makefile that specifies which objects to build:
# kernel/Makefile (simplified)
obj-y += fork.o exec.o exit.o # always built in
obj-$(CONFIG_POSIX_TIMERS) += posix-timers.o # built if CONFIG=y
obj-m += mydriver.o # built as a module
obj-y means 'compile into the kernel'. obj-m means 'compile as a loadable module'. obj-$(CONFIG_FOO) expands to obj-y or obj-m or nothing depending on .config.
The key compiler flags (from scripts/Makefile.build and Makefile):
-O2 # optimization level
-fno-strict-aliasing # kernel violates strict aliasing
-fno-delete-null-pointer-checks # security
-fstack-protector-strong # stack cookies
-Wall -Wundef -Wno-... # warnings
-mno-red-zone # no red zone (x86-64 kernel mode)
-mcmodel=kernel # 64-bit kernel code model
-fno-pie # no position-independent executable
vmlinux vs bzImage: The build produces vmlinux first — a raw, uncompressed ELF file containing the entire kernel. Then arch/x86/boot/ wraps it into bzImage (Big zImage), which is a self-decompressing boot image. The vmlinux is what you use with debuggers and nm/objdump.
Module compilation: Modules (obj-m) are compiled as separate .ko files. They must be compiled against the exact kernel they will load into (same version, same .config) — hence the need for kernel headers (linux-headers-$(uname -r) on Debian systems). The MODULE_LICENSE, MODULE_AUTHOR macros embed metadata that modinfo reads.
make V=1: Run with V=1 (verbose) to see the exact compiler command for every file. This is indispensable for debugging build failures and understanding which flags are applied where.
# Exploring the kernel build:
# 1. Get the kernel source
git clone --depth=1 https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
cd linux
# 2. Configure (use a minimal config for speed)
make defconfig # sensible defaults for current arch
# Or: make tinyconfig # absolute minimum
# 3. Build with verbose output to see flags
make -j$(nproc) V=1 2>&1 | head -50
# You'll see lines like:
# gcc -Wp,-MMD,kernel/.fork.o.d -nostdinc -I./arch/x86/include ...
# -O2 -fno-strict-aliasing -fno-delete-null-pointer-checks ...
# -c -o kernel/fork.o kernel/fork.c
# 4. Check what flags a specific file gets:
make kernel/fork.i # preprocessed output
make kernel/fork.s # assembly output
# 5. Build with Clang instead of GCC:
make CC=clang HOSTCC=clang -j$(nproc)
10GCC vs Clang for the Kernel: Practical Differences
Both GCC and Clang compile the Linux kernel, but they have important differences that kernel developers need to understand. As of 2024-2025, both are first-class supported compilers for the kernel.
Architecture of difference: GCC is a monolithic compiler that grew organically over decades. Clang is built on the LLVM framework — a reusable, modular compiler infrastructure. LLVM's modularity means that Clang produces LLVM IR, which can be consumed by many different backends (x86, ARM, RISC-V, WebAssembly...) and analysis tools.
Code generation quality: Neither is uniformly better. GCC tends to produce better code for some integer-heavy workloads; Clang tends to produce better code for autovectorization. For the kernel specifically, both are close in performance. The kernel has benchmarks showing neither is consistently ahead.
Build speed: Clang is generally faster at compilation. Its error messages are also generally more readable and precise.
Feature differences relevant to the kernel:
- Clang supports more sanitizers: MemorySanitizer, ShadowCallStack, CFI (Control Flow Integrity). The kernel uses Clang's CFI for
CONFIG_CFI_CLANG. __builtin_functions: Both support most builtins, but some differ. The kernel's<linux/compiler.h>and<linux/compiler-gcc.h>/<linux/compiler-clang.h>abstract these differences.- Diagnostic differences: Clang and GCC warn about slightly different things and have different false-positive profiles. A patch that compiles cleanly with GCC might warn with Clang (or vice versa). Always test with both.
- LTO differences: GCC uses GIMPLE IR for LTO; Clang uses LLVM bitcode. They are not interoperable. The kernel's
CONFIG_LTO_CLANG_THINspecifically requires Clang. - Stack usage analysis: GCC supports
-fstack-usageto generate per-function stack depth reports, useful for kernel code where stack space is precious (typically 8KB or 16KB per thread). Clang has-Wframe-larger-than=N.
The CC=clang path: To build the kernel with Clang:
make CC=clang HOSTCC=clang LD=ld.lld LLVM=1
The LLVM=1 shorthand enables the full LLVM toolchain (clang, lld, llvm-ar, etc.), which is needed for LTO and CFI. The ClangBuiltLinux project maintains patches and CI for Clang-built kernels.
Compiler version requirements: The kernel's Documentation/process/changes.rst specifies minimum versions. As of kernel 6.x: GCC >= 5.1, Clang >= 11.0. Newer kernels use C11/C17 features that require newer compilers. The kernel's Makefile checks the compiler version at build time.
A word on compiler bugs: Both GCC and Clang have bugs, and the kernel has workarounds for specific known miscompilations. Search the kernel source for __no_sanitize_address, __attribute__((optimize)) on specific functions, or comments like 'work around GCC bug #NNNNN'. This is normal at this level of software.
/* linux/include/linux/compiler_types.h — how the kernel
abstracts GCC vs Clang differences: */
/* (simplified from actual kernel header) */
#ifdef __clang__
# define __UNIQUE_ID(prefix) __COUNTER__ /* Clang has __COUNTER__ */
# define uninitialized_var(x) x = *(&(x)) /* suppress init warning */
#else /* GCC */
# define __UNIQUE_ID(prefix) \
__PASTE(__PASTE(__UNIQUE_ID_, prefix), __LINE__)
# define uninitialized_var(x) x = x
#endif
/* Both compilers support these, abstracted via macros: */
#define __always_inline inline __attribute__((always_inline))
#define __noinline __attribute__((noinline))
#define __noreturn __attribute__((noreturn))
#define __cold __attribute__((cold))
#define __pure __attribute__((pure))
/* Usage: static __always_inline int fast_fn(int x) { ... } */
11Inline Assembly and the Compiler-Barrier Interface
Sometimes C is not enough. Device driver code needs to execute specific machine instructions. Synchronization primitives need memory barriers. The kernel needs to manipulate registers the C abstract machine doesn't know about. For this, GCC and Clang provide inline assembly via the asm keyword (or __asm__ to avoid keyword conflicts).
Basic form:
asm volatile ("instruction" : outputs : inputs : clobbers);
- instruction string: The assembly text, with
%0,%1, etc., as operand placeholders. - outputs: C variables that the assembly writes to, with constraints like
"=r"(any register) or"=m"(memory). - inputs: C variables the assembly reads, with constraints like
"r"(register) or"i"(immediate). - clobbers: Registers/flags the assembly trashes, so the compiler won't assume they're preserved.
"memory"tells the compiler the assembly may read/write any memory, which is crucial for memory barriers.
volatile: Without volatile, the compiler may delete the asm block if its outputs are unused, or move it for optimization. volatile says 'this has side effects, don't touch it.'
The memory clobber and compiler barriers: One of the most important uses in the kernel:
#define barrier() asm volatile ("" ::: "memory")
This is an empty instruction string — it emits no machine code at all. But the "memory" clobber tells the compiler: 'assume all memory may have changed and all pending writes have been committed.' This is a compiler barrier — it prevents the compiler from reordering memory accesses across this point. It does NOT prevent the CPU from reordering (for that you need mb(), rmb(), wmb() which include actual memory fence instructions on architectures that need them).
Real kernel example — READ_ONCE and WRITE_ONCE: These macros in <linux/compiler.h> prevent the compiler from 'tearing' a read or write, and from caching a value in a register when it must be re-read from memory. They use a combination of volatile-typed pointer casts and compiler barriers. This is the foundation of the kernel's lock-free data structure guarantees.
Constraint characters: r=register, m=memory, i=immediate integer, ==write-only output, +=read-write, &=early-clobber (written before inputs are consumed). Learning constraints is a small art form; the GCC Inline Assembly HOWTO (ibiblio.org) and the GCC manual are the canonical references.
/* Inline assembly examples from kernel patterns */
/* 1. Read a CPU register (x86: read RFLAGS) */
static inline unsigned long read_flags(void) {
unsigned long flags;
asm volatile ("pushfq; popq %0"
: "=r"(flags) /* output: flags <- any reg */
: /* no inputs */
: "memory"); /* clobbers memory (flag read) */
return flags;
}
/* 2. Compiler barrier — prevents compiler reordering */
#define barrier() asm volatile ("" ::: "memory")
/* 3. CPU pause instruction for spin-wait loops (x86) */
static inline void cpu_relax(void) {
asm volatile ("rep; nop" ::: "memory");
/* On modern x86, REP NOP = PAUSE, hints spin loop to CPU */
}
/* 4. Atomic add (x86, simplified kernel pattern) */
static inline void atomic_add(int i, int *v) {
asm volatile ("lock; addl %1, %0"
: "+m"(*v) /* read-write memory operand */
: "ir"(i) /* input: immediate or register */
: "memory");
}
12Compiler Intrinsics and __builtin_ Functions
Below attributes and above inline assembly sits a middle ground: compiler intrinsics and __builtin_ functions. These look like normal function calls but are compiled directly into specific machine instructions or optimized patterns. They are safer and more portable than raw inline assembly.
Arithmetic and bit manipulation:
-
__builtin_clz(x): Count Leading Zeros. Compiles toBSR/LZCNTon x86,CLZon ARM. Used everywhere in the kernel forilog2(), power-of-two checks, buddy allocator. -
__builtin_ctz(x): Count Trailing Zeros. Compiles toBSF/TZCNTon x86. -
__builtin_popcount(x): Population count (number of set bits). Compiles toPOPCNTon x86 with SSE4.2. Used in bitmap operations. -
__builtin_expect(expr, expected): Branch prediction hint.if (__builtin_expect(condition, 0))tells the compiler the condition is rarely true. The kernel wraps this aslikely()andunlikely()— one of the most frequently seen macros in kernel source:#define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0)These affect code layout: the 'likely' path gets the fall-through branch (no branch instruction), the 'unlikely' path gets a taken branch (slower on modern CPUs that assume not-taken by default).
-
__builtin_unreachable(): Tells the compiler that this point is never reached. Enables dead code elimination after the call. Used afterswitchstatements covering all cases, afternoreturnfunction calls, and inBUG()/panic()paths in the kernel. -
__builtin_offsetof(type, member): Returns the byte offset of a struct member. This is also available as the standardoffsetof()macro from<stddef.h>, but the kernel often uses the builtin directly. -
__builtin_constant_p(x): Returns 1 ifxis a compile-time constant. Used for macros that have a fast constant path and a general runtime path:#define MY_MACRO(x) (__builtin_constant_p(x) ? fast_const(x) : slow_runtime(x))
SIMD intrinsics: For vectorization-critical code, both GCC and Clang provide Intel intrinsics (<immintrin.h>): _mm256_add_epi32(), _mm_loadu_si128(), etc. These map 1:1 to specific SSE/AVX instructions. The kernel uses these sparingly (mostly in crypto and raid code) because they require saving/restoring the FPU state (kernel_fpu_begin()/kernel_fpu_end()).
/* __builtin_ examples */
#include <stdio.h>
int main(void) {
/* clz: how many bits until the first 1 from the top? */
printf("clz(1) = %d\n", __builtin_clz(1)); /* 31 */
printf("clz(8) = %d\n", __builtin_clz(8)); /* 28 */
printf("clz(256) = %d\n", __builtin_clz(256)); /* 23 */
/* ilog2 using clz (kernel's actual implementation): */
/* ilog2(n) = 31 - __builtin_clz(n) for 32-bit n>0 */
int n = 64;
int log2_n = 31 - __builtin_clz(n);
printf("ilog2(%d) = %d\n", n, log2_n); /* 6 */
/* likely/unlikely pattern: */
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
int error_code = 0; /* almost always 0 */
if (unlikely(error_code)) {
/* This branch is laid out 'far away' in memory */
/* The fast path (error_code==0) has no branch */
}
return 0;
}
Key insights
- ✦Higher optimization levels are not 'safer' or 'more correct' — they expose latent undefined behavior that lower levels accidentally mask. Fix the UB, don't hide at -O0.
- ✦The Linux kernel compiles at -O2 with -fno-strict-aliasing and -fno-delete-null-pointer-checks because the kernel intentionally uses patterns that standard C prohibits, and these flags prevent the optimizer from miscompiling them.
- ✦Compiler warnings are a static test suite. The kernel's use of -Werror on critical warning classes means a new warning is a build failure, not a suggestion. This discipline catches real bugs.
- ✦Sanitizers (ASan, UBSan, TSan) are not just debug tools — they are specification enforcement: they make the C standard's undefined behavior rules into runtime failures, so you can find and fix UB before it becomes a security vulnerability.
- ✦GCC attributes like __init, __cold, __noreturn, and __section are not cosmetic annotations — they directly control code generation, section placement, and linker behavior. In the kernel, __init frees megabytes of memory after boot.
- ✦LTO moves optimization from compile time to link time, giving the compiler a whole-program view. The kernel needs this for CFI (Control Flow Integrity), not just performance.
- ✦The likely() and unlikely() macros are not hints to the programmer — they are machine-code-level branch layout directives that affect CPU branch predictor behavior in hot paths.
- ✦Both GCC and Clang are first-class kernel compilers with different strengths. GCC is the historical default; Clang enables CFI, better sanitizers, and Thin LTO. Always verify your patches compile cleanly with both.
Common pitfalls
- ⚠Assuming -Wall means 'all warnings'. It does not — it is a curated subset. Use -Wall -Wextra -Wformat=2 at minimum, and study the kernel's own warning flags in scripts/Makefile.extrawarn.
- ⚠Mixing -fsanitize=address and -fsanitize=thread in the same binary. They are mutually exclusive — their shadow memory schemes conflict. Use them in separate test runs.
- ⚠Forgetting -g when using sanitizers. ASan and UBSan error messages include file and line numbers only when debug info is present. Without -g, you get an address but no useful location.
- ⚠Using __attribute__((packed)) without understanding alignment. On ARM and RISC-V, unaligned memory accesses either fault or are silently slow depending on the CPU configuration. Packed struct fields accessed through pointers can trigger this.
- ⚠Believing that __builtin_expect / likely() / unlikely() work from profiling data alone. They are programmer-supplied hints. Get them wrong (annotate a hot path as unlikely) and you actively harm performance. Profile first, annotate second.
- ⚠Forgetting that LTO requires all objects to be compiled with the same -flto flag. Mixing LTO and non-LTO objects in the same link produces silent failures or linker errors depending on the version.
- ⚠Using asm without volatile when the assembly has side effects. Without volatile, the compiler may delete or deduplicate the asm block if it decides the output is unused. Almost all kernel asm blocks should be volatile.
- ⚠Not testing with both GCC and Clang. Code that compiles cleanly with one may produce warnings or errors with the other, and the kernel's CI will catch this. Run make CC=clang before submitting patches.
Practice
- 01medium
Write a C program with at least five different types of undefined behavior (signed overflow, null dereference, out-of-bounds access, use-after-free, uninitialized read). Compile it three ways: (a) gcc -O0, (b) gcc -O2, (c) gcc -O2 -fsanitize=address,undefined. Run all three. Document exactly what happens in each case and explain WHY -O2 may behave differently from -O0 for the UB cases.
- 02easy
Use Compiler Explorer (godbolt.org) to investigate how GCC and Clang handle a binary search function. Compile the same function with -O0, -O1, -O2, -O3 using both GCC and Clang targeting x86-64. Count the number of instructions at each optimization level. Write a one-paragraph analysis of the most interesting optimization you observe (e.g., branch elimination, loop unrolling, CMOV instead of conditional branch).
- 03hard
Build a minimal loadable kernel module that: (1) uses __init and __exit attributes correctly, (2) uses pr_info() with __attribute__((format(printf,1,2))) on a wrapper function, (3) has one __cold error path. Build it with both GCC and Clang (CC=clang). Use 'dmesg' to verify the module loads and unloads. Inspect the .ko with 'readelf -S mymodule.ko' and find the .init.text section.
- 04medium
Write a C function that sums an array of 1024 integers. Compile with: (a) -O2, (b) -O2 -march=native, (c) -O2 -march=native -fopt-info-vec (GCC) or -Rpass=loop-vectorize (Clang). Measure runtime using clock_gettime() over 10 million iterations. Report which version vectorized, what SIMD width was used (128/256/512-bit), and the measured speedup.
- 05hard
Find a real bug using sanitizers: write a ring buffer implementation with push/pop operations. Deliberately introduce one off-by-one error in the index wrapping. Use ThreadSanitizer to find a data race if the buffer is used from two threads without a lock, and AddressSanitizer to find the buffer overflow. Fix both bugs and re-run to confirm clean output.
- 06hard
Clone the Linux kernel source. Run `make defconfig` then `make kernel/fork.i` to get the preprocessed output of fork.c. Count how many lines it expands to. Then run `make kernel/fork.s` to get the assembly. Find the assembly for the `copy_process` function and identify: which calling convention is used (how are arguments passed), whether the function uses a frame pointer, and what the largest stack frame size is. Use `make V=1` to find the exact compiler flags used.
Resources
The single most useful tool for understanding what your compiler actually generates — supports GCC, Clang, MSVC, and dozens of architectures side by side.
The authoritative reference for every GCC flag. The 'Warning Options' and 'Optimization Options' sections are essential reading.
The canonical tutorial for GCC inline assembly constraints — the part of inline asm that most people get wrong.
Official documentation for the kernel build system, including kbuild Makefiles, modules, and the Kconfig language.
Clang's user manual covers all flags, sanitizers, and Clang-specific attributes. Essential when comparing with GCC behavior.
The original ASan paper by Serebryany et al. — explains the shadow memory design in detail. Reading it makes ASan's behavior feel obvious rather than magical.
LWN article on how the kernel uses GCC plugins for security hardening (STACKLEAK, LATENT_ENTROPY) — shows how deep the compiler-kernel relationship goes.
The gold standard for understanding what compiled code actually is — Chapter 3 teaches x86-64 assembly in the context of C, which is exactly what you need.
The project maintaining Clang/LLVM support for building the Linux kernel — tracks known issues, workarounds, and CI status across kernel versions.
Bootlin's kernel training materials are free and cover Kbuild in practical depth, including hands-on labs on actual embedded hardware.
Checkpoints — you can now…
- ✓You can explain what happens at each of the four compilation stages and produce the intermediate output (.i, .s, .o) for any C file using the right GCC flags.
- ✓You can predict which optimization level will be applied in a Linux kernel build and explain why -fno-strict-aliasing and -fno-delete-null-pointer-checks are kernel-mandatory flags.
- ✓You can read a kernel source file that uses __init, __cold, __noreturn, and likely()/unlikely() and explain precisely what each annotation causes the compiler or linker to do.
- ✓You can write a C program, compile it with -fsanitize=address,undefined, and correctly interpret the sanitizer output to identify and fix the underlying bug.
- ✓You can use Compiler Explorer to compare GCC vs Clang assembly output and identify whether a loop was vectorized, a function was inlined, or a branch was converted to a conditional move.
- ✓You can build the Linux kernel from source with both GCC and Clang (CC=clang LLVM=1) and use make V=1 to inspect the exact compiler flags applied to a specific source file.
- ✓You can write a valid GCC inline assembly statement with correct input/output constraints and a memory clobber, and explain why the memory clobber is required for a compiler barrier.
- ✓You can explain what LTO is, why the Linux kernel uses Thin LTO specifically for CFI support, and what CONFIG_LTO_CLANG_THIN enables.