首页 > 代码库 > DLL

DLL

1. DLL 和 进程的地址空间:
在 可执行模块 能够调用一个 dll 中的函数之前,必须将该 dll 的文件映像映射到进程的 地址空间中。
注意:
在 dll 中预定地址空间或者分配内存,这段内存是从进程地址空间中分配的,因此当 dll 被卸载时,之前由 dll 分配的内存并不会被清理掉。比如在 dll 中的一个函数 new 了一块内存,如果稍后将这个 dll 卸载,这块内存并不会被清理。
当 dll 被卸载时,dll 中的“全局变量”也将被卸载。
多个可执行文件共享一个 dll 时,dll 中的全局变量和静态变量并不会共享,当一个进程将一个 dll 映像文件映射到自己的地址空间时,系统会为全局变量和静态变量创建新的实例。
可执行文件和 dll 有可能使用不同的 C运行时库,比如在 dll 中使用 malloc 分配一块内存,在可执行文件中使用 free 去释放内存,可能因为两者使用了不同的 C运行时库,free 不能正确释放 malloc 分配的内存。针对这种问题,当一个模块提供一个内存分配函数时,必须同时提供另一个用来释放内存的函数。
void* DllAllocMem()
{
    return (malloc(100));
}
void DllFreeMem(void* pv)
{
    free(pv);
}

 

 

2. 隐式载入时链接 和 显式运行时链接
将 dll 文件载入到 进程地址空间中,有两种方法, 隐式载入时链接 和 显式运行时链接。
隐式载入时链接,是指在可执行模块载入的时候,把这个可执行模块需要用到的 dll 载入到进程地址空间中。
显式运行时链接,是指在可执行模块运行的时候,动态载入指定的 dll,然后设法获取导出内容的地址,进行调用。
使用 隐式载入时链接,需要在可执行模块编译的时候,传入一个 .lib 文件,这个 .lib 文件称为导入库文件。
使用 显式运行时链接,不需要 .lib 文件,仅从 .dll 文件中就可以解析出导出的内容。
隐式链接 dll,需要在工程的设置面板中设置 附加库文件,或者使用 #pragma comment(lib,"xxx.lib") 命令,告知链接器去链接指定的 .lib 文件。
显式链接 dll 需要用到的函数:
LoadLibrary(pszDllPathName);        // 载入 dll 到进程地址空间中
FreeLibrary(hInstDll);              // 从进程地址空间中卸载 dll
GetModuleHandle(pszModuleName);     // 可以用来检查一个 模块 是否被载入
FARPROC GetProcAddress(hInstDll, pszSymbolName);    // 得到 dll 中的指定导出函数。

 

 

3. 构建 dll
dll 模块的 构建过程:
       testdll.h
a.cpp    b.cpp    c.cpp
  |        |        |
  V        V        V
编译器   编译器   编译器
  |        |        |
  V        V        V
a.obj    b.obj    c.obj
     \     |     /
      V    V    V
         链接器 <-- (.def)
           |
           V
       .dll, .lib
编译期:
在编译器编译各个 .cpp 文件的时候,如果发现 __declspec(dllexport) 修饰符修饰的 变量、函数、或C++类 的话,就会在生成的 .obj 文件里嵌入这些要导出的内容的信息
链接期:
链接器会检测 .obj 中嵌入的导出信息,并利用这些信息生成一个 导出段(export section),这个导出段中列出了导出的变量、函数和类的符号名,链接器还会保存相对虚拟地址RVA(relative virtual address),表示每个符号可以在 dll 中的何处找到。
此外,链接器还会用导出信息 生成一个 .lib 文件,这个 .lib 文件列出了这个 dll 导出的符号。
我们可以使用 dumpbin.exe (加上-exports 选项)来查看一个 .dll 文件的导出段:
...
ordinal hint  RVA      name
   1    0     00001010 ReadBinaryFileToBuffer = ReadBinaryFileToBuffer
   2    1     00001090 WriteBinaryFileWithBuffer = WriteBinaryFileWithBuffer
...
如上,hint表示序号,也可以使用这个序号来访问 dll 中导出的内容,name 表示导出符号的名字,而 RVA 表示一个偏移量,导出的符号位于 dll 映像文件的这个位置。
注意:
    1. 为什么要有 __declspec(dllexport):
        我们必须告诉编译器和链接器,哪些函数、变量、C++类是需要导出的。因此需要 __declspec(dllexport) 这个修饰符来修饰那些需要导出的内容。
    2. 生成的 .dll 里包含了哪些信息?
        一个导出段,标识了这个 dll 里有哪些导出符号,如何寻找这些符号。
        这个导出段记录了访问导出内容所需要的全部信息。借助于导出段,只需要一个 dll 文件就足以访问 dll 中导出的所有内容。
    3. 生成的 .lib 里包含了哪些信息?
        既然只要有 .dll 就可以访问到 dll 中导出的内容,为什么还需要一个 .lib 呢?
        .lib 文件是专门为了隐式链接 dll 而创建的,其中仅包含了 dll 导出的符号。使用 .def 文件也可以产生出 .lib 文件,可见 .lib 中的信息有多简单。
    4. C++代码的名称粉碎问题:
        C++ 编译器在编译 C++ 代码的时候,会对 C++ 代码进行名称粉碎,比如 ReadBinaryFileToBuffer 被重命名为 ?ReadBinaryFileToBuffer@@YGKPB_WPAEI@Z, 这是为了实现函数重载,不同的参数调用不同的函数。
        因为 C++ 会有名称粉碎,而 C 没有,所以使用 C++ 编写的 dll 被 C模块 调用的时候,C 模块无法使用 ReadBinaryFileToBuffer 找到正确的函数。
        C++ 为了解决这个问题,引入了一个修饰符: extern "C" ,这要求编译器不要对指定的符号进行 名称粉碎。注意: extern "C" 是 C++ 的特性,C 语言中是没有这个修饰符的。
        如果使用 C++ 编写 dll,那么要在声明导出函数的时候加上 extern "C"
    5. 导出 C++类 的名称粉碎问题:
        针对导出 C++类,对于类来说,名称粉碎是必须的,不能通过 extern "C" 来消除,这就要求只有当导出 C++ 类的模块使用的编译器与导入 C++ 类的模块使用的编译器由同一厂商提供时,我们才可以导出 C++ 类,这样才可以保证C++类名称粉碎之后的结果是一致的。因此,除非知道可执行模块的开发人员与 dll 模块的开发人员使用的是相同的工具包,否则我们应该避免从 dll 中导出类。
    6. C 代码的名称粉碎问题:
        之前说过 C 编译器不会进行名称粉碎,但是不知道为啥,即使根本没有用到 C++,Microsoft 的 C 编译器也会对 C 函数进行名称粉碎,和 C++编译器粉碎的结果不大一样, ReadBinaryFileToBuffer 被粉碎为 _ReadBinaryFileToBuffer@12
        因此如果我们在VC上使用 C 语言编写 dll 模块,然后这个 dll 模块要给别的厂商使用的话,名称粉碎问题仍然会可执行模块不能正确找到 ReadBinaryFileToBuffer
        解决的办法是使用 模块定义文件 .def 。
        当链接器链接各个 .obj 文件的时候,会从 .obj 里找到对应的导出信息,比如 _ReadBinaryFileToBuffer@12,如果有 .def 文件,又从 .def 里找到了 ReadBinaryFileToBuffer,这两个函数是匹配的,链接器就会使用 .def 里定义的名字来作为导出的函数名。
        注意:即使在 .cpp 文件里没有使用 __declspec(dllexport) 修饰导出函数,在 .def 里声明了导出函数的话,也一样是可以的。

 

 

4. 构建可执行模块
可执行文件的构建过程:
        testexe.h
a.cpp    b.cpp    c.cpp
  |        |        |
  V        V        V
编译器   编译器   编译器
  |        |        |
a.obj    b.obj    c.obj
     \     |     /
      V    V    V
         链接器
           |
           V
       testexe.exe
编译期:
编译各个 .cpp 文件产生出 .obj文件,如果使用到了 dll 中的符号,把它当作外部符号暂不处理。
链接期:
将各个 .obj 文件合并,并使用 .lib 来解析对导入的函数、变量的引用,.lib 中只是包含了 dll 中导出的符号,链接器只是想知道被引用的符号确实存在,以及符号来自于哪个 dll。
如果链接器能够解决对所有外部符号的引用,就能链接成功生成可执行模块。如果没有包含 .lib 但是引用了 dll 中的符号的话,将会出现 error Link2091: 无法解析的外部符号 xxxFunc,因为链接器无法知道这个外部符号是否存在。
对于引用了外部符号的代码,链接器将这段代码编译为跳转到一个地址表中,在链接期不知道导入函数的地址所以这个地址表是空的,当可执行模块载入的时候,这个地址表将被导入函数的地址填充起来。
链接器解决导入符号的时候,会在生成的可执行模块中嵌入一个特殊的段,称为 导入段(import section)。导入段中列出了需要使用的 dll 模块,以及从每个 dll 模块中引用的符号。
我们可以使用 dumpbin.exe (加上 -imports 选项)来查看一个模块的导入段:
...
TestDll.dll
    4020B4 Import Address Table
    40242C Import Name Table
        0 time date stamp
        0 Index of first forwarder reference
        1 _WriteBinaryFileWithBuffer@12
        0 _ReadBinaryFileToBuffer@12
...
如上,TestDll.dll 是该可执行模块所依赖的 dll 的名称,Import Address Table 是 导入内容地址表,在 TestDll.dll 被加载之后,dll 中导出函数的地址将被填充到这个表里,此时为空。Import Name Table 是导入内容名称表,其中记录了从 TestDll.dll 导入的函数名称。
注意:
    1. 可执行文件构建完成后,只是知道依赖于哪些 dll,哪些dll中存在着外部符号。它的 Import Address Table 是空的,可执行模块和 dll 被加载的时候, Import Address Table 将被填充起来。
    2. .lib 文件并没有什么神奇的,它只是包含了 dll 导出函数的名称,链接器使用它只是为了确认被引用的外部符号存在与哪个dll中,根据这一条信息,链接器就可以产生针对某个 dll 的导入段。
    3. 我们可以使用 pexports.exe 工具由 .dll 产生出 .def, 然后使用 VC 的 lib.exe 工具由 .def 产生出 .lib 文件。

 

 

5. 运行可执行模块
运行过程:
    1. 为进程创建虚拟地址空间;
    2. 把可执行模块映射到进程地址空间中;
    3. 检查可执行模块的导入段,根据规则搜索程序路径和系统路径,找到所需的 dll 并加载;
    4. 检查 dll 的导入段,如果这个 dll 还依赖别的 dll,那么继续去定位所需的 dll 并加载;
    5. 开始修复所有对导入符号的引用,此时会再次查看所有模块的导入段。对导入段中列出的每个符号,加载程序会检查对应 dll 的导出段,看符号是否存在,如果符号存在,就从 dll 的导出段中取出 RVA 并加上模块的虚拟地址,这样就得到了这个符号在进程地址空间中的地址。
    6. 得到符号的地址后,加载程序会把这个虚拟地址保存到可执行模块的导入段中,此时 Import Address Table 将被填充起来。
    7. 当代码引用到一个导入符号的时候,会查看 Import Address Table 得到导入符号的地址,这样就能访问被导入的 变量、函数、C++类了。
注意:
    1. 在第三步定位 dll 的时候,如果没有找到所需要的 dll,则会弹出错误提示:“无法启动,因为计算机中缺失 xxx.dll”
    2. 在第五步修复导入符号引用的时候,如果在 dll 的导出段中没有找到对应的导出符号,则会弹出错误提示:“程序入口点 xxxFunc 无法定位到动态链接库 xxx.dll 上”