Virtqueues
A virtqueue is the data structure through which a guest driver and a host device exchange buffers. It is the single most important thing to understand in the entire virtio device model — every block read, every network packet, every vsock byte, every balloon page-hint moves across a virtqueue. The MMIO transport tells the guest where a device is and sets up the negotiation; the virtqueue is how the actual I/O flows once the device is live. Get this chapter into your bones and the per-device chapters become easy: each device is just "what does it put in the descriptors, and which direction does data flow."
This chapter covers the split virtqueue: the three rings (descriptor table, available ring, used
ring), descriptor chains and their flags, the kick/interrupt signalling, EVENT_IDX suppression,
how Firecracker's Queue reads all of this out of guest memory via vm-memory, and how split
differs from packed. After it you will be able to: draw the three rings and trace one buffer through
a complete round trip; explain who owns the avail ring vs the used ring; read Firecracker's
Queue::pop / descriptor-iteration code; and reason about why an off-by-one in idx handling is a
data-corruption bug, not a crash.
Note: The descriptor table, available ring, and used ring all live in guest memory — Firecracker does not own them, it reads and writes guest pages. Every access goes through
vm-memorybounds-checked accessors (vm-memory). A virtqueue is a protocol over shared memory, not a Rust data structure the VMM owns. This is why every field read must be validated: the guest is untrusted and may have written garbage.
Where the queue lives
# Firecracker's split-virtqueue implementation.
rg -n "struct Queue\b|impl Queue|fn pop\b|fn add_used|fn is_valid|next_avail|next_used" \
src/vmm/src/devices/virtio/queue.rs
# The descriptor type and its flags.
rg -n "struct Descriptor|VIRTQ_DESC_F_NEXT|VIRTQ_DESC_F_WRITE|VIRTQ_DESC_F_INDIRECT|flags|addr|len|next" \
src/vmm/src/devices/virtio/
# The three guest-memory addresses the transport handed us.
rg -n "desc_table|avail_ring|used_ring|set_desc_table|GuestAddress" src/vmm/src/devices/virtio/queue.rs
A Queue in Firecracker holds the negotiated queue size, the three guest physical base addresses
(descriptor table, available ring, used ring — set by the transport from the QueueDesc/Driver/Device
registers), and two cursors: next_avail (the next available-ring entry the device will consume) and
next_used (the next used-ring slot the device will write). rust-vmm provides a virtio-queue crate
with the same logic (virtio-queue); Firecracker has historically kept
its own in-tree — rg to see which your branch uses.
The three rings
A split virtqueue is three contiguous regions of guest memory, allocated by the driver and pointed at by the queue address registers.
┌─────────────────────────────────────────────────────────────────────────┐
│ Descriptor Table (driver writes, device reads) — the buffer pool │
│ [0] addr=0x1000 len=16 flags=NEXT next=1 ← request header │
│ [1] addr=0x2000 len=512 flags=NEXT|WRITE next=2 ← data (device writes) │
│ [2] addr=0x3000 len=1 flags=WRITE next=0 ← status (device writes)│
│ ... │
├─────────────────────────────────────────────────────────────────────────┤
│ Available Ring (driver → device) │
│ flags idx=N ring[] = [ head-of-chain descriptor indices... ] │
│ ▲ driver bumps idx after publishing a chain │
├─────────────────────────────────────────────────────────────────────────┤
│ Used Ring (device → driver) │
│ flags idx=M ring[] = [ {id=head, len=bytes_written}, ... ] │
│ ▲ device bumps idx after completing a chain │
└─────────────────────────────────────────────────────────────────────────┘
Descriptor table. An array of virtq_desc entries, the shared pool of buffers:
struct virtq_desc {
le64 addr; // guest physical address of this buffer
le32 len; // length in bytes
le16 flags; // NEXT | WRITE | INDIRECT
le16 next; // index of the next descriptor in the chain (if NEXT)
};
| Flag | Value | Meaning |
|---|---|---|
VIRTQ_DESC_F_NEXT | 1 | This descriptor chains to next. Without it, the chain ends here. |
VIRTQ_DESC_F_WRITE | 2 | Device-writable ("write" from the device's perspective). Without it, the buffer is device-readable (driver-filled). |
VIRTQ_DESC_F_INDIRECT | 4 | addr/len point to a table of further descriptors (needs INDIRECT_DESC feature). |
The WRITE flag is the direction bit and the source of endless confusion: WRITE means the
device writes into it (so it is the device→driver / "read this back" half — e.g. the data buffer
of a block read, or the status byte). A descriptor without WRITE is driver-filled and
device-readable (e.g. a request header, or the data of a block write).
Available ring (avail). Driver → device. A flags/idx header plus a ring of descriptor-chain
head indices. The driver builds a chain in the descriptor table, then writes the head index into
ring[avail.idx % qsize] and increments avail.idx. idx is a free-running 16-bit counter — it
wraps, and the device tracks its own next_avail against it. The device knows there is work when
avail.idx != next_avail.
Used ring (used). Device → driver. A flags/idx header plus a ring of virtq_used_elem:
struct virtq_used_elem {
le32 id; // head descriptor index of the completed chain
le32 len; // total bytes the device wrote into the device-writable descriptors
};
When the device finishes a chain, it writes a {id, len} into ring[used.idx % qsize], increments
used.idx, and signals an interrupt. The driver, watching used.idx, reclaims the buffers.
# Confirm the used-elem write and idx bump.
rg -n "fn add_used|virtq_used_elem|used.idx|next_used|fn add_used_elem" src/vmm/src/devices/virtio/queue.rs
A descriptor chain, end to end
A single logical I/O is a descriptor chain: a linked list through next, published as one head
index in the available ring. A virtio-block request, for instance, is a 3-descriptor chain:
flowchart LR
Avail["avail.ring[i] = 0 (chain head)"] --> D0
subgraph chain
D0["desc[0]: header\naddr=hdr, len=16\nflags=NEXT\n(device-readable)"] --> D1["desc[1]: data\naddr=buf, len=512\nflags=NEXT|WRITE\n(device-writable)"]
D1 --> D2["desc[2]: status\naddr=st, len=1\nflags=WRITE\n(device-writable)"]
end
D2 --> Used["used.ring[j] = {id:0, len:513}"]
The full round trip:
- Driver allocates buffers, fills descriptor table entries 0–2, links them with
NEXT, setsWRITEon the ones the device must fill. - Driver writes head index
0intoavail.ring[avail.idx % qsize], bumpsavail.idx. - Driver kicks — writes
QueueNotify. KVM converts that to an ioeventfd signal, waking the VMM thread; no VM exit. - Device (on the VMM thread, via
EventManager) seesavail.idx != next_avail, callsQueue::pop, which walks the chain from the head followingnext, validating everyaddr/lenagainst guest-memory bounds, and hands the device a descriptor iterator. - Device does the work (host
preadinto the writable data buffer, writes the status byte). - Device calls
add_used({id:0, len:513}), bumpsused.idx. - Device injects an interrupt via irqfd and sets
InterruptStatus. - Driver sees
used.idxadvance, reads{id, len}, frees the buffers.
# Firecracker's chain walk + bounds-checked memory access.
rg -n "fn pop\b|fn iter\b|DescriptorChain|checked_offset|read_obj|write_obj|fn is_valid" \
src/vmm/src/devices/virtio/queue.rs
Warning: Steps 4 and 5 read guest-controlled
addr/len/nextvalues. A malicious or buggy guest can setaddroutside guest RAM,lenenormous, ornextinto a loop. Firecracker's queue code must bounds-check every descriptor and bound the chain length, or you have a host out-of-bounds access — a guest→host escape.Queue::is_validand the per-descriptor checks are security-critical code. Read them as such.
The kick and the interrupt
Two signals drive the queue, in opposite directions, and they are deliberately asynchronous and batched:
| Signal | Direction | Mechanism | Register |
|---|---|---|---|
| Kick | driver → device | guest write → KVM_IOEVENTFD → eventfd → VMM thread wakes | QueueNotify (0x050) |
| Interrupt | device → driver | device sets InterruptStatus, fires KVM_IRQFD → guest IRQ | InterruptStatus/InterruptACK (0x060/0x064) |
Batching matters: a driver can publish many chains and kick once; a device can complete many
chains and interrupt once. The idx counters carry the "how many" so neither side needs a signal
per buffer. This is what lets virtio approach line-rate without a VM exit per packet.
EVENT_IDX: suppressing kicks and interrupts
Even batched, signals cost. Under high throughput the device is already polling the avail ring, so
the driver's kick is wasted; symmetrically, the driver is already polling the used ring, so the
device's interrupt is wasted. The VIRTIO_RING_F_EVENT_IDX feature (bit 29) lets each side tell the
other "don't signal me until you reach index N."
- The avail ring gains a trailing
used_eventfield: the device should only interrupt onceused.idxreaches that value. - The used ring gains a trailing
avail_eventfield: the driver should only kick onceavail.idxreaches that value.
rg -n "EVENT_IDX|used_event|avail_event|needs_notification|fn notify|suppress" \
src/vmm/src/devices/virtio/queue.rs
When negotiated, Firecracker's queue consults needs_notification (verify the name) before injecting
an interrupt, and the device can advise the driver how far to let it run before kicking again. The net
effect under load: far fewer interrupts and far fewer wake-ups, because both sides are spinning on
shared memory and only signal when the other side has genuinely gone to sleep.
Note:
EVENT_IDXis a performance feature, not a correctness one — but a bug in the suppression logic shows up as a hang (one side waiting for a signal the other side decided not to send), which is far nastier to debug than a crash. If a device intermittently stalls under load but works at low rate, suspect theEVENT_IDXnotification check.
How Firecracker reads it from guest memory
Everything above is guest memory. Firecracker reaches it through the
vm-memory crate — GuestMemoryMmap + GuestAddress — which gives
bounds-checked typed reads/writes over the host mmaps that back guest RAM.
rg -n "GuestMemoryMmap|GuestAddress|read_obj|write_obj|checked_offset|fn is_valid|MemoryError" \
src/vmm/src/devices/virtio/queue.rs
The pattern, for each field: take the ring base GuestAddress, add a checked offset, and read_obj
/ write_obj a typed value. If the offset is out of bounds, the accessor returns an error rather than
reading host memory — that is the boundary that keeps a hostile descriptor table from becoming a
host OOB read. When you read Queue::pop, watch how every addr, len, and next is validated
before it is trusted.
flowchart LR
Q["Queue (next_avail, next_used, base addrs)"] --> VM["vm-memory: GuestMemoryMmap"]
VM --> Host["host mmap backing guest RAM"]
Q -->|read_obj avail.idx| Check{idx != next_avail?}
Check -->|yes| Pop["pop: walk desc chain, bounds-check each"]
Pop --> Dev["device processes iterator"]
Dev -->|write_obj used elem + bump idx| VM
Split vs packed
| Split ring (Firecracker default) | Packed ring (VIRTIO_F_RING_PACKED, bit 34) | |
|---|---|---|
| Structure | 3 separate areas (desc table + avail + used) | 1 ring with per-descriptor avail/used wrap-counter bits |
| Cache behaviour | 3 regions touched per I/O | 1 region — fewer cache lines, better locality |
| Complexity | Simpler, the classic layout | More complex; phase bits instead of separate idx |
| Firecracker | Used | Verify support on your branch |
Packed rings fold availability and usedness into a single ring using a flip ("wrap counter") bit per
descriptor, so both sides walk one array. It is more cache-efficient at high packet rates but more
intricate. Firecracker has historically used split rings — confirm with the rg for
RING_PACKED/PackedQueue on your checkout before assuming.
Reading exercise
# 1. The Queue struct and its cursors.
rg -n "struct Queue\b|next_avail|next_used|fn is_valid|fn pop\b" src/vmm/src/devices/virtio/queue.rs
# 2. The descriptor type and the three flags.
rg -n "struct Descriptor|VIRTQ_DESC_F_NEXT|VIRTQ_DESC_F_WRITE|VIRTQ_DESC_F_INDIRECT" src/vmm/src/devices/virtio/
# 3. The chain walk and the bounds checks (security-critical).
rg -n "fn pop\b|DescriptorChain|checked_offset|read_obj|next\b|chain" src/vmm/src/devices/virtio/queue.rs
# 4. Completing a chain: the used ring write.
rg -n "fn add_used|virtq_used_elem|used.idx|UsedElement" src/vmm/src/devices/virtio/queue.rs
# 5. EVENT_IDX suppression.
rg -n "EVENT_IDX|used_event|avail_event|needs_notification" src/vmm/src/devices/virtio/queue.rs
# 6. On a booted guest, watch a queue do work:
# cat /proc/interrupts | grep virtio # interrupt counts per device
# (drive I/O, e.g. dd, and watch the counts move)
Answer:
- Name the three rings, who writes each, and what an
idxcounter on a ring means. - Decode the descriptor flags. What does
VIRTQ_DESC_F_WRITEmean — who writes — and why is that the common point of confusion? - Trace one block-read chain through all three rings, from the driver building descriptors to the driver reclaiming buffers. Where does the kick happen and where does the interrupt happen?
- Why must
Queue::popbounds-check every descriptor'saddr/len/next? What is the failure mode if it doesn't? - Explain
EVENT_IDX. Under what load is it a win, and what kind of bug does a mistake in it cause? - Contrast split and packed rings in one sentence each, and state which Firecracker uses.
Common bugs and symptoms
| Symptom | Root cause | Where to look |
|---|---|---|
| Data corruption / wrong bytes in guest | used_elem.len wrong, or device wrote past a descriptor's len, or read a WRITE buffer | add_used len calc; descriptor flag/length handling |
| Host crash / OOB on a malicious guest | descriptor addr/len/next not bounds-checked before use | Queue::pop; vm-memory checked_offset in the chain walk |
| Device processes the same buffer twice | next_avail not advanced, or avail.idx read with wrong wrap | cursor update in pop; idx % qsize arithmetic |
| Infinite loop walking a chain | malicious next forms a cycle; no chain-length bound | chain-length cap in pop/DescriptorChain |
| Guest hangs under load, fine at low rate | EVENT_IDX notification suppressed when it shouldn't be | needs_notification/used_event logic |
| Queue never starts | is_valid rejects the queue (size 0, unaligned/zero addresses) — driver setup or transport bug | Queue::is_valid; the QueueDesc/Driver/Device register writes |
Validation: prove you understand this
- Draw the three rings from memory and label the writer, the reader, and the
idxcounter on each. - Given a descriptor with
flags = NEXT | WRITE, say who writes the buffer, whether the chain continues, and where to look for the next descriptor. - Trace a complete block-read round trip across all three rings and both signals (kick + interrupt), naming the transport register each signal uses.
- Explain, with reference to
vm-memory, why every descriptor field must be validated before use, and name the function in Firecracker that does the walk. - Explain
EVENT_IDXsuppression for both directions and the hang it can cause if mis-implemented. - A reviewer sees a PR that advances
used.idxbefore writing thevirtq_used_elem. Explain the race this creates and why ordering matters.
Next: The virtio Block Device — your first concrete device: a single request queue, the header/data/status chain you just traced, and file-backed host I/O with the Sync vs io_uring engines.