Virtio(三):Virtqueue 建立与 Buffer 提交

Virtio(一):框架概览

Virtio(二):协议基础与设备初始化

  上一篇讨论了 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_descvring_availvring_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 侧配置。初始化过程包含以下四类工作:

  1. 在 guest 内存中分配并初始化 queue 的共享区域;
  2. 创建 Linux 用来管理该 queue 的软件对象,并绑定 descriptor 管理状态和完成 callback;
  3. 把 queue size 和 device 访问共享区域所需的 DMA 地址配置给 device;
  4. 将该 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.descvring.availvring.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] 出发,根据 flagsnext 遍历整条 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->idxused->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.addrvring_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;
	......
}

  这段代码依次完成三类动作:

  1. 对每个 scatterlist 段,取得 device-visible DMA 地址,并写入一个 descriptor;多个 descriptor 通过 next 形成 chain。
  2. 更新 num_freefree_head 等 Linux 私有状态,并在 desc_state[head].data 保存前端传入的 data。这些状态不会写入共享 ring,device 也看不到它们。
  3. 将 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
相关推荐
大模型丫丫19 小时前
Agent开发的难点是什么呢?
大数据·人工智能·学习
三水不滴19 小时前
RAG 评测数据集,到底该怎么准备?
经验分享·笔记
吃好睡好便好19 小时前
露易丝·海的诗歌13
学习·生活
Lhappy嘻嘻19 小时前
网络(四)|全网最细网络层原理:IP 协议、IP 地址分类、子网划分、路由转发、NAT 穿透详解
java·笔记·网络协议·计算机网络
YM52e20 小时前
鸿蒙Flutter Positioned定位组件:精确定位子组件
学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
范什么特西20 小时前
每日学习-04
学习
鱼子星_20 小时前
【C++】vector
开发语言·c++·笔记·stl
吃着火锅x唱着歌20 小时前
Effective C++ 学习笔记 条款38 通过复合塑模出has-a或“根据某物实现出”
c++·笔记·学习
syagain_zsx20 小时前
库制作与原理 · 链接知识笔记
c语言·c++·笔记·动态库·静态库