资讯动态

从零实现x86操作系统PCIe枚举与NVMe驱动开发实战

发布时间:2026/10/24 3:14:23 来源:尧图企业网站定制
1. 从零开始理解PCIe与NVMe为什么操作系统必须自己管硬盘搞操作系统开发到一定阶段你一定会碰到存储这块硬骨头。前面把中断、内存管理、进程调度都跑通了键盘能敲了屏幕能显示了但系统一重启数据就全没了——这时候你就得面对PCIe和NVMe这两个东西。我当初做这块的时候翻了不少资料发现大部分文章要么只讲协议规范要么只讲Linux驱动怎么调真正从零写一个能跑的操作系统角度出发的内容少得可怜。所以这篇东西我打算把我在实现x86操作系统存储子系统时踩过的坑、想明白的道理、以及最终跑通的方案完整地分享出来。先说清楚这个项目是干什么的。简单讲就是在自己写的x86操作系统里实现PCIe总线的枚举和配置然后基于PCIe找到NVMe固态硬盘写一个NVMe驱动程序最终让操作系统能够对硬盘进行读写操作。这中间涉及的东西非常多PCIe的配置空间怎么访问、BAR怎么映射、NVMe的队列怎么建立、命令怎么提交、中断怎么处理、DMA怎么配合。每一个环节都有坑而且这些坑在文档里往往不会告诉你。适合谁看如果你已经写过简单的操作系统内核知道怎么处理中断、怎么管理物理内存和虚拟内存那这篇内容就是为你准备的。如果你还没到这一步建议先把基础打牢因为PCIe和NVMe的驱动开发对底层功底要求比较高。但如果你只是想了解NVMe的工作原理或者想看看一个真实的存储驱动是怎么从零搭起来的那也可以读下去我会尽量把每个概念都讲清楚。为什么选择PCIe和NVMe而不是老旧的IDE或者AHCI这个问题我当初也纠结过。IDE接口太老速度慢而且现在的硬件基本不支持了。AHCI是SATA的控制接口虽然比IDE好很多但SATA本身的带宽有限6Gbps到头了。NVMe是专门为固态硬盘设计的协议走PCIe通道带宽可以到几十Gbps而且队列深度大、延迟低。既然是从零写操作系统那当然要选当前主流且面向未来的方案。而且NVMe的协议设计比AHCI简洁很多没有那么多历史包袱对于操作系统开发者来说反而更容易实现。不过这里有个前提你得先让PCIe总线能工作。NVMe硬盘是挂在PCIe总线上的设备操作系统必须先枚举PCIe总线找到NVMe控制器读取它的BARBase Address Register映射到内存空间然后才能访问NVMe的寄存器。所以整个项目的顺序是先搞PCIe枚举再搞NVMe驱动。这个顺序不能乱否则你连硬盘的寄存器都摸不到。我当初犯过一个错误就是先去看NVMe协议觉得NVMe的队列机制很清晰想先把NVMe驱动写出来然后再补PCIe的部分。结果发现根本没法调试因为连设备都找不到。后来老老实实从PCIe枚举开始一步一步来才把整个链路打通。所以这里也提醒你不要跳步底层的总线枚举是基础基础不牢后面全是空中楼阁。还有一个问题需要提前想清楚你的操作系统是跑在什么环境下的如果是真机那PCIe枚举和NVMe驱动都要考虑各种硬件差异调试起来很麻烦。如果是虚拟机比如QEMU那会简单很多因为QEMU模拟的NVMe设备行为比较规范而且你可以用QEMU的调试功能来观察PCIe配置空间和NVMe寄存器的状态。我建议先在QEMU上把整个流程跑通然后再考虑真机适配。QEMU的好处是你可以随时看到设备的状态出问题了也容易定位。真机上如果出了问题你可能连是硬件问题还是软件问题都分不清。另外NVMe驱动涉及DMA操作你的操作系统需要支持物理地址和虚拟地址的转换还需要分配物理上连续的内存作为DMA缓冲区。这些基础设施如果没准备好NVMe驱动也写不下去。所以我在正文开始之前先把这些前置条件列一下你可以对照检查操作系统已经能处理外部中断并且知道怎么注册中断处理程序。操作系统有物理内存管理器能分配和释放物理页。操作系统有虚拟内存管理器能把物理地址映射到虚拟地址。操作系统有基本的PCI设备访问能力至少能读写IO端口或者MMIO。有一个可靠的调试手段比如串口输出或者QEMU的调试端口。这些条件都满足了那就可以开始了。下面我会按照实际实现的顺序从PCIe枚举讲起然后讲NVMe控制器的初始化再讲队列的建立和命令的提交最后讲读写操作的完整流程。每一部分我都会给出关键代码片段和参数计算过程以及我在调试过程中遇到的问题和解决方法。2. PCIe总线枚举与配置空间访问找到那块NVMe硬盘2.1 PCIe配置空间的两种访问方式IO端口和MMIOPCIe的配置空间是每个PCIe设备都有的一个标准寄存器区域大小是4KB偏移0x00到0xFF是PCI兼容的配置空间偏移0x100到0xFFF是PCIe扩展配置空间。操作系统要访问这个区域有两种方式一种是通过IO端口也就是CF8h和CFCh这两个端口另一种是通过MMIO也就是把配置空间映射到内存地址空间然后像读写内存一样读写配置空间。在早期的PCI时代只能用IO端口方式。这种方式的操作很简单往CF8h端口写入要访问的总线号、设备号、功能号和寄存器偏移然后从CFCh端口读取或者写入数据。具体格式是这样的CF8h是一个32位寄存器最高位是使能位必须置1接下来8位是总线号5位是设备号3位是功能号最后8位是寄存器偏移但要注意寄存器偏移的低2位必须是0因为CFCh是32位对齐访问的。所以如果你要访问偏移0x10的寄存器实际上写入CF8h的值是0x80000000 | (bus 16) | (dev 11) | (func 8) | 0x10。MMIO方式就简单多了你只需要从ACPI的MCFG表里找到配置空间的基地址然后加上总线号、设备号、功能号和偏移就能直接访问。MMIO方式的好处是速度快而且可以访问扩展配置空间。但MMIO方式需要ACPI支持如果你的操作系统还没解析ACPI表那就只能用IO端口方式。我当初是先实现了IO端口方式因为不需要ACPI代码量也少。后来为了访问扩展配置空间才补上了MMIO方式。对于NVMe驱动来说其实PCI兼容的配置空间就够了因为NVMe控制器的主要寄存器都在BAR里配置空间里只需要读一下设备ID、厂商ID和BAR就行。所以如果你只是想快速跑通IO端口方式完全够用。这里有一个细节需要注意IO端口方式访问配置空间时每次只能读写32位。如果你要读一个16位的寄存器比如设备ID你需要先读32位然后取低16位或者高16位。这个操作在代码里要封装好否则很容易出错。我当初就是没注意这个读设备ID的时候直接读了一个32位结果把厂商ID也读出来了导致判断设备类型的时候出错。2.2 枚举PCIe总线的完整流程PCIe总线的枚举是一个递归的过程。根总线是0号总线上面可能有多个设备每个设备可能有多个功能。对于每个功能你需要读取它的厂商ID和设备ID如果厂商ID是0xFFFF说明这个功能不存在。如果存在你还需要读取它的类别代码判断它是哪种设备。如果是PCI桥那它下面还有子总线你需要递归枚举。具体步骤是这样的从总线0开始遍历设备号0到31功能号0到7。对于每个设备号和功能号读取厂商ID。如果厂商ID是0xFFFF跳过。读取类别代码、子类别代码和编程接口。类别代码0x01是存储控制器子类别0x08是NVMe控制器编程接口0x02表示NVMe。如果是NVMe控制器记录它的总线号、设备号、功能号以及BAR地址。如果类别代码是0x06子类别是0x04说明是PCI桥读取它的次级总线号然后递归枚举次级总线。这个流程看起来简单但实际写代码的时候有几个坑。第一个坑是设备号和功能号的遍历顺序。标准做法是先遍历功能号再遍历设备号但有些资料写反了。实际上顺序无所谓只要你能遍历到所有功能就行。第二个坑是PCI桥的次级总线号。有些桥的次级总线号是动态分配的你需要先给桥分配一个总线号范围然后再枚举。但在简单的系统里BIOS或者固件已经帮你分配好了你直接读就行。第三个坑是多功能设备。有些设备有多个功能比如一个PCIe设备可能同时有NVMe控制器和网卡。你需要遍历所有功能号不能只检查功能0。我当初在QEMU上测试的时候发现NVMe控制器在总线0、设备4、功能0上。这个位置不是固定的取决于QEMU的命令行参数。如果你用-device nvmeQEMU会自动分配一个位置。你可以用-device nvme,buspcie.0,addr0x4来指定位置。我建议在调试阶段指定一个固定的位置这样方便你写代码的时候硬编码测试。等驱动稳定了再改成动态枚举。2.3 BAR寄存器的读取与映射找到NVMe控制器之后下一步是读取它的BAR寄存器。BAR是Base Address Register的缩写每个PCIe设备最多有6个BAR偏移从0x10到0x24。BAR寄存器里存储的是设备需要的地址空间基地址以及地址空间的类型和大小。BAR的格式是这样的最低位是类型指示位0表示内存空间1表示IO空间。对于内存空间接下来的两位是地址类型00表示32位地址10表示64位地址。剩下的位是基地址但低几位是只读的用来指示地址空间的大小。具体来说你可以通过写入全1然后读回来看看哪些位是0从而计算出地址空间的大小。NVMe控制器通常有两个BARBAR0和BAR1。BAR0是NVMe的主寄存器区域大小通常是16KB或者更大。BAR1是MSI-X表区域大小通常是16KB。BAR0可能是64位的也就是说BAR0和BAR1组合起来表示一个64位地址。这时候BAR1就不能单独使用了它只是BAR0的高32位。读取BAR的步骤读取BAR0的原始值。判断最低位如果是1说明是IO空间NVMe控制器一般不用IO空间所以可以跳过。判断地址类型如果第2、3位是10说明是64位地址需要同时读取BAR1作为高32位。计算地址空间大小把原始值写入BAR0然后读回来取反加1就是地址空间的大小。恢复BAR0的原始值。这里有一个关键点在计算地址空间大小的时候你需要先保存原始值写入全1读回来然后再恢复原始值。这个操作会暂时修改BAR的值如果此时有其他代码在访问这个BAR可能会出问题。所以最好在单线程环境下做这个操作或者在操作之前先关闭中断。我当初在QEMU上读NVMe的BAR0发现它是一个64位内存空间大小是16KB。BAR0的低32位是0xFEBD0000BAR1的高32位是0x00000000。所以NVMe控制器的寄存器基地址是0xFEBD0000。这个地址是物理地址我需要把它映射到虚拟地址空间然后才能访问。映射的时候要注意NVMe的寄存器区域需要映射为不可缓存uncached的因为设备寄存器不能被CPU缓存否则读写顺序会乱。在x86上你可以通过设置页表的PCD位Page Cache Disable来实现不可缓存映射。如果你用的是恒等映射那也要确保这一块区域的缓存属性是正确的。2.4 中断配置从INTx到MSI-XNVMe控制器支持两种中断方式INTx和MSI-X。INTx是传统的中断方式通过PCIe的INTx信号线发送中断。MSI-X是消息信号中断通过写内存的方式发送中断支持多个中断向量。NVMe推荐使用MSI-X因为它的队列很多每个队列都可以有自己的中断向量。在QEMU上NVMe控制器默认可能使用INTx也可能使用MSI-X取决于你的配置。如果你用-device nvme默认是INTx。你可以用-device nvme,msixon来启用MSI-X。我建议在调试阶段先用INTx因为INTx的中断处理比较简单你只需要在中断处理程序里读取NVMe的中断状态寄存器判断是哪个队列产生了中断然后处理就行了。等基本功能跑通了再切换到MSI-X。INTx的中断号是从PCI配置空间的0x3C寄存器读取的这个寄存器叫中断线寄存器Interrupt Line。它的值就是IRQ号。在QEMU上NVMe的IRQ号通常是11。你需要把这个IRQ号注册到你的中断处理框架里当这个IRQ触发时调用NVMe的中断处理程序。MSI-X的配置就复杂一些。你需要先读取BAR1的地址然后找到MSI-X表。MSI-X表里每个条目包含一个消息地址和消息数据。你需要把消息地址设置为本地APIC的地址消息数据设置为中断向量号。然后设置MSI-X的使能位关闭INTx。当NVMe控制器产生中断时它会往你设置的消息地址写入消息数据从而触发中断。我当初在实现MSI-X的时候卡了很久。问题出在消息地址的设置上。在x86上MSI-X的消息地址应该是0xFEE00000加上目标APIC ID左移12位。我一开始没加APIC ID结果中断发到了错误的CPU上导致中断处理程序没被调用。后来查了资料才发现这个问题。所以如果你也要实现MSI-X一定要注意消息地址的格式。3. NVMe控制器初始化从复位到就绪3.1 NVMe寄存器概览NVMe控制器的寄存器都在BAR0里偏移从0x00到0x3FFF。主要的寄存器有0x00CAP控制器能力寄存器64位。包含队列深度、超时时间、支持的命令集等信息。0x08VS版本寄存器32位。包含NVMe协议的版本号。0x0CINTMS中断掩码设置寄存器32位。0x10INTMC中断掩码清除寄存器32位。0x14CC控制器配置寄存器32位。包含使能位、命令集、页大小、队列深度等。0x1CCSTS控制器状态寄存器32位。包含就绪位、错误位等。0x20NSSRNVM子系统复位寄存器32位。0x24AQA管理队列属性寄存器32位。包含管理队列的深度和连续标志。0x28ASQ管理队列基地址寄存器64位。管理队列的物理地址。0x30ACQ管理完成队列基地址寄存器64位。管理完成队列的物理地址。这些寄存器都是内存映射的你可以像读写内存一样读写它们。但要注意有些寄存器是只读的有些是只写的有些是读写但需要特定顺序。比如CC寄存器你需要先读修改需要的位然后再写回去。不能直接写一个值否则可能会把其他位清零。3.2 复位控制器与等待就绪在初始化NVMe控制器之前你需要先复位它。复位的步骤是读取CC寄存器清除EN位bit 0然后写回。这会禁用控制器。等待CSTS寄存器的RDY位bit 0变为0。这表示控制器已经停止。如果需要可以写NSSR寄存器来复位NVM子系统。复位之后你需要配置管理队列。管理队列是NVMe控制器用来接收管理命令的队列比如创建IO队列、获取设备信息等。管理队列包括一个提交队列ASQ和一个完成队列ACQ。你需要分配物理上连续的内存作为这两个队列然后把它们的物理地址写入ASQ和ACQ寄存器。管理队列的深度由AQA寄存器设置。AQA的低12位是提交队列深度减1高12位是完成队列深度减1。比如你要设置提交队列深度为64完成队列深度为64那AQA的值就是(63 16) | 63。注意队列深度必须是2的幂而且不能超过CAP寄存器里报告的最大值。分配管理队列内存的时候要注意对齐。提交队列的基地址需要4KB对齐完成队列的基地址也需要4KB对齐。每个提交队列条目是64字节每个完成队列条目是16字节。所以64深度的提交队列需要64 * 64 4096字节正好一页。64深度的完成队列需要64 * 16 1024字节不到一页但也需要分配一页因为基地址要4KB对齐。我当初在分配管理队列内存的时候忘了对齐结果控制器报错CSTS寄存器的RDY位一直不置1。后来查了规范才发现对齐要求。所以这里一定要小心分配内存的时候要确保物理地址是4KB对齐的。如果你的物理内存管理器不能保证对齐那你需要自己实现一个对齐分配函数。3.3 配置管理队列与使能控制器管理队列的配置步骤分配提交队列内存清零把物理地址写入ASQ寄存器。分配完成队列内存清零把物理地址写入ACQ寄存器。设置AQA寄存器指定队列深度。读取CC寄存器设置EN位为1设置CSS位为0NVM命令集设置MPS位为04KB页大小设置IOSQES为6提交队列条目大小64字节设置IOCQES为4完成队列条目大小16字节。然后写回CC寄存器。等待CSTS寄存器的RDY位变为1。这表示控制器已经就绪可以接收命令了。这里有一个细节IOSQES和IOCQES的值是条目大小的对数。提交队列条目是64字节2的6次方是64所以IOSQES是6。完成队列条目是16字节2的4次方是16所以IOCQES是4。这两个值必须设置正确否则控制器会报错。等待RDY位的时候要加超时。如果超时了说明初始化失败你需要检查前面的步骤有没有问题。常见的失败原因包括队列内存没有对齐、队列深度设置错误、CC寄存器的其他位没有正确设置。我当初就是IOSQES设错了设成了5结果控制器一直不就绪。后来改成6就好了。3.4 识别控制器与命名空间控制器就绪之后你可以发送Identify命令来获取控制器和命名空间的信息。Identify命令是管理命令的一种通过管理队列提交。Identify命令有两种一种是Identify Controller返回控制器的详细信息包括型号、序列号、支持的队列数等另一种是Identify Namespace返回命名空间的信息包括容量、扇区大小等。提交Identify命令的步骤在提交队列里找到一个空闲的条目。提交队列的每个条目有一个阶段位Phase Tag初始为0。当你提交一个命令时你需要把阶段位设置为当前阶段。控制器处理完命令后会把完成队列条目的阶段位设置为相同的值。所以你可以通过阶段位来判断哪些条目是空闲的。填充命令条目。命令条目是64字节包含操作码、命令ID、命名空间ID、数据缓冲区地址等。更新提交队列的尾门铃寄存器。尾门铃寄存器在BAR0的偏移0x1000加上队列ID乘以4。对于管理队列队列ID是0所以尾门铃寄存器在0x1000。等待完成队列。完成队列的条目有阶段位当阶段位翻转时表示命令完成。读取完成队列条目的状态字段判断命令是否成功。Identify Controller命令的操作码是0x06命令ID可以随便设一个唯一值。数据缓冲区需要分配4KB的物理连续内存用来存放返回的控制器信息。Identify Namespace命令的操作码也是0x06但需要设置命名空间ID数据缓冲区也是4KB。我当初在提交Identify命令的时候忘了更新尾门铃寄存器结果命令一直没被处理。后来查了规范才发现提交命令后必须写尾门铃寄存器否则控制器不知道有新命令。这个门铃寄存器的机制是NVMe的核心后面IO命令也要用到。4. IO队列的建立与命令提交4.1 创建IO提交队列和完成队列管理队列只能用来发送管理命令比如Identify、Create IO Queue等。要读写硬盘你需要创建IO队列。IO队列的创建也是通过管理命令来完成的。Create IO Submission Queue命令的操作码是0x01Create IO Completion Queue命令的操作码是0x05。创建IO完成队列的步骤分配完成队列内存清零确保物理地址4KB对齐。构造Create IO Completion Queue命令。命令里需要指定队列ID、队列深度、中断向量、中断使能等。提交命令到管理队列等待完成。检查完成状态如果成功完成队列就创建好了。创建IO提交队列的步骤类似但需要指定提交队列的优先级和关联的完成队列ID。提交队列和完成队列是一一对应的每个提交队列都有一个关联的完成队列。这里有一个细节队列ID的分配。管理队列的ID是0IO队列的ID从1开始。你可以创建多个IO队列每个队列有自己的ID。NVMe控制器支持的最大队列数在CAP寄存器里报告。在QEMU上通常支持64个队列。你可以只创建一个IO队列也可以创建多个。对于简单的操作系统一个IO队列就够了。中断向量的设置也很重要。如果你用的是INTx中断向量可以设为0因为INTx不区分队列。如果你用的是MSI-X你需要为每个完成队列分配一个中断向量。我建议在调试阶段先用INTx这样中断处理简单不需要为每个队列单独处理。4.2 提交队列与完成队列的内存布局提交队列的每个条目是64字节完成队列的每个条目是16字节。队列在内存里是环形缓冲区有一个头指针和尾指针。对于提交队列操作系统负责更新尾指针控制器负责更新头指针。对于完成队列控制器负责更新尾指针操作系统负责更新头指针。提交队列条目的格式字节0操作码字节1标志位字节2-3命令ID字节4-7命名空间ID字节8-15保留字节16-23元数据指针字节24-39数据指针PRP1和PRP2字节40-43命令特定字段字节44-63命令特定字段完成队列条目的格式字节0-1命令特定结果字节2-3保留字节4-5提交队列头指针字节6-7提交队列ID字节8-9命令ID字节10-11状态字段字节12-15保留状态字段里包含阶段位和状态码。阶段位在bit 0状态码在bit 1-15。状态码0表示成功其他值表示各种错误。4.3 读写命令的提交与完成处理读写命令的操作码是0x02读和0x01写。读写命令需要指定起始逻辑块地址LBA、传输的块数、数据缓冲区地址等。数据缓冲区地址用PRPPhysical Region Page来描述。PRP有两种PRP1和PRP2。如果传输的数据不超过一页PRP1指向数据缓冲区PRP2为0。如果超过一页PRP1指向第一页PRP2指向一个PRP列表列表里包含后续页的地址。对于简单的操作系统你可以限制每次读写不超过一页4KB这样只需要用PRP1不需要PRP列表。4KB对于大多数操作来说够用了如果你需要读写更大的数据可以在上层分多次提交。提交读写命令的步骤在提交队列里找到一个空闲条目。填充命令条目操作码、命令ID、命名空间ID、LBA、块数、PRP1。更新提交队列尾门铃寄存器。IO提交队列的尾门铃寄存器在BAR0的偏移0x1000加上队列ID乘以4。比如队列ID是1尾门铃寄存器在0x1004。等待完成队列。完成队列的阶段位翻转时表示命令完成。读取完成队列条目的状态字段判断命令是否成功。更新完成队列头门铃寄存器。头门铃寄存器在BAR0的偏移0x1000加上队列ID乘以4再加上0x1000。比如队列ID是1头门铃寄存器在0x2004。这里有一个关键点完成队列的头门铃寄存器必须更新否则控制器不知道你已经处理了完成条目完成队列会满。我当初就是忘了更新头门铃结果发了几个命令之后控制器就不处理新命令了。后来查了规范才发现这个问题。4.4 中断处理与轮询的取舍NVMe控制器支持中断方式通知完成事件也支持轮询方式。中断方式效率高但实现复杂轮询方式简单但浪费CPU。我建议在调试阶段先用轮询等基本功能跑通了再切换到中断。轮询的方式很简单提交命令后不断检查完成队列的阶段位直到阶段位翻转。这种方式不需要配置中断代码量少容易调试。但缺点是CPU会一直忙等效率低。对于操作系统来说长时间忙等是不可接受的所以最终还是要用中断。中断处理的方式当NVMe控制器产生中断时中断处理程序读取INTMS或者MSI-X表判断是哪个队列产生了中断然后处理对应的完成队列。处理完成后更新头门铃寄存器清除中断。如果还有未处理的完成条目继续处理直到完成队列为空。我当初在实现中断处理的时候遇到了一个问题中断处理程序里访问NVMe寄存器时有时候会读到错误的值。后来发现是因为没有正确设置内存屏障。在x86上读写MMIO寄存器需要确保顺序否则CPU可能会乱序执行。解决办法是在读写MMIO寄存器前后加上内存屏障指令比如mfence或者sfence。这个细节在文档里很少提到但实际写驱动的时候非常重要。5. 常见问题与排查技巧实录5.1 PCIe枚举常见问题问题1读不到设备厂商ID一直是0xFFFF。这个问题的原因可能有几个一是设备号或者功能号遍历错了二是配置空间访问方式不对三是设备根本没上电。排查的时候先用IO端口方式读一下总线0、设备0、功能0的厂商ID看看能不能读到。如果读不到说明配置空间访问有问题。如果读到了但读不到NVMe设备那可能是设备号不对。你可以在QEMU里用info pci命令查看PCI设备列表确认NVMe设备的总线号、设备号和功能号。问题2BAR地址读出来是0。这个问题的原因可能是BAR没有映射或者BAR是IO空间而不是内存空间。检查BAR的最低位如果是1说明是IO空间NVMe控制器一般不用IO空间。如果最低位是0但地址是0那可能是设备没有正确初始化。在QEMU上NVMe的BAR地址通常是自动分配的不应该为0。如果为0检查一下QEMU的命令行参数确保NVMe设备被正确添加。问题3映射BAR之后访问寄存器导致页错误。这个问题的原因可能是映射的地址不对或者映射的属性不对。检查映射的物理地址是否和BAR里读出来的一致检查页表是否设置了正确的权限和缓存属性。NVMe的寄存器区域必须映射为不可缓存否则读写顺序会乱。在x86上设置页表的PCD位可以禁用缓存。5.2 NVMe初始化常见问题问题1控制器一直不就绪RDY位不置1。这个问题的原因可能是队列内存没有对齐或者队列深度设置错误或者CC寄存器的其他位没有正确设置。检查ASQ和ACQ的物理地址是否是4KB对齐的检查AQA寄存器的值是否正确检查CC寄存器的IOSQES和IOCQES是否设置正确。我当初就是IOSQES设错了设成了5结果控制器一直不就绪。问题2Identify命令超时。这个问题的原因可能是尾门铃寄存器没有更新或者命令条目的格式不对或者数据缓冲区地址不对。检查提交命令后是否写了尾门铃寄存器检查命令条目的操作码和命令ID是否正确检查数据缓冲区的物理地址是否有效。我当初就是忘了写尾门铃结果命令一直没被处理。问题3创建IO队列失败。这个问题的原因可能是队列ID冲突或者队列深度超过最大值或者完成队列ID不对。检查队列ID是否已经被占用检查队列深度是否超过CAP寄存器报告的最大值检查完成队列ID是否和提交队列ID匹配。5.3 读写操作常见问题问题1读写的LBA超出范围。这个问题的原因可能是命名空间容量没有正确读取或者LBA计算错误。检查Identify Namespace命令返回的容量信息确认LBA没有超出范围。NVMe的LBA是从0开始的容量是命名空间的块数。问题2读写数据不正确。这个问题的原因可能是PRP地址不对或者数据缓冲区没有正确分配或者DMA没有正确配置。检查PRP1是否指向正确的物理地址检查数据缓冲区是否是物理连续的检查DMA方向是否正确。我当初就是PRP1指向了一个虚拟地址结果控制器读到了错误的数据。后来改成物理地址就好了。问题3完成队列满了控制器不处理新命令。这个问题的原因可能是完成队列的头门铃寄存器没有更新。每次处理完完成条目后必须更新头门铃寄存器告诉控制器你已经处理了这些条目。否则完成队列会满控制器会停止处理新命令。5.4 常见问题速查表问题现象可能原因排查方法厂商ID为0xFFFF设备不存在或配置空间访问错误检查设备号、功能号检查IO端口访问BAR地址为0设备未初始化或BAR类型错误检查BAR最低位检查QEMU参数访问寄存器页错误映射地址或属性错误检查物理地址检查页表缓存属性控制器不就绪队列未对齐或CC配置错误检查ASQ/ACQ对齐检查IOSQES/IOCQESIdentify超时尾门铃未更新或命令格式错误检查尾门铃写入检查命令条目创建IO队列失败队列ID冲突或深度超限检查队列ID检查CAP最大深度读写数据错误PRP地址错误或DMA配置错误检查PRP1物理地址检查DMA方向完成队列满头门铃未更新检查头门铃写入5.5 独家避坑技巧第一个技巧在调试PCIe枚举的时候先把所有设备的厂商ID和设备ID打印出来确认你能看到NVMe设备。如果看不到说明枚举逻辑有问题。不要急着去写NVMe驱动先把枚举搞对。第二个技巧在初始化NVMe控制器之前先把BAR0的寄存器全部打印一遍看看CAP、VS、CSTS的值。CAP寄存器会告诉你控制器支持的最大队列深度、超时时间等信息。VS寄存器会告诉你NVMe协议的版本。CSTS寄存器会告诉你控制器的当前状态。这些信息对后续的初始化很有帮助。第三个技巧在提交管理命令的时候先用轮询方式等待完成不要用中断。轮询方式简单可靠容易调试。等管理命令跑通了再切换到中断方式。第四个技巧在读写数据的时候先用小数据量测试比如读写一个扇区512字节。确认小数据量能正确读写之后再测试大数据量。大数据量涉及PRP列表复杂度高很多。第五个技巧在QEMU上调试的时候可以用-trace nvme*参数打开NVMe的跟踪日志看看控制器收到了什么命令返回了什么状态。这个日志对定位问题非常有帮助。第六个技巧如果你的操作系统支持串口输出把关键步骤的日志都打印出来比如PCIe枚举的结果、NVMe初始化的状态、命令提交和完成的信息。这些日志在调试的时候非常有用可以帮你快速定位问题出在哪一步。第七个技巧在读写操作中注意内存屏障的使用。x86的MMIO读写需要内存屏障来保证顺序否则CPU可能会乱序执行导致寄存器读写顺序错误。在读写NVMe寄存器前后加上mfence指令可以避免这个问题。第八个技巧在分配DMA缓冲区的时候确保缓冲区是物理连续的。如果你的物理内存管理器不能保证连续性你需要自己实现一个连续内存分配函数。NVMe的PRP要求物理地址连续如果缓冲区不连续你需要用PRP列表来描述复杂度会高很多。第九个技巧在处理完成队列的时候注意阶段位的翻转。阶段位初始为0控制器处理完命令后会翻转阶段位。你需要根据阶段位来判断哪些条目是新的哪些是已经处理过的。如果阶段位判断错误可能会重复处理或者漏处理完成条目。第十个技巧在创建IO队列的时候先创建一个队列测试读写。确认一个队列能正常工作之后再创建多个队列。多个队列涉及中断向量的分配和队列ID的管理复杂度更高。6. 性能优化与后续扩展方向6.1 队列深度与并发性能的权衡NVMe的一大优势是支持多队列和深队列。管理队列的深度通常是64IO队列的深度可以更大比如256或者1024。队列越深能同时处理的命令越多并发性能越好。但队列越深占用的内存也越多。每个提交队列条目64字节每个完成队列条目16字节。如果队列深度是1024提交队列需要64KB完成队列需要16KB。对于内存有限的操作系统来说需要权衡。我建议在初期先用较小的队列深度比如64等系统稳定了再增加。队列深度的设置受限于CAP寄存器报告的最大值。在QEMU上最大队列深度通常是1024。你可以根据实际需求调整。多队列的另一个好处是可以在多核系统上实现并行处理。每个CPU核心可以有自己的IO队列减少锁竞争。但多队列的实现复杂度也更高需要处理队列间的同步和中断分配。对于单核操作系统一个IO队列就够了。6.2 中断合并与轮询的混合策略NVMe控制器支持中断合并也就是多个完成事件合并成一个中断。这可以减少中断次数提高效率。中断合并的配置在Create IO Completion Queue命令里可以设置中断合并的阈值和时间。对于高负载场景中断合并可以显著降低CPU占用。另一种策略是混合使用中断和轮询。在低负载时用中断在高负载时切换到轮询。轮询可以避免中断开销提高吞吐量。但轮询会占用CPU需要根据负载动态调整。这种策略在Linux的NVMe驱动里有实现可以参考。6.3 后续可以扩展的方向第一个方向是支持多个命名空间。NVMe硬盘可以有多个命名空间每个命名空间是一个独立的逻辑块设备。你可以通过Identify Namespace命令获取每个命名空间的信息然后为每个命名空间创建一个块设备。第二个方向是支持TRIM命令。TRIM命令可以告诉硬盘哪些块不再使用硬盘可以提前擦除这些块提高后续写入的性能。TRIM命令的操作码是0x09通过IO队列提交。第三个方向是支持异步IO。当前的实现是同步的提交命令后等待完成。异步IO可以让操作系统在等待完成的同时做其他事情提高效率。异步IO的实现需要配合操作系统的调度器复杂度较高。第四个方向是支持电源管理。NVMe控制器支持多种电源状态可以在空闲时进入低功耗状态。电源管理的实现需要处理状态转换和唤醒对于移动设备来说很有价值。第五个方向是支持热插拔。PCIe支持热插拔NVMe硬盘可以在系统运行时插入或拔出。热插拔的实现需要处理PCIe的热插拔事件和NVMe控制器的动态添加和移除。我在实际实现中先跑通了基本的读写功能然后逐步添加了多队列支持和中断合并。每一步都做了充分的测试确保稳定性。存储驱动是操作系统的核心组件稳定性比性能更重要。如果驱动不稳定可能会导致数据丢失或者系统崩溃。所以建议在添加新功能之前先确保现有功能稳定可靠。最后再分享一个小技巧在调试NVMe驱动的时候可以用QEMU的-snapshot参数这样每次启动都是干净的状态不会因为上次的测试数据影响本次调试。另外可以用-drive filetest.img,formatraw,ifnone,iddrv0和-device nvme,drivedrv0,serial1234来创建一个带数据的NVMe硬盘方便测试读写。这些QEMU的参数在调试的时候非常有用可以帮你快速搭建测试环境。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑