静态链接&&动态链接
前言
程序猿在编程的代码无非是由函数和各种变量,以及对这些变量的读、写。不管是变量还是函数,它们最终都要存储在内存里。为每个变量和函数正确地分配内存空间,记录它们的地址,并把这个地址复写回调用或引用它们的地方。
而负责将这些符号转换成地址就是由链接器完成的 ~~~
通常分成3种情况:
静态链接:
- 生成二进制可执行文件的过程中。
动态链接:
- 在二进制文件被加载进内存时。在二进制文件保留符号,在加载时再把符号解析成真实的内存地址
- 在运行期间解析符号。这种情况会把符号的解析延迟到最后不得不做时才去做符号的解析
接下来,我将详细介绍这2种链接的具体工作原理。
前置知识
这部分主要记录ELF文件格式,已经会的可以直接跳到链接部分。
ELF:可执行和链接格式
ELF(Executable and Linkable Format)
ELF格式文件不仅用于可执行文件,还可以存储可重定位目标文件、动态库文件等。也就是说,ELF文件既要承载编译的输出(可重定位目标文件),又要承载链接的输出(可执行文件),因此其文件格式需要同时满足这两个功能。
可重定位目标文件格式
可重定位目标文件格式
可重定位目标文件主要包含代码部分和数据部分,它可以与其他可重定位目标文件链接,从而创建可执行目标文件、共享库文件。
-
可被链接(合并)生成可执行文件或共享目标文件;
-
静态链接库文件由若干个可重定位目标文件组成;
-
包含代码、数据(已初始化全局变量和局部静态变量.data和未初始化的全局变量和局部静态变量.bss)
-
包含重定位信息(指出哪些符号引用处需要重定位)
-
文件扩展名为.o(相当于Windows中的 .obj文件)
格式如下:由ELF头,节,节头表组成。
(1)ELF头
readelf -h 命令对某个可重定位目标文件的 ELF 头进行解析
浅浅查看一下:
1 | penge@penge-virtual-machine ~/Desktop/MordenCpp/tmp readelf -h main.o |
ELF 头位于目标文件的起始位置,包含文件结构说明信息。分32位版本和64位版本。
32位系统对应的数据结构,共占 52(0x34)字节:
1 |
|
-
魔数:文件开头几个字节通常用来确定文件的类型或格式。
- a.out的魔数:01H 07H
- PE格式魔数:4DH 5AH
- 加载或读取文件时,可用魔数确认文件类型是否正确
-
仅 ELF 头在文件中具有固定位置,即总是在开头位置,其余部分的位置由 ELF 头和节头表指出。
-
因为是可重定位文件,所以字段 e_entry 为0,无程序头表(Size of program headers = 0)。
(2)节
节是 ELF 文件中的主体信息,包含了链接过程所用的目标代码信息,包括指令、数据、符号表和重定位信息等。
节名 | 说明 |
---|---|
.text | 已编译程序的机器代码 |
.rodata | 只读数据,如 printf 语句中的格式串、开关语句(switch-case)的跳转表等 |
.data | 已初始化的全局变量 |
.bss | 未初始化的全局和静态变量,以及所有被初始化为 0 的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符,为了节省空间。在运行时,在内存中将这些变量初始化为 0。 |
.symtab | 符号表(symbol table),程序中定义的函数名和全局静态变量名都属于符号,与这些符号相关的信息保存在符号表中。每个可重定位目标文件都有一个 .symtab 节。和编译器中的符号表不同, .symtab 符号表不包含局部变量的条目。 |
.rel.text | .text 节相关的重定位信息。当链接器合并目标文件时,.text 中的代码合并后部分位置的数据需要修改。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改定位信息。另一方面,调用本地函数的指令则不需要修改。 TIP:可执行文件中并不需要重定位信息。 |
.rel.data | .data 节相关的可重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。 |
.debug | 调试用符号表。其条目是程序中定义的全局变量和类型定义,程序中定义和引用的全局变量,以及原始的 C 源文件。 |
.line | C 源程序中的行号和 .text 节中机器指令之间的映射。 |
.strtab | 字符串表。包括 .symtab 节和 .debug 节中的符号以及节头表中的节名。字符串表就是以 null 结尾的字符序列。 |
(3)节头表
节头表(Section Header Table)由若干表项组成,每个表项描述相应的一个节的节名、在文件中的偏移、大小、访问属性、对齐方式等,目标文件中的每个节都有一个表项与之对应。
- 除ELF头之外,节头表是ELF可重定位目标文件中最重要的部分内容。
以下是 32 位系统对应的数据结构(每个表项占40B):
1 | typedef struct { |
- 比较特殊的一个地方:.bss 在文件中不占用空间,但节头表记录了 .bss 节的长度为 0x0c = 12,因此需要在主存中给 .bss 节分配 12 字节空间。
整体就是通过 ELF 头连接了节头表,再通过节头表把每一个节连接起来了。
可执行目标文件格式
与可重定文件稍有不同:
- ELF头中字段 e_entry 给出执行程序时第一条指令的地址,而在可重定位文件中,此字段为0;
- 多一个程序头表,也称段头表(segment header table),是一个结构数组,用来说明段信息;
- 多一个.init节,用于定义_init函数,该函数用来进行可执行目标文件开始执行时的初始化工作;
- 少两个.rel节(无需重定位)。
在可执行文件中,ELF 头、程序头表、.init 节、.fini 节、.text 节和 .rodata 节合起来可构成一个只读代码段;.data 节和 .bss 节合起来可构成一个可读写数据段。显然,在可执行文件启动执行时,这两个段必须装入内存,因而称为可装入段。
静态链接
认知
先对链接器的的两步链接有个整体的认识:
第一步是,链接器需要对编译器生成的多个目标(.o) 文件进行合并,采取的策略是相似段的合并,最终生成共享文件 (.so) 或者可执行文件。这个阶段中,链接器对输入的各个目标文件进行扫描,获取各个段的大小,并且同时会收集所有的符号定义以及引用信息,构建一个全局的符号表。当链接器构造好了最终的文件布局以及虚拟内存布局后,我们根据符号表,也就能确定了每个符号的虚拟地址了。
第二步是,链接器会对整个文件再进行第二遍扫描,这一阶段,会利用第一遍扫描得到的符号表信息,依次对文件中每个符号引用的地方进行地址替换。也就是对符号的解析以及重定位过程。
简单来讲就是进行两遍扫描:【重点中的重点】第一遍扫描完成文件合并、虚拟内存布局的分配以及符号信息收集;第二遍扫描则是完成了符号的重定位过程。
接下来,我将分类讨论各种符号的处理方式。
在实操之前,先说总结:
- 静态函数不需要重定位因为和执行单元代码都在.text段,相对位置在编译的时候就能确定了,因为链接器合并中间文件时相对位置不会变。
- 静态变量需要重定位,因为和编译单元代码段.text分属不同的section,在.data,链接器合并文件时会重新排布,所以需要重定位。
- 全局变量/函数,外部变量/函数都是需要被重定位的,大致方法就是:编译器先用0占位符号、链接重定位表找符号、定位符号地址、然后在当前代码段计算RIP相对偏移位置填上。
实操
开始实操
工具:readelf 查看 elf 文件 和 objdump 查看反汇编。
- objdump:对二进制文件进行反汇编
- readelf :解析二进制文件信息
例子:
example.c
1 | // example.c |
external.c
1 | // external.c |
1 | ⚙ penge@penge-virtual-machine ~/Desktop/MordenCpp/tmp1 gcc example.c -c -o example.o -fno-PIC -g |
- -fno-PIC :告诉编译器不要生成 PIC 的代码。
- PIC(Position-Independent Code,位置无关代码)是一种编程技术,主要用于创建共享库(shared libraries),使其能够在内存中的任何位置加载和执行,而不需要在加载时进行重新定位(relocation)。
我们将两个.o 文件链接生成可执行文件
1 | gcc external.o example.o -o a.out -no-pie |
- -no-pie 表示关闭 pie 的模式。gcc 会默认打开 pie 模式,也就意味着系统 loader 对加载可执行文件时的起始地址,会随机加载。关闭 pie 之后,在 Linux 64 位的系统下,默认的加载起始地址是 0x400000。
1 | penge@penge-virtual-machine ~/Desktop/MordenCpp/tmp1 objdump -S example.o |
各种符号的处理方式
局部变量
从反汇编的结果里,我们可以看到,局部变量在程序中的地址,都是基于 %rbp的偏移这种形式,rbp 寄存器存放的是当前函数栈帧的基地址。这些局部变量的内存分配与释放,都是在运行时通过 %rbp 的改变来进行的,因此,局部变量的内存地址不需要链接器来操心。
静态函数
它是唯一不需要重定位的类型。
1 | int var5 = static_func(); |
e8是callq指令的编码,后边 4个字节就对应被调函数的地址。
如果采用小端的字节序数值来表示0x ff ff ff a6也就是对应十进制的-90。
此时,当 CPU执行到 callq这条指令时,rip 寄存器的值指向的是下一条指令的内存地址,也就是 5d这条指令的内存地址,通过计算0x69 – 90可以得到 0xf。从反汇编中可以得到,0xf 刚好是 static_fun 的地址。
同一个编译单元内部,static_func 与 main 函数的相对位置是固定不变的,即便链接的过程中会对不同.o 文件中的代码段进行合并,但是同一个.o 文件内部不同函数之间的位置也会保持不变,因此,我们在编译的时候,就能确定对静态函数调用的偏移。也就是说,静态函数的调用地址在编译阶段就可以确定下来。
外部变量,全局变量以及静态变量
从反汇编结果中看到,前三条语句对 extern_var、global_var 和 static_var 的访问,都生成了一条 mov 0x0(%rip),%eax 的指令。这是因为在这个时候,编译器还无法确定这三个变量的地址,因此,先通过 0 来进行占位,以后链接器会将真正的地址回填在这里。
extern_func 和 global_func 的调用
call 指令同样是通过 0 来进行占位,和全局变量的处理方式一样。
处理占位符【核心】
由于在无法确定变量的真实地址时,编译器先通过 0 来进行占位。所以,我们这里继续观察链接器对 extern_var,static_var,global_var,global_func 以及 extern_func 的重定位过程。我们来查看占位符是如何处理的。
1 | penge@penge-virtual-machine ~/Desktop/MordenCpp/tmp1 readelf -S example.o |
其中的 readelf -S选项是打印出二进制文件中所有 section-header的信息。我们可以看到 example.o里总共包含了 12 个 section,重点看.rela.text 段。
从 section-header的信息里可以看到,.rela.text 段的类型是 RELA 类型,也就是重定位表。
链接器在处理目标文件的时候,需要对目标文件里代码段和数据段引用到的符号进行重定位,而这些重定位的信息都记录在对应的重定位表里。
一般来说,重定位表的名字都是以 .rela 开头,比如 .rela.text 就是对 .text 段的重定位表,.rela.data 是对 .data 段的重定位表。
1 | penge@penge-virtual-machine ~/Desktop/MordenCpp/tmp1 readelf -r example.o |
.rela.text 的重定位表里存放了text段中需要进行重定位的每一处信息。所以,每个重定位项都会包含需要重定位的偏移、重定位类型和重定位符号。
1 | typedef struct { |
其中,r_info 的高 32bit 存放的是重定位符号在符号表的索引,r_info 的低 32bit 存放的是重定位的类型的索引。符号表就是.symtab 段,可以把它看成是一个字典,这个字典以整数为 key ,以符号名为 value。
查看重定位表中的四项,它们的类型都是 R_X86_64_PC32。这种类型的重定位计算方式为:S + A – P。
- S 表示完成链接后该符号的实际地址。在链接器将多个中间文件的段合并以后,每个符号就按先后顺序依次都会分配到一个地址,这就是它的最终地址 S。
- A 表示 Addend 的值,它代表了占位符的长度。
- P 表示要进行重定位位置的地址或偏移,可以通过 r_offset 的值获取到,这是引用符号的地方,也就是我们要回填地址的地方,简单说,它就是我们上文提到的用 0 填充的占位符的地址。
这里 S 与 P 所表示的地址都是文件合并之后最终的虚拟地址。
以extern_var为例进制演算:【下面的例子的地址和上面程序无关】
1 | 00000000004004ad <main>: |
-
S 是其最终符号的真实地址,如上汇编里边的注释所示 也就是上面注释的 0x601030 这个地址;
-
A 是 Addend 的值,可以从重定位表里查到是 -4,对于 A 的具体含义我还会进一步解释;
-
P 是重定位 offset 的地址,这里是 0x4004b7。
根据公式,我们算出重定位处需要填写的值应该是 0x601030 + (-4) – 0x4004b7 = 0x200b75,也就是最终可执行文件中这条 mov 指令里的值。
现在,我们从我们再从 CPU 的角度验证取值关系。从上面 main 函数的反编译的结果可以看到,我们最终对 extern_var 的访问生成的汇编是:
1 | mov 0x200b75(%rip), %eax |
这是一条 PC 相对偏移的寻址方式。当 CPU 执行到这条指令的时候,%rip 的值存放的是下一条指令的地址,也就是 0x4004bb。这时候可以算出 0x4004bb + 0x200b75 = 0x601030,刚好是 extern_var 的实际地址。
经过正面分析这个重定位的值的作用后,这里我们再来理解一下 S+A-P 这个公式的作用。链接器有了整体的虚拟内存布局后,知道的信息是:需要重定位符号的地址 S 的值是 (0x601030),以及需要重定位的位置地址 P 的值是 (0x4004b7)。
其实,到这里不照本宣科的想一下更加比较容易的想·懂。
静态变量
static_var 的最终地址就是本编译单元的.data 段的最终地址。
动态链接
回顾下静态链接的过程:源文件经过编译生成可重定位文件(.o文件),再经过静态链接生成可执行的ELF文件,最终运行时,通过页加载和页错误的方式,保证进程的正常运行。
假如像printf,scanf,strlen等基础函数,每个应用中都会使用到,那么不同的程序中一定会包含它们的指令部分。这就导致这些程序在磁盘保存时,都有这些基础函数的副本。运行时,也会将这些副本加载到对应进程的虚拟空间内存中去。这就导致了浪费磁盘和内存。
那么,接下来就要引出下面的主角了,动态链接。
而动态链接的重定位的时机又可以分成:加载期间或者运行期间。
加载期间
简单介绍下:我们希望将常用的公共的函数都放到一个文件中,在整个系统里只会被加载到内存中一次,无论有多少个进程使用它,这个文件在内存中只有一个副本,这种文件就是动态链接库文件。
动态链接库文件
- Linux:共享目标文件 (share object, so)
- Window:动态链接库文件 (dynamic linking library, dll)
我们先看个超级简单的例子
1 | //main.c |
1 | gcc -shared -fPIC -o libfunc.so fun.c |
-
-L: 指定了查找链接库的路径 (或者可以通过设置环境变量 LIBRARY_PATH 来追加路径)。 -L. 就是告诉链接器需要到当前目录下查找共享文件。
-
-l :指定了具体链接库的名称,需要注意的是,gcc 在处理链接库名称时,会自动加上 lib的前缀和.so的后缀,所以,gcc 命令选项写的 -lfunc,就是告诉链接器查找libfunc.so这个共享目标文件。
运行指令
1 | export LD_LIBRARY_PATH=./ |
通过命令的指令我们浅浅的猜测一下,编译参数中 -lfunc存在的目的 ?
因为libfunc.so的符号表中有func,当链接器发现该符号存在于.so中,则将其作为动态符号处理。这也说明了,为什么静态链接阶段,我们还需要动态库的原因。静态链接阶段,动态库的作用就是用于区别符号的类型。
在多进程共享动态库的时候,因为代码段是不可写的,所以进程间共享不存在问题,而数据段可写,系统必须保证一个进程写了共享库的数据段,另外一个进程看不到。因此,不同的库在不同的进程加载地址可能是不同的。
下面通过简单的实践查看一下
1 |
|
运行下面的指令
1 | gcc example.c -no-pie -o main |
- -no-pie 是禁止生成地址无关的可执行文件,方便查看进程的内存布局。
分别在终端启动2个程序,通过下面的命令查看进程pid,具体实践如下:
1 | ✘ penge@penge-virtual-machine ~/Desktop/MordenCpp ps aux | grep main |
查看进程内存:
1 | penge@penge-virtual-machine ~/Desktop/MordenCpp cat /proc/14241/maps |
可以看出同样的/usr/lib/x86_64-linux-gnu/libc-2.31.so但是空间是不一样的。
因此这时候就有了地址无关代码。
PIC
百度百科:在计算机领域中,地址无关代码 (position-independent code,PIC),又称地址无关可执行文件 (position-independent executable,PIE) ,是指可在主存储器中任意位置正确地运行,而不受其绝对地址影响的一种机器码。
再介绍PIC之前,先说说之前不同的库在不同的进程加载地址可能是不同的导致的问题吧。
问题
如上图所示,如果两个进程共享了libc.so和libd.so两个动态库,而且 libc 中会调用 libd 中定义的 foo 方法。
进程 1 将 foo 方法映射到自己的虚拟地址 0x1000 处,而调用 foo 方法的指令被映射到 0x2000 处,那么 call 指令如果采用依赖 rip 寄存器的相对寻址的办法,这个偏移量应该填 -0x1000。进程 2 将 foo 方法映射到自己虚拟地址 0x2000 处,调用 foo 方法的指令被映射到 0x5000 处,那么 call 指令的参数就应该填 -0x3000。这就产生了冲突。
因此需要地址无关代码技术。
在计算机科学领域,有一句名言:“计算机领域的所有问题都可以使用新加一层抽象来解决”。
那么有了这句话的引导,要实现代码段的地址无关代码,思路是通过添加一个中间层,使得对全局符号的访问由直接访问变成间接访问。
我们可以引入一个固定地址,让引用者与这个固定地址之间的相对偏移是固定的,然后这个地址处再填入 foo 函数真正的地址。当然,这个地方必然位于数据段中,是每个进程私有的,这样才能做到在不同的进程里,可以访问不同的虚拟地址。这个新引入的固定地址就是全局偏移表 (Global Offset Table, GOT)。
GOT 的工作原理如下
在上图中,call指令处被填入了 0x3000,这是因为进程 1 的 GOT 与 call 指令之间的偏移是 0x5000-0x2000=0x3000,同时进程 2 的 GOT 与 call指令之间的偏移是 0x8000-0x5000=0x3000。所以对于这一段共享代码,不管是进程 1 执行还是进程 2 执行,它们都能跳到自己的GOT 表里。
然后,进程 1 通过访问自己的 GOT 表,查到 foo 函数的地址是 0x1000,它就能真正地调用到 foo 函数了。进程 2 访问自己的 GOT 表,查到 foo 函数的地址是 0x2000,它也能顺利地调用 foo 函数。这样我们就通过引入了 GOT 这个间接层,解决了 call 指令和 foo 函数定义之间的偏移不固定的问题。
这种技术就是地址无关代码 (Position Independent Code, PIC)。
这个验证也比较简单,可以尝试自己通过程序验证下。
因此,共享库的好处也呼之欲出了。
静态库 VS 动态库
- 静态库是在编译阶段就和可执行文件打包链接在一起的,它可以看成是中间文件的简单集合,保留了符号,只有在静态链接的过程,才会真正地做地址分配和重定位。
- 动态库在编译阶段,它的代码并不会被合并进可执行文件中,在运行时才会被加载进内存,它被加载进内存的地址是不固定的,所以每次加载完成以后,才能为它的符号分配真实的内存地址,然后再把地址回填到引用它的GOT中。动态库的一个优点是可以在多个进程间共享,从而可以减少内存的重复。
进一步理解:
基本概念
- 全局偏移表(GOT):GOT是一种数据结构,用于存储在位置无关代码(PIC)中需要动态链接的全局变量和函数的实际地址。
- 在位置无关代码中,访问全局变量和函数时,不是直接使用绝对地址,而是通过GOT的偏移量来访问。
- 位置无关代码(PIC):
- PIC使得共享库可以在内存中的任何位置加载和执行,不需要在加载时进行重新定位(relocation)。
GOT的工作原理
- 编译时:
- 编译器生成位置无关代码(PIC),并将需要动态链接的全局变量和函数的引用转换为对GOT的间接访问。
- 例如,引用函数
foo
时,代码中会生成对GOT中某个偏移量的访问,例如GOT[foo]
。
- 加载时:
- 动态链接器(dynamic linker)在加载共享库时,会将实际的函数地址填入GOT中相应的位置。
- 这样,程序执行时,通过GOT的偏移量访问全局变量和函数,实际访问的是动态链接器填入的地址。
GOT为什么可以保证多个进程的.so库地址到GOT表的相对地址相同
- 相对位置固定:
- 在生成共享库时,编译器确保GOT的位置相对于共享库的基地址是固定的。
- 例如,假设共享库的基地址是
0x1000
,GOT的偏移量是0x200
,那么GOT的地址就是0x1000 + 0x200 = 0x1200
。
- 基地址无关:
- 当共享库被加载到内存中时,实际的基地址可能不同,但GOT的相对偏移量是固定的。
- 例如,一个进程加载共享库到地址
0x5000
,那么GOT的地址是0x5000 + 0x200 = 0x5200
;另一个进程加载到地址0x8000
,GOT的地址是0x8000 + 0x200 = 0x8200
。
- 通过GOT访问:
- 程序访问全局变量和函数时,是通过GOT的偏移量进行的。
- 例如,访问函数
foo
时,代码会访问GOT[foo]
,无论共享库被加载到哪里,这个偏移量都是GOT基地址 + foo的偏移量
。
运行期间
动态链接过程的基本原理:动态链接通过GOT 表加一层间接跳转的方式,解决了代码中 call 指令对绝对地址的依赖,从而实现了PIC的能力。
动态链接存在的问题:
- 每次对全局符号的访问都要转换为对 GOT 表的访问,然后进行间接寻址,这必然要比直接的地址访问速度慢很多。
- 动态链接和静态链接的区别是将链接中重定位的过程推迟到程序加载时进行。因此在程序启动的时候,动态链接器需要对整个进程中依赖的 so 进行加载和链接,也就是对进程中所有 GOT 表中的符号进行解析重定位。
延迟绑定技术
.got.plt
段专门用于存储与过程链接表(Procedure Linkage Table, PLT)相关的 GOT 项目。PLT 是用于延迟绑定(lazy binding)机制的,第一次调用某个函数时,PLT 会引导程序跳转到动态链接器,动态链接器随后更新 GOT 表项以指向实际的函数地址。
为了避免在加载时就把 GOT 表中的符号全部解析并重定位,那么就采取懒操作,把要做的事情推迟到必须做的时刻。简单来说,将函数地址的重定位工作一直推迟到第一次访问的时候再进行,这就是延迟绑定 (Lazy binding) 的技术。这样对于整个程序运行过程中没有访问到的全局函数,可以避免对这类符号的重定位工作,提高程序性能。
延迟绑定的实现使用了两个特殊的数据结构
- 全局偏移表(Global Offset Table,GOT)
- 过程链接表(Procedure Linkage Table,PLT)
大致的实现思路是我们把 GOT 中的待解析符号的地方都填成动态符号解析的函数,当 CPU 执行到这个函数的时候,就会跳转进去解析符号,然后把 GOT 表的这一项填成符号的真正的地址。
接下来,我们来看看下面的例子:
过程链接表(Procedure Linkage Table, PLT),将动态解析符号的过程做成了三级跳。
仔细查看,会发现上面的这张图多了.plt段。
在代码段里,main 函数对 B 函数的调用转成了对"B@plt"的调用,"B@plt"函数只有三条指令
- 第一条指令 jmp *(GOT[3]) 是一个间接跳转,跳转的目标是 GOT 表偏移为 0x18 的位置,这个位置应该放的是 B 函数的真实地址,但是第一次访问时,里面肯定是为空的,因为在加载时,并没有进行重定位。因此现在填入的是指向了 B@plt + 0x6 的位置,这是为了传递参数给 _dl_runtime_resolve 函数。
- B@plt+0x6 的位置其实就是 B@plt 函数的第二条指令,它的作用是将函数参数入栈。
- 接着执行第三条指令 jmp .plt 再准备第二个参数。
3级跳分析:
-
序号①箭头的位置,也就是第一级跳转,它的目的是把参数 0 入栈。由于 GOT 表的 0x0,0x8,0x10 的位置都被占用了,所以参数 0 代表的就是 0x18 位置,这就是 B 函数的真实地址应该存放的地方。
-
序号②箭头的位置,发生了第二级跳转,这一次是为了把动态库的 ID 号压栈传参。
-
序号③箭头的位置,继续进行第三级跳转,这一次跳转才真正地调用到了 _dl_runtime_resolve【动态解析符号的函数 _dl_runtime_resolve 依赖两个参数,一个是当前动态库的 ID,另一个是要解析的符号在 GOT 表中的序号】。调用完这个方法以后,B 函数的真实地址就会被填入 GOT 表中了。
最后,总结一下 GOT 表中的各个表项的含义。
- GOT.PLT[0]位置被加载器保留,它里面存放的是.dynamic 段的地址,这里我们不用关心。
- GOT.PLT[1]位置存放的是当前 so 的 ID,这个 ID 是加载器在加载当前动态库文件的时候分配的。
- GOT.PLT[2]位置存放的是动态链接函数的入口地址,一般是动态链接器中的 _dl_runtime_resovle 函数。这个函数的作用是找到需要查找的符号地址,并最终回填到 GOT.PLT 表的对应位置。
然后再回顾一下延迟绑定的整个过程。
- 当 demo 函数想要调用 global_func 的时候,程序调用先进入 global_func@plt 中;
- 在 global_func@plt 中,会先执行 jmpq *GOT.PLT[3] ,此时 GOT.PLT[3] 里存放的是 global_func@plt 项中的第二条指令,因此控制流继续返回到 global_func@plt 中进行执行;
- 接下会把数值 0x0 进行压栈,这个数值代表了 global_func 的 ID。然后 jmp 到 PLT[0] 的表项中进行执行;
- 在 PLT[0] 中,继续将 GOT.PLT[1] 的值也就是库文件的 ID 进行压栈,然后通过 GOT.PLT[2] 跳转到 _dl_runtime_resolve 函数中;
- dl_runtime_resolve 则根据存在栈上的函数 ID 和 so 的 ID 进行全局搜索,找到对应的函数地址之后就可以将其重新填充到 GOT.PLT[3] 中,这个时候延迟加载的整个过程就完成了;
- 当下一次调用 global_func 的时候,CPU 就可以通过 global_func@plt 中第一条指令 jmpq *GOT.PLT[3] 直接跳转到 global_func 的真实地址中。
Loader加载机制
一个完全静态链接的可执行文件则不需要动态链接器的辅助,所以内核加载完之后可以直接跳转到用户代码的入口中进行执行。而动态链接的可执行文件 a.out需要链接器。
Linux环境下:动态链接器名为ld.so,又因为它还负责加载动态库文件,所以我们有时也叫它 loader,或者加载器。
ld-linux.so完成4个工作:
- 启动动态链接器
- 根据可执行文件的动态链接信息,寻找并加载可执行文件依赖的.so文件
- 对符号进行解析和重定位
- 依次执行各个 so 的 init 函数
补充:Linker Script - 链接器脚本
链接器脚本(Linker Script)是用于控制链接过程的一种脚本语言,通常由链接器(如 GNU ld)使用。链接器脚本允许开发者自定义可执行文件、共享库或目标文件的链接过程,控制输出文件的结构、布局和符号处理等。通过链接器脚本,开发者可以指定哪些代码或数据段应该放在输出文件的哪个位置,如何对齐内存段,以及如何处理符号和地址等。
链接器脚本的作用
- 控制内存布局: 链接器脚本可以控制输出文件中各个段(如
.text
、.data
、.bss
等)的内存布局。开发者可以指定段的起始地址、对齐方式,以及各段在内存中的排列顺序。 - 设置段的属性: 可以通过链接器脚本为每个段设置特定的属性,如只读、可执行等。这对于嵌入式系统特别重要,因为某些代码段可能需要放在特定的内存区域。
- 符号管理: 链接器脚本可以定义、重定义或隐藏符号,甚至可以控制符号的地址。通过脚本,开发者可以创建新的符号或调整现有符号的位置。
- 生成多种输出格式: 链接器脚本可以指定生成的输出文件类型,如可执行文件、共享库、静态库等。
链接器脚本和链接器的关系
- 链接器:链接器负责将多个目标文件(.o 文件)和库文件组合在一起,生成最终的可执行文件或库文件。它按照一定的规则将各个目标文件的代码和数据段组合起来,并解决符号引用问题。
- 链接器脚本:链接器脚本是链接器的一个配置文件,提供了对链接过程的精细控制。默认情况下,链接器会使用内置的规则和标准的链接脚本来生成输出文件。而通过提供自定义的链接器脚本,开发者可以覆盖这些默认行为,控制输出文件的具体布局和内容。
示例说明
假设我们有一个简单的嵌入式系统,要求 .text
段(即代码段)必须放在 0x1000 地址开始,而 .data
段(即已初始化的数据段)放在 0x2000 地址开始,未初始化的数据段 .bss
放在 0x3000 地址开始。链接器脚本可以如下编写:
1 | ldCopy codeSECTIONS |
示例解释
- SECTIONS:定义了输出文件中的内存段的布局。
- .text 0x1000:指定
.text
段从内存地址 0x1000 开始。 - .data 0x2000:指定
.data
段从内存地址 0x2000 开始。 - .bss 0x3000:指定
.bss
段从内存地址 0x3000 开始。 \*
表示所有输入文件:脚本中的*
表示所有输入文件中对应的段都会被放在指定的内存位置。
总结
- 重定位的概念:编译器在把源代码翻译成汇编指令的过程中,由于不知道其他编译单元的符号的真实地址,在引用这些符号的时候只能使用占位符(通常是 0)来代替。这些占位符由链接器填充。当链接器把所有的符号的位置都确定好以后,再把真实地址回填到占位符里。
- 重定位的时机:编译期重定位,加载期和运行时重定位。
- 负责动态链接的是ld-linux.so,它被称为动态链接器,但因为它还负责加载文件工作,所以也被人称为加载器或者 loader。它的工作流程主要有启动,加载,重定位和 init 四个步骤。
参考资料
- 《程序员的自我修养》
- 《CSAPP》
- 《LINUX GNU C 程序观察 (罗秋明) 》
- 《高级C/C++编译技术》
- 极客时间