测试设备在老化台上跑了一个通宵,早上过去翻日志,整屏都是绿的。每个 case 结尾都规规矩矩写着 PASS,其中一个是向板载 Flash 写入一组固定 pattern,脚本自己写、自己读、自己比对,全部一致。我顺手用 OS 层命令去读同一个地址,结果 hexdump 吐回来的全是 0x00。
脚本说 PASS、OS 读全零,到底是谁在撒谎?这个问题如果你在嵌入式开发、设备老化测试、自动化测试脚本维护里碰到过,一定知道这背后有多坑。测试脚本是裁判员,结果裁判员自己说了谎,那整个测试结论就全部作废。这篇文章我会把整个排障思路完整串一遍:先还原现场,再解释脚本断言和 OS 读写路径这两个层面为什么会各说各话,然后给出一套从软件到硬件的分步排查方法,最后分享如何改造测试脚本,让它以后不再“虚报军情”。如果你正在写自动化测试脚本,或者做量产验证、老化测试,这篇能帮你少踩很多坑。
1. 先还原现场:脚本说 PASS、OS 读全零是怎么发生的
1.1 我遇到的真实场景
那是批量老化测试的一台设备,Linux 系统,板上带一片 SPI NOR Flash,容量 16MB,系统里有一个字符设备节点 /dev/xxxx0 作为访问入口。老化脚本是 Python3 写的,每一轮做同样的事:向 Flash 起始偏移地址写入一段 64 字节的测试 pattern(0xA5 0x5A 0xAA 0x55 交替),然后从同一地址读回,比对数据一致就输出 PASS。
日志大概长这样:
[2025-06-01 03:12:41] round 2047 write_ok=64 read_ok=64 status=PASS看着非常正常,每一轮都写进去了、读回来了、比对一致。然后我用 OS 层命令直接读同一地址:
dd if=/dev/xxxx0 of=/tmp/read.bin bs=1 count=64 skip=0 2>/dev/null hexdump -C /tmp/read.bin结果输出:
00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|全部是零。第一反应是脚本有问题,但脚本明明是自己写、自己读,读出来和写入的一致才打的 PASS。如果真的什么都没写进去,脚本为什么会读到一样的 pattern?难道硬件会欺骗软件?还是说脚本读的根本不是 Flash?
1.2 矛盾的核心:脚本和 OS,谁才是可信的那个
从证据上看,有两个互相矛盾的事实:脚本自读自比全部通过,OS 层直读全部为零。必须承认,两者可能都没撒谎,只是它们看到的“世界”不是同一个世界。
脚本说 PASS,只能说明“脚本调用某个 API 后,API 按它自己的逻辑返回了成功”。OS 读全零,只能说明“从某个文件描述符、某个偏移量读回来的数据是全零”。这两件事之间隔了用户态、内核态、驱动、总线、物理介质五层。每一层都可能对数据做手脚,把“假成功”包装成“真成功”,或者把“根本没写入”包装成“读回全零”。
另一个关键点是:测试脚本往往运行了很长时间,已经固化了“脚本 PASS 等于设备正常”的思维。一旦出现脚本 PASS 但 OS 读全零,很多人第一反应是“OS 命令用错了”“工具不对”,而不是怀疑脚本本身。但实际上,这种矛盾恰恰是排查方向最好的向导。
1.3 为什么这个坑值得单独拿出来写
在自动化测试、老化测试、产线验证里,脚本 PASS/FAIL 是直接决定一台设备能不能出货、一个固件版本能不能发布的依据。虚假的 FAIL 会导致误杀,虚假的 PASS 更可怕,它会让一批有问题的设备流向下一环节,等到终端用户手里才暴雷。而“脚本 PASS、OS 读全零”这类问题极具迷惑性,因为脚本自身的校验逻辑往往“看起来没问题”,很多人卡在刷新脚本、加日志、换十六进制查看工具这些表面操作上,折腾一两天定位不到根因。
我把这套排查思路整理成文,是希望遇到同样现象的人能直接按层次排查,而不是在工具层面瞎试。
2. 别急着查硬件,先搞懂脚本的 PASS 和 OS 的读全零各自意味着什么
2.1 脚本凭什么说 PASS:API 返回值不等于物理结果
先看一段我在代码评审里见过无数次的“毒瘤写法”:
f = open("/dev/xxxx0", "rb+") f.write(pattern) time.sleep(0.1) f.seek(0) buf = f.read(64) if buf == pattern: print("PASS")这段代码里,f.write() 确实返回了 64,f.read() 也确实读回了和 pattern 一模一样的数据。但问题在于:写入的数据可能只进了 OS 的 page cache,read() 读回来的数据可能同样来自 page cache。用户态进程根本没有和物理 Flash 打过照面。
更普遍的情况是驱动层面的 write cache。很多存储类芯片,比如 SPI NOR Flash,内部自带一页一页的缓存区,写命令写入后数据先待在芯片内部页缓存里,需要额外的“program confirm”或者等待 BUSY 位拉低才真正落进存储阵列。有些驱动为了性能,不会每次都等 BUSY,而是把“命令已发出”当作“写入成功”返回给用户态。脚本一看返回值是成功,再一读,芯片缓存里的数据还在,当然能对上。等到驱动释放缓存、芯片断电重新上电,缓存清了,真正写进存储阵列的可能是零。
所以,脚本说 PASS,本质只是“它调用的那一层接口没有反驳它”。没有 fsync、没有强制落盘、没有再重新打开设备节点真实读回,这个 PASS 的含金量约等于零。
2.2 OS 读全零到底说明了什么
OS 层读设备节点,同样不是读物理介质的保证。它读到的数据可能来自三个地方:物理介质、设备驱动的内部缓冲、OS 的文件缓存或块设备缓存。
如果脚本写入时把数据缓存到 OS 的 page cache 里,OS 层用 dd 去读,读到的可能是同一个缓存,结果反而不全零。我们这次读出来是全零,说明数据大概率没有进入 page cache,或者已经 cache 被丢弃,实际落到了物理介质上的“空区域”。
OS 读全零至少能说明两件事:第一,读取路径本身是通的,否则会报错而不是返回数据;第二,物理介质上那个地址的内容,在 OS 这个视角下确实是“未写入”的状态,也就是全零。至于为什么脚本视角是“已写入且一致”,那是需要继续追的。
另外还有一种情况,OS 读的不是脚本写的那个对象。设备节点有多个、设备节点存在分区映射、偏移量基准不同(逻辑地址 vs 物理地址,扇区号 vs 字节偏移),都会导致两边各读各的,结果各说各话。
2.3 信任链断裂的三个典型位置
从脚本到物理芯片,信任链大致是:用户态脚本调用库函数,库函数调用系统调用,内核驱动处理系统调用,驱动通过总线控制器访问芯片,芯片内部把数据写入存储单元。链条上任何一个节点都可能“撒谎”:
- 用户态层:脚本吞掉了异常、忽略了返回值、没有同步落盘。
- 内核驱动层:驱动对错误不上报、使用影子缓存、IOCTL 参数被静默截断。
- 硬件层:芯片写保护引脚被拉低、引脚虚焊、总线时序不满足、片选不稳、芯片本身损坏。
用一个生活类比来解释:快递显示“已签收”,只能说系统记录签收了,不代表包裹真的在你手里。可能是驿站代签,可能是快递员虚拟签收,可能是家人代收,也可能是包裹被放错柜子。脚本的 PASS 就是那个“已签收”状态,OS 读全零就是“你翻遍门口都没看到包裹”。到底是谁在撒谎,得一层层查。
3. 分步定位:从脚本一路查到物理引脚
3.1 第一步:复现现场,并做最简单的交叉验证
任何排障都从稳定复现开始。我先把脚本重新跑一轮,结束后立刻用 OS 层 dd 读取同一地址,确认全零现象能稳定复现。然后做第二组实验:用 OS 层命令直接写入同样的 pattern,再用 OS 层命令读回。
# OS 层写入 64 字节 pattern printf '\xA5\x5A\xAA\x55' | dd of=/dev/xxxx0 bs=1 count=64 seek=0 conv=notrunc 2>/dev/null # OS 层读回 dd if=/dev/xxxx0 of=/tmp/read2.bin bs=1 count=64 skip=0 2>/dev/null hexdump -C /tmp/read2.bin如果第二组实验成功,说明设备节点整体是能用的,OS 读到的零可能和“脚本和 OS 操作对象不一致”有关。如果第二组实验也读零,说明问题大概率真在驱动或硬件层,OS 层根本没写进去。
这里我建议做一个四组交叉矩阵:脚本写、脚本读;脚本写、OS 读;OS 写、OS 读;OS 写、脚本读。每一组的通过与否,能快速区分是“写入端有问题”还是“读取端有问题”,还是“两者访问的地址空间根本不重叠”。四组数据记下来,比盲目改代码高效得多。
3.2 第二步:用 strace 扒掉脚本的底裤
脚本是 Python 写的,但最终所有 IO 都走系统调用。用 strace 跟踪一遍,脚本干了什么一目了然。
strace -f -e trace=openat,write,read,ioctl,lseek,fsync -o /tmp/trace.log ./run_test.py打开 trace.log,重点看几件事:
- openat 打开的设备路径是什么,返回的 fd 是多少。
- write 调用是否真的往该 fd 写入了数据,返回值是否等于请求字节数。
- write 之后有没有调用 fsync 或 fdatasync。
- read 调用是否重新打开过设备节点,还是沿用同一个 fd,是否还先做了 seek。
我那次看到的关键一行是这样:
openat(AT_FDCWD, "/dev/xxxx0", O_RDWR|O_CREAT|O_TRUNC, 0666) = 3 write(3, "\xa5\x5a\xaa\x55...", 64) = 64 lseek(3, 0, SEEK_SET) = 0 read(3, "\xa5\x5a\xaa\x55...", 64) = 64问题已经很明显:脚本打开设备节点时带了 O_TRUNC,直接把这个设备节点的“文件长度”截断成 0 了。对普通文件没问题,对设备节点来说,这个 flag 可能导致驱动进入一种特殊状态,写入的数据被当作“新文件”内容处理,read 时读回的是缓冲区里刚写的东西,但底层介质未必动了。去掉 O_TRUNC,或者该用 O_SYNC,行为立刻不一样。
strace 还能看到错误被吞的痕迹:如果 read 返回 -1 EACCES,而脚本却打印了 PASS,那只有一种可能,代码里用 try-except 把异常吃了,或者用了非阻塞模式没有检查返回值。这种开关型问题在脚本层最常见。
3.3 第三步:绕过缓存,让 OS 读出真实硬件状态
strace 已经提示脚本在系统调用层有问题,但还不够直接。为了搞清“到底数据有没有在物理介质上”,我需要把 OS 层缓存全部绕开,直接访问设备。
对于块设备,dd 可以加 iflag=direct 或 oflag=direct,绕过 page cache:
dd if=/dev/xxxx0 bs=1 count=64 skip=0 iflag=direct status=none | hexdump -C注意如果设备驱动是字符设备,O_DIRECT 不一定支持,报错时不能强上。对字符设备,我更常用的办法是写一个几十行的小 C 程序,open 时用 O_RDONLY | O_SYNC,read 前重新打开设备,不做 seek 缓存,直接从物理偏移读取。甚至可以在 Linux 上直接开 /dev/mem,结合 /proc/iomem 里查到的物理基址去读原始内存映射区间,绕过驱动层。
只有把“驱动/缓存”这两个中间人踢开,读回来的数据才更接近物理真相。如果绕过缓存后依然全零,那基本可以判定,物理介质层面确实没数据,或者数据在介质上本来就是零。
3.4 第四步:从软件到示波器,看物理层的真相
软件层的证据已经足够,但我这个人习惯一条道走到底。把设备断电,取下来,用逻辑分析仪或者示波器挂到 SPI Flash 的 CS、CLK、MOSI、MISO、WP 引脚上,然后用脚本重新跑一轮写入,同时抓取总线波形。
那次抓出来的现象很有意思:CS 有拉到低电平,CLK 有时钟,MOSI 上也有数据,看起来像是正常的 SPI 传输。但是 WP 引脚一直是低电平。Flash 的写保护引脚被拉低,芯片会在命令解码阶段直接拒绝写操作,SPI 主机却完全不知道自己被拒了,写入命令发出去后没有得到任何错误反馈。驱动和脚本都认为写操作成功,实际上芯片根本没执行。
还有一个更极端的坑是引脚虚焊。有一次 MISO 引脚接触不良,读回的数据线永远是高阻态,上拉电阻把数据拉成了固定电平,脚本读回的数据自然也能通过“比对”测试——因为比对的是“错误但稳定”的数据。这种问题如果只靠软件刷脚本永远发现不了,最后是拿万用表量引脚对地波形,发现 MISO 浮动异常,补焊后恢复。
4. 我最后揪出来的几个真凶,还有一份速查表
4.1 真凶一:脚本写进了驱动缓存,没有真正落盘
这是最常见的一类。前面 strace 已经看到脚本没有调用 fsync,也没有重新打开设备节点读回。数据停留在驱动内部缓冲,脚本自读自比自然全对上,OS 层一旦重新初始化驱动、断电重启,缓存清空,物理介质的真实状态暴露出来。
不一定只在 Flash 上出现,很多带 FIFO、带页缓存的模组设备都有这个问题。典型特征是:脚本运行期间自读自比永远 PASS;断电重启后读全零;OS 层在脚本未退出时去读,可能读到的是缓存数据,也可能是缓存被丢弃后的零。这个真凶的破绽就是“重启后失效”,所以排查时一定要做一次下电/上电验证。
4.2 真凶二:地址错位,写 A 读 B
这个更隐蔽。脚本用 mmap 把设备物理地址映射到用户空间,逻辑偏移是 0x10000;OS 层用 dd 从设备节点读取时,skip=0x10000,以为读的是同一位置。但设备节点的偏移基准和 mmap 的物理偏移基准很可能不一样,或者设备节点上做了分区逻辑,0x10000 在脚本视角和 OS 视角根本是两个扇区。
另一个常见版本是扇区号和字节地址混淆:脚本用 block number 100 写,OS 用 byte offset 100 读。硬件上写读的都不是同一个地方,结果自然是“各自都对,合起来错”。验证方法很简单,用全片回读的方式,不指定偏移,把整个介质读出来,看写进去的数据到底落在哪个位置。
4.3 真凶三:把掉电易失介质当成了非易失存储
老化测试里有人拿 DDR、SRAM 甚至 FPGA 内部寄存器当“存储区”做掉电保存测试。脚本写 DDR 后读回,DDR 还能保持,PASS 没问题。一旦断电重启,DDR 数据自然全零。OS 读零完全正确,脚本 PASS 也完全正确,唯一的错误是测试逻辑一开始就不该用 DDR 模拟 Flash 做持久性校验。
这类问题的特点是:只要设备不掉电,怎么读都是正常的;掉电后必丢。排查时在脚本里加一个“断电重启后第二阶段校验”的用例,立刻现原形。另外,很多芯片内部 RAM 区域在软件复位后数据仍在,但硬件复位后清空,这一点也要在测试方案里写清楚。
4.4 真凶四:硬件写保护或虚焊,总线层面就丢了命令
像前面抓到的 WP 引脚被拉低,以及 MISO 虚焊,都是物理层问题。软件层无论怎么写都不可能看到“芯片拒绝执行”这个事实,因为 SPI 协议里写命令本身没有应答阶段,主机发完就认为自己完成了。Flash 内部状态机的错误状态寄存器里可能有位被置位,但驱动如果不读状态寄存器就不会知道。
遇到这种情况,必须先查电路原理图:WP 引脚有没有上拉到 VCC?HOLD 引脚有没有固定?片选有没有被其他设备共用?读回波形是否完整?这一步非常依赖硬件工具,但软件侧也有一个技巧:在脚本里加一步“擦除后读全 FF”的验证,如果有任何字节不是 0xFF,说明总线或芯片已经不干净了。
4.5 常见原因速查表
| 现象特征 | 怀疑方向 | 确认方法 |
|---|---|---|
| 脚本运行中自读自比 PASS,重启后读零 | 驱动写缓存未落盘 / 未 fsync | strace 看 write 后是否 fsync;断电重启后复读 |
| 脚本和 OS 各自单独测都正常,交叉测失败 | 地址基准不一致 / 操作对象不一致 | 全片回读确认数据实际落点;四组交叉实验 |
| 设备不掉电永远 PASS,掉电必丢 | 介质选型错误(DDR/SRAM 当存储) | 检查待测设备手册;做掉电对比实验 |
| 多个脚本操作都能“写成功”,但始终读回固定零或固定 FF | 写保护引脚电平异常 / 虚焊 | 万用表测量 WP、HOLD;逻辑分析仪抓总线波形 |
| OS 层读写时报错(如 os error 5)但脚本仍 PASS | 脚本吞掉权限异常 | 删掉 try-except;检查 errno 处理 |
| 写入返回值正常但读到的是旧数据 | 驱动回读缓存 / 数据总线时序错误 | 重新 open 设备文件;绕过缓存直读 |
5. 怎么改造测试脚本,让它不再撒谎
5.1 三个硬性要求:写后真读、同步落盘、错误零容忍
现在只要是我经手的测试脚本,都必须满足三条铁律。
第一,写操作后必须真实读回物理数据,而不是读进程内缓存。实现方法是写完后调用 fsync,再重新打开设备节点用新 fd 读,确保这条读写链路经过完整的内核驱动路径。
def verify_flash_write(dev_path, offset, pattern, length=64): import os import struct fd = os.open(dev_path, os.O_RDWR | os.O_SYNC) try: ret = os.pwrite(fd, pattern, offset) if ret != len(pattern): raise RuntimeError("short write: %d/%d" % (ret, len(pattern))) os.fsync(fd) os.close(fd) # 关键:重新打开设备,用全新的 fd 读回 fd = os.open(dev_path, os.O_RDONLY | os.O_SYNC) read_back = os.pread(fd, length, offset) if read_back != pattern: raise ValueError("verify mismatch at offset 0x%x: %r" % (offset, read_back)) finally: os.close(fd)注意 O_SYNC 对字符设备用途有限,很多字符设备驱动不支持这个语义。所以更可靠的兜底是“重新 open 设备节点 + 读回”,这一步能绕过大部分用户态和内核态的浅层缓存。
第二,任何 IO 调用都必须检查返回值,禁止 try-except 吞异常后继续打 PASS。至少要做到:write/read 的返回值不等于请求长度时,直接 FAIL;open 失败时记录 errno 并退出。连“报错后 PASS”这种代码都不要写,应为 test 脚本里 FAIL 是有效结果,虚假 PASS 是无效结果。
第三,验证数据不要用全零或全一,用伪随机 pattern 或者带地址特征的 pattern。比如让 pattern 和偏移量产生关联,读回时检查是否和该偏移的期望一致。这样能顺带发现地址线短路、总线位翻转问题,比固定 0xAA 0x55 更灵敏。
5.2 老化测试脚本的三道额外防线
老化测试运行时间长、轮次多,光靠单次读写校验不够,我还会加三道防线。
第一道防线是“两阶段校验”:第一阶段写完数据后先自检一次;第二阶段由测试框架在设备断电重启后,重启 OS,重新挂载设备,再由独立的 OS 层读取脚本去校验。只有第二阶段通过才算真正 PASS。这个设计直接干掉前面说的“缓存没落盘”和“掉电易失”两大类问题。
第二道防线是“交叉验证”:同一个地址,至少用两种不同的接口去读。比如驱动 read 一次、dd 一次、devmem 读物理内存一次。三个读数如果一致,再确认是真的;不一致,把这个不一致记录成 FAIL 并带上三个读数输出。很多驱动问题就是在这样的交叉验证中现形的。
第三道防线是“脚本自身健康检查”:每跑一定轮次,检查测试脚本自己所在文件系统的剩余空间、日志写入是否正常、dev 节点是否还在。老化测试跑十几个小时,中途如果设备节点被重命名或者驱动崩了,脚本可能一直在写缓存,打一整晚的假 PASS。健康检查能在第一时间打断这种无效运行。
5.3 我平时用的验证工具箱
每次遇到“脚本说成功但读到的东西不对”这种问题,我通常会开一个工具清单,按顺序用:
- strace:看系统调用层有没有吞错误、有没有 fsync、有没有重复打开设备。
- hexdump + dd:用不同方式读回数据,加 iflag=direct 绕缓存。
- flashrom:针对 SPI NOR Flash,直接整片读、整片擦、写回,不依赖厂商私有驱动。
- 自写 C 小工具:用 open/read/pwrite 直接操作设备节点,减少 Python 解释器行为引入变量。
- /proc/iomem + devmem2:对可映射设备直接读物理地址。
- 万用表和逻辑分析仪:到物理层之后必备,测 WP、HOLD、CS 电平,抓 SPI 时序。
- dmesg:每次操作后都看一眼内核日志,驱动是否悄悄报了 timeout / CRC error。
这套工具链从用户态一路贯穿到芯片引脚,基本上只要肯花时间,就没有查不出来的“撒谎者”。实际排障时,很多人只停留在前两步,所以问题反复出现。我的经验是:只要现象里存在“软件说成功、底层说你没做到”这种割裂,就不要在脚本里继续试来试去,趁早把示波器和万用表拿出来,硬件层一句话能说清的事,软件层加再多的日志也说不清。
我自己现在写测试脚本有个习惯,每次打 PASS 之前都会在心里问一句:这个 PASS 是从哪一层回来的?如果回答是“从驱动接口返回的”,我只能当它是一个待确认信号;只有当我用独立于被测逻辑的另一条路径,重新读回相同数据并且通过校验,我才敢在报告上写 PASS。虚假的 PASS 比十个 FAIL 更可怕,FAIL 至少说明设备有问题值得排查,PASS 只会让问题设备流向更远的地方。这几篇经验写出来,就是希望大家少交点这个坑的学费。