【Linux我做主】进程程序替换和exec函数族
进程程序替换和exec函数族
- 进程程序替换和exec函数族
- github地址
- 0. 前言
- 1. 单进程的进程替换
- 2. 进程替换的原理
- 1. 进程的创建
- 2. 可执行程序的加载
- 3. 程序入口地址的确定
- 4. 总结
- 3. 多进程的程序替换
- 1. 子进程被替换会不会影响父进程?
- 2. 进程替换是否创建新进程?
- 3. `fork` 与 `exec` 的关系
- 4. `exec` 调用后的代码执行情况
- 5. `exec` 系列函数的返回值
- 总结
- 4. 验证各种程序替换接口exec
- 1. exec 系列函数概述
- 2. exec 系列函数族
- 3. 函数参数说明和使用
- (1) `path` 和 `file`
- (2) `argv` / `arg`
- (3) `envp`
- 4. Makefile 一次编译多个可执行程序
- 跨语言调用
- 5. 一个程序调另一个程序验证命令行参数的传递
- 6. 一个程序调另一个程序验证环境变量的传递
- 7. 给子进程传递新的环境变量
- `putenv`添加新的环境变量
- 完全替换掉从父进程继承下来的环境变量
- 5. 环境变量与进程的关系
- 1. 环境变量的本质
- 2. 环境变量的继承
- 3. 环境变量的访问方式
- 5. 结语
进程程序替换和exec函数族
github地址
有梦想的电信狗
0. 前言
在 Linux 中,进程除了能通过 fork
创建子进程外,还可以通过 exec 系列函数进行进程替换。所谓替换,就是让一个正在运行的进程丢掉原来的程序映像,转而执行另一个可执行文件。
这正是命令行运行程序、Shell 调用脚本的底层机制。本文将通过实例,从单进程替换到 fork+exec
的组合,再到不同的 exec
接口,逐步剖析这一机制的原理与用法。
1. 单进程的进程替换
-
Linux为我们提供了一些列系统调用,用于进行进程替换!
-
先简单看一下单进程进程替换的现象,代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main() {printf("before: I am a process pid: %d, ppid: %d\n", getpid(), getppid());execl("/usr/bin/ls", "ls", "-a", "-l", NULL);printf("after: I am a process pid: %d, ppid: %d\n", getpid(), getppid());return 0;
}
注意:这里 execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
是exec
系列函数的标准写法,方便记忆
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
: 第一个参数是可执行文件的路径,中间是可变参数列表,用于指明程序执行的参数,最后一个参数必须是NULL
- 执行现象如下:
总结现象:
execl
参数中的命令和选项被执行了- 只执行了
execl
调用前的printf("before ...")
函数,调用execl
之后的printf("after ...")
没有执行 - 我们自己的进程,可以在运行时,运行其他路径的程序,这种现象就叫进程替换
2. 进程替换的原理
在 Linux 中,当我们在命令行运行一个程序时,本质上经历了以下几个步骤:
1. 进程的创建
- 用户在 bash 中输入一条命令。
- bash 进程调用
fork()
创建一个子进程,这个子进程就是将要执行用户命令的进程。 - 操作系统为该子进程分配必要的资源,包括:
- PCB(进程控制块):保存进程的标识、状态、寄存器等信息。
- 进程地址空间:包含代码段、数据段、堆、栈等区域。
- 页表:建立虚拟地址与物理地址的映射关系。
2. 可执行程序的加载
当子进程被创建后,bash 会调用 exec
系列函数,加载用户指定的可执行程序。
在这一过程中:
- 原进程在物理内存中的 代码段和数据段会被新程序替换,发生了写时拷贝。
- 操作系统会 重新建立页表,将新程序的虚拟地址映射到新的物理内存页。
- 虚拟地址空间本身的结构保持不变(依然分为代码段、数据段、堆、栈),但内容会根据新程序进行调整。
- PCB 不会被替换,它仍然是原来的进程控制块,只是内部记录的信息被更新。
因此,调用 exec
系列函数不会创建新进程,而是让当前进程“脱胎换骨”,从此开始运行新的程序。
3. 程序入口地址的确定
一个关键问题是:CPU 如何知道新程序应该从哪里开始执行?
答案在于 ELF(Executable and Linkable Format,可执行与可链接格式):
Linux
中形成的可执行程序,是有特定格式的,Linux中的可执行程序的格式为ELF
- 在程序源代码编译生成 ELF 文件时,编译器会将程序的 入口地址(entry point) 写入到 ELF 文件的头部(表头),可执行程序的入口地址存在表头中。
- 当
exec
将 ELF 加载到内存后,操作系统会读取 ELF 头部信息,从而得到程序的入口地址。 - CPU 在调度该进程运行时,会将指令寄存器(IP/EIP/RIP)设置为入口地址,从这个位置开始执行程序代码。
4. 总结
fork()
用于创建子进程。exec()
用于替换子进程的内存映像,加载并运行新程序。exec
系列函数的做法十分简单粗暴- 调用
exec
系列函数时,直接用新程序的代码替换原来进程的代码,用新程序的数据,替换原来进程的数据,并让CPU执行新程序的代码开始 - 该过程中没有创建新进程,原进程的PCB、进程地址空间都保持不变,页表中的相应字段被更新
- 调用
- 进程替换后:
- PCB 保留(进程还是原来的进程)。
- 虚拟地址空间结构不变,但内容被新程序覆盖。
- 页表更新,建立新的虚拟地址到物理地址的映射。
- 入口地址由 ELF 文件头部提供,CPU 从此位置开始执行新程序。
最终,用户在命令行中输入的程序就能在 CPU 上开始运行。
3. 多进程的程序替换
- 代码演示
// 多进程 程序替换
int main() {pid_t id = fork();if (id == 0) {// childprintf("before: I am a process pid: %d, ppid: %d\n", getpid(), getppid());sleep(5);execl("/usr/bin/ls", "ls", "-a", "-l", NULL);printf("after: I am a process pid: %d, ppid: %d\n", getpid(), getppid());exit(0);}// fatherpid_t ret = waitpid(id, NULL, 0); // 等待子进程,暂不关心进程退出状态,阻塞等待if (ret > 0) {printf("wait success, father: %d, ret %d\n", getpid(), ret);sleep(5);}return 0;
}
问题与总结:
1. 子进程被替换会不会影响父进程?
不会。
- 父子进程是独立的两个进程,它们各自拥有独立的虚拟地址空间。
- 当子进程调用
exec
系列函数时,替换的只是该子进程的用户空间代码和数据,不会对父进程的执行造成影响。 - 父进程依然可以通过
wait/waitpid
等方式监控子进程的退出状态。
2. 进程替换是否创建新进程?
进程替换不创建新进程,只进行进程代码和数据的替换
exec
并不会创建新进程,它只会在当前进程的上下文中加载并运行一个新的程序。- 因此:
- 进程 ID(PID)保持不变。
- PCB 仍然是原来的,只是其中的代码段、数据段、堆、栈等信息被新程序替换。
- 所以,
exec
本质上是“用另一个程序替换自己”,而不是启动一个新的进程。
3. fork
与 exec
的关系
-
调用
fork()
后,子进程和父进程最初运行的是相同的程序(代码空间一致,但地址空间独立)。 -
在很多场景下,子进程会立刻调用
exec
,以执行一个全新的程序。 -
这样形成了经典的模式:
父进程: 继续原有逻辑
子进程:
fork → exec
,替换为用户指定的程序
4. exec
调用后的代码执行情况
- 替换成功时:
- 进程的代码和数据完全被新程序替换。
- CPU 的指令寄存器被设置为新程序的入口地址。
exec
调用点之后的代码不会被执行。
- 替换失败时:
exec
会返回-1
,并设置errno
表示错误原因。- 此时,调用
exec
之后的代码才有机会被执行。
5. exec
系列函数的返回值
exec
没有成功返回值。- 如果成功执行了替换,原来的程序逻辑已经不存在,因此不可能返回到原调用点。
- 只有在加载失败时,
exec
才会返回-1
。
总结
- 子进程的替换不会影响父进程,它们的执行空间相互独立。
- 进程替换不创建新进程,只是在原有进程中加载新程序。
fork
与exec
常常配合使用:fork
创建子进程,exec
让子进程运行新程序。exec
成功执行后,调用点之后的代码不会被执行;只有失败时才会返回-1
并继续向下执行,exec
系列函数无成功时的返回值。
4. 验证各种程序替换接口exec
1. exec 系列函数概述
在 Linux 中,exec
系列函数用于 用一个新的程序替换当前进程的映像。
- 调用成功后,当前进程的代码段、数据段、堆、栈都会被新程序替换,不会创建新进程(这点和
fork
不同)。 - 调用成功时 不会返回;若调用失败,才会返回 -1。
它们主要定义在头文件:
#include <unistd.h>
2. exec 系列函数族
常见的 exec 系列库函数有:
// 以下为库函数
int execl(const char *path, const char *arg, ... /* (char *) NULL */);
int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
int execle(const char *path, const char *arg, ... /* (char *) NULL, 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[]);// 以下为系统调用
int execvpe(const char *file, char *const argv[], char *const envp[]);
-
以上函数,如果调用成功则加载新的程序从启动代码开始执行,不再返回。
-
如果调用出错则返回-1
-
所以
exec
函数只有出错的返回值,没有成功的返回值
命名规律总结:
exec 系列函数的后缀有规律:
后缀 | 含义 |
---|---|
l | 参数以列表(list)的形式传递(execl , execlp , execle ),传参时直接写 "arg1", "arg2", ..., NULL ,必须以NULL结尾。 |
v | 参数以向量(vector,数组)的形式传递(execv , execvp , execvpe ),传入 char *argv[] 。 |
p | 函数名中的p :即PATH 。代表默认在 PATH 环境变量搜索可执行文件(execlp , execvp , execlvpe ),传参时无需传入路径,只需传入要执行的程序名 |
e | 允许指定新的环境变量 envp[] (execle , execvpe )。 |
记忆口诀:
l
= listv
= vectorp
= pathe
= environment
3. 函数参数说明和使用
- exec系列所有函数:
- 第一个参数:帮助函数找到该程序,因此传入程序的绝对路径或相对路径或程序名
- 第二个参数:告诉程序如何执行,因此传入程序执行所需的参数
(1) path
和 file
path
:需要给出 可执行文件的绝对路径或相对路径,例如/usr/bin/ls
或。file
:只需要给出文件名,例如ls
,函数会在PATH
环境变量 指定的路径中搜索程序。
例如:
execl("/usr/bin/ls", "ls", "-a", "-l", NULL); // 直接指定路径
execlp("ls", "ls", "-a", "-l", NULL); // 默认在环境变量 PATH 搜索,只需传入程序名
(2) argv
/ arg
argv
和arg
都表示 传递给新程序的参数列表。- 以 NULL 结尾 表示参数结束。
示例:
char* const myargv[] = {"ls", "-a", "-l", NULL};
execv("/usr/bin/ls", myargv);
(3) envp
-
envp
表示 环境变量列表,即一个以NULL
结尾的字符串数组:// 自定义的环境变量 char *envp[] = {"PATH=/usr/bin", "USER=guest", NULL}; execle("/usr/bin/ls", "ls", "-a", "-l", NULL, envp);char* myagrv[] = {"ls", "-a", "-l", NULL}; execvpe("ls", myargv, envp)
-
execle
和execvpe
可以显式指定环境变量; -
其他函数(如
execl
,execv
,execlp
,execvp
)会默认继承调用进程的环境变量。
基本用法:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>int main() {printf("Before exec...\n");// ... 进程替换的接口perror("exec failed");return 1;
}
execl:
// 方法1: execl (指定路径 + 列表传参)
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
execlp:
// 方法2: execlp (用 PATH 搜索)
execlp("ls", "ls", "-a", "-l", NULL);
execv:
// 方法3: execv (指定路径 + 向量传参)
char *args[] = {"ls", "-a", "-l", NULL};
execv("/usr/bin/ls", args);
execle:
// 方法4: execle (指定路径 + 参数 + 环境变量)
char *envp[] = {"PATH=/bin", NULL};
execle("/usr/bin/ls", "ls", "-a", "-l", NULL, envp);
总结:
- 第一个参数pathname/file:传入程序的绝对路径或相对路径或程序名
- 后续如果是可变参数列表:命令行中怎么写,参数列表就怎么写,只不过空格分隔变成了逗号分隔,选项放在了双引号中
- 根据需要可传入指定的环境变量
- ls以及其他程序是
C/C++
写的程序,也有命令行参数,exec
系列函数调用时,会把后面参数列表或agrv[]中的选项和envp[]
,形成命令行参数表和环境变量表,传递给ls
或其他程序的main
函数
在 Linux 中,所有进程都是由已有进程
fork
出来的子进程。命令行运行程序时,Bash 先fork
出子进程,再用exec
系列函数把目标程序加载进内存运行。
exec
的作用是清空当前进程占用的物理内存空间,把磁盘上的可执行文件读入内存,并让 CPU 从新程序的入口开始执行,相当于内核提供的“程序加载器”。
4. Makefile 一次编译多个可执行程序
.PHONY:all
all:otherExe mycommandmycommand:mycommand.cgcc -o $@ $^ -std=c99
otherExe:otherExe.cppg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f mycommand otherExe
-
.PHONY: all
- 声明 伪目标
all
,表示all
不是一个文件名,而是一个逻辑目标。 - 如果没有声明
.PHONY
,当目录下存在一个叫all
的文件时,make all
会误认为已经生成了目标而不执行命令。
- 声明 伪目标
-
all: otherExe mycommand
-
all
目标依赖于otherExe
和mycommand
。 -
执行
make all
时,会先尝试生成otherExe
,再生成mycommand
(顺序由 make 自行决定,但通常按依赖书写顺序来执行)。
-
- 总结依赖关系图
all
├── otherExe (依赖于 otherExe.cpp)
└── mycommand (依赖于 mycommand.c)
make
或make all
:同时编译mycommand
和otherExe
。make mycommand
:只编译mycommand
。make otherExe
:只编译otherExe
。make clean
:清理编译产物。
跨语言调用
exec
接口调用我们自己的写的可执行程序以及调用其他语言形成的可执行程序:
// 执行我们自己写的程序 用C语言程序 调用C++程序
execl("./otherExe", "otherExe", NULL);// C语言程序调用 shell 脚本
execl("/usr/bin/bash", "bash", "test.sh", NULL);// C语言程序调用 python 脚本
execl("/usr/bin/python3", "python3", "test.py", NULL);
“所有语言编写的程序,运行起来本质都是进程”。
为什么可执行程序或脚本能跨语言调用?
- 程序运行后在操作系统看来都是 进程,无论是 C、Go 编译的二进制,还是 Python、Shell 脚本。
- 跨语言调用的本质就是 一个进程调用或替换成另一个进程。
- 操作系统不关心语言,只负责加载和运行进程。
👉 所以所有语言写的程序都能互相调用。
5. 一个程序调另一个程序验证命令行参数的传递
mycommand
程序向otherExe
传递命令行参数
// mycommand.c
int main() {pid_t id = fork();if (id == 0) {// childprintf("before: I am a process pid: %d, ppid: %d\n", getpid(), getppid());sleep(3);char* const myargv[] = {"otherExe", "-a", "-b", NULL};execv("./otherExe", myargv);printf("after: I am a process pid: %d, ppid: %d\n", getpid(), getppid());exit(0);}// fatherpid_t ret = waitpid(id, NULL, 0); // 等待子进程,暂不关心进程退出状态,阻塞等待if (ret > 0) {printf("wait success, father: %d, ret %d\n", getpid(), ret);sleep(3);}return 0;
}
// otherExe.cpp
#include <iostream>
using namespace std;int main(int argc, char* argv[]) {cout << argv[0]<<" begin running" << endl;for (int i = 0; argv[i]; ++i) {cout << i << " : " << argv[i] << endl;}cout << argv[0] << "otherExe stop running" << endl;return 0;
}
6. 一个程序调另一个程序验证环境变量的传递
mycommand
程序向otherExe
传递环境变量
extern char** environ;
execle("./otherExe", "otherExe", "-a", "-b", NULL, environ); // 传递系统的环境变量
#include <iostream>
using namespace std;int main(int argc, char* argv[], char* env[]) {cout << "这是命令行参数" << endl;cout << argv[0] << " begin running" << endl;for (int i = 0; argv[i]; ++i) {cout << i << " : " << argv[i] << endl;}cout << "这是环境变量" << endl;for (int i = 0; env[i]; ++i) {cout << i << " : " << env[i] << endl;}cout << argv[0] << "otherExe stop running" << endl;return 0;
}
7. 给子进程传递新的环境变量
传递新的环境变量有两种方式
- 添加新的环境变量
- 完全替换原来的环境变量
putenv
添加新的环境变量
putenv("MYPRIVATE_ENV=123456");
putenv
是为当前进程添加环境变量,不影响父进程中的环境变量
int main() {pid_t id = fork();putenv("MYPRIVATE_ENV=123456");if (id == 0) {// childprintf("before: I am a process pid: %d, ppid: %d\n", getpid(), getppid());sleep(3);char* const myargv[] = {"otherExe", "-a", "-b", NULL};execv("./otherExe", myargv);printf("after: I am a process pid: %d, ppid: %d\n", getpid(), getppid());exit(0);}// fatherpid_t ret = waitpid(id, NULL, 0); // 等待子进程,暂不关心进程退出状态,阻塞等待if (ret > 0) {printf("wait success, father: %d, ret %d\n", getpid(), ret);sleep(3);}return 0;
}
完全替换掉从父进程继承下来的环境变量
int main() {pid_t id = fork();extern char** environ;if (id == 0) {// childprintf("before: I am a process pid: %d, ppid: %d\n", getpid(), getppid());sleep(3);// execle("./otherExe", "otherExe", "-a", "-b", NULL, environ); // 传系统的环境变量// 传自定义的环境变量 会完全覆盖从系统继承下来的环境变量char* const myenv[] = {"MYVAL=123456", "MYPATH=/usr/bin/xxx", NULL};execle("./otherExe", "otherExe", "-a", "-b", NULL, myenv);printf("after: I am a process pid: %d, ppid: %d\n", getpid(), getppid());exit(0);}// fatherpid_t ret = waitpid(id, NULL, 0); // 等待子进程,暂不关心进程退出状态,阻塞等待if (ret > 0) {printf("wait success, father: %d, ret %d\n", getpid(), ret);sleep(3);}return 0;
}
-
使用
exec
系列函数中带e
的接口时(execle,execvpe
),手动传入新的环境变量,会覆盖掉从父进程继承下来的环境变量 -
exec
系列函数所有接口的调用关系execve
是系统调用,头文件为<unistd.h>
- 其他
exec
函数是库函数,头文件为<stdlib.h>
,底层调用execve
5. 环境变量与进程的关系
1. 环境变量的本质
- 环境变量也是数据,存放在进程的用户空间中。
- 每个进程在运行时,都有一份属于自己的环境变量表。
2. 环境变量的继承
- 当父进程通过
fork
创建子进程时,环境变量表会被一同复制到子进程的地址空间。 - 因此,子进程天生就继承了父进程的环境变量。
- 这意味着:环境变量是在 子进程创建阶段 就已经传递下去的,而不是运行后再赋值的。
3. 环境变量的访问方式
- 在
main
函数中,可以通过第三个参数char* envp[]
访问环境变量; - 即使不依赖
main
的参数,也可以通过 全局变量:
extern char **environ;
直接获取和操作当前进程的环境变量表。
✅ 总结一句话:
环境变量是进程运行时的一部分数据,创建子进程时会自动继承父进程的环境变量表;无论通过 main
参数还是 environ
变量,都可以访问和修改它。
5. 结语
进程替换并不会创建新进程,而是在原进程中装载新程序。配合 fork
使用,就形成了“父进程继续执行,子进程运行新程序”的经典模式。
理解 exec
系列函数,有助于把握 Linux 程序执行的本质:所有程序运行到最后都是进程,而进程既可以继承,也可以替换。这正是 Linux 灵活而高效的关键所在。
以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步
分享到此结束啦
一键三连,好运连连!
你的每一次互动,都是对作者最大的鼓励!
征程尚未结束,让我们在广阔的世界里继续前行!
🚀