Linux 下“Hello World”的幕后发生了什么
今天我在想 —— 当你在 Linux 上运行一个简单的 “Hello World” Python 程序时,发生了什么,就像下面这个?
print("hello world")
这就是在命令行下的情况:
$ python3 hello.py
hello world
但是在幕后,实际上有更多的事情在发生。我将描述一些发生的情况,并且(更重要的是)解释一些你可以用来查看幕后情况的工具。我们将用 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
。对我来说,它的输出如下:
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
当执行一个命令时,Shell 将会依序在 PATH
列表中的每个目录里搜索匹配的文件。
对于 fish
(我的 Shell),你可以在 这里 查看路径解析的逻辑。它使用 stat
系统调用去检验是否存在文件。
自行验证:执行 strace -e stat bash
,然后运行像 python3
这样的命令。你应该会看到如下输出:
stat("/usr/local/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/local/bin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/bin/python3", {st_mode=S_IFREG|0755, st_size=5479736, ...}) = 0
你可以观察到,一旦在 /usr/bin/python3
找到了二进制文件,搜索就会立即终止:它不会继续去 /sbin
或 /bin
中查找。
对 execvp 的补充说明
如果你想要不用自己重新实现,而运行和 Shell 同样的 PATH
搜索逻辑,你可以使用 libc 函数 execvp
(或其它一些函数名中含有 p
的 exec*
函数)。
3、stat 的背后运作机制
你可能在思考,Julia,stat
到底做了什么?当你的操作系统要打开一个文件时,主要分为两个步骤:
- 它将 文件名 映射到一个包含该文件元数据的 inode
- 它利用这个 inode 来获取文件的实际内容
stat
系统调用只是返回文件的 inode 内容 —— 它并不读取任何的文件内容。好处在于这样做速度非常快。接下来让我们一起来快速了解一下 inode。(在 Dmitry Mazin 的这篇精彩文章 《磁盘就是一堆比特》中有更多的详细内容)
$ stat /usr/bin/python3
File: /usr/bin/python3 -> python3.9
Size: 9 Blocks: 0 IO Block: 4096 symbolic link
Device: fe01h/65025d Inode: 6206 Links: 1
Access: (0777/lrwxrwxrwx) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2023-08-03 14:17:28.890364214 +0000
Modify: 2021-04-05 12:00:48.000000000 +0000
Change: 2021-06-22 04:22:50.936969560 +0000
Birth: 2021-06-22 04:22:50.924969237 +0000
自行验证:我们来实际查看一下硬盘上 inode 的确切位置。
首先,我们需要找出硬盘的设备名称:
$ df
...
tmpfs 100016 604 99412 1% /run
/dev/vda1 25630792 14488736 10062712 60% /
...
看起来它是 /dev/vda1
。接着,让我们寻找 /usr/bin/python3
的 inode 在我们硬盘上的确切位置(在 debugfs 提示符下输入 imap
命令):
$ sudo debugfs /dev/vda1
debugfs 1.46.2 (28-Feb-2021)
debugfs: imap /usr/bin/python3
Inode 6206 is part of block group 0
located at block 658, offset 0x0d00
我不清楚 debugfs
是如何确定文件名对应的 inode 的位置,但我们暂时不需要深入研究这个。
现在,我们需要计算硬盘中 “块 658,偏移量 0x0d00” 处是多少个字节,这个大的字节数组就是你的硬盘。每个块有 4096 个字节,所以我们需要到 4096 * 658 + 0x0d00
字节。使用计算器可以得到,这个值是 2698496
。
$ sudo dd if=/dev/vda1 bs=1 skip=2698496 count=256 2>/dev/null | hexdump -C
00000000 ff a1 00 00 09 00 00 00 f8 b6 cb 64 9a 65 d1 60 |...........d.e.`|
00000010 f0 fb 6a 60 00 00 00 00 00 00 01 00 00 00 00 00 |..j`............|
00000020 00 00 00 00 01 00 00 00 70 79 74 68 6f 6e 33 2e |........python3.|
00000030 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |9...............|
00000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000060 00 00 00 00 12 4a 95 8c 00 00 00 00 00 00 00 00 |.....J..........|
00000070 00 00 00 00 00 00 00 00 00 00 00 00 2d cb 00 00 |............-...|
00000080 20 00 bd e7 60 15 64 df 00 00 00 00 d8 84 47 d4 | ...`.d.......G.|
00000090 9a 65 d1 60 54 a4 87 dc 00 00 00 00 00 00 00 00 |.e.`T...........|
000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
好极了!我们找到了 inode!你可以在里面看到 python3
,这是一个很好的迹象。我们并不打算深入了解所有这些,但是 Linux 内核的 ext4 inode 结构 指出,前 16 位是 “模式”,即权限。所以现在我们将看一下 ffa1
如何对应到文件权限。
ffa1
对应的数字是0xa1ff
,或者 41471(因为 x86 是小端表示)- 41471 用八进制表示就是
0120777
- 这有些奇怪 - 那个文件的权限肯定可以是
777
,但前三位是什么呢?我以前没见过这些!你可以在 inode 手册页 中找到012
的含义(向下滚动到“文件类型和模式”)。这里有一个小的表格说012
表示 “符号链接”。
我们查看一下这个文件,确实是一个权限为 777
的符号链接:
$ ls -l /usr/bin/python3
lrwxrwxrwx 1 root root 9 Apr 5 2021 /usr/bin/python3 -> python3.9
它确实是!耶,我们正确地解码了它。
4、准备复刻
我们尚未准备好启动 python3
。首先,Shell 需要创建一个新的子进程来进行运行。在 Unix 上,新的进程启动的方式有些特殊 - 首先进程克隆自己,然后运行 execve
,这会将克隆的进程替换为新的进程。
自行验证: 运行 strace -e clone bash
,然后运行 python3
。你应该会看到类似下面的输出:
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f03788f1a10) = 3708100
3708100
是新进程的 PID,这是 Shell 进程的子进程。
这里有些工具可以查看进程的相关信息:
pstree
会展示你的系统中所有进程的树状图cat /proc/PID/stat
会显示一些关于该进程的信息。你可以在man proc
中找到这个文件的内容说明。例如,第四个字段是父进程的PID。
新进程的继承
新的进程(即将变为 python3
的)从 Shell 中继承了很多内容。例如,它继承了:
- 环境变量:你可以通过
cat /proc/PID/environ | tr ' ' 'n'
查看 - 标准输出和标准错误的文件描述符:通过
ls -l /proc/PID/fd
查看 - 工作目录(也就是当前目录)
- 命名空间和控制组(如果它在一个容器内)
- 运行它的用户以及群组
- 还有可能是我此刻未能列举出来的更多东西
5、Shell 调用 execve
现在我们准备好启动 Python 解释器了!
自行验证:运行 strace -f -e execve bash
,接着运行 python3
。其中的 -f
参数非常重要,因为我们想要跟踪任何可能产生的子进程。你应该可以看到如下的输出:
[pid 3708381] execve("/usr/bin/python3", ["python3"], 0x560397748300 /* 21 vars */) = 0
第一个参数是这个二进制文件,而第二个参数是命令行参数列表。这些命令行参数被放置在程序内存的特定位置,以便在运行时可以访问。
那么,execve
内部到底发生了什么呢?
6、获取该二进制文件的内容
我们首先需要打开 python3
的二进制文件并读取其内容。直到目前为止,我们只使用了 stat
系统调用来获取其元数据,但现在我们需要获取它的内容。
让我们再次查看 stat
的输出:
$ stat /usr/bin/python3
File: /usr/bin/python3 -> python3.9
Size: 9 Blocks: 0 IO Block: 4096 symbolic link
Device: fe01h/65025d Inode: 6206 Links: 1
...
该文件在磁盘上占用 0 个块的空间。这是因为符号链接(python3.9
)的内容实际上是存储在 inode 自身中:在下面显示你可以看到(来自上述 inode 的二进制内容,以 hexdump
格式分为两行输出)。
00000020 00 00 00 00 01 00 00 00 70 79 74 68 6f 6e 33 2e |........python3.|
00000030 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |9...............|
因此,我们将需要打开 /usr/bin/python3.9
。所有这些操作都在内核内部进行,所以你并不会看到其他的系统调用。
每个文件都由硬盘上的一系列的 块 构成。我知道我系统中的每个块是 4096 字节,所以一个文件的最小大小是 4096 字节 —— 甚至如果文件只有 5 字节,它在磁盘上仍然占用 4KB。
自行验证:我们可以通过 debugfs
找到块号,如下所示:(再次说明,我从 Dmitry Mazin 的《磁盘就是一堆比特》文章中得知这些步骤)。
$ debugfs /dev/vda1
debugfs: blocks /usr/bin/python3.9
145408 145409 145410 145411 145412 145413 145414 145415 145416 145417 145418 145419 145420 145421 145422 145423 145424 145425 145426 145427 145428 145429 145430 145431 145432 145433 145434 145435 145436 145437
接下来,我们可以使用 dd
来读取文件的第一个块。我们将块大小设定为 4096 字节,跳过 145408
个块,然后读取 1 个块。
$ dd if=/dev/vda1 bs=4096 skip=145408 count=1 2>/dev/null | hexdump -C | head
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 3e 00 01 00 00 00 c0 a5 5e 00 00 00 00 00 |..>.......^.....|
00000020 40 00 00 00 00 00 00 00 b8 95 53 00 00 00 00 00 |@.........S.....|
00000030 00 00 00 00 40 00 38 00 0b 00 40 00 1e 00 1d 00 |....@.8...@.....|
00000040 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 |........@.......|
00000050 40 00 40 00 00 00 00 00 40 00 40 00 00 00 00 00 |@.@.....@.@.....|
00000060 68 02 00 00 00 00 00 00 68 02 00 00 00 00 00 00 |h.......h.......|
00000070 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 |................|
00000080 a8 02 00 00 00 00 00 00 a8 02 40 00 00 00 00 00 |..........@.....|
00000090 a8 02 40 00 00 00 00 00 1c 00 00 00 00 00 00 00 |..@.............|
你会发现,这样我们得到的输出结果与直接使用 cat
读取文件所获得的结果完全一致。
$ cat /usr/bin/python3.9 | hexdump -C | head
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 3e 00 01 00 00 00 c0 a5 5e 00 00 00 00 00 |..>.......^.....|
00000020 40 00 00 00 00 00 00 00 b8 95 53 00 00 00 00 00 |@.........S.....|
00000030 00 00 00 00 40 00 38 00 0b 00 40 00 1e 00 1d 00 |....@.8...@.....|
00000040 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 |........@.......|
00000050 40 00 40 00 00 00 00 00 40 00 40 00 00 00 00 00 |@.@.....@.@.....|
00000060 68 02 00 00 00 00 00 00 68 02 00 00 00 00 00 00 |h.......h.......|
00000070 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 |................|
00000080 a8 02 00 00 00 00 00 00 a8 02 40 00 00 00 00 00 |..........@.....|
00000090 a8 02 40 00 00 00 00 00 1c 00 00 00 00 00 00 00 |..@.............|
关于魔术数字的额外说明
这个文件以 ELF
开头,这是一个被称为“ 魔术数字 ”的标识符,它是一种字节序列,告诉我们这是一个 ELF 文件。在 Linux 上,ELF 是二进制文件的格式。
不同的文件格式有不同的魔术数字。例如,gzip 的魔数是 1f8b
。文件开头的魔术数字就是 file blah.gz
如何识别出它是一个 gzip 文件的方式。
我认为 file
命令使用了各种启发式方法来确定文件的类型,而其中,魔术数字是一个重要的特征。
7、寻找解释器
我们来解析这个 ELF 文件,看看里面都有什么内容。
自行验证:运行 readelf -a /usr/bin/python3.9
。我得到的结果是这样的(但是我删减了大量的内容):
$ readelf -a /usr/bin/python3.9
ELF Header:
Class: ELF64
Machine: Advanced Micro Devices X86-64
...
-> Entry point address: 0x5ea5c0
...
Program Headers:
Type Offset VirtAddr PhysAddr
INTERP 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
0x000000000000001c 0x000000000000001c R 0x1
-> [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
...
-> 1238: 00000000005ea5c0 43 FUNC GLOBAL DEFAULT 13 _start
从这段内容中,我理解到:
- 请求内核运行
/lib64/ld-linux-x86-64.so.2
来启动这个程序。这就是所谓的动态链接器,我们将在随后的部分对其进行讨论。 - 该程序制定了一个入口点(位于
0x5ea5c0
),那里是这个程序代码开始的地方。
接下来,让我们一起来聊聊动态链接器。
8、动态链接
好的!我们已从磁盘读取了字节数据,并启动了这个“解释器”。那么,接下来会发生什么呢?如果你执行 strace -o out.strace python3
,你会在 execve
系统调用之后观察到一系列的信息:
execve("/usr/bin/python3", ["python3"], 0x560af13472f0 /* 21 vars */) = 0
brk(NULL) = 0xfcc000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=32091, ...}) = 0
mmap(NULL, 32091, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f718a1e3000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "177ELF211 3 >