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.
Boot Chain
-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
Install the Linux RISC-V toolchain
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
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
Clone the repository
cd ~/eclipse-workspace git clone https://github.com/riscv-software-src/opensbi.git cd opensbi # Tested with v1.8.1-38-g6d5b2b9b
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:
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
make clean && make will produce a correctly addressed binary without re-patching.Compile
Build fw_jump.elf
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)
| Variable | Value | Effect |
|---|---|---|
| CROSS_COMPILE | riscv64-linux-gnu- | Use the Linux-target toolchain (supports PIE) |
| PLATFORM | generic | The "generic" platform probes hardware via device tree — works for QEMU virt |
| FW_JUMP=y | enabled | Build fw_jump firmware: M-mode init, then unconditionally jump to a fixed address |
| FW_JUMP_ADDR | 0x80200000 | The 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:
# 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
Run bare — see the OpenSBI banner
# 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
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
...
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/ 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
# 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
| Instruction | Operation | Why Here |
|---|---|---|
| la sp, _sstack_top | sp ← address of _sstack_top symbol | Stack must be valid before any function call or trap. la uses auipc + addi — no absolute addresses needed. |
| la t0, _strap_vector | t0 ← address of our trap vector | Need the address in a register before we can write to stvec. |
| csrw stvec, t0 | stvec ← 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_main | ra ← PC+4; PC ← payload_main | Standard ABI function call. a0 and a1 preserved. |
| wfi + j 1b | Halt hart until interrupt; loop | Safety net if payload_main returns. wfi saves power; j loops back in case of spurious wake. |
Why stvec Must Be Installed First
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
# .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
/* 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)
/* 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
/* 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; }
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)
/* 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
/* 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
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
/* 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
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
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 value | Meaning | Source |
|---|---|---|
| 0x8000000000000005 | Supervisor Timer Interrupt (STI) | time >= stimecmp (sstc path) — what fires on QEMU |
| 0x8000000000000001 | Supervisor Software Interrupt (SSI) | OpenSBI injects via sip.SSIP — fallback on non-sstc hardware |
| 0x0000000000000005 | Load access fault | S-mode read of PMP-blocked address (e.g. CLINT MMIO) |
| 0x0000000000000009 | Environment call from S-mode | ecall instruction — not expected in this payload |
payload_main — Putting It Together
/* 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
/* 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 */ }
*(.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
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
Create the project directory
cd ~/eclipse-workspace/riscv-bare-metal-qemu mkdir opensbi_payload # Copy the 5 source files into this directory
Build the payload
cd opensbi_payload make # Verify entry point — must be 0x80200000 riscv64-unknown-elf-readelf -h payload.elf | grep Entry # Entry point address: 0x80200000
Run
make qemu-run # Press Ctrl-A X to quit when done
Expected Output
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
... [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.
| Feature | Without sstc | With sstc (QEMU virt) |
|---|---|---|
| Arm timer | ecall to SBI TIME ext (EID 0x54494D45) | Write stimecmp CSR (0x14D) directly from S-mode |
| OpenSBI role | Writes mtimecmp, enables MTIE, injects STIP via sip.SSIP | Sets MENVCFG.STCE=1 at boot — then stays out of the way |
| Interrupt code | scause code 1 (SSI — Software Interrupt) | scause code 5 (STI — Timer Interrupt) |
| Clear interrupt | Write sip.SSIP = 0 (csrc sip, bit 1) | Write stimecmp to a future value — clears automatically |
| MIDELEG bit 5 | STIE not delegated — OpenSBI forwards via SSIP | STI fires directly in S-mode via stimecmp |
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
| CSR | Number | Access | Purpose in This Project |
|---|---|---|---|
| stvec | 0x105 | S-mode R/W | S-mode trap vector address. Set to _strap_vector in entry.S. |
| sstatus | 0x100 | S-mode R/W | Bit 1 (SIE) = global S-mode interrupt enable. Must be 1 for any interrupt to fire. |
| sie | 0x104 | S-mode R/W | Per-interrupt enable. Bit 5 (STIE) enables Supervisor Timer Interrupts. |
| sip | 0x144 | S-mode R/W | Pending interrupt bits. Bit 5 (STIP) set by hardware when time >= stimecmp. |
| scause | 0x142 | S-mode R/O | Set by CPU on trap. Bit 63=interrupt type, bits[62:0]=cause code. |
| sepc | 0x141 | S-mode R/W | Saved PC of the instruction that was interrupted. sret returns here. |
| stimecmp | 0x14D | S-mode R/W | sstc extension. Timer fires (STI) when time CSR >= stimecmp value. |
| time | 0xC01 | S-mode R/O | Read-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.
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:
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
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:
- File → Import → C/C++ → Existing Code as Makefile Project
- Set Existing Code Location to
~/eclipse-workspace/opensbi/opensbi_payload - Set Toolchain to
Cross GCC - Click Finish, then Project → Build Project to confirm
payload.elfis produced
Step 3 — Create the Debug Configuration
Go to Run → Debug Configurations... → double-click GDB Hardware Debugging to create a new entry.
| Tab | Field | Value |
|---|---|---|
| Main | Project | opensbi_payload |
| Main | C/C++ Application | opensbi/opensbi_payload/payload.elf (full path) |
| Debugger | GDB Command | /usr/bin/gdb-multiarch |
| Debugger | Use remote target | ✅ checked |
| Debugger | JTAG Device | Generic TCP/IP |
| Debugger | Host name | localhost |
| Debugger | Port number | 1234 |
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):
set arch riscv:rv64 set confirm off
Run Commands (runs after connection, before stepping):
# 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
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:
| Location | How to Set | What You See There |
|---|---|---|
*0x80000000 | Run → Add Breakpoint → Address | First instruction of OpenSBI fw_jump — CPU just left MROM |
sbi_init | Right-click margin in sbi_init.c | OpenSBI C initialisation begins — PMP, UART, extension registration |
_start (entry.S) | Right-click margin in entry.S line 1 | OpenSBI mret lands here — you are now in S-mode |
payload_main | Right-click margin in payload.c | C entry point — hartid and DTB in a0/a1 |
demo_sbi_base | Right-click margin in payload.c | SBI BASE ecalls begin — watch a7=0x10 |
payload_trap_handler | Right-click margin in payload.c | Every timer IRQ lands here — inspect scause, sepc live |
demo_timer | Right-click margin in payload.c | stimecmp 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:
| Expression | Meaning |
|---|---|
$pc | Current program counter |
$a0 | hartid at entry / SBI error return during ecalls |
$a1 | DTB address at entry / SBI value return during ecalls |
$a7 | SBI extension ID — watch it change: 0x10, 0x54494D45 |
$scause | Trap cause — timer interrupt = 0x8000000000000005 |
$sepc | PC that was interrupted when the timer fires |
$sie | S-mode interrupt enable — bit 5 (STIE) must be 1 before timer fires |
$sstatus | bit 1 (SIE) = global interrupt enable |
timer_count | Your global counter — watch it increment 0 → 1 → 2 → 3 |
Step 7 — Full Boot-to-End Step-Through Workflow
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.
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.
hbreak *0x80000000 before the continue in Run Commands — it will stop there first, then you manually resume to reach _start.Step through entry.S
Use F5 (step into) or F6 (step over). You will see:
la sp, _sstack_top— watch$spload to the stack top addresscsrw stvec, t0— watchstvec(if shown in registers view) get set to_strap_vectorcall payload_main— step into to enter C code
Step through payload_main and SBI ecalls
In payload.c, step through demo_sbi_base(). Watch in Expressions view:
$a7becomes0x10(SBI_EXT_BASE) just before eachecall$a1returns the spec version, impl ID, impl version- After the probe loop, all 5 extensions are confirmed present
Watch the timer arm sequence
Stepping through demo_timer():
sbi_set_timer()fires — watch$a7 = 0x54494D45("TIME")sie |= (1<<5)— watch$siebit 5 go high in Expressionssstatus |= (1<<1)— global interrupts enabled, timer is now armedwfi— Eclipse pauses here. Click Resume. The timer fires.
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 thewfiloop)timer_countincrements 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.
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
| Symptom | Cause | Fix |
|---|---|---|
| Debug button stays greyed out | No binary found or project not selected | Build the project first; click the project once in Project Explorer to select it |
| Code runs straight through, doesn't stop | hbreak not in Run Commands, or connected after QEMU already ran | Ensure hbreak *0x80200000 + continue is in Startup → Run Commands; restart QEMU first |
| "Remote 'g' packet reply is too long" | GDB register set mismatch | Add set arch riscv:rv64 to Initialization Commands |
| No source shown for OpenSBI functions | OpenSBI source not indexed by Eclipse | File → 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-step | Expected — wfi sleeps until an interrupt | Use Resume (F8) to continue past wfi until the timer interrupt fires |
Breakpoint on _start not hit | Software breakpoint unreliable before memory init | Use hbreak *0x80200000 (hardware breakpoint) instead |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| QEMU prints nothing, hangs immediately | fw_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 output | payload.elf not at 0x80200000, or FW_JUMP_ADDR mismatch | Run 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 extension | Remove _zicsr suffix: use -march=rv64imac |
| Build error: unrecognized CSR 'stimecmp' | GCC 10.x doesn't know the sstc extension | Use raw CSR number: csrw 0x14D, %0 (already done in this project) |
| Timer hangs — "Waiting for 3 interrupts..." never completes | STIE not set in sie, or sstatus.SIE=0 | Verify both sie |= (1<<5) and sstatus |= (1<<1) execute before wfi |
| [EXC] scause=0x5 — load access fault in UART or timer code | Reading CLINT MMIO (0x200BFF8) from S-mode — blocked by PMP | Use 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.STCE | The SSI fallback path in payload_trap_handler handles this automatically |
| GDB breakpoint at _start not hit | GDB connected but payload.elf not loaded as symbol file | In GDB: file payload.elf, then add-symbol-file payload.elf 0x80200000 |