从零编写操作系统 00 - 从启动扇区到窗口:这套操作系统准备实现什么
这个系列准备从一个 512 字节启动扇区开始,逐步做出一个能运行用户程序、读取文件、响应键盘鼠标、显示窗口终端的教学操作系统。
路线故意选得朴素:x86 32 位、BIOS、C11、少量 NASM 汇编、QEMU 模拟 PC。它不是现代操作系统的完整复刻,也不追求真实机器兼容。它的目标是把“操作系统到底接管了什么”拆成一组可以运行、可以坏掉、可以诊断的实验。
最终目标是一条连续演示:从自制磁盘镜像启动,进入自制内核;打开一个终端窗口;运行 cat README.TXT;再运行 cat README.TXT | wc;最后运行一个 DEMO.EXE,它并发启动两个计数程序和一个故意越界访问的程序。越界程序应该被终止,两个计数程序继续前进,Shell 还能继续接收下一条命令。
这篇导读只给路线、边界和先修自测。首批配套项目提供工具链样例;自制内核尚未出现。后续每篇会在同一份代码上增加一个可观察能力,并给出构建命令、运行日志和故障实验。
最终系统长什么样
先用一张目标图定住边界。图里的“目标”表示后续实现完成后的结构,不是当前已经跑通的截图。
1 | |
这张图有四层责任。
BIOS 固件属于现成环境。它完成硬件初始化的一部分,把磁盘第一个扇区加载到固定内存位置,并把 CPU 交给这段代码。它还提供早期磁盘读取和显示服务。内核进入保护模式后,BIOS 调用不能再按实模式方式直接使用。
引导器负责把机器带到内核能接手的状态。第一阶段受 512 字节限制,只做最小工作;第二阶段读取更大的内核,收集内存图,准备描述系统状态的 BootInfo,再切到 32 位保护模式。
内核负责长期运行的资源管理。它接管异常、中断、物理页、虚拟地址、任务切换、系统调用、块设备、FAT16 只读文件、字符流、鼠标、窗口和终端。内核也负责在程序犯错时划出边界:用户程序的坏指针不能写坏内核。
用户程序只通过约定入口使用内核能力。Shell、cat、wc 和演示程序都运行在用户态。它们不能直接访问设备端口,也不能直接改页表;读写文件、创建进程和输出字符都经过系统调用。
操作系统课程常把四类动作混在一起:工具动作、固件动作、引导器动作、内核动作。这个系列会把交接处写清楚。
为什么选 x86 BIOS 这条线
x86 BIOS 路线不现代,但足够适合教学。
启动扇区可以真实地被 BIOS 执行。段寄存器、栈、磁盘读取、保护模式切换、GDT、IDT、页表和端口 I/O 都能在一套机器模型里逐步出现。读者能看到 CPU 从“执行一个扇区”走到“运行多个用户程序”的连续过程。对应的模拟设备和调试方式见:PC system emulator、GDB usage。
QEMU 的 qemu-system-i386 提供可重复的模拟 PC。这个计划默认使用 i440fx PC、单 CPU、64 MiB RAM、TCG 动态翻译,并配合 GDB 观察寄存器、内存和断点。具体 QEMU 版本、机器型号、CPU 型号和 BIOS 文件见第 01 篇的实测环境记录。
C 代码按 freestanding 环境处理。GCC 对 freestanding environment 的说明只约束编译环境假设,它不会替内核提供启动代码、链接脚本、BSS 清零、栈或 memcpy。这些内容会在进入 C 内核的阶段逐项补齐。
这条路线刻意不碰 x86-64、UEFI、SMP、APIC、ACPI、真实硬件适配、写时复制、交换空间、网络协议栈和完整桌面环境。它先把最小内核的骨架搭起来,再给后续扩展留下位置。
先修自测
正式进入第 01 篇之前,先完成四个小题。做不出来并不妨碍阅读,但对应位置需要短补课,否则后面的断点、寄存器和内存转储很难对上。
自测一:指针和指针指向的数据
题目:
1 | |
问题:p 本身保存的是什么?*p 改掉的是什么?如果把 p 的值当整数打印,它和 value 的值是不是同一个东西?
答案:p 保存的是 value 所在内存地址;*p 表示沿着这个地址找到那 4 个字节并写入新值。把 p 当整数打印,得到的是地址;打印 value,得到的是那块内存里的数据。后续页表、用户缓冲区校验和文件描述符都会反复遇到“名字/地址/句柄”和“它指向的资源”之间的区别。
短补课:画两格纸。左边写变量 p,格子里填一个地址,比如 0x1000;右边画地址 0x1000 对应的 4 个字节,里面放 0xaabbccdd。读 p 是看左格,读 *p 是顺着左格里的地址去看右格。
自测二:函数调用栈
题目:
1 | |
问题:在本系列第 01 篇采用的 IA-32、cdecl、-O0 条件下,调用 add1(a) 时,返回地址、参数 x、局部变量 y 大致会放在哪里?函数返回后,CPU 怎样知道回到 main 的下一条指令?
答案:在 IA-32 cdecl 且关闭优化的条件下,调用者先把参数放到栈上,调用指令再压入返回地址,被调用函数通常建立栈帧并给局部变量留位置。开启优化后,局部变量可能只存在于寄存器里,函数也可能被内联;仍然遵守 cdecl 的外部调用需要遵守它的栈传参约定。
短补课:把栈画成一列地址。main 调用 add1 时,列里新增一段 add1 的栈帧;函数尾声先撤销局部栈空间、恢复保存的寄存器,ret 再从栈顶取出返回地址并推进栈指针,执行流回到 main。这不等于旧栈帧的字节立刻被擦除,只是那段空间不再归 add1 使用。后续解释中断、系统调用和任务切换时,也会画类似的现场。
自测三:位掩码
题目:
1 | |
问题:最后第 3 位和第 7 位分别是什么?~(1u << 3) 为什么能清掉第 3 位?
答案:第 3 位最后是 0,第 7 位是 1。1u << 3 只让第 3 位为 1;取反以后,只有第 3 位为 0,其他位为 1。flags &= ... 会保留其他位,同时把第 3 位清零。
短补课:把二进制的低 8 位写出来,高位暂时省略。00001000 表示第 3 位,取反得到 11110111,和任意值按位与以后,第 3 位一定变成 0。页表项权限、PIC 屏蔽位、键盘状态和窗口标志都会用这种写法。
自测四:用 objdump 找 C 函数对应的机器指令
题目:
第 01 篇配套项目的 sample/demo.c 包含以下函数。工具链准备好后,在 examples/build-an-os/ 目录中运行命令;本题现在可以先阅读输出的识别方法。
1 | |
1 | |
问题:反汇编结果里怎样找到 add_one?能否指出进入函数、读参数、加一、返回的大致指令?
答案:objdump -d 会按符号列出机器指令。能看到类似 <add_one>: 的标签,后面是该函数对应的指令字节和汇编。不同优化级别会让指令形状变化,所以第 01 篇会固定编译选项,再用反汇编验证“源代码确实变成了目标架构机器码”。
短补课:先用 -O0 编译,让栈帧保留得更直观;再试一次 -O2,观察简单函数可能被优化到很短。读不懂每条指令没关系,先学会定位符号、地址和指令边界。
30 个单元怎样推进
“30 天”在这里表示 30 个学习单元,不承诺每天一篇。基础单元预留阅读 45 至 60 分钟、实现和调试 90 至 180 分钟;分页、任务切换、用户态、ELF、文件系统和窗口集成可能拆成 2 至 3 次实验。
| 阶段 | 单元 | 做到什么 | 阶段完成标志 |
|---|---|---|---|
| 启动 C 内核 | 01–06 | 工具链、启动扇区、二阶段加载、保护模式、C 入口、早期输出 | 能从镜像进入 kernel_main,并区分加载失败、跳错地址和进入后崩溃 |
| 中断与内存 | 07–12 | 异常、时钟、键盘、物理页、分页、内核堆 | 能响应输入、诊断页故障,并在资源耗尽时返回明确结果 |
| 任务与用户态 | 13–18 | 协作式任务、抢占、等待唤醒、Ring 3、系统调用、ELF 加载 | 用户程序通过系统调用输出,非法访问只终止违规进程 |
| 文件与 Shell | 19–24 | 进程生命周期、块设备、FAT16、文件描述符、Shell、两段管道 | 程序从磁盘文件加载,cat README.TXT | wc 能完成 |
| 图形窗口 | 25–30 | Mode 13h、字体缓冲、鼠标、窗口、窗口终端、综合验收 | 终端跑在窗口里,完整演示可从干净构建复现 |
每一篇都从上一版的一个限制开始。轮询键盘不够用,就引入中断;程序必须主动让出 CPU,就引入抢占;任意指针都能写内核,就引入用户态和页表权限。机制按可观察的问题推进。
每篇还会包含一个正常实验和一个故意失败实验。只有正常输出不能证明内核可靠;改坏启动签名、触发除零、传入坏指针、破坏 FAT 簇链,可以检查系统是否能报告错误并停在可诊断的位置。
边界
这个系列计划实现教学系统,不是生产内核。
它会使用现成编译器、汇编器、链接器、GDB、QEMU 和 BIOS 固件。自制范围计划覆盖最小引导程序、内核核心机制和一组用户程序。使用 BIOS 启动服务不等于实现了固件;使用 GCC 也不等于实现了编译器。处理器机制以 Intel 64 and IA-32 Architectures Software Developer Manuals 为准。
文件系统只读,默认支持 FAT16 根目录和 8.3 文件名。Shell 只支持最小命令解析和两段管道。图形部分使用 320×200、256 色的 Mode 13h,用来演示像素绘制、输入分发、遮挡恢复和窗口终端,不追求现代 UI。
内存模型计划采用 IA-32 非 PAE 两级分页和 4 KiB 页。它能演示用户/内核权限、只读页和页故障处理,但不宣称 NX 位、地址空间随机化或现代内核安全特性。
调度模型是单 CPU。中断里只做短处理,后续工作交给队列和任务。它会讲阻塞、唤醒、进程退出和资源回收,但不会实现多核同步、实时调度或完整 POSIX。
学习节奏
读者可以按三步推进一个单元。
第一步,先读目标和图。只要能说清“上一版缺什么”和“这一版多了什么”,就可以进入代码。
第二步,照着构建命令跑出预期结果。遇到差异时,先记录屏幕、串口、GDB 和镜像布局,不急着改代码。
第三步,运行故意失败实验。教学内核的价值不只在跑通 hello,更在失败时能把现场留给读者:CPU 在哪里、寄存器是什么、访问了哪个地址、哪个资源被耗尽。
业余时间学习时,可先按每周 2 至 3 个单元安排约 10 至 15 周;这是排期预算,尚未经读者试读校准。中断、分页、任务切换、用户态和文件系统这几段要留调试时间。它们第一次出错时往往不是语法问题,而是某个隐含约定没画出来。
第 01 篇会先做什么
第 01 篇不急着写启动扇区。它先冻结工具链:i686-elf GCC、Binutils、NASM、GDB、QEMU 和 Make。宿主系统可以不同,目标机器必须一致。
验收也很小:生成一个 ELF32/i386 样例文件,用 objdump 检查入口和反汇编;启动一个空实验镜像,让 QEMU 跑起来;让 GDB 连上 QEMU 并停住 CPU。
这一步看起来不像“写操作系统”,但它决定后面每一篇的可复现性。没有固定的目标架构、编译选项和调试入口,后面的崩溃会同时像 C 问题、汇编问题、链接问题和模拟器问题。
练习
- 画出“BIOS、启动扇区、第二阶段引导器、内核、用户程序”之间的交接图,并给每条箭头写一个交接物:地址、寄存器、内存图、入口地址或系统调用号。
- 用纸模拟一个
read(fd, buf, len)系统调用。标出哪些参数来自用户态,内核必须检查哪些地址和长度,错误发生时该返回错误还是终止进程。 - 查一次本机是否已经有
qemu-system-i386、i686-elf-gcc、nasm和gdb。只记录版本和路径,不需要现在安装。
下一篇:01 - 准备工具链。
