简介:面向操作系统初学者的启动实验资源,围绕QEMU模拟器完成操作系统引导,适配正在做操作系统课程实验的读者。压缩包内提供实验报告和完整源代码,通过编写多引导协议(Multiboot)头部,在VGA显存直接写入“helloworld”,并经串口输出“HELLOWORLD”,覆盖屏幕与串口两类输出方式;实验内容还涵盖Ubuntu与宿主机共享文件夹配置、多引导协议要点、必要汇编知识,以及Makefile与链接描述文件的配合使用。资源共10个文件,以PDF实验说明、汇编源码、链接脚本、构建脚本为主,另含编译产物与编辑器配置,总大小仅778KB,轻量易读。已有293人学习下载;可对照报告与源码,从多引导头部编写、汇编链接到QEMU加载启动及输出验证逐步理解,便于快速搭建实验环境,并为后续操作系统实验打好基础。
1. 一个操作系统的「第一次心跳」:用QEMU把Multiboot内核拉起来
手写操作系统的第一步,卡住的往往不是C语言,而是「怎么让内核在真机或虚拟机里被引导起来」。这个lab1实验解决的就是这个问题:在Ubuntu环境下,用汇编写一个符合Multiboot规范的入口,再用QEMU模拟x86机器,把这个最小内核跑起来。它不是完整的操作系统,而是一段能打印字符、能进入保护模式、能被GRUB或QEMU识别的最小骨架。
对刚接触OS的人来说,这个实验是理解「计算机启动时到底发生了什么」的绝佳切口——从BIOS/UEFI到Bootloader,再到内核入口,整条链路在一个小小的工程里全部打通。本文把这套lab1的完整实现拆开讲透,技术栈是Linux下常见的组合:汇编、GNU工具链、QEMU、Makefile。如果你正在啃操作系统课程,或者想搞明白自己写的内核为什么起不来,这篇能让你少走不少弯路。
2. 项目架构拆解:Multiboot规范、QEMU与最小内核的三角关系
2.1 为什么是Multiboot规范:给Bootloader一张「说明书」
Multiboot是GRUB等引导程序与操作系统内核之间的一种约定协议。它规定内核必须在文件开头放置一段固定结构的头(Multiboot Header),引导程序读取这个头,就知道该把内核加载到哪个内存地址、需要传递哪些信息。简单说,这是Bootloader和内核之间的「握手协议」。
实验里用到的Multiboot头结构,核心是三个32位整数:
| magic | flags | checksum |
|---|---|---|
| 0x1BADB002 | 0x00000003 | -(magic + flags) |
校验和的算法很简单:checksum = -(magic + flags),保证三者相加等于0。GRUB和QEMU的-kernel参数都会检查这个头,对不上就直接拒绝引导,报错信息通常暧昧得让人想摔键盘。flags字段置为3,表示告诉引导程序「我需要对齐到4KB边界,并且请求提供内存布局信息」。
这套结构的价值在于:你不需要自己写引导扇区代码,GRUB已经替你完成了从实模式到保护模式的肮脏工作,内核入口直接面对一个干干净净的32位环境。对刚接触OS的开发者来说,这意味着可以跳过最折磨人的实模式汇编,直接写C代码。
2.2 工程文件组成:从零到能跑的四个源文件
整个lab1工程结构非常精简,实验的核心文件就四个,我通常按下面的目录组织:
lab1/ ├── boot.S # 汇编写的Multiboot头 + 内核入口 ├── kernel.c # C语言写的主函数,打印字符 ├── linker.ld # 链接脚本,规定内存布局 └── Makefile # 一键编译 + 启动QEMUboot.S是整个工程的灵魂,它定义了Multiboot头,同时负责设置栈指针、调用C函数。kernel.c则是所有C语言操作的起点,这里通常只做一件事:向屏幕打印一串字符,证明内核活着。linker.ld把这两个文件按预期地址合并成ELF文件,Makefile则解决「每次敲一长串命令」的问题。
这三个文件配合的意义在于分层清晰:汇编管启动、C管逻辑、链接脚本管布局。初学者最容易搞混的是「谁先执行」——答案是CPU从物理地址0x100000开始执行,也就是boot.S的第一条指令,然后由它跳转到kernel.c里的入口函数。
2.3 链接脚本的关键作用:把内核放到指定内存位置
没有linker.ld,你编译出来的内核会被链接到默认地址,而Multiboot规范要求内核加载到1MB以上(通常是0x100000)。链接脚本就是干这个的:
OUTPUT_FORMAT(elf32-i386) ENTRY(start) SECTIONS { . = 0x100000; .text : { *(.multiboot) *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { *(COMMON) *(.bss) } }这段脚本有几个关键点。ENTRY(start)指定入口符号是start,对应汇编文件里的全局标号。. = 0x100000把当前地址计数器设为1MB处,这是Multiboot规范约定的内核加载点。*(.multiboot)表示把所有目标文件里的.multiboot段按顺序放到最前面,确保Multiboot头位于文件开头附近——引导程序扫描内核前8KB就能找到它。
链接脚本一写错,症状通常极其诡异:要么QEMU直接不认这个内核,要么跳转到错误地址导致三连重启。我在做这个实验时,就曾因为漏了.multiboot段,导致GRUB报「invalid magic number」,排查了整整一个下午。
3. 核心代码逐段拆解:汇编入口、保护模式与第一个C函数
3.1 boot.S:Multiboot头与16字节对齐
这是整个lab1里最容易翻车的文件。Multiboot规范要求头结构里的每个字段都是32位(4字节),且整个头的大小必须是4的倍数。更严格的说法是,在GRUB下启动时,头的前4字节必须是magic。看代码:
.set ALIGN, 1<<0 # 对齐加载模块 .set MEMINFO, 1<<1 # 提供内存映射 .set FLAGS, ALIGN | MEMINFO # Multiboot标志 .set MAGIC, 0x1BADB002 # 魔数,固定值 .set CHECKSUM, -(MAGIC + FLAGS) # 校验和 .section .multiboot .align 4 .long MAGIC .long FLAGS .long CHECKSUM .section .text .extern kernel_main .global start start: cli # 关闭中断 mov $kernel_stack, %esp # 设置栈指针 call kernel_main # 跳转到C入口 hlt # 停机 .section .bss .align 16 kernel_stack: .skip 16384 # 16KB内核栈逐行看这套汇编的意图。.set是GNU汇编器的宏定义,相当于C语言的#define。FLAGS取1和2的或值,即二进制11,含义是「请求4KB对齐 + 请求内存信息」。CHECKSUM的算法必须前文写的那样保证三个数相加为0,这是GRUB校验的关键。
.section .multiboot这一段——很多初学者在这里踩坑:段名必须和链接脚本里的对应,否则头结构被放到了别的位置,引导程序扫描时自然找不到。.long指令依次输出三个32位整数,.align 4保证起始地址是4的倍数。
进入start入口后,第一件事是cli关中断。为什么?因为此时中断描述符表(IDT)还没设置,任何一个硬件中断进来都会导致CPU跳转到随机地址,直接三重故障重启。然后设置栈指针——16KB栈空间足够了。最后call kernel_main跳进C世界,hlt让CPU停机,作为兜底——正常情况下kernel_main不会返回。
3.2 kernel.c:什么都不做,但要让屏幕「亮」起来
void kernel_main(void) { const char *message = "Hello, OS World!"; char *video_memory = (char *)0xB8000; for (int i = 0; i < 16; i++) { video_memory[i * 2] = message[i]; video_memory[i * 2 + 1] = 0x07; } while (1) { __asm__ __volatile__ ("hlt"); } }这段代码用了x86架构经典的VGA文本模式:物理地址0xB8000映射到显存,每两个字节对应屏幕上一个字符位置,低字节是ASCII码,高字节是颜色属性。0x07表示白字黑底——终端里最朴素的配色。
while(1)循环里嵌hlt,这是一个容易忽略的细节:如果直接while(1)空转,CPU会以100%占用率空跑,在QEMU里表现为宿主机的CPU风扇狂转。加了hlt指令后,CPU进入低功耗停机状态,直到下一个中断到来才被唤醒。
__asm__ __volatile__是GCC内联汇编的写法,volatile防止编译器把这个语句优化掉——如果不加,有些优化级别下编译器认为这个空循环没副作用,直接删掉,内核跑起来就变成「运行完就死」。这个坑在O2优化下真实发生过。
3.3 Makefile:把三条命令变成一条
lab1的编译命令不算复杂,但每次都敲一遍很容易出错。Makefile的价值在于把编译、链接、运行固化成标准流程:
C_SOURCES = kernel.c ASM_SOURCES = boot.S CC = gcc LD = ld CFLAGS = -m32 -Wall -O2 -ffreestanding -c LDFLAGS = -m elf_i386 -T linker.ld all: kernel.bin kernel.bin: $(ASM_SOURCES) $(C_SOURCES) linker.ld $(CC) $(CFLAGS) $(C_SOURCES) $(LD) $(LDFLAGS) -o kernel.bin boot.o kernel.o run: kernel.bin qemu-system-i386 -kernel kernel.bin clean: rm -f *.o kernel.bin-m32让GCC生成32位代码,这是x86保护模式的要求。-ffreestanding告诉编译器「不要依赖标准库」,因为操作系统内核里没有printf、memcpy这些函数。-O2在实验里通常没问题,但如果你用了复杂的内联汇编,建议先降到-O0验证逻辑,再开优化。
ld的参数-m elf_i386指定输出32位ELF格式,-T linker.ld指定链接脚本。如果漏了-m elf_i386,在64位Ubuntu上会默认输出64位ELF,QEMU加载时直接报格式错误。qemu-system-i386 -kernel kernel.bin用QEMU直接加载内核文件——这个-kernel参数背后是QEMU内置的Multiboot加载器,它模拟了GRUB的引导行为。
4. 在Ubuntu上跑通:从安装工具链到看到屏幕输出
4.1 环境准备:QEMU和GCC多架构支持
Ubuntu下需要安装的软件包不多,但版本坑比较密集。我实验用的环境是Ubuntu 22.04 LTS,需要安装三个核心包:
sudo apt update sudo apt install qemu-system-x86 gcc-multilib gdbqemu-system-x86提供qemu-system-i386命令,负责模拟x86平台。gcc-multilib解决-m32编译时缺少32位头文件的问题——64位系统上如果不装这个包,GCC会报一堆「bits/libc-header-start.h: No such file or directory」错误。gdb用来调试内核,后面排查问题时非常有用。
验证环境是否就绪,执行:
qemu-system-i386 --version gcc -m32 -v 2>&1 | tail -5如果gcc -m32报错找不到头文件,大概率是gcc-multilib没装成功,或者系统里GCC版本太新。碰到这种情况,用sudo apt install gcc-12-multilib指定版本装上即可,不要跟默认GCC版本死磕。
4.2 编译与运行:一次成功的心路历程
在工程目录下执行make,期望的输出是:
gcc -m32 -Wall -O2 -ffreestanding -c kernel.c ld -m elf_i386 -T linker.ld -o kernel.bin boot.o kernel.o没有任何报错,然后make run启动QEMU:
qemu-system-i386 -kernel kernel.binQEMU窗口弹出来,屏幕显示「Hello, OS World!」。这一刻的满足感,是后面写再多应用代码都换不来的。如果屏幕是黑的或者直接退出,不要慌,先检查Multiboot头——这是超过半数失败案例的原因。
调试手段我用的是qemu-system-i386 -kernel kernel.bin -gdb tcp::1234 -S,-S让CPU在启动时暂停,-gdb开放调试端口。然后在另一个终端里启动GDB远程连接:
gdb (gdb) target remote :1234 (gdb) info registers (gdb) break *0x100000 (gdb) continue这样可以在内核入口处停下来,单步跟踪每条指令的执行——比盲改代码瞎猜高效得多。当初我用这招排查过一个栈指针未设置的问题:调用kernel_main前%esp还是0,一进C函数第一条指令push %ebp就把内存写炸了。
4.3 用GRUB引导替代QEMU -kernel
实验要求里可能会提到用GRUB引导。QEMU的-kernel参数内置了Multiboot支持,但和真实GRUB仍有差异。要模拟真实的GRUB启动,可以制作一个启动镜像:
mkdir -p iso/boot/grub cp kernel.bin iso/boot/kernel.bin cat > iso/boot/grub/menu.lst << EOF default=0 timeout=0 title MyOS root (cd) kernel /boot/kernel.bin EOF genisoimage -R -b boot/grub/stage2_eltorito -no-emul-boot -boot-load-size 4 \ -A os -input-charset utf-8 -o os.iso iso qemu-system-i386 -cdrom os.iso这套流程每次都会重新生成ISO镜像,比较繁琐。日常开发用-kernel就够了,只有需要验证「我的内核能否被标准GRUB识别」时才值得走这遍流程。注意menu.lst里的root (cd)是GRUB的ISO路径表示法,不同GRUB版本写法略有差异。
5. 常见问题排查:lab1最容易翻车的五个现场
5.1 QEMU报「invalid magic number」
现象:执行make run后QEMU窗口一闪而过,终端报错qemu: fatal: Bad magic number。
原因:Multiboot头里的magic字段不是0x1BADB002,或者头没被放到ELF文件开头8KB内。常见诱因是.section .multiboot和链接脚本里的段名不一致,导致头被链接到了.text段后面。另一个可能是.align 4被误写成.align 16,造成头结构里出现额外的填充字节。
解决:先用objdump -h kernel.bin查看段表,确认.multiboot段的起始地址在文件偏移0x0附近。再用readelf -x .multiboot kernel.bin检查内容,前12个字节应当是02 B0 AD 1B 03 00 00 00 F5 4F 52 E4。不匹配就回头改汇编文件和链接脚本。
5.2 屏幕全黑,QEMU无任何报错
现象:QEMU启动后窗口存在,但屏幕上一个字符都没有,CPU像是卡住了。
原因:kernel_main没有执行,或者执行了但写地址错误。排第一位的原因是栈指针未初始化——mov $kernel_stack, %esp这句漏了或顺序不对,call kernel_main压栈时把%eip写到了随机地址。排第二位的是video_memory地址写错,比如写成0xB800这种经典笔误。
解决:GDB挂在0x100000断点,单步执行看%eip走到哪里。如果call kernel_main执行后%eip变成0x0或者明显非法的值,检查.bss段里kernel_stack的空间是否分配。确认kernel_stack标号地址正确,可以info variables查看。
5.3 编译报错:无法找到crt0.o
现象:ld链接阶段报错cannot find crt0.o: No such file or directory。
原因:64位Ubuntu系统的默认ld试图按64位方式链接,但输入文件是32位ELF,于是去找64位的启动文件。本质上就是漏了-m elf_i386参数。
解决:检查Makefile里LDFLAGS是否包含-m elf_i386。注意ld的参数顺序——-T linker.ld和-m elf_i386都要放在-o kernel.bin之前,否则ld可能报「unrecognized option」或者干脆忽略掉。
5.4 QEMU不是虚拟机,而是窗口瞬间退出
现象:QEMU窗口弹出后立刻关闭,像是崩溃了。
原因:可能是双线性映射问题——CPU执行到hlt指令但中断未关闭,或者kernel_main里while(1)循环前触发了异常。最隐蔽的一种是:链接脚本把.bss段放在了内存地址重叠的位置,栈指针初始值落在代码段里,一压栈就把指令覆盖了。
解决:检查链接脚本里.bss段的地址是否与.text段冲突。用nm -n kernel.bin查看符号地址,确认kernel_stack在.text段的后面。如果地址重叠,修改链接脚本的布局,或者给.bss段设置一个明确的起始地址。
5.5 QEMU窗口出现乱码字符
现象:屏幕上有内容,但是乱码——ASCII码显示成了奇奇怪怪的符号。
原因:video_memory[i * 2]和video_memory[i * 2 + 1]的顺序写反了。字符数组的布局是「低字节字符,高字节属性」,写反就变成用ASCII当作属性、用属性当作字符输出。第二个常见原因是数组越界,message字符串长度不够16却强行循环16次,读出了\0后面的垃圾数据。
解决:逐字符debug太痛苦,直接检查两个点:message的实际长度,以及循环边界。简单粗暴的办法是把循环上限定为sizeof(message),并且把message声明为const char *message = "Hello, OS World!";,编译时加-Wall会提示字符串字面量赋给char*的类型警告。
6. 二次开发:从最小内核到可打印数字的调试内核
6.1 第一个扩展:实现整数转十六进制打印
lab1做完之后,最自然的扩展是给内核加一个调试输出函数——打印寄存器值。这个功能做出来,后续lab里排查问题会舒服很多:
void print_hex(unsigned int value) { char hex_chars[] = "0123456789ABCDEF"; char *video_memory = (char *)0xB8000; int pos = 0; for (int i = 7; i >= 0; i--) { unsigned int shift = i * 4; unsigned int digit = (value >> shift) & 0xF; video_memory[pos * 2] = hex_chars[digit]; video_memory[pos * 2 + 1] = 0x07; pos++; } }这个函数把value当作32位整数,从高位到低位逐个取出4位(一个十六进制位),映射到字符表并写进显存。(value >> shift) & 0xF是提取指定位的经典写法,shift从28递减到0,正好覆盖8个十六进制字符。
放在kernel_main里调用:
void kernel_main(void) { print_hex(0xDEADBEEF); while (1) { __asm__ __volatile__ ("hlt"); } }屏幕上应该显示DEADBEEF。这个函数往后做中断处理、内存管理实验时到处都要用——建议在这个函数基础上再加一个print_string_at带位置参数的版本,免得每次只能从屏幕左上角覆盖输出。
6.2 第二个扩展:读取Multiboot信息结构
实验里Multiboot头的flags标志位请求了内存信息,引导程序会在进入内核时把信息结构的指针放在%ebx寄存器里。这个信息结构里包含内存布局、帧缓冲信息等关键数据。修改入口汇编:
start: cli mov $kernel_stack, %esp push %ebx # 保存Multiboot信息指针 call kernel_main hltkernel.c里从栈上取出指针:
void kernel_main(unsigned int magic, unsigned int mb_info_addr) { if (magic != 0x2BADB002) { /* 说明不是标准Multiboot引导程序加载的 */ return; } print_hex(mb_info_addr); while (1) { __asm__ __volatile__ ("hlt"); } }0x2BADB002是引导程序写入%eax的魔数,用来告诉内核「我是通过Multiboot协议启动你的」。判断失败就直接静默返回——这比乱读内存强得多。拿到mb_info_addr后,按Multiboot规范解析内存段:第一个uint32_t是flags,第二个是内存低位,第三个是内存高位,然后是mem_upper……这套结构是后面做物理内存管理器的前提。
6.3 我的实验习惯:内核日志先于一切功能
这个lab1带给我最大的收获不是会写了Multiboot头,而是养成了一个习惯:任何新功能落地前,第一步先实现可用的输出原语。不管是打印十六进制、打印字符串、还是调整屏幕位置,先把「看得到内容」这件事做扎实,后面调试任何模块都有了眼睛。从那以后,我做每一个OS实验,都强制先跑通输出函数,再碰中断、分页、进程调度——这个习惯帮我省下来的时间,比花在写代码上的多好几倍。希望这篇拆解能帮你把lab1跑通,也把这份「先把地基夯实」的思路带走,后面每一步都会顺很多。
本文还有配套的精品资源,点击获取