☰
Linux 0.01内核源码实战:从引导扇区到Shell的完整启动流程
2026/10/13 3:30:07 网站建设 项目流程

简介:Linux 0.01内核源代码压缩包是面向操作系统学习者、内核源码爱好者的经典入门资料,由林纳斯·托瓦兹于1991年编写,适合希望理解操作系统启动流程、进程与内存管理、设备驱动等底层原理的读者。压缩包内共91个文件,含45个C源文件、31个头文件、7个汇编源文件及5个Makefile,代码量约95KB;头文件提供数据结构与接口定义,汇编文件承担系统引导和部分硬件操作,Makefile则组织整体编译构建流程。目前已有755人学习下载,常被用来对照源码逐模块分析。资源目录划分清晰,涵盖boot启动引导、kernel核心、init初始化、fs文件系统、mm内存管理和tools工具等处,读者可据此追踪从开机到用户态进程建立的全过程,体会早期内核简洁而精巧的设计思路,是深入研究操作系统原理和Linux历史演进的宝贵素材。

1. 这份源码包到底能拿来干什么:从引导扇区到 shell 的完整轨迹

Linux 0.01 内核源代码这个 zip 包,第一眼像考古文件,但它确实是一份能编译、能启动、能进入终端交互的完整内核。拆开之后你会发现,这个系统的代码规模比大多数课程项目还小,却把引导、内存管理、进程调度、文件系统这几座大山全部用最直接的方式摆在面前。

它不适合当作现代服务器内核来用,适合用来回答一个核心问题:操作系统到底是怎么从 BIOS 的实模式一路走到用户 shell 的。需要做内核实验的学生、想深入理解保护模式切换的驱动开发者、以及被现代内核庞大源码劝退的爱好者,都可以拿它作为起点。

这篇笔记不写虚的,直接把我在复现过程中最关心的目录、编译参数、启动顺序和踩坑记录放出来。

2. 源码包与编译路径:解开压缩包后先看哪几个文件

2.1 顶层目录:12 个文件夹各自承担什么职责

解开 zip 后,第一眼不是密密麻麻的 C 文件,而是一个经过设计的目录树。boot 目录里放启动引导,包括 bootsect.s、setup.s、head.s 三个汇编文件;kernel 目录放进程、信号、中断、调度和字符设备;mm 目录只负责物理内存页管理;fs 目录覆盖文件系统与缓冲区。除此之外还有 lib 提供内核内部的基础库,include 放全局头文件。每一个子目录下面还挂着自己的 Makefile,最外层 Makefile 负责统一调度。

我建议的阅读顺序是 boot、kernel、mm,最后再碰 fs。原因是启动过程从 boot 进入 kernel,内存管理是进程调度的地基,文件系统只是后端存储逻辑,放最后读不会影响主线理解。下面这个表是按复现优先级整理的,不是按字母序:

| 目录 | 主要职责 | 进去第一眼看什么 | | boot | 引导与保护模式切换 | bootsect.s、setup.s、head.s | | kernel | 进程、中断、调度、字符设备 | sched.c、traps.c、system_call.s | | mm | 物理内存分配与页管理 | memory.c、page.s | | fs | 文件系统、缓冲区、管道 | inode.c、super.c、buffer.c | | lib | 内核内部的基础库 | string.c、malloc.c | | include | 内核全局头文件 | linux/head.h、sys/ 目录 |

如果你打开 include 目录发现里面有不少 sys/ 之类的子目录,不要慌,这属于当时老式源码常见的分法。真正重要的是你选的源码包是否保持完整目录结构,缺了 include 或者缺少链接,后面 make 一定失败。这个包的结构完整度直接决定复现时间,拿到手先find . -maxdepth 2 -type d | sort看一眼。如果要改动源码,建议先备份 boot 目录,引导部分的改动最容易让整个镜像报废。

2.2 Makefile 的编译顺序:为什么 boot 必须最先生成

最外层 Makefile 开头的变量列表几乎可以当作时代切片来看:AS、CC、LD、AR 分别指向当时的汇编器、C 编译器、链接器和归档器。CFLAGS 里带-mstring-ops这类 80386 时代的选项。真正影响全局的是链接顺序:boot 里的两个小程序单独生成,其余内核目标文件链成一个 system 模块,最后用 cat 把三者拼成最终镜像。

这里放一段简化过的 Makefile 片段,重点在顺序:

AS = as CC = gcc LD = ld CFLAGS = -O -g -mstring-ops -fno-builtin all: Image Image: boot/bootsect boot/setup system cat boot/bootsect boot/setup system > Image system: boot/head.o init/main.o kernel/*.o mm/*.o fs/*.o lib/*.o $(LD) -Ttext 0 -o system $^ boot/bootsect: boot/bootsect.s $(AS) -o boot/bootsect boot/bootsect.s

逻辑说明:Image 由 boot 块、setup 块、system 块三段顺序拼接。bootsect 必须占用磁盘第一个扇区,setup 紧跟其后,system 在最后。-Ttext 0把内核的虚拟起始地址固定在 0,配合保护模式分段机制,进入 32 位模式后跳转偏移可以直接落位。

参数说明:-mstring-ops让编译器使用 386 的字符串指令优化;-fno-builtin告诉编译器不要自作主张把 strlen 这类调用替换成内建逻辑。如果你拿新版 gcc 硬编,建议去掉-mstring-ops,保留-fno-builtin。如果链接时出现地址越界,先确认-Ttext 0还在,不要改成 0x100000。至于为什么用 cat 而不是链接器来做最终镜像,是因为 boot、setup、system 三段代码的运行基址完全不同,cat 是最简单直接的打包方式,在引导阶段由 BIOS 和 setup 接力把它们分配到各自位置。

2.3 三种编译路径:老工具链、新工具链、虚拟机交叉编译

0.01 的 C 代码用了大量八十年代末的语法,现代 gcc 直接编译会报错。常见做法按成功率排序,我实际用下来大概是这样:

第一种,搭一个 32 位老旧环境,安装配套老编译器,直接make Image。这是最贴近原版的方式,但环境难找。第二种,在新宿主上用老工具链交叉编译,安装兼容 binutils 后指定路径,成功率中等,踩的坑集中在汇编语法。第三种,在虚拟机里装一个教学用类 Unix 系统,把源码包用共享目录传进去,同样用老工具链编译,并在该系统内测试软盘启动。我一般推荐第三种,因为调试时的宿主噪声最少。

下面这条路径是典型的虚拟机内做法:

tar -xzf linux-0.01.tar.gz cd linux-0.01 make clean make Image ls -l Image

执行后如果出现Image文件,说明编译流程已经走通。这里的make clean不能省,因为前面如果有失败的编译残留,生成的 object 文件可能带着错误架构标记。如果 make 在某个子目录报as: unrecognized option,先确认 PATH 里的汇编器确实是老版本,而不是被系统默认的新版 binutils 截胡。如果看到/usr/include相关报错,说明 CC 指向了新编译器,它试图找宿主头文件,而内核本身并不需要这些外部头文件。

2.4 生成软盘镜像并跑起来:一条命令验证编译结果

拿到 Image 后,写盘这一步最容错。先建一个 1.44M 的全零软盘镜像,再把 Image 以不截断方式写进去:

dd if=/dev/zero of=floppy.img bs=1024 count=1440 dd if=Image of=floppy.img conv=notrunc qemu-system-i386 -fda floppy.img -boot a

逻辑说明:第一行构造标准软盘容量,第二行把 Image 覆盖到镜像开头但不改变目标文件大小,第三行让 QEMU 从软盘启动。conv=notrunc是关键,少了它 dd 会按源文件大小缩短目标文件,QEMU 会把软盘识别成异常格式。

参数说明:如果宿主是 64 位系统,有的 QEMU 版本需要额外加-machine pc或-cpu pentium才能兼容老式外设。看到黑屏上出现版本信息,然后进入类似 shell 的提示符,说明整条链路已经打通。跑不起来先回头看 Makefile 的-Ttext 0和目录结构,九成问题出在这两处。

提示:在进入下一步调试之前,最好先做一次unzip -l确认包内文件清单完整,尤其是 include 目录和软链接。这个动作能省掉后面很多 "文件不存在" 的排查。

3. 启动流程还原:从 BIOS 到第一个 shell 的每一步

3.1 bootsect.s 引导阶段:把 setup 和 system 按扇区读进内存

机器上电后 CPU 处于实模式,只有 1M 字节寻址空间。PC 兼容机约定把磁盘第一个扇区读入 0x7C00,然后跳过去执行。这个地址距离实模式内存顶端只差几百字节,所以 bootsect.s 一开始做的事不是站在 0x7C00 直接开读,而是把自己移动到 0x90000 这段安全区域。原因是后续要读入的 setup 和 system 会占用低地址空间,不搬家就会把自己的后续代码覆盖掉。实模式下计算内存地址要用段基址左移四位加偏移,这一点在读盘中断里体现得特别直接。

这里展示的读盘循环是简化过的示意:

load_setup: mov $0x02, %ah # 读磁盘 mov $0x04, %al # 要读的扇区数 mov $0x0000, %dx # 0 号磁头 mov $0x0002, %cx # 从第 2 扇区开始 mov $0x0200, %bx # 目标内存偏移 int $0x13 jc load_setup # 失败就重试

逻辑说明:int $0x13是 BIOS 磁盘中断,jc load_setup检查进位标志,一有错就无限重试。这种"读到成功为止"的写法在现在的引导程序里也很常见。读入位置通常放在 0x90200,也就是紧挨着自身区域,给后面的 setup 代码让出空间。

参数说明:%al里的扇区数不是随便写的。setup 模块的实际大小取决于你改了哪些汇编代码,改了 setup.s 后必须同步调整这个数字。%bx是目标偏移,实模式下是段基址加偏移的组合,这里用相对简单的偏移方式,详细地址要结合段寄存器的值计算。引导扇区本身只有 512 字节,bootsect.s 里如果塞了太多功能,编译出来超过 510 字节就会越界,启动时连加载步骤都走不完。

3.2 setup.s:收集硬件信息、搬移 system、准备 GDT

setup.s 的职责可以拆成三件事:读取内存大小,获取显卡参数,把 system 从临时加载点搬到地址 0。内存大小通常通过 BIOS 中断读取,结果存到内存固定位置,供后面 main.c 的mem_init使用。把内核放在 0 地址是保护模式分段机制决定的,后面 GDT 里代码段的基址设为 0,CPU 取指时逻辑地址直接等于物理地址,省去一套重定位逻辑。除了内存和显卡,setup 还必须处理 A20 地址线。实模式下 CPU 只能访问 1M 空间,保护模式要访问 1M 以上的内存,必须通过键盘控制器或快速 A20 门开启这条地址线,否则高地址会被回绕,表现就是系统反复重启。

准备保护模式的关键动作是加载全局描述符表:

lidtl idt_48 lgdtl gdt_48 movl %cr0, %eax orl $1, %eax movl %eax, %cr0 jmpi 0, 8

逻辑说明:lgdtl把预置的 48 位伪描述符加载进 GDTR 寄存器。movl、orl、movl这组操作是典型的读改写:把 CR0 最低位置 1,CPU 进入保护模式。jmpi 0, 8是远跳转,段选择子 8 指向 GDT 里的第二个描述符,偏移 0 正好落在 system 起始位置。

参数说明:选择子 8 换算成索引是 1(8 除以 8),对应 GDT 第一项真正的代码段描述符。索引 0 是强制保留的空描述符,不能碰。如果你把选择子改成 16 或 24,段基址会变到数据段或第三个段,指令流立即失控。GDT 伪描述符里包含表长度和线性基址,加载后 CPU 通过 GDTR 查找描述符,所以 48 位结构不能写错,否则第一次取指就重启。

3.3 head.s 与中断现场:从实模式过渡到 C 语言世界的跳板

setup 之后跳进 head.s。head.s 虽然只在开机时执行一遍,却决定了后续 C 代码能否安全运行。它建立正式 GDT 和 IDT,设置各个段寄存器,把栈顶指到内存布局的高端,然后调用 main.c 里的main()。此刻系统已经运行在 32 位模式,后续代码不再碰 BIOS 调用。setup 里的 GDT 只是临时撑场面的,head.s 加载的正式 GDT 会覆盖整个内核生命周期,包含代码段、数据段等多个描述符;IDT 则要初始化 256 个表项,每个表项 8 字节,存放各种中断和异常处理函数入口。

示意代码:

.globl _main start: lgdt gdt_48 lidt idt_48 xorl %eax, %eax movl %eax, %ds movl %eax, %es movl %eax, %fs movl %eax, %gs lss stack_start, %esp call _main

逻辑说明:段寄存器清零后访问内存时,线性地址直接等于逻辑地址,这是保护模式平坦段的特征。lss stack_start, %esp从内存结构里同时加载栈段和栈指针,栈顶指向系统内存布局的高端,避免向下增长时覆盖正在运行的内核代码。call _main是到 C 代码的最后一个台阶,之后就不再返回。

参数说明:stack_start在 kernel 目录里定义,栈大小和位置由链接脚本决定。调试时如果觉得栈溢出导致函数返回地址错乱,优先确认栈顶是否真的高于所有数据段,而不是盯着调用栈翻。IDT 表项的格式一旦出错,键盘中断、时钟中断都会丢,系统表现为完全没响应,但这条比栈问题更难定位,建议先用 gdb 检查 IDTR 指向的内存是否有效。

3.4 main.c 的初始化顺序:先内存后中断,再调度和文件系统

main()很短,但顺序很敏感。通常先mem_init,因为后续的中断表、页表、进程控制块都需要内存分配;然后trap_init把异常处理函数挂到 IDT;接着sched_init初始化第一个进程控制块和定时器。下面是一个按概念还原的初始化序列:

void main(void) { mem_init(memory_start, memory_end); trap_init(); sched_init(); buffer_init(); hd_init(); tty_init(); printk("<Linux version string>"); for (;;) pause(); }

逻辑说明:buffer_init打开的是文件系统的高速缓存,hd_init初始化硬盘中断,tty_init让终端可用。最后一行的printk能正常输出,说明内存、中断、键盘、显示都已就绪。pause在 0.01 里已经带有让出 CPU 的语义,这说明内核本身就是一个任务,初始化完之后它会退化成调度器的一员。

参数说明:memory_start和memory_end在 setup 阶段探测得到,不同机器取值不同,代码里必须有边界检查。调试时把这两个值打印出来,能快速排除内存上限探测错误导致的页表越界。这里的printk和标准 C 库的printf完全是两回事,内核不依赖用户态 libc,printk用的是内部实现的字符串格式化逻辑,所以它可以在tty_init之后稳定往终端写内容。

4. 避坑与常见问题:编译和启动过程中最容易翻车的五件事

这些坑不是从源码里看出来的,是我一遍遍编译、启动、加调试日志试出来的。它们集中在工具链版本和磁盘布局两类问题上。如果你用的也是零散下载的内核源码包,建议先看 Makefile 里提到的编译器版本,不要把宿主工具链直接当默认,这一步能避免后面大部分烦恼。另外不要用超级权限去编译老源码,老 Makefile 对文件属主敏感,权限错乱会带来与内核无关的奇怪问题。

4.1 编译阶段:工具链版本与汇编器兼容性

现象:make 时报 "as: unrecognized option"

原因:系统默认的汇编器是新版 binutils,不再支持上世纪八十年代的指令写法,报错常出现在-mstring-ops或老式操作数语法上。尤其在 64 位宿主上,新版 binutils 默认目标格式是 elf64,和 0.01 需要的 elf32 不一致。

解决:在 Makefile 里显式指定老汇编器路径,或命令行覆盖变量:

make AS=/usr/local/bin/as-2.x CC=/usr/local/bin/gcc-1.x

如果能正常生成 Image,说明工具链路径对了。不想改变系统 PATH 的情况下,这种方式最可控。改完记得make clean,否则旧的 object 文件会带着错误格式继续参与链接。

现象:解压后目录里的符号链接全部丢失

原因:zip 格式本身不保留 Unix 符号链接,而 0.01 源码包里的 include 目录常用链接指向具体架构文件。直接在宿主编译时,include 头文件路径残缺,报错往往是指定的头文件不存在。

解决:不要直接解压 zip 后在宿主编译,先把源码包在目标环境里重新打包成 tar 格式再解开,或者手动建立缺失的软链接。在解压前用unzip -Z列条目,能提前看到哪些条目是链接属性,提前做好替换准备。如果在宿主机上解压再传进虚拟机,换行符和文件权限也可能被破坏,减少一层变量最稳妥。

4.2 启动阶段:磁盘布局与调试输出的偏差

现象:编译通过,但 QEMU 黑屏无任何输出

原因:最常见的不是内核崩溃,而是 Image 文件没落到软盘镜像的开头。dd少了conv=notrunc,或者目标镜像大小不对,引导扇区全是零,CPU 根本走不到 bootsect。

解决:先用hexdump -C floppy.img | head -n 4看第一个扇区。如果开头是零,重新执行带conv=notrunc的写入。如果第一个扇区正常但后面系统没起来,问题就在 setup.s 的扇区数上,回到 3.1 里的%al检查。对于老式 VGA 终端,还可以尝试给 QEMU 加-vga std,部分新版默认显卡会让老内核的显示输出不可见。

现象:系统启动后反复重启,或卡在 "Loading system"

原因:setup.s 里读取 system 的扇区数与实际 system 模块占用的扇区数不符。0.01 的 Makefile 中这个数字往往是固定的,一旦改了内核代码体积,特别是一不小心多加了一个 C 文件,这个常数就失效了。

解决:重新计算 system 大小再对齐到扇区:

stat -c %s system echo $(( ( $(stat -c %s system) + 511 ) / 512 ))

把算出的扇区数填回 setup.s 里的读盘常量,重新make Image。我后来习惯把这个数字抽成 Makefile 变量,用size命令动态生成,避免每次手改出错。

现象:gdb 调试时 printk 输出看不到

原因:要么是printk在 tty_init 之前执行,输出设备还没注册;要么是 QEMU 的输出重定向没开,内核往串口或终端写的数据被调试器吞掉了。

解决:把 printk 调用挪到tty_init()之后,或者在 QEMU 启动参数里加-serial stdio,让内核输出直接落到宿主终端。调试启动流程时我一般先把 printk 临时改成写串口,这样 gdb 与内核输出不会互相干扰。改完记得复位打印位置,否则会掩盖初始化阶段的真实问题。

注意:建议第一遍先用原始代码跑通,不要一上来就加打印或改调度,不然会分不清是源码问题还是环境问题。老内核没有现代内核那么强的错误提示,一个黑屏背后可能是工具链、磁盘布局、中断表三处同时出问题。

5. 进阶验证:在 QEMU 里用 GDB 设断点,看保护模式切换的现场

5.1 停靠在开机地址等待调试器

QEMU 支持 gdbstub,可以暂停 CPU 执行来等外部调试器:

qemu-system-i386 -fda floppy.img -s -S

-s打开端口 1234 的 gdb 服务,-S表示初始 CPU 停在第一条指令之前。这个组合适合逐条观察引导扇区加载过程。

5.2 断点观察实模式到保护模式的跳变

在 GDB 里连接后,先停在 BIOS 加载 bootsect 的入口:

target remote localhost:1234 break *0x7c00 continue info registers

info registers能同时看到段寄存器和指令指针。继续单步到 bootsect 把自身复制到 0x90000,再用x/16bx 0x90000对比内存内容和源码里的机器码。等执行到 setup 里的jmpi 0, 8前后,单步跨过去,然后查看 CR0:

break *0x90200 continue stepi info registers cr

CR0 最低位从 0 变 1,说明保护模式已经开启。此时x/i $eip反汇编出的指令如果还是原来那行 32 位代码,说明 GDT 和段选择子都对。这套 "断点验证 CR0 切换" 的流程我每次拆引导资源都会强制走一遍,比盲改参数有说服力得多。从那以后,凡是涉及启动流程的源码,我都坚持先验证模式切换再碰业务逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询