Skip to content

Device Emulation: PIO, MMIO, and the Bus Model

27 min5204 words3 diagrams

Source: Victor Bona's Obsidian Compendium snapshot, Knowledge base/Virtualization/07 Device Emulation PIO MMIO and the Bus Model.md.

Purpose: explain how guest accesses to device registers become VM exits, how a VMM routes those exits through address-space buses to device models, and how to emulate the small set of legacy and PCI devices every real VMM needs.

Device Emulation: PIO, MMIO, and the Bus Model

Related notes: 04 KVM API and the vCPU Run Loop, 05 Memory Virtualization EPT NPT and Guest Memory, 06 Interrupts Timers and Interrupt Controller Virtualization, 08 Virtio Devices Virtqueues and vhost, 09 Booting Guests Firmware and the Linux Boot Protocol, 13 Security Isolation and Attack Surface, 13 Kernel Architecture Modules Drivers and Device Model, 07 Concurrency Parallelism and Synchronization.

Why device emulation is the center of the VMM

KVM virtualizes the CPU, the MMU, and (optionally) the interrupt controllers and timers. Everything else the guest believes is hardware is code in your VMM: the serial port it prints to, the keyboard controller it pokes to reboot, the RTC it reads at boot, the PCI host bridge it enumerates, and every virtio device. A minimal Linux microVM needs surprisingly little:

DeviceWhy the guest needs itWho usually emulates it
Serial console (16550 or PL011)Early boot log, console=ttyS0VMM userspace
Reset path (i8042, PSCI, SBI SRST, ACPI)reboot must terminate or restart the VMVMM, sometimes KVM
RTC (CMOS on x86, PL031 on arm64, goldfish on RISC-V virt)Wall clock at bootVMM userspace
Interrupt controller and timerEvery device interrupt and the scheduler tickKVM in-kernel (see 06 Interrupts Timers and Interrupt Controller Virtualization)
Block and network devicesRoot filesystem, connectivityvirtio in VMM, vhost in kernel or another process
PCI host bridge (optional)Device discovery, BARs, MSI-XVMM userspace

Device models are also the largest attack surface in a hypervisor. Most published VM escapes (VENOM in the QEMU floppy controller, many virtio and USB bugs) live in device emulation code, not in the CPU virtualization layer. Every register handler is a parser of attacker-controlled input. See 13 Security Isolation and Attack Surface.

Why it matters for your VMM: the shape of your bus and device traits decides how fast exits are dispatched, how devices are locked, how snapshots serialize state, and how much of the device layer you can later move out of the vCPU thread.

Two ways to touch a device: PIO and MMIO

Port I/O (x86 only)

x86 has a separate 16-bit I/O address space (ports 0x0000 to 0xFFFF) accessed with IN, OUT, INS, and OUTS. Under VT-x and SVM, the hypervisor configures I/O bitmaps; KVM intercepts all port accesses that are not handled in-kernel and, when no in-kernel device claims the port, exits to userspace with KVM_EXIT_IO.

The kvm_run structure describes the access:

/* KVM_EXIT_IO, from include/uapi/linux/kvm.h */
struct {
#define KVM_EXIT_IO_IN  0
#define KVM_EXIT_IO_OUT 1
    __u8  direction;
    __u8  size;        /* bytes per access: 1, 2 or 4 */
    __u16 port;
    __u32 count;       /* > 1 for string instructions (rep outsb) */
    __u64 data_offset; /* relative to the start of the kvm_run mapping */
} io;

Facts that bite:

  • count can be greater than 1 for REP INS and REP OUTS. The data for all iterations sits contiguously at data_offset. kvm-ioctls hands you a single slice; you must loop over size-sized chunks if you want per-access semantics.
  • For IN, you write the result into the shared buffer and the next KVM_RUN completes the instruction. The guest register is not updated until you re-enter.
  • Port accesses that hit nothing should read as all ones (0xFF per byte) and silently drop writes. Real chipsets float the bus high, and guest probe code depends on it.

Memory-mapped I/O (all architectures)

MMIO devices live at guest physical addresses that are not backed by a memory slot. When the guest loads or stores to such an address, the second-stage page walk (EPT on Intel, NPT on AMD, stage 2 on arm64, G-stage on RISC-V) fails, the CPU exits to KVM, KVM decodes the faulting access, and if no in-kernel device claims the address it exits to userspace with KVM_EXIT_MMIO:

/* KVM_EXIT_MMIO */
struct {
    __u64 phys_addr;
    __u8  data[8];
    __u32 len;
    __u8  is_write;
} mmio;

On x86, KVM marks unbacked GPAs with special "MMIO SPTEs" so repeated accesses take a fast EPT misconfiguration path instead of a full page walk. The decode step is still costly: KVM must fetch and emulate the instruction, because a VM exit for an EPT violation does not tell it the register or width on Intel. See 05 Memory Virtualization EPT NPT and Guest Memory.

Architecture comparison

Aspectx86-64ARM64RISC-V
Separate I/O spaceYes, 64 Ki portsNoNo
Userspace exit for PIOKVM_EXIT_IOn/an/a
How MMIO trapsEPT/NPT violation or misconfig on unbacked GPAStage 2 data abort at EL2G-stage guest page fault into HS-mode
How KVM learns width and registerInstruction emulator decodes guest codeESR_EL2 syndrome when ISV=1Transformed instruction in htinst, or KVM reads and decodes the trapping instruction
Undecodable accessRare (emulator covers most instructions)ISV=0 (for example load pair, writeback addressing): exits with KVM_EXIT_ARM_NISV if userspace enabled KVM_CAP_ARM_NISV_TO_USER, otherwise an errorKVM handles the base load/store encodings; unusual encodings fail
Userspace exit for MMIOKVM_EXIT_MMIOKVM_EXIT_MMIOKVM_EXIT_MMIO

On arm64, Linux guest drivers use readl/writel accessors that compile to simple single-register loads and stores precisely so that the syndrome is valid. Firmware or bare-metal guests that use load-pair or vector instructions on device memory break under virtualization; this is a guest bug, but your VMM must report it clearly rather than hang.

Batched exits: coalesced MMIO and PIO

For write-only registers whose side effects can be deferred (framebuffers, some legacy device registers), KVM can queue writes into a ring shared with userspace instead of exiting:

Ioctl or capabilityEffect
KVM_REGISTER_COALESCED_MMIOWrites to the zone are appended to the coalesced ring; no exit
KVM_CAP_COALESCED_PIOSame mechanism for ports (zone with pio = 1)
KVM_UNREGISTER_COALESCED_MMIORemove a zone

The ring is drained by userspace on the next exit of any kind. Never coalesce a register whose write must be observed before the guest's next read (for example a doorbell, or an index register paired with a data register), because the read would see stale device state.

The exit-to-device path

Rendering diagram...

With kvm-ioctls (checked against 0.25.1 on docs.rs), VcpuFd::run returns a VcpuExit that borrows the kvm_run data buffer:

use kvm_ioctls::{VcpuExit, VcpuFd};

fn run_vcpu(vcpu: &mut VcpuFd, bus: &Buses) -> anyhow::Result<()> {
    loop {
        match vcpu.run()? {
            VcpuExit::IoIn(port, data) => bus.pio.read(u64::from(port), data),
            VcpuExit::IoOut(port, data) => bus.pio.write(u64::from(port), data),
            VcpuExit::MmioRead(addr, data) => bus.mmio.read(addr, data),
            VcpuExit::MmioWrite(addr, data) => bus.mmio.write(addr, data),
            VcpuExit::Hlt => return Ok(()),
            VcpuExit::Shutdown => anyhow::bail!("guest triple fault or shutdown"),
            VcpuExit::SystemEvent(kind, _flags) => {
                // arm64 PSCI and RISC-V SBI reset/shutdown land here
                anyhow::bail!("system event {kind}");
            }
            other => anyhow::bail!("unhandled exit: {other:?}"),
        }
    }
}

Notes on this loop:

  • IoIn and IoOut slices already cover size * count bytes. If you need per-iteration semantics for string I/O, chunk by the original access size; most VMMs simply pass the whole slice and accept that rep outsb to a UART writes several bytes.
  • The SystemEvent variant's payload changed between kvm-ioctls releases (older versions passed flags as u64, newer ones pass the data array). Check the version you pin.
  • Hlt only appears if you do not use an in-kernel LAPIC (or on configurations where KVM forwards HLT). With KVM_CREATE_IRQCHIP, KVM handles HLT internally.

The bus model

A bus maps address ranges to devices. The VMM typically keeps two on x86 (PIO and MMIO) and one on arm64 and RISC-V (MMIO only). Requirements:

RequirementReason
Ranges never overlapOverlap means two devices claim the same register; reject at registration
Lookup is O(log n) or betterLookup runs on every userspace exit
Device receives an offset, not an absolute addressDevices can be relocated (PCI BAR reprogramming) without code changes
Lookup holds no lock across the device call, or a read lock onlyConcurrent vCPUs must not serialize on the bus map
Ranges can be added and removed at runtimePCI BAR moves, hotplug, device reset

A minimal bus in std Rust

The canonical structure is a BTreeMap keyed by range base, searched with range(..=addr).next_back():

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, RwLock};

pub trait BusDevice: Send {
    fn read(&mut self, offset: u64, data: &mut [u8]);
    fn write(&mut self, offset: u64, data: &[u8]);
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Range {
    base: u64,
    len: u64,
}

#[derive(Default)]
pub struct Bus {
    devices: RwLock<BTreeMap<u64, (Range, Arc<Mutex<dyn BusDevice>>)>>,
}

impl Bus {
    pub fn insert(&self, dev: Arc<Mutex<dyn BusDevice>>, base: u64, len: u64) -> Result<(), String> {
        if len == 0 || base.checked_add(len).is_none() {
            return Err("invalid range".into());
        }
        let mut map = self.devices.write().unwrap();
        // Overlap check: predecessor must end before base, successor must start after end.
        if let Some((_, (r, _))) = map.range(..=base).next_back() {
            if r.base + r.len > base {
                return Err(format!("overlaps {:#x}", r.base));
            }
        }
        if let Some((&next, _)) = map.range(base..).next() {
            if next < base + len {
                return Err(format!("overlaps {next:#x}"));
            }
        }
        map.insert(base, (Range { base, len }, dev));
        Ok(())
    }

    fn resolve(&self, addr: u64) -> Option<(u64, Arc<Mutex<dyn BusDevice>>)> {
        let map = self.devices.read().unwrap();
        let (_, (r, dev)) = map.range(..=addr).next_back()?;
        (addr < r.base + r.len).then(|| (addr - r.base, dev.clone()))
    }

    pub fn read(&self, addr: u64, data: &mut [u8]) {
        match self.resolve(addr) {
            Some((off, dev))=> dev.lock().unwrap().read(off, data),
            None=> data.fill(0xff),
        }
    }

    pub fn write(&self, addr: u64, data: &[u8]) {
        if let Some((off, dev))= self.resolve(addr) {
            dev.lock().unwrap().write(off, data);
        }
    }
}

The read lock on the map is released before the device lock is taken (the Arc is cloned out). This keeps lock ordering simple: bus map lock, then device lock, never the reverse, and never both at once.

An access that straddles two devices (an 8-byte read starting 4 bytes before the end of a range) is legal on real hardware for some buses but is almost always a guest bug. Production VMMs either deliver it to the first device with the full length or reject it. Pick one and log it.

rust-vmm vm-device

The rust-vmm vm-device crate (0.1.0 on docs.rs at time of writing) packages the same idea with typed addresses:

ItemRole
bus::BusRange&lt;A&gt;, MmioAddress, PioAddressTyped, non-overlapping ranges
DeviceMmio, DevicePioDevice traits with &self methods (device does its own interior locking)
MutDeviceMmio, MutDevicePio&mut self variants, adapted through Mutex
device_manager::IoManagerHolds an MMIO bus and a PIO bus
MmioManager, PioManagermmio_read(&self, addr: MmioAddress, data: &mut [u8]), pio_write(&self, addr: PioAddress, data: &[u8]), each returning Result&lt;(), bus::Error&gt;

The MutDevicePio methods have the shape fn pio_read(&mut self, base: PioAddress, offset: PioAddressOffset, data: &mut [u8]) and fn pio_write(&mut self, base: PioAddress, offset: PioAddressOffset, data: &[u8]). The crate is still 0.x and lightly versioned; Firecracker and Cloud Hypervisor each carry their own bus implementation (Cloud Hypervisor's vm-device fork has a BusDevice trait whose write can return a barrier so the vCPU thread waits for a device-side action). Copy the idea, pin the version, and expect to own the code.

Allocating addresses

Addresses come from a layout you define per architecture (see 09 Booting Guests Firmware and the Linux Boot Protocol):

Regionx86-64 (typical microVM)ARM64 (typical)RISC-V (typical)
Low RAM0 to below 3 GiBstarts at 1 GiB or 2 GiB (0x4000_0000 or 0x8000_0000)starts at 2 GiB (0x8000_0000)
32-bit MMIO hole3 GiB to 4 GiB (below LAPIC 0xFEE0_0000, IOAPIC 0xFEC0_0000)below RAMbelow RAM
64-bit MMIOabove top of RAMabove top of RAMabove top of RAM
Interrupt controllerIOAPIC, LAPIC fixed addressesGIC distributor and redistributorsPLIC or APLIC/IMSIC

rust-vmm vm-allocator provides an address allocator and an ID allocator (for GSIs or device IDs). Whether you use it or a few lines of bump allocation, the rule is the same: allocate every MMIO window and IRQ from one authority, record the allocation in the device's state (snapshots need it), and generate the FDT or ACPI tables from the same records so they cannot drift.

Legacy x86 platform devices

Port(s)DeviceUsually emulated byNotes
0x20-0x21, 0xA0-0xA18259 PICKVM (KVM_CREATE_IRQCHIP)See 06 Interrupts Timers and Interrupt Controller Virtualization
0x40-0x438254 PITKVM (KVM_CREATE_PIT2)Linux may calibrate TSC against it
0x60, 0x64i8042 keyboard controllerVMMReset path
0x61System control port B (PC speaker, PIT gate)KVM with in-kernel PIT (speaker_dummy)
0x70-0x71CMOS and RTCVMMWall clock
0x80POST diagnostic portVMM (ignore writes)Linux uses it as an I/O delay; make it cheap
0x2F8-0x2FFCOM2VMMIRQ 3
0x3F8-0x3FFCOM1VMMIRQ 4
0xCF8, 0xCFC-0xCFFPCI configuration mechanism #1VMMOnly if you expose PCI
0x510-0x511QEMU fw_cfgQEMU onlyNot needed for direct kernel boot

Linux writes to port 0x80 for I/O delays during early boot. If every one of those writes costs a userspace exit, boot gets measurably slower. Register an ioeventfd with no reader, or handle the port with an empty match arm high in the dispatch order.

Emulating a 16550 UART

The 16550A is the default x86 console and is also used as ns16550a on arm64 and RISC-V boards and the QEMU RISC-V virt machine.

Register map

Eight byte-wide registers at consecutive offsets. Offset 0 and 1 change meaning when the Divisor Latch Access Bit (DLAB, bit 7 of LCR) is set.

OffsetDLAB=0 readDLAB=0 writeDLAB=1
0RBR (receive buffer)THR (transmit holding)DLL (divisor low)
1IER (interrupt enable)IERDLM (divisor high)
2IIR (interrupt identification)FCR (FIFO control)same
3LCR (line control)LCRsame
4MCR (modem control)MCRsame
5LSR (line status)ignored (factory test)same
6MSR (modem status)ignoredsame
7SCR (scratch)SCRsame

Key bits for a console-only model:

RegisterBitMeaning
IER0Enable "received data available" interrupt
IER1Enable "THR empty" interrupt
IIR01 = no interrupt pending
IIR3:1010 THR empty, 100 RX data available
LSR0Data ready (RX FIFO non-empty)
LSR5THR empty
LSR6Transmitter empty
MCR4Loopback mode (Linux probes with it)
MCR3OUT2: gates the interrupt line on PC hardware

Interrupt behavior

Rendering diagram...

Two subtleties:

  • Reading IIR clears a pending THR-empty interrupt. If you raise the interrupt but do not clear it on IIR read, the guest's interrupt handler loops.
  • Linux's 8250 driver probes for a broken "THRE interrupt on enable" behavior by toggling IER and reading IIR. Getting this wrong produces the classic "serial console hangs after a few lines" symptom, where output only progresses when something else interrupts the guest.

Using vm-superio

The rust-vmm vm-superio crate (checked against 0.8.2) implements the register state machine; you provide the interrupt trigger and the output sink.

ItemSignature (0.8.2)
ConstructorSerial::new(trigger: T, out: W) -&gt; Serial&lt;T, NoEvents, W&gt;
With metrics hooksSerial::with_events(trigger: T, serial_evts: EV, out: W) -&gt; Self
Register readfn read(&mut self, offset: u8) -&gt; u8
Register writefn write(&mut self, offset: u8, value: u8) -&gt; Result&lt;(), Error&lt;T::E&gt;&gt;
Guest inputfn enqueue_raw_bytes(&mut self, input: &[u8]) -&gt; Result&lt;usize, Error&lt;T::E&gt;&gt;
Input backpressurefn fifo_capacity(&self) -&gt; usize
use std::io::{self, Stdout};
use vm_superio::{Serial, Trigger};
use vmm_sys_util::eventfd::EventFd;

/// Newtype so we can implement the foreign Trigger trait for an eventfd
/// that is registered with KVM as an irqfd on GSI 4.
pub struct IrqTrigger(pub EventFd);

impl Trigger for IrqTrigger {
    type E = io::Error;
    fn trigger(&self) -> io::Result<()> {
        self.0.write(1)
    }
}

pub struct SerialDevice {
    inner: Serial<IrqTrigger, vm_superio::serial::NoEvents, Stdout>,
}

impl SerialDevice {
    pub fn new(irq: EventFd) -> Self {
        Self { inner: Serial::new(IrqTrigger(irq), io::stdout()) }
    }

    /// Called from the VMM event loop when host stdin is readable.
    pub fn feed_input(&mut self, bytes: &[u8]) -> usize {
        let room = self.inner.fifo_capacity();
        let take = bytes.len().min(room);
        self.inner.enqueue_raw_bytes(&bytes[..take]).unwrap_or(0)
    }
}

impl BusDevice for SerialDevice {
    fn read(&mut self, offset: u64, data: &mut [u8]) {
        if data.len() == 1 {
            data[0] = self.inner.read(offset as u8);
        } else {
            data.fill(0xff);
        }
    }

    fn write(&mut self, offset: u64, data: &[u8]) {
        if data.len() == 1 {
            // Interrupt or output errors are logged, never propagated to the guest.
            let _ = self.inner.write(offset as u8, data[0]);
        }
    }
}

Input design: when the RX FIFO is full, stop polling host stdin (remove it from epoll) and re-arm when the guest drains the FIFO. Otherwise a paste of a large buffer is either dropped or spins the event loop. vm-superio's SerialEvents hook (with_events) lets you observe "buffer ready" and similar events to drive that re-arm.

Production consoles often redirect output to a rate-limited log file instead of stdout: a guest that prints in a tight loop can otherwise fill the host disk or stall the vCPU thread on a blocked pipe. Firecracker caps serial output for this reason and does not enable the serial console in its recommended production configuration.

UART per architecture

ArchitectureCommon choiceDescription to guestInterrupt
x86-6416550 at port 0x3F8Implicit (legacy ISA), console=ttyS0IOAPIC pin 4 (edge)
ARM64PL011 (Cloud Hypervisor, QEMU) or 16550 on MMIO (Firecracker)FDT node arm,pl011 or ns16550a, or ACPI SPCRGIC SPI
RISC-V16550 on MMIO (ns16550a)FDT nodePLIC or APLIC source

On MMIO, a 16550 may be laid out with 1-byte or 4-byte register stride; the FDT reg-shift and reg-io-width properties tell the guest which. A mismatch prints nothing and is a common first-boot failure on arm64 and RISC-V.

The i8042 reset path

On PC-compatible hardware the keyboard controller drives the CPU reset line. Linux's reboot=k path (and several fallbacks in the default sequence) writes command 0xFE (pulse output line 0) to the command port 0x64. A VMM implements just enough i8042 to see this:

AccessBehavior in a minimal model
Read 0x64 (status)Return 0 (input and output buffers empty) so the guest does not wait
Read 0x60 (data)Return 0
Write 0xFE to 0x64Signal reset to the VMM
Other writesIgnore

vm-superio provides I8042Device::new(reset_evt: T) with read(&mut self, _offset: u8) -&gt; u8 and write(&mut self, offset: u8, value: u8) -&gt; Result&lt;(), T::E&gt;. The device's docs describe it as handling only the CPU reset, and its example writes 0xFE at offset 4 (the device is placed at base 0x60, so offset 4 is port 0x64). Treat the reset eventfd as "the guest wants to reboot": Firecracker's policy is to exit the VMM process and let the orchestrator decide, because a fresh process is cheaper and safer than an in-place reset of every device model.

Other reset and shutdown mechanisms you will see:

MechanismArchitectureHow the VMM sees it
i8042 0xFEx86PIO write to 0x64
Triple faultx86KVM_EXIT_SHUTDOWN
ACPI reset register / PM1 sleepx86 (and arm64 with ACPI)PIO or MMIO write to addresses you advertise in FADT
Port 0xCF9 reset controlx86PIO write (some Linux reboot fallbacks use it)
PSCI SYSTEM_RESET / SYSTEM_OFFARM64KVM_EXIT_SYSTEM_EVENT (KVM implements PSCI in kernel)
SBI System Reset extension (SRST)RISC-VKVM_EXIT_SYSTEM_EVENT (KVM implements SBI in kernel)

If you do not implement any reset path on x86, reboot in the guest ends in a triple fault, which is also an acceptable termination signal as long as you treat KVM_EXIT_SHUTDOWN as "guest wants to stop".

CMOS and RTC

The MC146818-compatible RTC lives behind an index/data pair: write the register index to 0x70, then read or write 0x71. Bit 7 of the index write is the NMI-disable bit on real hardware; mask it off before using the index.

IndexRegisterNotes
0x00, 0x02, 0x04Seconds, minutes, hoursBCD unless Status B bit 2 is set
0x06Day of week
0x07, 0x08, 0x09Day of month, month, year (2 digits)
0x0AStatus ABit 7: update in progress (UIP)
0x0BStatus BBit 1: 24-hour mode, bit 2: binary mode, bits 4-6: interrupt enables
0x0CStatus CInterrupt flags, cleared on read
0x0DStatus DBit 7: RTC power valid; return 0x80
0x32Century (common convention, advertised via ACPI FADT century field)
0x34, 0x35Memory above 16 MiB in 64 KiB units (SeaBIOS and QEMU convention)Only needed with legacy firmware
pub struct Cmos {
    index: u8,
    nvram: [u8; 128],
}

impl Cmos {
    fn to_bcd(v: u8) -> u8 {
        ((v / 10) << 4) | (v % 10)
    }

    fn rtc_reg(&self, index: u8) -> u8 {
        // shortcut: host UTC each read, no guest-set offset; store a delta if guests may set time.
        let now = time_now_utc(); // returns (sec, min, hour, wday, mday, mon, year)
        match index {
            0x00 => Self::to_bcd(now.sec),
            0x02 => Self::to_bcd(now.min),
            0x04 => Self::to_bcd(now.hour),
            0x06 => now.wday + 1,
            0x07 => Self::to_bcd(now.mday),
            0x08 => Self::to_bcd(now.mon),
            0x09 => Self::to_bcd((now.year % 100) as u8),
            0x32 => Self::to_bcd((now.year / 100) as u8),
            0x0A => 0x26, // UIP clear, 32.768 kHz divider, 1024 Hz rate
            0x0B => 0x02, // 24-hour, BCD
            0x0D => 0x80, // battery good
            i => self.nvram[i as usize],
        }
    }
}

impl BusDevice for Cmos {
    fn read(&mut self, offset: u64, data: &mut [u8]) {
        data[0] = if offset == 1 { self.rtc_reg(self.index) } else { 0xff };
    }
    fn write(&mut self, offset: u64, data: &[u8]) {
        match offset {
            0 => self.index = data[0] & 0x7f,
            1 if self.index >= 0x0E => self.nvram[self.index as usize] = data[0],
            _ => {}
        }
    }
}

struct Now { sec: u8, min: u8, hour: u8, wday: u8, mday: u8, mon: u8, year: u16 }
fn time_now_utc() -> Now { unimplemented!("convert SystemTime::now() to calendar fields") }

Reading the host clock on every register read is the simplest correct model because the guest polls UIP and re-reads until two reads agree. It breaks across snapshot restore only if you promise the guest a frozen clock; most microVM platforms prefer the guest seeing real time after restore and rely on kvm-clock and NTP for the rest.

Equivalents elsewhere: arm64 uses the PL031 (vm-superio rtc_pl031::Rtc, a 32-bit seconds counter at an MMIO base), and the QEMU RISC-V virt machine uses the Goldfish RTC. On arm64 and RISC-V many minimal VMMs omit the RTC entirely and let the guest take time from the network or a paravirtual clock.

PCI configuration space

PCI gives you discovery, resource assignment (BARs), and MSI-X without per-device guest drivers needing hard-coded addresses. The cost is a larger emulation surface and more boot-time exits.

Configuration space layout (type 0 header)

OffsetSizeField
0x002Vendor ID (0xFFFF means "no device")
0x022Device ID
0x042Command (bit 0 I/O space, bit 1 memory space, bit 2 bus master, bit 10 INTx disable)
0x062Status (bit 4 capabilities list present)
0x081Revision ID
0x093Class code (prog IF, subclass, base class)
0x0E1Header type (bit 7 multifunction)
0x10-0x246 x 4BAR0 to BAR5
0x2C2Subsystem vendor ID
0x2E2Subsystem ID
0x341Capabilities pointer
0x3C1Interrupt line (software scratch)
0x3D1Interrupt pin (1 = INTA, 0 = none)

Conventional PCI has 256 bytes of configuration space per function; PCI Express extends it to 4 KiB, reachable only through ECAM.

CAM: configuration mechanism #1 (x86 port I/O)

The guest writes an address to port 0xCF8 and accesses data at 0xCFC to 0xCFF:

Bits of 0xCF8 valueMeaning
31Enable
23:16Bus
15:11Device
10:8Function
7:2Register (dword index)

Byte and word accesses to 0xCFD to 0xCFF address bytes within the selected dword. Two exits per config access (address write, data access) make CAM enumeration slow, which is one reason microVMs skip PCI or use ECAM.

ECAM: memory-mapped configuration

ECAM maps every function's 4 KiB configuration space into a contiguous MMIO window:

address = ecam_base + (bus << 20) + (device << 15) + (function << 12) + register

One bus needs 1 MiB; 256 buses need 256 MiB. x86 advertises the window in the ACPI MCFG table. ARM64 and RISC-V advertise it in the FDT with a pci-host-ecam-generic node (or MCFG under ACPI). ECAM is the only option on arm64 and RISC-V because there is no port I/O.

fn ecam_decode(offset: u64) -> (u8, u8, u8, u16) {
    let bus = ((offset >> 20) & 0xff) as u8;
    let dev = ((offset >> 15) & 0x1f) as u8;
    let func = ((offset >> 12) & 0x7) as u8;
    let reg = (offset & 0xfff) as u16;
    (bus, dev, func, reg)
}

#[test]
fn ecam_decode_roundtrip() {
    let off = (1u64 << 20) | (3 << 15) | (2 << 12) | 0x10;
    assert_eq!(ecam_decode(off), (1, 3, 2, 0x10));
}

BARs and sizing

A BAR tells the guest where a device's registers live and how large the window is. Bit 0 distinguishes I/O BARs (1) from memory BARs (0). For memory BARs, bits 2:1 give the type (00 32-bit, 10 64-bit, consuming the next BAR as the upper half) and bit 3 marks prefetchable.

Sizing protocol the guest (or firmware) runs:

  1. Disable decoding (clear command bits 0 and 1).
  2. Write 0xFFFF_FFFF to the BAR.
  3. Read it back. The device returns ones only in writable address bits; low read-only bits encode the type.
  4. Size is !(value & !0xF) + 1 for memory BARs (!0x3 for I/O BARs).
  5. Program the chosen address, restore the command register.

Your config space model must implement this with per-register write masks, not special cases:

pub struct Bar {
    addr: u64,
    size: u64, // power of two, at least 16 for memory BARs
    is_64: bool,
    prefetch: bool,
}

impl Bar {
    fn low_dword(&self) -> u32 {
        let flags = (u32::from(self.is_64) << 2) | (u32::from(self.prefetch) << 3);
        (self.addr as u32 & !(self.size as u32 - 1)) | flags
    }

    fn write_low(&mut self, value: u32) {
        let mask = !(self.size - 1) & 0xffff_fff0;
        self.addr = (self.addr & !0xffff_ffff) | (u64::from(value) & mask);
    }
}

When the guest moves a BAR, the VMM must:

  1. Remove the old range from the MMIO bus and insert the new one.
  2. Move every ioeventfd registered inside the BAR (virtio notify addresses) by unregistering and re-registering them with KVM.
  3. If the BAR maps host memory directly (VFIO, virtio shared memory regions), move the KVM memory slot.

Missing step 2 is a classic bug: after the guest reassigns BARs, doorbells silently stop working because KVM is still listening at the old address. Linux usually keeps firmware-assigned BARs, so the bug only appears with some firmware or with pci=realloc.

Capabilities and MSI-X

The capability list starts at the pointer at 0x34; each entry is (id, next) followed by capability data.

IDCapabilityWhy a VMM cares
0x05MSISimple message-signaled interrupts
0x09Vendor specificvirtio-pci uses it for its structure locations
0x10PCI ExpressRequired for PCIe devices and extended config space
0x11MSI-XPer-queue interrupt vectors, table lives in a BAR

MSI-X table entries are 16 bytes: message address low, message address high, message data, vector control (bit 0 masks the vector). When the guest programs an entry, translate it into a KVM GSI route (KVM_SET_GSI_ROUTING with an MSI entry) and attach an irqfd, or call KVM_SIGNAL_MSI directly. Masking must be honored: a masked vector sets the Pending Bit Array bit instead of firing. See 06 Interrupts Timers and Interrupt Controller Virtualization and 11 Device Passthrough VFIO IOMMU and Nested Virtualization.

Transports: PCI versus virtio-mmio

Virtio devices need a transport for discovery, configuration, and notification. The device logic is identical; only registers and discovery differ. Details are in 08 Virtio Devices Virtqueues and vhost.

Propertyvirtio-pcivirtio-mmio
DiscoveryPCI enumerationFDT node, ACPI, or x86 kernel cmdline virtio_mmio.device=
InterruptsMSI-X per queue (or INTx)One wired interrupt per device, shared by all queues
HotplugPCIe native or ACPI hotplugNot supported by the transport
Guest boot costBus enumeration, config space exitsAlmost none
Firmware supportUEFI and SeaBIOS drivers existLimited (some edk2 builds)
Passthrough coexistenceSame bus as VFIO devicesSeparate
Typical usersQEMU, Cloud Hypervisor, crosvmFirecracker, QEMU microvm, many arm64 microVMs

A pragmatic path for a new VMM: start with virtio-mmio (one register block per device, no config space machinery), then add a PCI host bridge when you need MSI-X per queue, multiqueue scalability, hotplug, or VFIO.

Describing devices to the guest

ArchitectureUsual description mechanism
x86-64ACPI tables (DSDT, MADT, MCFG), implicit legacy ports, or virtio_mmio.device=4K@0xd0000000:5 on the kernel cmdline for microVMs
ARM64FDT nodes (ACPI on SBSA-style servers)
RISC-VFDT nodes (ACPI support is newer and less common)

Flattened device tree (ARM64 and RISC-V)

The FDT is the authoritative device list on arm64 and RISC-V microVMs. With rust-vmm vm-fdt (checked against 0.3.0), a virtio-mmio node looks like this:

use vm_fdt::{FdtWriter, FdtWriterResult};

const GIC_FDT_IRQ_TYPE_SPI: u32 = 0;
const IRQ_TYPE_EDGE_RISING: u32 = 1;

fn add_virtio_mmio_node(fdt: &mut FdtWriter, base: u64, size: u64, spi: u32) -> FdtWriterResult<()> {
    let node = fdt.begin_node(&format!("virtio_mmio@{base:x}"))?;
    fdt.property_string("compatible", "virtio,mmio")?;
    fdt.property_array_u64("reg", &[base, size])?;
    // arm64 GIC binding: <type number flags>. RISC-V PLIC uses a single cell instead.
    fdt.property_array_u32("interrupts", &[GIC_FDT_IRQ_TYPE_SPI, spi, IRQ_TYPE_EDGE_RISING])?;
    fdt.property_null("dma-coherent")?;
    fdt.end_node(node)
}

The interrupts cell format depends on the parent interrupt controller's #interrupt-cells: three cells for a GICv3 (type, number, flags, where SPI numbers are offset by 32 from the INTID), one or two for a RISC-V PLIC or APLIC. Getting the cell count wrong produces a device that probes but never receives interrupts.

dma-coherent matters on arm64 and RISC-V: without it the guest assumes the device is not cache-coherent and performs cache maintenance on every virtqueue access, which is slow, and on some guests subtly wrong for a software device that reads through the cache.

ACPI (x86-64, and arm64 servers)

x86 guests discover the interrupt controllers (MADT), PCI ECAM (MCFG), and non-enumerable devices (DSDT AML) through ACPI. Firecracker historically relied on the MP table and kernel cmdline instead of ACPI on x86; it added ACPI support later. Cloud Hypervisor generates full ACPI tables (and uses the acpi_tables crate). On arm64, ACPI is the norm for SBSA-style servers and required for some Windows guests; FDT dominates in microVMs. On RISC-V, ACPI support exists in recent Linux kernels but FDT remains the common path.

Device lifecycle

Rendering diagram...
PhaseOwnerThings that go wrong
Create and allocateVMM builderOverlapping ranges, IRQ reused across devices
RegisterVMM builderioeventfd registered before the event loop subscribes to it
DescribeBoot setupFDT and bus disagree after a refactor
ActivateGuest driverBackend opened too early (before features are known) or too late
ResetGuest or VMMBackend threads still writing guest memory after reset
Pause and snapshotVMM control planeIn-flight I/O not drained, interrupt state lost
RemoveHotplug pathBus entry removed but eventfds still registered with KVM

Keep device state serializable from day one: a plain struct with explicit registers, queue indices, and pending interrupt flags, separate from host resources (file descriptors, threads). That separation is what makes snapshot and live migration tractable; see 12 Snapshots Live Migration and Dirty Page Tracking.

Threading device models

A device register access arrives on whichever vCPU thread executed the instruction. Data-plane work arrives from host I/O completions. You must decide where each piece of work runs.

ModelDescriptionUsed byTradeoff
Inline on vCPU threadRegister access and all side effects run in the exit handlerSimple devices everywhere (UART, RTC, config space)Simple; any blocking stalls the guest CPU
vCPU handles registers, event loop handles dataDoorbell via ioeventfd wakes a VMM thread running epollFirecracker (single VMM thread for all devices, using event-manager)Low thread count; one slow device delays others
Thread per device or per queueEach virtio device owns epoll threadsCloud HypervisorScales across host CPUs; more threads, more locking
Device in a separate processDevice runs sandboxed, talks over sockets and shared memorycrosvm (jailed device processes), vhost-user backendsStrong isolation; IPC and memory-sharing complexity
Device in host kernelvhost-net, vhost-vsock, in-kernel irqchipMost production stacksFastest; kernel attack surface

Rules that hold across all models:

  • Never perform blocking host I/O (disk reads, network sends that can block, stdout to a pipe) in a register handler. Hand it off.
  • Use one lock per device and keep it short. If a register write must wait for a device thread (for example "reset the queue and confirm the worker stopped"), make the wait explicit and bounded.
  • Two vCPUs can access the same device concurrently. With &mut self traits behind a Mutex, correctness is easy but contention is real for hot registers; with &self traits, the device chooses atomics or finer locks.
  • Interrupt injection must not require taking another device's lock. Use irqfd so raising an interrupt is just an eventfd write.
  • Lock order: bus map, then device, then backend. Never call back into the bus from inside a device handler.

See 07 Concurrency Parallelism and Synchronization for the Rust primitives and 14 Performance Observability and Debugging for measuring exit costs.

Architecture summary

Topicx86-64ARM64RISC-V
I/O spacesPIO and MMIOMMIOMMIO
Console16550 at 0x3F8PL011 or 16550 MMIO16550 MMIO
Reseti8042, 0xCF9, ACPI, triple faultPSCI via KVM_EXIT_SYSTEM_EVENTSBI SRST via KVM_EXIT_SYSTEM_EVENT
RTCCMOS at 0x70PL031Goldfish RTC or none
PCI configCAM ports or ECAMECAM onlyECAM only
DiscoveryACPI, cmdline for virtio-mmioFDT (ACPI on servers)FDT
Wired interruptsIOAPIC pinsGIC SPIsPLIC or APLIC sources
MSILAPIC message formatGIC ITS (LPIs)IMSIC (AIA)
Undecodable MMIORareISV=0 abortsRare, depends on KVM decoder

Common pitfalls

PitfallSymptomFix
Unmapped port reads return 0Guest believes devices exist, probes hangReturn 0xFF bytes for misses
Serial THR interrupt not cleared on IIR readGuest stuck in interrupt stormModel IIR read side effect exactly
Serial output written synchronously to a blocking pipeGuest vCPU stalls when the consumer is slowBuffer and rate limit output, or write from the event loop
Port 0x80 traps to userspaceSlow early bootIgnore cheaply, or handle in kernel
CMOS index not masked with 0x7FRandom reads return NVRAM garbageMask NMI bit
BAR sizing returns stale address bitsGuest computes a huge or zero BAR sizeImplement write masks per register
ioeventfds not moved with BARDoorbells stop after guest reassigns resourcesRe-register on BAR move
Wrong #interrupt-cells in FDTDevice probes, interrupts never arriveGenerate cells from the irqchip model
Missing dma-coherent on arm64 or RISC-VSlow virtio, odd cache behaviorAdd the property for software devices
Blocking I/O in vCPU exit handlerLatency spikes, soft lockups in guestHand off to event loop or worker
Overlapping device ranges acceptedLast device registered silently winsReject overlap at insert
Coalesced MMIO on a register with read side effectsGuest reads stale stateCoalesce only pure write sinks
Treating KVM_EXIT_ARM_NISV as fatal without contextUnexplained VM crash on arm64Log PC and address, explain guest used an undecodable instruction

Review checklist

  • Every device range is allocated from one allocator and checked for overlap.
  • Bus misses return all-ones on read and are counted in metrics.
  • No register handler performs blocking host I/O.
  • Device state is a plain serializable struct separate from host resources.
  • Serial console output is bounded and cannot block the vCPU.
  • Reset paths (i8042, triple fault, PSCI, SBI SRST) all reach one shutdown or reboot routine.
  • CMOS model masks the NMI bit and reports Status D valid.
  • PCI config space uses write masks; BAR sizing returns correct type bits.
  • BAR moves update the bus, ioeventfds, and any memory slots.
  • MSI-X masking and the pending bit array are honored.
  • FDT, ACPI, and cmdline descriptions are generated from the same device registry as the bus.
  • #interrupt-cells, reg-shift, and dma-coherent are correct per architecture.
  • Lock order is documented: bus, device, backend.
  • Fuzzing or at least property tests cover register handlers with random offsets, widths, and values.

Primary Sources

contactvictor.bona@hotmail.com
victor-bona · terminal

type help for commands · esc to close