Project 4
SMP Payload

Platform: QEMU virt · 2 cores × 2 harts · S-mode · OpenSBI v1.8  |  Files: entry.S · payload.c · payload.h · linker.ld · Makefile
Demonstrates: Atomic primary election · HSM hart start · Per-hart timers · Cross-hart IPI · amoswap spinlock · Atomic shared counter
① Overview
② PMP Regions
③ entry.S
④ payload.c
⑤ payload.h
⑥ linker.ld
⑦ Makefile

Project Overview

Project 4 is a bare-metal S-mode SMP payload running on 4 RISC-V harts (2 cores × 2 harts each). It demonstrates every fundamental multi-hart mechanism: atomic primary election, HSM-based secondary hart launch, per-hart timer interrupts via SBI, cross-hart IPIs, an atomic shared counter, and a contention-safe UART spinlock.

Boot Flow Summary
1
OpenSBI boots (M-mode)
One of the 4 harts wins OpenSBI's internal boot lottery. It initialises scratch spaces for all harts, sets up PMP, and does mret to 0x80200000 for all harts in S-mode. The winning hart is non-deterministic — it can be 0, 1, 2 or 3.
2
All harts arrive at _start
Every hart arrives at our _start entry point with a0=hartid, a1=DTB pointer. They all run the same first few instructions. The coldboot hart arrives first; warmboot harts may arrive slightly later.
3
Atomic lottery — one hart wins primary
amoswap on _boot_lottery. The very first hart to reach this instruction swaps 1 in and reads back 0 — it wins. All others read back 1 and fall through to _secondary_wait where they execute wfi in a loop.
4
Primary sets up and calls primary_main()
Sets its own stack pointer (using its hartid × 4KB offset from _stack_top), initialises GP register, clears BSS, then calls primary_main().
5
Primary HSM-starts all other harts
Writes primary_hartid so secondaries know who to IPI. Calls sbi_hsm_hart_start() for every other hartid pointing to _secondary_entry. OpenSBI wakes each waiting hart and delivers it to _secondary_entry in S-mode.
6
Secondaries run secondary_main()
Each secondary sets its stack, GP, sscratch. Calls secondary_main(). Sets hart_ready[hartid]=1 and sends an IPI to primary to wake it from wfi. Enables timer and arms it with a staggered delay.
7
All 4 harts run timer interrupts
Each hart fires 3 timer interrupts. Each interrupt atomically increments shared_counter. Total = 12. Primary fires at intervals of 1s. Secondaries stagger: hart N starts at (N+1) seconds.
8
Primary sends IPIs, summary printed
After primary's 3 timers complete it sends one IPI to each secondary. When all harts have finished timers and received IPIs, primary prints the summary showing shared_counter=12.
Memory Layout
OpenSBI firmware (M-mode, protected)
0x80000000
OpenSBI .text / .rodata
firmware code — S-mode: read+execute only
256 KB
0x80040000
OpenSBI .data / scratch
per-hart scratch, stacks, heap — S-mode: DENIED
101 KB
Payload (S-mode, full access)
0x80200000
_start / .text.entry
first instruction executed by payload
0x80200xxx
.text / .rodata
payload code and constants
0x80200bxx
.data
uart_lock, hart_ready[], timer_count[], shared vars
0x80200bxx
.bss
(empty — all vars forced to .data)
0x80200c20
_stack_bottom
hart 3 stack begins here (grows up toward _stack_top)
0x80201c20
_stack_top
hart 0 sp initial. Each hart gets 4KB. Total 16KB.
16 KB
Devices (MMIO)
0x10000000
UART NS16550
serial console — direct S-mode access via PMP
0x02000000
CLINT
mtime, mtimecmp — M-mode only (via SBI ecall)
Key Design Decisions
DecisionWhy
Atomic lottery for primaryOpenSBI boot hart is non-deterministic. Lottery ensures correctness on every run regardless of which hart wins OpenSBI.
HSM to start secondariesSecondaries that lost the lottery are in wfi with no stack. HSM gives them a clean S-mode entry with correct registers.
amoswap for UART lockLR/SC reservations are cancelled by other harts touching the same cache line. amoswap is indivisible — cannot be interrupted.
uart_lock in padded structEnsures uart_lock occupies its own 64-byte cache line so adjacent variables cannot interfere with the spinlock.
All shared vars in .dataGCC puts zero-initialised vars in .bss. The runtime BSS clear had a GP-relative bug. Forcing to .data means ELF loader initialises them correctly before _start runs.
wfi in wait loopsTight spinning on hart_ready[] shared the same cache line as uart_lock, cancelling LR/SC reservations. wfi yields the CPU and eliminates bus traffic.

PMP Regions — What OpenSBI Sets Up

When QEMU boots you see Domain0 Region entries in the OpenSBI banner. Each one is a PMP entry programmed by OpenSBI defining which physical addresses S-mode (your payload) can access and how. Here is every region explained.

How to read the permissions:   M: (F,R,W) = what M-mode (OpenSBI) can do  |  S/U: (R,W) = what S-mode (your payload) can do  |  F=fetch(execute)  R=read  W=write  ()=no access
Region00 — OpenSBI Read/Write Data (DENY to S-mode)
0x80040000 – 0x8005FFFF
OpenSBI .data / scratch / stacks / heap
128 KB — PMP entry 0, highest priority, L=1 (locked, applies to M-mode too via Smepmp)
M: F R W
S/U: DENY
Why denied: This is where OpenSBI keeps its scratch spaces (per-hart private data), stacks, and heap. If S-mode could read here it could steal cryptographic keys or firmware state. If it could write here it could corrupt OpenSBI and take over M-mode. Entry 0 has highest PMP priority — it wins over all other entries that cover the same address range. Any load, store, or fetch from your payload to this range causes an immediate access-fault exception (MCAUSE=5 for load, 7 for store, 1 for fetch).
Region01 — OpenSBI Code (Read+Execute for S-mode)
0x80000000 – 0x8003FFFF
OpenSBI .text and .rodata
256 KB — firmware code, read-only constants, SBI ecall handlers
M: F R X
S/U: — (implicit via R06)
Why read but not write: S-mode needs to be able to read and execute through OpenSBI's code during ecall handling — when you make an SBI ecall, execution jumps to M-mode MTVEC inside OpenSBI. S-mode cannot write here (write permission not granted) so your payload cannot patch or corrupt the firmware. Note: Region06 grants S/U broad RWX but Region00 and Region01's M-mode flags make the firmware readonly in practice.
Region02 — Test Device / Debug ROM (R/W for S-mode)
0x00100000 – 0x00100FFF
Test/Debug device MMIO
4 KB — QEMU test device, used for system reset/poweroff via syscon
M: I R W
S/U: R W
I = Idempotent: The I flag means accesses to this region are idempotent (reads have no side effects). This is used for memory-mapped control registers. S-mode can read and write here, which is how clean system shutdown works — writing a specific value to the syscon poweroff address causes QEMU to exit cleanly.
Region03 — CLINT (DENY to S-mode)
0x02000000 – 0x0200FFFF
CLINT — Core Local Interruptor
64 KB — msip[], mtimecmp[], mtime — timer and IPI hardware
M: I R W
S/U: DENY
Why denied: CLINT contains msip[] (software interrupt pending registers) and mtimecmp[] (timer compare registers). These are M-mode resources. If S-mode could write msip[] directly it could fire arbitrary IPIs without going through OpenSBI, bypassing the SBI IPC model. Your payload accesses the timer exclusively through sbi_set_timer() (SBI_EXT_TIME ecall) and IPIs through sbi_send_ipi() (SBI_EXT_IPI ecall). OpenSBI handles the actual CLINT register writes in M-mode.
Region04 — PLIC Context Registers (R/W for S-mode)
0x0C400000 – 0x0C5FFFFF
PLIC — S-mode context registers
2 MB — per-hart S-mode claim/complete, threshold, enable registers
M: I R W
S/U: R W
S-mode gets its own PLIC context: The PLIC has separate context registers for M-mode and S-mode per hart. This region contains the S-mode contexts. Your payload (or an OS kernel) can directly configure which external interrupt sources it wants to receive, claim pending interrupts, and complete them — all without going through OpenSBI. Project 4 does not use the PLIC but a real OS driver would access it here.
Region05 — PLIC Source/Priority/Enable Registers (R/W for S-mode)
0x0C000000 – 0x0C3FFFFF
PLIC — priority, pending, enable registers
4 MB — source priorities (0x4–0xFFC), pending bits (0x1000), enable bits (0x2000+)
M: I R W
S/U: R W
S-mode can configure interrupt routing: This region contains the PLIC source priority registers (one per interrupt source, 1-7) and per-hart-per-mode enable bitmaps. S-mode can set priorities and enable/disable specific interrupt sources for its own PLIC contexts. Combined with Region04, S-mode has full PLIC control for external device interrupt management.
Region06 — All Memory (Catch-all R/W/X for S-mode)
0x00000000 – 0xFFFFFFFFFFFFFFFF
Entire address space
Lowest priority — only applies where no higher-priority entry matched
M: none
S/U: R W X
The default allow rule: Without this entry, any address not covered by entries 0–5 would be denied to S-mode (PMP default is deny-all for S-mode if no entry matches). This catch-all grants S-mode RWX to everything that was not explicitly handled above. In practice this means: payload DRAM (0x80200000+), UART (0x10000000), and all other devices your payload needs to access directly. The higher-priority entries 0–5 override this for the firmware and CLINT regions.
M-mode has no permissions here: The empty M: () is because Smepmp is not active on this build. Without Smepmp, M-mode bypasses all unlocked PMP entries anyway. With Smepmp enabled (MML=1), M-mode would need explicit entries for its own regions — Region06 would then effectively block M-mode from payload memory, creating strong isolation.

entry.S — Line by Line

The assembly entry point. Runs before any C code. Sets up the execution environment for every hart: stack pointer, global pointer, trap vector, and the primary election lottery.

File Header and Definitions
entry.SRISC-V Assembly
21
.section .text.entry, "ax", %progbits
Goes first in memory at 0x80200000
22
.align 3
Align to 8-byte boundary (2³=8)
23
.global _start
Exported — linker uses as ELF entry point
24
.global _secondary_entry
Exported — HSM hart_start jumps here
25
.global _trap_entry
Exported — payload.c takes its address
28
#define CSR_STVEC 0x105
Supervisor trap vector CSR address
29
#define CSR_SIE 0x104
Supervisor interrupt enable CSR
31
#define CSR_SSCRATCH 0x140
Scratch CSR — we store hartid here for trap_handler
32
#define STACK_SIZE 0x1000
4 KB per hart stack
_start — Entry Point for All Harts
_start:called by OpenSBI mret with a0=hartid, a1=DTB, S-mode
38
mv s0, a0
Save hartid in s0 (callee-saved — survives function calls)
39
mv s1, a1
Save DTB pointer in s1 (callee-saved)
42
la t0, _trap_entry
Load address of our trap handler
43
csrw CSR_STVEC, t0
Install trap vector NOW — before anything else can fault
46
la t0, _boot_lottery
Address of the lottery flag in .data
47
li t1, 1
Value we want to swap in
48
amoswap.w.aq t2, t1, (t0)
ATOMIC: swap 1 in, get old value back in t2
49
beqz t2, _primary
Old value was 0 → we are the first → go to _primary
53
_secondary_wait:
Any other hart lands here and waits
54
wfi
Sleep until HSM hart_start wakes us (redirects PC to _secondary_entry)
55
j _secondary_wait
Loop back in case wfi woke from something else
Why amoswap and not LR/SC? amoswap is a single indivisible atomic operation — it cannot be cancelled. LR/SC has a gap between LR and SC where another hart touching the same memory cancels the reservation. On a 4-hart system with all harts arriving nearly simultaneously, LR/SC would cause a livelock.
_primary — Lottery Winner Setup
_primary:one hart only — any hartid
60
la sp, _stack_top
Stack top symbol from linker.ld
61
li t0, STACK_SIZE
4096 bytes
62
mul t1, s0, t0
t1 = hartid × 4096
63
sub sp, sp, t1
sp = _stack_top - hartid×4KB → each hartid gets its own stack region
66
.option push
Save assembler options
67
.option norelax
CRITICAL: prevent assembler from optimising away the la gp instruction
68
la gp, __global_pointer$
Set GP so C code can access global variables via GP-relative addressing
69
.option pop
Restore assembler options
72
csrw CSR_SSCRATCH, s0
Store hartid in sscratch — trap_handler reads this to know which hart trapped
75
la s2, _bss_start
BSS start address (loaded into callee-saved s2)
76
la s3, _bss_end
BSS end address — s2/s3 prevent GP-relative address substitution bug
78
bgeu s2, s3, _bss_done
If start >= end → BSS is empty → skip
79
sd zero, 0(s2)
Store 8 zero bytes
80
addi s2, s2, 8
Advance 8 bytes
84
mv a0, s0
Restore a0 = hartid (BSS clear clobbered t regs)
85
mv a1, s1
Restore a1 = DTB pointer
86
call primary_main
Jump into C code — never returns
_secondary_entry — HSM-Delivered Secondary Hart Entry
_secondary_entry:OpenSBI HSM delivers hart here with a0=hartid, a1=opaque, S-mode
95
mv s0, a0
Save hartid
96
mv s1, a1
Save opaque value (DTB pointer we passed to sbi_hsm_hart_start)
99
la t0, _trap_entry
100
csrw CSR_STVEC, t0
Install trap vector — OpenSBI may have set a different one
103
csrw CSR_SSCRATCH, s0
Store hartid so trap_handler knows who we are
106
la sp, _stack_top
107
li t0, STACK_SIZE
108
mul t1, s0, t0
109
sub sp, sp, t1
Same formula as _primary: each hart gets its own 4KB stack
112
.option norelax
113
la gp, __global_pointer$
MUST set GP before calling any C — without this, global var access faults
117
call secondary_main
Enter secondary C code
_trap_entry — S-mode Trap Handler Entry
Purpose: When any interrupt or exception fires in S-mode, the CPU jumps here automatically (because we wrote _trap_entry to stvec). It must save all CPU registers onto the stack, call our C trap_handler(), then restore everything and return.
_trap_entry:CPU jumps here on any S-mode interrupt or exception
122
addi sp, sp, -272
Make room on stack: 34 × 8 bytes = 272 bytes for all registers
124
sd ra, 0(sp)
Save return address
125
sd t0, 8(sp)
Save all caller-saved and callee-saved registers...
151
csrr t0, sepc
Read PC of instruction that caused the trap
152
sd t0, 240(sp)
Save it — trap_handler may modify sepc to skip past exception
153
csrr t0, sstatus
Save S-mode status (interrupt enable bits etc.)
154
sd t0, 248(sp)
157
mv a0, sp
Pass the trap frame pointer as argument to C trap_handler()
158
call trap_handler
Call our C function to handle the interrupt or exception
161
ld t0, 240(sp)
Reload possibly-modified sepc
162
csrw sepc, t0
Write it back — if exception handler changed frame->sepc, CPU resumes there
193
addi sp, sp, 272
Reclaim the 272 bytes of stack space
194
sret
Return from S-mode trap — resumes at sepc in S-mode

payload.c — Line by Line

All the C code: shared variables, UART driver, SBI ecall wrappers, trap handler, and the primary/secondary main functions.

Shared Variables — Why .data and Not .bss
payload.c — shared variables
11
volatile int _boot_lottery __attribute__((section(".data"))) = 0;
0=unclaimed, 1=taken. amoswap in entry.S elects primary.
14
volatile uint64_t primary_hartid __attribute__((section(".data"))) = 0;
Primary writes its hartid here before HSM-starting secondaries
18
static struct { volatile int lock; char _pad[60]; }
lock + 60 bytes padding = exactly 64 bytes = one cache line
19
uart_mutex __attribute__((section(".data"), aligned(64))) = {0};
aligned(64) ensures it starts at a cache line boundary — isolated
26
volatile int hart_ready[NUM_HARTS] IN_DATA = {0,0,0,0};
Each hart sets hart_ready[hartid]=1 when it is running
27
volatile uint64_t timer_count[NUM_HARTS] IN_DATA = {0,0,0,0};
Incremented by trap_handler on each timer IRQ per hart
28
volatile int ipi_received[NUM_HARTS] IN_DATA = {0,0,0,0};
Incremented by trap_handler when an IPI arrives
29
volatile uint64_t shared_counter IN_DATA = 0;
Incremented atomically by ALL harts on every timer — final value = 12
Why force .data? GCC treats = 0 as equivalent to .bss (zero at runtime). But our runtime BSS clear had a GP-relative addressing bug where the loop used the wrong end address. Forcing to .data means the ELF loader sets all values to zero before _start runs — no runtime clear needed, no bug possible.
UART Driver and Spinlock
uart_putc / uart_acquire
33
void uart_putc(char c) {
34
while (!(UART_LSR & UART_LSR_THRE));
Spin until UART transmit register is empty (ready to accept a byte)
35
UART_THR = (uint8_t)c;
Write byte to UART transmit holding register → character appears on terminal
55
static void uart_acquire(void) {
57
__asm__ volatile ("amoswap.w.aq %0, %2, (%1)"
Atomically swap 1 into uart_lock, get old value
61
if (old == 0) return;
Old was 0 (free) → we own the lock now
62
while (uart_lock);
Old was 1 (held) → wait for it to become 0 then retry
67
static void uart_release(void) {
68
__asm__ volatile ("amoswap.w.rl zero, zero, (%0)"...);
Atomically write 0 with release ordering → lock is free, visible to all harts
Why amoswap not LR/SC: LR/SC needs a reservation gap between two instructions. During that gap, other harts loading hart_ready[] (same cache line as uart_lock originally) would cancel the reservation. amoswap is a single bus transaction — cannot be cancelled. The padded struct ensures uart_lock has its own cache line so no neighbour can interfere.
SBI Ecall Interface
sbi_call() and wrappers
88
register uint64_t ra6 asm("a6") = fid;
SBI function ID goes in a6
89
register uint64_t ra7 asm("a7") = ext;
SBI extension ID goes in a7 — this tells OpenSBI which service you want
91
__asm__ volatile ("ecall" ...);
ecall → CPU traps to M-mode → OpenSBI handles → mret back to us
93
ret.error = (long)ra0; ret.value = (long)ra1;
OpenSBI returns error code in a0, result value in a1
99
sbi_set_timer(uint64_t stime_value)
Tell OpenSBI when to fire the next timer: ext=TIME, fid=0, a0=deadline
104
sbi_send_ipi(uint64_t mask, uint64_t base)
Send IPI: mask=bitmask of target harts, base=hartid of bit 0
114
sbi_hsm_hart_start(uint64_t hartid, start, opaque)
Tell OpenSBI to wake a stopped hart and deliver it to start_addr in S-mode
SBI extension IDs used: TIME (0x54494D45) for timer, IPI (0x735049) for inter-hart interrupts, HSM (0x48534D) for hart lifecycle management, BASE (0x10) for version query.
trap_handler() — Handles All S-mode Interrupts
trap_handler()called from _trap_entry with frame pointer as argument
152
uint64_t hartid = hartid_from_sscratch();
Read hartid we stored in sscratch in entry.S — no parameter needed
154
__asm__ volatile ("csrr %0, scause" ...);
scause tells us what happened: bit 63=1 means interrupt, =0 means exception
157
if (scause & (1UL << 63)) {
Interrupt path (bit 63 set)
158
uint64_t code = scause & ~(1UL << 63);
Strip the interrupt bit to get the interrupt code
160
if (code == 5) {
Code 5 = Supervisor Timer Interrupt (STIP)
162
timer_count[hartid]++;
Track how many times this hart's timer fired
163
ctr = atomic_inc(&shared_counter);
amoadd.d.aqrl — atomically increment shared counter, all harts safe
168
if (timer_count[hartid] < TIMER_FIRES_PER_HART)
169
timer_set_next(TIMER_INTERVAL);
Re-arm for next fire (SBI keeps timer going)
171
sbi_set_timer(0xFFFFFFFFFFFFFFFFULL);
Disarm: set deadline so far in future it will never fire again
175
} else if (code == 1) {
Code 1 = Supervisor Software Interrupt (SSIP) = IPI
177
ipi_received[hartid]++;
Track IPI count for this hart
180
"csrc sip, %0" ... 1UL<<1;
Clear SSIP bit in sip CSR — MUST do this or IPI fires again immediately
secondary_main() and primary_main()
secondary_main()runs on all non-primary harts
191
hprint(hartid, "Secondary started.");
First print — acquires UART lock, prints, releases
194
__asm__ volatile ("fence rw, rw");
Memory barrier: hart_ready write must be visible to ALL harts before IPI
195
hart_ready[hartid] = 1;
Signal to primary that we are alive and running
196
send_ipi_to_hart(primary_hartid);
Wake primary from wfi in wait_all_harts_ready — use primary_hartid not hardcoded 0
199
__asm__ volatile ("csrs sie, %0" ... SIE_STIE | SIE_SSIE);
Enable both timer (STIE) and software/IPI (SSIE) interrupts
200
__asm__ volatile ("csrs sstatus, %0" ... SSTATUS_SIE);
Enable global S-mode interrupt delivery (master switch)
203
sbi_set_timer(read_time() + TIMER_INTERVAL * (hartid + 1UL));
Stagger: hart 1 waits 1s, hart 2 waits 2s, hart 3 waits 3s
205
while (timer_count[hartid] < TIMER_FIRES_PER_HART || !ipi_received[hartid])
Wait until 3 timers fired AND at least 1 IPI received
206
__asm__ volatile ("wfi");
Sleep between interrupts — wake only when timer or IPI fires
primary_main()runs on lottery winner (any hartid)
228
primary_hartid = hartid;
Write OUR hartid BEFORE HSM-starting secondaries — they read this to IPI us
229
__asm__ volatile ("fence rw, rw");
Barrier: primary_hartid must be visible before secondaries see HSM start
235
for (h = 0; h < NUM_HARTS; h++) {
236
if ((uint64_t)h == hartid) continue;
Skip ourselves — we are already running
237
sbi_hsm_hart_start((uint64_t)h, entry, dtb);
Tell OpenSBI: wake hart h, deliver to _secondary_entry in S-mode
247
wait_all_harts_ready(hartid);
Sleep with wfi until all secondaries set hart_ready[] and IPI us
252
__asm__ volatile ("csrs sie, %0" ... SIE_STIE);
Enable only timer (no SSIE needed for primary's main loop)
254
timer_set_next(TIMER_INTERVAL);
Arm timer — first IRQ fires in 1 second
263
if (!ipi_sent && timer_count[hartid] >= TIMER_FIRES_PER_HART) {
Once our 3 timers complete, send IPI to all secondaries exactly once
267
send_ipi_to_hart((uint64_t)h);

payload.h — Line by Line

The shared header included by both entry.S (for bit mask defines) and payload.c. Defines all constants, types, and external symbols.

Constants
ConstantValueMeaning
NUM_HARTS4Total harts in the system
UART_BASE0x10000000NS16550 UART MMIO base address on QEMU virt
UART_THRoffset +0Transmit Holding Register — write byte here to send
UART_LSRoffset +5Line Status Register — read bit 5 to check TX ready
UART_LSR_THRE0x20Bit 5 of LSR — Transmit Holding Register Empty
SIE_STIE1<<5Supervisor Timer Interrupt Enable bit in sie CSR
SIE_SSIE1<<1Supervisor Software Interrupt Enable (IPI) in sie CSR
SSTATUS_SIE1<<1Global S-mode interrupt enable in sstatus CSR
TIMER_INTERVAL10,000,000CLINT ticks between timer fires. At 10 MHz = 1 second
TIMER_FIRES_PER_HART3Each hart fires its timer 3 times. Total = 12 across 4 harts
Types and SBI IDs
ItemPurpose
sbi_ret_tTwo-field struct {error, value} — every SBI ecall returns this pair. error=0 means success.
trap_frame_tStruct with one field per register saved by _trap_entry. Layout matches exactly — C code accesses frame->sepc to modify the return address.
SBI_EXT_BASE 0x10Base extension — version queries, feature probing
SBI_EXT_TIME 0x54494D45"TIME" in ASCII — timer extension
SBI_EXT_IPI 0x735049"sPI" — inter-processor interrupt extension
SBI_EXT_HSM 0x48534D"HSM" — Hart State Management extension
extern declarations: The header declares extern void _trap_entry(void) and extern void _secondary_entry(void) so payload.c can take their addresses and pass them to HSM and stvec writes.

linker.ld — Line by Line

Tells the linker where to place each section in physical memory and defines the symbols (_stack_top, _bss_start etc.) that entry.S uses.

Complete Linker Script Annotated
linker.ld
21
OUTPUT_ARCH("riscv")
Target architecture
22
ENTRY(_start)
ELF entry point — where QEMU/OpenSBI jumps to
25
. = 0x80200000;
Start placing sections at 0x80200000 — OpenSBI fw_jump convention
28
.text.entry : { *(.text.entry) }
_start must be at exactly 0x80200000 — entry.S uses this section name
33
.text : { *(.text .text.*) }
All other code follows immediately after entry
38
.rodata : ALIGN(8) { *(.rodata .rodata.*) }
Read-only data: string literals, const arrays (uart h[] table etc.)
43
.data : ALIGN(8) {
All initialised variables — including our forced .data shared vars
44
*(.data .data.*)
Normal .data objects
45
*(.sdata .sdata.*)
Small data (GP-relative) folded here — no separate .sdata section
46
. = ALIGN(8);
47
__global_pointer$ = . + 0x800;
GP points 0x800 bytes into .data — gives ±2KB GP-relative range covering all globals
51
.bss : ALIGN(8) {
Zero-initialised data (currently empty — everything forced to .data)
52
_bss_start = .;
Symbol used by entry.S BSS clear loop start
55
_bss_end = .;
Symbol used by entry.S BSS clear loop end
62
.stack : ALIGN(16) {
Stack section — 16-byte aligned (RISC-V ABI requires aligned sp)
63
_stack_bottom = .;
Lowest byte of all stacks
64
. += 0x1000;
4 KB for hart 3 (lowest in memory)
65
. += 0x1000;
4 KB for hart 2
66
. += 0x1000;
4 KB for hart 1
67
. += 0x1000;
4 KB for hart 0 (highest in memory)
68
_stack_top = .;
Hart 0 initial sp. Hart N: sp = _stack_top - N×4096
Stack layout in memory (high to low):
Hart 0 stack: _stack_top down to _stack_top - 4KB  |  Hart 1 stack: _stack_top - 4KB down to _stack_top - 8KB  |  Hart 2: down to -12KB  |  Hart 3: down to _stack_bottom
Each hart computes its own sp in entry.S as: sp = _stack_top - hartid × 4096

Makefile — Explained

Builds the payload, links it, and launches QEMU with the correct 4-hart topology.

Key Compiler Flags
FlagWhy it is needed
-march=rv64imacGCC 10.2 compatible. Includes I(base), M(multiply), A(atomics for amoswap/amoadd), C(compressed instructions). The A extension is essential for amoswap and amoadd.
-mabi=lp6464-bit integers and pointers, no floating point ABI. Matches the hardware (no FPU used).
-mcmodel=medanyPC-relative addressing for all symbol references. Required for code loaded at 0x80200000 — the linker generates auipc+addi pairs instead of absolute addresses.
-ffreestandingNo standard library. No startup code. No hidden calls to malloc or printf.
-fno-stack-protectorNo __stack_chk_guard — that symbol does not exist in bare metal.
-fno-builtinPrevent GCC from replacing memset/memcpy calls with library versions.
-nostdlib -nostartfilesDo not link libc or crt0. Our entry.S is the only startup code.
-fno-commonForce all tentative definitions to generate errors if duplicated — catches accidental multiple definitions.
-O1Basic optimisation. Higher levels can reorder memory accesses in ways that break bare-metal code without careful use of volatile.
QEMU Command Explained
make qemu-run
$(QEMU)
Full path to Zephyr SDK QEMU binary
-machine virt
QEMU generic RISC-V virtual machine — includes CLINT, PLIC, UART, DRAM
-smp cpus=4,cores=2,threads=2
4 total CPUs = 2 physical cores × 2 SMT threads each = 4 harts
-m 256M
256 MB DRAM starting at 0x80000000
-nographic
No graphical window. Routes UART0 to terminal stdout/stdin.
-bios $(OPENSBI_FW)
fw_jump.elf loaded at 0x80000000 — runs in M-mode, sets up PMP, jumps to kernel
-kernel payload.elf
Our ELF loaded at 0x80200000 — OpenSBI does mret here after init
-smp breakdown: cpus=4 is the total. cores=2 means 2 physical CPU packages. threads=2 means each core has 2 hardware threads (SMT harts). QEMU assigns MHARTIDs 0,1 to core 0 and 2,3 to core 1. From the software perspective all 4 are symmetric — each has its own pipeline, L1 caches, and register file, sharing execution units and L2 with their sibling on the same core.
Ctrl-A X to exit QEMU.  make qemu-debug adds -S -s to pause at startup and open a GDB server on port 1234.