
Latency x-ray for undocumented hardware
An exploratory tool for MMIO timing — time any physical address, and dissect the hardware off the latency.

Unexpectedly useful for hardware reverse-engineering, hypervisor fingerprinting, side-channelling device activity, register characterization, calibrating malicious triggers, and crafting obscenely long machine instructions.
The first megabyte of physical memory is an easy example to start with, even if its structure is already well-known.

The latency boundaries let us carve up the address space, without relying on reported layouts.
sudo ./mmiotic --start-address 0x0 --end-address 0x100000 --stride 4 --count 20 --quiet-mmap0x9FFFF at ~380 cy, then
the 128 KB video hole, which splits in two at 0xB0000. The change in
variance at 0x20000 may indicate a different page walk for the null page.A0000–AFFFF: slow and jittery (996 cy, stdev 327), routed to the
powered-but-idle Radeon. B0000–BFFFF: fast and rigid (780 cy, stdev 3.2,
reads ffffffff), claimed by nobody. Similar-looking data, but a 100x
variance gap.C0000–FFFFF: /proc/iomem says "System ROM", but scan shows DRAM
latency, not flash speed — the BIOS is shadowed into DRAM.Scan a root-complex config space (00:00.0, 4 KB) for latency outliers against
a flat ~675-cycle floor:

The mailbox latency is significantly higher than the surrounding data, and the duplicated spikes and equivalent variances let us match functionally similar registers.
sudo ./mmiotic --start-address 0xf8000000 --end-address 0xf8001000 --stride 4 --count 2000xe4 (1218 cy, value 80e3110b) and
0xa4 (1199 cy, value deadbeef). e4 is a documented doorbell; a4 is
undocumented.e4 is public.deadbeef reads as an uninitialized/error sentinel — same
mailbox, a4 unimplemented or fed invalid input.Carefully selected latencies can sometimes be used in unusual exploitation scenarios (MCHAMMER, smiiiiiiiiiiiiiiii):

Here an idle, driverless Radeon turns one aligned 4-byte read into a ~100,000-cycle stall.
sudo ./mmiotic --start-address 0x90e00000 --end-address 0x90e00100 --stride 4 --count 50 — sweep the head of the
GPU BAR (0x90e00000, 256 KB). 0x90e00008 costs 110,152 cy; 0x90e00018,
16 bytes on, is fast again at 1,523 — per-register, not per-page.sudo ./mmiotic --start-address 0x90e00000 --end-address 0x90e40000 --stride 4 --count 1 --binary — --binary
probes in bit-reversal order, so the full 256 KB aperture is uniformly covered
after 1,012 probes, mapping a contiguous 48 KB block where every register is slow.Ideally, reading a physical address would be a relatively safe operation, but
mmiotic is especially good at accidentally finding the exceptions:

For example, on the above platform, a 1-byte read of 0xdc5003b0 — offset
0x3b0 of an undriven Zen 4 iGPU register BAR (Region 5, 0xdc500000, 512 KB)
— reliably resets the system.
sudo ./mmiotic --address 0xdc5003b0 --size 1 --count 1. No output; the box
reboots.0x3ac and 0x3b4 on either side read 00000000 at ~3,200
cy and are harmless.0x3a0–0x3fc finds 7 poison dwords interleaved with 17
safe ones.sudo busybox devmem 0xdc5003b0 32 and sudo dd if=/dev/mem bs=1 count=1 skip=$((0xdc5003b0))
take the box down if you'd rather skip the middleman.[!WARNING] Some platforms will reset when specific address regions are probed by
mmiotic. Be careful where you use this.
| Option | Names |
|---|---|
-b/-d/-f/-r | PCI --bus/--device/--function/--register — decoded against the ECAM base |
-o, --offset <n> | offset from the MMIO base (skips B/D/F decode) |
-A, --address <n> | one physical address; sets the region base implicitly |
-a/-z | --start-address/--end-address — a physical range; implies --scan |
The ECAM base and size are auto-detected from ACPI MCFG
(/sys/firmware/acpi/tables/MCFG). Override with -M, --mmio-base /
-Z, --mmio-size.
| Option | Effect |
|---|---|
-S, --scan | walk all addresses in the target region |
-t, --stride <n> | step between addresses (default: 4) |
-n, --limit <n> | stop after this many addresses |
--binary | probe in bit-reversal order — after k probes the range has uniform coverage at ~range/k granularity (see the warning above) |
-x, --limited | limited register range (0x00–0xff vs 0x000–0xfff) |
-e, --skip | skip functions that read 0xffffffff at offset 0 (absent devices) |
-I, --iomem | scan every non–System-RAM top-level region in /proc/iomem |
-R, --ioregion <name> | scan /proc/iomem regions whose label matches <name>, any depth |
--find-target/--find-longest turn scanning into search:
| Option | Effect |
|---|---|
-F, --find-target <s> | find an address whose access time reaches s seconds, then escalate width (unaligned 4b → 8/16/32/64/512b) to push it higher |
-G, --find-longest | same search, but exhaust every candidate instead of bailing on the first hit |
-B, --fallbacks <n> | candidates carried into the escalation phase (default: 10) |
| Option | Effect |
|---|---|
-s, --size <n> | bytes per access: 1 / 2 / 4 (default) / 8 / 16 (XMM) / 32 (YMM) / 64 (ZMM) / 512 (fxrstor). Sizes > 4 are outside spec but work on tested hardware; AVX widths need AVX / AVX-512F |
-c, --count <n> | samples per address (default: 1); the minimum is reported |
-L, --lock | time a locked RMW: lock xadd (sizes 1/2/4/8) or lock cmpxchg16b (size 16). Writes the value back |
-g, --gather | time a vectored gather; --size 16/32/64 picks XMM/YMM/ZMM (AVX-512) |
-w, --write-read | capture write latency by timing a read that must observe a prior posted write — see below |
-E, --enter (--enter-2, --enter-3) | drive a burst of up to 30 accesses from one enter $0, $31 — see below |
-k, --continue | on an fxrstor fault, drop that sample and keep scanning |