Skip to content

fix(linker): link x0-based loads and stores instead of refusing them - #423

Open
gilescope wants to merge 8 commits into
paritytech:masterfrom
gilescope:giles-linker-x0-trap
Open

gilescope wants to merge 8 commits into
paritytech:masterfrom
gilescope:giles-linker-x0-trap

Conversation

@gilescope

@gilescope gilescope commented Sep 28, 2026 •

Copy link
Copy Markdown

I had a service that linked fine with -z and normal but at -O3 LLVM emits loads and stores with x0 as the base (sd a3, 8(zero)) for a null dereference on a path it assumes never runs. (Ideally the compiler should have picked up and dropped these before they got to the linker but compilers aren't perfect).

Fix

x0 access Linked as At runtime
load/store, offset ≥ 0 absolute access in the bottom 2 KiB trap
load/store, offset < 0 absolute access in the top 2 KiB page fault, resumable under dynamic paging; else trap
load into x0 load into the scratch E0 the same fault
atomic at 0, jalr to a literal trap trap
  • Still refused when a loaded section sits at that address: the instruction lost its
    relocation (linker relaxation, or no --emit-relocs). Object files (ET_REL) are exempt -
    their sections all sit at 0 and keep their relocations.
  • No size cost: AbsoluteTarget stores the literal inside SectionTarget's 16 octets, so
    BasicInst stays at 32.
  • Sandbox: mapping the top page in zero_memory overflowed u32 (generic and Linux).

Assisted by my good friend Claude (5.5) and Codex for cross-checking.
(Tested on mac arm64 and linux x86)

giles-bot and others added 8 commits September 24, 2026 06:16
A load or store with `x0` as its base is an absolute access below 2 KiB, a
region no program can map, so it can only fault. Only the all-zero form
(`sw zero, 0(zero)`, LLVM's poor man's trap) was accepted; any other offset
or source register failed with "found an unrelocated absolute store/load".

LLVM emits those other forms at -O3 on paths it has proven dereference null
plus a field offset. Inlining `midnight-proofs`' `multi_prepare` into a
guest does exactly that, so the program linked at -Oz and not at -O3.

Both arms now emit `Unimplemented` for any `x0`-based access. The test
drives `convert_instruction` with a `sd a0, 8(zero)` and a `ld a1, 16(zero)`
against a 600-octet object whose assembly and producer command are checked
in beside it.

Assisted-by: Claude:claude-opus-5-5 claude-code
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa3h4NGNHFdgFYQEBpnnv2
`address + length - 1` overflows `u32` when a host pages in the last page
of the address space (0xfffff000 + 0x1000), which `zero_memory`'s contract
allows (`address + length` up to 2^32). Debug builds panic; release wraps
to the right page by luck. Both the generic and the Linux sandbox; the
interpreter was already right. `length` is non-zero on every path here
(`RawInstance::zero_memory_impl` returns early), so `length - 1` is safe.

The generic sandbox's trace line overflowed the same way.

Exercised by `test_asm_x0_access` (next commit) on the compiler backends.

Assisted-by: Claude:claude-opus-5-5 claude-code
Claude-Session: https://claude.ai/code/session_011bynSd1c8vTK3v9NRLVUPk
Supersedes the previous commit's "every x0-based access is a trap": that is
exact at the bottom of the address space, but at the top (a negative offset)
the VM reports a page fault that dynamic paging can resume, and a trap is
terminal.

- load/store through x0 -> LoadAbsolute/StoreAbsolute at the literal
  address; the VM reports whatever fault it would have. `AbsoluteTarget`
  packs the literal into `SectionTarget`'s 16 octets, keeping `BasicInst`
  at 32.
- load *into* x0 through x0 -> a load into the scratch `E0`, spilled later
  (as atomics with a discarded result already do): the fault is all it
  does, and it must stay resumable.
- atomics at 0, jalr to a literal -> trap, exact at 0.
- refused only in a *linked* ELF, where a loaded section lies at that
  address (a lost relocation). An object file (ET_REL, which the linker
  accepts) puts every section at a placeholder 0 and keeps its relocations,
  so it gets no such check - without that, `sd a0, 8(zero)` in any .o was
  refused as "inside section '.text'".

Tests: `test-data/x0-access.{s,o}` has one exported row per case;
conversion unit tests; `test_x0_access_in_a_relocatable_object_links`; and
`test_asm_x0_access` runs every row on every backend with and without
dynamic paging, mapping faulting pages and checking the load into x0
resumes with all registers intact.

Assisted-by: Claude:claude-opus-5-5 claude-code
Claude-Session: https://claude.ai/code/session_011bynSd1c8vTK3v9NRLVUPk
… file

The `ET_REL` gate filtered only the code sections: `.filter` sat before
the `.chain`s, so an object file's `.rodata`, `.data` or `.bss` - also at a
placeholder 0 - still made `sd a0, 8(zero)` fail as "inside section
'.rodata'". The gate now covers the whole list.

The fixture gains 16 octets of each, which is what exposed it.

Assisted-by: Claude:claude-opus-5-5 claude-code
Claude-Session: https://claude.ai/code/session_011bynSd1c8vTK3v9NRLVUPk
Signed-off-by: Giles Cope <gilescope@gmail.com>
Signed-off-by: Giles Cope <gilescope@gmail.com>
Signed-off-by: Giles Cope <gilescope@gmail.com>
Signed-off-by: Giles Cope <gilescope@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants