27 进程的执行 exec()

exec() 函数

SYNOPSIS
#include <unistd.h>

extern char **environ;

int execl(const char *pathname, const char *arg, ...
               /*, (char *) NULL */);
int execlp(const char *file, const char *arg, ...
               /*, (char *) NULL */);
int execle(const char *pathname, const char *arg, ...
               /*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]); /* GNU 扩展 */

/* 不知道为什么单独出来一个手册页,和上面没有放在一起? */
int execve(const char *pathname, char *const _Nullable argv[],
          char *const _Nullable envp[]);

函数命名规则:

  1. 其中 exec 前缀是一定有的。
  2. 然后用 lva_list 形式)或者 v(数组形式)表示命令行参数,这也是一定有的。
  3. (可选)如果允许在 PATH 中搜索(可执行)文件,而不是用绝对或相对路径搜索文件,则有 p 后缀。
  4. (可选)如果允许(以字符串数组的形式)设置环境变量,则有 e 后缀。

如果在设置环境变量的 exec() 系列系统调用中没有提及 PATH 环境变量,则使用默认 PATH。根据系统不同,默认的 PATH 可能不同,常见的 PATH.:/usr/bin:/bin。处于安全考虑,特权用户的 PATH 中常常没有 .(当前工作目录)。

If this variable isn’t defined, the path list defaults to a list that includes the directories returned by confstr(_CS_PATH) (which typically returns the value “/bin:/usr/bin”) and possibly also the current working directory

其中 execvpe 是 GNU 扩展,需要先定义功能测试宏 _GNU_SOURCE,然后引入头文件 unistd.h 才可以使用。搜索命令使用的 PATH 是当前进程的 PATH,而不是这里提供的 PATH,所以即便是新进程的 PATH 为空也可以找到命令。如果传递参数的时候漏了 NULL 结尾标志,将出现错误:Bad address。

#define _GNU_SOURCE
#include <unistd.h>
#include <stdio.h>

int main() {
    char *argv[] = {
        (char *)"echo",
        (char *)"52",
        NULL,
    };
    char *envp[] = {
        (char *)"PATH=",
        NULL,
    };
    if (execvpe("echo", argv, envp) == -1) {
        perror("execvpe");
    }
}
// 输出:52

fexecve() 函数

是 GNU 的一个扩展,同样需要设置功能测试宏来启用。和 execve() 的区别是要执行的程序不是用路径名来查找,而是使用文件描述符。这在刚打开文件对其内容进行校验之后,再去执行文件的场景很有用。

解释器脚本(#!

如果 execve() 检查到文件是文本文件,而且开头一行是带有 #! 的特殊行,则会将文本作为一个解释器脚本来看待。格式:#! interpreter-path [optional-arg],其中可选参数是一个整体(只能有最多一个),不会像 shell 一样因为中间有空格就会被自动分割成多个参数。

常见的两种写法是:

#!/bin/bash

#!/usr/bin/env bash

所以不必为“可选参数中间的空格被保留,不用于分割参数”这种事情感到惊讶。因为我们平时真的没用到可选参数中间有空格的情况。

如果传给 execve() 的文件路径为 script-path,命令行参数为 args(这里假设已经去掉了 argv[0]),那么 execve() 真正执行的解释器程序是 interpreter-path [optional-arg] script-path args...

Linux 内核还要求起始行除了换行符之外,不得超过 127 个字符,超出则会静默抹去,不会报错。

为什么解释器脚本需要可选参数

有些解释器程序并不是默认将文件视为脚本的(pythonbashnode 都是默认将文件视为脚本,想要将字符串视为脚本反而需要加额外的参数),比如 awk 默认将字符串视为要处理的脚本,而如果脚本存储于文件中,则需要加上 -f 参数。因此,需要想要写一个 awk 脚本,那么 shebang 就应该写成 #!/usr/bin/awk -f

另外,如果用户安装的解释器不在默认位置时(比如用户的 python3 是通过 miniconda 安装的,而不是通过 apt 安装的),我们需要使用 /usr/bin/env 以在 PATH 中搜索程序(否则需要提供绝对路径,但是我们有时候确定不了绝对路径)。

execlp()execvp() 的特殊情况

这两个系统调用会从 PATH 中搜索文件名(如果提供的不是路径)。如果找到的文件是可执行的文本文件,同时开头又没有 #!,这两个函数就会假设文件最开头有一行是 #!/bin/sh。这是个特殊的例子。

文件描述符的执行时关闭标志

当使用 dup()dup2()fcntl() 为文件描述符创建副本时,总会清除副本文件描述符的执行时标志。这是 SUSv3 的要求。

信号处理器和 exec()

exec() 系统调用会替换进程的数据段和文本段,因此信号处理器函数也就会丢失,注册了信号处理器函数的信号会恢复默认处理行为。设置为忽略 / 默认行为的信号将延续其处理方式。同时由于数据段被替换,用 sigaltstack 创建的备选信号栈也会丢失。

在调用 exec() 期间,信号进程掩码和挂起的信号也被保留下来。

Tip

被保留下来的数据可以算作是进程的附属信息,而不是数据信息(进程地址空间中的信息)。

system() 函数实现的一些细节

在调用 system() 期间需要阻塞 SIGCHLD 信号,否则可能会因为主程序的信号处理器处理(等待)了子进程,而让 fork() 后的位置使用 waitpid() 等待子进程失败。

在终端中通过按键发送信号时(比如 SIGINT 和 SIGQUIT),信号会被发给终端前台进程组的所有进程(但无法确定是发给进程的哪一个线程,见 https://stackoverflow.com/a/67149780/ )。Shell 会在执行程序期间忽略 SIGINT 和 SIGQUIT,而我们的调用进程和 shell 创建出来的子进程则会因为 SIGINT 或 SIGQUIT 而终止。为了保证程序的正确运行,我们的 system() 函数也应该在调用期间忽略 SIGINT 和 SIGQUIT。

Caution

由于 system() 会在调用期间忽略 SIGINT 和 SIGQUIT,因此用 ctrl+c 只会杀掉 system() 中产生的子进程,不会杀掉 system() 的调用进程。调用进程也应该检查 system() 的返回值,决定其是否为信号所杀(大于等于 128),然后也采取相应的措施。

子进程创建成功,但是调用 exec() 失败时,应该用 _exit() 而不是 exit() 来结束进程,这样可以防止 stdio 缓冲区中的数据被刷新出来。根据 25 进程的终止 exit()_exit()fork() 出来的进程有原来的各个 stdio 流,而 exec() 之后这些缓冲区中的数据会被替换掉,所以在 exec() 之后的进程中直接 exit() 是没有问题的,而在 fork() 出来的进程中 exit() 是不妥的。