学习《程序员的自我修养》一书,进行记录总结。本文为第七章的主要内容。

静态链接

  • 符号表表示了符号和地址的关系,代码段中只有地址,没有符号,在编译时符号的地址被刷入到代码段。
  • 重定位表,每一项是代码段中需要被重定位的地方和符号的关系。如果符号地址是未知的,那么必须在链接过程中确定,然后根据重定位表将地址刷新到代码段里。

前面说过静态链接,大体上可以认为是将一大堆的.o文件合并成一个可执行文件(.a 是.o的打包而已)。这就造成一个.o文件发生变换,整个代码都需要重新进行链接,增加编译负担。而且不同进程使用相同的.o文件,都会在进程空间内存在相同的副本造成空间浪费。所以接着出现了动态链接的概念,相应的文件为.so文件,也被称为共享对象。
大体逻辑是,在编译时,只将so的部分信息添加到可执行文件里,而不是将整个so合并进来。在运行时,才将文件映射到进程的虚存空间中。而且,如果某个so已经被映射,那么另一个进程还需要这个so,只需要将映射信息复制一份到进程空间中即可。即so被映射到一段物理空间上,而这个物理空间可以被映射到多个进程各自虚拟空间的不同地址,从而达到避免相同信息在物理内存出现多次(当然,可读写数据还是进程私有,只能进行拷贝)。并且,要求这个映射到进程虚存的地址不能固定,否则这个so就强行占用了所有需要用到自身的进程的地址空间,对进程本身的代码以及内存管理带来极大地麻烦。也就是说,这个so,可以根据进程的实际情况装载到不同的虚存上。

动态链接

由虚存加载地址引发的一系列问题——代码段

动态链接首先遇到的问题是,共享对象被加载,如何确定在进程虚拟空间中的地址?这个一般是有内核决定,在进程虚存空间中选一个地址进行映射。这也就意味着,相同的物理地址,在不同的进程空间的虚拟地址是不一样的。那么如果共享对象的代码段有函数调用,数据访问,这些地址是不能直接定死的(试想如果每个动态库都固定的占去一段地址空间,那么占相同地址空间的动态库是无法互相共存的)。
为了实现这个有两种方案,一种是基址重置,一种是地址无关码。

  • 基址重置:也叫装载时重定位,即在编译时,将未知的部分填写成空,然后记录下来,装载时由装载器进行重定位。但是如果这个东西发生在代码段,那说明代码段需要被更改,这就导致指令无法在进程间共享浪费内存。但是由于是直接寻址,速度会快很多。

    • 我之前想过为什么不像名字一样采用基址+偏移的方式,偏移固定,在装载时确定基址不就可以避免更改代码段?后来想通了,这种策略对于模块内部的数据访问是有效的,且在地址无关码中确实是这么做的。而模块外部的数据无法确定其属于哪个模块,对于多个模块内的不同数据,每个模块都需要保存一个基址否则只通过一个修改一个基址是无法索引所有外部变量的,对每个模块保存基址这很浪费空间,如果N个模块相互依赖,每个模块都要保存N个基址就成了平方级的空间复杂度。
  • 地址无关码:即改变寻址方式,对于数据访问和指令跳转按照模块内和模块间分为四种方式。对于外部符号采用GOT的辅助表,外部符号地址未知,但咱们知道GOT相对于模块加载位置的偏移,所以将未知信息

    • 模块内的数据访问采用以当前PC指针+偏移的方式进行寻址。
    • 模块外的数据访问采用讲地址保存在一个叫GOT(Global Offset Table)表的地方进行索引的形式。当需要访问外部数据时,在GOT表里找这个地址,而代码段的指令存的是访问的数据在GOT表的下标,这就要求在动态库加载阶段,GOT表被填好,这个工作由链接器完成。
    • 模块内指令跳转(函数调用,实际上被视为模块外指令跳转,因为存在全局符号介入这么个机制,即不同动态库定义相同的符号,在加载时会自动忽略后加载的符号)与模块见指令跳转寻址方式相同。
    • 模块外指令跳转,与模块外数据访问策略相同,都是采用GOT表的方式。不过由于每次加载动态库都填GOT表造成的开销过大,所以动态库在加载时,只会填GOT的数据部分,而GOT的指令地址部分只有在函数被第一次调用时才会被链接器填上。为了实现这个基址,数据的GOT表和代码的GOT表被分开。分别命名为.got和.got.plt。并且同时引入新的PLT表,命名为.plt。这种机制中,代码段的指令跳转存的都是指向plt表的元素项的信息,每个plt元素包含若干指令,其中第一条a直接跳转到.got.plt与调用函数相关的表项,接下来b几条跳转到动态链接器函数内实现.got.plt的数据填充。需要注意的时,刚开始.got.plt内的地址并不是指向真正的函数地址,而是跳转到上一句提到的b。也就是说,函数没被调用过时,对于plt的跳转是走的是绕.got.plt回来走的b分支。而在b分支中更改了.got.plt的信息,使得下次调用走的是a到实际函数调用的分支。一下为一个具体案例
#-----------------plt段,每3*4个字节为一项(32位操作系统),除了第0项-----------------------
PLT0: 
push *(GOT+4)
jump *(GOT+8)
...
PLTn: #假设为bar函数的PLT项,在代码内调用方式为jmp *(bar@plt),n为编译后的下标,这一行等同于bar@plt。
jmp *(bar@GOT)
push n
jump PLT0

#-----------.got.plt段,在plt段中被表示为GOT,每4字节为一项-------------------------
addr of .dynamic                  #(GOT+0)
Module ID                         #(GOT+4)
_dl_runtime_resolve();            #(GOT+8)
addr of funca                     #初始情况下为PLT0+1*3*4+4
addr of funcb                     #初始情况下为PLT0+2*3*4+4
...
addr of bar                       #初始情况下为PLT0+n*3*4+4,等同于bar@plt+4
#.got.plt内第三项往后,在调用后会被_dl_runtime_resolve修正

#-----------对于bar函数的调用代码,在.text段中
...
push xxxx  #压参数,压返回地址, 于栈
push xxxx
jmp *(bar@plt)

上面这些代码的逻辑是这样的,首先看最后一个.text段中的调用,a直接跳向了.plt表中的bar项,b然后跳转到了.got.plt中的bar项所指的地址,c实际上就是跳回来到了push n这条指令。d接着调到plt的第0项,e压入模块的id,f又跳到了.got.plt的第三项即_dl_runtime_resolve,g在这里_dl_runtime_resolve修改了.got.plt中的bar项,并且使bar函数被调用。而第二次调用bar时,在b项就直接调到bar函数里了。

由虚存加载地址引发的一系列问题------数据段

为了实现代码段的进程间共享以及任意映射,人们设计了很多机制。但对于数据段,操作方式是不同的,数据段是指可读写数据,(只读数据可采用与代码段相同的处理策略在进程间共享)。对于数据段中的实际数据比如某个地址存个数字1,某个地址存个数字2,这就直接存就行了。但是怕就怕在数据段中某个位置上存的是某个符号的指针地址,在这种情况下,由于动态库加载的位置不同,这些指针地址其实是不一定一样的,所以本节讨论的问题主要是对数据段中绝对地址的引用这种情况进行处理的策略。数据段是在各个进程中是私有的,所以可以采取基址重置策略(装载时重定位),即数据段中存的绝对地址引用被标记为需要重定位,在装载时链接器完成重定位。

动态链接的主要段

  • .dynamic类似于elf文件的头部,标记了各个段的位置,以及依赖的动态链接库的信息,使用readelf -d xxx.so进行查看。同时可以用ldd xxx.so来看动态库或者可执行程序依赖的动态库。
  • 动态库包含两个符号表.symtab符号表包含所有符号,.dynsym包含动态链接相关的符号(如引用到的外部函数,外部数据,模块定义的全局函数,全局变量)。
  • 动态链接重定位表,数据段采用装载时重定位策略,而其重定位信息保存在这里,当数据段需要重定位时,在这里保存一项由地址x到符号的映射,当符号的地址被确定时,将符号的地址根据重定位表项,填入到x那里。同时GOT也是需要重定位的。包含两个表,.rel.dyn修正.got段和数据段,.rel.plt修正.got.plt。

动态链接的过程

  • 动态链接器自举,链接器自身需要静态链接,自己完成本身所需要的全局和静态变量的重定位工作。由于调用函数需要访问got表在自举时还未重定位,所以链接器不能调用任何函数(包括模块内部)。
  • 装载共享对象:

    • 首先将动态链接器和可执行文件的所有符号合并到一个符号表,即全局符号表。
    • 寻找可执行文件的依赖的动态库,在可执行文件的.dynamic段中。而动态库又可能依赖别的动态库,所以这是个树遍历过程。
    • 当合并符号表时,如果有重复的符号,只保存首次见到的,忽略以后见到的。为避免因为动态链接而导致将模块内部的函数调用也通过got,可以将函数定义为static函数,加快调用速度。
  • 从定位和初始化,装载完成,开始遍历可执行文件和动态库的重定位表。将got/plt映射到的内存地址需要重定位的地方进行重定位。
    总的来说还是和静态库思路一样,先把符号加载进来,代码加载进来,内存开好,然后根据符号以及重定位表刷新数据段内存和got/plt的数据(动态库就不刷代码段了,代码段是地址无关码)。

标签: 编译链接, 程序员的自我修养

添加新评论