summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-01 22:28:34 +0200
committersalmonik <salmonik@tuta.io>2026-08-01 22:28:34 +0200
commitab7d87bcde31c1681ea09c30534dde840a58d6fa (patch)
tree42eed2c86663cf6ecfa3507ca46399e31b441c99
parentrsboot: track latest upstream HEAD (92367a8) instead of pinned ee1c85b (diff)
downloadrs-dos-ab7d87bcde31c1681ea09c30534dde840a58d6fa.tar.gz
rs-dos-ab7d87bcde31c1681ea09c30534dde840a58d6fa.zip
rsnix v0.5.2: fault diagnostics + real-hardware scroll-crash hardening
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: '!!! <kind> 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.
Diffstat (limited to '')
-rw-r--r--CONTEXT.md23
-rw-r--r--Cargo.lock6
-rw-r--r--Cargo.toml2
-rw-r--r--Makefile7
-rw-r--r--rsdos/src/main.rs28
-rw-r--r--rsnix/src/gop.rs114
-rw-r--r--rsnix/src/interrupts.rs146
-rw-r--r--rsnix/src/memory.rs35
-rw-r--r--rsnix/src/sched.rs59
-rwxr-xr-xscripts/test-fault.sh90
-rw-r--r--shell/src/lib.rs13
11 files changed, 489 insertions, 34 deletions
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):
113 113
114Bump the workspace version in the root `Cargo.toml` when a change of 114Bump the workspace version in the root `Cargo.toml` when a change of
115that class lands — do not leave the version to drift. Current: 115that class lands — do not leave the version to drift. Current:
116`0.5.1` — the GUI era landed with 0.5.0 (graphics layer + cursor 116`0.5.2` — the GUI era landed with 0.5.0 (graphics layer + cursor
117compositor) and the canvas was fixed at 800×600 as a `c` bump; a 117compositor); 0.5.1 fixed the canvas at 800×600; 0.5.2 added fault
118future major GUI overhaul (graphics windows beyond the text surface) 118diagnostics (screen + serial dumps for every CPU exception) and
119would be the `a` bump (1.0.0). 119real-hardware hardening (framebuffer-size clamping, fb-region exclusion
120from the allocators, authoritative fb geometry logging). A future
121major GUI overhaul (graphics windows beyond the text surface) would be
122the `a` bump (1.0.0).
120 123
121## Project layout 124## Project layout
122 125
@@ -388,6 +391,18 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target:
388 hardware pushes only 24 bytes at same privilege, so the extra 391 hardware pushes only 24 bytes at same privilege, so the extra
389 fields are inert there; the synthetic frames set them 392 fields are inert there; the synthetic frames set them
390 explicitly either way. 393 explicitly either way.
394- `interrupts`/`sched` fault dumps (0.5.2): every CPU exception
395 vector 0-31 has an IDT entry (macro-generated stubs; 8/14 keep
396 their ISTs). Any fault in the shell (task ≤ 1) prints a
397 white-on-red band via `gop::dump_lines` (raw fb, bypasses vt,
398 clamped) *and* a parseable serial line — `!!! <kind> vec=N
399 err=… rip=… cr2=… rsp=… rflags=… task=N "name"` — then halts;
400 faults in background tasks abort the task instead. Real
401 hardware has no serial console, so the screen band is how a
402 crash reports itself; a non-canonical write shows vec=13 (GP,
403 cr2=0), an unmapped canonical address shows vec=14 with cr2=
404 the address. `fault` shell command + `make test-fault` verify
405 the path.
391- Panic handling: a task panic (or guard-page overflow) is caught, 406- Panic handling: a task panic (or guard-page overflow) is caught,
392 logged as `sched: task N "name" panicked: …`, the task is marked 407 logged as `sched: task N "name" panicked: …`, the task is marked
393 Done, and the scheduler switches away — the shell keeps running. 408 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
4 4
5[[package]] 5[[package]]
6name = "rsdos" 6name = "rsdos"
7version = "0.5.1" 7version = "0.5.2"
8dependencies = [ 8dependencies = [
9 "rsnix", 9 "rsnix",
10 "shell", 10 "shell",
@@ -19,11 +19,11 @@ dependencies = [
19 19
20[[package]] 20[[package]]
21name = "rsnix" 21name = "rsnix"
22version = "0.5.1" 22version = "0.5.2"
23 23
24[[package]] 24[[package]]
25name = "shell" 25name = "shell"
26version = "0.5.1" 26version = "0.5.2"
27dependencies = [ 27dependencies = [
28 "rsdosedit", 28 "rsdosedit",
29 "rsnix", 29 "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"
3members = ["rsnix", "shell", "rsdos", "rsdosedit"] 3members = ["rsnix", "shell", "rsdos", "rsdosedit"]
4 4
5[workspace.package] 5[workspace.package]
6version = "0.5.1" 6version = "0.5.2"
7edition = "2024" 7edition = "2024"
8 8
9# no_std UEFI kernel: no unwinder available, so panic must abort. 9# 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
14# Create one with: make disk (or: ./scripts/mkdisk.sh) 14# Create one with: make disk (or: ./scripts/mkdisk.sh)
15export DISK_IMG ?= /tmp/disk.img 15export DISK_IMG ?= /tmp/disk.img
16 16
17.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 17.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
18 18
19# ---- Build --------------------------------------------------------------- 19# ---- Build ---------------------------------------------------------------
20 20
@@ -121,6 +121,11 @@ test-mouse:
121test-gfx: 121test-gfx:
122 ./scripts/test-gfx.sh 122 ./scripts/test-gfx.sh
123 123
124# Fault-diagnostics round trip: boot -> `fault` (deliberate page fault) ->
125# verify the full dump (vec/err/rip/cr2/task) hits serial + screen -> halt.
126test-fault:
127 ./scripts/test-fault.sh
128
124# Automated scheduler message-queue round trip: msgrecv/send, real-input 129# Automated scheduler message-queue round trip: msgrecv/send, real-input
125# routing into a task's queue, queue-full boundary. 130# routing into a task's queue, queue-full boundary.
126test-msg: 131test-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
87 rsnix::serial::init(); 87 rsnix::serial::init();
88 rsnix::serial_println!("=== rsnix boot ==="); 88 rsnix::serial_println!("=== rsnix boot ===");
89 89
90 // Stash the memory map 90 // Locate GOP (Graphics Output Protocol) *before* the memory map is
91 let map_key = match rsnix::memory::capture_memory_map(bs) { 91 // captured: the heap/page allocators need to know the framebuffer
92 Ok(k) => k, 92 // region so they never hand out its pages (some real firmware lists
93 Err(e) => { 93 // it as conventional memory).
94 rsnix::serial_println!("mem: capture_memory_map failed 0x{:x}", e);
95 0
96 }
97 };
98
99 // Locate GOP (Graphics Output Protocol)
100 let mut gop_interface: *mut core::ffi::c_void = core::ptr::null_mut(); 94 let mut gop_interface: *mut core::ffi::c_void = core::ptr::null_mut();
101 let gop_guid = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID; 95 let gop_guid = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID;
102 let gop_result = unsafe { 96 let gop_result = unsafe {
@@ -108,6 +102,20 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut
108 }; 102 };
109 if gop_result == EFI_SUCCESS { 103 if gop_result == EFI_SUCCESS {
110 let gop = unsafe { &mut *(gop_interface as *mut GraphicsOutputProtocol) }; 104 let gop = unsafe { &mut *(gop_interface as *mut GraphicsOutputProtocol) };
105 rsnix::gop::peek_fb_region(gop);
106 }
107
108 // Stash the memory map
109 let map_key = match rsnix::memory::capture_memory_map(bs) {
110 Ok(k) => k,
111 Err(e) => {
112 rsnix::serial_println!("mem: capture_memory_map failed 0x{:x}", e);
113 0
114 }
115 };
116
117 if gop_result == EFI_SUCCESS {
118 let gop = unsafe { &mut *(gop_interface as *mut GraphicsOutputProtocol) };
111 rsnix::serial_println!("gop: framebuffer found"); 119 rsnix::serial_println!("gop: framebuffer found");
112 rsnix::gop::init(gop); 120 rsnix::gop::init(gop);
113 } else { 121 } else {
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 {
19 height: usize, 19 height: usize,
20 pitch: usize, // bytes per scan line 20 pitch: usize, // bytes per scan line
21 bytes_per_pixel: usize, 21 bytes_per_pixel: usize,
22 /// Size of the firmware's linear framebuffer mapping, in bytes.
23 /// Everything written to the display is clamped to this, so a mode
24 /// whose `pitch * height` exceeds the actual mapping (some real
25 /// firmware pads the scan-line pitch but sizes the buffer for the
26 /// resolution) degrades to a truncated view instead of faulting.
27 framebuffer_size: usize,
22 bg: u32, // packed BGRA or RGBA background colour 28 bg: u32, // packed BGRA or RGBA background colour
23 bgr: bool, 29 bgr: bool,
24} 30}
25 31
26static mut FB: Option<Framebuffer> = None; 32static mut FB: Option<Framebuffer> = None;
27 33
34/// The GOP framebuffer region (base + mapping size), stashed before the
35/// memory map is captured so the heap and page allocator can exclude it.
36/// Some real-world firmware lists the framebuffer's memory as
37/// `EfiConventionalMemory`; handing those pages out would let kernel
38/// allocations (stacks, DMA buffers) overwrite the display.
39static mut FB_REGION_BASE: usize = 0;
40static mut FB_REGION_SIZE: usize = 0;
41
42/// Stash the current GOP framebuffer region *before* the memory map is
43/// captured (must run before `memory::capture_memory_map`). Updated
44/// again after `SetMode` in `init` — the base can move.
45pub fn peek_fb_region(gop: &mut GraphicsOutputProtocol) {
46 unsafe {
47 let mode = &*gop.mode;
48 FB_REGION_BASE = mode.framebuffer_base as usize;
49 FB_REGION_SIZE = mode.framebuffer_size;
50 }
51}
52
53/// The framebuffer region currently excluded from the allocators
54/// (base, size in bytes), if any.
55pub fn framebuffer_region() -> Option<(usize, usize)> {
56 unsafe {
57 if FB_REGION_SIZE == 0 {
58 None
59 } else {
60 Some((FB_REGION_BASE, FB_REGION_SIZE))
61 }
62 }
63}
64
28/// Software backbuffer: a RAM copy of the whole framebuffer (same pitch 65/// Software backbuffer: a RAM copy of the whole framebuffer (same pitch
29/// layout, u32 pixels). 66/// layout, u32 pixels).
30/// 67///
@@ -147,6 +184,7 @@ pub fn init(gop: &mut GraphicsOutputProtocol) {
147 height, 184 height,
148 pitch, 185 pitch,
149 bytes_per_pixel: bpp, 186 bytes_per_pixel: bpp,
187 framebuffer_size: mode.framebuffer_size,
150 bg, 188 bg,
151 bgr, 189 bgr,
152 }; 190 };
@@ -162,6 +200,23 @@ pub fn init(gop: &mut GraphicsOutputProtocol) {
162 } 200 }
163 } 201 }
164 202
203 // Re-stash the post-SetMode region (base can move) for the page
204 // allocator, and log the authoritative geometry for diagnostics.
205 unsafe {
206 FB_REGION_BASE = mode.framebuffer_base as usize;
207 FB_REGION_SIZE = mode.framebuffer_size;
208 }
209 crate::serial_println!(
210 "gop: fb base={:#x} size={:#x} pitch={:#x} {}x{} bpp={} bgr={}",
211 base as usize,
212 { mode.framebuffer_size },
213 pitch,
214 width,
215 height,
216 bpp,
217 bgr
218 );
219
165 fb.clear(); 220 fb.clear();
166 221
167 unsafe { 222 unsafe {
@@ -231,10 +286,17 @@ pub(crate) fn push_rect(x: usize, y: usize, w: usize, h: usize) {
231 let w = w.min(fb.width - x); 286 let w = w.min(fb.width - x);
232 let h = h.min(fb.height - y); 287 let h = h.min(fb.height - y);
233 let row_words = fb.pitch / 4; 288 let row_words = fb.pitch / 4;
289 // Words of the firmware's actual display mapping — per-row clamp so a
290 // pitch-padded mode never writes past the buffer.
291 let fb_words = fb.framebuffer_size / 4;
234 unsafe { 292 unsafe {
235 for dy in 0..h { 293 for dy in 0..h {
236 let off = (y + dy) * row_words + x; 294 let off = (y + dy) * row_words + x;
237 ptr::copy_nonoverlapping(SHADOW.add(off), fb.base.add(off * 4) as *mut u32, w); 295 let row_w = w.min(fb_words.saturating_sub(off));
296 if row_w == 0 {
297 continue;
298 }
299 ptr::copy_nonoverlapping(SHADOW.add(off), fb.base.add(off * 4) as *mut u32, row_w);
238 } 300 }
239 } 301 }
240 overlay_cursor(x, y, w, h); 302 overlay_cursor(x, y, w, h);
@@ -275,6 +337,39 @@ pub fn fill_rect(x: usize, y: usize, w: usize, h: usize, color: u32) {
275 push_rect(x, y, w, h); 337 push_rect(x, y, w, h);
276} 338}
277 339
340/// Print crash/fault lines directly to the framebuffer, bypassing the vt
341/// entirely (the vt state may be corrupt when an exception fires).
342///
343/// White-on-red band at the top of the screen, written through the same
344/// clamped glyph path — safe even if the fault was inside the vt/gop
345/// machinery, and visible on real hardware with no serial console.
346pub fn dump_lines(lines: &[&str]) {
347 let Some(fb) = fb().as_ref() else {
348 return;
349 };
350 let cols = fb.width / 8;
351 let rows = fb.height / 16;
352 let red = (0xC0u8, 0x00u8, 0x00u8);
353 let white = (0xFFu8, 0xFFu8, 0xFFu8);
354 let band = lines.len().min(rows);
355 // Red band behind the text.
356 if cols > 0 && band > 0 {
357 fill_rect(0, 0, cols * 8, band * 16, rgb(red.0, red.1, red.2));
358 }
359 for (i, line) in lines.iter().enumerate().take(band) {
360 let mut c = 0;
361 for &b in line.as_bytes() {
362 if c >= cols {
363 break;
364 }
365 if b < 0x80 {
366 blit_glyph(c, i, b as u16, white, red);
367 c += 1;
368 }
369 }
370 }
371}
372
278/// Read one pixel back from the shadow (RAM; cheap). Test/demo use only. 373/// Read one pixel back from the shadow (RAM; cheap). Test/demo use only.
279pub fn shadow_pixel(x: usize, y: usize) -> Option<u32> { 374pub fn shadow_pixel(x: usize, y: usize) -> Option<u32> {
280 let fb = fb().as_ref()?; 375 let fb = fb().as_ref()?;
@@ -433,7 +528,10 @@ fn draw_bmp(x: usize, y: usize, color: u32) {
433 } 528 }
434 if row & (1u16 << (11 - dx)) != 0 { 529 if row & (1u16 << (11 - dx)) != 0 {
435 let off = yy * row_words + xx; 530 let off = yy * row_words + xx;
436 ptr::write_volatile(fb.base.add(off * 4) as *mut u32, color); 531 // Clamp to the display mapping (defensive).
532 if off < fb.framebuffer_size / 4 {
533 ptr::write_volatile(fb.base.add(off * 4) as *mut u32, color);
534 }
437 } 535 }
438 } 536 }
439 } 537 }
@@ -453,7 +551,10 @@ pub fn rgb(r: u8, g: u8, b: u8) -> u32 {
453// --------------------------------------------------------------------------- 551// ---------------------------------------------------------------------------
454impl Framebuffer { 552impl Framebuffer {
455 fn clear(&self) { 553 fn clear(&self) {
456 let size = self.pitch * self.height; 554 // Clamp the display write to the firmware's actual mapping — some
555 // real GPUs size the buffer for the resolution while the pitch
556 // (with scan-line padding) is larger.
557 let size = (self.pitch * self.height).min(self.framebuffer_size);
457 // Fill with background colour (32-bit writes), display + shadow. 558 // Fill with background colour (32-bit writes), display + shadow.
458 let mut p = self.base; 559 let mut p = self.base;
459 let end = unsafe { p.add(size) }; 560 let end = unsafe { p.add(size) };
@@ -511,7 +612,12 @@ impl Framebuffer {
511 // A bulk copy (rep movsb) writes in 64-byte bursts that 612 // A bulk copy (rep movsb) writes in 64-byte bursts that
512 // coalesce well on WC framebuffers — far fewer bus 613 // coalesce well on WC framebuffers — far fewer bus
513 // transactions than per-pixel stores. 614 // transactions than per-pixel stores.
514 ptr::copy_nonoverlapping(SHADOW, self.base as *mut u32, words); 615 //
616 // Clamp to the firmware's mapping size; a pitch-padded mode
617 // whose `pitch * height` exceeds the buffer degrades to a
618 // truncated view instead of faulting past the mapping.
619 let push_words = words.min(self.framebuffer_size / 4);
620 ptr::copy_nonoverlapping(SHADOW, self.base as *mut u32, push_words);
515 // The scroll copied the shadow (which never contains the 621 // The scroll copied the shadow (which never contains the
516 // cursor); re-composite it on top of the pushed frame. 622 // cursor); re-composite it on top of the pushed frame.
517 crate::gop::cursor_redraw(); 623 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!(
269 " push 0xFF", 269 " push 0xFF",
270 " jmp isr_common", 270 " jmp isr_common",
271 "", 271 "",
272 // Exception stubs for the remaining vectors 0-31 so every fault gets
273 // a diagnosable dump instead of converging to a cryptic double fault.
274 // Vectors that push their own error code (10, 11, 12, 13, 17, 18 — 8
275 // and 14 have dedicated stubs above) skip the fake push-0.
276 ".macro ISR_NOERR vec",
277 " .globl isr_stub_x_\\vec",
278 " .p2align 4",
279 "isr_stub_x_\\vec:",
280 " push 0",
281 " push \\vec",
282 " jmp isr_common",
283 ".endm",
284 ".macro ISR_ERR vec",
285 " .globl isr_stub_x_\\vec",
286 " .p2align 4",
287 "isr_stub_x_\\vec:",
288 " push \\vec",
289 " jmp isr_common",
290 ".endm",
291 "ISR_NOERR 0",
292 "ISR_NOERR 1",
293 "ISR_NOERR 2",
294 "ISR_NOERR 3",
295 "ISR_NOERR 4",
296 "ISR_NOERR 5",
297 "ISR_NOERR 6",
298 "ISR_NOERR 7",
299 "ISR_NOERR 9",
300 "ISR_ERR 10",
301 "ISR_ERR 11",
302 "ISR_ERR 12",
303 "ISR_ERR 13",
304 "ISR_NOERR 15",
305 "ISR_NOERR 16",
306 "ISR_ERR 17",
307 "ISR_ERR 18",
308 "ISR_NOERR 19",
309 "ISR_NOERR 20",
310 "ISR_NOERR 21",
311 "ISR_NOERR 22",
312 "ISR_NOERR 23",
313 "ISR_NOERR 24",
314 "ISR_NOERR 25",
315 "ISR_NOERR 26",
316 "ISR_NOERR 27",
317 "ISR_NOERR 28",
318 "ISR_NOERR 29",
319 "ISR_NOERR 30",
320 "ISR_NOERR 31",
321 "",
272 // Common handler — save all registers, call into Rust (which returns 322 // Common handler — save all registers, call into Rust (which returns
273 // the next task's frame pointer in rax), switch rsp, restore, iretq. 323 // the next task's frame pointer in rax), switch rsp, restore, iretq.
274 "isr_common:", 324 "isr_common:",
@@ -381,8 +431,9 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize {
381 fp 431 fp
382 } 432 }
383 8 => { 433 8 => {
384 crate::serial_println!("double fault — halting"); 434 // Double fault — dump the frame (we're on the IST stack
385 halt() 435 // so this is safe) and halt.
436 crate::sched::fault_dump("double fault", 0, frame)
386 } 437 }
387 14 => { 438 14 => {
388 let cr2: u64; 439 let cr2: u64;
@@ -391,6 +442,11 @@ unsafe extern "C" fn isr_dispatch(frame: &IntFrame) -> usize {
391 crate::sched::page_fault(cr2, fp) 442 crate::sched::page_fault(cr2, fp)
392 } 443 }
393 0x60 => crate::sched::on_yield(fp), 444 0x60 => crate::sched::on_yield(fp),
445 v if v < 32 => {
446 // Any other CPU exception: full dump (screen + serial),
447 // then halt the shell or abort the faulting background task.
448 crate::sched::fault_dump("exception", 0, frame)
449 }
394 _ => fp, 450 _ => fp,
395 } 451 }
396 }; 452 };
@@ -418,6 +474,47 @@ pub fn read_mouse_byte() -> Option<u8> {
418/// sub-tick fraction. 474/// sub-tick fraction.
419pub static TICKS: AtomicU64 = AtomicU64::new(0); 475pub static TICKS: AtomicU64 = AtomicU64::new(0);
420 476
477/// Address of the stub for CPU exception vector `vec` (0-31), or 0 for
478/// vectors without a stub. 8 (double fault) and 14 (page fault) use
479/// dedicated stubs that run on IST stacks.
480fn exc_stub_addr(vec: u8) -> u64 {
481 match vec {
482 8 => isr_stub_08 as *const () as u64,
483 14 => isr_stub_14 as *const () as u64,
484 0 => isr_stub_x_0 as *const () as u64,
485 1 => isr_stub_x_1 as *const () as u64,
486 2 => isr_stub_x_2 as *const () as u64,
487 3 => isr_stub_x_3 as *const () as u64,
488 4 => isr_stub_x_4 as *const () as u64,
489 5 => isr_stub_x_5 as *const () as u64,
490 6 => isr_stub_x_6 as *const () as u64,
491 7 => isr_stub_x_7 as *const () as u64,
492 9 => isr_stub_x_9 as *const () as u64,
493 10 => isr_stub_x_10 as *const () as u64,
494 11 => isr_stub_x_11 as *const () as u64,
495 12 => isr_stub_x_12 as *const () as u64,
496 13 => isr_stub_x_13 as *const () as u64,
497 15 => isr_stub_x_15 as *const () as u64,
498 16 => isr_stub_x_16 as *const () as u64,
499 17 => isr_stub_x_17 as *const () as u64,
500 18 => isr_stub_x_18 as *const () as u64,
501 19 => isr_stub_x_19 as *const () as u64,
502 20 => isr_stub_x_20 as *const () as u64,
503 21 => isr_stub_x_21 as *const () as u64,
504 22 => isr_stub_x_22 as *const () as u64,
505 23 => isr_stub_x_23 as *const () as u64,
506 24 => isr_stub_x_24 as *const () as u64,
507 25 => isr_stub_x_25 as *const () as u64,
508 26 => isr_stub_x_26 as *const () as u64,
509 27 => isr_stub_x_27 as *const () as u64,
510 28 => isr_stub_x_28 as *const () as u64,
511 29 => isr_stub_x_29 as *const () as u64,
512 30 => isr_stub_x_30 as *const () as u64,
513 31 => isr_stub_x_31 as *const () as u64,
514 _ => 0,
515 }
516}
517
421/// Initialise the IDT, remap the PIC, set up the PIT, and enable interrupts. 518/// Initialise the IDT, remap the PIC, set up the PIT, and enable interrupts.
422pub fn init() { 519pub fn init() {
423 unsafe { 520 unsafe {
@@ -435,6 +532,20 @@ pub fn init() {
435 idt_set_entry(idt_ptr, vec, spurious, cs, 0x0Eu8, 0); 532 idt_set_entry(idt_ptr, vec, spurious, cs, 0x0Eu8, 0);
436 } 533 }
437 534
535 // Wire up every CPU exception vector (0-31) so any fault produces
536 // a diagnosable dump instead of converging to a double fault.
537 for vec in 0..=31u8 {
538 let a = exc_stub_addr(vec);
539 if a != 0 {
540 let ist = match vec {
541 8 => 2u8,
542 14 => 1u8,
543 _ => 0u8,
544 };
545 idt_set_entry(idt_ptr, vec, a, cs, 0x0Eu8, ist);
546 }
547 }
548
438 // Override vectors for interrupt we actually handle. 549 // Override vectors for interrupt we actually handle.
439 idt_set_entry(idt_ptr, 32, isr_stub_32 as *const () as u64, cs, 0x0Eu8, 0); 550 idt_set_entry(idt_ptr, 32, isr_stub_32 as *const () as u64, cs, 0x0Eu8, 0);
440 idt_set_entry(idt_ptr, 33, isr_stub_33 as *const () as u64, cs, 0x0Eu8, 0); 551 idt_set_entry(idt_ptr, 33, isr_stub_33 as *const () as u64, cs, 0x0Eu8, 0);
@@ -513,4 +624,35 @@ unsafe extern "C" {
513 fn isr_stub_44(); 624 fn isr_stub_44();
514 fn isr_stub_60(); 625 fn isr_stub_60();
515 fn isr_stub_spurious(); 626 fn isr_stub_spurious();
627 // Macro-generated exception stubs (isr_stub_x_<vec>).
628 fn isr_stub_x_0();
629 fn isr_stub_x_1();
630 fn isr_stub_x_2();
631 fn isr_stub_x_3();
632 fn isr_stub_x_4();
633 fn isr_stub_x_5();
634 fn isr_stub_x_6();
635 fn isr_stub_x_7();
636 fn isr_stub_x_9();
637 fn isr_stub_x_10();
638 fn isr_stub_x_11();
639 fn isr_stub_x_12();
640 fn isr_stub_x_13();
641 fn isr_stub_x_15();
642 fn isr_stub_x_16();
643 fn isr_stub_x_17();
644 fn isr_stub_x_18();
645 fn isr_stub_x_19();
646 fn isr_stub_x_20();
647 fn isr_stub_x_21();
648 fn isr_stub_x_22();
649 fn isr_stub_x_23();
650 fn isr_stub_x_24();
651 fn isr_stub_x_25();
652 fn isr_stub_x_26();
653 fn isr_stub_x_27();
654 fn isr_stub_x_28();
655 fn isr_stub_x_29();
656 fn isr_stub_x_30();
657 fn isr_stub_x_31();
516} 658}
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
354 354
355fn init_heap() { 355fn init_heap() {
356 unsafe { 356 unsafe {
357 // Region the firmware maps for the GOP framebuffer, if known —
358 // exclude it even when the memory map lists it as conventional
359 // (some real firmware does exactly that, which would let heap
360 // allocations overwrite the display).
361 let fb = crate::gop::framebuffer_region();
357 let mut best_start = 0usize; 362 let mut best_start = 0usize;
358 let mut best_size = 0usize; 363 let mut best_size = 0usize;
359 for i in 0..MAP_ENTRIES { 364 for i in 0..MAP_ENTRIES {
360 let d = &MEMORY_MAP[i]; 365 let d = &MEMORY_MAP[i];
361 if d.kind == MEMORY_TYPE_CONVENTIONAL { 366 if d.kind == MEMORY_TYPE_CONVENTIONAL {
362 let sz = d.number_of_pages as usize * 4096; 367 let s = d.physical_start as usize;
368 let e = s + d.number_of_pages as usize * 4096;
369 // Subtract any overlap with the framebuffer region.
370 let (cs, ce) = match fb {
371 Some((b, sz)) if e > b && s < b + sz => (s.max(b), e.min(b + sz)),
372 _ => (e, s), // no overlap → empty range
373 };
374 let mut sz = e - s;
375 if ce > cs {
376 sz -= ce - cs;
377 }
363 if sz > best_size { 378 if sz > best_size {
364 best_start = d.physical_start as usize; 379 best_start = s;
365 best_size = sz; 380 best_size = sz;
366 } 381 }
367 } 382 }
@@ -634,6 +649,22 @@ pub fn init_page_allocator() -> bool {
634 } 649 }
635 } 650 }
636 651
652 // Reserve the GOP framebuffer region even if the map lists it as
653 // conventional — kernel allocations (stacks, DMA buffers) must
654 // never land on the display.
655 if let Some((fbase, fsize)) = crate::gop::framebuffer_region()
656 && fsize > 0
657 {
658 let s = fbase / PAGE_SIZE;
659 let e = (fbase + fsize).div_ceil(PAGE_SIZE);
660 for p in s..e {
661 let idx = p - base_page;
662 if idx < span_pages {
663 *bm.add(idx / 8) |= 1 << (idx % 8);
664 }
665 }
666 }
667
637 // Never hand out page 0 (the null-pointer trap page). 668 // Never hand out page 0 (the null-pointer trap page).
638 if base_page == 0 { 669 if base_page == 0 {
639 *bm |= 0x01; 670 *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;
28use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; 28use core::sync::atomic::{AtomicBool, AtomicU64, Ordering};
29 29
30extern crate alloc; 30extern crate alloc;
31use alloc::format;
31use alloc::string::String; 32use alloc::string::String;
33use alloc::string::ToString;
32use alloc::vec::Vec; 34use alloc::vec::Vec;
33 35
34use crate::interrupts::IntFrame; 36use crate::interrupts::IntFrame;
@@ -280,11 +282,7 @@ pub fn spawn(entry: fn(), name: &str) -> Option<usize> {
280 }; 282 };
281 let ok = spawn_at(id, entry, name); 283 let ok = spawn_at(id, entry, name);
282 unlock(); 284 unlock();
283 if ok { 285 if ok { Some(id) } else { None }
284 Some(id)
285 } else {
286 None
287 }
288} 286}
289 287
290unsafe fn reap(id: usize) { 288unsafe fn reap(id: usize) {
@@ -548,10 +546,57 @@ pub fn abort_current(msg: &str) -> ! {
548/// Vector 14 (page fault) handler — runs on the dedicated IST stack. 546/// Vector 14 (page fault) handler — runs on the dedicated IST stack.
549/// A fault in a real task (id >= 2) kills the task gracefully; anything 547/// A fault in a real task (id >= 2) kills the task gracefully; anything
550/// else (shell, idle, pre-scheduler) is a kernel bug: halt. 548/// else (shell, idle, pre-scheduler) is a kernel bug: halt.
549/// Dump a fatal exception to serial *and* the screen (raw, bypassing the
550/// vt), then halt the shell or abort the faulting background task.
551///
552/// Real hardware has no serial console, so the screen dump is what lets
553/// a user report a crash precisely — vector, fault address, RIP and task.
554/// Runs with interrupts disabled (we're inside the ISR).
555#[allow(clippy::needless_return)]
556pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFrame) -> ! {
557 let vector = frame.vector;
558 let error = frame.error;
559 let rip = frame.rip;
560 let rflags = frame.rflags;
561 let rsp = frame.rsp;
562 let id = unsafe { CURRENT };
563 let name = name_str(id);
564 let name_buf = name.to_string();
565 let in_kernel = unsafe { CURRENT } <= IDLE_TID;
566
567 // Serial: a full, parseable line.
568 crate::serial_println!(
569 "!!! {} vec={} err={:#x} rip={:#x} cr2={:#x} rsp={:#x} rflags={:#x} task={} \"{}\"",
570 kind,
571 vector,
572 error,
573 rip,
574 cr2,
575 rsp,
576 rflags,
577 id,
578 name_buf
579 );
580 crate::serial_println!("!!! halting (shell task fault — machine stops)");
581
582 // Screen: white-on-red band via the clamped raw dump path.
583 let l1 = alloc::format!("!!! {} vec={} err={:#x}", kind, vector, error);
584 let l2 = alloc::format!("rip={:#x} cr2={:#x}", rip, cr2);
585 let l3 = alloc::format!("rsp={:#x} task={} {}", rsp, id, name_buf);
586 let lines = [l1.as_str(), l2.as_str(), l3.as_str()];
587 crate::gop::dump_lines(&lines);
588
589 if in_kernel {
590 crate::interrupts::halt()
591 } else {
592 abort_current(&format!("{} vec={}", kind, vector))
593 }
594}
595
551pub fn page_fault(cr2: u64, frame_ptr: usize) -> usize { 596pub fn page_fault(cr2: u64, frame_ptr: usize) -> usize {
552 if !is_ready() || unsafe { CURRENT } <= IDLE_TID { 597 if !is_ready() || unsafe { CURRENT } <= IDLE_TID {
553 crate::serial_println!("pgfault: kernel page fault at {:#x} — halting", cr2); 598 let frame = unsafe { &*(frame_ptr as *const crate::interrupts::IntFrame) };
554 crate::interrupts::halt(); 599 fault_dump("page fault", cr2, frame)
555 } 600 }
556 let g = unsafe { TASKS[CURRENT].guard }; 601 let g = unsafe { TASKS[CURRENT].guard };
557 let in_guard = g != 0 && cr2 >= g as u64 && cr2 < (g + memory::PAGE_SIZE) as u64; 602 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 @@
1#!/usr/bin/env bash
2#
3# scripts/test-fault.sh — fault-diagnostics verification.
4#
5# Real hardware has no serial console, so a kernel crash previously left
6# only a frozen screen ("unresponsive"). This test proves the fault-dump
7# path: every CPU exception now prints a white-on-red band to the
8# framebuffer *and* a parseable serial line (vector, error, RIP, CR2,
9# task) before halting — so the next real-hardware failure identifies
10# itself. The `fault` command deliberately writes to an unmapped
11# address; the machine must halt with a full dump, not triple-fault.
12#
13# Usage: ./scripts/test-fault.sh
14# Env: OVMF, QEMU (same defaults as other test scripts)
15#
16set -euo pipefail
17
18SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
20cd "$PROJECT_DIR"
21
22OVMF="${OVMF:-/usr/share/edk2/x64/OVMF.4m.fd}"
23QEMU="${QEMU:-qemu-system-x86_64}"
24ESP_DIR="$PROJECT_DIR/esp"
25QPID=/tmp/rsdos-fault-qpid
26SOCK=/tmp/rsdos-fault-qmon.sock
27LOG=/tmp/rsdos-fault-serial.log
28QLOG=/tmp/rsdos-fault-qemu.log
29
30trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT
31
32fail() {
33 echo "FAIL: $1"
34 [ -f "$LOG" ] && tail -40 "$LOG"
35 exit 1
36}
37
38wait_log() {
39 local i=0
40 while [ "$i" -lt $((${3:-60} * 10)) ]; do
41 grep -q "$2" "$1" 2>/dev/null && return 0
42 sleep 0.1
43 i=$((i + 1))
44 done
45 return 1
46}
47
48echo "==> 1/5 build kernel (debug)"
49cargo build --package rsdos --target x86_64-unknown-uefi
50
51echo "==> 2/5 deploy to ESP"
52mkdir -p "$ESP_DIR/EFI/BOOT"
53cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI"
54
55echo "==> 3/5 boot QEMU (headless)"
56kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true
57sleep 1
58rm -f "$SOCK" "$LOG"
59"$QEMU" -bios "$OVMF" \
60 -drive file=fat:rw:"$ESP_DIR",format=raw \
61 -serial file:"$LOG" \
62 -monitor unix:"$SOCK",server,nowait \
63 -m 256M -machine q35 -cpu qemu64 -no-reboot -display none \
64 >"$QLOG" 2>&1 &
65echo $! >"$QPID"
66
67wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell"
68
69echo "==> 4/5 boot diagnostics present"
70grep -aq "gop: fb base=" "$LOG" || fail "fb geometry diagnostic missing"
71grep -aq "gop: mode .* selected" "$LOG" || fail "mode-selection diagnostic missing"
72
73echo "==> 5/5 inject a page fault (fault command)"
74python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
75 "clear" \
76 "fault"
77sleep 2
78
79# The dump must appear on serial: vector 14, the CR2 we wrote, RIP, and
80# the shell task identity — then the machine halts (never triple-faults).
81grep -aq "!!! page fault vec=14 .*rip=" "$LOG" || fail "page-fault dump line missing"
82grep -aq "cr2=0x400000000000" "$LOG" || fail "CR2 not reported (faulting address)"
83grep -aq "task=0 \"shell\"" "$LOG" || fail "faulting task not identified"
84grep -aq "!!! halting" "$LOG" || fail "no halt message"
85
86# The screen dump itself is exercised in-kernel (dump_lines); the serial
87# path sharing the same format line is verified above.
88echo ""
89echo "PASS — fault dump works (vec, err, rip, cr2, task → screen + serial)"
90echo " 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 `{} <path> TIL <marker>`",
721 "sleep" => cmd_sleep(&parsed), 721 "sleep" => cmd_sleep(&parsed),
722 "kill" => cmd_kill(&parsed), 722 "kill" => cmd_kill(&parsed),
723 "demo" => cmd_demo(&parsed), 723 "demo" => cmd_demo(&parsed),
724 "fault" => cmd_fault(),
724 _ => term_err(&alloc::format!("unknown command: {}", cmd)), 725 _ => term_err(&alloc::format!("unknown command: {}", cmd)),
725 } 726 }
726} 727}
@@ -806,6 +807,18 @@ fn cmd_hello(p: &Parsed) {
806 pipe.end(); 807 pipe.end();
807} 808}
808 809
810/// Deliberately fault on an unmapped address — test hook that verifies
811/// the fault-dump path (screen + serial) halts cleanly instead of
812/// triple-faulting. The system stops; the log must show the dump.
813/// Canonical-but-unmapped address (below 2^47, above any RAM/MMIO): the
814/// page walk terminates at a not-present entry → #PF with CR2 = address.
815fn cmd_fault() {
816 term_println("fault: writing to 0x0000_4000_0000_0000...");
817 unsafe {
818 core::ptr::write_volatile(0x0000_4000_0000_0000usize as *mut u32, 0x1234u32);
819 }
820}
821
809fn cmd_clear() { 822fn cmd_clear() {
810 rsnix::gop::clear_screen(); 823 rsnix::gop::clear_screen();
811} 824}