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

📝 前言
在前面的文章中,我们已经学习了 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 | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

2015

被折叠的 条评论
为什么被折叠?



