From 94d40dadeddfb40cd4fd237294037a562405a2c2 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 2 Aug 2026 01:34:58 +0200 Subject: v0.5.4: shadow backbuffer can no longer alias the framebuffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-hardware scroll freeze (no exception, QEMU-clean) — the per-word push (0.5.3) did not fix it, so the wedge is upstream of the push. A QEMU slow-display simulation (new DISPLAY_STALL knob + shell 'stall' command busy-waits per display store, mimicking an uncached real-GPU framebuffer) proved the per-word push and shadow memmove are slow but finite — the shell keeps ticking, scrolls eventually complete. The freeze mechanism QEMU cannot exhibit is the shadow *landing on the framebuffer*: the heap free list spans the fb hole, so the 1.9 MB heap shadow can alias the display on machines where firmware reports the fb as ConventionalMemory — every scroll then becomes an MMIO->MMIO memcpy on an uncached framebuffer, which wedges real hardware (a stall, no fault, hence no dump). Fix: - gop::init_shadow(): allocate the shadow from the fb-aware page allocator (init_page_allocator marks fb pages used per-page), so it can never overlap the display; heap allocation kept only as a fallback with an explicit overlap rejection. Boot log now prints 'gop: shadow @ ... origin=page-alloc|heap|none'. - Scroll shadow memmove converted to per-word stores (belt and suspenders — immune to any future fb-aliased shadow). - Verified on QEMU: shadow @ 0x100000 vs fb 0x80000000..0x801d5000, overlap=false; all 5 test suites pass; scroll completes even under the stalled-display simulation. --- CONTEXT.md | 28 +++++++---- Cargo.lock | 6 +-- Cargo.toml | 2 +- rsdos/src/main.rs | 1 + rsnix/src/gop.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++++------- shell/src/lib.rs | 12 +++++ 6 files changed, 158 insertions(+), 30 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index bf1e01f..1a1470c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -113,17 +113,21 @@ 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.3` — the GUI era landed with 0.5.0 (graphics layer + cursor +`0.5.4` — 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); 0.5.3 fixed -the real-hardware scroll wedge — the scroll's full-frame push is now -per-word volatile stores like the boot-time `clear()` instead of one -1.92 MB rep-movsb, which can stall forever on an uncached real-GPU -framebuffer (a stall, not an exception — hence no crash dump). A -future major GUI overhaul (graphics windows beyond the text surface) -would be the `a` bump (1.0.0). +from the allocators, authoritative fb geometry logging); 0.5.3 changed +the scroll's full-frame push to per-word volatile stores like the +boot-time `clear()` instead of one 1.92 MB rep-movsb (a REP MOVS to an +uncached real-GPU framebuffer can stall forever — a stall, not an +exception — hence no crash dump); 0.5.4 allocates the shadow +backbuffer from the fb-aware page allocator (it can no longer land on +the framebuffer — the heap free list spans the fb hole, so a 1.9 MB +shadow could alias the display and turn every scroll into an MMIO→MMIO +memcpy) and converts the scroll's shadow memmove to per-word stores as +a belt-and-suspenders measure. A future major GUI overhaul (graphics +windows beyond the text surface) would be the `a` bump (1.0.0). ## Project layout @@ -562,7 +566,13 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target: uncached real-GPU framebuffer wedged the CPU on some hardware (a stall, not an exception — no dump fired), while the boot-time `clear()` — the same per-word pattern — always worked. QEMU's - RAM-backed framebuffer never showed it. + RAM-backed framebuffer never showed it. **The shadow itself is + allocated from the fb-aware page allocator (0.5.4)** so it can + never land on the framebuffer: the heap's free list spans the fb + hole, so a 1.9 MB heap shadow could alias the display, making + every scroll an MMIO→MMIO memcpy that wedges real hardware. The + scroll's shadow memmove is also per-word stores (0.5.4) as a + belt-and-suspenders guard against any future fb-aliased shadow. 2. deferred scroll batching (`vt::PENDING_SCROLLS`): the logical grid scrolls instantly in RAM while the framebuffer shift+push is deferred; N pending scrolls cost one push. Bursts of streaming diff --git a/Cargo.lock b/Cargo.lock index 65166d4..2796caa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "rsdos" -version = "0.5.3" +version = "0.5.4" dependencies = [ "rsnix", "shell", @@ -19,11 +19,11 @@ dependencies = [ [[package]] name = "rsnix" -version = "0.5.3" +version = "0.5.4" [[package]] name = "shell" -version = "0.5.3" +version = "0.5.4" dependencies = [ "rsdosedit", "rsnix", diff --git a/Cargo.toml b/Cargo.toml index 6c16784..b7abb0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["rsnix", "shell", "rsdos", "rsdosedit"] [workspace.package] -version = "0.5.3" +version = "0.5.4" edition = "2024" # no_std UEFI kernel: no unwinder available, so panic must abort. diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs index 6dbad5f..3153930 100644 --- a/rsdos/src/main.rs +++ b/rsdos/src/main.rs @@ -172,6 +172,7 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut } else { rsnix::serial_println!("pages: init FAILED"); } + rsnix::gop::init_shadow(); if rsnix::memory::page_self_test() { rsnix::serial_println!("pages: self-test OK (alloc/free/runs)"); } else { diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index 439dfb1..8946f51 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -5,12 +5,9 @@ /// linear framebuffer. use crate::font; use crate::uefi::{GopModeInfo, GraphicsOutputProtocol, EFI_SUCCESS, PIXEL_BGR, PIXEL_RGB}; -use alloc::boxed::Box; -use alloc::vec; -use alloc::vec::Vec; use core::fmt; use core::ptr; -use core::sync::atomic::{AtomicU64, AtomicU8, Ordering}; +use core::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering}; /// Framebuffer state captured before ExitBootServices. pub struct Framebuffer { @@ -74,6 +71,27 @@ pub fn framebuffer_region() -> Option<(usize, usize)> { static mut SHADOW: *mut u32 = core::ptr::null_mut(); static mut SHADOW_WORDS: usize = 0; +/// Diagnostic: when > 0, every display store busy-waits ~this many +/// cycles first, simulating the per-write bus cost of an uncached real +/// GOP framebuffer (QEMU's framebuffer is RAM — instant). Lets us +/// reproduce real-hardware scroll timing headless and race it against +/// the preemptive scheduler. Set via the shell `stall` command. +pub static DISPLAY_STALL: AtomicU32 = AtomicU32::new(0); + +#[inline] +fn stall_display() { + let n = DISPLAY_STALL.load(Ordering::Relaxed); + if n > 0 { + // ~n pause-cycles per store; interrupts stay enabled so the + // timer can preempt mid-push exactly like on real hardware. + let mut i = n; + while i > 0 { + core::hint::spin_loop(); + i -= 1; + } + } +} + /// The GUI canvas. Every display surface assumes this resolution: /// `vt` derives its text grid from it, the compositor's cursor clamps /// to it, and the future window manager lays out windows on it. We @@ -189,16 +207,11 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { bgr, }; - // Allocate the RAM backbuffer before first paint so every write goes - // to both the display and the shadow. - let words = (pitch * height) / 4; - unsafe { - if words > 0 { - let v: Vec = vec![0; words]; - SHADOW = Box::into_raw(v.into_boxed_slice()) as *mut u32; - SHADOW_WORDS = words; - } - } + // The RAM backbuffer is allocated *after* ExitBootServices by + // `init_shadow()` (main.rs) — via the fb-aware page allocator so it + // can never land on the framebuffer (see init_shadow). Nothing is + // drawn between fb.clear() here and init_shadow(), so the blank + // shadow is in sync with the cleared display. // Re-stash the post-SetMode region (base can move) for the page // allocator, and log the authoritative geometry for diagnostics. @@ -224,6 +237,87 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { } } +/// Allocate the RAM backbuffer. Must run *after* +/// `memory::init_page_allocator` (which excludes the GOP framebuffer +/// pages per-page), and before any text is painted. +/// +/// The heap (pre-ExitBootServices) is excluded from the fb region only +/// by a coarse overlap subtraction that still leaves one contiguous +/// free block *spanning* the fb hole — a 1.9 MB shadow allocation could +/// therefore land *on* the framebuffer on machines where firmware +/// reports it as ConventionalMemory. A shadow aliased over the display +/// makes every scroll a 1.8 MB MMIO→MMIO memcpy, which can wedge a +/// real uncached framebuffer (a stall, not a fault — hence no dump). +/// The page allocator marks those pages used, so the shadow can never +/// land there. Heap allocation is kept only as a fallback, with an +/// explicit overlap rejection. +pub fn init_shadow() { + unsafe { + let Some(fb) = fb().as_ref() else { + return; + }; + let words = (fb.pitch * fb.height) / 4; + if words == 0 { + return; + } + let bytes = words * 4; + let pages = bytes.div_ceil(crate::memory::PAGE_SIZE); + let mut origin = "none"; + + // Primary: fb-aware page allocator (fb pages already marked + // used by init_page_allocator). + if let Some(p) = crate::memory::alloc_pages(pages) { + SHADOW = p as *mut u32; + SHADOW_WORDS = words; + origin = "page-alloc"; + for i in 0..words { + *SHADOW.add(i) = fb.bg; + } + } else { + // Fallback: heap, rejected if it overlaps the fb region. + let v: alloc::vec::Vec = alloc::vec![0; words]; + let p = v.as_ptr() as usize; + let overlaps = FB_REGION_SIZE > 0 + && p < FB_REGION_BASE + FB_REGION_SIZE + && p + bytes > FB_REGION_BASE; + if !overlaps { + SHADOW = v.as_ptr() as *mut u32; + SHADOW_WORDS = words; + origin = "heap"; + core::mem::forget(v); // shadow owns the memory now + } else { + drop(v); + } + } + let (rbase, rsize) = (FB_REGION_BASE, FB_REGION_SIZE); + let (shadow, swords) = (SHADOW as usize, SHADOW_WORDS); + crate::serial_println!( + "gop: shadow @ {:#x} words={} fb={:#x}..{:#x} origin={}", + shadow, + swords, + rbase, + rbase + rsize, + origin + ); + // Real hardware has no serial console: surface a degraded shadow + // (heap fallback or rejected allocation) as a visible warning + // band so a boot on hardware shows it on the screen. + if origin != "page-alloc" { + let msg = if SHADOW.is_null() { + "gop: WARNING — shadow allocation rejected (fb overlap); no-shadow fallback" + } else { + "gop: WARNING — shadow from heap (page allocator unavailable)" + }; + crate::serial_println!("{}", msg); + let lines: alloc::vec::Vec<&str> = msg.as_bytes() + .chunks(100) + .map(|c| core::str::from_utf8(c).unwrap_or("?")) + .collect(); + dump_lines(&lines); + } + } +} + // --------------------------------------------------------------------------- // Public text API — the text surface now lives in `crate::vt`; these wrappers // keep callers (shell, macros) unchanged. @@ -296,7 +390,10 @@ pub(crate) fn push_rect(x: usize, y: usize, w: usize, h: usize) { if row_w == 0 { continue; } - ptr::copy_nonoverlapping(SHADOW.add(off), fb.base.add(off * 4) as *mut u32, row_w); + for k in 0..row_w { + stall_display(); + ptr::write_volatile(fb.base.add((off + k) * 4) as *mut u32, *SHADOW.add(off + k)); + } } } overlay_cursor(x, y, w, h); @@ -530,6 +627,7 @@ fn draw_bmp(x: usize, y: usize, color: u32) { let off = yy * row_words + xx; // Clamp to the display mapping (defensive). if off < fb.framebuffer_size / 4 { + stall_display(); ptr::write_volatile(fb.base.add(off * 4) as *mut u32, color); } } @@ -606,8 +704,14 @@ impl Framebuffer { } return; } - // Shift the shadow up n rows (cached RAM — fast). - ptr::copy(SHADOW.add(n * row_words), SHADOW, words - n * row_words); + // Shift the shadow up n rows. Per-word stores, never a + // bulk REP MOVS: the shadow is cached RAM so this is fast, + // and it stays safe even if a future allocation ever aliased + // the shadow over an uncached framebuffer (MMIO→MMIO fast + // string ops can wedge real GPUs). + for i in 0..(words - n * row_words) { + *SHADOW.add(i) = *SHADOW.add(n * row_words + i); + } // Clear the exposed bottom rows in the shadow. for i in (words - n * row_words)..words { *SHADOW.add(i) = self.bg; @@ -630,6 +734,7 @@ impl Framebuffer { let push_words = words.min(self.framebuffer_size / 4); let dst = self.base as *mut u32; for i in 0..push_words { + stall_display(); ptr::write_volatile(dst.add(i), ptr::read(SHADOW.add(i))); } // The scroll copied the shadow (which never contains the diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 3090776..b2c4a1b 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -722,6 +722,7 @@ write `{} TIL `", "kill" => cmd_kill(&parsed), "demo" => cmd_demo(&parsed), "fault" => cmd_fault(), + "stall" => cmd_stall(&parsed), _ => term_err(&alloc::format!("unknown command: {}", cmd)), } } @@ -819,6 +820,17 @@ fn cmd_fault() { } } +/// Set the display-store stall (cycles per word) — diagnostics knob that +/// simulates the per-write cost of an uncached real-GPU framebuffer so +/// the preemptive scheduler races long scroll pushes like on real +/// hardware. `stall 0` disables (default; QEMU RAM framebuffers are +/// instant). +fn cmd_stall(p: &Parsed) { + let n: u32 = p.args.first().and_then(|a| a.parse().ok()).unwrap_or(0); + rsnix::gop::DISPLAY_STALL.store(n, core::sync::atomic::Ordering::Relaxed); + term_println(&alloc::format!("stall: display store stall = {}", n)); +} + fn cmd_clear() { rsnix::gop::clear_screen(); } -- cgit v1.2.3