前言
前面我们已经学习了进程的基本概念,知道了进程不仅仅是一段正在运行的程序,还需要操作系统为它维护 PCB、虚拟地址空间、页表等信息。
那么,接下来就有两个很自然的问题:
- 一个进程是怎么创建出另一个进程的?
- 一个进程执行完毕之后,操作系统又是怎么知道它的执行结果的?
在 Linux 中,这两个过程分别涉及进程创建和进程退出。
本篇我们就从 fork() 开始,看看一个进程是如何创建子进程的,再通过写时拷贝理解父子进程之间的内存关系,最后看看进程退出时,返回值又是如何传递给父进程的。
一、进程创建
1.1 fork 函数初识
在 Linux 中,如果我们想创建一个新的进程,就需要使用 fork() 函数。
先来看一下它的函数原型:
cpp
#include <unistd.h>
pid_t fork(void);
fork() 是一个系统调用,它的作用就是:以当前进程为基础,创建一个新的进程。
原来的进程叫作父进程,新创建出来的进程叫作子进程。
比如,现在有一个进程正在执行:
cpp
int main()
{
printf("hello Linux\n");
return 0;
}
当它执行到 fork() 时,操作系统就会创建一个子进程。
这里要注意,子进程不是从 main() 函数的开头重新执行,而是从 fork() 调用之后的位置开始执行。
也就是说,父子进程都会继续执行 fork() 后面的代码。
那么,父子进程既然都执行同一份代码,程序怎么知道当前到底是父进程还是子进程呢?
这就涉及 fork() 最有意思的地方:它会返回两次。
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
printf("我是子进程,pid: %d\n", getpid());
}
else if (id > 0)
{
printf("我是父进程,pid: %d\n", getpid());
}
else
{
printf("fork failed\n");
}
return 0;
}
运行结果:
text
我是父进程,pid: 1234
我是子进程,pid: 1235
这里的 PID 只是示意,实际运行时会有所不同。
我们发现,明明只有一个 fork() 调用,却出现了两次返回结果。
这是因为:
- 父进程调用
fork(),返回的是新创建的子进程 PID。 - 子进程调用
fork()对应的执行流,返回的是 0。 - 如果创建失败,则返回 -1,并且不会产生子进程。
为什么要设计成两个不同的返回值?
其实很好理解。
父进程需要知道自己创建出来的子进程是谁,所以返回子进程的 PID。
子进程只需要知道自己已经成功创建出来了,因此返回 0 就可以了。
这样,父子进程就可以通过 fork() 的返回值,分别执行不同的代码。
1.2 fork 创建子进程的底层过程
那么,fork() 到底做了什么?
我们之前说过,一个进程不仅仅是代码,还包含 PCB、地址空间、页表以及各种资源。
所以创建子进程,并不是简单地复制一份代码就结束了。
大致可以理解为以下几个步骤:
- 操作系统为子进程创建新的进程控制块(PCB,也就是 Linux 内核中的
task_struct)。 - 为子进程建立相应的虚拟地址空间和内存管理信息。
- 建立父子进程之间的资源关系,并处理需要继承的文件描述符等信息。
- 建立子进程的内存映射关系。
- 将子进程加入进程调度体系,等待 CPU 调度执行。
6.
这里有一个非常重要的问题:
父进程已经有自己的代码、数据和各种变量了,子进程创建出来之后,难道还要把这些内容全部重新复制一份吗?
如果每次创建子进程,都要完整复制父进程的所有内存,不仅浪费时间,也会占用大量物理内存。
Linux 为了解决这个问题,就使用了写时拷贝技术。
1.3 写时拷贝(Copy On Write)
写时拷贝,英文叫作 Copy On Write,简称 COW。
它的核心思想是:
先共享,等到真正需要修改数据的时候,再进行拷贝。
我们先从一个简单的例子理解。
假设父进程中有一个变量:
cpp
int a = 10;
当父进程调用 fork() 创建子进程时,父子进程都需要拥有这个变量。
如果直接复制,那么内存中就会有两份:
text
父进程:a = 10
子进程:a = 10
但问题是,子进程刚创建出来时,可能根本不会修改这个变量。
如果它一直只是读取数据,那么提前复制一份就没有必要。
所以 Linux 的做法是:
刚创建子进程时,父子进程暂时共享同一份物理内存。

这样,父子进程虽然各自有独立的虚拟地址空间和页表,但在没有发生写入操作时,可以共同使用同一份物理内存。
不过,这里还有一个问题:
如果父进程把 a 改成了 20,那么子进程看到的也会变成 20 吗?
当然不行。
父子进程是两个独立的执行实体,它们应该拥有各自独立的数据,不能因为一方修改变量,就影响另一方。
因此,操作系统还需要做一件事:防止父子进程直接修改这份共享数据。

1.3.1 写时拷贝是如何实现的?
当 fork() 创建子进程后,操作系统会对父子进程共享的相关内存页进行权限设置。
对于需要写时拷贝保护的页面,父子进程对应的页表项都会被设置为只读。
注意,这里设置的是页表中的访问权限,并不是把原本可以修改的变量永久变成了只读变量。
它只是暂时不允许父子进程直接修改共享的物理页面。
接下来,我们看看具体会发生什么。
假设:
cpp
int a = 10;
int main()
{
pid_t id = fork();
if (id == 0)
{
a = 20;
}
else if (id > 0)
{
a = 30;
}
return 0;
}
父子进程刚创建出来时,内存关系大致如下:
text
父进程虚拟地址 子进程虚拟地址
| |
v v
父进程页表 子进程页表
| |
+--------------+---------------+
|
v
物理内存
a = 10
(只读保护)
现在,子进程执行:
cpp
a = 20;
它想修改 a,但是页表告诉 CPU:这块内存目前只允许读取,不允许写入。
于是 CPU 就会产生一个异常,通知操作系统处理。
这里产生的异常并不意味着程序一定出错了。
因为操作系统知道,这个页面原本是可以写入的,只不过为了实现写时拷贝,暂时设置成了只读。
所以是在错误的驱使下,有了写时拷贝的行为
内核会结合进程的内存区域信息(vm_area_struct)和页表权限,判断这是不是一次合法的写时拷贝。
如果确认属于 COW,操作系统就会执行下面的操作:
- 申请一块新的物理内存。
- 将原来共享页面中的数据复制到新页面。
- 修改子进程的页表,让它指向新的物理页面。
- 将新页面的访问权限恢复为可读可写。
- 子进程重新执行刚才的写操作。
最终:
text
父进程页表 子进程页表
| |
v v
原来的物理页面 新的物理页面
a = 10 a = 20
这样,父进程和子进程就拥有了各自独立的数据。
如果父进程之后也尝试修改 a,同样会根据当前的共享情况触发相应的写时拷贝处理。
这就是写时拷贝的基本实现过程。
1.3.2 为什么操作系统能够分辨出谁是数据段?
我们刚才提到,父子进程共享的内存页面被设置成只读之后,如果发生写入,CPU 就会产生异常。
但操作系统怎么知道这次异常是写时拷贝引起的,而不是真正的非法内存访问呢?
这就要说到 vm_area_struct。
我们之前学习虚拟地址空间时,知道进程的虚拟地址空间中有不同的区域,比如代码段、数据段、堆区、栈区等。
Linux 内核通过 vm_area_struct 结构体来描述这些虚拟内存区域。
其中保存着该区域的起始地址、结束地址以及访问权限等信息。
比如:
text
某块虚拟内存区域:
起始地址:0x1000
结束地址:0x2000
权限:可读、可写
如果这块区域原本就允许写入,只是因为写时拷贝暂时被设置成只读,那么操作系统就有机会识别出这是一次合法的写入请求。
而如果访问本身就不合法,比如访问了不属于当前进程的地址,那么就不能简单地当成写时拷贝处理。
所以,操作系统不是只看页表中的只读标记,还需要结合虚拟内存区域的信息进行判断。
1.3.3 查询页表时可能出现的几种情况
我们可以把页表理解成一张地址转换表。
程序访问虚拟地址时,CPU 和 MMU 会根据页表完成地址转换,同时检查访问权限。
大致有以下几种情况:
- 页表项存在,权限也符合要求: 正常完成地址转换,访问物理内存。
- 页表项不存在: 可能需要申请物理内存并建立映射,也可能是非法访问。
- 页表项存在,但权限不符合要求: 触发异常,交给操作系统进一步判断。
对于写时拷贝来说,重点就是第三种情况。
父子进程尝试写入共享的只读页面时,CPU 会触发异常,操作系统确认属于 COW 后,再完成物理页面的复制和页表修改。
1.3.4 为什么要使用写时拷贝?
理解了实现过程,再回头看它的好处就很简单了。
第一,提高进程创建效率。
如果每次 fork() 都需要完整复制父进程的物理内存,那么内存越大,创建子进程所需要的时间就越长。
而写时拷贝只在真正发生写入时才复制页面,避免了不必要的操作。
第二,节省物理内存。
如果父子进程一直没有修改某些共享页面,那么它们就可以一直使用同一份物理内存。
只有需要修改时,才为对应的页面分配新的物理内存。
因此,写时拷贝并不是让父子进程永远使用同一份数据,而是让它们在不需要修改时共享,在需要修改时分离。
1.3.5 写时拷贝之后,父进程的权限会恢复吗?
这里再补充一个容易混淆的地方。
当子进程发生写时拷贝后,它会获得一块新的物理页面,并且对应的页表权限可以恢复为可读可写。
但是,父进程原来的页面是否恢复可写,不能简单地说一定要等所有子进程都完成拷贝。
操作系统会根据物理页面的共享引用情况和其他映射关系进行判断。
当原来的页面不再需要受到共享写保护时,内核才会恢复相应的写权限。
所以,我们记住核心即可:
写时拷贝的目的,是保证父子进程修改数据时互不影响,而不是简单地给某个进程恢复权限。
1.4 fork 的常见用法
理解了 fork(),我们再看看实际开发中为什么需要创建子进程。
常见的使用方式主要有两种。
第一种:父进程复制自己,让父子进程执行不同的任务。
例如,一个服务器进程需要同时处理多个客户端请求。
如果只有一个进程,那么它可能需要不断等待和处理请求。
而通过 fork(),父进程可以创建子进程,让子进程负责处理客户端请求,自己继续等待新的请求。
这样就可以把不同任务交给不同进程执行。
第二种:创建子进程后,让子进程执行一个全新的程序。
这就涉及我们之前接触过的 exec 函数族。
例如:
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
execlp("ls", "ls", "-l", NULL);
}
else if (id > 0)
{
printf("我是父进程\n");
}
return 0;
}
这里的过程是:
- 父进程调用
fork()创建子进程。 - 子进程执行
execlp()。 execlp()加载新的程序,替换子进程原来的用户空间程序映像。- 子进程开始执行
ls程序。
注意:exec 并不是创建一个新进程,而是替换当前进程所执行的程序。
所以,子进程的 PID 不会因为调用 exec 而改变。
这也是为什么 Linux 中经常使用:
text
fork() + exec()
来实现新程序的启动。
1.5 fork 调用失败的原因
fork() 也不是每次都能成功。
如果系统无法为新进程准备好所需资源,就可能创建失败。

常见原因有:
- 系统中的进程数量已经达到限制。
- 当前用户能够创建的进程数量超过了限制。
- 系统内存或内核资源不足,无法完成进程创建所需的分配。
所以,在代码中调用 fork() 时,一定要检查返回值:
cpp
pid_t id = fork();
if (id < 0)
{
printf("fork failed\n");
}
else if (id == 0)
{
printf("child process\n");
}
else
{
printf("parent process\n");
}
二、进程退出
上面我们讲了进程是怎么创建出来的。
那么,子进程创建出来之后,总有执行完毕的一天。
当一个进程执行结束时,操作系统需要知道它是否正常完成任务,以及最终的执行结果是什么。
这就涉及进程退出。
2.1 进程退出场景
2.1.1 进程退出的三种场景
一个进程退出,大致可以分为以下三种情况。
第一种:代码运行完毕,结果正确。
例如:
cpp
int main()
{
printf("hello Linux\n");
return 0;
}
程序正常执行完毕,返回 0。
通常我们用 0 表示程序执行成功。
第二种:代码运行完毕,但结果不正确。
例如:
cpp
int main()
{
// 执行某些操作
return 1;
}
程序虽然正常执行到了结束位置,但是由于某些操作没有达到预期,所以返回了非 0 值。
这时,程序本身并没有崩溃,只是通过退出码告诉外部:这次执行存在问题。
第三种:程序异常终止。
例如,程序发生非法内存访问,或者收到了某些终止信号。
这种情况下,程序可能还没有执行到 main() 的结束位置,就已经被操作系统终止了。
例如,在终端运行程序时按下 Ctrl+C,通常就会向前台进程发送 SIGINT 信号。
因此,我们可以这样理解:
- 正常退出:程序按照正常流程执行结束。
- 错误退出:程序执行结束,但通过退出码表示执行失败。
- 异常退出:程序因为信号或其他致命错误提前终止。
2.1.2 退出码有哪些?
退出码就是进程结束时留下的一个状态信息。
一般情况下:
0:表示执行成功。- 非
0:表示执行过程中存在问题。
Linux 中还存在一些比较常见的退出码:
| 退出码 | 含义 |
|---|---|
| 0 | 执行成功 |
| 1 | 通用错误 |
| 2 | 命令或参数使用错误 |
| 126 | 找到了命令,但无法执行 |
| 127 | 没有找到命令 |
| 128+n | 通常表示被编号为 n 的信号终止 |
| 130 | 常见于 Ctrl+C 终止 |
| 143 | 常见于 SIGTERM 终止 |
这些只是常见约定,不是所有程序都必须严格按照这个表使用退出码。
我们可以先记住一个最重要的结论:
退出码为 0,通常表示成功;非 0,通常表示失败或异常情况。
2.2 进程常见退出方法与核心机制
在 C/C++ 程序中,我们经常会看到三种退出方式:
returnexit()_exit()
它们都能让进程结束,但底层执行过程并不完全相同。
2.2.1 return 机制与返回值
我们先来看最熟悉的 return。
cpp
int Add(int x, int y)
{
return x + y;
}
int main()
{
int ret = Add(10, 20);
return 0;
}
这里 Add() 执行完毕后,通过 return 将计算结果返回给调用它的函数。
那么,返回值是怎么传递的呢?
从底层来看,函数调用时会按照调用约定,通过寄存器等方式传递参数和返回值。
例如在常见的 x86-64 System V ABI 中,整数类型的返回值通常通过 RAX 寄存器传递。
可以简单理解为:
text
main() 调用 Add()
|
v
Add() 执行计算
|
v
将结果放入返回值寄存器
|
v
返回 main()
|
v
main() 接收返回值
那么,main() 中的 return 0 又有什么不同呢?
普通函数的 return 是把结果交还给调用者。
而 main() 是程序的入口函数,当 main() 返回时,意味着整个程序已经执行完毕。
在正常的 C/C++ 运行环境中,main() 返回的状态会交给运行时库处理,最终完成进程退出。
所以:
cpp
return 0;
表示程序正常结束,并将 0 作为退出状态。
这里要注意:
不是所有函数的 return 都会导致进程退出,只有 main 函数正常返回时,才会结束整个程序。
2.2.2 exit() 与 _exit() 的底层区别
除了 return,我们还可以主动调用 exit() 或 _exit() 来结束进程。
先看一下:
cpp
#include <stdlib.h>
int main()
{
printf("hello Linux\n");
exit(0);
return 0;
}
这里调用 exit(0) 后,进程就会结束,后面的 return 0 不会再执行。
那么,exit() 和 _exit() 又有什么区别?
我们先看一下它们的关系:
text
用户程序
|
v
exit(0)
|
v
执行注册的清理函数
|
v
刷新标准 I/O 缓冲区
|
v
_exit(0)
|
v
进程终止
而如果直接调用 _exit():
text
用户程序
|
v
_exit(0)
|
v
进程终止
它不会替我们刷新 C 标准 I/O 库的缓冲区。
所以,两者最大的区别就是:
exit():先进行标准 I/O 清理、刷新缓冲区等操作,再结束进程。_exit():直接请求内核终止进程,不执行上述用户态标准 I/O 清理操作。
我们通过一个例子来看看。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("hello");
exit(0);
}
运行结果:
text
hello
再看:
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello");
_exit(0);
}
运行结果:
text
没有输出 hello
为什么会这样?
这里就涉及我们之前学习过的缓冲区。
2.2.3 深入理解:我们谈论的缓冲区在哪里?
之前我们学习文件 I/O 时,会接触到输入输出缓冲区。
这里先提前理解一个重要的知识点:
C 标准 I/O 库的缓冲区,不等于操作系统内核中的缓冲区。
例如:
cpp
printf("hello");
当我们调用 printf() 时,数据不一定会立即显示在屏幕上。
对于终端这种通常采用行缓冲的标准输出,输出内容可能暂时保存在 C 标准 I/O 库维护的用户态缓冲区中。
因为我们没有输出换行符,所以数据可能还没有真正通过底层输出操作交给内核。
这时候:
cpp
exit(0);
会先刷新标准 I/O 缓冲区,再结束进程。
所以我们能够看到 hello。
但是:
cpp
_exit(0);
不会执行 C 标准 I/O 库的缓冲区刷新操作。
进程直接终止,尚未刷新的用户态数据就可能丢失。
这也说明了一个问题:
如果缓冲区中的数据属于 C 标准 I/O 库,那么内核直接终止进程时,并不会自动替我们调用 printf() 对应的刷新逻辑。
这里要区分两个概念:
- 用户态缓冲区:由 C 标准 I/O 库维护,例如
printf()使用的FILE流缓冲区。 - 内核缓冲区:操作系统内核在处理 I/O 时使用的缓冲机制。
它们不是同一个东西。
另外,exit() 除了刷新标准 I/O 缓冲区,还会执行通过 atexit() 注册的清理函数等操作,最后再完成进程终止。
2.3 退出码的回收与访问
上面我们已经知道,进程退出时可以带着一个退出码。
那么,进程退出之后,这个退出码会交给谁?
答案就是:父进程。
2.3.1 子进程退出码的保存
我们之前学习过,父进程可以通过 fork() 创建子进程。
那么,子进程执行完毕之后,父进程怎么知道它的执行结果呢?
操作系统会在子进程退出时保存它的退出状态,供父进程后续获取。
这里还涉及一个重要概念:僵尸进程(Zombie)。
当一个进程退出时,并不意味着它的所有进程信息都会立即从系统中消失。
为了让父进程能够获取子进程的退出状态,内核会保留必要的退出信息。
此时,子进程就可能进入僵尸状态。
简单理解:
僵尸进程就是已经退出,但是还没有被父进程回收退出状态的进程。
它已经不再执行任务,也不再占用正常运行进程所需的用户态内存,但内核仍然保留着必要的进程信息。
等父进程调用 wait() 或 waitpid() 获取子进程的退出状态后,内核就可以完成对子进程的回收。
所以,进程退出和进程被彻底回收,并不是完全相同的概念。
2.3.2 退出码的访问
那么,我们平时在 Linux 终端中运行程序之后,怎么查看它的退出码呢?
例如:
cpp
#include <stdio.h>
int main()
{
printf("hello Linux\n");
return 10;
}
运行:
bash
./proc
然后输入:
bash
echo $?
就可以看到:
text
10
这里的 $? 是 Shell 中的一个特殊变量,表示上一条前台命令的退出状态。
为什么 Bash 能拿到这个值?
因为我们在终端中执行:
bash
./proc
实际上是 Bash 创建并启动了一个子进程来执行这个程序。
程序执行完毕之后,Bash 会通过等待机制获取它的退出状态,并将对应的状态码保存到 $? 中。
所以整个过程可以理解为:
text
Bash(父进程)
|
| fork()
v
子进程 proc
|
| 执行程序
v
return 10
|
v
进程退出
|
v
Bash 获取退出状态
|
v
保存到 $?
|
v
echo $?
|
v
10
这就解释了为什么我们可以通过 echo $? 查看程序的执行结果。
2.3.3 退出码可以转化为错误信息
除了直接查看退出码,我们还可以通过错误码获取对应的错误描述。
例如,C 语言提供了:
cpp
#include <string.h>
char* strerror(int errnum);
它的作用就是:将错误码转换成对应的错误描述字符串。
例如:
cpp
#include <stdio.h>
#include <string.h>
int main()
{
for (int i = 0; i < 10; i++)
{
printf("%d -> %s\n", i, strerror(i));
}
return 0;
}
运行之后,就可以看到不同错误码对应的描述信息。
这里需要区分:
- 退出码:表示程序最终的执行状态。
errno:通常用于保存某些库函数或系统调用失败时对应的错误编号。strerror():负责将错误编号转换成便于阅读的错误描述。
例如:
cpp
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main()
{
FILE* fp = fopen("test.txt", "r");
if (fp == NULL)
{
printf("打开文件失败:%s\n", strerror(errno));
return 1;
}
fclose(fp);
return 0;
}
这里如果文件打开失败,fopen() 会返回 NULL,并设置相应的 errno。
我们就可以通过 strerror(errno) 将错误编号转换成具体的错误信息。
这样,程序不仅能够通过退出码告诉外部自己是否执行成功,还可以通过错误信息帮助我们定位问题。
三、本篇总结
到这里,我们就把 Linux 中进程的创建与退出串起来了。
创建进程时,我们使用 fork(),让操作系统创建一个新的子进程。
为了避免直接复制大量内存造成浪费,Linux 使用写时拷贝技术,让父子进程先共享物理页面,等到真正需要修改数据时再进行复制。
而进程退出时,可以通过 return、exit() 或 _exit() 等方式结束执行,并留下相应的退出状态。
最后,父进程通过等待机制获取子进程的退出状态,完成对退出信息的回收。
我们可以用一张图把整个过程串起来:
text
父进程
|
v
fork()
|
创建子进程
|
v
父子进程共享内存页面
|
是否修改数据?
/ \
/ \
否 是
| |
| 写时拷贝
| |
| 分离物理页面
| |
+-----+-----+
|
v
进程继续执行
|
v
进程退出
|
v
保存退出状态
|
v
父进程 wait()
|
v
获取退出状态
|
v
回收子进程
这篇内容中有几个概念需要重点记住:
fork()创建子进程,父进程返回子进程 PID,子进程返回 0。- 写时拷贝的核心是先共享、后分离,真正修改数据时才复制物理页面。
exec()不创建新进程,而是替换当前进程的程序映像。return、exit()和_exit()都可以参与进程退出,但处理用户态缓冲区的方式不同。- 子进程退出后,父进程通过
wait()或waitpid()获取退出状态并完成回收。
至此,进程从创建到退出的基本过程就介绍完了。下一篇我们继续学习进程等待,看看父进程究竟是如何回收子进程的,以及僵尸进程和孤儿进程又是怎么产生的。