有在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;
}
int add(int);
int a = 5;
int main()
{
int i = add(2);
}
接下来我们先试试不用上述两个参数来编译
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
下一条命令是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
重定位节 '.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
重定位节 '.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动态链接库参数的解析就到这里。