链接装载与库
程序员的自我修养——读书笔记。
主要关于链接、装载、共享库。
在线书籍:https://jp311.github.io/a-programmer-s-work/cover.html
第二部分 静态链接
第2章 编译连接
代码到可执行文件的过程:预编译 -> 编译 -> 汇编 -> 链接
编译器干了些什么:
- 词法分析
- 语法分析
- 语义分析
- 中间语言生产
汇编器干什么?老老实实将汇编语言翻译成机器语言,生成一个可执行文件。
静态链接器干什么?
现代软件开发中往往拥有成千上万个模块,按照层次化以及模块化存储和组织源代码,可以让每个模块单独开发调试。在一个程序被划分为多个模块后,模块之间的如何组合的问题可以归结为模块之间如何通信的问题:
- 一种是模块间的函数调用
- 一种是模块间的变量访问
链接的主要内容就是将模块间相互引用的部分处理好,将一些指令对其他符号地址的引用加以修正,主要过程包括:
- 地址和空间分配 Address and Storage Allocation
- 符号决议 Symbol Resolution
- 重定位 Relocation
第3章 目标文件
为了探索链接的过程,我们需要对目标文件进行深度剖析。
COFF(Common File Format)是目标文件的祖宗,在这之下,winodws平台的PE(Portable Executable)和 linux平台下ELF(Executable Linkable Format)是两个变体。
使用file命令可以查看文件格式
目标文件以段(Segment)或者节(Section)格式存储,包括:
- file header
- text section 代码段
- data section 数据段
- rodata section
- bss setion等等
总体来说,程序源代码被编译后主要分成两种段:程序指令和程序数据。
数据指令分开存储好处:
- 权限不同,代码段只读,数据段读写
- 现代CPU的缓存分离,数据缓存和代码缓存
- 多程序副本可以共享代码段
使用objdump命令可以查看目标文件,比如
objdump -h(human) xxx.o
将分两行显示一个段,第二行为段属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 .\SimpleSection.o: file format pe-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000060 0000000000000000 0000000000000000 00000154 2**4
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000010 0000000000000000 0000000000000000 000001b4 2**4
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000010 0000000000000000 0000000000000000 00000000 2**4
ALLOC
3 .drectve 00000024 0000000000000000 0000000000000000 000001c4 2**2
CONTENTS, ALLOC, LOAD, DATA
4 .rdata 00000010 0000000000000000 0000000000000000 000001e8 2**4
CONTENTS, ALLOC, LOAD, READONLY, DATA
5 .xdata 00000018 0000000000000000 0000000000000000 000001f8 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .pdata 00000018 0000000000000000 0000000000000000 00000210 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
7 .rdata$zzz 00000020 0000000000000000 0000000000000000 00000228 2**4
CONTENTS, ALLOC, LOAD, READONLY, DATA
重定位表
我们可以使用readelf命令来查看ELF文件
-h 文件头选项 -S 段表选项 -s 符号表选项
我们注意到一个名称为“.rel.text”的段,它正是针对text段的重定位表。相似的,还有“.rel.data”的段,它是针对data段的重定位表。
字符串表
ELF文件中使用了许多字符串,例如段名、变量名等。常用的一个做法是把字符串集中起来,用\0作为分隔符(这玩意就是字符串终止符),记录偏移量即可知道每个字符串的内容。在引用字符串的时候只要告诉引用的地方你需要的字符串对应的偏移量就好了。
常见的段名为.strtab和.shstrtab,分别表示字符串表和段表字符串表,顾名思义,一个用来保存程序中出现的普通的字符串,比如变量名和函数名(这些都统称为符号)。另一个用来保存段表中段的名称。
符号表
符号表段名一般称为“.symtab”,是一个Elf32_Sym的数组,数组中每个元素对应一个符号。
1 | typedef struct{ |
符号值一般变量或者函数的地址。
符号修饰
C语言编译后,相应的符号名前会添加下划线,比如”foo“变为”_foo“,这是为了解决多种语言目标文件之间的符号冲突,但没有彻底解决。
CPP引用了名称空间,可以解决多模块的符号冲突问题。
CPP自身,比如函数重载,同名函数不同参数列表,又引入了CPP的符号修饰。如何保持CPP与C的兼容?extern “C”关键字。
CPP编译器,会将extern “C”括号内的函数变量等当作C语言代码处理,从而避免了符号修饰。
1 |
|
强弱符号
强符号:函数 以及 初始化的全局变量
弱符号:未初始化的全局变量
强符号和弱符号的规则大致如下:
- 规则1:不允许强符号被多次定义(不同的目标文件中不能有同名的强符号);如果重复定义则链接器报符号重复定义错误。
- 规则2:如果一个符号在某个文件中是强符号,其他文件中是弱符号。则按照强符号进行下一步。
- 规则3:如果一个符号在所有文件中都是弱符号,则按照占用字节数最大的弱符号继续进行下一步。
强引用:如果找不到外部符号,连接器会报符号未定义错误,这种被称为强引用。
弱引用:弱引用在没有找到符号定义的时候,链接器并不对该引用报错。而是把它默认置为0或一个特殊值。
1 | __attribute__((weakref)) void foo(); |
弱符号和弱引用有什么用呢?简单的来说就是在库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数。
第4章 静态链接
空间与地址分配
链接就是将几个目标文件合并成一个输出文件,那么这个输出文件的空间该怎么分配给输入目标文件呢?
相似段合并,不同目标文件的相同段合并到一起。
两步走:
- 第一步 空间与地址分配:扫描所有输入目标文件,获取它们的各个段的长度、属性和位置,将输入目标文件中的符号表中所有的符号定义和符号引用搜集起来,统一放到一个全局符号表中。
这一步中链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算输出文件各个段合并后的长度和位置,并建立映射关系。
- 第二步符号解析和重定位: 使用上面搜集到的信息,读取输入文件段中的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址。
第一步中,连接器为目标文件分配地址与空间有两个含义:
- 分配在输出的可执行文件的空间
- 分配装载后的虚拟地址中的虚拟空间
也就是说第一步各个段在链接后虚拟地址已经确定,各个符号在全局符号表中的虚拟地址也已经确定。
符号解析与重定位
objdump命令 -d 反汇编 -r 重定位表
从单个模块看起,如果这个模块用到了其他模块的变量,编译器怎么处理?很简单,将它的地址设置为0或者某个常量0xFFFFFC,然后把真正的地址计算工作留给链接器。
问题又来了,链接器怎么知道哪些指令的哪些部分需要调整?该怎么样调整?于是,重定位表应运而生。
重定位表的主要内容:
- OFFSET
- TYPE
- VALUE
第三部分 装载与动态链接
第6章 可执行文件的装载与进程
最通常的情形:创建一个进程,然后装载相应的可执行文件并执行。
- 创建一个独立的虚拟地址空间(分配一个页目录)
- 读取可执行文件头,建立可执行文件与虚拟地址空间的映射关系
- 设置CPU指令寄存器为可执行文件入口地址,启动运行。
页对齐与内存浪费:每个段在映射时的长度是系统页长度的整数倍,如果不是整数倍,那么多余的部分也将占满一个页,这就造成了内容浪费。
一个比较简单的方案是:相同权限的段,合并到一起当作一个段进行映射。
图6-9 ELF与Linux进程虚拟空间映射关系
第7章 动态链接
为什么需要动态链接
静态链接的缺点:
- 内存空间浪费
- 程序更新麻烦
比如我有多个模块同时用到了lib.o,采用静态链接,那么lib.o的内容就会被复制多份,物理内存上存在多份实例;比如我有个程序Program1用到了厂商A提供的lib.o,如果A更新了lib.o,那么整个程序Program1就要重新编译链接,假设程序大小20MB,同时用户也得重新下载这个20MB的文件。
动态链接的基本思想
将符号重定位推迟到程序运行时。
ELF动态链接文件:
- Linux中称为动态共享对象 Dynamic Shared Objects,共享对象,so
- Windows中称为动态链接库 Dynamic Linking Library ,dll
动态链接运行时地址空间分布
静态链接的可执行文件来说,整个进程只有一个文件要被映射。但是对于动态链接来说,除了要映射可执行文件外,还要映射它依赖的共享目标文件。这种情况下,地址空间是怎么分布的呢?
Linux的共享库装载地址通常从0xb7efc000开始.
地址无关代码
动态库被映射进程序的地址空间,这意味着指令被多份程序共享,那么如何保证程序的正确性?如果动态库使用了自己的全局变量?
基本想法:将动态库中的指令中会被改变地址的部分剥离至data段,每个程序有自己的一份动态库的data拷贝。
实现细节
(1)模块内部的函数调用
这种比较简单,调用者和被调用者处于同一个模块,两者偏移是固定的,采用相对地址调用即可。
(2)模块内部的数据访问
我们知道,要实现代码的共享,指令无法包含绝对地址,只能包含相对地址。同时,要实现指令的共享,那么指令的相对地址也无法改变(代码段是只读的,我们不能改变)。
在模块内部的数据访问中,幸好,同一模块的数据段和代码段之间的偏移是固定的,我们只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。
(3)模块间的数据访问
模块间的数据访问比较复杂,因为模块间的数据访问目标地址需要等到装载时才能决定。但是代码的相对地址是在编译期间决定的,无法等到装载期间,怎么办?
计算机科学领域的任何问题都可以通过一个中间层解决。
我们引入一个中间层——GOT,Global Offset Table,全局偏移表。
我们将GOT设置为目标文件中的一个段,然后让指令中的数据访问指向它,这样相对偏移就是固定的。同时,GOT的内容指向数据的真实位置(通过装载时重定位),这样我们就得到地址无关代码。
(4)模块间的函数访问
该场景也可以通过GOT解决。
共享模块的全局变量问题
定义在共享模块的全局变量会被当作模块间数据访问,通过GOT实现访问。这是因为编译器无法判断全局变量是否跨模块。
热知识:同一进程的两个线程对于lib.so的全局变量修改是互相可以看到的,因为他两个处于同一个地址空间。线程共享代码段、数据段、堆。线程不共享栈和寄存器。
数据段的地址无关性
假设有这么一段代码:
1 | static int a; |
指针p指向a,但是a的地址会随着共享对象装载地址改变而改变,有什么办法能够解决呢?
装载时重定位可以解决这个问题。
Q:为什么代码段的绝对地址不使用装载时重定位解决呢?
A:装载时重定位会修改代码中的绝对地址,这样每个进程看到的都是不一样的代码,这意味代码无法被共享,从而失去了动态链接的意义。
动态链接的性能优化
动态链接的好处是减轻了程序体积,易于发布更新,那么它有没有缺点呢?有的。
- 减慢程序运行速度
- 减慢程序启动速度
那有没有什么办法能改变它呢?有的
延迟绑定,其基本思想就是等到函数第一次被用到时才进行绑定(符号查找,重定位),这将大大加快程序的启动速度。
实现细节:PLT(Procedure Linkage Table)精巧的指令序列
每个外部函数都有一个对应的桩函数,函数调用就是对桩函数的调用,在桩函数内部通过 GOT 实现跳转、实现运行时装载。这样的“桩函数”称为 PLT(Procedure Linkage Table)项。
1 | func@plt: |
- 链接器将 GOT 中 func 所对应的项初始化为上面的“push index”指令的地址,使得首次执行此函数时相当于什么都没有做。从第二次调用此函数开始,就会通过 func@GOT 直接调用外部函数并直接返回,而不会执行“push index”及以下的几条指令。
- index 是 func 这个符号在重定位表“.rel.plt”中的下标。将 index 压入堆栈。
- 将当前模块的 ID 压入堆栈。(模块 ID 是动态链接器分配的)
- 以 moduleID,index 为参数,调用动态链接器的
_dl_runtime_resolve(),完成符号解析和重定位,并将 func 的真正地址填入 func@GOT。
动态链接相关结构
.interp段 保存动态链接器的路径
.dynamic段 动态链接的基本信息
.dynsym段 动态符号表
.dynsym.rel段 动态链接重定位表
显式运行时链接
动态库
- dlopen
- dlsym
- dlclose
- dlerror
符号优先级:先装入优先,没有覆盖一说。
第8章 共享库
共享库命名规则
在 Linux 中,共享库的版本问题通过文件名中包含版本号这一简单途径得以解决。共享库的命名规则是 libname.so.x.y.z:
其中x是主版本号,y是次版本号,z是发布版本号
主版本号表示重大升级,不同主版本号之间互不兼容。
次版本号表示增量更新,高次版本号向后兼容低次版本号。
发布版本号表示某些错误修正,相同主版本号、次版本号,不同发布版本号的共享库完全兼容。
SO-NAME
SO-NAME作为一种命名机制,记录了共享库的依赖关系。
SO-NAME 就是 libname.so.x,只保留主版本号。利用“SO-NAME 相同的两个共享库,次版本号大的兼容次版本号小的”这一特性,系统会为每个共享库创建一个以 SO-NAME 命名的软链接,主版本号相同的共享库只保留次版本号最高的那个。
这样,所有使用共享库的模块在编译链接时只要指定主版本号(SO-NAME)而无需指定详细的版本号;及时删除过时的冗余共享库,节约了磁盘空间。
当系统中安装一个新的共享库(就是把共享库放到 /lib、/usr/lib 或 /usr/local/lib,具体由 /etc/ld.so.conf 指定)时,需要使用 ldconfig 工具遍历共享库目录,创建或更新 SO-NAME 软链接,使它们指向最新的共享库;更新 SO-NAME 的缓存(/etc/ld.so.cache),加快共享库的查找过程。
链接名
只需要在编译器命令行中填写-lXXX,即可自动查找链接libXXX.so.x.y.z的动态库。
次版本号交会问题
如果动态链接器在进行链接时,只进行主版本号的判断,则若某个程序依赖次版本号更高的共享库,动态链接器就可能查不出版本冲突。
比如我们的目录内只有2.4版本的库,但是程序依赖的是2.6版本库,SO-NAME只记录了2的主版本。
现在的系统基于符号版本解决这个问题,这里按下不表。
常见环境变量
(1)LD_LIBRARY_PATH环境变量
用于临时改变某个应用程序的共享库查找路径,是由若干个路径组成的环境变量,每个路径之间由冒号隔开。
动态链接器查找共享库的顺序为:
- 由环境变量LD_LIBRARY_PATH指定的路径;
- 由路径缓存文件/etc/ld.so.cache指定的路径;
- 默认共享库目录,先/usr/lib,然后/lib。
(2)LD_PRELOAD环境变量
这个变量指定预先装载的一些共享库或是目标文件。在LD_PRELOAD里面指定的文件会在动态链接器按照固定规则搜索共享库之前装载,比LD_LIBRARY_PATH里面指定的目录中的共享库还要优先。
无论程序是否依赖于它们,LD_PRELOAD里面指定的共享库或目标文件都会被装载。
(3)LD_DEBUG环境变量
这个变量可以打开动态链接器的调试功能,当设置这个变量时,动态链接器会在运行时打印出各种有用的信息,对于开发和调试共享库有很大帮助。
第10章 内存
程序的地址空间布局
栈
函数调用流程:
- 参数和寄存器压栈
- 下一条指令地址压栈
- 跳转函数体执行
- 恢复寄存器
- 恢复返回地址并跳转
函数返回值传递
(1)小字节对象(8字节以下)寄存器
(2)大字节对象
栈上开辟临时对吸纳高,完成两次拷贝(拷贝构造、拷贝赋值)
细节:
- main函数在栈上开辟空间,作为传递返回值的临时对象temp
- temp地址作为额外参数传递给函数
- 函数将数据拷贝给temp,寄存器存temp地址返回
- main将返回的对象(其地址)再拷贝给承接对象
返回值优化RVO
返回匿名对象时,直接将其构造在栈上的临时对象temp上。
第11章 运行库
变长参数
首先必须明确函数参数在栈上的分布,一般来说是函数参数自左向右压栈。变长参数获取方法:
1 | va_list ap; //定义类型为va_list的变量 |
等同于
1 |
运行库与多线程
事实上glibc除了C标准库之外,还有几个辅助程序运行的运行库,这几个文件可以称得上是真正的”运行库”。它们就是/usr/lib/crt1.o、/usr/lib/crti.o和/usr/lib/crtn.o。
crt1.o里面包含的就是程序的入口函数_start,由它负责调用__libc_start_main初始化libc并且调用main函数进入真正的程序主体。实际上最初开始的时候它并不叫做crt1.o,而是叫做crt.o,包含了基本的启动、退出代码。由于当时有些链接器对链接时目标文件和库的顺序有依赖性,crt.o这个文件必须被放在链接器命令行中的所有输入文件中的第一个,为了强调这一点,crt.o被更名为crt0.o,表示它是链接时输入的第一个文件。
后来由于C++的出现和ELF文件的改进,出现了必须在main()函数之前执行的全局/静态对象构造和必须在main()函数之后执行的全局/静态对象析构。为了满足类似的需求,运行库在每个目标文件中引入两个与初始化相关的段”.init”和”.finit”。运行库会保证所有位于这两个段中的代码会先于/后于main()函数执行,所以用它们来实现全局构造和析构就是很自然的事情了。
链接器在进行链接时,会把所有输入目标文件中的”.init”和”.finit”按照顺序收集起来,然后将它们合并成输出文件中的”.init”和”.finit”。但是这两个输出的段中所包含的指令还需要一些辅助的代码来帮助它们启动(比如计算GOT之类的),于是引入了两个目标文件分别用来帮助实现初始化函数的crti.o和crtn.o。
GCC平台相关目标文件
其实C++全局对象的构造函数和析构函数并不是直接放在.init和.finit段里面的,而是把一个执行所有构造/析构的函数的调用放在里面。
首先是crtbeginT.o及crtend.o,这两个文件是真正用于实现C++全局构造和析构的目标文件。
我们知道,C++这样的语言的实现是跟编译器密切相关的,而glibc只是一个C语言运行库,它对C++的实现并不了解。而GCC是C++的真正实现者,它对C++的全局构造和析构了如指掌。于是它提供了两个目标文件crtbeginT.o和crtend.o来配合glibc实现C++的全局构造和析构。
事实上是crti.o和crtn.o中的”.init”和”.finit”提供一个在main()之前和之后运行代码的机制,而真正全局构造和析构则由crtbeginT.o和crtend.o来实现。
运行库与多线程
线程局部存储实现
LS的用法很简单,如果要定义一个全局变量为TLS类型的,只需要在它定义前加上相应的关键字即可。对于GCC来说,这个关键字就是__thread,比如我们定义一个TLS的全局整型变量:
1 | __thread int number; |
对于MSVC来说,相应的关键字为__declspec(thread):
1 | __declspec(thread) int number; |
对于Windows系统来说,正常情况下一个全局变量或静态变量会被放到”.data”或”.bss”段中,但当我们使用__declspec(thread)定义一个线程私有变量的时候,编译器会把这些变量放到PE文件的”.tls”段中。
当系统启动一个新的线程时,它会从进程的堆中分配一块足够大小的空间,然后把”.tls”段中的内容复制到这块空间中,于是每个线程都有自己独立的一个”.tls”副本。所以对于用__declspec(thread)定义的同一个变量,它们在不同线程中的地址都是不一样的。
如果一个TLS变量还是CPP的对象,那它还需要构造析构。TLS表中保存了所有TLS变量的构造函数和析构函数的地址,Windows系统就是根据TLS表中的内容,在每次线程启动或退出时对TLS变量进行构造和析构。
另外一个问题是,既然同一个TLS变量对于每个线程来说它们的地址都不一样,那么线程是如何访问这些变量的呢?其实对于每个Windows线程来说,系统都会建立一个关于线程信息的结构,叫做线程环境块(TEB,Thread Environment Block)。这个结构里面保存的是线程的堆栈地址、线程ID等相关信息,其中有一个域是一个TLS数组,它在TEB中的偏移是0x2C。
C++全局构造与析构
在C++的世界里,入口函数还肩负着另一个艰巨的使命,那就是在main的前后完成全局变量的构造与析构。
在前面介绍glibc的启动文件时已经介绍了”.init”和”.finit”段,我们知道这两个段中的代码最终会被拼成两个函数_init()和_finit(),这两个函数会先于/后于main函数执行。
1 | _start -> __libc_start_main -> __libc_csu_init -> _init -> __do_global_ctors_aux: |
上面这段代码首先将__CTOR_LIST__数组的第一个元素当做数组元素的个数,然后将第一个元素之后的元素都当做是函数指针,并一一调用。
这段代码的意图非常明显,我们都可以猜到__CTOR_LIST__里面存放的是什么,没错,__CTOR_LIST__里面存放的就是所有全局对象的构造函数的指针。
对于每个编译单元(.cpp),GCC编译器会遍历其中所有的全局对象,生成一个特殊的函数,这个特殊函数的作用就是对本编译单元里的所有全局对象进行初始化。
1 | static void GLOBAL__I_Hw(void) |
GLOBAL__I_Hw作为特殊的函数当然也享受特殊待遇,一旦一个目标文件里有这样的函数,编译器会在这个编译单元产生的目标文件(.o)的".ctors"段里放置一个指针,这个指针指向的便是GLOBAL__I_Hw。
由于每个目标文件的.ctors段都只存储了一个指针(指向该目标文件的全局构造函数),因此拼接起来的.ctors段就成为了一个函数指针数组,每一个元素都指向一个目标文件的全局构造函数。这个指针数组不正是我们想要的全局构造函数的地址列表吗?
在合并crtbegin.o、用户目标文件和crtend.o时,链接器按顺序拼接这些文件的.ctors段,因此最终形成.ctors段.
【小实验】
在main前调用函数:
glibc的全局构造函数是放置在.ctors段里的,因此如果我们手动在.ctors段里添加一些函数指针,就可以让这些函数在全局构造的时候(main之前)调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 #include <stdio.h>
void my_init(void)
{
printf("Hello ");
}
typedef void (*ctor_t)(void);
//在.ctors段里添加一个函数指针
ctor_t __attribute__((section (".ctors"))) my_init_p = &my_init;
int main()
{
printf("World!\n");
return 0;
}如果运行此程序,结果将打印出:Hello World!
当然,事实上,gcc里有更加直接的办法来达到相同的目的,那就是使用__attribute__((constructor))
示例如下:
1
2
3
4
5
6
7
8
9
10
11 #include <stdio.h>
void my_init(void) __attribute__ ((constructor));
void my_init(void)
{
printf("Hello ");
}
int main()
{
printf("World!\n");
return 0;
}
析构过程
实际上正常的全局对象析构与前面介绍的构造在过程上是完全类似的,而且所有的函数、符号名都一一对应,比如”.init”变成了”.finit”、”__do_global_ctor_aux”变成了”__do_global_dtor_aux”、”CTOR_LIST“变成了”DTOR_LIST“等。
不过这样做的好处是为了保证全局对象构造和析构的顺序(即先构造后析构),链接器必须包装所有的”.dtor”段的合并顺序必须是”.ctor”的严格反序,这增加了链接器的工作量,于是后来人们放弃了这种做法,采用了一种新的做法,就是通过__cxa_atexit()在exit()函数中注册进程退出回调函数来实现析构。