Enforce pristine interrupt/syscall paths with no logging, diagnostics, or heavy operations. Use when developing interrupt handlers, syscall entry/exit, context switches, or timer code.
These requirements are NON-NEGOTIABLE. Violations will cause catastrophic performance degradation and subtle timing bugs that are extremely difficult to debug.
NO logging or serial output
serial_println! or println! of any kindNO page table walks or memory diagnostics
translate_address() or similarNO function calls that allocate or take locks
Interrupt handlers must complete in < 1000 cycles
Context switch path must be deterministic and fast
Violating these rules causes a specific failure pattern that is extremely difficult to diagnose:
Case Study: The trace_iretq_to_ring3 Bug (December 2024)
Symptom: Userspace process appeared to be created successfully but never executed a single instruction. Process remained stuck at RIP=0x40000000 (entry point) through 2,389 scheduler iterations.
Root Cause: Heavy diagnostics in trace_iretq_to_ring3():
// THIS WAS THE BUG - DO NOT DO THIS
unsafe fn trace_iretq_to_ring3(frame: &TrapFrame) {
serial_println!("=== IRETQ to Ring 3 ===");
serial_println!(" RIP: {:#x}", frame.rip);
serial_println!(" RSP: {:#x}", frame.rsp);
// ... more logging ...
let phys_addr = current_page_table.translate_address(VirtAddr::new(frame.rip));
// ... even more diagnostics ...
}
What Actually Happened:
iretq instruction successfully transitioned to Ring 3Why This Happened:
Serial output in trace_iretq_to_ring3() consumed ~10,000-50,000 cycles. Timer fires every 20,000,000 cycles. The logging pushed the kernel dangerously close to the next timer interrupt. By the time iretq completed:
mov instructionEvidence:
The Fix:
Remove ALL diagnostics from the interrupt return path:
// CORRECT - pristine path
unsafe fn return_to_ring3(frame: &TrapFrame) {
// No logging. No diagnostics. Just return.
core::arch::asm!(
"iretq",
in("rsp") frame as *const _ as u64,
options(noreturn)
);
}
After this fix: Userspace executed immediately and correctly.
You cannot debug interrupt paths by adding logging to interrupt paths.
The act of observation changes the system behavior so dramatically that what you're debugging no longer exists. It's a kernel-level Heisenbug - the diagnostic itself destroys the evidence.
These operations are acceptable in interrupt/syscall paths:
TIMER_TICKS.fetch_add(1, Ordering::Relaxed);
SYSCALL_COUNT.fetch_add(1, Ordering::SeqCst);
if should_reschedule.load(Ordering::Acquire) {
schedule();
}
unsafe {
core::arch::asm!(
"mov {}, cr3",
out(reg) cr3_value
);
}
#[inline(always)]
fn save_context(regs: &mut Registers) {
// Direct memory writes, no allocations
regs.rax = read_rax();
regs.rbx = read_rbx();
// ...
}
debug_assert!(is_kernel_address(stack_ptr));
debug_assert_eq!(cs & 3, 0); // Must be ring 0
Assertions compile to nothing in release builds, so they're safe for sanity checks.
Since you cannot add logging to interrupt paths, use these techniques instead:
# External interrupt tracing - zero overhead
cargo run --bin qemu-uefi -- -d int,cpu_reset -D /tmp/qemu.log
# Then grep the log
grep "interrupt" /tmp/qemu.log
This shows every interrupt entry/exit with register state. No kernel instrumentation required.
# Terminal 1: Start QEMU with GDB server
cargo run --bin qemu-uefi -- -s -S
# Terminal 2: Attach GDB
gdb target/x86_64-breenix/release/breenix
(gdb) target remote :1234
(gdb) break timer_interrupt_handler
(gdb) continue
GDB lets you inspect state without modifying the timing characteristics.
#[cfg(debug_assertions)]
{
// This compiles out in release builds
INTERRUPT_COUNT.fetch_add(1, Ordering::Relaxed);
}
Only for counters and flags - never for serial output.
fn timer_interrupt_handler() {
// PRISTINE PATH - no logging
TIMER_TICKS.fetch_add(1, Ordering::Relaxed);
acknowledge_interrupt();
schedule_if_needed();
}
// Later, in a non-critical path:
pub fn print_timer_stats() {
let ticks = TIMER_TICKS.load(Ordering::Relaxed);
serial_println!("Timer ticks: {}", ticks);
}
Accumulate data in the hot path, log it in a cold path.
// Use CPU performance monitoring to count cycles
// This has minimal overhead (~10 cycles per read)
let start = read_tsc();
critical_operation();
let end = read_tsc();
CYCLE_COUNTS[operation_id].fetch_add(end - start, Ordering::Relaxed);
Before merging ANY code that touches interrupt or syscall paths, ask these questions:
When implementing interrupt or syscall handlers:
// Write this FIRST - the minimal handler
pub extern "C" fn syscall_entry() {
// Save registers
// Dispatch to handler
// Restore registers
// Return
}
static SYSCALL_COUNTS: [AtomicU64; 256] = [/* ... */];
pub extern "C" fn syscall_entry(syscall_num: u64) {
SYSCALL_COUNTS[syscall_num as usize].fetch_add(1, Ordering::Relaxed);
// ... rest of handler
}
cargo run --release --bin qemu-uefi
cargo run --release --bin qemu-uefi -- -d int -D /tmp/qemu.log
#[cfg(all(debug_assertions, feature = "interrupt-trace"))]
{
INTERRUPT_TRACE_BUFFER[index].store(rip, Ordering::Relaxed);
}
// NO! This is how bugs hide for months
fn timer_handler() {
serial_println!("Timer fired"); // "I'll remove this later"
// ...
}
Why it's wrong: You'll forget to remove it, or it will get copied to other handlers. When the bug appears in production, the logging will be the bug.
// NO! This creates timing-dependent behavior
fn context_switch(next: &Process) {
if next.is_userspace() {
serial_println!("Switching to PID {}", next.pid); // WRONG
}
// ...
}
Why it's wrong: Userspace processes get slower context switches than kernel processes. Creates Heisenbugs where timing depends on process type.
// NO! Interrupt handlers must be infallible
fn interrupt_handler() {
let msg = format!("Interrupt at {:#x}", rip); // WRONG - allocates
log_message(&msg);
}
Why it's wrong: What if the allocator is out of memory? What if the allocator lock is held? Handler must never fail.
// NO! Validation should be in non-critical paths
fn syscall_handler(num: u64, args: &[u64]) {
validate_user_memory(args[0]); // May page fault, walk tables, allocate
validate_file_descriptor(args[1]); // May take locks
// ...
}
Why it's wrong: Validation can be expensive. Do it before entering the critical path, or use hardware-based validation (page faults).
You've implemented a pristine interrupt/syscall path if:
Userspace makes forward progress immediately
Interrupt frequency is stable
Zero overhead in release builds
Debuggable with external tools
Invoke this skill when:
breenix-kernel-debug-loop: For iterative debugging with external toolsbreenix-systematic-debugging: For documenting root cause analysisbreenix-code-quality-check: For enforcing zero-warning buildsbreenix-interrupt-trace: For QEMU-based interrupt tracingbreenix-gdb-attach: For breakpoint-based debugging