从零跑通操作系统学习项目:QEMU调试与交叉编译实战 📅 发布时间:2026/8/31 12:50:32 👁 浏览次数: 拿到holaboss-ai/holaOS这个仓库名时先不用急着 clone 代码。holaOS这类带 OS 后缀的项目名在个人学习仓库和开源实验项目里很常见有人把它当作操作系统内核的练手项目也有人只搭了一个目录骨架。名称本身不能说明技术栈也不能说明运行方式它只能提供一个线索这个项目很可能不是普通业务应用而是一个需要工具链、模拟器和底层协议才能跑起来的东西。本文就以这样一类操作系统学习项目为例说明从拿到仓库名到真正把内核跑起来需要经历哪些步骤包括项目性质判断、交叉编译环境准备、启动链路分析、QEMU 调试、常见故障排查以及如何把这类项目变成自己的练习路线。这里会用holaOS作为讨论对象但不会替它伪造版本号或具体实现。如果你拿到的项目结构不同请以仓库里的 README、Makefile 和源码目录为准。下面所有命令和代码都用于说明思路落地时要结合自己的项目名、路径和依赖版本调整。1. 拿到 holaOS 这个项目名先做的几件事1.1 从命名和仓库元信息判断项目类型holaOS由两部分组成hola更像一个自定义名称OS则表示操作系统。在 GitHub 上这类项目通常有三种可能从零编写的微型内核代码集中在src/kernel、src/boot等目录。基于已有教程或开源框架如 OSDev wiki 的 Bare Bones、xv6、MIT 6.S08 实验作业修改出的学习版本。只是一个课程作业或实验仓库未必能直接生成可启动镜像。因此第一步不是读代码而是看仓库根目录的文件。常见的高信号文件是README.md、Makefile、linker.ld、boot.S、kernel.c或src/main.c。如果看到grub.cfg、multiboot、qemu这类词基本可以确认这是一个操作系统引导项目。如果看到configure、CMakeLists.txt、Cargo.toml则要注意它可能使用了不同工具链。还需要确认一个关键问题这个项目的目标运行环境是真实硬件还是模拟器。很多学习项目只保证在 QEMU 或 Bochs 里能跑并不承诺真机启动。你可以通过 README 里的启动命令、QEMU 配置、GRUB 配置文件是否存在来判断。1.2 从目录结构确认技术栈一个典型的操作系统学习项目目录结构大致如下holaOS/ ├── boot/ │ ├── boot.S │ └── multiboot.h ├── kernel/ │ ├── main.c │ ├── vga.c │ ├── gdt.c │ ├── idt.c │ └── memory/ ├── include/ │ └── kernel/ ├── linker.ld ├── grub.cfg ├── Makefile ├── iso/ └── README.md这份结构不是标准只是一个参考。实际项目可能用src而不是boot也可能直接用 Rust 的cargo组织。观察目录结构的目的是回答三个问题语言是什么C、C、Rust还是汇编为主是否依赖第三方库是否有vendor、deps、libc目录有没有现成构建脚本Makefile、build.sh、cargo config如果仓库里只有源码没有构建脚本那说明需要自己补全环境。这种情况在课程作业里很常见但不代表项目不能跑而是需要额外判断目标格式和链接地址。1.3 建立问题清单而不是急着读代码阅读操作系统源码最容易出现的错误是打开kernel.c从头看到尾看到一半就迷路。更有效的做法是先建立一个“待确认问题清单”这个内核是 32 位还是 64 位看boot.S里的bits 32或bits 64再看链接脚本。通过什么引导方式启动GRUB 还是直接由 QEMU-kernel加载内核的入口符号是什么链接脚本里ENTRY指向哪里内存布局是什么代码段从哪个虚拟地址开始是否已实现内存管理、中断管理、进程调度是否支持用户态程序有没有文件系统这些问题决定后续所有操作。一个 32 位内核和一个 64 位内核QEMU 参数完全不同一个通过 GRUB 引导的内核和一个直接使用-kernel加载的内核链接脚本要求也不同。带着这些问题去读文件效率会高很多。2. 构建环境编译 OS 前必须准备好的工具链2.1 工具链清单与作用操作系统内核不属于普通用户态程序通常不能依赖宿主系统的 libc也不能直接使用默认的启动文件。编译一个 x86 内核至少需要以下工具工具作用常见包名gcc 或 clang编译 C 代码gcc, clangbinutils汇编、链接、反汇编binutilsnasm 或 gas编写启动汇编代码nasmld链接内核binutilsmake执行构建流程makegrub-mkrescue制作可启动 ISOgrub-common, grub-pc-binqemu-system-x86_64运行和调试内核qemu-system-x86gdb内核调试gdb注意这里的gcc是否必须是交叉编译器取决于目标系统是否等于宿主系统。学习项目中常见的做法是使用带i686-elf或x86_64-elf前缀的交叉编译器避免生成的代码依赖宿主系统头文件和启动文件。如果只是在本机 Linux 上编译用普通gcc并加上-ffreestanding -nostdlib -nostdinc也能完成但这属于比较脆弱的写法。2.2 确定目标架构并编写最小 Makefile在编译一切之前先确认项目目标架构。holaOS如果使用 x86 架构常见目标是i686-elf或x86_64-elf。若使用 ARM 或 RISC-V工具链名称和 QEMU 参数都会变化。下面以一个最小 32 位内核为例。Makefile 的关键是刻意禁止链接标准库CROSS i686-elf- CC $(CROSS)gcc AS nasm LD $(CROSS)ld CFLAGS -ffreestanding -nostdlib -Wall -Wextra -O2 -g LDFLAGS -T linker.ld -melf_i386 OBJS boot.o kernel.o vga.o all: holaOS.bin holaOS.bin: $(OBJS) $(LD) $(LDFLAGS) -o $ $(OBJS) %.o: %.c $(CC) $(CFLAGS) -c -o $ $ %.o: %.asm $(AS) -f elf32 -o $ $ clean: rm -f *.o holaOS.bin run: holaOS.bin qemu-system-i386 -kernel holaOS.bin这段 Makefile 里的三个参数值得解释-ffreestanding告诉编译器不要假设存在标准库和标准启动文件。-nostdlib链接时不加入 libc 启动代码。-melf_i386在 x86_64 宿主上链接 32 位 ELF 时使用。如果项目本身就是 64 位-melf_i386要改为对应的-melf_x86_64汇编格式也要从elf32改成elf64。2.3 制作可启动 ISO 的常用命令如果项目通过 GRUB 启动那么除了编译出内核二进制还需要把内核放到 ISO 镜像中。准备一个grub.cfgmenuentry holaOS { multiboot /boot/holaOS.bin boot }目录结构通常是iso/ ├── boot/ │ ├── holaOS.bin │ └── grub/ │ └── grub.cfg然后执行mkdir -p iso/boot/grub cp holaOS.bin iso/boot/holaOS.bin cp grub.cfg iso/boot/grub/grub.cfg grub-mkrescue -o holaOS.iso iso qemu-system-i386 -cdrom holaOS.isogrub-mkrescue会把 GRUB 引导器和内核打包在一起。QEMU 从 CD-ROM 启动时GRUB 会解析grub.cfg读取multiboot命令指定的内核文件把控制权交给内核。2.4 构建阶段常见报错构建阶段最常见的三种报错如下报错现象可能原因处理方式undefined reference to _start链接器找不到入口符号检查链接脚本的ENTRY或确保启动汇编里定义了_startundefined reference to putchar调用了 libc 函数检查是否遗漏-ffreestanding -nostdlib删除对 libc 的依赖multiboot header not found启动文件里缺少 multiboot 头确认boot.S里放了 magic number 和校验字段链接时没有被丢弃这些报错都对应具体原因不能靠随机改代码解决。最有效的定位方式是先看链接脚本和被链接的符号表再看启动文件内容。3. 理解 holaOS 类项目的启动链路Bootloader 到内核入口3.1 从实模式到保护模式需要理解的状态切换处理器上电后先运行在实模式这种模式有 20 位地址线最大访问 1 MB 内存也没有内存保护。BootloaderGRUB负责把处理器切换到保护模式再跳转到内核。但你仍然需要在启动代码里做一些基础工作至少包括关闭中断防止 CPU 在模式切换过程中响应异常状态。设置 GDT让 CPU 知道代码段和数据段的特权级别。使能 A20 地址线确保能访问 1 MB 以上的内存。设置栈指针否则 C 代码一调用函数就会崩溃。保存 bootloader 传入的multiboot_info结构指针。一个简化的boot.S示例依然只是说明思路section .multiboot align 4 dd 0x1BADB002 dd 0x00000003 dd -(0x1BADB002 0x00000003) section .text global _start _start: mov esp, stack_top push ebx call kernel_main cli hlt section .bss align 16 stack_bottom: resb 16384 stack_top:这个代码里有三个关键点0x1BADB002是 multiboot header 的 magic numberGRUB 会用它判断内核是否符合规范。dd 0x00000003表示需要 bootloader 提供内存信息和对齐信息。-(0x1BADB002 0x00000003)是校验字段三项相加必须为 0否则 GRUB 会拒绝加载。如果你看到的项目不是 GRUB 引导而是 QEMU-kernel直接加载 ELF那么_start同样重要因为 QEMU 会根据 ELF 入口地址跳转。3.2 链接脚本决定内核加载地址链接脚本是操作系统项目中最容易被忽略、却最影响运行结果的文件。它决定代码段放在哪个地址、数据段放在哪个地址、哪些段在最终镜像中保留。一个典型的linker.ldENTRY(_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开始是因为 GRUB 通常把内核加载在物理地址 1 MB 处附近。如果你的项目使用虚拟内存链接脚本的地址还会和页表映射保持一致。KEEP(*(.multiboot))在复杂场景下是必须的因为默认的垃圾回收机制可能把只含数据的.multiboot段丢弃。修改链接脚本后务必反汇编确认结果objdump -h holaOS.bin objdump -d holaOS.bin看段地址是否符合预期。如果入口地址跟默认的 ELF 入口不一致启动时几乎必然崩溃。3.3 第一个 C 入口如何输出信息从汇编进入 C 的入口函数通常叫kernel_main。在还没有串口驱动、显存驱动的情况下最简单的输出方式是直接往 VGA 文本缓冲区写字符。VGA 文本模式在 0xB8000 地址处映射了显存每两个字节表示一个字符和属性void kernel_main(unsigned int multiboot_magic, unsigned int multiboot_addr) { volatile char *video (volatile char *) 0xB8000; const char *msg holaOS; int i; for (i 0; msg[i] ! \0; i) { video[i * 2] msg[i]; video[i * 2 1] 0x07; } for (;;) asm volatile(hlt); }这里0x07表示白字黑底。如果你开启调试模式也可以直接通过串口输出但 VGA 是最快能验证“内核已经进入 C 代码并开始执行”的方式。如果你在自己的项目里看到write_serial或printk这类输出函数可以直接从主入口往上追确认它初始化了什么、输出到哪个设备。这比盲目猜代码更容易建立整体认识。4. 内核核心模块的阅读顺序内存、中断、任务4.1 内存管理页表与堆进入 C 之后项目很快就会遇到内存管理。阅读内存管理代码时不要一开始就盯着高效算法而是先确认几个基础概念物理内存总量有多少、如何标记已用页、页表分几级、堆从哪里开始。一个简化的页表初始化流程可能像这样#define PAGE_PRESENT (1 0) #define PAGE_WRITE (1 1) void init_paging(unsigned long memory_size) { unsigned long *page_directory (unsigned long *) 0x10000; unsigned long *page_table (unsigned long *) 0x11000; unsigned long addr 0; for (int i 0; i 1024; i) { page_table[i] addr | PAGE_PRESENT | PAGE_WRITE; addr 0x1000; } for (int i 0; i 1024; i) { page_directory[i] (unsigned long) page_table | PAGE_PRESENT | PAGE_WRITE; } load_page_directory((unsigned long) page_directory); enable_paging(); }这里的数字 0x1000、1 0、1 1 必须理解清楚0x1000 是 4 KB 页大小PAGE_PRESENT表示页是否在内存中PAGE_WRITE表示是否可写。初始映射通常直接把物理地址映射到相等或偏移的虚拟地址这样在启动早期不需要复杂地址转换。阅读内存管理代码时建议先画一张地址映射草图包括哪个虚拟地址映射到哪个物理地址。页表放在什么位置。内核代码段占用了哪段地址。堆区从哪里开始由哪个函数管理。如果没有这张图后面看进程调度时很难理解为什么每个任务栈位置不同。4.2 中断与异常处理中断处理的阅读重点是 IDTInterrupt Descriptor Table中断描述符表。在 x86 上CPU 收到中断或异常时会根据 IDTR 指向的 IDT 查找对应表项跳转到处理函数。初始化 IDT 的常见步骤struct idt_entry { unsigned short base_low; unsigned short selector; unsigned char zero; unsigned char flags; unsigned short base_high; } __attribute__((packed)); struct idt_entry idt[256]; void set_idt_gate(int n, unsigned long handler) { idt[n].base_low handler 0xFFFF; idt[n].selector 0x08; idt[n].zero 0; idt[n].flags 0x8E; idt[n].base_high (handler 16) 0xFFFF; } void init_idt(void) { set_interrupt_descriptor_table((unsigned long) idt); }这里有几个容易踩坑的点flags的0x8E表示 ring 0 中断门且表项有效。如果写成0x80CPU 认为表项无效。selector必须指向 GDT 里已定义的内核代码段。如果 GDT 代码段选择子不是 0x08这里要同步修改。handler的地址必须与链接脚本中代码段所在位置一致特别是在开启分页后。阅读时先找init_idt和isr这两个关键函数确认哪些异常有独立 handler哪些共用一个默认 handler。最常见的观察点是最开始写的division_by_zero或page_fault处理函数它们会打印寄存器信息。4.3 最简单的任务切换思路很多学习项目在初期并没有完整的抢占式调度而是先实现一个协作式调度。协作式调度的核心是保存现场、切换栈、恢复现场struct context { unsigned int eip; unsigned int esp; unsigned int eflags; unsigned int eax, ebx, ecx, edx; unsigned int esi, edi, ebp; }; struct task { struct context ctx; unsigned char *stack; int state; };切换时把当前任务的寄存器保存到当前任务ctx再从目标任务ctx恢复寄存器然后通过iret或ret跳转到目标执行位置。理解这段代码时关键是画出每个任务栈里的布局。如果你在项目里看到大量位运算和汇编标签不用逐个指令查应该先抓住两件事当前任务保存了什么目标任务恢复了什么。恢复顺序错了程序一回到用户态就会崩溃。5. 用 QEMU 和 GDB 把 holaOS 类内核跑起来5.1 QEMU 启动参数最简单的运行方式是让 QEMU 执行已编译好的内核qemu-system-i386 -kernel holaOS.bin -m 256M -serial stdio这个命令做了三件事-kernel让 QEMU 直接加载 ELF 内核适合不需要 GRUB 的简易内核。-m 256M给虚拟机分配 256 MB 物理内存。-serial stdio把串口输出重定向到当前终端便于调试。如果项目是 GRUB ISO则改用qemu-system-i386 -cdrom holaOS.iso -m 256M -serial stdio初次运行前请把-serial stdio放在显眼位置。很多学习项目只在串口上打印日志不使用 VGA。如果不重定向你可能看到 QEMU 窗口一闪而过却看不到任何程序输出。5.2 用 GDB 远程调试内核QEMU 支持 GDB 远程调试。启动 QEMU 时加两个参数qemu-system-i386 -kernel holaOS.bin -m 256M -s -S-s在本地 1234 端口开放 GDB 调试服务。-S启动后暂停在第一条指令等待调试器连接。然后在另一个终端启动 GDBgdb build/kernel.bin进入 GDB 后执行target remote localhost:1234 file build/kernel.bin b _start continue layout asm info registerslayout asm可以直接查看当前汇编。info registers能看寄存器值。如果项目是多级启动比如_start后面紧接着 GDT 设置你可以单步执行stepi来观察 CPU 状态变化。调试步骤里最容易出的问题是符号对不上。原因是 GDB 加载的kernel.bin必须是带调试信息的版本CFLAGS里要有-g。另外-kernel加载的 ELF 和实际执行的内存地址必须与链接脚本一致否则断点会命不中。5.3 行为验证运行后怎么知道内核是否正常建议按以下顺序验证是否打印了预期字符串比如holaOS或日志消息。是否一直停留在空循环而不是重启。是否出现 Page Fault、General Protection Fault 等异常输出。如果你的项目带日志可以这样检查串口输出qemu-system-i386 -kernel holaOS.bin -m 256M -nographic-nographic会把 QEMU 的标准输入输出直接绑定到终端。如果内核通过串口打印日志这里就能看到完整日志。如果内核打印Panic: Triple Fault或 QEMU 反复重启请先运行带调试日志的版本qemu-system-i386 -kernel holaOS.bin -d int,cpu_reset -no-reboot-d int会打印每次中断和异常-no-reboot让 CPU 在三重故障时不自动重启这样日志能保留在屏幕上方便定位问题。6. 常见问题排查清单6.1 启动后没有任何输出现象QEMU 窗口黑屏或终端无输出程序看起来没执行。可能原因用了-kernel加载但项目只支持 GRUB ISO。串口没有重定向到标准输出。输出函数本身有错误比如写到了错误的 VGA 地址。内核在进入 C 入口之前就崩溃了。检查方式qemu-system-i386 -kernel holaOS.bin -d int,cpu_reset -no-reboot如果日志显示第一条指令后立刻异常说明启动汇编、GDT 或链接脚本有问题。如果日志没有任何内容则要确认引导方式是否正确。6.2 QEMU 反复重启现象虚拟机不断重启像死循环一样。这是操作系统开发里最常见的现象之一三重故障导致 CPU 复位。触发原因可能是 IDT 未初始化就触发了异常、GDT 配置错误、页表未开启却访问坏地址、或者栈指针设置错误。排查步骤加-d int,cpu_reset观察复位前最后一个中断是什么。看到triple fault后定位到第一次异常发生的eip。在 GDB 里对该地址下断点查看寄存器和内存。检查是否在未初始化 IDT 前就执行了int指令。预防做法初始化基本 IDT 后再开中断页表开启后立即测试一个已知地址不要一下子切换太多状态。6.3 编译通过运行却崩溃现象构建成功但在 QEMU 中无法启动或异常退出。这种情况往往是链接脚本问题而不是代码逻辑问题。检查内容检查项验证命令合格标准入口地址readelf -h holaOS.binEntry point address与linker.ld的ENTRY一致段地址readelf -S holaOS.bin.text地址在预期区间一般以 1M 附近开始multiboot 头readelf -l holaOS.bin能找到 multiboot magic 所在段确认这些之后再看代码里是否有未处理的中断号。比如在 IDT 只有 32 个中断管理项时执行int 0x80就会触发未定义异常。6.4 排错顺序遇到问题不要马上改代码按以下顺序排查输入条件是否正确QEMU 参数、内核文件路径、ISO 路径。构建产物是否正确反汇编查看_start和 multiboot 头。加载地址是否正确链接脚本与 QEMU 加载地址是否匹配。驱动是否初始化GDT、IDT、PIT 时钟是否在中断开启前就绪。日志是否打开使用-d int,cpu_reset -serial stdio收集可用信息。是不是工具链版本问题老版本 binutils 对链接脚本语法支持不同。这套顺序同样适用于其他操作系统学习项目。先确认机器可见的事实再调整代码才能减少无意义的尝试。7. 从 holaOS 类项目延伸出自己的练习路线7.1 最小复刻路线如果你拿到的 holaOS 项目并不完整或者你想从零写一个类似的实验内核可以按这个路线推进引导阶段输出Hello到 VGA。GDT 和 IDT让 CPU 能处理异常至少能打印异常号。定时器中断用 PIT 实现周期性中断验证中断链路。物理内存管理实现一个位图管理 4 KB 页。分页开启页表做一个简单的虚拟地址映射。进程调度先实现协作式两个任务切换。用户态通过iret切换到 ring 3执行简单用户程序。文件系统做一个极简内存文件系统支持ls和cat。每一步都要能验证。没有输出、没有中断、没有异常处理的步骤都不算完成。7.2 可复用的检查清单以下清单可以直接保存下来适用于大多数操作系统学习项目。环境检查清单是否安装gcc、nasm、ld、make。是否安装qemu-system-x86或对应架构版本。是否安装grub-mkrescue如果项目需要 GRUB ISO。内核工具链是宿主机 gcc 还是交叉编译器目标架构是什么。构建命令是否写入 README 或 Makefile。阅读代码前检查清单链接脚本里ENTRY指向哪里。boot.S是否声明 multiboot 头。栈是否在进入 C 代码前设置。GDT 和 IDT 的初始化在哪个文件。内核主入口接收哪些参数是否保存 bootloader 信息。调试前检查清单-g是否已在编译参数中。-s -S是否已传给 QEMU。GDB 中是否用file加载 kernel.bin。是否在断点处执行过continue而不是一直停留在启动第一条指令。7.3 最容易踩的三个坑第一个坑是忘记-ffreestanding -nostdlib。一旦开发者在kernel.c里调用printf、memcpy等函数链接阶段就会报 undefined reference。即使能链接通过也可能链接进了宿主系统的 libc运行时行为不可控。解决方式是自己实现少量字符串输出和内存操作函数或明确使用 kernel 自带的工具函数。第二个坑是链接脚本没有保留.multiboot段。编译器不会主动丢弃数据但链接器的--gc-sections或手动DISCARD可能把 multiboot 头丢掉。解决方式是给启动汇编指定独立段并在链接脚本里用KEEP显式保留。第三个坑是过早打开中断。很多项目会先写时钟中断再写 IDT 初始化。如果在 IDT 尚未设置时就执行stiCPU 会在第一个硬件中断到达时进入缺表异常反复异常之后触发 triple fault。解决方式是先初始化完整 IDT再在进入主循环或调度器前打开中断。7.4 下一步扩展方向把 holaOS 这类项目跑通后可以继续做下面几件事把输出从 VGA 扩展到串口并实现多级日志级别。用Rust而不是 C 重新实现某一部分对比语言对内存安全的影响。加入用户态系统调用做一次从 ring 0 到 ring 3 的完整切换。把 x86 代码迁移到 RISC-V熟悉另一套启动流程和中断模型。写自动化测试脚本把 QEMU 输出与预期结果做对比。使用向量中断、抢占式调度等更接近真实系统的机制。无论是继续完善 holaOS还是新开自己的实验仓库重要的是保持“每改动一个机制都能被观察到”的习惯。操作系统内核的难点不在于某个语法而在于它把 CPU、内存、编译器和硬件协议放在同一个时序里。先跑通最小链路再逐步往里面加模块这条路线比一开始就追求完整实现更稳妥。