Windows模块加载过程两步骤:
第一步是把EXE或DLL映射进内存。此时加载器查看模块的导入地址表(IAT)来判断这个模块是否依赖于其它DLL。如果它依赖的DLL还未被加载进那个进程,加载器也将它们映射进内存。这个过程递归进行,直到所有依赖的模块都被映射进内存。要查看一个可执行文件隐含依赖的所有DLL,最好的方法是使用Platform SDK附带的DEPENDS程序。
第二步是初始化所有DLL。在第一步中,当操作系统把EXE和DLL映射进内存时,它并不调用相应的初始化例程。初始化例程是在所有模块都被映射进内存之后才被调用的。关键是:DLL被映射进内存的顺序并不需要与它们被初始化的顺序一样。我曾经见到有人看到Developer Studio调试器中对DLL映射时的通知而误认为DLL是以相同的顺序被初始化的。
调用EXE和DLL入口点代码的例程被称为LdrpRunInitializeRoutines
在Windows NT中,调用EXE和DLL入口点代码的例程被称为LdrpRunInitializeRoutines。在平常的工作中,我已经多次跟踪到LdrpRunIntializeRoutines的汇编代码中。但是,看着大堆的汇编代码并不是理解它的好方法。因此我用类似C++的伪代码重写了Windows NT 4.0 SP3 的LdrpRunInitializeRoutines函数,如图1所示。实际上,在NTDLL.DBG中这个例程的名字按__stdcall调用约定被修饰成了_LdrpRunInitializeRoutines@4。在伪代码中,除了那些名字前面加了下划线的,其余的都是我起的名字。
// =============================================================================
// Matt Pietrek, September 1999 Microsoft Systems Journal
//
// NTDLL.DLL中LdrpRunInitializeRoutines例程的伪代码(NT 4,SP3)
//
// 在一个进程中,
// 首次调用LdrpRunInitializeRoutines(也就是在初始化隐含链接的模块)时, bImplicitLoad参数不为0;
// 在后续的调用(通过调用LoadLibrary而间接调用此例程)中,bImplicitLoad为0。
//
// =============================================================================
#include < ntexapi.h > // 用于函数末尾的HardError定义
// 以下是全局符号(这些名字是准确的,它们来自NTDLL.DBG文件)
// _NtdllBaseTag
// _ShowSnaps
// _SaveSp
// _CurSp
// _LdrpInLdrInit
// _LdrpFatalHardErrorCount
// _LdrpImageHasTls
NTSTATUS
LdrpRunInitializeRoutines( DWORD bImplicitLoad )
{
// 1, _LdrpClearLoadInProgress()这个NTDLL函数返回刚才被映射进内存的DLL数目(可能需要被初始化的模块数)。
// 例如如果你在FOO.DLL中调用LoadLibrary函数,而FOO隐含链接到了BAR.DLL和BAZ.DLL,那么_LdrpClearLoadInProgress将返回3,因为有三个DLL被映射进内存中。unsigned nRoutinesToRun = _LdrpClearLoadInProgress();
if ( nRoutinesToRun ) {
// 如果存在需要初始化的模块,就为保存模块相关信息的数组分配内存。这个数组中的每个元素(指针)最终分别指向一个包含有关最近加载(但尚未初始化)的DLL的信息的结够。 */
pInitNodeArray = _RtlAllocateHeap(GetProcessHeap(),
_NtdllBaseTag + 0x60000 ,
nRoutinesToRun * 4 );
if ( 0 == pInitNodeArray ) // 确保内存分配成功
return STATUS_NO_MEMORY;
}
else
pInitNodeArray = 0 ;// 2,使用内部进程数据结构来获取一个包含最近加载的DLL的链表。然后,它遍历这个链表来确定加载器:是否曾经加载过这个DLL;是否有入口点函数。如果这两个测试都通过了,它就将指向相应模块信息的指针添加到pInitNodeArray数组中。在伪代码中我称这个模块信息为pModuleLoaderInfo。一定要注意:一个DLL完全有可能不包含入口点函数——例如纯资源DLL。因此pInitNodeArray中的元素数可能比前面由_LdrpClearLoadInProgress函数返回的值小。// 进程环境块(Process Environment Block,Peb)中保存了一个指向已加载
// 模块链表的指针。现在获取这个指针。
pCurrNode = * (pCurrentPeb -> ModuleLoaderInfoHead);
ModuleLoaderInfoHead = pCurrentPeb -> ModuleLoaderInfoHead;
if ( _ShowSnaps ) {
_DbgPrint( " LDR: Real INIT LIST\n " );
}
nModulesInitedSoFar = 0 ;
if ( pCurrNode != ModuleLoaderInfoHead )
{
// 遍历链表
while ( pCurrNode != ModuleLoaderInfoHead )
{
ModuleLoaderInfo pModuleLoaderInfo;
// 显然指向下一个结点的指针在ModuleLoaderInfo结构中的0x10字节处
pModuleLoaderInfo = & NextNode - 0x10 ;
// 这条语句看起来好像没有什么作用
localVar3C = pModuleLoaderInfo;
//
// 确定模块是否已经被初始化。如果是,就跳过它
// X_LOADER_SAW_MODULE = 0x40
if ( ! (pModuleLoaderInfo -> Flags35 & X_LOADER_SAW_MODULE) )
{
// 此模块还未被初始化。检查它是否有入口点函数
if ( pModuleLoaderInfo -> EntryPoint )
{
// 这个未初始化的模块有入口点函数。将它添加到
// pInitNodeArray数组中。此函数会在后面初始化
// 这个数组中的模块。
//
pInitNodeArray[nModulesInitedSoFar] = pModuleLoaderInfo;
// 若ShowSnaps不为0,输出模块的路径及其入口点地址。例如:
// C:\WINNT\system32\KERNEL32.dll init routine 77f01000
if ( _ShowSnaps )
{
_DbgPrint( " %wZ init routine %x\n " ,
& pModuleLoaderInfo -> 24 ,
pModuleLoaderInfo -> EntryPoint );
}
nModulesInitedSoFar ++ ;
}
}
// 设置此模块的X_LOADER_SAW_MODULE标志。注意:此时模块实际
// 并未被初始化。要等到这个函数快结束时才初始化
pModuleLoaderInfo -> Flags35 &= X_LOADER_SAW_MODULE;
// 移向模块列表中的下一个结点
pCurrNode = pCurrNode -> pNext
}
}
else
{
pModuleLoaderInfo = localVar3C; // 可能未被初始化吗???
}
if ( 0 == pInitNodeArray )
return STATUS_SUCCESS;
//
// 3,现在pInitNodeArray数组中包含的是未初始化的模块的信息的指针。
// 是调用它们的初始化例程的时候了。
//
try // 用try块将整个代码包装起来,以防初始化例程失败。
{
nModulesInitedSoFar = 0 ; // 从索引为0的数组元素开始
//
// 开始遍历整个模块数组
//
while ( nModulesInitedSoFar < nRoutinesToRun )
{
// 获取有关模块信息的指针
pModuleLoaderInfo = pInitNodeArray[ nModulesInitedSoFar ];
// 这条语句好像没什么作用
localVar3C = pModuleLoaderInfo;
nModulesInitedSoFar ++ ;
// 将初始化例程的地址保存在一个局部变量中
pfnInitRoutine = pModuleLoaderInfo -> EntryPoint;
fBreakOnDllLoad = 0 ; // 默认加载时不中断
//
// 如果进程正处于被调试状态,确认一下是否应该在调用
// 初始化例程之前中断在调试器中Windows NT有一个选项允许你在DLL初始化之前将一个进程挂起并把控制权发送到调试器。这个功能是基于DLL的,可以通过向注册表中一个以DLL名称命名的键中添加一个字符串值(BreakOnDllLoad)来实现。// DebuggerPresent(在PEB结构中的偏移2处)是IsDebuggerPresent()
// 返回的内容。这个API仅存在于Windows NT上
if ( pCurrentPeb -> DebuggerPresent || pCurrentPeb -> 1 )
{
LONG retCode;
//
// 查询注册表中的“HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\
// Windows NT\CurrentVersion\Image File Execution Options”
// 这个键。如果它下面存在一个以这个可执行文件名命名的子键,
// 就检查这个子键下的BreakOnDllLoad值
//
retCode =
_LdrQueryImageFileExecutionOptions(
pModuleLoaderInfo -> pwszDllName,
" BreakOnDllLoad " ,
REG_DWORD,
& fBreakOnDllLoad,
sizeof (DWORD),
0 );
// 如果未找到这个值(通常是这样),在初始化DLL时就不中断
if ( retCode <= STATUS_SUCCESS )
fBreakOnDllLoad = 0 ;
}
if ( fBreakOnDllLoad )
{
if ( _ShowSnaps )
{
// 在实际中断进调试器之前,输出模块名称和初始化例程的地址
_DbgPrint( " LDR: %wZ loaded. " ,
& pModuleLoaderInfo -> pModuleLoaderInfo );
_DbgPrint( " - About to call init routine at %lx\n " ,
pfnInitRoutine )
}
// 中断进调试器
_DbgBreakPoint(); // 它实际是一条INT 3指令,后面跟着RET指令
}
else if ( _ShowSnaps && pfnInitRoutine )
{
// 在调用初始化例程之前输出模块名称和初始化例程的地址
_DbgPrint( " LDR: %wZ loaded. " ,
pModuleLoaderInfo -> pModuleLoaderInfo );
_DbgPrint( " - Calling init routine at %lx\n " , pfnInitRoutine);
}
if ( pfnInitRoutine )
{
// 设置标志来表明已将DLL_PROCESS_ATTACH通知发送给了DLL
//
// X_LOADER_CALLED_PROCESS_ATTACH = 0x8
pModuleLoaderInfo -> Flags36 |= X_LOADER_CALLED_PROCESS_ATTACH;
//
// 如果此模块使用了线程局部存储(TLS),现在调用TLS初始化函数
// *** 注意 ***
// 这仅发生在一个进程首次调用此函数时(也就是在初始化隐含
// 链接的DLL时)。动态加载的DLL不应该使用TLS变量,正如
// SDK文档所说的那样
//
if ( pModuleLoaderInfo -> bHasTLS && bImplicitLoad )
{
_LdrpCallTlsInitializers( pModuleLoaderInfo -> hModDLL,
DLL_PROCESS_ATTACH );
}EDI指向DLL的入口点函数,而入口点函数是由DLL的PE文件头指定的。当CALL EDI返回时,DLL已经完成了它的初始化。对于用C++写的DLL来说,这意味着它的DllMain函数已经执行完了与DLL_PROCESS_ATTACH相应的那部分代码。同时要注意传递给入口点函数的第三个参数,它通常被称为lpvReserved。事实上,对于可执行文件隐含链接(直接链接或通过其它DLL间接链接)到的DLL来说,这个参数非0。对于其它DLL(即通过调用LoadLibrary动态加载的DLL)来说,这个参数为0。hModDLL = pModuleLoaderInfo -> hModDLL;
MOV ESI,ESP // 将ESP寄存器的值保存到ESI中
MOV EDI,DWORD PTR [pfnInitRoutine] // 将模块的入口点地址加载到EDI中
// 以下的汇编语言代码用C++代码表示就是:
// initRetValue = pfnInitRoutine(hInstDLL,DLL_PROCESS_ATTACH,bImplicitLoad)
PUSH DWORD PTR [bImplicitLoad]
PUSH DLL_PROCESS_ATTACH
PUSH DWORD PTR [hModDLL]
CALL EDI // 调用初始化例程。这是设置断点的最佳位置。
// 单步跟踪这个调用就进入到了DLL的入口点中
MOV BYTE PTR [initRetValue],AL // 保存入口点函数的返回值
MOV DWORD PTR [_SaveSp],ESI // 保存入口点函数返回后的
MOV DWORD PTR [_CurSp],ESP // 堆栈指针的值
MOV ESP,ESI // 恢复调用入口点函数之前ESP中的值
//
// 校验调用前后堆栈指针(ESP)的值。如果它们不同,这
// 表明DLL的初始化例程并没有正确地清理堆栈。例如,它的
// 入口点函数可能定义的不正确。尽管这极少发生,但是如果
// 它确实发生了,我们要通知用户并让他们决定是否继续执行
//
if ( _CurSP != _SavSP )
{
hardErrorParam = pModuleLoaderInfo -> FullDllPath;
hardErrorRetCode =
_NtRaiseHardError(
STATUS_BAD_DLL_ENTRYPOINT | 0x10000000 ,
1 , // 参数个数
1 , // UnicodeStringParametersMask,
& hardErrorParam,
OptionYesNo, // 让用户决定
& hardErrorResponse );
if ( _LdrpInLdrInit )
_LdrpFatalHardErrorCount ++ ;
if ( (hardErrorRetCode >= STATUS_SUCCESS)
&& (ResponseYes == hardErrorResponse) )
{
return STATUS_DLL_INIT_FAILED;
}
}
//
// 如果DLL的入口点函数返回0(表示失败),通知用户
if ( 0 == initRetValue )
{
DWORD hardErrorParam2;
DWORD hardErrorResponse2;
hardErrorParam2 = pModuleLoaderInfo -> FullDllPath;
_NtRaiseHardError( STATUS_DLL_INIT_FAILED,
1 , // 参数个数
1 , // UnicodeStringParametersMask
& hardErrorParam2,
OptionOk, // 只能以“确定”作为响应
& hardErrorResponse2 );
if ( _LdrpInLdrInit )
_LdrpFatalHardErrorCount ++ ;
return STATUS_DLL_INIT_FAILED;
}
}
}
//
// 如果这个进程自身的EXE文件定义了TLS变量,现在调用TLS初始化例程。
// 要获取更详细的信息,参考前面调用_LdrpCallTlsInitializers时的注释
if ( _LdrpImageHasTls && bImplicitLoad )
{
_LdrpCallTlsInitializers( pCurrentPeb -> ProcessImageBase,
DLL_PROCESS_ATTACH );
}
}
__finally
{
// 在这个函数退出之前,确保它在前面分配的内存被释放
_RtlFreeHeap( GetProcessHeap(), 0 , pInitNodeArray );
}
return STATUS_SUCCESS;
}
这段加载器代码在被加载的DLL所在的那个进程环境中执行。也就是说,它并不是什么特别的加载器进程的一部分。
在进程启动过程中,处理隐含加载的DLL时,LdrpRunInitializeRoutines至少被调用一次。同时每当动态加载一个或多个DLL(一般是通过调用LoadLibrary来实现的)时,都要调用它, 每当LdrpRunInitializeRoutines执行时,它就查找并调用已经被映射进内存但还尚未被初始化的所有DLL的入口点代码。
在看上面的伪代码时,注意所有提供跟踪输出的额外代码(也就是上面的伪代码中使用_ShowSnaps变量和_DbgPrint函数的代码),它们甚至存在于非调试版的Windows NT中。稍候我会接着说这一点。
DLL的入口点函数被调用之后,LdrpRunInitializeRoutines开始进行安全性检查以确保DLL的入口点代码中没有错误。它比较调用入口点代码前后堆栈指针(ESP)的值。如果它们不同,那就表明DLL的初始化函数出现了错误。由于大多数程序员从未定义过真正的DLL入口点函数,这种情况很少发生。但是它一旦发生,Windows就会用一个对话框通知你这个问题(如图2所示)。我不得不使用调试器并在恰当的地方修改寄存器的值才产生了这个对话框。
图2 非法DLL入口点
堆栈检查完毕之后,LdrpRunInitializeRoutines检查入口点函数的返回值。对于用C++写的DLL来说,它就是DllMain的返回值。如果DLL返回0,它通常表示出现了错误,不能继续加载这个DLL了。如果发生这种情况,你就会得到一个令人害怕的“DLL初始化失败”对话框。
在所有的DLL初始化完毕之后开始执行LdrpRunInitializeRoutines函数的第三部分中的最后一些代码。如果进程本身的EXE文件包含TLS数据,并且如果隐含链接到的DLL已经被初始化,那它就调用_LdrpCallTlsInitializers。
LdrpRunInitializeRoutines函数的第四部分(也是最后一部分)是清理代码。还记得前面RtlAllocateHeap创建的pInitNodeArray数组吗?这部分内存需要被释放,释放它的代码在__finally块中。这样,即使这些DLL中可能有的在初始化时会失败,__try/__finally代码也能保证会调用RtlFreeHeap来释放pInitNodeArray。
我们的LdrpRunInitializeRoutines之旅就此结束了,现在让我们来看一下与此相关的一些问题。
调试初始化例程
我也曾经遇到过DLL在初始化时失败的情况。不幸的是,错误可能是好几个DLL中的一个,而操作系统并没有告诉我到底哪一个才是罪魁祸首。在这种情况下,你就可以使用调试器断点来解决问题。
大多数调试器都直接跳过静态链接的DLL的初始化例程。它们把注意力放在EXE文件的第一条指令或第一行上。但是知道了LdrpRunInitializeRoutines的内部情况之后,你就可以在CALL EDI这条指令上设置一个断点,此时正要执行的是DLL的入口点代码。一旦设置了这个断点,每次DLL将要接到DLL_PROCESS_ATTACH通知时,就会中断在NTDLL的CALL指令上。图3是在Visual C++® 6.0 IDE(MSDEV.EXE)中的情况。
图3 在CALL EDI指令上设置断点
如果单步跟踪CALL指令,你会遇到DLL入口点代码的第一条指令。意识到这段代码绝大多数情况下都不是你自己写的这一点很重要。因为它通常是运行时库中的代码,这段代码先做一些准备工作,然后再调用你的初始化代码。例如在用Visual C++写的DLL中,它的入口点函数是_DllMainCRTStratup,这个函数在CRTDLL.C中。在没有调试符号和源代码的情况下,你在MSDEV的汇编窗口中看到内容类似下面这个样子(图4):
图4 单步跟踪CALL指令
通常我在调试时会按照下面这个过程进行:
1)第一步, 找出错的DLL。通常设置前面讲的断点然后单步跟踪到每个DLL的初始化例程就可以了。使用调试器找出你当前正处于哪个DLL中,并把它记录下来。一种方法就是使用调试器的内存窗口来观察堆栈(ESP),获取你进入的DLL的HMODULE。
当你知道进入到了哪个DLL之后,让进程继续运行(一般是Go命令)。很快就会在下一个DLL中再次触发断点。重复这个过程直到你找到有问题的DLL为止。你很容易就能找到出错的DLL,因为它的初始化代码被调用了,但在这个初始化代码返回之前,进程却意外终止了(因为出错了)。
2)第二步就是仔细检查出错的DLL。如果你有那个DLL的源代码,你最好在DllMain上设置一个断点,然后让进程运行等待断点被触发。如果你没有源代码,只管让进程运行,等待你在CALL EDI指令上设置的断点在那个位置触发。继续运行直到你碰到出错的指令。单步跟踪进入这个入口点代码并一直单步跟踪下去直到你确定问题所在。这通常需要跟踪大量汇编代码!我从没有说过这很容易,但有时候这是解决问题的惟一方法。
找出CALL EDI指令需要一些技巧(至少是在当前的Microsoft®调试器上)。你现在就能理解我为什么在上面的伪代码中用汇编语言表示这部分代码了。首先,很明显你需要把NDDLL.DLL配套的NTDLL.DBG文件(现在当然是NTDLL.PDB文件)放在你的SYSTEM32目录中。当你开始单步跟踪你的程序时,调试器应该会自动加载调试符号。
在Visual C++的汇编窗口,原理上你可以使用符号名作为地址。在这里,你当然是想转到_LdrpRunInitializeRoutines@4,然后滚动窗口直到你看到CALL EDI这条指令。不幸的是,除非你中断在NTDLL.DLL中,否则Visual C++调试器并不能识别NTDLL中的符号名。
如果你碰巧知道_LdrpRunInitializeRoutines@4的地址(在Intel平台的Windows NT 4.0 SP3上这个地址为0x77F63242),你可以键入那个地址,汇编窗口很容易就会显示它。IDE甚至会显示这个函数的名称为_LdrpRunInitializeRoutines@4。如果你不是调试器老手,符号名识别失败让人很困惑。如果你和我一样是个调试器爱好者,这是非常讨厌的,因为你不知道到底问题出在哪里。
Platform SDK中的WinDBG在识别符号名方面稍好一些。一旦你启动了目标进程,你就可以用_LdrpRunInitializeRoutines@4的名称在这个函数上设置一个断点。不幸的是,当你首次执行这个进程时,你还没来得及在_LdrpRunInitializeRoutines@4上设置断点,执行流程已经过了这个函数了。为了解决这个问题,启动WinDBG后,先单步跟踪一步,然后设置断点并停止调试, 仍然保留调试器。然后你可以重启被调试程序,这次断点就会在每一次调用_LdrpRunInitializeRoutines@4时被触发。这个技巧也可以用在Visual C++调试器中。
ShowSnaps是什么?
ShowSnaps这个全局变量是我在查看LdrpRunInitializeRoutines的代码时首先注意到的内容之一。趁这个好机会简要地解释一下有关GlobalFlag和GFlags.EXE方面的内容。
Windows NT注册表中包含了影响系统代码某些行为的DWORD值。它们大部分与堆和调试有关。注册表中HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager子键下的GlobalFlag值是一组位域。知识库文章Q147314描述了这些域中的大部分,因此我在这里就不详细讲了。除了系统范围内的GlobalFlag值外,各个可执行文件也可以有它们自己的GlobalFlag值。与单个进程相关的GlobalFlag值被保存在注册表中HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\imagename这个子键下,这里的imagename是可执行文件的名字(例如WinWord.exe)。所有这些对于编制文档来说都是极大挑战的位域以及嵌套极深的注册表键急需有一个程序来简化。实际上,Microsoft就提供了一个这样的程序(GFlags.EXE)。(关于GFlags工具以及各个标志位的详细含义,可以参考最新的Microsoft® Debugging Tools for Windows®帮助文档。)
图5 GFlags.EXE
图5显示的是GFlags.EXE,它来自于Windows NT 4.0资源工具包。GFlags.EXE左上角是三个单选按钮。选择最上面的两个(System Registry或Kernel Mode)中任意一个就可以改变Session Manager中的GlobalFlags的值。如果你选择第三个单选按钮(Image File Options)的话,那么许多选项就会消失。这是因为一些GlobalFlag选项只影响内核模式代码,对每个进程来说并无多大意义。需要注意的一点是,大多数只用于内核模式的选项都假定你使用的是诸如i386kd之类的系统级调试器。如果不使用这样的调试器深入内部或接收输出信息,那使用这些选项也就没有什么意义了。(最新版本的GFlags.EXE除了使用了三个选项卡而不是三个单选按钮外,基本与此类似。)
这些标志中与ShowSnaps相关的就是Show loader snaps选项。如果它被选中,那么NTDLL.DLL中的ShowSnaps变量就会被设置成一个非0值。在注册表中,这个位是0x00000002,它被定义为FLG_SHOW_LDR_SNAPS。幸运的是,这个标志是GlobalFlag中可以被设置为针对于每个线程的一些标志中的一个。要不然你要是在系统范围使用这个标志的话,那输出内容会相当多。
检查ShowSnaps输出
首先运行GFlags打开CALC.EXE的Show loader snaps标志。然后我在MSDEV.EXE的控制下运行CALC.EXE,这样就从输出:
在图6中,注意所有从NTDLL中输出的内容前面都加了LDR:前缀。其它行(例如“Loaded symbols for XXX”)是由MSDEV进程插入的。在查看带有LDR:的行时会发现一些有价值的信息。例如在进程启动时给出了EXE文件的完整路径以及当前目录和搜索路径。
由于NTDLL加载各个DLL并修正导入函数的地址,因此你会看到类似下面的信息:
LDR: ntdll.dll used by SHELL32.dll
LDR: Snapping imports for SHELL32.dll from ntdll.dll
第一行表明SHELL32.DLL链接到了NTDLL中的API上。第二行表明了从NTDLL导入的API正常被“snapped(快照)”。当可执行模块从其它DLL导入函数时,在它里面就有一个函数指针数组。这个函数指针数组就是IAT。加载器的工作之一就是定位导入函数的地址并把它们填入IAT中。因此术语“snapping”就出现在了LDR:输出中。
输出内容中另一个引起我注意的是正在被处理的DLL的绑定信息。
LDR: COMCTL32.dll bound to KERNEL32.dll
LDR: COMCTL32.dll has correct binding to KERNEL32.dll
在以前的专栏中,我曾经讲过使用BIND.EXE程序或IMAGEHLP.DLL导出的BindImageEx这个API来绑定程序。将一个可执行文件绑定到某个DLL上实际就是查找导入函数的地址并把它们写入到磁盘上的可执行文件中。这可以加速加载过程,因为加载时不再需要查找导入函数的地址了。
上面的第一行表明COMCTL32绑定到了KERNEL32.DLL上。第二行表明绑定的地址是正确的。加载器通过比较时间戳来确定这一点。如果时间戳不匹配,那么绑定就是无效的。在这种情况下,加载器就重新查找导入函数的地址,就好像这个可执行文件并没有绑定一样。
TLS初始化
在LdrpRunInitializeRoutines函数中,在调用模块的入口点代码前的最后一刻,NTDLL检查这个模块是否需要初始化TLS。如果需要,它就调用LdrpCallTlsInitializers函数来进行初始化。图7是我为这个例程写的伪代码。
这个函数相当简单。PE文件头中保存了IMAGE_TLS_DIRECTORY结构(在WINNT.H中定义)的偏移(RVA)。这个函数调用RtlImageDirecotoryEntryToData来获取指向这个结构的指针。IMAGE_TLS_DIRECTORY结构中保存了一个指针,它指向一个由回调函数的地址组成的数组。这些回调函数是被声明为PIMAGE_TLS_CALLBACK类型的函数,这个类型在WINNT.H中定义。TLS初始化回调函数与DllMain函数非常相似。实际上在使用__declspec(thread)定义变量时,Visual C++生成了一些导致这些函数会被调用的数据。但是当前运行时库并未定义实际的回调函数,因此这个函数指针数组只有一个值为NULL的元素。
总结
我对Windows NT模块初始化方面的讨论已经结束了。很明显我跳过了许多相关内容。例如确定模块初始化顺序的算法是什么?Windows NT上的这个算法至少已经改变过一次,如果有Microsoft technical note就好了,至少它可以给我们一些指导。同样,我也没有讨论与模块加载对应的话题:模块卸载。然而,我希望我对Windows NT加载器内部工作过程的“一瞥”能够为你更深层次的探索提供一些材料。