Chapter 11: virtio -- The Paravirtualized Device Model

Emulating a particular hardware device preserves compatibility with its existing driver, including every register and historical behavior that driver expects. Virtio defines a device family for virtual environments instead. The guest uses a virtio driver, places buffer descriptions in shared guest memory, and notifies the device backend after publishing one or more requests. The backend returns completed buffers through the same queue structure.

This is paravirtualization: both sides implement an interface designed for a virtual device. It reduces register traffic and makes batching explicit without removing the normal device concepts of feature negotiation, DMA-like buffer access, interrupts, and transport discovery.

virtio is that contract, standardized. The OASIS virtio Committee Specification v1.2 CS01 defines the shared-memory ring formats, feature negotiation, MMIO, PCI, and channel-I/O transports, and the protocol for each device class. It is the interface that Firecracker, crosvm, Cloud Hypervisor, and QEMU all implement -- which means a Linux guest compiled once can run on any of them without modification, because the driver it loads is the kernel's standard virtio driver, not a VMM-specific one.

The Virtqueue

Every virtio device exposes one or more virtqueues: shared-memory rings through which the driver (the guest's kernel driver) submits work and the device (the VMM backend) returns completions. The virtio v1.2 spec defines two queue formats. The split virtqueue (spec section 2.7) uses three separate memory regions. The packed virtqueue (spec section 2.8), introduced in v1.1 and enabled by feature bit VIRTIO_F_RING_PACKED = 34, collapses those three regions into one circular ring plus two small event-suppression structures. Firecracker implements split virtqueues only; the packed format is not supported.

Three Regions, Two Writers

A split virtqueue consists of three physically independent memory regions. The driver writes the descriptor table and available ring; the device writes the used ring:

Guest Memory

+------------------------------------------------------+
| Descriptor Table  (16 bytes x Queue Size)            | <- Driver writes
| Alignment: 16 bytes                                  |    Device reads
+------------------------------------------------------+
| Available Ring    (4 + 2 x Queue Size, plus event)   | <- Driver writes
| Alignment: 2 bytes                                   |    Device reads
+------------------------------------------------------+
| Used Ring         (4 + 8 x Queue Size, plus event)   | <- Device writes
| Alignment: 4 bytes                                   |    Driver reads
+------------------------------------------------------+

The alignment constants are defined in linux/include/uapi/linux/virtio_ring.h as VRING_DESC_ALIGN_SIZE = 16, VRING_AVAIL_ALIGN_SIZE = 2, and VRING_USED_ALIGN_SIZE = 4. Queue Size must be a power of two, at least 1, and at most 32,768 (0x8000). Firecracker caps every queue at 256 entries (FIRECRACKER_MAX_QUEUE_SIZE = 256).

The ownership rule prevents both sides from updating the same ring metadata, but it does not eliminate concurrency. A driver must finish descriptor and available-ring writes before publishing avail.idx; a device must finish buffer and used-ring writes before publishing used.idx. The specification therefore requires memory barriers around those index updates.

The Descriptor Table

Each entry in the descriptor table is a struct virtq_desc (16 bytes, all fields in little-endian):

struct virtq_desc {
    le64 addr;   /* offset  0: guest-physical buffer address */
    le32 len;    /* offset  8: buffer length in bytes */
    le16 flags;  /* offset 12: control flags */
    le16 next;   /* offset 14: index of next descriptor (if chaining) */
};

Three flag bits control how the descriptor is used. VIRTQ_DESC_F_NEXT = 0x1 means the descriptor is not the last in a chain -- the next field holds the index of the next descriptor. VIRTQ_DESC_F_WRITE = 0x2 marks the buffer as device-writable; without it the buffer is device-readable. VIRTQ_DESC_F_INDIRECT = 0x4 signals that addr and len point not to data but to an in-memory table of further virtq_desc entries, enabled by feature bit VIRTIO_F_RING_INDIRECT_DESC = 28. Within an indirect table, only VIRTQ_DESC_F_WRITE and VIRTQ_DESC_F_NEXT are valid; VIRTQ_DESC_F_INDIRECT is forbidden in indirect entries, and the device must ignore VIRTQ_DESC_F_WRITE on the outer descriptor that points to the table.

Descriptors chain together to describe a single I/O request. A virtio-blk read, for example, uses three descriptors in a chain: a device-readable header (16 bytes: request type, reserved padding, sector number), one or more device-writable data buffers, and a device-writable one-byte status field. All device-readable descriptors precede all device-writable ones in the chain -- this is a hard split-virtqueue rule, not a convention.

The driver builds these chains by filling descriptor table entries, then publishes the chain by placing the head descriptor's index into the available ring.

The Available Ring

The available ring is the driver's outbox. Its layout (from spec section 2.7.6):

struct virtq_avail {
    le16 flags;                   /* VIRTQ_AVAIL_F_NO_INTERRUPT = 1 */
    le16 idx;                     /* where driver will write next head index */
    le16 ring[/* Queue Size */];  /* head indices of published chains */
    le16 used_event;              /* only if VIRTIO_F_EVENT_IDX negotiated */
};

The idx field wraps naturally at 2^16. The driver increments it by the number of chains it publishes, stores the head indices in ring[idx % QueueSize] through ring[(idx + n - 1) % QueueSize], then issues a write memory barrier before notifying the device. The device reads ring[(last_seen_idx % QueueSize)] through ring[(avail.idx - 1) % QueueSize] to collect new chains.

Notice that idx is never reset -- it grows monotonically, modulo 2^16. A device that tracks the last idx it saw can detect new work without any locking; the index is the only synchronization signal.

The Used Ring

The used ring is the device's completion outbox. Its layout (section 2.7.8):

struct virtq_used { le16 flags; /* VIRTQ_USED_F_NO_NOTIFY = 1 */ le16 idx; /* where device will write next */ struct virtq_used_elem ring[/* Queue Size */]; le16 avail_event; /* only if VIRTIO_F_EVENT_IDX */ }; struct virtq_used_elem { le32 id; /* head index of the completed descriptor chain */ le32 len; /* total bytes written by device into writable buffers */ };

Each virtq_used_elem is 8 bytes. When the device finishes a chain, it writes the head index and byte count into the current used slot, increments idx, and -- unless notification suppression says otherwise -- signals the guest interrupt. The driver scans from its last-seen idx to used.idx - 1 to harvest completions.

Notification Suppression

Left to themselves, driver and device fire an interrupt or a doorbell write after every descriptor batch. For high-throughput paths, that overhead adds up. virtio provides two suppression mechanisms.

The coarse mechanism uses the binary flags: the driver sets avail.flags = VIRTQ_AVAIL_F_NO_INTERRUPT to suppress device-to-driver interrupts; the device sets used.flags = VIRTQ_USED_F_NO_NOTIFY to suppress driver-to-device kicks. Either side can assert its flag at any time. The tradeoff is crude -- all notifications or none.

The fine-grained mechanism, enabled by VIRTIO_F_RING_EVENT_IDX = 29, uses used_event and avail_event thresholds. The notification test compares the old and new wrapping indices to determine whether publication crossed the peer's event index; equality alone is not sufficient around 16-bit wrap. Firecracker implements this calculation in prepare_kick() and checks it with Kani proofs.

Firecracker's Queue Implementation

Current Firecracker implements split queues in src/vmm/src/devices/virtio/queue.rs; it does not depend on the rust-vmm virtio-queue crate. Queue::initialize() validates readiness, power-of-two size, the 256-entry device limit, guest-memory bounds, and the 16-, 2-, and 4-byte alignments of the descriptor, available, and used regions.

Ring fields are accessed with volatile reads and writes. An acquire fence follows observation of available work, and a release fence precedes publication of used.idx. A descriptor chain carries a TTL initialized to the queue size, so a cycle cannot make traversal unbounded. The backend can add several used elements, publish the used index once, and call prepare_kick() once for the batch. Kani harnesses cover index arithmetic, notification suppression, queue bounds, and chain construction.

Feature Negotiation

The spec imposes a strict handshake before the device becomes usable. This is the mechanism by which a driver compiled three years ago negotiates with a device model compiled last week: each side publishes what it supports; the intersection is what they use. Neither side assumes the other is current.

The Nine-Step Sequence

Spec section 3.1.1 defines the mandatory initialization sequence. The driver must follow these steps in order:

sequenceDiagram participant D as Guest Driver participant Dev as virtio Device D->>Dev: Write 0 to Status (reset) D->>Dev: Set ACKNOWLEDGE (1) in Status D->>Dev: Set DRIVER (2) in Status D->>Dev: Read DeviceFeatures page 0 (bits 0-31) D->>Dev: Read DeviceFeatures page 1 (bits 32-63) D->>Dev: Write negotiated bits to DriverFeatures D->>Dev: Set FEATURES_OK (8) in Status D->>Dev: Read back Status Dev-->>D: Status returned Note over D: If FEATURES_OK absent -> abort (-ENODEV) D->>Dev: Configure virtqueues (QueueSel, QueueNum, ring GPAs, QueueReady=1) D->>Dev: Set DRIVER_OK (4) in Status Note over Dev: Device is now live

Features are read and written in two 32-bit pages via DeviceFeaturesSel and DriverFeaturesSel: page 0 covers bits 0-31, page 1 covers bits 32-63. This matters in practice because VIRTIO_F_VERSION_1 = 32 sits at bit 0 of page 1. A device presenting itself as modern must advertise this bit; a driver that does not acknowledge it is treated as a legacy driver, and a v2 MMIO device must reject initialization if the driver fails to acknowledge it.

The six device status register bits (from linux/include/uapi/linux/virtio_config.h) are the handshake signals:

Constant Value Meaning
VIRTIO_CONFIG_S_ACKNOWLEDGE 1 Driver found the device
VIRTIO_CONFIG_S_DRIVER 2 Driver knows how to drive it
VIRTIO_CONFIG_S_FEATURES_OK 8 Feature negotiation complete
VIRTIO_CONFIG_S_DRIVER_OK 4 Driver is live
VIRTIO_CONFIG_S_NEEDS_RESET 64 Device needs reset (unrecoverable)
VIRTIO_CONFIG_S_FAILED 128 Fatal error

Status starts at 0. The driver must not clear individual bits; only writing 0 resets the register and the device.

Kernel Implementation

virtio_dev_probe() in drivers/virtio/virtio.c implements steps 2-7: it sets DRIVER, calls virtio_get_features(), ANDs the device and driver feature tables, calls dev->config->finalize_features(), sets FEATURES_OK, and reads back status. If FEATURES_OK is absent, it returns -ENODEV. virtio_device_ready() sets DRIVER_OK after queue setup completes. virtio_features_ok() in drivers/virtio/virtio.c checks that VIRTIO_F_VERSION_1 is in the negotiated set before writing DriverFeatures to a modern device.

The Transport-Layer Feature Bits

Most of these bits live in the range VIRTIO_TRANSPORT_F_START = 28 through VIRTIO_TRANSPORT_F_END = 42 and apply to every device type. VIRTIO_F_ANY_LAYOUT is listed here for completeness -- it predates the formal transport range and sits at bit 27, just outside it.

The Linux kernel uapi headers (virtio_ring.h) name the indirect-descriptor and event-index bits VIRTIO_RING_F_INDIRECT_DESC and VIRTIO_RING_F_EVENT_IDX; the OASIS spec uses VIRTIO_F_RING_INDIRECT_DESC and VIRTIO_F_RING_EVENT_IDX for the same bits (28 and 29). This chapter follows the spec naming.

Constant (OASIS spec) Bit Meaning
VIRTIO_F_ANY_LAYOUT 27 Device handles any descriptor ordering (predates transport range)
VIRTIO_F_RING_INDIRECT_DESC 28 Indirect descriptor tables
VIRTIO_F_RING_EVENT_IDX 29 Descriptor-granularity notification suppression
VIRTIO_F_VERSION_1 32 Modern device (mandatory for modern devices)
VIRTIO_F_ACCESS_PLATFORM 33 IOMMU DMA required
VIRTIO_F_RING_PACKED 34 Packed virtqueue format
VIRTIO_F_IN_ORDER 35 Buffers used in availability order
VIRTIO_F_RING_RESET 40 Per-queue reset

Firecracker advertises VIRTIO_F_VERSION_1 on its modern devices, but feature sets differ by device. Net, virtio-blk, and vsock advertise VIRTIO_F_RING_EVENT_IDX; rng, balloon, pmem, and virtio-mem currently do not. No Firecracker device advertises VIRTIO_F_RING_PACKED.

Config Space Atomicity

Device-specific configuration fields (capacity, MAC address, queue pair count, and so on) live in a config space region that can be updated at any time -- for example, a network link-state change arriving mid-probe. Spec section 2.5 requires the driver to read config_generation, read the needed fields, then read the generation again. If the values differ, the driver retries. The device must return a changed value whenever there is a risk that the driver could observe an inconsistent configuration. The PCI field is only 8 bits, so blindly incrementing on every change is not by itself sufficient across wraparound.

The MMIO Transport

The MMIO transport (spec section 4.2) exposes the device as a flat register window mapped into the guest's physical address space. There is no bus, no enumeration protocol, no capability list -- just a base address and an IRQ number that the VMM communicates to the guest out-of-band.

Register Map

All registers are 4 bytes wide, 4-byte-aligned, at fixed offsets from the base address (from linux/include/uapi/linux/virtio_mmio.h):

Register Offset Dir Purpose
MagicValue 0x000 RO Must read 0x74726976 ("virt" in LE ASCII)
Version 0x004 RO 2 = modern; 1 = legacy
DeviceID 0x008 RO virtio device type
VendorID 0x00c RO Vendor identifier
DeviceFeatures 0x010 RO 32-bit feature page
DeviceFeaturesSel 0x014 WO Feature page selector (0 or 1)
DriverFeatures 0x020 WO Accepted feature bits
DriverFeaturesSel 0x024 WO Driver feature page selector
QueueSel 0x030 WO Select active queue (0-indexed)
QueueNumMax 0x034 RO Maximum queue size
QueueNum 0x038 WO Actual queue size (driver chooses)
QueueReady 0x044 RW Write 1 to activate queue
QueueNotify 0x050 WO Write queue index to kick device
InterruptStatus 0x060 RO Bit 0 = used-buffer; bit 1 = config change
InterruptACK 0x064 WO Acknowledge interrupt bits
Status 0x070 RW Device status register
QueueDescLow 0x080 WO Descriptor Table GPA bits 31:0
QueueDescHigh 0x084 WO Descriptor Table GPA bits 63:32
QueueAvailLow 0x090 WO Available Ring GPA bits 31:0
QueueAvailHigh 0x094 WO Available Ring GPA bits 63:32
QueueUsedLow 0x0a0 WO Used Ring GPA bits 31:0
QueueUsedHigh 0x0a4 WO Used Ring GPA bits 63:32
ConfigGeneration 0x0fc RO Config space atomicity counter
Config 0x100+ RW Device-specific config (up to 0xfff)

The legacy (Version 1) layout adds GuestPageSize at 0x028, QueueAlign at 0x03c, and QueuePFN at 0x040, and collapses the split 64-bit address pairs into a single page-frame number. Modern drivers do not touch these.

Device Discovery

MMIO has no self-describing discovery mechanism (spec section 4.2.1). The guest must learn each device's base address and IRQ from the VMM. Linux's drivers/virtio/virtio_mmio.c driver supports three paths: a device tree node with compatible = "virtio,mmio", a kernel command-line parameter virtio_mmio.device=<size>@<baseaddr>:<irq>[:<id>] (requires CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES), and static platform device registration in board code.

Firecracker has used more than one discovery path. Older x86_64 guests learned about virtio-MMIO devices through one virtio_mmio.device=... kernel-command-line entry per device. Current x86_64 Firecracker uses minimal ACPI tables to enumerate virtio-MMIO devices and their interrupts; aarch64 guests learn the same information from the device tree. The transport is still virtio-MMIO in all three cases. Only the discovery side channel changes.

Firecracker's MMIO Backend

Firecracker's MMIO transport is implemented in src/vmm/src/devices/virtio/transport/mmio.rs as MmioTransport, which implements the BusDevice trait. Configuration-register accesses are dispatched through BusDevice::read and BusDevice::write. Queue notifications are registered as ioeventfds, so an ordinary write to QueueNotify signals the device eventfd without returning that write through the vCPU exit dispatcher.

MMIO_VERSION = 2 is hardcoded, so the device presents the modern register layout. VENDOR_ID = 0 is a known mismatch with the specification's requirement for a nonzero vendor value. Reading DeviceFeatures with DeviceFeaturesSel = 1 ORs in 0x1 unconditionally, so VIRTIO_F_VERSION_1 (bit 32) is always visible to the driver regardless of what the inner device model advertises. set_device_status() enforces the spec state machine with a VALID_TRANSITIONS table; any status write that is not a legal transition logs a warning. Transition to DRIVER_OK calls locked_device().activate(), which hands control to the device backend -- at that point, the virtqueues are live and the device can begin processing descriptors.

The guest kernel requires CONFIG_VIRTIO_MMIO=y. On x86_64, CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES is required for legacy command-line discovery but may be disabled when ACPI supplies the device descriptions. Aarch64 uses the device tree.

The PCI Transport

The PCI transport (spec section 4.1) is discoverable through the PCI bus. The guest scans functions, matches virtio IDs, and walks vendor-specific capabilities that locate the common configuration, notification, ISR, and device-specific regions. The transport also defines an optional PCI-configuration access capability; a device need not expose all five capability types.

Device IDs

PCI device IDs split into two ranges. Legacy (transitional) devices use IDs 0x1000-0x103F; modern devices use 0x1040 + the virtio device ID, so virtio-net is 0x1041, virtio-blk is 0x1042, virtio-rng is 0x1044, and virtio-vsock is 0x1053.

Core PCI Capabilities

Each capability uses cap_vndr = PCI_CAP_ID_VNDR and a cfg_type field. The five original configuration types in linux/include/uapi/linux/virtio_pci.h are:

cfg_type Value Purpose
VIRTIO_PCI_CAP_COMMON_CFG 1 Common configuration struct (virtio_pci_common_cfg)
VIRTIO_PCI_CAP_NOTIFY_CFG 2 Queue doorbell addresses
VIRTIO_PCI_CAP_ISR_CFG 3 Interrupt status byte
VIRTIO_PCI_CAP_DEVICE_CFG 4 Device-specific configuration
VIRTIO_PCI_CAP_PCI_CFG 5 Alternative PCI config-space access window

The v1.2 specification also defines capabilities for shared-memory regions and vendor data. Required capabilities depend on the device and negotiated facilities; the PCI configuration access window is optional.

struct virtio_pci_cap records which BAR holds the region (bar, 0-5), the byte offset within that BAR (offset), and the region's length (length). The notification capability also carries notify_off_multiplier; the doorbell address for queue N is cap.offset + queue_notify_off x notify_off_multiplier.

struct virtio_pci_common_cfg exposes the feature selectors and data fields, the queue count, the device status register, config_generation, the queue selector and size, queue_enable, and the split 64-bit GPA fields queue_desc_lo/hi, queue_avail_lo/hi, and queue_used_lo/hi -- a superset of the MMIO register map, accessed through a memory-mapped struct rather than individual register offsets.

Firecracker's Transport Choice

Firecracker began with virtio-MMIO because it required less machine-model code. Current Firecracker optionally creates all virtio devices behind PCI when started with --enable-pci; without the flag it uses MMIO. The current getting-started and kernel-policy documents recommend PCI for higher throughput and lower latency. MSI-X gives queues separate message-signaled interrupt vectors, while MMIO devices share level-triggered interrupt status and acknowledgement registers. Workload and guest configuration still determine the realized difference, so a development-branch benchmark is not a portable transport guarantee.

Enabling PCI mode requires additional guest kernel configuration: CONFIG_PCI, CONFIG_PCI_MMCONFIG, CONFIG_PCI_MSI, CONFIG_PCIEPORTBUS, CONFIG_VIRTIO_PCI, CONFIG_BLK_MQ_PCI, CONFIG_PCI_HOST_COMMON, and CONFIG_PCI_HOST_GENERIC. The guest must not pass pci=off on its command line.

Selected Device Protocols

The following five devices show the recurring protocol shapes: packet queues, request/response storage, connection-oriented messages, memory reporting, and a device-writable byte stream. Current Firecracker also implements virtio-pmem and virtio-mem, plus a vhost-user backend option for virtio-blk; those additions do not change the queue and transport mechanics developed here.

virtio-net (Device ID 1)

The network device presents the guest with an Ethernet interface. At the virtio layer, the useful facts are the descriptor shape and the virtio_net_hdr_v1 header that travels with each frame. The host-side TAP setup belongs to Chapter 21, because it is host networking rather than virtio protocol.

The virtio-net header. Every frame crossing the TAP/virtqueue boundary carries a virtio_net_hdr_v1 (12 bytes) that describes the offload state of the packet (defined in linux/include/uapi/linux/virtio_net.h):

Offset Field Notes
0 flags VIRTIO_NET_HDR_F_NEEDS_CSUM = 1
1 gso_type NONE=0, TCPV4=1, UDP=3, TCPV6=4, ECN flag=0x80
2-3 hdr_len Total L2+L3+L4 header length
4-5 gso_size Desired MSS for segmentation
6-7 csum_start Byte offset where checksum computation begins
8-9 csum_offset Offset from csum_start to place the checksum
10-11 num_buffers Merged receive buffer count (if VIRTIO_NET_F_MRG_RXBUF)

Queues. Firecracker implements exactly two virtqueues: RX_INDEX = 0 and TX_INDEX = 1, each capped at NET_QUEUE_MAX_SIZE = 256 descriptors. The spec allows a multi-queue extension (VIRTIO_NET_F_MQ = 22) with one transmit and one receive queue per CPU, but Firecracker does not implement it; each virtio-net device has a single queue pair. MAX_BUFFER_SIZE = 65562 bytes (64 KiB plus the virtio-net header overhead) is the largest receive buffer the device will accept.

Feature bits Firecracker advertises. From linux/include/uapi/linux/virtio_net.h, Firecracker sets: VIRTIO_NET_F_CSUM (0), VIRTIO_NET_F_GUEST_CSUM (1), VIRTIO_NET_F_GUEST_TSO4 (7), VIRTIO_NET_F_GUEST_TSO6 (8), VIRTIO_NET_F_GUEST_UFO (10), VIRTIO_NET_F_HOST_TSO4 (11), VIRTIO_NET_F_HOST_TSO6 (12), VIRTIO_NET_F_HOST_UFO (14), VIRTIO_NET_F_MRG_RXBUF (15), plus VIRTIO_F_RING_EVENT_IDX (29) and VIRTIO_F_VERSION_1 (32). VIRTIO_NET_F_MAC (5) is added when a MAC address is configured; VIRTIO_NET_F_MTU (3) when an MTU override is set.

The full specification's virtio_net_config has later fields for status, queue pairs, speed, and duplex. Firecracker exposes a 12-byte prefix: MAC, zero-filled status and max_virtqueue_pairs slots, and MTU. It advertises only the MAC and MTU fields when configured.

virtio-blk (Device ID 2)

The block device exposes a single virtqueue to the guest. Firecracker implements BLOCK_NUM_QUEUES = 1, sized to 256 descriptors. IO_URING_NUM_ENTRIES = 128 (half the queue depth) because one block request typically spans two to three descriptors; a full 256-entry submission ring would overflow an io_uring ring of the same size.

The sector model. SECTOR_SIZE = 512 bytes (1 << 9). The capacity field in the config struct is a u64 reporting the total sector count. Sector N begins at byte offset N x 512 from the start of the backing file or device.

Request layout. Each I/O request is a three-descriptor chain:

  1. A 16-byte device-readable header: type (u32), reserved (u32), sector (u64).
  2. One or more data buffers -- device-readable for writes, device-writable for reads.
  3. A one-byte device-writable status field: VIRTIO_BLK_S_OK = 0, VIRTIO_BLK_S_IOERR = 1, or VIRTIO_BLK_S_UNSUPP = 2.

The type field in the header selects the operation: VIRTIO_BLK_T_IN = 0 (read), VIRTIO_BLK_T_OUT = 1 (write), VIRTIO_BLK_T_FLUSH = 4 (cache flush), VIRTIO_BLK_T_GET_ID = 8 (identify device: returns a 20-byte ASCII string). All defined in linux/include/uapi/linux/virtio_blk.h.

Feature bits Firecracker advertises. VIRTIO_F_VERSION_1 (32) and VIRTIO_F_RING_EVENT_IDX (29) always. VIRTIO_BLK_F_FLUSH (9) when the backing disk is in writeback-cache mode. VIRTIO_BLK_F_RO (5) when the disk is read-only.

virtio-vsock (Device ID 19)

vsock gives the guest and host a socket channel without a network interface. The guest opens a socket with socket(AF_VSOCK, SOCK_STREAM, 0) and addresses the host by its well-known CID. Firecracker translates the guest virtio-vsock protocol to a host Unix-domain socket backend.

Each endpoint is addressed by a (CID, port) pair. Reserved CIDs: VMADDR_CID_HYPERVISOR = 0, VMADDR_CID_LOCAL = 1, VMADDR_CID_HOST = 2, VMADDR_CID_ANY = 0xFFFFFFFF. Firecracker sets VSOCK_HOST_CID = 2 for the host-side endpoint.

Queues. Firecracker implements three queues (VSOCK_NUM_QUEUES = 3): RXQ (index 0) for data from host to guest, TXQ (index 1) for data from guest to host, and EVQ (index 2) for event messages. All three are sized to 256 descriptors. Each descriptor chain encodes exactly one vsock packet: a 44-byte header followed by an optional payload up to MAX_PKT_BUF_SIZE = 65536 bytes.

The header. virtio_vsock_hdr is 44 bytes, packed:

Offset Field Type Notes
0-7 src_cid le64 Source context ID
8-15 dst_cid le64 Destination context ID
16-19 src_port le32 Source port
20-23 dst_port le32 Destination port
24-27 len le32 Payload byte count
28-29 type le16 Socket type (1=STREAM, 2=SEQPACKET)
30-31 op le16 Operation code
32-35 flags le32 Operation-specific flags
36-39 buf_alloc le32 Receiver buffer allocation (flow control)
40-43 fwd_cnt le32 Bytes consumed by receiver (flow control)

The op field drives the connection state machine. VIRTIO_VSOCK_OP_REQUEST = 1 initiates a connection; VIRTIO_VSOCK_OP_RESPONSE = 2 accepts it; VIRTIO_VSOCK_OP_RST = 3 rejects or aborts; VIRTIO_VSOCK_OP_SHUTDOWN = 4 begins a graceful close; VIRTIO_VSOCK_OP_RW = 5 carries data; VIRTIO_VSOCK_OP_CREDIT_UPDATE = 6 and VIRTIO_VSOCK_OP_CREDIT_REQUEST = 7 implement receive-window flow control through buf_alloc and fwd_cnt in the header -- the receiver advertises available buffer space, and the sender tracks how much of it it has consumed.

Feature bits. Firecracker advertises VIRTIO_F_VERSION_1 (32), VIRTIO_F_IN_ORDER (35), and VIRTIO_F_RING_EVENT_IDX (29), combined as AVAIL_FEATURES = (1 << 32) | (1 << 35) | (1 << 29). The device-specific feature VIRTIO_VSOCK_F_SEQPACKET = 1 (SOCK_SEQPACKET support) is not advertised.

virtio-balloon (Device ID 5)

The balloon device lets a cooperating guest return memory to the host without stopping. Firecracker writes a target page count into num_pages; the guest driver allocates pages into the balloon and reports their PFNs on the inflate queue. Firecracker validates those ranges and discards their host mappings, which on its anonymous private guest memory releases resident pages. A later access faults in a zero-filled page. Deflation returns pages to the guest allocator; Firecracker only acknowledges that queue because the earlier discard was a one-time operation.

This is cooperative reclamation, not an enforceable memory limit. A compromised or nonresponsive guest driver can ignore the target or report misleading statistics, so the host must remain able to accommodate the VM's configured memory.

Config struct. virtio_balloon_config (from linux/include/uapi/linux/virtio_balloon.h):

Field Type Meaning
num_pages le32 Host-requested balloon size in 4 KiB pages
actual le32 Current balloon size in 4 KiB pages
free_page_hint_cmd_id le32 Command ID for free page hinting

All balloon accounting is in 4 KiB pages; 256 pages equals 1 MiB. The host sets num_pages; the guest updates actual as it completes inflation or deflation.

Queues. The inflate queue (index 0) and deflate queue (index 1) are always present, with Firecracker's current 256-entry queue size. Optional queues are appended in configuration order: statistics when polling is enabled, free-page hinting when requested, and free-page reporting when requested. Their numeric indices therefore depend on which earlier options are present.

Feature bits Firecracker advertises. VIRTIO_F_VERSION_1 (32) is always present. VIRTIO_BALLOON_F_DEFLATE_ON_OOM (2), VIRTIO_BALLOON_F_STATS_VQ (1), VIRTIO_BALLOON_F_FREE_PAGE_HINT (3), and VIRTIO_BALLOON_F_REPORTING (5) are advertised only when their corresponding Firecracker options are enabled. DEFLATE_ON_OOM lets the guest kernel reclaim balloon pages when a guest memory allocation would otherwise enter its OOM path; it is unrelated to the host OOM killer.

Statistics. The stats queue carries packed 10-byte entries with a u16 tag and a u64 value. The defined tags include swap-in and swap-out counts (VIRTIO_BALLOON_S_SWAP_IN = 0, VIRTIO_BALLOON_S_SWAP_OUT = 1), page fault counts (VIRTIO_BALLOON_S_MAJFLT = 2, VIRTIO_BALLOON_S_MINFLT = 3), free and total memory (VIRTIO_BALLOON_S_MEMFREE = 4, VIRTIO_BALLOON_S_MEMTOT = 5), available memory (VIRTIO_BALLOON_S_AVAIL = 6), page cache size (VIRTIO_BALLOON_S_CACHES = 7), huge-page allocation results (8 and 9), and, with the guest support Firecracker documents for Linux 6.12 and later, OOM, allocation-stall, scan, and reclaim counters (10 through 15). The polling interval is configurable in Firecracker; setting it to 0 disables polling. The guest kernel requires CONFIG_MEMORY_BALLOON=y and CONFIG_VIRTIO_BALLOON=y.

virtio-rng (Device ID 4)

The entropy device is the simplest virtio device in the spec: linux/include/uapi/linux/virtio_rng.h contains no device-specific feature bits -- it includes only virtio_ids.h and virtio_config.h. There is no device-specific config space. The entire protocol fits in a paragraph.

Firecracker exposes one queue (RNG_NUM_QUEUES = 1) and advertises only VIRTIO_F_VERSION_1 (32). The queue direction is device-writable only: the guest posts write-only descriptors pointing to buffers it wants filled with entropy, and the device fills them. The guest never sends data to the device.

MAX_ENTROPY_BYTES = 65536 (64 KiB) bounds the host allocation for one request, including a malicious chain whose overlapping descriptors inflate its apparent length. Firecracker draws entropy from aws_lc_rs::rand (the AWS LibCrypto Rust bindings), not from /dev/random or getrandom() directly. Rate limiting is available via the Firecracker API, with independent controls for bytes-per-second and operations-per-second.

The simplicity is the point. A random number device has no protocol state, no connection setup, no flow control, and no error conditions beyond buffer exhaustion. It is what virtio looks like when nothing is left to remove.

Wiring It Together

flowchart TB
    gk["Guest Kernel Driver"]
    vq["Virtqueue<br/>(desc / avail / used rings)"]
    mmio["virtio-MMIO or PCI Transport"]
    vmm["VMM Device Backend<br/>(Firecracker)"]
    tap["TAP /dev/net/tun"]
    blkfile["Block backing file"]
    vsockunix["Unix socket<br/>(vsock muxer)"]
    awslc["aws_lc_rs::rand"]

    gk -->|"write head idx to avail.ring,\nkick QueueNotify"| vq
    vq -->|"queue notification via ioeventfd"| mmio
    mmio -->|"wake device event handler"| vmm
    vmm -->|"net: read/write virtio_net_hdr + frame"| tap
    vmm -->|"blk: read/write 512-byte sectors"| blkfile
    vmm -->|"vsock: virtio_vsock_hdr + payload"| vsockunix
    vmm -->|"rng: fill entropy bytes"| awslc
    vmm -->|"write id+len to used.ring,\ninterrupt guest"| vq
    vq -->|"driver polls used.idx"| gk

Sources And Further Reading