← Home / Project 2

Project 2 — ZSBL + Supervisor

A two-stage boot loader: M-mode ZSBL in assembly sets up the machine, then drops to S-mode C supervisor with working SBI timer interrupts.

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

── Physical memory layout ─────────────────────────────────────
0x1000 QEMU MROM reset vector (5 instructions, not your code)
↓ loads 0x80000000 from 0x1018, jumps there
0x80000000 zsbl.S — M-mode, loaded via -bios zsbl.elf
├ stack setup, BSS zero, UART print "ZSBL"
├ mtvec = _mtrap_vector
├ PMP: full access to all memory
├ medeleg = 0xFFFFF & ~(1<<9) [keep S-ecall in M]
├ mideleg = 0x0222 [delegate S-timer to S]
├ mie.MTIE = 1, mstatus.MIE = 1
└ mepc=0x80200000, MPP=S → mret
↓ mret → S-mode
0x80200000 supervisor_entry.Ssupervisor_main() — S-mode
├ stvec = _strap_vector
├ arithmetic demo, memory demo
├ sbi_set_timer(mtime+5M) ← ecall to M-mode zsbl
└ wfi loop — waits for 3 STIP interrupts
↑ each timer tick:
M-mode: MTIE fires → clear MTIE, set STIP in mip
S-mode: STIP fires → supervisor_trap_handler()

The SBI Timer Flow

Why can't S-mode write mtimecmp directly?
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_supervisor/ ├── zsbl.S # M-mode boot loader — runs at 0x80000000 ├── supervisor_entry.S # S-mode entry + trap vector — jumps to supervisor_main() ├── supervisor.c # S-mode C code — UART, demos, timer handler ├── supervisor.h # function prototypes ├── linker_zsbl.ld # zsbl.elf layout: 0x80000000 ├── linker_super.ld # supervisor.elf layout: 0x80200000 └── Makefile # builds two ELFs, QEMU launch targets

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
Common mistake: using 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 $@ $^
Do not add -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

1

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
2

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.       #
########################################
3

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.

TabSettingValue
MainC/C++ Applicationzsbl.elf
DebuggerGDB Command/usr/bin/gdb-multiarch
DebuggerHost / Portlocalhost / 1234
StartupInit commandsset architecture riscv:rv64
StartupLoad symbolszsbl.elf
StartupSet breakpoint at_start
StartupResume☑ 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

SymptomCauseFix
Build fails: "unrecognized option -mcmodel"-mcmodel=medany in ASFLAGSRemove 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 _startSymbols not loaded, no breakpoint at _startRun -exec file zsbl.elf then -exec break _start then -exec continue
Timer fires infinitelySTIE not disabled after 3rd tickAfter 3rd tick: sie &= ~(1<<5) — already in the code
supervisor.elf entry is not 0x80200000*(.text.super_entry) missing from linkerCheck linker_super.ld — super_entry section must come first
Illegal instruction in M-modeOpenSBI is running (default -bios)Use -bios zsbl.elf not default. zsbl runs in M-mode.