.. /blog
2026-02-24 // 7 min

HPET Emulation: Memory-Mapped Timing at 0xFED00000

hpettimingmmio

The High Precision Event Timer is the third timing source I had to deal with. Unlike TSC (a CPU register) or the PM timer (an I/O port), HPET is memory-mapped. Its registers live at physical address 0xFED00000, and the main counter is at offset 0xF0. Any code that reads physical address 0xFED000F0 gets the current HPET counter value -- and that value is an independent clock that doesn't know about my TSC compensation.

The HPET runs at a configurable frequency, typically 14.318180 MHz (or sometimes 24 MHz on newer chipsets). Its counter is 64-bit and free-running. Like the PM timer, it's completely independent of the CPU clock. And like the PM timer, if it disagrees with my compensated TSC, a cross-reference check catches me.

NPT-based MMIO interception

The PM timer was intercepted via the I/O permission bitmap because it uses I/O ports. HPET is memory-mapped, so I intercept it through NPT. I find the NPT entry covering 0xFED00000, split the 2MB large page down to 4KB granularity if needed, and then clear the read permission on the specific 4KB page containing the HPET registers.

When the guest reads any address in the range 0xFED00000-0xFED00FFF, the CPU can't satisfy the read through NPT (the page has no read permission) and triggers a Nested Page Fault -- #NPF, exit code 0x400. My handler checks the faulting address, recognizes it as the HPET MMIO region, and returns a compensated value.

// Intercept HPET reads via NPT page fault
fn handle_npf(per_cpu: &PerCpu, gpa: u64, info: NpfInfo) {
    if gpa & !0xFFF == 0xFED00000 {
        let offset = gpa & 0xFFF;

        if offset == 0xF0 && info.is_read {
            // main counter register -- compensate it
            let real_val = read_hpet_counter();
            let compensated = apply_hpet_offset(
                real_val,
                per_cpu.tsc_offset,
                per_cpu.tsc_freq,
                per_cpu.hpet_freq,
            );
            // write compensated value to guest's RAX
            per_cpu.vmcb.save.rax = compensated;
        } else {
            // other HPET registers (capabilities, config, etc)
            // pass through the real hardware value
            per_cpu.vmcb.save.rax = read_mmio(gpa);
        }
        advance_rip(per_cpu);
        return;
    }
    // ... handle other NPF causes ...
}

The tricky part is that HPET reads are memory-mapped, which means the CPU executes a regular MOV instruction. I need to decode the instruction to figure out the destination register, the access width, and advance RIP past it. For the main counter at offset 0xF0, it's almost always a 64-bit MOV to RAX, but I handle 32-bit reads too.

Compensating the HPET counter

The compensation formula is the same pattern as the PM timer: convert the TSC offset to HPET ticks using the frequency ratio, and apply it to the real counter value.

// HPET compensation -- same pattern as PM timer
fn apply_hpet_offset(
    real_val: u64,
    tsc_offset: i64,
    tsc_freq: u64,
    hpet_freq: u64,
) -> u64 {
    // convert TSC offset to HPET ticks
    let hpet_offset = tsc_offset * hpet_freq as i64 / tsc_freq as i64;
    // apply -- guest sees a counter that matches TSC-derived time
    (real_val as i64 + hpet_offset) as u64
}

With a TSC offset of -150 cycles at 3.8 GHz, the HPET offset works out to about -0.57 HPET ticks (at 14.318 MHz). Over longer measurement windows, this scales linearly. A 1-second measurement window would see a 566,000-tick HPET offset, which exactly matches the time delta that the TSC offset hides.

The alternative: disable HPET entirely

There's a simpler approach that I actually used initially before implementing full compensation: just disable the HPET counter at initialization. The HPET has a General Configuration Register at offset 0x10 with an "Enable CNF" bit. If I clear that bit before virtualizing, the counter stops incrementing.

Windows handles this gracefully -- it falls back to TSC (which I've already compensated) or PM timer (also compensated). The HPET is the lowest-priority timing source in the Windows HAL's timer selection logic. Disabling it doesn't break anything; it just removes one timing source from the hierarchy.

But disabling HPET is itself detectable. If anti-cheat code reads the HPET capabilities register and sees the timer exists, then reads the config register and sees it's disabled, that's unusual for a running system. A paranoid detector could flag that. So I moved to full compensation -- the counter keeps running, reads get intercepted, and the values match the compensated TSC.

The three timing sources now form a consistent view: TSC is compensated via the hardware tsc_offset field in the VMCB (zero-cost, applied by CPU microcode). PM timer is compensated via I/O port interception (IOPM). HPET is compensated via NPT page fault interception (#NPF). Three different interception mechanisms, one unified compensation formula: offset = tsc_offset * target_freq / tsc_freq. All clocks agree.

What's left uncovered

Even with TSC, PM timer, and HPET compensated, there are still timing sources I haven't fully addressed. KUSER_SHARED_DATA at 0x7FFE0000 has timing fields (InterruptTime, SystemTime, TickCount) that the kernel updates on every timer interrupt. These fields are derived from the hardware timer, not from RDTSC, so they don't automatically benefit from TSC compensation. That's a known Tier 3 gap in my detection vector analysis.

There are also the RAPL power MSRs (energy counters that can be used as a coarse timing source) and IBS result MSRs (instruction-level sampling that can leak handler addresses). I intercept both of those via the MSRPM, but their compensation is cruder -- I strip values rather than precisely offsetting them.

The timing story is never truly "done." Every clock source on the system is a potential cross-reference. But with TSC + PM + HPET + APERF/MPERF all telling the same story, I've covered the clocks that anti-cheat software actually checks in practice. The academic attack vectors (RAPL side-channels, cache timing, branch predictor pollution) remain theoretical for now.