如果只盯着题目里的“45个BUG到165个”,你大概率会以为这项目越修越烂,代码质量崩了。实际上恰恰相反——写操作系统的骨架部分,启动、分页、中断、任务调度,这些做完之后我手头可复现的BUG是45个。等我把文件持久化、USB协议栈和“让OS自己编译自己”这三座大山全踩一遍,可复现BUG涨到了165个。原因不复杂:测试范围从“只能在QEMU里跑通”扩大到了“真机断电、拔U盘、在自己的OS里跑编译链路”,每多一类场景,就会多冒出一大片原来藏得很深的边角问题。这篇文章就是这三块硬仗的完整拆解,写给正在写OS或者正准备跳进这个坑的人。
1. 先说结论:为什么BUG不降反升
1.1 这45个BUG是怎么来的
我容易先解释一下BUG是怎么数的。不是随手翻代码看到可疑点就算,我的标准是“有明确复现路径”。比如切换任务后打印线程ID,隔几次触发一次页错误;或者连续fork十几个进程之后,某个地址突然被映射到同一块物理页。这类问题我每修一个,就补一个对应的回归测试,然后写进一个固定脚本。基础版OS完成时,这个脚本里有大概120条用例,对应的可复现BUG数就是45。
这些45个BUG主要集中在中断抢占与锁竞争、页表切换时的TLB没及时flush、内核堆的越界写坏等等。它们看起来很多,但都属于“教科书上会警告过你,但你必须亲自撞一次才记得住”的经典问题。修完45个以后,系统在模拟器里跑一个简单的shell、几个演示程序,已经很顺了。
然后我就飘了。
1.2 加了三个子系统,BUG总数反而朝165狂奔
新增这三个模块之前,我自己预判是“最多加到60个”。真做下来,数字直接跳过一百奔一百六五,原因其实特别朴素:
- 文件系统让“程序不再活在内存里”。程序一重启,数据要能从磁盘回来,这引来读写时序、缓存同步、异常中断恢复的成堆问题。
- USB把“外部设备交互”从模拟的串口和PS/2键盘,换成了真实硬件协议栈。你面对的不再是自己简单轮询的8259A中断,而是端口状态、设备枚举、传输描述符调度、U盘的SCSI指令。
- 自举要求OS内部具备一个完整的类POSIX用户态环境:fork、exec、管道、重定向、文件权限或者至少是读写权限的模拟。这些东西本身就能让BUG总数再翻一倍。
所以165这个数字,我的解读是“系统的复杂度真实上升了”。如果做完这些改动BUG数还是45,那你反而应该怀疑自己的测试是不是根本没覆盖到关键路径。
2. 假持久化:看似写进去了,其实断电就没了
2.1 “假”在哪一步
先说我口中的“假持久化”到底是什么。
最初我做文件系统的时候,思路很简单:实现一个FAT32兼容的驱动,把我的shell里能创建的文件写到这个分区上。为了性能,我在内核里构造了一个类似Linux page cache的结构:把磁盘扇区缓存到内存,读文件先查缓存,写文件先写缓存,然后定时把脏块统一写回磁盘。
这套东西在QEMU里跑起来没有任何问题。创建文件、写内容、关机、再启动,文件还在。我当时还挺得意,觉得持久化也不过如此。
直到我拿真机测试。
我在一块32G的U盘上格式化出一个FAT32分区,编译好内核镜像,写入,启动,创建了一个测试文件,写入4096字节内容,然后直接按主机电源键强制断电。再启动,文件确实还在,但是内容全是零。再试一次,连文件大小都变了,目录项里记录的大小和FAT表里实际分配的簇数对不上。
这就是标准“假持久化”:看起来写入成功了,数据其实只在内存缓存里,根本没真正落到盘上。
2.2 写序才是持久化的核心
排查到最后,问题不只是“没落盘”,而是落盘的顺序完全错了。
我当时通过缓存定期回写,但流程是:先更新目录项,再更新FAT表,最后才写数据内容。掉电正好发生在FAT表更新之后、数据块写入之前,磁盘上就会出现一个目录项声称文件占用N个簇,但那些簇里的数据还是旧内容,甚至全是零。
这个问题非常经典。FAT文件系统不像日志文件系统那样有事务概念,你要保证掉电一致性,只能靠严格控制写序。
我最后采用的写盘顺序是这样:
- 先分配好干净的簇,把文件的数据块直接写入对应的扇区;
- 再更新FAT表,把分配关系记上去;
- 更新目录项里的起始簇号、文件长度和时间戳;
- 回写FAT表的两份副本,确保元数据有冗余;
- 最后更新卷上的“干净卸载标志”并把FSInfo扇区刷新。
这个过程不能反。如果我先把FAT表标记了簇已分配,万一掉电,数据块区域可能还是旧的,就会出现文件指向垃圾数据。反过来先写数据块,最多出现“有些簇占用但没有任何目录项指向”的孤儿簇,这种问题用一次启动扫描就能清理。
2.3 我做了一个最小化的掉电恢复机制
为了解决孤儿簇问题,我加了一个非严格意识但实际有效的机制。
在FAT32卷的保留扇区里,我留了一个自定义标志字。系统正常卸载时把它置为“干净”,每次挂载的时候检查:如果是“干净”,直接使用;如果不是,就扫描一遍整个FAT表,把所有标记为占用但又没有被任何目录项引用的簇链,全部释放掉。这本质上是一个非常简化的FSCK。
这个方案和完整日志文件系统相比粗糙很多,但对自己的OS项目来说足够用。好处是我不需要为每个写操作都准备复杂的journal记录,只需要维护好“写序 + 脏标志 + 启动扫描”这个铁三角。
2.4 验证真持久化的实测姿势
假持久化变成真持久化之后,我专门做了一轮比较狠的验证方式,这里分享给大家。
- 在QEMU里,用一个raw格式的磁盘镜像作为FAT32盘。写一个脚本,循环写入不同大小的文件,然后随机执行
killall qemu-system-i386模拟掉电,重启后跑文件完整性校验脚本。 - 在真机上,用串口把系统日志输出到另一台电脑,然后反复执行“写入文件 -> 立即拔U盘不卸载”的操作,再插回主机,用一台正常Linux机器执行
fsck.fat -v检查卷状态。
这两轮操作之后,我才敢说这个持久化不是“假”的。最终测试跑下来,数据损坏率降到了零,但孤儿簇清理逻辑确实触发过好几次,说明写序规划还是有意义的。
3. USB地狱:握手、枚举、带宽、事务翻译
3.1 为什么非要碰USB
最初为了避免麻烦,我在键盘输入上用的是PS/2。QEMU里和真机上都稳,代码也就几百行。但玩操作系统的朋友都知道,真正的现代设备接口是USB,连工业化键鼠很多都直接占用XHCI控制器。PS/2虽然省心,说白了是躲进舒适区。
我给自己定的目标是“至少支持USB1.1键盘和USB2.0 U盘”,这个目标听起来不大,但需要处理主机控制器里UHCI/EHCI两套体系,还要处理设备枚举、中断传输、批量传输、SCSI命令、事务翻译器等等。这一块是目前整个项目里BUG数量增长最快的地方。
3.2 枚举过程里的时序坑
USB设备枚举看起来是一个标准状态机:检测端口连接变化、复位端口、读取设备描述符、设置地址、读取配置描述符、设置配置。但每一步之间的延时要求都非常严格,漏掉一个都会导致设备无法识别。
我挨个踩过下面这些坑:
- 设备插入后,端口状态寄存器的“连接状态变化”位置1,此时不能立刻复位端口,需要至少等待100ms让设备电源稳定;
- 复位完成后,需要读端口状态寄存器,看到“已启用”位到位后,还要再等10ms再发起控制传输;
- 向地址0发送SET_ADDRESS命令后,必须要等待10ms以上,不能马上对新的设备地址发起传输,很多U盘对时序特别敏感;
- 读取设备描述符,第一次只读前8个字节,拿到
wMaxPacketSize0,再用这个参数读取全量的18字节设备描述符,如果一开始就要求全量,低速设备会直接STALL。
这一连串时序要求,我在QEMU里很难100%暴露问题,因为在模拟器里,udelay几乎就是空转几个CPU周期,根本不会真的等待。上了真机,这些延时一错,设备就掉线。
3.3 低速设备与事务翻译器的问题
USB 2.0的EHCI主机控制器本身只负责全速和高速设备,低速键盘鼠标挂在EHCI的根端口上时,实际必须由控制器里的事务翻译器来处理,或者由系统把端口ownership交给一个配套的UHCI控制器。
我一开始没有处理这个逻辑,直接把低速键盘当作普通设备走EHCI的端口,结果就是键盘灯亮一下,之后就再也没有任何响应。这个差点让我以为是键盘坏了。
后来我打印了端口状态寄存器的每步变化,才理解EHCI要求你在端口检测到低速设备后,设置端口ownership切换位,把控制权交给UHCI。这个过程在QEMU里用什么设备模型都很难触发完整路径,真机上就特别赤裸裸。
3.4 U盘批量传输的额外一重“地狱”
USB键盘还只是中断传输,U盘则是批量传输,还要叠加一套USB Mass Storage(BOT协议)和SCSI命令。
有个问题差点让我怀疑人生:文件写成功了,读取也正常,把U盘拔下来插到电脑上,Windows提示“文件或目录损坏且无法读取”。仔细排查后发现,U盘逻辑扇区大小不是512字节,而是4096字节。我的存储驱动默认按512处理,写出来的每个“扇区”实际上只填满了U盘一个逻辑块的八分之一,文件系统自然就乱了。
正确的做法是在初始化阶段向设备发送SCSI Inquiry命令,从返回值里读取逻辑块大小,所有后续读写都按这个值来。在那之后,我还在枚举流程里加了一个环节:读取U盘的Capacity列表,确保簇大小计算和扇区大小匹配。
3.5 USB调试工具与思路整理
调试USB这类协议,最忌讳的是靠肉眼盯着代码瞎猜。我的调试三板斧供参考:
- 用串口输出,别用屏幕输出。USB驱动出问题时,屏幕驱动本身很可能也刚好处于“半坏不坏”状态,VGA打印不可靠。我所有USB调试信息都走串口,在主机上开一个
minicom直接看。 - 在QEMU里过基本流程,真机过时序。任何USB功能我都在QEMU里验证“逻辑对”,然后立刻搬到真机验证“时序对”,否则很容易被模拟器的“假快”欺骗。
- 给每个USB状态寄存器变化加日志。比如UHCI端口状态寄存器每一位变化,都打印出来,这样能明显看到设备插入、复位、启用、断开的过程。
最终我把真机上遇到的几类高频问题整理成了一个小速查表,见下表。
| 症状 | 常见原因 | 解决方案 |
|---|---|---|
| 复位后设备无法启用 | 没有做电源稳定延时 | 连接变化后等待100ms |
| SET_ADDRESS后设备无响应 | 没有等待10ms | 控制传输间隔至少10ms |
| 键盘在批量传输时失灵 | interrupt传输调度被批量任务饿死 | 在USB调度里保证每帧留出中断传输槽 |
| 读U盘数据乱 | 逻辑块大小假设512 | 从SCSI Inquiry读取真实逻辑块大小 |
| 真机USB口全不认 | 新主板把端口全部交给XHCI | BIOS开启Legacy USB,或先插USB2.0口 |
4. OS自举:在自己的系统里编译自己的内核
4.1 这里的“自举”到底是什么
“自举”这个词在硬件领域有自举电容、自举电路,容易让人产生误会。我说的是软件层面经典的“self-hosting”:让你的操作系统成为一个足够完整的开发环境,能够亲自完成编译、链接、打包自己内核的全过程。
我不是从零写一个GCC,那工作量太不现实。我的路线是移植了一个轻量C编译器到我的OS上,然后让整个系统里有一个buildos.sh脚本。这个脚本负责调用编译器、汇编器、链接器,把内核源码和用户态工具链编译成最终的启动镜像。当这个脚本在我的OS里能够跑通,并且生成的新镜像重启后还能正常启动,就意味着OS具备了自己构建自己的能力。
4.2 前置条件:你的内核必须像一个真正的操作系统
这里有个工程师很容易低估的地方——自举的前置条件比想象中高得多。
要在自家里编译内核,你至少需要:
- fork和exec能正常工作,编译器作为一个用户态进程可以启动;
- 内存管理要足够灵活,编译器编译大文件时动不动申请几十MB地址空间;
- 管道和重定向必须要稳定,因为构建脚本会把编译输出喂给文件或过滤程序;
- 文件系统不仅要能读写,还要能覆盖创建、删除、截断等各种组合场景。
我一开始以为只要能跑起一个“helloworld.c”就算成功,实际把整个内核源码丢进去编译时,用户态malloc的碎片问题就暴露了。编译器在分配符号表时反复malloc和free,我的内存分配器如果有合并策略,就会把堆空间越搞越碎,最终地址空间耗尽。这个锅是我的malloc,不完全是编译器的。
4.3 踩到的几个非常心痛的自举问题
- fork之后父子进程内存重叠。原因是我早期用的是4MB大页映射,fork简单拷贝页目录项,结果父子进程实际映射到了同一批物理页。子进程写入数据时,直接把父进程内存改写了。修了好久才发现是物理内存分配器的引用计数没被正确维护,而大页映射让问题更加隐蔽。
- 管道缓冲区只有4KB,编译时子进程print信息太多,写端阻塞在pipe上,读端进程又还没被调度,形成死锁。解决方法是给字符设备驱动加上阻塞队列,并且保证写阻塞后,读端唤醒逻辑能正确找到等待队列。
- exec加载ELF文件时,把BSS段清零和代码段映射的顺序搞反了,导致某些大型目标文件加载后运行时直接跳到一个全是零的地址。这个在QEMU里其实也会稳定复现,修掉之后整个系统稳定不少。
- 构建脚本里的“rm -rf build”直接失败。我的rm命令不支持递归删除目录,然后整个脚本就停在那里。这种小问题虽然不深刻,但特别真实。
4.4 自举成功的那一刻
完成自举的验证方式我选得很直接:在我自己的OS里执行./buildos.sh release,编译大概半个多小时,中间经过几次源码级调试,等脚本结束后,磁盘上出现一个新的boot.img。我记录下它的SHA256,然后重启,让这个新内核接管系统,它能正常进入shell,并且启动时打印出一个由自举脚本写入的内核构建时间戳。
这一步跑通之后,我觉得165个BUG这个数字十分值得。“从零写OS”和“写一个能自己编译自己的OS”完全是两个难度级。前者像是在平地上搭积木,后者是你要确保每一块积木都有足够的承重能力,让人站在上面继续搭新的一层。
5. 常见问题与排查技巧实录
5.1 我的排查方法论
写到这里,我把这三个月里最常用的一套排查方式分享出来。
第一,软件问题与硬件问题分开。遇到任何一个BUG,我首先判断能不能在QEMU里复现。如果QEMU里能复现,那大概率是逻辑问题,直接单步调;如果QEMU里复现不了,那大概率是时序或者真机设备兼容问题,马上切换到串口日志,把每一处寄存器和状态都打出来,对比两边的差异。
第二,保留每个“能跑”的版本。我每次修完一个Bug,都会用git打一个tag,同时把当时的构建镜像存档。不要嫌麻烦,很多新Bug都是在你改了某个模块之后冒出来的,没有旧镜像,你很难判断是自己的新改动导致回归,还是老版本本来就有问题。
第三,写回归测试,并且坚持“没有测试就不能关闭issue”。这是我维持住165个可复现BUG统计的主要手段。例如持久化相关Bug修完之后,我写了一个脚本,在镜像里创建几百个文件,再随机执行断电复现,跑50轮才算通过。
5.2 给新手写OS的几条实在建议
- 不要在真机的USB3.0蓝色口上调试自己的USB驱动。新主板默认把USB3口交给XHCI,你写的EHCI驱动根本接管不到。要么插USB2.0黑口,要么在BIOS里把Legacy USB打开。
- 串口是你的第一生命线。屏幕输出在文件系统或者USB出问题时经常跟着一起卡死,但串口只需要一个UART芯片和一根线,稳定得多。
- 保存好你每一个“能跑”的版本,写一个脚本自动构建加回归测试。我自己会在每次提交之后自动跑一遍全部用例,如果挂了,立刻能看到是哪一个regression。
- 持久化类的开发,先想写序再想性能。你不需要一上来就做一个完整的日志文件系统,但只要能把“先数据、后元数据”这个原则做好,就已经超过很多粗糙实现。
- 真机测试前先把代码跑在QEMU这类模拟器里。模拟器不能代替真机,但它可以把90%的逻辑问题暴露出来,省掉大量插拔U盘、反复开关机的体力劳动。
Linux里有一句话,“你花在调试上的时间,最后都会以更少的生产事故回报你。”我觉得这句话在从零写OS这个项目里尤其适用。那45个到165个的bug,每一个都代表着一个我原本完全没意识到的边界条件或时序陷阱。自己写过一遍之后,再看那些成熟操作系统里的设计,你才会明白每一个看似繁琐的步骤,背后可能都有一段惨痛的历史。
最后再分享一个小技巧。我在项目目录里放了一个bugs.md,每次修完一个可复现BUG,都用一行字记录“触发条件、根因、修复方式”。这既方便回溯,也算给自己攒了一份操作系统的踩坑地图。后面如果哪天真要把它写成一份完整教程,这些记录就是最靠谱的一手素材。