第八章 动态链接库的载入分析
动态链接库 (DLL) 一直以来都是Windows的重要基础,Windows CE也不例外。DLL对操作系统十分重要,本节的内容主要是分析loader.c中的程序代码,它负责加载EXE和DLL。这里要讨论的是关于DLL的部分,如无特别说明,本章中所引用的程序代码,都是来自Windows CE原始程序代码树中的[CEROOT]/PRIVATE/WINCEOS/COREOS/NK/KERNEL/loader.c。
8.1 loader.c中程序代码的组织结构
loader.c主要透过以下的API函数,来完成NK核心加载EXE和DLL处理程序的工作,这是使用者程序对DLL和EXE程序操作的进入点。之后再透过一系列的函数呼叫,来完成DLL和EXE的加载和卸载,以及对处理程序和执行绪的其它操作。由于我们现在要讨论的是有关DLL的部分,所以只列出了部分函式。
l Win32 LoadLibrary call
HANDLE SC_LoadLibraryW(LPCWSTR lpszFileName)
HINSTANCE SC_LoadLibraryExW(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags)
HANDLE SC_LoadDriver(LPCWSTR lpszFileName)
HANDLE SC_LoadKernelLibrary(LPCWSTR lpszFileName)
HANDLE SC_LoadIntChainHandler(LPCWSTR lpszFileName, LPCWSTR lpszFunctionName,
BYTE bIRQ)
这几个函数基本上都是负责DLL的载入,它们都呼叫了LoadOneLibraryW这个函数,函数的原型如下:
HANDLE LoadOneLibraryW(LPCWSTR lpszFileName, DWORD fLbFlags, WORD wFlags)
这个函数在加载DLL的前后,处理一些必要的细节工作。更重要的是呼叫了LoadOneLibraryPart2这个函数,加载的重要工作都在该函数的程序代码中完成,在本章的后面会详细分析它的内容。
l Win32 FreeLibrary call
HANDLE SC_FreeIntChainHandler(HANDLE hLib)
BOOL SC_FreeLibrary(HANDLE hInst)
主要是FreeLibrary函数。这个API函数负责卸载DLL。它呼叫FreeOneLibrary这个函数,函数原型如下:
BOOL FreeOneLibrary(PMODULE pMod, BOOL bCallEntry)
卸载的过程主要是由FreeOneLibraryPart2负责。在后面的章节会有具体的分析。
l Win32 GetProcAddress call
LPVOID SC_GetProcAddressA(HANDLE hInst, LPCSTR lpszProc)
LPVOID SC_GetProcAddressW(HANDLE hInst, LPCSTR lpszProc)
这两个函数可以得到处理程序的地址。
8.2 module structure
这是记录DLL信息的重要数据结构之一,每一个程序对应一个module,但一个module可以对应多个处理程序。在系统中维持一条已加载模块的串行,它是一个单向链接串行。第一个元素是pModList。
程序代码8.1 module structure
typedef struct Module { LPVOID lpSelf; PMODULE pMod; LPWSTR lpszModName; DWORD inuse; DWORD calledfunc; WORD refcnt[MAX_PROCESSES]; LPVOID BasePtr; DWORD DbgFlags; LPDBGPARAM ZonePtr; ulong startip; openexe_t oe; e32_lite e32; o32_lite *o32_ptr; DWORD breadcrumb; DWORD dwNoNotify; WORD wFlags; BYTE bTrustLevel; BYTE bPadding; PMODULE pmodResource; DWORD rwLow; DWORD rwHigh; } Module; |
/* 用于验证,指向自己的指标 */ /* 链接串行中的下一个模块 */ /* 模块名字 */ /* 使用状况的位向量 */ /* 被呼叫的进入点,但不退出 */ /* 处理程序的引用计数 */ /* DLL载入基址 */ /* 侦错旗标 */ /* Debug zone 指标 */ /* 基于0的进入点 */ /* 执行档指标 */ /* e32标头 */ /* O32串行指标 */
/* 每个处理程序对应一个位,当Notify被禁用时设为1 */
/* 包含资源的模块 */ /* ROM DLL中可擦写段的基地址 */ /* ROM DLL中可擦写段的高位地址 */ |
refcnt[MAX_PROCESSES]是每个处理程序的引用计数。BasePtr是DLL加载的基地址,这是比较需要注意的部分。oe、e32、*o32_ptr放在后面介绍。
8.3 LoadOneLibraryPart2加载DLL的过程
减少DLL的引用计数 |
透过文件名字查找模块,得到pMod |
模块已经加载到处理程序的地址空间了吗? |
增加引用计数, 且如果是第一次加载处理程序,则复制字段 |
为模块分配内存 |
初始化 module structure |
系统能找到指定的DLL档吗? |
增加引用计数 |
使该模块成为pModList的第一个元素 |
细节处理 |
传回NULL |
传回pMod |
参数与载入已载入的mod符合吗? |
传回NULL |
否 |
是 |
不符合 |
符合 |
否 |
是 |
呼叫 LoadOneLibraryPart2 |
图8.1 LoadOneLibraryPart2的基本执行步骤
函数原型:
PMODULE LoadOneLibraryPart2(LPWSTR lpszFileName, DWORD fLbFlags,WORD wFlags)
参数lpszFileName
指向DLL名字的字符串,该名字确定了模块 (module) 的文件名称,而与它储存在模块库中的名字无关。函式库模块以LIBRARY为关键词,在模块定义文件 (.def) 中定义。
参数hFile
是保留做将来使用的,现在必须是NULL。
参数wFlags
指定加载模块时所要处理的工作。其值可以为0、DONT_RESOLVE_DLL_REFERENCES、LOAD_LIBRARY_AS_DATAFILE或者是LOAD_LIBRARY_IN_KERNEL。
DONT_RESOLVE_DLL_REFERENCE:如果使用这个旗标,而且可执行模块本身是一个DLL,则系统并不呼叫DllMain来初始化和结束处理程序和执行绪。此外,一个DLL可能会引入包含在另一个DLL中的函数,而系统映像一个DLL时也会自动加载,当这个旗标被设置之后,系统就不再自动加载额外的DLL。加载过程如图8.1所示。 加载的过程首先设定wFlags的值,然后呼叫pMod = FindModByName(dllname),藉由查找pModList串行,来得到DLL的pMod指标。对于已经加载的module,增加其引用次数。由以下的程序代码来完成:
if (!(pMod->inuse & (1<<pCurProc->procnum))) {
pMod->inuse |= (1<<pCurProc->procnum);
pMod->calledfunc &= ~(1<<pCurProc->procnum);
}
return pMod->refcnt[pCurProc->procnum]++;
呼叫该module的处理程序是目前的处理程序。将目前处理程序对该module的引用计数加一,传回pMod,便完成了加载的过程。如果module之前并未加载,则需要一系列的工作:
1) 为module配置内存
2) 初始化module,呼叫InitModule
3) 增加引用计数
4) 将这个新的pMod插入module链接串行中,作为第一个元素。程序代码如下:
EnterCriticalSection(&ModListcs);
pMod->pMod = pModList;
pModList = pMod;
LeaveCriticalSection(&ModListcs)
8.4 DLL加载过程 — InitModule的执行
InitModule处理加载一个新的DLL所要做的大部分工作。函数原型如下:
DWORD InitModule (PMODULE pMod, LPWSTR lpszFileName, LPWSTR lpszDllName,
DWORD fLbFlags, WORD wFlags)
在函数InitModule中,具体执行步骤如下:
1) 初始化pMod中的字段 (field)
2) 呼叫函数OpenADll,产生执行文件指标 (openexe_t)
3) 加载 module的e32 信息,产生e32标头信息 (e32_lite)
4) 检查bTrust level参数
5) 配置内存给DLL,取得Module->BasePtr,即加载DLL的基地址
6) 配置内存给name 和O32 对象,读取这个module的O32信息
7) 改变module的名字
8) 复位位映射
9) 呼叫函数FindEntryPoint ,找到EXE的起始IP (或者DLL的进入点)
整体来说,这个函数就是负责设定pMod各个字段的初始值、取得可执行档的指标以及EXE或DLL的进入点。在这个过程中,将各个步骤、判断中的错误码,传回给LoadOneLibraryPart2,用来作为判断pMod是否建立成功的信息,以继续以后的工作。下面,将对这几个重要步骤作详细分析。
8.4.1 呼叫OpenADll,产生执行档指标 (openexe_t)
module中的oe是可执行档的指针,每个程序对应一个module,每个module对应一个可执行档指标。oe的型别openexe_t定义如下:
程序代码8.2 openexe_t structure
typedef struct openexe_t { union { int hppfs; HANDLE hf; TOCentry *tocptr; }; BYTE filetype; BYTE bIsOID; WORD pagemode; DWORD offset; union { Name *lpName; CEOID ceOid; }; } openexe_t; |
// ppfs handle // 对象储存指针 // rom entry pointer
//档案类型
//分页模式 //偏移
|
这实际上是与档案处理等有关的结构,描述可执行程序代码的地址、分页模式、偏移等信息。OpenADll呼叫OpenExe,由OpenExe呼叫SafeOpenExe,设定可执行档指标的工作基本上都在SafeOpenExe中完成。
BOOL SafeOpenExe(LPWSTR lpszName, openexe_t *oeptr, BOOL bIsDLL, BOOL bAllowPaging, OEinfo_t *poeinfo)
SafeOpenExe还执行以下的工作:
1) 寻找EXE档案所在的目录
2) 按照指定路径寻找档案
3) 在Windows目录中寻找档案
4) 在根目录中寻找档案
在档案系统中搜寻DLL档案时,根据搜寻过程中的信息设定其中各个字段的值。并藉由下面的程序代码取得档案的储存指标:oeptr->hf=CreateFileW((LPWSTR)poeinfo->tmpname, GENERIC_READ, FILE_SHARE_READ, 0,
OPEN_EXISTING, 0, 0);
即透过CreateFile开启档案。如果在ROM中有备份,则设定oeptr->tocptr的值。传回结果:如果找到,则传回1,否则传回0。
8.4.2 设定module的e32 标头信息
module的成员e32的型别定义如下:
程序代码8.3 e32_lite structure
typedef struct e32_lite { unsigned short e32_objcnt; BYTE e32_cevermajor; BYTE e32_ceverminor; unsigned long e32_stackmax; unsigned long e32_vbase; unsigned long e32_vsize; unsigned long e32_sect14rva; unsigned long e32_sect14size; struct info e32_unit[LITE_EXTRA]; } e32_lite, *LPe32_list; | /* PE 32-bit .EXE header */ /* 内存对象个数 */ /* 版本信息 */ /* 版本信息 */ /* 堆栈的最大值 */ /* module的虚拟内存基地址 */ /* 整个映射的Virtual 大小 */ /* section 14 rva */ /* section 14 size */ /* Array of extra info units */
|
这是与内存相关的一组数据,在InitModule中有一段程序代码如下:
// load O32 info for the module
eptr = &pMod->e32;
if (retval = LoadE32(&pMod->oe, eptr, &flags, &entry, (pMod->wFlags &
LOAD_LIBRARY_AS_DATAFILE) ? 1 : 0, bAllowPaging, &pMod->bTrustLevel)) {
return retval;
}
它藉由呼叫LoadE32函数读取DLL文件,设定e32标头的各部分内容,设定堆栈、虚拟内存基地址、映像等正确的值,使接下来DLL的内存配置工作能够顺利进行。
8.4.3 DLL的内存配置与Module->BasePtr的取得
InitModule中的这一部分程序代码的主要作用是设定pMod->BasePtr。为了方便解释,图8.2是简化的流程图。
是 “execute in place” DLL 吗? |
是载入核心地址空间吗? |
呼叫函数 VirtualAlloc 设定 pMod->BasePtr |
保留虚拟内存成功? |
由上往下载入 |
修改DLL Load Base |
取得连续的实体页面,设定pMod->BasePtr |
藉由函数 MapPtrProc 设定pMod->BasePtr |
是 |
否 |
是 |
否 |
否 |
是 |
图8.2 DLL配置内存的过程
由可执行档指标pMod->oe.filetype可得到档案类型。DLL有两种映像,一种是普通档案,需要将其加载到RAM中执行。为了节省时间,还有另一种方式就是XIP (Execute in place) 方式。顾名思义,就是可以立即执行,而不用加载RAM中,所以相应pMod->BasePtr的值也有两种不同的情况。如果不是XIP (Execute in place) DLL,则保留足够的虚拟内存地址空间以容纳整个映像。从已经存在的DLL的底部开始由上往下分配。
DLL加载到核心地址空间 (kernel space) 或是使用者空间 (user space) 也有差别。对于加载核心地址空间的DLL,必须为其取得连续的实体页面。因为核心空间地址和虚拟内存地址之间是静态映像的,这是为了让核心执行时,不必进行地址的转换,藉以加快核心的执行。例如在Platform Builder 下产生的platform,启动时加载的features等就是加载核心的DLL。
if (wFlags & LOAD_LIBRARY_IN_KERNEL) {
PHYSICAL_ADDRESS paRet;
// Loading in the kernel address space
paRet = GetContiguousPages((DWORD) (eptr->e32_vsize + PAGE_SIZE - 1) / PAGE_SIZE,
0, 0);
if (paRet == INVALID_PHYSICAL_ADDRESS || !(pMod->BasePtr =
(PVOID) Phys2Virt(paRet))) {
return ERROR_OUTOFMEMORY;
}
}
它先向系统要求连续的实体地址页面,取得实体地址,再将其直接映像到虚拟地址空间,并把虚拟内存地址传给pMod->BasePtr。有关GetContiguousPages及Phys2Virt的细节请参考其它的源文件。如果不是加载核心地址空间,则必须为其保留地址空间,以避免其它DLL加载同样的地址空间,而使该DLL的卸载发生问题。程序代码如下:
} else {
// try to honor the Dll's relocation base to prevent relocation
if ((pTOC->ulKernelFlags & KFLAG_HONOR_DLL_BASE) && (eptr->e32_vbase +
eptr->e32_vsize < ROMDllLoadBase)) {
pMod->BasePtr = VirtualAlloc ((LPVOID)(ProcArray[0].dwVMBase +
eptr->e32_vbase), eptr->e32_vsize, MEM_RESERVE | MEM_IMAGE,
PAGE_NOACCESS);
DEBUGMSG (pMod->BasePtr, (L"Loading DLL '%s' at the preferred loading address
%8.8lx/n", lpszFileName, ZeroPtr (pMod->BasePtr)));
}
其中,eptr->e32_vbase是module映像的虚拟内存基地址,eptr->e32_vsize是module的虚拟内存大小。利用VirtualAlloc在目前处理程序的虚拟内存地址空间保留一个区域,基地址是处理程序地址空间基地址ProcArray[0].dwVMBase + module映像的虚拟内存基地址。大小受模块影响。它使用参数MEM_RESERVE | MEM_IMAGE,只保留了处理程序的一部分虚拟内存空间,而没有实际分配物理内存。而且这部分空间不能透过其它内存配置的操作如malloc和LocalAlloc使用,也不能被其它DLL占用。如果配置基地址时,发现要使用的区域已经被保留,即已经有其它DLL占用,造成配置虚拟内存失败,则由上往下配置。
// allocate top-down if we can't load it in the dll's preferred load base.
if (!pMod->BasePtr && !(pMod->BasePtr = VirtualAlloc ((LPVOID) ProcArray[0].
dwVMBase,eptr->e32_vsize, MEM_RESERVE | MEM_TOP_DOWN | MEM_IMAGE,
PAGE_NOACCESS))) {
return ERROR_OUTOFMEMORY;
}
}
配置的基地址是目前处理程序的虚拟内存空间基地址,大小不变,只是由上往下配置。这样就得到了pMod->BasePtr。下面的程序代码修改DLL的加载基址,这是个全域变数。
if (ZeroPtr(pMod->BasePtr) < (DWORD)DllLoadBase)
DllLoadBase = ZeroPtr(pMod->BasePtr);
如果要加载的DLL是XIP的,即表示它是在ROM中,不需加载到处理程序地址空间,如coredll.dll就是XIP型的DLL。只需在ROM中找到要执行的DLL的基地址,传给pMod->BasePtr即可,如以下程序代码所示:
} else {
e32_rom *e32rp = (e32_rom *) pMod->oe.tocptr->ulE32Offset;
pMod->BasePtr = (LPVOID) MapPtrProc (e32rp->e32_vbase, ProcArray);
if ((wFlags & LOAD_LIBRARY_IN_KERNEL) && !IsKernelVa (pMod->BasePtr)) {
return ERROR_BAD_EXE_FORMAT;
}
}
8.4.4 name和o32对象的内存配置
o32对象是与存取控制有关的对象。结构定义如下。
typedef struct o32_lite {
unsigned long o32_vsize;
unsigned long o32_rva;
unsigned long o32_realaddr;
unsigned long o32_access;
unsigned long o32_flags;
unsigned long o32_psize;
unsigned long o32_dataptr;
} o32_lite, *LPo32_lite;
8.4.5 复位位映射
非XIP映射需要重新寻址 (Relocate)。
if (pMod->oe.filetype != FT_ROMIMAGE) {
//
// Non-XIP image needs to be relocated.
//
if ((pMod->oe.pagemode == PM_NOPAGING) &&
!(pMod->wFlags & LOAD_LIBRARY_AS_DATAFILE) &&
!Relocate (eptr, pMod->o32_ptr, (ulong)pMod->BasePtr,
((wFlags & LOAD_LIBRARY_IN_KERNEL) ? 0 : ProcArray[0].dwVMBase))) {
return ERROR_OUTOFMEMORY;
}
这里呼叫Relocate对 DLL重新寻址,传递的参数为:可执行文件指针eptr,o32标头信息pMod->o32_ptr,DLL加载的基地址pMod->BasePtr,如果DLL加载到核心,则最后一个参数为0,否则传递的参数是目前处理程序虚拟内存空间的基址。这里还需要解释一下重新寻址 (Relocate) 的过程。重新寻址的过程,是将DLL映像地址定位到目前处理程序的地址空间,即地址0x00000000-0x02000000,从0x01FFFFFF开始由上往下,最顶端是coredll.dll,然后是其它DLL。
如果是XIP映射,即DLL在ROM中。如果module被加载内存的Slot1 (DLL高地址区域),或者加载到核心中,则需要记录为这个module而寻址的读写区。程序代码如下:
o32_lite *optr = pMod->o32_ptr;
//
// If the module is loaded into the Slot 1 (DLL High) area or into
// the kernel we need to record where the read/write section has
// been located for this module.
//
if (IsModCodeAddr (pMod->BasePtr) || IsKernelVa(pMod->BasePtr)) {
// find the high/low of RW sections
for (loop = 0; loop < eptr->e32_objcnt; loop ++, optr ++) {
if ((optr->o32_flags & IMAGE_SCN_MEM_WRITE) && !(optr->o32_flags &
IMAGE_SCN_MEM_SHARED)) {
if (pMod->rwLow > optr->o32_realaddr)
pMod->rwLow = optr->o32_realaddr;
if (pMod->rwHigh < optr->o32_realaddr + optr->o32_vsize)
pMod->rwHigh = optr->o32_realaddr + optr->o32_vsize;
}
}
}
8.4.6 EXE的起始IP
这里是藉由呼叫函数FindEntryPoint ,来设定pMod->startip。
if (entry) {
if ((wFlags & LOAD_LIBRARY_IN_KERNEL) || !pMod->e32.e32_sect14rva)
pMod->startip = FindEntryPoint(entry,&pMod->e32,pMod->o32_ptr);
else {
HANDLE hLib;
if (!(hLib = LoadOneLibraryW (L"mscoree.dll",0,0)) || !(pMod->startip =
(DWORD)GetProcAddressA (hLib,"_CorDllMain"))) {
return ERROR_DLL_INIT_FAILED;
}
}
}
如果是加载到核心,则呼叫FindEntryPoint函数,将pMod的e32信息和o32标头当作参数,寻找进入点的内存对象,最后取得实际地址。如果不是加载到核心,则是利用GetProcAddressA函数取得DLL的进入点。
8.5 实例分析
1. 范例环境的建立过程
1) 在Platform Builder 4.0下,使用其所提供的emulator作为platform的BSP,建立新的platform — tiny kernel。
2) Build后产生新的Platform — loader_test,它同时产生debug和release版本。用debug版本侦错,追踪loader.c,可以看到这个用作测试的loader_test启动时加载各个DLL的过程。
3) 建立控制台应用程序,编译产生loader_test上的应用程序 — console_test。要注意的是,因为这里建立起来的是tiny kernel,所以不支持一些C链接库函数。当然,你也可以建立其它类型的platform。
4) 建立空的动态链接库dll_test,用console_test来呼叫dll_test,追踪DLL载入的过程。主要是看其加载的地址pMod->BasePtr。在Platform Builder的target中看Modules and Symbols窗口,可以看到DLL加载的映像地址范围和重新寻址后的地址范围。
2. 启动时加载DLL
对loader_test的debug版本进行追踪,在loader.c的程序代码上设置断点,观察其在启动时,依次加载的几个DLL的情况。表8.1是loader_test启动完成时,已加载的几个DLL和EXE:
表8.1 载入的DLL和EXE
Module | Image Address Range | Relocated Data Address Range | Status |
coredll.dll | 0x03FC0000-0x03FF6FFF | 0x01FFF000-0x01FFF780 | loaded |
filesys.exe | 0x04010000-0x04049FFF |
| loaded |
fsdmgr.dll | 0x03F80000-0x03F92FFF | 0x01FF9000-0x01FF98F0 | loaded |
Kd.dll | 0x80293000-0x802A9FFF | 0x80354000-0x8035A584 | loaded |
Nk.exe | 0x80220000-0x802B8FFF | 0x80330000-0x8034A7E6 | Loaded |
relfsd.dll | 0x03FB0000-0x03FB7FFF | 0x01FFD000-0x01FFDABC | Loaded |
shell.exe | 0x06010000-0x0601CFFF |
| Loaded |
toolhelp.dll | 0x03FA0000-0x03FA3FFF | 0x01FFB000-0x01FFB050 | loaded |
已经加载的module有5个DLL和3个EXE。3个EXE中,fileSys.exe在Slot2,nk.exe在核心地址空间,shell.exe在Slot3,属于device部分。5个DLL中,kd.dll也在核心地址空间。剩下的4个DLL,都在Slot1。而Slot1正是载入ROM DLL的地方。由于现在还没有使用者程序,所以这几个DLL是原先储存于ROM中,XIP型的DLL。(详细内存映像,请参照内存管理部分)。Windows CE的内存映像中,Slot0 (地址0x00000000-0x02000000) 是目前处理程序的地址空间。DLL经过重新寻址后,对应到目前处理程序的地址空间。由上往下依次是coredll.dll,relfsd.dll,toolhelp.dll,fsdmgr.dll。
3. 使用者DLL加载过程小结
每个DLL对应一个module结构,只有一个BasePtr,所以不同的处理过程调用相同DLL加载的基地址是一样的。module中还包含DLL或EXE的可执行文件指针信息、档案信息、内存信息、存取控制信息等,这些都是不同的处理程序所要共享的。同一个处理程序对一个DLL的多次加载只会增加它的引用计数。它只会加载到处理程序的虚拟地址空间中一次,每次对它的引用都是引用相同的module,用相同的执行档指标,只是增加一个引用计数而已。多个处理程序加载相同的DLL时,因为使用共同的BasePtr,所以就必须保留每个处理程序的每个DLL的虚拟地址空间,这可以藉由DllLoadBase来达成。每次载入新的DLL后,就修改DllLoadBase的值,而再加载时就从这里开始。所以就保留了所有其它处理程序已经加载之DLL的空间。确保呼叫时不会出错。图8.3简单说明了这一加载过程。
|
DLL1 |
DLL2 |
DLL3 |
DLL1 |
Process1 |
process2 |
DllLoadBase(0) |
DllLoadBase |
(1) |
(2) |
process3 |
图8.3 处理程序加载DLL的示意图
process1、process2、process3是3个处理程序,各自有自己的处理程序空间。Process1加载DLL1时,从DllLoadBase所指的地址开始,将DLL加载到process1的虚拟地址空间,然后修改DllLoadBase的值到指向地址 (1)。Process2也需要加载DLL1,但是DLL1已经加载过 (从module链接串行中可以查出)。所以,还是引用process1加载DLL1时产生的module,由于用相同的BasePtr,所以加载相应的地址空间。Process3加载DLL2,它从 (1) 这个地址开始加载,DllLoadBase现在指向 (2) 的地址。而process1加载DLL3时,空下了DLL2的空间,以备它以后加载DLL2时使用。