.. /blog
2026-03-07 // 12 min

0xC0000225: The Windows Boot Error That Took Two Weeks

windowsbootdebugging

I spent two weeks staring at serial console output trying to boot Windows inside my hypervisor. The bootloader loaded. Winload loaded. The progress bar filled to 100%. Then: 0xC0000225. STATUS_NOT_FOUND. The kernel couldn't initialize. Every fix I discovered came through serial debugging, and every fix revealed the next bug hiding behind it.

The goal was ambitious -- boot a full Windows 10 installation as a guest VM under my blue-pill hypervisor. Not a lightweight OVMF shell, not a toy OS. Real Windows, real NTFS, real kernel, real drivers. A 40GB disk image served through a virtual NVMe controller. I had the guest VM infrastructure working from Stage 9 -- OVMF booted fine on dedicated cores. But Windows is a different beast entirely.

This is the story of every wall I hit, in order, and how serial output was the only thing that kept me sane.

The serial console setup

Before anything else, I needed visibility. Windows boot is a black box without serial debugging. I configured the BCD store in the guest disk image to send debug output over a virtual COM port, and my hypervisor forwarded that to the host's real serial port. Every boot attempt produced megabytes of text.

// BCD configuration for serial debug output
bcdedit /dbgsettings serial debugport:1 baudrate:115200
bcdedit /set {default} bootdebug on
bcdedit /set {default} debug on
bcdedit /set {default} sos on

The sos flag was critical. Without it, Windows shows the spinner animation and tells you nothing. With it, every driver load, every initialization step, every failure gets printed. I could watch the boot process in real time and see exactly where it stalled.

Wall #1: bootmgfw to winload

The first stage worked immediately. bootmgfw.efi loaded from the EFI System Partition, found the BCD store, located the Windows installation, and handed off to winload.efi. I saw the handoff in serial output -- the transition from UEFI boot services to the Windows boot environment. This part was clean because OVMF had already validated my firmware emulation.

Winload started loading files. I could see it pulling ntoskrnl.exe, hal.dll, CI.dll, boot-start drivers -- all from the virtual NVMe. The progress bar appeared on the guest display and started filling. Everything looked normal.

Then at 100%, the screen went blank. Serial output showed the last message from winload, then... nothing from the kernel. No KdInitSystem call. No ExpInitializeExecutive. The kernel simply never started.

Wall #2: GDT was wrong -- #GP on segment load

The first real bug. WinDbg serial debugging showed a #GP(0) -- General Protection fault -- happening during the transition from winload to the kernel. The exception frame showed the faulting instruction was a segment register load. The selector value didn't match any valid GDT entry.

// the exception I was seeing in serial output
// #GP(0) at winload!OslArchTransferToKernel+0x42
// faulting instruction: mov ss, ax  (AX = 0x18)
//
// selector 0x18 = GDT index 3, RPL 0
// but my GDT entry 3 was a code segment, not data

// Windows expects this GDT layout:
gdt[0] = 0x0000000000000000;  // null
gdt[1] = 0x00AF9B000000FFFF;  // CS  (0x08)
gdt[2] = 0x00CF93000000FFFF;  // DS  (0x10)
gdt[3] = 0x00CF93000000FFFF;  // SS  (0x18)

My hypervisor's GDT had entries in a different order. The OVMF UEFI environment uses its own GDT layout, and when I set up the guest VM I copied that layout. But winload builds its own GDT during the transition to protected mode and expects specific selectors. Selector 0x18 must be a data segment. Mine was a code segment. Hence #GP.

The fix was straightforward -- patch the GDT entries in the guest's VMCB to match what winload expects. I intercept the LGDT instruction via a #VMEXIT, inspect the GDT base and limit, and if the layout doesn't match the Windows convention, I rewrite entries 1-3 to the correct descriptors. After this patch, the #GP disappeared.

Finding this took three days. The #GP happened once, the machine triple-faulted, and my hypervisor killed the guest. I had to add exception logging to my #VMEXIT handler that captured the full exception frame -- faulting RIP, error code, CR2, stack dump -- before the triple fault could fire. Only then could I see the selector value and trace it back to the GDT.

Wall #3: MTRR types weren't emulated

With the GDT fixed, the kernel started initializing. I could see KdInitSystem in serial output, then early memory manager setup. But it crashed during MmInitSystem -- the memory manager was reading MTRR MSRs to determine cache attributes for physical memory regions, and my hypervisor was returning garbage.

// the kernel reads these MSRs during MmInitSystem
MSR 0x2FF  // IA32_MTRR_DEF_TYPE
MSR 0x200  // IA32_MTRR_PHYSBASE0
MSR 0x201  // IA32_MTRR_PHYSMASK0
// ... up to PHYSBASE7/PHYSMASK7 (0x20E/0x20F)

// my shadow MTRR state
struct MtrrShadow {
    def_type: u64,       // 0x2FF -- default WB
    var_base: [u64; 8],  // 0x200, 0x202, ...
    var_mask: [u64; 8],  // 0x201, 0x203, ...
}

fn handle_msr_read(msr: u32, shadow: &MtrrShadow) -> u64 {
    match msr {
        0x2FF => shadow.def_type,
        0x200..=0x20F => {
            let idx = ((msr - 0x200) / 2) as usize;
            if msr % 2 == 0 { shadow.var_base[idx] }
            else { shadow.var_mask[idx] }
        }
        _ => 0,
    }
}

I built an MTRR shadow -- a software copy of what the guest should see when it reads MTRR MSRs. The shadow presents a clean layout: all memory is write-back by default, with the MMIO regions (above 0xC0000000) marked as uncacheable. Guest MTRR writes go into the shadow but never touch real hardware. The kernel's memory manager reads my shadow values, sets up its cache attribute tables correctly, and moves on.

Wall #4: disk I/O was incomplete

The next crash came during driver loading. The kernel finished early init, started loading boot-start drivers, and then faulted reading a driver image from disk. The problem: my disk I/O path only had 4GB mapped into guest RAM, but the Windows installation was 40GB. When winload and the kernel tried to read sectors beyond the mapped region, they got zeros or stale data.

I couldn't map 40GB into the guest's physical address space -- I didn't have that much RAM free. So I built a deferred I/O system: 4GB loaded into RAM (the first 2GB and a critical mid-range 2GB chunk), and everything else served on-demand by a worker thread.

// deferred I/O architecture
struct DeferredIo {
    queue: Vec<IoRequest>,
    worker: HANDLE,
    mapped_regions: [(PhysAddr, usize); 2],
}

fn handle_nvme_read(lba: u64, count: u32, buf_gpa: PhysAddr) {
    let offset = lba * 512;
    if is_in_mapped_region(offset, count) {
        // fast path: copy from pre-loaded RAM
        memcpy_to_guest(buf_gpa, mapped_ptr(offset), count * 512);
    } else {
        // slow path: queue to worker thread
        queue_deferred_read(offset, count, buf_gpa);
        // guest vCPU pauses until I/O completes
        pause_vcpu_until_io();
    }
}

The worker thread runs on a host core, outside the guest. When the guest's NVMe controller receives a read command for a sector that isn't in RAM, I queue the request, pause the guest vCPU, and the worker calls ZwReadFile against the real 40GB disk image file. When the read completes, the worker copies data into the guest's buffer and unpauses the vCPU.

There was a write bounds bug hiding in here too. When the guest wrote to sectors near the boundary of the mapped region, the write handler calculated the destination address wrong -- it used the LBA offset without accounting for the mapped region's base. Writes went to the wrong physical address, silently corrupting guest memory. Took another day to find because the corruption didn't manifest until much later in the boot sequence.

Wall #5: the 500 million exit limit

With I/O working, the boot progressed further than ever. Drivers loaded, the registry hive mounted, services started initializing. Then my hypervisor killed the guest. Not a crash -- my own safety code did it.

I had a development safety limit: if the exit counter exceeded 500 million, kill the guest to prevent runaway infinite loops. During OVMF testing, 500 million was insanely high -- OVMF never generated more than a few million exits. But Windows boot is different. Every CPUID, every MSR access, every I/O port read is an exit. Windows generates hundreds of millions of exits during boot. My safety limit was too low for a real operating system.

// the line that killed the boot
if exit_count > MAX_EXITS {
    log!("exit limit reached: {} exits, killing guest", exit_count);
    return VmAction::Kill;
}

// fix: raised the limit to 10 billion (and added a manual override)
const MAX_EXITS: u64 = 10_000_000_000;

I raised the limit to 10 billion and added a flag to disable it entirely for production runs. Windows boot on my hardware generates roughly 800 million exits. OVMF generates about 3 million. The gap is three orders of magnitude. Lesson learned: don't put arbitrary safety limits on systems when you don't know the real workload's scale.

Wall #6: back to 0xC0000225

With all five walls down, the boot progressed to a new point -- "Preparing Automatic Repair" appeared on the guest display. Then "Loading files..." with a progress bar that filled to 100%. Then 0xC0000225.

This was the original error I started with, but now happening at a later point in the boot. The previous crashes had been masking this deeper issue. The kernel loaded, initialized memory, loaded drivers, but then failed to find a critical boot resource. The serial output showed winload successfully handing off to ntoskrnl, the kernel initializing its subsystems, and then a STATUS_NOT_FOUND during IoInitSystem -- the I/O manager couldn't mount the boot volume.

The root cause: my virtual NVMe controller didn't properly emulate the IDENTIFY NAMESPACE command. The kernel's NVMe driver enumerated the controller, but when it tried to get namespace details, my response had the wrong LBA count. The driver thought the disk was smaller than it actually was, so sectors beyond a certain point returned errors. The boot volume's critical files (registry hives, specifically) lived in those sectors.

// the IDENTIFY NAMESPACE response I was sending
struct NvmeIdentifyNamespace {
    nsze: u64,     // namespace size in logical blocks
    ncap: u64,     // namespace capacity
    nuse: u64,     // namespace utilization
    // ...
}

// bug: I was calculating nsze from a u32 division
// let nsze = (disk_size_bytes / 512) as u32;  // WRONG -- truncates at 4GB
// fix:
let nsze: u64 = disk_size_bytes / 512;
A 32-bit integer can hold up to ~4.2 billion -- which is 2TB in 512-byte sectors. My 40GB disk is well under that. But I was casting the byte count to u32 before dividing, and the byte count (40GB = ~42 billion bytes) overflowed the u32. The result was a sector count of about 2.8 million instead of 83 million. Classic integer overflow hiding behind a type cast.

The final boot

After fixing the NVMe namespace size: bootmgfw loaded, winload loaded, the progress bar filled, the Windows logo appeared, and then -- the login screen. Two weeks, six bugs, and a few hundred hours of serial output later, Windows booted inside my hypervisor.

The boot takes about 45 seconds, compared to 12 seconds bare-metal. Most of the overhead is the deferred disk I/O -- every sector not in the pre-loaded 4GB region requires a round trip through the worker thread. With more RAM mapped, the boot would be faster. But it works. A full Windows 10 installation running as a guest VM under a blue-pill hypervisor, with the host Windows still running underneath.

Each of these walls -- GDT patching, MTRR shadow, deferred I/O, the exit limit, NVMe namespace size -- is its own blog post. But this is the overview. Two weeks of serial debugging, one STATUS_NOT_FOUND error code, and a lesson in how many things have to be exactly right for Windows to boot.