ZeroOne AI
← 返回文章列表

Linux系统篇(十六)——进程(五):Linux 进程控制全解:fork、exec、wait 核心原理与实战

👁 4
分类:工业互联网

从原理到实战完整梳理进程生命周期:fork 创建与写时拷贝、exit 终止与退出码、wait/waitpid 回收、exec 系列程序替换,附全套代码案例。

项目背景

进程是 Linux 操作系统调度资源的基本单位,而进程控制则是 Linux 系统编程中最核心的知识点之一。无论是日常开发、底层学习还是面试考察,fork 创建子进程、exec 系列函数实现程序替换、wait/waitpid 完成进程等待与资源回收,都是绕不开的重点。

很多初学者对这三者的理解停留在"会用"层面:知道 fork 会复制进程,却不理解它的返回值机制和写时拷贝;知道 exec 能执行新程序,却搞不清六个函数变体的区别;知道要 wait,却不明白僵尸进程的危害和退出状态的解析方法。本文从原理入手,结合完整代码案例,梳理进程从创建、运行、替换到退出、回收的全流程。

技术方案

1. fork():一个调用,两个返回值

fork() 是创建新进程的核心系统调用,定义在 <unistd.h> 中:

pid_t fork(void);

返回值规则:

这是 fork 最特殊的地方:一次函数调用,会在两个进程中分别返回两次。fork 之后,系统中出现两个二进制代码完全相同的进程,它们都会从 fork 调用之后的位置开始执行。

2. fork 的内核工作流程

进程调用 fork 时,内核完成以下关键操作:

  1. 为子进程分配新的内存块和内核数据结构(PCB);
  2. 将父进程的部分数据结构内容拷贝至子进程;
  3. 将子进程添加到系统进程列表中;
  4. 调度器开始调度子进程。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void) {
    pid_t pid;
    printf("Before: pid is %d\n", getpid());
    if ((pid = fork()) == -1) {
        perror("fork()");
        exit(1);
    }
    printf("After: pid is %d, fork return %d\n", getpid(), pid);
    sleep(1);
    return 0;
}

3. 写时拷贝(COW)

默认情况下,父子进程的代码段是共享的,数据段在子进程未写入时也共享。当任意一方尝试修改数据时,内核才以写时拷贝的方式为修改方生成一份数据副本,从而保证进程独立性。写时拷贝是一种"延迟申请"技术,避免了 fork 时直接拷贝整个进程地址空间,大幅提升了内存利用率和进程创建速度。

4. fork 的常规用法与失败原因

常见用法:

失败原因:

系统架构

1. 进程终止的三种方式

进程终止的本质是释放系统资源,包括内核数据结构、代码和数据。进程退出的场景分三类:

  1. 代码运行完毕且结果正确(如 main 函数 return 0);
  2. 代码运行完毕但结果不正确(如 main 函数返回非 0 值);
  3. 代码异常终止(收到信号被杀死,如段错误、Ctrl+C 中断等)。一旦异常终止,退出码将无意义,操作系统会提供一个"信号终止状态码"说明进程是怎么死的:
#include <unistd.h>
#include <signal.h>

int main(void)
{
    kill(getpid(), SIGKILL);   // 主动用 SIGKILL 杀死自己
    return 0;
}

2. 正常终止:exit 与 _exit 的区别

正常终止有三种途径:

区别体现在代码上:

// exit():会刷新缓冲区,输出 hello
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    printf("hello");   // 未加 \n,数据暂存在缓冲区
    sleep(1);
    printf("\n");
    exit(0);           // 刷新缓冲区,hello 被输出
}
// _exit():不刷新缓冲区,无输出
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("hello");
    _exit(0);          // 直接终止,缓冲区内容丢失
}

3. 退出码约定

退出码反映命令执行结果,Linux 约定:

退出码解释
0命令成功执行
1通用错误代码
2命令(或参数)使用不当
126权限被拒绝,无法执行
127未找到命令或 PATH 错误
130通过 Ctrl+C(SIGINT)终止

实施过程

1. 进程等待:避免僵尸进程

如果子进程退出后父进程没有回收它的资源,子进程就会变成僵尸进程,占用系统资源且无法被 kill -9 杀死。进程等待的核心目的:回收子进程资源,避免资源泄漏获取子进程退出状态,判断任务是否正常完成

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

// 没有 wait,子进程结束后会变成僵尸状态
int main()
{
    pid_t t = fork();
    if (t == 0)
    {
        printf("我是一个子进程:%d\n", getpid());
        sleep(5);
    }
    else
    {
        while (1)
        {
            sleep(1);
            printf("我是一个父进程:%d\n", getpid());
        }
    }
    return 0;
}

wait():等待任意子进程

pid_t wait(int *wstatus);

返回值:成功返回被等待进程的 PID,失败返回 -1;参数 wstatus 是输出型参数,用于获取子进程退出状态,不关心时可传 NULL。

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

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程:可改为 exit(42) 或触发段错误测试
        printf("我是子进程,pid=%d\n", getpid());
        sleep(2);
        exit(42);          // 测试1:正常退出
        // int *p = NULL; *p = 1;   // 测试2:段错误
    } else {
        int status;
        wait(&status);     // 等子进程,同时把状态存入 status

        if (WIFEXITED(status)) {
            printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("子进程被信号杀死,信号号:%d\n", WTERMSIG(status));
        }
    }
    return 0;
}

waitpid():更灵活的等待

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

退出状态解析:status 是一个位图,仅低 16 位有效——正常终止时高 8 位为退出码、低 7 位为 0;异常终止时低 7 位为终止信号、第 8 位为 core dump 标志。常用宏:WIFEXITED(status) 判断是否正常退出,WEXITSTATUS(status) 提取退出码(仅正常退出时有效)。

阻塞等待示例

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

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        printf("child is run, pid is: %d\n", getpid());
        sleep(5);
        exit(257);
    } else {
        printf("我是父进程:%d\n", getpid());
        int status = 0;
        pid_t ret = waitpid(pid, &status, 0);   // 阻塞等待
        if (ret > 0 && WIFEXITED(status)) {
            printf("wait success, child return code is: %d\n", WEXITSTATUS(status));
        }
    }
    return 0;
}

非阻塞等待示例

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

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        printf("child is run, pid is: %d\n", getpid());
        sleep(5);
        exit(257);
    } else {
        int status = 0;
        pid_t ret = waitpid(pid, &status, WNOHANG);  // 非阻塞等待
        while (1) {
            sleep(1);
            printf("我是父进程:%d\n", getpid());
            ret = waitpid(pid, &status, WNOHANG);    // 轮询检查子进程
            if (ret > 0 && WIFEXITED(status)) {
                printf("wait success, child return code is: %d\n", WEXITSTATUS(status));
                break;
            }
        }
    }
    return 0;
}

2. 进程程序替换:exec 系列函数

fork 创建的子进程会与父进程执行相同代码,而程序替换可以让子进程加载并执行一个全新的程序,且不改变进程的 PID。原理:进程调用 exec 系列函数时,内核将新程序的代码和数据加载进进程地址空间,覆盖原有代码段和数据段,进程从新程序的启动例程开始执行。

六个 exec 函数

#include <unistd.h>

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

命名规律:l(list)参数以列表形式传递、必须以 NULL 结尾;v(vector)参数以字符串数组形式传递、数组末尾必须为 NULL;p(path)自动搜索 PATH 环境变量、无需写全路径;e(env)可自定义传入环境变量数组。

函数对比:

函数名参数格式是否自动用 PATH是否自定义环境变量
execl列表否(使用当前环境)
execlp列表否(使用当前环境)
execle列表
execv数组否(使用当前环境)
execvp数组否(使用当前环境)
execve数组是(真正的系统调用)

先准备一个被替换执行的程序 other.cc:

// other.cc:编译为 other 可执行文件
#include <iostream>
#include <unistd.h>

int main(int argc, char *argv[], char *env[])
{
    std::cout << "I am other program, pid: " << getpid() << std::endl;
    for (int i = 0; i < argc; i++)
        std::cout << "argv[" << i << "] = " << argv[i] << std::endl;
    return 0;
}

execl:完整路径 + 列表参数

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

int main() {
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }
    if (pid == 0) {
        // 参数列表必须以 NULL 结尾
        execl("/bin/ls", "ls", "-l", NULL);
        // execl 成功则下面代码不会执行
        perror("execl failed");
        exit(EXIT_FAILURE);
    } else {
        wait(NULL);
        printf("execl 测试完成\n");
    }
    return 0;
}

execlp:自动搜索 PATH + 列表参数

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

int main() {
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }
    if (pid == 0) {
        // 无需写 /bin/ls,自动从 PATH 查找
        execlp("ls", "ls", "-a", NULL);
        perror("execlp failed");
        exit(EXIT_FAILURE);
    } else {
        wait(NULL);
        printf("execlp 测试完成\n");
    }
    return 0;
}

execle:完整路径 + 列表参数 + 自定义环境变量

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

int main() {
    // 自定义环境变量列表,必须以 NULL 结尾
    char *const env[] = {
        "MY_NAME=Tom",
        "MY_AGE=18",
        NULL
    };

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }
    if (pid == 0) {
        // 用 execle 执行 env 命令查看环境变量
        execle("/usr/bin/env", "env", NULL, env);
        perror("execle failed");
        exit(EXIT_FAILURE);
    } else {
        wait(NULL);
        printf("execle 测试完成\n");
    }
    return 0;
}

execv:完整路径 + 数组参数

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

int main() {
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }
    if (pid == 0) {
        char *const argv[] = {"ls", "-lh", NULL};   // 数组以 NULL 结尾
        execv("/bin/ls", argv);
        perror("execv failed");
        exit(EXIT_FAILURE);
    } else {
        wait(NULL);
        printf("execv 测试完成\n");
    }
    return 0;
}

execvp:自动搜索 PATH + 数组参数

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

int main()
{
    printf("我的程序要运行了!\n");
    if (fork() == 0)
    {
        char *const argv[] = {"other", "-a", "-b", "-c", "-d", NULL};
        execvp("./other", argv);   // 执行当前目录的 other 程序
        exit(1);
    }
    waitpid(-1, NULL, 0);
    printf("我的程序运行完毕了\n");
}

execve:真正的系统调用

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

int main()
{
    printf("我的程序要运行了!\n");
    if (fork() == 0)
    {
        char *const argv[] = {"other", "-a", "-b", "-c", "-d", NULL};
        extern char **environ;
        execve("/home/user/project/other", argv, environ);  // 完整路径 + 环境数组
        exit(1);
    }
    waitpid(-1, NULL, 0);
    printf("我的程序运行完毕了\n");
}

注意:如果传入自己的环境变量数组,就不会继承父进程的环境变量,即程序替换会覆盖之前进程的环境变量。想保留原环境并追加新变量,可以用 putenv 先注入再调用不带 e 的函数:

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

char *const addenv[] = {
    (char *const)"MYVAL=123456789",
    (char *const)"MYVAL1=123456789",
    (char *const)"MYVAL2=123456789",
    NULL
};

int main()
{
    printf("我的程序要运行了!\n");
    if (fork() == 0)
    {
        // 先把新变量注入当前环境
        for (int i = 0; addenv[i]; i++)
            putenv(addenv[i]);

        char *const argv[] = {"other", "-a", "-b", "-c", "-d", NULL};
        execvp("./other", argv);   // 非 e 结尾:继承当前(已扩展的)环境
        exit(1);
    }
    waitpid(-1, NULL, 0);
    printf("我的程序运行完毕了\n");
}

应用价值

掌握 fork、exec、wait 这四大核心能力,就真正握住了 Linux 系统编程的骨架。无论是以后深入研究 Shell 解释器的模拟实现、多进程网络服务器(如早期的 Apache 模型),还是理解容器(Container)底层的隔离机制,进程控制都是最坚实的底座。建议在理解原理的基础上,亲手跑一遍文中所有示例代码,用 ps 观察进程状态变化,用 kill 制造异常退出再通过 wait 解析信号,把"概念"变成"手感"。

SEO关键词

Linux, 进程控制, fork, exec, wait, waitpid, 僵尸进程, 写时拷贝

评论(0