Linux操作系统--动态链接库和代码段地址无关性(PIC)

注意:本文中的大部分是阅读 《程序员的自我修养》 作 者:俞甲子,石凡,潘爱民  的读书笔记。推荐大家看看这本书。

 

一,动态链接

   操作系统将把程序依赖的目标文件全部加载到内存,如果依赖关系满足,则系统开始进行链接。链接与静态链接相似,即进行符号解析、地址重定位。

   例如程序program1和program2都依赖于lib.o,而在运行program1的时候,lib.o已经被加载,那么在运行program2的时候,系统不需要加载lib.o,而只是将program2和lib.o进行链接。

   这样不仅仅节省内存,还减少了内存物理页面的换入换出,增加CPU缓存命中。

   动态链接的另外一个特点是程序运行时可以动态选择加载各种程序模块。这个优点即人们制作程序的插件(Plug-in)。

   例如一个公司制定的产品,并制定了接口。其他公司按照这些借口编写符合要求的动态链接库,程序可以动态载入这些开发的模块,程序运行时动态的链接,拓展程序的功能。

   动态链接也增加了程序兼容性,比如不同操作系统的库都提供了printf,在该库之上的代码,可以跨不同操作系统。

二,动态链接的实现

   动态链接使用的物件,理论上是可以是目标文件的,但是实际上动态连接库与目标文件稍有差别。

   动态链接涉及运行时链接,需要操作系统支持,一些存储管理,共享内存、进程线程机制,在动态链接下,也会与静态链接不同。Linux下,ELF动态链接文件称作DSO(动态共享对象),Windows下,一般为DLL。

   Linux下常用C语言运行库为glibc,其动态链接库形式版本在/lib目录下的libc.so。程序加载时,动态链接器将所有动态连接库装载到进程地址空间,将程序中未决议符号绑定到相应的动态链接库,进行重定位工作。

   由于每次加载需要动态的链接,所以性能有损失,采取延迟绑定(Lazy Binding)可以对其进行优化。

三,一个例子

Program1.c:

#include "Lib.h"

int main(){

   foobar(1);
   return 0;

}
Program2.c:
#include "Lib.h"

int main(){
        foobar(2);
        return 0;

}
Lib.h:

#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif

lib.c:

#include

void foobar(int i)

{
        printf("Printing from Lib.so %d/n",i);
        sleep(-1);
}

使用如下编译 gcc -fPIC -shared -o Lib.so Lib.c

编译链接program1和program2:

gcc -o Program1 Program1.c ./Lib.so

gcc -o Program2 Program2.c ./Lib.so

在静态链接器链接program1和program2的过程中,它必须知道foobar这个函数的性质。如果是静态目标模块中的函数,那么其必须进行地址重定位,如果是动态链接模块中的,则标记为动态链接符号,在装载时进行重定位。

那么静态链接器如何知道该符号是动态链接符号呢?传入的./Lib.so 文件中包含完整的符号信息,静态链接器以及装载时的动态链接器都是通过其中的符号信息获知这些信息的。这样静态链接器将foobar这个函数符号标识为动态链接符号。

开启Program1,使用cat /proc/进程ID/maps 查看其进程映射:

00400000-00401000 r-xp 00000000 ca:02 399066                             /root/mylinuxc/Program1
00600000-00601000 r--p 00000000 ca:02 399066                             /root/mylinuxc/Program1
00601000-00602000 rw-p 00001000 ca:02 399066                             /root/mylinuxc/Program1
7fa557326000-7fa55747a000 r-xp 00000000 ca:02 651529                     /lib64/libc-2.11.1.so
7fa55747a000-7fa55767a000 ---p 00154000 ca:02 651529                     /lib64/libc-2.11.1.so
7fa55767a000-7fa55767e000 r--p 00154000 ca:02 651529                     /lib64/libc-2.11.1.so
7fa55767e000-7fa55767f000 rw-p 00158000 ca:02 651529                     /lib64/libc-2.11.1.so
7fa55767f000-7fa557684000 rw-p 00000000 00:00 0
7fa557684000-7fa557685000 r-xp 00000000 ca:02 399064                     /root/mylinuxc/Lib.so
7fa557685000-7fa557884000 ---p 00001000 ca:02 399064                     /root/mylinuxc/Lib.so
7fa557884000-7fa557885000 r--p 00000000 ca:02 399064                     /root/mylinuxc/Lib.so
7fa557885000-7fa557886000 rw-p 00001000 ca:02 399064                     /root/mylinuxc/Lib.so
7fa557886000-7fa5578a5000 r-xp 00000000 ca:02 651522                     /lib64/ld-2.11.1.so
7fa557a70000-7fa557a73000 rw-p 00000000 00:00 0
7fa557aa2000-7fa557aa4000 rw-p 00000000 00:00 0
7fa557aa4000-7fa557aa5000 r--p 0001e000 ca:02 651522                     /lib64/ld-2.11.1.so
7fa557aa5000-7fa557aa6000 rw-p 0001f000 ca:02 651522                     /lib64/ld-2.11.1.so
7fa557aa6000-7fa557aa7000 rw-p 00000000 00:00 0
7fff5c82c000-7fff5c841000 rw-p 00000000 00:00 0                          [stack]
7fff5c9b2000-7fff5c9b3000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

libc-2.11.1.so是c语言运行库。

ld-2.11.1.so这个共享目标文件其实是Linux下的动态链接器,系统执行program1之前,会将控制权交给动态链接器,它将完成所有动态链接工作,然后把控制权交给program1。

使用readelf -l查看Lib.so:

Elf file type is DYN (Shared object file)
Entry point 0x570
There are 7 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000734 0x0000000000000734  R E    200000
  LOAD           0x0000000000000e18 0x0000000000200e18 0x0000000000200e18
                 0x0000000000000208 0x0000000000000218  RW     200000
  DYNAMIC        0x0000000000000e40 0x0000000000200e40 0x0000000000200e40
                 0x0000000000000190 0x0000000000000190  RW     8
  NOTE           0x00000000000001c8 0x00000000000001c8 0x00000000000001c8
                 0x0000000000000024 0x0000000000000024  R      4
  GNU_EH_FRAME   0x00000000000006e0 0x00000000000006e0 0x00000000000006e0
                 0x0000000000000014 0x0000000000000014  R      4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     8
  GNU_RELRO      0x0000000000000e18 0x0000000000200e18 0x0000000000200e18
                 0x00000000000001e8 0x00000000000001e8  R      1

Section to Segment mapping:
  Segment Sections...
   00     .note.gnu.build-id .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame
   01     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
   02     .dynamic
   03     .note.gnu.build-id
   04     .eh_frame_hdr
   05    
   06     .ctors .dtors .jcr .dynamic .got

发现其加载地址为0x0000000000000000,其实是7fa557684000,此时的值是因为共享库文件的装载地址在链接时不能确定,而是在动态链接器加载时才得以确定的。所以在动态连接库文件中是无效的0x0000000000000000。

早期的系统,采用的是对于某些共享库,指定其装载地址的方法。比如上边的Lib.so,可以制定其装载地址为7fa557684000。这样可能引发地址的冲突。比如另外的Lib2.so也是指定的7fa557684000。这种方式称为静态共享库(Static shared object)。静态共享库除了地址冲突,还会因为预先留有的空间的限制,导致新版本的库的大小必须有制约。

为了解决这个问题,必须保证共享库能够在任意装载时确定的地址装载。

解决这种问题,自然想到可以利用静态链接中的地址重定位方法。自然,这里应该是在装载而不是链接时进行重定位,即装载时重定位。

例如,foobar的地址相对给库文件起始的偏移为0x100,该库文件在装载时的初始地址确定为0x10000000,则foobar的地址装载后会是0x100000100,装载器遍历整个重定位表,对重定位表中记录的有对foobar地址引用的地方,全部重新改写为0x100000100。

  这个过程称为 装载时重定位,Windows下称为基址重置(Rebasing)。

但是为了解决这个问题我们想的这个类似静态链接的方法,对于要在进程间进行共享对象的动态库文件,却不适用。因为该方法,在重定位的时候,必须修改代码段中的对foobar的引用地址为新的值,而这个值是进程相关的,一旦一个进程修改为自己用的值后,其他进程就无法使用该共享对象了。

对于上述库文件中的可修改数据部分,因为每个进程都存在它的一个副本,所以可以采用上述办法。

Linux的GCC中的不使用-fPIC而仅仅使用-shared就是产生装载时重定位的代码。

其中的-fPIC的意义能解决装载时重定位将导致指令部分无法进程间共享的问题,其实现的基本思想是把指令中需要进行重定位修改的那部分分离出来,跟数据部分一起,使得每个进程都有一个副本。这种技术即地址无关代码技术。

四,地址无关代码

  产生地址无关代码其实对于现在机器,并不麻烦。首先我们看看哪些需要地址重定位,把可以不需要地址重定位的,就用不需要地址重定位的方法实现。那些必须要重定位的,就采用一种将其地址存储到一个数据结构中,而该数据结构放置到数据段,代码段通过该数据结构间接访问该地址的方法。由于数据段每个进程都有一份副本,所以该代码(动态链接库)是可以进程共享的。

模块中的地址引用方式,可以按照是否跨模块分成:模块内和模块外引用;按照不同引用方式可以划分为指令引用和数据访问。分类具体如下:

  1,模块内函数调用

  2,模块内数据访问

  3,模块外函数调用

  4,模块外数据访问

  例如代码:

     static int a;

     extern int b;

     extern void ext();

   void bar()

  {

      a=1;

      b=2;

  }

  void foo()

  {

        bar();

        ext();

  }

bar的反汇编代码:

 

编译器在编译上述pic.c代码时,并不能确定b、ext是模块内部还是模块外部的。因为extern意味着在别的目标文件,但是有可能别的目标文件和自身产生的目标文件是同一个共享库中的,所以是一个模块的。编译器将所有不确定的当作模块外部函数和变量处理。MSVC编译器提供了__declspec(dllimport)拓展,制定一个符号是模块内部还是模块外部的。

对于类型1,由于他们调用者和被调用者相对位置固定,采取相对地址调用即可。或者基于寄存器的相对调用。因此,对于这种指令,其实不需要重定位。这样产生的汇编代码,只要调用者和被调用者的相对地址不变,则总是有效的。

对于类型2,采用相对寻址也可解决。即对于数据的访问,采取相对访问这个数据的指令的地址,来寻址的方式,由于该数据和访问它的指令的相对地址不变,所以不需要重定位了。当然,目前一般都是相对下一条指令的地址来访问数据。那么如何获取下一条指令的地址呢?编译后的汇编码可以看到,其实程序会先调用__i686.get_pc_thunk.cx函数,该函数将返回地址的值放到ecx寄存器(本质上是通过eip寄存器的值,因为eip即下一条指令地址),然后通过ecx和预先指令中寻址带有的偏移量,即可获取当前数据存在哪了。(其实相对当前指令寻址也是同样道理,只是有点麻烦,相对当前指令需要将之前(也就是当前指令)的eip保存)。

          假设加载到0x10000000,那么,a的地址就是(0x10000000+0x454)+0x118c+0x28。(0x10000000+0x454)即下一条指令的地址。0x118c+0x28是a相对于该指令的偏移。

之所以 <__i686.get_pc_thunk.cx> 是 mov  (%esp)  %ecx是因为在调用之前,调用函数将把下一条指令地址压栈,所以%esp即调用函数的下一条地址。 [e01]

对于类型3,必然需要重定位。ELF的做法,是将其他模块的全局变量的地址存储到数据段里的全局偏移表(Global Offset Table,GOT)中。例如变量b,程序找到GOT,获知b的目标地址,然后再去访问。链接器在装载模块的时候,会将该GOT表进行正确的填充。GOT在数据段,保证了多个进程有自己的副本。GOT自己本身也要是地址无关的,不能因为加载地址不同,而需要对GOT的访问也进行重定位,那样就不能多进程共享了。

    GOT本身的地址无关是通过与模块内部数据访问类似的方法:编译的时候确定GOT与当前指令的偏移。那么在指定指令的时候,获取该指令的地址PC,再加上偏移量即可得到GOT的位置。然后再根据变量在GOT中的位置,获取变量的地址。

上述bar()访问b,假设加载到0x10000000,则b的地址在GOT的位置为(0x10000000+0x454+0x118c)+(-8)=0x100015d8(-8的补码是0xfffffff8)。(0x10000000+0x454+0x118c)是GOT表的地址,-8是b的地址在GOT表中的偏移量。

假设0x10000000是当前段加载的地址,而0x454则是call指令的下一条,也就是add指令的地址。此时的ecx即该值,之后ecx被加上0x118c,得到了GOT表的地址。此时的ecx即GOT表地址,+0xfffffff8的位置是存储的b的地址。

  使用objdump -h 查看GOT的位置,如果要查看动态加载库的定位项目

 

b的偏移是000015d8,这个值是相对于模块的,而不是GOT表,因此跟我们使用相对指令的偏移得出的结果地址0x100015d8,减去首址0x10000000得到的结果15d8一致。

    对于类型4,类似于类型3的处理方法,采用GOT表。例如调用ext()函数:

  call 494 <__i686.get_pc_thunk.cx>

add $0x118c,%ecx

mov 0xffffffffc(%ecx),%eax

call *(%eax)

也是得到PC,然后加上偏移得到GOT中的偏移,最后使用间接调用。

其实ELF采用了一种更为复杂和精巧的方法,因为上述这种方法简单,但是存在性能问题。

这样,对于四种类型,我们对应的采取方法,使得代码达到地址无关:

                    指令跳转                   数据访问

模块内部:       相对跳转和调用          相对地址访问

模块外部:       间接跳转和调用(GOT) 间接访问(GOT)

-fPIC和-fpic区别在于-fpic产生的代码小,执行速度快。但是-fpic在某些平台上会有限制,因为地址无关代码是硬件平台相关的。比如全局符号的数量、代码长度等。-fPIC则没有这种限制。

五,查看是否是PIC的

readelf -d foo.so|grep TEXTREL

如果上述命令有输出,则不是PIC的,否则就是。PIC的动态链接库不会含有任何代码段重定位表,TEXTREL即代码段重定位表。

六,PIE

   地址无关技术也可以用在可执行文件上,这种为Position-Independent Executable。使用参数为-fPIE和-fpie。

七,可执行文件中对外部数据的访问

   通过上边的描述,我们知道,对于动态链接库,它的符号,如果是对模块内部的数据访问和函数调用,则使用相对地址访问的方式,这样就不需要进行地址重定位了,因为代码中含有获取下一个指令地址的指令,而又有相对下一条指令地址的偏移量,通过指令地址和偏移量访问数据或者进行函数调用。由于不需要重定位,因此多个进程可以共享该动态链接库。对于模块外部的数据访问和函数调用,则采用了GOT表的方法,将需要访问的模块外部数据和函数,使用GOT表做记录,在进行动态加载的时候,改写GOT表中符号的对应地址。而对GOT表本身的访问则采用类似模块内部数据访问的方法,因为GOT表与加载地址的偏移(实现上是采用与指令的相对偏移)可以在编译的时候确定。使得对GOT表的访问具有代码无关性。这样由于进程都有自己的GOT的副本,使得多个进程可以在加载重定位的时候,修改自己的GOT表而不影响别的进程。

   对于可执行文件,以external声明的全局变量可能是来自本模块的其他目标文件或者其他模块。可执行文件中,对于模块内的符号引用和模块外的符号引用,由于无法编译时确定,都作为模块外符号处理。

   对于可执行文件中访问共享对象文件中的全局变量符号的问题,如果也采用上述的PIC机制,则会如下处理:在生成的代码中,采用相对于GOT表的地址偏移的寻址方式。则访问该全局变量的时候,需要首先获取PC的值,然后加上该偏移获取到GOT表的位置,再加上在GOT表的偏移获取该变量的地址在GOT表中存储的位置,然后获取到该变量的地址(改写GOT表中全局变量的地址是在动态链接库被运行时加载的时候填写的),之后进行访问。

   由于可执行文件编译产生的代码,不采用如同上述的PIC机制,即不采用相对下一条指令的地址的偏移来寻找GOT表,进而寻找数据地址的方式,而是依然采用与普通数据一样的方式,即绝对地址访问,因此,可执行文件中的全局变量符号的地址,必须在进行编译链接的时候可以决定出来。而实际上,由于定义在其他模块的全局变量的地址,如果其他模块采用的是动态链接的方式,那么这个地址必然是不能在编译链接的时候决议出来的,而是只有在加载时,获知了模块加载地址,才能通过变量与模块加载地址的偏移获知变量的地址,因此,可执行文件采用了如下机制,使得编译链接时,可以不知道变量的地址,也可以正常进行:在bss段中分配该变量,重定位表中的类型为COPY。

   例如:

   external int global;

   int foo()

{

   global=1;

}      

   int main(){

}

将上述编译链接成为可执行文件,使用objdump -R 查看重定位表,发现global类型为“COPY”,而不是像函数访问一样,是JUMP_SLOT等,而且,其是存放在bss段的,而不是在got表中。

  这样,如果加载模块后,必然在加载的模块中(数据段)也有该变量的副本,产生矛盾。实际上,ELF在编译共享库的时候,都将把全局变量当作模块外引用,使用GOT表访问,即使明确知道该变量是自己模块的(例如就在该目标文件中)。这样,如果运行时动态加载的时候,发现可执行文件中也有该变量,则会统一在GOT表中重定位填充为可执行文件bss段中该变量副本的地址。如果在共享库中对该变量进行了初始化,动态装载器还得负责将初始化的值拷贝到可执行文件bss中该变量的副本位置。如果可执行文件中没有该变量,则GOT表中重定位后,指向自己模块内的该变量。这样就意味着对模块内的变量访问,也采用了GOT表。也就是或,对于共享库中的全局对象,无论是否是内部的,还是无法决定是否是内部的,都得作为外部模块访问那样,使用GOT表进行访问。

问题:

  共享对象lib.so中的全局变量 G,进程A和B都使用了lib.so,那么A改变G的时候,是否影响进程B中的G?

回答:

  不会。因为G其实是存储在bss中的,bss类似数据段,每个进程都有自己的副本。这样看起来,共享库的全局变量与程序内部全局变量没有区别,因为都是数据段(或bss段),都会有自己的副本。如果想通过全局变量进行进程间通信,可以采用"共享数据段"技术,使得不同进程访问同一个全局变量。而对于一个进程,如果想让变量不被多个线程共享,即多个线程拥有自己数据段的副本,可以采用“线程局部存储”技术。

 

你可能感兴趣的:(linux,linux,数据结构,存储,编译器,printing,汇编)