脚本说PASS但dd读盘全零?老化测试挂载失效排查实录
前一晚跑的设备老化测试,脚本在屏幕上干干净净打出一行RESULT: PASS。第二天我想着再复核一下介质内容,直接拿dd读块设备,结果hexdump从第一字节开始全是 00。脚本说 PASS,OS 读全零——到底是谁在撒谎?
这不是灵异事件,也不是玄学,是我在做存储设备老化测试时踩过的一个非常典型的坑。这篇文章不做任何概念铺垫,直接从我那台测试机上的完整排查过程讲起。如果你也负责设备老化测试、写过类似的全自动执行脚本,或者只是被 Linux 的缓存机制坑过,这篇应该能帮你省下至少一个通宵。
1. 现场:老化测试一夜 PASS,dd 读盘却全是零
1.1 两种互相“打脸”的证据
测试对象是一批 TF 卡,用读卡器插在一台 Linux 测试服务器上。我写了一个老化测试脚本,流程很简单:格式化卡片、写一段随机数据、读回来做哈希比对,一致就输出 PASS,计划循环跑多轮。
第二天早上我进了机房,屏幕上的日志停在了这一行:
[2025-xx-xx 06:00:01] round 12: RESULT: PASS十二轮全部通过。按理说这个批次应该可以签收了,但我有个习惯:任何“整批 PASS”的结论,都要抽一块盘做点对点复核。于是我拔下其中一张卡,插到另一台机器的读卡器上,直接读原始设备:
dd if=/dev/sdc bs=1M count=64 | hexdump -C | head -30结果令我当场愣住:从头到尾,全部是00 00 00 00 ...。
这相当于什么概念?一个脚本说“我写进去的数据读回来一模一样”,另一个证据说“这块盘上压根没有任何有效数据”。两者必有一个在说谎,或者更糟,两者都有一套我没搞懂的逻辑在背后运作。
1.2 第一反应别急着断言“脚本 bug”
排查这类问题的第一反应容易走偏。
有人会直接断定“脚本有 bug,校验逻辑坏了”,有人会怀疑“读卡器坏了”,还有人会怀疑“这批卡本身就是坏片,写入全丢”。这些猜测在没有证据之前都只是猜测。
我的原则是:先把“脚本说 PASS”这句话的完整语义搞清楚,再去问“OS 读全零”这句话的完整语义。双方各自基于什么数据、走到了什么分支、得出的结论。搞清楚这两件事,再回来看它们为什么冲突,比直接甩锅给某一方要靠谱得多。
2. 拆脚本:那个 PASS 到底证明了什么
2.1 脚本主流程:短短几行,三个致命忽略
我先把脚本翻了出来。为了讲清楚问题,我把核心逻辑压缩成下面这个版本,名字就叫aging_test.sh,各位可以先自行感受一下问题出在哪:
#!/bin/bash # 老化测试脚本(有问题的原始版本,节选) DEV=/dev/sdc PART=${DEV}1 # 错误一:这张 TF 卡根本没有 /dev/sdc1 MNT=/mnt/tf PAT=/tmp/aging.pat # 生成 256MB 随机测试数据 dd if=/dev/urandom of=$PAT bs=1M count=256 # 格式化 + 挂载(都没有检查退出码) mkfs.vfat $PART >/dev/null 2>&1 # 错误二:输出被吞掉,失败完全不可见 mkdir -p $MNT mount $PART $MNT # 错误三:同样未检查返回值 # 写入测试文件 cp $PAT $MNT/aging.img sync H1=$(md5sum $PAT | awk '{print $1}') H2=$(md5sum $MNT/aging.img | awk '{print $1}') if [ "$H1" == "$H2" ]; then echo "RESULT: PASS" fi三处致命忽略:
- 脚本来自另一台设备的老化方案,那块设备是有分区的,所以作者用了
$PART=${DEV}1。但 TF 卡经读卡器暴露出来通常是一整块裸设备,没有分区表,系统根本不会生成/dev/sdc1。 mkfs.vfat的输出被>/dev/null 2>&1吞掉了,而且没有检查退出码。对不存在的/dev/sdc1执行 mkfs,必然失败,但脚本当无事发生。mount同样失败,但脚本依旧继续往下走。
这三处叠加,后面的一切假象都有了土壤。
2.2 mount 失败之后,数据去了哪里
这里才是整个案子的核心谜底:mount /dev/sdc1 /mnt/tf失败之后,/mnt/tf这个目录并没有消失,它仍然存在于根文件系统上,还是一个普普通通的目录。
关键在于一个很容易被忽略的事实:往/mnt/tf/aging.img写入文件时,你写的到底是什么介质,完全取决于/mnt/tf这个挂载点此刻指向哪里。
- 如果 mount 成功,
/mnt/tf指向 TF 卡的根目录,写进去就是在写 TF 卡。 - 如果 mount 失败,
/mnt/tf依然是系统盘上的一个空目录,写进去就是在写系统盘(那台测试机用的是另一块 SSD)。
脚本继续往后执行cp $PAT $MNT/aging.img,数据其实被写到了测试机系统盘的/mnt/tf/aging.img里。这不叫“TF 卡老化测试”,这叫“拿一块系统盘跑了一个通宵的全自动写入程序”。TF 卡从头到尾没被碰过。
可以打个比方:你要把货物放进 A 仓库的房间,但仓库门钥匙不对,管理员没给你开门。你以为自己进了 A 仓库,其实那只是同一个门牌号下面原来的普通房间,货物整晚都堆在了错误的房间里。第二天你拿着仓库的入库单说“货已入库 PASS”,但去 A 仓库一看,里面空空如也。
2.3 md5sum 为什么能一路绿灯
有人会问:如果脚本校验的文件其实写在系统盘上,那md5sum $MNT/aging.img和md5sum $PAT为什么能一致?
因为cp是真正执行成功的。它把 256MB 的随机数据完整复制为/mnt/tf/aging.img,这个文件在系统盘上真实存在,内容也没有损坏。md5sum对比的双方都是有效文件,哈希自然一致。
所以脚本输出的 PASS 并不是“随机数撞了”,它背后是一次真实发生的、字节级完全一致的写读比对。脚本没有在数学上欺骗你,它只是在错误的对象上完成了正确的校验。
这里就浮现出了整篇文章最关键的一句话:
脚本输出的 PASS,实质是“位于 /mnt/tf/aging.img 的文件内容与测试模板一致”,而不是“TF 卡上的数据写入正确”。脚本作者把两者的语义混为一谈。一旦 mount 失败,PASS 的意义就失效了,但脚本本身并不知情。
3. 查 OS:全零意味着底层真的没有数据
3.1 文件路径读回 vs 块设备直读,看到的是两层世界
脚本里校验走的是文件系统路径:/mnt/tf/aging.img。而我复核时直接读的是块设备节点:/dev/sdc。
这两条读路径在 Linux 里完全是两个世界:
- 文件路径读回,经过 VFS、页缓存、具体文件系统的寻址逻辑,读到的是“某个文件在当前已挂载文件系统里的内容”。
- 块设备直读,绕开文件系统语义,直接向磁盘控制器要“物理块上的原始字节”。
对一块从未被格式化成功的 TF 卡来说,物理块上没有任何有效数据。SD/TF 控制器的未映射区域通常会返回 0x00 或者 0xFF——不同厂商有差异,但本质都是“空白内容”。我看到的hexdump全 00,正是这种情况。
这也是为什么“OS 读全零”这个证据如此有力:如果脚本曾经往 TF 卡里写过哪怕一次数据,块设备直读就不可能从头到尾全零。除非后续有一个覆盖写零的步骤,而老化测试流程里根本没有这一步。
3.2 page cache 在中间做了“和事佬”
排查过程中还有一层容易让人误判的因素:页缓存(page cache)。
Linux 对文件读写的默认策略是:写文件时数据先落到内存页缓存里,再异步回写到磁盘;读文件时优先从缓存命中,而不是直接读介质。于是出现一种常见的假阳性:
- 脚本刚写完文件,马上又读同一个文件做校验。
- 读操作命中页缓存,返回的和刚写入的完全一样,哈希一致。
- 测试人员据此判定 PASS,但实际底层介质可能压根没有收到这些数据,或者收到后写入失败了。
在这个案子里,页缓存不是根因——因为cp写的是系统盘,文件系统正常,数据最终确实落到了系统盘上。但我在实际排查时依然做了一次缓存穿透验证,用来排除干扰。后面第 6 章我会专门讲这种页缓存造成的假阳性场景,它就是另一个“看似 PASS 实则无效”的典型坑。
3.3 验证结论:盘没挂上,测试全程打在空气上
我把几条铁证汇总起来:
# 1. 挂载点里有什么 mount | grep sdc # 没有任何输出 # 2. 设备当前状态 lsblk -f /dev/sdc # NAME FSTYPE LABEL UUID MOUNTPOINT # sdc # FSTYPE 为空,没有挂载点 # 3. 块设备直读 dd if=/dev/sdc bs=512 count=16 | hexdump -C # 0000000 0000 0000 0000 0000 0000 0000 0000 0000 # ... # 4. 尝试手动挂载,暴露真实报错 mount /dev/sdc1 /mnt/tf # mount: /mnt/tf: special device /dev/sdc1 does not exist.到这一步,结论已经板上钉钉:脚本老化测试的对象压根不是 TF 卡,TF 卡在整晚测试里从未被格式化、从未被挂载、从未被写入。OS 层面读出来的全零,不是“数据被擦了”,而是“从来没有数据”。
4. 真相复盘:谁都没撒谎,但测试设计失效了
4.1 脚本的 PASS 是真的,OS 的全零也是真的
把两边的证据摆在桌面上,用表格整理一遍最直观:
| 证据 | 观察手段 | 结论 | 语义 |
|---|---|---|---|
| 脚本输出 PASS | md5sum比对/mnt/tf/aging.img与模板 | 两边哈希一致 | /mnt/tf里的文件内容与模板相同 |
| OS 读到全零 | dd直读/dev/sdc | 物理块上无有效数据 | TF 卡从未被成功写入 |
手动挂载/dev/sdc1 | mount报设备不存在 | 卡上根本没有该分区 | 脚本的挂载操作从未成功 |
现在可以回答标题那个问题了:到底是谁在撒谎?
答案是:谁都没撒谎。
脚本说的是真话,按它自己锁定的对象/mnt/tf/aging.img,文件内容确实没错;OS 说的也是真话,按块设备/dev/sdc的物理状态,数据确实是零。两个真命题之所以看起来矛盾,是因为它们描述的根本不是同一个对象。
真正出问题的是测试设计本身:脚本作者把/mnt/tf当成了 TF 卡的代理,但挂载失败后这个代理失效了,而脚本没有任何机制检测到这一点。这不是“哪个环节说谎”的问题,而是“测试对象发生了漂移”的问题。
4.2 失效点:测试把“目录可写”当成了“介质已写入”
把这个问题再抽象一层,可以得到一个对所有自动化测试都适用的教训:
当你准备断言“XX 写读验证通过”之前,必须先确认“写读验证确实发生在 XX 上”。
在这个案子里,脚本断言的是“数据写入成功并读回一致”,但它实际只证明了“某个目录可写、某个文件可读”。目录可写和介质已写入之间隔着一层挂载关系,而这层关系恰恰是脚本里唯一没有被验证的关键步骤。
硬件老化测试最怕的就是这种“无效测试”:机器跑了一整夜,电费、时间、人力都花了,日志里全是 PASS,但被测对象根本没参与测试。这类测试通过得越漂亮,对产品质量的误导越严重。
4.3 这类假阳性在自动化里比你想的更常见
不要以为只有“挂载失败被忽略”这一种形态。我事后复盘时列了一串类似的场景,全部都能产生“看起来在测、实际上没测”的假阳性:
- 脚本里设备路径写错,比如
/dev/sdb实际是系统盘,测试一直在给启动盘做读写。 - USB 读卡器在热插拔后设备名漂移,
/dev/sdc变成了/dev/sdd,多轮测试可能测的不是同一张卡。 - 测试机器上有多个同型号读卡器,脚本用
sdX定位,插拔顺序一变,目标就飘了。 - 文件系统挂载到了 tmpfs 或 overlay,重启后一切归零,但测试期间读写全部“成功”。
这些问题有一个共同的检测手段:在测试开始前、进行中、结束后,都要用独立于文件系统之外的视角去确认——你正在操作的对象确实是那块预期的物理介质。
5. 修复:给老化测试脚本装上“强制落地”的保险
5.1 强制校验退出码与挂载状态
第一道保险是让脚本“停下来承认失败”,而不是“滑过去假装成功”。
我改脚本的第一版,做了三件事:
- 启用
set -euo pipefail,让未处理变量报错、管道中间失败也能被捕获。 - 不再用
>/dev/null 2>&1吞掉格式化输出,改用显式重定向到日志文件。 - 每条关键命令后面直接检查退出码,失败立即终止并返回特定错误码。
挂载之后,还要用独立命令验证挂载是否真正生效:
mount "$PART" "$MNT" if ! findmnt -rn "$MNT" >/dev/null 2>&1; then echo "FATAL: mount check failed on $PART" >&2 exit 30 fifindmnt比mount | grep更适合脚本使用:它输出干净、退出码可靠,而且不会因为字符串匹配误判。老写法mount | grep "$MNT"有个隐患——挂载点路径如果恰好是另一个挂载串的子串,grep 也会匹配上,产生新的假阳性。
5.2 绕过缓存,从块设备直接取证据
文件哈希校验通过之后,不要急着 PASS。我加的第二道保险是“卸载后从块设备再取一次证据”。
具体做法是:写入完成后先sync,然后umount,重新挂载,再读回文件做哈希;最后再卸载一次,直接对块设备分区做一次哈希或抽查对比关键位置。
# 第一次读取:重新挂载后走文件系统路径 umount "$MNT" mount "$PART" "$MNT" HASH_FILE=$(md5sum "$MNT/aging.img" | awk '{print $1}') umount "$MNT" # 第二次读取:从块设备直读(绕过文件系统语义) HASH_DEV=$(dd if="$PART" bs=4M count=$$CNT 2>/dev/null | md5sum | awk '{print $1}')注意dd读块设备时,bs和count要精确覆盖写入范围,否则哈希对不上。这里的CNT需要根据测试文件大小换算:256MB 除以 4MB 就是 64 块。如果文件系统和分区起始偏移不对齐,最稳妥的做法是把整块分区在预期范围内都算进去。
页面缓存那层干扰,在umount之后重挂也会被冲淡——重新挂载意味着之前的缓存条目失效,读回时大概率需要真正访问介质。如果想彻底压掉缓冲影响,还可以在卸载后执行:
echo 3 > /proc/sys/vm/drop_caches不过这条命令在共享的生产机器上要非常慎重,全局清缓存会影响同机其他业务,我一般只在专用测试机上用。
5.3 一份可直接改着用的最小脚本
下面给出我重构后的最小可用版本。它保留了老化测试的核心逻辑,但每一步都有退路。多轮循环就套在外面,这里展示单轮结构。
#!/bin/bash set -euo pipefail DEV=/dev/sdc PART=${DEV}1 # 如果介质无分区,改为 DEV MNT=/mnt/aging_tf PAT=/tmp/aging.pat LOG=/var/log/aging_test.log CNT=64 # 256MB / 4MB # 确认设备存在且有可写介质 [ -b "$DEV" ] || { echo "FATAL: $DEV not found" >&2; exit 10; } # 生成随机 pattern,保留哈希 dd if=/dev/urandom of="$PAT" bs=4M count=64 status=none PAT_HASH=$(md5sum "$PAT" | awk '{print $1}') # 格式化:要么成功,要么停下来 mkfs.vfat "$PART" >>"$LOG" 2>&1 || { echo "FATAL: mkfs failed" >&2; exit 20; } # 挂载 + 强校验 mkdir -p "$MNT" mount "$PART" "$MNT" >>"$LOG" 2>&1 || { echo "FATAL: mount failed" >&2; exit 25; } findmnt -rn "$MNT" >/dev/null || { echo "FATAL: mount check failed" >&2; exit 30; } # 写入 cp "$PAT" "$MNT/aging.img" sync # 第一轮校验:卸载重挂后读文件 umount "$MNT" mount "$PART" "$MNT" FILE_HASH=$(md5sum "$MNT/aging.img" | awk '{print $1}') umount "$MNT" # 第二轮校验:块设备直读 DEV_HASH=$(dd if="$PART" bs=4M count="$CNT" 2>/dev/null | md5sum | awk '{print $1}') # 汇总判定 if [ "$PAT_HASH" != "$FILE_HASH" ] || [ "$PAT_HASH" != "$DEV_HASH" ]; then echo "RESULT: FAIL (pat=$PAT_HASH file=$FILE_HASH dev=$DEV_HASH)" exit 40 fi echo "RESULT: PASS"这里有一个细节:脚本里用PART=${DEV}1,前提是介质上确实有分区表。如果测试对象是一张无分区裸卡,正确做法是mkfs.vfat "$DEV"后直接挂$DEV,而不是$PART。重构脚本时我加了一个显式的[ -b "$PART" ]检查,确保分区节点存在再走分区流程。否则,第一道保险就会拦住这个典型的移植错误。
5.4 每轮测试日志该怎么留
老化测试动辄几十轮,日志必须能支撑事后回溯。我在脚本里加了这样一段:
echo "$(date '+%F %T') round=$ROUND dev=$DEV part=$PART size=$SIZE pat_hash=$PAT_HASH file_hash=$FILE_HASH dev_hash=$DEV_HASH result=$RESULT" >> "$LOG"日志的好处在于:当 PASS 和全零再次同时出现时,你能立刻在日志里查到“这一轮实际操作了什么设备、哈希是什么、哪一步退出码是多少”。没有这些信息,你只能在黑盒里反复猜测。
我还习惯在每轮开始前和结束后各自打一次lsblk -o NAME,SIZE,MODEL,SERIAL,把设备拓扑也留档。这样即使设备节点漂移,也能从序列号上找到“本轮测的到底是哪张卡”。
6. 同类坑位:还有哪些“看似 PASS 实则无效”的测试
6.1 写入后立即读回:page cache 的假阳性变体
这是最常见的假阳性场景,和“挂载失败”可以并列前两名。
操作路径是:向闪存盘写入一个文件,立刻md5sum对比,哈希一致就输出 PASS。这个流程在没有强制落盘的情况下,存在严重隐患——写入的数据还留在 page cache,读回时直接从内存命中,根本不会去介质里取数据。
最典型的翻车现场是:脚本输出 PASS,测试人员随即把读卡器拔下来,插到另一台机器上一看,文件不存在或者内容损坏。因为拔下设备时,脏页还没来得及回写,或者介质控制器在最后回写时因为供电不稳失败了。
对策其实已经在第 5 章里写了:写入后sync,然后卸载重挂,再读回校验。如果你做的是掉电可靠性测试,还要在写完后直接断电、重新上电再校验,那才能真正覆盖“flash 控制器承诺写入成功,实际上掉电即丢”的经典故障。
6.2 稀疏文件 + 全零 pattern:校验和天然一致
另一种非常隐蔽的假阳性来自稀疏文件和全零数据组合。
dd if=/dev/zero of=/mnt/tf/disk.img bs=1M seek=8192 count=1这条命令创建了一个“看起来 8GB”的文件,但实际只写了末尾 1MB,文件中间全是洞(空洞)。如果脚本的 pattern 恰好是全零——很多初版老化脚本图省事,直接用/dev/zero当测试数据——那么读回稀疏文件时,空洞部分读到的也是全零,哈希自然和 pattern 完全一致,PASS 顺理成章。
但实际问题很严重:8GB 的测试文件里只有 1MB 真正命中了介质块,剩下 7.999GB 根本没有触发写入。老化测试想考察的“介质全区域擦写耐久度”,完全没测到。
对策有两个:一是测试 pattern 一律用随机数据,不要用全零,让空洞在读回时必然产生哈希分歧;二是写入后用du或stat检查实际占用块数,看文件是否真的是完整写入。stat -c '%b'输出的是实际占用块数,和理论值对不上就要警惕。
6.3 /dev/sdX 漂移:测的根本不是同一块盘
多路热插拔是老化测试的常态。插三张卡、拔掉一张、又插一张,Linux 的设备节点顺序往往就变了:原来/dev/sdc的卡可能下次变成/dev/sde。
如果你的脚本里写死/dev/sdc,第二跑很可能测到另一张卡上。更麻烦的是,如果三张卡型号相同、容量一致,脚本不会报错,哈希也正常,但你看不到。你以为测完了三张,实际上某张卡被重复测了三轮,另一张卡一轮都没测。
对策是放弃sdX定位,改用/dev/disk/by-id/下的稳定链接。例:
DEV_ID=$(readlink -f /dev/disk/by-id/usb-Transcend_JetFlash_01234-0:0)这个链接以硬件序列号为锚点,即使设备节点从 sdc 变成 sdd,指向的物理介质也不会变。脚本启动时打印完整的lsblk拓扑,便于人工复核。
6.4 tmpfs / overlay 里跑持久化测试:重启即失忆
最后一个坑,也是嵌入式测试环境里很常见的一个:以为自己在测持久化存储,实际上挂在 tmpfs 或 overlay 文件系统上。
tmpfs直接跑在内存里,读写速度飞快,测试必过,但一重启全部归零。overlayfs场景下,脚本写入叠加层,看起来在底层介质上建了文件,实际读写都走的是上一层临时目录,底层介质可能根本没有变化。
我之前排查过一个“固件升级后配置丢失”的 bug,最后定位到现象就是:升级脚本在 overlay 环境下测试写配置成功,输出 PASS,但重启后 overlay 上层被丢弃,配置文件回到旧版本。从脚本视角看,每一步都对;从用户视角看,产品就是个会失忆的残次品。
检测手段非常简单,脚本开头加两行:
findmnt -T "$MNT" df -T "$MNT"看 FSTYPE 是ext4、vfat还是tmpfs、overlay。看到后两种,就要明白测试结果对“持久化”这个目标没有任何证明力。
写脚本十几年,我现在养成了两个雷打不动的习惯:一是不相信任何没检查退出码的命令;二是凡是结论里出现“写盘成功”四个字,我至少要绕开文件系统,从块设备再取一次证据。这次“脚本说 PASS、OS 读全零”的案子,最终就是这么破的。技术在进步,工具在变,但“确认你测的确实是那个对象”这件事,永远是自动化测试里最值得花时间的一道保险。