背景

如果我在 CentOS 7 上 安裝了一個 Python 3,我是否能把它複製到 CentOS 6 上運行呢?

短答案:可以。

但是這牽扯到如何處理所有 Python 3 依賴的問題。

動態鏈接 vs 靜態鏈接

絕大部分程序都或多或少共享了一些相同的功能,比如讀寫文件、創建HTTP連接等。這些相同的功能無需在每個程序中包含,只需大家共享一份相同的即可。

使用這種共享技術的,就是動態鏈接;相反不使用這種共享技術的,就是靜態鏈接。

對於一個傳統的二進位可執行程序(PE for Windows, ELF for Linux),除卻系統特定的一些內容之外,都遵循相同的概念:

  • 每個功能都可以被劃分成一個對應函數
  • 函數的二進位名稱叫做「符號」
  • 靜態鏈接還是動態鏈接,將決定這些符號解析至何處
  • 在可執行程序被編譯時,實際被劃分為編譯和鏈接兩個大步驟
    • 編譯時只會將程序源代碼變成機器碼
    • 鏈接時將解決符號的解析問題
    • 因此動態鏈接和靜態鏈接,通常是在程序鏈接時決定的

運行可執行程序時,操作系統負責查找動態鏈接的符號,如果都能夠找到且互相匹配,那麼這個程序很大概率即可正常啟動。

我們想把一個 CentOS 7 上安裝的 Python 3 複製到 CentOS 6 上運行,理論上只需處理好 Python 3 的所有動態鏈接庫即可。

ELF 格式

Linux 上可執行程序遵循的是 ELF 格式。

ELF 格式示意圖

ELF 格式本身有很多細節,這裡只簡單說說。

一般一個 ELF 文件都會有兩個 header ,一個叫 ELF header,作用是描述整個 ELF 文件的一些信息,比如適用的平臺是不是 x64,內容是大端序還是小端序等。

另一個 header 叫 program header,它的作用主要是為了表明自己應該如何被載入和執行。

通過 readelf 命令,可以在 Linux 系統中查看任意 ELF 文件的信息,包括這兩個 header :

# readelf --file-header /usr/bin/awk
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2s complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x408a5e
Start of program headers: 64 (bytes into file)
Start of section headers: 426728 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 29
Section header string table index: 28
# readelf --program-header /usr/bin/awk
?
Elf file type is EXEC (Executable file)
Entry point 0x408a5e
There are 9 program headers, starting at offset 64
?
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040
0x00000000000001f8 0x00000000000001f8 R E 8
INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238
0x000000000000001c 0x000000000000001c R 1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x000000000006547c 0x000000000006547c R E 200000
LOAD 0x0000000000065df0 0x0000000000665df0 0x0000000000665df0
0x0000000000000bdc 0x0000000000008608 RW 200000
DYNAMIC 0x0000000000065e08 0x0000000000665e08 0x0000000000665e08
0x00000000000001f0 0x00000000000001f0 RW 8
NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254
0x0000000000000044 0x0000000000000044 R 4
GNU_EH_FRAME 0x000000000005db50 0x000000000045db50 0x000000000045db50
0x0000000000000f2c 0x0000000000000f2c R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 10
GNU_RELRO 0x0000000000065df0 0x0000000000665df0 0x0000000000665df0
0x0000000000000210 0x0000000000000210 R 1
?
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
08 .init_array .fini_array .jcr .dynamic .got

在這兩個 header 之後,有很多 section。這些 section 會在 ELF 文件被載入時,歸納為若干 segment 。這裡面既有代碼也有數據,其中我們最關心的一個 section 叫做 .dynamic

.dynamic 包含了可執行程序所需的動態鏈接庫,使用 readelf 也可以解析:

# readelf -d /usr/bin/awk
?
Dynamic section at offset 0x65e08 contains 26 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libdl.so.2]
0x0000000000000001 (NEEDED) Shared library: [libm.so.6]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x406d20
0x000000000000000d (FINI) 0x44fb84
0x0000000000000019 (INIT_ARRAY) 0x665df0
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x665df8
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x400298
0x0000000000000005 (STRTAB) 0x404370
0x0000000000000006 (SYMTAB) 0x400f20
0x000000000000000a (STRSZ) 5368 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x666000
0x0000000000000002 (PLTRELSZ) 3888 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x405df0
0x0000000000000007 (RELA) 0x405d78
0x0000000000000008 (RELASZ) 120 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000006ffffffe (VERNEED) 0x405cc8
0x000000006fffffff (VERNEEDNUM) 3
0x000000006ffffff0 (VERSYM) 0x405868
0x0000000000000000 (NULL) 0x0

上面的回顯裏,標有」NEEDED「字樣的行,即是這個 ELF 文件所需的動態鏈接庫。

不過要注意,readelf 本質上只解析這一個 ELF 文件。如果一個 ELF 文件依賴的動態鏈接庫,又依賴了其他動態鏈接庫,那麼這條命令就不夠用了。為此,可以使用 ldd 命令:

# ldd /usr/bin/python3
linux-vdso.so.1 => (0x00007ffd5adad000)
libpython3.4m.so.1.0 => /lib64/libpython3.4m.so.1.0 (0x00007f682638d000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f6826171000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007f6825f6d000)
libutil.so.1 => /lib64/libutil.so.1 (0x00007f6825d6a000)
libm.so.6 => /lib64/libm.so.6 (0x00007f6825a68000)
libc.so.6 => /lib64/libc.so.6 (0x00007f682569b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f682681f000)

這個命令會遞歸地處理依賴。由此可見,我們只需要在複製可執行文件時,將這些文件也一起複制即可。

linux-vdso.so.1

ldd 命令非常友好地列印出了所有動態鏈接庫的地址,但怎麼唯獨沒有列印出 linux-vdso.so.1 呢?而且這個文件 ldd 並不是沒有找到,它甚至給出了入口地址。如果我們自己在系統裏搜索,也是無法搜索到這個文件的。

原來這個 linux-vdso.so.1 文件,並不是一個真實存在的文件,而是 Linux 中的一個虛擬文件,專門用於將內核中一些常用的函數從內核空間映射到用戶空間。也就是說,這個文件不用複製。

LD_LIBRARY_PATH & rpath & runpath

我們複製了目標程序所需的動態鏈接庫,但是我們如何確定程序啟動時,真的能夠順利找到這些動態鏈接庫呢?

在 Linux 中,主要有三個因素可以決定特定可執行文件的動態鏈接庫的搜索路徑:環境變數 LD_LIBRARY_PATHrpathrunpath

絕大部分動態鏈接庫會從環境變數 LD_LIBRARY_PATH 中查找載入。要指定運行 python3 時這些庫的查找路徑,可以這樣做:

LD_LIBRARY_PATH=/new/path:$LD_LIBRARY_PATH /usr/bin/python3

這樣在複製 Python 3 時,連帶複製所需的動態運行庫至單獨的目錄,然後重新指定 LD_LIBRARY_PATH 即可。

rpathrunpath 是 ELF 文件的可選內容。如果一個 ELF 文件含有 rpath ,那麼系統會優先在 rpath 所指向的路徑搜索,然後再搜索 LD_LIBRARY_PATH ;而 runpath 則是在 LD_LIBRARY_PATH 之後搜索。

這樣看來 runpath 對我們沒有什麼幫助,LD_LIBRARY_PATH 可以幫助我們引導系統搜索動態鏈接庫的路徑。而 rpath,因為是內嵌在 ELF 當中的,一旦程序中有這個內容會影響我們使用 LD_LIBRARY_PATH 的效果。

ld-linux.so.2

所幸我要複製的 Python 3 沒有指定 rpath,這樣以上的方案看起來就可以搞定。

但是在實際使用中,我卻碰到了這樣的異常:

python3: relocation error: symbol _dl_starting_up, version GLIBC_PRIVATE not defined in file ld-linux-x86-64.so.2 with link time reference

大意是說,在 ld-linux-x86-64.so.2 中找不到 _dl_starting_up 。既然鏈接庫都已經複製且指定了查找路徑,為何在原系統能夠成功執行,在新系統卻不能呢?

原來 ld-linux-x86-64.so.2 這個文件有它自己的故事。

在 Linux 當中, 內核並不直接負責載入動態鏈接的 ELF 文件所需要的鏈接庫,這些鏈接庫的載入職責由ld-linux.so.2 來負責,而ld-linux.so.2 的路徑在程序的鏈接時就已經寫成了硬編碼。因此在新系統中, 這位 python3 依然會使用硬編碼的路徑來調用 ld-linux-x86-64.so.2 ,這會直接使用到新系統的 ld-linux-x86-64.so.2

由於 ld-linux-x86-64.so.2 必須與 glibc 的版本匹配,而 glibc 其他動態鏈接庫均來自老系統,這就產生了上面的錯誤。

如果這個 Python 3 是我們從源碼構建的,那麼 GCC 允許我們在鏈接時指定ld-linux.so.2 的位置:

g++ main.o -o myapp ...
-Wl,--rpath=/path/to/newglibc
-Wl,--dynamic-linker=/path/to/newglibc/ld-linux.so.2

其中第三行會指定ld-linux.so.2 的位置,而第二行可以用來設定 ELF 文件的 rpath ,這樣我們就不用在啟動時調整 LD_LIBRARY_PATH 了。另外,通過 rpath 參數還有另外的好處,那就是進程再產生子進程時,不會因需要改變當前進程的 LD_LIBRARY_PATH 而連帶受到影響。

那麼如果我們並不是從源碼構建呢?至少對於我來說,我的 Python 3 並不是自己編譯的,而是從 yum 上安裝的。ld-linux.so.2 的絕對路徑已經成為了二進位文件的一部分,要修改就相對繁瑣一些了。因為絕對路徑在修改後長度不同,將有可能導致 ELF 的結構被破壞。

所幸有個名為 patchelf 的工具,來幫我們解決這個難題:

$ ./patchelf --set-interpreter /path/to/newglibc/ld-linux.so.2 --set-rpath /path/to/newglibc/ myapp

大功告成,這樣一來,所有的依賴都能順利地解決了。不過成功運行 Python 3 的可執行文件,並不代表就能順利運行所有的 Python 程序。作為一個腳本語言, Python 程序還有很多依賴包,這裡就不詳細聊了。

結語

本文只是從應用的角度來揣摩了 Linux ELF 文件中針對動態鏈接庫的載入,所用目的也比較「hack」。實際 Linux 的 ELF loader 要更複雜,包含了地址變換等一系列初始化工作,大家可以借 tel_ldr來更精細地窺探。

參考資料

  • stackoverflow.com/quest
  • amir.rachum.com/blog/20
  • blog.csdn.net/wang_xya/
  • man7.org/linux/man-page
  • blog.csdn.net/omnispace
  • gcc.gnu.org/wiki/Symbol

頭圖來自Bryson Hammer


推薦閱讀:
相關文章