The site
compiler/ir_codegen_aarch64.inc:2869, inside SetLength on a frozen inline
string:
EmitLoadVarAddrA64(si); { x0 = buffer addr (or &slot for a string param) }
if (Syms[si].Kind = skParam) and TypeIsFrozenString(Syms[si].TypeKind) and
not Syms[si].IsArray then
EmitI32($F9400000); { ldr x0, [x0] = P (param slot holds the pointer) }
That condition is verbatim the question ABIParamSlotHoldsValueAddr exists to
answer — "does a PARAMETER's stack slot hold the ADDRESS OF THE VALUE?" — and
it is answered here by hand.
The delta, exactly
abi.inc:69:
if Syms[symIdx].Kind <> skParam then Exit; { both agree }
if Syms[symIdx].IsRef or Syms[symIdx].IsArray or
TypeIsFrozenString(Syms[symIdx].TypeKind) or ... { oracle: IsArray => True }
Kind=skParam, frozen string, IsArray |
oracle | aarch64:2869 |
|---|---|---|
| False | True | True |
| True | True (deref) | False (no deref) |
An open-array-of-frozen-string parameter's slot holds the caller's data pointer, so the oracle's answer is the right one and the hand chain skips a deref — writing the 8-byte length prefix to the address of the slot rather than to the buffer.
What is NOT established
No repro. I did not build one, and the combination may be unconstructible:
the site is guarded by IRKind[left] = IR_LEA and by SetLength's own
argument checking, which may reject an array-typed target before this line. So
this is a divergence, confirmed by reading, and not yet a confirmed defect.
Both outcomes are worth the same small amount of work and neither leaves the line as it is:
- Reachable → aarch64 miscompiles it, and the fix is to call the oracle.
- Unreachable →
not IsArrayis dead defensive text whose only effect is to make this site disagree with the oracle in the linter's eyes; delete it and call the oracle, which is one line and removes the copy.
Note the twins do NOT have this shape: riscv32, xtensa and x86-64 reach the
same decision through arms that depend on InLValueWrite (marked
abi-divergence:, since the oracle's signature cannot see write context).
aarch64 is alone in re-deriving a question the oracle answers with no such
excuse — which is what makes it the interesting hit rather than noise.
How it was found
tools/abi_oracle_lint.py, on its first real run. Baseline 78 raw hits →
22 after narrowing to parameter questions → 6 after exempting
EmitParamSpillsForTarget (which asks slot WIDTH, a question the oracle does
not answer) → 1 after marking five sites whose divergence is real and stated.
This is that 1.
Resolution (frankS, 2026-08-31) — a third outcome
The ticket enumerated two, and said neither leaves the line as it is. It was right about that and wrong about both branches.
Reachable, trivially, and with no array in sight.
type TS = string[20];
procedure P(var s: TS); begin SetLength(s, 3); WriteLn(Length(s)); end;
SIGSEGV on aarch64. IsArray is False here, so not IsArray is TRUE and the
arm fires — and EmitLoadVarAddrA64 has already emitted ldr x0, [x9] for a
by-ref param, so the extra ldr x0, [x0] is a second dereference. The array
case the ticket reasoned about was a distraction; the bug was in the half the
condition was letting through.
And it cannot call the oracle, so branch (b) was wrong too.
ABIParamSlotHoldsValueAddr answers does this param's slot hold the value's
address? — True for every frozen-string param, by-ref or not. The question at
this site is has the emitter one line above already consumed that fact?, which
a symIdx cannot express. Correct condition: not Syms[si].IsRef. Site now
carries an abi-divergence: marker whose stated reason is true of this arm
(the failure mode of the four original markers, one of which was written for its
neighbour), and tools/abi_oracle_lint.baseline's entry is deleted — the
linter's stale-entry error is what told me to delete it, working as designed.
The bigger finding: it is one bug in THREE places
Chasing the aarch64 repro turned up the same narrow predicate twice more, and the aarch64 one is the least severe of the three:
| site | wrote | effect |
|---|---|---|
symtab.inc × 4 (EmitStoreStrLen, EmitLoadStrLen, EmitLeaStrDataRdi/Rsi) |
TypeKind = tyString |
x86-64: SetLength writes the length OVER the parameter slot, destroying the pointer; next Length() derefs 3 → SIGSEGV |
ir_codegen.inc:1258 (i386 prologue) |
TypeKind in [tyString, …] |
i386 REFUSED a by-value string[20] param — "only ordinal/pointer parameters supported yet", which reads like a target limitation and is not one |
ir_codegen_aarch64.inc |
not IsArray |
aarch64 double-deref on a var one |
TypeIsFrozenString exists precisely to be the one answer — its own comment
says "widen existing = tyString codegen checks to this predicate" — and
these three were missed. string[20] is tyFixedString, ShortString is
tyShortString; only the legacy overloaded tyString matched. i386 and
arm32 were correct throughout, which is what made it findable at all: four
backends, two right.
A local frozen string is correct everywhere. Only the PARAMETER arms were narrow, in all three places, which is why nothing caught it.
Evidence
test/test_frozen_string_param_setlength.pas, wired into test-core,
test-i386, test-aarch64 and test-arm32. By-value and by-ref, because
they fail on different targets — a test with only one of them passes on one of
the two broken backends. Byte-identical to FPC. Positive control: pinned
compiles it and SIGSEGVs at the first by-value row.
riscv32 and xtensa cannot run it: SetLength (builtin 101) is unimplemented in
their bare-metal stage 1. An honest compile-time refusal, not a wrong value, and
unrelated.
Log
- 2026-08-31 — fixed, commit e53b6a6fd. Three sites widened to
TypeIsFrozenString, aarch64's condition corrected tonot IsRefand markedabi-divergence, baseline entry retired.