@panic,SmartX 存储研发工程师。
SmartX是中国领先的超融合产品与企业云解决方案提供商,拥有国内最顶尖的分布式存储和超融合架构研发团队,在分布式存储、虚拟化计算、微服务、容器、前端开发、自动化测试等领域都做着行业最前沿的实践。现正在招兵买马,看完请点击左下角阅读原文查看福利哦~
背景
Virtio 来源于 virtio: towards a de-facto standard for virtual I/O devices 这篇论文[1]。论文发表于 2008 年,已经十来年了,但是它的设计思想依旧不过时,今天来重读一下此文,看看 virtio 是如何统一半虚拟化的。
在那个时代(2008),Linux 作为 Guest OS 已经被多个系统支持,以及用户模式的 Linux 作为一个单独的进程存在。同时,对于 X86 来说,有 3 种 Hypervisor:Xen,KVM,VMWare。但是,当时每一个平台都想要有自己的网络,块设备,console 驱动程序。每个平台都实现自己的虚拟化仿真设备,它们很多相互重叠但是又有些不同,慢慢的性能优化以及驱动的维护都成为问题,所以,急需一个半虚拟化的统一模型,来解决性能以及分裂的麻烦。
论文提出了几个目标,总结来说就是提供两个通用的 ABI,Virtqueue和 Linux API for virtual IO device,以及提供虚拟设备方便的 feature 协商机制以及维持向后兼容性。
PCI 配置操作分成以下几个部分:
读写 feature bits;
读写配置空间;
读写 status bits;
Device reset;
Virtqueue 的创建和销毁
抽象后的操作如下:
struct virtio_config_ops{ bool (*feature)(struct virtio_device *vdev, unsigned bit); void (*get)(struct virtio_device *vdev, unsigned offset, void *buf, unsigned len); void (*set)(struct virtio_device *vdev, unsigned offset, const void *buf, unsigned len); u8 (*get_status)(struct virtio_device *vdev); void (*set_status)(struct virtio_device *vdev, u8 status); void (*reset)(struct virtio_device *vdev); struct virtqueue *(*find_vq)(struct virtio_device *vdev, unsigned index, void (*callback)(struct virtqueue *)); void (*del_vq)(struct virtqueue *vq);};
Feature bits
定义了 Guest 和 Host 支持的功能,例如 VIRTIO_NET_F_CSUM bit 表示网络设备是否支持 checksum offload。feature bits 机制提供了未来扩充功能的灵活性,以及兼容旧设备的能力。
配置空间
一般通过一个数据结构和一个虚拟设备关联,Guest 可以读写此空间。
Status bits
这是一个 8 bits 的长度,Guest 用来标识 device probe 的状态,当 VIRIO_CONFIG_S_DRIVE_OK 被设置,那么 Guest 已经完成了 feature 协商,可以跟 host 进行数据交互了。
Device reset
重置设备,配置和 status bits。
Virtqueue 创建和销毁
find_vq 提供了分配 virtqueue 内存,和 Host 的 IO 空间的初始化操作。
Virtqueues 主要包含了数据的操作。一个虚拟设备可能有一个 virtqueue,或者多个。例如 virtio-blk 只有一个 virtqueue,而 virtio-net/virtio-console 有两个 virtqueue,一个用于输入,一个用于输出。
为什么有些只有一个 virtqueue 就可以,有些需要两个呢?
主要差别是:
对于 virtio-blk/virtio-scsi,对于读写,IO 发起方都是 Guest OS,所以发起方在进行 IO 操作的时候,均可以通过调用下面的 add_buf,在 avail ring 里面放置请求;
对于输入输出的驱动,例如 virtio-net,驱动需要随时准备好接受网络数据的缓冲区,也就是说需要提前准备好 avail ring,所以,需要单独占用一个 virtqueue,提前填满空的请求,hypervisor 在收到数据包之后,可以立即放置到接收的 virtqueue 中,并通知 Guest OS。
同时,为了性能,Qemu 和 Guest driver 可以支持为 virtio-blk 创建多个 virtqueues,来支持 multi queue 特性(注:需要块层的 blk-mq 支持)。
Hypervisor 和 Guest OS 之间初始化如下:
首先,Hypervisor(Qemu) 需要有创建对应的 queue 结构;
然后 virtio device 通过读取 PCI IO 空间来查询 queue;
Guest OS 随后分配 virtio 的 3 种描述符的内存空间。
对于 Step 2,3,使用 find_vq 这个接口来抽象。
Virtqueue 的操作如下:
struct virtqueue_ops { int (*add_buf)(struct virtqueue *vq, struct scatterlist sg[], unsigned int out_num, unsigned int in_num, void *data); void (*kick)(struct virtqueue *vq); void *(*get_buf)(struct virtqueue *vq, unsigned int *len); void (*disable_cb)(struct virtqueue *vq); bool (*enable_cb)(struct virtqueue *vq);};
上述五个操作,定义了 virtuque 的 5 个操作,分成 2 类:
IO 机制实现:add_buf,get_buf
通知机制实现:kick,disable_cb,enable_cb
Guest OS driver 初始化 Virtqueue 以及提交一个标准的 IO 流程是:
Driver 初始化 virtqueue 结构,调用 find_vq,传入 IO 完成时的回调函数;
准备请求,调用 add_buf;
Kick 通知后端有新的请求,Qemu/KVM 后端处理请求,先进行地址转换,然后提取数据以及操作,提交给设备;
请求完成,Qemu/KVM 写 IO 空间触发提前定义好的 MSI 中断,进而进入到 VM,Guest OS 回调被调用,接着 get_buf 被调用,一次 IO 到此全部处理完成;
通过 5 个参数的接口定义了所有的通用数据放置的操作。
vq 表示一个 virtqueue;
sg 定义了一组 scatterlist,这些 sg 是灵魂,数据或者 header 都可以放在这里,自由定义。
out_num 表示 sg 中,有多少是 Guest 要丢给 Host 的;
in_num 表示 sg 中,有多少是 Guest 需要从 Host 拿过来的;
data 表示 private data,完成时 get_buf 返回此数据,一般代表一个 request 的指针。
add_buf 的通用实现是:
将 sg 放入到描述符 table 里面,并且串在一起,然后将第一个 desc idx 放到 avail ring 里面,并存放 data 到数组里。
get_buf 的通用实现是:
检查 last_used_idx < used.idx,表示有已经完成的请求需要处理,然后返回 add_buf 存放的 data ,修改 last_used_idx。
通过 PCI 来触发一次通知,表示有新的请求已经准备好了。通用的是现实通过 iowrite 操作来写 PCI 对应的 IO 空间,触发 VMEXIT。
设置 avail flags字段为 VRING_AVAIL_F_NO_INTERRUPT,让 Host 在请求完成后不通知 Guest。
disable_cb 的相反操作。
在 virtio 1.1 [2] 之后,有两种内存布局:
老的 virtqueue 内存布局称为 Split Virtqueues;
新的 virtqueue 内存布局称为 Packed Virtqueues;
本文不关注 packed virtqueues。
下面是 split virtqueue 的 3 种最基本数据结构的示意图:
这里面有 3 部分,Desc,Used,Avail。他们在物理内存上是连续的,这样方便寻址和映射。
Desc 定义了数据地址,长度,和 flags 和 Next 指针,可以实现多个 desc 项的串联,如图所示。
Avail 存放已经有数据的 desc 的 idx,Used 存放已经完成的 desc 的 idx,各自都有一个头指针和尾指针,来表示可以消费的区间。
以 virtio-blk 为例,当使用 add_buf 添加一个请求后,描述符变化成下面的结构:
对于 virtio-blk 来说,读写需要知道以下几个问题:
读写的设备的偏移:
virtio_blk_outhdr 头描述了读写的偏移,以及附加的信息,占用一个描述符项。
数据源或者目的地 buffer:
Iovec 描述了 buffer 空间的地址和长度,支持 scatter 和 gather IO。对于每一段 scatter/gather 空间,都占用一个描述符项。
操作完成状态:
Virtio_blk_inhdr 头描述了 IO 的状态,占用一个描述符项。
上图的 Data 数组是用来存放请求的指针,作 callback 用。在处理完成事件时,通过 get_buf 可以拿到这个指针,然后可以执行完成相关的上层回调。
请求的头部,以及状态和数据部分,是不同的地址区间,依次填充到可用的描述符表里面,并标记是读或者写。最后在添加了新的请求后,avail table 里面会添加了一条记录,指向整个请求的第一个描述符的 index。实际上一个请求,占用的描述符项的数量等于(2 + scatter-gather list 的长度),这种结构有很好的扩展性,可以描述任意类型的 IO 请求。
此后通过写 PCI 的 IO 空间来触发 notify 操作,Host 检查 last_avail_idx 跟 avail->idx 来判断有多少请求需要处理。notify 也会触发 KVM 的 VMEXIT 事件,造成较大开销。virtio 可以利用 flags 以及 features 来控制双向的 notify 频率,降低 VMEXIT 的调用,提高性能。
virtio 的一个目标就是提高虚拟设备性能,就要消除数据拷贝,采用共享内存的方式访问数据。virtio 的 virtqueue 在 Guest 和 Host 之间是共享的,但是由于在两个不同的地址空间,一个是 Guest Physical Address, 一个是 Host Virtual Address。virtqueue 在 Guest 端构建并初始化,Host 端只需要经过地址转换来建立对应的 virtqueue 结构即可,不用再重新初始化 virtqueue 结构。
Virtio 在初始化的时候进行第一步的地址映射:
Guest 构建好 desc table;
通过写 PCI IO 空间 VIRTIO_PCI_QUEUE_PFN 来告知 Host ,Guest 的 virtqueue 的 GPA 地址;
Host 收到了 GPA,然后转换成 Host 的虚拟地址。
因为 Host 是 Qemu 后端,Qemu 给虚拟机提供了内存,所以它知道 Guest OS 的物理地址范围。Qemu 根据自己记录的信息,可以将 gpa 转换成 hva。
Host 在处理完请求之后,将 desc 的 head 编号放到 used table 里面,然后构造 irq,通过 ioctl 通知 KVM,有请求完成了。在 Guest driver 初始化的时候,提前注册了PCI 的 irq 的 handler。handler 调用 get_buf 来获取 last_used_idx 到 used->idx 区间,已经完成的请求,从 data 数组里面找到 request 的指针,调用对应的回调即可。
至此,虚拟化设备的数据通路走通了,没有数据拷贝,高效的实现了数据在 Guest 和 Host 的传递。Guest 里面的 driver 一般叫做前端,Host 里面对应的虚拟硬件叫做后端。
下面看一下 virtio 如何处理 block 的虚拟化的呢。
我们先从后端讲起,因为后端相当于一个虚拟的硬件,后端提供什么功能,前端才能使用什么功能。对于 block 设备的虚拟化,后端需要提供 virtio 定义的 PCI 的能力,包括:
Feature bits
Status bits
配置空间
reset
...
其中配置空间比较重要,通过 PCI 提供了Guest 访问 virtio 虚拟硬件的一些参数。对于 virtio-blk,包括基本的磁盘布局信息。
struct virtio_blk_config{ uint64_t capacity; uint32_t size_max; uint32_t seg_max; uint16_t cylinders; uint8_t heads; uint8_t sectors; uint32_t blk_size; uint8_t physical_block_exp; uint8_t alignment_offset; uint16_t min_io_size; uint32_t opt_io_size;} __attribute__((packed));
当 Guest OS 需要访问以上配置信息的时候,只需要调用 ioread 读对应的 offset,就可以读到数值。
同理,Guest OS 也可以通过 iowrite 来写对应的 offset,修改结构。
当完成 PCI 设置注册之后,前端 virtio-blk 调用 probe 来装载驱动。进行 feature 协商,以及基本的 IO 空间配置,此时前后端就可以进行数据传递。
当收到 Guest 的 notify 时,如图所示,Host 根据 last_used_idx 从 desc 表中重构 request,包括 virtio_blk_outhdr,iovec 等,这个过程需要进行地址空间转换,然后提交给 backend,就完成了,过程非常简单。当 IO 完成后,注入中断通知 Guest OS。
这是 Linux kernel 里面的一个 PCI 驱动,在 probe 阶段完成:
Virtqueue 的创建;
Feature 的协商;
PCI 配置空间读取 block 设备的空间布局等信息;
在完成 probe 之后,此时前后端就可以进行数据传递。
请求从 block 层到达 virtio-blk 驱动之后,构造 virtio_blk_outhdr,以及 scatterlist,然后通过 add_buf 放入描述符表以及notify host,至此 IO 提交完成。完成事件由中断触发。一个基本的 virtio-blk Guest driver 只需要 300 行左右就可以完成。
Virtio 作者的目标是设计一套通用的,隐藏细节的,前后端方便实现,共享通用代码的虚拟化框架,从分析看来,通过 PCI 和 virtqueue 的抽象,的确能达到目的。
本文对部分细节只进行简单的说明,后续会在以下几个话题开展:
Qemu 和 virtio 的内存映射
中断注入的实现
Virtio 流控以及性能优化
Vhost-user
当前,距离 virtio 问世已经十年有余,现在 virtio 的发展还在继续,包括增加更多的 feature bits,新的内存布局,以及 vhost-user,目的是提供更好的性能,以及更强大的功能。
ozlabs.org/~rusty/virti
docs.oasis-open.org/vir
kernel.org/
qemu.org/
查看我们精华技术文章请移步:
Linux阅码场原创精华文章汇总
扫描下方二维码关注"Linux阅码场"
感谢您的耐心阅读,请别忘记点击左下角阅读原文查看福利哦~