type
status
date
slug
summary
tags
category
icon
password

NVMe相关

存储卡基础知识

接口:也就是设备如何与计算机通信。常见的存储设备接口包括:
  • SATA接口,通常用于2.5寸和3.5寸硬盘,有时候一些M.2设备也会使用
  • PCI Express(PCIe)接口, 用于M.2和PCIe设备
  • SAS(串行SCSI)和FC(Fibre Channel)接口,仅用于服务器领域和数据中心 PCIe接口要比SATA接口快的多,SATA3最大带宽是6Gb/s,而基于4X PCIe的M.2接口最大可以达到32Gb/s。
协议:定义了如何在计算机与设备之间传输数据。常见的协议包括:
  • 用于SATA接口的AHCI或者ATA协议
  • 用于PCIe接口的NVMe协议
💡
这里主要是注意一些英文名词
下图为存储卡与主机通信的协议栈(与计网很类似):
notion image

NVMe协议

在NVMe协议中,有三个主要部分:SQ(Submission Queue)、CQ(Completion Queue)、DB(DoorBell Register)

SQ

主要功能:存储host需要发送给SSD的命令,由host写入;而SSD从SQ中取出指令并执行。

CQ

主要功能:存储指令执行的结果,由SSD写入;而又host从CQ中取出数据,以了解指令执行的情况
💡
SQ/CQ分为Admin 与IO,Admin SQ中存储的是Admin指令,而IO SQ中存储的是IO指令。一个系统中只有一对Admin SQ/CQ,但可以有多对IO SQ/CQ

SQ与CQ的一些注意点

  • SQ用以Host发命令,CQ用以SSD回命令完成状态
  • SQ/CQ可以在Host的内存中,也可以在SSD中,但一般在Host 内存中(所有系列文章都是基于SQ/CQ在Host内存中讲的);
  • 两种类型的SQ/CQ:Admin和I/O,前者发送Admin命令,后者发送I/O命令;
  • 系统中只能有一对Admin SQ/CQ,但可以有很多对I/O SQ/CQ;
  • I/O SQ与CQ可以是一对一的关系,也可以是一对多的关系;
  • I/O SQ是可以赋予不同优先级的;
  • I/O SQ/CQ深度可达64K,Admin SQ/CQ深达4K;
  • I/O SQ/CQ的广度(队列数量)和深度(队列可以容纳的元素个数)都可以灵活配置;
  • 每条命令大小是64字节,每条命令完成状态是16字节。

DB

主要功能:从名称可以看出,DoorBell(门铃)。当host将指令写入SQ后,需要通过DB寄存器通知SSD。DB也用来记录一个SQ或者CQ的Head和Tail。每个SQ或者CQ,都有两个对应的DB: Head DB和Tail DB

三者的协同工作与NVMe协议

总体框架图如下:
notion image
💡
SQ与CQ可能在host的内存中,也有可能在SSD中,但是资料上是以在host中为例的
NVMe协议过程为:
  1. Host写命令到SQ;
  1. Host写DB,通知SSD取指;
  1. SSD收到通知,于是从SQ中取指;
  1. SSD执行指令;
  1. 指令执行完成,SSD往CQ中写指令执行结果;
  1. 然后SSD发短信通知Host指令完成;
  1. 收到短信,Host处理CQ,查看指令完成状态;
  1. Host处理完CQ中的指令执行结果,通过DB回复SSD:指令执行结果已处理,辛苦您了!
💡
可以发现在修改DB的时候实际上就带上了DB的通知功能

一个例子

  1. 开始假设SQ1和CQ1是空的,Head = Tail = 0.
    1. notion image
  1. Host在往SQ1写入三个命令后,同时更新SSD Controller端的SQ1 Tail DB寄存器,值为3。Host更新这个寄存器的同时,也是在告诉SSD Controller:有新命令了,需要你去取。
    1. notion image
  1. SSD Controller收到通知后,于是派人去SQ1把3个命令都取回来执行。SSD把SQ1的三个命令都消费了,SQ1的Head DB从而也调整为3
    1. notion image
  1. SSD执行完了两个命令,于是往CQ1中写入两个命令完成信息,同时更新CQ1对应的Tail DB 寄存器,值为2。SSD并且发消息给Host:有命令完成,请注意查看。
    1. notion image
  1. Host收到SSD的短信通知,于是从CQ1中取出那两条完成信息处理。处理完毕,Host又往CQ1 Head DB寄存器中写入CQ1的head,值为2。
    1. notion image
💡
在这个过程中可以发现host实际上只有对SQ Tail DB的写权限和对CQ head DB的写权限。

一个问题

Q:host在更新寄存器的时候需要检查SQ的头(防止SQ元素的覆盖)和CQ的尾(防止读取无效数据),但是host自己只知道SQ的尾和CQ的头。因此需要通过某种方式使host知道相关的信息
NVMe协议的处理为:在CQ元素中提供相应的信息
notion image
  • SQ的头:通过CQ元素中第三字节的后半字提供CQ对应的SQ指针(也是SQ在某一时刻的头指针)
  • CQ的尾:通过标志位P进行判断。当CQ初始化的时候,所有元素的P标志位都会被置为“0”,而在写入一条有效数据的时候,该有效数据中的P标志位为“1”(按道理来说host读取之后也要将该位置0),这样主机就知道CQ中的元素哪些是有效元素了
    • 一个图解:
      notion image
💡
在CQ元素中提供CQ的信息很好理解,但是提供SQ的信息就不太好理解了。这里需要理解清除CQ和SQ的关系。只有当一条指令被执行结束之后,才会有对应的CQ元素。因此SQ head的移动是与CQ密切相关的;同时CQ元素还需要体现出相应的是哪一条指令,以便进行相关的处理,所以CQ元素中是直接提供其相应指令的SQ指针(一个猜测:虽然上面写的是SQ head Ptr,但是实际上就是一个CQ相应的SQ的指针,因为在生成CQ的时候,应该已经记录了对应指令的SQ位置,而该位置正好是SQ的头,因为一个指令被取出来执行的时候,SQ的头指针就是该指令的位置。而只要在取出指令的时候,SSD马上更新SQ head,就可以保证这个过程是正确的了)

PRP池

在NVMe设备与主机通信的过程中:
  • 数据传送方向为host→SSD时,SSD将根据host的write指令从指定的内存地址读取若干数据,并建立数据的内存地址与SSD上的存储地址之间的映射关系
  • 数据传送方向为SSD→host时,SSD将根据host的read指令,通过查表找到SSD上的数据并传送到指定的内存地址中。
💡
可以发现host都是被动的
在进行数据传送的过程中,host一定要告诉NVMe设备数据的内存地址(应该是物理地址,因为虚拟地址会重复),而PRP就是一种方式。
  • PRP项
    • PRP项的格式如下
      notion image
      不难发现,一个PRP项就是一个物理内存地址,并且很像内存的页表机制,将一个物理地址划分为了页号和页内偏移。
  • PRP列表
    • 将若干个PRP项组织起来,就形成了PRP列表。PRP列表长用于表示若干个不连续的物理内存
      💡
      PRP列表通常使用数组的形式进行组织,并且当一个PRP列表不够使用时,会将PRP列表的最后一个PRP项用于指示下一个PRP列表。
      一个PRP项指向PRP列表的例子如下:
      notion image
      notion image
不难发现,PRP列表与内存的页表非常相似,只不过一个PRP项对应的是一个物理页面的起始地址(所以Offset通常为0);而页表机制中,一个页表项对应的就是一个物理地址

SGL

  • SGL描述符
    • SGL描述符的作用与PRP项的作用很像,也是用于描述一段连续的内存空间。SGL描述符包含的信息为其描述的内存空间的起始地址以及内存空间的长度。
  • SGL段
    • SGL段由多个SGL描述符组成。此外SGL段中还存在一个段描述符,用于将SGL段构建为一个SGL。
  • SGL
    • SGL(Scatter Gather List)是一个链表,其中链表的每一个节点都是一个SGL段,这些通过SGL段描述符链接在一起(即SGL段描述符描述的是它下个Segment所在的空间)。所以不难发现,一个SGL是由多个碎片化的存储空间组成的数据空间,这个空间可以是数据源所在的空间(向SSD卡中写入数据时数据所在的内存空间),也可以是数据目标空间(从SSD卡中读取数据时用于存储该数据的内存空间)。
SGL组织模式如下
notion image
💡
需要注意的是,在倒数第二个SGL段中,其段描述符被称为Last Segment描述符。当SSD在解析SGL的时候,碰到SGL Last Segment描述符,就知道链表即将遍历结束
从上图中也可以看见,一个SGL将一个逻辑上连续的NVMe逻辑块映射到了一段不连续的存储空间中,进而提高了存储空间的利用率。

PRP与SGL的不同点

  • SGL不仅提供了一个内存基地址,还提供了该段内存的大小信息(length),PRP只提供了一个基地址,内存大小需要SSD根据命令上下文去猜;
  • SGL可描述任意的内存空间,相对PRP来说更灵活,后者基本按页访问内存;
  • 另外SGL提供了一个Bit Bucket的东西,一段连续的LBA空间,其中的一些数据我可以不需要传输,PRP好像做不到这点。
💡
就我自己的理解而言,SGL相较于PRP而言,管理的内存空间更小一点,更有利于提高内存的利用率;而PRP以物理页面(或者说较大块的物理内存,后面在阅读源码的时候能发现)为单位,操作比较简单,可以有效提高处理的速度。
这两种方式同时存在就可以根据NVMe设备的要求以及内存空间的情况选择更优的策略,灵活处理IO操作

Linux内核的编译与运行

以下的所有代码都放在了github仓库中:
参考:

Linux Kernal下载

💡
我选择的是v4.19.127v5.15.1(换了一个内核编译是因为我的ubuntu不能使用v4的内核)。可以通过下面的指令查看当前ubuntu可以使用的linux内核版本:
正常下载之后解压就好了。

编译Linux Kernal

解压之后进入解压文件的目录,并执行以下指令生成config文件:
💡
这一行指令的含义是,使用当前机器的配置(也就是我的Ubuntu)来配置我现在要编译的Linux Kernal。从cp指令也可以看出来。
此外,这个config文件的配置比较麻烦,我感觉跟学习的主要内容没什么关系,就直接复制了)
复制之后输入以下指令:
这个指令会打开一个配置界面,这里才是实际上进行内核配置的地方。只不过在这里的配置界面中,我们会直接使用上面复制过来的.config文件来配置当前要编译的内核。
接下来就可以直接使用make指令进行内核编译了:
💡
一个小技巧,当多线程编译的时候出现错误时,报错不会很详细(只会显示是哪个文件或目录的编译出错)。想要具体了解编译时出现了什么问题可以直接使用make指令。在这里我就是发现错误之后直接使用make指令对内核代码稍微修改了一下才通过的
让我们再回到Linux Kernal的config环节,在config文件中,有以下的配置:
这里的“m”就表示将NVMe作为模块引入,可以安装也可以卸载。
编译结束之后,在终端中可以看见:
就说明我们的编译成功了,bzImage文件就是linux内核的镜像文件,可以直接装载进内存

运行Linux Kernal

在编译成功之后,使用以下指令:
这条指令将会把所有的模块安装在当前文件系统的/lib/modules/版本号 目录下
💡
在我的学习过程中,版本号为:4.19.127,原内核的版本号为:6.8.0-47-generic
接下来使用:
通过这条指令就将把内核安装到/boot 目录下,并更新GRUB的配置,以便在后续选择内核。
在重启之前,最好再手动更新一下GRUB:
💡
经过测试,好像使用install伪目标的时候并不会更新GRUB,还是手动更新一下吧
接下来就可以重启系统,然后进入GRUB界面选择系统了
💡
进入GRUB界面的方式为在系统启动的时候按住shift键
如果需要替换ubuntu的linux内核,需要进入ubuntu的高级设置:
notion image
在这里面就可以选择ubuntu使用的linux内核了(在这里也能找到我们自己编译的4.19.1275.15.10版本):
notion image
之后就可以进入系统了:
notion image

模块的安装与下载

参考:
由于手头没有NVMe设备(我也不知道自己的机器上有没有,就算有我也不知道怎么才能把NVMe设备连接到ubuntu上。。),所以我没有办法通过查看/dev目录来查看NVMe模块是否被装载/卸载,所以我只能使用lsmod指令来看当前内核是否装载了NVMe模块了
首先查看当前内核是否装载了NVMe模块:
安装NVMe模块:
同样使用lsmod | grep nvme指令查看当前内核是否装载了NVMe模块
接下来使用下面的指令卸载NVMe模块:
最后再次使用lsmod | grep nvme 指令查看当前内核是否卸载掉了NVMe模块。
最后附上指令运行结果:
notion image

源码阅读

工具配置

在阅读源码之前,先配一下clangd,不然好像没办法进行跳转。
在Makefile中加入:
以便在使用Makefile进行构建的时候能生成compile_commands.json 文件。
💡
这里需要使用UBUNTU_VERSION 变量是因为bear在不同版本的Ubuntu下使用有一定的区别
编译时需要使用下面的指令进行编译:
编译之后就可以正常进行定义跳转了。

源码阅读

文档中要求阅读的函数(我原来还以为每一个条目对应的是一个文件。。。)都在文件/drivers/nvme/host/pci.c中。
这个文件的码量还是有点大的(近3k行),全部读下来还是有点困难,所以就只读文档中的阅读重点了。。。
我阅读源码的方式为:看代码,并直接为源码补充注释
在开始看函数功能之前,需要先了解以下几个数据结构:
这个结构体用于表示一个NMVe设备,相当于一个设备的“DCB”(设备控制块)
接下来就正式进入函数代码阅读
  • nvme_probe:将一个PCI设备初始化为一个NVMe设备,其中包含了分配DCB等操作
    • 代码如下:
  • nvme_dev_map:实现设备物理内存地址到内核虚拟空间的映射
    • 代码如下:
  • nvme_setup_prp_pools:设置NVMe设备的PRP池
    • 代码如下:
  • nvme_pci_configure_admin_queue:配置admin队列
    • 代码如下:
  • nvme_setup_io_queues:配置io队列
    • 代码如下:
  • nvme_queue_rq:处理NVMe请求
    • 代码如下:
  • nvme_setup_cmd:封装命令的部分字段(也就是初始化cmnd结构体)结合command manual看,重点关注r/w command(本函数在core.c中被定义)
    • 代码如下:
  • nvme_pci_use_sgls:判断使⽤prp还是sgl。当这个函数返回true时就表示在当前请求中最好使用sgl描述涉及的内存空间;返回false就表示在当前的请求中最好使用prp来描述涉及的内存空间
    • 代码如下:
  • nvme_map_data:如何映射prp/sgl
    • 代码如下:
  • nvme_submit_cmd:命令提交时修改了哪个db
    • 代码如下:
  • nvme_write_sq_db:写入DB寄存器
    • 代码如下:
  • nvme_irq:中断处理函数(在queue_request_irq中被使用,可能是top handler,也可能是bottom handler)
    • 代码如下:

杂记

  • ubuntu下查看Linux Kernal的版本
    • NUMA 节点
      • NUMA(Non-Uniform Memory Access node,非一致性内存访问节点) 是一种内存架构,其中系统内存被划分为多个节点,每个节点与一个或多个处理器紧密耦合。
        获取设备的 NUMA 节点有助于优化内存分配和性能,因为可以将内存分配到与设备或处理器更接近的节点上。
        NUMA内存架构框架图如下:
        notion image
        💡
        在这里QPI总线与IMC总线速度速度相差较大,因此左侧的CPU想要访问右侧的内存,速度就会慢很多。同时也可以发现,NUMA架构实际上就是为了多核服务的,并且一个NUMA节点是由多个内存块组成的,这些内存块与若干个核是紧耦合的(在现在的使用场景下,一个NUMA节点应该也与外设是紧耦合的,因为外设需要在内存中保存一些信息)。
        参考:
    • Linux中的工作队列
      • 可以理解为工作队列(是一个work_struct链表)维护了一堆回调函数,以便在特定的时间点可以调用该回调函数。
        此外,工作队列是属于系统的资源,而不是独属于某一个进程(或线程)。当一个工作项被执行了之后,该工作项就会从工作队列中被移除。
        💡
        通过回调函数的机制就可以实现异步调用
    • QEMU的安装
      • 安装x86架构的qemu模拟器:
    • Linux中的completion结构体
      • completion结构体的定义如下:
        在wait是一个双向链表的头指针,其中有一个自旋锁,用于保证等待队列的互斥访问。
        在linux中,completion结构体主要用于实现线程之间的同步。当completion→done被置为1之后,才会将wait队列中的所有任务;当completion→done为0时,wait队列中的任务就将一直等待直到done为1。

    一些坑

     
    Paper2PX4
    Loading...
    Noah
    Noah
    永远年轻,永远热泪盈眶
    公告
    ❗❗复习笔记问题❗❗
    由于兼容性问题
    导入md文件可能导致了一些格式错误
    🌹如发现格式错误,请联系我~🌹
    🌹如博客内容有误也欢迎指出~🌹