先摆一个让我印象深刻的现场。上周帮一个做老化测试的朋友排查疑难自动化脚本连跑了几千轮每轮都往某个 eMMC 分区写 64KB 随机数据然后读回比对日志干净得发亮一行一个 PASS。测试收了工程师另起一台机器直接对板子的存储区做原始读结果那片区域从头到尾全是 0x00。测试脚本说全过底层的操作系统却读不出任何有效数据。第一反应板子坏了第二反应脚本假报第三反应才坐下来正经查问题。这种 脚本说 PASS、OS 读全零 的矛盾在嵌入式测试、固件升级、密钥烧录和老化验证里并不少见坑就藏在测试路径和观测路径的差异里。这篇文章我把排查思路完整拆开从 Linux 存储栈一路讲到介质层再给出能直接抄作业的五步定位流程。做设备测试、存储验证、自动化脚本的工程师都能用上读完你至少能少熬两个通宵。1. 一个让存储测试工程师脊背发凉的现场1.1 脚本的 PASS 是怎么产生的先看一个典型到不能再典型的脚本逻辑伪代码长这样import os, random dev /dev/mtd3 block random.randbytes(65536) with open(dev, rb, buffering0) as f: f.seek(0) f.write(block) # 写 64KB f.seek(0) rback f.read(65536) # 立即读回 if rback block: print(PASS) else: print(FAIL)看着没毛病吧写完后读回逐字节比对全部一致就 PASS。很多测试脚本写到这里就觉得万事大吉日志流程、退出码、看板全齐了几千轮跑完全是绿。但这里有个致命问题这个 PASS 只能证明同一个文件描述符、连续两次系统调用之间缓存层面的一致性。它压根不能证明数据真的进了介质。为什么因为 Linux 的读写路径从来不是应用直接碰硬件。应用写一个字节先经过 VFS 层再落进 page cache 就返回成功了。紧接着的 read 调用内核一看 page cache 里有新鲜数据直接就给你了根本没有触发针对 Flash 或 eMMC 的物理访问。这一来一回全是内核缓存自己在跟自己玩。1.2 OS 读全零 读的到底是什么另一边工程师用 dd 或 nanddump 读的时候通常是这么操作的dd if/dev/mmcblk0p6 of/tmp/dump.bin bs1024 count64 xxd /tmp/dump.bin | head或者 NAND 平台的另一种常见姿势nanddump -p /dev/mtd3这类命令直接走块设备层或者 MTD 层不从文件系统路径绕读的是裸设备区域。dd 读回全零说明这个读取通道本身是通的设备能响应,能返回数据只是数据全是 0x00。这里有个非常容易被忽略的细节全零本身也是有信息的。NOR Flash 擦除后常读 0xFFNAND Flash 擦除后理论上也读 0xFFeMMC 出厂白片可能读 0x00也可能是 0x00 刷出来的痕迹。如果你读回来是干净的 0x00而不是 0xFF那大概率不是因为没写过这么简单——这块区域很可能真被什么东西写过 0x00或者是错误处理代码把缓冲区给清零了。这一点对后续定位很有引导意义。1.3 第一层真相两条路径从一开始就不相交脚本说 PASS 和系统读全零两边都可能在讲真话。这就像你在手机银行 App 里看到转账成功但银行业务系统的流水里根本查不到这笔钱。App 验证的是请求提交成功银行验证的是账务实际变更两边根本不是同一个账本。脚本测试的路径是应用层 - VFS - page cache - 块设备层 - 驱动 - 介质。观测的路径是独立工具 - 块设备层 - 驱动 - 介质。虽然看着都通向介质中间任何一个环节出现偏移、缓存或静默失败PASS 和全零就可以同时为真。搞清楚这个前提后面的排查才不慌。2. 从 Linux 存储栈层层拆解谁在撒谎2.1 page cache那个最经典的谎言制造者先把这个最基础也最容易被忽略的机制讲透。Linux 下普通 read/write 的完整路径是应用 - syscall - VFS - page cache - 块设备层 - 驱动 - 控制器 - 介质write() 系统调用成功只代表数据从用户态拷贝到了内核的 page cache 这一层后面什么时候回写到介质由内核的脏页回写机制决定。你可以调整 /proc/sys/vm/dirty_writeback_centisecs默认 500 厘秒也就是 5 秒一次还有 dirty_expire_centisecs默认 3000 厘秒意思是脏页在缓存里待满 30 秒才会被优先回写。老练的测试脚本和野路子的测试脚本差异往往就体现在这里。前者写完数据后会调用 fsync 或 fdatasync 强制落盘或者干脆用 O_DIRECT 绕过 page cache。后者写完立刻读回比对秒级 PASS其实比对的是缓存自己跑一万轮都是自嗨。更别提还有一种情况是脚本用 mmap 映射文件区域去写写完后直接读映射内存来做比对。这种路径已经不经过存储栈了验证的是 CPU 内存视角的数据一致性。它跟 OS 去读介质完全是两套世界。所以第一层判断标准是脚本是否有强制落盘动作是否有绕过缓存的机制。如果没有那 PASS 的可信度要打五折。2.2 分区表与偏移你写的可能根本不是它读的第二个高频坑是对象错位。看起来很蠢但在现场出现频率远超想象。同一个板子上脚本写 /dev/mtd3OS 读 /dev/mtd4两个设备号风马牛不相及能读出一致的才有鬼。驱动加载顺序变化、内核设备编号漂移、多个 mtd 分区轮流注册都可能导致上次是 mtd2这次是 mtd3。还有一种更隐蔽的错位脚本写入的是文件系统里的一个路径比如 /userdata/test.bin而 OS 读的是底层块设备 /dev/mmcblk0p6 的起始扇区。文件系统本身有元数据、对齐策略、可能的日志或压缩你写在文件逻辑位置的东西在物理介质上根本不可能出现在分区起点。再往后还有 GPT/MBR 的偏移问题。假设 /dev/mmcblk0p6 在分区表里起始扇区是 2048扇区大小 512 字节那这个分区的物理偏移是 2048*512 1MB。如果你脚本写的是 p6 的逻辑起点 0那没问题因为内核拿到分区节点会自动映射但如果 OS 观测时直接读 /dev/mmcblk0 的物理偏移 0两者整整差 1MB读出来当然是零。排查这类问题的标准动作是先对齐元信息cat /proc/partitions cat /proc/mtd fdisk -l /dev/mmcblk0 ls -l /dev/mtd*把脚本里写入的设备节点、偏移、长度、pattern和 OS 读取的设备节点做逐一对照。这一步做完了能直接排除掉一半的灵异事件。2.3 静默写失败驱动悄悄吞掉的错误比缓存和偏移更隐蔽的是静默写失败。应用层 write() 返回成功驱动层实际已经超时失败但错误没有被正确上报数据就没写进去。这类问题在 eMMC 和 UFS 上都有出现属于底层协议处理或固件 bug 的范畴。eMMC 写一个块要走 CMD24 WRITE_BLOCK设备会给 busy 信号然后 host 等待传输完成。如果设备内部因为介质磨损、温度、电压波动导致重试耗尽正常应该返回错误。可有些驱动实现里对命令超时的处理是把缓冲区标记完成返回成功因为 DMA 已经把数据从内存搬到了控制器——控制器有没有搬进介质它不管了。数据从内存角度消失了从介质角度却从未存在。这跟现实里的快递显示签收其实根本没送到是一模一样的逻辑。还有一种只出现在特定平台的场景UFS 的 WriteBooster 缓冲。数据先写进一个高速缓冲固件后续才会搬到主存储。如果你在还在缓冲里的时间窗内断电数据就没了。从软件层看 write 是成功返回的但从持久化角度它根本没完成。验证这类问题的动作有三个查 dmesg 里有没有 mmc 或 ufs 相关的 error/timeout 记录查 /sys/block/mmcblk0/device/ 下的错误计数器用 eMMC 的 extcsd 命令读设备健康状态。后面实操部分我会具体展开。2.4 介质层透明加工ECC、压缩与加解密最后一层藏在物理介质本身的加工逻辑里这一层最容易让工程师怀疑人生。先以 NAND 为例。NAND 的页结构除了数据区还有后备区OOB里面放着 ECC 校验字节、坏块标记等。如果你用 nanddump 不带参数去读看到的是 main data 区如果文件系统启用压缩比如 UBIFS 的压缩属性那 main data 区里存的是压缩后的数据不是你写的明文。这时候你用 nanddump 读到一堆看起来不对的内容不代表写入失败只是看到的是另一层视图。eMMC 和 UFS 内部还有 FTL闪存转换层负责把逻辑扇区地址翻译成物理块地址。理论上这块对用户透明但如果固件有 bugLBA 映射表在异常掉电时可能丢失重启后某些逻辑地址对应的物理块根本不存在有效数据读出来就是全零。更硬核的是带硬件加密引擎的 SoC。有些平台启用了 Inline Crypto EngineCPU 写明文硬件加密后再落盘。如果你绕过存储栈用一个特殊通道去读物理介质读到的可能是密文。密文里碰巧出现全零字节并不奇怪特别是有固定模式填充的加密数据。这一层给我们的启示是两种读法看到的都不是物理真相的完整集合。脚本看到的是应用层明文路径OS 裸读看到的是介质层翻译结果两者之间隔着一层或多层设备内部加工。这不能叫任何一方撒谎只能说是各自的观测范围不同。3. 排障实操五步锁定问题源头3.1 第一步对齐测试对象与观测对象不管现场多乱先做对象对齐这步能过滤掉一半问题。你要整理的清单包括脚本实际打开的设备节点major:minor、写入的起始偏移、长度、pattern 种子、是否做 fsync、是否用了 O_DIRECT。同时记下 OS 观测用的设备节点、偏移、读写方式。两边逐项横向对比。具体动作# 在设备上分别记录 cat /proc/partitions cat /proc/mtd ls -l /dev/mtd* /dev/mmcblk*顺手用 stat 拿到设备号stat -c major%t minor%T device%n /dev/mtd3如果脚本写的是 mtd3OS 读的是 mtd4通过这一步当场就能发现。如果脚本写的是 /userdata/test.binOS 读的是 /dev/mmcblk0p6 的起始扇区这一步也会暴露路径不对等的问题。3.2 第二步绕过缓存强制读物理介质对齐之后如果设备和偏移都对得上那就该怀疑缓存了。这步要做的是绕过 page cache让读动作真正落到介质上。Linux 下最直接的方式是 O_DIRECTdd if/dev/mmcblk0p6 of/tmp/verify.bin iflagdirect bs1024 count64注意 dd 的 iflagdirect 在不同版本和文件系统上支持程度不一样不好使就换别的方法。第二种方式是把 page cache 里的脏数据先落盘再清掉缓存sync echo 2 /proc/sys/vm/drop_caches丢缓存这个操作在测试设备上问题不大但生产环境千万别手贱它会让全系统缓存失效带来不可控的 IO 抖动。第三种方式更彻底umount 文件系统再从裸设备读。比如把存放测试数据的文件系统卸载掉直接对底层分区做 dd。这能保证不存在文件系统层的缓存和映射干扰。如果绕过缓存之后读回来的数据和脚本里写的 pattern 完全一致那真相就是介质没毛病脚本和 OS 的矛盾来自缓存层。这种情况最好解决改脚本加落盘动作就行。3.3 第三步用断电重启验证介质真实性在线缓存绕过了但还差最后一道硬核验证断电重启。为什么必须是断电重启不是 reboot嵌入式 Linux 的 reboot 是软复位路径内核可能不重新初始化设备page cache 也可能留着不动甚至某些 DMA 缓冲区还会残留旧数据。真正要验证数据是不是躺在介质上必须物理断电让所有易失状态全部清零再上电去看。标准流程长这样# 设备端先落盘 sync sync然后断电等待 5~10 秒让电容彻底放电再上电。系统起来后立刻用 dd 或 nanddump 读目标区域和写入的 pattern 比对。如果断电重启后数据完整那这个 PASS 和全零的矛盾基本可以定位到缓存层。如果断电重启后依然是全零那问题就严重了指向静默写失败、FTL 映射丢失或介质本身的问题。这一步才是存储类测试真正的一锤定音。3.4 第四步查底层状态字、坏块和错误计数断电重启后依然全零就得往底层翻了。先看内核日志这是第一手证据dmesg | grep -i -E mmc|nand|ufs|ecc|timeout|error重点找有没有 end request、I/O error、CMD24、CRC failure 之类的记录。有时候写入失败的现场已经在日志里只是测试脚本没去翻。NAND 平台可以进一步查坏块和 OOB 信息cat /sys/class/mtd/mtdX/bad_blocks nanddump -n -f /tmp/oob_dump.bin /dev/mtdX-n 参数代表保留 OOB 区域对比 OOB 里的坏块标记能看出该区域是不是被标记为坏块。如果目标块在坏块表里而驱动写的时候没有把数据重映射到备份块读出来就会是空的或者全零。eMMC 平台用 mmc-utils 查设备内部健康状态mmc extcsd read /dev/mmcblk0重点关注 device life time、eMMC 命令错误计数等字段。有些驱动把这些信息暴露在 sysfs 里可以顺手看 /sys/class/mmc_host/mmc0/mmc0:*/ 目录下的几个计数器。3.5 第五步改造脚本加介质级硬校验定位到原因之后最关键的还是把测试脚本改到从设计上就不会再犯。我的建议是三层校验第一层应用层校验写完必须 fsync 或 fdatasync。别只调用一次 sync现场经验是 sync 两次更稳妥有些物联网设备的内核版本对单次 sync 的语义处理不完整。第二层缓存层绕行读回校验时用 O_DIRECT 打开设备或者读之前先 drop_caches。确保不命中 page cache。第三层介质层验证周期性做断电重启回读。不用每轮都这样那样测试时间受不了但每 100 轮抽取 1 轮做断电验证就足以暴露大多数静默写失败问题。改造后的脚本验证部分大概长这样import os, mmap buf bytearray(65536) # 用 O_DIRECT 打开注意用户态缓冲区要对齐 fd os.open(/dev/mmcblk0p6, os.O_RDONLY | os.O_DIRECT) try: n os.readv(fd, [buf]) # 按实际设备能力读取 finally: os.close(fd)注意 O_DIRECT 要求缓冲区在内存页边界对齐实践中不要直接拿 Python 普通 bytearray 硬怼用 mmap 或 posix_memalign 对齐后效率更稳。这一点不少新手踩过坑。另外一个容易忽略的细节脚本日志里必须打印设备节点的 major:minor、起始偏移、读写长度、fsync 与否。将来做问题回溯时这些元信息比那一堆 PASS 日志值钱得多。4. 常见问题速查表与避坑经验4.1 速查表现象、原因、对策现象最可能原因第一步做什么脚本 PASSOS 读全零观测对象和写入对象不是同一个设备/分区比对 major:minor 和分区起始偏移脚本 PASSOS 读全零且是多个设备复现写缓存未落盘加 fsync 后断电重启回读脚本 PASS重启后全零静默写失败或 FTL 映射问题查 dmesg 和 extcsd 健康状态脚本 PASS裸读读到 0xFF区域被擦除过查擦除动作、坏块标记脚本 PASS裸读读到非明文文件系统压缩/加密或 ECC 数据从文件系统层验证不要只读 raw偶发读回全零ECC 不可纠正或介质弱块查 OOB 坏块标记和设备错误计数这张表我建议直接截图存到团队知识库基本能覆盖九成以上同类问题。4.2 避坑经验测试设计要能发现静默失败最后分享几条实打实的经验都是交过学费换来的。第一不要把 reboot 当断电用。软复位和物理断电是两个世界内存不断电、控制器不复位数据还在某种热状态里。要验证持久化必须物理断电。这条没商量。第二sync 不代表介质层已经完成写操作。sync 给内核的承诺是把脏页交到块设备层但设备固件内部还在做什么内核管不着。eMMC 内部突然掉电可能丢 FTL 映射UFS 的 WriteBooster 缓冲可能来不及搬运到主存储。真正能验证持久化的只有断电后回读。第三O_DIRECT 不是拿过来就能用。用户态缓冲区需要按块大小对齐否则 open 返回 EINVAL。我看到过不少同事在这上面耗了一下午。稳妥做法是用 mmap 分配页对齐缓冲区或者用 C 语言里头 posix_memalign 的思路在 Python 里可以用 ctypes 手工对齐。第四PASS 的意义被高估了。一个测试如果从设计上就只验证了协议层自洽那它的 PASS 只是一个软件栈认为写入完成的信号。只有脚本 PASS 加上断电重启回读一致才叫真正的测试通过。写在最后做了这么些年存储相关的测试我学到的最深一课就是永远不要把一次热读回验证当作数据落盘的证明。在自动化测试脚本里加上断电重启后数据必须完整这一条能挡掉至少八成测试骗过所有人眼睛的尴尬情况。这次的现场最终定位到的是驱动层静默吞错误数据没真正进介质脚本却忠实上报了一路绿灯。整个排查下来问题不是出在脚本撒不撒谎而是出在测试设计压根没给谎言留出暴露的机会。希望大家下次看到脚本说 PASS、OS 读全零这种诡异现场时先深呼吸然后按这套流程一步一步走大概率不用熬到天亮就能收工。
阅读完成 · 觉得有帮助?