引言:从“单机”到“多机”的飞跃

在计算机科学的世界里,有一个非常著名的思想实验:如果你有一台完美的复印机,并且这台复印机不仅能复印纸张,还能复印出一个和你一模一样的人,同时赋予这个“复制品”独立的意识和执行能力。你会怎么做?在现实世界这可能只是科幻,但在 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)中的流程图。

父进程

子进程

内核空间

原进程(父进程)

调用系统调用 fork()

复制进程表项,创建子进程

子进程获得返回值

父进程获得返回值

pid = 0

pid = 子进程PID (>0)

子进程继续执行

父进程继续执行

让我们来详细拆解这个神奇的流程图:

  1. 指令交汇点:当 CPU 执行到 pid = fork() 这个程序语句时,这一刻,只有一个进程在执行。 操作系统内核接手,进行进程克隆。
  2. 瞬间分裂:当内核完成了进程表复制、内存空间复制(Linux 0.11 后续采用了写时复制 Copy on Write 技术来延迟真正的内存复制)后,系统有了两个完全一样的执行流。
  3. 命运的分岔口注意关键点! 系统调用 fork() 在父进程和子进程中各返回了一次。但返回的数值完全不同。这就是“一次调用,两次返回”的魔法!
    • 父进程中,fork() 的返回值是 子进程的进程 ID(PID)。这是一个大于 0 的整数。
    • 子进程中,fork() 的返回值是 0

为什么返回值是 0?
因为子进程不需要用 PID 来标记自己,它刚刚诞生,还没有后代。而父进程需要拥有子进程的 PID,以便将来可以通过 wait() 来“收尸”(回收子进程占用的资源)。

代码中的分叉:
Linux 0.11 的 main.cinit() 函数正是利用这个特性,让父子进程走向完全不同的命运。我们在代码中看到:

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()

四、进程的生命周期

在这里插入图片描述

  1. “祖先”与“后裔”:原进程是“祖先”。哪怕子进程最后通过 exec() 执行了别的程序,但它的“血缘”(父进程 PID、进程组、会话 ID 等)依然存在。
  2. 错误处理:书上提到“如果 fork() 调用失败,则会返回小于 0 的值。”在真实操作系统中,如果进程表已经满了(比如系统有了 64 个进程,对于 Linux 0.11 来说已经达到 NR_TASKS 的上限),或者内存耗尽无法分配新的 PCB,fork() 就会返回 -1。所以在实际健壮的代码里,我们必须判断 if (pid < 0)
  3. 分头行动:图中那条黄色的原进程线,和绿色的新进程线,是拥有各自独立的 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 的操作系统伦理。敬请期待!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐