从零构建操作系统:引导程序、保护模式与内核开发实战
1. 为什么我们要从零开始造轮子“从零开发一个操作系统”这个念头听起来既疯狂又迷人。它不像学一门新语言或者一个新框架更像是决定从烧制砖块开始亲手盖一座摩天大楼。很多人第一反应是现在有Linux、Windows、macOS甚至各种RTOS为什么还要自己从头写这纯粹是浪费时间吗作为一个在底层系统领域摸爬滚打多年的老码农我可以很负责任地告诉你这个过程的价值远超你的想象。它不是一个为了“造”而“造”的玩具项目而是一次对计算机灵魂的深度解剖。当你真正动手从按下电源键到屏幕上出现第一个字符这中间发生的每一件事你都将了如指掌。你会明白为什么程序需要编译、链接内存是如何被划分和管理的CPU是如何从一条简单的指令开始跳舞的。这种理解是通透的、骨子里的它会彻底改变你写代码的思维方式。以后遇到任何“玄学”问题比如内存泄漏、死锁、驱动兼容性你都能下意识地联想到底层硬件和系统软件是如何交互的从而快速定位到问题的根源。这比读十本操作系统原理教材都管用。那么谁适合踏上这段旅程首先你需要有强烈的求知欲和足够的耐心因为前期的“黑暗”阶段会很长你可能要面对几个小时甚至几天都看不到任何输出的调试。其次最好具备一些C语言和汇编语言的基础尤其是对指针、内存地址有清晰的认识。如果你对计算机组成原理有一定了解那就更好了。但别被吓到即使你是新手只要跟着清晰的路线图一步步走完全有可能走通。我们这次的目标不是做一个比Linux更强大的通用系统而是构建一个能够自己启动、在屏幕上打印字符、并能响应键盘输入的“最小可行操作系统”。麻雀虽小五脏俱全它将包含引导、内核、基础驱动和交互的核心概念。2. 环境搭建选择你的“数字实验室”在开始敲代码之前我们必须建立一个可靠的开发与调试环境。物理机直接调试操作系统是不现实的任何一个小错误都可能导致系统崩溃甚至硬件损坏。因此我们需要一个虚拟机它就像我们的数字实验室允许我们安全、反复地“折腾”。2.1 虚拟机选型Bochs vs QEMU这里有两个主流选择Bochs和QEMU。它们各有优劣选择哪个取决于你的侧重点。Bochs这是一个纯粹的解释型模拟器。它用软件模拟了整个x86 PC环境包括CPU、芯片组、显卡、硬盘等。它的最大优点是调试功能极其强大。你可以设置任意内存断点、指令断点单步执行每一条汇编指令并完整观察所有寄存器、内存和端口的状态变化。这对于操作系统开发初期尤其是引导程序和内核初始化的调试是无价之宝。它的缺点是速度慢因为每条指令都是软件模拟的。但对我们这个阶段来说速度不是关键洞察力才是。很多经典的操作系统教程如《操作系统真相还原》都首选Bochs。QEMU这是一个快速的处理器模拟器它支持动态翻译TCG甚至可以利用硬件虚拟化KVM来获得接近物理机的速度。QEMU更快功能也更丰富支持多种架构。它的调试功能通过GDB stub也很强大但配置和使用上可能比Bochs稍显复杂一些对底层状态的观察不如Bochs那么直观。我的建议是初学者首选Bochs。它的调试器是内置的、交互式的能让你清晰地看到计算机启动的每一个细节。当你对启动流程非常熟悉后可以切换到QEMU进行更快速的测试。在本文中我们将以Bochs作为主要环境。2.2 工具链准备编译器与链接器我们的系统将主要用C语言和汇编语言编写。我们需要一个能够生成在裸机上运行的、不依赖任何现有操作系统库的代码的编译器。这就是“交叉编译器”的概念不过对于x86平台我们通常使用GCC和NASM。GCCGNU编译器套件。我们需要它来编译C代码。关键是要告诉它我们的目标环境是“裸机”不要链接标准C库并且要生成适合在保护模式之前运行的32位代码。对应的关键编译选项是-m32生成32位代码、-ffreestanding指示编译器我们是在独立环境中编译不依赖标准库、-nostdlib不链接标准库。NASMNetwide Assembler。这是一个强大、流行的x86汇编器。我们将用它来编写最开始的引导扇区代码和部分底层汇编例程。它的语法清晰比GASGNU Assembler更易读。LDGNU链接器。我们需要它把多个编译好的目标文件.o文件链接成一个最终的可执行文件并且要按照我们指定的内存地址进行布局。这需要通过编写链接脚本Linker Script来实现这是操作系统开发中的一个核心环节。在Ubuntu/Debian系统上你可以通过以下命令安装所需工具sudo apt-get update sudo apt-get install build-essential nasm bochs bochs-sdl bochsbios vgabiosbuild-essential包含了GCC和LD等基础编译工具。2.3 项目结构与第一个文件让我们先创建一个清晰的项目目录结构这有助于管理越来越多的源文件。my_os/ ├── boot/ # 引导相关代码 │ ├── boot.asm # 主引导记录MBR │ └── loader.asm # 第二阶段加载器 ├── kernel/ # 内核代码 │ ├── entry.asm # 内核入口点汇编 │ ├── main.c # 内核主函数C语言 │ └── ... ├── libs/ # 未来放自己写的库 ├── scripts/ # 链接脚本、构建脚本 │ └── link.ld ├── Makefile # 自动化构建 └── bochsrc.txt # Bochs虚拟机配置文件现在我们先创建最关键的bochsrc.txt配置文件。这个文件告诉Bochs如何模拟我们的硬件。# bochsrc.txt megs: 32 # 模拟32MB内存足够我们初期使用 romimage: file/usr/share/bochs/BIOS-bochs-latest vgaromimage: file/usr/share/bochs/VGABIOS-lgpl-latest boot: disk # 从磁盘启动 # 创建一个虚拟硬盘如果不存在的话 ata0-master: typedisk, pathmy_os.img, modeflat, cylinders20, heads16, spt63 # 打开日志方便调试 log: bochslog.txt panic: actionask error: actionreport debug: actionignore info: actionreport # 启用调试器 display_library: sdl debugger: enabled这个配置定义了一个32MB内存的虚拟机其硬盘对应我们即将创建的my_os.img镜像文件。3. 穿越黑暗从通电到第一个字符这是最激动人心也最挑战性的部分——引导过程。当你按下电源键CPU复位从物理地址0xFFFF0CS:IP 0xF000:0xFFF0开始执行那里是BIOS或UEFI的代码。我们的旅程就从这里开始。3.1 主引导记录512字节的奇迹BIOS完成自检后会按照预设的顺序如硬盘、光驱、USB寻找可引导设备。对于硬盘它会读取第一个扇区512字节即主引导记录并检查其最后两个字节是否为魔数0x55AA。如果是BIOS就将这512字节数据加载到内存0x7C00处然后跳转过去执行。这512字节就是我们的第一阶段引导程序。它的任务极其有限因为空间太紧张了。它主要做两件事1. 将自己移动到内存中一个安全的位置比如0x90000避免被后续加载的数据覆盖2. 从磁盘上加载真正的、更大的第二阶段加载器Loader到内存然后跳转过去。下面是一个极度简化的boot.asm示例它用NASM语法编写[org 0x7c00] ; 告诉编译器这段代码将被加载到 0x7c00 mov ax, cs mov ds, ax ; 设置数据段 DS CS mov es, ax ; 设置附加段 ES CS ; 在屏幕上打印一个字符 B (引导开始) mov ah, 0x0e ; BIOS 中断 0x10 的功能号电传打字机输出 mov al, B int 0x10 ; 这里本应包含从磁盘加载Loader的代码... ; 为了简化我们用一个死循环代替 jmp $ times 510-($-$$) db 0 ; 填充剩余空间确保总长度为510字节 dw 0xaa55 ; 引导扇区结束标志注意这只是一个演示打印的“玩具”MBR。一个真正的MBR必须包含磁盘读取代码这涉及到BIOS中断int 0x13需要正确设置驱动器号、磁头、柱面、扇区等参数。这是第一个坑点磁盘CHS参数与LBA转换务必参考主板BIOS和Bochs的文档。使用NASM编译它nasm -f bin boot/boot.asm -o boot/boot.bin3.2 创建硬盘镜像并写入引导扇区现在我们需要创建一个空的硬盘镜像文件并把编译好的boot.bin写入它的第一个扇区。# 创建一个全零的1MB镜像文件足够初期使用 dd if/dev/zero ofmy_os.img bs512 count2048 # 将引导程序写入镜像的第一个扇区 dd ifboot/boot.bin ofmy_os.img bs512 count1 convnotruncconvnotrunc参数至关重要它确保只覆盖第一个扇区而不截断清空整个镜像文件。3.3 配置Bochs并首次启动确保bochsrc.txt中的pathmy_os.img指向正确的路径。然后在终端运行bochs -f bochsrc.txt -q-f指定配置文件-q表示快速启动跳过初始菜单。Bochs会启动并弹出一个窗口。如果一切正常你应该能在屏幕上看到一个孤零零的字母B。恭喜你的“操作系统”已经完成了从硬件上电到执行你代码的第一步虽然它什么都没做但这是一个从0到1的质变。此时你可以按CtrlC退出Bochs或者在Bochs命令行输入quit。查看bochslog.txt文件你能看到Bochs模拟的CPU执行的每一条指令这是绝佳的调试资料。4. 搭建基石进入保护模式与内核加载在屏幕上打印一个‘B’只是开始。实模式Real Mode下我们只能访问1MB内存且没有内存保护非常脆弱。现代操作系统都运行在保护模式Protected Mode下。因此第二阶段加载器Loader的核心任务就是准备并切换到保护模式然后加载内核到内存最后跳转到内核入口。4.1 编写第二阶段加载器Loader会比Boot大得多我们可以把它放在硬盘上Boot之后的连续扇区中。Boot的工作就是把它读入内存例如0x90000然后跳转过去。Loader的主要工作流程如下继续初始化可能关闭中断设置栈空间。检测物理内存通过BIOS中断int 0x15, ax0xE820获取内存布局信息保存下来供内核使用。这是操作系统管理内存的基础。加载全局描述符表保护模式的核心数据结构之一。GDT定义了内存段的属性基地址、界限、类型、权限等。我们需要在内存中准备好GDT然后用lgdt指令加载GDTR寄存器。打开A20地址线这是一个历史遗留问题。为了兼容早期8086第21根地址线A20默认是关闭的这会导致访问超过1MB的地址时回绕。必须打开它才能使用全部32位地址空间。方法是通过键盘控制器端口0x64/0x60或Fast A20方式端口0x92。设置CR0寄存器将CR0寄存器的PE位第0位设置为1这标志着CPU进入保护模式。远跳转执行一个远跳转指令jmp dword SELECTOR_CODE:protected_mode_entry这会清空CPU的指令流水线并以新的代码段选择子开始执行。至此CPU正式运行在保护模式下了。初始化保护模式下的段寄存器设置DS, ES, SS等段寄存器为数据段选择子。读取内核从硬盘上例如从第100个扇区开始将内核的二进制文件读取到内存中一个约定的地址比如0x1000001MB处这是传统上可用的物理内存开始区域。跳转到内核内核文件的开头是一个入口点。Loader需要根据内核文件的格式例如ELF格式或者我们自定义的简单格式找到这个入口地址然后跳转过去将控制权彻底交给内核。由于这部分汇编代码较长这里不展开全部。但关键点在于GDT的设置和A20的开启是两大常见坑点。GDT描述符的每个字段基地址、界限、属性字节都必须计算正确一个比特的错误都可能导致CPU触发保护异常系统立刻挂起。A20开启方法如果不对在访问高端内存时会出现诡异的数据错误。4.2 内核的“Hello World”混合编程假设我们的内核入口是一个汇编函数_start它位于kernel/entry.asm。它的任务是为C语言环境做准备然后调用用C写的内核主函数。kernel/entry.asm:[BITS 32] ; 告诉编译器生成32位保护模式代码 global _start ; 导出符号供链接器使用 extern kernel_main ; 声明外部符号即C语言的主函数 section .text _start: ; 设置保护模式下的栈指针栈向下增长 mov esp, 0x90000 ; 栈顶设在 0x90000这是一个安全区域 ; 调用C语言内核主函数 call kernel_main ; 如果kernel_main返回理论上不应该则挂起 cli ; 关闭中断 hlt ; 停机kernel/main.c:// 定义一个函数用于向屏幕写入字符 // 在保护模式下我们不能再用BIOS中断需要直接写显存 // 文本模式显存起始地址是 0xB8000 void put_char(char c, int color) { volatile char* video_memory (volatile char*)0xB8000; static int cursor_pos 0; // 简单的光标位置 if (c \n) { cursor_pos (80 - (cursor_pos % 80)); // 移动到下一行开头 } else { video_memory[cursor_pos * 2] c; video_memory[cursor_pos * 2 1] color; cursor_pos; } // 简单的屏幕滚动处理略 } void kernel_main(void) { // 清屏用空格填充 for (int i 0; i 80 * 25; i) { put_char( , 0x07); // 灰色背景白色字体 } // 在屏幕中央打印 Hello, My OS! char* msg Hello, My OS!; int start_pos (80 * 12) (40 - (strlen(msg) / 2)); // 粗略居中 volatile char* vid_mem (volatile char*)0xB8000; for (int i 0; msg[i] ! \0; i) { vid_mem[(start_pos i) * 2] msg[i]; vid_mem[(start_pos i) * 2 1] 0x0A; // 黑底绿字 } // 挂起 while(1); }注意这里的strlen函数我们还没有实现我们需要一个最简单的、不依赖标准库的实现。这就引出了下一个关键点。4.3 链接脚本告诉程序“你住在哪里”我们有了Boot、Loader、内核入口和主函数等多个目标文件。链接器需要知道如何把它们拼装成一个整体更重要的是每个部分应该放在内存的什么地址。这就是链接脚本Linker Script的工作。scripts/link.ld:/* 指定输出格式为32位ELF这是Bochs可以识别的格式之一 */ OUTPUT_FORMAT(elf32-i386) OUTPUT_ARCH(i386) /* 指定入口点为 _start 我们汇编代码中的符号 */ ENTRY(_start) SECTIONS { /* 内核将被加载到物理地址 1MB (0x100000) 处。 Loader需要知道这个地址并把内核读到这里。 */ . 0x100000; /* 代码段 */ .text : { *(.text) /* 所有输入文件的 .text 段 */ } /* 只读数据段 */ .rodata : { *(.rodata*) } /* 已初始化的数据段 */ .data : { *(.data) } /* 未初始化的数据段BSS段在加载时其内容应清零 */ .bss : { *(COMMON) *(.bss) } /* 可以在这里定义一些符号供代码使用例如内核的结束地址 */ end .; }这个脚本告诉链接器从地址0x100000开始放置代码段然后是只读数据、已初始化数据最后是BSS段。end符号指向了内核映像的末尾这在后续分配内存时非常有用。编译和链接命令会变得复杂通常我们写一个Makefile来自动化这个过程。5. 自动化构建与调试让流程飞起来手动敲命令效率太低且容易出错。一个精心编写的Makefile是项目成功的保障。5.1 编写MakefileMakefile:# 工具定义 ASM nasm CC gcc LD ld CFLAGS -m32 -ffreestanding -nostdlib -Wall -Wextra -c LDFLAGS -m elf_i386 -T scripts/link.ld -nostdlib ASMFLAGS -f elf32 # 目标文件 BOOT_BIN boot/boot.bin LOADER_BIN boot/loader.bin KERNEL_ELF kernel/myos.elf IMG my_os.img # 默认目标构建所有 all: $(IMG) # 创建磁盘镜像并写入引导程序 $(IMG): $(BOOT_BIN) $(LOADER_BIN) $(KERNEL_ELF) # 创建1MB空镜像 dd if/dev/zero of$ bs512 count2048 # 写入主引导记录 dd if$(BOOT_BIN) of$ bs512 count1 convnotrunc # 写入Loader假设从第2扇区开始 dd if$(LOADER_BIN) of$ bs512 seek1 convnotrunc # 写入内核假设从第100扇区开始 dd if$(KERNEL_ELF) of$ bs512 seek100 convnotrunc # 编译引导扇区纯二进制 $(BOOT_BIN): boot/boot.asm $(ASM) -f bin $ -o $ # 编译Loader这里也先假设为纯二进制实际可能是ELF $(LOADER_BIN): boot/loader.asm $(ASM) -f bin $ -o $ # 编译内核 KERNEL_OBJS kernel/entry.o kernel/main.o $(KERNEL_ELF): $(KERNEL_OBJS) scripts/link.ld $(LD) $(LDFLAGS) -o $ $(KERNEL_OBJS) # 编译C文件 kernel/%.o: kernel/%.c $(CC) $(CFLAGS) $ -o $ # 编译汇编文件ELF格式 kernel/%.o: kernel/%.asm $(ASM) $(ASMFLAGS) $ -o $ # 运行Bochs run: $(IMG) bochs -f bochsrc.txt -q # 清理 clean: rm -f $(BOOT_BIN) $(LOADER_BIN) $(KERNEL_ELF) $(IMG) kernel/*.o bochslog.txt .PHONY: all run clean这个Makefile定义了从源代码到最终镜像的完整构建链。执行make会生成my_os.img执行make run会自动启动Bochs。5.2 使用Bochs进行源码级调试Bochs的强大之处在于其内置调试器。我们可以在代码中设置断点单步跟踪。修改bochsrc.txt确保debugger: enabled。运行bochs -f bochsrc.txt -q。Bochs会启动并停在BIOS代码处。在Bochs调试命令行终端里输入vb 0x7c00 # 在物理地址 0x7c00 (MBR加载地址) 设置断点 c # 继续执行直到断点程序会在MBR的第一条指令处停下。此时你可以使用s或step单步执行步入函数。n或next单步执行步过函数。r或reg查看寄存器状态。x /10i $eip查看当前指令指针附近的10条指令。print-stack查看栈内容。c继续执行直到下一个断点或程序结束。通过单步跟踪你可以亲眼看到CPU如何执行你的每一条指令如何从实模式切换到保护模式如何加载内核。当你的内核kernel_main函数被调用时设置一个断点然后观察0xB8000地址处的内存是如何被修改从而在屏幕上显示出字符的。这种洞察力是无与伦比的。6. 超越“Hello World”下一步的方向当屏幕上成功显示出“Hello, My OS!”时你已经跨越了最大的障碍。但这仅仅是开始。一个真正的操作系统需要更多组件中断与异常处理这是操作系统接管硬件、实现多任务的基础。你需要设置中断描述符表编写中断服务例程处理时钟中断、键盘中断等。这是从“单线程”到“可响应外部事件”的关键一步。内存管理实现页式内存管理这是实现虚拟内存、进程隔离的基础。你需要初始化页目录和页表开启分页机制。进程与线程实现简单的进程调度。这需要创建进程控制块管理进程状态并在时钟中断的驱动下进行上下文切换。简单的文件系统在虚拟硬盘上规划一个简单的结构比如FAT12或ext2的极简版实现文件的读写。驱动程序框架为键盘、屏幕、硬盘等设备编写最基础的驱动抽象出统一的接口。系统调用为用户态程序提供服务的接口通常通过软中断如int 0x80实现。用户态与内核态分离通过分段和分页机制实现特权级的切换保护内核代码。每一个主题都足够写一本书。但有了从零构建引导和内核的基础你再去学习这些概念会发现它们不再是空中楼阁而是可以落在你代码中的具体实现。你会真正理解“特权级”、“上下文”、“页错误”这些术语背后CPU和内存到底在做什么。这条路很长充满了挑战但每一步的突破都会带来巨大的成就感。最重要的是你获得的不是某个特定操作系统的知识而是对“计算机系统”这个整体深刻而直观的理解。这份理解将是你技术生涯中最坚实的基石。