购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.4 构建动态库

既然静态库已经非常简单易用了,为什么还需要动态库呢?显然,静态库有它的缺点。

● 难以维护。如果想要修复静态库中的一个错误,我们必须重新编译(链接)所有使用该静态库的程序。

● 浪费空间。因为静态库的目标文件会在编译过程中被链接到最终的程序中,所以每一个链接了静态库的程序都相当于将静态库的目标代码复制了一份。如果某个静态库相当通用而被很多程序静态链接,将是对空间的巨大浪费。这里不仅是指编译后的程序文件的体积,更重要的是指程序运行时占用的内存空间。

动态库,就是为了解决静态库的维护问题和空间利用问题而产生的。动态库(dynamic library),也称为动态链接库(Dynamically-Linked Library,DLL)或共享库(shared library) 。与静态库不同,动态库的目标代码是在程序装载时或运行时被动态链接的,而非在编译过程中静态链接的。这样,动态库与使用动态库的程序就在编译期做到了解耦。如果想更新动态库,那么只需分发新版动态库,并让用户替换掉旧版动态库。程序运行时自然会链接新版的动态库。同时,多个程序也可以共享一个动态库,换句话说,任何程序都能够在运行时将同一个动态库的目标代码动态链接到自己的程序中执行,而且这份动态库的代码在内存中可以只装载一份。这样,空间利用效率就大大提高了。这也是动态库也称为共享库的原因。

Windows和Linux操作系统的动态链接机制有些差异,这也导致其构建过程会有一点不同。因此,在具体实践构建过程之前,一起先来探究一下不同环境中动态链接的原理吧!

1.4.1 Windows中动态链接的原理

当启动进程时,Windows操作系统会装载进程所需的动态链接库,并调用动态链接库的入口函数。由于64位Windows操作系统默认启用地址空间布局随机化(Address Space Layout Randomization,ASLR)特性,动态链接库被装载时,会根据特定规则随机选取一个虚拟内存地址进行装载。ASLR特性是一个计算机安全特性,主要用于防范内存被恶意破坏并利用。它的存在使得动态链接库装载的内存地址是不固定的,这就意味着其编译后的机器代码中,凡是访问内存某一绝对位置的代码,在装载时都需要被改写。这就是重定位(relocation)。

在32位Windows操作系统中,ASLR没有默认开启。此时,动态链接库将会被装载到偏好基地址(preferred base address)这里。偏好基地址是编译时指定的。不过在装载时,这个地址未必总是可用的:当多个动态链接库都设置了同一个偏好基地址(如均采用默认值),然后被同时装载到同一个进程时,就会出现冲突。这时,后装载的动态链接库就不得不改变装载的内存位置,也就同样需要重定位了。

回想之前提到动态链接库的一大优势,就是复用内存以节约空间。如果Windows操作系统对每个进程装载的动态链接库都重定位到了不同的内存地址,那么装载好的动态链接库该如何被复用呢?

事实上,Windows操作系统并没有总是对动态链接库进行重定位。一旦确定了某一动态链接库装载的虚拟内存地址,后面任何进程再用到同一个动态链接库时,都会将它装载到同一虚拟内存地址中。换句话说,Windows操作系统中的ASLR特性的“随机化”,对于动态链接库而言,只发生在计算机重启后

现在基本了解了Windows操作系统中动态链接的原理,那么我们就着手构建一个动态库吧!

使用MSVC和NMake构建

前面讲了这么多,现在如果只是演示一下构建过程就太无趣了!因此本例要构建的这个动态库不仅仅演示构建过程本身,还能够印证前面提到的部分原理。程序会输出一些变量和函数的内存地址,用于辅助验证。

首先,动态库的源程序a.c中有一个变量x,以及一个函数a,函数的功能是输出变量x的内存地址。其代码如代码清单1.14所示。

代码清单1.14 ch001/动态库/a.c
#include <stdio.h>
 
int x = 1;
 
void a() { printf("&x: %llx\n", (unsigned long long)&x); }

动态库的头文件liba.h只需声明函数a,如代码清单1.15所示。

代码清单1.15 ch001/动态库/liba.h
void a();

最后是主程序main.c,它会调用动态库中的函数a,同时输出函数a的内存地址。另外,主程序也有一个变量y和函数b,它们的内存地址也会被输出。因此,运行主程序后应该输出四个内存地址。主程序代码如代码清单1.16所示。

代码清单1.16 ch001/动态库/main.c
#include "liba.h"
#include <stdio.h>
 
void b() {}
int y = 3;
 
int main() {
    a();
    printf("&a: %llx\n", (unsigned long long)&a);
    printf("&b: %llx\n", (unsigned long long)&b);
    printf("&y: %llx\n", (unsigned long long)&y);
    getchar();
    return 0;
}

主程序最后还调用了getchar()函数,这是为了避免程序执行完后立刻退出,便于同时运行多个程序,以观察每一个程序输出的内存地址。当然,在运行之前需要先把动态库和主程序都构建出来。

MSVC构建动态库需要提供一个模块定义文件(扩展名为.def),用于指定导出的符号名称(函数或变量的名称)。开发者可以决定动态库暴露给用户使用的函数或变量有哪些,并隐藏其他符号,避免外部用户使用。这也是动态库的一个特点,相比静态库而言,动态库能够提供更好的封装性。

对于liba.dll动态库来说,只需导出函数a。其模块定义文件liba.def如代码清单1.17所示。

代码清单1.17 ch001/动态库/liba.def
EXPORTS 
    a

有了模块定义文件,就可以构建动态库了。构建命令与构建静态库非常类似:输入参数多了一个模块定义文件,输出参数要指定动态库的文件名,然后由参数指定构建目标的类型是动态库,另外还多了一个/link参数。Makefile如代码清单1.18所示。

代码清单1.18 ch001/动态库/NMakefile(第7行、第8行)
liba.lib liba.dll: a.obj liba.def
    cl a.obj /link /dll /out:liba.dll /def:liba.def

/link参数用于分隔编译器参数和链接器参数,即/link后面的参数都将传递给链接器。与可执行文件类似,动态库也是将编译好的目标文件链接后的产物,因此/dll、/out和/def这些参数实质上是传递给链接器的,它们分别用于设置构建类型为动态库、输出的动态库文件名及输入的模块定义文件名。

Makefile中构建动态库的这一行规则,构建目标不止一个:除了liba.dll外,还有一个liba.lib。这怎么会有一个静态库呢?

其实这并非一个静态库。“.lib”文件还可以是动态库的导入库文件,也就是这里的情况。在Windows操作系统中,一个程序如果想链接一个动态库,就必须在编译时链接动态库对应的导入库 。我们可以简单地把“.lib”导入库看作一种接口定义,在链接时提供必要信息;而“.dll”动态库则包含运行时程序逻辑的目标代码。因此,编译链接时,只导入库提供的链接信息就够了;只有程序运行时,才需要动态库的存在。

该实例的完整Makefile如代码清单1.19所示。

代码清单1.19 ch001/动态库/NMakefile
main.exe: main.obj liba.lib
    cl main.obj liba.lib /Fe"main.exe"
 
main.obj: main.c
    cl -c main.c /Fo"main.obj"
 
liba.lib liba.dll: a.obj liba.def
    cl a.obj /link /dll /out:liba.dll /def:liba.def
 
a.obj: a.c
    cl /c a.c /Fo"a.obj"
 
clean:
    del /q *.obj *.dll *.lib *.exp *.ilk *.pdb main.exe

由于导入库文件和静态库文件的扩展名都是“.lib”,第一条主程序链接动态库的构建规则看起来和链接静态库时的规则完全一致。

Makefile最后增加了一条清理构建文件的规则。执行make clean指令,就会删除工作目录中所有的目标文件、库文件和可执行文件等。

那么,现在开始构建吧:

> cd CMake-Book\src\ch001\动态库
> nmake /F NMakefile
> main.exe
&x: 7ff87abcb000
&a: 7ff678e51117
&b: 7ff678e51000
&y: 7ff678e6d000

为了验证前面提到的原理,不妨同时运行多个主程序实例,观察它们各自输出的内存地址:同时运行两个main.exe,它们输出的内存地址将是相同的;重启计算机后,再次运行 main.exe,它输出的内存地址就发生了变化,但此时再运行一个main.exe,它又会输出同样的内存地址。这个现象印证了Windows操作系统中动态库会被装载到同一虚拟内存地址的说法,而且重启计算机后装载地址会被重新随机计算。

当然,目前只能证明动态库被装载到了同一虚拟内存地址中。为了进一步证明它在物理内存中也是被共享的,可以借助VMMap工具查看主程序main.exe进程的虚拟内存,观察动态库liba.dll虚拟内存空间的使用情况。

如图1.3高亮选中的数据所示,liba.dll的专用工作集(private working set)只占用了的虚拟内存空间(12 KB),而共享工作集(shared working set)则占用了更多的虚拟内存空间(80 KB)。对于工作集(Working Set,WS)这个概念,本书不做过多解释,读者只需将其类比为占用的内存 。 “专用”指只能被当前进程访问,“共享”则指能够被多个进程访问。由此可见,动态库liba.dll 被装载到虚拟内存中的大部分空间,都是在物理内存中共享的。

图1.3 VMMap内存分析工具

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。有兴趣的读者也可以向动态库的程序中多写入一些函数代码等,让代码段所需的内存空间增加一些,再来做这个实验。 /Mz5eRUkuCTU7Ytgx4P2UOoSm/8s9D1+xtC+BwKw1nW68fZxuVpG0++ayv7+pyLn

点击中间区域
呼出菜单
上一章
目录
下一章
×