---
author: Julia Evans
category: 技术
comments_data:
- date: '2023-08-29 23:22:49'
  message: 干货满满
  postip: 112.36.232.129
  username: 来自山东济南的 Chrome 116.0|Windows 10 用户
count:
  commentnum: 1
  favtimes: 0
  likes: 0
  sharetimes: 0
  viewnum: 2042
date: '2023-08-29 14:36:53'
editorchoice: false
excerpt: 当你在 Linux 上运行一个简单的 “Hello World” Python 程序时，发生了什么
fromurl: https://jvns.ca/blog/2023/08/03/behind--hello-world/
id: 16138
islctt: true
largepic: /images/p/202308/29/143604o5o22os0h20d4lz5.jpg
permalink: /article-16138-1.html
pic: /images/p/202308/29/143604o5o22os0h20d4lz5.jpg.thumb.jpg
related: []
reviewer: wxy
selector: lujun9972
summary: 当你在 Linux 上运行一个简单的 “Hello World” Python 程序时，发生了什么
tags:
- Hello World
thumb: false
title: Linux 下“Hello World”的幕后发生了什么
titlepic: true
translator: ChatGPT
updated: '2023-08-29 14:36:53'
---

![](/images/p/202308/29/143604o5o22os0h20d4lz5.jpg)


今天我在想 —— 当你在 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](http://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），你可以在 [这里](https://github.com/fish-shell/fish-shell/blob/900a0487443f10caa6539634ca8c49fb6e3ce5ba/src/path.cpp#L31-L45) 查看路径解析的逻辑。它使用 `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` 到底做了什么？当你的操作系统要打开一个文件时，主要分为两个步骤：


1. 它将 **文件名** 映射到一个包含该文件元数据的 **inode**
2. 它利用这个 **inode** 来获取文件的实际内容


`stat` 系统调用只是返回文件的 inode 内容 —— 它并不读取任何的文件内容。好处在于这样做速度非常快。接下来让我们一起来快速了解一下 inode。（在 Dmitry Mazin 的这篇精彩文章 《[磁盘就是一堆比特](https://www.cyberdemon.org/2023/07/19/bunch-of-bits.html)》中有更多的详细内容）



```
$ 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 结构](https://github.com/torvalds/linux/blob/fdf0eaf11452d72945af31804e2a1048ee1b574c/fs/ext4/ext4.h#L769) 指出，前 16 位是 “模式”，即权限。所以现在我们将看一下 `ffa1` 如何对应到文件权限。


* `ffa1` 对应的数字是 `0xa1ff`，或者 41471（因为 x86 是小端表示）
* 41471 用八进制表示就是 `0120777`
* 这有些奇怪 - 那个文件的权限肯定可以是 `777`，但前三位是什么呢？我以前没见过这些！你可以在 [inode 手册页](https://man7.org/linux/man-pages/man7/inode.7.html) 中找到 `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 中继承了很多内容。例如，它继承了：


1. **环境变量**：你可以通过 `cat /proc/PID/environ | tr '\0' '\n'` 查看
2. **标准输出和标准错误的文件描述符**：通过 `ls -l /proc/PID/fd` 查看
3. **工作目录**（也就是当前目录）
4. **命名空间和控制组**（如果它在一个容器内）
5. 运行它的**用户**以及**群组**
6. 还有可能是我此刻未能列举出来的更多东西


### 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 的《[磁盘就是一堆比特](https://www.cyberdemon.org/2023/07/19/bunch-of-bits.html)》文章中得知这些步骤）。



```
$ 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` 开头，这是一个被称为“<ruby> 魔术数字 <rt>  magic number </rt></ruby>”的标识符，它是一种字节序列，告诉我们这是一个 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


```

从这段内容中，我理解到：


1. 请求内核运行 `/lib64/ld-linux-x86-64.so.2` 来启动这个程序。这就是所谓的**动态链接器**，我们将在随后的部分对其进行讨论。
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, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 l\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=149520, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f718a1e1000
...
close(3)                        = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3

```

这些内容初看可能让人望而生畏，但我希望你能重点关注这一部分：`openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0" ...`。这里正在打开一个被称为 `pthread` 的 C 语言线程库，运行 Python 解释器时需要这个库。


**自行验证**：如果你想知道一个二进制文件在运行时需要加载哪些库，你可以使用 `ldd` 命令。下面展示的是我运行后的效果：



```
$ ldd /usr/bin/python3.9
    linux-vdso.so.1 (0x00007ffc2aad7000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f2fd6554000)
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f2fd654e000)
    libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000)
    libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000)
    libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000)

```

你可以看到，第一个列出的库就是 `/lib/x86_64-linux-gnu/libpthread.so.0`，这就是它被第一个加载的原因。


#### 关于 LD\_LIBRARY\_PATH


说实话，我关于动态链接的理解还有些模糊，以下是我所了解的一些内容：


* 动态链接发生在用户空间，我的系统上的动态链接器位于 `/lib64/ld-linux-x86-64.so.2`. 如果你缺少动态链接器，可能会遇到一些奇怪的问题，比如这种 [奇怪的“文件未找到”错误](https://jvns.ca/blog/2021/11/17/debugging-a-weird--file-not-found--error/)
* 动态链接器使用 `LD_LIBRARY_PATH` 环境变量来查找库
* 动态链接器也会使用 `LD_PRELOAD` 环境变量来覆盖你想要的任何动态链接函数（你可以使用它来进行 [有趣的魔改](https://jvns.ca/blog/2014/11/27/ld-preload-is-super-fun-and-easy/)，或者使用像 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 输出](https://gist.github.com/jvns/4254251bea219568df9f43a2efd8d0f5) 中，我仍然对动态链接之后出现的一些系统调用感到困惑（什么是 `prlimit64`？本地环境的内容是如何介入的？`gconv-modules.cache` 是什么？`rt_sigaction` 做了什么？`arch_prctl` 是什么？以及 `set_tid_address` 和 `set_robust_list` 是什么？）。尽管如此，我觉得已经有了一个不错的开头。


#### 旁注：ldd 实际上是一个简单的 Shell 脚本！


在 Mastodon 上，有人 [指出](https://octodon.social/@lkundrak/110832640058459399)，`ldd` 实际上是一个 Shell 脚本，它设置了 `LD_TRACE_LOADED_OBJECTS=1` 环境变量，然后启动程序。因此，你也可以通过以下方式实现相同的功能：



```
$ LD_TRACE_LOADED_OBJECTS=1 python3
    linux-vdso.so.1 (0x00007ffe13b0a000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f01a5a47000)
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f01a5a41000)
    libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000)
    libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000)
    libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000)

```

事实上，`ld` 也是一个可以直接运行的二进制文件，所以你也可以通过 `/lib64/ld-linux-x86-64.so.2 --list /usr/bin/python3.9` 来达到相同的效果。


#### 关于 init 和 fini


让我们来谈谈这行 `strace` 输出中的内容：



```
set_tid_address(0x7f58880dca10) = 3709103

```

这似乎与线程有关，我认为这可能是因为 `pthread` 库（以及所有其他动态加载的库）在加载时得以运行初始化代码。在库加载时运行的代码位于 `init` 区域（或者也可能是 `.ctors` 区域）。


**自行验证**：让我们使用 `readelf` 来看看这个：



```
$ readelf -a /lib/x86_64-linux-gnu/libpthread.so.0
...
  [10] .rela.plt         RELA             00000000000051f0  000051f0
       00000000000007f8  0000000000000018  AI       4    26     8
  [11] .init             PROGBITS         0000000000006000  00006000
       000000000000000e  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         0000000000006010  00006010
       0000000000000560  0000000000000010  AX       0     0     16
...

```

这个库没有 `.ctors` 区域，只有一个 `.init`。但是，`.init` 区域都有些什么内容呢？我们可以使用 `objdump` 来反汇编这段代码：



```
$ objdump -d /lib/x86_64-linux-gnu/libpthread.so.0
Disassembly of section .init:

0000000000006000 <_init>:
    6000:       48 83 ec 08             sub    $0x8,%rsp
    6004:       e8 57 08 00 00          callq  6860 <__pthread_initialize_minimal>
    6009:       48 83 c4 08             add    $0x8,%rsp
    600d:       c3

```

所以它在调用 `__pthread_initialize_minimal`。我在 glibc 中找到了 [这个函数的代码](https://github.com/bminor/glibc/blob/a78e5979a92c7985eadad7246740f3874271303f/nptl/nptl-init.c#L100)，尽管我不得不找到一个较早版本的 glibc，因为在更近的版本中，libpthread [不再是一个独立的库](https://developers.redhat.com/articles/2021/12/17/why-glibc-234-removed-libpthread)。


我不确定这个 `set_tid_address` 系统调用是否实际上来自 `__pthread_initialize_minimal`，但至少我们知道了库可以通过 `.init` 区域在启动时运行代码。


这里有一份关于 .init 区域的 elf 手册的笔记：



```
$ man elf

```

`.init` 这个区域保存着对进程初始化代码有贡献的可执行指令。当程序开始运行时，系统会安排在调用主程序入口点之前执行该区域中的代码。


在 ELF 文件中也有一个在结束时运行的 `.fini` 区域，以及其他可以存在的区域 `.ctors` / `.dtors`（构造器和析构器）。


好的，关于动态链接就说这么多。


### 9、转到 \_start


在动态链接完成后，我们进入到 Python 解释器中的 `_start`。然后，它将执行所有正常的 Python 解析器会做的事情。


我不打算深入讨论这个，因为我在这里关心的是关于如何在 Linux 上运行二进制文件的一般性知识，而不是特别针对 Python 解释器。


### 10、写入字符串


不过，我们仍然需要打印出 “hello world”。在底层，Python 的 `print` 函数调用了 libc 中的某个函数。但是，它调用了哪一个呢？让我们来找出答案！


**自行验证**：运行 `ltrace -o out python3 hello.py`：



```
$ ltrace -o out python3 hello.py
$ grep hello out
write(1, "hello world\n", 12) = 12

```

看起来它确实在调用 `write` 函数。


我必须承认，我对 `ltrace` 总是有一些疑虑 —— 与我深信不疑的 `strace` 不同，我总是不完全确定 `ltrace` 是否准确地报告了库调用。但在这个情况下，它似乎有效。并且，如果我们查阅 [cpython 的源代码](https://github.com/python/cpython/blob/400835ea1626c8c6dcd967c7eabe0dad4a923182/Python/fileutils.c#L1955)，它似乎在一些地方确实调用了 `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](https://www.openwall.com/lists/musl/2023/05/02/1)，[这是一篇关于这个问题引发的错误的博客文章](https://christoph.luppri.ch/fixing-dns-resolution-for-ruby-on-alpine-linux)。


#### 关于 stdout 和终端的小插曲


在我们的程序中，stdout（`1` 文件描述符）是一个终端。你可以在终端上做一些有趣的事情！例如：


1. 在终端中运行 `ls -l /proc/self/fd/1`。我得到了 `/dev/pts/2` 的结果。
2. 在另一个终端窗口中，运行 `echo hello > /dev/pts/2`。
3. 返回到原始终端窗口。你应会看到 `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 可执行文件](https://www.muppetlabs.com/~breadbox/software/tiny/teensy.html)
* [在 FreeBSD 上探索 “hello world”](https://people.freebsd.org/~brooks/talks/asiabsdcon2017-helloworld/helloworld.pdf)
* [微观视角下的 Windows 中 “Hello World”][23A]
* 来自 LWN 的文章：[如何运行程序](https://lwn.net/Articles/630727/) （[以及第二部分](https://lwn.net/Articles/631631/)）详尽介绍了 `execve` 的内部机制
* Lexi Mattick 的文章，[赋予 CPU “你” 的存在](https://cpu.land/how-to-run-a-program)
* [从零开始在 6502 上实现 “Hello, World”](https://www.youtube.com/watch?v=LnzuMJLZRdU) （来自 Ben Eater 的视频）


*（题图：MJ/b87ed0a2-80d6-49cd-b2bf-1ef822485e3f）*




---


via: <https://jvns.ca/blog/2023/08/03/behind--hello-world/>


作者：[Julia Evans](https://jvns.ca/) 选题：[lujun9972](https://github.com/lujun9972) 译者：ChatGPT 校对：[wxy](https://github.com/wxy)


本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创编译，[Linux中国](https://linux.cn/) 荣誉推出