序
上一篇讨论了 virtio 初始化:driver/device 如何通过 status、feature negotiation、device config 和 queue setup 进入可用状态。本文继续向下展开 split virtqueue,说明 Linux guest 如何创建并配置 queue,以及前端驱动如何把业务 buffer 发布到 queue 并通知 device。
VIRTIO 1.1 定义了两种常见的 virtqueue 布局:split virtqueue 和 packed virtqueue。split virtqueue 将 Descriptor Table、Available Ring 和 Used Ring 分开保存;packed virtqueue 则将 descriptor 及其状态组织在同一个 packed ring 中。两者完成的都是 driver 与 device 之间的 buffer 传递,但初始化方式、索引维护和完成判断并不相同。
本文以 split virtqueue 为主线,具体展开它的初始化、Descriptor Table 与两个 ring 的关系,以及 guest driver 如何提交 buffer。device 收到通知后如何解析 descriptor chain、访问后端资源并通过 used ring 归还完成项,本文只说明必要的协议边界,具体实现留到后续文章。后文提到的 virtqueue、descriptor chain 和 buffer 传递目标具有一般性,但 vring_desc、vring_avail、vring_used 以及相关索引逻辑属于 split virtqueue。packed virtqueue 不在本文展开。
1. virtqueue 解决什么问题
virtqueue 是 driver 和 device 之间传递 buffer 的共享内存队列。driver 不是把完整数据写进某个"数据寄存器",而是把 buffer 的地址、长度、方向等信息写入 descriptor,再把 descriptor chain 的 head 放入 available ring。device 根据这些 descriptor 找到真正的 buffer,处理完成后把 head 和完成长度写入 used ring。
一次 I/O 的主线可以概括为:
text
queue 初始化
-> driver 准备业务 buffer
-> driver 把 buffer 转换为 descriptor chain
-> driver 将 descriptor chain 的 head 发布到 available ring
-> driver notify device 检查指定 queue
-> device 根据已配置的 ring 地址和 available ring 中的 head 访问 buffer
需要区分三类对象。下面的共享内存结构以本文讨论的 split virtqueue 为例:
text
device 可访问的共享队列内存
-> descriptor table
-> available ring
-> used ring
Linux virtio ring 层的管理状态
-> free_head / num_free
-> desc_state / desc_extra
-> last_used_idx
具体 virtio Frontend 设备驱动的业务状态
-> 业务 buffer 的实际数据
-> 与 buffer 对应的业务请求和上下文
2. virtqueue 初始化
前端驱动在初始化自身设备时,需要确定所需的 queue 数量、每条 queue 的用途以及对应的完成回调。随后由 virtio 层与 transport 驱动完成 queue 的创建、初始化以及 device 侧配置。初始化过程包含以下四类工作:
- 在 guest 内存中分配并初始化 queue 的共享区域;
- 创建 Linux 用来管理该 queue 的软件对象,并绑定 descriptor 管理状态和完成 callback;
- 把 queue size 和 device 访问共享区域所需的 DMA 地址配置给 device;
- 将该 queue 登记到 transport 的私有管理结构中。
本章以 virtio-console 的初始化流程为例,说明前端驱动如何创建并配置 virtqueue,以及 Linux virtio 层和 transport 驱动在其中分别承担什么工作。
初始化完成后,前端驱动会得到一个 struct virtqueue *。可以先把它理解为前端驱动操作某条 virtqueue 的入口:后续提交 buffer、查询完成项,都通过这个指针完成。它本身是 Linux 的软件管理对象,内部关联着前面分配的 ring 内存和 descriptor 管理状态。
2.1 前端驱动调用 virtio_find_vqs()
virtio-console 的 init_vqs() 先准备 queue 数组、回调数组和名称数组。以只有一个 port 的情况为例:
c
/* Linux 6.5.3: drivers/char/virtio_console.c */
static int init_vqs(struct ports_device *portdev)
{
vq_callback_t **io_callbacks;
char **io_names;
struct virtqueue **vqs;
int err;
/* 没有 multiport 时,port 0 有一对 data queue。 */
nr_queues = 2;
...
/* input queue 完成:device 已经写完 guest 提供的 buffer。 */
io_callbacks[0] = in_intr;
/* output queue 完成:device 已经消费 guest 提供的 buffer。 */
io_callbacks[1] = out_intr;
io_names[0] = "input";
io_names[1] = "output";
err = virtio_find_vqs(portdev->vdev, nr_queues, vqs,
io_callbacks,
(const char **)io_names, NULL);
if (err)
goto free;
portdev->in_vqs[0] = vqs[0];
portdev->out_vqs[0] = vqs[1];
...
}
这里的 in / out 是 guest 前端驱动的视角:
text
in_vq :host 写入 guest,guest 提供可写 buffer
out_vq :guest 写给 host,guest 提供 device-readable buffer
callback 不会在这里直接绑定到硬件 IRQ;它会随对应 queue 继续传入 transport,并在创建 struct virtqueue 时保存。第 2.2 节将说明 transport IRQ 如何最终分发到这条 callback。本文聚焦发送路径,不展开完成项的回收逻辑。
virtio_find_vqs() 定义在 include/linux/virtio_config.h 中,本身只是一个通用包装函数:
c
/* Linux 6.5.3: include/linux/virtio_config.h */
static inline int virtio_find_vqs(struct virtio_device *vdev,
unsigned nvqs,
struct virtqueue *vqs[],
vq_callback_t *callbacks[],
const char * const names[],
struct irq_affinity *desc)
{
return vdev->config->find_vqs(vdev, nvqs, vqs, callbacks,
names, NULL, desc);
}
以 virtio-mmio 为例,在 probe 时把 transport 操作表挂到 vdev->config:
c
/* Linux 6.5.3: drivers/virtio/virtio_mmio.c */
static const struct virtio_config_ops virtio_mmio_config_ops = {
.get = vm_get,
.set = vm_set,
.get_status = vm_get_status,
.set_status = vm_set_status,
.reset = vm_reset,
.find_vqs = vm_find_vqs,
.del_vqs = vm_del_vqs,
...
};
/* virtio_mmio_probe() */
vm_dev->vdev.config = &virtio_mmio_config_ops;
所以调用关系是:
text
virtio_find_vqs()
-> vdev->config->find_vqs()
-> virtio_mmio_config_ops.find_vqs
-> vm_find_vqs()
前端驱动只依赖统一的 virtio 接口,不需要知道当前 queue 是通过 MMIO 寄存器、PCI capability 还是其他 transport 配置的。
2.2 vm_find_vqs():准备 transport 资源并逐个创建 queue
vm_find_vqs() 是 virtio-mmio 对统一接口的实现。它注册后续完成通知所需的 transport IRQ,然后循环创建每个 queue。发送路径中需要关注的是:每次循环都会把当前 queue 的 device index、前端 callback 和名称交给 vm_setup_vq():
c
/* Linux 6.5.3: drivers/virtio/virtio_mmio.c */
static int vm_find_vqs(struct virtio_device *vdev, unsigned int nvqs,
struct virtqueue *vqs[],
vq_callback_t *callbacks[],
const char * const names[],
const bool *ctx,
struct irq_affinity *desc)
{
struct virtio_mmio_device *vm_dev =
to_virtio_mmio_device(vdev);
int irq = platform_get_irq(vm_dev->pdev, 0);
int i, err, queue_idx = 0;
if (irq < 0)
return irq;
/* MMIO 通常使用一条共享 IRQ。 */
err = request_irq(irq, vm_interrupt, IRQF_SHARED,
dev_name(&vdev->dev), vm_dev);
if (err)
return err;
for (i = 0; i < nvqs; ++i) {
/* names[i] == NULL 表示这个数组位置不创建 queue。 */
if (!names[i]) {
vqs[i] = NULL;
continue;
}
/* queue_idx 是 device queue index,不是 vqs[] 下标。 */
vqs[i] = vm_setup_vq(vdev, queue_idx++, callbacks[i],
names[i], ctx ? ctx[i] : false);
if (IS_ERR(vqs[i])) {
/* 如果失败,已创建的 queue 需要全部回滚。 */
vm_del_vqs(vdev);
return PTR_ERR(vqs[i]);
}
}
return 0;
}
对于普通 virtio-console:
text
vqs[0], name = "input" -> device queue index 0
vqs[1], name = "output" -> device queue index 1
vm_setup_vq() 的其余职责是建立 ring 内存、写入 MMIO 配置,并把 Linux 的 struct virtqueue 与 transport 私有状态关联起来。
这里申请的 IRQ 和传入的 callback 用于未来的完成通知。它们不是"把 callback 直接注册成硬件 IRQ handler",而是建立一条按 queue 分发的路径:
text
device 未来完成请求并触发 transport IRQ
-> vm_interrupt()
-> 遍历该 device 的 virtqueue
-> vring_interrupt()
-> 对应 queue 的 vq->callback
这解释了 virtio_find_vqs() 为什么需要为每条 queue 提供 callback。本文只需知道 callback 在初始化阶段被保存;used ring 的完成项处理、前端 callback 的业务逻辑和 buffer 回收留到后续文章。
2.3 vm_setup_vq():创建 queue 并登记 transport 私有状态
单个 queue 的关键初始化都集中在 vm_setup_vq():它先选择 device queue,创建 guest 侧 ring 和 Linux 的通用 queue 对象,再把该对象登记到 virtio-mmio 的私有管理结构中。实际的 queue size 和 DMA 地址配置留到第 2.5 节说明。
c
/* Linux 6.5.3: drivers/virtio/virtio_mmio.c,保留关键路径 */
static struct virtqueue *vm_setup_vq(struct virtio_device *vdev,
unsigned int index,
void (*callback)(struct virtqueue *vq),
const char *name, bool ctx)
{
struct virtio_mmio_device *vm_dev =
to_virtio_mmio_device(vdev);
struct virtio_mmio_vq_info *info;
struct virtqueue *vq;
unsigned int num;
bool (*notify)(struct virtqueue *);
unsigned long flags;
/* 为这条 queue 选择 notify 回调;其发送时调用路径见 4.3 节。 */
if (__virtio_test_bit(vdev, VIRTIO_F_NOTIFICATION_DATA))
notify = vm_notify_with_data;
else
notify = vm_notify;
/* 后续 queue 寄存器都作用于当前 device queue。 */
writel(index, vm_dev->base + VIRTIO_MMIO_QUEUE_SEL);
/* 当前 queue 已经配置过就不能重复创建。 */
if (readl(vm_dev->base + (vm_dev->version == 1 ?
VIRTIO_MMIO_QUEUE_PFN : VIRTIO_MMIO_QUEUE_READY)))
return ERR_PTR(-ENOENT);
/* transport 私有的 queue 描述,后面挂到 vq->priv。 */
info = kmalloc(sizeof(*info), GFP_KERNEL);
if (!info)
return ERR_PTR(-ENOMEM);
/* device 报告的最大 queue 长度。 */
num = readl(vm_dev->base + VIRTIO_MMIO_QUEUE_NUM_MAX);
if (!num) {
kfree(info);
return ERR_PTR(-ENOENT);
}
/* 创建 guest 侧的 vring 和 struct vring_virtqueue。 */
vq = vring_create_virtqueue(index, num, VIRTIO_MMIO_VRING_ALIGN,
vdev, true, true, ctx,
notify, callback, name);
if (!vq) {
kfree(info);
return ERR_PTR(-ENOMEM);
}
/* 记录 device 支持的上限;实际 ring 大小与 DMA 地址在 2.5 节配置。 */
vq->num_max = num;
......
/* 把同一个 queue 同时挂到 transport 私有结构中。 */
vq->priv = info;
info->vq = vq;
spin_lock_irqsave(&vm_dev->lock, flags);
list_add(&info->node, &vm_dev->virtqueues);
spin_unlock_irqrestore(&vm_dev->lock, flags);
return vq;
}
这里暂时省略了"写入实际 queue size 和 ring DMA 地址"的部分,避免与 2.5 节重复。在进入这些寄存器之前,先理解 ring 内存如何分配、以及 guest 与 device 分别使用哪套地址。
struct virtqueue 是 virtio ring 层的通用对象,本身不保存 virtio-mmio 的链表节点。struct virtio_mmio_vq_info 则是 MMIO transport 为每条 queue 维护的私有对象:info->vq 让 transport 能从自己的 queue 链表找到通用 virtqueue;vq->priv = info 则让 transport 在删除这条 queue 时能反向找回私有对象。共享 IRQ 到 callback 的具体分发路径已在 2.2 节概括,此处不再展开 used ring 和完成回收。
这个函数可以按四步理解:
text
选择 device queue
-> 创建 guest 侧 vring 和 Linux queue 对象
-> 配置实际大小和地址到 MMIO(见 2.5 节)
-> 登记到 transport 的 queue 管理结构
2.4 ring 内存和地址视图
vring_create_virtqueue() 根据是否协商 VIRTIO_F_RING_PACKED 选择实现;本文只关注 split 路径:
c
/* Linux 6.5.3: drivers/virtio/virtio_ring.c */
struct virtqueue *vring_create_virtqueue(...)
{
if (virtio_has_feature(vdev, VIRTIO_F_RING_PACKED))
return vring_create_virtqueue_packed(...);
return vring_create_virtqueue_split(...);
}
vring_create_virtqueue_split() 先调用 vring_alloc_queue_split() 分配 ring 内存,再调用 __vring_new_virtqueue() 创建 Linux 侧的 vring_virtqueue:
c
static struct virtqueue *vring_create_virtqueue_split(
unsigned int index, unsigned int num,
unsigned int vring_align, struct virtio_device *vdev,
bool weak_barriers, bool may_reduce_num, bool context,
bool (*notify)(struct virtqueue *),
void (*callback)(struct virtqueue *),
const char *name, struct device *dma_dev)
{
struct vring_virtqueue_split vring_split = {};
struct virtqueue *vq;
int err;
err = vring_alloc_queue_split(&vring_split, vdev, num,
vring_align, may_reduce_num,
dma_dev);
if (err)
return NULL;
vq = __vring_new_virtqueue(index, &vring_split, vdev,
weak_barriers, context, notify,
callback, name, dma_dev);
if (!vq) {
vring_free_split(&vring_split, vdev, dma_dev);
return NULL;
}
to_vvq(vq)->we_own_ring = true;
return vq;
}
2.4.1 vring_alloc_queue_split() 分配 ring 内存
vring_alloc_queue_split() 的关键结果不是具体的分配重试策略,而是为一条 queue 建立一块连续的 split ring 内存,并同时保存 guest 和 device 需要的两套地址:
text
vring_size(num, vring_align)
-> 分配一块连续的 queue 内存,并取得其 DMA 起始地址
-> vring_init() 建立 desc / avail / used 的 guest 虚拟地址视图
-> 保存 queue_dma_addr、queue_size_in_bytes 和实际 ring 大小
这里一次分配的是一整块连续的 ring 内存:
text
queue
-> descriptor table
-> available ring
-> alignment padding
-> used ring
在未使用 DMA API 的常见路径中,底层会通过 alloc_pages_exact() 分配连续物理页,得到 guest kernel 可访问的虚拟地址 queue,并据此得到 device-visible DMA 起始地址。
c
/* Linux 6.5.3: drivers/virtio/virtio_ring.c */
static void *vring_alloc_queue(struct virtio_device *vdev, size_t size,
dma_addr_t *dma_handle, gfp_t flag,
struct device *dma_dev)
{
if (vring_use_dma_api(vdev)) {
return dma_alloc_coherent(dma_dev, size,
dma_handle, flag);
} else {
void *queue = alloc_pages_exact(PAGE_ALIGN(size), flag);
if (queue) {
phys_addr_t phys_addr = virt_to_phys(queue);
*dma_handle = (dma_addr_t)phys_addr;
......
}
return queue;
}
}
使用 DMA API 或 IOMMU 时,具体分配方式不同,但下文的 guest 虚拟地址与 device-visible DMA 地址这两个角色不变。ring 内存地址视图如下:

2.4.2 vring_init() 建立 guest 的虚拟地址视图
vring_init() 不再分配内存,只根据 queue 这个 guest CPU 虚拟地址计算三个 ring 区域的虚拟地址:
c
/* Linux 6.5.3: include/uapi/linux/virtio_ring.h */
static inline void vring_init(struct vring *vr, unsigned int num,
void *p, unsigned long align)
{
vr->num = num;
vr->desc = p;
vr->avail = (struct vring_avail *)
((char *)p + num * sizeof(struct vring_desc));
vr->used = (void *)(((uintptr_t)&vr->avail->ring[num]
+ sizeof(__virtio16) + align - 1) & ~(align - 1));
}
同一块内存有两套地址视图:
| 对象 | 保存的地址 | 谁使用 | 什么时候使用 |
|---|---|---|---|
| queue / vring.desc、avail、used | guest CPU 虚拟地址 | Linux guest | guest 填 descriptor、写 available ring、读 used ring |
| queue_dma_addr | device-visible DMA 地址(无 IOMMU 常见路径下就是物理起始地址) | device/transport | vm_setup_vq() 配置 MMIO queue 地址,device 后续访问 ring |
2.5 vm_setup_vq():将 ring 地址配置给 device
前面的 vring_alloc_queue_split() 和 vring_init() 只完成了 guest 侧的 ring 分配和地址初始化,device 还不知道这些区域位于哪里。本节只讨论 VIRTIO 1.1 的 modern MMIO 路径:vm_setup_vq() 把实际 queue size 以及 Descriptor Table、Available Ring、Used Ring 的 DMA 地址写入 device。
c
/* Linux 6.5.3: drivers/virtio/virtio_mmio.c */
static struct virtqueue *vm_setup_vq(struct virtio_device *vdev,
unsigned int index,
void (*callback)(struct virtqueue *vq),
const char *name, bool ctx)
{
...
if (vm_dev->version == 1) {
/* legacy MMIO 使用 QUEUE_PFN 描述整块 vring。 */
...
} else {
u64 addr;
addr = virtqueue_get_desc_addr(vq);
writel((u32)addr,
vm_dev->base + VIRTIO_MMIO_QUEUE_DESC_LOW);
writel((u32)(addr >> 32),
vm_dev->base + VIRTIO_MMIO_QUEUE_DESC_HIGH);
addr = virtqueue_get_avail_addr(vq);
writel((u32)addr,
vm_dev->base + VIRTIO_MMIO_QUEUE_AVAIL_LOW);
writel((u32)(addr >> 32),
vm_dev->base + VIRTIO_MMIO_QUEUE_AVAIL_HIGH);
addr = virtqueue_get_used_addr(vq);
writel((u32)addr,
vm_dev->base + VIRTIO_MMIO_QUEUE_USED_LOW);
writel((u32)(addr >> 32),
vm_dev->base + VIRTIO_MMIO_QUEUE_USED_HIGH);
/* 告诉 device:当前 queue 的地址和大小已配置完成。 */
writel(1, vm_dev->base + VIRTIO_MMIO_QUEUE_READY);
}
...
}
virtqueue_get_desc_addr()、virtqueue_get_avail_addr() 和 virtqueue_get_used_addr() 返回的不是 Linux 通过 vring.desc、vring.avail、vring.used 使用的 guest CPU 虚拟地址,而是 device-visible DMA 地址。
对本文讨论的 split virtqueue,三个 helper 的结果可以理解为:
text
descriptor table DMA 地址 = queue_dma_addr
available ring DMA 地址 = queue_dma_addr + avail 相对 desc 的字节偏移
used ring DMA 地址 = queue_dma_addr + used 相对 desc 的字节偏移
这里配置的是 Descriptor Table、Available Ring 和 Used Ring 这三块 ring 内存 的 DMA 地址。不要将它与后文的 vring_desc.addr 混淆:后者在 driver 提交请求时填写,描述的是某个业务 buffer 的 device-visible DMA 地址。
每个地址拆成低 32 位和高 32 位,分别写入对应的 *_LOW 和 *_HIGH 寄存器。QUEUE_READY = 1 表示当前 queue 的地址和大小已经配置完成,device 可以按照这些 DMA 地址访问 Descriptor Table、Available Ring 和 Used Ring;它不表示 queue 中已经提交了业务 buffer,也不等于 virtio device 已经进入 DRIVER_OK。

到这里,guest 已经分配 ring 内存,并把三个共享区域的 DMA 地址配置给 device;但还没有说明这些区域在协议中各自保存什么。QUEUE_READY 也不等于 DRIVER_OK。下一节先从 split virtqueue 的协议结构出发,再回到 Linux guest 的发送路径。
3. split virtqueue 的协议结构
上一节从 Linux 实现角度说明了 ring 内存的分配、地址视图和 MMIO 配置。本节切换到协议视角,说明同一块内存中的 Descriptor Table、Available Ring 和 Used Ring 分别承担什么职责。对本文的 guest 发送路径而言,核心是 driver 如何用前两者发布一条请求;Used Ring 只作为完成方向的对照出现。
split virtqueue 在协议上由三块区域组成:
| 结构 | 写入方 | 读取方 | 作用 |
|---|---|---|---|
| Descriptor Table | driver | device | 描述 buffer 的地址、长度、方向和链表关系 |
| Available Ring | driver | device | 发布可供 device 处理的 descriptor chain head |
| Used Ring | device | driver | 归还已处理的 descriptor chain,并报告写入长度;本文仅说明其位置 |
Linux UAPI 头文件中的结构定义基本对应规范中的 split virtqueue layout。
c
/* Linux 6.5.3: include/uapi/linux/virtio_ring.h,关键结构摘录 */
#define VRING_DESC_F_NEXT 1
#define VRING_DESC_F_WRITE 2
#define VRING_DESC_F_INDIRECT 4
struct vring_desc {
__virtio64 addr;
__virtio32 len;
__virtio16 flags;
__virtio16 next;
};
struct vring_avail {
__virtio16 flags;
__virtio16 idx;
__virtio16 ring[];
};
struct vring_used_elem {
__virtio32 id;
__virtio32 len;
};
struct vring_used {
__virtio16 flags;
__virtio16 idx;
vring_used_elem_t ring[];
};
3.1 Descriptor Table:连续的 vring_desc 数组
vring.desc 指向 Descriptor Table 的起始位置。它不是单个 descriptor,而是由 num 个连续的 struct vring_desc 组成的数组。每个 descriptor 描述一段 guest buffer:addr 是 device-visible DMA 地址,len 是该段长度,flags 描述访问方向和 chain 关系,next 则是 Descriptor Table 内下一个 descriptor 的数组索引。
text
vring.desc
|
+-- desc[0]
+-- desc[1]
+-- desc[2]
...
+-- desc[num - 1]
一个请求可能包含多个 buffer 段。driver 为这些段建立 descriptor chain,例如:
text
desc[5]: addr = buffer_a, flags = NEXT, next = 8
desc[8]: addr = buffer_b, flags = NEXT, next = 9
desc[9]: addr = buffer_c, flags = 0
descriptor chain: 5 -> 8 -> 9
chain head: 5
这条 chain 的 head 是 5。driver 不会把 desc[5] 的内存地址写入 Available Ring,而是仅写入索引 5。device 读取这个 head 后,从 desc[5] 出发,根据 flags 和 next 遍历整条 chain;只有设置了 VRING_DESC_F_NEXT 时,next 才表示有效的后继 descriptor 索引。
因此,vring_avail.ring[] 的每个元素都是一个 descriptor chain 的 head index 。device 处理完成后,会在 vring_used.ring[] 中写回同一条 chain 的 head index 和完成长度;Used Ring 的消费与 buffer 回收不属于本文范围。
Available Ring 和 Used Ring 的槽位由各自的 idx 决定,而不是由 descriptor index 决定。avail->idx 和 used->idx 都是递增计数器;访问 ring[] 时再对 queue size 取模,因此二者本质上都是环形队列:
text
avail->ring[avail->idx % num] = chain_head;
used->ring[used->idx % num] = { id = chain_head, len = written_len };
avail->idx 表示 driver 已发布请求的累计位置,used->idx 表示 device 已发布完成项的累计位置。双方各自保存已处理的位置,通过比较旧值和新 idx 判断是否有新增项。本文沿着 avail->idx 增长的发送方向展开。
例如,driver 使用 desc[5]、desc[8] 和 desc[9] 组成一条 descriptor chain,其中 desc[5] 是 head。发送侧的发布动作是:
text
1. driver 填写 desc[5]、desc[8]、desc[9],并通过 next 把它们串起来
2. driver 写入 avail->ring[old_avail_idx % num] = 5
3. driver 更新 avail->idx,表示新增一个待处理请求
4. driver 随后通知 device 检查这条 queue
5. device 读取 ring 中的值 5,从 `desc[5]` 开始遍历整条 chain 并访问 buffer
Available Ring 和 Used Ring 传递的不是数据本身,而是"哪条 descriptor chain 需要处理"和"哪条 descriptor chain 已处理完成"。业务 buffer 的 DMA 地址和长度保存在 Descriptor Table 的 vring_desc.addr、vring_desc.len 中;数据不会复制到两个 ring。本文到达 device 取得 chain 的边界为止,device 如何更新 Used Ring 将在后文展开。
VRING_DESC_F_NEXT 表示后面还有 descriptor,next 给出下一个 descriptor 的索引。VRING_DESC_F_WRITE 的方向以 device 视角命名:
- 未设置:device-readable,device 从 buffer 中读取;
- 已设置:device-writable,device 向 buffer 中写入。
本文随后以 guest 写向 host 的 output queue 为例,descriptor 均为 device-readable,因此不会设置 VRING_DESC_F_WRITE。明确三块共享内存的职责后,就可以回到 Linux guest,观察前端驱动如何把业务 buffer 填入 Descriptor Table,并通过 Available Ring 发布给 device。
4. Linux guest:从业务 buffer 到 available ring
本节以 virtio-console 作为源码入口。不同前端设备的业务 buffer、缓存对象和锁各不相同,但提交路径的共同点是:先把一个或多个 guest buffer 组织成 scatterlist,再调用 virtqueue_add_*()。
从 virtqueue 公共接口看,真正重要的输入只有三类:
text
struct virtqueue *vq
-> 要提交到哪一条 queue
struct scatterlist
-> guest buffer 的一段或多段内存
void *data
-> 前端驱动传入的业务上下文指针
前端驱动将 data 作为 virtqueue_add_*() 的参数传入;virtio ring 层仅在 guest 内部保存它,以便未来回收时找回前端业务对象,device 看不到这个指针。换言之,scatterlist 描述 device 实际访问的 guest buffer,data 描述 guest 前端如何管理这次请求,二者用途不同。
4.1 virtio-console 提交 output buffer
以下以 virtio-console 的 output queue 为例。guest 将待发送内容提交给 virtio device:前端驱动先组织 scatterlist,调用 virtqueue_add_outbuf() 发布请求,再调用 virtqueue_kick() 提示 device 检查 queue。在典型的 QEMU 场景中,device backend 随后可能把这些数据转交给 host 的 chardev;这是后端实现行为,不是 virtio 协议中 output queue 的直接语义。
c
/* Linux 6.5.3: drivers/char/virtio_console.c */
static ssize_t __send_to_port(struct port *port, struct scatterlist *sg,
int nents, size_t in_count,
void *data, bool nonblock)
{
...
err = virtqueue_add_outbuf(out_vq, sg, nents, data, GFP_ATOMIC);
virtqueue_kick(out_vq);
if (err)
goto done;
...
}
只有 virtqueue_add_outbuf() 返回 0,请求才会真正发布到 Available Ring。上述 virtio-console 代码紧接着调用 virtqueue_kick();若提交失败,virtio ring 层不会增加 avail->idx,因此不会产生新的可消费请求。
virtqueue_add_outbuf() 位于 virtio ring 层。outbuf 中的 out 是 device 视角的方向:device-readable,即 device 将从 guest 提供的 buffer 读取数据。
c
/* Linux 6.5.3: drivers/virtio/virtio_ring.c */
int virtqueue_add_outbuf(struct virtqueue *vq,
struct scatterlist *sg, unsigned int num,
void *data, gfp_t gfp)
{
return virtqueue_add(vq, &sg, num, 1, 0, data, NULL, gfp);
}
4.2 填写 descriptor chain 并发布 available ring
descriptor 的组织方式分为 direct 和 indirect。direct 模式下,每个 scatter-gather 段占用主 vring Descriptor Table 中的一个 descriptor,并由 next 串成 chain。协商 VIRTIO_RING_F_INDIRECT_DESC 后,driver 也可以只占用主表中的一个 descriptor:该 descriptor 带有 VRING_DESC_F_INDIRECT,其 addr 保存独立 indirect descriptor table 的 device-visible DMA 地址,实际的多个 buffer descriptor 保存在这张表中。
indirect descriptor 可以减少单个请求占用的主 vring descriptor 数量;但对发布语义没有影响,Available Ring 仍只发布主表中的 chain head,后续 virtqueue_kick() 也不变。virtqueue_add_outbuf() 最终进入 virtqueue_add_split()。
c
/* Linux 6.5.3: drivers/virtio/virtio_ring.c */
static inline int virtqueue_add_split(struct virtqueue *_vq,
struct scatterlist *sgs[],
unsigned int total_sg,
unsigned int out_sgs,
unsigned int in_sgs,
void *data,
void *ctx,
gfp_t gfp)
{
......
/* 是否使用 indirect descriptor 描述方式 */
if (virtqueue_use_indirect(vq, total_sg))
desc = alloc_indirect_split(_vq, total_sg, gfp);
else {
desc = NULL;
WARN_ON_ONCE(total_sg > vq->split.vring.num && !vq->indirect);
}
if (desc) {
/* Use a single buffer which doesn't continue */
indirect = true;
/* Set up rest to use this indirect table. */
i = 0;
descs_used = 1;
} else {
indirect = false;
desc = vq->split.vring.desc;
i = head;
descs_used = total_sg;
}
......
/* out_sgs 是 device-readable buffer:device 从 guest 读取。 */
for (n = 0; n < out_sgs; n++) {
for (sg = sgs[n]; sg; sg = sg_next(sg)) {
/* 将 sg 的 guest 虚拟地址映射为 device-visible DMA 地址 */
dma_addr_t addr = vring_map_one_sg(vq, sg, DMA_TO_DEVICE);
if (vring_mapping_error(vq, addr))
goto unmap_release;
prev = i;
/* 将 addr 加入到 desc 中 */
i = virtqueue_add_desc_split(_vq, desc, i, addr, sg->length,
VRING_DESC_F_NEXT,
indirect);
}
}
/* in_sgs 是 device-writable buffer:device 向 guest 写入。 */
for (; n < (out_sgs + in_sgs); n++) {
for (sg = sgs[n]; sg; sg = sg_next(sg)) {
dma_addr_t addr = vring_map_one_sg(vq, sg, DMA_FROM_DEVICE);
if (vring_mapping_error(vq, addr))
goto unmap_release;
prev = i;
/* 将 addr 加入到 desc 中 */
i = virtqueue_add_desc_split(_vq, desc, i, addr,
sg->length,
VRING_DESC_F_NEXT |
VRING_DESC_F_WRITE,
indirect);
}
}
/* Last one doesn't continue. */
desc[prev].flags &= cpu_to_virtio16(_vq->vdev, ~VRING_DESC_F_NEXT);
if (!indirect && vq->use_dma_api)
vq->split.desc_extra[prev & (vq->split.vring.num - 1)].flags &=
~VRING_DESC_F_NEXT;
/* indirect table 填写完后,再用主 vring 的 head 指向它 */
if (indirect) {
/* Now that the indirect table is filled in, map it. */
dma_addr_t addr = vring_map_single(
vq, desc, total_sg * sizeof(struct vring_desc),
DMA_TO_DEVICE);
if (vring_mapping_error(vq, addr))
goto unmap_release;
virtqueue_add_desc_split(_vq, vq->split.vring.desc,
head, addr,
total_sg * sizeof(struct vring_desc),
VRING_DESC_F_INDIRECT,
false);
}
/* 这次请求从主 vring 空闲 descriptor 池中消耗的数量。 */
vq->vq.num_free -= descs_used;
/* 更新下次分配 descriptor 时使用的空闲链表头。 */
if (indirect)
vq->free_head = vq->split.desc_extra[head].next;
else
vq->free_head = i;
......
}
这段代码依次完成三类动作:
- 对每个 scatterlist 段,取得 device-visible DMA 地址,并写入一个 descriptor;多个 descriptor 通过
next形成 chain。 - 更新
num_free、free_head等 Linux 私有状态,并在desc_state[head].data保存前端传入的data。这些状态不会写入共享 ring,device 也看不到它们。 - 将 chain 的
head写入avail->ring[],再递增avail->idx,把这条请求发布给 device。
virtio_wmb() 是发布边界:在 device 观察到新的 avail->idx 前,descriptor 内容和 avail->ring[] 槽位必须已经可见。业务数据仍在 guest buffer 中;descriptor 保存业务 buffer 的 DMA 地址,Available Ring 只保存 descriptor chain 的 head index。
4.3 通知 device 检查 queue
发布到 Available Ring 与通知 device 是两个动作。成功的 virtqueue_add_outbuf() 已经把请求发布到共享内存;virtqueue_kick() 只提示对端重新检查 queue,并不传递业务数据。
c
/* Linux 6.5.3: drivers/char/virtio_console.c */
static ssize_t __send_to_port(struct port *port, struct scatterlist *sg,
int nents, size_t in_count,
void *data, bool nonblock)
{
......
err = virtqueue_add_outbuf(out_vq, sg, nents, data, GFP_ATOMIC);
/* Tell Host to go! */
virtqueue_kick(out_vq);
......
}
c
/* Linux 6.5.3: drivers/virtio/virtio_ring.c */
bool virtqueue_kick(struct virtqueue *vq)
{
if (virtqueue_kick_prepare(vq)) /* 先判断是否需要、可以通知 device */
return virtqueue_notify(vq); /* 如果需要,则通知 device */
return true;
}
bool virtqueue_notify(struct virtqueue *_vq)
{
struct vring_virtqueue *vq = to_vvq(_vq);
if (unlikely(vq->broken))
return false;
/* Prod other side to tell it about changes. */
if (!vq->notify(_vq)) {
vq->broken = true;
return false;
}
return true;
}
virtqueue_kick_prepare() 会根据协商的通知抑制机制判断此次是否需要实际通知;即使不执行通知,前面已经发布到 available ring 的请求仍然有效。需要通知时,vq->notify 会进入具体 transport。以 virtio-mmio 为例:
c
/* Linux 6.5.3: drivers/virtio/virtio_mmio.c */
static bool vm_notify(struct virtqueue *vq)
{
struct virtio_mmio_device *vm_dev = to_virtio_mmio_device(vq->vdev);
writel(vq->index, vm_dev->base + VIRTIO_MMIO_QUEUE_NOTIFY);
return true;
}
这里写入的是 vq->index,即 device queue index,而不是 descriptor 的地址或业务数据。初始化和发送阶段传递的信息可以对应起来:
| 信息 | 告诉 device 什么 |
|---|---|
QUEUE_DESC_*、QUEUE_AVAIL_*、QUEUE_USED_* |
这条 queue 的 Descriptor Table、Available Ring、Used Ring 位于哪些 DMA 地址 |
QUEUE_READY = 1 |
上述地址和 queue size 已配置完成,这条 queue 可以使用 |
QUEUE_NOTIFY 中的 queue index |
此次应检查哪一条已经就绪的 queue |
avail->ring[] 中的 head |
哪一条 descriptor chain 是新发布的请求 |
direct 模式中 descriptor 的 desc[].addr |
该请求对应的业务 buffer 位于哪个 DMA 地址 |
因此,device 收到通知后能够先定位到已配置的 queue,再检查 available ring 中的新 head,并从 Descriptor Table 的对应项取得业务 buffer 的 DMA 地址。
至此,本文的发送路径结束于 guest 向 device 发布请求并发出通知。device 如何校验和遍历 descriptor chain、如何访问 backend、以及如何写回 used ring,属于 device 侧消费路径,将在后续文章中单独展开。
5. 小结
本文只关注 split virtqueue 的 guest 发送方向,可以将其概括为:
text
virtio_find_vqs()
-> vm_setup_vq() 分配 ring 并将 DMA 地址配置给 device
-> 前端驱动准备 guest buffer 与 scatterlist
-> virtqueue_add_outbuf()
-> descriptor chain
-> available ring 写入 chain head,并更新 avail->idx
-> virtqueue_kick()
-> virtio-mmio 向 QUEUE_NOTIFY 写入 queue index
其中,descriptor 保存业务 buffer 的 DMA 地址,available ring 保存 descriptor chain 的 head index;二者都位于 device 可访问的共享 ring 内存中。QUEUE_READY 让 device 知道这块 ring 内存位于哪里,QUEUE_NOTIFY 则提示 device 检查哪一条已经就绪的 queue。通知本身不携带业务数据。
本文没有展开 used ring、完成中断与 virtqueue_get_buf(),也没有进入 device/backend 的具体实现。这些内容构成 buffer 的消费与回收路径,作为下一篇文章的主题。
参考资料
- Virtual I/O Device (VIRTIO) Version 1.1
- Linux 6.5.3:
include/linux/virtio_config.h - Linux 6.5.3:
include/uapi/linux/virtio_ring.h - Linux 6.5.3:
drivers/virtio/virtio_ring.c - Linux 6.5.3:
drivers/virtio/virtio_mmio.c - Linux 6.5.3:
drivers/char/virtio_console.c