
上一篇我们讲透了
fork()分身术:调用一次,分裂出两个独立进程。但 fork 有个局限:子进程只能复制父进程的代码和数据,跑和父进程一样的逻辑。
如果想让子进程跑一个全新的程序呢?比如执行ls命令、跑 python 脚本、启动另一个程序?
这就需要 Linux 系统编程第二大利器:exec 系列程序替换函数。
本篇我们从替换原理讲起,拆解 6 个 exec 函数的命名规律、参数含义、使用场景,最后结合 fork 写出完整的程序替换实战代码,讲透所有坑点。
一、什么是进程程序替换?
1.1 本质:换代码,不换进程
很多人以为 exec 是创建新进程,完全不是。
程序替换的核心:进程 PID 不变,PCB 保留,只把用户态的代码段、数据段,全部替换成新程序(ELF 文件)的内容,重新初始化栈、堆,然后从新程序的入口开始执行。
一句话总结:还是原来那个进程,只是里面跑的代码换成别的程序了。
1.2 核心结构变化
- ✅ 不变:PID、PPID、PCB、打开的文件描述符、信号处理方式、进程优先级
- ❌ 全部替换:代码段、数据段、堆、栈、指令指针 PC
替换之后,进程从新程序的 _start 开始执行,原来的代码彻底消失,不会再继续往下走。
1.3 为什么 fork + exec 是黄金搭档?
fork:创建子进程,拥有独立地址空间exec:子进程里执行替换,只改子进程自己的代码数据,父进程完全不受影响
这就是 shell 的工作原理:
- 你输入命令,shell 调用 fork 创建子进程
- 子进程调用 exec,把自己替换成你输入的命令程序
- 父进程 shell 等待子进程退出,退出后继续等待下一条命令
💡 为什么不直接 exec 替换自己?
替换了 shell 自己,命令跑完 shell 就没了。所以必须 fork 出子进程来替换,父进程 shell 保留,继续接收命令。
二、exec 系列函数命名规律(核心记忆法)
exec 一共 6 个常用函数,不用死记硬背,名字里的每个字母都有明确含义,掌握规律一眼就能看懂。
表格
| 后缀字母 | 全称 | 含义 |
|---|---|---|
| l | list | 参数用列表形式传递,逐个写参数,最后以 NULL 结尾 |
| v | vector | 参数用数组形式传递,把所有参数放进数组,传数组首地址 |
| p | path | 自动在 PATH 环境变量里搜索可执行文件,不用写全路径 |
| e | environment | 传入自定义环境变量,不继承父进程的环境变量表 |
6 个函数就是这几个特征的组合:
l / v二选一:参数怎么传p可选:要不要自动搜 PATHe可选:要不要自定义环境变量
底层真相:
只有execve是真正的系统调用,剩下 5 个都是 C 标准库封装的库函数,最终都会调用execve。
三、逐个接口详解:原型 + 参数 + 场景
3.1 execl:列表传参,需写全路径
函数原型
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
参数说明
path:可执行文件的完整路径,必须写全,不会自动搜索arg:命令行参数列表,第一个参数约定是程序名本身,后面依次传参数- 最后必须以
NULL结尾,标记参数列表结束
适用场景
知道程序的完整路径,参数不多,逐个写比较清晰。
示例
// 执行 /usr/bin/ls -l -a
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
3.2 execlp:列表传参,自动搜索 PATH
函数原型
int execlp(const char *file, const char *arg, ...);
参数说明
file:可执行文件名,不需要写全路径,系统自动去PATH环境变量的目录里查找- 后面参数和 execl 一样,列表形式,NULL 结尾
适用场景
执行系统自带命令(ls、cat、gcc),不用找路径,直接写命令名。
示例
// 执行 ls -l -a,自动在 PATH 里找 ls
execlp("ls", "ls", "-l", "-a", NULL);
💡 注意:第二个参数
ls是传给新程序的 argv [0],和第一个参数文件名可以不一样,但约定写成一样的。
3.3 execle:列表传参,自定义环境变量
函数原型
int execle(const char *path, const char *arg, ..., char *const envp[]);
参数说明
path:可执行文件全路径arg...:参数列表,NULL 结尾envp[]:自定义环境变量数组,格式为KEY=VALUE字符串,最后 NULL 结尾。新程序只会拿到这个数组里的环境变量,不继承父进程的。
适用场景
需要给新程序单独设置环境变量,隔离父进程环境。
示例
char *my_env[] = {
"PATH=/usr/bin",
"USER=test",
NULL
};
// 执行 ls,只传入自定义环境变量
execle("/usr/bin/ls", "ls", "-l", NULL, my_env);
3.4 execv:数组传参,需写全路径
函数原型
int execv(const char *path, char *const argv[]);
参数说明
path:可执行文件全路径argv[]:参数数组,第一个是程序名,后面依次是参数,最后 NULL 结尾
适用场景
参数多、或者参数是动态生成的,放数组里更方便。
示例
char *const argv[] = {
"ls",
"-l",
"-a",
NULL
};
execv("/usr/bin/ls", argv);
3.5 execvp:数组传参,自动搜索 PATH
函数原型
int execvp(const char *file, char *const argv[]);
参数说明
file:可执行文件名,自动搜 PATHargv[]:参数数组
适用场景
动态拼接参数,又不想写全路径,是最常用的组合之一。
示例
char *const argv[] = {
"ls",
"-l",
"-a",
NULL
};
execvp("ls", argv);
3.6 execve:系统调用本尊
函数原型
int execve(const char *path, char *const argv[], char *const envp[]);
参数说明
path:可执行文件全路径argv[]:参数数组envp[]:自定义环境变量数组
这是最完整的一个,也是真正的系统调用。前面 5 个库函数,内部最终都是调用它。
适用场景
需要完全控制参数和环境变量,或者想直接调用系统调用。
四、完整实战:fork + exec 程序替换
4.1 完整代码 proc.c
#include <stdio.h>
#include <errno.h>
#include <string.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)
{
// ========== 子进程:执行程序替换 ==========
printf("[子进程] pid=%d,准备替换成 ls 程序\n", getpid());
sleep(1);
// 方式1:execl 列表传参 + 全路径
// execl("/usr/bin/ls", "ls", "-ln", "-a", NULL);
// 方式2:execv 数组传参 + 全路径(推荐写法)
char *const argv[] = {
"ls",
"-l",
"-a",
NULL
};
execv("/usr/bin/ls", argv);
// ===== 注意:下面的代码只有 exec 失败才会执行 =====
// 成功的话,代码已经被替换,永远走不到这里
perror("exec 失败");
exit(1);
}
else
{
// ========== 父进程:等待子进程 ==========
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
}
return 0;
}
4.2 编译运行
gcc proc.c -o proc
./proc
4.3 运行结果
我的程序要运行了!
[子进程] pid=xxxx,准备替换成 ls 程序
总用量 xx
drwxrwxr-x ...
...(ls 命令的输出)
我的程序运行完毕了
4.4 逐行理解
- 父进程 fork 出子进程
- 子进程调用
execv,把自己的代码替换成ls程序 - 替换成功后,子进程开始执行 ls 的代码,输出目录内容
- ls 执行完,子进程退出
- 父进程 wait 回收,打印结束提示
💡 关键观察:
exec 之后,子进程还是那个 PID,但跑的已经是 ls 的代码了。原来子进程里 exec 后面的代码,只要替换成功就永远不会执行。
五、核心特性与常见坑点
5.1 返回值:成功不返回,失败才返回
这是最反直觉的一点:
exec 系列函数,成功了就不会返回了,直接跑新程序去了。
只有出错的时候,才会返回-1,并且设置errno错误码。
所以写法一定是:
execxxx(...);
// 走到这里一定是失败了
perror("exec fail");
exit(1);
不要写 if(execxxx() > 0) 这种判断,成功根本不会回来。
5.2 参数列表必须以 NULL 结尾
不管是 l 系列的列表,还是 v 系列的数组,最后必须加 NULL,告诉系统参数传完了。
不加 NULL 会导致越界访问,结果不可预期。
5.3 替换后原代码全部失效
exec 成功后,原进程后面的所有代码都被覆盖了,不会执行。
所以错误处理、清理工作一定要写在 exec 之前,或者失败分支里。
5.4 环境变量继承规则
- 不带
e的函数(execl、execlp、execv、execvp):继承父进程的全部环境变量 - 带
e的函数(execle、execve):只用你传入的环境变量,完全不继承父进程
六、6 个函数对比总结表
表格
| 函数名 | 传参方式 | 自动搜 PATH | 环境变量 | 本质 | 推荐场景 |
|---|---|---|---|---|---|
execl | 列表 | ❌ 要写全路径 | 继承父进程 | 库函数 | 参数少、知道完整路径 |
execlp | 列表 | ✅ 自动搜索 | 继承父进程 | 库函数 | 执行系统命令、参数少 |
execle | 列表 | ❌ 要写全路径 | ✅ 自定义 | 库函数 | 需要自定义环境变量 |
execv | 数组 | ❌ 要写全路径 | 继承父进程 | 库函数 | 参数多、动态参数 |
execvp | 数组 | ✅ 自动搜索 | 继承父进程 | 库函数 | 最常用,动态参数 + 系统命令 |
execve | 数组 | ❌ 要写全路径 | ✅ 自定义 | 系统调用 | 底层完全控制 |
全文总结
- 程序替换本质:不创建新进程,PID 不变,替换代码段数据段,跑新程序。
- 命名规律:l 列表、v 数组、p 搜 PATH、e 自定义环境,组合出 6 个函数。
- 返回值特性:成功不返回,失败返回 - 1;exec 后面的代码只有失败才执行。
- 黄金搭档:fork 创建子进程,子进程 exec 替换,父进程 wait 回收,这就是 shell 的核心原理。
- 底层真相:只有 execve 是系统调用,其他都是库函数封装。

转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/mogreat/article/details/164496720




