Fil-C on Nix

Filnix wiki

b987d40

Fil-C · FilPizlonator · global and thread-local initializers · commit b987d40 on mbrock/fil-c init-misaligned-pointers, based on pizlonator/fil-c deluge 9d1ed206

Misaligned pointers in global and thread‑local initializers

A packed initializer can put a pointer at byte 1. Fil-C 0.686 aborts clang on it for a global. For a thread-local it compiles, then writes the capability across two shadow words, and the program later segfaults with no Fil-C error. The fix turns both into a compile error.

The bug and the fix in under a minute. Made with Manim (source); the page below has the details.

The program

Nothing exotic: a packed struct with a char and then a pointer, initialized with an address.

int anchor = 5;
struct __attribute__((packed)) P { char c; int *p; };
_Thread_local struct P t = { 1, &anchor };   /* or a plain global */

In C this is fine: t.p sits at byte 1, and x86-64 can load it there. Packed records that hold pointers turn up in real code, for example systemd's HashmapBase, network headers and serialized formats. Most of them never put a non-null pointer at an odd offset in a static initializer, which is presumably why this has not come up.

Why Fil-C can't represent it

Fil-C gives every pointer an invisible capability. In memory, an object's bytes are split into 8-byte words, and each word has a shadow word next to it that holds the capability of the pointer stored in that word, or null. A pointer access loads or stores the word and its shadow word together. These words are used below:

word
An 8-byte, 8-aligned piece of an object.
shadow word
The word's partner in the object's aux allocation, holding a capability or null. In the code: AuxP, the lower.
capability
What the runtime trusts to say which object a pointer may touch. A shadow word must hold either null or a real capability.
initializer
The constant value of a global or thread-local, stored before the program can see the object.

A pointer at byte 1 covers bytes 1 through 8: the end of word 0 and the start of word 1. No single shadow word belongs to it. That is fine at run time, because every pointer load and store checks that its address is 8-aligned first. t.p in the program above traps with alignment requirement of 8 bytes not met, which is the normal Fil-C answer. Initializers are different: the compiler writes them itself, without those checks.

object shadow word 0 word 1 int *p, bytes 1–8 capability written at shadow + 1 shadow word 0: capability shifted by a byte, garbage shadow word 1: its top byte
The thread-local t: what release 0.686 stores when it initializes the pointer at byte 1.

What Fil-C 0.686 does

A global: clang aborts

FilPizlonator turns a global's pointers into a list of relocations, one per pointer, that the runtime applies when the global is first used. Each relocation fills one whole shadow word, so computeConstantRelocations asserts that its offset is a multiple of 8. For g at byte 1 it isn't:

clang-20: FilPizlonator.cpp:6806: bool computeConstantRelocations(...):
  Assertion `!(Offset % WordSize)' failed.
clang-20: error: clang frontend command failed with exit code 134

This one fails safe, but as a compiler crash rather than a diagnostic. The runtime's filc_execute_constant_relocations asserts the same thing. If relocations can't be built at all, for example for an unusual constant expression, the pass falls back to storeValueRecurseAfterCheck, the unchecked store described next.

A thread-local: an unchecked store

A thread-local has no relocations. Its getter allocates the object on first use in each thread and stores the initializer with storeValueRecurseAfterCheck. As the name says, that function assumes its caller has already checked the access, so it checks nothing. For t the result is this IR:

%filc_InnerAuxP_struct = getelementptr i8, ptr %filc_aux_ptr, i64 1
store ptr %filc_ptr_lower, ptr %filc_InnerAuxP_struct, align 8
%filc_InnerP_struct = getelementptr i8, ptr %filc_thread_local_allocate, i64 17
store ptr %filc_ptr_ptr, ptr %filc_InnerP_struct, align 8

The capability goes to shadow + 1, with an align 8 that isn't true. Shadow word 0 now holds the capability shifted left by a byte: a non-null value that is not a capability. Shadow word 1 holds its top byte.

tls-shadow.c shows the effect. It initializes t and then does an ordinary, 8-aligned pointer load of word 0, where the initializer never stored a pointer, and asks whether the result has a valid capability:

void *volatile *word = (void *volatile *)&t;
void *w = *word;
printf("word0 has valid cap: %d\n", (int)zhasvalidcap(w));
BuildResult
Without packed (control)prints word0: 0x1,<null> and 0, exits 0
Packed, Fil-C 0.686Segmentation fault, no Fil-C diagnostic
Packed, with the fixcompile error, below

The load itself is legal: it is aligned, in bounds, and of a live object. It returns the garbage from shadow word 0 as the pointer's capability, and the runtime dereferences it. We have only shown a crash. We have not shown that this garbage can be turned into a working capability, or what the garbage collector does when it scans t. But Fil-C's guarantee rests on shadow words holding only null or real capabilities, and here the compiler breaks that by itself, with no help from the program.

The same applies to a null pointer at byte 1 in a thread-local, which writes zeros across two shadow words, and to arrays of packed records or unions whose stride is not a multiple of 8. union __attribute__((packed)) { int *p; char b[9]; } a[2] puts the second pointer at byte 9.

The fix

An initializer that would store a pointer at a non-word offset is now a compile error, for globals and thread-locals:

error: Fil-C cannot initialize 't': a pointer in its initializer is not word-aligned

A new helper, hasMisalignedPtrs, walks the initializer's type: struct fields at their offsets, array and vector elements at their strides. Element offsets repeat modulo 8 after at most eight elements, so a large array costs at most eight visits. Three places use it:

// computeConstantRelocations: no assertion; fall back to the checked path below
-      assert(!(Offset % WordSize));
+      if (Offset % WordSize)
+        return false;
// global initializer, the fallback after relocations fail
+      } else if (hasMisalignedPtrs(G->getInitializer()->getType())) {
+        reportMisalignedInitializer(G);
       } else {
         ... storeValueRecurseAfterCheck(...)
// thread-local initializer
+        } else if (hasMisalignedPtrs(G->getValueType())) {
+          reportMisalignedInitializer(G);
         } else {
           ... storeValueRecurseAfterCheck(...)

The error goes through LLVMContext::emitError, so clang prints it as an ordinary diagnostic and exits with status 1. What does not change:

An alternative was to make these initializers work, by storing only the bytes and leaving the shadow words null, which is what a byte copy into a misaligned place does at run time. That would silently turn &anchor into a pointer without a capability. An error seemed better: C code that needs this layout is rare, and the fix there is to align the pointer.

Tests

With a development compiler built from the commit on deluge 9d1ed206, both tests pass at -O0, -O2 and -O3, and the three reproducers each give the new error. Of the 7,006 C and C++ files under filc/tests, compiled at -O2, 6,831 compile; none hit the new error or an assertion, and the other 175 fail for unrelated reasons such as missing per-test flags, ARM-only headers and the intentionally broken badasm tests.

One commit on pizlonator/fil-c deluge: llvm/lib/Transforms/Instrumentation/FilPizlonator.cpp and two test directories. The reproducers are global.c, tls.c and tls-shadow.c. Filnix's staging toolchain has carried the same check since 2e92032. This page was written for review and is not part of the patch.