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-memory bounds-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)
};
FlagValueMeaning
VIRTQ_DESC_F_NEXT1This descriptor chains to next. Without it, the chain ends here.
VIRTQ_DESC_F_WRITE2Device-writable ("write" from the device's perspective). Without it, the buffer is device-readable (driver-filled).
VIRTQ_DESC_F_INDIRECT4addr/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:

  1. Driver allocates buffers, fills descriptor table entries 0–2, links them with NEXT, sets WRITE on the ones the device must fill.
  2. Driver writes head index 0 into avail.ring[avail.idx % qsize], bumps avail.idx.
  3. Driver kicks — writes QueueNotify. KVM converts that to an ioeventfd signal, waking the VMM thread; no VM exit.
  4. Device (on the VMM thread, via EventManager) sees avail.idx != next_avail, calls Queue::pop, which walks the chain from the head following next, validating every addr/len against guest-memory bounds, and hands the device a descriptor iterator.
  5. Device does the work (host pread into the writable data buffer, writes the status byte).
  6. Device calls add_used({id:0, len:513}), bumps used.idx.
  7. Device injects an interrupt via irqfd and sets InterruptStatus.
  8. Driver sees used.idx advance, 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/next values. A malicious or buggy guest can set addr outside guest RAM, len enormous, or next into 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_valid and 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:

SignalDirectionMechanismRegister
Kickdriver → deviceguest write → KVM_IOEVENTFD → eventfd → VMM thread wakesQueueNotify (0x050)
Interruptdevice → driverdevice sets InterruptStatus, fires KVM_IRQFD → guest IRQInterruptStatus/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_event field: the device should only interrupt once used.idx reaches that value.
  • The used ring gains a trailing avail_event field: the driver should only kick once avail.idx reaches 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_IDX is 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 the EVENT_IDX notification 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)
Structure3 separate areas (desc table + avail + used)1 ring with per-descriptor avail/used wrap-counter bits
Cache behaviour3 regions touched per I/O1 region — fewer cache lines, better locality
ComplexitySimpler, the classic layoutMore complex; phase bits instead of separate idx
FirecrackerUsedVerify 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:

  1. Name the three rings, who writes each, and what an idx counter on a ring means.
  2. Decode the descriptor flags. What does VIRTQ_DESC_F_WRITE mean — who writes — and why is that the common point of confusion?
  3. 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?
  4. Why must Queue::pop bounds-check every descriptor's addr/len/next? What is the failure mode if it doesn't?
  5. Explain EVENT_IDX. Under what load is it a win, and what kind of bug does a mistake in it cause?
  6. Contrast split and packed rings in one sentence each, and state which Firecracker uses.

Common bugs and symptoms

SymptomRoot causeWhere to look
Data corruption / wrong bytes in guestused_elem.len wrong, or device wrote past a descriptor's len, or read a WRITE bufferadd_used len calc; descriptor flag/length handling
Host crash / OOB on a malicious guestdescriptor addr/len/next not bounds-checked before useQueue::pop; vm-memory checked_offset in the chain walk
Device processes the same buffer twicenext_avail not advanced, or avail.idx read with wrong wrapcursor update in pop; idx % qsize arithmetic
Infinite loop walking a chainmalicious next forms a cycle; no chain-length boundchain-length cap in pop/DescriptorChain
Guest hangs under load, fine at low rateEVENT_IDX notification suppressed when it shouldn't beneeds_notification/used_event logic
Queue never startsis_valid rejects the queue (size 0, unaligned/zero addresses) — driver setup or transport bugQueue::is_valid; the QueueDesc/Driver/Device register writes

Validation: prove you understand this

  1. Draw the three rings from memory and label the writer, the reader, and the idx counter on each.
  2. Given a descriptor with flags = NEXT | WRITE, say who writes the buffer, whether the chain continues, and where to look for the next descriptor.
  3. Trace a complete block-read round trip across all three rings and both signals (kick + interrupt), naming the transport register each signal uses.
  4. 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.
  5. Explain EVENT_IDX suppression for both directions and the hang it can cause if mis-implemented.
  6. A reviewer sees a PR that advances used.idx before writing the virtq_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.