/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
一、进程程序替换的原理
1.1 什么是进程程序替换?
在 Linux 中,用 fork() 创建子进程后,子进程往往会执行与父进程相同的代码(或者是通过 if-else 分流执行父进程代码的一部分)。如果我们想让子进程执行一个全新的程序,就需要用到进程程序替换。
进程程序替换是通过 exec* 系列系统调用/库函数实现的。当进程调用这类函数时,该进程的用户空间代码和数据完全被新程序替换,并从新程序的初始化代码开始执行。
1.2 程序替换的底层原理
- 不创建新进程:程序替换前后,进程的 PID 并没有发生改变。它依然是原来的那个进程,只是内核把新程序的代码和数据加载到了该进程的物理内存中;
- 页表与物理内存的映射改变:原本进程的虚拟地址空间通过页表映射到旧的代码和数据。当调用 exec* 函数时,磁盘上要执行的新程序会被加载器加载到物理内存中。接着,操作系统会重新更新该进程的页表映射关系,让虚拟地址空间的各个区域指向新加载的代码和数据;
- 写时拷贝保证独立性:父子进程原本共享代码段,一旦子进程调用 exec*,发生大范围的代码和数据覆盖,操作系统会触发写时拷贝。此时,子进程会拥有属于自己独立的物理内存空间,从而绝对不会影响父进程的代码和数据。这也完美体现了进程的独立性。

操作系统内核的加载器就是 exec 的这个原理。
代码证明进程程序替换没有生成新的进程(先看运行结果,后面解释):
test.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("替换程序要运行了!\\n");
printf("我的pid: %d\\n", getpid());
printf("替换程序运行完毕了\\n");
return 0;
}
myexec.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("我的程序要运行了!\\n");
printf("我的pid: %d\\n", getpid());
execl("./test", "test", NULL);
printf("我的程序运行完毕了\\n");
return 0;
}

我们发现两件事:
二、认识 exec* 系列函数
Linux 提供了多种 exec 版本的函数,它们的底层功能相同,但参数传递的方式有所差异。

2.1 execl
1. 介绍
int execl(const char *path, const char *arg, ...);
- 参数含义:
- path:要执行程序的绝对路径或相对路径(回答了"我要执行谁");
- arg:命令行参数列表。我们在终端怎么写命令,就怎么传参数(回答了"我想怎么执行它");
- …:可变参数列表,必须以 NULL 结尾,用以表明参数传递结束。
- 示例:
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
2. 代码
我们都知道指令就是一个程序,都是存放在 /usr/bin/ls 下的可执行程序,那我们把它替换进来试试:
void Func01()
{
pid_t id = fork();
if (id == 0)
{
execl("/bin/ls", "ls", "-a", "-l", NULL);
exit(0);
}
int status = 0;
// 等待 pid 为 id 的子进程,等待方式为阻塞等待
pid_t wid = waitpid(id, &status, 0);
if (wid > 0)
printf("子进程退出成功,退出码是:%d\\n", WEXITSTATUS(status));
else if (wid < 0)
printf("子进程退出异常\\n");
}

2.2 自动搜索路径:execlp
1. 介绍
int execlp(const char *file, const char *arg, ...);
- 特点:名字中的 p 代表 PATH。它不需要输入完整的路径,只需要提供可执行文件的文件名;
- 机制:系统会自动在环境变量 $PATH 中查找指定的命令;
- 示例:
execlp("ls", "ls", "-l", "-a", NULL);
2. 代码
void Func01()
{
pid_t id = fork();
if (id == 0)
{
// execl("/bin/ls", "ls", "-a", "-l", NULL);
execlp("ls", "ls", "-l", "-a", NULL);
exit(0);
}
int status = 0;
// 等待 pid 为 id 的子进程,等待方式为阻塞等待
pid_t wid = waitpid(id, &status, 0);
if (wid > 0)
printf("子进程退出成功,退出码是:%d\\n", WEXITSTATUS(status));
else if (wid < 0)
printf("子进程退出异常\\n");
}

3. 补充
虽然它说可以不用带上地址,但是我们带上地址也没事,其他的同理。这里的接口设计师在设计的时候考量的问题挺多的,当然写的时候尽可能严谨一点。
2.3 数组传参:execv
1. 介绍
int execv(const char *path, char *const argv[]);
- 特点:名字中的 v 代表 vector(数组)。它不再使用可变参数一个一个传,而是把所有的命令行参数装进一个指针数组中统一传递。
2. 代码
void Func01()
{
pid_t id = fork();
if (id == 0)
{
// 数组最后一位也要带 NULL
char *_argv[] = {"ls", "-a", "-l", NULL};
// execl("/bin/ls", "ls", "-a", "-l", NULL);
// execlp("ls", "ls", "-l", "-a", NULL);
execv("/bin/ls", _argv);
exit(0);
}
int status = 0;
// 等待 pid 为 id 的子进程,等待方式为阻塞等待
pid_t wid = waitpid(id, &status, 0);
if (wid > 0)
printf("子进程退出成功,退出码是:%d\\n", WEXITSTATUS(status));
else if (wid < 0)
printf("子进程退出异常\\n");
}


2.4 传递环境变量:execvpe 与 execle
1. 介绍
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)会全部丢失。
- 利用 putenv 实现追加传递:
如果我们只想新增一两个环境变量给子进程,可以在调用 exec* 之前,使用 putenv(char *string) 函数将新变量导入到当前进程的全局 environ 中,然后再通过 environ 整体传递给子进程。
- 环境变量的单向传递性:
假设存在进程链:A → B → C(B 是 A 的子进程,C 是 B 的子进程)。
如果 B 进程 putenv 了一个环境变量,C 看得到,A 看不到。这是因为子进程的环境变量拷贝自父进程,修改只能向下单向传递,无法逆向影响父进程。
2. 代码
test.c
int main()
{
printf("替换程序要运行了!\\n");
// printf("我的pid: %d\\n", getpid());
// 全局的、指向 main 函数 env 表的二级指针
extern char** environ;
int i = 0;
for(i; environ[i]; i++)
printf("%s\\n", environ[i]);
printf("替换程序运行完毕了\\n");
return 0;
}
myexec.c
void Func01()
{
pid_t id = fork();
if (id == 0)
{
char env[] = "MYNAME=ljm";
// 追加导入 env
putenv(env);
// 全局的、指向 env 表的二级指针
extern char** environ;
// 数组最后一位也要带 NULL
char *_argv[] = {"ls", "-a", "-l", NULL};
// execl("/bin/ls", "ls", "-a", "-l", NULL);
// execlp("ls", "ls", "-l", "-a", NULL);
// execv("/bin/ls", _argv);
execle("./test", NULL, environ);
execve("./test", _argv, environ);
exit(0);
}
int status = 0;
// 等待 pid 为 id 的子进程,等待方式为阻塞等待
pid_t wid = waitpid(id, &status, 0);
if (wid > 0)
{
int i = 0;
extern char** environ;
// 验证环境变量只能向下传
for(i; environ[i]; i++)
printf("%s\\n", environ[i]);
printf("子进程退出成功,退出码是:%d\\n", WEXITSTATUS(status));
}
else if (wid < 0)
printf("子进程退出异常\\n");
}

三、接口其他问题
3.1 接口的命名规律
只要理解了接口后缀的英文字母,就能轻松记忆和选用所有接口:
- l(list):参数采用列表形式(可变参数);
- v(vector):参数采用指针数组形式;
- p(path):能自动在 $PATH 环境变量中搜索可执行文件;
- e(env):可以传入自定义的环境变量数组。
3.2 函数返回值与错误处理
1. 函数返回值
exec* 系列函数的返回值设计非常特殊,与绝大多数系统调用不同。

- 成功时:没有返回值。因为一旦替换成功,原程序的整个代码段、包括接收返回值的逻辑全部都不复存在了,进程会直接去执行新程序的代码;
- 失败时:一定会返回 -1,并且系统会设置全局变量 errno 以指示具体的错误原因。
2. 错误处理
因为 exec* 函数只要成功就绝不返回,所以在实际编写代码时,我们不需要对 exec* 成功的情况做任何返回值判断。只要 exec* 后面的代码被执行了,那必然是替换失败了!
// 经典规范写法
execvp("ls", argv);
// 如果能走到下一行,说明 exec 必然失败了!
perror("exec failed");
exit(1);
3.3 底层是谁
事实上,只有 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 没有被使用,使用的是你传递过去的那个”。
3.4 支持跨语言替换
进程程序替换不仅可以替换由 C/C++ 编译出来的二进制可执行程序,还可以替换成 Shell、Python 等任意脚本语言。
核心原理:无论是二进制程序还是脚本文件,在操作系统看来都是一个可执行文件。对于脚本文件,只要它拥有可执行权限,且其首行指定了正确的解释器路径,内核就会自动调用相应的解释器来执行该脚本。
只要是进程就可以进行进程程序替换。
1. 准备
在进行代码测试前,我们需要在同级目录下准备好相应的脚本文件。脚本通过解释器运行,不需要进行编译。
#!/bin/bash
echo "我是一个shell脚本"
#!/usr/bin/env python3
print("我是一个python脚本")
2. 绝对路径与可执行权限
3. 解释型语言
Python 等解释型语言在运行时,本质上是在执行解释器程序。解释器负责将源代码动态翻译并执行。同理,Shell 脚本的解释器就是系统中的 Shell 终端程序本身。
4. 代码
void Func01()
{
pid_t id = fork();
if (id == 0)
{
char env[] = "MYNAME=ljm";
// 追加导入 env
putenv(env);
// 全局的、指向 env 表的二级指针
extern char** environ;
// 数组最后一位也要带 NULL
char *_argv[] = {"ls", "-a", "-l", NULL};
// execl("/bin/ls", "ls", "-a", "-l", NULL);
// execlp("ls", "ls", "-l", "-a", NULL);
// execv("/bin/ls", _argv);
// execle("./test", NULL, environ);
// execve("./test", _argv, environ);
// execl("./mytest.sh", NULL);
execl("./mytst.py", NULL);
exit(0);
}
int status = 0;
// 等待 pid 为 id 的子进程,等待方式为阻塞等待
pid_t wid = waitpid(id, &status, 0);
if (wid > 0)
{
// int i = 0;
// extern char** environ;
// 验证环境变量只能向下传
// for(i; environ[i]; i++)
// printf("%s\\n", environ[i]);
printf("子进程退出成功,退出码是:%d\\n", WEXITSTATUS(status));
}
else if (wid < 0)
printf("子进程退出异常\\n");
}

网硕互联帮助中心



评论前必须登录!
注册