ecall Instruction

ecall (Environment Call) is RISC-V’s one-instruction request to the next-higher privilege level: it raises a precise synchronous exception whose cause code identifies the originating mode (8 = ecall-from-U, 9 = ecall-from-S, 11 = ecall-from-M), and the handler running at the higher privilege does the actual work. The unprivileged spec defines its purpose in one sentence: “The ecall instruction is used to make a service request to the execution environment. The EEI will define how parameters for the service request are passed, but usually these will be in defined locations in the integer register file” (unpriv rv32.adoc lines 902-905). On Linux, U-mode ecall is the syscall mechanism: a7 holds the syscall number, a0..a5 hold the arguments, a0 holds the return value. On a system with SBI firmware, S-mode ecall is the SBI call mechanism: a7 holds the SBI extension ID, a6 holds the function ID, and the firmware in M-mode services the request.

This note narrowly covers the instruction itself and its two canonical uses (Linux syscall convention and the SBI-call convention). The general trap entry/exit machinery that ecall rides on is in RISC-V Trap Handling. The CSRs (mcause, mepc, etc.) that the hardware writes are in Control and Status Registers. The full SBI ABI is in Supervisor Binary Interface.

Mental Model: A Single-Instruction Privilege-Bumping Call

ecall is the only instruction in the base ISA whose entire purpose is “ask the next privilege level up for something.” Everything else about it falls out of that single design choice:

  • It must trap; that is the only way to switch privilege levels.
  • It must be precise; the kernel needs to know exactly which ecall was executed, with the user’s register file intact.
  • It must encode the originating mode in the cause code; the higher-privilege handler needs to know whether it is servicing a user-program syscall (cause 8), a kernel-to-firmware SBI call (cause 9), or a degenerate M-mode-to-M-mode self-call (cause 11).
  • It must not, itself, define the calling convention; that is the responsibility of the Execution Environment Interface (EEI) layered on top (Linux’s syscall ABI, the SBI ABI, a microkernel’s IPC ABI, etc.).
flowchart TD
  subgraph U["U-mode (user program)"]
    UA["app sets up args:<br/>a0=fd, a1=buf, a2=len<br/>a7=64 (Linux __NR_write)"]
    UA --> UE["ecall"]
  end
  subgraph S["S-mode (Linux kernel)"]
    SA["handle_exception:<br/>scause = 8<br/>dispatch by a7<br/>→ sys_write(fd, buf, len)"]
    SA --> SR["return value in a0<br/>sepc += 4<br/>sret"]
  end
  subgraph M["M-mode (OpenSBI firmware)"]
    MA["sbi_trap_handler:<br/>mcause = 9<br/>dispatch by a7 EID, a6 FID<br/>→ sbi_debug_console_write(...)"]
    MA --> MR["sbiret in a0/a1<br/>mepc += 4<br/>mret"]
  end
  UE -- "trap, cause=8" --> SA
  SR -- "resume at next insn" --> U
  S -. "kernel itself uses<br/>ecall to call SBI" .-> M
  KE["kernel sets up:<br/>a7=0x4442434E (DBCN EID)<br/>a6=0 (console_write FID)<br/>a0=len, a1=lo, a2=hi"]
  KE --> KEC["ecall"]
  KEC -- "trap, cause=9" --> MA
  MR -- "resume at next insn" --> S

Three privilege levels, two distinct uses of the same instruction. What it shows: ecall from U-mode lands in the S-mode kernel (cause 8), and ecall from S-mode lands in the M-mode firmware (cause 9). The same opcode means “syscall” or “SBI call” depending on who runs it. The insight to take: ecall is the universal upward call, and the convention layered on top (Linux ABI vs SBI ABI) is what distinguishes the two uses.

Mechanical Walk-through: One Instruction, Many Roles

Encoding

ecall is encoded as a SYSTEM-opcode I-type instruction with every other field zero. From the unprivileged ISA manual’s instruction listing (rv-32-64g.adoc line 102):

funct12 (31:20)rs1 (19:15)funct3 (14:12)rd (11:7)opcode (6:0)
00000000000000000000000001110011

In hex, the entire 32-bit encoding of ecall is 0x00000073. Its sibling ebreak shares the same opcode with funct12 = 000000000001 (encoding 0x00100073). Because both ecall and ebreak occupy the funct12 field of the SYSTEM opcode and have rs1 = rd = 00000, they cannot have register operands at the encoding level: every argument must be passed via the existing register state, by ABI convention, not in the instruction itself.

A consequence: ecall has no compressed (16-bit) form in the C extension. The instruction is always 4 bytes, which simplifies the “advance mepc past the ecall” step in the handler (always += 4, never += 2).

Hardware semantics

When the hart retires an ecall, it raises a precise synchronous exception. The cause code depends on the current privilege (Privileged ISA mcause table, machine.adoc lines 1880-1900):

executed inmcause exception codename
U-mode8Environment call from U-mode
S-mode9Environment call from S-mode
M-mode11Environment call from M-mode

Three distinct cause codes (rather than one) means the higher-privilege handler instantly knows the privilege of the caller and does not need to consult mstatus.MPP separately. It also lets a kernel selectively delegate U-mode ecall (cause 8) to S-mode while keeping S-mode ecall (cause 9) trapping to M-mode; that selectivity is what makes the SBI design work. The unprivileged spec explains the design intent: “ECALL generates a different exception for each originating privilege mode so that environment call exceptions can be selectively delegated. A typical use case for Unix-like operating systems is to delegate to S-mode the environment-call-from-U-mode exception but not the others” (unpriv intro.adoc lines 615-617, paraphrased in tutorial form).

Beyond the cause code, the trap-entry sequence is identical to any other synchronous exception (see RISC-V Trap Handling for the five-step CSR update). mepc is set to the address of the ecall instruction. mtval is typically zero. mstatus.MPP records the source privilege.

The handler must advance mepc by 4

ecall is a request, not a “skip me” hint to the hardware. Unlike x86’s syscall (which returns to the instruction following), RISC-V leaves mepc pointing at the ecall itself, and the handler must explicitly increment mepc by 4 before mret/sret, or the very next mret will land back on the same ecall and trap immediately. This is the single most reliable way to write an infinite loop by accident. The convention is documented across every RISC-V OS tutorial: “The handler must increment the program counter by 4 bytes to avoid re-executing ecall” (osblog.stephenmarz.com ch7).

The handler’s PC-advance also doubles as the signal to the user-mode program that the syscall was accepted: code that loops doing csrr t0, mepc; jr t0 (a manual restart) is broken, because the user’s PC is the post-ecall address as far as user code is concerned.

Privilege transition vs delegation

By default an ecall traps to M-mode, no matter the source privilege. This is the right behavior on a bare M-mode-only system (the v1.0 of definitely-not-esp32, which has no S-mode). For systems that do implement S-mode, the kernel typically delegates cause 8 (U-mode ecall) to S-mode by setting medeleg[8], so the syscall does not pay the cost of a round trip through M-mode. The full delegation story is in RISC-V Trap Delegation; the key point here is that the instruction itself does not know whether its trap will land in M or S. The hardware looks at medeleg and routes accordingly.

There is one rule the spec spells out: ecall from M-mode (cause 11) cannot be delegated, because medeleg[11] is read-only zero (machine.adoc lines 1329-1331). M-mode is already the highest privilege; there is nowhere “more privileged” to delegate to. This makes M-mode ecall a degenerate case useful mostly for early-boot debugging and for the M-mode-runs-everything model in deeply embedded systems.

The Linux syscall convention (U-mode ecall)

Linux on RISC-V (riscv32, riscv64) uses the ecall Instruction as the syscall trigger with the following ABI, documented in the Linux syscall(2) manual page’s per-architecture table (man syscall(2)):

elementregisterrole
instruction(none)ecall
syscall numbera7which syscall (numbers in include/uapi/asm-generic/unistd.h)
arg 0..5a0..a5up to six arguments
return valuea0success: non-negative; failure: -errno (small magnitude negative integer)
error indication(none)distinguished by sign of return

The userspace wrapper (glibc, musl) does the convention: copy syscall number into a7, copy args into a0..a5, execute ecall, copy a0 into the return-value slot, and if a0 is negative-with-small-magnitude, store -a0 into errno and return -1.

RISC-V Linux uses the generic syscall numbering from include/uapi/asm-generic/unistd.h, the same numbering used by aarch64 and several other newer architectures. The relevant numbers for a hello-world include __NR_write = 64, __NR_exit = 93, __NR_openat = 56 (Linux dropped the no-at versions on newer ports). The kernel header line is exactly:

#define __NR_write 64
__SYSCALL(__NR_write, sys_write)

at line ~50-ish of include/uapi/asm-generic/unistd.h, with RISC-V architectures defined to use the generic table (asm-generic/unistd.h).

The SBI calling convention (S-mode ecall)

When an S-mode kernel (Linux, seL4, NuttX, RustOS) needs a service from M-mode firmware, it makes an SBI call by setting a7 = EID, a6 = FID, a0..a5 = arguments, and executing ecall. The SBI specification documents the convention verbatim (riscv-sbi-doc binary-encoding.adoc):

All SBI functions share a single binary encoding, which facilitates the mixing of SBI extensions. The SBI specification follows the below calling convention.

  • An ECALL is used as the control transfer instruction between the supervisor and the SEE.
  • a7 encodes the SBI extension ID (EID).
  • a6 encodes the SBI function ID (FID) for a given extension ID encoded in a7 for any SBI extension defined in or after SBI v0.2.
  • a0 through a5 contain the arguments for the SBI function call.
  • All registers except a0 & a1 must be preserved across an SBI call by the callee.
  • SBI functions must return a pair of values in a0 and a1, with a0 returning an error code.

The return is struct sbiret { long error; long value; } packed into a0 and a1. Error codes are negative integers like SBI_SUCCESS = 0, SBI_ERR_NOT_SUPPORTED = -2, SBI_ERR_INVALID_PARAM = -3, listed in the binary-encoding section of the spec.

The two ABIs are deliberately analogous but distinct. Linux puts the syscall number in a7; SBI puts the EID in a7 and the FID in a6. A kernel that does both (every Linux/RISC-V kernel) keeps them straight because Linux syscalls are made from U-mode (cause 8) and SBI calls are made from S-mode (cause 9), so the wrong layer never sees the other’s convention.

Calling convention versus instruction semantics

A point worth dwelling on, because it confuses newcomers: the ecall instruction does not specify what is in a7, a0..a5, etc. The spec deliberately keeps the instruction-level definition minimal (“a service request to the execution environment, parameters per the EEI”) and pushes the calling convention up into the EEI/ABI/SBI specifications. The same ecall opcode can be:

  • the Linux syscall trigger with a7 = syscall number
  • the SBI call trigger with a7 = EID, a6 = FID
  • a microkernel IPC call with a different per-kernel convention (the user’s definitely-not-esp32 kernel will pick its own)
  • a hypervisor call with the H-extension’s VS-ecall semantics

The instruction is transport; the EEI is protocol.

Configuration / Code / Specification

Worked example 1: hello-world write() from a Linux user program

Goal: print the four bytes “hi\n” to file descriptor 1 (stdout).

    .data
msg:        .ascii  "hi\n"
msg_len = . - msg                # 3 bytes
 
    .text
    .global _start
_start:
    # Set up arguments for sys_write(fd, buf, count).
    li      a0, 1                # a0 = fd = 1 (stdout)
    la      a1, msg              # a1 = buf = address of "hi\n"
    li      a2, msg_len          # a2 = count = 3
 
    # Syscall number for write is 64 (per asm-generic/unistd.h).
    li      a7, 64               # a7 = __NR_write
 
    ecall                        # trap into the kernel
 
    # On return, a0 = number of bytes written (3) or -errno.
    # Now exit cleanly.
    li      a0, 0                # a0 = exit code 0
    li      a7, 93               # a7 = __NR_exit
    ecall                        # never returns

Step by step:

  1. li a0, 1 puts the file descriptor in a0 (the first argument register).
  2. la a1, msg places the address of the message bytes in a1.
  3. li a2, msg_len places the byte count in a2.
  4. li a7, 64 puts the syscall number for write in a7 (verified from asm-generic/unistd.h: #define __NR_write 64).
  5. ecall traps. The hart was in U-mode, so mcause becomes 8 (Environment call from U-mode). The hardware sets sepc (or mepc if undelegated) to the address of this ecall and changes privilege to S-mode (or M-mode if undelegated), jumping to stvec/mtvec.
  6. The Linux kernel’s handle_exception enters, saves the user register file into pt_regs, recognizes scause = 8 (the user-ecall case), advances sepc by 4 (so the eventual sret returns to the instruction after the ecall), and dispatches to the syscall table. With a7 = 64, the kernel calls sys_write(fd=1, buf=&msg, count=3).
  7. sys_write does the actual work and returns the byte count (3) which the kernel stores into pt_regsa0.
  8. The kernel runs sret, which restores the user privilege level and jumps to sepc (now pointing at li a0, 0).
  9. The user program continues with a0 = 3, then does the exit syscall the same way.

If write had failed (say, fd = 1 was closed), the kernel would have stored a small negative integer like -9 (-EBADF) in a0. The user wrapper interprets a0 < 0 && a0 > -4096 as an error, sets errno = -a0, and returns -1 from the C write() function.

Worked example 2: an S-mode kernel calls SBI to print a character

Goal: an S-mode kernel writes the byte ‘X’ to the console via the SBI Debug Console Extension (EID 0x4442434E = “DBCN”, FID 0 = console_write).

    .data
ch:         .byte   'X'
 
    .text
print_x:
    li      a0, 1                # a0 = num_bytes = 1
    la      a1, ch               # a1 = base_addr_lo (low XLEN bits of address)
    li      a2, 0                # a2 = base_addr_hi = 0 on RV32, or upper bits on RV64
    li      a6, 0                # a6 = FID 0 (sbi_debug_console_write)
    li      a7, 0x4442434E       # a7 = EID "DBCN"
    ecall                        # S-mode ecall, mcause = 9 in OpenSBI
 
    # On return:
    #   a0 = sbiret.error  (0 = SBI_SUCCESS, or negative error)
    #   a1 = sbiret.value  (number of bytes actually written)
    ret

Step by step:

  1. The kernel constructs the argument tuple: number of bytes, low and high halves of the physical address of the byte buffer.
  2. li a6, 0 chooses function 0 of the DBCN extension (console_write); li a7, 0x4442434E chooses the DBCN extension itself.
  3. ecall traps. The hart is in S-mode, so mcause = 9 (Environment call from S-mode). medeleg[9] is not set (the kernel does not delegate its own ecalls to itself), so the trap goes to M-mode.
  4. OpenSBI’s sbi_trap_handler runs, sees mcause = 9, looks at a7 to find the EID (DBCN), then a6 for the FID (0), and dispatches to sbi_debug_console_write(1, &ch, 0).
  5. The function does an MMIO write to the UART transmit register, returns sbiret{ error: SBI_SUCCESS, value: 1 }.
  6. OpenSBI packs the result back into a0 (error) and a1 (value), advances mepc by 4, and executes mret.
  7. The kernel resumes after the ecall with a0 = 0, a1 = 1, returns to its caller.

The same mechanism scales up: for a 1024-byte buffer, the kernel passes num_bytes = 1024 and a single physical address; OpenSBI streams the bytes to the UART. The U-mode user program that originated the print would have called write(1, "...", 1024), which the kernel forwarded by an SBI call.

Worked example 3: a microkernel using ecall for IPC

In the user’s definitely-not-esp32 project, the Microkernel uses ecall as the message-passing trigger between user processes. The convention is the kernel’s own to pick:

// User-side IPC stub.
long ipc_send(int dest, void *msg, size_t len) {
    register long a7 asm("a7") = IPC_SYSCALL_SEND;
    register long a0 asm("a0") = dest;
    register long a1 asm("a1") = (long)msg;
    register long a2 asm("a2") = len;
    asm volatile ("ecall"
                  : "+r"(a0)
                  : "r"(a7), "r"(a1), "r"(a2)
                  : "memory");
    return a0;
}

This looks identical to a Linux syscall stub because the transport (ecall + a7 = call number) is identical; the call set (IPC primitives only, no POSIX) is the kernel’s choice. The handler reads mcause = 8, dispatches on a7, runs the IPC code, and returns through mret.

Failure Modes and Common Misunderstandings

The biggest pitfall is forgetting to advance mepc. A handler that does its work, restores all registers, and then mret’s without having added 4 to mepc loops forever on the same ecall. The bug is so common that it has become idiomatic to write the increment as the first action of the dispatcher.

A close second is conflating Linux’s a7-syscall-number convention with SBI’s a7-EID-plus-a6-FID convention. A kernel that calls into SBI with a7 = 64 (because that worked for syscalls) gets a “not supported” error or unspecified behavior, because EID 64 (0x40) is not a registered SBI extension. The two ABIs use the same registers by accident of design, not by interchangeability.

Wrong direction of the call. ecall always traps to a higher privilege. A user program that tries to ecall into another user program will trap to the kernel, not the other program; the kernel must explicitly synthesize the IPC. There is no peer-to-peer ecall.

Assuming compressed ecall. Some new developers assume that ecall, like most short instructions, has a 2-byte form. It does not; ecall is always 4 bytes (the SYSTEM-opcode instructions are not part of the C extension). This matters when a handler needs to advance mepc: always by 4, never by 2.

Misreading the return-error convention. Linux returns -errno in a0 for error; SBI returns a struct with error in a0 and value in a1. A wrapper that only reads a0 from an SBI call discards the value half; a wrapper that treats a0 as -errno for an SBI call gets the codes wrong (SBI_ERR_NOT_SUPPORTED is -2, not -ENOSYS).

ebreak vs ecall confusion. ebreak (same opcode, funct12 = 1) is for debugger entry; it raises cause 3 (Breakpoint), not cause 8/9/11. A program that uses ebreak for syscalls has invented its own debugger protocol by accident.

Alternatives and When to Choose Them

x86 int 0x80 / syscall: x86 has historically had two syscall mechanisms. int 0x80 raises a software interrupt that vectors through the IDT; syscall (SYSENTER on 32-bit) is a faster path that bypasses the IDT and goes directly to a register-specified handler. RISC-V’s ecall is closer in spirit to syscall (one instruction, direct transition to a known handler) but uses the existing trap mechanism rather than a separate fast path. The cost is a few extra cycles on entry (the cause-code dispatch); the benefit is one mechanism to learn and verify.

ARM svc: ARM’s Supervisor Call instruction (formerly SWI) takes an 8-bit immediate that the handler can use as a function code. RISC-V chose not to include such an immediate in ecall, on the theory that the calling convention can put the function code in a register (a7) just as easily, and not burning bits in the instruction encoding leaves the SYSTEM opcode space tidy for other uses. The tradeoff is one extra register-load before the ecall.

MIPS syscall: MIPS’s syscall instruction is direct analog to ecall, with v0 holding the syscall number and arguments in a0..a3. The semantics are nearly identical; the only meaningful difference is that MIPS has no SBI-like layered design, so syscalls always go directly to the kernel.

For definitely-not-esp32’s v1.0, ecall is the only syscall mechanism worth implementing. The microkernel runs in M-mode with no S-mode, so all user-process ecalls (cause 8) trap directly into the kernel; there is no SBI layer. The dispatch is a small switch on a7 that calls the IPC primitive or the rare syscall the microkernel exposes.

Production Notes

In Linux’s RISC-V port the user-mode ecall is handled by do_trap_ecall_u (defined in arch/riscv/kernel/traps.c), reached via the exception vector table in arch/riscv/kernel/entry.S (torvalds/linux entry.S, excp_vect_table). The handler advances sepc by 4 (or 2 if the ecall was on a 2-byte-aligned address, but ecall is never compressed so this is always 4), then enters the generic syscall entry path that looks up sys_call_table[a7].

The Linux kernel itself uses ecall for SBI calls when running on a system that boots through OpenSBI or RustSBI (the common case). The wrappers live in arch/riscv/include/asm/sbi.h as small inline-asm helpers, one per SBI extension. The pattern is the same ecall with a7/a6 set; the kernel does not need a separate “SBI instruction” because the privilege-and-trap machinery already routes correctly.

For deeply embedded RV32 cores (the ESP32-C3, plus the definitely-not-esp32 core in its M+U configuration), there is no SBI and no S-mode. The user-mode ecall traps straight to the M-mode kernel via cause 8, and the kernel never makes an ecall of its own. This is the simplest possible configuration and is exactly what the user’s project will demonstrate.

A subtle production gotcha: on cores that implement the H extension (Hypervisor), there is a fourth ecall cause, “Virtual supervisor environment call” (cause 10), raised when ecall is executed in VS-mode. Most current systems leave the H extension unimplemented; if definitely-not-esp32 ever grows a hypervisor mode, the trap handler will need a case for cause 10. The privileged spec keeps cause 10 as “Reserved” in the cause table specifically so the H-extension addition could slot in cleanly later (and indeed, with the H extension ratified, cause 10 is now “Virtual supervisor environment call from VS-mode” in the H-extension spec).

The riscv-elf-psabi-doc repository documents the broader ABI conventions (calling convention, register usage, naming) that underpin the syscall convention shown above; the syscall convention is consistent with the procedure-call convention so that compilers can pass arguments to syscall wrappers exactly as they would to any other function (riscv-elf-psabi-doc).

See Also