我的 Bash 脚本中这个 @#$%&! (见鬼) 的 #! (Hash-Bang) 东西是什么

作者:Mitch Frazier

 

您已经见过无数次了——脚本顶部的 hash-bang (#!) 行——无论是 Bash、Python、Perl 还是其他脚本语言。而且,我确信您知道它的目的是什么:它指定了用于执行脚本的脚本解释器。但是,您知道它实际上是如何工作的吗?您最初的想法可能是您的 shell (bash) 读取该行,然后执行指定的解释器,但事实并非如此。本文的重点是它实际上是如何工作的,但我也想介绍如果您有兴趣,如何创建您自己的“hash-bang”版本。

当您在脚本文件上设置可执行位,然后尝试执行该文件时,文件名将直接传递给内核;shell 与解释脚本的第一行无关。文件中的前两个字符(hash 和 bang)通常(当组合成一个词时)被称为脚本文件的“魔数”。借助这个“魔数”,内核能够将文件识别为脚本,然后(内核)读取文件的第一行,启动第一行中指定的脚本解释器,并将脚本文件名传递给解释器。

可执行文件格式是内核中 “binfmt”(二进制格式)代码的一部分。脚本的 “binfmt” 处理位于文件 binfmt_script.c 中,在接近底部的位置您将看到以下代码

static struct linux_binfmt script_format = {
    .module      = THIS_MODULE,
    .load_binary = load_script,         // <<<<< script loading function
};

static int __init init_script_binfmt(void)
{
    register_binfmt(&script_format);    // <<<<< register the binfmt
    return 0;
}

在内核加载的某个时刻,会调用函数 __init_script_binfmt() 来初始化 “script” binfmt 处理程序。初始化函数将 binfmt 注册到内核,并指定应调用函数 load_script() 来尝试加载和执行脚本。内核将所有这些注册的 binfmt 放入一个名为 formats 的列表中。

如果您现在查看内核的 exec.c 代码(可执行文件在此处启动),您将看到如下代码

int search_binary_handler(struct linux_binprm *bprm)
{
    // ...
    list_for_each_entry(fmt, &formats, lh) {
        // ...
        retval = fmt->load_binary(bprm);
        // ...
    }
    // ...
}

在这里,内核正在循环遍历已注册的 binfmt 列表,依次调用每个 binfmt 的加载函数,直到其中一个识别出该文件。对于脚本,这将调用在注册脚本 binfmt 时引用的函数 load_script()。代码 fmt->load_binary() 通过指向函数的指针间接调用加载函数,这就是名称不同的原因。

如果您现在回到 binfmt_script.c,并找到 load_script() 函数,您会在其顶部看到如下代码

static int load_script(struct linux_binprm *bprm)
{
    // ...

    /* Not ours to exec if we don't start with "#!". */
    if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
        return -ENOEXEC;
    // ...
}

在这里,您可以看到代码检查缓冲区 bprm->buf 的前两个字符,以查看文件是否以字符 “#” 和 “!” (hash 和 bang)开头的位置。如果缓冲区不是以这两个字符开头,则该函数返回错误,并且内核继续在 binfmt 列表中查找可以识别该文件的 binfmt。如果文件确实以 hash-bang 开头,则该函数加载请求的解释器,将脚本文件名传递给它,然后就 大功告成

如果内核找不到可以识别该文件的 binfmt,它将向调用者返回错误。为了好玩,为了测试这一点,我尝试将一个图像文件设置为可执行文件

$ chmod +x image.png
$ ./image.png

我期望看到的是类似于 “不是可执行文件” 的错误;但我得到的却是这个

$ ./image.png
./image.png: line 1: $'\211PNG\r': command not found
./image.png: line 2: $'\032': command not found
...

这看起来很可疑,像是 bash 正在尝试解释该文件。事实证明,情况正是如此。如果您查看 bash 手册页,您会发现这个

如果此执行失败,因为文件不是可执行格式,并且该文件不是目录,则假定它是一个 shell 脚本,一个包含 shell 命令的文件。

因此 bash 假设如果内核无法执行它,那它一定是 shell 脚本。这意味着如果您仅从 bash shell 启动 shell 脚本,则实际上不必在 shell 脚本的顶部包含 "#!/bin/sh#!/bin/bash

接下来,我想看看如何创建您自己的可执行格式(无需修改 Linux 内核)。假设我是一个 yoda 程序员,并且我想反转脚本中 hash-bang 字符的顺序,换句话说,bang before hash prefer I(向 Yoda 和 George Lucas 致歉)。我可以使用杂项二进制格式 “binfmt_misc” 来做到这一点,您应该可以使用 mount 命令看到它的提示

$ mount | grep ^binfmt_misc
binfmt_misc on /proc/sys/fs/binfmt_misc type binfmt_misc (rw,relatime)

在创建任何 binfmt 之前,上面的目录包含以下内容

$ ls -la /proc/sys/fs/binfmt_misc/
total 0
--w------- 1 root root 0 May  4 11:59 register
-rw-r--r-- 1 root root 0 Apr 30 06:28 status

在我创建自己的 binfmt 之前,我需要一个 “解释器”,它将在我运行我的 yoda 脚本之一时执行。为了测试,我将使用以下 C 程序,该程序仅打印出其参数,然后将其传递给它的任何输入文件的内容复制到 stdout(本质上是标准 Linux 命令 cat 的一个版本)

#include <stdio.h>

int main(int argc, char** argv)
{
    int  i;

    // Print arguments.
    for ( i = 0; i < argc; i++ ) {
        printf("Arg %d: %s\n", i, argv[i]);
    }

    // Copy files to stdout.
    for ( i = 1; i < argc; i++ ) {
        FILE*  fd = fopen(argv[i], "r");
        if ( fd ) {
            char  s[80];
            while ( fgets(s, sizeof(s)-1, fd) ) {
                fputs(s, stdout);
            }
            fclose(fd);
        }
    }
    return 0;
}

然后我编译解释器,并将其可执行文件放在我的 bin 目录中

$ gcc -o /home/user/bin/yoda yoda.c

要创建我的 binfmt,我需要向文件写入一个配置行/proc/sys/fs/binfmt_misc/register(Wikipedia binfmt_misc 页面包含有关此配置行的良好信息)

$ su
Password: *****
# echo ':YodaFiles:M::!#::/home/user/bin/yoda:' >/proc/sys/fs/binfmt_misc/register
# exit

上面行中的 “M” 表示此 binfmt 使用魔数,行中的 “!#” (bang hash) 指定了魔数。

现在当我列出目录/proc/sys/fs/binfmt_misc/,我看到以下内容

$ ls -la /proc/sys/fs/binfmt_misc/
total 0
--w------- 1 root root 0 May  4 11:59 register
-rw-r--r-- 1 root root 0 Apr 30 06:28 status
-rw-r--r-- 1 root root 0 May  4 11:59 YodaFiles

当我查看我的 binfmt 的文件时,我可以看到它引用了我的解释器和我的魔数 (2123 == !#)

$ cat /proc/sys/fs/binfmt_misc/YodaFiles
enabled
interpreter /home/user/bin/yoda
flags:
offset 0
magic 2123

现在我可以为我的 “脚本” 设置可执行位,并让它通过我的解释器运行

$ cat test.yoda
!# powerful you have become

$ chmod +x test.yoda

$ ./test.yoda
Arg 0: /home/mitch/bin/yoda
Arg 1: ./test.yoda
!# powerful you have become

我可以通过分别向其 proc 文件写入 0 和 1 来禁用和重新启用我的 binfmt

# echo  0 >/proc/sys/fs/binfmt_misc/YodaFiles     # disable
# echo  1 >/proc/sys/fs/binfmt_misc/YodaFiles     # re-enable

我可以通过向其 proc 文件写入 -1 来删除它

# echo -1 >/proc/sys/fs/binfmt_misc/YodaFiles
# ls /proc/sys/fs/binfmt_misc/YodaFiles
ls: cannot access '/proc/sys/fs/binfmt_misc/YodaFiles': No such file or directory

如果您想创建一个持久的杂项 binfmt,您可以为其创建一个配置文件(/etc/binfmt.d/*.conf).

显然,所有这些对于为不同的文本文件格式添加解释器的用途都值得怀疑。更简单的方法是坚持使用 hash-bang 约定,并让您的解释器忽略第一行。但是,如果您有一个二进制文件,这可能不是一个选项。使用 binfmt_misc,您可以将文件与解释器关联起来。请注意,binfmt_misc 还允许您根据文件扩展名将文件与解释器关联起来,这在您的文件并不总是以该值开头时非常方便。

附注:使用标点符号指代脏话被称为 grawlix,其中 “@#$%&!” 是标准


我文章中发现的任何非来自其他来源的代码,都应被视为按以下方式授权

# Copyright 2019 Mitch Frazier <mitch -at- linuxjournal.com>
#
# This software may be used and distributed according to the terms of the
# MIT License or the GNU General Public License version 2 (or any later version).

Mitch Frazier 是 Emerson Electric Co. 的一名嵌入式系统程序员。自 2000 年代初期以来,Mitch 一直是 Linux Journal 的贡献者和朋友。

加载 Disqus 评论