Skip to content

6. User Processes

Chapter 5 had one hardcoded process with an inline scheduler. This chapter replaces that with a real process abstraction: a spawn_with_args function that builds an address space from a code blob, a process table that tracks state, and a run loop that picks the next ready process.

By the end, two user programs print interleaved output through cooperative yielding:

Heap: 0xffffffc000000000 (1024 KiB)
spawned pid 0
spawned pid 1
A1
B1
A2
pid 0: exited
B2
pid 1: exited
all processes exited

This is another large chapter. It introduces a heap allocator, a process table, a scheduler, and restructures the boot sequence.

FileStatus
src/memory/heap.rsnew — linked-list global allocator
src/memory/mod.rsadd alloc_frames, map_stack, heap module
src/sched/mod.rsnew — scheduler run loop
src/sched/process.rsnew — process table, spawn_with_args, state machine
src/sched/syscall.rsnew — syscall dispatch
src/demo.rsnew — spawns two user programs
src/boot.rsrestructure: heap init, kernel stack, return stack top
src/main.rsextern crate alloc, _switch_to_stack, new boot flow
src/trap/mod.rsminor: timer dispatch update
boot.Sadd _switch_to_stack

The process table stores processes in a Vec. Rust’s alloc crate provides Vec, but it needs a global allocator. The kernel provides one: a simple linked-list allocator backed by physical frames mapped at a fixed virtual address.

Create src/memory/heap.rs:

use super::{PAGE_SIZE, alloc_frames};
use crate::paging;
use core::alloc::{GlobalAlloc, Layout};
use core::ptr::{self, NonNull};
use spin::Mutex;
const HEAP_BASE: usize = 0xFFFF_FFC0_0000_0000;
const INIT_ORDER: usize = 8; // 2^8 = 256 pages = 1 MiB
struct FreeBlock {
size: usize,
next: Option<NonNull<FreeBlock>>,
}
struct LinkedListAllocator {
head: Option<NonNull<FreeBlock>>,
heap_end: usize,
}
unsafe impl Send for LinkedListAllocator {}
impl LinkedListAllocator {
const fn new() -> Self {
Self {
head: None,
heap_end: 0,
}
}
fn init(&mut self, start: usize, size: usize) {
let block = start as *mut FreeBlock;
unsafe { block.write(FreeBlock { size, next: None }) };
self.head = NonNull::new(block);
self.heap_end = start + size;
}
fn adjusted_layout(layout: &Layout) -> (usize, usize) {
let align = layout.align().max(core::mem::align_of::<FreeBlock>());
let size = layout
.size()
.max(core::mem::size_of::<FreeBlock>())
.next_multiple_of(align);
(size, align)
}
fn alloc(&mut self, layout: Layout) -> *mut u8 {
let (size, align) = Self::adjusted_layout(&layout);
let mut prev: Option<NonNull<FreeBlock>> = None;
let mut current = self.head;
while let Some(block_ptr) = current {
let block = block_ptr.as_ptr();
let block_addr = block as usize;
let (block_size, block_next) =
unsafe { ((*block).size, (*block).next) };
let aligned_addr = block_addr.next_multiple_of(align);
let total_size = (aligned_addr - block_addr) + size;
if block_size >= total_size {
let remaining = block_size - total_size;
let next = if remaining >= core::mem::size_of::<FreeBlock>() {
let new_block = (aligned_addr + size) as *mut FreeBlock;
unsafe {
new_block.write(FreeBlock {
size: remaining,
next: block_next,
})
};
NonNull::new(new_block)
} else {
block_next
};
match prev {
Some(mut p) => unsafe { p.as_mut().next = next },
None => self.head = next,
}
return aligned_addr as *mut u8;
}
prev = current;
current = block_next;
}
ptr::null_mut()
}
fn dealloc(&mut self, ptr: *mut u8, layout: Layout) {
let (size, _) = Self::adjusted_layout(&layout);
let block = ptr as *mut FreeBlock;
unsafe {
block.write(FreeBlock {
size,
next: self.head,
})
};
self.head = NonNull::new(block);
}
}
struct LockedHeap(Mutex<LinkedListAllocator>);
impl LockedHeap {
const fn new() -> Self {
Self(Mutex::new(LinkedListAllocator::new()))
}
}
unsafe impl GlobalAlloc for LockedHeap {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
self.0.lock().alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
self.0.lock().dealloc(ptr, layout);
}
}
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::new();
pub fn init() {
let pages = 1 << INIT_ORDER;
let pa = alloc_frames(INIT_ORDER).expect("out of memory for heap");
paging::with_kernel_pt_mut(|pt| {
pt.map_range(
HEAP_BASE,
pa,
pages * PAGE_SIZE,
paging::PTE_R | paging::PTE_W,
);
});
let size = pages * PAGE_SIZE;
ALLOCATOR.0.lock().init(HEAP_BASE, size);
println!("Heap: {:#x} ({} KiB)", HEAP_BASE, size / 1024);
}

The allocator is a first-fit free list. Each free block stores its size and a pointer to the next free block. Allocation walks the list until a large enough block is found, splits the remainder, and returns the aligned address. Deallocation adds the block back to the front of the list.

HEAP_BASE is 0xFFFF_FFC0_0000_0000 — the start of the kernel’s high virtual address range. init allocates 256 physical frames (1 MiB) from the buddy allocator, maps them at HEAP_BASE in the kernel page table, and initializes the free list.

#[global_allocator] tells Rust to use this allocator for all alloc crate operations (Vec, Box, etc.).

The heap and kernel stacks need multi-frame allocation and a stack mapping helper. Keep the alloc_zeroed_frame helper from Chapter 5; the complete listing below includes it for context. Update src/memory/mod.rs:

pub mod frame_allocator;
pub mod heap;
use frame_allocator::BuddyAllocator;
use spin::{Mutex, Once};
use crate::paging::{PTE_R, PTE_W, PageTable};
pub use frame_allocator::{PAGE_SIZE, Region};
static MEMORY: Once<Mutex<&'static mut BuddyAllocator>> = Once::new();
static REGIONS: Once<&'static [Region]> = Once::new();
pub fn init(addr: usize) {
let ptr = addr as *mut BuddyAllocator;
unsafe {
ptr.write(BuddyAllocator::new());
MEMORY.call_once(|| Mutex::new(&mut *ptr));
}
}
pub fn lock() -> spin::MutexGuard<'static, &'static mut BuddyAllocator> {
MEMORY.get().expect("memory not initialized").lock()
}
/// Allocate 2^order contiguous frames. Returns the physical address.
pub fn alloc_frames(order: usize) -> Option<usize> {
lock().alloc(order)
}
pub fn alloc_frame() -> Option<usize> {
alloc_frames(0)
}
/// Allocate a single physical frame and clear it before use.
pub fn alloc_zeroed_frame() -> Option<usize> {
let pa = alloc_frame()?;
unsafe { core::ptr::write_bytes(pa as *mut u8, 0, PAGE_SIZE) };
Some(pa)
}
/// Map a stack with one unmapped guard page below it.
///
/// `slot` selects a non-overlapping stack region under `va_base`; the first
/// page in that region is left unmapped, and `pages` writable pages follow it.
/// Returns the stack top.
pub fn map_stack(
pt: &mut PageTable,
va_base: usize,
slot: usize,
pages: usize,
extra_flags: usize,
) -> usize {
let region_size = (pages + 1) * PAGE_SIZE;
let stack_base = va_base + slot * region_size + PAGE_SIZE;
for page in 0..pages {
let va = stack_base + page * PAGE_SIZE;
let pa = alloc_zeroed_frame().expect("out of memory for stack");
pt.map_at(va, pa, PTE_R | PTE_W | extra_flags, 0);
}
stack_base + pages * PAGE_SIZE
}
pub fn freeze_regions() {
let mem = lock();
let ptr = mem.regions_ptr() as *const Region;
let len = mem.regions().len();
REGIONS.call_once(|| unsafe { core::slice::from_raw_parts(ptr, len) });
}
pub fn regions() -> &'static [Region] {
REGIONS.get().expect("regions not frozen")
}

alloc_frames(order) allocates 2^order contiguous frames — the heap init uses it for the initial 256-page block.

map_stack allocates pages physical frames and maps them in a page table starting at a computed virtual address. The first page in each slot is left unmapped as a guard page — accessing it causes a page fault instead of silent corruption. The slot parameter lets multiple stacks coexist without overlapping. extra_flags is PTE_U for user stacks and 0 for kernel stacks.

The kernel needs a properly allocated kernel stack before running the scheduler. boot::init now returns a stack top, and kernel_main switches to it.

Update boot.S — add _switch_to_stack after the existing code:

# Switch to a new stack and tail-call an entry point.
# a0 = stack_top, a1 = entry fn, a2 = first arg
.section .text
.globl _switch_to_stack
_switch_to_stack:
mv sp, a0
mv a0, a2
jr a1

This is a one-shot trampoline: it sets sp to the new stack, moves the argument to a0, and jumps to the entry function. It never returns.

Update src/boot.rs:

use crate::utils::symbols::*;
use fdt::Fdt;
const KSTACK_PAGES: usize = 4;
const KSTACK_VA_BASE: usize = 0xFFFF_FFD0_0000_0000;
pub fn init(hartid: usize, dtb_ptr: usize) -> usize {
let _ = hartid;
println!("jikei: booted under OpenSBI");
print_kernel_info();
let fdt = unsafe { Fdt::from_ptr(dtb_ptr as *const u8) }
.unwrap_or_else(|e| panic!("failed to parse DTB: {e}"));
let cpu_count = fdt.cpus().count();
println!("CPUs: {}", cpu_count);
crate::memory::init(kernel_end());
{
let mut mem = crate::memory::lock();
fdt.memory()
.regions()
.filter_map(|r| {
r.size
.filter(|&s| s > 0)
.map(|s| (r.starting_address as usize, s))
})
.for_each(|(start, size)| {
println!(
"RAM: {:#x} - {:#x} ({} MB)",
start,
start + size,
size >> 20
);
mem.add_region(start, start + size);
});
let alloc_end = mem.end_ptr();
let dtb_end = dtb_ptr + fdt.total_size();
mem.init(&[(text_start(), alloc_end), (dtb_ptr, dtb_end)]);
println!(
"Memory: {} free frames ({} MB), {} total",
mem.free_count,
(mem.free_count * 4096) >> 20,
mem.num_frames,
);
}
crate::memory::freeze_regions();
crate::paging::init();
crate::memory::heap::init();
alloc_kernel_stack(0)
}
fn alloc_kernel_stack(hart_index: usize) -> usize {
crate::paging::with_kernel_pt_mut(|pt| {
crate::memory::map_stack(pt, KSTACK_VA_BASE, hart_index, KSTACK_PAGES, 0)
})
}
fn print_kernel_info() {
let kb = |a: usize, b: usize| (b - a) / 1024;
println!(
"Kernel: {:#x} - {:#x} ({} KB)",
text_start(),
bss_end(),
kb(text_start(), bss_end()),
);
println!(
" .text: {} KB, .rodata: {} KB, .data: {} KB, .bss: {} KB",
kb(text_start(), text_end()),
kb(rodata_start(), rodata_end()),
kb(rodata_end(), data_end()),
kb(data_end(), bss_end()),
);
}

boot::init now returns a usize — the top of the freshly allocated kernel stack. The boot sequence adds three new steps after paging::init:

  1. heap::init — allocates 1 MiB of physical frames and maps them at HEAP_BASE. After this, Vec and Box work.
  2. alloc_kernel_stack(0) — allocates 4 pages at KSTACK_VA_BASE with a guard page, mapped in the kernel page table. Returns the stack top.

The kernel stack lives at a high virtual address (0xFFFF_FFD0_...), separate from the identity-mapped RAM. This means the guard page below it is truly unmapped — a stack overflow faults immediately.

Create src/sched/process.rs:

use crate::memory::{PAGE_SIZE, alloc_frame, alloc_zeroed_frame};
use crate::paging::*;
use crate::trap::TrapFrame;
use alloc::vec::Vec;
use spin::Mutex;
const CODE_VA: usize = 0x10000;
const USER_STACK_BASE: usize = 0x0100_0000;
const USER_STACK_PAGES: usize = 4;
enum State {
Ready,
Running(usize),
Exited,
}
struct Process {
state: State,
tf: *mut TrapFrame,
satp: usize,
tf_va: usize,
}
unsafe impl Send for Process {}
#[derive(Clone, Copy)]
pub(crate) struct Runnable {
pub pid: usize,
pub tf: *mut TrapFrame,
pub satp: usize,
pub tf_va: usize,
}
pub(crate) enum Next {
Runnable(Runnable),
Done,
Empty,
}
static TABLE: Mutex<ProcessTable> = Mutex::new(ProcessTable::new());
/// Allocate address space, copy code in, and register a new process.
pub(crate) fn spawn_with_args(code: &[u8], _args: [usize; 4]) -> usize {
assert!(code.len() <= PAGE_SIZE, "user program too large");
// Code page is zeroed: bytes past `code.len()` decode to a deterministic
// illegal instruction instead of stale frame contents.
let code_pa = alloc_zeroed_frame().expect("oom");
let tf_pa = alloc_frame().expect("oom");
unsafe {
core::ptr::copy_nonoverlapping(
code.as_ptr(),
code_pa as *mut u8,
code.len(),
);
}
let tf_va: usize = TRAMPOLINE - PAGE_SIZE;
let pt = PageTable::alloc();
pt.map_at(CODE_VA, code_pa, PTE_U | PTE_R | PTE_X, 0);
let stack_top = crate::memory::map_stack(
pt,
USER_STACK_BASE,
0,
USER_STACK_PAGES,
PTE_U,
);
pt.map_trampoline();
pt.map_at(tf_va, tf_pa, PTE_R | PTE_W, 0);
let tf_ptr = tf_pa as *mut TrapFrame;
let tf = TrapFrame::new(CODE_VA, stack_top);
unsafe { tf_ptr.write(tf) };
let pid = TABLE.lock().add(tf_ptr, pt.satp(), tf_va);
println!("spawned pid {}", pid);
pid
}
pub(crate) fn pick_next(hartid: usize) -> Next {
let mut table = TABLE.lock();
table
.pick_next(hartid)
.map(Next::Runnable)
.or_else(|| table.all_exited().then_some(Next::Done))
.unwrap_or(Next::Empty)
}
pub(crate) fn ready(pid: usize) {
TABLE.lock().ready(pid);
}
pub(crate) fn exit(pid: usize) {
TABLE.lock().exit(pid);
}
impl Process {
fn new(tf: *mut TrapFrame, satp: usize, tf_va: usize) -> Self {
Self {
state: State::Ready,
tf,
satp,
tf_va,
}
}
fn to_runnable(&self, pid: usize) -> Runnable {
Runnable {
pid,
tf: self.tf,
satp: self.satp,
tf_va: self.tf_va,
}
}
}
struct ProcessTable {
processes: Vec<Process>,
cursor: usize,
}
impl ProcessTable {
const fn new() -> Self {
Self {
processes: Vec::new(),
cursor: 0,
}
}
fn add(&mut self, tf: *mut TrapFrame, satp: usize, tf_va: usize) -> usize {
let pid = self.processes.len();
self.processes.push(Process::new(tf, satp, tf_va));
pid
}
fn pick_next(&mut self, hartid: usize) -> Option<Runnable> {
let len = self.processes.len();
(0..len).find_map(|_| {
let idx = self.cursor % len;
self.cursor = (idx + 1) % len;
let proc = &mut self.processes[idx];
if matches!(proc.state, State::Ready) {
proc.state = State::Running(hartid);
Some(proc.to_runnable(idx))
} else {
None
}
})
}
fn all_exited(&self) -> bool {
!self.processes.is_empty()
&& self
.processes
.iter()
.all(|proc| matches!(proc.state, State::Exited))
}
fn ready(&mut self, pid: usize) {
self.processes[pid].state = State::Ready;
}
fn exit(&mut self, pid: usize) {
self.processes[pid].state = State::Exited;
}
}

A process is four things: a page table (satp), a trap frame (physical and virtual address), and a lifecycle state. spawn_with_args builds a complete address space:

  1. Allocate a code frame and copy the program into it.
  2. Create a page table with: user code at 0x10000 (R+X+U), a 4-page stack at 0x01000000 (R+W+U) with a guard page, the trampoline, and a kernel-only trap frame page just below TRAMPOLINE.
  3. Initialize the trap frame with the entry point and stack top.
  4. Add the process to the table as Ready.

pick_next does round-robin: starting from cursor, scan for the first Ready process, mark it Running, and return a Runnable with the pointers needed by enter_usermode. The cursor advances so the same process is not picked twice in a row when multiple are ready.

Process contains a raw *mut TrapFrame. This is safe because each trap frame lives in a dedicated physical frame for the lifetime of the process. unsafe impl Send is required because raw pointers are not Send by default.

Create src/sched/syscall.rs:

use crate::trap::TrapFrame;
use super::process;
const SYS_EXIT: usize = 1;
const SYS_YIELD: usize = 3;
const SYS_PUTCHAR: usize = 100;
/// Handle a syscall. Returns true if the process should be re-entered
/// immediately (fast path), false if the scheduler should pick next.
pub(super) fn handle(pid: usize, tf: &mut TrapFrame) -> bool {
match tf.a7() {
SYS_EXIT => {
println!("pid {}: exited", pid);
process::exit(pid);
false
}
SYS_YIELD => {
process::ready(pid);
false
}
SYS_PUTCHAR => {
print!("{}", tf.a0() as u8 as char);
true
}
nr => {
println!("pid {}: unknown syscall {nr}", pid);
process::exit(pid);
false
}
}
}

Three syscalls:

  • SYS_EXIT (1) — marks the process as exited. Returns to the scheduler.
  • SYS_YIELD (3) — puts the process back in the ready queue. The scheduler picks the next one.
  • SYS_PUTCHAR (100) — prints one character and re-enters the process immediately.

The bool return controls whether the process keeps running. Putchar returns true so the process can print a full line without being interrupted by the scheduler. Yield and exit return false to trigger a context switch.

Unknown syscalls kill the process.

Create src/sched/mod.rs:

pub mod process;
mod syscall;
use crate::trap::{self, TrapCause};
use process::{Next, Runnable};
pub fn run(hartid: usize) -> ! {
loop {
match process::pick_next(hartid) {
Next::Runnable(proc) => run_process(proc),
Next::Done => {
println!("all processes exited");
loop {
unsafe { core::arch::asm!("wfi") }
}
}
Next::Empty => {
unsafe { core::arch::asm!("wfi") };
}
}
}
}
fn run_process(proc: Runnable) {
loop {
let cause = enter_process(proc);
if !handle_trap(proc, cause) {
return;
}
}
}
fn enter_process(proc: Runnable) -> TrapCause {
trap::enter_usermode(unsafe { &mut *proc.tf }, proc.satp, proc.tf_va)
}
/// Returns true if the process should be re-entered immediately.
fn handle_trap(proc: Runnable, cause: TrapCause) -> bool {
match cause {
TrapCause::Syscall => {
syscall::handle(proc.pid, unsafe { &mut *proc.tf })
}
TrapCause::TimerInterrupt => {
crate::utils::sbi::set_timer(u64::MAX);
process::ready(proc.pid);
false
}
TrapCause::Exception {
scause,
stval,
sepc,
} => {
println!(
"pid {}: exception scause={scause:#x} stval={stval:#x} sepc={sepc:#x}",
proc.pid
);
process::exit(proc.pid);
false
}
}
}

The outer loop picks a ready process. The inner run_process loop keeps re-entering the same process as long as its syscall handler returns true (e.g., putchar). When the process yields or exits, handle_trap returns false and control goes back to pick_next.

This means a process can print a full line of output without being interrupted by the scheduler. Context switches happen at explicit yield points — that is what makes this a cooperative scheduler.

handle_trap dispatches on the cause:

  • Syscall — passed to syscall::handle, which returns whether to re-enter.
  • Timer interrupt — clear the timer and put the process back in the ready queue. (There is no preemption timer yet — timer interrupts only arrive if something set one.)
  • Exception — print a diagnostic and kill the process.

Create src/demo.rs:

use crate::sched::process;
pub fn spawn() {
process::spawn_with_args(prog_bytes(_prog_a_start, _prog_a_end), [0; 4]);
process::spawn_with_args(prog_bytes(_prog_b_start, _prog_b_end), [0; 4]);
}
fn prog_bytes(
start: unsafe extern "C" fn() -> u8,
end: unsafe extern "C" fn() -> u8,
) -> &'static [u8] {
let s = start as usize;
let len = end as usize - s;
unsafe { core::slice::from_raw_parts(s as *const u8, len) }
}
unsafe extern "C" {
fn _prog_a_start() -> u8;
fn _prog_a_end() -> u8;
fn _prog_b_start() -> u8;
fn _prog_b_end() -> u8;
}
core::arch::global_asm!(
r#"
.pushsection .rodata.user_prog, "a"
.globl _prog_a_start
_prog_a_start:
li a7, 100
li a0, 'A'
ecall
li a7, 100
li a0, '1'
ecall
li a7, 100
li a0, '\n'
ecall
li a7, 3
ecall
li a7, 100
li a0, 'A'
ecall
li a7, 100
li a0, '2'
ecall
li a7, 100
li a0, '\n'
ecall
li a7, 1
ecall
.globl _prog_a_end
_prog_a_end:
.globl _prog_b_start
_prog_b_start:
li a7, 100
li a0, 'B'
ecall
li a7, 100
li a0, '1'
ecall
li a7, 100
li a0, '\n'
ecall
li a7, 3
ecall
li a7, 100
li a0, 'B'
ecall
li a7, 100
li a0, '2'
ecall
li a7, 100
li a0, '\n'
ecall
li a7, 1
ecall
.globl _prog_b_end
_prog_b_end:
.popsection
"#
);

Two user programs, embedded as raw bytes in .rodata. Each prints its letter + number, yields, prints again, and exits.

prog_bytes converts a pair of linker symbols into a byte slice — the same technique from Chapter 5 but generalized for multiple programs.

Replace src/main.rs:

#![no_std]
#![no_main]
extern crate alloc;
#[macro_use]
mod utils;
mod boot;
mod demo;
mod memory;
mod paging;
mod sched;
mod trap;
core::arch::global_asm!(include_str!("../boot.S"));
#[unsafe(no_mangle)]
pub extern "C" fn kernel_main(hartid: usize, dtb_ptr: usize) -> ! {
let stack_top = boot::init(hartid, dtb_ptr);
unsafe { _switch_to_stack(stack_top, kernel_main_on_stack, hartid) }
}
unsafe extern "C" {
fn _switch_to_stack(
stack_top: usize,
entry: extern "C" fn(usize) -> !,
arg0: usize,
) -> !;
}
extern "C" fn kernel_main_on_stack(hartid: usize) -> ! {
trap::init_hart();
demo::spawn();
sched::run(hartid)
}

The boot flow is now two stages:

  1. kernel_main runs on the static boot stack. It calls boot::init which sets up memory, paging, the heap, and allocates a kernel stack. It returns the stack top.
  2. _switch_to_stack moves to the allocated kernel stack and calls kernel_main_on_stack, which never returns.
  3. kernel_main_on_stack sets up traps, spawns the demo processes, and enters the scheduler.

extern crate alloc makes Vec, Box, and other heap types available throughout the crate.

In src/trap/mod.rs, update handle_kernel_trap to dispatch timer interrupts through the scheduler:

#[unsafe(no_mangle)]
extern "C" fn handle_kernel_trap(sepc: usize, scause: usize, stval: usize) {
if scause & INTERRUPT_BIT != 0 {
match scause & !INTERRUPT_BIT {
SUPERVISOR_TIMER_INTERRUPT => {
crate::utils::sbi::set_timer(u64::MAX);
}
cause => panic!(
"unhandled kernel interrupt: cause={cause}, sepc={sepc:#x}"
),
}
} else {
panic!(
"kernel exception: scause={scause:#x}, stval={stval:#x}, sepc={sepc:#x}"
);
}
}

This is unchanged from Chapter 5 — timer interrupts during kernel execution just clear the timer. The scheduler handles timer interrupts that occur during user execution through TrapCause::TimerInterrupt in handle_trap.

Terminal window
cargo run --release
jikei: booted under OpenSBI
Kernel: 0x80200000 - ...
...
Paging enabled
Heap: 0xffffffc000000000 (1024 KiB)
spawned pid 0
spawned pid 1
A1
B1
A2
B2
pid 0: exited
pid 1: exited
all processes exited

The two processes interleave through cooperative yielding:

  1. Scheduler picks pid 0 (A). A prints “A1\n” (putchar re-enters immediately), then yields.
  2. Scheduler picks pid 1 (B). B prints “B1\n”, then yields.
  3. Scheduler picks pid 0 (A). A prints “A2\n”, then exits.
  4. Scheduler picks pid 1 (B). B prints “B2\n”, then exits.
  5. All exited — halt.
OpenSBI
-> boot::init
-> memory, paging, heap
-> alloc kernel stack
-> _switch_to_stack
-> kernel_main_on_stack
-> trap::init_hart
-> demo::spawn (pid 0, pid 1)
-> sched::run
-> pick_next -> enter_usermode -> handle_trap -> repeat
-> all exited -> halt

The kernel manages multiple isolated processes. Each has its own page table, trap frame, and lifecycle state. The spawn_with_args function builds a complete address space from a code blob. The scheduler round-robins between ready processes.

The scheduling is cooperative — processes must yield or exit. A process that loops without calling ecall holds the CPU indefinitely. The next chapter fixes this with timer-driven preemption.

Chapter 7 adds timer interrupts for preemptive scheduling, sleep support, and SMP — multiple harts running processes in parallel.