背景

如果我在 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


推荐阅读:
相关文章