The i386 PIC prefix guard reads a displacement byte as a prefix
Measured 2026-09-06 at compiler a4b3cd42d8da, built at 5e6d6d829. Reproduces on pin v405 too, so
it is not a regression — it is a property that surfaces and hides with unrelated
code motion.
The instruction
test-emit-obj builds test/c_obj_data_pascal.pas for i386 and asserts zero
absolute .text relocations. It gets one:
00028f26 00000301 R_386_32 .bss
In PXXIoCheck (lib/rtl/textfile.pas), which ends Halt(code):
28f22: 8b 45 f0 mov -0x10(%ebp),%eax
28f25: a3 b8 92 00 00 mov %eax,0x92b8 <- R_386_32, absolute
28f2a: e8 ... call __pxx_run_finalizers
emit.inc's TryI386PcRelStore handles A3 explicitly — the moffs case is the
first arm in the function. It refuses because of its guard:
if ((modrm = $A2) or (modrm = $A3)) and not I386PrefixBefore(CodeLen-1) then
I386PrefixBefore reads the single byte before the opcode and answers True for
$66 $67 $F0 $F2 $F3 and the segment overrides. The byte before a3 is the
f0 of -0x10 — the displacement of the preceding instruction. It is read as
LOCK, and the rewrite is refused.
The count is data-dependent, and that is the part that matters
Perturbing an unrelated declaration moves the answer, so the answer came from a channel and not from the program:
lib/rtl/textfile.pas |
absolute .text relocs |
|---|---|
| unmodified | 1 |
one extra local added to PXXIoCheck |
0 |
| restored | 1 |
One local moves code off -0x10, its displacement byte stops being f0, the
guard stops false-refusing. Nothing about position-independence changed.
So done/bug-a-an-i386-object-carries-text-relocations-as-soon-as-it-uses-sysutils
is not wrong about what it measured — 62 really did become 0 at cd4af7824 —
but "0" is not a property of the fix. It is a property of the frame offsets that
happened to exist that day. 46 commits touched lib/rtl since, and one of them
put a local at -0x10 in a routine that stores to a global. Any future
lib/rtl change can re-red this row, and the diff that does it will look
unrelated. That is why this is filed rather than left as a flaky row.
Why the guard is right to refuse and still has to change
Its own comment is the best statement of the trade-off and should not be overridden lightly:
It REFUSES rather than re-emitting the prefix in the right place, because scanning backwards for a prefix cannot be made sound: the preceding byte may be the last byte of the previous instruction and merely LOOK like one. False refusals cost position-independence on a handful of 16-bit accesses; a false acceptance costs a wrong-width memory access, which is silent.
Both halves are correct. The prediction — that false refusals would cost
position-independence — is exactly what happened; only the estimate of how
often was low, because it assumed the trigger was a real 66 prefix on 16-bit
accesses rather than any byte in the set appearing as data.
Do not fix this by narrowing the prefix set (e.g. "LOCK cannot legally
precede A3"). It is true and it does not help: 66 is both a legal prefix
here and a perfectly ordinary displacement or immediate byte, so the ambiguity
survives any narrowing.
The fix shape
Stop scanning; have the emitter say so. emit.inc already uses exactly this
pattern for the address-as-immediate family — its comment reads "announced by
the call site rather than sniffed off the end of the buffer — see
X386AddrImmPic." The same treatment here: a variable recording the offset at
which a prefix was last emitted, set where prefixes are emitted, and
I386PrefixBefore(pos) becomes an exact comparison instead of a guess.
Not attempted here, deliberately. The change is only sound if every prefix
emission that can precede a rewritable i386 instruction sets it, and a missed
one converts a conservative refusal into the silent wrong-width access the guard
exists to prevent. EmitB($66) alone appears 88 times across seven files, not
all of them i386 instruction streams. Enumerating that set is the work, and
"I found them all" is precisely the claim that needs a positive control rather
than a grep — a probe that a missed site FAILS on, not just a count.
Repro
./compiler/pascal26 -Fulib/rtl --emit-obj --target=i386 \
test/c_obj_data_pascal.pas /tmp/pcr.o
readelf -rW /tmp/pcr.o | awk '/Relocation section/{s=($0 ~ /rel\.text/)} \
s && /R_386_32/{n++} END{print n+0}' # want 0, gets 1
Note awk and not strtonum: this box runs mawk, where strtonum is undefined
and a scan written with it fails rather than answering — the Makefile row beside
this one records the same trap.
RESOLVED — the guard now asks the call site instead of the buffer
Fixed the same evening it was filed, and the park reason above is superseded rather than wrong: the repair it argued against (announce every PREFIX) really does need the exhaustive audit, because a missed announcement flips the guard to ACCEPT and that is the silent wrong-width access. The repair that landed inverts the failure direction, which is what makes it affordable.
X386InstrStart records where the instruction being emitted BEGAN.
I386PrefixBefore(pos) exits early only when pos matches it. A site that
never sets it leaves an earlier, SMALLER offset — code offsets only grow — so
pos does not match, the byte test runs, and that site refuses exactly as it
does today. Completeness is a coverage property, not a soundness one.
Adoption is incremental and a miss costs position-independence, not correctness.
Adopted by the moffs accumulator family through EmitMovGlobAcc: A0/A1/A2/A3,
one opcode byte then the moffs, 42 sites. The 16-bit 66 A3 store is not in the
family and is deliberately not routed there — it has a prefix, and in
EmitObjMode it never reaches the sniffer at all because EmitMovGlobAx16
emits its whole PIC form itself. xtensaenc.inc's $A0/$A1 are xtensa
opcodes and are untouched. Cleared where CodeLen rewinds.
Measured — and the row that matters is the flap, not the count
lib/rtl/textfile.pas |
old compiler | new compiler |
|---|---|---|
| unmodified | 1 | 0 |
one extra local in PXXIoCheck |
0 | 0 |
| restored | 1 | 0 |
The count no longer moves with an axis that has nothing to do with position-independence. A before/after pair on the unmodified row alone would not have shown that — it would have looked like any other fix, and this row could reach 0 on its own.
PC32 floor 3147 on the same object, so "zero absolute" is not passing on an object where nothing was emitted.
What is still red on test-emit-obj, and it is older than this
make test-emit-obj now runs past the i386 relocation assertion and every other
i386 row, and stops later at the xtensa link — a failure this one was hiding.
test/test_emit_obj.pas pulls in the PAL socket/timer backend and the recipe's
shim provides no ESP-IDF, so the link wants lwip_*, esp_timer_get_time and
vTaskDelay. 25 undefined references from the PINNED compiler and 25 from
HEAD, on both xtensa ABIs; riscv32 links clean. Filed as
[[bug-a-the-emit-obj-xtensa-link-shim-does-not-provide-the-pal-backends-esp-idf-symbols]].
Verified: make compiler/pascal26 converged, 189e9b74036e. gate.sh quick's
only FAIL was a silent-assertion lint on Makefile:15977 from fecdfe6dc,
which is not this change and is fixed alongside; self-host fixedpoint and
testmgr --tier quick both PASS.
Log
- 2026-09-06 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 8a9f2ad2e.