
这次我们来看一个“爆肝”属性拉满的方向自研操作系统。不是基于 Linux 换皮不是套个桌面主题美化一下而是从 boot 引导开始把内核、显示引擎、系统架构逐层写出来。这个工程的主线概念并不复杂复杂的点在于能不能在 QEMU 里把镜像真正跑起来能不能稳定处理中断能不能让多任务切换不崩能不能给你留出继续扩展的空间。如果你正在学习操作系统原理或者准备做课程设计、毕业设计想弄清进程、内存、中断这些概念在真实代码里长什么样这篇文章可以直接收藏。全文会围绕“自研内核、自研引擎、自研架构”这三个关键词展开。我会先把核心能力做成一张速览表再讲环境准备、引导程序开发、内核代码编写、显示引擎封装、中断与调度设计、构建启动验证、资源占用观察最后给出一套常见问题排查清单。整个流程不需要高配 GPU也不需要专门的开发板一台普通电脑加 QEMU 虚拟机就够。接下来直接进入正题。1. 核心能力速览能力项说明项目类型自研操作系统内核与架构设计核心目标从引导扇区开始实现内核、显示引擎、系统架构不依赖现成内核内核模块引导程序、中断管理、内存管理、进程调度、显示引擎硬件平台x86 体系结构建议先在 QEMU / Bochs 虚拟机中运行硬件门槛普通 PC 即可建议 2GB 以上内存模拟器环境不依赖 GPU 显存开发语言汇编NASM、CGCC构建工具nasm、gcc、ld、make、qemu-system-i386、gdb启动方式生成软盘/硬盘镜像通过 QEMU 加载启动接口能力自研内核 API / 系统调用注册机制任务能力简单多任务 / 时间片轮转调度适合场景操作系统原理学习、内核编程实践、课程设计、毕设这张表的重点不是“这个系统能像 Windows 一样日常使用”而是“从零开始能不能把内核跑起来”。标题里说的自研内核、自研引擎、自研架构落到代码层面分别对应引导与内核模块、显示输出模块、系统调用与任务调度框架。三者是递进关系先把引导做通再把输出做亮最后把调度做稳。从硬件门槛看这类项目最大的开销是开发时间和调试成本不是电脑配置。QEMU 软件模拟 x86 环境足够跑通本文的示例所以即使是核显笔记本或云服务器都没有问题。更稳妥的做法是先在 QEMU 中验证每一步等阶段稳定后再考虑是否放到真实硬件上启动。2. 适用场景与使用边界2.1 这个项目适合谁第一类读者是正在学“操作系统”课程的学生。课本上讲进程状态、页表、中断向量、调度算法时如果只停留在幻灯片层面很难真正理解。自己动手写一遍引导、中断、内存管理概念会立刻变得具体。第二类读者是想做课程设计或毕设的人。与其选用一个裁剪过的 Linux 然后只写调研报告不如从一个最小内核开始逐步实现文字输出、物理内存页管理、任务调度和简单的系统调用接口。这种项目的完成度更容易展示也更有区分度。第三类读者是对计算机底层感兴趣的开发人员。平时写业务代码写多了可能已经很久没有关注过程序是怎么被加载进内存、中断是怎么进入内核的。自研操作系统正好可以把这些知识串起来。2.2 这个项目不适合什么它不适合作为日常使用的操作系统也不适合替代 Windows、Linux 或 macOS。不要指望在自研内核上跑安卓应用、运行 Office 或联网办公。这里做的“系统”本质是一个内核实验工程能力边界非常清晰能输出字符、能响应定时器中断、能切换任务已经算很成功了。它也不适合在没有任何操作系统基础的情况下直接硬啃。建议至少知道进程、内存地址、中断这几个名词并且写过几段 C 语言代码。完全零基础的话建议把《操作系统原理》的进程管理和内存管理章节先过一遍。2.3 合规与安全边界自研操作系统涉及底层汇编、GCC 交叉编译和引导程序所有验证都应优先在 QEMU 虚拟机中进行。不要把尚未稳定的内核直接刷写到真实电脑上以免造成引导损坏、数据丢失。如果参考了开源内核代码、书籍或别人的博客必须保留原协议声明标注出处。涉及 BIOS 中断、VGA 显存、磁盘读写等硬件操作时也只应在自己的实验环境和授权设备上测试。不能把自研内核用于绕过系统安全机制、破坏其他设备或侵犯第三方版权的场景。3. 环境准备与开发依赖3.1 开发环境选择建议在 Linux 环境下完成Ubuntu 22.04 / Debian 或 WSL 都可以。macOS 也支持但个别工具链参数需要调整。Windows 原生环境建议用 WSL 或 MSYS2否则 nasm、qemu 的路径和权限问题会分散精力。开发语言只需要两套工具链NASM把 boot.asm 编译成裸二进制文件。GCC 与 ld把 kernel.c 编译、链接成可加载的二进制镜像。make串联整个构建流程。QEMU模拟 x86 机器启动生成的镜像。gdb内核调试配合 QEMU 的 gdbstub 使用。3.2 安装命令在 Ubuntu / Debian 下执行下面的命令安装全部依赖sudo apt update sudo apt install -y nasm gcc make qemu-system-x86 gdb安装完成后检查工具版本是否正常nasm -v gcc --version qemu-system-i386 --version make --version还有一种常见情况在某些精简系统里qemu-system-x86可能不会自动带qemu-system-i386。可以单独安装sudo apt install -y qemu-system-i3863.3 磁盘与端口准备建议单独建一个工作目录例如~/myos后续的源码、中间文件和镜像都放在这个目录下。不需要高端显卡也不需要独立显存因为本文示例只用 QEMU 自带的虚拟 VGA 和文本模式不涉及复杂图形渲染。另外要注意QEMU 默认端口和本机没有冲突但如果后续你启动多个 QEMU 实例做并行测试建议用-monitor、-serial参数区分端口避免日志串扰。4. 工程目录与引导程序开发4.1 目录结构先建立下面的文件结构myos/ ├── boot.asm ├── kernel.c ├── linker.ld ├── Makefile └── README.md文件说明boot.asm512 字节引导扇区负责初始化段寄存器、加载内核并跳转。kernel.c内核入口代码负责初始化显存并输出字符。linker.ld链接脚本指定内核加载地址和内存布局。Makefile把源码编译成可启动镜像 os.img。4.2 引导扇区 boot.asm引导扇区是 BIOS 启动后加载到物理地址0x7C00的代码。它的大小必须正好是 512 字节且最后两个字节为0x55AA否则 BIOS/QEMU 会认为这个镜像不可引导。[org 0x7c00] [BITS 16] start: mov ax, cs mov ds, ax mov es, ax mov [boot_drive], dl ; 使用 BIOS 中断读取磁盘把内核从第二扇区加载到 0x1000:0x0000 mov ax, 0x1000 mov es, ax xor bx, bx mov ah, 0x02 ; 读扇区 mov al, 4 ; 读入扇区数示例取 4 mov ch, 0 ; 磁道号 mov cl, 2 ; 起始扇区从第二扇区开始 mov dh, 0 ; 磁头号 mov dl, [boot_drive] int 0x13 mov si, boot_msg print_boot_msg: lodsb or al, al jz jump_to_kernel mov ah, 0x0e int 0x10 jmp print_boot_msg jump_to_kernel: jmp 0x1000:0x0000 boot_msg db Boot OK, 0 boot_drive db 0 times 510-($-$$) db 0 dw 0xaa55这段代码的原理是先记录 BIOS 传来的启动盘号dl。用int 0x13读磁盘扇区把内核从镜像偏移 512 字节处读到物理地址0x10000。打印 “Boot OK” 提示引导成功。通过jmp 0x1000:0x0000跳转到内核入口。times 510-($-$$) db 0的作用是填充前面区域到 510 字节最后用dw 0xaa55补齐引导签名。4.3 链接脚本 linker.ld链接脚本决定内核代码放在内存的哪个位置。这里让内核起始地址对应物理地址0x10000与引导程序加载地址保持一致。ENTRY(kernel_main) SECTIONS { . 0x10000; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } }如果链接地址和加载地址不一致跳转进内核后大概率直接崩溃。这个问题后面也会在常见问题排查表里重点强调。4.4 内核入口 kernel.c内核入口不依赖任何 C 标准库直接操作 VGA 显存。__attribute__((section(.text))) void kernel_main(void) { const char *msg Hello, MyOS; char *video (char *)0xb8000; int i 0; while (msg[i] ! \0) { video[i * 2] msg[i]; video[i * 2 1] 0x07; i; } while (1) { __asm__ volatile (hlt); } }这里的0xB8000是 x86 文本模式显存缓冲区起始地址。每个字符占两个字节第一个字节是 ASCII 码第二个字节是颜色属性。0x07表示白字黑底。4.5 Makefile 构建脚本AS nasm CC gcc LD ld all: os.img boot.bin: boot.asm $(AS) -f bin boot.asm -o boot.bin kernel.o: kernel.c $(CC) -m32 -ffreestanding -fno-pie -fno-stack-protector -c kernel.c -o kernel.o kernel.bin: kernel.o linker.ld $(LD) -m elf_i386 -T linker.ld --oformat binary kernel.o -o kernel.bin os.img: boot.bin kernel.bin cat boot.bin kernel.bin os.img dd if/dev/zero bs512 count10 os.img run: os.img qemu-system-i386 -fda os.img clean: rm -f boot.bin kernel.o kernel.bin os.img这里有几个关键点-m32表示生成 32 位代码。-ffreestanding告诉 GCC 不使用宿主系统的启动代码和标准库。--oformat binary让 ld 输出裸二进制而不是 ELF 可执行文件。cat boot.bin kernel.bin把引导扇区和内核拼接成同一个镜像。dd if/dev/zero bs512 count10 os.img给镜像填充剩余空间确保 QEMU 以软盘镜像方式读取时不会越界。构建并启动make clean make make run如果一切正常QEMU 窗口里会先显示 “Boot OK”随后屏幕左上角显示 “Hello, MyOS”。这一步跑通说明“从引导到内核入口”的链路已经打通。5. 显示引擎让内核输出文字5.1 显存操作原理操作系统在文本模式下的“显示引擎”本质是向显存缓冲区写数据。x86 的文本模式缓冲区从物理地址0xB8000开始每两个字节对应屏幕上一个字符位。字符位置可以按行 * 80 列计算因为一屏默认是 80 列、25 行。示例#define VIDEO_ADDR 0xb8000 #define SCREEN_COLS 80 void kputc(char c, int row, int col, char color) { char *video (char *)VIDEO_ADDR; int offset (row * SCREEN_COLS col) * 2; video[offset] c; video[offset 1] color; }5.2 封装显示 API自研一个简单的kputs支持换行和清屏。#define VIDEO_ADDR 0xb8000 #define SCREEN_COLS 80 #define SCREEN_ROWS 25 static char *video (char *)VIDEO_ADDR; static int cursor_row 0; static int cursor_col 0; void kclear(void) { int i; for (i 0; i SCREEN_COLS * SCREEN_ROWS * 2; i 2) { video[i] ; video[i 1] 0x07; } cursor_row 0; cursor_col 0; } void kputc(char c) { if (c \n) { cursor_row; cursor_col 0; return; } if (cursor_col SCREEN_COLS) { cursor_col 0; cursor_row; } if (cursor_row SCREEN_ROWS) { cursor_row 0; } int offset (cursor_row * SCREEN_COLS cursor_col) * 2; video[offset] c; video[offset 1] 0x07; cursor_col; } void kputs(const char *str) { int i 0; while (str[i] ! \0) { kputc(str[i]); i; } }这个“引擎”虽然简单但已经具备了输出能力。后续做 GUI 图形引擎时可以把绘制像素点封装成底层接口再在这上面做矩形、位图、字体渲染。5.3 验证输出修改kernel_main调用kclear和kputsvoid kernel_main(void) { kclear(); kputs(Hello, MyOS\n); kputs(Stage 1: boot and display engine\n); while (1) { __asm__ volatile (hlt); } }重新编译运行屏幕会按行显示文字。如果输出乱码优先检查颜色属性字节和光标位置计算逻辑。如果屏幕空白优先检查显存地址是否被错误覆盖。6. 中断、内存与多任务调度设计引导和显示跑通后下一步是给系统注入“动态能力”中断响应、物理内存管理和任务切换。这部分是自研架构最核心的部分。6.1 GDT 与 IDT进入 32 位保护模式后CPU 需要查 GDT全局描述符表才能确定段权限需要查 IDT中断描述符表才能把键盘、定时器等硬件中断路由到处理函数。GDT 的任务是定义内核代码段、内核数据段。IDT 的任务是把中断向量号映射到isr_handler这样的 C 函数入口。常见的做法是typedef struct { unsigned short offset_low; unsigned short selector; unsigned char zero; unsigned char type_attr; unsigned short offset_high; } __attribute__((packed)) idt_entry_t;IDT 的 vector 0 到 31 是 CPU 异常32 到 47 是 IRQ 中断。定时器通常挂到 vector 32键盘挂到 vector 33。6.2 物理内存管理最简单的物理内存管理是页分配器。用一个位图记录每个 4KB 页是否被占用。分配时从低地址往高地址扫描找到空闲页后把对应位置 1。#define PAGE_SIZE 4096 #define MAX_PAGES 1024 static unsigned char page_bitmap[MAX_PAGES / 8]; void bitmap_set(unsigned int page) { page_bitmap[page / 8] | (1 (page % 8)); } int bitmap_test(unsigned int page) { return page_bitmap[page / 8] (1 (page % 8)); } unsigned int alloc_page(void) { unsigned int i; for (i 0; i MAX_PAGES; i) { if (!bitmap_test(i)) { bitmap_set(i); return i * PAGE_SIZE; } } return 0; }这个实现只演示思路真实内核还需要处理内存探测、多级页表、虚拟地址映射和页错误异常。但作为自研架构的第一版能分配和释放物理页已经足够支撑后续任务栈分配。6.3 多任务调度多任务调度的核心是保存当前任务上下文恢复下一个任务的上下文。上下文至少包括通用寄存器、栈指针esp和指令指针eip。可以用一个任务链表管理多个任务typedef struct task { int id; unsigned int esp; unsigned int eip; struct task *next; } task_t;定时器中断触发时先将当前寄存器和下一条指令地址保存到当前任务结构体然后切换到下一个任务并恢复其寄存器和栈最后执行iret返回。这个机制叫作时间片轮转调度。void timer_handler(void) { current_task-esp get_esp(); current_task-eip get_eip(); current_task current_task-next; set_esp(current_task-esp); jmp_to(current_task-eip); }需要注意这里的jmp_to是示意代码真实实现要处理栈切换和特权级转换。第一次进入用户态任务时需要用iret伪造一个返回现场让 CPU 看起来像是从中断返回从而跳转到任务入口。6.4 系统调用接口系统调用是操作系统提供给上层程序的接口。自研系统可以按编号分发typedef void (*syscall_handler)(void); #define SYSCALL_NUM 16 static syscall_handler syscall_table[SYSCALL_NUM]; void syscall_register(int num, syscall_handler handler) { if (num 0 || num SYSCALL_NUM) { return; } syscall_table[num] handler; } void syscall_dispatch(int num) { if (num 0 || num SYSCALL_NUM || syscall_table[num] 0) { return; } syscall_table[num](); }用户程序通过软中断或syscall指令进入内核根据系统调用号查表执行对应功能。这套机制虽然简陋但已经把“接口能力”从硬件层提升到了软件层后面可以直接扩展文件读写、内存映射等功能。7. 构建启动与效果验证7.1 构建镜像在项目目录执行make clean make如果构建成功会得到os.img。可以用ls -lh os.img查看镜像大小也可以用xxd查看镜像尾部是否存在55 aa引导签名xxd os.img | tail -5正常情况下镜像中既有 boot.bin 的开头也有 kernel.bin 的代码。这就是自研操作系统最原始、最完整的一份“产物”。7.2 使用 QEMU 启动make run等价命令qemu-system-i386 -fda os.img -m 64预期启动过程BIOS 引导到 boot.bin。屏幕打印 “Boot OK”。跳转到内核入口。屏幕显示 “Hello, MyOS” 或后续扩展的启动日志。系统进入hlt空闲循环。判断成功的标准很简单屏幕出现字符串且没有无限重启说明启动链路是通的。7.3 使用 gdb 调试内核遇到跳转崩溃时可以配合 gdb 和 QEMU 调试qemu-system-i386 -fda os.img -m 64 -s -S然后另开终端gdb kernel.o target remote :1234 break kernel_main continue这样可以在kernel_main处打断点查看寄存器、显存内容和内存布局。内核调试的核心是确认eip是否停在预期地址以及esp是否在合法栈空间。7.4 批量验证多个启动参数如果你修改了内存布局或调度实现建议准备一组“最小回归测试”make clean make run qemu-system-i386 -fda os.img -m 32 -no-reboot-no-reboot参数可以防止 QEMU 在崩溃后自动重启方便截图和分析日志。8. 资源占用与性能观察8.1 镜像体积引导扇区固定占 512 字节内核部分的大小取决于编译后的代码量。一个仅包含文本输出和简单中断的内核镜像通常不会超过几十 KB。加入物理内存管理、任务调度和系统调用后镜像体积仍会保持在一个非常小的量级。这个特点与日常使用的操作系统形成强烈对比。8.2 QEMU 内存占用QEMU 默认给虚拟机分配 128MB 内存本文示例把-m 64调低也不会影响运行。观察自研内核真实占用时可以在内核里维护一个内存统计变量打印已分配页数。这样就能直观看到一个内核代码段、数据段、任务栈分别占多少内存。观察方式make run # 在 QEMU 窗口中观察串口输出或屏幕日志如果后续加入了图形引擎内存占用会明显上升因为每个像素点都要占用显存缓冲区。QEMU 的虚拟显卡会把这些缓冲映射到客户机内存中所以不能只看 QEMU 进程本身的内存占用。8.3 构建和启动性能构建本地小型内核的速度很快瓶颈通常出现在代码编辑和调试上。启动过程中QEMU 软件模拟 CPU 会比真机慢但如果只是文本模式和定时器中断几乎不需要等待。真正影响性能的地方是屏幕刷新每打印一行字符都要写显存。内存分配位图扫描在页面数量增大后会有线性开销。调度切换频繁切换任务会增加上下文保存和恢复的次数。降低资源占用的常用方法减少诊断日志输出提高调度时间片使用更高效的内存分配算法或者把字符输出改为带缓冲的批量刷新。9. 常见问题与排查方法问题现象可能原因排查方式解决方案QEMU 无法启动镜像提示 “Boot failed”引导扇区缺少 0x55AA 签名用xxd或hexdump查看镜像最后两个字节检查 boot.asm 的times填充和dw 0xaa55屏幕黑屏无任何输出显存地址错误或段寄存器未初始化用 gdb 查看eip是否进入kernel_main检查0xB8000地址检查 boot.asm 中 ds/es 寄存器显示乱码颜色属性字节设置错误打印字符和属性字节的十六进制值使用0x07白字黑底检查video[i * 2 1]跳转到内核后重启链接地址和加载地址不一致用readelf -s kernel.o对比.text地址确认 linker.ld 中. 0x10000;编译报错 “implicit declaration of function”freestanding 环境没有标准库声明不要include stdio.h只保留必要的类型定义在kernel.c中自行声明内核入口函数中断处理函数触发后系统死机IDT 描述符或 iret 结尾错误检查中断处理函数末尾是否有iret给中断处理函数加上正确的iret指令任务切换后寄存器错乱上下文保存不完整打印每次切换的esp和eip保存全部通用寄存器不只保存一两个值gdb 连接不上QEMU 未开启-s -S确认 QEMU 启动参数重新启动qemu-system-i386 -s -S真机启动失败BIOS 环境差异、镜像格式差异先不要用真机测试在 QEMU 中稳定运行后再考虑 ISO 或 U 盘引导排查时最有效的动作是“拆分问题”。先确认引导扇区能打印再确认内核入口能进入最后再测中断和调度。每层单独验证不要把多个模块一起改完再调试。10. 最佳实践与里程碑规划10.1 分阶段开发路线自研操作系统最忌讳“一口气写一个完整内核”。建议按里程碑推进阶段一引导扇区输出。阶段二进入 32 位保护模式。阶段三加载 GDT 和 IDT。阶段四实现定时器与键盘中断。阶段五物理内存页分配。阶段六多任务切换。阶段七系统调用接口。阶段八用户态切换。阶段九图形引擎或简单文件系统。每个阶段都要保留一个可以编译、可以运行的版本。每完成一个里程碑就打一个 Git tag例如v0.1-boot、v0.2-protected-mode、v0.3-interrupt。后续如果改崩了可以随时退回上一个可用版本。10.2 工程管理建议写操作系统不是只写代码还涉及大量硬件查阅和排错记录。建议在doc/目录下维护一份笔记记录每个寄存器的含义、每次中断调试的结论、每个异常现象的复现步骤。这比单纯写注释更有利于长期维护。代码结构上把启动汇编、显示模块、内存模块、调度模块拆成独立文件myos/ ├── boot/ │ └── boot.asm ├── kernel/ │ ├── kernel.c │ ├── display.c │ ├── memory.c │ └── schedule.c ├── include/ │ ├── display.h │ ├── memory.h │ └── schedule.h ├── linker.ld └── Makefile这样的结构也能让后续扩展更清晰。10.3 合规与授权如果你参考了《30天自制操作系统》《操作系统真象还原》或 GitHub 上的开源内核项目请严格遵守对应的开源许可协议。开源代码的 Copyleft 协议可能要求衍生项目同样开源使用时务必阅读 LICENSE 文件。自研不等于“闭门造车”合理借鉴并标注来源是更专业的行为。11. 总结与下一步这个“爆肝”项目的价值不在于操作系统本身有多完整而在于把操作系统原理变成了可运行、可调试、可扩展的真实代码。最先应该验证的是引导链路从Boot OK到Hello, MyOS这一小段跑通后面所有模块都有了地基。最容易踩的坑是链接地址与加载地址不一致以及中断处理函数结尾缺少iret。这两个问题在 debug 时会反复出现排查它们的过程本身就是内核开发最有价值的学习环节。下一步的方向很多给内核加入 VBE 图形模式做一个像素级显示引擎写一个内存分配器支持kmalloc实现用户态进程加载扩展文件系统或者把网络协议栈接进来。无论选哪条路建议先保留当前这套最小可运行镜像再逐步扩展。自研操作系统是一场长跑先跑通基础再谈架构最后才能谈“可用”。建议收藏备用后面写内核模块时可以直接对照。