
测试设备在老化台上跑了一个通宵早上过去翻日志整屏都是绿的。每个 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 字节的测试 pattern0xA5 0x5A 0xAA 0x55 交替然后从同一地址读回比对数据一致就输出 PASS。日志大概长这样[2025-06-01 03:12:41] round 2047 write_ok64 read_ok64 statusPASS看着非常正常每一轮都写进去了、读回来了、比对一致。然后我用 OS 层命令直接读同一地址dd if/dev/xxxx0 of/tmp/read.bin bs1 count64 skip0 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难道硬件会欺骗软件还是说脚本读的根本不是 Flash1.2 矛盾的核心脚本和 OS谁才是可信的那个从证据上看有两个互相矛盾的事实脚本自读自比全部通过OS 层直读全部为零。必须承认两者可能都没撒谎只是它们看到的“世界”不是同一个世界。脚本说 PASS只能说明“脚本调用某个 API 后API 按它自己的逻辑返回了成功”。OS 读全零只能说明“从某个文件描述符、某个偏移量读回来的数据是全零”。这两件事之间隔了用户态、内核态、驱动、总线、物理介质五层。每一层都可能对数据做手脚把“假成功”包装成“真成功”或者把“根本没写入”包装成“读回全零”。另一个关键点是测试脚本往往运行了很长时间已经固化了“脚本 PASS 等于设备正常”的思维。一旦出现脚本 PASS 但 OS 读全零很多人第一反应是“OS 命令用错了”“工具不对”而不是怀疑脚本本身。但实际上这种矛盾恰恰是排查方向最好的向导。1.3 为什么这个坑值得单独拿出来写在自动化测试、老化测试、产线验证里脚本 PASS/FAIL 是直接决定一台设备能不能出货、一个固件版本能不能发布的依据。虚假的 FAIL 会导致误杀虚假的 PASS 更可怕它会让一批有问题的设备流向下一环节等到终端用户手里才暴雷。而“脚本 PASS、OS 读全零”这类问题极具迷惑性因为脚本自身的校验逻辑往往“看起来没问题”很多人卡在刷新脚本、加日志、换十六进制查看工具这些表面操作上折腾一两天定位不到根因。我把这套排查思路整理成文是希望遇到同样现象的人能直接按层次排查而不是在工具层面瞎试。2. 别急着查硬件先搞懂脚本的 PASS 和 OS 的读全零各自意味着什么2.1 脚本凭什么说 PASSAPI 返回值不等于物理结果先看一段我在代码评审里见过无数次的“毒瘤写法”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() 确实返回了 64f.read() 也确实读回了和 pattern 一模一样的数据。但问题在于写入的数据可能只进了 OS 的 page cacheread() 读回来的数据可能同样来自 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 bs1 count64 seek0 convnotrunc 2/dev/null # OS 层读回 dd if/dev/xxxx0 of/tmp/read2.bin bs1 count64 skip0 2/dev/null hexdump -C /tmp/read2.bin如果第二组实验成功说明设备节点整体是能用的OS 读到的零可能和“脚本和 OS 操作对象不一致”有关。如果第二组实验也读零说明问题大概率真在驱动或硬件层OS 层根本没写进去。这里我建议做一个四组交叉矩阵脚本写、脚本读脚本写、OS 读OS 写、OS 读OS 写、脚本读。每一组的通过与否能快速区分是“写入端有问题”还是“读取端有问题”还是“两者访问的地址空间根本不重叠”。四组数据记下来比盲目改代码高效得多。3.2 第二步用 strace 扒掉脚本的底裤脚本是 Python 写的但最终所有 IO 都走系统调用。用 strace 跟踪一遍脚本干了什么一目了然。strace -f -e traceopenat,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 可以加 iflagdirect 或 oflagdirect绕过 page cachedd if/dev/xxxx0 bs1 count64 skip0 iflagdirect statusnone | hexdump -C注意如果设备驱动是字符设备O_DIRECT 不一定支持报错时不能强上。对字符设备我更常用的办法是写一个几十行的小 C 程序open 时用 O_RDONLY | O_SYNCread 前重新打开设备不做 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 把设备物理地址映射到用户空间逻辑偏移是 0x10000OS 层用 dd 从设备节点读取时skip0x10000以为读的是同一位置。但设备节点的偏移基准和 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 引脚有没有上拉到 VCCHOLD 引脚有没有固定片选有没有被其他设备共用读回波形是否完整这一步非常依赖硬件工具但软件侧也有一个技巧在脚本里加一步“擦除后读全 FF”的验证如果有任何字节不是 0xFF说明总线或芯片已经不干净了。4.5 常见原因速查表现象特征怀疑方向确认方法脚本运行中自读自比 PASS重启后读零驱动写缓存未落盘 / 未 fsyncstrace 看 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, length64): 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 的返回值不等于请求长度时直接 FAILopen 失败时记录 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用不同方式读回数据加 iflagdirect 绕缓存。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 只会让问题设备流向更远的地方。这几篇经验写出来就是希望大家少交点这个坑的学费。