
如果你和我一样干过从零写内核这种“自虐型项目”大概率会对着自己的 issue 列表产生过类似的怀疑别人的 bug 都是越修越少我的 BUGS.md 却在进入第二个大版本之后从 45 条一路涨到了 165 条。看到统计数字跳上三位数那天我甚至在文档顶部写了一句话“这周是不是白干了”但等我把这 165 条逐条过完反而松了一口气新增的绝大部分问题都不是回归或劣化而是新的子系统开始接受真实硬件检验后暴露出来的欠账。这篇下篇就聊聊这段“越修越多”的阶段主角是三个最折磨人的主题假持久化、USB 地狱以及 OS 自举时那些绕不开的底层细节。1. 从45到165数量翻了近四倍反而说明这轮迭代做对了1.1 统计口径变化导致数字必然上涨先说清楚 45 这个数字的来源。上个阶段我统计 bug基本是“QEMU 里把主线流程跑通就算过”启动、中断、键盘输入、串口输出、内存分配能复现的缺陷才往 BUGS.md 里记。当时很多问题其实存在但因为我只测了 happy path它们就一直潜伏着没被触发。第二阶段我干了几件事每一件都会让 bug 数字“膨胀”把测试环境从 QEMU 扩展到两台真实物理机其中一台还是杂牌主板ACPI 表写得比较随意。给内核加了自定义文件系统 MyFS并设计了脏页回写机制。开始写 USB 子系统目标是让键盘和 U 盘能同时工作。写了一个简陋的自动回归脚本每次构建后自动执行一组用例。真实硬件和自动回归一上原来藏在角落里的问题就全浮出来了。早期那种“手测一遍就放心”的做法本质上是用人的惰性掩盖了测试深度的不足。1.2 BUG分布能看出系统在往哪个方向长肉把 165 条 bug 按模块归类后分布很有意思模块bug 数量主要触发场景USB 子系统61键盘枚举失败、U盘识别不稳定、传输超时MyFS 文件系统28写入后断电丢失、目录项损坏、回写顺序错误引导阶段17实模式切换长模式失败、GDT 加载异常中断与调度31中断丢失、竞态条件、上下文切换后寄存器错乱内存管理20页表权限位错误、堆分配越界其他杂项8工具链脚本、构建脚本、串口初始化顺序USB 占了 61 条远超其他模块。这也是我把它叫作“USB 地狱”的直接原因。剩下的部分文件系统和引导阶段的问题虽然数量不如 USB但每一个都更隐蔽尤其是那个让我花掉整个周末的假持久化。1.3 记录 bug 时我坚持的几条规则数字能不能说明问题取决于记录方式。我给自己定了几条规矩执行了一段时间后效果不错建议想用“bug 数”来跟踪项目健康度的人参考bug 描述必须包含“预期行为”和“实际行为”不能只写“这里坏了”。每个 bug 都要有最小复现步骤哪怕步骤是“开机后 5 秒内立刻断电”。修完之后不立刻关单必须跑一遍回归脚本才允许标记为 fixed。因为测试环境变化导致的“新发现问题”和真正由新提交引入的回归问题分开计数。这套规矩做下来虽然数字变大了但每个数字都有据可查。到后期我甚至能通过新增 bug 集中在哪几个模块判断出当前代码哪块最不健康。2. 假持久化断电前它一直以为自己是持久存储2.1 “写入成功”只是自我感觉良好“假持久化”这个词是我自创的指的是文件系统在运行期间看起来一切正常写入能成功、读取能返回正确数据、文件大小也能更新但只要一断电重启所有刚刚写进去的内容全部消失。整个系统就像失忆了一样。第一期的时候MyFS 的实现非常简单所有数据直接写在内存里的一个 RAM disk 上本来就没打算持久化。所以那时候我会很清楚地知道“这只是个实验性文件系统掉电就没了”。但第二阶段我把底层存储从 RAM disk 换成了真实的 ATA 磁盘驱动问题就开始变得暧昧了。当时 MyFS 的写入路径是用户态调用write()。内核把数据放到页缓存。返回写入成功。按这个流程数据并没有真正落盘只是进了缓存。设计上我应该等一个合适的时机把脏页批量刷到磁盘或者至少在关机前把脏数据全部写出去。但坦白讲初期我把“写入成功”和“数据已经安全”这两件事混为一谈了。// 简化后的伪代码展示我当时的问题 void myfs_write(struct file *fp, const char *buf, size_t len) { // 分配页缓存拷贝数据 struct page *pg page_cache_alloc(); memcpy(pg-data, buf, len); // 把这个页标记为“脏”但没触发真正的磁盘写入 mark_page_dirty(pg); // 看起来很完美写入成功 fp-size len; }单看这段代码问题已经很清楚了mark_page_dirty()只是把页面加入脏页链表真正写盘的动作发生在后台回写线程里。如果回写逻辑没有正确执行或者系统在回写前就掉电数据就没了。2.2 找到“重启后文件丢失”根因的那次抓狂发现这个问题不是在开发环境里而是在一次看起来很普通的测试中我启动系统创建一个新目录往里面写了一个文本文件然后正常关机。再开机时目录还在文件没了。第一次遇到时我的第一反应是“目录项损坏了”。于是花了不少时间去查目录项的分配逻辑查了很久都没找到问题。后来换个思路在关机流程里加入串口日志把脏页回写函数的调用情况打了出来才发现关机流程根本没有触发回写。正常关机流程应该是调用sync()把所有脏页刷回磁盘。卸载文件系统确保目录项和位图更新。停止磁盘控制器并断电。我的内核里前两步全都没做。也就是说我一直在靠“运气”维持文件系统的假象只要不主动断电后台回写线程偶尔也会把脏页刷到磁盘上但时机完全随缘。2.3 持久化组件要过的三道验收关这个假持久化 bug 的根因找到后修复本身并不难在关机流程中插入同步逻辑并确保回写线程在系统停止前完成工作。但让我真正害怕的是这个 bug 潜伏了很久都未被发现。只是因为测试方式碰巧太温和——每次都正常关机给了系统足够的运行时间去“补写”。后来我给持久化相关的功能设定了三道验收关缺一不可读写一致性测试开机写文件立刻读回校验内容一致。掉电恢复测试写完文件后不做任何清理直接按电源键断电再开机检查文件是否还在。重复压力测试连续写删改写几百次期间随机断电最后做全盘一致性检查。第一道关谁都能过第二道关才是真正的试金石。我的假持久化就是栽在第二道关上的。你写盘功能做得再花哨如果断电后数据没了那它就不是持久存储只是一个跑在内存里的模拟器。3. USB地狱从“点亮键盘灯”到“识别U盘”之间隔着一整本USB协议3.1 为什么那么多人绕开USB去用PS/2很多从零写 OS 的人会刻意绕开 USB因为 PS/2 键盘在 QEMU 里简直不要太友好端口固定协议简单读一个状态寄存器就能拿到按键扫描码。我以前也这么干过USB 键盘的初始化代码被我注释掉了快两个月。直到我把系统搬到真实笔记本上才发现 PS/2 这条路在物理机上并不总是可用。现代主板默认把键盘接到 USB 控制器上而很多 BIOS 的“USB Legacy Support”选项又不够稳定。与其和 BIOS 设置较劲不如直面 USB 协议栈。USB 麻烦在哪里我总结下来有三个层次协议层次多物理层、传输层、协议层、类驱动层每一层都可能出问题。硬件差异大不同厂商的 U 盘对命令的容忍度不一样有的设备在枚举阶段就需要反复 reset。可观测性差出了问题你很难判断是控制器驱动的问题还是设备自己挂了。3.2 XHCI驱动一场关于环形队列和门铃的持久战USB 3.0 时代的主机控制器是 xHCIeXtensible Host Controller Interface它和老的 UHCI/OHCI 完全不一样。UHCI/OHCI 的编程模型相对直观而 xHCI 把所有请求都抽象成 TRBTransfer Request Block通过环形队列提交给控制器再用事件队列拿回完成状态。我花在 xHCI 驱动上的时间远超其他任何模块。最典型的坑有三个坑一门铃寄存器Doorbell得按特定顺序敲。xHCI 的设计是你把 TRB 写到内存里的环形队列之后还需要往 Doorbell 寄存器写一个值控制器才会知道你提交了新请求。这个值的大小取决于目标端点。我曾经因为漏写了这一步导致 U 盘插入后毫无反应所有命令都卡在“等待完成”状态。坑二事件环形队列的段切换。控制器完成请求后会在另一个环形队列里生成事件 TRB。这个队列的每个段是连续内存但段和段之间靠 Next 指针连接。当控制器走到段尾时需要手动中止并指向下一段。如果处理不好轻则丢事件重则控制器直接挂起。坑三端口状态轮询的时机。xHCI 的端口状态变化是通过PORTSC寄存器暴露的检测到设备连接后需要做端口 reset、发送Set Address、读取设备描述符、配置端点等一系列操作。这个过程涉及一长串控制传输任何一个环节超时都得重来。我用一串伪代码总结一下枚举 U 盘的流程骨架// 检测到端口连接事件后 usb_port_reset(port); // 复位端口让设备进入默认状态 usb_set_address(port, addr); // 分配设备地址 usb_get_descriptor(dev, DEVICE, 0); // 读取设备描述符确认设备类型 usb_set_configuration(dev, cfg); // 设置配置激活所有端点 usb_get_descriptor(dev, STRING, langid); // 读取字符串描述符得到厂商信息一套流程下来任何一个步骤失败都要靠打印日志一步步定位。后来我干脆写了一个“UsbDebug”模式把枚举过程中的每一步都输出到串口配合协议分析工具去抓总线上的报文效率才高起来。3.3 HID键盘和U盘的“性格”完全不同USB 键盘通常走 HID 协议初始化时要发送Set_Protocol请求让设备进入 Boot Protocol 模式。在这个模式下键盘只需要用中断传输上报 8 字节的输入报告前 4 字节是修饰键状态后面是按键码规则很简单。但 U 盘走的是 Bulky-Only TransportBOT协议底层用 Bulk 传输上层跑 SCSI 命令集。U盘识别过程中你还需要处理Inquiry、Read Capacity、Test Unit Ready这些 SCSI 命令。和 HID 相比SCSI 命令的 timeout 处理更严格设备忙的时候会回复一个STALL条件需要重新发送Clear Feature来清除端点异常状态。这块我当初踩过一个具体问题某些 U 盘插入后Test Unit Ready命令需要重试几十次才返回成功因为设备内部的闪存初始化非常慢。如果驱动只等待一次就放弃U 盘就会表现为“无法识别”。后来加的方案是对Not Ready状态码做有限次重试每次间隔 100ms最多重试 10 次。3.4 调USB问题时我依赖的三个工具经验之谈一个人调 USB 协议栈时不能只靠 log 硬看。我常用的三样东西USB 抓包工具能看到控制传输的交互过程判断是设备没回数据还是主机没发请求。逻辑分析仪关键时刻用来确认总线上是不是有信号尤其是设备枚举失败怀疑是硬件问题时。协议文档xHCI 规范、USB 2.0 规范和 USB Mass Storage Class 规范翻烂了。规范虽然厚但每个概念都有对应的章节可以查表。有了这三样省下的时间绝对值得。否则你会陷入“猜设备行为”的泥潭一猜就是好几个晚上。4. OS自举从引导扇区里的长模式迁移到“在OS里编译OS”4.1 从reset vector到内核入口最容易被忽略的几步“自举”这个词在我的项目里有两层含义。第一层是计算机领域最经典的 bootstrapping从 CPU 复位的那一刻开始一步步把内核加载进内存并跳转执行。这个过程的骨架看起来很简单CPU 从 BIOS 固件初始化开始执行。引导扇区被 BIOS 加载到内存的 0x7C00 地址。引导程序负责加载内核镜像到内存。内核入口通常是 32 位或 64 位代码需要先设置好 GDT、分页和栈。但“看起来简单”和“真正跑起来”是两码事。我在自举阶段踩到的第一个大坑是引导扇区只有 512 字节既要加载内核文件又要准备切换长模式的环境留给代码的余量非常小。为了能放下 FAT32 文件系统的解析逻辑我不得不用汇编手写一个极简版的文件查找函数只支持从根目录读取连续文件。进入长模式的过程也不像书上写的那么顺利。当时频繁遇到的问题是分页表设置完之后lgdt和远跳转之间的顺序不对导致 CPU 在关键指令上抛异常。后来我把这几步做成了固定清单关中断。加载 GDT确保 64 位代码段描述符已经定义。设置 CR4 的 PAE 位。设置 CR3 指向 PML4 页表。打开 CR0 的 PG 位。执行远跳转切换 CS 到 64 位代码段。每一步之间加一条串口日志确认状态才终于摆脱了“人格分裂式”的调试方法。4.2 引导期让人崩溃的三个实际问题第一个问题是BIOS 到底加载了哪个扇区有些主板的启动设备列表里有同名设备排序还不一样。调试时我打印过 BIOS 的磁盘号才发现它给的是 0x80 而不是 0x00。这个问题排查了很久最后解决办法是引导代码里同时尝试 0x80 和 0x81确保能找到启动介质。第二个问题是同一份引导扇区在 VirtualBox、QEMU 和真机上的表现不一样。QEMU 默认把引导扇区加载到 0x7C00但某些主板的 BIOS 会先把控制权交给自己的选项 ROM再跳转到引导扇区。如果不处理这种“被转送”的场景代码里直接引用的绝对地址就会出错。第三个问题是把内核镜像从 FAT32 分区拷到内存时连续读取的块数超过 65535 个扇区。FAT 的按 cluster 链遍历本身不是问题但我在“簇号转 LBA”的换算公式上写错过一次偏移导致内核加载到一半就卡死。这些问题的共性是它们不会在纯模拟环境里暴露只有真实固件和真实硬件才有这些“商家自定义行为”。调试自举代码时一个重要心得是先把日志输出写到串口并加充足延时否则屏幕上最后一个字往往不是真正的错误点而是 UART 缓冲还没传完就死机了。4.3 第二层自举在OS里跑起交叉编译器如果说引导加载器是“让 OS 站起来”那么另一个意义上的自举就是“让 OS 能编译自己项目里的工具链”。第二期我开始尝试更激进的目标不只是跑我们的内核还希望自己的系统能编译 C 代码。通过移植一个简单的 C 编译器到内核的 ELF 加载器之上再加上自己写的命令行 shell我可以在系统里执行类似cc -o hello hello.c这样的命令。这项工作的价值在于它证明了系统不再只是一个“能启动的玩具”已经具备了一定的自我演化和能力循环能力。当然离“完全自举”——也就是内核的所有源码都能在自己的环境里重新生成镜像还有很长一段距离。但当我看到自己写的 OS 调用自己加载的编译器把一段 C 源码编译成可执行文件并在自己系统里运行时那种成就感比跑通任何 Demo 都强烈。一个能让你少走许多弯路的建议我试着把所有问题做了个简单归因。USB 的 61 个 bug 让我明白协议栈绝不能靠想象实现必须一开始就准备好抓包工具和硬件观测手段。假持久化则让我学会衡量文件系统是否可靠不是看运行一段时间的“感觉”而是看它在最恶劣的掉电条件下能不能保住数据。至于自举的层层细节则是每个从零写 OS 的人都必须交的学费——哪怕你用的是最成熟的工具链方案真实硬件永远会给你惊喜。翻回 BUGS.md从 45 到 165这个数字现在在我看来不是failure count而是系统健康度的度量表。真正需要注意的是三类信号集中爆发在同一个模块的 bug量在变大的同时复现步骤是否变得困难以及修完老问题之后是否引入了同类新模式。如果这三类信号都正常那 bug 数量的增加反而是系统正在长大的证据。