从零自研操作系统:内核、引导、显示引擎与多任务调度实战
2026/8/31 4:05:33 网站建设 项目流程

这次我们来看一个“爆肝”属性拉满的方向:自研操作系统。不是基于 Linux 换皮,不是套个桌面主题美化一下,而是从 boot 引导开始,把内核、显示引擎、系统架构逐层写出来。这个工程的主线概念并不复杂,复杂的点在于:能不能在 QEMU 里把镜像真正跑起来,能不能稳定处理中断,能不能让多任务切换不崩,能不能给你留出继续扩展的空间。如果你正在学习操作系统原理,或者准备做课程设计、毕业设计,想弄清进程、内存、中断这些概念在真实代码里长什么样,这篇文章可以直接收藏。

全文会围绕“自研内核、自研引擎、自研架构”这三个关键词展开。我会先把核心能力做成一张速览表,再讲环境准备、引导程序开发、内核代码编写、显示引擎封装、中断与调度设计、构建启动验证、资源占用观察,最后给出一套常见问题排查清单。整个流程不需要高配 GPU,也不需要专门的开发板,一台普通电脑加 QEMU 虚拟机就够。接下来直接进入正题。

1. 核心能力速览

能力项说明
项目类型自研操作系统内核与架构设计
核心目标从引导扇区开始实现内核、显示引擎、系统架构,不依赖现成内核
内核模块引导程序、中断管理、内存管理、进程调度、显示引擎
硬件平台x86 体系结构,建议先在 QEMU / Bochs 虚拟机中运行
硬件门槛普通 PC 即可,建议 2GB 以上内存;模拟器环境不依赖 GPU 显存
开发语言汇编(NASM)、C(GCC)
构建工具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-i386

3.3 磁盘与端口准备

建议单独建一个工作目录,例如~/myos,后续的源码、中间文件和镜像都放在这个目录下。不需要高端显卡,也不需要独立显存,因为本文示例只用 QEMU 自带的虚拟 VGA 和文本模式,不涉及复杂图形渲染。

另外要注意,QEMU 默认端口和本机没有冲突,但如果后续你启动多个 QEMU 实例做并行测试,建议用-monitor-serial参数区分端口,避免日志串扰。

4. 工程目录与引导程序开发

4.1 目录结构

先建立下面的文件结构:

myos/ ├── boot.asm ├── kernel.c ├── linker.ld ├── Makefile └── README.md

文件说明:

  • boot.asm:512 字节引导扇区,负责初始化段寄存器、加载内核并跳转。
  • 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 bs=512 count=10 >> 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 bs=512 count=10 >> 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调用kclearkputs

void 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 签名xxdhexdump查看镜像最后两个字节检查 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指令
任务切换后寄存器错乱上下文保存不完整打印每次切换的espeip保存全部通用寄存器,不只保存一两个值
gdb 连接不上QEMU 未开启-s -S确认 QEMU 启动参数重新启动qemu-system-i386 -s -S
真机启动失败BIOS 环境差异、镜像格式差异先不要用真机测试在 QEMU 中稳定运行后再考虑 ISO 或 U 盘引导

排查时最有效的动作是“拆分问题”。先确认引导扇区能打印,再确认内核入口能进入,最后再测中断和调度。每层单独验证,不要把多个模块一起改完再调试。

10. 最佳实践与里程碑规划

10.1 分阶段开发路线

自研操作系统最忌讳“一口气写一个完整内核”。建议按里程碑推进:

  • 阶段一:引导扇区输出。
  • 阶段二:进入 32 位保护模式。
  • 阶段三:加载 GDT 和 IDT。
  • 阶段四:实现定时器与键盘中断。
  • 阶段五:物理内存页分配。
  • 阶段六:多任务切换。
  • 阶段七:系统调用接口。
  • 阶段八:用户态切换。
  • 阶段九:图形引擎或简单文件系统。

每个阶段都要保留一个可以编译、可以运行的版本。每完成一个里程碑就打一个 Git tag,例如v0.1-bootv0.2-protected-modev0.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 OKHello, MyOS这一小段跑通,后面所有模块都有了地基。最容易踩的坑是链接地址与加载地址不一致,以及中断处理函数结尾缺少iret。这两个问题在 debug 时会反复出现,排查它们的过程本身就是内核开发最有价值的学习环节。

下一步的方向很多:给内核加入 VBE 图形模式,做一个像素级显示引擎;写一个内存分配器,支持kmalloc;实现用户态进程加载;扩展文件系统;或者把网络协议栈接进来。无论选哪条路,建议先保留当前这套最小可运行镜像,再逐步扩展。自研操作系统是一场长跑,先跑通基础,再谈架构,最后才能谈“可用”。建议收藏备用,后面写内核模块时可以直接对照。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询