第04章 初始化程序(4):pid = fork() —— 操作系统中的“一生二”与程序分身术
引言:从“单机”到“多机”的飞跃
在计算机科学的世界里,有一个非常著名的思想实验:如果你有一台完美的复印机,并且这台复印机不仅能复印纸张,还能复印出一个和你一模一样的人,同时赋予这个“复制品”独立的意识和执行能力。你会怎么做?在现实世界这可能只是科幻,但在 Linux 内核中,这个魔法每天都在发生,它就是
fork()。在 Linux 0.11 的
main.c初始化函数中,仅仅一行if (!fork())的代码,就触发了操作系统的“分身术”,让一个孤单的“进程0(idle)”瞬间创造出了人类历史上无数进程的祖先——“进程1(init)”。今天,我们就来深度解开这个“分身术”背后的秘密。
一、 不是“生”,而是“复制”:fork() 的核心哲学
在大多数人的直觉里,创建一个新程序应该像是“新建一个空白文档”,或者从硬盘上读取一个新的 exe 文件加载到内存里。然而,UNIX/Linux 系统的哲学完全出乎意料:创建一个新的进程,几乎等同于“克隆(Clone)一个现有的进程”。
fork() 就是执行这个“克隆”动作的系统调用。
- 原进程:被称为 父进程(Parent Process)。
- 新进程:被称为 子进程(Child Process)。
正如您提供的图片中所描述的那样:
“该调用复制当前进程,并在进程表中创建一个与原进程几乎完全一样的新表项,并执行同样的代码,但该新进程拥有自己的数据空间和环境参数。”
为什么设计成“复制”,而不是“创建”呢?
因为从零创建一个进程的“上下文”(包括环境变量、文件描述符、信号处理等)是极其耗时且复杂的。而通过复制父进程,子进程一出生就继承了父亲的所有“家产”(环境、打开的文件句柄等),这不仅极大地提高了创建进程的速度,也方便了父进程向子进程传递初始状态。
💡 技术内幕(深入内核):
在内核层,fork()会去查看内核维护的 进程表(Process Table)。父进程在进程表中有一个条目,称为 进程控制块(PCB,Linux 中表现为task_struct结构体)。fork()的核心工作,就是申请一个空闲的 PCB 条目,然后把父进程 PCB 里的绝大部分内容,原封不动地“复制”到这个新的 PCB 中。
二、 奇迹般的“一次调用,两次返回”
这是 fork() 最令人惊叹、也是让初学者最容易迷惑的地方。我们来看看您提供的图片(原书图4-3)中的流程图。
让我们来详细拆解这个神奇的流程图:
- 指令交汇点:当 CPU 执行到
pid = fork()这个程序语句时,这一刻,只有一个进程在执行。 操作系统内核接手,进行进程克隆。 - 瞬间分裂:当内核完成了进程表复制、内存空间复制(Linux 0.11 后续采用了写时复制 Copy on Write 技术来延迟真正的内存复制)后,系统有了两个完全一样的执行流。
- 命运的分岔口:注意关键点! 系统调用
fork()在父进程和子进程中各返回了一次。但返回的数值完全不同。这就是“一次调用,两次返回”的魔法!- 在父进程中,
fork()的返回值是 子进程的进程 ID(PID)。这是一个大于 0 的整数。 - 在子进程中,
fork()的返回值是 0。
- 在父进程中,
为什么返回值是 0?
因为子进程不需要用 PID 来标记自己,它刚刚诞生,还没有后代。而父进程需要拥有子进程的 PID,以便将来可以通过 wait() 来“收尸”(回收子进程占用的资源)。
代码中的分叉:
Linux 0.11 的 main.c 和 init() 函数正是利用这个特性,让父子进程走向完全不同的命运。我们在代码中看到:
if (!fork()) {
init(); // 子进程执行的代码
}
对于子进程,fork() 返回 0,!0 也就是 1(真),因此它会进入 if 内部,执行 init()。
对于父进程,fork() 返回大于 0 的 PID,!PID 也就是 0(假),因此它跳过 if 内部,继续往后执行循环。
三、 复制之后呢?真正的使命是 exec()
书上清晰地指出了 fork() 的核心用途:
“创建新进程的主要用途在于在新进程中使用
exec()簇函数去执行其他不同的程序。”
通俗理解:
如果把 fork() 比作“复制了一个空壳(或者是拥有模版内容的身子)”,那么 exec() 就是将这个“空壳”里面的脑子“彻底换掉”。
当子进程执行了 execve("/bin/sh", ...) 之后,内核会清除掉子进程原先从父进程复制过来的代码段和数据段,然后从磁盘上把 /bin/sh 这个二进制文件加载进内存,替换掉子进程的内容。子进程从这一刻起,就不再是它父亲的克隆体了,它摇身一变,成为了全新的 shell 程序。
这就是经典的 UNIX 启动模式:fork() 后紧跟 exec()。
四、进程的生命周期

- “祖先”与“后裔”:原进程是“祖先”。哪怕子进程最后通过
exec()执行了别的程序,但它的“血缘”(父进程 PID、进程组、会话 ID 等)依然存在。 - 错误处理:书上提到“如果
fork()调用失败,则会返回小于 0 的值。”在真实操作系统中,如果进程表已经满了(比如系统有了 64 个进程,对于 Linux 0.11 来说已经达到NR_TASKS的上限),或者内存耗尽无法分配新的 PCB,fork()就会返回 -1。所以在实际健壮的代码里,我们必须判断if (pid < 0)。 - 分头行动:图中那条黄色的原进程线,和绿色的新进程线,是拥有各自独立的 CPU 执行流道的。在
fork()之后,父进程和子进程会并行运行(当然,在单核 CPU 上是分时调度,但在宏观上它们是并行的)。父进程原本可以使用wait()等待子进程结束,或者继续去做别的事。
五、 Linux 0.11 中的世界级实例:init 进程的诞生
结合我们阅读的 init/main.c,我们再来看 fork() 在这个特殊场景下的意义。
main() 函数一路跑到最后,执行了 move_to_user_mode(),把目前运行的内核态代码,降级为了用户态下的“进程0”。
紧接着,系统执行了 fork()。这是系统第一次在用户态(特权级3)下创建进程。
- 父进程(进程0):获得子进程 PID = 1。它随后来到一个
for(;;) pause();的死循环中。当没有其他进程可运行时,调度器就会调度这唯一的“空闲”进程运行。 - 子进程(进程1):在
if (!fork())分支中,它获得返回值 0。它立刻去执行init()函数。 - 进程1 随后会在
init()中再次调用fork(),创建出新的子进程(进程2)。这就是整个 Linux 大家族开枝散叶的过程。
可以说,如果没有 fork() 这个神奇的“分身术”系统调用,Linux 的多任务环境将无法从“进程0”这个唯一的起点启动。
下篇预告:
进程在 Linux 中不仅要学会“诞生”和“执行”,还要学会“得体地死去”。在init()循环中,我们看到了wait(&i)和exit(),这背后隐藏着子进程如何向父进程“报丧”、以及父进程如何“收尸”(清理僵尸进程)的底层逻辑。下一篇,我们将聚焦于进程的终止、等待与回收机制,为您解析 Linux 的操作系统伦理。敬请期待!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)