最近在工作中遇到了几次动态库链接报错的问题。

一次是 import 两个 python 模块,必须将一个模块放到前面加载,另一个放到后面加载,不然会报找不到版本的错误。

1
ImportError: /usr/lib64/libstdc++.so.6: version 'CXXABI_1.3.9' not found (required by......

另一次是先加载了 c++ 版本的 libtorch 库,然后在同一进程中尝试 import python 版本的 libtorch,报符号未定义的错误,通过将其中一个放到子进程中运行可以解决。

1
ImportError:/root/miniconda3/lib/python3.9/site-
packages/torch/lib/libtorch_cuda_cpp.so: undefined symbol: _ZTIN4c10d4WorkE

这些报错以前也经常遇到,解决也比较简单,无非就是升级库版本,或者设置正确的库加载路径。不过这些版本错误、符号未定义的含义和底层原理是什么样的呢?带着问题看了《程序员的自我修养》中,linux下的编译链接、可执行文件加载执行相关的内容,整理一些比较常用的内容出来,方便记忆、加深理解。

1. 编译链接各阶段定义

image.png
gcc 编译过程分解

1.1 预编译

1
gcc –E hello.c –o hello.i / cpp hello.c > hello.i

预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令。比如“#include”、“#define”等。

1.2 编译

1
gcc –S hello.i –o hello.s

编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件。

image.png

编译过程:
词法分析(得到标记) →
语法分析(得到语法书) →
语义分析(静态语义分析) →
机器无关的中间语言生成(源代码级别优化,编译器前端优化) →
目标代码生成与优化(目标机器代码生成、目标代码优化器,编译器后端优化)

1.3 汇编

1
as hello.s –o hello.o / gcc –c hello.c –o hello.o

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。
所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此。

1.4 链接

最早期的计算机程序是打在纸带上的:

image.png

这个程序的第一条指令就是一条跳转指令,它的目的地址是第5条指令(注意,第5条指令的绝对地址是4)。

一条纸带就相当于一份代码文件,当一个程序由多个纸带(多份代码)组成,要把他们拼接成一张纸条以加载执行时,就需要考虑如何重新分配命令和变量空间、如何重新定位数据位置等问题,这个工作就是链接。

image.png

2. 目标文件分析

objdump、readelf、nm命令
readelf 命令包含了 objdump 和 nm 命令,更全

2.1 目标文件结构和内容

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* 
* SimpleSection.c
*
* Linux:
* gcc -c SimpleSection.c
*
* Windows:
* cl SimpleSection.c /c /Za
*/


int printf( const char* format, ... );
int global_init_var = 84;
int global_uninit_var;

void func1( int i )
{

printf( "%d\n", i );
}

int main(void)
{

static int static_var = 85;
static int static_var2;

int a = 1;
int b;

func1( static_var + static_var2 + a + b );

return a;
}

编译 gcc –c SimpleSection.c
binutils的工具objdump可以用来查看各种目标文件的结构和内容
objdump -h SimpleSection.o

1
SimpleSection.o:     file format elf32-i386

Sections:

Idx Name        Size      VMA         LMA       File off  Algn
  0 .text       0000005b  00000000  00000000  00000034  2**2
                CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data       00000008  00000000  00000000  00000090  2**2
                CONTENTS, ALLOC, LOAD, DATA
  2 .bss        00000004  00000000  00000000  00000098  2**2
                ALLOC
  3 .rodata     00000004  00000000  00000000  00000098  2**0
                CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .comment    0000002a  00000000  00000000  0000009c  2**0
                CONTENTS, READONLY
  5 .note.GNU-stack 00000000  00000000  00000000  000000c6  2**0
                CONTENTS, READONLY

段名称含义

  • .text: 代码段
  • .data: 数据段
  • .bss: 未初始化的数据段 Block Started by Symbo
  • .rodata 只读数据段 read only data
  • .comment 注释段
  • .note.GNU-stack 堆栈提示段

列名称含义

  • Size:表示该段(section)在文件中的大小,以字节为单位。
  • VMA(Virtual Memory Address):表示该段在虚拟内存中的起始地址。当程序加载到内存中运行时,该段将被映射到这个虚拟地址
  • LMA(Load Memory Address):表示该段在加载到物理内存时的起始地址。通常情况下,LMA 和 VMA 是相同的,但在某些特殊情况下,例如运行在 ROM 中的程序,它们可能会有所不同。
  • File off:表示该段在目标文件中的偏移量,即从文件开始到该段开始的字节数。
  • Algn(Alignment):表示该段在内存中的对齐方式。对齐是为了提高内存访问性能,通常以 2 的幂次方表示。例如,22 表示 4 字节对齐,20 表示无需对齐。例如32位系统一次读取4个字节,char a, int b两个变量,未对齐时 abbbbc,要读取两次才能读到4字节b变量,对齐后 a—bbbbc,浪费了3个字节,但是可以一次性读取到 b 变量,速度快。

每个段的第二行含义

  • CONTENTS:表示该段在文件中存在,我们可以看到BSS段没有“CONTENTS”,表示它实际上在ELF文件中不存在内容
  • “.note.GNU-stack”段虽然有“CONTENTS”,但它的长度为0,这是个很古怪的段,我们暂且忽略它,认为它在ELF文件中也不存在
  • 实际存在的也就是“.text”、“.data”、“.rodata”和“.comment”这4个段

image.png

代码段

objdump的“-s”参数可以将所有段的内容以十六进制的方式打印出来,“-d”参数可以将所有包含指令的段反汇编。我们将objdump输出中关于代码段的内容提取出来,分析一下关于代码段的内容

1
$ objdump -s -d SimpleSection.o
……
Contents of section .text:
 0000 5589e583 ec088b45 08894424 04c70424  U......E..D$...$
 0010 00000000 e8fcffff ffc9c38d 4c240483  ............L$..
 0020 e4f0ff71 fc5589e5 5183ec14 c745f401  ...q.U..Q....E..
 0030 0000008b 15040000 00a10000 00008d04  ................
 0040 020345f4 0345f889 0424e8fc ffffff8b  ..E..E...$......
 0050 45f483c4 14595d8d 61fcc3             E....Y].a..  
……
00000000 <func1>:
   0:   55                    push   %ebp
   1:   89 e5                 mov    %esp,%ebp
   3:   83 ec 08                  sub    $0x8,%esp
   6:   8b 45 08                  mov    0x8(%ebp),%eax
   9:   89 44 24 04             mov    %eax,0x4(%esp)
   d:   c7 04 24 00 00 00 00  movl   $0x0,(%esp)
  14:   e8 fc ff ff ff        call   15 <func1+0x15>
  19:   c9                        leave
  1a:   c3                        ret

0000001b <main>:
  1b:   8d 4c 24 04             lea  0x4(%esp),%ecx
  1f:   83 e4 f0                and    $0xfffffff0,%esp
  22:   ff 71 fc                pushl  -0x4(%ecx)
  25:   55                        push   %ebp
  26:   89 e5                   mov    %esp,%ebp
  28:   51                        push   %ecx
  29:   83 ec 14                sub    $0x14,%esp
  2c:   c7 45 f4 01 00 00 00  movl   $0x1,-0xc(%ebp)
  33:   8b 15 04 00 00 00     mov  0x4,%edx
  39:   a1 00 00 00 00          mov  0x0,%eax
  3e:   8d 04 02                lea    (%edx,%eax,1),%eax
  41:   03 45 f4                add[…]

Contents of section .text”就是.text的数据以十六进制方式打印出来的内容,总共0x5b字节,跟前面我们了解到的“.text”段长度相符合,最左面一列是偏移量,中间4列是十六进制内容,最右面一列是.text段的ASCII码形式。对照下面的反汇编结果,可以很明显地看到,.text段里所包含的正是SimpleSection.c里两个函数func1()和main()的指令。.text段的第一个字节“0x55”就是“func1()”函数的第一条“push %ebp”指令,而最后一个字节0xc3正是main()函数的最后一条指令“ret”。

数据段和只读数据段、BSS段

data段保存的是那些已经初始化了的全局静态变量和局部静态变量

1
$ objdump -x -s -d SimpleSection.o

其它常用段

image.png

size

1
size SimpleSection.o

查看各个段的长度

1
text  data  bss  dec  hex  filename
   95     8    4  107   6b  SimpleSection.o

2.2 ELF文件结构

ELF文件全称:Executable and Linkable Format
image.png

  • ELF Header 文件头 ,描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等
    • text ~ other sections 各个段的数据
  • Section header table 段表,描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性
    • 除了代码段数据段等段的描述信息外,还包含符号表、字符串表、段名字符串表、重定位表等

      elf 文件头

      1
      $readelf –h SimpleSection.o
      ELF Header:
        Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
        Class:                              ELF32
        Data:                             2's complement, little endian
        Version:                          1 (current)
        OS/ABI:                             UNIX - System V
        ABI Version:                    0
        Type:                               REL (Relocatable file)
        Machine:                            Intel 80386
        Version:                        0x1
        Entry point address:            0x0
        Start of program headers:       0 (bytes into file)
        Start of section headers:       280 (bytes into file)
        Flags:                          0x0
        Size of this header:          52 (bytes)
        Size of program headers:        0 (bytes)
        Number of program headers:      0
        Size of section headers:        40 (bytes)
        Number of section headers:      11
        Section header string table index:  8

ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。

ELF文件结构及相关常数被定义在“/usr/include/elf.h”里,因为ELF文件在各种平台下都通用,ELF文件有32位版本和64位版本。它的文件头结构也有这两种版本,分别叫做“Elf32_Ehdr”和“Elf64_Ehdr”。

段表

段表数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
Elf64_Word sh_name; /* Section name (string tbl index) */
Elf64_Word sh_type; /* Section type */
Elf64_Xword sh_flags; /* Section flags */
Elf64_Addr sh_addr; /* Section virtual addr at execution */
Elf64_Off sh_offset; /* Section file offset */
Elf64_Xword sh_size; /* Section size in bytes */
Elf64_Word sh_link; /* Link to another section */
Elf64_Word sh_info; /* Additional section information */
Elf64_Xword sh_addralign; /* Section alignment */
Elf64_Xword sh_entsize; /* Entry size if section holds table */
} Elf64_Shdr;

查看段表

1
$ readelf -S SimpleSection.o
There are 11 section headers, starting at offset 0x118:

Section Headers:
 [Nr] Name          Type      Addr     Off    Size   ES Flg Lk Inf Al
 [ 0]               NULL      00000000 000000 000000 00 0   0  0
 [ 1] .text         PROGBITS  00000000 000034 00005b 00 AX  0  0   4
 [ 2] .rel.text     REL       00000000 000428 000028 08     9  1   4
 [ 3] .data         PROGBITS  00000000 000090 000008 00 WA  0  0   4
 [ 4] .bss          NOBITS    00000000 000098 000004 00 WA  0  0   4
 [ 5] .rodata       PROGBITS  00000000 000098 000004 00 A   0  0   1
 [ 6] .comment        PROGBITS  00000000 00009c 00002a 00 0   0  1
 [ 7] .note.GNU-stack PROGBITS  00000000 0000c6 000000 00 0   0  1
 [ 8] .shstrtab   STRTAB    00000000 0000c6 000051 00 0   0  1
 [ 9] .symtab       SYMTAB    00000000 0002d0 0000f0 10     10 10   4
 [10] .strtab       STRTAB    00000000 0003c0 000066 00 0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

结构描述

image.png

段的类型

image.png

.symtab 符号表

后面细说

.rel.text / .rel.data 重定位表

链接器在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。这些重定位的信息都记录在ELF文件的重定位表里面,对于每个须要重定位的代码段或数据段,都会有一个相应的重定位表。

.strtab 字符串表

ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。

2.3 符号

查看目标文件的符号表

在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。
用“nm”来查看“SimpleSection.o”的符号结果如下:

1
$ nm SimpleSection.o
00000000 T func1
00000000 D global_init_var
00000004 C global_uninit_var
0000001b T main
U printf
00000004 d static_var.1286
00000000 b static_var2.1287

符号表数据结构

1
2
3
4
5
6
7
8
typedef struct {
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
} Elf32_Sym;

image.png

使用 readelf 查看完整符号表

1
readelf –s SimpleSection.o

Symbol table '.symtab' contains 15 entries:
   Num:    Value  Size  Type    Bind   Vis      Ndx Name
     0: 00000000  0   NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000  0   FILE    LOCAL  DEFAULT  ABS SimpleSection.c
     2: 00000000  0   SECTION LOCAL  DEFAULT    1 
     3: 00000000  0   SECTION LOCAL  DEFAULT    3 
     4: 00000000  0   SECTION LOCAL  DEFAULT    4 
     5: 00000000  0   SECTION LOCAL  DEFAULT    5 
     6: 00000000    4   OBJECT  LOCAL  DEFAULT    4 static_var2.1534
     7: 00000004    4   OBJECT  LOCAL  DEFAULT    3 static_var.1533
     8: 00000000  0   SECTION LOCAL  DEFAULT    7 
     9: 00000000  0   SECTION LOCAL  DEFAULT    6 
    10: 00000000    4   OBJECT  GLOBAL DEFAULT    3 global_init_var
    11: 00000000  27  FUNC    GLOBAL DEFAULT    1 func1
    12: 00000000  0   NOTYPE  GLOBAL DEFAULT  UND printf
    13: 0000001b  64  FUNC    GLOBAL DEFAULT    1 main
    14: 00000004    4   OBJECT  GLOBAL DEFAULT  COM global_uninit_var

对于变量和函数来说,符号值就是它们的地址。除了函数和变量之外,还存在其他几种不常用到的符号。我们将符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种:

  • 定义在本目标文件的全局符号,可以被其他目标文件引用。比如SimpleSection.o里面的“func1”、“main”和“global_init_var”
  • 段名,这种符号往往由编译器产生,它的值就是该段的起始地址。比如SimpleSection.o里面的“.text”、“.data”等。
  • 局部符号,这类符号只在编译单元内部可见。比如SimpleSection.o里面的“static_var”和“static_var2”。调试器可以使用这些符号来分析程序或崩溃时的核心转储文件。这些局部符号对于链接过程没有作用,链接器往往也忽略它们。
  • 行号信息,即目标文件指令与源代码中代码行的对应关系,它也是可选的

    特殊符号

  • __executable_start,该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址。
  • __etext或_etext或etext,该符号为代码段结束地址,即代码段最末尾的地址。
  • _edata或edata,该符号为数据段结束地址,即数据段最末尾的地址。
  • _end或end,该符号为程序结束地址。

这些符号可以在代码中使用,比如给打印出来。

符号修饰

简单下划线修饰:_foo、foo

c++修饰规则

image.png

所有的符号都以“_Z”开头,对于嵌套的名字(在名称空间或在类里面的),后面紧跟“N”,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以“E”结尾。比如N::C::func经过名称修饰以后就是_ZN1N1C4funcE。

extern “C”

被这个包含的符号不会给加修饰

1
#ifdef __cplusplus
extern "C" {
#endif
 
void *memset (void *, int, size_t);

#ifdef __cplusplus
}
#endif

可以用 __cplusplus 让c++程序可以链接到c的 memset,同时也能让不支持 extern “C” 的 c 语言引入这个头文件

2.4 调试信息

在GCC编译时加上“-g”参数,编译器就会在产生的目标文件里面加上调试信息,我们通过readelf等工具可以看到,目标文件里多了很多“debug”相关的段。
调试信息在目标文件和可执行文件中占用很大的空间,往往比程序的代码和数据本身大好几倍,所以当我们开发完程序并要将它发布的时候,须要把这些对于用户没有用的调试信息去掉,以节省大量的空间。在Linux下,我们可以使用“strip”命令来去掉ELF文件中的调试信息:

1
$strip foo

3. 静态链接

3.1 空间和地址分配

简单按序叠加

最简单的想法:把各个目标文件直接拼接在一起
存在的问题:空间浪费,每个段即使只有1字节,也可能占用一个内存页的空间,如4096字节

image.png

相似段合并

第一步 空间与地址分配 扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。

第二步 符号解析与重定位 使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程。

image.png

1
ld a.o b.o -e main -o ab

链接后的结果

1
$ objdump -h a.o
Sections:
Idx Name          Size    VMA       LMA       File off  Algn
  0 .text       00000034  00000000  00000000  00000034  2**2
                CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data       00000000  00000000  00000000  00000068  2**2
                CONTENTS, ALLOC, LOAD, DATA
  2 .bss        00000000  00000000  00000000  00000068  2**2
                ALLOC
...
$ objdump -h b.o
...
Sections:
Idx Name        Size      VMA       LMA       File off  Algn
  0 .text       0000003e  00000000  00000000  00000034  2**2
                CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data       00000004  00000000  00000000  00000074  2**2
                CONTENTS, ALLOC, LOAD, DATA
  2 .bss        00000000  00000000  00000000  00000078  2**2
                ALLOC
...
$objdump –h ab
...
Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text       00000072  08048094  08048094  00000094  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data       00000004  08049108  08049108  00000108  2**2
                  CONTENTS, ALLOC, LOAD, DATA

VMA表示Virtual Memory Address,即虚拟地址,LMA表示Load Memory Address,即加载地址,正常情况下这两个值应该是一样的,但是在有些嵌入式系统中,特别是在那些程序放在ROM的系统中时,LMA和VMA是不相同的。这里我们只要关注VMA即可。

链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,即我们关心上面各个段中的VMA(Virtual Memory Address)和Size,而忽略文件偏移(File off)。

我们可以看到,在链接之前,目标文件中的所有段的VMA都是0,因为虚拟空间还没有被分配,所以它们默认都为0。等到链接之后,可执行文件“ab”中的各个段都被分配到了相应的虚拟地址。这里的输出程序“ab”中,“.text”段被分配到了地址0x08048094,大小为0x72字节;“.data”段从地址0x08049108开始,大小为4字节。如下图所示。

image.png

3.2 符号的解析和重定位

重定位

根据段表中的重定位表,对链接后目标文件中依赖外部目标文件的变量和指令的方式进行调整,指向真正的虚拟地址中的位置。
重定位前:

image.png

重定位后:

image.png

符号解析

在我们通常的观念里,之所以要链接是因为我们目标文件中用到的符号被定义在其他目标文件,所以要将它们链接起来。比如我们直接使用ld来链接“a.o”,而不将“b.o”作为输入。链接器就会发现shared和swap两个符号没有被定义,没有办法完成链接工作:

1
$ ld a.o
a.o: In function `main':
a.c:(.text+0x1c): undefined reference to `shared'
a.c:(.text+0x27): undefined reference to `swap

导致这个问题的原因很多,最常见的一般都是链接时缺少了某个库,或者输入目标文件路径不正确或符号的声明与定义不一样。

重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。

3.3 c++相关

全局构造/析构

程序的一些特定的操作必须在main函数之前被执行,还有一些操作必须在main函数之后被执行,其中很具有代表性的就是C++的全局对象的构造和析构函数。因此ELF文件还定义了两种特殊的段。

.init 该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段的中的代码。

.fini 该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。
这两个段.init和.fini的存在有着特别的目的,如果一个函数放到.init段,在main函数执行前系统就会执行它。同理,假如一个函数放到.fint段,在main函数返回后该函数就会被执行。利用这两个特性,C++的全局构造和析构函数就由此实现。

ABI接口

如果要使两个编译器编译出来的目标文件能够相互链接,那么这两个目标文件必须满足下面这些条件:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同,等等。

其中我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)。

3.4 静态库链接

一个静态库可以看做一组目标文件的集合,用下面的命令可以查看静态库的目标文件组成,后面会细说静态库链接过程。

1
$ar -t libc.a
init-first.o
libc-start.o
sysdep.o
version.o
check_fds.o
libc-tls.o
......

通过静态链接生成一个可执行文件,除了程序本身的目标代码,还有一堆系统的静态库需要被链接进去,以支持程序的加载执行。

image.png

4. 可执行文件的装载与进程

4.1 虚拟地址空间

硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如32位的硬件平台决定了虚拟地址空间的地址为 0 到 2^32-1,即0x00000000~0xFFFFFFFF,也就是我们常说的4 GB虚拟空间大小;而64位的硬件平台具有64位寻址能力,它的虚拟地址空间达到了264字节,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,总共17 179 869 184 GB,这个寻址能力从现在来看,几乎是无限的。

32位硬件平台虚拟地址空间的一种分配示例如下

image.png

通过 PAE(Physical Address Extension)等方式,可以在32位平台将地址位扩大到36位,从而访问64G的内存,不过一般情况下只有操作系统感知这种做法。

应用程序可以分配一块内存区出来转门用于内存映射,如 linux 下的 mmap 技术来使用大于 4G 的空间。

4.2 装载方式

覆盖装入

image.png

覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉。这个小的辅助代码就是所谓的覆盖管理器(Overlay Manager)。

页映射

image.png

与覆盖装入的原理相似,页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照“页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页。

4.3 从操作系统角度看可执行文件的装载

进程的建立

从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。
创建一个进程,然后装载相应的可执行文件并且执行,在有虚拟存储的情况下,上述过程最开始只需要做三件事情:

  • 创建一个独立的虚拟地址空间。
  • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
  • 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

4.4 进程虚拟空间分布

ELF文件链接视图和执行视图

image.png

对于相同权限的段,把它们合并到一起当作一个段进行映射。比如有两个段分别叫“.text”和“.init”,它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的。假设.text为4 097字节,.init为512字节,这两个段分别映射的话就要占用三个页面,但是,如果将它们合并成一起映射的话只须占用两个页面。

ELF可执行文件引入了一个概念叫做“Segment”,上面合并后的映射就叫做一个“Segment”,包含一个或多个属性类似的ELF段(“Section”)。

image.png

ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存“Segment”的信息。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有。跟段表结构一样,程序头表也是一个结构体数组,它的结构体如下:

1
2
3
4
5
6
7
8
9
10
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;

Elf32_Phdr结构体的几个成员与前面我们使用“readelf –l”打印文件头表显示的结果一一对应。Elf32_Phdr结构的各个成员的基本含义如下

image.png

堆和栈

在操作系统里面,VMA除了被用来映射可执行文件中的各个“Segment”以外,它还可以有其他的作用,操作系统通过使用VMA来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到栈(Stack)、堆(Heap)等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的VMA。在Linux下,我们可以通过查看“/proc”来查看进程的虚拟空间分布:

1
$ ./SectionMapping.elf &
[1] 21963
$ cat /proc/21963/maps
08048000-080b9000 r-xp 00000000 08:01 2801887    ./SectionMapping.elf
080b9000-080bb000 rwxp 00070000 08:01 2801887    ./SectionMapping.elf
080bb000-080de000 rwxp 080bb000 00:00 0          [heap]
bf7ec000-bf802000 rw-p bf7ec000 00:00 0          [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]
```  

第一列是VMA的地址范围;第二列是VMA的权限,“r”表示可读,“w”表示可写,“x”表示可执行,“p”表示私有(COW, Copy on Write),“s”表示共享。第三列是偏移,表示VMA对应的Segment在映像文件中的偏移;第四列表示映像文件所在设备的主设备号和次设备号;第五列表示映像文件的节点号。最后一列是映像文件的路径。  

可以看到进程中有5个VMA,只有前两个是映射到可执行文件中的两个Segment。另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域(Anonymous Virtual Memory Area)。  

有两个区域分别是堆(Heap)和栈(Stack),它们的大小分别为140 KB和88 KB。这两个VMA几乎在所有的进程中存在,我们在C语言程序里面最常用的malloc()内存分配函数就是从堆里面分配的,堆由系统库管理。栈一般也叫做堆栈,我们知道每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用。
一个常见的进程的虚拟空间:  

![image.png](https://zoucz.com/blogimgs/5544b600-a0e7-11ee-b717-b77abb79b472.md/d27f9929eced110716f29feb72afc32d.jpg)  

## 4.5 linux内核装载ELF的过程
在Linux系统的bash下输入一个命令执行某个ELF程序时,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
```c
int execve(const char *filename, char *const argv[], char *const envp[]);

三个参数分别是被执行的程序文件名、执行参数和环境变量。Glibc对execvp()系统调用进行了包装,提供了execl()、execlp()、execle()、execv()和execvp()等5个不同形式的exec系列API。
下面是一个简单的使用fork()和execlp()实现的minibash:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{

char buf[1024] = {0};
pid_t pid;
while(1) {
printf("minibash$");
scanf("%s", buf);
pid = fork();
if(pid == 0) {
if(execlp(buf, 0 ) < 0) {
printf("exec error\n");
}
} else if(pid > 0){
int status;
waitpid(pid,&status,0);
} else {
printf("fork error %d\n",pid);
}
}
return 0;
}

5.动态链接

5.1 动态链接的好处

image.png

上面是静态链接程序的磁盘空间和内存空间使用情况,磁盘中每个程序中都保存了一个 Lib.o 的文件,运行时在内存中每个进程了加载了一份 Lib.o 的文件。

image.png

对比使用动态链接的程序,磁盘中只保存了一份 Lib.o,运行时多个程序的进程也是共享的一份 Lib.o。

动态链接的方式可以减少程序升级成本、节省内存空间、减少物理页面的换入换出,也可以增加CPU缓存的命中率。

在Linux中,常用的C语言库的运行库glibc,它的动态链接形式的版本保存在“/lib”目录下,文件名叫做“libc.so”。整个系统只保留一份C语言库的动态链接文件“libc.so”,而所有的C语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。

5.2 动态链接简介

动态链接过程的示例

image.png

对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身,但是对于动态链接来说,除了可执行文件本身之外,还有它所依赖的共享目标文件。

可以查看一个进程的虚拟地址空间分布:

1
“$./Program1 &
[1] 12985
Printing from Lib.so 1
$ cat /proc/12985/maps
08048000-08049000 r-xp 00000000 08:01 1343432    ./Program1
08049000-0804a000 rwxp 00000000 08:01 1343432    ./Program1
b7e83000-b7e84000 rwxp b7e83000 00:00 0
b7e84000-b7fc8000 r-xp 00000000 08:01 1488993    /lib/tls/i686/cmov/libc-2.6.1.so
b7fc8000-b7fc9000 r-xp 00143000 08:01 1488993    /lib/tls/i686/cmov/libc-2.6.1.so
b7fc9000-b7fcb000 rwxp 00144000 08:01 1488993    /lib/tls/i686/cmov/libc-2.6.1.so
b7fcb000-b7fce000 rwxp b7fcb000 00:00 0
b7fd8000-b7fd9000 rwxp b7fd8000 00:00 0
b7fd9000-b7fda000 r-xp 00000000 08:01 1343290    ./Lib.so
b7fda000-b7fdb000 rwxp 00000000 08:01 1343290    ./Lib.so
b7fdb000-b7fdd000 rwxp b7fdb000 00:00 0
b7fdd000-b7ff7000 r-xp 00000000 08:01 1455332    /lib/ld-2.6.1.so
b7ff7000-b7ff9000 rwxp 00019000 08:01 1455332    /lib/ld-2.6.1.so
bf965000-bf97b000 rw-p bf965000 00:00 0          [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]
$ kill 12985
[1]+  Terminated              ./Program1

可以看到,整个进程虚拟地址空间中,多出了几个文件的映射。Lib.so与Program1一样,它们都是被操作系统用同样的方法映射至进程的虚拟地址空间,只是它们占据的虚拟地址和长度不同。

地址无关代码

希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术。

分析模块中各种类型的地址引用方式,把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样就得到了4种情况:

  • 第一种是模块内部的函数调用、跳转等。
  • 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
  • 第三种是模块外部的函数调用、跳转等。
  • 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。

image.png

对于模块外部的地址引用方式,ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用

image.png

可以使用objdump来查看GOT的位置:

1
$ objdump -h pic.so
...
17 .got       00000010  000015d0  000015d0  000005d0  2**2
                  CONTENTS, ALLOC, LOAD, DATA
...

image.png

延迟绑定

在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。

ELF使用PLT(Procedure Linkage Table)的方法来实现延迟绑定。

5.3 动态链接相关结构

在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker)。
在Linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。

.interp 段

并不是所有系统的动态链接器都位于 /lib/ld.so,.interp 段中可以查看

1
$ objdump -s a.out

a.out:     file format elf32-i386
Contents of section .interp:
 8048114 2f6c6962 2f6c642d 6c696e75 782e736f  /lib/ld-linux.so
 8048124 2e3200

.dynamic 段

这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。

1
2
3
4
5
6
7
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;

使用readelf工具可以查看“.dynamic”段的内容

1
readelf -d Lib.so

使用 ldd 命令可以查看一个可执行程序或者一个 动态共享库依赖于哪些动态共享库

1
$ ldd Program1
        linux-gate.so.1 =>  (0xffffe000)
        ./Lib.so (0xb7f62000)
        libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e0d000)
        /lib/ld-linux.so.2 (0xb7f66000)

.dynsym 动态符号表

与“.symtab”不同的是,“.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有“.dynsym”和“.symtab”两个表,“.symtab”中往往保存了所有符号,包括“.dynsym”中的符号。
与“.symtab”类似,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表。静态链接时叫做符号字符串表“.strtab”(String Table),在这里就是动态符号字符串表“.dynstr”(Dynamic String Table);由于动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(“.hash”)。我们可以用readelf工具来查看ELF文件的动态符号表及它的哈希表:

1
$readelf -sD Lib.so
......

动态链接重定位表

动态链接的可执行文件使用的是PIC方法,但这不能改变它需要重定位的本质。对于动态链接来说,如果一个共享对象不是以PIC模式编译的,那么它也是需要在装载时被重定位的。

如果一个共享对象是PIC模式编译的,对于使用PIC技术的可执行文件或共享对象来说,虽然它们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,因为代码段中绝对地址相关的部分被分离了出来,变成了GOT,而GOT实际上是数据段的一部分。除了GOT以外,数据段还可能包含绝对地址引用。

在静态链接中,目标文件里面包含有专门用于表示重定位信息的重定位表,比如“.rel.text”表示是代码段的重定位表,“.rel.data”是数据段的重定位表。

动态链接的文件中,也有类似的重定位表分别叫做“.rel.dyn”和“.rel.plt”,它们分别相当于 “.rel.text”和“.rel.data”。“.rel.dyn”实际上是对数据引用的修正,它所修正的位置位于“.got”以及数据段;而“.rel.plt”是对函数引用的修正,它所修正的位置位于“.got.plt”。我们可以使用readelf来查看一个动态链接的文件的重定位表:

1
readelf -r Lib.so

动态链接时进程堆栈初始化信息

可执行文件有几个段(“Segment”)、每个段的属性、程序的入口地址(因为动态链接器到时候需要把控制权交给可执行文件)等。

5.4 动态链接的步骤和实现

动态链接器自举

动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。首先动态链接器本身不可以依赖于其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。

对于第二个条件,动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)。

装载共享对象

完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。

相同符号的装载

当有两个不同的模块定义了同一个符号,Linux下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。装载顺序是按照广度优先的顺序进行的。
(我理解在同一个动态库的不同版本之间,也存在这个问题)。

重定位和初始化

当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正。

重定位完成之后,如果某个共享对象有“.init”段,那么动态链接器会执行“.init”段中的代码,用以实现共享对象特有的初始化过程,比如最常见的,共享对象中的C++的全局/静态对象的构造就需要通过“.init”来初始化。相应地,共享对象中还可能有“.finit”段,当进程退出时会执行“.finit”段中的代码,可以用来实现类似C++全局对象析构之类的操作。

Linux动态链接器实现

动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序,可以直接在命令行下面运行

1
/lib/ld-linux.so.2

其实Linux的内核在执行execve()时不关心目标ELF文件是否可执行(文件头e_type是ET_EXEC还是ET_DYN),它只是简单按照程序头表里面的描述对文件进行装载然后把控制权转交给ELF入口地址(没有“.interp”就是ELF文件的e_entry;如果有“.interp”的话就是动态链接器的e_entry)。这样我们就很好理解为什么动态链接器本身可以作为可执行程序运行,这也从一个侧面证明了共享库和可执行文件实际上没什么区别,除了文件头的标志位和扩展名有所不同之外,其他都是一样的。Windows系统中的EXE和DLL也是类似的区别,DLL也可以被当作程序来运行,Windows提供了一个叫做rundll32.exe的工具可以把一个DLL当作可执行文件运行。

具体逻辑这里不细展开。

5.5 显式运行时链接

支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接(Explicit Run-time Linking),有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。从前面我们了解到的来看,如果动态链接器可以在运行时将共享模块装载进内存并且可以进行重定位等操作,那么这种运行时加载在理论上也是很容易实现的。而且一般的共享对象不需要进行任何修改就可以进行运行时装载,这种共享对象往往被叫做动态装载库(Dynamic Loading Library),其实本质上它跟一般的共享对象没什么区别,只是程序开发者使用它的角度不同。

  • dlopen() 函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程
  • dlsym() 函数基本上是运行时装载的核心部分,我们可以通过这个函数找到所需要的符号
  • dlclose()的作用跟dlopen()刚好相反,它的作用是将一个已经加载的模块卸载
  • 调用dlopen()、dlsym()或dlclose()以后,可以调用dlerror()函数来判断上一次调用是否成功

6. linux 共享库的组织

6.1 共享库版本兼容性

共享库兼容性

下图描述了各种操作导致ABI接口兼容性变化的情况:

image.png

版本命名

libname.so.x.y.z

最前面使用前缀“lib”、中间是库的名字和后缀“.so”,最后面跟着的是三个数字组成的版本号。“x”表示主版本号(Major Version Number),“y”表示次版本号(Minor Version Number),“z”表示发布版本号(Release Version Number)。

一般不同主版本号之间不兼容;高版本次版本号兼容低版本次版本号;发布版本号之间互相兼容。

SO-NAME

每个共享库都有一个对应的“SO-NAME”,这个SO-NAME即共享库的文件名去掉次版本号和发布版本号,保留主版本号。比如一个共享库叫做libfoo.so.2.6.1,那么它的SO-NAME即libfoo.so.2。很明显,“SO-NAME”规定了共享库的接口,“SO-NAME”的两个相同共享库,次版本号大的兼容次版本号小的。

Linux中提供了一个工具叫做“ldconfig”,当系统中安装或更新一个共享库时,就需要运行这个工具,它会遍历所有的默认共享库目录,比如/lib、/usr/lib等,然后更新所有的软链接,使它们指向最新版的共享库;如果安装了新的共享库,那么ldconfig会为其创建相应的软链接。

6.2 符号版本(兼容性)

SO-NAME 解决不了次版本号低版本不兼容高版本的问题。

Linux下的Glibc从版本2.1之后开始支持一种叫做基于符合的版本机制(Symbol Versioning)的方案。这个方案的基本思路是让每个导出和导入的符号都有一个相关联的版本号,它的实际做法类似于名称修饰的方法。

与以往简单地将某个共享库的版本号重新命名不同(比如将libfoo.so.1.2升级到libfoo.so.1.3),当我们将libfoo.so.1.2升级至1.3时,仍然保持libfoo.so.1这个SO-NAME,但是给在1.3这个新版中添加的那些全局符号打上一个标记,比如“VERS_1.3”。那么,如果一个共享库每一次次版本号升级,我们都能给那些在新的次版本号中添加的全局符号打上相应的标记,就可以清楚地看到共享库中的每个符号都拥有相应的标签,比如“VERS_1.1”、“VERS_1.2”、“VERS_1.3”、“VERS_1.4”。

在动态库次版本不兼容的时候,可能看到类似如下错误:

1
./main
./main: ./lib.so: version `VERS_1.2' not found (required by ./main)

可以用 strings 命令查看共享库支持的符号版本

1
2
3
4
5
strings /lib64/libc.so.6 | grep GLIBC
GLIBC_2.2.5
GLIBC_2.2.6
GLIBC_2.3
......

6.3 共享库系统路径

目前大多数包括Linux在内的开源操作系统都遵守一个叫做FHS(File Hierarchy Standard)的标准,这个标准规定了一个系统中的系统文件应该如何存放,包括各个目录的结构、组织和作用,这有利于促进各个开源操作系统之间的兼容性。共享库作为系统中重要的文件,它们的存放方式也被FHS列入了规定范围。FHS规定,一个系统中主要有两个存放共享库的位置,它们分别如下:

  • /lib,这个位置主要存放系统最关键和基础的共享库,比如动态链接器、C语言运行库、数学库等,这些库主要是那些/bin和/sbin下的程序所需要用到的库,还有系统启动时需要的库。
  • /usr/lib,这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些共享库一般不会被用户的程序或shell脚本直接用到。这个目录下面还包含了开发时可能会用到的静态库、目标文件等。
  • /usr/local/lib,这个目录用来放置一些跟操作系统本身并不十分相关的库,主要是一些第三方的应用程序的库。比如我们在系统中安装了python语言的解释器,那么与它相关的共享库可能会被放到/usr/local/lib/python,而它的可执行文件可能被放到/usr/local/bin下。GNU的标准推荐第三方的程序应该默认将库安装到/usr/local/lib下。

6.4 共享库查找过程

动态链接的模块所依赖的模块路径保存在“.dynamic”段里面,由DT_NEED类型的项表示。动态链接器对于模块的查找有一定的规则:

  • 如果DT_NEED里面保存的是绝对路径,那么动态链接器就按照这个路径去查找;
  • 如果DT_NEED里面保存的是相对路径,那么动态链接器会在/lib、/usr/lib和由/etc/ld.so.conf配置文件指定的目录中查找共享库。为了程序的可移植性和兼容性,共享库的路径往往是相对的。

ld.so.conf是一个文本配置文件,它可能包含其他的配置文件,这些配置文件中存放着目录信息。

如果动态链接器在每次查找共享库时都去遍历这些目录,那将会非常耗费时间。所以Linux系统中都有一个叫做ldconfig的程序,这个程序的作用是为共享库目录下的各个共享库创建、删除或更新相应的SO-NAME(即相应的符号链接),这样每个共享库的SO-NAME就能够指向正确的共享库文件;并且这个程序还会将这些SO-NAME收集起来,集中存放到/etc/ld.so.cache文件里面,并建立一个SO-NAME的缓存。

很多软件包的安装程序在往系统里面安装共享库以后都会调用ldconfig。

6.5 环境变量

LD_LIBRARY_PATH

动态链接器会按照下列顺序依次装载或查找共享对象(目标文件):

  • 由环境变量LD_LIBRARY_PATH指定的路径。
  • 由路径缓存文件/etc/ld.so.cache指定的路径。
  • 默认共享库目录,先/usr/lib,然后/lib。

LD_PRELOAD

系统中另外还有一个环境变量叫做LD_PRELOAD,这个文件中我们可以指定预先装载的一些共享库甚或是目标文件。

LD_DEBUG

这个变量可以打开动态链接器的调试功能,当我们设置这个变量时,动态链接器会在运行时打印出各种有用的信息。

可以设置值:

1
“files”:显示程序依赖于哪个共享库并且按照什么步骤装载和初始化,共享库装载时的地址等。
“bindings”显示动态链接的符号绑定过程。
“libs”显示共享库的查找过程。
“versions”显示符号的版本依赖关系。
“reloc”显示重定位过程。
“symbols”显示符号表查找过程。
“statistics”显示动态链接过程中的各种统计信息。

6.6 共享库的创建和安装

创建

1
$gcc –shared –Wl,-soname,my_soname –o library_name source_files library_files

如果我们不使用-soname来指定共享库的SO-NAME,那么该共享库默认就没有SO-NAME,即使用ldconfig更新SO-NAME的软链接时,对该共享库也没有效果。

1
strip libfoo.so

去除符号和调试信息以后的文件往往比之前要小很多,一般只有原来的一半大小,甚至不到一半。除了使用“strip”工具,我们还可以使用ld的“-s”和“-S”参数,使得链接器生成输出文件时就不产生符号信息。

“-s”和“-S”的区别是:“-S”消除调试符号信息,而“-s”消除所有符号信息。我们也可以在gcc中通过“-Wl,-s”和“-Wl,-S”给ld传递这两个参数。

安装

最简单的办法就是将共享库复制到某个标准的共享库目录,如/lib、/usr/lib等,然后运行ldconfig即可。

共享库构造和析构函数

GCC提供了一种共享库的构造函数,只要在函数声明时加上“attribute((constructor))”的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行。如果我们使用dlopen()打开共享库,共享库构造函数会在dlopen()返回之前被执行。

1
2
void __attribute__((constructor)) init_function(void);
void __attribute__((destructor)) fini_function (void);

7. 回归问题

7.1 version not found 问题

1
import torch
import skimage

ImportError: /usr/lib64/libstdc++.so.6: version `CXXABI_1.3.9' not found (required by /root/miniconda3/lib/python3.9/site-packages/scipy/sparse/_sparsetools.cpython-39-x86_64-linux-gnu.so)

通过 strings 命令查看 /usr/lib64/libstdc++.so.6 的符号版本

1
(base) [root@79579837774f /]# strings /usr/lib64/libstdc++.so.6|grep CXXABI
CXXABI_1.3
CXXABI_1.3.1
CXXABI_1.3.2
CXXABI_1.3.3
CXXABI_1.3.4
CXXABI_1.3.5
CXXABI_1.3.6
CXXABI_1.3.7
CXXABI_TM_1

那为什么先加载 skimage 就没问题呢?

通过 ldd 命令或者 readelf -d 命令可以查看报错的库的动态库依赖情况
ldd 命令

1
(base) [root@79579837774f /]# ldd /root/miniconda3/lib/python3.9/site-packages/scipy/sparse/_sparsetools.cpython-39-x86_64-linux-gnu.so
        linux-vdso.so.1 =>  (0x00007ffc679ea000)
        /$LIB/libonion.so => /lib64/libonion.so (0x00007f3124f7e000)
        libstdc++.so.6 => /root/miniconda3/lib/python3.9/site-packages/scipy/sparse/../../../../libstdc++.so.6 (0x00007f31248e4000)
        libgcc_s.so.1 => /root/miniconda3/lib/python3.9/site-packages/scipy/sparse/../../../../libgcc_s.so.1 (0x00007f3124f63000)
        libc.so.6 => /usr/lib64/libc.so.6 (0x00007f3124516000)
        libdl.so.2 => /usr/lib64/libdl.so.2 (0x00007f3124312000)
        libm.so.6 => /usr/lib64/libm.so.6 (0x00007f3124010000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f3124e65000)

readelf -d 命令

1
(base) [root@79579837774f /]# readelf -d /root/miniconda3/lib/python3.9/site-packages/scipy/sparse/_sparsetools.cpython-39-x86_64-linux-gnu.so

Dynamic section at offset 0x39bc58 contains 27 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libstdc++.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000f (RPATH)              Library rpath: [$ORIGIN/../../../..]
......

可以看到它依赖的库是 miniconda3 目录下自带的一个 libstdc++.so,/root/miniconda3/lib/libstdc++.so.6,查看它的符号

1
(base) [root@79579837774f /]# strings /root/miniconda3/lib/libstdc++.so.6|grep CXXABI
......
CXXABI_1.3.9
......

可以看到果然是有报错的符号版本的。

也就是说操作系统的 libstdc++.so 库和 miniconda3 的 libstdc++.so 主版本相同,都是 6,所以动态库加载时是可以互相兼容的, 但是次版本号不一致,导致低版本不兼容高版本。若先引入 libtorch,则会加载低次版本的 libstdc++.so,导致依赖高此版本号的 skimage 模块不兼容。先引入 skimage,则 skimage 和 libtorch 都是兼容的,可以正常工作。

7.2 undefined symbol 问题

背景是业务中有一些 c++ 的 so 库,依赖了 c++ 版本的 torch / cuda 相关的 so 库,这些 c++ 版本的 torch / cuda 相关的 so 是由业务自行编译的。

1
2
3
4
5
import ctypes
ctypes.cdll.LoadLibrary("/lib_one_cpp_lib_about_libtorch.so")
import torch

ImportError: /root/miniconda3/lib/python3.9/site-packages/torch/lib/libtorch_cuda_cpp.so: undefined symbol: _ZTIN4c10d4WorkE

先引入依赖了 libtorch 的c++共享库,再通过引入 pyhton 版本的 torch 模块,发现后引入的报符号未定义错误

1
2
3
4
5
import torch
import ctypes
ctypes.cdll.LoadLibrary("/lib_one_cpp_lib_about_libtorch.so")

OSError: xxxx.so: undefined symbol: _ZNK3c104Type14isSubtypeOfExtERKSt10shared_ptrIS0_EPSo

同样会报服务错误。 c++ 版本的 torch / cuda 相关库是业务自行编译的版本,其 SO-NAME 和 python 版本的一样,但是 ABI 接口不能保证和 python 版本 torch 模块的兼容。进程根据 SO-NAME 加载 so 库时,只会加载先引入的那个,后引入的因为依赖的 SO-NAME 已经在前面映射过了,会直接复用。那么有依赖后加载库的地方,符号解析就会发生错误。

☞ 参与评论