ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从45到165个BUG:操作系统实战中的持久化、USB与自举深坑

从45到165个BUG:操作系统实战中的持久化、USB与自举深坑 很多人看到“从45个BUG到165个”这个标题第一反应是你是不是把操作系统越写越烂了恰恰相反这说明我的操作系统开始从“能跑”走向“能活”。上篇发出来后系统能在QEMU里启动、打印日志、跑调度器我当时觉得一切都在掌控之中。直到我决定不依赖GRUB、自己引导内核把系统放到真实U盘和硬盘上跑才第一次体会到什么叫“模拟器里岁月静好真机上处处是坑”。这篇下篇我打算把这段被真机反复捶打的过程完整拆开来讲假持久化、USB地狱、OS自举。这三个词每一个背后都藏着一堆我在文档里根本查不到的细节。165个BUG也不是说质量下降而是系统真正开始接受“真实世界”的考验时被逼出来的那一大批问题。1. 从45到165不是BUG越写越多是以前的“假稳定”被戳穿了1.1 上篇做完时系统到底处于什么状态先把上下文说清楚。上篇结束时我的内核在QEMU里已经具备这些能力能从GRUB引导、初始化GDT和IDT、处理中断异常、有基础的物理内存分配器和页表管理、能创建并切换两个进程、有简易的printk输出、能用PS/2模拟键盘输入。当时累计修了45个可复现的逻辑问题大部分是页表项设错、中断处理函数没保存寄存器、调度器切换上下文时栈没对齐这类典型新手坑。那个阶段所有测试都在QEMU里完成启动路径固定硬件环境固定连时钟中断的频率都固定。所以45个BUG本质上是“在一条固定路径上跑出来的45个问题”。你只要保证这条路径没问题系统看起来就稳如老狗。1.2 这轮迭代做了什么BUG为什么会翻将近四倍下篇的核心目标有三个让文件系统的数据真正持久化、支持USB键盘而不是依赖PS/2模拟、去掉GRUB让内核自己从U盘引导。表面上看只是增加三块功能实际做起来才发现这三块触及的底层复杂度完全不同。先说结论从45到165代码量只增加了大概两千多行但测试场景从“单一路径”变成了“全路径”。以前很多函数只在理想条件下被调用现在要面临真机上的各种边界文件系统写入路径上数据要穿过VFS、块缓存、AHCI驱动、磁盘写缓存任何一层没考虑“掉电”就完蛋。USB协议栈是一个极其松散的状态机每次枚举都是和硬件玩一次“对表”时序差几毫秒就失败。自己引导不是把内核load进内存就完事还要处理A20、分页、ELF段加载这些bootloader默认帮你做好的事。更重要的是统计口径变了。上篇我只会把“导致功能不工作的bug”记入台账下篇开始所有可能导致数据损坏、死锁、真机启动失败的问题我也全部计入。比如文件系统位图分配在极端情况下可能覆盖脏数据这种问题在QEMU里永远不会暴露但隐患是实打实的必须记下来修掉。1.3 项目管理上的一个经验把BUG分类不要被数字吓到我维护了一个纯文本的BUG台账每个问题记录四列复现路径、根因、修复方案、验证方式。165个问题里能归为“可复现型”的占大部分大概110个剩下的是“环境型”比如真机USB枚举时快时慢、AHCI某些端口状态延迟、A20打开后内存行为变化还有一小部分是“设计型”例如文件系统缺少barrier概念、USB轮询调度没有周期性框架。分类最大的好处是你不会在深夜面对一个随机出现的故障时毫无头绪——你知道该去翻哪一类。如果只盯着“BUG变多了”这个数字心态很容易崩。实际上165这个数字代表的是质量门槛在提高。单元测试覆盖率从10%提到80%时测试失败数也会暴增但没有人会说是代码变差了。操作系统开发也是一样当你开始做崩溃恢复、掉电测试、真机压力测试时问题数量必然上升。2. 假持久化数据能写能读断电后全部蒸发2.1 现象在QEMU里一切正常真机上重启文件没了文件系统是最早动手的部分。我参照MINIX文件系统的思路实现了简单的inode、位图、数据块分配能创建目录、写文件、读文件。挂在QEMU的磁盘镜像上测试一切正常格式化、建目录、写文件、关掉QEMU、再次启动、文件还在。我当时觉得自己已经掌握了存储的真谛。直到某天我把系统烧到一块真实U盘上写了一个日志文件正常关机重新上电文件消失了。不是损坏不是变成乱码是干干净净地没了。目录还在inode还在位图上标记为已分配文件大小也在但你打开文件时读到的是空数据块。那一刻我整个人是懵的。2.2 排查链路从文件系统一路查到块设备层排查的第一步当然是怀疑文件系统层。我在VFS的write路径上加了大量跟踪日志确认写文件时inode确实被更新脏块也被标记。然后我写了一个“把所有脏块强制刷盘”的函数在关机前调用它甚至还验证了内存里的数据块内容是正确的。结果重启后依然丢数据。这意味着问题不在文件系统逻辑上而在文件系统之下的某个环节。于是我把矛头转向AHCI驱动。在驱动里每次write都会返回成功命令很快就“完成”了。我用一个调试命令读取固定LBA的数据发现读到的是全零。这时候真相已经露了一半我们的写命令根本没有把数据送到磁盘介质上。再往下挖发现根子在驱动层对“完成”的理解。当时我写的AHCI写逻辑大致是把命令列表项填好设置物理区域描述符PRD把命令提交到端口命令列表然后轮询端口中断状态位等到位置位就认为写完了。看上去没问题但AHCI协议里命令完成只代表数据已经交给磁盘控制器不代表数据已经落盘。磁盘内部还有一个易失性写缓存命令完成时数据可能在缓存里还没来得及写入盘片。要保证掉电后数据还在必须额外发送FLUSH CACHE命令。QEMU掩盖了这个错误。QEMU的磁盘控制器模拟得非常“宽容”即使你不发FLUSH命令它在进程退出时也会帮你把整个镜像文件刷完整所以我在模拟器里反复测试都发现不了问题。真实U盘不会替你兜底断电就是断电写缓存里没来得及落盘的数据直接蒸发。2.3 根因卷写缓存与没实现的同步机制用生活化的类比来解释你把现金递给银行柜员柜员给你一张回执这相当于DMA传输完成但柜员并没有立刻把这笔钱记入账户钱躺在柜台的抽屉里准备下班前统一入账这就是磁盘上的易失性写缓存。你要求的“这笔交易必须立刻入账”在操作系统里就是FLUSH命令。只要没发FLUSH数据就只存在于硬件内部的临时存储里断电后找谁都没用。问题梳理清楚后修复方案也清晰了在块设备层为每个设备实现flush能力。IDE磁盘发送FLUSH CACHEE7h或FLUSH CACHE EXTEAhAHCI磁盘在命令列表里构造一个FLUSH命令并等待其完成。VFS层增加全局“同步队列”所有脏块按LBA排序后统一写出而不是随机顺序写。排序能减少磁盘寻道也能让flush语义更清晰。关机、重启路径必须统一调用一个sync_all_devices()确保所有设备在掉电前处于干净状态。对于未来要做的日志型文件系统还引入了barrier概念日志提交前先flush一次日志提交后再flush一次保证元数据更新不会先于日志落盘。2.4 修复之后测试方式也必须跟着变修完这些测试方式如果不升级迟早还会出事。我开始在每次启动流程里注入故障用参数控制系统在第N次启动时主动触发一次panic模拟意外断电然后检查文件系统在重启后能否恢复。这个测试一发不可收拾又拉出一批新BUG释放数据块后位图上对应位没有清零导致后续分配重复使用同一块区域。删除文件时inode的链接计数没有递减目录项删了但inode还挂着磁盘空间永远释放不掉。日志重放时按写入顺序而不是按日志序号处理导致乱序恢复时出现不一致。这些问题的共同特点是普通功能测试完全测不出来必须靠崩溃注入才能在特定组合下复现。我把这个测试脚本保留下来每次改动文件系统相关代码后都会跑一遍。现在系统在真机上每次启动、使用、关机我都会下意识地默认“下次上电可能面临一次崩溃恢复”这种心态比任何代码审查都有用。3. USB地狱从枚举到键盘输入处处都是“规格陷阱”3.1 为什么USB栈会成为操作系统的噩梦USB协议栈的复杂度远超大部分人的预期。物理层有Root Hub和端口状态机传输层有UHCI、OHCI、EHCI、xHCI四种不同的主控制器模型协议层有SETUP、IN、OUT三类事务再往上还有设备层配置、接口、端点和类层HID、存储、网络。每一层只要有一个位错了结果往往不是报错而是设备静默无响应你只能在黑暗里慢慢摸。我最初选择UHCI原因很简单UHCI的寄存器在PCI I/O空间用in/out指令就能访问调试比内存映射的EHCI直观。代价是它只支持低速和全速设备后来的USB 2.0高速设备只能靠配套的EHCI控制器处理。但作为第一个USB栈UHCI已经把该学的坑都教给我了。3.2 端口复位与SetAddress一个时序错误引起的枚举失败第一个拦路虎是枚举阶段。插入USB键盘后Root Hub端口状态寄存器检测到设备连接主控驱动复位端口然后我立刻往端点0发送Get Descriptor请求结果设备完全不响应。一开始怀疑是控制传输的结构体没填对反复检查TD字段没有发现问题。又怀疑是设备根本没有进入Default状态于是我把端口状态寄存器打印出来发现复位动作确实触发了但我给设备的时间太短了。USB 2.0规范要求复位后至少等待10毫秒的恢复时间实际操作中很多设备需要更长时间我一开始只等了1毫秒就急不可耐地发包。QEMU再次成为掩盖问题的帮凶——它的模拟主控对时序完全不做限制复位后立刻发包也能正常响应。真机上这套逻辑就不成立了。我把复位恢复时间改成100毫秒之后枚举才稳定下来。另一个隐藏在角落的问题是SetAddress命令的生效时机。控制传输的Setup阶段先发出地址设置请求设备完成该事务后新地址才生效。如果后续请求仍然发给地址0设备会认为你在跟一个根本不存在的设备通信直接把包丢掉。这个顺序错一次后面所有描述符请求都会落空而且错误不会以中断形式通知你纯粹是“发出去就没有然后了”。3.3 TD/QH链表的指针错位结构体对齐带来的低层翻车枚举能跑通之后数据传输又出了一堆问题。UHCI的传输模型是把队列头QH和传输描述符TD串成链表主控制器自己沿着链表取TD执行。问题出在Link Pointer的低位含义上bit0是终止标志bit1是队列类型标志。如果直接把结构体指针塞进Link Pointer字段而不处理低两位主控制器解析链表时会直接走错路表现就是不执行你安排的传输或者跳到随机内存执行。这里还牵扯到结构体对齐。UHCI要求QH和TD按16字节对齐但编译器默认可能只保证4字节或8字节对齐。必须显式使用__attribute__((aligned(16)))否则在开启某些优化选项后结构体地址会漂移主控制器读到的标志位全乱。这类问题在x86上特别玄学同一份代码不开优化时一切正常开了-O2就开始随机失败根源往往就是对硬件对齐要求处理不到位。我还在这里浪费了整整一个晚上原因是把UHCI的QH/TD链表指针设置成了循环链表。主控制器沿着链表跑执行完一个TD后继续走下一个但循环链表会让它在同一圈里永远转下去中断风暴直接把系统打挂。正确做法是要么在最后一个TD上设置终止位要么把链表末端指向一个特殊的状态节点。这个细节不亲手踩一遍真的不会长记性。3.4 轮询调度中断传输不是“中断”USB里的“中断传输”是我见过最名不副实的名字它不是硬件中断而是主机周期性轮询设备端点。对于全速HID键盘bInterval字段通常是10也就是主机每10毫秒必须对端点发送一次IN令牌键盘才会把手头的数据交出来。我最初把IN端点设计成“发送一次就等着”结果键盘初始化完成后一个字节都收不到。解决方法是把IN端点注册进帧列表调度器在每帧开始前检查该端点是否需要被轮询需要则构造IN事务令牌发送到总线上。轮询间隔必须严格按照bInterval执行如果主机轮询太快设备会NAK太慢按键响应会卡顿。这套调度机制看似简单但要和已有定时器、调度器、中断状态机配合一开始实现时总会漏掉“NAK之后还要继续轮询”这个基本事实导致键盘用着用着就失联。HID报告的解析也藏着坑。报告描述符里字段长度包含报告ID字节很多键盘的报告ID会占据第一字节直接按内存地址解析时如果把报告ID当成按键码所有按键都会错位。我当时还在想为什么我按A它输出B好在打印原始字节后一眼就发现问题了。3.5 真机排错的方式日志、寄存器和“暴力超时”的取舍写OS时最痛苦的事情是没有现成调试器。QEMU有monitor可以看任意内存和寄存器真机只能自己想办法。我为USB栈专门做了一套调试命令打印端口状态寄存器、打印所有QH/TD的地址和状态字、打印控制传输的完成码。最有效的调试手段反而是最简单的在每个关键等待点写一个带超时的轮询超时后把寄存器快照打印到屏幕然后挂起。另一个经验是不要轻易相信“在哪一步失败”就是“那一步有问题”。USB设备的状态会因为时序变化而漂移有时候复位成功有时候失败看起来像随机故障。实际上往往是某个环节的等待时间卡在临界值附近比如供电稳定时间、复位恢复时间、描述符请求间隔。找到那个临界组合把等待时间放宽到一个可靠范围随机问题就变成确定问题了。4. OS自举让内核自己搬自己是一次真正的“主奴切换”4.1 从引导扇区出发的启动链路在决定不再依赖GRUB之前我的内核一直靠GRUB加载它帮忙切到保护模式、解析ELF、设置分页然后跳转到内核入口。这套流程太顺滑以至于我完全没想过自己动手做这些步骤时会有多艰难。所谓的“OS自举”其实就是要替换掉GRUB的角色在512字节的引导扇区代码里完成全部启动准备。我的启动链路大致是BIOS把MBR读入0x7C00引导代码第一件事是先把自己复制到0x0600处防止后续读取内核时覆盖当前正在执行的代码。从U盘的FAT32文件系统里读取内核镜像文件或者从固定LBA区域直接读取内核字节流。关闭中断加载自己的GDT表设置CR0的保护模式位PE1。执行远跳转清空流水线进入32位保护模式。建立早期页表开启分页CR0.PG1。跳转到C语言内核入口开始真正的系统初始化。每一步之间的“缝隙”全是坑。4.2 高半区链接与分页打开的那一瞬我的内核链接在0xFFFFFFFF80000000这个高半区地址上也就是说编译生成的代码里到处都是高地址引用。但CPU上电时运行在低地址物理内存上所以“进入保护模式、开启分页、跳转到高地址”这三步的顺序必须十分小心。我在早期页表里做了双重映射物理地址0到1GB映射到低半区0x0000000000000000起同时也映射到高半区0xFFFFFFFF80000000起。这样无论是低地址执行还是高地址执行页表都能覆盖当前代码段。在这个阶段最容易出的问题是页表项里忘记设置PS位页面大小位或者PML4/PDPT层级之间的地址填错导致跳转到高地址后立刻Page Fault。调试这个问题的难处在于Page Fault发生时的现场已经被破坏了一半你很难判断是页表项缺失还是跳转地址错误。我的办法是在跳转前把目标入口地址和CR3的值打印到屏幕上然后人工核对页表结构。整个过程非常原始但有效。4.3 A20地址线看起来像内存损坏的经典小坑自举到真机之后我遇到了一个特别诡异的现象内核启动到某个阶段之后某个全局变量会出现“幽灵变化”明明没有任何代码写它值却变了内存分配器偶尔会报出地址冲突。我甚至怀疑过是物理内存损坏差点去换内存条。最后定位到A20地址线的问题。老式PC为了兼容8086的地址回卷行为默认禁用第20条地址线。这意味着访问超过1MB的内存时地址会回卷到0到1MB的区间。我在1MB以上的某个物理页里写数据读出来的却是低地址区域里另一份内容的残影。两个完全不相关的变量占用了同一个物理地址自然会互相覆盖。修复方式有两种通过键盘控制器8042发送命令打开A20或者通过BIOS中断INT 15h的AX2401h功能打开A20。在引导阶段BIOS中断还在所以直接调INT 15h最省事进入保护模式后想再调整就只能操作8042了。QEMU默认始终开启A20所以这个问题完全不在模拟器里复现。真机测A20是我这个项目里“调试体验最差”的一晚因为屏幕打印只能显示文本无法表达内存地址之间的映射关系。4.4 极简ELF loader的几个细节自举意味着必须自己解析ELF格式的内核镜像。GRUB之前把这些事全包了现在全部要自己干这里面的细节密度远超想象program header要过滤类型。只需要处理PT_LOAD段PT_PHDR、PT_NOTE这些元数据段直接跳过否则会把ELF头当代码加载进内存。bss段必须清零。它不在文件里占空间但加载器必须知道它的起始地址和长度把这段内存清零。我一开始忘了这一步内核里所有未初始化的全局变量都是旧内存的残留调度器跑起来后各种随机崩溃。加载地址和链接地址必须对齐。链接脚本里我把.text、.data、.bss按页对齐加载器就应该按p_vaddr读入如果直接按文件偏移读读进来的是ELF头部和节区表而不是可执行代码。入口点要从ELF头部的e_entry字段读取不能默认是0x100000或者某个固定地址。如果你在链接脚本里指定了入口符号也要检查e_entry是否对应那个符号的地址。4.5 从U盘启动的策略选择为了降低初版自举的复杂度我一开始走了捷径内核镜像直接放在U盘固定的LBA区域比如LBA 2048开始引导代码按扇区编号连续读取完全绕过文件系统。优点是代码只有几百字节异常稳定缺点是你没法像普通文件一样往U盘里拷内核必须用专门的写入工具。等到引导代码稳定之后我才补上了FAT32解析读取BPB参数块里的每扇区字节数、保留扇区数、FAT个数、根目录起始簇号按簇链遍历文件找到KERNEL.BIN后按簇读取内容。过程中遇到的坑是分区表偏移U盘的分区起始LBA不一定为0MBR里的分区表项记录了起始位置引导代码必须先解析分区表把LBA加上分区起始值否则读出来的就是U盘最开头的引导区内容永远不是你要的内核。如果你也想做自举我强烈建议先走“固定LBA”路线把启动链路跑通再升级到文件系统解析。一步到位往往会同时面对多个问题排查起来非常痛苦。5. 当前状态与写OS同好的一点心得5.1 现在的操作系统能做什么折腾完这三个大坑之后系统现在的状态是能从U盘自举到高半区内核支持FAT32文件系统读取有串口和VGA输出有完整的中断异常处理能管理四级页表能创建和切换进程有带脏块跟踪的块设备层和文件系统同步机制关机时会正确flush所有挂载设备USB键盘可以稳定输入崩溃恢复测试已经跑过上百轮。“从45个BUG到165个”这个过程让我明白了一个道理操作系统项目最难的不是那些宏大设计而是把数据真正写进磁盘、把设备状态机调到稳定、把自己从bootloader手里安全接管过来这些基础动作。每一件看起来理所当然的事情深入到硬件层面都是无数个细节堆出来的。5.2 关于“BUG数量上升”的心态调整如果让我给同样在写OS的人一个建议我会说不要害怕BUG数量上升要害怕的是“所有测试都通过但系统其实在悄悄损坏数据”。我做假持久化修复之前所有测试都显示文件系统工作正常可一旦断电就全丢。这种“假稳定”比明确的BUG可怕得多。建议把可观测性当成第一优先级。串口日志、panic回调、寄存器dump、trace buffer这些基础设施越早做越好。我在USB调试期间没有逻辑分析仪全靠自己打印寄存器状态硬扛效率低到想哭。后来加了一个简单的8通道逻辑分析仪一次USB传输里到底有几根信号线变了、时序对不对一眼就能看出来。5.3 给想入坑的人的工具链建议最后整理一份我实际用下来最顺手的工具组合开发调试主环境QEMU配合自定义机器模型可以快速迭代大部分内核逻辑但一定要时刻记住“QEMU能跑不代表真机没问题”。真机测试准备一台便宜的旧笔记本或者几块U盘专门用来做启动测试和掉电测试。真机的硬件时序、A20行为、USB枚举行为都是模拟器的友好版之外的真实面目。逻辑分析仪对USB、串口、磁盘这类有明确时序信号的外设调试帮助极大几十块入门级就够用。版本控制配合小步提交每次自举、枚举、崩溃修复都要能做到“改一笔提交一笔”否则环境型故障出现时git diff是唯一能帮你定位“到底改了什么导致坏的”的工具。从崩溃中恢复的测试脚本早期就写好不要等文件系统完整了再补。后续我打算把USB Mass Storage支持做完让内核能直接从U盘读写FAT文件而不是只读。现在每次按下电源到屏幕上跳出那一行内核启动日志心里的感觉和当初在QEMU里看到hello world完全不一样。那种自己写的代码在真实硬件上一点点爬起来的过程大概是写OS最上头的部分了。
返回列表