Overview
This project implements the first two stages of a real embedded boot sequence on QEMU virt. It demonstrates privilege level transitions, the SBI (Supervisor Binary Interface) calling convention, and hardware timer interrupts — all from scratch without OpenSBI or any external firmware library.
Stage 1 — zsbl.S (M-mode)
Runs at 0x80000000. Sets up stack, zeros BSS, installs M-mode trap vector, configures PMP and interrupt delegation, then uses mret to drop to S-mode.
Stage 2 — supervisor.c (S-mode)
Runs at 0x80200000. Prints to UART, runs arithmetic and memory demos, then fires 3 timer interrupts by arming the CLINT via SBI ecall.
Architecture
The SBI Timer Flow
The CLINT's
mtimecmp register is an M-mode resource. S-mode cannot write to it directly — attempting to do so causes a store access fault. The standard solution is the SBI (Supervisor Binary Interface): S-mode calls ecall with a7=0 (SET_TIMER) and a0=desired_mtime. M-mode handles the ecall, writes mtimecmp, then returns. This is exactly what real OpenSBI does — our zsbl.S is a minimal hand-written SBI implementation.
Project Files
zsbl.S — M-mode Boot Loader
The key sections of zsbl.S explained:
Interrupt Delegation — The Critical Line
# CRITICAL: mideleg must be 0x0222, NOT 0x0202 # Bit 5 (S-mode timer) MUST be delegated. # Without bit 5, sie.STIE is hardwired to 0 from S-mode # — S-mode can never enable its own timer interrupt. li t0, 0x0222 # bits: 9=S-ext, 5=S-timer, 1=S-sw csrw mideleg, t0
0x0202 (missing bit 5). The timer demo hangs silently — S-mode sets STIE in sie but it immediately reads back as 0 because the hardware ignores it when the corresponding mideleg bit is not set.M-mode Trap Dispatch
_mtrap_vector: # Save t0-t3 on M-mode stack csrr t0, mcause # 0x8000000000000007 = interrupt + code 7 = M-timer beq t0, t1, _mtimer_irq # code 9 = S-mode ecall beq t0, t2, _sbi_ecall # unknown — spin for debugger j _trap_hang _mtimer_irq: # 1. Clear MTIE so M-timer doesn't re-fire immediately # 2. Set STIP in mip — inject S-mode timer pending # 3. mret — S-mode will see the pending interrupt csrs mip, (1<<5) # set Supervisor Timer Interrupt Pending mret _sbi_ecall: # a7=0, a0=absolute mtime value for next interrupt li t0, 0x2004000 sd a0, 0(t0) # write mtimecmp for hart 0 # Clear STIP, re-enable MTIE, advance mepc+4, mret
supervisor_entry.S
_supervisor_start: # S-mode entry at 0x80200000 la sp, _sstack_top # S-mode 8KB stack la t0, _strap_vector csrw stvec, t0 # install S-mode trap vector call supervisor_main # a0=hartid, a1=dtb from reset _strap_vector: # called on every S-mode interrupt/exception addi sp, sp, -256 # allocate stack frame # sd ra, t0-t6, a0-a7 — save 16 registers call supervisor_trap_handler # ld restore all 16 registers addi sp, sp, 256 sret # return from S-mode trap
sd a0, 32(sp) — stores all 64 bits of a0 to Memory[sp+32]. On RV64 each register is 8 bytes, so offsets step by 8: ra=0, t0=8, t1=16, t2=24, a0=32, a1=40, ...supervisor.c — Key Functions
SBI ecall — arming the timer
/* S-mode cannot write mtimecmp directly. * Use ecall with a7=0 (SBI SET_TIMER legacy extension). * zsbl.S M-mode handler receives this and writes 0x2004000. */ static void sbi_set_timer(unsigned long long t) { register unsigned long a0 __asm__("a0") = (unsigned long)t; register unsigned long a7 __asm__("a7") = 0UL; __asm__ volatile("ecall" : "+r"(a0) : "r"(a7) : "memory"); }
S-mode trap handler — timer interrupt
void supervisor_trap_handler(void) { unsigned long scause; __asm__ volatile("csrr %0, scause" : "=r"(scause)); if (scause & (1UL << 63)) { // interrupt (top bit set) unsigned long code = scause & ~(1UL << 63); if (code == 5) { // Supervisor Timer Interrupt timer_count++; if (timer_count < 3) { sbi_set_timer(CLINT_MTIME + TIMER_INTERVAL); // re-arm } else { sbi_set_timer(0xFFFFFFFFFFFFFFFFULL); // disarm // disable STIE so no more S-mode timer interrupts unsigned long sie; __asm__ volatile("csrr %0, sie" : "=r"(sie)); sie &= ~(1UL << 5); __asm__ volatile("csrw sie, %0" :: "r"(sie)); } } } }
Linker Scripts
linker_zsbl.ld
ENTRY(_start) MEMORY { RAM(rwx): ORIGIN=0x80000000, LENGTH=2M } SECTIONS { .text : { *(.text.zsbl) /* _start first */ *(.text*) } > RAM .bss : { _bss_start = .; *(.bss*) *(COMMON) _bss_end = .; } > RAM .mstack : { *(.mstack) } > RAM }
linker_super.ld
ENTRY(_supervisor_start) MEMORY { RAM(rwx): ORIGIN=0x80200000, LENGTH=2M } SECTIONS { .text : { *(.text.super_entry) /* entry first */ *(.text*) } > RAM .data : { *(.data*) } > RAM .bss : { *(.bss*) } > RAM .sstack : { *(.sstack) } > RAM }
Makefile
ASFLAGS = -g $(ARCH) # NO -mcmodel here — assembler rejects it CFLAGS = -g -O0 $(ARCH) -nostdlib -nostartfiles \ -ffreestanding -fno-builtin -Wall \ -mcmodel=medany -mno-relax # -mcmodel only for GCC all: zsbl.elf supervisor.elf zsbl.elf: zsbl.o $(LD) -T linker_zsbl.ld -nostdlib -o $@ $^ supervisor.elf: supervisor_entry.o supervisor.o $(LD) -T linker_super.ld -nostdlib -o $@ $^
-mcmodel=medany to ASFLAGS. The GNU assembler rejects this flag with "unrecognized option". It is a GCC-only code model flag. Keep it only in CFLAGS.Build & Run
Clone & build
git clone https://github.com/vwire/riscv-bare-metal-qemu.git cd riscv-bare-metal-qemu/zsbl_supervisor make # Produces: zsbl.elf (entry 0x80000000) and supervisor.elf (entry 0x80200000)
Verify both entry points:
riscv64-unknown-elf-readelf -h zsbl.elf | grep Entry # Entry point address: 0x80000000 riscv64-unknown-elf-readelf -h supervisor.elf | grep Entry # Entry point address: 0x80200000
Run — expected output
make qemu-run
ZSBL ######################################## # RISC-V ZSBL + Supervisor Demo # ######################################## Hart ID : 0x0000000000000000 DTB : 0x0000000087E00000 [---- Arithmetic Demo ----] a = 123456789 b = 987654321 a+b = 1111111110 b-a = 864197532 a*3 = 370370367 [---- Memory Read/Write Demo ----] [0x0000000080400000] PASS [0x0000000080400008] PASS [0x0000000080400010] PASS [0x0000000080400018] PASS Result: 4/4 passed [---- Timer Interrupt Demo ----] Arming timer via SBI ecall... Waiting for 3 interrupts... [IRQ] Timer #1 mtime=0x00000000004CFF5A [IRQ] Timer #2 mtime=0x0000000000997082 [IRQ] Timer #3 mtime=0x0000000000E5EA70 3 timer interrupts received. ######################################## # Demo complete. System halted. # ########################################
Run with GDB (terminal)
# Terminal 1 make qemu # QEMU paused at 0x1000 # Terminal 2 gdb-multiarch (gdb) set architecture riscv:rv64 (gdb) file zsbl.elf (gdb) target remote localhost:1234 (gdb) break _start (gdb) continue # runs from 0x1000 reset vector to _start (gdb) stepi # After mret — load supervisor symbols (gdb) add-symbol-file supervisor.elf 0x80200000 (gdb) break supervisor_main (gdb) continue
Eclipse CDT Debug Setup
The Eclipse configuration is identical to Project 1 except for the ELF files and working directory.
| Tab | Setting | Value |
|---|---|---|
| Main | C/C++ Application | zsbl.elf |
| Debugger | GDB Command | /usr/bin/gdb-multiarch |
| Debugger | Host / Port | localhost / 1234 |
| Startup | Init commands | set architecture riscv:rv64 |
| Startup | Load symbols | zsbl.elf |
| Startup | Set breakpoint at | _start |
| Startup | Resume | ☑ checked |
To debug the supervisor in C, after stopping at the mret boundary run in the GDB Console:
-exec add-symbol-file supervisor.elf 0x80200000 -exec break supervisor_main -exec continue
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Build fails: "unrecognized option -mcmodel" | -mcmodel=medany in ASFLAGS | Remove it from ASFLAGS — keep only in CFLAGS |
| Timer demo hangs after "Waiting for 3 interrupts" | mideleg = 0x0202 (missing bit 5) | Change to 0x0222 — bit 5 must be set |
| GDB stops at 0x1000 not _start | Symbols not loaded, no breakpoint at _start | Run -exec file zsbl.elf then -exec break _start then -exec continue |
| Timer fires infinitely | STIE not disabled after 3rd tick | After 3rd tick: sie &= ~(1<<5) — already in the code |
| supervisor.elf entry is not 0x80200000 | *(.text.super_entry) missing from linker | Check linker_super.ld — super_entry section must come first |
| Illegal instruction in M-mode | OpenSBI is running (default -bios) | Use -bios zsbl.elf not default. zsbl runs in M-mode. |