拓冰建站拓冰建站
首页 / 资讯中心 / 正文

手写迷你操作系统内核:从引导扇区到进程调度全记录

山水观心操作系统Shanshui-guanxin是我最近一年主要投入的一个学习型项目。它不是又一个 Linux 发行版也不是拿现成内核改个名字的玩具而是从引导扇区开始用汇编和 C 一行一行写出来的微型操作系统内核。整个项目包括引导加载、中断处理、物理内存与虚拟内存管理、抢占式进程调度、一个简单的 FAT16 文件系统模块以及一个只能跑十几条内置命令的 Shell前后加起来一万多行代码目前能在 QEMU 和 VirtualBox 里稳定启动。名字里的“山水观心”其实是我给自己定的开发状态看代码执行像看山水一步一步走不要急。这篇复盘会把你从零带到能做出一个可运行镜像的完整路径也把我在 QEMU、VMware、VirtualBox 里踩过的各种启动坑一并列出来适合正在读《操作系统概念》、打算做操作系统课程设计、或者想挑战“30 天自制操作系统”的人参考。1. 项目背景与设计初衷1.1 从“读不懂操作系统”到“亲手写一个”我先从自己的困惑说起。以前刷操作系统基础知识的时候对进程、内存、文件系统这些概念背得滚瓜烂熟但真让我说清楚“程序是从哪里开始被加载的”“发生一次系统调用要经过几层跳转”一下就懵了。当时正好读完了《30天自制操作系统》的前半部分又翻了 xv6 的源码脑子里隐约有了一个轮廓。我决定慢下来不急着读完整本书而是自己动手写一个足够小的内核把“引导—分页—调度—终端”这条主线走通一遍。山水观心操作系统Shanshui-guanxin就是这么来的。这个项目的目标非常明确第一要在虚拟机里能启动能从磁盘读到内核并进入保护模式第二要有简单的内存分页和进程调度能在终端里同时跑几个进程第三要有最基础的 Shell能执行内置命令和加载外部程序。功能上不追求 Linux 那样的庞大体量更像是一个能让人一晚上读完代码的教学内核。正因为小核心逻辑没有被各种驱动淹没学习成本反而低得多。1.2 这个项目具体能做什么、覆盖了哪些知识点当前版本的功能清单可以分为六块引导加载支持从 FAT 格式的虚拟软盘/硬盘读取内核进入 32 位保护模式。中断与异常自定义 IDT 表、8259A 中断控制器、时钟中断驱动调度。内存管理物理页分配器加二级分页每个用户进程拥有独立的地址空间。进程管理PCB 队列、上下文切换、基于时钟中断的抢占式调度。文件系统一个极简 FAT16 读取模块支持打开、读取、列出根目录文件。Shell 交互提供 help、cls、echo、ls、mem、ps、kill、run 等命令。这些功能连一个“玩具 OS”都算不上但恰好覆盖了《计算机操作系统》课程里最核心的几个章节处理器管理、存储管理、文件管理和用户接口。做完之后再看期末考试题很多题不再是死记硬背而是能对应到具体代码行。比如考“进程切换的时机”我会直接想到时钟中断里那个保存寄存器现场的地方考“页表项有哪些字段”我写过的那个结构体就是现成的答案。这大概是这个项目对我最大的影响。2. 整体架构与核心技术选型2.1 系统的分层结构与模块关系整个系统分为三层。最下面是硬件层也就是模拟环境里的 CPU、内存、磁盘、串口和键盘中间是内核层负责硬件初始化、中断、内存、进程和文件系统的管理最上面是用户层只有一个 Shell 主程序以及通过加载器放进内存的外部程序。内核运行在特权级 0用户程序运行在特权级 3。中断是内核和用户程序之间唯一的入口。这样设计的最大好处是边界清晰。调度的代码不会和终端输出的代码缠在一起出 bug 时能快速定位到某个模块。比如屏幕乱码先查显存映射和串口驱动进程不切换先查时钟中断有没有触发Shell 崩溃导致整个系统重启说明用户态和内核态的隔离还没做好。分层结构听起来是个老生常谈的名词真正动手之后才会发现它不只是在教科书里画个分层图而是直接决定你调试的效率。2.2 为什么选 x86、QEMU 和 GCC 交叉编译选 x86 不是因为它先进而是资料最多。普通 PC 的启动流程、BIOS 中断调用、保护模式切换这些在《x86 汇编语言》或者《30天自制操作系统》里讲得非常细。ARM 板子虽然便宜但要从设备树、UART 初始化开始很容易被硬件细节带跑一上来就想着支持树莓派最后很可能连串口都没点亮。虚拟机用 QEMU因为可以一行命令启动还能远程 GDB 调试这对内核开发来说是救命级功能。我偶尔也会在 VirtualBox 和 VMware 里跑同一个镜像验证兼容性。开发环境就放在一台 Linux 机器上安装的工具有五个nasm、gcc-multilib、ld、qemu-system-x86 和 make。其中gcc-multilib最关键因为后续要用-m32参数编译 32 位内核代码。可能有人会问为什么不用现成的 GRUB 引导或者用 xv6 那样的 Makefile 模板我的回答是这套流程本身就是学习的一部分。从 BIOS 把引导扇区读进来再从引导扇区把内核读进来最后跳到内核的入口函数这里面每一步都有机会亲手碰到底层硬件。如果直接 GRUB很多东西就被吞掉了。虽然慢但踩坑越多后面看到各类国内外的 OS 教程就会越有底气。2.3 关键模块的设计取舍在开发前我对几个核心模块都做了方案对比这里整理成一张表。模块我采用的方案备选方案取舍理由引导方式BIOS 自写引导扇区UEFI GRUB自写引导能把磁盘读写讲清楚UEFI 太复杂CPU 模式从实模式切换到保护模式全程实模式实现分页与特权级隔离必须进入保护模式内存管理二级页表 物理页位图段式 简单分配页表能隔离进程也更贴近现代操作系统进程调度抢占式时间片轮转协作式 yield时钟中断是内核基本功抢占式更能说明调度本质文件系统FAT16 读取子集自研格式FAT16 有公开文档还可以用真实镜像验证每个选择我都会展开说几句。引导方式我坚持用自写引导扇区是因为这是理解“计算机如何启动”最短的一条路径。CPU 模式选择保护模式是因为如果只停留在实模式无法使用分页也无法实现用户态和内核态的隔离那这个操作系统就失去了最重要的学习价值。内存管理用页表而不是纯段式是因为页表能实现“每个进程以为自己在独享内存”的假象。这个假象是整个操作系统的魔法来源。后面的页目录和页表结构我会在实操章节给出代码这里先记住结论分页的出发点是隔离和抽象而不是为了复杂而复杂。进程调度用抢占式时间片轮转是因为我想要一个“不需要用户程序配合”的多任务机制。协作式调度虽然实现简单但只要一个进程死循环整个系统就卡死了。用时钟中断驱动的抢占式调度才是现代操作系统的基本玩法。文件系统选择 FAT16则纯粹是为了省时间。它有公开规范可以用真实格式化的软盘镜像测试不用自己发明一套二进制格式再花大量时间调试。3. 实操过程从零构建一个可运行的镜像3.1 开发环境与工具链搭建我用的操作系统是 Ubuntu 22.04安装依赖只需要一条命令sudo apt update sudo apt install build-essential nasm gcc-multilib qemu-system-x86 make装完以后可以用nasm -v和gcc --version确认版本。要注意的是gcc-multilib只在 64 位系统上需要它的作用就是让 GCC 能编译 32 位代码。如果缺了这个包编译时会出现bits/predefs.h: No such file or directory之类的报错。项目的目录结构我尽量保持简单shanshui-guanxin/ ├── boot/ │ ├── boot.asm │ └── loader.asm ├── kernel/ │ ├── main.c │ ├── idt.c │ ├── page.c │ ├── task.c │ ├── fat16.c │ └── include/ └── build/ └── Makefile这个目录不是一开始就有的而是写到一半才整理出来的。我强烈建议从一开始就用 Git 管理每次让内核能跑起来就打个 tag。这样后面改崩了随时可以回到最近一个正常版本而不是靠记忆力回退代码。3.2 引导扇区把内核从磁盘加载进内存引导扇区是整个系统真正意义上的第一段代码。BIOS 在自检完成后会把磁盘的第一个扇区加载到内存地址0x7C00然后跳过去执行。所以我们的boot.asm的第一行必须是org 0x7c00告诉汇编器所有地址都基于这个偏移。下面是我项目里引导扇区的核心代码已经删掉了很多不重要的部分保留最关键的动作BITS 16 org 0x7c00 start: cli xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 sti load_kernel: mov ax, 0x1000 mov es, ax xor bx, bx mov ah, 0x02 mov al, 64 ; 连续读 64 个扇区 mov ch, 0 mov cl, 2 ; 从第 2 个扇区开始 mov dh, 0 int 0x13 jc read_error lgdt [gdt_desc] mov eax, cr0 or eax, 1 mov cr0, eax jmp 0x08:protected_start BITS 32 protected_start: mov ax, 0x10 mov ds, ax mov es, ax mov ss, ax mov esp, 0x10000 call 0x10000 read_error: mov al, E mov ah, 0x0e int 0x10 cli hlt gdt_desc: dw gdt_end - gdt_start - 1 dd gdt_start gdt_start: dq 0x0000000000000000 dq 0x00cf9a000000ffff dq 0x00cf92000000ffff gdt_end: times 510-($-$$) db 0 dw 0xaa55这段代码要完成三件事读磁盘、切保护模式、跳转到内核。int 0x13是 BIOS 提供的磁盘读取中断参数比较多最容易错的是cl。我一开始写成从第 1 个扇区开始结果把引导扇区自己又覆盖了一遍整个系统直接重启。后来改成cl2才正常。切保护模式的步骤固定把 GDT 地址加载到 GDTR打开 CR0 的第 0 位然后远跳转刷新流水线。GDT 里前两个表项是空描述符和代码段描述符、数据段描述符具体的段基址都是 0所以保护模式下的分段其实是被“关掉”的内存管理主要靠后面的页表。进入保护模式后call 0x10000就是跳到内核的入口地址这个地址会在链接内核时用-Ttext 0x10000固定下来。3.3 内存分页与进程调度的落地内核里最值得反复看的是分页和调度两块。分页的核心数据结构是页目录和页表。在我的实现里每个进程都有自己的页目录地址空间是 4GB但实际映射到的物理页很少。页表项结构体如下typedef struct page_table_entry { uint32_t present : 1; uint32_t write : 1; uint32_t user : 1; uint32_t reserved : 2; uint32_t accessed : 1; uint32_t dirty : 1; uint32_t unused : 2; uint32_t available : 3; uint32_t address : 20; } __attribute__((packed)) pte_t;这里最关键的是present和address。present0表示该页不在内存中访问它会触发 Page Fault 异常address存放物理页基址的高 20 位低 12 位是属性位。我最初只设置了present和write忘了user位结果用户态程序一访问内存就缺页。排查了很久才发现在特权级 3 下运行页表项必须设置 user 位否则 CPU 会认为权限不足。进程调度涉及上下文切换。每个task_t结构体里保存着自己内核栈的 esp、ebp 和下一次执行的 eip。切换过程本质上是把当前 CPU 寄存器保存到旧进程的 PCB再从新进程的 PCB 恢复寄存器。简化后的代码长这样void context_switch(task_t *next) { if (current next) return; asm volatile ( movl %%esp, %0\n movl %%ebp, %1\n movl $1f, %2\n : r(current-esp), r(current-ebp), r(current-eip) ); current next; asm volatile ( movl %0, %%esp\n movl %1, %%ebp\n pushl %2\n ret\n : : r(next-esp), r(next-ebp), r(next-eip) ); }注意我把eip的保存方式做了个 trick。$1f是一个局部标签编译器会把它的地址赋给临时寄存器然后由内联汇编存到当前进程的 eip 字段。这样进程被再次切换回来时会直接跳到标签处继续执行。这个手法不是最优解标准做法是在中断入口处统一保存全部寄存器但对于一个教学内核能用最简单的方式讲清“保存现场、切换栈、恢复现场”就够了。调度器本身是时钟中断触发的。每发生一次时钟中断就检查当前进程的时间片是否用完。如果用完了就在就绪队列里找下一个进程然后调用context_switch。这里有一个很容易被忽略的细节在中断上下文里切换进程必须保证时钟中断处理完成之后返回到新进程的用户态而不是返回旧进程被打断的地方。我的做法是把进程的 eip 直接设置成iret的返回地址并且在内核栈里提前伪造好一个中断帧。这个思路来自于 Linux 的ret_from_fork非常适合接口型讲解。3.4 构建脚本与镜像生成手动编译链接容易出错我用 Makefile 管理整个构建过程。核心目标只有一个生成os.img然后交给 QEMU 启动。NASM nasm GCC gcc LD ld OBJCOPY objcopy BOOT_SRC boot/boot.asm KERNEL_SRC kernel/main.c kernel/page.c kernel/task.c kernel/fat16.c all: os.img boot.bin: $(BOOT_SRC) $(NASM) -f bin $ -o $ %.o: %.c $(GCC) -m32 -fno-pie -ffreestanding -fno-stack-protector -c $ -o $ kernel.bin: $(KERNEL_SRC:.c.o) $(LD) -m elf_i386 -Ttext 0x10000 $^ -o kernel.elf $(OBJCOPY) -O binary kernel.elf kernel.bin os.img: boot.bin kernel.bin cat boot.bin kernel.bin os.img qemu-system-i386 -fda os.img -serial stdio-fno-pie和-ffreestanding是必须的。前者让 GCC 不生成位置无关代码否则跳转地址会变成相对地址和链接脚本指定的绝对地址冲突后者告诉编译器没有标准库可用不要自作主张插入任何运行时函数。-fno-stack-protector则是关掉栈保护这个选项在用户态程序里很安全但在内核里会因为找不到__stack_chk_fail导致链接失败。-Ttext 0x10000指定内核的加载地址和引导扇区里的call 0x10000对应。最后用cat把引导扇区和内核二进制拼在一起就得到了一张可以直接启动的软盘镜像。QEMU 的-serial stdio会把串口输出重定向到终端。我习惯在启动早期把调试日志通过串口打印因为显卡输出在进入保护模式后还需要额外初始化串口反而更省事。4. 常见问题与排查技巧实录4.1 启动阶段虚拟机没反应、黑屏、报 VMware CPU 错误这是整个项目里最劝退的阶段。我第一次做引导扇区烧进镜像后 QEMU 一直黑屏甚至直接 reboot loop。后来用-d int,cpu_reset打开 QEMU 的调试输出才发现是读磁盘失败后跳到了read_error而那个函数打印一个字符后又hlt看起来就像死机。我把这类问题整理成了速查表。现象可能原因解决办法启动后黑屏QEMU 窗口一闪而过引导扇区没有跳到内核或者读磁盘失败用qemu-system-i386 -d int,cpu_reset观察中断确认cl从 2 开始QEMU 报 Could not read from disk镜像制作时 boot.bin 小于 512 字节或文件总大小不是 512 整数倍确保最后两字节是0x55AA用ls -l os.img检查大小VMware 报 客户机操作系统已禁用 CPU。请关闭或重置虚拟机客户机操作系统类型选错或 CPU 虚拟化未开启把客户机类型设为 Other在虚拟机设置里开启 VT-x/AMD-VVirtualBox 无法启动没有选择软驱或光驱启动BIOS 里启用软驱或者用VBoxManage modifyvm --fda挂载镜像其中 VMware 那个报错尤其容易踩。创建虚拟机时如果选了“Linux”VMware 会默认给虚拟机配置一些硬件特性比如 ACPI 和 SMP。我的教学内核压根没有实现 ACPI 的完整处理导致 VMware 检测到 CPU 被“禁用”并给出提示。解决方法是把客户机操作系统类型改为 Other/Other或者 Other/Linux 2.6 64-bit然后重新启动。这不是项目代码错误而是虚拟化层对未知系统的保护机制。4.2 运行阶段中断、页错误与调度器问题进入保护模式后第一个高频问题是时钟中断不触发导致进程调度完全失效。原因基本上是两个一是 IDT 没设置好8259A 的中断向量默认在 0 到 15 之间会和 CPU 异常冲突二是忘了执行sti打开中断。解决方案是重新编程 8259A把 IRQ0 映射到 IDT 的 32 号中断IRQ1 映射到 33 号然后再打开中断。第二个高频问题是 Page Fault。页错误发生时CPU 会把出错地址放到 CR2 寄存器。我写过一个小函数在异常处理里打印 CR2、错误码和当前 EIP这样很快就能定位是空指针解引用还是页表项权限不对。之前碰到一个很隐蔽的问题内核创建用户进程时只映射了用户代码页没映射用户栈页。进程一调用函数栈一压栈就触发缺页而且因为处理缺页的代码本身也在该页上直接双重故障系统重新启动。后来在page.c里为每个用户进程默认分配一页栈问题才消失。调度器问题最典型的表现是“系统跑起来后只显示一个进程在动另一个进程完全不执行”。我排查了很久最后在context_switch里发现切换时没有保存 GDT 选择子。虽然我的所有段基址都是 0选择子不变但在调试打印时误改了 DS导致恢复现场后数据访问出了问题。为了避免这类低级错误后来的上下文切换统一改为保存所有通用寄存器并且用 C 函数只负责换栈不做其他操作。4.3 调试工具与心法内核调试最痛苦的是看不到变量。给内核写printf又不现实所以我第一件事就是打通串口输出。在串口初始化函数里把 0x3F8 这个 I/O 端口配置好然后用outb(0x3f8, ch)输出一个字节。QEMU 启动时加-serial stdio所有日志就能在终端看到。还可以用 QEMU 的 GDB 调试接口一条命令就能把 QEMU 变成一个小型调试服务器qemu-system-i386 -s -S -fda os.img-s表示在 TCP 1234 端口监听 GDB-S表示启动后暂停等待调试器连接。然后在另一个终端里运行gdb kernel.elf (gdb) target remote :1234 (gdb) b main (gdb) c这里要注意kernel.elf必须带符号不能用kernel.bin。内核被加载到 0x10000GDB 需要知道符号地址所以链接时保留 ELF 文件非常关键。我一开始图省事只留着二进制结果在 GDB 里看main函数完全是空的。还有一个心法每次只改一件事。内核开发里多个 bug 经常同时出现如果一口气改了分页和调度再出问题就根本不知道是哪个改动引起的。我在 git 里保留了一个stable-min分支任何时候只要系统能启动、时钟中断能打印 tick就提交一次并打 tag。所有新功能都放在另外一个分支上跑通了再合回主分支。这种流程上的“笨办法”帮我节省了至少一倍时间。5. 经验心得与后续扩展5.1 实测有效的学习路径如果完全从零开始我建议按下面这条链路走顺序不要乱。第一步先别急着写代码花三天时间看懂一个最简引导扇区。自己反复修改字符输出的颜色看串口和屏幕上有什么变化。第二步把时钟中断和串口输出打通。这一步做完你就拥有了一个“活着的内核”它会每隔一段时间吐一个字符出来。这种正反馈非常重要能支撑你继续往下写。第三步实现物理内存管理。先做位图分配器把物理页一个个分配出来打印每次分配的结果。第四步再分页。把内核自身映射到高地址用户程序映射到低地址可以专门做一个测试验证用户程序访问内核地址时是否触发缺页。第五步进程调度放最后。为什么放最后因为它依赖前面所有基础尤其是时钟中断和内存分页。在实现调度之前得先把“中断返回用户态”这条路径搞清楚。我当时是在实现了fork功能的简化版之后才彻底明白iret在切换进程时的重要性。读书方面我推荐《操作系统概念》配合 xv6 源码再加上《一个操作系统的实现》作为实战参考。但不要贪多每天只留一个半小时盯着一个模块反复看比刷十本书都有用。如果你是在准备“操作系统期末复习”那可以把我前面列的功能清单当作考试大纲逐项对照复习效率会高很多。5.2 从“能启动”到“能上课”这个项目还能怎么扩展山水观心现在还只是一个教学内核但后续扩展空间很大。最直接的方向是补 ELF 加载器。现在我只支持加载固定地址的二进制程序没法运行真正的编译器或者工具链加入 ELF 之后就可以把 C 程序编译成可执行文件再丢进系统里跑。另一个方向是引入用户态库和系统调用封装。现在用户程序还是靠直接调用中断进入内核这相当于把内核的全部能力暴露给了用户。下一步可以做一个libc子集提供printf、open、read这些函数的系统调用包裹让用户程序不要再碰汇编。还有一个我特别想做的实验是把核心模块移植到 RISC-V 上。x86 的保护模式和中断控制器太绕RISC-V 的机器模式、监管者模式区分得更清晰内核代码量还能再减三分之一。如果只是作为课程设计可以给每个模块写实验文档把分页、调度、Shell 命令都变成带编号的实验学生做完一个勾一个会很有成就感。如果你想长期玩我建议把现有的 Shell 命令做成一个独立的命令注册表每次加命令只需要往函数指针数组里添一项这样维护起来非常舒服。这也是我在写完山水观心之后最想回头重构的一处。项目到现在还远谈不上完善但它已经让我把“操作系统”从名词变成了动词。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门