一、文件概述:生命的复制与进化
fork.c 是Linux 0.11中实现进程创建的核心文件,位于/kernel目录。如果说sched.c是进程的调度者,那么fork.c就是进程的造物主。它实现了Unix哲学中最具革命性的概念之一:fork()系统调用——通过复制自身来创建新进程。这个看似简单的操作,却蕴含着操作系统设计中最精妙的内存管理和性能优化思想。
1.1 历史背景:Unix的进程模型
在Unix之前,操作系统创建新进程的方式通常是加载一个全新的程序。但Unix的创始人Ken Thompson和Dennis Ritchie提出了一个革命性的想法:让进程复制自己。这样做的优势在于:
简化编程模型:子进程继承父进程的所有状态(文件描述符、内存、环境变量)。
实现并发:通过
fork()后跟exec(),可以轻松创建新程序。支持管道:
fork()使得进程间通信(如管道)变得自然。
Linux 0.11的fork.c实现了这一经典模型,并引入了写时复制(Copy-on-Write, COW)这一关键优化。
1.2 核心挑战:高效复制
完全复制一个进程的所有资源(尤其是内存)是极其昂贵的。在1991年的硬件条件下(4-8MB内存),复制几MB的进程空间可能导致:
内存浪费:父子进程可能很快执行
exec()替换整个内存映像。性能瓶颈:内存复制操作耗时,降低系统响应速度。
资源竞争:大量复制操作可能耗尽物理内存。
fork.c通过写时复制技术优雅地解决了这些问题。
二、关键数据结构与宏定义
2.1 进程表与内存管理
在深入fork.c之前,需要理解几个关键数据结构:
// 进程表(最大64个进程) struct task_struct *task[NR_TASKS] = {&init_task,}; // 内存映射位图(管理物理页) extern unsigned char mem_map[PAGING_PAGES]; // 获取线性地址对应的页目录项 #define get_base(ldt) _get_base( ((char *)&(ldt)) ) #define _get_base(addr) ({\ unsigned long __base; \ __asm__("movw %1,%%dx\n\t" \ "movw %%dx,%0" \ :"=m" (__base) \ :"m" (*((addr)+2))); \ __base; })2.2 写时复制标志
写时复制的核心在于页表项的权限控制:
// 页表项标志位(在 mm.h 中定义) #define PAGE_PRESENT 0x001 // 页面存在 #define PAGE_RW 0x002 // 页面可写 #define PAGE_USER 0x004 // 用户可访问 #define PAGE_COW 0x800 // 自定义:写时复制页面(Linux 0.11实际使用PAGE_RW控制) // 实际实现中,通过清除PAGE_RW位来标记COW页面三、copy_process():进程复制的核心
3.1 函数原型与参数
int copy_process(int nr, long ebp, long edi, long esi, long gs, long none, long ebx, long ecx, long edx, long fs, long es, long ds, long eip, long cs, long eflags, long esp, long ss)这个函数有17个参数!这是因为fork()是通过系统调用进入的,所有寄存器值都从用户态栈中保存而来。参数对应着system_call.s中压栈的寄存器顺序。
3.2 复制流程详解
步骤1:分配进程控制块(PCB)
struct task_struct *p; p = (struct task_struct *) get_free_page(); // 获取4KB空闲页 if (!p) return -EAGAIN; task[nr] = p; // 放入进程表 *p = *current; // 复制父进程的task_struct(浅拷贝)内存分配:
get_free_page()从mem_map中找一个空闲物理页。结构复制:直接内存拷贝父进程的整个
task_struct。这是安全的,因为不包含指针(除了后面会单独处理的字段)。
步骤2:初始化子进程特有字段
p->state = TASK_UNINTERRUPTIBLE; // 设置为不可中断状态 p->pid = last_pid; // 分配新PID p->father = current->pid; // 设置父进程PID p->counter = p->priority; // 初始化时间片 p->signal = 0; // 清除信号 p->alarm = 0; // 清除闹钟 p->leader = 0; // 不是会话首进程 p->utime = p->stime = 0; // 清空时间统计 p->cutime = p->cstime = 0; p->start_time = jiffies; // 设置启动时间PID分配算法:last_pid从1开始递增,但需要检查是否已被占用(进程表遍历)。
步骤3:复制内核栈
// 为子进程分配一页作为内核栈 long *krnstack; krnstack = (long *) get_free_page(); if (!krnstack) { free_page((long) p); return -EAGAIN; } // 设置子进程的内核栈指针 p->tss.esp0 = (long) krnstack + PAGE_SIZE; p->tss.ss0 = 0x10; // 内核数据段选择子 // 复制父进程的内核栈内容(系统调用返回时需要) *((long *)krnstack + PAGE_SIZE/4 - 1) = 0; // 栈底标记 // ... 复制其他栈内容关键点:每个进程有两个栈——用户栈和内核栈。fork()时只复制内核栈,因为用户栈将通过写时复制共享。
步骤4:复制页表(写时复制的关键)
// 复制页目录和页表 copy_page_tables(get_base(current->ldt[1]), get_base(p->ldt[1]), get_limit(0x0f) >> 12);copy_page_tables()是写时复制的核心实现,位于mm/memory.c中,但由fork.c调用。
步骤5:复制文件描述符
for (i = 0; i < NR_OPEN; i++) if ((f = p->filp[i])) f->f_count++; // 增加文件引用计数共享打开文件:子进程继承父进程所有打开的文件,通过增加引用计数实现共享。
步骤6:复制当前目录和根目录
if (current->pwd) current->pwd->i_count++; // 增加inode引用计数 if (current->root) current->root->i_count++;共享目录项:子进程继承父进程的工作目录和根目录。
步骤7:设置任务状态段(TSS)和局部描述符表(LDT)
set_tss_desc(gdt + (nr << 1) + FIRST_TSS_ENTRY, &(p->tss)); set_ldt_desc(gdt + (nr << 1) + FIRST_LDT_ENTRY, &(p->ldt));TSS:保存进程的硬件上下文(寄存器值)。
LDT:定义进程的代码段、数据段和堆栈段。
步骤8:最终设置
p->state = TASK_RUNNING; // 设置为就绪状态 return last_pid; // 返回子进程PID给父进程四、写时复制(Copy-on-Write)的深度解析
4.1 copy_page_tables() 的实现
int copy_page_tables(unsigned long from, unsigned long to, long size) { unsigned long *from_page_table, *to_page_table; unsigned long this_page, nr; // 遍历所有页目录项 for ( ; size-- > 0 ; from += 4 * 1024 * 1024, to += 4 * 1024 * 1024) { // 获取源页目录项 from_page_table = (unsigned long *) (0xfffff000 & *from_dir); // 为目标分配新页表 to_page_table = (unsigned long *) get_free_page(); if (!to_page_table) return -1; // 内存不足 // 设置目标页目录项 *to_dir = ((unsigned long) to_page_table) | 7; // 用户可读写 // 复制页表项 nr = 1024; for ( ; nr-- > 0 ; from_page_table++, to_page_table++) { this_page = *from_page_table; if (!(this_page & 1)) // 页面不存在 continue; // 清除源页表的写权限(标记为COW) *from_page_table = this_page & ~2; // 清除PAGE_RW位 // 复制页表项到子进程 *to_page_table = this_page; // 增加物理页的引用计数 this_page &= 0xfffff000; // 获取物理页地址 mem_map[MAP_NR(this_page)]++; // 引用计数加1 } } return 0; }4.2 COW的工作原理
共享物理页:父子进程的页表项指向相同的物理页。
只读保护:两个进程的页表项都被标记为只读(清除PAGE_RW位)。
写时触发缺页:当任一进程尝试写入该页时,触发页错误(Page Fault)。
缺页处理:内核的缺页中断处理程序(
page_fault)检测到这是COW页面:分配新的物理页
复制原页内容到新页
修改当前进程的页表项,指向新页并恢复写权限
另一进程仍指向原页(仍为只读)
4.3 缺页中断中的COW检测
在mm/page.s的缺页处理程序中:
page_fault: // ... 保存寄存器等 // 检查错误代码(由CPU自动生成) testl $1, %error_code // 测试是否由写操作引起 jz 1f // 如果不是写操作,跳转到其他处理 // 检查页表项是否标记为只读(COW页面) movl %cr2, %edi // 出错地址 call is_cow_page // 检查是否为COW页面 testl %eax, %eax jnz handle_cow // 如果是COW页面,跳转到处理程序 1: // 其他缺页处理...五、性能优化与内存管理
5.1 引用计数机制
mem_map[]数组跟踪每个物理页的引用计数:
0:空闲页
1:被一个进程独占
>1:被多个进程共享(COW页面)
当引用计数降为0时,页面被释放回空闲池。
5.2 惰性分配策略
fork()并不立即复制所有页面,而是:
立即复制:页表结构(4KB)
惰性复制:实际数据页面(在写时复制)
对于典型的进程(代码段1MB,数据段100KB):
传统fork:立即复制1.1MB
COW fork:立即复制4KB(页表),实际数据页面按需复制
5.3 内存压力下的行为
当系统内存不足时:
get_free_page()可能失败,返回-EAGAIN父进程的
fork()返回-1,设置errno为EAGAIN应用程序应等待并重试
六、vfork():更激进的优化
Linux 0.11还实现了vfork(),这是fork()的变体,更加激进:
6.1 vfork与fork的区别
// sys_vfork 系统调用 int sys_vfork(void) { return do_fork(CLONE_VFORK | CLONE_VM, 0, 0, 0); }共享地址空间:
CLONE_VM标志表示父子进程共享相同的页表。父进程挂起:
CLONE_VFORK标志表示父进程等待子进程执行exec()或exit()。不复制页表:完全没有内存复制开销。
6.2 vfork的使用场景
pid_t pid = vfork(); if (pid == 0) { // 子进程:立即调用exec() execl("/bin/ls", "ls", "-l", NULL); _exit(1); // 如果exec失败 } // 父进程:等待子进程结束警告:子进程不能修改任何栈变量或返回,否则会破坏父进程的状态。
七、进程创建的性能分析
7.1 时间开销对比
在33MHz 386上(假设4MB内存):
操作 | 传统fork | COW fork | vfork |
|---|---|---|---|
页表复制 | 4KB/1ms | 4KB/1ms | 0 |
数据复制 | 1MB/250ms | 0 | 0 |
总时间 | ~251ms | ~1ms | ~0.1ms |
7.2 内存开销对比
状态 | 传统fork | COW fork |
|---|---|---|
fork后 | 2倍内存 | 1倍内存+COW开销 |
子进程写一半页面后 | 2倍内存 | 1.5倍内存 |
子进程exec后 | 2倍内存(短暂) | 1倍内存 |
八、设计哲学与历史意义
8.1 Unix哲学的体现
简单接口:
fork()返回两次的神奇设计,用简单的API隐藏了复杂的进程复制。资源继承:子进程继承父进程的所有环境,使得shell、管道等设计变得自然。
组合性:
fork()+exec()的组合提供了强大的进程创建能力。
8.2 COW的创新意义
写时复制技术是操作系统领域的重大创新:
延迟优化:将昂贵的操作推迟到真正需要时。
透明性:对应用程序完全透明,无需修改代码即可获得性能提升。
通用性:不仅用于
fork(),后来也用于mmap()、fork()+exec()优化等。
8.3 局限性
地址空间浪费:32位地址空间限制下,COW的页表复制仍可能浪费资源。
fork炸弹风险:恶意程序可以通过不断fork消耗系统资源。
vfork的危险性:要求程序员严格遵循使用规范,容易出错。
九、现代Linux的演进
9.1 从COW到更细粒度的共享
现代Linux引入了:
线程:通过
clone()系统调用,共享地址空间、文件描述符等。命名空间:容器技术的基础,隔离进程视图。
cgroups:控制资源使用,防止fork炸弹。
9.2 性能优化
快速用户空间互斥(futex):优化进程同步。
透明大页(THP):减少页表项数量。
PID命名空间:支持更多的进程ID。
9.3 安全增强
地址空间布局随机化(ASLR):防止攻击者预测内存布局。
栈保护:防止栈溢出攻击。
seccomp:限制进程的系统调用。
十、总结:复制的艺术
fork.c 展现了操作系统设计的精髓:在简单接口背后隐藏复杂实现。
哲学层面:
fork()的“一次调用,两次返回”是Unix API设计的典范,体现了“做一件事并做好”的哲学。技术层面:写时复制是计算机科学中惰性求值思想的完美应用,将昂贵的复制操作推迟到最后一刻。
工程层面:仅用几百行代码,就实现了安全、高效的进程复制,支持了Unix的多任务生态。
从fork.c出发,我们可以看到Linux如何平衡:
性能与功能:通过COW在几乎零开销的情况下实现进程复制。
简单与强大:简单的API背后是复杂的内存管理和硬件交互。
共享与隔离:父子进程共享资源,但又通过COW保持写操作的隔离。
这个1991年的实现,至今仍是现代操作系统进程模型的基石。每当你在Linux中运行一个命令,或看到Docker容器启动时,背后都是fork.c中开创的复制机制在默默工作。