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 >