Supervisor Binary Interface

The Supervisor Binary Interface (SBI) is the RISC-V ABI between M-mode firmware (OpenSBI, RustSBI, the legacy Berkeley Boot Loader) and the S-mode kernel (Linux, seL4, NuttX, Xen, KVM hosts). It is the RISC-V analog of “BIOS plus ACPI plus PSCI” rolled into one: a small, standardized table of ecall-based function calls that the kernel uses to ask the firmware to do platform-specific work (program the timer, send an inter-processor interrupt, write to the debug console, suspend a hart, reboot the system) without itself touching the M-mode hardware. The spec defines the role in two sentences: “This specification describes the RISC-V Supervisor Binary Interface, known from here on as SBI. The SBI allows supervisor-mode (S-mode or VS-mode) software to be portable across all RISC-V implementations by defining an abstraction for platform (or hypervisor) specific functionality” (riscv-sbi-doc intro.adoc). The latest ratified version, as of 2026-05, is v3.0 (riscv-sbi-doc changelog.adoc, top entry: “Version 3.0 / Update the document state to Ratified”); v2.0 is the previous milestone, also ratified. The same single instruction (ecall from S-mode, cause code 9) carries every SBI call; the convention layered on top distinguishes them via Extension ID (EID, in register a7) and Function ID (FID, in a6).

Uncertain

Verify: SBI v3.0 ratification status as a stable, widely-deployed version. Reason: the spec’s own changelog.adoc marks v3.0 as “Ratified” (top entry), but the RustSBI README still claims “supports v2.0 ratified” as of the project’s recent release, and most production firmware in the wild still ships v2.0 or v1.0. v3.0 is genuinely ratified per the spec repo but the ecosystem has not yet caught up. To resolve: check the RISC-V International ratified-specs page for the formal ratification date, and survey OpenSBI release notes to see when v3.0 features actually appear in shipping firmware.

This note is the central reference for the SBI ABI and its implementations. The instruction that drives every SBI call is ecall Instruction. The trap mechanism is RISC-V Trap Handling. The CSR delegation that arranges for kernel ecalls to reach M while user-mode ecalls reach S is RISC-V Trap Delegation. The microkernel notes (Microkernel) describe the OS-side counterpart.

Mental Model: A Thin Tabular ABI Between Firmware and Kernel

SBI exists because the alternative is worse. Without it, every RISC-V kernel would have to know the platform’s exact memory map for the timer, the exact MMIO offset for “send IPI to hart N,” the exact reset sequence, and so on. With SBI, the kernel asks the firmware “set the timer for absolute time T” and does not care whether the platform’s timer is the standard CLINT or a vendor-specific block. The firmware in M-mode is the only software that needs the platform-specific knowledge.

The transport is the [[ecall Instruction|ecall]] instruction from S-mode (mcause = 9, undelegated). The kernel sets up a7 (Extension ID), a6 (Function ID), and a0..a5 (arguments), executes ecall, traps to M-mode, the firmware reads the registers, dispatches to the function, and returns the result in {a0 = error, a1 = value}.

flowchart LR
  subgraph S["S-mode kernel (Linux, seL4, NuttX, ...)"]
    K["kernel code"]
    KSTUB["SBI stub in arch/riscv/include/asm/sbi.h<br/>sets a7=EID, a6=FID, a0..a5=args<br/>then ecall"]
    K --> KSTUB
  end
  subgraph M["M-mode firmware (OpenSBI / RustSBI / BBL)"]
    TH["sbi_trap_handler<br/>reads mcause = 9<br/>reads a7 (EID), a6 (FID)"]
    DISP["dispatch table<br/>per (EID, FID) → function"]
    IMPL["per-extension implementation<br/>e.g. DBCN console_write<br/>= MMIO write to UART"]
    TH --> DISP --> IMPL
  end
  subgraph H["Hardware"]
    UART["UART"]
    TIMER["CLINT mtimecmp"]
    PLIC["PLIC"]
  end
  KSTUB -- "ecall (cause 9)" --> TH
  IMPL -. "writes" .-> UART
  IMPL -. "writes" .-> TIMER
  IMPL -. "configures" .-> PLIC
  IMPL -- "sbiret { error, value } in a0, a1" --> KSTUB
  KSTUB -- "mret restores S-mode" --> K

The SBI call flow. What it shows: every SBI call rides the same ecall transport; what distinguishes one call from another is the pair (EID in a7, FID in a6). The firmware does the platform-specific work and returns a two-value result through a0 and a1. The insight to take: SBI is an ABI specification first; the implementations (OpenSBI, RustSBI) are software projects that satisfy the spec, not parts of it.

Mechanical Walk-through: The ABI, the Extensions, the Implementations

What SBI is, what it is not

SBI is a specification, not a program. It defines the rules a piece of M-mode firmware must follow to be “an SBI implementation.” It is roughly analogous to the System V ABI on x86, or to PSCI on ARM: a contract that lets a kernel built once run on any conforming firmware.

The spec is maintained as the riscv-non-isa/riscv-sbi-doc repository on GitHub, owned by RISC-V International’s Platform Runtime Services Task Group. The latest ratified version (per the changelog at the time of writing) is v3.0 (riscv-sbi-doc changelog.adoc, entry “Version 3.0 - Update the document state to Ratified”). v2.0 is the previous milestone, also ratified. Implementations report whichever version they conform to via the Base Extension’s sbi_get_spec_version() function.

The implementations are separate projects:

  • OpenSBI (riscv-software-src/opensbi): the C reference implementation, maintained by RISC-V International, originally contributed by Western Digital. BSD-2-Clause licensed. The de facto default firmware on most Linux-bootable RISC-V hardware (QEMU virt, SiFive HiFive Unleashed / Unmatched, Allwinner D1, etc.).
  • RustSBI (rustsbi/rustsbi): a Rust implementation, written as a library plus per-platform binaries. Supports SBI 2.0 (and tracking 3.0). Used in research kernels, in some boards’ first-stage boot, and as a teaching reference. Implementation ID 4 in the SBI spec’s registry.
  • Berkeley Boot Loader (BBL): the original SBI implementation, predating the formal spec. Implementation ID 0. Now largely retired in favor of OpenSBI but still encountered in older Linux distributions and educational projects.
  • Other registered implementations include Xvisor (ID 2, a Type-1 hypervisor that also speaks SBI to its guests), KVM (ID 3, the Linux KVM module exposes a virtual SBI to L1 guests), Diosix (5), Coffer (6), Xen Project (7), PolarFire Hart Software Services (8), coreboot (9), oreboot (10), bhyve (11) (ext-base.adoc Implementation IDs table).

The distinction matters: when documenting “what SBI is,” refer to the spec; when documenting “what your firmware does,” refer to OpenSBI or RustSBI specifically.

The binary encoding

The SBI calling convention is a per-call subset of the standard RISC-V ELF psABI procedure-call convention. The spec lays it out verbatim (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. Registers that are not defined in the SBI function call are not reserved.
  • 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 two-value return uses a C struct sbiret:

struct sbiret {
    long error;
    union {
        long value;
        unsigned long uvalue;
    };
};

The error half is a signed integer drawn from a small standard set (binary-encoding.adoc, Standard SBI Errors table):

codenamemeaning
0SBI_SUCCESSCompleted successfully
-1SBI_ERR_FAILEDFailed
-2SBI_ERR_NOT_SUPPORTEDNot supported
-3SBI_ERR_INVALID_PARAMInvalid parameter(s)
-4SBI_ERR_DENIEDDenied or not allowed
-5SBI_ERR_INVALID_ADDRESSInvalid address(s)
-6SBI_ERR_ALREADY_AVAILABLEAlready available
-7SBI_ERR_ALREADY_STARTEDAlready started
-8SBI_ERR_ALREADY_STOPPEDAlready stopped
-9SBI_ERR_NO_SHMEMShared memory not available
-10SBI_ERR_INVALID_STATEInvalid state
-11SBI_ERR_BAD_RANGEBad (or invalid) range
-12SBI_ERR_TIMEOUTFailed due to timeout
-13SBI_ERR_IOInput/Output error
-14SBI_ERR_DENIED_LOCKEDDenied or not allowed due to lock status

The value half is per-function: number of bytes written for console_write, current spec version for get_spec_version, current hart state for hart_get_status, etc. When error is non-zero, the spec says value is unspecified unless explicitly defined for that function.

The spec also calls out that EIDs and FIDs are encoded as signed 32-bit integers, and that for the legacy v0.1 extensions, only a7 was used (no a6); legacy calls also have a different return convention (the legacy extensions return a single value, not the sbiret pair). Modern code should never use legacy extensions; the spec keeps them around only for backward compatibility with old kernels.

The extension model

SBI is modular. The spec defines a core Base Extension (mandatory, EID 0x10) plus a growing set of optional extensions, each identified by its own EID. A kernel uses sbi_probe_extension(EID) to discover which optional extensions the firmware implements.

The current ratified extensions, with their EIDs, are:

extensionEIDrole
Base0x10spec version, impl ID, extension probe
Timer (TIME)0x54494D45 (“TIME”)program S-mode timer interrupt
IPI (sPI)0x735049 (“sPI”)inter-processor interrupt
RFENCE0x52464E43 (“RFNC”)remote TLB and instruction-cache fences
HSM0x48534D (“HSM”)hart state management (start/stop/suspend)
SRST0x53525354 (“SRST”)system reset (shutdown, reboot)
PMU0x504D55 (“PMU”)performance monitoring
DBCN0x4442434E (“DBCN”)debug console
SUSP0x53555350 (“SUSP”)system suspend
CPPC0x43505043 (“CPPC”)collaborative processor performance control
NACL0x4E41434C (“NACL”)nested acceleration (for KVM-on-RISC-V)
STA0x535441 (“STA”)steal-time accounting (for guest VMs)
FWFT0x46574654 (“FWFT”)firmware features
DBTR0x44425452 (“DBTR”)debug triggers
MPXY0x4D505859 (“MPXY”)message proxy
SSE0x535345 (“SSE”)supervisor software events

(Each EID verified from the corresponding ext-*.adoc file in riscv-sbi-doc/src; the EID is usually the ASCII for the extension’s short name, which doubles as a mnemonic.)

The Base Extension is mandatory; every SBI implementation supports it. The others are negotiable: a small embedded SBI may implement only Base, Timer, IPI, and DBCN; a Linux-hosting firmware will implement essentially all of them. The spec says: “An SBI extension defines a set of SBI functions which provides a particular functionality to supervisor-mode software. SBI extensions as a whole are optional and cannot be partially implemented unless an SBI extension defines a mechanism to discover implemented SBI functions” (intro.adoc lines 10-14).

The Base Extension (EID 0x10)

Mandatory, defines the discovery primitives. Its seven functions are (ext-base.adoc Base Function List):

FIDfunctionreturns
0sbi_get_spec_version()SBI spec version (major in bits 30:24, minor in bits 23:0)
1sbi_get_impl_id()implementation ID (0=BBL, 1=OpenSBI, 4=RustSBI, …)
2sbi_get_impl_version()implementation-specific version
3sbi_probe_extension(EID)0 if not supported, non-zero (typically 1) if supported
4sbi_get_mvendorid()value legal for the mvendorid CSR (0 is always legal)
5sbi_get_marchid()value legal for the marchid CSR
6sbi_get_mimpid()value legal for the mimpid CSR

A kernel’s first SBI call is essentially always sbi_get_spec_version() followed by sbi_probe_extension() for each extension it needs. From the responses it knows whether to use the Timer/IPI extensions or fall back to legacy paths.

The Timer Extension (EID 0x54494D45 = “TIME”)

A single function, sbi_set_timer(uint64_t stime_value). The kernel asks the firmware to deliver the supervisor timer interrupt (mip.STIP, which surfaces as sip.STIP after delegation) when the time counter reaches stime_value. The spec (ext-time.adoc) notes that “If the supervisor wishes to clear the timer interrupt without scheduling the next timer event, it may request a timer interrupt infinitely far into the future (i.e., (uint64_t)-1).” This is exactly how Linux’s RISC-V tick handler clears the timer: it writes the next desired tick time, or -1 if there is no next tick.

The reason this is in SBI rather than direct CSR access: on a standard CLINT-based platform, the kernel could just write mtimecmp directly. But mtimecmp is an M-mode-only MMIO register; an S-mode kernel cannot reach it without trapping. The Timer Extension is the standardized syscall that exposes the equivalent functionality without requiring the kernel to know the MMIO address.

The IPI Extension (EID 0x735049 = “sPI”)

Function: sbi_send_ipi(unsigned long hart_mask, unsigned long hart_mask_base). Sends inter-processor interrupts to the harts indicated by hart_mask (a bit vector of hart IDs, with the bit 0 corresponding to hart_mask_base). The receiving harts see a Supervisor Software Interrupt (sip.SSIP) and dispatch accordingly. From the spec: “Send an inter-processor interrupt to all the harts defined in hart_mask. Interprocessor interrupts manifest at the receiving harts as the supervisor software interrupts” (ext-ipi.adoc).

The hart_mask + hart_mask_base scheme allows the kernel to address up to XLEN harts in one call. For more than XLEN harts the kernel makes multiple calls, each with a different hart_mask_base.

The HSM (Hart State Management) Extension (EID 0x48534D)

This extension is the SBI replacement for the legacy “boot all harts simultaneously” model. It introduces a small state machine per hart (ext-hsm.adoc, HSM Hart States table):

state IDnamemeaning
0STARTEDhart is powered up and running
1STOPPEDhart is parked (possibly physically powered down)
2START_PENDINGtransition STOPPED → STARTED is in progress
3STOP_PENDINGtransition STARTED → STOPPED is in progress
4SUSPENDEDhart is in a platform-specific low-power state
5SUSPEND_PENDINGtransitioning into SUSPENDED
6RESUME_PENDINGtransitioning out of SUSPENDED

The functions are sbi_hart_start(hartid, start_addr, opaque), sbi_hart_stop(), sbi_hart_get_status(hartid), sbi_hart_suspend(suspend_type, resume_addr, opaque). The boot-time pattern: hart 0 is the boot hart and starts in STARTED; all other harts are in STOPPED at reset. The kernel starts each secondary hart by calling sbi_hart_start(N, kernel_secondary_entry, opaque). The firmware brings hart N up, sets up its registers per a specific contract (satp = 0, sstatus.SIE = 0, a0 = hartid, a1 = opaque), and jumps to kernel_secondary_entry in S-mode.

This is the equivalent of ARM’s PSCI CPU_ON/CPU_OFF, and it is the reason SBI is sometimes called “the RISC-V PSCI.”

The SRST (System Reset) Extension (EID 0x53525354)

A single function, sbi_system_reset(uint32_t reset_type, uint32_t reset_reason). The reset types are (ext-sys-reset.adoc, SRST System Reset Types table):

valuetype
0x00000000Shutdown
0x00000001Cold reboot
0x00000002Warm reboot
0x00000003 - 0xEFFFFFFFReserved
0xF0000000 - 0xFFFFFFFFVendor or platform specific

The reset reason is a hint: 0 = no reason, 1 = system failure, and platform-defined ranges. This is what Linux’s reboot() syscall ends up invoking on RISC-V.

The Debug Console Extension (EID 0x4442434E = “DBCN”)

Three functions: sbi_debug_console_write(num_bytes, base_addr_lo, base_addr_hi), sbi_debug_console_read(...), sbi_debug_console_write_byte(byte). The Debug Console Extension replaces the legacy sbi_console_putchar (EID 0x01) and sbi_console_getchar (EID 0x02), which were one-byte-at-a-time and used the legacy single-value return.

The DBCN extension lets the kernel print many bytes per call (handy for early-boot panic messages where the UART driver may not yet be initialized) and uses physical addresses (so it works before the kernel has set up its own page tables for the UART). ext-debug-console.adoc: “Write bytes to the debug console from input memory. The num_bytes parameter specifies the number of bytes in the input memory. The physical base address of the input memory is represented by two XLEN bits wide parameters.”

The RFENCE extension (EID “RFNC”) provides remote fence.i and sfence.vma for multi-hart TLB and instruction-cache invalidation; PMU exposes performance counters; the rest are niche or evolving.

OpenSBI: the reference implementation

OpenSBI (github.com/riscv-software-src/opensbi) is a C library plus three reference firmware variants (opensbi/docs/firmware/fw.md):

  • FW_PAYLOAD: the OpenSBI binary directly contains the next stage (typically the kernel image or U-Boot SPL) embedded in its .rodata. Used when the prior boot stage cannot load multiple separate binaries. The downside is that updating the kernel requires rebuilding the firmware.
  • FW_JUMP: OpenSBI is loaded separately from the next stage, and is told (at build time) the address to jump to after initialization. Used when the prior stage can place two binaries in memory and the next-stage entry is at a known fixed address.
  • FW_DYNAMIC: the most flexible. The prior boot stage passes a struct fw_dynamic_info (via a2) containing the next-stage entry address, the privilege level to enter, and other runtime options. This is what U-Boot’s SPL and most modern bootloaders use.

The reference firmware entry contract is a0 = hartid, a1 = device tree blob address, a2 = (for FW_DYNAMIC) pointer to fw_dynamic_info (fw.md, register convention; also documented in Uros Popovic’s SBI boot walkthrough).

Once initialized, OpenSBI sets up trap delegation (per RISC-V Trap Delegation), installs its own trap handler in mtvec, sets mstatus.MPP = S, sets mepc to the next stage’s entry, and executes mret. The kernel then runs in S-mode and makes SBI calls back into M-mode for every platform-specific operation.

OpenSBI’s library API (the static library libsbi.a) lets a vendor build a custom M-mode firmware that still speaks SBI without reimplementing the spec (opensbi/docs/library_usage.md). The main entry is sbi_init() and the trap entry is sbi_trap_handler().

RustSBI: the Rust alternative

RustSBI is a clean-room Rust implementation, designed as a library (rustsbi on crates.io) plus per-platform binaries (rustsbi-qemu, rustsbi-d1, …). Its README states it “builds under stable Rust” and “supports RISC-V SBI specification v2.0 ratified” (rustsbi/rustsbi). Implementation ID is 4 in the SBI registry.

The interesting design choice is that RustSBI is library-first: a hardware vendor writes a small Rust binary that pulls in the rustsbi crate, fills in platform-specific bits (UART driver, CLINT/PLIC addresses), and the library handles the SBI dispatch. Compare to OpenSBI, which is firmware-first (the reference firmware is itself an executable). Both approaches work; the trade-off is build complexity vs deployment flexibility.

RustSBI is the natural pairing for a Rust microkernel like definitely-not-esp32’s, if the project ever grows S-mode. The Rust-on-Rust pairing keeps the boot story uniform.

Versioning

The SBI spec uses semantic-version-style numbering: major.minor, with the major number incremented for incompatible changes. The ratified versions to date (changelog.adoc):

versionnotable changes
0.1legacy extensions (PUTCHAR, GETCHAR, single-value return)
0.2sbiret two-value return, modular extensions (Base, Timer, IPI), a6 becomes the FID
0.3RFENCE extension hardened, HSM clarified
1.0first formally ratified version (mid-2022), adds SRST, PMU
2.0ratified (~2024), adds CPPC, NACL, STA, DBCN, others
3.0ratified (recent), adds SSE, MPXY, FWFT, DBTR

Software queries the runtime spec version via sbi_get_spec_version(). A modern Linux kernel typically requires SBI 0.2 minimum (so it gets the modular extension model), and uses 1.0+ features (HSM, SRST) when available.

Configuration / Code / Specification

A minimal Linux kernel SBI stub

Linux’s RISC-V SBI helpers live in arch/riscv/include/asm/sbi.h and arch/riscv/kernel/sbi.c. The core inline-asm pattern is:

struct sbiret sbi_ecall(int ext, int fid, unsigned long arg0,
                        unsigned long arg1, unsigned long arg2,
                        unsigned long arg3, unsigned long arg4,
                        unsigned long arg5)
{
    struct sbiret ret;
    register uintptr_t a0 asm ("a0") = (uintptr_t)(arg0);
    register uintptr_t a1 asm ("a1") = (uintptr_t)(arg1);
    register uintptr_t a2 asm ("a2") = (uintptr_t)(arg2);
    register uintptr_t a3 asm ("a3") = (uintptr_t)(arg3);
    register uintptr_t a4 asm ("a4") = (uintptr_t)(arg4);
    register uintptr_t a5 asm ("a5") = (uintptr_t)(arg5);
    register uintptr_t a6 asm ("a6") = (uintptr_t)(fid);
    register uintptr_t a7 asm ("a7") = (uintptr_t)(ext);
    asm volatile ("ecall"
                  : "+r" (a0), "+r" (a1)
                  : "r" (a2), "r" (a3), "r" (a4),
                    "r" (a5), "r" (a6), "r" (a7)
                  : "memory");
    ret.error = a0;
    ret.value = a1;
    return ret;
}

Every higher-level SBI function (set_timer, send_ipi, …) is a thin wrapper around sbi_ecall that fills in the EID and FID constants. The "memory" clobber ensures the compiler does not reorder memory accesses across the SBI call (the firmware can do anything to memory).

Probing for an extension before using it

long sbi_probe(int ext)
{
    struct sbiret ret = sbi_ecall(SBI_EXT_BASE, SBI_EXT_BASE_PROBE_EXT,
                                  ext, 0, 0, 0, 0, 0);
    if (ret.error != SBI_SUCCESS)
        return -1;
    return ret.value;        // 0 = absent, nonzero = present
}
 
void use_timer(uint64_t deadline)
{
    if (sbi_probe(SBI_EXT_TIME) > 0) {
        sbi_ecall(SBI_EXT_TIME, SBI_EXT_TIME_SET_TIMER,
                  deadline, deadline >> 32, 0, 0, 0, 0);
    } else {
        legacy_set_timer(deadline);   // EID 0, legacy single-arg convention
    }
}

The probe pattern is critical because not every firmware implements every extension. A kernel that hard-codes sbi_set_timer without probing will fail on a minimal SBI implementation.

A worked SBI call from the kernel side

Consider an S-mode Linux kernel that wants to suspend hart 3 (the hart calling sbi_hart_suspend stops executing and resumes at a given address when woken). The kernel constructs:

  • a7 = 0x48534D (HSM EID)
  • a6 = 1 (sbi_hart_stop FID; suspend is a different FID, but stop keeps the example simple)
  • a0..a5 = (none for stop)

Then ecall. The hart in S-mode generates a trap with mcause = 9 (Environment call from S-mode). mideleg/medeleg do not delegate cause 9 to S, so the trap goes to M-mode. OpenSBI’s trap handler runs, decodes the EID/FID, dispatches to its hart-stop implementation, marks the hart STOPPED in the per-hart state, executes WFI in a tight loop, and the firmware never returns until another hart calls sbi_hart_start on this hart.

A worked example walked end-to-end in code by Uros Popovic (popovicu.com SBI boot walkthrough) shows the same pattern for DBCN: a7 = 0x4442434E, a6 = 0, a0 = num_bytes, a1 = address_lo, a2 = address_hi, ecall, sbiret returned with error in a0 and bytes-written in a1.

A minimal OpenSBI-like dispatch in M-mode

Pseudocode for what OpenSBI’s sbi_ecall_handler does on entry:

struct sbiret handle_sbi_ecall(unsigned long a0, unsigned long a1,
                               unsigned long a2, unsigned long a3,
                               unsigned long a4, unsigned long a5,
                               unsigned long fid, unsigned long eid)
{
    switch (eid) {
    case SBI_EXT_BASE:
        return base_dispatch(a0, fid);
    case SBI_EXT_TIME:
        return time_dispatch(a0, a1, fid);
    case SBI_EXT_IPI:
        return ipi_dispatch(a0, a1, fid);
    case SBI_EXT_HSM:
        return hsm_dispatch(a0, a1, a2, fid);
    case SBI_EXT_DBCN:
        return dbcn_dispatch(a0, a1, a2, fid);
    case SBI_EXT_SRST:
        return srst_dispatch(a0, a1, fid);
    default:
        return (struct sbiret){ .error = SBI_ERR_NOT_SUPPORTED, .value = 0 };
    }
}

The trap handler that invokes this is responsible for: reading a0..a5/a6/a7 from the trap frame, calling handle_sbi_ecall, writing the returned error/value back to the trap frame’s a0/a1, advancing mepc by 4 (because the kernel’s ecall is 4 bytes), then mret. The “advance mepc by 4” step is essential, exactly as for any other synchronous-exception handler.

Failure Modes and Common Misunderstandings

The dominant misunderstanding is conflating “SBI” with “OpenSBI.” SBI is the spec; OpenSBI is one implementation. RustSBI, BBL, the SBI shim inside KVM, the SBI shim inside Xvisor are all legitimate SBI implementations. A kernel that hard-codes assumptions about OpenSBI’s behavior (specific impl_id, specific error code for a vendor-specific corner case) breaks on other implementations.

Forgetting to probe. Code that calls sbi_set_timer without first calling sbi_probe_extension(SBI_EXT_TIME) will fault on a firmware that only implements the legacy SET_TIMER (EID 0). The legacy interface uses a single argument convention (only a0) and a single-value return; calling it with the modern convention silently misbehaves.

Confusing the SBI ABI with the Linux syscall ABI. Both put a number in a7 and use ecall; both return in a0. They are deliberately analogous (the spec authors wanted SBI calls to feel familiar to Linux developers), but they are not interchangeable: SBI uses a6 for the FID, returns a struct in (a0, a1), and uses negative SBI error codes (-1, -2, …) rather than Linux errnos (-EAGAIN = -11, etc.). The two ABIs do not collide because Linux syscalls are made from U-mode (cause 8) and SBI calls from S-mode (cause 9); the kernel never confuses them at runtime, but a programmer writing both kinds of stubs can easily mix the conventions.

Assuming the legacy extensions are present. SBI 0.1 had a single-call mechanism for putchar, getchar, shutdown, set_timer, send_ipi, clear_ipi, and a few fences. These are deprecated; the spec keeps them around for old kernels but new code should use the modular extensions (DBCN for console, SRST for shutdown, TIME for set_timer, IPI for send_ipi, RFENCE for the cache/TLB fences). A small SBI implementation may legitimately not implement the legacy entry points at all.

Trying to make SBI calls from M-mode. Cause-11 ecall (from M-mode) is not delegatable and not meaningful as an SBI call: there is nothing more privileged to call into. M-mode code that wants to use SBI-like functionality just calls the firmware’s internal C functions directly, no ecall involved.

Misreading the return convention for the legacy extensions. SBI 0.1’s legacy extensions returned a single signed-integer value in a0, not a sbiret pair. Modern code that calls a legacy function (typically by mistake) and reads a1 will read garbage. The spec says: “the value returned in a1 is unspecified unless explicitly defined for that SBI function” (binary-encoding.adoc) for SBI_ERR cases, and the same caution applies to all legacy calls.

Hart-mask confusion. Some extensions (IPI, RFENCE) take a hart_mask plus hart_mask_base rather than a flat hartid. The convention is that bit i of hart_mask refers to hart hart_mask_base + i. Code that passes a single hartid in hart_mask without setting hart_mask_base will target hart 0 with bit 0 (probably wrong). Setting hart_mask_base = -1 is a documented sentinel meaning “all available harts” (binary-encoding.adoc, Hart list parameter section).

Alternatives and When to Choose Them

Direct M-mode access (no SBI). A small embedded RISC-V system without S-mode (the ESP32-C3, definitely-not-esp32 v1.0) does not need SBI at all. The M-mode kernel touches the CLINT, PLIC, and UART directly. SBI only makes sense as a contract between M-mode firmware and an S-mode kernel.

ARM PSCI (Power State Coordination Interface). PSCI is ARM’s analog of SBI’s HSM and SRST extensions, providing CPU on/off, system reset, idle-state coordination via an SMC instruction trapped into the secure monitor (EL3). The SBI spec was explicitly influenced by PSCI; the HSM extension’s state machine looks very PSCI-like. The main difference is scope: PSCI is power-only, while SBI covers power plus console, IPI, timer, performance counters, and so on, in one ABI.

UEFI runtime services. UEFI defines a set of services callable by the OS after boot (variable storage, time, reset). Comparable in spirit but heavier in mechanism (UEFI runtime services are C-callable function pointers that must be mapped into the kernel’s address space). SBI’s ecall transport is lighter weight; the kernel does not need to map firmware code into its own page tables.

BBL (Berkeley Boot Loader), the historical RISC-V firmware, predated the formal SBI spec. It implemented a similar API but with its own conventions, and was retired when OpenSBI shipped (~2018). Old educational distributions (riscv-tools, the original Spike-based Linux build) still use BBL; modern distributions never do.

For definitely-not-esp32 the practical answer is: SBI is a stretch goal. The v1.0 design is M+U only with a microkernel in M, so there is no S-mode kernel to call SBI from. If the project ever adds S-mode for a stretch Linux port, RustSBI is the most natural firmware choice (Rust-on-Rust), and the SBI configuration would mirror the typical OpenSBI defaults documented above.

Production Notes

A real-world SBI configuration for a Linux-on-QEMU-virt boot looks roughly like this:

  1. QEMU loads OpenSBI (FW_JUMP variant) at 0x80000000.
  2. OpenSBI’s entry assembly sets up M-mode CSRs: medeleg/mideleg per the Linux-friendly mask in RISC-V Trap Delegation, mtvec to OpenSBI’s trap handler, pmpaddr/pmpcfg to allow S-mode access to all memory except OpenSBI’s own region.
  3. OpenSBI prints a banner via its console driver, calls platform-init code.
  4. OpenSBI sets mstatus.MPP = S, mstatus.MPIE = 1, mepc = kernel entry, then mret.
  5. The Linux kernel starts in S-mode with a0 = hartid, a1 = device tree blob address. It reads the DTB, sets up its own trap handlers (stvec), enables paging.
  6. From this point on, every Linux SBI call is an S-mode ecall (cause 9), trapping to OpenSBI’s handler, dispatched, returned via a0/a1, kernel resumes.

The cost of each SBI call is roughly: a trap entry (1 trap, ~10-30 cycles on a typical RV64 in-order core), a small dispatch table lookup (~5-10 cycles), the actual function (varies wildly: a debug console write is fast, an IPI to many harts is slow), the return via mret (~5-10 cycles). Total: ~50-100 cycles for a simple call like sbi_set_timer. Comparable to a Linux syscall, which is what one would expect from “ecall into a higher-privilege handler.”

A practical wrinkle: when a Linux kernel runs in a KVM guest on a RISC-V host, the guest’s SBI calls go to the host’s KVM module (impl_id 3), which fakes the SBI interface in software. This is how Linux/KVM/RISC-V works: the guest thinks it is talking to OpenSBI, but it is actually talking to KVM, which decides whether to forward the call to the real OpenSBI underneath or to handle it directly. The SBI spec is the universal language for this layered system.

The OpenSBI deep-dive presentation by Anup Patel (OpenSBI Deep Dive slides PDF) is a useful secondary reference for how the boot flow, delegation setup, and runtime services fit together in OpenSBI specifically; for the canonical version, read the source code in lib/sbi/ and the headers in include/sbi/ of the OpenSBI repository.

See Also