rust-kernel
Rustbelt: a hobby x86_64 kernel written in Rust
| author | Tyler Fowler <tyler@localhost> | 2026-10-29 23:41:12 -0700 |
|---|---|---|
| commit | dd01e731c4dc78d44c54c1f6c1abd9108e5b6a50 | |
| parent | 9ebb4aa97444ff9dd043ab0c20797578cb73eee1 | |
| tree | browse files at this commit | |
Add IDT/exceptions, GDT+TSS double-fault stack, PIC timer + keyboard interrupts
| M | .cargo/config.toml | +2 -1 |
| M | Cargo.lock | +17 -0 |
| M | Cargo.toml | +5 -0 |
| M | README.md | +91 -32 |
| M | boot.png | +0 -0 |
| M | scripts/run-headless.sh | +7 -1 |
| A | scripts/runner.sh | +11 -0 |
| M | scripts/screenshot.sh | +36 -7 |
| A | src/gdt.rs | +67 -0 |
| A | src/interrupts.rs | +202 -0 |
| M | src/main.rs | +46 -11 |
| M | src/serial.rs | +6 -1 |
| M | src/vga_buffer.rs | +38 -1 |
13 files changed, 528 insertions, 54 deletions
@@ -8,4 +8,5 @@ target = "x86_64-rust_kernel.json" # `cargo run` -> build a bootable image and boot it in QEMU. [target.'cfg(target_os = "none")']-runner = "bootimage runner"+# scripts/runner.sh = `bootimage runner` + a cache fix (see the script).+runner = "scripts/runner.sh"
@@ -42,6 +42,21 @@ dependencies = [ ] [[package]]+name = "pc-keyboard"+version = "0.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed089a1fbffe3337a1a345501c981f1eb1e47e69de5a40e852433e12953c3174"++[[package]]+name = "pic8259"+version = "0.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "62d9a86c292b165f757e47e7fd66855def189b2564609bc4203727b27c33db22"+dependencies = [+ "x86_64",+]++[[package]] name = "raw-cpuid" version = "10.7.0" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -56,6 +71,8 @@ version = "0.1.0" dependencies = [ "bootloader", "lazy_static",+ "pc-keyboard",+ "pic8259", "spin 0.5.2", "uart_16550", "volatile 0.2.7",
@@ -11,6 +11,8 @@ volatile = "0.2.6" # stops the compiler optimising away VGA buffer writes spin = "0.5.2" # spinlock Mutex (no OS = no std::sync::Mutex) x86_64 = "0.15" # safe wrappers for x86 instructions/ports (hlt, outb, ...) uart_16550 = "0.3" # driver for the serial port (COM1)+pic8259 = "0.11" # driver for the two chained 8259 interrupt controllers+pc-keyboard = "0.7" # turns PS/2 scancodes into key events / characters [dependencies.lazy_static] version = "1.0"@@ -21,6 +23,9 @@ features = ["spin_no_std"] # printing its greeting (used by scripts/run-headless.sh). Without it the # kernel just halts forever so you can look at the QEMU window. headless = []+# Build with `--features stack-overflow` to deliberately overflow the kernel+# stack after boot, to check the double-fault handler (GDT/TSS/IST) catches it.+stack-overflow = [] # No unwinding in a kernel: just abort on panic. [profile.dev]
@@ -2,8 +2,10 @@ 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 a greeting on the VGA text screen and on-the serial port (COM1), and then halts.+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) and echoes what you+type on the PS/2 keyboard.  @@ -16,13 +18,14 @@ the serial port (COM1), and then halts. | `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`) | 10.0.13 | the emulator we boot in |+| QEMU (`qemu-system-x86`, `qemu-system-gui`) | 10.0.13 | the emulator we boot in (`-gui` adds the GTK/SDL window) | `rust-toolchain.toml` pins the project to nightly with the two components, so `cargo` inside this directory picks them up automatically. 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.+`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). Fresh setup on another machine: @@ -30,7 +33,7 @@ Fresh setup on another machine: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # rustup rustup toolchain install nightly -c rust-src -c llvm-tools-preview cargo install bootimage-sudo apt install qemu-system-x86+sudo apt install qemu-system-x86 qemu-system-gui ``` ## Build and run@@ -38,66 +41,117 @@ sudo apt install qemu-system-x86 ```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- # (kernel halts forever, close the window or press Ctrl+C to quit) ``` +**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). Shift works; Enter starts a new line; arrows/F-keys show as e.g.+`<ArrowUp>`. Backspace isn't handled yet (shows a block) -- a good first+exercise. 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 # builds with --features headless, boots with -display none,- # kernel exits QEMU via isa-debug-exit; script exits 0 on success-cargo run --features headless -- -display none # same thing via cargo- # (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] # boots, saves the VGA screen to boot.png via QEMU monitor `screendump`+./scripts/run-headless.sh # boot, breakpoint, ~3 s of 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+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] # boot, "type" text via QEMU monitor `sendkey`, check it echoes+ # on serial, save the VGA screen to boot.png ``` -Expected serial output:+Cargo features (in `Cargo.toml`):++| Feature | Effect |+|---|---|+| `headless` | print a `.` per timer tick to serial and exit QEMU after 54 ticks (~3 s); panics exit with failure |+| `stack-overflow` | after boot, recurse forever to test the double-fault handler (with `headless`: exit success when caught) |++Expected serial output of `./scripts/run-headless.sh` (stack frame dump trimmed): ``` Hello from Tyler's Rust kernel! (via serial COM1)-Kernel booted successfully.+EXCEPTION: BREAKPOINT+InterruptStackFrame { instruction_pointer: ..., ... }+Survived the breakpoint, still running.+Timer + keyboard interrupts enabled.+.................. 18 ticks+.................. 36 ticks+.................. 54 ticks+[ok] timer works, exiting ``` Extra args after `--` in `cargo run -- ...` are appended to the QEMU command-line, e.g. `cargo run -- -d int -no-reboot` to debug triple faults, or-`cargo run -- -s -S` to wait for `gdb` on port 1234.+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 + custom target + "bootimage runner" as the cargo runner+.cargo/config.toml build-std + 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, `headless` feature, QEMU run-args for `cargo run`-src/main.rs entry point `_start`, panic handler, QEMU exit helper-src/vga_buffer.rs VGA text-mode writer + print!/println! macros-src/serial.rs COM1 UART writer + serial_print!/serial_println! macros+Cargo.toml deps, `headless` / `stack-overflow` features, QEMU run-args for `cargo run`+src/main.rs entry point `_start`, init order, panic handler, hlt_loop, QEMU exit helper+src/gdt.rs GDT + TSS with a separate IST stack for the double-fault handler+src/interrupts.rs IDT; breakpoint / page fault / double fault handlers; 8259 PIC;+ timer (uptime + ticks) and keyboard (echo) interrupt handlers+src/vga_buffer.rs VGA text-mode writer, print!/println!, uptime display+src/serial.rs COM1 UART writer + serial_print!/serial_println!+scripts/runner.sh `cargo run` runner: `bootimage runner` + stale-image fix scripts/run-headless.sh headless boot with timeout + exit-code check-scripts/screenshot.sh headless boot + PNG screenshot via QEMU monitor+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, decodes it with `pc-keyboard` (US layout,+ scancode set 1) and echoes it. 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.+ ## Roadmap -Already done (roughly chapters 1–3, plus the serial/QEMU-exit bits of chapter 4):+Already done (chapters 1-3 and 5-7, plus the serial/QEMU-exit bits of chapter 4): - [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] 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)) Next stages: -1. **Testing** — custom test framework (`cargo test` in QEMU). [phil-opp: Testing](https://os.phil-opp.com/testing/)-2. **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)-3. **GDT / TSS / double faults** — separate 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)-4. **Hardware interrupts** — 8259 PIC, timer, PS/2 keyboard. [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))-5. **Paging** — 4-level page tables, mapping frames. [phil-opp: Introduction to Paging](https://os.phil-opp.com/paging-introduction/), [phil-opp: Paging Implementation](https://os.phil-opp.com/paging-implementation/), [OSDev: Paging](https://wiki.osdev.org/Paging)-6. **Heap allocation** — `alloc` crate, `Box`/`Vec`, allocator designs. [phil-opp: Heap Allocation](https://os.phil-opp.com/heap-allocation/), [phil-opp: Allocator Designs](https://os.phil-opp.com/allocator-designs/), [OSDev: Memory Allocation](https://wiki.osdev.org/Memory_Allocation)-7. **Multitasking** — cooperative async/await executor first, then preemptive threads. [phil-opp: Async/Await](https://os.phil-opp.com/async-await/), [OSDev: Multitasking Systems](https://wiki.osdev.org/Multitasking_Systems), [OSDev: Brendan's Multi-tasking Tutorial](https://wiki.osdev.org/Brendan%27s_Multi-tasking_Tutorial)-8. **User mode & syscalls** — ring 3, `syscall`/`sysret`, loading ELF programs. (Beyond the phil-opp series.) [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), [OSDev: ELF](https://wiki.osdev.org/ELF)-9. **Filesystem & shell** — VFS, a RAM disk / FAT, a tiny shell reading the keyboard. [OSDev: File Systems](https://wiki.osdev.org/File_Systems), [OSDev: VFS](https://wiki.osdev.org/VFS), [OSDev: FAT](https://wiki.osdev.org/FAT), [OSDev: Initrd](https://wiki.osdev.org/Initrd)+1. **Testing** — custom test framework (`cargo test` in QEMU; needs a `lib.rs` split). [phil-opp: Testing](https://os.phil-opp.com/testing/)+2. **Small exercises** — handle Backspace, set the PIT to a faster rate ([OSDev: PIT](https://wiki.osdev.org/Programmable_Interval_Timer)), add handlers for more exceptions (divide error, general protection fault).+3. **Paging** — 4-level page tables, mapping frames. [phil-opp: Introduction to Paging](https://os.phil-opp.com/paging-introduction/), [phil-opp: Paging Implementation](https://os.phil-opp.com/paging-implementation/), [OSDev: Paging](https://wiki.osdev.org/Paging)+4. **Heap allocation** — `alloc` crate, `Box`/`Vec`, allocator designs. [phil-opp: Heap Allocation](https://os.phil-opp.com/heap-allocation/), [phil-opp: Allocator Designs](https://os.phil-opp.com/allocator-designs/), [OSDev: Memory Allocation](https://wiki.osdev.org/Memory_Allocation)+5. **Multitasking** — cooperative async/await executor first, then preemptive threads. [phil-opp: Async/Await](https://os.phil-opp.com/async-await/), [OSDev: Multitasking Systems](https://wiki.osdev.org/Multitasking_Systems), [OSDev: Brendan's Multi-tasking Tutorial](https://wiki.osdev.org/Brendan%27s_Multi-tasking_Tutorial)+6. **User mode & syscalls** — ring 3, `syscall`/`sysret`, loading ELF programs. (Beyond the phil-opp series.) [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), [OSDev: ELF](https://wiki.osdev.org/ELF)+7. **Filesystem & shell** — VFS, a RAM disk / FAT, a tiny shell reading the keyboard. [OSDev: File Systems](https://wiki.osdev.org/File_Systems), [OSDev: VFS](https://wiki.osdev.org/VFS), [OSDev: FAT](https://wiki.osdev.org/FAT), [OSDev: Initrd](https://wiki.osdev.org/Initrd) 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),@@ -114,3 +168,8 @@ and for a UEFI/framebuffer setup later, the newer [`bootloader` 0.11](https://gi 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.+- 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.
Binary file changed.
@@ -2,10 +2,16 @@ # Build the kernel with the `headless` feature and boot it in QEMU with no # window. Serial output goes to this terminal; the kernel powers QEMU off via # the isa-debug-exit device when done. A timeout guards against hangs.+#+# Usage: ./scripts/run-headless.sh # normal boot, exits after ~3 s of timer ticks+# ./scripts/run-headless.sh stack-overflow # extra cargo features, comma separated set -euo pipefail cd "$(dirname "$0")/.." -cargo bootimage --features headless+cargo build --features "headless${1:+,$1}"+# Force bootimage to repack the disk image (see scripts/runner.sh for why).+touch target/x86_64-rust_kernel/debug/rust_kernel+cargo bootimage --features "headless${1:+,$1}" IMG=target/x86_64-rust_kernel/debug/bootimage-rust_kernel.bin set +e
@@ -0,0 +1,11 @@+#!/usr/bin/env bash+# Cargo runner (see .cargo/config.toml): `cargo run` calls this with the+# kernel ELF path followed by any extra args.+#+# Why not call `bootimage runner` directly? When you switch cargo features+# (e.g. --features headless and back), cargo re-uses an older kernel binary+# with an *old* timestamp, so bootimage thinks its cached disk image is still+# up to date and boots the wrong kernel. Touching the ELF forces a rebuild.+set -euo pipefail+touch "$1"+exec bootimage runner "$@"
@@ -1,25 +1,54 @@ #!/usr/bin/env bash-# Boot the (non-headless) kernel with no window, wait a moment, then ask the-# QEMU monitor to dump the VGA screen to boot.png. Handy on a headless box.+# Boot the normal (non-headless) kernel with no window, "type" some keys into+# it through the QEMU monitor's `sendkey` command, then dump the VGA screen to+# a PNG. Doubles as an automated keyboard-interrupt test: the typed text is+# echoed on the serial port too, so we can grep for it.+#+# Usage: ./scripts/screenshot.sh [out.png] [text-to-type] set -euo pipefail cd "$(dirname "$0")/.." +cargo build+# Force bootimage to repack the disk image (see scripts/runner.sh for why).+touch target/x86_64-rust_kernel/debug/rust_kernel cargo bootimage IMG=target/x86_64-rust_kernel/debug/bootimage-rust_kernel.bin OUT="${1:-$PWD/boot.png}"+TEXT="${2:-Hello, kernel!}" SOCK=$(mktemp -u /tmp/qemu-mon.XXXXXX)+SERIAL_LOG=$(mktemp /tmp/serial.XXXXXX) qemu-system-x86_64 -drive format=raw,file="$IMG" \- -serial stdio -display none -monitor unix:"$SOCK",server,nowait &+ -serial file:"$SERIAL_LOG" -display none -monitor unix:"$SOCK",server,nowait & QPID=$!-sleep 3 # give the BIOS + bootloader + kernel time to draw+sleep 3 # give the BIOS + bootloader + kernel time to boot # Talk to the QEMU monitor over its UNIX socket (python avoids needing socat).-python3 - "$SOCK" "$OUT" <<'PY'+python3 - "$SOCK" "$OUT" "$TEXT" <<'PY' import socket, sys, time-s = socket.socket(socket.AF_UNIX); s.connect(sys.argv[1])-s.sendall(f"screendump {sys.argv[2]} -f png\n".encode()); time.sleep(1)+sock, out, text = sys.argv[1:]+# QEMU key names for the characters we might type.+names = {' ': 'spc', ',': 'comma', '.': 'dot', '!': 'shift-1', '?': 'shift-slash',+ '-': 'minus', '\n': 'ret'}+def key(c):+ if c in names: return names[c]+ if c.isupper(): return 'shift-' + c.lower()+ return c+s = socket.socket(socket.AF_UNIX); s.connect(sock)+for c in text:+ s.sendall(f"sendkey {key(c)}\n".encode()); time.sleep(0.1)+time.sleep(0.5)+s.sendall(f"screendump {out} -f png\n".encode()); time.sleep(1) s.sendall(b"quit\n"); time.sleep(0.5) PY wait $QPID || true+echo "---- serial output ----"+cat "$SERIAL_LOG"; echo+echo "-----------------------"+if grep -qF -- "$TEXT" "$SERIAL_LOG"; then+ echo "[screenshot] keyboard echo OK: '$TEXT'"+else+ echo "[screenshot] keyboard echo NOT found"+fi+rm -f "$SERIAL_LOG" echo "[screenshot] saved $OUT"
@@ -0,0 +1,67 @@+//! GDT (Global Descriptor Table) + TSS (Task State Segment).+//!+//! In 64-bit mode segmentation is mostly dead, but we still need a GDT for one+//! important reason: it is how the CPU finds the TSS, and the TSS holds the+//! Interrupt Stack Table (IST) -- a list of known-good stacks the CPU can+//! switch to when an exception happens.+//!+//! Why it matters: if the kernel stack overflows, the CPU hits the guard page+//! and raises a page fault. Pushing the exception frame onto the (already+//! overflowed) stack faults again -> double fault -> same problem -> TRIPLE+//! fault, which resets the machine. Giving the double-fault handler its own+//! IST stack breaks that chain.++use lazy_static::lazy_static;+use x86_64::VirtAddr;+use x86_64::structures::gdt::{Descriptor, GlobalDescriptorTable, SegmentSelector};+use x86_64::structures::tss::TaskStateSegment;++/// Which IST slot (0..=6) the double-fault handler uses.+pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;++lazy_static! {+ static ref TSS: TaskStateSegment = {+ let mut tss = TaskStateSegment::new();+ tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {+ // No memory allocator yet, so the stack is just a static array.+ // (It has no guard page, so don't do anything recursive in the+ // double-fault handler!)+ const STACK_SIZE: usize = 4096 * 5;+ static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];++ // Stacks grow downwards on x86, so hand the CPU the *end* address.+ let stack_start = VirtAddr::from_ptr(&raw const STACK);+ stack_start + STACK_SIZE as u64+ };+ tss+ };+}++lazy_static! {+ /// The GDT, plus the selectors (indexes) of the entries we added.+ static ref GDT: (GlobalDescriptorTable, Selectors) = {+ let mut gdt = GlobalDescriptorTable::new();+ let code_selector = gdt.append(Descriptor::kernel_code_segment());+ let tss_selector = gdt.append(Descriptor::tss_segment(&TSS));+ (gdt, Selectors { code_selector, tss_selector })+ };+}++struct Selectors {+ code_selector: SegmentSelector,+ tss_selector: SegmentSelector,+}++/// Load our GDT, point CS at our code segment and load the TSS.+pub fn init() {+ use x86_64::instructions::segmentation::{CS, Segment};+ use x86_64::instructions::tables::load_tss;++ GDT.0.load();+ unsafe {+ // The old CS value points into the bootloader's GDT, which we just+ // replaced, so reload it. Then tell the CPU where our TSS is.+ CS::set_reg(GDT.1.code_selector);+ load_tss(GDT.1.tss_selector);+ }+}
@@ -0,0 +1,202 @@+//! Interrupt handling: the IDT, CPU exception handlers and hardware IRQs.+//!+//! The IDT (Interrupt Descriptor Table) maps each interrupt vector number to+//! a handler function:+//! * vectors 0..=31 are CPU exceptions (divide error, page fault, ...)+//! * vectors 32..=47 are where we remap the 8259 PIC's 16 hardware IRQs+//!+//! Handlers use the special `extern "x86-interrupt"` calling convention: the+//! compiler saves/restores all registers and returns with `iretq`.++use crate::{gdt, print, println, serial_print, serial_println};+use core::sync::atomic::{AtomicU64, Ordering};+use lazy_static::lazy_static;+use pic8259::ChainedPics;+use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame, PageFaultErrorCode};++// ---------------------------------------------------------------------------+// 8259 PIC (Programmable Interrupt Controller)+// ---------------------------------------------------------------------------++/// The PICs deliver IRQ 0..15 on vectors 0..15 by default, which clash with+/// CPU exceptions. We remap them to 32..47.+pub const PIC_1_OFFSET: u8 = 32;+pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;++/// Two PICs: primary handles IRQ 0-7, secondary IRQ 8-15 (chained via IRQ 2).+pub static PICS: spin::Mutex<ChainedPics> =+ spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });++/// Vector numbers of the hardware interrupts we handle.+#[derive(Debug, Clone, Copy)]+#[repr(u8)]+pub enum InterruptIndex {+ Timer = PIC_1_OFFSET, // IRQ 0: Programmable Interval Timer, ~18.2 Hz by default+ Keyboard, // IRQ 1: PS/2 keyboard+}++impl InterruptIndex {+ fn as_u8(self) -> u8 {+ self as u8+ }+}++// ---------------------------------------------------------------------------+// IDT setup+// ---------------------------------------------------------------------------++lazy_static! {+ /// The IDT must live forever (`'static`): the CPU keeps a pointer to it.+ static ref IDT: InterruptDescriptorTable = {+ let mut idt = InterruptDescriptorTable::new();+ idt.breakpoint.set_handler_fn(breakpoint_handler);+ idt.page_fault.set_handler_fn(page_fault_handler);+ unsafe {+ // Run the double-fault handler on its own known-good stack (IST).+ idt.double_fault+ .set_handler_fn(double_fault_handler)+ .set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX);+ }+ idt[InterruptIndex::Timer.as_u8()].set_handler_fn(timer_interrupt_handler);+ idt[InterruptIndex::Keyboard.as_u8()].set_handler_fn(keyboard_interrupt_handler);+ idt+ };+}++/// Load the IDT into the CPU (`lidt`).+pub fn init_idt() {+ IDT.load();+}++// ---------------------------------------------------------------------------+// CPU exception handlers+// ---------------------------------------------------------------------------++/// `int3` (what debuggers use). Harmless: print and carry on.+extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {+ println!("EXCEPTION: BREAKPOINT");+ serial_println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);+}++/// Page fault: accessing unmapped/protected memory. CR2 holds the bad address.+/// We can't fix it yet (no paging code), so report and stop.+extern "x86-interrupt" fn page_fault_handler(+ stack_frame: InterruptStackFrame,+ error_code: PageFaultErrorCode,+) {+ use x86_64::registers::control::Cr2;+ println!("EXCEPTION: PAGE FAULT at {:?}", Cr2::read());+ serial_println!(+ "EXCEPTION: PAGE FAULT\nAccessed address: {:?}\nError code: {:?}\n{:#?}",+ Cr2::read(),+ error_code,+ stack_frame+ );+ crate::hlt_loop();+}++/// Double fault: an exception happened while handling another one. Must not+/// return (hence `-> !`). Runs on the IST stack set up in gdt.rs.+extern "x86-interrupt" fn double_fault_handler(+ stack_frame: InterruptStackFrame,+ _error_code: u64, // always 0 for double faults+) -> ! {+ serial_println!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);++ // In the deliberate stack-overflow test, reaching here means success.+ #[cfg(all(feature = "headless", feature = "stack-overflow"))]+ {+ serial_println!("[ok] stack overflow was caught by the double fault handler");+ crate::exit_qemu(crate::QemuExitCode::Success);+ }++ panic!("EXCEPTION: DOUBLE FAULT");+}++// ---------------------------------------------------------------------------+// Hardware interrupt handlers+// ---------------------------------------------------------------------------++/// Timer ticks since boot. Atomic, so it's safe to touch from an interrupt.+pub static TICKS: AtomicU64 = AtomicU64::new(0);++/// IRQ 0. Fires ~18.2 times per second (the PIT's power-on default).+extern "x86-interrupt" fn timer_interrupt_handler(_stack_frame: InterruptStackFrame) {+ let ticks = TICKS.fetch_add(1, Ordering::Relaxed) + 1;++ // Show uptime in the top-right corner of the screen, updated each second.+ if ticks % 18 == 0 {+ crate::vga_buffer::show_uptime(ticks / 18);+ }++ // Headless runs: print a dot per tick to serial (proof the timer fires),+ // then stop after ~3 seconds so scripts finish on their own. (Not done in+ // normal runs, so the dots don't get mixed into your keyboard echo.)+ #[cfg(feature = "headless")]+ {+ serial_print!(".");+ if ticks % 18 == 0 {+ serial_println!(" {} ticks", ticks);+ }+ #[cfg(not(feature = "stack-overflow"))]+ if ticks >= 54 {+ serial_println!("[ok] timer works, exiting");+ crate::exit_qemu(crate::QemuExitCode::Success);+ }+ }++ // Tell the PIC we're done, or it will never send another timer interrupt.+ unsafe {+ PICS.lock().notify_end_of_interrupt(InterruptIndex::Timer.as_u8());+ }+}++/// IRQ 1. The keyboard controller has a scancode waiting at I/O port 0x60;+/// we must read it (or no more keyboard interrupts arrive), decode it with+/// `pc-keyboard`, and echo the character to the screen and serial port.+extern "x86-interrupt" fn keyboard_interrupt_handler(_stack_frame: InterruptStackFrame) {+ use pc_keyboard::{DecodedKey, HandleControl, KeyCode, Keyboard, ScancodeSet1, layouts};+ use spin::Mutex;+ use x86_64::instructions::port::Port;++ // Decoder state (shift held? multi-byte scancode in progress?) persists+ // between interrupts, so it lives in a static.+ static KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> = Mutex::new(Keyboard::new(+ ScancodeSet1::new(),+ layouts::Us104Key,+ HandleControl::Ignore,+ ));++ let mut keyboard = KEYBOARD.lock();+ let mut port = Port::new(0x60);+ let scancode: u8 = unsafe { port.read() };++ // add_byte: scancode -> key event (press/release); process_keyevent:+ // key event -> character, applying shift/caps-lock etc.+ if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {+ if let Some(key) = keyboard.process_keyevent(key_event) {+ match key {+ DecodedKey::Unicode(character) => {+ print!("{}", character);+ serial_print!("{}", character);+ }+ // Modifier keys only change how *other* keys decode; the+ // decoder already tracks them, so don't print anything.+ DecodedKey::RawKey(+ KeyCode::LShift | KeyCode::RShift | KeyCode::LControl+ | KeyCode::RControl | KeyCode::LAlt | KeyCode::RAltGr+ | KeyCode::CapsLock,+ ) => {}+ DecodedKey::RawKey(key) => {+ // Other keys without a character (arrows, F1, ...)+ print!("<{:?}>", key);+ serial_print!("<{:?}>", key);+ }+ }+ }+ }++ unsafe {+ PICS.lock().notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());+ }+}
@@ -1,4 +1,4 @@-//! A minimal freestanding x86_64 kernel.+//! A small freestanding x86_64 kernel. //! //! Boot flow: BIOS -> `bootloader` crate (switches to 64-bit long mode, sets up //! paging) -> `_start` below. There is no OS underneath us, so no `std`, no@@ -6,14 +6,17 @@ #![no_std] // don't link the Rust standard library (it needs an OS) #![no_main] // no normal `main`: the bootloader jumps to `_start`--mod serial; // COM1 serial port -> shows up in your terminal via `-serial stdio`-mod vga_buffer; // 80x25 VGA text mode screen at physical address 0xb8000+#![feature(abi_x86_interrupt)] // nightly: lets us write `extern "x86-interrupt" fn` // The bootloader is linked in by `cargo bootimage`, not called from our code; // this line just tells Rust/cargo the dependency is intentional. extern crate bootloader; +mod gdt; // GDT + TSS: gives the double-fault handler its own stack+mod interrupts; // IDT, exception handlers, PIC, timer + keyboard+mod serial; // COM1 serial port -> shows up in your terminal via `-serial stdio`+mod vga_buffer; // 80x25 VGA text mode screen at physical address 0xb8000+ use core::panic::PanicInfo; /// Kernel entry point. The bootloader calls this after setting up the CPU.@@ -21,18 +24,49 @@ use core::panic::PanicInfo; #[unsafe(no_mangle)] pub extern "C" fn _start() -> ! { println!("Hello from Tyler's Rust kernel!");- println!("VGA text mode is working. Next stop: interrupts.");- serial_println!("Hello from Tyler's Rust kernel! (via serial COM1)");- serial_println!("Kernel booted successfully."); - // In headless mode, power QEMU off so scripts/CI finish on their own.- #[cfg(feature = "headless")]- exit_qemu(QemuExitCode::Success);+ init(); + // Fire a breakpoint exception on purpose: the handler prints a message,+ // returns, and we carry on as if nothing happened.+ x86_64::instructions::interrupts::int3();+ println!("Survived the breakpoint, still running.");+ serial_println!("Survived the breakpoint, still running.");++ #[cfg(feature = "stack-overflow")]+ {+ serial_println!("Overflowing the stack on purpose...");+ stack_overflow();+ }++ println!("Type something! (keyboard echo)");+ serial_println!("Timer + keyboard interrupts enabled.");++ // Nothing else to do: sleep until the next interrupt, forever. The timer+ // and keyboard handlers do their work in between. hlt_loop(); } +/// Set up everything interrupt-related. Order matters:+/// GDT/TSS first (the IDT refers to the IST stack), then the IDT, then the+/// PICs, and only then enable interrupts (`sti`).+fn init() {+ gdt::init();+ interrupts::init_idt();+ unsafe { interrupts::PICS.lock().initialize() };+ x86_64::instructions::interrupts::enable();+}++/// Recurse forever until we run off the end of the stack into the guard page.+#[cfg(feature = "stack-overflow")]+#[allow(unconditional_recursion)]+fn stack_overflow() {+ stack_overflow();+ // Stop the compiler turning the recursion into a loop (tail call).+ unsafe { core::ptr::read_volatile(&0u8) };+}+ /// Called on `panic!`. Print the message to both outputs, then stop. #[panic_handler] fn panic(info: &PanicInfo) -> ! {@@ -43,7 +77,8 @@ fn panic(info: &PanicInfo) -> ! { hlt_loop(); } -/// Sleep the CPU until the next interrupt, forever (cheaper than `loop {}`).+/// Sleep the CPU until the next interrupt, forever (cheaper than `loop {}`,+/// which would keep a host CPU core at 100%). pub fn hlt_loop() -> ! { loop { x86_64::instructions::hlt();
@@ -18,7 +18,12 @@ lazy_static! { #[doc(hidden)] pub fn _print(args: core::fmt::Arguments) { use core::fmt::Write;- SERIAL1.lock().write_fmt(args).expect("printing to serial failed");+ // Disable interrupts while we hold the lock. Otherwise an interrupt+ // handler that also prints would spin forever waiting for a lock held by+ // the code it interrupted (a deadlock).+ x86_64::instructions::interrupts::without_interrupts(|| {+ SERIAL1.lock().write_fmt(args).expect("printing to serial failed");+ }); } /// Like `print!`, but to the serial port.
@@ -122,10 +122,47 @@ lazy_static! { }); } +/// Draw " uptime: N s " right-aligned on the top row (row 0), in a different+/// colour. The normal Writer only writes the bottom row and scrolls up, so if+/// text scrolls over this, the next update simply redraws it. Called from the+/// timer interrupt handler (interrupts already off), so taking the lock is safe.+pub fn show_uptime(seconds: u64) {+ // Format the number by hand, right to left: no heap, so no `format!`.+ let mut digits = [0u8; 20]; // u64 has at most 20 decimal digits+ let mut start = digits.len();+ let mut n = seconds;+ loop {+ start -= 1;+ digits[start] = b'0' + (n % 10) as u8;+ n /= 10;+ if n == 0 {+ break;+ }+ }++ let color = ColorCode::new(Color::Black, Color::LightGray);+ let mut writer = WRITER.lock();+ // Write the pieces one after another, ending at the right screen edge.+ let parts: [&[u8]; 3] = [b" uptime: ", &digits[start..], b"s "];+ let len: usize = parts.iter().map(|p| p.len()).sum();+ let mut col = BUFFER_WIDTH - len;+ for part in parts {+ for &byte in part {+ writer.buffer.chars[0][col].write(ScreenChar { ascii_character: byte, color_code: color });+ col += 1;+ }+ }+}+ #[doc(hidden)] pub fn _print(args: fmt::Arguments) { use core::fmt::Write;- WRITER.lock().write_fmt(args).unwrap();+ // Disable interrupts while we hold the lock. Otherwise an interrupt+ // handler that also prints would spin forever waiting for a lock held by+ // the code it interrupted (a deadlock).+ x86_64::instructions::interrupts::without_interrupts(|| {+ WRITER.lock().write_fmt(args).unwrap();+ }); } /// `print!` for the VGA screen.