如果线程需要调用D L L模块中的函数,那么D L L的文件映像必须映射到调用线程的进程地址空间中。可以用两种方法进行这项操作。第一种方法是让应用程序的源代码只引用D L L中包含的符号。这样,当应用程序启动运行时,加载程序就能够隐含加载(和链接)需要的D L L。
创造DLL: 1) 建立带有输出原型/结构/符号的头文件。 2) 建立实现输出函数/变量的C/C++源文件。 3) 编译器为每个C/C++源文件生成.obj模块。 4) 链接程序将生成DLL的.obj模块链接起来。 5) 如果至少输出一个函数/变量,那么链接程序也生成.lib 文件。 创造EXE: 6) 建立带有输入原型/结构/符号的头文件(视情况而定)。 7) 建立不引用输入函数/变量的C/C++源文件。 8) 编译器为每个C/C++源文件生成.obj源文件。 9) 链接程序将各个.obj模块链接起来,生成.exe文件。 注: DLL的lib文件是不需要的,因为并不直接引用输出符号。 .exe 文件不包含输入表。 运行应用程序: 10) 加载程序为.exe 创建模块地址空进程的主线程开始执行; 应用程序启动运行。 显式加载DLL: 11) 一个线程调用LoadLibrary (Ex)函数,将DLL加载到进程 的地址空间这时线程可以调用GetProcAddress以便间接 引用DLL的输出符号。
图20-1 应用程序创建和显式链接DLL的示意图
第二种方法是在应用程序运行时让应用程序显式加载需要的D L L并且显式链接到需要的输出符号。换句话说,当应用程序运行时,它里面的线程能够决定它是否要调用D L L中的函数。该线程可以将D L L显式加载到进程的地址空间,获得D L L中包含的函数的虚拟内存地址,然后使用该内存地址调用该函数。这种方法的优点是一切操作都是在应用程序运行时进行的。
图2 0 - 1显示了一个应用程序是如何显式地加载D L L并且链接到它里面的符号的。
20.1.1 显式加载DLL模块
无论何时,进程中的线程都可以决定将一个D L L映射到进程的地址空间,方法是调用下面两个函数中的一个:
HINSTANCE LoadLibrary(PCTSTR pszDLLPathName); HINSTANCE LoadLibraryEx( PCTSTR pszDLLPathName, HANDLE hFile, DWORD dwFlags);
你会注意到, L o a d L i b r a r y E x函数配有两个辅助参数,即h F i l e和d w F l a g s。参数h F i l e保留供将来使用,现在必须是N U L L 。对于参数d w F l a g s ,必须将它设置为0 ,或者设置为D O N T _ R E S O LV E _ D L L _ R E F E R E N C E S、L O A D _ L I B R A RY _ A S _ D ATA F I L E和L O A D _ W I T H _A LT E R E D _ S E A R C H _ PAT H等标志的一个组合。
1. DON T_RESOLV E _ D L L _ R E F E R E N C E S
DON T_RESOLV E _ D L L _ R E F E R E N C E S标志用于告诉系统将D L L映射到调用进程的地址空间中。通常情况下,当D L L被映射到进程的地址空间中时,系统要调用D L L中的一个特殊函数,即D l l M a i n(本章后面介绍)。该函数用于对D L L进行初始化。DON T_RESOLV E _D L L _ R E F E R E N C E S标志使系统不必调用D l l M a i n函数就能映射文件映像。
此外,D L L能够输入另一个D L L中包含的函数。当系统将一个D L L映射到进程的地址空间中时,它也要查看该D L L 是否需要其他的D L L ,并且自动加载这些D L L 。当D O NT _ R E S O LV E _ D L L _ R E F E R E N C E S标志被设定时,系统并不自动将其他的D L L加载到进程的地址空间中。
2. LOAD_LIBRARY _ A S _ D ATA F I L E
L O A D _ L I B R A RY _ A S _ D ATA F I L E标志与DON T_RESOLV E _ D L L _ R E F E R E N C E S标志相类似,因为系统只是将D L L映射到进程的地址空间中,就像它是数据文件一样。系统并不花费额外的时间来准备执行文件中的任何代码。例如,当一个D L L被映射到进程的地址空间中时,系统要查看D L L中的某些信息,以确定应该将哪些页面保护属性赋予文件的不同的节。如果设定了L O A D _ L I B R A RY _ A S _ D ATA F I L E标志,系统将以它要执行文件中的代码时的同样方式来设置页面保护属性。
由于下面几个原因,该标志是非常有用的。首先,如果有一个D L L(它只包含资源,但不包含函数),那么可以设定这个标志,使D L L的文件映像能够映射到进程的地址空间中。然后可以在调用加载资源的函数时,使用L o a d L i b r a r y E x函数返回的H I N S TA N C E值。通常情况下,加载一个. e x e文件,就能够启动一个新进程,但是也可以使用L o a d L i b r a r y E x函数将. e x e文件的映像映射到进程的地址空间中。借助映射的. e x e文件的H I N S TA N C E值,就能够访问文件中的资源。由于. e x e文件没有D l l M a i n函数,因此,当调用L o a d L i b r a r y E x来加载一个. e x e文件时,必须设定L O A D _ L I B R A RY _ A S _ D ATA F I L E标志。
3. LOAD_WITH_ALT E R E D _ S E A R C H _ PAT H
L O A D _ W I T H _ A LT E R E D _ S E A R C H _ PAT H标志用于改变L o a d L i b r a r y E x用来查找特定的D L L文件时使用的搜索算法。通常情况下, L o a d L i b r a r y E x按照第1 9章讲述的顺序进行文件的搜索。但是,如果设定了L O A D _ W I T H _ A LT E R E D _ S E A R C H _ PAT H标志,那么L o a d L i b r a r y E x函数就按照下面的顺序来搜索文件:
1) pszDLLPathName参数中设定的目录。
2) 进程的当前目录。
3) Wi n d o w s的系统目录。
4) Wi n d o w s目录。
5) PAT H环境变量中列出的目录。
20.1.2 显式卸载DLL模块
当进程中的线程不再需要D L L中的引用符号时,可以从进程的地址空间中显式卸载D L L,方法是调用下面的函数:
BOOL FreeLibrary(HINSTANCE hinstDll);
也可以通过调用下面的函数从进程的地址空间中卸载D L L:
VOID FreeLibraryAndExitThread( HINSTANCE hinstDll, DWORD dwExitCode);
VOID FreeLibraryAndExitThread(HINSTANCE hinstDll, DWORD dwExitCode) { FreeLibrary(hinstDll); ExitThread(dwExitCode); }
但是,如果线程分开调用F r e e L i b r a r y和E x i t T h r e a d,就会出现一个严重的问题。这个问题是调用F r e e L i b r a r y会立即从进程的地址空间中卸载D L L。当调用的F r e e L i b r a r y返回时,包含对E x i t T h r e a d调用的代码就不再可以使用,因此线程将无法执行任何代码。这将导致访问违规,同时整个进程终止运行。
但是,如果线程调用F r e e L i b r a r y A n d E x i t T h r e a d,该函数调用F r e e L i b r a r y,使D L L立即被卸载。下一个执行的指令是在K e r n e l 3 2 . d l l中,而不是在刚刚被卸载的D L L中。这意味着该线程能够继续执行,并且可以调用E x i t T h r e a d。E x i t T h r e a d使该线程终止运行并且不返回。
一般来说,并没有很大的必要去调用F r e e L i b r a r y A n d E x i t T h r e a d函数。我曾经使用过一次,因为我执行了一个非常特殊的任务。另外,我为Microsoft Windows 3.1编写了一个代码,它并没有提供这个函数。因此我高兴地看到M i c r o s o f t将这个函数增加到了较新的Wi n d o w s版本中。
在实际环境中,L o a d L i b r a r y和L o a d L i b r a r y E x这两个函数用于对与特定的库相关的进程使用计数进行递增,F r e e L i b r a r y和F r e e L i b r a r y A n d E x i t T h r e a d这两个函数则用于对库的每个进程的使用计数进行递减。例如,当第一次调用L o a d L i b r a r y函数来加载D L L时,系统将D L L的文件映像映射到调用进程的地址空间中,并将D L L的使用计数设置为1。如果同一个进程中的线程后来调用L o a d L i b r a r y来加载同一个D L L文件映像,系统并不第二次将D L L映像文件映射到进程的地址空间中,它只是将与该进程的D L L相关的使用计数递增1。
为了从进程的地址空间中卸载D L L文件映像,进程中的线程必须两次调用F r e e L i b r a r y函数。第一次调用只是将D L L的使用计数递减为1,第二次调用则将D L L的使用计数递减为0。当系统发现D L L的使用计数递减为0时,它就从进程的地址空间中卸载D L L的文件映像。试图调用D L L中的函数的任何线程都会产生访问违规,因为特定地址上的代码不再被映射到进程的地址空间中。
系统为每个进程维护了一个D L L的使用计数,也就是说,如果进程A中的一个线程调用下面的函数,然后进程B中的一个线程调用相同的函数,那么M y L i b . d l l将被映射到两个进程的地址空间中,这样,进程A和进程B的D L L使用计数都将是1。
HINSTANCE hinstDll = LoadLibrary("MyLib.dll");
FreeLibrary(hinstDll);
HINSTANCE GetModuleHandle(PCTSTR pszModuleName);
HINSTANCE hinstDll = GetModuleHandle("MyLib"); //DLL extension assumed if (hinstDll == NULL) { hinstDll = LoadLibrary("MyLib"); // DLL extension assumed }
DWORD GetModuleFileName( HINSTANCE hinstModule, PTSTR pszPathName, DWORD cchPath);
20.1.3 显式链接到一个输出符号
一旦D L L模块被显式加载,线程就必须获取它要引用的符号的地址,方法是调用下面的函数:
FARPROC GetProcAddress( HINSTANCE hinstDll, PCSTR pszSymbolName);
FARPROC pfn = GetProcAddress(hinstDll, "SomeFuncInDll");
参数p s z S y m b o l N a m e的第二种形式用于指明你想要其地址的符号的序号:
FARPROC pfn = GetProcAddress(hinstDll, MAKEINTRESOURCE(2));
这两种方法都能够提供包含在D L L中的必要符号的地址。如果D L L模块的输出节中不存在你需要的符号,G e t P r o c A d d r e s s就返回N U L L,表示运行失败。
应该知道,调用G e t P r o c A d d r e s s的第一种方法比第二种方法要慢,因为系统必须进行字符串的比较,并且要搜索传递的符号名字符串。对于第二种方法来说,如果传递的序号尚未被分配给任何输出的函数,那么G e t P r o c A d d r e s s就会返回一个非N U L L值。这个返回值将会使你的应用程序错误地认为你已经拥有一个有效的地址,而实际上你并不拥有这样的地址。如果试图调用该地址,肯定会导致线程引发一个访问违规。我在早期从事Wi n d o w s编程时,并不完全理解这个行为特性,因此多次出现这样的错误。所以一定要小心(这个行为特性是应该避免使用序号而使用符号名的另一个原因)。
一个D L L可以拥有单个进入点函数。系统在不同的时间调用这个进入点函数,这个问题将在下面加以介绍。这些调用可以用来提供一些信息,通常用于供D L L进行每个进程或线程的初始化和清除操作。如果你的D L L不需要这些通知信息,就不必在D L L源代码中实现这个函数。例如,如果你创建一个只包含资源的D L L,就不必实现该函数。如果确实需要在D L L中接受通知信息,可以实现类似下面的进入点函数:
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, PVOID fImpLoad) { switch(fdwReason) { case DLL_PROCESS_ATTACH: //The DLL is being mapped into the process's address space. break; case DLL_THREAD_ATTACH: //A thread is being created. break; case DLL_THREAD_DETACH: //A thread is exiting cleanly. break; case DLL_PROCESS_DETACH: //The DLL is being unmapped from the process's address space. break; } return(TRUE); // Used only for DLL_PROCESS_ATTACH }
参数h i n s t D l l包含了D L L的实例句柄。与( w ) Wi n M a i n函数的h i n s t E x e参数一样,这个值用于标识D L L的文件映像被映射到进程的地址空间中的虚拟内存地址。通常应将这个参数保存在一个全局变量中,这样就可以在调用加载资源的函数(如D i a l o g B o x和L o a d S t r i n g)时使用它。最后一个参数是f I m p L o a d,如果D L L是隐含加载的,那么该参数将是个非0值,如果D L L是显式加载的,那么它的值是0。
参数f d w R e a s o n用于指明系统为什么调用该函数。该参数可以使用4个值中的一个。这4个值是: D L L _ P R O C E S S _ AT TA C H、D L L _ P R O C E S S _ D E TA C H、D L L _ T H R E A D _ AT TA C H或D L L _ T H R E A D _ D E TA C H。这些值将在下面介绍。
注意必须记住,D L L使用D l l M a i n函数来对它们进行初始化。当你的D l l M a i n函数执行时,同一个地址空间中的其他D L L可能尚未执行它们的D l l M a i n函数。这意味着它们尚未初始化,因此你应该避免调用从其他D L L中输入的函数。此外,你应该避免从D l l M a i n内部调用L o a d L i b r a r y ( E x )和F r e e L i b r a r y函数,因为这些函数会形式一个依赖性循环。
Platform SDK文档说,你的D l l M a i n函数只应该进行一些简单的初始化,比如设置本地存储器(第2 1章介绍),创建内核对象和打开文件等。你还必须避免调用U s e r、S h e l l、O D B C、C O M、R P C和套接字函数(即调用这些函数的函数),因为它们的D L L也许尚未初始化,或者这些函数可能在内部调用L o a d L i b r a r y ( E x )函数,这同样会形成一个依赖性循环。
另外,如果创建全局性的或静态的C + +对象,那么应该注意可能存在同样的问题,因为在你调用D l l M a i n函数的同时,这些对象的构造函数和析构函数也会被调用。
20.2.1 DLL_PROCESS_ATTACH通知
当D L L被初次映射到进程的地址空间中时,系统将调用该D L L的D l l M a i n函数,给它传递参数f d w R e a s o n的值D L L _ P R O C E S S _ AT TA C H。只有当D L L的文件映像初次被映射时,才会出现这种情况。如果线程在后来为已经映射到进程的地址空间中的D L L调用L o a d L i b r a r y ( E x )函数,那么操作系统只是递增D L L的使用计数,它并不再次用D L L _ P R O C E S S _ AT TA C H的值来调用D L L的D l l M a i n函数。
当处理D L L _ P R O C E S S _ AT TA C H时,D L L应该执行D L L中的函数要求的任何与进程相关的初始化。例如, D L L可能包含需要使用它们自己的堆栈(在进程的地址空间中创建)的函数。通过在处理D L L _ P R O C E S S _ AT TA C H通知时调用H e a p C r e a t e函数,该D L L的D l l M a i n函数就能够创建这个堆栈。已经创建的堆栈的句柄可以保存在D L L函数有权访问的一个全局变量中。
当D l l M a i n处理一个D L L _ P R O C E S S _ AT TA C H通知时,D l l M a i n的返回值能够指明D L L的初始化是否已经取得成功。如果对H e a p C r e a t e的调用取得了成功,D l l M a i n应该返回T R U E。如果堆栈不能创建,它应该返回FA L S E 。如果f d w R e a s o n 使用的是其他的值,即D L L _P R O C E S S _ D E TA C H、D L L _ T H R E A D _ AT TA C H和D L L _ T H R E A D _ D E TA C H,那么系统将忽略D l l M a i n返回的值。
当然,系统中的有些线程必须负责执行D l l M a i n函数中的代码。当一个新线程创建时,系统将分配进程的地址空间,然后将. e x e文件映像和所有需要的D L L文件映像映射到进程的地址空间中。然后它创建进程的主线程,并使用该线程调用每个D L L的带有D L L _ P R O C E S S _AT TACH 值的D l l M a i n函数。当已经映射的所有D L L都对通知信息作出响应后,系统将使进程的主线程开始执行可执行模块的C / C + +运行期启动代码,然后执行可执行模块的进入点函数(m a i n、w m a i n、Wi n M a i n或w Wi n M a i n)。如果D L L的任何一个D l l M a i n函数返回FA L S E,指明初始化没有取得成功,系统便终止整个进程的运行,从它的地址空间中删除所有文件映像,给用户显示一个消息框,说明进程无法启动运行。Windows 2000的这个消息框如图2 0 - 2所示,再下面是Windows 98的消息框(见图2 0 - 3)。
图20-2 Windows 2000下显示的消息框
图20-3 Windows 98下显示的消息框
下面让我们来看一看D L L被显式加载时的情况。当进程中的一个线程调用L o a d L i b r a r y ( E x )时,系统会找出特定的D L L ,并将它映射到进程的地址空间中。然后,系统使用调用L o a d L i b r a r y ( E x )的线程,调用D L L的带有D L L _ P R O C E S S _ AT TACH 值的D l l M a i n函数。当D L L的D l l M a i n函数处理了通知消息后,系统便允许调用的L o a d L i b r a r y ( E x )函数返回,同时该线程像平常一样继续进行处理。如果D l l M a i n函数返回FA L S E,指明初始化没有取得成功,那么系统就自动从进程的地址空间中卸载D L L的文件映像,而对L o a d L i b r a r y ( E x )的调用则返回N U L L。
20.2.2 DLL_PROCESS_DETACH通知
D L L从进程的地址空间中被卸载时,系统将调用D L L的D l l M a i n函数,给它传递f d w R e a s o n的值D L L _ P R O C E S S _ D E TA C H。当D L L处理这个值时,它应该执行任何与进程相关的清除操作。例如, D L L可以调用H e a p D e s t r o y函数来撤消它在D L L _ P R O C E S S _ D E TA C H通知期间创建的堆栈。注意,如果D l l M a i n函数接收到D L L _ P R O C E S S _ D E TA C H通知时返回FA L S E,那么D l l M a i n就不是用D L L _ P R O C E S S _ D E TA C H通知调用的。如果因为进程终止运行而使D L L被卸载,那么调用E x i t P r o c e s s函数的线程将负责执行D l l M a i n函数的代码。在正常情况下,这是应用程序的主线程。当你的进入点函数返回到C / C + +运行期库的启动代码时,该启动代码将显式调用E x i t P r o c e s s函数,终止进程的运行。
如果因为进程中的线程调用F r e e L i b r a r y或F r e e L i b r a r y A n d E x i t T h r e a d函数而将D L L卸载,那么调用函数的线程将负责执行D l l M a i n函数的代码。如果使用F r e e L i b r a r y,那么要等到D l l M a i n函数完成对D L L _ P R O C E S S _ D E TA C H通知的执行后,该线程才从对F r e e L i b r a r y函数的调用中返回。
注意,D L L能够阻止进程终止运行。例如,当D l l M a i n接收到D L L _ P R O C E S S _ D E TA C H通知时,它就会进入一个无限循环。只有当每个D L L都已完成对D L L _ P R O C E S S _ D E TA C H通知的处理时,操作系统才会终止该进程的运行。
注意如果因为系统中的某个线程调用了Te r m i n a t e P r o c e s s而使进程终止运行,那么系统将不调用带有D L L _ P R O C E S S _ D E TA C H值的D L L的D l l M a i n函数。这意味着映射到进程的地址空间中的任何D L L都没有机会在进程终止运行之前执行任何清除操作。这可能导致数据的丢失。只有在迫不得已的情况下,才能使用Te r m i n a t e P r o c e s s函数。
图2 0 - 4显示了线程调用L o a d L i b r a r y时执行的操作步骤。图2 0 - 5显示了线程调用F r e e L i b r a r y函数时执行的操作步骤。
图20-4 线程调用L o a d L i b r a r y时系统执行的操作步骤
图20-5 线程调用F r e e L i b r a r y时系统执行的操作步骤
20.2.3 DLL_THREAD_ATTACH通知
当在一个进程中创建线程时,系统要查看当前映射到该进程的地址空间中的所有D L L文件映像,并调用每个文件映像的带有D L L _ T H R E A D _ AT TA C H值的D l l M a i n函数。这可以告诉所有的D L L执行每个线程的初始化操作。新创建的线程负责执行D L L的所有D l l M a i n函数中的代码。只有当所有的D L L都有机会处理该通知时,系统才允许新线程开始执行它的线程函数。
当一个新D L L被映射到进程的地址空间中时,如果该进程内已经有若干个线程正在运行,那么系统将不为现有的线程调用带有D L L _ T H R E A D _ AT TA C H值的DDL 的D l l M a i n函数。只有当新线程创建时D L L被映射到进程的地址空间中,它才调用带有D L L _ T H R E A D _ AT TA C H值的D L L的D l l M a i n函数。
另外要注意,系统并不为进程的主线程调用带有D L L _ T H R E A D _ AT TA C H值的任何D l l M a i n 函数。进程初次启动时映射到进程的地址空间中的任何D L L 均接收D L L _P R O C E S S _ AT TA C H通知,而不是D L L _ T H R E A D _ AT TA C H通知。
20.2.4 DLL_THREAD_DETACH通知
让线程终止运行的首选方法是使它的线程函数返回。这使得系统可以调用E x i t T h r e a d来撤消该线程。E x i t T h r e a d函数告诉系统,该线程想要终止运行,但是系统并不立即将它撤消。相反, 它要取出这个即将被撤消的线程, 并让它调用已经映射的D L L 的所有带有D L L _ T H R E A D _ D E TACH 值的D l l M a i n函数。这个通知告诉所有的D L L执行每个线程的清除操作。例如,D L L版本的C / C + +运行期库能够释放它用于管理多线程应用程序的数据块。
注意, D L L能够防止线程终止运行。例如,当D l l M a i n函数接收到D L L _ T H R E A D _D E TA C H通知时,它就能够进入一个无限循环。只有当每个D L L已经完成对D L L _ T H R E A D _D E TA C H通知的处理时,操作系统才会终止线程的运行。
注意如果因为系统中的线程调用Te r m i n a t e T h r e a d函数而使该线程终止运行,那么系统将不调用带有D L L _ T H R E A D _ D E TA C H值的D L L的所有D l l M a i n函数。这意味着映射到进程的地址空间中的任何一个D L L都没有机会在线程终止运行之前执行任何清除操作。这可能导致数据的丢失。与Te r m i n a t e P r o c e s s一样,只有在迫不得已的时候,才可以使用Te r m i n a t e T h r e a d函数。
如果当D L L被撤消时仍然有线程在运行,那么就不为任何线程调用带有D L L _ T H R E A D _D E TA C H值的D l l M a i n。可以在进行D L L _ T H R E A D _ D E TA C H的处理时查看这个情况,这样就能够执行必要的清除操作。
上述规则可能导致发生下面这种情况。当进程中的一个线程调用L o a d L i b r a r y来加载D L L时,系统就会调用带有D L L _ P R O C E S S _ AT TA C H值的D L L的D l l M a i n函数(注意,没有为该线程发送D L L _ T H R E A D _ AT TA C H通知)。接着,负责加载D L L的线程退出,从而导致D L L的D l l M a i n函数被再次调用,这次调用时带有D L L _ T H R E A D _ D E TA C H值。注意,D L L得到通知说,该线程将被撤消,尽管它从未收到D L L _ T H R E A D _ AT TA C H的这个通知,这个通知告诉该库说线程已经附加。由于这个原因,当执行任何特定的线程清除操作时,必须非常小心。不过大多数程序在编写时就规定调用L o a d L i b r a r y的线程与调用F r e e L i b r a r y的线程是同一个线程。
20.2.5 顺序调用DllMain
系统是顺序调用D L L的D l l M a i n函数的。为了理解这样做的意义,可以考虑下面这样一个环境。假设一个进程有两个线程,线程A和线程B。该进程还有一个D L L,称为S o m e D L L . d l l,它被映射到了它的地址空间中。两个线程都准备调用C r e a t e T h r e a d函数,以便再创建两个线程,即线程C和线程D。
当线程A调用C r e a t e T h r e a d来创建线程C时,系统调用带有D L L _ T H R E A D _ AT TA C H值的S o m e D L L . d l l的D l l M a i n函数。当线程C执行D l l M a i n函数中的代码时,线程B调用C r e a t e T h r e a d函数来创建线程D。这时系统必须再次调用带有D L L _ T H R E A D _ AT TA C H值的D l l M a i n函数,这次是让线程D 执行代码。但是,系统是顺序调用D l l M a i n函数的,因此系统会暂停线程D的运行,直到线程C完成对D l l M a i n函数中的代码的处理并且返回为止。
当线程C完成D l l M a i n的处理后,它就开始执行它的线程函数。这时系统唤醒线程D,让它处理D l l M a i n中的代码。当它返回时,线程D开始处理它的线程函数。
通常情况下,根本不会考虑到D l l M a i n的这个顺序操作特性。我曾经遇到过一个人,他的代码中有一个D l l M a i n顺序操作带来的错误。他创建的代码类似下面的样子:
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, PVOID fImpLoad) { HANDLE hThread; DWORD dwThreadId; switch(fdwReason) { case DLL_PROCESS_ATTACH: // The DLL is being mapped into the process's address space. // Create a thread to do some stuff. hThread = CreateThread(NULL, 0, SomeFunction, NULL, 0, &dwThreadId); // Suspend our thread until the new thread terminates. WaitForSingleObject(hThread, INFINITE); // We no longer need access to the new thread. CloseHandle(hThread); break; case DLL_THREAD_ATTACH: // A thread is being created. break; case DLL_THREAD_DETACH: // A thread is exiting cleanly. break; case DLL_PROCESS_DETACH: // The DLL is being unmapped from the process's address space. break; } return(TRUE); }
当我刚刚开始考虑如何解决这个问题的时候,我发现了D i s a b l e T h r e a d L i b r a r y C a l l s函数:
BOOL DisableThreadLibraryCalls(HINSTANCE hinstDll);
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, PVOID fImpLoad) { HANDLE hThread; DWORD dwThreadId; switch(fdwReason) { case DLL_PROCESS_ATTACH: // The DLL is being mapped into the process's address space. // Prevent the system from calling DllMain // when threads are created or destroyed. DisableThreadLibraryCalls(hinstDll); // Create a thread to do some stuff. hThread = CreateThread(NULL, 0, SomeFunction, NULL, 0, &dwThreadId); // Suspend our thread until the new thread terminates. WaitForSingleObject(hThread, INFINITE); // We no longer need access to the new thread. CloseHandle(hThread); break; case DLL_THREAD_ATTACH: // A thread is being created. break; case DLL_THREAD_DETACH: // A thread is exiting cleanly. break; case DLL_PROCESS_DETACH: // The DLL is being unmapped from the process's address space. break; } return(TRUE); }
当C r e a t e T h r e a d函数被调用时,系统首先创建线程的内核对象和线程的堆栈。然后它在内部调用Wa i t F o r S i n g l e O b j e c t函数,传递进程的互斥对象的句柄。一旦新线程拥有该互斥对象,系统就让新线程用D L L _ T H R E A D _ AT TA C H的值调用每个D L L的D l l M a i n函数。只有在这个时候,系统才调用R e l e a s e M u t e x,释放对进程的互斥对象的所有权。由于系统采用这种方式来运行,因此添加对D i s a b l e T h r e a d L i b r a r y C a l l s的调用,并不会防止线程被暂停运行。防止线程被暂停运行的唯一办法是重新设计这部分源代码,使得Wa i t F o r S i n g l e O b j e c t不会在任何D L L的D l l M a i n函数中被调用。
20.2.6 DllMain与C/C++运行期库
在上面介绍的D l l M a i n函数中,我假设你使用M i c r o s o f t的Visual C++编译器来创建你的D L L。当编写一个D L L时,你需要得到C / C + +运行期库的某些初始帮助。例如,如果你创建的D L L包含一个全局变量,而这个全局变量是个C + +类的实例。在你顺利地在D l l M a i n函数中使用这个全局变量之前,该变量必须调用它的构造函数。这是由C / C + +运行期库的D L L启动代码来完成的。
当你链接你的D L L时,链接程序将D L L的进入点函数嵌入产生的D L L文件映像。可以使用链接程序的/ E N T RY开关来设定该函数的地址。按照默认设置,当使用M i c r o s o f t的链接程序并且设定/ D L L开关时,链接程序假设进入点函数称为_ D l l M a i n C RT S t a r t u p。该函数包含在C / C + +运行期的库文件中,并且在你链接D L L时它被静态链接到你的D L L文件的映像中(即使你使用D L L版本的C / C + +运行期库,该函数也是静态链接的)。
当你的D L L文件映像被映射到进程的地址空间中时,系统实际上是调用_ D l l M a i n C RTS t a r t u p函数,而不是调用D l l M a i n函数。_ D l l M a i n C RT S t a r t u p函数负责对C / C + +运行期库进行初始化,并且确保在_ D l l M a i n C RT S t a r t u p收到D L L _ P R O C E S S _ AT TA C H通知时创建任何全局或静态C + +对象。当执行任何C / C + +运行期初始化时, _ D l l M a i n C RT S t a r t u p函数将调用你的D l l M a i n函数。
当D L L收到D L L _ P R O C E S S _ D E TA C H通知时,系统再次调用_ D l l M a i n C RT S t a r t u p函数。这次该函数调用你的D l l M a i n函数,当D l l M a i n返回时,_ D l l M a i n C RT S t a r t u p就为D L L中的任何全局或静态C + +对象调用析构函数。当_ D l l M a i n C RT S t a r t u p收到D L L _ T H R E A D _ AT TA C H通知时,_ D l l M a i n C RT S t a r t u p函数并不执行任何特殊的处理操作。但是对于D L L _ T H R E A D _ D E TA C H来说,C / C + +运行期将释放线程的t i d d a t a内存块(如果存在这样的内存块的话)。但是,通常情况下,这个t i d d a t a 内存块是不应该存在的,因为编写正确的线程函数将返回到内部调用_ e n d t h r e a d e x的C / C + +运行期的_ t h r e a d s t a r t e x函数(第6章已经介绍),它负责在线程试图调用E x i t T h r e a d之前释放内存块。
然而,让我们看一看这样一种情况,即用P a s c a l编写的应用程序调用D L L中用C / C + +编写的函数。在这种情况下, P a s c a l应用程序创建了一个线程,并且不使用_ b e g i n t h r e a d e x。因此线程对C / C + +运行期库的情况一无所知。这时线程调用D L L中的一个函数,该函数又调用一个C运行期函数。当你再次调用该函数时, C运行期函数为该线程创建一个t i d d a t a内存块,并且在创建过程中将它与线程关联起来。这意味着P a s c a l应用程序能够创建成功地调用C运行期函数的线程。当用P a s c a l编写的线程函数返回时, E x i t T h r e a d被调用。C / C + +运行期库的D L L收到D L L _ T H R E A D _ D E TA C H通知,并释放t i d d a t a内存块,这样就不会出现任何内存泄漏。这确实是个非常出色的思路。
前面讲过,不必在D L L源代码中实现D l l M a i n函数。如果你并不拥有自己的D l l M a i n函数,可以使用C / C + +运行期库的D l l M a i n函数的实现代码,它类似下面的形式(如果静态链接到C / C + +运行期库的话):
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, PVOID fImpLoad) { if(fdwReason == DLL_PROCESS_ATTACH) DisableThreadLibraryCalls(hinstDll); return(TRUE); }
Microsoft Visual C++ 6.0提供了一个出色的新特性,它能够使D L L的操作变得更加容易。这个特性称为延迟加载D L L。延迟加载的D L L是个隐含链接的D L L,它实际上要等到你的代码试图引用D L L中包含的一个符号时才进行加载。延迟加载的D L L在下列情况下是非常有用的:
• 如果你的应用程序使用若干个D L L,那么它的初始化时间就比较长,因为加载程序要将所有需要的D L L映射到进程的地址空间中。解决这个问题的方法之一是在进程运行的时候分开加载各个D L L。延迟加载的D L L能够更容易地完成这样的加载。
• 如果调用代码中的一个新函数,然后试图在老版本的系统上运行你的应用程序,而该系统中没有该函数,那么加载程序就会报告一个错误,并且不允许该应用程序运行。你需要一种方法让你的应用程序运行,然后,如果(在运行时)发现该应用程序在老的系统上运行,那么你将不调用遗漏的函数。例如,一个应用程序在Windows 2000上运行时想要使用P S A P I函数,而在Windows 98上运行想要使用To o l H e l p函数(比如P r o c e s s 3 2 N e x t)。当该应用程序初始化时,它调用G e t Ve r s i o n E x函数来确定主操作系统,并正确地调用相应的其他函数。如果试图在Windows 98上运行该应用程序,就会导致加载程序显示一条错误消息,因为Windows 98上并不存在P S A P I . d l l模块。同样,延迟加载的D L L能够使你非常容易地解决这个问题。
我花费了相当多的时间来检验Visual C++ 6.0中的延迟加载D L L特性,必须承认,M i c r o s o f t在实现这个特性方面做了非常出色的工作。它提供了许多特性,并且在Windows 98 和Windows 2000上运行得都很好。
下面让我们从比较容易的操作开始介绍,也就是使延迟加载D L L能够运行。首先,你象平常那样创建一个D L L。也要象平常那样创建一个可执行模块,但是必须修改两个链接程序开关,并且重新链接可执行模块。下面是需要添加的两个链接程序开关:
/Lib:DelayImp.lib /DelayLoad:MyDll.dll
• 从可执行模块的输入节中删除M y D l l . d l l,这样,当进程被初始化时,操作系统的加载程序就不会显式加载D L L。
• 将新的Delay Import(延迟输入)节(称为. d i d a t a)嵌入可执行模块,以指明哪些函数正在从M y D l l . d l l输入。
• 通过转移到对- - d e l a y L o a d H e l p e r函数的调用,转换到对延迟加载函数的调用。
当应用程序运行时,对延迟加载函数的调用实际上是对- - d e l a y L o a d H e l p e r函数的调用。该函数引用特殊的Delay Import节,并且知道调用L o a d L i b r a r y之后再调用G e t P r o c A d d r e s s。一旦获得延迟加载函数的地址, - - d e l a y L o a d H e l p e r就要安排好对该函数的调用,这样,将来的调用就会直接转向对延迟加载函数的调用。注意,当第一次调用同一个D L L中的其他函数时,必须对它们做好安排。另外,可以多次设定/ d e l a y L o a d链接程序的开关,为想要延迟加载的每个D L L设定一次开关。
好了,整个操作过程就这么简单。但是还应该考虑另外两个问题。通常情况下,当操作系统的加载程序加载可执行模块时,它将设法加载必要的D L L。如果一个D L L无法加载,那么加载程序就会显示一条错误消息。如果是延迟加载的D L L,那么在进行初始化时将不检查是否存在D L L。如果调用延迟加载函数时无法找到该D L L,- - d e l a y L o a d H e l p e r函数就会引发一个软件异常条件。可以使用结构化异常处理(S E H)方法来跟踪该异常条件。如果不跟踪该异常条件,那么你的进程就会终止运行(S E H将在第2 3、2 4和2 5章中介绍)。
当- - d e l a y L o a d H e l p e r确实找到你的D L L,但是要调用的函数不在该D L L中时,将会出现另一个问题。比如,如果加载程序找到一个老的D L L版本,就会发生这种情况。在这种情况下,- - d e l a y L o a d H e l p e r也会引发一个软件异常条件,对这个软件异常条件的处理方法与上面相同。下一节介绍的示例应用程序显示了如何正确地编写S E H代码以便处理这些错误。
你会发现代码中有许多其他元素,这些元素与S E H和错误处理毫无关系。但是这些元素与你使用延迟加载的D L L时可以使用的辅助特性有关。下面将要介绍这些特性。如果你不使用更多的高级特性,可以删除这些额外的代码。
如你所见, Visual C++ 开发小组定义了两个软件异常条件代码,即V c p p E x c e p t i o n( E R R O R _ S E V E R I T Y _ E R R O R、E R R O R _ M O D _ N O T _ F O U N D )和V c p p E x c e p t i o n(E R R O R _S E V E R I T Y _ E R R O R、E R R O R _ P R O C _ N O T _ F O U N D)。这些代码分别用于指明D L L模块没有找到和函数没有找到。我的异常过滤函数D e l a y L o a d D l l E x c e p t i o n F i l t e r用于查找这两个异常代码。如果两个代码都没有找到,过滤函数将返回E X C E P T I O N _ C O N T I N U E _ S E A R C H,这与任何出色的过滤函数返回的值是一样的(对于你不知道如何处理的异常代码,请不要随意删除)。但是如果这两个代码中的一个已经找到,那么- - d e l a y L o a d H e l p e r函数将提供一个指向包含某些辅助信息的D e l a y L o a d I n f o结构的指针。在Visual C++的D e l a y I m p . h文件中,D e l a y L o a d I n f o结构定义为下面的形式:
typedef struct DelayLoadInfo { DWORD cb; // Size of structure PCImgDelayDescr pidd; // Raw data (everything is there) FARPROC ppfn; // Points to address of function to load LPCSTR szDll; // Name of dll DelayLoadProc dlp; // Name or ordinal of procedure HMODULE hmodCur; // hInstance of loaded library FARPROC pfnCur; // Actual function that will be called DWORD dwLastError;// Error received } DelayLoadInfo, *PDelayLoadInfo;
typedef struct DelayLoadProc { BOOL fImportByName; union { LPCSTR szProcName; DWORD dwOrdinal; }; } DelayLoadProc;
在其余的成员中,c b用于确定版本,p i d d指向嵌入模块中包含延迟加载的D L L和函数的节,p p f n是函数找到时,函数的地址应该放入的地址。最后两个成员供- - d e l a y L o a d H e l p e r函数内部使用。它们有着超高级的用途,现在还没有必要观察或者了解这两个成员。
到现在为止,已经讲述了如何使用延迟加载的D L L和正确解决错误条件的基本方法。但是M i c r o s o f t的延迟加载D L L的实现代码超出了迄今为止我已讲述的内容范围。比如,你的应用程序能够卸载延迟加载的D L L。假如你的应用程序需要一个特殊的D L L来打印一个文档,那么这个D L L就非常适合作为一个延迟加载的D L L,因为大部分时间它是不用的。不过,如果用户选择了P r i n t命令,你就可以调用该D L L中的一个函数,然后它就能够自动进行D L L的加载。这确实很好,但是,当文档打印后,用户可能不会立即打印另一个文档,因此可以卸载这个D L L,释放系统的资源。如果用户决定打印另一个文档,那么D L L就可以根据用户的要求再次加载。
若要卸载延迟加载的D L L,必须执行两项操作。首先,当创建可执行文件时,必须设定另一个链接程序开关( / d e l a y : u n l o a d)。其次,必须修改源代码,并且在你想要卸载D L L时调用- -F U n l o a d D e l a y L o a d e d D L L函数:
BOOL __FUnloadDelayLoadedDLL(PCSTR szDll);
下面要指出一些重要的问题。首先,千万不要自己调用F r e e L i b r a r y来卸载D L L,否则函数的地址将不会被清除,这样,当下次试图调用D L L中的函数时,就会导致访问违规。第二,当调用- - F U n l o a d D e l a y L o a d e d D l l时,传递的D L L名字不应该包含路径,名字中的字母必须与你将D L L名字传递给/ D e l a y L o a d链接程序开关时使用的字母大小写相同,否则, - - F U n l o a dD e l a y L o a d e d D l l的调用将会失败。第三,如果永远不打算卸载延迟加载的D L L,那么请不要设定/ D e l a y : u n l o a d链接程序开关,并且你的可执行文件的长度应该比较小。最后,如果你不从用/ D e l a y : u n l o a d开关创建的模块中调用- - F U n l o a d D e l a y L o a d e d D l l,那么什么也不会发生, - -F U n l o a d D e l a y L o a d e d D l l什么操作也不执行,它将返回FA L S E。
延迟加载的D L L具备的另一个特性是,按照默认设置,调用的函数可以与一些内存地址相链接,在这些内存地址上,系统认为函数将位于一个进程的地址中(本章后面将介绍链接的问题)。由于创建可链接的延迟加载的D L L节会使你的可执行文件变得比较大,因此链接程序也支持一个/ D e l a y : n o b i n d开关。因为人们通常都喜欢进行链接,因此大多数应用程序不应该使用这个链接开关。
延迟加载的D L L的最后一个特性是供高级用户使用的,它真正显示了M i c r o s o f t的注意力之所在。当- - d e l a y L o a d H e l p e r函数执行时,它可以调用你提供的挂钩函数。这些函数将接收- -d e l a y L o a d H e l p e r函数的进度通知和错误通知。此外,这些函数可以重载D L L如何加载的方法以及如何获取函数的虚拟内存地址的方法。
若要获得通知或重载的行为特性,必须对你的源代码做两件事情。首先必须编写类似清单2 0 - 1所示的D l i H o o k函数那样的挂钩函数。D l i H o o k框架函数并不影响- - d e l a y L o a d H e l p e r函数的运行。若要改变它的行为特性,可启动D l i H o o k函数,然后根据需要对它进行修改。接着将函数的地址告诉- - d e l a y L o a d H e l p e r。
在D e l a y I m p . l i b静态链接库中,定义了两个全局变量,即- - p f n D l i N o t i f y H o o k和- -p f n D l i F a i l u r e H o o k。这两个变量均属于p f n D l i H o o k类型:
typedef FARPROC(WINAPI *PfnDliHook)( unsigned dliNotify, PDelayLoadInfo pdli);
PfnDliHook __pfnDliNotifyHook = DliHook; PfnDliHook __pfnDliFailureHook = DliHook;
Visual C++ 6.0的延迟加载D L L的新特性非常出色,许多编程人员几年前就希望使用这个特性。可以想像许多应用程序(尤其是M i c r o s o f t的应用程序)都将充分利用这个特性。
D e l a y L o a d A p p示例应用程序
清单2 0 - 1中列出的D e l a y L o a d A p p应用程序(“20 DelayLoadApp.exe”)显示了在充分利用延迟加载D L L时应该做的所有工作。为了演示的需要,必须使用一个简单的的D L L,它的代码位于2 0 - D e l a y L o a d L i b目录中。
由于该应用程序加载了“ 20 DelayLoadLib”模块,因此当运行该应用程序时,加载程序不必将该模块映射到进程的地址空间中。在该应用程序中,我定期调用I s M o d u l e L o a d e d函数。该函数只是用来显示一个消息框,通知是否有一个模块加载到了进程的地址空间中。当该应用程序初次启动运行时,“20 DelayLoadLib”模块尚未加载,因此出现图2 0 - 6所示的消息框。
然后该应用程序调用从D L L输入的一个函数,这使得_ _ d e l a y L o a d H e l p e r函数能够自动加载该D L L。当该函数返回时,便出现图2 0 - 7所示的消息框。
图20-6 DelayLoadApp显示“20 DelayLoadLib”模块尚未加载
图20-7 DelayLoadApp显示“20 De l a y L o a d L i b”模块已经加载
当这个消息框关闭时,D L L中的另一个函数被调用。由于该函数是在同一个D L L中,因此该D