☰
os-tutorial 从零手写操作系统:基于 GitHub 开源教程的 25 课渐进式实战指南
2026/9/30 7:27:18 网站建设 项目流程
  • 示例工程

【免费下载链接】os-tutorial

How to create an OS from scratch

项目地址:https://gitcode.com/gh_mirrors/os/os-tutorial
点击查看免费下载

导读

本文围绕开源仓库 os-tutorial("How to create an OS from scratch")的根级 README,系统梳理"从零编写操作系统"的完整实战路线:从 BIOS 引导扇区、16 位实模式汇编起步,依次跨越 32 位保护模式、GDT、C 语言内核、屏幕驱动、中断与 IRQ、键盘与时钟、简易 shell 与内存分配器,最终形成可运行在 QEMU 上的最小操作系统。读完本文,你将掌握一条可复现的从第一行引导汇编到 C 内核、再到硬件中断驱动与用户输入的渐进式开发路径,并能独立运行、调试和扩展这个迷你 OS。

项目定位:面向动手派的零理论 OS 课程

os-tutorial 是一个个人学习型开源项目,作者在根 README 中坦言其动机:大学里学过分页、信号量、内存管理等高级特性,却从未从自己的引导扇区开始动手实现过操作系统。这个仓库因此被设计成一份"代码教程",面向对底层计算感兴趣、但没有时间或精力通读 Linux 内核的开发者,核心特征是:

  • 几乎不讲理论:作者认为"过多的理论比没有理论更糟,因为它让事情看起来比实际更难"。理论交给 Google,教程只负责"动手"。
  • 课时短小:每课 5~15 分钟即可完成,配合一份简洁 README 和注释详尽的代码示例。
  • 严格按序推进:所有章节基于前一章的代码层层叠加,跳过章节会错过上下文(例如不知道第 05 课里mov ah, 0x0e为何存在,就是因为没看第 02 课)。

仓库根 README 同时给出明确的使用方法:先按编号顺序打开每个子目录的 README,阅读第一行(应预先熟悉的 Concepts 关键词)与第二行(本课 Goal),再读正文,最后打开注释完善的代码文件;有余力者可以"先读完 README 后尝试自己写代码,再对照示例,并动手破坏/复现以验证理解"。仓库的课程策略清单(Strategy)展示了完整路线图,其中已完成项包括:无 GRUB 从零引导、进入 32 位模式、从汇编跳转到 C、中断处理、屏幕输出与键盘输入、随需增长的迷你libc;尚未完成项包括:内存管理、文件系统、更完整的 shell、用户态、文本编辑器、多进程与调度,以及(如果足够大胆)BASIC 解释器、GUI 与网络。

需要特别说明的是,README 顶部的警示信息指出这是一个已停止维护的旧项目(存在已知技术与设计问题),其定位是"玩得开心"的教学资料,读者若想深入学习 OS 设计,应同时参考更新、更权威的资料。本文基于该仓库当前内容进行事实性讲解,不将其包装为最佳实践。

环境准备:安装 QEMU、NASM 与工具链

仓库第 00 章00-environment/README.md明确了运行全部课程所需的环境:QEMU 模拟器与NASM 汇编器。作者的开发环境是 Mac(并注明 Linux 体验更好,因为标准工具通常已就绪)。

  • 在 macOS 上:先安装 [Homebrew],再执行brew install qemu nasm。
  • 关键警告:不要使用 Xcode 自带的nasm,它多数情况下不可用,务必使用/usr/local/bin/nasm。
  • 部分系统把 QEMU 拆分成多个二进制,此时应以qemu-system-x86_64 binfile的方式调用。

对于进入 C 内核阶段(第 11 章起),还需要交叉编译工具链i386-elf-gcc、i386-elf-ld等,其构建过程详见下文"交叉编译器"一节。

第 01~02 课:写出第一个被 BIOS 认可的引导扇区

引导扇区的原理:512 字节与0xAA55魔数

计算机上电后,BIOS 并不知道如何加载操作系统,因此把这项任务委托给引导扇区(boot sector):它必须被放在磁盘的固定标准位置——磁盘的第一个扇区(cylinder 0, head 0, sector 0),大小恰为 512 字节。为了确认"这个磁盘可引导",BIOS 检查该扇区的第 511、512 字节是否为0xAA55。

第 01 课01-bootsector-barebones/README.md给出了最简单的引导扇区二进制形态:除首部用于跳转的e9 fd ff(无限跳转)外全是零,末尾以55 aa收尾。注意 x86 是小端序,因此 16 位值0xAA55在内存中写作字节序列55 aa。仓库中的 boot_sect_simple.asm 以汇编形式复现了同样的结构:

; Infinite loop (e9 fd ff) loop: jmp loop ; Fill with 510 zeros minus the size of the previous code times 510-($-$$) db 0 ; Magic number dw 0xaa55

其中times 510-($-$$) db 0负责把扇区填充到 510 字节($为当前位置、$$为段起始,二者相减得到已用字节数),dw 0xaa55在最后 2 字节写入魔数。用 NASM 编译为纯二进制并交给 QEMU 运行:

nasm -f bin boot_sect_simple.asm -o boot_sect_simple.bin qemu boot_sect_simple.bin

(某些系统需改用qemu-system-x86_64 boot_sect_simple.bin;若报 SDL 错误,可尝试--nographic与/或--curses标志。)运行后会看到一个提示 "Booting from Hard Disk..." 的窗口,随后便是无尽的死循环——这是新手第一次亲手让"硬件"按自己的代码运转。

用 BIOS 中断打印字符

第 02 课02-bootsector-print/README.md让沉默的引导扇区开口说话。做法是借助 BIOS 的视频服务中断int 0x10:把字符写入al(ax的低 8 位),把功能码0x0e写入ah(ax的高 8 位),0x0e的含义是"以 tty 模式写出al的内容",然后触发int 0x10。仓库 boot_sect_hello.asm 完整演示了逐字符打印 "Hello":

mov ah, 0x0e ; tty mode mov al, 'H' int 0x10 mov al, 'e' int 0x10 mov al, 'l' int 0x10 int 0x10 ; 'l' is still on al, remember? mov al, 'o' int 0x10 jmp $ ; jump to current address = infinite loop ; padding and magic number times 510 - ($-$$) db 0 dw 0xaa55

这个例子的精妙之处在于复用:连续两次int 0x10之间没有重新设置al,因此第二个 'l' 会重复打印——顺便证明了al的值在中断返回后保持不变。作者同时提醒:现实中不应假设ah恒定(其他进程可能污染寄存器),只是此刻我们是 CPU 上唯一运行的东西。可用xxd file.bin检查二进制内容,再用同样的nasm -fbin+qemu组合运行,屏幕上将出现 'Hello' 并挂起在死循环中。

第 03~06 课:内存寻址、栈与 16 位实模式基本功

内存布局与0x7C00偏移

第 03 课03-bootsector-memory/README.md的目标是搞清引导扇区被 BIOS 加载到0x7C00这一关键事实。为了讲透"偏移"概念,作者设计了一个对照实验:先在数据区定义the_secret: db "X",然后用四种方式尝试访问它:

  1. mov al, the_secret—— 把标签的偏移量当作值(错误)
  2. mov al, [the_secret]—— 把偏移量当作地址解引用(缺少0x7C00基址,仍是错的)
  3. mov al, the_secret + 0x7C00—— 手动加上基址(正确)
  4. mov al, 2d + 0x7C00—— 用字节计数手工计算 X 的实际位置(同样正确,但脆弱)

运行 boot_sect_memory.asm 会看到类似1[2¢3X4X的输出:第 1、2 种策略得到的是随机垃圾字节,第 3、4 种策略打印出正确的 X。注意:如果增删指令,必须重新数字节更新0x2d这个硬编码偏移。作者强调"没有 100% 理解引导扇区偏移和内存寻址之前不要进入下一节"。

随后引入的解决方案是汇编器的全局偏移指令[org 0x7c00](见 boot_sect_memory_org.asm):声明之后,所有内存标签的偏移自动加0x7C00,于是第 2 种策略mov al, [the_secret]变成标准写法。

栈:bp与sp

第 04 课04-bootsector-stack/README.md篇幅很短:bp寄存器保存栈底(基址),sp保存栈顶,栈从bp向下增长(即sp递减)。作者建议在代码不同位置自行尝试访问栈内地址,观察发生了什么。

控制结构、函数调用与字符串

第 05 课05-bootsector-functions-strings/README.md是进入磁盘读写前最重要的汇编基本功训练,覆盖四个主题:

字符串:以 null 字节结尾(和 C 一致),如mystring: db 'Hello, World', 0。引号内的文本会被汇编器转换为 ASCII,孤零零的0则作为0x00字节写入。

控制结构:除了已经用过的jmp $死循环,汇编跳转由上一条指令的结果决定,例如:

cmp ax, 4 ; if ax = 4 je ax_is_four ; do something (by jumping to that label) jmp else ; else, do another thing jmp endif ; finally, resume the normal flow ax_is_four: ..... jmp endif else: ..... jmp endif ; not actually necessary but printed here for completeness endif:

je(相等跳转)、jl(小于跳转)等条件跳转很多,语义直观。

函数调用:调用函数本质是跳转到标签,难点在参数与返回。最原始的做法是"预先约定"用al/ax传参、用固定标签(如endprint)返回,但这会迅速变成意大利面条式代码,且无法复用。正确解法分两步:

  • 用call/ret代替jmp对——CPU 会把返回地址压栈,ret自动弹出,返回点因此可变;
  • 用pusha/popa在函数入口把所有寄存器压栈、出口恢复,保证子函数修改寄存器没有副作用。

包含外部文件:语法为%include "file.asm"。主文件 boot_sect_main.asm 示范了完整用法:先mov bx, HELLO; call print,打印新行后mov dx, 0x12fe; call print_hex,最后jmp $挂起,并在挂起指令之后(而不是之前)%include两个子例程文件。这里也展示了调用约定:print用bx接收字符串基址,print_hex用dx接收 16 位数值。

子例程实现见 boot_sect_print.asm:print用pusha保护现场后,循环执行mov al, [bx]、cmp al, 0、je done检查串尾,再mov ah, 0x0e; int 0x10打印,add bx, 1步进;print_nl则依次发送换行0x0a与回车0x0d两个字节(可以删除回车字节观察差异)。

十六进制打印例程 boot_sect_print_hex.asm 是教学重点:它以dx(示例假设dx=0x1234)为输入,循环 4 次,每次用and ax, 0x000f屏蔽出低 4 位,加0x30转成数字字符('0'=0x30~'9'=0x39),若超过0x39再额外加 7('A'=0x41,与 58 相差 7)以表示 A~F;随后按HEX_OUT + 5 - cx计算写入位置,用ror dx, 4把下一个 nibble 移到低位;循环结束把HEX_OUT(初始为db '0x0000',0)交给print输出。运行结果会依次打印 "Hello, World"、换行、"Goodbye"、换行和0x12fe。

实模式分段

第 06 课06-bootsector-segmentation/README.md讲解 16 位实模式的分段寻址(熟悉者可直接跳过)。分段指通过cs(代码)、ds(数据)、ss(栈)、es(附加,即用户自定义)四个段寄存器为所有数据访问指定偏移;这些寄存器被 CPU隐式使用,一旦设置了ds,后续所有内存访问都会自动加上ds的偏移。实际地址的计算不是拼接,而是重叠:segment << 4 + address。例如ds = 0x4d时,[0x20]实际指向0x4d0 + 0x20 = 0x4f0。代码层面有一个实用提醒:不能直接把字面量mov进段寄存器,必须先用通用寄存器中转。

第 07 课:用 BIOS 中断从磁盘读数据

第 07 课07-bootsector-disk/README.md是"从引导扇区走向内核"的关键一跃:OS 不可能装进 512 字节的引导扇区,必须从磁盘读出更多代码。好消息是不必亲自控制盘片转动,只需像打印字符那样调用 BIOS 例程:把al设为0x02(读扇区功能码),配合所需的柱面(cylinder)、磁头(head)与扇区(sector)参数,触发int 0x13。

本课首次引入进位标志(carry bit):它是寄存器之外的一个额外位,用于记录运算是否溢出。例如mov ax, 0xFFFF; add ax, 1之后ax = 0x0000且 carry = 1。进位不能直接读取,而是被jc(carry 置位则跳转)等控制指令使用。另外,BIOS 会把al设为实际读到的扇区数,因此务必把它与期望值比较,以确认读取完整。

仓库的 boot_sect_disk.asm 实现了完整的磁盘读取例程,boot_sect_main.asm 准备参数并调用它。磁盘几何关系在此明确:引导扇区是 hdd 0 的 cylinder 0、head 0 的sector 1(扇区从 1 编号),因此第 512 字节之后的所有数据恰好属于 sector 2——主例程把这些"越界"数据填成样例数据,供引导扇区读回验证。

文档还记录了 QEMU 的实战坑:BIOS 在调用引导程序前会把驱动器号放入dl,但 QEMU 从硬盘引导时可能有兼容问题,两种解决办法是:

  1. 使用-fda标志(如qemu -fda boot_sect_main.bin),把dl设为0x00,通常即可正常工作;
  2. 显式使用-boot标志(如qemu boot_sect_main.bin -boot c),自动把dl设为0x80,引导程序便可读取数据。

第 08~10 课:32 位保护模式、GDT 与模式切换

直接写 VGA 显存打印

进入 32 位模式后 BIOS 中断将失效,因此第 08 课08-32bit-print/README.md提前准备了一套不依赖int 0x10的打印例程:直接操作 VGA 视频内存。VGA 内存起始地址为0xb8000,其文本模式(80×25 网格)可避免操作像素。单个字符的寻址公式为:

0xb8000 + 2 * (row * 80 + col)

即每个字符占 2 字节:一字节 ASCII、一字节颜色/属性,行与行在内存中连续拼接。实现见 32bit-print.asm,它固定把字符串打印到屏幕左上角,更高级的例程留待 C 内核阶段。

GDT:全局描述符表

第 09 课09-32bit-gdt/README.md说明保护模式下分段机制的变化:段偏移不再是地址,而成为GDT 中段描述符(Segment Descriptor)的索引。描述符定义段基址(32 位)、大小(20 位)及权限等标志位;为便于编程,最省事的方式是定义重叠的代码段与数据段各一个——放弃内存保护,但足够完成引导(后续用高级语言实现再修复)。教学上还有两个要点:

  • 第一个 GDT 表项必须为0x00,用于暴露程序员管理地址时的失误;
  • CPU 不能直接加载 GDT 地址,需要一层元结构"GDT 描述符"(16 位大小 + 32 位地址),用lgdt指令装载。

进入保护模式的七个步骤

第 10 课10-32bit-enter/README.md把切换过程封装进 32bit-switch.asm,标准流程为:

  1. 禁用中断(cli);
  2. 加载 GDT(lgdt);
  3. 设置控制寄存器cr0的保护模式位;
  4. 通过精心构造的远跳转冲刷 CPU 流水线;
  5. 更新全部段寄存器;
  6. 更新栈;
  7. 跳转到已知标签BEGIN_PM(32 位下第一段有效代码的入口)。

运行 32bit-main.asm 即可在屏幕上看到两段消息,标志着我们已经"站在"保护模式中,下一步就是编写真正的内核。

第 11~13 课:交叉编译器与第一个 C 内核

搭建 i386-elf 交叉编译器

从第 11 课11-kernel-crosscompiler/README.md开始,开发语言转向 C,为此需要一个交叉编译工具链。Mac 用户必须立即执行本课;Linux 用户可稍后。步骤分两段:

准备:安装依赖包 gmp、mpfr、libmpc、gcc(Mac 上经 Homebrew;构建交叉 gcc 需要本机 gcc 而非已弃用的 clang),然后导出环境变量:

export CC=/usr/local/bin/gcc-4.9 export LD=/usr/local/bin/gcc-4.9 export PREFIX="/usr/local/i386elfgcc" export TARGET=i386-elf export PATH="$PREFIX/bin:$PATH"

构建 binutils:

mkdir /tmp/src cd /tmp/src curl -O http://ftp.gnu.org/gnu/binutils/binutils-2.24.tar.gz # 若 404 请寻找更新版本 tar xf binutils-2.24.tar.gz mkdir binutils-build cd binutils-build ../binutils-2.24/configure --target=$TARGET --enable-interwork --enable-multilib --disable-nls --disable-werror --prefix=$PREFIX 2>&1 | tee configure.log make all install 2>&1 | tee make.log

构建交叉 gcc:

cd /tmp/src curl -O https://ftp.gnu.org/gnu/gcc/gcc-4.9.1/gcc-4.9.1.tar.bz2 tar xf gcc-4.9.1.tar.bz2 mkdir gcc-build cd gcc-build ../gcc-4.9.1/configure --target=$TARGET --prefix="$PREFIX" --disable-nls --disable-libssp --enable-languages=c --without-headers make all-gcc make all-target-libgcc make install-gcc make install-target-libgcc

产物位于/usr/local/i386elfgcc/bin,全部带i386-elf-前缀以避免与本机编译器冲突;建议把$PATH写入.bashrc。文档特别提醒:粘贴网上的长命令前要小心,推荐逐行复制。注意,仓库中的版本号(binutils-2.24、gcc-4.9.1)是写作当时的选择,链接失效时应自行查找更新版本。

用 C 编写同样的底层代码

第 12 课12-kernel-c/README.md的核心实验是"用 C 编译出和汇编等价的东西,并反汇编验证"。编译独立的系统级代码需要-ffreestanding标志:

i386-elf-gcc -ffreestanding -c function.c -o function.o i386-elf-objdump -d function.o

反汇编输出中的机器码与汇编课程里见过的如出一辙。随后用链接器生成无元数据的裸二进制(本例把偏移固定在0x0):

i386-elf-ld -o function.bin -Ttext 0x0 --oformat binary function.o

用xxd对比function.o(含调试信息、标签)与function.bin(纯机器码),再用ndisasm -b 32 function.bin反汇编。文档还布置了三个可选的进阶小实验:localvars.c(局部变量)、functioncalls.c(函数调用)、pointers.c(指针),并抛出思考题:为什么pointers.c的反汇编结果与预期不符?"Hello" 的 ASCII0x48656c6c6f去哪儿了?

第一个可引导的 C 内核

第 13 课13-kernel-barebones/README.md拼合出完整的最小 OS:一个打印 'X' 的 C 内核 + 一个能引导它的引导扇区。

内核:kernel.c 里有一个空函数dummy_test_entrypoint()——它的存在是为了迫使内核入口例程指向一个真实标签main(),而不是内核的第 0 字节。编译命令为i386-elf-gcc -ffreestanding -c kernel.c -o kernel.o。

入口例程:kernel_entry.asm 演示了汇编的[extern main]用法:声明外部符号后用call main调用 C 函数,链接器负责解析它的内存位置,随后jmp $挂起。它不再生成二进制,而是生成 ELF 目标文件:nasm kernel_entry.asm -f elf -o kernel_entry.o。

链接:把两个目标文件链接成单个裸二进制内核,并把内核放到0x1000(引导扇区必须知道这个地址):

i386-elf-ld -o kernel.bin -Ttext 0x1000 kernel_entry.o kernel.o --oformat binary

引导扇区:与第 10 课的版本高度相似,bootsect.asm 去掉打印消息的行后只有几十行。编译:nasm bootsect.asm -f bin -o bootsect.bin。

拼接与运行:两个文件直接拼接成一个镜像:

cat bootsect.bin kernel.bin > os-image.bin

用qemu-system-i386 -fda os-image.bin运行(若遇磁盘读取错误,可在软盘0x0与硬盘0x80间调整驱动器号)。屏幕上应依次出现四段内容:16 位实模式启动消息、"Loading kernel into memory"、左上角 "Landed in 32-bit Protected Mode",以及覆盖其上的 'X'。

Makefile:Makefile 把这个过程自动化:kernel.bin由i386-elf-ld -o $@ -Ttext 0x1000 $^ --oformat binary生成,kernel_entry.o由nasm -f elf生成,kernel.o由i386-elf-gcc -ffreestanding -c生成,kernel.dis用ndisasm -b 32反汇编(便于调试),os-image.bin用cat $^拼接,run目标执行qemu-system-i386 -fda,另有clean清理产物。首个规则all: run使无参数make直接进入运行。

第 14 课:代码重组与 gdb 调试

第 14 课14-checkpoint/README.md是一个里程碑:停下来把代码整理进boot/、kernel/等目录(多数文件是从前几课符号链接而来,日后需要修改时应删除软链重建实体文件),编写可扩展的 Makefile,并引入qemu 的 gdb 远程调试。由于 OSX 的lldb与 ELF 格式不兼容(Homebrew 的 gdb 也不行),需要交叉编译一个i386-elf-gdb(步骤与交叉编译器类似,--program-prefix=i386-elf-)。

make debug目标会构建带符号的kernel.elf(得益于 gcc 的-g标志;用strings kernel.elf可以看到其中的字符串),并启动 qemu 的 gdb 连接。调试演示步骤极具教学价值:

b main ; 在 kernel.c:main() 设断点 continue ; 运行 OS next ; 单步 next ; 再单步,屏幕上还没有 'X' print *video_memory ; 查看显存,能看到 "Landed in 32-bit Protected Mode" 的 'L' print video_memory ; 确认指针地址正确 next ; 执行写入 print *video_memory ; 'X' 已经出现在显存与屏幕上

文档还顺带预告了内核架构取向:鉴于实现难度,预计是宏内核(monolithic),微内核设计留作未来的 "15-b" 可能。

第 15~17 课:I/O 端口、VGA 驱动与屏幕滚动

用 C 访问 I/O 端口

第 15 课15-video-ports/README.md开始用 C 与设备通信,核心是 drivers/ports.c 中的内联汇编——它的语法与手写汇编不同(源/目标操作数顺序相反、操作数绑定变量的写法特殊)。示例目标:查询屏幕光标位置。具体做法是向端口0x3d4写入14请求光标位置的高字节,写入15请求低字节,查询结果保存在端口0x3d5(实现见 drivers/ports.h 声明的端口读写函数)。文档再次强调 gdb 的用途:因为还无法在屏幕上打印 C 变量,可对kernel.c:21设断点用print检查变量。最后把查到的光标位置用于写一个字符。

kprint 屏幕驱动

第 16 课16-video-driver/README.md正式实现字符串输出。驱动接口定义在 drivers/screen.h:屏幕常量 + 三个公开函数——清屏clear_screen()与写字符串的kprint()/kprint_at()("kernel print" 的著名命名)。drivers/screen.c 内部结构为:

  • 两个 I/O 端口例程get_cursor_offset()/set_cursor_offset()(复用第 15 课);
  • 直接操作显存的print_char();
  • 三个行列与偏移互转的小工具函数。

kprint_at:col/row传-1表示在当前光标位置打印。它先设定行列与偏移三个变量,再遍历char*逐个调用print_char();print_char返回下一个光标的偏移并回传给循环。kprint只是kprint_at的薄封装。

print_char:同样允许-1坐标(此时从硬件读光标位置)。它还处理换行——把光标偏移定位到下一行的第 0 列。VGA 单元占 2 字节:1 字节字符 + 1 字节属性。

新版 kernel/kernel.c 终于能打印字符串,并刻意测试了跨行定位、换行与越界写,为下一课的滚动埋下伏笔。

屏幕滚动

第 17 课17-video-scroll/README.md在 drivers/screen.c 的print_char底部新增检查:当当前偏移超过屏幕大小时滚动文本。滚动由新函数memory_copy承担——它是标准memcpy的简化版(为避免命名空间冲突而改名),实现位于 kernel/util.c。为直观演示滚动,还实现了整数转文本的int_to_ascii(标准itoa的快速实现),注意多位整数会反序打印——这是有意为之,后续课程再扩展。kernel.c每行先打印行号,后续的kprint会迫使内核滚动。文档指出:本课覆盖完了 os-dev.pdf 的全部内容,之后转向 OSDev wiki 的 Meaty Skeleton 路线。

第 18~20 课:IDT、中断、IRQ 与首个硬件中断

中断描述符表(IDT)

第 18 课18-interrupts/README.md(深受 JamesM 教程启发)建立 IDT。首先在 cpu/types.h 定义u8/u16/u32类型以解耦原始字节与 char/int,并让既有文件改用新类型;从此 C 头文件一律带 include guard。

中断是内核必须处理的核心事项之一,例子包括除零、越界、非法操作码、页错误等。IDT 的条目与 GDT 类似(第 09 课),但用 C 而非汇编实现:

  • cpu/idt.h 定义idt_gate(必须配满 256 个条目,哪怕为空,否则 CPU 可能 panic)与idt_register(仅含内存地址与大小,类似 GDT 寄存器),以及供汇编访问的变量;
  • cpu/idt.c 为每个结构填入 handler,然后执行lidt汇编指令装载。

ISR(中断服务例程):cpu/isr.h 手动声明 32 个 ISR(extern,因为实现位于汇编);cpu/isr.c 提供一次安装全部 ISR 并装载 IDT 的函数、错误消息列表与高层 handler(默认 kprint 信息后停机,可自定义);cpu/interrupt.asm 是胶水层:公共低层 ISR 模板负责保存/恢复状态并调用 C 代码,随后是isr.h引用的具体 ISR 汇编函数。registers_t结构正是interrupt.asm中压栈的全部寄存器的镜像。最后把interrupt.asm纳入 Makefile,让内核安装 ISR 并触发其中一个——注意 CPU 不会自动停机(虽然部分中断后停机是好习惯)。

重映射 PIC,打通 IRQ

第 19 课19-interrupts-irqs/README.md解决一个关键冲突:CPU 启动时,PIC 把 IRQ 0~7 映射到 INT 0x8~0xF、IRQ 8~15 映射到 INT 0x70~0x77,这与上一课编程的 ISR 0~31 冲突;标准做法是把 IRQ重映射到 ISR 32~47。PIC 通过 I/O 端口通信(见第 15 课):主 PIC 命令口0x20、数据口0x21,从 PIC 命令口0xA0、数据口0xA1。重映射代码含掩码,细节见 OSDev 的 PIC 页面。

后续改动沿 C→汇编→C 的链路推进:在isr.c中为 IRQ 添加 IDT 门;在interrupt.asm的global声明末尾补充 IRQ 符号、在文件底部添加 IRQ handler(跳转到新的irq_common_stub);irq_common_stub与 ISR 版类似,并声明[extern irq_handler];C 侧的irq_handler()向 PIC 发送 EOI(中断结束命令)并调用interrupt_handlers数组中的对应 handler(数组与注册函数定义在isr.h顶部/isr.c中)。本课kernel.c无改动,成果留待下一课检验。

时钟与键盘:第一个实战 IRQ

第 20 课20-interrupts-timer/README.md终于可以检验硬件中断。时钟:在 cpu/timer.h 声明、cpu/timer.c 实现init_timer()——计算时钟频率并把字节发送到相应端口。同时修正int_to_ascii()使其正序打印数字(需要实现reverse()与strlen())。回到kernel.c做两件事:重新启用中断(关键!)并初始化定时器中断,make run即可看到时钟跳动。

键盘:drivers/keyboard.c 中有一个回调函数与一个配置中断回调的初始化函数(接口见 drivers/keyboard.h)。难点在于 PIC 返回的不是 ASCII 码而是键按下/键抬起的扫描码(scancode),因此文件中有一张长表把扫描码翻译成 ASCII(当前仅覆盖美式键盘的简单子集)。至此已具备构建简易 shell 的前提。

第 21~24 课:shell、内存分配器与收尾修复

简易 shell:解析用户输入

第 21 课21-shell/README.md做两件事:清理代码 + 解析用户输入。

代码清理:随着功能增长,把utils.c拆分为 libc/mem.c 与 libc/string.c(各自带头文件,这就是内核版 "C 库" 的开端);新增irq_install()让内核一次调用即可初始化所有 IRQ(类似isr_install());同时禁用timer_callback()中的kprint()以免刷屏;把drivers/ports.*移入cpu/(明显是 CPU 相关代码);Makefile 增加更严格的 CFLAGS(关闭-nostdlib/-nostdinc带来的隐患、把警告升级为错误,并为此修正若干指针声明);在 libc/function.h 加宏避免未使用参数的警告错误。

键盘字符的流动:按键时回调通过新数组取得 ASCII 码 → 追加到key_buffer缓冲区 → 同时打印到屏幕;操作系统需要读取输入时调用libc/io.c:readline()。keyboard.c还解析退格键:从缓冲区移除末字符,并调用screen.c:kprint_backspace()从屏幕删除(为此print_char()被改成打印退格时不推进偏移)。

响应输入:键盘回调检测到换行便通知内核"用户有输入"。最后一块拼图是libc/string.c中的strcmp()(两串相等返回 0)。当用户输入 "END" 时,内核停机——这就是从零实现的最基本 shell。文档鼓励继续扩展kernel.c解析更多命令,未来接入文件系统后就能执行更多指令。

kmalloc:极简内核内存分配器

第 22 课22-malloc/README.md在 libc/mem.c 中实现分配器。原理极简:一个指向空闲内存的指针free_mem_addr不断增长。kmalloc(size, align, phys_addr)行为如下:

  • align == 1时把地址对齐到 4K(0x1000)边界(free_mem_addr &= 0xFFFFF000; free_mem_addr += 0x1000);
  • 若传入phys_addr则同时保存真实物理地址;
  • 返回当前指针并free_mem_addr += size递增。

kernel.c保留全部 shell 代码,仅演示新函数:首个页面从硬编码的0x10000开始,随后的kmalloc()依次产出与前一次相隔 4096 字节(0x1000)的地址。配套改动:新增strings.c:hex_to_ascii()以便更友好地打印十六进制数;把types.c改名为type.c保持语言一致性。

修复 JamesM 教程的已知问题

第 23 课23-fixes/README.md针对 OSDev wiki 记录的 JamesM 教程缺陷(第 18~22 课沿用了该教程)逐项修复:

  1. CFLAGS 错误:编译.o时补上-ffreestanding;删除此前禁用了 libgcc 的-nostdlib及下一项需要的-nostdinc。
  2. main()命名:gcc 视main为特殊关键字,故改为kernel_main(),并同步修改boot/kernel_entry.asm;为消除 "cannot find entry symbol _start" 警告,在kernel_entry.asm中增加global _start;与_start:标签。
  3. 自定义类型:放弃u32等非标类型,改用 C99 标准定宽类型uint32_t(<stdint.h>在-ffreestanding下仍可用),删除type.h中的自定义类型及多余的__asm__/__volatile__下划线。
  4. kmalloc对齐与类型:大小参数改用size_t(<stddef.h>),凡"计数"且不可能为负的参数都应使用它;未来将把kmalloc改造成真正的内存管理器。
  5. 缺失函数:mem*系列函数留待后续课程实现。
  6. 中断处理器:cli冗余(IDT 条目的idt_gate_t标志已决定 handler 内是否开中断);sti同样冗余(iret从栈中恢复的 eflags 已含中断开关位,即中断处理器自动恢复中断状态)。registers_t中声称的esp改名useless(它属于当前栈上下文而非被中断现场),useresp改名esp。在cpu/interrupt.asm的call isr_handler前加cld;最关键的是 ABI 修复:公共 stub 在栈上构造registers实例再调用 C handler 会破坏 ABI(栈归被调函数所有),应改为传指针——registers_t r改为registers_t *t、字段访问由.改->,汇编中调用isr_handler/irq_handler前push esp传指针、事后pop eax清理。timer 与 keyboard 两个回调同样改为接收registers_t指针。

El Capitan 环境升级

第 24 课24-el-capitan/README.md面向从课程一开始跟随、升级 macOS El Capitan 后 Makefile 无法编译的读者:升级交叉编译器(其余读者可跳过)。流程与第 11 课基本一致:brew upgrade(gcc 升到 5.0)、xcode-select --install更新命令行工具、导出CC=/usr/local/bin/gcc-5与LD=/usr/local/bin/gcc-5,随后按相同步骤重建 binutils 与交叉 gcc(源码版本同样需要按需更新)。完成后在本课目录执行make验证一切编译顺畅。

总结与延伸路径

从仓库根 README 的 Strategy 清单看,os-tutorial 完成了从引导到输入输出的完整闭环:无 GRUB 从零引导 → 32 位保护模式 → 汇编到 C → 中断处理 → 屏幕/键盘 → 迷你 libc →(部分)内存管理 → 简易 shell;尚未完成的有:正式的内存管理、文件系统、更完整的 shell、用户态、文本编辑器、多进程与调度,以及可选的 BASIC 解释器、GUI 与网络。每一课的成果都以可编译、可运行、可调试为验收标准,从 01-bootsector-barebones/boot_sect_simple.asm 的 7 行死循环,到 23-fixes 中带严格 CFLAGS、标准类型与指针化中断处理的工程化代码,读者可以清楚地看到一条由简到繁、不断重构的 OS 开发真实轨迹。

结合根 README 顶部的弃坑警告,建议把本仓库当作"第一遍动手"的体验教材:它教会你 BIOS 引导协议、实模式寻址、模式切换、GDT/IDT、端口 I/O、中断驱动的设备交互等核心机制;在形成整体概念后,再转向更现代、权威的 OS 设计资料(如 OSDev wiki 的 Meaty Skeleton 及其后续章节)进行系统学习。动手方式上,始终遵循仓库 README 给出的六步法:按顺序读每章 README 首行(预备概念)与次行(课时目标)→ 通读正文 → 尝试自行编写 → 对照注释详尽的示例 → 主动破坏与复现验证。这样每个 5~15 分钟的课时,最终都会沉淀为一个可运行的最小部件。

  • 示例工程

【免费下载链接】os-tutorial

How to create an OS from scratch

项目地址:https://gitcode.com/gh_mirrors/os/os-tutorial
点击查看免费下载

相关推荐

上一篇:蓝奏云直链解析终极指南:3秒获取高速下载链接的完整方案
下一篇:Navicat Mac版无限试用期终极解决方案:3种高效重置方法详解

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询