@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 配置操作分成以下几个部分:
抽象后的操作如下:
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 就可以,有些需要两个呢?
主要差别是:
同时,为了性能,Qemu 和 Guest driver 可以支持为 virtio-blk 创建多个 virtqueues,来支持 multi queue 特性(注:需要块层的 blk-mq 支持)。
Hypervisor 和 Guest OS 之间初始化如下:
对于 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 类:
Guest OS driver 初始化 Virtqueue 以及提交一个标准的 IO 流程是:
通过 5 个参数的接口定义了所有的通用数据放置的操作。
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] 之后,有两种内存布局:
本文不关注 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 来说,读写需要知道以下几个问题:
读写的设备的偏移:
数据源或者目的地 buffer:
操作完成状态:
上图的 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 在初始化的时候进行第一步的地址映射:
因为 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 的能力,包括:
其中配置空间比较重要,通过 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 阶段完成:
在完成 probe 之后,此时前后端就可以进行数据传递。
请求从 block 层到达 virtio-blk 驱动之后,构造 virtio_blk_outhdr,以及 scatterlist,然后通过 add_buf 放入描述符表以及notify host,至此 IO 提交完成。完成事件由中断触发。一个基本的 virtio-blk Guest driver 只需要 300 行左右就可以完成。
Virtio 作者的目标是设计一套通用的,隐藏细节的,前后端方便实现,共享通用代码的虚拟化框架,从分析看来,通过 PCI 和 virtqueue 的抽象,的确能达到目的。
本文对部分细节只进行简单的说明,后续会在以下几个话题开展:
当前,距离 virtio 问世已经十年有余,现在 virtio 的发展还在继续,包括增加更多的 feature bits,新的内存布局,以及 vhost-user,目的是提供更好的性能,以及更强大的功能。