· 6 min read ·

Clang Does What You Said, Not What You Meant

Source: lobsters

There is a category of compiler bug reports that are not actually bugs. The programmer writes code, the compiled output behaves in a way that seems clearly wrong, and the bug tracker gets a new ticket. Then someone closes it with a comment referencing the relevant section of the C standard, and the programmer walks away frustrated. This pattern is old, but it keeps happening because the intuition that causes it is persistent and reasonable: most developers think of the compiler as a sophisticated assembler, turning C into machine code in an obvious, mechanical way. Clang and GCC have not been that for a long time.

A recent article on xania.org captures the feeling well. The compiler does something surprising, it turns out to be technically correct, and the only honest response is to update your mental model. This post tries to do that more systematically: to explain what the C abstract machine actually is, what it permits the compiler to assume, and why those assumptions produce behavior that looks like sabotage.

The Abstract Machine and the Real One

The C standard does not specify a particular machine. It specifies an abstract machine with particular rules about program behavior. Your compiler’s job is to produce output on real hardware that matches what the abstract machine would do, but only for programs that follow the rules. Programs that violate the rules, those that invoke undefined behavior, are outside the contract entirely. The standard makes no promise about what happens; the compiler is free to assume they never occur.

This is not a loophole or an oversight. It is load-bearing. If compilers could not assume that undefined behavior never occurs, they would have to be conservative about almost every optimization. The performance of every C and C++ program you have ever run depends on this contract.

The problem is that undefined behavior is surprisingly common and surprisingly invisible. The canonical example is signed integer overflow.

int increment_and_check(int x) {
    return x + 1 > x;
}

On most hardware, x + 1 wraps around when x == INT_MAX, so a naive reading says this should return 0 for that input. Clang compiles it to return 1 unconditionally. That is not a bug. Signed integer overflow is undefined behavior in C, so the compiler is permitted, by the standard, to assume it never occurs. If overflow never occurs, x + 1 > x is always true, and the function collapses to a constant. This is correct behavior for a conforming program. The shock comes from the assumption that the hardware’s behavior and the standard’s behavior are the same thing; they are not.

You can turn this off. Compiling with -fwrapv tells Clang to treat signed overflow as two’s complement wraparound, the way most programmers expect. The Clang documentation on -fwrapv is explicit about this tradeoff: you get predictable overflow at the cost of optimization opportunities. The Linux kernel has compiled with -fwrapv for years for exactly this reason.

Null Pointer Elimination

Another class of surprises comes from what the compiler can infer when you dereference a pointer. In C, dereferencing a null pointer is undefined behavior. So if you dereference a pointer and the program is well-formed, the compiler can conclude the pointer was not null at that point. This sounds benign. It is not.

void process(int *p) {
    int val = *p;  // dereference here
    if (p == NULL) {
        handle_null();
    }
    use(val);
}

Clang, at optimization level -O2, will eliminate the null check entirely. The reasoning is valid: you just dereferenced p, which is UB if p is null, so the compiler assumes p is not null, and the check is dead code. The branch and the call to handle_null disappear from the binary. If you were using that null check as a defensive fallback after an uncertain dereference, it is silently gone.

This is not hypothetical. The Linux kernel hit exactly this pattern with GCC over a decade ago. Code that had worked defensively for years started silently dropping safety checks as compilers got better at this inference. The fix was to restructure the code so the null check happens before any dereference, which is correct anyway but requires you to understand why the original was broken.

Strict Aliasing

The strict aliasing rule is perhaps the most reliably surprising piece of the C standard for systems programmers. It says that objects of different types cannot alias each other, with a short list of exceptions. The compiler uses this to avoid memory loads it would otherwise need for correctness.

The classic example is type-punning through pointer casts:

float float_bits(uint32_t x) {
    float *fp = (float *)&x;
    return *fp;
}

This is undefined behavior under strict aliasing. The compiler is allowed to assume that float * and uint32_t * point to different objects, which means it may reorder or eliminate reads and writes based on that assumption. In practice this can produce code that reads stale values from registers instead of updated values in memory, because the optimizer decided the store to x could not affect what fp reads.

The correct way to do this in C99 and later is memcpy, which is defined to work and which compilers know to optimize into a register move:

float float_bits(uint32_t x) {
    float result;
    memcpy(&result, &x, sizeof(result));
    return result;
}

GCC and Clang both provide -fno-strict-aliasing if you need to type-pun through pointers. The -O2 preset does not enable strict-aliasing-based optimizations on GCC by default, but -O3 does, which is a historical source of confusion. Clang enables it at -O2.

Catching It Before It Bites You

The right tool for finding undefined behavior before the optimizer exploits it is UBSan, Clang’s undefined behavior sanitizer. Compiling with -fsanitize=undefined instruments the binary to trap at runtime when UB occurs, before any optimization has had a chance to make the symptoms misleading.

clang -fsanitize=undefined -g -O1 your_program.c

The -O1 is deliberate. At -O0 the sanitizer catches less because less optimization has occurred and some UB only manifests under the optimizer’s assumptions. At -O2 and above, the optimizer may have already removed the code path that would have triggered the sanitizer. -O1 is a reasonable middle ground for finding latent issues.

For production security work, ASan catches a broader class of memory errors and is the more commonly used sanitizer in CI pipelines. Running both during testing is not excessive.

Why It Keeps Surprising People

The mental model most C programmers carry was formed on old compilers and simple codebases. GCC 2.x, the compiler many systems programmers learned on, did not perform aggressive UB-based optimizations. The current behavior is the result of decades of work on LLVM and GCC’s optimization infrastructure, work that made programs faster at the cost of making the gap between the abstract machine and programmer intent more consequential.

The frustrating part is that the compiler is not adversarial. It is strictly following a contract that was always there. The contract says: write conforming programs and we will produce correct output. Undefined behavior means undefined output, and the optimizer interprets that freedom aggressively because doing so produces faster code for the programs that actually conform.

The solution is not to distrust the compiler. It is to understand what the rules actually are, use sanitizers to catch violations early, and reach for flags like -fwrapv and -fno-strict-aliasing when you genuinely need the hardware semantics instead of the abstract-machine semantics. The compiler is doing exactly what you told it to do. The work is making sure you said what you meant.

Was this interesting?