Fil-C on Nix

Filnix wiki

nanofil/docs/m3.md

M3: pointer class and Fil-C lowering

This is the first, deliberately naive Fil-C lowering, in src/filc_amd64.pl. Every rule is chosen to be easy to check, not fast. Optimizations come in M7.

cd nanofil/src
../bin/nanofil run ../tests/filc/basic.ir walk 9      # trap out_of_bounds
../bin/nanofil compile ../tests/filc/basic.ir > basic.s
as basic.s -o basic.o && filcc your.c basic.o             # link with Fil-C code

IR

A module that uses class p anywhere is a Fil-C module. It is validated, interpreted and compiled with Fil-C rules. Modules without p keep the plain meaning from M1 and M2 (once checked against real qbe) and compile with the plain backend.

In a Fil-C module:

Interpreter semantics (the oracle)

A pointer is ptr(Address, Cap), where Cap is none or obj(Id). Objects never die (the naive lowering heap-allocates every alloc, and the GC keeps them while reachable). Memory has bytes plus an aux map from 8-aligned address to the Cap stored there by store(p, ...).

Lowering

Frame. Each function pushes filc_frame {parent, origin, lowers[N]} in its stack frame and sets thread->top_frame (offset 16) to it. Every p temporary and every p phi shadow slot has a dedicated lowers[i] slot for its whole lifetime, and its address lives in an ordinary slot. All lowers are zeroed at entry. So at every safepoint, every capability the function can still use is in a precisely scanned frame slot, because values are never kept in registers across anything. Frame stores have no barrier: FUGC is grey-stack Dijkstra with a soft-handshake fixpoint, and clang's output stores frame lowers the same way. The frame is popped (top_frame = parent) on every return and on exception propagation. lowers[] is also what callee arguments rely on: callers root what they pass.

Origins. One filc_function_origin with can_catch = 1 for the frame and call sites, and one with can_catch = 0 for pollchecks and check failures. Both use num_lowers_ish = N in staging's 48-byte layout (num_setjmps counts static sites, num_stack_auxes = 0). See setjmp.md for the final per-site lowers. frame.origin is reset to the call-site origin before every call, because filc_pollcheck_slow overwrites it.

Entry. cmpq %rsp, (%rdi); jae filc_stack_overflow_failure after the frame is allocated, then the frame push.

Safepoints. A pollcheck (testb $14, 8(thread), then filc_pollcheck_slow(thread, origin)) on every edge that goes backwards in block layout order. Every cycle has such an edge, so every loop polls.

Access checks, for S bytes at flight pointer (P, L), with failure calling filc_optimized_access_check_fail(P, L, &check_origin):

  1. L ≠ 0
  2. P ≥ L
  3. P ≤ upper − S, with upper = *(L − 16)
  4. writes: *(u8 *)(L − 2) & 6 == 0 (READONLY | FREE)
  5. load(p)/store(p, ...): P & 7 == 0

No separate not-freed check: free sets upper = lower, so check 3 fails for every S ≥ 1.

Pointer load after checks with S = 8: aux = *(L − 8) & (2^48 − 1); if 0 the loaded lower is 0, else w = *(aux + (P − L)), and if w & 1 the lower is *((w & ~1) + 8) (atomic box). The address is *P.

Pointer store of (V, VL) after write checks with S = 8: if aux is 0, aux = filc_object_ensure_aux_ptr_outline(thread, L − 16). If VL ≠ 0 and filc_current_marking_state ≠ 0, call filc_store_barrier_for_lower_slow(thread, VL). Then *(aux + (P − L)) = VL and *P = V.

alloc is filc_allocate(thread, N), or filc_allocate_with_alignment for 16, giving P = L = object + 16.

ptoi/itop. ptoi drops the lower; itop and integer constants used as p have lower 0, so any access through them fails check 1.

Calls use the fast convention: rdi = thread, rsi = 0, then each argument's words in rdx, rcx, r8, r9, then 8-byte stack slots; a p argument is two words (address, lower), w is zero-extended. The result is al = has_exception, rdx and rcx (a p result's lower). If al & 1, the caller pops its frame and returns al = 1. A call goes to pizlonatedFI<sig>_f; for a function outside the module, a signature mismatch is a link error (no fallback thunk yet).

Function symbols for f with signature sig: pizlonatedFIP<sig>_f (implementation), pizlonatedFI<sig>_f (alias, exported functions only), pizlonated_f (getter returning FO + 16 in rax and rdx), and pizlonatedFO_f: {FO+16, (FO+16) + 0x0083 << 48, fast, generic, sig} in .data.rel.ro. The generic entry is per signature and local to the module. It checks args_size, calling filc_cc_args_check_failure if it is too small, unpacks arguments from thread + 128 (data) and thread + 384 (aux), calls the fast entry through *callee_lower, and writes results back to those buffers.

Not yet supported, and rejected: more than 16 arguments. (Data definitions, symbol addresses and indirect calls arrived in M4c; see docs/m4.md.) unreachable is ud2, as in M2.

Testing

All of this runs in checks.x86_64-linux.nanofil.

Fil-C's malloc rounds sizes up to 16 bytes, which the interop test's overruns account for.