The virtio-MMIO Transport
Every virtio device in Firecracker — block, net, vsock, balloon, rng — speaks the same wire protocol
to its guest driver: virtio. But virtio is split into two halves. The device-type half
(how a block request is laid out, what a net header contains) is per-device. The transport half —
how the guest finds the device, negotiates features, sets up queues, kicks the device, and receives
interrupts — is shared. Firecracker's default transport is virtio-MMIO: each device is a small
block of memory-mapped registers at a fixed guest physical address plus one interrupt line. There is
no PCI bus, no enumeration, no configuration space probing — the guest is simply told where each
device lives via the kernel command line. This chapter dissects that register block, the
device/driver split, feature negotiation, the status state machine, how a guest's MMIO access becomes
a VM exit that lands on the right device, and how virtio-PCI (behind --enable-pci) differs.
After this chapter you will be able to: read the virtio-MMIO register map and say what each access
does; explain the ACKNOWLEDGE → DRIVER → FEATURES_OK → DRIVER_OK handshake; trace a guest register
write from a KVM_EXIT_MMIO to a specific device's write method; and explain why Firecracker
chose MMIO over PCI.
Note: virtio-MMIO is the transport, not the device. The same
MmioTransportwraps a block device or a net device indifferently; it knows nothing about sectors or Ethernet frames. Keep the transport (this chapter) and the queue mechanics (virtqueues.md) and the device logic (virtio-block.md) as three separate layers in your head.
Where the transport lives
# The MMIO transport wrapper — the object that owns the register block for one device.
rg -n "struct MmioTransport|impl MmioTransport|fn bus_read|fn bus_write" src/vmm/src/devices/virtio/
# The register offset constants (Magic, Version, DeviceID, QueueSel, ...).
rg -n "MMIO_MAGIC_VALUE|0x7472|VENDOR_ID|fn read|fn write" src/vmm/src/devices/virtio/mmio.rs
# How devices get placed on the MMIO bus and assigned an address + IRQ.
rg -n "MMIODeviceManager|register_mmio|allocate|IrqTrigger" src/vmm/src/device_manager/
The transport is one type — call it MmioTransport (verify the name on your branch) — that holds a
reference to the inner VirtioDevice plus the transport-level state: the selected queue, the device
status byte, the negotiated feature bits, and the interrupt status. The
MMIODeviceManager owns a collection of these, each at a
distinct guest physical address, and routes bus accesses to the right one.
The register block
A virtio-MMIO device occupies a fixed-size window (4 KiB is typical) of guest physical address space.
The guest driver reads and writes 32-bit registers at fixed offsets within that window. These offsets
are defined by the virtio specification, not by Firecracker — they are identical across every
compliant VMM, which is why an unmodified Linux virtio_mmio driver works against Firecracker.
| Offset | Register | Dir | Meaning |
|---|---|---|---|
0x000 | MagicValue | R | 0x74726976 = ASCII "virt". The driver's first sanity check. |
0x004 | Version | R | Transport version. 2 = modern (virtio 1.0+); 1 = legacy. |
0x008 | DeviceID | R | The virtio device type: net=1, block=2, rng=4, balloon=5, vsock=19. 0 = no device. |
0x00c | VendorID | R | Vendor identifier. |
0x010 | DeviceFeatures | R | 32-bit window into the device's offered feature bits (selected by DeviceFeaturesSel). |
0x014 | DeviceFeaturesSel | W | Selects which 32-bit word of device features 0x010 exposes (0 = bits 0–31, 1 = bits 32–63). |
0x020 | DriverFeatures | W | The features the driver accepts (windowed by DriverFeaturesSel). |
0x024 | DriverFeaturesSel | W | Selects the driver-features word being written. |
0x030 | QueueSel | W | Selects which virtqueue the queue registers below operate on. |
0x034 | QueueNumMax | R | Max queue size (descriptors) the device supports for the selected queue. |
0x038 | QueueNum | W | The queue size the driver actually chooses. |
0x044 | QueueReady | RW | Write 1 to enable the selected queue; reads back its enabled state. |
0x050 | QueueNotify | W | The kick. Driver writes the queue index here to tell the device "I added buffers." |
0x060 | InterruptStatus | R | Bitmask: which events caused the pending interrupt (used-ring update vs config change). |
0x064 | InterruptACK | W | Driver writes the bits it has handled to clear them. |
0x070 | Status | RW | The device status state machine (see below). Write 0 to reset. |
0x080 | QueueDescLow/High | W | Physical address of the selected queue's descriptor table. |
0x090 | QueueDriverLow/High | W | Physical address of the available ring (driver area). |
0x0a0 | QueueDeviceLow/High | W | Physical address of the used ring (device area). |
0x100+ | device config space | RW | Device-type-specific config (e.g. block capacity, net MAC). |
guest physical address space
┌────────────────────────────────────────┐
│ ... │
│ virtio_mmio.device=0x1000@0xd0000000:5 │ ← cmdline tells the guest: a device,
│ ┌──────────────────────────┐ │ 4 KiB window @ 0xd0000000, IRQ 5
│ │ 0x000 MagicValue 'virt' │ │
│ │ 0x008 DeviceID = 2 (blk) │ │
│ │ 0x030 QueueSel │ │
│ │ 0x050 QueueNotify ◄── kick (write triggers eventfd via KVM_IOEVENTFD)
│ │ 0x060 InterruptStatus │ │
│ │ 0x070 Status (state mach)│ │
│ │ 0x080 QueueDesc addr │ │
│ │ 0x100 config (capacity) │ │
│ └──────────────────────────┘ │
└────────────────────────────────────────┘
Note: The guest is never handed this map. It learns it from the kernel command line on x86 (
virtio_mmio.device=SIZE@ADDR:IRQ, repeated per device) or from an FDT node on aarch64. There is no enumeration step. Find where Firecracker emits those cmdline fragments:rg -n "virtio_mmio.device|add_virtio_mmio_device|cmdline" src/vmm/src/device_manager/ src/vmm/src/arch/
The device/driver split and feature negotiation
virtio is a contract between a device (Firecracker's emulation, the host side) and a driver
(the guest kernel module). The two must agree on a feature set before any I/O happens. The device
advertises a 64-bit feature bitmask via DeviceFeatures; the driver reads it, masks it down to the
bits it understands and wants, and writes the result back via DriverFeatures. The intersection is
the negotiated feature set, frozen at FEATURES_OK.
# What features each device offers — every virtio device implements avail_features().
rg -n "avail_features|fn features|acked_features|VIRTIO_F_|AVAIL_FEATURES" src/vmm/src/devices/virtio/
Feature bits you will meet constantly (the transport-level ones):
| Bit | Name | Meaning |
|---|---|---|
| 32 | VIRTIO_F_VERSION_1 | Modern virtio 1.0 layout. Firecracker always requires this. |
| 29 | VIRTIO_RING_F_EVENT_IDX | Enables interrupt/kick suppression via the used_event/avail_event fields (see virtqueues.md). |
| 28 | VIRTIO_RING_F_INDIRECT_DESC | Lets a descriptor point to a table of further descriptors (longer chains in one slot). |
| 34 | VIRTIO_F_RING_PACKED | The packed-ring layout (Firecracker uses split rings; verify). |
Each device type adds its own bits on top — VIRTIO_NET_F_MAC, VIRTIO_BLK_F_RO, etc. — covered in
the per-device chapters. The negotiation is mandatory and ordered: the driver may not touch a
queue until FEATURES_OK is set and read back successfully.
The status state machine
Status (offset 0x070) is how the driver narrates its progress to the device. The bits are
cumulative — the driver ORs in the next bit and writes the whole byte. The device watches for the
transition and reacts.
rg -n "ACKNOWLEDGE|DRIVER\b|FEATURES_OK|DRIVER_OK|DEVICE_NEEDS_RESET|FAILED|fn set_device_status|fn check_device_status" src/vmm/src/devices/virtio/
flowchart TD
R["reset (Status=0)"] --> A["ACKNOWLEDGE (1): guest sees the device"]
A --> D["DRIVER (2): guest has a driver for it"]
D --> Feat["read DeviceFeatures, write DriverFeatures"]
Feat --> FO["FEATURES_OK (8): driver accepts the feature set"]
FO --> Check{device re-reads Status: is FEATURES_OK still set?}
Check -->|no| Fail["FAILED (128): negotiation rejected"]
Check -->|yes| Q["driver sets up queues: QueueDesc/Driver/Device + QueueReady"]
Q --> DO["DRIVER_OK (4): driver is live — I/O may begin"]
DO -->|device.activate| Live["device starts processing virtqueues"]
The bit that matters most to Firecracker's code is DRIVER_OK. When the driver finally ORs it in
and writes Status, the transport calls the inner device's activate method. That is the
moment the device wires up its eventfds, registers itself with the
EventManager, and becomes ready to process queues. Before DRIVER_OK, the
device is configured but inert; after it, the device is hot. Writing 0 to Status is a reset,
which tears all of that down.
# The activate hook — every device implements this; it is the DRIVER_OK -> live transition.
rg -n "fn activate|fn is_activated|ACTIVATE_EVENT|register_runtime" src/vmm/src/devices/virtio/
From a guest register access to a device method
How does a guest writing to 0x050 become a Rust function call? Through a VM exit. Most virtio-MMIO
registers are serviced as MMIO exits (vcpu-run-loop-and-vm-exits.md):
the guest executes a store to a guest physical address that has no backing RAM, the CPU traps into
KVM, KVM returns from KVM_RUN with exit_reason = KVM_EXIT_MMIO carrying phys_addr, data, len,
and is_write. The vCPU thread looks the address up on the MMIO bus, finds the MmioTransport whose
window contains it, computes the offset, and calls read/write.
rg -n "KVM_EXIT_MMIO|VcpuExit::MmioRead|VcpuExit::MmioWrite|mmio_bus|fn read|fn write" src/vmm/src/vstate/vcpu/ src/vmm/src/device_manager/
sequenceDiagram
participant G as guest driver
participant K as KVM
participant Vc as vCPU thread
participant Bus as MMIO bus
participant Dev as MmioTransport/device
participant Ev as VMM thread (EventManager)
G->>K: store to QueueNotify (0x050)
Note over K: address registered via KVM_IOEVENTFD
K-->>Ev: signal the device's eventfd (no exit!)
Ev->>Dev: EventManager wakes; device.process_queue()
G->>K: store to Status / QueueSel (0x070 / 0x030)
K->>Vc: KVM_RUN returns KVM_EXIT_MMIO
Vc->>Bus: lookup phys_addr
Bus->>Dev: transport.write(offset, data)
There is one crucial optimization on the kick path. QueueNotify writes are the hottest register
access — every batch of I/O ends with one. Routing each through a full MMIO exit and bus lookup would
be slow. Instead Firecracker registers the QueueNotify address with KVM via KVM_IOEVENTFD: a
guest write to that exact address is converted by KVM itself into a signal on an eventfd, with
no VM exit and no vCPU involvement. That eventfd is registered in the VMM thread's
EventManager epoll set. So a kick wakes the VMM thread directly, off the
vCPU's path. Completion interrupts go the other way: the device updates the used ring, then injects
an IRQ via KVM_IRQFD (an eventfd wired to a guest interrupt line). Find both:
rg -n "KVM_IOEVENTFD|register_ioevent|IoEventAddress|KVM_IRQFD|register_irqfd|IrqTrigger|trigger" \
src/vmm/src/device_manager/ src/vmm/src/devices/virtio/
virtio-MMIO vs virtio-PCI
| Aspect | virtio-MMIO (default) | virtio-PCI (--enable-pci) |
|---|---|---|
| Discovery | None — cmdline/FDT tells the guest the address+IRQ | PCI bus enumeration; guest probes config space |
| Per-device cost | One MMIO window + one IRQ | A PCI function, BARs, MSI-X vectors |
| Code surface | Minimal — no PCI host bridge, no config space | A whole PCI transport + host bridge (more attack surface) |
| Interrupts | One legacy IRQ line per device | MSI-X: per-queue vectors, better scaling |
| Guest needs | virtio_mmio driver + cmdline entries | Standard PCI virtio drivers; ACPI |
| Why FC default | Smallest attack surface, fastest boot, no enumeration latency | Higher device counts, per-queue interrupts |
rg -n "enable_pci|virtio.*pci|PciDevice|VirtioPciDevice|MsixConfig" src/vmm/src/devices/ src/firecracker/src/
The minimal-device-model philosophy (minimal-device-model-philosophy.md) explains the default: a PCI host bridge plus config space plus MSI-X is a lot of host code a malicious guest can poke at, and the boot-time enumeration costs milliseconds Firecracker does not want to spend. MMIO is "the device is there, full stop." PCI exists for workloads that need many devices or per-queue interrupt scaling, and it is opt-in.
Warning: virtio-PCI is the newer, larger-surface transport — CVE-2026-5747 was a vulnerability in the PCI transport, fixed in 1.14.4 / 1.15.1 (verify on your branch and the CHANGELOG). The MMIO default is the conservative, hardened path. Treat any PCI-transport change as security-sensitive.
Reading exercise
# 1. The transport wrapper and its read/write dispatch.
rg -n "struct MmioTransport|fn bus_read|fn bus_write|fn read|fn write" src/vmm/src/devices/virtio/mmio.rs
# 2. The register offsets — confirm Magic, QueueNotify@0x050, InterruptStatus@0x060, Status@0x070.
rg -n "0x00|0x50|0x60|0x70|MAGIC|QUEUE_NOTIFY|INTERRUPT_STATUS|STATUS" src/vmm/src/devices/virtio/mmio.rs
# 3. The status state machine and the activate hook.
rg -n "ACKNOWLEDGE|DRIVER_OK|FEATURES_OK|fn activate|fn is_activated" src/vmm/src/devices/virtio/
# 4. Feature negotiation on a real device.
rg -n "avail_features|acked_features|VIRTIO_F_VERSION_1|EVENT_IDX" src/vmm/src/devices/virtio/
# 5. The kick fast path (ioeventfd) and interrupt injection (irqfd).
rg -n "KVM_IOEVENTFD|register_ioevent|KVM_IRQFD|register_irqfd|IrqTrigger" \
src/vmm/src/device_manager/ src/vmm/src/devices/virtio/
# 6. How the device's cmdline address fragment is generated.
rg -n "virtio_mmio.device|add_virtio_mmio_device" src/vmm/src/device_manager/ src/vmm/src/arch/
# 7. On a booted microVM, see what the guest discovered:
# cat /proc/cmdline | tr ' ' '\n' | grep virtio_mmio
# ls /sys/bus/virtio/devices
Answer:
- What are the first three registers a virtio driver reads, and what does each tell it? What value
must
MagicValuehold and why? - Walk the status state machine. At which transition does the device call
activate, and what doesactivateactually do? - Describe feature negotiation: which side writes
DriverFeatures, and what doesFEATURES_OKguarantee? - A guest writes to
QueueNotify(0x050). Trace what happens — and explain why it does not take a VM exit, unlike a write toStatus. - How does a guest find its virtio devices under MMIO when there is no PCI enumeration?
- Give two reasons Firecracker defaults to MMIO over PCI, and one reason a workload might want PCI.
Common bugs and symptoms
| Symptom | Root cause | Where to look |
|---|---|---|
Guest never sees the device (/sys/bus/virtio empty) | DeviceID returns 0, or cmdline/FDT fragment wrong/missing | MmioTransport DeviceID read; cmdline emission in device_manager |
Driver hangs after writing FEATURES_OK | Device cleared FEATURES_OK because the driver acked a bit the device didn't offer | feature negotiation; avail_features/acked_features |
| Device configured but no I/O ever happens | activate never called — DRIVER_OK transition missed/mishandled | Status write handling; fn activate/is_activated |
| Kick has no effect (I/O stalls) | QueueNotify not wired to an ioeventfd, or eventfd not in the epoll set | register_ioevent; EventManager registration in activate |
| Interrupts never delivered (guest spins) | irqfd not registered, or InterruptStatus bits not set before injecting | register_irqfd/IrqTrigger; used-ring update path |
| Wrong device serviced for an MMIO access | Bus address ranges overlap or offset math is wrong | MMIODeviceManager address allocation; bus lookup |
Validation: prove you understand this
- Draw the virtio-MMIO register block from memory with the offsets for
MagicValue,DeviceID,QueueSel,QueueNotify,InterruptStatus,Status, and the queue address registers. - Explain the device/driver split and feature negotiation in one paragraph, naming who writes
DeviceFeaturesvsDriverFeatures. - Walk the full status state machine and say precisely what
activatedoes and when it fires. - Explain why a
QueueNotifywrite is handled byKVM_IOEVENTFDand not as an MMIO exit, and what that buys you. - Contrast virtio-MMIO and virtio-PCI on discovery, interrupt model, and attack surface; justify the MMIO default.
- A guest's virtio-block device appears in
/sys/bus/virtiobut no reads ever complete. List the transport-level checks you would make, in order, to find where the handshake broke.
Next: Virtqueues — the split-ring data structure (descriptor table, available ring, used ring) that every transport drives. The single most important chapter in the device model.