I/O 虚拟化(一):Split Virtqueue

基本数据结构和传输流程

Table of Contents

这篇文章被转载到 Kata Containers 公众号「Virtio I/O 虚拟化(一):Split Virtqueue」。

网络虚拟化作为 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 包含了 vring、last_avail_idx、used_idx 等,vring 包含了 descriptor ring、avail ring 和 used ring。

Split Virtqueue

Split virtqueue 根据功能分出了几个独立的 virtqueue,这是这种 virtqueue 叫 split 的原因。Virtio 1.1 引入了 packed virtqueue,稍后会详细介绍。

Virtqueue 是什么?

  • Virtqueue 是 guest 申请的一块内存区域,共享给 host 的 VMM。
  • Vring 被分为 3 个部分(areas),每个部分只能被一端写(如果被 device 写,那么 driver 就不能写),它们分别是:
    • Descriptor Area:描述了已经被使用的 buffer 的信息。
    • Driver Area (A.K.A avail virtqueue):Driver 向 device 传输的数据。
    • Device Area (A.K.A used virtqueue):Device 向 driver 传输的数据。

Vring 是一种环形队列数据结构,virtqueue 是基于 vring 这种数据结构实现的。

不难看出,一个 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:硬件直通会用到,地址翻译由硬件完成。

Descriptor ring

Descriptor ring 是一个 virtq_desc 组成的数组,可以理解为 descriptor 是指向实际数据 buffer 的指针。Descriptor ring 的 item 定义如下所示:

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 只能被一端写,比如 flags 中有 VRING_DESC_F_WRITE 意味着 device 端 write-only(driver 端 read-only),而没有这个标志位则表示 device 端 read-only(driver 端 write-only)。

(TODO: 需要再次确认一下)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 或者写入 buffer。

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 由 driver 写入等待 device 消费的 descriptor 索引。Avail ring 的 item 的定义如下所示:

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 是存放 descriptor index 的数组,长度是 queue size。

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

Avail ring 的长度与 descriptor area 的长度保持一致,且 descriptor area 的长度应该是 2 的次方,比如 256、512 等等。 Avail ring 的 idx 值取模,假设 descriptor area 的长度是 256,那么 idx 为 1、257 都表示的是 idx 为 1 的 descriptor。

Driver 想要发送一个数据,流程是:

  1. 申请一块 buffer 并填充数据(地址是 0x8000,长度是 2000)。
  2. 将 buffer 信息填充到 descritptor area 中。
  3. 将对应的 descriptor index 写入到 avail ring 中。此时 driver 不能对这个 buffer 以及这个 avail ring item 进行任何的操作了。
  4. 等待 device 消费。

Used ring

Used ring 是指无论被 device 读或者写,只要是被 device 用了就会被添加到 used ring 中,这是它叫 used 的原因。

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 的更新权限,需要通过 virtq_used_elem.len 通知 driver 写入了多少长度的数据。len 记录的是写书数据的总长度,如果 buffer 是链式的(chained),其长度可能会超过单个 buffer 的长度。

Descriptor ring 的第 0 项和第 1 项组成了一个长度为 0x4000 的 chained buffer,它的权限是 device write-only。Driver 将 descriptors 放到了 avail ring 中,然后 device 消费并在该 buffer 中存放了长度为 0x3000 的数据,等待 driver 消费。

buffer 都是由 driver 分配并传递给 device 的,那么 driver 没有那么多数据发送的时候,device-writable buffer 是如何传递的呢?

  • 对于 virtio-blk 等设备,device 不会主动发送信息,那么 driver 发送请求的时候再发送 buffer。
  • 对于 virtio-net 等设备,device 会预先分配好 buffer 通过 avail ring 传递给 device,如果 buffer 被打满则 device 会被阻塞。

Indirect descriptors

Indirect descriptors 对应的 flag 是 VIRTQ_DESC_F_INDIRECT (0x4),可以类比多级页表。一个 descriptor 对应了一个子 descriptor table,子 descriptor table 再携带多个 descriptors,多个 descriptors 在 avail ring 或者 used ring 中只需要占用一个位置,提升了 descriptors 传递的效率。

流程分析

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

Guest -> host

步骤 1: 获取 driver-writable buffer。获取 buffer 有两种方式,一种是分配一个 buffer,将信息添加到 desc table 中(①A),一种是从 used ring 中获取一个 buffer

  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