Skip to content
Mirror-C HelpA notional machine for C
05 / Semantics

The picture is the machine, not a diagram of one

Every Step advances a real abstract machine — an extension of the substitution-free SF-CESK family, with an extra component for output. What the Stack, Heap and Output regions draw is a rendering function applied to that machine's own configuration, and nothing else feeds the picture.

The configuration

A configuration is a seven-part tuple: ⟨C, ρ, σ, κe, κs, F, out⟩.

PartHolds
CControl — whatever is currently being evaluated or executed.
ρThe environment, mapping a name to the address holding it.
σThe store: address to cell. Its domain only grows — addresses are never reused (below).
κeThe expression continuation — what is still owed to finish evaluating the expression in focus.
κsThe statement continuation — the statements still to run once the current one finishes.
FThe frame stack: one entry per function call still live, innermost last.
outEverything printed so far. Part of the configuration, not a side channel — see below.

A step is a function Conf → (Conf, Event[]): one configuration in, one configuration and a list of events out. “Small” is meant literally — evaluating x + 1 is several steps, not one, because each operand's read lands in κe as its own transition before the addition itself has both values it needs.

One rule, one row

Every transition rule the machine can fire has exactly one row in a fixed event table, and every event that row can emit changes the picture in some observable way — there is no event that fires and draws nothing. The rule's name and the block it highlights are set inside the transition itself, not computed afterwards by inspecting the resulting value, which is what keeps the sentence above the machine regions and the highlighted block from ever drifting out of sync with what actually happened. That sentence is the fastest way to answer “what did the last Step actually do” without reading the picture cell by cell.

Output is state

printf's bytes live in out, the seventh part of the configuration — they are not a log kept beside the machine, they are a component of it. That is why pressing Back genuinely un-prints text from the Output region with no special-case code of its own: the region renders the current configuration, stepping backward produces an earlier configuration, and an earlier configuration simply has a shorter out.

Cells die but never disappear

The store's domain only grows. When a function returns, its frame's cells are marked dead rather than removed, and a dead cell is drawn greyed rather than erased. A pointer that still targets one keeps visibly pointing at something dead instead of silently becoming valid again the way a real allocator's address reuse would let it.

The landing example makes this concrete:

int *f(void) { int x = 1; return &x; }
int main(void) { int *p = f(); int y = *p; return y; }

Thirteen steps in, Mirror-C stops:

Stuck — Use After Return (E-DerefPopped)

and the picture shows exactly why: f's frame is still drawn, greyed, with x greyed inside it, because p still points there — two arrows converging on a dead block. gcc -O0 and clang -O0 compile the same program without a word and return 1.

The Mirror-C machine stopped with a red banner reading Stuck: UseAfterReturn. The status line reads step 13, Stuck — Use After Return (E-DerefPopped). The Stack region shows main's frame, with f's greyed-out frame still drawn beside it and an arrow from p landing on the dead cell.
Fig. 1 The landing example, stopped. f's frame stays drawn and greyed after it returns, and p's arrow still lands on it.

Twelve reasons, and the list is closed

Every stuck configuration carries a named Reason, and the set of names is fixed at twelve. That closure is a property the project has actually had to defend, not just assert: adding structs, enums, a second array extent, or union/const each turned out to need zero new reasons — every new way a program can go wrong collapses into a Reason the set already had.

ReasonFires when…
NullDerefa null pointer is dereferenced, indexed, or followed through ->.
UseAfterFreea pointer into a freed heap cell is read or written through.
UseAfterReturna pointer into a stack frame that has already returned is read or written through.
FreeNonHeapfree is called on something that was never returned by malloc.
DoubleFreefree is called twice on the same allocation.
FreeIndeterminatefree is called on a pointer whose own value was never written.
IndeterminateReada cell is read that was declared but never assigned — or a union member that is not the one last written.
OutOfBoundsan array read or write's index falls outside its declared extent, on either dimension of a two-extent array.
DivByZeroa division or modulo has a zero right-hand side.
FellOffNonVoida non-void function's body runs out of statements without reaching a return.
BreakOutsideLoopa break is reached with no enclosing loop.
ContinueOutsideLoopa continue is reached with no enclosing loop.

A stuck state is not the same thing as an ill-typed program. Anything a real compiler would reject before running — a non-integer array index, for instance — is refused at the block editor and never reaches a Reason at all; the twelve names above are exclusively for programs that build cleanly and then meet genuine undefined behaviour while running.

Why it matters

Twelve programs, one per reachable stuck reason, live in the project's divergence corpus, each recording what gcc does, what clang does, and what Mirror-C does with it — and those programs are recompiled against both real compilers on every test run, so the comparison cannot go stale silently. The landing example above is one of the twelve; a starker one is Freeing through one of two pointers, where liveness belongs to the cell rather than to whichever alias you called free through, so every alias goes stale at once — while gcc, given no reason to stop, keeps running and returns a plausible-looking 72.

This is not what your compiler does

A program Mirror-C stops on may well run and print something plausible under gcc or clang. That is the point: undefined behaviour permits both outcomes, and the one that looks fine is the more dangerous of the two, because nothing about running it once tells you it was ever unsafe.

What is proved, and what is not

The claim behind Mirror-C is deliberately narrow, and it is checked rather than assumed. Every source form has a transition rule; every rule has exactly one row in the event table; every stuck configuration's reason is one of the twelve above; and the rendering function is total and a function of the configuration alone — two configurations that render identically are guaranteed the same future, not merely observed to be so in the cases anyone tried.

That last guarantee was not true on the first attempt. A rendering that ignored the shape of pending evaluation contexts made two genuinely different configurations — 0 + 0 having just reduced its left operand versus having just reduced its right one — draw identically despite having different futures, which is exactly the property the guarantee promises cannot happen. The repair renders the evaluation context explicitly, one entry per pending frame, and the counterexample is now a regression test rather than an open finding.

Not everything is proved to the same standard. The soundness of the layer that turns a mouse action into a machine-level edit is measured by exhaustive testing over a corpus of programs rather than established as a theorem, and the project says so plainly rather than rounding it up. Readers who want the full mechanization — the Coq development, the numbered findings and decisions — will find them in the paper's own repository rather than here; this help documents the tool, not the proof.

Mirror-C is developed at L-Workshop. This help was written against the build running at m-c.l-workshop.my.id.