1 进程替换
进程替换是为了让程序能在不创建新进程的情况下,让父进程和子进程执行不同的代码,以实现控制清晰、执行高效的程序调度机制。
1.1 先看效果
#include <stdio.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);// 想要执行程序的路径 怎么执行这个命令 最后必须NULL结尾printf("after:I am a process, pid:%d,ppid:%d\n",getpid(),getppid());return 0;
}
我们会发现这里并没有if else但是子进程在execl后却没有执行父进程的代码,这说明子进程所执行的代码被替换了!! 这就是发生了进程替换!
1.2 进程替换的原理
在使用 fork() 创建进程时,我们经常看到父进程和子进程执行的是不同的逻辑,但却没有使用 if/else 来区分函数入口。这是如何做到的?要理解这一点,就得先回答下面五个问题。
问题1:子进程执行了 ls 这个程序,是不是创建了一个新的子进程?
- 答:不是的,子进程在执行 ls 的过程中,并不会再创建一个新的进程。相反,它会调用 exec 系列函数(如 execl、execvp 等),这类函数的作用是:将当前进程的代码和数据空间完全替换为另一个可执行程序的内容(比如 ls 的代码和数据)。这一过程由操作系统完成,原来的用户态执行内容被新程序接管。虽然进程内容变了,但进程的 PID 不变,内核中的 PCB(进程控制块)结构仍然保留,只是部分字段(如指令入口地址、页表等)发生了更新。(就是写时拷贝)
问题 2:既然子进程的内容被替换了,为什么父进程没有受到影响?
- 答:这是因为 Linux 在 fork() 创建进程时采用了写时拷贝技术。简单来说:起初父子进程共享相同的内存页面(包括代码段和数据段),但在其中一个进程尝试修改内存时,操作系统才会为它单独分配新页面,实现真正的物理内存拷贝。当子进程执行 exec 系列函数时,它会重新加载目标程序的代码和数据,触发写时拷贝,从而不影响父进程的内存空间。这样,父进程可以继续执行自己原来的代码,而子进程则运行被替换的新程序。(就有点像你的第二人格出现,但是你已经不记得自己的第一人格做过什么或者说过什么)
问题 3:我们常说写时拷贝作用于数据段,那代码段也可以写时拷贝吗?
- 答:可以,代码段同样可以触发写时拷贝,尽管它通常是只读的。这是因为:操作系统不相信任何人,通常情况下,用户程序无法修改代码段,但 exec 是一种特殊情况,它是由操作系统内核完成的程序替换操作;因此操作系统有权限回收原有代码段并重新加载新的代码段(如 ls),实现了用一份全新的代码段替换旧的的效果。这并不意味着代码段可以随意修改,而是说在 exec 的语义中,代码段可以被替换。
问题 4:如果进程替换失败了会怎样?
- 答:如果替换失败了,就只能执行自己原先的代码了!!所以exec系列的函数只有失败的返回值而没有成功的返回值,因为一但成功后跑的就是新的代码和数据了,返回就没有意义了。
问题 5:我们说 main 是程序入口,但它不一定写在文件开头,Linux 是如何找到它的?
- 答: Linux中的可执行程序,是有自己的组织形式的,也就是有自己的格式的(有一张表),我们把这个格式叫做ELF ,ELF 文件中包含一个头部结构体,里面记录了程序的各个段的起始地址,其中就有一个字段叫做 e_entry,它指向程序的真实入口地址,操作系统加载可执行文件时,根据 e_entry 所指的地址启动执行,从而精确找到程序入口,无需扫描整个文件内容。
1.3 探究各个程序替换的接口
函数名 | 参数类型 | 是否搜索 PATH | 可否传 envp | 使用方式 |
---|---|---|---|---|
execl | path + 可变参数 | ❌ 否 | ❌ 否 | 写死路径 + 手动写参数 |
execlp | file + 可变参数 | ✅ 是 | ❌ 否 | 像终端命令一样写 |
execle | path + 可变参数 | ❌ 否 | ✅ 是 | 自定义环境变量 |
execv | path + argv[] | ❌ 否 | ❌ 否 | 参数数组(变量较多时) |
execvp | file + argv[] | ✅ 是 | ❌ 否 | 动态命令调用 |
execve | path + argv[] + envp[] | ❌ 否 | ✅ 是 | 最底层、最灵活的调用 |
1.3.1 execl:路径+变长参数(手动列出参数)
#include <unistd.h>
#include <stdio.h>// int execl(const char *path, const char *arg, ...);int main()
{// 什么路径下,执行什么程序// 使用完整路径,执行 ls -l -a,最后一个一定是NULL结尾execl("/bin/ls", "ls", "-l", "-a", NULL);perror("execl failed");return 1;
}
1.3.2 execlp:文件名+变长参数(自动按 PATH 搜索)
#include <unistd.h>
#include <stdio.h>// int execlp(const char *file, const char *arg, ...);int main()
{// 自动在 PATH 中找 ls,等同于直接在终端输入 ls -l -aexeclp("ls", "ls", "-l", "-a", NULL);perror("execlp failed");return 1;
}
1.3.3 execv:路径+参数数组
#include <unistd.h>
#include <stdio.h>// int execv(const char *path, char *const argv[]);int main()
{char *args[] = {"ls", "-l", "-a", NULL};execv("/bin/ls", args);perror("execv failed");return 1;
}
1.3.4 execvp:文件名+参数数组(PATH 搜索)
#include <unistd.h>
#include <stdio.h>// int execvp(const char *file, char *const argv[]);int main()
{char *args[] = {"ls", "-l", "-a", NULL};execvp("ls", args);perror("execvp failed");return 1;
}
execle/execvpe:多个一个envp[ ] 意思就是我们可以自己用一套自己的环境变量,而不是用从父进程继承下来的。 (后面补充!!!)
1.4 接口总结和加载器理解
在 Linux 中,exec 系列函数虽然有多个变种,但它们的本质区别只在于参数的形式和功能的侧重点不同。这些不同的接口设计,实际上是围绕以下几个核心问题展开的:
(1)程序在哪里?——执行目标的位置问题
- 进程替换的第一步是定位目标程序的位置。为此,exec 系列提供了两种方式:
- 明确路径:如 execl、execv 等函数要求你传入程序的完整路径(如 /bin/ls)。
- 自动搜索:如 execlp、execvp 则只需传入程序名,系统会自动从 PATH 环境变量中查找对应的可执行文件。
(2)参数如何传?——执行参数的组织方式问题
- 找到程序之后,下一个问题是:我们需要为它传递哪些参数?如何传递?为此,exec 系列函数支持两种参数传递形式:
- 可变参数列表(如 execl, execlp, execle):适合参数数量固定、较少的情况,手动列出每个参数,最后以 NULL 结尾。
- 参数数组形式(如 execv, execvp, execve):适合动态构造参数列表,将参数统一组织在 char *argv[] 中传入。
(3)是否使用自定义环境变量?——环境隔离问题
- 最后一个核心问题是:新执行的程序是否必须继承当前进程的环境变量?
- 默认继承:大部分 exec 函数(如 execl, execvp, execv 等)会沿用当前进程的环境变量。
- 自定义传入:而以 e 结尾的函数(如 execle, execve)允许你显式传入一组新的环境变量数组 envp[],实现更高的控制力和隔离性
总结:exec 系列函数的多样设计,其实是从“程序在哪、参数怎么传、环境用谁的”这三个角度出发,对进程替换这一行为进行了细粒度的接口划分,满足了不同场景下的执行需求。
1.5 makefile一次生成两个可执行文件
补充知识:.cc .cpp .cxx 都是C++中的文件后缀
test1.c文件
#include <stdio.h>int main()
{printf("hello HYQ\n");printf("hello HYQ\n");printf("hello HYQ\n");printf("hello HYQ\n");return 0;
}
test2.cpp文件
#include <iostream>using namespace std;int main()
{printf("Hello C++ Linux\n");printf("Hello C++ Linux\n");printf("Hello C++ Linux\n");printf("Hello C++ Linux\n");return 0;
}
makefile文件
test1:test1.cgcc -o $@ $^
test2:test2.cppg++ -o $@ $^
.PHONY:clean
clean:rm -f test1 test2
上面的makefile文件只能生成一个可执行文件,因为它一旦扫描到一个推导链,就会立马执行,执行一个推导链就结束了,不会去执行第二个。
因此,如果我想生成两个可执行文件,就必须让这两个程序有一个关系,才可能一次执行两个文件。
唯一的解决方法就是,谁都不要放前面,而是提前建立一个伪目标all放在前面,多一层推导关系,这样两个文件就会根据推导链的执行而被编译了。
.PHONY:all
all:test1 test2 // 不能有缩进test1:test1.cgcc -o $@ $^ // 一定是tab缩进,空格会出问题
test2:test2.cppg++ -o $@ $^
.PHONY:clean
clean:rm -f test1 test2
一个可执行程序能不能调另一个可执行程序了???答案是:可以的!!!
int main()
{printf("hello HYQ\n");printf("hello HYQ\n");printf("hello HYQ\n");printf("hello HYQ\n");// 第一次参数:什么路径执行什么东西// 第二个参数:怎么执行// 第三个参数:一定是NULL结尾execl("./test2","test2",NULL);printf("HYQ\n");return 0;
}
所以语言和语言之间是可以相互调用的!!任何语言都有像exec这类的接口,语言可以互相调用的原因是无论什么语言写的程序在操作系统看来都是进程。
补充关于环境变量:环境变量是在子进程创建的时候就默认继承了,即使没有传环境变变量参数,也可以在地址空间找到。所以进程替换中,环境变量信息不会被替换!
1.6 总结进程替换系列的函数
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
- 如果调用出错则返回-1
- 所以exec函数只有出错的返回值而没有成功的返回值
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有p自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量