riscv32 cannot reach a far call, so the compiler will not link
Measured
$ compiler/pascal26 --target=riscv32 compiler/compiler.pas /tmp/out
pascal26:8307: error: target riscv32: jal displacement 2197196 is outside the
encodable range -1048576..1048574; the code is too large for this branch form
2.20 MB against JAL's ±1 MB. Ordinary programs cross-build and run on riscv32 fine; it is specifically the compiler's size that exceeds the form.
The shape of the fix already exists in-tree
xtensa hit the identical problem and solved it: EmitXtensaLongCall
(symtab.inc) materialises the target address into a register and does an
indirect call, and EmitXtensaCallToCode picks the short or long form by asking
XtensaCallReaches. riscv32 needs the same pair — auipc+jalr is the natural
long form there, and it is a two-instruction sequence with ±2 GB reach.
The reach test must be the thing that chooses, not a heuristic about program size: a build that is nearly over the line must still emit the cheap form for the calls that fit.
Sibling
[[bug-a-xtensa-cannot-widen-a-forward-call-so-a-big-image-still-refuses-to-build]] is the same family and is about xtensa's remaining forward-reference case. This one is riscv32 and is about JAL specifically.
Gate
make compiler/pascal26, then pascal26 --target=riscv32 compiler/compiler.pas
must produce an artifact; plus the riscv32 cross battery, since every call in it
now goes through whichever form the new chooser picks.
What it actually was (measured, not reasoned)
Two hypotheses were wrong before the third was measured, and both were wrong in the same way -- they were about CALLS, because the ticket's title says call.
-
The signal-install site. Ruled out with
--no-signals: still fails. -
The runtime-helper calls (
ExcSetJmpAddr,ExcRaiseAddr,SigInstallAddr,ExcLongJmpAddr,SigSetHookAddr). Six sites routed through a new reach-choosingEmitRiscv32CallToCode; the error came back byte-identical, same displacement 2197196. That is what retired the hypothesis. The routing was kept anyway -- the helpers sit at the front of the image and nothing was reach-checking them -- but it fixed nothing here. -
The answer, from tagging every
EncodeRISCVJALcall site with an id and printing it besideCodeLenand the current proc:jal displacement 2197196 ... [CodeLen=6397364 site=16 proc=ParseFactorCore@4199288->6396484 lbl2 nfix2267]
Site 16 is the label fixup loop -- a body jumping to its own label.
ParseFactorCoreoccupies 4199288..6396484: 2.20 MB in one procedure, with 2267 forward jumps in it.The x86-64 map says this is a narrow wall and a real one: two procedures exceed 1 MB (
ParseFactorCore1.15 MB,PyParseFactorCore1.24 MB) and the third largest is 0.40 MB.
Behind it sat a second, larger one that only appeared once the first was fixed:
the program entry jump (EmitProgramEntryForTarget / PatchProgramEntryJump),
which reserved one JAL to reach the main body past every proc body in the image,
and asked for 20089124.
The fix
A forward jump cannot be widened at patch time unless the space was reserved, so:
- Backward / known target --
Riscv32JalReachesdecides: bare 4-byte JAL when it reaches,auipc+jalr zerowhen it does not. - Forward -- reserve 8 bytes (
jal zero, 0+nop, the JAL opcode kept so the fixup loop can still tell a jump slot from a branch slot), and patch it at fixup time tojal+noporauipc+jalrby the same reach test. This is the tradeEmitCallProc's riscv32 arm already makes for every call. - Every skip branch over a slot now asks
Rv32JumpSlotBytesfor the size before it encodes its displacement, which is why both halves live in one place instead of being open-coded at the five sites.
Five copies of the same if-known-else-record-a-fixup block collapsed into
EmitRv32JumpToLabel.
Evidence
-
pascal26 --target=riscv32 compiler/compiler.pas-> 20184940 B of code, an ELF 32-bit RISC-V executable. -
That image runs: under
qemu-riscv32it compileshelloto x86-64 (which then runs natively and prints the right thing) and to riscv32 (which runs under qemu and prints the right thing). Parsing anything runsParseFactorCore, so the widened jumps executed. -
Both arms of the chooser fire, and the reach test is what chooses -- slot shapes counted straight out of the artifacts rather than inferred:
artifact jal+nop(short)auipc+jalr zero,t0(long)hello, riscv32 1261 0 the compiler, riscv32 (20 MB) 80348 51 A 20 MB image still takes the cheap form for 80348 of 80399 label jumps. A size heuristic would have taken all 80348.
-
Differential rv32-vs-x86-64, run one file at a time, on the paths whose skip distances this change re-encodes:
test_cross_exception,test_exception_typed,test_exception_finally,test_except_derived_caught_by_base,test_div_by_zero_raises_on_every_target-- all MATCH. -
Size cost, and a CORRECTION to the first figure published here. The original row said "245612 -> 249708 B, +1.67%, which is 1024 forward jumps x 4 bytes", read off the compiler's
code=line. That line is quantized to 4096. Swept across programs with 1..101 procedures,code=moves only in 4096-byte steps, so it cannot resolve a change of this size and the tidy "1024 x 4" arithmetic was a coincidence fitted to a rounded number. Found while measuring the xtensa frame change, where the same instrument reported a delta of zero for a change that provably alters 161 prologues.The exact instrument is
--emit-objplusreadelf -S's.textsize, which is not page-padded. Re-measured onhello:.text before after delta riscv32 243060 248252 +5192 (+2.14%) The untouched-target control was ALSO taken with the quantized instrument and is withdrawn: comparing
helloagainst the pinned compiler byte-for-byte shows arm32/aarch64/i386 (and x86-64) all differing, becausepinnedis many commits behind and those differences are other people's. A real control here needs an ablation build, which was not run. What does stand is that this change is insideif TargetArch = TARGET_RISCV32arms only, and that the x86-64 self-host fixedpoint converged. -
Gate:
make compiler/pascal26converged 1 round (6b9d17ec4961),tools/gate.sh quickGREEN.
Regression test
make test-riscv32 grew one: a GENERATED bigbody.pas -- one procedure with a
4000-arm if-chain, 1146732 B of rv32 code, compiled and run against the x86-64
oracle in 0.9 s. The compiler itself is the only other program that crosses the
wall and it is a terrible regression test (the whole self-build has to fail
first). Generated rather than checked in: the source has to be ~260 KB to
produce >1 MB of code.
It carries its own POSITIVE CONTROL -- an assertion that the body really does
exceed 1048576 B -- because a generator, a backend change or an optimisation
could quietly bring it back under the line and the test would go on passing
while covering nothing. Verified to reject both a small code= and a missing
one; the missing case is also what makes the | tee safe, since a pipeline's
status is tee's and a failed compile would otherwise exit 0.
Pinned refuses that file today (jal displacement 1106292 ...); the new
compiler builds it and it prints the oracle's output under qemu.
What this does NOT close
asmtext_rv32.inc's own forward-reference patch (inline-asm labels) is still a
bare 4-byte JAL. It was left alone deliberately: those labels are inside one
hand-written asm block, and no block in the tree is anywhere near 1 MB. If one
ever is, it fails loudly with the same message and this is the shape to copy.
Log
- 2026-08-30 — resolved, commit 1df4ee490.