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}.mdThe 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):
- Descriptor local alias (
d249619, nowf708c342): binds clang's descriptor to its module's implementation; no descriptor layout or entry signature change. Nanofil's existing function objects still work. - Getter cache (
51e7f8c, now671bf4da): optional module-private caller optimization. Getter arguments/results and initialization remain compatible; nanofil continues calling getters, without this cache. - Aggregate varargs (
df06cdc, now0ded64b): new aggregate packet lowering/intrinsic, not a scalar packet ABI change. Nanofil rejects aggregate va_arg and unnamed aggregate arguments; scalar varargs retain the same helper/packet contract. - Safepoints/may-free (
e714461): changes clang's analysis and adds non-exiting allocation helpers. Nanofil retains conservative roots, outline allocation and fresh access/aux checks, not cached not-free or aux facts across helpers/polls. No changed runtime calling sequence is required for what it emits.
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.
- Entry:
cmpq %rsp, (%rdi); jae filc_stack_overflow_failure. - Frame push:
frame = {parent = thread->top_frame, origin, lowers[N]}on the stack, thenthread->top_frame = &frame(offset 16). Lowers that have not been written yet are zeroed. Restore the parent before every return. - Pollcheck:
testb $14, 8(%rdi); jne slow, where the slow path callsfilc_pollcheck_slow(thread, origin). - Read check (S bytes):
lower != 0,ptr >= lower,ptr + S <= upper(upper = -16(lower)). A failure callsfilc_optimized_access_check_fail(ptr, lower, &check_origin). - Write check: also
testb $6, -2(lower)(READONLY | FREE). - Pointer load: check 8-byte alignment, then
auxp = -8(lower) & 0xFFFFFFFFFFFF. Ifauxpis 0, the lower is 0. Otherwisew = auxp[ptr - lower], and ifw & 1, the word is an atomic box and the lower is the 8 bytes at(w & ~1) + 8. Then load the address fromptr. - Pointer store: check alignment and write
permission. If
auxpis 0,auxp = filc_object_ensure_aux_ptr_outline(thread, lower - 16). If the stored lower is not 0 andfilc_current_marking_state(GOT, 32-bit) is not 0, callfilc_store_barrier_for_lower_slow(thread, stored_lower). Then store the lower toauxp[ptr - lower]and the address toptr.
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.
- The getter
pizlonated_g(thread, origin)returns ptr in RAX and lower in RDX. Its fast path loads both cache words and takes the slow path if either is 0. The slow path callsfilc_global_initialization_start(thread, origin, &GP_g, DO_g). startwritesoriginintothread->top_frame->originwhen it is not NULL, so the caller must have its frame pushed. It checks the stack limit, takes the initialization lock, and registersDO_ginfilc_global_variable_roots. It returns true only to the initializer.- The initializer runs
filc_execute_constant_relocations(thread, DO_g, relocs, n). Each 24-byte relocation is{u64 offset, u32 kind, pad, target}: kind 0 is a getter, kind 1 is a constexpr node{u32 op = 0 (add immediate), u32 left_kind, left_target, u64 offset}. Each one writes the address into the payload and the lower into the aux array at the same offset. It asserts both slots are still 0, so pointer slots in the static initializer must be emitted as 0. Then the initializer callsfilc_global_initialization_end(thread), which publishes the cache. - Nested getters run from relocations get
origin = NULL. A getter can run arbitrary initialization code, so a call to a getter is a safepoint like any other call: everything live must be in the frame first.
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.
- Each eightbyte of a struct or union of 16 bytes or less is
one word of the signature: a ptr word (digit 7) if a pointer
starts there, an i64 word (digit 0) otherwise, even when the eightbyte
is short. So
struct {int; char}is one i64,struct {int; int; char *}is (i64, ptr), andunion {char *; long}is ptr. Arguments are those words in order:take_mix(struct mix)isFIP11839with the packed ints in RDX and the pointer in RCX:R8. A one-word struct return is the same as a scalar return:struct {char *}andchar *are bothFI1073. - Returns of two words with no pointer come back like
a scalar pair:
struct {long, long} f(long)isFIP146(ret digits [0, 0]), with AL = has_exception and the words in RDX:RCX. - Returns with a pointer field need more than two
return registers, so they come back in memory. The
caller passes a buffer in RDI, so the thread moves to RSI, the callee's
lower to RDX and the argument words start at RCX (then R8, R9, the
stack). The callee writes
has_exceptionas a byte at buf+0 and the slots from buf+8, a pointer being address then lower:{char *p; long n}is{p@8, lower@16, n@24},{char *p, *q}{p@8, lo@16, q@24, lo@32}. The caller testscmpb $0, buf. The signature still encodes just the result words:{ptr, i64}=FI1084,{i64, ptr}=FI1154,{ptr, ptr}=FI1161. - Larger returns are an sret pointer argument,
exactly as if written by hand:
struct {long a, b, c} f(long)has the signature ofvoid g(struct three *, long),FI2528. The caller allocates the object. - Larger arguments are byval:
take_three(struct three)isFIP0and reads its argument from the generic buffers. See Byval arguments below for what nanofil does with them. - Generic return of a struct: word i (an eightbyte) at thread+128+8i with its lower at thread+384+8i, and the returned size is 8 × the number of words (16 for a two-word struct); callers check for at least that much. A generic entry for a buffered return calls the fast entry with a buffer on its own stack and copies the slots out.
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:
- Any byval parameter makes the function
generic-only:
computeSignaturereturns the generic signature 0 for a non-direct argument, so the function object hasfast_entry0 and signature 0, the generic entry ispizlonatedFIP0_f, andpizlonatedFI0_fis its alias. Callers of a byval function always go generic: direct external calls through the weakpizlonatedFI0_fthunk (getter, function check, then a tail jump to*8(lower)), indirect calls straight to the generic entry with RDX = the packet size. - Packet layout (
iterateArgs): each argument starts at an offset aligned to max(8, its ABI alignment) and advances by its alloc size; the total rounds up to 8. Payload at thread+128+off, aux at thread+384+off, inline up to 256 bytes.take2(long, struct{long; char *; long}, long)needs 40 bytes: the entry testscmpq $39, %rdx; jbeand callsfilc_cc_args_check_failure(actual, 40, origin). A 17-byte struct takes 24 bytes. So for alignment up to 8, a byval struct's eightbytes are consecutive 8-byte cells, the same cells a caller fills when it passes those eightbytes as separate words. - Caller (
storeCC): it copies the struct into a fresh heap object, zeroes payload and aux buffers on its stack, and demotes the object into them withfilc_demote_word_aligned_already_checked_heap_to_stack_without_exiting(types with pointers) orfilc_demote_already_checked_heap_to_stack_without_exiting, which write each pointer word's lower into the aux cell. Then both buffers are copied to the thread. - Callee (
loadCC): it copies the packet to its stack and callsfilc_promote_already_checked_stack_to_heap_without_exiting(thread, payload, aux, size rounded to 8)(the_with_alignment_variant above 16), which returns a new heap object carrying payload and aux. That object is the parameter, rooted by the callee. - Variadic calls put a byval struct in the packet the
same way:
callv(fmt, big24, long, small16)passes 56 bytes. An aggregateva_argreads withfilc_get_next_ptr_bytes_for_va_arg(size). - A byval struct with a pointer whose size is not a multiple
of 8 crashes filcc:
storeCCasserts!(DL.getTypeAllocSize(AI.T) & (WordSize - 1))before the word-aligned demotion (FilPizlonator.cpp:6804 at the pin), so passingstruct __attribute__((packed)) { char c; int *p; }(9 bytes) by value aborts clang ("Assertion ... failed" in FilPizlonatorPass), at -O0 and -O1. Only a packed or#pragma packstruct can have that size. nanofil compiles it, its eightbytes rounded up as for any byval struct; the pointer at 1 has no capability in the packet (it starts in no eightbyte), as it has none in clang's copy for a 16-byte one ({ char c; int *p; char d[7]; }), whose callee traps reading it ("alignment requirement of 8 bytes not met") under both compilers. Not yet reported upstream.
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):
sizeof(long double)is 16,_Alignof16,LDBL_MANT_DIG64. Data holds the 80-bit value in its first 10 bytes and zeros after:0.1Lis.quad 0xcccccccccccccccdthen.short 0x3ffb, padded to 16.- A load or store is a 10-byte access with 8-byte alignment (the check
is
addq $-10against the upper bound andtestb $7), thenfldtorfstpt. - Fast convention. Signature digit 3
(
FilPizlonator.cpp's x87 digit). An argument goes on the native stack in a 16-byte slot: the caller writesfstpt (%rsp), the callee readsfldt 16(%rbp), beside integer words in registers:ld(long double, int)isFI2000, with the int in EDX. A result comes back in ST0 besidehas_exceptionin AL:long double f(long double *)isFI1069, and a struct of one long double is returned as{i1, x86_fp80}, also in ST0 (FI537). - Generic convention. A long double is one 16-byte
cell: the generic entry of
ld(long double, int)checks a 24-byte packet, reads the value withfldt 128(%rdi)and the int at thread+144. A cell holding one starts at a 16-aligned offset: after anint, a long double is at +16. A generic return writesfstptat thread+128, zeroes bytes 138–143 and the aux word at +384, and returns size 16. - Structs. A struct or union holding a long double is
SysV class X87 or MEMORY. As an argument it is generic-only
(
FIP0), as is any function taking one; a 32-byte one returns through sret. - Variadic.
va_arg(ap, long double)isfilc_get_next_ptr_bytes_for_va_arg(ap, 16, 16), thenfldtfrom the returned address. A variadic call's packet holds each long double in a 16-aligned 16-byte cell; clang calls even a variadic callee through apizlonatedFI<Sig>thunk whose generic fallback lays the packet out so. An aggregateva_argof a struct of long doubles takes its size at alignment 16 at once.
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:
- Clang retains x86-64's
__builtin_va_list: an array of one 24-byte, 8-aligned record{unsigned gp_offset, fp_offset; void *overflow_arg_area, *reg_save_area;}. Fil-C uses only the first word as a capability-bearing cursor, not the native SysV register-save protocol. The other words remain unused. Array decay, array stride, sizeof and alignment must still be those of clang's record. FilPizlonator.cpp:usesVariadicCCselects signature zero for a function usingva_start(not for a fixed-signature helper merely usingva_arg). Its function object has fast entry NULL, generic entrypizlonatedFIP0_name, signature 0. Calls still use call-site-signature thunks; the mismatch sends them through the generic entry. All scalar arguments occupy eight payload bytes each, including int; a pointer's lower occupies the corresponding auxiliary slot, not another payload slot.- Entry lowering around lines 17283–17323 computes the fixed-prefix
size, calls
filc_promote_args_to_heap(thread, incoming_size), roots its lower in a dedicated frame slot, and retains snapshot + fixed size for everyva_start. Promotion must precede body calls, even calls before the first start or on paths not executing start. The helper copies inline or outline CC data/aux into a readonly heap object, resolving boxed auxiliary entries. Its source isfilc_runtime.c:promote_cc_to_heap(around 6214–6260); incoming pointees are rooted by the caller, and the helper tracks the new snapshot during allocation/copying. - Intrinsic lowering around lines 7950–7969 makes start a checked pointer store, copy a checked pointer load and store, and end a no-op. Copying the cursor gives independently advancing lists.
VAArgInstlowering around lines 14308–14342 callsfilc_get_next_ptr_bytes_for_va_arg(list_address, size, alignment). For int these are 4/4; for long and pointer, 8/8. The runtime helper (around 2468–2530) loads the list pointer with capability/alignment checks, aligns its cursor up to max(alignment, 8), checks the requested read against the cursor capability, checks write access to the list, and updates only its address (the lower stays unchanged). It returns payload address and auxiliary-slot address, not a flight pointer. These must be consumed immediately without a safepoint. A pointer load resolves a tagged atomic box if present and roots the loaded lower; integers load just their requested bytes.
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:
- Before the change,
tests/c/variadic.cfailed at__builtin_va_arg is not supported; the interop fixture failed atunsupported instruction va_args. The old source tree was rerun from an archive, not inferred from the patch. - The C difftest compares interpreter, nanofil and clang for int,
long, pointers, independent va_copy cursors, repeated start, calls
before start, a passed va_list, indirect calls, fixed pointers across
the register/stack split, pointer-bearing struct returns, exhaustion and
a forged pointer dereference. Runs pass normally and with
-O, also underFUGC_SCRIBBLE=1 FUGC_VERIFY=1. tests/filc/run-c.sh variadiclinks seven cross-compiler cases: both call directions, passing lists both ways, pointer return, exhaustion, and a function-pointer call. It forces collections before and after extracting a pointer and passes with scribble/verify, at both levels. The first collection precedes even the va_args instruction, exercising the hidden entry root alone. Reducing nsum's emitted origin lower count from four to three (excluding that hidden root without changing the stored pointer) made case 0 fail with exit 133 andfilc safety errorunder scribble/verify. The unmodified assembly passes.- GNU hello 2.12.3 was configured using the pinned filcc with
--disable-nlsand built withmake V=1. Reusing make's flags withclang -Ereproduced the old errors atlib/fcntl.c:209(start) andlib/version-etc.c:208(arg). Both preprocessed translation units now compile withnanofil ccand assemble with GNU as. Replacing those two objects in libhello.a and relinking produced workinghelloandhello --version, including--versionunder scribble/verify. Make's relink log confirmed it reused the nanofil objects, without compiling them again with clang. - Existing accepted fixture/program assembly outputs were compared byte-for-byte against the unchanged compiler: 27 identical outputs. The full Nix check covers both Prolog hosts, the existing corpus and the new variadic tests.
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:
FilPizlonator.cpp:1130–1143assigns fast type digits float = 1, double = 2 (integer = 0, pointer = 7). The existing bijective base-11 signature formula applies:float(float)is FI269;double(double)is FI403.normalizeArgType(6074) only widens small integers: floats retain their type and are never normalized to integer words.fastFunctionTypeForSignature(13503) prepends thread and callee lower, and returns{i1 has_exception, float|double}. Scalar floats use the independent SysV XMM0–7 argument bank and return in XMM0, with the exception flag in AL. They consume no integer argument register. Stack overflow from either bank follows source order; pointer halves still split across R9 and stack. These transformed fast functions are not variadic: SysV's vector-count AL applies only to ordinary plain variadic calls, not Fil-C generic calls.iterateArgs(6030) rounds each generic argument position to eight bytes.storeCC(6516–6543) zeroes the entire payload and aux buffers before typed stores. Thus an outgoing s slot is low four payload bytes plus four zero bytes and eight zero aux bytes; d is eight payload bytes and eight zero aux bytes. Generic fallback reinterprets bits, without numeric conversion. Preserve argument/result size checks.- Ordinary scalar memory stores differ from generic marshalling:
storeValueRecurseAfterCheck(2551) stores only payload for types without pointers. A float overwrite leaves any previous aux alone, like an integer store. No pointer barrier or aux allocation is needed. Loads/stores check four/eight bytes at alignment one, including the existing write permission checks; s must not read eight bytes. va_arg(14308–14340) uses type size/alignment, then immediately loads the payload.filc_runtime.c:2468–2530rounds alignment up to eight, checks the read and cursor write, and advances the cursor. Passing 4/4 for s or 8/8 for d to the existing checked helper works; ignore its aux result and consume payload before a safepoint. C default argument promotion still passes float as double.
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:
tests/fixtures/float.ir: arithmetic, NaN/unordered comparisons, casts, signed and unsigned conversions (including above 2^63), s/d memory and static data, phis and simultaneous GPR/XMM spills.tests/filc/run-c.sh float: clang ↔︎ nanofil scalar parameters and returns, mixed spills, direct and indirect calls, generic bitwise mismatch in both directions, zero single-precision padding, attempted float-to-pointer forgery (traps), double va_arg in both directions, short varargs and invalid memory accesses (trap), and buffered tuple returns with float arguments through fast and generic adapters. The harness forces collections while pointer arguments/snapshots are live; run withRUN_ENV='FUGC_SCRIBBLE=1 FUGC_VERIFY=1'on both hosts.- Both new modules fail on the pre-M9 backends after the IR grammar
lands:
backend cannot emit $floatsandFil-C backend cannot emit $nfloat. A plain SysV nine-double variadic probe gives 45 on both hosts; planting AL=0 gives 9, catching a missing vector-register count. - Scratch native differential checks: 368 arithmetic/comparison/unsigned conversion bit patterns agree with QBE (including NaNs, infinities, subnormals and signed zero), and 896 conversion cases agree with C and QBE, on both hosts. Existing accepted fixture and C input assembly remains byte-identical (35 inputs).
Setjmp and longjmp
Verified in staging 35faf87, not inferred from libc
conventions:
FilPizlonator.cpp:3439assignsSetjmps[CI] = FrameSize++after all ordinary/stack-aux slots; origins at 1764–1782 encode the count. The type at 16695 has{node, ptr, i8, i8, i32, i32}, agreeing withfilc_runtime.h:465–521and the 48-byte table above.- Lowering at 8583–8603 calls
filc_jmp_buf_recreate(thread, &slot, kind, mask), stores its flight pointer into the user buffer and calls native_setjmpin the caller. No weak-map helper remains in this path. Kind numbers remain setjmp=0, _setjmp=1, sigsetjmp=2. filc_runtime.c:7981–8064allocates/root-initializes a site's jmpbuf once, reusing its identity thereafter. Every execution overwrites its saved lowers with barriers and updates its native destination. All user buffers from that site alias its latest execution (#335).filc_native_zlongjmp(8066–8139) searches the highestnum_setjmpslowers for the exact jmpbuf, rejects expired destinations withcannot longjmp unless the setjmp destination is on the stack., unwinds intermediate frames and restores ordinary lowers, skipping the site slots. Native stack payload slots are not restored.- Staging
af813053ecb5resets the signal-mask policy on every recreation, fixing same-site mask 1→0 reuse without changing this ABI; see setjmp.md. Both toggle directions are regression-tested.
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:
filc_object_ensure_aux_ptr_outlinecan exit (a safepoint): for an object larger thanFILC_MAX_BYTES_BETWEEN_POLLCHECKSit zeroes the new aux betweenfilc_exit_with_allocation_rootandfilc_enter_with_allocation_root(filc_runtime.c:2575-2590), andfilc_exitruns deferred signal handlers and the pollcheck callback (filc_runtime.c:1231-1266).filc_store_barrier_for_lower_slowdoes not exit (barrier_impl, filc_runtime.c:2278).free_implsetsupper = lowerand the FREE flag but keeps the aux pointer (filc_runtime.c:3172-3195). Frame lowers are marked with plainmarker.mark(filc_runtime_inlines.h:459), so a freed object a frame still roots is not reclaimed; heap references to freed objects are redirected tofilc_free_singleton(fugc.h:162-170).filc_allocateexits only for sizes aboveFILC_MAX_BYTES_BETWEEN_POLLCHECKS(filc_runtime.c:2919, 2954-2957).
Nonescaping LocalExplicit storage
Verified against the same staging pin, not inferred from heap lowers:
FilPizlonator.cpp:17626-17655gives each nonescaping alloca a payload and separate aux.alignedAllocaSizerounds physical storage toGCMinAlign(16); payload alignment is at least 16, aux alignment is 8. LocalExplicit adds one word before aux for its count header.initializeNonescapingAlloca(8276) stores the physical word count, recordsAuxAllocain anExplicitStackAuxframe entry, and zeroes both payload and aux. LocalNaked instead replicates individual lowers into ordinary frame roots. Nanofil uses only LocalExplicit, initializes before publishing the root, and retains exact logical bounds.filc_runtime.h:425definesfilc_stack_auxassize_t num_lowersfollowed byvoid *lowers[]. The origin's special root range is[num_lowers_ish - num_setjmps - num_stack_auxes, num_lowers_ish - num_setjmps)(2189-2206).filc_mark_outgoing_ptrs_in_frames(filc_runtime_inlines.h:413-460) distinguishes that range before converting a lower to a heap object. It skips null stack-aux roots, otherwise scans precisely the header's word count. Registering the native aux address as an ordinary heap lower is invalid; it is not a heap object with a hidden header.
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).
-O0is deterministic.-O1and-O2are not: four compiles, four objects.- Under
setarch -R(no ASLR) three-O2compiles are byte-identical, so the output depends on where objects are allocated. -mllvm -print-changedruns first differ inFilPizlonatorPass's output: thefilc_optimized_access_check_originconstants come out in a different order (.38/.39swap their write flag inLZ4_compress_fast_extState_fastReset).AccessCheck::operator<(FilPizlonator.cpp:737) orders checks first byCanonicalPtr < Other.CanonicalPtr, aValue*comparison, andcanonicalizeAccessChecks(5229)stable_sorts by it;emitChecks(3568) then emits one group per pointer in that order. So the order of check groups, their fail blocks and their origins follows heap addresses, and later passes (block placement, register allocation) turn it into different code and speed.
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).
- Clang emits the same code for every order. It checks the slot inline
(8 bytes, 8-aligned, write rights for a store; a store also ensures the
object's aux inline, calling
filc_object_ensure_aux_ptr_outline), sets the frame's call-site origin and calls a helper. No fence is emitted outside the helper, and none follows it. filc_ptr filc_load_ptr_atomic_with_manual_tracking_outline(filc_ptr p):pin%rdi(address) and%rsi(lower), the result in%rax/%rdx. It checks again, reads the address, does a load-load fence, then reads the slot's lower-or-box: a box's 16-byte pair is read atomically and returned whole. It takes no thread and cannot exit. The caller roots the result in a frame slot; there is no tracking call.void filc_store_ptr_atomic_outline(filc_thread *t, filc_ptr dest, filc_ptr value):tin%rdi,destin%rsi/%rdx,valuein%rcx/%r8. It checks again (bounds, alignment, read-only), ensures aux (this may exit), runs the store barrier on the value, stores the pair atomically into the slot's atomic box (allocating one the first time), does a store-store fence, then writes the raw address. A box is published with a seq_cst store.- Plain accesses interoperate: a plain load reads a box's lower, and a plain store replaces the box with a plain lower.
- An integer seq_cst store is
xchg; other integer orders and all integer loads are plainmov.
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.
- Integer
atomicrmwandcmpxchgstay native: an inline write check (bounds, write rights, alignment to the width), thenlock xadd,xchg,lock cmpxchgor a cmpxchg loop. A misaligned one traps: "alignment requirement of 4 bytes not met". - Pointer
cmpxchgcallsfilc_strong_cas_ptr_with_manual_tracking(thread, dest pair, offset, expected pair, new pair). Success is address equality, unboxed or boxed; the old pair comes from the slot. The helper ensures aux (it may exit) before the store barrier of New. - Pointer
xchgcallsfilc_xchg_ptr_with_manual_tracking: an atomic load, then a weak-CAS loop. atomic_fetch_addon an_Atomic(T*)is an integeratomicrmw addon the address word with a null-capability result. On a boxed slot the update is lost for atomic readers (they read the box's pair).++/--on an_Atomic(T*)is an atomic load and a loop offilc_strong_cas_ptr_with_manual_tracking; the result keeps its capability. clang refuses+=on one.- GNU
__atomic_fetch_addon a pointer breaks filcc's module ("atomicrmw add operand must have integer type"). - An under-aligned (packed) member goes through libatomic's
__atomic_loadand__atomic_compare_exchangecalls.
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.
- A thread-local
xkeeps the ordinary getterpizlonated_x, called with the thread in%rdiand returning the pointer in%rax/%rdx(both the payload address). Other translation units call it as for any global (pizlonated_y@PLTfor anextern _Thread_local int y); nothing else aboutxis visible to them. - The getter loads
pizlonatedTP_x, an 8-byte.tbssword withx's linkage, through%fs(@GOTTPOFFfor a visible symbol,@TPOFFfor an internal one). It holds this thread's payload address, or 0 before the thread first asks; then the getter calls the private slow pathpizlonatedGS_x(thread). - The slow path calls
filc_allocate_thread_local(thread, size, align), orfilc_allocate_thread_local_with_ptrs(which also ensures the aux array) when the type holds pointers, with the type's alloc size and alignment. The runtime allocates an ordinary heap object (notGLOBAL, not read-only even forconst) and pushes it on the thread'sthread_localsarray, which roots it for the collector until the thread exits. The slow path stores the initializer into the fresh object (pointers with the store barrier), writes the payload address toTP_xand returns it. - Getter results are never cached (
shouldCacheGetterexcludes thread-locals), since each thread gets a different pointer. &xis not an address constant: clang refusesint *p = &x;with "initializer element is not a compile-time constant" (C11 6.6p9).
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.
- Each constructor gets a private native function
.Lfilc_ctor_forwarder[.N]:lea pizlonatedFO_f+16(%rip), %rdi; mov %rdi, %rsi; jmp filc_defer_or_run_global_ctor@PLT. A destructor's forwarder jumps tofilc_run_global_dtor@PLTthe same way. The runtime takes the function object (its payload address, twice: as the pointer and its lower) and calls the function through it, so the function keeps its ordinary pizlonated entries and linkage. - The forwarder's address is the one
.quadof an.init_arrayentry (.fini_arrayfor a destructor),.init_array.Nfor priority N, as the linker sorts them: lower priorities first, then those without.
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:
- copies every integer word whole, from a register or the stack, since
wandlshare digit 0 (the d1d5986 fix above); - truncates only floats (
s, digit 1), whose upper half no caller defines, and passes doubles (d, digit 2) and pointers (p, digit 7, with their lower) whole; - builds the generic packet from the same saved words, one 8-byte cell
each, so its
ArgsSizeis the word count every caller of the digit list shares; - loads the result as whole words from the thread's return words, or copies them whole into a buffer result.
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):
- Weak definition. The pass keeps C's
weaklinkage on the getterpizlonated_<f>, the implementationFIP<Sig>_<f>and the2ETentrypoint thunk, which are all.weak. There is noFI<Sig>_<f>alias, and the function objectFO_<f>is local. Data likewise has a weak getter with a localDO_/GP_. The module's own calls and address-taking go through the getter (@PLT), and calls through the hidden weakFIthunk, as they would for an external function. The linker keeps a strong definition from another object over this one; of two weak ones it keeps the first in link order. - Weak declaration
(
extern int maybe(int) __attribute__((weak))). The pass emits.weak pizlonated_<f>. Where it needs the symbol's pointer (filc_weak_symbol_is_null) it tests the getter's GOT slot,cmpq $0, pizlonated_<f>@GOTPCREL(%rip), and uses the null flight pointer if the slot is 0, else calls the getter. The callee thunkFI<Sig>_<f>does the same test, so calling an absent weak function fails the function check, as a call through a null pointer does. A static initializer's relocation names the getter, which the runtime (filc_execute_constant_relocations) leaves null when the getter is 0. #pragma weak nmarksnweak wherever it is declared or defined in the translation unit.#pragma weak n = tand__attribute__((weak, alias("t")))definenweakly ast.
nanofil does the same. The IR got three linkages (ir.pl):
weakfor functions and data;ro(weak)for read-only data;extern(weak), a data item with no items that declares an undefined symbol weak.
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 + 64The 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.