盘一台机器上的盘,最容易被忽略的往往不是读带宽、写带宽这些跑分指标,而是“这块区间我想让它变成干净的零”这类管理动作。几年前给一批测试机做批量镜像还原,脚本里老老实实地用dd往整盘灌/dev/zero,一块 1.6 TB 的企业级盘要跑接近四十分钟,跑完还得再校验一遍。后来有个做固件的朋友看了一眼,说你这纯属用 PCIe 带宽喂 NAND,一条 Write Zeroes 就完事了。当时将信将疑地试了一下,同样的区域,命令下去几秒钟就返回,hexdump抽查读回全是零。那次之后我才认真去读 NVMe 的 NVM Command Set,才发现 Write Zeroes 不是一个孤立的命令,它周围站着一圈语义相近、用途各异的“亲戚”:同门的 Deallocate、Compare、Write Uncorrectable,跨协议的 SCSI WRITE SAME、UNMAP,还有 ATA 那边的 TRIM。这些命令长得像、名字像,语义差别却不小,用错了轻则性能打折,重则把数据擦得莫名其妙。这篇就把 Write Zeroes 这一条命令连同它周围这一圈关系户,从命令帧到主机软件栈,从协议位到实测坑,一次性盘清楚。做存储测试、虚拟化、文件系统、固件验证的同行可以直接拿去对照,刚接触 NVMe 的朋友也能顺着这条线把整个命令集的脉络摸一遍。
1. 主机灌零的时代结束了:Write Zeroes在链路上省了什么
1.1 一次镜像还原暴露出来的带宽浪费
先说清楚问题的原始形态。假设你要把一块盘的某个区域清成零,最朴素的做法是在主机侧准备一块全零的内存 buffer,然后反复下发写命令,把零从内存搬到盘上。这条路在协议层面完全正确,但成本极高:主机要消耗 CPU 去准备 buffer,PCIe 链路要承载这些零字节的传输,SSD 的 DMA 引擎要接收、要解包、要写进 SRAM,最后 FTL 还要为这些零分配物理页、发起 NAND 编程。整个过程里,真正有价值的只有最后一步的“结果”——那些 LBA 读出来是零,前面所有的搬运都是纯开销。
一块盘做整盘清零,按 1.6 TB 算,就是上千 GB 的数据从主机流向设备。就算按每通道 6 GB/s 的理论带宽算,光传输就得几分钟,算上命令开销、队列深度受限、写放大,实际耗时往往是几十分钟级别。更要命的是这些零数据会在 NAND 上留下真实的物理页,SSD 的可用空间被吃掉一块,后面真正写业务数据的时候反而要先做垃圾回收。所以“写零”这件事,主机做得越多,设备越难受。
Write Zeroes 命令的设计动机就是把这件事从主机侧挪到设备侧。主机只告诉设备“从 LBA X 开始,连续 N 个逻辑块,请你保证它们读出来是零”,至于设备内部怎么实现,主机不管。这个语义上的微小转移,带来的是整条数据路径的成本重构:PCIe 上不再有零字节流动,DMA buffer 不需要准备,命令队列里只多了一个 64 字节的条目。协议栈往下走,SSD 的固件拿到这条命令之后,理论上有好几条路可以走,走哪条决定了这条命令到底能省多少。
1.2 命令语义:让介质自己“造零”
严格来说,Write Zeroes 在 NVMe 里的语义定义是:控制器应当使指定 LBA 范围在后续的读命令中返回全零,除非该命名空间另有声明。注意这里的措辞是“返回全零”,它约束的是读回值,而不是物理介质的状态。这个区别非常关键,它留出了后面要讲的 DEAC 语义漂移的空间,也解释了为什么不同 SSD 上同一条命令的性能能差出一个数量级。
从设备的实现角度看,要让一个 LBA 读出来是零,至少有三种做法。第一种最笨也最直接:真的在 NAND 上编程一份零数据。这种做法在语义上最稳妥,读回值绝对确定,但性能上几乎和主机灌零没有本质区别,只是省了 PCIe 传输那一小段,NAND 编程的耗时一分不少,写放大也照样存在。第二种是映射到一个只读的“零页”:FTL 里预先准备一个装满零的物理页,把所有被 Write Zeroes 覆盖的 LBA 都指向这一页,读命令走映射表直接返回这一页的内容。这种做法省掉了绝大部分 NAND 编程,代价是映射表会被撑大,而且这个零页必须被保护起来不能被 GC 回收。第三种是先把这些 LBA 标记为 deallocated,然后在读路径上根据 DLFEAT 字段声明的规则返回零。这一条路最省资源,但它的确定性和“真正写零”之间就有了缝隙。
提示:如果你的业务依赖“写零之后读回一定是零”这个强保证,不要只看控制器是否支持 Write Zeroes,还要看它是否支持 DEAC 位,以及 DLFEAT 字段里声明的读回值是什么。有些型号在不支持 DEAC 的情况下,反而会老老实实走前两种实现,确定性和性能之间要做取舍。
从协议演进的角度看,Write Zeroes 很早就进入了 NVM Command Set,但“设备可以把写零顺手优化成 deallocate”这个开关是后来才补上的。这个补丁让 Write Zeroes 从一个偏保守的语义保证命令,变成了一个既能保证读回值、又能借用 deallocate 性能的命令,但同时也把语义的复杂度往上抬了一档。
1.3 什么样的硬件才真正把这条命令吃进去
一条命令“支持”和“用好”是两回事。判断一个控制器是不是真的把 Write Zeroes 吃进去,我一般看三件事:ONCS 里的支持位、NSFEAT 里的精简置备标志、以及 DLFEAT 里的读回值声明。ONCS 是 Identify Controller 返回的 Optional NVM Command Support 字段,第 3 位对应的就是 Write Zeroes。这一位为 0,命令下去直接返回 Invalid Opcode,主机侧看到的错误码是 0x0001,块层会回退到实际的写零路径,你根本感知不到异常,只是性能上没有任何收益。
NSFEAT 是命名空间级别的特性位,第 0 位如果置位,说明这个命名空间支持精简置备,也就是说设备允许物理上不分配空间而逻辑上仍然存在。这一位和 Write Zeroes 的第三种实现强相关,如果它不置位,设备就不太可能把写零映射成 deallocate。DLFEAT 在 Identify Namespace 的偏移 33 字节,低三位是 DLRV,定义了 deallocate 之后的 LBA 读回来是什么:三个 0 表示全零,001 表示全一,010 及以上表示未定义。这一位的取值直接决定了你怎么理解一条 Write Zeroes 之后读到的数据。
我实际碰到过一个比较典型的坑:某型盘在 ONCS 里置了 Write Zeroes 位,DLFEAT 声明 DLRV 为 0,看起来一切正常,但真跑起来发现一条覆盖 128 MiB 的 Write Zeroes 命令耗时接近主机灌零的 70%。用厂商工具抓了内部 trace 才看明白,固件的实现路径是“把零数据在 SRAM 里拼出来再编程 NAND”,完全没有走映射表。这种实现协议上挑不出毛病,但对你来说这条命令就是白支持。所以判断一条命令值不值得用,光看能力位不够,还得做实测——同样的区域,Write Zeroes 和主机灌零各跑一遍,看耗时是不是差出一个数量级以上,差不到这个量级,就说明设备内部还是老老实实在写 NAND。
2. CDW10到CDW12:一条命令帧里埋着的三个语义开关
2.1 SLBA与NLB的边界计算
Write Zeroes 的命令帧结构很紧凑,属于典型的 NVMe I/O 命令布局。前两个 qword 是保留和元数据指针,接着两个 qword 是 PRP1 和 PRP2,这两项在 Write Zeroes 里通常用不上,因为命令本身不携带数据载荷,但协议上仍然保留字段位置以保持命令格式对齐。真正的参数从 CDW10 开始。
CDW10 和 CDW11 拼成一个 64 位的 SLBA,也就是起始逻辑块地址的低 32 位和高 32 位。CDW12 的低 16 位是 NLB,也就是 Number of Logical Blocks,注意它是零基的,填 0 表示 1 个逻辑块,填 2047 表示 2048 个逻辑块。这一点是很多人第一次写测试脚本时容易弄错的地方:nvme write-zeroes的--block-count参数同样是零基的,传 2047 才是 2048 个块。如果按“块数”的直觉填 2048,实际会覆盖 2049 个块,最后多擦一个块,如果那个块上正好有文件系统的元数据,问题就出来了。
NLB 是 16 位,理论最大 65536 个逻辑块,按 4 KiB 块大小算就是 256 MiB 单条命令的覆盖范围。这个上限在某些大块清零场景下会显得不够,比如做整盘擦除,几 TB 的空间要拆成上万条命令。好在 NVMe 的命令队列深度通常很深,主机侧可以并发下发,实际吞吐不受单条命令上限的严重制约。但手工写脚本的时候要注意,一次覆盖超过 65536 块就必须拆分,否则 NLB 会溢出回绕,实际清掉的范围和你预期的完全不同,这种 bug 在校验阶段才会暴露,排查起来很费劲。
还有一点容易被忽略:SLBA + NLB 不能越过命名空间的 NSZE 边界。如果越界,控制器返回 LBA Out of Range 错误,状态码是 0x0080。有些脚本为了省事直接按整盘大小算 NLB 不拆分,在某些容量不是 2 的整数次幂的盘上就会踩到这个边界,最后一条命令报错,前面的都成功了,看起来像是“清了 99%”。
2.2 DEAC位:省下NAND编程的那一下
CDW12 的 bit 25 是 DEAC 位,全称 Deallocate。这一位置 1 的时候,语义发生了一次微妙但重要的变化:控制器被允许把这段 LBA 当作 deallocate 处理,也就是放弃“必须真的写零”这个强约束,转而采用“标记为空闲”的做法。从主机的角度看,命令还是同一条,参数还是同一组,只是多置了一个位,但设备内部的执行路径可能完全换了一条。
DEAC 置位带来的最大好处是省掉 NAND 编程。设备不需要为这些 LBA 分配物理页,只需要在映射表里把它们标记成无效,或者指向一个共享的零页。对于 SSD 来说,这几乎等于零成本操作,命令的完成时间可以从毫秒级降到微秒级。在做整盘清零或者大批量稀疏文件初始化的时候,这个差异非常可观。
代价是读回值的确定性下降。DEAC 置位之后,这些 LBA 读回来是不是零,取决于 DLFEAT 的 DLRV 声明。如果 DLRV 是 0,读回全零,和真正的写零没有区别;如果 DLRV 是 1,读回全一;如果是 2 及以上,读回值未定义,可能是零、可能是旧数据、可能是随机值。这就意味着你在用 DEAC 之前必须先读一次 DLFEAT,确认你的业务逻辑能接受对应的读回值。虚拟化场景里如果把这个区域再映射给虚拟机,虚拟机看到的可能是全一,某些文件系统会把它当成坏块标记,问题就很隐蔽了。
注意:DEAC 位在较早的协议版本里可能不存在。如果你的目标设备固件比较老,置了这一位可能会被当成保留位忽略,命令照常执行但走的是普通写零路径,性能上没有收益,也不会报错。这种“静默忽略”是最难排查的一类问题,建议在测试阶段先用一条小命令验证耗时,确认 DEAC 真的生效了再上批量。
再补充一个实操细节:某些控制器在 DEAC 置位且 NLB 很大时,会做内部分段处理,分段的粒度可能是 128 KiB 或者 1 MiB。分段本身不影响语义,但会让命令的实际耗时呈现非线性,比如 NLB 从 255 涨到 256 时耗时突然翻倍。做性能对比测试的时候要把这个因素考虑进去,不要拿一条 4 KiB 的命令和一条 128 MiB 的命令直接比吞吐。
2.3 DLFEAT、NSFEAT与ONCS:能力位要连着看
三个能力位字段分布在两个数据结构里,第一次接触很容易看混。ONCS 在 Identify Controller 里,是控制器级别的能力,说明“这个控制器支持不支持这条命令”。NSFEAT 和 DLFEAT 在 Identify Namespace 里,是命名空间级别的特性,说明“这个命名空间上这条命令的语义是什么”。同一个控制器下挂多个命名空间,ONCS 是共享的,NSFEAT 和 DLFEAT 可以各不相同,这是做多命名空间测试时必须注意的地方。
具体到字段位,ONCS 第 3 位是 Write Zeroes 支持位,第 1 位是 Write Uncorrectable,第 0 位是 Compare,第 2 位是 Dataset Management。这四位经常是一起出现的,做命令集验证的时候可以一次性全部检查。NSFEAT 第 0 位是 Thin Provisioning,第 3 位是 Deallocated or Unwritten Logical Block Error Support,前者决定设备是否可能把写零优化成 deallocate,后者决定读一个未写过的 LBA 会不会直接报错而不是返回零。
DLFEAT 在偏移 33 字节,低三位 DLRV 定义 deallocate 后的读回值,第 3 位定义读未写块是否报错。这个第 3 位和 NSFEAT 的第 3 位是联动的:两个都置位,读未写块才会真正返回错误状态。如果你的业务逻辑依赖“读到零”来判断区域是否为空,而设备配成了“读未写块报错”,那么一次 Write Zeroes 之后去读,得到的是错误而不是零,校验脚本会误判成写零失败。
我用一个小脚本一次性把这三个字段都读出来做对照,比手工一条条 grep 效率高得多:
#!/bin/bash DEV=${1:-/dev/nvme0} NS=${2:-${DEV}n1} echo "=== Controller level (ONCS) ===" nvme id-ctrl "$DEV" | grep -E "^oncs|^oacs|^vwc" echo "=== Namespace level (NSFEAT / DLFEAT) ===" nvme id-ns "$NS" | grep -E "^nsfeat|^dlfeat|^nsze|^ncap|^nuse" echo "=== LBA format ===" nvme id-ns "$NS" | grep -E "^lbaf"把这三组字段放在一起看,基本就能对这台设备上 Write Zeroes 的行为做出判断:ONCS 第 3 位为 1 是前提,NSFEAT 第 0 位为 1 说明有优化的可能,DLFEAT 的 DLRV 值决定了 DEAC 之后读回什么。三个都指向“优化路径可用”的配置,才值得在上面做大规模清零。
3. 同门近亲:Deallocate、Compare、Write Uncorrectable各自守着哪块地盘
3.1 Deallocate与Write Zeroes的语义鸿沟
Dataset Management 这条命令在 NVM Command Set 里的定位是“批量管理数据集的属性”,它最常见的用法是其中的 Deallocate 属性。很多人把 Deallocate 和 Write Zeroes 当成同一件事的两种叫法,这是最容易出问题的一种误解。两者的共同点只是“都会让原来的数据不可恢复”,除此之外几乎处处不同。
Deallocate 的语义是“告诉设备,这些 LBA 上的数据我不再需要了,你可以回收物理资源”。它约束的是设备的资源管理行为,不约束读回值。设备回收之后,这些 LBA 读回来是什么,完全由 DLFEAT 的 DLRV 决定,可能是零、可能是全一、可能是未定义。Write Zeroes 则明确约束读回值必须是零。换句话说,Write Zeroes 是一个“结果导向”的命令,Deallocate 是一个“资源导向”的命令。前者保证你能读到什么,后者只保证设备能省下什么。
从性能上看,Deallocate 几乎总是比 Write Zeroes 快,因为它不需要为读回值做任何保证。从安全性上看,Deallocate 也不比 Write Zeroes 差,两者都不会真的把数据从 NAND 上擦掉,物理数据仍然可能残留在介质上,真正的安全擦除要靠 Sanitize 命令。所以如果你的目的是“让这些位置读出来是零”,用 Deallocate 是有风险的;如果目的是“把这些物理资源还给设备”,用 Write Zeroes 反而浪费。
两者在主机软件栈里对应的是两条不同的路径。blkdiscard不带-z走的是 DISCARD,也就是 Deallocate;带-z走的是 ZEROOUT,也就是 Write Zeroes。文件系统的 punch hole 走 Deallocate,zero range 走 Write Zeroes。分清楚这两个语义,是后面所有操作选择的前提。
命令参数上,Deallocate 比 Write Zeroes 复杂一些。它通过 CDW10 的低 8 位指定范围数量减一,最多 256 个范围,剩下的字段通过 PRP 指向一块内存,里面是一组 16 字节的范围描述符,每个描述符包含 4 字节的上下文属性、4 字节的逻辑块数量和 8 字节的起始 LBA。这个设计允许一条命令回收最多 256 段不连续的区间,在文件系统大规模释放块的场景下很有用。Write Zeroes 只支持一段连续区间,超出就要拆成多条命令。
3.2 Compare:写零前的“确认键”
Compare 命令在 ONCS 的第 0 位,功能是把主机提供的数据和盘上对应 LBA 的数据做逐字节比较,全部一致就返回成功,有任何不一致就返回 Compare Failure,状态码 0x85。这条命令本身不改变任何数据,是一个只读操作,但它在写零相关的流程里扮演了一个很有意思的角色。
典型用法是在做“条件写零”这类场景:先把预期内容读出来比较,确认这块区域的状态和预期一致,再下 Write Zeroes。比如做镜像还原的时候,某些机器的旧数据是敏感的,需要确认这块区域已经被前一次写零覆盖过,这时候 Compare 就能派上用场。它不是必需的,但它是验证语义的一个廉价手段,比全量读回来在主机侧比对要省带宽——虽然 Compare 的实际数据流仍然是主机到设备,但它省掉了主机侧的比对逻辑和随后的读写往返。
Compare 的参数结构和 Write 命令几乎一样,CDW10 和 CDW11 拼成 SLBA,CDW12 的低 16 位是 NLB,只是没有数据写入方向,数据是从主机流向设备做比较用。这里有一个容易忽略的点:Compare 失败的时候,控制器的返回是命令级别的失败,而不是读错误。也就是说,如果某个 LBA 读出来就是介质错误(比如真的坏了),Compare 返回的是 Unrecovered Read Error,状态码 0x0281,和普通的比较不匹配是两回事。区分这两种失败,在自动化脚本里很重要,前者说明介质有问题,后者说明数据不符合预期。
Compare 的另一个用途是在调试 Write Zeroes 的时候做交叉验证。下完 Write Zeroes 之后,用 Compare 传一份全零的数据去比,比得过说明读回确实是零,比不过说明设备的实现和你的预期有出入。这比写脚本去读一整段再在内存里判断要省事,也更贴近协议层的视角。
3.3 Write Uncorrectable:造坏块的正当用途
Write Uncorrectable 在 ONCS 第 1 位,功能是让控制器把指定 LBA 范围标记成不可纠正错误状态,之后读这些 LBA 会返回 Unrecovered Read Error。从名字上看这像是一条“破坏性”命令,实际上它的正当用途全是测试和验证。
在存储测试里,Write Uncorrectable 是构造坏块场景的标准手段。你要验证文件系统的坏块处理逻辑、验证 RAID 或纠删码的重建路径、验证上层应用对读错误的重试策略,总不能真的等一块盘自然老化出坏块。用 Write Uncorrectable 定点标记几个 LBA,读的时候就会触发错误路径,整个过程可控、可重复、可恢复——恢复的方式是 Write Zeroes 或者普通的 Write 覆盖这几个 LBA,因为写入会重新建立映射,把不可纠正状态清掉。
这一点和 Write Zeroes 形成了一个有趣的闭环:Write Zeroes 可以修复 Write Uncorrectable 造成的错误状态。所以在做测试序列设计的时候,可以拿 Write Uncorrectable 造错误,验证完再拿 Write Zeroes 清掉,不需要重新格式化整个命名空间。这个组合我在做文件系统容错验证的时候用过很多次,比反复 mkfs 省时间得多。
参数上 Write Uncorrectable 和 Write Zeroes 几乎一样,SLBA 加 NLB,同样受 65536 块上限约束。区别是它没有 DEAC 位这种优化开关,语义非常直接,就是“把这些位置标记成坏”。还有一点:某些控制器对 Write Uncorrectable 的覆盖范围有限制,比如只允许标记一定数量以内的 LBA,超过就返回错误。这个上限在 Identify Controller 里不一定有对应的字段声明,只能实测。
4. 跨协议的远房亲戚:WRITE SAME、UNMAP与ATA TRIM的映射关系
4.1 SCSI WRITE SAME的NDOB位与UNMAP位
如果从 SCSI 那边过来,看到 Write Zeroes 的第一反应是“这不就是 WRITE SAME 吗”。这个直觉大体正确,但 WRITE SAME 的语义比 Write Zeroes 更宽。SCSI 的 WRITE SAME(10) 和 WRITE SAME(16) 命令的原始语义是:把主机提供的一个逻辑块的内容,重复写入到指定的 LBA 范围。它允许主机传任意一个块的数据,设备负责复制填充,所以它不只是能写零,理论上能写任何重复模式。
WRITE SAME 的 CDB 里有两个关键位。一个是 NDOB,No Data-Out Buffer,置位的时候主机不传数据,设备自己决定写什么,这时候写的内容由设备的实现决定,规范建议是全零但不是强制。另一个是 UNMAP 位,置位的时候命令的语义直接退化成 unmap,也就是 deallocate,完全不再写任何数据。这两个位的组合决定了 WRITE SAME 到底走哪条路径。
从 NVMe Write Zeroes 的视角看,它对应的是 SCSI WRITE SAME 在 NDOB 置位且 UNMAP 不置位时的场景,也就是“不传数据、设备自己写零”。至于 WRITE SAME 的 UNMAP 路径,对应的是 NVMe 的 Deallocate。这个映射关系在做协议转换层或者兼容性测试的时候特别重要:一个 SCSI 主机过来的 WRITE SAME 命令,你得先解析 UNMAP 位和 NDOB 位,再决定翻译成 NVMe 的 Write Zeroes 还是 DSM Deallocate,翻译错了语义就变了。
我见过一次典型的翻译错误:某中间层把 SCSI 的 WRITE SAME 无脑翻译成 NVMe 的 Write Zeroes,结果主机发的是 UNMAP 置位的 WRITE SAME,本意是“把空间还给设备”,翻译之后变成了“保证读回零”,设备走了映射零页的路径,物理空间一点没省下来,性能还不如直接 deallocate。这种问题在协议转换产品里不算罕见,做兼容性验证的时候要专门构造带 UNMAP 位的 WRITE SAME 来测。
SSI 侧还有一个 UNMAP 命令本身,opcode 是 0x42,通过参数列表一次提交多个范围,和 NVMe 的 DSM Deallocate 在功能上是对齐的。两者的范围数量上限不同,SCSI UNMAP 的参数列表理论上可以很长,NVMe 的 DSM 单条命令最多 256 个范围。做跨协议性能对比的时候,这个粒度差异会体现在命令数量上。
4.2 ATA世界为什么没有真正对等的命令
ATA 这边对应的命令是 TRIM,从 SATA 协议的角度看,TRIM 的语义非常接近 NVMe 的 Deallocate:告诉设备这些 LBA 不再需要,设备可以回收。它没有 NVMe Write Zeroes 那种“保证读回零”的语义,也没有 SCSI WRITE SAME 那种“重复填充一个块”的能力。所以在 ATA 世界里,如果你想“写零”,只能老老实实从主机灌零,TRIM 只是告诉设备“这些位置的数据作废了”,读回来是什么完全取决于设备实现。
这个差异的根源在于 ATA 协议的设计年代和定位。TRIM 是在 SSD 普及初期为了缓解写放大和性能衰减引入的,核心诉求是“让操作系统告诉设备哪些块真的没用了”,并不涉及读回值的保证。NVMe 是后设计的协议,直接吸收了 TRIM 的经验,并在此基础上把“写零”这个高频需求单独拿出来做成了一条命令,顺便还加了 DEAC 位让设备有优化空间。所以 NVMe 的 Write Zeroes 在概念上是 SCSI WRITE SAME 和 ATA TRIM 的一个折中产物:既有 SCSI 那边的读回值保证,又借了 ATA 那边的资源回收思路。
在 SATA 上做类似 Write Zeroes 的动作,比较接近的手段是hdparm --trim-sector-ranges或者blkdiscard,但这些都是 discard 语义,不保证读回零。如果业务确实需要读回零,就只能走主机灌零,没有别的选择。这也是 NVMe 盘在虚拟化和云场景里比 SATA 盘更受欢迎的原因之一:批量初始化、稀疏文件、快照克隆这些操作,NVMe 有原生命令支持,SATA 只能靠主机硬扛。
4.3 一张跨协议语义对照表
把几个协议的对应命令放在一张表里对照,很多模糊的地方会一下子清楚。下面这张表是我自己整理出来贴在工位上的,做跨协议测试的时候随时查:
| 协议 | 命令 | 语义约束 | 读回值保证 | 资源回收 |
|---|---|---|---|---|
| NVMe | Write Zeroes (DEAC=0) | 指定 LBA 读回为零 | 强保证 | 视实现而定 |
| NVMe | Write Zeroes (DEAC=1) | 允许按 deallocate 处理 | 由 DLFEAT 决定 | 强保证 |
| NVMe | DSM Deallocate | 标记 LBA 不再需要 | 无保证 | 强保证 |
| NVMe | Write Uncorrectable | 标记 LBA 不可纠正 | 读回报错 | 无 |
| NVMe | Sanitize | 物理层面清除数据 | 不适用 | 强保证 |
| SCSI | WRITE SAME (NDOB=1, UNMAP=0) | 设备写零 | 通常保证 | 视实现而定 |
| SCSI | WRITE SAME (UNMAP=1) | 等价于 unmap | 无保证 | 强保证 |
| SCSI | UNMAP | 标记 LBA 不再需要 | 无保证 | 强保证 |
| ATA | TRIM | 标记 LBA 不再需要 | 无保证 | 强保证 |
这张表里最值得琢磨的是 Sanitize 那一行。很多人把 Write Zeroes 当成“安全擦除”来用,这是一个相当危险的误解。Write Zeroes 只是在逻辑层面让读命令返回零,物理介质上的旧数据可能仍然残留,通过某些手段(比如芯片级读取)仍然有可能恢复。真正的安全擦除只有 Sanitize 或者 Format NVM 带 Crypto Erase 才能做到。如果业务场景涉及敏感数据销毁,Write Zeroes 只能作为流程中的一步,最终必须用 Sanitize 收尾。
5. 从REQ_OP_WRITE_ZEROES到nvme-cli:命令在主机的两层入口
5.1 块层与文件系统的触发链路
Linux 块层里有一个专门的 bio 操作类型叫REQ_OP_WRITE_ZEROES,它对应的就是 NVMe 的 Write Zeroes。这个操作类型的引入就是为了让上层能够表达“我要这块区域变成零”的意图,而不必亲自准备零数据。块层的通用函数blkdev_issue_zeroout()是这条链路的入口,它会先尝试用REQ_OP_WRITE_ZEROES下发,如果设备不支持或者返回错误,再回退到实际的写零页路径。
这个回退逻辑值得仔细说一下,因为它是很多“为什么 Write Zeroes 没生效”问题的根源。blkdev_issue_zeroout()的行为受 flags 影响,最主要的是BLKDEV_ZERO_NOUNMAP。如果不加这个 flag,函数在设备不支持 Write Zeroes 的时候可能会尝试用 discard 代替,语义就从“保证读回零”退化成了“只回收资源”。加了BLKDEV_ZERO_NOUNMAP就禁止这个退化,设备不支持就直接走实际的写零页。所以在写内核模块或者自己的工具时,如果业务语义要求读回必须是零,一定要把这个 flag 带上。
文件系统这边,ext4 和 xfs 都提供了fallocate的FALLOC_FL_ZERO_RANGE标志,对应的就是“把这段范围清零”。内核实现里会调用blkdev_issue_zeroout(),最终落到设备上就是 Write Zeroes。另一个相关的是FALLOC_FL_PUNCH_HOLE,对应的语义是“打洞”,走的是 discard 路径,落到设备上是 Deallocate。这两个标志在应用层面看起来都是“释放空间”,实际行为差异很大,选错了会导致文件读出来是零还是随机值。
我做过一个对比测试:在一个支持 Write Zeroes 的命名空间上,对一个 1 GiB 的文件分别用fallocate -z(zero range)和fallocate -p(punch hole)处理,然后立刻dd读回来。-z之后读回全是零,-p之后读回是一堆看起来随机的数据,实际上是设备上残留的旧数据,因为 punch hole 走的是 Deallocate,没有读回值保证。这个差异在备份、克隆、快照这些场景里影响很大,如果克隆工具用的是 punch hole,克隆出来的文件可能有部分区域是脏的。
5.2 blkdiscard、fallocate与BLKZEROOUT的实操差异
在用户态,往块设备上写零有三条常见路径,分别是blkdiscard -z、fallocate的文件范围清零,以及ioctl的BLKZEROOUT。这三条路径最终都通向blkdev_issue_zeroout(),但在参数和适用范围上有区别。
blkdiscard -z /dev/nvme0n1是最直接的一条,作用于整块块设备。不带-z的时候它走 discard,也就是 Deallocate;带-z的时候走 zeroout,也就是 Write Zeroes。这个命令的语义是“把整个设备或者指定区间清零”,用起来最省事,但也最危险,一旦作用在挂载了文件系统的设备上,数据会直接没。我在生产环境里只在确认设备未挂载的情况下用,或者加--force参数明确表达这个意图。
fallocate -z -l 1G filename作用于文件,底层调FALLOC_FL_ZERO_RANGE,适合在文件系统层面做清零。它的好处是只影响文件数据范围,不动元数据,也不影响文件系统的其他部分。缺点是依赖文件系统支持,ext4、xfs、btrfs 这几个主流文件系统都支持,但一些老的或者特殊的文件系统可能不支持,fallocate会回退到实际的写零。回退的时候用户态是感知不到的,只有看strace或者内核 trace 才能发现。
BLKZEROOUT是 ioctl,作用于块设备的指定区间,参数是起始字节和长度,内部会转成 LBA 和 NLB。它比blkdiscard更细粒度,可以只清一段。写自己的工具的时候用这个 ioctl 比较合适,比手工组 NVMe 命令要安全得多。这个 ioctl 在老版本内核里可能不存在,做跨版本兼容的时候要检查返回值。
5.3 nvme-cli手工发命令的完整示例
nvme-cli 提供的write-zeroes子命令是验证这条命令最直接的工具,绕过了块层,直接构造 NVMe 命令帧。做设备兼容性测试或者排查块层问题的时候,我一般先用它确认设备本身的行为,再往上查块层的翻译。
# 第一条:查能力和 LBA 格式,确认支持 Write Zeroes 且块大小为 4 KiB nvme id-ctrl /dev/nvme0 | grep -E "^oncs|^vwc" nvme id-ns /dev/nvme0n1 | grep -E "^nsfeat|^dlfeat|^lbaf" # 第二条:从 LBA 0 开始清零 2048 个块,-c 是零基,所以传 2047 nvme write-zeroes /dev/nvme0n1 --start-block=0 --block-count=2047 # 第三条:带 DEAC 位,允许设备走 deallocate 优化路径 # 先确认 nvme-cli 版本支持 --deac 参数,老版本可能是 --deac=1 的形式 nvme write-zeroes /dev/nvme0n1 --start-block=0 --block-count=2047 --deac # 第四条:用 DSM Deallocate 做对照,验证两种路径的耗时差异 nvme dsm /dev/nvme0n1 --slbs=0 --blocks=2048 --ad # 第五条:用 Compare 验证读回值确实是零 # 先准备一个 4 KiB 的全零文件 dd if=/dev/zero of=/tmp/zero.bin bs=4096 count=1 nvme compare /dev/nvme0n1 --start-block=0 --block-count=0 \ --data-size=4096 --data=/tmp/zero.bin这里有几个参数细节值得说明。--block-count和--blocks都是零基的,传 2047 表示 2048 个块,这个和 NVMe 协议里的 NLB 定义一致,nvme-cli 没有做额外的偏移。--start-block是 SLBA,直接对应协议字段。--data-size是 Compare 时主机传输的数据长度,必须是块大小的整数倍。--deac参数在比较新的 nvme-cli 版本里才有,如果手上的版本较老,可能需要升级到 1.16 以上。
再补一个实测技巧:做性能对比的时候,每组至少跑 5 次,去掉第一次。第一次跑的时候设备的映射表和缓存状态都是冷的,耗时会明显偏高,用来和后面的数据平均会失真。另外在做大批量清零之前,先把测试区域用普通 Write 写满,把映射表建立起来,再对比 Write Zeroes 和灌零的耗时,这样测出来的才是稳态差异,否则前面那条“区域本来就是空的”会让 Write Zeroes 显得格外快。
6. 踩坑实录:命令返回成功、读回却不是零的几种情况
6.1 控制器把Write Zeroes悄悄翻译成普通写
这是最常见也最难受的一类坑。命令返回状态是成功,主机侧看不到任何异常,但实际耗时和主机灌零差不多,说明设备内部根本没有走优化路径。前面提过的那块 128 MiB 写零耗时接近灌零 70% 的盘就是这种情况,固件把零数据在 SRAM 里拼出来再编程 NAND,协议上完全合规,因为规范只要求“读回是零”,没规定必须用哪种实现。
排查这类问题,第一步是把 Write Zeroes 的耗时和主机灌零的耗时并排测。如果两者差异小于 2 倍,基本可以断定设备没有走优化路径。第二步是看设备的 SMART 信息,注意Data Units Written这个计数:如果一条 Write Zeroes 之后这个计数明显增长,说明设备真的往 NAND 上写了数据;如果基本不动,说明走的是 deallocate 或者映射零页。这个观察方法比抓 trace 简单得多,nvme-cli 的smart-log子命令就能读。
我碰到过一个更隐蔽的变种:设备的Data Units Written不增长,看起来像是走了优化路径,但读回值校验的时候发现中间有几段不是零。查下去发现是设备把 Write Zeroes 翻译成了一连串的 Deallocate,而 DLFEAT 的 DLRV 声明是 0,理论上应该读回零,但中间有几段 LBA 之前被 Write 过,映射表里的项没有被正确覆盖,读回来是旧数据。这是一个固件 bug,触发条件是“先写后零再读”,做测试序列的时候把顺序打乱就能复现。这种 bug 只能靠长时间跑随机序列来抓,单条命令的测试覆盖不到。
6.2 元数据区与混合命名空间的连带效应
NVMe 的命名空间可以带元数据,每个逻辑块除了 4 KiB 的用户数据外,还可以有 8 字节或 64 字节的元数据,由 LBA Format 里的 MS 字段声明。Write Zeroes 命令默认只作用于用户数据区,元数据区怎么处理,不同实现的行为不一致。有些设备会把元数据也清零,有些保留原值,有些返回未定义。如果你的业务依赖元数据区的内容,写零之后元数据变成了什么必须实测确认,不能假设。
这个坑在做文件系统校验的时候会冒出来:文件系统把校验和存在元数据区,写零把用户数据清了但元数据没清,读回的时候校验和与全零的用户数据对不上,文件系统报出校验错误。表面上看起来像是“写零把文件系统写坏了”,实际是元数据的处理不符合预期。规避的办法是看 Identify Namespace 的 LBA Format 表,确认 MS 字段,如果非零就要在写零之后单独处理元数据,或者干脆用 Format NVM 重建命名空间。
混合命名空间是另一个容易踩的地方。同一个控制器下挂多个命名空间,各自的 LBA Format 可能不同,块大小可能是 512 字节也可能是 4 KiB。Write Zeroes 的 NLB 是按当前命名空间的逻辑块算的,如果你在脚本里按 4 KiB 计算 NLB,实际下发到一个 512 字节块的命名空间上,覆盖范围会大出 8 倍,把后面的区域一起清掉。做多命名空间测试的时候,一定要在每条命令之前重新读一次 ID-NS 确认块大小,不要沿用上一个命名空间的假设。
还有一种情况是命名空间被格式化过之后,LBA Format 变了。比如原来用 4 KiB 块,Format NVM 之后切到 512 字节块,之前存下来的 LBA 和 NLB 就全部失效了。自动化脚本里如果硬编码了这些值,格式化之后会算错范围。我的做法是每次操作前都重新解析 ID-NS,把 NSZE、LBAF、LBADS 这几个值读出来现算,多花几毫秒,省掉一整类 bug。
6.3 持久化与掉电语义的边界
Write Zeroes 完成之后,这些零是不是立刻持久化了?答案取决于命名空间有没有易失写缓存(VWC)。如果 VWC 置位,命令完成只表示数据进了设备的缓存,还没有落到 NAND 上。这时候掉电,重新上电之后读那些 LBA,读到的可能是旧数据,写零的效果丢了。要保证持久化,命令完成之后需要跟一条 Flush,等 Flush 返回才算落地。
这个语义和普通 Write 是一样的,但写零场景下更容易被忽略,因为大家潜意识里觉得“擦除”是个很重的操作,应该立刻生效。实际上对于走映射零页或者 deallocate 路径的实现,写零操作在缓存里就是一个标记位,掉电后缓存丢了,标记也就丢了,映射表恢复到掉电前的状态,读回来还是旧数据。做掉电测试的时候如果把 Write Zeroes 当成“原子擦除”来设计测试用例,结果会不符合预期。
注意:VWC 置位的设备上做批量写零,一定要在批次结束之后跟一次 Flush。如果设备支持