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 想要发送一个数据,流程是:
- 申请一块 buffer 并填充数据(地址是 0x8000,长度是 2000)。
- 将 buffer 信息填充到 descritptor area 中。
- 将对应的 descriptor index 写入到 avail ring 中。此时 driver 不能对这个 buffer 以及这个 avail ring item 进行任何的操作了。
- 等待 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
- ①A 表示分配一个 buffer 并添加到 virtqueue 中,①B 表示从 used ring 中获取一个buffer,这两种中选择一种方式;
- ② 表示将数据拷贝到 buffer中,用于传送;
- ③ 表示更新 avail ring 中的描述符索引值,注意,驱动中需要执行 memory barrier 操作,确保 device 能看到正确的值;
- ④ 与 ⑤ 表示 driver 通知 device 来取数据;
- ⑥ 表示 device 从 avail ring 中获取到描述符索引值;
- ⑦ 表示将描述符索引对应的地址中的数据取出来;
- ⑧ 表示 device 更新 used 队列中的描述符索引;
- ⑨ 与 ⑩ 表示 device 通知 driver 数据已经取完了。
Host -> Guest
步骤:
- ① 表示 device 从 avail ring 中获取可用描述符索引值;
- ② 表示将数据拷贝至描述符索引对应的地址上;
- ③ 表示更新 used ring 中的描述符索引值;
- ④ 与 ⑤ 表示 device 通知 driver 来取数据;
- ⑥ 表示 driver 从 used ring 中获取已用描述符索引值;
- ⑦ 表示将描述符索引对应地址中的数据取出来;
- ⑧ 表示将 avail ring 中的描述符索引值进行更新;
- ⑨ 与 ⑩ 表示 driver 通知 device 有新的可用描述符;