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. --- rsnix/src/gop.rs | 114 +++++++++++++++++++++++++++++++++++-- rsnix/src/interrupts.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++++- rsnix/src/memory.rs | 35 +++++++++++- rsnix/src/sched.rs | 59 ++++++++++++++++--- 4 files changed, 339 insertions(+), 15 deletions(-) (limited to 'rsnix') 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; -- cgit v1.2.3