☰
脚本PASS但OS读全零?存储老化测试的假象排查与脚本加固
2026/10/7 14:46:06 网站建设 项目流程

凌晨一点多,自动化老化测试跑到第三十二轮,控制台上又是一排绿色 PASS。我顺手在隔壁终端敲了一条 dd 命令,想抽查一下固定偏移区域的数据。结果读出来的东西让我瞬间清醒——全是 0x00,整片整片的零。脚本说 PASS,OS 层面却读到全零,这两边总有一个在撒谎。问题是,谁?

这个现象在存储设备、闪存、磁盘的老化测试里非常典型。脚本作为自动化执行者,返回一个 PASS 意味着它认为这一轮测试没问题;但 OS 直接读设备时却交出一份全零的“答卷”。如果只是偶发一次,可能是个小概率事件,但我在实际测试中遇到过多次,而且每一次都不是简单的“脚本写错了”就能解释。

这篇文章我会从一次典型的排查经历出发,把脚本、OS、设备固件三个层面拆开,讲清楚“PASS”到底是怎么来的、全零又是怎么来的,以及遇到这种矛盾时,你该按什么顺序去查、怎么改脚本才能不被假 PASS 坑。

1. 矛盾现场:脚本说 PASS,OS 却读出全零

1.1 事情是怎么发生的

先说背景。我是在做一块嵌入式存储设备的老化测试,环境是 Linux,设备通过 USB 转接挂载成 /dev/sdb。测试脚本是 Python 加 shell 混合写的,每一轮做的事情大致是:对设备写固定 pattern 数据、回读校验、检查 SMART 状态、记录耗温和时长。脚本核心逻辑是“只要命令返回成功、SMART 没有 critical warning,就判 PASS”。

问题就出在这里。某轮循环结束后,脚本打印 PASS,但这种 PASS 只是“脚本没遇到错误”的 PASS。我手动用 dd 去读设备的一定偏移区域,得到的是全零。注意,不是报 I/O error,不是设备掉线,而是读操作“成功”返回,内容却全部是 00。

这种矛盾现场最让人头疼的地方在于:它不会主动触发告警。设备没有报告故障,命令没有失败,读写没有超时,代码里所有 if 分支都走了正常路径。如果不是碰巧手动抽查,这个批量基本就这么“合格”地流出去了。

1.2 为什么这个现象比直接报错更危险

直接报错是好事。报错说明系统还在如实反映问题,你只需要顺着错误栈往下查就行。PASS 但数据错则完全不同,它代表某一个环节在“假装一切正常”。

我当时的第一反应是:脚本是不是用了缓存?比如写入后立刻回读,读到的可能是 page cache 里的旧数据,根本没落到设备上。于是我用sync、echo 3 > /proc/sys/vm/drop_caches清理缓存后再读,结果依然全零。那就不是缓存问题。

第二种可能是脚本判断 PASS 的逻辑太薄弱。很多脚本只在协议层面判断“命令是否被设备接受”,比如发一个 SCSI 命令,设备返回 status=0,就认为成功。这就像你发了一条微信,对方回了个“好的”,你就以为事情办妥了——对方可能根本没办。

第三种可能,也是最危险的:设备固件已经在内部悄悄降级。它内部发生了坏块重映射,把某个逻辑地址映射到了空白块或坏块标记区域,读出来自然就是零。但设备不会主动把这种情况告诉上层,因为从协议角度看,命令确实执行成功了。

所以当你看到“脚本说 PASS、OS 读全零”时,不要急着骂脚本或者骂设备,先按下面几条路径把事实查清楚。

2. 从脚本到 OS,数据到底走了几条路

2.1 脚本的 PASS 是怎么得出来的

要判断谁在撒谎,第一步就要搞清楚脚本的 PASS 依据是什么。常见的几种脚本判断方式,可信度完全不一样:

第一种,只看命令的退出码。比如 shell 脚本里执行smartctl -H /dev/sdb,然后if [ $? -eq 0 ]; then echo PASS。这种判断只代表 smartctl 命令正常跑完,不代表设备状态真的健康。

第二种,看协议层的 status。比如通过 ioctl 发一块数据的读命令,只要设备返回成功,就认为数据正确。这种比退出码好一点,但它验证的是“设备接收了命令”,不代表“返回的数据和你写入的一致”。

第三种,看寄存器或状态位的值。比如有些脚本会读 ATA 的 Error Register,只要没有置位就报 PASS。这个更接近硬件层,但依然不覆盖数据的实际内容。

第四种,也是我后来坚持使用的:端到端数据比对。写入固定 pattern,回读,逐字节或按块做哈希比对,全部一致才叫 PASS。

我当时那个脚本用的是第二种。它通过第三方库发底层命令,设备每次都能顺利返回,脚本就一路绿灯。可以说,脚本没有主观撒谎,但它看到的维度太少了——它只看到了协议层的应答,没看到数据本身。

2.2 OS 读到全零,有多少种可能

OS 层面读到全零,并不一定等于“设备里真的全是零”。全零的来源至少有四种,每一种对应的处理方式完全不同。

第一种,真实全零。这个区域原本就没写入有效数据,比如刚做完 TRIM/擦除的块,或者文件系统还没有分配过的区域,读出来就是合法的全零。这种情况属于“你读错了地方”,不算故障。

第二种,缓存/稀疏文件效应。如果读的是文件系统上的文件而不是裸设备,而文件是稀疏的或者页面缓存没有及时回写,读出来可能是全零或旧数据。

第三种,驱动或控制器填充零。某些设备在内部出错、掉盘、reset 之后,驱动为了不让上层崩溃,会用一个全零缓冲区来“完成”读请求。也就是说,你读到了“零”,但这个零是驱动给的,不是设备给的。

第四种,固件坏块管理重映射。设备内部把逻辑地址重映射到一个空白块,相当于“换了个空仓库给你”,零就是新的物理位置的出厂内容。

这四种里,第一种是无辜的,第二种是测试方法问题,第三种和第四种才真正指向设备故障或固件异常。而我们要做的,就是先把前两种排掉,再判断后两种到底谁在起作用。

2.3 谁在撒谎:四种典型情况对比

我把实践中碰到的情况整理成一张对照表,排查时直接对号入座。

现象特征可能的真相脚本视角OS/驱动视角实际结论
命令全成功,读全零,报错为空设备真实数据为 0(TRIM/未写区域)PASS 合理读到合法全零没有撒谎,是测试范围错了
脚本读缓存,OS 读裸设备数据其实没问题,脚本看到缓存PASS 但不可靠裸设备读到真实数据脚本被骗,不是设备
设备掉线后驱动补零物理 I/O 失败被掩盖PASS 只代表命令发出读到驱动塞的 0驱动在掩盖,最危险
固件内部坏块重映射逻辑地址对应空白块PASS 合理读到 0设备固件在撒谎或已降级

看到这张表的最后一栏,你就明白标题里“到底是谁在撒谎”有多重要。大多数时候不是一个角色在撒谎,而是多个层面同时缺失校验能力。

3. 实测排查:一步一步找出“话事人”

3.1 先确认脚本 PASS 的逻辑

我的做法是先把脚本的判断逻辑打印出来。在 Python 脚本里,封装的底层库返回值可能是一个元组,里面包含 sense key、status、data。很多脚本只看了 status。

我当时把脚本改成临时调试模式,把每一层的返回值都落到日志里。结果发现,脚本判断 PASS 用的字段只是一个“命令状态”返回码,设备给出的 sense key 是NO SENSE,表示“命令执行正常”,但这和“数据内容正确”完全是两码事。如果你写的是一段测试脚本,我建议你立刻检查代码里 PASS 的触发条件,确认它到底校验了什么、没校验什么。

3.2 用 dd 绕过文件系统直接读

脚本层查完后,接下来要用裸设备读,绕开文件系统、绕开 page cache。关键命令是 dd 的iflag=direct。

# 直接读块设备的前 64MB,强制绕过 page cache dd if=/dev/sdb of=/tmp/check.bin bs=1M count=64 iflag=direct status=progress # 看读出来的内容里零占比 xxd /tmp/check.bin | head -20 # 对比校验和(如果之前有写入过的基准) sha256sum /tmp/check.bin

注意,iflag=direct在 Linux 上绕过的是块设备层的缓存,但它不绕过设备固件内部的逻辑。也就是说,如果设备固件决定给你返回零,dd 照样读到零。这个步骤的价值在于排除驱动缓存这个变量,让问题更靠近设备本身。

如果你有之前写入的 pattern 基准文件,可以直接cmp或sha256sum对比。这一对比,矛盾才真正暴露出来:写入时校验过的 pattern,现在读回变成零。

3.3 查 dmesg 和 SMART 底账

一旦确认裸设备读到全零,立刻去查系统日志。

dmesg | tail -100

重点看有没有这几类记录:I/O error、reset、abort、task timeout、link down。这些都是设备在底层已经出事的暗号。如果 dmesg 干干净净没有错误,再查 SMART。

smartctl -a /dev/sdb

关注这几个字段:

  • Reallocated_Sector_Ct:已经重映射的扇区数,如果这个数字在老化过程中一直涨,说明坏块越来越多。
  • Current_Pending_Sector:等待重映射的扇区数量。出现这个值,说明设备已经发现了问题但还没处理完,读这些区域大概率读不到正确数据。
  • Offline_Uncorrectable:离线无法纠正的数量。一旦出现这个,设备已经放弃了对某些数据的纠错能力。

我那次排查的结果是Current_Pending_Sector从 0 涨到了 16,而脚本因为只看命令状态完全没察觉。设备固件其实早就知道这些扇区读不出来,只是它选择了“给你返回零”而不是“给你报错”。这在老化的存储设备上并不罕见,属于固件“带病工作”的表现。

3.4 权限与“拒绝访问”的一个隐藏坑

排查时还有一个特别容易混淆的情况,就是权限问题。很多脚本跑在普通用户下,直接读 /dev/sdb 会报error: 拒绝访问 (os error 5)。如果脚本把这种异常吞掉,或者在异常分支里默认返回了 PASS,那就会出现“脚本说 PASS、实际上连数据都没看到”的状况。

我见过一次类似的情况:脚本在某轮循环中突然读设备失败,错误处理分支不够严谨,直接跳过了数据校验,顺手打了 PASS。而等你手动去读时,要么权限不足读不了,要么读出来的内容不对。热词里那条error: 拒绝访问。 (os error 5) to work without the background server, rerun描述的其实就是这类现象在别的一个场景下的翻版。

排查时如果遇到 os error 5,先去确认脚本运行用户的权限:

# 确认当前用户 id # 查看设备权限 ls -l /dev/sdb # 如果普通用户需要访问,临时用 sudo 验证一次 sudo dd if=/dev/sdb of=/dev/null bs=1M count=1 iflag=direct

不要小看这一步。很多时候“PASS 但读全零”的真正原因不是设备,而是脚本在无权限或异常状态下走了一条“静默成功”的路径,压根没做实质校验。这也呼应了热词里提到的“脚本进入安全模式”——脚本自身降级了,却不告诉你。

4. 老化测试脚本怎么改,才能不被“PASS”骗

4.1 从“命令成功”到“数据正确”

经过那一次之后,我把老化测试里所有脚本的 PASS 定义都改了。原来的脚本是“指令成功即 PASS”,现在强制改成“数据校验成功才 PASS”。具体来说,脚本每一轮必须做到下面四件事,缺一不可:

第一,写入时必须带 pattern 标记。不要写随机数,要写可识别的固定模式,比如 0xA5、0x5A、递增序列,方便回读时快速定位。

第二,回读时用裸设备读取并强制绕过缓存。写入后sync,然后在脚本里执行类似dd iflag=direct的操作,对读取结果做哈希或逐块比对。

第三,每一轮结束要抓取 dmesg 增量日志,并在脚本里做关键词匹配,出现 I/O error、reset、abort 就直接判 FAIL。

第四,SMART 要记录绝对值而非只看状态。也就是说,不能只看 “SMART overall-health PASSED”,要把Reallocated_Sector_Ct、Current_Pending_Sector这些原始值记下来,做趋势分析。一次两个重映射可能是偶发,但一轮涨十个,即使还在 PASS 范围,也要列为 WARN。

以上四点里,第四条往往最容易被忽略。我在实际测试中有一个体会:老化测试里设备很少会“突然暴毙”,更多是“指标缓慢恶化”。如果你只看 PASS/FAIL 这个最终结果,等 FAIL 出现时,数据大概率已经损坏很久了。

4.2 一个可落地的端到端校验脚本思路

下面给一个精简但完整可用的 shell 校验片段,思路是在指定范围内写入固定 pattern,再回读比对。实际量产脚本建议用 Python 或 C 封装,处理异常会更精细。

#!/bin/bash # 端到端数据完整性校验示例 DEV=/dev/sdb START_OFFSET=1048576 # 从 1MB 偏移开始 BLOCK_COUNT=256 # 校验 256 个块 BLOCK_SIZE=4096 # 块大小 4KB # 1. 写入固定 pattern:每个 4KB 块填充 0xA5 echo "== 写入 pattern ==" dd if=/dev/zero bs=$BLOCK_SIZE count=$BLOCK_COUNT 2>/dev/null | tr '\0' '\245' \ | dd of=$DEV bs=$BLOCK_SIZE seek=$((START_OFFSET / BLOCK_SIZE)) conv=notrunc status=none sync # 2. 丢弃缓存,确保读的是设备真实内容 echo 3 > /proc/sys/vm/drop_caches # 3. 回读并校验 echo "== 回读校验 ==" dd if=$DEV of=/tmp/readback.bin bs=$BLOCK_SIZE skip=$((START_OFFSET / BLOCK_SIZE)) \ count=$BLOCK_COUNT iflag=direct status=none # 4. 计算实际内容是不是全 0xA5 # 方法:把回读文件的非 0xA5 字节数统计出来 python3 - <<EOF import sys data = open('/tmp/readback.bin', 'rb').read() expect = bytes([0xA5]) * len(data) if data == expect: print("RESULT: PASS") sys.exit(0) else: # 找出第一个不一致的位置 for i, (a, b) in enumerate(zip(data, expect)): if a != b: print(f"RESULT: FAIL at offset {i}, got {a:#04x}, expected {b:#04x}") break sys.exit(1) EOF

这段示例逻辑很简单:写入全 0xA5 的 pattern,回读后逐字节比对。你把 0xA5 换成任何自己的 pattern 都行。实际测试里,为了防伪随机干扰,可以写入按块递增的 pattern,比如块序号写入到每块开头,校验时先看块号对不对,再看内容对不对,能更早定位故障块。

脚本里有个细节值得注意:/proc/sys/vm/drop_caches需要 root 权限。如果你的脚本在普通用户下跑,这个操作会失败。更稳妥的做法是每次都改用iflag=direct绕过缓存,而不是依赖 drop_caches。我在生产脚本里就只用iflag=direct,不依赖系统缓存清理权限。

4.3 记录现场证据,别只留一个 PASS

脚本改造的另一半工作是日志和证据记录。原来脚本只有一行PASS,现在每一轮循环至少要留下这几样东西:

  1. 当前轮次编号和时间戳。
  2. 写入的 pattern 和校验范围。
  3. 回读校验的哈希值或比对结果。
  4. dmesg 该轮新增的错误片段。
  5. SMART 关键字段的原始值。

为什么要坚持记原始值?因为老化测试的核心目的是观察趋势,而趋势必须有历史数据支持。拿Pending_Sector来说,单轮读到的绝对值为 0 不代表健康,它可能在写入阶段已经发生大量重映射,只是 SMART 还没来得及更新。把每一轮的值画出来,曲线一旦抬头,你就知道设备开始不行了,而不必等它彻底读全零那一刻。

日志记录推荐用 CSV 或 SQLite,不要用 txt 追加。因为一轮测试可能跑几百个小时,数据量可观,结构化存储方便后面做趋势分析和出报告。

5. 常见问题速查与避坑技巧

把这段时间踩过的坑和排查经验汇总一下,方便你直接当速查表用。

现象可能原因排查方法处理建议
脚本 PASS,dd 读全零读的区域本来就没写过数据确认测试范围是否覆盖写入过的区域限定范围和偏移,不用整盘比对
脚本 PASS,dd 读全零固件坏块重映射到空白块看 SMART 的 Pending/Reallocated 计数换设备或标记该区域坏块
脚本 PASS,dd 报权限拒绝脚本异常吞掉错误并默认成功检查异常分支,观察 os error 5给脚本最小权限但不吞异常
读全零且 dmesg 有 reset/timeout设备掉线后被驱动补零查看 dmesg 时间点与读操作对齐立即停止测试,更新固件
写后回读一致,但隔几轮变零设备固件校验/磨损均衡策略异常做跨轮次的数据保持性测试延长静置时间后再次校验

几个避坑要点单独拎出来说:

坑一:把“每次都能读到”当成了“每次读到都对”。脚本里如果只是open(dev).read()成功,不代表数据就是对的。读操作成功只是协议层的状语,不是数据正确性的证明。要加内容比较。

坑二:用文件系统路径做校验。如果读的是挂载点下的文件而不是裸设备,你测的是“文件系统+块设备+驱动”的整条链路,出了问题很难定位。老化测试最好直接操作裸设备,避开文件系统这层变量。

坑三:写测试区域包含未初始化空间。我最初几次读全零,后来才发现测试脚本写的是文件,而文件的底层块是稀疏的,没分配的块读出来就是零。这解释了大量“假异常”。测试前先确认该区域的实际数据和逻辑分配状态,稀疏文件和真实全零要分清。

坑四:依赖普通用户权限跑底层命令。底层设备访问基本都需要 root 或 disk 组权限。如果你不想用 root 跑整个脚本,可以把设备访问单独拆成一个服务,用 sudo 执行,但一定不要让脚本在权限异常时“静默转为 PASS”。异常必须显式抛出来,哪怕先报 FAIL 也比假 PASS 强。

这些年做老化测试,我有一个很深的感受:自动化脚本最大的风险不是它跑错了,而是它在跑错的时候还开开心心地告诉你一切正常。“PASS”这个词太有迷惑性了,它让人下意识地放心,却忘了确认这个 PASS 到底校验了什么。我后来给自己定了一条规则:无论脚本打出什么结果,都必须能在日志里一行行看到它检验过什么、比较过什么、哪些字段支撑了这个结论。如果你的脚本也能做到这一点,那“脚本说 PASS、OS 读全零”这种荒唐事,就会在第一时间被拦下来,而不是等到出货之后才被发现。

最后再补一句:不要轻易放过任何一个“全零”。全零在存储测试里太容易被人当成“空数据、没写入、无所谓”,但它恰恰可能是固件在向你打暗号。看到全零,先别急着翻代码,先问三个问题——这个区域写过吗?脚本看到的是数据本身还是命令状态?设备在 dmesg 里还有没有别的尾巴?这三个问题问完,撒谎的人自然就会现形。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询