virtio Device Model
virtio is the standardized paravirtual device interface that lets a guest operating system stop pretending it is talking to real hardware. Instead of emulating a physical network card or disk controller register-by-register — every register poke forcing a costly trap into the hypervisor (see VM Exit Reasons and Handling) — virtio defines a cooperative contract: the guest loads a driver that knows it is virtualized and talks to the host through an efficient shared-memory transport. The model is split into three orthogonal pieces that this note unpacks: a device-type taxonomy (net, block, console, …), a transport that carries configuration and notifications (virtio-pci, virtio-mmio, virtio-ccw), and a handshake — the device-status byte plus feature-bit negotiation — that the driver and device walk through at boot to agree on a common protocol. The interface is governed by an open standard, the OASIS Virtual I/O Device (VIRTIO) Version 1.3 specification, edited by Michael S. Tsirkin and Cornelia Huck and released as a Committee Specification Draft on 06 October 2023 (OASIS announcement, 2023). The actual data plane — how buffers cross the boundary — is the virtqueue; this note covers everything around it.
Uncertain
Verify: that 1.3 is still only a Committee Specification Draft 01 (CSD01, Oct 2023) and has not been promoted to a final OASIS Committee Specification or OASIS Standard as of mid-2026. Reason: OASIS document tracks (CSD → CS → OS) advance over time and the index page (docs.oasis-open.org/virtio/virtio/v1.3/) may have been updated since this note was written. To resolve: re-check the OASIS virtio TC document index for a
virtio-v1.3-cs01or-osartifact. The technical content cited here is stable regardless of track. uncertain
Mental Model
The cleanest way to think about virtio is as three loosely-coupled layers stacked on the guest side, each replaceable without disturbing the others. At the bottom is a transport whose only job is to expose a handful of configuration registers and a way to ring a doorbell — over a virtual PCI device, a flat memory-mapped register block, or an IBM mainframe channel. In the middle is the virtio core: the bus, the status-byte state machine, and feature negotiation, all transport-agnostic. At the top is a device driver (virtio-net, virtio-blk, …) that knows the semantics of one device type. The genius of the design is that the driver author never touches the transport; the same virtio-net driver runs identically whether the host presented it over PCI in a QEMU x86 guest or over channel I/O on an s390x mainframe.
flowchart TB subgraph DRV["virtio device drivers (one per device type)"] NET["virtio-net"] BLK["virtio-blk"] CON["virtio-console"] OTHER["...30+ types"] end subgraph CORE["virtio core (drivers/virtio/virtio.c)"] BUS["virtio bus<br/>match + probe"] STAT["device-status state machine<br/>ACK -> DRIVER -> FEATURES_OK -> DRIVER_OK"] FEAT["feature negotiation<br/>device_features AND driver_features"] end subgraph TRANS["transports (config + notify)"] PCI["virtio-pci<br/>(PCI capabilities)"] MMIO["virtio-mmio<br/>(register block)"] CCW["virtio-ccw<br/>(channel I/O, s390)"] end DRV --> CORE CORE -->|"config_ops"| TRANS TRANS -->|"shared-memory ring"| VQ["virtqueue (the data plane)"] DRV -.->|"add/get buffers"| VQ
How the virtio stack layers. What it shows: the device driver sits on top of a transport-neutral core, and the core reaches the hardware-ish transport only through a function-pointer table (config_ops). The insight to take: virtio is deliberately factored so the protocol (feature bits, status byte, virtqueues) is decoupled from the plumbing (PCI vs MMIO vs CCW). The cost-elimination payoff — avoiding a VM exit per register access — comes from the data plane below, not from this control plane.
Why Paravirtualization Beats Full Emulation
To understand virtio you have to understand the problem it kills. A fully emulated device — say, an Intel e1000 NIC — is a faithful software model of a real chip. When the guest driver writes to the e1000’s “transmit descriptor tail” register to say “I have a packet to send,” that write is to a memory-mapped I/O (MMIO) address the hypervisor has trapped. The CPU takes a VM exit: it stops running the guest, saves the entire guest CPU state, and transfers control to the host hypervisor (KVM, then often up to the userspace VMM) to decode the instruction, figure out which register was touched, run the emulated chip’s logic, and resume the guest. For a single packet on a busy 10-gigabit link this can mean millions of exits per second, each costing on the order of thousands of CPU cycles. Emulation buys perfect compatibility — the guest needs no special driver, just the stock e1000 driver it would use on bare metal — at a catastrophic throughput cost.
virtio flips the contract. The guest admits it is virtualized and loads a driver written specifically for the virtual device. That driver does not poke a register per packet; it appends a batch of I/O descriptors to a lock-free ring in shared memory (the virtqueue) and rings a single doorbell only when it must — and even that doorbell can be suppressed by the other side (see virtio Notifications and Virtqueue Kicks). The result is the single most important throughput technique in the whole KVM stack, and the foundation that vhost later accelerates by moving the device backend into the host kernel.
This is paravirtualization: the guest is modified (with a cooperative driver) rather than perfectly deceived. The original design and rationale come from Rusty Russell’s 2008 paper virtio: towards a de-facto standard for virtual I/O devices; the copyright notice in the kernel’s UAPI ring header still reads “Copyright Rusty Russell IBM Corporation 2007” (virtio_ring.h, v6.12).
The Device-Status Byte: The Initialization State Machine
Every virtio device exposes an 8-bit device-status register. The guest writes bits into it to announce its progress through a strictly-ordered boot handshake; the device reads them to know when it is allowed to start working. The bit values are defined in the kernel UAPI (virtio_config.h, v6.12):
#define VIRTIO_CONFIG_S_ACKNOWLEDGE 1 /* guest found the device, recognized it as virtio */
#define VIRTIO_CONFIG_S_DRIVER 2 /* guest has a driver that can drive it */
#define VIRTIO_CONFIG_S_DRIVER_OK 4 /* driver is set up and ready to go */
#define VIRTIO_CONFIG_S_FEATURES_OK 8 /* driver has finished feature negotiation */
#define VIRTIO_CONFIG_S_NEEDS_RESET 0x40 /* device hit an unrecoverable error */
#define VIRTIO_CONFIG_S_FAILED 0x80 /* guest gave up on the device */The OASIS spec spells out the contract: “The device status field starts out as 0, and is reinitialized to 0 by the device during reset… The driver MUST update device status, setting bits to indicate the completed steps of the driver initialization sequence. The driver MUST NOT clear a device status bit” (virtio 1.3 §2.1). The bits are cumulative — the driver ORs new bits in, never clears them, and the only way back to zero is a full device reset.
The canonical ordering is: reset → ACKNOWLEDGE → DRIVER → (read & negotiate features) → FEATURES_OK → (re-read FEATURES_OK to confirm the device accepted them) → set up virtqueues → DRIVER_OK. The crucial subtlety is the FEATURES_OK round-trip: after the driver sets FEATURES_OK, it must read the status back; if the device has cleared the bit, the device rejects the chosen feature subset and the driver must treat the device as unusable. DEVICE_NEEDS_RESET and FAILED are the two error exits — the former set by the device to signal it has wedged, the latter set by the driver to signal surrender.
You can watch this exact sequence in the Linux virtio core. register_virtio_device() resets the device and sets ACKNOWLEDGE, then virtio_dev_probe() (called when the bus finds a matching driver) walks the rest (virtio.c, v6.12):
/* register_virtio_device(): */
virtio_reset_device(dev); /* status -> 0 */
virtio_add_status(dev, VIRTIO_CONFIG_S_ACKNOWLEDGE); /* "I see a virtio device" */
/* later, in virtio_dev_probe() once a driver matches: */
virtio_add_status(dev, VIRTIO_CONFIG_S_DRIVER); /* "I have a driver for it" */
device_features = dev->config->get_features(dev); /* read what the device offers */
/* ... compute dev->features = driver_features & device_features ... */
err = dev->config->finalize_features(dev); /* push driver_features back */
/* ... */
err = virtio_features_ok(dev); /* sets FEATURES_OK, re-reads to confirm */
err = drv->probe(dev); /* device-specific setup, allocates vqs */
if (!(dev->config->get_status(dev) & VIRTIO_CONFIG_S_DRIVER_OK))
virtio_device_ready(dev); /* sets DRIVER_OK */virtio_add_status() is a literal read-modify-write of the byte through the transport’s set_status op: dev->config->set_status(dev, dev->config->get_status(dev) | status). If anything in the chain fails, the err: path runs virtio_add_status(dev, VIRTIO_CONFIG_S_FAILED) — the kernel sets the FAILED bit exactly as the spec demands.
Feature Negotiation: device_features AND driver_features
virtio is extensible without versioning. Rather than bumping a protocol version, each capability is a feature bit, and the driver and device negotiate the intersection of what each supports. The mechanism has two halves: the device advertises a 64-bit (conceptually 128-bit, read in 32-bit windows) device_features bitmap of everything it can do; the driver writes back a driver_features bitmap of the subset it wants and understands. The negotiated set is the bitwise AND.
The hard rules from the spec (virtio 1.3 §2.2): “The driver MUST NOT accept a feature which the device did not offer, and MUST NOT accept a feature which requires another feature which was not accepted.” Symmetrically, “The device MUST NOT offer a feature which requires another feature which was not offered. The device SHOULD accept any valid subset of features the driver accepts, otherwise it MUST fail to set the FEATURES_OK device status bit.” That last clause is why the FEATURES_OK read-back exists: it is the device’s veto.
The feature-bit number space is partitioned (virtio 1.3 §2.2): bits 0–23 and 50–127 are per-device-type (e.g. VIRTIO_NET_F_CSUM for the net device); bits 24–41 are reserved for the transport and queue/feature machinery; bits 42–49 and 128+ are reserved for the future. The reserved-range bits relevant to nearly every device, defined in the UAPI header (virtio_config.h, v6.12):
| Bit | Name | Meaning |
|---|---|---|
| 28 | VIRTIO_F_INDIRECT_DESC | driver may use indirect descriptor tables (see The Virtqueue and Vring Layout) |
| 29 | VIRTIO_F_EVENT_IDX | enables the used_event/avail_event interrupt/kick suppression (see virtio Notifications and Virtqueue Kicks) |
| 32 | VIRTIO_F_VERSION_1 | device is virtio-1.0+ compliant (“modern”), not a legacy device |
| 33 | VIRTIO_F_ACCESS_PLATFORM | device honours the platform’s DMA/IOMMU translation (formerly IOMMU_PLATFORM) |
| 34 | VIRTIO_F_RING_PACKED | use the packed virtqueue layout instead of split |
| 35 | VIRTIO_F_IN_ORDER | device uses buffers strictly in the order they were made available |
| 36 | VIRTIO_F_ORDER_PLATFORM | memory accesses are ordered per the platform’s rules |
| 37 | VIRTIO_F_SR_IOV | device supports Single-Root I/O Virtualization |
| 38 | VIRTIO_F_NOTIFICATION_DATA | driver passes extra data in notifications |
| 40 | VIRTIO_F_RING_RESET | a single queue can be reset individually |
| 41 | VIRTIO_F_ADMIN_VQ | device exposes an administration virtqueue |
Two of these are load-bearing for “modern” virtio. The Linux core refuses a device that, while requiring DMA-API access, fails to offer VIRTIO_F_VERSION_1 and VIRTIO_F_ACCESS_PLATFORM — virtio_features_ok() returns -ENODEV with "device must provide VIRTIO_F_VERSION_1" / "device must provide VIRTIO_F_ACCESS_PLATFORM" (virtio.c, v6.12). The reverse polarity of VIRTIO_F_ACCESS_PLATFORM is a notorious gotcha: clear means “the device has the IOMMU-bypass quirk,” set means “use platform DMA tools” — the opposite sense from most feature bits, kept that way for legacy compatibility (per the header comment).
In the kernel, the negotiated set is computed in virtio_dev_probe(): the driver’s feature_table[] array is folded into a driver_features mask, then dev->features = driver_features & device_features (or the legacy table if VERSION_1 is absent). Transport feature bits in the reserved range 28–41 are always preserved into finalize_features() regardless of the driver table, because they belong to the plumbing, not the device.
The Transports: virtio-pci, virtio-mmio, virtio-ccw
A transport’s job is narrow: present the configuration registers (status byte, feature selectors, per-queue addresses, the device-specific config space) and provide a notification doorbell. The virtio core never knows which transport is underneath — it calls through a struct virtio_config_ops function table whose members are get/set (config space), generation, get_status/set_status, reset, find_vqs/del_vqs, get_features/finalize_features, and a few optional helpers (include/linux/virtio_config.h, v6.12). Each transport supplies its own config_ops.
virtio-pci presents the device as a PCI(e) function with vendor ID 0x1AF4 (Red Hat / Qumranet). Device IDs split into two ranges: transitional 0x1000–0x103F (legacy + modern hybrid) and modern 0x1040–0x107F, where the device type is 0x1040 + virtio_device_id (virtio 1.3 §4.1). Modern virtio-pci does not cram everything into a tiny legacy I/O bar; it advertises its structures through PCI vendor-specific capabilities, each tagged by a cfg_type (virtio_pci.h, v6.12):
#define VIRTIO_PCI_CAP_COMMON_CFG 1 /* the common configuration block */
#define VIRTIO_PCI_CAP_NOTIFY_CFG 2 /* the notification doorbell region */
#define VIRTIO_PCI_CAP_ISR_CFG 3 /* legacy interrupt-status byte */
#define VIRTIO_PCI_CAP_DEVICE_CFG 4 /* device-specific config space */
#define VIRTIO_PCI_CAP_PCI_CFG 5 /* indirect access via PCI config */
#define VIRTIO_PCI_CAP_SHARED_MEMORY_CFG 8 /* additional shared-memory regions */The VIRTIO_PCI_CAP_COMMON_CFG capability points at a struct virtio_pci_common_cfg in a BAR — the heart of the control plane. Its fields are exactly the negotiation machinery in register form: device_feature_select + device_feature (read the 32-bit feature window selected), guest_feature_select + guest_feature (write back the driver’s choice), num_queues, device_status, config_generation, and per-queue queue_select, queue_size, queue_enable, queue_notify_off, and the split queue_desc_lo/hi, queue_avail_lo/hi, queue_used_lo/hi that tell the device where each part of the virtqueue lives in guest memory (virtio_pci.h, v6.12). The modern variant virtio_pci_modern_common_cfg adds queue_notify_data, queue_reset, and admin-queue index fields. Notifications use a per-queue offset queue_notify_off scaled by a notify_off_multiplier to compute the address in the notify BAR to write — typically wired to an ioeventfd so the write becomes a lightweight kick (see irqfd and ioeventfd).
virtio-mmio is the embedded/ARM-friendly transport: no PCI bus, just a flat block of MMIO registers at a fixed physical address described to the guest via device tree or a kernel command-line virtio_mmio.device= parameter. The register offsets are fixed constants (virtio_mmio.h, v6.12): offset 0x000 is MagicValue (the ASCII string "virt", 0x74726976), 0x004 is Version, 0x008 DeviceID, 0x010/0x014 the DeviceFeatures/DeviceFeaturesSel pair, 0x020/0x024 DriverFeatures/DriverFeaturesSel, 0x030 QueueSel, 0x034 QueueNumMax, 0x044 QueueReady, 0x050 QueueNotify, 0x070 Status, and the 64-bit-in-two-halves QueueDescLow/High (0x080), QueueAvailLow/High (0x090, called the “Driver” ring in modern parlance), and QueueUsedLow/High (0x0a0, the “Device” ring). Config-space-change atomicity is handled by ConfigGeneration at 0x0fc, and the device-specific config space starts at 0x100. This is the transport Firecracker and Cloud Hypervisor lean on for their minimal device models.
virtio-ccw is the IBM Z (s390x mainframe) transport, layering virtio onto the venerable channel I/O subsystem: each virtio device is a subchannel, and configuration/queue setup is performed by issuing Channel Command Words (CCWs) rather than reading/writing memory-mapped registers (virtio 1.3 §4.3 “Virtio Over Channel I/O”). It is functionally equivalent to the other transports — same status byte, same feature negotiation, same virtqueues — but the carrier is the mainframe’s channel architecture, which is why the spec is careful to keep all device-type and queue semantics transport-independent.
The Device Configuration Space
Beyond the negotiation registers, each device type exposes a configuration space: a little-endian structure holding device-specific parameters the driver reads (and occasionally writes). For virtio-net it carries the MAC address, link status, and max queue pairs; for virtio-blk it carries capacity, block size, and geometry. The space is read through the transport’s config->get/config->set ops at a device-type-defined offset.
The hazard is atomicity: a multi-byte field can be updated by the device mid-read, and reads wider than 32 bits are not guaranteed atomic. The spec mandates a generation-counter retry loop: “Drivers MUST NOT assume reads from fields greater than 32 bits wide are atomic… Each transport also provides a generation count for the device configuration space, which will change whenever there is a possibility that two accesses to the device configuration space can see different versions of that space” (virtio 1.3 §2.5). The pattern, also implemented in the kernel’s virtio_cread_* helpers:
u32 before, after;
do {
before = get_config_generation(device); /* config->generation() */
/* read config entry/entries */
after = get_config_generation(device);
} while (after != before); /* retry if it moved under us */A config_changed interrupt (the PCI ISR VIRTIO_PCI_ISR_CONFIG bit, or the MMIO VIRTIO_MMIO_INT_CONFIG bit) tells the driver the config space changed asynchronously — e.g. the host changed a NIC’s link state — which the core routes to the driver’s config_changed callback via virtio_config_changed().
The Device-Type Taxonomy
A virtio device’s type is a small integer assigned by the spec and enumerated in the kernel (virtio_ids.h, v6.12). The classic four are VIRTIO_ID_NET = 1, VIRTIO_ID_BLOCK = 2, VIRTIO_ID_CONSOLE = 3, and VIRTIO_ID_RNG = 4 (an entropy source). The list now spans 40+ types: VIRTIO_ID_BALLOON = 5 (memory ballooning for overcommit), VIRTIO_ID_SCSI = 8, VIRTIO_ID_GPU = 16, VIRTIO_ID_INPUT = 18, VIRTIO_ID_VSOCK = 19 (host↔guest sockets), VIRTIO_ID_CRYPTO = 20, VIRTIO_ID_IOMMU = 23, VIRTIO_ID_MEM = 24 (memory hotplug), VIRTIO_ID_FS = 26 (virtio-fs / FUSE-over-virtio), VIRTIO_ID_PMEM = 27 (persistent memory), up through VIRTIO_ID_GPIO = 41. The kernel binds a driver to a device by matching id_table entries on the virtio bus — virtio_dev_match() compares the device’s id.device and id.vendor against each driver’s claimed IDs, calling virtio_dev_probe() on a hit. Note the transitional device IDs (VIRTIO_TRANS_ID_NET = 0x1000, etc.) used on the PCI transport for devices straddling the legacy/modern boundary.
Failure Modes and Common Misunderstandings
“My device never reaches DRIVER_OK.” The usual culprit is the FEATURES_OK veto: the device cleared the bit because the driver accepted a feature combination the device cannot honour (often a missing dependency feature). The kernel logs "virtio: device refuses features: %x" from virtio_features_ok(). Diagnose by reading /sys/devices/.../virtio*/status and features — the core exports both as sysfs attributes (status_show, features_show in virtio.c).
Confusing transitional, legacy, and modern. A legacy device predates virtio 1.0 and never offers VIRTIO_F_VERSION_1. A modern device requires it. A transitional device speaks both and is detected by the driver checking whether VIRTIO_F_VERSION_1 is offered. Mixing these up produces wrong feature-table selection — the kernel keeps a separate feature_table_legacy for exactly this reason.
Assuming config-space reads are atomic. Skipping the generation-count loop on a 64-bit field like block capacity can read a torn value during a hot-resize. Always loop until the generation is stable.
VIRTIO_F_ACCESS_PLATFORM polarity. Because its sense is inverted relative to other bits, forgetting it under a guest with a real or emulated IOMMU (e.g. confidential VMs needing bounce buffers) silently breaks DMA. Modern guests require it alongside VERSION_1.
Alternatives and When to Choose Them
The spectrum of guest I/O runs from maximum compatibility to maximum speed (the MOC’s decision framework). Full device emulation (e1000, IDE, AC’97) needs no guest cooperation but pays a VM exit per register access — choose it only when the guest has no virtio driver (ancient OSes, firmware, installers). virtio is the default sweet spot: a standard, cross-hypervisor paravirtual interface with a batched ring. vhost keeps the same virtio guest driver but moves the backend into the host kernel, eliminating the userspace round-trip for the data path. vDPA push the backend into another userspace process or onto real hardware that speaks virtqueues natively. VFIO/SR-IOV passthrough hands the guest a real device for native speed, at the cost of live migration and overcommit. virtio’s enduring value is that it is the portable point on this curve — the same driver works everywhere and migrates cleanly.
Production Notes
Because virtio is an open OASIS standard rather than a Linux-only interface, the exact same guest drivers run under QEMU/KVM, Firecracker, Cloud Hypervisor, and even non-Linux hosts; Windows guests use the signed “virtio-win” driver package. This portability is why every major cloud uses virtio for guest networking and storage — it is the lowest-common-denominator fast path that survives live migration between heterogeneous hosts. The standard’s governance under the OASIS Virtual I/O Device Technical Committee, with the spec source maintained openly on GitHub (oasis-tcs/virtio-spec), means feature bits are added through a public process rather than vendor fiat — the reason a feature like VIRTIO_F_RING_RESET (bit 40) can appear in the spec and land in kernels in a coordinated way. When debugging a slow guest, the first question is whether it is even using virtio (lspci -nn | grep 1af4 inside the guest, or ethtool -i eth0 showing driver: virtio_net); a guest accidentally falling back to emulated e1000 is a classic, silent 10x throughput regression.
See Also
- The Virtqueue and Vring Layout — the shared-memory data plane this control plane sets up
- virtio Notifications and Virtqueue Kicks — how the doorbell and interrupt suppression work, and the
EVENT_IDXfeature - vhost (In-Kernel virtio Backend) — moving the virtio backend into the host kernel for speed
- vhost-user and vDPA — userspace and hardware-offloaded virtio backends
- virtio-net and virtio-blk — the two most important device types and their config spaces
- VM Exit Reasons and Handling — the cost unit virtio exists to minimize
- irqfd and ioeventfd — the eventfd plumbing behind virtio notifications
- Linux Virtualization MOC — the parent map (§5, the virtio paravirtualized device model)