时间轴
时间轴
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 设备的作用基本上就是将数据传输到主机的实际物理硬件。

- VM:我想去 google.com。嘿 virtio-net,你能告诉主机帮我调取这个网页吗?
- Virtio-net:好的。Host,你能帮我们调出这个网页吗?
- Host:好的。我现在正在获取网页数据。
- Host:这是所要求的网页数据
- Virtio-net:谢谢。嘿,VM,这是你要求的网页
虽然这是一个过于简化的例子,但还是能体现出核心思想。也就是说,让主机的硬件尽可能多地完成工作,让 VirtIO 负责发送和接收数据。将大部分工作卸载给主机,使得虚拟机上的执行比模拟设备更快更高效。
VirtIO 的另一个重要方面是其核心框架已标准化为官方 VirtIO 规范。VirtIO 规范定义了 VirtIO 设备和驱动程序必须满足的标准要求(例如功能位、状态、配置、通用操作等)。这很重要,因为这意味着无论使用 VirtIO 的环境或操作系统为何,其实现的核心框架都必须相同。
虽然 VirtIO 实现有一定的合规性,但在组织和设置上也有一定的灵活性。例如,Linux 内核中的
virtqueue结构与 QEMU 的VirtQueue结构不同。
VirtIO architecture
VirtIO 的架构包含三个关键部分:
- 前端驱动
- 后端设备
- VirtQueues 和 VRings
下图中可以看到每个部件在典型的主机和访客(Guest)配置中 VirtIO 的位置(无 vHost、SR-IOV 等)。

前端 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 设备对应处检索已完成的请求
例如:
virtio-scsi 的 I/O 请求可能是用户想从存储中检索文档。
virtio-scsi 驱动程序接受请求以获取该文档,并将请求发送给 virtio-scsi 设备(后端)。
VirtIO 设备完成请求后,文档会提供给 VirtIO 驱动。VirtIO 驱动检索该文档并向用户开放。
VirtIO Device (Back-End)
此外,在典型的 Host 和 Guest 配置中,虚拟机中存在 VirtIO 设备。在上图中,我们将使用 QEMU 作为我们的(Type 2)虚拟机。这意味着我们的 VirtIO 设备将存在于 QEMU 进程中。VirtIO 设备的核心职责包括:
- 接受对应前端 VirtIO 驱动的 I/O 请求
- 通过将 I/O 操作卸载到主机的物理硬件来处理请求
- 使处理中的请求数据向 VirtIO 驱动开放
回到 virtio-scsi 的例子:
- virtio-scsi 驱动会通知其设备对应者,告知设备需要在实际物理硬件上提取存储中的请求文档。
- virtio-scsi 设备接受该请求并执行必要的调用,从物理硬件中获取数据。
- 最后,设备通过将检索到的数据放入共享的 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 | 低地址 ────────────────────────────────────────────────────► 高地址 |
注意整个 VRing 位于 Guest 物理地址空间(在 OpenAMP 场景中则是 A7 与 M4 共享的 DDR 地址空间),描述符中记录的 buffer 地址也是这个地址空间中的地址,对端通过它直接访问实际数据缓冲区——数据本身只在共享内存中流转一次,VRing 中传递的只是“指针 + 长度”。
Descriptor Table
描述符表是一个数组,每个元素描述一段数据缓冲区:
1 | /* include/uapi/linux/virtio_ring.h */ |
flags定义了三个标志:
1 |
- 一个 I/O 请求往往由多个内存不连续的片段组成(scatter-gather),驱动会把每个片段填入一个描述符,并用
NEXT标志串成描述符链;WRITE标志决定每个片段是设备读(out,如发包的数据)还是设备写(in,如收包的缓冲区),同一条链上不能混杂方向。 INDIRECT是性能优化项:驱动可以分配一块只含一条链的“间接描述符表”,在主表中只占一个描述符位,避免长链占满主表。
Avail Ring 与 Used Ring
可用环由驱动生产,告诉设备“哪些描述符链已经准备好了”:
1 | struct vring_avail { |
已用环由设备生产,告诉驱动“哪些描述符链已经处理完了”:
1 | struct vring_used_elem { |
两个环都是无锁的 SPSC(单生产者-单消费者)环形队列:双方各自只写自己生产的那份环(idx单调递增、回绕),只读对方生产的那份环,因此不需要任何同步原语,只依赖内存屏障保证可见性。这是 VirtQueue 高吞吐的关键设计。
数据传输流程
以 RPMsg 发送一条消息(A7 -> M4,即virtqueue_add_outbuf()+virtqueue_kick())为例,一次完整的传输流程如下:
1 | 驱动(生产者) 设备(消费者) |
几个关键点:
- 通知(kick/notify)与数据是两条独立的通路。VRing 负责“摆数据”,通知负责“喊一嗓子”。在 QEMU/KVM 中通知是 VMExit + 对设备寄存器(PCI BAR / MMIO)的写入;在 OpenAMP 中则是写 IPCC/mailbox 触发对端中断。
idx是单调递增再回绕的自由运行计数器,判断“有没有新东西”只需比较本地缓存的idx与环上的idx,这也是批量处理(一次 kick 处理多个 buffer)的基础。- 驱动回调后通常用
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 Idx(
VIRTIO_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_AVAIL和VRING_DESC_F_USED两个 flag 的翻转来表示状态变化(等于/不等区分,配合 wrap counter 回绕),驱动和设备各自只写自己拥有的 flag; - 描述符数量可以少于队列深度,驱动可以逐个增量发布,无需一次性构建完整链。
1 | struct vring_packed_desc { |
设备与驱动通过协商特性位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 | struct virtqueue { |
常用 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 | /* drivers/char/hw_random/virtio-rng.c(简化,省略 hwrng 注册、错误处理) */ |
把每一步落到 vring 上(假设队列深度为 64,驱动取到空闲描述符desc[5]):
| 步骤 | API | vring 上的实际效果 |
|---|---|---|
| 投递空缓冲 | virtqueue_add_inbuf() | 填desc[5]:addr = vi->buf、len = 64、flags = 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 | sgs[0](out,设备读) sgs[1..n](in,设备写) sgs[末尾](in,设备写) |
1 | /* drivers/block/virtio_blk.c(简化) */ |
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描述(num、align、da等),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 上的交互就是兼容的。
