资讯动态

手写操作系统第一步:从工具链到引导扇区的环境配置实战

发布时间:2026/10/27 6:51:34 来源:尧图企业网站定制
手写一个能跑起来的操作系统最劝退人的往往不是中断描述符表或者分页机制而是第一步——环境配置。我见过太多人兴致勃勃地打开教程结果卡在编译器版本不对、链接脚本报错、虚拟机启动黑屏这三件事上折腾两三天之后热情就耗光了。这篇东西就是把我自己踩过的这些坑摊开讲清楚从工具链选型到第一个引导扇区的完整跑通流程每一步都说明白为什么这么做、不这么做会出什么问题。适合两类人看一类是刚学完汇编和C语言、想找个项目把知识串起来的学生另一类是工作几年之后想回头补一补底层知识的开发者。不需要你之前写过操作系统但至少得知道寄存器是什么、能看懂简单的汇编指令。1. 先把工具链这件事想明白1.1 为什么不能直接用系统自带的编译器很多人第一反应是打开终端敲gcc --version看到有输出就觉得环境齐了。这个想法在写普通应用程序时没问题但做操作系统内核开发时是个大坑。你系统里预装的那个 GCC是给当前操作系统编译程序的它默认链接的是宿主系统的 C 运行库生成的可执行文件格式也带着宿主系统的烙印。而我们要写的东西是要在没有任何操作系统支撑的裸机上跑起来的没有 libc、没有动态链接器、没有系统调用。具体来说宿主 GCC 编出来的目标文件里会混入一些我们控制不了的东西比如栈保护相关的符号引用、位置无关代码的重定位信息、以及对__libc_start_main之类的隐式依赖。这些东西在裸机环境下根本找不到对应实现链接阶段就会报一堆 undefined reference。所以正确做法是准备一套交叉编译工具链让编译器明确知道我编出来的东西不是给当前系统用的。这里有个细节值得说清楚交叉编译工具链的核心不在于编译器本身有多特殊而在于它配套的 binutils汇编器、链接器和它默认的目标格式。比如目标是 x86 裸机我们需要的是i686-elf-gcc这类工具它生成的 ELF 文件不依赖任何宿主运行库链接时用我们自己写的链接脚本控制内存布局。你可以选择自己编译一套也可以直接用发行版仓库里现成的包。自己编译的好处是版本完全可控坏处是编译 binutils gcc 这一套下来配置参数写错一个就得重来对新手不太友好。我的建议是先用现成的包把流程跑通等整个系统能启动了再回头折腾工具链的定制。1.2 三种搭建路径的取舍实际可选的路子大概有三条我列个表对比一下你可以按自己的情况挑。路径具体做法优点缺点适合人群包管理器安装直接装i686-elf-gcc等预编译包五分钟搞定版本稳定某些发行版仓库里没有需要加第三方源想快速跑通流程的新手源码编译下载 binutils 和 gcc 源码自行配置编译版本完全可控能开各种定制选项耗时长配置出错排查困难想深入理解工具链的进阶者容器化环境用预配置好的开发容器镜像环境隔离换机器不用重配需要额外学容器基本操作多设备切换或团队协作我个人的习惯是第二条路走一遍、第一条路日常用。第一次自己编译工具链的过程能让你真正理解--target、--prefix、--with-sysroot这些参数在干什么后面遇到链接错误时心里有底。但日常开发时用包管理器装好的版本更省事不用每次改代码都担心工具链本身出问题。如果你走源码编译这条路配置 binutils 时最关键的是--targeti686-elf和--prefix两个参数。前者决定了生成的可执行文件前缀名后者决定安装位置。编译 GCC 时还要加上--without-headers因为我们还没有自己的 C 库头文件和--enable-languagesc,c只编我们需要的语言前端省时间。整个编译过程在普通笔记本上大概要半小时到一小时取决于机器性能。提示编译 GCC 时如果报找不到 GMP、MPFR、MPC 这几个库说明缺少依赖先装开发包再重来。这几个库是 GCC 内部做高精度运算用的不是可选项。1.3 汇编器与链接器的角色分工工具链里除了编译器还有两个角色必须搞清楚汇编器和链接器。汇编器通常是as交叉工具链里叫i686-elf-as负责把汇编源码翻译成机器码形式的目标文件链接器ld负责把多个目标文件按链接脚本规定的布局拼成一个完整的可执行文件。为什么单独强调这个因为操作系统开发里链接脚本的重要性远超普通应用开发。普通程序链接时用的是系统默认脚本你根本不用关心代码被放在哪个地址。但内核不一样引导程序把控制权交给你的时候CPU 处于实模式或者刚切到保护模式代码必须被加载到约定的物理地址上链接脚本就是用来告诉链接器把这段代码放到这个地址、那个数据段对齐到那个边界的。写错一个段地址程序跑起来就是三重故障重启而且没有任何错误信息排查起来极其痛苦。2. 虚拟机与调试环境的搭建细节2.1 为什么用虚拟机而不是真机有人会问既然写操作系统为什么不直接找台旧电脑刷进去跑答案很简单调试成本。真机上出了问题你只能看到黑屏或者一串乱码没有任何手段查看寄存器状态、内存内容、执行到哪一条指令。虚拟机则提供了完整的调试接口可以单步执行、下断点、查看任意内存地址甚至能把执行指令流完整记录下来。对于学习阶段来说可观测性比性能重要一百倍。虚拟机的选择上主流的两三个方案都能用核心要求是支持从软盘或硬盘镜像启动、支持串口输出、支持 GDB 远程调试。串口输出这一点特别值得强调在图形界面还没做出来之前你的内核唯一能往外输出信息的手段就是往串口寄存器写字符然后宿主机这边用终端软件接收。这比直接往显存写字符要可靠得多因为显存操作涉及显卡状态早期很容易因为模式设置不对而看不到任何输出。2.2 串口调试通道的配置逻辑串口调试的原理不复杂虚拟机模拟一个 UART 芯片你的内核往它的数据寄存器写一个字节虚拟机就把这个字节转发到宿主机的某个文件或者管道上。宿主机这边开一个终端程序读这个通道就能看到内核打印的内容。配置时要注意几个参数波特率一般选 115200数据位 8 位无校验停止位 1 位。这几个参数在虚拟机的启动配置里和内核的串口初始化代码里必须一致不一致的话收到的就是乱码。我见过有人内核里初始化成 9600虚拟机配置里写的 115200结果终端里全是问号和方块还以为是代码逻辑错了查了半天。启动虚拟机的命令里通常有这么几个关键参数指定启动镜像、开启串口并重定向到标准输出、关闭图形界面早期不需要、开启调试模式。把这些参数写进一个脚本里每次改完代码重新编译后一键启动比手动敲一长串命令靠谱得多。脚本里还可以加上编译步骤形成编译—链接—启动的完整流水线。2.3 GDB 远程调试的连接方式当串口输出不够用的时候就轮到 GDB 上场了。虚拟机的调试模式会在本地开一个端口GDB 通过这个端口连上去就能像调试普通程序一样调试内核。连接命令大概是target remote localhost:端口号这种形式。这里有个新手常犯的错误连上之后直接敲continue然后程序跑飞了不知道怎么停下来。正确做法是先设置断点比如在引导扇区的入口地址下断或者在你怀疑出问题的那条指令上下断。另外刚连上时 CPU 处于复位后的初始状态这时候查看寄存器看到的值和你的代码还没关系得先让程序跑到你的代码入口才有意义。调试符号的生成也需要注意。编译内核时加上-g参数链接时保留符号表GDB 才能把地址映射回源码行号。如果链接脚本里把符号表段丢弃了GDB 里看到的就全是裸地址调试体验直线下降。3. 从零写出第一个能启动的引导扇区3.1 引导扇区的硬性约束在真正写内核之前得先有一个能把控制权交给内核的东西这就是引导扇区。它的约束是硬件定的绕不过去必须是 512 字节最后两个字节必须是0x55和0xAA会被加载到物理地址0x7C00处开始执行。这三个条件缺一个机器就不会认它是可启动的。512 字节这个限制意味着你不能在里面写太多逻辑它唯一该干的事就是把后续的代码从磁盘读进内存然后跳过去执行。很多人一开始想在引导扇区里直接写内核逻辑写到一半发现空间不够了又得回头重构白白浪费时间。正确的思路是一开始就把引导扇区和内核分开引导扇区只负责加载。最后两个字节的魔数是给固件看的固件扫描启动设备时会检查这两个字节不匹配就跳过这个设备继续找下一个。这个检查发生在你的任何代码执行之前所以如果忘了写这两个字节表现就是虚拟机直接跳过你的镜像什么都不发生连报错都没有。3.2 用汇编写一个最小可启动镜像下面这段汇编就是一个能跑通的最小引导扇区我加了详细注释说明每条指令的意图。; 告诉汇编器生成 16 位实模式代码 bits 16 ; 指定代码起始地址和固件加载地址一致 org 0x7C00 start: ; 关中断避免初始化过程中被中断打断 cli ; 数据段、附加段、栈段全部指向代码段 ; 这样后续访问内存时偏移量计算简单 xor ax, ax mov ds, ax mov es, ax mov ss, ax ; 栈指针设在 0x7C00 之前避免覆盖引导扇区自身 mov sp, 0x7C00 ; 开中断 sti ; 往串口数据寄存器写一个字符验证执行流到达这里 mov al, A mov dx, 0x3F8 out dx, al hang: ; 死循环防止执行流跑飞 jmp hang ; 填充到 510 字节最后两个字节写魔数 times 510 - ($ - $$) db 0 dw 0xAA55这段代码里几个地方值得展开说。org 0x7C00这条伪指令告诉汇编器后面所有地址计算都以 0x7C00 为基准这样你写的标号比如start在汇编后得到的绝对地址就是正确的。如果漏了这条标号地址会从 0 开始算跳转指令就会跳到错误的地方。cli和sti的配对使用是为了在设置段寄存器和栈指针期间不被中断打断。虽然在这个极简例子里中断还没配置但养成这个习惯有好处后面代码复杂了之后初始化顺序不对很容易出玄学问题。往串口写字符那三行是验证执行流的关键。0x3F8是 COM1 串口的数据寄存器端口地址out指令把al里的字节送出去。如果虚拟机配置正确、宿主机终端也连上了你就能看到一个字符 A 出现在终端里。看到这个 A说明从固件加载、到代码执行、到串口输出整条链路都是通的。3.3 汇编、链接、生成镜像的完整命令链有了源码接下来是把它变成虚拟机认识的镜像文件。步骤是汇编成目标文件、链接成扁平二进制、补齐到 512 字节如果链接输出不足的话、写入镜像。# 汇编成 ELF 目标文件 i686-elf-as boot.asm -o boot.o # 链接成扁平二进制-Ttext 指定代码段起始地址 i686-elf-ld -Ttext 0x7C00 --oformat binary boot.o -o boot.bin # 检查生成文件大小应该正好 512 字节 ls -l boot.bin这里用--oformat binary而不是默认的 ELF 格式是因为固件不认 ELF它只认从第一个字节开始就是机器码的扁平二进制。链接器默认会生成带各种段头和符号表的 ELF 文件那些头部信息对固件来说是垃圾数据必须去掉。-Ttext 0x7C00这个参数和源码里的org 0x7C00是配套的一个管汇编阶段的地址计算一个管链接阶段的段布局两个都得对。只改一个不改另一个生成的代码跳转地址就会错位表现是执行几条指令之后跑飞。生成boot.bin之后可以直接把它当作软盘镜像用因为 512 字节正好是一个扇区。虚拟机启动参数里指定这个文件为启动盘即可。如果后面要加载更大的内核就需要把引导扇区扩展成能读磁盘的版本那是下一步的事。4. 环境验证与常见故障的排查链路4.1 启动后黑屏无输出的分层排查黑屏是最常见的现象也是最难直接定位的因为它可能是任何一个环节出的问题。我的排查习惯是从后往前推一层一层缩小范围。第一层确认虚拟机到底有没有把你的镜像当作启动盘。有些虚拟机在找不到可启动设备时会直接进 BIOS 界面或者报 No bootable device如果你的现象是这个说明镜像本身没被识别回头检查文件大小是不是 512 字节、最后两个字节是不是0x55 0xAA。第二层确认代码有没有被执行。在代码最开头往串口写字符如果串口终端里能看到说明固件加载和执行都没问题问题出在后面的逻辑。如果看不到可能是串口初始化参数不对或者虚拟机串口没配置好。第三层如果串口有输出但程序很快跑飞用 GDB 连上去在入口地址下断单步执行看在哪条指令上出的问题。常见原因是段寄存器设置不对导致内存访问越界或者栈指针指向了不该指的地方。4.2 链接阶段的典型报错与处理链接阶段最常见的报错是 undefined reference to xxx意思是某个符号被引用了但找不到定义。在裸机开发里这通常是因为你不小心用了标准库函数比如printf、memcpy之类。这些函数在宿主系统里有实现但在你的裸机环境里没有链接器自然找不到。处理办法有两个方向要么自己实现这些基础函数对于内核开发来说这是必经之路memcpy、memset、strlen这些迟早要自己写要么在编译时加-ffreestanding参数告诉编译器我在一个没有标准库的环境里这样它就不会隐式插入对标准库的调用。另一个常见报错是 relocation truncated to fit意思是某个重定位的偏移量超出了目标格式能表示的范围。这在链接脚本布局不合理时容易出现比如代码段和数据段被放得太远相对跳转的偏移量装不下。解决办法是调整链接脚本里的段顺序和对齐让相关代码靠得近一些。4.3 工具链版本不一致引发的隐蔽问题这个问题比较隐蔽表现是代码逻辑看起来完全正确但运行结果就是不对。根源在于你用的汇编器、编译器、链接器版本之间不匹配或者和教程里用的版本差异太大导致某些伪指令的展开方式、默认对齐策略、甚至指令编码都不一样。我遇到过一次同样的汇编源码在两台机器上汇编出来的二进制不一样查了半天发现是两台机器的汇编器版本差了两年新版本对某条伪指令的默认处理变了。这种问题没有通用解法只能靠固定工具链版本来规避。建议在项目开始时就把工具链版本记录下来写进一个说明文件里换机器或者过几个月再回来时照着装。注意如果你跟着某篇教程做尽量用它指定的工具链版本。教程作者验证过的组合是经过实践检验的自己随意升级版本很可能引入教程里没提到的新问题排查起来毫无头绪。5. 把环境固化成可复现的工程结构5.1 目录布局与构建脚本的设计环境跑通之后第一件该做的事是把它整理成可复现的结构而不是散落一堆命令在终端历史里。我的习惯是这样一个布局源码放一个目录构建产物放另一个目录工具脚本放第三个目录再加一个顶层 Makefile 把所有步骤串起来。Makefile 里至少要有这几个目标all负责完整构建run负责启动虚拟机clean负责清理产物debug负责以调试模式启动并等待 GDB 连接。每个目标依赖关系写清楚改了一个源文件只重新编译受影响的部分不用每次全量重建。构建脚本里把工具链前缀做成一个变量比如CROSS_COMPILE ? i686-elf-这样换工具链时只改变量不改命令。这个习惯在后期会省很多事尤其是当你需要在不同机器上构建、而各机器工具链安装路径不一样的时候。5.2 版本记录与依赖清单裸机开发对环境敏感所以依赖清单必须写清楚。清单里至少包含工具链的具体版本号、虚拟机软件的名称和版本、宿主机操作系统的类型不同系统下某些命令行为有差异、以及构建时用到的所有命令行参数。我还会额外记录一份已知可用组合就是某几个版本搭配在一起验证过没问题的组合。当环境出问题时先对照这份记录看是不是某个组件被无意中升级了。这个做法听起来有点笨但在排查那些昨天还好好的今天就不行了的问题时特别有效。5.3 从单扇区到多扇区加载的过渡准备第一个引导扇区跑通之后下一步通常是让它能加载更多扇区把真正的内核代码读进内存。这一步的环境准备工作和之前略有不同需要准备一个比 512 字节大的镜像文件需要理解磁盘读取的 BIOS 中断调用约定还需要在链接脚本里规划好内核被加载到的内存地址。我的建议是不要急着写复杂的加载逻辑先用一个假内核验证加载流程假内核就是一段往串口打印特定字符串的代码被放在镜像的第二个扇区。引导扇区负责把它读进内存并跳转过去。如果串口里能看到假内核打印的字符串说明加载链路是通的这时候再把假内核替换成真正的内核代码出问题时就能确定是新内核的问题而不是加载逻辑的问题。这种先验证链路、再填充内容的思路贯穿整个操作系统开发过程。每加一个新功能先用最小实现验证它和已有部分的衔接是通的再往里填真正的逻辑。这样任何一步出问题排查范围都被限制在很小的范围内不会出现改了一堆东西然后全挂了不知道是哪里的问题的局面。环境配置这件事说到底就是把不确定性一个个消掉。工具链版本固定了不确定性少一个虚拟机参数写进脚本了少一个构建流程固化成 Makefile 了又少一个。等这些不确定性都消得差不多了你才能把精力真正放在操作系统本身的逻辑上而不是每次改完代码都在猜这次能不能跑起来。我自己的经验是环境阶段多花两天把该固定的都固定住后面能省下至少两周的无效排查时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑