【Linux操作系统】进程篇(十一):进程程序替换——从底层原理到 exec 函数族

前言

前面我们学习了 fork(),知道它可以创建一个子进程。调用 fork() 之后,父子进程各自拥有独立的进程身份,但子进程会继承父进程的代码和相关资源,从 fork() 返回的位置继续执行。

那么问题来了:

如果我不想让子进程继续执行原来的代码,而是想让它去执行一个完全不同的程序,比如 ls、bash,甚至一个 Python 脚本,该怎么办?

难道还要重新创建一个进程吗?

其实不需要。Linux 提供了一组非常重要的函数------exec 函数族,可以直接将当前进程的程序替换成另一个程序。

这也是为什么我们在 Linux 中经常看到 fork() 和 exec() 配合使用。

不过,理解 exec 不能只停留在函数怎么调用。我们还需要结合之前学过的进程地址空间、页表、环境变量等知识,弄清楚一个问题:

原来的程序被替换了,为什么进程却还是原来的进程?

下面就从这个问题开始。

一、进程程序替换的原理

1.1 为什么需要进程程序替换?

先回顾一下之前学习的 fork()。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>

using namespace std;

int main()
{
    pid_t id = fork();

    if (id == 0)
    {
        cout << "我是子进程" << endl;
    }
    else if (id > 0)
    {
        cout << "我是父进程" << endl;
    }

    return 0;
}

调用 fork() 之后,操作系统创建了一个子进程。

子进程会继承父进程的相关资源,包括代码、数据以及环境变量等。父子进程从 fork() 返回的位置继续执行,只不过可以根据返回值走不同的执行分支。

但是,如果我们希望子进程执行的不是这份代码,而是一个全新的程序呢?

例如:

text 复制代码
父进程:原来的程序
    |
    | fork()
    |
    ├── 父进程:继续执行原来的程序
    |
    └── 子进程:不再执行原来的程序
                |
                └── 执行 ls 程序

这时候就需要使用 exec。

在理解 exec 之前,我们先区分两个概念:

  • 程序: 存放在磁盘上的可执行文件,本身是静态的,里面包含程序运行所需要的代码和数据。
  • 进程: 程序运行起来之后,操作系统为它创建的执行实体,拥有自己的进程信息、地址空间和运行状态等。

简单来说,程序是要执行的内容,进程是承载程序运行的实体。

而 exec 的作用,就是让当前进程不再执行原来的程序,而是加载并执行另一个程序。

这里要注意:

exec 不会创建新的进程,而是直接替换当前进程的程序。

所以,调用 exec 前后,进程的 PID 不会发生改变。

1.2 程序替换到底替换了什么?

这个问题需要结合之前学过的进程地址空间来理解。

我们知道,一个进程并不是简单地把程序代码放在内存里就能运行。操作系统还需要为它准备相应的虚拟地址空间,通过页表建立虚拟地址和物理地址之间的映射。

一个进程的用户空间大致可以理解为:

text 复制代码
高地址
+------------------+
|       栈         |
|                  |
+------------------+
|                  |
|       堆         |
|                  |
+------------------+
|       BSS        |
+------------------+
|       数据段     |
+------------------+
|       代码段     |
+------------------+
低地址

这就是原来程序运行时所使用的地址空间布局。

当进程调用 exec 时,原来程序的用户空间内容就不再保留了。

操作系统会为新程序建立所需要的运行环境,装载新的程序映像,并建立新的地址空间映射。

整个过程可以这样理解:

第一步:原来的程序不再执行。

原进程的代码、数据、堆栈等用户空间内容将被新程序替换。

第二步:加载新程序。

操作系统根据目标可执行文件的格式,解析程序需要的代码段、数据段等信息。

在 Linux 中,常见的 ELF 可执行文件由内核的 ELF 加载逻辑处理。

第三步:建立新的地址空间映射。

原来的用户空间映射被替换,新的程序拥有适合自己运行的虚拟地址空间布局。

注意,这里并不是说操作系统必须一次性把新程序的所有内容全部读入物理内存。很多页面可以在程序真正访问时再通过缺页异常按需加载。

第四步:从新程序的入口开始执行。

程序加载完成后,CPU 开始执行新程序的启动代码,经过运行时初始化,最终进入我们熟悉的 main() 函数。

因此,整个过程可以画成:

text 复制代码
调用 exec 之前

PID:1001
PCB:原来的进程控制信息
地址空间:原程序
代码:原来的 main()
       |
       | exec()
       ↓
调用 exec 成功之后

PID:1001
PCB:仍然对应原来的进程
地址空间:新程序
代码:新程序的启动代码
       |
       ↓
进入新程序的 main()

可以发现,改变的是进程所执行的程序,而不是重新创建一个进程。

这里再联系一下之前学习的 PCB。

PCB(进程控制块)是操作系统用来管理进程的数据结构 ,里面保存着进程的标识、状态、调度信息等内容。

调用 exec 并不意味着重新创建一个全新的进程控制块。原来的进程身份仍然保留 ,PID 也不变,不过进程的内存管理信息会随着程序替换而更新。

当然,exec 不只是替换代码和数据,部分进程属性也会按照操作系统规定发生变化,例如捕获信号的处理方式会被重置,设置了 close-on-exec 的文件描述符会被关闭。

所以,进程程序替换的核心就是:保留原进程身份,替换原来的程序映像,让这个进程开始执行另一个程序。

1.3 exec 和 fork 有什么区别?

前面两个函数都能让程序执行发生变化,但它们解决的是不同的问题。

对比 fork() exec()
主要作用 创建子进程 替换当前进程的程序
是否创建新进程 是 否
PID 子进程拥有新的 PID 保持原 PID
原程序 父子进程分别执行 原程序被替换
地址空间 子进程获得独立的进程地址空间,通常采用写时拷贝 原程序的地址空间映像被新程序替换

这里补充一下写时拷贝(Copy On Write,COW)。

之前讲 fork 的时候,我们知道父子进程虽然拥有各自独立的地址空间,但在刚创建时,操作系统可以让它们的虚拟地址暂时映射到相同的物理页面。

只有当其中一方真正修改相关页面时,才需要进行相应的复制。

这就是写时拷贝。

但是,exec 和 fork 的作用并不一样。

当子进程调用 exec 时,原来的程序映像会被替换,不需要先把父进程原来的全部页面复制一份,再逐个覆盖。操作系统会直接建立新程序所需的地址空间。

所以不要把 exec 理解成 fork 的写时拷贝过程。

理解到这里,我们就可以继续思考:

既然 exec 会直接替换调用它的进程,那为什么实际开发中经常是 fork 和 exec 配合使用呢?

二、为什么通常使用 fork + exec?

我们先看一个常见的代码结构:

cpp 复制代码
pid_t id = fork();

if (id == 0)
{
    // 子进程
    exec...();
}

// 父进程
waitpid(id, NULL, 0);

为什么要这样写?

原因其实很简单:我们既想让一个子进程去执行新的程序,又不想让父进程原来的程序被替换。

如果父进程自己调用 exec,那么父进程就会直接变成新程序,原来父进程的代码也不会再继续执行。

但如果先调用 fork,就不一样了。

fork 创建一个新的子进程,exec 只替换子进程的程序。

这样父进程和子进程就能各自完成自己的工作。

例如:

text 复制代码
父进程
  |
  | fork()
  |
  ├── 父进程
  |      |
  |      └── 继续执行原来的代码
  |              |
  |              └── waitpid() 等待子进程
  |
  └── 子进程
         |
         └── exec()
                 |
                 └── 执行新的程序

2.1 结合 Shell 理解 fork + exec

我们平时打开 Linux 终端,输入:

bash 复制代码
ls

然后终端就会显示当前目录下的文件。

那么,Shell 是怎么做到这一点的?

首先,Shell 本身也是一个正在运行的进程。

当我们输入 ls 后,Shell 需要找到对应的可执行程序,并让它运行起来。

通常可以简化为以下过程:

  1. Shell 通过 fork() 创建一个子进程。
  2. 子进程调用 exec 函数族,将自己替换成 ls 程序。
  3. ls 程序执行,输出目录内容。
  4. ls 执行结束,子进程退出。
  5. 对于前台命令,父 Shell 通常通过等待机制获取子进程的退出状态,然后继续接收用户输入。

这里要注意,Shell 并没有把自己替换成 ls。

如果 Shell 自己调用 exec,那么它就不再是原来的 Shell 了,也就无法继续接收下一条命令。

所以才需要创建子进程,让子进程去执行新程序。

这也就把前面学习的三个知识点联系起来了:

  • fork():创建子进程。
  • exec():让子进程执行另一个程序。
  • waitpid():父进程等待并回收子进程。

2.2 一个完整的 fork + exec 示例

下面通过一段代码,把这三个函数放在一起理解。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdlib>
#include <cstdio>

using namespace std;

int main()
{
    pid_t id = fork();

    if (id < 0)
    {
        perror("fork");
        return 1;
    }

    if (id == 0)
    {
        // 子进程:替换为 ls 程序
        execl("/bin/ls", "ls", "-a", "-l", NULL);

        // 如果执行到这里,说明 exec 失败
        perror("execl");
        _exit(127);
    }

    // 父进程:等待子进程结束
    int status = 0;

    if (waitpid(id, &status, 0) > 0)
    {
        if (WIFEXITED(status))
        {
            cout << "子进程正常退出,退出码:"
                 << WEXITSTATUS(status) << endl;
        }
        else
        {
            cout << "子进程没有正常退出" << endl;
        }
    }

    return 0;
}

编译运行:

bash 复制代码
g++ test.cpp -o test
./test

运行后可以观察到:

  • 子进程执行 ls -a -l。
  • 父进程没有被替换,仍然执行 waitpid()。
  • 子进程执行结束后,父进程获得它的退出状态。

这里使用 _exit(127) 是为了在 exec 失败后结束子进程,避免继续执行不应该执行的原程序逻辑。

理解了 fork 和 exec 的配合关系,接下来我们就来看 exec 到底提供了哪些接口。

三、认识 exec 函数族

Linux 提供了多个 exec 接口。

它们完成的核心工作都是一样的:替换当前进程的程序映像。

不同的地方主要在于:

  • 程序路径如何查找。
  • 命令行参数如何传递。
  • 新程序的环境变量如何指定。

常见的六个接口分别是:

cpp 复制代码
execl()
execlp()
execle()

execv()
execvp()
execvpe()

这些函数的名字看起来很像,但只要理解后缀字母的含义,就不会觉得复杂。

在讲具体函数之前,先理解两个最重要的参数。

3.1 path 和 file:告诉操作系统执行哪个程序

例如:

cpp 复制代码
execl("/usr/bin/ls", "ls", "-l", NULL);

第一个参数:

cpp 复制代码
"/usr/bin/ls"

就是要执行的程序路径。

它回答的是:

我要执行谁?

如果传入的是完整路径或者相对路径,操作系统就可以按照这个路径寻找目标程序。

而有些接口允许只传入程序名称,例如:

cpp 复制代码
execlp("ls", "ls", "-l", NULL);

这时候就需要通过环境变量 PATH 查找对应的程序。

3.2 arg 和 argv:告诉新程序怎么执行

还是看这个例子:

cpp 复制代码
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);

第一个参数是程序路径,后面的参数才是传递给新程序的命令行参数。

最终新程序接收到的参数可以理解为:

cpp 复制代码
argv[0] = "ls";
argv[1] = "-a";
argv[2] = "-l";
argv[3] = NULL;

这里有一个容易混淆的地方:

argv0 通常存放程序名称,它不是程序的实际文件路径。

例如:

bash 复制代码
ls -a -l

可以理解为:

参数 内容
argv0 ls
argv1 -a
argv2 -l
argv3 NULL

其中,-a 和 -l 是 ls 程序接收到的选项。

这也说明了为什么 exec 函数需要分别接收程序路径和命令行参数。

一个负责找到程序,另一个负责告诉程序如何运行。

四、exec 函数族的六个接口

4.1 execl():使用参数列表传递参数

先来看第一个接口:

cpp 复制代码
int execl(const char *path, const char *arg, ...);

这里的 l 代表 list,也就是列表。

它的特点是:通过可变参数,一个一个地传入新程序需要的命令行参数。

例如:

cpp 复制代码
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);

我们把它拆开来看:

cpp 复制代码
execl(
    "/usr/bin/ls",  // 要执行的程序路径
    "ls",           // argv[0]
    "-a",           // argv[1]
    "-l",           // argv[2]
    NULL            // 参数结束标志
);

这里最后的 NULL 非常重要。

因为 execl 使用的是可变参数,操作系统需要知道参数列表在哪里结束,所以我们必须通过 NULL 告诉它参数已经传递完毕。

可以把 execl 理解为:

text 复制代码
程序路径:/usr/bin/ls

参数列表:
    "ls"
    "-a"
    "-l"
    NULL

它适合参数数量固定、调用时可以直接写出参数的场景。

4.2 execlp():通过 PATH 搜索程序

接下来是:

cpp 复制代码
int execlp(const char *file, const char *arg, ...);

和 execl 相比,它的参数形式没有变化,仍然是一个一个地传递参数。

不同的是多了一个 p。

这里的 p 代表 PATH。

我们之前学习环境变量的时候,接触过 PATH。

可以通过下面的命令查看:

bash 复制代码
echo $PATH

PATH 中保存着多个目录,目录之间通过冒号分隔。

例如:

text 复制代码
/usr/local/bin:/usr/bin:/bin

当我们在 Shell 中输入:

bash 复制代码
ls

Shell 可以根据 PATH 中的目录寻找 ls 对应的可执行文件。

execlp 也有类似的路径搜索能力。

例如:

cpp 复制代码
execlp("ls", "ls", "-a", "-l", NULL);

这里不需要写:

cpp 复制代码
"/usr/bin/ls"

因为 execlp 可以通过 PATH 搜索程序。

需要注意:

如果传入的 file 中已经包含 /,例如:

cpp 复制代码
execlp("./test", "test", NULL);

那么它会按照指定的路径执行,而不会再通过 PATH 搜索。

因此,execlp 与 execl 的主要区别就是:

  • execl:需要指定程序路径。
  • execlp:可以通过 PATH 搜索程序。

两者的参数传递方式是一样的。

4.3 execv():使用指针数组传递参数

前面两个函数都是通过一个一个传递参数的方式来调用。

但是,如果参数数量比较多,或者参数是程序运行过程中动态生成的,逐个传参就不太方便了。

所以又有了使用数组传参的接口:

cpp 复制代码
int execv(const char *path, char *const argv[]);

这里的 v 代表 vector,可以理解为数组。

它把所有命令行参数放进一个指针数组中,再统一传递给 execv。

例如:

cpp 复制代码
char arg0[] = "ls";
char arg1[] = "-a";
char arg2[] = "-l";

char* const argv[] = {
    arg0,
    arg1,
    arg2,
    NULL
};

execv("/usr/bin/ls", argv);

我们可以看到:

cpp 复制代码
argv[0] = "ls";
argv[1] = "-a";
argv[2] = "-l";
argv[3] = NULL;

这和之前 execl 传递的参数完全一样。

只是传递方式发生了变化。

这里尤其要注意,数组最后一个元素同样必须是 NULL。

因为 execv 需要通过这个结束标志确定参数数组的边界。

因此:

  • execl:使用可变参数列表。
  • execv:使用指针数组。

如果参数是动态生成的,通常使用 execv 会更加方便。

4.4 execvp():PATH 搜索 + 数组传参

继续看:

cpp 复制代码
int execvp(const char *file, char *const argv[]);

它可以理解为前面两个接口特点的组合。

  • v:通过指针数组传递参数。
  • p:通过 PATH 搜索程序。

例如:

cpp 复制代码
char arg0[] = "ls";
char arg1[] = "-a";
char arg2[] = "-l";

char* const argv[] = {
    arg0,
    arg1,
    arg2,
    NULL
};

execvp("ls", argv);

这里不需要写 /usr/bin/ls,因为 execvp 可以通过 PATH 搜索。

同时,它又使用 argv 数组统一传递参数。

因此,execvp 适合参数需要动态组织,同时又希望通过 PATH 搜索程序的场景。

4.5 execle():指定新程序的环境变量

前面四个接口解决的是程序查找和参数传递的问题。

但是,一个程序运行时不只有命令行参数,还会使用环境变量。

例如:

bash 复制代码
PATH
HOME
USER

我们之前学习过,进程可以通过环境变量获取运行环境中的配置信息。

那么,exec 替换成新程序之后,新程序的环境变量从哪里来?

普通的 exec 接口默认使用调用进程当前的环境变量。

如果我们希望自己指定一份环境变量表,就需要使用带有 e 的接口。

先来看:

cpp 复制代码
int execle(const char *path,
           const char *arg, ...);

它的完整参数形式中还包含一个 envp:

cpp 复制代码
int execle(const char *path,
           const char *arg,
           ...,
           char *const envp[]);

这里的 e 代表 environment。

也就是说,它允许我们给新程序指定一份环境变量表。

例如:

cpp 复制代码
char arg0[] = "printenv";

char env1[] = "MY_NAME=Yi";
char env2[] = "MY_JOB=student";

char* envp[] = {
    env1,
    env2,
    NULL
};

execle("/usr/bin/printenv",
       arg0,
       NULL,
       envp);

这里需要特别注意参数的位置。

因为 execle 本身使用的是可变参数,所以我们需要先传递命令行参数,再使用 NULL 表示命令行参数结束,最后传入环境变量数组。

可以理解为:

text 复制代码
execle(
    程序路径,
    argv[0],
    argv[1],
    ...
    NULL,
    envp
);

这里的 NULL 和 envp 不是一回事。

  • 第一个 NULL:表示命令行参数结束。
  • envp:表示新程序需要使用的环境变量表。
  • envp 数组最后的 NULL:表示环境变量表结束。

这是 execle 最容易写错的地方。

4.6 execvpe():数组传参 + PATH 搜索 + 指定环境变量

最后一个接口:

cpp 复制代码
int execvpe(const char *file,
            char *const argv[],
            char *const envp[]);

它可以理解为前面几个接口特点的组合:

  • v:通过数组传递参数。
  • p:通过 PATH 搜索程序。
  • e:指定新程序的环境变量。

例如:

cpp 复制代码
char arg0[] = "other";

char* const argv[] = {
    arg0,
    NULL
};

char env1[] = "MY_NAME=Yi";
char env2[] = "MY_JOB=student";

char* const envp[] = {
    env1,
    env2,
    NULL
};

execvpe("other", argv, envp);

这里,execvpe 会通过调用进程的 PATH 搜索 other,并将我们指定的 envp 传递给新程序。

不过这里有一个很重要的细节:

execvpe 搜索程序时使用的是调用进程当前环境中的 PATH,而不是传入的 envp 中的 PATH。

也就是说,程序查找所使用的环境和新程序最终接收的环境变量表是两个不同的概念。

如果 file 本身包含 /,例如 ./other,则会按照指定路径执行,不再搜索 PATH。

另外,execvpe 是 GNU 扩展接口,并不是 POSIX 标准规定的接口。使用时需要注意系统和 C 库的支持情况。

五、六个 exec 接口的命名规律

到这里,六个函数就全部认识了。

其实不需要死记硬背,只要理解三个字母就够了。

字母 英文 含义
l list 使用可变参数列表传参
v vector 使用指针数组传参
p PATH 通过 PATH 搜索程序
e environment 指定新程序的环境变量

再把六个接口放在一起比较:

接口 程序查找方式 参数传递 指定环境变量
execl 指定路径 列表 否
execlp PATH 或指定路径 列表 否
execle 指定路径 列表 是
execv 指定路径 数组 否
execvp PATH 或指定路径 数组 否
execvpe PATH 或指定路径 数组 是

这里可以按照自己的需求来选择:

  • 参数固定,直接使用列表传参:execl。
  • 参数固定,同时希望 PATH 搜索:execlp。
  • 参数需要动态组织:execv。
  • 参数需要动态组织,同时希望 PATH 搜索:execvp。
  • 需要自己指定环境变量:execle 或 execvpe。

这样一来,六个接口就不再是六个完全独立的函数,而是由几种功能组合起来的。

六、exec 成功为什么不返回?

前面六个接口已经介绍完了,但还有一个非常重要的问题没有解决:

我们调用 exec 之后,新程序开始执行,那么原来的程序还能不能继续往下执行?

例如:

cpp 复制代码
execl("/usr/bin/ls", "ls", "-l", NULL);

cout << "exec 执行结束" << endl;

这句 cout 有没有机会执行?

答案是:如果 exec 成功,它就不会执行。

为什么?

我们再回到进程程序替换的本质。

调用 exec 之前,当前进程执行的是原来的程序:

text 复制代码
原程序:

main()
  |
  | 执行原来的代码
  |
  | execl()
  |
  | cout << "exec 执行结束"
  |
  ↓

但是,当 exec 成功之后,原来的程序映像已经被替换了。

原来 main 函数中的代码、数据以及后续执行逻辑都不再属于当前进程的新程序。

所以,原来的程序根本没有机会再从 exec 调用的位置返回。

此时,CPU 已经开始执行新程序的代码了。

因此,exec 有一个很特殊的返回值特点:

  • 调用成功:不返回。
  • 调用失败:返回 -1,并设置 errno。

这里说的不返回,不是说进程消失了,而是原来的程序不再继续执行,原进程已经开始运行新程序。

6.1 为什么 exec 后面通常要写错误处理?

看下面这段代码:

cpp 复制代码
execvp("ls", argv);

perror("exec failed");
exit(1);

为什么 exec 后面还要写错误处理?

因为 exec 只有失败时才会返回。

如果 exec 成功,程序已经进入新程序,不可能再执行下面的代码。

如果执行到了:

cpp 复制代码
perror("exec failed");

就说明前面的 exec 调用失败了。

所以,exec 后面通常直接跟错误处理代码。

例如:

cpp 复制代码
if (id == 0)
{
    execl("/usr/bin/ls", "ls", "-l", NULL);

    // 只有替换失败才会执行到这里
    perror("execl");
    _exit(127);
}

这里还有一个细节:

exec 失败时,当前进程仍然是原来的进程,原程序可以继续执行后面的代码。

因此我们必须主动处理失败情况,否则子进程可能继续执行原来的逻辑。

这也是为什么不能简单地认为只要调用了 exec,进程就一定完成了程序替换。

七、谁才是真正的系统调用?

前面介绍了六个 exec 接口,但实际上 Linux 底层并不是为每一个接口都提供了一套独立的程序替换机制。

真正需要了解的是:

execve() 才是 Linux 提供的核心程序执行系统调用接口。

它的原型是:

cpp 复制代码
int execve(const char *pathname,
           char *const argv[],
           char *const envp[]);

三个参数分别表示:

  • pathname:要执行的程序路径。
  • argv:传递给新程序的命令行参数数组。
  • envp:传递给新程序的环境变量数组。

可以把前面学习的函数理解为不同的使用方式,最终通过 execve 完成程序替换。

例如:

text 复制代码
execl()
execlp()
execle()
execv()
execvp()
execvpe()
      |
      ↓
C 库负责参数整理、PATH 搜索等
      |
      ↓
execve()
      |
      ↓
Linux 内核执行程序替换

那么为什么还要提供这么多接口呢?

因为不同的编程场景对参数传递方式和程序查找方式有不同需求。

例如:

  • 想逐个传参数,可以使用 execl。
  • 想通过 PATH 搜索,可以使用 execvp。
  • 想指定环境变量,可以使用 execle。

如果所有操作都直接使用 execve,就需要自己准备完整的参数数组和环境变量数组,并且自己处理程序路径。

使用封装好的接口,可以减少很多重复工作。

这里还可以通过 man 手册验证:

bash 复制代码
man 2 execve

查看 execve 的系统调用说明。

bash 复制代码
man 3 exec

查看 exec 函数族的库函数说明。

需要注意,execvpe 属于 GNU 扩展接口,并不是所有系统都保证提供。

八、exec 与环境变量的关系

前面学习六个接口时,我们发现有两个函数带有 e:

cpp 复制代码
execle()
execvpe()

它们都允许我们自己指定新程序的环境变量表。

那么,为什么有时候不需要传递环境变量,新程序也可以直接获取 PATH、HOME 等信息?

这就要联系之前学习过的环境变量。

8.1 普通 exec 函数的环境变量从哪里来?

我们先回忆一下:

之前学习 fork() 的时候,知道子进程会继承父进程的相关资源。

其中就包括环境变量。

例如:

text 复制代码
父进程 A
环境变量:
    PATH=/usr/bin:/bin
    HOME=/home/user
        |
        | fork()
        ↓
子进程 B
环境变量:
    PATH=/usr/bin:/bin
    HOME=/home/user

子进程创建时,会获得父进程环境变量的副本。

所以,子进程本身就有一份自己的环境变量。

那么,当子进程调用普通 exec 函数时,新程序的环境变量从哪里来?

答案是:

不带 e 的 exec 接口,默认使用调用进程当前的环境变量。

例如:

cpp 复制代码
execv("/usr/bin/env", argv);

虽然这里没有传递环境变量数组,但新程序仍然能够获得当前进程的环境变量。

这是因为 C 库封装这些接口时,会默认使用当前进程的环境变量。

8.2 environ 是什么?

之前学习环境变量时,我们还接触过:

cpp 复制代码
extern char** environ;

这个变量非常重要。

environ 是一个指针变量,指向当前进程的环境变量字符串数组。

数组中的每一个元素都是一个字符串,通常采用:

text 复制代码
KEY=VALUE

这样的形式。

例如:

text 复制代码
PATH=/usr/bin:/bin
HOME=/home/user
USER=user

可以把它理解为:

text 复制代码
environ
   |
   ↓
+----------------------+
| "PATH=/usr/bin:/bin" |
+----------------------+
| "HOME=/home/user"    |
+----------------------+
| "USER=user"          |
+----------------------+
| NULL                 |
+----------------------+

这里的 extern 表示当前文件要使用一个在其他地方定义的外部变量,并不是在这里重新创建了一份环境变量。

也就是说,environ 并不是执行到这行代码时才产生的,也不是 PCB 中的一个普通成员字段。

它是 C 运行库提供的、用于访问当前进程环境变量表的接口。

我们可以通过下面的代码查看环境变量:

cpp 复制代码
#include <iostream>
#include <unistd.h>

using namespace std;

extern char** environ;

int main()
{
    for (int i = 0; environ[i] != NULL; i++)
    {
        cout << environ[i] << endl;
    }

    return 0;
}

这里通过遍历 environ,就可以看到当前进程拥有的环境变量。

那么,如果我们调用带 e 的 exec 函数,又会发生什么呢?

8.3 带 e 的 exec 函数为什么会覆盖环境变量?

先看:

cpp 复制代码
execvpe("./other", argv, environ);

这里第三个参数就是环境变量表。

假设当前进程拥有:

text 复制代码
PATH=/usr/bin:/bin
HOME=/home/user
USER=user

如果我们把 environ 传给 execvpe,那么新程序就能获得这份环境变量表。

但如果我们自己创建一份新的环境变量数组:

cpp 复制代码
char env1[] = "MY_NAME=Yi";
char env2[] = "MY_JOB=student";

char* envp[] = {
    env1,
    env2,
    NULL
};

再调用:

cpp 复制代码
execvpe("./other", argv, envp);

那么新程序接收到的环境变量就是:

text 复制代码
MY_NAME=Yi
MY_JOB=student

原来的 PATH、HOME 等环境变量不会自动追加进来。

为什么?

因为 envp 表示的是新程序要使用的完整环境变量表,而不是在原有环境变量基础上追加几项。

所以,如果我们只传入自己创建的两个环境变量,新程序就不会自动获得原来的 PATH 和 HOME。

这里可以把两种情况对比一下:

情况一:传递当前环境变量。

cpp 复制代码
execvpe("./other", argv, environ);

新程序获得当前进程已有的环境变量。

情况二:传递自定义环境变量。

cpp 复制代码
execvpe("./other", argv, envp);

新程序获得我们自己指定的环境变量表。

因此,带 e 的接口适合需要控制新程序运行环境的场景。

8.4 putenv():在原有环境变量基础上添加新变量

如果我们只是想给新程序增加一个环境变量,而不是把原来的环境变量全部替换掉,该怎么办?

这时候就可以使用 putenv()。

先来看:

cpp 复制代码
putenv(newenv);

putenv 的作用是修改当前进程的环境变量。

例如:

cpp 复制代码
#include <iostream>
#include <cstdlib>
#include <unistd.h>

using namespace std;

extern char** environ;

int main()
{
    static char newenv[] = "MY_NAME=Yi";

    putenv(newenv);

    for (int i = 0; environ[i] != NULL; i++)
    {
        cout << environ[i] << endl;
    }

    return 0;
}

调用 putenv 之前,当前进程可能只有:

text 复制代码
PATH=/usr/bin:/bin
HOME=/home/user
USER=user

调用之后,环境变量中就增加了:

text 复制代码
MY_NAME=Yi

这样我们就可以在调用 exec 之前,先修改当前进程的环境变量,再将 environ 传递给新程序。

例如:

cpp 复制代码
putenv(newenv);

execvpe("./other", argv, environ);

这样,新程序就能同时获得原来的环境变量和我们新添加的环境变量。

这里有一个容易忽略的细节:

putenv() 使用传入的字符串作为环境变量条目,不能随意传入一个即将销毁的局部临时字符串,也不能在不理解其影响的情况下释放或修改它。

上面的例子使用 static,就是为了让字符串在程序运行期间一直有效。

如果只是想安全地增加一个环境变量,实际开发中也经常使用 setenv():

cpp 复制代码
setenv("MY_NAME", "Yi", 1);

它会将变量名和值复制到环境变量管理中,使用起来通常更加方便。

8.5 环境变量为什么只能向子进程传递,不能反向传递?

这个问题可以结合进程之间的父子关系理解。

假设现在有三个进程:

text 复制代码
       A
       |
       | fork()
       ↓
       B
       |
       | fork()
       ↓
       C

A 创建 B,B 又创建 C。

那么:

  • B 创建时,会继承 A 当时的环境变量。
  • C 创建时,会继承 B 当时的环境变量。

现在假设 B 调用:

cpp 复制代码
putenv(newenv);

给自己增加一个环境变量:

text 复制代码
MY_NAME=Yi

之后 B 再调用 fork 创建 C。

那么 C 就可以获得这个环境变量。

但是 A 能不能获得?

不能。

为什么?

因为 fork 创建子进程时,子进程继承的是父进程当时的环境变量,而不是父子进程实时共享同一份环境变量。

B 修改自己的环境变量,不会反过来修改 A 的环境变量。

可以这样理解:

text 复制代码
A:PATH、HOME
       |
       | fork()
       ↓
B:PATH、HOME
  + MY_NAME
       |
       | fork()
       ↓
C:PATH、HOME、MY_NAME

B 的修改可以通过后续创建子进程的方式向下传递,但不会自动反向传递给 A。

这就是环境变量的单向继承关系。

如果确实需要让子进程向父进程传递信息,就不能依靠环境变量,需要使用管道、共享内存、消息队列等进程间通信方式。

九、进程程序替换支持跨语言执行吗?

前面我们一直使用 ls 作为例子,它是一个 Linux 下的可执行程序。

那么,exec 是不是只能执行 C/C++ 编译出来的程序?

当然不是。

实际上,exec 还可以执行 Shell、Python 等脚本。

这就涉及一个问题:

Python 文件本身只是源代码,并不是 CPU 能够直接执行的机器指令,那么操作系统是怎么执行它的?

9.1 Python 脚本是怎么运行起来的?

先准备一个 Python 文件:

myscript.py

python 复制代码
#!/usr/bin/env python3

print("hello python")

这里第一行:

python 复制代码
#!/usr/bin/env python3

叫作 Shebang。

它告诉操作系统,这个脚本应该交给哪个解释器处理。

这里指定的是:

text 复制代码
/usr/bin/env

而 python3 是传递给 env 的参数。

env 会通过 PATH 查找 python3,然后启动对应的 Python 解释器。

整个过程可以理解为:

text 复制代码
执行 myscript.py
       |
       ↓
内核识别文件首行的 Shebang
       |
       ↓
启动 /usr/bin/env
       |
       ↓
env 根据 PATH 查找 python3
       |
       ↓
启动 Python 解释器
       |
       ↓
Python 解释器读取 myscript.py
       |
       ↓
执行 Python 代码

所以,真正被 CPU 执行的是 Python 解释器程序,而不是直接把 Python 源代码当成机器指令执行。

这也解释了为什么 exec 不仅可以执行 C/C++ 程序,还可以执行 Python、Shell 等脚本。

9.2 脚本为什么需要可执行权限?

我们在 Linux 中创建好 Python 脚本之后,还需要给它增加可执行权限:

bash 复制代码
chmod +x myscript.py

这里的 +x 表示给文件增加执行权限。

然后就可以直接通过:

bash 复制代码
./myscript.py

执行脚本。

为什么需要这个权限?

因为我们现在是希望操作系统直接执行这个脚本文件,而不是仅仅把它当作普通文本文件读取。

当然,也可以不设置可执行权限,直接使用:

bash 复制代码
python3 myscript.py

这种方式是先启动 Python 解释器,再把脚本作为参数交给解释器。

两种方式的启动过程有所不同,但最终都是由 Python 解释器执行源代码。

9.3 使用 exec 执行 Python 脚本

下面通过一个简单的 C++ 程序,演示使用 exec 替换成 Python 脚本。

目录结构:

text 复制代码
test/
├── main.cpp
└── myscript.py

Python 脚本:

python 复制代码
#!/usr/bin/env python3

print("hello python")

C++ 程序:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdlib>
#include <cstdio>

using namespace std;

int main()
{
    pid_t id = fork();

    if (id < 0)
    {
        perror("fork");
        return 1;
    }

    if (id == 0)
    {
        // 子进程替换为 Python 脚本
        execl("./myscript.py", "myscript.py", NULL);

        // 如果执行到这里,说明替换失败
        perror("execl");
        _exit(127);
    }

    // 父进程等待子进程
    int status = 0;

    if (waitpid(id, &status, 0) > 0)
    {
        cout << "子进程执行结束" << endl;
    }

    return 0;
}

编译:

bash 复制代码
g++ main.cpp -o main

赋予脚本执行权限:

bash 复制代码
chmod +x myscript.py

运行:

bash 复制代码
./main

输出:

text 复制代码
hello python
子进程执行结束

这里发生的事情就是:

  1. main 程序调用 fork 创建子进程。
  2. 子进程调用 execl,准备执行 Python 脚本。
  3. 内核识别 Shebang,找到对应的解释器。
  4. Python 解释器开始执行脚本。
  5. 脚本执行结束,子进程退出。
  6. 父进程通过 waitpid 回收子进程。

从这个例子可以看到,exec 并不关心新程序是用什么语言编写的。

只要目标文件能够按照操作系统支持的可执行格式或脚本解释机制被正确加载,就可以完成程序替换。

十、把整篇知识联系起来

学到这里,我们已经认识了 exec 的作用、底层原理、六个接口以及环境变量的传递方式。

最后再把这些知识串起来。

首先,我们通过 fork 创建子进程。

子进程继承父进程的相关资源,但拥有自己的进程身份和地址空间。

接下来,如果子进程想要执行一个新的程序,就可以调用 exec。

exec 成功后,原来的程序映像被替换,新程序开始执行,但进程 PID 不变。

如果新程序执行结束,子进程就会退出。

父进程则可以通过 waitpid 等待并回收子进程。

完整过程:

text 复制代码
             父进程
                |
                |
              fork()
                |
        +-------+-------+
        |               |
        ↓               ↓
      父进程           子进程
        |               |
        |             exec()
        |               |
        |               ↓
        |          程序映像替换
        |               |
        |               ↓
        |          执行新程序
        |               |
        |               ↓
        |            进程退出
        |               |
        +---- waitpid --+
                |
                ↓
          回收子进程

再回到最开始的问题:

为什么 Linux 中经常使用 fork + exec?

因为 fork 负责创建一个新的进程,exec 负责让这个进程执行新的程序。

两者配合,就可以在不影响父进程原有程序的情况下,让子进程完成新的任务。

而这正是 Shell 执行命令时使用的基本思路。


本篇知识总结

这次学习的内容可以归纳为以下几个重点:

1. 进程程序替换的本质

exec 不创建新进程,而是替换当前进程的程序映像。替换前后 PID 不变。

2. fork 和 exec 的区别

fork 负责创建子进程,exec 负责让当前进程执行新的程序。

3. exec 函数族的命名规律

  • l:可变参数列表。
  • v:指针数组。
  • p:PATH 搜索。
  • e:指定环境变量。

4. exec 的返回值

成功不返回,失败返回 -1,并设置 errno。

5. 环境变量传递

普通 exec 接口默认继承调用进程的环境变量;带 e 的接口使用指定的环境变量表。

6. 跨语言程序替换

exec 不仅能执行 C/C++ 编译出来的程序,还可以通过 Shebang 执行 Shell、Python 等脚本。

最后,记住这三个函数的分工:

函数 解决的问题
fork() 创建一个新的进程
exec() 替换当前进程的程序
waitpid() 等待并回收子进程

到这里,Linux 进程程序替换的基本原理和 exec 函数族就学习完了。

真正理解这些内容之后,再回头看 Shell 执行命令的过程,就不会觉得它只是在简单地调用一个函数了。

它背后其实涉及进程创建、程序加载、地址空间替换、环境变量传递以及进程回收等一系列操作。

学习 Linux,不能只知道一个函数怎么用,还要知道操作系统为什么要这样设计。

相关推荐
tellmewhoisi36 分钟前
深度学习0:数学知识:正态分布1
人工智能·深度学习·算法
-梅37 分钟前
算法(1) 动态规划
算法·动态规划
沫璃染墨38 分钟前
从零入门计算机网络系列(十五):TCP可靠传输机制详解——从序号与确认应答到乱序处理
服务器·网络·c++·tcp/ip·tcp
青少儿编程课堂1 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:Bellman-Ford与SPFA含负权最短路及负环检测全解析
c++·python·算法·bfs·信息学竞赛
叼烟扛炮1 小时前
[LangChain] 学习笔记(03):打破失忆症——对话机制与上下文管理
java·前端·javascript·笔记·学习·langchain
景煜拾年1 小时前
InnoAI SQL 助手
运维·数据库·笔记·sql
旖旎夜光1 小时前
【C++ 基础入门】类和对象(下):初始化列表、static 成员、友元与内部类
c++·笔记·学习
j7~1 小时前
【C++ 八股文】(篇四)C++11 新特性
开发语言·c++·面试·c++11·八股文
无人生还别怕1 小时前
如何搭建docker仓库(轻量级)registry
linux·运维·docker·云原生·容器·registry·openanolisos