云计算百科
云计算领域专业知识百科平台

Linux 学习笔记(十九)系统编程:深度拆解 wait/waitpid 与 exec 函数族

一、 进程基础:从 PCB 到内存分布

1. 什么是进程?

进程是程序执行的过程。它不仅是硬盘上的二进制文件(代码),还需要 CPU 调度、内存资源、文件系统等配合。
Linux 内核通过 PCB(进程控制块,task_struct 结构体) 来管理进程。PCB 中记录了:PID、父进程 PID、当前工作路径、打开的文件描述符列表、信号处理、资源上限等核心信息。

2. 进程的内存分布

当我们运行一个可执行文件时,操作系统会为其分配 0~3G 的用户空间(内核空间占 3~4G):

  • 代码段 (Code):只读,存放机器指令。

  • 数据段 (Data/BSS):存放全局变量、静态变量。

  • 堆区 (Heap):由程序员 malloc/free 管理,向高地址生长。

  • 共享库映射区:存放 C 库函数等动态链接库。

  • 栈区 (Stack):存放局部变量,默认 8MB,向低地址生长。

3. 进程的状态切换与 8 种退出方式

经典的进程状态包括:就绪态、运行态、阻塞态。在 Linux 内核中,还有 僵尸态 (Zombie) 和 暂停态 (Stopped)。

进程退出的 8 种情况:

  • main 函数中 return。

  • 调用 exit()(C 库函数,会刷新 I/O 缓冲区,关闭流)。

  • 调用 _exit() 或 _Exit()(系统调用,直接退出,不刷新缓冲区)。

  • 主线程退出。

  • 主线程调用 pthread_exit。

  • 异常终止:调用 abort()。

  • 接收到致命信号(如 kill -9)。

  • 最后一个线程被 pthread_cancel。


  • 二、 进程的创建:fork() 魔法与坑点

    c

    #include <unistd.h>
    pid_t fork(void);

    fork() 会克隆出一个子进程。它有一个神奇的特性:调用一次,返回两次。

    • 在父进程中返回子进程的 PID(> 0)。

    • 在子进程中返回 0。

    • 失败返回 -1。

    1. 底层机制:写时复制 (Copy-on-Write)

    fork 刚创建时,父子进程的用户空间是完全共享的(只读)。一旦父进程或子进程尝试修改内存数据,内核会触发缺页异常,此时才会为修改方真正分配物理内存页。这极大提升了 fork 的效率。

    代码实战:验证写时复制

    c

    #include <stdio.h>
    #include <unistd.h>

    int a = 10;
    int main() {
    pid_t pid = fork();
    if (pid > 0) {
    sleep(3);
    printf("父进程: a = %d\\n", a); // 依然是 10
    } else if (pid == 0) {
    a = 50; // 触发写时复制,子进程开辟新内存页
    printf("子进程: a = %d\\n", a);
    }
    return 0;
    }

    2. 致命坑点与隐藏大坑

    • Fork 炸弹:如果在 for 循环里 fork,子进程创建后必须 exit() 或 break。否则子进程也会继续循环去 fork,导致进程数呈指数级爆炸。

    • 文件共享坑:如果在 fork 之前打开了文件,父子进程会共享同一个文件表项(包括文件偏移量)。一方读取后,另一方的偏移量也会跟着变。

    • 缓冲区重复打印坑:如果在 fork 之前 printf 且没加 \\n(或者输出重定向到了文件),数据会滞留在父进程的标准 I/O 缓冲区里。fork 时子进程会复制这个缓冲区,最终导致父子进程各打印一次,数据翻倍。


    三、 进程回收:wait() 与 waitpid() 深度剖析

    子进程退出后,用户空间被释放,但内核里的 PCB 没有被销毁,变成僵尸进程。父进程必须对其进行回收。

    1. wait() 函数底层原理与状态位图

    c

    #include <sys/wait.h>
    pid_t wait(int *wstatus);

    (1)状态字 wstatus 的二进制奥秘
    wstatus 是一个 4 字节的 int,但 Linux 只使用了它的低 16 位。

    • 高 8 位:存放正常退出时的退出码(如 exit(20) 中的 20)。

    • 低 8 位:如果被信号杀死的,低 7 位存放信号编号,最低位是 core dump 标志(表示是否生成了核心转储文件)。

    千万不能直接判断 wstatus == 20!因为退出码在高位,实际数值可能是 20 << 8 = 5120。必须使用系统宏:

    • WIFEXITED(wstatus):判断是否正常退出。

    • WEXITSTATUS(wstatus):获取正常退出的退出码。

    • WIFSIGNALED(wstatus):判断是否被信号异常终止。

    • WTERMSIG(wstatus):获取终止信号编号。

    • WCOREDUMP(wstatus):判断是否生成了 core 文件。

    (2)被信号中断的坑(EINTR)
    父进程在 wait 阻塞时,如果收到如 SIGALRM 等中断信号,wait 会提前返回 -1,且 errno = EINTR。此时子进程未结束,必须重新调用。

    c

    int status;
    pid_t ret;
    do {
    ret = wait(&status);
    } while (ret == -1 && errno == EINTR); // 被中断就重试

    实战:阻塞等待与状态解析

    c

    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <sys/wait.h>

    void print_wait_status(int status) {
    if (WIFEXITED(status)) {
    printf("子进程正常退出,退出码: %d\\n", WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
    printf("子进程被信号杀死,信号编号: %d\\n", WTERMSIG(status));
    if (WCOREDUMP(status)) printf(" [注意] 进程死前生成了 core 文件\\n");
    }
    }


    2. waitpid() 高级控制与作业控制

    c

    pid_t waitpid(pid_t pid, int *wstatus, int options);

    waitpid 是 wait 的超集,支持精准定位和非阻塞。

    (1)参数 pid:

    • pid > 0:精准等待指定的子进程。

    • pid == -1:等待任意子进程(等价于 wait)。

    • pid == 0:等待同进程组的任意子进程。

    • pid < -1:等待进程组 ID 等于 |pid| 的任意子进程。

    (2)参数 options 与返回值:

    • 0:默认阻塞。

    • WNOHANG:非阻塞模式。子进程还在跑则立即返回 0,绝不阻塞父进程。

    • WUNTRACED:如果子进程被暂停(如 SIGSTOP),也立即返回状态。

    • 返回值:成功 >0,未结束 0(仅非阻塞),错误 -1。

    实战 1:非阻塞轮询与防 CPU 空转

    c

    #include <stdio.h>
    #include <unistd.h>
    #include <sys/wait.h>

    int main() {
    pid_t ret[5] = {0};
    for (int i = 0; i < 5; i++) {
    ret[i] = fork();
    if (ret[i] == 0) {
    sleep(rand() % 5 + 1); // 随机睡 1-5 秒
    exit(0);
    }
    }

    // 非阻塞轮询回收第二个子进程
    while (1) {
    pid_t recycle_pid = waitpid(ret[1], NULL, WNOHANG);
    if (recycle_pid == ret[1]) {
    printf("成功回收第二子进程 wait pid :%d\\n", recycle_pid);
    break;
    } else if (0 == recycle_pid) {
    usleep(100000); // 防 CPU 空转,睡 100ms 再试
    } else {
    break;
    }
    }
    return 0;
    }

    实战 2:利用 WUNTRACED 实现作业控制(Job Control)
    当你在终端按下 Ctrl+Z,内核向进程发送 SIGTSTP 信号,进程进入 T (Stopped) 状态。父进程使用 WUNTRACED 就能捕捉到这个暂停事件。

    c

    // 子进程死循环,父进程 2秒后发 SIGSTOP 暂停它,再发 SIGCONT 恢复
    if (fork() == 0) { while(1) sleep(1); }
    sleep(2); kill(pid, SIGSTOP);
    // 捕捉暂停
    int status;
    waitpid(pid, &status, WUNTRACED);
    if (WIFSTOPPED(status)) printf("捕捉到暂停信号:%d\\n", WSTOPSIG(status));

    实战 3:终极组合 SIGCHLD 信号 + 异步回收
    不要让父进程傻傻阻塞在 wait 里。子进程状态改变时会给父进程发 SIGCHLD 信号,在处理函数里回收:

    c

    void sigchld_handler(int sig) {
    int status;
    pid_t pid;
    // 必须用 while + WNOHANG 回收所有死去的子进程,防止信号丢失导致僵尸
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
    printf("[异步] 回收子进程 PID: %d\\n", pid);
    }
    }
    // 在 main 中注册:
    // struct sigaction sa; sa.sa_handler = sigchld_handler; sa.sa_flags = SA_RESTART;
    // sigaction(SIGCHLD, &sa, NULL);


    四、 进程替换:exec 函数族与内存夺舍

    如果说 fork 是“生娃”,exec 就是“夺舍”。调用 exec 后,当前进程的代码段、数据段、堆栈会被新程序完全覆盖,PID 保持不变。一旦成功,exec 后面的原代码永远不会执行。

    1. 内存布局的变迁与保留

    exec 执行成功时,内核会重建进程的内存。但以下属性被保留:

    • PID、PPID、进程组 ID。

    • 当前工作目录。

    • 打开的文件描述符(fd)(除非设置了 FD_CLOEXEC)。

    • 信号掩码(但自定义信号处理函数重置为默认)。

    2. 函数族与命名规则

    后缀含义说明
    l (list) 列表 参数逐个列出,必须以 NULL 结尾。
    v (vector) 数组 参数打包进 char *argv[]。
    p (PATH) 路径 自动去 $PATH 中查找,无需绝对路径。
    e (env) 环境变量 显式传入自定义环境变量数组,而不是继承。

    代码实战对比

    c

    // 1. execl: 全路径 + 列举
    execl("/bin/ls", "ls", "-a", "-l", NULL);

    // 2. execlp: PATH 查找 + 列举
    execlp("ls", "ls", "-a", "-l", NULL);

    // 3. execvp: PATH 查找 + 数组(最常用)
    char *arg[] = {"ls", "-a", "-l", NULL};
    execvp("ls", arg);

    // 4. execle: 列表传参 + 自定义环境变量
    char *my_env[] = {"PATH=/bin:/usr/bin", "MY_VAR=hello", NULL};
    execle("/usr/bin/env", "env", NULL, my_env);

    3. 必须警惕的夺舍坑点

    • 坑点 1:成功不返回,失败必须处理
      如果失败返回 -1,如果不处理,子进程会继续执行父进程原本的代码逻辑。

      c

      if (fork() == 0) {
      execlp("firefox", "firefox", NULL);
      perror("启动失败"); // 失败才会走到这
      exit(1); // 必须退出
      }

    • 坑点 2:C标准库缓冲区的丢数据问题
      如果在 exec 之前有 printf,且数据滞留在缓冲区(被重定向到文件时)。exec 把内存清空,数据直接丢失。
      解决:在调用 exec 之前,显式刷新缓冲区:fflush(stdout);

    • 坑点 3:FD 泄漏与 FD_CLOEXEC
      如果不关闭不相关的文件描述符,exec 后会泄漏给新程序。在 open 时加 O_CLOEXEC 标志,内核会自动在 exec 时帮你关闭该 FD。

    • 坑点 4:为什么不能用 exec 执行 cd?
      cd 是 Shell 内置命令。且在子进程中 cd 不影响父进程路径。改变目录必须用父进程调用 chdir()。


    五、 终极封装:system 函数与返回值解包

    system 封装了 fork + exec(/bin/sh -c) + waitpid,它会启动子进程调用 Shell 执行字符串。

    c

    int ret = system("ls -l no_such_file.txt");

    返回值解包(非常重要):
    如果 Shell 找不到命令,退出码通常是 127;如果能找到但无权限执行,通常是 126。

    c

    if (ret == -1) {
    perror("system 创建子进程失败");
    } else if (WIFEXITED(ret)) {
    int exit_status = WEXITSTATUS(ret);
    if (exit_status == 127) printf("系统找不到指定的命令\\n");
    else if (exit_status == 0) printf("命令执行成功\\n");
    else printf("命令异常退出,码:%d\\n", exit_status);
    }

    缺点:缺乏精细控制。如果是写高级程序,建议老老实实用 fork + execvp。


    六、 终极实战:手写一个迷你 Shell

    结合我们学过的所有知识:fork 生娃,execvp 替换,waitpid 收尸,再加上内置命令拦截。

    c

    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <string.h>
    #include <sys/wait.h>

    #define MAX_ARGS 64

    int main() {
    char line[1024];
    char *args[MAX_ARGS];

    while (1) {
    printf("mini-shell> ");
    if (fgets(line, sizeof(line), stdin) == NULL) break;
    line[strcspn(line, "\\n")] = 0; // 去除换行

    if (strlen(line) == 0) continue;

    // 解析参数
    int argc = 0;
    char *token = strtok(line, " ");
    while (token != NULL && argc < MAX_ARGS – 1) {
    args[argc++] = token;
    token = strtok(NULL, " ");
    }
    args[argc] = NULL;

    // 处理内置命令 (Built-in)
    if (strcmp(args[0], "exit") == 0) break;
    if (strcmp(args[0], "cd") == 0) {
    // 必须由 Shell 父进程亲自切换
    if (args[1] == NULL) chdir(getenv("HOME"));
    else if (chdir(args[1]) != 0) perror("cd 失败");
    continue; // 直接进入下一轮循环,不用 fork
    }

    // 外部命令:fork + exec
    pid_t pid = fork();
    if (pid == 0) {
    // 子进程:替换为外部程序
    execvp(args[0], args);
    perror("执行失败");
    exit(1); // exec 失败必须退出
    } else if (pid > 0) {
    // 父进程:等待子进程结束
    waitpid(pid, NULL, 0);
    } else {
    perror("fork");
    }
    }
    return 0;
    }


    总结

    进程控制是 Linux 高级开发的基石。理解了状态字的 16 位位图,你就能看透进程的“死因报告”;掌握了 WNOHANG 与 SIGCHLD,你就掌握了异步并发控制的钥匙;吃透了 exec 与 system,你就真正理解了 Shell 甚至容器的底层运作方式。请务必将以上代码在你的 Linux 环境里亲手跑一遍,并结合 ps aux 观察状态切换!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Linux 学习笔记(十九)系统编程:深度拆解 wait/waitpid 与 exec 函数族
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!