Linux 下“Hello World”的幕后发生了什么
今天我在想 —— 当你在 Linux 上运行一个简单的 “Hello World” Python 程序时,发生了什么,就像下面这个?
1 |
|
这就是在命令行下的情况:
1 |
|
但是在幕后,实际上有更多的事情在发生。我将描述一些发生的情况,并且(更重要的是)解释一些你可以用来查看幕后情况的工具。我们将用 readelf
、strace
、ldd
、debugfs
、/proc
、ltrace
、dd
和 stat
。我不会讨论任何只针对 Python 的部分 —— 只研究一下当你运行任何动态链接的可执行文件时发生的事情。
0、在执行 execve 之前
要启动 Python 解释器,很多步骤都需要先行完成。那么,我们究竟在运行哪一个可执行文件呢?它在何处呢?
1、解析 python3 hello.py
Shell 将 python3 hello.py
解析成一条命令和一组参数:python3
和 ['hello.py']
。
在此过程中,可能会进行一些如全局扩展等操作。举例来说,如果你执行 python3 *.py
,Shell 会将其扩展到 python3 hello.py
。
2、确认 python3 的完整路径
现在,我们了解到需要执行 python3
。但是,这个二进制文件的完整路径是什么呢?解决办法是使用一个名为 PATH
的特殊环境变量。
自行验证:在你的 Shell 中执行 echo $PATH
。对我来说,它的输出如下:
1 |
|
当执行一个命令时,Shell 将会依序在 PATH
列表中的每个目录里搜索匹配的文件。
对于 fish
(我的 Shell),你可以在 这里 查看路径解析的逻辑。它使用 stat
系统调用去检验是否存在文件。
自行验证:执行 strace -e stat bash
,然后运行像 python3
这样的命令。你应该会看到如下输出:
1 |
|
你可以观察到,一旦在 /usr/bin/python3
找到了二进制文件,搜索就会立即终止:它不会继续去 /sbin
或 /bin
中查找。
对 execvp 的补充说明
如果你想要不用自己重新实现,而运行和 Shell 同样的 PATH
搜索逻辑,你可以使用 libc 函数 execvp
(或其它一些函数名中含有 p
的 exec*
函数)。
3、stat 的背后运作机制
你可能在思考,Julia,stat
到底做了什么?当你的操作系统要打开一个文件时,主要分为两个步骤:
- 它将 文件名 映射到一个包含该文件元数据的 inode
- 它利用这个 inode 来获取文件的实际内容
stat
系统调用只是返回文件的 inode 内容 —— 它并不读取任何的文件内容。好处在于这样做速度非常快。接下来让我们一起来快速了解一下 inode。(在 Dmitry Mazin 的这篇精彩文章 《磁盘就是一堆比特》中有更多的详细内容)
1 |
|
自行验证:我们来实际查看一下硬盘上 inode 的确切位置。
首先,我们需要找出硬盘的设备名称:
1 |
|
看起来它是 /dev/vda1
。接着,让我们寻找 /usr/bin/python3
的 inode 在我们硬盘上的确切位置(在 debugfs 提示符下输入 imap
命令):
1 |
|
我不清楚 debugfs
是如何确定文件名对应的 inode 的位置,但我们暂时不需要深入研究这个。
现在,我们需要计算硬盘中 “块 658,偏移量 0x0d00” 处是多少个字节,这个大的字节数组就是你的硬盘。每个块有 4096 个字节,所以我们需要到 4096 * 658 + 0x0d00
字节。使用计算器可以得到,这个值是 2698496
。
1 |
|
好极了!我们找到了 inode!你可以在里面看到 python3
,这是一个很好的迹象。我们并不打算深入了解所有这些,但是 Linux 内核的 ext4 inode 结构 指出,前 16 位是 “模式”,即权限。所以现在我们将看一下 ffa1
如何对应到文件权限。
ffa1
对应的数字是0xa1ff
,或者 41471(因为 x86 是小端表示)- 41471 用八进制表示就是
0120777
- 这有些奇怪 - 那个文件的权限肯定可以是
777
,但前三位是什么呢?我以前没见过这些!你可以在 inode 手册页 中找到012
的含义(向下滚动到“文件类型和模式”)。这里有一个小的表格说012
表示 “符号链接”。
我们查看一下这个文件,确实是一个权限为 777
的符号链接:
1 |
|
它确实是!耶,我们正确地解码了它。
4、准备复刻
我们尚未准备好启动 python3
。首先,Shell 需要创建一个新的子进程来进行运行。在 Unix 上,新的进程启动的方式有些特殊 - 首先进程克隆自己,然后运行 execve
,这会将克隆的进程替换为新的进程。
自行验证: 运行 strace -e clone bash
,然后运行 python3
。你应该会看到类似下面的输出:
1 |
|
3708100
是新进程的 PID,这是 Shell 进程的子进程。
这里有些工具可以查看进程的相关信息:
pstree
会展示你的系统中所有进程的树状图cat /proc/PID/stat
会显示一些关于该进程的信息。你可以在man proc
中找到这个文件的内容说明。例如,第四个字段是父进程的PID。
新进程的继承
新的进程(即将变为 python3
的)从 Shell 中继承了很多内容。例如,它继承了:
- 环境变量:你可以通过
cat /proc/PID/environ | tr '\0' '\n'
查看 - 标准输出和标准错误的文件描述符:通过
ls -l /proc/PID/fd
查看 - 工作目录(也就是当前目录)
- 命名空间和控制组(如果它在一个容器内)
- 运行它的用户以及群组
- 还有可能是我此刻未能列举出来的更多东西
5、Shell 调用 execve
现在我们准备好启动 Python 解释器了!
自行验证:运行 strace -f -e execve bash
,接着运行 python3
。其中的 -f
参数非常重要,因为我们想要跟踪任何可能产生的子进程。你应该可以看到如下的输出:
1 |
|
第一个参数是这个二进制文件,而第二个参数是命令行参数列表。这些命令行参数被放置在程序内存的特定位置,以便在运行时可以访问。
那么,execve
内部到底发生了什么呢?
6、获取该二进制文件的内容
我们首先需要打开 python3
的二进制文件并读取其内容。直到目前为止,我们只使用了 stat
系统调用来获取其元数据,但现在我们需要获取它的内容。
让我们再次查看 stat
的输出:
1 |
|
该文件在磁盘上占用 0 个块的空间。这是因为符号链接(python3.9
)的内容实际上是存储在 inode 自身中:在下面显示你可以看到(来自上述 inode 的二进制内容,以 hexdump
格式分为两行输出)。
1 |
|
因此,我们将需要打开 /usr/bin/python3.9
。所有这些操作都在内核内部进行,所以你并不会看到其他的系统调用。
每个文件都由硬盘上的一系列的 块 构成。我知道我系统中的每个块是 4096 字节,所以一个文件的最小大小是 4096 字节 —— 甚至如果文件只有 5 字节,它在磁盘上仍然占用 4KB。
自行验证:我们可以通过 debugfs
找到块号,如下所示:(再次说明,我从 Dmitry Mazin 的《磁盘就是一堆比特》文章中得知这些步骤)。
1 |
|
接下来,我们可以使用 dd
来读取文件的第一个块。我们将块大小设定为 4096 字节,跳过 145408
个块,然后读取 1 个块。
1 |
|
你会发现,这样我们得到的输出结果与直接使用 cat
读取文件所获得的结果完全一致。
1 |
|
关于魔术数字的额外说明
这个文件以 ELF
开头,这是一个被称为“ 魔术数字 ”的标识符,它是一种字节序列,告诉我们这是一个 ELF 文件。在 Linux 上,ELF 是二进制文件的格式。
不同的文件格式有不同的魔术数字。例如,gzip 的魔数是 1f8b
。文件开头的魔术数字就是 file blah.gz
如何识别出它是一个 gzip 文件的方式。
我认为 file
命令使用了各种启发式方法来确定文件的类型,而其中,魔术数字是一个重要的特征。
7、寻找解释器
我们来解析这个 ELF 文件,看看里面都有什么内容。
自行验证:运行 readelf -a /usr/bin/python3.9
。我得到的结果是这样的(但是我删减了大量的内容):
1 |
|
从这段内容中,我理解到:
- 请求内核运行
/lib64/ld-linux-x86-64.so.2
来启动这个程序。这就是所谓的动态链接器,我们将在随后的部分对其进行讨论。 - 该程序制定了一个入口点(位于
0x5ea5c0
),那里是这个程序代码开始的地方。
接下来,让我们一起来聊聊动态链接器。
8、动态链接
好的!我们已从磁盘读取了字节数据,并启动了这个“解释器”。那么,接下来会发生什么呢?如果你执行 strace -o out.strace python3
,你会在 execve
系统调用之后观察到一系列的信息:
1 |
|
这些内容初看可能让人望而生畏,但我希望你能重点关注这一部分:openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0" ...
。这里正在打开一个被称为 pthread
的 C 语言线程库,运行 Python 解释器时需要这个库。
自行验证:如果你想知道一个二进制文件在运行时需要加载哪些库,你可以使用 ldd
命令。下面展示的是我运行后的效果:
1 |
|
你可以看到,第一个列出的库就是 /lib/x86_64-linux-gnu/libpthread.so.0
,这就是它被第一个加载的原因。
关于 LD_LIBRARY_PATH
说实话,我关于动态链接的理解还有些模糊,以下是我所了解的一些内容:
- 动态链接发生在用户空间,我的系统上的动态链接器位于
/lib64/ld-linux-x86-64.so.2
. 如果你缺少动态链接器,可能会遇到一些奇怪的问题,比如这种 奇怪的“文件未找到”错误 - 动态链接器使用
LD_LIBRARY_PATH
环境变量来查找库 - 动态链接器也会使用
LD_PRELOAD
环境变量来覆盖你想要的任何动态链接函数(你可以使用它来进行 有趣的魔改,或者使用像 jemalloc 这样的替代品来替换默认内存分配器) strace
的输出中有一些mprotect
,因为安全原因将库代码标记为只读- 在 Mac 上,不是使用
LD_LIBRARY_PATH
(Linux),而是DYLD_LIBRARY_PATH
你可能会有疑问,如果动态链接发生在用户空间,我们为什么没有看到大量的 stat
系统调用在 LD_LIBRARY_PATH
中搜索这些库,就像 Bash 在 PATH
中搜索那样?
这是因为 ld
在 /etc/ld.so.cache
中有一个缓存,因此所有之前已经找到的库都会被记录在这里。你可以在 strace
的输出中看到它正在打开缓存 - openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
。
在 完整的 strace 输出 中,我仍然对动态链接之后出现的一些系统调用感到困惑(什么是 prlimit64
?本地环境的内容是如何介入的?gconv-modules.cache
是什么?rt_sigaction
做了什么?arch_prctl
是什么?以及 set_tid_address
和 set_robust_list
是什么?)。尽管如此,我觉得已经有了一个不错的开头。
旁注:ldd 实际上是一个简单的 Shell 脚本!
在 Mastodon 上,有人 指出,ldd
实际上是一个 Shell 脚本,它设置了 LD_TRACE_LOADED_OBJECTS=1
环境变量,然后启动程序。因此,你也可以通过以下方式实现相同的功能:
1 |
|
事实上,ld
也是一个可以直接运行的二进制文件,所以你也可以通过 /lib64/ld-linux-x86-64.so.2 --list /usr/bin/python3.9
来达到相同的效果。
关于 init 和 fini
让我们来谈谈这行 strace
输出中的内容:
1 |
|
这似乎与线程有关,我认为这可能是因为 pthread
库(以及所有其他动态加载的库)在加载时得以运行初始化代码。在库加载时运行的代码位于 init
区域(或者也可能是 .ctors
区域)。
自行验证:让我们使用 readelf
来看看这个:
1 |
|
这个库没有 .ctors
区域,只有一个 .init
。但是,.init
区域都有些什么内容呢?我们可以使用 objdump
来反汇编这段代码:
1 |
|
所以它在调用 __pthread_initialize_minimal
。我在 glibc 中找到了 这个函数的代码,尽管我不得不找到一个较早版本的 glibc,因为在更近的版本中,libpthread 不再是一个独立的库。
我不确定这个 set_tid_address
系统调用是否实际上来自 __pthread_initialize_minimal
,但至少我们知道了库可以通过 .init
区域在启动时运行代码。
这里有一份关于 .init 区域的 elf 手册的笔记:
1 |
|
.init
这个区域保存着对进程初始化代码有贡献的可执行指令。当程序开始运行时,系统会安排在调用主程序入口点之前执行该区域中的代码。
在 ELF 文件中也有一个在结束时运行的 .fini
区域,以及其他可以存在的区域 .ctors
/ .dtors
(构造器和析构器)。
好的,关于动态链接就说这么多。
9、转到 _start
在动态链接完成后,我们进入到 Python 解释器中的 _start
。然后,它将执行所有正常的 Python 解析器会做的事情。
我不打算深入讨论这个,因为我在这里关心的是关于如何在 Linux 上运行二进制文件的一般性知识,而不是特别针对 Python 解释器。
10、写入字符串
不过,我们仍然需要打印出 “hello world”。在底层,Python 的 print
函数调用了 libc 中的某个函数。但是,它调用了哪一个呢?让我们来找出答案!
自行验证:运行 ltrace -o out python3 hello.py
:
1 |
|
看起来它确实在调用 write
函数。
我必须承认,我对 ltrace
总是有一些疑虑 —— 与我深信不疑的 strace
不同,我总是不完全确定 ltrace
是否准确地报告了库调用。但在这个情况下,它似乎有效。并且,如果我们查阅 cpython 的源代码,它似乎在一些地方确实调用了 write()
函数,所以我倾向于相信这个结果。
什么是 libc?
我们刚刚提到,Python 调用了 libc 中的 write
函数。那么,libc 是什么呢?它是 C 的标准库,负责许多基本操作,例如:
- 用
malloc
分配内存 - 文件 I/O(打开/关闭文件)
- 执行程序(像我们之前提到的
execvp
) - 使用
getaddrinfo
查找 DNS 记录 - 使用
pthread
管理线程
在 Linux 上,程序不一定需要使用 libc(例如 Go 就广为人知地未使用它,而是直接调用了 Linux 系统调用),但是我常用的大多数其他编程语言(如 node、Python、Ruby、Rust)都使用了 libc。我不确定 Java 是否也使用了。
你能通过在你的二进制文件上执行 ldd
命令,检查你是否正在使用 libc:如果你看到了 libc.so.6
这样的信息,那么你就在使用 libc。
为什么 libc 重要?
你也许在思考 —— 为何重要的是 Python 调用 libc 的 write
函数,然后 libc 再调用 write
系统调用?为何我要着重提及 libc
是调用过程的一环?
我认为,在这个案例中,这并不真的很重要(根据我所知,libc 的 write
函数与 write
系统调用的映射相当直接)。
然而,存在不同的 libc 实现,有时它们的行为会有所不同。两个主要的实现是 glibc(GNU libc)和 musl libc。
例如,直到最近,musl 的 getaddrinfo
并不支持 TCP DNS,这是一篇关于这个问题引发的错误的博客文章。
关于 stdout 和终端的小插曲
在我们的程序中,stdout(1
文件描述符)是一个终端。你可以在终端上做一些有趣的事情!例如:
- 在终端中运行
ls -l /proc/self/fd/1
。我得到了/dev/pts/2
的结果。 - 在另一个终端窗口中,运行
echo hello > /dev/pts/2
。 - 返回到原始终端窗口。你应会看到
hello
被打印出来了!
暂时就到这儿吧!
希望通过上文,你对 hello world
是如何打印出来的有了更深的了解!我暂时不再添加更多的细节,因为这篇文章已经足够长了,但显然还有更多的细节可以探讨,如果大家能提供更多的细节,我可能会添加更多的内容。如果你有关于我在这里没提到的程序内部调用过程的任何工具推荐,我会特别高兴。
我很期待看到一份 Mac 版的解析
我对 Mac OS 的一个懊恼是,我不知道如何在这个级别上解读我的系统——当我打印 “hello world”,我无法像在 Linux 上那样,窥视背后的运作机制。我很希望看到一个深度的解析。
我所知道的一些在 Mac 下的对应工具:
ldd
->otool -L
readelf
->otool
- 有人说你可以在 Mac 上使用
dtruss
或dtrace
来代替strace
,但我尚未有足够的勇气关闭系统完整性保护来让它工作。 strace
->sc_usage
似乎能够收集关于系统调用使用情况的统计信息,fs_usage
则可以收集文件使用情况的信息。
延伸阅读
一些附加的链接:
- 快速教程:如何在 Linux 上创建超小型 ELF 可执行文件
- 在 FreeBSD 上探索 “hello world”
- [微观视角下的 Windows 中 “Hello World”][23A]
- 来自 LWN 的文章:如何运行程序 (以及第二部分)详尽介绍了
execve
的内部机制 - Lexi Mattick 的文章,赋予 CPU “你” 的存在
- 从零开始在 6502 上实现 “Hello, World” (来自 Ben Eater 的视频)
(题图:MJ/b87ed0a2-80d6-49cd-b2bf-1ef822485e3f)
via: https://jvns.ca/blog/2023/08/03/behind--hello-world/
作者:Julia Evans 选题:lujun9972 译者:ChatGPT 校对:wxy