有一次我在改一个NVMe驱动的IO路径把一直沿用的PRP换成了SGL。结果性能没有变好反而时不时冒出一个状态码命令提交直接失败。排查到最后问题出在我把SGL描述符当PRP列表那样用直接往DPTR里塞了一个数据块描述符完全忘了SGL数组的第一项必须是一个Segment Descriptor。从那以后我对SGL就多留了个心眼也把构建、校验、调试的那点门道摸了一遍。这篇文章想围绕SGL描述符展开说说它到底解决了什么问题、16字节描述符是怎么组织的、驱动里怎么把一次IO请求拼成一个合法的SGL链以及实际切换过程中最容易踩的坑。适合正在写NVMe驱动、做存储性能优化或者对内核IO路径感兴趣的同学。内容不求面面俱到但会尽量把实现原理和实操细节讲透。1. PRP到SGL这套描述符机制到底替换了什么1.1 PRP怎么描述内存一个地址一个页要说SGL必须先说PRP。PRP全称Physical Region Page直译就是“物理区域页”。它的基本单元是一个64位物理地址叫做PRP Entry。NVMe早期的IO命令数据指针默认就是一组PRP Entry。当命令要读写一块内存时驱动把这块内存按物理页切开每个物理页在PRP列表里占一个Entry。控制器拿到命令后根据PRP Entry提供的地址把散落在不同物理页里的数据按顺序拼成一个完整的数据流。这套逻辑和早年IDE控制器里的PRDPhysical Region Descriptor很像硬件实现确实简单但代价就是它和“页”的概念绑定得太死。在Linux内核的NVMe驱动里struct nvme_prp相关代码花了大量精力去计算页边界比如当前DMA地址距离页尾还有多少字节、剩余长度会不会跨页、PRP列表是否需要另一个页来存放。这些分支逻辑又密又绕每一条都是潜在的出错点。1.2 页对齐规则带来的麻烦PRP对页对齐的要求是出了名的苛刻。第一个PRP Entry允许带页内偏移因为数据起始地址不一定恰好落在页边界上。但后续的每个Entry本质上都被当作一个物理页的起始地址来处理控制器默认从该地址开始传输一整页数据。举个例子上层给了一个64KB的缓冲区物理地址连续但起始地址落在某个物理页的中间比如偏移了1000字节。PRP的做法不是简单地填一个entry而是先描述“从当前偏移到本页页尾”的那一小块然后对后面每一个整页各填一个entry最后再处理尾部的不足一页部分。算下来一次64KB的IO可能要十几个PRP Entry。这还只是连续内存如果上层给的是几十个分散的物理页PRP列表会膨胀得更明显。更麻烦的是驱动的代码逻辑。每处理一个段都要算一遍“当前地址到页尾还剩多少”然后决定是整页传输还是截断。这个计算是纯CPU开销而且和具体页大小耦合。页面大小一旦从4KB切到64KB整条逻辑都要重新核对。1.3 SGL的引入动机不再强迫按页切分SGL全称Scatter-Gather List直译是“分散聚合列表”。它的核心思路很简单每一个描述符直接给出一个DMA段的起始地址和长度控制器按描述符一条一条传输不再强制要求某个段必须落在一个物理页内。这个思路更贴近PCIe上DMA引擎的真实工作方式。对DMA控制器来说它只需要知道“地址是多少、长度是多少”它完全不关心这段内存是否跨页。“跨页”这个概念是上层协议强加给硬件的。SGL把这一层约束拿掉了驱动构建描述符时不再需要按页去拆分DMA段只需要把上层传下来的物理连续段逐个翻译成SGL描述符即可。另外NVMe over Fabrics这类场景也对SGL更友好。在RDMA或类似的网络传输路径里上层拿到的本来就是分散的内存段用SGL描述比强行整理成PRP要自然得多。这也是为什么后来的NVMe规范把SGL作为与PRP并列的合法描述方式并且很多控制器对其支持得越来越完整。但我得先泼一盆冷水SGL不是PRP的全面替代品它只是另一条合法的路。什么时候选哪条得看场景。这个放到后面第三章和第四章细说。2. 16字节一个描述符SGL的数据格式与类型语义2.1 描述符的基本骨架地址、长度、标识SGL描述符统一占16字节这一点从Linux内核头文件里也能直接看到。你可以把它理解成三条信息8字节的地址、4字节的长度、4字节的标识字段。struct nvme_sgl_desc { __le64 addr; __le32 length; __u8 key[4]; /* Type/Subtype 等标识字段按协议填充 */ };addr是DMA地址length是这段数据或描述符数组的长度key区域则承载类型、子类型等标识信息。16字节这个大小对齐到64字节cache line正好是四分之一控制器读取和处理起来也方便。需要注意这里的addr必须是控制器能访问的DMA地址而不是CPU的虚拟地址。驱动在构建SGL之前必须先通过dma_map_sg或dma_map_page把物理页映射成设备可见地址。如果直接把内核虚拟地址填进去控制器读到的内容就是乱的。2.2 四类描述符的分工SGL描述符不是只有一种。按NVMe规范常见的有Null描述符、Keyed Data Block、Unkeyed Data Block、Segment Descriptor和Last Segment Descriptor。我这里按实际使用频率整理一下。描述符类型主要作用典型使用场景Null Descriptor占位不传输数据某些管理命令中用于填充Keyed SGL Data Block描述一个数据块带额外Key需要携带附加标识的场景Unkeyed SGL Data Block描述一个数据块不带Key最常见的普通数据传输SGL Segment Descriptor指向下一个SGL段描述符数量超出一段容量时Last SGL Segment Descriptor指向最后一个SGL段每条SGL链的结尾Keyed和Unkeyed的区别在于描述符的key字段是否有业务含义。某些控制器支持在Key里放校验码或块标识用来做端到端的数据保护。普通块设备读写一般用不到直接把key区域清零也不会错。Segment Descriptor和Last Segment Descriptor是用来组织描述符数组的。SGL不是无限长的一串描述符而是由一个或多个“段”Segment组成的链。每个段本质上是一段物理连续的描述符数组段的第一条必须是Segment Descriptor。如果这条Segment Descriptor的类型是Last就表示这是整条链的最后一个段。2.3 命令里的SGL标识与DPTR怎么配合SGL不是独立于NVMe命令之外的东西它仍然通过命令里的数据指针字段被引用。在IO命令的Dword6区域有一个SGL标识位配合DTYPE字段一起使用。当SGL标识位置位时控制器会把原本用于PRP的DPTR字段解释为“SGL描述符数组的起始物理地址”。这里有一个和PRP很不一样的点。PRP可以做到把第一个Entry直接内嵌在命令里后面的Entry再用一个PRP列表页承接。而SGL的命令侧不直接放数据块描述符它只放一个指向第一条描述符的地址。也就是说命令Dword7和Dword8拼起来的那个64位地址指向的应该是第一个SGL Segment Descriptor而不是第一个Data Block Descriptor。很多刚接触SGL的人就是在这里翻车的。拿着一个Data Block Descriptor就往DPTR里填控制器按照Segment Descriptor的格式去解析自然解读失败。2.4 对齐、长度与保留位最容易翻车的硬规则SGL对对齐的硬性要求有两个数据块的起始地址必须按Dword4字节对齐数据块的长度也必须是4的倍数。Segment Descriptor里的Length同样要求4字节对齐。我猜这个设计是为了简化控制器的DMA逻辑。地址和长度的低两位都是0硬件就不用处理非对齐的跨粒度传输内部的状态机可以少掉很多分支。你可以把它类比成分区表要求按扇区对齐不是能力做不到是大家约定俗成地降低复杂度。Segment Descriptor的Length有一个特别容易搞混的点它表示的是“这个段里除了Segment Descriptor本身之外后续所有描述符的总字节数”不是数据的总长度。假设一个段里有1条Segment Descriptor和16条Data Block Descriptor那Length就是16乘以16等于256字节。如果你把这个字段填成数据自身的字节数控制器数描述符的时候会直接数出问题。保留位则需要一律清零。NVMe规范对保留位的态度是读必须返回0写则写0。如果你不小心往保留位塞了东西不同厂商控制器的表现会不一样。有的直接报错有的忽略有的可能触发未定义行为。调试的时候这类问题最隐蔽因为错误现象往往不是第一时间和SGL相关。2.5 一个最简单的数据块描述符构造函数写一个最基础的构造函数用来把一个DMA地址和长度翻译成Unkeyed Data Block描述符。这里我省略了Type/Subtype具体编码原因是不同版本的NVMe规范对这部分字段虽然稳定但开发者还是要以自己手上的规范文档为准。static void sgl_build_data_block(struct nvme_sgl_desc *desc, dma_addr_t addr, u32 len) { if (!IS_ALIGNED(addr, 4) || !IS_ALIGNED(len, 4)) { pr_warn(SGL data block unaligned: addr0x%llx len0x%x\n, (unsigned long long)addr, len); return; } desc-addr cpu_to_le64(addr); desc-length cpu_to_le32(len); /* 按协议填充 Type/Subtype普通数据传输通常用 Unkeyed Data Block。如果控制器要求 Keyed 还需要额外填写 key 字段。 */ }这段代码只做了最基础的对齐检查和字段填充。实际驱动里通常还要考虑端序转换、描述符数量预算、段容量检查等。但核心思想就是这样一个描述符对应一个物理连续的DMA段仅此而已。3. 驱动侧构建一条SGL链从IO请求到命令填充3.1 输入是什么请求中的bvec和sg在Linux内核里一个IO请求的数据描述通常是struct bio里面包含多个bio_vec。在块设备分层中最终会把这些bio_vec转换成struct scatterlist然后通过dma_map_sg映射成一组DMA地址。构建SGL的输入就是这组已经mapping好的DMA地址和长度。你不需要关心虚拟地址和物理页号只需要知道设备端能看到哪些连续的地址区间。每个DMA段对应一个Data Block Descriptor。不过需要提醒一点dma_map_sg出来的段数量和bio_vec数量不一定相同。如果你的DMA层有IOMMU多个不连续的物理页可能被映射成一个连续的IOVA地址区间这时它们会合并成一个sg段。所以正确的做法是遍历最后的sg表而不是直接拿bio_vec当输入。3.2 从DMA地址列表到SGL段组装算法拿到sg表之后构建SGL的基本流程分四步统计sg表里的段数量估算需要多少条Data Block Descriptor。检查这些描述符能否放进一个SGL段。如果不能需要拆分到多个段并在段间加Segment Descriptor。填充第一个段的Last Segment Descriptor或Segment Descriptor取决于它是不是最后一个段。从第二个描述符位置开始逐个填充Data Block Descriptor。伪代码可以写成这样/* 假设 sgl_buf 是一块物理连续的 DMA 缓冲区 seg_cnt 表示 dma_map_sg 得到的段数量 */ int idx 0; /* 第一个条描述符必须是 Segment Descriptor先占位 */ sgl_buf[idx].addr cpu_to_le64(sgl_seg_addr); sgl_buf[idx].length cpu_to_le32(0); /* 稍后回填 */ sgl_fill_segment_header(sgl_buf[idx], is_last_segment); idx; for (i 0; i seg_cnt; i) { dma_addr_t addr sg_dma_address(sg_table[i]); u32 len sg_dma_len(sg_table[i]); /* 超过当前 SGL 段容量时需要切换到下一段 这里先不展开按容量预检查处理 */ sgl_build_data_block(sgl_buf[idx], addr, len); idx; } /* 回填 Segment Descriptor 里的 Length 后续所有描述符的字节总数本身不包含 Segment Descriptor */ sgl_buf[0].length cpu_to_le32((u32)(idx - 1) * sizeof(struct nvme_sgl_desc));这段代码的核心思路是先把Segment Descriptor的位置预留出来填完所有数据块之后再回填它的Length。这样能在构建过程中动态决定段里放多少条描述符。3.3 一个SGL段放不下的情况一个SGL能容纳的数据块描述符数量不是无限的。通常驱动会把SGL描述符数组放在一个或多个预先分配的缓冲区页里每个段对应一个物理页。NVMe规范没有规定一个段必须以页为界但实际工程中大家都习惯让段边界和物理页对齐这样方便DMA映射和内存管理。假设一个段缓冲区有4KB每条描述符16字节那么一个段最多能放256条描述符。其中1条是Segment Descriptor剩下255条是Data Block Descriptor。一旦需要描述的数据块超过255段就要在当前段末尾放一条非Last的Segment Descriptor指向下一个段的起始地址。下一个段的第一条再放Last Segment Descriptor后面继续填数据块。分段逻辑决定了Segment Descriptor的Length必须准确描述“段内后续描述符的总字节数”。如果长度填错控制器会少读或多读描述符数据错位之后的表现通常是数据校验失败而不是立刻报错。这种问题在调试时特别费劲因为现象看起来像是数据写坏了。3.4 只有一个SGL Segment也别忘了段描述符这是我在文章开头提到的那次踩坑。无论整个SGL链只有一个段还是多个段命令DPTR指向的第一个描述符都必须是Segment Descriptor或Last Segment Descriptor。即使你只需要描述一个4KB的数据块也要先放一条Last Segment Descriptor再放一条Data Block Descriptor。很多初写SGL的驱动代码会把Data Block Descriptor放在DPTR首位因为直觉上“第一条就应该是数据地址”。结果控制器按Segment Descriptor的格式去解析发现Type/Subtype对不上直接返回SGL相关错误。这个错误的典型状态是丢一个Invalid SGL或类似的状态码但不同控制器实现可能报得不一致。从描述符数量上看单段单块场景下SGL要用2条描述符共32字节而PRP只要1个Entry共8字节。这也是小IO场景里PRP仍然有优势的原因后面说收益边界时还会再提。4. SGL的收益边界什么场景值得用什么场景不如PRP4.1 描述符开销的真实对比别迷信SGL聊SGL很容易陷入“新的一定更好”的错觉。我直接列一个估算对照表大家心里就有数了。场景PRP大致开销SGL大致开销4KB页对齐单段IO1个Entry8字节1条Segment 1条Data Block32字节64KB物理连续但起始地址非页对齐约17个Entry可能还需PRP列表页1条Segment 1条Data Block32字节128KB由32个分散4KB页组成32个Entry 一页PRP列表1条Segment 32条Data Block528字节多段非页对齐、长度任意的DMA段每跨一个页都要额外Entry每个DMA段一条Data Block光看前两行就能发现在页对齐、物理连续、IO又小的情况下PRP完胜。SGL反而因为必须保留Segment Descriptor而多出16字节开销。所以那种“把PRP全换成SGL就能提速”的说法是站不住脚的。4.2 SGL真正擅长的地方非对齐且长跨度的DMA段SGL的价值体现在两类场景。第一类是数据缓冲区起始地址不是页对齐的情况。PRP遇到这种地址驱动代码要算一堆页边界还要拆出首尾两个不满页的块SGL只需要一个Data Block Descriptor直接给起始地址和长度管它落在页的哪个位置。第二类是一个DMA段本身跨越多个物理页。比如一段物理连续的256KB内存起始地址恰好带100字节的页内偏移。PRP要拆成大约64个Entry才能描述完SGL依然是一条Data Block搞定。控制器拿到地址和长度直接一路传输根本不需要关心跨页。如果你的工作负载大量是这种非对齐、长跨度、多段的IOSGL可以减少驱动构建描述符的CPU分支也能让命令里的描述符数量更紧凑。但要注意这只是降低了“描述内存”的开销NAND本身的读写延迟、GC、队列深度这些才是真正的性能大头。4.3 用Identify里的SGLS判断控制器能力不是所有NVMe控制器都支持SGL也不是所有支持SGL的控制器都支持所有描述符类型。在启用SGL之前必须读取Identify Controller返回的数据结构找到SGLS字段按位确认控制器支持哪些SGL能力。SGLS字段里通常区分Keyed SGL Data Block、Unkeyed SGL Data Block以及是否支持分段SGL链等能力。如果你的驱动要同时兼容老控制器就不能在初始化阶段写死“支持SGL”而要在拿到SGLS之后动态决定该用PRP还是SGL。有个常见错误是只检查“控制器支持SGL”然后默认所有描述符类型都可用。结果某些老控制器只支持Keyed Data Block不支持Unkeyed命令一样会失败。因此每类描述符的使用前都应该对照SGLS确认。4.4 硬件视角SGL不是性能银弹从硬件角度看SGL只是改变了“数据位置描述”的方式不会改变NAND闪存本身的读写速度。控制器的SGL处理能力取决于DMA引擎能否高效预取和解析描述符数组。如果控制器内部实现不够好SGL带来的额外描述符读取还可能增加延迟。我在实际测试里见过一种情况切到SGL之后吞吐量没有变化但延迟抖动反而变大。后来分析发现是控制器每次命令都要额外DMA读取描述符数组这些读取和正常数据传输在同一PCIe链路上竞争带宽。这就是SGL的隐性成本。所以我的建议是要不要用SGL应该基于具体的IO特征和实测数据而不是跟随技术潮流。先在小范围测试用数据说话。5. 调试SGL最容易出错的四个地方与排查顺序5.1 控制器报SGL错误时先查这四个点SGL相关的错误定位我习惯按下面的顺序检查命令里SGL标识位是不是真的置位了。有时候驱动逻辑分支错误命令走到PRP分支但DPTR里填了SGL描述符控制器按PRP解析自然报错。命令DPTR指向的第一个描述符是不是Segment Descriptor。这是新手最容易踩的坑直接放Data Block Descriptor就是错。Segment Descriptor的Length是不是统计了后续描述符的总字节数而不是业务数据长度。还要检查这个Length是不是4的倍数。SGL描述符所在的内存有没有做对DMA方向和地址映射。如果SGL缓冲只做了CPU端访问控制器DMA读出来的就是随机数据。这四个点覆盖了我见过的大多数SGL失败场景。其中第四点最隐蔽因为代码编译、运行都不会报错只有数据内容不对而且时好时坏。5.2 最小化复现从一条数据块起手遇到SGL问题我的调试建议只有一个把场景降到最小。先构造一次单段、单数据块、固定地址、固定长度的IO确认这条最简单的SGL链能跑通。然后逐步增加数据块数量、让起始地址非对齐、引入多段SGL每一步都做一次完整的数据回读校验。这么做的好处是一旦出错你能立刻把问题范围压缩到最近修改的那一步。比如先单段单块跑通再改成两段数据块如果这时出错问题大概率在你的段计数或Length回填逻辑上。如果一上来就调试一个128段、还有非对齐地址的复杂IO错误现象和数据内容都是乱的你很难判断是描述符格式错了还是数据本身覆盖了。5.3 通过日志确认SGL描述符被正确解析确认SGL是否被正确解析最直接的方法是把命令里的描述符数组内容dump出来和dma_map_sg返回的sg表逐条对照。我习惯在驱动里留一个调试开关打印每个描述符的地址和长度static void sgl_dump_desc(struct nvme_sgl_desc *desc, int nr) { int i; for (i 0; i nr; i) { pr_info(SGL[%d]: addr0x%llx len0x%x key0x%02x%02x%02x%02x\n, i, le64_to_cpu(desc[i].addr), le32_to_cpu(desc[i].length), desc[i].key[0], desc[i].key[1], desc[i].key[2], desc[i].key[3]); } }把这个日志和sg表里的sg_dma_address、sg_dma_len对照一眼就能看出描述符有没有少、有没有错位。特别是跨段情况下要检查Segment Descriptor指向的下一段地址和下一段缓冲区实际地址是否一致。5.4 从PRP切到SGL的平滑切换建议如果决定把一个原本用PRP的驱动加上SGL路径我强烈建议不要直接删除PRP逻辑而是加一个开关让两条路径共存。模块参数、内核启动参数都可以比如默认用PRP设置参数后才走SGL。这样做的理由很简单一旦SGL路径出现问题可以快速切回PRP验证是不是SGL自身的问题而不是整个IO路径都不可用。等SGL在小流量、长时间测试下稳定了再考虑默认开启。我在实际项目里的做法是先拿空闲盘做SGL回读校验再用专门的压力工具跑混合读写最后才上生产流量灰度。整个过程看下来SGL本身不难难的是保证所有边界场景都不出意外。如果你现在也要动SGL我最后提一个经验先确认控制器SGLS支持再拿最小化的单数据块场景做冒烟测试最后再逐步加复杂度。别一上来就把整条IO路径重写成SGL那样出了问题真的很难查。
阅读完成 · 觉得有帮助?