The vCPU Run Loop and VM Exits

A vCPU thread's entire working life is one loop: call ioctl(vcpufd, KVM_RUN), let the guest execute on the physical CPU until it does something the VMM must handle, read the exit reason, dispatch it, and loop. This is the data plane's hot path — the place where a guest's I/O instruction becomes a host pread, where a halt becomes a clean idle, where a triple fault becomes a shutdown. This chapter dissects that loop in src/vmm/src/vstate/vcpu/: the VcpuExit taxonomy KVM returns, how PIO and MMIO exits are dispatched to the device buses, how the kvm_run shared page carries the exit data, how the vCPU thread coordinates with the VMM thread through VcpuEvent/VcpuResponse, and how EINTR and pause interact with the loop.

After this chapter you will be able to: read the run loop without a guide; name every common VcpuExit variant and what the handler does with it; explain the difference between how PIO and MMIO data reach you; and trace a guest device access from KVM_RUN to a bus to a device and back.

Note: A VM exit is synchronous and on the vCPU thread. While the handler runs, the guest is stopped. That is why exit handlers must be fast and why the expensive parts of device work are pushed to the VMM thread via eventfds (see kvm-fundamentals.md on KVM_IOEVENTFD). A slow exit handler is a stalled guest.


The loop

# Find the run loop. Do not trust a line number.
rg -n "fn run\b|fn run_emulation|KVM_RUN|vcpu.run\(|VcpuExit|loop \{" src/vmm/src/vstate/vcpu/

In skeleton, the loop is:

#![allow(unused)]
fn main() {
// Shape only — read the real thing with the rg above; names drift.
loop {
    match self.fd.run() {                 // ioctl(KVM_RUN): blocks while guest runs
        Ok(VcpuExit::IoIn(addr, data))    => self.pio_bus.read(addr, data),
        Ok(VcpuExit::IoOut(addr, data))   => self.pio_bus.write(addr, data),
        Ok(VcpuExit::MmioRead(addr, data))  => self.mmio_bus.read(addr, data),
        Ok(VcpuExit::MmioWrite(addr, data)) => self.mmio_bus.write(addr, data),
        Ok(VcpuExit::Hlt)                 => break, // guest idled/halted
        Ok(VcpuExit::Shutdown | VcpuExit::SystemEvent(..)) => /* reset/shutdown */,
        Err(e) if e == EINTR              => { /* signal: check for pause/exit */ },
        Err(e)                            => /* fatal: report and stop */,
        _                                 => /* other exits */,
    }
    // between iterations: respond to any pending VcpuEvent (Pause/Resume/...)
}
}

vcpu.run() is kvm-ioctls' wrapper around KVM_RUN. It returns a VcpuExit (success) or an errno (notably EINTR when a signal interrupted the ioctl). The match arms are the heart of device emulation.


The VcpuExit taxonomy

# The full set of variants comes from kvm-ioctls; see which ones Firecracker handles.
rg -n "VcpuExit::" src/vmm/src/vstate/vcpu/
cargo doc -p kvm-ioctls --open    # then search "VcpuExit"
VcpuExit variantKVM exit reasonMeaningFirecracker's response
IoIn(port, data)KVM_EXIT_IO (in)Guest executed IN on a PIO portRead from the PIO bus into data
IoOut(port, data)KVM_EXIT_IO (out)Guest executed OUT to a PIO portWrite data to the PIO bus
MmioRead(addr, data)KVM_EXIT_MMIO (read)Guest read an MMIO addressRead from the MMIO bus into data
MmioWrite(addr, data)KVM_EXIT_MMIO (write)Guest wrote an MMIO addressWrite data to the MMIO bus
HltKVM_EXIT_HLTGuest executed HLT (idle)End this emulation round; the guest is idle
Shutdown / SystemEventKVM_EXIT_SHUTDOWNTriple fault / reset / poweroffInitiate reset/shutdown; signals-shutdown-and-reset.md
FailEntryKVM_EXIT_FAIL_ENTRYThe CPU could not enter the guestFatal: bad vCPU state (regs/sregs); report
InternalErrorKVM_EXIT_INTERNAL_ERRORKVM hit an internal errorFatal: report and stop

Most guest device interaction shows up as MmioRead/MmioWrite (virtio-mmio registers, the-mmio-bus-and-device-manager.md) or IoIn/IoOut (the serial console and legacy x86 ports). Note what is not here on the fast path: a virtio "kick" that has been wired to an ioeventfd does not appear as an MMIO exit at all — KVM signals an eventfd instead, and the VMM thread handles it. The exits you see are the ones not (or not yet) offloaded.


PIO vs MMIO: how the data reaches you

This distinction trips people up because the raw kvm_run shared page lays the two out differently. KVM mmaps a struct kvm_run page per vCPU (size from KVM_GET_VCPU_MMAP_SIZE); on exit, the relevant union member is populated.

PIO (KVM_EXIT_IO)MMIO (KVM_EXIT_MMIO)
Triggered byIN/OUT instructions, 16-bit port spaceloads/stores to a physical address
Data locationa separate buffer at (char*)run + run->io.data_offsetinline in run->mmio.data[8]
Addressrun->io.portrun->mmio.phys_addr
Size/directionrun->io.size, run->io.direction, run->io.countrun->mmio.len, run->mmio.is_write

kvm-ioctls hides this: VcpuExit::IoOut(port, data) already hands you the data slice (resolved from io.data_offset), and VcpuExit::MmioWrite(addr, data) hands you the inline mmio.data. You rarely touch the raw page in Firecracker — but you must know the distinction, because a bug where PIO data is read from the wrong offset, or an MMIO write of the wrong len, manifests as garbage in/out of a device.

# If you need the raw page (rare): the mmap and accessors.
rg -n "kvm_run|get_vcpu_mmap_size|VcpuFd::run|io.data_offset|mmio" src/vmm/src/vstate/vcpu/

Dispatch to the buses

The PIO and MMIO arms each call into a Bus — a sorted map from address range to device — that finds the device owning the address and forwards the access. This is the boundary between the run loop and device emulation.

rg -n "Bus|pio_bus|mmio_bus|fn read\b|fn write\b|BusDevice|insert" src/vmm/src/vstate/vcpu/ src/vmm/src/device_manager/
flowchart TD
    Run["ioctl(KVM_RUN)"] --> Exit{VcpuExit?}
    Exit -->|IoIn/IoOut| PIO["PortIODeviceManager.bus"]
    Exit -->|MmioRead/MmioWrite| MMIO["MMIODeviceManager.bus"]
    Exit -->|Hlt| Idle["end round (guest idle)"]
    Exit -->|Shutdown/SystemEvent| Shut["reset / shutdown"]
    Exit -->|FailEntry/InternalError| Fatal["report fatal, stop"]
    PIO --> Dev1["serial / i8042 / legacy"]
    MMIO --> Dev2["virtio-mmio device registers"]
    Dev1 --> Run
    Dev2 --> Run

The bus lookup, the device's read/write, and any side effect (e.g. the serial device emitting a byte) all happen synchronously, on the vCPU thread, with the guest stopped. The device handler must be quick. Heavy work — actually reading a block from disk, sending a network frame — is not done here; the virtio device only updates ring state and the real I/O is driven later by the VMM thread off an eventfd. This split is the whole performance argument. See the-mmio-bus-and-device-manager.md for the bus internals.


Coordinating with the VMM thread: VcpuEvent / VcpuResponse

A vCPU thread cannot be left to spin forever with no way to control it. The VMM thread holds a VcpuEvent sender per vCPU and receives VcpuResponses; between (or interrupting) KVM_RUN calls, the vCPU thread checks for control events.

rg -n "enum VcpuEvent|enum VcpuResponse|Pause|Resume|Exit|VcpuHandle|send_event" src/vmm/src/vstate/vcpu/
VcpuEventThe vCPU thread doesReplies
PauseExit the run loop into a paused state (used by snapshot, clean shutdown)VcpuResponse::Paused
ResumeRe-enter the KVM_RUN loopVcpuResponse::Resumed
Exit / FinishTear down and end the threadterminal
(state save/restore)Dump/load KVM state for snapshotsstate payload

To make a running vCPU notice a Pause, the VMM thread sends the event and sends the vCPU thread a signal. The signal interrupts the in-flight KVM_RUN ioctl, which returns EINTR; the loop's EINTR arm then checks the VcpuEvent channel and transitions to paused. This is exactly how snapshot create gets every vCPU to a stable, readable state before serializing it (snapshotting.md).

rg -n "EINTR|VCPU_RTSIG_OFFSET|kill|pthread_kill|signal" src/vmm/src/vstate/vcpu/ src/vmm/src/signal_handler.rs

Tip: EINTR on KVM_RUN is normal, not an error — it is the mechanism by which the VMM thread reaches into a running vCPU. Treat it as "check for control events and continue," not "something broke."


Reading exercise

# 1. The loop and the exit match.
rg -n "fn run\b|VcpuExit::|vcpu.*run\(" src/vmm/src/vstate/vcpu/

# 2. The PIO/MMIO bus dispatch.
rg -n "pio_bus|mmio_bus|Bus|\.read\(|\.write\(" src/vmm/src/vstate/vcpu/

# 3. The control channel.
rg -n "enum VcpuEvent|enum VcpuResponse|Pause|Resume" src/vmm/src/vstate/vcpu/

# 4. The EINTR / signal interaction.
rg -n "EINTR|signal|pthread_kill|VCPU_RTSIG" src/vmm/src/vstate/vcpu/ src/vmm/src/signal_handler.rs

# 5. (optional) the raw kvm_run page accessors.
rg -n "kvm_run|get_vcpu_mmap_size|data_offset" src/vmm/src/vstate/vcpu/

# 6. Boot a microVM and watch exits indirectly: a guest that spams the serial console drives IoOut.

Answer:

  1. Write the run loop's match arms from memory and say what each does.
  2. Why is a VM exit handler required to be fast? What stops while it runs?
  3. How do PIO and MMIO differ in where their data lives in kvm_run, and how does kvm-ioctls hide that?
  4. Trace an MMIO write to a virtio device register: KVM_RUN → which bus → what the device does → back.
  5. A virtio kick wired to an ioeventfd does not show up as a VcpuExit. Why, and where is it handled instead?
  6. Explain how Pause + a signal + EINTR combine to stop a running vCPU for a snapshot.

Common bugs and symptoms

SymptomRoot causeWhere to look
Guest hangs, one vCPU pinned at 100%Exit handler loops or blocks; never returns to KVM_RUNthe matching Bus device read/write
KVM_EXIT_FAIL_ENTRY on first runInitial regs/sregs wrong (not long mode)the-boot-sequence.md; set_sregs
Device reads/writes garbageWrong len/offset handling of MMIO/PIO dataexit arm + device read/write width handling
Snapshot create hangsA vCPU never reaches paused; signal/EINTR path brokenVcpuEvent::Pause; signal delivery; EINTR arm
Spurious "vCPU error" logs under loadEINTR treated as fatal instead of "check control events"the Err(EINTR) arm
Guest reset does nothingShutdown/SystemEvent not routed to the reset paththe shutdown arm; signals-shutdown-and-reset.md

Validation: prove you understand this

  1. Draw the run loop and the dispatch fan-out to the PIO/MMIO buses, halt, and shutdown.
  2. List the VcpuExit variants Firecracker handles and the action for each.
  3. Explain the PIO-vs-MMIO data layout in kvm_run and why a width/offset bug corrupts a device.
  4. Explain, with the ioeventfd mechanism, why some virtio activity never appears as a VM exit.
  5. Walk the Pause sequence: how does the VMM thread stop a vCPU that is inside KVM_RUN?
  6. Why is EINTR on KVM_RUN expected behaviour and not an error?

Next: Guest Memory Management — the host memory that backs everything the guest, the loader, and the devices touch.