我最初只是想换一种方式学操作系统原理,结果一头扎进了一个“自己从零写内核”的项目里。起因很简单:工作几年后,觉得上层的业务代码写得再多,底层的很多机制还是“知其然不知其所以然”。进程到底怎么切换的?内存分页是怎么映射的?中断来了CPU到底做了什么?这些问题看了很多书、很多博客,总觉得隔着一层纱。有人推荐直接啃教学系统内核的源码,但那些代码动辄几十万行,新人进去就是大海捞针。
后来在一个技术群里看到有人提了一句:“其实你可以自己在 WSL 里用 QEMU 跑一个自己写的小内核,环境比实体机干净多了。”这句话点醒了我。WSL Ubuntu 本质是一个跑在 Windows 上的 Linux 用户态环境,配合 QEMU 模拟器,完全可以当做一个内核开发的“沙盒”——既不用反复重启电脑,也不怕把宿主机玩坏,所有调试都在一个虚拟环境里闭环完成。于是就有了这个项目标题背后的整个实践过程。
这篇文章不打算写成教程式的完整代码流水账,因为那会非常冗长。我更想把我的整体思路、核心模块的取舍、调试过程中踩过的坑和一些实操心得,完整地分享出来。如果你也想在WSL2 + Ubuntu环境下动手写一个能跑起来的小型操作系统内核,这份经验应该能帮你少走一大半弯路。
1. 项目整体设计与环境搭建
1.1 为什么选 WSL2 而不是双系统或者虚拟机
先回答一个很多人都会问的问题:为什么选 WSL?做操作系统开发和调试,为什么不直接用 VMware 或者 VirtualBox,或者干脆装个双系统?
我个人的体验结论是:WSL2 对内核开发者来说,是一个“性能足够、交互极简、文件系统顺手”的开发环境。
- WSL2 底层就是一个轻量虚拟机,跑了一个完整的 Linux 内核,但它的文件系统、命令行环境和 Windows 的交互无缝集成。我可以在 Windows 上用 VSCode 写代码,然后在 WSL 终端里直接编译运行,体验非常流畅。
- 相比 VMware 里再套一个 QEMU,WSL 里跑 QEMU 的 IO 开销更小,启动模拟器很快,调试迭代效率高。
- 相比双系统,不用重启切换,也不用担心把开发搞砸了影响日常工作。
当然 WSL2 有一个很重要的特点:默认没有 systemd(较新版本已支持开启),网络模型也和普通 Linux 稍有不同。但这些对内核开发来说影响不大,因为我们主要做的是编译、运行 QEMU、调试镜像,不需要太多系统服务。
另一个选择 WSL2 而不是 WSL1 的关键原因:WSL1 是系统调用翻译层实现,不是完整内核,很多系统调用行为和 Linux 原生不一致,对后续要用的构建工具、调试器、模拟器支持都不够纯粹。WSL2 完全就是虚拟化出来的真 Linux,编译出来的工具链、行为都和真机一致。
1.2 工具链与依赖安装清单
这是我实际使用的环境配置,你可以直接作为参考。系统是 Windows 11 + WSL2,WSL 发行版是 Ubuntu 22.04 LTS。
# 更新软件源 sudo apt update sudo apt upgrade -y # 安装构建工具链 sudo apt install -y build-essential nasm gcc gdb qemu-system-x86 grub2-common mtools # 验证工具版本 gcc --version # 我用的是 11.4.0 nasm --version # 2.15.05 qemu-system-x86_64 --version # 6.2.0这里面最核心的是四类工具:
- gcc:编译内核源码的 C 编译器,后面还要配合 ld 链接脚本生成扁平二进制文件。
- nasm:汇编器,用于写启动阶段的汇编代码,因为没有它,我们没法在 C 代码接管 CPU 之前完成“从实模式到保护模式”的跳跃。
- qemu-system-x86_64:整个项目的“台子”,它模拟出一台完整的 x86 PC,包括 CPU、内存、磁盘、键盘、显示器。内核本质上就是一个编译出来的二进制文件,交给 QEMU 去执行。
- gdb:调试器,配合 QEMU 的远程调试接口,可以查看断点处的寄存器、内存、指令流,没有它,单步排查 CPU 异常几乎寸步难行。
1.3 写一个操作系统的总体心智模型
在动手之前,我建议你先在脑子里搭建一个总体的心智模型,否则很容易陷入一堆汇编指令里出不来。
一个从零开始的操作系统,其实就是完成下面这条链路的“接力”:
- 引导阶段:计算机上电后,BIOS/UEFI 会加载引导程序,引导程序把我们的内核文件从磁盘读入内存,然后跳转执行。
- 实模式到保护模式切换:CPU 从 16 位模式切换到 32 位模式,设置段机制,启用分页(可延后),一步步接管硬件。
- C 运行时初始化:内核启动汇编代码做完最基本的环境设置后,跳转到
kmain(),进入 C 语言世界。 - 设备抽象与基础服务:清理 BSS 段、设置中断描述符表、初始化内存管理、初始化定时器和键盘驱动。
- 多任务调度:创建进程控制块、实现上下文切换、按时间片轮转调度进程。
- 用户态 / 内核态分离(高级阶段):实现特权级切换、系统调用入口、用户程序装载。
这个项目我到最后完成了前五步,第六步做了一半——系统调用和用户态切换已经能跑通,但用户程序加载器只支持一种极简的二进制格式。不过在 WSL + QEMU 这种环境下,这些步骤全部可以在一个窗口里调试完成,非常便利。
2. 内核启动流程:从一条汇编指令到 C 世界
2.1 引导方案选型:Multiboot 规范与 GRUB2
写一个操作系统,最底层的选择是“如何被启动”。这个决定会影响你后续整个项目的工具链。
我当时选了Multiboot 规范 + GRUB2 引导,而不是自己写一个完整的引导扇区启动代码。原因很现实:
- 自己写裸引导扇区需要处理 FAT/EXT 文件系统、磁盘读写、实模式下的各种 BIOS 中断,工作量不小且调试难度大。
- Multiboot 规范是一个公开标准,GRUB2 作为成熟的引导程序,会帮我们完成“把内核加载进内存”“设置好基本环境”“跳转到入口”这三件事。
- 对我们来说,起点可以缩短为“写一个符合 Multiboot 标准的内核头部 + 一段启动汇编代码”。
这相当于我们把“从零开始做 CPU 初始化”里面最脏最累的引导部分,交给了成熟的 GRUB。这是我在实践中强烈建议的方案:先让内核跑起来,再来深入研究引导细节。
2.2 Multiboot 请求头:内核的第一份身份证
稍微解释一下 Multiboot 规范。一个符合规范的内核,在二进制文件的前 8KB 内需要放置一个结构,我们叫它Multiboot Header,里面有魔数、标志位、校验和等字段。GRUB2 在加载内核时,会扫描这个结构,确认它“认识”这个文件。
我用的头部很精简,只要求 GRUB 把内核放在它希望的位置,并按页对齐:
section .multiboot align 4 dd 0x1BADB002 ; 魔数:Multiboot 规范要求的固定值 dd 0x3 ; 标志位:请求按页对齐,并提供内存信息 dd -(0x1BADB002 + 0x3) ; 校验和,保证三者相加为 0三个字段加起来是 0,GRUB 靠这个检查头部合法性。我第一次写的时候校验和算错了,结果 GRUB 直接报Error 13: Invalid or unsupported executable format,这个错误信息对你排错是很有用的提示。
2.3 启动汇编:进入保护模式与设置栈
我用的启动汇编代码核心思想很简单:GRUB 进入我们的内核时,CPU 其实已经处在某种保护模式的扁平段模型下,但很多环境变量还没有准备好(比如栈、GDT、页表)。所以我们自己再做一次“二次初始化”。
下面是我精简后的启动入口:
section .text global _start _start: ; 1. 加载新的 GDT 描述符 lgdt [gdt_descriptor] ; 2. 设置段寄存器为内核数据段 mov ax, DATA_SEG mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax ; 3. 设置栈顶,然后用栈立即跳转 C 入口 mov esp, stack_top push eax push ebx ; multiboot 信息结构指针传给 C 代码 call kmain cli hlt这里有几个至关重要的细节,很多人第一次写都会在这里卡住:
- GDT(全局描述符表):CPU 在保护模式下访问内存,需要通过段寄存器查到段描述符,再拿到底地址。GRUB 会给我们提供一个可用的 GDT 环境,但不同 PC 上的 GDT 可能不同。为了统一和可控,我们自己定义一套内核 GDT,再重新加载。
- 栈必设:C 语言编译出的代码,隐含了大量“用栈传递参数、保存返回地址”的操作。如果没有一个可用的栈,
kmain()一执行就会崩溃,而且根本查不到原因,因为连栈回溯都做不到。 - 压栈顺序:我先把
ebx(GRUB 传进来的 multiboot 信息结构指针)压栈,再压eax,这样一个kmain(int magic, multiboot_info_t* mb_info)函数签名就能对应上 C 调用约定。这里的细节是,kmain的第一个参数是eax,第二个参数是ebx,压栈顺序是反的(C 调用约定是从右往左压栈)。
2.4 链接脚本:把段放到内存的正确位置
写出汇编和 C 源码之后,还需要一个链接脚本,告诉链接器“把这些段放到哪个虚拟地址”。这一步经常被忽略,但特别关键。
我使用的是这样一个简单脚本:
ENTRY(_start) SECTIONS { . = 0x100000; .text : { *(.multiboot) *(.text) } .rodata : { *(.rodata*) } .data : { *(.data) } .bss : { *(.bss*) *(COMMON) } }几个要点:
ENTRY(_start)是指定入口点,GRUB 加载我们的内核后,会从这里开始执行。. = 0x100000是让链接器把代码段的起始地址设在 1MB 处。这是约定俗成的内核装载地址:低 1MB 是 BIOS、实模式中断向量和各种硬件映射区域,不适合放内核。从 1MB 开始装载,简单且规范。- 我把
.multiboot段放在文本段最前面,确保 Multiboot Header 出现在内核二进制文件的前部,满足规范中的“8KB 内”要求。
编译命令大致是这样:
nasm -f elf32 boot.asm -o boot.o gcc -m32 -ffreestanding -fno-pic -nostdlib -c kmain.c -o kmain.o ld -m elf_i386 -T link.ld boot.o kmain.o -o kernel.bin说明一下-ffreestanding和-nostdlib这两个参数:内核代码不依赖任何标准 C 库,因为标准库本身就是建立在内核提供的系统调用之上的。所以我们要告诉编译器“你是独立环境,别自动链接任何库函数”。
2.5 生成可引导镜像与 QEMU 启动验证
内核二进制编译出来了,还需要把它做成 GRUB 能引导的镜像文件。我用的是grub-mkrescue,它会生成一个 ISO 文件,QEMU 可以直接虚拟出一个光驱来加载它。
mkdir -p isodir/boot/grub cp kernel.bin isodir/boot/kernel.bin cat > isodir/boot/grub/grub.cfg <<EOF set timeout=1 set default=0 menuentry "my-os" { multiboot /boot/kernel.bin boot } EOF grub-mkrescue -o myos.iso isodir/ qemu-system-x86_64 -cdrom myos.iso -serial stdio -display none如果你看到控制台输出,说明内核已经成功启动。这就是第一个里程碑:一个能打印字符的裸机“玩具操作系统”。
3. 内核核心模块:显存、中断、内存管理与调度
3.1 直接用显卡显存打印字符
操作系统内核里,最“爽”的一个模块是往屏幕上打印字符。没有 printf,没有标准库,一切都得自己来。
在 x86 的 VGA 文本模式下,显存被映射到物理地址0xB8000处。这一块内存的布局是:每两个字节表示一个字符,低字节是 ASCII 码,高字节是前景色和背景色。比如我们要在屏幕第一行第一列打印红色的字符A,就往0xB8000写入字节0x41和0x04。
我的实现方式是维护一个简单的光标位置,写一个kputc函数:
#define VGA_ADDRESS 0xB8000 #define VGA_WIDTH 80 #define VGA_HEIGHT 25 void kputc(char c, uint8_t color) { static int row = 0, col = 0; char* vga = (char*) VGA_ADDRESS; if (c == '\n') { row++; col = 0; } else { int offset = (row * VGA_WIDTH + col) * 2; vga[offset] = c; vga[offset + 1] = color; col++; } if (col >= VGA_WIDTH) { col = 0; row++; } if (row >= VGA_HEIGHT) { // 简单的滚屏:把第二行开始的显存内容整体上移一行 for (int i = 0; i < VGA_WIDTH * (VGA_HEIGHT - 1) * 2; i++) { vga[i] = vga[(VGA_WIDTH * 2) + i]; } row = VGA_HEIGHT - 1; } }这里要注意,我们没有权限访问标准库的printf,因为那会触发系统调用,而系统调用是我们自己还没实现的功能。所以哪怕只是把数字打印成十六进制字符串,也需要自己写itoa。这看起来很简单,但在没有库函数依赖的环境里,它逼着你去理解“格式化输出”背后到底做了什么。
3.2 中断机制:从三行汇编到中断描述符表
中断是操作系统的地基。CPU 处理外部设备事件、内部异常,都需要走中断机制。没有中断,键盘输入无法感知,时间片也没法切换。
我实现中断的方式是经典的“IDT 三步走”:
第一步,定义中断描述符表(IDT)。IDT 的每一项是 8 字节,包含中断处理函数的段选择子和偏移地址。我们需要为 0~255 号中断向量都注册一个处理函数,即使有些中断用不到,也要注册一个兜底函数,防止意外触发时系统直接崩溃。
第二步,编写汇编中断入口。CPU 在响应中断时,会自动压入部分寄存器(在某些异常下还会压入错误码),然后跳转到 IDT 中指向的地址。我们用汇编编写统一的isr_common_stub,它负责把所有通用寄存器压栈保存,然后调用 C 语言的isr_handler(struct registers* regs),最后恢复寄存器并iret返回。
isr_common_stub: pusha ; 保存所有通用寄存器 push ds push es push fs push gs mov ax, 0x10 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov eax, esp push eax ; 作为参数传给 C 处理函数 call isr_handler pop eax pop gs pop fs pop es pop ds popa iret第三步,往 IDT 装载指令。编译好之后,用lidt指令把 IDT 的描述符加载进 CPU。
中断相关的调试是全程最痛苦的阶段。我说一个真实的踩坑经历:当时我注册好 IDT 后,为了测试,故意触发了一个除零异常(int 0),结果系统直接重启,看不到任何输出。最后加了串口日志才发现问题——IDT 描述符里的段选择子写错了,CPU 在跳转中断处理函数时,用的不是一个有效的代码段描述符。这类问题只能靠 QEMU 的-d int,cpu_reset调试选项配合日志来定位。
3.3 内存分页与简单物理内存分配器
分页机制是 32 位保护模式下最核心的抽象。分页让我们可以给每个进程一个独立的虚拟地址空间,也让你写内核时能安全地管理物理内存。
我的实现是:
- 使用
CR3寄存器指向页目录表(Page Directory)。 - 页目录里每个条目(PDE)指向一个页表(Page Table)。
- 页表里每个条目(PTE)指向一个 4KB 对齐的物理页帧。
- 通过
PDE和PTE的控制位(present、rw、user)来设置权限。
启用分页的汇编代码是这样一段:
enable_paging: ; 将页目录地址加载到 CR3 mov eax, page_directory mov cr3, eax ; 开启 PSE(页大小扩展),也就是 4MB 大页 mov eax, cr4 or eax, 0x00000010 ; PSE mov cr4, eax ; 开启分页 mov eax, cr0 or eax, 0x80000000 mov cr0, eax ret为了简单,我把最初的内核映射成4MB 大页,这样直接用一个页目录就能映射 4GB 虚拟地址空间(每个 PDE 指向一个 4MB 物理页帧),省去了管理二级页表的麻烦。等后面实现用户态的时候,再换成标准 4KB 页表,这样页表的管理更精细,权限控制也更灵活。
物理内存分配器我用的方式是极简的空闲链表分配器(page allocator)。把内核镜像之后的所有物理内存看作一串连续的页帧,用一个链表记录哪些页是空闲的。分配时从链表头部取一个页帧,释放时把页帧挂回去。这种方式和真正工业级内核里的 buddy allocator 相去甚远,但足够支撑我这个项目前期的需求。后续如果要写更复杂的 malloc,可以在这个基础上逐步增强。
3.4 定时器与最简单的轮转调度
中断和内存准备好后,我实现了一个非常简化的优先级队列调度器,但最核心的机制是定时器中断驱动的进程调度。
定时器使用的是 x86 的 PIT(可编程间隔定时器),它会产生周期性中断。我把它设置为系统时钟源,每次中断到来时,CPU 自动进入时钟中断处理函数,在函数里完成进程切换。
进程切换的实质就是保存当前进程的上下文(寄存器状态)到它的内核栈,然后从目标进程的内核栈恢复上下文。这个操作在汇编层面极其优雅:
switch_to: ; 当前进程上下文保存 mov [current_tcb + context.eip], eax mov [current_tcb + context.esp], esp ; ... 同理保存其他寄存器 ; 加载目标进程上下文 mov esp, [next_tcb + context.esp] mov eax, [next_tcb + context.eip] jmp eax这里有一个很多新手容易犯错的地方:你不能直接在 C 代码里用函数调用链来实现上下文切换,而必须用纯汇编来保存和恢复esp、eip等寄存器。因为 C 函数调用会修改栈,你无法干净地保存现场。我一开始用setjmp/longjmp类似思路,结果切换几次后栈就乱了,调试了很久才发现问题根源。
运行效果是屏幕上能看到两个进程交替打印字符,各自的计数器在递增。虽然只是最粗糙的“轮转调度”,但当你在终端里第一次看到自己写的内核让两个任务“同时”跑起来,那种成就感是任何上层框架开发都给不了的。
4. 实操过程中的问题排查与调试技巧
4.1 内核开发调试三板斧:日志、GDB、CPU 异常
写操作系统没有“断点打 log 再 run”这种普通应用开发体验那么顺滑,因为你连printf都得自己先实现。我调试过程中反复用到的手段有三个:
第一,串口日志输出。我在 QEMU 里把串口重定向到标准输出,内核里直接往COM1端口(0x3F8)写字节,这样内核打印的调试信息会直接出现在终端里。
qemu-system-x86_64 -cdrom myos.iso -serial stdio -display none这个技巧对“内核启动到了哪一步”非常有用,比看寄存器靠谱。
第二,QEMU 自带的 QEMU Monitor 和 GDB 调试。在另一个终端里启动 GDB,连接 QEMU 的远程调试端口:
qemu-system-x86_64 -cdrom myos.iso -s -S -display none # -s 表示开启 GDB 远程调试端口 1234 # -S 表示在启动时暂停 CPU,等 GDB 连上再开始然后在 GDB 里:
target remote localhost:1234 break kmain continue info registers x/20x 0x100000这样就能看到 CPU 停在kmain入口时的寄存器状态,以及内核二进制被加载到内存里的原始字节。GDB 调试内核是效率最高的调试方式,它能看到真实硬件状态。
第三,CPU 异常日志。QEMU 有一个非常有用的调试选项,能够在每次 CPU 中断或异常时打印详细信息:
qemu-system-x86_64 -cdrom myos.iso -d int,cpu_reset -D qemu.log这个日志文件会记录每一个中断、异常、寄存器的值。比如我排查“为什么内核一执行到某条指令就重启”时,看qemu.log就会发现是缺页异常(#PF)或者无效操作码异常(#UD),再回溯到对应地址,很快就能定位问题。
4.2 经典故障:为什么我的内核一运行就重启
这是新手最常遇到的诡异问题。内核代码编译出来了,QEMU 一运行,屏幕闪一下,又回到 GRUB 菜单,像是计算机被重置了。
我详细分析过一个这样的案例。最初我怀疑是代码写错了,但代码逻辑看起来完全正确。后来加了-d int,cpu_reset -D qemu.log才发现,CPU 在执行初始化代码的某处时,触发了一个未注册的异常(比如除以零),而 IDT 里没有为这个中断注册处理函数,CPU 找不到入口,三重故障,直接复位。
这类问题的排查口诀是:
- 确认所有中断向量都有默认兜底处理函数。
- 确认 GDT、IDT 的地址和长度是正确的,特别是指针有没有用 32 位地址。
- 确认栈存在,且已正确写入
esp。 - 确认分页开启后,当前执行的 EIP 在映射范围内。
- 逐个模块测试,每次只解锁一小块功能,不要让问题累积。
4.3 一些容易踩的坑汇总
我把我踩过的坑整理成一个速查表,希望你能避开:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| GRUB 报 Invalid or unsupported executable format | Multiboot 头校验和错误或头不在文件开头 | 检查魔数、标志位、校验和,确认链接脚本把.multiboot段放最前面 |
| 启动后黑屏 | VGA 初始化失败或显示缓冲区没写对 | 检查0xB8000地址映射是否还停留在实模式映射下,确认使用了正确的段选择子 |
| 一触发中断就重启 | IDT 未初始化或IDT描述符错误 | 用qemu.log查看 #PF/#GP 异常,检查lidt设置是否正确 |
| 启用分页后程序跑飞 | PDE/PTE 的 present 位或权限位错误 | 逐条检查页表项结构,确认正确的CR3 |
| 字符串打印乱码 | 显存属性字节未正确设置 | 显存每个字符占两字节,写高字节之前确保有正确的颜色属性 |
| GDB 连不上 QEMU | 忘记了-s参数或端口被占用 | 确保 QEMU 以-s -S启动,-S是让我们手动 continue 调试 |
4.4 GDB 调试技巧补充
再说几个对内核调试特别实用的 GDB 命令:
info registers:查看所有寄存器当前的值。切换前后对比上下文非常方便。x/20i $eip:反汇编当前eip附近的指令,查看执行流是否正确。bt:查看栈回溯。前提是内核的栈帧结构正常,如果栈被破坏了,这个命令会直接报错。set $eax = xxx:在调试时可以手动改寄存器,用来模拟特定场景,非常方便。watch命令:可以监视某个内存地址上一次写操作,如果我想知道“这个变量为什么被改掉了”,这个命令很有效。
有一次我发现一个全局变量被莫名其妙地修改了,排查了很久。后来用watch监视了这个变量的地址,发现是DMA(内存访问)和 CPU 写操作的竞争问题。当然,在我的小内核里没有真正的 DMA 设备,这个现象其实是因为页表映射重了。这提醒我:调试一个底层系统,比普通应用调试更需要关注硬件和内存布局。
5. 这个项目的价值边界与扩展方向
5.1 哪些部分是值得做深做透的
做这个项目最大的收获,不是“我写了一个操作系统”这么一句空话,而是对几个概念的认知发生了根本变化:
- 中断到底怎么工作:从“知道 CPU 会跳到中断向量”到“亲手把 IDT 建起来,看着键盘按下时中断号 33 触发,CPU 跳到我的处理函数,再把字符读出来”,这个体验完全不一样。
- 内存管理为什么复杂:当你亲手给每个进程分配独立的页表,切进程时还要切换 CR3,才会明白为什么内存管理要对 TLB 如此小心。
- 为什么要把内核态和用户态分开:当你发现自己写的内核里所有代码都拥有最高权限,一个 bug 就能打挂整个系统时,你会立刻理解系统调用的价值。
如果要把它扩展成更有深度的项目,我建议往这几个方向补:
- 完整的物理内存分配器:用 buddy allocator 替换空闲链表分配器,实现内存碎片管理和回收。
- 用户态与系统调用:把 CPL 从 0 切到 3,实现真正的用户态进程,用
int 0x80做系统调用。这一步做完,你就真正理解了权限级别。 - 文件系统:挂载一个简单的 ramdisk 文件系统,实现文件打开、读写、关闭。这会让你的内核从“演示玩具”变成“能用终端交互”的迷你系统。
- 网络栈(进阶):往驱动里加一个虚拟网卡,实现最简的 ARP 和 UDP 协议栈。这个难度很大,但也最接近现代操作系统的核心功能。
5.2 对后续学习方向的启发
做完这个项目后,再回来看《深入理解Linux内核》或者《操作系统设计与实现》,感受完全不同了。那些原本抽象的描述——比如“进程调度用了 active/inactive 两个队列”“内存回收用了 LRU 链表”——现在都能和自己在代码里实现过的粗糙版本对应上。
更进一步的理解是:现代操作系统里大量的复杂性,并不是因为设计者喜欢复杂,而是为了应对极端场景下的性能、安全和可用性需求。你写的玩具内核只需要跑两个进程,自然不需要太复杂的调度器;可如果你想让 50 个进程同时流畅运行,那就必须考虑优先级、时间片、IO 等待状态等一系列工程问题。
5.3 代码管理与文档沉淀建议
最后说点项目管理上的心得。这个项目的源码量不大,但结构一定要清晰。我在实际组织代码时是这么分的:
myos/ ├── boot/ # 启动汇编代码 │ ├── boot.asm │ └── gdt.asm ├── kernel/ │ ├── kmain.c # 内核入口 │ ├── idt.c # 中断描述符表 │ ├── memory.c # 内存分配器 │ ├── paging.c # 分页管理 │ ├── process.c # 进程控制块与调度 │ ├── timer.c # PIT 定时器驱动 │ └── video.c # VGA 文本模式驱动 ├── include/ │ ├── kernel.h │ ├── idt.h │ └── ... ├── link.ld ├── Makefile └── grub.cfg每个模块一定要配上README.md,记录这一个模块“为什么这么设计”的思路,而不是只写“怎么编译使用”。因为三个月后回头看你自己的代码,你最需要的不是当时写了什么,而是当时为什么这么写。
5.4 一些延展实验的探索
如果你做完了基础流程,我可以分享一些我后来折腾过、也很有意思的扩展小实验:
- 在同一个内核里跑两个不同优先级的进程:试着给优先级高的进程分配更多时间片。你会理解为什么现代调度器要引入“nice 值”。
- 给内核加一个简单的命令行 shell:在键盘中断处理里维护一个输入环形缓冲区,再在
kmain主循环里解析命令。这个实验做完,你就离“交互式操作系统”不远了。 - 尝试动态加载一个外部程序:把用户程序编译成独立二进制,放进 ramdisk,再从内核里装载进内存、跳转执行。这能帮你理解“可执行文件格式”和装载器的工作原理。
6. 实际使用的小经验与最后的碎碎念
文章写到这里,其实已经很完整了。最后分享几条我在实践过程中的碎碎念,算是给想动手的人一个兜底建议。
第一,先在 QEMU 里跑通,不要一开始追求真实硬件。为什么?真实硬件上你没法轻松打断点看寄存器,显示屏输出也没法重定向到调试终端。而 QEMU 的-serial stdio和-s -S调试组合,几乎成了我整个开发周期的“眼睛”。等内核具备基本功能后,再考虑把它烧到真实硬件上也不迟。
第二,每次只改一个变量。这个项目不像 Web 开发,改错一行代码不会报出优雅的错误信息,最轻也是黑屏,严重就是 CPU 三重故障直接重置。所以我给自己定了一条规矩:每次改动后,只验证一个功能点,确认无误再继续下一项。
第三,保留旧的镜像文件。我在开发到内存管理阶段时,为了加一个新特性,把旧的 ISO 镜像覆盖了,结果新功能编译失败,想退回旧版对比行为都做不到。后来我养成了每个里程碑都保留镜像文件的习惯,比如kernel-v0.1.iso、kernel-v0.2.iso,排查问题时可以直接对比新旧版本的行为差异。
第四,把“make run”变成肌肉记忆。我写了一个简单的 Makefile,目标只有两个:make build负责全量编译和生成 ISO,make run负责启动 QEMU。这样每次实验都只是敲两个命令,迭代速度快很多。
all: build run build: nasm -f elf32 boot/boot.asm -o build/boot.o gcc -m32 -ffreestanding -fno-pic -nostdlib -c kernel/kmain.c -o build/kmain.o # ... 其他源文件同样处理 ld -m elf_i386 -T link.ld build/*.o -o build/kernel.bin mkdir -p isodir/boot/grub cp build/kernel.bin isodir/boot/kernel.bin grub-mkrescue -o myos.iso isodir run: qemu-system-x86_64 -cdrom myos.iso -serial stdio -display none第五,多看看别人的教学内核项目,然后重新自己写一遍。一开始我在某些实现上卡住了,就去参考别人写好的小内核,觉得“这有什么难的”,然后关掉网页自己写,结果还是写不出来。只有亲自动手把这些细节雕琢过一遍,那些知识才真正是你的。这个项目的价值就在这里——它逼着你去直面那些“你以为懂了但其实没懂”的概念。