Windows核心编程笔记——DLL基础
一、DLL的作用和优点
动态链接库一直是Windows操作系统的基石。Windows API提供的所有函数都包含在DLL中,其中最重要的三个是:
- Kernel32.dll
- 包含的函数用来管理内存、进程、线程。
- User32.dll
- 包含的函数用来执行与用户界面相关的任务,例如创建窗口和发送消息。
- GDI32.dll
- 包含的函数用来绘制图像和显示文字。
DLL的优点:
- 扩展了应用程序的特性。
- 简化了项目管理。
- 有助于节省内存。
- 促进资源共享。
- 促进了本地化。
- 有助于解决平台间的差异。
- 可以用于特殊目的。
二、DLL和进程的地址空间
创建DLL通常比创建应用程序容易,因为DLL通常由一组可供任何应用程序使用的独立函数组成。
在DLL中,通常没有用来处理消息循环或创建窗口的代码。DLL只不过是一组源代码模块,。
创建DLL时候,必须给链接器传递/DLL
,这样操作系统的加载程序才能够将该文件映像识别为DLL。
在应用程序或其他DLL调用一个DLL中的函数之前,必须将DLL的文件映像映射到调用进程的地址空间中。由两种方法:
- 隐式载入时链接(implicit load-time linking)
- 显式运行时链接(explicit run-time linking)
本章节只介绍第1种。
在系统将一个DLL的文件映像映射到调用进程的地址空间之后,进程中所有的线程都可以调用该DLL中的函数。
事实上,该DLL几乎完全丧失了它的DLL身份:对进程中的线程来说,该DLL中的代码和数据就像是一些附加的代码和数据,碰巧放在进程地址空间中。
当线程调用DLL中的一个函数的时候,该函数会在线程栈中取得传给它的参数,并使用线程栈来存放它需要的局部变量。
此外,该DLL中的函数创建的任何对象都为调用线程或进程所拥有——DLL绝不会拥有任何对象。
例如,DLL中的函数如果调用了VirtualAlloc
,系统就会从调用进程的地址空间预定地址空间区域。如果稍后从进程中卸载该DLL,该区域仍旧不会被释放,该区域实际上被线程/进程所拥有,只有当线程调用了VirtualkFree
或当进程终止时才会释放。
DLL中的全局变量和局部变量是通过写时复制机制来保证的。当一个进程将一个DLL映像文件映射到自己的地址空间中时,系统也会为全局变量和静态变量创建新的实例。
一个地址空间是由一个可执行模块和多个DLL模块构成的。这些模块中,有些可能会链接到C/C++运行库的静态版本,有些可能会链接到C/C++运行库的DLL版本,还有一些可能根本不需要C/C++运行库。
许多开发人员经常犯的错误就是忘记一个地址空间中可能会存在多个C/C++运行库。
例如下面的代码:
VOID EXEFunc(){
PVOID pv = DLLFunc();
// Access the storage pointed to by pv ...
// Assumes that pv is in EXE's C/C++ run-time heap
free(pv);
}
PVOID DLLFunc(){
// Allocate block from DLL's C/C++ run-time heap
return malloc(100);
}
怎么?上面的代码能正常工作吗?答案是:也许是。
如果EXE和DLL都链接到C/C++运行库的DLL版本,那么代码能够正常工作。
但是,如果其中的一个,两个模块都链接到C/C++u运行库的静态版本,free
调用就会失败。
这个问题有一个简单的解决办法,即:当一个模块提供一个内存分配函数的时候,它必须同时提供另一个用来释放内存的函数。
重写上面的那段代码:
VOID EXEFunc(){
PVOID pv = DLLFunc();
// Access the storage pointed to by pv...
// Makes no assumptions about C/C++ run-time heap
DLLFreeFunc(pv);
}
PVOID DLLFunc(){
// Access block from DLL's C/C++ run-time heap
PVOID pv = malloc(100);
return pv;
}
BOOL DLLFreeFunc(PVOID pv){
//Free block from DLL's C/C++ run-time heap
return free(pv);
}
这段代码才是正确的。在编写一个模块的时候,不要忘记其他模块中的函数甚至可能不是C/C++编写的,因此可能不会用malloc()
和free()
来进行内存分配。这也适用C++的new
和delete
。
三、DLL的隐式链接
DLL创建过程及应用程序隐式链接DLL的过程如下:
构建DLL
- 1)头文件,其中包含待导出函数的原型、结构和符号的声明。
- 2)C/C++源文件,其中包含待导出函数的实现和变量。
- 3)编译器为每个C/C++源文件生成 .obj 文件。
- 4)链接器将每个 .obj 模块合并,从而生成DLL。
- 5)如果至少到处了一个函数/变量,那么链接器会同时生成 .lib 文件。
构建EXE
- 6)头文件,其中包含待导出函数的原型、结构和符号的声明。
- 7)C/C++源文件,其中包含待导出函数的实现和变量。
- 8)编译器为每个C/C++源文件生成 .obj 文件。
- 9)链接器将每个 .obj 模块合并,并使用 .lib 文件来解析对导入函数/变量的引用,从而生成.exe
- EXE包含一个导入表,其中列出了必需的DLL和导入的符号。
运行应用程序
- 10)加载程序为 .exe 创建地址空间。
- 11)加载程序将必需的DLL载入到地址空间中–>进程的主线程开始执行,应用程序开始运行。
隐式链接是迄今为止最常见的链接类型。
DLL模块也可以,而且经常会导入一些包含在其他DLL模块中的函数和变量。
四、DLL的构建步骤
构建DLL需要的步骤:
- 先创建一个头文件,在其中包含我们想要在DLL中导出的函数原型、结构及符号而定义。
- 为了构建该DLL,DLL的所有源文件都需要包含这个头文件,稍后会介绍。
- 在构建可执行文件时需要用到同一个头文件。
- 创建C/C++源文件来实现需要在DLL导出的函数和变量。
- 由于在构建可执行模块时不需要这些源文件,因此创建该DLL的公司可以将这些源代码作为公司的机密。
- 在构建该DLL模块的时候,编译器会对每个源文件生成一个 .obj 模块。
- 每个源文件对应一个 .obj模块。
- 当所有的 .obj 模块都创建完毕后,链接器会将所有 .obj 模块的内容合并起来,产生一个单独的DLL映像文件。
- 这个映像文件(或模块)包含DLL中所有的二进制代码以及全局/静态变量。
- 为了执行可执行模块,这个文件是必需的。
- 如果链接器检测到DLL的源文件输出了至少一个函数或变量,则链接器还会生成一个 .lib 文件。
- 这个 .lib 文件非常小,因为它并不包含任何函数和变量。
- 这个 .lib 文件只是列出了所有被导出的函数和变量的符号名。
- 为了构建可执行模块,这个文件是必需的。
在构建完DLL模块后,可以通过下列步骤来构建可执行模块:
- 在所有引用了导出的函数、变量、数据结构或符号的源文件中,必须包含由DLL的开发人员所创建的头文件。
- 创建C/C++源文件来实现想要包含在可执行模块中的函数和变量。
- 当然,代码可以引用在DLL的头文件中定义的函数和变量。
- 在构建可执行模块的时候,编译器会对每个源文件产生一个 .obj 文件(一一对应)。
- 所有的 .obj 都创建完成后,链接器将所有的 .obj 合并到一起,产生一个 .exe 。
- 该可执行模块还包含一个导入段(import section),其中列出了所有它需要的DLL模块的名称。
- 对列出的每个DLL,导入段还记录了该可执行文件的二进制代码从中引用的函数和变量的符号名。操作系统的加载程序会解析这个导入段。
- 加载程序先为进程创建一个虚拟地址空间,并将可执行模块映射到新进程的地址空间中。加载程序接着解析可执行模块的导入段。对导入段中列出的每个DLL,加载程序会在用户的系统中对该DLL模块进行定位,并将该DLL映射到进程的地址空间中。
- 注意,由于DLL模块可以从其他DLL模块中导入函数和变量,因此DLL模块可能有自己的导入段并需要将它所需要的DLL模块映射到进程的地址空间中。可以看到,初始化一个进程可能会耗费很长的时间。
最后,进程的主线程就可以开始执行了。
五、构建DLL模块
实际开发中,应该避免从DLL导出变量,因为这去除了一个抽象层,使得DLL的代码更加难以维护。
此外,只有当导出C++类的模块使用的编译器与导入C++类的模块使用的编译器由同一家厂商提供时,才可以导出C++类。
因此,除非知道可执行模块的开发人员与DLL模块的开发人员使用的是相同的工具包,否则应该避免从DLL中导出C++类。
- 在创建DLL时,应该首先创建一个头文件来包含想要导出的变量(类型和名称)和函数(原型和名称)。
- 这个头文件还必须定义导出的函数或变量所用到的任何符号和数据结构。
- DLL的所有源文件都应该包含这个头文件。
- 此外,必须分发这个头文件,这样任何需要导入这些函数或变量的源文件就可以包含该文件。让DLL的构建者和可执行模块的构建者使用同一个头文件可以使维护变得更加容易。
1. 头文件的编写
// MyLib.h
#ifdef MYLIBAPI
#else
#define MYLIBAPI extern "C" __declspec(dllimport)
#endif
///
// Define any date structure and symbols here.
///
// Define exported variables here. (NOTE: Avoid exporting variables.)
MYLIBAPI int g_nResult;
///
//Define exported function prototypes here.
MYLIBAPI int ADD(int nLeft, int nRight);
///
2. 源文件的编写
在每个源文件中,必须包含该头文件,如下面的代码所示:
// MyLibFile1.cpp
#include <windows.h>
//This DLL source code file exports functions and variables.
#define MYLIBAPI extern "C" __declspec(dllexport)
//Include the exported data structures, symbols, functions, and variables.
#include "MyLib.h"
/
// Place the code for this DLL source code file here.
int g_nResult;
int Add(int nLeft, int nRight){
g_nResult = nLeft + nRight;
return g_nResult;
}
在编译DLL源文件之前,MYLIBAPI在包含MyLib.h头文件之前被定义为_declspec(dllexport)
,编译器看到变量、函数或C++类被__declspec(dllexport)
修饰,就会在生成DLL模块时将它导出。
对于那些要被导出的变量和函数,必须在头文件中的变量和函数定义的前面加上MYLIBAPI
标识符。
在源文件中,不必在要被导出的变量和函数前加MYLIBAPI
标识符。原因是编译器再解析头文件时会记住应该导出哪些变量和函数。
可执行文件不应该在包含这个头文件见之前定义MYLIBAPI
,这样头文件就会将MYLIBAPI
定义为__declspec(import)
,编译器就知道该可执行文件的源文件要从DLL模块中导入一些变量和函数。
* 3. 导出的过程
当MSVC编译器看到__declspec(dllexport)
修饰的变量、函数原型或C++类时,会在生成的 .obj 文件中嵌入一些额外的信息。
当链接器在链接DLL所有的 .obj文件时,会解析这些信息。
在链接DLL时,链接器会检测到这些与导出变量、函数或类有关的嵌入信息,并生成一个 .lib 文件。这个 .lib 文件列出了该DLL导出的符号。在链接任何可执行模块的时候,只要可执行模块引用了该DLL导出的符号,那么这个 .lib 文件就是必需的。
除了创建这个 .lib 文件之外,链接器还会在生成的DLL文件中嵌入一个导入符号表。这个按字母顺序排列的导出段(export section)列出了导出的变量、函数和类的符号名。
链接器还会保存相对虚拟地址(relative virtual adress,RVA),标识每个符号可以在DLL模块中的何处找到。
可以使用Visual Studio中的
dumpbin.exe
(加上-exports
)选项来查看一个DLL的导出段。
* 4. 为非Visual C++工具包创建DLL
如果用Visual C++创建的DLL要与其他厂商的工具包构建的可执行文件链接,就必须做一些额外的工作。
即使根本没有用到C++,MSVC编译器也会对C函数的名称进行改变。只有当我们的函数使用了__stdcall(WINAPI)
调用约定的时候才生这种情况。
但不巧的时,这个调用约定是最常见的类型。
当使用__stdcall
来导出C函数的时候,MSVC会对函数名进行改编.
具体的改编方法是给函数名添加下划线前缀和一个特殊的后缀,该后缀由一个@符号后跟作为参数传给函数的字节数组成。
例如函数__declspec(dllexport) LONG __stdcall MyFunc(int a, int b)
被导出为_MyFunc@8
。
如果用另一家厂商的工具包来构建可执行文件,链接器会试图链接到一个名为MyFunc
的函数,由于该函数在MSVC编译器生成的DLL中并不存在,因此链接会失败。
为了让Microsoft工具包构建一个能与其他编译器厂商的工具包链接的DLL,必须让MSVC不要对导出的函数名进行改编。
有两种方法来达到这一目的。
第一种方法是为项目创建一个 .def 文件,并在 .def文件中包含一些类似下面的 EXPORTS 段:
EXPORTS
MyFunc
当Microsoft链接器解析这个 .def 文件时,会发现 _MyFunc@8
和MyFunc
都被导出,由于这两个函数名是匹配的(不考虑改编),因此链接器会用 .def 文件中定义的名称,也就是 MyFunc
来导出函数,而根本不会用 _MyFunc@8
来导出函数。
Microsoft工具包链接到一个DLL时,也能够做出正确的选择,保证链接到名为MyFunc
的函数。
如果不想使用.def
文件,那么还有第二种办法,
可以在DLL的源文件中添加一行类似下面的代码:
#pragma comment(linker, "/export:MyFunc=_MyFunc@8")
这行代码会使得编译器产生一个链接器提示符,该指示符告诉链接要导出一个名为MyFunc
的函数,该函数的入口的与_MyFunc@8
相同。
第二种方法相对来说不够方便,必须自己进行改编,而且它实际上导出了两个符号,即MyFunc
和_MyFunc@8
,它们都对应于同一个函数。但是第一种方法则只导出了MyFunc
符号。
第二种方法并没有什么特别之处,只不过能避免使用 .def而已。
六、构建可执行模块
在编写可执行模块时,必须包含DLL的头文件。
可执行模块的源文件不应该在包含DLL的头文件之前定义MYLIBAPI
。在编译前面这个可执行模块的源文件时,MYLIBAPI
在MyLib.h
中将被定义为__declspec(dllimport)
。
如果编译器看到一个变量、函数或C++类用__declspec(dllimport)
来修饰,那么它会知道应该从某个DLL模块中导入该符号。但编译器不知道也不需要知道具体的DLL模块。
接下来,链接器必须将所有的 .obj模块合并到一起。由于链接器必须确定代码中引用的导入符号来自哪个DLL,因此我们必须将DLL的 .lib文件传给链接器。
.lib文件只不过列出了DLL模块导出的符号。链接器只想知道被引用的符号确实存在,以及该符号来自哪个DLL模块。
* 导入的过程
在导入符号时,不必使用__declspec(dllimport)
,而可以直接使用C语言的extern
关键字。但是,如果编译器能够提前知道我们引用的符号是从哪一个DLL的 .lib 文件中导入的,那么它能够产生略微高校的代码。
所以建议在导入函数和符号的时候使用 __declspec(dllimport)
关键字。
可以使用Visual Studio的dumpbin.exe
(加上-imports
选项)来查看一个模块的导入段。
七、运行可执行模块
导入段只包含了DLL的名称,不包含DLL的路径。因此加载程序必须在用户的磁盘上搜索DLL。下面是加载程序的搜索顺序:
- 包含可执行文件的目录。
- Windows的系统目录,该目录可以通过
GetSystemDirectory
得到。 - 16位的系统目录,即Windwos目录中的System子目录。
- Windows目录,该目录可以通过
GetWindowsDirectory
得到。 - 进程的当前目录。
- PATH环境变量中所列出的目录。
注意,对应用程序当前目录的搜索位于Windows目录之后。其目的是为了防止加载程序在应用程序当前目录中找到伪造的系统DLL并将它们载入,从而保证系统DLL始终都是从它们在Windows目录中的正式位置载入的。