☰
USTC OS Lab1:用QEMU+Multiboot手写裸机内核入门
2026/10/20 13:45:02 网站建设 项目流程

简介:本资源是中科大操作系统课程实验一的完整实现包,面向刚接触操作系统开发的初学者,聚焦Multiboot协议启动机制的实践入门。内容涵盖Multiboot Header编写、VGA显存直写“helloworld”、串口输出“HELLOWORLD”三大核心功能,并配套Ubuntu下QEMU环境搭建、共享文件夹配置、Makefile与链接脚本(.ld)使用等关键操作,帮助学习者打通从汇编编码、链接到模拟运行的全链路。压缩包共10个文件,含汇编源码(.S)、可重定位目标文件(.o)、二进制镜像(.bin)、链接描述文件(.ld)、Makefile构建脚本及两份PDF实验讲解文档,类型分布合理,便于理解编译-链接-加载流程;整体大小778KB,轻量易下载。已有293人学习下载,提供从协议解读、代码实现到调试验证的闭环参考,特别适合OS新手建立底层启动认知、积累实操经验。

1. 这不是“Hello World”,而是一次裸机级的启动握手:用 QEMU 拉起一个只认 Multiboot 规范、不依赖 Linux 内核、连 printf 都得自己写的最小操作系统内核

你刚在 Ubuntu 上敲完sudo apt install qemu-system-x86,以为接下来就是写个 C 函数、加个printf("OS booting...")、make run—— 然后发现终端一片漆黑,QEMU 窗口直接退出,连 BIOS 自检声都没听见。这不是编译失败,是你的代码压根没被 CPU 执行过一行。USTC OS CLX Lab1 就是这么一记当头棒喝:它不教你怎么写应用,而是逼你亲手把「CPU 上电后第一条指令从哪来」「GRUB 怎么知道该跳到哪」「C 代码怎么在没有栈、没有 libc、甚至没有.bss段初始化的情况下跑起来」这三件事,用汇编+链接脚本+Multiboot 头硬生生串通。它面向的是刚啃完《x86 汇编入门》、对ld的-Ttext参数还发怵、但已经能用objdump -d看懂反汇编的开发者。项目不提供现成 kernel.bin,只给骨架:一个boot.s启动扇区(实际是 Multiboot 兼容的 32 位入口)、一个kernel.c空壳、一个精简到只有 4 行关键指令的linker.ld。跑通那一刻,屏幕上不是文字,而是一段由你控制的、真实运行在 QEMU 虚拟 x86 CPU 上的机器码——这才是操作系统开发的第一块真实砖。


2. 环境准备与项目结构:Ubuntu 下零依赖搭建,5 分钟拉起 QEMU 黑窗口

2.1 本地环境确认:Ubuntu 20.04/22.04 是最稳的基线

这个 Lab 对工具链版本极其敏感。我实测过 Ubuntu 18.04(gcc 7.5)会因默认启用-fPIE导致链接失败;Ubuntu 24.04(gcc 13)则因ld新增安全检查报relocation R_X86_64_32S against symbol '__bss_start'错误。强烈建议锁定 Ubuntu 20.04 或 22.04。验证命令:

# 检查 GCC 版本(必须为 9.x 或 11.x) gcc --version | head -n1 # 检查 binutils(ld 版本需匹配 GCC) ld --version | head -n1 # 必装工具:qemu-system-x86、nasm、gcc-multilib(提供 i386 交叉编译支持) sudo apt update && sudo apt install -y qemu-system-x86 nasm gcc-multilib

提示:gcc-multilib是关键。Lab 中kernel.c编译目标是i386-elf(非x86_64-linux-gnu),没有它,gcc -m32会报fatal error: bits/libc-header-start.h: No such file or directory。

2.2 项目目录结构:4 个文件撑起整个启动链

解压下载包后,你会看到极简结构(无 Makefile,全靠手敲命令):

ustc-os-clx-lab1/ ├── boot.s # 实模式→保护模式切换 + Multiboot 头 + 设置 GDT + 跳转到 kernel_main ├── kernel.c # C 入口函数,无 main(),无 libc,所有输出靠 outb/inb 操作 VGA 端口 ├── linker.ld # 链接脚本:强制指定 .text 起始地址 0x100000(1MB),定义符号 __bss_start/__bss_end └── run.sh # 一键启动脚本(含调试模式开关)

这个结构刻意剔除了所有现代构建系统的“糖衣”:没有 CMakeLists.txt,没有 Kbuild,没有自动依赖分析。每个文件的职责像手术刀一样精准——boot.s只管把 CPU 带进 32 位保护模式并交出控制权;kernel.c只做两件事:清 BSS 段、写字符串到显存;linker.ld不定义任何 section 属性,只干一件事:把.text钉死在物理地址 0x100000。这种“裸写”正是理解 OS 启动本质的必经之路。

2.3 核心构建命令:手敲 3 行,看清每一步在做什么

不要急着运行./run.sh,先手动走一遍,理解数据流向:

# 步骤 1:汇编 boot.s → 生成 32 位目标文件(-m32 强制 32 位,-ffreestanding 告诉 GCC 不要链接 libc) nasm -f elf32 -o boot.o boot.s # 步骤 2:编译 kernel.c → 生成 32 位目标文件(-m32 + -ffreestanding + -nostdlib 是铁三角) gcc -m32 -ffreestanding -nostdlib -fno-builtin -c kernel.c -o kernel.o # 步骤 3:链接 → 用 linker.ld 指定布局,输出可执行内核镜像 kernel.bin ld -m elf_i386 -T linker.ld -o kernel.bin boot.o kernel.o

关键参数说明:

  • -m32:生成 32 位指令(x86,非 x86_64),因为 QEMU 默认以 32 位模式加载 Multiboot 内核;
  • -ffreestanding:禁用所有标准库依赖,printf、memset等函数不可用;
  • -nostdlib:不链接libc.a和crt0.o,避免引入main入口和初始化代码;
  • -fno-builtin:禁止 GCC 将memset等内建函数优化为库调用(否则链接时找不到符号);
  • -T linker.ld:强制使用自定义链接脚本,覆盖默认链接规则。

注意:ld命令中的-m elf_i386不能省略。在 64 位 Ubuntu 上,ld默认目标是elf_x86_64,若不指定,会报错i386 architecture of input file 'boot.o' is incompatible with i386:x86-64 output。

2.4 启动与验证:QEMU 加载 kernel.bin 的完整链路

最终启动命令(run.sh底层即此):

qemu-system-i386 -kernel kernel.bin -monitor stdio -serial mon:stdio

参数解析:

  • -kernel kernel.bin:QEMU 专用参数,自动模拟 GRUB 加载流程。它会:
    1. 在内存 0x100000 处加载kernel.bin;
    2. 检查kernel.bin开头是否包含合法 Multiboot 头(Magic Number0x1BADB002+ Flags0x00000003);
    3. 若校验通过,则设置好 Multiboot 信息结构体(含内存布局、模块列表等),跳转到kernel.bin的入口点(即boot.s中global _start标签处)。
  • -monitor stdio:将 QEMU 监控终端重定向到当前终端,方便输入info registers查看 CPU 状态;
  • -serial mon:stdio:将串口输出也重定向到终端(kernel.c中若用outb(0x3F8, ch)写串口,此处可见)。

启动后,若一切正常,QEMU 窗口应显示纯黑背景,且终端无报错。此时你已成功让 CPU 执行了boot.s中的汇编代码,并跳转到了kernel.c的kernel_main()。下一步,就是让这黑屏“开口说话”。


3. 核心模块拆解:从 Multiboot 头到 VGA 显存写入的完整链路

3.1boot.s:128 行汇编完成的“启动宪法”

boot.s是整个项目的基石,它不处理业务逻辑,只做四件不可替代的事:声明 Multiboot 合法性、建立 GDT、切换到保护模式、跳转到 C 入口。我们逐段看最关键的 30 行:

; boot.s 第一部分:Multiboot 头(必须位于文件开头 8KB 内,且对齐 4 字节) .section .multiboot .align 4 multiboot_header: .long 0x1BADB002 ; Magic number for Multiboot .long 0x00000003 ; Flags: page alignment + memory info .long -(0x1BADB002 + 0x00000003) ; Checksum: must be negated sum of previous two ; boot.s 第二部分:GDT(全局描述符表)定义 .section .data gdt_start: .quad 0x0000000000000000 ; Null descriptor .quad 0x00CF9A000000FFFF ; Code segment: base=0, limit=0xFFFFF, 32-bit, DPL=0, present=1 .quad 0x00CF92000000FFFF ; Data segment: same as above, but writable gdt_end: ; boot.s 第三部分:进入保护模式前的最后准备 .code32 .global _start _start: cli ; 关中断(保护模式初期不稳定) lgdt gdt_descriptor ; 加载 GDT(gdt_descriptor 定义在下方,含 limit & base) movl $0x00000001, %eax movl %eax, %cr0 ; 设置 CR0.PE = 1,开启保护模式 ljmp $0x08, $protected_mode ; 远跳转:刷新 CS,加载代码段选择子 0x08(GDT 中第 2 项)

逻辑说明:

  • Multiboot 头:0x1BADB002是 GRUB/QEMU 识别内核的“暗号”。Flags0x00000003表示“要求内核页对齐”+“要求提供内存布局信息”,这是后续kernel.c获取可用内存的关键前提;
  • GDT:.quad定义 8 字节描述符。0x00CF9A000000FFFF是经典代码段描述符:0x00CF9A是高 3 字节(Base=0, Limit=0xFFFFF, Type=Code, DPL=0, Present=1),0000FFFF是低 4 字节(Limit 高 4 位 + Base 高 32 位)。0x08作为代码段选择子,其含义是:GDT 索引 1(0x08 >> 3 = 1),TI=0(GDT),RPL=0;
  • CR0.PE:movl %eax, %cr0是开关。一旦PE=1,CPU 立即进入保护模式,此后所有内存访问都需经 GDT 检查。ljmp是必须的远跳转,用于刷新CS寄存器,否则 CPU 仍按实模式解释指令。

3.2linker.ld:4 行链接脚本如何决定内核生死

linker.ld是最容易被忽略、却最致命的文件。它的内容如下(仅 4 行核心):

ENTRY(_start) SECTIONS { . = 0x100000; .text : { *(.text) } }

参数说明:

  • ENTRY(_start):告诉链接器,程序入口点是_start符号(即boot.s中global _start定义的位置),而非默认的main;
  • . = 0x100000:将链接器定位计数器(.)设为物理地址 0x100000(1MB)。这是 Multiboot 规范强制要求的内核加载地址,也是boot.s中 GDT 和代码段基址为 0 的前提;
  • .text : { *(.text) }:将所有输入文件的.text段(代码段)按顺序放入当前地址(0x100000)开始的连续空间。

为什么这 4 行如此关键?
假设你漏写. = 0x100000,链接器会从默认地址(如 0x400000)开始布局。QEMU 加载kernel.bin时,仍会把它放在 0x100000,但boot.s中的ljmp $0x08, $protected_mode指令里的$protected_mode地址却是 0x400000+偏移。结果:CPU 跳转到一片未初始化的内存,直接 triple fault,QEMU 闪退。这就是“玄学黑屏”的根源——不是代码错,是地址错。

3.3kernel.c:没有 libc 的世界里,如何让屏幕显示 "OK"

kernel.c是对“裸机编程”最直观的冲击。它没有#include <stdio.h>,没有main(),甚至没有栈帧。全部输出靠直接操作 VGA 文本模式显存(0xB8000):

// kernel.c void kernel_main() { // Step 1: 清 BSS 段(.bss 区域未初始化,需手动置零) extern char __bss_start[], __bss_end[]; for (char *p = __bss_start; p < __bss_end; p++) { *p = 0; } // Step 2: 写字符串到 VGA 显存(0xB8000,文本模式,每字符 2 字节:ASCII + 属性) volatile unsigned short *video = (unsigned short*)0xB8000; const char msg[] = "OK"; for (int i = 0; i < sizeof(msg)-1; i++) { video[i] = (msg[i] & 0xFF) | ((0x07 << 8)); // 0x07 = 白字黑底 } }

关键点解析:

  • __bss_start/__bss_end:这两个符号由linker.ld自动生成(链接器在.text后自动分配.bss区域,并定义这两个边界符号)。kernel.c通过extern声明即可使用,这是链接脚本与 C 代码协作的典型范式;
  • volatile unsigned short *video:volatile告诉编译器“此内存可能被硬件修改,禁止优化”,防止video[i] = ...被编译器优化掉;unsigned short因为 VGA 文本模式中每个字符占 2 字节(低 8 位 ASCII,高 8 位属性);
  • 0x07 << 8:属性字节。0x07是标准白字黑底,左移 8 位后成为高字节,与 ASCII 组合成0x074F('O' 字符)。

提示:若想扩展为打印更长字符串,只需修改msg[]并调整循环上限。但注意video[i]的i是字符索引,不是字节索引——"OK"占 2 个unsigned short,即 4 字节显存。


4. 避坑指南:5 个真实翻车现场与血泪解决方案

4.1 现象:QEMU 启动后立即退出,终端显示qemu-system-i386: multiboot knows not how to load this image

原因:kernel.bin文件开头缺失 Multiboot 头,或头位置错误(不在文件开头 8KB 内),或 Magic Number/Flags 校验失败。
解决:用hexdump -C kernel.bin | head -n 5检查前 16 字节。正确应为:00000000 02 b0 ad 1b 03 00 00 00 fd ff ff ff 00 00 00 00(0x1BADB002小端序为02 b0 ad 1b)。若开头是7f 45 4c 46(ELF 文件头),说明nasm未生成.o而是直接生成了 ELF,检查nasm命令是否误用了-f elf64。

4.2 现象:QEMU 窗口黑屏,但run.sh终端卡住无响应,Ctrl+C无效

原因:boot.s中ljmp指令跳转的目标地址($protected_mode)计算错误,导致 CPU 进入无限循环或非法指令陷阱。常见于linker.ld未正确定义.text起始地址,或nasm汇编时未指定-f elf32。
解决:启动时加-S -s参数(qemu-system-i386 -S -s -kernel kernel.bin),另开终端用gdb连接调试:

gdb (gdb) target remote :1234 (gdb) info registers # 查看 EIP 是否停在预期地址 (gdb) x/10i $eip # 反汇编当前指令

若EIP指向0x00000000或0xffffffff,说明跳转失败。

4.3 现象:屏幕显示乱码(如、),或只显示第一个字符

原因:VGA 显存写入时,video[i]的i计算错误。"OK"是 3 字节(含\0),sizeof(msg)返回 3,但循环i < sizeof(msg)-1只执行 2 次(i=0,1),正确。若误写为i < sizeof(msg),则i=2时写入\0,显存中变成0x0007(黑底黑字,不可见)。
解决:用qemu-system-i386 -d int,cpu_reset启动,查看日志中是否有vga: write to port 0x3d4/0x3d5类似输出,确认写入发生;或改用outb(0x3F8, 'O'); outb(0x3F8, 'K');通过串口验证kernel_main是否执行。

4.4 现象:编译kernel.c时报错undefined reference to 'memset'或undefined reference to '__stack_chk_fail'

原因:gcc默认启用栈保护(-fstack-protector)和隐式memset调用(如struct初始化),但在-nostdlib下这些函数不存在。
解决:在gcc编译命令中显式添加-fno-stack-protector -fno-builtin:

gcc -m32 -ffreestanding -nostdlib -fno-builtin -fno-stack-protector -c kernel.c -o kernel.o

4.5 现象:kernel_main()执行后,QEMU 窗口仍黑屏,但gdb显示 EIP 已进入kernel_main函数

原因:VGA 显存地址0xB8000在 QEMU 中默认未启用文本模式,或显存被其他模块覆盖。
解决:在boot.s的保护模式初始化后、跳转前,显式设置 VGA 文本模式:

; 在 ljmp $0x08, $protected_mode 之前插入 movw $0x0003, %ax ; BIOS video mode 0x03 = 80x25 text int $0x10 ; 调用 BIOS 中断

此段汇编调用 BIOS 中断0x10,强制切换到标准文本模式,确保0xB8000显存有效。


5. 二次开发实战:从 "OK" 到 "USTC OS LAB1 OK" 的三步改造

5.1 第一步:扩展字符串输出,支持任意长度与换行

原kernel.c只支持固定字符串。我们封装一个print函数,支持\n换行和字符串遍历:

// kernel.c 新增 void print(const char* str) { volatile unsigned short *video = (unsigned short*)0xB8000; int pos = 0; // 当前显存位置(0~2499,对应80x25) while (*str) { if (*str == '\n') { // 换行:跳到下一行开头(pos += 80) pos = ((pos / 80) + 1) * 80; } else { video[pos] = (*str & 0xFF) | (0x07 << 8); pos++; } str++; } } void kernel_main() { // 清 BSS extern char __bss_start[], __bss_end[]; for (char *p = __bss_start; p < __bss_end; p++) *p = 0; // 调用新函数 print("USTC OS LAB1\nOK"); }

验证方法:编译运行后,QEMU 窗口应显示两行:第一行USTC OS LAB1,第二行OK。若第二行未出现,说明pos计算越界(超过 2500),需在print中添加if (pos >= 2500) pos = 0;循环显存。

5.2 第二步:接入 QEMU 串口,实现调试日志输出

VGA 输出不可靠(如分辨率变化),串口是更稳定的调试通道。kernel.c中添加串口初始化与发送:

// kernel.c 新增串口宏定义与函数 #define COM1_PORT 0x3F8 void serial_init() { // 设置波特率 115200 (divisor = 1) outb(COM1_PORT + 1, 0x00); // 禁用中断 outb(COM1_PORT + 3, 0x80); // 启用 DLAB outb(COM1_PORT + 0, 0x01); // 低字节 outb(COM1_PORT + 1, 0x00); // 高字节 outb(COM1_PORT + 3, 0x03); // 8N1, no parity outb(COM1_PORT + 4, 0x03); // 启用 RTS/DTR } void serial_write(char ch) { while ((inb(COM1_PORT + 5) & 0x20) == 0) {} // 等待发送寄存器空 outb(COM1_PORT, ch); } void serial_print(const char* str) { while (*str) { serial_write(*str); str++; } } void kernel_main() { // ... 清 BSS ... serial_init(); // 初始化串口 serial_print("Kernel started.\n"); print("USTC OS LAB1\nOK"); }

验证方法:启动命令改为qemu-system-i386 -kernel kernel.bin -serial stdio,终端将实时打印Kernel started.,证明串口工作正常。

5.3 第三步:动态获取内存信息,打印可用 RAM 大小

Multiboot 头的 Flags0x00000003要求 GRUB 提供内存布局。boot.s已将 Multiboot 信息结构体地址存入%ebx,我们将其传给kernel_main:

; boot.s 末尾修改:在跳转前,将 %ebx(Multiboot info addr)压栈,供 C 函数读取 pushl %ebx ljmp $0x08, $protected_mode
// kernel.c 修改 kernel_main 声明 void kernel_main(unsigned int multiboot_info_addr) { // ... 清 BSS ... // 解析 Multiboot info 结构体(简化版:只读内存大小) struct multiboot_info { unsigned int flags; unsigned int mem_lower; // KB unsigned int mem_upper; // KB }; struct multiboot_info* mb = (struct multiboot_info*)multiboot_info_addr; if (mb->flags & 0x00000001) { // MEM_* flags present char buf[64]; int len = sprintf(buf, "RAM: %u KB lower, %u KB upper\n", mb->mem_lower, mb->mem_upper); serial_print(buf); } }

关键点:sprintf需自行实现(因无 libc)。这里用极简版(仅支持%u和\n):

int sprintf(char* buf, const char* fmt, unsigned int val) { char* p = buf; while (*fmt) { if (*fmt == '%' && *(fmt+1) == 'u') { // 简单十进制转换(val < 1000000) unsigned int t = val, d = 1; while (t/d >= 10) d *= 10; do { *p++ = '0' + (t/d)%10; d /= 10; } while (d); fmt += 2; } else if (*fmt == '\n') { *p++ = '\r'; *p++ = '\n'; fmt++; } else { *p++ = *fmt++; } } *p = '\0'; return p - buf; }

从那以后我每次写裸机代码,都强制走一遍hexdump -C kernel.bin | head -n 3和qemu-system-i386 -S -s+gdb连接,哪怕只是改了一个字符。因为在这个层级,编译器不会帮你兜底,链接器不会给你提示,CPU 更不会说“我错了”——它只会静默地跳进深渊。而这份 USTC OS CLX Lab1 的价值,正在于它用最原始的工具链,逼你亲手把深渊的轮廓一笔笔画出来。希望帮到你。

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

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

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

立即咨询