Operating Systems Module 1 – Foundations: what an operating system does
Kernel designs: monolithic to microkernel
Monolithic, modular, microkernel, hybrid, layered, exokernel and unikernel designs compared, with the crossings and message costs that decide between them.
What you will learn
- Compare monolithic, layered, modular, microkernel, hybrid, exokernel and unikernel designs
- Explain the performance and reliability trade-offs of moving services out of the kernel
- Describe how Linux loads modules and where Rust fits in the kernel
- Choose a kernel design for a system from its requirements
Before you start
What you’ll learn · Before you start
What you will learn
- Compare monolithic, layered, modular, microkernel, hybrid, exokernel and unikernel designs
- Explain the performance and reliability trade-offs of moving services out of the kernel
- Describe how Linux loads modules and where Rust fits in the kernel
- Choose a kernel design for a system from its requirements
Before you start
On this page
The Linux kernel is of the order of 20 million lines of source code, all of it written to run in kernel mode, where one bad write can bring down the whole machine; seL4’s kernel is about 10,000 lines and keeps everything else in ordinary processes. How much code to trust with full privilege is the first decision in designing a kernel, and every design answers it differently.
This lesson compares the main designs: monolithic and modular, microkernel, hybrid, layered, exokernel and unikernel. A model counts the crossings one read request makes under three of them, a measurement prices a message between two processes, and a table that answers back helps choose a design for a system’s requirements.
A monolithic kernel runs every service in one privileged address space
In a monolithic kernel: A kernel that runs all its services, such as file systems, networking and drivers, in kernel mode in one address space, as Linux and the BSDs do., the scheduler, memory management, file systems, the network stack and the drivers all run in kernel mode, in one shared address space. Linux and the BSDs are built this way. A request from a file system to a driver is an ordinary function call, as fast as a call can be. The figure sets it beside the opposite design:
Where the services run: monolithic kernel against microkernel
Text description of the diagram
The figure has two columns, with user mode in the top row and kernel mode in the bottom row.
- The left column is a monolithic kernel. In user mode there are only the applications. In kernel mode there is everything else: the scheduler, memory management, file systems, the network stack and the drivers, about 20 million lines of code in Linux. An arrow labelled system call leads from the applications down into the kernel.
- The right column is a microkernel. In user mode there are the applications and also the services: a file server, a network server and the drivers, each a process of its own. In kernel mode there is only a small core of address spaces, threads, messages and scheduling, about 10,000 lines of code in seL4. An arrow labelled messages leads from the processes down into the core, which carries every message between them.
The price is on the left of the figure: every line in that box is trusted. Microsoft’s driver documentation draws the consequence for Windows: because kernel-mode code shares one address space, nothing protects the rest of the system from a faulty driver there, and a crash in such a driver brings down the whole operating system. The seL4 whitepaper puts numbers on the exposure. It counts the kernel in the system’s trusted computing base, the part that must work correctly for the system to be secure, puts Linux at about 20 million lines against about 10,000 in seL4, three orders of magnitude, and argues that the attack surface shrinks accordingly.
Checkpoint
Check: The monolithic kernel
Loadable modules extend a running kernel, with full privilege
Linux is monolithic, yet it adds and removes code while it runs. A loadable module: Kernel code, often a driver, compiled separately and loaded into a running kernel. Once loaded it runs in kernel mode with full privilege., often a driver, is compiled separately and loaded on demand. init_module(2) describes what loading does: it loads an ELF image into kernel space, relocates its symbols, sets its parameters and runs its init function. Follow a module from its build to that function:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Build. A module is compiled against a prepared kernel tree, with that kernel's configuration and header files, by the kernel's own build system, kbuild.
- Dependencies. modprobe adds a module together with the modules it depends on, which it reads from modules.dep.bin, the file that depmod writes.
- The system call. Loading is itself a system call, finit_module or init_module, and only a privileged process may make it, one that holds the CAP_SYS_MODULE capability.
- The version check. With module versioning on, the kernel compares the checksums of the kernel interfaces the module was built against with its own, and if they are not equal it refuses to load the module.
- Refused. A module built for another kernel is turned away at this point and has to be built again against the running kernel.
- Relocate. When the check passes, the kernel copies the module's ELF image into kernel memory, fixes up its references to kernel symbols, and sets the parameters the caller gave.
- Init. The kernel runs the module's init function. From here on the module's code runs in kernel mode, as if it had been part of the kernel from the start.
- No wall inside. Nothing separates the module from the rest of the kernel. A bug in a loaded driver can corrupt any kernel data or crash the machine, exactly as a bug in the kernel's own code can.
The checks happen before the module runs, never after. Only a process holding the CAP_SYS_MODULE capability may
load one, and kbuild’s guide notes that, with module versioning on, the kernel refuses a module whose interface
checksums differ from its own. Once loaded, though, a module is simply kernel code. Modules change when code arrives,
not where it runs, which is why Linux is both monolithic and modular.
Rust entered the same picture as a safer way to write such code. The kernel’s Rust documentation describes abstractions that wrap the kernel’s C interfaces in an as-safe-as-possible API; drivers and file systems written in Rust use those abstractions rather than the raw C bindings, so code built on sound wrappers, whose own unsafe blocks keep their safety contracts, cannot introduce undefined behaviour. The code still runs in kernel mode with full privilege; what Rust removes is a large class of memory errors.
Linux 6.1
Linux 6.1 added the first support for writing kernel code in Rust; the KernelNewbies notes on that release describe it as initial support, to be improved in later releases.
Checkpoint
Check: Loadable modules and Rust in Linux
A microkernel moves services into processes and pays for every message
A microkernel: A kernel that keeps only address spaces, threads, scheduling and message passing in kernel mode, and runs file systems and drivers as processes, as seL4 and MINIX 3 do. keeps only what must be privileged: address spaces, threads, scheduling and a way to pass messages. File systems, network stacks and drivers become ordinary processes. MINIX 3, for example, keeps a tiny core in kernel mode and runs the other parts of the system as separate, protected processes in user mode. A crashing driver now ends one process, not the system.
Every request to a service then becomes inter-process communication (IPC): Ways for separate processes to exchange data, such as pipes, sockets, shared memory and messages, all set up or carried by the kernel.. The model below follows one read, from an application through a file system and a disk driver and back, under three designs, and counts the crossings:
Python 3.14 · main.py
PATH = [
"app",
"file system",
"disk driver",
"file system",
"app",
] # one read and its reply
DESIGNS = [ # each design and the parts it runs inside the kernel
("monolithic kernel", {"file system", "disk driver"}),
("driver in user space", {"file system"}),
("microkernel", set()),
]
def trace(design: str, in_kernel: set[str]) -> None:
print(f"{design}:")
modes = contexts = 0
current = PATH[0] # the process whose address space is in use
for a, b in zip(PATH, PATH[1:]):
if a in in_kernel and b in in_kernel:
how = "function call"
elif b in in_kernel:
how, modes = "trap into the kernel", modes + 1
elif a in in_kernel: # back to user mode, perhaps in another process
modes += 1
if b == current:
how = "return to user mode"
else:
how, contexts, current = "switch to its process", contexts + 1, b
else: # two processes: a message goes through the kernel
how, modes, contexts, current = (
"message through the kernel",
modes + 2,
contexts + 1,
b,
)
print(f" {a} -> {b}: {how}")
crash = (
"stops the whole kernel"
if "disk driver" in in_kernel
else "ends only its process"
)
print(
f" {modes} mode switches, {contexts} context switches; a driver crash {crash}"
)
for design, in_kernel in DESIGNS:
trace(design, in_kernel) In Python: A set holds the parts each design runs inside the kernel, and zip(PATH, PATH[1:]) walks the request one hop at a time.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const PATH = ["app", "file system", "disk driver", "file system", "app"]; // one read and its reply
const DESIGNS = [
// each design and the parts it runs inside the kernel
["monolithic kernel", new Set(["file system", "disk driver"])],
["driver in user space", new Set(["file system"])],
["microkernel", new Set()],
];
function trace(design, inKernel) {
console.log(`${design}:`);
let modes = 0;
let contexts = 0;
let current = PATH[0]; // the process whose address space is in use
for (let i = 0; i + 1 < PATH.length; i++) {
const [a, b] = [PATH[i], PATH[i + 1]];
let how;
if (inKernel.has(a) && inKernel.has(b)) {
how = "function call";
} else if (inKernel.has(b)) {
how = "trap into the kernel";
modes += 1;
} else if (inKernel.has(a)) {
// back to user mode, perhaps in another process
modes += 1;
if (b === current) {
how = "return to user mode";
} else {
how = "switch to its process";
contexts += 1;
current = b;
}
} else {
// two processes: a message goes through the kernel
how = "message through the kernel";
modes += 2;
contexts += 1;
current = b;
}
console.log(` ${a} -> ${b}: ${how}`);
}
const crash = inKernel.has("disk driver") ? "stops the whole kernel" : "ends only its process";
console.log(` ${modes} mode switches, ${contexts} context switches; a driver crash ${crash}`);
}
for (const [design, inKernel] of DESIGNS) trace(design, inKernel); In JavaScript: The designs keep their in-kernel parts in a Set, so has answers each test in one step.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.List;
import java.util.Set;
public class Main {
static final List<String> PATH =
List.of("app", "file system", "disk driver", "file system", "app"); // a read and its reply
record Design(String name, Set<String> inKernel) {} // the parts it runs inside the kernel
static void trace(Design design) {
System.out.println(design.name() + ":");
Set<String> inKernel = design.inKernel();
int modes = 0;
int contexts = 0;
String current = PATH.get(0); // the process whose address space is in use
for (int i = 0; i + 1 < PATH.size(); i++) {
String a = PATH.get(i);
String b = PATH.get(i + 1);
String how;
if (inKernel.contains(a) && inKernel.contains(b)) {
how = "function call";
} else if (inKernel.contains(b)) {
how = "trap into the kernel";
modes += 1;
} else if (inKernel.contains(a)) { // back to user mode, perhaps in another process
modes += 1;
if (b.equals(current)) {
how = "return to user mode";
} else {
how = "switch to its process";
contexts += 1;
current = b;
}
} else { // two processes: a message goes through the kernel
how = "message through the kernel";
modes += 2;
contexts += 1;
current = b;
}
System.out.println(" " + a + " -> " + b + ": " + how);
}
String crash =
inKernel.contains("disk driver") ? "stops the whole kernel" : "ends only its process";
System.out.println(
" "
+ modes
+ " mode switches, "
+ contexts
+ " context switches; a driver crash "
+ crash);
}
public static void main(String[] args) {
trace(new Design("monolithic kernel", Set.of("file system", "disk driver")));
trace(new Design("driver in user space", Set.of("file system")));
trace(new Design("microkernel", Set.of()));
}
} In Java: Design is a record holding its name and the Set of parts it runs inside the kernel.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <print>
#include <set>
#include <string>
#include <vector>
// one read and its reply
const std::vector<std::string> PATH = {"app", "file system", "disk driver",
"file system", "app"};
struct Design {
std::string name;
std::set<std::string> in_kernel; // the parts it runs inside the kernel
};
void trace(const Design &design) {
std::println("{}:", design.name);
const auto &in_kernel = design.in_kernel;
int modes = 0;
int contexts = 0;
std::string current = PATH[0]; // the process whose address space is in use
for (std::size_t i = 0; i + 1 < PATH.size(); i++) {
const std::string &a = PATH[i];
const std::string &b = PATH[i + 1];
std::string how;
if (in_kernel.contains(a) && in_kernel.contains(b)) {
how = "function call";
} else if (in_kernel.contains(b)) {
how = "trap into the kernel";
modes += 1;
} else if (in_kernel.contains(a)) { // back to user mode, perhaps elsewhere
modes += 1;
if (b == current) {
how = "return to user mode";
} else {
how = "switch to its process";
contexts += 1;
current = b;
}
} else { // two processes: a message goes through the kernel
how = "message through the kernel";
modes += 2;
contexts += 1;
current = b;
}
std::println(" {} -> {}: {}", a, b, how);
}
const char *crash = in_kernel.contains("disk driver")
? "stops the whole kernel"
: "ends only its process";
std::println(" {} mode switches, {} context switches; a driver crash {}",
modes, contexts, crash);
}
int main() {
const std::vector<Design> designs = {
{"monolithic kernel", {"file system", "disk driver"}},
{"driver in user space", {"file system"}},
{"microkernel", {}},
};
for (const auto &design : designs) {
trace(design);
}
} In C++: std::set's contains, new in C++20, does the membership tests, and Design is a plain aggregate.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
string[] path = ["app", "file system", "disk driver", "file system", "app"]; // a read and its reply
(string Name, string[] InKernel)[] designs = // each design and the parts it runs inside the kernel
[
("monolithic kernel", ["file system", "disk driver"]),
("driver in user space", ["file system"]),
("microkernel", []),
];
foreach (var (name, inKernel) in designs)
{
Trace(name, inKernel, path);
}
static void Trace(string design, string[] inKernel, string[] path)
{
Console.WriteLine($"{design}:");
int modes = 0;
int contexts = 0;
string current = path[0]; // the process whose address space is in use
for (int i = 0; i + 1 < path.Length; i++)
{
var (a, b) = (path[i], path[i + 1]);
string how;
if (inKernel.Contains(a) && inKernel.Contains(b))
{
how = "function call";
}
else if (inKernel.Contains(b))
{
how = "trap into the kernel";
modes += 1;
}
else if (inKernel.Contains(a)) // back to user mode, perhaps in another process
{
modes += 1;
if (b == current)
{
how = "return to user mode";
}
else
{
(how, contexts, current) = ("switch to its process", contexts + 1, b);
}
}
else // two processes: a message goes through the kernel
{
how = "message through the kernel";
(modes, contexts, current) = (modes + 2, contexts + 1, b);
}
Console.WriteLine($" {a} -> {b}: {how}");
}
string crash = inKernel.Contains("disk driver")
? "stops the whole kernel"
: "ends only its process";
Console.WriteLine($" {modes} mode switches, {contexts} context switches;"
+ $" a driver crash {crash}");
} In C#: Each design is a named tuple; the tuple assignments update several counters at once.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
// one read and its reply
static const char *const PATH[] = {"app", "file system", "disk driver",
"file system", "app"};
typedef struct {
const char *name;
const char *in_kernel[2]; // the parts it runs inside the kernel
} Design;
static bool contains(const Design *d, const char *part) {
for (size_t i = 0; i < 2; i++) {
if (d->in_kernel[i] != NULL && strcmp(d->in_kernel[i], part) == 0) {
return true;
}
}
return false;
}
static void trace(const Design *d) {
printf("%s:\n", d->name);
int modes = 0;
int contexts = 0;
const char *current = PATH[0]; // the process whose address space is in use
for (size_t i = 0; i + 1 < sizeof PATH / sizeof PATH[0]; i++) {
const char *a = PATH[i];
const char *b = PATH[i + 1];
const char *how;
if (contains(d, a) && contains(d, b)) {
how = "function call";
} else if (contains(d, b)) {
how = "trap into the kernel";
modes += 1;
} else if (contains(d, a)) { // back to user mode, perhaps elsewhere
modes += 1;
if (strcmp(b, current) == 0) {
how = "return to user mode";
} else {
how = "switch to its process";
contexts += 1;
current = b;
}
} else { // two processes: a message goes through the kernel
how = "message through the kernel";
modes += 2;
contexts += 1;
current = b;
}
printf(" %s -> %s: %s\n", a, b, how);
}
const char *crash = contains(d, "disk driver") ? "stops the whole kernel"
: "ends only its process";
printf(" %d mode switches, %d context switches; a driver crash %s\n", modes,
contexts, crash);
}
int main(void) {
const Design designs[] = {
{"monolithic kernel", {"file system", "disk driver"}},
{"driver in user space", {"file system", NULL}},
{"microkernel", {NULL, NULL}},
};
for (size_t i = 0; i < sizeof designs / sizeof designs[0]; i++) {
trace(&designs[i]);
}
return 0;
} In C: Each design lists at most two parts in the kernel, with NULL for an empty place, and contains compares them with strcmp.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
const PATH = ['app', 'file system', 'disk driver', 'file system', 'app']; // a read and its reply
/** @param list<string> $inKernel the parts the design runs inside the kernel */
function trace(string $design, array $inKernel): void
{
echo "{$design}:\n";
$modes = 0;
$contexts = 0;
$current = PATH[0]; // the process whose address space is in use
for ($i = 0; $i + 1 < count(PATH); $i++) {
[$a, $b] = [PATH[$i], PATH[$i + 1]];
$aIn = in_array($a, $inKernel, true);
$bIn = in_array($b, $inKernel, true);
if ($aIn && $bIn) {
$how = 'function call';
} elseif ($bIn) {
$how = 'trap into the kernel';
$modes += 1;
} elseif ($aIn) { // back to user mode, perhaps in another process
$modes += 1;
if ($b === $current) {
$how = 'return to user mode';
} else {
[$how, $contexts, $current] = ['switch to its process', $contexts + 1, $b];
}
} else { // two processes: a message goes through the kernel
$how = 'message through the kernel';
[$modes, $contexts, $current] = [$modes + 2, $contexts + 1, $b];
}
echo " {$a} -> {$b}: {$how}\n";
}
$crash = in_array('disk driver', $inKernel, true)
? 'stops the whole kernel'
: 'ends only its process';
echo " {$modes} mode switches, {$contexts} context switches; a driver crash {$crash}\n";
}
trace('monolithic kernel', ['file system', 'disk driver']);
trace('driver in user space', ['file system']);
trace('microkernel', []); In PHP: in_array with true as its third argument compares strictly; a list assignment updates the counters together.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
const PATH: [&str; 5] = ["app", "file system", "disk driver", "file system", "app"]; // read, reply
/// Traces one read for a design; `in_kernel` lists the parts it runs inside the kernel.
fn trace(design: &str, in_kernel: &[&str]) {
println!("{design}:");
let (mut modes, mut contexts) = (0, 0);
let mut current = PATH[0]; // the process whose address space is in use
for pair in PATH.windows(2) {
let (a, b) = (pair[0], pair[1]);
let how = match (in_kernel.contains(&a), in_kernel.contains(&b)) {
(true, true) => "function call",
(false, true) => {
modes += 1;
"trap into the kernel"
}
(true, false) if b == current => {
modes += 1;
"return to user mode"
}
(true, false) => {
// back to user mode, in another process
modes += 1;
contexts += 1;
current = b;
"switch to its process"
}
(false, false) => {
// two processes: a message goes through the kernel
modes += 2;
contexts += 1;
current = b;
"message through the kernel"
}
};
println!(" {a} -> {b}: {how}");
}
let crash = if in_kernel.contains(&"disk driver") {
"stops the whole kernel"
} else {
"ends only its process"
};
println!(" {modes} mode switches, {contexts} context switches; a driver crash {crash}");
}
fn main() {
trace("monolithic kernel", &["file system", "disk driver"]);
trace("driver in user space", &["file system"]);
trace("microkernel", &[]);
} In Rust: windows(2) walks the request one hop at a time, and the match on two booleans names the four cases, with a guard for the return to the same process.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
const PATH = ["app", "file system", "disk driver", "file system", "app"]; // one read and its reply
const DESIGNS: [string, Set<string>][] = [
// each design and the parts it runs inside the kernel
["monolithic kernel", new Set(["file system", "disk driver"])],
["driver in user space", new Set(["file system"])],
["microkernel", new Set()],
];
function trace(design: string, inKernel: Set<string>): void {
console.log(`${design}:`);
let modes = 0;
let contexts = 0;
let current = PATH[0]; // the process whose address space is in use
for (let i = 0; i + 1 < PATH.length; i++) {
const [a, b] = [PATH[i], PATH[i + 1]];
let how: string;
if (inKernel.has(a) && inKernel.has(b)) {
how = "function call";
} else if (inKernel.has(b)) {
how = "trap into the kernel";
modes += 1;
} else if (inKernel.has(a)) {
// back to user mode, perhaps in another process
modes += 1;
if (b === current) {
how = "return to user mode";
} else {
how = "switch to its process";
contexts += 1;
current = b;
}
} else {
// two processes: a message goes through the kernel
how = "message through the kernel";
modes += 2;
contexts += 1;
current = b;
}
console.log(` ${a} -> ${b}: ${how}`);
}
const crash = inKernel.has("disk driver") ? "stops the whole kernel" : "ends only its process";
console.log(` ${modes} mode switches, ${contexts} context switches; a driver crash ${crash}`);
}
for (const [design, inKernel] of DESIGNS) trace(design, inKernel); In TypeScript: A tuple type pairs each design's name with its Set; everything else is the JavaScript program.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"slices"
)
// one read and its reply
var path = []string{"app", "file system", "disk driver", "file system", "app"}
// trace follows one read; inKernel lists the parts the design runs inside the kernel.
func trace(design string, inKernel []string) {
fmt.Printf("%s:\n", design)
modes, contexts := 0, 0
current := path[0] // the process whose address space is in use
for i := 0; i+1 < len(path); i++ {
a, b := path[i], path[i+1]
aIn, bIn := slices.Contains(inKernel, a), slices.Contains(inKernel, b)
var how string
switch {
case aIn && bIn:
how = "function call"
case bIn:
how = "trap into the kernel"
modes++
case aIn: // back to user mode, perhaps in another process
modes++
if b == current {
how = "return to user mode"
} else {
how, contexts, current = "switch to its process", contexts+1, b
}
default: // two processes: a message goes through the kernel
how, modes, contexts, current = "message through the kernel", modes+2, contexts+1, b
}
fmt.Printf(" %s -> %s: %s\n", a, b, how)
}
crash := "ends only its process"
if slices.Contains(inKernel, "disk driver") {
crash = "stops the whole kernel"
}
fmt.Printf(" %d mode switches, %d context switches; a driver crash %s\n",
modes, contexts, crash)
}
func main() {
trace("monolithic kernel", []string{"file system", "disk driver"})
trace("driver in user space", []string{"file system"})
trace("microkernel", nil)
} In Go: A tagless switch takes the cases in order, and slices.Contains does the membership tests.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
let path = ["app", "file system", "disk driver", "file system", "app"] // a read and its reply
/// Traces one read; `inKernel` holds the parts the design runs inside the kernel.
func trace(_ design: String, inKernel: Set<String>) {
print("\(design):")
var (modes, contexts) = (0, 0)
var current = path[0] // the process whose address space is in use
for (a, b) in zip(path, path.dropFirst()) {
let how: String
switch (inKernel.contains(a), inKernel.contains(b)) {
case (true, true):
how = "function call"
case (false, true):
how = "trap into the kernel"
modes += 1
case (true, false) where b == current:
how = "return to user mode"
modes += 1
case (true, false): // back to user mode, in another process
how = "switch to its process"
(modes, contexts, current) = (modes + 1, contexts + 1, b)
case (false, false): // two processes: a message goes through the kernel
how = "message through the kernel"
(modes, contexts, current) = (modes + 2, contexts + 1, b)
}
print(" \(a) -> \(b): \(how)")
}
let crash = inKernel.contains("disk driver") ? "stops the whole kernel" : "ends only its process"
print(" \(modes) mode switches, \(contexts) context switches; a driver crash \(crash)")
}
trace("monolithic kernel", inKernel: ["file system", "disk driver"])
trace("driver in user space", inKernel: ["file system"])
trace("microkernel", inKernel: []) In Swift: zip(path, path.dropFirst()) gives each hop, and the switch on two Booleans covers the four cases, with where for the same process.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
PATH = ['app', 'file system', 'disk driver', 'file system', 'app'].freeze # a read and its reply
# Follows one read through a design whose in_kernel parts run inside the kernel.
class Trace
def initialize(in_kernel)
@in_kernel = in_kernel
@modes = 0
@contexts = 0
@current = PATH.first # the process whose address space is in use
end
def run
PATH.each_cons(2) { |from, to| puts " #{from} -> #{to}: #{hop(from, to)}" }
crash = inside?('disk driver') ? 'stops the whole kernel' : 'ends only its process'
puts " #{@modes} mode switches, #{@contexts} context switches; a driver crash #{crash}"
end
private
def inside?(part) = @in_kernel.include?(part)
def hop(from, to)
return 'function call' if inside?(from) && inside?(to)
return trap if inside?(to)
return back_to_user(to) if inside?(from)
message(to)
end
def trap
@modes += 1
'trap into the kernel'
end
# Back to user mode, perhaps in another process.
def back_to_user(to)
@modes += 1
return 'return to user mode' if to == @current
switch_to(to)
'switch to its process'
end
# Two processes: a message goes through the kernel.
def message(to)
@modes += 2
switch_to(to)
'message through the kernel'
end
def switch_to(process)
@contexts += 1
@current = process
end
end
[
['monolithic kernel', ['file system', 'disk driver']],
['driver in user space', ['file system']],
['microkernel', []]
].each do |design, in_kernel|
puts "#{design}:"
Trace.new(in_kernel).run
end In Ruby: A small Trace class keeps the counters, so that each method stays as short as RuboCop asks, and each_cons(2) gives the hops.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
val path = listOf("app", "file system", "disk driver", "file system", "app") // a read and its reply
// Traces one read; inKernel holds the parts the design runs inside the kernel.
fun trace(
design: String,
inKernel: Set<String>,
) {
println("$design:")
var modes = 0
var contexts = 0
var current = path[0] // the process whose address space is in use
for ((a, b) in path.zipWithNext()) {
val how =
when {
a in inKernel && b in inKernel -> {
"function call"
}
b in inKernel -> {
modes += 1
"trap into the kernel"
}
a in inKernel -> {
// back to user mode, perhaps in another process
modes += 1
if (b == current) {
"return to user mode"
} else {
contexts += 1
current = b
"switch to its process"
}
}
else -> {
// two processes: a message goes through the kernel
modes += 2
contexts += 1
current = b
"message through the kernel"
}
}
println(" $a -> $b: $how")
}
val crash = if ("disk driver" in inKernel) "stops the whole kernel" else "ends only its process"
println(" $modes mode switches, $contexts context switches; a driver crash $crash")
}
fun main() {
trace("monolithic kernel", setOf("file system", "disk driver"))
trace("driver in user space", setOf("file system"))
trace("microkernel", emptySet())
} In Kotlin: zipWithNext gives the hops, and the when expression's branches return the label for each one.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
const path = [
'app',
'file system',
'disk driver',
'file system',
'app',
]; // a read and its reply
/// Traces one read; [inKernel] holds the parts the design runs inside the kernel.
void trace(String design, Set<String> inKernel) {
print('$design:');
var modes = 0;
var contexts = 0;
var current = path[0]; // the process whose address space is in use
for (var i = 0; i + 1 < path.length; i++) {
final (a, b) = (path[i], path[i + 1]);
final String how;
if (inKernel.contains(a) && inKernel.contains(b)) {
how = 'function call';
} else if (inKernel.contains(b)) {
how = 'trap into the kernel';
modes += 1;
} else if (inKernel.contains(a)) {
// back to user mode, perhaps in another process
modes += 1;
if (b == current) {
how = 'return to user mode';
} else {
how = 'switch to its process';
contexts += 1;
current = b;
}
} else {
// two processes: a message goes through the kernel
how = 'message through the kernel';
modes += 2;
contexts += 1;
current = b;
}
print(' $a -> $b: $how');
}
final crash = inKernel.contains('disk driver')
? 'stops the whole kernel'
: 'ends only its process';
print(
' $modes mode switches, $contexts context switches; a driver crash $crash',
);
}
void main() {
trace('monolithic kernel', {'file system', 'disk driver'});
trace('driver in user space', {'file system'});
trace('microkernel', {});
} In Dart: final String how is assigned exactly once on every path, which the compiler checks.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
monolithic kernel: app -> file system: trap into the kernel file system -> disk driver: function call disk driver -> file system: function call file system -> app: return to user mode 2 mode switches, 0 context switches; a driver crash stops the whole kernel driver in user space: app -> file system: trap into the kernel file system -> disk driver: switch to its process disk driver -> file system: trap into the kernel file system -> app: switch to its process 4 mode switches, 2 context switches; a driver crash ends only its process microkernel: app -> file system: message through the kernel file system -> disk driver: message through the kernel disk driver -> file system: message through the kernel file system -> app: message through the kernel 8 mode switches, 4 context switches; a driver crash ends only its process
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 10 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 154 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 20 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 716 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 191 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 11 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
The monolithic kernel served the read with 2 mode switches and no context switch, the design with the driver in user space needed 4 and 2, and the microkernel 8 and 4, one for each of its four messages. In return, only the monolithic kernel loses everything when the driver crashes. What one message costs decides how heavy those four are. This program measures a round trip between two processes through a pair of pipes:
import os
import time
ROUNDS = 20_000 # round trips timed in each run
RUNS = 3 # the best of 3 runs: other programs can only slow a run
def function_call(value: int) -> int:
return value + 1
def main() -> None:
to_child = os.pipe()
to_parent = os.pipe()
pid = os.fork()
if pid == 0: # the child sends every byte straight back
for _ in range(ROUNDS * RUNS):
os.write(to_parent[1], os.read(to_child[0], 1))
os._exit(0)
best_trip = best_call = float("inf")
for _ in range(RUNS):
start = time.perf_counter()
for _ in range(ROUNDS):
os.write(to_child[1], b"x") # a message to the other process ...
os.read(to_parent[0], 1) # ... and its answer
best_trip = min(best_trip, (time.perf_counter() - start) / ROUNDS)
start = time.perf_counter()
for i in range(ROUNDS):
function_call(i)
best_call = min(best_call, (time.perf_counter() - start) / ROUNDS)
os.waitpid(pid, 0)
print(f"a Python function call: {best_call * 1e9:.0f} ns")
print(f"a round trip to another process: {best_trip * 1e6:.1f} microseconds")
print(f"one round trip took as long as {best_trip / best_call:,.0f} function calls")
main() Output
a Python function call: 44 ns a round trip to another process: 42.3 microseconds one round trip took as long as 960 function calls
This output changes from run to run: the times depend on the machine and on what else it is running
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ipc_cost.py
The round trip took some tens of microseconds, hundreds of times a Python function call. Pipes in a general-purpose kernel are slow messengers, though, and kernels built around messages do far better. The seL4 whitepaper reports that performance has always been the hallmark of the L4 family it belongs to, with a round trip around 720 cycles on x86, and cites a comparison that puts seL4’s message cost about 10 to 20 % above the hardware’s own limit for entering the kernel, switching address spaces and leaving it.
Key idea
Moving a service out of the kernel buys isolation and a smaller trusted base, and costs a message, with its mode and context switches, on every request to that service.
Checkpoint
Check: Microkernels and the cost of messages
Hybrid, layered, exokernel and unikernel designs
The two poles leave room between and beyond them. CS2023 lists monolithic, layered, modular, microkernel and unikernel models among the core ideas every computer-science student should meet:
| Design | The idea | An example |
|---|---|---|
| Layered | Each level builds only on the levels below it | Dijkstra’s THE system: processor allocation at level 0, memory at 1, the console at 2, input and output at 3, user programs at 4 |
| Modular | A monolithic kernel that loads parts at run time | Linux and its loadable modules |
| Hybrid | A microkernel’s structure, with services kept in the kernel for speed | XNU, which its README calls a hybrid of the Mach kernel, components of FreeBSD and the IOKit driver framework |
| Exokernel | A minimal kernel that only multiplexes the hardware securely, with abstractions in libraries | The research exokernel of Engler, Kaashoek and O’Toole |
| Unikernel | One application compiled with the library operating system it needs, as one specialised image | MirageOS, whose unikernels run under a Xen or KVM hypervisor |
A hybrid behaves like a monolithic kernel wherever code runs inside it: a driver that crashes inside XNU’s kernel is a kernel crash. Exokernels and unikernels go the other way, moving abstractions out of the kernel into libraries linked with each application, so the protection boundary moves too: a unikernel relies on the hypervisor to keep applications apart.
Checkpoint
Check: Hybrid, layered, exokernel and unikernel designs
Choosing a design from what the system must do
No design wins everywhere, so the choice starts from the system’s requirements. Answer the four questions in the table below; each answer rules designs out or adds a caveat, with the reason and the lesson’s own numbers:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Design | Cost of a request | A driver crash | Privileged code | Hardware support |
|---|---|---|---|---|
| Monolithic (Linux, the BSDs) Runs in kernel mode: every service and driver | Good a read in the model: 2 mode switches, no context switch | Poor a driver runs in kernel mode, so its crash stops the system | Poor Linux: about 20 million lines, all of it privileged | Good drivers for a very wide range of hardware |
| Microkernel (seL4, MINIX 3) Runs in kernel mode: address spaces, threads, messages | Mixed a read in the model: 8 mode and 4 context switches; fast messages help | Good a driver is a process, so its crash ends only that process | Good seL4: about 10,000 lines, with a proof of correctness | Mixed fewer drivers, written as processes |
| Hybrid (XNU) Runs in kernel mode: Mach plus BSD services and drivers | Good busy services stay in the kernel, so requests stay cheap | Mixed drivers inside the kernel can still bring it down | Poor a large kernel of Mach, BSD parts and drivers | Good the platform's full set of drivers |
| Unikernel (MirageOS) Runs in kernel mode: one application and its library OS | Good the application and its library OS share one address space | Mixed one application per machine; the hypervisor keeps machines apart | Good only the library code the application links in | Poor a few virtual devices under a hypervisor such as Xen or KVM |
How the answers decide
- “Must the system keep running when a driver crashes?” Yes, a driver may fail without stopping it: rules out Monolithic (Linux, the BSDs) and adds a caveat to Hybrid (XNU). A crashing driver in kernel mode stops the whole system. A monolithic kernel runs every driver there; a hybrid runs many of them there too.
- “Must the privileged code be small enough to verify or audit?” Yes, it has to be proved or pass an audit: rules out Monolithic (Linux, the BSDs) and Hybrid (XNU). Millions of lines of privileged code cannot be verified. seL4's proof covers a kernel of about 10,000 lines, and a unikernel keeps the privileged code to what one application links.
- “Will the machine run many different programs?” Yes, many programs and users: rules out Unikernel (MirageOS). A unikernel is one application linked with the library operating system it needs; it does not run other programs.
- “Must it run on a wide range of off-the-shelf hardware?” Yes, whatever hardware users have: rules out Unikernel (MirageOS) and adds a caveat to Microkernel (seL4, MINIX 3). Unikernels run on a hypervisor's few virtual devices, and microkernels have fewer drivers than Linux or a vendor's own kernel.
- “Must the system keep running when a driver crashes?” No, a reboot is acceptable and “Must the privileged code be small enough to verify or audit?” No, testing is enough: adds a caveat to Microkernel (seL4, MINIX 3). Without a need for fault isolation or a small trusted base, the extra messages of a microkernel buy little.
Try two systems. A laptop answers no, no, yes and yes: the table rules out the unikernel, adds a caveat to the microkernel and leaves a monolithic or hybrid kernel. A controller that must keep running and whose kernel must pass a safety audit, on known hardware, answers yes, yes, no and no, and only the microkernel and the unikernel remain.
In short: a system that must survive a crashing driver, or prove its privileged code correct, points to a microkernel; one application on a hypervisor suits a unikernel; and many programs on whatever hardware users own favour a monolithic kernel such as Linux, or a hybrid such as XNU on Apple’s machines.
Checkpoint
Check: Choosing a design
Practise: load a module after the modules it needs
modprobe adds a module together with the modules it depends on, and each one must be loaded before the modules that use it. Write the order it needs, and catch the dependencies that can never be satisfied:
Exercise · Medium · Python
Load a module after the modules it needs
A tool such as modprobe adds a module together with the modules it depends on, and each one has to be in the kernel before any module that uses it. Write load_order(module, depends) in loader.py.
dependsmaps a module's name to the list of modules it needs; a module that is not independsneeds nothing.- Return the list of modules to load for
module, in order: every module after all the modules it needs, each module once, andmoduleitself last. Visit a module's needs in the order its list gives them. - If a module ends up needing itself, through any chain of dependencies, raise
ValueError: no order can work.
For example, with {"camera": ["video-core", "usb-core"], "video-core": ["media-core"]}, load_order("camera", ...) is ["media-core", "video-core", "usb-core", "camera"]. The names are invented for the exercise.
Starter code · loader.py
def load_order(module, depends):
"""The modules to load for module, each after the modules it needs, each once."""
# Replace this line with your code.
return [module] The sample tests · test_loader.py
from loader import load_order
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_the_example():
"""loads every dependency before the module that needs it"""
depends = {"camera": ["video-core", "usb-core"], "video-core": ["media-core"]}
assert load_order("camera", depends) == ["media-core", "video-core", "usb-core", "camera"]
def test_shared_dependency():
"""loads a module that two others need only once, before both"""
depends = {"vpn": ["net", "crypto"], "net": ["core"], "crypto": ["core"]}
assert load_order("vpn", depends) == ["core", "net", "crypto", "vpn"]
def test_no_dependencies():
"""loads a module that needs nothing on its own"""
assert load_order("solo", {}) == ["solo"]
assert load_order("solo", {"solo": []}) == ["solo"]
def test_order_of_needs():
"""follows the order in which a module lists its needs"""
assert load_order("x", {"x": ["b", "a"]}) == ["b", "a", "x"]
assert load_order("x", {"x": ["a", "b"]}) == ["a", "b", "x"]
def test_long_chain():
"""loads a chain from its far end"""
depends = {"d": ["c"], "c": ["b"], "b": ["a"]}
assert load_order("d", depends) == ["a", "b", "c", "d"]
def test_cycles():
"""refuses a module that needs itself, directly or through others"""
for depends in [{"a": ["a"]}, {"a": ["b"], "b": ["a"]}, {"a": ["b"], "b": ["c"], "c": ["a"]}]:
assert raises_value_error(lambda: load_order("a", depends)), f"{depends} should raise ValueError" A hint
Use a depth-first walk: before you add a module to the result, visit each module it needs. Keep a set of the modules you are in the middle of visiting; meeting one of them again means a cycle.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then try the lesson’s questions on kernel designs; the idea you know least well comes up first:
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
A warm-up comes next, with the answer an interviewer hopes to hear. The bank of this module goes further, asking whether a monolithic kernel with modules is a contradiction and which kernel suits a medical device.
-
Fresher · Warm-up
What is the difference between a monolithic kernel and a microkernel?
A monolithic kernel runs all of its services, the scheduler, memory management, file systems, the network stack and the drivers, in kernel mode in one address space; Linux and the BSDs work this way. Calls between services are cheap function calls, but every line is privileged, so a bug in any driver can crash the whole system. A microkernel keeps only the minimum in kernel mode, address spaces, threads, scheduling and message passing, and runs file systems and drivers as ordinary processes; seL4 and MINIX 3 are examples. A crashing driver then ends only its own process, and the privileged code is small, about 10,000 lines in seL4 against millions in Linux. The price is that every request to a service becomes messages between processes, so a microkernel lives or dies by how fast it makes them.
Key takeaways
- A monolithic kernel runs every service in one privileged address space: requests are cheap function calls, and a bug anywhere can crash the system; Linux has about 20 million such lines.
- Linux’s loadable modules are checked before they load (privilege, interface versions) but then run as ordinary kernel code; Rust adds memory safety inside the kernel, not isolation.
- A microkernel keeps about 10,000 lines privileged in seL4 and turns every service request into messages: 8 mode and 4 context switches for one read in the model, against 2 and none in a monolithic kernel.
- A pipe round trip between two processes cost some tens of microseconds, while seL4’s round trip is near the hardware’s limit, so the price of each message decides how much a microkernel pays.
- Hybrid, layered, exokernel and unikernel designs trade the same things differently, and the requirements of the system, not the fashion, should pick one.
Terms in this lesson
- inter-process communication (IPC)
- Ways for separate processes to exchange data, such as pipes, sockets, shared memory and messages, all set up or carried by the kernel.
- loadable module
- Kernel code, often a driver, compiled separately and loaded into a running kernel. Once loaded it runs in kernel mode with full privilege.
- microkernel
- A kernel that keeps only address spaces, threads, scheduling and message passing in kernel mode, and runs file systems and drivers as processes, as seL4 and MINIX 3 do.
- monolithic kernel
- A kernel that runs all its services, such as file systems, networking and drivers, in kernel mode in one address space, as Linux and the BSDs do.
References
- The seL4 Microkernel: An Introduction (whitepaper, revision 1.4.1) (The seL4 Foundation)
- User mode and kernel mode (Windows drivers) (Microsoft)
- Building external modules (kbuild) (The Linux Kernel Organization)
- init_module(2): load a kernel module (Linux man-pages project (man7.org))
- modprobe(8): add and remove modules from the Linux kernel (Linux man-pages project (man7.org))
- Rust in the Linux kernel, general information (The Linux Kernel Organization)
- Linux 6.1 release notes (Rust support) (KernelNewbies)
- XNU kernel source and README (Apple (open-source distributions))
- MINIX 3 (MINIX 3 project)
- The structure of the "THE"-multiprogramming system (EWD 196; Communications of the ACM 11(5), 1968) (E. W. Dijkstra Archive, The University of Texas at Austin)
- Exokernel: an operating system architecture for application-level resource management (SOSP 1995) (ACM (Engler, Kaashoek and O'Toole))
- MirageOS, a library operating system for unikernels (MirageOS project)
- Computer Science Curricula 2023: Operating Systems knowledge area (Version Gamma) (ACM, IEEE Computer Society and AAAI (CS2023 Task Force))
Report a problem with this lesson
Kept only in this browser. Your Learn progress