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 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.
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));
| Build | Result |
|---|---|
Without packed (control) | prints word0: 0x1,<null> and 0, exits 0 |
| Packed, Fil-C 0.686 | Segmentation fault, no Fil-C diagnostic |
| Packed, with the fix | compile 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:
- Initializers whose pointers sit at multiples of 8, including packed records such as
{ int *p; char tail[3]; }and a single 9-byte packed union. - All-zero initializers, which skip the store entirely.
- A null pointer at byte 1 of an ordinary global: it needs no relocation, so it never reaches the check. (A thread-local with the same initializer is rejected, because it does go through the unchecked store.)
- Run-time loads and stores of misaligned pointers. They were already checked, and they still trap.
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
filc/tests/initmisalignedptrfail(compileFailure): a packed global, a packed thread-local, a thread-local with a null pointer at byte 1, and global and thread-local arrays of a 9-byte packed union. The test requires the new message for each and forbidsAssertion.filc/tests/initpackedptr(success): packed globals and thread-locals with pointers at word offsets, a single 9-byte packed union, and a null pointer at byte 1 of a global still initialize and work.
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.