Chapter 22 — Compiling to Native
Every program in this book so far has run the same way: the compiler turns your .ig file into
bytecode, and a small virtual machine executes it. That VM is the beating heart of Ingle — it is
where contracts are checked, where the tape is recorded, where --emit=prove, --emit=check and
--emit=replay live. It is the language’s reference semantics: the definition of what an Ingle
program means.
But a virtual machine is not how you ship software. For that, Ingle has a second path: it can lower your program to C, hand that to the system C compiler, and produce a standalone native binary — a real executable, no interpreter, no runtime to install, nothing of Ingle left on the surface.
Two commands
The one you’ll reach for is -o:
inglec -o hello hello.ig
./hello
the answer is 42
=> 0
That is a native executable. It links against Ingle’s small runtime — allocation, the drop machinery, strings, arrays, the handful of things the language needs while it runs — and depends on nothing else. Run it, ship it, drop it in a container without packing an interpreter alongside it.
If you’re curious what Ingle handed the C compiler, ask to see it:
inglec --emit=c hello.ig
// Generated by `inglec --emit=c` from hello.ig. Do not edit.
// The bytecode VM is the reference semantics; tests/native diffs the two.
#include "ember_rt.h"
// ... your functions, lowered one-to-one ...
int main(int argc, char **argv) {
Value r = em_fn_1();
if (IS_INT(r)) printf("=> %lld\n", (long long)AS_INT(r));
rt_free_objects(&g_em);
return 0;
}
It is ordinary, readable C — your add becomes a C function, a + b becomes a + b, and main
runs your main and prints the same => <value> you’ve seen all book. --emit=c writes it to
standard output so you can read it, pipe it, or compile it by hand; -o does the whole job in one
step.
One front end, two lowerings — and a referee
Here is the decision that makes this safe. Ingle does not have two compilers. It has one front
end — one lexer, one parser, one type checker — and two lowerings hanging off the same checked
syntax tree: the one to bytecode you’ve used all along, and a new one (in src/cgen_c.c) to C. The
bytecode VM stays the canonical reference semantics; the native binary is the release build.
Two implementations of anything drift apart unless something forces them together, so Ingle runs a differential test: a corpus of programs is executed both ways — on the VM and as a compiled binary — and their output must match, byte for byte. A divergence fails the build.
If that sounds familiar, it should: it is the same bet as the whole of Part IV. Two independent implementations that are required to agree is a correctness check with real teeth — the compiler holding itself to the determinism standard it asks of your code.
What compiles
Everything the VM accepts. Not “the scalar subset,” not “everything except closures” — the whole
language you’ve learned in this book lowers to native: value structs (which become real C
structs, used by value, with no heap traffic at all), enums and match, arrays and strings,
erased generics, Option/Result and ?, closures and higher-order functions, dynamic dispatch
through interfaces, the bounded generics that Map and Set are built from, and the extern "c"
FFI (which simply binds the real libc you were already calling). Even concurrency comes across: a
program that uses spawn/nursery/channels is detected automatically and compiled against a
threaded runtime, so its tasks run on real OS threads.
The one thing it doesn’t do: check your work
A native binary is a release build, and that has a precise, deliberate consequence: contracts
and asserts are not enforced in native output. They are compiled out, exactly as --release
elides them on the VM.
Watch the difference. Here is a function whose postcondition is a lie — x is never greater than
x:
fn bad(x: int) -> int
ensures result > x
{
return x
}
fn main() -> int {
let y = bad(5)
println("y={y}")
return 0
}
Run it on the VM and the contract fires before main ever reaches the println:
inglec --emit=run bad.ig
inglec: runtime error: postcondition failed in 'bad' (ensures, line 2)
Compile the very same program to native, and it runs straight through — the spec isn’t in there anymore:
inglec -o bad bad.ig && ./bad
y=5
=> 0
This is not an oversight; it is the division of labour. Verification lives on the VM —
--emit=check, --emit=prove, --emit=replay, the tape, and runtime contract checks are all
reference-semantics capabilities, because the VM is where determinism and observability are
guaranteed. The native binary’s job is to be fast and standalone. So the workflow is a clean
two-step: prove, check and replay on the VM until you trust the program, then -o it for the
world. You verify the meaning where meaning is defined, and ship the speed where speed is wanted.
(One practical note while the toolchain is young: a freshly built inglec -o finds its runtime
next to the compiler, in the build tree, so native compilation expects to run from there for now.
Packaging it for a system-wide install is plumbing, tracked on the
Not Yet List.)
Why C, and why this matters
Ingle lowers from the typed AST, not from the bytecode, and targets C, not assembly or LLVM IR.
Both choices point the same way. C gives natural output and a compiler on every platform with no new
dependency — Ingle’s empty dependency tree survives intact. And lowering from the AST, where the
expression a + b still exists as an expression, rather than from stack-based bytecode, where it
has been flattened into pushes and pops, is what keeps the generated C readable instead of an
unrolled interpreter.
That readability matters for a concrete reason: you cannot run an operating system as a guest inside a virtual machine that itself needs an operating system to run. A language that means to reach bare metal has to be able to leave its VM behind, and emitting readable C is an early step in that direction.
Fireside trivia. Compiling a new language to C is one of the oldest tricks in the trade, and one of the most quietly successful. The first real C++ “compiler” — Bjarne Stroustrup’s cfront, at Bell Labs in the early 1980s — was not a compiler at all in the usual sense: it translated C++ into C and let the ordinary C compiler finish the job. That is how C++ reached every machine that already had a C compiler, which in 1983 was very nearly all of them. Four decades on, the move still works for the same reason: C is the closest thing computing has to a universal assembler, and a language that learns to speak it inherits the entire world C already runs on. Ingle’s
--emit=cis the newest entry in a long and respectable line.