
上篇写完之后我的极简内核在 QEMU 里已经能跑起来十几个 shell 命令和内存盘读写都正常工作。但那个阶段其实是“温室里的幼苗”——QEMU 的宽容把很多真实问题都盖住了只要不重启一切看起来都好。这篇下篇记录的是后面的硬仗假持久化差点让我发布一个一断电就丢数据的系统USB 协议栈把我拖进连续两周的“枚举地狱”最后才终于让系统做到了自举——不仅能引导自己还能在自己上面编译自己的内核镜像。先说个数据变化上篇收尾时我列了 45 个已知 BUG等这三块啃完回归测试清单反而涨到了 165 个。不是系统变烂了而是它终于有了足够多的测试和工具能把自己的问题照出来了。各位如果也在写 OS、做嵌入式底层、或者研究存储和 USB 驱动这篇应该能帮你少走很多弯路。我不打算把过程吹成“牛人闯关”只讲实际发生了什么、为什么发生、怎么查、怎么修。1. 假持久化数据看起来落盘了但它骗了我一整周1.1 先定义“真持久化”掉电那一刻系统是什么状态持久化这个问题平时写应用的人不太会直接接触到。文件写下去之后我们会自然认为“数据已经存在了”但在操作系统层面这个认知不一定成立。磁盘有缓存、内核有页缓存、设备有自己的写入队列一次 write() 返回成功只代表数据进了内核的某个缓冲区不代表它已经躺到非易失介质上。真正的持久化要求你在掉电之后的任意时间点回看系统状态都满足一致性约束。普通 Linux 用 fsync/fdatasync 让用户主动保证这一点文件系统用 ordered/journal 模式保证崩溃时不会出现“目录项指向一个根本没写全的数据块”。我第一版“持久化方案”完全没有考虑这些结果可想而知。1.2 我当时是怎么实现“看起来很像持久化”的我的早期文件系统设计非常简单把一块 16MB 的内存当作磁盘启动时从镜像文件加载内容然后在内存里做 FAT16 的读写。为了“持久化”我写了一个 flush 函数每 30 秒把整块内存镜像写回宿主系统的磁盘镜像文件。测试的时候一切正常。写文件、列目录、关机、再启动文件还在。我当时还觉得自己挺聪明直到我做了一次“强制断电”测试直接用 QEMU 的 kill 命令杀掉进程再重启系统挂载同一个镜像。结果惨不忍睹——有的文件彻底消失有的文件还在但内容是一堆旧数据还有的目录项存在但指向了错误的簇链。后来我仔细看代码发现自己犯了一个经典错误内存里的磁盘镜像在写入时目录项和 FAT 表的更新跟数据块的更新完全无关联。比如写一个文件时可能数据块已经写进镜像缓冲了但 FAT 表和目录项的更新还没跟上flush 时我又把数据块、FAT 表、目录项分开刷写一旦在中间断电镜像里就会出现“数据在、目录不在”“目录在、数据不在”的混乱状态。这其实就是文件系统一致性问题的雏形我只是用最简陋的方式踩了一遍。1.3 拆穿“假持久化”的三板斧这个教训说明了一个问题写存储相关代码不能靠“好像能行”来判断必须把验证工具先建起来。我后来总结了三个办法实测非常有效。掉电测试不要用正常关机直接随机时机 kill 掉 QEMU 进程。反复跑几十次再启动系统检查镜像里所有文件的校验和。这个动作能模拟真实掉电的大部分场景。脏页对账实现一个脏块位图每次写磁盘时标记对应块flush 后清零。重启后再扫描一遍镜像文件的块位图和日志里记录的“已写入块”做对比有偏差就说明漏刷了。校验和锚点给每个磁盘块加一个 32 位 CRC启动时全盘扫描。这能查出“数据没丢但内容错了”的静默损坏只靠列目录根本发现不了。这三板斧下去我第一版假持久化当场露馅。实际上很多“看起来没问题”的存储代码在这套检查下撑不过十分钟。1.4 修复方案脏列表驱动写盘 两级提交于是我把 flush 机制整个重做。不再定时整盘写回而是维护一张脏块列表任何写入操作都会把涉及的块号加进列表flush 时先把脏块对应的数据顺序写到镜像文件的末尾区域再写一个提交记录commit block说明哪些块已经落盘最后才更新 FAT 表和目录项。启动时如果发现提交记录缺失就回滚到上一个完整事务。这个做法很像简化版的日志文件系统先写数据、再写元数据、最后写提交标记。我不需要完整的 journal 恢复机制只要保证掉电后能回到一个可用状态就足够了。改造之后我又跑了五十多次随机掉电测试再也没有出现过文件消失或者内容错乱的情况。这里分享一个经验处理文件系统这种“只有在崩溃时才会出错”的逻辑最大的难点不是修而是你怎么让问题稳定复现。我后来写了一个 fuzz 脚本随机生成文件操作序列再随机决定什么时候断电跑一个通宵。第二天早上看日志哪些场景挂了、挂在哪一步、当时的 FAT 表状态是什么都清清楚楚。没有这个脚本我可能到现在还在“看着没问题”的自欺欺人阶段。2. USB 地狱从“设备不响应”到用抓包锁定时序问题2.1 为什么自研 OS 里 USB 特别容易变成地狱文件系统的问题暂时稳定后我开始调 USB。之前一直用 PS/2 键盘接口但想让系统支持 U 盘和更多外设USB 绕不开。可 USB 比 PS/2 复杂了不止一个数量级控制器有 EHCI/XHCI 之分设备有地址 0 枚举阶段传输有四类控制、中断、批量、等时类协议还得区分 HID、Mass Storage、Hub每一层都可能出错。更折磨人的是USB 的错误表现常常不直接。寄存器读回来是 0你以为是控制器没初始化实际可能是 PCI 枚举时 BAR 地址映射错了设备枚举失败你以为是设备坏了实际是你要 64 字节描述符但设备只回了 8 字节后面把状态机搞乱了。这类问题靠看代码往往是看不出来的必须靠抓包工具还原真实时序。2.2 第一个坑MMIO 读写顺序和控制器初始化姿势XHCI 控制器的寄存器都在 MMIO 空间。我最初实现时把 CAPLENGTH、RTSOFF、DCBAAP 这些偏移量理解错了导致寄存器地址算错控制器怎么都跑不起来。更隐蔽的是访问 MMIO 时没有做内存屏障。编译器可能会把对寄存器的写操作重排或合并CPU 也可能乱序执行结果就是命令环提交了控制器根本没收到。解决办法是在关键的寄存器访问前后插入内存屏障指令。我自己写了一个 mb() 宏里面是__sync_synchronize()加上内联汇编的mfence实测下来问题少了很多。这一点尤其在 QEMU 上不明显因为 QEMU 的 MMIO 模拟比较老实但放到真实硬件上不处理顺序就是偶发性的“时好时坏”。如果你是从零写 XHCI 驱动我建议先把控制器重置、端口状态轮询、命令环提交这三件事跑通再想设备枚举。每一步都用状态寄存器的值做断言宁可报错也不要糊着往下走。Linux 的 xhci 驱动代码和 U-Boot 里的 USB 栈都可以当参考资料别闭门造车。2.3 第二个坑枚举阶段的“8 字节描述符陷阱”USB 设备枚举有个经典规则第一次读取设备描述符时只需要读前 8 字节因为第 7 个字节才是 bMaxPacketSize0告诉主机它的控制传输端点最多能发多少字节。我当时图省事直接发了一个标准 GET_DESCRIPTOR 请求缓冲给 64 字节想一次拿全。结果键盘、鼠标这种低速设备还好一些 U 盘直接返回 STALL枚举失败。后来看抓包记录才明白设备在地址 0 阶段还不知道你的缓冲区大小它只按自己的 MaxPacketSize0 往外发数据。如果它只有 8 字节的包能力你要求 64 字节它就只能拒绝。标准做法是分两步先读 8 字节拿到 MaxPacketSize0再给设备分配地址然后用新地址重新读完整描述符。这个坑也顺带让我把 USB 的传输状态机补齐了。控制传输三个阶段的完成判断SETUP 阶段看命令完成事件DATA 阶段要识别短包收到的数据比请求短STATUS 阶段要等到状态包完成。任何一个阶段没处理好设备就可能处于半初始化状态后面你发什么它都不理你。2.4 用 USB 抓包工具还原现场比日志高一个维度第一次遇到键盘中断传输不工作时我打了几百行日志也没看出问题。后来在 Linux 下用 usbmon 加 Wireshark 抓同一个 USB 键盘对比正常系统的时序才发现我的驱动把中断传输的轮询间隔配置错了我把 bInterval 当成毫秒计但 USB 2.0 的中断传输间隔单位是帧1ms 的整数倍某些设备对 polling interval 特别敏感设置过短会直接 NAK 风暴设置过长又丢手感。如果你手头有逻辑分析仪或者专业的 USB 分析仪调试效率会更高。没有的话像我一样借一台 Linux 机器用 usbmon 抓包然后对照 Wireshark 的协议解析也能还原出绝大部分时序问题。我后来把所有 USB 枚举包都导出来存成 pcap遇到疑难杂症就翻记录比堆日志高效得多。2.5 USB 驱动开发经验速查表这部分给一张表对照着查能少踩一半的坑。层次常见问题排查思路控制器寄存器MMIO 地址偏移错、无内存屏障检查 CAPLENGTH、读操作寄存器时必须 volatile/mb端口状态插拔事件无响应、复位卡死轮询 PORTSC 状态位处理 CSC 变化清除设备枚举读描述符失败、地址分配后无响应先读 8 字节再 SetAddress再读完整描述符控制传输STALL、超时检查 bmRequestType 方向位检查短包结束状态中断传输键盘丢键、轮询过快确认 bInterval 单位识别 NAK 和实际数据包批量传输U 盘读写失败、数据残留跟踪 DATA0/DATA1 翻转处理短包和 ZLPHub多级级联枚举失败递归遍历端口先清状态变化位再重新枚举顺序建议先做 boot protocol 键盘再做鼠标/HID 解析最后做 U 盘批量传输。难度从低到高而且每一步都有明确的行为可以观测。3. OS 自举从引导扇区到让系统“自己编译自己”3.1 先借“自举电路”的物理含义讲一句自举这个词在电子工程里有个非常形象的含义自举电容利用开关节点在低电平时充电等开关管导通时把电容另一端电压抬起来使驱动电路能输出比电源电压更高的栅极电压。这就像一个系统借助某个偏置条件硬生生把自己拉到更高的电位上。操作系统的自举也有点这个感觉。早期的 PC 依靠 BIOS/UEFI 固件引导固件就是那颗“偏置电容”引导程序负责把内核从磁盘加载到内存再利用 GDT、分页这些机制把 CPU 切到长模式让内核接管全部硬件资源。整个过程环环相扣哪一环没接上系统就直接断电“趴窝”。3.2 引导链的每一步到底有哪些细节能坑人我最初为了省事用 GRUB 配合 Multiboot2 引导这样不用写五脏俱全的 bootloaderGRUB 会把内核 ELF 加载进内存并传一组内存布局信息。看起来简单实际写入口汇编时暴露了很多问题。实模式跳到保护模式必须关中断加载 GDT然后用 far jump 刷新 CS。忘了 reload CS后面的 32 位代码会直接 #GP。保护模式再到长模式设置 CR4.PAE、写 MSR EFER.LME、把 CR3 指向 PML4、打开 CR0.PG。顺序不能乱页表必须覆盖到当前正在执行的代码页否则切换后 PC 飞掉。从汇编跳 C 入口要自己把栈指针设好BSS 段清零检查 bootloader 传入的参数有没有因为页表切换失效。有一晚我折腾到凌晨原因是页表构建时犯了个低级错误我分配了 PML4 和 PDPT但没初始化页目录项指向实际的页表页结果页表里全是 0切换之后直接 triple fault。查了很久才发现因为我在链接脚本里把内核加载地址和虚拟地址设置成一样导致引导时即使页表缺失前几行代码刚好还在 1:1 映射的区域内运行等到访问另一个段才炸。这个问题在日志上表现为“随机崩溃”非常迷惑人。3.3 真自举闭环在自家系统上编译自家内核引导只是“把系统拉起来”真正的自举闭环是让操作系统拥有承载工具链的能力——在它上面运行编译器、汇编器、链接器然后编译出自身的新版本内核。这个目标比单纯引导难十倍但一旦做到系统就真的“活”了。我当时的路径是先在宿主 Linux 上交叉编译一份 LCC 小型的 C 编译器把它移植到我的 OS 的 ABI 上然后给我的 OS 写一个极简的 ELF 加载器支持静态可执行文件的段加载、重定位和解析接着实现文件系统、内存映射、终端、管道这些必要的系统调用让编译进程能读源码、写中间文件和最终输出。最后我在自己的 OS 里启动一个 shell 命令运行“内核源码 编译器”把新内核镜像写到磁盘镜像重启加载它。那个瞬间和“点灯程序跑通”完全不是一个感觉。当然如果你没有精力自己写编译器可以先用 clang/LLVM 生成目标平台的可执行文件再让系统加载运行。真正的自举能力可以分阶段实现先是能运行任意的静态编译 C 程序再是能用外部编译器交叉编译内核最后才是用系统内的编译器编译系统自身。每一步都对 ABI 设计、链接脚本和加载器提出了更严格的要求。3.4 自举构建的调试心得自举构建最烦人的是“构建进程跑了半天中间步骤崩了崩溃点还不确定”。我后来把所有编译中间文件放到内存盘同时把编译器的每个阶段用管道串起来失败时直接输出当前 token 位置和符号表状态。看起来没什么但调试效率比黑盒高非常多。另外链接脚本的布局非常关键。内核入口地址、页表地址、堆栈地址、中断描述符表地址都需要在 ld 脚本里显式声明并让汇编里的符号与 C 引用保持一致。我吃过一次亏因为 .bss 段没有对齐导致加载器给 BSS 清零时覆盖了页表所在页系统起来后被中断折腾得反复重启。这些问题用 objdump 对比符号表就能查出别硬猜。4. 从 45 个 BUG 到 165 个BUG 不是变多了是系统开始能被测试了4.1 为什么功能越完善BUG 数量反而暴涨很多人看到这个数字会觉得越写越差其实恰恰相反。上篇的 45 个 BUG是我只能靠手动测试暴露出来的等到文件系统、USB 栈、多级页表、自举构建这些模块都上了我写了一个自动化回归脚本里面有几十个测试用例覆盖启动、文件读写、内存分配、USB 枚举、编译执行等场景。测试跑一圈问题全部浮上来有的是旧代码在边界条件下暴露出的未定义行为有的是新模块和旧模块的交互问题有的是真正的竞态条件。BUG 数量从 45 涨到 165意味着系统的可测性上来了问题的可见度上来了。这时候最忌讳的是“看见一个修一个”不做记录和分类修完根本不知道哪些问题被覆盖了。4.2 我给自己定的五种 BUG 处理规范大厂里通行的 BUG 管理模型对个人项目同样适用只是规模缩小。我把自己的规范定成了五条。每个 BUG 必须能复现复现不了的先写复现环境和概率不在代码里盲猜。每次 BUG 记录必须包含模块、触发条件、期望行为、实际行为、日志片段、修复后的验证结果。修复前先写一个失败的测试用例修复后能通过才允许关掉这个 BUG。严重级划分P0 是启动失败、内核崩溃、数据丢失P1 是主要功能不可用P2 是边界条件错误P3 是提示信息问题。每周做一次回归记录 BUG 数量的走势和模块分布而不是只看总量。我后来把 BUG 记录模板放在仓库里长这样项目内容BUG IDBUG-0117模块USB Mass Storage / BOT复现步骤连续写入超过 4GB 文件在残留数据阶段拔盘期望行为写入返回失败不能留下半截文件实际行为写入返回成功文件系统目录项指向错误簇日志片段scsi_write failed resid1280修复方案处理 CSW 的 data residue释放未写入扇区验证结果回归测试 USB-STORAGE-09 通过事实证明没有这套纪律的话165 个 BUG 很容易变成“今天修了昨天忘了”。别觉得个人项目不需要这些流程实际写 OS 的复杂度远超普通应用你的记忆力根本不值一提。4.3 几个最有代表性的“诡异 BUG”实录挑几个印象深刻的供大家参考。第一个是键盘偶发丢键。代码读键盘时只检查了状态寄存器的数据就绪位没检查输出缓冲区是否被占用导致按键扫描码覆盖了旧值。后来改成先读状态、再清 OBF、最后读数据彻底解决。看似简单但在 USB 键盘场景下问题会表现为“偶尔丢字符”很难发现。第二个是 U 盘批量读取大文件时的残留数据。scsi READ_10 命令返回的数据比请求的长度少但协议里这属于正常短包我却把它当异常直接终止传输结果后续命令的标签对不上设备进入“怪状态”。修法是完整处理 CSW 里的 data residue 字段并把未完成的传输事务全部取消再复位驱动状态机。第三个是自举构建过程中的随机崩溃。折腾了三天后来用日志定位到是用户进程栈的虚拟地址和内核页表映射冲突了。我初始的页表只映射了固定 512MB 区域但自举编译时用户进程的堆和栈不断增长越过映射边界后触发数据异常。修法是用 4 级页表配合 vmalloc 区域动态映射才把问题解决了。这些 BUG 单独看都不算高深但组合在一起就是 OS 开发的日常。很多时候你以为自己在处理一个无关紧要的小错误实际那是一个更深层架构问题的第一个“前哨站”。4.4 给独立 OS 开发者的几条忠告内核代码里一定要写 assert 和 panic 信息。宁可系统崩溃时打印一堆寄存器现场也不要让它悄悄跑偏否则排查成本翻十倍。测试要跟代码同步写。没有自动化回归你根本不敢在文件系统代码上做一次大重构。不要只在 QEMU 里跑。拿到真实硬件上跑一次你会发现内存顺序、MMIO 时序、USB 控制器的兼容性都完全不同。多利用 Linux 内核源码当参考。它的协议栈、驱动实现是经过全世界开发者验证过的不是你闭门造车能比的。我自己的体会是45 个 BUG 那会儿我以为快收尾了等修到 165 个心里反而更踏实。每个被记录、复现、修复的 BUG都是在给自己的系统补边界。如果你也在折腾类似的东西建议先把掉电测试脚本、USB 抓包工具、自动回归跑起来再往上堆功能。这些工具不是“锦上添花”是让你能从“看起来能跑”走向“确实能跑”的必经之路。