第2 0章
DLL的高级操作技术
20.1 DLL模块的显式加载和符号链接
如果线程需要调用DLL模块中的函数,那么DLL的文件映像必须映射到调用线程的进程地址空间中。可以用两种方法进行这项操作。
第一种方法是让应用程序的源代码只引用
DLL
中包含的符号。这样,当应用程序启动运行时,加载程序就能够隐含加载(和链接)需要的DLL。
第二种方法是在应用程序运行时让应用程序显式加载需要的
DLL
并且显式链接到需要的输出符号。换句话说,当应用程序运行时,它里面的线程能够决定它是否要调用DLL中的函数。该线程可以将DLL显式加载到进程的地址空间,获得DLL中包含的函数的虚拟内存地址,然后使用该内存地址调用该函数。这种方法的优点是一切操作都是在应用程序运行时进行的。
20.1.1
显式加载DLL模块
无论何时,进程中的线程都可以决定将一个DLL映射到进程的地址空间,方法是调用下面两个函数中的一个:
HINSTANCE LoadLibrary (PCTSTR pszDLLPathname);
HINSTANCE LoadLibraryEx (
PCTSTR pszDLLPathName,
HANDLE hFile,
DWORD dwFlags );
这两个函数均用于找出用户系统上的文件映像(使用上一章中介绍的搜索算法),并设法将DLL的文件映像映射到调用进程的地址空间中。两个函数返回的
HINSTANCE
值用于标识文件映像映射到的虚拟内存地址。如果DLL不能被映射到进程的地址空间,则返回NULL。若要了解关于错误的详细信息,可以调用GetLastError。
通常情况下,当
DLL
被映射到进程的地址空间中时,系统要调用
DLL
中的一个特殊函数,
DllMain(本章后面介绍)。该函数用于对DLL进行初始化。
20.1.2 显式卸载D L L模块
当进程中的线程不再需要DLL中的引用符号时,可以从进程的地址空间中显式卸载DLL,方法是调用下面的函数:
BOOL FreeLibrary (HINSTANCE hinstDll);
必须传递HINSTANCE值,以便标识要卸载的DLL。该值是较早的时候调用LoadLibrary(Ex)而返回的值。
也可以通过调用下面的函数从进程的地址空间中卸载DLL:
void
FreeLibraryAndExitThread (
HINSTANCE hinstDll,
DWORD dwExitCode );
该函数是在Kernel32.dll中实现的。
在实际环境中,LoadLibrary和LoadLibraryEx这两个函数用于对与特定的库相关的进程使用计数进行递增,FreeLibrary和FreeLibraryAndExitThread这两个函数则用于对库的每个进程的使用计数进行递减。例如,
当第一次调用
LoadLibrary
函数来加载
DLL
时,系统将
DLL
的文件映像映射到调用进程的地址空间中,并将
DLL
的使用计数设置为
1
。如果同一个进程中的线程后来调用
LoadLibrary
来加载同一个
DLL
文件映像,系统并不第二次将
DLL
映像文件映射到进程的地址空间中,它只是将与该进程的
DLL
相关的使用计数递增
1
。
为了从进程的地址空间中卸载
DLL
文件映像,进程中的线程必须两次调用
FreeLibrary
函数。第一次调用只是将
DLL
的使用计数递减为
1
,第二次调用则将
DLL
的使用计数递减为
0
。当系统发现
DLL
的使用计数递减为
0
时,它就从进程的地址空间中卸载
DLL
的文件映像。试图调用
DLL
中的函数的任何线程都会产生访问违规,因为特定地址上的代码不再被映射到进程的地址空间中。
系统为每个进程维护了一个
DLL
的使用计数,也就是说,如果进程A中的一个线程调用下面的函数,然后进程B中的一个线程调用相同的函数,那么MyLib.dll将被映射到两个进程的地址空间中,这样,进程A和进程B的DLL使用计数都将是1。
如果调用GetModuleHandle函数,线程就能够确定DLL是否已经被映射到进程的地址空间中:
HINSTANCE
GetModuleHandle ( PCSTR pszModuleName) ;
如果只有DLL的HINSTANCE值,那么可以调用GetModuleFileName函数,确定DLL(或.exe)的全路径名。
20.1.3 显式链接到一个输出符号
一旦DLL模块被显式加载,线程就必须获取它要引用的符号的地址,方法是调用下面的函数:
FARPROC GetProcAddress (
HINSTANCE hinstDll ,
PCSTR pszSymbolname );
参数
hinstDll
是调用
LoadLibrary(Ex)
或
GetModuleHandle
函数而返回的,它用于设定包含符号的
DLL
的句柄。参数
pszSymbolName
可以采用两种形式。第一种形式是以
0
结尾的字符串的地址,它包含了你想要其地址的符号的名字:
FARPROC
pfn = GetProcAddress (hinstDll, "SomeFuncInDll");
注意,参数pszSymbolName的原型是PCSTR,而不是PCTSTR。这意味着GetProcAddress函数只接受ANSI字符串,决不能将Unicode字符串传递给该函数,因为编译器/链接程序总是将符号名作为ANSI字符串存储在DLL的输出节中。
参数pszSymbolName的第二种形式用于指明你想要其地址的符号的序号:
FARPROC
pfn = GetProcAddress (hinstDll , NAKEINTRESOURCE(2));
这种用法假设你知道你需要的符号名被DLL创建程序赋予了序号值2。同样,我要再次强调,Microsoft非常反对使用序号,因此你不会经常看到GetProcAddress的这个用法。
这两种方法都能够提供包含在DLL中的必要符号的地址。如果DLL模块的输出节中不存在你需要的符号,GetProcAddress就返回NULL,表示运行失败。
应该知道,调用GetProcAddress的第一种方法比第二种方法要慢,因为系统必须进行字符串的比较,并且要搜索传递的符号名字符串。
20.2 DLL的进入点函数
一个DLL可以拥有单个进入点函数。系统在不同的时间调用这个进入点函数,这个问题将在下面加以介绍。这些调用可以用来提供一些信息,
通常用于供
DLL
进行每个进程或线程的初始化和清除操作。如果你的
DLL
不需要这些通知信息,就不必在
DLL
源代码中实现这个函数。
BOOL WINAPI DllMain (HINSTANCE hinstDll , DWORD fdwReason , PVOID fImpLoad) ;
注意:函数名
DllMain
是区分大小写的。许多编程人员有时调用的函数是DLLMain。这是一个非常容易犯的错误,因为D L L这个词常常使用大写来表示。
如果调用的进入点函数不是
DllMain
,而是别的函数,你的代码将能够编译和链接,但是你的进入点函数永远不会被调用,你的
DLL
永远不会被初始化。
参数hinstDll包含了DLL的实例句柄。与( w ) WinMain函数的
hinstExe
参数一样,这个值用于标识
DLL
的文件映像被映射到进程的地址空间中的虚拟内存地址。通常应将这个参数保存在一个全局变量中,这样就可以在调用加载资源的函数(如DialogBox和LoadString)时使用它。
最后一个参数是
fImpLoad
,如果
DLL
是隐含加载的,那么该参数将是个非
0
值,如果
DLL
是显式加载的,那么它的值是
0
。
参数
fdwReason
用于指明系统为什么调用该函数。该参数可以使用4个值中的一个。这4个值是: DLL_PROCESS_ATTACH,DLL_PROCESS_DETACH,DLL_THREAD_ATTACH或
DLL_THREAD_DETACH。
注意:必须记住,DLL使用DllMain函数来对它们进行初始化。当你的DllMain函数执行时,同一个地址空间中的其他DLL可能尚未执行它们的DllMain函数。这意味着它们尚未初始化,因此你应该避免调用从其他DLL中输入的函数。此外,你应该避免从DllMain内部调用LoadLibrary(Ex)和FreeLibrary函数,因为这些函数会形式一个依赖性循环。
Platform SDK文档说,你的DllMain函数只应该进行一些简单的初始化,比如设置本地存储器(第21章介绍),创建内核对象和打开文件等。你还必须避免调用User、Shell、ODBC、COM、RPC和套接字函数(即调用这些函数的函数),因为它们的DLL也许尚未初始化,或者这些函数可能在内部调用LoadLibrary(Ex)函数,这同样会形成一个依赖性循环。
另外,如果创建全局性的或静态的C++对象,那么应该注意可能存在同样的问题,因为在你调用DllMain函数的同时,这些对象的构造函数和析构函数也会被调用。
20.2.1 DLL_PROCESS_ATTACH
通知
当
DLL
被初次映射到进程的地址空间中时,系统将调用该
DLL
的
DllMain
函数,给它传递参数
fdwReason
的值
DLL_PROCESS_ATTACH
。只有当DLL的文件映像初次被映射时,才会出现这种情况。如果线程在后来为已经映射到进程的地址空间中的DLL调用LoadLibrary(Ex)函数,那么操作系统只是递增DLL的使用计数,它并不再次用DLL_PROCESS_ATTACH的值来调用DLL的DllMain函数。
当处理DLL_PROCESS_ATTACH时,DLL应该执行DLL中的函数要求的任何与进程相关的初始化。例如, DLL可能包含需要使用它们自己的堆栈(在进程的地址空间中创建)的函数。通过在处理DLL_PROCESS_ATTACH通知时调用HeapCreate函数,该DLL的DllMain函数就能够创建这个堆栈。已经创建的堆栈的句柄可以保存在DLL函数有权访问的一个全局变量中。
当然,系统中的有些线程必须负责执行DllMain函数中的代码。当一个新线程创建时,系统将分配进程的地址空间,然后将.exe文件映像和所有需要的DLL文件映像映射到进程的地址空间中。然后它创建进程的主线程,并使用该线程调用每个DLL的带有DLL_PROCESS_ATTACH 值的DllMain函数。当已经映射的所有D L L都对通知信息作出响应后,系统将使进程的主线程开始执行可执行模块的C/C++运行期启动代码,然后执行可执行模块的进入点函数(main、wmain、WinMain或wWinMain)。如果DLL的任何一个DllMain函数返回FALSE,指明初始化没有取得成功,系统便终止整个进程的运行,从它的地址空间中删除所有文件映像,给用户显示一个消息框,说明进程无法启动运行。
20.2.2 DLL_PROCESS_DETACH
通知
DLL
从进程的地址空间中被卸载时,系统将调用
DLL
的
DllMain
函数,给它传递
fdwReason
的值
DLL_PROCESS_DETACH
。当DLL处理这个值时,它应该执行任何与进程相关的清除操作。
注意:如果因为系统中的某个线程调用了TerminateProcess而使进程终止运行,那么系统将不调用带有DLL_PROCESS_DETACH值的DLL的DllMain函数。这意味着映射到进程的地址空间中的任何DLL都没有机会在进程终止运行之前执行任何清除操作。这可能导致数据的丢失。只有在迫不得已的情况下,才能使用TerminateProcess函数。
线程调用
LoadLibrary
时系统执行的基本步骤:
(1)
线程调用
LoadLibrary
函数;
(2)DLL
映射到进程的地址空间;
(3)
递增
DLL
的使用计数;
(4)
调用该库带有
DLL_PROCESS_ATTACH
值的
DllMain
函数;
(5)
返回该库的
hinstDLL(
加载地址
)
;
线程调用
FreeLibrary
时系统执行的基本步骤:
(1)
线程调用
FreeLibrary
函数;
(2)
判断
hinstDll
参数的有效性,无效返回
FALSE
;
(3)
递减
DLL
的使用计数;
(4)
判断使用计数为
0
否,
(
否
)
返回
TRUE
;
(
是
)
调用该库带有
DLL_PROCESS_DETACH
值的
DllMain
函数,从进程的地址空间撤消该
DLL
;
20.2.3 DLL_THREAD_ATTACH
通知
当在一个进程中创建线程时,系统要查看当前映射到该进程的地址空间中的所有
DLL
文件映像,并调用每个文件映像的带有
DLL_THREAD_ATTACH
值的
DllMain
函数。这可以告诉所有的DLL执行每个线程的初始化操作。新创建的线程负责执行DLL的所有DllMain函数中的代码。只有当所有的DLL都有机会处理该通知时,系统才允许新线程开始执行它的线程函数。
当一个新DLL被映射到进程的地址空间中时,如果该进程内已经有若干个线程正在运行,那么系统将不为现有的线程调用带有DLL_THREAD_ATTACH值的DDL的DllMain函数。只有当新线程创建时DLL被映射到进程的地址空间中,它才调用带有DLL_THREAD_ATTACH值的DLL的DllMain函数。
另外要注意,系统并不为进程的主线程调用带有DLL_THREAD_ATTACH值的任何DllMain 函数。进程初次启动时映射到进程的地址空间中的任何DLL 均接收DLL_PROCESS_ATTACH通知,而不是DLL_THREAD_ATTACH通知。
20.2.4 DLL_THREAD_DETACH
通知
让线程终止运行的首选方法是使它的线程函数返回。这使得系统可以调用
ExitThread
来撤消该线程。
ExitThread
函数告诉系统,该线程想要终止运行,但是系统并不立即将它撤消。相反,
它要取出这个即将被撤消的线程,
并让它调用已经映射的
DLL
的所有带有
DLL_THREAD_DETACH
值的
DllMain
函数。这个通知告诉所有的
DLL
执行每个线程的清除操作。
注意: DLL能够防止线程终止运行。例如,当DllMain函数接收到DLL_THREAD_DETACH通知时,它就能够进入一个无限循环。只有当每个DLL已经完成对DLL_THREAD_DETACH通知的处理时,操作系统才会终止线程的运行。
注意:如果因为系统中的线程调用TerminateThread函数而使该线程终止运行,那么系统将不调用带有DLL_THREAD_DETACH值的DLL的所有DllMain函数。这意味着映射到进程的地址空间中的任何一个DLL都没有机会在线程终止运行之前执行任何清除操作。这可能导致数据的丢失。与TerminateProcess一样,只有在迫不得已的时候,才可以使用TerminateThread函数。
20.2.5 顺序调用DllMain
20.2.6 DllMain与C/C++运行期库
在上面介绍的DllMain函数中,我假设你使用Microsoft的Visual C++编译器来创建你的DLL。当编写一个DLL时,你需要得到C/C++运行期库的某些初始帮助。例如,如果你创建的DLL包含一个全局变量,而这个全局变量是个C++类的实例。在你顺利地在DllMain函数中使用这个全局变量之前,该变量必须调用它的构造函数。这是由C/C++运行期库的DLL启动代码来完成的。
当你链接你的DLL时,链接程序将DLL的进入点函数嵌入产生的DLL文件映像。可以使用链接程序的/ENTRY开关来设定该函数的地址。按照默认设置,当使用Microsoft的链接程序并且设定/ DLL 开关时,链接程序假设进入点函数称为_DllMainCRTStartup。该函数包含在C/C++运行期的库文件中,并且在你链接DLL时它被静态链接到你的DLL文件的映像中(即使你使用DLL版本的C/C++运行期库,该函数也是静态链接的)。
当你的
DLL
文件映像被映射到进程的地址空间中时,系统实际上是调用
_DllMainCRTStartup
函数,而不是调用
DllMain
函数。
_DllMainCRTStartup
函数负责对
C/C++
运行期库进行初始化,并且确保在
_DllMainCRTStartup
收到
DLL_PROCESS_ATTACH
通知时创建任何全局或静态
C++
对象。当执行任何
C/C++
运行期初始化时,
_DllMainCRTStartup
函数将调用你的
DllMain
函数。当
DLL
收到
DLL_PROCESS_DETACH
通知时,系统再次调用
_DllMainCRTStartup
函数。这次该函数调用你的
DllMain
函数,当
DllMain
返回时,
_DllMainCRTStartup
就为
DLL
中的任何全局或静态
C++
对象调用析构函数。当
_DllMainCRTStartup
收到
DLL_THREAD_ATTACH
通知时,
_DllMainCRTStartup
函数并不执行任何特殊的处理操作。但是对于
DLL_THREAD_DETACH
来说,
C/C++
运行期将释放线程的
tiddata
内存块(如果存在这样的内存块的话)。但是,通常情况下,这个
tiddata
内存块是不应该存在的,因为编写正确的线程函数将返回到内部调用
_endthreadex
的
C/C++
运行期的
_threadstartex
函数(第
6
章已经介绍),它负责在线程试图调用
ExitThread
之前释放内存块。
前面讲过,不必在DLL源代码中实现DllMain函数。如果你并不拥有自己的DllMain函数,可以使用C/C++运行期库的DllMain函数的实现代码。当链接程序链接DLL时,如果链接程序无法找到DLL的.obj文件中的DllMain函数,那么它就链接C/C++运行期库的DllMain函数的实现代码。如果你没有提供自己的DllMain函数,C/C++运行期库就正确地假设你不在乎DLL_THREAD_ATTACH和DLL_THREAD_DETACH通知。为提高创建和撤消线程的性能,调用DisableThreadLibraryCalls函数。
20.3 延迟加载DLL
20.4 函数转发器
函数转发器是DLL的输出节中的一个项目,用于将对一个函数的调用转至另一个DLL中的另一个函数。
20.5 已知的DLL
操作系统提供的某些DLL得到了特殊的处理。这些DLL称为已知的DLL。它们与其他DLL基本相同,但是操作系统总是在同一个目录中查找它们,以便对它们进行加载操作。
20.6 DLL转移
20.7 改变模块的位置
每个可执行模块和
DLL
模块都有一个首选的基地址,用于标识模块应该映射到的进程地址空间中的理想内存地址。当创建一个可执行模块时,链接程序将该模块的首选基地址设置为
0x00400000
。如果是
DLL
模块,链接程序设置的首选基地址是
0x10000000
。
为什么首选基地址这么重要呢?让我们看一看下面的代码:
int
g_x ;
void
Func()
{
g_x = 5 ;
}
当编译器处理Func函数时,该编译器和链接程序创建类似下面的机器代码:
MOV
[0x00414540] , 5
换句话说,编译器和链接程序创建的机器代码实际上是在变量g_x的地址0x00414540中
硬编码的代码。该地址位于机器代码中,用于标识变量在进程的地址空间中的绝对位置。但是,当并且仅当可执行模块加载到它的首选基地址0x00400000中时,这个内存地址才是正确的。
如果在一个DLL模块中我们拥有与上面完全相同的代码,那将会如何呢?在这种情况下,编译器和链接程序将生成类似下面的机器代码:
MOV
[0x10014540] , 5
同样,注意DLL的变量g_z的虚拟内存地址是在磁盘驱动器上的DLL文件映像中硬编码的代码。而且,如果该DLL确实是在它的首选基地址上加载的,那么这个内存地址是绝对正确的。
现在假设你设计的应用程序需要两个DLL。按照默认设置,链接程序将.exe模块的首选基地址设置为0x00400000,同时,链接程序将两个DLL模块的首选基地址均设置为0x10000000。如果想要运行.exe模块,那么加载程序便创建该虚拟地址空间,并将.exe模块映射到内存地址0x00400000中。然后加载程序将第一个DLL映射到内存地址0x10000000中。但是,当加载程序试图将第二个DLL映射到进程的地址空间中去时,它将无法把它映射到该模块的首选基地址中,必须改变该DLL模块的位置,将它放到别的什么地方。
改变可执行(或DLL)模块的位置是个非常可怕的过程,应该采取措施避免这样的操作。为什么要避免这样的操作呢?假设加载程序将第二个DLL的地址改到0x20000000。这时,将变量g_x的值改为5的代码应该是:
MOV
[0x20014540] , 5
但是文件映像中的代码却类似下面的样子:
MOV
[0x10014540] , 5
如果文件映像的代码被允许执行,那么第一个DLL模块中大约有4个字节的值将被值5改写。这是不能允许的。加载程序必须修改这个代码。
当链接程序创建你的模块时,它将把一个移位节嵌入产生的文件中。这一节包含一个字节位移的列表。每个字节位移用于标识一个机器代码指令使用的内存地址。如果加载程序能够将一个模块映射到它的首选基地址中,那么系统将永远不会访问模块的移位节。这当然是我们所希望的—你永远不希望使用移位节。
另一方面,如果该模块不能映射到它的首选基地址中,加载程序便打开模块的移位节,并对所有项目重复执行该操作。对于找到的每个项目,加载程序将转至包含要修改机器代码指令的存储器页面。然后它抓取机器指令当前正在使用的内存地址,并将模块的首选基地址与模块实际映射到的地址之间的差与该地址相加。
因此,在上面的例子中,第二个DLL被映射到0x20000000,但是它的首选基地址是0x10000000。它产生的差是0x10000000,然后这个差与机器代码指令的地址相加,产生的结果如下:
MOV
[0x20014540] , 5
现在,第二个DLL中的代码将能够正确地引用它的变量g_x。
注意:首选基地址必须始终从分配粒度边界开始。在迄今为止的所有平台上,系统的分配粒度是64 KB。将来这个分配粒度可能发生变化。第1 3章已经对分配粒度进行了详细的介绍。
20.8 绑定模块