在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) | 通过 |
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(代码已在你提供的文档中),可以极大地加深对进程控制的理解。