I/O 虚拟化(一):Virtqueue 介绍

基本数据结构和传输流程

Table of Contents

网络虚拟化作为 IO 虚拟化的重要组成部分是非常值得研究的,包含的技术包括 virtio-net、vhost-net、vhost-user-net 等等。我发现网上对这部分的技术介绍散落在各个角落,作为一个初学者能站在和大家一样的视角上,将整个知识框架进行归纳和整理。这篇文章作为系列的开篇,将会结合 virtio-net 介绍 virtqueue 的原理和实现。

在进入正文之前,先简单的介绍下参考资料:[1] 是 RedHat 一篇较为经典的 virtqueue 介绍,也是本篇文章主要参考的内容。[2] 是 Loyen Wang 对 virtqueue 的介绍,完整介绍了 virtqueue 传输数据的流程,可以作为 [1] 的补充。[3] 是 Oracle 的一片对 virtqueue 的介绍,没怎么仔细看,也可以作为 [1] 的补充。最后,还是得看代码,如果你懂一点 Rust,建议参考 Dragonball/dbs-virtio-devices,Dragonball 是一个面向云原生场景的 micro VM,也是 Kata Containers 3.0 的默认 Hypervisor。

总览

Virtqueue 是对 desc table、vring 等数据结构的抽象,被上层 virtio 设备使用,通过内存 buffer 实现 host 和 guest 之间的数据交换能力。这些 virtio 设备使用 0 到多个 virtqueues,比如本文要讲的 virtio-net 使用了两个 virtqueues(如右半边所示),分别是 tx queue 和 rx queue,前者是 guest 向 host 发送数据,后者则是 host 向 guest 发送数据。需要注意的是一个 virtqueue 就具备双向传输数据的能力,virtio-net 使用两个 virtqueues 实现全双工通信的目的是提升效率、避免读写冲突。

在不同文章中,有关于 host 端和 guest 端可能存在不同的描述,device、后端、host 都表示 host 端,driver、前端、guest 都表示 guest 端。

Virtqueue

一个 virtqueue 是一块由 guest 申请的共享内存区域,guest 和 host 可以在这块内存中读或者写,然后被对端消费实现数据传递。一个 virtqueue 可以被认为由 desc table、avail ring、used ring 和 buffer 四个部分组成,其中 avail ring 和 used ring 是一个循环队列(一种著名的数据结构,如果不懂的话建议回炉重造 lol),也被称为 vring。

  • Desc table:描述了已经被使用的 buffer 的信息。
  • Avail ring:Driver 向 device 传输的数据。
  • Used ring:Device 向 driver 传输的数据。

不难看出,一个 virtqueue 已经具备了全双工通信,所以 virtio-net 使用两个 virtqueue 真的是为了性能考虑的。这些数据结构都是由 driver 申请的(处于 guest 地址空间),因此 device 必须翻译到 host 地址空间才能使用,还是以网络虚拟化为例,翻译的过程包括:

  • Emulated device:类似于 virtio-net,guest 地址空间就是 hypervisor 进程的地址空间,所以用不着翻译。
  • 其他 emulated deivce:类似于 vhost-net 和 vhost-user-net,使用的是 POSIX 共享内存的方式,即 hypervisor 进程与内核(vhost-net)或者其他用户态应用(vhost-user-net)的地址空间不是同一个。
  • IOMMU:暂略。

Desc table

Desc table 是一个 virtq_desc 组成的数组,每一个入口项(entry)描述了一段内存区域(起始地址 addr 和长度 len),其数据结构如下所示。

struct virtq_desc {
le64 addr;
le32 len;
le16 flags;
le16 next;
};

Flags 字段表示该入口的特征,比较典型的包括:

  • VRING_DESC_F_NEXT (0x1):是否有下一个项目(稍后介绍)。
  • VRING_DESC_F_WRITE (0x2):表示该入口项对应的 buffer 是否可以被 device 写入。这里必须要多说几句,一个入口项对应的 buffer 在一端的权限只能是 write-only 或者 read-only,比如 flags 中有 VRING_DESC_F_WRITE 意味着 device 端 write-only(driver 端 read-only),而没有这个标志位则表示 device 端 read-only(driver 端 write-only)。

Driver 需要获得一个 buffer 的途径有二:(1)从 buffer 中分配一个区域,并将其对应的入口项添加到 desc table。(2)从 used ring 中获取一个已经被 device 消费过的 buffer(在流程分析里再次提到,可以到那里再品品这句话)。

需要注意的是,只有 driver 有更新 desc table 的权限(绿色 read-wirte 线),device 只有读取 desc table 的权限(红色 read-only 线),但是 driver(绿色 read-only/write-only 线)和 device(红色 read-only/write-only 线)可以根据 VRING_DESC_F_WRITE 标志位从对应的 buffer 读取数据或者写入数据。Desc table 可以被理解为这些 buffer 的元数据(metadata)。

VRING_DESC_F_NEXT 标志位和 next 字段的作用是实现链式描述符(chained descriptors),也就是将多个 buffer 合并为一个大的 buffer。VRING_DESC_F_NEXT 标志位表示数据没有结束,需要读取 next 字段以获取下一个入口项的索引。在一个链式描述符之间不共享 flags,也就说有些描述符是只读的,有些是只写的,。我想这样的原因是一个请求有参数,也要求有返回值,这样一个请求的完整流程就可以在一个链式描述符完成了。在这种情况下 write-only descriptors 排在 read-only descriptors 后面(对于 device 来说可写的排在前,可读的排在后),原因是 used queue(后面介绍)除了传递 index 以外还有一个长度,这表明 device 写入的长度,可以尽可能的释放不使用的描述符(这部分仅仅是我的推测,需要看下 kernel 的 driver 实现)。

Avail ring

Avail ring 用于传递等待 device 处理的描述符索引,该数据结构主要是由 driver 维护(对 device 只读)。Avail ring 的数据结构是 virtq_avail,如下所示。

struct virtq_avail {
le16 flags;
le16 idx;
le16 ring[ /* Queue Size */ ];
};

其中,

  • Flags 是与 avail ring 相关的标志位,比如 VIRTQ_AVAIL_F_NO_INTERRUPT 用于标识在有新的 device 待处理的描述符索引被添加到 avail ring 之后是否立即通知 device(如果数据频繁到达,立即通知可能导致性能低下)。
  • Idx 表示下一个入口项的索引(类似于环形队列的 tail 指针)。
  • Ring 是存放描述符索引的数组,长度是 queue size。

Avail ring 与 desc table 关联如下图所示(图片来源 [1]),这个表示 device 需要在起始位置 0x8000 长度 2000 的 buffer 中写入一些数据回传给 driver。

Used ring

Used ring 与 avail ring 在结构上基本一致,由 device 维护(对 driver 只读),其数据结构如下所示。

struct virtq_used {
le16 flags;
le16 idx;
struct virtq_used_elem ring[ /* Queue Size */];
};
struct virtq_used_elem {
le32 id;
le32 len;
};

与 avail ring 的区别是 ring 字段的类型变为了 virtq_used_elem,该结构体包含了一个描述符索引和长度,表示 device 在向 buffer 写入的数据的长度。为什么会有这个区别呢?我猜是因为 device 没有 desc table 的更新权限,它无法修改入口项的长度字段,因此不得不通过 used ring 来告知 driver 本次数据的长度是多少。

至于其他字段与 avail ring 保持一致,这里就不再赘述了。

这个图是上文介绍所有内容的大杂烩,隐含的信息非常多。首先,desc table 的第 0 项和第 1 项组成了一个长度为 0x4000 的 chained buffer,它的权限是 device write-only。Driver 将 descriptors 放到了 avail ring 中,然后 device 消费并在该 buffer 中存放了长度为 0x3000 的数据,等待 driver 消费(或者 driver 已经消费过了)。

流程分析

我个人认为 [2] 中描述的已经非常详细了,因此这部分内容基本上从 [2] 中搬运。

Guest -> host

步骤:

  1. ①A 表示分配一个 buffer 并添加到 virtqueue 中,①B 表示从 used ring 中获取一个buffer,这两种中选择一种方式;
  2. ② 表示将数据拷贝到 buffer中,用于传送;
  3. ③ 表示更新 avail ring 中的描述符索引值,注意,驱动中需要执行 memory barrier 操作,确保 device 能看到正确的值;
  4. ④ 与 ⑤ 表示 driver 通知 device 来取数据;
  5. ⑥ 表示 device 从 avail ring 中获取到描述符索引值;
  6. ⑦ 表示将描述符索引对应的地址中的数据取出来;
  7. ⑧ 表示 device 更新 used 队列中的描述符索引;
  8. ⑨ 与 ⑩ 表示 device 通知 driver 数据已经取完了。

Host -> Guest

步骤:

  1. ① 表示 device 从 avail ring 中获取可用描述符索引值;
  2. ② 表示将数据拷贝至描述符索引对应的地址上;
  3. ③ 表示更新 used ring 中的描述符索引值;
  4. ④ 与 ⑤ 表示 device 通知 driver 来取数据;
  5. ⑥ 表示 driver 从 used ring 中获取已用描述符索引值;
  6. ⑦ 表示将描述符索引对应地址中的数据取出来;
  7. ⑧ 表示将 avail ring 中的描述符索引值进行更新;
  8. ⑨ 与 ⑩ 表示 driver 通知 device 有新的可用描述符;

References

  1. https://www.redhat.com/en/blog/virtqueues-and-virtio-ring-how-data-travels
  2. https://www.cnblogs.com/LoyenWang/p/14589296.html
  3. https://blogs.oracle.com/linux/post/introduction-to-virtio
All rights reserved
Except where otherwise noted, content on this page is copyrighted.