从45到165个BUG:操作系统实战中的持久化、USB与自举深坑
2026/9/15 8:19:33 网站建设 项目流程

很多人看到“从45个BUG到165个”这个标题,第一反应是:你是不是把操作系统越写越烂了?恰恰相反,这说明我的操作系统开始从“能跑”走向“能活”。上篇发出来后,系统能在QEMU里启动、打印日志、跑调度器,我当时觉得一切都在掌控之中。直到我决定不依赖GRUB、自己引导内核,把系统放到真实U盘和硬盘上跑,才第一次体会到什么叫“模拟器里岁月静好,真机上处处是坑”。

这篇下篇,我打算把这段被真机反复捶打的过程完整拆开来讲:假持久化、USB地狱、OS自举。这三个词每一个背后都藏着一堆我在文档里根本查不到的细节。165个BUG也不是说质量下降,而是系统真正开始接受“真实世界”的考验时,被逼出来的那一大批问题。

1. 从45到165:不是BUG越写越多,是以前的“假稳定”被戳穿了

1.1 上篇做完时,系统到底处于什么状态

先把上下文说清楚。上篇结束时,我的内核在QEMU里已经具备这些能力:能从GRUB引导、初始化GDT和IDT、处理中断异常、有基础的物理内存分配器和页表管理、能创建并切换两个进程、有简易的printk输出、能用PS/2模拟键盘输入。当时累计修了45个可复现的逻辑问题,大部分是页表项设错、中断处理函数没保存寄存器、调度器切换上下文时栈没对齐这类典型新手坑。

那个阶段所有测试都在QEMU里完成,启动路径固定,硬件环境固定,连时钟中断的频率都固定。所以45个BUG,本质上是“在一条固定路径上跑出来的45个问题”。你只要保证这条路径没问题,系统看起来就稳如老狗。

1.2 这轮迭代做了什么,BUG为什么会翻将近四倍

下篇的核心目标有三个:让文件系统的数据真正持久化、支持USB键盘(而不是依赖PS/2模拟)、去掉GRUB让内核自己从U盘引导。表面上看只是增加三块功能,实际做起来才发现,这三块触及的底层复杂度完全不同。

先说结论:从45到165,代码量只增加了大概两千多行,但测试场景从“单一路径”变成了“全路径”。以前很多函数只在理想条件下被调用,现在要面临真机上的各种边界:

  • 文件系统写入路径上,数据要穿过VFS、块缓存、AHCI驱动、磁盘写缓存,任何一层没考虑“掉电”就完蛋。
  • USB协议栈是一个极其松散的状态机,每次枚举都是和硬件玩一次“对表”,时序差几毫秒就失败。
  • 自己引导不是把内核load进内存就完事,还要处理A20、分页、ELF段加载这些bootloader默认帮你做好的事。

更重要的是统计口径变了。上篇我只会把“导致功能不工作的bug”记入台账;下篇开始,所有可能导致数据损坏、死锁、真机启动失败的问题我也全部计入。比如文件系统位图分配在极端情况下可能覆盖脏数据,这种问题在QEMU里永远不会暴露,但隐患是实打实的,必须记下来修掉。

1.3 项目管理上的一个经验:把BUG分类,不要被数字吓到

我维护了一个纯文本的BUG台账,每个问题记录四列:复现路径、根因、修复方案、验证方式。165个问题里能归为“可复现型”的占大部分,大概110个;剩下的是“环境型”,比如真机USB枚举时快时慢、AHCI某些端口状态延迟、A20打开后内存行为变化;还有一小部分是“设计型”,例如文件系统缺少barrier概念、USB轮询调度没有周期性框架。分类最大的好处是,你不会在深夜面对一个随机出现的故障时毫无头绪——你知道该去翻哪一类。

如果只盯着“BUG变多了”这个数字,心态很容易崩。实际上165这个数字代表的是质量门槛在提高。单元测试覆盖率从10%提到80%时,测试失败数也会暴增,但没有人会说是代码变差了。操作系统开发也是一样,当你开始做崩溃恢复、掉电测试、真机压力测试时,问题数量必然上升。

2. 假持久化:数据能写能读,断电后全部蒸发

2.1 现象:在QEMU里一切正常,真机上重启文件没了

文件系统是最早动手的部分。我参照MINIX文件系统的思路,实现了简单的inode、位图、数据块分配,能创建目录、写文件、读文件。挂在QEMU的磁盘镜像上测试一切正常:格式化、建目录、写文件、关掉QEMU、再次启动、文件还在。我当时觉得自己已经掌握了存储的真谛。

直到某天我把系统烧到一块真实U盘上,写了一个日志文件,正常关机,重新上电,文件消失了。不是损坏,不是变成乱码,是干干净净地没了。目录还在,inode还在位图上标记为已分配,文件大小也在,但你打开文件时读到的是空数据块。那一刻我整个人是懵的。

2.2 排查链路:从文件系统一路查到块设备层

排查的第一步,当然是怀疑文件系统层。我在VFS的write路径上加了大量跟踪日志,确认写文件时inode确实被更新,脏块也被标记。然后我写了一个“把所有脏块强制刷盘”的函数,在关机前调用它,甚至还验证了内存里的数据块内容是正确的。结果重启后依然丢数据。

这意味着问题不在文件系统逻辑上,而在文件系统之下的某个环节。于是我把矛头转向AHCI驱动。在驱动里,每次write都会返回成功,命令很快就“完成”了。我用一个调试命令读取固定LBA的数据,发现读到的是全零。这时候真相已经露了一半:我们的写命令根本没有把数据送到磁盘介质上。

再往下挖,发现根子在驱动层对“完成”的理解。当时我写的AHCI写逻辑大致是:把命令列表项填好,设置物理区域描述符(PRD),把命令提交到端口命令列表,然后轮询端口中断状态位,等到位置位就认为写完了。看上去没问题,但AHCI协议里,命令完成只代表数据已经交给磁盘控制器,不代表数据已经落盘。磁盘内部还有一个易失性写缓存,命令完成时数据可能在缓存里,还没来得及写入盘片。要保证掉电后数据还在,必须额外发送FLUSH CACHE命令。

QEMU掩盖了这个错误。QEMU的磁盘控制器模拟得非常“宽容”,即使你不发FLUSH命令,它在进程退出时也会帮你把整个镜像文件刷完整,所以我在模拟器里反复测试都发现不了问题。真实U盘不会替你兜底,断电就是断电,写缓存里没来得及落盘的数据直接蒸发。

2.3 根因:卷写缓存与没实现的同步机制

用生活化的类比来解释:你把现金递给银行柜员,柜员给你一张回执,这相当于DMA传输完成;但柜员并没有立刻把这笔钱记入账户,钱躺在柜台的抽屉里准备下班前统一入账,这就是磁盘上的易失性写缓存。你要求的“这笔交易必须立刻入账”在操作系统里就是FLUSH命令。只要没发FLUSH,数据就只存在于硬件内部的临时存储里,断电后找谁都没用。

问题梳理清楚后,修复方案也清晰了:

  • 在块设备层为每个设备实现flush能力。IDE磁盘发送FLUSH CACHE(E7h)或FLUSH CACHE EXT(EAh),AHCI磁盘在命令列表里构造一个FLUSH命令并等待其完成。
  • VFS层增加全局“同步队列”,所有脏块按LBA排序后统一写出,而不是随机顺序写。排序能减少磁盘寻道,也能让flush语义更清晰。
  • 关机、重启路径必须统一调用一个sync_all_devices(),确保所有设备在掉电前处于干净状态。
  • 对于未来要做的日志型文件系统,还引入了barrier概念:日志提交前先flush一次,日志提交后再flush一次,保证元数据更新不会先于日志落盘。

2.4 修复之后,测试方式也必须跟着变

修完这些,测试方式如果不升级,迟早还会出事。我开始在每次启动流程里注入故障:用参数控制系统在第N次启动时主动触发一次panic,模拟意外断电,然后检查文件系统在重启后能否恢复。这个测试一发不可收拾,又拉出一批新BUG:

  • 释放数据块后,位图上对应位没有清零,导致后续分配重复使用同一块区域。
  • 删除文件时inode的链接计数没有递减,目录项删了但inode还挂着,磁盘空间永远释放不掉。
  • 日志重放时按写入顺序而不是按日志序号处理,导致乱序恢复时出现不一致。

这些问题的共同特点是:普通功能测试完全测不出来,必须靠崩溃注入才能在特定组合下复现。我把这个测试脚本保留下来,每次改动文件系统相关代码后都会跑一遍。现在系统在真机上每次启动、使用、关机,我都会下意识地默认“下次上电可能面临一次崩溃恢复”,这种心态比任何代码审查都有用。

3. USB地狱:从枚举到键盘输入,处处都是“规格陷阱”

3.1 为什么USB栈会成为操作系统的噩梦

USB协议栈的复杂度远超大部分人的预期。物理层有Root Hub和端口状态机,传输层有UHCI、OHCI、EHCI、xHCI四种不同的主控制器模型,协议层有SETUP、IN、OUT三类事务,再往上还有设备层(配置、接口、端点)和类层(HID、存储、网络)。每一层只要有一个位错了,结果往往不是报错,而是设备静默无响应,你只能在黑暗里慢慢摸。

我最初选择UHCI,原因很简单:UHCI的寄存器在PCI I/O空间,用in/out指令就能访问,调试比内存映射的EHCI直观。代价是它只支持低速和全速设备,后来的USB 2.0高速设备只能靠配套的EHCI控制器处理。但作为第一个USB栈,UHCI已经把该学的坑都教给我了。

3.2 端口复位与SetAddress:一个时序错误引起的枚举失败

第一个拦路虎是枚举阶段。插入USB键盘后,Root Hub端口状态寄存器检测到设备连接,主控驱动复位端口,然后我立刻往端点0发送Get Descriptor请求,结果设备完全不响应。

一开始怀疑是控制传输的结构体没填对,反复检查TD字段,没有发现问题。又怀疑是设备根本没有进入Default状态,于是我把端口状态寄存器打印出来,发现复位动作确实触发了,但我给设备的时间太短了。USB 2.0规范要求复位后至少等待10毫秒的恢复时间,实际操作中很多设备需要更长时间,我一开始只等了1毫秒就急不可耐地发包。

QEMU再次成为掩盖问题的帮凶——它的模拟主控对时序完全不做限制,复位后立刻发包也能正常响应。真机上这套逻辑就不成立了。我把复位恢复时间改成100毫秒之后,枚举才稳定下来。

另一个隐藏在角落的问题是SetAddress命令的生效时机。控制传输的Setup阶段先发出地址设置请求,设备完成该事务后,新地址才生效。如果后续请求仍然发给地址0,设备会认为你在跟一个根本不存在的设备通信,直接把包丢掉。这个顺序错一次,后面所有描述符请求都会落空,而且错误不会以中断形式通知你,纯粹是“发出去就没有然后了”。

3.3 TD/QH链表的指针错位:结构体对齐带来的低层翻车

枚举能跑通之后,数据传输又出了一堆问题。UHCI的传输模型是把队列头(QH)和传输描述符(TD)串成链表,主控制器自己沿着链表取TD执行。问题出在Link Pointer的低位含义上:bit0是终止标志,bit1是队列类型标志。如果直接把结构体指针塞进Link Pointer字段而不处理低两位,主控制器解析链表时会直接走错路,表现就是不执行你安排的传输,或者跳到随机内存执行。

这里还牵扯到结构体对齐。UHCI要求QH和TD按16字节对齐,但编译器默认可能只保证4字节或8字节对齐。必须显式使用__attribute__((aligned(16))),否则在开启某些优化选项后,结构体地址会漂移,主控制器读到的标志位全乱。这类问题在x86上特别玄学:同一份代码,不开优化时一切正常,开了-O2就开始随机失败,根源往往就是对硬件对齐要求处理不到位。

我还在这里浪费了整整一个晚上,原因是把UHCI的QH/TD链表指针设置成了循环链表。主控制器沿着链表跑,执行完一个TD后继续走下一个,但循环链表会让它在同一圈里永远转下去,中断风暴直接把系统打挂。正确做法是要么在最后一个TD上设置终止位,要么把链表末端指向一个特殊的状态节点。这个细节,不亲手踩一遍真的不会长记性。

3.4 轮询调度:中断传输不是“中断”

USB里的“中断传输”是我见过最名不副实的名字,它不是硬件中断,而是主机周期性轮询设备端点。对于全速HID键盘,bInterval字段通常是10,也就是主机每10毫秒必须对端点发送一次IN令牌,键盘才会把手头的数据交出来。我最初把IN端点设计成“发送一次就等着”,结果键盘初始化完成后一个字节都收不到。

解决方法是把IN端点注册进帧列表调度器,在每帧开始前检查该端点是否需要被轮询,需要则构造IN事务令牌,发送到总线上。轮询间隔必须严格按照bInterval执行,如果主机轮询太快,设备会NAK;太慢,按键响应会卡顿。这套调度机制看似简单,但要和已有定时器、调度器、中断状态机配合,一开始实现时总会漏掉“NAK之后还要继续轮询”这个基本事实,导致键盘用着用着就失联。

HID报告的解析也藏着坑。报告描述符里字段长度包含报告ID字节,很多键盘的报告ID会占据第一字节,直接按内存地址解析时如果把报告ID当成按键码,所有按键都会错位。我当时还在想:为什么我按A它输出B?好在打印原始字节后一眼就发现问题了。

3.5 真机排错的方式:日志、寄存器和“暴力超时”的取舍

写OS时最痛苦的事情是没有现成调试器。QEMU有monitor可以看任意内存和寄存器,真机只能自己想办法。我为USB栈专门做了一套调试命令:打印端口状态寄存器、打印所有QH/TD的地址和状态字、打印控制传输的完成码。最有效的调试手段反而是最简单的:在每个关键等待点写一个带超时的轮询,超时后把寄存器快照打印到屏幕,然后挂起。

另一个经验是:不要轻易相信“在哪一步失败”就是“那一步有问题”。USB设备的状态会因为时序变化而漂移,有时候复位成功,有时候失败,看起来像随机故障。实际上往往是某个环节的等待时间卡在临界值附近,比如供电稳定时间、复位恢复时间、描述符请求间隔。找到那个临界组合,把等待时间放宽到一个可靠范围,随机问题就变成确定问题了。

4. OS自举:让内核自己搬自己,是一次真正的“主奴切换”

4.1 从引导扇区出发的启动链路

在决定不再依赖GRUB之前,我的内核一直靠GRUB加载:它帮忙切到保护模式、解析ELF、设置分页,然后跳转到内核入口。这套流程太顺滑,以至于我完全没想过自己动手做这些步骤时会有多艰难。所谓的“OS自举”,其实就是要替换掉GRUB的角色,在512字节的引导扇区代码里完成全部启动准备。

我的启动链路大致是:

  • BIOS把MBR读入0x7C00,引导代码第一件事是先把自己复制到0x0600处,防止后续读取内核时覆盖当前正在执行的代码。
  • 从U盘的FAT32文件系统里读取内核镜像文件,或者从固定LBA区域直接读取内核字节流。
  • 关闭中断,加载自己的GDT表,设置CR0的保护模式位(PE=1)。
  • 执行远跳转清空流水线,进入32位保护模式。
  • 建立早期页表,开启分页(CR0.PG=1)。
  • 跳转到C语言内核入口,开始真正的系统初始化。

每一步之间的“缝隙”,全是坑。

4.2 高半区链接与分页打开的那一瞬

我的内核链接在0xFFFFFFFF80000000这个高半区地址上,也就是说编译生成的代码里到处都是高地址引用。但CPU上电时运行在低地址物理内存上,所以“进入保护模式、开启分页、跳转到高地址”这三步的顺序必须十分小心。

我在早期页表里做了双重映射:物理地址0到1GB映射到低半区(0x0000000000000000起),同时也映射到高半区(0xFFFFFFFF80000000起)。这样无论是低地址执行还是高地址执行,页表都能覆盖当前代码段。在这个阶段最容易出的问题,是页表项里忘记设置PS位(页面大小位),或者PML4/PDPT层级之间的地址填错,导致跳转到高地址后立刻Page Fault。

调试这个问题的难处在于:Page Fault发生时的现场已经被破坏了一半,你很难判断是页表项缺失还是跳转地址错误。我的办法是在跳转前把目标入口地址和CR3的值打印到屏幕上,然后人工核对页表结构。整个过程非常原始,但有效。

4.3 A20地址线:看起来像内存损坏的经典小坑

自举到真机之后,我遇到了一个特别诡异的现象:内核启动到某个阶段之后,某个全局变量会出现“幽灵变化”,明明没有任何代码写它,值却变了;内存分配器偶尔会报出地址冲突。我甚至怀疑过是物理内存损坏,差点去换内存条。

最后定位到A20地址线的问题。老式PC为了兼容8086的地址回卷行为,默认禁用第20条地址线。这意味着访问超过1MB的内存时,地址会回卷到0到1MB的区间。我在1MB以上的某个物理页里写数据,读出来的却是低地址区域里另一份内容的残影。两个完全不相关的变量占用了同一个物理地址,自然会互相覆盖。

修复方式有两种:通过键盘控制器8042发送命令打开A20,或者通过BIOS中断INT 15h的AX=2401h功能打开A20。在引导阶段,BIOS中断还在,所以直接调INT 15h最省事;进入保护模式后想再调整,就只能操作8042了。QEMU默认始终开启A20,所以这个问题完全不在模拟器里复现。真机测A20是我这个项目里“调试体验最差”的一晚,因为屏幕打印只能显示文本,无法表达内存地址之间的映射关系。

4.4 极简ELF loader的几个细节

自举意味着必须自己解析ELF格式的内核镜像。GRUB之前把这些事全包了,现在全部要自己干,这里面的细节密度远超想象:

  • program header要过滤类型。只需要处理PT_LOAD段,PT_PHDR、PT_NOTE这些元数据段直接跳过,否则会把ELF头当代码加载进内存。
  • bss段必须清零。它不在文件里占空间,但加载器必须知道它的起始地址和长度,把这段内存清零。我一开始忘了这一步,内核里所有未初始化的全局变量都是旧内存的残留,调度器跑起来后各种随机崩溃。
  • 加载地址和链接地址必须对齐。链接脚本里我把.text、.data、.bss按页对齐,加载器就应该按p_vaddr读入;如果直接按文件偏移读,读进来的是ELF头部和节区表,而不是可执行代码。
  • 入口点要从ELF头部的e_entry字段读取,不能默认是0x100000或者某个固定地址。如果你在链接脚本里指定了入口符号,也要检查e_entry是否对应那个符号的地址。

4.5 从U盘启动的策略选择

为了降低初版自举的复杂度,我一开始走了捷径:内核镜像直接放在U盘固定的LBA区域(比如LBA 2048开始),引导代码按扇区编号连续读取,完全绕过文件系统。优点是代码只有几百字节,异常稳定;缺点是你没法像普通文件一样往U盘里拷内核,必须用专门的写入工具。

等到引导代码稳定之后,我才补上了FAT32解析:读取BPB参数块里的每扇区字节数、保留扇区数、FAT个数、根目录起始簇号,按簇链遍历文件,找到KERNEL.BIN后按簇读取内容。过程中遇到的坑是分区表偏移:U盘的分区起始LBA不一定为0,MBR里的分区表项记录了起始位置,引导代码必须先解析分区表,把LBA加上分区起始值,否则读出来的就是U盘最开头的引导区内容,永远不是你要的内核。

如果你也想做自举,我强烈建议先走“固定LBA”路线把启动链路跑通,再升级到文件系统解析。一步到位往往会同时面对多个问题,排查起来非常痛苦。

5. 当前状态与写OS同好的一点心得

5.1 现在的操作系统能做什么

折腾完这三个大坑之后,系统现在的状态是:能从U盘自举到高半区内核,支持FAT32文件系统读取,有串口和VGA输出,有完整的中断异常处理,能管理四级页表,能创建和切换进程,有带脏块跟踪的块设备层和文件系统同步机制,关机时会正确flush所有挂载设备,USB键盘可以稳定输入,崩溃恢复测试已经跑过上百轮。

“从45个BUG到165个”这个过程,让我明白了一个道理:操作系统项目最难的不是那些宏大设计,而是把数据真正写进磁盘、把设备状态机调到稳定、把自己从bootloader手里安全接管过来这些基础动作。每一件看起来理所当然的事情,深入到硬件层面都是无数个细节堆出来的。

5.2 关于“BUG数量上升”的心态调整

如果让我给同样在写OS的人一个建议,我会说:不要害怕BUG数量上升,要害怕的是“所有测试都通过,但系统其实在悄悄损坏数据”。我做假持久化修复之前,所有测试都显示文件系统工作正常,可一旦断电就全丢。这种“假稳定”比明确的BUG可怕得多。

建议把可观测性当成第一优先级。串口日志、panic回调、寄存器dump、trace buffer,这些基础设施越早做越好。我在USB调试期间没有逻辑分析仪,全靠自己打印寄存器状态硬扛,效率低到想哭。后来加了一个简单的8通道逻辑分析仪,一次USB传输里到底有几根信号线变了、时序对不对,一眼就能看出来。

5.3 给想入坑的人的工具链建议

最后整理一份我实际用下来最顺手的工具组合:

  • 开发调试主环境:QEMU配合自定义机器模型,可以快速迭代大部分内核逻辑,但一定要时刻记住“QEMU能跑不代表真机没问题”。
  • 真机测试:准备一台便宜的旧笔记本或者几块U盘,专门用来做启动测试和掉电测试。真机的硬件时序、A20行为、USB枚举行为,都是模拟器的友好版之外的真实面目。
  • 逻辑分析仪:对USB、串口、磁盘这类有明确时序信号的外设调试帮助极大,几十块入门级就够用。
  • 版本控制配合小步提交:每次自举、枚举、崩溃修复,都要能做到“改一笔提交一笔”,否则环境型故障出现时,git diff是唯一能帮你定位“到底改了什么导致坏的”的工具。
  • 从崩溃中恢复的测试脚本:早期就写好,不要等文件系统完整了再补。

后续我打算把USB Mass Storage支持做完,让内核能直接从U盘读写FAT文件,而不是只读。现在每次按下电源到屏幕上跳出那一行内核启动日志,心里的感觉和当初在QEMU里看到hello world完全不一样。那种自己写的代码在真实硬件上一点点爬起来的过程,大概是写OS最上头的部分了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询