简介本资源是中科大操作系统课程实验一的完整实现包面向刚接触操作系统开发的初学者聚焦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.04gcc 7.5会因默认启用-fPIE导致链接失败Ubuntu 24.04gcc 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 # 检查 binutilsld 版本需匹配 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 起始地址 0x1000001MB定义符号 __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.binQEMU 专用参数自动模拟 GRUB 加载流程。它会在内存 0x100000 处加载kernel.bin检查kernel.bin开头是否包含合法 Multiboot 头Magic Number0x1BADB002 Flags0x00000003若校验通过则设置好 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.s128 行汇编完成的“启动宪法”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: base0, limit0xFFFFF, 32-bit, DPL0, present1 .quad 0x00CF92000000FFFF ; Data segment: same as above, but writable gdt_end: ; boot.s 第三部分进入保护模式前的最后准备 .code32 .global _start _start: cli ; 关中断保护模式初期不稳定 lgdt gdt_descriptor ; 加载 GDTgdt_descriptor 定义在下方含 limit base movl $0x00000001, %eax movl %eax, %cr0 ; 设置 CR0.PE 1开启保护模式 ljmp $0x08, $protected_mode ; 远跳转刷新 CS加载代码段选择子 0x08GDT 中第 2 项逻辑说明Multiboot 头0x1BADB002是 GRUB/QEMU 识别内核的“暗号”。Flags0x00000003表示“要求内核页对齐”“要求提供内存布局信息”这是后续kernel.c获取可用内存的关键前提GDT.quad定义 8 字节描述符。0x00CF9A000000FFFF是经典代码段描述符0x00CF9A是高 3 字节Base0, Limit0xFFFFF, TypeCode, DPL0, Present10000FFFF是低 4 字节Limit 高 4 位 Base 高 32 位。0x08作为代码段选择子其含义是GDT 索引 10x08 3 1TI0GDTRPL0CR0.PEmovl %eax, %cr0是开关。一旦PE1CPU 立即进入保护模式此后所有内存访问都需经 GDT 检查。ljmp是必须的远跳转用于刷新CS寄存器否则 CPU 仍按实模式解释指令。3.2linker.ld4 行链接脚本如何决定内核生死linker.ld是最容易被忽略、却最致命的文件。它的内容如下仅 4 行核心ENTRY(_start) SECTIONS { . 0x100000; .text : { *(.text) } }参数说明ENTRY(_start)告诉链接器程序入口点是_start符号即boot.s中global _start定义的位置而非默认的main. 0x100000将链接器定位计数器.设为物理地址 0x1000001MB。这是 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 faultQEMU 闪退。这就是“玄学黑屏”的根源——不是代码错是地址错。3.3kernel.c没有 libc 的世界里如何让屏幕显示 OKkernel.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 *videovolatile告诉编译器“此内存可能被硬件修改禁止优化”防止video[i] ...被编译器优化掉unsigned short因为 VGA 文本模式中每个字符占 2 字节低 8 位 ASCII高 8 位属性0x07 8属性字节。0x07是标准白字黑底左移 8 位后成为高字节与 ASCII 组合成0x074FO 字符。提示若想扩展为打印更长字符串只需修改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 000x1BADB002小端序为02 b0 ad 1b。若开头是7f 45 4c 46ELF 文件头说明nasm未生成.o而是直接生成了 ELF检查nasm命令是否误用了-f elf64。4.2 现象QEMU 窗口黑屏但run.sh终端卡住无响应CtrlC无效原因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 字节含\0sizeof(msg)返回 3但循环i sizeof(msg)-1只执行 2 次i0,1正确。若误写为i sizeof(msg)则i2时写入\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-builtingcc -m32 -ffreestanding -nostdlib -fno-builtin -fno-stack-protector -c kernel.c -o kernel.o4.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 末尾修改在跳转前将 %ebxMultiboot 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和\nint sprintf(char* buf, const char* fmt, unsigned int val) { char* p buf; while (*fmt) { if (*fmt % *(fmt1) 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 -sgdb连接哪怕只是改了一个字符。因为在这个层级编译器不会帮你兜底链接器不会给你提示CPU 更不会说“我错了”——它只会静默地跳进深渊。而这份 USTC OS CLX Lab1 的价值正在于它用最原始的工具链逼你亲手把深渊的轮廓一笔笔画出来。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?