ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

拆解MiniOS:32位教学内核的启动、保护模式与进程管理

拆解MiniOS:32位教学内核的启动、保护模式与进程管理 简介MiniOS是一款面向操作系统开发学习者的微型内核实践项目基于Oranges教学系统改造可运行在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 教学内核代码量小到可以通读但启动、保护模式、进程、内存管理、系统调用和文件系统这些核心部件一个不少。它基于 Oranges 的教学系统保留了从 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.asmboot 负责搬运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 ; AH02 读扇区, AL02 读 2 个扇区 mov cx, 0002h ; CH0 磁道 0, CL2 扇区号 2 mov dx, 0000h ; DH磁头 0, DL驱动器 A mov bx, 1000h ; ES:BX 指向加载地址 0x1000 int 13h jc read_error这里的寄存器是 BIOS 固定约定AH0x02 表示读扇区AL 是扇区数CH/CL 是磁道与扇区号DX 是磁头和驱动器ES:BX 是目标内存地址。跳转看 CF置位就说明读取失败。实模式下只能访问 20 位物理地址所以目标地址不能超过 1MB。初学者最容易把 CL 的扇区号写错或者把 AL 设置得太大导致跨磁道读错最简单的做法是让引导扇区后面紧跟 loader并保证镜像结构、扇区偏移一致。2.2 进入保护模式前先准备好 GDTloader 接管后的第一件事是打开 A20 地址线。A20 关闭时访问 1MB 以上的地址会发生绕回内核代码只要放在 1MB 之外就会神秘跳飞。常见做法是用 BIOSint 15h或操作键盘控制器 0x64/0x60 端口打开 A20MiniOS 里可以直接照搬。接着需要为 CPU 准备 GDTGDT 是描述段基址、长度和权限的系统表通过lgdt指令装入 GDTR再把 CR0 的第 0 位 PE 置 1CPU 就进入保护模式。切换后必须立即做一次远跳转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 字节描述符核心字段如下字段位数作用Base32 位段起始线性地址Limit20 位段大小单位由 G 位决定G1 位0 为字节粒度1 为 4KB 粒度D/B1 位决定默认操作数大小代码段用 D栈段用 BP1 位段是否存在S1 位0 是系统段1 是代码/数据段Type4 位段类型可执行、可写、只读等MiniOS 的 GDT 至少需要内核代码段、内核数据段、TSS 和 LDT 描述符。由于每个进程都有独立 LDTGDT 里只需保留一个 LDT 描述符调度时把它指向当前进程的 LDT 即可。这些数据结构定义在 include/protect.hprotect.c 负责填表。2.3 loader 最后的接力跳到 start.cGDT 配置好以后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 提供时钟 tickmemman.c 管理物理内存。教学内核里的 init 函数通常没有复杂错误处理失败就while(1)死循环这在缺少 console 的早期阶段是合理做法。初始化函数主要模块前置依赖init_paging()pagetbl.cGDT 已装载CR0.PE1init_8259A()i8259.cIDT 准备好init_clock()clock.c8259A 已重映射init_mem()memman.c物理内存边界已知init_process_table()proc.cmemman 可以分配 PCBinit_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; };TSSTask 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 只是把规范流程做到了最薄接下来你完全可以自己扩展一个内核线程、一个信号量或者把物理内存分配换成伙伴算法。本文还有配套的精品资源点击获取
返回列表