feat(kernel): KRNBUG-AUDIT-002 — multi-frame guest stack capture at handle creation
Adds `walk_guest_back_chain` (PPC EABI back-chain walker) and a
`record_create_with_stack` audit hook gated on `--trace-handles-focus`.
NtCreateEvent / NtCreateSemaphore / NtCreateTimer / XamTaskSchedule now
route through the new helper so focused handles capture up to 6 stack
frames at allocation time. Diagnostic-only, read-only memory access:
unfocused handles pay one HashSet lookup, focused ones pay six
back-chain dereferences. Lockstep determinism preserved.
End-to-end finding: handles 0x1004 (8-instance pool via static ctor at
0x8280F810), 0x100c (singleton built inside main()), 0x15e0 (singleton
in distinct cluster) are silph-framework dispatcher objects whose
producer code is unreached at -n 500M. The producer hunt now has class
ownership; vtable/RTTI readout is the next step.
Tests: 576 → 581 green. `--stable-digest -n 100M` instructions=100000002
unchanged. Master HEAD prior: 9d45efe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3207,6 +3207,15 @@ fn dump_thread_diagnostic(kernel: &xenia_kernel::KernelState, quiet: bool) {
|
||||
" created cycle={} tid={} lr={:#010x} src={}",
|
||||
t.created.cycle, t.created.tid, t.created.lr, t.created.source,
|
||||
);
|
||||
if !t.created_stack.is_empty() {
|
||||
println!(" created stack ({} frames):", t.created_stack.len());
|
||||
for (i, (fp, lr)) in t.created_stack.iter().enumerate() {
|
||||
println!(
|
||||
" [{:>2}] fp={:#010x} lr={:#010x}",
|
||||
i, fp, lr,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Producer-class classification. Source strings are stable
|
||||
// labels passed to `record_signal` at the export sites.
|
||||
|
||||
@@ -51,6 +51,14 @@ pub struct HandleAuditTrail {
|
||||
pub kind: &'static str,
|
||||
/// When/who/where the handle was minted.
|
||||
pub created: HandleAuditEntry,
|
||||
/// KRNBUG-AUDIT-002 producer-trace. Captured frames at allocation
|
||||
/// time, only populated when the handle is in `HandleAudit::focus`
|
||||
/// AND the create site routed through the `_with_stack` variant.
|
||||
/// Frame layout: `(frame_pointer, saved_lr_for_caller_of_that_frame)`.
|
||||
/// Index 0 is the live frame: `(ctx.gpr[1], ctx.lr)`. Index 1+ comes
|
||||
/// from walking the PPC back-chain. An empty vec means either the
|
||||
/// handle wasn't in focus or the create site didn't capture a stack.
|
||||
pub created_stack: Vec<(u32, u32)>,
|
||||
/// Bounded ring of signal events.
|
||||
pub signals: VecDeque<HandleAuditEntry>,
|
||||
/// Bounded ring of wait-entry events (one per `Wait*` call).
|
||||
@@ -64,6 +72,7 @@ impl HandleAuditTrail {
|
||||
Self {
|
||||
kind,
|
||||
created,
|
||||
created_stack: Vec::new(),
|
||||
signals: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
|
||||
waits: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
|
||||
wakes: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
|
||||
@@ -121,6 +130,26 @@ impl HandleAudit {
|
||||
.insert(handle, HandleAuditTrail::new(kind, entry));
|
||||
}
|
||||
|
||||
/// Same as `record_create`, but additionally stores a captured guest
|
||||
/// stack trace on the trail (`created_stack`). Intended for handles
|
||||
/// in `focus` so the dump can name the actual subsystem caller of the
|
||||
/// kernel API rather than just the immediate wrapper return.
|
||||
#[inline]
|
||||
pub fn record_create_with_stack(
|
||||
&mut self,
|
||||
handle: u32,
|
||||
kind: &'static str,
|
||||
entry: HandleAuditEntry,
|
||||
stack: Vec<(u32, u32)>,
|
||||
) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
let mut trail = HandleAuditTrail::new(kind, entry);
|
||||
trail.created_stack = stack;
|
||||
self.trails.insert(handle, trail);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn record_signal(&mut self, handle: u32, entry: HandleAuditEntry) {
|
||||
if !self.enabled {
|
||||
@@ -268,4 +297,34 @@ mod tests {
|
||||
let a = HandleAudit::default();
|
||||
assert!(a.counts(0x10FC).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_with_stack_stores_frames() {
|
||||
let mut a = HandleAudit { enabled: true, ..HandleAudit::default() };
|
||||
let frames = vec![
|
||||
(0x7000_0100, 0x824a_9f6c),
|
||||
(0x7000_0200, 0x824a_b020),
|
||||
(0x7000_0300, 0x82bb_aa00),
|
||||
];
|
||||
a.record_create_with_stack(
|
||||
0x1004,
|
||||
"Event/Manual",
|
||||
entry(0, "NtCreateEvent"),
|
||||
frames.clone(),
|
||||
);
|
||||
let trail = &a.trails[&0x1004];
|
||||
assert_eq!(trail.created_stack, frames);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_with_stack_disabled_is_noop() {
|
||||
let mut a = HandleAudit::default();
|
||||
a.record_create_with_stack(
|
||||
0x1004,
|
||||
"Event/Manual",
|
||||
entry(0, "NtCreateEvent"),
|
||||
vec![(0x7000_0000, 0x8200_0000)],
|
||||
);
|
||||
assert!(a.trails.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1462,10 +1462,11 @@ fn nt_create_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSt
|
||||
signaled,
|
||||
waiters: Vec::new(),
|
||||
});
|
||||
state.audit_create(
|
||||
state.audit_create_with_ctx(
|
||||
handle,
|
||||
if manual_reset { "Event/Manual" } else { "Event/Auto" },
|
||||
ctx.lr as u32,
|
||||
ctx,
|
||||
mem,
|
||||
"NtCreateEvent",
|
||||
);
|
||||
if handle_ptr != 0 {
|
||||
@@ -1484,7 +1485,7 @@ fn nt_create_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kern
|
||||
max,
|
||||
waiters: Vec::new(),
|
||||
});
|
||||
state.audit_create(handle, "Semaphore", ctx.lr as u32, "NtCreateSemaphore");
|
||||
state.audit_create_with_ctx(handle, "Semaphore", ctx, mem, "NtCreateSemaphore");
|
||||
if handle_ptr != 0 {
|
||||
mem.write_u32(handle_ptr, handle);
|
||||
}
|
||||
@@ -1516,10 +1517,11 @@ fn nt_create_timer(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSt
|
||||
callback_arg: 0,
|
||||
waiters: Vec::new(),
|
||||
});
|
||||
state.audit_create(
|
||||
state.audit_create_with_ctx(
|
||||
handle,
|
||||
if timer_type == 0 { "Timer/Manual" } else { "Timer/Auto" },
|
||||
ctx.lr as u32,
|
||||
ctx,
|
||||
mem,
|
||||
"NtCreateTimer",
|
||||
);
|
||||
if handle_ptr != 0 {
|
||||
|
||||
@@ -455,6 +455,41 @@ impl KernelState {
|
||||
self.audit.record_create(handle, kind, entry);
|
||||
}
|
||||
|
||||
/// KRNBUG-AUDIT-002. Variant of `audit_create` that additionally
|
||||
/// captures a 6-frame guest stack trace at allocation time when the
|
||||
/// handle is in `audit.focus`. Outside the focus set this falls back
|
||||
/// to plain `audit_create` (no stack walk → no extra cost on the hot
|
||||
/// path of unfocused handle creation).
|
||||
///
|
||||
/// The walk reads the PPC EABI back-chain: `[r1] = prev_sp`, and the
|
||||
/// LR saved by *that* prev frame's prologue lives at `[prev_sp - 8]`.
|
||||
/// Frame 0 is the live frame `(ctx.gpr[1], ctx.lr)`. Frames 1..N walk
|
||||
/// upward. A read returning 0 / 0xFFFF_FFFF, or a self-loop, ends the
|
||||
/// walk early. This is read-only — guest memory and CPU state are not
|
||||
/// mutated, so lockstep determinism is unaffected (a parallel run with
|
||||
/// no focus is byte-identical to one without this code path).
|
||||
pub fn audit_create_with_ctx(
|
||||
&mut self,
|
||||
handle: u32,
|
||||
kind: &'static str,
|
||||
ctx: &PpcContext,
|
||||
mem: &GuestMemory,
|
||||
source: &'static str,
|
||||
) {
|
||||
if !self.audit.enabled {
|
||||
return;
|
||||
}
|
||||
let lr = ctx.lr as u32;
|
||||
let entry = self.audit_entry(lr, source, 0);
|
||||
if !self.audit.focus.contains(&handle) {
|
||||
self.audit.record_create(handle, kind, entry);
|
||||
return;
|
||||
}
|
||||
let stack = walk_guest_back_chain(ctx.gpr[1] as u32, lr, mem, 6);
|
||||
self.audit
|
||||
.record_create_with_stack(handle, kind, entry, stack);
|
||||
}
|
||||
|
||||
/// Record a Set/Pulse/Release/etc. call against a handle. `aux` is the
|
||||
/// previous signal state (or per-export-specific data).
|
||||
pub fn audit_signal(&mut self, handle: u32, lr: u32, source: &'static str, aux: u64) {
|
||||
@@ -675,6 +710,47 @@ impl Default for KernelState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the PPC EABI back-chain starting from `sp` (the value in r1 at
|
||||
/// the moment of capture). Returns up to `max_frames` entries of
|
||||
/// `(frame_pointer, saved_lr)`. Index 0 is the live frame
|
||||
/// `(sp, live_lr)` — `live_lr` is the caller-supplied current LR, since
|
||||
/// it has not yet been spilled to memory by this frame's prologue.
|
||||
///
|
||||
/// PPC convention reminder: a function's prologue stores the caller's
|
||||
/// LR at `[old_sp - 8]` *before* bumping `r1` down to the new frame. So
|
||||
/// from the live `sp`, `prev_sp = mem[sp]` and the LR saved in the
|
||||
/// frame above is at `mem[prev_sp - 8]`. The walk stops on a
|
||||
/// 0/0xFFFFFFFF/self-loop sentinel — those guard against
|
||||
/// uninitialized stacks and the topmost frame.
|
||||
///
|
||||
/// This is read-only; it never mutates guest memory or CPU state.
|
||||
pub fn walk_guest_back_chain(
|
||||
sp: u32,
|
||||
live_lr: u32,
|
||||
mem: &GuestMemory,
|
||||
max_frames: usize,
|
||||
) -> Vec<(u32, u32)> {
|
||||
let mut frames = Vec::with_capacity(max_frames);
|
||||
if max_frames == 0 {
|
||||
return frames;
|
||||
}
|
||||
frames.push((sp, live_lr));
|
||||
let mut cur = sp;
|
||||
while frames.len() < max_frames {
|
||||
if cur == 0 || cur == 0xFFFF_FFFF {
|
||||
break;
|
||||
}
|
||||
let prev = mem.read_u32(cur);
|
||||
if prev == 0 || prev == 0xFFFF_FFFF || prev == cur {
|
||||
break;
|
||||
}
|
||||
let saved_lr = mem.read_u32(prev.wrapping_sub(8));
|
||||
frames.push((prev, saved_lr));
|
||||
cur = prev;
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -759,4 +835,64 @@ mod tests {
|
||||
);
|
||||
assert!(set.iter().all(|h| (h - 0x1000) % 4 == 0));
|
||||
}
|
||||
|
||||
/// KRNBUG-AUDIT-002: synthesize a 3-level back-chain in mapped guest
|
||||
/// memory and walk it. Verifies that frame 0 is the live-LR frame and
|
||||
/// that subsequent frames pull `prev_sp` from `[sp]` and the saved LR
|
||||
/// from `[prev_sp - 8]`.
|
||||
#[test]
|
||||
fn back_chain_walker_resolves_synthetic_frames() {
|
||||
let mem = GuestMemory::new().expect("memory init");
|
||||
let mut state = KernelState::new();
|
||||
let base = state.heap_alloc(0x4000, &mem).expect("scratch");
|
||||
// Lay out three frames inside the scratch page. Each frame gets
|
||||
// its own 0x100-byte slot. Frame N's `[sp + 0]` points at frame
|
||||
// N+1's sp, and frame N+1's `[sp - 8]` holds the LR saved by
|
||||
// that frame for the call into frame N.
|
||||
let sp0 = base + 0x100;
|
||||
let sp1 = base + 0x300;
|
||||
let sp2 = base + 0x500;
|
||||
// Back-chain pointers
|
||||
mem.write_u32(sp0, sp1);
|
||||
mem.write_u32(sp1, sp2);
|
||||
mem.write_u32(sp2, 0); // top of stack
|
||||
// Saved LRs (the LR of the call that reached the *next* frame
|
||||
// up are stored at the next frame's sp - 8)
|
||||
mem.write_u32(sp1.wrapping_sub(8), 0xAAAA_BBBB);
|
||||
mem.write_u32(sp2.wrapping_sub(8), 0xCCCC_DDDD);
|
||||
|
||||
let frames = walk_guest_back_chain(sp0, 0x1111_2222, &mem, 6);
|
||||
assert_eq!(frames.len(), 3);
|
||||
assert_eq!(frames[0], (sp0, 0x1111_2222));
|
||||
assert_eq!(frames[1], (sp1, 0xAAAA_BBBB));
|
||||
assert_eq!(frames[2], (sp2, 0xCCCC_DDDD));
|
||||
}
|
||||
|
||||
/// Walker must not loop on a self-referential back-chain (a corrupted
|
||||
/// frame where `[sp] == sp`).
|
||||
#[test]
|
||||
fn back_chain_walker_stops_on_self_loop() {
|
||||
let mem = GuestMemory::new().expect("memory init");
|
||||
let mut state = KernelState::new();
|
||||
let base = state.heap_alloc(0x1000, &mem).expect("scratch");
|
||||
let sp = base + 0x100;
|
||||
mem.write_u32(sp, sp); // self-loop
|
||||
let frames = walk_guest_back_chain(sp, 0x4242_4242, &mem, 6);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], (sp, 0x4242_4242));
|
||||
}
|
||||
|
||||
/// Walker must terminate on the standard top-of-stack sentinel
|
||||
/// (`[sp] == 0`) without spilling a bogus frame.
|
||||
#[test]
|
||||
fn back_chain_walker_stops_on_zero_sentinel() {
|
||||
let mem = GuestMemory::new().expect("memory init");
|
||||
let mut state = KernelState::new();
|
||||
let base = state.heap_alloc(0x1000, &mem).expect("scratch");
|
||||
let sp = base + 0x100;
|
||||
mem.write_u32(sp, 0);
|
||||
let frames = walk_guest_back_chain(sp, 0x8242_0000, &mem, 6);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], (sp, 0x8242_0000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,6 @@ fn xam_task_schedule(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kernel
|
||||
let message_ptr = ctx.gpr[4] as u32;
|
||||
let optional_ptr = ctx.gpr[5] as u32;
|
||||
let handle_ptr = ctx.gpr[6] as u32;
|
||||
let lr = ctx.lr as u32;
|
||||
|
||||
if optional_ptr != 0 {
|
||||
let v1 = mem.read_u32(optional_ptr);
|
||||
@@ -266,7 +265,7 @@ fn xam_task_schedule(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kernel
|
||||
if handle_ptr != 0 {
|
||||
mem.write_u32(handle_ptr, handle);
|
||||
}
|
||||
state.audit_create(handle, "Thread", lr, "XamTaskSchedule");
|
||||
state.audit_create_with_ctx(handle, "Thread", ctx, mem, "XamTaskSchedule");
|
||||
tracing::info!(
|
||||
"XamTaskSchedule: tid={} handle={:#x} hw={} callback={:#010x} message={:#010x}",
|
||||
tid,
|
||||
|
||||
Reference in New Issue
Block a user