从45到165个Bug:OS开发中的假持久化、USB协议栈与自举实践

发布时间:2026/9/18 11:01:26
从45到165个Bug:OS开发中的假持久化、USB协议栈与自举实践 上篇发布的时候我的OS刚能开机能跑几个简单的用户程序GitHub上挂着45个issue。我当时信心满满地跟朋友说接下来就是收尾的活儿把内核打磨打磨然后就可以开始OS自举。三个月后再翻commit记录我沉默了——修复过的标题编到第97号而新开的issue已经悄悄涨到165个。问题越修越多一度让我怀疑自己是不是在给一个永远填不满的坑做装修。这篇把这段时间里最折腾的三个大坑完整摊开假持久化、USB协议栈地狱以及让系统最终能够自己编译自己的自举过程。如果你也在写操作系统、嵌入式固件或者只是跟文件系统、USB打过交道这篇里的排查思路和踩坑记录应该能帮你省下不少冤枉时间。1. 数字反转从45个BUG到165个问题到底出在哪先聊一个让人沮丧的事实bug数量变多不代表项目变差但一定说明之前的修法有问题。上篇结束时系统状态是能启动、能跑内建shell、能执行几个编译好的用户程序、有一个简陋的内存管理器和任务调度器。整体处在“能跑但像纸糊的房子”的阶段。我当时给自己定的路径是先修完45个已知bug再做文件系统持久化再接USB最后做自举。听起来很线性对吧实际走下来45个bug修到第20个的时候总数已经变成70多。原因不是新写了多少代码而是修bug的方式本身有毛病。1.1 三种“伪修复”让BUG曲线不降反升第一种叫表面修复。比如某个系统调用在特定参数下会触发page fault我当时的反应是在异常处理入口加一个判断如果是这个系统调用路径上的地址错误就直接返回错误码。看起来问题解决了但根因是某个内核结构体的引用计数没管理好对象提前被释放了。表面修复只是让第一现场消失后续同一个对象被另一个模块用到时会以更诡异的方式炸开。这类bug平均潜伏期在两天左右然后带着新的现场出现初次排查的人完全看不出来是同一个病根。第二种是时序式修复。硬件设备初始化经常有“等待就绪”的步骤比如SATA硬盘上电后需要时间完成内部自检USB设备复位后需要时间进入地址状态。我最早图省事在好多地方直接靠空转循环硬等后来发现某些机器上还是不稳定就又加了更长的空转。这类修复最坑人的地方在于它确实能降低故障率但你完全不知道等待多久才合理、硬件内部到底在干什么。延长等待只是在跟随机性做交易交易一多同一个bug就会以“偶尔失败”的方式反复出现极难定位。第三种是无回归叠加。改完内存分配器的边界检查顺手把一块原本会被提前释放的缓冲区“救”了回来但没重新跑一遍之前修好的fork测试。结果就是一个bug修了三个原来的测试用例挂了。这个阶段的commit信息全是fix typo、fix page fault、fix random crash看着很忙实际上是在漏水的船上打补丁打完一个洞旁边又裂开一条。1.2 可复现性内核调试的第一原则被165这个数字打醒之后我做的第一件事不是继续修而是先停下来搭“可复现环境”。内核调试和普通应用调试有一个巨大差别应用崩了你还能看core dump内核崩了整个系统都hang住调试器的存在感接近于零。没有可复现手段的情况下修bug基本等于蒙眼拆弹。我给自己立了一条规矩任何bug必须先构造出一个最小触发用例再动手改代码。比如系统调用返回错误码时如果某个寄存器没保存我就在内核里加一个专门的测试入口用用户态程序调它几十万次观察失败率。这类测试代码后来整理成了白盒自测框架跑在内核的测试模式下也就是后来自举用的构建验证套件。开始比较痛苦因为很多bug需要写专门的触发程序才能复现但坚持下来之后修复效率明显提升——因为每次修复都有明确的“修复前失败、修复后通过”的判据而不是靠重启几次碰运气。1.3 补上“真·回归测试”后系统才真正稳下来我后来把bug曲线拉出来看45个到165个这个上升段正好是“伪修复”最集中的两个星期。开始强制可复现和回归测试之后曲线终于掉头向下。到自举成功那天打开的issue列表只剩下11个而且多半是“增强功能”而不是“崩溃”。这段经历让我彻底服了一件事写OS最耗时间的不是实现功能是给功能建立可信赖的验证体系。没有验证体系你会被自己的修改反复杀死。2. 假持久化排查数据明明写成功了重启后却消失如果说bug数量暴增是“管理学问题”那假持久化就是一个实打实的技术深坑。它耗费的时间仅次于USB。2.1 现象文件写成功了但只在“当前启动”内有效事情是这样的文件系统这一块我选择直接操作SATA硬盘而不是先挂在RAM disk上糊弄。实现思路很直接——按ATA命令读写扇区文件系统用自研的极简格式一个超级块记录块大小和根目录位置目录项直接平铺在数据块里数据区按块位图分配。听起来很简单对吧写文件需要做到三件事分配数据块、把内容写进这些块、更新目录项和位图。我当时把这三个动作都做完了还特意在返回用户态之前flush到硬盘驱动层测试结果也确实显示写入成功。结果一重启文件没了。更诡异的是有时候文件在目录项也在但内容是新旧数据混杂像是把两次不同时间写入的数据块错乱地拼接在一起。这个现象让我一度怀疑文件系统逻辑出了问题于是反反复复检查块分配算法、目录项格式、位图更新顺序——全都没问题。被逼得没办法我开始怀疑硬盘驱动。2.2 第一层嫌疑文件系统自身的缓存和顺序我实现的文件系统在内存里维护了一个扇区缓存写文件时先更新缓存再把脏扇区异步刷回硬盘。异步是因为启动阶段任务调度还不太稳定同步刷盘容易在中断里睡死。问题就出在这个缓存策略上——目录项、位图、数据块在内存里的更新顺序是先分配块、改位图、写数据最后写目录项。看起来挺合理但如果掉电或重启内存缓存全部丢失硬盘上可能只留下了部分写回的数据块而目录项还是旧的甚至位图还是旧的于是出现文件“不存在”或者“内容错乱”的现象。这个排查方向让我浪费了整整两天加了大量调试日志后发现缓存机制本身没问题脏页写回也确实执行了。也就是说文件系统层面该落盘的数据全都到了硬盘驱动。2.3 真凶AHCI命令完成与硬盘写缓存真正的凶手藏在硬盘驱动的实现里。我用的SATA接口走的是AHCIAdvanced Host Controller Interface驱动逻辑也不算复杂把命令填充到命令列表写到寄存器通知控制器执行然后通过中断或轮询等待设备完成命令。这里有一个特别容易踩的坑——AHCI控制器执行的“完成”指的是SATA设备接收了命令并返回了状态不代表数据已经物理写入磁盘介质。现代硬盘内部都有一块DRAM或闪存写缓存设备接收到写命令后可能先把数据放进缓存向主机报告“写完成”然后再慢慢把缓存刷到盘片上。如果这时候系统断电或者重启缓存里的数据极有可能丢失。我做的就是每次写命令完成后直接认为数据已经安全落盘。从系统内部看一切都很正常但对硬件来说数据还在半空中。修复方法很复古——在每次写操作完成之后额外向硬盘发送一条FLUSH CACHE命令强制设备把内部缓存刷到物理介质上。指令很简单但效果立竿见影重启之后文件老老实实待在那里。后来查资料发现Linux的块设备层也处理同样的问题/sys/block/sda/device/scsi_disk/*/cache_type里可以控制写缓存策略还分write-back和write-through。我当时为了省事选了write-back却漏了flush正好踩中教科书级别的假持久化。2.4 修复之后三层“成功”同时成立才是真成功修复完最大的收获不是“文件系统终于持久了”而是明白了一个判断指标写操作要同时具备“文件系统层成功”“驱动层成功”“物理层成功”三层含义缺一层都是假持久化。文件系统层成功等于目录项和位图正确更新驱动层成功等于AHCI命令队列确实执行物理层成功等于数据已经写到盘片介质而非设备缓存。我之前把前两层当成了全部差点就带着这个定时炸弹去做自举——如果真的带着假持久化去做系统自举我可能编译完内核重启一下三个小时的全部产物瞬间清零那种打击足够劝退很多人。这是一个可以类推到所有存储场景的经验。嵌入式开发里用SD卡、eMMC、NAND Flash时不少“偶发数据丢失”的bug根因就是控制器或Flash芯片内部有缓存单纯的写命令完成并不等于数据落盘。最稳妥的姿势永远是重要数据写完主动刷缓存/等待设备空闲再考虑下一步操作。3. USB地狱从UHCI到XHCI的协议栈踩坑记录说实话我在做USB之前就听说过它是个坑但觉得不过是“端点传输”嘛比文件系统简单多了。结果整个USB子系统的实现耗时是文件系统的三倍。165个bug里有将近一半跟USB有关而且都是那种“看着成功但实际没成功”的隐蔽问题。3.1 为什么说USB是“地狱”协议层次远比想象的多USB长盛不衰的原因是好用、即插即用、一根线走天下。但代价是协议栈里塞满了兼容层和抽象概念。从控制器角度有老古董UHCI和OHCIUSB 1.1、EHCIUSB 2.0、还有从2010年后几乎一统天下的XHCIUSB 3.x。我的目标是真实机器上跑所以绕不开XHCI。XHCI最大的特点是它同时管理USB 2.0和USB 3.0设备通过一种叫“根系端口”的抽象把两类设备混合在一起。实现XHCI驱动时你以为自己在跟寄存器打交道实际上是在跟一套复杂的事件处理和命令完成机制打交道出错的方式非常多元化。从设备端角度USB设备用“描述符”一层层描述自己——设备描述符、配置描述符、接口描述符、端点描述符。初看一个GET_DESCRIPTOR请求就够了实际跑起来发现设备描述符只是“自我介绍”的开胃菜接下来还要配置、选择接口、设置端点。期间任何一个字节的应答和预期不符整个枚举过程就会卡在日常最不起眼的环节。一句话解释就是USB是分层体系链路层之上有传输层、传输层之上有设备框架、设备框架之上才是HID键鼠、U盘或串口。任何一层出问题表现出来都是“设备连不上”或“没反应”。这种一层套一层的结构对开发者最大的折磨就是你以为在修A层其实问题在C层。3.2 设备枚举地狱的第一道门USB设备接入主机后驱动要做的就是“枚举”给它分配一个地址读取它的描述符选择一个配置让它进入可用状态。我在地狱里遇见的第一个坑叫“地址0”。所有USB设备刚接入时默认地址都是0。主机在地址0上发出第一个GET_DESCRIPTOR请求设备描述符然后发送SET_ADDRESS给设备分配一个非0地址。听起来很简单问题在于——不同控制器对这个过程的时序要求不同。我的XHCI驱动在SET_ADDRESS之后直接在新地址上发第二个GET_DESCRIPTOR结果设备没响应。排查后发现部分设备在收到SET_ADDRESS后如果主机紧接着发送的请求太快设备可能还在切换地址状态直接丢掉请求。解决办法是发完SET_ADDRESS之后等一小段时间再在新地址上发请求。就这么一个“等一等”解决了一批随机枚举失败。第二个坑是首次GET_DESCRIPTOR的字节长度。USB规范里说的标准设备描述符是18字节但部分设备在刚上电时只支持请求前8字节你如果一上来就请求18字节它直接返回STALL。当初实现的时候我照着最早接触的USB书籍一开始就请求18字节日志里全是reset风暴。改成先请求8字节读完前8字节再发一次完整请求之后马上顺了。这属于USB开发里的老生常谈但我用一个下午亲身把它踩疼了才知道为什么这么多人会强调它。第三个坑来自Hub。USB Hub本身也是一个USB设备它下面还能挂设备。早期实现枚举时只处理直接插在根端口的设备后来插了一个Hub系统瞬间炸了——因为Hub要在枚举完成后才能开启端口供电和数据连接等到子设备插入时还要发一个专门的“端口状态变化”通知。处理这个通知的时机如果不对子设备会被反复复位、枚举、断连。这个机制叫“复合设备拓扑”。3.3 HID键盘与报告描述符键码为什么对不上枚举结束后HID键盘的设备驱动就相对简单了——它通过一个中断IN端点每隔一段时间向主机发送8字节报告第0字节是修饰键比如Shift、Ctrl、Alt的状态第1字节保留第2到7字节是普通按键码。结构确实简单但有一个坑藏得很深部分键盘不是标准的Boot Protocol设备而是必须走Report Protocol需要主动解析HID Report Descriptor才能知道报告格式。我最初实现时假设所有键盘都走Boot Protocol测试用的那块10块钱USB键盘没问题把代码放到另一块机械键盘上结果键盘完全没反应。原因是它默认走Report Protocol主机没有正确读取和解析HID描述符它就不发数据。为了兼容所有键盘我最后把“读取HID报告描述符并解析”这条路走完了解析出一个tokens数组后再拿到报告长度和按键字段偏移。至此键盘才真正在物理机上可用。键码映射本身也有故事。USB HID标准里0x04是A键0x05是B键0x28是回车这种映射表和ASCII完全对不上一开始我的测试程序打印出来的全是乱码我还以为是USB传输的字节序错了。后来直接打印原始键值跟HID Usage Table一比对才知道是映射表没加。建议任何做USB键盘的朋友先把那张HID Usage Table保存到硬盘里这玩意儿看着不起眼真到用的时候能救命。3.4 用USB抓包和USB转串口撬开黑盒在没有显示器、没有网卡驱动的调试早期阶段我最依赖的两个调试工具一个是USB总线抓包一个是USB转串口。USB抓包我用过Wireshark配合usbmon内核模块Linux效果非常好。驱动向设备发出的每一次控制传输、中断传输、批量传输都能被完整记录下来哪个阶段设备没响应、哪个请求返回了错误状态一目了然。这比盲改代码加日志的效率高太多。硬件USB分析仪当然更专业但对个人项目来说软件抓包已经足够看出绝大多数交互问题。USB转串口的意义更大。机器上如果没有串口内核日志就看不到什么都写不了。我后来从仓库翻出一块带FT232R芯片的USB转串口小板把系统日志通过内核的早期输出驱动全部重定向到这块虚拟串口上。虽然没有键盘、没有显示器但我仍能实时看到“现在到了哪一步、卡死在哪个中断处理函数里”。这属于那种谁用谁知道、不用就永远不知道有多重要的基础设施。在写完键盘驱动之前我全靠它跟机器“说话”。3.5 从地狱里爬出来的心得先抓包再改代码USB地狱走了几轮我总结出的调试顺序是先确认链路层枚举能不能过、再确认传输层数据有没有丢、最后才去看设备类逻辑。排查任何USB问题时第一步永远是抓包看物理总线上到底发生了什么而不是对着代码猜。没有总线数据一切“我觉得”“我猜”“理论上”都是自己骗自己。等时、批量、中断、控制四类传输中中断传输看似简单但调度间隔的配置直接影响设备的事件响应速度。太慢键盘敲一下半秒才反应太快控制器中断风暴直接拖垮系统。这个平衡点需要实测我最终的方案是把中断间隔设置成8毫秒肉眼看起来无延迟同时不增加明显CPU占用。4. OS自举让系统自己编译自己到这里系统终于像一个可以工作的OS了有真正的持久化存储、能插USB键盘、能通过USB串口输出日志还能跑几十个用户态程序。接下来就是标题里说的“OS自举”。先明确一下概念。自举bootstrap在操作系统语境下有两层含义一层是引导加载开机这个我上篇就做了另一层是“编译器自举”——让你的操作系统能够重新编译自己的源码从而摆脱对交叉编译器和宿主机环境的依赖。我这次要完成的是后者。4.1 自举的第一步把工具链搬进“新家”为了实现编译器自举我需要把完整的开发工具链搬进自己的OS里跑起来。这套工具链包括make、GNU Binutils汇编器、链接器、GCCC编译器以及必要的头文件。把这些工具移植到新OS上并不像“装软件”那么简单。工具链本身就依赖操作系统提供的系统调用和C标准库。我的内核已经有进程、文件系统、内存管理但系统调用接口是按自己的需求设计的和Linux的POSIX接口并不完全一致。于是摆在面前的第一项工作是实现一个足够接近POSIX的系统调用层让GCC和binutils能跑起来。这里有一个很尴尬的兼容性问题GCC的构建脚本里大量使用fork()、exec()、waitpid()、pipe()这些POSIX接口。我的OS里没有fork和exec怎么办方案只有一个老老实实把进程创建、程序加载、进程等待这些机制做完。这一步做完系统的用户态编程模型才真正成型——可以启动一个新的程序而不再是初始化时静态绑定任务列表。这一下子就把工作量拉大了进程表要有完整的状态机、内存要支持写时拷贝或至少支持独立地址空间、文件系统要能查找可执行文件、加载器要能解析可执行文件格式并做重定位。做完这些GCC和binutils才可能在自己的OS上编译出自己的代码。4.2 鸡生蛋问题GCC需要makemake需要文件系统工具链有一个经典的鸡生蛋问题GCC要用make来构建但make本身又不是第一个能跑的程序要把make编译出来你又需要有能跑的C编译器。而C编译器正是GCC。破局方法很传统第一步用宿主机上的交叉编译器把make的源码编译成目标OS的可执行文件然后在目标OS上运行make让它去编译GCC。前提是make能跑起来而make又依赖操作系统的进程管理和文件系统。好在这些前置条件已经在本阶段完成了。于是整个过程是 宿主机交叉编译出make → 拷到OS里 → make读取GCC的Makefile → 在OS上调用交叉编译器把GCC编译出来 → 得到一个能在OS里运行的GCC副本。说说我踩过的细节坑。GCC的构建脚本里有很多“假设”比如假定存在/usr/include和/usr/lib假定shell支持某些操作。我的OS没有这些目录结构也没有完整shell。解决办法是在文件系统里手动创建/usr/include、/usr/lib等目录放好头文件和库文件。这种“用真实项目逼自己实现目录规范”的方式比空想设计高效太多。4.3 自举成功的那个下午实际执行的时候系统在长时间运行中暴露出了不少只在重负载下才会暴露的问题——比如内存突然不够用、文件描述符溢出、调度器在持续fork时不公平。修这些问题的过程其实又带来了一批新bug好在前期有了回归测试基本能在编译阶段就发现。自举成功的那一瞬间已经有点模糊了记忆里只有绿色的Bootstrap complete字样出现在终端上。那一刻机器的屏幕上显示着由我写的操作系统编译出来的、运行在我写的操作系统上的可执行文件。用宿主机交叉编译器生成的可执行文件和OS自己编译出来的可执行文件行为完全一致。这意味着我的OS已经获得“独立生存”的能力不再依赖宿主机就能自我延续。自举成功后的第一件事是删掉宿主机提供的交叉编译器镜像。这个动作带有仪式感但更实际的意义是以后所有代码都不经过宿主机之手整个开发闭环全部在自己OS内部跑通。后续修bug、加功能、重新编译、生成新镜像、启动验证全都在OS内部完成。4.4 自举的真正价值独立性和验证能力自举的最大收获不是“能在目标机器上编译”而是让系统拥有了自我验证的能力。以前改一行内核代码需要交叉编译、制作镜像、重启虚拟机来回至少几分钟自举后可以直接在自己的OS里编译、运行测试程序几分钟能跑好几轮。迭代速度上来了调试效率自然也上来了。另外一个附带价值是自举让项目变得不再“依赖某个特定环境”。环境一换工具链版本一变交叉编译器可能就跑不起来。而自举之后的系统只要有源码和基础引导信息就能完整再现整个系统。对个人项目来说这是里程碑式的“独立”。5. 165个BUG修完之后的几点复盘心得写完驱动、做完自举之后再回头看这165个bug我想说几句大实话。第一bug数量暴增不可怕可怕的是修得不明白。我踩过最深的坑就是“好像修好了”的状态日志不报错了、测试能过了、重启也正常了但其实只是把表象掩盖了。真正的修复应该让你清楚解释“为什么之前会坏”并且能稳定复现修复后的正确行为。如果解释不了原因这个bug以后一定会换个马甲回来。第二调试工具投资得趁早。USB转串口小板便宜得令人发指但它给我省下的时间远超我写过的任何昂贵设备。软件层面的抓包工具、断点、跟踪同样如此。不要等到问题已经变成“随机崩溃”了才回头补工具工欲善其事真不是一句空话。第三最少但可运行的测试用例比任何设计文档都更能推动项目。我的回归测试就是从一批简单的系统调用测试开始的每修一个bug就加一个对应用例。到后面这套测试成了我改代码的最大底气——没有它我根本不敢动内存管理器的代码。任何项目无论大小测试覆盖永远是值得投入的部分尤其当修改的代码会影响到系统全局行为的时候。最后一个关于中文编程的体会。这个项目从内核注释、函数命名到终端输出大量使用了中文。很多人觉得编程语言用英文是天经地义但实际做下来用母语组织复杂逻辑时思路确实会更顺畅尤其在调度器这种状态机泛滥的模块里中文注释能直接表达意图调试时不用再在心里做一次中英转换。当然这只是一种个人选择不代表“中文编程比英文好”但它让我更早看清一个道理工具应该服务于人而不是反过来。从45个bug到165个这个过程像爬一条陡峭的山路。回头看最开始那45个bug其实只是山脚的路障真正难的是山腰上的系统性问题。把这些问题一个接一个按下去最后站在山顶上的感觉值得每一个做底层的开发者去体验一次。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询