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

【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系统调用与库函数关系 理解底层机制

文章目录

  • 进程程序替换:从原理到实战,彻底搞懂exec函数族
    • 📝 前言
    • 一、🌲 什么是进程程序替换
      • 1.1 基本概念
      • 1.2 为什么要fork子进程来替换
      • 1.3 替换成功 vs 替换失败
    • 二、🔍 exec函数族全景
      • 2.1 六个函数一览
      • 2.2 命名规律——字母含义
    • 三、🛠️ 列表传参——execl / execlp / execle
      • 3.1 execl——最基础的列表传参
        • … 可变参数列表是什么
        • 为什么最后要传入一个 NULL
        • 代码示例
      • 3.2 execlp——能搜索PATH的列表传参
      • 3.3 execle——列表传参 + 自定义环境变量
    • 四、📦 数组传参——execv / execvp / execve
      • 4.1 execv——数组传参
        • 为什么是 char* const argv[]
      • 4.2 execvp——数组传参 + PATH搜索
      • 4.3 execve——系统调用本尊
    • 五、⚙️ 六个函数的关系
    • 六、🔄 环境变量的传递
      • 6.1 不传 envp 时——继承父进程环境变量
      • 6.2 传 envp 时——覆盖式替换
      • 6.3 如何新增环境变量——putenv()
        • 两种新增方式
    • 七、🤔 为什么替换不影响父进程
      • 7.1 原因一:写时拷贝(Copy-on-Write)
      • 7.2 原因二:地址空间隔离
    • 八、🧪 完整实战示例
    • 九、🤔 几个思考题
      • 1️⃣ exec 成功后,进程的 PID 会变吗?
      • 2️⃣ execve 和其他5个函数有什么本质区别?
      • 3️⃣ 为什么 execle 传的 envp 是覆盖式的,而不是追加式的?
      • 4️⃣ 如果 execlp 找不到可执行文件,会发生什么?
    • 本节完

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

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 | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

赞(0)
未经允许不得转载:网硕互联帮助中心 » 【Linux系统编程】进程程序替换:为什么替换、如何替换、替换效果和底层原理
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!