Cover image for VirtIO

VirtIO

字数 4.5k
阅读
访客

时间轴

时间轴

2026-07-22

init

2026-08-15

补充 VRing 结构(split ring 三大区域)、数据传输流程、通知抑制机制、Packed Virtqueue、Linux 内核与 OpenAMP 中的 virtqueue 实现,以及 Linux 内核使用 VRing 的实例(virtio-rng、virtio-blk)


VirtIO introduction

VirtIO,即虚拟输入输出,是主机设备上虚拟机的抽象层。本质上,它是一个接口,允许虚拟机通过最小化的虚拟设备(称为 VirtIO 设备)使用主机的设备。这些 VirtIO 设备非常简约,因为它们只实现了发送和接收数据的基本需求。

这是因为在 VirtIO 中,我们让主机负责其实际硬件设备的大部分设置、维护和处理。VirtIO 设备的作用基本上就是将数据传输到主机的实际物理硬件

virtio-net-simple-ex1
virtio-net-simple-ex1

  1. VM:我想去 google.com。嘿 virtio-net,你能告诉主机帮我调取这个网页吗?
  2. Virtio-net:好的。Host,你能帮我们调出这个网页吗?
  3. Host:好的。我现在正在获取网页数据。
  4. Host:这是所要求的网页数据
  5. Virtio-net:谢谢。嘿,VM,这是你要求的网页

虽然这是一个过于简化的例子,但还是能体现出核心思想。也就是说,让主机的硬件尽可能多地完成工作,让 VirtIO 负责发送和接收数据。将大部分工作卸载给主机,使得虚拟机上的执行比模拟设备更快更高效。

VirtIO 的另一个重要方面是其核心框架已标准化为官方 VirtIO 规范。VirtIO 规范定义了 VirtIO 设备和驱动程序必须满足的标准要求(例如功能位、状态、配置、通用操作等)。这很重要,因为这意味着无论使用 VirtIO 的环境或操作系统为何,其实现的核心框架都必须相同。

虽然 VirtIO 实现有一定的合规性,但在组织和设置上也有一定的灵活性。例如,Linux 内核中的virtqueue结构与 QEMU 的VirtQueue结构不同。

VirtIO architecture

VirtIO 的架构包含三个关键部分:

  • 前端驱动
  • 后端设备
  • VirtQueuesVRings

下图中可以看到每个部件在典型的主机和访客(Guest)配置中 VirtIO 的位置(无 vHost、SR-IOV 等)。

virtio-architecture
virtio-architecture

前端 VirtIO 驱动存在于 Guest 内核,后端 VirtIO 设备存在于虚拟机(QEMU),它们之间的通信通过 VirtQueue 和 VRing 在数据平面中处理

我们还可以看到来自 VirtIO 驱动和设备发出的通知(如 VMExit、vCPU IRQ),这些通知被路由到 KVM 中断。

VirtIO Driver (Front-End)

在典型的 VirtIO Host 和 Guest 配置中,VirtIO 驱动存在于 Guest 内核中。在 Guest 操作系统中,每个 VirtIO 驱动都被视为内核模块。VirtIO 驱动的核心职责包括:

  • 接受来自用户进程的输入输出请求

  • 将这些 I/O 请求传输到对应的后端 VirtIO 设备

  • 从 VirtIO 设备对应处检索已完成的请求

例如:

  1. virtio-scsi 的 I/O 请求可能是用户想从存储中检索文档。

  2. virtio-scsi 驱动程序接受请求以获取该文档,并将请求发送给 virtio-scsi 设备(后端)。

  3. VirtIO 设备完成请求后,文档会提供给 VirtIO 驱动。VirtIO 驱动检索该文档并向用户开放。

VirtIO Device (Back-End)

此外,在典型的 Host 和 Guest 配置中,虚拟机中存在 VirtIO 设备。在上图中,我们将使用 QEMU 作为我们的(Type 2)虚拟机。这意味着我们的 VirtIO 设备将存在于 QEMU 进程中。VirtIO 设备的核心职责包括:

  • 接受对应前端 VirtIO 驱动的 I/O 请求
  • 通过将 I/O 操作卸载到主机的物理硬件来处理请求
  • 使处理中的请求数据向 VirtIO 驱动开放

回到 virtio-scsi 的例子:

  1. virtio-scsi 驱动会通知其设备对应者,告知设备需要在实际物理硬件上提取存储中的请求文档。
  2. virtio-scsi 设备接受该请求并执行必要的调用,从物理硬件中获取数据。
  3. 最后,设备通过将检索到的数据放入共享的 VirtQueue,使驱动程序能够使用这些数据。

VirtQueues

VirtIO 架构的最后一个关键部分是 VirtQueue,它们是帮助设备和驱动程序执行各种 I/O 操作的数据结构。

VirtQueue 共享在 Guest 物理内存中,这意味着每个 VirtIO 驱动和设备对访问 RAM 中的同一页面。换句话说,驱动程序和设备的 VirtQueue 并不是两个同步的不同区域。

网上关于 VirtQueue 的描述存在很多不一致。有些人将其与虚拟环(VRings,或 Virtio-rings)同义使用,而另一些则分别描述。这是因为 VRings 是 VirtQueue 的主要特性,VRing 是促进 VirtIO 设备与驱动程序之间数据传输的实际数据结构。我们将在这里单独描述,因为 VirtQueue 不仅仅是 VRing。

VRing:Split Ring 布局

VirtIO 1.0 规范定义的经典 VRing 布局称为 split ring(分离环),它由三块连续的内存区域组成:

区域中文名生产者消费者
Descriptor Table描述符表驱动(分配/回收)设备(读取)
Avail Ring可用环驱动(写入)设备(读取)
Used Ring已用环设备(写入)驱动(读取)

三块区域在内存中的布局如下(vring_init()vring_align对齐摆放):

1
2
3
4
5
6
低地址 ────────────────────────────────────────────────────► 高地址
┌─────────────────────┬──────────────────┬─── 对齐填充 ───┬──────────────────┐
│ Descriptor Table │ Avail Ring │ │ Used Ring │
│ vring_desc[num] │ vring_avail │ │ vring_used │
│ 16 * num 字节 │ 6 + 2*num 字节 │ │ 6 + 8*num 字节 │
└─────────────────────┴──────────────────┴────────────────┴──────────────────┘

注意整个 VRing 位于 Guest 物理地址空间(在 OpenAMP 场景中则是 A7 与 M4 共享的 DDR 地址空间),描述符中记录的 buffer 地址也是这个地址空间中的地址,对端通过它直接访问实际数据缓冲区——数据本身只在共享内存中流转一次,VRing 中传递的只是“指针 + 长度”

Descriptor Table

描述符表是一个数组,每个元素描述一段数据缓冲区:

1
2
3
4
5
6
7
/* include/uapi/linux/virtio_ring.h */
struct vring_desc {
__virtio64 addr; /* buffer 的地址 */
__virtio32 len; /* buffer 长度 */
__virtio16 flags; /* 标志位 */
__virtio16 next; /* 链表中下一个描述符的索引 */
};

flags定义了三个标志:

1
2
3
#define VRING_DESC_F_NEXT	1	/* next 字段有效,描述符链还有后继 */
#define VRING_DESC_F_WRITE 2 /* 设备可写(方向为设备 -> 驱动) */
#define VRING_DESC_F_INDIRECT 4 /* 该描述符指向另一个描述符表 */
  • 一个 I/O 请求往往由多个内存不连续的片段组成(scatter-gather),驱动会把每个片段填入一个描述符,并用NEXT标志串成描述符链WRITE标志决定每个片段是设备读(out,如发包的数据)还是设备写(in,如收包的缓冲区),同一条链上不能混杂方向。
  • INDIRECT是性能优化项:驱动可以分配一块只含一条链的“间接描述符表”,在主表中只占一个描述符位,避免长链占满主表。

Avail Ring 与 Used Ring

可用环由驱动生产,告诉设备“哪些描述符链已经准备好了”:

1
2
3
4
5
struct vring_avail {
__virtio16 flags; /* VRING_AVAIL_F_NO_INTERRUPT 等 */
__virtio16 idx; /* 驱动下一次写入 ring[] 的位置 */
__virtio16 ring[]; /* 描述符链头(descriptor index)数组 */
};

已用环由设备生产,告诉驱动“哪些描述符链已经处理完了”:

1
2
3
4
5
6
7
8
9
10
struct vring_used_elem {
__virtio32 id; /* 描述符链头的索引 */
__virtio32 len; /* 设备实际写入的字节数 */
};

struct vring_used {
__virtio16 flags; /* VRING_USED_F_NO_NOTIFY 等 */
__virtio16 idx; /* 设备下一次写入 ring[] 的位置 */
struct vring_used_elem ring[];
};

两个环都是无锁的 SPSC(单生产者-单消费者)环形队列:双方各自只写自己生产的那份环(idx单调递增、回绕),只读对方生产的那份环,因此不需要任何同步原语,只依赖内存屏障保证可见性。这是 VirtQueue 高吞吐的关键设计。

数据传输流程

以 RPMsg 发送一条消息(A7 -> M4,即virtqueue_add_outbuf()+virtqueue_kick())为例,一次完整的传输流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
驱动(生产者)                                    设备(消费者)
──────────────────────────── ────────────────────────────
1. 构造 buffer,写入消息内容
2. virtqueue_add_outbuf()
- 取空闲描述符,填 addr/len
- 链头写入 avail ring[avail.idx]
- avail.idx++(伴随内存屏障)
3. virtqueue_kick()
- 写 VRING_USED_F_NO_NOTIFY
检查是否需要通知 ---------- 通知 ----------> 4. 收到通知(VMExit / IPCC 中断)
- 触发 notify - 读 avail.idx,发现新链
5. 按描述符 addr 到共享内存
读取数据并处理
6. 把链头和写入长度放入
used ring[used.idx]
used.idx++(伴随内存屏障)
<---------- 中断 ------ 7. 触发回调(vring 回调 / vq callback)
8. virtqueue_get_buf()
- 读 used.idx,取出已处理链
- 回收描述符到空闲池
- 释放或复用 buffer

几个关键点:

  1. 通知(kick/notify)与数据是两条独立的通路。VRing 负责“摆数据”,通知负责“喊一嗓子”。在 QEMU/KVM 中通知是 VMExit + 对设备寄存器(PCI BAR / MMIO)的写入;在 OpenAMP 中则是写 IPCC/mailbox 触发对端中断。
  2. idx单调递增再回绕的自由运行计数器,判断“有没有新东西”只需比较本地缓存的idx与环上的idx,这也是批量处理(一次 kick 处理多个 buffer)的基础。
  3. 驱动回调后通常用virtqueue_disable_cb()关中断回调,批量收割virtqueue_get_buf(),收完再virtqueue_enable_cb(),避免中断风暴。

通知抑制

通知(VMExit / 核间中断)是整个流程里最昂贵的操作,因此规范提供了两个方向的抑制开关,由接收方置位、发送方在通知前检查:

  • VRING_USED_F_NO_NOTIFY(avail ring 上的 flags,设备写给驱动看):设备置位表示“你别老 kick 我,我会自己轮询”。驱动在virtqueue_kick()中检查该标志决定是否真正发出通知。
  • VRING_AVAIL_F_NO_INTERRUPT(used ring 上的 flags,驱动写给设备看):驱动置位表示“处理完不必立刻中断我”。
  • Event IdxVIRTIO_F_EVENT_IDX特性):不再用开关量,而是双方通过used_event/avail_event精确告知“直到 idx 达到多少之前都别通知我”,把抑制粒度做到 buffer 级别。

Packed Virtqueue

VirtIO 1.1 引入了 packed ring(打包环),目的是减少 cache line 占用并消除 split ring 中 avail/used 与 descriptor table 分离带来的多次内存访问:

  • 描述符表和 avail/used 环合并为一个环形描述符数组,描述符自身携带可用/已用标记;
  • 每个描述符用VRING_DESC_F_AVAILVRING_DESC_F_USED两个 flag 的翻转来表示状态变化(等于/不等区分,配合 wrap counter 回绕),驱动和设备各自只写自己拥有的 flag;
  • 描述符数量可以少于队列深度,驱动可以逐个增量发布,无需一次性构建完整链。
1
2
3
4
5
6
struct vring_packed_desc {
__le64 addr; /* buffer 地址 */
__le32 len; /* buffer 长度 */
__le16 id; /* 驱动自由使用的标识,通常是链头索引 */
__le16 flags; /* NEXT/WRITE/AVAIL/USED */
};

设备与驱动通过协商特性位VIRTIO_F_RING_PACKED决定使用 packed 还是 split 布局,两者在功能上等价。Linux 内核的virtio_ring.c同时实现了两套(vring_create_virtqueue_split()/vring_create_virtqueue_packed()),而 OpenAMP 库目前只实现了 split ring。

Linux 内核中的 virtqueue

内核侧的 VirtQueue 由drivers/virtio/virtio_ring.c实现,对驱动暴露统一的struct virtqueueAPI(定义在include/linux/virtio.h):

1
2
3
4
5
6
7
8
9
10
11
struct virtqueue {
struct list_head list; /* 挂到 virtio_device 的 vq 链表 */
void (*callback)(struct virtqueue *vq); /* 设备通知“有 buffer 已用完” */
const char *name;
struct virtio_device *vdev;
unsigned int index; /* vq 编号 */
unsigned int num_free; /* 空闲描述符数量 */
unsigned int num_max;
void *priv;
bool reset;
};

常用 API(在 Virtio Rpmsg Bus 系列中会大量用到):

API作用
virtqueue_add_sgs()通用接口:把多个 scatterlist 以 in/out 混合方向挂入 avail ring
virtqueue_add_outbuf()给设备一个"可读取"的 buffer(驱动 -> 设备方向)
virtqueue_add_inbuf()给设备一个"可写入"的 buffer(设备 -> 驱动方向)
virtqueue_kick()通知设备:队列有新缓冲区
virtqueue_get_buf()从 used ring 取回设备处理完的 buffer
virtqueue_disable_cb()/virtqueue_enable_cb()关闭/恢复处理完成中断回调
virtqueue_detach_unused_buf()取回挂入但尚未被设备使用的 buffer(remove 时用)
virtqueue_get_vring_size()获取队列深度

Linux 内核使用 VRing 的实例

最小实例:virtio-rng

drivers/char/hw_random/virtio-rng.c是内核里最简单的 virtqueue 用户:设备只有一个队列,驱动投递一个 8 字节的可写缓冲,设备往里面填随机数。整个驱动核心只有三步,正好对应上一节的传输流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* drivers/char/hw_random/virtio-rng.c(简化,省略 hwrng 注册、错误处理) */
struct virtio_rng {
struct virtqueue *vq;
unsigned char buf[64];
};

static int virtio_rng_probe(struct virtio_device *vdev)
{
struct virtio_rng *vi = ...;

/* 只有一个队列,注册时给出回调(设备填完数据中断时触发) */
vi->vq = virtio_find_single_vq(vdev, virtio_rng_done, "input");
...
}

/* 第 1、2 步:投递空缓冲 + kick */
static void register_buf(struct virtio_rng *vi)
{
struct scatterlist sg;

sg_init_one(&sg, vi->buf, sizeof(vi->buf));
if (virtqueue_add_inbuf(vi->vq, &sg, 1, vi->buf, GFP_KERNEL) < 0)
return;
virtqueue_kick(vi->vq);
}

/* 第 8 步:回调里收割 */
static void virtio_rng_done(struct virtqueue *vq)
{
struct virtio_rng *vi = vq->vdev->priv;
unsigned int len;
unsigned char *buf;

/* 可能有虚假回调(共享 IRQ 等),get_buf 返回 NULL 表示没有新数据 */
buf = virtqueue_get_buf(vq, &len);
if (!buf)
return;

add_device_randomness(vi->buf, len);
register_buf(vi); /* 收完立刻再挂一个新缓冲,保持流水不断 */
}

把每一步落到 vring 上(假设队列深度为 64,驱动取到空闲描述符desc[5]):

步骤APIvring 上的实际效果
投递空缓冲virtqueue_add_inbuf()desc[5]addr = vi->buflen = 64flags = VRING_DESC_F_WRITE;把5写入avail.ring[avail.idx]avail.idx++
通知设备virtqueue_kick()used.flags检查NO_NOTIFY,未置位则写设备通知寄存器(KVM 下是一次 VMExit)
设备填随机数(设备侧动作)vi->buf写入随机数;把{id=5, len}写入used.ring[used.idx]used.idx++,触发中断
回调收割virtqueue_get_buf()used.ring取回链头5和写入长度,desc[5]回到空闲池,返回当初传入的data(即vi->buf

可以看到驱动自始至终没有触碰任何 vring 结构体:取描述符、更新 avail、检查 used 都封装在virtio_ring.c里,驱动只跟 scatterlist 和data私有指针打交道。

混合方向实例:virtio-blk

drivers/block/virtio_blk.c展示了virtqueue_add_sgs()的典型用法–一次请求由方向不同的多个片段组成描述符链。以一次 512 字节的磁盘读为例,请求在队列里长这样:

1
2
3
4
5
6
7
8
9
  sgs[0](out,设备读)   sgs[1..n](in,设备写)      sgs[末尾](in,设备写)
┌────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ virtio_blk_outhdr │ │ 512B 页缓冲 │ │ 1B status 字节 │
│ type/sector/数据 │ │ (读盘的落盘位置) │ │ (设备回写 OK) │
└─────────┬──────────┘ └──────────┬───────────┘ └─────────────────┘
│ NEXT │ NEXT
▼ ▼
desc[i] ─────────────► desc[i+1..] ─────────────► desc[j](VRING_DESC_F_WRITE)
avail ring 里登记链头 i
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* drivers/block/virtio_blk.c(简化) */
static int virtblk_add_req(struct virtblk_req *vbr, struct virtqueue *vq)
{
struct scatterlist hdr, status, *sgs[...];
unsigned int num_out = 0, num_in = 0;

/* 请求头:块设备请求类型 + 起始扇区号,方向 out */
sg_init_one(&hdr, &vbr->out_hdr, sizeof(vbr->out_hdr));
sgs[num_out++] = &hdr;

/* 数据段:读请求方向 in,写请求方向 out */
blk_mq_rq_to_sgl(rq, data_sgs); /* 把 bio 的物理页映射成 sg */
if (req_op(rq) == REQ_OP_WRITE)
sgs[num_out++] = data_sgs; /* out 段必须排在 in 段前面 */
else
sgs[num_out + num_in++] = data_sgs;

/* 状态字节:设备处理完后回写成功与否,方向 in */
sg_init_one(&status, &vbr->status, sizeof(vbr->status));
sgs[num_out + num_in++] = &status;

/* data 参数是 vbr,中断收割时 virtqueue_get_buf() 原样返回 */
return virtqueue_add_sgs(vq, sgs, num_out, num_in, vbr, GFP_ATOMIC);
}

virtqueue_add_sgs()内部(split 实现为virtqueue_add_split())会把这段 sg 数组翻译成上一节描述符链:out 段在前、in 段在后串成一条链(同一条链上方向不能混杂,规范要求先 out 后 in),并把vbr这个私有指针挂在virtqueue的管理结构里。设备处理完写回 used ring 后,virtblk_done()回调里virtqueue_get_buf()取回vbr,检查vbr->status并向上层块设备栈结束这个请求。

对比 virtio-rng,virtio-blk 的差别在于:

  • 一次挂多段、方向混合:请求头和数据、状态字节共用一条描述符链,靠NEXT+WRITE标志区分方向,这正是描述符链存在的意义;
  • 真正的异步:驱动可以连续挂 N 个请求再统一 kick 一次(批量化),回调里循环while ((vbr = virtqueue_get_buf(...)))收割,把通知开销摊薄到每个请求上–这也是 Virtio Rpmsg Bus 中 RPMsg 预投递 256 个接收缓冲的思路来源。

OpenAMP 中的 virtqueue

在本系列的 OpenAMP 场景(STM32MP157,A7 跑 Linux、M4 裸机固件)中,M4 侧由 OpenAMP 库lib/virtio/virtqueue.c)实现同一套 split ring 协议,两侧分工如下:

  • VRing 的内存来源:vring 的位置和大小由 M4 固件资源表(resource table)中的struct fw_rsc_vdev_vring描述(numalignda等),A7 侧 remoteproc 解析资源表后在共享内存(carveout)中分配实际内存,并按照同一个 vring ID 建立映射。这保证了 A7 的virtio_ring.c和 M4 的 OpenAMP 看到的是同一块物理内存上的同一个环
  • 数据通路:RPMsg 的收发 buffer 本身放在共享内存中,通过 vring 的描述符互相“递交”,具体调用流程在 Virtio Rpmsg Bus 中详细分析。
  • 通知通路:OpenAMP 的virtqueue_kick()最终走到 virtio device 的 notify 回调,在 STM32MP157 上就是往 IPCC 写通道标志触发对端中断(详见 IPCC)。

OpenAMP 侧对应 Linux API 的函数(语义基本一一对应,但面向裸机环境,更精简):

OpenAMP API对应 Linux API作用
virtqueue_create()vring_create_virtqueue()在给定 vring 内存上初始化队列,注册 notify 和 callback
virtqueue_fill_avail_buffers()virtqueue_add_inbuf()预先批量投递可写 buffer(RPMsg RX 队列的用法)
virtqueue_add_buffer()virtqueue_add_sgs()把 buffer 链挂入 avail ring
virtqueue_get_buffer()virtqueue_get_buf()从 used ring 取回 buffer(RX 收数据 / TX 回收)
virtqueue_kick()virtqueue_kick()通知对端(走 IPCC)
virtqueue_disable_cb()/virtqueue_enable_cb()同名通知抑制
为什么两边结构体不同也能通信

OpenAMP 与 Linux 内核对struct virtqueue的定义并不相同(这也印证了开头所说的:规范只约束 vring 协议与行为,不约束软件结构体的组织方式),但只要双方遵守同一版本的 virtio 规范(特性协商一致),vring 上的交互就是兼容的。

参考文档