标签:Linux 进程控制 exit _exit 退出码 task_struct 进程等待
重点回答三个问题:父进程为什么要关心子进程怎么退出的?进程有哪几种退出方式,区别是什么?异常终止时退出信息存在哪里?
一、为什么要关心进程的退出情况
子进程也是进程,它由父进程创建,替父进程执行任务。任务执行得怎么样,父进程自然需要知道。进程退出可以分为三种场景:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
前两种是代码跑完了 ,结果好坏由退出码 决定;第三种是代码没跑完 ,要看终止信号。
二、类比:函数的返回值与进程的退出码
函数调用中,被调函数通过返回值(x86 下放在 eax 寄存器)告诉调用者执行情况:
比如a()调用b() :
b() 中
mov eax, 1
ret
a() 中
call b
mov 0x123[], eax ; 从 eax 取到返回值
进程之间是同样的模式:
| 函数世界 | 进程世界 |
|---|---|
| call | fork + exec |
| return | exit / return |
| 返回值 | 退出码 |
| 调用者读取返回值 | 父进程 wait 读取退出码 |
三、进程的三种正常退出方式
正常终止 可以通过 echo $? 查看退出码:
- 从
main返回 - 调用
exit - 调用
_exit
异常退出:Ctrl+C 等信号终止。
3.1 从 main 返回
int main()
{
return 0; // 0 表示成功
}
main 的返回值通常用来表明程序的执行情况:
return 0:代码跑完,结果正确return 非0(1, 2, 3 ...):代码跑完,结果不正确,不同的值代表不同的出错原因,具体含义由程序员约定
return n 等价于 exit(n):因为调用 main 的运行时启动代码,会把 main 的返回值当作 exit 的参数。所以第 1 种本质上就是第 2 种。
3.2 exit(n)
#include <stdlib.h>
void exit(int status);
exit 是 C 库函数,依次做三件事:
- 执行用户通过
atexit或on_exit注册的清理函数 (此函数是用户自己写的) - 关闭所有打开的流,刷新缓冲区,缓冲数据全部写出
- 调用
_exit
3.3 _exit(n)
#include <unistd.h>
void _exit(int status);
_exit 是 系统调用 ,直接进入内核终止进程,不执行清理函数,不刷新 C 库的缓冲区 。status 定义了进程的终止状态,父进程通过 wait 获取。
说明:status 虽然是 int,但只有低 8 位 能被父进程使用。所以
_exit(-1)时,终端echo $?看到的是 255;exit(257)得到 1(257 % 256)。
3.4 exit 与 _exit 的关系
exit = 用户态收尾工作 + _exit
四、核心实验:为什么 exit 有输出而 _exit 没有
// 实验一
int main()
{
printf("hello"); // 注意:没有 \n
exit(0);
}
// 运行结果:hello[root@localhost linux]#
// 实验二
int main()
{
printf("hello"); // 注意:没有 \n
_exit(0);
}
// 运行结果:[root@localhost linux]# (什么都没有)
原因:数据有两个"仓库"
printf 并不是直接把字符写到屏幕,而是先写进 C 库维护的用户态缓冲区,之后才刷新到内核、再到屏幕:
printf ──> [C库缓冲区(在进程自己的内存里)]
刷新 ──> 内核 ──> 屏幕
终端的刷新策略是行缓冲 :遇到 \n、缓冲区满、主动 fflush、正常退出时才会刷新。
exit:退出前先刷新缓冲区,字符送出去了,所以能看到hello_exit:直接结束进程,缓冲区属于进程的内存,进程一没,里面的字符就丢了
这也说明:这个缓冲区是 C 库的,不是操作系统内核的 。如果缓冲区在内核里,
_exit也不会丢数据。原因:
printf("hello"); _exit(0);的结果是:hello 丢了。什么情况下数据会随着进程结束而丢失?
- 数据放在进程自己的内存里:进程一结束,这块内存被回收,数据就没了。
- 数据放在内核的内存里:内核不会因为你的进程结束而消失,它会继续把数据送到屏幕(或写到文件),数据不会丢。
所以缓冲区在进程自己的内存 里**。**
一个实际坑:fork 之后的子进程
fork 会把父进程用户态缓冲区里未刷新的内容也复制给子进程 。(因为子进程没缺页的时候,mm_struct也会指向父进程的内存)。如果父子进程退出时都调用 exit,各自都会刷新一次,导致同一份内容被打印两遍。因此子进程在 fork 后需要提前退出(如 exec 失败后)时,通常用 _exit 而不是 exit。
五、谁在读取退出码:echo $?
$ ./proc
$ echo $?
1
./proc这个进程的父进程是 bash- bash 通过等待子进程拿到退出码并保存下来
echo $?打印的就是最近一个程序(进程)退出时的退出码
注意:
$?每次都是"上一条命令"的结果,所以连续执行两次echo $?,第二次得到的是第一次 echo 本身的退出码 0。
六、退出码存在哪里:task_struct
进程退出时,退出信息被记录在进程的 PCB 中(Linux 下是 task_struct )。进程退出后会先变成僵尸状态 ,PCB 保留,等待父进程通过 wait/waitpid 来读取。这也是进程等待必要性的来源之一。
七、异常终止时 task_struct 里还有东西吗
既然异常终止"退出码无意义",是不是 task_struct 里什么都没有?
不是。 进程终止时,内核会把终止信息统一填入 task_struct 的 exit_code 字段,只是两种终止方式填的内容不同:
正常终止: | 15 ...... 8 | 7 | 6 ...... 0 |
| 退出码 | 0 | 0 |
被信号杀死: | 15 ...... 8 | 7 | 6 ...... 0 |
| 未用 | core dump | 终止信号 |
- 正常退出(return / exit / _exit):退出码放在第 8~15 位,低 7 位为 0
- 被信号杀死:终止信号编号放在低 7 位,第 7 位是 core dump 标志,高 8 位无参考价值
所以"异常时退出码无意义"的准确含义是:进程没机会执行 return/exit/_exit,没有程序员设定的退出码,此时应该看的是终止信号。
补充:task_struct 里还有一个
exit_signal字段,它表示子进程退出时发给父进程的信号(通常是 SIGCHLD),和"子进程被什么信号杀死"不是一回事,不要混淆。
父进程如何区分两种终止方式?
wait/waitpid 的 status 参数取出的就是上面这个位图,不能当普通整数看:
int status = 0;
waitpid(pid, &status, 0);
if ((status & 0x7F) == 0) {
// 低 7 位为 0:正常退出
printf("exit code: %d\n", (status >> 8) & 0xFF);
} else {
// 低 7 位非 0:被信号终止
printf("signal: %d\n", status & 0x7F);
}
标准库提供了等价的宏,实际开发中更推荐使用:
if (WIFEXITED(status)) {
printf("exit code: %d\n", WEXITSTATUS(status));
} else {
printf("killed by signal: %d\n", WTERMSIG(status));
}
八、总结
| 属于 | 执行清理函数 | 刷新缓冲区 | 最终调用 | |
|---|---|---|---|---|
| return(main) | 语言层面 | 是 | 是 | exit |
| exit | C 库函数 | 是 | 是 | _exit |
| _exit | 系统调用 | 否 | 否 | 内核 |
- 子进程是父进程派出去干活的,父进程需要知道结果
- 三种正常退出方式:main 返回 → 本质是 exit → 最终都落到
_exit - exit 比 _exit 多做用户态收尾(清理函数、刷新缓冲区),缓冲区在 C 库而非内核
- 退出码 0 表示成功,非 0 表示失败,只有低 8 位有效,数值含义由程序约定
- bash 是命令的父进程,
echo $?读取的是上一个进程的退出信息 - 退出信息保存在
task_struct中,进程退出后以僵尸状态等待父进程读取 - 正常退出看退出码 (第 8~15 位),异常退出看终止信号(低 7 位)
- 退出码(
$?)不是 errno;信号终止在 shell 中显示为128 + 信号编号