极简的种子:Linux 0.01 内核架构与关键代码鉴赏
摘要:Linux 0.01 是林纳斯·托瓦兹于1991年9月发布的首个公开内核版本,仅包含约10239行代码(剔除注释与空行后约8670行)-10。本文以该版本完整源码为基础,从启动流程、进程管理与调度、内存管理、MINIX文件系统、系统调用与信号机制、字符设备驱动六个维度展开深度剖析,逐一解读 head.s 引导路径、 schedule() 调度算法、 copy_page_tables() 地址映射、 buffer_head 缓冲区管理、 system_call.s 中断入口等关键代码。在此基础上,本文将0.01版本置于操作系统设计的历史语境中,分析其宏内核选择与x86分段机制的深度耦合所带来的架构约束,并探讨这一“极简种子”如何通过后续版本的渐进演化,生长为今日超过3600万行的现代内核。
一、引言:一万行的操作系统
1991年9月17日,芬兰赫尔辛基大学21岁的学生林纳斯·托瓦兹在 comp.os.minix 新闻组发布了一个“基于AT机、类似minix的kernel”的源代码,版本号标为0.01-。这个版本的内核由约5900行ANSI C、2500行C头文件和1450行i386汇编代码构成,总计10239行,若剔除注释与空行则仅余约8670行-10。作为对比,2023年的Linux 6.5-rc5内核已包含约3600万行代码-10。
0.01版本的功能边界极为清晰:它实现了多任务处理、基本的虚拟内存分页、基于MINIX的文件系统,以及66个系统调用,涵盖 open、read、write、fork、execve、pipe 等核心接口-10。但它尚不支持网络(socket概念完全缺席), mount 等系统调用仅返回 ENOSYS,可执行文件格式仅有 a.out(即汇编代码中硬编码的格式常量)-10。
本文将深入这批代码的每一个关键节点,解析一个“刚够运行”的操作系统是如何被构建的,以及它所做出的那些设计选择,哪些被后续版本继承,哪些被彻底抛弃。
二、代码结构与架构总览
Linux 0.01的源码树极其紧凑。最顶层包含 boot/、init/、kernel/、mm/、fs/、include/、lib/ 等目录。 boot/ 下仅有 boot.s 和 head.s 两个汇编文件; init/ 下仅有 main.c; kernel/ 集中了调度( sched.c)、系统调用入口( system_call.s)、进程创建( fork.c)、信号( signal.c)和陷阱处理( traps.c); mm/ 包含内存管理( memory.c)和页面错误处理( page.s); fs/ 则承载了文件系统的全部逻辑,从缓冲区管理( buffer.c)到MINIX文件系统实现( bitmap.c、inode.c、super.c)再到字符设备接口( char_dev.c)和管道( pipe.c)。
这一目录结构本身即是一种架构宣言:内核的每一类职责都被映射到一个独立的编译单元,彼此通过头文件中的全局函数声明和全局变量耦合。没有模块化加载机制,没有设备驱动的统一抽象层,所有代码在编译时即静态链接为一个单一的可执行镜像。
从架构范式来看,0.01是彻头彻尾的宏内核。林纳斯在与MINIX作者塔能鲍姆的著名论战中明确选择了单体内核路线,理由直截了当:“消息传递开销过大,宁愿牺牲模块性换取性能”-4。这一判断在当时x86硬件性能有限的条件下具有充分的工程合理性,但也决定了0.01内核的所有子系统——调度、内存、文件系统、设备驱动——共享同一个地址空间和同一套全局数据结构,子系统之间通过直接函数调用和全局变量引用实现交互,而非通过定义良好的接口。
三、启动流程:从BIOS到第一个C函数
Linux 0.01的启动过程分为三个阶段:BIOS引导、32位保护模式初始化和内核初始化-。
3.1 boot.s:实模式下的自举
boot.s 运行于实模式,由BIOS从软盘引导扇区加载至物理地址 0x7C00。它的首要任务是将自身从 0x7C00 重定位到 0x9000,以释放低地址空间。随后通过 BIOS 中断 int 0x13 将内核镜像(由 head.s 和 main.c 编译链接而成)从软盘加载到物理地址 0x10000。关键的实模式到保护模式切换由以下代码完成:
text
复制
下载
mov ax,#0x0001lmsw axjmpi 0,8lmsw 指令将CR0的PE位(保护模式使能位)置1,随后的 jmpi 0,8 执行远跳转,段选择符 8 指向GDT中的代码段描述符,偏移 0 跳转到该段的起始地址。此时的GDT是 boot.s 中临时建立的,仅定义了覆盖0-8MB的代码段和数据段。
3.2 head.s:32位初始化与分页开启
head.s 以 .code32 开头,在保护模式下运行。它首先设置数据段和栈段,调用 setup_idt 建立中断描述符表,调用 setup_gdt 建立更完整的全局描述符表。随后进入分页初始化:
text
复制
下载
setup_paging:movl $1024*3,%ecxxorl %eax,%eaxxorl %edi,%edicld;rep;stoslmovl $pg0+7,pg_dirmovl $pg1+7,pg_dir+4这段代码首先清空从 pg_dir 开始的3×1024个双字(即页目录和两个页表),然后将页目录的第0项设为 pg0+7 (物理地址加上存在位、读写位和用户位),第1项设为 pg1+7。由于每个页表覆盖4MB,两个页表恰好映射前8MB物理内存-61。最后设置CR3指向页目录,并置CR0的PG位开启分页。
值得注意的是, head.s 在开启分页后,通过 pushl $L6 和 pushl $main 将 main 函数的地址压入栈,再跳转到 setup_paging,利用 ret 指令“返回”到 main。这一技巧使得从汇编到C的过渡在栈上完成, main 被调用时栈帧已经就绪-61。
3.3 main.c:内核的“第一推动”
init/main.c 是内核中唯一的初始化代码。它的执行序列精确反映了0.01内核的依赖关系:首先通过 CMOS_READ 从CMOS芯片读取当前时间存入 startup_time,然后依次调用 tty_init() 初始化终端设备、 trap_init() 设置中断向量表、 sched_init() 初始化调度器、 buffer_init() 初始化磁盘缓冲区、 hd_init() 设置硬盘中断处理程序-。
sched_init() 中有一个关键操作:将任务0的TSS和LDT描述符写入GDT,并加载TR和LDTR寄存器。任务0是内核在启动过程中“手工构造”的,其 task_struct 在编译时静态分配于 init_task 变量中,而非通过 fork 创建。初始化完成后, main 执行 move_to_user_mode() 宏,通过构造iret栈帧将特权级从0切换到3,进入用户态。随后调用 fork() 创建任务1(init进程),任务0自身则进入一个无限循环,反复调用 pause(),本质上是执行 schedule() 并让出CPU-31。
任务1的 init() 函数进一步调用 setup() 检查硬盘分区表,然后 fork 出 update 进程用于定期同步磁盘,再 fork 出 shell 进程,以 HOME=/usr/root 的环境打开登录shell-31。至此,一个完整的、可交互的多任务操作系统启动完毕。
四、进程管理与调度: counter 的朴素公平4.1 task_struct 与进程控制块
0.01的 task_struct 定义于 include/linux/sched.h,是整个内核中进程抽象的载体。它包含的关键字段有: state (进程状态,取值 TASK_RUNNING、TASK_INTERRUPTIBLE、TASK_UNINTERRUPTIBLE、TASK_ZOMBIE、TASK_STOPPED)、 counter (剩余时间片)、 priority (静态优先级)、 signal (信号位图)、 alarm (定时器)、 pid 及 father/p_pptr (父子关系)、 start_code/end_code 等内存区域边界,以及 tss (任务状态段)的完整副本-4。
全局数组 task[NR_TASKS] 持有所有进程的指针, NR_TASKS 硬编码为64。 current 指针指向当前运行的进程。进程0( init_task )和进程1( init )在系统启动时被创建,其余进程通过 fork 动态分配。
4.2 schedule():完整代码与逐层解读
Linux 0.01的调度器是理解其设计哲学的最佳入口。整个调度逻辑集中在 kernel/sched.c 的 schedule() 函数中,不足40行:
c
复制
下载
void schedule(void) {int i, next, c;struct task_struct **p;/* 检查alarm,唤醒收到信号的进程 */for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)if (*p) {if ((*p)->alarm && (*p)->alarm < jiffies) {(*p)->signal |= (1 << (SIGALRM-1));(*p)->alarm = 0;if ((*p)->signal && (*p)->state == TASK_INTERRUPTIBLE)(*p)->state = TASK_RUNNING;/* 调度主体 */while (1) {c = -1; next = 0; i = NR_TASKS;p = &task[NR_TASKS];while (--i) {if (!*--p) continue;if ((*p)->state == TASK_RUNNING && (*p)->counter > c)c = (*p)->counter, next = i;if (c) break;for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)if (*p)(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;switch_to(next);}这段代码的每一个部分都值得剖析。
第一部分(alarm与信号唤醒):调度器在做出调度决策之前,先遍历所有进程,检查哪些进程的 alarm 定时器已到期( (*p)->alarm < jiffies )。到期时,进程的 signal 位图中 SIGALRM 对应的位被置位。同时,任何处于 TASK_INTERRUPTIBLE 状态且收到了信号的进程被置为 TASK_RUNNING。这一设计将信号唤醒逻辑嵌入调度器,而非独立的信号处理路径,使得信号导致的进程状态变化与调度决策在同一临界区中完成,避免了竞态条件。
第二部分(选择下一个进程):内层 while (--i) 循环从 task[NR_TASKS-1] 向 task[0] 遍历,对每个 TASK_RUNNING 状态的进程,比较其 counter 值。 counter 最大的进程被选为 next 。如果所有可运行进程的 counter 都为0或负数,则跳出内层循环,进入第三部分:重新计算所有进程的 counter 为 (counter >> 1) + priority 。这是一种经典的多级反馈思想的简化实现:当一个进程的时间片耗尽后,其 counter 被减半,然后加上静态优先级 priority 。这意味着刚刚用完时间片的进程( counter 接近0)获得的重置值接近其 priority ,而长时间睡眠的进程( counter 保持为正数)获得的重置值更高,从而在重新获得CPU时享有更高的调度权重-64。这种机制自然地对I/O密集型(交互式)进程有利——它们大部分时间在睡眠,counter 不会因时间片耗尽而衰减。
switch_to(next)宏是调度器与硬件上下文切换之间的唯一接口。它通过比较 next 与 current ,若不同则执行 ljmp 跳转到 next 进程的TSS选择符,由硬件完成寄存器保存与恢复-4。
4.3 调度触发:时钟中断驱动
调度并非随时发生。在0.01中,最主要的调度触发源是时钟中断。 sched_init() 将8253定时器配置为100Hz,即每10ms产生一次IRQ0中断。中断处理程序 timer_interrupt 调用 do_timer(1) 递增 jiffies,并递减当前进程的 counter 。当 counter 降至0或以下,或进程主动调用 pause() / sleep_on() 时,调度才会发生-。这种“被动触发”的设计意味着0.01的调度是粗粒度的:进程在两次时钟中断之间不会被抢占,即使有更高优先级的进程就绪。
五、内存管理:共享页目录下的64MB切片5.1 地址映射的双层机制
Linux 0.01运行于80386的保护模式,虚拟地址到物理地址的转换经过两个阶段:分段将16位段选择符和32位偏移组成的逻辑地址映射为32位线性地址;分页将线性地址映射为物理地址。在0.01中,分段并非可选的“遗留机制”,而是进程隔离的核心手段。
5.2 每个进程64MB的虚拟地址空间
0.01强制每个进程的虚拟地址空间为64MB。这一约束直接源于GDT中LDT描述符的段限长设置。在 include/linux/head.h 中, set_base 和 set_limit 宏用于动态填充段描述符。每个进程拥有自己的LDT,其中代码段和数据段的限长被设为64MB(以页粒度计,即 0xFFFF 个4KB页)。因此,进程的虚拟地址范围始终是 0 到 64MB-5。
所有进程共享一个页目录。进程0的64MB虚拟空间通过页目录的第0-15项映射到线性地址0-64MB;进程1的虚拟空间通过第16-31项映射到线性地址64-128MB,以此类推。 copy_page_tables() 函数在 fork 时被调用,其核心操作是将父进程页目录中的对应项复制到子进程的线性地址“窗口”中,并递归复制页表项,同时将页表项设置为只读,实现写时复制(Copy-on-Write)。当任一进程尝试写入时,页面错误触发 do_wp_page() ,此时才分配新物理页并复制内容。
5.3 物理内存管理与页面分配
0.01管理8MB物理内存,通过 mem_map[] 数组追踪每个4KB页的使用情况。 get_free_page() 遍历 mem_map ,返回第一个空闲页的物理地址并标记为已用; free_page() 执行相反操作。 free_page_tables() 在进程退出时递归释放页表映射的所有物理页。这些函数构成了一个极简的物理内存分配器,没有伙伴系统,没有slab缓存,分配策略是简单的线性扫描。
由于没有按需分页(demand paging),可执行文件在 execve 时被完整加载到内存中。 do_no_page() 和 do_wp_page() 是页面错误的两个主要处理程序,分别处理“页不存在”和“写保护”两种情况,但它们的逻辑仅涉及线性扫描和页表操作,没有预读或页面替换算法。
六、文件系统:MINIX之上的缓冲层6.1 架构分层
0.01的文件系统是MINIX文件系统的一个变体实现,代码集中在 fs/ 目录下。其架构分为三层:最底层是缓冲区管理层( buffer.c ),负责磁盘块的缓存和I/O调度;中间层是MINIX文件系统逻辑( bitmap.c、inode.c、super.c ),处理块分配、inode管理和超级块操作;最上层是文件操作接口( file.c、char_dev.c、pipe.c ),向上暴露系统调用。
6.2 buffer_head与缓冲区管理
所有磁盘I/O都通过缓冲区完成。 struct buffer_head 是缓冲区的基本单位:
c
复制
下载
struct buffer_head {char *b_data;unsigned long b_blocknr;unsigned char b_dev;unsigned char b_uptodate;unsigned char b_dirt;unsigned char b_count;struct buffer_head *b_prev;struct buffer_head *b_next;};b_data 指向实际的数据块(1KB), b_blocknr 记录对应的逻辑块号, b_dirt 标记脏页, b_count 是引用计数。系统启动时 buffer_init() 根据可用内存大小分配缓冲区,在8MB RAM的典型配置下可分配约522个缓冲区-。缓冲区以哈希表组织以便快速查找,同时通过 b_prev/b_next 维护空闲链表和脏块链表。当文件系统请求一个块时, bread() 首先在哈希表中查找;命中则直接返回,未命中则通过 ll_rw_block() 发起磁盘读请求,并阻塞等待。
6.3 inode与目录操作
MINIX文件系统使用inode作为文件元数据的载体。 struct m_inode 包含 i_mode (文件类型和权限)、 i_size 、 i_zone[9] (9个块指针,其中前7个直接指向数据块,第8个为一级间接,第9个为二级间接)、 i_dev 等字段。 iget() 和 iput() 管理inode的引用计数, ialloc() 和 ifree() 处理inode的分配与释放。
目录被实现为文件,其数据块中包含 struct dir_entry 数组,每项由16位的inode号和14字节的文件名组成。 namei() 函数负责路径解析,逐级查找目录项。
6.4 pipe的实现
0.01的管道机制巧妙复用了文件系统和inode抽象。 pipe.c 中,管道被实现为一种特殊的inode:当用户调用 pipe() 时,内核分配一个空闲inode并调用 get_free_page() 为其分配一个数据页,管道的读写两端分别对应两个 struct file 结构,共享同一个inode。写端调用 write() 时向inode的缓冲区写入数据,读端通过 read() 读取,两端通过inode的 i_size 和读写位置进行同步,当缓冲区满或空时通过 sleep_on() 阻塞-。这种设计使得管道复用了文件系统的inode生命周期管理(iget/iput)和缓冲区机制,以极少的代码实现了进程间通信。
七、系统调用与信号:中断入口的汇编艺术7.1 system_call.s的栈布局
系统调用的硬件入口由 int 0x80 触发,CPU自动切换到内核栈并保存 ss、esp、eflags、cs、eip。 system_call.s 中的 system_call 标签随后压入 ds、es、fs,再将 ebx、ecx、edx 压栈作为C函数的参数-72。 sys_call_table 是一个函数指针数组,索引即为系统调用号:
asm
复制
下载
call sys_call_table(,%eax,4)这条指令通过 eax 中的系统调用号乘以4,从表中取出函数地址并调用。调用号的有效性由 cmpl $nr_system_calls-1,%eax 检查,越界则跳转到 bad_sys_call 返回 -1-72。
7.2 信号处理的内核路径
信号检查发生在每次系统调用返回和时钟中断返回的末尾。 ret_from_sys_call 标签首先检查当前进程是否为任务0(任务0不接收信号),然后检查返回时CS的RPL位——如果返回到内核态(RPL=0),则跳过信号检查,因为信号只应递交给用户态进程-72。信号位图通过 bsfl 指令找到最低置位,即优先级最高的待处理信号。如果信号处理函数地址为1,表示忽略该信号;如果为0,执行默认处理(终止进程);否则将处理函数地址写入EIP,并调整用户栈以传入信号编号。
这一机制的关键特征是:信号不是在任意时刻递交给进程的,而只发生在从系统调用或中断返回用户态的时刻。这意味着信号的响应具有一定的延迟,但避免了在内核态任意点打断执行流所可能引发的复杂同步问题。
八、设备驱动:硬编码的字符接口
0.01的设备驱动支持极为有限。 char_dev.c 仅51行,其核心是一个 crw_table 函数指针数组,索引为设备的主设备号,指向对应的读写函数。当 rw_char() 被调用时,它检查主设备号是否在有效范围内,然后通过函数指针分派到 tty_read、 tty_write 或 rw_memory-。
终端驱动( tty_io.c )是0.01中最复杂的驱动代码,处理键盘输入、屏幕输出的缓冲和终端控制。键盘中断通过 keyboard_interrupt 处理,从端口 0x60 读取扫描码,通过 key_table 映射为ASCII字符,存入 tty_table[0].read_q 队列。当用户进程调用 read(0, buf, n) 时, tty_read 从该队列中取出字符。
这种驱动的特点是:没有统一的 device 抽象、没有设备的注册/注销机制、没有ioctl的分层分派。每个驱动都直接操作硬件端口和内核全局变量,与调度器和文件系统紧密耦合。
九、设计局限与历史回响9.1 x86分段机制的深度耦合
0.01最显著的设计约束来自对x86分段机制的依赖。每个进程的64MB虚拟地址空间是通过LDT段描述符的限长字段实现的。这意味着进程隔离依赖于分段硬件,而非纯粹的分页机制。其直接后果是:全局描述符表(GDT)中LDT和TSS描述符的数量限制了系统的最大进程数——64个进程共用128个GDT条目(每个进程一个LDT和一个TSS)-5。
这一约束在后来的内核版本中被彻底抛弃。现代Linux使用“平坦模式”,所有段的基址为0、限长为4GB,进程隔离完全由分页机制实现。CR3寄存器的页目录切换取代了LDT切换,使得每个进程可以独享完整的4GB虚拟地址空间。
9.2 调度器的局限
0.01的调度器虽然在概念上已经包含了动态优先级的雏形,但存在几个根本性局限。首先,调度是时钟中断驱动的,粒度为10ms,且在两次中断之间不可抢占。其次, schedule() 函数本身在进程遍历期间持有全局锁(通过禁用中断实现),这对于64个进程的系统尚可接受,但不具备向多处理器扩展的可能。第三, counter 的重置公式 (counter >> 1) + priority 虽然对睡眠进程有利,但缺乏对CPU密集型进程的惩罚机制——一个长期运行的进程可能在每次重置后仍获得较高的 counter 值。
这些局限在后续版本中被逐步解决:1.2引入了更精细的优先级计算,2.4引入了O(1)调度器,2.6引入了CFS(完全公平调度器)。
9.3 从“极简种子”到现代内核的演化逻辑
0.01的代码注释中频繁出现“future extension”和“to be implemented”的字样,表明作者在最初编写时已经有了长期演进的规划-4。这种“极简起步、渐进扩展”的策略,恰恰是0.01最重要的遗产。它没有试图在第一版就解决所有问题,而是定义了一组最小可用的抽象—— task_struct 、 buffer_head 、 m_inode 、 system_call 入口——这些抽象在后来的三十余年间被反复扩展和重构,但核心概念始终保持稳定。
现代C++重构项目对0.01的批评也揭示了这种极简主义的代价:大量的宏定义、全局变量和类型不安全的函数指针表使得代码的可读性和可维护性在规模增长后迅速恶化。但正是这种“丑陋但有效”的代码,在1991年以不到一万行的体量,完成了一个操作系统内核应有的全部核心职责,并在随后的开源协作中获得了自我演化的能力。从这个意义上说,Linux 0.01的价值不在于它的代码质量,而在于它定义了一个可以持续成长的架构起点。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.