Windows静态库和动态库实践技巧&常见问题

前置知识

本文假定读者已经大概知道什么是静态库和动态库,并且有一定的使用经验;编写过简单的dll和lib模块,并用于开发可执行文件中。前置知识部分会介绍.lib和.dll文件的文件结构(即PE/COFF)文件结构,解释什么是符号,什么是导入表和导出表,链接的简要过程等,强烈建议不要跳过。

PE/COFF文件结构

.lib文件为COFF格式,示意图如下。

COFF目标文件格式

可以看到,COFF文件中包含映像头(Image Header),段表(Section Table),.text段,.data段,等部分组成。我知道你很急,但是你先别急,咱一个个来看。

映像头(Image Header)用于描述COFF文件总体属性,可以表示为一个“IMAGE_FILE_HEADER”的数据结构。它的数据结构定义可以从WinNT.h里找到。

typedef struct _IMAGE_FILE_HEADER {
    WORD Machine; //目标机器类型,包括86到MIPS R系列、ALPHA、ARM、PowerPC等
    WORD NumberOfSections; //PE/COFF所包含的“段”的数量
    DWORD TimeDateStamp; //PE/COFF文件的创建时间
    DWORD PointerToSymbolTable; //符号表在PE/COFF文件中的位置
    DWORD NumberofSymbols;
    WORD SizeOfOptionalHeader; //Optional Header的大小。COFF文件中始终等于0
    WORD Characteristics; //标志位
}IMAGE_FILE_HEADER,*PIMAGE_FILE_HEADER;

映像头后面紧跟着的就是COFF文件的段表(Section Table),它是一个类型为“IMAGE_SECTION_HEADER”结构的数组,数组里面每个元素代表一个段的描述信息。定义同样可以在WinNT.h中找到。

typedef struct _IMAGE_SECTION_HEADER{
    BYTE Name[8]; //段名
    union {
        DWORD PhysicalAddress;
        DWORD Virtualsize;
    } Misc;
    DWORD VirtualAddress;
    DWORD SizeOfRawData; // 该段在文件中的大小
    DWORD PointerToRawData; //段在文件中的位置
    DWORD PointerToRelocations; //该段的重定位表在文件中的位置
    DWORD PointerToLinenumbers; //段的行号表在文件中的位置
    WORD NumberOfRelocations;
    WORD NumberOfLinenumbers;
    DWORD Characteristics; //标志位
)IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER;

.textbss即BSS段(bss segment),通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配。(vc编译器会将BSS段合并到.data数据段中,加快映射过程)。
.text代码段(text segment)通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域属于只读。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
.data 数据段(data segment)通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
.rdata 只读数据段,包含导出表、导入表。导出表和导入表的概念,后文会讲到。
.idata 导入段。包含程序需要的所有DLL文件信息。
.edata 导出段。包含所有提供给其他程序使用的函数和数据。(一般链接器不生成这个段,而是合并到.rdata 只读数据段中)
.rsrc 资源数据段,程序用到什么资源数据都在这里。
.reloc 重定位段。用于在PE/COFF文件加载到内存中时,进行内存地址的修正。
.drectve 链接指示段。drectve实际上是“Directive”的缩写,它的内容是编译器传递给链接器的指令,即编译器希望告诉链接器应该怎样链接这个目标文件。以一个原始数据”/DEFAULTLIB:‘LIBCMT’”的.drectve段为例,表示编译器希望告诉链接器,该目标文件须要LIBCMT这个默认库。
所有以“.debug”开始的段都包含着调试信息。比如“.debug$S”表示包含的是符号(Symbol)相关的调试信息段;“.debug$P”表示包含预编译头文件(PrecompiledHeader Files)相关的调试信息段;“.debug$T”表示包含类型(Type)相关的调试信息段。
符号表(Symbol table),包含符号名、符号的类型、所在的模块。至于什么是符号,后文会讲到。

PE文件是基于COFF的扩展,它比COFF文件多了几个结构。最主要的变化有两个:第一个是文件最开始的部分不是COFF文件头,而是DOS MZ可执行文件格式的文件头和桩代码(DOS MZ File Header and Stub);第二个变化是原来的COFF文件头中的IMAGE_FILE_HEADER部分扩展成了PE文件文件头结构IMAGE_NT_HEADERS,这个结构包括了原来的“Image Header”及新增的PE扩展头部结构(PE Optional Header)。PE文件的结构如图所示。.dll和.exe文件的文件结构即为PE文件结构。

PE文件格式

Image DOS Header”和“DOS Stub”这两个结构就为了兼容DOS系统而设计的,可忽略。
IMAGE_NT_HEADERS”是PE真正的文件头,它包含了一个标记(Signature)和两个结构体。标记是一个常量,合法的PE文件的标记是ASCII编码的“PE/0/0”。文件头包含的两个结构,分别是映像头(Image Header),和COFF文件的映像头一致;另外一个是PE扩展头部结构(Image Optional Header)。

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature; //标记
    IMAGE_FILE_HEADER FileHeader; //映像头
    IMAGE_OPTIONAL_HEADER OptionalHeader; //PE扩展头部结构
}IMAGE_NT_HEADERS,*PIMAGE_NT_HEADERS;

实际上对于PE可执行文件(包括DLL)来说,PE扩展头部结构(Image Optional Header)是必需的。在Windows系统装载PE可执行文件时,往往须要很快地找到一些装载所须要的数据结构,比如导入表、导出表、资源、重定位表等。这些常用的数据的位置和长度都被保存在了一个叫数据目录(Data Directory)的结构里面,其实它就是前面IMAGE_OPTIONAL_HEADER结构里面DataDirectory成员。这个成员是一个IMAGE_DATA_DIRECTORY的结构数组。

//标识导入表、导出表、资源、重定位表的地址和大小的结构
typedef struct _IMAGE_DATA_DIRECTORY{ 
    DWORD VirtualAddress;
    DWORD Size;
    }IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY;
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16 //数组长度

.lib文件和.dll文件的主要区别在于?

其实一个静态库(.lib文件)可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。生成.lib静态库文件的时候,若发生了符号重定义,即工程里有多个cpp文件定义了相同的全局符号,是不会被警告的,原因是生成静态库的时候,根本不会进行链接。那么什么时候会发生警告呢?exe可执行文件或者dll动态库链接静态库时,链接器会发现发生了符号冲突,并告知链接失败。

而动态库(.dll文件)则不同,他是会在构建的时候发生链接过程,若发生了符号重定义,则根本不会生成动态库文件。本质上可执行文件和动态库基本是一回事,它们都由PE文件结构组成,只是其中一个能够双击运行,而另一个不行。

另外,动态库构建的时候,一般也会生成一个.lib文件。在动态库进行静态加载时,会使用这个.lib文件,这是怎么一回事呢?这样的文件并不是静态库,而是成为导入库(Import Library),它包含了一段桩代码,用于获取导入符号的地址;另外,导入库会描述dll中导出的符号,用于链接时的符号决议。所以,要搞清楚.lib文件不一定等于静态库。(微软也是搞事情,为什么要用相同的后缀?)

什么是符号?

链接失败时,比如VS编译失败并提示符号重定义,符号没找到等情况时,这里的符号 (Symbol)是指一个函数,或者变量的起始地址。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名,就是符号名(Symbol Name)。
每一个目标文件都会有一个相应的符号表 (Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一 个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。

一般意义上讲,我们关注的符号有两种,一种是定义在本目标文件的全局符号,可以被其他目标文件引用。而另一种是在本目标文件中引用的全局符号,却没有定义在本目标文件,称为外部符号。

什么是导出表和导入表?

当一个PE需要将一些函数或变量提供给其他PE文件使用时,我们把这种行为叫做符号导出(Symbol Exporting),最典型的情况就是一个DLL将符号导出给EXE文件使用。Windows系统中,所有导出的符号被集中存放在了被称作导出表(Export Table)的结构中。事实上导出表从最简单的结构上来看,它提供了一个符号名与符号地址的映射关系,即可以通过某个符号查找相应的地址。

PE文件头中有一个叫做DataDirectory的结构数组,这个数组共有 16个元素,每个元素中保存的是一个地址和一个长度。数据目录(Data Directory)有16个_IMAGE_DATA_DIRECTORY结构体元素,该结构体数组是可选PE头中最后一个成员。这十六个元素分别存储了不同信息,分别是:导入表、导出表、资源、异常信息、安全证书、重定位表、调试信息、版权所有、全局指针、TLS、加载配置、绑定导入、IAT、延迟导入、COM信息、最后一个保留未使用。在这里我们关心的是导出表,其中第一个元素就是导出表的结构的地址和长度。导出表的位置位于.rdata 只读数据段中。WinNT.h中同样能找到导出表的数据结构表示。

typedef struct _IMAGE_EXPORT_DIRECTORY{
    DWORD Characteristics;
    DWORD TimeDateStamp;
    WORD MajorVersion;
    WORD MinorVersion;
    DWORD Name;
    DWORD Base;
    DWORD NumberOfFunctions;
    DWORD NumberOfNames;
    DWORD AddressOfFunctions; 
    DWORD AddressOfNames; 
    DWORD AddressOfOrdinals;
}IMAGE_EXPORT_DIRECTORY

导出表结构中,最后的3个成员指向的是3个数组,这3个数组是导出表中最重要的结构,它们是导出地址表(EAT,ExportAddress Table)、符号名表(Name Table)名字序号对应表(Name-Ordinal Table)。早期Windows系统,DLL的函数导出的主要方式是序号(Ordinals),使用序号进行链接,能够节省内存空间,且链接时能够省去符号名查找过程。现在的符号的导出,为了保持兼容,仍使用序号进行导出,因此有了名字序号对应表。

如果我们在某个程序中使用到了来自DLL的函数或者变量,那么我们就把这种行为叫做符号导入(Symbol Importing)。当某个PE文件被加载时,Windows加载器的其中一个任务就是将所有需要导入的函数地址确定并且将导入表中的元素调整到正确的地址,以实现动态链接的过程。在PE文件中,导入表是一个IMAGE_IMPORT_DESCRIPTOR的结构体数组,每一个IMAGE_IMPORT_DESCRIPTOR结构对应一个被导入的DLL。

typedef struct {
    DWORD OriginalFirstThunk;
    DWORD TimeDateStamp;
    DWORD ForwarderChain;
    DWORD Nae;
    DWORD FirstThunk;
}IMAGE_IMPORT_DESCRIPTOR;

FirstThunk指向一个导入地址表(Import Address Table),IAT是导入表中最重要的结构,IAT中每个元素对应一个被导入的符号,元素的值在不同的情况下有不同的含义。在动态链接器刚完成映射还没有开始重定位和符号解析时,IAT中的元素值表示相对应的导入符号的序号或者是符号名;当Windows的动态链接器在完成该模块的链接时,元素值会被动态链接器改写成该符号的真正地址。

指针OriginalFirstThrunk指向一个数组叫做导入名称表(Import Name Table),简称INT。这个数组跟IAT一摸一样,里面的数值也一样,其作用是用于DLL绑定中(一种DLL的性能优化手段,后文会讲到)。

链接的主要过程?

链接过程主要包括了地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution)和重定位(Relocation)等这些步骤。地址空间分配是指在进程空间(内存)中找到一块合适的位置,放置模块程序(一般是dll);符号决议,即是确定主模块(一般是exe文件)引用的外部符号的在哪一个子模块;重定位是指修正导入段(PE文件结构中的一部分,稍后会提到)中的地址,即确定引用的外部符号的地址。

大部分链接器的链接方法叫两步链接法(Two-pass Linking):

  • 第一步——空间与地址分配:扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
  • 第二步——符号解析与重定位:使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程。

实践技巧

入口点函数

可以在DLL源代码中实现一个入口点函数,以获得一些事件发生时的通知。如果不需要被通知,也可不实现。

BOOL WINAPI Dl1Main(HINSTANCE hInstD11, DWORD fdwReason, PVOID fImpLoad) {
    switch (fdwReason) {
    case DLL_PROCESS_ATTACH:
        //当DLL文件被映射到进程空间,也就是DLL被加载时(不管是静态加载还是动态加载),
        //DllMain被调用且fdwReason为DLL_PROCESS_ATTACH。
        //注意,多次调用LoadLibrary(Ex)并不会让程序调用入口点函数。
        break;
    case DLL_PROCESS_DETACH:
        //DLL文件从进程空间中撤销映射时,DllMain被调用且fdwReason为DLL_PROCESS_DETACH。
        //注意若在处理DLL_PROCESS_ATTACH时返回的是FALSE,则撤销映射时不会调用DllMain。
        break;
    case DLL_THREAD_ATTACH:
        //当进程创建线程时,所有DLL的DllMain会被调用且fdwReason为DLL_THREAD_ATTACH。
        //新创建的线程负责调用所有DLL的DllMain函数,只有全部完成后才开始执行线程自身的程序。
        //注意在创建新线程的时候DLL已经被映射到进程的地址空间中,DllMain才会被调用。
        break;
    case DLL_THREAD_DETACH:
        //线程正常退出时,所有DLL的DllMain会被调用且fdwReason为DLL_THREAD_DETACH。
        //注意,存在场景fdwReason为DLL_THREAD_DETACH的DllMain被调用,但是
        //fdwReason为DLL_THREAD_ATTACH的DllMain在之前并没有被调用的场景。
        break;
    }
    //DLL初始化场景下(fdwReason==DLL_PROCESS_ATTACH),若返回FALSE,
    //则弹出消息框告知初始化失败,并终止进程。注意,其他场景下,返回值无意义。
    return TRUE;
}

注意,如果进程终止是因为系统中的某个线程调用了TerminateProcess,系统便不会用DLL_PROCESS_DETACH来调用DLL的DIIMain函数。这意味着在进程终止之前,已映射到进程的地址空间中的任何DLL将没有机会执行任何清理代码。这可能会导致数据丢失。因此,除非万不得已,我们应该避免使用TerminateProcess函数。

同样,如果线程终止是因为系统中的某个线程调用了TerminateThread,那么系统不会用DLL_THREAD_DETACH来调用所有DLL的DIMain函数。这意味着在线程终止之前,已映射到进程的地址空间中的任何DLL将没有机会执行任何清理代码。这可能会导致数据丢失。因此,与TerminateProcess一样,除非万不得已,我们应该避免使用TerminateThread函数。

另外,处理fdwReason为DLL_THREAD_ATTACH和DLL_THREAD_DETACH的场景,需要留意是否可能发生死锁问题。一般情况下,不建议在DLL的DIIMain函数中调用WaitForSingleObject。

函数转发器

函数转发器(function forwarder)是DLL输出段中的一个条目,用来将一个函数调用转发到另 一个DLL中的另一个函数。比如kernel32.dll中导出的CloseThreadpoollo,CloseThreadpoolTimer,CloseThreadpoolWait和CloseThreadpoolWork等函数,实质是调用的NTDLL.dll中的TpReleaseIoCompletion等函数。

使用函数转发器的一种方法是使用pragma指示符。这个pragma告诉链接器,正在编译的DLL应该输出一个名为SomeFunc的函数,但实际实现SomeFunc的是另一个名为SomeOtherFunc的函数,该函数被包含在另一个名为DIIWork.dIl的模块中。

// Function forwarders to functions in Dllwork
#pragma comment(linker,"/export:SomeFunc=Dl1Work.SomeotherFunc")

DLL延迟载入

一个延迟载入的DLL是隐式链接的,系统一开始不会将该DLL载入,只有当我们的代码试图去引用DLL中包含的一个符号时,系统才会实际载入该DLL。这可以大大加快进程的初始化时间,避免在应用程序启动时加载不必马上加载的DLL。使用延迟载入机制也有一些局限,包括:

  • 延迟载入机制的底层实现要经过LoadLibrary和GetProcAddress函数,所以Kernel32.dll无法被延迟载入。
  • 导出了全局变量的DLL无法被延迟载入。
  • DllMain入口点函数调用一个延迟载入的DLL导出的函数,可能会引发程序异常。

使用延迟载入机制的常见方法是在Visual Studio的工程设置中增加两个链接器开关,包括/Lib:xxx.lib和/DelayLoad:xxx.dIl,xxx代表需要延迟载入的DLL及其导入库的名字。链接时,链接器检测到这两个开关,会将可执行文件中导入段关于xxx.dll的信息去除,当进程初始化时,操作系统便不会加载该DLL到进程地址空间中。并且,链接器会往可执行模块中增加一个延迟载入段(.didata)来标识要从xxx.dll中导入那些函数。最后,当应用程序启动时,若对延迟载入的函数进行调用,会调用延迟载入段导向的_delayLoadHelper2函数,并调用LoadLibrary和GetProcAddress函数,实现延迟载入。

此外,还可以将延迟载入的DLL动态地卸载掉,需用增加新的链接器开关/Delay:unload,并调用_FUnloadDelayLoadedDLL2函数实现卸载。

重定基地址

将DLL加载到进程空间时,加载到哪个位置是一个问题。如果没有为DLL模块指定基地址,那么会先尝试往默认的首选基地址去加载该模块(一般是0x10000000),若发生地址位置冲突(当前位置已经有其他模块),则需要对DLL加载进行运行时的重定基地址,这会导致额外的性能开销。可以在VS中的链接器设置中指定DLL模块的首选基地址,但这么做灵活性比较低。使用Rebase工具,可以处理大量不同的DLL模块载入同一个可执行文件的场景(避免重定基地址之后依然有冲突)。通过调用ReBaseImage函数,我们也可以实现自己的重定如果在执行Rebase工具的时候传给它一组映像文件名,那么它会执行下列操作:

  1. 它会模拟创建一个进程地址空间。
  2. 它会打开应该被载入到这个地址空间中的所有模块,并得到每个模块的大小以及它们的首选基地址。
  3. 它会在模拟的地址空间中对模块重定位的过程进行模拟,使各模块之间没有交叠。
  4. 对每个重定位过的模块,它会解析该模块的重定位段,并修改模块在磁盘文件中的代码。
  5. 为了反映新的首选基地址,它会更新每个重定位过的模块的文件头。

需要注意的是,Windows操作系统提供的DLL模块一般不需要重定基地址,它们已经经过特殊处理。

DLL绑定

对一个模块进行绑定,是用该模块导入的所有符号的虚拟地址,来对该模块的导入段进行预处理。这样处理过的DLL模块,不需要花太长的时间读取导入段,去解析导入符号的地址,进一步减少了启动应用程序的开销。Visual Studio提供了另一个名为Bind.exe的工具,帮助我们完成了DLL模块绑定的工作。同样,也可以使用BindImageEx函数来实现相同的特性。在实施DLL模块绑定之前,需要先进行首选基地址的重定,确保DLL载入过程冲不会发生地址冲突。

如果在执行Bind工具的时候传给它一个映像文件名,它会执行下列操作:

  1. 它会打开指定的映像文件的导入段。
  2. 对导入段中列出的每个DLL,它会查看该DLL文件的文件头,来确定该DLL的首选基地址。
  3. 它会在DLL的导出段中查看每个符号。
  4. 它会取得符号的RVA,并将它与模块的首选基地址相加。它会将计算得到的地址,也就是导入符号预期的虚拟地址,写入到映像文件的导入段中。
  5. 它会在映像文件的导入段中添加一些额外的信息。这些信息包括映像文件被绑定到的各DLL模块的名称,以及各模块的时间戳。

如果Windwos系统加载程序发现模块已经绑定过了,所需的DLL也确实被载入到了它们的首选基地址,而且时间戳也吻合,那么它实际上就不需要再做任何事情了。它不必再对任何模块进行重新定位,它也不必再查看任何导入函数的虚拟地址。应用程序的启动性能会有比较明显的改善。另外,如果我们在公司内部对模块进行绑定,那么会将它们绑定到我们安装的系统DLL,而这些DLL很可能与用户安装的系统DLL不同(Windows不同版本的系统DLL有所差异)。因此我们应该在应用程序的安装过程中来进行绑定。

DLL注入

DLL注入是一种使应用程序跨越进程边界来访问另一个进程的地址空间的机制。目标应用程序的源代码可能无法修改,或者不太好直接修改,那么使用DLL注入的方式可以执行自己编写的代码,实现一些特殊的功能,比如嵌入自己的子类窗口,进行辅助调试,以及其他的业务逻辑和功能等。由于这里说到的“自己编写的代码”是以DLL作为载体注入到其他应用程序中,因此成为DLL注入(DLL Injection)。DLL注入的实现方式包括:

  1. 使用注册表来注入DLL。HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows\注册表路径下AppInit_Dlls键的值写入想要注入的DLL的文件名,LoadAppInit_DIls键的值设为1,即可在所有使用了User32.dIl的进程启动时,都加载上想要注入的DLL,执行自己的代码。此外,设置动态的上下文菜单(即Windows桌面和应用程序图标上鼠标右键点击时弹出的菜单,实现方式参考:https://learn.microsoft.com/zh-cn/windows/win32/shell/shortcut-menu-using-dynamic-verbs),一些应用支持插件形式让三方开发者编写自己的业务界面和逻辑(比如开发office插件,实现方式参考https://learn.microsoft.com/zh-cn/office/dev/add-ins/develop/develop-overview),都需要在注册表特定位置写入需要注入的DLL路径和名称,也属于使用注册表进行DLL注入的范畴。
  2. 使用Windows挂钩(Windows Hook)来注入DLL。可以使用 SetWindowsHookEx向指定的应用程序注入DLL。
  3. 注入DLL的第三种方法是使用远程线程(remote thread),它提供了最高的灵活性。Windows提供了CreateRemoteThread函数,令目标进程创建线程,并在此线程中动态加载自己的DLL,即可实现此种方式的DLL注入。
  4. 直接替换目标进程会使用的DLL。使用函数转发器机制,能够保持被替换DLL导出的所有符号,并增加自己的程序逻辑。由于不能适应DLL版本变化,不建议使用。这种DLL也叫木马DLL。
  5. 其他的方式包括把DLL作为调试器来注入,用CreateProcess来注入代码等,需要针对具体CPU编写代码,比较复杂,不常用。

常见问题

链接过程中发现符号重定义问题,或者引用的符号不符合预期的原因。

对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)。GCC编译器可以使用_attribute_((weak))前缀来显式地指定一个强符号为弱符号,但Windows平台下一般使用MSVC编译器和工具链,不支持类似特性。
针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号:

  • 规则1:不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误。
  • 规则2:如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
  • 规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个(坑爹吧)。比如目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节,那么目标文件A和B链接后,符号global占8个字节。

尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误。

更新DLL时,发生难以理解和调试的崩溃的可能原因。

最可能的原因是二进制兼容问题。二进制兼容这个概念的含义比较广,限定在Windows动态库开发的框下,其中一个场景是:dll文件的符号布局发生变化时,直接替换dll文件,但没有使用最新的导入库(.lib)对exe可执行文件进行重新编译和链接,从而导致exe文件获取dll文件中的符号时发生地址访问异常,引发堆栈或栈顶指针被破坏,内存访问违例异常的情况。

从另一个角度来看,这种场景的dll更新方式(exe使用dll静态加载,但不重新使用最新的导入库编译和链接exe)的本质,是程序员将dll提供的接口视为ABI(Application Binary Interface),但dll提供的只是API(Application Programming Interface)。它们是内涵上相当不同的概念。API往往是指源代码级别的接口,比如我们可以说POSIX是一个API标准、Windows所规定的应用程序接口(Win32 API)是一个API;而ABI是指二进制层面的接口,ABI的兼容程度比API要更为严格,比如我们可以说C++的对象内存分布(Object Memory Layout)是C++ABI的一部分。ABI的概念其实从开始至今一直存在,因为人们总是希望程序能够在不经任何修改的情况下得到重用(在上文提到的场景下,即只替换dll文件而不重新编译链接exe)。人们始终在朝这个方向努力,但是由于现实的因素,二进制级别的重用还是很难实现。最大的问题之一就是各种硬件平台、编程语言、编译器、链接器和操作系统之间的ABI相互不兼容,由于ABI的不兼容,各个目标文件之间无法相互链接,二进制兼容性更加无从谈起。相对于Linux而言,Windows的动态库使用得更加频繁,但没有一种非常好的机制去保证其二进制兼容问题,极容易发生问题,这个现象被人戏称为DLL噩梦(DLL hell)

解决DLL hell的有效方法,包括使用.NET框架提供的清单文件机制,COM接口机制等。但这些机制的引入会增加开发的复杂度,需要程序员掌握更多的繁杂的知识,因此大部分软件公司不会采取这些技术去解决DLL hell问题(以本人呆过的公司来说)。所以解决DLL hell问题,更多的是可以模仿COM组件的思想,去规范编写动态库程序,进而减少二进制兼容问题的发生。

  • 接口类的函数都应使用纯虚函数,向外暴露的是接口,dll内部继承这个接口类去实现方法逻辑,对外提供一个类似CreateInstance接口,通过操作接口类的指针,去使用dll提供的能力(啰里八嗦的,其实从设计模式的角度讲,就是依赖倒转原则+工厂方法)。或者将类的方法声明为inline。
  • 所有的全局函数都应该使用extern“C”来防止名字修饰的不兼容。并且导出函数的都应该是_stdcall调用规范的(COM的DLL都使用这样的规范)。这样即使用户本身的程序是默认以_cdecl方式编译的,对于DLL的调用也能够正确。
  • 函数接口的参数和返回值,不要使用STL,避免STL和编译器版本不同导致的兼容问题。
  • 不同编译器的异常处理机制可能不同,因此不要使用异常(主要是抛异常,必要时还是需要catch一些三方库抛出的异常)。
  • 不同编译器对虚函数和虚函数指针机制实现有差异,因此不要使用虚析构函数。可以创建一个destroy()方法,并且重载delete操作符在dll内部调用destroy()。
  • 不要在DLL里面中请内存,而且在DLL外释放(或者相反)。不同的DLL和可执行文件可能使用不同的CRT堆(C运行时库维护的一块内存区域),在一个堆里面申请内存而在另外一个堆里面释放会导致错误。
  • 对于内存分配相关的函数不应该是inline的,以防止它在编译时被展开到不同的DLL和可执行文件。
  • 不要在使用重载的接口函数,否则在dll动态加载的场景下,GetProcAddress获取的函数位置,可能不符合预期。
  • 如无必要,尽量不要导出类,而是导出普通函数。另外,增加接口时应在末尾增加,避免影响原有函数接口的二进制地址位置。

代码层面的实践参考可以阅读这篇文章Dll导出C++类的3种方式(多干货)。总之,实现二进制兼容性良好的动态库,需要有一套严格的编码规范。

参考&推荐阅读:
Peering Inside the PE: A Tour of the Win32 Portable Executable File Format
《程序员的自我修养 :链接、装载与库》 ——俞甲子 石凡 潘爱民
《Windows核心编程》——Jeffrey Richter
Dll导出C++类的3种方式(多干货)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容