mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
6539 字
17 分钟
从零开始的操作系统
2021-12-14

系列简介#

本系列的目标是:从零开始,一步步构建一个完整的操作系统——从引导程序到 Shell 命令行。

编写操作系统是理解计算机底层原理最直接的方式。市面上虽然有不少操作系统教材,但往往因篇幅限制或面向特定体系结构,让初学者难以找到一条循序渐进、可动手实践的路径。本系列试图弥补这一不足。

市面上大多数操作系统课程从概念出发,先讲进程调度、内存管理、文件系统这些高层抽象,再回头看底层实现。这种路径适合建立宏观认知,但对想真正理解「计算机从上电到运行程序究竟发生了什么」的人来说,总觉得少了点什么。本系列反其道而行,从按下电源键的那一刻开始,逐行代码地走完从硬件到软件的整条链路。

完成本系列后,你将亲手构建出一个完整的操作系统:从 512 字节的 MBR 引导程序,到能切换保护模式、开启分页的 Loader,再到加载 ELF 格式内核并跳转到 C 代码执行,最终实现中断处理、内存管理、进程调度、文件系统、系统调用和 Shell——这些不是模拟或伪代码,而是在 QEMU 和真实硬件上都能运行的真实程序。

为什么要学习操作系统开发#

你可能会问:我又不打算写一个生产级操作系统,为什么要花时间学这个?

第一,消除知识的黑盒。 每天都在用的 printfmallocfork,它们背后究竟发生了什么?虚拟地址是怎么变成物理地址的?进程切换时 CPU 做了哪些事?理解这些原理,能让你从「会用」变成「真正理解」。

第二,提升调试能力。 当程序出现段错误、内存泄漏、死锁时,理解底层机制的人能更快定位问题。你知道缺页异常的来龙去脉,就知道为什么某些内存访问模式会导致性能骤降。

第三,面试与职业发展。 互联网大厂的基础架构、数据库、云原生等岗位,操作系统原理是核心考察点。手写过引导程序和内存管理代码,比单纯背诵概念有说服力得多。

第四,纯粹的技术乐趣。 当你第一次看到自己写的 MBR 在屏幕上打印出 Hello World,那种从无到有创造出一个能被 CPU 执行的程序的感觉,是任何高级语言框架都给不了的。

系列大纲#

引导篇:从加电到内核#

章节标题主要内容
第 1 章从系统加电到 ShellBIOS/UEFI 启动流程,GRUB 引导,内核初始化,第一个用户进程
第 2 章手写 MBR 引导程序实模式内存布局,BIOS 磁盘读写 INT 13h,MBR 与 Loader 的结构设计
第 3 章开启保护模式与虚拟内存GDT 描述符表,CR0 切换保护模式,页目录/页表结构,开启分页
第 4 章虚拟内存虚拟地址空间布局,MMU 工作原理,多级页表结构,缺页处理与 TLB
第 5 章正式加载内核ELF 格式解析,内核进入,跳转到 C 代码

内核篇:中断、内存与调度#

章节标题主要内容
第 6 章代码重构:模块化架构设计基础类型定义、VGA 驱动封装、格式化输出、端口 I/O 抽象
第 7 章中断系统:IDT、PIC 与定时器IDT 构建、PIC 编程、时钟中断、键盘中断
第 8 章内存管理:PMM、VMM 与内核堆物理内存管理器、虚拟内存管理器、kheap 分配器
第 9 章任务调度系统:task_t 与上下文切换task_t 结构体、上下文保存与恢复、协作式调度
第 10 章调度器:运行队列与 Round-Robin运行队列、RR 调度算法、定时器驱动的抢占式调度

同步与特权篇#

章节标题主要内容
第 11 章同步原语:自旋锁、互斥锁与信号量自旋锁、互斥锁、信号量的实现与使用
第 12 章用户空间:TSS 与 Ring 3 切换TSS 结构、Ring 0→Ring 3 切换、用户态栈与内核态栈
第 13 章设备驱动:键盘与 ATA 硬盘键盘驱动、ATA PIO 硬盘读写、中断驱动的 I/O

文件与进程篇#

章节标题主要内容
第 14 章文件系统:VFS 与 SimpleFSVFS 抽象层、SimpleFS 磁盘布局、文件读写操作
第 15 章进程管理:fork 与 execfork 进程复制、exec 程序加载、进程生命周期管理
第 16 章系统调用:20 个 POSIX 接口int 0x80 系统调用、POSIX 兼容接口实现

用户空间篇#

章节标题主要内容
第 17 章Shell:命令解析与执行命令行解析、管道与重定向、内置命令
第 18 章用户工具:ls、cat、cp 等命令用户态工具实现、文件操作命令
第 19 章启动镜像:系统集成与自检完整系统镜像构建、启动自检、集成验证

各章重点概念#

第 1 章建立全貌认知。你将了解 CPU 上电后第一条指令从哪里来,BIOS POST 自检做了什么,Legacy BIOS 和 UEFI 有什么区别,GRUB 是如何把内核加载进内存的,以及 init 进程如何成为所有用户进程的祖先。这章不写代码,但它是后续所有章节的知识地基。

第 2 章是动手的起点。你将编写一个 512 字节的 MBR 程序,它会被 BIOS 加载到内存 0x7C00 处执行。核心技能包括:实模式下通过 BIOS INT 13h 读取磁盘扇区,将 Loader 程序从磁盘加载到内存 0x8000,然后跳转过去执行。同时你会深入理解实模式 1MB 内存的布局,知道哪些区域可用、哪些被硬件占用。

第 3 章是技术难度最高的一章。你需要做三件关键的事:构建 GDT(全局描述符表)定义代码段和数据段,将 CR0.PE 位置 1 切换到 32 位保护模式,然后搭建二级页表并开启分页。每一步都有严格的顺序要求和陷阱,比如 GDT 必须在切换保护模式之前加载,页表必须在开启分页之前初始化好。

第 4 章从理论层面深入虚拟内存。在上一章实际开启分页的基础上,这章会解释虚拟地址到物理地址的完整转换过程,为什么需要多级页表,TLB 如何加速地址翻译,以及缺页异常的处理流程。理解这些对后续学习 Linux 内核的内存管理至关重要。

第 5 章完成引导链的最后一环。你将解析 ELF 文件的头部和程序头表,把内核的各个段加载到正确的内存位置,最终从汇编代码跳转到 C 语言的 kernel_main 函数。到此,从按下电源键到内核开始执行 C 代码的完整链路就打通了。

第 6 章是代码质量的关键转折点。内核从「能运行」到「可维护」,需要模块化重构:定义基础类型(u8/u16/u32)、封装 VGA 文本模式驱动、实现 printk 格式化输出、抽象端口 I/O 操作。好的架构让后续功能开发事半功倍。

第 7 章构建中断基础设施。你将设置 IDT(中断描述符表)、编程 8259A PIC、注册时钟中断和键盘中断处理程序。中断是操作系统响应外部事件的根本机制,也是实现抢占式调度的前提。

第 8 章实现内存管理三大组件:物理内存管理器(PMM,位图法分配物理页)、虚拟内存管理器(VMM,管理进程地址空间)、内核堆分配器(kheap,提供 kmalloc/kfree 接口)。没有内存管理,后续的进程和文件系统都无从谈起。

第 9 章定义任务结构和上下文切换机制。task_t 结构体保存进程的寄存器状态、页目录、栈指针等信息,上下文切换通过保存/恢复寄存器实现。这是多任务操作系统的核心。

第 10 章实现 Round-Robin 调度器。运行队列管理所有就绪任务,时钟中断驱动调度决策,每个任务获得相等的时间片。从此内核具备了多任务并发的能力。

第 11 章实现同步原语。自旋锁用于 SMP 短临界区、互斥锁用于可睡眠的长临界区、信号量用于资源计数。没有同步,多任务系统就会数据竞争、死锁。

第 12 章实现用户空间。通过 TSS 结构和 iret 指令从 Ring 0 切换到 Ring 3,为用户进程建立独立的栈空间。这是操作系统安全隔离的基础。

第 13 章编写设备驱动。键盘驱动通过中断接收按键、ATA 硬盘驱动通过 PIO 模式读写扇区。设备驱动是内核与硬件交互的桥梁。

第 14 章实现文件系统。VFS 提供统一抽象层,SimpleFS 定义磁盘布局(超级块、inode、数据块),支持文件的创建、读写和删除。

第 15 章实现进程管理。fork 复制进程地址空间和文件描述符、exec 加载新程序替换进程映像。这是 UNIX 进程模型的精髓。

第 16 章实现系统调用。通过 int 0x80 从用户态进入内核态,实现 20 个 POSIX 兼容接口(read/write/open/close/fork/exec/waitpid 等)。系统调用是用户与内核的唯一通道。

第 17 章实现 Shell。命令行解析、管道(|)和重定向(>/<)、内置命令(cd/exit)。Shell 是用户与操作系统交互的界面。

第 18 章实现用户工具。lscatcpmvrmmkdir 等常用命令,它们通过系统调用操作文件,是用户空间程序的最佳范例。

第 19 章系统集成与自检。将 MBR、Loader、内核、文件系统、用户程序打包为完整的启动镜像,启动后进行自检,验证各子系统协同工作。至此,一个完整的操作系统从零构建完成。

从加电到 Shell:全局视角#

在深入每一章之前,先对整个系统构建流程建立一个整体认知。下图展示了从 CPU 上电到 Shell 运行的关键步骤和各步骤之间的衔接关系:

sequenceDiagram participant CPU as CPU participant BIOS as BIOS/UEFI participant MBR as MBR (0x7C00) participant Loader as Loader (0x8000) participant PM as 保护模式 participant Page as 分页机制 participant Kernel as 内核 (C 代码) participant IDT as 中断系统 participant MM as 内存管理 participant Sched as 调度器 participant User as 用户空间 participant Shell as Shell CPU->>BIOS: 上电复位,CS:IP = 0xF000:0xFFF0 BIOS->>BIOS: POST 自检、硬件初始化 BIOS->>MBR: 加载磁盘第 1 扇区到 0x7C00 Note over MBR: 实模式,16 位 MBR->>MBR: 使用 INT 13h 读取磁盘 MBR->>Loader: 加载 Loader 到 0x8000 并跳转 Loader->>Loader: 构建 GDT,lgdt 加载 Loader->>PM: 设置 CR0.PE = 1,远跳转 Note over PM: 保护模式,32 位 PM->>PM: 初始化页目录和页表 PM->>Page: 设置 CR0.PG = 1 Note over Page: 分页开启,虚拟内存可用 Page->>Kernel: 加载 ELF 内核,跳转到 entry Note over Kernel: C 语言内核开始执行 Kernel->>IDT: 初始化 IDT、PIC、定时器 IDT->>MM: 初始化 PMM、VMM、kheap MM->>Sched: 创建 task_t、运行队列、RR 调度 Sched->>User: TSS 切换 Ring 3、系统调用 User->>Shell: fork + exec 启动 Shell Note over Shell: 用户可以输入命令了

这个流程图揭示了几个关键事实:

  • 整个启动过程是一个接力赛,每一棒都把 CPU 从一个状态推进到下一个状态
  • 模式切换是单向的:实模式 → 保护模式 → 开启分页,没有回头路
  • 每一步都必须在前一步正确完成的基础上才能进行,顺序不能打乱
  • 最终目标是从裸机到用户可交互的 Shell,完成一个完整操作系统的构建

将构建什么#

本系列的最终产出是一个完整的操作系统,从引导程序到 Shell 命令行。涉及的核心组件如下:

graph LR subgraph 引导链 A[mbr.bin<br>512 字节<br>磁盘第 1 扇区] --> B[loader.bin<br>若干扇区<br>磁盘第 2 扇区起] B --> C[kernel.bin<br>ELF 格式<br>磁盘后续扇区] end subgraph 内存布局 D[0x7C00<br>MBR 加载位置] --> E[0x8000<br>Loader 加载位置] E --> F[0x100000<br>内核加载位置<br>1MB 起始] end A -.->|BIOS 加载| D B -.->|MBR 读取| E C -.->|Loader 读取| F style A fill:#ffcdd2 style B fill:#c8e6c9 style C fill:#bbdefb style D fill:#ffcdd2 style E fill:#c8e6c9 style F fill:#bbdefb

引导链的三个程序各司其职:

程序大小加载位置运行模式核心职责
mbr.bin512 字节0x7C00实模式读取磁盘,将 Loader 加载到内存,跳转执行
loader.bin数 KB0x8000实→保护构建GDT,切换保护模式,开启分页,加载内核
kernel.bin数十 KB0x100000保护模式C 语言内核入口,从此进入操作系统开发阶段

内核启动后,将依次初始化以下子系统:

子系统对应章节核心功能
中断系统第 7 章IDT、PIC、时钟中断、键盘中断
内存管理第 8 章PMM 物理页分配、VMM 地址空间管理、kheap 堆分配
任务调度第 9-10 章task_t 结构、上下文切换、RR 调度器
同步原语第 11 章自旋锁、互斥锁、信号量
用户空间第 12 章TSS、Ring 3 切换、用户态栈
设备驱动第 13 章键盘驱动、ATA 硬盘驱动
文件系统第 14 章VFS 抽象层、SimpleFS 实现
进程管理第 15 章fork、exec、进程生命周期
系统调用第 16 章int 0x80、20 个 POSIX 接口
Shell第 17 章命令解析、管道、重定向
用户工具第 18 章ls、cat、cp 等命令

学习路径#

本系列采用「自底向上」的学习路径,每一章都建立在前一章的基础上:

flowchart LR subgraph 引导篇 A[第1章<br>从加电到 Shell] --> B[第2章<br>手写 MBR] B --> C[第3章<br>保护模式与分页] C --> D[第4章<br>虚拟内存深入] D --> E[第5章<br>加载内核] end subgraph 内核篇 E --> F[第6章<br>代码重构] F --> G[第7章<br>中断系统] G --> H[第8章<br>内存管理] H --> I[第9章<br>任务调度系统] I --> J[第10章<br>调度器] end subgraph 同步与特权篇 J --> K[第11章<br>同步原语] K --> L[第12章<br>用户空间] L --> M[第13章<br>设备驱动] end subgraph 文件与进程篇 M --> N[第14章<br>文件系统] N --> O[第15章<br>进程管理] O --> P[第16章<br>系统调用] end subgraph 用户空间篇 P --> Q[第17章<br>Shell] Q --> R[第18章<br>用户工具] R --> S[第19章<br>启动镜像] end style A fill:#e1f5fe style B fill:#e3f2fd style C fill:#bbdefb style D fill:#90caf9 style E fill:#64b5f6 style F fill:#c8e6c9 style G fill:#a5d6a7 style H fill:#81c784 style I fill:#66bb6a style J fill:#4caf50 style K fill:#fff9c4 style L fill:#fff176 style M fill:#ffee58 style N fill:#ffe0b2 style O fill:#ffcc80 style P fill:#ffb74d style Q fill:#ffcdd2 style R fill:#ef9a9a style S fill:#e57373

引导篇(第 1-5 章)#

  • 第 1 章:建立对启动流程的整体认知,理解 BIOS、Bootloader、内核、init 各层的职责
  • 第 2 章:动手实践,编写第一个能运行的引导程序,掌握实模式下的内存和磁盘操作
  • 第 3 章:突破实模式限制,进入 32 位保护模式,搭建虚拟内存的基础设施
  • 第 4 章:深入理解虚拟内存机制,为后续内核开发打下理论基础
  • 第 5 章:完成引导链的最后一步,加载并运行真正的 C 语言内核

内核篇(第 6-10 章)#

  • 第 6 章:模块化重构,让内核代码从「能跑」变成「可维护」
  • 第 7 章:构建中断系统,让内核能响应外部事件
  • 第 8 章:实现内存管理,为进程和文件系统提供基础
  • 第 9 章:定义任务结构,实现上下文切换
  • 第 10 章:实现调度器,让多个任务轮流执行

同步与特权篇(第 11-13 章)#

  • 第 11 章:实现同步原语,解决多任务并发问题
  • 第 12 章:进入用户空间,实现特权级隔离
  • 第 13 章:编写设备驱动,让内核与硬件交互

文件与进程篇(第 14-16 章)#

  • 第 14 章:实现文件系统,支持持久化存储
  • 第 15 章:实现 fork/exec,支持进程创建和程序加载
  • 第 16 章:实现系统调用,建立用户态与内核态的桥梁

用户空间篇(第 17-19 章)#

  • 第 17 章:实现 Shell,用户可以通过命令行与系统交互
  • 第 18 章:实现常用命令工具,验证系统调用和文件系统
  • 第 19 章:集成所有组件,构建完整的启动镜像

知识依赖关系#

各章之间的依赖不只是「读完上一章再读下一章」那么简单。下图展示了核心知识点之间的依赖关系:

graph TD A[实模式内存布局] --> B[BIOS INT 13h 磁盘读写] B --> C[MBR 编写] C --> D[Loader 加载与执行] D --> E[GDT 段描述符] E --> F[CR0 切换保护模式] F --> G[32 位寻址与段选择子] G --> H[页目录与页表构建] H --> I[CR0 开启分页] I --> J[虚拟地址转换] J --> K[TLB 与缺页处理] F --> L[ELF 格式解析] I --> L L --> M[内核加载与跳转] M --> N[模块化重构] N --> O[IDT 与 PIC] O --> P[物理内存管理 PMM] P --> Q[虚拟内存管理 VMM] Q --> R[内核堆 kheap] R --> S[task_t 与上下文切换] S --> T[RR 调度器] T --> U[同步原语] U --> V[TSS 与 Ring 3] O --> W[设备驱动] R --> W W --> X[VFS 与 SimpleFS] S --> Y[fork 与 exec] X --> Y V --> Z[系统调用 int 0x80] Y --> Z Z --> AA[Shell] AA --> AB[用户工具] AB --> AC[启动镜像]

注意几个关键分支点:

  • GDT 是保护模式的入口:不理解段描述符就无法正确切换保护模式
  • 分页建立在保护模式之上:必须先进入保护模式,才能开启分页
  • ELF 解析依赖保护模式:内核通常被链接到 1MB 以上的地址,实模式无法直接访问
  • 中断是调度和驱动的基础:时钟中断驱动调度,键盘中断驱动 Shell 输入
  • 内存管理是进程和文件系统的前提:PMM 为 fork 提供物理页,VMM 为 exec 提供地址空间
  • 系统调用是用户态的桥梁:Shell 和用户工具都通过系统调用与内核交互

前置知识#

阅读本系列之前,建议先了解:

汇编语言基础(x86 指令集)#

本系列使用 NASM 语法编写汇编代码。你需要理解以下概念:

  • 常用寄存器的用途:EAX(累加器)、EBX(基址)、ECX(计数器)、EDX(数据)、ESP(栈指针)、EBP(基址指针)、ESI/EDI(源/目标索引)
  • 内存寻址方式:立即寻址、寄存器寻址、直接寻址、寄存器间接寻址、基址变址寻址
  • 常用指令:movjmpcallretpushpopintlgdtlidt
  • 16 位实模式与 32 位保护模式下指令的差异

可参考 汇编语言:从机器码到现代应用的底层探索

CPU 工作模式#

x86 处理器有三种工作模式,本系列会用到前两种:

模式地址宽度最大寻址空间特点
实模式20 位1MB段地址 × 16 + 偏移地址,无权限检查
保护模式32 位4GB段选择子 + GDT 描述符,支持特权级与分页
长模式64 位256TB64 位寻址,兼容模式,本系列不涉及

可参考 CPU 工作模式

C 语言编程#

从第 5 章开始,内核代码将使用 C 语言编写。你需要熟悉:

  • 指针与内存操作
  • 结构体与位域(解析 ELF 头部时会用到)
  • 链接器的基本概念(段、符号表、重定位)

计算机组成原理#

理解以下概念会让学习过程更顺畅:

  • CPU 流水线与指令执行周期
  • 内存层次结构:寄存器 → Cache → 内存 → 磁盘
  • 中断机制:硬件中断与软件中断的区别
  • I/O 端口与内存映射 I/O

开发环境搭建#

本系列在 Linux 环境下开发,使用以下工具链:

必装工具#

# 汇编器:NASM(Netwide Assembler)
# NASM 是 x86 平台最流行的汇编器之一,语法简洁,支持多种输出格式
sudo apt install nasm
# 模拟器:QEMU(用于无需真实硬件的调试)
# qemu-system-x86 可以模拟完整的 x86 计算机
sudo apt install qemu-system-x86
# 调试器:GDB(可选,用于断点调试引导程序)
sudo apt install gdb
# 十六进制查看工具
sudo apt install hexdump

项目目录结构#

建议按以下结构组织项目代码:

my-os/
├── boot/
│ ├── mbr.asm # MBR 引导程序源码
│ └── loader.asm # Loader 程序源码
├── kernel/
│ ├── entry.asm # 内核入口(汇编,设置栈后调用 C 函数)
│ └── main.c # 内核主函数
├── build/
│ ├── mbr.bin # 编译输出的 MBR 二进制
│ ├── loader.bin # 编译输出的 Loader 二进制
│ ├── kernel.bin # 编译输出的内核二进制
│ └── os.img # 最终的磁盘映像
├── Makefile # 构建脚本
└── run.sh # QEMU 运行脚本

Makefile 模板#

下面是一个完整的 Makefile,可以一键完成编译、链接、生成磁盘映像和启动 QEMU:

# Makefile for OS development
ASM = nasm
QEMU = qemu-system-i386
CC = gcc
LD = ld
# 编译选项
ASM_FLAGS = -f bin
C_FLAGS = -m32 -ffreestanding -fno-pie -nostdlib -nostdinc \
-fno-builtin -fno-stack-protector -nostartfiles \
-nodefaultlibs -Wall -Wextra -c
LD_FLAGS = -m elf_i386 -Ttext 0x1000 --oformat binary
# 源文件
MBR_SRC = boot/mbr.asm
LOADER_SRC = boot/loader.asm
KERNEL_ENTRY = kernel/entry.asm
KERNEL_C = kernel/main.c
# 输出文件
BUILD_DIR = build
MBR_BIN = $(BUILD_DIR)/mbr.bin
LOADER_BIN = $(BUILD_DIR)/loader.bin
KERNEL_BIN = $(BUILD_DIR)/kernel.bin
OS_IMG = $(BUILD_DIR)/os.img
.PHONY: all clean run debug
all: $(OS_IMG)
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
# 编译 MBR
$(MBR_BIN): $(MBR_SRC) | $(BUILD_DIR)
$(ASM) $(ASM_FLAGS) $< -o $@
# 编译 Loader
$(LOADER_BIN): $(LOADER_SRC) | $(BUILD_DIR)
$(ASM) $(ASM_FLAGS) $< -o $@
# 编译内核
$(KERNEL_BIN): $(KERNEL_ENTRY) $(KERNEL_C) | $(BUILD_DIR)
$(ASM) -f elf32 $(KERNEL_ENTRY) -o $(BUILD_DIR)/entry.o
$(CC) $(C_FLAGS) $(KERNEL_C) -o $(BUILD_DIR)/main.o
$(LD) $(LD_FLAGS) $(BUILD_DIR)/entry.o $(BUILD_DIR)/main.o -o $@
# 生成磁盘映像
$(OS_IMG): $(MBR_BIN) $(LOADER_BIN) $(KERNEL_BIN) | $(BUILD_DIR)
cat $(MBR_BIN) > $(OS_IMG)
# 用零填充 MBR 到一个完整磁道(通常 1 磁道 = 18 扇区)
dd if=/dev/zero bs=512 count=17 >> $(OS_IMG) 2>/dev/null
# 写入 Loader
dd if=$(LOADER_BIN) bs=512 seek=1 of=$(OS_IMG) conv=notrunc 2>/dev/null
# 写入内核(从第 10 扇区开始)
dd if=$(KERNEL_BIN) bs=512 seek=10 of=$(OS_IMG) conv=notrunc 2>/dev/null
# 启动 QEMU
run: $(OS_IMG)
$(QEMU) -drive format=raw,file=$(OS_IMG) -m 128M
# 启动 QEMU 并等待 GDB 连接
debug: $(OS_IMG)
$(QEMU) -drive format=raw,file=$(OS_IMG) -m 128M \
-s -S -serial stdio
clean:
rm -rf $(BUILD_DIR)

使用 dd 手动构建磁盘映像#

如果不使用 Makefile,也可以手动用 dd 命令构建磁盘映像:

# 创建 1MB 的空白磁盘映像
dd if=/dev/zero of=os.img bs=512 count=2048
# 写入 MBR 到第 1 个扇区(扇区编号从 0 开始)
dd if=mbr.bin of=os.img bs=512 count=1 conv=notrunc
# 写入 Loader 从第 2 个扇区开始
dd if=loader.bin of=os.img bs=512 seek=1 conv=notrunc
# 写入内核从第 10 个扇区开始
dd if=kernel.bin of=os.img bs=512 seek=10 conv=notrunc

conv=notrunc 参数非常重要,它告诉 dd 不要截断输出文件。如果省略这个参数,dd 会把目标文件截断到写入的数据长度,后续写入的内容就会丢失。

QEMU 常用启动参数#

# 基础启动
qemu-system-i386 -drive format=raw,file=os.img
# 指定内存大小(默认 128MB,建议显式指定)
qemu-system-i386 -drive format=raw,file=os.img -m 128M
# 启用串口输出(方便查看 print 调试信息)
qemu-system-i386 -drive format=raw,file=os.img -serial stdio
# 等待 GDB 远程连接(端口 1234)
qemu-system-i386 -drive format=raw,file=os.img -s -S
# 同时启用 GDB 和串口
qemu-system-i386 -drive format=raw,file=os.img -s -S -serial stdio
# 无图形界面模式(纯串口输出)
qemu-system-i386 -drive format=raw,file=os.img -nographic
# 使用 KVM 加速(如果宿主机支持)
qemu-system-i386 -drive format=raw,file=os.img -enable-kvm

关键参数说明:

参数含义
-s在 TCP 1234 端口监听 GDB 连接
-S启动时暂停 CPU,等待 GDB 发出 continue
-serial stdio将串口输出重定向到终端标准输出
-nographic禁用图形窗口,仅使用串口终端
-m 128M分配 128MB 内存给虚拟机
conv=notruncdd 写入时不截断目标文件

调试技巧#

操作系统开发中的调试比普通程序困难得多,因为你写的就是最底层的代码,没有操作系统帮你打印错误信息。掌握以下调试手段至关重要。

QEMU Monitor#

QEMU 运行时,图形窗口中按 Ctrl+Alt+2 可以切换到 QEMU Monitor 控制台(-nographic 模式下没有此功能)。常用命令:

# 查看寄存器状态
info registers
# 查看内存内容(x/Nx 表示以十六进制显示 N 个字)
x/16x 0x7c00
# 查看页表信息
info tlb
info mem
# 单步执行
singlestep on
# 暂停/继续
stop
cont
# 保存虚拟机快照
savevm snapshot_name
# 恢复快照
loadvm snapshot_name

GDB 远程调试#

这是调试引导程序最强大的方式。启动 QEMU 时加 -s -S 参数,然后在另一个终端启动 GDB:

# 终端 1:启动 QEMU,暂停等待 GDB 连接
qemu-system-i386 -drive format=raw,file=os.img -s -S
# 终端 2:启动 GDB 连接到 QEMU
gdb
(gdb) target remote :1234
(gdb) break *0x7c00 # 在 MBR 入口处设断点
(gdb) break *0x8000 # 在 Loader 入口处设断点
(gdb) continue # 继续执行到断点
(gdb) info registers # 查看寄存器
(gdb) x/16x $esp # 查看栈内容
(gdb) si # 单步执行一条汇编指令
(gdb) ni # 单步执行(跳过函数调用)

调试 16 位实模式代码时,GDB 需要特殊设置:

# 设置为 16 位反汇编模式
set disassembly-flavor intel
set architecture i8086
# 实模式下查看物理内存
x/16xb 0x7c00
# 查看段寄存器
info registers cs ds es fs gs ss

调试保护模式代码时,需要切换回 32 位模式:

set architecture i386

Bochs 调试器#

Bochs 是另一个 x86 模拟器,内置了强大的调试器,特别适合调试引导程序:

# 安装 Bochs(需要带调试器的版本)
sudo apt install bochs bochs-sdl
# 启动 Bochs 调试模式
bochs -dbg

Bochs 调试器的优势在于它对实模式的支持更好,而且可以方便地查看 GDT、IDT、页表等数据结构:

# Bochs 调试器命令
info gdt # 查看 GDT 内容
info idt # 查看 IDT 内容
info page # 查看页表
info tss # 查看 TSS
creg # 查看控制寄存器(CR0, CR2, CR3, CR4)
sreg # 查看段寄存器
vb 0x7c00 # 在物理地址设断点

十六进制检查#

当引导程序行为异常时,第一步通常是检查生成的二进制文件是否正确:

# 查看整个 MBR 的十六进制内容
hexdump -C mbr.bin
# 只看最后两个字节(应该是 0x55 0xAA,即 MBR 签名)
hexdump -C mbr.bin | tail -1
# 用 od 查看
od -A x -t x1z mbr.bin
# 检查 MBR 签名是否正确
# 偏移 510 和 511 处应该是 55 AA
xxd -s 510 -l 2 mbr.bin

常见陷阱与排错#

操作系统开发中有些错误几乎是每个初学者都会遇到的,提前了解可以节省大量调试时间。

MBR 签名缺失#

现象:QEMU 启动后没有任何输出,黑屏。

原因:MBR 的最后两个字节必须是 0x55 0xAA,BIOS 在加载 MBR 后会检查这个签名。如果签名不存在,BIOS 不会跳转到 0x7C00 执行。

解决方法:在 NASM 源码末尾添加:

; MBR 签名(必须位于第 510-511 字节)
times 510 - ($ - $$) db 0
dw 0xAA55 ; 小端序,磁盘上为 55 AA

times 510 - ($ - $$) db 0 这行的作用是用零填充到第 510 字节,确保签名恰好位于正确位置。

org 指令与加载地址不匹配#

现象:字符串打印乱码或跳转到错误地址。

原因org 指令告诉汇编器程序将被加载到哪个地址,所有地址计算都基于这个值。如果 org 0x7C00 但实际加载到别的地址,所有偏移量都会错位。

解决方法:MBR 必须使用 org 0x7C00,Loader 必须使用 org 0x8000(或其他你设定的加载地址),保持与实际加载位置一致。

实模式段寄存器未初始化#

现象:使用 esiedi 等寄存器访问内存时数据错误。

原因:实模式下,内存访问使用 段地址:偏移地址 的方式。dses 等段寄存器必须正确设置,否则即使偏移地址正确,实际访问的物理地址也是错的。

解决方法:在 MBR 入口处初始化段寄存器:

[bits 16]
[org 0x7C00]
start:
xor ax, ax
mov ds, ax ; DS = 0
mov es, ax ; ES = 0
mov ss, ax ; SS = 0
mov sp, 0x7C00 ; 栈指针设在 MBR 下方,向下增长
; ... 后续代码

保护模式切换后忘记远跳转#

现象:设置 CR0.PE = 1 后程序崩溃或行为异常。

原因:修改 CR0 后,CPU 的预取队列中仍然有实模式的指令。需要通过远跳转(far jump)清空预取队列,让 CPU 真正以保护模式取指令。

解决方法

; 设置 CR0.PE = 1
mov eax, cr0
or eax, 1
mov cr0, eax
; 远跳转清空流水线,跳转到 32 位代码段
jmp dword 0x08:protected_mode_entry
[bits 32]
protected_mode_entry:
; 现在才真正在保护模式下执行
mov ax, 0x10 ; 加载数据段选择子
mov ds, ax
mov es, ax
mov ss, ax
; ...

其中 0x08 是代码段选择子(GDT 中第 1 个描述符,RPL=0),0x10 是数据段选择子(GDT 中第 2 个描述符,RPL=0)。

分页开启后地址映射错误#

现象:开启分页后立即三重故障重启。

原因:开启分页(CR0.PG = 1)的一瞬间,CPU 开始通过页表翻译地址。如果页表没有正确映射当前正在执行的代码,CPU 就取不到下一条指令,触发缺页异常。如果缺页处理程序也无法访问(IDT 还没设置),就会触发双倍故障,最终三重故障重启。

解决方法:在开启分页前,确保页目录和页表已经正确初始化,并且当前执行的代码地址已经被映射。常见做法是建立恒等映射(identity mapping),让虚拟地址等于物理地址:

; 假设页目录在 0x100000,页表在 0x101000
; 恒等映射:虚拟地址 = 物理地址
; 设置页目录的第 0 项,指向第一个页表
mov dword [0x100000], 0x101003 ; 页表地址 + Present + R/W 位
; 设置页表的各项,映射前 4MB(1024 个 4KB 页面)
mov ecx, 0
mov edi, 0x101000
.fill_page_table:
mov eax, ecx
shl eax, 12 ; eax = ecx * 4096(物理地址)
or eax, 0x003 ; Present + R/W 位
mov [edi], eax
add edi, 4
inc ecx
cmp ecx, 1024
jl .fill_page_table
; 加载页目录地址到 CR3
mov eax, 0x100000
mov cr3, eax
; 开启分页
mov eax, cr0
or eax, 0x80000000 ; CR0.PG = 1
mov cr0, eax

扩展学习方向#

完成本系列后,你已经有了一个能进入保护模式并加载内核的基础平台。接下来的扩展方向包括:

  • 进入长模式:从 32 位保护模式切换到 64 位长模式,支持更大的地址空间
  • 中断与异常处理:设置 IDT,处理硬件中断和 CPU 异常,这是实现键盘中断、时钟中断的基础
  • 内核内存管理:实现物理内存分配器(buddy system、slab 分配器)和虚拟内存区域管理
  • 进程管理:实现上下文切换、调度器、进程间通信
  • 文件系统:实现简单的文件系统驱动,支持从磁盘读写文件
  • 用户态:实现特权级切换(Ring 0 → Ring 3),系统调用接口

每个方向都值得深入研究。本系列的目标不是构建一个完整的操作系统,而是打通从硬件到内核的这条最关键的路。走通这条路之后,其他的扩展就是在这个基础上的增量开发。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

从零开始的操作系统
https://blog.souloss.com/posts/os/os-series-guide/
作者
Souloss
发布于
2021-12-14
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时