D159 - Every guest call was misaligned, and nobody was looking
decided ยท 2026-08-20
The one number that settles it had never been recorded: what
rsp is when the guest's call lands on us.
The trampoline is entered by a jmp from a thunk, and a
jump pushes nothing - so the very first instruction of the trampoline
sees exactly what the guest's call left behind. One
mov r11, rsp before it touches anything captures it.
r11 is the one register free to carry it: scratch under
System V, and already dead because the thunk used it to hold this
address and jumped through it.
The measurement
System V requires rsp % 16 == 0 immediately
before a call. The call pushes an eight-byte return address, so
a callee begins eight past alignment, and every
compiler does its own arithmetic from that assumption before using an
instruction that moves sixteen bytes at once.
PPSA28061, on the entry convention that was the default that morning:
abi 370 of 372 calls arrived on a MISALIGNED stack
first at #0 libc::0x92f57c2dc704346f, rsp 0x600000800f40 (rsp % 16 = 0)
Not a rare case. Not a subtle one. The first call, and effectively all of them, at remainder 0 where the convention requires 8. Off by exactly eight, which is the signature of one missing return address somewhere upstream.
The cause, and why it was invisible
Convention::Process enters the guest by jumping with
rsp sixteen-byte aligned - correct for a System V process
entry. Convention::Function enters by calling, leaving it
eight past - correct for a function. The guest carries whichever it was
handed through every frame it builds afterwards, so the choice
propagates into every call it makes back to us.
| convention | conforming calls |
|---|---|
Process |
2 of 372 |
Function |
372 of 372 |
So this target's entry point is called, not jumped to. Nothing published says that - it was established by measuring the guest, which is the oracle this project actually has.
D153 tested exactly these two conventions the same afternoon and recorded "no difference", because the only instrument then was how far the guest got, and nothing it reached had yet executed an aligned vector access. That conclusion was not wrong about what it measured; it was measuring something too coarse to see this. Worth remembering: a negative result is only as strong as the instrument that produced it.
What it cost, and what fixing it bought
D157 - sceKernelMapNamedDirectMemory faulting in host
code and parked as unexplained - was never a bug in the mapping. Every
earlier import was small enough that the compiler never needed a
sixteen-byte stack access; the mapping, which takes a mutex and builds a
vector, was simply the first one that did. It was the first
witness, not the cause.
With the convention corrected, on PPSA28061:
| before | after | |
|---|---|---|
| distinct imports | 38 | 46 |
| calls | 372 | 799 |
| fault | host code, unattributable | image+0xecda, in the guest |
PPSA25872 moved further still - from faulting at
image+0x7a having made no calls at all, to 1735 calls and a
fault eight megabytes into its own image.
The telemetry stays on, and it reports when it is clean
Two movs in the trampoline and, in the common case, no
atomic operations at all - recording must not change the program it
observes (principle 9).
The report prints every run, including when nothing is wrong:
abi 799 calls, all on a conforming stack
A line that only appeared on failure would be indistinguishable from a line nobody wired up, which is precisely the state this project was in for its whole life until today.
It measures; it does not correct
Forcing alignment in the trampoline - and rsp, -16 -
would have made the crash vanish in one instruction, and it was
explicitly rejected. The guest would still have been running misaligned
internally, and the loud, immediate, perfectly-located fault would have
become silent corruption surfacing somewhere with no relation to the
cause. That is principle 3's failure mode, chosen deliberately, which is
worse than arriving at it by accident.
The general rule: when a convention is being violated, measure who is violating it before making the symptom go away. The measurement was ten lines and it found a bug affecting every run this project has ever made. The forced fix would have hidden it permanently.