“上篇”发布之后,有朋友留言问我:内核已经能在模拟器里跑、调度器也能切换任务了,下一步是不是顺水推舟做点应用就行?我当时也是这么想的。直到我开始碰持久化、USB 和自举,才发现前 45 个 BUG 只是热身。这篇文章记录的就是从 45 个 BUG 到 165 个 BUG 的过程:假持久化欺骗了我整整两天,USB 协议栈差点让我放弃外设支持,OS 自举则重新教会了我什么叫“启动不是弹个黑窗口”。如果你也在写操作系统,或者准备挑战类似项目,这篇踩坑复盘应该能帮你少走几段弯路。
1. 从45到165:BUG数量暴涨不是退步,是系统开始真正接吻硬件
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(dst+offset, 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 控制器为了高性能,引入了 TRB(Transfer 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 命令之后,设备没有任何响应,超时标志被置位。
排查链路:
- 先确认批量端点描述符是否正确解析,打印端点地址、最大包长度、轮询间隔;
- 构造 CBW(Command 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,它的传输流程是:主机先发送 CBW(31 字节的命令块包装器),设备收到后执行命令,然后通过批量传输返回数据,最后通过 CSW(13 字节的命令状态包装器)报告成功或失败。
看起来流程明确,但实际中设备的行为非常“魔幻”。有的 U 盘在收到 RESET 命令后必须等待一定时间才能重新初始化;有的 U 盘对 READ_CAPACITY 命令的响应特别慢;还有的 U 盘会在连续传输大量数据时出现 STALL,主机必须发送 CLEAR_FEATURE 端点清除停止状态,然后才能继续。
我花了整整一周适配各类 U 盘,最终放弃了“完美兼容所有设备”的目标,只保证几个常见型号能用。这也是开源社区许多轻量级内核的做法:与其追求万能兼容,不如先把特定设备跑通,再逐步扩展兼容性表。
4. OS自举:让系统在物理机上自己站起来
4.1 自举到底是什么——从BIOS到内核入口的完整链路
“自举”(bootstrap)这个词来自“pull oneself up by one's 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 自举则让我明白,一个系统能不能站起来,关键不在某个高深算法,而在无数细节是否都咬合到位。如果你也正在写自己的操作系统,请一定为自己准备一个自动化回归脚本,哪怕它简陋得像一个批处理文件,也能在无数个深夜帮你拦住那些本不该出现的回归问题。