我上次写这个中文OS系列的上篇时,系统里一共记了45个BUG,评论区有人问我是不是整个项目就靠“数BUG”来推进。我当时也没想到,下篇写到一半,BUG数量直接飙到165,翻了三倍还多。你别误会,这不是越修越烂,而是我这套系统从“能启动、能执行代码”的演示阶段,正式走进了“要存文件、要接U盘、要能自举开发”的真实世界。这一篇我不打算按时间线记流水账,重点讲三件让我印象最深的事:假持久化、USB地狱、以及用自己写的系统来开发自己这件事。
这篇文章适合谁看?正在从零写玩具OS的人、操作系统期末复习却总觉得“文件系统和设备管理好抽象”的同学、以及被USB协议栈折磨过的嵌入式开发者。我会把踩坑过程、定位方法和最终方案都写清楚,保证你不是只看到一个标题,而是能真的拿来参考。
1. 先说BUG数:45涨到165,是系统在长大,不是在变质
很多人看到“从45个BUG到165个”第一反应是:这项目是不是失控了?我一开始也差点这么想,后来把Bug台账拉出来一分析,发现这个数字变化完全是系统规模膨胀的正常结果。上篇我做的是启动流程、GDT/IDT、分页、中断和内存分配,这些都是相对封闭的模块,输入输出好控制,调试也方便。下篇开始加文件系统、块设备层、USB协议栈和Shell,任何一个新模块都会带来一大批全新类型的BUG。45个BUG时系统大概几千行代码,到165个BUG时系统已经超过了两万行,BUG数量和代码量一起涨,这不是什么玄学。
1.1 为什么修着修着BUG越来越多
首先,新代码必然带新错误。文件系统要对磁盘块做分配和释放,块设备层要处理设备命令的异步完成,USB协议栈要做设备枚举和端点调度,这三层叠在一起,任何一层出错都会影响到上层,表现出的症状五花八门。很多BUG不是新写出来的,而是新功能把旧模块里没暴露的薄弱点给逼出来了。比如之前我的中断处理程序只处理时钟和键盘,当USB控制器开始产生中断时,我才发现中断控制器初始化少配了一个电平触发寄存器,这种问题在之前的测试中根本不会出现。
其次是回归。修复一个BUG经常会把其他地方搞坏。我遇到过最典型的例子:为了修复FAT32目录项缓存不一致的问题,我改了内存里的文件控制块结构,结果Shell里所有关于文件大小的统计全错乱了。因为文件控制块有两个字段分别缓存文件逻辑大小和实际扇区数,我只更新了前者,后者还是旧值。这类“按下葫芦浮起瓢”的情况,在个人项目里特别常见,因为没有大厂的CI流程,全靠自己手动回归。
1.2 我的BUG台账长什么样
个人项目最大的问题不是代码写不出来,而是BUG记不住。我见过太多人修完一个BUG,过两周又因为同样的原因栽一次。我大概在30多个BUG时开始建了一份Bug台账,用一个Markdown文件和一个脚本管理,每条记录包含现象、复现步骤、期望结果、实际结果、定位方向、根因、修复方案、回归测试这几个字段。每修完一个BUG,我会把修复后的验证结果也写进去,这样后面排查相似问题能直接检索。
我给BUG分了四级,这个分级方式参考了系统软件行业常见的Severity分类,但不完全照搬:
| 级别 | 定义 | 我的处理方式 |
|---|---|---|
| P0 | 系统无法启动、会丢数据 | 必须立刻放下手头所有事处理 |
| P1 | 核心功能不可用,但有临时绕行方案 | 当天处理,先给临时方案再彻底修 |
| P2 | 功能可用但行为不符合预期 | 按迭代计划处理,记录在案 |
| P3 | 边界情况、兼容性瑕疵 | 有空再处理,不影响主流程 |
这套台账最大的作用是心理层面的:当你看到同一类BUG被后台自动归组时,你会意识到某个模块的薄弱不是偶然,而是设计上缺了东西。比如我们下面要讲的“假持久化”,第一次出现时我记为P2,第二次出现同类问题时我直接提到了P0,因为它已经开始导致数据丢失了。
1.3 BUG与“特性”的灰色地带
还有一类问题很难界定是BUG还是功能过度设计。举个具体例子:我的FAT32实现早期只支持短文件名,长文件名完全忽略。有人会觉得这算功能缺失,不算BUG,但在我的使用场景里,当我在U盘上创建名为“README_2024.txt”的文件时,系统把它截断成“README_~1.TXT”显示也能用,这个就不算严重BUG。可是当shell脚本里依赖长文件名做匹配时,文件就打不开了。我的判断标准很简单——只要用户输入了合法内容但系统没按预期响应,就记为BUG,不管是不是功能边界的锅。
2. 假持久化:数据看似写成功,重启一刻全清零
“假持久化”是我给这个BUG起的名字,实际现象可以用一句话概括:在系统里写入文件,写入后马上读,内容完全正确;但一旦重启或断电,之前写的文件内容会部分丢失,甚至文件整个消失。第一次遇到时,我还以为是我买的U盘坏了,又换了一个,结果问题依旧。最后才发现,这不是硬件问题,是我自己的文件系统写路径在设计上就有缺陷。
2.1 到底什么叫持久化
做应用开发的同学可能都写过类似write(fd, buf, len)这样的代码,但很多人没想过一个问题——write返回成功,到底代表什么?它只代表数据被内核接收了,不代表数据已经落到了磁盘上。现代操作系统为了让写入更快,会在内存里保留一份缓存,真正的设备写操作被推迟到合适的时间批量执行,这类缓存叫Page Cache或Buffer Cache。用户程序写入时,数据先进入缓存,然后根据策略要么同步写盘(write-through),要么等脏页积累到一定程度再回写(write-back)。
生活里可以这么理解:文件系统像一个仓库管理员,内存缓存是仓库门口的临时货架。用户把货放到临时货架上,管理员就说“收下了,办好了”。但如果管理员不把货从临时货架搬进仓库,而是直接下班回家了,第二天你来取货时货架已经被清了,货自然也没了。write返回成功只是“放到货架”,持久化要求的是“放进仓库”。
2.2 我的“假持久化”是怎么出现的
我最初实现FAT32文件系统时,为了让写入性能好看一点,在块设备层之上加了一个写缓存。这个缓存实现了一个非常简单的“回写”逻辑:标记脏块,然后在某个周期点统一写回磁盘。但问题在于,我的块设备层在缓存写回失败时,并不会向上层返回错误,更致命的是,很多写路径在拿到“缓存已接受”的结果后就直接返回了成功。
同时,我的FAT32还有另一个问题:目录项和数据区内容的写入时序不对。FAT文件系统结构中有文件分配表和目录项,这两个元数据跟数据区域是分开的。我在创建文件时,先把文件数据写进数据区,再更新目录项。看起来顺序是对的,但我没有把这两步作为一个原子操作来保证。系统如果在数据写完后、目录项更新前发生重启,就会出现“文件数据其实在磁盘上,但没有一个目录项能指向它”的状态,表现出来就是文件丢失,或者文件大小为0。
还有一个让我格外印象深刻的场景:我通过系统里的Shell创建一个文本文件,写入了大约200KB内容,然后立即重新打开读取,内容完全正常。但当我执行reboot命令重启系统后,文件还在,打开后长度只有160KB,后面一部分数据变成了垃圾内容。后来排查发现,这个不是FAT表的问题,而是我的块设备驱动只把数据写到了设备内部缓存里就返回了完成。硬盘或U盘控制器普遍有自己的内部高速缓存,如果主机发送写命令后不等到设备报告“写完成”就继续下一步,设备里的缓存数据可能还没真正落到介质上,断电后那些扇区还是旧内容。这类问题在真实硬件上最容易暴露,QEMU模拟的磁盘反而是“完美设备”,永远即时写盘,所以很多在虚拟机上跑得好好的系统,一到真机上就各种丢数据。
2.3 定位假持久化的三板斧
第一个方法是“重启验证法”,听起来很笨但最有效。写一个测试脚本,往文件里写入固定模式的数据,写入完成后强制重启,重启后读取文件并比对内容。如果每次都能通过,基本说明持久化链路是通的。我把这个脚本固化成了一个启动时自动运行的内核测试,每次系统启动后自动跑一遍,通过就打印一条“persistence test passed”。这个习惯帮我回了不少坑。
第二个方法是“磁盘镜像直接检查法”。在QEMU环境下,退出虚拟机后,直接hexdump磁盘镜像文件,看目标扇区的内容是不是真的发生了修改。有时候你会很惊讶地发现,写入操作压根没走到设备层,只是改了一段内存缓存,所以磁盘镜像里的对应扇区完全是旧数据。这说明问题不在设备驱动,而在上层的缓存策略或flush流程。
第三个方法是“接线抓包法”,这个主要针对真机和USB设备。把USB分析仪或逻辑分析仪接到设备和主机之间,抓取实际在总线上传输的SCSI命令,看有没有发WRITE10、WRITE16命令,以及设备返回的STATUS是什么。我后来自购了一个入门级USB分析仪,才发现很多我以为是“驱动层逻辑错误”的问题,真实原因是命令根本没发出去,或者发了但设备返回CHECK CONDITION我没处理。
2.4 从write-through到write-back,我最终选择了什么
最开始我为了性能直接上了write-back缓存,但以我的时间精力,很难把脏页回写、断电一致性、RAM和磁盘内容同步这些问题在短期内全部做对。所以我做了一个务实的取舍:在系统成熟之前,先把文件系统设为write-through模式,也就是所有写入都同步穿透到设备,设备返回完成才向上报告成功。性能确实掉了一截,但换来的是不掉数据。你写一个页面,我就真的把这个页面发到磁盘,等到设备告诉我“写完了”,我再返回成功。对于开发中的操作系统来说,正确性远比性能重要。
后续等系统稳定了,我再考虑把真正意义上的write-back缓存加回来。包括脏页替换策略、定期刷盘、卸载和重启前强制sync,这套完整流程需要在所有写路径上保证一致性,不是简单加一个链表就能解决的。我个人给其他做玩具OS的朋友的建议是:如果你不是在研究缓存算法本身,前期直接用write-through,命中的问题少得多。
3. USB地狱:从UHCI/EHCI握手到设备枚举的连环翻车
USB这部分我本来想放在更后才做,因为实现USB主机控制器驱动的工作量不亚于一个迷你文件系统。但现实条件不允许:我想让系统支持U盘启动,还想让键盘鼠标用的不是老式PS/2口,所以USB是绕不开的。很多人以为USB地狱是指协议复杂,实际做下来发现,协议复杂反而是最小的问题,真正折磨人的是设备多样性。
3.1 为什么一个玩具OS会去碰USB
做OS的都知道,早期调试靠串口最稳,串口线一接,日志哗哗往外打。但串口在现代消费级主板上越来越稀缺,很多笔记本根本没有串口。用PS/2键盘倒是简单,可PS/2接口的键盘也不好找了。U盘更是实际情况,因为想把新内核拷进测试机器,总不能每次都用光驱刻盘。让系统支持USB,本质上是让系统具备连接现代外设的基本能力。
USB协议栈要做的事情,大体上包括主机控制器驱动的初始化、根Hub和外部Hub的端口管理、设备枚举、端点配置、以及各种传输事务的调度。我从UHCI开始做,后来发现很多真实主板的主机控制器是EHCI(USB 2.0高速),又回头补了EHCI的部分。
3.2 USB系统到底难在哪
难在层级太多,排查问题时要同时面对好几层。常见的有电气层、链路层、协议层、类驱动层。设备枚举就是典型的链路层和协议层混杂的过程:主机先给设备复位,让设备到一个默认地址0,然后发送GET_DESCRIPTOR请求获取设备描述符,接着给设备分配一个新的地址,再获取配置描述符,最后配置设备。每一步都有超时和重试的机制,任何一步失败,设备都无法识别。
四种传输类型也是初次接触时最容易混乱的地方:控制传输用于枚举和命令下发,批量传输用于U盘这类大数据量的传输,中断传输用在键盘鼠标这种周期性输入但数据量小的场景,等时传输则用于音视频这类对时间敏感的场景。最要命的是,不同类型传输的调度规则不一样,全速设备和高速设备的帧周期也不一样,控制器内部的队列头和传输描述符需要按硬件规范组织得严丝合缝,稍微错一个字节,控制器可能直接宕掉,连日志都不给你机会打。
3.3 实测踩坑:四个让我熬夜的USB问题
| 问题 | 现象描述 | 根因 | 解决办法 |
|---|---|---|---|
| EHCI主机控制器所有权没有交接 | USB根端口无法复位设备,寄存器写入无响应 | 主板上BIOS默认占用了EHCI控制器,OS没有做所有权交接 | 找到PCI配置空间里的Legacy Support寄存器,写命令触发Owner Handoff,等待状态位翻转 |
| Set Address后设备“失联” | 设备枚举第一步GET_DESCRIPTOR正常,分配地址后再发请求设备无ACK | 地址端口的切换时序不对,设备没有在指定时间内完成状态阶段 | 严格按USB规范,Set Address后等待至少2ms控制传输完成再发新请求,同时清掉上一次传输的STALL状态 |
| 中断传输轮询间隔设置错误 | 键盘经常漏字符,USB鼠标事件丢失 | 中断端点的bInterval字段解析错误,全速设备以1ms为单位,高速设备以125us为单位,我按固定值处理了 | 根据设备速率动态计算轮询间隔 |
| U盘BOT协议CSW状态不等于成功 | 写入文件后偶发校验错误,重启后数据错乱 | 批量传输完成后没有检查CSW,命令状态字显示操作失败但驱动视而不见 | 在CBW/CSW中间增加状态机检查,失败时重启重置该设备 |
这四个问题里的第一个,尤其值得展开说。EHCI控制器有一个很隐蔽的特性:系统BIOS在启动初期会占据控制器,把USB设备模拟成传统键盘和U盘,让用户能在BIOS设置界面使用它们。OS接管时,必须先在PCI配置空间里找到EHCI的“USB Legacy Support”扩展能力,把ownership从BIOS手里拿过来。我一开始没做这一步,结果初始化寄存器全部“成功”,但根端口复位却毫无反应,设备完全枚举不出来。这个坑在QEMU里基本不会触发,因为QEMU默认BIOS不做USB Legacy模拟,所以我最初完全没往这个方向想。后来拿到真机上测试,才在查阅主板芯片资料时发现还有所有权交接这个步骤。
第二个问题也是只会在真实设备上遇到的。USB规范里设备初始地址是0,主机在枚举过程中会发一个SET_ADDRESS请求把设备地址改成例如1。协议规定,设备在收到SET_ADDRESS后,必须在最长不超过2毫秒的时间内切换到新地址上。我当时的实现是:发完SET_ADDRESS请求后立刻继续下一轮控制传输,没有等待2ms,导致后面的请求还在往地址0上发,设备已经切到地址1了,自然没有ACK。这种问题在慢速枚举顺序里不明显,但一旦你尝试把枚举流程做快,就会触发。
3.4 调试USB的趁手工具
USB调试最怕的是“只有现象没有数据”,这时候工具比脑子重要。我主要依赖这几样:
- QEMU的虚拟USB设备:在虚拟机上先把协议栈跑通,QEMU的
-device usb-kbd、-device usb-storage这类参数可以帮助你模拟键盘和U盘。QEMU的USB设备实现非常规范,能在虚拟机里通过,说明基本协议流程是通的,可以放心拿去真机上验证。 - Linux下的Wireshark抓USB包:在Linux主机上用
usbmon模块可以把USB总线上传输的数据包抓下来,再用Wireshark分析。这个方案主要用来调试“U盘插到我的OS开发机上”时的行为,能清楚地看到设备枚举阶段主从双方交互的原始数据包。 - USB转串口工具:调试真机时,系统没有网络栈,甚至没有显示输出,这时候串口日志就是唯一的信息来源。我用过FT232R和FT231X这类USB转UART芯片做的调试线,把内核启动输出引到另一台电脑上。一套能稳定工作的串口调试环境,比任何调试器都重要。
- USB分析仪:如果预算允许,建议备一台入门级USB分析仪,可以实时抓取总线上所有事务,包括SOF帧、SETUP包、IN/OUT事务和握手包。它比逻辑分析仪方便的地方在于可以自动解析协议层内容,不像逻辑分析仪还得自己根据波形去对应位域。
我自己的经验是:USB协议栈的调试不能靠猜,必须靠包。每一次时序异常,总线上都会留下痕迹,只要有包在手,定位起来并不难。难的是你手里没有包,全靠现象反推,那才是真正的“地狱”。
4. 用自己写的OS来开发OS:自举这件事的实践路径
标题里的“OS自举”有两层含义。第一层是计算机启动时bootloader把内核加载进内存的“自举启动”,这个是每个OS都会经历的;第二层是更硬核的“自举开发循环”——用你写的系统去修改它自己的源码,然后重新生成新系统,这个叫self-hosting。我想在这里顺便澄清一下:如果你搜索“自举”这个词,大概率会搜到“自举电容”这种硬件电路术语,那是利用电容电压不能突变的特性实现升压的驱动电路,跟OS里的自举是两码事,别学我一样跑偏到嵌入式文档里去了。
4.1 最小自举链需要什么
一套OS要能“养自己”,至少需要文件系统、文本编辑器、Shell和编译器。文件系统是本篇前面讲过的重点,没有真正持久化的文件系统,你改完源码一重启全丢,自举就是镜花水月。文本编辑器可以很简单,我实现的是一个行编辑器,类似老Unix里的ed加上一点点交互提示。Shell需要能执行外部命令、重定向输出,至少能跑构建脚本。
编译器是整个链条里最难的环节。完整的C编译器工作量巨大,我不建议所有人从一开始就写一个C编译器。合理的路径是分步走:先在自己的系统里用行编辑器改源码,通过共享磁盘镜像或虚拟硬盘和宿主机交换文件,然后用宿主机的交叉工具链完成编译,生成新的内核镜像。也就是说,编辑器和构建脚本在自己系统里跑,编译器暂时借用宿主机的。这样做的好处是,你依然能体会到“在自己系统上修改内核并用新内核启动”的完整闭环,而不必一次性吞下编译器这个巨大的工程。
4.2 我做到的自举程度:编辑器优先,编译器逐步迁移
我目前的做法是:在QEMU里挂载一个虚拟硬盘镜像,镜像里分了一个FAT32分区,里面放着内核的全部源码。我自己的系统启动后,Shell里可以进入源码目录,用内置的行编辑器修改文件,修改完保存。之后把磁盘镜像在宿主机上挂载,读取修改后的源码,再在宿主机上跑交叉编译脚本,生成新的内核二进制,写回镜像。重启虚拟机,一个新版本的系统就跑起来了。
这套流程听起来有点“伪自举”,但它形成了一个非常有价值的开发循环:修改源码、保存文件、重新构建、启动验证,这个完整链路完全依赖我的文件系统是真的、持久化是真的。如果哪一步存在假持久化问题,流程跑到一半就会暴露。我的下一步计划,是先把制作系统镜像的工具链也搬进系统内部,让“构建新内核”这个动作也在自己系统里执行,再往后才是真正的自托管编译器。
4.3 自举到底改变了什么
自举最直接的价值,是倒逼“编程环境可用性”。很多玩具OS的Shell只能跑几个演示命令,文件系统只能写固定文件,但一旦你把它当做日常开发环境来用,那些“演示级”的能力立刻不够用了。你会需要文件追加写、需要光标移动、需要长文件名、需要目录遍历,每一点缺失都会切实地打断你的开发流程。这就倒逼你把系统功能从“能演示”打磨到“能干活”。
我最直观的体验是:在自举流程之前,文件系统和设备驱动在我的认知里始终是“给系统用的子系统”;自举之后,它们变成了“我自己每天在用的开发工具”。这种角色变化会极大提升你对系统稳定性的敏感度。以前写文件崩了,我还能安慰自己“反正只是测试”;现在写源码崩了,我自己的开发效率直接受损,逼着我把每一个错误都修得彻底。
5. 常见问题速查表:给所有做玩具OS的人
如果你也在做类似项目,我把踩过的坑整理成了一份速查表,覆盖了我认为做OS时最高频的错误假设。这些不是完整列表,但每一条我都真实付出过Debug时间。
| 错误假设 | 实际结果 | 教训 |
|---|---|---|
| 写文件返回成功就等于磁盘上有数据 | 重启后数据丢失 | 设备命令完成后不一定真正落盘,必须处理命令完成状态和持久化语义 |
| QEMU里测试通过就等于真机没问题 | 真机上设备枚举失败、数据错乱 | 虚拟机太“干净”,BIOS行为、设备差异都要在真机上验证 |
| USB控制器初始化寄存器写入成功就是已就绪 | 根端口复位无反应,U盘不工作 | 先处理BIOS所有权交接,再做寄存器初始化 |
| FAT32只需要支持短文件名 | 长文件名文件在重启后变得无法打开 | 文件系统元数据支持不足,会被当成数据损坏 |
| 磁盘命令只要发出去就会按顺序完成 | 设备可能乱序或延迟完成 | 命令队列和完成队列要配套,不能只按提交顺序处理 |
| 串口日志可有可无 | 核心模块崩溃时无法判断位置 | 从第一天起就要有可靠的调试输出通道 |
| 中断处理程序写多长都没事 | 中断处理与主流程并发导致数据竞争 | 尽早实现中断嵌套屏蔽或锁机制 |
这些条目背后,我最想强调的还是“假设管理”。构建操作系统时,出错最多的往往不是“代码不会写”,而是“对一个模块的行为做出了超出其实现范围的假设”。你假设USB控制器已经初始化好了,其实只写了一半;你假设设备写完了,其实只是写进了设备缓存;你假设目录项已经更新了,其实还留在内存里。每一条假设的失败都是一次深入硬件规范的机会,这大概就是“从45个BUG到165个BUG”给我的最大收获:每修一次BUG,就是补上一条认知盲区。
6. 回到165个BUG:一点真实体会
如果你现在也在写一个自己的操作系统,或者准备开始写,我最想分享的体会是:不要把BUG数上升当成失败信号。BUG数从45涨到165,不是说明系统变差了,而是说明系统的疆域变大了。一个只有内存分配和中断处理的系统,想出165个BUG都难,因为它做的事太少了。每一个新的BUG背后,都对应着一个你之前不懂的硬件细节或系统原理。
这次处理“假持久化”让我意识到,工程师嘴里说的“写成功”和用户理解的“写成功”根本不是一回事;处理“USB地狱”让我意识到,真实世界的兼容性不是靠读规范就能解决的,必须靠足够多的真实设备来验证;做到“OS自举”时我才真正相信,这套系统已经从“教学玩具”变成了“可以持续生长的自洽系统”。下一步我打算把USB键盘鼠标的HID类驱动补上,再把FAT32的长文件名支持完善,到时候如果又冒出一堆新BUG,我希望自己还能保持这次的心态:每一个BUG都是一次系统升级的机会。