No CSR/special-register write is expressible, so no raw ISR can be installed
What is already correct
interrupt; codegen is complete on both ESP ISAs — verified by disassembly at
pin v414 (--emit-obj, test/test_esp_interrupt.pas): full caller-saved
save/restore, .iram1.text placement, and the right trap-return instruction
(mret on riscv32, rfe on xtensa Call0). Nothing about the handler needs
work.
WHAT LANDED 2026-09-21 — read this before the historical sections below
The riscv32 half. compiler/rv32enc.inc grew a general rv32_csr plus
csrrw/csrrs/csrrc/csrrci wrappers, and the two hardwired mstatus helpers
are now DERIVED from them rather than restating the encoding — $300
previously appeared in two instruction words that no test related to each
other. compiler/asmtext_rv32.inc accepts csrw/csrs/csrc (CSR first
operand, decided before the unconditional AsmRv32RegOp that used to raise
"expected register"), csrr, the three-operand csrrw/csrrs/csrrc, and
mret — which had an encoder and no way to reach it, so a raw handler could
not have RETURNED even once the vector could be installed.
All eight forms were diffed against riscv32-esp-elf-as and match
byte-for-byte, csrw mtvec,t0 = 30529073. AsmRv32CheckCsr rejects
outside 0..4095 — checked and not masked, because rv32_csr masks to 12 bits
and an out-of-range address would otherwise silently address a different
CSR in a well-formed instruction. Boundary measured: $fff accepted, $1000
and -1 refused.
test/test_esp_bare_csr.pas is the first thing in the tree to take a
trap. It writes mtvec, reads it straight back, and takes two ecalls
through a hand-written handler that steps mepc and mrets. Byte-identical to
the x86-64 oracle; wired into test-esp-bare; the pinned compiler cannot
build it. gate.sh quick GREEN including the self-host fixedpoint.
The regression risk I took deliberately — deriving the mstatus helpers —
was controlled by running test_esp_bare_atomic.pas under qemu with the
pinned and HEAD compilers: both match the oracle. The riscv32 atomics mask
interrupts through exactly those two helpers, so that is the behavioural
control, not a byte comparison.
No pin is needed. esp_run_bare.sh builds with HEAD.
What was missing — the measurement that opened this ticket
Five spellings, all measured, none working at the time — measured at
ed1c3dfe6, binary 497489e8a723. Rows 1-4 are now fixed; row 5 (xtensa)
is not.
csrw mtvec, t0 asm: unknown symbol: mtvec
csrw 0x305, t0 asm: unknown symbol: x305 (C hex; see the note below)
csrw $305, t0 EmitAsmRv32: expected register
csrw 773, t0 EmitAsmRv32: expected register
wsr a4, vecbase asm: unknown symbol: vecbase (xtensa)
The $305 row is the one that names the gap, and it is why the hex question
is a distraction: the Pascal hex spelling gets all the way through the
tokenizer and the integer parser and then fails for the real reason — csrw's
FIRST operand is parsed as a register (AsmRv32RegOp, asmtext_rv32.inc:65-69,
which is what raises expected register), and a CSR address is not one. The
decimal spelling fails identically, which is the control: this is not about how
the number is written.
csrw is recognised as a mnemonic — it reaches the emitter — so this is a
missing encoding and an operand-class mismatch, not a missing number parser. rv32enc.inc:161 already has
rv32_csrw_mstatus, used by the atomics' interrupt-disable, which is the
same instruction with the CSR number frozen.
Why it matters beyond tidiness
devdocs/dev/esp32-hardening-map.md orders ESP work by who can answer a
row. This row is the one that moves the owner's stated must-have from
answerable by nobody to answerable in qemu on this box. ESP-IDF and
Espressif qemu for both ISAs are installed on plexus; the only thing missing
between here and a fixture that installs a vector, takes a deliberate trap and
asserts the handler ran is this instruction. It never becomes a board question.
Two things for whoever takes it
-
A named-CSR table is a nicety; the numeric form is the capability. If only one lands, make it the numeric one. Nothing has to land with it — this ticket said otherwise for a day and was wrong, see the correction in the summary.
csrw $305, t0is the spelling to write:$is Pascal hex, the text assembler's own integer parser takes it, and it is general.The
0xband is worth knowing about and is NOT a silent-wrong-value hazard, which is the reading to head off.0xNNis accepted exactly when the tail spells a valid RISC-V register — decimal digits, value ≤ 31 — and refused otherwise, because the tokenizer is askingAsmRv32RegNum(asmtext_rv32.inc:48-60) about the identifier it split off. Measured ated1c3dfe6, operand ofaddi t0, t0, <v>, read back by disassemblingInstthrough its ownnmaddress:0x9 -> 9 0x11 -> 17 0x19 -> 25 0x25 -> 37 0x30 -> 48 0x31 -> 49 0x1f -> refused 0x32 -> refused 0x40 -> refused 0xff -> refused 0x305 -> refusedIn the accepted band the value is the correct HEX value, not the register index —
0x11is 17 and not 11,0x25is 37 and not 25 — because the operand text is reassembled and re-parsed byAsmTextParseInt, which does handle0x. So every outcome is either right or loud, and a0xCSR address (all ≥0x300) lands squarely in the loud half. The two readings collide for0x9and below, which is why the discriminating rows are the ones quoted.The instrument that got this wrong first is worth recording: a
grep addi.*t0,t0over the WHOLE object answered-80for every row including the decimal controls, because it matched RTL prologue code and never my instruction. It agreed with itself across seven spellings, which read as stability. Window the disassembly to the symbol. -
A design question this exposes, and it is not an implementation detail. An
interrupt;routine has no way to adjust the return address, so for a synchronous trap (ecall, illegal instruction, load fault)mretreturns to the faulting instruction and re-faults forever. The directive is asynchronous-only and nothing in its surface says so. Decide whether that is the intended scope before building a fixture that traps synchronously — otherwise the first test written against this will hang, correctly, and read as a defect in the new code.UPDATE 2026-09-21 — this got smaller, and the fix is a consequence of the landing itself.
mepcis CSR$341, so a handler can now step the return address by hand:csrr t0, $341 / addi t0, t0, 4 / csrw $341, t0, which is whattest_esp_bare_csr.pasdoes and why its twoecalls both return. So the capability exists; what is still missing is any surface for it on aninterrupt;body, and the hazard is unchanged for anyone who does not know to write those three instructions. The+4is also not universally right — it assumes a 4-byte trapping instruction, and RV32C compressed instructions are 2 bytes — so a general answer reads the trapping instruction rather than assuming a width. The fixture is safe becauseecallis always 4 bytes.
The install is not the whole job — added 2026-09-21
See devdocs/dev/esp32-hardening-map.md §1.7. rtos_int_enter is what gives an
IDF-dispatched handler its safety properties, and a raw vector entry never
reaches it:
portasm.S:598-605 port_uxInterruptNesting[coreID] += 1 { xPortInIsrContext reads this }
portasm.S:643-645 lw sp, (xIsrStackTop[coreID]) { SP -> dedicated ISR stack }
A raw handler therefore runs on the interrupted task's stack and reports itself as not-in-an-ISR. Do not land the CSR write on its own. Whatever prologue an installed raw handler gets has to establish its own stack, or the feature's first real use is a silent overflow into whatever is below that task's stack.
Suggested acceptance, so this cannot be forgotten at review: the fixture that first installs a vector must also assert the handler ran on a stack OUTSIDE the interrupted task's stack bounds — not merely that it ran.
And a SECOND thing it must carry — added 2026-09-21
devdocs/dev/esp32-hardening-map.md §1.6. On the bare profile PXXAlloc
backs onto the EspArena free list and takes no lock under any flag:
the codegen hard lock is ThreadSafeMode AND TARGET_X86_64
(frontend_prologue.inc:127), and PXX_TS_SOFTLOCK covers only
i386/aarch64/arm32 (paslexer.inc:1226). riscv32 and xtensa are in neither.
--threadsafe is refused on those targets, so there is no flag that fixes
it.
Today that is safe by unreachability: bare has no FreeRTOS, so the only possible concurrency is an interrupt, and no interrupt handler can be installed — which is this ticket. Landing the install makes an unlocked allocator concurrently reachable for the first time.
So the enabler has two dependants, not one: the stack story above, and this. Neither is optional and neither is visible from the CSR encoding itself.
Related
bug-s-an-interrupt-directive-proc-reached-through-a-normal-call-returns-via-mret-with-no-diagnostic— the sibling hazard; its refusal is cheap because this ticket is open.devdocs/dev/esp32-hardening-map.md§1.1.
Log
- 2026-09-21 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 37904fdaa.