Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

09 · A System Call of Your Own

The question

What has to exist before ecall is a system call rather than a trap?

ch06 already executed an ecall and handled it. Nothing about that was a system call: no request was made, nothing was asked for, and the handler had nothing to decide. Four things are missing, and this chapter adds them — a number saying which call, somewhere to put arguments, somewhere to put a result, and a dispatch. A fifth is missing too, and it is the expensive one: the arguments and the result live in the caller’s registers, so the handler has to save every register somewhere it can read and write them.

The material

The saved registers are the interface

ch06’s handler could let the compiler save the registers because it never needed to look at them. This one does: the call number and the arguments arrive in the caller’s registers, and the result has to go back in one, so the saved registers have to be somewhere the handler’s C can read and write. That is a frame the handler lays out itself.

The frame holds every register the caller had, not the three the interface needs, because a frame that holds every register is a complete description of the caller — the thing ch11 copies to make a second process from the first — where a frame that holds three is not:

syscall.c
/* The trap entry. Naked because every instruction in it matters and a prologue the compiler
 * chose would run before the caller's registers had been saved.
 *
 * This is the file's whole point, so it is spelled out rather than generated: thirty-one stores
 * on the way in and thirty-one loads on the way out, and the only reason it is not thirty-two is
 * that x0 cannot hold anything to lose. */
__attribute__((naked, aligned(4))) static void syscall_entry(void) {
    asm volatile(
        ".macro SAVE reg, slot\n"
        "  sd \\reg, \\slot*8(sp)\n"
        ".endm\n"
        ".macro LOAD reg, slot\n"
        "  ld \\reg, \\slot*8(sp)\n"
        ".endm\n"

Thirty-one, not thirty-two, because x0 is hard-wired to zero and has nothing to lose. It is written out rather than generated so that its length is visible: this is the cost of describing the caller completely, and it is paid on every single call.

The frame also becomes the interface. Once the registers are in memory, “the arguments” and “the registers” are the same thing, and the handler reads them as an array:

syscall.c
void syscall_dispatch(uint64 *frame) {
    uint64 cause = bare_csr_read(mcause);
    uint64 number = frame[17]; /* a7, by convention — the convention is this line */
    uint64 first = frame[10];  /* a0 */
    uint64 second = frame[11]; /* a1 */

a7 holds the number and a0 the first argument because those two lines say so, and nothing else enforces it. A convention is an agreement, and here you are both parties.

The dispatch

syscall.c
    switch (number) {
    case SYS_ADD:
        frame[10] = first + second;
        break;
    case SYS_WITNESS:
        frame[10] = frame[18]; /* s2, read out of the saved frame rather than out of the CPU */
        break;

Writing to frame[10] is how a result gets back: the epilogue restores a0 from that slot, so the caller finds it in the register the convention promised. Nothing returns anything in the C sense.

The default case decides what a call number nobody implemented does:

syscall.c
    default:
        /* A number nobody implemented is a refusal, not a crash. Returning an error is what
         * makes this an interface rather than a trapdoor. */
        unknown_calls_refused++;
        saw_unknown_number = (number == 99);
        frame[10] = SYS_UNKNOWN_RESULT;

A number nobody implemented gets an error, not a stop. That is the difference between an interface and a trapdoor, and it is a decision — the hardware would have been equally happy to let the machine wander off.

Calling it

From the other side, a system call is four register moves and an instruction:

syscall.c
static uint64 call(uint64 number, uint64 first, uint64 second) {
    register uint64 a0 asm("a0") = first;
    register uint64 a1 asm("a1") = second;
    register uint64 a7 asm("a7") = number;
    asm volatile("ecall" : "+r"(a0) : "r"(a1), "r"(a7) : "memory");
    return a0;
}

The "+r"(a0) tells the compiler that a0 is both an input and an output, which is exactly the claim the convention makes. Get that wrong and the compiler will cheerfully assume the register still holds what it put there.

What we measured

Run it yourself before reading the table — the rows below are what you should see, and a figure you have reproduced is worth more than one you have been shown:

./run syscall
What crosses the boundaryObserved
Registers the handler saves by hand31
Calls dispatched4
A call number chose what happenedyes
3 and 4 went in; this came back7
The caller’s register, read out of the saved frameyes
…and unchanged when the caller resumedyes
Calls refused as unknown1
…by returning an error, not by stoppingyes

Conditions: target bare · qemu virt (-machine virt -bios none) · QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18) · no firmware, no kernel · riscv64-linux-gnu-gcc · -march=rv64g -mabi=lp64 -mcmodel=medany -ffreestanding -nostdlib -fno-pic -fno-stack-protector -fno-builtin -O1 -g -Wall -Werror · 2026-09-17 · Source: bench/results/syscall-bare.json, code hash acb9c60789cf0db5.

Two of those rows are the same fact from opposite sides. caller_register_in_frame is the handler finding the caller’s s2 in the saved frame; caller_register_intact is the caller finding it still there afterwards. A handler that saved nothing would fail the second and never reach the first.

What this cannot tell you

What a system call costs. Thirty-one stores and thirty-one loads is a count, not a price, and the price is not thirty-one times anything. ch29 measures a real one on real hardware, and the number is larger than this chapter would lead you to expect — the register saves are not where the time goes.

Whether thirty-one is the right number. It is the safe number, and a real kernel saves fewer by making the convention promise more. Where that is possible and what it buys is a question this target cannot answer, because the answer is entirely about cost.

What happens when the caller lies. Every argument here is a number the handler uses as a number. The moment an argument is a pointer, the handler is dereferencing an address chosen by untrusted code, and everything about that is ch16’s problem — ch11 runs into the first half of it.

Problems

Three, in tests/a_system_call_of_your_own/.

9.1 — Add a call, and an error. Add a call that can fail for a reason other than being unknown, and return something the caller can distinguish from a valid result. Say why your choice of sentinel is safe. The test exercises both the success and the failure and checks the two cannot be confused.

python3 -m pytest tests/a_system_call_of_your_own/test_problem_1_add_a_call.py

9.2 — Save one register fewer. Remove exactly one store and its matching load from the entry stub, and produce a program in which that omission is visible in the output. The test checks that the register you dropped is the one your program shows being corrupted.

python3 -m pytest tests/a_system_call_of_your_own/test_problem_2_one_fewer.py

9.3 — Where does the frame live? The stub puts the frame on whatever stack the caller was using. Say what goes wrong if the caller arrives with a stack pointer it does not own, and change the stub so the kernel uses a stack of its own instead. The test checks the frame is no longer on the caller’s stack.

python3 -m pytest tests/a_system_call_of_your_own/test_problem_3_whose_stack.py

Where to go next

The unprivileged specification RISC-V International (n.d.) defines ecall; the privileged one RISC-V International (n.d.) defines what it does at each privilege level, which is not the same thing at each.

ch10 gives this mechanism its first API worth calling.

References
  1. RISC-V International. (n.d.). The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture. https://riscv.org/technical/specifications/
  2. RISC-V International. (n.d.). The RISC-V Instruction Set Manual, Volume II: Privileged Architecture. https://riscv.org/technical/specifications/