Cover image for VirtIO

VirtIO

字数 4.8k
阅读
访客

时间轴

时间轴

2026-07-22

init

2026-08-15

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

本文介绍了VirtIO(虚拟输入输出)的基本概念与核心架构。VirtIO是虚拟机与主机设备之间的抽象层,通过极简的VirtIO设备实现数据传输,将大部分硬件处理工作卸载给主机,从而提升效率。文章详细阐述了VirtIO架构中的三个关键部分:前端驱动(Guest内核中的内核模块)、后端设备(如QEMU中的VirtIO设备)以及VirtQueues/VRings。其中,VRing是实际的数据结构,经典布局为split ring,由描述符表、可用环和已用环三块连续内存区域组成,采用无锁SPSC机制实现驱动与设备间的数据共享与通知。文章还提及了VirtIO规范的标准化意义,以及不同实现(如Linux内核与OpenAMP)中的灵活性。

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 的架构包含三个关键部分:

  • 前端驱动
  • 后端设备
  • VirtQueues 和 VRings

下图中可以看到每个部件在典型的主机和访客(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对齐摆放):

123456
低地址 ────────────────────────────────────────────────────► 高地址┌─────────────────────┬──────────────────┬─── 对齐填充 ───┬──────────────────┐│  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

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

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

flags定义了三个标志:

123
#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

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

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

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

12345678910
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())为例,一次完整的传输流程如下:

123456789101112131415161718192021
驱动(生产者)                                    设备(消费者)────────────────────────────                    ────────────────────────────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 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;
  • 描述符数量可以少于队列深度,驱动可以逐个增量发布,无需一次性构建完整链。
123456
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):

1234567891011
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 字节的可写缓冲,设备往里面填随机数。整个驱动核心只有三步,正好对应上一节的传输流程:

1234567891011121314151617181920212223242526272829303132333435363738394041
/* 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->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 字节的磁盘读为例,请求在队列里长这样:

123456789
  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
123456789101112131415161718192021222324
/* 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描述(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 上的交互就是兼容的。

参考文档

评论加载中…