Kvm是一种全虚拟化架构的虚拟机,其IO通常由qemu进行模拟实现,相比半虚拟化方案xen,其IO效率相对较低,为了提高kvm的IO效率,在其中引入virtio模块。Virtio是一种linux的半虚拟化IO框架,对块设备、网络等进行了虚拟化。将virtio运用半虚拟化思想,分为前端驱动和后端驱动,前端驱动在客户机中,因此,需要在原来客户机操作系统中增加一个新的virtio前端驱动模块,后端驱动处于主机中。前端驱动中,由虚拟队列中的环形缓冲区保存一次或多次IO请求,并交由后端驱动处理,最终由主机中设备驱动实现真正的IO操作,从而实现批量IO 处理,减少由于IO带来的vmexit/vmentry次数,提高虚拟机IO效率。将virtio引入kvm优点在于提高IO效率,而缺点在于需要对客户操作系统进行修改。
Virtio分为前端与后端驱动,前端驱动在客户机操作系统中实现,在本文中,主要介绍前端驱动的架构以及与块设备相关的主要模块的初始化流程。
前端驱动由多个模块组成,virtio作为一种linux 内部的API,连接块设备、网络设备等前端驱动模块,并通过连接点与后端驱动连接,如下图1:
由图可看出,前端驱动根据功能的不同,分为七个模块,分别包括块设备、网络设备、控制台等,其中与块设备虚拟化相关的模块包括:virito-pci、virtio、virtio-blk和Transport。
通过这四个模块的协同工作,完成了前端驱动中块设备的IO虚拟化。
Virtio前端中,块设备虚拟化主要由四个模块完成,这四个模块分别创建与初始化相关的数据结构,例如virtio初始化总线、virtio-pci初始化driver结构等,这些数据结构相互之间紧密联系,抽象化virtio前端驱动,如图2。本节从这个抽象化的结构出发,探讨virtio前端驱动模块初始化流程。
根据图2所示前端抽象层次,各个模块的初始化实际就是对这个模块中核心数据结构的初始化,接下来,假设系统启动后,在PCI总线上挂载一个能由virtio-blk驱动的设备,对virtio前端驱动模块初始化流程进行分析:
1)PCI扫描设备,发现可由virtio-pci驱动的设备,调用virtio_pci_init加载virtio-pci总线驱动,然后:
i. 调用pci_register_driver注册驱动,初始化pci_driver结构;
ii. 调用pci_bus_match匹配总线与pci_device设备,并调用pci_device_probe与virtio_pci_probe探测设备与驱动是否匹配,并填充virtio_pci_device结构;
iii. 调用register_virtio_device构建虚拟子设备virtio_device。
2)在virtio总线上,注册子设备virtio_device,并匹配相关驱动:
i. 调用device_register,初始化virtio_device,设置设备层次;
ii. 调用bus_probe_device,将设备挂载到总线virtio-bus,匹配驱动virtio_driver;
iii. 调用virtio_dev_match匹配驱动,并使用virtio_dev_probe设置特征位,加载驱动。
3)在块驱动模块virtio-blk中,驱动块设备virtio_device,并分配虚拟队列(环形缓冲):
i. 调用register_virtio_driver注册驱动程序,并初始化virtio_driver;
ii. 调用virtio_probe,声明virtio_blk结构;
iii. 调用virtio_find_single_vq函数,注册中断处理函数,分配vring_virtqueue,绑定notify,为virtqueue队列初始化callback/virtqueue_ops/virtio_pci_vq_info等;
iv. 调用alloc_disk/blk_init_queue/add_disk等,初始化并加载磁盘结构gendisk。
至此,virtio前端驱动模块初始化流程结束,块设备被成功挂载到总线并被驱动,总线、设备、驱动以及环形缓冲区相关的数据均保留在相应的数据结构中。接下来,就是研究前端驱动中块设备(磁盘)的访问流程,环形缓冲的具体工作机制以及前端驱动与主机的通信机制。
前面介绍了virtio前端驱动模块初始化的流程,完成之后,磁盘正式被驱动。本节在前者的基础上,分析virtio前端驱动对磁盘请求的处理流程和机制。
在物理机中,用户空间发起一个IO请求,会经过文件系统层、通用块层和IO调度层,最后到达块设备驱动层,由驱动层处理请求队列,读取磁盘数据。在虚拟机中,IO请求的过程大致相同,本文主要介绍请求队列在驱动层的处理过程。
通过前面的一系列处理过程,文件系统下发的bio均合并到request_queue中,在驱动层中,request_queue会调用其中的request_fn方法,这个方法会对request_queue进行处理,并出发之后的工作,在virtio前端驱动中,request_fn是virtio-blk模块的do_virtblk_request方法,接下来流程如下:
1). do_virtblk_request(struct request_queue *q)
:每次从请求队列q中取出最上面未处理的队列,交由函数do_req处理;当处理完所有请求后,调用函数vring_kick通知主机缓冲区已准备完成;
2). static bool do_req(struct request_queue *q, struct virtio_blk *vblk, struct request *req)
:将req中bio链表中的bio_vec指向的内存页面,转为由vblk中的sg指向,并且初始化virtblk_req结构,结构中保存着块请求信息,如读写标志;sg完成后,调用函数vring_add_buf;
3). static int vring_add_buf(struct virtqueue *_vq, struct scatterlist sg[]...)
: 对vring_virtqueue数据结构初始化,将sg中所指向的内容转由vring_virtqueue中的vring结构来保存,即填充环形缓冲区;
4). static void vring_kick(struct virtqueue *_vq)
:do_virtblk_request等以上方法,将磁盘请求对应的内存页均转由环形缓冲区来保存,之后调用vring_kick,调整vring中数据索引,并调用vp_notify(),以通知主机环形缓冲区准备完毕;
5). static void vp_notify(struct virtqueue *vq)
:写VIRTIO_PCI_QUEUE_NOTIFY位的IO内存区域,内容为当前所处理请求的索引。这条IO指令实际上对寄存器进行了读写操作,引发vm_exit。
Virtio前端驱动的IO处理流程,很重要的功能就是初始化并填充vring_virtqueue环形缓冲区,缓冲区内容由request而来,如图3:
图3中,request与环形缓冲区vring之间的关系一目了然。经过do_req()与vring_add_buf()方法,磁盘请求页面均保存到环形缓冲区中。环形缓冲区是客户机与主机交换数据的一个缓冲区,客户机中的磁盘请求转换到环形缓冲区之后,主机获取缓存区相关信息,并处理缓冲区内容,以达到对块设备IO请求的虚拟化。接下来介绍环形缓冲区中完成IO请求所必须的数据结构和方法:
1). virtblk_req:这个结构包含指向request的指针,并被vring_virtqueue指向;其中的virtio_blk_outhdr数据结构包含了本次IO请求的类型,如读/写/屏障等;还有IO请求的优先级,以及读写的扇区;同时还包含请求状态status等信息;
2). vring:这个数据结构重要的数据项有vring_desc和vring_avail。Vring_desc是保存请求段的结构,所有的请求段以环形缓冲的形式保存到这个结构中,主要包含三部分数据:缓冲区地址、长度、缓冲区描述符以及状态(读写),这部分数据被主机和客户机共享;vring_avail中的ring一直指向当前可用的vring_desc结构头,而idx表示待处理的请求个数;vring_used指示已经被主机使用的缓冲区,需在客户机中释放。在环形缓冲vring中,desc、avail和used的内存区域是一块精确数目的连续物理内存区域,在主机上则体现为连续虚拟内存区域,desc与avail在字节上是相连的,而avail与used则是页相连。
3). vring_virtqueue:这个结构连接了reuqest与环形缓冲区vring,并监控了环形缓冲区的使用情况。
4). vring_new_virtqueue:分配并初始化环形缓冲区;
5). add_buf和get_buf:使用和释放环形缓冲区;
6). vp_vring_interrupt/vring_interrupt:调用虚拟队列回调函数,处理环形缓冲区。
以上结构由disk->queue->queuedata->vblk->vq->vring与设备一一对应,每一次初始化均有唯一的虚拟队列和环形缓冲,关闭前的块设备的所有IO请求均在这个队列中完成。环形缓冲区在virtio中发挥至关重要的作用,以生产者-消费者模型,架起主机与客户机数据交互的桥梁。而且环形缓冲区发挥自身的循环利用的特点,客户机不断的add_buf与get_buf,降低了对缓冲区管理的难度,提高了IO效率。
主机与客户机通过环形缓冲区进行交互,这个交互主要是在客户机与主机同步缓冲区信息,以及准备好缓冲区内容,要通知主机去读写,而主机完成这个读写之后,又要知会客户机结束这次交互,这个交互如何发起又如何结束,需要对IO内存读写完成。
1). IO内存区域:IO内存基址由主机分配,virtio中主要有24byte的控制字段,如表1:
表1 IO内存控制字段
区域 | 字节 | 描述 |
---|---|---|
VIRTIO_PCI_HOST_FEATURES | 4 | 主机特征描述符的地址 |
VIRTIO_PCI_GUEST_FEATURES | 4 | 客户机特征描述符的地址 |
VIRTIO_PCI_QUEUE_PFN | 4 | 客机当前环形队列在主机中的虚拟页地址 |
VIRTIO_PCI_QUEUE_NUM | 2 | 被选择的当前队列大小 |
VIRTIO_PCI_QUEUE_SEL | 2 | 被选择的队列号,块设备只有一个队列 |
VIRTIO_PCI_QUEUE_NOTIFY | 2 | 通知主机虚拟队列,表示队列内容准备完毕 |
VIRTIO_PCI_STATUS | 1 | 设备状态寄存器 |
VIRTIO_PCI_ISR | 1 | 中断状态寄存器 |
VIRTIO_MSI_CONFIG_VECTOR | 2 | MSI-X配置寄存器 |
VIRTIO_MSI_QUEUE_VECTOR | 2 | 一个16位的寄存器,保存被通知的队列 |
2). 客户机知会主机:在前文中已经介绍,客户机通过一系列调用,准备好环形缓冲区后,调用vp_notify()方法,写IO内存的VIRTIO_PCI_QUEUE_NOTIFY区域,引起原因为IO指令的虚拟机退出。退出之后,根据退出原因,处理IO指令,在主机端的virtio后端驱动接收到这个通知,调用相关函数处理请求,至此,主机接收到客户机的通知。
3). 主机知会客户机:主机接收到客户机通知,调用读写函数,如virtio_ioport_write/read,继而调用块处理函数virtio_blk_handle_output处理环形缓冲区,处理后,调用virtqueue_push将数据放入环形缓冲区中,然后通过virtio_notify()、qemu_set_irq等函数,生成并注入中断,通知客户机,客户机响应中断,调用相应中断处理程序,完成接下来的工作。
主机处理完IO请求后,会通过生成并注入中断的形式通知客户机,再次进入客户机之后,会相应中断请求。在前端驱动模块初始化时,就已经指定了virtio-pci的中断处理函数,处理过程如下:
vp_interrupt //中断处理程序,判断是否开中断
--> vp_vring_interrupt //遍历设备的vq,处理vq的中断,块设备只有一个vq0
--> vring_interrupt //调用回调函数blk_done
--> blk_done //循环释放被used标记的desc,并结束请求
--> vring_get_buf //调用detach_buf释放desc,标记空闲
--> __blk_end_request_all //完成处理
blk_done循环调用vring_get_buf,释放被主机标记为used的环形缓冲区desc,设置当前空缓冲区索引和空闲缓冲区个数,并释放每个虚拟队列指向的virtblk-req结构,接下来调用__blk_end_request_all
结束本次的IO请求,如此循环上述过程,直到所有请求处理完毕。
概述中已经提到,virtio是一种半虚拟化的IO虚拟化方案,对比全虚拟化在性能上有较大提高,本章将对比全虚拟化ide驱动,分析virtio-blk应用于kvm时如何提高IO性能。
1). 初始化阶段
Virtio-blk与ide驱动在初始化阶段均需要多次访问IO端口或者IO内存,每一次访问都会造成虚拟机退出。
2). 处理请求的机制
Virtio-blk:从前文分析可以看出,virtio-blk接收到磁盘请求队列request_queue后,从中取出一个request,调用一系列函数将其中的bio所指页面转换到散列表sg,然后sg转换到环形缓冲区中,环形缓冲区填充完之后,返回继续处理request_queue,直到所有的request处理完毕后,会调用notify指令,写IO寄存器,导致一次虚拟机退出。
Ide驱动:首先,同样接收到磁盘请求队列request_queue后,从中取出一个request,调用do_ide_request()—>start_request()处理请求。start_request()中会作如下操作:ide_wait_stat()、ide_dma_host_set()以及ide_exec_command()。Ide_wait_stat()会循环的调用ide_read_status读取所有磁盘(10块)的状态,例如空闲/忙,每一次读取均会访问IO端口,引起虚拟机退出;ide_dma_host_set()会调用ide_dma_sff_read_status()读取DMA的状态,并设置开启/关闭DMA通道标志,同样会访问IO端口,引起虚拟机退出;ide_exec_command()则是向磁盘发起读写命令,也会引起虚拟机退出。处理完一个request指令后,调用回调函数,并开始处理下一个request,如此处理一个request,需要重复多次处理一个request的过程,造成很多次虚拟机退出。
3). 性能分析
对两者请求处理的机制可以看出,virtio-blk处理一次request仅仅需要一次,处理一个请求队列也仅仅需要一次,而ide驱动处理一个请求队列,需要访问多次IO端口,造成多次虚拟机退出,从而在IO性能上,半虚拟化的virtio-blk较之全虚拟化的IDE驱动性能有极大提高。
以上的分析过程均是根据相关文档与代码得来,接下来,在虚拟机的virtio相关模块中添加printk语句,跟踪virtio-blk初始化以及对IO请求的处理流程。
观察下图4,是virtio、virtio-pci以及virtio-blk模块初始化过程以及初始化过程中,总线扫描设备后,对设备加载时的设备号记载。图中显示,各个模块的初始化是按照virtio—>virtio-pci—>virtio-blk的顺序进行的,厂商号为6900,设备号分别是1、2和5,分别对应的总线编号为1—>03.0,2—>04.0/05.0/07.0,5—>06.0,查看lspci可以知道,设备1是虚拟机的virtio-net,设备2为virtio-blk,设备5为virtio-balloon。这些设备均在virtio-pci初始化时,被发现并随后被驱动运行。据图,说明前文对模块初始化的流程分析正确。
IO路径从virtio-blk模块do_virtblk_request开始,跟踪请求队列处理流程,按照前文分析主要经过五个函数的处理,如图5,1~5表示了块IO访问在virtio-blk中的路径,验证了前文对IO路径分析的正确性。
本阶段完成了virtio-blk前端驱动以及环形缓冲区的分析,理清了前端驱动各个模块的初始化流程,对块设备访问的流程,并在虚拟机内核中跟踪所得流程,验证所得流程的正确性。同时,对比分析ide驱动,讨论了半虚拟化对比全虚拟化在IO性能上的优势,认识到半虚拟化virtio通过减少读写配置信息、设置环形缓冲区、IO请求批量处理等方式,以减少虚拟机退出,为KVM带来IO性能的提升。通过这一阶段的工作,对virtio的块设备虚拟化有了一定的了解,下一阶段将对virtio后端驱动做深入分析,理解其工作原理以及与前端之间如何协调工作完成对块设备的虚拟化。