🖥️

Building an Operating System Kernel

Core Fundamentals — Memory, Processes, Context Switching & Scheduling, From Scratch

Topics covered:
Physical & virtual memory, page tables & page faults
Process Control Blocks & a real state machine
Context switching, interrupts & a real syscall boundary
Cooperative, preemptive & priority scheduling with aging

Capstone: every mechanism wired into one working multi-process kernel
Exercises: hands-on exercises with worked, verified solutions
Format: A4 · Dark-theme code examples
Philip Osztromok · Generated with Claude

Table of Contents

  1. Why Build a Kernel? Processes, Memory & the Illusion of Multitasking
  2. Physical Memory: A Byte Array and a Free-List Allocator
  3. Virtual Memory: Address Translation & Page Tables
  4. Processes & the Process Control Block
  5. Context Switching: Saving & Restoring Execution State
  6. Interrupts & System Calls: The Boundary Between User and Kernel
  7. Cooperative Scheduling: A Simple Round-Robin Scheduler
  8. Preemptive Scheduling & Timer Interrupts
  9. Priority Scheduling & the Starvation Problem
  10. Capstone — A Working Multi-Process Kernel
Chapter 1 of 10

Why Build a Kernel? Processes, Memory & the Illusion of Multitasking

Building an Operating System Kernel: Core Fundamentals

Chapter 1 · Why Build a Kernel? Processes, Memory & the Illusion of Multitasking

A CPU can only ever do one thing at a time. Every program running on your machine right now — dozens of them, easily — is taking turns on a small number of cores, switched between so quickly it looks simultaneous. That illusion doesn't happen on its own. Something has to decide whose turn it is, protect one program's own memory from another's, and step in the instant a program tries to do something it shouldn't. That something is a kernel, and this two-course project builds a real, working one from the ground up.

Scope note — read this before anything else
This project is a real, verified Python simulation of genuine kernel mechanisms — physical memory as an actual byte array, a real page-table structure, real simulated CPU-state save/restore, a real scheduler — not literal bare-metal programming or x86 assembly. Every claim in every chapter is backed by real, hand-run code, the same discipline this site's own compiler1/compiler2, browserengine1/browserengine2, and dbengine1/dbengine2 projects already used. Deliberately out of scope for both courses: real hardware or assembly, multi-core/SMP scheduling, real device drivers for actual physical hardware (simulated devices only), and any networking stack.

The Pipeline Both Courses Build

StageWhat it doesBuilt in
Physical memoryA real byte array standing in for RAM, with a real allocator managing free and used spaceChapter 2
Virtual memoryA real page table translating each process's own private address space onto physical memoryChapter 3
ProcessesA real Process Control Block — the record a kernel keeps of everything one running program needsChapter 4
Context switchingReal, verified save-and-restore of one process's own execution state, so pausing it and resuming it later is genuinely safeChapter 5
Interrupts & syscallsThe real, controlled boundary between a user program and the kernel itselfChapter 6
SchedulingReal, working schedulers — cooperative, then preemptive, then priority-based — deciding whose turn it isChapters 7–9

Finding 1: Running Two Programs "One After Another" Is Not Multitasking

Two simple programs, each printing its own progress through a short loop, run the most obvious way: fully finish the first, then start the second.

def program_a(): total = 0 for i in range(4): print(f"A: step {i}") total += i yield total # a point where the CPU COULD switch away -- but nothing forces it to for _ in program_a(): pass for _ in program_b(): pass
Verified directly — zero interleaving, confirmed by real line position
Every single one of program A's own printed steps appears before program B's very first step. This is real, correct, sequential execution — and it is not multitasking in any sense. It's one program, then the next, exactly as written.

Finding 2: A Program That Never Yields Can Starve Everything Else, Forever

A program with no cooperation point at all — no natural place to pause — run before a second, ordinary program.

Verified directly — the second program gets exactly zero steps until the first one finishes
program_hog runs 500,000 real steps to completion, and program_starved gets 0 steps of its own in that entire time — not because it's slow, but because nothing exists yet to interrupt the hog and give it a turn. This test bounds the hog at 500,000 steps only so the test itself can finish; a real infinite loop in the hog's place would make the starved program wait forever, not just a long time. This is the exact problem a kernel's scheduler exists to prevent — and why Chapter 8 builds a real timer interrupt that forces control away from a running program whether it cooperates or not.

Finding 3: Manual Interleaving — A Real Preview of the Illusion

Instead of running each program to completion, alternate between them by hand — one step from A, one step from B, repeat.

gen_a, gen_b = program_a(), program_b() while not done: try: next(gen_a) except StopIteration: pass try: next(gen_b) except StopIteration: done = True
Verified directly — a real, confirmed interleaved execution order
The real printed output alternates cleanly: A B A B A B A B — A's own step 0, then B's step 0, then A's step 1, and so on. Neither program's own internal state — its own running total, its own position in its own loop — was lost or corrupted by being paused and resumed four separate times. Python's own generator machinery is quietly doing something a real kernel has to do explicitly, by hand: saving and restoring a program's own execution state every single time it's paused. This hand-driven alternation is exactly what Chapters 7–9 turn into a real, automatic scheduler, and what Chapter 5 formalizes as context switching.

Where This Connects

This chapter's findingWhat it connects to
A non-yielding program starving everything elseChapter 8 — Preemptive Scheduling & Timer Interrupts, the real fix that forces control away regardless of cooperation
Manual interleaving preserving each program's own stateChapter 5 — Context Switching, which formalizes exactly this save-and-restore discipline for a real simulated CPU
This site's own real-verification disciplinecompiler1/compiler2, browserengine1/browserengine2, dbengine1/dbengine2 — every claim in this course is verified with real, hand-run Python before being written, not asserted from theory alone

Hands-On Exercises

Exercise 1

Extend Finding 3's own manual interleaving to three programs instead of two, each with a different number of steps handled gracefully as programs finish at different times. Confirm the real output shows a clean, repeating A B C A B C ... pattern.

📄 View solution
Exercise 2

Build two programs — one whose steps are nearly instant, one where a single step does a real, substantial amount of work — and give both the same number of "turns" in a manual round-robin. Measure the real time each one actually consumes, and explain why counting turns isn't the same as sharing CPU time fairly.

📄 View solution
Exercise 3

Make one of two interleaved programs raise a real exception partway through, simulating a crash, while the other keeps running normally. Confirm the surviving program completes every one of its own steps untouched, and explain what would happen instead if the crash's own exception were allowed to propagate up through the shared interleaving loop.

📄 View solution

Chapter 1 Quick Reference

  • This project's own honest scope: a real, verified Python simulation of genuine kernel mechanisms — no bare-metal/assembly, no SMP, no real hardware drivers, no networking
  • Verified Finding 1: running two programs sequentially produces zero interleaving — that's not multitasking, just one program then the next
  • Verified Finding 2: a program with no cooperation point can starve every other program on the same CPU completely — forever, without a mechanism forcing control away
  • Verified Finding 3: manually alternating between programs produces a real interleaved execution order with each program's own state preserved — the real preview of both scheduling and context switching
  • The pipeline: physical memory → virtual memory → processes → context switching → interrupts/syscalls → scheduling
  • Next chapter: Physical Memory — a real byte array and a free-list allocator
Chapter 2 of 10

Physical Memory: A Byte Array and a Free-List Allocator

Building an Operating System Kernel: Core Fundamentals

Chapter 2 · Physical Memory: A Byte Array and a Free-List Allocator

Every program a kernel runs needs somewhere real to live — actual bytes it can read and write. This chapter builds the very bottom of the whole project: a real block of simulated physical memory, and a real allocator that hands out and reclaims regions of it, tracked with nothing more than a list of where the free space currently is.

A Real Byte Array, a Real Free List

class PhysicalMemory: def __init__(self, size): self.data = bytearray(size) # the actual simulated RAM self.free_list = [(0, size)] # [(start, size), ...] -- real, contiguous free regions def alloc_first_fit(self, size): for i, (start, block_size) in enumerate(self.free_list): if block_size >= size: if block_size == size: del self.free_list[i] else: self.free_list[i] = (start + size, block_size - size) return start return None # no single block big enough

Finding 1: This Is Real, Writable, Readable Memory

Verified directly — bytes written to an allocated region genuinely persist
Sixteen bytes allocated, then the real string b'HELLO, KERNEL!!!' written directly into mem.data[addr:addr+16]. Reading the same range back afterward returns exactly those bytes. The free list is only bookkeeping — where memory is free or used. The memory itself is a real, mutable byte array the whole time.

Finding 2: Real External Fragmentation

Ten 100-byte blocks allocated back to back, then every other one freed — a checkerboard pattern of small, scattered free regions.

Verified directly — 500 free bytes, and a 200-byte request still fails
total_free() reports 500 bytes — plenty, in total, for a 200-byte request. But largest_free_block() is only 100: [(0,100), (200,100), (400,100), (600,100), (800,100)]. A request for 200 bytes returns None — not because memory ran out, but because no single free region is large enough. This is real external fragmentation, not a bug in the allocator's own bookkeeping — it's the genuine cost of scattering many small blocks across memory.

Finding 3: A Real Bug — Freeing Doesn't Merge Adjacent Blocks

Three 100-byte blocks allocated in sequence — A at address 0, B at address 100, C at address 200. A and B are physically touching: byte 99 and byte 100 are adjacent addresses in the same real memory.

Verified directly — a 200-byte request fails even though a real 200-byte contiguous region exists
Freeing A, then B, with a naive free_naive() that just appends to the free list: [(0,100), (100,100)] — two separate entries. A request for 200 bytes (exactly A and B's own combined space) returns None. The bytes are genuinely, physically contiguous right now — but the free list never recognized that, and first-fit can't use two separate entries to satisfy one request.
def free_coalescing(self, start, size): self.free_list.append((start, size)) self.free_list.sort() merged = [] for block in self.free_list: if merged and merged[-1][0] + merged[-1][1] == block[0]: # physically adjacent? prev_start, prev_size = merged[-1] merged[-1] = (prev_start, prev_size + block[1]) # merge into ONE block else: merged.append(block) self.free_list = merged
The fix — merge with physically adjacent neighbors on every free
The identical scenario with free_coalescing(): the free list ends up as [(0, 200)] — one real, merged region. The 200-byte request now succeeds, returning address 0 — using the exact same physical bytes the naive version had access to the whole time, but couldn't recognize as contiguous.

Finding 4: First-Fit vs. Best-Fit — An Honest, Non-Cherry-Picked Comparison

Both strategies run against five separate, real randomized alloc/free workloads — 300 operations each, identical requests fed to both strategies within each run.

Verified directly — neither strategy is a consistent winner
First-fit produced fewer allocation failures in 3 of 5 runs; best-fit in the other 2 of 5. Both are real, correct allocators — but which one performs better on a given workload genuinely depends on the specific pattern of requests, not a fixed rule either strategy can claim in general. This chapter deliberately doesn't hand down a single blanket recommendation, because the real, measured results don't support one.

Where This Connects

This chapter's findingWhat it connects to
External fragmentation, even with coalescingChapter 3 — Virtual Memory, whose whole point is letting a process see one clean, contiguous address space regardless of how scattered its real physical pages are
A real, writable, readable simulated memoryChapter 4 — the Process Control Block, which will track exactly which regions of this same memory belong to which process
Coalescing free blocks by re-sorting and mergingBuilding a Database Engine's own B-tree indexes — a different data structure, the same underlying discipline of keeping a structure correctly merged/balanced after every mutation, not just after the ones that happen to need it

Hands-On Exercises

Exercise 1

Allocate three physically adjacent blocks, then free them using free_coalescing() in a different order than they were allocated (e.g., free the middle one first). Confirm the three blocks still correctly merge into one single region covering all of them, regardless of free order.

📄 View solution
Exercise 2

Request one byte more than a memory's own total size, and confirm it fails cleanly (returns None, no crash). Then request exactly the full size and confirm that succeeds.

📄 View solution
Exercise 3

Free two adjacent 100-byte blocks with coalescing, then request 150 bytes — a size neither original block could have satisfied on its own. Confirm the request succeeds, and that writing real bytes into the returned region and reading them back works correctly.

📄 View solution

Chapter 2 Quick Reference

  • Physical memory: a real byte array; the free list is bookkeeping over it, not the memory itself
  • Verified Finding 1: allocated regions are genuinely writable and readable — real memory, not just tracked addresses
  • Verified Finding 2: real external fragmentation — enough total free space, but no single block big enough
  • Verified Finding 3 (bug + fix): naive free() leaves physically adjacent blocks artificially separate — fixed by coalescing (merging) on every free
  • Verified Finding 4: first-fit and best-fit are both real, correct allocators — neither is a consistent winner across different real workloads
  • Next chapter: Virtual Memory — address translation and a real page table
Chapter 3 of 10

Virtual Memory: Address Translation & Page Tables

Building an Operating System Kernel: Core Fundamentals

Chapter 3 · Virtual Memory: Address Translation & Page Tables

Chapter 2's own allocator handed out physical addresses directly — a process had to know exactly where its own memory really lived. Virtual memory removes that: every process gets its own private address space, starting at 0, completely independent of where its bytes actually sit in real physical memory. A page table is the real, working translation between the two — and this chapter builds one that does more than bookkeeping: it resolves Chapter 2's own fragmentation problem outright, and finds a genuine, dangerous bug along the way.

A Real Page Table, Real Frames

Physical memory is now divided into fixed-size frames — every frame identical in size, so allocation never has to search for a big-enough contiguous run.

class PageTable: def __init__(self, page_size): self.page_size = page_size self.table = {} # vpn -> pfn def translate(self, vaddr): vpn = vaddr // self.page_size offset = vaddr % self.page_size if vpn not in self.table: raise PageFault(f"no mapping for virtual page {vpn}") pfn = self.table[vpn] return pfn * self.page_size + offset

Finding 1: Real Address Translation, Verified Against Raw Physical Memory

Verified directly — a genuinely different physical address, confirmed by a raw read
Virtual page 0 mapped to physical frame 1 (deliberately not frame 0, so the translation is genuinely non-trivial): translate(10) returns 74. Writing real bytes through that translated address, then reading the same bytes back directly from raw physical memory — bypassing the page table entirely — confirms they land exactly where the translation said they would.

Finding 2: Two Processes, the Same Virtual Address, No Collision

Verified directly — identical virtual addresses, completely isolated real memory
Two independent page tables both map virtual address 0 — process A to physical frame 0, process B to physical frame 1. Writing 'AAAAA' through A's own translation and 'BBBBB' through B's leaves both fully intact and correctly separated. This is the entire point of virtual memory: every process gets to believe it owns the whole address space, with the page table quietly making that a safe illusion.

Finding 3: Paging Resolves Chapter 2's Own Fragmentation Problem

The exact same checkerboard scenario from Chapter 2's own Finding 2 — ten pages allocated, then every other one freed — run against a real, framed page table instead of a contiguous-region allocator.

Verified directly — the identical scattered-free-space scenario, now succeeding completely
A new process requesting 5 pages, one at a time, against those same five scattered free frames: 5 of 5 succeed. Chapter 2's own equivalent request (a single 200-byte contiguous region) failed outright in this exact situation. Paging never needs a single large contiguous run of physical memory — only enough individual free frames, anywhere at all — because virtual pages don't need to be physically adjacent to be virtually contiguous. External fragmentation, as Chapter 2 defined it, is now structurally impossible.

Finding 4: A Real, Dangerous Bug — Writes That Cross a Page Boundary

Two consecutive virtual pages — VPN 0 and VPN 1 — mapped to two non-adjacent physical frames, 5 and 2. A completely ordinary outcome of paging. A 20-byte write starting 10 bytes before the end of page 0, spilling into page 1.

def write_naive(mem, page_table, vaddr, data): paddr = page_table.translate(vaddr) # translates only the STARTING address mem.data[paddr:paddr + len(data)] = data # assumes the WHOLE write is contiguous -- BUG
Verified directly — the last 10 bytes silently land in the wrong physical frame
The first 10 bytes correctly reach frame 5. The last 10 bytes — meant for frame 2, where virtual page 1 actually lives — instead land in frame 5's own next 10 bytes, since the naive write only translated the starting address and then wrote all 20 bytes as one contiguous bulk operation. Frame 2 stays completely untouched. Frame 5 silently holds 10 bytes of data that never belonged there at all — no error, no crash, just quietly wrong memory.
The fix — translate every page's own portion of the write separately
write_paged() walks the write in page-sized chunks, translating each one independently. The same 20-byte write now correctly splits: the first 10 bytes in frame 5, the last 10 in frame 2 — exactly where virtual pages 0 and 1 actually live, regardless of whether those physical frames happen to be adjacent.

Where This Connects

This chapter's findingWhat it connects to
Paging resolving external fragmentationChapter 2 (this course) — the exact scattered-free-space scenario that broke a contiguous allocator, resolved here by construction
Isolated address spaces for two processesChapter 4 — the Process Control Block, which will hold each process's own page table alongside its other state
A naive write silently corrupting data across a boundaryBuilding a Web Browser Engine: Layout & Rendering Chapter 8's own rasterizer bounds-clipping bug — a different domain, the same lesson that a boundary case silently succeeding with wrong data is worse than crashing outright

Hands-On Exercises

Exercise 1

Translate a virtual address whose page was never mapped. Confirm it raises a real, catchable PageFault rather than a raw KeyError or a silently wrong address, and confirm a genuinely mapped address right next to it still translates correctly.

📄 View solution
Exercise 2

Construct a write that spans three separate pages, not just two, mapped to three non-adjacent physical frames. Confirm write_paged() still splits it correctly across all three, and reconstruct the original data from the three real physical locations to prove it.

📄 View solution
Exercise 3

Write real data to a page, free its frame, then map that same physical frame into a different page table for a "new process." Read the freshly-mapped page before writing anything to it, and report honestly what you find.

📄 View solution

Chapter 3 Quick Reference

  • translate(vaddr): vpn = vaddr // page_size, offset = vaddr % page_size, look up vpn in the page table, return pfn * page_size + offset
  • Verified Finding 1: translation produces a genuinely different, correct physical address, confirmed against a raw read
  • Verified Finding 2: two processes can share identical virtual addresses with zero collision — the whole point of virtual memory
  • Verified Finding 3: paging resolves Chapter 2's own external fragmentation by construction — no contiguous run ever needed
  • Verified Finding 4: a naive multi-byte write silently corrupts data across a page boundary onto a non-adjacent frame — fixed by translating each page's own portion separately
  • Honest finding: reusing a physical frame without clearing it lets a new process read the old one's own leftover data — a real security concern, not fixed in this course
  • Next chapter: Processes & the Process Control Block
Chapter 4 of 10

Processes & the Process Control Block

Building an Operating System Kernel: Core Fundamentals

Chapter 4 · Processes & the Process Control Block

Every piece built so far — allocated memory, a page table — belongs to nobody in particular yet. A Process Control Block is what ties them to a real, identifiable process: a unique ID, a real state, and its own page table. This chapter builds a real one, and finds a genuine, classic bug the moment a process actually tries to exit.

A Real PCB and a Real State Machine

class PCB: _next_pid = 1 def __init__(self, page_table, owned_frames): self.pid = PCB._next_pid; PCB._next_pid += 1 self.state = ProcessState.NEW self.page_table = page_table self.owned_frames = list(owned_frames) # every real frame this process owns ALLOWED_TRANSITIONS = { ProcessState.NEW: {ProcessState.READY}, ProcessState.READY: {ProcessState.RUNNING}, ProcessState.RUNNING: {ProcessState.READY, ProcessState.BLOCKED, ProcessState.TERMINATED}, ProcessState.BLOCKED: {ProcessState.READY}, ProcessState.TERMINATED: set(), # a terminal state -- no way out }

Finding 1: A Real Process, With Real Memory

Verified directly — a genuine, working PCB, not just a label
Two processes created, requesting 3 and 2 pages: each gets a unique PID, its own real page table mapped onto real, disjoint physical frames, and state READY. Writing bytes through the first process's own translation and reading them back confirms the memory is genuinely usable, not just recorded.

Finding 2: A Genuine Memory Leak on Termination

Five processes, each requesting 2 pages from a 10-frame memory, created and terminated one after another.

def terminate_process_buggy(self, pid): pcb = self.processes[pid] pcb.transition(ProcessState.TERMINATED) del self.processes[pid] # BUG: pcb.owned_frames are never returned to the free pool!
Verified directly — free memory permanently shrinks from 10 to 0
Every one of the five processes correctly transitions to TERMINATED and is removed from the process table. But len(mem.free_frames) drops to 0 — a sixth process requesting memory, with nothing actually running anymore, gets None. This is a real, classic operating-system memory leak: exiting cleanly at the process level says nothing about whether the resources that process owned were ever actually reclaimed.
The fix — termination frees every frame the process owned
terminate_process_fixed() loops over pcb.owned_frames and calls free_frame() on each one before removing the process from the table. The identical five-create-five-terminate sequence now leaves free frames back at 10 — exactly where it started — and a sixth process creates successfully.

Finding 3: Illegal State Transitions Are Genuinely Rejected

Verified directly — three real invariants, all enforced
A terminated process attempting TERMINATED → READY: rejected. Terminating the same process a second time: a KeyError, since it's already gone from the process table. A fresh READY process attempting to jump straight to BLOCKED, skipping RUNNING entirely: rejected. None of these are special-cased checks scattered through the code — they all fall out of the same single ALLOWED_TRANSITIONS table.

Where This Connects

This chapter's findingWhat it connects to
A real page table owned by a real processChapter 3 (this course) — the exact PageTable class, now attached to something identifiable for the first time
A memory leak from resources never reclaimedChapter 2's own allocator — the same free-frame pool, now leaking not from a bad allocation strategy but from a termination path that simply forgot to give anything back
A real, enforced state machineChapter 6 — Interrupts & System Calls, where a syscall's own boundary will need to check a process's own state before acting on its behalf

Hands-On Exercises

Exercise 1

Create a process, run it, terminate it correctly, then create a new process. Confirm the new process's own PID is different from — and greater than — the terminated one's, and explain the real tradeoff between never reusing PIDs (this engine's own choice) and reusing them once free (what many real operating systems do).

📄 View solution
Exercise 2

Request a process with far more pages than the physical memory has frames for, confirming it fails cleanly. Confirm the number of free frames is exactly the same before and after the failed attempt, and that a genuinely fitting request afterward still succeeds.

📄 View solution
Exercise 3

Take a process through RUNNING → BLOCKED, confirm it can't jump straight back to RUNNING, then take it through the real required path (BLOCKED → READY → RUNNING) and confirm that succeeds. Explain why a scheduler needing to get involved again matters here.

📄 View solution

Chapter 4 Quick Reference

  • PCB: a unique PID, a real state, a real page table, and the list of physical frames it owns
  • State machine: NEW → READY → RUNNING → (READY / BLOCKED / TERMINATED), BLOCKED → READY only — enforced by one shared transition table
  • Verified Finding 1: two processes get real, disjoint physical memory, genuinely writable through their own translation
  • Verified Finding 2 (bug + fix): terminating a process without freeing its own frames permanently leaks memory — fixed by reclaiming every owned frame on exit
  • Verified Finding 3: illegal transitions (resurrection, double termination, skipping RUNNING) are all genuinely rejected
  • Next chapter: Context Switching — saving and restoring a process's own execution state
Chapter 5 of 10

Context Switching: Saving & Restoring Execution State

Building an Operating System Kernel: Core Fundamentals

Chapter 5 · Context Switching: Saving & Restoring Execution State

Chapter 1's own manual interleaving quietly relied on Python's generator machinery to remember where each program was paused. A real kernel has no such help — it has to do that saving and restoring itself, explicitly, for every single switch. This chapter builds that mechanism for real, and finds a bug the moment the two steps happen in the wrong order.

A Real Simulated CPU

class CPU: def __init__(self): self.registers = {'PC': 0, 'ACC': 0, 'R1': 0, 'R2': 0} self.current_pid = None def context_switch_fixed(cpu, old_pcb, new_pcb): if old_pcb is not None: old_pcb.registers = dict(cpu.registers) # SAVE the old process's real state FIRST old_pcb.transition(ProcessState.READY) cpu.registers = dict(new_pcb.registers) # THEN load the new process's own saved state new_pcb.transition(ProcessState.RUNNING) cpu.current_pid = new_pcb.pid

Finding 1: A Real Switch, Genuinely Preserving State

Verified directly — process A's own progress survives, untouched
Process A "executes" — its simulated PC reaches 42, ACC reaches 100. Switching to process B: the CPU now shows B's own fresh, empty registers, while proc_a.registers holds exactly {'PC': 42, 'ACC': 100, 'R1': 7, 'R2': 0} — A's own real progress, completely untouched by the switch.

Finding 2: A Real Bug — Loading Before Saving

The most natural-looking mistake: load the new process's state into the CPU first, then "save" the old one.

def context_switch_buggy(cpu, old_pcb, new_pcb): cpu.registers = dict(new_pcb.registers) # loads new FIRST if old_pcb is not None: old_pcb.registers = dict(cpu.registers) # 'saves' -- but cpu.registers is ALREADY B's!
Verified directly — process A's real progress is silently gone
Process A reaches PC=99, ACC=555. After the buggy switch: proc_a.registers is {'PC': 0, 'ACC': 0, 'R1': 0, 'R2': 0} — identical to process B's own fresh state. A's real progress was overwritten before it was ever saved. If A is resumed later, it silently restarts from PC=0, not from where it actually was.

Finding 3: The Fix, Verified Across a Full Multi-Switch Round Trip

Verified directly — four real switches in a row, every one correct
A runs to PC=10, switches to B. B runs to PC=20, switches back to A — the CPU correctly shows PC=10 again. A continues to PC=11, switches to B a second time — the CPU correctly shows PC=20, B's own earlier state, not A's most recent one and not a reset. Each process resumes exactly where it left off, every single time.

Finding 4: Context Switching Has a Real, Measured Cost

Verified directly — even a tiny, 4-register CPU costs real, non-zero time
200,000 real switches between two processes: 0.633 microseconds average per switch. Copying registers, checking state transitions, updating bookkeeping — none of it is free. A real CPU has far more state to save (dozens of registers, cache effects, memory-management hardware), making this a genuine, unavoidable cost — exactly why Chapters 7–9 have to be careful about how often a switch happens, not just whether scheduling looks fair on paper.

An Honest Surprise: Switching a Process to Itself

A natural question: what happens if context_switch_fixed() is called with the same process on both sides?

Verified directly — a genuine no-op, but only by accident
No error. The CPU's registers end up completely unchanged (PC still 77), and the process ends up back in RUNNING. Since old_pcb and new_pcb are the same object, the save step legally moves it RUNNING → READY and stores its own current registers on itself; the load step then reads those same registers straight back into the CPU, and READY → RUNNING is legal too, since the process is genuinely READY at that exact point. It works — but purely because of the specific order the two steps happen to run in, not because this case was ever deliberately designed for.

Where This Connects

This chapter's findingWhat it connects to
Manual save/restore, formalized for realChapter 1 (this course) — the exact "alternating next() calls" preview, now built as a real, explicit mechanism instead of leaning on Python's own generator machinery
Context switching has a real costChapter 8 — Preemptive Scheduling, where switching too often to enforce fairness has to be weighed against the real time each switch itself consumes
A real bug from operation orderingBuilding a Database Engine's own WAL/rollback capstone bug — a different domain, the same lesson that two individually-correct steps can produce a genuinely wrong result if run in the wrong order

Hands-On Exercises

Exercise 1

Extend Finding 3's own round trip to three processes: switch A → B → C → back to A, changing the CPU's own PC at each step. Confirm A resumes with its own original PC value, having survived being switched away from twice, not just once.

📄 View solution
Exercise 2

Call context_switch_fixed(cpu, p, p) — the same process as both the old and new process — and report honestly what actually happens, rather than assuming it should raise an error or corrupt state.

📄 View solution
Exercise 3

After switching from process 1 to process 2, mutate the CPU's own current registers directly. Confirm process 1's own saved registers are unaffected, and explain what would happen instead if context_switch_fixed() assigned cpu.registers directly instead of a real dict(...) copy.

📄 View solution

Chapter 5 Quick Reference

  • The rule: save the old process's own current state first, THEN load the new process's own saved state — never the other way around
  • Verified Finding 1: a real switch genuinely preserves a process's own execution state, completely untouched
  • Verified Finding 2 (bug + fix): loading before saving silently destroys the old process's real progress — fixed by reordering to save-then-load
  • Verified Finding 3: a full multi-switch round trip (A→B→A→B) — every process resumes exactly where it left off, every time
  • Verified Finding 4: context switching has a real, measured, non-zero cost — never free, even for a tiny simulated CPU
  • Honest surprise: switching a process to itself is a genuine no-op — by accident of step ordering, not deliberate design
  • Next chapter: Interrupts & System Calls — the real, controlled boundary between user programs and the kernel
Chapter 6 of 10

Interrupts & System Calls: The Boundary Between User and Kernel

Building an Operating System Kernel: Core Fundamentals

Chapter 6 · Interrupts & System Calls: The Boundary Between User and Kernel

Every mechanism built so far — memory, page tables, processes, context switching — has been trusted code, called directly. A real kernel can't trust its own user programs that way. This chapter builds the real boundary: interrupts for the kernel to be notified of something, and syscalls for user code to ask the kernel to do something on its behalf — and finds that the whole boundary is worthless the moment it leaks a reference it shouldn't.

A Real Interrupt-Dispatch Table

class InterruptTable: def __init__(self): self.handlers = {} def register(self, number, handler): self.handlers[number] = handler def dispatch(self, number, *args, **kwargs): if number not in self.handlers: raise RuntimeError(f"no handler registered for interrupt {number}") return self.handlers[number](*args, **kwargs)
Verified directly — correct routing, and a loud failure for the unhandled case
Two real handlers registered and dispatched by number — each one runs, and only the correct one for the number given. Dispatching an unregistered interrupt raises immediately, rather than being silently dropped — exactly how a real kernel treats a genuinely unhandled interrupt: as a serious fault, never something to quietly ignore.

Finding 2: A Severe Bug — One Leaked Reference Defeats Everything

What happens if "user" code is simply handed an object that wraps the physical memory — the most natural first attempt at giving a process "access to memory"?

Verified directly — process A reads process B's private data, byte for byte
Process B writes a real secret into its own, legitimately mapped memory. Process A — holding only an UnsafeUserInterface wrapping the raw PhysicalMemory object — reads it back exactly: b'TOP-SECRET-DATA!'. Process A never touched its own page table for this. Every isolation guarantee Chapters 3 and 4 built — separate virtual address spaces, separate physical frames — is completely worthless the instant user code holds a reference to raw memory instead of being forced through its own translation.
class Syscall: def __init__(self, mem): self._mem = mem # kept internal -- NEVER returned to a caller def sys_read(self, pcb, vaddr, length): # ... walks page boundaries, but EVERY address goes through pcb.page_table.translate() ... paddr = pcb.page_table.translate(current_vaddr) # ALWAYS the CALLER'S OWN table
The fix — never expose raw memory; always translate through the caller's own page table
Syscall never returns self._mem to anyone. Every method requires the caller's own pcb, and translates strictly through that process's own page table. There is no parameter anywhere in this interface that accepts a raw physical frame number or another process's own PCB — process A's own virtual address 0 can only ever resolve to process A's own physical frame, no matter what's tried.

Finding 3: A Privileged Syscall Enforces Real Policy

mem.alloc_frame() has no concept of "how many pages this process is allowed" — it just hands out whatever's free. A syscall is where the kernel gets to say no.

Verified directly — stopped at exactly 3 pages, with 17 real free frames left untouched
sys_allocate_page() enforces a real quota of 3 pages per process. A process requesting page after page is granted exactly 2 more (on top of its 1 starting page) before being rejected — even though physical memory genuinely has 17 more frames sitting free. The quota is a real policy decision enforced at the syscall boundary, something the underlying allocator itself was never asked to know or care about.

Finding 4: A Fault Is Contained, Not a Kernel Crash

Verified directly — the faulting process dies alone; everything else keeps running
A process reads a genuinely unmapped virtual address through sys_read() — a real PageFault. Dispatching it to a registered handler cleanly terminates only that process (its own frames correctly reclaimed, per Chapter 4's own fix). A second, unrelated process remains fully READY and fully readable through the exact same syscall interface, completely untouched by the other one's failure.

Where This Connects

This chapter's findingWhat it connects to
A leaked memory reference defeating isolationChapters 3 and 4 (this course) — the exact page-table and process isolation those chapters built, now shown to depend entirely on nothing ever bypassing them
A quota the allocator itself doesn't enforceChapter 2's own allocator — a reminder that "free space exists" and "this request should be granted" are genuinely separate questions
A single process's fault contained to itselfChapter 1's own crash-isolation exercise — the same principle, now enforced by a real kernel mechanism instead of a correctly-placed try/except

Hands-On Exercises

Exercise 1

Verify sys_write() is exactly as isolated as sys_read(): have one process write to its own virtual address 0, and confirm a completely different process's own data at the same virtual address is unaffected.

📄 View solution
Exercise 2

Create a process directly with create_process(num_pages=5) against a kernel whose quota is 3. Report honestly whether the quota applies, and explain why.

📄 View solution
Exercise 3

Register a handler that itself raises an exception when called, and dispatch it. Confirm the exception genuinely propagates out of dispatch(), and explain the difference between this case and Finding 1's own "no handler registered" case.

📄 View solution

Chapter 6 Quick Reference

  • InterruptTable: a real dict-based dispatch by number — a missing handler fails loudly, never silently
  • Verified Finding 2 (bug + fix): exposing raw physical memory to "user" code defeats every isolation guarantee built so far — fixed with a Syscall class that never returns raw memory and always translates through the caller's own page table
  • Verified Finding 3: a syscall can enforce real kernel policy (a quota) the underlying allocator itself has no concept of
  • Verified Finding 4: a real fault cleanly terminates only the process that caused it — everything else keeps running
  • Golden rule: user code touches memory only through a syscall that translates via its own page table — never a raw reference to physical memory
  • Next chapter: Cooperative Scheduling — a real round-robin scheduler, using this chapter's own syscall boundary
Chapter 7 of 10

Cooperative Scheduling: A Simple Round-Robin Scheduler

Building an Operating System Kernel: Core Fundamentals

Chapter 7 · Cooperative Scheduling: A Simple Round-Robin Scheduler

Chapter 1 hand-simulated round-robin turn-taking by manually interleaving a few processes in a loop. Chapter 5 built real context switching. Chapter 6 built a real syscall boundary. This chapter finally wires them together into a genuine scheduler component — a FIFO ready queue and a run_cooperative() function that decides, mechanically, who runs next — and then finds that fairness isn't automatic: it's a property that a single careless code path can silently destroy.

A Real FIFO Ready Queue

class Scheduler: def __init__(self): self.ready_queue = deque() def add(self, pcb): if pcb.state != ProcessState.READY: raise ValueError(f"cannot schedule PID {pcb.pid}: not READY (state={pcb.state.value})") self.ready_queue.append(pcb) def pick_next(self): if not self.ready_queue: return None return self.ready_queue.popleft()

run_cooperative() ties it together: the currently-running process voluntarily yields, the scheduler picks the next READY process off the front of the queue, a real context switch happens (Chapter 5's own context_switch_fixed(), unchanged), and the process that just ran goes to the back of the queue.

def run_cooperative(cpu, scheduler, current_pcb): next_pcb = scheduler.pick_next() if next_pcb is None: return current_pcb context_switch_fixed(cpu, current_pcb, next_pcb) scheduler.add(current_pcb) return next_pcb

Finding 1: Real, Measured Fairness

Verified directly — 4 processes, 40 total turns, an exactly even split
Four processes run through run_cooperative() for 40 total turns. The measured result: {1: 10, 2: 10, 3: 10, 4: 10} — every single process gets exactly the same number of turns. This isn't assumed or asserted; it's what a FIFO queue plus "always re-enqueue at the back" mechanically guarantees, formalizing Chapter 1's own hand-simulated round-robin into a real, tested kernel component.

Finding 2: Reusing Chapter 4's State Machine as a Real Safety Check

What happens if something tries to schedule a process that's currently BLOCKED — say, waiting on disk I/O?

Verified directly — a BLOCKED process is rejected before it ever reaches the queue
scheduler.add() on a BLOCKED process raises immediately: cannot schedule PID 5: not READY (state=BLOCKED). This is Chapter 4's own state machine doing real, active work — not just bookkeeping. A process that's genuinely waiting on something can never accidentally be handed a CPU turn it isn't ready to use.

Finding 3: A Real Bug — Double-Enqueuing Silently Breaks Fairness

Imagine a plausible-looking I/O-completion handler: when a device finishes, the kernel just appends the waiting process straight back onto the ready queue.

def io_complete_naive(scheduler, pcb): # BUG: no check for whether pcb is already in the queue scheduler.ready_queue.append(pcb)
Verified directly — one process ends up in the queue twice, and gets nearly double the turns
A real race is reproduced: a process finishes its own turn normally (going to the back of the queue the ordinary way) at the exact moment an unrelated I/O-completion event fires for that same process. io_complete_naive() appends it a second time with no check at all — the process now genuinely appears in ready_queue twice. Measured over the next 30 turns: {6: 8, 7: 7, 8: 15} — process 8 gets roughly double the turns of its two siblings. Finding 1's own guaranteed fairness silently breaks the instant any code path adds a process to the queue without first checking whether it's already there.

What run_cooperative() Assumes About the Outgoing Process

run_cooperative() always calls scheduler.add(current_pcb) on the way out — it assumes the process it's switching away from is always still alive and simply yielding its turn. Exercise 3 below puts that assumption under real pressure.

Where This Connects

This chapter's findingWhat it connects to
Measured, guaranteed fairness from a FIFO queueChapter 1's own hand-simulated round-robin — the same idea, now a real, tested component instead of a manual loop
The scheduler rejecting a non-READY processChapter 4's own state machine — reused here as a live safety check, not just bookkeeping
A real context switch on every scheduling decisionChapter 5's own context_switch_fixed() — reused completely unchanged
Double-enqueuing silently breaking fairnessChapter 6's own memory-quota gap and Chapter 4's own memory-leak bug — the same shape of problem: an invariant enforced on one code path, quietly undermined by another that nobody remembered to check

Hands-On Exercises

Exercise 1

Call run_cooperative() on a single process with nothing else in the ready queue. Report exactly what happens to the process's own state and whether a real context switch occurs at all.

📄 View solution
Exercise 2

Transition a process all the way to TERMINATED and try to scheduler.add() it. Confirm it's rejected, and explain why no special-case code was needed to catch this on top of Finding 2's own guard.

📄 View solution
Exercise 3

Have a process transition itself to TERMINATED mid-turn (instead of yielding normally), then call run_cooperative() on it. Confirm what actually happens, then write a fixed version that switches away from a terminated process cleanly without trying to re-enqueue it.

📄 View solution

Chapter 7 Quick Reference

  • Scheduler: a real FIFO ready_queue, with add() rejecting anything that isn't genuinely READY
  • run_cooperative(): pick the next READY process, context-switch to it (Chapter 5), then re-enqueue the outgoing process at the back
  • Verified Finding 1: a FIFO queue plus always-re-enqueue-at-the-back guarantees exactly equal turns — measured, not assumed
  • Verified Finding 2: Chapter 4's own state machine actively blocks scheduling a non-READY (e.g. BLOCKED) process
  • Verified Finding 3 (bug): a naive handler that double-enqueues a process silently breaks fairness — the affected process gets roughly double the turns
  • Golden rule: a queue only stays fair if every code path that touches it respects the same invariants — one careless append is enough to break it
  • Next chapter: Preemptive Scheduling — what happens when a process doesn't voluntarily yield
Chapter 8 of 10

Preemptive Scheduling & Timer Interrupts

Building an Operating System Kernel: Core Fundamentals

Chapter 8 · Preemptive Scheduling & Timer Interrupts

Chapter 7's own run_cooperative() guarantees measured fairness — but only if every process actually calls it. An infinite loop, a genuine bug, or hostile code never calling it at all isn't a hypothetical edge case a real kernel can shrug off — it's the single most important reason preemption exists. This chapter builds a real timer interrupt, reusing Chapter 6's own InterruptTable, that reclaims the CPU whether the running process cooperates or not — and finds a bug hiding directly underneath a result that, at first glance, looks completely fine.

Finding 1: Cooperative Scheduling Has No Way to Reclaim the CPU

Verified directly — 500 raw instructions, and the other two processes get exactly zero turns
A process runs 500 raw instructions in a row without ever calling run_cooperative(). The two other processes sitting in scheduler.ready_queue are still exactly where they started — genuinely READY, genuinely untouched, genuinely getting zero turns. Nothing about Chapter 7's own mechanism is broken; it simply has no way to intervene when a process refuses to give the CPU back voluntarily.

A Real Timer Interrupt, Reusing Chapter 6's InterruptTable

TIMER_INTERRUPT = 0 class PreemptiveKernel: QUANTUM = 5 # instructions allowed before a forced switch def __init__(self, cpu, scheduler): self.cpu = cpu self.scheduler = scheduler self.interrupts = InterruptTable() self.interrupts.register(TIMER_INTERRUPT, self._on_timer) self.ticks_since_switch = 0 self.current_pcb = None def run_instruction(self): self.cpu.registers['PC'] += 1 self.ticks_since_switch += 1 if self.ticks_since_switch >= self.QUANTUM: self.interrupts.dispatch(TIMER_INTERRUPT)

Every single raw instruction now runs through run_instruction() instead of a bare loop. Once QUANTUM instructions have executed, the timer interrupt fires through InterruptTable.dispatch() — the exact same dispatch mechanism Chapter 6 built for genuinely unrelated interrupts, now firing on a schedule rather than in response to an external event.

Finding 2: Real, Measured Fairness — With Zero Cooperation

Verified directly — 4 processes, 400 instructions, none ever yielding
Four processes run 400 total raw instructions through the preemptive kernel. None of them ever calls run_cooperative() — every single switch is forced by the timer. Measured result: {4: 103, 5: 99, 6: 99, 7: 99}, a spread of only 4 instructions — well within a single quantum. The timer interrupt alone is enough to fix Finding 1's own starvation, without requiring a single process to behave.

Finding 3: A Real Bug Hiding Underneath a Fair-Looking Result

Finding 2's own totals look completely healthy. But averages can hide a lot — what does the length of each individual quantum actually look like?

def _on_timer(self): # BUG: never resets self.ticks_since_switch next_pcb = self.scheduler.pick_next() if next_pcb is None: return context_switch_fixed(self.cpu, self.current_pcb, next_pcb) self.scheduler.add(self.current_pcb) self.current_pcb = next_pcb
Verified directly — measured quantum lengths: [5, 1, 1, 1, 1, 1, 1, 1, 1, 1, ...]
The first quantum is a genuine 5 instructions, exactly as configured. Every quantum after that is exactly 1 instruction. Once ticks_since_switch first reaches QUANTUM, nothing in _on_timer() ever resets it back to 0 — so it stays >= QUANTUM forever, and the very next instruction (and every one after it) immediately re-triggers the timer interrupt. Finding 2's own near-equal totals were real, but they completely hid this: a real kernel would be context-switching on almost every single instruction, paying full save/restore overhead for essentially zero real work per turn.
The fix — reset the tick counter as part of handling the interrupt
Adding a single line, self.ticks_since_switch = 0, inside _on_timer() itself is enough. Verified directly: every measured quantum afterward is a real, full 5 instructions — [5, 5, 5, 5, ...] — not just the first one.

Where This Connects

This chapter's findingWhat it connects to
The timer interrupt itselfChapter 6's own InterruptTable — reused completely unchanged, just registered against a scheduled event instead of an external one
The forced context switchChapter 5's own context_switch_fixed() — reused unchanged; preemption doesn't need a different switch mechanism, only a different reason to trigger one
A bug hidden by an average that still looked fairTechnical Support's own System Monitoring & Performance Diagnosis course — its own Chapter 7 makes exactly this point about dashboard averages hiding a real spike
The READY-only scheduling guard still applyingChapter 7's own Scheduler.add() — preemption never bypasses it; a BLOCKED process still can't be handed a forced turn

Hands-On Exercises

Exercise 1

Run the fixed preemptive kernel with only a single process in the entire system. Confirm whether the timer interrupt still fires on schedule, and explain exactly what happens to the process's own state each time it does.

📄 View solution
Exercise 2

Set up a running process and a second, BLOCKED process (never added to the scheduler), then run several preemptible instructions. Confirm the blocked process is never handed a turn, and explain which guard from an earlier chapter is actually responsible.

📄 View solution
Exercise 3

Run the identical amount of real work (200 instructions, 3 processes) through the fixed preemptive kernel twice — once with QUANTUM=1 and once with QUANTUM=20. Measure and report the real number of context switches each one performs, and explain the tradeoff the difference reveals.

📄 View solution

Chapter 8 Quick Reference

  • PreemptiveKernel: counts instructions per process and dispatches a real TIMER_INTERRUPT through Chapter 6's own InterruptTable once QUANTUM is reached
  • Verified Finding 1: cooperative scheduling alone has no mechanism to reclaim the CPU from a process that never yields — measured starvation, not a hypothetical
  • Verified Finding 2: a timer interrupt restores fairness with zero cooperation required from any process
  • Verified Finding 3 (bug): forgetting to reset the tick counter inside the interrupt handler collapses every quantum after the first down to a single instruction — invisible in aggregate turn counts, visible only by measuring individual quantum lengths
  • Golden rule: an average that looks fair can still hide a real, measurable problem underneath it — always check the individual measurements, not just the summary
  • Next chapter: Priority Scheduling & the Starvation Problem — what happens once not every process is equally important
Chapter 9 of 10

Priority Scheduling & the Starvation Problem

Building an Operating System Kernel: Core Fundamentals

Chapter 9 · Priority Scheduling & the Starvation Problem

Chapters 7 and 8 both guarantee fairness — every process eventually gets a turn. But not every process is equally important: a user-facing task genuinely should run before a background cleanup job. This chapter builds a real priority scheduler that honors that — and finds, in real, measured numbers, exactly what it costs: a lower-priority process can be starved completely and indefinitely. The fix, aging, sounds simple — and comes with a bug that shows the difference between a mechanism existing and a mechanism actually being used.

A Real Strict-Priority Scheduler

class PriorityScheduler: def __init__(self): self.ready_queue = [] # list of [pcb, priority]; higher number = higher priority def add(self, pcb, priority): if pcb.state != ProcessState.READY: raise ValueError(f"cannot schedule PID {pcb.pid}: not READY") self.ready_queue.append([pcb, priority]) def pick_next(self): if not self.ready_queue: return None best_idx = 0 for i in range(1, len(self.ready_queue)): if self.ready_queue[i][1] > self.ready_queue[best_idx][1]: best_idx = i return self.ready_queue.pop(best_idx)

Finding 1: The Highest Priority Always Wins — But That's Not the Whole Story

Verified directly — 3 processes (priorities 10 / 5 / 1), 21 turns
Measured result: {high: 11, med: 10, low: 0}. Low priority gets exactly zero turns — but high and medium don't split into "10 monopolizes everything," either. Each time a process runs, it goes back into the queue at its own priority — so high and medium end up alternating: whichever of the two is currently waiting always outranks low, so the one that just ran is immediately re-challenged by the other. Strict priority guarantees the highest-priority READY process always wins the comparison — it does not guarantee only one process ever runs.
A genuinely surprising side finding — priority doesn't matter with only two processes
Run the identical setup with only two total processes — one high, one low — and they alternate turns regardless of their priority values. After every switch, the ready queue holds exactly one entry, and with nothing to compare it against, the priority comparison never actually fires. Real starvation needs a genuine competitor at every point in time, not just a numerically higher priority sitting unused.

Finding 2: Real, Measured, Indefinite Starvation

Verified directly — 3 processes, 2000 turns, still exactly zero
The same 3-process setup (priorities 10 / 5 / 1) run for 2000 turns instead of 20. Low priority's own turn count: 0 — unchanged. This isn't a rare edge case that eventually resolves itself — it's the guaranteed, structural consequence of strict priority scheduling. As long as at least one higher-priority process stays genuinely READY, a lower-priority process has no path to a CPU turn, ever.

Finding 3: A Real Bug — The Aging Mechanism Exists, But Nothing Calls It

The classic fix for starvation is aging: every waiting process's own priority slowly climbs the longer it waits, until it eventually outranks even the busiest process.

class AgingScheduler(PriorityScheduler): AGING_INCREMENT = 1 def age_waiting(self): # bump every WAITING process's own priority -- correct in isolation for entry in self.ready_queue: entry[1] += self.AGING_INCREMENT
Verified directly — 500 turns, and low priority's own stored value never moves
AgingScheduler is dropped straight into the same 3-process setup from Finding 2, run for 500 turns. Result: identical to Finding 2low still gets 0 turns, and its own stored priority in the queue is still exactly 1 after all 500. Having a correct, tested age_waiting() method changes nothing by itself, because nothing in the scheduling loop ever actually calls it. A mechanism existing is not the same as a mechanism being wired in.
The fix — call age_waiting() every turn, and reset to base priority on a real run
Adding one call, scheduler.age_waiting(), at the top of every scheduling decision — plus resetting the outgoing process back to its own base priority (not whatever aged value it happened to reach) once it actually gets to run — is enough. Verified directly: low priority now gets real turns (83 of 500 measured, first one at turn 5), and its own stored priority climbs by exactly 1 every turn it's skipped, exactly as designed.

Where This Connects

This chapter's findingWhat it connects to
A mechanism existing but never being calledChapter 8's own timer-reset bug — a single missing line quietly undoing everything the surrounding code was built to guarantee
Priority scheduling reusing the READY-only guardChapter 7's own Scheduler.add() — the exact same non-READY rejection, unchanged
Real, indefinite starvation as a structural guaranteeTechnical Support's own Incident Response & Ticketing Workflows course — its own severity-vs-priority chapter treats these as genuinely separate axes for the same reason
Two processes alternating regardless of priorityA reminder that a scheduling algorithm's guarantees only apply to the scenarios that actually put them to the test — a claim needs a real competitor to verify, not just a numerically different value

Hands-On Exercises

Exercise 1

Add two processes to the ready queue with the exact same priority. Run several turns and determine which one pick_next() consistently favors, then explain exactly why — in terms of the actual comparison operator used.

📄 View solution
Exercise 2

Set up three processes using negative and zero priorities (e.g. -1, 0, -10) instead of positive ones. Confirm the scheduler still behaves correctly, and explain why nothing about the algorithm assumes priorities are positive.

📄 View solution
Exercise 3

Wire up aging correctly (call age_waiting() every turn) but forget to reset a process's own priority back to its base value once it actually gets to run. Run 200 turns and report what happens to the stored priorities, and why this defeats the original point of having priorities at all.

📄 View solution

Chapter 9 Quick Reference

  • PriorityScheduler: pick_next() always returns the ready entry with the strictly highest stored priority
  • Verified Finding 1: the highest priority always wins the comparison — but with 2+ competitors above it, they can alternate rather than one monopolizing every turn
  • Verified Finding 2: strict priority scheduling guarantees complete, indefinite starvation of any process with a genuine, persistent higher-priority competitor
  • Verified Finding 3 (bug): an AgingScheduler class with a correct age_waiting() method changes nothing if nothing ever calls it — starvation was completely unaffected until the one missing call was added
  • The fix: call age_waiting() every scheduling decision, and reset a process back to its own base priority once it actually runs
  • Golden rule: a fix that exists in the codebase but isn't wired into the actual decision path provides zero real protection — test the end-to-end behavior, not just that the class exists
  • Next chapter: Capstone — every mechanism from this course, wired together into one working multi-process kernel
Chapter 10 of 10

Capstone — A Working Multi-Process Kernel

Building an Operating System Kernel: Core Fundamentals

Chapter 10 · Capstone: A Working Multi-Process Kernel

Nine chapters have each built one real, independently-verified piece: memory, page tables, processes, context switching, syscalls, quota enforcement, cooperative scheduling, preemption, and priority-with-aging. Every one of them was correct on its own terms. This capstone wires all of them together for the first time — and finds that "each piece is correct" and "the pieces fit together" are two genuinely different claims.

Step 1: Memory, Processes & Syscalls — Assembled and Verified

Verified directly — two independently-created processes, genuinely isolated data
Three real processes are created against one shared physical memory pool (Chapter 2), each with its own real page table (Chapter 3) and PCB (Chapter 4). Process A writes b'PROC-A-DATA' to its own virtual address 0; process B writes b'PROC-B-DATA' to the same virtual address. Reading each back through the syscall boundary (Chapter 6) returns exactly the right data for each — the foundation still holds with three real processes sharing one kernel.

Step 2: A Real Cross-Chapter Bug — Two Correct Interfaces That Disagree

Chapter 8's PreemptiveKernel was built and tested against Chapter 7's own Scheduler. Chapter 9's AgingScheduler was built and tested entirely on its own. What happens the first time they're actually combined?

pk = PreemptiveKernel(cpu, aging_scheduler) # Chapter 8's own class, unchanged pk.current_pcb = high_priority_process for _ in range(20): pk.run_instruction()
Verified directly — raises: 'list' object has no attribute 'registers'
Chapter 7's Scheduler.pick_next() returns a bare PCB (or None). Chapter 9's PriorityScheduler.pick_next() returns a (pcb, priority) tuple instead. PreemptiveKernel._on_timer() takes whatever pick_next() hands it and passes it straight to context_switch_fixed() — which tries to read .registers off a two-element list. Each chapter's own pick_next() was independently correct against its own tests. Neither chapter was ever wrong. Only wiring them together for the first time surfaces that they were never designed against a shared interface.

Step 3: The Fix — a Unified Preemptive Priority Kernel

class PreemptivePriorityKernel: def _on_timer(self): self.ticks_since_switch = 0 self.scheduler.age_waiting() # Chapter 9's own fix picked = self.scheduler.pick_next() if picked is None: return next_pcb, _priority = picked # unpack the tuple correctly context_switch_fixed(self.cpu, self.current_pcb, next_pcb) self.scheduler.add(self.current_pcb, self.base_priorities[self.current_pcb.pid]) self.current_pcb = next_pcb
Verified directly — 3 processes, 1000 instructions, none ever yielding
Real, measured result: {high: 500, medium: 335, low: 165}. Every mechanism from this course is genuinely present at once: preemption (Chapter 8) forces every single switch without any process cooperating; priority (Chapter 9) means high > medium > low in real turn counts; aging (Chapter 9) means low priority gets a real, nonzero share — unlike Chapter 9's own strict-priority-only measurement, where low priority was starved completely.

Step 4: Quota Enforcement and Fault Containment, Fully Assembled

Verified directly — both Chapter 6 protections still hold, unaffected by Chapters 7-9
A process already at its page quota is correctly denied further allocation: PID 9 exceeded page quota (3). A process reading a genuinely unmapped virtual address correctly raises a real PageFault — and its own valid data, read again immediately afterward, comes back completely unaffected: b'STILL-SAFE'. Everything Chapter 6 built stays correct inside the fully assembled kernel, with zero changes required to make it so.

Chapter Attribution

Capstone componentBuilt in
Physical memory, frame allocationChapter 2
Page tables, virtual address translation, PageFaultChapter 3
PCBs, the process state machineChapter 4
Context switching (context_switch_fixed)Chapter 5
The syscall boundary, quota enforcementChapter 6
Cooperative scheduling, the Scheduler interfaceChapter 7
Timer interrupts, preemption, the quantumChapter 8
Priority scheduling, agingChapter 9
The pick_next() interface mismatch, found and fixedThis capstone — only visible once Chapters 8 and 9 were combined

What This Course Doesn't Cover

This kernel is a real, verified Python simulation of genuine kernel mechanisms — not bare-metal code, not assembly, not anything that runs on real hardware. It deliberately doesn't cover: multi-core or SMP scheduling, real device drivers for actual hardware, a networking stack, or real synchronization primitives for genuinely concurrent access (every switch here is a clean, sequential handoff — nothing runs at the same instant as anything else). That's exactly where Course 2 picks up.

Where this connects
Course 2, Building an Operating System Kernel: Concurrency, I/O & Synchronization, builds directly on this kernel: real mutexes and semaphores built from scratch, a genuine measured deadlock with wait-for-graph detection, inter-process communication, device drivers, and a small syscall library tying it all together — everything this course deliberately left as sequential and single-threaded.

Capstone Quick Reference

  • Step 1: memory + page tables + PCBs + syscalls, assembled and verified with real, isolated multi-process data
  • Step 2 (bug): Chapter 8's PreemptiveKernel assumes pick_next() returns a bare PCB; Chapter 9's PriorityScheduler returns a tuple — both correct alone, incompatible together
  • Step 3 (fix): a unified kernel that unpacks the tuple correctly, calls age_waiting() every decision, and resets priority to base on every real run
  • Step 4: quota enforcement and fault containment (Chapter 6) both verified still correct, completely unaffected by anything built on top of them
  • The one big lesson: each chapter's own tests proving a component correct in isolation is not the same claim as the whole system working — only real, end-to-end integration finds the gap between them
  • Course complete: Building an Operating System Kernel: Core Fundamentals, 10/10 chapters — Course 2 continues with concurrency, I/O & synchronization