ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

操作系统Lab1实战:用QEMU和GRUB引导Multiboot最小内核

操作系统Lab1实战:用QEMU和GRUB引导Multiboot最小内核 简介面向操作系统初学者的中科大操作系统课程实验一资料聚焦QEMU虚拟机环境下的Multiboot启动流程适合刚接触操作系统实验、想理解引导协议与最小内核运行的读者。压缩包共10个文件包含实验报告PDF、Multiboot头汇编源码、链接脚本、编译生成的二进制与目标文件以及Makefile构建脚本整体仅778KB结构精简便于逐项对照学习。实验实现了Multiboot协议要求的头结构并完成两个明确功能在VGA显存中直接写入“helloworld”以及通过串口输出“HELLOWORLD”。配套讲解PDF还系统梳理了Ubuntu与主机共享文件夹的建立、QEMU对Multiboot的支持方式、必要的汇编语法、Makefile与链接描述文件的编写思路能帮助读者理解从源码到可引导镜像的完整构建链条。读者可从这份资料中同时获得可直接运行的二进制镜像、可阅读的实验报告和可复用的构建配置快速掌握Multiboot头构造及裸机字符输出方法。目前已有291人学习是一份适合自学验证的QEMU与Multiboot最小示例。 这周终于把 OS 课程 Lab1 磕下来了——用 QEMU 模拟器加载一个符合 Multiboot 规范的最小内核从连 GRUB 报错都看不懂到屏幕上打出第一行字符整个过程像拆盲盒一样刺激。作为刚接触操作系统的新手这篇记录写得有点乱主要是把自己折腾的过程和踩过的坑整理一下也希望能给后面做 Lab1 的同学一点参考。水平有限哪里理解得不对欢迎指正。1. Lab1 到底在解决什么从开机到内核第一行代码先说结论Lab1 要完成的事情就是让 QEMU 模拟出来的一台“裸机”电脑在开机之后一步步把你的内核加载进内存并且执行到你写的入口函数里。注意这里说的是“一步步”而不是“一键跑通”。整条链路看起来简单但每一个环节背后的原理都值得掰开看清楚。一台物理机从按下电源到进入操作系统大致经历这么几个阶段固件自检BIOS 或 UEFI、加载引导程序Bootloader、引导程序读取内核文件并跳转执行、内核初始化硬件并接管系统。Lab1 用 QEMU 替代了真实硬件用 GRUB2 替代了引导程序我们的任务就是写好那个能被 GRUB 识别和加载的内核映像。这里有个关键点GRUB 凭什么知道你的内核文件可以引导靠的就是 Multiboot 规范。这是一套约定好的“握手协议”——内核文件里必须包含一个 Multiboot Header里面写明了一些魔法数Magic Number和标志位GRUB 在加载时会校验这些值校验通过才认为这是一个合法的 Multiboot 内核然后把控制权交过去。换句话说Multiboot Header 就是内核在引导程序面前出示的“身份证”。所以在 Lab1 里其实不是“写一个能跑的内核”而是“写一个能被 GRUB 正确识别并引导的内核”。这个侧重点很重要因为很多人一开始会把精力放在 C 代码逻辑上结果连引导都没过卡在 GRUB 的 Error 界面半天。我自己就吃过这个亏。从学习路径上讲Lab1 放在整个实验序列的最前面是有道理的它逼着你先把“代码是怎么从磁盘到内存、从实模式到保护模式、从固件到引导程序”这条链路理顺了后面做内存管理、中断、进程切换时才不会空中楼阁。反正我做完之后再回头看课本上“启动过程”那一章感受完全不同。2. 环境与工具链Ubuntu 下的 QEMU、GCC 和 GRUB 搭建细节工欲善其事必先利其器。Lab1 的环境要求不复杂但有几个细节容易翻车我分开说。2.1 需要安装的软件包我用的宿主机是 Ubuntu 22.0464 位。需要装的东西如下sudo apt update sudo apt install -y build-essential gcc-multilib qemu-system-x86 grub-pc-bin grub-common mtools xorriso逐个解释一下每个都不是白装的gcc-multilib这个最容易漏。我们编译的内核目标是 32 位i686但宿主机的 GCC 默认是 64 位的如果不装 multilib 支持编译时加-m32会报一堆找不到头文件的错误。后面我专门吃了这个亏。qemu-system-x86提供qemu-system-i386命令用来模拟 32 位 x86 机器。grub-pc-bin提供 GRUB2 在 PC 平台下的二进制模块grub-mkrescue打包 ISO 镜像时必须要用到它。mtools和xorrisogrub-mkrescue的依赖没有它们会在打包阶段报错前者管 fat 镜像的读写后者管 ISO 9660 的生成。2.2 为什么目标是 i386 而不是 x86_64这个问题我纠结了很久。既然 QEMU 都支持qemu-system-x86_64为什么不直接做 64 位内核我的结论是Lab1 的重点是跑通 Multiboot 引导流程而不是探索 64 位特性。32 位模式下的 Multiboot v1 协议是最经典、资料最全、坑最少的选择。OSDev Wiki 上几乎所有的入门教程都是基于 32 位保护模式展开的GRUB 对multiboot命令对应 v1 协议的支持也最成熟。等基础打牢了再去碰 x86_64 的 Long Mode 切换会轻松很多。当然如果你非要挑战 64 位那就要研究 Multiboot2 规范和multiboot2命令以及从保护模式切到长模式的整套代码工程量不是一个量级的。建议 Lab1 老老实实 32 位。2.3 验证环境是否就绪装完之后顺手确认一下qemu-system-i386 --version gcc -m32 --version grub-mkrescue --version这三个命令都能打印出版本信息说明环境基本没问题。如果gcc -m32提示找不到crt1.o之类的文件那基本可以确定是 multilib 没装好。3. Multiboot Header 与入口汇编让 GRUB 认可你的内核这一节是整个 Lab1 的核心。在我理解里内核能否被引导完全取决于你写的 Multiboot Header 对不对。3.1 工程目录结构我的做法是建一个干净的项目目录所有实验文件都放在里面lab1/ ├── boot.S # 入口汇编包含 multiboot header 和 _start ├── kernel.c # C 语言写的内核主体极简版 ├── linker.ld # 链接脚本控制内存布局 ├── Makefile # 编译、链接、打包、运行的一条龙入口 └── iso/ └── boot/ ├── grub/ │ └── grub.cfg └── kernel.bin # 由 make 生成3.2 boot.SHeader 与入口代码这是最重要的一个文件。我把注释写得比较细逐一说明.set ALIGN, 1 0 # 要求 GRUB 对内核按 4KB 对齐 .set MEMINFO, 1 1 # 要求 GRUB 把内存布局信息传给我们 .set FLAGS, ALIGN | MEMINFO # 上述标志位的组合 .set MAGIC, 0x1BADB002 # Multiboot v1 规定的魔法数 .set CHECKSUM, -(MAGIC FLAGS) # 校验和三者之和必须为 0 .section .multiboot .align 4 .long MAGIC .long FLAGS .long CHECKSUM .section .bss .align 16 stack_bottom: .skip 16384 # 16KB 内核栈 stack_top: .section .text .global _start .type _start, function _start: mov $stack_top, %esp # 设置栈指针C 代码依赖它 push %ebx # 把 multiboot_info 结构体地址压栈第2个参数 push %eax # 把 magic 值压栈第1个参数 call kernel_main cli 1: hlt jmp 1b .size _start, . - _start几个关键点我挨个说Header 的三个字段为什么必须长这样MAGIC是协议规定的身份标识FLAGS告诉 GRUB“我希望能 4KB 对齐、并且请把内存信息传给我”。CHECKSUM是校验用的它必须保证MAGIC FLAGS CHECKSUM 0这样 GRUB 在读到这三个 32 位整数时一算和是零就知道“这确实是一个有效的 Multiboot Header”。这是最基础的容错设计也让我第一次感受到“规范”这个东西的巨大威力。为什么 Header 要放在单独一个.multiboot段里因为协议要求 Header 必须出现在内核文件的前 8192 字节内准确说是前 8KB且 4 字节对齐。如果我们把它放在.text段的中间链接器一优化Header 可能就被挪到后面去了GRUB 找不到就直接拒载。单独建一个段并在链接脚本里把它排到最前面是最稳妥的做法。后面我会在链接脚本部分再展开。为什么要自己建栈C 语言的函数调用、局部变量、返回地址全都依赖栈这个数据结构。在call kernel_main之前CPU 的esp寄存器根本不知道栈在哪里。GRUB 加载内核时不会好心帮你把栈初始化好所以必须自己在汇编里从一个固定位置建栈。16KB 的 BSS 段栈空间够用了后续加功能再加也不迟。为什么要push %ebx、push %eax这是 Multiboot 协议规定的GRUB 跳转到内核入口时eax里放着魔法数0x2BADB002注意和 Header 里的0x1BADB002不一样一个是请求值一个是确认值ebx里放着multiboot_info_t结构体的物理地址。我想在 C 代码里拿到这些信息就得通过栈把它们传过去对应 C 里的kernel_main(unsigned int magic, unsigned int mbi_addr)。hlt指令也值得说一下。它是“暂停 CPU 直到下一个中断到来”配合外面套一层jmp 1b死循环能防止内核从kernel_main返回后继续执行到未知区域。这个循环看起来简单但少了它QEMU 可能会直接崩掉或者 CPU 占用飙到 100%。3.3 kernel.c先让屏幕吐出一行字入口汇编写好了C 侧先不管控制台驱动直接往 VGA 文本模式的内存地址写数据void kernel_main(unsigned int magic, unsigned int mbi_addr) { char *vga (char *)0xB8000; const char *msg Hello, USTC OS Lab1!; int i 0; (void)mbi_addr; if (magic ! 0x2BADB002) { msg Bad magic number!; } for (; msg[i] ! \0; i) { vga[i * 2] msg[i]; // 字符的 ASCII 码 vga[i * 2 1] 0x0A; // 属性字节浅绿色前景、黑色背景 } for (; i 80 * 25; i) { vga[i * 2] ; vga[i * 2 1] 0x0A; } while (1); }这里把显存当做普通内存一样写是因为 x86 架构把 VGA 文本缓冲区映射到了物理地址0xB8000。每一对字节对应屏幕上的一个字符单元第一个字节是字符的 ASCII 码第二个字节是颜色属性。80 列 × 25 行就是标准的 VGA 文本模式分辨率。这个理解起来非常简单但它是“内存映射 I/O”思想的一个雏形后面做串口、键盘、磁盘驱动时还会反复遇到。我特意把magic的校验写进去了用来确认 GRUB 确实是按 Multiboot 协议把控制权交过来的。如果魔法数不对那说明引导方式有问题后面的操作全不可信。4. 链接脚本把内核放到 1MB 处的理由与陷阱链接脚本是 Lab1 里最容易被人忽略、但又极其关键的一环。它的作用是把汇编和 C 编译出来的目标文件按照我们指定的布局拼成一个最终的 ELF 可执行文件。4.1 为什么内核要从 1MB 开始我的链接脚本长这样ENTRY(_start) SECTIONS { . 1M; .text BLOCK(4K) : ALIGN(4K) { *(.multiboot) *(.text) } .rodata BLOCK(4K) : ALIGN(4K) { *(.rodata) } .data BLOCK(4K) : ALIGN(4K) { *(.data) } .bss BLOCK(4K) : ALIGN(4K) { *(COMMON) *(.bss) } }开头的. 1M是核心它把链接器的起始地址设置为 1MB。为什么是 1MB这和 PC 的物理内存布局有关。从0x00000000到0x000FFFFF这 1MB 内存区域被实模式的 IDT、BIOS 数据区、VGA 显存、选项 ROM 等传统硬件占据。虽然 GRUB 在加载内核前已经切到了保护模式但这些硬件映射仍然存在你不能随便踩。从0x00100000也就是 1MB开始才是用户可以自由使用的“高内存”。几乎所有操作系统的内核都会被加载到这个位置这已经是约定俗成的做法。4.2 段顺序的微妙之处链接脚本里我把*(.multiboot)放在了.text段的最开头这是有讲究的。因为链接器会把所有输入文件里相同名字的段合并而boot.o里的.multiboot段就会成为最终.text段的开头部分从而保证 Multiboot Header 位于 ELF 文件的最前面满足“前 8KB 内必须包含 Header”的协议要求。链接完以后我建议用工具自查一下别急着上 QEMUobjdump -h kernel.bin # 查看段表确认 .text 起始地址是 0x100000 objdump -s -j .multiboot kernel.bin # 直接查看 header 的字节内容我当时的验证结果是.multiboot段的地址确实在0x100000三个 32 位整数依次是1badb002 00000003 e4524ffb十六进制加起来正好是 0。看到这个结果你就放心大胆地打包运行吧。4.3 编译和链接的实际命令Makefile 里的关键部分如下CFLAGS -m32 -ffreestanding -nostdlib -fno-pie -fno-stack-protector -Wall -Wextra LDFLAGS -m elf_i386 -T linker.ld -nostdlib kernel.bin: boot.o kernel.o ld $(LDFLAGS) -o $ $^ boot.o: boot.S gcc $(CFLAGS) -c -o $ $ kernel.o: kernel.c gcc $(CFLAGS) -c -o $ $逐个解释这些参数的用意这部分也是踩坑重灾区-m32生成 32 位代码配合 multilib 才能工作。-ffreestanding告诉 GCC 这是独立环境不依赖标准库printf、malloc 这些都没有。-nostdlib链接时不链接标准启动文件和库因为我们自己写入口。-fno-pie、-fno-stack-protector关闭 PIE 和栈保护否则链接器可能生成奇怪的重定位或者插入额外代码在裸机环境下会出问题。-m elf_i386让ld以 32 位 ELF 格式输出和-m32对应。如果不用 Makefile 直接手敲命令最后的链接命令长这样gcc -m32 -ffreestanding -nostdlib -fno-pie -c boot.S -o boot.o gcc -m32 -ffreestanding -nostdlib -fno-pie -c kernel.c -o kernel.o ld -m elf_i386 -T linker.ld -nostdlib boot.o kernel.o -o kernel.bin顺便说一句链接器输出的是 ELF 格式的kernel.binGRUB 加载时读的是 ELF 的程序头而不是简单地把整个文件原样搬到内存。所以链接脚本里声明的虚拟地址VMA必须和物理加载地址LMA一致否则 GRUB 把代码加载到一个地址、跳转又是另一个地址直接飞了。这也是为什么要统一从 1MB 开始的另一个原因。5. grub-mkrescue 打包 ISO 并 QEMU 实测内核编译出来了下一步就是做成 GRUB 能引导的介质。这里有两种方式做成 ISO 光盘镜像或者用 QEMU 的-kernel参数直接加载。我建议都试试感受一下区别。5.1 编写 grub.cfg在iso/boot/grub/grub.cfg里写入set timeout0 set default0 menuentry ustc-os-clx-lab1 { multiboot /boot/kernel.bin boot }multiboot命令告诉 GRUB“我们要按 Multiboot v1 协议加载后面的文件”。注意不要写成multiboot2那是另一套协议我们的 Header 不兼容。5.2 制作并运行 ISOgrub-mkrescue -o os.iso iso/ qemu-system-i386 -cdrom os.iso -m 64Mgrub-mkrescue会把整个iso/目录打包成一个可引导的 ISO 镜像。如果报错说找不到mformat或xorriso回第二小节补装依赖即可。QEMU 起来以后你应该能看到一个 GRUB 菜单一闪而过然后屏幕被清空出现一行浅绿色的Hello, USTC OS Lab1!。那一刻的感觉还挺奇妙的——虽然只有一行字但它是从你写的内核里输出来的整个引导链从固件到 GRUB 到你的代码全链路打通了。退出 QEMU 的方式也记录一下先按CtrlAltG释放鼠标然后CtrlAlt2切换到 QEMU monitor 终端输入quit回车。或者直接关掉 QEMU 窗口。5.3 QEMU 直接加载内核的便捷方式调试的时候每次都打包 ISO 有点慢。QEMU 其实支持直接从 Multiboot 内核文件启动qemu-system-i386 -kernel kernel.bin这个命令会尝试以 Multiboot 协议加载kernel.bin。验证启动链时用 ISO 方式更真实日常快速调试用-kernel方式更高效。两种方式下GRUB 传给内核的magic值都是一样的0x2BADB002但mbi_addr指向的结构体内容可能略有差别不影响 Lab1 的东西。6. 我踩过的坑GRUB Error、乱码、GDB 断不进去这部分是真正的经验值所在。我前前后后重来了好几轮把印象最深的几个问题列出来希望能帮大家少走弯路。6.1 gcc -m32 直接编译失败第一次敲 Makefilemake直接报了一堆fatal error: stdio.h: No such file or directory。这是因为我之前没装gcc-multilib。64 位系统的 GCC 只带了 64 位的头文件和库要用 32 位模式编译必须装 multilib 支持。装上之后这个问题就消失了。后来我发现很多初学者卡在 Lab1 环境搭建这一步多半就是漏了这一个包。6.2 GRUB 提示 “invalid magic number”这个错误最让人崩溃ISO 打包没问题GRUB 菜单也能出来但选中后立刻报错大意是invalid magic number或not multiboot。问题几乎都出在 Multiboot Header 上。我反复检查后发现了几个可能的坑Header 没有放在前 8KB 内。检查链接脚本里*(.multiboot)是不是.text段的第一项。三个字段的值不对。尤其是CHECKSUM一定要等于0 - (MAGIC FLAGS)多一位少一位都不行。内存布局不对。可以用objdump -h查看段地址确认.multiboot段确实在0x100000附近。6.3 启动后黑屏无输出这个问题的根源通常不在显示而在程序根本没执行到kernel_main。我用 GDB 调试时才看清qemu-system-i386 -kernel kernel.bin -s -S-s让 QEMU 监听 1234 端口作为 GDB 远程调试端口-S让 CPU 在启动时暂停等我手动连过去再继续。然后在另一个终端gdb kernel.bin target remote :1234 b *0x100000 c如果断点打在内核入口0x100000但 GDB 一直等不到命中说明 GRUB 根本没跳转到我的内核。如果命中了就可以逐步看esp有没有设置、kernel_main有没有被调用。GDB 调试裸机内核这个操作OSDev Wiki 上有专门一篇强烈建议 Lab1 阶段就学会。很多时候光看代码是看不出 bug 的能看到 CPU 实实在在停在哪一步一切真相大白。6.4 屏幕上输出乱码或白块如果你能看到字符但内容是乱的大概率是显存地址写错了。VGA 文本模式的起始地址是0xB8000不是0xB80000也不是0xA0000那是图形模式。另外注意每个字符占两个字节第一个是 ASCII第二个是属性属性不要写成 0否则黑底黑字等于没输出。6.5 hlt 缺失导致的 CPU 占用 100%调试早期我写过一个没有hlt的内核kernel_main里也不是死循环而是ret返回。结果 QEMU 窗口完全没有任何反应但宿主机的 CPU 风扇开始狂转。原因是程序跳转到了一串未知指令或者处于忙等状态。后来在入口汇编里固定用cli 1: hlt jmp 1bQEMU 的 CPU 占用才恢复正常。所以从这个不起眼的细节里我学到了 HLT 对内核的意义它让 CPU 进入低功耗暂停状态而不是空转烧电。7. 从 Lab1 继续延伸内存信息、GDT 与调试习惯Lab1 只是起步但它已经为后面的实验铺了不少路。整理几个我做完之后明显感觉得到“有后劲”的方向。7.1 用 multiboot_info_t 探测内存我在kernel_main里把mbi_addr的地址传进去了但因为 Lab1 只要求显示字符串所以还没真正解析它。实际上multiboot_info_t结构体里放着内存布局、帧缓冲信息、命令行参数等一大堆内容。做完 Lab1下一步就可以尝试解析mbi里的mmap_addr和mmap_length打印出系统中有哪些物理内存区域。这个能力在做物理内存管理Lab 通常涉及页表、伙伴系统时非常关键属于避不开的基础设施。7.2 从“能启动”到“能被调试”Lab1 给我最大的一个认知是在操作系统开发的早期阶段调试手段非常少。没有 printf没有 GDB 插桩没有断点至少在你写好中断描述符表之前。能依赖的无非是 QEMU monitor、VGA 文本输出、以及 GDB 的远程调试能力。所以早点把 GDB 调试 QEMU 的流程跑熟后面写 GDT、IDT、内存管理代码时会从容很多。不然代码一旦跑飞连出错的迹象都找不到。7.3 规范文档是最好的老师这次实验过程中我反复翻阅了两份资料OSDev Wiki 上的 Multiboot 与 Bare Bones 教程以及 GRUB 官方手册里关于 multiboot 命令和 magic 值的说明。当时觉得文档写得抽象咬着牙啃完才发现网上能找到的博客文章大部分都是在复述这些原始资料。遇到“为什么”层面的疑问第一反应应该是去翻规范原文而不是在中文社区里搜二手答案。这个习惯对我后续做实验帮助极大。Lab1 到这里基本走完了。回头看看最让我受益的不是那行绿色字符本身而是为了得到这行字符被迫把启动链路从头到尾捋了一遍的过程。刚接触操作系统第一篇笔记若有理解不对或者表述不准确的地方还请大家多指正一起把基础打扎实。本文还有配套的精品资源点击获取
返回列表