Fil-C on Nix

Filnix wiki

nanofil/docs/asm-fixed.md

Fixed-register inline asm (B1): contract (reviewed, implemented)

The frontend thread measured the 17 survey packages that inline asm blocks. filcc passes through a single instruction that has no operand in its text and works only on fixed registers, such as cpuid, xgetbv, rdtsc, the fences and pause. That wins libblake3, pcre2, util-linux, physfs and fstrm now. B1 lets nanofil do the same. It runs native code inside checked functions, so this contract comes before any code and goes to Oracle for review, as rounds 1–5 of M7 did.

Scope

filcc's validator (validateSafeInlineAsm, FilPizlonator.cpp 8933–11494 in filc-staging-ref-src) is general. It knows about 200 mnemonics, checks each explicit operand's role, and requires every register an instruction writes to be declared. B1 takes only one corner of it:

Mnemonic Reads Writes (32-bit, upper half zeroed) filcc's coverage rule (lines) Flags
cpuid eax, ecx eax, ebx, ecx, edx each written register is an output or clobber (10521–10546) none
xgetbv ecx eax, edx ecx is an input; eax and edx are outputs, not clobbers (10548–10566) none
rdtsc — eax, edx eax and edx are outputs or clobbers (10644–10660) none
rdtscp — eax, ecx, edx eax, ecx and edx are outputs or clobbers (10662–10682) none
lfence, sfence, pause — — none (9300–9330) none
mfence — — none none

In every case filcc requires that the line has no operands: "X takes no operands". None of these instructions sets flags, so filcc requires no cc clobber (11489). None of them accesses memory. cpuid and the rdtsc pair are never privileged. Some can still fault:

#GP is SIGSEGV and #UD is SIGILL. Fil-C makes both uncatchable (is_unsafe_signal_for_handlers, 525–536; sigaction, 9043), so the process dies, as it does under filcc.

The term

One new instruction, a statement because it defines up to four results:

asm_fixed(M, Ins, Outs)
Part Rule
M one of cpuid, xgetbv, rdtsc, rdtscp, lfence, sfence, pause: an atom, never text
Ins R-V for exactly the registers M reads, in the order a, b, c, d. Each V is a w operand (a w temporary or a constant)
Outs R-tmp(T) for a subset of the registers M writes, in order, each R at most once. Each T is defined here as a w temporary

mfence is the existing fence instruction, emitted as mfence and already supported by every pass, the interpreter, both backends and the register checker. The frontend lowers asm "mfence" to it. fence is at least as strong as the asm in every respect, including clang's treatment of a memory clobber.

Text, constraints and clobbers stay in the frontend. The emitter prints an instruction from its own table for M, so no source text reaches the assembler. Effects come from M, not from the declared clobbers, so a wrong clobber list cannot make the emitter keep a value in a register M writes.

Operands are scalars only

Every V and T is class w. The validator rejects p, so no pointer and no capability reaches the asm or comes out of it. It also rejects l, s, d and x. Fil-C keeps capabilities out of band, and an integer carries none. A program may pass (uintptr_t)ptr as an input, which is an integer. An output turned back into a pointer has no capability, as with any int-to-pointer cast in Fil-C.

All operands are 32-bit because M reads only the low 32 bits of its inputs and every write of M zero-extends.

What the frontend does with the rest (acceptance, owned there)

Validator (ir.pl)

asm_fixed(M, Ins, Outs) is valid in both the plain and the checked stage iff M is in the table, Ins names exactly M's read registers in order with w values, and Outs names distinct registers in order that M writes, each defining a w temporary. Strict SSA covers the results: cfg:inst_def gives each output, and cfg:inst_uses each input temporary. A missing inst_uses clause would fail open: simplify's dead code would delete the inputs' definitions. A missing inst_def clause would let frame_slots start an output's interval at its first use, so a temporary sharing its slot could be overwritten at the asm. It is not evidence_preserving (rounds 1–5): every check fact dies at it. That is conservative, since it can neither free nor change bounds, but B1 does not need its facts.

Middle end

The pattern is fence, which every pass already treats as ordered:

Today ordering holds because no pass moves memory operations at all; fence gets no special treatment. simplify keeps it because removable/1 covers only pure lets. The test that guards this is therefore "each pass keeps every asm_fixed, in order", not a particular reordering.

Clauses that must be added, since without them the asm's effects silently disappear (Oracle):

These defaults are already right for it:

escape:inst_event needs a clause too: in plain mode a w input can be a derived address, so its object escapes, as at a call.

Emission (filc_amd64.pl and amd64.pl)

movl <V_a>, %eax      ; each input, from its slot, home or constant
movl <V_c>, %ecx
asm_fixed(M)          ; one line in the stream (below)
movl %eax, <T_a>      ; each output, into its slot or home
movl %edi, <T_b>      ; cpuid's b result arrives in %edi
...

The line asm_fixed(M) prints as M's table entry. For cpuid:

movq %rbx, %rsi
cpuid
movl %ebx, %edi
movq %rsi, %rbx

For the others it is just the instruction. rbx is callee-saved, and under --registers=temps (the -O default) it may hold a home. The save and restore keep both the caller's rbx and that home. %rsi and %rdi are scratch inside one IR instruction, like %rax–%rdx. In the reference stream, no scratch register holds a value at an IR instruction boundary. The -O step-3b rewrite does carry scratch contents across boundaries within a region (amd64_regs.pl forward, cell_forward, dead_move), so the line must end such a region (below). Roots never live in registers: FUGC scans frame lowers, not native registers.

Register allocator and checker

The line is the non-ins term asm_fixed(M), not ins(M, none, []). The checker's operand pairing (paired/7) takes register effects only from operand roles and implicit/4, never from instruction_effects/2. So an ins line with no operands would look register-neutral, and a 3b rewrite that forwards a stale rcx or rsi across cpuid, or drops its input move, would be accepted. (This was Oracle's blocker on the first draft, which modelled it on mfence.) As a non-ins line:

M Reads Writes Flags Memory Control
cpuid rax, rcx rax:32, rcx:32, rdx:32, rsi:64, rdi:32 preserve none next
xgetbv rcx rax:32, rdx:32 preserve none next
rdtsc — rax:32, rdx:32 preserve none next
rdtscp — rax:32, rcx:32, rdx:32 preserve none next
lfence, sfence, pause — — preserve none next

None of them changes the flags, so preserve is exact, and region_end compares any flags live across the line.

No memory, poll or GC interaction

Interpreter (interp.pl) and difftest

Tests and plants

Review

Oracle reviewed the first draft. The core argument held: operands are w only, there is no memory access and no safepoint, homes are never in rax–rdx, rsi or rdi, and the cpuid save and restore are correct. Its blocker was that a line with no operands, modelled on mfence, hides cpuid's register effects from the checker while -O forwards scratch values across IR boundaries; the line is now a region boundary with call-like liveness. Its should-fixes were:

All are folded in above.

As built

The IR side follows the contract. Two points it makes concrete:

Tests:

Plants, each one source edit (unit = the unit tests; native = run-c asm-fixed, plain and -O):

Plant Caught by
1. cpuid without the rbx save unit (asm_fixed_lines), native -O
2. b read from ebx after the restore unit, native
4 and 4b together: cpuid's effect omits the rcx read and the line is not call-like unit (asm_fixed_drops_subleaf), native -O
5. the validator accepts p unit
6. an output from an unwritten register unit
7. the interpreter runs cpuid as a no-op unit
8. no inst_uses clause unit, native (compile fails)
9. no call_outcome_result clause unit
10. simplify drops an asm_fixed without outputs unit
11. the effect admits a write of rbx native -O ("register home translation check failed")
12. no escape:inst_event clause unit (asm_fixed_escape)
13. the validator accepts an l input unit
14. one temp defined by two outputs unit

Not caught, and why that is sound: an omitted write in the effect (3a, rsi; 3b, rdtscp's rcx) changes nothing because the boundary resets the checker's symbolic state, so an omitted write can only make liveness more conservative. An omitted read (4 alone) is covered by the call-like use of every scratch register, and that use alone (4b) by the effect's reads: each guards the other, and the pair is caught. Plant 11 without the hardening passes because the printed text really preserves rbx; the hardening guards the model, not the text. Oracle's implementation review confirmed these are equivalent mutants: on a boundary line Use ∪ 127 clears every scratch Def bit, and no peephole or forwarding runs across it.

Oracle's implementation review found no blocker. It added the escape clause (plant 12: in plain -O, promotion deleted the let defining an input that was a derived address; that failed closed, at emission), the checked error messages in asm_fixed_validation, two checker cases whose B stream also dropped the move, preserve flags (exact, where write would let flags live across the line go uncompared), and distinct output temporaries.

Limits: the interpreter validates nothing after a cpuid, xgetbv or rdtsc(p) in a run; native tests cover what follows. The native r10 and r11 cover is incidental (cpuid4's out lands in r11 under -O).