第2 0章 D L L的高级操作技术
看了下这章的内容,谈不上高级,都是些常用相关,但是还是有一些细节需要注意。
20.1 DLL模块的显式加载和符号链接
如果线程需要调用D L L模块中的函数,那么D L L的文件映像必须映射到调用线程的进程地址空间中。可以用两种方法进行这项操作。第一种方法是让应用程序的源代码只引用 D L L中包含的符号。这样,当应用程序启动运行时,加载程序就能够隐含加载(和链接)需要的 D L L。
第二种方法是在应用程序运行时让应用程序显式加载需要的 D L L并且显式链接到需要的输出符号。换句话说,当应用程序运行时,它里面的线程能够决定它是否要调用 D L L中的函数。该线程可以将D L L显式加载到进程的地址空间,获得D L L中包含的函数的虚拟内存地址,然后使用该内存地址调用该函数。这种方法的优点是一切操作都是在应用程序运行时进行的。
20.1.1 显式加载D L L模块
无论何时,进程中的线程都可以决定将一个 D L L映射到进程的地址空间,方法是调用下面两个函数中的一个:
HINSTANCE LoadLibrary(PCTSTR pszPathName)
HINSTANCE LoadLibraryEx(
PCTSTR pszDLLPathName,
HANDLE hFile,
DWORD dwFlags);
这两个函数均用于找出用户系统上的文件映像(使用上一章中介绍的搜索算法),并设法将D L L的文件映像映射到调用进程的地址空间中。两个函数返回的 H I N S TA N C E值用于标识文件映像映射到的虚拟内存地址。如果 D L L不能被映射到进程的地址空间,则返回 N U L L。若要了解关于错误的详细信息,可以调用G e t L a s t E r r o r.
你会注意到,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 显式卸载D L L模块
当进程中的线程不再需要 D L L中的引用符号时,可以从进程的地址空间中显式卸载 D L L,方法是调用下面的函数:
BOOL FreeLibrary(HINSTANCE hinstDLL);
必须传递 H I N S TA N C E 值,以便标识要卸载的 D L L。该值是较早的时候调用
L o a d L i b r a r y ( E x )而返回的值。
也可以通过调用下面的函数从进程的地址空间中卸载D L L:
VOID FreeLibraryAndExitThread(
HINSTANCE hinstDll,
DWORD dwExitCode);
该函数是在K e r n e l 3 2 . d l l中实现的,如下所示:
VOID FreeLibraryAndExitThread(HINSTANCE hinstDll,DWORD dwExitCode){
FreeLibrary(hinstDll);
ExitThread(dwExitCode);
}
初看起来,这并不是个非常高明的代码,你可能不明白,为什么 M i c r o s o f t要创建
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就创建一个线程。当该线程完成它的操作时,它通过调用F r e e L i b r a r y函数,从进程的地址空间中卸载该 D L L,并且终止运行,然后立即调用E x i t T h r e a d。
但是,如果线程分开调用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使该线程终止运行并且不返回。
调用G e t M o d u l e H a n d l e函数,线程就能够确定D L L是否已经被映射到进程的地址空间中:
HINSTANCE hinstDll = GetModuleHandle(“MyLib”);
If(hinstDll == NULL){
hinstDll = LoadLibrary(“MyLib”);
}
如果只有 D L L的H I N S TA N C E值,那么可以调用 G e t M o d u l e F i l e N a m e函数,确定 D L L(或. e x e)的全路径名:
DWORD GetModuleFileName(
HINSTANCE hinstModule,
PTSTR pszPathName,
DWORD cchPath);
第一个参数是D L L(或. e x e)的H I N S TA N C E。第二个参数p s z P a t h N a m e是该函数将文件映像的全路径名放入的缓存的地址。第三参数c c h P a t h用于设定缓存的大小(以字符为计量单位)。
20.1.3 显式链接到一个输出符号
根据函数名字加载:
FARPOC pfn = GetProcAddress(hinstDll ,”SomeFuncInDll”);
根据函数编号加载:
FARPOC pfn = GetProcAddress(hinstDll ,MAKEINTRESOURCE(2));
这种用法假设你知道你需要的符号名被 D L L创建程序赋予了序号值2。同样,我要再次强调,M i c r o s o f t非常反对使用序号,因此你不会经常看到G e t P r o c A d d r e s s的这个用法。
这两种方法都能够提供包含在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编程时,并不完全理解这个行为特性,因此多次出现这样的错误。所以一定要小心(这个行为特性是应该避免使用序号而使用符号名的另一个原因)。
20.2 DLL的进入点函数
参数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_AT TA C H通知
当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返回的值。
20.2.2 DLL_PROCESS_DETA C H通知
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能够阻止进程终止运行。例如,当 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函数。
20.2.3 DLL_THREAD_AT TA C H通知
当在一个进程中创建线程时,系统要查看当前映射到该进程的地址空间中的所有 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_DETA C H通知
让线程终止运行的首选方法是使它的线程函数返回。这使得系统可以调用 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 顺序调用D l l M a i n
系统是顺序调用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开始处理它的线程函数。
看下面的常见错误(DllMain里创建线程导致死锁)
自己测试了一次有WaitForSingleObject会死锁,没有就不会死锁了。
当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.3 延迟加载D L L
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能够使你非常容易地解决这个问题。
20.4 函数转发器
函数转发器是D L L的输出节中的一个项目,用于将对一个函数的调用转至另一个 D L L中的另一个函数。例如,如果在Windows 2000 的K e r n e l 3 2 . d l l上运行Visual C++的D u m p B i n实用程序,那么将看到类似下面的一部分输出:
这个输出显示了4个转发函数。每当你的应用程序调用H e a p A l l o c、H e a p F r e e、H e a p R e A l l o c或H e a p S i z e时,你的可执行模块就会自动与K e r n e l 3 2 . d l l相链接。当激活你的可执行模块时,加载程序就加载 K e r n e l 3 2 . d l l并看到转发的函数实际上包含在 N T D L L . d l l中。然后它也加载N T D L L . d l l。当你的可执行模块调用 H e a p A l l o c时,它实际上调用的是 N T D L L . d l l中的R t l A l l o c a t e H e a p函数。系统中的任何地方都不存在H e a p A l l o c函数。
如果调用下面的函数,G e t P r o c A d d r e s s就会查看K e r n e l 3 2的输出节,发现H e a p A l l o c是个转发函数,然后按递归方式调用 G e t P r o c A d d r e s s函数,查找 N T D L L . d l l的输出节中的 R t l A l -l o c a t e H e a p。
这个p r a g m a告诉链接程序,被编译的 D L L应该输出一个名叫 S o m e F u n c的函数。但是S o m e F u n c函数的实现实际上位于另一个名叫 S o m e O t h e r F u n c的函数中,该函数包含在称为D l l Wo r k . d l l的模块中。必须为你想要转发的每个函数创建一个单独的 p r a g m a代码行。