
如果一个函数在你眼皮底下返回两次第一次返回一个正整数第二次返回0你可能会觉得是语言层面的Bug。但在操作系统内核里这正是fork的正常行为。而把这套行为讲得最清楚、最没有信息噪音的版本我认为就是 linux0.11 fork源码。最近又把这段代码从头到尾走了一遍包括用户态库函数怎么进入内核、sys_fork怎么找任务槽、copy_process怎么复制父进程的PCB和页表、最后子进程为什么不用走“内核返回路径”就能直接落在用户态。把这些链路串起来之后很多在现代Linux里怎么都看不明白的设计在这份只有两万行左右的“小内核”里都找到了源头。这篇就按我实际阅读的顺序把每一个关键函数、每一处容易踩坑的细节都摊开讲。1. 为什么是0.11的fork一段值得反复读的经典路径1.1 0.11这个版本的特殊位置linux0.11发布的时候整个内核还处在“能跑起来、但很多机制都还没成熟”的阶段。代码量不大目录结构也简单几个核心文件就撑起了一个完整的操作系统骨架。对于想搞懂进程、内存、文件系统、中断这些基础概念的人这个版本是极其友好的阅读对象。fork这个函数在0.11里的实现尤其典型。它不像现代内核那样有几百行的copy_process也不涉及cgroup、namespace、pidfd这些叠加概念。0.11的fork就是最原始的“复制父进程”逻辑找一个空闲任务槽把父进程的task_struct整体拷过去修正关键字段再拷贝页表让父子共享物理页面最后设置好子进程的TSS等调度器把它“放出来”。这条链路虽然短但每一步都踩在操作系统的核心机制上而且和现在Linux内核的思路完全同构只是实现方式更朴素、更直接。我还记得第一次读完0.11 fork源码之后再回去看现代内核的copy_process突然就不怕那一堆复杂字段了。因为你知道那些字段只是在处理更细的边界条件主干逻辑还是0.11里那几条。1.2 fork调用链的完整地图在进入源码之前先把整条路径画出来后面读代码时才有坐标系。0.11里一次完整的fork调用长这样用户态程序执行fork()库函数这个函数在include/unistd.h里通过_syscall0(int, fork)宏展开实际就是往寄存器里放系统调用号2然后执行int 0x80。之后CPU陷入内核跳转到kernel/system_call.s里的system_call入口。system_call先保存用户态的段寄存器、数据寄存器然后根据系统调用号去查sys_call_table找到对应的处理函数。0.11里fork对应的系统调用号是2表里第2项就是sys_fork。sys_fork不是一个普通C函数它写在汇编文件里。它先调用find_empty_process()找一个空闲的任务槽然后依次把gs、esi、edi、ebp和找到的任务槽号压栈再调用copy_process()。copy_process是fork真正干活的函数它复制父进程的task_struct调用copy_mem()接管线性地址空间和页表最后返回一个指向子进程task_struct的指针。从copy_process返回后sys_fork清理掉自己压的参数一路ret回system_callsystem_call再通过iret把执行权交回父进程的用户态。父进程的fork()返回值是子进程的PID子进程则是在之后某个时刻被调度器选中时通过TSS切换“空降”到用户态的返回值是0。整个调用链可以用一句话概括用户态fork()宏 →int 0x80→system_call→sys_fork→find_empty_processcopy_process→copy_mem→copy_page_tables。后面每个环节都值得单独拆开看。2. copy_process前半程槽位、PID与参数的来龙去脉2.1 find_empty_processPID就是槽位号的年代先看find_empty_process它在kernel/fork.c里逻辑很短但信息密度很高int find_empty_process(void) { int i; repeat: if ((last_pid) 0xffff) last_pid 1; for (i 0; i NR_TASKS; i) if (task[i] task[i]-pid last_pid) goto repeat; for (i 1; i NR_TASKS; i) if (!task[i]) return i; return -EAGAIN; }这个函数干了两件事生成一个不冲突的last_pid以及找一个空的任务槽下标。注意0.11的设计里PID和任务槽下标是强耦合的全局数组task[NR_TASKS]的下标就是进程的身份标识task[nr]-pid也直接等于nr。这和现代Linux里PID与task_struct完全解耦的设计很不一样算是早期内核的朴素做法。第一段循环是检查last_pid是否和已有进程PID冲突。last_pid是一个全局静态变量每次fork都把它加一如果和某个现存进程的PID撞了就重新加一。 0xffff是为了限制PID最大值不超过65535避免整数溢出后PID出现负数或乱跳。第二段循环是从下标1开始找第一个task[i]为空的槽位。注意下标从1开始0号位永远留给task0也就是内核初始化时创建的第一个进程。找到空槽后直接返回下标这个下标既是任务数组下标又是子进程将来的PID。如果所有64个槽位都满了就返回-EAGAIN。这就是0.11里fork失败最直接的原因进程数打到了系统上限。这里有个当年很常见但实际上很微妙的设计task[NR_TASKS]里最后一个元素位置通常是task0相当于设置了一个“哨兵”防止调度器扫描时越界。这个细节在0.11的调度代码里很常见也体现了早期内核用“约定哨兵”代替防御性编程的风格。2.2 copy_process的入参是如何拼凑出来的copy_process的C语言声明长这样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个参数看起来吓人但本质上就是一次“寄存器快照的搬运”。要理解为什么会有这么多参数得看sys_fork这个汇编函数sys_fork: call find_empty_process testl %eax,%eax js 1f push %gs pushl %esi pushl %edi pushl %ebp pushl %eax call copy_process addl $20,%esp 1: ret执行到sys_fork的时候CPU栈上已经从低到高积压了好几层数据system_call入口处压进的ds、es、fs、系统调用号eax、还有ebx、ecx、edx再往下是int 0x80由硬件自动压入的ss、esp、eflags、cs、eip。这一整块就是父进程被中断时的完整现场。sys_fork又往栈上压了5个参数gs、esi、edi、ebp、eax。这里的eax就是find_empty_process返回的任务槽号将来作为copy_process的第一个参数nr。所以copy_process的参数表可以按来源分成三组参数范围来源含义nr, ebp, edi, esi, gssys_fork显式压栈任务槽号 父进程的通用寄存器、gs段寄存器none, ebx, ecx, edx, fs, es, dssystem_call入口压栈父进程的通用寄存器、fs/es/ds段寄存器eip, cs, eflags, esp, ssCPU的int 0x80自动压栈用户态被中断时的指令指针、代码段、标志寄存器、栈指针、栈段第6个参数none是个占位符对应的栈位置其实是system_call压入的系统调用号。这个位置的数据没有实际用途但又不能跳过所以C函数声明里用一个none参数占住。这种写法在今天的代码里很少见但对汇编和C混合编程的老内核来说是常规操作。2.3 复制PCB之后哪些字段必须手动修正copy_process主体开始后第一件事是关中断并分配物理页if (nr 0) return -EAGAIN; p (struct task_struct *) get_free_page(); if (!p) return -EAGAIN; *p *current;get_free_page()从物理内存里拿一个4KB页作为子进程的task_struct存储位置。然后*p *current把父进程的整个task_struct结构体复制过去。这一步非常“暴力”父进程的所有状态包括信号处理函数表、文件系统相关字段、报警定时器、时间统计、TSS全部一次性拷给子进程。但这只是个起点接下来需要把“属于父进程”的部分修正成“属于子进程”p-pid nr; p-father current-pid; p-pgrp current-pgrp; p-session current-session; p-leader 0; p-utime p-stime 0; p-cutime p-cstime 0; p-start_time jiffies;关键行是p-pid nr和p-father current-pid。前者把子进程的PID设成任务槽下标后者把子进程的“父亲”指向当前进程。p-leader 0意味着子进程不会成为会话首进程即使父亲是首进程子进程也不能继承“组长”身份。时间统计清零也很好理解子进程自己还没运行过CPU时间当然是0。再往下就是整个fork里最容易被忽略但极其重要的一行p-tss.esp0 PAGE_SIZE (long)p;0.11里一个进程的task_struct和它的内核栈共用同一个4KB物理页。task_struct从页底开始往后放内核栈从页顶往下长。esp0是TSS里记录的内核栈指针当子进程将来因为中断或系统调用从用户态陷入内核时CPU会自动读取tss.esp0作为内核栈顶然后压入用户态现场。因为p是页底地址PAGE_SIZE (long)p就是页顶地址也就是内核栈的初始栈顶。这个设计到现代Linux也没有本质变化只是thread_info和内核栈的布局更复杂了。你把这一行读懂了再看task_struct和内核栈“同居一页”的由来就非常自然。在copy_process结束前还有一段需要特别留意的临界区处理整个复制过程都是在关中断状态下完成的直到task[nr] p把子进程挂到任务数组之后才开中断。因为task数组是所有进程共享的全局数据结构如果中途来了中断或时钟切换就可能在调度器扫描时看到“半初始化”的进程。这种“先关中断保护全局结构、操作完再开”的做法在很多老内核里都非常常见。3. 子进程地址空间的搭建copy_mem与页表复制3.1 64MB线性地址空间和LDT段基址copy_process里复制完PCB之后紧跟着调用copy_mem(nr, p)。这个函数负责给子进程搭建一套新的线性地址空间。0.11运行在x86的段页式内存管理模式下。每个进程不是像现代Linux那样直接使用平坦的线性地址而是通过LDT里的两个段描述符代码段、数据段来定义自己的线性地址范围。0.11给每个进程分配的线性地址空间大小是64MB从nr * 64MB开始。也就是说任务0的线性空间在0到64MB任务1在线性地址64MB到128MB任务2在128MB到192MB依此类推。段描述符的基址存放在该进程LDT表项里代码段和数据段都从同一个基址开始。为什么必须给子进程换一个线性地址空间的基址因为页表是按线性地址组织的。如果父子进程的线性地址基址完全相同那么子进程复制父进程的页表时新的页表项会直接覆盖掉父进程线性地址对应的映射造成空间冲突整个内存管理就乱了。所以子进程必须“搬家”搬到一段新的线性地址上然后在新地址对应的页表里复制父进程的页表内容。copy_mem的核心逻辑并不复杂old_code_base get_base(current-ldt[1]); // 父进程代码段基址 data_limit get_limit(current-ldt[2]); // 父进程数据段限长 new_code_base nr * 0x4000000; // 子进程新基址 set_base(p-ldt[1], new_code_base); // 修改子进程LDT代码段 set_base(p-ldt[2], new_code_base); // 修改子进程LDT数据段 copy_page_tables(old_code_base, new_code_base, data_limit);get_base从LDT描述符里抽出段基址set_base回填新基址然后以data_limit为范围把父进程从old_code_base开始的页表映射复制到new_code_base对应的页表位置。可能有人会问为什么不把父子进程安排到完全相同的线性地址上然后通过页表项区分物理页现代Linux就是这样的所有进程共享同一个0到TASK_SIZE的线性地址映射靠不同的页表指向不同物理页。但0.11的段页式架构里既然有LDT这个现成的“地址空间切换”工具老设计就直接用段基址来隔离进程了。3.2 copy_page_tables的关键循环逐行拆解copy_page_tables是fork里技术含量最高、也最值得逐行读的函数。先看主体结构int copy_page_tables(unsigned long from, unsigned long to, long size) { unsigned long *from_page_table; unsigned long *to_page_table; unsigned long from_dir, to_dir; int i; from_dir from 0xfffff000; to_dir to 0xfffff000; size ((from 0x3fffff) size 0x3fffff) 22; for (; size 0; size--) { from_page_table (unsigned long *)(0xfffff000 *from_dir); to_page_table (unsigned long *)(0xfffff000 *to_dir); for (i 0; i 1024; i, from_page_table, to_page_table) { if (!(1 *from_page_table)) continue; *to_page_table *from_page_table ~0x2; *from_page_table ~0x2; } from_dir 4; to_dir 4; } return 0; }逐行看。from_dir from 0xfffff000的意思是去掉from的低12位得到一个页目录项的起始地址。from是父进程的代码段基址比如任务1的代码段基址是0x400000064MB对齐低22位本来就是0。但写成 0xfffff000是保险做法防止传入的不是对齐地址。size的计算是整个函数里最容易被看懵的一行size ((from 0x3fffff) size 0x3fffff) 22;size入参是字节数这里要算的是“需要覆盖多少个页目录项”。一个页目录项对应4MB线性空间所以需要把字节数向上取整到4MB的倍数。0x3fffff是4MB减1加上它再右移22位就是向上取整的意思。前面加的(from 0x3fffff)是为了处理from不在4MB边界的情况。通常0.11里的进程代码段很小data_limit也就几十KB到几百KB算下来size往往是1只有当进程数据段跨了4MB边界时才会是2或更大。进入外层循环后from_page_table和to_page_table分别从页目录项里拿出页表的物理地址from_page_table (unsigned long *)(0xfffff000 *from_dir);页目录项的低12位是present、rw、user、accessed、dirty等标志位所以要清掉低12位才能得到页表基址。这和现代x86的分页机制完全一致。内层循环遍历1024个页表项。每个页表项对应一个4KB物理页1024项正好覆盖4MB线性空间。if (!(1 *from_page_table)) continue;这一行判断父进程的这个页表项是否存在present位是否为1。如果父进程这个线性地址还没有映射物理页就直接跳过子进程也不需要这个映射。所以fork复制页表时只复制“已映射”的部分没有偷懒去处都是实打实的范围扫描。真正复制页表的操作就两行*to_page_table *from_page_table ~0x2; *from_page_table ~0x2;第一行把父进程页表项的值复制到子进程页表项同时把bit1也就是rw位清掉。第二行把父进程自己的页表项的rw位也清掉。这意味着父子进程的这些页面都变成只读。不要小看这两行。它们的设计意图是既然物理页是共享的那任何一方都不能写。将来谁想写这些页面就需要通过某种机制重新获得可写权限。这个机制在0.11里并不完整后面我会专门说。最后from_dir 4; to_dir 4是把指针往后移动一个页目录项继续处理下一个4MB块。页目录项和页表项一样每个占4字节所以加4就是指向下一个目录项。3.3 清掉写权限位之后父与子的物理页共享copy_page_tables完成后父子进程的线性地址不同但它们的页表项指向的物理页是完全相同的。父进程数据段里的每一页子进程在对应位置也能访问到同一块物理内存。这个阶段进程切换后父子进程“看到”的内存内容是一样的就像一份文档的两个副本指针指向同一个文件。区别只在于谁都不能写因为写权限位被清掉了。这里需要特别澄清一个误区很多资料说0.11 fork实现了写时复制COW这是不准确的。0.11确实做了“共享物理页”和“清写位”这两步看起来和COW的起点一样但它缺少COW最关键的第二步当进程试图写入只读页时会触发写保护异常异常处理程序应该分配新物理页、复制原页面内容、并把对应页表项重新设为可写。0.11并没有这个异常处理流程。它清掉写权限位之后并没有do_wp_page这样的函数来补救。所以0.11的“共享只读”其实是一个不完整的过渡方案。后面的第5部分我会展开讲这对实际运行意味着什么。4. 返回两次的秘密TSS构建与首次调度4.1 子进程的“中断现场”是怎么伪造出来的copy_process复制完PCB和地址空间后接下来就是最精彩的部分伪造子进程的TSS寄存器现场。看代码p-tss.esp0 PAGE_SIZE (long)p; p-tss.eip eip; p-tss.eflags eflags; p-tss.eax 0; p-tss.ecx ecx; p-tss.edx edx; p-tss.ebx ebx; p-tss.esp esp; p-tss.ebp ebp; p-tss.esi esi; p-tss.edi edi; p-tss.es es 0xffff; p-tss.cs cs 0xffff; p-tss.ss ss 0xffff; p-tss.ds ds 0xffff; p-tss.fs fs 0xffff; p-tss.gs gs 0xffff;这堆赋值基本上就是把copy_process收到的“父进程用户态寄存器快照”原样填进子进程的TSS。这个快照是父进程调用int 0x80时CPU自动压栈保存的包括eip、cs、eflags、esp、ss以及system_call入口压栈的ebx、ecx、edx、fs、es、ds。唯一被刻意修改的是eaxp-tss.eax 0;eax在x86调用约定里是函数的返回值寄存器。父进程在用户态执行int 0x80时eax放的是系统调用号2。子进程继承这个eax的话将来返回用户态时就会看到2而不是期望的0。这里把eax强制改成0就是给子进程手动设定fork返回值。还要注意一个细节eip这个字段是从父进程用户态里int 0x80指令的下一条指令地址。也就是说子进程TSS里的eip指向的地址和父进程从系统调用返回后要执行的地址是同一个位置。这保证了父子进程在用户态看到的“fork调用之后的第一条指令”是完全一致的。到了这一步子进程的寄存器状态已经准备齐全。它就像一个“从未真正运行过、但已经被精心打扮成刚从系统调用返回”的进程。4.2 从schedule到switch子进程第一次跑起来是什么样子进程构建好TSS之后并不会马上运行。它只是挂在了task[nr]里等待调度器选中。0.11的调度逻辑在kernel/sched.c的schedule()里。它会遍历所有任务选择一个counter最大、状态为可运行的进程然后调用switch_to(next)执行任务切换。switch_to用的是硬件任务切换机制ljmp *%0这是一条长跳转指令跳转目标是下一个进程的TSS。x86 CPU执行这条ljmp时发现目标是一个TSS描述符就会自动触发一次硬件任务切换把当前CPU所有寄存器的值保存到当前进程的TSS然后从目标TSS里恢复全部寄存器包括cs、eip、esp、eflags、各段寄存器等。注意ljmp跳转的时候CPU已经切走了。下一个进程的TSS是子进程的TSS而子进程TSS里cs、eip、esp、ss这些字段都已经被copy_process设置成了父进程的用户态现场。所以子进程第一次被调度时直接“落在”用户态CPU看到的cs:eip是用户态代码段和fork调用之后的那条指令esp是父进程当时的用户栈指针eax被设成0。它就像自己刚刚从fork()返回一样继续往下执行而返回值是0。这就是fork“返回两次”的底层真相父进程走的是完整的内核调用返回路径子进程走的是任务切换恢复现场路径。两者到达的用户态位置相同但一个是从内核栈iret回去一个是CPU从TSS里把寄存器“倒”出来。子进程压根没有真实经历过system_call和copy_process的调用栈它只是被一根PM_4K的线“恰好”拉到了正确的位置。这里有个最容易混淆的点有些人以为子进程第一次运行是从ret_from_sys_call开始实际上0.11里完全不是。因为switch_to用的是硬件TSS切换切换后直接继承了子进程TSS里设置好的用户态cs:eip没有经过任何内核态返回代码。它是“空降”到用户态的。直到子进程下次因为系统调用或中断再次进入内核时才会用到tss.esp0指向的内核栈那时它才真正拥有自己的内核栈使用记录。4.3 父子返回路径对比一个沿栈返回一个空降用户态把两条路径放在一张表里看会更清楚对比项父进程子进程返回前状态正在copy_process的调用栈里挂在task[nr]等待调度返回方式copy_process返回后逐层ret回到system_call再iret回用户态被schedule选中后经switch_to的TSS切换直接恢复用户态现场返回值fork()返回子进程PIDfork()返回0执行位置用户态fork调用下一条指令同一个用户态位置但CPU寄存器是从TSS恢复的是否经过内核返回代码经过完整的ret_from_sys_call不经过直接落在用户态在很多现代操作系统课程里老师会强调“fork调用一次返回两次”。如果你想在源码级别真正理解这句话0.11的这段设计就是最直观的教材。理解了TSS和硬件任务切换之后你会瞬间明白所谓子进程“返回0”其实是内核给子进程提前伪造好了一个“刚从fork返回”的CPU状态然后找机会把它切进去而已。5. 0.11的伪写时复制与之后内核的演化5.1 共享只读的真正代价没有do_wp_page的0.11前面提到0.11的copy_page_tables清掉了父子进程页表项的写权限位但后面并没有配套的“写保护异常处理”把物理页复制一份。这是0.11和现代写时复制最关键的区别。那么0.11这版设计在实际运行时会遇到什么问题如果父子进程在fork之后都没有写那些共享页一切正常。但如果某一方尝试往共享页里写数据就会触发x86的写保护异常page fault错误码里present位为1、write位为1。0.11的缺页异常处理入口是do_no_page它主要处理的是线性地址还没有映射物理页的情况。遇到“物理页已经存在但被写保护”的情况0.11并没有准备好恢复可写性的逻辑最终结果很可能是进程收到段错误或行为异常。那为什么当年的0.11还能正常跑起来因为在实际使用场景里fork之后几乎总是马上调用exec加载新程序。exec会释放老进程的页表为新的可执行文件建立全新的页表映射父子之间的共享只读页面关系很快就拆掉了。只有在不exec、父子都写共享数据的程序里问题才会暴露。所以0.11的“清写位”设计与其说是写时复制不如说是“为即将发生的exec做安全准备”或者是现代COW机制的雏形。它把物理页共享给做出来了但缺了“写时复制”的“复制”两个字。5.2 0.12补全写时复制fork才真正变快0.12版本在copy_page_tables里保留了一模一样的共享清写位逻辑但补上了真正的异常处理路径新增了do_wp_page函数。当进程访问一个只读共享页并尝试写入时缺页异常处理程序会发现这个页表项存在且写保护于是分配一个新的物理页把原物理页的内容复制过去再把当前进程的页表项指向新物理页并设置可写位。这样写入操作就被“拦截”下来物理页复制只发生在“确实要写”的时候。这才是完整的写时复制fork之初不复制物理页只有一方要写共享页的瞬间才做一次“现场复制”。现代Linux的fork依然沿用这个思路只是实现细节大幅复杂化引入了vm_area_struct、anon_vma、rmap这些反向映射机制来管理共享和分裂还考虑了NUMA、THP、KSM等场景。但你抓住0.12到现代Linux这条主线会发现核心思想没变过。所以读0.11 fork源码时我建议人手边再放一份0.12的fork代码对比着看。看到do_wp_page的加入你会更加理解0.11缺失的到底是一块多么关键的拼图。5.3 精读fork源码的实操建议和一句总结最后给想精读这份源码的人一点实际建议。第一不要只看fork.c把system_call.s、sched.c、page.s放一起读。fork的实现横跨了用户态库、系统调用入口、进程管理、内存管理、任务切换五层只看copy_process单体会漏掉一半信息。第二有条件的话用模拟器跑起来在copy_process入口和copy_page_tables里打断点单步跟踪寄存器和页表的变化。我在读这段代码的时候光是“子进程第一次跑起来时到底从哪个地址开始执行”这个问题就反复验证了好几遍。跑起来之后看着子进程的TSS切换落到用户态那条指令上才真正把“返回两次”这件事焊死在脑子里。第三可以准备一个现代Linux的fork调用路径图做对照。现代内核的copy_process巨长但很多分支都是在处理0.11时代根本不用担心的事。先看0.11的朴素的版本等于给大脑装了一个“主干模型”后面看现代代码就不容易在细节森林里迷路。读完linux0.11的fork源码我最直接的体会是操作系统的每一层设计都是在有限的硬件条件下做取舍。0.11用硬件TSS切换简化任务切换用段基址区分进程线性空间用共享页表加速fork都是当时“够用就好”的选择。而理解这些取舍比背诵一堆现代内核API有用得多。