ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零构建操作系统内核:从引导到内存管理的实践指南

2026/8/5 6:33:20 拓冰建站 浏览量
从零构建操作系统内核:从引导到内存管理的实践指南 1. 项目概述为什么我们要从零开始“从零开发一个操作系统”这个标题听起来像是一个计算机科学领域的终极挑战或者是一个只存在于顶尖大学实验室里的项目。但今天我想和你聊聊为什么一个普通的开发者甚至是一个有好奇心的学生都应该尝试去理解甚至动手去“造”一个自己的操作系统。这绝不是为了替代Windows或Linux而是为了彻底搞懂我们每天打交道的计算机到底是怎么从一堆冰冷的硅片和电路变成一个能运行我们代码、播放视频、连接世界的智能伙伴的。你可能已经熟练使用Python写后端用JavaScript构建炫酷的页面或者用Java开发企业级应用。但当你的程序在操作系统上运行时你是否想过内存是如何被分配和管理的你的代码是如何被CPU执行的点击一下鼠标屏幕上的光标为什么会移动这些问题都藏在操作系统这个“黑盒子”里。通过从零开始构建一个极简的操作系统内核你将亲手打开这个盒子看到计算机启动时第一条指令的执行、内存分页机制的建立、中断如何响应键盘敲击。这种理解是深刻的、体系化的它会让你从一个“应用程序员”蜕变为真正理解计算机系统的“工程师”。这个项目适合所有对计算机底层抱有好奇心的朋友。无论你是想夯实基础、准备系统方向面试的学生还是希望突破技术瓶颈、理解问题本质的开发者甚至是嵌入式领域的硬件工程师这个过程都将是一次无价的旅程。我们不会去实现一个功能完整的现代OS那需要数百人年的工作量。我们的目标是从通电自检到显示一行“Hello, OS World!”构建一个能在模拟器中独立运行的最小化系统。在这个过程中你会接触到引导程序、保护模式、内存管理、中断处理等核心概念并亲手用汇编和C语言将它们实现。2. 核心思路与工具选型搭建我们的“数字车间”在动手敲代码之前我们必须规划好整个项目的技术路线和开发环境。一个操作系统的开发不同于普通的应用开发它没有现成的运行时环境我们需要自己创造一切。因此工具链的选择至关重要它决定了我们开发的效率和调试的便利性。2.1 开发环境与工具链构建我们的核心开发环境将基于Linux如Ubuntu或WSL2Windows Subsystem for Linux。这是因为我们需要一套强大且标准的GNU工具链。以下是必须准备的“四大件”编译器与链接器我们需要交叉编译器。为什么不是直接用gcc因为宿主机的gcc默认会链接宿主机的标准库如glibc并生成针对宿主操作系统格式的可执行文件。我们的内核需要“裸机”运行不能依赖任何现有OS。因此我们要使用i686-elf-gcc这样的交叉编译器它专门为目标平台我们的是32位x86架构生成独立的二进制代码。安装命令示例Ubuntusudo apt-get install build-essential bison flex libgmp-dev libmpc-dev libmpfr-dev texinfo libisl-dev # 然后从源码编译安装 binutils 和 gcc目标指定为 i686-elf实操心得编译交叉工具链本身可能是个耗时且容易出错的过程。对于初学者我强烈建议先使用社区维护的预编译版本或者直接使用像homebrewmacOS这样的包管理器安装以快速搭建环境把精力集中在核心逻辑上。例如在macOS上可以brew install i686-elf-gcc。汇编器用于编译我们内核启动时最关键的、用汇编语言写的引导代码和部分底层例程。我们使用nasm因为它语法清晰对x86汇编支持非常好特别适合编写引导扇区代码。安装sudo apt-get install nasm模拟器这是我们的“试验场”。我们不可能每次修改都刷到真实的物理机器上测试。Bochs和QEMU是两个绝佳选择。Bochs这是一个纯软件模拟的x86模拟器速度较慢但它的调试功能极其强大。你可以单步执行每一条CPU指令随时查看寄存器、内存、中断描述符表IDT、全局描述符表GDT的状态。对于操作系统开发初期的调试Bochs是无价之宝。QEMU它提供了更快的速度支持多种架构并且可以模拟整个PC系统。在开发中后期当我们需要快速启动验证功能时QEMU是首选。建议前期深度调试用Bochs后期快速测试用QEMU。两者可以共存。版本控制毫无疑问使用Git。这个项目的代码会经历无数次重构和试验清晰的提交历史能帮你回溯任何一次修改。2.2 项目结构与构建系统设计一个清晰的项目结构能让开发过程井然有序。我建议的初始结构如下my_os/ ├── boot/ # 引导相关代码 │ ├── boot.asm # 主引导记录MBR汇编代码 │ └── loader.asm # 第二阶段加载器 ├── kernel/ # 内核核心代码 │ ├── src/ # C语言源码 │ │ ├── main.c │ │ ├── io.c # 端口读写等底层IO │ │ └── ... │ ├── include/ # 头文件 │ └── arch/i386/ # 架构相关代码如GDT, IDT设置 ├── libs/ # 可能需要的库如自己实现的字符串处理 ├── scripts/ # 链接脚本、构建脚本 ├── Makefile # 自动化构建 └── .bochsrc # Bochs模拟器配置文件构建系统一个精心编写的Makefile是项目的灵魂。它应该能自动化完成从汇编、编译、链接到生成最终磁盘映像的所有步骤。一个好的Makefile能让你通过一句make run就完成编译并在模拟器中启动。注意在配置Bochs时.bochsrc文件中的boot:选项务必设置为boot: floppy或boot: disk指向你生成的包含引导程序的磁盘映像文件。同时确保模拟的内存大小如megs: 32足够你的内核运行。3. 从通电到Hello World核心环节拆解现在让我们进入最激动人心的部分看看计算机是如何从一片黑暗到执行我们第一行C代码的。这个过程就像一场精心编排的接力赛。3.1 第一阶段BIOS与主引导记录MBR当你按下电源键CPU复位从物理地址0xFFFF0通常是BIOS ROM的地址开始执行第一条指令。这是BIOS的代码它的工作是进行上电自检POST并初始化硬件。之后BIOS会按照预设的顺序如软盘、硬盘、光驱、网络寻找可引导设备。关键动作BIOS检查每个存储设备的第一个扇区512字节即主引导记录MBR寻找结束标志0x55AA。如果找到它就会将这512字节代码加载到物理内存0x7C00处然后跳转到那里执行。这512字节就是我们的舞台起点。我们的boot.asm就要写在这512字节里它必须完成以下任务初始化环境设置栈指针SP寄存器为调用子程序做准备。加载第二阶段加载器512字节远远不够放下内核。因此MBR的主要职责是从磁盘上把更大的第二阶段加载器比如2KB的loader.bin读入内存。这需要调用BIOS提供的磁盘服务中断INT 0x13。跳转到加载器将CPU控制权交给刚刚加载到内存的第二阶段代码。代码片段示例nasm语法org 0x7c00 ; 告诉编译器这段代码将被加载到0x7c00处 bits 16 ; 启动时CPU处于16位实模式 start: cli ; 关闭中断 mov ax, cs mov ds, ax ; 设置数据段寄存器 mov es, ax mov ss, ax mov sp, 0x7c00 ; 栈指针设在0x7c00下方 ; 调用BIOS中断读取磁盘扇区示例读取2个扇区到0x8000 mov ah, 0x02 ; 功能号读扇区 mov al, 2 ; 扇区数 mov ch, 0 ; 柱面号 mov cl, 2 ; 起始扇区号从第2扇区开始第1扇区是MBR自己 mov dh, 0 ; 磁头号 mov dl, 0x80 ; 驱动器号0x80表示第一块硬盘 mov bx, 0x8000 ; ES:BX 缓冲区地址 (0x0000:0x8000 - 物理地址0x8000) int 0x13 jc disk_error ; 如果出错进位标志CF1跳转到错误处理 jmp 0x0000:0x8000 ; 跳转到第二阶段加载器 disk_error: mov si, error_msg call print_string hlt print_string: ... ; 使用BIOS中断0x10显示字符串的代码 error_msg db Disk read error!, 0 times 510-($-$$) db 0 ; 填充剩余空间确保总长为510字节 dw 0xaa55 ; 引导扇区结束标志3.2 第二阶段加载器与进入保护模式第二阶段加载器loader.asm拥有更多的空间它的核心使命是为执行用C语言编写的高阶内核做准备最关键的一步就是切换到32位保护模式。在实模式下软件可以直接访问任何物理内存没有内存保护且只能使用1MB地址空间。现代操作系统必须运行在保护模式下以启用虚拟内存、特权级保护和更大的寻址空间。加载器需要完成关闭中断加载全局描述符表GDTGDT定义了内存段的属性基地址、界限、类型、特权级。这是保护模式的基石。打开A20地址线这是一个历史遗留问题为了突破1MB内存访问限制必须开启。设置控制寄存器CR0将CR0寄存器的PEProtection Enable位设置为1CPU就正式进入保护模式。长跳转执行一个远跳转jmp CODE_SELECTOR:protected_mode_entry来清空CPU指令流水线并加载CS段选择子正式进入32位代码段。初始化保护模式下的段寄存器DS, ES, SS等。从磁盘加载内核映像此时可以启用更高效的磁盘读取方式如使用硬盘的LBA模式将编译好的内核二进制文件如kernel.bin加载到内存的高地址例如0x100000即1MB以上避开BIOS和实模式代码使用的区域。跳转到内核入口点最终通过jmp KERNEL_ENTRY_POINT将控制权交给我们的C语言内核。注意事项在编写GDT时务必仔细计算每个描述符的基地址、界限和粒度。一个错误的GDT会导致CPU在切换保护模式后立即触发异常通常表现为模拟器卡死或重启。在Bochs中你可以使用info gdt命令来检查GDT的设置是否正确。3.3 第三阶段C语言内核的“土地”终于我们跳转到了用C语言写的kernel_main函数。此时内存看起来还是一片“荒原”。我们的内核需要在这片土地上建立秩序。内核的初始任务初始化屏幕输出在保护模式下BIOS的中断如INT 0x10不能再使用。我们需要直接读写显存。对于80x25文本模式显存起始地址是0xB8000。向这个内存区域写入字符和属性字节就能在屏幕上显示内容。void kprint(const char* str) { volatile char* video (volatile char*)0xB8000; while (*str) { *video *str; *video 0x0F; // 白字黑底 } }在kernel_main中调用kprint(Hello, OS World!)你将在屏幕上看到第一行来自你自己操作系统的问候这是一个里程碑式的瞬间。设置中断描述符表IDT没有中断操作系统就无法响应键盘、鼠标、时钟等外部事件。IDT将中断号如键盘中断是0x21映射到对应的处理函数地址。设置好IDT后使用lidt指令加载其地址并用sti指令打开中断。初始化物理内存管理你需要知道机器有多少可用内存可以通过BIOS中断在引导阶段获取或者使用像GRUB这样的引导器传递的内存信息。然后你需要实现一个简单的物理页帧分配器例如使用位图bitmap来记录哪些4KB的页是空闲的哪些已分配。启用分页这是实现虚拟内存的关键。你需要设置页目录Page Directory和页表Page Table将线性地址映射到物理地址。例如你可以将内核代码所在的物理地址0x100000映射到虚拟地址0xC0000000一个典型的高端内核地址。然后将控制寄存器CR3指向页目录并设置CR0的PGPaging位。从此所有内存地址都将经过MMU内存管理单元的翻译。4. 核心模块深入内存、中断与进程雏形当“Hello World”显示成功后我们可以开始为操作系统添加更核心的功能模块。这些模块是现代操作系统的骨架。4.1 物理内存管理位图分配器实现在获取了内存大小比如从BIOS中断0x15子功能0xE820获取内存映射后我们需要一个数据结构来管理这些物理页帧。位图是一种简单高效的方法。设计思路确定位图位置通常将位图本身放在内核数据段的末尾。每个比特bit代表一个物理页帧如4KB0表示空闲1表示占用。计算位图大小如果系统有mem_size_kbKB内存页大小为4KB则共有mem_size_kb / 4个页帧。需要的字节数是(页帧数 7) / 8向上取整。实现核心函数void pmm_init(size_t total_mem_kb, uint32_t bitmap_start)初始化标记所有不可用如被内核代码占用的页帧为1。uint32_t pmm_alloc_page()扫描位图找到第一个为0的比特将其置1返回对应的物理页帧起始地址。void pmm_free_page(uint32_t addr)根据地址计算出页帧号将位图中对应的比特清0。实操心得在分配器初始化时必须精确标记内核自身代码、数据、栈以及位图本身所占用的物理页为“已使用”。否则后续分配可能会覆盖正在运行的内核代码导致不可预测的崩溃。这是一个非常隐蔽的bug。4.2 虚拟内存与分页机制物理内存管理直接操作硬件地址而分页机制则提供了虚拟地址空间这是实现进程隔离、内存保护和按需调页的基础。实现步骤构建页目录和页表在物理内存中分配一个4KB对齐的页作为页目录Page Directory, PD。PD有1024个条目每个条目指向一个页表Page Table, PT。每个PT也有1024个条目每个条目指向一个4KB的物理页帧。建立内核空间映射通常将高1GB的虚拟地址0xC0000000-0xFFFFFFFF映射到物理内存的低端。例如将虚拟地址0xC0000000映射到物理地址0x00000000。这样内核代码在编译时就可以使用高地址链接而物理内存的访问通过一个简单的偏移- 0xC0000000来计算。加载CR3与启用分页将页目录的物理地址装入CR3寄存器然后设置CR0的PG位。刷新TLB在修改页表条目后需要刷新转换后备缓冲器TLB使更改生效。可以通过重新加载CR3mov cr3, eax或使用invlpg指令来实现。提示在早期调试分页时可以暂时采用“恒等映射”即将虚拟地址0x00000000~0x00400000前4MB一对一映射到相同的物理地址。这可以简化从开启分页到跳转到高地址内核代码之间的过渡。4.3 中断与异常处理系统与外界的桥梁没有中断内核就是一个死循环。中断处理程序ISR是内核响应硬件事件时钟、键盘和软件异常除零错误、页错误的入口。关键流程编写中断处理函数每个中断号对应一个C函数或汇编桩函数。这个函数需要保存所有寄存器状态中断上下文处理中断然后恢复上下文并返回。对于硬件中断处理结束后需要向可编程中断控制器PIC发送EOIEnd Of Interrupt信号。填充IDTIDT的每个条目包含处理函数的地址、代码段选择子以及门描述符属性如32位中断门、特权级DPL。使用lidt指令加载IDT。重新编程PIC默认的BIOS设置可能不适合保护模式。我们需要重新初始化8259A PIC将硬件中断号从0x08~0x0F重映射到0x20~0x27以避免与CPU内部异常号冲突。处理时钟中断时钟中断IRQ0是操作系统的“心跳”。在它的处理函数中可以实现一个简单的任务调度器雏形或者至少维护一个系统时钟滴答数。键盘中断示例思路 当键盘按键被按下PIC会产生IRQ1中断映射后中断号0x21。ISR从键盘控制器端口0x60读取扫描码将其转换为ASCII字符并存入一个缓冲区。内核的其他部分如一个简单的shell可以从这个缓冲区读取字符。5. 进阶探索与调试实战当基础框架搭建完成后你可以选择不同的方向进行深化将你的迷你OS变得更像真正的操作系统。5.1 实现一个简单的任务调度这是从“内核”迈向“操作系统”的关键一步。你不需要实现完整的进程可以先实现协作式的线程或任务。定义任务控制块TCB一个结构体保存任务的栈指针esp、状态就绪、运行、阻塞、入口函数等。创建任务栈为每个任务在内存中分配一块空间作为私有栈。上下文切换编写一段汇编代码switch_to其功能是保存当前任务的寄存器主要是esp到其TCB中。从下一个任务的TCB中加载新的esp。恢复新任务的寄存器上下文。通过ret指令跳转到新任务上次被切换出去时正在执行的指令地址这个地址保存在栈中。调度器维护一个就绪任务队列。当时钟中断发生时或者在任务主动调用yield()时调度器就调用switch_to切换到下一个任务。5.2 集成GRUB作为引导器之前我们是从MBR开始自己写引导。一个更现代、更强大的方式是使用GRUB这样的引导加载器。GRUB遵循Multiboot规范它会把操作系统内核加载到内存并提供一个包含内存布局、引导设备等信息的结构体multiboot_info_t给内核然后跳转到内核入口。这样做的好处省去了自己写磁盘读取、内存探测、模式切换的复杂汇编代码。可以直接从GRUB进入32位保护模式甚至可以直接启用分页。能获得标准化的引导信息兼容性更好。你需要做的在内核汇编入口点确保按照Multiboot规范定义头部包含魔数、标志位等。在链接脚本中确保这个头部位于内核映像的前8KB内。在kernel_main函数中将GRUB传递过来的multiboot_info_t*参数保存下来用于获取内存地图。5.3 调试技巧与常见问题实录操作系统内核的调试是“地狱难度”因为你没有printf没有GDB服务器。Bochs是你的救星。Bochs调试命令速查c连续运行。s/n单步执行s会步入子程序n会越过。b 0x7c00在物理地址0x7c00设置断点。这是MBR开始的地方。info gdt/info idt查看GDT和IDT的内容。x /64bx 0x7c00以十六进制字节形式检查0x7c00开始的内存。print-stack显示当前栈的内容。trace-reg on开启寄存器变化跟踪。ctrlC中断模拟器运行回到调试器命令行。常见问题与排查模拟器启动后黑屏无任何输出检查点首先在0x7c00设断点看MBR是否被加载和执行。如果没有检查.bochsrc中磁盘映像配置是否正确以及MBR的结束标志0x55AA。如果MBR执行了但在切换保护模式或跳转后卡死用s单步跟踪并用info gdt检查GDT设置。最常见的错误是GDT描述符的界限或基地址计算错误。进入保护模式后第一条指令就触发通用保护错误GPF这几乎肯定是段选择子Segment Selector的问题。检查在远跳转jmp CODE_SELECTOR:protected_mode_entry时CODE_SELECTOR是否指向GDT中正确的代码段描述符通常是第一个描述符之后的第二个索引为1所以选择子可能是0x08。同时确保描述符的DPL描述符特权级等属性正确。启用分页后系统立刻重启或卡死检查CR3寄存器加载的页目录物理地址是否正确是否4KB对齐。检查页目录条目PDE和页表条目PTE的格式是否正确特别是Present位第0位是否设置为1。使用info tabBochs或info memQEMU monitor命令查看当前的页表映射验证你的映射关系是否如预期。键盘或时钟中断无法触发首先用info idt确认IDT条目已正确设置处理函数地址无误。确认是否用sti指令打开了全局中断开关。对于硬件中断确认PIC是否已正确初始化和重映射并且在中断处理程序末尾是否发送了EOI向端口0x20写入0x20。链接错误kernel.bin过大无法被加载器加载检查第二阶段加载器读取的扇区数是否足够。一个简单的内核可能就有几十KB。优化内核代码或者考虑使用GRUB等更强大的引导器它们可以加载任意大小的内核。从零开始构建操作系统的旅程就像在微观世界里扮演一次造物主。你会经历无数次的失败、调试和恍然大悟。但当你最终看到屏幕上闪烁的光标完全受控于你写的代码当你实现的任务切换流畅地运行那种对计算机系统透彻理解的成就感和掌控感是任何其他编程项目都无法给予的。这条路不容易但每一步都算数它最终会塑造你作为一名开发者的底层思维和解决问题的能力。