从Multiboot到Hello World:操作系统内核引导实验全解析
2026/9/20 12:04:06 网站建设 项目流程

简介:面向操作系统初学者的USTC课程实验一资料,聚焦在QEMU虚拟机下实现Multiboot规范启动,目标是从零搭建一个能显示文字的最小内核启动框架,帮助读者理解操作系统启动阶段的底层机制。作者以亲历者视角记录了搭建Ubuntu与主机共享文件夹、安装QEMU、了解Multiboot协议、编写符合规范的Multiboot头部,并直接在VGA显存写入“helloworld”、通过串口输出“HELLOWORLD”的完整过程,实验报告与源代码一一对应,方便复盘。压缩包共10个文件,大小仅778KB,包含两份PDF讲解、汇编源文件(.S)、链接脚本(.ld)、Makefile、编译产物(.o/.bin)以及配置文件,目录结构保留了工程原始样貌。目前已有291人学习,适合刚接触操作系统、正在做类似Lab的初学者对照练习。资源虽然篇幅不大,却覆盖了Multiboot协议、汇编语法、显存操作、串口通信、Makefile和链接脚本等核心知识点,自带讲解PDF和可运行二进制,能有效减少环境搭建和排错成本。

1. 实验思路与Multiboot协议

1.1 为什么不用自定义引导扇区

操作系统课程Lab1的常规目标,是先让一个极简内核能在QEMU里跑起来。刚开始接触OS的人,第一反应往往是“直接从引导扇区写起”,毕竟网上教程都在教你怎么写512字节的boot sector。但我个人的建议是,第一个实验直接用Multiboot规范,把“引导”这件事交给GRUB,你的精力全部集中在内核本身的入口和初始化上。

不是说引导扇区不重要,而是实验设计有先后。自己从实模式写引导扇区,至少要处理A20线、GDT、保护模式切换这一长串问题,任何一个细节出错,屏幕就黑在那里,根本分不清是引导问题还是内核问题。Multiboot等于把这一整套“脏活”打包给了引导器。你需要做的,只是在内核文件开头按规范放一个特殊结构,GRUB读到之后就会帮你把CPU置入保护模式、关中断、准备好内存信息,然后跳到你指定的入口地址。这样第一阶段就能快速看到自己的C代码打印出字符,建立起“我能写OS”的信心。

1.2 一条完整的启动链路

要理解这个实验,得先弄清楚整个启动过程经历了什么。QEMU启动后,模拟的是PC上电流程:BIOS自检,找到启动介质,这里是GRUB镜像或带有GRUB的ISO;GRUB加载你编译出来的kernel.elf,在文件前8192字节内寻找Multiboot header;找到后校验magic和checksum,通过就把控制权交给内核入口。

关键点在于,GRUB跳转之前已经把环境收拾好了:CPU处于32位保护模式,A20开启,中断关闭,eax寄存器是Multiboot引导magic0x2BADB002ebx指向一个multiboot_info_t结构体,里面装着可用内存范围等启动信息。这些约定在Multiboot规范里写得明明白白,实验中我们至少要用到eaxebx,哪怕现在只打印一行Hello,也得先把这两个寄存器保存下来。整个链路不复杂,但因为是第一次接触,每一步都值得亲手查一下对应规范。

2. 环境准备与构建工具

2.1 安装QEMU和开发工具

实验环境我用的Ubuntu,也可以在其他Linux发行版或WSL2里做。需要的工具一共就这么几样:编译用的gccldmake,汇编用asnasm,打包ISO用grub-mkrescue,运行用qemu-system-i386

sudo apt install build-essential nasm grub-pc-bin xorriso qemu-system-x86 gcc-multilib

grub-pc-bin很容易被漏掉。我第一次直接跑grub-mkrescue -o os.iso iso的时候报了一堆错,找了一圈才发现是缺这个包,装上之后才能正常生成ISO。WSL2用户也可以这样装,如果装了WSLg,QEMU的图形窗口能正常弹出;如果是在无图形界面的服务器上,记得用后面讲到的串口方案。

2.2 关于交叉编译器

做OS实验时,你的内核其实是“裸机程序”,运行在没有任何操作系统支撑的环境里。理论上用系统自带的gcc-m32就能编译32位内核,但64位主机默认不带32位头文件和运行时库,所以要么安装gcc-multilib,要么用专门的i686-elf-gcc交叉编译器。

我的选择是系统gcc-m32,理由就是快,不必再折腾一套工具链。但编译选项里必须加几个关键参数:-ffreestanding告诉编译器“别指望libc”,-fno-pic避免生成位置无关代码,-fno-stack-protector关掉栈保护,否则链接阶段会找不到__stack_chk_fail这类符号。这些参数在普通应用开发里根本不会用到,但写内核时一个都不能少。

3. 代码实现与关键细节

3.1 Multiboot头部的精确布局

起步代码我用的是GNU as语法,好处是能跟GCC编译出的ELF无缝链接。Multiboot header在汇编文件里是这样写的:

.set MAGIC, 0x1BADB002 .set FLAGS, 0x3 .set CHECKSUM, -(MAGIC + FLAGS) .section .multiboot .align 4 .long MAGIC .long FLAGS .long CHECKSUM .section .text .extern kernel_main .global _start

MAGIC是个固定魔数,等于0x1BADB002,引导器靠它判断文件是不是合法的Multiboot内核。FLAGS我用了0x3,二进制是11,对应两个bit:bit0要求GRUB把加载的模块按页对齐,bit1要求GRUB把内存布局信息传递给内核。CHECKSUM不是随便填的,它需要满足MAGIC + FLAGS + CHECKSUM的低32位等于0,所以直接写成-(MAGIC + FLAGS)。三个字段一共12字节,整个header必须放在文件的最前面,并且按4字节对齐,这就是.align 4的意义。

这里的坑在于,如果FLAGS设置不对,GRUB可能不会主动提供内存信息,ebx指向的结构体内容就不完整。实验阶段用0x3最稳妥,既不会有对齐问题,也能拿到内存布局,后面做内存管理用得上。

3.2 汇编入口与C世界交接

_start是CPU真正跳到的地方。这里只做两件事:设置栈、调用C入口函数。

_start: mov $stack_top, %esp call kernel_main cli hlt .Lhang: jmp .Lhang .section .bss .align 16 stack_bottom: .skip 16384 stack_top:

为什么需要手动设置栈?因为GRUB跳过来的时候并没有给内核准备一个可用的栈,而C代码一旦调用函数或者声明局部变量,就会用到栈指针。如果不设置esp,程序会往随机内存地址写数据,随时崩溃。.bss段里的16KB栈空间足够这个阶段用了。

C侧入口函数我写成:

#define VGA_ADDR 0xb8000 #define COLUMNS 80 static void putchar_at(char c, int col, int row) { char *vga = (char *)VGA_ADDR; int offset = (row * COLUMNS + col) * 2; vga[offset] = c; vga[offset + 1] = 0x07; } void kernel_main(unsigned int magic, unsigned int mbi_addr) { const char *msg = "Hello USTC OS Lab1!"; int i = 0; if (magic != 0x2BADB002) { const char *err = "BAD MAGIC"; for (i = 0; err[i]; i++) putchar_at(err[i], i, 0); while (1) ; } for (i = 0; msg[i]; i++) putchar_at(msg[i], i, 0); while (1) ; }

打印用的是一个非常原始的方法:直接往内存地址0xB8000写数据。这是VGA文本模式的显存基地址,字符模式下一屏80列25行,每个字符占两字节,低字节是ASCII码,高字节是颜色属性,0x07表示黑底白字。这个阶段不搞复杂抽象,能看见字符就行。

magic参数如果不等于0x2BADB002,说明引导器不是按照Multiboot规范把我们拉起来的,继续执行没有任何意义,干脆死循环。mbi_addr虽然是地址参数,但暂时先存着不用,后面解析内存布局时再派用场。

3.3 链接脚本:让地址对得上

有了目标文件和入口,还需要一个链接脚本把布局固定下来:

ENTRY(_start) SECTIONS { . = 1M; .text : { *(.multiboot) *(.text) } .data : { *(.data) } .bss : { *(.bss) } }

ENTRY(_start)告诉链接器入口符号。最关键的地址是从1M开始,也就是1MB处。为什么是1MB?因为PC架构里低1MB内存被BIOS、VGA缓冲区、设备映射占据,操作系统内核的加载地址惯例上从1MB开始。GRUB会帮我们把ELF文件按Program Header内容加载到正确物理地址,所以链接地址必须和加载地址一致。

这里最容易翻车的地方是.multiboot段的顺序。链接脚本必须把*(.multiboot)放在.text的第一个位置,输入文件里start.o也要排在其他对象文件之前,否则header就会被排到文件后面,GRUB找不到,直接报Error 13

4. 构建运行与调试

4.1 Makefile与ISO打包

实验的构建流程适合做成Makefile,完整内容如下:

OBJS = start.o kernel.o CFLAGS = -m32 -ffreestanding -fno-pic -fno-stack-protector -Wall -Wextra LDFLAGS = -m elf_i386 -T linker.ld -nostdlib all: kernel.elf start.o: start.S as --32 start.S -o $@ kernel.o: kernel.c gcc $(CFLAGS) -c kernel.c -o $@ kernel.elf: $(OBJS) ld $(LDFLAGS) $(OBJS) -o $@ iso: kernel.elf mkdir -p iso/boot/grub cp kernel.elf iso/boot/kernel.elf printf 'set timeout=0\nmenuentry "ustc-os" {\n multiboot /boot/kernel.elf\n boot\n}\n' > iso/boot/grub/grub.cfg grub-mkrescue -o os.iso iso run: kernel.elf qemu-system-i386 -kernel kernel.elf clean: rm -f *.o kernel.elf os.iso rm -rf iso

as --32不能漏,否则在64位系统上会生成64位目标文件,跟后面的ld -m elf_i386不匹配。GRUB配置文件里的命令是multiboot,对应Multiboot1协议;如果哪天你写了Multiboot2的头部,这里就要改成multiboot2,两个协议的header格式不通用。

平时调试我不用ISO,直接qemu-system-i386 -kernel kernel.elf就行。QEMU自己内置了Multiboot加载器,能从你的ELF文件里找到header并加载,省掉每次生成ISO的时间。等到需要完整验证引导过程时,再用make iso做光盘镜像。

4.2 QEMU运行与常用参数

最基本的运行命令:

qemu-system-i386 -kernel kernel.elf -m 128

-m 128表示虚拟机内存128MB。如果运行环境没有图形界面,VGA窗口出不来,可以加-nographic -serial stdio,配合串口输出。这里有个经验:纯-nographic模式看不到VGA文本,所以我在早期调试时特意写了一个串口发送函数,把日志同时发到串口,这样不管是本地窗口还是远程终端都能看到输出。

QEMU的串口参数还支持更多定制,比如-chardev stdio,id=ch0 -device isa-serial,chardev=ch0,如果你自己在做串口驱动,还可以设置baudbase调整波特率基值。这些细节等做到驱动实验时再展开,现在先用默认值就行。

4.3 用GDB和QEMU日志排查启动段

遇到黑屏或重启,最有效的手段是GDB远程调试。QEMU启动时加两个参数:

qemu-system-i386 -kernel kernel.elf -s -S

-s让QEMU监听1234端口,-S让CPU启动后先暂停。另一个终端里运行:

gdb kernel.elf (gdb) target remote :1234 (gdb) b *0x100000 (gdb) continue

如果GRUB正常加载并跳转,程序会停在入口0x100000(也就是链接脚本里的1M地址)。这时候用info registers看寄存器,eax应该是0x2badb002ebx是指向multiboot信息的地址。如果断点始终没触发,说明GRUB压根没跳进我们的内核,问题大概率出在Multiboot header或ELF加载地址上。

另一个好用的开关是-d int,cpu_reset。它能记录QEMU里发生的每次中断和CPU重启原因。如果你看到CPU自动重启,基本都是因为发生了三重故障,日志里会写明具体是哪种异常(比如#GP)。有了这个日志,排查方向会明确很多。

5. 我在实验里踩过的坑

5.1 头部落了或者魔数不对

症状是GRUB菜单能出来,但一选内核就报Error 13: Invalid or unsupported executable format。我一开始反复看汇编代码,魔数没错、checksum也算了,就是不明白问题在哪。最后用objdump -s -j .multiboot kernel.elf一查,发现.multiboot段根本没被放到文件开头。

原因很简单:链接脚本里如果只写*(.text),ld会按输入文件的顺序排布内容,但当时我的Makefile里kernel.o排在start.o前面,导致.text段后面才是.multiboot,前面被其他数据占据。解决办法有两个,一是把OBJS顺序调整为start.o kernel.o,二是在链接脚本里把*(.multiboot)显式写在*(.text)之前。改完之后再用readelf -x .multiboot kernel.elf验证,能看到开头有1badb002就对了。

5.2 屏幕有输出但不断重启

这个现象最迷惑人:VGA上已经打印出几个字符,然后QEMU窗口一闪,整个虚拟机重启。用-d int,cpu_reset看日志,发现是#GP异常不断发生,最后CPU三重故障复位。

定位下来有两个原因。第一个是栈溢出,我当时在.bss里只留了4KB栈,又在kernel_main里声明了一个局部数组,一调用就爆炸。把栈扩到16KB之后不再发生。第二个是全局变量位置问题,C代码里const char *msg指向的字符串如果被链接到错误的地址,程序取出来的是乱数据,后续访问直接非法。确保链接脚本里的.data段和.text段顺序正确,且装载地址连续,就不会出问题。

5.3 打印函数失灵的排查顺序

如果屏幕上一个字都没有,别先在0xB8000上死磕。我的排查顺序是:先用GDB确认程序确实到了kernel_main;再用x/10gx 0xb8000看看显存里有没有数据;最后检查字符串本身是否被正确加载。这个顺序能快速切分问题是在入口、打印逻辑还是数据布局。

还有一个常见坑:如果Multiboot header里的FLAGS设置了请求视频模式(bit2),GRUB有可能会切到图形帧缓冲,这时候0xB8000文本显存是不可用的。我实验里只用0x3,没请求视频模式,QEMU默认停在文本模式,所以打印没问题。等你以后真的去调图形模式,就要区分文本缓冲区和帧缓冲区的差异了。

5.4 典型报错速查表

现象常见原因处理手段
GRUB报 Error 13.multiboot段没在开头,或magic错误用readelf/objdump检查header位置与内容
运行后无限重启异常导致三重故障-d int,cpu_reset看日志,查栈和链接地址
屏幕全黑但GDB能进_start打印函数/显存地址被覆盖先改用串口输出,排除VGA问题
ld报错和ELF格式不匹配默认ld输出64位目标使用-m elf_i386
grub-mkrescue找不到工具缺grub-pc-bin或xorriso安装对应包后重试

6. 下一步可以扩展的方向

6.1 把打印函数升级成基础内核设施

实验代码里的putchar_at一次只能往固定位置写字符,没有光标移动,也没有滚屏。下一步可以做成一个简单的终端驱动:支持\n换行、\r回车、维护光标行列位置,屏幕写满时整体上移。这虽然看起来只是打印功能,但它是所有内核日志输出的基础,后面调试中断、调度器、文件系统都依赖它。

6.2 解析multiboot_info的内存布局

kernel_main接收的第二个参数我们已经拿到了,那是GRUB填入的multiboot_info_t地址。下一步可以解析其中的mmap_addrmmap_length字段,遍历可用物理内存区域,打印出哪些地址范围可以分配。这一步是所有物理内存管理器的起点,做页分配器时会频繁用到。

6.3 尝试Multiboot2和不同引导方式

Multiboot1够用但偏老,现代GRUB对Multiboot2支持得更好,协议结构也更清晰。可以把header升级到Multiboot2,对比一下GRUB传给内核的信息有什么不同。另外,QEMU的-kernel直载方式和光盘ISO启动的实际路径有细微差别,多跑几种引导方式,能加深对“引导协议”这个概念的理解。

这个实验做完,我最大的感受是:操作系统入门看起来门槛高,但只要把“引导协议”这条主线抽出来,先让内核被加载、被跳转、能打印,后续很多抽象概念都更容易落地。代码乱没关系,关键是把每一步背后的“为什么”想清楚,后面实验会越写越顺。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询