由于局部非静态变量保存在寄存器或栈中,所以符号只包含函数、全局变量和静态变量。
判断符号的信息:
判断符号所在的节:
- 如果是函数,则在
.text
节中 - 如果是未初始化全局变量,则在
COMMIN
节中 - 如果是未初始化静态变量,以及初始化为0的全局变量或静态变量,则在
.bss
节中 - 如果是初始化过的全局变量或静态变量,则在
.data
节中
- 如果是函数,则在
判断符号类型:
- 如果是在当前文件中定义的函数或全局变量,则为全局符号
- 如果是在当前文件定义的静态函数或静态全局变量,则为局部符号
- 如果在别的文件中定义,则为外部符号
注意:局部非静态变量保存在栈或寄存器中,所以不考虑
保存在什么表,是根据符号的类型和定义情况,而定义和引用是从变量或函数的角度来看的
判断符号采用哪种定义:
- 在各个文件中确定同名全局符号的强弱,其中符号和初始化的全局符号为强符号,未初始化的全局符号为弱符号
- 如果存在多个同名的强符号,则会出现链接错误
- 如果存在一个强符号和多个同名的弱符号,则会采用强符号的定义
- 如果存在多个同名的弱符号,则会随机采用一个弱符号的定义
需要根据目标文件和库之间对符号解析的依赖关系,来确定命令行中输入文件的顺序,保证前面文件中未解析的符号,能在后面文件中得到解析。
重定位包含两层意思:分配内存地址,根据重定位表条目来修改占位符。
链接(Linking)是将各种代码和数据片段收集并组合成一个单一的文件的过程,然后该文件会被加载到内存中执行。该过程由链接器(Linker)程序自动执行。链接存在三种类型:
- 执行于编译时(Compile Time),即在源代码被翻译成机器代码时的传统静态链接
- 执行于加载时(Load Time),即程序被加载器(Loader)加载到内存并执行时的动态链接
- 执行于运行时(Run Time),即由应用程序来执行的动态链接
作用:链接的存在,使得分离编译(Separate Compilation)成为可能,一个大型应用程序可以分解成若干个小的模块,只需要对这些模块进行修改编译,然后通过链接器将其组合成大的可执行文件就行。
学习的意义:能帮助你构建大型程序,避免一些危险的编程错误,理解语言的作用域规则是如何实现的,理解其他重要的系统概念,比如加载和运行程序、虚拟内存、分页和内存映射,并且可以让你利用共享库。
本文使用的环境:运行Linux的x86-64系统,使用标准的ELF-64目标文件格式。
基本概念
大多数编译系统提供编译器驱动程序(Compiler Driver),使得用户可以调用预处理器、编译器、汇编器和链接器对程序进行编译。
如上图所示是一个示例程序,对于GNU编译系统,可以通过输入
1 | gcc -Og -o prog main.c sum.c // -Og 指定了优化级别 |
来调用GCC驱动程序,然后执行以下过程:
- GCC驱动程序运行C预处理器(cpp),将C源程序
main.c
翻译为ASCII码的中间文件main.i
。cpp [other arg] main.c /tmp/main.i
- GCC驱动程序运行C编译器(ccl),将
main.i
翻译成ASCII汇编语言文件main.s
。ccl /tmp/main.i -Og [other arg] -o /tmp/main.s
。 - GCC驱动程序运行汇编器(as),将
main.s
翻译成一个可重定位目标文件(Relocatable Object File)main.o
。as [other arg] -o /tmp/main.o /tmp/main.s
。 - 对
sum.c
执行相同的过程,得到sum.o
。 - GCC驱动程序运行链接器(ld),将
main.o
和sum.o
以及其他必要的系统目标文件组合起来,得到可执行目标文件(Executable Object File)prog
。ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o
。 - 在shell中输入
./prog
,则shell会调用操作系统的加载器(Loader)函数,将该可执行文件prog
复制到内存中,然后执行该程序。
可以发现存在不同的目标文件,主要包含:
可重定位目标文件:由各种不同的二进制代码和数据节(Section)组成,每一节是一个连续的字节序列,不同节存放不同的数据。
- 生成:由编译器和汇编器生成
- 用处:在编译时与其他可重定位目标文件合并起来,得到一个可执行目标文件。
可执行目标文件:包含二进制代码和数据
- 生成:将可重定位目标文件和静态库输入到链接器中,可产生可执行目标文件
- 用处:可被加载器直接复制到内存中并执行
共享目标文件:特殊类型的可重定位目标文件,可以在加载时或运行时被动态地加载进内存并链接。
注意:我们称目标模块(Object Module)为一个字节序列,而目标文件(Object File)是以文件形式存放在磁盘的目标模块。
目标文件是按照特定的目标文件格式进行组织的,Windews中使用可移植可执行(Portable Executable,PE)格式,Max OS-X使用Mach-O格式,x86-64 Linux和Unix使用可执行可链接格式(Executable and Linkable Format,ELF)。
以可重定位目标文件的ELF格式为例,如下图所示
ELF头(ELF header):包含生成该目标文件的系统的字大小和字节顺序、ELF头的大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数目。
.text
: 已编译程序的机器代码.rodata
: 只读数据,比如跳转表等等.data
: 保存已初始化的全局变量和静态变量(全局和局部).bss
: 保存未初始化的静态变量(全局和局部),以及被初始化为0的全局变量和静态变量(全局和局部)注意:
- 在目标文件中
.bss
不占据实际的空间,只是一个占位符 - 之所以要将初始化和未初始化分成两个节,因为在目标文件中,未初始化变量不需要占据任何实际的磁盘空间,运行时,再在内存中分配这些变量,初始值为0。
- 局部变量在运行时只保存在栈中,不出现在
.data
和.bss
中 - 静态局部变量不受函数栈的管理,所以也要在这个位置创建
- 在目标文件中
.symtab
: 符号表,存放在程序中定义和引用的函数和变量的符号信息- 注意:不包含局部非静态变量条目,因为该变量是由栈管理的
.rel.text
:.text
节的重定位信息,可执行目标文件中需要修改的指令地址.rel.data
:.data
节的重定位信息,合并后的可执行目标文件中需要修改的指针数据的地址注意:
- 一般已初始化的全局变量,如果初始值是一个全局变量地址或外部定义函数的地址,就需要被修改
- 可执行目标文件已完成重定位,就不需要
.rel.text
和.rel.data
数据节了。
.debug
:调试符号表,其条目是程序中定义的局部变量和类型定义,程序汇总定义和引用的全局变量,以及原始的C源文件.line
: 原始C源程序中的行号和.text节中机器指令之间的映射- 注意:只有以-g选项调用编译器驱动程序,才会出现
.debug
和.line
- 注意:只有以-g选项调用编译器驱动程序,才会出现
.strtab
: 字符串表,包括.symtab
和.debug
节中的符号表,以及节头部中的节名字节头部表(Section Header Table):给出不同节的大小和位置等其他信息
我们可以使用GNU READELF程序来查看目标文件内容。
每个可重定位目标模块都会有一个由汇编器构造的符号表.symtab,包含了当前模块中定义和引用的符号信息。在链接器的上下文中(链接器是对不同的可重定位目标文件进行操作的,所以它的上下文就是不同的可重定位目标模块),根据符号定义和引用的情况,可以将其分成以下类型:
- 全局链接器符号:在当前可重定位目标模块中定义,并能被其他模块引用的符号。对应于非静态的函数和全局变量。
- 外部链接器符号:在别的可重定位目标模块中定义,并被当前模块引用的符号。对应于在其他模块中定义的非静态的函数和全局变量。(外部连接器符号也是全局连接器符号)
- 局部链接器符号:只在当前可重定位目标模块定义和引用的符号。对应于静态的函数和全局变量,这些符号在当前模块中任何位置都可见,但不能被别的模块引用。
注意:局部静态变量不在栈中管理,所以编译器在
.data
或.bss
中为其分配空间,并在符号表.symtab
中创建一个有唯一名字的局部链接器符号。
符号表.symtab
中的每个条目具有以下格式
name:保存符号的名字,是
.strtab
的字节偏移量type:说明该符号的类型,是函数、变量还是数据节等等
binding:说明该符号是局部还是全局的
value:对于可重定位目标文件而言,是定义该符号的节到该符号的偏移量(比如函数就是在
.text
中,初始化的变量在.data
,未初始化的变量在.bss
中);对于可执行目标文件而言,是绝对运行形式地址。size:是符号的值的字节数目。(通过value和size就能获得该符号的值)
section:说明该符号保存在哪个节中,是节头部表中的偏移量。
注意:可重定位目标文件中有三个无法通过节头部表进行索引的数据节,称为伪节(Pseudosection)
- ABS:不该被重定位的符号
- UNDEF:未定义的符号,即在当前可重定位目标文件中引用,但在别的地方定义的符号
- COMMON:表示未被分配位置的未初始化的全局变量。此时value给出对齐要求,size给出最小的大小。
对于像Linux LD这样的静态链接器(Static Linker),是以一组可重定位目标文件和命令参数为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。为了构造可执行目标文件,链接器有两个任务:
- 符号解析(Symbol Resolution):将每个符号引用和一个符号定义关联起来
- 重定位(Relocation):编译器和汇编器生成从地址0开始的代码和数据节,链接器会对代码、数据节、符号分配内存地址,然后使用汇编器产生的重定位条目(Relocation Entry)的指令,修改所有对这些符号的引用,使得它们指向正确的内存位置。
符号解析
定义:链接器符号解析是将每个符号引用与输入的所有可重定位目标文件的符号表中的一个确定的符号定义关联起来。
- 对于局部链接器符号,由于符号定义和符号引用都在同一个可重定位目标文件中,情况相对简单,编译器只允许每个可重定位目标文件中每个局部链接器符号只有一个定义。而局部静态变量也会有局部链接器符号,所以编译器还要确保它有一个唯一的名字。
- 对于全局符号(包括全局符号和外部符号),编译器可能会碰到不在当前文件中定义的符号,则会假设该符号是在别的文件中定义的,就会在重定位表中产生该符号的条目,让链接器去解决。而链接器可能还会碰到在多个可重定位目标文件中定义相同名字的全局符号,也要解决这些冲突。
链接器解析多重定义的全局符号
编译器会向汇编器输出每个全局符号是强(Strong)还是弱(Weak),而汇编器会把这些信息隐式编码在可重定位目标文件的符号表中。函数和已初始化的全局符号是强符号,未初始化的全局符号是弱符号。
然后链接器通过以下规则来处理在多个可重定位目标文件中重复定义的全局符号:
- 不允许有多个同名的强符号,如果存在,则链接器会报错
- 如果有一个强符号和多个弱符号同名,则符号选择强符号的定义
- 如果有多个弱符号同名,符号就随机选择一个弱符号的定义
我们从编译器的角度来看,当编译器看到一个弱全局符号时,它并不确定是否会在别的文件中对该符号进行定义,也无法确定链接器会采用多重定义的哪个定义。所以编译器将未初始化的全局符号放在COMMON
表中,让链接器去决定。而当全局符号初始化为0时,它就是一个强全局符号,根据规则1可知该符号是唯一的,所以编译器可以直接将其分配到.bss
中。而对于静态变量,由于其符号也是唯一的,所以编译器也可以直接将其放到.bss
或.data
中。
特别要注意的是,当同名符号的类型不同时,规则2和3可能会导致意想不到的错误,比如以下代码
这里在文件foo5.c
中,x
和y
是强全局符号,而在文件bar5.c
中,x
是弱全局符号,所以链接器会选择foo5.c
中对x
的定义,将其定义为int
类型。但是f
函数会用double
类型的-0.0
对x
进行赋值,而int
是4字节,double
是8字节的,可能会造成错误。
静态库的链接与解析引用
链接器除了能将一组可重定位目标文件链接起来得到可执行目标文件以外,编译系统还提供一种机制,将所有相关的目标模块打包为一个单独文件,称为静态库(Static Library),可以作为链接器的输入。静态库是以存档(Achive)的文件格式存放在磁盘的,它是一组连接起来的可重定位目标文件的集合,有一个头部来描述每个成员目标文件的大小和位置,后缀为.a
。使用静态库的优点有:
- 相关的函数可以被编译为独立的目标模块,然后封装成一个独立的静态库文件。
- 链接时,链接器只会复制静态库中被应用程序引用的目标模块,减少了可执行文件在磁盘和内存中的大小
- 应用程序员只需要包含较少的库文件名就能包含很多的目标模块,比如ISO C99中在
libc.a
静态库中包含了atoi.o
、scanf.o
、strcpy.o
等可重定位目标模块,在libm.a
静态库中包含了数学函数的目标模块。
比如我们有以下函数
我们可以用AR工具创建包含这些函数的静态库,首先需要得到这两个函数的可重定位目标文件
1 | gcc -c addvec.c multvec.c |
由此可以得到addvec.o
和multvec.o
,然后创建包含这两个可重定位目标文件的静态库
1 | ar rcs libvector.a addvec.o multvec.o |
由此就得到了静态库libvector.a
。
为了便于说明静态库中包含了那些函数,以及这些函数的原型,我们会创建一个头文件来包含这两个函数的函数原型,便于想要使用该静态库的人员查看
如上面的代码,在头文件vector.h
中给出了函数addvec
和multvec
的函数原型。想要创建可执行目标文件,就要编译和链接main2.o
和libvector.a
。首先先产生可重定位目标文件
1 | gcc -c main2.c |
由此可以得到main2.o
,然后运行以下代码:
1 | gcc -static -o prog2c main2.o ./libvector.a |
由此就能得到一个可执行目标文件prog2c
。这里的-static
表示链接器需要构建一个完全链接的可执行目标文件,可以加载到内存并运行,无需进一步链接。我们同样可以使用以下方法:
1 | gcc -static -o prog2c main.o -L. -lvector |
这里的-lvector
是libvector.a
的缩写,-L.
告诉链接器在当前目录中查找libvector.a
静态库。
当运行了该命令行,在符号解析阶段,链接器会维护一个可重定位目标文件的集合E
,一个引用了但是还未定义的符号集合U
,一个前面输入文件中已经定义的符号集合D
,然后在命令行中从左到右依次扫描可重定位目标文件和存档文件:
- 如果输入文件是可重定位目标文件,链接器就将其添加到
E
中,然后根据该文件的符号表来修改U
和D
,然后继续下一个输入文件。 - 如果输入文件是存档文件,则链接器会依次扫描存档文件中的成员
m
,如果m
定义了U
中的一个符号,则将m
添加到E
中,然后根据m
的符号表来修改U
和D
。最后没有包含在E
中的成员就会被丢弃,然后继续下一个输入文件。 - 如果链接器扫描完毕,
U
中还存在没有确定定义的符号,则链接器会报错并终止,否则链接器会合并和重定位E
中的目标文件,得到可执行目标文件。
在这个例子中,链接器首先得到输入文件mian2.o
,其中存在未解析的符号addvec
,则会将该符号保存在集合U
中,然后扫描下一个输入文件libvector.a
时,由于是存档文件,就会依次扫描其中的成员,首先扫描到addvec.o
时,能对符号addvec
进行解析,则将addvec.o
保存在E
中,并将符号addvec
从U
中删除,扫描到multvec.o
时,由于U
中已不存在未解析的符号了,所以不会将multvec.o
包含在E
中,最终链接器会合并和重定位E
中的目标文件,得到可执行目标文件。所以链接器最终只会从静态库libvector.a
中提取addvec.o
根据以上过程的描述,我们需要小心命令行上库和目标文件的顺序,要保证前面输入文件中未解析的符号能在后续输入文件中进行解析,否则会出现链接错误,一般是将库放在后面,如果库之间存在依赖,也要注意库之间的顺序,并且为了满足依赖关系,可以在命令行上重复库。
特别的:首先输入目标文件,由于目标文件会直接包含在E
中,所以可以得到目标文件中所有未解析的符号,并且提供了该目标文件中的所有解析的符号,相当于“无条件加入”的,如果存在库依赖目标文件,就无需再输入目标文件了。然后根据库之间的依赖来排序库,存档文件会根据U
的内容来确定是否将成员m
保存在E
中,相当于“按序加入”的,所以需要重复输入库来满足依赖关系。
比如p.o -> libx.a -> liby.a
且liby.a -> libx.a -> p.o
。此时我们先输入p.o
,就包含了解析lib.a
符号的定义了,然后我们根据依赖输入libx.a liby.a
,此时由于第一个libx.a
只是解析了p.o
中未定义的符号,而liby.a
中还存在由libx.a
解析的符号,所以我们还需输入libx.a
来解析liby.a
的符号。
重定位
当链接器完成符号解析时,就能确定在多个目标文件中重定义的全局符号的解析,以及获得静态库中需要的目标模块,此时所有符号引用都能和一个符号定义关联起来了。此时开始重定位步骤,包括:
- 链接器将所有目标模块中相同类型的节合并成同一类型的新的聚合节,比如将所有输入目标模块的
.data
节聚合成可执行文件中的.data
节,其他节也如此操作。 - 此时链接器知道代码节和数据节的确切大小,就将运行时内存地址赋给新的聚合节,以及输入模块定义的每个符号。此时程序的每条指令和全局变量都有唯一的运行时内存地址了。
- 记得之前可重定位目标文件中,由于编译器和汇编器并不知道符号的运行时内存地址,所以使用一个占位符来设置符号引用的地址,而当前链接器已为符号分配了内存地址,所以链接器需要修改代码节和数据节中对每个符号的引用,使它们指向正确的运行时内存地址。
当汇编器生成目标模块时,它无法确定数据和代码最终会放在内存的什么位置,也无法确定该模块引用外部定义的函数和全局变量的位置,所以汇编器先用占位符来占领位置,然后对地址未知的符号产生一个重定位条目(Relocation Entry),代码的重定位条目会保存在.rel.text
节中,已初始化数据的重定位条目会保存在rel.data.
节中。重定位条目的数据结构如下所示
其中,offset
表示要修改符号引用的内存地址,type
表示重定位的类型,symbol
是符号表的索引值,表示引用的符号,可以通过该符号获得真实的内存地址,addend
是一个有符号常数,有些重定位需要使用这个参数来修改引用位置。
我们通过以下代码来介绍两个重定位类型:R_X86_64_PC32
和R_X86_64_32
。
我们可以通过objdump -dx main.o
来得到main.o
的反汇编代码,可以发现该函数中无法确定array
和其他目标模块中定义的函数sum
在内存中的地址,所以会对array
和sum
产生重定位条目
R_X86_64_PC32
该重定位条目主要用来产生32位PC相对地址的引用,即函数调用时的重定位。
其中call
指令的开始地址处于节偏移0xe
处,然后有一个字节的操作码e8
,后面跟着的就是函数sum
的32位PC相对引用的占位符,所以链接器修改的位置在当前节偏移0xf
处。该重定位条目r
包含以下字段
1 | r.offset = 0xf //该值是当前节的偏移量,定位到重定位的位置 |
当前链接器已经确定了各个节和符号的的内存地址,该代码处于.text
节中,则我们可以通过.text
和r.offset
的值来确定占位符的内存地址
1 | ADDR(s) = ADDR(.text) = 0x4004d0 |
然后我们需要计算占位符的内容,根据相对地址的计算方法,可以知道占位符的内容是目标地址减去当前PC的下一条指令的地址。可以通过ADDR(r.symbol)
来获得目标地址,即sum
函数的地址,可以通过refaddr
减去4字节来获得下一指令的地址,然后可以通过以下计算公式来计算占位符内容
1 | refptr = s + r.offset //占位符的指针 |
由此在可执行目标文件中,call
指令就有如下的重定位形式:
R_X86_64_32
该重定位条目主要用来产生32位绝对地址的引用,即数组的重定位。
使用数组array
的指令处于.text
节偏移0x9
处,后面有一个字节的操作码,后面跟着的就是数组array
的32位绝对地址的引用的占位符,所以链接器修改的位置在当前节偏移0xa
处。该重定位条目r
包含以下字段
1 | r.offset = 0xa |
我们可以通过r.symbol
的地址来确定数组array
的内存地址,然后直接将该内存地址保存到占位符中,即
1 | refptr = s + r.offset //占位符的指针 |
由此在可执行目标文件中,该引用有以下重定位形式
重定位后,加载器就会将这些节的字节直接复制到内存中,可以直接执行。
可执行目标文件
ELF格式
通过以上符号解析和重定位过程,链接器已将可重定位目标文件和库合并成一个可执行目标文件了,目标文件的ELF格式如下所示
- ELF头:描述了文件的总体格式,还包括程序的入口点(Entry Point),即当程序运行时要执行的第一条指令的地址。
.init
:定义了一个小函数_init
,程序的初始化代码会调用.text
、.rodata
和.data
和可重定位目标文件中的类似,只是这里被重定位到了最终的运行时内存地址- 由于可执行目标文件是完全链接的,已经不需要重定位了,所以不需要
.rel
节了。
段头部表(Segment Header Table):包括页大小、虚拟地址内存段(节)、段大小等等。描述了可执行文件连续的片到连续的内存段的映射关系,如下图所示是通过OBJDUMP
显示的prog
的段头部表
在可执行目标文件中,根据不同数据节对读写执行的不同要求,将不同的数据节分成了两个段:代码段和数据段,其中代码段包含ELF头、段头部表、.init
、.text
和.rodata
,数据段包括.data
和.bss
。然后段头部表中就描述了代码段和数据段到内存段的映射关系,其中off
是目标文件中的偏移,表示要从目标文件的什么位置开始读取该段;vaddr/paddr
是内存地址,表示要将该段加载到的内存地址;align
是对齐要求;filesz
是目标文件中的段大小,则通过off
和filesz
就能确定我们要加载的段的内容;memsz
是内存中的段大小,表示我们养将目标文件中的该段加载到多大的内存空间中;flags
表示该段运行时的访问权限。
比如第1行、第2行描述的就是代码段,表示将目标文件中从0x0
开始的0x69c
个字节数据保存到从0x400000
开始的,大小为0x69c
字节的内存空间中,并具有读和可执行权限。第3行、第4行描述的是数据段,表示将目标文件从0xdf8
开始的0x228
个字节数据保存到从0x600df8
开始的,大小为0x230
字节的内存空间中,并具有读写权限。
这里为了使得程序执行时,目标文件中的段能高效地传送到内存中,要求
加载可执行目标文件
当我得到可执行目标文件prog
时,我们可以在shell
中输入./prog
。
由于prog
不是内置的shell命令,所以shell会认为prog
是一个可执行目标文件,就通过调用execve
函数来调用内核中的加载器(Loader),则加载器会在可执行目标文件的段头部表的引导下,将可执行文件中的数据段和代码段复制到对应的内存位置,然后加载器会创建如下运行时内存映射
- 代码段和数据段:x86-64通常将代码段保存在
0x400000
处,所以会将可执行目标文件的代码段和数据段映射为如上形式。注意:这里数据段为了满足对齐要求,会和代码段之间存在间隙。 - 运行时堆:在数据段之后会有一个运行时堆,是通过调用
malloc
库动态往上增长的 - 共享库:在堆之后是一个共享库的内存映射区域
- 用户栈:用户栈是从最大的合法用户地址开始,向较小的地址增长
- 内核:最上方的是位内核中的数据和代码保留的,是操作系统驻留在内存的位置
注意:链接器通常会使用地址空间布局随机化(ASLR)来修改堆、共享库和栈的地址,但是会保持三者相对位置不变。
随后加载器会跳转到程序的入口点,到达_start
函数的地址,然后该函数调用系统启动函数_libc_start_main
,然后该函数初始化执行环境,然后调用用户层的main
函数。其中,_start
定义在系统目标文件ctrl.o
,__libc_start_main
定义在libc.so
中。
共享库
静态库具有以下缺点:需要定期维护和更新,并且几乎所有C程序都会使用标准I/O函数,则运行时这些函数的代码会被复制到每个运行进程的文本段中,占用大量的内存资源。
为了解决静态库的问题提出了共享库(Shared Library),它是一个目标模块,不会在产生可执行目标文件时将数据段和代码段复制到可执行目标文件中进行静态链接,而是等到程序要加载时或要运行时才进行链接,我们可以提供最新的共享库,使得可执行目标文件可以直接和最新的共享库在加载或运行时链接,无需重新产生可执行目标文件。共享库由动态链接器(Dynamic Linker)加载到任意的内存地址,并和一个在内存中的程序链接起来,该过程称为动态链接(Dynamic Linking)。动态链接器本身就是一个共享目标,Linux中为ld-linux.so
。
共享库的“共享”具有两层含义:
- 在任意文件系统中,一个库只有一个
.so
文件,所有引用该共享库的可执行目标文件都共享该.so
文件中的代码和数据,不像静态库的内容会被复制到可执行目标文件中。 - 在内存中,一个共享库的
.text
节可以被不同正在运行的进程共享。
加载时动态链接
我们可以通过以下形式产生共享库
1 | gcc -shared -fpic -o libvector.so addvec.c multvec.c |
其中,-shared
指示链接器创建一个共享的目标文件,-fpic
指示编译器生成与位置无关的代码。然后我们可以通过以下形式利用该共享库
1 | gcc -o prog2l main2.c ./libvector.so |
由此就创建了一个可执行目标文件prog2l
,其过程如下图所示
- 在创建可执行目标文件时,链接器会复制共享库中的重定位
.rel
和符号表.symtab
信息,使得运行时可以解析对共享库中代码和数据的引用,由此得到部分链接的可执行目标文件。注意:此时没有将共享库的代码和数据节复制到可执行文件中。 - 调用加载器加载部分链接的可执行目标文件时,加载器会在段头部表的引导下,将可执行文件中的数据段和代码段复制到对应的内存位置。
- 加载器可以在
prog2l
中发现.interp
节,其中保存了动态链接器的路径,则加载器会加载和运行这个动态链接器 - 动态链接器会将不同的共享库的代码和数据保存到不同的内存段中
- 动态链接器还会根据共享库在内存的位置,来重定位
prog2l
中所有对共享库定义的符号的引用 - 最后加载器将控制权传递给应用程序,此时共享库的位置就固定了,并在程序执行的过程中不会改变。
此时就能在应用程序被加载之后,在运行之前动态链接器加载和链接共享库。
运行时动态链接
应用程序还可以在它运行时要求动态链接器加载和链接某个共享库。
Linux为动态链接器提供一个接口,使得应用程序在运行时加载和链接共享库
1 |
|
dlopen
函数可以打开filename
指定的共享库,并返回句柄指针,而参数flag
可以用来确定共享库符号解析方式以及作用范围,两个可用|
相连,包括:
RTLD_NOW
:在dlopen返回前,解析出全部没有定义符号,假设解析不出来,则返回NULLRTLD_LAZY
:在dlopen返回前,对于共享库中的没有定义的符号不运行解析,直到执行来自共享库中的代码(仅仅对函数引用有效,对于变量引用总是马上解析)。RTLD_GLOBAL
:共享库中定义的符号可被其后打开的其他库用于符号解析RTLD_LOCAL
:与RTLD_GLOBAL
作用相反,共享库中定义的符号不能被其后打开的其他库用于重定位,是默认的。
1 |
|
该函数返回之前打开的共享库的句柄中symbol
指定的符号的地址
1 |
|
用来关闭打开的共享库句柄
1 |
|
如果dlopen
、dlsym
或dlclose
函数发生错误,就返回字符串。
如下图所示的代码示例
该程序就会在运行时动态链接共享库libvector.so
,然后调用addvec
函数。
我们可以用以下的编译方式
1 | gcc -rdynamic -o prog2r dll.c -ldl |
其中,-rdynamic
通知链接器将全部符号加入到动态符号表中,就可以通过使用dlopen
来实现向后跟踪,-ldl
表示程序运行时会动态加载共享库。
位置无关代码
当链接器产生可执行目标文件时,已为目标文件中的数据节和符号分配好了内存地址,如果可执行目标文件有引用共享库中的符号时,就需要假设共享库符号的地址。较早存在静态共享库(Static Shared Library)方法,即操作系统会在某个特定的地址中划分一部分,为已知的共享库预留空间,则共享库会被加载到对应的地址空间中,而可执行目标文件就可以在对应的地址空间中找到想要的共享库。但是该方法会造成地址冲突,并造成地址空间的浪费,以及维护的困难。
所以就想能否将共享库加载到任意的内存位置,还能使得可执行目标文件能找到。类似于使用静态库时,链接器会根据重定位表和分配好的内存地址来替换编译时未知的地址,这里可以使用加载时重定位(Load Time Relocation)方法,由于编译、汇编和链接时对共享库在内存的位置是未知的,所以可执行目标文件对共享库的符号的引用也用占位符代替,当加载器加载可执行目标文件进行加载时,会调用动态链接加载器将共享库加载到内存中,此时就能根据共享库被加载的内存地址,对可执行目标文件中的占位符进行重定位。但是该方法会对共享库中的指令进行修改,由于指令被重定位后对于每个进程是不同的,所以该共享库无法在多个进程中共享。
对这句话的理解:共享对象也就是动态链接库在被装载到物理内存后,始终是只有一份的,不管有多少个进程使用它。但是对于每一个进程,共享对象会映射一次到虚拟地址空间,也就是每个进程空间都有一份共享对象的映射,此时,对于不同的进程,映射的地址(基址)是不一样的(大部分情况下)。紧接着,进行装载时重定位。加载时重定位由动态链接器完成,动态链接器会被一起映射到进程空间中。它根据共享对象在虚拟内存空间中的地址修改在物理内存中的共享对象中的指令,为什么会修改指令,原因在于绝对地址访问(如模块内的变量访问)是直接用mov指令完成的(x86 上 mov 之类访问程序中数据段的指令,它要求操作数是绝对地址),也就是直接将地址打入寄存器,所以,此时的重定位会直接修改指令。进一步,共享对象中修改的指令是根据共享对象被映射到虚拟空间中的地址(基址)决定的,而每个进程对共享对象的映射不可能都是在相同地址。所以也就无法完成这一部分代码的共享。
但是共享库中的数据部分在多个进程中是有自己备份的,所以这就给我们提供了一个思路。我们的目的其实就是希望共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为**地址无关代码(PIC, Position-independent Code)**的技术。
注意:对于动态库的创建,-fpic
选择地址无关代码是必须的编译选项。
PIC 数据引用的实现原理
编译器通过运用以下事实来生成对全局变量的PIC引用:无论我们在内存中的何处加载一个目标模块(包括共享目标模块),数据段和代码段的距离总是保持不变。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。
编译器在数据段开始的地方创建了一个表,叫做全局偏移表(Global Offset Table, GOT)。在GOT中,每个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个8字节条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的GOT。GOT属于数据段的一部分。示例如下:
PIC函数调用的实现原理
如果程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。不过,这种方法并不是PIC,因为它需要链接器修改调用模块的代码段(要想通过动态链接器对可执行目标文件的代码段进行修改,需要先将此段重映射为可写段这种修改需要预留交换空间,并且会形成此进程的文本段专用副本,此文本段不再供多个进程共享,会严重影响性能),GNU编译系统使用了一种很有趣的技术来解决这个问题,成为延迟绑定(lazy binding),将过程地址的绑定推迟到第一次调用该过程时。
一个程序只会调用共享库中一部分的程序,将函数地址的绑定推迟到实际调用的时候,能够避免动态链接器在加载的时候进行成百上千不需要的重定位(意思是有些函数声明了,但是没有被调用?)。
延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是:全局偏移量表(Global Offset Table,GOT)和过程链接表(Procedure Linkage Table,PLT)。我们以下方的例子进行介绍PIC函数调用的过程:
我们可以通过以下命令行来产生位置无关代码的共享库libvector.so
1 | gcc -shared -fpic -o libvector.so addvec.c multvec.c |
然后通过以下命令行产生可执行目标文件prog
1 | gcc -o prog main2.c ./libvector.so |
首先,为了不对可执行目标文件的代码段进行修改,减小开销,编译器在数据段的开头新建了一个.got
节,用来保存共享库函数的地址,由于数据节是可写的,所以就能在知道共享库函数地址时,对该数据节进行修改,使其指向正确的共享库函数的地址。比如我们这里可以通过readelf -a -x .got prog
来解读可执行目标文件,得到以下.got
数据节
1 | Hex dump of section '.got': |
其次,由于链接器无法修改编译器产生的汇编代码,所以无法修改调用共享库的函数的call
指令,所以编译器在代码段新建一个.plt
节,然后对所有引用自共享库中的函数都在该数据节中创建一个新函数xxx@plt
,然后将代码中调用地址替换成call xxx@plt
,所以就能通过函数xxx@plt
来完成对.got
的更新,以及指向正确的地址。
我们这里可以通过objdump -dx prog
来将其转化为汇编代码。首先查看main
函数中对共享库libvector.so
的addvec
函数和共享库glibc.so
的printf
函数的调用
1 | 000000000000077a <main>: |
可以发现,这里将对addvec
函数和对prinrf
函数的调用转化为了对addvec@plt
和对__printf_chk@plt
函数的调用,这两个函数就是在.plt
节中定义的,而.plt
节中的内容如下所示
1 | Disassembly of section .plt: |
这里首先需要介绍一个位置无关代码的性质:我们将可以加载而无需重定位的代码称为位置无关代码。由于我们无论在内存什么位置加载该目标模块(包括共享目标模块),数据段与代码段的距离总是保持不变的。所以我们可以让处于代码段的plt
函数通过距离常量来访问处于数据段中对应的got
中保存的地址。
比如上面我们调用addvec@plt
函数时,会执行0x640
处的jmpq *0x200982(%rip)
指令, 这里的0x200982
就是上面所说的距离常量,用来指向特定的got
项,这里可以得到访问的got
项的地址为0x200982+0x646=0x200fc8
,而该地址对应的got
内容如下所示
1 | 0x00200fc0 00000000 00000000 46060000 00000000 ........F....... |
根据小端法可以得知跳转地址为0x646
,即跳转回到下一条指令,然后调用.plt
函数
1 | 0000000000000630 <.plt>: |
其中第一条指令是将地址0x200982+0x636=0x200fb8
作为参数压入栈中,而第二条指令是跳转到0x200984+0x63c=0x200fc0
处保存的地址,我们通过上面可以看到,在未运行可执行目标文件时,该地址的值为0
,而当运行了可执行目标文件时,该地址的值会修改到动态链接器中的_dl_runtime_resolve
函数,来进行地址解析,查看共享库的addvec
被加载到什么内存地址。那该函数是如何知道要获得哪个函数的地址,以及要将函数地址保存到哪个got
项呢?
我们观察可执行目标文件中以下共享库的函数
1 | 0000000000000640 <addvec@plt>: |
可以发现每个函数的第一条指令是跳转到对应的got
项,而对应的got
项被初始化为下一条指令的地址,当got
项没有被修改时,就自动跳转到下一条指令。而第二条指令在不同函数中是不同的,其实对应的是.rela.plt
的索引
1 | Relocation section '.rela.plt' at offset 0x5e8 contains 2 entries: |
其中,offset
表示对应的got
项的地址,Sym.Name
就是函数的名字。所以动态链接器通过索引值和.rela.plt
数据组就能确定要定位哪个动态库函数,以及将其内存地址保存到哪个got
项。
当动态链接后的addvec
函数的内存地址保存到对应的got
项时,下次再调用addvec
函数时,就能直接通过该got
项直接获得addvec
函数的内存地址。
我们可以发现,第一次调用共享库的函数时,对应的xxx@plt
函数并不会跳转到正确的函数地址,而是调用动态链接器来获得函数的地址,然后将其保存到got
项中,下一次再运行时,才会跳转到正确的函数地址,该方法称为延迟绑定(Lazy Binding),只有共享库的函数要用时,才会重定位它的地址,否则不会,由此防止可执行目标文件加载时需要对大量的共享库的地址进行重定位。
综上所述:当函数要访问共享库中的函数时,实现执行call xxx@plt
,访问该函数的封装函数,然后该plt
函数会访问对应的got
项,如果got
项被赋值为对应的xxx
函数的地址,则会调用该函数,否则会调用.plt[0]
中的动态链接器,来定位xxx
函数的内存地址,然后将其保存到对应的got
项中。
库打桩
Linux链接器支持库打桩(Library Interpositioning)技术,允许你截获对共享库函数的调用,替换成自己的代码。基本思想为:创建一个与共享库函数相同函数原型的包装函数,是不不同的库打桩技术,使得系统调用包装函数,而不是调用目标函数。
编译时打桩
我们以以上代码为例,说明编译时打桩技术,替换动态库libc.so
的malloc
和free
函数的调用。
首先,我们可以定义一个本地的头文件malloc.h
,如下所示
1 | //本地malloc.h |
然后在编译int.c
时,使用-I.
编译选项,使得预处理器首先从本地查找malloc.h
文件,由此就能将共享库的malloc
和free
函数替换成我们自己的mymalloc
混合myfree
函数。
而我们需要自己实现mymalloc
和myfree
函数,其中需要调用原始的malloc.h
,所以需要先将该函数进行编译,所以创建以下文件
所以我们可以通过以下代码得到该函数的可重定位目标文件mymalloc.o
1 | gcc -DCOMPILETIME -c mymalloc.c |
然后在本地的malloc.h
中给出包装函数的函数原型,即
然后就可以通过以下命令行进行编译时打桩
1 | gcc -I. -o intc int.c mymalloc.o |
此时,由于-I.
编译选项,对于int.c
中的malloc.h
,预处理器会首先从本地搜索malloc.h
文件,而在本地malloc.h
文件中,对malloc
和free
函数重新包装成mymalloc
和myfree
函数,而这两个函数在之前编译好的mymalloc.o
可重定位目标文件中,此时就完成了编译时打桩。
综上所述:想要在编译时打桩,意味着要通过#define
来使用预处理器将目标函数替换成包装函数。
链接时打桩
Linux静态链接器也支持使用--wrap f
标志进行链接时打桩,此时会将符号f
解析为__wrap_f
,而将对__real_f
符号的引用解析为f
, 意味着原始对函数f
的调用,还会替换成对__wrap_f
函数的调用,而通过__real_f
函数来调用原始函数f
。
我们定义以下函数
然后我们可以同时进行编译
1 | gcc -DLINKTIME -Wl,--wrap,malloc -Wl,--wrap,free -o intl int.c mymalloc.c |
也可以分开编译
1 | gcc -DLINKTIME -c mymalloc.c |
其中,-Wl
表示传递链接器参数,而这些参数通过,
相连。
由此,int.c
中对malloc
和free
函数的调用,会变成对__wrap_malloc
和__wrap_free
函数的调用。
综上所述:想要在链接时打桩,意味着在对可重定位目标文件的符号进行解析时,进行替换。
运行时打桩
想要在运行时进行打桩,意味着是对共享库的函数进行打桩,这里使用动态链接器提供的LD_PRELOAD
环境变量,通过该变量设置共享库路径列表,执行可执行目标文件时,动态链接器就会先搜索LD_PRELOAD
共享库。
我们可以定义以下函数
然后通过以下命令行将其编译成共享库
1 | gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl |
然后在运行时指定环境变量LD_PRELOAD
1 | gcc -o intr int.c |
此时运行到malloc
和free
函数时,就会调用动态链接器搜索该符号的定义,此时会先搜索LD_PRELOAD
指定的共享库,而mymalloc.so
中定义了这两个符号,所以就替换了这两个函数的具体实现。注意:如果想要调用原始的定义,就需要用运行动态链接的方式,通过指定dlsym
的参数为RTLD_NEXT
,来在后续的共享库中获得malloc
的定义。