静态链接与动态链接的宏观概述及微观详解
第一部分 宏观概述
1. 静态链接
静态链接就是在程序运行前,链接器通过对象文件中包含的重定位表,完成所有重定位操作,并最终形成一个在运行时不需要再次进行依赖库的加载和重定位操作(因为所有的依赖库在运行前都被链接到程序中了)。
2. 动态链接
动态链接指的是主程序对动态共享库或对象中符号的引用,是等到程序运行后再加载并进行重定位操作。程序的主体部分也称为主程序还是静态链接的,这部分链接是不会将依赖的动态共享库或对象链接进主程序的。
2.1 装载时重定位
装载时重定位:就是程序在运行的过程中,装载依赖的动态共享库或对象并在装载的过程中完成以下两种操作:
(1)修正主程序中对动态共享库或对象中定义的符号的引用,因为这时动态共享库中所有全局符号的虚拟地址都已确定了。
(2)修正动态共享库或对象中指令和数据对绝对虚拟地址的引用,因为动态共享库的虚拟基地址一般都是从0x00开始的,所以当动态共享库或对象被加载到进程的虚拟地址空间的某地址处(假设加载地址为:virt_so_base),那么它的指令或数据中对绝对虚拟地址的引用就都要加上virt_so_base,这样就完成动态共享库或对象中指令和数据的重定位了。
经过以上的操作,大家会发现装载时重定位虽然解决了动态共享库或对象可以被动态加载到进程的不同地址空间这一困扰静态共享库的难题,但是它还是没有解决“共享”这个核心问题,也就是同一个动态共享库或对象不能被多个进程共享这一问题。
例如进程A将DSO加载到自己的地址空间0x1000处,那么DSO中需要重定位的指令和数据就要加上0x1000,而进程B要想将DSO加载到自己的地址空间0x4000处时,就不能共享内存中已有的DSO,因为进程A已经将其重定位了,进程B无法共享。
2.2 PIC机制—解决DSO模块中指令部分无法共享的问题
关于PIC机制的详解,我的另一篇文章关于Linux KASLR机制的里面有详细的关于PIC的介绍。
解决思路:DSO模块中,指令对本模块内定义的静态数据和过程的引用,全都编译成相对寻址,对全局符号(函数和变量)的访问,不管是内部定义的还是外部模块定义的,都通过存储在RW数据段中的GOT表间接访问。
(1)如何通过GOT表间接访问
例如DSO1中定义了call func1这条指令,调用的func1过程是定义在DSO2中的,
这时在编译DSO1的时候会在DSO1的GOT表中分配一个空表项,并将该空表项相对于
Call func1这条指令的offset存储在DSO1中call指令的operand位置(相对寻址),该
指令最终会被编译成call *(offset)这种间接寻址方式,在装载DSO2后,会修正这个GOT
表项,使其指向DSO2模块中func1被加载到进程地址空间中实际的虚拟地址,实现代
码的PIC机制,这样DSO1的指令部分就能被映射到不同的进程的不同地址空间了,因为它是PIC Code。
(2)数据段中存在的绝对地址引用问题
指针变量是数据段中常见的对某变量的绝对虚拟地址的引用,因此其引用的虚拟地址,会随着数据段被加载到不同进程的不同地址空间而改变,再加上数据段中的数据值是可变的,其本身就不能被多个进程共享,所以通过装载时重定位的方式可以解决该问题。
例如:static int a = 3; static int a_p = &a; a_p中存储的绝对虚拟地址是随着DSO装载地址的改变而不停变化的,而且这种数据段每个进程都会有自己的副本的,所以可以用装载时重定位的方法解决。
(3)DSO内部定义的全局变量和全局函数
DSO内部代码对其内部自定义的全局变量和全局函数的访问,统一按照extern方式处理,也就是统一通过GOT表间接访问,即使它们是定义在同一个DSO内部。
3. 动态链接与静态链接的本质区别
动态链接是在程序运行过程中,可以根据需要(使用了PLT延迟加载技术),由动态LD按需对依赖的外部函数进行绑定的过程。
静态链接是在程序LD成可执行文件的时候就已经将所有需要重定位的地址修正好了,这也意味着所有的依赖库都要打包成一个可执行文件,这样就起不到库文件在不同进程间共享的作用了。
动态链接的本质是:通过GOT表将指令中对(内外)全局符号的引用,转换成对存储在RW数据区的GOT表项的间接引用,从而实现指令段的PIC,如果要支持延迟加载技术,那么还需要PLT表辅助,动态LD先通过PLT表,更新相应的GOT表项,然后再通过GOT表项间接访问外部符号。但是这里有个例外,那就是可执行程序也被称为主程序,对其内部定义的全局符号的访问是静态链接的(不作为外部符号处理),对DSO中定义的全局函数的访问都是通过GOT访问的,而对DSO中定义的全局变量会根据自身是否是PIC code做出不同的处理方式。
3.1 可执行程序不是PIC code
如果访问了DSO中定义的全局变量,那么会在可执行程序的.bss段中分配一个同名变量,指令对DSO中该变量的访问将被更改为对.bss段中新分配的变量的访问;当DSO被加载后,会将其定义的同名变量的初始值复制到主程序的同名变量(.bss段中新分配的),如果DSO中有对该变量访问的指令的话,还要将其对应的GOT表项重定位指向主程序的同名变量,这样该变量最终在程序中就只有一个副本可用。后面会详解这一过程。
3.2 可执行程序是 PIC code
主程序会在GOT表中建立一个访问该变量的表项,主程序通过GOT表间接访问DSO中的该变量。
PIC技术是通过相对寻址和相对间接寻址技术,从而实现指令部分的位置无关,对内部静态数据和过程的访问完全可以通过offset相对寻址完成,对(内外)全局数据和过程的访问可以通过相对offset到GOT再间接寻址;因此无论将它加载到任意虚拟地址空间都能正确访问对应的数据和方法,但是数据里的绝对地址引用是编译的时候固定的(例如static int a=10;static int * b=&a,这时a存储的是b的绝对虚拟地址),所以当加载到不同的虚拟地址空间后,一定要重定位的。
总之一旦指令部分PIC后,其操作数地址(offset)是不变的,变的是数据区。
注意:虚拟地址空间肯定是连续的,但是物理空间不一定连续,这点一定要牢记。
第二部分 ELF文件格式
这里将ELF格式拿出来单独讲,是因为下面第三部分要详细介绍静态链接和动态链接详细过程了,里面有大量各类表的详细分析,第一部分中的静态链接关系图和动态链接关系图详细描述了静态链接和动态链接中各种表之间的关联关系,至于它们之间如何关联的以及表中各个字段的含义这就要看ELF的具体协议了。
俞甲子的《程序员的自我修养》这本书关于编译和链接讲的非常好,对于ELF文件格式讲的也很好,本文就不讲ELF文件格式这块了,有兴趣的话可以看这本书并参考下面的链接看会更好;因为这本书写的是32位的编译与链接,64位的有些字段含义是有变化的,例如重定位表中的r_info字段包含两部分信息;32位:低8位表示重定位类型,高24位表示符号在符号表中的索引,而64位:低32位表示重定位类型,高32位表示符号在符号表中的索引。
https://docs.oracle.com/cd/E23824_01/html/819-0690/chapter6-54839.html#chapter7-2
这个链接可以当作工具书,里面对ELF的讲解非常详细,值得收藏。
ELF headr 包含(program header table) 和 (section header table )
Program header table: 包含每个program segment and relative info (execute view).
Section header table: 包含每个section header and relative info (link view) .
第三部分 静态链接微观详解
首先明确一点:源码先要编译成目标文件.o,然后再链接成可执行文件或共享对象。
编译阶段生成目标文件,目标文件由各个段(section:链接视图概念)构成,段内对函数或变量的引用分为如下两种类型处理:
(1)全局函数或变量(文件内部或外部定义的全局符号)
统一作为外部符号处理,因此在重定位表中都有相应的描述项
将全局变量统一作为外部符号处理好理解,因为LD合并同类项之后全局变量在数据段(segment:执行视图)内的offset就变了,编译的时候不能用相对寻址,更别说绝对寻址了,LD时还要再次进行重定位,所以编译时统一当作外部符号,由LD统一进行重定位。
将全局函数统一视为外部函数这点和动态共享库处理内部定义的全局变量的思想是一样,因为编译的时候是不知道引用的函数是定义在文件内部的还是文件外部的,例如被引用的函数定义在引用函数的后面的话,这种情况是先编译引用函数的,这时还是不知道被引用函数是否是定义在本文件,所以在编译的时候将对函数的引用统一看作是对外部函数的引用来处理,由LD统一进行重定位。
(2)静态函数或变量(文件内部定义的静态符号)
分为两类:静态变量和静态函数
静态变量:静态变量也会被当作外部符号处理,需要重定位,想想看为什么,还是因为文件中定义的静态变量(包括其他文件)最终会被合并成可执行文件的一个segment,所以相对地址会变化的,因此编译的时候就不能相对寻址了(因为LD合并同类项的时候会变的),故直接将其放到重定位表中,让LD最后进行重定位。
静态函数是编译的时候就可以相对寻址的,不需要重定位,想想看为什么,因为静态函数肯定是定义在同文件中的,因此编译的时候可以确定函数不是外部函数,它们之间的相对地址是固定的,即使后面的LD过程合并同类项也不会有变化,所以在编译的时候就可以确定下来了。
所以在编译的时候对于全局函数,全局变量和静态变量的访问都是当作对外部符号访问来处理的,唯一的例外就是对静态函数的处理,在编译的时候就完成地址绑定了。
下面通过举例详细介绍
如图1所示,main.c中定义了全局变量和静态变量,以及通过全局指针变量和静态指针变量分别引用全局变量和静态变量。
下面通过图2重定位表描述编译阶段是如何处理这些变量和引用的。
图2 .rela.text重定位表存储的是text段中哪些地方需要被重定位的相关信息,其中类型1~6是对不同类型变量的引用,其重定位的基地址是有所不同,下面详细阐述。
3.1 图2中类型1重定位记录分析
Offset=0x1a:表明被重定位的位置在.text段中offset=0x1a处,如下图5中的R1位置。Info=0x001200000002:由两个部分构成(32:32),低32位(0x02)表明重定位入口的类型是R_X86_64_PC32,高32位(0x12)表明重定位入口的符号在符号表中的索引是index=18。
下图3符号表最左边一列就是符号在符号表中的索引:
如上图3所示:num=18就是符号在符号表中的索引,value=0说明该符号在ndx=5(data.rel.local段)中的offset=0,size=8表明该符号占用的空间为8bytes,type=OBJECT表明该符号是一个对象变量,bind=GLOBAL表明该符号绑定的对象是全局的,ndx=5表明该符号所在的段在段表中的索引等于5,参考下图4的段表可知, ndx=5就是.data.rel.local段。
由上图2的sym.name+addend=stat_var_gp-4可得addend=-4,根据ELF linkage协议中定义的relocation_type可知,R_X86_64_PC32类型(相对寻址)的重定位公式是:S+A-P. 具体参见下图6.
S:符号链接后最终的虚拟地址
A: 加数(addend,也称修正值)
P: 需要被重定位处在链接后最终的虚拟地址
S+A-P=S-(P-A)=S-(P+4)
P+4:由图5 重定位R1处可知就是被重定位处下一条指令的虚拟地址
所以,S-(P+4)就是被重定位处下一条指令到目的地址之间的offset,完成重定位后就是相对寻址了。
由此可知推出:全局符号是基于自身符号地址寻址的,且其重定位的加数(addend)就是被重定位处的长度的负数(因为相对寻址是基于下一条指令的地址到目的地址之间的offset)。
3.2 图2中类型2重定位记录分析
Offset=0x2b:表明被重定位的位置在.text段中offset=0x2b处,如下图5中的R2位置。Info=0x000700000002:由两个部分构成(32:32),低32位(0x02)表明重定位入口的类型是R_X86_64_PC32,高32位(0x07)表明重定位入口的符号在符号表中的索引是index=7。
如上图3所示:num=7就是符号在符号表中的索引,value=0说明该符号在ndx=5(data.rel.local段)中的offset=0,size=0表明该符号占用的空间为0bytes,type=SECTION表明该符号标识一个段,bind=LOCAL表明该段是文件内部定义的,ndx=5表明该符号所在的段在段表中的索引等于5,参考上图4的段表可知, ndx=5表示.data.rel.local段。
看到没有,代码段对分配在.data.rel.local段中的stat_var_gp和stat_var_sp这两个指针进行访问,但是寻址的方式却不一样,对全局变量stat_var_gp是基于自身的符号地址进行访问,而对静态变量stat_var_sp则基于.data.rel.local段基址进行访问,想想看这是为什么呢?后面会详述。
根据图2中的Sym.name+addend=.data.rel.local+4可得:addend=4.
下面很有必要解释下这个addend=4是怎么计算得到的。
指令相对寻址的offset是指当前执行指令的下一条指令的起始地址到目的地址之间的差值,
而重定位表中每条记录的r_offset项(参见下面静态链接关系图)中只记录了被重定位处在段中的offset,并没有记录其下一条指令的地址或重定位处的长度(因为通过重定位处长度和r_offset可以计算出下一条指令的地址),这样一来在链接的时候就无法计算出它们之间相对offset,而addend就是用来弥补这个鸿沟的。
(1)全局符号
因为全局符号都是基于自己符号地址寻址的,所以addend中存储的就是被重定位处长度的负数,上面类型1已经解释过了。
(2)静态符号(静态变量和静态指针变量)
因为静态符号都是基于自己所在段的基地址寻址的,所以addend中存储的就是:
被引用静态符号在其所在段中的offset - 被重定位处的长度。
这里作如下定义,进行随后的推导过程:
sym_section_offset : 被引用符号在其所在段中的offset
rela_position_len : 被重定位处的长度
假设符号.data.rel.local,也就是该段在可执行文件中最终的虚拟地址是S,要被修正的位置在可执行文件中的虚拟地址是P, 那么根据relocation_type=R_X86_64_PC32其修正的公式是:S+A-P。
A = sym_section_offset - rela_position_len
S + A - P = S + sym_section_offset - (P + rela_position_len)
S + sym_section_offset:就是符号stat_var_sp链接后的实际虚拟地址
P + rela_position_len: 就是需要被重定位处的下一条指令的虚拟地址
这样(S + sym_section_offset) - (P + rela_position_len)就是LD合并同类项后它们之间实际的offset,将这个值更新到P位置上就完成了地址重定位操作。
因此,由图3符号表可知stat_var_sp在.data.rel.local段中的offset=8,因此addend=8-4=4。
3.3 图2中类型3重定位记录的分析
Offset=0x22:表明被重定位的位置在.text段中offset=0x22处,如下图5中的R3位置。Info=0x000300000002:由两个部分构成(32:32),低32位(0x02)表明重定位入口的类型是R_X86_64_PC32,高32位(0x03)表明重定位入口的符号在符号表中的索引是index=3。
如上图3所示:num=3就是符号在符号表中的索引,value=0说明该符号在ndx=3(data段)中的offset=0,size=0表明该符号占用的空间为0bytes,type=SECTION表明该符号标识一个段,bind=LOCAL表明该段是文件内部定义的,ndx=3表明该符号所在的段在段表中的索引等于3,参考上图4的段表可知, ndx=3表示.data段。
因此可知,静态变量stat_var不是以自己的符号寻址的,而是以其所在的data作为基地址寻址的,关于这一点类型2中已经得出结论,这里是验证。
根据图2中Sym.name + addend = .data - 4,可得addend = -4。
根据类型2中给出的计算addend的公式:A = sym_section_offset - rela_position_len
计算过程如下:
由图3可得符号stat_var在.data段中的sym_section_offset =0
由图5重定位处R3可得rela_position_len=4
所以,addend = 0 - 4 = -4
3.4 图2中类型4重定位记录的分析
Offset=0x36:表明被重定位的位置在.text段中offset=0x36处,如下图5中的R4位置。Info=0x001400000002:由两个部分构成(32:32),低32位(0x02)表明重定位入口的类型是R_X86_64_PC32,高32位(0x14)表明重定位入口的符号在符号表中的索引是index=20。
如上图3所示:num=20就是符号在符号表中的索引,value=0x10说明该符号在ndx=5(data.rel.local段)中的offset=0x10,size=8表明该符号占用的空间为8bytes,type=OBJECT表明该符号标识一个对象变量,bind=GLOBAL表明该对象是全局的,ndx=5表明该符号所在的段在段表中的索引等于5,参考上图4的段表可知, ndx=5表示.data.rel.local段。
根据图2中Sym.name + addend = .abc_gp -4,可得addend=-4。
因为类型4和类型1都是全局指针,基于自己符号地址寻址。
所以根据类型1的结论可以推出:addend = - rela_position_len = -4
3.5 图2中类型5重定位记录的分析
Offset=0x41:表明被重定位的位置在.text段中offset=0x41处,如下图5中的R5位置。Info=0x000700000002:由两个部分构成(32:32),低32位(0x02)表明重定位入口的类型是R_X86_64_PC32,高32位(0x07)表明重定位入口的符号在符号表中的索引是index=7。
如上图3所示:num=7就是符号在符号表中的索引,value=0x00说明该符号在ndx=5(data.rel.local段)中的offset=0x00,size=0表明该符号占用的空间为0bytes,type=SECTION表明该符号标识一个段,bind=LOCAL表明该段是文件本地定义的,ndx=5表明该符号所在的段在段表中的索引等于5,参考上图4的段表可知, ndx=5表示.data.rel.local段。
因此可知,静态指针abc_sp不是以自己的符号地址寻址的,而是以其所在的.data.rel.local段作为基地址寻址的,关于这一点类型2中已经得出结论,这里是验证。
根据图2中Sym.name + addend = .data.rel.local + 14,可得addend=0x14。
根据类型2中给出的计算addend的公式:A = sym_section_offset - rela_position_len,计算过程如下:
由图3可得符号abc_sp在.data.rel.local段中的sym_section_offset =0x18
由图5重定位处R5可得rela_position_len=4
所以,addend = 0x18 - 4 = 0x14
3.6 图2中类型6重定位记录的分析
Offset=0x4c:表明被重定位的位置在.text段中offset=0x4c处,如下图5中的R6位置。Info=0x001500000002:由两个部分构成(32:32),低32位(0x02)表明重定位入口的类型是R_X86_64_PC32,高32位(0x15)表明重定位入口的符号在符号表中的索引是index=21。
如上图3所示:num=21就是符号在符号表中的索引,value=0x00说明该符号在ndx=7(data.rel段)中的offset=0x00,size=8表明该符号占用的空间为8bytes,type=OBJECT表明该符号标识一个对象变量,bind=GLOBAL表明该对象是全局的,ndx=7表明该符号所在的段在段表中的索引等于7,参考上图4的段表可知, ndx=7表示.data.rel段。
根据图2中Sym.name + addend = global_var_gp - 4,可得addend = -4。
因为类型6和类型1都是全局指针,基于自己符号地址寻址。
所以根据类型1的结论可以推出:addend = - rela_position_len = -4
3.7 图2中类型7重定位记录的分析
Offset=0x57:表明被重定位的位置在.text段中offset=0x57处,如下图5中的R7位置。Info=0x000a00000002:由两个部分构成(32:32),低32位(0x02)表明重定位入口的类型是R_X86_64_PC32,高32位(0x0a)表明重定位入口的符号在符号表中的索引是index=10。
如上图3所示:num=10就是符号在符号表中的索引,value=0x00说明该符号在ndx=7(data.rel段)中的offset=0x00,size=0表明该符号占用的空间为0bytes,type=SECTION表明该符号标识一个段,bind=LOCAL表明该段是文件本地定义的,ndx=7表明该符号所在的段在段表中的索引等于7,参考上图4的段表可知, ndx=7表示.data.rel段。
因此可知,静态指针global_var_sp不是以自己的符号地址寻址的,而是以其所在的.data.rel段作为基地址寻址的,关于这一点类型2中已经得出结论,这里是验证。
根据图2中Sym.name + addend = .data.rel + 4,可得addend=0x04。
根据类型2中给出的计算addend的公式:A = sym_section_offset - rela_position_len,计算过程如下:
由图3可得符号global_var_sp在.data.rel段中的sym_section_offset = 0x08
由图5重定位处R7可得rela_position_len = 4
所以,addend = 0x08 - 4 = 0x04
3.8 图2中类型8重定位记录的分析
Offset=0x98:表明被重定位的位置在.text段中offset=0x98处,如下图5中的R8位置。Info=0x001600000002:由两个部分构成(32:32),低32位(0x02)表明重定位入口的类型是R_X86_64_PC32,高32位(0x16)表明重定位入口的符号在符号表中的索引是index=22。
如上图3所示:num=22就是符号在符号表中的索引,value=0x00且ndx=UND(undefined)说明该符号是外部符号,文件内部没有定义,size=0且type=NOTYPE说明符号的最终类型目前还无法确定,因为外部同一个全局符号可以被定义成多个弱类型或多个弱类型+一个强类型且数据类型可以不同,所以此时无法确定(关于强弱符号的解释请看”程序员的自我修养”一书,里面有详解),bind=GLOBAL表明该对象是全局的,ndx=UND表明该符号是外部符号,文件内部没有定义该符号。
根据图2中Sym.name + addend = global_var - 4,可得addend = -4。
因为类型8是全局符号,基于自己符号地址寻址。
所以根据类型1的结论可以推出:addend = - rela_position_len = -4
3.9 图2中类型9重定位记录的分析
Offset=0x7c:表明被重定位的位置在.text段中offset=0x7c处,如下图5中的R9位置。Info=0x001900000004:由两个部分构成(32:32),低32位(0x04)表明重定位入口的类型是R_X86_64_PLT32,高32位(0x19)表明重定位入口的符号在符号表中的索引是index=25。
如上图3所示:num=25就是符号在符号表中的索引,value=0x00且ndx=UND(undefined)说明该符号是外部符号,文件内部没有定义,size=0且type=NOTYPE说明符号的最终类型目前还无法确定,因为外部同一个全局符号可以被定义成多个弱类型或多个弱类型+一个强类型且数据类型可以不同,所以此时无法确定(关于强弱符号的解释请看”程序员的自我修养”一书,里面有详解),bind=GLOBAL表明该对象是全局的,ndx=UND表明该符号是外部符号,文件内部没有定义该符号。
根据图6所示:relocation_type=R_X86_64_PLT32=4重定位计算公式为:L+A-P。
L: The section offset or address of the procedure linkage table entry for a symbol
LD对外部函数符号的处理分两种情况
编译器在处理该外部函数符号的时候是不知道这个符号是定义在普通对象还是共享对象里的,所以在rel表中统一将其定义为R_X86_64_PLT32类型,在随后的链接过程会根据函数符号是定义在普通对象还是共享对象进行不同的处理。
(1)该函数符号定义在普通对象中
LD将其当做普通的R_X86_64_PC32类型进行处理,这时L+A-P = S+A-P
(2)该函数符号定义在共享对象中
LD将其作为R_X86_64_PLT32进行处理,LD会为其create一个“函数名@plt”过程和在.got.plt表中创建一个表项(用于存储函数被加载后的实际虚拟地址),并将代码中对该函数的访问改为对该过程的访问,这些操作都要在静态链接的时候完成的,这个过程(函数名@plt)的地址就是L,所以relocate计算公式变为:L+A-P。
最后动态链接的时候会将函数的实际虚拟地址更新到.got.plt表项中,这样该过程通过.got.plt表项就可以间接跳转到实际要访问的函数了。
根据图2中Sym.name + addend = multiple - 4,可得addend = -4。
因为类型9是全局符号,基于自己符号地址寻址。
所以根据类型1的结论可以推出:addend = - rela_position_len = -4
通过对以上9种重定位类型的分析我们可以总结出如下结论:
1. 全局符号(包括全局指针)是以自己的符号地址进行重定位的
2. 静态符号(静态变量和静态指针)是以自己所在的段为基地址进行重定位的
3. 指向非外部符号的指针(全局和静态)都被分配在.data.rel.local段中
4. 指向外部符号的指针(全局和静态)都被分配在.data.rel段中
首先要搞清楚.data.rel.local段的含义:.data表明它是一个数据段,.rel表明这个数据段中的数据是对其他符号的引用(例如int* var_p = &var),是需要被重定位的,.local表明这个被引用的符号是在本文件定义的(例如文件内部定义了int var=3),而不是外部符号(extern int var)。根据以上的解释:.data.rel段的含义大家应该都明白了,是变量引用了外部符号,需要被重定位这里不阐述了。
注意:不管是.data.rel.local段还是.data.rel段,其内部定义的数据变量既可以是全局变量,
也可以是仅内部可见的静态变量,这点一定要搞清楚,下面举例说明。
例如: int a = 3; int* a_p = &a; static int* s_a_p = &a;
全局指针变量a_p和静态指针变量s_a_p都会放在.data.rel.local段中,
但对它们的寻址是不同的:全局指针变量a_p = a_p + addend; 而静态指针变量s_a_p = .data.rel.local + addend。
如果将int a = 3改为extern int a的话,全局指针变量a_p和静态指针变量s_a_p都会放在.data.rel段中,对它们的寻址也会变为:全局指针变量a_p = a_p + addend; 而静态指针变量s_a_p = .data.rel + addend。
看到这里可能会问为什么静态符号都是以所在段的基地址为base寻址呢?
因为静态变量(包括静态指针变量)是文件内部定义的仅内部可见符号,所以当LD在合并同类项,建立全局符号表时是不会记录这些仅文件内部可见的符号的,但会通过它们所在的段基地址+addend来定位它们。
例如:static int a=3; 是通过.data + addend来定位静态变量a
static Int* a_p = &a; 是通过.data.rel.local + addend来定位静态变量指针a_p
extern int b; static int* b_p = &b; 是通过.data.rel + addend来定位静态变量指针b_p
通过以上的解释大家有没有发现一个现象,仅内部可见的符号都是以所在的段基地址为base进行寻址的,这样做的好处就是全局符号表中只需要记录全局符号和段符号就行了,大量的仅内部可见符号就不用记录了。
下面列举重定位表rela.data.rel.local中的两条记录来分析如何对数据区内的指针变量进行重定位。
首相要明白指针是对一个符号的绝对地址引用,所以relocation_type=R_X86_64_64(绝对地址引用)。
3.10 图2中类型10重定位记录的分析
Offset=0x08:表明被重定位的位置在.data.rel.local段中offset=0x08处,如下图5中的R10位置。Info=0x000300000001:由两个部分构成(32:32),低32位(0x01)表明重定位入口的类型是R_X86_64_64,高32位(0x03)表明重定位入口的符号在符号表中的索引是index=0x03=3。
如上图3所示:num=3就是符号在符号表中的索引,value=0x00说明该符号在ndx=3(data段)中的offset=0x00,size=0表明该符号占用的空间为0bytes,type=SECTION表明该符号标识一个段,bind=LOCAL表明该段是文件本地定义的,ndx=3表明该符号所在的段在段表中的索引等于3,参考上图4的段表可知, ndx=3表示.data段。
因此可知,静态变量stat_var_bk不是以自己的符号地址寻址的,而是以其所在的.data段作为基地址寻址的。
根据图2中Sym.name + addend = .data + 4,可得addend=0x04。
由于relocation_type=R_X86_64_64所以根据图6得知其计算公式为:S+A
S:是符号链接后的虚拟地址
A:加数(修正值)
stat_var_bk是以.data段作为基地址寻址的,由下图5可知stat_var_bk在.data段内的offset=4
所以,符号stat_var_bk的虚拟地址=.data连接后的虚拟地址+offset=S+offset。
所以可以推出:addend=符号在所在段的offset.
3.11 图2中类型11重定位记录的分析
Offset=0x10:表明被重定位的位置在.data.rel.local段中offset=0x10处,如下图5中的R11位置。Info=0x001300000001:由两个部分构成(32:32),低32位(0x01)表明重定位入口的类型是R_X86_64_64,高32位(0x13)表明重定位入口的符号在符号表中的索引是index=19。
如上图3所示:num=19就是符号在符号表中的索引,value=0x08说明该符号在ndx=3(data段)中的offset=0x08,size=4表明该符号占用的空间为4bytes,type=OBJECT表明该符号标识一个对象,bind=GLOBAL表明绑定的是全局对象,ndx=3表明该符号所在的段在段表中的索引等于3,参考上图4的段表可知, ndx=3表示.data段。
因此可知,全局变量abc是以自己的符号地址寻址的。
根据图2中Sym.name + addend = .data + 0,可得addend=0x00。
由于relocation_type=R_X86_64_64所以根据图6得知其计算公式为:S+A。
S就是全局变量abc的虚拟地址,所以可以推出:addend = 0x00。
由类型10和11可以得出如下结论:
指针的重定位操作分为两种类型:对静态变量引用和对全局变量引用
(1)对静态变量引用
公式S+A中的S是段基址,那么A就是符号在该段中的offset
(2)对全局变量的引用
公式S+A中的S就是实际符号地址,那么A值永远为0。
下面看一下main.c依赖的外部模块funcs.c的对象文件及相关的重定位表。
从图1中funcs.c的定义可以看出,函数set_multiple_index和函数multiple是定义在同一个文件中的,但是从下面图7 中funcs.o的重定位表可以发现multiple对set_multiple_index函数的调用是需要重定位的,将set_multiple_index当作外部符号处理了。
重定位类型是:R_X86_64_PLT32,参考上面的main.c中对multiple调用的处理,这里就不多做解释了。
编译时对调用静态方法的处理与对全局方法的处理是不同的
由图5中对divide方法的调用可以看出,在编译时就通过相对寻址设置好了跳转地址,在图2的重定位表中也没发现要对divide函数调用进行重定位,所以静态方法调用是不需要重定位的,因为首先它肯定是在文件内有定义的,其次调用它的代码块肯定与静态方法编译在同一个.text段中的,因此它们之间的offset就固定下来了,即使LD合并很多.text段在一起形成一个segment,它们之间的offset也不会改变,因此可以在编译时就设定好。
下图11是main.c链接成可执行文件后main的汇编代码,从中可以看出对divide调用的offset值与图5 main.o中的offset是一样的。
第四部分 动态链接微观详解
4.1 动态链接的主要有2大优点
1. 共享DSO,节省内存
2. DSO版本更新后,程序不需要重新编译
要实现以上两点,DSO必须是PIC代码。
4.2 动态链接有1个缺点
在运行时要一次性链接整个程序依赖的包(DSO如果已经存在于内存的话,仅需要relocate and remapping),这样程序运行的速度肯定会慢的(所以比静态共享库要慢,后面会讲静态共享库的优缺点)。尤其是程序运行的过程中,有可能会走不同的分支,从而运行不到有些依赖包,这时也把这些包链接和加载进内存会大大消耗时间和内存。
为了克服这个缺点,就有了PLT技术,按需加载,用到了才加载并链接。
但有一点一定要注意:如果对DSO包定义的全局变量有引用的话,那么不管该变量的引用是否会被执行到,该DSO包都会被加载到内存并对该全局变量的引用重定位,因为PLT是对函数调用的延迟加载技术,而全局变量访问是没有这项技术的,所以函数可以是RTLD_LAZY,但变量一定要RTLD_NOW。
4.3 动态链接库/对象为什么要是PIC code
(1)共享对象(dso)要想被不同进程共享必须是PIC code
想想看如果dso包不是PIC code的情形
如果sample.dso包被加载到进程A的0x3000~0x4000虚拟地址空间,那么该包中所有对绝对虚拟地址的引用(指令部分)都要以0x3000作为virt_base进行更新;当进程B要把该包加载进自己0x7000~0x8000虚拟地址空间,那么就无法共享进程A加载的sample.dso包的指令部分了。
(2)DSO包的更新与发布必须要PIC code
下面以静态共享库为例说明。
静态共享库的虚拟基地址是固定的,所以每个进程要想引用静态共享库,那么在每个进程的虚拟地址空间中,必须预留固定的虚拟地址区间用于该静态共享库。
例如sample.sso是静态共享库且其虚拟基地址是0x4000,占用一页大小(0x1000),这样每个要用到它的进程都要在自己的进程地址空间中预留0x4000~0x5000这个虚拟地址区间给sample.sso。
程序在静态链接的时候就可以完成所有对静态共享库的函数和全局变量引用的重定位工作(静态共享库在进程中的虚拟地址是固定的),但是不会在此时把静态共享库链接到应用程序中,而是等到运行时才加载到内存并映射到程序固定的虚拟地址区间,通过这样的方式实现库文件在不同进程之间的共享,这样只保留一份库文件在内存中,从而实现节省内存和减少程序本身大小的问题,而且运行时仅需加载一次但不需要重定位了,所以速度比DSO要快。
但这样的方式实现库共享会导致以下两个大问题:
(1)进程虚拟地址的利用很不灵活很容易造成地址冲突
(2)静态库文件如果版本升级后其中的函数或全局变量的地址一旦发生改变,原来的应用程序必须重新链接。
所以PIC code就是为了解决问题1,装载时重定位就是为了解决问题2,这样静态共享库就编程动态共享库了。
4.4 编译和链接的总特点
1.4.1 编译器在编译的时候(注意不是链接),对象内对所有全局变量(包括内外定义或声明的)的引用,不管是声明的还是定义的,默认都是当作外部符号处理的,都会放到重定位表中去。
1.4.2 连接器在链接可执行文件的时候,会遍历程序所有的符号表(包括依赖的共享库),并做如下的操作。
(1)首先检查主程序定义的全局强符号与所有共享库的全局强符号是否有重定义冲突。
(2)检查主程序及共享库中所有以extern声明的全局符号,在其他的模块和共享库中是否有定义,如果没有定义就会报符号未定义的链接错误。
(3)如果主程序中声明的extern全局符号在其它的模块中定义了,那么在静态链接的时候就直接重定位了(链接时已经知道其虚拟地址了)。
(4)如果主程序中声明的extern全局符号在其它的共享库中定义了,那么此时还无法决定该符号的实际虚拟地址是多少,那么就根据主程序是否是PIC code进行不同的处理,后面会详解。
(5)在链接共享库自身的时候,是不会检查共享库中用到的但声明为extern 的全局符号是否有定义,但当将共享库链接到主程序的时候,连接器会检查共享库用到的但声明为extern 的全局符号在全局符号表中是否有定义。
例如在sample.dso共享库中声明了一个extern int ext_var; 并且被其定义的funcA使用了;而Test.c主程序依赖该sample.dso库,并且调用了其定义的方法funcB(没有使用ext_var),即使这样在链接Test.c为可执行程序的时候也会报undefined reference to ext_var这样的错误,如果在Test.c中或其他依赖的库文件中定义了该变量就不报错了。
4.5 对extern变量和弱类型变量的处理是动态链接中比较复杂的部分。
下面从编译和链接两个阶段分别讲解
4.5.1编译阶段
1. extern 变量被当做外部符号(ndx=UND)处理,如果代码和数据有对其引用,重定位表中有R_X86_64_PC32类型的重定位记录。
2. 弱类型变量(例如这样: int a;仅声明未定义的变量)会被当做COMMON类型的符号(ndx=COM)来处理,如果代码和数据有对其引用,重定位表中有R_X86_64_PC32类型的重定位记录。
COM类型在编译的时候其实也可以被看作是UND类型,因为它不属于当前文件的任何一个段,之所以将其定义为COM类型,那是因为后面LD的时候有用。
4.5.2 链接阶段
根据ndx=UND和ndx=COM的区别,分开讲解
4.5.2.1 ndx=UND
说明该变量是extern声明的外部变量,在本文件中没有定义,所以不属
于当前文件的任何一个段,对它的处理分两种情况。
1. 共享对象中声明的extern变量
因为共享对象中,对(内外)全局符号(函数和变量)的访问,都被当做外部符号来处理,所以extern声明的变量当然也就按照外部符号处理了,最终链接成的共享对象的.rela.dyn段中,会有一条R_X86_64_GLOB_DAT类型的重定位记录。
2. 可执行文件中声明的extern变量
这里又分成两种情况
(1)该extern变量是定义在主程序的其它模块文件中,那么就按照静态链接来处理,在链接成主程序的过程中,就重定位好了,相对寻址即可。
(2)该extern变量是定义在DSO对象中的话,那么又可以分成两种情况来处理。
【1】如果可执行文件不是PIC code的话
那么必须要在.bss段中分配一个该变量的副本,然后重定位到该处即可,在加载DSO的过程中,会将DSO中的该变量的初始值COPY到可执行程序.bss段中的这个变量的副本上,然后会重定位DSO中该变量对应的.got表项,使其指向主程序.bss段中的这个副本。
可能有人会问,为什么不能在主程序的.got中分配一个表项,然后重定位到共享对象中定义的该变量,这样多简单方便,因为主程序对共享对象中定义的函数引用就是这么干的(通过.got.plt表项);稍后会做详细解读,这里可能大多数人都没搞懂,而且俞甲子的那本书说的也不对,主要原因是:指令对数据访问地址无关性的处理和对函数调用地址无关性的处理机制不一样。
【2】如果可执行文件是PIC code的话
通过在.got表中分配一个表项,用于存储DSO中定义的该变量加载后的实际虚拟地址,实现重定位。
4.5.2.2 ndx=COM
链接阶段对ndx=COM的处理也分为两种情况。
1. DSO中声明的弱类型变量
链接的时候会在最终的DSO对象的.bss段中为其分配空间,然后.rala.dyn段中会有一条R_X86_64_GLOB_DAT类型的重定位记录。
2. 主程序中声明的弱类型变量
有3种情况需要考虑
(1)主程序所依赖的DSO中没有该变量的声明或定义。
会在主程序的.bss段中为其分配空间,然后根据重定位记录重定位。
(2)主程序所依赖的DSO中也仅有该变量的声明(弱类型)。
会在主程序的.bss段中为其分配空间,然后根据重定位记录重定位。
(3)主程序所依赖的DSO中有该变量的定义(强类型,以它为准)。
这又得分成两种情况来看
【1】主程序不是PIC code
这时就相当于对DSO中该变量的引用了,所以会在.bss中分配一个副本,且在.rela.dyn段中会新增有一条R_X86_64_COPY类型的重定位记录,后面会详解。
【2】主程序是PIC code
这时就相当于对DSO中该变量的引用了,但不会在.bss中分配一个副本,而是会在.got表中增加一个指向该变量的表项,并在.rela.dyn段中会新增有一条R_X86_64_GLOB_DAT类型的重定位记录,后面会详解。
4.6 举例详解以上列举的动态链接过程中的各类重定位类型
下面是主程序和两个DSO的源码
4.6.1 分析funcs.c的编译和链接
因为maths.c是一个纯粹的函数库,没有需要重定位的引用,所以这里先分析funcs.c的编译和链接,看看DSO库或对象是如何生成的。
如图15所示:通过gcc -fPIC -c funcs.c -o funcs.o命令生成PIC目标对象。
由上图15所示:
所有对全局变量的访问(不管是弱类型还是强类型,或是extern类型)都会有相应的R_X86_64_REX_GOTP类型重定位记录,该类型告诉链接器要创建.got表并且将所有对全局变量的访问都修改为通过.got中的表项间接访问,从而实现指令访问全局变量的地址无关性。
所有对全局函数的访问都会有相应的R_X86_64_PLT32类型重定位记录,该类型告诉链接器要建立.got.plt表并且将所有对全局函数的访问都修改为通过.got.plt中的表项间接访问,从而实现指令访问全局函数的地址无关性。
图15中重定位表.rela.text中的各个字段的含义这里就不一一解释了,因为上一章的静态链接详解中已经详细介绍过了,这里仅对weak类型变量的Sym.value值解释一下;因为weak变量的编译后的ndx=COM不在任何段中,所以其Sym.value值表示该变量的长度,而不再是符号在段中的offset了,这点一定要清楚,这是ELF协议规定的。
下图17 funcs.o的符号表中,高亮部分显示了weak_var1和weak_var2的ndx=COM,weak_var3在本文件中确实有定义,所以其ndx=3(.data段);set_multiple_index是在本文件定义的,所以其ndx=1(.text段),而math_add不是本文件定义的,所以其ndx=UND。
下面看看执行完链接指令ld -fPIC -shared -o funcs.dso funcs.o 后得到的动态共享对象,其重定位表有哪些变化。
下面通过funcs.dso的符号表,段表,数据段表和代码段表详细阐述DSO对象在加载的时候是如何被重定位的。
如图19所示,weak_var1,weak_var2和weak_var3的ndx分别为14,14和13;再看看段表图20可知ndx=14是.bss段,ndx=13是.data段。
所以对于弱类型在链接DSO的过程中,最终会在.bss段为其分配内存空间的。
首先得详细介绍下如何利用PLT技术实现延迟或按需绑定外部函数。
如果不使用PLT技术的话,程序启动后,动态链接器会将程序中所有要访问的外部符号的实际虚拟地址更新到对应的.got表项中(当然先要加载对应的DSO),程序的指令部分是通过.got表项间接寻址这些外部符号的,从而实现了指令的PIC。
而PLT(procedure linkage table)相当于在指令间接寻址和.got表之间又加了一层间接寻址,它是一段代码,这点一定要搞清楚。
因为PLT是专门用于对外部函数引用的延迟绑定,所以将原先的一个.got表分成了两个表:
.got表: 专门用于存储外部全局变量的实际虚拟地址
.got.plt表:专门用于存储外部全局函数的实际虚拟地址
.got.plt表的结构稍微有点特殊,它的前三项分别被用于存储:.dynamic段地址,本模块ID和dl_runtime_resolve的地址;之后的表项才被用于存储每个外部函数的实际虚拟地址。
.plt段:是个代码段,专门用于将被调用到的外部全局函数的实际虚拟地址绑定到对应的.got.plt表中。
它的结构图22的高亮注释对其进行了详细解释,下面通过对math_add的绑定再次进行详细解读。
math_add是funcs.dso的外部函数,因此在funcs.dso中有一个math_add@plt项,图22中的0x108d处对math_add的调用就变成先跳转到math_add@plt(相对跳转),而不是直接通过.got表项间接跳转到外部函数。
下面看看math_add@plt项干了什么。
如代码图22中0x1020处所示:
第一条指令间接跳转到.got.plt表项0x4020处存储的地址,由图21可得0x4020处存储的地址是0x1026,而0x1026就是math_add@plt的下一条指令地址,该指令将math_add符号引用在重定位表.rela.plt中的下标入栈,第三条指令是跳转到0x1000处。
0x1000处的.plt项是每个plt函数项的都要执行的部分,因此将其独立出来单独成项。
.plt项第一条指令是将本模块的moduleID入栈,然后跳转到dl_runtime_resolve函数执行math_add的绑定工作,dl_runtime_resolve函数会根据外部函数引用在.rela.plt重定位表中的索引(入栈的参数),解析处需要的重定位信息,查找全局重定位表,找到math_add符号的实际虚拟地址,并根据重定位类型R_X86_64_JUMP_SLO = S(符号实际虚拟地址),将math_add的实际虚拟地址直接更新到.got.plt表项0x4020处,最后再返回继续执行call math_add@plt,就跳转到maths.dso的math_add执行了。
以上详述了如何通过PLT技术延迟绑定外部函数引用,下面将讲一下如何绑定外部变量的引用。
由图18可知,动态链接的重定位表被分为两类:.rela.dyn和.rela.plt。
.rela.dyn : 用于对外部变量引用的重定位
.rela.plt : 用于对外部函数的重定位
我们看一下图18中.rela.dyn表的第一条记录是怎么重定位的。
Offset Info Type Sym. Value Sym. Name + Addend
000000003fe0 000400000006 R_X86_64_GLOB_DAT 0000000000004034 multiple_index + 0
Offset=0x3fe0是.got表的起始位置,说明此处用于存储multiple_index的实际虚拟地址。
Info.relocation_type=0x06=R_X86_64_GLOB_DAT = S表明就是将multiple_index的实际虚拟地址更新到0x3fe0即可。
Info.symbol_index=0x04说明该符号在.dynsym动态符号表中的索引是4,看一下图18可知multiple_index是分配在.data段中的全局对象。
此时的Sys.value是链接DSO对象时分配的虚拟地址0x4034(以0x00为基地址),图21中显示此处属于.data段且存储的值为0x03,在动态链接的时候会将funcs.dso被加载到进程的虚拟基地址+Sys.value形成最终的实际虚拟地址并更新到全局符号表中,随后会遍历.rela.dyn重定位表并检索全局符号表中multiple_index的实际虚拟地址,最后更新到对应的.got表项,到此就完成了对外部变量引用的重定位了。
这里可能大家会有个疑问,为什么对外部变量引用不应用PLT技术进行延迟绑定呢?
这里解释一下,一方面是因为DSO对象之间相互引用全局变量这种情况本身就是要尽量避免的,因为这会增加共享模块之间的耦合度,不利于功能扩展,所以需要被重定位的量相对于函数来说比较少,没必要延迟绑定;另一方面就是从技术实现上来说在链接阶段也不可能,除非在编译阶段就做好了这方面的考虑,后面会通过解释主程序(非PIC)引用共享对象中的变量时会在自己的.bss段中分配一个COPY对象的原因来回答这个问题。
4.6.2 下面详细分析主程序被编译和链接成PIC和非PIC code时,对共享对象中函数和变量的引用是如何处理的。
4.6.2.1 主程序是PIC code
用命令:gcc -fPIC -c -main.c -o main.pic.o将main.c编译成PIC code模式,重定位表如下图23所示:
这里和图15 funcs.o的重定位表的重定位类型一样,分为两类:
(1)所有对全局变量的访问(不管是弱类型还是强类型,或是extern类型)都会有相应的R_X86_64_REX_GOTP类型重定位记录,该类型告诉链接器要创建.got表并且所有对全局变量(内外定义或声明的)的访问都修改为通过.got中的表项间接访问,从而实现指令访问全局变量的地址无关性。
(2)所有对全局函数的访问都会有相应的R_X86_64_PLT32类型重定位记录,该类型告诉链接器要建立.got.plt表并且将所有对全局函数的访问都修改为通过.got.plt中的表项间接访问,从而实现指令访问全局函数的地址无关性。
下图24是对main.pic.o的部分反汇编,这里主要关注如何实现访问全局变量和全局函数的地址无关性。
图24中注释,详细分析了如何实现对全局函数inner_add和全局变量dso_var引用的PIC。
对inner_add引用的分析
地址0x22处的指令,操作码E8表明它是call指令相对跳转,后面的4个字节是offset(需要被重定位),由图23 重定位表可知,此处是对inner_add引用的重定位。
对该处offset的重定位分两种情况:
1. 如果inner_add是主程序内部自定义的,那么静态链接的时候就可以计算出inner_add相对于调用指令之间的offset了,因此静态链接的时候就实现重定位了。
2. 如果inner_add是外部DSO中定义的,那么静态链接的时候无法知道其虚拟地址,所以就得通过.got表项实现运行时动态链接重定位。
注意:可执行程序也是通过在外部函数引用与.got表之间增加一层间跳转(函数名@plt过程),实现外部函数引用的PIC,但是不分.got和.got.plt两个表了,统一都放在.got表中,.got的前三项分别用来存储: .dynamic段地址,本某块moduleID和dl_runtime_resolve地址;而且主程序中对外部函数的引用是立即重定位(尽管指令也是通过call 函数名@plt调用,但不起作用)和外部变量引用的处理是一样,下面会debug验证这一点。
下面看看main.pic.o被链接成可执行文件后的重定位表和反汇编代码是什么样
用gcc -o main.pic main.pic.o ./funcs.dso ./maths.dso生成main.pic可执行文件
由上图25可知,只有对外部变量dso_var,内部声明weak_var3和外部函数multiple的引用需要重定位,而之前main.pic.o的重定位表(图23)列出的对inner_add,weak_var1和weak_var2引用的是需要重定位的,这里没有了。
下面结合main.pic的反汇编代码段和数据段详细分析为什么这三个符号不需要重定位了。
PIC主程序中对自定义的全局函数inner_add的处理
图26中0x1167地址处是对inner_add引用的重定位,是直接修正为相对跳转到inner_add(0x1145),从中可以看出两点:
1. 没有在.plt段中创建一个inner_add@plt过程用于间接访问和重定位.got中其对应的表项。
这和链接器处理共享对象中自定义的函数之间引用不一样。
从图15(目标对象funcs.o)和图18(目标对象main.pic.o)中可以看出,编译阶段它们对内部自定义的set_multiple_index和inner_add的处理,用的都是一样的重定位类型:R_X86_64_PLT32。
但链接阶段不一样,共享对象中即使函数都是定义在同一个DSO中,但是它们之间的调用还是通过.got.plt表间接跳转的。
可以看一下funcs.dso中的重定位表(图18).rela.plt,可以看出multiple和set_multiple_index这两个全局函数虽然都是定义在funcs.dso中的,但是multiple调用set_multiple_index函数还是要通过.got.plt间接调用。
2. 没有在.got表中创建一个表项,用于存储inner_add实际虚拟地址。
这个就好明白了,指令(e8)是相对跳转指令且没有在.plt段中创建间接跳转项(inner_add@plt),是在静态链接的时候就修正好了,所以不会在.got表中创建表项了。
PIC主程序对自己声明的弱类型全局变量的处理
这里主要分为2种情况
情况1: 主程序和依赖共享对象中都声明了同名的弱类型
weak_var1和weak_var2在main.c和funcs.c中都声明为弱类型
那么会在主程序的.bss段为weak_var1和weak_var2分配空间,这里不管是int还是long数据类型都分配了8bytes,如图26 main.pic的数据部分所示。
我们知道目标对象funcs.o的重定位表(图15)显示,weak_var1和weak_var2的重定位类型都是R_X86_64_REX_GOTP,这是指示LD在链接的时候在.got表中创建相应的表项并通过.got表项间接访问weak_var1和weak_var2。
共享对象funcs.dso的重定位表(图18)显示,weak_var1和weak_var2的重定位类型都是R_X86_64_GLOB_DAT,表明LD在链接的时候确实为它们在.got中创建表项了并通过got表项间接访问。虽然已经在(图21)funcs.dso的.bss段中为它们分配了空间了吗,但是此时还不知道其他的模块(例如主程序)或DSO中是否定义了同名的强类型,如果有定义了强类型这里就要重定位到强类型的地址了,如果没有就重定位到自己的.bss段中分配的变量。
目标对象main.pic.o中的重定位表(图23)显示,weak_var1和weak_var2的重定位类型都是R_X86_64_REX_GOTP,这也是指示LD在链接的时候在.got表中创建相应的表项并通过.got表项间接访问weak_var1和weak_var2;但因为main.pic.o是主程序,所以在链接为可执行程序的时候,会检索自己及其依赖的所有对象的符号表,从而能够确认这两个符号没有请类型定义,所以就会将分配在自己.bss段中的weak_var1和weak_var2在符号表中设定为强类型符号,然后对这两个变量的引用也不经过.got表了,其依赖对象funcs.dso中对这两个变量的访问也都会重定位到main的.bss段中来(因为main中的被改为强类型了)。
这里要着重解释一下这两个变量的重定位方式是如何转变的。
我们知道目标对象main.pic.o中已经将对weak_var1和weak_var2的访问分成两步来完成了,如图24(main.pic.o代码部分)的0x62和0x69两处地址所示:
第一步:48 8b 05 00 00 00 00 mov 0x0(%rip),%rax
寄存器相对寻址从.got表项中取出weak_var1的实际虚拟地址存入%rax。
第二步:8b 10 mov (%rax),%edx
寄存器寻址读取weak_var1的值
现在主程序中可以通过寄存器相对寻址(也就是第一步)一步就访问到存储在main.pic的.bss段中的weak_var1了,但现在这里已经被编译成分两步访问了,怎么办?
有两个办法:
【1】不用修改第一步指令
LD的时候还是为weak_var1在.got中创建表项,并在rela.dyn中创建该变量重定位记录,这样在运行main主程序的时候动态链接器会将main运行时bss段中weak_var1的实际虚拟地址更新到其对应的.got表项中了,从而完成重定位。这也就和DSO对象的处理方式一样了。
【2】修改第一步指令
通过lea指令和(%rip)相对寻址相结合的方式,实现运行时动态获取bss段中weak_var1的实际虚拟地址;但这种方式有个前提:不能改变指令的长度。
目前LD就是采用了这种方式将第一步改为:
图27中0x1127处:48 8d 05 6a 2e 00 00 lea 0x2e6a(%rip),%rax
指令长度都为7bytes,没有变。
为什么指令长度不能变呢,如果能变的话就简单了,直接将第二部去掉不就行了吗,通过寄存器相对寻址一步就搞定了,想想看为什么?
因为代码段中如果存在大量相对跳转指令的话,一旦你增加或减少了指令的长度,很可能会导致这些跳转目的地址是错误的了,想想看如果有大量判断语句以weak_var1为判断条件进行跳转,尤其是通过标号的跳转,这些跳转都是相对寻址的,别指望LD会帮你对这些进行修改,不可能的,LD这时压根不知道上下文的联系和逻辑。
这才是非PIC主程序如果引用DSO中的变量的时候,静态LD要在自己的bss段中分配一个该变量的COPY的根本原因。
因为在用非PIC方式编译主程序时,对全局变量的访问就是用一条寄存器相对寻址的(也就是上面的第一步)搞定,在链接的时候其实是知道这个变量是被主模块定义的还是被DSO对象定义的,但是代码的长度已经固定了,不能改了。
如果是主模块自己定义的话,那么静态LD的时候直接重定位就好了。
如果是DSO对象中定义的话,那么LD的时候绝不能改为在该寄存器相对寻址指令(上面的第一步)的后面再加一条寄存器寻址指令,在GOT表中增加一项,最终通过got表间接寻址的方式寻址。
所以LD最终采用了这个方式:在主模块的bss段中为该变量分配一个副本,然后直接重定位到这个副本。
情况2: 主程序中定义了同名弱类型,而依赖对象中定义了同名强类型
因为主程序是使用PIC方式编译的,所以对所有全局变量(内外)的访问在编译时就确定通过如上所述的两步访问模式寻址的。
weak_var3是DSO中定义的全局变量,在链接器链接主程序的时候,会检索自己包括依赖对象的符号表的,从而可以确定是对funcs.dso中定义的该变量的引用,所以是不会在自己的bss段再为其分配空间了,接着会在.got中为其分配一个表项,将第一步寄存器相对寻址重定位到该表项,这样在程序运行并通过动态链接器加载funcs.dso并且将weak_var3的实际虚拟地址更新到对应的got后,如上所述的第一和第二步就可以通过got表间接访问weak_var3了。
4.6.2.2 主程序不是PIC code
下面通过分析非PIC主程序的重定位表,反汇编代码来看看与其PIC code的区别。
通过gcc -c -o main.o main.c 编译出非PIC main.o
如图28所示,在非PIC模式编译时,对全局变量和方法(内外)都是当做外部符号处理的,都是需要重定位的,不过重定位类型和PIC模式编译的不一样。
(1)方法的重定位类型都是:R_X86_64_PLT32。
这里着重讲一下,为什么方法在PIC和非PIC模式的编译下都可以是R_X86_64_PLT32呢?
通过上面的讲解我们知道,在编译时,对全局变量的访问会根据PIC和非PIC模式生成的指令个数是不一样的,PIC模式会生成两条指令,非PIC模式会生成一条指令,所以LD的时候考虑的情况就比较多。
而方法的引用在两种编译模式下就是一条相对跳转指令搞定,这是因为如下原因:
1. 如果引用的方法是在主模块中定义的,那么在静态链接的时候就知道它们之间的offset了,所以静态LD时通过计算出来的offset,直接修改该指令的操作数就完成重定位了。
2. 如果引用的方式DSO中定义的,那么在静态链接的时候还不知道方法的实际虚拟地址,所以在静态链接的时候,会先创建.plt段及函数名@plt表项并且重定位call指令使其相对跳转到该表项,然后再在.got表中分配一个该方法的表项并且表项中存储函数名@plt过程的第二条指令的地址,从而实现通过.got.plt表对方法引用的延迟绑定。
(2)全局变量的重定位类型都是R_X86_64_PC32
这种类型的重定位公式:S+A-P,就不解释了,静态链接那章已经详细解释过了,总是相对寻址形式。
下面看一下链接后可执行程序的重定位表有什么变化。
通过 gcc -o main main.o ./funcs.dso ./maths.dso 生成main可执行文件。
下面结合图29着重分析R_X86_64_COPY类型的作用。
.rela.dyn重定位表中显示:对weak_var3和dso_var的引用的重定位类型变成R_X86_64_COPY类型了,上面已经讲过非PIC程序对全局变量的访问一律都是用一条寄存器相对寻址指令实现的,而此时外部定义的变量weak_var3和dso_var的实际虚拟地址还不知道呢,所以就在主程序的.bss段中分配一个weak_var3_copy和dso_var_copy,并将指令修正到指向这些副本,当主程序运行时,会处理重定位表.rela.dyn中的所有重定位记录(变量的重定位是没有延迟绑定的),首先会加载funcs.dso,这时内存中就有两个weak_var3和两个dso_var了,此时
动态链接器会将funcs.dso中这连个变量的值copy到副本中,实现数据的一致性。
当主程序执行到multiple方法时,动态链接器就会根据funcs.dso中的重定位记录,将funcs.dso中的.got表中用于存储weak_var3和dso_var实际虚拟地址的表项,重置为存储主程序.bss中分配的 weak_var3_copy和dso_var_copy的实际虚拟地址,至此分别完成了主程序中副本数据的初始化和funcs.dso中对这两个变量引用的重定位,而funcs.dso中自定义的这两个全局变量就废弃了。
可能有人会问,可以对寄存器相对寻址这一条访存指令,建立一个重定位记录,这样当把funcs.dso加载到进程的地址空间后,就知道weak_var3或dso_var的实际虚拟地址了,然后计算该访存指令到weak_var3或dso_var的实际虚拟地址之间的offset,然后重定位该指令的操作数,这样不就可以了吗?
的确是可以这样,但相对寻址要修正的操作数的长度是4bytes,也就是最大4G的寻址空间,而对于64位处理器来说,当funcs.dso映射到进程的虚拟地址基地址到调用指令之间的距离一旦大于4G,那么就无法访问了,所以这种方式有缺陷。
下面非PIC主程序的数据段和代码段很好的印证了以上的分析
好了罗里吧嗦这么多终于解释完了动态链接的各种细节。下面就通过debug PIC模式的main程序来验证一下是否如上所说。
因为加入了debug信息,所以有些地方的offset就不一样了,所以要重新对main.pic和funcs.dso进行反汇编。
结果如下图所示:
验证点1: 主程序中的PLT延迟加载是否有效
根据以上说的,只有调用了funcs.dso中的multiple方法才会绑定,我们看看是不是这样。
因为inner_add方法是在multiple方法之前就调用了,所在调用inner_add处打断点,
看看此时有没有绑定multiple。
如上图34可知,multiple@plt过程的实际虚拟地址是0x555555555030,根据图32 PIC-main-debug的反汇编代码片段,如下所示:
0000000000001030 <multiple@plt>:
1030: ff 25 82 2f 00 00 jmpq *0x2f82(%rip) # 3fb8 <multiple>
1036: 68 00 00 00 00 pushq $0x0
103b: e9 e0 ff ff ff jmpq 1020 <.plt>
0x555555555030 + 0x6 + 0x2f82 = 0x555555557FB8,该值就是.got表(主程序只有一个.got表,没有.got.plt表)中的一个表项的虚拟地址,该表项中存储multiple函数实际虚拟地址,这里用“函数名@got.item”这种形式来表示该函数引用对应的got表项的虚拟地址,既multiple@got.item = 0x555555557FB8。
由上图34可知:
1. 通过“x /2wx 0x555555557FB8”指令打印出该处内存存储的值为0x7ffff7fc8190,这就是multiple函数的实际虚拟地址,即*(multiple@got.item) = 0x7ffff7fc8190。
2. 通过p multiple指令打印出来的该函数的虚拟地址也是0x7ffff7fc8190。
3. 通过“x /2wx 0x555555557FA0”指令打印出.got表起始处存储的.dynamic的地址为0x3d90,由图32可知.dynamic段的地址的确是0x3d90,.got段的起始处存储的也是0x3d90。
因为,此时还没访问mulitple函数呢,这1,2的值竟然是一样的,所以对于主程序来说,PLT压根就没起作用。
验证点2:funcs.dso调用math_add的时候,PLT延迟绑定有没有起作用。
由funcs.c的源码可以知道,math_add是被set_multiple_index调用的,所在调用set_multiple_index处打断点,按道理是不应该会触发对math_add的绑定的。
如上图35可知,math_add@plt过程的实际虚拟地址是0x7ffff7fc8040,根据图33 funcs.dso-debug的反汇编代码片段,如下所示:
0000000000001040 <math_add@plt>:
1040: ff 25 da 2f 00 00 jmpq *0x2fda(%rip) # 4020 <math_add>
1046: 68 01 00 00 00 pushq $0x1
104b: e9 d0 ff ff ff jmpq 1020 <.plt>
0x7ffff7fc8040 + 0x6 + 0x2fda = 0x7FFFF7FCB020, 该值就是.got.plt表中的一个表项的虚拟地址,该表项中存储multiple函数的实际虚拟地址,这里用“函数名@got.plt.item”这种形式来表示该函数引用对应的got表项的虚拟地址,既math_add@got.plt.item = 0x555555557FB8。
由上图35可知:
1. 通过“x /2wx 0x7FFFF7FCB020”指令打印出该处内存存储的值是0x7ffff7fc8046,也就是函数math_add的实际虚拟地址,即*(math_add@got.plt.item) = 0x7ffff7fc8046。
2. 通过p multiple指令打印出来的该函数的虚拟地址也是0x7ffff7fc30f5。
3. 通过“x /2wx 0x7FFFF7FCB000”指令打印出.got.plt表起始处存储的.dynamic的地址为0x3e40,从图33可知,.dynamic段的地址的确是0x3e40,.got.plt的起始项存储的也是0x3e40。
因为,此时还没访问mulitple函数,1,2这两处的值也不一样,所以funcs.dso对maths.dso中math_add方法的引用是延迟绑定的。
之前还讲过,在没有重定位之前,.got.plt中每个表项(不包括前三项)存储的默认值是其对应的“函数名@plt”过程的第二条指令的地址,从图33可知,0x4020处存储的地址是0x1046,正是math_add@plt的第二条指令的地址(68 01 00 00 00 pushq $0x1);打印0x7FFFF7FC8046处地址存储的值为:0x00000168 0x00,由此可得math_add@got.plt.item表项中存储的值由0x1046变为0x0x7FFFF7FC8046。
所以将funcs.dso加载到进程的0x7FFFF7FC7000虚拟基地址处时,首先会更新.got.plt表中所有表项(不含前三项)存储的值:*(math_add@got.plt.item) += 0x7FFFF7FC7000,这样才能保证后面对math_add进行绑定时正确跳转到math_add@plt的第二条指令,进而跳转到.plt中调用dl_runtime_resolve函数执行真正的绑定操作。
看到没有,实际的操作和之前讲的还是稍微有点出入的。
验证点3:主程序所依赖的直接或间接DSO对象是何时被加载映射到进程地址空间的。
上面讲过,对DSO中全局变量的引用是无法PLT处理的,只要程序中有引用,不管执行的时候是否会被执行到,都要无条件的重定位;从这个例子可以推出:funcs.dso只对maths.dso中的方法有调用,所以在没访问maths.dso之前不应该加载maths.dso到进程地址空间的。
下面我们来验证一下。
由上图34,35的native process 58576可知,这个进程的ID=58576。
当前还没有运行到math_add@plt过程,math_add.dso应该还没加载。
通过cat /proc/58576/maps 指令可以查看进程的地址空间分布情况。
由图36可知,此时已经将maths.dso加载映射到进程的地址空间了。
通过过以上三点可以得出结论:
1. 主程序直接或间接依赖的DSO对象,在主程序开始运行时会一次性加载映射到进程的地址空间。
2. 在主程序开始运行时,完成所有外部全局变量的重定位以及.rela.plt所有表项(不含前三项)的*(math_add@got.plt.item) += DSO_VIRT_BASE操作,上一步操作完后就知道每个DSO的虚拟基地址DSO_VIRT_BASE了。
3. 主程序开始运行时,其直接依赖的外部函数不是延迟绑定的,和对外部变量的处理一样。
4. 共享对象中对外部函数的引用是使用PLT处理的,PLT机制有效。
在家窝了这些天,终于写完了,也该告一段落了。