TRIM / Discard 与文件系统协同 · 技术探讨
从「删除」这三个动作在协议层、文件系统、器件内部的三次现身出发,把通知机制、责任边界、失效形态与工程落地讲透的一份方法论参考 | 编制日期 2026-10-14 | 版本号 Rev 1.0
「删掉一个文件」这个动作,在现代存储栈里会发生三次:文件系统的逻辑删除、块设备层的丢弃通知、器件内部的物理失效处理。三次各自解决不同的问题,彼此之间靠一份契约连着——这份契约就是 TRIM。
工程现场围绕 TRIM 的争论,绝大多数不是「TRIM 好不好」,而是「谁该在什么时候发这条通知、发多大粒度、发错之后会怎样」。更常见的是把 TRIM 误解成安全机制:以为「告诉器件这里可以随便擦」是加密擦除的替代品,或者以为发了 TRIM 就有掉电一致性。这两种误解在嵌入式产品里代价尤其高。
这份文档不讲某个厂商的私有算法,也不给具体数值,只把三件事讲清楚:通知这条链上每段各做什么、三层之间是什么契约关系、哪些环节写错了会导致数据出错。围绕这三点,把协议层形态、各文件系统的删除路径、器件侧回收策略、分工边界、失效形态、观测手段、嵌入式约束串成一条完整链路。
结论: TRIM 是一份「意图通知」,它的价值只在「主机知道、器件不知道」的时候才成立。 器件自己能推出来的(比如根据映射表就能判定哪些块全是垃圾),通知是多余的; 主机以为删了却没有通知,器件就只能一直保留这些块,让垃圾堆积、写放大上升、可用容量提前见底。 所以并行 NAND 方案几乎必须依赖 TRIM,而内置控制器方案对它的依赖度天然低得多——这正是责任边界画在哪里的经典案例。 同时必须记住:TRIM 是性能与寿命的优化手段,不是数据安全机制,它既不替代文件系统的日志与一致性设计,也不替代掉电保护协议。 本文所有比例、粒度关系与数量级判断均为方向性结论,实际设计一律以正式规范、datasheet 与实测为准。
读者路径
- 先把概念与边界钉清楚:第 1 章(TRIM 是什么、承诺什么、不承诺什么)→ 第 2 章(三层删除语义的分工)。
- 想知道器件收到通知之后干了什么:第 3 章(标记失效、垃圾回收、映射表、掉电)。
- 要打通主机侧的通路:第 4 章(SATA / NVMe / eMMC / SD / USB 桥接各自的形态与断点)。
- 要在具体文件系统上做取舍:第 5 章(日志式、F2FS、CoW、ext、FAT)→ 第 6 章(与磨损均衡、垃圾回收的耦合)。
- 要划定责任边界:第 7 章(主机侧与设备侧的分工,内置控制器 vs 并行 NAND 的判据)。
- 现场出了问题:第 8 章(失效与误用,含最危险的挂载表过期)→ 第 9 章(验证与观测)→ 附录 C(排查决策树)。
- 做数据库 / 嵌入式负载:第 10 章(数据库与日志类负载)→ 第 11 章(嵌入式特殊约束)。
- 要做设计落地:第 12 章(选型、挂载参数、上电自检、检查清单)+ 附录 A / B / D(协议与文件系统对照、术语速查)。
1. TRIM 究竟是什么
这一章只做一件事:把「TRIM 是什么」这个问题问到不能再往下拆。核心结论有三句话——TRIM 是一份来自主机的意图通知;它在协议层是一类命令而不是一种机制;它的语义边界远比大多数人以为的窄。把第三条讲清楚,就能同时解释「为什么它不是安全机制」和「为什么它无法替代文件系统的日志」。
1.1 从文件系统的删除说起
在有 TRIM 之前,「删除一个文件」在系统看来是这样一件事:把目录项去掉,把 inode 的引用计数减一,如果归零就释放 inode,同时把数据块归还给文件系统的空闲结构。整个过程里,数据块的内容通常一个字节都没有变——它只是从「已分配」变成了「空闲」。
这件事在机械盘上完全没问题。盘片的扇区是可直接覆写的,旧内容被新内容覆盖之后就真的没有了,操作系统根本不需要通知介质「这块我不要了」。介质自己知道:下一个写到这个位置的请求会把旧内容覆盖掉。
闪存改变了这个前提。SSD 内部的闪存转换层把逻辑块散列到不同的物理页上,一个逻辑块被删除之后,它所在的物理页不会自动变成可擦状态,因为控制器无法判断这些页里是否还装着别的逻辑块的有效数据。控制器只能靠自己的启发式规则去猜——按写入时间、擦写次数、温度、访问频次挑出「看起来冷」的页——而这些猜测在真实业务负载里经常是错的。
于是缺口就出现了:文件系统知道哪些块真的不要了,器件不知道。TRIM 这个动作存在的全部理由,就是把这个只有主机才知道的信息送出去。
| 观察层次 | 「删除」在这里意味着什么 | 谁在这里做决定 | 介质内容变了吗 |
|---|---|---|---|
| 文件系统 | 释放 inode 与数据块,把块标记为空闲 | 文件系统自己 | 没变 |
| 块设备层 | 把一段「以后不再需要」的 LBA 区间转发给下层 | 块设备层只转发,不判断 | 没变 |
| 器件内部 | 把对应的物理页标记为无效,进入回收候选 | 控制器或主机侧 FTL | 通常也没变,只是标记变了 |
这张表值得多看两眼:三层的「删除」都不是擦除。真正的物理擦除发生在更后面,由垃圾回收或预留块回收在合适的时机触发。把「删除」和「擦除」混为一谈,是后面很多误解的源头。
1.2 TRIM 命令的协议层定义
要理解 TRIM 的定义方式,最好的办法是先把它拆成三个正交的维度——这三个维度贯穿全文,也是本文反复要求分开的三件事。
- 协议层命令:怎么通知。命令长什么样、地址怎么表达、粒度受什么约束、完成语义是什么。
- 文件系统策略:什么时候删、删多少。什么时候认为一块不要了、攒多大一批发出去、发之前要不要先落盘。
- 器件回收:怎么释放。收到通知之后标记什么、什么时候合并、什么时候真正擦除。
三者可以任意组合:同一套协议命令可以配不同的文件系统策略,也可以配完全不同的器件回收实现。把它们混成一段话,是读不懂 TRIM 文档、也设计不好 TRIM 通路的主要原因。
回到协议层。TRIM 这一类命令在不同协议里的名字不同,但共同骨架完全一致:告诉设备一段逻辑地址区间,其内容之后不会再被读取,设备可以自行决定如何处置这些区间。
| 要素 | 含义 | 各家做法的差异 |
|---|---|---|
| 区间边界 | 一段连续的逻辑块范围 | 有的支持一次命令携带多个区间,有的只支持单区间 |
| 粒度约束 | 实际生效的最小单位是逻辑块大小与设备内部擦除粒度的较大者 | 设备按内部擦除粒度向上对齐,实际生效区间可能比请求更长;相邻未请求空间也可能被一并处置,需谨慎圈定请求范围 |
| 完成语义 | 命令返回时数据可能还没真正释放 | 多数标准把它定义为异步:完成表示已接收,不表示已擦除 |
| 幂等与回读 | 对同一区间重复下发是允许的 | 已丢弃区间的读返回值通常不再有内容语义,视为未定义 |
| 前置条件 | 必须在文件系统元数据落盘之后再发 | 顺序反了会导致挂载表过期,这是最危险的失效形态 |
注意第二行:粒度受「逻辑块大小」与「设备内部擦除粒度」的较大者约束。文件系统发一个 4 KB 的丢弃请求,如果器件内部按更大的单位管理空间,实际生效的粒度会被放大到那个更大的单位。这不算 bug,而是接口层面的必然结果,也正是附录 A 里「地址与粒度约定」那一节存在的理由。
还要强调最后一行:TRIM 的前置条件不是技术细节,而是正确性要求。文件系统必须先把自己的元数据变更(哪些块被释放了)可靠地落到介质上,再把「这些块可以不要了」这条通知发出去。顺序反了,器件可能已经回收了空间,而文件系统重启后并不知道这些块已经失效——第 8 章会用一个完整故障图说明这条链怎么断。
1.3 Discard、TRIM、UNMAP、Deallocate 的命名差异
同一件事在不同圈子里有四个名字,这不是术语混乱,而是各自的历史来源不同。把它们对齐,后面读任何一份文档都不会被绕进去。
| 名称 | 主要出现在哪一层 | 命令形态 | 容易产生的误解 |
|---|---|---|---|
| TRIM | ATA 协议族、通用口语 | ATA DATA SET MANAGEMENT(命令码 0x06)下的一个子功能 | 以为是「安全擦除」 |
| Discard | Linux 块设备层、挂载选项 | 内核内部的请求类型,可由挂载或周期性任务批量下发 | 以为必须实时跟随每一次删除 |
| UNMAP | SCSI 协议族 | SCSI UNMAP(操作码 0x42),携带区间描述符列表 | 以为只在阵列控制器上有 |
| Deallocate | NVMe、eMMC | NVMe Dataset Management;eMMC 的 ERASE 类命令 | 以为等同于数据擦除 |
表里最后一列的四个误解,几乎在每个项目里都能见到一次。其中「实时跟随每一次删除」这一条尤其值得强调:没有任何一种文件系统会为每次 unlink 单独发一条丢弃命令,全部都是批量攒单。批量不是为了省事,而是因为单条命令的开销在小粒度下会超过收益本身——第 8 章会用数量级关系说明这一点。
1.4 为什么 TRIM 最初是为 SSD 定义的
把历史顺序摆清楚,能省掉很多「为什么现在才需要它」的困惑。
在机械盘时代,删除一块数据的处理方式是:文件系统把它标记为空闲,磁盘上那块数据的扇区还留着旧内容,直到被新的写入覆盖。整个链路不需要任何通知,因为覆写本身就是通知。这条路在 HDD 上走了几十年,没有出过结构性问题。
SSD 出现之后,情况变了,但变的不是「删除」这个动作,而是「逻辑块与物理位置不再一一对应」这件事。一旦映射关系被打散,控制器就没法从「我要往逻辑块 L 写数据」这个请求里推断出「L 原来那个物理页上的数据已经没人要了」。它面对的是一堆互不相关的物理页,只能用启发式规则去猜哪些冷了、哪些可以回收。
这套启发式在没有通知的情况下也能跑,但它有一个结构性缺陷:它猜的是「物理页冷不冷」,而真正该被释放的是「逻辑块还要不要」。这两件事高度相关,却不等价。一个很久没被写、也没被读,但内容仍然有效的物理页,在启发式眼里很冷,控制器会保留它;而一个刚被删除、内容无人需要的物理页,因为写入时间很新,在启发式眼里很热,控制器会继续留着它。该留的留了,不该留的没走,垃圾就堆积起来了。
TRIM 的全部设计动机,就是把「该走的是哪些」这个确定性答案从主机传给器件,让它不必再猜。
| 维度 | 机械盘 + 传统文件系统 | 闪存 + 缺少通知 | 闪存 + 有通知 |
|---|---|---|---|
| 介质能否自己判断块是否被删 | 能:覆写即回收 | 不能:映射已打散 | 不能,但可以从主机拿到答案 |
| 逻辑块与物理位置的关系 | 一一对应或近似对应 | 完全散列 | 完全散列 |
| 无效数据的发现方式 | 由下一次覆写被动完成 | 靠写入时间等启发式猜测 | 由主机显式给出 |
| 猜测出错的后果 | 基本不存在 | 垃圾堆积、写放大上升、可用容量下降 | 把猜测空间压到很小 |
| 文件系统与介质的契约 | 隐含,无需表达 | 无法表达 | 可表达,即 TRIM 这条命令 |
不行,而且这句话背后藏着本文要反复纠正的一个错误认知。TRIM 传递的是「哪些块不要了」,不是「把空间还给你了」。它不能替代文件系统的空闲空间管理,也不能替代预留块策略,更不能替代日志与一致性机制。器件侧的可用容量变化由控制器自己的回收与预留逻辑决定,与文件系统之间不存在「还空间」这个交互。
真正可以少做的只有一件事:器件不必再靠启发式去猜哪些块是垃圾了。这是性能与寿命上的节省,不是功能上的替代。
1.5 TRIM 的语义边界:它承诺了什么、不承诺什么
这一节是全文最重要的一节。TRIM 的误解绝大多数集中在边界上——把它当得太多,就期待它承担它承担不了的责任,最终以「数据丢了」或者「掉电挂了」的形式爆发。
先把三层模型摆出来,后面所有章节都以这个分层来看问题:最上面是文件系统的逻辑删除,中间是块设备层的丢弃通知,最下面是器件内部的物理失效。这三层各司其职,靠两份契约连起来:上层承诺不再读,下层承诺可以自行处置。
顺着这两条契约往下推,TRIM 承诺的东西就很清楚了,而且非常窄:它只承诺「我通知你的这一段区间,我不会再读」。设备可以立刻擦,可以过一会儿擦,也可以只做个标记就把它算作回收候选——三种做法都合规。
与此对称,TRIM 不承诺的东西同样清楚,而且这部分是误解的重灾区。
| TRIM 承诺的 | TRIM 不承诺的 |
|---|---|
| 通知区间内的内容之后不会被主机读取 | 不承诺数据安全:发完通知后旧数据不可依赖,也可能立刻读不到 |
| 设备可以在该区间上做任何处置,包括释放 | 不承诺擦除时机:命令返回时通常还没真正释放 |
| 不需要主机逐块确认,逐区间通知即可 | 不承诺掉电一致性:这完全是文件系统与驱动层的事 |
| 批量通知与单条通知在语义上等价 | 不承诺空间立即还给文件系统:容量变化由设备侧策略决定 |
| 重复下发同一区间是允许的 | 不替代加密擦除、更不替代安全擦除:TRIM 后数据不可恢复但不可证明 |
| 未通知的区间设备不得擅自释放 | 不替代文件系统的日志与 checkpoint:元数据一致性另有机制 |
第三行值得单独强调,因为它直接对应 8.3 节要讲的「挂载表过期」这个最危险的失效:在文件系统元数据落盘之前发出通知,设备完全可以合法地把那些空间收回去。等到下一次挂载时,文件系统读到的是一份「这些块还归我」的旧记录,而物理上它们可能已经被新数据覆盖了——此时文件系统会把新数据当成旧文件的一部分读出来,或者把新数据覆盖掉。这类故障不报错、日志里也看不到,属于最难定位的一类。
第一种:以为「发了 TRIM 就等于数据擦掉了」,于是用它替代安全擦除。实际效果是数据变得不可恢复,但没有任何擦除证明,在合规与取证场景下不满足要求。
第二种:以为「发了 TRIM 数据就安全了」,于是在掉电场景里少做甚至不做一致性保护,结果丢的是真实业务数据。
第三种:以为「不发 TRIM 就绝对不会出错」,于是把文件系统的一致性设计也一并省掉。这两种错误都是把 TRIM 的边界往外扩了,扩得越远,出问题时越难查。
1.6 本文要回答的问题与主线
全文收敛到一句判断,其余章节都是这句话的展开:TRIM 的价值是「消除信息不对称」,不是「提供新能力」。围绕这句话,本文要回答六个问题。
| 序号 | 问题 | 对应章节 | 为什么重要 |
|---|---|---|---|
| 1 | 三层删除语义各做什么,契约是什么 | 第 2 章 | 不理解分工就无法定位责任 |
| 2 | 器件收到通知之后,为什么不立刻擦 | 第 3 章 | 这是「标记不等于释放」的由来 |
| 3 | 主机到器件的通路在哪一环会断 | 第 4 章 | 断点通常不在器件,而在中间的透传层 |
| 4 | 不同文件系统什么时候删、删多大 | 第 5 章 | 粒度直接决定收益与开销的平衡 |
| 5 | 不发通知的话器件靠什么撑住 | 第 7 章 | 这是内置控制器与并行 NAND 的分水岭 |
| 6 | 哪些失效会造成真正的数据损坏 | 第 8 章 | 挂载表过期是其中最隐蔽的一个 |
第一章的三条结论,按重要性排序是:第一,TRIM 是通知而不是能力,它把「主机知道而器件不知道」的信息补上,仅此而已;第二,TRIM 是性能与寿命的优化手段,不是数据安全机制,不承诺数据安全也不承诺掉电一致性;第三,TRIM 的正确性前提是文件系统元数据先落盘,顺序反了就是静默数据损坏。
下一章把这三条放进三层语义里,把每层的职责、契约与失效形态逐条摆开。
2. 三层删除语义的分工
如果只读一章,这一章是最不能跳过的。它给出一套判断框架:任何一个删除相关的现象,先问它属于哪一层,再问这一层能看见什么。绝大多数「TRIM 到底有没有生效」的分歧,都是因为双方在讨论不同的层。
2.1 文件系统的逻辑删除
文件系统的逻辑删除要处理的事情比想象的复杂,因为它必须同时维护三类元数据:目录结构(名字到 inode 的映射)、inode 与引用计数(这个文件还有几个硬链接与引用)、以及空间管理结构(哪些块空闲、哪些块可用)。
复杂之处在于,「块变成空闲」这件事在不同文件系统里被推迟到了不同程度。推迟的原因通常不是设计偷懒,而是为了保证崩溃一致性:如果先把块标记为空闲、然后才写元数据,中途掉电就会让文件系统认为这块是空闲的、可以分配出去,而它其实还属于一个存在的文件——写进去就是静默损坏。
于是出现了三条不同的删除路径。
| 路径 | 做法 | 代表形态 | 对 TRIM 的直接影响 |
|---|---|---|---|
| 立即释放 | 删除时同步更新元数据,块立刻进空闲结构 | 多数无日志或弱日志的文件系统 | 块进空闲结构的同一时刻就具备发通知的条件 |
| 延迟释放 | 先把释放意图写进日志,元数据落盘后再回收块 | ext 系列等日志式文件系统 | 通知必须等日志提交之后再发,早了就是挂载表过期 |
| 引用计数持有 | 块被快照或写时复制引用,删除不释放空间 | 写时复制类快照文件系统 | 块在快照存续期内根本不该被通知丢弃 |
第三条路径值得特别点出,因为它与前两条性质完全不同:文件的删除不等于块的释放。当文件系统用写时复制维持快照时,删除一个文件只是减少引用计数,物理块要等最后一个快照释放它才会真正回到空闲结构。这时候如果文件系统按「文件被删了」就发通知,通知出去的区间里可能还装着活跃快照的数据——这属于把仍被引用的块误报为可丢弃,后果同样是静默数据损坏。
所以判断一条 TRIM 实现是否可靠,第一件事不是看它的批量策略,而是看它是在「元数据提交之后」还是「引用计数归零之后」发通知。这两个条件在日志式文件系统里通常同时满足,在写时复制文件系统里则需要文件系统自己显式区分。
2.2 块设备层的丢弃通知
这一层几乎没有业务逻辑,它的存在是为了把「很多次小规模删除」整理成「少数几次区间通知」,并把这条请求可靠地送到设备控制器。它做三件事。
- 攒批:把逻辑块设备的空闲区间合并成尽量少的连续段再下发。这一步对收益的影响极大——碎片化的小区间会让器件几乎没有可合并的对象。
- 排序与合并:同一时刻可能有多个文件系统、多个进程在删除,必须统一收集再统一下发,否则顺序和完整性都难以保证。
- 限流与异步化:丢弃请求全部走队列,不阻塞前台 I/O。这一点在第 8 章会反过来变成一个陷阱:限流参数不当会形成 TRIM 风暴。
通知的触发时机有三种常见形态,它们在收益、时机风险与实现复杂度上各不相同。
| 形态 | 触发方式 | 优点 | 缺点 |
|---|---|---|---|
| 挂载时批量 | 文件系统挂载时扫描一次空闲区并下发 | 时机明确,量集中,合并效果好 | 运行期新产生的空闲区要等下次挂载才被通知 |
| 周期性批量 | 由独立任务周期性扫描空闲区并下发 | 空闲区能被及时通知,开销可预期 | 扫描本身有成本,周期与负载不匹配时收益打折 |
| 在线攒批 | 删除动作入队,达到阈值或超时时批量下发 | 延迟最短,对突发删除响应好 | 碎片率高时合并效果差,队列管理复杂 |
因为丢弃收益与区间连续性成正比,与通知条数几乎无关。一千条长度为 1 的区间通知和一条长度为 1000 的区间通知,语义上等价,但前者会让器件侧付出上千次区间解析与标记的开销,而这些开销会挤占控制器本该用来做回收与前台 I/O 的时间。
更深一层的原因是:碎片化的通知会让垃圾块「攒不起来」。回收的基本动作是合并,合并需要同一块里的多个无效页;如果每次通知都是零散的,器件就必须频繁地做无效的合并尝试,写放大不降反升。
2.3 器件内部的物理失效
第三层是真正释放物理空间的地方。它处理通知的方式有三种粒度,从粗到细排列。
- 整块丢弃:如果一个块内的所有逻辑块都被通知了,控制器可以直接把这个块整体标记为待擦除,跳过逐页跟踪。这是最省事、也最有利的一种情况。
- 页级标记:块内只有部分页被通知,控制器把这些页标记为无效,等垃圾比例达到阈值时再做块级合并。这是日常负载下最常见的情况。
- 仅记录、不处理:某些实现允许把通知先记下来,等到合适的时机(比如空闲期、或者下一次大批量写入之前)再统一处理。这样做是为了不打散前台 I/O。
无论哪种粒度,控制器都要遵守一个原则:标记为无效不等于立即擦除。原因有三个,都很实际。
| 粒度 | 触发条件 | 对控制器成本的影响 | 对寿命的影响 |
|---|---|---|---|
| 整块丢弃 | 块内全部逻辑块都被通知 | 最低,直接进擦除队列 | 最有利:这一块不用再做均衡搬移 |
| 页级标记 | 块内部分逻辑块被通知 | 中,需要维护块内有效性结构 | 取决于合并效率,合并得好则寿命收益明显 |
| 仅记录不处理 | 碎片化通知或前台繁忙 | 摊薄到后续时段 | 短期无收益,长期取决于最终能否凑齐合并 |
三个原因具体是:一,擦除是重操作,在前台 I/O 繁忙时做会直接拉低性能;二,标记本身需要落到非易失存储,而落一次标记也有成本,频繁落标记反而更费;三,回收需要凑够条件,过早擦除会把还能救的块白白浪费掉。
第三条尤其值得展开。如果一个块里还有相当比例的有效数据,控制器会选择把它搬走(合并)而不是直接擦掉——这既回收了空间,又保住了那些块。但这里存在一个取舍:搬移本身要消耗写带宽并增加写入量,这部分开销会被计入写放大。TRIM 的作用正是让「直接擦除」的候选块变多,从而减少这种搬移。第 6 章会把这个关系量化。
2.4 三者之间的契约关系
把三层的职责摆开之后,契约关系就可以直接写出来了。这份契约是判断任何 TRIM 实现是否正确的唯一标准。
| 契约方向 | 承诺内容 | 违约后果 | 谁负责保证 |
|---|---|---|---|
| 文件系统 → 设备 | 通知发出后不再读取该区间 | 读到未知内容,行为未定义 | 文件系统与块设备层 |
| 文件系统 → 设备 | 元数据落盘之后才发通知 | 挂载表过期,已删块被复用,静默损坏 | 文件系统 |
| 设备 → 文件系统 | 可自行决定释放时机与方式 | 若主机假设容量立即变化会误判 | 设备控制器 |
| 设备 → 文件系统 | 未通知的区间不得擅自释放 | 活跃数据被覆盖 | 设备控制器 |
| 设备 → 主机 | 通知为幂等,重复下发无害 | 重试机制失效或者产生额外副作用 | 设备控制器 |
| 双向 | 命令完成表示已接收,不表示已擦除 | 把异步当同步,产生时序假设错误 | 双方都要理解 |
第二行是本文反复强调的那条正确性前提,值得换个角度再说一次:「先落盘再通知」这个顺序,本质上是在用一条命令的时机换取崩溃一致性。文件系统必须在某一点上说「我已经把这些块记成空闲了,而且这个记录已经可靠落盘」,只有在这个点之后,那块物理空间才真正变成「可以拿去干别的事」。TRIM 就是把这个「点」显式地告诉器件。
反过来看,如果器件不理会这条契约、在文件系统还没落盘时就自行释放那些空间,它在协议上仍然是「无法被判错」的——因为设备确实没有收到过这些块可以释放的授权。
2.5 谁先谁后:顺序错了会怎样
把删除动作在栈内从下往上追一遍,就能看清顺序的重要性。下面这条链的每一步都有先后约束,任意一步插队都可能出问题。
这条链上有三个顺序陷阱,每个都对应一类真实故障。
- 陷阱一:在日志提交之前发通知。这是最严重的一个。元数据变更还只存在于内存里,通知已经下发了。掉电之后,文件系统重启读到的是「这些块属于某个文件」的旧记录,而器件已经把它们回收给了新数据。第 8 章会画出这条故障链的完整形态。
- 陷阱二:把通知碎片化下发。不违反任何契约,但把器件的合并效率毁掉了。系统看起来「一切正常」,只是写放大降不下来。这类故障隐蔽得多,因为它不产生任何错误,只表现为性能与寿命不达预期。
- 陷阱三:忽略引用计数,直接按「文件被删」发通知。写时复制文件系统上有快照在引用这些块,通知一旦发出去就是误报。
把三个陷阱放在一起看,可以提炼出一条工程原则:通知的正确性由「谁来决定这个块不要了」决定,通知的收益由「怎么攒批」决定,两者必须分别设计、分别验证。
2.6 一张表看清三层各自能看到什么
最后一节把三层的可见性拉成一张表。这张表是全文最常被回查的一张,建议把它当作判断任何「TRIM 有没有生效」问题的起点:先确认现象发生在哪一层,再去看那一层能拿到的证据。
| 观察维度 | 文件系统层 | 块设备层 | 器件层 |
|---|---|---|---|
| 能否知道哪些块不要了 | 能,完全确定 | 能,来自文件系统 | 不能,除非收到通知 |
| 能否知道哪些块还活着 | 能,完全确定 | 不能,只知道空闲与非空闲 | 不能,只知道有效与无效标记 |
| 能否决定释放时机 | 不负责 | 不负责 | 完全由自己决定 |
| 能否看到擦除动作 | 不能 | 不能 | 能,这是它自己的内部动作 |
| 能否看到写入量 | 能,逻辑层面 | 能,块设备层面 | 能,含写放大部分 |
| 能否看到物理布局 | 不能 | 不能 | 能,且只有它知道 |
| 失效时的表现 | 元数据不一致、挂不上或挂错 | 队列积压、通路静默失效 | 容量不释放、写放大升高 |
这张表最关键的一行是第二行:只有文件系统知道哪些块还活着。块设备层知道「哪些块是空闲的」,但它不知道这些空闲块里是不是还有通过快照、硬链接、或者引用计数保活的文件数据。器件层更彻底:它只知道某个物理页被标记成有效还是无效,而这个标记的准确性完全依赖上游有没有说实话。
因此可以给出一条贯穿全文的判据:TRIM 通路的可靠性上界,取决于这条链上最靠近语义的那一环——也就是文件系统。器件做得再好,也无法弥补文件系统在引用计数或落盘顺序上的错误;反过来,文件系统做得再规范,如果中间的透传层把通知吞掉了,性能和寿命的收益也一分都拿不到。
下一章往下走一层,看器件内部收到通知之后到底做了什么,以及为什么「标记失效」和「真正释放」之间隔着那么远。
3. NAND 侧收到 TRIM 之后做了什么
前两章把「通知」这件事的两端说清楚了,这一章往下钻进器件内部,看通知落地之后真正发生了什么。核心结论是:器件收到通知之后做的第一件事是「记一笔」,而不是「擦一下」。标记与释放之间隔着的这段距离,解释了 TRIM 收益的全部延迟特性,也解释了为什么频繁小粒度 TRIM 反而可能有害。
3.1 标记失效不等于立刻释放
这是全章最重要的一句话,也是最容易在产品沟通中被跳过的一句:TRIM 通知的是「意图」,器件执行的是「动作」,两者之间没有同步关系。
控制器收到一段区间的失效通知后,通常只做一件事:把对应的映射项改成「无效」。这个动作的成本极低——改几个表项,写一次元数据,甚至只在易失性缓存里打个标记。它不涉及任何擦除动作。
真正的物理擦除被推迟到两个条件之一满足时才会发生:
- 垃圾比例触发:块内无效页累积到预设阈值,控制器决定回收这个块。
- 空间压力触发:可用空闲块低于下界,必须回收才能继续接收写入。
推迟的理由全是工程上的:擦除是重操作,它阻塞同die 上的其它操作;标记也需要落盘,落一次元数据也有成本;回收需要判断值不值得——如果一个块里有效数据还很多,直接擦掉是浪费,搬走更划算。三个理由加起来,结论就是「标记要快要,擦除可以慢」。
| 动作 | 发生时机 | 典型成本量级 | 是否阻塞前台 | 掉电后是否需要恢复 |
|---|---|---|---|---|
| 解析通知区间 | 收到命令即刻 | 极低,按区间长度线性 | 不阻塞 | 否,无状态 |
| 标记映射项无效 | 收到命令即刻或稍后 | 低,但需要元数据落盘 | 通常不阻塞 | 是,靠元数据恢复 |
| 更新块内有效性统计 | 标记阶段或回收阶段 | 低 | 不阻塞 | 否,可重算 |
| 搬移有效页合并 | 垃圾比例或空间压力触发 | 较高,写入放大主要来源 | 会占用写带宽 | 是,映射表需保持一致 |
| 擦除物理块 | 合并完成之后 | 高,最重的介质操作 | 会阻塞同 die 操作 | 否,由坏块管理接手 |
这张表把「快」与「慢」分得很清楚:通知的接收是廉价的,通知的兑现是昂贵的。因此一条 TRIM 命令在很短时间内返回,不代表空间已经释放,也不代表器件已经开始做任何回收工作。
这个异步特性带来两个直接推论,工程上都要用到。
推论一:不能通过「命令返回得快」来判断 TRIM 是否生效。它只说明器件接收了。至于有没有兑现,要看观测指标(第 9 章)。
推论二:TRIM 的收益是延迟兑现的。刚删完文件立刻测可用容量,通常看不到变化;过一段时间、设备经历了几轮回收之后才看得到。把这一点搞错,很容易得出「TRIM 没用」的错误结论。
图里最值得看的是三个物理块的处理差异。块 B 因为丢弃比例高,很可能走「整块丢弃」这条最省事的路;而块 A 明明也有无效页,却因为有效页还占多数,可能被选择搬移而不是直接擦除。这个差别就是 TRIM 收益的来源——通知把「哪些块可以整块丢掉」这个信息送出去,让控制器不必靠猜。
反过来看,如果这些通知没发:控制器只知道「这几页很久没被写」,不知道它们已经被主机释放了。它可能选择搬移(浪费写带宽),也可能干脆保留(浪费空间)。两种猜法都不对,而通知直接把答案给了出来。
3.2 与垃圾回收的关系
垃圾回收是器件内部真正释放空间的机制。它的基本动作只有两个:把无效页从待回收块里剔除,让块尽快变空;把有效页搬到空闲块,让待回收块凑成一个整块可擦的单元。TRIM 的作用是让第一步做得更准、第二步做得更少。
没有通知时,控制器判断「这一页无效」只能靠映射表——而映射表里的「无效」本来就是由 TRIM 写进去的。所以对内置FTL 来说,TRIM 与垃圾回收是同一条链的前后两段:TRIM 负责让映射表准确,回收负责把准确性兑现成物理空间。
但对并行 NAND 方案(主机侧 FTL),关系要反过来看。此时控制器是「哑」的,映射表由主机维护,垃圾回收也由主机执行。TRIM 通知先经过主机侧 FTL,被翻译成「这些页无效」,然后才成为回收的输入。换句话说,并行 NAND 上回收的准确性完全建立在通知被正确送达的前提上——这正是第 7 章分水岭的由来。
| 维度 | 内置控制器方案 | 并行 NAND 方案(主机侧 FTL) |
|---|---|---|
| 映射表位置 | 器件内部,主机不可见 | 主机侧维护,器件只执行 |
| 垃圾回收执行者 | 器件内部控制器 | 主机侧 FTL |
| TRIM 在链中的位置 | 直接成为回收的输入 | 先被主机侧 FTL 翻译,再成为输入 |
| 无 TRIM 时的退路 | 可退回到启发式冷块识别 | 几乎无退路,见第 7 章 |
| 回收粒度 | 可按整块或页级自主决定 | 受主机侧分配策略约束 |
| 写入放大的来源 | 控制器内部合并策略 | 主机侧 FTL 的搬移决策 |
页级回收(把无效页标记掉,块内有效页继续用)永远不会浪费整块空间,但会产生持续的写入放大——因为每搬一次有效页就是一次额外写入。
整块回收(块内全部无效,直接进擦除队列)不产生写入放大,是最理想的结果。而 TRIM 的直接贡献,恰恰是提高「整块回收」出现的概率:它让无效页在块内聚集得更快、更整齐。
这解释了一个反直觉的现象:TRIM 提升的不是「能回收多少空间」,而是「回收时需要搬多少数据」。后者才是写放大的来源。
3.3 与块映射表更新的关系
映射表是通知的落点,也是通知是否可靠的判据。三个问题必须回答清楚:更新什么、多久更新一次、更新失败怎么办。
更新什么:被通知的逻辑块对应的映射项,从「有效」改成「无效」,并且更新块内的有效性统计。后者是回收决策的输入,只改映射不改统计的话,控制器仍然不知道这个块快可以收了。
多久更新一次:这是性能与可靠性的折中点。
- 立刻更新:映射一致性最好,但每次通知都要写一次元数据,通知密集时开销显著。
- 批量延迟更新:把一批通知攒起来一起落盘,开销摊薄。代价是这段时间里映射表不准确,如果掉电,这批通知的处理结果就丢了。
- 分两级落盘:先把「本批已收到 N 个丢弃请求」这个摘要落盘,明细靠重放恢复。掉电后靠摘要驱动重建。
更新失败怎么办:这是最容易被忽略的一环。映射表落盘失败时,正确的做法是放弃这批通知并把映射回退,而不是继续往下走。理由很直接:如果映射表说这些页无效、而实际的失效记录没落盘,那么掉电之后映射表会把它们重新视为有效页,而物理上它们可能已经被合并掉了——这是一个会产生静默错误的状态。
| 落盘策略 | 元数据开销 | 掉电后的状态 | 适用取向 |
|---|---|---|---|
| 通知即刻落盘 | 高,与通知条数同阶 | 一致,无中间态 | 通知稀疏、可靠性优先的场景 |
| 攒批落盘 | 低,摊薄到每批 | 本批通知结果丢失,退回上一批状态 | 通知密集、允许少量丢失的场景 |
| 摘要加重建 | 中,只落摘要 | 靠摘要驱动重放收敛 | 对吞吐要求高且有重建能力的方案 |
| 失败即回退 | 不适用 | 映射回到通知前状态,绝不前进 | 所有策略的必备兜底 |
表里最后一行是硬要求:落盘失败必须回退,不能前进。这一条听起来是废话,实际实现里却经常被写成「记录失败但继续处理」,理由通常是「这样吞吐更高」。但代价是引入了一个无法自检的状态:映射表说无效、失效记录没落、掉电之后两者对不上,而系统没有任何手段能发现这件事。
3.4 一次性丢弃大块与碎片式丢弃的差别
这一节是全文最需要「用量级关系而非绝对值」来表述的地方。核心结论只有一句:收益来自区间的连续性,不来自通知的数量或总量。
把两种极端摆在一起看最清楚。
| 对比项 | 整块丢弃 | 碎片式丢弃 |
|---|---|---|
| 区间形态 | 少数大段,与器件擦除单位对齐 | 大量小区间,散落各块 |
| 对块内有效性的影响 | 整块转为可回收 | 每块都差一点,条件不满足 |
| 是否需要搬移 | 不需要 | 需要,且反复搬移 |
| 写入放大 | 不增加 | 上升 |
| 元数据写入开销 | 低 | 高,与通知条数同阶 |
| 常见成因 | 大文件删除、批量清理、日志轮转 | 小文件逐个删除、频繁覆盖写 |
| 改进手段 | 保持攒批粒度 | 提高攒批阈值、改用周期性批量任务 |
3.5 掉电发生在丢弃过程中会怎样
这一节要区分两个完全不同的问题:掉电对通知本身的影响,以及掉电对映射表的影响。前者通常无害,后者才是真问题。
通知本身是幂等的、可重放的。掉电把它截断,最坏的结果是「这一批只处理了一部分」。系统重启之后,要么从头重扫补齐,要么就少丢一部分——后者只影响回收效率,不影响数据正确性,因为那些块本来就已经是无效页了。
映射表就不一样了。如果一批通知已经改了内存里的映射、但还没落盘就掉电,重启后读到的映射表是通知之前的状态:那些页会被重新视为有效。此时有两种结果。
- 器件侧也回退了(推荐):映射表和失效记录一起回到旧状态,两者一致。系统的代价只是这批通知白发了,下次重发即可。
- 器件侧没有回退(危险):映射表说这些页有效,而器件侧已经把它们合并进别的块了。此后任何一次对这些逻辑块的写入,都会写到「已经没有对应物理位置」的地方;任何一次读取,都会读到某个不相干的物理页。这就是静默数据损坏的标准形态。
所以「映射表落盘失败必须回退」这条硬要求,在掉电场景下不是保守,而是唯一正确的选择。
| 掉电落点 | 映射表状态 | 器件侧状态 | 后果 | 是否可自愈 |
|---|---|---|---|---|
| 通知未开始处理 | 旧 | 旧 | 无影响 | 下次重发即可 |
| 处理中,尚未落盘 | 旧 | 旧或部分 | 无数据风险,回收效率略降 | 可自愈,重扫补齐 |
| 已落盘,通知未完成 | 新 | 部分 | 回收效率略降,映射仍然准确 | 可自愈,补发剩余 |
| 映射表前进但记录丢失 | 新 | 旧 | 映射说有效、器件已合并,静默损坏 | 不可自愈,必须避免 |
| 文件系统元数据未提交就掉电 | 旧 | 已回收 | 挂载表过期,已删块被复用 | 不可自愈,见第 8 章 |
第四行与第五行的差别值得点出来。器件侧能不能安全回退,取决于它能不能把「这批通知的失效记录」与「映射表」做成同一个原子单元——这是器件实现的选择,做不了就必须在协议层面保证不会进入这个状态。
第五行则完全是主机侧的义务:文件系统必须先提交元数据再发通知。这不是性能取舍,是正确性前提。两者都不是「优化项」,是「必选项」。
3.6 丢弃与写放大的关系
写放大是衡量「为了给主机腾出 1 单位空间,实际往介质写了多少」的指标。它在闪存上是绕不开的,因为回收时必须搬移有效页。
TRIM 对写放大的影响是间接但显著的,链路是这样的:通知让无效页被准确标记 → 无效页在块内聚集 → 整块回收的条件更容易满足 → 需要搬移的场景减少 → 搬移次数下降 → 写放大下降。
这里有一个必须点明的因果方向问题。很多文档会写成「TRIM 降低了写放大」,但这个说法在缺少前提时会误导人。准确的说法是:TRIM 提高了「整块回收」相对于「页级回收」的出现比例,而写放大的主要来源是页级回收中的搬移。所以 TRIM 不是直接减少搬移,而是通过改变回收形态间接减少搬移。
| 回收形态 | 触发条件 | 搬移需求 | 对写放大的贡献 | TRIM 的影响 |
|---|---|---|---|---|
| 整块回收 | 块内全部页无效 | 无 | 几乎为零 | 直接提高其出现比例 |
| 页级回收 | 块内部分页无效 | 搬移全部有效页 | 主要来源 | 通过聚集无效页降低其出现比例 |
| 擦除不划算 | 有效页占比过高 | 搬移成本超过收益 | 反而可能增加 | 更准确的判断让这种情况减少 |
| 延后处理 | 前台繁忙 | 不立即发生 | 延后但不消失 | 只影响兑现时机 |
第三行是「TRIM 越多越好」这个直觉最容易出错的地方。如果一个块里有效页占绝大多数,把它搬走再回收可能比留着不动更不划算。控制器必须有权拒绝回收,而通知只是提供了更准确的信息,让这个判断更准——它不是一道「必须擦除」的命令。
把这一章收束一下:TRIM 在器件侧的全部作用可以归纳成一句话——它把「哪些页可以整块丢掉」这个主机才知道的答案送进来,让回收的形态判断从猜测变成计算。下一章往上走一层,看这份通知在主机侧是怎么走到器件面前的,以及中间哪一环最容易把它吞掉。
4. 主机侧 TRIM 通路全景
这一章讲通路。工程上关于「TRIM 到底有没有生效」的争论,大部分不是在讨论 TRIM 本身,而是在讨论这条通路上某一环把通知吞掉了。本章的目标是让读者能对着自己的系统逐环排查:文件系统发了没有、内核转成了什么、驱动有没有屏蔽、接口有没有定义、控制器收不收。
4.1 块设备层到文件系统的通路
先看通用形态。通知从产生到抵达器件,中间要穿过五段,每一段都可能出问题。
| 段 | 职责 | 可能出问题的方式 | 可观测性 |
|---|---|---|---|
| 文件系统 | 决定何时删、删多少,生成空闲区间 | 引用计数未归零就发通知;落盘顺序错误 | 较强,可查空闲区变化 |
| 块设备层 | 攒批、合并、排队、限流 | 碎片化下发;限流过严导致积压 | 较强,有统计计数 |
| 传输层映射 | 把逻辑块号翻译成设备可见地址 | 分区映射或偏移配置错误 | 中等,需要对照分区表 |
| 驱动 / 控制器 | 把请求翻译成协议命令并下发 | 默认屏蔽丢弃;能力位未上报 | 较弱,需读驱动日志 |
| 接口与设备 | 解析命令并执行标记 | 接口未定义该命令;固件忽略 | 弱,需设备侧工具 |
这张表的最后一列是排查的入口。越往下的段,可观测性越差。文件系统层的空闲区变化随时可查,而设备侧是否真的解析了命令,通常只能靠设备厂商的工具。这意味着:如果要判断问题在哪,必须先在可观测性好的上游建立证据(比如确认文件系统确实产生了连续的空闲区间),再往下推。
反过来说,一条有效的排查顺序永远是从上游往下游:先证明通知产生了,再证明它没被合并掉,然后证明它没被屏蔽,最后才怀疑设备没处理。跳过前面几步直接查设备,是这条链上最常见的时间浪费。
4.2 SATA 的接口形态
SATA 上TRIM 以 ATA 协议族里的 DATA SET MANAGEMENT 命令承载,命令码是 0x06,通过其中的子功能区分「声明范围」与「发送范围」两种形态。发送范围才是通常所说的 TRIM;声明范围只是先告诉设备哪些区间是空闲的,让设备自行安排后续处理,不立即执行。
对工程实践来说,有三点值得注意。
- 粒度由逻辑扇区数与设备内部对齐共同决定。主机发出的扇区数如果不对齐到设备内部单位,设备会按内部擦除粒度向上对齐,实际生效区间可能比请求更长,相邻未请求空间也可能被一并处置。这是正常的语义,不是丢数据,但圈定请求范围时要谨慎。
- 能力位决定支持程度。设备是否支持、支持的粒度上限,都通过识别命令里的能力上报机制给出。主机不应假设设备一定支持。
- 没有异步完成通知机制。命令返回表示已接收,不表示已释放。SATA 上没有比这更进一步的确认手段。
4.3 NVMe 的接口形态
NVMe 上对应的是 Dataset Management 命令,同样区分「声明」与「释放」两种属性。它的表达能力比 SATA 更强,主要体现在三点。
- 区间描述符列表:一条命令可以携带多个区间描述符,而不是只覆盖一个连续范围。碎片化的空闲区可以在一条命令里表达,攒批质量因此更高。
- 命名空间粒度对齐:请求会按命名空间的格式与粒度属性对齐,超出部分的处理由规范规定。
- 状态查询:NVMe 有专门的状态查询机制,可以拿到设备对数据集管理的支持情况与粒度上限。这让主机可以做精细的能力判断,而不是发出去再看是否报错。
这里有一个常被忽略的设计意图:NVMe 的多区间描述符不是为了「一次发更多碎片」,而是为了把碎片合并成更少的命令。如果文件系统侧不做合并,只是把原来 100 条单区间命令塞成1 条 100 区间命令,收益完全一样,命令解析开销还更高。协议提供了表达能力,攒批策略仍需自己实现。
| 维度 | SATA | NVMe | eMMC | SD 卡 |
|---|---|---|---|---|
| 承载命令 | ATA 数据集管理 | 数据集管理 | 擦除命令族 | 应用擦除类命令 |
| 一次命令的区间数 | 以单区间为主 | 多区间描述符列表 | 按参数指定 | 按批次指定 |
| 粒度对齐方向 | 向上取整到内部单位 | 按命名空间格式对齐 | 由参数显式指定 | 由主机侧与卡共同约束 |
| 能力查询 | 识别命令 | 专门的状态查询 | 设备特性扩展寄存器 | 规范版本与卡特性 |
| 常见断点 | 桥接芯片不转发 | 虚拟化层屏蔽 | 驱动未使能 | 卡与主控能力不匹配 |
4.4 eMMC 与 SD 卡的接口形态
移动存储与嵌入式场景里最常见的两种形态。它们和 SATA / NVMe 有个结构性差别:命令空间被裁剪过,可用的粒度参数比通用协议少。这直接决定了嵌入式场景下的攒批策略必须更保守。
在 eMMC 上,丢弃通知走擦除命令族,通过参数指定要擦除的是单个逻辑块、一整组、还是整个分区。参数选得越粗,实际生效范围可能越大——如果文件系统给出的空闲区不连续,而命令参数又指定了整组擦除,多出来的部分会连带处理。这在实现上必须小心,附录 A 里专门讨论这件事。
在 SD 卡上,情况类似但更受限。命令本身有明确的能力位声明是否支持,不支持时命令直接失败而不报错——「静默无效」是这一段最常见的现象,因为很多代码路径把失败当成正常返回处理了。
把两者放在一起看,可以提炼一条嵌入式场景下的实用判据:在 eMMC / SD 这类命令空间受限的接口上,优先选择「让文件系统攒大段」而不是「依赖设备做精细管理」。设备的表达能力越有限,主机侧就越要把区间整理干净。
4.5 USB 存储与桥接芯片的通路
这一段是工程上最容易出问题的地方,原因是链路上多了一个不受你控制的环节:桥接芯片或存储控制器。
USB 大容量存储类协议本身定义了对应的命令,但支持与否完全取决于设备内部那颗桥接芯片的实现。同样是一个 USB 移动固态,不同主控的丢弃支持程度可以差别很大,主机侧从标准接口上完全看不出区别。
这一段有三种典型的失效形态。
- 能力不上报:设备不支持,但也不主动说明,主机只能靠命令返回码推断。如果代码把失败当成功处理,就彻底静默了。
- 能力上报了但桥接丢弃:主机发得出去、也收到成功返回,但桥接芯片根本没把命令转发给后面的控制器。这种最隐蔽,因为所有返回值都是正常的。
- 桥接做地址重映射:芯片内部对容量做了转换,导致主机发出的逻辑地址与设备侧理解的地址不一致。这种情况下即使命令被正确执行,标记的也是错误的块。
这是本文档要反复强调的一个通用判据,在桥接链路上尤其严重:命令的成功返回只证明链路把这条命令送到了下一层并且下一层没有报错,不证明最后一层真的做了它该做的事。
在嵌入式产品里,如果你无法确证设备侧的处理行为,那么唯一可靠的做法是用可观测的结果反推——比如对比开启与关闭通知两种条件下的写入量与磨损计数差异(第 9 章会给出这套方法),而不是相信返回值。
4.6 通路上任一环缺失的后果
把五段通路的失效形态汇总一下,可以看到一条清晰的规律:越靠上游的失效越容易发现,也越容易造成正确性问题;越靠下游的失效越难发现,只影响性能与寿命。
| 缺失环节 | 直接现象 | 是否报错 | 影响类型 | 定位手段 |
|---|---|---|---|---|
| 文件系统不产生空闲区间 | 没有任何通知 | 否 | 无正确性问题,收益为零 | 查文件系统空闲区统计 |
| 落盘顺序错误 | 偶发数据损坏 | 通常否 | 正确性,最严重 | 挂载表一致性检查 |
| 引用计数未归零 | 快照数据被覆盖 | 否 | 正确性 | 检查快照引用状态 |
| 攒批阈值过小 | 通知碎片化 | 否 | 性能与寿命 | 统计通知条数与平均区间长度 |
| 驱动默认屏蔽丢弃 | 通知不出内核 | 否 | 性能与寿命 | 读驱动参数与统计计数 |
| 虚拟化层未透传 | 通知不到下层 | 否 | 性能与寿命 | 逐层对照能力位 |
| 接口未定义该命令 | 命令失败或无效 | 可能 | 性能与寿命 | 查接口版本与能力位 |
| 设备固件忽略 | 命令成功但无效果 | 否 | 性能与寿命 | 只能用结果反推 |
这张表可以直接当排查清单用。前两行是正确性问题,后六行是性能与寿命问题——这个区分决定了排查时的优先级:出现数据损坏类症状时,先查上游;出现「TRIM 开了但没收益」时,先查下游。
最后一行也是最需要提醒的:「设备固件忽略」这一档在实践中几乎无法直接观测。它和「命令成功但无效果」在主机侧看到的现象完全一样。因此第 9 章会专门给出一套故障注入方法,用「故意不发」与「正常发」两组对照,把这一档从其它档里区分出来。