The Compiler Deleted My Code and It Was Right To

There’s a moment every systems programmer hits eventually. You stare at the disassembly of a function you wrote, and an entire if block is just… gone. Not compiled to a nop. Not short-circuited. Gone. The compiler ate it.

And when you finally understand why, you don’t feel relief. You feel something closer to dread.


The check that wasn’t

Here’s a pattern I’ve seen in real codebases — not toy examples, not contrived CTF challenges. Production code. Security-critical code.

char *buf = get_user_input();
char *end = buf + len;

if (end < buf) {
    // overflow detected
    return -EINVAL;
}

This is an overflow check. If buf + len wraps around and end ends up before buf in memory, something has gone wrong. The programmer is being careful.

The compiler deletes the entire if block.

Not sometimes. Not with specific flags. With -O2, which is what virtually every serious C project compiles with. GCC and Clang both. Silently.

Why the compiler is technically correct

Pointer arithmetic on char * that overflows past the bounds of the object it points to is undefined behavior in C. The standard says so. §6.5.6 ¶8 if you want to look it up, but the important part isn’t the section number — it’s the consequence.

When the compiler sees end = buf + len, it is allowed to assume that buf + len does not overflow. Because if it did, the behavior is undefined, and undefined behavior means the compiler can do anything. Including pretending the overflow is impossible.

If the overflow is impossible, then end < buf is always false. If it’s always false, the branch is dead code. Dead code gets eliminated.

Your security check just became a no-op because the compiler proved — within the rules of the abstract machine — that the dangerous condition you’re checking for cannot happen.

The compiler didn’t misunderstand your intent. It understood the language better than you did.

The abstract machine is not your machine

This is where most explanations stop. “Undefined behavior, bad, don’t do it.” But that misses what’s actually interesting.

The C standard defines an abstract machine. This machine has properties that no real hardware has:

  • Pointers don’t have numeric values you can reason about
  • Signed overflow doesn’t wrap
  • Reading uninitialized memory doesn’t give you garbage — it gives you nothing, the program has no defined state anymore
  • Two pointers to different objects cannot be meaningfully compared

Your x86 box disagrees with all of this. On your machine, pointers are integers. Signed overflow does wrap (two’s complement, always). Uninitialized memory does have some value — whatever was there before.

The compiler’s job is to translate from the abstract machine to your real machine. And here’s the thing that takes time to internalize:

The compiler is not translating your code. It’s translating the abstract machine’s behavior. Your code is just a description of that behavior — and where your code and the abstract machine disagree, the abstract machine wins.

 Your mental model          The C standard          What gets compiled
┌───────────────┐      ┌──────────────────┐      ┌──────────────────┐
│ pointers are  │      │ pointers are     │      │ the compiler     │
│ integers,     │ ───► │ abstract tokens, │ ───► │ uses whichever   │
│ memory is     │      │ UB means anything│      │ interpretation   │
│ flat, overflow│      │ can happen       │      │ produces faster  │
│ wraps         │      │                  │      │ code             │
└───────────────┘      └──────────────────┘      └──────────────────┘

The compiler doesn’t pick the interpretation that matches your hardware. It picks the one that lets it emit fewer instructions.

The kernel lives in the gap

This is what makes kernel programming a different discipline from application programming.

The Linux kernel needs to compare pointers from different objects. It needs to do integer arithmetic on pointer values. It needs signed overflow to wrap. The kernel operates on the real machine, not the abstract one.

And so the kernel fights the compiler.

// From the Linux kernel: include/linux/compiler.h (simplified)
#define __must_check  __attribute__((warn_unused_result))

That’s tame. Here’s where it gets interesting:

// The kernel's way of doing pointer arithmetic without UB
#define __ACCESS_ONCE(x) ({ \
    __maybe_unused typeof(x) __var = (__force typeof(x)) 0; \
    (volatile typeof(x) *)&(x); })

The kernel casts pointers to uintptr_t for arithmetic. It uses volatile to prevent the compiler from reasoning about memory access patterns. It passes -fno-strict-aliasing because the kernel knows it’s violating aliasing rules and doesn’t want the compiler to “optimize” based on rules the kernel deliberately breaks.

-fwrapv tells GCC to treat signed integer overflow as two’s complement wrapping instead of undefined behavior. The kernel needs this. Your application might need this too, and you might not know it.

The kernel doesn’t just use C. It uses a specific dialect of C that is defined by particular compiler flags, GNU extensions, and a gentleman’s agreement about what the compiler is and isn’t allowed to assume.

It gets worse: time travel

The C standard allows undefined behavior to have retroactive effects. This isn’t a theoretical concern.

int table[4];
int exists_in_table(int v) {
    for (int i = 0; i <= 4; i++) {
        if (table[i] == v) return 1;
    }
    return 0;
}

The bug: i <= 4 should be i < 4. On the last iteration, table[4] is an out-of-bounds access — undefined behavior.

What the compiler sees: “On the path where i == 4, undefined behavior occurs. Therefore I can assume i never reaches 4. Therefore I can assume the loop either finds v and returns 1, or continues forever. The return 0 is unreachable.”

A sufficiently aggressive optimizer can compile this function to return 1.

For any input.

The undefined behavior on one path infected the other paths. The compiler reasoned backward from the UB to conclude that certain program states are impossible, and then optimized the possible states.

This is what people mean when they say UB enables “time travel.” The compiler’s reasoning about future UB changes what it generates for code that executes before the UB would have occurred.

Undefined behavior is not “what happens when something goes wrong.” It’s the compiler’s license to rewrite your program’s past.

The sanitizers knew all along

Here’s what changed my relationship with this: tooling.

gcc -fsanitize=undefined -O2 overflow_check.c

UBSan catches the pointer overflow at runtime. It tells you exactly where the UB is, exactly what happened, and it does it before the optimizer has a chance to delete evidence.

The workflow that actually works:

  1. Compile with -fsanitize=undefined,address during development
  2. Run your tests, run your fuzzer
  3. Watch UBSan catch things you didn’t know were wrong
  4. Fix the UB, not the symptom

The trap most people fall into is looking at the output of an optimized build and trying to understand why the behavior is wrong. By that point, the compiler has already rewritten your program. You’re debugging the compiler’s interpretation, not your code.

What the optimizer actually sees

I want to make one more thing concrete. When the compiler encounters:

int x = *p;
if (!p) return;

It reasons: “p was dereferenced on line 1. If p were null, that’s UB. I can assume UB doesn’t happen. Therefore p is not null. Therefore the if (!p) check is always false.”

The null check is deleted. After the dereference.

This is not a bug in the compiler. Both GCC and Clang do this intentionally. It falls directly out of the optimization framework: assume UB doesn’t happen, propagate that assumption, eliminate dead branches.

If your codebase has a pattern where you dereference first and check later — even if those two things are in different functions that got inlined together — the compiler may silently delete your null check.

![conceptual diagram: the compiler’s assumption propagation through inlined function boundaries, showing how a dereference in function A eliminates a null check in function B after inlining]

The mental model that stuck

After enough time with this, here’s the framing that finally made everything click:

C is not a language where you tell the computer what to do. It’s a language where you make promises to the compiler about what will never happen. Every time you write a pointer dereference, you’re promising it’s not null. Every time you do arithmetic, you’re promising it won’t overflow. Every time you access an array, you’re promising you’re in bounds.

The compiler collects these promises and uses them against you. Every promise it can extract is an optimization opportunity. And when you break a promise, the compiler doesn’t catch you — it was never checking. It was trusting you, and optimizing based on that trust.

Kernel developers understand this. That’s why the kernel builds with a specific set of flags that narrow what the compiler can assume. They draw a smaller circle of trust.

The rest of us are out here making promises we don’t know we’re making.


Something I think about: the C abstract machine was designed in an era when compilers were simple translators. The language assumed the programmer knew more than the compiler. Modern optimizers have flipped that assumption entirely — and the spec never caught up.

Subscribe to My Newsletter

Daily technical content, project updates, and automation tips straight to your inbox.