No. 40 / project of 147 on the ladder

M-mode runtime API audit

introduces — Full trap-frame ABI, ecall round-trip, and irq_save/irq_restore primitives - the runtime foundation FreeRTOS will inherit

harden statelast run2026-05-03
areaunknownμm²
signoff
  • DRCNOT RUN
  • LVSNOT RUN
  • antennaNOT RUN

P40 keeps the P39 RTL unchanged and rewrites the M-mode runtime so the trap path is solid enough for the FreeRTOS port at P41 to inherit without surprise.

Status: RTL pass. Directed test passes (5808 clocks - P39 plus the new ecall + IRQ-save probes). No P40 harden run yet.

Why an audit, not a feature

The P39 runtime trap glue saved only t0/t1 and didn’t recognise ecall at all. Calling ecall from FreeRTOS-style code on it would have:

  1. Trapped to trap_vector,
  2. Failed to match MTI or MEI,
  3. Fallen through to p37_fail and halted with x5 = 31.

That is the worst kind of bug for software bring-up - “kernel just stops, no message” instead of “port doesn’t compile” or “test asserts.” P40 closes that gap before P41 has the chance to discover it the painful way.

What changed

changelocation
128-byte trap frame on entryruntime/start.S
mcause dispatch (ECALL_M / MTI / MEI / fail)runtime/start.S
c_trap_handler() for ecallruntime/main.c
Trap-frame ABI headerruntime/trap.h
irq_save() / irq_restore() inline asm helpersruntime/trap.h
_Static_assert catching layout drift between asm and Cruntime/trap.h
ecall_probe() directed testruntime/main.c
irq_save_restore_probe() directed testruntime/main.c

The frame layout is fixed-size and uniform across every cause. We pay 128 bytes per trap regardless of what triggered it; the win is that FreeRTOS context-switching doesn’t need to special-case trap-cause shape.

What c_trap_handler proves

The directed test’s ecall_probe() does:

uint32_t result = do_ecall(0x1234u);   // a0 = 0x1234, then ecall
// expect: result == ECALL_COOKIE (set by handler)
//         p40_ecall_count incremented by 1

The path is:

  1. ecall instruction raises ECALL_M trap.
  2. start.S saves the full 128-byte frame.
  3. c_trap_handler reads frame->mcause, sees MCAUSE_ECALL_M, bumps p40_ecall_count, writes 0xC0DEC0DE into frame->a0, and bumps frame->mepc by 4.
  4. start.S writes the (updated) frame’s mepc back to the CSR, restores all GPRs from the frame (including the mutated a0), then mret.
  5. C caller observes a0 == 0xC0DEC0DE and the counter incremented.

A second back-to-back ecall verifies re-entrance in the trivial sense. Both pass.

What irq_save / irq_restore proves

Four checks, all on the same routine:

  1. With MIE = 0: irq_save() returns 0, leaves MIE = 0.
  2. With MIE = 1 set explicitly: irq_save() returns 0x8, leaves MIE = 0 (atomic via csrrci).
  3. irq_restore(0x8) re-arms MIE.
  4. irq_restore(0x0) does not flip MIE if it’s already 0.

That is the bracket FreeRTOS uses to enter and leave critical sections. P40 verifies the primitives behave exactly as expected before P41 starts using them under a real scheduler.

What stays the same

  • src/top.sv - identical to P39 except for mimpid = 0x40.
  • ISA - RV32IM + Zicsr + Zifencei + Zicntr.
  • Memory map - same external-RAM-at-zero, same MMIO at 0x10000000.
  • Arch-test plugin - same DUT contract, copied verbatim into projects/40_mmode_runtime_audit/arch_test/. Sweep wired up but not yet re-run; expected to match P39’s PASS=56.

What just happened?

P40 is software-side packaging on top of P39’s hardware. No new instructions, no new traps, no new MMIO. What changed is the shape of the runtime’s contract with the OS that will eventually run on top of it. The trap frame is uniform, the C handler can see and mutate full register state, and IRQ save/restore is a real bracket.

The next rung in the roadmap is P41, the actual FreeRTOS port (compile-only). P40 is the foundation that lets P41 focus on port.c and portASM.S instead of inventing a trap frame from scratch.