在分析SPDK数据面代码之前,需要我们对qemu中实现的IO环以及virtio前后端驱动的实现有所了解(后续我计划出专门的博文来介绍qemu)。这里我们仍以SPDK前端配置vhost-blk,后端对接NVMe SSD为例(有关NVMe驱动涉及较多规范细节,这里也不作过于深入的讨论,感兴趣的读者可以结合NVMe规范展开阅读)进行分析。
总流程
前文在分析SPDK IO栈时已经大致分析了IO处理的调用层次,在此我们进一步打开内部实现细节,更细致地分析一下IO处理流程:

首先,从虚拟机视角来说,它看到的是一个virtio-blk-pci设备,该pci设备内部包含一条virtio总线,其上又连接了virtio-blk设备。qemu在对虚拟机用户呈现这个virtio-blk-pci设备时,采用的具体设备类型是vhost-user-blk-pci(这是virtio-blk-pci设备的一种后端实现方式。另外两种是:vhost-blk-pci,由内核实现后端;普通virtio-blk-pci,由qemu实现后端处理),这样便可与用户态的SPDK vhost进程建立连接。SPDK vhost进程内部对于虚拟机所见的virtio-blk-pci设备也有一个对象来表示它,这就是spdk_vhost_blk_dev。该对象指向一个bdev对象和一个io channel对象,bdev对象代表真正的后端块存储(这里对应NVMe SSD上的一个namespace),io channel代表当前线程访问存储的独立通道(对应NVMe SSD的一个Queue Pair)。这两个对象在驱动层会进一步扩展新的成员变量,用来表示驱动层可见的一些详细信息。
其次,当虚拟机往IO环中放入IO请求后,便立刻被vhost进程中的某个reactor线程轮循到该请求(轮循过种中执行函数为vdev_worker)。reactor线程取出请求后,会将其映成一个任务(spdk_vhost_blk_task)。对于读写请求,会进一步走到bdev层,将任务封状成一个bdev_io对象(类似内核的bio)。bdev_io继续往驱动层递交,它会扩展为适配具体驱动的io对象,例如针对NVMe驱动,bdev_io将扩展成nvme_bdev_io对象。NVMe驱动会根据nvme_bdev_io对象中的请求内容在当前reactor线程对应的QueuePair中生成一个新的请求项,并通知NVMe控制器有新的请求产生。
最后,当物理NVMe控制器完成IO请求后,会往QueuePair中添加IO响应。该响应信息也会很快被reactor线程轮循到(轮循执行函数为bdev_nvme_poll)。reactor取出响应后,根据其id找到对应的nvme_bdev_io,进一步关联到对应的bdev_io,再调用bdev_io中的记录的回调函数。vhost-blk下发请求时注册的回调函数为blk_request_complete_cb,回调参数为当前的spdk_vhost_blk_task对象。在blk_request_complete_cb中会往虚拟机IO环中放入IO响应,并通过虚拟中断通知虚拟机IO完成。
IO请求下发流程代码解析
vhost进程通过vdev_worker函数以轮循方式处理虚拟机下发的IO请求,调用栈如下:
vdev_worker()
\-process_vq()
|-spdk_vhost_vq_avail_ring_get()
\-process_blk_request()
|-blk_iovs_setup()
\-spdk_bdev_readv()/spdk_bdev_writev()
\-spdk_bdev_io_submit()
\-bdev->fn_table->submit_request()
下面我们先来分析一下vhost-blk层的具体代码实现:
spdk/lib/vhost/vhost-blk.c:
/* reactor线程会采用轮循方式周期性地调用vdev_worker函数来处理虚拟机下发的请求 */
static int
vdev_worker(void *arg)
{
/* arg在注册轮循函数时指定,代表当前操作的vhost-blk对象 */
struct spdk_vhost_blk_dev *bvdev = arg;
uint16_t q_idx;
/* vhost-blk对象bvdev中含有一个抽象的spdk_vhost_dev对象,其内部记录所有vhost_dev类别对象
均含有的公共内容,max_queues代表当前vhost_dev对象共有多少个IO环,virtqueue[]数组记录了
所有的IO环信息 */
for (q_idx = 0; q_idx < bvdev->vdev.max_queues; q_idx++) {
/* 根据IO环的个数,依次处理每个环中的请求 */
process_vq(bvdev, &bvdev->vdev.virtqueue[q_idx]);
}
...
}
/* 处理IO环中的所有请求 */
static void
process_vq(struct spdk_vhost_blk_dev *bvdev, struct spdk_vhost_virtqueue *vq)
{
struct spdk_vhost_blk_task *task;
int rc;
uint16_t reqs[32];
uint16_t reqs_cnt, i;
/* 先给出一些关于IO环的知识:
(1) 简单来说,每个IO环分成descriptor数组、avail数组和used数组三个部分,数组元素个数均为环的最大请求个数。
(2) descriptor数组元素代表一段虚拟机内存,每个IO请求至少包含三段,请求头部段、数据段(至少一个)和响应段。
请求头部包含请求类型(读或写)、访问偏移,数据段代表实际的数据存放位置,响应段记录请求处理结果。一般来说,
每个IO请求在descriptor中至少要占据三个元素;不过当配置了indirect特性后,一个IO请求只占用一项,只不过
该项指向的内存段又是一个descriptor数组,该数组元素个数为IO请求实际所需内存段。
(3) avail数组用来记录已下发的IO请求,数组元素内容为IO请求在descriptor数组中的下标,该下标可作为请求的id。
(4) used数组用来记录已完成的IO响应,数组元素内容同样为IO在descritpror数组中的下标。
*/
/* 从IO环的avail数组中中取出一批请求,将请求id放入reqs数组中;