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

Virtio 是一个半虚拟化 I/O 抽象层,通过 host-guest 共享内存的方式实现了高效的数据交换。基于 virtio 技术的一系列设备在虚拟化中大放异彩:网卡虚拟化 virtio-net,共享文件系统 virtio-fs 等等。

Kata Containers 是一个基于虚拟机的容器运行时,也大量的使用了 virtio 技术。Dragonball 是一个为云原生场景优化的轻量虚拟机,也是 runtime-rs 的默认 VMM,为用户提供了开箱即用的体验。在这个系列的文章中,将会借助 Dragonball 的实现,深入探讨 virtio 的实现细节。

在开始前将会定义一些术语。不同的文章对 host 端和 guest 端有不同的描述,设备(device)和后端(backend)都表示 host 端,驱动(driver)和前端(frontend)都表示 guest 端。

Split virtqueue

顾名思义,split virtqueue 根据功能分出了几个独立的区域(area)。尽管 virtio spec 1.1 引入了更高效的 packed virtqueue,但是较为复杂且核心概念是一致的,因此 packed virtqueue 将在随后的文章中介绍。

Virtqueue 是一块由 guest 申请的内存区域,共享给 host 的 VMM 使用。换句话说,host 只有使用权。其内存空间属于 guest 内存空间,必须先翻译到 host 地址空间后才能被 device 使用,主要的翻译方式有三种:

•Emulated device(适用于 virtio-* 设备):guest 地址空间翻译为 hypervisor 的地址空间。

•其他 emulated device(适用于 vhost-* 和 vhost-user-* 设备):一般通过 POSIX 共享内存的方式将内存 offload 到内核空间或者其他用户进程,需要将 guest 地址空间翻译为内核内存空间或其他用户进程的地址空间。

•硬件翻译:一般通过 IOMMU 翻译地址。

915e54dce3d615981c1cd83ae9419709.jpeg

上图[1] 展示了 Linux 内核中 virtqueue 及其相关结构体的层次关系,它被分为了三个部分:

Avail vring 和 used vring 本质上是一个环形队列数据结构。

一个 virtqueue 已经具备了全双工通讯,但是处于性能的考虑一些 virtio 设备会使用至少一对 queues,每个 queue 负责一个单向的数据传递。比如 virtio-net 使用了至少一组 rx queue 和 tx queue 实现网络数据包的交换,前者用于 host 到 guest 的数据传输,后者则用于 guest 到 host 的数据传输。

Descriptor Table

介绍

Descriptor table 保存了一组 descriptors,descriptor 的结构体类型是 vring_desc,它的定义如下所示。

struct vring_desc {
    __virtio64 addr;
    __virtio32 len;
    __virtio16 flags;
    __virtio16 next;
};

Descriptor 可以被看作是一个指向实际数据 buffer 的指针,addr 和 len 分别确定了 buffer 的起始地址和长度,唯一确定了 buffer 的位置。

21ee1b5f17e16b8c58102929e8efc124.jpeg

Flags 字段有几个比较典型的标志位:

•VRING_DESC_F_NEXT (0x1): 表示当前 descriptor 是否链接了额外一个 descriptor(稍后详细介绍)。

•VRING_DESC_F_WRITE (0x2): 表示 buffer 是否可以被 device 写入。一个 buffer 只能被一端写入,比如当 VRING_DESC_F_WRITE 标志位置 1 时意味着 device 只写(driver 只读),反之没有该标志位则 device 只读(driver 只写)。

需要注意的是,VRING_DESC_F_WRITE 控制的是 buffer 的写入权限,但是 descriptor table 只能由 driver 更新!

e8c29f37816cc55864cc58ef2cd78646.jpeg

这张图对上面介绍的内容做了小结:

•Driver(绿色)对 descriptor table 有读取写权;如果 VRING_DESC_F_WRITE 存在,对 buffer 只读,否则只写。

•Device(红色)对 descriptor table 只读;如果 VRING_DESC_F_WRITE 存在,对 buffer 只写,否则只读。

Chained Descriptors

VRING_DESC_F_NEXT 标志位和 next 字段的作用是实现链式(chained)descriptors,也就是将多个 buffer 合并为一个大的逻辑 buffer,如下图所示。

b13e24675aedfb1563d70a8aaa0fee4c.jpeg

VRING_DESC_F_NEXT 标志位表示数据没有结束,需要读取 next 字段以获取下一个 descriptor 索引。一个链式descriptor 之间不共享 flags,也就说一个 buffer 的写权限是由 descriptor 的 VRING_DESC_F_WRITE 标志位决定。

链式 descriptor 被顺序划分为 driver 写和 device 写两组,因为一个请求大概率既有输入也有输出,这样一次完整的数据交换就可以在一个链式 descriptors 中完成了。

Indirect Descriptors

Indirect descriptors 对应的 flag 是 VIRTQ_DESC_F_INDIRECT (0x4)。该标志位表面一个 descriptor 包含了一个子 descriptor table。带来的好处是 descriptor table 承载的 descriptor 数量增加(类比多级页表)。

Avail Ring

Avail ring 存放的是等待 device 消费的 descriptor 索引,只能由 driver 更新。Descriptor table 是一个数组,通过 descriptor 索引就能定位到 descriptor,进而定位到 buffer。

struct vring_avail {
    __virtio16 flags;
    __virtio16 idx;
    __virtio16 ring[];
};

Avail ring 在 Linux 的定义如上所示,需要说明的是:

•__virtio16 表示小端 u16 的类型。

•flags 是标志位,比如 VIRTQ_AVAIL_F_NO_INTERRUPT 标志位标识当有新的索引被添加到 avail ring 之后是否立即通知 device(如果数据频繁到达,频繁通知可能导致性能低下)。

•idx 表示下一个 driver 写入的 ring 的位置。

•ring 是循环队列的数据存储部分(a.k.a. 数组),它的长度是 queue size。

8896fed6870210006bbeee4c500daf36.jpeg

Avail ring 与 descriptor table 的关系如上图所示(图片来源于 RedHat[2]),driver 在 ring[0] 中存储了 0,表示 avail ring 第一个等待 device 消费的 descriptor 索引是 0,即红色标识行。同时 idx 自增 1,意味着下一次 driver 需要将 descriptor 索引写到 ring[1]。

那么又有一个新问题:driver 使用的 buffer 是哪来的?有两个途径(1)driver 新申请一个 buffer,再填入到 descriptor table。(2)Driver 复用已申请过的空闲 buffer(当然不同的 driver 表现不一致)。

Avail ring 的长度与 descriptor table 的长度保持一致,且它们的长度必须是 2 的幂,比如 256、512 等等。

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

1.搞到一块可以用的 buffer 并填充数据(假设地址是 0x8000,长度是 2000)。

2.将 buffer 信息填充到 descritptor table 中。

3.将对应的 descriptor 索引写入到 avail ring 的 ring[idx] 后,idx 自增。此时 driver 不能对这个 buffer 以及这个 avail ring item 进行任何的操作了。

4.通过中断通知 device 有新数据到达。

5.等待 device 消费。

Used Ring

Used ring 的作用是归还 driver 已申请的 descriptor 和 buffer。Used ring 与 avail ring 在结构上基本一致,由 device 维护(对 driver 只读),其数据结构如下所示。

struct vring_used_elem {
    __virtio32 id;
    __virtio32 len;
};


typedef struct vring_used_elem __attribute__((aligned(VRING_USED_ALIGN_SIZE)))
    vring_used_elem_t;


struct vring_used {
    __virtio16 flags;
    __virtio16 idx;
    vring_used_elem_t ring[];
};

与 avail ring 相比,最大的区别是 ring 的类型从 __virtio16 数组变成了 vring_used_elem_t 数组,它不仅保存了索引还存了写入的长度。这样的原因是 driver 能够确定 buffer 长度并写入 descriptor table,但是 device 没有这个权力。因此 device 只能被迫在自己能掌控的 used queue 告知 driver 写入长度。

c77d3546576cbdf197688b308b3860f7.jpeg

这张图隐含了一个链式 descriptor(因为它已经超过了 buffer 0x8000 的长度了),它表明 device 写入链式 buffer 的长度为 0x3000。

之前提到过 virtio 设备出于性能的考虑都会单独设置一个 rx queue 用于 host 到 guest 的数据传递(guest 到 host 的数据传递是通过 tx queue)。那么 host 既没有申请 buffer 的权力,也没有更新 descriptor table 的权力,那一开始没有任何可用的 buffer 时如何向 guest 传递数据呢?

一般拥有 rx queue 的设备都会在每次 rx queue 被唤醒的时候检查 rx buffer 的数量。以 virtio-vsock 为例,当 buffer 数量低于最大数量的一半时,virtio-vsock 的 driver 就会主动申请 buffer 放入到 avail ring 中供 device 使用。

Device 更新了 used vring 后,driver 是如何处理和回收 descriptor 和 buffer 呢?下面这张图展示了一个链式 descriptor 被回收的过程,蓝色的 descriptors 表示 driver 下一个消费的一个链式 descriptor,绿色表示空闲(free)descriptor。

e80f60209ee83bd4296d2bebbf8600fd.jpeg

Virtqueue 根据 used ring 的 id 字段找到下一个被消费的 desc,然后将它们(上图的 #0、#2 和 #3)的 addr 字段置空,同时将它们链接到空闲链式 desc 中,这实现了 descriptor 回收。

虽然 addr 被设置了空值(NULL),但是 buffer 并没有被从内存中清理,而是交给了上层 driver 处理,比如 virtio-net 设备就会处理网络包。在 driver 处理完毕之后,该 buffer 才会真正被系统回收。

致谢

「Virtqueues and virtio ring: How the data travels」是一篇由 RedHat 发布的介绍 virtio 的文章,是一篇完成度非常高的文章。本文也受到该文章的启发,它的原版文章非常值得一读。

References

[1] 「Introduction to VirtIO」: https://blogs.oracle.com/linux/post/introduction-to-virtio
[2] 「Virtqueues and virtio ring: How the data travels」: https://www.redhat.com/en/blog/virtqueues-and-virtio-ring-how-data-travels

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值