资讯动态

Ubuntu下用QEMU+Multiboot启动x86实模式内核并打印OK

发布时间:2026/10/20 13:45:01 来源:尧图企业网站定制
简介本资源是中科大操作系统课程实验一的完整实现包面向刚接触操作系统开发的初学者聚焦Multiboot协议启动机制的实践入门。内容涵盖Multiboot Header编写、VGA显存直写“helloworld”、串口输出“HELLOWORLD”三大核心功能并配套Ubuntu下QEMU环境搭建、共享文件夹配置、Makefile与链接脚本.ld使用等关键操作帮助学习者打通从汇编编码、链接到模拟运行的全链路。压缩包共10个文件含汇编源码.S、可重定位目标文件.o、二进制镜像.bin、链接描述文件.ld、Makefile构建脚本及两份PDF实验讲解文档类型分布合理便于理解编译-链接-加载流程整体大小778KB轻量易下载。已有293人学习下载提供从协议解读、代码实现到调试验证的闭环参考特别适合OS新手建立底层启动认知、积累实操经验。1. 这不是“Hello World”而是一次真实的内核启动用 QEMU 模拟 x86 实模式亲手把一段汇编C代码喂给 Multiboot 兼容引导器看到OK打印在黑底白字的实模式屏幕上——适合刚啃完《OrangeS》前两章、手头只有 Ubuntu 22.04、连nasm -f elf32都报错三次的 OS 新手你不需要懂 GRUB 内部调度也不用编译整个 Linux 内核这个项目只做一件事在 Ubuntu 上用 QEMU 搭起一个最小可启动环境让一段自己写的 boot.S汇编 kernel.cC组合体通过 Multiboot 规范被加载进内存、完成段寄存器初始化、跳转到 C 入口、最后在实模式 VGA 文本缓冲区0xB8000上打印两个字符。它不带中断、不接管键盘、不初始化堆栈帧——但只要你看到OK稳稳出现在 QEMU 窗口左上角你就完成了操作系统开发里最硬核的第一关控制权从固件真正移交到你写的代码。项目结构极简仅 5 个文件所有工具链全靠apt install解决没有 Makefile 嵌套陷阱没有交叉编译器版本对齐玄学也没有任何外部依赖。如果你正卡在“为什么jmp $不停重启”或“为什么mov word [0xB8000], 0x074F没反应”那这份 lab1 就是为你写的血泪调试笔记。2. 环境准备与工具链搭建Ubuntu 22.04 下零配置启动 QEMUMultiboot 工作流2.1 必装工具清单nasm、gcc、qemu-system-x86_64、binutils-gold关键这个项目表面看只用汇编和 C但背后有三处隐性依赖一是 NASM 必须支持-f elf32输出Ubuntu 默认源里的nasm满足二是 GCC 编译内核 C 代码时必须生成 32 位 flat binary不能用默认的 64 位x86_64-linux-gnu-gcc三是链接阶段必须用ld的--oformat binary模式且必须指定--script脚本控制入口和段布局——而默认ldBFD 版本在处理 Multiboot header 时容易把.multiboot_header段丢掉导致 QEMU 报multiboot: no multiboot header found。解决方案是强制使用binutils-gold更快更严格并配专用链接脚本。sudo apt update sudo apt install -y nasm gcc-11-multilib qemu-system-x86 qemu-utils # 安装 gold linker关键 sudo apt install -y binutils-gold # 验证ld --version 应含 gold 字样 ld --version | grep gold提示不要用gcc-multilib代替gcc-11-multilib—— Ubuntu 22.04 中前者默认指向 gcc-11但显式指定版本可避免未来系统升级导致的 ABI 不一致。qemu-utils提供qemu-img虽本项目不用但后续扩展磁盘镜像时会省去重装步骤。2.2 目录结构与文件职责5 个文件各司其职无冗余项目根目录下只有以下 5 个文件全部手写无自动生成. ├── boot.S # 实模式入口设置段寄存器、加载 Multiboot header、跳转到 kernel_main ├── kernel.c # C 语言内核主体初始化 VGA 缓冲区、打印 OK ├── linker.ld # GNU ld 链接脚本定义入口地址0x100000、.text/.data/.bss 段位置、保留 .multiboot_header 段 ├── Makefile # 四行命令汇编 boot.S → 编译 kernel.c → 链接 → 启动 QEMU └── run.sh # 一行快捷脚本make qemu-system-x86_64 -kernel kernel.bin -monitor stdio注意kernel.bin是最终输出不是源码-kernel参数是 QEMU 对 Multiboot 镜像的原生支持方式不经过 GRUB 安装或 ISO 封装直接加载二进制并校验 Multiboot header。2.3 Multiboot Header 的硬编码细节为什么必须放在 boot.S 开头且对齐 8 字节Multiboot 规范v1要求 header 必须位于镜像前 8KB 内且起始地址必须是 8 字节对齐。header 本身是 12 字节固定结构但为兼容旧版 GRUB通常扩展为 16 字节补 4 字节 magic。本项目boot.S开头如下; boot.S 第 1–16 行Multiboot header必须紧贴文件开头 .align 4 multiboot_header: .long 0x1BADB002 ; magic: valid multiboot .long 0x00000000 ; flags: no flags set .long -(0x1BADB002 0x00000000) ; checksum: must be 0 mod 2^32 .fill 4, 1, 0 ; padding to 16 bytes total关键点.align 4→ 强制 4 字节对齐即 16 进制地址末位为 0/4/8/C满足 8 字节对齐要求因为 4 字节对齐是 8 字节对齐的子集checksum计算必须为负数(magic flags)的 32 位补码使三者相加为 0header 必须是文件第一个符号NASM 默认按源码顺序排布段所以multiboot_header:必须写在boot.S最顶部不能包在.section .text里否则可能被重排。若 header 位置错误QEMU 启动时会静默失败黑屏无提示或报multiboot: no multiboot header found—— 这是新手第一大翻车点。2.4 链接脚本 linker.ld控制 0x100000 加载地址与段合并逻辑x86 实模式下Multiboot 引导器默认将内核加载到物理地址0x1000001MB这是传统保护模式切换的安全起点避开低地址中断向量表。linker.ld必须精确声明此地址并确保.multiboot_header段不被优化掉/* linker.ld */ ENTRY(multiboot_header) /* 入口符号必须是 header 地址不是 kernel_main */ SECTIONS { . 0x100000; /* 加载地址所有段从此开始映射 */ .text ALIGN(4K) : { *(.multiboot_header) /* 关键显式保留 header 段放最前 */ *(.text) } .data ALIGN(4K) : { *(.data) *(.rodata) } .bss ALIGN(4K) : { *(.bss) . . 0x1000; /* 预留 4KB bss 空间避免未初始化变量越界 */ } /DISCARD/ : { *(.comment) *(.note.*) } /* 丢弃调试信息减小 bin 体积 */ }注意ENTRY(multiboot_header)是硬性要求QEMU 的 Multiboot 加载器会从该符号地址开始执行而不是从_start或kernel_main*(.multiboot_header)必须放在.text段最前面否则链接器可能将其优化到末尾导致 header 不在前 8KB. . 0x1000是安全冗余.bss在实模式下不自动清零手动预留空间可避免kernel.c中全局变量覆盖 VGA 缓冲区。3. 核心模块拆解boot.S 与 kernel.c 的逐行执行链路3.1 boot.S从 reset 向量到 C 函数调用的完整控制流x86 上电后 CPU 处于实模式CS:IP 0xF000:0xFFF0ROM BIOS 地址BIOS 自检后跳转到 0x7C00MBR而本项目由 QEMU 的-kernel参数绕过 MBR直接将kernel.bin加载到0x100000并跳转到multiboot_header。此时 CS0x1000IP0x0000因 0x100000 / 16 0x10000段基址取高 16 位。boot.S接下来要做三件事重置段寄存器、建立栈、跳转 C 入口。; boot.S 续写header 之后 .code32 cli ; 关中断实模式下禁用避免 IRQ 打断初始化 xor %ax, %ax ; 清零 ax mov %ax, %ds ; 数据段 → 0 mov %ax, %es ; 附加段 → 0 mov %ax, %fs ; fs/gs → 0实模式下无用但清零防脏数据 mov %ax, %gs mov %ax, %ss ; 栈段 → 0 ; 设置栈顶实模式栈向下增长选 0x90000640KB 64KB避开 BIOS 数据区 mov $0x90000, %sp ; 跳转到 C 函数 kernel_main注意此处无 call因无栈帧需求 jmp kernel_main逻辑说明.code32告诉 NASM 生成 32 位指令尽管仍在实模式但允许使用 32 位寄存器如%espcli是必须的实模式下 0x20 端口PIC默认开启若此时触发 IRQCPU 会尝试读取中断向量表0x0000:0x0000而该地址未初始化导致不可预测行为栈地址选0x90000是经验值低于0x100000内核加载地址高于0x7E00传统 MBR 结束位置避开 BIOS 保留区0xA0000–0xFFFFFjmp kernel_main是直接跳转不压入返回地址——因为这是内核入口永不返回。3.2 kernel.c在实模式 VGA 文本缓冲区写入字符的底层实现kernel.c是纯 C但必须用-m32 -ffreestanding -nostdlib编译无标准库、无运行时初始化。核心逻辑只有两行计算 VGA 缓冲区地址、写入带属性的字符。// kernel.c void kernel_main(void) { // VGA 文本缓冲区起始物理地址0xB8000 // 每个字符占 2 字节低字节ASCII高字节属性0x07灰字黑底 volatile unsigned short* vga_buffer (volatile unsigned short*)0xB8000; // 写入 O0x4F 属性0x07→ 0x074F位置 0左上角第1字符 vga_buffer[0] 0x074F; // 写入 K0x4B 属性0x07→ 0x074B位置 1左上角第2字符 vga_buffer[1] 0x074B; // 死循环防止 CPU 执行到未初始化内存 for(;;); }参数说明volatile关键字禁止编译器优化掉对vga_buffer的写入否则可能被优化成寄存器操作不写内存unsigned short*VGA 文本模式每个字符占 2 字节必须用short16 位指针不能用char*否则只写 1 字节0x074F拆解低字节0x4F ASCII O高字节0x07 属性字节bit7blink, bit6–4bg, bit3–0fg0x07即 foreground7白色、background0黑色、blink0for(;;);是实模式必需没有操作系统接管CPU 执行完就继续取下一条指令若后面是垃圾数据会崩溃。3.3 Makefile四行命令背后的编译器行为解析Makefile 看似简单但每行都针对实模式特殊约束# Makefile CC gcc-11 -m32 -ffreestanding -nostdlib -nostartfiles -nodefaultlibs AS nasm LD ld.gold # 强制使用 gold linker kernel.bin: boot.o kernel.o $(LD) -T linker.ld -o $ $^ boot.o: boot.S $(AS) -f elf32 -o $ $ kernel.o: kernel.c $(CC) -c -o $ $ run: kernel.bin qemu-system-x86_64 -kernel kernel.bin -monitor stdio .PHONY: clean run clean: rm -f *.o kernel.bin关键参数说明-m32生成 32 位目标码即使 host 是 64 位 Ubuntu-ffreestanding告知编译器不依赖标准库无printf、malloc-nostdlib -nostartfiles -nodefaultlibs彻底禁用 crt0.o、libc.a 等启动文件避免插入call __libc_start_main等无关代码-f elf32NASM 输出 ELF32 格式供ld.gold链接不能用-f bin否则无法处理 Multiboot header 符号ld.gold -T linker.ld链接时严格按脚本布局-T指定脚本-o kernel.bin输出纯二进制非 ELF。若漏掉任一 flag常见现象是kernel.bin体积异常大含 libc 符号、QEMU 启动后立即 triple fault、或vga_buffer写入无效。4. 常见问题排查5 条真实踩坑记录每条对应一次凌晨三点的重启4.1 现象QEMU 窗口全黑无任何输出-monitor stdio里也无日志原因Multiboot header 未被识别QEMU 未加载内核直接进入空闲状态。常见于 header 位置错误不在文件开头、checksum 计算错误、或linker.ld未显式包含*(.multiboot_header)。解决用hexdump -C kernel.bin | head -n 20查看前 20 行确认 offset 0x00 处为02 b0 ad 1b 00 00 00 00magicflags 小端序且 offset 0x0c 处为00 00 00 00checksum。若不符检查boot.S中.fill行数和checksum公式。4.2 现象QEMU 报错multiboot: no multiboot header found原因ld使用了 BFD 版本而非 gold或linker.ld中ENTRY指向错误符号。BFD linker 在处理*(.multiboot_header)时可能将其合并到.text段末尾导致 header 不在前 8KB。解决执行ld --version确认含gold在linker.ld中添加ASSERT(SIZEOF(.multiboot_header) 16, multiboot header size mismatch)并在 Makefile 中加$(LD) -T linker.ld --verbose kernel.o 21 | grep multiboot查看段布局。4.3 现象屏幕显示乱码如K或O而非OK原因kernel.c中vga_buffer指针类型错误。若声明为char*则vga_buffer[0] 0x074F只写入低字节0x4F高字节0x07被丢弃导致属性丢失字符显示为默认颜色常为黑底黑字不可见。解决严格使用unsigned short*并在编译时加-WallGCC 会对char*指针赋值short值发出 warning。4.4 现象make报错undefined reference to kernel_main原因boot.S中jmp kernel_main的符号名与kernel.c中函数名大小写不一致或kernel.c未编译kernel.o不存在。NASM 默认导出符号为小写而 C 编译器对kernel_main无修饰但若kernel.c中写成void Kernel_Main()则符号为Kernel_Main大小写不匹配。解决统一用小写kernel_main用nm kernel.o | grep main确认符号存在检查kernel.c文件名是否拼错如kernle.c。4.5 现象QEMU 启动后立即蓝屏triple fault窗口关闭原因实模式下执行了非法指令最常见是kernel.c中用了 64 位寄存器如%rax或未声明volatile导致vga_buffer写入被优化掉CPU 取指时读到全零内存解码为非法指令。解决在kernel.c中所有硬件访问指针前加volatile用objdump -d kernel.bin反汇编确认kernel_main函数内无movq、pushq等 q 后缀指令应为movl、pushl检查gcc-11 -m32是否生效gcc-11 -m32 -dM -E - /dev/null | grep __i686应输出#define __i686 1。5. 二次开发实战从打印 OK 到实现简易字符串输出与内存探测5.1 扩展 VGA 输出封装vga_write_string()支持任意字符串kernel.c当前只能硬编码打印两个字符。要支持字符串需实现循环写入 位置偏移。实模式下无strlen需以\0结尾// kernel.c 新增函数 void vga_write_char(char c, int pos) { volatile unsigned short* vga (volatile unsigned short*)0xB8000; vga[pos] (0x07 8) | (unsigned char)c; // 属性8 | ASCII } void vga_write_string(const char* str) { int i 0; while (str[i] ! \0) { vga_write_char(str[i], i); i; } } void kernel_main(void) { vga_write_string(USTC OS LAB1); for(;;); }注意const char* str在实模式下地址有效因kernel.bin加载到0x100000字符串字面量存于.rodata段也在该镜像内i作为局部变量存于栈0x90000安全。5.2 探测可用内存解析 Multiboot Info Structure 获取内存布局Multiboot 规范要求引导器在启动时将内存信息存入multiboot_info_t结构物理地址由eax传入kernel_main。扩展kernel_main参数即可获取// kernel.c 修改 struct multiboot_info { uint32_t flags; uint32_t mem_lower; // 低位内存 KB通常 640KB uint32_t mem_upper; // 高位内存 KB通常 ~3840KB // ... 其他字段略 }; void kernel_main(unsigned int mb_info_addr) { struct multiboot_info* mb_info (struct multiboot_info*)mb_info_addr; if (mb_info-flags 0x00000001) { // 检查 flags bit 0memory info valid char buf[32]; // 简易 itoa将 mem_upper 转为字符串 int val mb_info-mem_upper; int i 0; do { buf[i] 0 (val % 10); val / 10; } while (val); buf[i] \0; // 反转字符串 for (int j 0; j i/2; j) { char t buf[j]; buf[j] buf[i-1-j]; buf[i-1-j] t; } vga_write_string(MEM UPPER: ); vga_write_string(buf); vga_write_string( KB); } for(;;); }关键点kernel_main签名改为void kernel_main(unsigned int mb_info_addr)Multiboot 引导器自动将 info structure 地址放入eax并跳转mb_info_addr是物理地址无需转换实模式下段地址物理地址itoa手写避免依赖sprintf无 libc。5.3 构建可复用的内核骨架分离 boot 与 kernel 的编译单元当前boot.S和kernel.c紧耦合。生产级 OS 会拆分为bootloader独立项目和kernel本项目。本项目可模拟此结构将boot.S编译为boot.okernel.c编译为kernel.o再用ld链接。这正是当前 Makefile 所做但可进一步抽象# Makefile 增强版支持多 kernel 版本 KERNEL_OBJS kernel.o utils.o # 添加工具函数 boot.o: boot.S $(AS) -f elf32 -o $ $ kernel.o: kernel.c $(CC) -c -o $ $ utils.o: utils.c $(CC) -c -o $ $ kernel.bin: $(KERNEL_OBJS) boot.o $(LD) -T linker.ld -o $ boot.o $(KERNEL_OBJS)utils.c可存放itoa、memset等基础函数避免重复造轮子。这种分层让boot.S专注引导kernel.c专注业务符合真实 OS 工程实践。从那以后我每次新建 OS 实验都会先跑通这个OK版本再逐行替换功能——不是为了炫技而是因为0xB8000上那个O字符是我在裸机世界里刻下的第一个可信锚点。它不依赖任何库、不经过任何中间层纯粹由我的汇编指令驱动 CPU 写入显存。当它稳定亮起我就知道这一行行代码真的在物理世界里发生了作用。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑