1. 问题现场还原:脚本说 PASS,系统却读出一片零
1.1 这个现象到底长什么样
先把这个场景描述清楚,因为很多人第一次遇到时会怀疑人生。你写了一个设备老化测试脚本,或者一个批量校验脚本,跑完以后脚本自己打印了一行PASS,退出码是 0,日志里干干净净。结果你换了一台机器,或者重启之后用系统工具去读那块盘,发现读出来的数据全是00,也就是全零。脚本说没问题,操作系统说这盘是空的,两边各执一词。
我第一次碰到这个问题是在做批量设备老化测试的时候。脚本逻辑很简单:往指定 LBA 写一段特征数据,读回来比对,一致就 PASS。单机跑了几十轮都没事,结果换到另一批机器上,脚本依然 PASS,但用系统自带的工具去看那块盘,前几个扇区全是零。当时第一反应是脚本有 bug,查了半天发现脚本没问题,问题出在数据根本没落到盘上。
这个现象的本质,是写入路径上的某一层把数据"吞"了,但读取路径上脚本读到的又是缓存里的旧数据,所以脚本比对通过,而真正落到介质上的数据是零。要搞清楚是谁在撒谎,得把整条链路拆开看。
1.2 为什么这个问题值得单独拿出来讲
很多人会觉得,脚本 PASS 就行了,系统读出来是零可能是工具的问题。但在设备老化测试、产线校验、固件升级验证这些场景里,这个判断是致命的。老化测试的目的就是验证存储介质在长时间、多轮次读写之后是否还可靠。如果脚本因为缓存的原因一直报 PASS,那这批设备里真正有问题的就会被放行,到了客户手里才暴露,返修成本是产线拦截的几十倍。
而且这个问题的迷惑性极强。脚本逻辑看起来无懈可击,退出码正常,日志正常,单机复现还复现不出来。它往往只在特定硬件组合、特定驱动版本、特定写入模式下才出现。所以它值得单独拎出来,从 AHCI、LBA、MBR 这几个层面把链路走一遍,搞清楚每一层各自在干什么,谁有可能把数据吃掉。
1.3 涉及的核心概念先铺垫一下
在往下拆之前,先把几个关键词用大白话解释清楚,不然后面看链路会卡。
LBA是逻辑块地址,你可以把它理解成硬盘上的"门牌号"。操作系统不直接跟盘说"我要第 3 个扇区",而是说"我要 LBA 3",由盘自己翻译成物理位置。脚本读写数据,本质上就是往某个 LBA 写、从某个 LBA 读。
AHCI是主板和 SATA 盘之间的一种通信协议,它规定了数据怎么从内存搬到盘上。你可以把它想象成一条快递通道,CPU 把包裹(数据)放到内存的某个位置,然后告诉 AHCI 控制器"把这个包裹送到盘的某个门牌号",控制器负责实际搬运。
MBR是主引导记录,位于盘的第一个扇区(LBA 0),里面存着分区表和一小段引导代码。很多校验脚本会去读 LBA 0 来确认盘的状态,所以 MBR 区域的数据对不对,直接关系到脚本的判断。
缓存是这里的关键角色。数据从脚本到最终落到盘上,中间可能经过操作系统页缓存、文件系统缓存、块设备层缓存、盘自己的写缓存。任何一层缓存没刷下去,数据就还在内存里,盘上还是旧的。
2. 数据链路全拆解:从脚本到盘,中间隔了几道门
2.1 一条写入请求的完整旅程
要找出谁在撒谎,最直接的办法就是把一条写入请求从脚本发出到真正落到盘上的全过程走一遍。假设脚本执行的是"往 LBA 100 写入 512 字节特征数据"这个操作,它的旅程大致是这样的:
- 脚本调用系统接口,发起写请求,数据先进入操作系统的页缓存(Page Cache)。
- 如果走的是文件系统,文件系统会把逻辑偏移映射到具体的 LBA,然后交给块设备层。
- 块设备层把请求放进 IO 调度队列,等待合适的时机下发给驱动。
- 驱动把请求翻译成 AHCI 命令,写入命令列表,然后通知控制器。
- AHCI 控制器通过 DMA 把数据从内存搬到盘的内部缓存。
- 盘收到数据后,先放进自己的写缓存,然后择机写入实际介质。
- 盘返回完成信号,控制器通知驱动,驱动通知块设备层,块设备层通知文件系统,最后脚本收到"写成功"。
这条链路上,第 1 步、第 3 步、第 6 步都是缓存点。任何一步的缓存没有正确刷下去,脚本都会收到"成功",但盘上可能还是旧数据。
2.2 脚本为什么读到了"正确"的数据
脚本写完立刻读回来比对,读的是哪里?大概率读的还是页缓存。因为刚写进去的数据就在缓存里,读请求会直接命中缓存,根本不会下发到盘。所以脚本读到的就是它刚写的那份数据,比对当然通过。
这就是"脚本说 PASS"的真相:它验证的是缓存,不是介质。它以为自己验证了盘,实际上验证的是内存。这个逻辑在单机、短时间、小数据量的场景下不会出问题,因为缓存迟早会刷下去。但在老化测试这种高频、多轮、可能中途断电或重启的场景下,缓存没刷下去的概率就上来了。
2.3 系统为什么读到了全零
系统工具读出来全零,通常有两种情况。
第一种是数据确实没写下去。比如写请求还在缓存里,盘上那个 LBA 从来没被写过,读出来自然是零。或者写请求下发了,但盘内部缓存没落盘,掉电后数据丢失,重启再读就是零。
第二种是读的路径和写的路径不一致。比如脚本写的是某个分区内的偏移,系统工具读的是裸设备偏移,两者算出来的 LBA 根本不是同一个位置。这种情况在 MBR 和分区表存在的时候特别容易发生,因为分区有起始偏移,脚本如果没把偏移算进去,写的位置和读的位置就错开了。
还有一种比较隐蔽的情况:AHCI 模式切换导致地址映射变化。比如系统从 IDE 兼容模式切到 AHCI 模式,或者反过来,盘的访问方式变了,之前写的数据在新模式下读出来的位置可能就不对了。这个在 Windows 改 AHCI 模式无法启动的案例里经常出现,本质是驱动加载顺序和存储栈初始化的问题。
2.4 三层缓存的职责与失效场景
把缓存分三层来看,会更清楚谁该负责什么。
| 缓存层级 | 位置 | 职责 | 失效场景 |
|---|---|---|---|
| 页缓存 | 操作系统内存 | 加速文件读写,合并 IO | 未调用 fsync/sync,掉电丢失 |
| 块设备层缓存 | 内核块设备 | 合并、排序 IO 请求 | 未 flush,请求还在队列 |
| 盘写缓存 | 盘内部 | 加速写入响应 | 未发 FLUSH CACHE,掉电丢失 |
脚本报 PASS 最常见的原因,就是只验证了页缓存,没有强制刷到盘。要验证介质,必须在写完之后调用 flush 类操作,把三层缓存都刷下去,然后再读。而且读的时候要绕过缓存,直接读裸设备,才能读到介质上的真实数据。
3. 实操排查:怎么定位到底是哪一层在撒谎
3.1 第一步:确认写入是否真的下发到了盘
排查的第一步,是确认写请求有没有离开操作系统。最直接的办法是看块设备层的统计。在 Linux 下可以读/sys/block/sdX/stat,里面有几个关键字段:写完成次数、写合并次数、写扇区数。如果脚本写了很多次,但这个统计里的写扇区数没怎么涨,说明请求还堵在缓存里,根本没下发。
另一个办法是用iostat或者blktrace观察实际下发的 IO。blktrace能看到每个 IO 请求从进入到完成的完整生命周期,包括它在队列里待了多久、什么时候下发给驱动、什么时候完成。如果脚本报 PASS 但blktrace里看不到对应的写请求,那基本可以确定数据没下去。
注意:读块设备统计之前,先确认你读的是正确的设备节点。多盘机器上很容易看错盘,尤其是盘符会变的环境。建议用盘的序列号或者 WWN 来定位,不要依赖 sdX 这种会变的命名。
3.2 第二步:确认盘上数据是否真的落盘
确认请求下发之后,下一步是确认数据有没有真正落到介质。这里的关键是绕过所有缓存去读。在 Linux 下可以用dd直接读裸设备,配合iflag=direct绕过页缓存:
dd if=/dev/sdX of=/tmp/readback.bin bs=512 skip=100 count=1 iflag=direct然后用hexdump或者xxd看读出来的内容是不是你写的那份特征数据。如果是全零,说明数据没落盘,或者落到了别的位置。
如果条件允许,最彻底的办法是把盘拆下来,用另一台机器读,或者用专门的读盘工具读。这样能完全排除操作系统缓存的影响,看到介质的真实状态。老化测试场景下,我一般会在测试前后各做一次裸盘读取,对比数据是否一致。
3.3 第三步:确认 LBA 计算是否正确
如果数据确实写下去了,但读出来的位置不对,那问题就在 LBA 计算上。这里最常见的坑是分区偏移没算进去。
假设盘上有一个 MBR 分区表,第一个分区从 LBA 2048 开始。脚本如果直接往 LBA 100 写,那写的是 MBR 保留区域,不是分区内的数据。而系统工具如果通过文件系统读,读的是分区内的偏移,算出来的 LBA 是 2048 + 100 = 2148。两边差了一个分区起始偏移,读出来的当然不是同一份数据。
排查这个问题的办法很简单:把脚本用的 LBA 和系统工具用的 LBA 都打印出来,对比一下差值。如果差值正好等于分区起始扇区,那就是偏移没算。MBR 分区表里每个分区的起始 LBA 可以在分区表项里读到,用fdisk -l或者直接解析 MBR 都能看到。
3.4 第四步:确认 AHCI 模式与驱动状态
如果前面三步都排除了,那就要看 AHCI 层面。AHCI 模式切换会导致存储栈重新初始化,如果切换过程中有未刷下去的数据,就可能丢失。Windows 改 AHCI 模式无法启动,很多时候就是因为切换前没把存储驱动准备好,系统起来之后找不到盘,或者盘的状态不对。
在 Linux 下可以看dmesg里 AHCI 相关的日志,确认控制器是否正常初始化、盘是否被正确识别、有没有报错。如果看到failed to IDENTIFY或者link reset之类的信息,说明链路层有问题,数据可能根本没到盘。
还有一个容易被忽略的点:盘的写缓存策略。有些盘默认开启写缓存,返回完成信号时数据其实还在盘的内存里。如果这时候掉电,数据就丢了。要确保数据落盘,需要在写完以后发FLUSH CACHE命令。在 Linux 下可以用hdparm -F /dev/sdX或者sg_raw发 flush 命令。
4. 脚本层面的修复:让 PASS 变得可信
4.1 写完必须 flush,读必须 direct
脚本要可信,核心就两条:写完强制刷盘,读的时候绕过缓存。
写完之后,根据你用的接口,选择对应的 flush 方式。如果是文件操作,调用fsync或者fdatasync;如果是裸设备操作,发BLKFLSBUFioctl 或者fsync到设备文件;如果要对盘发 flush,用hdparm -F或者直接发 SCSI/ATA 命令。
读的时候,用O_DIRECT打开设备文件,或者在dd里加iflag=direct,确保读请求不经过页缓存,直接下发到盘。这样读到的才是介质上的真实数据。
import os # 写:O_DIRECT 绕过页缓存,写完 fsync 刷盘 fd = os.open("/dev/sdX", os.O_RDWR | os.O_DIRECT) os.lseek(fd, 100 * 512, os.SEEK_SET) os.write(fd, b"\xAA" * 512) os.fsync(fd) os.close(fd) # 读:同样 O_DIRECT,读回来比对 fd = os.open("/dev/sdX", os.O_RDONLY | os.O_DIRECT) os.lseek(fd, 100 * 512, os.SEEK_SET) data = os.read(fd, 512) os.close(fd) assert data == b"\xAA" * 512, "数据不一致,介质上不是写入的内容"这段代码的关键在于O_DIRECT和fsync的配合。O_DIRECT让读写都不经过页缓存,fsync确保数据从盘内部缓存落到介质。两个都做到,脚本的 PASS 才是真的 PASS。
4.2 加入掉电模拟与重启验证
老化测试场景下,光刷盘还不够,还要模拟掉电和重启。因为有些盘在正常掉电时能保住数据,但异常掉电时就不行。要验证这一点,可以在写入并 flush 之后,直接断电,然后重新上电读盘,看数据还在不在。
如果条件不允许直接断电,可以用软件方式模拟:写完 flush 之后,卸载设备、重新扫描、再读。或者用echo 1 > /sys/block/sdX/device/delete把盘移除再重新扫描。这样能强制走一遍重新初始化的流程,暴露缓存和初始化顺序的问题。
实操心得:掉电测试不要只做一次。我一般会做至少 10 轮,每轮写不同的特征数据,掉电后读回来比对。有些盘的问题是概率性的,做一次两次看不出来,做多了才能暴露。
4.3 校验范围要覆盖关键区域
脚本校验不要只盯着自己写的那几个 LBA。老化测试的目的是验证整块盘的可靠性,所以校验范围要覆盖 MBR、分区表、文件系统元数据区、以及数据区。尤其是 MBR 区域,很多盘的问题会先在这里暴露。
一个比较稳妥的做法是:测试前先把整块盘的关键区域读一遍,存一份基准;测试后再读一遍,逐字节比对。关键区域包括 LBA 0(MBR)、分区表所在扇区、文件系统超级块、以及测试数据区。这样任何一处被改写或者变成零,都能被发现。
4.4 日志要记录足够的信息
脚本的日志不能只打 PASS 和 FAIL,要记录足够的信息用于事后分析。至少包括:测试的 LBA 范围、写入的特征数据模式、flush 的返回状态、读回数据的校验和、以及每次操作的耗时。如果出现 FAIL,还要记录读回的实际数据内容,方便判断是全零、旧数据、还是部分损坏。
我习惯在日志里加一个时间戳和盘序列号,这样多盘并行测试的时候,能快速定位是哪块盘、哪个时间点出的问题。日志格式用结构化格式(比如 JSON Lines),方便后续用脚本分析。
5. 常见问题速查与避坑清单
5.1 问题速查表
| 现象 | 可能原因 | 排查方法 | 修复方式 |
|---|---|---|---|
| 脚本 PASS,系统读全零 | 数据还在页缓存 | 看块设备写统计 | 写后 fsync,读用 O_DIRECT |
| 脚本 PASS,重启后数据丢失 | 盘写缓存未刷 | 掉电重启后读盘 | 写后发 FLUSH CACHE |
| 脚本读到的数据和系统不一致 | LBA 偏移算错 | 对比两边 LBA | 加上分区起始偏移 |
| 切换 AHCI 后盘读不到 | 驱动未正确加载 | 看 dmesg 日志 | 切换前准备驱动,按顺序初始化 |
| 部分 LBA 读出来是旧数据 | 写请求被合并或丢弃 | blktrace 看 IO 生命周期 | 检查 IO 调度器和队列深度 |
| 老化测试偶发 FAIL | 盘介质老化或缓存策略 | 多轮测试,统计失败率 | 更换盘或调整测试参数 |
5.2 避坑清单
不要相信脚本自己的 PASS。脚本验证的是它能看到的东西,如果它看的是缓存,那 PASS 没有意义。任何涉及介质可靠性的测试,都必须绕过缓存去读。
不要在测试过程中切换存储模式。AHCI 模式切换会重新初始化存储栈,过程中未刷盘的数据可能丢失。如果必须切换,先 flush 所有缓存,再切换,切换后重新验证。
不要忽略盘的写缓存策略。有些盘默认开启写缓存,响应很快但掉电丢数据。老化测试要模拟真实使用场景,如果真实场景会掉电,测试就必须覆盖掉电情况。
不要只测一个 LBA。单点测试覆盖不了整块盘的问题。老化测试要覆盖多个区域,尤其是 MBR、分区表、文件系统元数据这些关键位置。
不要用会变的设备名。/dev/sda、/dev/sdb这些名字在多盘环境下会变,用盘的序列号或者/dev/disk/by-id/下的稳定链接来定位盘。
不要忘记记录基准数据。测试前先读一遍关键区域存基准,测试后比对。没有基准,出了问题都不知道是原来就这样还是测试导致的。
5.3 一个真实的排查案例
之前遇到过一个案例:脚本在 A 机器上跑,PASS;换到 B 机器上跑,也 PASS;但把 B 机器上测试过的盘拆下来装到 C 机器上读,数据全零。查了很久,最后发现是 B 机器的 AHCI 驱动版本有问题,写请求下发了但控制器没有正确执行 DMA,数据根本没到盘。而脚本读的时候命中了页缓存,所以报 PASS。
这个案例的教训是:脚本 PASS 只能证明脚本自己逻辑自洽,不能证明数据真的落盘了。要证明数据落盘,必须用独立于写入路径的方式去读,比如换机器读、裸盘读、掉电后读。只有读到的数据和写入的一致,才能说这次写入是可靠的。
6. 把测试做扎实的几个经验
6.1 测试设计要覆盖真实场景
老化测试不是跑个脚本就完事,测试设计要贴近真实使用场景。真实场景里盘会经历什么?频繁读写、随机掉电、温度变化、长时间运行。测试就要覆盖这些:多轮次读写、随机掉电、高温老化、长时间连续跑。只做一轮写入读回比对,覆盖不了这些场景。
我一般会把测试分成几个阶段:预热阶段做基础读写验证,压力阶段做高频随机读写,掉电阶段做随机掉电恢复验证,最后做全盘数据一致性校验。每个阶段都有明确的通过标准,任何一个阶段不过,整块盘就判 FAIL。
6.2 数据特征要有多样性
写入的特征数据不要只用一种模式。全AA、全55、递增序列、随机数据,这几种模式对盘的考验是不一样的。全AA和全55能暴露位翻转问题,递增序列能暴露地址映射问题,随机数据能暴露更复杂的介质缺陷。测试时轮换使用这几种模式,覆盖更全面。
6.3 失败要能复现和定位
测试失败不可怕,可怕的是失败了不知道怎么复现。所以测试脚本要记录足够的信息:失败时的 LBA、写入的数据、读回的数据、操作序列、时间戳、盘的状态。有了这些信息,才能复现问题、定位原因。
如果条件允许,失败时自动保存现场:把失败 LBA 附近的数据 dump 出来,把 dmesg 日志保存下来,把盘的 SMART 信息读出来。这些信息对后续分析非常有价值。
6.4 定期校准测试环境
测试环境本身也要定期校准。比如测试用的机器,AHCI 驱动版本、内核版本、BIOS 设置,都要记录并保持一致。如果测试环境变了,测试结果就没有可比性。我一般会给每台测试机建一个配置档案,记录硬件、固件、驱动、系统版本,每次测试前核对一遍。
还有一点:测试用的参考盘要定期校验。参考盘是用来做基准比对的,如果参考盘本身有问题,测试结果就不可信。定期用独立工具校验参考盘,确保它本身是可靠的。
6.5 脚本要能独立运行和自检
测试脚本最好能独立运行,不依赖太多外部环境。脚本启动时先做自检:确认目标盘存在、确认权限足够、确认 flush 和 direct 读可用、确认日志路径可写。自检不过就直接退出,不要带着问题往下跑。
脚本还要能处理异常情况:盘突然掉线、读写超时、flush 失败。这些情况要能捕获并记录,而不是直接崩溃。老化测试跑的时间长,中途出异常的概率不低,脚本要能扛住。
7. 关于这个问题的几点个人体会
脚本说 PASS、系统读全零,这个问题看起来是脚本和系统在互相甩锅,实际上大多数时候是测试方法本身有漏洞。脚本验证的是它能看到的数据,如果它看到的是缓存,那 PASS 就是假的。系统读全零,是因为它读的是介质,介质上确实没有数据。
我踩过几次坑之后,现在做任何存储相关的测试,都会坚持三条原则:写后必 flush,读必 direct,验证必换路径。写后 flush 确保数据离开内存,读用 direct 确保读到介质,换路径验证确保不是自己骗自己。这三条做到了,脚本的 PASS 才有含金量。
还有一点,AHCI、LBA、MBR 这些概念,平时写业务代码可能用不到,但一旦涉及存储可靠性测试,就必须搞清楚。因为问题往往就出在这些底层细节上,上层脚本写得再漂亮,底层数据没落盘,一切都是白搭。
最后分享一个小技巧:如果你不确定数据有没有落盘,最笨但最有效的办法就是把盘拆下来,换一台机器读。换机器读能排除所有操作系统缓存和驱动的影响,看到介质的真实状态。这个办法虽然麻烦,但在关键测试场景下,值得做。