news 2026/10/19 8:47:18

NVMe驱动中SGL描述符构建与调试实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
NVMe驱动中SGL描述符构建与调试实战

有一次我在改一个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控制器里的PRD(Physical 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对对齐的硬性要求有两个:数据块的起始地址必须按Dword(4字节)对齐,数据块的长度也必须是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: addr=0x%llx len=0x%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的基本流程分四步:

  1. 统计sg表里的段数量,估算需要多少条Data Block Descriptor。
  2. 检查这些描述符能否放进一个SGL段。如果不能,需要拆分到多个段,并在段间加Segment Descriptor。
  3. 填充第一个段的Last Segment Descriptor或Segment Descriptor(取决于它是不是最后一个段)。
  4. 从第二个描述符位置开始,逐个填充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的收益边界:什么场景值得用,什么场景不如PRP

4.1 描述符开销的真实对比:别迷信SGL

聊SGL很容易陷入“新的一定更好”的错觉。我直接列一个估算对照表,大家心里就有数了。

场景PRP大致开销SGL大致开销
4KB页对齐单段IO1个Entry,8字节1条Segment + 1条Data Block,32字节
64KB物理连续但起始地址非页对齐约17个Entry,可能还需PRP列表页1条Segment + 1条Data Block,32字节
128KB由32个分散4KB页组成32个Entry + 一页PRP列表1条Segment + 32条Data Block,528字节
多段非页对齐、长度任意的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搞定。控制器拿到地址和长度,直接一路传输,根本不需要关心跨页。

如果你的工作负载大量是这种非对齐、长跨度、多段的IO,SGL可以减少驱动构建描述符的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相关的错误定位,我习惯按下面的顺序检查:

  1. 命令里SGL标识位是不是真的置位了。有时候驱动逻辑分支错误,命令走到PRP分支但DPTR里填了SGL描述符,控制器按PRP解析自然报错。
  2. 命令DPTR指向的第一个描述符是不是Segment Descriptor。这是新手最容易踩的坑,直接放Data Block Descriptor就是错。
  3. Segment Descriptor的Length是不是统计了后续描述符的总字节数,而不是业务数据长度。还要检查这个Length是不是4的倍数。
  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]: addr=0x%llx len=0x%x key=0x%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,那样出了问题真的很难查。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 8:47:10

用Transformer搭建中英互译系统:核心原理与工程实践

1. 项目概述与设计思路1.1 为什么文本翻译选 Transformer 而不是 LSTM前阵子我在本机搭了一套中英文本翻译演示系统&#xff0c;跑通之后最大的感触是&#xff1a;如果放在几年前&#xff0c;拿循环神经网络做同一个任务&#xff0c;光是把代码调成能并行训练这一步&#xff0c…

作者头像 李华
网站建设 2026/10/19 8:46:46

基于深度学习的人脸识别系统开发全指南:从算法选型到工程部署

每年到毕设季&#xff0c;总有人问我人脸识别能不能做、好不好做。我的回答一直是&#xff1a;能&#xff0c;而且很适合&#xff0c;但前提是你得把它当成一个系统工程来做&#xff0c;而不是单纯把模型下载下来跑个demo。这篇文章想分享的&#xff0c;正是我基于深度学习从零…

作者头像 李华
网站建设 2026/10/19 8:46:46

Windows下EMQX启动报错vcruntime140.dll缺失的排查与解决

解压完 emqx-5.3.2-windows-amd64.zip&#xff0c;在 Windows 上运行 bin\emqx start&#xff0c;结果啪一下弹出一个窗口&#xff1a;找不到 vcruntime140.dll&#xff0c;程序直接起不来。我遇到过好几台这样的机器&#xff0c;这类报错十有八九不是 EMQX 本身的问题&#xf…

作者头像 李华
网站建设 2026/10/19 8:46:45

open-code-review:专注 git diff 的结构化代码审查 CLI 工具

1. 这不是又一个“AI写代码”工具&#xff1a;open-code-review 的真实定位与边界你可能刚在 GitHub Trending 上刷到open-code-review这个仓库&#xff0c;点进去看到 README 里写着“LLM-powered code review agent”&#xff0c;心里一咯噔&#xff1a;又来了&#xff1f;又…

作者头像 李华
网站建设 2026/10/19 8:46:45

开源代码审查CLI工具:基于Git Diff与AST的LLM增量分析方案

1. 项目概述&#xff1a;一个真正能落地的开源代码审查 CLI 工具“open-code-review”这个名字乍看平平无奇&#xff0c;但拆开来看——open不是指“开源”&#xff0c;而是指“开放上下文”&#xff1b;code-review也不是走形式的 PR 点赞&#xff0c;而是让大模型真正理解你改…

作者头像 李华