提要
本系列文章,旨在带你开发一个NVMe SSD控制器的前端协议逻辑,只不过是在QEMU虚拟机环境中。上回说到了大白接受领导写到SQ队列的命令,设定计时器,让下面的小弟到时间后执行任务。本篇就来看SQ的命令怎么执行。你知道大部分SSD公司的工程师是工作在哪个SQ队列吗?阿呆将告诉你惊人的真相。
SQ的定时回调函数
其实我们在NVMe初始化的时候,就注册了SQ计时器对应的回调函数,sq_processing_timer_cb,所以计时器时间一到,这个函数就被调用了。这个函数的任务很简单,就是把所有的SQ队列扫描一遍,只要那个队列Head和Tail不相等,就意味着还有活没干完,就调用干活的函数process_sq去料理一下。如果料理完,还有没干完的活,说明在干活过程中勤快的大白又分派领导下发的命令了,所以再设定一个定时器,稍后继续干。唉,大白啊大白,怪不得领导那么信任你,谁叫你那么勤快呢?就是苦了俺们这帮干活的函数,每次听大白门铃一响,就不敢偷懒了:赶快干完活回家带孩子。
看得出来,process_sq是执行任务的核心,SQ有Admin队列和普通IO队列,这些都要在这个函数去区分执行。核心流程很清晰,如下,就是如果Admin队列,就调用nvme_admin_command执行Admin命令,IO队列,就调用nvme_command_set执行。执行完成后,填响应的内容到CQ条目中。post_cq_entry函数会把cqe的内容复制到CQ队列的尾巴上,并触发中断,向上级领导汇报工作成果。
1 if (sq_id == ASQ_ID) { 2 nvme_admin_command(n, &sqe, &cqe); 3 } else { 4 nvme_command_set(n, &sqe, &cqe); 5 } 6 7 /* Filling up the CQ entry */ 8 cqe.sq_id = sq_id; 9 cqe.sq_head = n->sq[sq_id].head; 10 cqe.command_id = sqe.cid; 11 post_cq_entry(n, &n->cq[cq_id], &cqe);
Admin队列的执行
上面说是所有的SQ都扫一遍,那第一个就是Admin队列了。nvme_admin_command的任务很简单,就是看Admin命令是什么,就调用对应的处理函数,如下。adm_cmds_funcs是个静态数组,里面是各种Admin命令的处理函数指针。阿呆哥还是决定把所有的函数都贴出来,满足你的好奇心。
1 f = adm_cmds_funcs[sqe->opcode]; 2 ret = f(n, sqe, cqe);
1 static adm_command_func * const adm_cmds_funcs[] = { 2 [NVME_ADM_CMD_DELETE_SQ] = adm_cmd_del_sq, 3 [NVME_ADM_CMD_CREATE_SQ] = adm_cmd_alloc_sq, 4 [NVME_ADM_CMD_GET_LOG_PAGE] = adm_cmd_get_log_page, 5 [NVME_ADM_CMD_DELETE_CQ] = adm_cmd_del_cq, 6 [NVME_ADM_CMD_CREATE_CQ] = adm_cmd_alloc_cq, 7 [NVME_ADM_CMD_IDENTIFY] = adm_cmd_identify, 8 [NVME_ADM_CMD_ABORT] = adm_cmd_abort, 9 [NVME_ADM_CMD_SET_FEATURES] = adm_cmd_set_features, 10 [NVME_ADM_CMD_GET_FEATURES] = adm_cmd_get_features, 11 [NVME_ADM_CMD_ASYNC_EV_REQ] = adm_cmd_async_ev_req, 12 [NVME_ADM_CMD_ACTIVATE_FW] = adm_cmd_act_fw, 13 [NVME_ADM_CMD_DOWNLOAD_FW] = adm_cmd_dl_fw, 14 [NVME_ADM_CMD_FORMAT_NVM] = adm_cmd_format_nvm, 15 [NVME_ADM_CMD_LAST] = NULL, 16 };
也许你觉得这些命令都很陌生,但是一旦你踏入NVMe SSD Firmware或者QA职业,那么很有可能,你每天的大部分时间就是在跟上面这些函数打交道,因为相比SSD控制器的FTL和后端Flash命令处理,前端的任务其实更琐碎,更多,而且一旦一个产品开发完成之后,FTL和后端的活很少,大部分人都要到前端处理NVMe的命令了。所以,也许对你来说,Admin队列才是养家糊口的饭碗,不要小看这些命令,每一个命令都有很多细节的协议,相当复杂,不夸张的说,如果公司大一点,这里面每个命令都可以养一个工程师。我们的社会也是如此,国家刚开始的时候,需要的是工程师领导人,建设祖国,等到经济发达了,大家都有钱了,需要的就是律师型领导人,这些人精通各种法律条文,擅长的不是建设,而是沟通,分配人群的利益,解决纠纷。本文题图就是喜剧明星金凯利演的律师,法律越复杂,律师的饭碗就越稳,协议越复杂,FW和QA的饭碗就越稳,总是有干不完的活啊!为什么阿呆这么清楚,因为阿呆当年刚入行的时候也是每天靠ATA协议吃饭,搞各种Smart Log,GPL Log之类,忙得不亦乐乎。
对用户来说,这些函数都挺重要的,因为它们是用户观察SSD内部的窗口。比如,我们要了解SSD内部情况,经常要查看Smart信息,处理Smart的函数流程是adm_cmd_get_log_page->adm_cmd_smart_info,里面是各种SSD的统计数据,比如Host读写命令的个数等。
PRP的原理
先来补补课,回顾一下《蛋蛋读NVMe之三》里面讲的PRP,这个是Host内存和SSD数据交互的内存页管理结构。
“NVMe把Host的内存划分为一个一个页(Page),页的大小可以是4KB,8KB,16KB… 128MB。
PRP是什么,长什么样呢?
PRP Entry本质就是一个64位内存物理地址,只不过把这个物理地址分成两部分:页起始地址和页内偏移。最后两bit是0,说明PRP表示的物理地址只能四字节对齐访问。页内偏移可以是0,也可以是个非零的值。
PRP Entry描述的是一段连续的物理内存的起始地址。如果需要描述若干个不连续的物理内存呢?那就需要若干个PRP Entry。把若干个PRP Entry链接起来,就成了PRP List。
是的,正如你所见,PRP List中的每个PRP Entry的偏移量都必须是0,PRP List中的每个PRP Entry都是描述一个物理页。它们不允许有相同的物理页,不然SSD往同一个物理页写入几次的数据,导致先写入的数据被覆盖。
每个NVMe命令中有两个域:PRP1和PRP2,Host就是通过这两个域告诉SSD数据在内存中的位置或者数据需要写入的地址。
PRP1和PRP2有可能指向数据所在位置,也可能指向PRP List。类似C语言中的指针概念,PRP1和PRP2可能是指针,也可能是指针的指针,还有可能是指针的指针的指针。别管你包的有多严实,根据不同的命令,SSD总能一层一层的剥下包装,找到数据在内存的真正物理地址。SSD善解人衣。
下面是一个PRP1指向PRP List的示例:
PRP1指向一个PRP List,PRP List位于Page 200,页内偏移50的位置。SSD确定PRP1是个指向PRP List的指针后,就会去Host内存中(Page 200,Offset 50)把PRP List取过来。获得PRP List后,就获得数据的真正物理地址,SSD然后就会往这些物理地址读入或者写入数据。”
读写IO命令执行
nvme_command_set函数内容如下,看得出来,包括读写IO命令和Trim命令:
1 if (sqe->opcode == NVME_CMD_READ ,, (sqe->opcode == NVME_CMD_WRITE)){ // 读写IO命令 2 return nvme_io_command(n, sqe, cqe); 3 } else if (sqe->opcode == NVME_CMD_DSM) { // Data Set Management,其实这就是Trim命令 4 return nvme_dsm_command(n, sqe, cqe); 5 } else if (sqe->opcode == NVME_CMD_FLUSH) { 6 return NVME_SC_SUCCESS;
一说到读写,你或许会觉得很复杂,不过阿呆要善意的提醒一句:这里是QEMU虚拟机,读写就是个读写文件,操作起来相当的easy。首先是通过LBA地址,计算出文件内将要读写的偏移地址和LBA个数。后面就是内存操作了,读就是往内存写数据,写就是从内存读数据。我们前文说过,QEMU通过mmap,把文件的操作等同于内存读写,读写文件的某个位置,就是操作指针和偏移地址。
比较绕的反而是PRP的操作。核心代码如下,首先对prp1进行读写,如果数据还没完,就看数据量是不是在一个page内,在的话,只需要读写prp2内存地址就可以了,数据量大于1个page,就需要读出prp list。
1 /* Writing/Reading PRP1 */ 2 res = do_rw_prp(n, e->prp1, &data_size, &file_offset, mapping_addr, 3 e->opcode); 4 5 if (data_size > 0) { 6 if (data_size <= PAGE_SIZE) { 7 res = do_rw_prp(n, e->prp2, &data_size, &file_offset, mapping_addr, 8 e->opcode); 9 } else { 10 res = do_rw_prp_list(n, sqe, &data_size, &file_offset, 11 mapping_addr); 12 } 13 }
do_rw_prp_list函数的内容和上面的理论一致,首先通过数据大小算出有多少个prp条目,然后从prp2内存地址读出prp list,接着遍历每个prp条目,读写内存地址,与SSD对应的文件指针进行数据搬移。
虚拟机的Trim命令
其实很简单,就是每个namespace维护了一个bitmap,有Trim命令来,就在bitmap做个标记而已。
NVMe的metadata机制
这里需要额外提一下,NVMe支持一种metadata机制,就是每个LBA有一段metadata,内容是什么完全看上级领导的心情,可以是校验位,也可以是其他的。metadata有两种传输方法,一种如下图,紧跟在LBA之后连续传输。
另一种是单独传输。请你往上翻翻有张图是NVMe命令的解析,紧跟在PRP1,PRP2之后的就是metadata pointer,指向的是metadata内存地址的PRP信息,所以虚拟机里面也有metadata文件来提供metadata的读写。代码很简单,就是DMA读写,所以阿呆就不复制粘贴了。
大结局
至此,阿呆实战NVMe算是写完了,真是虎头蛇尾啊,花了8篇写初始化,NVMe的具体实现却只用了2篇。主要还是因为我们是虚拟机,不管SSD的具体实现,只负责NVMe协议实现。对协议来说,万事开头难,开头初始化流程理清楚了,基本就知道NVMe和PCIe的虚拟化技术,后面的真正实现反而很简单,水到渠成。