ZeroOne AI
← 返回文章列表

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

👁 37
分类:工业互联网

从原理到实战完整梳理进程生命周期: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, 僵尸进程, 写时拷贝

推荐阅读

更多工业 AI、智能制造与技术实践文章

AI视觉

树莓派部署YOLOv8手势检测实战:USB IMX378摄像头 + Python + PyTorch + ONNX Runtime + Systemd开机自动运行

本文面向树莓派AI视觉、Python人工智能、YOLOv8目标检测初学者,以树莓派部署手势检测项目为案例,完整介绍从USB IMX378摄像头安装、Miniconda环境配置、Python虚拟环境创建、YOLOv8依赖安装,到手势检测程序Systemd开机自动运行的完整流程。在Raspberry Pi 5 / ARM64(aarch64)环境下建立独立的yolov8-env环境,安装PyTorch、Ultralytics、ONNX Runtime、NumPy等AI视觉组件,最后通过Linux systemd使fatigueDetect.py开机自动运行,实现无需手工登录的AI视觉自动检测,为后续手势识别、人体检测、疲劳驾驶检测、工业视觉检测与边缘AI视觉系统打下基础。

M3 Max 96GB 本地部署 Qwen3.6-35B-A3B:打造 SolidWorks 智能机械设计与企业知识库 AI 工作站

M3 Max 96GB 统一内存适合同时运行本地大模型与设计工具链。本文推荐 MoE 架构的 Qwen3.6-35B-A3B(总参数约 35B、单 Token 激活约 3B)配合 MLX 4bit 量化本地部署,打造 SolidWorks 智能机械设计与企业知识库 AI 工作站:mlx-lm 提供 OpenAI 兼容 API,OpenWebUI 做对话入口,通过 SolidWorks MCP 工具调用实现参数化建模,结合 BGE-M3 + Reranker 构建企业知识库 RAG,并规划多模型架构与“小龙虾”本地 AI Agent 平台的演进路线。

工业互联网

Linux系统篇(二十三)——通信(一):什么是进程间通信?一文搞懂背景与全貌

通过实验揭示进程隔离的本质,系统梳理Linux进程间通信的六种机制:管道、消息队列、共享内存、信号量、信号与Socket,并给出选型方法。

工业AI

RAG 实战教程(三):向量数据库检索算法,KNN、IVF、HNSW 与 Faiss 实战

本文讲解RAG检索环节的核心:向量检索算法。先厘清相似度算法与检索算法的区别,再逐一剖析KNN/ANN、暴力检索Flat、倒排索引IVF(nlist/nprobe)与分层可导航小世界图HNSW(M/efConstruction/efSearch)的原理与适用场景,并通过Faiss在同一套接口下对三种索引做构建时间、查询时间与Recall@10的实际对比,最后给出调参与项目选型建议。

工业互联网

ESP32-SOLO-1 在 ESP-IDF v5.5 下启动不断重启问题分析与解决

ESP32 工程升级到 ESP-IDF v5.5 后烧录成功,上电却不断重启?日志提示 Running on single core variant of a chip, but app is built with multi-core support。本文定位根因:ESP32-SOLO-1 是单核芯片,工程却按多核 ESP32 编译,cpu_start 启动第二核失败触发 abort 复位;同时工程目录变化导致 build/CMakeCache.txt 缓存旧路径,menuconfig 无法正常配置。给出完整解决流程:清理 build → 确认 Target → 重新生成 CMake → 编译烧录,并建议用 sdkconfig.defaults 固化稳定配置。

工业互联网

Linux系统篇(十一)——进程(一):从零入门操作系统:冯诺依曼体系到进程的完整理解

从冯诺依曼体系讲起,理解操作系统内核与系统调用,再到进程与 PCB,用 fork 与写时拷贝实战入门进程编程

评论(0)