news 2026/9/13 9:55:44

手写迷你操作系统内核:mingOS 0.0.2 实现解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写迷你操作系统内核:mingOS 0.0.2 实现解析

1. 项目概述:mingOS 0.0.2 到底是个什么东西

先直接说结论:mingOS 是我自己从零开始写的一个迷你操作系统内核,目前迭代到了 0.0.2 版。这个版本号非常诚实地暴露了它的状态——它不是一个能装进实体机日常使用的系统,而是一个跑在 QEMU 虚拟机里的教学级内核,能完成启动、输出文字、接收键盘输入、管理物理内存这几件基础得不能再基础的事情。

但千万别小看这个“玩具”。操作系统可以说是计算机世界最底层的那一层软件,平时我们写业务代码、调后端接口、操作数据库,全都在操作系统之上干活。而当你开始自己写内核,你会被迫重新认识计算机的启动流程、CPU 的分段分页机制、中断控制器的工作方式、设备 IO 的交互协议……这些知识在应用开发里几乎永远碰不到,但它们才是计算机真正的工作方式。我做 mingOS 的初衷,就是不想只停留在“会用操作系统”的层面,想亲手把这一层纸捅破看看里面到底怎么运转的。

0.0.2 版在 0.0.1 的基础上补上了两个大件:一个是物理内存管理,一个是键盘输入的响应处理。同时把之前硬编码的显示输出整理成了一个可以按坐标、按颜色写字符的简单 VGA 驱动。整个内核的代码量不大,C 文件加汇编文件加链接脚本,全部加起来不到 1500 行,但我敢说读透这些代码对底层原理的理解,比翻十本厚书都管用。

这篇文章适合谁看?两种人:一种是学了一段时间操作系统理论、但始终觉得“概念理解了但没真正上手”的人;另一种是已经在写业务代码、想业余时间搞点硬核项目提升内功的人。我可以负责任地告诉你,哪怕你之前没写过汇编、没接触过链接脚本,只要跟着这篇文章的思路走一遍,也能把这个迷你内核跑起来,并且理解每一行代码到底在干什么。

2. 整体设计与思路拆解:0.0.2 版本的技术决策

2.1 引导方式的选择:为什么要抱 GRUB 的大腿

做过底层开发的朋友都知道,x86 架构的启动流程是整个系统开发里最容易劝退新人的部分。8086 CPU 上电之后会从物理地址 0xFFFF0 开始执行 BIOS 代码,BIOS 完成硬件自检后找到可引导磁盘,把引导扇区的 512 字节读入内存 0x7C00 处并跳转执行。接下来引导代码要经历实模式到保护模式的切换、加载 GDT、设置段寄存器、开启 A20 地址线……这一整套流程光是在纸上画清楚都要好几页,而且每一步出错都是灾难性的三重故障(Triple Fault),机器直接重启,连个报错都不给你。

mingOS 从一开始就决定走捷径:使用 GRUB 作为引导器,内核镜像通过 Multiboot 规范交给 GRUB 来加载。这样做的原因很实际——引导代码是所有环节里最“一次性的”、最没复用价值的、也最难调试的部分。当年 Linux 内核的引导代码维护成本一直很高,Linus 自己都吐槽过引导噩梦。而我做 mingOS 的目标是研究内核的进程调度、内存管理、设备驱动这些核心机制,不是去考古 32 位保护模式的切换细节。

写进 0.0.2 的内核入口汇编代码只有五十多行,全部干的事情就是:把 Multiboot 规范要求的一些信息从寄存器里取出来、设置好 C 语言运行环境用的栈、调用kmain主函数。这算是一个典型的技术选型问题——永远不要在你项目的非核心路径上花费过多精力,哪怕它看起来很酷。

2.2 32 位还是 64 位:一个务实的决定

mingOS 目前是 32 位内核,运行在 x86 的保护模式下。很多朋友可能会问:现在都什么年代了,怎么不直接上 64 位长模式?

这个决策我反复权衡过。64 位长模式有很多优势:寄存器数量翻倍、可以直接使用 64 位地址空间、不再有分段机制的困扰。但是代价是复杂度大幅上升——你需要额外处理页表(哪怕是静态页表)、需要适配不同的编译器 ABI、需要处理额外的模式切换状态。而 32 位保护模式的生态非常成熟,整个 GDT/IDT/中断处理的知识体系已经被无数教材和实践者验证过,出问题的时候参考资料很好找。

另外还有一层考虑:mingOS 是拿来学习的,不是拿来生产使用的。在 32 位模式下能把内存管理、进程调度、系统调用这些核心机制吃透,之后迁移到 64 位只是顺着同样的思路多写一些页表代码而已。地基和框架能力才是重点,寄存器宽度只是一个参数。事实证明这个决定非常正确,0.0.2 版本的开发周期比我预想中短了一半多。

2.3 目录结构与模块划分

0.0.2 的源代码分了这么几个模块:

  • boot/:引导汇编和链接脚本。汇编代码负责接收 GRUB 的跳转、设置栈指针;链接脚本控制内核各个段的布局,确保入口点是第一个要执行的代码。
  • kernel/:内核核心代码。包括kmain.c(主入口)、vga.c(显示驱动)、gdt.c(全局描述符表)、idt.c(中断描述符表)、isr.c(中断服务例程)、memory.c(物理内存管理)、keyboard.c(键盘驱动)。
  • lib/:几个常用的 C 库函数实现,主要是memcpymemsetstrlen这些内核里没法依赖 libc 的常用函数。

模块化设计从 0.0.1 就开始做,到了 0.0.2 版本基本稳定下来了。每个模块的职责单一、接口清晰。比如显示模块对外只暴露vga_init()vga_putc()vga_write()三个函数,内存管理模块暴露memory_init()alloc_page()free_page()三个接口。这样的好处是后面每加一个功能,比如进程调度器,只要往这个框架里挂一个新的模块就行,不需要大规模重构已有代码。内核开发最怕的就是写成了几千行的单片 C 程序,一个头文件改错全家引爆炸,分模块的意义会在你调试到凌晨两点的时候格外深刻地体会到。

3. 核心细节解析与实操要点:0.0.2 的四大核心模块

3.1 VGA 文本模式驱动:最直观的输出窗口

在内核开发的最早期阶段,你没有任何调试器、打印函数和日志库可用,唯一能用的“输出设备”就是 VGA 文本模式的显存区。这块区域映射在物理内存0xB8000处,以 80 列乘以 25 行的矩阵方式组织,每个屏幕位置占用两个字节:一个字节存 ASCII 码,一个字节存属性(前景色和背景色)。

mingOS 的 VGA 驱动就是一个非常基础的内存写入封装。vga_putc()把字符写到当前光标位置,然后光标后移;光标到行尾就换行,到屏幕最底下就滚动。硬件上还有一个叫做光标寄存器的东西,通过0x3D40x3D5两个 IO 端口可以控制光标位置,这样用户才能看到当前输入焦点在哪。

这一段代码的踩坑点主要在颜色属性字节的位布局上。属性字节低 4 位是前景色,bit 4 到 bit 6 是背景色,最高位是闪烁标志。我一开始写的时候把前景和背景搞反了,结果屏幕上所有的字都变成了蓝底白字,看起来像 Windows 蓝屏现场。调试了半天才发现是0x0f(白字黑底)和0xf0(黑字白底)写混了。

另一个容易踩的坑是滚动操作。屏幕写满之后要整体上移一行,最直接的办法是把第 1 到第 24 行的内容逐行搬移到第 0 到第 23 行,最后一行用空格清掉。这个操作用memcpy就能做,但需要注意源地址和目的地址之间的重叠问题,需要从前往后搬(从第 1 行搬到第 0 行,从第 2 行搬到第 1 行……),而不是反向搬。

3.2 GDT 与分段机制:CPU 的世界观

虽然 32 位 CPU 上默认的分段机制已经很“透明”了(你写 C 语言的时候根本感觉不到段的存在),但是从实模式切换到保护模式之后,CPU 要求你必须配置一套完整的段描述符表,告诉它代码段、数据段分别在哪里、权限是多少。这套表就是 GDT(Global Descriptor Table,全局描述符表)。

mingOS 里 GDT 的初始化极其克制,只定义了四个描述符:

  • 一个空的 null 描述符,CPU 规定这个必须是全零,用来捕获空指针的段错误。
  • 一个内核代码段,基址 0、界限 4GB、特权级 0。
  • 一个内核数据段,基址 0、界限 4GB、特权级 0。
  • 一个用户数据段,基址 0、界限 4GB、特权级 3。(用户代码段在 0.0.2 里还没有,等做到用户态再补)

在这些段描述符里,基址都是 0、界限都是 4GB,这其实意味着分段机制被“摊平”了——不管代码还是数据都指向同一个平坦的内存空间。这是一种非常常见的现代操作系统做法,因为后来的分页机制会负责真正的内存隔离和保护,分段就不需要再做精细划分了。

实现 GDT 的时候有一个汇编层面的细节特别容易翻车:让 CPU 加载 GDT 的指令叫lgdt,它需要传入一个 48 位的伪描述符结构体,里面包含 GDT 表的长度和起始地址。这个结构体在 C 语言里可以用__attribute__((packed))定义,但长度字段必须存的是表大小减一,因为 CPU 硬件逻辑是用“界限+1”来计算合法范围的。我第一次写的时候就忘了减一,结果是合法的最后一次 GDT 访问也会被判定为越界,系统一进内核就跑飞。

3.3 IDT 与中断处理:让 CPU 学会“分心”

0.0.2 新增的中断系统是整个版本最核心的东西之一。CPU 执行指令的过程是串行的,从内存取一条指令、执行、取下一条……但外部设备时刻在发生各种事件,比如键盘被按下、网卡来数据了、时钟嘀嗒走了。如果 CPU 每次都停下当前任务去轮询这些设备,性能会浪费得一塌糊涂。中断机制就是为了解决这个问题——设备主动向 CPU 发信号,CPU 收到信号后暂停手头的工作,跳转到预先注册的中断处理函数,处理完再恢复之前的执行现场。

实现中断机制需要三样东西:

  • IDT(Interrupt Descriptor Table,中断描述符表):一张记录中断号和对应处理函数入口地址的表。
  • 中断服务例程(ISR):每个中断号一个,由汇编编写,负责保存现场、调用 C 处理函数、恢复现场。
  • 8259A 可编程中断控制器(PIC):管理硬件设备发来的 IRQ(中断请求),我们今天用到的键盘就挂在 IRQ1 上。

0.0.2 里最让我头疼的就是 IDT 表项的 flags 字段。每个 IDT 表项是一个 64 位的结构,包含中断处理函数地址的低 16 位、高 16 位、段选择子、以及一组描述这个中断类型的标志位。标志位里 bit 0 表示表项是否有效,bit 6 到 bit 7 表示特权级。如果 flags 设置错误,比如忘记把第 0 位置 1,CPU 遇到中断的时候会直接报“无效 IDT 表项”而触发异常,整个内核就瞬间死给你看。

另一个关键点是 PIC 的重映射。默认情况下 CPU 把硬件中断(IRQ0-IRQ15)映射到中断向量 0 到 15 这个范围,但这和 CPU 内部的异常向量(比如除零错是 0、页错误是 14)冲突。所以必须在初始化时向 PIC 发送重映射命令,把硬件中断挪到 32 到 47 号。这个操作需要用 IO 端口向两个 PIC 分别写入 ICW1、ICW2、ICW3、ICW4 四个命令字序列,顺序不能乱,而且中间必须间隔若干 IO 周期让 PIC 内部完成初始化。我把这个初始化函数封装成pic_remap(),配合 IDT 的初始化调用,键盘中断才终于能在屏幕上打出字来。

3.4 物理内存管理:用位图法管好每一页

0.0.2 的新功能重头戏是物理内存管理。此前的 0.0.1 版本,所有内存相关操作都是直接写在虚拟地址上的硬编码,什么地址能用什么地址不能用全靠程序员脑子记,这显然没法支撑将来的多进程调度。所以 0.0.2 引入了一个简单的页分配器。

页面大小定的是 4KB,这也是 x86 分页机制的标准页面大小,和后续开启分页后的页面粒度保持一致。整个物理内存被切割成若干个 4KB 大小的页框,分配器维护一个位图(bitmap),每一位对应一个页框:0 表示空闲,1 表示已分配。

分配逻辑特别直白:从位图的开头往后扫,找到第一个空闲位就把它置 1,然后返回该位对应的物理地址。释放逻辑正好相反:把对应物理地址映射到位图的下标,把这一位清零。这个算法在最坏情况下的查找复杂度是 O(n),对于 32 位环境下最大能管理的 4GB 内存也就是 1M 个位图项,扫一遍的时间完全可以接受。

页分配器需要知道一个关键信息:当前机器有多少内存。这部分信息由 GRUB 在启动时通过 Multiboot 头结构体传给内核,里面有一个mmap_entries数组,记录了每段物理内存的地址和长度。不是所有从 0 开始的地址都能用——比如 0xB8000 那块是 VGA 显存、0x100000 之前是 BIOS 和实模式中断向量表的地盘、内核自己也要占一块区域。所以memory_init()干的第一件事就是遍历 GRUB 传进来的内存地图,把可用的内存区间挑出来,把其中已经被内核和显存占用的部分标记为已分配,剩下的才能放进分配器。

这里还要吐槽一个我踩过的坑:直接使用 GRUB 报告的内存总量来决定位图大小是不可靠的,因为物理地址可能不是连续的(比如有些机器在 3GB 到 4GB 之间有空洞,那里是设备映射区)。所以我实现的时候严格要求只能从内存地图报告的可用区间里取页,宁缺毋滥,避免分配到一个不存在的物理页上然后机器立刻崩掉。

3.5 键盘驱动:从 IRQ 到屏幕的完整链路

键盘驱动是 0.0.2 最让人有成就感的部分,因为它是第一个“完整的中断驱动设备”。键盘控制器(8042/PS2 接口)在每次按键和松键时都会产生一个 IRQ1 中断,CPU 收到后跳转到 ISR1(中断服务例程),ISR 里从 IO 端口0x60读出键盘扫描码(scancode),再把扫描码翻译成 ASCII 字符放到屏幕显示缓冲区里。

这里的核心难点在于扫描码是“分层的”。键盘上每个键都有按下和松开两种状态,按下时产生一个小于 0x80 的扫描码,松开时产生同样的扫描码加上 0x80 作为前缀(比如 0x1E 是 A 键按下,0x9E 是 A 键松开)。如果只关注按下事件还好,但做驱动就不能太粗暴,因为多功能键(Shift、Ctrl、Alt)的按下和松开会改变后面按键的翻译结果。

mingOS 0.0.2 的键盘驱动为了控制复杂度,只支持了 Shift 键的组合。维护一个shift_pressed标志变量,扫描码为 0x2A(左 Shift 按下)时置 1,扫描码为 0xAA(左 Shift 松开)时清零。后续按键翻译时查两张表:未按住 Shift 用小写字母表,按住 Shift 用大写字母表。像1!2@这种字符的切换也靠这个标志位解决。

另外一个必须处理的坑是缓冲区竞争问题。键盘中断随时可能来临,不管 CPU 此时正在干什么。如果 CPU 正在执行vga_write()往屏幕打印字符串,此时键盘中断来了,中断服务程序也需要调用vga_putc()来显示字符——这样会发生什么?显示输出的光标位置变量和屏幕内容会被两份代码交错修改,最后变成屏幕花屏、光标乱跳的诡异状态。这个问题在 0.0.2 里我偷了个懒,直接用cli/sti(关闭/开启中断)包裹住关键操作,暂时规避了竞争问题,真正的多核安全的并发锁得等以后做到更高级别再回头来补。

4. 实操过程与核心环节实现:从零编译到跑起来

4.1 构建环境准备:不要用系统自带的 gcc

如果你在 Linux 上打开终端直接敲gcc,你会发现系统里装的是为本机目标架构(比如 x86-64)生成的编译器。但我们要编译的是 32 位的内核,而且这个内核运行在无操作系统的裸机上,没有 glibc、没有标准库头文件、也没有运行时启动文件。如果用普通的 gcc 去编译,链接阶段会到处报找不到crt1.o、找不到libc之类的错——因为常规编译链路径上那些文件根本不存在于裸机环境。

解决办法是准备一个独立的交叉编译工具链,目标三元组叫i686-elf(也可以是i386-elf)。在 Ubuntu/Debian 上,直接一条命令就能装好:

sudo apt install gcc-multilib g++-multilib binutils-i686-linux-gnu

需要注意的是这个命令安装的并不是真正意义上的i686-elf裸机工具链,它实际是 Linux 系统的 32 位交叉编译器,仍然依赖部分 Linux 的启动文件。如果追求更纯净的环境,推荐自己从源码编译binutilsgcc,目标平台设为i686-elf,这样得到的编译器完全不带任何本地系统依赖,生成的二进制就是纯粹的裸机格式。我自己的 mingOS 开发环境是后者,虽然构建工具链本身需要个把小时,但之后省心很多。

mingOS 的构建整体用 Makefile 管理。内核源码里有两个特殊文件:boot.s是汇编代码,用as(汇编器)编译;vga.ckmain.c这些是 C 文件,用gcc编译。两种目标文件最终通过链接脚本整合成一个mingOS.bin

4.2 链接脚本:布局为王

链接脚本是内核构建中容易被忽略但是极其重要的部分。它决定了内核镜像里各个段(代码段、数据段、BSS 段)被加载到内存中的哪个地址。x86 内核约定俗成的加载地址是0x00100000(1MB 处),因为低于 1MB 的内存大多被 BIOS、VGA 显存和实模式数据结构占用了。

mingOS 的链接脚本长这样:

OUTPUT_FORMAT(elf32-i386) ENTRY(start) SECTIONS { . = 0x00100000; .text : { *(.text) } .data : { *(.data) } .bss : { *(COMMON) *(.bss) } }

第一个关键点是ENTRY(start),它声明了内核镜像的入口点。start符号在boot.s里定义,它是 CPU 被 GRUB 加载后执行的第一条指令所在的地址。第二个关键点是. = 0x00100000,它告诉链接器把代码段放在物理地址 1MB 处。链接脚本的顺序很重要:文本段、数据段、BSS 段的排列顺序和地址都是从这个初始地址依次往下排的,如果顺序搞错,链接出来的镜像会错乱得一塌糊涂。

还有一个容易忽略的细节:BSS 段存放的是未初始化的全局变量和静态变量,比如内存分配器的位图数组和键盘驱动里的状态变量。这类变量在加载时不占镜像空间,但在运行时会占内存,并且 C 标准要求它们初始化为 0。问题在于 GRUB 加载内核镜像时只会把文件本身读入内存,BSS 段在文件里根本没有数据内容,如果你不在启动汇编里手动清零 BSS 段,那些变量加载完大概率是随机的垃圾值,然后你的键盘驱动就莫名其妙失灵了。所以 0.0.2 的boot.s里专门有一段循环,用rep stosb指令把 BSS 段整个清一遍。这里我建议你记住stosb指令的用法,它一次只能存一个字节,配合rep前缀就能重复执行,是把一块内存快速初始化的标准方案。

4.3 编译、链接的完整命令

接下来说说整个构建过程。Makefile 的核心命令大概长这样:

CC = i686-elf-gcc CFLAGS = -m32 -nostdlib -fno-builtin -fno-stack-protector -Wall -O2 -Iinclude LD = i686-elf-ld mingOS.bin: boot.o kmain.o vga.o gdt.o idt.o isr.o memory.o keyboard.o $(LD) -T linker.ld -o $@ $^ %.o: %.s $(CC) $(CFLAGS) -c $< -o $@ %.o: %.c $(CC) $(CFLAGS) -c $< -o $@

几个编译参数值得解释一下:

  • -m32:生成 32 位代码,这是硬性要求。
  • -nostdlib:告诉编译器不要链接标准库。裸机环境根本没有标准库,加上它会减少许多不必要的依赖。
  • -fno-builtin:禁止编译器把memcpystrlen这类函数自动替换成内建优化版本,否则编译器可能会生成对标准库函数的隐式调用,而我们在裸机上根本没有那个库。
  • -fno-stack-protector:关闭栈保护机制。默认开栈保护的系统通常会在函数开头插入一段检查代码,读写某个“金丝雀”值来检测栈溢出,但这个机制依赖我们在内核里实现一个特定的钩子函数,0.0.2 阶段还没到那个程度。

链接完成之后,你会得到一个mingOS.bin,这是扁平格式的 32 位 ELF 可执行文件。接下来要用grub-mkrescue把它打包成一个可启动的 ISO 镜像(镜像里包含 GRUB 引导文件和 Multiboot 头文件),这样 QEMU 才能直接引导它。不过这一步骤里有一个需要注意的小地方:ISO 里除了内核文件之外,还需要一个grub.cfg,最小化的写法就三行:

set timeout=0 set default=0 menuentry "mingOS" { multiboot /boot/mingOS.bin boot }

4.4 在 QEMU 里运行与调试

构建完成后,运行只需要一条命令:

qemu-system-i386 -cdrom mingOS.iso

如果你是第一次跑,大概率会遇到一个现象:QEMU 窗口弹出后,黑色的屏幕一角出现一个闪烁的光标,然后……没有然后了。不要慌,这通常不是内核死机,而是键盘中断还没有收到任何输入事件,系统的状态其实是在等待中。此刻你按下键盘上的任意字母键,屏幕上就会打印出你按下的字符——0.0.2 的“交互”虽然简陋,但这可是从 CPU 硬件层面一路打通到用户输入输出的完整链路,看到自己打的字被自己的内核接收并打印出来,这个成就感比写完一百个 CRUD 接口都强。

调试方面,QEMU 提供了远程 GDB 调试接口,是一个非常强力的内核调试手段。运行 QEMU 时加上-s -S参数会让它在启动时暂停并监听 GDB 的远程连接:

qemu-system-i386 -cdrom mingOS.iso -s -S

另一个终端用gdb连接:

gdb (gdb) target remote localhost:1234 (gdb) file mingOS.bin (gdb) break kmain (gdb) c

断点一打,单步调试一开,你就相当于获得了一个比瘟都斯时代任何调试工具都好用的内核调试环境。我在写 0.0.2 的时候,光是靠 GDB 查看 GDT 表项内容和物理内存分配状态就省了不知道多少眼瞎排查的时间。强烈建议每个做内核开发的人都要掌握这个技能——它比你在代码里写一堆printk要高效得多。

5. 常见问题与排查技巧实录:0.0.2 开发踩坑全记录

5.1 三重故障:内核开发最经典的“真香”体验

说到内核开发,最常见也最崩溃的现象就是传说中的“三重故障”(Triple Fault)。简单说:CPU 在保护模式下遇到一个非常严重的问题(比如试图访问不存在的段)时触发异常,异常的处理本身又失败,CPU 就再触发一个双重重故障;双重重故障处理再次失败,CPU 已经彻底不信任当前的代码状态,于是选择直接复位重启。表现就是你的 QEMU 窗口突然黑屏再重新出现——内核仿佛啥也没发生过一样重启了。

0.0.2 里我遇到过三次三重故障,原因各不相同:一次是 GDT 描述符权限位设置错误,一次是 IDT 表项标志位没置有效位,还有一次是键盘中断服务例程返回前忘了执行iret指令。排查三重故障的办法只有一条:用-d int打开 QEMU 的 CPU 中断日志,看 CPU 在崩溃前到底在处理哪个中断、哪条指令触发的异常。这一步能把问题范围缩小到极小的范围内。

5.2 屏幕不显示任何字符:BSS 段没清零的连锁反应

0.0.2 开发到中间阶段,我犯过一个非常隐蔽的错。当时我给 VGA 驱动加了一个全局变量cursor_xcursor_y来记录光标位置,编译运行后,屏幕干干净净,一个字符都不输出。排查了很久,GDB 单步发现vga_init执行完毕之后cursor_x的值还是 0,但cursor_y的值变成了一个莫名其妙的大数字,这直接导致vga_putc把字符写到了屏幕外的坐标上。

最终定位到就是前面提到的 BSS 段清零问题。我当时链接脚本里 BSS 段声明是对的,但boot.s里清零 BSS 段的循环写错了长度——只清了内核加载地址附近的一个页(4KB),而 BSS 段在全代码膨胀之后已经超出了这个范围。事实证明,每加一个全局变量,BSS 段的大小就变大一点,如果你的清零逻辑是写死的固定长度,那么迟早会翻车。后来我改成从链接脚本读取_bss_start_bss_end两个符号来动态计算长度,这个问题从此绝迹。

5.3 键盘输入偶尔丢字符:中断被屏蔽了

从 0.0.2 的功能测试来看,键盘输入的响应偶尔会出现“按了十次键,屏幕上只显示八个字符”的情况。查了一会儿发现,问题出在我的vga_write()函数里有一段用cli关中断来保护光标操作的代码。如果屏幕正在连续输出大量内容(比如同时跑了循环打印测试),这段关中断的临界区可能会持续一小段时间,而这期间键盘中断来了全部被挂起,直到中断重新打开后 PIC 才把积压的 IRQ1 发过来一批。

理论上这不算太严重的问题,但你要知道,键盘扫描码在 PS/2 缓冲区里可是不排队等着的——如果 8042 控制器里的数据不被及时读走,新的按键事件就会丢。解决办法有两个方向:一是尽量缩短关中断的临界区,让保护区域只有“读取光标坐标、写入显存、更新光标坐标”这一小段;二是键盘中断处理函数一进来就把0x60端口读掉,一次性把按键数据取走。0.0.2 目前用的是第二种方案——中断处理函数早读早好,宁可把数据先缓存在内核的一个循环缓冲区里,后面逐个处理也不迟。

5.4 常见问题速查表

现象可能原因排查思路
QEMU 反复重启三重故障-d int查看中断日志,确认崩溃点
启动后黑屏无输出BSS 段未清零 / VGA 初始化未调用GDB 断点查看全局变量初始值
屏幕出现乱码字符颜色属性字节写反 / 扫描码翻译表错误检查属性位布局、核对扫描码表
键盘输入完全无效IDT 或 PIC 初始化顺序问题确认 IDT 装载和 PIC 重映射顺序
所有中段都失效sti指令未执行检查启动流程里是否开启了中断标志
内存分配返回同一地址位图释放逻辑错误单步运行分配释放函数,检查位图位变化

5.5 我的专属检查清单

经历了这些坑之后,我把 0.0.2 的开发流程整理成了一份检查清单,每次改动后按顺序过一遍能省掉很多时间:

  • 检查链接脚本里段边界符号是否与代码中引用一致。
  • 检查启动汇编里是否清除 BSS,且清除范围是否覆盖全部 BSS。
  • 检查 GDT 表项的标志位:访问位、可读可写位、特权级是否正确。
  • 检查 IDT 表项的 flags:最后一个字节是否为 0x8E(表示 32 位中断门、特权级 0、有效位)。
  • 检查 PIC 重映射是否先于任何硬件中断的启用。
  • 检查所有中断服务例程最后是否以iret结尾(不是ret)。
  • 检查所有中断服务例程是否正确地保存了 CPU 寄存器现场,尤其是需要跨调用使用的寄存器。
  • 检查键盘中断处理函数内是否有死循环或阻塞操作。

这份清单伴随着 mingOS 的每一次构建。虽然看起来啰嗦,但每一次检查几分钟时间,换来的是少一次“抱着电脑左思右想哪行代码写错了”的痛苦之夜,非常划算。

6. 0.0.2 版本的局限与后续路线

6.1 为什么它还不是一个操作系统

坦诚地讲,mingOS 目前距离一个“真正可用”的操作系统还差得很远。它没有文件系统、没有进程调度、没有用户态和内核态的分界、没有虚拟内存页表、没有系统调用接口。键盘输入的数据在中断处理完之后只是被直接打印在屏幕上,而没有一个输入缓冲区把一连串按键组合成一行命令。内存管理虽然有了分配页的能力,但目前只提供了最简单的位图分配,连分配大小的对齐和内存碎片的处理都还是空白状态。

但这并不妨碍它作为后续版本的坚实起点。内核开发本来就是一个渐进式的过程——每一个版本的微小进展,都建立在前面版本所有模块的正确运行之上,每加一个新功能都能直接在前一个版本上测试验证。

从 0.0.2 到下一个版本,我计划做三件事:

  • 实现基于页表的内存映射,开启分页机制。这一步能隔离内核和以后各个进程的地址空间,为多进程打基础。
  • 实现简单的时间片轮转任务调度器。有了它,多个任务就能共享 CPU,这是“多任务”的核心原语。
  • 补齐第一个“系统调用”接口——比如让内核提供一个打印字符串的服务,用户态程序通过触发软中断来请求。有了系统调用,内核和用户态之间的壁垒才算真正建立起来。

6.2 给同样想动手写内核的人一些建议

如果你看完这篇文章也产生了自己动手写一个内核的冲动,我建议不要一上来就想着高大上的目标,而是先把基础流程打通:

从打印 “Hello, Kernel!” 开始。了解 GRUB 怎么把内核加载进内存,怎么在屏幕上显示字符。这一步能让你对开发和调试工具有了直观感受。

然后实现中断处理,让 8259A 定时器中断周期性触发,看看你能不能通过中断让屏幕上的数字每秒加一。这一步能让你充分理解中断和硬件交互的底层逻辑。

有了这些基础,后续的内存管理和任务调度就是水到渠成的事情了。

内核开发这个领域的特点是:入门门槛高,每个环节都有非常多细节,而且任何一个细小的错误都会导致整个系统直接崩溃。但它的回报也极高——每当你解决一个底层疑难杂症,你都会发现自己对整个计算机系统的理解上升了一个层次。从“用别人的系统”到“自己搭一个系统”,这个跨越带来的认知红利是你在业务代码里写写得再多也换不来的。

7. 个人体会:做内核改变了我看代码的方式

写 mingOS 这几个月,我最大的感受是:以前看框架、看中间件、看数据库源码时,很多地方知其然不知其所以然——比如线程是怎么切换的?虚拟内存是怎么映射的?中断是怎么打到业务代码里然后又无声无息地返回的?这些都像隔着一层毛玻璃。自从自己动手实现过这些底层机制之后,再回去读任何系统软件的源码,会明显感觉那些代码变得亲切、可读、有血有肉了。

可能有人会觉得花几个月时间做一个连 0.1 版本都算不上的玩具内核有点不值。但我觉得,这个世界上真正稀缺的并不是能做出来“用户量大、KPI亮眼”的功能的人,而是能搞明白计算机底层到底是怎么运转的人。如果你也对底层原理感兴趣,别犹豫,从今天的 0.0.1 或者 0.0.2 开始动手吧——哪怕刚开始什么都跑不起来,被三重故障折磨得嗷嗷叫,但只要过了这道坎,你会看到一个完全不一样的计算世界。

mingOS 项目会一直维护下去。0.0.2 版本的代码目前还留在我的仓库里,有想直接上手的朋友可以去看看。等下一次迭代到了 0.1.0,也就是内核能真正跑起多任务的时候,我再来给大家汇报进展。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 9:55:26

MongoDB 数据库 启用访问控制

0. 最近服务器安装了 MongoDB 被勒索了 测试服务器安装了 MongoDB 等&#xff0c;开放了 27017 对所有 ip。 哈哈哈哈哈哈&#xff0c;问就是有点犯懒&#xff0c;之前都是只允许自己的 ip。 好家伙&#xff0c;然后没过几个小时&#xff0c;数据库集合被清空&#xff0c;只…

作者头像 李华
网站建设 2026/9/13 9:54:04

告别429:Portkey 网关重试配置指南

告别429&#xff1a;Portkey 网关重试配置指南 【免费下载链接】gateway A blazing fast AI Gateway with integrated guardrails. Route to 1,600 LLMs, 50 AI Guardrails with 1 fast & friendly API. 项目地址: https://gitcode.com/GitHub_Trending/ga/gateway …

作者头像 李华
网站建设 2026/9/13 9:51:18

XVERSE-Ent开源双语大模型:泛娱乐领域的AI解决方案

1. 项目概述&#xff1a;XVERSE-Ent的定位与核心价值XVERSE-Ent是元象科技针对泛娱乐领域推出的开源双语大模型&#xff0c;包含中英文双版本。这个模型专门为社交互动、游戏叙事、文化创意等场景优化&#xff0c;在同类产品中首次提出"泛娱乐底座"的概念。作为从业者…

作者头像 李华
网站建设 2026/9/13 9:49:51

基于主从博弈的能源系统Matlab优化方案

1. 项目背景与核心价值在能源互联网快速发展的当下&#xff0c;多主体综合能源系统的协同优化成为行业痛点。传统集中式调度难以适应分布式能源的灵活特性&#xff0c;而主从博弈理论恰好为解决这一难题提供了数学框架。我们团队开发的这套Matlab解决方案&#xff0c;实现了需求…

作者头像 李华
网站建设 2026/9/13 9:49:12

STM32+MAX31865+PT100高精度温度采集方案详解与驱动实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华