Implementing RISC-V Trap Handling and System Calls

Engineering

Posted by Bruce Lee on 2024-09-15

About Me

Welcome to my blog! This is where I collect my observations and notes on programming and technology. The main subjects range from implementation details to broader ideas about programming.

Main Topics

  • Engineering Projects: Exploring implementation details and how technical systems work.
  • C/C++: Notes on language features and programming techniques.
  • The Programmer’s Perspective: Ideas about developing a career and a way of thinking as a programmer.

For more, visit the categories page.

Contact

If you have questions or would like to discuss something, please get in touch through the About page.

Thank you for reading and for your support. I hope these notes help you on your own technical journey!


Overview

These notes follow an educational RISC-V emulator through trap entry, control/status registers, an Abstract Machine callback, yield, ecall, mret, and differential testing. They record intermediate debugging hypotheses as well as the resulting implementation approach. Corrections below distinguish those hypotheses from architectural requirements.

Starting the trap path

The intr.c test initializes the context/trap environment with cte_init, passing a callback. Conceptually this callback stands in for an operating-system handler accepting an event and a context, although the test supplies it here.

cte_init installs the address of __am_asm_trap in mtvec and records the callback in user_handler. Calling yield loads the project’s yield identifier, -1, into a7 and executes ecall. That identifier belongs to this software convention; it is not the architectural exception cause.

Implementing CSR access

csrw is a pseudoinstruction based on csrrw, with the destination result discarded. Register operands use five-bit register-file indices; the CSR operand uses a twelve-bit CSR identifier.

The initial attempt treated 0x305, the identifier of mtvec, as an ordinary memory address and passed it to the emulator’s memory-access functions. Address validation then rejected it. The key realization is that CSR identifiers occupy a separate namespace. They are neither normal physical-memory addresses nor virtual addresses needing page translation.

This also resolves the apparent RV64 overlap between mstatus at identifier 0x300 and nearby CSRs: the numbers identify registers, not starting byte addresses in a packed memory array. Define a CSRS structure in isa-def.h and expose its instance to instruction execution:

1
CSRS csrs = {.mstatus = 0x1800, .mtvec = 0, .mepc = 0, .mcause = 0};

The emulator can then access CSR state explicitly and extend the differential-testing interface to include relevant trap behavior.

ecall and isa_raise_intr

At execution, ecall invokes the trap-raising helper. The helper records the exception PC in mepc, writes the cause, updates the implemented trap state, and selects the handler entry derived from mtvec.

The notes initially explored deriving the cause from mstatus and used inconsistent numerical values. Architectural clarification: environment calls from U, S, and M modes have exception codes 8, 9, and 11 respectively. mstatus.MPP records a previous privilege level for trap return; setting it does not by itself establish the hart’s current privilege mode. A complete emulator needs an appropriate current-mode model.

In the limited PA environment, initialization to mstatus = 0x1800 was used to match the reference environment. That convention should not be generalized into an alternative mapping of exception codes.

From assembly entry to the callback

__am_asm_trap is exported by trap.S. It allocates a context on the stack, saves registers and selected CSRs, and calls __am_irq_handle. The context pointer is passed in a0, following the calling convention; its value is the address of the saved stack context.

The C handler inspects the cause, constructs an Event, and invokes the previously registered callback. The current exercise maps the relevant software-triggered event to EVENT_YIELD. The initialized mtvec remains the common entry address rather than changing for each callback.

The original inspection noticed that x0 need not be saved and did not find an ordinary save of x2. Stack-pointer handling must be understood from the exact frame layout and restoration sequence; omission should not be assumed correct without checking how the original stack is recovered. The assembly path also restores state and executes mret.

Testing context preservation

CSR read/write instructions must work before the trap assembly can run far enough to inspect mcause, mstatus, and the saved context. Printing a Context is a useful debugging step to confirm the expected layout and values. It is not a substitute for testing preservation of every required register.

Which PC should be saved?

For ecall, architectural mepc identifies the ecall instruction itself. Software normally advances the saved return PC when it wants to resume after that instruction. Other traps can require retrying the faulting instruction, so blindly adding four for every trap is incorrect. The helper should receive the relevant instruction PC; the software call identifier is separate information.

Returning the next execution address

In this emulator, s is local to instruction execution, and cpu.pc is later updated from s->dnpc. Directly changing cpu.pc inside isa_raise_intr would therefore be overwritten. The helper returns the handler address, and the ecall implementation assigns it to s->dnpc. This fits both the helper’s return type and the emulator’s control-flow convention.

mret in the exercise

The first implementation only changes the next PC to mepc, because the current exercise does not yet model all privilege and interrupt-enable behavior. A complete mret must also implement the relevant privilege and status transitions. Those can be added when the emulator expands beyond this restricted stage. Trap-entry updates belong correspondingly in isa_raise_intr.

Differential-testing investigation

The debugging sequence tried 0x1800, then zero, then restored the original initialization after later mstatus mismatches. The source speculated that the reference reversed cause values; the architectural mapping above shows why that hypothesis should be checked rather than adopted.

The notes explicitly state that general-register preservation was only inspected through failure-time register dumps and was not rigorously verified. They also record an unrelated ITRACE output path missing conditional compilation. These are limitations of that experiment, not evidence of a fully validated trap implementation.

Adding exception tracing

Add an ETRACE option through Kconfig, as with the other tracing facilities. An early idea was to print in __am_irq_handle, but Abstract Machine printf ultimately uses guest putch, whereas emulator printf uses the host library. Mixing them can interleave output and obscure the event sequence.

Placing exception tracing in the emulator’s isa_raise_intr exposes cause and key registers at the actual trap transition and keeps it at the intended hardware-emulation layer.

For terminology and architectural trap behavior, see the RISC-V introduction and privileged architecture specification.


If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. All the images used in the blog are my original works or AI works, if you want to take it,don't hesitate. Thank you !