在Linux系统中,进程是程序执行的基本单位。理解进程如何结束、父进程如何回收子进程资源,以及进程如何执行新的程序,是掌握系统编程的关键。本篇博客将深入探讨进程的终止、等待和程序替换。
一、进程终止
当一个进程完成其任务或遇到异常时,它需要终止。进程终止的本质是操作系统回收其占用的资源(如内存、文件描述符等)。
1. 进程退出的场景
进程退出主要有三种场景:
-
代码运行完毕,结果正确:这是最理想的情况。
-
代码运行完毕,结果不正确:程序逻辑执行完成,但可能由于输入或逻辑错误,得到了错误的结果。
-
代码异常终止 :进程在运行过程中被信号(如
SIGSEGV段错误)终止。
2. 进程退出的方法
正常终止:
-
从
main函数返回 :return 0等同于调用exit(0)。 -
调用
exit函数:这是标准的库函数,在终止进程前会执行清理工作。 -
调用
_exit或_Exit函数:这是系统调用,直接终止进程,不做任何清理。
异常退出:
-
通过
Ctrl+C产生SIGINT信号终止进程。 -
其他信号,如
kill -9发送的SIGKILL。
3. 退出码
进程退出时,会有一个 退出码 ,用于向启动它的进程(通常是父进程或Shell)报告自己的终止状态。可以通过 echo $?命令查看上一个命令的退出码。
常见的退出码及其含义如下:
| 退出码 | 解释 |
|---|---|
| 0 | 命令成功执行 |
| 1 | 通用错误代码 |
| 2 | 命令或参数使用不当 |
| 126 | 权限被拒绝或无法执行 |
| 127 | 未找到命令(PATH错误) |
| 128+n | 命令被信号终止(n为信号编号) |
| 130 (128+2) | 通过 Ctrl+C(SIGINT) 终止 |
| 143 (128+15) | 通过 SIGTERM(默认终止信号)终止 |
注意 :_exit(int status)函数中,虽然 status是 int类型,但只有低8位会被父进程使用。所以 _exit(-1)在 Shell 中查看到的退出码是 255。
4. exit与 _exit的区别
这是理解进程终止的一个关键点。
-
_exit:系统调用。直接使进程终止,立即关闭所有文件描述符,不会刷新(flush)标准I/O缓冲区。 -
exit:库函数 。它在调用_exit之前,会先执行以下清理工作:-
执行用户通过
atexit()或on_exit()注册的清理函数。 -
关闭所有打开的流(标准I/O流,如
stdout),并将缓冲区中的数据写入文件。
-
示例对比:
// 示例1:使用 exit
int main() {
printf("hello"); // 注意:字符串后没有换行符 \n
exit(0);
}
// 运行结果:输出 hello
// 因为 exit 会刷新缓冲区,将 "hello" 写入标准输出。
// 示例2:使用 _exit
int main() {
printf("hello");
_exit(0);
}
// 运行结果:可能没有任何输出
// 因为 _exit 直接终止进程,缓冲区中的 "hello" 未被刷新。
二、进程等待
1. 为什么需要进程等待?
当一个子进程先于父进程终止时,如果父进程不采取任何措施,子进程就会进入 **"僵尸进程"** 状态。
-
僵尸进程的危害 :僵尸进程保留了其在内核中的进程描述符等少量资源,如果父进程一直不回收,会导致资源泄漏(内存泄漏)。更严重的是,僵尸进程"刀枪不入",连
kill -9也无法杀死。 -
获取子进程信息:父进程需要通过等待来获取子进程的退出状态,判断其是正常结束还是异常退出,以及正常的退出码是多少。
因此,进程等待 是父进程的 责任,其主要目的有两个:
-
回收子进程资源,防止僵尸进程的产生。
-
获取子进程的退出信息。
2. 进程等待的方法
主要有两个函数:wait和 waitpid。
wait函数
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
-
作用:阻塞等待任意一个子进程退出。
-
参数 :
status是一个输出型参数,用于获取子进程的退出状态。如果不关心状态,可设置为NULL。 -
返回值:成功则返回被等待子进程的PID,失败返回-1。
waitpid函数
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
-
作用:功能更强大,可以等待指定的子进程,并支持非阻塞模式。
-
参数:
-
pid:-
pid = -1:等待任意一个子进程,与wait等效。 -
pid > 0:等待进程ID等于pid的子进程。
-
-
status:同wait。 -
options:-
0:默认选项,表示 阻塞等待。 -
WNOHANG:表示 非阻塞等待 。如果指定的子进程没有结束,则waitpid立即返回0,不等待。
-
-
-
返回值:
-
成功时返回收集到的子进程的PID。
-
如果设置了
WNOHANG且子进程未退出,则返回0。 -
调用失败返回
-1。
-
3. 如何解析 status 参数?
status参数不能简单地当作一个整数来看待,而应该将其视为一个 位图。它的低16位包含了退出信息(在32位系统上)。
我们通常使用宏来安全地解析这些信息:
-
WIFEXITED(status):如果这个宏为真(非零),表示子进程是 正常终止 的。 -
WEXITSTATUS(status):如果WIFEXITED为真,此宏用于提取子进程的 退出码 (即exit或return的参数)。 -
WIFSIGNALED(status):如果这个宏为真,表示子进程是被 信号终止 的(异常退出)。 -
WTERMSIG(status):如果WIFSIGNALED为真,此宏用于获取导致子进程终止的 信号编号。
示例代码:
int main(void) {
pid_t pid;
if ((pid = fork()) == -1) {
perror("fork");
exit(1);
}
if (pid == 0) { // 子进程
sleep(20);
exit(10); // 子进程正常退出,退出码为10
} else { // 父进程
int st;
int ret = wait(&st);
if (ret > 0) {
if (WIFEXITED(st)) { // 正常退出
printf("Child exit code: %d\n", WEXITSTATUS(st)); // 输出 10
} else if (WIFSIGNALED(st)) { // 被信号杀死
printf("Child killed by signal: %d\n", WTERMSIG(st));
}
}
}
return 0;
}
三、进程程序替换
fork创建的子进程默认执行的是父进程相同的代码。如果我们希望子进程去执行一个 全新的、不同的程序 (例如,在Shell中输入 ls命令),就需要用到 进程程序替换。
1. 替换原理
进程程序替换的核心函数是 exec系列函数。当进程调用 exec函数时,该进程的用户空间代码和数据 完全被新程序替换 ,并从新程序的 main函数开始执行。
- 关键点 :
exec函数并 不创建新的进程。调用前后,进程的PID保持不变。它只是用磁盘上的一个新程序,替换了当前进程的代码段、数据段等。
2. exec 函数族
有6个以 exec开头的函数,它们功能相同,但参数传递方式不同。
#include <unistd.h>
int execl (const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv (const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
命名规律:
-
l (list) :参数采用 列表 形式,逐个传入,以
NULL结尾。例如:execl("/bin/ls", "ls", "-l", NULL)。 -
v (vector) :参数放入一个 字符串数组 中传入,数组最后一个元素必须是
NULL。例如:char *const argv[] = {"ls", "-l", NULL}; execv("/bin/ls", argv); -
p (path) :带有
p的函数会自动在 环境变量 PATH 指定的目录中搜索可执行文件,无需写全路径。例如:execlp("ls", "ls", "-l", NULL)。 -
e (environment) :带有
e的函数需要用户自己 组装并传入环境变量 数组envp[],不使用当前进程的环境变量。
重要特性 :exec函数只有在调用失败时才有返回值(-1)。如果调用成功,则执行新程序,原程序的后续代码不会再执行。
3. 函数关系
实际上,只有 execve是真正的 系统调用 ,其他五个函数都是库函数,它们最终都会封装 execve来实现功能。其关系如下图所示:
+----------+ +----------+ +----------+
| execl | | execv | | execlp | ... (Library Functions)
+----------+ +----------+ +----------+
| | |
v v v
+-------------------------------------------------+
| execve | (System Call)
+-------------------------------------------------+
总结与实践:微型Shell
将进程创建(fork)、进程等待(waitpid)和进程替换(exec)结合起来,就能理解Shell的工作原理。一个简单的Shell流程如下:
-
获取命令行:显示提示符,读取用户输入的命令。
-
解析命令行:将命令字符串解析为命令名和参数列表。
-
创建子进程 :使用
fork。 -
子进程程序替换 :在子进程中使用
execvp执行命令。 -
父进程等待子进程退出 :使用
waitpid等待子进程结束,防止其变成僵尸进程。
这个过程完美体现了 **"调用/返回"** 的对称性:
-
在函数中,
call调用函数,函数return返回。 -
在进程间,
fork创建子进程,子进程exec执行新程序,新程序exit退出,父进程wait回收。
通过编写一个微型的Shell(代码已在你提供的文档中),可以极大地加深对进程控制的理解。