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. --- rsnix/src/gop.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 122 insertions(+), 17 deletions(-) (limited to 'rsnix/src') 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 -- cgit v1.2.3