
◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)
文章目录
- 概要&序論
- [一、 进程程序替换的原理](#一、 进程程序替换的原理)
-
- [1.1 什么是进程程序替换?](#1.1 什么是进程程序替换?)
- [1.2 程序替换的底层原理](#1.2 程序替换的底层原理)
- [二、 认识 exec* 系列函数](#二、 认识 exec* 系列函数)
-
- [2.1 基础接口:execl](#2.1 基础接口:execl)
- [2.2 自动搜索路径:execlp](#2.2 自动搜索路径:execlp)
- [2.3 数组传参:execv](#2.3 数组传参:execv)
- [2.4 传递环境变量:execvpe 与 execle](#2.4 传递环境变量:execvpe 与 execle)
-
- [2.4.1 核心接口介绍](#2.4.1 核心接口介绍)
-
- [2.4.1.1利用 putenv 实现追加传递](#2.4.1.1利用 putenv 实现追加传递)
- 2.4.1.2环境变量的单向传递性
- 2.4.2详细测试代码
- 2.5接口的命名规律
- 2.6函数返回值与错误处理
-
- [2.6.1 只有失败返回值](#2.6.1 只有失败返回值)
- [2.6.2 代码编写避坑指南](#2.6.2 代码编写避坑指南)
- 2.7谁才是真正的系统调用
- [2.8 支持跨语言替换](#2.8 支持跨语言替换)
-
- [2.8.1 环境准备](#2.8.1 环境准备)
-
- [1. Shell 脚本 (myscript.sh)](#1. Shell 脚本 (myscript.sh))
- [2. Python 脚本 (myscript.py)](#2. Python 脚本 (myscript.py))
- [2.8.2 详细测试代码](#2.8.2 详细测试代码)
概要&序論
本文深刻探讨 Linux 进程程序替换机制。
- 核心内容:剖析程序替换不创建新进程、重映射页表与写时拷贝的底层物理内存原理;
- 核心内容 :详解
execl、execlp、execv等exec*全系列接口的参数构造与传参差异;- 细节补充 :阐述自定义环境变量传递与
putenv对子进程环境表的影响;- 细节补充 :揭示
exec*仅有失败返回值的独特机制与代码避坑规范;
好的,我们直接开始。
一、 进程程序替换的原理
1.1 什么是进程程序替换?
在 Linux 中,用 fork() 创建子进程后,子进程往往会执行与父进程相同的代码(或者是通过 if-else 分流执行父进程代码的一部分)。如果我们想让子进程执行一个全新的程序 ,就需要用到进程程序替换 。
进程程序替换是通过 exec* 系列系统调用/库函数实现的。当进程调用这类函数时,该进程的用户空间代码和数据完全被新程序替换,并从新程序的初始化代码开始执行。
1.2 程序替换的底层原理
- 不创建新进程 :程序替换前后,进程的 PID并没有发生改变。它依然是原来的那个进程,只是内核把新程序的代码和数据加载到了该进程的物理内存中。
- 页表与物理内存的映射改变 :原本进程的虚拟地址空间通过页表映射到旧的代码和数据。当调用
exec*函数时,磁盘上要执行的新程序会被加载器加载到物理内存中。接着,操作系统会重新更新该进程的页表映射关系,让虚拟地址空间的各个区域指向新加载的代码和数据。 - 写时拷贝保证独立性 :父子进程原本共享代码段,一旦子进程调用
exec*,发生大范围的代码和数据覆盖,操作系统会触发写时拷贝 。此时,子进程会拥有属于自己独立的物理内存空间,从而绝对不会影响父进程的代码和数据。这也完美体现了进程的独立性。

操作系统内核的加载器就是exec的这个原理
如何证明进程程序替换没有生成新的进程?

二、 认识 exec* 系列函数
Linux 提供了多种 exec 版本的函数,它们的底层功能相同,但参数传递的方式有所差异。
2.1 基础接口:execl
2.1.1详细介绍
c
int execl(const char *path, const char *arg, ...);
-
参数含义 :
path:要执行程序的绝对路径或相对路径(回答了"我要执行谁")。arg:命令行参数列表。我们在终端怎么写命令,就怎么传参数(回答了"我想怎么执行它")。...:可变参数列表,必须以NULL结尾,用以表明参数传递结束。
-
示例 :
cexecl("/usr/bin/ls", "ls", "-a", "-l", NULL);
2.1.2详细测试代码
cpp
#include<iostream>
#include<cstring>
#include<sys/types.h>
#include<unistd.h>
#include<sys/wait.h>
using namespace std;
void Func01(){
pid_t id = fork();
if(id==0){
char* _argv[]={"ls","-a","-l",NULL};
execl("/bin/ls","ls","-a","-l",NULL);
exit(0);
}
int status = 0;
pid_t rid = waitpid(id, &status , 0);
if(rid > 0)
cout<<"子进程退出成功过"<<"退出码是:"<<WEXITSTATUS(status)<<endl;
else if(rid < 0)
cout<<"子进程退出异常"<<endl;
}
int main(){
Func01();
return 0;
}
2.2 自动搜索路径:execlp
2.2.1详细介绍
c
int execlp(const char *file, const char *arg, ...);
-
特点 :名字中的
p代表 PATH 。它不需要输入完整的路径,只需要提供可执行文件的文件名。 -
机制 :系统会自动在环境变量
$PATH中查找指定的命令。 -
示例 :
cexeclp("ls", "ls", "-l", "-a", NULL);
2.2.2详细测试代码
cpp
#include<iostream>
#include<cstdio>
#include<cstring>
#include<sys/types.h>
#include<unistd.h>
#include<sys/wait.h>
using namespace std;
void Func01()
{
pid_t id = fork();
if(id==0)
{
char* _argv[]={"ls","-a","-l",NULL};
//execv("/bin/ls",_argv);
//execl("/bin/ls","ls","-a","-l",NULL);
execlp("ls","-a","-l",NULL);
execlp("./myscript.py","myscript.py",NULL);
exit(0);
}
int status = 0;
pid_t rid = waitpid(id, &status , 0);
if(rid > 0)
cout<<"子进程退出成功过"<<"退出码是:"<<WEXITSTATUS(status)<<endl;
else if(rid == 0 )
cout<<"子进程未退出"<<endl;
else if(rid < 0)
cout<<"子进程退出异常"<<endl;
}
int main()
{
Func01();
//Func02();
return 0;
}
虽然它说可以不用带上地址,但是我们带上地址也没事,其他的同理,这里的接口设计师在设计的时候考量的问题挺多的,当然尽可能写的时候严谨一点。
cpp
execlp("/bin/ls","ls","-a","-l",NULL);
2.3 数组传参:execv
c
int execv(const char *path, char *const argv[]);
-
特点 :名字中的
v代表 vector(数组) 。它不再使用可变参数一个一个传,而是把所有的命令行参数装进一个指针数组中统一传递。 -
示例 :
cchar *const my_argv[] = { "ls", "-l", "-a", NULL // 数组最后同样必须以 NULL 结尾 }; execv("/usr/bin/ls", my_argv);

2.4 传递环境变量:execvpe 与 execle
2.4.1 核心接口介绍
c
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
- 特点 :名字中的
e代表 environment 。它允许开发者向被替换后的新进程传递一张全新的环境变量表envp。 - 应用场景 :如果我们希望被替换的子进程在一个纯净的、或者完全由我们定制的环境变量下运行,就可以使用带
e的接口。
核心冲突 :
正常情况下,子进程不需手动传递就能默认获取父进程的环境变量。但带有
e的接口传递环境变量是覆盖式的,而不是追加式的 。一旦使用,新进程原有的环境变量(如PATH)会全部丢失。
2.4.1.1利用 putenv 实现追加传递
如果我们只想新增 一两个环境变量给子进程,可以在调用 exec* 之前,使用 putenv(char *string) 函数将新变量导入到当前进程的全局 environ 中,然后再通过 environ 整体传递给子进程。
2.4.1.2环境变量的单向传递性
假设存在进程链:A → \rightarrow → B → \rightarrow → C (B 是 A 的子进程,C 是 B 的子进程)。
如果 B 进程 putenv 了一个环境变量,C 看得到,A 看不到。这是因为子进程的环境变量拷贝自父进程,修改只能向下单向传递,无法逆向影响父进程。
2.4.2详细测试代码
cpp
//proc.cpp
#include<iostream>
#include<cstdio>
#include<cstring>
#include<sys/types.h>
#include<unistd.h>
#include<sys/wait.h>
using namespace std;
char* newenv = (char* ) "MyEnv=11223344";
void Func()
{
pid_t id = fork();
if(id==0)
{
putenv(newenv);
extern char** environ;
char* _argv[]={(char*)"other",NULL};
//execv("/bin/ls",_argv);
//execl("/bin/ls","ls","-a","-l",NULL);
//execlp("ls","-a","-l",NULL);
//execlp("./myscript.py","myscript.py",NULL);
execvpe("./other",_argv,environ);
exit(0);
}
int status = 0;
pid_t rid = waitpid(id, &status , 0);
if(rid > 0)
cout<<"子进程退出成功过"<<"退出码是:"<<WEXITSTATUS(status)<<endl;
else if(rid == 0 )
cout<<"子进程未退出"<<endl;
else if(rid < 0)
cout<<"子进程退出异常"<<endl;
}
int main()
{
Func();
return 0;
}
//other.cpp
#include<iostream>
using namespace std;
int main(){
extern char** environ;
for(int i =0;environ[i];i++)
cout<<environ[i];
return 0;
}
2.5接口的命名规律
只要理解了接口后缀的英文字母,就能轻松记忆和选用所有接口:
l(list):参数采用列表形式(可变参数)。v(vector):参数采用指针数组形式。p(path) :能自动在$PATH环境变量中搜索可执行文件。e(env):可以传入自定义的环境变量数组。
2.6函数返回值与错误处理
exec* 系列函数的返回值设计非常特殊,与绝大多数系统调用不同。

2.6.1 只有失败返回值
成功时 :没有返回值。因为一旦替换成功,原程序的整个代码段、包括接收返回值的逻辑全部都不复存在了,进程会直接去执行新程序的代码。
- 失败时 :一定会返回
-1,并且系统会设置全局变量errno以指示具体的错误原因。
2.6.2 代码编写避坑指南
因为 exec* 函数只要成功就绝不返回,所以在实际编写代码时,我们不需要对 exec* 成功的情况做任何返回值判断 。只要 exec* 后面的代码被执行了,那必然是替换失败了!
c
// 经典规范写法
execvp("ls", argv);
// 如果能走到下一行,说明 exec 必然失败了!
perror("exec failed");
exit(1);
2.7谁才是真正的系统调用
事实上,只有 execve 是真正的系统调用, 其它五个函数最终都调用 execve ,所以 execve 在 man 手册 第2节,其它函数在 man 手册第3节。这些函数之间的关系如下图所示。

- 其他的诸如
execl,execlp,execle,execv,execvp,execvpe等,全部都是在execve之上进行的 C 语言库函数封装。
那么这个封装体现在哪里?我举一个例子,我们C 语言库函数带e的exec族函数你可以传递环境变量,但是实际上没有带e的exec族函数其实底层使用了环境变量的缺省值 (environ)。原因是底层封装的系统调用execve支持他这么干。
这也是为什么带e的exec族函数会覆盖式的传递环境变量的根本原因:"缺省参数environ没有被使用,使用的是你传递过去的那个"
2.8 支持跨语言替换
进程程序替换不仅可以替换由 C/C++ 编译出来的二进制可执行程序,还可以替换成 Shell、Python 等任意脚本语言 。
核心原理 :无论是二进制程序还是脚本文件,在操作系统看来都是一个可执行文件。对于脚本文件,只要它拥有可执行权限,且其首行指定了正确的解释器路径,内核就会自动调用相应的解释器来执行该脚本。
只要是进程就可以进行进程程序替换
2.8.1 环境准备
在进行代码测试前,我们需要在同级目录下准备好相应的脚本文件。脚本通过解释器运行,不需要进行编译。
1. Shell 脚本 (myscript.sh)
bash
#!/bin/bash
echo "我是一个shell脚本"
2. Python 脚本 (myscript.py)
python
#!/usr/bin/env python3
print("我是一个python脚本")
关键细节:绝对路径与可执行权限
- Shebang 的意义 :
#!/bin/bash和#!/usr/bin/env python3告知内核该用哪个路径下的解释器去加载后面的脚本代码。- 赋予可执行权限 :脚本编写完成后,必须在终端通过
chmod +x myscript.sh myscript.py命令为它们加上可执行权限 ,否则子进程在执行程序替换时会报Permission denied的错误。

补充小知识:
Python 等解释型语言在运行时,本质上是在执行解释器程序。解释器负责将源代码动态翻译并执行。同理,Shell 脚本的解释器就是系统中的 Shell 终端程序本身。

2.8.2 详细测试代码
以下 C++ 代码将通过 fork() 创建子进程,并分别演示使用 execv 和 execl 接口去替换执行上述的 Shell 脚本和 Python 脚本。
cpp
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <cstdlib>
using namespace std;
void TestScriptReplace()
{
// 1. 测试替换为 Shell 脚本
pid_t id1 = fork();
if (id1 == 0)
{
cout << "[子进程1] 准备替换为 Shell 脚本..." << endl;
// 使用 execl,通过相对路径直接执行拥有 +x 权限的脚本
execl("./myscript.sh", "myscript.sh", NULL);
// 如果走到这里,说明替换失败
perror("exec shell failed");
exit(1);
}
// 2. 测试替换为 Python 脚本
pid_t id2 = fork();
if (id2 == 0)
{
cout << "[子进程2] 准备替换为 Python 脚本..." << endl;
// 使用 execv 传参
char* const py_argv[] = {(char*)"myscript.py", NULL};
execv("./myscript.py", py_argv);
// 如果走到这里,说明替换失败
perror("exec python failed");
exit(2);
}
// 父进程回收子进程资源
int status = 0;
pid_t rid1 = waitpid(id1, &status, 0);
if (rid1 > 0 && WIFEXITED(status)) {
cout << "Shell 脚本子进程退出成功,退出码:" << WEXITSTATUS(status) << endl;
}
pid_t rid2 = waitpid(id2, &status, 0);
if (rid2 > 0 && WIFEXITED(status)) {
cout << "Python 脚本子进程退出成功,退出码:" << WEXITSTATUS(status) << endl;
}
}
int main()
{
TestScriptReplace();
return 0;
}
好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye!