本篇研究的nvme驱动基于Linux 3.10.73 ,为什么选择这个版本呢,因为这个版本之后Linux 块层马上就换成支持多队列(可以参考Linux块层多队列之引入内核),小编的SUSE 11.3也正好能编译这个相对比较低的版本。(随后再看最新版本内核上nvme驱动的实现)
通过nvme_alloc_ns可知,nvme设备通过nvme_make_request()函数进入快层:
nvme_alloc_ns()blk_queue_make_request(ns->queue, nvme_make_request);
快速看一下nvme_make_request()这个函数,就会发现nvme设备有多么任性,都没有申请request, 合并bio这种常规操作(因为它支持随机写,而且速度快,不需要),直接把提交过来的bio送到块设备驱动进行处理。(小编第一次看到这个函数时就小激动了一把)。
714 static void nvme_make_request(struct request_queue *q, struct bio *bio)715 {716 struct nvme_ns *ns = q->queuedata;717 struct nvme_queue *nvmeq = get_nvmeq(ns->dev);718 int result = -EBUSY;719720 spin_lock_irq(&nvmeq->q_lock);721 if (bio_list_empty(&nvmeq->sq_cong))722 result = nvme_submit_bio_queue(nvmeq, ns, bio);723 if (unlikely(result)) {724 if (bio_list_empty(&nvmeq->sq_cong))725 add_wait_queue(&nvmeq->sq_full, &nvmeq->sq_cong_wait);726 bio_list_add(&nvmeq->sq_cong, bio);727 }728729 spin_unlock_irq(&nvmeq->q_lock);730 put_nvmeq(nvmeq);731 }
虽说在这个linux版本上块设备层只支持单队列,但是nvme设备有自己的多队列,每个cpu上绑着一个队列(自己玩)。
每个队列是一个先进先出的FIFO管道,用于连通主机端(Host)和设备端(Device)。其中从主机端发送到设备端的命令管道称之为发送队列.从设备端发送到主机端的命令完成管道称之为完成队列。对于一个IO请求,在主机端组装完成后,通过发送队列发到设备端,然后在设备中进行处理并把相应的完成结果组装成IO完成请求,最后通过完成队列返还给主机端。
不管是发送队列还是完成队列,都是一段内存,通常位于主机端的DDR空间(申请的DMA内存)里。这段内存划分成若干等长的内存块,每一块用于存储一个定常的消息(nvme的发送消息和完成消息都是定常的)。在使用的时候,对于这个队列,有一个头指针和一个尾指针。当两者相等时,队列是空的。见下图。
随着新的消息加入到队列中来,尾指针不停向前移动。因为内存是定常的,因此指针一旦移动到内存的最后一个存储空间,之后再移动的话需要环回到内存的起始位置。因此内存在使用上实际上当作一个环来循环使用。当尾指针的下一个指针就是头指针的时候,这个队列不能再接收新的消息,即队列已经满了,如下图所示。
随着队列的使用者不断取出消息并修改头指针,队列中的元素不断释放,一直到头指针再次追上尾指针时,队列完全变空。
那么主机端将数据写入队列后,设备端是怎么知道该队列所在的内存已经更新了呢?这就需要利用门铃机制(Doorbell)。每个队列都有一个门铃指针。对于发送队列来说,这个指针表示的是发送队列的尾指针。主机端将数据写入到发送队列后,更新映射到位于设备寄存器空间中的门铃的尾指针。实现在SoC控制器芯片上的尾指针一旦被更新,设备就知道新数据到了。
这里并未涉及到主机端如何知道数据已经取走并且设备已经更新了头指针了。nvme协议并没有采用传统的查询寄存器的方式来让主机获得这个信息,因为这样势必造成CPU与硬件寄存器的交互。对于x86来说,每一次与硬件的交互都会带来性能的损失,因此降低硬件交互尤为重要。NVMe的方案是对于这个发送消息,在当它完成的时候会将完成的结果通过DMA的方式写入到内存中,主机根据每个IO请求及其完成请求中的Command Identifier (CID)字段来匹配相应的发送请求和完成请求。其中完成结果中携带有信息表明最新的该请求所对应的发送队列的当前头指针。
nvme_alloc_queue()nvmeq->cqes = dma_alloc_coherent(dmadev, CQ_SIZE(depth),…nvme_process_cq(){/* 检查dma完成请求中 strcut nvme_completion数组,确定发送队列的head指针 */struct nvme_completion cqe = nvmeq->cqes[head];free_cmdid(nvmeq, cqe.command_id, &fn)}
反过来,当设备端完成一个nvme请求时,也需要通过完成队列来把完成的结果告知主机端,这是通过完成队列来实现的。与发送队列不同,完成队列是通过中断机制告诉接收端(主机CPU)收到了新的完成消息并安排后续处理。同样的,为了确定完成队列里到底有多少是新的完成消息,在每一个完成请求中,有一个标志位phase,这个标志位每次写入的数值都会发生改变,并据此确定每一个完成请求是否是新的完成请求(比如一次完成请求phase为1,第二次完成请求phase值为0,从代码来看phase值初始值为1,说明设备发送给主机端phase的值第一次为1)。通过这种机制,虽然主机端不能一下子确定到底有多少新的完成请求,但是可以逐渐的、一步步完成所有的完成请求,并将完成队列用空。随着主机逐渐从完成队列里取出完成消息,主机会更新位于设备上的完成队列头指针寄存器,告诉设备完成队列的实施状况。
nvme_process_cq()/* 检查dma完成请求中 strcut nvme_completion数组,确定完成* 队列的head ,通过门铃通知设备 db(doorbeel) */head = nvmeq->cq_head;writel(head, nvmeq->q_db + (1 <dev->db_stride));
在最新的nvme1.2A中,每一个NVMeController允许最多65535个IO队列和一个Admin队列。Admin队列在设备初始化之后随即创建,包括一个发送队列和一个完成队列。其他的IO队列则是由Admin队列中发送的控制命令来产生的。nvme规定的IO队列的关系比较灵活,既可以一个发送队列对应一个完成队列,也可以几个发送队列共同对应一个完成队列。在主流的实现中,较多采用了一对一的方式。下图列举了两种方式的示意。
nvme_setup_io_queues()负责初始化队列,它先查询了到底有多少个CPU,然后再调用set_queue_count发命令给设备,让设备按照CPU的个数来设置队列的个数。
nvme_setup_io_queues()set_queue_count(dev, nr_io_queues);nvme_set_features(dev,NVME_FEAT_NUM_QUEUES, q_count, 0,&result);
在set_queue_count中,按照之前传入的CPU数量来设置设备的能力。其中NVME_FEAT_NUM_QUEUES对应于NVMe协议的Number of Queues (Feature Identifier 07h)。当然,根据设备能力不同,如果不巧设备刚好没办法支持这么多队列的话,驱动程序也会做一些取舍,选取设备的能力和CPU数量中较小的值。
参考: 晶格思维微信公众号
--The end--