A Simplified Model of Fil-C
- title
- A Simplified Model of Fil-C
- type
- summary
- summary
- Walks through how Fil-C makes C/C++ memory-safe โ shadow AllocationRecord pointers, parallel "invisible bytes" for heap pointers, and a GC
- parent
- fil-c
- tags
- c, cpp, memory-safety, compilers, garbage-collection
- sources
- simplified-model-of-fil-c
- created
- 2026-04-18
- updated
- 2026-05-08
Peter Cawley's walkthrough of Fil-C, a memory-safe implementation of C/C++. The real Fil-C rewrites LLVM IR; Cawley's simplified model is presented as a source-to-source C rewrite, which is easier to reason about first and a small mental step from the production version.
The core mechanism: shadow capabilities
Every local variable of pointer type gets a companion variable of type AllocationRecord*:
T1* p1; T1* p1; AllocationRecord* p1ar = NULL;
Where AllocationRecord holds the bounds:
struct AllocationRecord {
char* visible_bytes;
char* invisible_bytes;
size_t length;
};
Trivial pointer ops move the companion along with the pointer: p1 = p2 becomes p1 = p2, p1ar = p2ar. Pointer arithmetic keeps the same companion (p1 = p2 + 10 keeps p1ar = p2ar). Casts from integer null out the companion.
Function calls pass the companion alongside each pointer argument, and specific standard library functions are replaced by Fil-C versions: malloc becomes filc_malloc, free becomes filc_free, and so on.
filc_malloc allocates three things
void* filc_malloc(size_t length) {
AllocationRecord* ar = malloc(sizeof(AllocationRecord));
ar->visible_bytes = malloc(length);
ar->invisible_bytes = calloc(length, 1);
ar->length = length;
return {ar->visible_bytes, ar};
}
Three allocations per malloc call: the record, the user-visible bytes, and a parallel "invisible" array the same size as the user allocation.
Bounds checks on deref
Every dereference expands to the obvious checks:
assert(p1ar != NULL);
uint64_t i = (char*)p1 - p1ar->visible_bytes;
assert(i < p1ar->length);
assert((p1ar->length - i) >= sizeof(*p1));
x = *p1;
Shadow memory for heap pointers
The clever part: when pointers live in heap memory, the compiler can't just add a companion local. Instead, invisible_bytes acts as a parallel array indexed identically to visible_bytes, but with element type AllocationRecord*. If a pointer sits at visible_bytes + i, its capability sits at invisible_bytes + i. i must be aligned to sizeof(AllocationRecord*) for sane access.
Loading or storing a pointer through another pointer therefore performs two loads/stores: one of the value, one of the companion. This is the reason memmove of eight aligned bytes behaves differently from eight separate 1-byte memmoves โ the aligned version also moves shadow memory; the unaligned one doesn't.
The GC
filc_free frees visible_bytes and invisible_bytes but not the AllocationRecord itself. That gets handled by a garbage collector that traces through AllocationRecords and frees unreachable ones. Production Fil-C uses FUGC, a parallel concurrent incremental collector; the simplified model can use stop-the-world.
The GC does two extra things:
- On freeing an unreachable
AllocationRecord, callfilc_freeon it. So forgettingfreeis no longer a leak โ the GC cleans it up. Explicitfreejust makes the release happen sooner. - If an
AllocationRecordhas length 0, pointers to it get rewritten to point at a single canonical length-0 record. This lets use-after-free resolve safely.
Once you have a GC, it becomes tempting to use more of it. Fil-C does: if a local variable has its address taken and the compiler can't prove the address doesn't escape, the local is promoted to heap allocation. No matching free needed โ the GC picks it up.
Production complications
The simplified model skips four pieces of complexity in real Fil-C:
- Threads.
filc_freecan't free immediately โ another thread might still be reading. Atomic pointer operations need extra magic since the default lowering splits one load into two (value + companion), breaking atomicity. - Function pointers. An extra
AllocationRecordfield marks executable-code targets. Calls checkp1 == p1ar->visible_bytesand the flag. To prevent type-confusion attacks, the calling ABI is uniform: every function takes a singleAllocationRecordfor a packed-struct argument frame. - Memory optimization. Tempting to allocate
invisible_byteslazily, colocate record and visible bytes into one allocation, reuse the underlying allocator's metadata slot. - Performance optimization. Bounds-check elimination and similar tricks to reduce the overhead.
When to use it
Cawley's four use cases:
- Large pre-existing C/C++ code that's probably memory-safe but unproven, where you'll eat the GC + perf cost in exchange for safety, perhaps as a bridge to a Rust/Go/Java rewrite.
- A sanitizer โ like ASan, but with stronger guarantees, for bug finding.
- Safe compile-time evaluation in languages where compile-time and runtime share the same language (Zig is the cited example) even if runtime stays unsafe.
- A concrete, tractable instance of pointer-provenance. The companion
AllocationRecord*is the provenance โ so Fil-C demonstrates why compilers can't generally rewriteif (p1 == p2) { f(p1); }toif (p1 == p2) { f(p2); }: equal bit patterns can carry different provenances, and Fil-C would propagate different capabilities tof.
Related
- fil-c โ the project itself
- pointer-provenance โ the memory-model concept Fil-C makes concrete
- abi-stability โ Fil-C's function-pointer defense requires a uniform ABI, which is a different angle on ABI design
- meta-tracing โ another "rewrite the program to gain a property" technique, for perf rather than safety
- raii / stroustrup-memory-leaks โ the social-contract version of the same goal: Stroustrup says "write code without leaks"; Fil-C enforces it mechanically when you can't trust the discipline