【Linux系统编程】进程程序替换:为什么替换、如何替换、替换效果和底层原理

进程程序替换:从原理到实战,彻底搞懂exec函数族

在这里插入图片描述

🌈 say-fall:个人主页
🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》
💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。

📝 前言

在前面的文章中,我们已经学习了 fork 创建子进程和 wait 等待子进程退出。但你有没有想过一个问题:fork 创建的子进程,执行的代码和父进程几乎一模一样——这有什么意义?

在实际开发中,我们往往需要子进程去执行一个 完全不同的程序。比如你在终端敲 ls,shell 并不是一个自己实现了列目录功能的程序,它只是 fork 了一个子进程,然后让子进程去执行 /bin/ls 这个程序。

场景fork 能做到吗我们需要什么
fork子进程复制父进程代码只能执行相同代码
exec替换进程的代码段和数据段执行全新的程序

🔍 问题来了: 如何让一个已经运行的进程,放下手头的代码,去执行另一个全新的程序?

答案就是——进程程序替换(exec函数族)!它能让一个进程"换芯换骨",加载磁盘上一个新程序到当前进程的地址空间中运行。

通过本文,你将掌握:

技能应用场景
进程程序替换的原理理解fork+exec的经典组合
exec系列6个函数的使用在子进程中加载新程序
函数命名规律(l/v/p/e)快速记忆参数传递方式
环境变量的传递与覆盖控制子进程的环境
execve系统调用与库函数关系理解底层机制


一、🌲 什么是进程程序替换

1.1 基本概念

进程程序替换,指的是一个进程调用特定的接口后,将磁盘上的一个全新程序(代码和数据)加载到 调用进程的地址空间 中,从而 替换掉 当前进程的 代码段、数据段、堆、栈 等内容。

关键要点:

替换内容是否被替换说明
代码段被新程序的代码替换
数据段被新程序的数据替换(大小会改变)
堆和栈被重新初始化
PCB(进程控制块)不变PID不变,页表不变,文件描述符表不变

💡 简单理解: 进程程序替换就像一个人换了一身衣服和技能,但身份证号码(PID)没变。

1.2 为什么要fork子进程来替换

一旦程序替换 成功,当前进程 之后的代码就不会执行了——因为代码段已经被替换成新程序的代码了,原来的代码根本不存在了。

所以,如果我们直接在父进程中调用 exec,父进程就"回不来了"。正确的做法是先 fork 一个子进程,在子进程中调用 exec:

pid_t pid = fork();
if (pid == 0) {
    // 子进程:去执行新程序
    execl("/bin/ls", "ls", "-l", NULL);
    // 如果exec成功,下面的代码不会执行
    exit(1);  // 只有exec失败才会执行到这里
} else {
    // 父进程:继续干自己的事
    waitpid(pid, &status, 0);  // 等待子进程结束
}

⚠️ 核心理解: exec 成功无返回值(进程直接去执行新程序了),只有失败才返回 -1 并设置全局变量 errno 记录错误原因。

1.3 替换成功 vs 替换失败

情况代码段/数据段后续代码返回值
替换成功被新程序替换不执行无返回值
替换失败不受影响继续执行返回 -1,设置 errno

💡 设计哲学: exec 函数"只返回错误,没有成功返回值"。因为成功就意味着进程已经变成另一个程序了,原来的执行流已经不存在了。


二、🔍 exec函数族全景

2.1 六个函数一览

exec 系列共有 6个 库函数,它们的底层都调用同一个 系统调用 execve

#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[]);

2.2 命名规律——字母含义

exec 后面的字母决定了 参数传递方式路径搜索方式

字母含义说明
l (list)列表传参参数逐个列出,以 NULL 结尾
v (vector)数组传参参数放在指针数组中传入
p (path)路径搜索不用给完整路径,自动搜索环境变量 PATH
e (env)环境变量自己维护环境变量,传入自定义 envp

组合起来:

函数传参方式搜索PATH自定义环境变量
execl列表
execlp列表
execle列表
execv数组
execvp数组
execve数组

💡 记忆口诀: l 是逐个列、v 是装数组、p 能搜 PATH、e 能传环境。


三、🛠️ 列表传参——execl / execlp / execle

3.1 execl——最基础的列表传参

int execl(const char *path, const char *arg, ...);

参数说明:

参数含义示例
path可执行文件的 完整路径"/bin/ls"
arg程序名(约定俗成传入,不影响执行结果)"ls"
可变参数列表,即命令行参数"-l", "-a"
NULL必须以 NULL 结尾标记参数结束
… 可变参数列表是什么

你可能对 ... 感到陌生,其实你早就用过——printf 就是可变参数函数:

printf("hello %d %s\n", 42, "world");  // 你可以传任意数量的参数
execl("/bin/ls", "ls", "-l", "-a", NULL);  // 同样可以传任意数量参数

区别在于:printf 用格式字符串中的 %d%s 来确定参数个数和类型,而 execl 用 NULL 来标记参数列表的结束。

为什么最后要传入一个 NULL

可变参数函数 无法自动知道传了多少个参数。execl 内部会依次读取每个参数,直到遇到 NULL 才停止。如果你 忘记传 NULL

// ❌ 错误!没有NULL结尾,函数会越界读取内存
execl("/bin/ls", "ls", "-l");

// ✅ 正确!NULL标记参数结束
execl("/bin/ls", "ls", "-l", NULL);

⚠️ 必须以NULL结尾: 不传 NULL 会导致未定义行为,程序可能崩溃或读取垃圾数据。NULL 是告诉 exec 函数"参数到这里就结束了"的哨兵值。

代码示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    printf("程序替换开始...\n");
    
    // 用 execl 执行 /bin/ls -l -a
    execl("/bin/ls", "ls", "-l", "-a", NULL);
    
    // 如果exec成功,下面的代码不会执行
    // 只有失败才会走到这里
    perror("execl failed");
    exit(1);
}

运行结果:

程序替换开始...
total 8
drwxr-xr-x 2 user user 4096 Jul 26 17:00 .
drwxr-xr-x 3 user user 4096 Jul 26 16:50 ..
-rw-r--r-- 1 user user  200 Jul 26 17:00 a.out

💡 注意: “程序替换开始…” 打印了,但 execl 之后的 perror 没有执行——说明替换成功了,后续代码不再执行。

3.2 execlp——能搜索PATH的列表传参

int execlp(const char *file, const char *arg, ...);

和 execl 的 唯一区别:第一个参数不需要给完整路径,只需要给程序名,execlp 会自动在 环境变量 PATH 指定的目录中搜索。

函数第一个参数示例
execl完整路径execl("/bin/ls", ...)
execlp程序名execlp("ls", ...)
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    // 不需要写 /bin/ls,直接写 ls
    execlp("ls", "ls", "-l", "-a", NULL);
    
    perror("execlp failed");
    exit(1);
}

💡 为什么 execlp 能直接传入参数名而不传路径?

因为 execlp 内部会调用 getenv("PATH") 获取环境变量 PATH 的值(例如 /usr/local/bin:/usr/bin:/bin),然后依次在 PATH 指定的每个目录中搜索可执行文件。就像你在终端直接敲 ls 能执行一样,shell 也是通过 PATH 找到 /bin/ls 的。带 p 的函数把这个搜索逻辑封装好了,你不用手动拼路径。

3.3 execle——列表传参 + 自定义环境变量

int execle(const char *path, const char *arg, ..., char *const envp[]);

最后一个参数 envp 是环境变量列表,它是一个 指针数组,每个元素格式为 "KEY=***",以 NULL 结尾。

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

int main()
{
    char *const envp[] = {
        "PATH=/bin:/usr/bin",
        "TERM=console",
        "HOME=/home/user",
        NULL
    };
    
    // 用自定义环境变量执行 /bin/env
    execle("/bin/env", "env", NULL, envp);
    
    perror("execle failed");
    exit(1);
}

⚠️ 重要: envp 是 覆盖式 的!不是追加。新程序看到的环境变量 只有 envp 中的那些,原来的环境变量全部被替换掉。


四、📦 数组传参——execv / execvp / execve

4.1 execv——数组传参

int execv(const char *path, char *const argv[]);

这里的 v 代表 vector(数组)。和 execl 的区别只是传参方式不同——execl 把参数逐个列出,execv 把参数放在一个 指针数组 里传入。

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

int main()
{
    // 参数数组——和main函数的argv一样
    char *const argv[] = {"ls", "-l", "-a", NULL};
    
    execv("/bin/ls", argv);
    
    perror("execv failed");
    exit(1);
}

💡 v 和 l 的关系: 就像 main(int argc, char *argv[]) 接收数组参数一样,execv 用数组传参更适合程序化构造参数列表的场景——比如你在写一个 shell,需要动态拼接命令参数。

为什么是 char* const argv[]

char *const argv[] 的含义拆解:

部分含义
char *指向字符串的指针
const指针本身不可变(不能修改 argv[i] 指向哪个字符串)
argv[]数组形式

⚠️ const 的作用: 防止函数内部修改调用者传入的指针数组。execv 只需要读取参数,不应该修改你传入的数组。这是一种 契约式设计——我承诺不改你的数据,你也放心传给我。

4.2 execvp——数组传参 + PATH搜索

int execvp(const char *file, char *const argv[]);

execvp = execv + PATH 搜索,和 execlp 类似,只是传参方式从列表变成了数组:

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

int main()
{
    char *const argv[] = {"ps", "-e", "-f", NULL};
    
    // 不用写 /bin/ps,直接写 ps
    execvp("ps", argv);
    
    perror("execvp failed");
    exit(1);
}

4.3 execve——系统调用本尊

int execve(const char *path, char *const argv[], char *const envp[]);

execve 是 真正的系统调用,上面5个函数都是 C 库函数,它们最终都会 调用 execve

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

int main()
{
    char *const argv[] = {"ls", "-l", "-a", NULL};
    char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
    
    execve("/bin/ls", argv, envp);
    
    perror("execve failed");
    exit(1);
}

💡 查看方法: 在 Linux 下输入 man 2 execve 看到的是系统调用手册(第2节),而 man 3 execl 看到的是库函数手册(第3节)。这印证了 execve 是系统调用,其余5个是库函数。


五、⚙️ 六个函数的关系

exec 系列六个函数的底层关系:

        execl     execlp    execle
          │         │         │
          ▼         ▼         ▼
        execv     execvp    ──────────────────────┐
          │         │                              │
          ▼         ▼                              ▼
    ┌─────────────────────────┐            ┌──────────────┐
    │  C库函数(封装层)       │            │  系统调用     │
    │  做参数格式转换          │            │  execve()    │
    │  处理PATH搜索            │ ─────────▶ │  内核实现     │
    │  处理环境变量继承        │            │              │
    └─────────────────────────┘            └──────────────┘

简单总结:

函数类型传参搜PATH自定义env
execl库函数列表
execlp库函数列表
execle库函数列表
execv库函数数组
execvp库函数数组
execve系统调用数组

💡 记忆技巧: execve 是唯一的系统调用,名字最全——有 v(数组)、e(环境变量),但没有 p(不搜 PATH)。其他5个都是对 execve 的封装,各自添加了便利功能。


六、🔄 环境变量的传递

6.1 不传 envp 时——继承父进程环境变量

execl、execlp、execv、execvp 这四个函数没有 envp 参数,那么新程序怎么获得环境变量?

答案是:新程序会 自动继承 调用进程的环境变量。这通过一个全局变量 environ 实现。

#include <unistd.h>

// environ 是一个全局变量,指向环境变量列表
extern char **environ;

// 不带 e 的 exec 函数,内部相当于:
// execve(path, argv, environ);

💡 environ 是什么?为什么无需传参就能获得环境变量?

每个C程序启动时,操作系统会把环境变量列表放入进程的内存空间中,并通过全局变量 environ 暴露出来。environ 是一个指向字符串数组的指针,每个字符串格式为 "KEY=***"。不带 e 的 exec 函数内部就是拿 environ 传给了 execve,所以你不需要显式传参,新程序也能自动获得当前进程的所有环境变量。这就是为什么 main 函数不接收环境变量参数,程序里用 getenv("PATH") 也能拿到 PATH 的原因。

6.2 传 envp 时——覆盖式替换

execle 和 execve 这两个带 e 的函数,可以显式传入环境变量列表。注意这是 覆盖式 的:

// 父进程有这些环境变量:PATH, HOME, USER, TERM, ...

char *const envp[] = {
    "PATH=/bin:/usr/bin",
    "TERM=console",
    NULL
};

// 新程序只能看到 PATH 和 TERM,其他的全部丢失!
execve("/bin/env", argv, envp);

6.3 如何新增环境变量——putenv()

如果想让子进程继承父进程的所有环境变量 加上 一个新的环境变量,怎么办?

putenv() 函数:

#include <stdlib.h>

int putenv(char *string);
// 参数格式:"KEY=***"
// 成功返回0,失败返回非0

putenv 会修改当前进程的 environ 列表,新增或修改一个环境变量。

两种新增方式
方式操作适用场景
方式一putenv("KEY=***"),再调用不带 e 的 exec简单,继承全部环境变量 + 新增
方式二putenv("KEY=***"),再调用带 e 的 exec,传入 environ需要完全控制子进程环境变量

方式一示例:

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

int main()
{
    // 新增一个环境变量
    putenv("MY_VAR=hello");
    
    // 子进程会继承 environ(包含MY_VAR)
    char *const argv[] = {"env", NULL};
    execvp("env", argv);  // 不带e,自动继承environ
    
    perror("execvp");
    exit(1);
}

方式二示例:

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

int main()
{
    extern char **environ;
    
    // 新增环境变量到environ
    putenv("MY_VAR=hello");
    
    char *const argv[] = {"other_program", NULL};
    
    // 显式传入environ(已被putenv修改过)
    execve("./other", argv, environ);
    
    perror("execve");
    exit(1);
}

💡 两种方式的区别: 方式一让子进程继承全部环境变量 + 新增的;方式二可以 完全控制 子进程看到哪些环境变量(先 putenv 再传 environ,或自己构造 envp 数组)。带 e 的函数配合 putenv + environ,既能在原有基础上增量修改,又能完全自定义。


七、🤔 为什么替换不影响父进程

当我们在 fork 出的子进程中调用 exec 替换程序时,父进程完全不受影响,继续正常执行。原因有 两个

7.1 原因一:写时拷贝(Copy-on-Write)

fork 创建子进程时,父子进程 共享同一份物理内存(代码段、数据段等)。只有当某一方尝试 写入 时,内核才会真正复制一份。

exec 调用后,内核会为新程序分配 全新的物理内存页 来加载代码段和数据段。子进程的页表被指向新页面,父进程的页表仍然指向原来的页面——两者从此 完全独立

7.2 原因二:地址空间隔离

每个进程都有 独立的虚拟地址空间。exec 替换的是 当前进程 的地址空间,不会碰父进程的地址空间。

原因说明
写时拷贝fork 后共享物理页,exec 触发新分配,父子页表分离
地址空间隔离每个进程独立虚拟地址空间,exec 只替换当前进程

💡 一句话总结: fork 让子进程获得了独立的地址空间(COW 机制),exec 只是在自己的地址空间里"翻新",关父进程什么事都没有。


八、🧪 完整实战示例

下面用一个完整的示例演示 fork + exec + wait 的经典组合:

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

int main()
{
    printf("== 父进程开始 ==\n");
    
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        exit(1);
    }
    
    if (pid == 0) {
        // ========== 子进程 ==========
        printf("子进程 PID=%d,准备执行 ls -l\n", getpid());
        sleep(1);  // 故意睡1秒
        
        // 用 execlp 执行 ls 命令
        execlp("ls", "ls", "-l", NULL);
        
        // 如果走到这里,说明 exec 失败了
        perror("execlp failed");
        exit(1);
    } else {
        // ========== 父进程 ==========
        printf("父进程 PID=%d,等待子进程 %d\n", getpid(), pid);
        
        int status;
        pid_t ret = waitpid(pid, &status, 0);
        
        if (ret > 0 && WIFEXITED(status)) {
            printf("子进程正常退出,退出码=%d\n", WEXITSTATUS(status));
        } else if (ret > 0) {
            printf("子进程异常退出,信号=%d\n", WTERMSIG(status));
        } else {
            perror("waitpid");
        }
        
        printf("== 父进程结束 ==\n");
    }
    
    return 0;
}

运行结果:

== 父进程开始 ==
父进程 PID=5000,等待子进程 5001
子进程 PID=5001,准备执行 ls -l
total 8
-rwxr-xr-x 1 user user 8560 Jul 26 17:00 a.out
-rw-r--r-- 1 user user 1200 Jul 26 17:00 demo.c
子进程正常退出,退出码=0
== 父进程结束 ==

💡 观察要点:

  • 子进程的 printf 打印了,说明 fork 成功
  • ls -l 的输出来了,说明 exec 替换成功
  • 子进程 execlp 之后的 perror 没有执行,说明替换成功后不再返回
  • 父进程的 waitpid 成功回收了子进程

九、🤔 几个思考题

学完本文,来试试回答这些问题:

1️⃣ exec 成功后,进程的 PID 会变吗?

答: 不会变。exec 只替换了进程的代码段、数据段、堆和栈,但 PCB(进程控制块)不变。PID 存储在 PCB 中,所以 PID 保持不变。这也是为什么 shell 能用 waitpid 等待到执行了新程序的子进程——因为 PID 没变。

💡 进程的"身份证"不变,只是"工作内容"变了。

2️⃣ execve 和其他5个函数有什么本质区别?

答: execve 是系统调用(由内核直接实现,对应 man 2 手册),其他5个是 C 库函数(由 glibc 封装,对应 man 3 手册)。库函数最终都会调用 execve 系统调用,它们只是做了参数格式转换的便利封装:

  • l 变体:把可变参数列表转成数组(给 execve 的 argv)
  • p 变体:在 PATH 环境变量中搜索可执行文件
  • 不带 e 的函数:自动传入 environ 给 execve

3️⃣ 为什么 execle 传的 envp 是覆盖式的,而不是追加式的?

答: 因为在操作系统层面,execve 系统调用 完全替换 了进程的地址空间,环境变量区域也被 全部替换。execve 不知道也不关心原来有哪些环境变量,它只认你传入的 envp。如果需要"在原有基础上新增",应该先用 putenv() 修改 environ,再传入 environ(或使用不带e的函数自动继承)。

💡 覆盖式的哲学:简单直接,不会有意外的环境变量泄露给新程序。

4️⃣ 如果 execlp 找不到可执行文件,会发生什么?

答: execlp 在 PATH 指定的所有目录中搜索完毕后仍未找到文件,会 返回 -1,并设置 errno 为 ENOENT(No such file or directory)。此时进程的代码段和数据段 不受影响,继续执行 exec 之后的代码。

execlp("nonexistent_command", "nonexistent_command", NULL);
// 返回-1,errno = ENOENT
perror("execlp");  // 输出:execlp: No such file or directory

本节完

📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

评论 35
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值