Virtio的代码主要分两个部分:QEMU和内核驱动程序。Virtio设备的模拟就是通过QEMU完成的,QEMU代码在虚拟机启动之前,创建虚拟设备。虚拟机启动后检测到设备,调用内核的virtio设备驱动程序来加载这个virtio设备。
对于KVM虚拟机,都是通过QEMU这个用户空间程序创建的,每个KVM虚拟机都是一个QEMU进程,虚拟机的virtio设备是QEMU进程模拟的,虚拟机的内存也是从QEMU进程的地址空间内分配的。
VRING是由虚拟机virtio设备驱动创建的用于数据传输的共享内存,QEMU进程通过这块共享内存获取前端设备递交的IO请求。
如下图所示,虚拟机IO请求的整个流程:
1) 虚拟机产生的IO请求会被前端的virtio设备接收,并存放在virtio设备散列表scatterlist里;
2) Virtio设备的virtqueue提供add_buf将散列表中的数据映射至前后端数据共享区域Vring中;
3) Virtqueue通过kick函数来通知后端qemu进程。Kick通过写pci配置空间的寄存器产生kvm_exit;
4) Qemu端注册ioport_write/read函数监听PCI配置空间的改变,获取前端的通知消息;
5) Qemu端维护的virtqueue队列从数据共享区vring中获取数据
6) Qemu将数据封装成virtioreq;
7) Qemu进程将请求发送至硬件层。
前后端主要通过PCI配置空间的寄存器完成前后端的通信,而IO请求的数据地址则存在vring中,并通过共享vring这个区域来实现IO请求数据的共享。
从上图中可以看到,Virtio设备的驱动分为前端与后端:前端是虚拟机的设备驱动程序,后端是host上的QEMU用户态程序。为了实现虚拟机中的IO请求从前端设备驱动传递到后端QEMU进程中,Virtio框架提供了两个核心机制:前后端消息通知机制和数据共享机制。
消息通知机制,前端驱动设备产生IO请求后,可以通知后端QEMU进程去获取这些IO请求,递交给硬件。
数据共享机制,前端驱动设备在虚拟机内申请一块内存区域,将这个内存区域共享给后端QEMU进程,前端的IO请求数据就放入这块共享内存区域,QEMU接收到通知消息后,直接从共享内存取数据。由于KVM虚拟机就是一个QEMU进程,虚拟机的内存都是QEMU申请和分配的,属于QEMU进程的线性地址的一部分,因此虚拟机只需将这块内存共享区域的地址传递给QEMU进程,QEMU就能直接从共享区域存取数据。
PCI配置空间
由整体流程图可知,guest和host交互传送信息的两个重要结构分别的PCI config和vring,本节重点分析实现消息通知机制的PCI配置空间。
虚拟机是如何获取PCI配置空间的?
首先,我们为虚拟机创建的virtio设备都是PCI设备,它们挂在PCI总线上,遵循通用PCI设备的发现、挂载等机制。
当虚拟机启动发现virtio PCI设备时,只有配置空间可以被访问,配置空间内保存着该设备工作所需的信息,如厂家、功能、资源要求等,通过对这个空间信息的读取,完成对PCI设备的配置。同时配置空间上有一块存储器空间,里面包含了一些寄存器和IO空间。
前后端的通知消息就是写在这些存储空间的寄存器,virtio会为它的PCI设备注册一个PCI BAR来访问这块寄存器空间。配置空间如下图所示:
虚拟机系统在启动过程中在PCI总线上发现virtio-pci设备,就会调用virtio-pci的probe函数。该函数会将PCI配置空间上的寄存器映射到内存空间,并将这个地址赋值给virtio_pci_device的 ioaddr 变量。之后要对PCI配置空间上的寄存器操作时,只需要ioaddr+偏移量。
pci_iomap函数完成PCI BAR的映射,第一个参数是pci设备的指针,第二个参数指定我们要映射的是0号BAR,第三个参数确定要映射的BAR空间多大,当第三个参数为0时,就将整个BAR空间都映射到内存空间上。VirtioPCI设备的0号BAR指向的就是配置空间的寄存器空间,也就是配置空间上用于消息通知的寄存器。
通过pci_iomap之后,我们就可以像操作普通内存一样(调用ioread和iowrite)来读写pci硬件设备上的寄存器。
虚拟机是如何操作这个配置空间的?
1. kick
当前端设备的驱动程序需要通知后端QEMU程序执行某些操作的时候,就会调用kcik函数,来触发读写PCI配置空间寄存器的动作。
2. 读写PCI寄存器
ioread/iowrite实现了对配置空间寄存器的读写,例如:
vp_dev->ioaddr + VIRTIO_PCI_QUEUE_NOTIFY 表示写notify这个寄存器,位置如图 2 1所示。
ioread读取QEMU端在配置空间寄存器上写下的值。
在读写PCI设备配置空间的操作中,我们可以看到都是通过iodaar+偏移,来指向某个寄存器,ioaddr这个变量是我们在Virtio-pci设备初始化的时候对它赋值,并指向配置空间寄存器的首地址位置。
QEMU如何感知虚拟机的操作的?
虚拟机内调用kick函数实现通知之后,会产生KVM_EXIT。Host端的kvm模块捕获到这个EXIT之后,根据它退出的原因来做处理。如果是一个IO_EXIT,kvm会将这个退出交给用户态的QEMU程序来完成IO操作。
QEMU为kvm虚拟机模拟了virtio设备,因此后端的virtio-pci设备也是在QEMU进程中模拟生成的。QEMU对模拟的PCI设备的配置空间注册了回调函数,当虚拟机产生IO_EXIT,就调用这些函数来处理事件。
这里只分析legacy模式,其实在初始化阶段guest会判断设备是否支持modern模式,如果支持,回调函数会发生一些变化。挖个坑有时间以后补。
1. 监听PCI寄存器
virtio_ioport_write/read就是QEMU进程监听PCI配置空间上寄存器消息的函数,针对前端iowrite/ioread读写了哪个PCI寄存器,来决定下一步操作:
2. 监听函数的注册
PCI寄存器的这些监听函数,都是在QEMU为虚拟机创建虚拟设备的时候注册。
QEMU先为虚拟机的virtio-pci设备创建PCI配置空间,配置空间内包含了设备的一些基本信息;在配置空间的存储空间位置注册了一个PCI BAR,并为这个BAR注册了回调函数监听寄存器的改变。
这部分代码是初始化配置空间的基本信息。
给PCI设备注册了PCI BAR,指定起始地址为PCI_BASE_ADDRESS_SPACE_IO(即PCI配置空间中存储空间到配置空间首地址的偏移值);
指定这个BAR的大小为size,回调函数为virtio_pci_config_ops中的读写函数。
这里的read/write最终都会调用virtio_ioport_write(virtio_ioport_write处理前端写寄存器时触发的事件,virtio_ioport_read处理前端要读寄存器时触发的事件)来统一的管理。