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[]);
函数命名规则:
- 其中
exec
前缀是一定有的。 - 然后用
l
(va_list
形式)或者v
(数组形式)表示命令行参数,这也是一定有的。 - (可选)如果允许在
PATH
中搜索(可执行)文件,而不是用绝对或相对路径搜索文件,则有p
后缀。 - (可选)如果允许(以字符串数组的形式)设置环境变量,则有
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 个字符,超出则会静默抹去,不会报错。
为什么解释器脚本需要可选参数
有些解释器程序并不是默认将文件视为脚本的(python
、bash
、node
都是默认将文件视为脚本,想要将字符串视为脚本反而需要加额外的参数),比如 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()
是不妥的。