第20章 DLL高级技术
20.1 DLL模块的显式载入和符号链接
为了调用DLL中一个函数,必须将DLL的文件映像映射到调用线程所在进程的地址空间中。两种方式:第一种直接让应用程序的源代码引用DLL中所包含的符号,这使得加载程序会在应用程序运行时隐式载入并链接所需DLL(系统控制,需要时自动加载,控制性差)。第二种是让应用程序在运行时,显式载入所需DLL并显式与想要的输出符号进行连接(自己加载,自己卸载,可控性强,胜在高效,可控制)。
1) 显式载入DLL模块
HMOUDLE LoadLibrary(PCTSTR pszDLLPathName);
HMOUDLE LoadLibraryEx(PCTSTR pszDLLPathName, HANDLE hFile, DWORD dwFlags);
返回值HMOUDLE表示文件映像被映射到的虚拟内存地址。HMOUDLE类型等价于HINSTANCE。
2) 显式卸载DLL模块
BOOL FreeLibrary(HMOUDLE hInstDll);
VOID FreeLibraryAndExitThread(HMOUDLE hInstDll, DWORD dwExitCode);
这个函数在kernel32.dll中被实现如下:
VOID FreeLibraryAndExitThread(HMOUDLE hInstDll, DWORD dwExitCode)
{ FreeLibrary(hInstDll);
ExitThread(dwExitCode);
}此函数的意义:
若我们正编写一个DLL,在一开始被映射到进程的地址空间中时,这个DLL会创建一个进程。当线程完成了它的工作后,可先后调用FreeLibrary和ExitThread,来从进程地址空间中撤销对DLL的映射并终止进程。但若分别调用FreeLibrary和ExitThread,则会出现一个严重问题:FreeLibrary会立即从进程地址空间中撤销对DLL的映射。当FreeLibrary调用返回后,ExitThread的代码已经不复存在了。线程试图执行的是不存在的代码,将引发访问违规,并导致整个进程被终止。
但,若线程调用FreeLibraryAndExitThread,则此函数会调用FreeLibrary,这使得对DLL的映射会立即撤销。要执行的下一条指令仍在kernel32.dll中,而不是在已经被撤销映射的DLL中。意味着线程可继续执行并调用ExitThread。ExitThread会使线程终止且不再返回。
关于DLL调用次数:实际上每个DLL在进程中有一个与之对应的使用计数,LoadLibrary和LoadLibraryEx递增该使用计数,而FreeLibrary和FreelibraryAndExitThread会递减该使用计数。第一次调用LoadLibrary载入一个DLL时,系统会将DLL的文件映像映射到调用进程的地址空间中,并将DLL的使用计数设为1.若同一个进程中的一个线程后来再调用Loadlibrary来载入同一个DLL文件映像时,系统不会再次将DLL文件映像映射到进程地址空间中。它只是将进程中与该DLL对应的使用计数递增。当DLL使用计数变为0时,系统会从进程地址空间中撤销对该DLL文件映像的映射。撤销后再访问该DLL中函数,将引发访问违规。系统在每个进程中为每个DLL维护一个使用计数。
即便LoadLibrary和LoadLibraryEx载入的DLL是磁盘上同一个文件,也不能将他们返回的映射地址互换使用。若调用LoadLibraryEx时不用任何标志,则等价于LoadLibrary;否则,用两者来载入DLL所得到的句柄时不等价的。
3) 显式地链接到导出符号
一旦显式载入一个DLL模块,线程必须通过调用下面函数来得到它想引用的符号的地址:
FARPROC GetProcAddress(
HMOUDLE hInstDll, //通过LoadLibrary(Ex)或GetModuleHandle获得
PCSTR pszSymbolName);//第一种是用符号名来指定我们想要得到哪个符号的地址(即函数名)。注意类型不是PCTSTR,即只能接受ANSI字符串,因编译器/链接器都是将符号的名称以ANSI字符串形式保存在DLL的导出段中。第二种是用序号来指定符号地址,如:FARPROC pfn = GetProcAddress(hInstDll, MAKEINTRESOURCE(2));建议不用。
在使用GetProcAddress返回指针前,还需将它转为与函数签名相匹配的正确类型。如typedef int(*lpAddFun)(int, int);是与int Add (int, int)函数对应的函数的类型签名。则该:lpAddFun addFun = (lpAddFun)GetProcAddress(hDll, “Add”);
20.2DLL的入口点函数:
一个DLL可有一个入口点函数。接受一些通知消息,通常用来执行一些与线程或进程有关的初始化和清理工作,入口点函数非必需。入口点函数的实现:
BOOL WINAPI DllMain(HINSTANCE hInstDll, DWORD fdwReason, PVOID fImLoad) { 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_THREAD_DETACH: //The DLL is being unmapped from the process's address space. break; } return(TRUE); //Used only for DLL_PROCESS_ATTACK }
hInstDll:该DLL实例的句柄。值表示一个虚拟内存地址,DLL的文件映像就被映射到进程地址空间中的这个位置。通常将此参数保存在一个全局变量中,这样调用资源载入函数(如DialogBox和LoadString)时,就可使用它。若DLL是隐式载入的,则最后一个参数fImpLoad值将不为零;若DLL显式载入,则fImpLoad值为零。
fdwReason:表示系统调用入口点函数的原因。参数值为下列4个之一:DLL_PROCESS_ATTACH,DLL_THREAD_ATTACH,DLL_THREAD_DETACH,DLL_PROCESS_DETACH。
切记:DLL使用DllMain函数对自己初始化,只应执行简单的初始化,如设置线程局部存储区,创建内核对象,打开文件等等。应避免调用那些从其他DLL中导入的函数,避免调用User,Shell,ODBC,COM,RPC及套接字函数,因包含这些函数的DLL可能尚未初始化完毕;也应避免调用LoadLibrary(Ex)和FreeLibrary,这可能产生循环依赖。
20.2.1DLL_PROCESS_ATTACH通知
原理:仅当系统第一次将一个DLL映射到进程地址空间时,会调用DllMain函数,并在fdwReason中传入DLL_PROCESS_ATTACH。若之后一个线程再调用LoadLibrary(Ex)载入一个已被映射到地址空间的DLL,则OS仅递增该DLL的使用计数,而不会再次用DLL_PROCESS_ATTACH来调用DllMain。
调用结果:当DllMain处理DLL_PROCESS_ATTACH时,DllMain的返回值用来表示该DLL的初始化是否成功。若fdwReason是其他三个值时,系统忽略DllMain的返回值。
调用时机:创建新进程时,系统会分配进程地址空间并将.exe的文件映像以及所需DLL文件映像映射到进程的地址空间中。然后,系统将创建进程的主线程并用这个线程来调用每个DLL的DllMain函数,同时传入DLL_PROCESS_ATTACH。当所有已映射DLL都完成了对该通知的处理,系统会先让进程的主线程开始执行可执行模块的C/C++运行时启动代码,然后执行可执行模块的入口点函数(_tmain或_tWinMain)。若任何一个DLL的DllMain返回FALSE,即初始化失败,则系统会把所有的文件映像从地址空间清除,向用户显示一个消息框来告诉用户进程无法启动,然后终止整个进程。
显式载入DLL过程:进程调用LoadLibrary(Ex)时,系统对指定DLL进行定位,并将该DLL映射到进程地址空间中。然后系统会用调用LoadLibrary(Ex)的线程来调用DLL的DllMain函数,并传入DLL_PROCESS_ATTACH值。当DLL的DllMain处理完后,系统让LoadLibrary(Ex)返回,这样线程就可继续正常运行。若DllMain返回FALSE,系统会自动从进程地址空间中撤销对DLL文件映像的映射,并让LoadLibrary(Ex)返回NULL。
20.2.2DLL_PROCESS_DETACH通知
原理:当系统将一个DLL从进程地址空间中撤销映射时,会调用DLL的DllMain函数,并在fdwReason中传入DLL_PROCESS_DETACH。若DllMain在处理DLL_PROCESS_ATTACH时返回FALSE,则DllMain将不会收到DLL_PROCESS_DETACH通知(此时就没映射何来撤销映射)。
撤销DLL的原因:①若撤销映射的原因是进程要终止,则调用ExitProcess函数的线程将负责执行DllMain的代码。当入口点函数返回到C/C++运行时的启动代码后,启动代码会显式调用ExitProcess来终止进程。
②若撤销的原因是进程中一个线程调用了FreeLibrary或FreeLibraryAndExitThread,则发出调用的线程将执行DllMain中代码。若调用的是FreeLibrary,则DllMain处理完DLL_PROCESS_DETACH通知前,线程不会从该调用中返回。
注意:①DLL可能会阻碍进程的终止。如:若DllMain收到DLL_PROCESS_DETACH通知时,进入无限循环。仅当每个DLL都处理完DLL_PROCESS_DETACH后,OS才真正终止进程。
②若进程终止是因某线程调用了TerminateProcess,则系统不会用DLL_PROCESS_DETACH来调用DLL的DllMain函数。意味着进程终止前,已映射到进程的地址空间中的任何DLL将没有机会执行任何清理代码。
20.2.3DLL_THREAD_ATTACH通知
原理:进程创建一个线程时,系统会检查当前映射到进程的地址空间中的所有DLL文件映像,并用DLL_THREAD_ATTACH来调用每个DLL的DllMain函数。新创建的线程负责执行所有DLL的DllMain函数中的代码,当所有DLL都完成对该通知的处理后,系统才会让新线程开始执行它的线程函数。当系统将一个新的DLL映射到进程地址空间中时,若进程中已有多个线程在运行,则系统不会让任何已有线程用DLL_THREAD_ATTACH来调用该DLL的DllMain函数。
简单来说,即新创建的线程会调用已映射到进程地址空间中的DLL的DllMain函数;新映射到进程地址空间中的DLL的DllMain函数不会被任何已运行线程调用。
另:系统不会让进程的主线程用DLL_THREAD_ATTACH来调用DllMain函数。在进程创建时被映射到进程地址空间中的任何DLL会收到DLL_PROCESS_ATTACH通知,但不会收到DLL_THREAD_ATTACH通知。
20.2.4DLL_THREAD_DETACH通知
原理:让线程终止的首选方法是让其线程函数返回。这会使得系统自动调用ExitThread来终止线程。ExitThread告诉系统该线程想要终止,但系统不会立即终止该线程,而会让这个即将终止的线程用DLL_THREAD_DETACH来调用所有已映射到DLL的DllMain函数。
若撤销对一个DLL的映射时还有任何线程在运行,则系统不会让任何这些线程调用DLL_THREAD_DETACH来调用DllMain。
注意:①DLL可能会妨碍线程的终止。同DLL_PROCESS_DETACH。
②若线程终止是因系统中某个线程调用了TerminateThread,则系统不会用DLL_TREAHD_DETACH来调用所有DLL的DllMain函数。意味着线程终止前,已映射到进程地址空间中的任何DLL将没机会执行任何清理代码。
③还记得进程终止时,若DllMain处理DLL_PROCESS_ATTACH时返回FALSE,则DllMain将不会收到DLL_PROCESS_DETACH通知。但线程终止不存在此情况:进程中一个线程调用LoadLibrary载入一个DLL,这使得系统用DLL_PROCESS_ATTACH来调用该DLL的DllMain函数(注意线程不会得到DLL_THREAD_ATTACH通知)。接着,载入该DLL的线程退出,这使得系统再次调用DllMain函数-----但这次传入的是DLL_THREAD_DETACH。正因此,执行与线程相关的清理时必须及其小心。幸运的是,大多数程序中,调用LoadLibrary的线程与调用FreeLibrary的线程是同一个线程。
不要在DLL的DllMain函数中调用WaitForSingleObject参考P539:20.2.5。
20.3延迟载入:用于两种情况:①程序需要DLL太多,导致初始化太慢。②新系统的函数在旧系统中运行,可以延迟载入来根据实际系统判定使用哪个版本的函数。P542
20.4函数转发器:在第一个DLL中调用某函数,而该函数实际存在在第二个DLL中。通过函数转发器来实现。P553
20.5已知的DLL:OS对某些DLL进行了特殊处理,这些DLL称为已知的DLL。与其他DLL区别仅一个:OS在载入它们时总是在同一个目录中查找。
打开注册表:HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnowDLLs,会发现“数据”的值正好是“名称”加上.dll,并非必须这样。当调用LoadLibrary或LoadLibraryEx时,函数首先检查我们传入的DLL的名字是否包含”.dll”扩展名。若无,则按正常搜索规则搜索此DLL;若有,则会先去掉扩展名,再在KnownDLLs注册表项中搜索,看其中是否有与之相符的“名称”。若无,则使用正常的搜索规则;若有,则系统会查看与“名称”对应的“数据”,并试图用该“数据”值来载入DLL。系统还会从此注册表项的DllDirectory值所表示的目录中开始搜索DLL。此目录中找到就载入,找不到就返回NULL。
XP中截图:
举例说明:若在KnownDLLs中增加一项:
value name:SomeLib value data:SomeOtherLib.dll
LoadLibrary(_T(“SomeLib”));按正常搜索规则对此DLL进行定位。
LoadLibrary(_T(“SomeLib.lib”));在%SystemRoot%\system32中查找SomeOtherLib.dll。找到就载入,找不到就返回NULL。
20.6DLL重定向:自Windows2000系统开始新增了DLL重定向特性。这项特性强制OS的加载程序首先从应用程序的目录中载入模块。
用法:为使用此特性,需将一个文件放到应用程序的目录中。此文件内容不重要,但文件名必须是AppName.local。即若exe名为SuperApp.exe,则重定向文件名必须是SuperApp.exe.local。
除了创建一个.local文件,还可创建一个.local的文件夹,将自己的DLL保存在这个文件夹中,让Windows能轻易找到他们。
20.7模块的基地址重定向
每个EXE和DLL模块都有一个首选基地址(preferred base address),它表示在将模块映射到进程的地址空间中时的最佳内存地址。当构建一个可执行模块时,链接器会将模块的首选基地址设为0x00400000,对DLL来说,链接器会将首选基地址设为0x10000000.可用VS的DumpBin(加上/headers开关)来查看文件映像的首选基地址。
若一个EXE需要两个DLL,默认,链接器会将EXE模块的首选基地址设为0x04000000,并将第一个DLL的首选基地址设为0x10000000,加载程序必须对第二个DLL进行重定位,把它放到别的地方。当链接器在构建我们的模块时,会将重定位段嵌入到生成的文件中。若加载程序无法将模块载入到它的首选基地址,则系统会打开模块的重定位段并遍历其中所有的条目。
当模块无法被载入到它的首选基地址时,主要有两个确定:
1) 加载程序必须遍历重定位段并修改模块中大量代码。
2) 当加载程序写入到模块代码页面中时,系统的写时复制会强制将这些页面以系统的页交换文件为后备存储器。意味着系统必须在需要时将内存页面换出到系统的页交换文件,并将页交换文件中的页面换入到内存。
若将多个模块载入到同一个地址空间中,则必须给每个模块指定不同的首选基地址。三种方法:
1)VS的Configuration Properties\Linker\Advanced属性页,然后在Base Address一栏输入一个数值来指定首选基地址,比如0x02000000。首选基地址必须从分配粒度的边界开始,至今所有平台,系统的分配粒度都是64KB。
2)VS提供了一个Rebase.exe工具。推荐用此方法。等应用程序所有模块构建完后运行它。若用了Rebase,则会忽略方法1中的基地址设定。Rebase做了什么:
①它会模拟创建一个进程地址空间。
②它会打开应被载入到这个地址空间中的所有模块,并得到每个模块的大小及它们的首选基地址。
③它会在模拟的地址空间中对模块重定位的过程进行模拟,使各模块之间没有重叠。
④对每个重定位过的模块,它会解析该模块的重定位段,并修改模块在磁盘文件中的代码。
⑤为反映新的首选基地址,它会跟心每个重定位过的模块的文件头。
3) 通过调用ImageHlp API提供的ReBaseImage函数可实现自己的重定位工具。