NVMe SGL描述符原理与构建实战:从PRP到SGL的演进
做存储开发的朋友只要碰过NVMe驱动或者SSD固件肯定绕不开两个词PRP和SGL。PRP全称Physical Region Page是NVMe 1.0时代的主力数据指针方式SGL全称Scatter Gather List散聚列表从NVMe 1.0就写进了规范但真正被广泛使用是后面几个版本的事。这篇文章我想结合自己的开发经验聊一聊SGL描述符的实现原理、构建流程以及在什么情况下你必须放弃PRP改用SGL。写这块内容主要是因为在调一个模拟NVMe控制器的时候发现很多人对着协议里的Dptr字段手足无措宁可来回拷贝数据凑PRP也不敢迈出用SGL这一步。其实搞清楚SGL描述符的原理之后事情远没有想象中复杂。1. 为什么说PRP带不动新型IO负载SGL才顺势上位1.1 PRP的内存组织方式和它解决不了的问题PRP的思想非常朴素既然DMA要操作物理连续内存而Linux内核里多半给的是分散的物理页那就把每个物理页的地址排成一个表交给控制器去挨个读。PRP Entry在64位寻址下每个占8字节存一个物理页的起始地址。一个NVMe命令的Dptr区域里正常情况下能直接放PRP1和PRP2两个入口如果数据跨的页太多PRP2就不指向数据页了而是指向一个PRP ListList里面再存一串PRP Entry。这个设计在NVMe 1.0时代很合理因为那时候SSD的队列深度和块大小都比较规矩IO缓冲区也以页为单位对齐。但它有两个硬伤。第一PRP要求每个Entry对应的地址必须物理页对齐页内偏移只能在命令的第一个PRP里出现。换句话说你的IO缓冲区哪怕只有512字节偏移也得靠页对齐和补页来处理稍微不规整一点描述起来就非常别扭。第二PRP表达的是“一维”的连续页表它没法表达任意位置、任意长度的数据块组合。一旦IO涉及多个虚拟地址段、元数据交织、或者需要跳过某些数据区域PRP就变成了一种折磨。1.2 SGL的本质和它带来的三个改变SGL的做法是把每个不连续的内存片段抽象成一个描述符每个描述符16字节里面可以写任意物理地址和任意长度。多个描述符按顺序排在一起构成一个SGL段多个SGL段还可以通过指针串成链表。控制器从命令指定的SGL段开始逐个解析描述符就知道去哪搬数据、搬多长。SGL带来的改变第一个是打破页对齐的限制。数据块描述符里的地址和长度字段让一次DMA可以覆盖任意偏移的缓冲区只要满足控制器要求的字节对齐就行通常是4字节或8字节。第二个改变是表达能力强了很多既能描述正常数据块也能描述“这里有一段数据要丢掉”的Bit Bucket块还能把数据和元数据混排。第三个改变是链式结构一个命令可以挂几十段描述符覆盖几百块碎片内存这在大IO和高队列深度场景下非常关键。2. 上手SGL必先看懂的四类描述符2.1 SGL数据块描述符最常用的那一个SGL描述符一共分几类数据块描述符是平时打交道最多的。每一类描述符都是固定的16字节结构统一为前4字节是类型和控制信息中间4字节是长度后8字节是物理地址。数据块描述符的Dword0里高4位是描述符类型字段值为0001b表示这是一个数据块描述符紧接着4位是子类型0h表示带密钥的数据块主要给某些保护信息和KV命令用1h表示无密钥数据块普通IO一律用它。后面还有16位Identifier和两个地址类型位不做密钥相关功能时不用关心。Dword1是32位的数据块长度单块最大可以到4GB。Dword2和Dword3组成64位物理地址指向DMA要访问的内存起始位置。我在实际使用中最常踩的坑就是把子类型写成0。控制器解析到Keyed Data Block时会额外检查Identifier和密钥相关字段普通命令根本带不了这些结果就是返回SGL描述符类型无效之类的错误。所以写成无密钥数据块子类型给1万事大吉。2.2 SGL段描述符链式存储的关键当描述符数量太多一个SGL段装不下时就需要用到SGL段描述符。这个描述符的地址字段不再指向普通内存数据块而是指向下一个SGL段的起始地址。它的类型字段是0000b子类型0h表示普通段描述符2h表示最后一个段描述符。普通SGL段描述符和最后一个段描述符之间的区别不在于是否还有人跟在后面而在于最后一个段描述符多了一个Length字段用来告诉控制器这个SGL段有多大。控制器在遍历SGL段链的时候看到普通段描述符就知道还要继续往下跳看到最后一个段描述符就知道解析到这里为止同时可以用Length字段校验段内描述符数量是否合法。这里有个特别容易懵的点命令Dptr区域既能放SGL段地址也能放一个完整的最后一个SGL段描述符。如果整个SGL只有一个段命令里填的那个段地址和最后一个段描述符里的地址指向同一个位置。如果有多段命令里通常指向第一个段最后一个段描述符指向链表末尾。理解不了这层关系后面构建多段SGL时大概率要翻车。2.3 Bit Bucket只跳过不搬运的神奇描述符Bit Bucket描述符在SGL里是个特立独行的存在。它的类型字段是0010b没有地址字段只有Length。控制器看到这个描述符不是去搬数据而是把输出数据流里对应的长度吞掉不写入内存。这个描述符的经典场景是写命令里的“洞”。比如你想在文件中间挖掉一块又不想在主机侧把数据重新拼一遍就可以放一个Bit Bucket描述符长度填要跳过的字节数控制器写数据时自动跳过这段。另一个场景是某些压缩或校验算法需要连续的数据流但输入来源有空洞Bit Bucket正好补上这个空洞。刚开始我用SGL时一直不理解这个描述符存在的意义直到一次做数据流重排才明白它的本质是让“不搬运”这件事也变成一种显式的IO语义描述能力一下子就立体了。3. 从数据块到命令下发SGL构建全流程拆解3.1 命令里的Dptr在三态之间切换NVMe命令的Dword0里有2位叫PSDT专门用来指定数据指针的类型。00b表示PRP01b表示SGL。这2位决定了Dptr区域的解释方式。在PRP模式下Dptr区域是PRP1和PRP2两个8字节地址。在SGL模式下Dptr区域被拆成两半前8字节放一个SGL段的物理地址后16字节放一个完整的最后一个SGL段描述符。这里不要死记字节偏移关键是理解控制器拿到命令后怎么走它先看PSDT知道是SGL然后读Dptr里的SGL段地址找到SGL段开始逐个解析描述符。如果解析过程中遇到普通段描述符就跳到下一个SGL段继续遇到最后一个段描述符就校验长度并结束解析。我自己调试时有个习惯先把PSDT确认好再检查Dptr里两个部分各自指向哪。很多问题根本不是SGL内容写错了而是PSDT写成00b控制器拿PRP的解析逻辑硬读SGL内存读出来的全是垃圾描述符报的错自然也是驴唇不对马嘴。3.2 一个64KB读IO的SGL构建实例用一个具体例子把构建流程走一遍。假设一次读IO总长度64KB缓冲区由5个不连续物理片段组成8KB、4KB、16KB、20KB、16KB各自物理地址分别是A1到A5。按照PRP的做法你得把每个4KB页拆出来可能有16个PRP Entry再加上页偏移这种补丁操作。用SGL直接写5个数据块描述符就完了。第一步准备一块SGL段内存物理连续大小至少5乘以16字节再加段描述符自身占的16字节。第二步依次填充5个无密钥数据块描述符Dword0写DT为1、DType为1Dword1写各自的长度Dword2和Dword3写各自的物理地址。第三步在段尾部填一个最后一个SGL段描述符类型0000b、子类型2h地址字段回填这块SGL段内存自己的物理地址Length填整个段实际占用的字节数。第四步把该SGL段地址填到命令Dptr的前8字节把最后一个段描述符原样拷贝到Dptr的后16字节。第五步PSDT设为01b命令下发。我加一个容易忽略的细节SGL段内存本身必须是物理连续的而且它的地址要满足控制器在SGLAL里要求的对齐。如果是模拟器或者简单的PCIe验证环境通常没这么严格但真实SSD控制器会对齐查得很死。建议在分配SGL段时直接按64字节对齐分配怎么都够用了。3.3 主机侧与控制器侧各要保证什么主机侧的职责是构建合法且对齐的SGL描述符并把链头地址和最后段描述符正确填进命令。除此之外主机还要保证整个IO生命周期内SGL段内存和数据缓冲区的物理地址一直有效不能被换页、不能被其他设备偷改。这个和PRP的要求本质一样只是SGL多了链式结构任何一环的内存被释放整条链就废了。控制器侧的职责是按规范解析描述符解析出每个数据块后发起DMA读取读取过程中要处理地址翻译、越界保护、长度校验。控制器必须能区分普通数据块和Bit Bucket遇到后者要跳过对应长度。如果控制器发现SGL段里的描述符类型组合不合法比如在数据流中间出现一个普通段描述符就要在完成队列里上报对应的SGL状态码。我调试SGL时发现一个有趣的现象主机侧构建SGL特别顺手但控制器侧如果固件写得不严谨解析链式结构时容易在段与段的衔接出问题。有个同事写过一版控制器普通段描述符处理得好好的一旦跟在后面的还是段描述符就出现“先读取再跳转”的逻辑混乱。所以玩SGL不能只看主机驱动还得理解控制器固件的解析思路。4. 构建SGL最容易踩的六个坑4.1 描述符地址对齐不是页对齐就万事大吉SGL的一大卖点是不要求4KB页对齐这导致很多人误以为对齐约束彻底消失了。实际上数据块描述符里的起始地址还得满足控制器的SGLAL要求常见的控制器要求4字节对齐严格一点的8字节个别的可能要求16字节甚至更高。SGL段地址和最后一个段描述符里的段地址同样要满足这个对齐要求。我在一个模拟NVMe控制器的项目里最开始偷懒把所有地址按1字节对齐处理结果大约三分之一的IO会随机报地址错误。排查半天发现是模拟器里检查对齐的代码写得严苛数据块地址低3位不能有值。后来统一把主机侧分配缓冲区的逻辑改成自然对齐问题立刻清零。4.2 Length字段的“16字节倍数”陷阱SGL段描述符的Length字段表示这个SGL段以字节为单位的长度。规范要求它的值必须是16字节的倍数同时至少要能容纳一个描述符。翻译一下就是你SGL段里实际填充的描述符总长度加上必要的对齐填充写进Length字段后低4位必须是0。有次我写驱动把一个SGL段的Length填成实际使用的字节数比如缓存了300字节描述符Length直接填300。控制器返回Data SGL Length Invalid我查了好久才发现300除以16不等于整数。后来改成304也就是向上取整到16的倍数故障消失。认真讲这是因为控制器拿Length除16算描述符数量余数直接算非法。写SGL段时不如一开始就按16倍数分配内存最后按分配值减掉尾部填充空间来填Length别按“用了多少”来填。4.3 只剩一个段时的双份地址一致性问题单段SGL是最常见的情况也是出错非常隐蔽的场景。命令Dptr前8字节填了SGL段地址后16字节的最后一个段描述符又指向同一个地址。很多人图省事后16字节不填或者填0控制器大概率直接报错。反过来有人填了最后一个段描述符但是地址和Dptr里的SGL段地址不一致控制器就会认为这是两个不同的段。假设Dptr指向段A的基址最后一个段描述符却指向段B控制器读完段A的最后一个普通描述符后会尝试跳转结果一切乱套。我的做法是封装一个构建函数SGL段地址和最后一个段描述符的地址字段都从同一个物理地址变量里读出来从根源上避免两份地址不一致。4.4 控制器的SGL能力位没有打开NVMe规范里控制器在Identify Controller数据结构中通过SGLS字段声明自己支持SGL的命令集。如果SGLS为0那么主机发送任何带SGL的命令都是非法的。这个说得容易做起来坑很深因为有些控制器声称支持SGL但只支持部分命令或部分队列。某次调试一个企业级SSD的NVMe驱动读命令用SGL一切正常写命令却稳定返回Invalid Command。排查发现该控制器虽然全局开着SGL能力但写命令的SGL支持位在命令集定义里是关闭的。查了半天才发现是SGL能力分命令粒度控制不能只看全局字段。所以正式产品里不要默认SGL全命令可用先查命令集支持矩阵。4.5 小心CMB撞车NVMe控制器内存缓冲区简写为CMB是控制器暴露给主机的一段映射内存。SGL段放在CMB里可以降低主机内存到控制器内存的拷贝开销这是很多人追求的性能优化点。但使用CMB有一个隐含假设控制器必须支持把CMB地址当作SGL段地址来解析。部分控制器的CMB只支持数据块DMA不支持描述符读取强行放进去就报Invalid Use of Controller Memory Buffer。我之前优化一个IO路径时把SGL段搬到CMB里结果性能没提升错误先冒出来了。后来对照规范才发现这个控制器明确不支持SGL段放在CMB中。改回普通主机内存问题解决。想用CMB这个优化前先确认目标控制器能力不要想当然。4.6 并发与Cache一致性检查SGL段内存如果复用同一块内存可能同时被多个提交队列引用或者一个命令正在DMA时主机就开始改描述符这是典型的数据竞争。别以为写完SGL段就不管了控制器是异步读它的你没法保证命令写完队列时描述符已经全部落内存。实际操作中我会在建好SGL段后执行一次内存屏障确保描述符的修改对DMA可见。如果是带Cache的体系结构还得处理好Cache一致性必要时做clean操作。这一条在高性能场景下尤其重要很多人遇到的“IO偶尔超时”其实是描述符没刷脏引起的。这类问题的排查难度极高因为看起来是随机故障实际上是个缓存时序问题。5. 实战选型同一块SSDPRP和SGL到底差多少5.1 PRP与SGL的六维对比表把二者的差异放到一张表里选型的时候一眼就能定位问题。对比维度PRPSGL描述符大小8字节/Entry16字节/描述符地址对齐要求物理页对齐通常4KB控制器指定常见4/8字节碎片描述能力只能描述整页页内偏移受限任意偏移、任意长度多段组织方式PRP List单向列表支持有限SGL段链表可无限扩展数据跳过能力不支持Bit Bucket描述符支持命令Dptr占用PRP1PRP2共16字节段地址最后段描述符共24字节这里有一个容易被忽略的点SGL在命令Dptr区域比PRP多占了8字节因为多了一个完整的最后一个段描述符。所以不是所有命令都设计成支持SGL有些命令体本身字段塞得很满放不下最后的描述符就只支持PRP。这也是为什么有些命令明明SGL能力开着你却不能用的原因之一。5.2 我的选型经验什么时候无脑PRP什么时候必须SGL如果IO缓冲区天然按页对齐内存碎片少又不需要跳过数据PRP足够。比如块设备的4KB对齐读写、顺序IOPRP的解析效率很高控制器固件处理起来也简单。NVMe 1.0时代的大多数产品用PRP就能跑出很好的性能。一旦出现以下情况我会毫不犹豫切SGL缓冲区跨页且带非页对齐偏移一次IO由多个虚拟内存段组成例如用户态直接注册内存需要在数据流里跳过空洞需要把元数据和数据放在同一条描述符流里或者在做虚拟化直通客户机给的是分散的物理内存。用SGL能把主机侧的建址和映射开销降很多虽然描述符解析多花一点控制器时间但整体收益是正的。实测经验也验证了这一点。在同一个全闪存阵列模拟环境里SGL构建耗时比PRP少了大约三到五成具体取决于碎片数量。控制器侧解析SGL比PRP多花一定开销但和高并发的总时延相比是可以接受的。真要追求极致性能可以配合批量分配SGL段和缓存描述符复用进一步摊薄构建成本。5.3 虚拟化场景里SGL为什么是刚需虚拟化是SGL最能发挥价值的地方。虚拟机IO的GVA地址经过IOMMU翻译后对应到宿主机的一组分散物理页中间还带着各种偏移和间隙。如果用PRP需要先做页对齐整理再填表用SGL直接把翻译结果逐段填到数据块描述符里即可偏移和长度都不需要额外打补丁。我做的某个IO虚拟化模拟项目里客户机每次读写内存布局都会变来变去PRP构建逻辑越写越长最后干脆全面改SGL。描述符构建从十几行代码压缩到三五行排错也清晰了。加上SGL支持Bit Bucket虚拟机的稀疏镜像读写也能直接用硬件跳过未分配块效率提升非常明显。可以这么说新起一个存储虚拟化项目直接按SGL为主、PRP兜底来设计能少走很多弯路。6. 排查SGL相关错误状态的速查表和调试心得6.1 状态码速查表NVMe完成队列里返回的SGL错误状态码是排查问题的第一手线索。下面这几个是我见过的高频代码对应原因和处理方向都整理一下。状态码代表含义常见原因0x1CInvalid SGL Segment Descriptor段描述符类型/子类型组合非法0x1DInvalid Number of SGL Descriptors段长度不是16倍数或描述符数越界0x1EData SGL Length Invalid数据块描述符长度累计和命令不符0x1FMetadata SGL Length Invalid元数据描述符长度与命令不符0x20SGL Descriptor Type Invalid描述符DT或DType不被支持0x24SGL Offset Invalid数据块地址对齐不符合要求拿到状态码后第一步不要急着翻驱动代码先用工具把下发命令的Dptr原始字节和SGL段内存内容抓出来核对每个描述符前4位。大部分问题都是描述符类型写错、长度不是16倍数、地址没对齐这三类。排查和修复都不难难的是定位。6.2 一次SGL段越界的排查实录说一个我印象很深的排查经历在某总线分析工具配合下发现读命令下发后SGL段地址指向的物理内存只有256字节但最后一个段描述符的Length填了2048字节。控制器按Length连续读取SGL段越界读了邻居内存把后面的描述符解析得乱七八糟返回的错误码一会儿是描述符类型无效一会儿是段描述符非法完全不规律。根源是驱动里分配了256字节的SGL段缓存但构建时没有把实际段大小回写到最后一个描述符的Length里。后续版本里我在分配函数里强制规定SGL段缓存大小必须等于“当前段实际描述符数量乘以16之后向上取到16倍数”再把这个计算值写进Length。修复以后同类错误没再出现过。这段经历说明SGL的段大小必须由主机侧精确告知控制器控制器不做推理只做校验。6.3 调试SGL问题的三个实用建议第一个建议用工具抓命令和描述符的原始字节。任何理论分析都不如直接看DMA发出去的16进制数据来得实在。命令里PSDT、Dptr、SGL段内存全部打印出来对照规范每4字节检查。我排查过的大部分SGL问题基本都能靠这一步定位出具体字段。第二个建议写一个SGL构建的单元测试。不真正下发IO只构建SGL段并校验长度、地址、类型字段再模拟控制器端做解析。这样把问题隔离在主机侧。很多构建错误能在不碰硬件的情况下暴露省调试时间。第三个建议时刻关注规范版本差异。NVMe 1.3到2.0之间SGL有些字段细节做过调整对齐和长度要求也在细化。参考网上博客可以但真正改代码时以手头控制器对应的规范版本为准。我一向的做法是打开规范原文找到SGL章节把字段布局打印出来贴在工位上比什么博客都好使。SGL这块内容我个人在实际操作中的体会是它本身不复杂复杂的是“以为自己会了结果一个字段认错”。写这篇梳理时我又翻了一遍自己过去调过的几个patch发现每一次返工都源于对描述符类型和长度的理解不够精确。如果你也准备在自己驱动或者固件里上SGL我建议从最小改动开始先只做单段无密钥数据块跑通IO再加Bit Bucket再加多段链表一步一步来。等整条链路的每个字段在你脑子里都清清楚楚了再求性能和花活也不晚。