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

1.4.1 Windows中动态链接的原理

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

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

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

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


[7]事实上,当动态链接库不被所有进程使用后,它会被操作系统从内存中卸载;当它又被重新使用并装载时,其装载位置有可能发生变化,但操作系统并不保证这一点。所以,重启操作系统是唯一能够保证动态链接库装载地址发生随机改变的方法。

现在基本了解了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操作系统中,一个程序如果想链接一个动态库,就必须在编译时链接动态库对应的导入库[8]。我们可以简单地把“.lib”导入库看作一种接口定义,在链接时提供必要信息;而“.dll”动态库则包含运行时程序逻辑的目标代码。因此,编译链接时,只导入库提供的链接信息就够了;只有程序运行时,才需要动态库的存在。


[8]这里指在编译的链接阶段进行动态链接需要导入库。如果是运行时动态装载链接,则不需要。

该实例的完整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)这个概念,本书不做过多解释,读者只需将其类比为占用的内存[9]。 “专用”指只能被当前进程访问,“共享”则指能够被多个进程访问。由此可见,动态库liba.dll 被装载到虚拟内存中的大部分空间,都是在物理内存中共享的。


[9]这个类比并不准确,工作集实际上指进程的那些已被加载到物理内存中的虚拟内存页。

图1.3 VMMap内存分析工具