一文搞懂 Linux 进程替换:从 fork 到 exec 的完整链路
目录
---------- exec 系列接口 ----------
------------- 其他问题 -------------
问题:exec 系列函数只能执行系统命令吗?能不能执行自己写的程序?
问题:父进程如何通过 exec* 给子进程传递命令行参数和环境变量
进程替换是什么?
进程替换就是:一个正在运行的进程,用一个全新的可执行程序,完全替换掉自己当前的代码、数据和堆栈,但进程的 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、进程替换会发生写实拷贝吗?
exec 替换不会触发写时拷贝(COW)✅
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 执行 ls -l -a execl("/usr/bin/ls", "ls", "-l", "-a", NULL); // 如果 execl 执行成功,下面的代码永远不会执行 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; // 执行 ls -l -a,通过 PATH 环境变量自动查找 ls int ret = execlp("ls", "ls", "-l", "-a", NULL); // 如果 execlp 成功,下面的代码永远不会执行 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}; // 执行 /bin/ls -l -a int ret = execv("/bin/ls", argv); // 如果 execv 成功,下面的代码不会执行 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}; // 执行 ls -l -a,通过 PATH 自动查找 ls int ret = execvp("ls", argv); // 如果 execvp 成功,下面的代码不会执行 if (ret == -1) { perror("execvp failed"); } cout << "After execvp (won't print if success)" << endl; return 0; }
5、execle
📝 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) { // fork 创建子进程失败 perror("fork failed"); return 1; } else if (pid == 0) { // 子进程:使用 execle 显式传递 environ int ret = execle("./testc", "./testc", "-a", "-b", "-c", NULL, environ); // 只有 execle 失败时,下面的代码才会执行 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) { // fork 创建子进程失败 perror("fork failed"); return 1; } else if (pid == 0) { // 子进程执行逻辑 char* const argv[] = { "./testc", "-a", "-b", "-c", NULL }; int ret = execv("./testc", argv); // 只有 execv 失败时,下面的代码才会执行 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() { // 设置环境变量,注意格式 KEY=VALUE 不能有空格 putenv("MY_ENV=666666"); // 创建子进程 pid_t pid = fork(); if (pid == -1) { perror("fork failed"); return 1; } else if (pid == 0) { // 子进程:执行 testc 程序 char* const argv[] = { "./testc", "-a", "-b", "-c", NULL }; int ret = execv("./testc", argv); // 如果 execv 失败才会执行到这里 if (ret == -1) { perror("execv failed"); return 1; } } else { // 父进程:等待子进程结束 wait(NULL); // 父进程打印自己的环境变量 cout << "-------------------父进程新增env-------------------" << endl; cout << getenv("MY_ENV") << endl; } return 0; }
1、子进程成功获取并输出了通过 putenv 设置的环境变量。
2、当前进程成功获取并输出了通过 putenv 设置的环境变量。
3、bash 父进程的环境变量并未新增该条目。
问题:如何彻底替换环境变量?
#include <iostream> #include <cstdlib> #include <unistd.h> #include <sys/wait.h> using namespace std; int main() { // 创建子进程 pid_t pid = fork(); if (pid == -1) { // fork 创建子进程失败 perror("fork failed"); return 1; } else if (pid == 0) { // 子进程:用 execle 显式传递自定义环境变量 char* const argv[] = { "./testc", "-a", "-b", "-c", NULL }; char* const myenv[] = { "MYAVL=111111", "HELLO=222222", NULL }; // 显式传递自定义环境变量数组 myenv int ret = execle("./testc", "./testc", "-a", "-b", "-c", NULL, myenv); // 只有 execle 失败时才会执行到这里 if (ret == -1) { perror("execle failed"); return 1; } } else { // 父进程:等待子进程执行完毕,避免子进程变成僵尸进程 wait(NULL); } return 0; }
子进程环境变量被自定义 myenv 数组完全替换,仅能访问显式传递的 MYAVL/HELLO,未继承父进程的 PATH 等原有环境变量。
