Linux 系统主要使用 C 语言和汇编语言编写,其核心功能通过系统调用(System Calls)向用户空间开放。C 语言标准库(libc)对这些系统调用进行了封装,方便开发者调用。在进程管理领域,fork()exec() 系列和 wait() 是最基础且最重要的三个系统调用。

  • fork(): 用于创建一个新的子进程。
  • exec(): 用于在进程中执行一个新的可执行文件。
  • wait(): 用于使父进程阻塞,等待子进程的状态改变(如终止)。

本文将详细探讨如何使用这些系统调用,并以此一窥 Linux 进程的运行机制。建议配合进程原理一文共同阅读,效果更佳。

fork():创建进程

fork() 系统调用用于创建一个子进程。该函数在父进程中返回子进程的 PID(进程 ID),在子进程中返回 0,如果创建失败则返回一个负数。

我们来看一段代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <stdlib.h>

int main(){
int apple = 5; // 初始有 5 个苹果
pid_t pid;

pid = fork(); // 创建子进程
if(pid < 0){ // 如果创建失败
fprintf(stderr, "Error: creating child process\n");
exit(EXIT_FAILURE); // 退出
}

if(pid == 0){ // 如果在子进程中
// 在这个 if 块中的所有操作都在子进程中执行
apple -= 4; // 子进程吃掉 4 个苹果
printf("Child process: I have %d apple(s).\n", apple);
} else { // 如果在父进程中
printf("Parent process: There are %d apples\n", apple); // 输出父进程的苹果数量
}
return 0;
}

猜一下输出结果中,父进程输出的苹果数量是 1 还是 5?

当父进程调用 fork() 时,操作系统会创建一个几乎完全一样的子进程。父进程的地址空间、代码段、数据段、堆和栈等资源都会被“复制”给子进程。 这种“复制”意味着子进程拥有独立的内存空间,它对变量的修改不会影响到父进程。

其过程如下图所示:

进程 fork 示意图

因此,父进程依然拥有 5 个苹果,而子进程拥有独立的 5 个苹果并吃掉了其中的 4 个。答案是 5,你猜对了嘛?

需要注意的是,在代码层面上,fork() 返回的 pid 是区分父子进程的唯一依据。子进程的 pid 变量值为 0,而父进程的 pid 变量值为子进程的实际 PID(大于 0)。我们不能说子进程的作用域仅仅局限于 if(pid == 0) 语句块内。事实上,fork() 之后的所有代码,父子进程都会执行,除非在代码中进行了逻辑判断。

下面的代码可以更好地证明父子进程互不干扰,且均会执行 if 块之外的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>

int main(){
int apple = 5; // 初始有 5 个苹果
pid_t pid;

pid = fork(); // 创建子进程
if(pid < 0){ // 如果创建失败
fprintf(stderr, "Error: creating child process\n");
exit(EXIT_FAILURE); // 退出
}

if(pid == 0){ // 如果在子进程中
apple -= 4; // 吃掉 4 个苹果
}

// 父子进程都会执行到这里
printf("There are %d apples\n", apple);
return 0;
}

Shell 终端会得到两个输出(顺序可能随机):

1
2
There are 5 apples
There are 1 apples

前一个输出通常来自父进程,苹果数量未变;后一个输出来自子进程,吃掉了 4 个。这证明了即使在 if 语句外面,代码也是被父子两进程分别执行的。if 条件仅仅用于辅助判断哪些代码应该在子进程逻辑中执行,哪些在父进程逻辑中执行。接下来我们讨论 exec()

exec() 系列:执行新程序

exec() 并不是一个单一的函数,而是一系列相关系统调用函数的总称(包括 execl(), execlp(), execle(), execv(), execvp(), execvpe())。它们的主要作用是用一个新的程序文件替换当前进程的映像。简单来说,exec() 可以用来运行一个全新的程序。

面对这么多函数,该如何选择?可以通过函数名的后缀来确定:

  • l (list): 代表参数以列表形式逐个传入,最后一个参数必须是 NULL
  • v (vector): 代表参数以字符串数组(向量)的形式传入。
  • e (environment): 表示可以为新程序设置环境变量,不带 e 则继承当前进程的环境变量。
  • p (path): 表示输入的是可执行文件的文件名,函数会根据 $PATH 环境变量来查找该文件。如果不带 p,则必须输入可执行文件的完整路径。

下面这段代码演示了 execlp()execvp() 的区别。程序中,子进程和父进程最终都会执行同样的命令(ls -la),得到相同的输出结果。不同之处在于,子进程使用 execlp(),它接受参数列表;而父进程在等待子进程结束后,使用 execvp(),它接受参数数组和文件名。你可以尝试修改代码,使用其他 exec 函数来观察效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>

int main(){
char *args1 = "ls";
char *args2 = "-la";

// 参数数组,用于 execvp
char* command[] = {args1, args2, NULL};
pid_t pid1;

pid1 = fork();
if(pid1 < 0){
fprintf(stderr, "Error: creating child process\n");
exit(EXIT_FAILURE);
}

if(pid1 == 0){
// execlp 接收 N 个参数:
// 参数 1:要执行的可执行文件名
// 参数 2: 命令行参数 0(通常也是文件名)
// 参数 3...N-1: 命令行参数
// 最后一个参数必须为 NULL。
printf("Child executing execlp...\n");
execlp(args1, args1, args2, NULL);
// 如果 execlp 执行成功,绝不会返回到这里
perror("execlp failed");
exit(EXIT_FAILURE);
} else {
// 父进程等待子进程结束
wait(NULL);
// execvp 接收两个参数:
// 参数 1:要执行的可执行文件名
// 参数 2: 执行参数的字符串数组
// 数组最后的元素必须为 NULL。
printf("\nParent executing execvp...\n");
execvp(args1, command);
// 如果 execvp 执行成功,绝不会返回到这里
perror("execvp failed");
exit(EXIT_FAILURE);
}
return 0;
}

exec 系列函数中,NULL 参数的作用是标志参数列表或数组的结束。

wait():进程同步与资源回收

我们在上面的代码中已经使用过 wait(),但没有详细介绍。它的典型用法是与 fork() 搭配使用。wait(NULL) 的意思是使调用进程(父进程)阻塞,直到其任意一个子进程状态改变(通常是终止)。

如果没有使用 wait(),父进程可能先于子进程终止,此时子进程会变成“孤儿进程”,被 init 进程接管。更严重的情况是,如果子进程终止了,但父进程没有调用 wait() 来读取它的退出状态,子进程在系统进程表中依然占用一个位置,直到父进程结束。这种状态的子进程被称为僵尸进程 (zombie process)[^1]。

你可能对第一次写 Hello World 时的 return 0; 还有印象。那时课本上只告诉你:“return 0 代表函数的返回值是 0”,但你可能无法理解为什么要返回它,或者是返回给谁。现在理解了父子进程的概念,你就会明白这个 0 是返回给父进程的,用于接收子进程的退出状态。

要获取这个返回值,我们需要定义一个整型变量 status,并将它的地址传递给 wait()。之后,我们可以使用宏来解析这个变量,确定子进程的退出状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>

int main(){
pid_t pid;
pid = fork();

if(pid < 0){
fprintf(stderr, "Error: creating child process\n");
exit(EXIT_FAILURE);
}

if(pid == 0){
printf("Child process (PID: %d) is exiting...\n", getpid());
// 尝试修改这个返回值(例如 exit(5)),看看父进程输出的结果
exit(42);
} else {
int status;
// wait() 阻塞父进程,并将子进程状态信息存入 status
pid_t id = wait(&status);

printf("\nParent process (PID: %d) reports:\n", getpid());

// 使用 WEXITSTATUS 宏从 status 中提取子进程的 exit code
if (WIFEXITED(status)) {
printf("Child process (PID: %d) returned with status: %d\n", id, WEXITSTATUS(status));
} else {
printf("Child process (PID: %d) did not terminate normally.\n", id);
}
}
return 0;
}

WEXITSTATUS 是一个预定义的宏,用于在确定子进程正常终止(WIFEXITED(status) 为真)后,查看其返回的值。还有很多其他的宏可以检查不同的状态改变(例如被信号终止、暂停等),详情可以点这里查看 manual 手册。

这里有几个关键点,一定不要混淆:

  1. wait() 的返回值不是子进程的退出状态,而是状态发生改变的子进程的 PID
  2. status 变量本身并不直接等于子进程的返回值,它包含了有关子进程退出状态的多位信息,需要通过宏来提取。

另外需要说明的是,wait() 函数并不仅仅是在等待子进程终止,而是在子进程发生状态转移(state change)的时候返回。根据文档提示:

A state change is considered to be: the child terminated; the child was stopped by a signal; or the child was resumed by a signal[^2]。

也就是说,进程在子进程终止、被信号暂停(stop)、以及被信号重新激活(resume)的时候,都算作状态转移,都会使 wait() 返回。

总结

  1. 创建子进程:使用 fork()。父进程复刻自身,通过返回值区分父子进程逻辑。
  2. 执行新程序:使用 exec() 系列函数。它会用新程序的映像替换当前进程的内容。
  3. 进程同步与资源回收:父进程使用 wait()waitpid() 来等待子进程状态改变,防止僵尸进程的产生,并获取子进程的退出码。

[^1]: 参见 Wikipedia: 僵尸进程
[^2]: 参见 Linux man page: wait(2)