从45到165个BUG:操作系统开发中的假持久化、USB地狱与自举实践

从45到165个BUG:操作系统开发中的假持久化、USB地狱与自举实践 上篇写完时我在文末列了一个清单还剩 45 个已知 BUG。当时有朋友在评论里问那把这些修完系统是不是就稳定了我没敢直接回答因为我知道下篇才是真正的考验。这篇文章就是来交代这之后发生的事的我踩进了假持久化的坑被 USB 折腾到怀疑人生最后逼着自己做了一次完整的 OS 自举。当然还有那个从 45 涨到 165 的 BUG 数字。很多人看到这数字第一反应是“写崩了”但看完这篇你可能会有完全不同的想法。这篇的内容比较适合正在折腾操作系统、嵌入式驱动、文件系统或者任何“需要和真实硬件打交道”的开发者。你不用真的写过一个 OS但如果有过“明明一切正常断电后全没了”的经历你会很快进入状态。我会把每个问题拆到“为什么会出现”“当时怎么排查”“最后怎么解决”三层尽量让每个坑都能直接被借鉴。1. 从45个到165个为什么BUG越修越多1.1 上篇那45个旧账到底是怎么处理的先交代一下 45 个 BUG 的结局。这些大多来自上篇的几个核心模块内存管理、中断、定时器、PS/2 键盘、简单的串口输出。说实话这些模块基本都属于“能和 CPU 直接对话”的底层代码逻辑相对封闭问题类型也集中。当时我的处理方式很简单每修一个就把相关的复现步骤写成一个自动化测试脚本固化到tests/regression/目录下。比如有个内存分配的对齐问题测试脚本会反复分配和释放大量对象然后检查所有返回地址是否按 16 字节对齐、释放后再次访问是否能触发页错误。修完以后这个脚本必须一直通过防止后续又改回去。所以 45 个旧账的实际去向是这样有 38 个被确认真实修复并配套了回归测试有 4 个是重复报的场景完全一样只是触发方式不同有 3 个最后发现“不是 BUG是设计如此”但在注释和文档里做了特别说明。到这里如果项目就此打住那 45 个 BUG 确实已经被清完。但我没打算打住因为写上篇时就已经规划好了下篇的目标先给系统一个真正的文件系统再接入 USB最后让系统能自己编译自己。这三个目标每一个都是大坑而文件系统那个坑直接贡献了本篇文章标题里的“假持久化”。1.2 新模块上线旧数字是怎么“礼崩乐坏”的从 45 涨到 165不是一夜之间发生的。我复盘了整个过程中的问题来源大概可以分成三类。第一类是基础设施变强之后旧代码的隐患开始显形。上篇阶段内核能跑起来就算赢很多函数的错误处理路径几乎从没被执行过。但下篇开始写文件系统后各种操作开始依赖返回值、错误码、超时机制于是那些以前“永远不会出错”的代码突然就会出错了。典型例子是中断处理里没有清理 pending state正常情况下不会触发但 USB 设备如果响应慢中断风暴一来问题立刻暴露。第二类是新增模块带来的真实缺陷。文件系统、USB 协议栈、自举构建过程这些都是全新的代码行数从上篇的几千行暴涨到两万多行。代码量上去了接触面大了BUG 数量必然上升。这部分占了大头。第三类是更严格的测试方法把隐藏问题翻了出来。我开始做随机压力测试、强制断电测试、反复拔插 USB 设备的测试而这些操作在 45 个 BUG 阶段根本没做过。很多问题不是“新写坏的”而是“早就在那里只是一直没被发现”。所以 165 这个数字如果拆开看真正新增的缺陷占了一半另一半是“因测试能力提升而暴露的历史存量”。在后来的开发中我逐渐形成了一个判断BUG 数量上升在早期项目里往往不是质量变差的信号而是可观测性变强的信号。如果每次测试都只能覆盖最顺的那条路径你自然觉得系统很稳一旦把各种异常路径铺开问题就会被成批地暴露出来。这不是坏事真正可怕的是 BUG 一直藏在某个犄角旮旯然后在关键时候给你致命一击。2. 假持久化你以为写进了磁盘其实根本没有2.1 “磁盘写成功”但断电后一切归零的尴尬现场先说一个我印象极深的下午。当时文件系统已经做到了能创建文件、能读写文件、能列出目录。我在串口终端里敲了一条命令往磁盘上写一个测试文件系统返回[OK] 写入成功共 128 字节。为了确认数据真实落盘我直接杀掉 QEMU 进程等价于拔掉电源重启系统挂载文件系统然后ls查看。文件不在。准确说目录里能看到名字但文件内容是空的又或者是乱码。再试一次把文件写完正常执行关机流程再重启文件还在。改成直接断电源文件就没了。那一刻我心里飘过一万个问号难道是文件系统索引没写还是块分配表没更新我花了大半天去排查文件系统的各个层面最后才意识到问题根本不是文件系统的逻辑而是整个持久化链路的底层就没有真正落盘。这里有一个生活化的类比。你把快递单填好交给快递员快递员说“收下了”但你其实没有看到他装车快递公司也说没有揽收记录。后来发现他只是口头答应包裹还在你手里。我的操作系统就是那个“口头答应的快递员”告诉应用层“数据已经写入磁盘”实际上数据只是暂时躺在内存里断电就蒸发。2.2 假持久化到底发生在哪一层搞清楚问题得先看数据从应用层到磁盘要走哪些环节。在我这个系统里链路大致是这样的应用调用write()写入文件系统的某个文件描述符文件系统把文件逻辑地址翻译成磁盘上的块号块设备驱动通过端口或 DMA 把数据从内存搬到磁盘控制器的缓冲区磁盘控制器再把缓冲区的内容写到物理介质上。看起来每一步都有但实际上每一步都可能埋雷。第一个雷文件系统的“页缓存”。很多操作系统为了性能会把“写请求”先放进内存缓存然后在某个时机再批量写回磁盘。我的实现里也有类似的缓存机制但问题在于我没有实现“写回触发条件”缓存满了或者进程主动flush才会写。测试脚本写完文件就退出数据还在缓存里系统一断电就全部蒸发。第二个雷磁盘控制器的“写缓存”。就算数据已经通过 DMA 发给了磁盘控制器也未必立刻写到介质上。硬盘和 U 盘内部往往还有一层缓存命令执行完成不代表数据已经物理落盘。SPINUP 阶段尤其明显如果磁盘策略是“先回成功后台慢慢再写”断电时就会丢数据。第三个雷命令完成顺序。对于支持 NCQ 的 SATA 硬盘多个命令可能被磁盘控制器重排完成顺序和提交顺序并不保证一致。如果你的文件系统假设“命令 A 完成后命令 B 肯定在 A 之后落盘”那就可能在小概率时序下实现相反的结果。要真正保证持久化必须在合适的位置插入“落盘屏障”最直接的办法是给磁盘发一条类似FLUSH CACHE的命令等它返回后才能向上层报告“本次写入已经持久化”。我最初完全没有这个概念所以所有写操作全都是假成功。2.3 在 QEMU 和真机上怎么验证“真持久化”理论清楚了接下来是怎么复现和验证。如果你也在做类似内核开发这套方法可以直接复用。最容易的验证方式是“杀掉 QEMU 进程”。QEMU 进程被杀等价于目标机掉电不会执行任何优雅关闭逻辑。配合脚本就是一套自动化断电测试启动 QEMU等待系统启动向文件系统写入测试文件直接kill -9 qemu重启 QEMU检查文件是否存在、内容是否一致。把这个流程跑 100 遍只要有一次内容不对就算持久化失败。我最初跑这个脚本时失败率接近 80%。后来逐步修好最终把失败率降到了 0。另外QEMU 的monitor里其实有system_reset、stop、cont等命令但它们不等于“直接断电”。system_reset走的是模拟的复位线流程很多设备会执行内部清理。要最大程度模拟真实掉电kill -9反而更狠、更接近物理世界的情况。真机上验证同样重要。我当时拿了块淘汰下来的 SATA SSD把系统镜像装进去写文件后再用物理电源开关直接断电反复几十次。真机验证的好处是能覆盖控制器内部行为的不确定性这在 QEMU 里是覆盖不到的。QEMU 的磁盘模型往往过于理想化很多丢数据路径只在真实控制器上才会触发。2.4 修复方案写穿、落盘屏障和日志修复不是加一个flush那么简单。我当时把整个持久化策略梳理了一遍做了三个层面的处理。第一层区分“延迟写”和“同步写”。普通数据写入仍然走缓存但关键位置比如文件系统元数据更新的写操作必须设置同步标志要求数据在返回前真正到达磁盘。应用层如果要更强的保证系统额外提供fsync()调用让用户可以主动请求“把这个文件的所有脏数据刷下去”。第二层在块设备层实现“落盘屏障”。也就是说当上层请求一次持久化时驱动不仅要确保之前提交的数据都发送到磁盘还必须发送缓存刷新命令并等设备的完成状态返回。这一步是为了防止磁盘控制器内部的写缓存捣乱。第三层实现了一个精简的日志系统write-ahead log。它的核心逻辑很简单在任何关键元数据修改之前先把“将要做什么”记录在磁盘日志区等待日志也落盘后再实际修改数据区。系统崩溃重启后先从日志区恢复现场把未完成的事务要么回滚、要么完成。这其实是数据库事务、日志文件系统一直在做的事只是我第一次亲手在裸盘上写了一遍。这里放一段简化后的伪代码展示“写一个块并等待真正落盘”的思路int write_block_sync(block_dev_t *dev, uint64_t lba, void *buf) { // 第一步把数据发送给磁盘控制器 int status dev-write(dev, lba, buf); if (status ! OK) return status; // 第二步请求刷掉设备内部写缓存 dev-flush_cache(dev); // 第三步等待 flush 完成确认数据已到介质 if (dev-wait_flush(dev) ! OK) return E_IO_ERROR; return OK; }看起来简单但这一步补上之后文件系统才算是“真的”把数据写到了磁盘上。写文件断电后文件丢失的问题彻底消失回归测试里的强制断电用例也终于开始稳定通过。3. USB地狱从“识别到U盘”到“稳定读写”3.1 USB为什么会被称为地狱如果说假持久化是“数据链路”的坑那 USB 完全就是“协议地狱”。我在规划下篇时天真地以为USB 无非是读几个描述符、发几个命令两三天就能搞定。结果从第一次插上 U 盘到稳定读写整整花了两周。先看 USB 涉及的分层。协议栈从底往上大致是主机控制器UHCI/EHCI/xHCI、根集线器和 Hub 管理、USB 总线的包传输、设备枚举与描述符解析、按设备类型的协议HID、Mass Storage 等。光这个分层就比 PS/2 键盘复杂一个数量级。更要命的是传输类型还分四种控制传输、中断传输、批量传输、等时传输。键盘鼠标走中断传输U 盘走批量传输而所有设备第一次接入时都要先通过控制传输完成枚举。也就是说你连“识别到设备”都还没做到就已经要面对最复杂的协议编排了。当时的目标明确只需要支持两类设备一个是键盘HID 中断传输一个是 U 盘Mass Storage 批量传输。但后来发现哪怕只是这两类也要走完整个 USB 协议栈的绝大部分路径。3.2 枚举和描述符的坑USB 枚举流程大致是设备插入 → 主机复位总线 → 设备地址设为 0 → 主机读取设备描述符 → 分配地址 → 再次读取设备描述符 → 读取配置描述符及所有接口/端点描述符 → 设置配置。理论上很清晰但实际写出来全是细节问题。第一个坑是端点 0 的最大包长。USB 规范说设备描述符前 8 个字节里有bMaxPacketSize0但在读到这个字段之前你必须先假设一个值去做控制传输。规范允许的初始假设是 8 字节全速设备或 64 字节高速设备。问题在于很多“非典型”设备的返回时序不好如果你一次请求 64 字节它可能只回 8 字节你再怎么等也等不到剩余数据最后设备直接挂死。更好的做法是分两步先请求 8 字节解析出真实的最大包长再用正确的长度重新发送。第二个坑是配置描述符的读取方式。同一份数据第一次读可能只会返回前几个字节因为 USB 规范规定在第一次读取配置描述符时设备可以只返回配置描述符本身9 个字节其中包含完整描述符总长度然后你需要用这个总长度再读一次。我当时图省事直接请求 256 字节结果一部分设备返回了STALL。后来老老实实按照标准流程走了两遍读取。第三个坑是多级 Hub。你插入的 U 盘可能不是直接连在根 Hub 上而是通过一个 USB Hub 转接。这样枚举时就必须先枚举 Hub 本身再通过 Hub 的下行端口去发现真正的设备。Hub 的端口状态不是一步到位的要先检测设备连接信号、复位端口、等待设备重新在线。如果忽略“复位端口后要等至少 10ms”这类时序要求高速设备就会出各种奇怪的CRC error。第四个坑是键盘的 boot protocol。有些键盘在启动阶段会声明自己是 boot 设备但并不会自动切换到 boot 模式需要发送SET_PROTOCOL请求。如果跳过这步键盘可能在真实硬件上不产生任何输入报告。3.3 U盘读写背后的BOT和SCSI设备能枚举成功只是第一关真正读写 U 盘还要过 Mass Storage 的 Bulk-Only TransportBOT协议。BOT 的流程是主机发送一个 31 字节的 CBW 命令包里面包含 SCSI 命令描述块CDB然后按需传输数据最后设备返回一个 13 字节的 CSW 状态包告诉主机命令是否成功。操作 U 盘时最核心的几个 SCSI 命令是INQUIRY查询设备基本信息TEST UNIT READY查询设备是否就绪READ CAPACITY获取总扇区数和扇区大小READ(10)/WRITE(10)按逻辑块地址读写数据SYNCHRONIZE CACHE刷 U 盘内部写缓存。每个命令都有坑。READ CAPACITY返回的不是扇区总数减一之类的直观值而是最后一个有效逻辑块地址要自己加一才是总块数。WRITE(10)的块计数如果是 0代表要写 65536 块这是规范里相当容易踩的边界。“设备忙碌”“命令未就绪”这类状态则需要通过TEST UNIT READY去轮询不能一失败就报错。BOT 协议本身有个很隐蔽的问题CBW 和 CSW 都包含 tag 字段。主机每次发送 CBW 时指定一个 tag设备在 CSW 里必须回同一个 tag。如果设备返回的 tag 不匹配说明事务已经乱序必须恢复。部分兼容性差的 U 盘会在高负载下出现这种乱序。还有一个和第 2 节强相关的点U 盘内部同样有写缓存。操作系统层面调用WRITE(10)成功返回后数据可能还在 U 盘控制器的缓存里。如果不发SYNCHRONIZE CACHE直接拔盘或者断电文件数据照样丢。这个坑让我的“OS 自举”流程里又埋了不少雷。3.4 USB抓包与调试不要靠猜调试 USB 协议栈最大的感受就是如果不把设备到底发了什么包搞清楚你永远只能靠猜。我调试 USB 时用了几类工具效果差别很大。第一类是自己做的日志插桩在 USB core 和主机控制器驱动之间加一个环形缓冲区记录每次请求的类型、地址、端点、数据和返回状态。这类日志能帮你确认“我的代码到底做了什么”但它看不到“设备实际回了什么”的全貌尤其是在总线物理层出问题的时候。第二类是软件抓包。在 Linux 上usbmon配合 Wireshark 可以直接抓 USB 总线流量看到主机和设备之间的所有包。我当时的方法是用一台 Linux 机器插上同一个 U 盘抓它在 Linux 下枚举和读写的完整流程作为对照样本。然后让自己实现的协议栈在同样场景下抓输出两个一对比差异点就是问题所在。第三类是硬件 USB 协议分析仪。这个相对冷门价格也偏高但对真机调试帮助极大。一台简单的 USB 分析仪能看到总线上的电平变化、复位时序、包 CRC 是否错误。特别是那种“有时能枚举有时枚举一半设备消失”的问题软件抓包看不出来的地方硬件分析仪一眼就能定位。分享一个具体排查案例某个 U 盘在 QEMU 里读写完全正常插到真机 USB 2.0 口就会在枚举阶段随机失败。用 usbmon 抓包后发现它在读到配置描述符时返回的最大包长和我请求的长度不一致导致下一条控制传输出现Babble错误。查 Linux 的枚举日志发现 Linux 也会在第一次读配置描述符时只请求 9 字节而我一次请求 255 字节把设备搞蒙了。修完这个点U 盘在真机上立刻稳定。3.5 模拟器流畅、真机翻车的核心原因说到 QEMU 和真机的差异这几乎是所有外设驱动开发的共性问题。QEMU 的 USB 设备模型在实现上非常规整枚举流程按部就班描述符完整时序永远理想设备永远“乖巧”。如果你的驱动写出来完全依赖这种秩序那在 QEMU 里怎么测都通过一上真机就一塌糊涂。真机上有几个 QEMU 模拟不出来的变量设备响应速度低速设备发一个控制传输可能要几毫秒到几十毫秒而 QEMU 几乎瞬间完成总线噪声和时序抖动真实 USB 线的长度、质量、供电都会影响眼图质量多级 Hub 的拓扑差异不同的 Hub 芯片对复位、重枚举、唤醒时机的处理略有差异设备实现的质量有些 U 盘协议栈写得很差甚至返回错误的描述符字段你需要做足够的容错处理。我从这次经历里总结了一个“USB 测试矩阵”至少准备 3 台不同芯片组的真机分别插在根端口和一个外置 Hub 后测试至少准备 3 个不同品牌/容量的 U 盘和一个低速键盘测试场景要包括冷启动插入、热插拔、反复拔插、读写中途拔出。这个矩阵跑完协议栈的稳定性才会有一个基本保证。4. 让OS自己构建自己自举的完整闭环4.1 为什么要做OS自举“OS 自举”这个词在不同的上下文里含义不太一样。在编译器领域自举通常指“写一个能编译自己的编译器”而在操作系统领域更严格的理解是这套系统能够依靠自身能力从源码重新构建出完整的内核镜像并运行起来。也就是说系统不仅是一个能跑的程序它还得是一条能够自我再生的生产链。我在最初规划项目时就很清楚前期的所有系统都是在一台 Linux 机器上交叉编译然后通过 QEMU 加载镜像来运行。这意味着如果脱离了那台 Linux 机器这套系统就再也无法被重建。那更像是在别人家里做饭离“自己养活自己”还差很远。所以“OS 自举”被列为下篇的收尾大戏。目标很具体让我的系统在 QEMU 里自行启动挂载存有内核源码和工具链的磁盘镜像运行一个自研的编译器去编译出新的内核镜像然后把新内核写回磁盘重启后加载新内核并从启动日志里看到版本号确实变了。这个目标的另外一个好处是它天然是一个“端到端测试”。编译、文件系统、持久化、内存管理、进程调度和磁盘驱动任何一个环节有问题这条路都走不通。4.2 工具链的自举路线从汇编到C的螺旋上升提到自举就不得不先讲我的“C 编译器问题”。想要系统自己编译自己首先得有在这套系统上能运行的编译器。而我之前用的交叉编译器运行在 Linux 上显然不在讨论范围内。我当时走了一条“螺旋上升”的路线分四步第一步先拿汇编语言写了第一个极简编译器就叫cc0。cc0只支持一个很小的 C 子集整数类型、全局变量、简单的表达式、条件分支、循环和一些函数调用。它生成的代码质量不高但足够把一个更复杂的编译器代码翻译成汇编。第二步用这个 C 子集写了一个更完整的 C 编译器cc1。cc1支持结构体、指针、数组、多文件编译甚至能处理一部分函数内联。由于cc1是用cc0能编译的子集写的所以它首先可以用cc0编译成可执行文件。第三步用cc1编译cc1自身。这一步是传统意义上的编译器自举测试如果cc1能编译出自己的源码并生成一个新的cc1那么编译器就算“活”了。做完这一步后cc1就正式替代cc0。第四步把cc1装到操作系统自己的磁盘上接着在系统内用它来编译整个内核源码生成新的内核镜像。这一步成了操作系统级别的自举闭环就完成了。这里有个细节值得提一下cc0和cc1的源码都必须是“用这套系统自己的文件系统能读取”的格式所以交叉编译阶段我就得把源码文件按目标文件系统格式打包到磁盘镜像里。这意味着哪怕只是准备自举用的源码文件系统写入路径也得先跑通。除了强制断电丢数据那种场景之外正常的建立文件、写入文件、关闭文件、再次读回校验都必须可靠。4.3 自举全流程拆解我最终完成的自举流程大致是这样启动系统时引导程序加载一个最小化的初始内核这个内核要支持磁盘驱动、文件系统驱动、内存管理和进程加载但不包含图形、USB 这类重量级子系统初始内核挂载根文件系统在根文件系统上找到源码目录/src/kernel和编译器/tools/cc1通过一个简单的 Shell 脚本或控制台命令启动构建过程依次读取源码文件、调用cc1编译、用链接器链接生成新的内核镜像将新内核镜像写回磁盘上的引导分区替换旧镜像这里再次用到了“同步写”和“落盘屏障”否则镜像文件可能写到一半断电就废了重启系统引导程序加载新内核内核打印版本号确认版本号与自举前不一致说明流程生效。为了验证整个流程不是碰巧成功我在脚本末尾加了好几段校验逻辑解析构建日志里的源码文件数、计算哈希、启动后读取版本号并与预期比对。第一次跑完整个流程时串口输出的最后一行是新内核已生成并写入引导盘版本号 2.1.0构建时间 2024-xx-xx 03:12:47然后我手动重启 QEMU看到了新内核的启动横幅。那一刻确实有种“这套系统站起来了”的感觉。4.4 自举时踩的几个尴尬但关键的坑自举流程虽然只有几步但每一步都有让人折寿的坑。挑几个印象最深的。第一个坑编译器把源码读成了“二进制”。我的文件系统接口在底层还是以“块”为单位但文件描述符的open()没有区分文本模式和二进制模式。C 源码文件里到处都是换行符如果fread()直接按块把数据原封不动传给编译器编译器默认遇到字节0x0A就当成行结束其他控制字符全被忽略就可能导致源码里的某些字符串字面量被错误截断。最后花了一个多小时定位发现是编译器的next_token()函数跳过了一部分字符。修复方式是在open()语义里加上模式位让编译器在读取源码时也做完整逐字节处理。第二个坑链接脚本里的地址写死了。我最初的链接脚本把内核入口地址固定为一个常量但只要代码量超过预期布局就会重叠甚至出现新镜像加载后直接触发三重故障triple fault的情况。后来我把链接脚本改成基于符号表计算段地址同时在链接完成后检查镜像长度和加载地址范围发现越界就立刻报错而不是静默构建。第三个坑崩溃一致性问题再次出现。自举流程向磁盘写新镜像时如果写的过程突然断电磁盘上可能留下一个半截镜像引导程序加载它时直接崩溃。这个问题的根源和第 2 节的假持久化同源所以我顺手把引导分区也接入了 WAL 机制先写一个“新镜像信息”的日志记录确认镜像文件本身完整落盘后再更新引导记录指向新镜像。这样即使写入中途断电下次启动时系统会检测到日志不完整自动回退到旧镜像。第四个坑内存不够。我的初始内核只分配了 64MB 内存给构建过程cc1编译大型文件时的内存峰值一度超过这个限制。解决办法不是简单加大内存而是在编译器里实现了简单的寄存器分配优化减少中间表达式的内存占用。这也是自举带给我的额外收益编译器被迫变得更好否则系统自己都跑不起来。5. 常用问题速查表与排障心得到了这一节把之前踩过的坑整理成速查表方便以后直接对照。5.1 假持久化相关问题速查现象可能原因排查/修复方法写文件后重启文件丢失数据停留在页缓存未回写实现同步写/fsync或回写触发机制断电后文件大小正确但内容损坏元数据已落盘但数据块未落盘引入 WAL保证先写日志再写数据偶发文件系统结构损坏命令完成顺序被控制器重排块设备层加落盘屏障发送 FLUSH CACHEU盘拔出后文件丢失U盘内部写缓存未刷新调用 SYNCHRONIZE CACHE 命令5.2 USB问题速查现象可能原因排查/修复方法枚举永远失败端点0最大包长假设错误先读8字节再按真实值重新请求QEMU正常真机失败设备时序、Hub拓扑差异用usbmon/分析仪抓真机流量设备返回STALL请求长度超过设备能力请求返回的实际长度不要盲目申请255字节U盘读写超时BOT tag不匹配或命令乱序增大超时时间实现tag校验和恢复机制插在Hub后设备消失端口复位后未等待设备稳定延长端口复位后等待时间增加重试5.3 自举问题速查现象可能原因排查/修复方法新内核无法启动链接地址/加载地址不对改用符号表动态计算段地址编译过程内存不足编译器资源占用过大增大镜像内存或优化编译器的中间表示镜像写到一半重启就崩缺少崩溃一致性保护引导分区接入WAL不完整则回退编译结果版本号未变旧的编译器替换自己没有成功验证编译器可执行文件哈希确认替换完成这几张表算是我这次项目全过程中最高浓度的经验汇总。每个条目背后都是一次至少半天起步的折腾。5.4 排障心法错误可见、对比参照、双重验证如果你也在做类似的系统级开发有几个习惯强烈建议早期就建立起来。第一让错误可见。日志插桩、环形缓冲、串口输出都要尽早做。我后期靠在 USB core 层加的环形缓冲定位了大量问题如果当时没有这个缓冲每次都要重新烧录调试浪费时间到离谱。第二找“对照参照”不要凭空猜。在 Linux 上抓一遍 usbmon在模拟器里跑一遍自己的协议栈两者对比很多问题会立刻显形。做编译器时我也习惯用宿主机的 gcc 先编一遍自己写的cc1源码和自举编译产生的结果做行为对比能快速圈定编译器自身的缺陷。第三双重验证。模拟器和真机的验证都不能少。模拟器负责快速迭代和逻辑正确性真机负责覆盖物理世界的各种异常场景。自举完成后我在模拟器里跑了两轮又在一台淘汰的笔记本真机上跑了一轮确认了流程的可靠性。说到最终那个 165 的数字我现在的看法已经彻底改变。系统能查出 165 个 BUG说明它已经有足够多的探针、足够的异常路径覆盖、足够多的真实场景压力。一个查不出 BUG 的系统要么真的完美要么从没被认真测试过。我更愿意和 165 个“已知问题”待在一起也不愿意对着一堆“未知问题”假装岁月静好。如果这个项目继续往下走我还会把网络协议栈加进来到时问题数量大概率会突破 300。到那时回看这 165 个 BUG我相信它们更多的会变成“历史遗留”和“边界场景”而不是可怕的质量危机。毕竟真正的成就感从来不是“没有问题了”而是“问题发生后你比上一次更快地定位并解决它”。