Fil-C on Nix

Filnix wiki

nanofil/docs/abi-findings.md

Fil-C ABI findings at the filnix pin

Nanofil requires staging Fil-C 0.687, core revision af81305 of mbrock/fil-c branch filnix-staging (lib/filc-staging.json). There is no 0.686 compatibility path. The original probes below were established at 163fae5; their emitted ABI contracts were rechecked against staging's pass and runtime. The handoff is historical, not the target. Rerun probes/ with nanofil/probes/run.sh and FILCC=/tmp/filcc/bin/clang after building .#filcc-staging.

To read the sources at the pin, build the small reference checkout (about 6.5 MB: the design docs, FilPizlonator.cpp and its helper passes, the clang pipeline and driver files, and the runtime):

nix build -o /tmp/filc-ref .#lib.x86_64-linux.filc-staging-ref-src
# /tmp/filc-ref/libpas/src/libpas/filc_runtime.{h,c}
# /tmp/filc-ref/llvm/lib/Transforms/Instrumentation/FilPizlonator.cpp
# /tmp/filc-ref/{Manifesto,gimso_semantics,invisicaps_by_example}.md

The full checkouts in lib/sources.nix are for building; filc0-src alone is about 1.6 GB.

Staging differences that matter

Read the complete pass/runtime changes from 163fae5 to 35faf87. Upstream 43d345a81212 is the ABI break: 48-byte function origins and per-static-site setjmp lowers/recreation replace the weak map. See the setjmp section below. Thread fields nanofil uses, signatures, fast/generic calling conventions, object headers, getter interface, access checks, pointer aux loads/stores, allocation and scalar va_arg helpers retain their emitted contracts. Staging adds thread fields only after existing CC buffers; nanofil does not emit the changed unwind/fiber state.

The named staging changes were verified, not assumed harmless (their rebased commits at this pin differ from the earlier branch IDs):

Open questions from the handoff, answered

Struct arguments and returns. Clang classifies structs by SysV rules before the pass runs. The pass only sees the result (from abi.ll):

C Fast signature seen by the pass
long take_small(struct {long a, b;}) (i64, i64), symbol FI1598, the same as long f(long, long)
long take_withptr(struct {char *p; long n;}) (ptr, i64); the pointer field travels as a flight pointer
struct {long a, b;} ret_small(long) returns {i1, i64, i64} (FI146)
struct {char *p; long n;} ret_withptr(char *) returns {i1, flight_ptr, i64}
struct {long a, b, c;} ret_big(long) sret becomes a leading flight-pointer argument, and the return is {i1} (FI2528)
long take_big(struct {long a, b, c;}) byval, so generic only (FI0); the function object's fast_entry is 0

So nanofil's frontend must do the SysV struct classification itself. That means splitting a struct of 16 bytes or less into scalars, with pointer fields as pointer class. Larger returns become an sret pointer, and larger arguments force the generic convention.

Pointer pairs across R9 and the stack. They do split. In ext5(long, char *, char *) (abi2.s, call5 and def5), the registers are RDI = thread, RSI = callee lower, RDX = the long, and RCX:R8 = first pointer. R9 holds the second pointer's address, and its lower goes in the first stack slot. Pairs are not kept together. Each half takes the next GPR, then the next 8-byte stack slot. This is plain SysV assignment of a flattened (ptr, ptr) list.

has_exception. It comes back in AL. Every caller and thunk tests only testb $1, %al. Callees zero EAX, but paths through a select define only AL. Rule: write 0 or 1 to AL, and read only bit 0. Integer results come back in RDX (and RCX for a second element), and a pointer comes back as RDX:RCX. Getters return in RAX:RDX.

Does free collapse bounds? Yes. free_impl in filc_runtime.c sets object->upper = lower, then sets FILC_OBJECT_FLAG_FREE in aux with a CAS, panicking if it is already set. Global, special and mmap objects cannot be freed. Note that the write check still tests FREE together with READONLY (testb $6, -2(lower)). So skipping only the not-freed check when bounds are checked is sound, but write checks remain.

The interpreter's lifetime contract records the verified libc wrapper, null/base-pointer validation, zero-sized objects, readonly heap snapshots, shared alias invalidation and the boundary with GC. It also documents the Oracle review and native/interpreter regression gates.

Can string literals skip the getter? Clang does not skip it. Each literal gets pizlonatedDO_.str with header {upper = DO + 16 + size rounded up to 8, aux = 0x0003 << 48} (GLOBAL | READONLY, no aux array), a 16-byte cache and a getter that calls filc_global_initialization_start. That call only pushes the object onto filc_global_variable_roots, so the collector scans it for pointers. fugc_mark_fast in fugc.h returns "already marked" for any GLOBAL object and never touches it. Function objects already rely on this: their getters just return FO+16 and they are never registered. A pointer-free GLOBAL | READONLY object therefore needs no registration, and can be referenced directly with a RIP-relative lea. This is the reading, not yet a GC stress test. The same argument covers any readonly global whose pointers all target other GLOBAL objects. Only globals that can come to hold heap pointers need registration.

Callees root their arguments (staging since deluge). Up to 0.687 the pass said "Fil-C callers root the objects they pass as arguments; callees do not root their incoming arguments (except byval ones)." Upstream f565a56df1f, which staging has had since it moved to deluge (November 2026), turned that around: a callee's prologue records each incoming pointer's lower in its frame, and a clang caller need not keep an argument's lower once the call is its last use (a frame-popping musttail drops it before the call). nanofil's entry already roots its own: receive runs after push_frame and before any safepoint (filc_abi.pl's entry sequence) and stores each incoming lower in the parameter's lowers[] entry. A nanofil caller keeps its arguments' lowers in its frame across the call anyway (musttail is an ordinary call), which the new rule allows. A thunk's saved words are not roots, so between its getter call and the callee's entry an argument is rooted only if the caller kept it: a nanofil caller does, a clang caller may not. clang's own thunks are alike, and so is the open upstream question whether a getter can reach a safepoint there (filnix docs/upstream-updates.md).

Sequences observed at the pin

These are clang's output at -O2, and they are what nanofil's inline paths should reproduce.

Static data layouts at the pin

Struct Size Fields
filc_function_origin (including base node) 48 function, filename, u32 num_lowers_ish, pad, personality_getter, u8 can_throw, can_catch, pad to 36, u32 num_setjmps, u32 num_stack_auxes, pad to 48
filc_origin 16 origin_node, u32 line, u32 column
filc_optimized_access_check_origin 24 + 8n u32 size, u8 alignment, alignment_offset, needs_write, pad, scheduled_origin, semantic_origins[] (NULL-terminated)
Function object 40 {FO+16, (FO+16) + 0x0083 << 48}, then fast_entry, generic_entry, u64 signature

The function object's aux flags 0x0083 are GLOBAL | READONLY | special type FUNCTION (1 << 7).

Generic entry. {i1, i64 ret_size} generic(thread, callee_lower, args_size). Arguments arrive in thread+128 (data) and thread+384 (aux), and results go back the same way. An entry must check args_size and call filc_cc_args_check_failure(actual, expected, origin) when it is too small.

Globals, getters and indirect calls (for M4c)

Read in filc_runtime.{h,c} and FilPizlonator.cpp at the pin, and checked against filcc -S output.

Aux flag bits (aux >> 48): GLOBAL 0x1, READONLY 0x2, FREE 0x4, MMAP 0x8, GLOBAL_AUX 0x10 (the aux array is static, so it is not marked), WEAK_KEY 0x20; special type in bits 7–10 (function = 1), log2 alignment in bits 11–15. Clang's globals use 0x0001 (no pointers), 0x0011 (pointer-holding, aux = DA_g), 0x0003 (string literals), and 0x0013 for a readonly global that holds pointers.

A global g is DO_g: {upper = DO_g + 16 + size rounded up to 8, aux} followed by the payload, plus a 16-byte cache GP_g holding a filc_ptr {ptr, lower} (zero until initialized). A pointer-holding global also has DA_g, a zeroed aux array as large as the payload.

Calling through a function pointer (checkFunctionAndGetLower, and the thunk clang emits for each external callee): lower ≠ 0, ptr equal to the aux pointer of lower (a function object's aux points to its own payload), and special type function, i.e. aux & 0x0780 << 48 == 0x0080 << 48. Otherwise call filc_check_function_call_fail(ptr, lower). Then, if the signature word at lower + 16 is the call site's, call *(lower + 0) (the fast entry), with RSI = lower. If not, marshal the arguments into thread + 128 (data) and thread + 384 (lowers), call *(lower + 8) (the generic entry) with RDX = argument bytes, and if no exception, require the returned size to be at least the return's size, calling filc_cc_rets_check_failure(got, want, origin) otherwise. The inline buffers hold 256 bytes each. Larger argument lists use outline buffers, which nanofil does not need while calls stay at 16 words or fewer.

Structs as values (for M4d)

Probed with filcc -O1 -S at the pin; the generic-entry layout matches pizlonated1ET<sig>/2ET<sig>, clang's shared generic helpers.

Byval arguments

Verified at the staging pin (FilPizlonator argInfosForFunction, iterateArgs, loadCC, storeCC, and probes compiled with -O2). A struct or union of SysV class MEMORY (over 16 bytes, or packed with misaligned members) is a byval argument, and:

nanofil reaches the same packet without a new IR form. A byval argument is lowered as its eightbytes, one IR argument each, with a pointer eightbyte as class p; a call carrying them is not generic-only by itself, but it calls a generic-only callee through the thunk or the dispatch, whose generic path writes each word and its lower to the cell clang's demotion would. A function with a byval parameter also takes the entry snapshot (va_args, unbound), so filc_abi makes it generic-only exactly like a variadic function: its generic entry promotes the whole packet to the heap (filc_promote_args_to_heap, capabilities included) and passes the words to the private body, which roots the snapshot. A small struct passed past a prototype's ... goes as its eightbytes too, as clang coerces it. A function of more than 16 words is generic-only as well, and its packet may use the outline buffers (below). An aggregate va_arg reads one cell per eightbyte (a pointer one with its capability).

Structs with float members

Verified at the staging pin with -O2 probes and -emit-llvm. Clang classifies a struct or union of 16 bytes or less per eightbyte, as the SysV psABI says (3.2.3), and FilPizlonator keeps the resulting scalar types, so the fast signature has their digits:

C type fast form
struct {float a;} one float (digit 1): r_f1 returns {i1, float}, FI269
struct {double a, b;} two doubles: returned in XMM0 and XMM1 ({i1, double, double}, FI436), passed as two double arguments
struct {long a; double b;} {i1, i64, double}: RDX and XMM0; arguments i64, double
struct {double a; char *p;} {i1, double, flight_ptr}: XMM0, then RDX:RCX
struct {double a; float b;} double, float
struct {float a; int b;} one i64 (a mixed eightbyte is INTEGER)
struct {float a, b;}, {float a, b, c;} <2 x float> for the first eightbyte, which has no signature digit: the function is generic-only (FIP0), as a caller or as a callee, argument or result

Integer words of a returned pair go in RDX then RCX and float words in XMM0 then XMM1, whatever their order in the struct. In the generic packet each eightbyte is one 8-byte cell, a float one holding its bits. nanofil's abi_eightbytes/4 gives each eightbyte the IR class s, d, l or p accordingly, a pair of floats l (its bytes); a function taking or returning such a pair takes the entry snapshot so it is generic-only too, and progtest.sh's // signatures mode compares every exported pizlonatedFI<Sig>_ name with clang's.

More than 16 argument words, or 256 bytes of packet. A call with more than 16 fast words has the generic signature 0. A packet past the 256 inline bytes continues in the thread's outline buffers: cc_outline_buffer at thread+640, cc_outline_aux_buffer at +648, and their size at +656; storeCC first calls filc_thread_ensure_cc_outline_buffer_slow(thread, size - 256) when the size is short. nanofil's generic call paths do the same, and its call sites with more than 16 words use a signature no object has, so they always go generic.

Distinct thunks for distinct word lists. Every direct call of more than 16 words has that one never-matching signature, so the weak thunk it goes through cannot be named by signature alone: two calls to one callee with different word lists (a variadic one, say, or printf with 18 and then 24 words) would share one thunk, sized for whichever came first, and the second call's callee would read past its packet. filc_abi:call_symbol_signature/4 names such a thunk pizlonatedFIN<R>_<A>_<f> instead, R and A being the bijective base-11 values of the whole result and argument digit lists. Only nanofil makes these weak hidden thunks; no definition exports such a name. tests/c/prog/wide-varargs.c is the regression test.

x87 long double

Verified at the staging pin with -O2 probes (-S and -emit-llvm):

nanofil (float.md, x87 long double) keeps long double values in the IR class x and never puts one in the fast convention. A function taking or returning one, or a struct holding one, is generic-only, and its long double words are the 16 bytes as two l words at a 16-aligned packet offset, after a zero pad word when needed. A clang caller's FI thunk finds no matching signature and uses the generic entry, and nanofil's calls of clang functions go generic through nanofil's own thunks. tests/c/prog/ldouble.c checks both directions against clang, directly, indirectly, variadically and with wrong prototypes, under FUGC_SCRIBBLE and FUGC_VERIFY too.

Verified end to end

probes/link/add2.s is hand-written GNU as assembly. It contains an implementation, an alias, a getter, a function object and a generic entry for long add2(long, long). Linked with a clang-built Fil-C main:

direct 42 indirect 1337
filc safety error: argument size mismatch (actual = 8, expected = 16).

The direct call resolves to our strong pizlonatedFI1598_add2 over clang's weak thunk. The indirect call goes through our getter and function object checks. A call through a wrong prototype (FI135) takes the generic path into our entry, which rejects it. Code emitted outside clang can participate fully in the Fil-C ABI, using GNU as and nothing else.

Variadic definitions and va_list

Verified against the same 163fae5 reference checkout, and clang's -S -emit-llvm output for tests/c/variadic.c:

Checking means exactly what the pinned implementation checks. There are no scalar type tags: the helper cannot distinguish int from pointer or signed from unsigned. An integer argument read as a pointer has no capability and traps on dereference; reading a pointer as an integer reads its address bits. It would be incorrect to promise a scalar type-mismatch trap that clang-built Fil-C does not implement. Also, promotion uses the allocator's 16-byte-rounded bounds, not a separate logical argument end. A single eight-byte fixed argument with no extras can leave eight readable padding bytes. Exhaustion tests use a 16-byte incoming argument area and read at offset 16, so their first missing argument is genuinely out of bounds in both compilers.

Nanofil keeps func/5 unchanged: va_args const(N) marks an eager snapshot, and va_arg list_address reads and updates its list in memory. The backend's generic adapter promotes once, then passes the snapshot as a hidden pointer argument to a private fast body. There is no safepoint between promotion's return and the body's prologue rooting that lower. The normal fast-body return code and existing generic result marshalling handle scalar, pointer and buffered struct returns. Nonvariadic assembly is unchanged. Escape analysis treats va_arg's list as accessed and escaping, so -O cannot promote its storage out of memory.

Oracle reviewed snapshot lifetime, caller rooting, helper bounds and alignment, and the adapter design before implementation. Its required invariants were eager promotion using original incoming size, a dedicated snapshot root before the first body safepoint, no safepoint while unpacking helper results, and generic routing for recursive/same-module calls too. Its two caveats were the absent scalar type tags and rounded allocation bounds described above.

Verification:

Dynamic alloca and unreachable

At the pinned reference source, FilPizlonator.cpp:14155 lowers an LLVM AllocaInst to allocate(Size, Alignment), including dynamic array sizes. allocate at line 2745 returns flightPtrForObject for the new runtime object. This is a GC allocation with its own capability, not a raw stack adjustment. nanofil's __builtin_alloca(n) uses the existing alloc(16) IR instruction at the call site (not hoisted), which calls filc_allocate_with_alignment(thread, n, 16) and roots the returned flight pointer just like every other p temporary. No new IR or check elimination is involved. tests/c/prog/alloca-bounds.c writes the last valid byte of a dynamic 32-byte allocation, then traps on byte 32 in both nanofil and clang builds, also under the GC verification environment. Variable length arrays lower the same way: an alloc of the run-time size where the declaration is reached, which is what clang's dynamic alloca becomes under the pass (tests/c/vla.c traps one past a 16-byte array in nanofil, the interpreter and clang alike). The runtime's prepare_allocate_object rounds sizes up to FILC_MINALIGN (16 bytes), so the capability for a 37-byte request covers 48 bytes in both compilers; byte 37 is not a valid bounds-trap probe at this pin.

The pass's UnreachableInst case at line 14392 calls the runtime error helper with llvm unreachable instruction. nanofil instead uses its existing unreachable terminator, emitted as ud2: executing it stops rather than falling through, but does not produce clang's diagnostic text.

Scalar floating point (M9)

Checked against filc-ref-src at the pin, not upstream head:

Safety boundaries: float bits in private eight-byte slots carry no capability. Generic outgoing slots must clear aux even after an earlier pointer argument. Ordinary float stores must not create capabilities. Mixed-call adapters retain caller-rooted pointer arguments, and variadic adapters retain the promoted snapshot's root. Save incoming XMM arguments before calling getters, which may clobber them. Test returned AL before using RAX as a float-bit scratch register. An independent reviewing subagent (Oracle) checked the implemented sequences against the pinned pass/runtime: no soundness blockers found, scoped to scalar s/d and the existing integer/pointer tuple returns, including buffered tuple returns with float arguments. It verified zero generic aux/padding, payload-only ordinary stores, AL handling, independent banks, XMM saves before getters, caller roots and the promoted snapshot's lifetime. Its suggestion to retain an executable buffered-return regression is covered by the buffered-return interop cases below.

Reproducible coverage:

Setjmp and longjmp

Verified in staging 35faf87, not inferred from libc conventions:

Nanofil's approved IR, conservative payload snapshot, optimizer barrier, Oracle review conditions and test coverage are in setjmp.md.

Safepoints between a check and its access

Verified at the pin for pizlonation.md, which has the details:

Nonescaping LocalExplicit storage

Verified against the same staging pin, not inferred from heap lowers:

Nanofil's ABI product carries local(Name,Size) storage descriptors and distinct stack_aux(Name) root keys. origin(Which,Catch,Roots,Setjmps,Locals) states both suffix counts. Local payload/aux never enter the temporary alias pool; the origin remains correct across calls and pollchecks. The exact IR, lifetime and conservative selection policy are in m5.md.

filcc output depends on heap addresses

Verified October 2026 with filcc-staging, for the toolchain owner; nanofil does not depend on it. The same filcc -O2 -c of one file gives a different object on each run: zlib's deflate.c, bzip2's blocksort.c and lz4's lz4.c and lz4hc.c. -O2 compiles of lz4.c disassemble to anywhere from 57,561 to 59,241 lines, with LZ4_decompress_safe at 0x3301 or 0x3478 bytes, and a filcc lz4 ran lz4 -9 in 2.99G instructions in one build and 2.84G in another. Two Nix builds of one derivation differ (nix build --rebuild).

Likely fix (not tried): order by a stable key, such as the instruction or argument index of CanonicalPtr in the function, instead of its address. Other pointer-keyed containers in the pass (58 DenseMap/ unordered_map/SmallPtrSet of pointers) are harmless only where they are not iterated to produce output.

Pointer atomics

Read from filc_runtime.h/filc_runtime.c and clang -O1 output at the pin (__atomic_load_n and __atomic_store_n on a void * global, every order).

nanofil passes the checked pair and the value's rooted pair the same way. It moves the pair out of %rax/%rcx before storing the origin.

Atomic read-modify-write

Read from FilPizlonator.cpp and filc_runtime.{c,h} at the pin, and from filcc -O1 output on probes.

nanofil lowers every one of them through the one IR term cas(T) (docs/pizlonation.md): integers as lock cmpxchg after the same check, singly or in a loop; pointers through the same CAS helper. Pointer fetch-and-add and ++/-- are loops over a flight add of the old pair, so a boxed slot keeps the update; the __c11 fetch builtins then strip the result's capability, as filcc's does, while ++/-- keep it. Compare-exchange writes *expected back only on failure, with the old value (C11 7.17.7.4p2). GNU __atomic_fetch_add on a pointer, fetch operations on a floating _Atomic, and read-modify-writes of packed members are refused.

Thread-local variables

Read from FilPizlonator.cpp (G->isThreadLocal() in the global lowering), filc_runtime.c and clang -O1 output at the pin, for _Thread_local ints with and without initializers, a pointer, a struct with a pointer to a global, a block-scope static _Thread_local and an extern one.

nanofil emits the same symbols and protocol (filc_amd64.pl, thread_local_object//4), always with @GOTTPOFF, which also resolves internal symbols. Its slow path copies the non-pointer bytes from a read-only template .LpizlonatedTI_x, then fills the pointers with filc_execute_constant_relocations, the global slow path's own helper. That helper stores without a barrier, which is sound here: a static initializer's pointers all point to global objects (functions, globals, literals), which the collector does not free. nanofil asks for the aux array only when the initializer has pointers; a later pointer store ensures it as any heap store does. In the IR a thread-local is data of linkage tls(export) or tls(local); the interpreter runs one thread, so there it is an ordinary object. tests/c/prog/thread-local.c runs eight threads over thread-locals defined on both sides, with forced collections, against clang.

Constructors and destructors

Read from clang -O1 -S output at the pin for static and external constructors with and without a priority and a static destructor.

nanofil emits the same (init_entry//4 in filc_amd64.pl), named .L__nanofil_ctor_f..forwarder, from IR data of linkage init(ctor|dtor, Priority) whose one item is item(p, sym(f, 0)). tests/c/prog/constructor.c mixes nanofil's and clang's constructors and checks the order.

Eightbytes without data

A struct or union of at most 16 bytes that is not MEMORY passes only the eightbytes holding some member's data (clang's X86_64ABIInfo::classify leaves the others NO_CLASS, and its coerced type covers just the other eightbyte). Observed at the pin with -emit-llvm:

Type clang passes
struct __attribute__((aligned(16))) { int x; } (16 bytes) i32
struct { long a; int :32; } i64
struct { int :32; int :32; long b; } i64, the eightbyte at 8

A long double covers both its eightbytes (X87, X87UP). Past a prototype, clang passes such a struct byval instead, so the generic packet holds every eightbyte, padding included, where va_arg reads it (llvm.filc.va.arg.address with the struct's size). nanofil's abi_eightbytes/4 drops the empty eightbytes for parameters, arguments and results, and variadic_words/4 keeps them for variadic arguments.

Weak thunks are shared by signature

A call of a function defined elsewhere goes through pizlonatedFI<Sig>_<f>, a hidden weak thunk each calling object emits for each signature it calls f with; the linker keeps one. A signature digit says an argument is one integer word, not whether it is 32 or 64 bits wide: snprintf(buf, n, "%x", (uint32_t)u) and snprintf(buf, n, "%lx", (uint64_t)v) share FI308296_snprintf. A thunk must therefore copy every integer word whole. clang's do; nanofil's copied a stack word at the width of the call it was emitted for, so its thunk for a 32-bit call, chosen by the linker, truncated other objects' 64-bit arguments, clang's included (xz's -lvv CRC64 values, found by the xz check; fixed in d1d5986).

Symbols the linker merges

An audit of what nanofil emits that the linker can merge with clang's, or with another nanofil object's. The weak thunks are the only kind. nanofil emits no comdat or linkonce section, and nothing else is .weak:

Symbol Binding Shared?
pizlonatedFI<Sig>_<f>, call thunk (callee elsewhere) hidden, weak yes, by name and signature, with clang's and nanofil's
pizlonatedFI<Sig>_<f>, call thunk for a local f (a static variadic one, or an unprototyped call) local no: each object's static f is its own, as clang's thunk is local too (tests/c/prog/static-thunks.c; potrace's two static ships once merged)
pizlonatedFIN<R>_<A>_<f>, thunk for more than 16 words hidden, weak among nanofil objects only (clang calls FI0_<f>)
pizlonatedFI<Sig>_<f> / FIP<Sig>_<f>, definition entries global or local no: one definition
pizlonated_<f> getter, pizlonatedFO_<f> function object global or local no
pizlonatedTP_<x> thread-local key, init forwarders global or local no
string data, origins, check-failure data local (.L) no

clang also emits linkonce_odr pizlonated1ET<Sig> / 2ET<Sig> entrypoint thunks, keyed by signature alone. nanofil emits neither name, so nothing of nanofil's merges with them.

A shared call thunk is safe when it does the same thing for every caller that can reach it. Callers of one f can legally differ only in words passed past a prototype: variadic arguments, or unprototyped calls. Differing parameter or result types are undefined (C11 6.5.2.2p9), and they mostly differ in signature digits anyway. Within one digit, the thunk now:

For more than 16 words clang names its call thunk FI0_<f> (signature 0) and the callee's entry FIP0_<f>. nanofil names the entry FIP18446744073709551615_<f> and also exports FI0_<f> as the definition's generic entry, which overrides a clang caller's weak FI0_<f>. A function object's signature then differs from clang's (never against 0), so an indirect call between the two takes the generic path. That path is slower but correct.

tests/c/prog/shared-thunks.c links a nanofil and a clang object that call each other's variadic, mixed and two-word-result functions, snprintf, and 18-parameter functions both directly and through a pointer. Their 32-bit and 64-bit words meet in one thunk, so the test fails without d1d5986. shared-thunks-clang.c links clang's object first, so clang's thunks serve nanofil's calls. vararg-thunk-width.c is the single-thunk regression.

Weak C symbols are covered in Weak symbols below.

Inline asm

At the pin the pass (handleInlineAsm) passes through an empty asm whose operands hold no pointers, and a validated AT&T asm without pointer operands or result. Anything else, in particular an asm with a memory (m) operand, whose operand is a pointer, becomes a call of the runtime's error with the message cannot handle inline asm (inline assembly with pointer argument is not supported): ..., followed by a fake load for its result: the program compiles and stops with a safety error only if it runs the asm. The checks run in this order, each probed at -O0 and -O2: asm goto (asm goto is not supported), a pointer result (inline assembly with pointer return type is not supported, also for "+r" on a pointer), a pointer argument (a memory operand: an output constraint with m or g, an input "m"; or a pointer input), then validateSafeInlineAsm's constraints (an input "g", "rm", "i" or "n" is unsupported constraint for safe inline asm: imr, LLVM's spelling), clobbers, and each line's mnemonic, lines split at newlines and semicolons, blank ones skipped, the mnemonic lowercased and a label such as 1: counting as one. The message goes on with the call's LLVM IR, attribute group and !dbg numbers included.

nanofil passes no instruction through. The empty asm is compiled when its operands are values ("+r", "+g", "=r", "r", "g", "i", "n"; "memory" and "cc" clobbers). An asm that filcc certainly refuses becomes a call of zsafety_error with filcc's message up to its colon, then the template instead of the IR: asm goto without pointer operands, a pointer result, a pointer argument, and an asm whose operands and clobbers the probe saw pass validation (outputs "=r", "+r", "=a"; inputs "r", "a", digits; "memory", "cc") and whose first nonblank line's mnemonic is one filcc refuses whatever its operands (c_types filcc_refused_mnemonic/1: int, int3, syscall, hlt, lock, rep, push, call, .byte and others). An asm whose value operands have a constraint the validator's first loop refuses becomes the same call with unsupported constraint for safe inline asm: C, C being the first such constraint in clang's IR spelling (outputs, then inputs; g is imr, a fixed register letter {ax}...): longlong.h's umul_ppmm ("rm") and sub_ddmmss ("rme"), which coreutils' factor reaches above 2^64 and filcc's factor traps on too. Everything else is refused at compile time: cpuid, rdtsc and the fences, which filcc may pass through; a constraint whose lowering nanofil does not know ("=A"); an unknown mnemonic, which only filcc's full table shows to be unknown; asm goto with a pointer operand, which crashes clang's Fil-C (SelectionDAGBuilder's getCopyToParts assertion). tests/c/prog/asm-refused.c compares the reasons with filcc's ("// trap-reason").

Function objects name a local alias

A function object's third word is its fast entry. clang writes .quad .LpizlonatedFIP<Sig>_<f>.local, a local symbol .set to the entry, for exported and weak functions, and the entry itself only for a static one. The global entry symbol is preemptible in a shared object, so a word naming it gets a dynamic relocation against the name, which the dynamic linker binds to the first definition it loaded. netcdf's NCZarr opens every HDF5 plugin with RTLD_GLOBAL, each defines H5PLget_plugin_info, and until nanofil wrote the alias each plugin's object called the first plugin's function: every filter had deflate's id 1, and the tests failed with "undefined filter encountered" where filcc's passed. Calls still go through the PLT in both compilers, as C's interposition rules allow; only the function object is bound at link time. tests/c/prog/function-object-local.c compares the words with clang -S ("// asm-lines").

Top-level asm

compileModuleAsm reads the module asm a line at a time and rewrites .symver L, V to .symver pizlonated_L, pizlonated_V, versioning the getters; blank lines vanish, its own .filc_* directives change linkage or names, and any other text stops clang (llvm_unreachable). The survey's packages use only .symver (xz's and libapparmor's compatibility symbols) and an empty asm("") (libressl's LCRYPTO_ALIAS). nanofil prints the same .symver lines after the module (main.pl's cc, not an IR item) and refuses every other line at compile time. tests/c/prog/asm-symver.c compares the lines with clang -S ("// asm-lines").

Weak symbols

__attribute__((weak)), #pragma weak and weak aliases, as clang lowers them at the pin (FilPizlonator.cpp, observed with -S on hook, wdata, maybe, mdata):

nanofil does the same. The IR got three linkages (ir.pl):

c_lower gives a name declared weak anywhere in the unit the weak linkage on its definition, or an extern(weak) declaration if the unit uses it without defining it. filc_abi routes a weak definition's calls and addresses as an external symbol's, and a weak declaration's address through weak_getter. filc_amd64 emits the GOT test in front of the getter call and in the weak thunk.

A weak alias is a weak function of its own name that calls the target, as nanofil's strong aliases are, so its address seen from another object is not the target's. Weak static and weak thread-local names are refused.

tests/c/prog/weak-symbols.c links a nanofil and a clang object that each have weak definitions used alone and overridden by the other's strong definition, a name weak in both (both objects must see one definition), #pragma weak, weak aliases, a weak reference that the other object resolves, and unresolved weak functions and data tested for null, called only if present, and whose addresses are taken in code and in static initializers. weak-symbols-clang.c links clang's object first. Without weak linkage the overrides are duplicate definitions and the unresolved references are undefined, so both fail to link.

Alignment above 16

Read in filc_runtime.c (allocate_aligned_impl, prepare_allocate) and FilPizlonator.cpp (allocateObject) at the pin, and checked against filcc -S output.

Heap objects. filc_object* filc_allocate_with_alignment(filc_thread* thread, size_t size, size_t alignment) returns the object header, and the payload is header + 16, aligned to alignment (at least 8). The size rounds up to 16, the allocation is size + max(alignment, 16) bytes from verse_heap_allocate_with_alignment, and above 16 its base holds a filc_alignment_header (log2 alignment in the high 16 bits) for the GC's object scans. An alignment above the heap's maximum fails FILC_CHECK(... "alignment %zu too big"). clang's allocateObject calls filc_allocate (or allocates inline) when the alignment is at most GCMinAlign (16) and filc_allocate_with_alignment otherwise. nanofil lowers alloc(Align) with Align ≥ 16 to filc_allocate_with_alignment(thread, size, Align) and smaller ones to filc_allocate. A local object is a frame slot (local_alloc) only if its alloc asks for 16 or less (local_storage.pl), since a frame slot is 16-aligned.

Data objects. clang aligns DO_g to the payload's alignment A and emits A − 16 zero bytes before the header, so the payload starts at DO_g + A:

    .p2align    6, 0x0
pizlonatedDO_x:
    .zero   48
    .quad   pizlonatedDO_x+72          # upper
    .quad   0+281474976710656          # aux: GLOBAL, no alignment bits
    .long   1                          # payload, at DO + 64

The getter returns DO_g + A, and filc_global_initialization_start gets the header, DO_g + A − 16. The aux word is the same as for a 16-aligned global: globals carry no log2 alignment. nanofil emits the same bytes but puts its DO_g label after the pad, at the header. DO_g is local to its module, since other modules reach a global through its getter, so only the label's position differs, and with it the symbol's .size (the payload plus header rather than the pad too).

Bounds. The capability's lower is the payload in both cases, so the pad, and on the heap the alignment header, are never in bounds. tests/c/prog/over-aligned-trap.c and over-aligned-trap-global.c read the byte below an aligned(64) local and an aligned(64) global, and both trap under nanofil as they do under filcc. The GC sees a global through its header, registered by the getter, and never scans the pad.

tests/c/prog/over-aligned.c (with lib/over-aligned.c) checks alignments from 32 to 4096 across a nanofil and a clang object: globals, a pointer-holding global whose relocations follow the pad, locals passed to the other side, an aligned(32) member, and aligned typedefs. The Nix check runs it under FUGC_SCRIBBLE and FUGC_VERIFY. Thread-local objects already pass their alignment to filc_allocate_thread_local. nanofil refuses alignments above 4096, a pointer-holding member aligned below 8 (Fil-C keeps capabilities only at 8-aligned addresses, as for packed pointers), and an aligned request on a bit-field, which clang honours by starting the bit-field at a boundary of that alignment even when its type already has it. Arrays of an aligned typedef whose element size is not a multiple of the alignment, which clang rejects, are still accepted.

A pointer cast through a 64-bit integer

filcc gives (T *)(uintptr_t)&g, a pointer cast to a 64-bit integer and straight back, &g's capability, at -O0 and -O1, at run time and in a static initializer: *p reads g (checked October 2026 against the pinned filcc). nanofil's run-time lowering already did; a static initializer now does too (c_lower address_constant/5), so lighttpd's static connection *const sentinel = (connection *)(uintptr_t)&queue compiles. A round trip through a narrower integer stays refused, as clang refuses it ("initializer element is not a compile-time constant"). tests/c/prog/uintptr-roundtrip.c.

Cleanup and a returned struct (NRVO)

__attribute__((cleanup(F))) runs F(&v) after a return's value is taken. For a function returning a struct, clang returns a named local in place (NRVO, into the caller's sret buffer or the return registers), so a cleanup on the returned variable itself sees, and can change, the object the caller receives: struct pair p __attribute__((cleanup(zap))) = {7, 8}; return p; returns {-1, 8} under filcc when zap sets p->a = -1. nanofil copies the returned struct before the cleanups run, so the caller gets {7, 8}. GCC documents neither; code that relies on it is mutating the value it returns after returning it. Scalars and pointers agree: both compilers read the value first (g_steal_pointer depends on that, and tests/c/prog/cleanup.c checks it).

Attributes

c_types known_attribute/3 lists every GNU attribute nanofil accepts: hints that change nothing nanofil emits, and attributes it implements. c_parse refuses any other where it reads it, in __attribute__((...)) and in [[gnu::X]], [[clang::X]] or a vendor's [[v::X]], with "attribute X is not supported". Clang warns about and ignores an attribute it does not know; nanofil refuses it too, since an attribute it once dropped (cleanup, mode) silently changed what the program did.

Sections

__attribute__((section("S"))) changes nothing under filcc. In FilPizlonator (filc-staging-ref-src, llvm/lib/Transforms/Instrumentation/ FilPizlonator.cpp, where it builds pizlonatedDO_<name> and the new function), neither the data object nor the function gets the original global's section: no setSection, no copyAttributesFrom. For tests/c/prog/section.c filcc's object has no .mydata, .myrel, .myrodata, .mybss or .text.mine; pizlonatedDO_counter and pizlonatedDO_zeroed are in .data and pizlonated_bump is in .text. nanofil's object places them the same way: data objects in .data and .bss, functions in .text. Fil-C code cannot observe a section either: extern int __start_S[] is the Fil-C symbol pizlonated___start_S, which nothing defines, so filcc's link fails. nanofil accepts the attribute (c_types known_attribute/3, honored) and emits what filcc emits. CPython puts _PyRuntime in .PyRuntime only for debuggers.