尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

45个BUG到165个:操作系统开发中的持久化、USB与自举硬仗

45个BUG到165个:操作系统开发中的持久化、USB与自举硬仗 如果只盯着题目里的“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 StorageBOT协议和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端口状态寄存器每一位变化都打印出来这样能明显看到设备插入、复位、启用、断开的过程。最终我把真机上遇到的几类高频问题整理成了一个小速查表见下表。症状常见原因解决方案复位后设备无法启用没有做电源稳定延时连接变化后等待100msSET_ADDRESS后设备无响应没有等待10ms控制传输间隔至少10ms键盘在批量传输时失灵interrupt传输调度被批量任务饿死在USB调度里保证每帧留出中断传输槽读U盘数据乱逻辑块大小假设512从SCSI Inquiry读取真实逻辑块大小真机USB口全不认新主板把端口全部交给XHCIBIOS开启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都用一行字记录“触发条件、根因、修复方式”。这既方便回溯也算给自己攒了一份操作系统的踩坑地图。后面如果哪天真要把它写成一份完整教程这些记录就是最靠谱的一手素材。
返回列表