跳到主要内容Linux 进程替换详解:从 fork 到 exec 的完整链路 | 极客日志C++
Linux 进程替换详解:从 fork 到 exec 的完整链路
本文详细讲解了 Linux 进程替换的概念与原理。进程替换指运行中的进程被新程序完全替换,PID 不变。通过 fork 创建子进程后调用 exec 系列函数可实现。exec 不会触发写时拷贝,会丢弃原内存空间。CPU 通过 ELF 头入口地址执行。exec 系列函数包括 execl, execlp, execv, execvp, execle, execvpe,均封装自 execve 系统调用。父进程可通过 fork 继承环境变量,或通过 execle/execvpe 显式传递自定义环境变量。
苹果系统1 浏览 进程替换是什么?
进程替换是指一个正在运行的进程,用一个全新的可执行程序,完全替换掉自己当前的代码、数据和堆栈,但进程的 PID 保持不变。
简单来说,就像你正在用一个记事本,然后直接把它变成了一个浏览器 —— 窗口(PID)没变,但里面的程序已经完全换了。
我们先来看一个简单的演示,直观感受一下进程替换的效果。
#include <iostream>
#include <unistd.h>
using namespace std;
int main() {
cout << "before: I am process, Mypid: " << getpid() << " Myppid: " << getppid() << endl;
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
cout << "after: I am process, Mypid: " << getpid() << " Myppid: " << getppid() << endl;
return 0;
}
从结果能看到,execl 之后程序就变成 ls 了,原进程后续的代码不再执行,这就是进程替换。
进程替换原理
用 fork 创建子进程后,子进程执行的是和父进程相同的程序(但可能执行不同的代码分支)。为了让子进程执行另一个程序,我们通常会在子进程中调用一种 exec 函数。
当进程调用 exec 函数时,该进程的用户空间代码和数据会被新程序完全替换,并从新程序的启动例程开始执行。注意,调用 exec 并不会创建新进程,所以调用 exec 前后,进程的 PID 并未改变。
1. 进程替换会发生写实拷贝吗?
- COW 的触发前提:只有
fork() 创建子进程后,父子进程共享内存页,且某一方对共享页执行写入操作时,才会触发 COW。
exec 的本质:它会直接丢弃当前进程的代码段、数据段等所有用户空间内容,重新加载新程序,相当于销毁了原有的地址空间映射,不再与任何进程共享内存页。
- 子进程场景:
fork() 后虽与父进程共享内存,但 exec() 会直接丢弃这些共享资源,无需写入,因此不触发 COW。
- 父进程场景:直接替换自身时,不存在任何共享内存的前提,自然也不会触发 COW。
2. 普通只读 vs COW 只读
父进程依赖「物理内存页引用计数」触发 COW,无需页表带 COW 标志;子进程依赖「页表中的 COW 标志 + 临时只读属性」触发 COW,因此只有子进程的页表会存在 COW 标志,而父子进程修改共享页,最终都会触发 COW 实现数据隔离。
3. exec 函数执行后,后续代码还会执行吗?
- 执行成功:程序会被完全替换为新的可执行文件,
exec 后面的代码永远不会执行。因为 exec 成功时没有返回值,不会回到原程序的执行流。
- 执行失败:
exec 会返回 -1,程序会继续执行 exec 之后的代码(通常这里会写错误处理逻辑,比如 perror 然后退出)。
4. CPU 如何知道程序的入口地址?
Linux 下的可执行文件是 ELF 格式,文件头部的 e_entry 字段会存储程序的入口地址。当 exec 加载新程序时,内核会读取 ELF 头中的这个地址,并把 CPU 的指令指针(PC)设置到该地址,让程序从这里开始执行。
5. 子进程进行程序替换后,会影响父进程的代码和数据吗?
不会。子进程替换时会丢弃原有资源并构建全新地址空间,与父进程完全独立,因此不会影响父进程的代码和数据。
exec 系列接口
execve() 是内核直接提供的系统调用,是'真身'。其他 exec 函数都是 C 库提供的封装,是'马甲',最终都会调用 execve() 来完成实际的进程替换。
1. execl
int execl(const char *path, const char *arg, ...);
path:要执行的程序的完整路径(如 /bin/ls),不能只写文件名,因为它不会去 PATH 里查找。
arg:传递给新程序的命令行参数,第一个参数通常是程序名本身,后续是其他参数。
...:可变参数列表,最后必须用 NULL 来标志参数结束。
- 调用成功时,函数不会返回(因为当前进程已被新程序替换)。
- 调用失败时,返回
-1,并设置 errno 来指示错误原因。
#include <iostream>
#include <unistd.h>
using namespace std;
int main() {
cout << "before: I am process, Mypid: " << getpid() << " Myppid: " << getppid() << endl;
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
cout << "after: I am process, Mypid: " << getpid() << " Myppid: " << getppid() << endl;
return 0;
}
2. execlp
int execlp(const char *file, const char *arg, ...);
file:要执行的程序文件名(如 "ls"),它会自动在系统 PATH 环境变量中查找该程序,无需写完整路径。
arg:传递给新程序的命令行参数,第一个参数通常是程序名本身,后续是其他参数。
...:可变参数列表,最后必须用 NULL 来标志参数结束。
- 调用成功时,函数不会返回(当前进程已被新程序替换)。
- 调用失败时,返回
-1,并设置 errno 来指示错误原因。
#include <iostream>
#include <unistd.h>
#include <cstdio>
using namespace std;
int main() {
cout << "Before execlp, PID: " << getpid() << endl;
int ret = execlp("ls", "ls", "-l", "-a", NULL);
if (ret == -1) {
perror("execlp failed");
}
cout << "After execlp (won't print if success)" << endl;
return 0;
}
3. execv
int execv(const char *path, char *const argv[]);
path:要执行的程序的完整路径(例如 /bin/ls),不会自动在 PATH 中查找。
argv:传递给新程序的命令行参数数组,格式为 {"程序名", "参数 1", "参数 2", ..., NULL},必须以 NULL 结尾。
- 调用成功时,函数不会返回(进程已被新程序替换)。
- 调用失败时,返回
-1,并设置 errno 指示错误原因。
#include <iostream>
#include <unistd.h>
using namespace std;
int main() {
cout << "Before execv, PID: " << getpid() << endl;
char *const argv[] = {"ls", "-l", "-a", NULL};
int ret = execv("/bin/ls", argv);
if (ret == -1) {
perror("execv failed");
}
cout << "After execv (won't print if success)" << endl;
return 0;
}
4. execvp
int execvp(const char *file, char *const argv[]);
file:要执行的程序文件名(如 "ls"),会自动在系统 PATH 环境变量中查找该程序,无需写完整路径。
argv:传递给新程序的命令行参数数组,格式为 {"程序名", "参数 1", "参数 2", ..., NULL},必须以 NULL 结尾。
- 调用成功时,函数不会返回(进程已被新程序替换)。
- 调用失败时,返回
-1,并设置 errno 指示错误原因。
#include <iostream>
#include <unistd.h>
using namespace std;
int main() {
cout << "Before execvp, PID: " << getpid() << endl;
char *const argv[] = {"ls", "-l", "-a", NULL};
int ret = execvp("ls", argv);
if (ret == -1) {
perror("execvp failed");
}
cout << "After execvp (won't print if success)" << endl;
return 0;
}
5. execle
int execl(const char *path, const char *arg, ..., char *const envp[]);
path:要执行的程序的完整路径(比如 "/bin/ls")。
arg:传递给新程序的命令行参数,第一个参数通常是程序名,后续是选项或参数,最后必须以 NULL 结尾。
envp:你自定义的环境变量数组,格式为 KEY=VALUE,同样以 NULL 结尾,用来完全替换子进程的环境变量表。
- 调用成功时,函数不会返回(进程已被新程序替换)。
- 调用失败时,返回
-1,并设置 errno 指示错误原因。
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
extern char** environ;
int main() {
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
} else if (pid == 0) {
int ret = execle("./testc", "./testc", "-a", "-b", "-c", NULL, environ);
if (ret == -1) {
perror("execle failed");
return 1;
}
} else {
wait(NULL);
}
return 0;
}
目前,我们在调用 execle 时,第三个参数传递的是当前进程的 environ 变量,也就是把父进程的完整环境变量表传给子进程(如何传自定义环境变量表在文章最后会有讲解)。
6. execvpe
这个接口我就不介绍了,execvpe 与 execle 的唯一区别在于,它使用数组来传递命令行参数,其余功能与 execle 完全一致。
exec 系列库函数与系统调用的关系
exec 系列库函数(如 execl、execle)都是对底层系统调用 execve 的封装,它们通过不同的参数传递方式(可变参数或数组)来提供更易用的接口。
其他问题
问题:exec 系列函数只能执行系统命令吗?能不能执行自己写的程序?
exec 系列函数不仅能执行系统命令(比如 ls、cat),也能执行你自己写的可执行程序;只要你把程序编译成可执行文件,然后把它的完整路径(或文件名,如果在 PATH 里)传给 exec 函数,就能像执行系统命令一样运行它。
#include <iostream>
#include <unistd.h>
using namespace std;
int main() {
int ret = execl("./testc", "testc", NULL);
if (ret == -1) {
cout << "execl failed" << endl;
}
return 0;
}
这段演示清晰地证明了 exec 系列函数的强大能力:它不仅能执行 ls、cat 这类系统命令,也能无缝运行我们自己编写并编译好的程序。
很多人会疑惑:为什么在命令行执行自己的程序时需要加 ./(比如 ./testc),但在 exec 函数的参数里只传了 testc 就能运行?
- 在外部终端执行程序时,系统需要明确路径来找到可执行文件,所以必须加上
./ 表示当前目录。
- 而在
exec 函数中,第一个参数是程序的完整路径(如 ./testc),它已经告诉系统去哪里找文件;后面的 testc 只是传递给程序的 argv[0] 参数,不是用来定位文件的,因此不需要再加路径(当然,加了也可以运行)。
问题:为什么我们的可执行程序、脚本,都能跨语言调用呢?
因为所有语言运行起来,本质都是进程。不管是 C/C++ 编译出的可执行文件、Shell 脚本,还是 Python 脚本,最终都是由操作系统创建为独立的进程来执行的。调用的本质都是让操作系统加载并执行目标程序,和它原本用什么语言编写无关。
tip:exec*有着加载器的作用
exec 系列函数是操作系统层面的程序加载与替换接口,核心作用是加载指定可执行文件,将当前进程的内存空间完全替换为新程序内容,从而启动新程序执行。
在终端中,我们输入的非内建指令(如 ls、自定义编译程序),本质上都是 Shell 先通过 fork() 创建子进程,再在子进程中调用 exec 系列函数加载并执行目标程序。因此,exec 是终端非内建指令执行的核心环节,扮演'加载器'的关键角色。
问题:父进程如何通过 exec*给子进程传递命令行参数和环境变量
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
} else if (pid == 0) {
char* const argv[] = { "./testc", "-a", "-b", "-c", NULL };
int ret = execv("./testc", argv);
if (ret == -1) {
perror("execv failed");
return 1;
}
}
wait(NULL);
return 0;
}
#include <stdio.h>
int main(int argc, char *argv[], char *env[]) {
printf("-------------------这是命令行参数-------------------\n");
for (int i = 0; argv[i]; i++) {
printf("[%d]: %s\n", i, argv[i]);
}
printf("-------------------这是环境变量-------------------\n");
for (int i = 0; env[i]; i++) {
printf("[%d]: %s\n", i, env[i]);
}
return 0;
}
从运行结果来看,我们传递的命令行参数被完整打印,但是我们没有传递环境变量为啥也能打印出来呢?
这是因为环境变量是在 fork() 创建子进程时,由操作系统自动从父进程复制并继承给子进程的,并非需要我们手动传递。后续调用 execv 替换子进程时,这份环境变量会被完整保留,所以新程序依然可以通过 main 的 env[] 参数或全局变量 environ 访问并打印它们。
env[]:是 main 函数的第三个参数,它直接指向子进程继承来的环境变量表,是函数参数级别的访问入口。
environ:是系统为每个进程预设的全局变量,也指向同一份环境变量表,是全局级别的访问入口。
putenv(新增环境变量)
putenv 是一个用于新增或修改当前进程环境变量的函数。
- 作用:将一个格式为
KEY=VALUE 的字符串添加到当前进程的环境变量表中;如果 KEY 已存在,则会覆盖原有值。
- 生效范围:仅对当前进程有效,并且会被后续
fork() 创建的子进程继承。
- 注意点:参数必须是
KEY=VALUE 格式,等号两边不能有空格。
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main() {
putenv("MY_ENV=666666");
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
} else if (pid == 0) {
char* const argv[] = { "./testc", "-a", "-b", "-c", NULL };
int ret = execv("./testc", argv);
if (ret == -1) {
perror("execv failed");
return 1;
}
} else {
wait(NULL);
cout << "-------------------父进程新增 env-------------------" << endl;
cout << getenv("MY_ENV") << endl;
}
return 0;
}
- 子进程成功获取并输出了通过
putenv 设置的环境变量。
- 当前进程成功获取并输出了通过
putenv 设置的环境变量。
- bash 父进程的环境变量并未新增该条目。
问题:如何彻底替换环境变量?
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main() {
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
} else if (pid == 0) {
char* const argv[] = { "./testc", "-a", "-b", "-c", NULL };
char* const myenv[] = { "MYAVL=111111", "HELLO=222222", NULL };
int ret = execle("./testc", "./testc", "-a", "-b", "-c", NULL, myenv);
if (ret == -1) {
perror("execle failed");
return 1;
}
} else {
wait(NULL);
}
return 0;
}
子进程环境变量被自定义 myenv 数组完全替换,仅能访问显式传递的 MYAVL/HELLO,未继承父进程的 PATH 等原有环境变量。
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown 转 HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
- HTML 转 Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online