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 0spawned pid 1A1B1A2pid 0: exitedB2pid 1: exitedall processes exitedWhat Changes
Section titled “What Changes”This is another large chapter. It introduces a heap allocator, a process table, a scheduler, and restructures the boot sequence.
| File | Status |
|---|---|
src/memory/heap.rs | new — linked-list global allocator |
src/memory/mod.rs | add alloc_frames, map_stack, heap module |
src/sched/mod.rs | new — scheduler run loop |
src/sched/process.rs | new — process table, spawn_with_args, state machine |
src/sched/syscall.rs | new — syscall dispatch |
src/demo.rs | new — spawns two user programs |
src/boot.rs | restructure: heap init, kernel stack, return stack top |
src/main.rs | extern crate alloc, _switch_to_stack, new boot flow |
src/trap/mod.rs | minor: timer dispatch update |
boot.S | add _switch_to_stack |
The Heap Allocator
Section titled “The Heap Allocator”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.).
Update the Memory Module
Section titled “Update the Memory Module”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.
Restructure the Boot Sequence
Section titled “Restructure the Boot Sequence”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 a1This 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:
heap::init— allocates 1 MiB of physical frames and maps them atHEAP_BASE. After this,VecandBoxwork.alloc_kernel_stack(0)— allocates 4 pages atKSTACK_VA_BASEwith 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.
The Process Table
Section titled “The Process Table”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:
- Allocate a code frame and copy the program into it.
- Create a page table with: user code at
0x10000(R+X+U), a 4-page stack at0x01000000(R+W+U) with a guard page, the trampoline, and a kernel-only trap frame page just belowTRAMPOLINE. - Initialize the trap frame with the entry point and stack top.
- 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.
Syscall Dispatch
Section titled “Syscall Dispatch”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.
The Scheduler
Section titled “The Scheduler”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.
The Demo
Section titled “The Demo”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.
Update main.rs
Section titled “Update main.rs”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:
kernel_mainruns on the static boot stack. It callsboot::initwhich sets up memory, paging, the heap, and allocates a kernel stack. It returns the stack top._switch_to_stackmoves to the allocated kernel stack and callskernel_main_on_stack, which never returns.kernel_main_on_stacksets up traps, spawns the demo processes, and enters the scheduler.
extern crate alloc makes Vec, Box, and other heap types available throughout the crate.
Update the Kernel Trap Handler
Section titled “Update the Kernel Trap Handler”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.
Run It
Section titled “Run It”cargo run --releasejikei: booted under OpenSBIKernel: 0x80200000 - ... ...Paging enabledHeap: 0xffffffc000000000 (1024 KiB)spawned pid 0spawned pid 1A1B1A2B2pid 0: exitedpid 1: exitedall processes exitedThe two processes interleave through cooperative yielding:
- Scheduler picks pid 0 (A). A prints “A1\n” (putchar re-enters immediately), then yields.
- Scheduler picks pid 1 (B). B prints “B1\n”, then yields.
- Scheduler picks pid 0 (A). A prints “A2\n”, then exits.
- Scheduler picks pid 1 (B). B prints “B2\n”, then exits.
- All exited — halt.
Checkpoint
Section titled “Checkpoint”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 -> haltThe 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.
What Comes Next
Section titled “What Comes Next”Chapter 7 adds timer interrupts for preemptive scheduling, sleep support, and SMP — multiple harts running processes in parallel.