← Home / Project 3

Project 3 — OpenSBI + S-mode Payload

Build real OpenSBI from source, boot it on QEMU virt, and write an S-mode payload that queries all SBI extensions and fires timer interrupts using the stimecmp CSR (sstc extension).

OpenSBI v1.8 S-mode SBI v1.0 TIME ext sstc / stimecmp fw_jump.elf rv64imac_zicsr

Overview

Projects 1 and 2 handled M-mode themselves — Project 1 ran with -bios none, and Project 2 hand-wrote a minimal M-mode SBI stub. This project hands that responsibility to production OpenSBI, the same firmware used on real RISC-V hardware.

What OpenSBI gives you

Full M-mode init: PMP, CLINT, PLIC, UART, medeleg/mideleg. The official SBI v1.0 extension set: BASE, TIME, IPI, RFENCE, HSM, SRST. You arrive in S-mode with everything already set up.

What this payload demonstrates

Live SBI BASE queries (spec version, impl ID, probe all extensions). Then 3 timer interrupts — using stimecmp CSR directly on QEMU's sstc hardware instead of going through SBI.

Final result: OpenSBI banner → S-mode payload prints SBI info → 3 STI timer interrupts → "Demo complete."

Boot Chain

── QEMU virt boot ─────────────────────────────────────────────────────────
0x1000 MROM reset vector (5 instructions, hardcoded in QEMU)
↓ auipc t0,0 / lw t0,24(t0) / jr t0 → 0x80000000
0x80000000 fw_jump.elf ← your OpenSBI build (-bios fw_jump.elf)
M-mode: sets mtvec, mideleg, medeleg, PMP
Registers SBI extensions: TIME IPI RFENCE HSM SRST
Enables MENVCFG.STCE ← allows S-mode to write stimecmp
Prints OpenSBI banner to UART
Sets mepc=0x80200000, sstatus.MPP=S → mret
↓ mret → CPU enters S-mode, PC=0x80200000
a0 = hartid (0) a1 = DTB address (0x82200000)
0x80200000 payload.elf ← your S-mode code (-kernel payload.elf)
entry.S: sp setup, stvec install, call payload_main
payload.c: SBI BASE queries, SBI comparison note
demo_timer(): write stimecmp, enable STIE, wfi loop
trap_handler(): 3× STI (scause=0x8000000000000005)
-bios vs -kernel: -bios fw_jump.elf loads your OpenSBI build as the machine firmware, replacing QEMU's built-in OpenSBI. -kernel payload.elf tells QEMU to load the ELF at its linked address (0x80200000) and pass the DTB pointer in a1. OpenSBI then jumps to it. Never use -kernel fw_jump.elf — that loads OpenSBI twice.

Building OpenSBI from Source

OpenSBI uses position-independent code (PIE) for self-relocation. The bare-metal riscv64-unknown-elf- toolchain does not support PIE. You must use the Linux-target toolchain.

Prerequisites

1

Install the Linux RISC-V toolchain

terminalbash
sudo apt update
sudo apt install gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu

# Verify — should print GCC 11+ or 13+
riscv64-linux-gnu-gcc --version
Why not riscv64-unknown-elf-? That toolchain targets bare-metal freestanding code and omits PIE support. OpenSBI requires PIE so its self-relocation stub (fw_base.S) can copy the firmware to its final address at run time. The linux-gnu toolchain includes PIE and is the only supported choice for building OpenSBI.

Clone & Patch

2

Clone the repository

terminalbash
cd ~/eclipse-workspace
git clone https://github.com/riscv-software-src/opensbi.git
cd opensbi
# Tested with v1.8.1-38-g6d5b2b9b
3

Permanently fix FW_TEXT_START

OpenSBI's default FW_TEXT_START is 0x0. This causes the generated linker script to place the entry point at address zero — QEMU prints nothing and hangs. Patch it permanently:

firmware/objects.mk — one-time patchbash
sed -i 's/firmware-genflags-y += -DFW_TEXT_START=0x0/firmware-genflags-y += -DFW_TEXT_START=0x80000000/' \
    firmware/objects.mk

# Verify
grep "FW_TEXT_START" firmware/objects.mk
# firmware-genflags-y += -DFW_TEXT_START=0x80000000  ← correct
This patch is required. Without it the firmware self-relocates to 0x0 and QEMU silently hangs. The patch is permanent within your clone — any future make clean && make will produce a correctly addressed binary without re-patching.

Compile

4

Build fw_jump.elf

terminalbash
cd ~/eclipse-workspace/opensbi
make PLATFORM=generic clean

make CROSS_COMPILE=riscv64-linux-gnu- \
     PLATFORM=generic \
     FW_JUMP=y \
     FW_JUMP_ADDR=0x80200000 \
     -j$(nproc)
VariableValueEffect
CROSS_COMPILEriscv64-linux-gnu-Use the Linux-target toolchain (supports PIE)
PLATFORMgenericThe "generic" platform probes hardware via device tree — works for QEMU virt
FW_JUMP=yenabledBuild fw_jump firmware: M-mode init, then unconditionally jump to a fixed address
FW_JUMP_ADDR0x80200000The fixed address fw_jump will jump to — must match where your payload is linked

In some OpenSBI versions the generated linker script still embeds 0x0 despite the patch. If so, patch the generated file and relink:

fix generated linker script if neededbash
# Check the entry point — must show 0x80000000
riscv64-linux-gnu-readelf -h \
    build/platform/generic/firmware/fw_jump.elf | grep Entry

# If it shows 0x0, patch the generated script and relink:
sed -i 's/\. = 0x0;/. = 0x80000000;/' \
    build/platform/generic/firmware/fw_jump.elf.ld
make CROSS_COMPILE=riscv64-linux-gnu- PLATFORM=generic \
     FW_JUMP=y FW_JUMP_ADDR=0x80200000 -j$(nproc)

Verify

5

Run bare — see the OpenSBI banner

terminalbash
# Run OpenSBI with no payload — it will print the banner then hang
qemu-system-riscv64 -machine virt -nographic \
    -bios build/platform/generic/firmware/fw_jump.elf
# Press Ctrl-A X to quit
OpenSBI v1.8.1-38-g6d5b2b9b
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
...
Platform Name : riscv-virtio,qemu
Boot HART ISA Extensions : sstc, zicntr, zihpm, ...
Boot HART MIDELEG : 0x0000000000001666
Domain0 Next Address : 0x0000000080200000
Domain0 Next Arg1 : 0x0000000082200000
... hangs here — no payload at 0x80200000 yet

The banner proves OpenSBI is running from your source build. Note sstc listed in ISA Extensions — this means the CPU supports stimecmp, which the payload will use directly.

Project Files

opensbi_payload/ ├── entry.S ← S-mode entry at 0x80200000: stack, stvec, trap vector ├── payload.c ← SBI BASE queries, stimecmp timer demo, trap handler ├── payload.h ← sbiret struct, SBI extension IDs, function IDs ├── linker.ld ← ORIGIN = 0x80200000, .text.entry first └── Makefile ← riscv64-unknown-elf-, rv64imac_zicsr, qemu-run target
All five files go in opensbi_payload/ at the repository root, alongside bare_metal_qemu/ and zsbl_supervisor/.

entry.S — Line by Line

This is the very first instruction the CPU executes after OpenSBI's mret. The CPU arrives in S-mode at 0x80200000 with a0 = hartid and a1 = DTB address. Both registers must survive intact to reach payload_main.

Stack Setup and Entry

entry.S — _startRISC-V ASM
# Place this section in .text.entry so the linker script can
# put it at the very start of the binary (0x80200000).
.section .text.entry
.global _start

_start:
    # la = Load Address.  Expands to: auipc t0, %pcrel_hi(sym)
    #                                  addi  t0, t0, %pcrel_lo(sym)
    # Both instructions are PC-relative — safe under mcmodel=medany
    # for targets within ±2 GB of the current PC.
    la      sp, _sstack_top     # sp ← top of our 8 KB S-mode stack

    # Install the S-mode trap vector BEFORE calling any C code.
    # If anything faults before stvec is set the CPU will vector
    # to OpenSBI's internal handler which knows nothing about our
    # stack and will hang silently.
    la      t0, _strap_vector
    csrw    stvec, t0          # MODE = Direct (bits[1:0]=0b00)

    # Jump to C.  a0 (hartid) and a1 (DTB) set by OpenSBI.
    # We have not touched a0/a1 so they reach payload_main intact.
    call    payload_main

    # payload_main never returns but spin safely if it somehow does.
1:  wfi
    j       1b
InstructionOperationWhy Here
la sp, _sstack_topsp ← address of _sstack_top symbolStack must be valid before any function call or trap. la uses auipc + addi — no absolute addresses needed.
la t0, _strap_vectort0 ← address of our trap vectorNeed the address in a register before we can write to stvec.
csrw stvec, t0stvec ← t0 (Direct mode)All S-mode traps will jump to _strap_vector. Bits[1:0]=0 = Direct mode (single entry point for all traps).
call payload_mainra ← PC+4; PC ← payload_mainStandard ABI function call. a0 and a1 preserved.
wfi + j 1bHalt hart until interrupt; loopSafety net if payload_main returns. wfi saves power; j loops back in case of spurious wake.

Why stvec Must Be Installed First

OpenSBI sets stvec to point at its own handler before the mret. If your entry code triggers any fault (e.g. a page fault from a misaligned stack) before you overwrite stvec, the CPU will trap to OpenSBI's handler. OpenSBI's handler does not know your stack pointer and will silently corrupt state or hang. Always install your stvec as the very first CSR write.

The Trap Vector

entry.S — _strap_vector (S-mode trap entry)RISC-V ASM
# .align 2 = align to 4 bytes.  RISC-V spec requires stvec[1:0]=0b00
# for Direct mode, so the vector address must be 4-byte aligned.
.align 2
.global _strap_vector
_strap_vector:
    # Make room on the stack: 256 bytes (32 slots × 8 bytes each).
    # sp must already be valid — which it is because we set it in _start.
    addi    sp, sp, -256

    # Save all caller-saved registers.  The trap can fire in the middle
    # of any code path, so these registers may contain live values.
    # Callee-saved registers (s0-s11) are preserved by C functions.
    sd      ra,   0(sp)  # return address — must preserve, call clobbers ra
    sd      t0,   8(sp)  # temporaries
    sd      t1,  16(sp)
    sd      t2,  24(sp)
    sd      a0,  32(sp)  # a0-a7: argument / return-value registers
    sd      a1,  40(sp)  # a0 and a1 also hold sbiret results
    sd      a2,  48(sp)
    sd      a3,  56(sp)
    sd      a4,  64(sp)
    sd      a5,  72(sp)
    sd      a6,  80(sp)
    sd      a7,  88(sp)
    sd      t3,  96(sp)  # more temporaries
    sd      t4, 104(sp)
    sd      t5, 112(sp)
    sd      t6, 120(sp)

    # Call the C handler.  It reads scause and sepc itself via inline asm.
    call    payload_trap_handler

    # Restore all 16 registers in reverse order.
    ld      ra,   0(sp)
    ld      t0,   8(sp)
    ld      t1,  16(sp)
    ld      t2,  24(sp)
    ld      a0,  32(sp)
    ld      a1,  40(sp)
    ld      a2,  48(sp)
    ld      a3,  56(sp)
    ld      a4,  64(sp)
    ld      a5,  72(sp)
    ld      a6,  80(sp)
    ld      a7,  88(sp)
    ld      t3,  96(sp)
    ld      t4, 104(sp)
    ld      t5, 112(sp)
    ld      t6, 120(sp)
    addi    sp, sp, 256       # release the stack frame

    # sret: restores sepc → PC, restores SPP → privilege level
    # The interrupted code continues exactly where it was.
    sret

# ── S-mode stack: 8 KB, page-aligned ──────────────────────────
.section .sstack
.align 12                   # 2^12 = 4096-byte page alignment
_sstack_bottom:
    .space  8192             # reserve 8 KB of zeros
_sstack_top:                # sp starts here; stack grows downward

payload.h — SBI Interface Definitions

payload.hC
/* Every SBI ecall returns two values in a0 and a1.
 * We wrap them in a struct so callers get both atomically.        */
struct sbiret {
    long error;   /* 0 = SBI_SUCCESS; negative = error code */
    long value;   /* result — e.g. spec version, impl ID, probe answer */
};

/* ── Extension IDs (EID) ──────────────────────────────────────
 * EIDs 0x00–0x0F are "legacy" (SBI v0.1, deprecated).
 * EIDs 0x10+ are the SBI v1.0 standard extensions.
 * The string EIDs (e.g. 0x54494D45 = "TIME") are defined so
 * that you can read the ID as ASCII.                             */
#define SBI_EXT_BASE    0x10        /* mandatory on all v1.0 implementations */
#define SBI_EXT_TIME    0x54494D45  /* 'T','I','M','E' — set timer */
#define SBI_EXT_IPI     0x735049    /* 's','P','I'  — send IPI */
#define SBI_EXT_RFENCE  0x52464E43  /* 'R','F','N','C' — remote fence */
#define SBI_EXT_HSM     0x48534D    /* 'H','S','M' — hart state management */
#define SBI_EXT_SRST    0x53525354  /* 'S','R','S','T' — system reset */

/* ── BASE function IDs (used with SBI_EXT_BASE) ──────────────── */
#define SBI_BASE_GET_SPEC_VERSION   0
#define SBI_BASE_GET_IMPL_ID        1
#define SBI_BASE_GET_IMPL_VERSION   2
#define SBI_BASE_PROBE_EXTENSION    3  /* a0=eid → returns 0=absent, >0=present */
#define SBI_BASE_GET_MVENDORID      4
#define SBI_BASE_GET_MARCHID        5

/* ── TIME function ID ─────────────────────────────────────────── */
#define SBI_TIME_SET_TIMER          0  /* a0=absolute stime value */

/* ── Implementation IDs ──────────────────────────────────────── */
#define SBI_IMPL_OPENSBI            1

payload.c — Line by Line

UART Output (NS16550A)

payload.c — UART sectionC
/* QEMU virt places an NS16550A UART at 0x10000000.
 * OpenSBI already initialised it — we can write directly.       */
#define UART0     0x10000000UL

/* volatile: the compiler must not cache these — they are hardware regs.
 * UART_TX  (offset 0x00) = Transmit Holding Register — write a byte here.
 * UART_LSR (offset 0x05) = Line Status Register — bit 5 = THRE.          */
#define UART_TX   (*(volatile unsigned char*)(UART0 + 0x00))
#define UART_LSR  (*(volatile unsigned char*)(UART0 + 0x05))
#define UART_THRE (1 << 5)  /* Transmit Holding Register Empty — safe to send */

static void uart_putc(char c) {
    /* Spin until UART is ready, then write the character. */
    while (!(UART_LSR & UART_THRE));
    UART_TX = c;
}

static void puts(const char *s) { while (*s) uart_putc(*s++); }

/* Print 64-bit value as 0xHHHHHHHHHHHHHHHH (always 16 nibbles). */
static void put_hex(unsigned long v) {
    const char *h = "0123456789ABCDEF";
    puts("0x");
    /* Shift 60 bits right to get the most-significant nibble first,
     * then step down 4 bits at a time to the least-significant nibble. */
    for (int i = 60; i >= 0; i -= 4)
        uart_putc(h[(v >> i) & 0xF]);
}

Reading the Timer — time CSR

payload.c — get_time()C
/* The 'time' CSR (0xC01) is a read-only alias for mtime.
 * It is always readable from S-mode — OpenSBI does not block it.
 *
 * DO NOT try to read CLINT mtime directly via MMIO (0x200BFF8).
 * OpenSBI's PMP configuration marks the entire CLINT region
 * (0x2000000–0x200FFFF) as M-mode only.  An S-mode MMIO load
 * to that address will take a load access fault (mcause=5).       */
static inline unsigned long long get_time(void) {
    unsigned long t;
    __asm__ volatile("csrr %0, time" : "=r"(t));
    return t;
}
Why not read CLINT MMIO directly? OpenSBI's boot-time PMP setup creates a region covering 0x2000000–0x200FFFF (the CLINT) with M-mode permissions only. Any S-mode load from that range causes a load access fault — you will get stuck in your own trap handler printing [EXC] scause=0x0000000000000005 forever. Always use csrr %0, time from S-mode.

Writing the Timer — stimecmp (sstc Extension)

payload.c — set_stimecmp()C
/* stimecmp is the S-mode timer compare register added by the sstc extension.
 *
 * When stimecmp is present and MENVCFG.STCE=1 (set by OpenSBI):
 *   - S-mode can write stimecmp directly to schedule a timer interrupt.
 *   - When time >= stimecmp, the STI bit (bit 5) of sip fires.
 *   - The interrupt arrives as an STI: scause = 0x8000000000000005.
 *   - No SBI ecall is needed — zero M-mode involvement.
 *
 * stimecmp CSR number = 0x14D.
 * riscv64-unknown-elf-gcc 10.2 does not recognise the mnemonic 'stimecmp'
 * because it predates the _sstc ISA extension string in that toolchain.
 * Using the raw CSR number (0x14D) bypasses this — the assembler accepts
 * any valid 12-bit CSR number regardless of ISA extensions.            */
static inline void set_stimecmp(unsigned long long t) {
    __asm__ volatile("csrw 0x14D, %0" :: "r"(t));
    /* Encoding: csrw 0x14D, rs1
     * This is functionally identical to: csrw stimecmp, rs1
     * RISC-V CSR instructions always accept the CSR as an immediate.  */
}

#define TIMER_INTERVAL  5000000ULL
/* QEMU's virtual CLINT runs at 10 MHz on most builds.
 * 5,000,000 ticks ≈ 0.5 seconds per tick.
 * The exact period doesn't matter — we just want visible output.   */

SBI ecall — The Core Mechanism

payload.c — sbi_ecall()C
/* SBI v1.0 calling convention (SBI spec §3.1):
 *
 *   BEFORE ecall        AFTER ecall
 *   a7 = Extension ID   a0 = error code (0 = success)
 *   a6 = Function ID    a1 = return value
 *   a0-a5 = arguments
 *
 * 'ecall' in S-mode generates an "environment call from S-mode"
 * exception (mcause = 9).  OpenSBI's M-mode handler dispatches
 * based on a7 (EID) and a6 (FID), runs the implementation,
 * stores results in a0/a1, and mret-s back to S-mode.
 *
 * The register constraints below bind C variables to hardware
 * registers so GCC knows exactly which physical registers carry
 * the arguments and receive the results.                          */

static struct sbiret sbi_ecall(
    unsigned long eid, unsigned long fid,
    unsigned long a0,  unsigned long a1,
    unsigned long a2,  unsigned long a3,
    unsigned long a4,  unsigned long a5)
{
    /* "register ... __asm__("aN")" = this C variable MUST live in
     * hardware register aN for the duration of the inline asm block.
     * Without this, GCC might load the value into a different register
     * and the ecall would pass garbage.                              */
    register unsigned long _a0 __asm__("a0") = a0;
    register unsigned long _a1 __asm__("a1") = a1;
    register unsigned long _a2 __asm__("a2") = a2;
    register unsigned long _a3 __asm__("a3") = a3;
    register unsigned long _a4 __asm__("a4") = a4;
    register unsigned long _a5 __asm__("a5") = a5;
    register unsigned long _a6 __asm__("a6") = fid;  /* function ID */
    register unsigned long _a7 __asm__("a7") = eid;  /* extension ID */

    __asm__ volatile("ecall"
        : "+r"(_a0), "+r"(_a1)   /* "+r" = read-write: GCC treats as both
                                    input and output.  OpenSBI overwrites
                                    a0 with error and a1 with value.     */
        : "r"(_a2), "r"(_a3),   /* "r"  = read-only input registers       */
          "r"(_a4), "r"(_a5),
          "r"(_a6), "r"(_a7)
        : "memory");             /* memory clobber: OpenSBI may read/write
                                    memory (e.g. device tree, UART).     */

    return (struct sbiret){ .error = _a0, .value = _a1 };
}

SBI BASE Extension — Runtime Queries

payload.c — demo_sbi_base()C
static void demo_sbi_base(void) {
    struct sbiret ret;

    /* ── GET_SPEC_VERSION ──────────────────────────────────────────
     * Returns the SBI specification version this firmware implements.
     * Packed as: bits[30:24] = major, bits[23:0] = minor.
     * Example: 0x03000000 → major=3, minor=0 → print "3.0"           */
    ret = sbi_ecall(SBI_EXT_BASE, SBI_BASE_GET_SPEC_VERSION, 0,0,0,0,0,0);
    puts("  SBI Spec Version : ");
    put_dec((ret.value >> 24) & 0x7F);  /* major in bits[30:24] */
    uart_putc('.');
    put_dec(ret.value & 0xFFFFFF);       /* minor in bits[23:0]  */

    /* ── PROBE_EXTENSION ──────────────────────────────────────────
     * Pass any EID in a0; returns 0 = not present, >0 = available.
     * Use this at runtime rather than assuming extension support.      */
    unsigned long exts[] = {
        SBI_EXT_TIME, SBI_EXT_IPI, SBI_EXT_RFENCE,
        SBI_EXT_HSM,  SBI_EXT_SRST
    };
    const char *names[] = { "TIME", "IPI", "RFENCE", "HSM", "SRST" };
    puts("  Extensions       : ");
    for (int i = 0; i < 5; i++) {
        ret = sbi_ecall(SBI_EXT_BASE, SBI_BASE_PROBE_EXTENSION,
                        exts[i], 0,0,0,0,0);
        if (ret.value)  /* non-zero = extension present */
            { puts(names[i]); uart_putc(' '); }
    }
}

SBI TIME Extension — The Correct SBI v1.0 API

payload.c — sbi_set_timer() and comparisonC
/* SBI TIME extension (EID = 0x54494D45 "TIME").
 *
 * Old way — legacy SBI v0.1 (deprecated since SBI v0.2):
 *   a7 = 0x00 (SET_TIMER legacy), a0 = timer_value
 *   This is how zsbl.S in Project 2 arms the timer.
 *
 * Correct way — SBI v1.0:
 *   a7 = 0x54494D45 (extension "TIME"), a6 = 0 (SET_TIMER function)
 *   a0 = stime_value (absolute value — timer fires when time >= this)
 *
 * Both write mtimecmp in M-mode.  The v1.0 form is what production
 * firmware beyond OpenSBI v0.x will support.                        */
static void sbi_set_timer(unsigned long long t) {
    sbi_ecall(SBI_EXT_TIME, SBI_TIME_SET_TIMER, t, 0,0,0,0,0);
}

Timer Demo — Arming and Waiting

payload.c — demo_timer()C
static void demo_timer(void) {
    /* On QEMU with sstc, we write stimecmp directly (no SBI ecall).
     * OpenSBI set MENVCFG.STCE=1 during boot, enabling S-mode writes.  */
    set_stimecmp(get_time() + TIMER_INTERVAL);
    /* The CPU will now fire an STI when time >= stimecmp.
     * But interrupts are still globally disabled — we must enable them. */

    /* Enable Supervisor Timer Interrupt Enable — bit 5 in sie.
     * Without this bit set, the STI pending bit (sip.STIP) will never
     * be acted upon even when time >= stimecmp.                         */
    unsigned long sie;
    __asm__ volatile("csrr %0, sie" : "=r"(sie));
    sie |= (1UL << 5);   /* bit 5 = STIE */
    __asm__ volatile("csrw sie, %0" :: "r"(sie));

    /* Enable global S-mode interrupt enable — bit 1 in sstatus.
     * This is the master interrupt switch for S-mode.  Even with
     * STIE set, no interrupt fires until sstatus.SIE=1.              */
    unsigned long ss;
    __asm__ volatile("csrr %0, sstatus" : "=r"(ss));
    ss |= (1UL << 1);    /* bit 1 = SIE */
    __asm__ volatile("csrw sstatus, %0" :: "r"(ss));

    /* wfi: Wait For Interrupt — suspends the hart until an interrupt
     * is pending and enabled.  More power-efficient than spinning.
     * Break out of the loop once we have received 3 interrupts.      */
    while (timer_count < 3)
        __asm__ volatile("wfi");
}

Trap Handler

payload.c — payload_trap_handler()C
void payload_trap_handler(void) {
    unsigned long scause, sepc;
    __asm__ volatile("csrr %0, scause" : "=r"(scause));
    __asm__ volatile("csrr %0, sepc"   : "=r"(sepc));

    /* scause bit 63: 1 = interrupt (async), 0 = exception (sync).
     * Interrupts and exceptions share the same CSR but use the top
     * bit as a type discriminator.                                    */
    if (scause & (1UL << 63)) {
        unsigned long code = scause & ~(1UL << 63); /* strip the type bit */

        if (code == 5) {
            /* code 5 = Supervisor Timer Interrupt (STI).
             * Fires because time >= stimecmp (sstc path).
             * With sstc, the interrupt clears automatically once
             * stimecmp is written to a future value.                */
            timer_count++;
            puts("[IRQ] Timer #");
            put_dec(timer_count);
            puts("  time=");
            put_hex((unsigned long)get_time());
            puts("\r\n");

            if (timer_count < 3) {
                /* Re-arm: write a new stimecmp value in the future. */
                set_stimecmp(get_time() + TIMER_INTERVAL);
            } else {
                /* Disarm: set stimecmp to max so it never fires again. */
                set_stimecmp(0xFFFFFFFFFFFFFFFFULL);
                /* Also clear STIE in sie for belt-and-suspenders. */
                unsigned long sie;
                __asm__ volatile("csrr %0, sie" : "=r"(sie));
                sie &= ~(1UL << 5);
                __asm__ volatile("csrw sie, %0" :: "r"(sie));
            }
        } else if (code == 1) {
            /* code 1 = Supervisor Software Interrupt (SSI).
             * Fallback path: OpenSBI injects STIP via sip.SSIP
             * on hardware without sstc.  Should not fire on QEMU virt
             * when sstc is present — included for completeness.        */
            timer_count++;
            puts("[IRQ] SSI Timer #"); put_dec(timer_count); puts("\r\n");
            __asm__ volatile("csrc sip, %0" :: "r"(1UL << 1)); /* clear SSIP */
            if (timer_count < 3)
                sbi_set_timer(get_time() + TIMER_INTERVAL);
        } else {
            puts("[IRQ] Unknown code="); put_dec(code); puts("\r\n");
        }
    } else {
        /* Synchronous exception.  Print diagnostics and skip the
         * faulting instruction by advancing sepc by 4 bytes.          */
        puts("[EXC] scause="); put_hex(scause);
        puts(" sepc=");        put_hex(sepc); puts("\r\n");
        __asm__ volatile("csrw sepc, %0" :: "r"(sepc + 4));
    }
}

/* Global interrupt counter — declared volatile because it is written
 * by the trap handler (interrupt context) and read in demo_timer()
 * (normal context).  volatile prevents the compiler from caching the
 * value in a register across the wfi instruction.                     */
static volatile int timer_count = 0;
scause valueMeaningSource
0x8000000000000005Supervisor Timer Interrupt (STI)time >= stimecmp (sstc path) — what fires on QEMU
0x8000000000000001Supervisor Software Interrupt (SSI)OpenSBI injects via sip.SSIP — fallback on non-sstc hardware
0x0000000000000005Load access faultS-mode read of PMP-blocked address (e.g. CLINT MMIO)
0x0000000000000009Environment call from S-modeecall instruction — not expected in this payload

payload_main — Putting It Together

payload.c — payload_main()C
/* Called by entry.S after sp and stvec are set up.
 * a0 and a1 set by OpenSBI are passed through intact:
 *   a0 = hartid (which hardware thread/core we are running on)
 *   a1 = DTB physical address (device tree blob OpenSBI built)      */
void payload_main(unsigned long hartid, unsigned long dtb) {
    puts("\r\n########################################\r\n");
    puts("#   OpenSBI Payload Demo               #\r\n");
    puts("########################################\r\n");
    puts("  Hart ID : "); put_hex(hartid); puts("\r\n");
    puts("  DTB     : "); put_hex(dtb);    puts("\r\n");

    demo_sbi_base();       /* query spec version, impl, extensions */
    demo_sbi_comparison(); /* print note on legacy vs SBI v1.0 convention */
    demo_timer();          /* write stimecmp, wfi, 3 timer interrupts */

    puts("\r\n########################################\r\n");
    puts("#   Demo complete. System halted.       #\r\n");
    puts("########################################\r\n");

    while (1) __asm__ volatile("wfi"); /* halt: power-efficient infinite loop */
}

linker.ld

linker.ldlinker script
/* Tell the linker which symbol is the entry point.
 * This sets the ELF e_entry field.  QEMU's -kernel reads e_entry
 * to find the program's start address.                           */
ENTRY(_start)

MEMORY {
    /* 0x80200000: the address fw_jump jumps to (FW_JUMP_ADDR).
     * Must match the make variable passed during OpenSBI build.  */
    RAM (rwx) : ORIGIN = 0x80200000, LENGTH = 2M
}

SECTIONS {
    . = 0x80200000;

    .text : {
        *(.text.entry)  /* _start MUST be first — entry.S .text.entry */
        *(.text*)       /* all other .text from payload.c and entry.S  */
    } > RAM

    .rodata : { *(.rodata*) } > RAM   /* string literals, const arrays */
    .data   : { *(.data*)   } > RAM   /* initialised global variables */

    .bss : {
        _bss_start = .;
        *(.bss*) *(COMMON)          /* zero-initialised globals */
        _bss_end = .;
    } > RAM

    .sstack : { *(.sstack) } > RAM    /* 8 KB stack from entry.S */
}
Why *(.text.entry) first? Without this the linker is free to place any function first in the .text section. If payload_main ends up before _start, the CPU would jump straight into the middle of a C function with no stack — instant fault. The explicit .text.entry placement guarantees _start is always at 0x80200000.

Makefile

Makefilemake
CROSS   = riscv64-unknown-elf-
AS      = $(CROSS)as
CC      = $(CROSS)gcc
LD      = $(CROSS)ld

# rv64imac_zicsr:
#   rv64    = 64-bit base integer ISA
#   i       = base integer (RV64I)
#   m       = multiply/divide
#   a       = atomic instructions
#   c       = compressed 16-bit instructions
#   _zicsr  = CSR instructions (csrr, csrw, etc.)
#
# _zicsr must be explicit on GCC 12+ which separates it from the base ISA.
# GCC 10.2 (used in this project) includes CSR instructions in rv64imac
# by default — but including _zicsr doesn't hurt.
# We do NOT add _sstc because GCC 10.2 doesn't know it.
# We use raw CSR number 0x14D for stimecmp instead.              
ARCH    = -march=rv64imac_zicsr -mabi=lp64

ASFLAGS = -g $(ARCH)
CFLAGS  = -g -O0 $(ARCH) \
           -nostdlib -nostartfiles \  # no C runtime startup code
           -ffreestanding \           # no hosted standard library
           -fno-builtin \             # do not replace puts/memcpy with builtins
           -Wall \
           -mcmodel=medany \          # PC-relative ±2 GB addressing
           -mno-relax                 # disable linker relaxation (avoids GNU ld issues)

OPENSBI = $(HOME)/eclipse-workspace/opensbi/build/platform/generic/firmware/fw_jump.elf

all: payload.elf

payload.elf: entry.o payload.o
	$(LD) -T linker.ld -nostdlib -o $@ $^

entry.o: entry.S
	$(AS) $(ASFLAGS) -o $@ $<

payload.o: payload.c
	$(CC) $(CFLAGS) -c -o $@ $<

# Run with your locally-built OpenSBI
qemu-run: all
	qemu-system-riscv64 \
		-machine virt \
		-nographic \
		-bios $(OPENSBI) \
		-kernel payload.elf

# Same but paused — waiting for GDB on port 1234
qemu: all
	qemu-system-riscv64 \
		-machine virt \
		-nographic \
		-bios $(OPENSBI) \
		-kernel payload.elf -s -S

clean:
	rm -f *.o *.elf

.PHONY: all clean qemu qemu-run

Build & Run

1

Create the project directory

terminalbash
cd ~/eclipse-workspace/riscv-bare-metal-qemu
mkdir opensbi_payload
# Copy the 5 source files into this directory
2

Build the payload

terminalbash
cd opensbi_payload
make
# Verify entry point — must be 0x80200000
riscv64-unknown-elf-readelf -h payload.elf | grep Entry
# Entry point address: 0x80200000
3

Run

terminalbash
make qemu-run
# Press Ctrl-A X to quit when done

Expected Output

OpenSBI v1.8.1-38-g6d5b2b9b
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
... [banner] ...
Boot HART ISA Extensions : sstc, zicntr, ...
Domain0 Next Address : 0x0000000080200000

########################################
# OpenSBI Payload Demo #
# S-mode under real OpenSBI #
########################################
Hart ID : 0x0000000000000000
DTB : 0x0000000082200000

[---- SBI Base Extension (0x10) ----]
SBI Spec Version : 3.0
Implementation : OpenSBI
OpenSBI Version : 1.8
mvendorid : 0x0000000000000000
marchid : 0x0000000000000000
Extensions : TIME IPI RFENCE HSM SRST

[---- SBI Calling Convention Comparison ----]
Your zsbl.S (legacy): a7=0x00, a0=timer_val
OpenSBI standard (TIME ext): a7=0x54494D45, a6=0, a0=timer_val
On sstc hardware: write stimecmp directly, no SBI call needed

[---- SBI Timer / sstc Extension Demo ----]
sstc present — writing stimecmp (CSR 0x14D) directly
Interrupt fires as STI (scause interrupt code 5)
Waiting for 3 timer interrupts...
[IRQ] Timer #1 time=0x000000000052DA69
[IRQ] Timer #2 time=0x00000000009F494D
[IRQ] Timer #3 time=0x0000000000EBBCEB
3 timer interrupts received.

########################################
# Demo complete. System halted. #
########################################

sstc Extension — Deep Dive

The sstc extension (Supervisor-mode Timer Comparison) allows S-mode to schedule timer interrupts without an SBI ecall. Understanding how it interacts with OpenSBI is important for real hardware work.

FeatureWithout sstcWith sstc (QEMU virt)
Arm timerecall to SBI TIME ext (EID 0x54494D45)Write stimecmp CSR (0x14D) directly from S-mode
OpenSBI roleWrites mtimecmp, enables MTIE, injects STIP via sip.SSIPSets MENVCFG.STCE=1 at boot — then stays out of the way
Interrupt codescause code 1 (SSI — Software Interrupt)scause code 5 (STI — Timer Interrupt)
Clear interruptWrite sip.SSIP = 0 (csrc sip, bit 1)Write stimecmp to a future value — clears automatically
MIDELEG bit 5STIE not delegated — OpenSBI forwards via SSIPSTI fires directly in S-mode via stimecmp
OpenSBI banner shows: Boot HART MIDELEG : 0x0000000000001666. Bit 5 (STIE) is not set in MIDELEG — normally this would mean timer interrupts cannot be delegated to S-mode. But with sstc, delegation is bypassed entirely: stimecmp fires STI directly in S-mode without going through M-mode at all.

CSR Quick Reference

CSRNumberAccessPurpose in This Project
stvec0x105S-mode R/WS-mode trap vector address. Set to _strap_vector in entry.S.
sstatus0x100S-mode R/WBit 1 (SIE) = global S-mode interrupt enable. Must be 1 for any interrupt to fire.
sie0x104S-mode R/WPer-interrupt enable. Bit 5 (STIE) enables Supervisor Timer Interrupts.
sip0x144S-mode R/WPending interrupt bits. Bit 5 (STIP) set by hardware when time >= stimecmp.
scause0x142S-mode R/OSet by CPU on trap. Bit 63=interrupt type, bits[62:0]=cause code.
sepc0x141S-mode R/WSaved PC of the instruction that was interrupted. sret returns here.
stimecmp0x14DS-mode R/Wsstc extension. Timer fires (STI) when time CSR >= stimecmp value.
time0xC01S-mode R/ORead-only alias of mtime. Always accessible from S-mode on QEMU virt.

Eclipse CDT Debugger

With the payload running correctly from the command line, the next step is full source-level debugging inside Eclipse CDT — single-stepping through entry.S, payload.c, and even into OpenSBI's own C source. The approach uses QEMU's built-in GDB server (-S -s) and Eclipse's GDB Hardware Debugging launcher.

What you get: Single-step through assembly and C, live register/CSR watching, breakpoints on SBI ecalls, and the timer interrupt firing live in the debugger — all the way from the MROM reset vector to "Demo complete."

Step 1 — Create the QEMU Debug Launch Script

QEMU's -S flag freezes the CPU at reset before executing a single instruction. -s opens a GDB server on localhost:1234. Create a permanent script for this:

terminalbash
cat > ~/eclipse-workspace/opensbi/opensbi_payload/debug_qemu.sh << 'EOF'
#!/bin/bash
qemu-system-riscv64 \
    -machine virt \
    -nographic \
    -bios ~/eclipse-workspace/opensbi/build/platform/generic/firmware/fw_jump.elf \
    -kernel ~/eclipse-workspace/opensbi/opensbi_payload/payload.elf \
    -S -s
EOF
chmod +x ~/eclipse-workspace/opensbi/opensbi_payload/debug_qemu.sh
Run this script in a terminal first, before launching the debugger in Eclipse. QEMU will freeze silently — that is correct. Eclipse then connects to port 1234.

Each debug session: open a terminal, run ./debug_qemu.sh, then click Debug in Eclipse. The cat and chmod commands above are one-time setup only.

Step 2 — Import the Project into Eclipse CDT

If not already imported:

The Debug button only activates once Eclipse sees a built binary. If it stays greyed out, click once on the project in the Project Explorer to select it, then try again.

Step 3 — Create the Debug Configuration

Go to Run → Debug Configurations... → double-click GDB Hardware Debugging to create a new entry.

TabFieldValue
MainProjectopensbi_payload
MainC/C++ Applicationopensbi/opensbi_payload/payload.elf (full path)
DebuggerGDB Command/usr/bin/gdb-multiarch
DebuggerUse remote target✅ checked
DebuggerJTAG DeviceGeneric TCP/IP
DebuggerHost namelocalhost
DebuggerPort number1234
Do not check Load image or Load symbols in the Startup tab — QEMU already has the ELF loaded. You only need to tell GDB where the symbol files are (done in the next step).

Step 4 — Startup Tab Commands

This is the most critical section. In the Startup tab, fill in the two command boxes as follows:

Initialization Commands (runs before GDB connects):

GDB Initialization Commands
set arch riscv:rv64
set confirm off

Run Commands (runs after connection, before stepping):

GDB Run Commands
# Load symbols for both ELFs so you can step through OpenSBI AND your payload
symbol-file /home/vikram/eclipse-workspace/opensbi/opensbi_payload/payload.elf
add-symbol-file /home/vikram/eclipse-workspace/opensbi/build/platform/generic/firmware/fw_jump.elf 0x80000000

# Hardware breakpoint — reliable even before memory is fully initialised
hbreak *0x80200000
continue
Why hbreak instead of break _start? GDB connects at 0x1000 (MROM reset vector). A software breakpoint on _start is resolved to an address but QEMU's memory model can make the ebreak write unreliable at that stage. A hardware breakpoint (hbreak) uses QEMU's internal watchpoint mechanism and fires reliably when PC reaches 0x80200000.

With both symbol files loaded, Eclipse will show source for OpenSBI's fw_jump.S / sbi_init.c during M-mode, and your entry.S / payload.c once execution crosses to 0x80200000.

Step 5 — Recommended Breakpoints

Set these before clicking Resume to observe the full boot-to-end flow:

LocationHow to SetWhat You See There
*0x80000000Run → Add Breakpoint → AddressFirst instruction of OpenSBI fw_jump — CPU just left MROM
sbi_initRight-click margin in sbi_init.cOpenSBI C initialisation begins — PMP, UART, extension registration
_start (entry.S)Right-click margin in entry.S line 1OpenSBI mret lands here — you are now in S-mode
payload_mainRight-click margin in payload.cC entry point — hartid and DTB in a0/a1
demo_sbi_baseRight-click margin in payload.cSBI BASE ecalls begin — watch a7=0x10
payload_trap_handlerRight-click margin in payload.cEvery timer IRQ lands here — inspect scause, sepc live
demo_timerRight-click margin in payload.cstimecmp arm sequence — watch sie bit 5 get set

Step 6 — Expressions View

Open Window → Show View → Expressions and add these for live monitoring as you step:

ExpressionMeaning
$pcCurrent program counter
$a0hartid at entry / SBI error return during ecalls
$a1DTB address at entry / SBI value return during ecalls
$a7SBI extension ID — watch it change: 0x10, 0x54494D45
$scauseTrap cause — timer interrupt = 0x8000000000000005
$sepcPC that was interrupted when the timer fires
$sieS-mode interrupt enable — bit 5 (STIE) must be 1 before timer fires
$sstatusbit 1 (SIE) = global interrupt enable
timer_countYour global counter — watch it increment 0 → 1 → 2 → 3

Step 7 — Full Boot-to-End Step-Through Workflow

1

Start QEMU frozen

In a terminal: cd ~/eclipse-workspace/opensbi/opensbi_payload && ./debug_qemu.sh. QEMU prints nothing and waits. Do not close this terminal.

2

Launch debugger in Eclipse

Click Debug. Eclipse connects to port 1234, loads both symbol files, and runs continue. Execution stops at 0x80200000 (_start) automatically via the hbreak set in Run Commands.

If you want to catch OpenSBI's very first instruction, add hbreak *0x80000000 before the continue in Run Commands — it will stop there first, then you manually resume to reach _start.
3

Step through entry.S

Use F5 (step into) or F6 (step over). You will see:

  • la sp, _sstack_top — watch $sp load to the stack top address
  • csrw stvec, t0 — watch stvec (if shown in registers view) get set to _strap_vector
  • call payload_main — step into to enter C code
4

Step through payload_main and SBI ecalls

In payload.c, step through demo_sbi_base(). Watch in Expressions view:

  • $a7 becomes 0x10 (SBI_EXT_BASE) just before each ecall
  • $a1 returns the spec version, impl ID, impl version
  • After the probe loop, all 5 extensions are confirmed present
5

Watch the timer arm sequence

Stepping through demo_timer():

  • sbi_set_timer() fires — watch $a7 = 0x54494D45 ("TIME")
  • sie |= (1<<5) — watch $sie bit 5 go high in Expressions
  • sstatus |= (1<<1) — global interrupts enabled, timer is now armed
  • wfi — Eclipse pauses here. Click Resume. The timer fires.
6

Timer interrupt fires — inspect the trap

Eclipse jumps to _strap_vector in entry.S (register save), then into payload_trap_handler in payload.c. At this moment check:

  • $scause = 0x8000000000000005 — bit 63 set (interrupt), code 5 (STI)
  • $sepc = the PC that was executing when the timer fired (inside the wfi loop)
  • timer_count increments to 1, then 2, then 3

After the 3rd interrupt, stimecmp is pushed to 0xFFFFFFFFFFFFFFFF and STIE is cleared — the timer disarms and execution returns to payload_main.

7

Program ends

Execution falls through to the final while(1) wfi loop. Confirm timer_count == 3 in Expressions. The demo is complete.

Common Debug Issues

SymptomCauseFix
Debug button stays greyed outNo binary found or project not selectedBuild the project first; click the project once in Project Explorer to select it
Code runs straight through, doesn't stophbreak not in Run Commands, or connected after QEMU already ranEnsure hbreak *0x80200000 + continue is in Startup → Run Commands; restart QEMU first
"Remote 'g' packet reply is too long"GDB register set mismatchAdd set arch riscv:rv64 to Initialization Commands
No source shown for OpenSBI functionsOpenSBI source not indexed by EclipseFile → Open File → browse to opensbi/lib/sbi/sbi_init.c manually, or add opensbi as a linked folder in the project
Hangs at wfi during single-stepExpected — wfi sleeps until an interruptUse Resume (F8) to continue past wfi until the timer interrupt fires
Breakpoint on _start not hitSoftware breakpoint unreliable before memory initUse hbreak *0x80200000 (hardware breakpoint) instead

Troubleshooting

SymptomCauseFix
QEMU prints nothing, hangs immediatelyfw_jump.elf entry point at 0x0 (FW_TEXT_START not patched)Patch firmware/objects.mk. Run readelf -h fw_jump.elf | grep Entry — must show 0x80000000.
OpenSBI banner, then hangs with no payload outputpayload.elf not at 0x80200000, or FW_JUMP_ADDR mismatchRun readelf -h payload.elf | grep Entry — must show 0x80200000.
Build error: unsupported ISA subset 'zicsr'Toolchain older than GCC 12 doesn't accept _zicsr as separate extensionRemove _zicsr suffix: use -march=rv64imac
Build error: unrecognized CSR 'stimecmp'GCC 10.x doesn't know the sstc extensionUse raw CSR number: csrw 0x14D, %0 (already done in this project)
Timer hangs — "Waiting for 3 interrupts..." never completesSTIE not set in sie, or sstatus.SIE=0Verify both sie |= (1<<5) and sstatus |= (1<<1) execute before wfi
[EXC] scause=0x5 — load access fault in UART or timer codeReading CLINT MMIO (0x200BFF8) from S-mode — blocked by PMPUse csrr %0, time CSR instead of direct MMIO
Timer interrupt fires as SSI (code 1) not STI (code 5)QEMU build without sstc, or OpenSBI did not set MENVCFG.STCEThe SSI fallback path in payload_trap_handler handles this automatically
GDB breakpoint at _start not hitGDB connected but payload.elf not loaded as symbol fileIn GDB: file payload.elf, then add-symbol-file payload.elf 0x80200000