JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

The C Abstract Machine and the As-If Rule

Your C program does not describe what the CPU will do; it describes what an imaginary, idealized computer would do. Once you see that the standard talks about this abstract machine — and that the compiler may do anything at all as long as the result looks the same — undefined behavior stops being mysterious and becomes the exact crack where that promise is allowed to break.

What does your C program actually mean?

You have spent this whole ladder learning what really happens underneath: source becomes machine code, the loader drops it into memory, the CPU fetches and obeys one tiny instruction at a time, and the stack and heap shuffle bytes around as your program runs. It is tempting, now, to think of C as a thin, honest label for those instructions — that `x + 1` simply is an add instruction. But that is not what the language promises, and this rung is built on understanding the gap. The C standard does not describe your processor at all. It describes an imaginary computer.

That imaginary computer has a name: the C abstract machine. When the language standard — the document, first ratified in 1989 as C89, that defines what C means — tells you what a piece of code does, it is describing the behavior of this abstract machine, not of any silicon. The abstract machine has variables with values, an order in which side effects happen, rules for what arithmetic produces, and so on. Your real program is correct if and only if it would behave a certain way on this idealized machine. The compiler's job is then narrower than you might think: produce a real executable that, when run, gives the same observable results the abstract machine would have given.

The as-if rule: the contract that frees the optimizer

The principle has a name worth knowing: the as-if rule. The standard says, roughly, that an implementation may transform a program however it pleases, as if it had faithfully executed each step of the abstract machine — provided the program's observable behavior is preserved. "Observable" is a precise list: the input/output your program performs (every read() and write(), every printf()), and the final state of certain volatile objects. Everything else — the exact registers used, the order of independent computations, whether a value lives in memory or only ever in a register — is the compiler's private business, invisible to a conforming program.

A tiny example makes the freedom vivid. Suppose you write `int x = 2 + 3; int y = x * 10; printf("%d\n", y);`. The abstract machine computes x = 5, then y = 50, then prints 50. But the as-if rule lets the compiler skip all of that: it can compute 50 at compile time and emit code that just prints the constant. There is no x, no y, no multiply in the final binary — yet the only observable thing, the printed 50, is identical. The program behaved as if it had run the abstract machine, which is all the standard ever asked.

you wrote (the abstract machine's steps):
    int x = 2 + 3;     // x <- 5
    int y = x * 10;    // y <- 50
    printf("%d\n", y); // observable: prints 50

the compiler may emit (as-if, same observable result):
    mov   edi, 50      ; the 50 was folded at compile time
    call  printf       ; x and y never exist at run time

the contract: only the printed 50 (an output) is observable;
everything else is the compiler's to rearrange or delete.
The as-if rule in one picture: the source names five operations, the binary keeps only the one observable output, and both are equally correct.

This is the engine behind every optimization level. When you compile with `gcc -O2 -Wall main.c`, you are handing the compiler permission to exploit the as-if rule aggressively — folding constants, deleting unused variables, reordering independent work, keeping values in registers, inlining whole functions. None of that is allowed to change what your program observably does. So far this sounds purely benevolent, and at -O0 versus -O2 most code does behave identically. The trouble begins the moment your program leaves the abstract machine's rules — because then the compiler's promise was conditional, and you just broke the condition.

Three ways the abstract machine can be vague

The abstract machine does not pin down every detail. The standard deliberately leaves some things loose, and it is crucial to separate the kinds of looseness, because they carry wildly different consequences. There are three, and the entire safety story of C lives in the difference between them. The next guide in this rung dives into the most dangerous one; here we lay out all three side by side so you never confuse them again.

First, implementation-defined behavior: the standard demands a choice be made and documented, but lets each implementation pick. How many bits are in an `int`, whether a plain `char` is signed or unsigned, the result of certain conversions — these are real, stable answers your compiler will tell you in its manual, just not the same answer everywhere. Second, unspecified behavior: the standard offers a menu of allowed outcomes and the implementation may pick any of them, without having to document or even be consistent. The classic case is the order in which function arguments are evaluated in `f(a(), b())` — a() may run first or b() may, and you must not rely on either. These two are the realm of unspecified and implementation-defined behavior, and both are survivable: a portable program simply avoids depending on them.

Third, and on a different planet entirely: undefined behavior, usually written UB. Here the standard does not offer a menu of outcomes — it withdraws all requirements. If your program executes an action the standard labels undefined, the abstract machine has, from that point, no defined behavior at all, and the implementation is permitted to do literally anything. Not "one of several things": anything. The phrase the standard uses is that it "imposes no requirements." That is the precise topic this rung exists to teach, and the rest of this guide shows why that single rule is so much sharper and more dangerous than it first sounds.

Why UB lets the compiler delete and reorder your code

Here is the step that surprises everyone, and it follows from the as-if rule with brutal logic. The optimizer is allowed to assume that undefined behavior never happens. It does not insert checks for it; it does not handle it gracefully; it simply takes for granted that you, the programmer, never wrote a program that triggers UB — because the standard says a correct program does not. So the compiler reasons forward from that assumption, and that reasoning can reach back and rewrite code that looks completely unrelated.

Take a famous shape. Suppose somewhere you write `int *p = ...; int v = *p; if (p == NULL) handle_null();`. By dereferencing p in the line `int v = *p;`, you have promised the compiler that p is not null — because dereferencing a null pointer is undefined, and the compiler assumes UB never occurs. So it concludes p cannot be null on the next line either, and it is fully entitled to delete the `if (p == NULL)` check as dead code, because under its assumption that branch can never be taken. Your null check vanishes. The program that was supposed to defend itself now sails straight into the crash it was guarding against — and the compiler did nothing wrong by the standard.

Now you can finally explain a phenomenon that baffles every beginner: a bug that behaves at -O0 and corrupts at -O2. At -O0 the compiler optimizes almost nothing, so your buggy code often happens to do something innocuous — the deleted check is still there, the stale value is still in memory, the program limps along. Turn the optimizer up and the same code, now reasoned about under the as-if rule, gets transformed in ways your UB made "legal," and the latent bug detonates. The code did not change; the compiler's permission to exploit your broken promise did. The bug was always there. -O2 just stopped hiding it.

Reading the abstract machine: sequence points and order

One last piece of the abstract machine repays a close look, because it generates a whole family of UB you will meet in the next guides: the order in which side effects happen. On the abstract machine, the moments where everything is guaranteed to have settled are called sequence points — for example, the end of a full statement at its semicolon, or the comma in a comma operator. Between two sequence points the implementation may interleave the sub-computations however it likes, by the as-if rule. That freedom is harmless until two of those sub-computations touch the same object.

The canonical trap is `i = i++ + 1;` or `a[i] = i++;`. Here you modify i and also read or modify it again with no sequence point separating the two accesses, and the standard declares the whole thing undefined — not unspecified, not "i ends up one of two values," but full UB. People expect a definite-but-platform-specific result; what they actually have is a program with no defined meaning at all, which the optimizer may treat as license to do anything. The honest rule is blunt: never read and write the same object twice between sequence points, and never write a single expression whose result depends on argument evaluation order.

Notice how clean the whole picture has become. The abstract machine tells you the meaning; the as-if rule tells you the compiler may use any means to deliver that meaning; implementation-defined and unspecified behavior are the bounded vagueness you can program around; and UB is the cliff edge where the meaning simply stops, after which the as-if rule no longer protects you because there is nothing left to be faithful to. Every guide that follows in this rung is a tour of specific cliff edges — overflow, out-of-bounds access, uninitialized reads, buffer overruns — and the secure-coding habits that keep you back from them. You now hold the frame they all hang on.