简介:MiniOS是一款面向操作系统开发学习者的微型内核实践项目,基于Orange's教学系统改造,可运行在32位x86架构CPU上。资源包含完整的C语言与x86汇编源码、Makefile构建脚本、Bochs/QEMU虚拟机运行及调试配置,目录按boot、include、kernel、lib、command等模块划分,方便对照学习进程管理、内存管理、文件系统与系统调用等核心机制。压缩包共65个文件,以C源码(22个.c、14个.h)和汇编(8个.asm、4个.inc)为主,配套启动镜像、链接映射文件、说明文档等,整体仅166KB,轻量适合初学者快速上手。已有219人学习。通过该项目,读者既可获得一份能直接编译运行的内核骨架,也能依据代码注释和调试指南逐步理解操作系统从开机到进程调度的完整脉络,为后续实用内核开发打下基础。
1. 为什么一个 32 位迷你内核值得拆开看
很多想接触操作系统底层的人一开始就打开 Linux 源码,结果在 arch 目录和驱动树里迷路。MiniOS 是另一种选择:一个能在 QEMU/Bochs 里跑起来的 32 位 x86 教学内核,代码量小到可以通读,但启动、保护模式、进程、内存管理、系统调用和文件系统这些核心部件一个不少。它基于 Orange's 的教学系统,保留了从 boot.asm 到 main.c、再到 fork/exec 的完整链路,C 和汇编的边界非常清楚。适合已经会写 C 但对内核没有整体感的人。拆完这个项目再回头看 Linux 的调度与内存管理,很多概念都能对号入座。
2. 实模式到保护模式:boot.asm 和 loader.asm 的边界
x86 内核的第一步不是 C 函数,而是 BIOS 加载到 0x7C00 的引导扇区。MiniOS 的 a.img 第一个扇区就是 boot 代码,但 512 字节只够做一件事:把后续代码从软盘镜像读进内存。所以项目把引导拆成 boot.asm 和 loader.asm,boot 负责搬运,loader 负责进入保护模式并加载 kernel。这个分工和早期 Linux 的 bootsect/setup 很接近,理解之后再看现代 bootloader 会轻松很多。
2.1 boot.asm 如何把 loader 放进内存
实模式下读取磁盘,最直接的方式是 BIOS 的int 13h。boot.asm 先设置好段寄存器,然后把 loader 读到 0x1000 附近,再跳转过去。典型代码如下:
mov ax, 0 ; 把 ES 段寄存器设为 0 mov es, ax mov ax, 0202h ; AH=02 读扇区, AL=02 读 2 个扇区 mov cx, 0002h ; CH=0 磁道 0, CL=2 扇区号 2 mov dx, 0000h ; DH=磁头 0, DL=驱动器 A mov bx, 1000h ; ES:BX 指向加载地址 0x1000 int 13h jc read_error这里的寄存器是 BIOS 固定约定:AH=0x02 表示读扇区,AL 是扇区数,CH/CL 是磁道与扇区号,DX 是磁头和驱动器,ES:BX 是目标内存地址。跳转看 CF,置位就说明读取失败。实模式下只能访问 20 位物理地址,所以目标地址不能超过 1MB。初学者最容易把 CL 的扇区号写错,或者把 AL 设置得太大导致跨磁道读错,最简单的做法是让引导扇区后面紧跟 loader,并保证镜像结构、扇区偏移一致。
2.2 进入保护模式前先准备好 GDT
loader 接管后的第一件事是打开 A20 地址线。A20 关闭时,访问 1MB 以上的地址会发生绕回,内核代码只要放在 1MB 之外就会神秘跳飞。常见做法是用 BIOSint 15h或操作键盘控制器 0x64/0x60 端口打开 A20,MiniOS 里可以直接照搬。接着需要为 CPU 准备 GDT,GDT 是描述段基址、长度和权限的系统表,通过lgdt指令装入 GDTR,再把 CR0 的第 0 位 PE 置 1,CPU 就进入保护模式。切换后必须立即做一次远跳转:
lgdt [gdtr] mov eax, cr0 or eax, 1 mov cr0, eax jmp dword SELECTOR_KERNEL_CODE:flushjmp dword后面的 SELECTOR_KERNEL_CODE 不是普通地址,而是 GDT 里的段选择子。之所以必须跳转,是因为实模式流水线已经预取指令,且段寄存器缓存还残留 16 位语义,远跳转会强制 CPU 用新的代码段描述符重新计算线性地址。如果漏掉这一步,往往第一条指令就会触发 #GP 异常。
GDT 表本身是一组 8 字节描述符,核心字段如下:
| 字段 | 位数 | 作用 |
|---|---|---|
| Base | 32 位 | 段起始线性地址 |
| Limit | 20 位 | 段大小,单位由 G 位决定 |
| G | 1 位 | 0 为字节粒度,1 为 4KB 粒度 |
| D/B | 1 位 | 决定默认操作数大小,代码段用 D,栈段用 B |
| P | 1 位 | 段是否存在 |
| S | 1 位 | 0 是系统段,1 是代码/数据段 |
| Type | 4 位 | 段类型,可执行、可写、只读等 |
MiniOS 的 GDT 至少需要内核代码段、内核数据段、TSS 和 LDT 描述符。由于每个进程都有独立 LDT,GDT 里只需保留一个 LDT 描述符,调度时把它指向当前进程的 LDT 即可。这些数据结构定义在 include/protect.h,protect.c 负责填表。
2.3 loader 最后的接力:跳到 start.c
GDT 配置好以后,loader 继续从磁盘读取 kernel.bin。这一步和 boot 读 loader 类似,但内核文件更大,需要循环读入。读完还要设置栈顶,再把控制权交给 start.c 或 start.asm 里的入口。这里有一个容易忽略的点:链接脚本必须保证入口代码位于内核镜像的开头,否则 CPU 跳到一个未初始化数据区,立刻崩溃。MiniOS 在这个阶段还没有开启分页,线性地址等于物理地址,C 代码可以直接读写物理内存,方便后续逐模块调试。等 main.c 初始化完内存管理,再打开分页,就进入虚拟地址世界了。
3. 内核初始化:从 start.c 到 main.c 的最小执行环境
loader 进入内核后,代码已经在保护模式下运行,但 C 运行环境还不完整。start.asm/start.S 这段汇编要负责设置栈、清 BSS、再调用 main。BSS 里的未初始化全局变量如果不置零,后面的进程表和缓冲区会带着随机值工作,问题非常难查。所以这个微型内核的启动流程虽然只有几段,却是整个系统能跑起来的前提。
3.1 start.asm 要做的事
start.asm 的主要工作是准备 C 语言的执行环境。栈用stack_top指向,BSS 段通过链接脚本暴露的符号来清零,最后call main。典型代码:
global _start section .text _start: cld ; 方向标志置 0,确保 stosb 递增 mov esp, stack_top mov edi, __bss_start mov ecx, __bss_end sub ecx, edi xor al, al rep stosb call mainrep stosb会重复执行edi写入al,直到ecx为零。cld必须放在前面,否则方向标志若被置位,写入地址会递减,BSS 清不干净。stack_top一般定义在链接脚本指定的栈区域,不要随意放在内核代码段中间,否则函数调用压栈会覆盖代码。
3.2 main.c 里的初始化顺序
main.c 的初始化顺序是一个经典模板:先 CPU 基础环境,再物理内存,然后中断、进程、文件系统。顺序颠倒会引起连锁问题,比如在 8259A 重映射之前开中断,CPU 会被 IRQ 向量和 Intel 保留异常冲突弄挂。一个典型的调用序列如下:
void main(void) { init_paging(); /* 建立页目录,开启分页 */ init_8259A(); /* 重映射 IRQ 到 0x20-0x2F */ init_clock(); /* 初始化 PIT 定时器 */ init_mem(); /* memman 接管空闲内存 */ init_process_table(); /* 清空 PCB 数组 */ init_hard_disk(); /* 探测硬盘参数 */ init_filesystem(); /* 读取超级块和根目录 */ start_first_process(); /* 创建并启动 init */ }各函数对应的模块在源码里一目了然,pagetbl.c 负责分页,i8259.c 初始化中断控制器,clock.c 提供时钟 tick,memman.c 管理物理内存。教学内核里的 init 函数通常没有复杂错误处理,失败就while(1)死循环,这在缺少 console 的早期阶段是合理做法。
| 初始化函数 | 主要模块 | 前置依赖 |
|---|---|---|
| init_paging() | pagetbl.c | GDT 已装载,CR0.PE=1 |
| init_8259A() | i8259.c | IDT 准备好 |
| init_clock() | clock.c | 8259A 已重映射 |
| init_mem() | memman.c | 物理内存边界已知 |
| init_process_table() | proc.c | memman 可以分配 PCB |
| init_filesystem() | fs.c / hd.c | 硬盘驱动能读扇区 |
3.3 8259A 与时钟中断
8259A 是 x86 传统中断控制器,默认把 IRQ0 映射到中断向量 8,而向量 8 是双重故障异常,直接开中断会触发致命错误。init_8259A 要做的是把主片 IRQ0 重映射到 0x20。常见初始化序列如下:
void init_8259A(void) { out_byte(0x20, 0x11); /* ICW1: 边沿触发,级联 */ out_byte(0x21, 0x20); /* ICW2: 主片 IRQ0 -> INT 0x20 */ out_byte(0x21, 0x04); /* ICW3: slave 接在 IRQ2 上 */ out_byte(0x21, 0x01); /* ICW4: 8086 模式 */ out_byte(0x21, 0xFF); /* 先屏蔽所有中断 */ /* 随后对 0xA0/0xA1 重复类似配置 */ }0x11 是初始化命令,0x20 是偏移量,0x04 指定从片级联引脚,0x01 表示 8086 工作模式。配置完成后,init_clock 设置 PIT 定时器,使它以大约 100 Hz 产生时钟中断。每次中断都会递增全局 tick,并调用调度函数,这样进程才能获得时间片。x86 里访问端口需要out_byte/in_byte这样的封装,一般放在 klib.c 或 kliba.asm 中,你可以直接看这些文件里的内联汇编,它们是理解内核 I/O 的入口。
3.4 进入 C 后的第一个坑:栈和 GDT 不一致
start.asm 里设置 esp 之前,任意call/ret都是不可信的。有些教学镜像把栈放在低内存 0x9FF00,但那段区域可能被 loader、GDT 或内核映像占用,冲突后表现是 C 函数第一次返回就跳飞。检查这类问题,可以在 QEMU 里连接调试器,停在call main前,用info registers esp看栈顶地址。如果 esp 不在 RAM 高位,优先怀疑链接脚本或 start.asm 的.bss位置。这也是为什么我喜欢从一开始就把栈放在链接脚本预留的高地址区域,省掉后面无数诡异 bug。
4. 进程管理核心:proc.c、syscallc.c、fork.c 的组合
MiniOS 的进程模型比 Linux 简单,但基本要素完整:PCB 保存上下文、TSS 负责内核栈切换、系统调用通过中断进入内核、fork/exec 完成进程创建和换体。这里最值得读的是进程从用户态陷入内核态再返回的完整流程,而不是只看某个函数。
4.1 进程控制块与 TSS
每个进程一个 PCB 记录寄存器、状态、时间片和内存映射。典型结构体如下:
struct proc { struct stack_frame regs; /* 用户寄存器现场 */ uint16_t ldt_sel; /* 用户进程 LDT 选择子 */ uint32_t cr3; /* 进程页目录地址 */ int pid; int state; /* RUNNABLE / BLOCKED / ... */ int ticks_left; /* 剩余时间片 */ struct mem_map *mm; };TSS(Task State Segment)是 x86 提供的硬件数据结构,MiniOS 不一定用它做硬件任务切换,但必须填充 TSS 中的ss0和esp0。用户程序执行int 80h时,CPU 会自动从 TSS 加载内核栈指针,并把用户栈切换过去。如果 TSS.esp0 不对,系统调用进入内核的第一刻就会把用户现场压到错误地址。TSS 的关键字段如下:
| 字段 | 作用 | 使用时机 |
|---|---|---|
| SS0 / ESP0 | 内核栈段与栈顶 | 从用户态陷入内核时自动加载 |
| CR3 | 进程页目录物理地址 | 任务切换时可选加载 |
| LDT | 用户进程 LDT 选择子 | 保护模式访问用户段 |
4.2 系统调用的入口与分发
系统调用是用户进程进入内核的正式入口。MiniOS 的 syscall.asm 保存现场,再调用 C 分发函数。常见写法:
sys_call: push ds push es push fs pushad push esp call sys_call_table_func ; 按 eax 索引分发 add esp, 4 popad pop fs pop es pop ds iret系统调用编号放在 eax,参数放在 ebx、ecx、edx。pushad保存所有通用寄存器,push esp让 C 函数能拿到完整的寄存器现场。iret一次性恢复 eflags、cs、eip,并切回用户栈。注意这里不能用ret,因为ret只会弹出 eip,不会恢复 eflags 和 cs。syscallc.c 里通常提供get_ticks()、write()等包装函数,用户程序不需要直接写汇编。系统调用表是一个函数指针数组,每增加一个系统调用都要同步修改编号、数组和用户态封装,这是新手最容易漏掉的地方。
4.3 fork 与 exec:进程分裂和换体
fork 在 MiniOS 里不做写时复制,最直观的方式就是完整复制用户内存:为子进程分配物理页,把父进程的用户空间一页页拷贝过去,然后复制 PCB,并把子进程的 eax 改为 0。父进程的 eax 保持子进程 pid。这个“返回值不同”不是魔法,而是系统调用返回时故意修改了保存的 eax 现场。exec 则相反,它把当前进程的地址空间替换成 ELF 文件的内容:通过 elf.c 读取程序头,按段的虚拟地址分配页并拷贝,最后修改 eip 到入口点。旧内存页释放,但 PCB 保留,所以 pid 不变。
fork 的流程可以压缩成几步:
- 在进程表中找到一个空闲槽;
- 把父进程 PCB 复制到新槽;
- 分配物理内存并复制用户态页;
- 子进程现场中的 eax 清 0,父进程现场中的 eax 设为子进程 pid;
- 子进程状态改为就绪,返回父进程。
如果后续想做写时复制,只需要在复制页时把页表项置为只读,等到写异常时再真正复制。MiniOS 的教学价值就在于它能让你清楚看到,复杂机制其实是这些基础步骤的优化叠加。
4.4 时间片轮转调度
调度器挂在时钟中断里。每次 tick 到来,当前进程的ticks_left减一,减到 0 就选下一个 RUNNABLE 进程。最朴素的实现是循环扫描进程链表:
void schedule(void) { struct proc *p = current; do { p = p->next; } while (p->state != RUNNABLE && p != current); if (p->state == RUNNABLE) { p->ticks_left = TIME_SLICE; switch_to(p); } }switch_to是一段汇编,它把旧进程的寄存器压入当前内核栈,再从新进程的内核栈弹出现场。理解这段的关键是:每个进程的内核栈上都保存着上次被切走时的完整寄存器,所以第一次切换到新进程时,弹出的上下文看起来就像它刚从系统调用返回一样。只要 PCB 和 TSS 里的 esp0 正确,这个跳转可以做到完全对称。
5. 内存管理到文件系统:memman.c、pagetbl.c 和 fs.c 的分工
内存和磁盘是内核的两大底层资源。MiniOS 用 memman.c 管理物理内存,pagetbl.c 做分页映射,hd.c 直接操作 IDE 端口,fs.c 在磁盘上搭出文件系统。这一条链从物理页一直延伸到用户能执行echo命令。
5.1 物理内存分配器
memman.c 的空闲内存管理采用链表结构:每个空闲块记录首地址和长度,分配时按首次适配找第一个足够大的块,从低地址切走一段。释放时把空间重新插入链表,并按地址排序,方便后续合并。一个简单实现如下:
void *memman_alloc(struct memman *man, unsigned int size) { struct free_block *b = man->head; while (b) { if (b->len >= size) { void *addr = (void *)b->base; b->base += size; b->len -= size; return addr; } b = b->next; } return NULL; }首次适配速度不慢,但会产生大量外部碎片。MiniOS 的物理内存总量固定,所以可以接受。如果你想把它改成伙伴分配器,可以从这里入手,这是扩展这个内核最明显的入口之一。注意释放函数必须处理相邻块的合并,否则长时间运行后空闲链表里会有很多无法利用的碎片区。
5.2 页表与虚拟地址映射
分页由 pagetbl.c 初始化,常见做法是把低端物理内存直接映射到 3GB 以上的高地址,同时保留低端 1MB 的恒等映射,以便读取 BIOS 数据。初始化时先清空页目录,再填入低端页表的物理地址:
void init_paging(void) { /* 清空页目录 */ for (int i = 0; i < 1024; i++) page_dir[i] = 0; /* 低端 4MB 物理内存映射到 0xC0000000 */ for (int i = 0; i < 1024; i++) page_tbl_low[i] = (i << 12) | 0x3; page_dir[0] = ((uint32_t)page_tbl_low) | 0x3; page_dir[768] = ((uint32_t)page_tbl_low) | 0x3; /* 768*4M = 3G */ asm volatile("mov %0, %%cr3" :: "r"(page_dir) : "memory"); asm volatile("movl %%cr0, %%eax; orl $0x80000000, %%eax; movl %%eax, %%cr0" ::: "eax"); }页目录项的低 12 位是标志位,0x3表示存在且可写。开启分页后,CPU 执行的所有地址都要经过页表翻译,因此当前 eip 必须落在映射覆盖的范围内。这也是为什么很多内核在改 CR0 之后会紧接着做一次跳转,以确保流水线里没有旧地址指令。
5.3 简单的 IDE 硬盘驱动
MiniOS 的磁盘驱动大概率走 PIO 模式,通过 0x1F0-0x1F7 端口操作 IDE 设备。读一个扇区的流程如下:
void hd_read_sector(uint32_t lba, uint8_t *buf) { out_byte(0x1F6, 0xE0 | ((lba >> 24) & 0x0F)); /* 主盘,LBA 高 4 位 */ out_byte(0x1F2, 1); /* 扇区数=1 */ out_byte(0x1F3, lba & 0xFF); /* LBA 低 8 位 */ out_byte(0x1F4, (lba >> 8) & 0xFF); /* LBA 中 8 位 */ out_byte(0x1F5, (lba >> 16) & 0xFF); /* LBA 高 8 位 */ out_byte(0x1F7, 0x20); /* READ SECTOR */ while (!(in_byte(0x1F7) & 0x08)); /* 等待 DRQ */ for (int i = 0; i < 256; i++) ((uint16_t *)buf)[i] = in_word(0x1F0); /* 读 256 个字 */ }0x1F6 的高 4 位 0xE0 表示 LBA 模式、选主盘。0x1F7 写入 0x20 是读命令,读取期间这个端口的状态寄存器会变化,DRQ 置 1 表示数据缓冲准备好。之后从数据端口 0x1F0 连续读取 256 个 16 位数,凑成 512 字节一个扇区。整个过程 CPU 忙等,没有 DMA,教学场景完全够用。
5.4 文件系统的层次
fs.c 在磁盘驱动上面实现文件系统,基本布局和常见教学文件系统类似:
| 区域 | 作用 |
|---|---|
| 引导区 | 引导扇区或保留区 |
| 超级块 | 文件系统大小、块数、inode 起始位置 |
| inode 表 | 每个文件/目录一个 inode,记录大小和数据块号 |
| 数据区 | 文件内容,目录也作为特殊文件存储 |
一个文件的读取路径是:按文件名在目录 inode 的数据块里找目录项,得到文件 inode 号;再读文件 inode,得到数据块地址;最终用 hd_read_sector 把块读入内存。MiniOS 的做法比 VFS 简单很多,块大小直接等于扇区大小,没有页缓存。但也因为这个简单,你能一眼看到磁盘驱动和文件系统的接口边界,这对理解 Linux 的 block 层和 VFS 层非常有帮助。文件系统完成后,像 command/echo.c 这样的用户进程才能把参数写到标准输出,成为从内核到用户态的全链路示范。
6. 在 QEMU/Bochs 里跑起来并添加新系统调用
MiniOS 最大的优点不是代码技巧,而是能快速验证。使用虚拟机比真机方便得多,既能单步调试,又能随时重置。
6.1 用 QEMU 启动 MiniOS
项目自带的launch-qemu.sh可以直接运行,等价于:
qemu-system-i386 -fda a.img -hda 80m.img -m 64-fda指定软盘镜像,-hda挂载 IDE 硬盘镜像,-m 64分配 64MB 内存。内核启动后可以把串口重定向到终端,便于捕获日志:
qemu-system-i386 -fda a.img -hda 80m.img -serial stdio如果启动后黑屏,先检查 a.img 是否可读、路径是否正确;如果内核 panic 显示cr0相关异常,多半是分页初始化写坏了页表,回到 GDB 看 CR3。
6.2 连 GDB 看内核状态
QEMU 支持 GDB stub,启动时加-S -gdb tcp::1234可以让模拟器停在第一条指令,然后从另一个终端连接:
gdb -ex "target remote :1234" -ex "symbol-file kernel.map" -ex "b main"-S表示等待调试器连入后再执行,-gdb tcp::1234指定监听端口。连接后先b main,再c,可以直接断在 C 入口。此时用info reg cr0和info reg esp可以看到分页是否开启、栈是否正常。如果要观察 GDT,使用info reg gdtr读出基址,再用x/16bx 0x...查看描述符字节。这种调试方式对理解实模式到保护模式的切换非常有效。
6.3 添加一个新的系统调用
给 MiniOS 增加一个系统调用,需要同时改四个地方:用户封装、中断入口、系统调用表、内核处理函数。通用步骤:
- 在 syscallc.c 里写一个 C 函数,把系统调用编号放入 eax,参数放入 ebx/ecx/edx,然后执行
int 0x80。 - 在 syscall.asm 入口里根据 eax 跳转。
- 在系统调用表中增加一项,指到你新写的内核函数。
- 重新 make,把新内核写入 a.img。
最容易出错的是编号冲突。新增系统调用要使用无人使用的编号,并且内核处理函数取值时要和你 C 包装函数的参数个数一致。系统调用内部不要直接引用用户空间的高地址指针,所有参数都应该先按值或按拷贝复制到内核空间,否则进程切换后指针可能指向非法页,导致缺页异常。调试新系统调用时,先在 GDB 里b sys_mycall,然后从用户态触发调用,单步确认参数是否正确,再放行。这个流程走通后,你再去看 Linux 的sys_call_table和SYSCALL_DEFINE宏,会意识到 MiniOS 只是把规范流程做到了最薄,接下来你完全可以自己扩展一个内核线程、一个信号量,或者把物理内存分配换成伙伴算法。
本文还有配套的精品资源,点击获取