对在64位Linux下编译动态链接库参数的探究

    有在Linux开发程序的经验的朋友都应该知道,在64位Linux下编译动态链接库时,一般在编译时要加上参数-fPIC或者-mcmodel=large,不然在链接时会报错。但是却很少有人真正理解这些参数对于动态链接库的意义,所以今天我们就来探究以下这两个参数的真正含义。

    首先要先说明以下动态库和共享库的区别,现在很多人,包括很多教材都把这两个词混用,虽然不能说错,但是在动态链接这个概念出现时,这两个词的含义还是有差别的。

    首先说动态库,动态库的思想是装载时重定位,即在编译出的二进制代码中不包含动态链接的代码,而仅仅包含链接必要的信息,这样就可以充分缩减程序的体积。但是这也是有缺点的,因为包含的信息不足,且二进制代码已经写死,所以在装载程序时,装载器会复制一份动态库的代码到应用程序中,即一个动态库的代码可能在内存中存在多个副本。造成这个结果的原因是因为上述的二进制码写死了,在.text节中对动态链接的符号写入了固定的位置,导致其他程序无法复用。接下来要讲的mcmodel=large就是动态库。

    接下来是共享库,这个是真正现代意义上的“动态库”,再将共享库中的内容复制到内存中之后,所有调用这个库的程序都会到该内存地址去调用库中的函数,实现了真正意义上的共享。-fPIC就是共享库的实现。

    接下来我们进入正题,首先来看两个文件,第一个是cal.c:

extern int a;

int add(int b)
{
	int c = a + b;
	return c;
}

    代码非常简单,我们将会用这段代码来实现动态库和共享库。接下来是main.c:

int add(int);
int a = 5;

int main()
{
	int i = add(2);
}

    我们将会用这段代码去调用库里的函数,同时给库提供变量a。

    接下来我们先试试不用上述两个参数来编译

    gcc -c cal.c && gcc -shared cal.o -o libcal.so

    发现报了如下错误:

    /usr/bin/ld: cal.o: relocation R_X86_64_PC32 against undefined 符号 `a' can not be used when making a shared object; recompile with -fPIC
    /usr/bin/ld: 最后的链结失败: 错误的值
    collect2: error: ld returned 1 exit status
    这是开发中最常见的错误之一,这样编译出来的目标文件只能作为静态链接的文件或库使用。为什么会这样,我们用两个命令来看一下,首先是objdump -d cal.c:

0000000000000000 :
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	89 7d ec             	mov    %edi,-0x14(%rbp)
   7:	8b 15 00 00 00 00    	mov    0x0(%rip),%edx        # d 
   d:	8b 45 ec             	mov    -0x14(%rbp),%eax
  10:	01 d0                	add    %edx,%eax
  12:	89 45 fc             	mov    %eax,-0x4(%rbp)
  15:	8b 45 fc             	mov    -0x4(%rbp),%eax
  18:	5d                   	pop    %rbp
  19:	c3                   	retq   

    这里我反汇编出了cal.c的代码,重点是标号7的那一行,可以看到,程序将对于rip(指令位置)偏移的某个位置的值加入了eax,这其实就是变量a,只是因为现在不确定其位置所以用四个字节的00来代替。但这就是问题的所在,我们知道在32位下,四个字节可以覆盖整个虚拟内存,但是在64位下则不然,而共享库中的符号位置在运行时是不确定的,无法预测是否在四字节可访问空间内,所以才会导致链接失败。多说一句,标号4的行实际上是传递参数,只是在x64架构下参数传递是通过寄存器而不是栈(寄存器装不下采用栈),使用的寄存器为rdi,rsi,rcx,rdx,r8,r9。

    下一条命令是readelf -r cal.o,输出信息是:

重定位节 '.rela.text' 位于偏移量 0x1c8 含有 1 个条目:
  偏移量          信息           类型           符号值        符号名称 + 加数
000000000009  000900000002 R_X86_64_PC32     0000000000000000 a - 4

    这个类型的符号因为预留的是32位,实际上是不符合64位系统的ABI(应用程序二进制接口)的,所以这就是链接失败的原因。

    下面我们来看一种可行的方案,就是加上参数mcmodel=large,这个参数的意思是指生成代码对于符号的偏移保留了64位的地址,理论上讲可以索引到全部虚拟地址。gcc默认的编译选项是mcmodel=small,这个选项只保留了32位,从上面的代码我们也能看出来。接下来我们用如下命令来编译程序:gcc -c cal.c -mcmodel=large,并用objdump来查看汇编代码:

0000000000000000 :
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	89 7d ec             	mov    %edi,-0x14(%rbp)
   7:	48 b8 00 00 00 00 00 	movabs $0x0,%rax
   e:	00 00 00 
  11:	8b 10                	mov    (%rax),%edx
  13:	8b 45 ec             	mov    -0x14(%rbp),%eax
  16:	01 d0                	add    %edx,%eax
  18:	89 45 fc             	mov    %eax,-0x4(%rbp)
  1b:	8b 45 fc             	mov    -0x4(%rbp),%eax
  1e:	5d                   	pop    %rbp
  1f:	c3                   	retq   

    这里看到,与上一次的代码的区别是没有使用rip作为基址来偏移,而是直接把地址放入rax,然后直接取该地址中的值。这里我们发现,编码留下的保留地址是八个字节,也就是64位,这就能所引导全部内存空间了。接下来我们用readelf -r cal.o来看看它的符号:

重定位节 '.rela.text' 位于偏移量 0x1d0 含有 1 个条目:
  偏移量          信息           类型           符号值        符号名称 + 加数
000000000009  000900000001 R_X86_64_64       0000000000000000 a + 0

    可以看到,这次的类型就是64位的符号,这样子就能直接索引到该值。这里我们在写一条命令nm cal.o,输出如下:

                                      U a
0000000000000000 T add
    这里先卖个关子,等看到共享库时就知道了。
    接着使用gcc -shared cal.o -o libcal.so就可以编译出动态库,接下来用gcc -o main main.c -lcal 就可以编译出可执行程序,这里面是没有包含add函数的,add函数将会在加载时被加载到.text节中,如果有多个程序链接到该库,那么add就会有多个副本,原因很简单,因为在main函数中对add函数的调用地址已经写死了,所以其他的程序无法定位到该地址。接下来我们用命令nm main看一看可执行文件:

0000000000601038 D a

    这里我只列出相关的条目,可以看到,a的地址已经分配完成,这样再add被加载之后会把这个地址填到函数代码中去,有兴趣的可以使用gdb来在运行时动态的查看add函数的汇编代码,这里限于篇幅我就不演示了,结果应该是上面那个地址。

    接下来我们来看-fPIC参数,首先使用命令gcc -fPIC -c cal.c 来编译出包含地址无关信息的目标文件,通过objdump -d cal.o来查看汇编代码:

0000000000000000 :
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	89 7d ec             	mov    %edi,-0x14(%rbp)
   7:	48 8b 05 00 00 00 00 	mov    0x0(%rip),%rax        # e 
   e:	8b 10                	mov    (%rax),%edx
  10:	8b 45 ec             	mov    -0x14(%rbp),%eax
  13:	01 d0                	add    %edx,%eax
  15:	89 45 fc             	mov    %eax,-0x4(%rbp)
  18:	8b 45 fc             	mov    -0x4(%rbp),%eax
  1b:	5d                   	pop    %rbp
  1c:	c3                   	retq   

    一眼看去和之前的差不多,但仔细看看比第一次什么参数都没加的多了一行,就是编号e的那一行,我们看到,上一行将某个偏移后的地址放入rax,这一行将rax的内容当成地址并解引用,其实这个解出来的引用就是全局变量a。看到这里很多人可能会有疑问,为什么一个全局变量要用这么麻烦访问,这个偏移后的地址又是哪里等,这里我们留着疑问,再用命令readelf -r cal.o来查看文件的符号信息:

重定位节 '.rela.text' 位于偏移量 0x200 含有 1 个条目:
  偏移量          信息           类型           符号值        符号名称 + 加数
00000000000a  000a0000002a R_X86_64_REX_GOTP 0000000000000000 a - 4

    这里我们看到,符号类型变成了一个新的值,我们继续用nm cal.o来查看:

                 U a
0000000000000000 T add
                 U _GLOBAL_OFFSET_TABLE_

    这就是fPIC和之前两种方式不同的地方,在最下面多了一个全局偏移表。现在就揭开这个谜底,其实,在计算机学科中,很多问题都能通过增加一个间接层来解决。上述的全局偏移表就是起到这个作用,通过这张表,对动态链接的符号进行间接的访问,而不需要在意它的具体位置,也不需要硬编码到代码中,只需保证got能够在偏移内被索引到就可以了。所以整个过程是程序先偏移到got表中的某一个表项,然后对这个表项解引用,得到真正的值。下面我们来把它编译成共享库,用命令gcc -shared cal.o -o libcal.so,接下来用命令objdump -d libcal.so 来查看相关代码:

0000000000000680 :
 680:	55                   	push   %rbp
 681:	48 89 e5             	mov    %rsp,%rbp
 684:	89 7d ec             	mov    %edi,-0x14(%rbp)
 687:	48 8b 05 52 09 20 00 	mov    0x200952(%rip),%rax        # 200fe0 <_DYNAMIC+0x1a0>
 68e:	8b 10                	mov    (%rax),%edx
 690:	8b 45 ec             	mov    -0x14(%rbp),%eax
 693:	01 d0                	add    %edx,%eax
 695:	89 45 fc             	mov    %eax,-0x4(%rbp)
 698:	8b 45 fc             	mov    -0x4(%rbp),%eax
 69b:	5d                   	pop    %rbp
 69c:	c3                   	retq   


    这里我们只关注add函数,可以看到,地址已经被填上了,接下来用readelf -r libcal.so 可以看到如下信息:

重定位节 '.rela.dyn' 位于偏移量 0x468 含有 9 个条目:
  偏移量          信息           类型           符号值        符号名称 + 加数
000000200e28  000000000008 R_X86_64_RELATIVE                    650
000000200e30  000000000008 R_X86_64_RELATIVE                    610
000000201018  000000000008 R_X86_64_RELATIVE                    201018
000000200fd0  000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000200fd8  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000200fe0  000400000006 R_X86_64_GLOB_DAT 0000000000000000 a + 0

    这里跟之前的目标文件有一个地方不一样,就是重定位节是rela.dyn,之前的是rela.text,该节是要通过表去更新地址并索引的,所以访问起来效率稍低,但是兼容性和可扩张性非常好。这里的表因为是调用该函数的程序的got表所以自然会映射到该程序的空间中,这样在共享库函数中得到的a值就能对应到调用者的变量了。

    讲到这里-fPIC的真面目我们就揭晓完了,对于PIC代码,额外采用了存储空间来作为全局符号的映射,通过这张程序特定的表来找到该程序对应的变量,从而实现共享代码。

    下面我们来总结一下,对于没有加-fPIC或者-mcmodel=large编译的目标文件,因为对于未知符号默认预留的是四个字节的空间,而且是直接索引,所以无法采用动态链接。而对于使用编译参数-mcmodel=large的目标文件,因为其预留了八个字节,所以可以通过编译,但是因为也是采用直接索引,所以无法与其他程序共享符号。对于-fPIC则是真正实现了动态和共享,采用间接索引的方式来避免预留字长和无法共享的问题,但是因为会增加内存访问次数,所以会牺牲效率。

    那么,对于64位下的Linux动态链接库参数的解析就到这里。

你可能感兴趣的:(Linux,linux,库)