rust-kernel
Rustbelt: a hobby x86_64 kernel written in Rust
57781 bytes · raw
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 | # rust-kernel
A tiny hobby x86_64 kernel written in Rust, following the 2nd edition of
Philipp Oppermann's [Writing an OS in Rust](https://os.phil-opp.com/).
Right now it boots in QEMU, prints to the VGA text screen and the serial port
(COM1), handles CPU exceptions (breakpoint, page fault, double fault on its own
stack), runs the timer interrupt (uptime clock top-right), echoes what you
type on the PS/2 keyboard, walks and edits its own page tables, and has a
kernel heap (`Box`, `Vec`, `Rc` work) backed by a fixed-size-block allocator.
It multitasks two ways: an **async/await executor** (cooperative tasks; the
keyboard is one of them) and **preemptive kernel threads** switched by the timer
interrupt (threads A/B/C count on the top line while you type).
It runs **user programs in ring 3** (small Rust ELF binaries) as processes
with their own address spaces, which talk to the kernel via `syscall`
(about 20 calls, numbered like Linux: `read`, `write`, `open`, `stat`, `lseek`,
`getdents64`, `chdir`, `pipe`, plus our own `spawn` and `wait`...). Files live
behind a Linux-style **VFS**: an in-memory ramfs at `/` and `/tmp`, and a
**FAT32** partition on a second **virtio-blk** disk (found by **PCI**
enumeration, **GPT/MBR** partitions parsed) mounted at `/mnt/disk0`. The user
programs themselves are loaded from that disk.
It has a `cargo test` suite that runs inside QEMU.
Most code follows the blog_os sources (MIT / Apache-2.0) closely, with extra
comments; additions are the serial mirroring, uptime clock, demo features,
scripts, and everything past chapter 12: preemptive threads, user mode,
syscalls, ELF loading, the VFS, PCI, virtio-blk, partitions and FAT32.

## What's installed (on this box)
| Tool | Version | Why |
|---|---|---|
| rustup | 1.29.2 | toolchain manager (in `~/.cargo/bin`, ahead of Debian's `/usr/bin/cargo`) |
| Rust nightly | rustc 1.101.0-nightly (2026-10-29) | custom JSON targets + `build-std` are nightly-only |
| `rust-src` component | – | source of `core`, recompiled for our bare-metal target |
| `llvm-tools-preview` component | – | `llvm-objcopy` etc., used by `bootimage` |
| `bootimage` (cargo subcommand) | 0.10.5 | links kernel + bootloader into a bootable disk image |
| QEMU (`qemu-system-x86`, `qemu-system-gui`) | 10.0.13 | the emulator we boot in (`-gui` adds the GTK/SDL window) |
| `dosfstools`, `mtools`, `fdisk` | Debian packages | `build.rs` makes the FAT32 test disk with `sfdisk` + `mkfs.vfat` + `mmd`/`mcopy` |
`rust-toolchain.toml` pins the project to nightly with the two components, so
`cargo` inside this directory picks them up automatically. It also installs
the built-in `x86_64-unknown-none` target, used to compile the user programs.
Crates: `bootloader` 0.9.35 (BIOS bootloader used by the 2nd edition),
`volatile` 0.2, `spin` 0.5, `x86_64` 0.15, `uart_16550` 0.3, `lazy_static` 1.5,
`pic8259` 0.11 (interrupt controller), `pc-keyboard` 0.7 (scancode decoding),
`linked_list_allocator` 0.9 (fallback heap allocator), `fatfs` (git, `no_std` +
`alloc`; FAT12/16/32 reading and writing).
Fresh setup on another machine:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # rustup
rustup toolchain install nightly -c rust-src -c llvm-tools-preview -t x86_64-unknown-none
cargo install bootimage
sudo apt install qemu-system-x86 qemu-system-gui dosfstools mtools fdisk
```
## Build and run
```sh
cargo bootimage # build -> target/x86_64-rust_kernel/debug/bootimage-rust_kernel.bin
cargo run # build + boot in a QEMU window; serial output also prints in the terminal
```
**Trying the keyboard:** with `cargo run`, click into the QEMU window and type;
characters appear on the bottom line of the screen *and* in your terminal (via
serial). Press Enter and the `echo` user program reads the line with the
`read` syscall and prints `[echo] you typed: ...` (type `exit` to stop it). Shift works; Enter starts a new line; arrows/F-keys show as e.g.
`<ArrowUp>`; Backspace erases the previous character (on the current line). To get the mouse/keyboard back from QEMU press Ctrl+Alt+G; close the
window or press Ctrl+C in the terminal to quit. (Over SSH without X, use
`cargo run -- -display curses` to see the VGA screen right in your terminal;
quit from another terminal with `pkill qemu-system`.)
Headless (no window, good for SSH / scripts):
```sh
./scripts/run-headless.sh # boot, breakpoint, demos, ~5 s of threads + timer ticks, then QEMU exits (script exit 0)
./scripts/run-headless.sh stack-overflow # deliberately overflow the stack; passes if the double-fault handler catches it
./scripts/run-headless.sh page-fault # write to unmapped 0xdeadbeaf; passes if the page-fault handler catches it
cargo run --features headless -- -display none # same as the first, via cargo
cargo run --features headless,stack-overflow -- -display none # same as the second
# (QEMU's exit status is 33 = success; cargo just reports it as non-zero)
cargo run -- -display none # headless but kernel keeps running; Ctrl+C to stop
./scripts/screenshot.sh [out.png] [text] [expected]
# boot, "type" text via QEMU monitor `sendkey` ("<" = Backspace,
# "|" = Enter; default "Hello, kernex<l!|"), check serial shows the
# expected result ("[echo] you typed: Hello, kernel!"), save the
# VGA screen to boot.png; exits 1 if the text is missing
```
Cargo features (in `Cargo.toml`):
| Feature | Effect |
|---|---|
| `headless` | print a `.` per timer tick to serial and exit QEMU after 90 ticks (~5 s); panics exit with failure |
| `stack-overflow` | after boot, recurse forever to test the double-fault handler (with `headless`: exit success when caught) |
| `page-fault` | after reading CR3, write to unmapped address `0xdeadbeaf` (with `headless`: exit success when caught) |
Expected serial output of `./scripts/run-headless.sh` (stack frame dump and
some timer dots / thread lines trimmed):
```
Hello from Tyler's Rust kernel!
EXCEPTION: BREAKPOINT
... (breakpoint, paging and memory lines as before)
[heap] 4096 KiB heap at 0x444444440000 (fixed-size-block allocator)
... (heap demo lines)
[thread] spawned threads A, B, C (status on the top line)
[pci] 00:00.0 8086:1237 host bridge
[pci] 00:01.0 8086:7000 ISA bridge
[pci] 00:01.1 8086:7010 IDE controller
[pci] 00:01.3 8086:7113 bridge (other)
[pci] 00:02.0 1234:1111 display controller
[pci] 00:03.0 8086:100e network controller
[pci] 00:04.0 1af4:1001 SCSI storage (virtio-blk)
[pci] 7 devices on the PCI bus
[disk] vda: virtio-blk, 131072 sectors (64 MiB)
[disk] vda: GPT partition table, 1 partition(s)
[disk] vda1: start 2048 sectors 126976 type Microsoft basic data (FAT/exFAT/NTFS) name "fat32"
[disk] vda1: FAT32 volume "TYLERDISK" mounted at /mnt/disk0
[vfs] mounted ramfs at /
[vfs] mounted ramfs at /tmp
[vfs] mounted fat32 at /mnt/disk0
[process] started pid 1 (hello), entry 0x8000400180
Hello from user mode! I'm pid 1.
[hello] /hello.txt says: Hello from the ramfs! This is /hello.txt.
[hello] wrote and read back /tmp/note.txt
[hello] all syscall checks passed, exiting with 0
[process] pid 1 (hello) exited with code 0
[process] started pid 2 (evil), entry 0x80004000f0
[process] started pid 3 (spin), entry 0x80004000e0
[evil] pid 2: reading kernel memory at 0x200000...
[kernel] user page fault: address Ok(VirtAddr(0x200000)), error PageFaultErrorCode(PROTECTION_VIOLATION | USER_MODE)
[kernel] page fault in user mode at rip 0x800040017e
[process] pid 2 (evil) killed: page fault
[spin] pid 3: spinning without syscalls...
[process] started pid 4 (demo), entry 0x8000400320
[process] started pid 5 (echo), entry 0x8000400180
Type a line + Enter: the echo program (ring 3) reads it
[async] async number: 42
...
[demo] pid 4 started from /mnt/disk0/bin/demo
[demo] readme.txt from FAT32: Hello from the FAT32 disk! This file lives on a virtio-blk disk, in a GPT
[demo] /mnt/disk0/bin: hello evil spin echo demo child
[echo] pid 5: type a line and press Enter
[spin] round 1 done (count = 30000000)
[demo] mkdir/unlink/rmdir work on ramfs and FAT32
[process] started pid 6 (child), entry 0x8000400240
[process] pid 6 (child) exited with code 0
[demo] read from pipe: "hello through a pipe from pid 6"
[process] started pid 7 (child), entry 0x8000400240
[child] pid 7 got 3 args: "child" "with" "three args"
[process] pid 7 (child) exited with code 0
[process] started pid 8 (child), entry 0x8000400240
[process] pid 8 (child) exited with code 42
[spin] round 3 done (count = 90000000)
[process] pid 3 (spin) exited with code 0
[demo] spawned child 42 (pid 8), wait -> exit code 42
[process] started pid 9 (evil), entry 0x80004000f0
[evil] pid 9: reading kernel memory at 0x200000...
...
[process] pid 9 (evil) killed: page fault
[demo] all stage 6 checks passed, exiting with 0
[process] pid 4 (demo) exited with code 0
[thread C] done, exiting
.................. 90 ticks
[ok] timer works, exiting
[run-headless] kernel exited OK
```
Each `.` is a timer tick. Threads A and B only print their first 5 counts to
serial (so the keyboard echo in your terminal stays readable); after that they
keep counting on the VGA top line. (Exact interleaving and dot counts vary a
little from run to run -- that's preemption at work.) Spin's rounds interleave
with threads A/B/C and the demo: that's preemption of ring-3 code by the timer.
The `[pci]` lines only go to serial (the VGA screen gets the count).
The kernel boots from the bootimage on the first (IDE) disk; the scripts and
`Cargo.toml` attach a **second** disk, `target/disk.img`, as
`-drive file=target/disk.img,format=raw,if=virtio,snapshot=on`. `snapshot=on`
makes QEMU throw away writes when it exits, so every boot and every test sees
the same fresh image even though the kernel writes to it.
## Tests
```sh
cargo test # everything (lib unit tests + tests/*.rs), each booted in QEMU headless
cargo test --lib # just the unit tests inside src/
cargo test --test heap_allocation # one integration test
```
Each test binary is its own little kernel: `bootimage runner` boots it with the
`test-args` from `Cargo.toml` (no display, serial to stdout, isa-debug-exit) and
treats QEMU exit code 33 as a pass. Output looks like:
```
Running 5 tests
heap_allocation::large_allocation_uses_fallback... [ok]
heap_allocation::large_vec... [ok]
heap_allocation::many_boxes... [ok]
heap_allocation::many_boxes_long_lived... [ok]
heap_allocation::simple_allocation... [ok]
```
| Test | What it checks |
|---|---|
| lib (`src/*.rs` `#[test_case]`s) | trivial assertion, breakpoint handler returns, VGA `println!` writes the right cells |
| `tests/basic_boot.rs` | printing works straight after boot, before any `init()` |
| `tests/heap_allocation.rs` | Box, big Vec, 100 K short-lived boxes (memory reuse), same with one long-lived box, >2 KiB fallback allocation |
| `tests/should_panic.rs` | a failing `assert_eq!` reaches the panic handler (`harness = false`) |
| `tests/stack_overflow.rs` | stack overflow lands in the double-fault handler on the IST stack (`harness = false`) |
| `tests/executor.rs` | `SimpleExecutor` runs an async fn to completion; the waker-based `Executor` interleaves two tasks that `yield_now().await` (a1 b1 a2 b2 a3 b3) |
| `tests/user_mode.rs` | programs loaded from the FAT disk: `hello` exits 0, `demo` passes all its checks (exit 0), `evil` is killed with code 139, `spin` is preempted; spawn with arguments + trace + exit code 7, then reaped; a child's stdout into a pipe; spawn errors (`ENOENT`, `ENOEXEC`, `EISDIR`, `ECHILD`); ELF loader rejects garbage; user pointers give `EFAULT` (length 0 is fine); `sleep` blocks for the right number of ticks; a `WaitQueue` wakes its waiter |
| `tests/vfs.rs` | read `/hello.txt` to EOF, path normalisation (`.`, `..`, relative to a cwd), `ENOENT`/`ENOTDIR`/`EINVAL`/`EISDIR`/`EEXIST`, `/tmp` is a separate ramfs, longest-prefix mounts, sparse writes zero-fill, listing, remove (`ENOTEMPTY`, `EBUSY` on mount points), seek/stat/truncate, `getdents64` record layout, pipes pass bytes and report EOF / `EPIPE` |
| `tests/block.rs` | PCI scan finds the virtio disk; sector 1 of the image is the GPT header; multi-sector reads equal single-sector reads; write + read back; out-of-range and partial-sector requests fail; the GPT partition is registered as `vda1` (with a FAT32 boot sector); MBR parsing on a RAM disk |
| `tests/fat.rs` | the FAT32 volume is mounted at `/mnt/disk0` with the expected files; read `readme.txt`; every program in `bin/` is a valid ELF; create/write/read/truncate/remove a 10 KB file; directories with long names, `ENOTEMPTY`; the `BlockIo` adapter handles unaligned reads and writes |
| `tests/threads.rs` | two busy-looping threads that never yield both make progress while the main thread busy-waits too (only possible with preemption); a thread can exit and the others keep running |
How it works: `#![feature(custom_test_frameworks)]` collects every `#[test_case]`
function and passes them to `rust_kernel::test_runner` (in `src/lib.rs`), which
prints each name to serial, runs it, prints `[ok]`, then exits QEMU with success.
A panic goes to `test_panic_handler`, which prints `[failed]` and exits with failure.
Extra args after `--` in `cargo run -- ...` are appended to the QEMU command
line, e.g. `cargo run -- -d int -no-reboot` to log every interrupt and stop on
triple faults, or `cargo run -- -s -S` to wait for `gdb` on port 1234.
## Project layout
```
.cargo/config.toml build-std (core, alloc) + custom target + scripts/runner.sh as the cargo runner
rust-toolchain.toml pin nightly + rust-src + llvm-tools-preview
x86_64-rust_kernel.json custom target: no OS, no red zone, soft-float, abort on panic, rust-lld
Cargo.toml deps, features, test settings, QEMU run/test args
src/lib.rs the kernel library: module list, init(), kprintln!, test framework, exit_qemu
src/main.rs the bootable binary: entry_point!(kernel_main), paging + heap demos, panic handler
src/gdt.rs GDT (kernel + user segments) + TSS (double-fault IST stack, rsp0 per thread)
src/interrupts.rs IDT; breakpoint / page fault / GPF / invalid opcode / double fault handlers
(faults from ring 3 kill the process); 8259 PIC;
timer handler (uptime, ticks, calls the thread scheduler),
keyboard handler (just queues the scancode)
src/memory.rs OffsetPageTable setup from CR3, example mapping, BootInfoFrameAllocator,
global frame allocator, per-process address spaces, user pointer checks
src/allocator.rs heap location/size, init_heap (maps heap pages), #[global_allocator], Locked<A>
src/allocator/bump.rs bump allocator (not active, for learning)
src/allocator/fixed_size_block.rs fixed-size-block allocator (ACTIVE), falls back to linked_list_allocator
src/task/mod.rs async: Task (pinned boxed future + id), yield_now()
src/task/simple_executor.rs first executor: polls every task in a loop (no wakers)
src/task/executor.rs real executor: task queue + wakers, sleeps with hlt when idle
src/task/keyboard.rs lock-free scancode queue, ScancodeStream, async keyboard echo task
src/thread.rs preemptive threads: spawn, round-robin schedule(), asm context switch,
per-thread kernel stack (TSS.rsp0) + CR3 switching
src/syscall.rs syscall/sysret MSR setup, asm entry stub, dispatch + all syscalls (table at the top)
src/process.rs Process (pid, parent, cwd, args, fd table, exit status), spawn from a path or
ELF bytes, iretq into ring 3, exit/kill, wait (reaping)
src/pci.rs PCI bus scan through config ports 0xCF8/0xCFC, device names
src/block/mod.rs BlockDevice trait, device registry (vda, vda1...), RamDisk (for tests)
src/block/virtio_blk.rs legacy virtio-blk driver: one virtqueue, polled, bounce buffer
src/block/partition.rs MBR + GPT parsing, Partition = a BlockDevice window onto its disk
src/elf.rs minimal ELF64 loader (PT_LOAD segments -> user pages)
src/errno.rs Linux errno numbers
src/tty.rs keyboard line buffer + Console (the File behind fds 0/1/2)
src/fs/mod.rs VFS: Inode + FileSystem traits, mount table, path normalize/lookup/create/remove,
init (boot files), mount_disks (FAT partitions -> /mnt/diskN)
src/fs/file.rs File trait (what an fd points to), InodeFile (inode + offset), Stat, getdents64
src/fs/ramfs.rs in-memory filesystem
src/fs/fat.rs FAT32 via the fatfs crate: BlockIo adapter + FatFs/FatInode for the VFS
src/fs/pipe.rs pipes: a byte queue with a blocking read end and a write end
build.rs compiles user/*.rs to ELF files, then builds target/disk.img (GPT + FAT32)
disk/ files copied onto the FAT32 disk (readme.txt, notes/todo.txt)
user/ulib.rs tiny user-space "libc": syscall wrappers, println!, _start, panic handler
user/link.ld linker script for user programs (base 0x80_0040_0000)
user/{hello,evil,spin,echo,demo,child}.rs the user programs (copied to /mnt/disk0/bin)
src/vga_buffer.rs VGA text-mode writer, print!/println!, print_at (status line), backspace
src/serial.rs COM1 UART writer + serial_print!/serial_println!
tests/ integration tests, one QEMU-booted kernel each
scripts/runner.sh `cargo run`/`cargo test` runner: `bootimage runner` + stale-image fix
scripts/run-headless.sh headless boot with timeout + exit-code check
scripts/screenshot.sh headless boot + typed keys + PNG screenshot via QEMU monitor
boot.png screenshot of the VGA screen
```
## What the interrupt code does (chapters 5-7)
- **CPU exceptions / IDT** (`interrupts.rs`): the IDT maps vector numbers to handler
functions written with `extern "x86-interrupt"` (nightly `abi_x86_interrupt`), so
the compiler saves all registers and returns with `iretq`. `_start` fires an
`int3` on purpose; the breakpoint handler prints the stack frame and execution
continues. A page-fault handler prints the faulting address from CR2.
- **Double faults** (`gdt.rs`): a stack overflow hits the guard page -> page fault ->
the CPU can't push the exception frame on the broken stack -> double fault ->
same again -> *triple fault* = machine reset. We load our own GDT containing a
TSS whose Interrupt Stack Table entry 0 points at a separate 20 KiB static stack,
and mark the double-fault IDT entry to use it. Try `./scripts/run-headless.sh stack-overflow`.
- **Hardware interrupts**: the two 8259 PICs are remapped to vectors 32-47 (0-31 are
CPU exceptions) and then `sti` enables interrupts. The timer (IRQ 0, ~18.2 Hz)
bumps an atomic tick counter and redraws the uptime clock; the keyboard (IRQ 1)
reads the scancode from port 0x60 and queues it; an async task decodes it with
`pc-keyboard` (US layout, scancode set 1) and echoes it (see Multitasking below). Each handler must send End-Of-Interrupt to the
PIC or no further interrupts arrive. `print!`/`serial_print!` disable interrupts
while holding their lock, otherwise an interrupt handler printing while
`_start` holds the lock would deadlock. The idle loop uses `hlt`, so QEMU
doesn't burn a host CPU core.
## Memory (chapters 8-11)
**Paging.** Every address the kernel uses is *virtual*. The CPU translates it with
a 4-level page table tree (level 4 -> 3 -> 2 -> 1 -> 4 KiB page); 9 bits of the
address index each level, the low 12 bits are the offset in the page. CR3 holds the
*physical* address of the level-4 table (here `0x1000`). The `page-fault` feature
writes to `0xdeadbeaf`, which no table maps, and the page-fault handler reports
the address (from CR2) and the reason (`CAUSED_BY_WRITE`, not `PROTECTION_VIOLATION`
-> the page simply isn't present).
**Reaching page tables.** Page tables contain physical addresses, but we can only
dereference virtual ones. With the bootloader's `map_physical_memory` feature, all of
physical RAM is also mapped at `physical_memory_offset` (`0x18000000000` here), so
`virt = offset + phys`. `memory::init` wraps that in the x86_64 crate's
`OffsetPageTable`, which can `translate_addr` (walk the tables) and `map_to` (add entries).
**Virtual memory layout** (as set up by bootloader 0.9 in this build):
| Virtual address | What |
|---|---|
| `0xb8000` | VGA text buffer (identity-mapped: virt = phys) |
| `0x200000`... | kernel code + data (the ELF; maps to phys around `0x400000`) |
| `0x0100_0000_0000`... | kernel stack (80 pages, guard page below it) |
| `0x0180_0000_0000`... | all physical memory (`physical_memory_offset`) |
| `0x4444_4444_0000` | kernel heap, 4 MiB (also holds thread stacks) (we map it in `allocator::init_heap`) |
| `0xdead_beaf_000` | demo page we map to the VGA frame (writes "New!" on screen) |
The "New!" demo writes to screen row 20 through the new mapping; by the time you look,
later lines have scrolled it up, so in `boot.png` it overwrote the start of the first line.
**Physical frames.** `BootInfoFrameAllocator` hands out 4 KiB frames from the regions
the BIOS memory map marks `Usable` (~121 MiB of QEMU's default 128 MiB). It never frees;
fine for now.
**Heap.** `allocator::init_heap` maps the 1024 pages (4 MiB) of `HEAP_START..+HEAP_SIZE` to fresh
frames, then gives the range to the `#[global_allocator]`. From then on the `alloc`
crate (`Box`, `Vec`, `Rc`, `String`, `BTreeMap`...) works. `alloc` is compiled for our
target by `build-std` in `.cargo/config.toml`.
**Allocator designs** (`src/allocator/`): the *bump* allocator just moves a pointer
forward and can only reclaim memory when every allocation is freed (it fails the
`many_boxes_long_lived` test). The `linked_list_allocator` crate keeps a list of free
regions (first fit, slower, but reuses any freed memory). The active
*fixed-size-block* allocator rounds each request up to 8/16/.../2048 bytes and keeps
one free list per size, so alloc/free are O(1) pops/pushes; bigger requests and empty
lists fall back to the linked-list allocator. Switch allocators by editing one line in
`src/allocator.rs`.
## Multitasking (async/await + threads)
### Cooperative: async/await (`src/task/`, phil-opp chapter 12)
An `async fn` compiles into a state machine (a `Future`). An *executor* calls
`poll` on it; the future runs until it has to wait, then returns `Pending` and
remembers a `Waker`. Whoever makes progress possible later (here: the keyboard
interrupt) calls `waker.wake()`, which puts the task's id back into the
executor's queue. When the queue is empty, the executor `hlt`s -- carefully:
it disables interrupts, checks the queue, and then uses `sti; hlt` as one step
(`enable_and_hlt`), so a wakeup arriving in between can't be missed.
The keyboard interrupt handler now does almost nothing: read the scancode from
port 0x60 and push it into a fixed-size lock-free queue (`crossbeam_queue::ArrayQueue`),
then wake the keyboard task (`futures_util::task::AtomicWaker`). No locks in the
interrupt handler means no deadlock if the interrupt arrives while the task
holds one. The task (`print_keypresses`) is an `async` loop over a
`ScancodeStream` that decodes and echoes characters.
"Cooperative" means a task only gives up the CPU at an `.await`. A task that
loops forever without awaiting freezes every other task. Upside: switching is
cheap (just return from `poll`), and all tasks share one stack.
### Preemptive: kernel threads (`src/thread.rs`, beyond the blog series)
Each thread has its own 64 KiB stack (a `Vec<u8>` on the heap). On every timer
interrupt (~18 times a second) the handler sends the EOI to the PIC and then
calls `thread::schedule()`, which picks the next thread round-robin and calls
`switch_context(&mut old.rsp, new.rsp)` (a dozen lines of `global_asm!`):
```
push rbp, rbx, r12, r13, r14, r15 ; callee-saved regs of the OLD thread, onto its stack
mov [rdi], rsp ; remember where the old stack ends
mov rsp, rsi ; ---- now on the NEW thread's stack ----
pop r15, r14, r13, r12, rbx, rbp ; its callee-saved regs
ret ; return to wherever IT called switch_context
```
Why only those six registers? `switch_context` is called like a normal
function, so the compiler already saved the caller-saved registers, and the
`x86-interrupt` handler around it saved everything the interrupted code was
using (plus the CPU pushed RIP/RSP/RFLAGS). So a paused thread's complete
state sits on its own stack; the scheduler only stores its `rsp`. The new
thread "returns" from `schedule()`, from its timer handler, and `iretq`s back to
where it was interrupted.
A brand-new thread was never interrupted, so `spawn` hand-builds a stack that
looks as if `switch_context` had saved it: six register slots (r12 = the
thread's function) and a return address pointing to `thread_trampoline`, which
moves r12 into rdi and calls `thread_start(entry)`. That re-enables interrupts
(we arrive from inside the timer interrupt, where they're off), runs the
function, and marks the thread finished when it returns. The code that booted
the kernel becomes thread 0, "main", which runs the async executor.
Preemption means even `loop {}` can't hog the CPU (thread C in the demo is a
pure busy loop). The price: any code can be interrupted anywhere, so shared
data needs locks, and a lock that the timer handler might also want must be
taken with interrupts disabled. Here: the print macros and `print_at` already
hold their spinlocks with interrupts off (`without_interrupts`), so a thread
can't be switched away while holding them; the scheduler lock is only taken
with interrupts off and is released *before* switching (the next thread would
otherwise wait forever for a lock held by a paused thread).
**The locking rule:** a lock that is *ever* taken with interrupts off must
*always* be taken with interrupts off. Otherwise: thread T takes the lock with
interrupts on, gets preempted, and some code running with interrupts off (so
it can never be preempted back to T) spins on that lock forever. Stage 6 hit
exactly this with the heap allocator: wait-queue code runs with interrupts off
and allocates, so `GlobalAlloc::alloc`/`dealloc` now take the heap lock inside
`without_interrupts`.
**Blocking (stage 6).** A thread can be *blocked*: the scheduler skips it until
someone wakes it. Two ways to be woken:
- `sleep_ticks(n)` sets `wake_at = now + n`; `schedule()` (run on every timer
tick) unblocks it once that tick has passed (the `sleep_ms` syscall rounds
up to 55 ms ticks).
- A `WaitQueue` (a list of waiting thread ids). `wait_until(|| condition)`
turns interrupts off, checks the condition, and if it's false puts the
thread on the queue, marks it blocked and switches away; when woken it checks
again. `wake_all()` unblocks everyone on the queue. Checking with interrupts
off is what prevents a *lost wakeup* (the event firing between "check" and
"sleep"). Users: the console (`read(0)` waits for Enter; the keyboard path
wakes it), pipes (reader waits for data or EOF) and `wait(pid)` (parent
waits on the child's `exited` queue).
If every thread is blocked, the scheduler idles with `sti; hlt` until an
interrupt makes something runnable.
**Stack size and the canary.** Stage 6 first used 16 KiB stacks and crashed
with a ring-0 jump to address 0: the syscall -> VFS -> FAT -> ELF loader path in
an unoptimized debug build used more than that, overflowed into the heap and
smashed a neighbour. Now stacks are 64 KiB, and the lowest 8 bytes of each
hold a canary value (`0x5AFE_57AC_C0DE_CAFE`). `schedule()` checks it on every
switch and panics with "kernel stack overflow in thread" if it changed: a
cheap stand-in for a real guard page. Finished threads' stacks are freed (the
next `spawn` drops them, since a thread can't free the stack it's running on).
Known simplifications (good exercises): no guard page under thread stacks (the
canary only catches overflows after the fact); no priorities; a single CPU,
so `spin::Mutex` is enough.
## User mode, syscalls and processes
Six tiny Rust programs in `user/` run in **ring 3** as separate processes, each
with its own address space and file-descriptor table:
| Program | What it does |
|---|---|
| `hello` | prints "Hello from user mode!", `getpid`, reads `/hello.txt` from the ramfs, creates and reads back `/tmp/note.txt`, checks error cases (`ENOENT`, `EBADF`, `EFAULT`, `ENOSYS`), exits with the number of failed checks (0) |
| `evil` | reads kernel memory at `0x200000` -> page fault in ring 3 -> the kernel kills it and keeps going |
| `spin` | busy-loops ~2 s with no syscalls; it still gets preempted (threads A/B/C keep printing) |
| `echo` | `read(0)` a line from the keyboard (the thread is blocked until Enter), prints `[echo] you typed: ...`; type `exit` to end it |
| `demo` | stage 6 checks: cwd, reads `/mnt/disk0/readme.txt`, `stat`/`fstat`/`lseek`, lists `bin/` with `getdents64`, `chdir` errors, `mkdir`/`unlink`/`rmdir` on ramfs and FAT, a pipe from a child, arguments, `wait` exit codes (42, 139 for killed `evil`), `ECHILD`/`ENOENT`/`ENOEXEC`, `sleep_ms`; exits with the number of failed checks |
| `child` | helper for `demo` and tests: `child pipe` writes a line to stdout, `child cat` copies stdin to stdout, `child 42` exits with 42, otherwise prints its arguments |
The kernel starts them all from `/mnt/disk0/bin/` -- they're read from the FAT32
partition on the virtio disk, not compiled into the kernel any more.
### Rings in one paragraph
The CPU's *current privilege level* (CPL) is the low 2 bits of the CS register:
0 = kernel, 3 = user. In ring 3 the CPU refuses privileged instructions (`hlt`,
`cli`, writing CR3, `in`/`out`...) and refuses to touch any page whose page-table
entries lack the `USER_ACCESSIBLE` bit. User code can only get back into ring 0
through doors the kernel set up: interrupts/exceptions (via the IDT) and the
`syscall` instruction (via the LSTAR MSR). Every such door switches to a kernel
stack, so a user program can't corrupt kernel state through its own stack pointer.
### GDT and TSS (`src/gdt.rs`)
```
0x00 null
0x08 kernel code (ring 0) 0x10 kernel data (ring 0)
0x18 user data (ring 3) |3 0x20 user code (ring 3) |3
0x28 TSS (16 bytes: two slots)
```
The odd order (user *data* before user *code*) is forced by `sysret`: it loads
SS = STAR[63:48] + 8 and CS = STAR[63:48] + 16, so the two user segments must
be adjacent in that order. The TSS has two jobs: IST[0] is the double-fault
stack (stage 2), and **`rsp0`** is the stack the CPU switches to when an
interrupt arrives while in ring 3. Each thread has its own kernel stack, so the
scheduler writes the next thread's stack top into `TSS.rsp0` on every switch
(`gdt::set_kernel_stack`).
### The syscall path (`src/syscall.rs`)
`syscall` was chosen over `int 0x80`: it's what Linux uses on x86_64, it's
faster, and the setup is only four MSRs. The catch is that, unlike an
interrupt, `syscall` does **not** switch stacks -- the entry code must.
Setup (`syscall::init`): `EFER.SCE = 1` (enable), `STAR` = the selectors above,
`LSTAR` = address of `syscall_entry`, `SFMASK = IF|DF|TF` (so we enter with
interrupts off until we're on a kernel stack).
One call, step by step:
1. User code puts the number in `rax`, args in `rdi, rsi, rdx` (Linux ABI; we
also save `r10, r8, r9`) and executes `syscall`.
2. CPU: `rcx` = user RIP, `r11` = user RFLAGS, CS/SS = kernel, RFLAGS &= ~SFMASK,
RIP = LSTAR. RSP is still the *user* stack.
3. `syscall_entry` (asm): stash user RSP in a global, load this thread's kernel
stack top (set by the scheduler), push user RSP, `rcx`, `r11` and the args.
4. Shuffle into the C ABI and call `dispatch(nr, a1..a5)` in Rust with
interrupts re-enabled (so a long syscall, e.g. a blocking `read`, can be preempted).
5. `dispatch` validates user pointers (`user_slice`: range must be inside the
user half *and* mapped with `USER_ACCESSIBLE` in the current page table),
runs the call, and returns a value or `-errno` in `rax`.
6. `cli`, pop everything back, restore user RSP, `sysretq` (RIP = rcx,
RFLAGS = r11, CPL = 3).
Single CPU, so the "current kernel stack" and "saved user RSP" are plain
globals; SMP kernels use per-CPU data reached with `swapgs`.
Numbers are Linux's wherever Linux has the same call, so man pages and
`strace` habits mostly apply. Args 4 and 5 go in `r10` and `r8` like Linux.
| nr | name | args | notes |
|---|---|---|---|
| 0 | `read` | fd, buf, len | console: blocks until Enter; pipe: blocks until data or EOF |
| 1 | `write` | fd, buf, len | console = VGA + serial; pipe: `EPIPE` once the reader is gone |
| 2 | `open` | path, path_len, flags | `O_CREAT` 0o100, `O_TRUNC` 0o1000; directories can be opened (for `getdents64`) |
| 3 | `close` | fd | |
| 4 | `stat` | path, path_len, *Stat | |
| 5 | `fstat` | fd, *Stat | |
| 8 | `lseek` | fd, offset, whence | `SEEK_SET/CUR/END` = 0/1/2; `ESPIPE` on console/pipes |
| 22 | `pipe` | *[u32; 2] | [read end, write end] |
| 24 | `sched_yield` | | |
| 35 | `sleep_ms` | ms | **ours**: Linux nr 35 is `nanosleep(timespec)` |
| 39 | `getpid` | | |
| 60 | `exit` | code | |
| 61 | `wait` | pid | **simpler than Linux `wait4`**: blocks, returns the exit code (139 = killed, like a shell shows SIGSEGV), reaps the child; `ECHILD` if not your child |
| 79 | `getcwd` | buf, len | returns the length (no NUL); `ERANGE` if too small |
| 80 | `chdir` | path, path_len | per-process cwd, inherited by spawned children |
| 83 | `mkdir` | path, path_len | |
| 84 | `rmdir` | path, path_len | empty directories only (`ENOTEMPTY`); `EBUSY` on a mount point |
| 87 | `unlink` | path, path_len | files only (`EISDIR`) |
| 217 | `getdents64` | fd, buf, len | real Linux `struct linux_dirent64` records (ino, off, reclen, type, name\0) |
| 500 | `spawn` | path, path_len, argv, argv_len, *SpawnOptions | **ours**: start a program, returns its pid |
Deviations from Linux worth knowing:
- **Paths are pointer + length**, not NUL-terminated C strings (easier to
validate, and Rust `&str`s aren't NUL-terminated). Relative paths start at
the process's cwd; `.` and `..` are resolved in the kernel (`fs::normalize`).
- **`Stat` is tiny**: `#[repr(C)] struct Stat { kind: u64, size: u64 }`, kind
1 = file, 2 = directory, 3 = console, 4 = pipe. No inode numbers, owners,
modes or times yet.
- **spawn instead of fork + exec.** `fork` copies the whole address space (or
needs copy-on-write page tables to be fast) and `exec` immediately throws the
copy away. `spawn` does the useful part in one step, like Windows
`CreateProcess` or POSIX `posix_spawn`. What Unix shells do between fork and
exec -- pointing fds 0/1/2 at a pipe or a file -- is spawn's `SpawnOptions`:
`{ stdin, stdout, stderr: i64 (one of *my* fds, -1 = same as mine), flags }`.
`flags = SPAWN_TRACE` (1) prints every syscall the child makes, as
`[trace pid] name(a1, a2, a3) = result` -- that's what the shell's
`run --trace` will use. fork + exec could still be added later.
- **argv** is passed as one buffer with NUL-separated arguments
(`"child\0with\0three args"`; empty = just the program name). The kernel
copies the strings onto the new process's stack and starts `_start` with
`rdi = argc`, `rsi = pointer to argc [ptr, len] pairs` (Rust-friendly
slices instead of C's `char **argv`). `ulib` wraps that as `argc()`/`arg(i)`.
Errors are negative errno values like Linux (`-2` ENOENT, `-9` EBADF, `-14`
EFAULT, `-38` ENOSYS...). Unknown numbers return `-ENOSYS`.
### Address spaces (`src/memory.rs`, `src/process.rs`)
Each process gets a fresh level-4 page table. The kernel's L4 entries are
**copied** into it (so the kernel, heap, physical-memory map and thread stacks
are mapped in every address space and the kernel keeps working after a CR3
switch), and L4 slot 1 is the process's own:
```
0x0000_0000_0020_0000 kernel code (bootloader 0.9 puts it here) shared, ring 0 only
0x0000_0080_0000_0000 USER_START (L4 slot 1) per process
0x0000_0080_0040_0000 program (.text R-X, .rodata R--, .data/.bss RW-)
0x0000_00ff_ffff_b000 user stack (16 KiB, RW-, grows down)
0x0000_0100_0000_0000 USER_END
0x0000_0100_0020_0000 boot stack (L4 slot 2) shared
0x0000_0180_0000_0000 all physical memory (phys_mem_offset, slot 3) shared
0x0000_4444_4444_0000 kernel heap (thread kernel stacks live here) shared
```
Real kernels put the kernel in the upper half (`0xffff_8000_...`) and give
user space the whole lower half. With bootloader 0.9 the kernel sits low, so
here user space is one 512 GiB slot that the kernel doesn't use. Only user
pages have `USER_ACCESSIBLE`, which is why `evil` faults: the page at
`0x200000` *is* mapped, just not for ring 3 (`PROTECTION_VIOLATION | USER_MODE`).
`.text` is not writable and everything else is `NO_EXECUTE` (when EFER.NXE is on).
A **process** = a kernel thread (`thread.rs`, it already has a kernel stack and
gets preempted) + a CR3 value + a `Process` struct (pid, name, fd table, exit
status). The scheduler, on each switch, sets `TSS.rsp0` and the syscall kernel
stack to the next thread's stack and writes CR3 if it differs. A new process's
thread starts in the kernel, then `iretq`s into ring 3 with a hand-built frame
(SS=user data, RSP=user stack, RFLAGS=IF, CS=user code, RIP=ELF entry).
When ring-3 code faults (page fault with `USER_MODE`, general protection fault
or invalid opcode with CS RPL 3), the handler calls `process::exit_current`
instead of panicking: the process is marked `Killed`, its thread exits, the
scheduler picks someone else. A fault in ring 0 still panics -- that's a kernel bug.
### ELF loading and building user programs (`src/elf.rs`, `build.rs`, `user/`)
`build.rs` compiles each `user/*.rs` with plain `rustc` for the built-in
`x86_64-unknown-none` target (no build-std needed: it ships a precompiled
`core` and uses soft-float, so no SSE state to save). Flags worth knowing:
`-C relocation-model=static` (no dynamic loader), `-C code-model=large`
(the program lives above 4 GiB; the default model assumes 32-bit addresses
and fails with "relocation overflow"), `-T user/link.ld` (one PT_LOAD
segment per permission, page-aligned). The large code model names its
sections `.ltext`/`.lrodata`/`.ldata`/`.lbss`, so the linker script lists
those too -- forgetting that put the code into a non-executable segment and
every program died at its first instruction. The ELFs (~15-20 KB) used to be
embedded in the kernel with `include_bytes!`; since stage 6 `build.rs` copies
them onto the FAT32 disk image instead and `process::spawn_path` reads them
through the VFS (see "Disks" below).
`user/ulib.rs` is a tiny libc-equivalent included by each program: the
`syscall` inline asm, wrappers, `print!/println!`, `_start` (calls `main`,
then `exit` with its return value) and a panic handler (`exit(101)`).
The loader (~160 lines, no crate) checks the header (magic, 64-bit, little
endian, x86_64, `ET_EXEC`), and for every `PT_LOAD` segment: checks it's
inside the user range, maps zeroed frames with flags from the segment
(`W` -> writable, no `X` -> NX), copies `filesz` bytes through the
physical-memory map and leaves the rest (`.bss`) zero.
### VFS (`src/fs/`) and file descriptors
Designed like Linux's so real filesystems can plug in later:
- `trait Inode` (`fs/mod.rs`): a file or directory: `kind`, `size`,
`read_at(offset, buf)`, `write_at`, `truncate`, `lookup(name)`,
`create(name, kind)`, `list` (names + kinds), `remove(name)`.
Methods have default impls returning an error, so a read-only fs implements
only what it supports.
- `trait FileSystem`: `name()` and `root() -> Arc<dyn Inode>`. A disk filesystem
will hold its block device and parsed superblock.
- **Mount table**: `mount("/tmp", fs)`; `lookup(path)` picks the longest mount
point that prefixes the path, then walks the rest with `Inode::lookup`.
Boot mounts a ramfs on `/` (`/hello.txt`, `/etc/motd`, `/tmp`, `/mnt`), a
second, separate ramfs on `/tmp`, and each FAT partition at `/mnt/diskN`.
`fs::normalize(cwd, path)` turns any path into a clean absolute one first.
- `trait File` (`fs/file.rs`): what a file descriptor points to: `read(buf)`,
`write(buf)`, `seek`, `stat`, `getdents`. `InodeFile` = an inode + its own offset (two opens of the same
file have separate offsets, like Linux's `struct file`). `tty::Console` is
another `File`: writes go to VGA+serial, reads come from the keyboard line
buffer. `fs/pipe.rs` gives two more: a pipe's read and write ends.
- **fd table**: `Vec<Option<Arc<dyn File>>>` per process (max 16), fds 0-2 =
Console. `open` = `fs::lookup` (or `fs::create` with `O_CREAT`) + `InodeFile::open`
+ first free slot.
Keyboard input for `read(0)` goes through a minimal tty line discipline
(`src/tty.rs`): the keyboard task feeds characters in, Backspace edits the
current line, Enter makes the line (with `\n`) available to readers.
Known simplifications (exercises): a process's page tables and frames are
never freed on exit; orphans aren't reparented; no signals; user pointer
checks walk the page table but don't guard against another thread unmapping
the page mid-syscall (there's no `munmap` yet, so that's fine for now).
## Disks: PCI, virtio-blk, partitions, FAT32 (stage 6)
```
/mnt/disk0/readme.txt
VFS (fs/mod.rs) longest mount prefix -> FatFs, rest "readme.txt"
FatInode (fs/fat.rs) fatfs crate: clusters, FAT chains, directory entries
BlockIo adapter byte offsets -> whole 512-byte sectors
Partition vda1 sector + 2048, bounds-checked
VirtioBlk vda virtqueue request to QEMU
PCI found the device and its I/O port at boot
```
**PCI** (`src/pci.rs`). Every PCI device has a 256-byte configuration space
(vendor/device id, class code, BARs = where its registers live). On a PC you
read it by writing `bus/device/function/offset` to port `0xCF8` and reading
port `0xCFC`. `pci::scan()` tries every bus/device/function, keeps the ones
whose vendor id isn't `0xFFFF`, and prints them with a class name. QEMU's
default machine has 7: host bridge, ISA/IDE/ACPI parts of the PIIX chipset,
VGA, the e1000 network card, and our disk, `1af4:1001`.
**Why virtio-blk and not ATA PIO?** ATA PIO is simpler (a few port writes,
then 256 `in` instructions per sector) but it's a 1980s interface that only
emulators and old hardware have. virtio is what QEMU/KVM and cloud VMs
actually use, and it teaches the pattern every modern device uses: the driver
and device share **queues of descriptors in RAM**. The legacy (virtio 0.9.5)
interface is used, through I/O port BAR0, because it needs no MMIO mapping or
PCI capability parsing; QEMU's device is "transitional" and speaks both.
**virtio-blk in one paragraph** (`src/block/virtio_blk.rs`, ~280 lines).
Reset the device, set ACKNOWLEDGE + DRIVER, accept no optional features,
read the queue size, allocate the virtqueue (descriptor table + available
ring + used ring, physically contiguous: `memory::alloc_contiguous_frames`)
and give its page number to the device, set DRIVER_OK. A request is a chain
of three descriptors: a 16-byte header (`IN` = read or `OUT` = write, sector
number), the data buffer, and a 1-byte status the device fills in. Put the
chain's head in the available ring, write the queue number to the notify
port, then **poll** the used ring until the device hands it back. Data goes
through a one-page **bounce buffer** whose physical address we know (heap
buffers are virtually contiguous but not physically). Interrupts are switched
off for the device (`VRING_AVAIL_F_NO_INTERRUPT`); polling is fine because QEMU
completes requests almost instantly. Exercises: IRQ-driven completion (block
the thread on a wait queue), several requests in flight, virtio 1.0 "modern"
MMIO.
**`BlockDevice`** (`src/block/mod.rs`): `sector_count()`,
`read_sectors(lba, buf)`, `write_sectors(lba, buf)`, buffers a whole number of
512-byte sectors. Devices get Linux-style names in a registry
(`block::devices()`, `block::get("vda1")`). `RamDisk` is a `Vec<u8>` disk used
by tests.
**Partitions** (`src/block/partition.rs`). Sector 0 is the MBR: four 16-byte
entries at offset 446 and the `55 AA` signature. A single entry of type `0xEE`
is a "protective MBR" meaning *look for a GPT*: sector 1 has the `EFI PART`
header, which says where the partition entry array is (128 bytes each: type
GUID, unique GUID, first/last LBA, UTF-16 name). Each partition becomes a
`Partition` -- itself a `BlockDevice` that adds its start sector and refuses
to go past its end -- registered as `vda1`, `vda2`... CRCs aren't checked yet.
**FAT32 via the `fatfs` crate** (`src/fs/fat.rs`). `fatfs` does the
filesystem logic; it wants something implementing its `Read + Write + Seek`
traits on *bytes*. `BlockIo` is that adapter: it keeps a byte position, sends
whole aligned sectors straight to the device, and handles partial sectors
through a one-sector buffer (read the sector, change the bytes, write it back).
Writes go straight through, so `flush` has nothing to do. (`fatfs` 0.3 from
crates.io depends on the abandoned `core_io` crate, which doesn't build on
current nightlies, so `Cargo.toml` pins a git revision of the 0.4 branch with
its own `no_std` IO traits.) For the VFS, `FatFs` holds the `fatfs::FileSystem`
in an `Arc<Mutex<..>>`, and a `FatInode` is just *(that filesystem, a path, a
kind)*: each call reopens its path from the root. Slower than caching
directory entries, but simple and never stale. Lookups are case-insensitive,
like FAT itself. No timestamps (`NullTimeProvider`) and long file names on.
**The test disk** (`build.rs`). After compiling the user programs,
`build.rs` makes `target/disk.img` (64 MiB): `sfdisk` writes a GPT with one
"Microsoft basic data" partition at sector 2048, `mkfs.vfat -F 32 -s 1 -n
TYLERDISK` formats a separate partition image (1 sector per cluster so 62 MiB
is enough clusters for FAT32), `mmd`/`mcopy` (mtools, which edit FAT images
without mounting, so no root) copy in `disk/` and `bin/<programs>`, and the
partition image is spliced into the disk at sector 2048. If a tool is missing
the build stops with `sudo apt install dosfstools mtools fdisk`
(`mkfs.vfat` lives in `/usr/sbin`, which isn't in a normal user's `PATH` on
Debian, so `build.rs` looks there too). Look inside it from Linux:
```sh
/usr/sbin/sfdisk -l target/disk.img # not in a normal user's PATH on Debian
export MTOOLS_SKIP_CHECK=1 # mtools: don't complain about the small-cluster FAT32
mdir -i target/disk.img@@1M ::/bin # @@1M = partition offset (2048 * 512)
mtype -i target/disk.img@@1M ::/readme.txt
```
## Next stage: the shell (Tyler's design)
A **hybrid** shell: Unix navigation with a DOS flavour on top. All the
syscalls it needs exist now (`read(0)` blocks, `spawn` + `wait`, `pipe`,
`getdents64`, `stat`, `chdir`/`getcwd`, `mkdir`/`rmdir`/`unlink`, `lseek`,
`SPAWN_TRACE`).
- **Unix navigation**: `cd`, `ls`, `pwd`, `cat`, `mkdir`, `rm`, `echo`.
- **DOS-style program running**: `run prog args` (foreground: spawn + wait,
show the exit code) and `start prog args` (background: spawn, don't wait),
plus aliases `dir` (= ls), `type` (= cat), `cls` (clear screen), `ver`.
- **Pipes and redirection**: `a | b` (pipe + two spawns with stdout/stdin set)
and `> file` (open with `O_CREAT|O_TRUNC`, pass it as the child's stdout).
- `help`, `exit`.
- **`run --trace prog`**: spawn with `SPAWN_TRACE` to print every syscall the
program makes (a built-in mini `strace`).
- **Drive letters as aliases over the single tree**: `C:` = `/`, and each
mount gets the next letter automatically (`D:` = `/mnt/disk0`, ...). It's
only shell syntax: the kernel keeps one Unix-style tree.
## Roadmap
Done: all 12 chapters of the 2nd edition, plus preemptive kernel threads,
user mode + syscalls, ELF loading, a VFS with a ramfs, PCI, virtio-blk,
MBR/GPT partitions, FAT32, blocking threads, pipes, spawn/wait.
- [x] Freestanding binary — [phil-opp](https://os.phil-opp.com/freestanding-rust-binary/)
- [x] Minimal kernel booting in QEMU — [phil-opp](https://os.phil-opp.com/minimal-rust-kernel/), [OSDev: Bare Bones](https://wiki.osdev.org/Bare_Bones)
- [x] VGA text mode printing — [phil-opp](https://os.phil-opp.com/vga-text-mode/), [OSDev: Printing to Screen](https://wiki.osdev.org/Printing_To_Screen)
- [x] Serial output + QEMU exit — [phil-opp: Testing](https://os.phil-opp.com/testing/), [OSDev: Serial Ports](https://wiki.osdev.org/Serial_Ports)
- [x] CPU exceptions / IDT — breakpoint & page fault handlers. [phil-opp: CPU Exceptions](https://os.phil-opp.com/cpu-exceptions/), [OSDev: IDT](https://wiki.osdev.org/Interrupt_Descriptor_Table), [OSDev: Exceptions](https://wiki.osdev.org/Exceptions)
- [x] GDT / TSS / double faults — separate IST stack so stack overflows don't triple-fault. [phil-opp: Double Faults](https://os.phil-opp.com/double-fault-exceptions/), [OSDev: GDT](https://wiki.osdev.org/Global_Descriptor_Table), [OSDev: TSS](https://wiki.osdev.org/Task_State_Segment)
- [x] Testing — `cargo test` in QEMU, custom test framework, integration tests. [phil-opp: Testing](https://os.phil-opp.com/testing/)
- [x] Hardware interrupts — 8259 PIC, timer, PS/2 keyboard echo, `hlt` idle loop. [phil-opp: Hardware Interrupts](https://os.phil-opp.com/hardware-interrupts/), [OSDev: 8259 PIC](https://wiki.osdev.org/8259_PIC), [OSDev: PIT](https://wiki.osdev.org/Programmable_Interval_Timer), [OSDev: PS/2 Keyboard](https://wiki.osdev.org/PS/2_Keyboard) (later: [APIC](https://wiki.osdev.org/APIC))
- [x] Introduction to paging — CR3, page fault demo. [phil-opp](https://os.phil-opp.com/paging-introduction/), [OSDev: Paging](https://wiki.osdev.org/Paging)
- [x] Paging implementation — OffsetPageTable, translation, frame allocator, example mapping. [phil-opp](https://os.phil-opp.com/paging-implementation/), [OSDev: Page Frame Allocation](https://wiki.osdev.org/Page_Frame_Allocation)
- [x] Heap allocation — `alloc`, heap mapping, global allocator. [phil-opp](https://os.phil-opp.com/heap-allocation/), [OSDev: Memory Allocation](https://wiki.osdev.org/Memory_Allocation)
- [x] Allocator designs — bump, linked list (crate), fixed-size block (active). [phil-opp](https://os.phil-opp.com/allocator-designs/)
- [x] Async/await — Task, simple executor, waker-based executor, lock-free async keyboard task (with Backspace). [phil-opp: Async/Await](https://os.phil-opp.com/async-await/), [OSDev: Multitasking Systems](https://wiki.osdev.org/Multitasking_Systems)
- [x] Preemptive kernel threads — own stacks, timer-driven round-robin, asm context switch. [OSDev: Brendan's Multi-tasking Tutorial](https://wiki.osdev.org/Brendan%27s_Multi-tasking_Tutorial), [OSDev: Context Switching](https://wiki.osdev.org/Context_Switching)
- [x] User mode (ring 3) & syscalls — GDT user segments, TSS.rsp0 per thread, `syscall`/`sysret`, per-process page tables, user faults kill the process. [OSDev: Getting to Ring 3](https://wiki.osdev.org/Getting_to_Ring_3), [OSDev: System Calls](https://wiki.osdev.org/System_Calls), [OSDev: SYSENTER/SYSCALL](https://wiki.osdev.org/SYSENTER)
- [x] ELF loading — hand-written PT_LOAD loader, user programs built by `build.rs`. [OSDev: ELF](https://wiki.osdev.org/ELF), [OSDev: ELF Tutorial](https://wiki.osdev.org/ELF_Tutorial)
- [x] VFS skeleton + ramfs — Inode/FileSystem/File traits, mount table, fd table, `open/read/write/close`. [OSDev: VFS](https://wiki.osdev.org/VFS), [Linux VFS overview](https://www.kernel.org/doc/html/latest/filesystems/vfs.html)
- [x] PCI enumeration + virtio-blk driver + `BlockDevice` trait — [OSDev: PCI](https://wiki.osdev.org/PCI), [OSDev: Virtio](https://wiki.osdev.org/Virtio), [virtio spec (legacy interface)](https://docs.oasis-open.org/virtio/virtio/v1.2/virtio-v1.2.html)
- [x] MBR + GPT partitions — [OSDev: MBR](https://wiki.osdev.org/MBR_(x86)), [OSDev: GPT](https://wiki.osdev.org/GPT)
- [x] FAT32 through `fatfs`, mounted at `/mnt/disk0`; programs loaded from disk — [OSDev: FAT](https://wiki.osdev.org/FAT)
- [x] Blocking threads + wait queues, pipes, `spawn`/`wait`, cwd, `stat`/`lseek`/`getdents64` — [OSDev: Blocking Process](https://wiki.osdev.org/Blocking_Process)
- [ ] Shell (next stage, design above) — [OSDev: Shell](https://wiki.osdev.org/Shell)
### Filesystem roadmap (Tyler's plan)
Everything below plugs into the VFS above: a filesystem is a `FileSystem`
whose `root()` returns `Inode`s; mounting it is one `fs::mount` call.
1. **ramfs / tmpfs** — done (`src/fs/ramfs.rs`).
2. **Block device driver** — done: `BlockDevice` trait + legacy virtio-blk
(stage 6). Still to do: a block cache, IRQ-driven completion, maybe an
ATA PIO driver for comparison ([OSDev: ATA PIO Mode](https://wiki.osdev.org/ATA_PIO_Mode)).
3. **Partition tables** — done: MBR + GPT (no CRC checks yet).
4. **FAT32** — done via the `fatfs` crate (`src/fs/fat.rs`). A good exercise
later: a hand-written read-only FAT32 to see what the crate does.
5. **ext2** — written by hand: superblock, block groups, inode table, direct/indirect
blocks, directory entries. The classic "learn how Unix filesystems work" step,
and the base for understanding ext4 later. [OSDev: Ext2](https://wiki.osdev.org/Ext2),
[The Second Extended File System (Dave Poirier)](https://www.nongnu.org/ext2-doc/ext2.html)
6. **End goals:**
- **exFAT** — because it works on literally anything (cameras, phones,
Windows, macOS, Linux), so a kernel that speaks exFAT can share USB sticks
and SD cards with every other machine. Allocation bitmap + FAT-like cluster
chains + directory entry sets with checksums.
[Microsoft exFAT specification](https://learn.microsoft.com/en-us/windows/win32/fileio/exfat-specification)
- **btrfs** (read-only first) — because most of Tyler's Linux systems will use
btrfs going forward, so this kernel could read their disks. Much bigger:
copy-on-write B-trees, the chunk tree (logical -> physical mapping), the
root/fs trees, checksums, compression (zlib/lzo/zstd). Read-only means
parsing the superblock, chunk tree and fs tree -- no transactions or
space allocation. [btrfs on-disk format](https://btrfs.readthedocs.io/en/latest/dev/On-disk-format.html),
[btrfs docs](https://btrfs.readthedocs.io/)
7. **NTFS** — last, maybe never.
Other next stages:
- **Shell** — next stage; design in "Next stage: the shell" above.
- **Process cleanup** — free a process's page tables and frames on exit.
Small exercises along the way: guard pages under thread stacks, a timer-based
async `sleep` future, speed up the PIT
([OSDev: PIT](https://wiki.osdev.org/Programmable_Interval_Timer)), add handlers for
divide error / general protection fault, make `BootInfoFrameAllocator` O(1), add a
`mem` stat (bytes allocated) to the allocator.
Other good references: [OSDev wiki main page](https://wiki.osdev.org/Expanded_Main_Page),
[Intel SDM](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdms.html),
the [blog_os repo](https://github.com/phil-opp/blog_os) (one branch per post, e.g. `post-04`),
and for a UEFI/framebuffer setup later, the newer [`bootloader` 0.11](https://github.com/rust-osdev/bootloader) crate.
## Notes / gotchas
- The target JSON changes occasionally with new nightlies (e.g. `target-pointer-width`
became an integer, `rustc-abi` was added). If a nightly update breaks the build,
compare with the latest `x86_64-blog_os.json` in the blog_os repo, or pin a date in
`rust-toolchain.toml` (`channel = "nightly-2026-10-29"`).
- The "packages contain code that will be rejected by a future version of Rust: core"
warning comes from building `core` with build-std and is harmless.
- `bootimage` prints "`CARGO_MANIFEST_DIR` env variable not set" when run outside
`cargo run`; also harmless.
- `cargo test` with build-std breaks ("duplicate lang item in crate `core`: `sized`")
if `Cargo.toml` sets `panic = "abort"` in `[profile.*]`. The target JSON already
sets `"panic-strategy": "abort"`, so the profile settings were removed.
- Stale disk image when switching cargo features: cargo re-uses a previously built
kernel ELF with an *old* mtime, so bootimage thinks its cached image is current
and boots the wrong build. `scripts/runner.sh` and the scripts `touch` the ELF
first to avoid this. If you call `cargo bootimage --features ...` by hand after
switching features, `touch target/x86_64-rust_kernel/debug/rust_kernel` first.
- `build.rs` reruns when `user/`, `disk/` or `build.rs` change and rebuilds
`target/disk.img`. QEMU opens it with `snapshot=on`, so the kernel's writes
(the tests create and delete files) never reach the file on disk.
- Debug builds use a lot of stack: deep call chains (syscall -> VFS -> fatfs
-> ELF loader) needed more than 16 KiB. If you see a weird ring-0 fault at
address 0 or "kernel stack overflow in thread", suspect the stack first.
|