From ab7d87bcde31c1681ea09c30534dde840a58d6fa Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 1 Aug 2026 22:28:34 +0200 Subject: rsnix v0.5.2: fault diagnostics + real-hardware scroll-crash hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real-hardware scroll crash (white bands + instant freeze, QEMU-clean) was invisible because the only trace was a single serial line on machines with no serial console. This makes every crash self-identifying and hardens the real-hardware failure surfaces: - Every CPU exception vector 0-31 now has an IDT entry (macro-generated stubs; 8/14 keep their IST stacks). A fault in the shell prints a white-on-red band to the framebuffer via gop::dump_lines (raw fb writes bypassing the vt, fully clamped) plus a parseable serial line: '!!! vec=N err=… rip=… cr2=… rsp=… rflags=… task=N "name"', then halts — instead of converging to a cryptic double fault. Background-task faults abort the task as before. - Boot log now reports the authoritative post-SetMode geometry: 'gop: fb base=… size=… pitch=… WxH bpp=… bgr=…', so the next real machine's report identifies whether pitch*height exceeds the mapping. - Framebuffer writes (clear, push_rect, shift_up_n bulk push, cursor overlay) are clamped to the firmware's framebuffer_size: a pitch- padded mode degrades to a truncated view instead of faulting past the mapping. - The GOP framebuffer region is excluded from the page allocator (and subtracted from the heap's conventional-region pick) even when the firmware lists it as ConventionalMemory — kernel allocations can never overwrite the display again. The GOP protocol is located and peeked before the memory map is captured so the exclusion is known at heap-init time. - Shell gains a hidden 'fault' command (deliberate unmapped write) and 'make test-fault' verifies the dump: vec=14, cr2=0x400000000000, task=0, halt — no triple fault. All other suites re-pass. - CONTEXT.md: fault-dump format documented for real-hardware reports. --- CONTEXT.md | 23 ++++++-- Cargo.lock | 6 +- Cargo.toml | 2 +- Makefile | 7 ++- rsdos/src/main.rs | 28 ++++++---- rsnix/src/gop.rs | 114 +++++++++++++++++++++++++++++++++++-- rsnix/src/interrupts.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++++- rsnix/src/memory.rs | 35 +++++++++++- rsnix/src/sched.rs | 59 ++++++++++++++++--- scripts/test-fault.sh | 90 +++++++++++++++++++++++++++++ shell/src/lib.rs | 13 +++++ 11 files changed, 489 insertions(+), 34 deletions(-) create mode 100755 scripts/test-fault.sh diff --git a/CONTEXT.md b/CONTEXT.md index 375bf57..f068035 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -113,10 +113,13 @@ Workspace version is `a.b.c` (single version for all crates): Bump the workspace version in the root `Cargo.toml` when a change of that class lands — do not leave the version to drift. Current: -`0.5.1` — the GUI era landed with 0.5.0 (graphics layer + cursor -compositor) and the canvas was fixed at 800×600 as a `c` bump; a -future major GUI overhaul (graphics windows beyond the text surface) -would be the `a` bump (1.0.0). +`0.5.2` — the GUI era landed with 0.5.0 (graphics layer + cursor +compositor); 0.5.1 fixed the canvas at 800×600; 0.5.2 added fault +diagnostics (screen + serial dumps for every CPU exception) and +real-hardware hardening (framebuffer-size clamping, fb-region exclusion +from the allocators, authoritative fb geometry logging). A future +major GUI overhaul (graphics windows beyond the text surface) would be +the `a` bump (1.0.0). ## Project layout @@ -388,6 +391,18 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target: hardware pushes only 24 bytes at same privilege, so the extra fields are inert there; the synthetic frames set them explicitly either way. +- `interrupts`/`sched` fault dumps (0.5.2): every CPU exception + vector 0-31 has an IDT entry (macro-generated stubs; 8/14 keep + their ISTs). Any fault in the shell (task ≤ 1) prints a + white-on-red band via `gop::dump_lines` (raw fb, bypasses vt, + clamped) *and* a parseable serial line — `!!! vec=N + err=… rip=… cr2=… rsp=… rflags=… task=N "name"` — then halts; + faults in background tasks abort the task instead. Real + hardware has no serial console, so the screen band is how a + crash reports itself; a non-canonical write shows vec=13 (GP, + cr2=0), an unmapped canonical address shows vec=14 with cr2= + the address. `fault` shell command + `make test-fault` verify + the path. - Panic handling: a task panic (or guard-page overflow) is caught, logged as `sched: task N "name" panicked: …`, the task is marked Done, and the scheduler switches away — the shell keeps running. diff --git a/Cargo.lock b/Cargo.lock index 0d31a3b..942456c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "rsdos" -version = "0.5.1" +version = "0.5.2" dependencies = [ "rsnix", "shell", @@ -19,11 +19,11 @@ dependencies = [ [[package]] name = "rsnix" -version = "0.5.1" +version = "0.5.2" [[package]] name = "shell" -version = "0.5.1" +version = "0.5.2" dependencies = [ "rsdosedit", "rsnix", diff --git a/Cargo.toml b/Cargo.toml index 9bf5854..0aa38e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["rsnix", "shell", "rsdos", "rsdosedit"] [workspace.package] -version = "0.5.1" +version = "0.5.2" edition = "2024" # no_std UEFI kernel: no unwinder available, so panic must abort. diff --git a/Makefile b/Makefile index 9604893..4c57ac2 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ export BOOT_EFI=$(ESP_DIR)/EFI/BOOT/BOOTX64.EFI # Create one with: make disk (or: ./scripts/mkdisk.sh) export DISK_IMG ?= /tmp/disk.img -.PHONY: all run run-gui clean image debug release fmt bootloader boot-image disk disk-fresh inspect test-disk test-mouse test-gfx test-msg +.PHONY: all run run-gui clean image debug release fmt bootloader boot-image disk disk-fresh inspect test-disk test-mouse test-gfx test-fault test-msg # ---- Build --------------------------------------------------------------- @@ -121,6 +121,11 @@ test-mouse: test-gfx: ./scripts/test-gfx.sh +# Fault-diagnostics round trip: boot -> `fault` (deliberate page fault) -> +# verify the full dump (vec/err/rip/cr2/task) hits serial + screen -> halt. +test-fault: + ./scripts/test-fault.sh + # Automated scheduler message-queue round trip: msgrecv/send, real-input # routing into a task's queue, queue-full boundary. test-msg: diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs index 13923ea..6dbad5f 100644 --- a/rsdos/src/main.rs +++ b/rsdos/src/main.rs @@ -87,16 +87,10 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut rsnix::serial::init(); rsnix::serial_println!("=== rsnix boot ==="); - // Stash the memory map - let map_key = match rsnix::memory::capture_memory_map(bs) { - Ok(k) => k, - Err(e) => { - rsnix::serial_println!("mem: capture_memory_map failed 0x{:x}", e); - 0 - } - }; - - // Locate GOP (Graphics Output Protocol) + // Locate GOP (Graphics Output Protocol) *before* the memory map is + // captured: the heap/page allocators need to know the framebuffer + // region so they never hand out its pages (some real firmware lists + // it as conventional memory). let mut gop_interface: *mut core::ffi::c_void = core::ptr::null_mut(); let gop_guid = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID; let gop_result = unsafe { @@ -106,6 +100,20 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut &mut gop_interface, ) }; + if gop_result == EFI_SUCCESS { + let gop = unsafe { &mut *(gop_interface as *mut GraphicsOutputProtocol) }; + rsnix::gop::peek_fb_region(gop); + } + + // Stash the memory map + let map_key = match rsnix::memory::capture_memory_map(bs) { + Ok(k) => k, + Err(e) => { + rsnix::serial_println!("mem: capture_memory_map failed 0x{:x}", e); + 0 + } + }; + if gop_result == EFI_SUCCESS { let gop = unsafe { &mut *(gop_interface as *mut GraphicsOutputProtocol) }; rsnix::serial_println!("gop: framebuffer found"); diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index dad4505..47a2208 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -19,12 +19,49 @@ pub struct Framebuffer { height: usize, pitch: usize, // bytes per scan line bytes_per_pixel: usize, + /// Size of the firmware's linear framebuffer mapping, in bytes. + /// Everything written to the display is clamped to this, so a mode + /// whose `pitch * height` exceeds the actual mapping (some real + /// firmware pads the scan-line pitch but sizes the buffer for the + /// resolution) degrades to a truncated view instead of faulting. + framebuffer_size: usize, bg: u32, // packed BGRA or RGBA background colour bgr: bool, } static mut FB: Option = None; +/// The GOP framebuffer region (base + mapping size), stashed before the +/// memory map is captured so the heap and page allocator can exclude it. +/// Some real-world firmware lists the framebuffer's memory as +/// `EfiConventionalMemory`; handing those pages out would let kernel +/// allocations (stacks, DMA buffers) overwrite the display. +static mut FB_REGION_BASE: usize = 0; +static mut FB_REGION_SIZE: usize = 0; + +/// Stash the current GOP framebuffer region *before* the memory map is +/// captured (must run before `memory::capture_memory_map`). Updated +/// again after `SetMode` in `init` — the base can move. +pub fn peek_fb_region(gop: &mut GraphicsOutputProtocol) { + unsafe { + let mode = &*gop.mode; + FB_REGION_BASE = mode.framebuffer_base as usize; + FB_REGION_SIZE = mode.framebuffer_size; + } +} + +/// The framebuffer region currently excluded from the allocators +/// (base, size in bytes), if any. +pub fn framebuffer_region() -> Option<(usize, usize)> { + unsafe { + if FB_REGION_SIZE == 0 { + None + } else { + Some((FB_REGION_BASE, FB_REGION_SIZE)) + } + } +} + /// Software backbuffer: a RAM copy of the whole framebuffer (same pitch /// layout, u32 pixels). /// @@ -147,6 +184,7 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { height, pitch, bytes_per_pixel: bpp, + framebuffer_size: mode.framebuffer_size, bg, bgr, }; @@ -162,6 +200,23 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { } } + // Re-stash the post-SetMode region (base can move) for the page + // allocator, and log the authoritative geometry for diagnostics. + unsafe { + FB_REGION_BASE = mode.framebuffer_base as usize; + FB_REGION_SIZE = mode.framebuffer_size; + } + crate::serial_println!( + "gop: fb base={:#x} size={:#x} pitch={:#x} {}x{} bpp={} bgr={}", + base as usize, + { mode.framebuffer_size }, + pitch, + width, + height, + bpp, + bgr + ); + fb.clear(); unsafe { @@ -231,10 +286,17 @@ pub(crate) fn push_rect(x: usize, y: usize, w: usize, h: usize) { let w = w.min(fb.width - x); let h = h.min(fb.height - y); let row_words = fb.pitch / 4; + // Words of the firmware's actual display mapping — per-row clamp so a + // pitch-padded mode never writes past the buffer. + let fb_words = fb.framebuffer_size / 4; unsafe { for dy in 0..h { let off = (y + dy) * row_words + x; - ptr::copy_nonoverlapping(SHADOW.add(off), fb.base.add(off * 4) as *mut u32, w); + let row_w = w.min(fb_words.saturating_sub(off)); + if row_w == 0 { + continue; + } + ptr::copy_nonoverlapping(SHADOW.add(off), fb.base.add(off * 4) as *mut u32, row_w); } } overlay_cursor(x, y, w, h); @@ -275,6 +337,39 @@ pub fn fill_rect(x: usize, y: usize, w: usize, h: usize, color: u32) { push_rect(x, y, w, h); } +/// Print crash/fault lines directly to the framebuffer, bypassing the vt +/// entirely (the vt state may be corrupt when an exception fires). +/// +/// White-on-red band at the top of the screen, written through the same +/// clamped glyph path — safe even if the fault was inside the vt/gop +/// machinery, and visible on real hardware with no serial console. +pub fn dump_lines(lines: &[&str]) { + let Some(fb) = fb().as_ref() else { + return; + }; + let cols = fb.width / 8; + let rows = fb.height / 16; + let red = (0xC0u8, 0x00u8, 0x00u8); + let white = (0xFFu8, 0xFFu8, 0xFFu8); + let band = lines.len().min(rows); + // Red band behind the text. + if cols > 0 && band > 0 { + fill_rect(0, 0, cols * 8, band * 16, rgb(red.0, red.1, red.2)); + } + for (i, line) in lines.iter().enumerate().take(band) { + let mut c = 0; + for &b in line.as_bytes() { + if c >= cols { + break; + } + if b < 0x80 { + blit_glyph(c, i, b as u16, white, red); + c += 1; + } + } + } +} + /// Read one pixel back from the shadow (RAM; cheap). Test/demo use only. pub fn shadow_pixel(x: usize, y: usize) -> Option { let fb = fb().as_ref()?; @@ -433,7 +528,10 @@ fn draw_bmp(x: usize, y: usize, color: u32) { } if row & (1u16 << (11 - dx)) != 0 { let off = yy * row_words + xx; - ptr::write_volatile(fb.base.add(off * 4) as *mut u32, color); + // Clamp to the display mapping (defensive). + if off < fb.framebuffer_size / 4 { + ptr::write_volatile(fb.base.add(off * 4) as *mut u32, color); + } } } } @@ -453,7 +551,10 @@ pub fn rgb(r: u8, g: u8, b: u8) -> u32 { // --------------------------------------------------------------------------- impl Framebuffer { fn clear(&self) { - let size = self.pitch * self.height; + // Clamp the display write to the firmware's actual mapping — some + // real GPUs size the buffer for the resolution while the pitch + // (with scan-line padding) is larger. + let size = (self.pitch * self.height).min(self.framebuffer_size); // Fill with background colour (32-bit writes), display + shadow. let mut p = self.base; let end = unsafe { p.add(size) }; @@ -511,7 +612,12 @@ impl Framebuffer { // A bulk copy (rep movsb) writes in 64-byte bursts that // coalesce well on WC framebuffers — far fewer bus // transactions than per-pixel stores. - ptr::copy_nonoverlapping(SHADOW, self.base as *mut u32, words); + // + // Clamp to the firmware's mapping size; a pitch-padded mode + // whose `pitch * height` exceeds the buffer degrades to a + // truncated view instead of faulting past the mapping. + let push_words = words.min(self.framebuffer_size / 4); + ptr::copy_nonoverlapping(SHADOW, self.base as *mut u32, push_words); // The scroll copied the shadow (which never contains the // cursor); re-composite it on top of the pushed frame. crate::gop::cursor_redraw(); diff --git a/rsnix/src/interrupts.rs b/rsnix/src/interrupts.rs index 7ea0b07..be252df 100644 --- a/rsnix/src/interrupts.rs +++ b/rsnix/src/interrupts.rs @@ -269,6 +269,56 @@ core::arch::global_asm!( " push 0xFF", " jmp isr_common", "", + // Exception stubs for the remaining vectors 0-31 so every fault gets + // a diagnosable dump instead of converging to a cryptic double fault. + // Vectors that push their own error code (10, 11, 12, 13, 17, 18 — 8 + // and 14 have dedicated stubs above) skip the fake push-0. + ".macro ISR_NOERR vec", + " .globl isr_stub_x_\\vec", + " .p2align 4", + "isr_stub_x_\\vec:", + " push 0", + " push \\vec", + " jmp isr_common", + ".endm", + ".macro ISR_ERR vec", + " .globl isr_stub_x_\\vec", + " .p2align 4", + "isr_stub_x_\\vec:", + " push \\vec", + " jmp isr_common", + ".endm", + "ISR_NOERR 0", + "ISR_NOERR 1", + "ISR_NOERR 2", + "ISR_NOERR 3", + "ISR_NOERR 4", + "ISR_NOERR 5", + "ISR_NOERR 6", + "ISR_NOERR 7", + "ISR_NOERR 9", + "ISR_ERR 10", + "ISR_ERR 11", + "ISR_ERR 12", + "ISR_ERR 13", + "ISR_NOERR 15", + "ISR_NOERR 16", + "ISR_ERR 17", + "ISR_ERR 18", + "ISR_NOERR 19", + "ISR_NOERR 20", + "ISR_NOERR 21", + "ISR_NOERR 22", + "ISR_NOERR 23", + "ISR_NOERR 24", + "ISR_NOERR 25", + "ISR_NOERR 26", + "ISR_NOERR 27", + "ISR_NOERR 28", + "ISR_NOERR 29", + "ISR_NOERR 30", + "ISR_NOERR 31", + "", // Common handler — save all registers, call into Rust (which returns // the next task's frame pointer in rax), switch rsp, restore, iretq. "isr_common:", @@ -381,8 +431,9 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize { fp } 8 => { - crate::serial_println!("double fault — halting"); - halt() + // Double fault — dump the frame (we're on the IST stack + // so this is safe) and halt. + crate::sched::fault_dump("double fault", 0, frame) } 14 => { let cr2: u64; @@ -391,6 +442,11 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize { crate::sched::page_fault(cr2, fp) } 0x60 => crate::sched::on_yield(fp), + v if v < 32 => { + // Any other CPU exception: full dump (screen + serial), + // then halt the shell or abort the faulting background task. + crate::sched::fault_dump("exception", 0, frame) + } _ => fp, } }; @@ -418,6 +474,47 @@ pub fn read_mouse_byte() -> Option { /// sub-tick fraction. pub static TICKS: AtomicU64 = AtomicU64::new(0); +/// Address of the stub for CPU exception vector `vec` (0-31), or 0 for +/// vectors without a stub. 8 (double fault) and 14 (page fault) use +/// dedicated stubs that run on IST stacks. +fn exc_stub_addr(vec: u8) -> u64 { + match vec { + 8 => isr_stub_08 as *const () as u64, + 14 => isr_stub_14 as *const () as u64, + 0 => isr_stub_x_0 as *const () as u64, + 1 => isr_stub_x_1 as *const () as u64, + 2 => isr_stub_x_2 as *const () as u64, + 3 => isr_stub_x_3 as *const () as u64, + 4 => isr_stub_x_4 as *const () as u64, + 5 => isr_stub_x_5 as *const () as u64, + 6 => isr_stub_x_6 as *const () as u64, + 7 => isr_stub_x_7 as *const () as u64, + 9 => isr_stub_x_9 as *const () as u64, + 10 => isr_stub_x_10 as *const () as u64, + 11 => isr_stub_x_11 as *const () as u64, + 12 => isr_stub_x_12 as *const () as u64, + 13 => isr_stub_x_13 as *const () as u64, + 15 => isr_stub_x_15 as *const () as u64, + 16 => isr_stub_x_16 as *const () as u64, + 17 => isr_stub_x_17 as *const () as u64, + 18 => isr_stub_x_18 as *const () as u64, + 19 => isr_stub_x_19 as *const () as u64, + 20 => isr_stub_x_20 as *const () as u64, + 21 => isr_stub_x_21 as *const () as u64, + 22 => isr_stub_x_22 as *const () as u64, + 23 => isr_stub_x_23 as *const () as u64, + 24 => isr_stub_x_24 as *const () as u64, + 25 => isr_stub_x_25 as *const () as u64, + 26 => isr_stub_x_26 as *const () as u64, + 27 => isr_stub_x_27 as *const () as u64, + 28 => isr_stub_x_28 as *const () as u64, + 29 => isr_stub_x_29 as *const () as u64, + 30 => isr_stub_x_30 as *const () as u64, + 31 => isr_stub_x_31 as *const () as u64, + _ => 0, + } +} + /// Initialise the IDT, remap the PIC, set up the PIT, and enable interrupts. pub fn init() { unsafe { @@ -435,6 +532,20 @@ pub fn init() { idt_set_entry(idt_ptr, vec, spurious, cs, 0x0Eu8, 0); } + // Wire up every CPU exception vector (0-31) so any fault produces + // a diagnosable dump instead of converging to a double fault. + for vec in 0..=31u8 { + let a = exc_stub_addr(vec); + if a != 0 { + let ist = match vec { + 8 => 2u8, + 14 => 1u8, + _ => 0u8, + }; + idt_set_entry(idt_ptr, vec, a, cs, 0x0Eu8, ist); + } + } + // Override vectors for interrupt we actually handle. idt_set_entry(idt_ptr, 32, isr_stub_32 as *const () as u64, cs, 0x0Eu8, 0); idt_set_entry(idt_ptr, 33, isr_stub_33 as *const () as u64, cs, 0x0Eu8, 0); @@ -513,4 +624,35 @@ unsafe extern "C" { fn isr_stub_44(); fn isr_stub_60(); fn isr_stub_spurious(); + // Macro-generated exception stubs (isr_stub_x_). + fn isr_stub_x_0(); + fn isr_stub_x_1(); + fn isr_stub_x_2(); + fn isr_stub_x_3(); + fn isr_stub_x_4(); + fn isr_stub_x_5(); + fn isr_stub_x_6(); + fn isr_stub_x_7(); + fn isr_stub_x_9(); + fn isr_stub_x_10(); + fn isr_stub_x_11(); + fn isr_stub_x_12(); + fn isr_stub_x_13(); + fn isr_stub_x_15(); + fn isr_stub_x_16(); + fn isr_stub_x_17(); + fn isr_stub_x_18(); + fn isr_stub_x_19(); + fn isr_stub_x_20(); + fn isr_stub_x_21(); + fn isr_stub_x_22(); + fn isr_stub_x_23(); + fn isr_stub_x_24(); + fn isr_stub_x_25(); + fn isr_stub_x_26(); + fn isr_stub_x_27(); + fn isr_stub_x_28(); + fn isr_stub_x_29(); + fn isr_stub_x_30(); + fn isr_stub_x_31(); } diff --git a/rsnix/src/memory.rs b/rsnix/src/memory.rs index e03af8b..a947c53 100644 --- a/rsnix/src/memory.rs +++ b/rsnix/src/memory.rs @@ -354,14 +354,29 @@ pub unsafe fn refresh_and_exit(bs: &BootServices, image_handle: Handle) -> Resul fn init_heap() { unsafe { + // Region the firmware maps for the GOP framebuffer, if known — + // exclude it even when the memory map lists it as conventional + // (some real firmware does exactly that, which would let heap + // allocations overwrite the display). + let fb = crate::gop::framebuffer_region(); let mut best_start = 0usize; let mut best_size = 0usize; for i in 0..MAP_ENTRIES { let d = &MEMORY_MAP[i]; if d.kind == MEMORY_TYPE_CONVENTIONAL { - let sz = d.number_of_pages as usize * 4096; + let s = d.physical_start as usize; + let e = s + d.number_of_pages as usize * 4096; + // Subtract any overlap with the framebuffer region. + let (cs, ce) = match fb { + Some((b, sz)) if e > b && s < b + sz => (s.max(b), e.min(b + sz)), + _ => (e, s), // no overlap → empty range + }; + let mut sz = e - s; + if ce > cs { + sz -= ce - cs; + } if sz > best_size { - best_start = d.physical_start as usize; + best_start = s; best_size = sz; } } @@ -634,6 +649,22 @@ pub fn init_page_allocator() -> bool { } } + // Reserve the GOP framebuffer region even if the map lists it as + // conventional — kernel allocations (stacks, DMA buffers) must + // never land on the display. + if let Some((fbase, fsize)) = crate::gop::framebuffer_region() + && fsize > 0 + { + let s = fbase / PAGE_SIZE; + let e = (fbase + fsize).div_ceil(PAGE_SIZE); + for p in s..e { + let idx = p - base_page; + if idx < span_pages { + *bm.add(idx / 8) |= 1 << (idx % 8); + } + } + } + // Never hand out page 0 (the null-pointer trap page). if base_page == 0 { *bm |= 0x01; diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs index dbe3647..cfce58f 100644 --- a/rsnix/src/sched.rs +++ b/rsnix/src/sched.rs @@ -28,7 +28,9 @@ use core::fmt; use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; extern crate alloc; +use alloc::format; use alloc::string::String; +use alloc::string::ToString; use alloc::vec::Vec; use crate::interrupts::IntFrame; @@ -280,11 +282,7 @@ pub fn spawn(entry: fn(), name: &str) -> Option { }; let ok = spawn_at(id, entry, name); unlock(); - if ok { - Some(id) - } else { - None - } + if ok { Some(id) } else { None } } unsafe fn reap(id: usize) { @@ -548,10 +546,57 @@ pub fn abort_current(msg: &str) -> ! { /// Vector 14 (page fault) handler — runs on the dedicated IST stack. /// A fault in a real task (id >= 2) kills the task gracefully; anything /// else (shell, idle, pre-scheduler) is a kernel bug: halt. +/// Dump a fatal exception to serial *and* the screen (raw, bypassing the +/// vt), then halt the shell or abort the faulting background task. +/// +/// Real hardware has no serial console, so the screen dump is what lets +/// a user report a crash precisely — vector, fault address, RIP and task. +/// Runs with interrupts disabled (we're inside the ISR). +#[allow(clippy::needless_return)] +pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFrame) -> ! { + let vector = frame.vector; + let error = frame.error; + let rip = frame.rip; + let rflags = frame.rflags; + let rsp = frame.rsp; + let id = unsafe { CURRENT }; + let name = name_str(id); + let name_buf = name.to_string(); + let in_kernel = unsafe { CURRENT } <= IDLE_TID; + + // Serial: a full, parseable line. + crate::serial_println!( + "!!! {} vec={} err={:#x} rip={:#x} cr2={:#x} rsp={:#x} rflags={:#x} task={} \"{}\"", + kind, + vector, + error, + rip, + cr2, + rsp, + rflags, + id, + name_buf + ); + crate::serial_println!("!!! halting (shell task fault — machine stops)"); + + // Screen: white-on-red band via the clamped raw dump path. + let l1 = alloc::format!("!!! {} vec={} err={:#x}", kind, vector, error); + let l2 = alloc::format!("rip={:#x} cr2={:#x}", rip, cr2); + let l3 = alloc::format!("rsp={:#x} task={} {}", rsp, id, name_buf); + let lines = [l1.as_str(), l2.as_str(), l3.as_str()]; + crate::gop::dump_lines(&lines); + + if in_kernel { + crate::interrupts::halt() + } else { + abort_current(&format!("{} vec={}", kind, vector)) + } +} + pub fn page_fault(cr2: u64, frame_ptr: usize) -> usize { if !is_ready() || unsafe { CURRENT } <= IDLE_TID { - crate::serial_println!("pgfault: kernel page fault at {:#x} — halting", cr2); - crate::interrupts::halt(); + let frame = unsafe { &*(frame_ptr as *const crate::interrupts::IntFrame) }; + fault_dump("page fault", cr2, frame) } let g = unsafe { TASKS[CURRENT].guard }; let in_guard = g != 0 && cr2 >= g as u64 && cr2 < (g + memory::PAGE_SIZE) as u64; diff --git a/scripts/test-fault.sh b/scripts/test-fault.sh new file mode 100755 index 0000000..49ba791 --- /dev/null +++ b/scripts/test-fault.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# scripts/test-fault.sh — fault-diagnostics verification. +# +# Real hardware has no serial console, so a kernel crash previously left +# only a frozen screen ("unresponsive"). This test proves the fault-dump +# path: every CPU exception now prints a white-on-red band to the +# framebuffer *and* a parseable serial line (vector, error, RIP, CR2, +# task) before halting — so the next real-hardware failure identifies +# itself. The `fault` command deliberately writes to an unmapped +# address; the machine must halt with a full dump, not triple-fault. +# +# Usage: ./scripts/test-fault.sh +# Env: OVMF, QEMU (same defaults as other test scripts) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_DIR" + +OVMF="${OVMF:-/usr/share/edk2/x64/OVMF.4m.fd}" +QEMU="${QEMU:-qemu-system-x86_64}" +ESP_DIR="$PROJECT_DIR/esp" +QPID=/tmp/rsdos-fault-qpid +SOCK=/tmp/rsdos-fault-qmon.sock +LOG=/tmp/rsdos-fault-serial.log +QLOG=/tmp/rsdos-fault-qemu.log + +trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT + +fail() { + echo "FAIL: $1" + [ -f "$LOG" ] && tail -40 "$LOG" + exit 1 +} + +wait_log() { + local i=0 + while [ "$i" -lt $((${3:-60} * 10)) ]; do + grep -q "$2" "$1" 2>/dev/null && return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +echo "==> 1/5 build kernel (debug)" +cargo build --package rsdos --target x86_64-unknown-uefi + +echo "==> 2/5 deploy to ESP" +mkdir -p "$ESP_DIR/EFI/BOOT" +cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI" + +echo "==> 3/5 boot QEMU (headless)" +kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true +sleep 1 +rm -f "$SOCK" "$LOG" +"$QEMU" -bios "$OVMF" \ + -drive file=fat:rw:"$ESP_DIR",format=raw \ + -serial file:"$LOG" \ + -monitor unix:"$SOCK",server,nowait \ + -m 256M -machine q35 -cpu qemu64 -no-reboot -display none \ + >"$QLOG" 2>&1 & +echo $! >"$QPID" + +wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell" + +echo "==> 4/5 boot diagnostics present" +grep -aq "gop: fb base=" "$LOG" || fail "fb geometry diagnostic missing" +grep -aq "gop: mode .* selected" "$LOG" || fail "mode-selection diagnostic missing" + +echo "==> 5/5 inject a page fault (fault command)" +python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ + "clear" \ + "fault" +sleep 2 + +# The dump must appear on serial: vector 14, the CR2 we wrote, RIP, and +# the shell task identity — then the machine halts (never triple-faults). +grep -aq "!!! page fault vec=14 .*rip=" "$LOG" || fail "page-fault dump line missing" +grep -aq "cr2=0x400000000000" "$LOG" || fail "CR2 not reported (faulting address)" +grep -aq "task=0 \"shell\"" "$LOG" || fail "faulting task not identified" +grep -aq "!!! halting" "$LOG" || fail "no halt message" + +# The screen dump itself is exercised in-kernel (dump_lines); the serial +# path sharing the same format line is verified above. +echo "" +echo "PASS — fault dump works (vec, err, rip, cr2, task → screen + serial)" +echo " every CPU exception now halts with a diagnosable dump, not a freeze" diff --git a/shell/src/lib.rs b/shell/src/lib.rs index e1d54c8..3090776 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -721,6 +721,7 @@ write `{} TIL `", "sleep" => cmd_sleep(&parsed), "kill" => cmd_kill(&parsed), "demo" => cmd_demo(&parsed), + "fault" => cmd_fault(), _ => term_err(&alloc::format!("unknown command: {}", cmd)), } } @@ -806,6 +807,18 @@ fn cmd_hello(p: &Parsed) { pipe.end(); } +/// Deliberately fault on an unmapped address — test hook that verifies +/// the fault-dump path (screen + serial) halts cleanly instead of +/// triple-faulting. The system stops; the log must show the dump. +/// Canonical-but-unmapped address (below 2^47, above any RAM/MMIO): the +/// page walk terminates at a not-present entry → #PF with CR2 = address. +fn cmd_fault() { + term_println("fault: writing to 0x0000_4000_0000_0000..."); + unsafe { + core::ptr::write_volatile(0x0000_4000_0000_0000usize as *mut u32, 0x1234u32); + } +} + fn cmd_clear() { rsnix::gop::clear_screen(); } -- cgit v1.2.3