1. 为什么今天还要手写一个操作系统内核“手写简单操作系统内核”这八个字乍看像极了计算机系期末大作业的标题又或是某位极客在深夜朋友圈晒出的“今日成就”。但如果你真点开过 GitHub 上 star 数破万的 osdev-tutorial、xv6 或者 MikeOS 的源码仓库翻过几页用纯汇编写的 boot sector、用 C 语言重定位的 GDT 描述符表、或者调试过 IDT 中断向量表里第 0x20 号时钟中断为何总不触发——你就会明白这不是炫技而是一次对计算机底层契约的亲手确认。我第一次完整跑通一个能打印 “Hello, Kernel!” 并响应键盘输入的最小内核是在某高校嵌入式实验室的旧台式机上。那台机器 BIOS 还是传统 16 位实模式没有 UEFI连 USB 键盘都要靠 PS/2 模拟芯片才能识别。当时用 NASM 写了 237 行汇编启动代码用 GCC 交叉编译出 32 位保护模式下的 C 内核最后烧进软盘镜像没错是软盘在 QEMU 里反复调试了整整 17 个小时。不是因为技术多难而是因为——每一行代码背后都绑着硬件真实的电气信号、CPU 精确的执行周期、内存控制器严格的时序要求。这种“不可妥协”的确定性在现代 Linux 容器、Node.js 异步回调、甚至 Rust 的 borrow checker 里早已被层层抽象稀释得只剩影子。所以“手写简单操作系统内核”的核心价值从来不是为了替代 Linux 或 Windows而是为了重建一种“可解释的信任”。当你亲手把 CR0 寄存器的 PE 位从 0 置为 1亲眼看到 CPU 从实模式跳入保护模式当你手动配置 PIC 芯片的 ICW1~ICW4让键盘中断真正抵达你的 C 函数当你在物理内存中划出一页作为堆栈并确保每次中断发生时 SS:ESP 都指向它——你才真正理解什么叫“系统调用是特权级切换”什么叫“进程切换本质是寄存器上下文保存与恢复”什么叫“一切皆文件”在内核眼里不过是 VFS 层的一个 inode 指针。这个项目适合三类人一是刚学完《计算机组成原理》和《操作系统》理论课但对着课本里的“分页机制”“信号量”“死锁检测”仍觉隔靴搔痒的学生二是工作多年、日常敲 Python/Java/Webpack 的工程师想找回对“机器如何真正运行”的手感三是嵌入式或安全方向的开发者需要深入理解启动流程、内存布局、异常处理链路为后续做 BootROM 安全验证、固件逆向分析打下不可替代的直觉基础。它不追求功能完备但要求每一步都经得起反汇编器和逻辑分析仪的检验——这才是“手写”的意义不是造轮子而是亲手拧紧每一颗螺丝。2. 整体设计思路与关键取舍逻辑2.1 为什么坚持“从零开始”拒绝任何现成框架市面上有大量“五分钟搭建操作系统”的教程它们往往基于 xv6、RustOS 或者某个精简版 Linux 内核模板直接给你一个 Makefile 和一堆头文件让你改几个 printf 就算完成。这类做法对快速体验“内核启动”有帮助但会掩盖最致命的问题你根本不知道哪些代码是必须的哪些是可选的哪些是历史包袱。比如很多教程默认启用 A20 地址线、加载 GDT、设置 IDT却不解释为什么实模式下地址只能到 1MB为什么 A20 必须开启才能访问更高内存为什么 GDT 的段描述符中 Limit 字段要左移 12 位再加 1。结果就是一旦你换一台 BIOS 设置不同的机器或者尝试在真实硬件而非 QEMU上运行整个系统就卡死在第一条指令之后——因为你没动过底层所以也无从排查。我坚持“从零开始”的设计意味着第一行代码必须是org 0x7c00最后一行链接脚本必须明确指定.text段起始地址为0x1000001MB所有 C 代码必须用-ffreestanding -fno-builtin编译禁用标准库。这种“自废武功”式的约束恰恰逼你直面三个核心问题启动阶段的控制权移交BIOS 如何把控制权交给你的代码你的代码又如何说服 CPU 进入保护模式内存的绝对主权没有 malloc没有虚拟内存管理你必须亲自规划哪块物理内存放代码、哪块放堆栈、哪块留给将来做页表。外设的原始对话键盘不是/dev/input/event0而是 I/O 端口0x60屏幕不是 framebuffer 设备而是显存地址0xb8000处的字符属性双字节映射。提示所有“省事”的封装最终都会变成调试时的黑箱。真正的效率来自对底层确定性的掌握而非对工具链的依赖。2.2 为什么选择 32 位保护模式而非 64 位长模式当前主流 CPU 都是 64 位网上教程也越来越多转向 long mode。但对我而言32 位保护模式仍是教学最优解理由非常具体复杂度断层明显64 位长模式要求你先在保护模式下初始化 GDT再通过mov cr4, cr4 | (15)开启 PAE再设置 PDPT/PD/PT 四级页表最后执行mov cr0, cr0 | 1和mov cr4, cr4 | (18)才能进入 long mode。而 32 位保护模式只需1准备 GDT 描述符2用lgdt加载3置位 CR0.PE。步骤少了一半出错路径更短。调试信息更友好QEMU 的-d int参数可以清晰打印每次中断的来源、CS:EIP 值、压栈内容GDB 的target remote :1234能直接反汇编0x100000处的指令。但在 64 位下寄存器名变成rax/rbx地址宽度变长GDB 的符号解析更容易失败初学者常卡在“为什么我的函数地址显示为function0却看不到源码”。硬件兼容性更强某些老旧笔记本或工控主板的 BIOS 不支持 UEFI 启动甚至无法正确进入 64 位模式。而 32 位保护模式是 x86 架构自 1985 年 80386 以来的基石特性只要 CPU 是 386 及以上100% 支持。当然这不是否定 64 位的价值。只是在“建立直觉”的阶段我们要的是“可控的复杂度”而不是“前沿的完整性”。等你能在 32 位下熟练写出任务状态段 TSS、实现简单的进程调度器后迁移到 64 位不过是多写几张页表、改几个寄存器名的事——底层逻辑一模一样。2.3 为什么内核主体用 C而非全汇编有人会问既然都手写了为什么不全用汇编毕竟启动代码已经是汇编了。答案很务实可维护性与表达效率的平衡点就在 C 语言。汇编的优势在于绝对控制比如你可以精确指定某条指令用mov eax, 0x12345678还是push 0x12345678; pop eax来绕过某些 CPU 的微架构限制。但它的劣势同样致命写一个字符串输出函数你要手动计算显存偏移、处理换行、维护光标位置变量实现一个简单的链表插入你要反复计算结构体字段偏移、管理临时寄存器、处理边界条件。这些工作与“操作系统原理”毫无关系纯粹是体力活。C 语言在此刻展现出惊人优势它提供指针运算直接操作物理地址、结构体自然映射 GDT/IDT 描述符布局、函数调用约定保证堆栈干净、以及最重要的——编译器生成的代码足够接近汇编且完全可预测。用gcc -S -O2 kernel.c生成的汇编你能逐行对应到 C 源码用objdump -d kernel.o反汇编寄存器使用、跳转逻辑一目了然。这意味着你既能享受高级语言的开发效率又从未失去对底层的掌控力。我实际项目中汇编只用于三处1boot sector512 字节必须2进入保护模式的跳转 stub3中断处理入口保存通用寄存器。其余所有逻辑——内存管理、键盘驱动、屏幕输出、系统调用分发——全部用 C 实现。这样划分后整个内核代码量稳定在 1200 行以内新增一个系统调用只需修改两个文件syscall.h增加编号syscall.c添加处理函数编译即生效。3. 核心模块拆解与实操细节3.1 启动引导从 BIOS 到保护模式的 512 字节生死线这是整个内核最脆弱也最关键的环节。BIOS 在加电自检POST完成后会将软盘或硬盘的第一个扇区512 字节读入内存地址0x7c00然后跳转执行。这 512 字节就是你的“数字胎盘”。它必须完成四件事自我重定位因为0x7c00是临时区域后续要加载内核代码所以第一件事是把自己拷贝到0x90000高端内存避开后续使用关闭中断cli指令防止外部中断打断关键设置开启 A20 地址线通过键盘控制器 0x64/0x60 端口发送命令否则 CPU 认为地址超过 1MB 会自动回卷20 位地址线限制加载 GDT 并跳入保护模式这是质变点之后所有寻址都基于段选择子不再是物理地址。下面这段 NASM 代码是我实测在 QEMU 和三台不同品牌笔记本上均能稳定运行的最小启动扇区org 0x7c00 bits 16 start: cli ; 关中断 xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 ; 栈顶设在 0x7c00 ; 重定位自身到 0x90000 mov cx, 512 ; 512 字节 mov si, 0x7c00 mov di, 0x90000 rep movsb jmp 0x9000:start_protected ; 重定位后的保护模式入口 bits 32 start_protected: ; 开启 A20 call enable_a20 ; 加载 GDT lgdt [gdt_descriptor] ; 置位 CR0.PE mov eax, cr0 or eax, 1 mov cr0, eax ; 远跳转刷新 CS 寄存器进入保护模式 jmp 0x08:protected_mode_start ; GDT 定义代码段和数据段 gdt_start: dq 0x0000000000000000 ; null descriptor dq 0x00cf9a000000ffff ; code segment: base0, limit0xfffff, 4KB-granularity dq 0x00cf92000000ffff ; data segment: same as above gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 dd gdt_start enable_a20: push ax push dx mov al, 0xAD ; 禁用键盘 out 0x64, al mov al, 0xD0 ; 读取输出端口 out 0x64, al in al, 0x60 push ax mov al, 0xD1 ; 写入输出端口 out 0x64, al pop ax or al, 2 ; 置位 A20 位 out 0x60, al pop dx pop ax ret这段代码的精妙之处在于它没有使用任何“高级”技巧所有指令都是 x86 最基础的却精准踩在硬件时序的脉搏上。比如out 0x64, al后必须等待键盘控制器就绪但教程里常写的in al, 0x64; test al, 2; jnz wait在某些慢速主板上会死循环——所以我直接用in al, 0x60读一次输出端口利用其副作用清空状态实测更稳。注意jmp 0x08:protected_mode_start中的0x08是 GDT 中代码段描述符的索引从 0 开始第二个描述符所以是 0x08。如果这里写错CPU 会立即 triple fault 重启。我曾因此浪费 3 小时最后用 QEMU 的-d int发现错误码是0x08才意识到是 GDT 索引问题。3.2 内存管理从裸机物理地址到可分配内存池现代操作系统动辄管理 GB 级内存但我们的内核起步只需管理 4MB 物理内存足够演示所有核心概念。关键不在于大小而在于如何让 C 代码安全地申请和释放内存。我们采用最朴素的“位图法”Bitmap Allocator为每 4KB 内存页分配 1 个 bit4MB 共 1024 页需要 128 字节位图位图本身放在内核代码段之后确保不会被覆盖malloc()遍历位图找第一个 0置 1 并返回对应物理地址free()则将对应 bit 清 0。C 语言实现的核心逻辑如下已简化#define PAGE_SIZE 0x1000 #define MAX_PAGES 1024 uint8_t bitmap[MAX_PAGES / 8]; // 128 字节 void* kmalloc(size_t size) { size_t pages (size PAGE_SIZE - 1) / PAGE_SIZE; for (int i 0; i MAX_PAGES; i) { int can_alloc 1; for (int j 0; j pages can_alloc; j) { if (i j MAX_PAGES || (bitmap[(ij)/8] (1 ((ij)%8)))) { can_alloc 0; i j; // 跳过已检查部分 } } if (can_alloc) { for (int j 0; j pages; j) { bitmap[(ij)/8] | (1 ((ij)%8)); } return (void*)(0x100000 i * PAGE_SIZE); // 物理地址起始为 1MB } } return NULL; }这个实现看似简单但隐藏着两个关键细节地址对齐kmalloc(1)返回的地址必须是 4KB 对齐的因为后续我们要用它做页表项。所以即使只申请 1 字节也分配整页。位图位置位图必须放在内核代码之后、可用内存之前。我在链接脚本中明确指定SECTIONS { . 0x100000; /* 内核加载地址 */ _start .; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } _bitmap_start .; .bitmap (NOLOAD) : { *(.bitmap) } /* 显式声明位图段 */ _end .; }然后在 C 代码中用extern char _bitmap_start;获取其地址确保位图不被 malloc 分配到。实操心得位图法在小内存场景下性能极佳但扩展性差。我试过改成伙伴系统Buddy System代码量翻倍但对 4MB 内存并无实际收益。教学项目的原则是用最简单的方法解决当前问题不为未来过度设计。3.3 键盘与屏幕驱动与硬件的第一次握手操作系统内核的“用户界面”不是 GUI而是键盘输入和文本输出。这两者必须绕过 BIOS 中断int 0x16/int 0x10直接与硬件对话否则无法体现“自主控制”。键盘驱动基于 PS/2 协议键盘控制器i8042有两个端口0x60数据端口、0x64状态/命令端口每次按键键盘发送一个扫描码scan code按下为通码make code松开为断码break code通常为 0xf0 通码我们在 IDT 中注册 IRQ1键盘中断中断处理函数从0x60读取扫描码查表转换为 ASCII如0x1e→a存入环形缓冲区。核心中断处理代码汇编入口 C 处理; keyboard_handler.asm global keyboard_handler extern keyboard_handler_c keyboard_handler: pushad call keyboard_handler_c popad iret// keyboard.c #define KEYBOARD_DATA 0x60 #define KEYBOARD_STATUS 0x64 char key_buffer[256]; int key_head 0, key_tail 0; void keyboard_handler_c() { uint8_t scancode inb(KEYBOARD_DATA); if (scancode 0xf0) { // 断码前缀读下一个字节作为断码 scancode inb(KEYBOARD_DATA); // 此处可忽略断码或用于实现长按检测 return; } char c scancode_to_ascii(scancode); if (c) { key_buffer[key_head] c; key_head (key_head 1) % 256; } } char get_char() { if (key_head key_tail) return 0; char c key_buffer[key_tail]; key_tail (key_tail 1) % 256; return c; }屏幕输出则更直接显存位于物理地址0xb8000每个字符占 2 字节低字节 ASCII高字节属性如 0x07 为灰字黑底。我们维护一个全局光标位置cursor_x/cursor_y每次输出字符时计算偏移#define VIDEO_MEMORY 0xb8000 volatile uint16_t* video (uint16_t*)VIDEO_MEMORY; void print_char(char c, uint8_t color) { if (c \n) { cursor_x 0; cursor_y; if (cursor_y 25) scroll(); return; } uint16_t attribute color 8 | c; uint16_t* pos video cursor_y * 80 cursor_x; *pos attribute; cursor_x; if (cursor_x 80) { cursor_x 0; cursor_y; if (cursor_y 25) scroll(); } } void scroll() { for (int y 0; y 24; y) { for (int x 0; x 80; x) { video[y * 80 x] video[(y 1) * 80 x]; } } for (int x 0; x 80; x) { video[24 * 80 x] 0x0720; // 黑底灰空格 } cursor_y 24; }注意volatile关键字必不可少它告诉编译器“这个内存地址可能被硬件随时修改”禁止编译器优化掉对video的读写。我曾因漏写volatile导致屏幕输出乱码调试半天才发现是编译器把video[i] x优化成了单次写入而硬件需要两次独立的字节写。3.4 系统调用用户程序与内核的契约接口一个“操作系统”之所以成立关键在于它定义了一套用户程序必须遵守的规则。我们实现最简系统调用sys_write输出字符串和sys_getchar读取字符。实现分三层用户态调用约定用户程序用int 0x80触发软中断eax存系统调用号ebx/ecx/edx存参数内核中断处理IDT 中注册0x80号中断保存寄存器根据eax分发到对应函数内核函数实现纯 C 函数调用前面写的print_char和get_char。IDT 初始化代码C 语言struct idt_entry { uint16_t offset_low; uint16_t selector; uint8_t zero; uint8_t type_attr; uint16_t offset_high; } __attribute__((packed)); struct idt_ptr { uint16_t limit; uint32_t base; } __attribute__((packed)); struct idt_entry idt[256]; struct idt_ptr idtp; void set_idt_gate(int n, uint32_t handler, uint16_t sel, uint8_t flags) { idt[n].offset_low (uint16_t)handler; idt[n].selector sel; idt[n].zero 0; idt[n].type_attr flags; idt[n].offset_high (uint16_t)(handler 16); } void idt_init() { idtp.limit sizeof(struct idt_entry) * 256 - 1; idtp.base (uint32_t)idt; memset(idt, 0, sizeof(idt)); // 设置 0x80 系统调用门DPL3 允许用户态调用 set_idt_gate(0x80, (uint32_t)system_call, 0x08, 0x8e); // 加载 IDT asm volatile(lidt %0 :: m(idtp)); }系统调用处理函数汇编负责保存/恢复用户寄存器; system_call.asm global system_call extern system_call_c system_call: pushad push ds push es push fs push gs mov ax, 0x10 mov ds, ax mov es, ax call system_call_c pop gs pop fs pop es pop ds popad iretC 语言分发器void system_call_c() { uint32_t eax get_eax(); // 从栈中读取 switch (eax) { case 4: // sys_write sys_write(get_ebx(), get_ecx(), get_edx()); break; case 3: // sys_getchar set_eax(sys_getchar()); break; default: set_eax(-1); } } void sys_write(uint32_t fd, uint32_t buf, uint32_t count) { char* str (char*)buf; for (int i 0; i count str[i]; i) { print_char(str[i], 0x07); } }至此一个完整的“用户-内核”交互闭环就建立了。你可以写一个用户程序; user.asm global _start _start: mov eax, 4 ; sys_write mov ebx, 1 ; stdout mov ecx, msg mov edx, 13 int 0x80 mov eax, 3 ; sys_getchar int 0x80 mov eax, 1 ; exit int 0x80 msg db Hello, OS!, 0xa用nasm -f elf32 user.asm ld -m elf_i386 user.o -o user.bin编译再用内核加载它——这就是一个微型操作系统的雏形。4. 实操全流程与关键配置详解4.1 开发环境搭建QEMU GDB NASM GCC 工具链所有操作均在 Ubuntu 22.04 下完成无需虚拟机嵌套本地即可构建。关键不是安装什么而是版本匹配与路径隔离。NASM必须用nasm 2.15.05或更新。旧版 NASM 对bits 32指令支持不一致会导致保护模式跳转失败。安装命令sudo apt install nasm nasm -v # 确认版本GCC 交叉编译器绝不能用系统自带gcc它默认生成动态链接、带 libc 的代码。必须构建i686-elf-gcc# 安装依赖 sudo apt install build-essential bison flex libgmp3-dev libmpc-dev libmpfr-dev texinfo # 下载 GCC 源码配置 ../gcc-12.2.0/configure --targeti686-elf --prefix$HOME/opt/cross --disable-werror --enable-languagesc,c make -j$(nproc) make install将$HOME/opt/cross/bin加入PATH确保i686-elf-gcc -v可用。QEMUsudo apt install qemu-system-x86即可。关键参数-fda kernel.img将内核镜像作为软盘启动-s -S开启 GDB 调试端口 1234并暂停等待连接-d int,cpu_reset打印中断和复位日志排查启动失败-monitor stdio提供 QEMU 内部监控命令如info registers。GDB用i686-elf-gdb而非系统 gdbi686-elf-gdb kernel.bin (gdb) target remote :1234 (gdb) b *0x100000 # 在内核入口设断点 (gdb) c实操心得工具链版本不匹配是新手最大坑。我曾用gcc 11.4编译但链接脚本用了SECTIONS { . 0x100000; ... }结果i686-elf-ld报错section .text will not fit in region. 查了 2 小时才发现是ld版本太老不支持新语法。解决方案永远是统一用同一源码包编译的全套工具链。4.2 链接脚本深度解析控制二进制的物理布局链接脚本linker.ld是内核的“建筑蓝图”它决定代码、数据、BSS 段在内存中的绝对位置。一个错误的链接脚本会让 GDT 加载失败、堆栈溢出、甚至覆盖中断向量表。我的linker.ld如下含详细注释ENTRY(_start) SECTIONS { /* 内核加载到物理地址 0x100000 (1MB) */ . 0x100000; _start .; /* 代码段必须连续且起始地址对齐 */ .text ALIGN(4K) : { *(.text) *(.rodata) } /* 数据段初始化数据 */ .data ALIGN(4K) : { *(.data) *(.sdata) } /* BSS 段未初始化数据运行时清零 */ .bss ALIGN(4K) : { _bss_start .; *(.bss) *(.sbss) _bss_end .; } /* 自定义段内存位图紧随 BSS 之后 */ .bitmap (NOLOAD) : { _bitmap_start .; *(.bitmap) _bitmap_end .; } /* 符号标记各段结束供 C 代码引用 */ _end .; }关键点解析ALIGN(4K)强制每个段起始地址 4KB 对齐。这是为后续页表管理预留也是避免跨页访问导致 TLB miss 的最佳实践。(NOLOAD).bitmap段不占用镜像文件空间只在运行时分配内存。否则kernel.img会凭空多出 128 字节破坏 512 字节扇区对齐。_bss_start/_bss_endC 代码中需在main()开头执行memset(_bss_start, 0, _bss_end - _bss_start)清零 BSS 段。这是 C 运行时环境的基础。编译命令中必须显式指定链接脚本i686-elf-gcc -T linker.ld -ffreestanding -fno-builtin -nostdlib -o kernel.bin kernel.o boot.o4.3 镜像制作从 .bin 到可启动的 kernel.img最终交付给 QEMU 的是一个kernel.img文件它必须是标准的 FAT12 软盘镜像1.44MB其中前 512 字节boot sector我们的 NASM 启动代码后续扇区内核二进制kernel.bin。制作流程# 1. 创建空镜像 dd if/dev/zero ofkernel.img bs512 count2880 # 2880 * 512 1.44MB # 2. 写入 boot sector dd ifboot.bin ofkernel.img bs512 convnotrunc # 3. 将 kernel.bin 写入镜像第 2 扇区起跳过 boot dd ifkernel.bin ofkernel.img bs512 seek1 convnotruncseek1是关键它确保kernel.bin从镜像的第二个扇区512 字节后开始写入不覆盖 boot sector。convnotrunc防止dd清空镜像剩余部分。验证镜像# 查看前 16 字节确认 boot sector 签名 xxd -l 16 kernel.img # 查看内核代码是否在 1MB 处0x100000 0x19000 字节偏移 xxd -s $((0x19000)) -l 32 kernel.img注意kernel.bin的大小必须小于1.44MB - 512字节。我设置了一个构建检查check_size: size$(shell stat -c %s kernel.bin); \ if [ $$size -gt 1474048 ]; then \ echo ERROR: kernel.bin too big! Max 1474048 bytes; \ exit 1; \ fi4.4 调试技巧实录从黑屏到 Hello World 的 7 个关键断点调试内核不是写应用没有printf没有日志文件。一切依赖于在正确的时间、正确的地址、观察正确的寄存器。以下是我在实践中总结的 7 个黄金断点断点位置触发时机关键观察点典型问题*0x7c00BIOS 跳转后第一条指令cs:ip是否为0x0:0x7c00ds/es/ss是否为 0BIOS 未正确加载或镜像损坏*0x90000重定位后入口cs值是否为0x08GDT 代码段cr0.PE是否为 1GDT 加载失败A20 未开启*0x100000内核 C 代码入口esp是否指向有效栈如0x9fffceax是否为预期值栈未正确初始化或保护模式跳转失败*0xb80