从假持久化到USB地狱:自研操作系统的真实踩坑与自举实践

发布时间:2026/9/13 10:09:37
从假持久化到USB地狱:自研操作系统的真实踩坑与自举实践
我上次写这个中文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开始做后来发现很多真实主板的主机控制器是EHCIUSB 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都是一次系统升级的机会。