CMake构建实战:项目开发卷
上QQ阅读APP看书,第一时间看更新

1.4.2 Linux中动态链接的原理

Linux操作系统同样具有ASLR特性:通常情况下,每一个进程被创建时,都会将其可执行文件及其链接的动态库装载到不同的随机虚拟地址。这相比Windows操作系统更为激进,也提供了更好的安全性。

不过,如果每一个进程都对代码中访问绝对地址的部分进行重定位,由于其装载地址不同,这些绝对地址也就不同,重定位后的访存的代码就不可能一致,从而无法在物理内存中共享代码段。Linux中通常将动态库称为共享库,要是连共享都不支持,又怎么会这么称呼呢?显然,这是能做到的——不访问内存绝对地址不就可以了嘛!

地址无关代码(Position-Independent Code,PIC)就是指这种不访问内存绝对地址的代码。如果想让GCC编译器和Clang编译器生成地址无关代码,必须指定一个编译器参数-fPIC。

既然地址无关代码这么方便,编译器为什么不直接默认启用它呢?这是因为它往往是有额外代价的。当启用了地址无关代码之后,目标代码访问全局变量、调用全局函数时,都会使用全局偏移表(Global Offset Table,GOT)做一次中转。也就是说,目标代码中访问的内存地址实际上对应GOT的某个位置,这个位置记录了要访问的变量或调用的函数的实际内存地址。由于ASLR特性的存在,动态链接库会在运行时被装载到随机的内存地址中,则GOT各个表项的值只能在运行时被替换——这就是动态重定位。

可见,GOT是作为一个跳板存在的,启用地址无关代码会导致访存次数增多,指令数增多,也就在一定程度上影响性能;另外,由于多了这些记录内存地址的条目,目标代码的体积也不可避免地要大一些。

事实上,由于x64 CPU指令集支持相对当前指令地址寻址(Relative Instruction Pointer Addressing,RIP Addressing),在实现地址无关代码时,相比x86 CPU指令集可以减少很多指令。尽管如此,由于指令数和访存次数终究比直接重定位的程序要多,性能自然还是有所损失,只不过x86平台损失的会更多。因此,编译器并不会默认开启地址无关代码的编译选项。

那么,Linux操作系统为什么不直接像Windows操作系统一样直接对代码中的访存地址进行重定位,而是一定要加一个跳板呢?别忘了,Linux操作系统的ASLR特性提供了更好的安全性,每次启动进程时,动态库的装载地址都是随机的。如果直接对代码中的访存地址进行重定位,这段代码就不能被共享了。另外,Linux操作系统在进行动态重定位时,可以只修改数据段中的GOT,而且每一条目只修改对应的一处数据段的位置。这样,比起修改代码段每一处访存位置要轻松得多,同时也避免了修改代码段这种比较危险的行为。

实际上,Linux确实也支持类似Windows操作系统中通过静态重定位实现动态链接的方式,不过如果此时ASLR特性也是启用的,动态库就确实不能在物理内存中共享了。

使用GCC和make构建

同样为了验证原理,本节实例的源程序直接复用前面在Windows中编写的实例源程序。与MSVC相比,GCC构建动态库的方法可以说大同小异,最主要的区别就是刚刚在原理中提到的用于启用地址无关代码的-fPIC编译选项,以及用于表示生成动态库的-shared编译选项。Makefile如代码清单1.20所示。

代码清单1.20 ch001/动态库/Makefile0

main: main.o liba.so
    gcc main.o -o main -L. -la
 
main.o: main.c
    gcc -c main.c -o main.o
 
liba.so: a.o
    gcc -shared a.o -o liba.so
 
a.o: a.c
    gcc -fPIC -c .a.c -o a.o
 
clean:
    rm *.o *.so main || true

Makefile中也加入了一个clean目标,以便清理构建文件。使用make构建该实例并运行主程序:

$ cd CMake-Book/src/ch001/动态库
$ make -f Makefile0
$ ./main
./main: error while loading shared libraries: liba.so: cannot open shared object file: No such file or directory
$ ls *.so
liba.so

运行主程序会报错,提示找不到动态库liba.so,可它明明就在当前目录呀!

当运行主程序时,系统的动态链接器必须能够找到主程序所需的动态库,但它默认只会在系统配置的一些目录下搜索动态库,而不会考虑当前目录。包含搜索路径的配置文件位于/etc/ld.so.conf。当然,为了运行程序就去修改系统配置显然是不合理的。动态链接器还可以根据环境变量LD_LIBRARY_PATH的值来搜索动态库,因此可以通过设置环境变量来提示链接器:

$ LD_LIBRARY_PATH=. ./main
&x: 7fdce6ff1028
&a: 7fdce6df063a
&b: 7fdce740078a
&y: 7fdce7601010

主程序运行成功!不过,不管是修改配置文件还是修改环境变量,都需要用户来操作,这未免太不方便了。程序的作者是否有办法告诉链接器去哪里搜索动态库呢?

当然可以,程序既然有能力告诉动态链接器它需要链接哪些动态库,就也应该有本事提醒动态链接器去哪里搜索动态库。这些信息存储在程序的动态节(dynamic section)中,我们可以通过readelf 命令查看:

$ readelf -d ./main
 
Dynamic section at offset 0xda8 contains 28 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [liba.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 ...

其中,-d参数就是指查看动态节的内容。主程序的动态节前两项是NEEDED项,记录了它所依赖的动态库的名称。那么该如何把动态库的搜索路径也存进去呢?

Linux可执行文件的动态节中有两个与动态库搜索路径相关的条目,一个是RPATH,一个是 RUNPATH。二者的区别在于优先级,动态链接器会按照下面列举的顺序依次搜索:

1.动态节中的RPATH项指定的路径;

2.环境变量LD_LIBRARY_PATH指定的路径;

3.系统配置文件/etc/ld.so.conf指定的路径;

4.动态节中的RUNPATH项指定的路径。

如果程序中写死了RPATH,就相当于堵死了用户去覆盖搜索路径的可能。因此,RPATH已经被废弃,但由于它还有一定的实用性,实际上仍然很常用。例如,程序依赖某一特定版本的系统库,并将这一系统库与程序一同打包发布,希望程序使用打包提供的这一个版本的系统库,而不是去系统搜索路径中搜索系统自带的版本。此时,就可以通过设置RPATH来实现该需求。这样,就可以避免一些版本不一致造成的兼容性问题了。

当然,如果是类似现在所遇到的找不到库的情况,指定RUNPATH就是推荐的方法,因为这样可以把链接库存放位置的决定权留给用户。我们可以通过修改链接器参数向程序中写入 RUNPATH,如代码清单1.21所示。

代码清单1.21 ch001/动态库/Makefile(第1行、第2行)

main: main.o liba.so
    gcc main.o -o main -L. '-Wl,-R$$ORIGIN' -la

Makefile在构建主程序时为编译器加上了参数'-Wl,-R$$ORIGIN'。逗号前的部分-Wl类似MSVC中的编译器参数/link,用于在编译器的命令行中向链接器传递参数。不过MSVC中的/link是将所有跟随其后的参数作为链接器的参数,而GCC 编译器中的-Wl会将其逗号后的一个参数当作链接器参数进行传递。所以,这里实质上是为链接器传递了一个-R参数。

Makefile中的$一般用于引用变量,当确实需要$这个字符时,可以通过两个$符号来转义。因此,这里的$$ORIGIN实际上是字面量$ORIGIN。另外,整个链接器参数是夹在单引号间的,这样$ORIGIN就不会被当作对环境变量的引用,而是将其本身的字面量作为参数进行传递。总而言之,这就是向链接器传递了一个-R参数,其值为$ORIGIN。

链接器参数-R正是用于设置RUNPATH,$ORIGIN则是程序所在目录。之所以设置为程序所在目录$ORIGIN,而非当前工作目录“.”,是因为用户通常不会以动态库所在的目录作为当前工作目录来运行程序,但动态库通常会在可执行文件的同一目录下。当然,动态库也可以与可执行文件保持一个相对位置,这样RUNPATH也就应该设置为相对$ORIGIN的路径,如$ORIGIN/lib。

使用修改后的Makefile重新构建该实例:

$ make clean
rm *.o *.so main || true
$ make
...
$ ./main
&x: 7f5b97ff1028
&a: 7f5b97df063a
&b: 7f5b9840078a
&y: 7f5b98601010

终于可以直接运行主程序main,而不必设置任何环境变量了。除了替换RUNPATH外,我们也可以通过替换RPATH来解决问题,但不推荐采用这种方法。二者方法基本一致,只需将参数改为 '-Wl,-rpath=$$ORIGIN'。

现在不妨同时运行多个实例,回顾一下前面提到的原理。在终端中运行主程序main:

$ ./main
&x: 7fcf7bff1028
&a: 7fcf7bdf063a
&b: 7fcf7c40078a
&y: 7fcf7c601010

目前主程序停在getchar()函数中等待输入,先不要中断它。与此同时,再打开一个终端运行主程序:

$ ./main
&x: 7f2a883f1028
&a: 7f2a881f063a
&b: 7f2a8880078a
&y: 7f2a88a01010

啊哈,二者输出的地址都不一样!这确实可以反映Linux中较为激进的ASLR特性。下面再观察一下动态库是否真的在物理内存中共享。我们可以借助进程的内存使用记录表来证明这一点。再打开一个新的终端(不要关闭之前运行中的两个主程序):

$ ps aux | grep main
...      15521  ...   ./main
...      15571  ...   ./main
...
$ cat /proc/15521/smaps
...
7fcf7bdf0000-7fcf7bdf1000 r-xp 00000000 00:00 1057893    .../liba.so
Pss:                   1 kB
...
7fcf7bff1000-7fcf7bff2000 rw-p 00001000 00:00 1057893    .../liba.so
Pss:                   4 kB
...
 
$ cat /proc/15571/smaps
...
7f2a881f0000-7f2a881f1000 r-xp 00000000 00:00 1057893    .../liba.so
Pss:                   1 kB
...
7f2a883f1000-7f2a883f2000 rw-p 00001000 00:00 1057893    .../liba.so
Pss:                   4 kB
...

smaps中包含程序虚拟内存空间的使用情况,其中的Pss指分摊内存(Proportional Set Size,PSS),代表了这部分内存空间被共享进程平均分摊后的大小。或者说,用总占用内存空间除以共享这部分内存的进程的数量得出的结果。

观察程序输出的&x和&a,它们分别位于动态库的代码段和数据段中。例如,&x: 7fcf7bff1028对应的smaps表就位于最后一部分7fcf7bff1000-7fcf7bff2000中,可见这部分对应于动态库的数据段。同理,&a: 7fcf7bdf063a对应第一部分的7fcf7bdf0000-7fcf7bdf1000,属于代码段。动态库被多个进程共享的部分应是代码段,所以着重观察第一部分。

目前对于动态库的第一部分(代码段)的内存空间,在两个主程序进程中都占用了1 KB的空间。关闭一个终端中的程序,再次观察:

$ kill 15571
$ cat /proc/15521/smaps
...
7fcf7bdf0000-7fcf7bdf1000 r-xp 00000000 00:00 1057893    .../liba.so
Pss:                   2 kB
...
7fcf7bff1000-7fcf7bff2000 rw-p 00001000 00:00 1057893    .../liba.so
Pss:                   4 kB
...

果然,剩下的唯一主程序进程中,动态库所在内存空间的第一部分,也就是代码段的Pss上涨到了2 KB,而最后一部分对应的数据段的Pss则没有变化。也就是说,代码段确实在物理内存中共享。

读者如果怀疑这只是巧合,不妨亲自尝试一下启动更多进程时,分摊的内存空间是否刚好成比例变小。当然2 KB实在太小,这里只显示整数,分摊多了就会变成0。有兴趣的读者也可以向动态库的程序中多写入一些函数代码等,让代码段所需的内存空间增加一些,再来做这个实验。