Building an Operating System Kernel
Core Fundamentals — Memory, Processes, Context Switching & Scheduling, From Scratch
Table of Contents
- Why Build a Kernel? Processes, Memory & the Illusion of Multitasking
- Physical Memory: A Byte Array and a Free-List Allocator
- Virtual Memory: Address Translation & Page Tables
- Processes & the Process Control Block
- Context Switching: Saving & Restoring Execution State
- Interrupts & System Calls: The Boundary Between User and Kernel
- Cooperative Scheduling: A Simple Round-Robin Scheduler
- Preemptive Scheduling & Timer Interrupts
- Priority Scheduling & the Starvation Problem
- Capstone — A Working Multi-Process Kernel
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.
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
| Stage | What it does | Built in |
|---|---|---|
| Physical memory | A real byte array standing in for RAM, with a real allocator managing free and used space | Chapter 2 |
| Virtual memory | A real page table translating each process's own private address space onto physical memory | Chapter 3 |
| Processes | A real Process Control Block — the record a kernel keeps of everything one running program needs | Chapter 4 |
| Context switching | Real, verified save-and-restore of one process's own execution state, so pausing it and resuming it later is genuinely safe | Chapter 5 |
| Interrupts & syscalls | The real, controlled boundary between a user program and the kernel itself | Chapter 6 |
| Scheduling | Real, working schedulers — cooperative, then preemptive, then priority-based — deciding whose turn it is | Chapters 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.
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.
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.
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 finding | What it connects to |
|---|---|
| A non-yielding program starving everything else | Chapter 8 — Preemptive Scheduling & Timer Interrupts, the real fix that forces control away regardless of cooperation |
| Manual interleaving preserving each program's own state | Chapter 5 — Context Switching, which formalizes exactly this save-and-restore discipline for a real simulated CPU |
| This site's own real-verification discipline | compiler1/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
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.
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 solutionMake 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 solutionChapter 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
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
Finding 1: This Is Real, Writable, Readable Memory
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.
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.
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.
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.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| External fragmentation, even with coalescing | Chapter 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 memory | Chapter 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 merging | Building 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
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.
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.
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 solutionChapter 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
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.
Finding 1: Real Address Translation, Verified Against Raw Physical Memory
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
'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.
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.
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 finding | What it connects to |
|---|---|
| Paging resolving external fragmentation | Chapter 2 (this course) — the exact scattered-free-space scenario that broke a contiguous allocator, resolved here by construction |
| Isolated address spaces for two processes | Chapter 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 boundary | Building 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
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.
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.
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 solutionChapter 3 Quick Reference
- translate(vaddr):
vpn = vaddr // page_size,offset = vaddr % page_size, look upvpnin the page table, returnpfn * 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
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
Finding 1: A Real Process, With Real Memory
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.
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.
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
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 finding | What it connects to |
|---|---|
| A real page table owned by a real process | Chapter 3 (this course) — the exact PageTable class, now attached to something identifiable for the first time |
| A memory leak from resources never reclaimed | Chapter 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 machine | Chapter 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
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 solutionRequest 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 solutionTake 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.
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
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
Finding 1: A Real Switch, Genuinely Preserving State
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.
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
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
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?
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 finding | What it connects to |
|---|---|
| Manual save/restore, formalized for real | Chapter 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 cost | Chapter 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 ordering | Building 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
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 solutionCall 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.
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.
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
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
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"?
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.
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.
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
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 finding | What it connects to |
|---|---|
| A leaked memory reference defeating isolation | Chapters 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 enforce | Chapter 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 itself | Chapter 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
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.
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.
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.
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
Syscallclass 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
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
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.
Finding 1: Real, Measured Fairness
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?
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.
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 finding | What it connects to |
|---|---|
| Measured, guaranteed fairness from a FIFO queue | Chapter 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 process | Chapter 4's own state machine — reused here as a live safety check, not just bookkeeping |
| A real context switch on every scheduling decision | Chapter 5's own context_switch_fixed() — reused completely unchanged |
| Double-enqueuing silently breaking fairness | Chapter 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
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.
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.
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.
Chapter 7 Quick Reference
- Scheduler: a real FIFO
ready_queue, withadd()rejecting anything that isn't genuinelyREADY - run_cooperative(): pick the next
READYprocess, 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
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
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
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
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?
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.
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 finding | What it connects to |
|---|---|
| The timer interrupt itself | Chapter 6's own InterruptTable — reused completely unchanged, just registered against a scheduled event instead of an external one |
| The forced context switch | Chapter 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 fair | Technical 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 applying | Chapter 7's own Scheduler.add() — preemption never bypasses it; a BLOCKED process still can't be handed a forced turn |
Hands-On Exercises
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 solutionSet 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.
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.
Chapter 8 Quick Reference
- PreemptiveKernel: counts instructions per process and dispatches a real
TIMER_INTERRUPTthrough Chapter 6's ownInterruptTableonceQUANTUMis 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
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
Finding 1: The Highest Priority Always Wins — But That's Not the Whole Story
{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.
Finding 2: Real, Measured, Indefinite Starvation
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.
AgingScheduler is dropped straight into the same 3-process setup from Finding 2, run for 500 turns. Result: identical to Finding 2 — low 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.
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 finding | What it connects to |
|---|---|
| A mechanism existing but never being called | Chapter 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 guard | Chapter 7's own Scheduler.add() — the exact same non-READY rejection, unchanged |
| Real, indefinite starvation as a structural guarantee | Technical 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 priority | A 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
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.
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 solutionWire 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.
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
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
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?
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
{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
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 component | Built in |
|---|---|
| Physical memory, frame allocation | Chapter 2 |
| Page tables, virtual address translation, PageFault | Chapter 3 |
| PCBs, the process state machine | Chapter 4 |
| Context switching (context_switch_fixed) | Chapter 5 |
| The syscall boundary, quota enforcement | Chapter 6 |
| Cooperative scheduling, the Scheduler interface | Chapter 7 |
| Timer interrupts, preemption, the quantum | Chapter 8 |
| Priority scheduling, aging | Chapter 9 |
| The pick_next() interface mismatch, found and fixed | This 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.
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
PreemptiveKernelassumespick_next()returns a bare PCB; Chapter 9'sPrioritySchedulerreturns 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