
“上篇”发布之后有朋友留言问我内核已经能在模拟器里跑、调度器也能切换任务了下一步是不是顺水推舟做点应用就行我当时也是这么想的。直到我开始碰持久化、USB 和自举才发现前 45 个 BUG 只是热身。这篇文章记录的就是从 45 个 BUG 到 165 个 BUG 的过程假持久化欺骗了我整整两天USB 协议栈差点让我放弃外设支持OS 自举则重新教会了我什么叫“启动不是弹个黑窗口”。如果你也在写操作系统或者准备挑战类似项目这篇踩坑复盘应该能帮你少走几段弯路。1. 从45到165BUG数量暴涨不是退步是系统开始真正接吻硬件1.1 前45个BUG集中在哪上篇结束时我的“玩具内核”已经具备了几样基础能力能通过 GRUB 加载进内存能在保护模式下初始化 GDT 和 IDT能分配物理页也能让两个任务轮流切换。这 45 个 BUG 绝大多数来自三个地方内存越界、链表操作、中断处理。内存越界是最典型的“新手村”问题。当时我实现物理页分配器时把位图放在了一个静态数组里数组大小计算错误导致分配器本身写穿了相邻的全局变量。表现出来不是立刻崩溃而是过一会儿某个任务的栈指针突然变成垃圾值。为了找这个 BUG我把 qemu 的-d int日志打开逐条看中断触发情况最后才发现写穿发生在分配器初始化阶段。链表操作的问题集中在任务队列和空闲块链表。一开始我用的是单向链表删除中间节点需要额外记录前驱有一次条件判断写错把头节点直接丢了。这类 BUG 在模拟器里不会立刻暴露往往要等任务切换几十次后系统才死锁。中断处理的问题则属于“半懂不懂”我给 PIC 初始化时把 IRQ mask 设置反了导致键盘中断永远触发不了。当时一度以为是 IDT 配置错误折腾了半天才发现是 8259A 的 OCW1 写错了值。这些 BUG 的共同特点是逻辑层面错误调试手段靠 printk 和 qemu 日志就够用。它们虽然多但不难定位修复思路也相对线性。1.2 新增120个BUG的来源拆解下篇新增的功能主要有三块持久化文件系统、USB 驱动、OS 自举引导。最终 BUG 数量从 45 涨到 165多了 120 个。我把它们做了个分类统计BUG 来源数量主要原因文件系统与块设备交互42数据路径抽象不完整、目录项解析边界未处理USB 协议栈48描述符解析、传输类型混淆、设备枚举时序引导与内存布局18链接地址与加载地址不一致、GDT 设置错误回归引入12修改了全局数据结构的语义旧逻辑未同步调整其他0这里留个空行意思是分类统计时强迫自己把每个 BUG 都归因注意第三行“引导与内存布局”只有 18 个不代表自举模块简单而是这部分我写得比较收敛没有像 USB 那样一口气吃成胖子。从这里能看到一个现象新增功能越多BUG 数量非线性增长。原因在于模块之间产生交互。文件系统要访问块设备块设备要挂接在 PCI 总线上PCI 总线又要通过 I/O 端口访问而 USB 控制器本身也是 PCI 设备。一个 USB 存储设备要工作需要内核具备完整的“PCI 枚举 中断路由 块设备抽象 文件系统”四条链路。任何一环出问题最终都表现为“设备不工作”但真正原因可能藏在完全不同的模块里。1.3 一个关键认知BUG数量不是质量的衡量标准写 OS 修到第 100 个 BUG 的时候我一度怀疑自己是不是在做无用功。后来想明白一个道理BUG 数量上升并不代表代码变烂而代表系统开始触碰到真正的硬件复杂度和边界条件。前 45 个 BUG 对应的是“纯逻辑”问题新 120 个 BUG 则大多来自“硬件行为与预期不符”。比如 USB 设备对 Set Address 指令后的处理时间有长有短有的设备必须等 10ms 才能接收下一个请求有的设备 1ms 就能响应。如果你按照自己的主观节奏发送请求就会踩到随机失败。这不是代码错误而是没有考虑到设备的物理特性。所以我对“BUG 越修越多”的看法是只要每个 BUG 都带来了对系统的新理解这个趋势就是良性的。真正需要警惕的是同一个 BUG 反复出现那说明没有找到根因。2. 假持久化写进去的文件关机后人间蒸发2.1 我的持久化为什么是“假”的——数据路径上的偷懒持久化的需求一开始听起来很简单把内存里的文件数据保存到磁盘上重启后还能读出来。我最初的实现是在内存里维护了一个“虚拟磁盘”用一段 8MB 的物理内存模拟块设备上面实现了一个简单的 FAT-like 文件系统目录区记录文件名字、起始扇区和大小数据区存放内容。测试的时候一切正常写文件、读文件、列目录都没有问题。直到我把整个内核那个可虚拟机镜像一关重新启动然后发现文件全部消失。那一刻才意识到我一直把数据写在“内存盘”里根本没有真正写进磁盘镜像文件。这个问题的根源在于数据路径太短又太隐蔽。在 qemu 里模拟磁盘是一个独立文件比如disk.img。要让磁盘真的持久化操作系统必须通过 PCI/IDE/AHCI 控制器发起 DMA 或 PIO 写操作把数据搬向磁盘控制器的寄存器再由控制器固件把数据写进镜像文件。而我的“虚拟磁盘”直接用一个内存数组模拟了块设备接口block_read和block_write操作的都是内存地址。对内核来说它确实“写盘”了对用户来说关机等于一切归零。2.2 真实文件系统到底怎么保证数据落盘为了把假持久化改成真持久化我先去翻了真实文件系统的设计思路。Linux 里写一个文件并不等于数据立刻落到磁盘上。整个路径是用户调用write()后VFS 层把数据放进 page cache标记为脏页内核线程在合适时机调用writeback把脏页通过块设备层提交给驱动程序磁盘驱动真正把数据写入物理介质后DMA 完成中断触发整个事务才算结束。这里有两个关键语义缓存回写writeback数据先缓存在内存稍后异步刷盘掉电安全fsync / FUA如果用户需要确保数据落盘必须显式调用同步接口。在真实内核里写丢数据不是罕见事尤其是磁盘缓存策略设置为 write-back 时突然断电会导致数据丢失。所以我意识到我在内核里偷懒的“内存盘”并不是真正的设备驱动而是一个迎合上层接口的仿真玩具。想实现真持久化必须打通“设备访问”这一整条链路。2.3 完整排查链路从“咦文件在”到“文件没了”发现假持久化问题的过程其实是一次标准的排查链路值得复盘先在模拟器里写了一个文件/hello.txt内容为hello, os读取该文件内容正确返回重新启动系统文件消失初步怀疑是文件系统初始化代码复位了目录区于是用 printk 打印目录区内容发现目录区 JDK里确实是空白排查文件系统创建逻辑发现初始化时会调用format_disk()把数据区清零接着怀疑是启动流程在格式化前没有加载磁盘内容于是查看 block 层的初始化顺序终于定位到block_write()的底层实现只是memcpy(dstoffset, src, len)目标地址是内存中预分配的缓冲区跟模拟磁盘镜像毫无关系。当时代码里有一段特别迷惑的注释// TODO: 真正写盘。看到这行注释时我的心情和看到“这里应该没问题”一样复杂。修复方向有两个一是为 IDE/AHCI 控制器写一个真正的驱动二是继续走捷径让 qemu 支持的内核把数据通过 virtio 或者串口转发给宿主机。我最终选择了 AHCI虽然工作量大一些但至少能真正理解硬盘控制器的运作方式。2.4 修补假持久化先写一个能断电不丢的极简文件系统实现 AHCI 驱动的过程又是另一场硬仗。AHCI 的核心是把命令写入 HBA 内存空间的 Command List然后设置PxCI寄存器通知控制器开始执行。它的关键数据结构包括 Command Header、Command Table、PRD物理区域描述符。每个 PRD 描述一段物理内存控制器通过 DMA 把数据搬到磁盘或从磁盘搬到内存。真正让我卡住的不是 DMA 本身而是 PCI 总线上 MMIO 地址映射。AHCI 控制器暴露的 HBA 内存是 MMIO 区域必须先把该物理地址映射进内核的虚拟地址空间然后通过内存读写访问寄存器。我当时直接用了ioremap的简化版直接把物理地址强制转成虚拟地址访问结果遇到缓存一致性问题——CPU 缓存了寄存器内容导致修改PxCI后控制器读取到旧值。解决办法是打开 MMIO 的强 uncacheable 属性。在 x86 上最简单的方式是设置 MTRR 或 PAT把该区域标记为 UC。我用的是 CR3 页表里的 PAT 位把页表项的 PAT 位设为 1强制不可缓存。搞定 AHCI 后我就实现了第一个“断电不丢”的文件系统目录区 64 个条目数据区从第 100 个扇区开始写文件时把数据通过 AHCI 写入磁盘镜像读文件时同样从磁盘读回。这次重启后文件还在。虽然协议仍然简陋但至少“持久化”三个字终于名副其实。3. USB地狱为什么USB协议栈值得一个专属形容词3.1 USB难在哪比串口高一个维度的协议复杂度如果说硬盘驱动是“熟悉套路就能过关”USB 驱动就是完全不同的物种。USB 协议的复杂度在于它的层次非常多物理层负责信号编码和电气特性链路层负责传输事务token、data、handshake协议层负责包格式和错误检测再往上还有设备框架层的描述符、接口、端点最后才是各类设备类协议HID、Mass Storage、CDC 等。USB 的难点可以拆成几个方面设备枚举不是一次“读寄存器”就完成的而是一连串状态机和时序的组合同一个端点上传输可以是控制、批量、中断、同步四种类型处理逻辑各不相同设备对错误恢复的要求极高一个字节的 CRC 错误可能导致整个传输事务重试xHCI 控制器为了高性能引入了 TRBTransfer Request Block环形队列所有请求都通过内存数据结构提交控制器异步完成后用事件 TRB 通知软件。我最初的 USB 驱动基于 UHCI相对简单但它的限制也很明显UHCI 只支持低速和全速设备不能直接接 USB 3.0 存储设备。后来我转向 xHCI才真正进入“地狱模式”。3.2 四种传输类型与枚举流程的关键细节先说说四种传输类型传输类型用途特点控制传输设备枚举、获取描述符、设置配置双向且由 Setup 事务发起批量传输U 盘、SSD 等大块数据传输带宽大无实时性保证中断传输键盘、鼠标等低频输入有轮询周期保证延迟同步传输音频、摄像头保证带宽不保证正确性USB 设备枚举的标准流程是设备接入后主机控制器检测到端口状态变化触发端口事件软件对端口执行复位等待设备进入“已复位”状态随后主机发送SET_ADDRESS控制请求为设备分配一个新的地址接下来依次获取设备描述符、配置描述符、接口描述符和端点描述符最终发送SET_CONFIGURATION设备进入“已配置”状态可以开始数据传输。这个流程看起来不长但每一步都有坑。我踩得最深的坑是发送SET_ADDRESS之后立即发送后续控制请求设备经常不响应。翻阅 xHCI 规范才发现控制器在收到SET_ADDRESS后需要一个短暂的时间来切换设备地址软件必须等待一个完成事件或者至少插入几毫秒的延迟才能继续访问新地址。有些设备宽容快速访问也能通过有些设备则严格按照规范立刻访问就直接返回 STALL。3.3 一个SCSI命令超时的完整排查过程USB 驱动里让我印象最深的 BUG 是插入 U 盘后能成功枚举但读取扇区时永远超时。现象描述U 盘枚举成功可以通过GET_DESCRIPTOR拿到设备信息设置配置也返回成功。但发送第一个 READ_10 SCSI 命令之后设备没有任何响应超时标志被置位。排查链路先确认批量端点描述符是否正确解析打印端点地址、最大包长度、轮询间隔构造 CBWCommand Block Wrapper数据包检查字节序和长度发送后观察设备是否有响应发现设备完全没有 ACK于是怀疑 CBW 的端点地址错误对照设备描述符发现U 盘有两个批量端点一个方向为 OUT一个方向为 IN。我在构造 UR B 时把 CBW 发到了 IN 端点写成了0x81而 CBW 必须发送到 OUT 端点修正方向位后设备正确返回 CSW数据读取正常。这个 BUG 让我意识到USB 驱动的调试必须依赖抓包工具。我后来买了一个廉价的 USB 分析仪把总线上的 Token、Data、Handshake 全部抓下来看效率比盲目改代码高得多。若没有硬件抓包工具也可以使用 qemu 的 USB 虚拟设备配合日志输出但 qemu 对 xHCI 的模拟在传输时序上不够真实很多问题在实体机上才能复现。3.4 USB键盘能用之后我差点放弃了USB存储解决枚举问题后我先把目标放在 USB 键盘上。HID 键鼠设备的协议相对简单只需要配置好控制传输和中断传输的轮询周期键盘的按键报告就能稳定收进来。当终端里出现第一个按键字符时说实话那一刻比完成文件系统还激动。但 USB 存储设备跟键鼠完全是两个世界。USB Mass Storage 协议基于 Bulk-Only Transport它的传输流程是主机先发送 CBW31 字节的命令块包装器设备收到后执行命令然后通过批量传输返回数据最后通过 CSW13 字节的命令状态包装器报告成功或失败。看起来流程明确但实际中设备的行为非常“魔幻”。有的 U 盘在收到 RESET 命令后必须等待一定时间才能重新初始化有的 U 盘对 READ_CAPACITY 命令的响应特别慢还有的 U 盘会在连续传输大量数据时出现 STALL主机必须发送 CLEAR_FEATURE 端点清除停止状态然后才能继续。我花了整整一周适配各类 U 盘最终放弃了“完美兼容所有设备”的目标只保证几个常见型号能用。这也是开源社区许多轻量级内核的做法与其追求万能兼容不如先把特定设备跑通再逐步扩展兼容性表。4. OS自举让系统在物理机上自己站起来4.1 自举到底是什么——从BIOS到内核入口的完整链路“自举”bootstrap这个词来自“pull oneself up by ones bootstraps”意思是系统靠自身机制启动。操作系统领域的自举指的是计算机从加电开始、在没有任何外部帮助的情况下把内核从磁盘加载到内存并运行起来的过程。整个链路大致是加电后CPU 从固定物理地址开始执行固件代码BIOS 或 UEFI 完成硬件初始化后读取引导介质第一个扇区512 字节到内存的0x7C00处然后跳转执行这个引导扇区不仅要在 512 字节内完成基础硬件初始化还要把真正的内核加载进内存最后跳转过去。我以前在模拟器里启动内核依赖 GRUB 加载 ELF 文件内核入口很简单GRUB 按照 ELF 头的程序头表把各个段加载到对应虚拟地址然后跳转。但当我决定脱离 GRUB、自己写引导器时才真正理解了“从 0 到 1”的含义。4.2 保护模式切换、GDT 与地址一致性最容易翻车的三连引导扇区代码运行在实模式下CPU 默认 16 位寻址最大访问内存只有 1MB。而现代内核几乎都是 32 位或 64 位代码运行在保护模式或长模式下所以引导程序必须完成从实模式到保护模式的切换。这一步有三个任务关闭中断设置 A20 地址线解除 1MB 寻址上限构造 GDT加载到 GDTR 寄存器设置段寄存器的段选择子修改 CR0 的保护模式位然后跳转到 32 位代码段。听起来顺序明确但第一个大坑就在“跳转到 32 位代码段”这一步。如果跳转指令的目标地址没有经过段选择子的重定位CPU 会把实模式的段基址错误地叠加到目标地址上导致执行流直接飞到未知区域。第二个大坑是 GDT 中代码段的基址设置。GDT 的每个描述符都包含段基址和段界限如果代码段的基址设置为0x00000000那么 CS 段选择子选中它后线性地址就等于偏移地址如果基址被错误地写成0x00007C00那么从 0x100000 执行跳转时CPU 会访问 0x107C00 的地址直接后果就是取指错误。第三个坑来自分页机制。如果内核开启了分页引导程序必须建立好页表并确保跳转前后的虚拟地址映射一致否则开启分页的瞬间 CPU 可能用旧地址继续取指立刻触发 Page Fault。我实际调试时在开启分页后遇到了这个问题解决办法是通过一条长跳转指令刷新流水线和段状态让 CPU 重新从新的地址空间取指。4.3 从“能启动”到“自举”第二阶段引导的设计取舍512 字节的引导扇区空间非常有限连一个完整的内核都可能放不下更别说解析 FAT 文件系统目录、加载内核文件等操作了。常见的做法是“两阶段引导”第一阶段boot sector初始化基础环境读取磁盘上的第二阶段引导程序到内存第二阶段loader把控制权交给第二阶段代码负责实际的内核加载和启动参数设置。我的第一阶段引导程序做了这几件事关闭中断、启用 A20、加载 GDT、进入保护模式、读取磁盘根目录区、找到名为KERNEL.BIN的文件、加载到内存地址0x100000、跳转执行。这里有个容易忽略的细节在内核实模式进入保护模式的过程中“取下一条指令”的地址可能已经过了 0x7C00 区域跳转时必须用绝对地址而不是相对地址。我最初写的跳转指令是jmp stage2_start在汇编器里它可能被编码为相对跳转导致地址计算错误。改成显式的 far jump指定段选择子和偏移地址问题立刻消失。4.4 为什么我最终把引导器限定在两个扇区内写完第一阶段引导器后我统计了一下大小刚好 512 字节一个扇区。第二阶段加载器因为包含磁盘驱动和 ELF 解析逻辑占了两千多字节。我本来想到用 FAT 文件系统直接加载 ELF 文件省去额外步骤但后来放弃了。原因有两个第一ELF 段的虚拟地址可能在链接时被固定而引导阶段还没有开启分页无法直接把所有段加载到高地址第二ELF 解析需要处理 program header table 的偏移、类型、标志等字段在引导器里实现这些逻辑很繁琐容易出错。最终我把内核编译成扁平格式flat binary由第二阶段加载器直接把整个文件复制到约定地址省掉所有解析。这个决定换来了引导阶段的稳定性两个扇区内的代码总共只有几百行逻辑足够简单没有太多出错空间。对自举来说稳定优先于功能完备。5. 165个BUG之后测试、复现与“最小改动原则”开始起作用5.1 给内核补回归测试哪怕只是最笨的那种当 BUG 数量上升到三位数时我意识到一个问题每修一个 BUG 都可能引入另一个 BUG。没有回归测试的内核开发就等于在不带安全绳的情况下走钢丝。我给内核补的“测试”非常笨重但有效把 qemu 启动时的输出重定向到文件自动执行一组固定任务比如创建文件、写入固定内容、读取并比对、切换若干次任务。每次修改代码后跑一遍这套流程如果发现某项测试失败说明这次改动破坏了既有功能。还有一种测试方式是用 qemu 的内存快照功能。执行到指定断点时记录整个内存状态下一次运行时在同样断点恢复快照对比哪里的数据不一致。这个方法在调试内存越界时特别有用因为它可以把“何时被修改”转化为“哪一步修改了它”。5.2 学会构造最小复现环境而不是盲修修 BUG 最忌讳的是“根据现象猜原因”。现象往往是多个因素叠加的结果猜对的概率不高。所以我的原则是遇到 BUG先想办法构造一个最小复现环境把无关变量全部去掉。举一个具体例子USB 键盘偶尔丢键表现为按下按键后终端没有输出。一开始我以为是 HID 协议解析问题反复检查报告描述符。后来我构造了一个最小环境只初始化键盘设备、接收中断传输、打印原始字节不运行任务调度器。结果发现丢键问题完全消失。再加入调度器问题复现。这时候定位方向才明确调度器在频繁切换任务时中断处理函数的延迟太长USB 中断传输的轮询节奏被打乱导致设备认为主机没有读取数据。修复方式是在中断入口处加入一个快速路径如果当前中断来自 USB 控制器先不保存大量寄存器直接调用 USB 驱动的中断处理回调处理完再继续正常中断流程。这样能把 USB 中断响应时间从几十微秒降到几微秒。5.3 两个改动引发的连锁BUG案例最后说一个让我印象深刻的连锁 BUG。某个版本里我把物理内存布局调整了一下把内核镜像的加载地址从0x100000改到0x200000原因是给后续模块留出空间。结果出现了三个症状文件系统中的数据开始随机损坏ACPI 表无法访问用户键盘输入偶尔无效。排查后发现第一个症状是因为文件系统驱动里有一个硬编码的缓冲区地址偏移量还是按旧布局计算的第二个症状是因为 ACPI 表被加载到了一个和内核镜像冲突的物理地址第三个症状则是因为 USB 驱动的 EHCI BAR 映射区域被覆盖。这个案例证明操作系统开发中模块之间的隐式依赖比显式调用更危险。改一个地址牵动的可能是十几个模块的隐含假设。我现在记录每个模块的“假设清单”比如“这个地址不能超过多少”“这个缓冲区必须 16 字节对齐”避免改完一个地方又把另一个地方弄坏。回头看看这 165 个 BUG真正让我成长的不是“修了多少个”而是“学会用什么方式定位问题”。假持久化教会我先打通数据路径再谈速度USB 地狱教会我设备的行为才是最终标准OS 自举则让我明白一个系统能不能站起来关键不在某个高深算法而在无数细节是否都咬合到位。如果你也正在写自己的操作系统请一定为自己准备一个自动化回归脚本哪怕它简陋得像一个批处理文件也能在无数个深夜帮你拦住那些本不该出现的回归问题。