写一个操作系统听起来像是操作系统课程里的大作业但你不用非得有物理机和真实主板的折腾机会。如果你手头正好是一台 Windows 机器又想体验从空目录到内核跑起来的过程WSL Ubuntu 其实是个非常顺手的实验箱。这篇东西记录的就是我在 WSL2 的 Ubuntu 环境里把一个教学级 32 位保护模式内核从零写出来、并且成功在 QEMU 里看到输出的全过程。适合有一点 C 语言和汇编基础、但没接触过内核启动细节的人参考。1. 在 WSL 上写操作系统的边界先想清楚哪些能做哪些不能1.1 WSL2 与真实 Linux 的差距在哪里WSL2 本质上是一个跑在 Hyper-V 虚拟机里的精简 Linux 发行版它用的是真实 Linux 内核所以大部分命令行工具、编译器、链接器的行为跟原生 Ubuntu 几乎没有区别。但有一点要注意WSL2 没有直接的硬件访问权限/dev下面不会有真实的磁盘控制器、网卡、显卡也没有/dev/kvm。这意味着你不能像在原生 Linux 主机上那样让 QEMU 通过 KVM 加速也不能探测真实 USB 设备。对我这次写内核的目标来说这完全不是问题因为内核开发本来就是在 QEMU 这种模拟器里验证的TCG 软件模拟慢一点但正确性完全够用。另一个差异是图形界面。WSL2 自带 WSLg 时QEMU 的 GTK 窗口是可以弹出的但如果不带图形环境QEMU 的默认显示初始化可能会报错。我的习惯是直接用-display curses或-nographic让内核字符输出直接打在终端里省去窗口转发的心智负担。这一点在后面运行章节会详细展开。1.2 工具链安装与最简单的环境验证写一个可引导的内核最少需要四样东西汇编器、C 编译器、链接器、引导工具。我在 Ubuntu 里的安装命令是sudo apt update sudo apt install -y build-essential nasm gcc-multilib grub-pc-bin grub-common xorriso mtools qemu-system-x86这里有个很容易踩的坑如果只装了build-essential默认的 GCC 不支持-m32编译 32 位内核时会报一堆找不到头文件的错误。gcc-multilib解决的就是这个问题。grub-pc-bin提供grub-mkrescue需要的 GRUB 模块xorriso和mtools是生成可启动 ISO 镜像时缺一不可的辅助工具。QEMU 则负责把镜像真正跑起来。装完之后建议先做一个很小的环境验证nasm -v gcc -m32 -ffreestanding -c -o /dev/null -x c /dev/null grub-mkrescue --version qemu-system-i386 --version我实际碰到过的情况是grub-mkrescue前面几次都报xorriso not found就是因为只装了grub-pc-bin。所以如果你用的是无桌面版 WSL一定记得把xorriso装上否则生成 ISO 那一步无法通过。1.3 图形显示和运行策略QEMU 是标配写操作系统时的“运行”和普通程序不太一样你不能直接双击一个 exe。最终交付物是一个磁盘镜像或 ISO然后交给虚拟机去加载。QEMU 在这个环节里既当主板又当显卡又当串口是我们最主要的调试对象。在 WSL 里跑 QEMU 有三种显示方式我按优先级说明方式命令参数适用场景终端字符显示-display curses只写文本终端内核时的最优解串口重定向-nographic配合内核串口调试输出稳定干净图形窗口默认 GTK 参数需要画像素、测显存映射时再开坦白讲教学级内核的整个生命周期里我都几乎没碰过图形窗口。因为早期最需要的只有两件事看到字符串有没有打出来、CPU 有没有跑飞。curses模式刚好满足而且它在 WSL 终端里的刷新是即时可靠的。即使 WSLg 已经安装我也建议在调试阶段先不要依赖窗口省得 DISPLAY 变量配置出问题。2. 起步方案的取舍用手写引导扇区还是 GRUB Multiboot2.1 手写引导扇区的成本初学者写 OS 时通常会犹豫要不要从 BIOS 引导扇区开始自己写那 512 字节的启动代码我不是说这条路不能走但它会很快消耗掉你的耐心。512 字节里要塞下引导标志、读取内核扇区、设置实模式到保护模式的切换每多一个功能就要跟扇区大小做斗争。再加上现在多数 BIOS 用 UEFI 引导传统 BIOS 引导扇区的兼容性反而成了新问题。所以我建议如果你目标不是“研究软盘引导历史”而是“尽快看到自制内核跑起来”那就默认走 GRUB Multiboot 这条路。GRUB 会替你把 CPU 从实模式切换到保护模式把内核文件从磁盘加载到内存再跳转过来。你这边只需要专心写内核本身。2.2 Multiboot 标准为我们做了哪些事Multiboot 是一个公开的引导协议GRUB 启动时会检查内核文件头部有没有特定的魔数。我们写的头部只有三个 32 位整数非常直观字段值含义magic0x1BADB002让 GRUB 识别这是 Multiboot 内核flags0x0不需要 GRUB 做额外内存映射处理checksum-(0x1BADB002 0)校验和三者相加必须为 0写成汇编是这样section .multiboot align 4 dd 0x1BADB002 dd 0x0 dd -0x1BADB002这段代码必须出现在内核文件靠前的位置并且最好放在独立 section 里方便链接脚本安排布局。只要头部合法GRUB 在加载内核后会把eax设置为0x2BADB002、ebx指向一个 multiboot 信息结构。这些信息以后做内存管理、ACPI 探测时都有用但现在可以先不管。2.3 我实际采用的目录结构项目不用铺太开四个文件足够撑起第一版内核myos/ ├── loader.s ; 汇编入口含 multiboot 头 ├── kernel.c ; C 语言主逻辑向显存写字符 ├── linker.ld ; 链接脚本控制内核段布局 ├── grub.cfg ; GRUB 菜单配置 └── Makefile ; 一键构建我一开始也想过把链接脚本直接写在 Makefile 里后来发现完全没必要。链接脚本这东西一旦内核开始分.text、.rodata、.data、.bss段它就会变长单独放一个文件更利于维护。grub.cfg 则是为了给grub-mkrescue提供菜单项后面会写到。3. 完整构建过程汇编入口 C 内核 链接脚本3.1 入口汇编的每一步汇编入口的任务说起来很简单告知 GRUB 内核存在、设置栈、跳转到 C 函数。但里面的细节决定内核能不能稳定运行。先看loader.s的完整内容; loader.s section .multiboot align 4 dd 0x1BADB002 ; magic dd 0x0 ; flags dd -0x1BADB002 ; checksum section .text global start extern kernel_main start: cli ; 关中断防止未初始化的 IDT 被调用 mov esp, stack_top ; 设置内核栈 push eax ; multiboot magic可不使用 push ebx ; multiboot info 指针可不使用 call kernel_main cli hlt ; 理论上不该回到这里 section .bss align 16 stack_bottom: resb 32768 ; 32KB 栈空间 stack_top:这里有两个容易被忽略的点。第一cli必须在设置栈之前尽早执行。因为 CPU 从 GRUB 跳过来时中断控制器的状态不是我们能预料的如果开着中断又没有中断描述符表任何一个硬件中断都可能让eip跳到不可预知的地址。第二栈空间放在.bss段resb是 NASM 的“保留字节”伪指令不会在文件里真正占用空间但链接器会在内存里为它预留空间。栈方向在 x86 上是向下增长的所以mov esp, stack_top把栈顶指向预留区域的最高地址。我一开始犯过一个错误把栈放在.data段并初始化了很大的值结果内核文件体积暴涨。放在.bss就干净多了。3.2 内核 C 代码与 VGA 输出C 语言这边最少要能向屏幕输出字符串。在 32 位保护模式下文本模式显存被映射到物理地址0xB8000每个字符占两个字节低字节是 ASCII 码高字节是颜色属性。用 C 写一个极简的 VGA 字符输出函数/* kernel.c */ #define VGA_ADDR 0xB8000 #define VGA_COLS 80 #define VGA_ROWS 25 static void vga_write(const char *str, unsigned char color) { volatile unsigned short *vga (volatile unsigned short *)VGA_ADDR; unsigned int i 0; while (str[i] ! \0) { vga[i] (unsigned short)str[i] | ((unsigned short)color 8); i; } } void kernel_main(void) { vga_write(Hello from my OS running on WSL, 0x07); while (1) ; }注意volatile关键字。显存在操作系统眼里是一块映射到物理地址的内存但它本质上属于设备。如果编译器认为“这段内存写了但没人再读”开启-O2后可能直接把整个写入优化掉。用volatile是告诉编译器这里每次访问都有副作用不要替我删。颜色属性0x07表示灰底黑字数值本身是低四位前景色、高四位背景色之后你要做有颜色输出时改这个字节就行。3.3 链接脚本与 Makefile 的关键参数链接脚本控制内核的段布局。GRUB 喜欢内核被加载到1MB以上所以链接脚本要从1M地址开始/* linker.ld */ ENTRY(start) SECTIONS { . 1M; .text ALIGN(4K) : { *(.multiboot) *(.text) } .rodata ALIGN(4K) : { *(.rodata*) } .data ALIGN(4K) : { *(.data) } .bss ALIGN(4K) : { *(COMMON) *(.bss) } }*(.multiboot)要放在.text的第一位因为 GRUB 是在文件起始位置附近扫描 multiboot 头的。如果kernel_main的代码把.multiboot覆盖到后面可能出现 “no multiboot header found” 的错误。Makefile 里最关键的几个编译参数是CC gcc AS nasm LD ld CFLAGS -m32 -ffreestanding -fno-stack-protector -fno-pic -fno-pie -O2 -Wall -Wextra LDFLAGS -m elf_i386 -T linker.ld -nostdlib-ffreestanding告诉编译器不要依赖宿主系统的标准库因为我们没有printf、没有malloc-nostdlib让链接器不引入crt0那套启动代码。链接参数-m elf_i386则确保 ld 按 32 位 ELF 格式处理。3.4 构建并生成 ISO完整构建流程是三步汇编、编译、链接。Makefile 可以这样写all: myos.iso kernel.elf: linker.ld loader.s kernel.c $(AS) -f elf32 -o loader.o loader.s $(CC) $(CFLAGS) -c -o kernel.o kernel.c $(LD) $(LDFLAGS) -o $ loader.o kernel.o myos.iso: kernel.elf grub.cfg mkdir -p iso/boot/grub cp kernel.elf iso/boot/kernel.elf cp grub.cfg iso/boot/grub/grub.cfg grub-mkrescue -o myos.iso iso clean: rm -rf *.o kernel.elf myos.iso iso run: myos.iso qemu-system-i386 -cdrom myos.iso -m 128M -display cursesgrub.cfg 的内容非常简单menuentry myos { multiboot /boot/kernel.elf boot }执行make run如果一切顺利你会立刻在终端里看到Hello from my OS running on WSL。我第一次跑通时最难处理的不是代码本身而是 Makefile 里grub-mkrescue需要的目录结构。只要iso/boot/grub/grub.cfg这个路径和multiboot /boot/kernel.elf对应上基本不会出问题。4. QEMU 运行与调试从黑屏到看到输出4.1 WSL 下 QEMU 的启动参数成功看到字符输出的前提是 QEMU 用对了参数。我最常用的完整命令是qemu-system-i386 -cdrom myos.iso -m 128M -display curses -no-reboot -no-shutdown这里-no-reboot和-no-shutdown不是必须的但调试时非常有用。如果内核触发 triple faultQEMU 默认会自动重启导致你根本看不到崩溃前的最后画面。加了这两个参数后QEMU 会停在异常现场方便排查。在 WSL 的终端里-display curses模式下 QEMU 会捕获部分键盘输入。想退出 QEMU按Ctrla x不是Ctrlc。这个快捷键不熟悉的话很容易卡在虚拟机里我第一次就愣了半天。4.2 GDB 断点调试 反汇编技巧如果只是字符串没输出可以先怀疑逻辑如果 QEMU 直接异常重启那就得用 GDB 看看到底卡在哪。QEMU 提供远程调试接口qemu-system-i386 -cdrom myos.iso -m 128M -display curses -s -S-s是开启 1234 端口的 GDB stub-S是让 CPU 在启动前暂停。然后另开一个终端gdb kernel.elf (gdb) target remote :1234 (gdb) break kernel_main (gdb) continue断点命中后可以单步看汇编(gdb) layout asm (gdb) info registers eip eax ebx esp有个细节要留意GDB 默认加载的是kernel.elf但 QEMU 里的内存地址和 ELF 里的虚拟地址是对得上的因为链接脚本把内核放在1M开始。如果你的内核以后加了分页、映射到高半区那 GDB 的连接方式会复杂很多意味着你要向 GDB 传set $pagetable之类的信息那是另一个话题了。4.3 WSL 特有坑和绕过方式我在 WSL 里遇到过的坑大概有这几类列出来给你避雷现象原因解决办法grub-mkrescue: error: xorriso not found没按xorrisosudo apt install xorriso mtools编译报bits/libc-header-start.h: No such file缺少 32 位编译支持安装gcc-multilibQEMU 打开后黑屏无文字显存段错误或链接地址不对用-display curses看输出GDB 查eip按 Ctrlc 无法退出 QEMU终端被虚拟机捕获按Ctrla x退出还有一个 Windows 特有的路径问题如果项目放在/mnt/c/Users/xxx/project下NASM、ld 的操作速度会明显变慢因为 WSL 访问 Windows 文件系统需要跨系统转换。我实测下来把项目放在 WSL 自己的文件系统比如~/myos里构建速度快很多。这个对所有编译型项目都适用不只是 OS 开发。5. 实现新内核功能的扩展思路5.1 中断、定时器、串口输出等下一步方向第一个能跑起来的“最小内核”只是起点。我自己的扩展顺序是实现串口输出用 COM1 替代 VGA。VGA 显示受屏幕布局限制串口能直接把日志打到 QEMU 的 stdout配合-append或串口重定向调试体验会好很多。设置 GDT 和 IDT至少要能处理时钟中断和键盘中断。中断是操作系统的心脏没有中断抢占式调度无从谈起。做完分页和用户态切换这基本是“从玩具内核到真内核”的分水岭。每走一步都建议回到 QEMU 验证不要一口气写完一大坨再测。内核调试和普通软件调试不太一样错误的溢出、越界、特权级混乱往往会以极其隐蔽的方式出现最小化验证是唯一的解药。5.2 我在这个过程里的几个实操心得第一个心得汇编入口越短越好。把栈设好、调用 C 函数就够了其余逻辑都放 C 里写。你可能会想在汇编里测试一些花活但汇编代码越多调试成本越高收益却不见得高。第二个心得链接脚本里.bss的对齐非常关键。.bss段如果没有ALIGN(4K)某些地方可能出现符号地址错乱栈顶地址和实际预留区域对不上。同样stack_bottom的align 16也不只是形式主义某些指令对栈对齐有硬性要求。第三个心得善用-d guest_errors,unimp参数。QEMU 会在碰到未实现指令或非法端口访问时打印详细原因。比如你写了out dx, al但端口没映射QEMU 会警告。这个日志在早期捉 bug 时比 GDB 还快。综合来看在 WSL Ubuntu 里写一个操作系统比我预想中要踏实很多工具链、调试器、虚拟机全都有现成的而且社区里大量 OS 开发文档都以 Ubuntu 环境为例遇到问题直接照着改参数就能对齐。你不需要一台专门装 Linux 的物理机不需要烧写 U 盘只用一个终端就能反复重置整个引导过程。至于接下来想实现什么功能从串口到中断到调度器每一层都会有新的坑等你踩但这也是写 OS 最有意思的地方。
阅读完成 · 觉得有帮助?