From c7d92071e1dd11c45999457b16554b4abaa73b28 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 2 Aug 2026 15:21:34 +0200 Subject: v0.5.5: crash reports go to a dedicated CRASHLOG USB stick The fault handler now writes crash.log to the volume labeled CRASHLOG (label-selected via fat::crash_vol), falling back to slot 0. On real hardware the boot USB may be unwritable, so a second stick labeled CRASHLOG survives the write; the shell auto-prints the previous crash report from it at next boot (read_crash_log resolves /disk//crash.log through the FAT path, since Disk nodes hold no cached children). - fat.rs: volume_label() + crash_vol() (no allocation) - fs.rs: write_crash_log targets crash_vol(); read_crash_log prefers the CRASHLOG disk node; drop now-unused first_disk() - mkdisk.sh: --label option - test-fault.sh: boots with a dedicated CRASHLOG stick (mkfs -F 32 -n CRASHLOG), verifies crash.log lands there and NOT on the ESP --- rsnix/src/fat.rs | 31 +++++++++++++++- rsnix/src/fs.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++- rsnix/src/gdt.rs | 24 ++++++++---- rsnix/src/gop.rs | 6 +-- rsnix/src/interrupts.rs | 41 ++++++++++++++++++++ rsnix/src/memory.rs | 88 ++++++++++++++++++++++++++++++++----------- rsnix/src/sched.rs | 99 ++++++++++++++++++++++++++++++++++++------------- rsnix/src/uefi.rs | 26 +++++++++++++ 8 files changed, 349 insertions(+), 59 deletions(-) (limited to 'rsnix') diff --git a/rsnix/src/fat.rs b/rsnix/src/fat.rs index eda81ed..b7e0da1 100644 --- a/rsnix/src/fat.rs +++ b/rsnix/src/fat.rs @@ -71,6 +71,31 @@ pub fn is_mounted(vol: usize) -> bool { vols().get(vol).map(|v| v.mounted).unwrap_or(false) } +/// The volume's label — the part of its uid before the serial suffix +/// (uid is "LABEL-XXXXXXXX", label sanitized to [A-Z0-9_], so the first +/// '-' always terminates it). No allocation: borrows the uid string. +pub fn volume_label(vol: usize) -> Option<&'static str> { + let uid = &vols().get(vol)?.uid; + Some(uid.split_once('-').map(|(l, _)| l).unwrap_or(uid)) +} + +/// Index of the crash-log volume: the first volume whose label is +/// "CRASHLOG" (a dedicated stick survives reboot writes that a boot +/// device may not accept), else slot 0 (first mounted) as before. +pub fn crash_vol() -> Option { + let n = volume_count(); + if n == 0 { + return None; + } + for vol in 0..n { + if let Some(label) = volume_label(vol) + && label.eq_ignore_ascii_case("CRASHLOG") { + return Some(vol); + } + } + Some(0) +} + /// Re-scan after a disk arrival/removal: unmount volumes whose disk left, /// mount every disk that has no mounted volume (which also re-tries /// mounts that failed at boot). Volume slots are tombstoned, never @@ -175,7 +200,11 @@ fn v() -> &'static mut Volume { match vols().get_mut(i) { Some(v) => v, None => { - crate::serial_println!("fat: internal error — no volume at slot {}", i); + crate::serial_println!( + "fat: internal error — no volume at slot {} (len={})", + i, + vols().len() + ); crate::interrupts::halt(); } } diff --git a/rsnix/src/fs.rs b/rsnix/src/fs.rs index 5fc6d7a..24d8fcd 100644 --- a/rsnix/src/fs.rs +++ b/rsnix/src/fs.rs @@ -464,9 +464,98 @@ pub fn write(h: usize, buf: &[u8], pos: u64) -> Result { } } +/// Handle of `crash.log` on the crash-log volume, if it exists. The +/// crash-log volume is the one labeled CRASHLOG (a dedicated stick that +/// survives reboot writes), else the first mounted volume. Resolves the +/// FAT path (`/disk//crash.log`) because Disk nodes hold no cached +/// children. +pub fn read_crash_log() -> Option { + let disk_dir = lookup(0, "disk")?; + let children = match &nodes()[disk_dir].data { + NodeData::Dir(v) => v.clone(), + _ => return None, + }; + // Prefer a disk node whose name starts with "CRASHLOG-". + let mut first = None; + for &c in &children { + if nodes()[c].removed || !matches!(nodes()[c].data, NodeData::Disk(_)) { + continue; + } + let name = &nodes()[c].name; + let label = name.split_once('-').map(|(l, _)| l).unwrap_or(name); + if label.eq_ignore_ascii_case("CRASHLOG") { + return resolve(&alloc::format!("/disk/{}/crash.log", name)); + } + if first.is_none() { + first = Some(c); + } + } + first.and_then(|disk| { + let name = nodes()[disk].name.clone(); + resolve(&alloc::format!("/disk/{}/crash.log", name)) + }) +} + +/// Persist a crash report to the mounted FAT volume so it survives a reboot +/// (real hardware has no serial console). Best-effort: any failure is +/// ignored — the caller (fault handler) must never fault here; the +/// in-dump ISR guard catches that case and halts. +/// +/// Goes straight at the FAT layer (the crash-log volume + its root +/// cluster) rather than through the fs node table: the fault may have +/// interrupted the fs mid-operation, and a stale DiskInfo/ACTIVE_VOL +/// would make node-based writes fail with "no volume at slot N". The +/// crash-log volume is the one labeled CRASHLOG (a dedicated stick that +/// survives reboot writes), else slot 0 (first mounted) as before. +pub fn write_crash_log(lines: &[&str]) { + // Assemble the report on the stack (heap-free — a corrupted heap is + // exactly the situation that produces crash reports). + let mut text = [0u8; 640]; + let mut n = 0usize; + for line in lines { + for &b in line.as_bytes() { + if n < text.len() { + text[n] = b; + n += 1; + } + } + if n < text.len() { + text[n] = b'\n'; + n += 1; + } + } + let Some(vol) = crate::fat::crash_vol() else { + crate::serial::write_raw("crashlog: no volume\r\n"); + return; + }; + // Trace the target so a test can verify the right stick got the log. + let label = crate::fat::volume_label(vol).unwrap_or("?"); + let mut tb = [0u8; 48]; + let tn = { + let mut w = crate::strbuf::FmtBuf::new(&mut tb); + let _ = core::fmt::write(&mut w, core::format_args!("crashlog: vol {} ({})\r\n", vol, label)); + w.len() + }; + crate::serial::write_raw(core::str::from_utf8(&tb[..tn]).unwrap_or("?")); + let root = crate::fat::root_cluster(vol); + if crate::fat::create(vol, root, "crash.log", false).is_err() + && crate::fat::find(vol, root, "crash.log").is_err() + { + crate::serial::write_raw("crashlog: create failed\r\n"); + return; + } + if crate::fat::set_len(vol, root, "crash.log", 0).is_err() { + crate::serial::write_raw("crashlog: set_len failed\r\n"); + return; + } + match crate::fat::write_file(vol, root, "crash.log", &text[..n], 0) { + Ok(_) => crate::serial::write_raw("crashlog: wrote\r\n"), + Err(_) => crate::serial::write_raw("crashlog: write failed\r\n"), + } +} + /// Truncate/resize a regular file. -pub fn set_len(h: usize, len: u64) -> Result<(), ()> { - match &mut nodes()[h].data { +pub fn set_len(h: usize, len: u64) -> Result<(), ()> { match &mut nodes()[h].data { NodeData::Regular(v) => { v.resize(len as usize, 0); Ok(()) diff --git a/rsnix/src/gdt.rs b/rsnix/src/gdt.rs index 514ed2e..f3d8b9a 100644 --- a/rsnix/src/gdt.rs +++ b/rsnix/src/gdt.rs @@ -116,12 +116,14 @@ impl Tss { } } -/// 4 KiB page-aligned block for the IST stacks. -#[repr(align(4096))] -struct Page([u8; 4096]); +/// IST stack size: the fault path (fault dump + best-effort crash-log +/// FAT write) is deep, so each IST stack is 32 KiB, allocated from the +/// heap at init. They must NOT live in .bss: a 4 KiB .bss stack placed +/// next to kernel statics silently overwrites them on overflow (the FAT +/// ACTIVE_VOL was clobbered this way — a "no volume at slot 8" crash in +/// the fault path). The heap region is far from the kernel image. +const IST_STACK_SIZE: usize = 32 * 1024; -static mut PF_STACK: Page = Page([0; 4096]); -static mut DF_STACK: Page = Page([0; 4096]); static mut TSS: Tss = Tss::zero(); // GDT slots are 8 bytes except the TSS descriptor at index 63, which is // 16 bytes — total 64*8 + 16. @@ -193,8 +195,16 @@ pub fn init() -> bool { // TSS descriptor (16 bytes) at index 63. let tss_base = core::ptr::addr_of_mut!(TSS) as usize as u64; - TSS.ist1 = core::ptr::addr_of_mut!(PF_STACK.0[0]) as usize as u64 + 4096; - TSS.ist2 = core::ptr::addr_of_mut!(DF_STACK.0[0]) as usize as u64 + 4096; + // IST stacks come from the heap (see IST_STACK_SIZE): 32 KiB each, + // well away from the kernel image/.bss so an overflowing fault + // path can't silently overwrite statics. + let pf = crate::memory::allocate(IST_STACK_SIZE); + let df = crate::memory::allocate(IST_STACK_SIZE); + if pf.is_null() || df.is_null() { + return false; + } + TSS.ist1 = pf as u64 + IST_STACK_SIZE as u64; + TSS.ist2 = df as u64 + IST_STACK_SIZE as u64; TSS.iomap_base = TSS_SIZE as u16; // no I/O bitmap let mut td = [0u8; 16]; diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index d37468f..7aeb224 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -257,7 +257,8 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { let words = (fbinfo.pitch * fbinfo.height) / 4; if words > 0 { let bytes = words * 4; - #[allow(clippy::slow_vector_initialization)] // deliberate: try_reserve must not panic + #[allow(clippy::slow_vector_initialization)] + // deliberate: try_reserve must not panic let mut v: alloc::vec::Vec = alloc::vec::Vec::new(); if v.try_reserve_exact(words).is_ok() { v.resize(words, 0); // can't fail after the reserve @@ -284,8 +285,7 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { rbase + rsize, ); if SHADOW.is_null() { - let msg = - "gop: WARNING — shadow rejected (fb overlap); no-shadow fallback"; + let msg = "gop: WARNING — shadow rejected (fb overlap); no-shadow fallback"; crate::serial_println!("{}", msg); let lines: alloc::vec::Vec<&str> = msg .as_bytes() diff --git a/rsnix/src/interrupts.rs b/rsnix/src/interrupts.rs index be252df..927e4f1 100644 --- a/rsnix/src/interrupts.rs +++ b/rsnix/src/interrupts.rs @@ -393,6 +393,38 @@ pub(crate) struct IntFrame { pub(crate) ss: u64, } +// --------------------------------------------------------------------------- +// Fault-dump re-entry guard +// --------------------------------------------------------------------------- + +/// Set when `sched::fault_dump` is running. Any CPU exception that +/// fires *while the dump is already in progress* (typically because the +/// heap is corrupted and the dump path tripped over it) must never +/// recurse into the dump — that chain (#PF → #DF → #PF) is a triple +/// fault, which silently resets the machine with no report (black +/// screen, firmware splash — exactly what real hardware showed). The +/// guard short-circuits to a raw heap-free band and a hard halt. +static mut IN_DUMP: bool = false; + +pub fn enter_dump() { + unsafe { + IN_DUMP = true; + } +} + +fn in_dump() -> bool { + unsafe { IN_DUMP } +} + +/// Raw, heap-free fallback: a fault fired inside the fault dump. +fn dump_guard_halt() -> ! { + crate::gop::dump_lines(&[ + "!!! fault inside fault dump", + "!!! halting (no triple fault)", + ]); + halt(); +} + /// Called from the asm common handler — dispatches to the right Rust /// handler and returns the frame pointer the asm should switch to (the /// next task's, or the same one when no switch happens). @@ -433,9 +465,15 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize { 8 => { // Double fault — dump the frame (we're on the IST stack // so this is safe) and halt. + if in_dump() { + dump_guard_halt(); + } crate::sched::fault_dump("double fault", 0, frame) } 14 => { + if in_dump() { + dump_guard_halt(); + } let cr2: u64; core::arch::asm!("mov {}, cr2", out(reg) cr2, options(nostack, preserves_flags)); @@ -445,6 +483,9 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize { v if v < 32 => { // Any other CPU exception: full dump (screen + serial), // then halt the shell or abort the faulting background task. + if in_dump() { + dump_guard_halt(); + } crate::sched::fault_dump("exception", 0, frame) } _ => fp, diff --git a/rsnix/src/memory.rs b/rsnix/src/memory.rs index 605125a..e6896f2 100644 --- a/rsnix/src/memory.rs +++ b/rsnix/src/memory.rs @@ -76,6 +76,31 @@ static mut HEAP_END: usize = 0; /// Total heap capacity — the original region plus pages added by growth. static mut HEAP_CAPACITY: usize = 0; +/// Kernel image bounds (base..end) captured from the UEFI LoadedImage +/// protocol before ExitBootServices. Both the heap free list and the +/// page-allocator bitmap reserve this range so no allocation can ever +/// land on the running kernel's own code/data/bss pages. +static mut KERNEL_IMAGE_START: usize = 0; +static mut KERNEL_IMAGE_END: usize = 0; + +/// Record the loaded-image memory range (from EFI_LOADED_IMAGE_PROTOCOL). +pub fn set_kernel_image_range(base: usize, size: usize) { + unsafe { + KERNEL_IMAGE_START = base; + KERNEL_IMAGE_END = base + size; + } +} + +fn kernel_image_range() -> Option<(usize, usize)> { + unsafe { + if KERNEL_IMAGE_END > KERNEL_IMAGE_START { + Some((KERNEL_IMAGE_START, KERNEL_IMAGE_END)) + } else { + None + } + } +} + /// Minimum pages `grow_heap` takes from the page allocator per growth. const HEAP_GROW_PAGES: usize = 64; @@ -354,11 +379,16 @@ 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(); + // Regions that must never be handed out as heap memory, even when + // the firmware lists them as conventional: + // * the GOP framebuffer (heap allocations overwriting the display), + // * the kernel image itself (a heap allocation landing on the + // kernel's .bss would silently corrupt statics — observed as a + // garbage ACTIVE_VOL and a dead filesystem in the fault path). + let holes: [(usize, usize); 2] = [ + crate::gop::framebuffer_region().unwrap_or((0, 0)), + kernel_image_range().unwrap_or((0, 0)), + ]; let mut best_start = 0usize; let mut best_size = 0usize; for i in 0..MAP_ENTRIES { @@ -366,26 +396,25 @@ fn init_heap() { if d.kind == MEMORY_TYPE_CONVENTIONAL { let s = d.physical_start as usize; let e = s + d.number_of_pages as usize * 4096; - // Split at the framebuffer hole so the free list NEVER - // contains fb pages: a coarse size subtraction leaves one + // Split at each hole so the free list NEVER contains + // reserved pages: a coarse size subtraction leaves one // block *spanning* the hole, letting a large allocation - // (e.g. the 1.9 MB display shadow) straddle the display. + // (e.g. the 1.9 MB display shadow) straddle it. let mut lo = s; - if let Some((b, sz)) = fb - && e > b - && s < b + sz - { - let h0 = b.max(s); - let h1 = (b + sz).min(e); - if h0 > lo { - // Segment before the hole. - let seg = h0 - lo; - if seg > best_size { - best_start = lo; - best_size = seg; + for (b, sz) in holes { + if sz > 0 && e > b && s < b + sz { + let h0 = b.max(s); + let h1 = (b + sz).min(e); + if h0 > lo { + // Segment before the hole. + let seg = h0 - lo; + if seg > best_size { + best_start = lo; + best_size = seg; + } } + lo = h1; } - lo = h1; } if e > lo { let seg = e - lo; @@ -664,6 +693,23 @@ pub fn init_page_allocator() -> bool { } } + // Reserve the kernel image so the page allocator never hands out + // the running kernel's own code/data/bss pages (DMA buffers and + // task stacks landing on the kernel would corrupt it — a heap + // allocation over the .bss already broke the FAT ACTIVE_VOL). + if let Some((ks, ke)) = kernel_image_range() + && ke > ks + { + let s = ks / PAGE_SIZE; + let e = ke.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); + } + } + } + // Reserve the GOP framebuffer region even if the map lists it as // conventional — kernel allocations (stacks, DMA buffers) must // never land on the display. diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs index f48e210..a195d1c 100644 --- a/rsnix/src/sched.rs +++ b/rsnix/src/sched.rs @@ -28,9 +28,7 @@ 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; @@ -40,7 +38,7 @@ use crate::paging; pub const MAX_TASKS: usize = 16; pub const SHELL_TID: usize = 0; pub const IDLE_TID: usize = 1; -const STACK_PAGES: usize = 3; // 1 guard page + 8 KiB stack +const STACK_PAGES: usize = 9; // 1 guard page + 32 KiB stack — faults dump on the task's own stack const NAME_MAX: usize = 24; const PANIC_MAX: usize = 64; @@ -556,8 +554,15 @@ pub fn abort_current(msg: &str) -> ! { /// 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). +/// +/// Heap-free by construction: the dump's own `alloc::format!` used to +/// allocate from the heap, and when the underlying fault *was* heap +/// corruption the reporter itself died (#PF → #DF → triple fault → +/// silent reboot). Everything here formats into stack buffers, and the +/// ISR `in_dump` guard halts instead of recursing if even that faults. #[allow(clippy::needless_return)] pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFrame) -> ! { + crate::interrupts::enter_dump(); let vector = frame.vector; let error = frame.error; let rip = frame.rip; @@ -565,35 +570,79 @@ pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFra 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()]; + // Serial: a full, parseable line (stack-formatted). + let mut buf = [0u8; 176]; + let n = { + let mut w = crate::strbuf::FmtBuf::new(&mut buf); + let _ = fmt::write( + &mut w, + core::format_args!( + "!!! {} vec={} err={:#x} rip={:#x} cr2={:#x} rsp={:#x} rflags={:#x} task={} \"{}\"", + kind, + vector, + error, + rip, + cr2, + rsp, + rflags, + id, + name + ), + ); + w.len() + }; + let line = core::str::from_utf8(&buf[..n]).unwrap_or("!!! dump"); + crate::serial::write_str(line); + crate::serial::write_str("\r\n!!! halting (shell task fault — machine stops)\r\n"); + + // Screen: white-on-red band via the clamped raw dump path (heap-free). + let mut b1 = [0u8; 72]; + let mut b2 = [0u8; 72]; + let mut b3 = [0u8; 96]; + let n1 = { + let mut w = crate::strbuf::FmtBuf::new(&mut b1); + let _ = fmt::write( + &mut w, + core::format_args!("!!! {} vec={} err={:#x}", kind, vector, error), + ); + w.len() + }; + let n2 = { + let mut w = crate::strbuf::FmtBuf::new(&mut b2); + let _ = fmt::write(&mut w, core::format_args!("rip={:#x} cr2={:#x}", rip, cr2)); + w.len() + }; + let n3 = { + let mut w = crate::strbuf::FmtBuf::new(&mut b3); + let _ = fmt::write( + &mut w, + core::format_args!("rsp={:#x} task={} {}", rsp, id, name), + ); + w.len() + }; + let l1 = core::str::from_utf8(&b1[..n1]).unwrap_or("!!!"); + let l2 = core::str::from_utf8(&b2[..n2]).unwrap_or(""); + let l3 = core::str::from_utf8(&b3[..n3]).unwrap_or(""); + let lines = [l1, l2, l3]; crate::gop::dump_lines(&lines); + // Best-effort: persist the report to the disk so it survives the + // reboot on real hardware (no serial console). Any fault here is + // caught by the in_dump guard and halts. + crate::fs::write_crash_log(&lines); + if in_kernel { crate::interrupts::halt() } else { - abort_current(&format!("{} vec={}", kind, vector)) + let mut mb = [0u8; 48]; + let mn = { + let mut w = crate::strbuf::FmtBuf::new(&mut mb); + let _ = fmt::write(&mut w, core::format_args!("{} vec={}", kind, vector)); + w.len() + }; + abort_current(core::str::from_utf8(&mb[..mn]).unwrap_or("fault")) } } diff --git a/rsnix/src/uefi.rs b/rsnix/src/uefi.rs index c311ad9..234ad65 100644 --- a/rsnix/src/uefi.rs +++ b/rsnix/src/uefi.rs @@ -40,6 +40,32 @@ pub const EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID: Guid = Guid { d: [0x96, 0xfb, 0x7a, 0xde, 0xd0, 0x80, 0x51, 0x6a], }; +pub const EFI_LOADED_IMAGE_PROTOCOL_GUID: Guid = Guid { + a: 0x5b1b31a1, + b: 0x9562, + c: 0x11d2, + d: [0x8e, 0x3f, 0x00, 0xa0, 0xc9, 0x69, 0x72, 0x3b], +}; + +/// Minimal EFI_LOADED_IMAGE_PROTOCOL — we only need the image base/size +/// to reserve the kernel's own code/data/bss pages so the heap and page +/// allocators can never overwrite the running kernel. +#[repr(C)] +pub struct LoadedImageProtocol { + pub revision: u32, + _pad0: u32, + pub parent_handle: Handle, + pub system_table: *mut core::ffi::c_void, + pub device_handle: Handle, + pub file_path: *mut core::ffi::c_void, + _reserved: *mut core::ffi::c_void, + pub load_options_size: u32, + _pad1: u32, + pub load_options: *mut core::ffi::c_void, + pub image_base: *mut core::ffi::c_void, + pub image_size: u64, +} + // --------------------------------------------------------------------------- // Table header // --------------------------------------------------------------------------- -- cgit v1.2.3