第 5 章 进程与线程 --- 5.7 Windows DLL 的装入和连接
本节承接 5.6「进程创建与映像装入」,聚焦在 DLL 依赖解析和 IAT 连接。
概述
DLL(Dynamic-Link Library,动态链接库)是 Windows 操作系统中最基本的代码共享与复用单位。一个普通的 Win32 应用在启动时至少会装入 ntdll.dll、kernel32.dll(或 kernelbase.dll)、可能还有 shell32.dll、user32.dll、gdi32.dll......这一串链式依赖的解析、映射、重定位与「接入(Snap)」工作,全部由 ntdll.dll 内部的 LDR(Loader)子系统 在用户态完成。
「装入」和「连接」在本章里各指什么?
- 装入(Loading):把磁盘上的 DLL 文件,通过内存映射(Section)映射到本进程的虚拟地址空间,并完成基址重定位,让 DLL 的指令在运行时能正确执行。对应 5.7.1~5.7.4 的内容。
- 连接(Linking at runtime / Snap) :在 DLL 已经被装入之后,解析它的导入表(
.idata),把它所依赖的其他 DLL 中的函数地址写进它的 IAT 槽位 ,让call [IAT_xxx]指令在第一次执行时就能落到正确的目标函数上。对应 5.7.5~5.7.6 的内容。换句话说------「装入」让 DLL『存在于内存中』,『连接』让 DLL『能调用别人』。 只有这两步都完成,DLL 才算真正「可用」。
想象你在一家连锁咖啡馆点一杯拿铁:点单(LoadLibrary)→ 查询原料仓库(搜索路径)→ 调运咖啡豆/牛奶(打开文件并创建 Section)→ 在吧台里组装咖啡机(映射到地址空间并做重定位)→ 插上电源(解析 IAT 让函数指针接通 --- 这就是「连接」) → 出杯(调用 DllMain)。本节就是这套「下单 → 出杯」流程的完整说明书。
本节内容概览
- 5.7.0 框架图:一张总览图把 LoadLibrary 到返回 HMODULE 的全过程串起来。
- 5.7.1 DLL 的本质与 HMODULE :DLL 与 EXE 的差别、装载器维护的三条链表、
_LDR_DATA_TABLE_ENTRY数据结构。 - 5.7.2 装载器搜索顺序与路径解析 :标准搜索顺序、KnownDLLs、SafeDllSearchMode、
SetDllDirectory的作用。 - 5.7.3 LdrLoadDll 的内部执行流程:从检查已加载链表、到打开文件、创建 Section、MapAndSnap、再到插入链表的完整六步。
- 5.7.4 DLL 映射与基地址重定位 :ImageBase 冲突检测、
.reloc段结构、LdrpFixupRelocs的工作方式。 - 5.7.5 导入表解析与 IAT Snap(「连接」的核心) :什么是动态连接、为什么连接必须在运行时做、INT/IAT 两份表的分工、
LdrpWalkImportDescriptor递归加载依赖、绑定导入、延迟加载、Forwarder。 - 5.7.6 导出表与 GetProcAddress:三数组结构、二分查找、Forwarder 解析、序数 vs 命名导出。
- 5.7.7 DLL 入口点与初始化顺序 :
LdrpCallInitRoutine、InInitializationOrder 列表、TLS、DllMain 的死锁陷阱。 - 5.7.8 卸载与引用计数 :
LdrUnloadDll/FreeLibrary的递减与回收流程。 - 5.7.9 特殊装载与安全:SxS/Manifest、Loader Lock、ASLR/DEP/SafeSEH。
- 5.7.10 为什么会这样------10 个设计哲学问答:对前面各节核心设计做一次「反推式」回顾。
学习目标
读完本节后,读者应当能够:
- 在不看源码的前提下,说清从
LoadLibrary("foo.dll")到返回HMODULE的完整步骤。 - 理解
_LDR_DATA_TABLE_ENTRY三条链表的含义与遍历顺序。 - 能够徒手解释
.reloc、.idata、.edata三个 PE 数据目录的结构与作用。 - 理解为什么 DLL 的 DllMain 里不能再调用 LoadLibrary / CreateThread。
- 理解 FreeLibrary 的引用计数机制,排查「为什么 DLL 卸载不掉」的常见原因。
涉及的内核子系统
与 5.6 不同的是,5.7 的主角是 ntdll 的用户态 LDR,内核只是配角。下面列出在 DLL 装入过程中被间接调用的内核子系统:
| 子系统 | 职责 |
|---|---|
| ntdll!Ldr | 模块链表维护、路径搜索、PE 解析、IAT Snap、DllMain 调用顺序(主角) |
| ntoskrnl!Mm | NtCreateSection 创建 SEC_IMAGE 段对象、NtMapViewOfSection 映射 view、NtUnmapViewOfSection 回收 |
| ntoskrnl!Ob | 对 Section/File 对象做引用计数、句柄表管理 |
| ntoskrnl!Ps | 进程/线程创建过程中触发 LdrInitializeThunk;TLS 槽分配时访问 PEB |
| ntoskrnl!Se | 打开 DLL 文件时做权限检查 |
关键函数调用链
用户态调用者 (kernel32 / 你的应用)
│
├─► LoadLibraryW(lpLibFileName) [kernel32/client/loader.c]
│ │
│ └─► LoadLibraryExW(..., 0, 0)
│ │
│ └─► LdrLoadDll(DllPath, Flags, &Filename, &Base) [ntdll/ldr/ldrapi.c]
│ │
│ ├─► LdrpLoadDll() 「主循环」
│ │ ├─► LdrpFindLoadedDll() 先查链表
│ │ ├─► LdrpOpenImageFile() NtOpenFile
│ │ ├─► NtCreateSection(SEC_IMAGE)
│ │ ├─► LdrpMapAndSnapProcess()
│ │ │ ├─► NtMapViewOfSection
│ │ │ ├─► LdrpFixupRelocs()
│ │ │ ├─► LdrpWalkImportDescriptor()
│ │ │ │ └─► 递归 LdrpLoadDll(依赖)
│ │ │ ├─► LdrpSnapIAT() / LdrpSnapThunk()
│ │ │ └─► LdrpProcessTls()
│ │ └─► LdrpCallInitRoutine() DLL_PROCESS_ATTACH
│ │
│ └─► 返回 HMODULE = (HMODULE)Base
│
▼
GetProcAddress(hModule, "FunctionName") [kernel32/client/loader.c]
│
└─► LdrGetProcedureAddress() [ntdll/ldr/ldrapi.c]
└─► 二分查找导出表 → 返回函数地址
5.7.0 框架图
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ Windows DLL 装入全流程(从 LoadLibrary 到返回 HMODULE) │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ 阶段 A:用户发起调用 │ │
│ │ LoadLibraryW("foo.dll") │ │
│ │ └─► LoadLibraryExW(..., 0, 0) │ │
│ │ └─► ntdll!LdrLoadDll │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ 阶段 B:路径解析与搜索(LdrpLoadDll 的前缀) │ │
│ │ 1. 如果传入的是全路径 → 直接使用 │ │
│ │ 2. 如果只有文件名 → 依次搜索: │ │
│ │ a. 应用所在目录 │ │
│ │ b. SetDllDirectory 追加的目录(若有) │ │
│ │ c. System32 │ │
│ │ d. 系统目录 (GetSystemDirectory) │ │
│ │ e. Windows 目录 │ │
│ │ f. %PATH% 中列出的目录 │ │
│ │ 3. 每一步都做:NtOpenFile → 检查是否为有效 PE → 成功则停止 │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ 阶段 C:查询是否已加载 + 创建 Section │ │
│ │ 1. LdrpFindLoadedDll:遍历 InLoadOrder 链表,比较 BaseDllName / FullDllName │ │
│ │ └─► 命中:直接 ++LoadCount 返回;未命中:继续 │ │
│ │ 2. NtCreateSection(FileHandle, SEC_IMAGE | SEC_COMMIT) │ │
│ │ └─► 内核 Mm 把磁盘上的 PE 识别为「可执行映像段」 │ │
│ │ 3. 再次查询「已映射 Section 列表」(SectionHandle 级别的去重) │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ 阶段 D:映射与修正(LdrpMapAndSnap) │ │
│ │ 1. NtMapViewOfSection:把 Section 映射到 ImageBase(若可用) │ │
│ │ └─► 若 ImageBase 已被占用 → 让内核选一个空闲地址 │ │
│ │ 2. 读取 ImageBase + SizeOfImage + 各数据目录偏移 │ │
│ │ 3. LdrpFixupRelocs:对 .reloc 中每个条目加/减 (实际基址 - 期望基址) │ │
│ │ 4. LdrpWalkImportDescriptor:递归 LoadLibrary 解析每个依赖 DLL │ │
│ │ 5. LdrpSnapIAT / LdrpSnapThunk:把 IAT 中每一项改写为目标函数的实际地址 │ │
│ │ 6. 处理 TLS:在 PEB.TlsBitmap 分配槽位,调用 TlsAlloc 语义 │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ 阶段 E:初始化与接入 │ │
│ │ 1. 分配并填充 _LDR_DATA_TABLE_ENTRY │ │
│ │ 2. 插入三条链表尾部: │ │
│ │ ├─► InLoadOrderModuleList │ │
│ │ ├─► InMemoryOrderModuleList │ │
│ │ └─► InInitializationOrderModuleList (先把被依赖的 DLL 先插入) │ │
│ │ 3. LdrpCallInitRoutine:依次调用已排好序的 DllMain(DLL_PROCESS_ATTACH) │ │
│ │ └─► 有失败则回滚:卸载刚才装入的 DLL │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 返回: HMODULE hModule = (HMODULE)DllBase │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
5.7.1 DLL 的本质与 HMODULE
5.7.1.1 DLL vs EXE 的差异
从 PE 结构角度看,DLL 和 EXE 几乎相同 :同样的 IMAGE_DOS_HEADER、同样的 IMAGE_NT_HEADERS、同样的节区(.text/.data/.rdata/.reloc)。二者的差异只体现在下面这几个字段上:
| 字段 | EXE | DLL |
|---|---|---|
Characteristics 中的 IMAGE_FILE_DLL (0x2000) |
通常为 0 | 置 1 |
OptionalHeader.Subsystem |
IMAGE_SUBSYSTEM_WINDOWS_GUI 或 IMAGE_SUBSYSTEM_WINDOWS_CUI |
同为 WINDOWS_GUI / WINDOWS_CUI(不区分) |
OptionalHeader.AddressOfEntryPoint |
指向 _WinMainCRTStartup / _mainCRTStartup |
指向 _DllMainCRTStartup(由 C Runtime 提供) |
OptionalHeader.ImageBase |
默认 0x00400000(32 位) |
默认 0x10000000(32 位,VC 旧版);在现代编译器上通常相同 |
.edata(导出表) |
通常为空(普通应用不导出函数) | 必须存在,否则 DLL 无法被外部调用 |
你甚至可以把一个
.dll文件改扩展名后用rundll32作为「应用」跑起来,也可以把 EXE 的导出表填上后再LoadLibrary它------结构层面没有任何禁止。
5.7.1.2 HMODULE = 映像基址(PVOID)
设计哲学:「模块句柄不是内核句柄,而是一个用户态地址指针。」
HMODULE hModule = LoadLibrary("foo.dll"); 中 hModule 的值,就是 DLL 在本进程地址空间中的映像基址 DllBase 。你可以直接把它当 PIMAGE_DOS_HEADER 来用:
c
HMODULE hMod = LoadLibraryW(L"foo.dll");
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hMod;
assert(dos->e_magic == IMAGE_DOS_SIGNATURE); // 总是成立
为什么会这样?因为:
- DLL 的装入完全在 ntdll(用户态)完成,内核只负责「创建 Section」和「映射 view」,不维护任何 module handle 表。
- 每个 DLL 在进程里都必然有一个唯一的基址;用它当句柄就省掉了一次额外的句柄/指针映射。
- 这种做法自 Windows 3.x 时代沿用至今,兼容性要求让它无法再被推翻重设计。
代价 :它把「句柄」的抽象污染成了「指针」。调用者若把它当作 CloseHandle 风格的内核句柄来使用,会立刻崩溃------这也是为什么 Win32 API 专门有
FreeLibrary而不是CloseHandle。
5.7.1.3 三条模块链表
PEB(进程环境块)中的 PEB_LDR_DATA 结构里,维护着三条各自独立但指向相同节点 的双向链表。每条链表的头部都是 LIST_ENTRY;链表上挂的都是同一个 _LDR_DATA_TABLE_ENTRY 结构,区别只是使用了该结构中不同的 LIST_ENTRY 字段:
PEB.Ldr → PEB_LDR_DATA
├─► InLoadOrderModuleList ← 按装入顺序排序
│ (新模块插在尾部)
├─► InMemoryOrderModuleList ← 按在内存中的基址升序排列
│ (供 GetModuleHandleEx 快速查找)
└─► InInitializationOrderModuleList ← 按初始化顺序排序
(依赖先于被依赖被插入;
DllMain 调用时按此顺序)
每条链表挂的节点都是 _LDR_DATA_TABLE_ENTRY。这个结构内部有三组 LIST_ENTRY:
c
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase; // 就是 HMODULE
PVOID EntryPoint; // DllMain 的地址
ULONG SizeOfImage;
PUNICODE_STRING FullDllName; // 完整路径
PUNICODE_STRING BaseDllName; // 文件名(不含路径)
ULONG Flags;
USHORT LoadCount; // LoadLibrary 的引用计数
USHORT TlsIndex; // __declspec(thread) 的槽位
// ......
} LDR_DATA_TABLE_ENTRY;
当我们说「把 DLL 插入到链表」时,实际做的是把 InLoadOrderLinks 接到 PEB_LDR_DATA 的 InLoadOrderModuleList 尾部;同时把 InMemoryOrderLinks 按 DllBase 升序插入到 InMemoryOrderModuleList;InInitializationOrderLinks 则在 DllMain 真正被调用前一次性排好序。
三条链表各司其职:装入顺序 反映加载历史;内存顺序 加速二分/线性查找;初始化顺序 确保 DllMain(DLL_PROCESS_ATTACH) 按依赖顺序执行。
5.7.1.4 PEB 中的 LDR 数据结构全景
下面这段伪 C 代码展示了从进程到 DLL 的「向下钻取」路径(与 ReactOS 真实结构基本一致):
c
// 1. 拿到本进程的 PEB
PEB *peb = NtCurrentTeb()->ProcessEnvironmentBlock;
// 2. 拿到 LDR 子系统
PEB_LDR_DATA *ldr = peb->Ldr;
// 3. 遍历 InLoadOrderModuleList
for (LIST_ENTRY *le = ldr->InLoadOrderModuleList.Flink;
le != &ldr->InLoadOrderModuleList;
le = le->Flink)
{
LDR_DATA_TABLE_ENTRY *entry = CONTAINING_RECORD(le,
LDR_DATA_TABLE_ENTRY,
InLoadOrderLinks);
DPRINT1("DllBase=%p Name=%wZ\n", entry->DllBase, entry->BaseDllName);
}
源码位置:sdk/include/ndk/ldrtypes.h(file:///d:/reactos/sdk/include/ndk/ldrtypes.h)、dll/ntdll/ldr/ldrutils.c(file:///d:/reactos/dll/ntdll/ldr/ldrutils.c)
5.7.2 装载器搜索顺序与路径解析
5.7.2.1 设计意图
核心问题 :给定一个 LoadLibrary("foo.dll") 调用,系统如何在 10 秒内找到正确的 foo.dll,而不是攻击者放置的同名冒牌货?
设计哲学 :「先查已加载表(避免重复)→ 再查 KnownDLLs(安全白名单)→ 最后在文件系统上按优先级搜索。」
本节定位:理解搜索顺序是理解 DLL 预劫攻击(Binary Planting)与防护的第一步。
5.7.2.2 标准搜索顺序
当调用者传入的不是完整路径(例如 L"foo.dll")时,装载器按照如下顺序在应用目录 → 系统目录 → Windows 目录 → PATH 之间尝试打开文件,一旦打开成功且通过 PE 校验即停止:
1. 应用所在目录(即主 EXE 被装载的目录)
2. 由 SetDllDirectory 设置的目录(如果调用过 SetDllDirectory)
3. Windows 的系统目录(System32,通过 GetSystemDirectory 获取)
4. 16 位系统目录(即 System;现代系统上通常没有实际文件,但仍会尝试)
5. Windows 目录(GetWindowsDirectory)
6. 当前工作目录(GetCurrentDirectory) ← 受 SafeDllSearchMode 控制
7. %PATH% 环境变量中列出的每一个目录
第 6 项的特殊之处 :在 Windows XP SP2 之前,「当前目录」被放在「Windows 目录」之前,这使得攻击者可以在一个可写的 CWD 中放入冒牌 DLL 来劫持应用------即经典的「CWD 预劫」。Windows XP SP2(KB2264107)引入
HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\SafeDllSearchMode注册表项:
SafeDllSearchMode = 1(默认):当前目录被移动到最后(第 6 位)SafeDllSearchMode = 0:保留旧顺序,当前目录优先于系统目录(不推荐)
5.7.2.3 KnownDLLs:系统的预映射白名单
「KnownDLLs」是一个注册表项(位于 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs),其中列出了 ntdll、kernel32、user32、gdi32、shell32、comctl32、rpcrt4......等一批系统自带且永不可能被第三方覆盖的 DLL。
它的工作方式是:
- 系统启动时 :会话管理器(smss.exe)读取这个列表,对每一个 DLL 调用
NtCreateSection创建一个SEC_IMAGESection,并把 Section 句柄缓存到内核的一个全局表中。 - 后续任意进程在 LoadLibrary 命中 KnownDLLs 时 :LDR 不需要打开磁盘文件、也不需要做路径搜索,直接复用已存在的 Section,把它映射到本进程地址空间即可。
- 文件路径解析:KnownDLLs 的「DllDirectory」值指定 DLL 实际所在目录,避免了 %PATH% 污染。
这个机制除了性能收益外,最关键的收益是安全------KnownDLLs 列表中的 DLL 永远来自系统目录,攻击者无法在应用目录或 PATH 中塞一个同名文件来替换它。
ReactOS 在 dll/ntdll/ldr/ldrapi.c(file:///d:/reactos/dll/ntdll/ldr/ldrapi.c) 的
LdrpLoadDll中通过LdrpCheckForKnownDll(实际在 ReactOS 里合并进了路径搜索流程)来处理这条路径。
5.7.2.4 SetDllDirectory / AddDllDirectory
Win32 提供了两个 API 让调用者主动影响搜索顺序:
SetDllDirectory(lpPathName):把一个自定义目录「插入」到第 2 位(应用目录之后、System32 之前)。再次以NULL调用可撤销。每次调用都会覆盖前一次的目录。AddDllDirectory(Windows 8+):可以累积多个自定义目录,不互相覆盖。
它们的底层机制是修改 PEB 中的 ProcessParameters->DllPath 字段------这是一个以 ; 分隔的路径串,正是装载器在第 6 步迭代 PATH 时真正读取的对象。
c
// 伪代码:LdrpBuildDllSearchPath
VOID LdrpBuildDllSearchPath(PUNICODE_STRING OutPath, PCWSTR ExplicitDir)
{
// 依次拼接:应用目录 ; SetDllDirectory 目录 ; System32 ; System ; Windows ; CWD ; PATH
RtlAppendUnicodeToString(OutPath, L"C:\\App\\");
RtlAppendUnicodeToString(OutPath, L";");
if (PEB->ProcessParameters->DllPath) {
RtlAppendUnicodeStringToString(OutPath, PEB->ProcessParameters->DllPath);
}
// ...
}
5.7.2.5 .local 文件、SxS、Win32s 的兼容重定向(简述)
历史上 Windows 还引入过三种影响搜索顺序的机制(本节只做概念提示,不展开细节):
| 机制 | 作用 |
|---|---|
.local 文件 |
在 EXE 同目录放一个 myapp.exe.local 空文件,强制让系统在 EXE 同目录搜索 DLL,优先于系统目录;用来绕过 DLL Hell |
| SxS 并排组件(Fusion / WinSxS) | 通过 EXE 内嵌的 manifest 指定 dependency,让装载器在 WinSxS 目录中找到特定版本的 DLL;Visual C++ CRT 的多版本共存就是典型例子 |
| Win32s / 16 位兼容 | 装载器仍会尝试打开 16 位系统目录里的 DLL;在 32 位 Windows 上有 WOW32 子系统参与 |
现代工程实践中,
.local基本不再被推荐;SxS 也在让位于Package.Current(UWP/Desktop Bridge)。但 ReactOS 作为 NT 架构复刻系统,仍需在 LDR 层保留对.local和 manifest 依赖解析的分支。
源码位置 :dll/ntdll/ldr/ldrinit.c(file:///d:/reactos/dll/ntdll/ldr/ldrinit.c) 中的 LdrpInitializeProcessCompat 反映了 ReactOS 对 manifest / 兼容性数据库的处理思路。
5.7.3 LdrLoadDll 的内部执行流程
5.7.3.1 签名与参数
LdrLoadDll 是 ntdll 对外暴露的正式 API,签名如下:
c
NTSTATUS NTAPI LdrLoadDll(
IN PWSTR DllPath OPTIONAL, // 额外搜索路径;可 NULL
IN PULONG DllCharacteristics OPTIONAL, // 返回实际的 Characteristics
IN PUNICODE_STRING DllName, // 要装入的 DLL 文件名
OUT PVOID *DllBase // 成功时返回映像基址(即 HMODULE)
);
上层的 LoadLibraryExW 会把 lpLibFileName 转成 UNICODE_STRING,再调用它。
5.7.3.2 六步主流程(LdrpLoadDll)
下面把 LdrpLoadDll 拆成 6 个逻辑步骤。真实 ReactOS 源码中这 6 步被写成一个连续的函数(dll/ntdll/ldr/ldrapi.c 里的 LdrpLoadDll);为便于理解,我们把它切开来看。
┌──────────────────────────────────────────────────────────────────────────────┐
│ LdrpLoadDll 六步主流程 │
│ │
│ 步骤 1:检查是否已加载 ------ LdrpFindLoadedDll │
│ 步骤 2:打开文件,验证 PE 头部 ------ LdrpOpenImageFile │
│ 步骤 3:NtCreateSection 创建 SEC_IMAGE Section │
│ 步骤 4:基于 Section 再查一次已加载列表(避免不同路径指向同一份 DLL) │
│ 步骤 5:LdrpMapAndSnap ------ 映射、重定位、解析导入、Snap IAT │
│ 步骤 6:插入三条链表,递增 LoadCount,返回 DllBase │
└──────────────────────────────────────────────────────────────────────────────┘
步骤 1:LdrpFindLoadedDll ------ 检查是否已加载
c
// 伪代码
LDR_DATA_TABLE_ENTRY *LdrpFindLoadedDll(PCUNICODE_STRING BaseName,
PCUNICODE_STRING FullPath)
{
PEB_LDR_DATA *ldr = NtCurrentPeb()->Ldr;
for (LIST_ENTRY *le = ldr->InLoadOrderModuleList.Flink;
le != &ldr->InLoadOrderModuleList;
le = le->Flink)
{
LDR_DATA_TABLE_ENTRY *entry = CONTAINING_RECORD(
le, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
// 优先用完整路径比较;若调用者没传路径则用文件名比较
if (FullPath && RtlEqualUnicodeString(FullPath, &entry->FullDllName, TRUE))
return entry;
if (BaseName && RtlEqualUnicodeString(BaseName, &entry->BaseDllName, TRUE))
return entry;
}
return NULL;
}
要点 :比较路径时不区分大小写(最后一个
TRUE参数),因为 Windows 文件系统是大小写不敏感的。
若命中,则只需要做一次 ++entry->LoadCount 就可以返回 entry->DllBase,整个 LoadLibrary 的代价就只有一次链表遍历(几十纳秒)。这也是「为什么频繁 LoadLibrary 不会很慢」的核心原因。
步骤 2:LdrpOpenImageFile ------ 打开文件并验证 PE 头部
若第 1 步没有命中,装载器就必须去磁盘上找文件。这里会执行 5.7.2 节描述的路径搜索,对每一个候选目录调用:
NtOpenFile(→ FileHandle) → 读取 DOS/PE 头 → 验证 signature → 成功即跳出
如果搜索完所有目录都没有找到文件,返回
STATUS_DLL_NOT_FOUND(被 Win32 层转成 Win32 错误码ERROR_MOD_NOT_FOUND)。
步骤 3:NtCreateSection ------ 创建 SEC_IMAGE Section
打开文件后还不能直接把文件内容 ReadFile 到进程的堆里。Windows 要求所有可执行映像都必须通过内存映射 Section 来装入,因为:
- 多个进程共享同一份物理页面(DLL 的
.text节在物理内存中只有一份); - 内核可以对 Section 做「复制时写入(Copy-on-Write)」;
.reloc的重定位工作也依赖 Section 级别的管理。
c
// 伪代码:创建 Section
HANDLE SectionHandle;
Status = NtCreateSection(&SectionHandle,
SECTION_MAP_EXECUTE | SECTION_MAP_READ,
NULL,
NULL,
PAGE_EXECUTE_READ,
SEC_IMAGE, // 关键:告诉内核这是一个 PE
FileHandle);
NtClose(FileHandle); // Section 已经引用了文件对象,FileHandle 可以关闭
步骤 4:第二次「已加载检查」
在 ReactOS 与 Windows 的实现中,完成 NtCreateSection 后会再做一次查找------这次不是按文件名,而是按 Section 的「内部文件对象」去比。因为可能出现这种情况:
- 线程 A 打开了
C:\App\foo.dll,正在创建 Section; - 线程 B 同时打开了
C:\App\Plugins\..\foo.dll(这是同一个文件,但路径写法不同)。
若只在第 1 步按字符串比较就会漏掉,导致同一个 DLL 被映射两次,最终 DllMain 也被调用两次,引发各种诡异的全局变量初始化两次的 bug。第 4 步就是为了兜住这个边界条件。
步骤 5:LdrpMapAndSnap ------ 本小节的「大动作」
这一步会做四件事(后续 5.7.4 / 5.7.5 会展开):
NtMapViewOfSection:把 Section 映射到地址空间;LdrpFixupRelocs:若实际基址 ≠ 期望基址,修正.reloc中的指针;LdrpWalkImportDescriptor:解析.idata,对每个依赖 DLL 递归调用LdrpLoadDll;LdrpSnapIAT/LdrpSnapThunk:把 IAT 里的每一项替换为真实函数地址;
为了节省本节篇幅,第 5 步拆到后面两小节展开。
步骤 6:插入三条链表并返回
最后一步是「登记入库」:
c
// 伪代码:把一个新的 LDR_DATA_TABLE_ENTRY 插到三条链表里
LDR_DATA_TABLE_ENTRY *entry = LdrpAllocateDataTableEntry();
entry->DllBase = ActualBase;
entry->EntryPoint = LdrpGetEntryPoint(NtHeaders);
entry->SizeOfImage = NtHeaders->OptionalHeader.SizeOfImage;
entry->LoadCount = 1;
RtlCopyUnicodeString(&entry->BaseDllName, &BaseName);
RtlCopyUnicodeString(&entry->FullDllName, &FullPath);
// ① 装入顺序 ------ 新模块总是追加到尾部
InsertTailList(&ldr->InLoadOrderModuleList, &entry->InLoadOrderLinks);
// ② 内存顺序 ------ 按 DllBase 升序插入(便于后续 O(log n) 查找)
InsertEntryByAddress(&ldr->InMemoryOrderModuleList, entry);
// ③ 初始化顺序 ------ 稍后在 LdrpCallInitRoutine 之前再根据依赖排一次
// (先把它挂在尾部,排序时重连 FLink/BLink)
InsertTailList(&ldr->InInitializationOrderModuleList, &entry->InInitializationOrderLinks);
*DllBase = entry->DllBase;
关于 Loader Lock :上述所有步骤都在
LdrpLoaderLock(一个 RTL_CRITICAL_SECTION)保护下执行。这保证了多线程同时 LoadLibrary 时不会把链表搞坏;但也带来了 5.7.9 节将要谈到的「DllMain 死锁」隐患。
源码位置 :dll/ntdll/ldr/ldrapi.c(file:///d:/reactos/dll/ntdll/ldr/ldrapi.c) 的 LdrLoadDll、LdrpLoadDll、LdrpMapAndSnap。
5.7.4 DLL 映射与基地址重定位
5.7.4.1 ImageBase 与冲突检测
每个 PE 在链接时都声明了一个「我期望被加载到的虚拟地址」------OptionalHeader.ImageBase。例如:
- 32 位 EXE 默认
0x00400000; - 32 位 DLL 默认
0x10000000; - 64 位 DLL 默认
0x0000000180000000;
这个「期望地址」在链接时就被写死在了机器码里------所有直接使用绝对地址的指令(例如 mov eax, [0x10001234]、或跳转表条目)都以 ImageBase 为基准。
但 :EXE 永远第一个被加载,所以它几乎一定能如愿占用自己的 ImageBase(除非启用了 ASLR)。而 DLL 则不然------它是「后来的」,其 ImageBase 可能已被另一个 DLL 占用。此时装载器只能把它加载到另一个地址,然后对所有硬编码的指针做「加减」------这个工作就是重定位(Relocation)。
5.7.4.2 SEC_IMAGE vs 普通数据 Section
NtCreateSection 的第 6 个参数 AllocationAttributes 传入 SEC_IMAGE 时,内核会:
- 自己读取 PE 头,了解有几个节区、每个节区的内存属性、SizeOfImage;
- 把整个 DLL 映射到调用者指定(或空闲)的虚拟地址范围;
- 自动应用
.reloc中的内核级重定位------内核内部对自己的驱动也使用同样的机制。
在用户态层面,ntdll 的 LDR 反而不自己去调用
ReadFile/VirtualAlloc------它把最底层的映射工作都交给了内核的SEC_IMAGE。
5.7.4.3 .reloc 段的结构
重定位信息被存在 .reloc 节区里,按 4KB 页面 为单位组织。每个页面一个块:
┌──────────────────────────────────────────────┐
│ IMAGE_BASE_RELOCATION (8 字节) │
│ ├── VirtualAddress : 本块相对于 ImageBase 的 RVA │
│ └── SizeOfBlock : 本块大小(含头部 + 所有条目) │
├──────────────────────────────────────────────┤
│ 条目 1 (2 字节) : | Type (4 bit) | Offset (12 bit) | │
│ 条目 2 (2 字节) : | Type (4 bit) | Offset (12 bit) | │
│ ... │
│ 最后一个条目 (2 字节) : 0 (用作哨兵) │
└──────────────────────────────────────────────┘
(重复上述结构,直到所有需要重定位的页面都被描述完)
- Type = 重定位类型。对 x86 来说最重要的是
IMAGE_REL_BASED_HIGHLOW(值 3,占 32 位,整条指令都要改); - Offset = 本页内需要修改的字节偏移(12 位,范围 0~4095);
(VirtualAddress + Offset)= 真正需要重定位的 32 位指针在映像中的 RVA。
为什么按页面组织?因为在页面交换/写时,Windows 只需要为「被写过的页面」额外申请物理内存------重定位时会把页面从「只读的共享物理页」变成「私有写时复制页」。按页组织让操作系统按页粒度释放磁盘 I/O 与物理内存压力。
5.7.4.4 LdrpFixupRelocs 伪代码
c
VOID LdrpFixupRelocs(PVOID DllBase, PIMAGE_NT_HEADERS NtHeaders, PVOID ActualBase)
{
// 1. 计算偏移量(实际被加载到的地址 - 链接时期望地址)
INT_PTR Delta = (INT_PTR)ActualBase - (INT_PTR)NtHeaders->OptionalHeader.ImageBase;
if (Delta == 0) return; // 正好落在期望基址,无需重定位
// 2. 取得 .reloc 段
IMAGE_DATA_DIRECTORY *relocDir =
&NtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
if (relocDir->Size == 0) return;
PIMAGE_BASE_RELOCATION block =
(PIMAGE_BASE_RELOCATION)((PBYTE)DllBase + relocDir->VirtualAddress);
PBYTE blockEnd = (PBYTE)block + relocDir->Size;
while (block < blockEnd && block->SizeOfBlock > 0)
{
// 3. 计算这个 block 覆盖的页面起始地址
PBYTE pageBase = (PBYTE)DllBase + block->VirtualAddress;
int numEntries = (block->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2;
USHORT *entries = (USHORT *)(block + 1);
for (int i = 0; i < numEntries; ++i)
{
USHORT entry = entries[i];
int type = entry >> 12; // 高 4 位
int offset = entry & 0x0FFF; // 低 12 位
switch (type)
{
case IMAGE_REL_BASED_HIGHLOW:
*(INT32 *)(pageBase + offset) += (INT32)Delta;
break;
case IMAGE_REL_BASED_HIGH:
// 古老 16 位分段模式,现代系统基本看不到
break;
case IMAGE_REL_BASED_LOW:
break;
case IMAGE_REL_BASED_ABSOLUTE:
// 「无操作」条目:用来让每个 block 的条目数凑成 4 字节对齐
break;
case IMAGE_REL_BASED_DIR64: // x64
*(INT64 *)(pageBase + offset) += Delta;
break;
// MIPS/ARM 等省略
}
}
block = (PIMAGE_BASE_RELOCATION)((PBYTE)block + block->SizeOfBlock);
}
}
/FIXED链接器开关:告诉链接器「这个模块永远不要被重定位」。生成的 PE 里.reloc段将被丢弃,IMAGE_FILE_RELOCS_STRIPPED(0x0001)位会被置位。如果运行时 ImageBase 被占用,整个模块无法被加载。
/DYNAMICBASE(Visual Studio 2008+ 默认):告诉装载器「允许在每次运行时随机选择一个基址」。这就是 ASLR(Address Space Layout Randomization) 。它要求.reloc必须完整保留。
源码位置 :ReactOS 中 LdrpFixupRelocs 的实现分布在 dll/ntdll/ldr/ldrpe.c(file:///d:/reactos/dll/ntdll/ldr/ldrpe.c) 中;pecoff.h 定义了 IMAGE_BASE_RELOCATION 与重定位类型常量。
5.7.5 导入表解析与 IAT Snap(「连接」的核心)
5.7.5.0 什么是动态连接
静态链接 vs 动态连接,区别只有一个:「call 的目标地址是在 link.exe 时定下来,还是在进程运行时由 ntdll 填进去」。
| 阶段 | 做连接的人 | 怎么「接好」 | call 指令目标 |
|---|---|---|---|
| 静态链接(link.exe) | 开发者/构建机器 | 把 .obj 中的每个外部引用直接解析成最终可执行文件里的相对偏移或绝对地址 |
写死在指令里 |
| 动态连接(ntdll!Ldr) | 操作系统 / 装载器 | 在 DLL/EXE 里留下一张导入表 (描述「我需要哪个 DLL 的哪个函数」)和一张槽位表(IAT,准备好被改写);进程启动时由 Ldr 按照导入表「去别人的导出表里查函数地址,再把地址填进我自己的 IAT」 | 第一次执行前还是个占位地址,装载器填好后才指向真实函数 |
换句话说,「动态连接」中的「连接」,就是把 IAT 里的占位值替换成目标函数在内存中的真实地址这一动作。下面这张图把「连接前 / 连接后」摆在一起看:
foo.dll 的 IAT(连接前 --- 文件中) foo.dll 的 IAT(连接后 --- 内存中)
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ IAT[0] → RVA 指向 "CreateFileW" │→│ IAT[0] → 0x7C810000 ← kernel32!CreateFileW
│ IAT[1] → RVA 指向 "LoadLibraryW" │→│ IAT[1] → 0x7C820000 ← kernel32!LoadLibraryW
│ IAT[2] → RVA 指向 "ReadFile" │→│ IAT[2] → 0x7C830000 ← kernel32!ReadFile
│ ... │ │ ... │
│ 末端:0 (哨兵) │ │ 末端:0 (哨兵) │
└──────────────────────────────┘ └──────────────────────────────┘
↑ ↑
还没接通 --- call 指令会落到 已接通 --- call 指令直接
一个存放着字符串/RVA 的数据段 跳到 kernel32 里的函数
(链接器故意留出的「待填槽」)
「INT(原始意图清单)」始终不变,是连接时的参考
┌──────────────────────────────┐
│ INT[0] → IMAGE_IMPORT_BY_NAME("CreateFileW") │
│ INT[1] → IMAGE_IMPORT_BY_NAME("LoadLibraryW") │ ← 告诉装载器:
│ INT[2] → IMAGE_IMPORT_BY_NAME("ReadFile") │ 「帮我去查这几个名字」
└──────────────────────────────┘
「Snap」这个词来自 ReactOS/Windows 的内部函数名
LdrpSnapIAT/LdrpSnapThunk,直译是「拍一下、卡塔一声接上」。Snap = 连接,两个词在本章中完全同义。
为什么连接不能在 build 阶段一次做完、而必须留给运行时?主要有四类原因(对应后面 5.7.10 的问答):
- DLL 的 ImageBase 只是期望地址,运行时可能被重定位到别的位置,所以函数的绝对地址在 link.exe 时根本不可知;
- DLL 被多个进程共享物理页,不可能为某个 EXE 重写别人 DLL 的内部;
- 延迟加载 / 插件式 DLL:要加载哪个 DLL 可能由运行时的用户输入决定;
- SxS(并排组件)与 manifest:同名字的 DLL 在不同机器 / 不同配置下可能有不同版本,必须在运行时才知道是哪一个。
理解了这一点,5.7.5 的每一步都是在回答「如何把『名称 → 地址』的映射从依赖 DLL 搬运到本 DLL 的 IAT 里」。
5.7.5.1 为什么需要「两份表」------INT / IAT
设计哲学 :「保留一份只读的原始信息(INT),让装载器对另一份可写表(IAT)进行原地改写。」
一个 DLL 若依赖其他 DLL 的函数(比如 foo.dll 依赖 kernel32!CreateFileW),PE 文件中会同时保存两份「目录」:
| 名称 | 英文 | RVA 数据目录 | 用途 | 装载后是否被改写 |
|---|---|---|---|---|
| INT | Import Name Table | OriginalFirstThunk |
保存「按名字导入」时指向的 IMAGE_IMPORT_BY_NAME 条目,或「按序号导入」时的序号标记 |
永远不变 |
| IAT | Import Address Table | FirstThunk |
装载前保存与 INT 相同的内容;装载后被装载器原地改写为目标函数的实际地址 | 被改写 |
这种「双生」结构的原因是:在 Snap 过程中,装载器需要按名字或序号去依赖 DLL 的导出表里查函数------这一步必须读取原始的导入信息。如果只用一份表,一旦把第一项改写为了真实地址,就再也回不去了:既无法判断它原本是按名字还是按序号导入,也无法在调试器中展示「这个导入项原本叫什么」。
5.7.5.2 IMAGE_IMPORT_DESCRIPTOR
导入表由一组 IMAGE_IMPORT_DESCRIPTOR 组成,每个代表一个依赖 DLL。结构如下:
c
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD OriginalFirstThunk; // 指向 INT 的 RVA
DWORD Characteristics;
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 绑定导入时使用
DWORD ForwarderChain; // 绑定导入时的转发链
DWORD Name; // 指向 DLL 名字符串的 RVA
DWORD FirstThunk; // 指向 IAT 的 RVA(装载后被改写)
} IMAGE_IMPORT_DESCRIPTOR;
最后以一个全零的 IMAGE_IMPORT_DESCRIPTOR 作为终止哨兵。
为每个 DLL 装载器都会执行:
-
读取
Name字段,拼出依赖 DLL 的文件名(如L"KERNEL32.dll"); -
递归调用
LdrpLoadDll把依赖 DLL 先装入; -
对
OriginalFirstThunk指向的 INT 数组逐项解析:┌──────────────────────────────────────────────────────┐ │ IMAGE_THUNK_DATA32 (4 字节,按数组存储;以 0 结尾) │ │ │ │ 高 1 位 = IMAGE_ORDINAL_FLAG (0x80000000) │ │ └─► 按序号导入:低 16 位是导出序号 │ │ 高 1 位 = 0 │ │ └─► 按名字导入:整个 32 位是指向 │ │ IMAGE_IMPORT_BY_NAME 的 RVA │ │ ├── Hint (2 字节):加速查找的建议序号 │ │ └── Name ("CreateFileW\0") │ └──────────────────────────────────────────────────────┘ -
在依赖 DLL 的导出表里查到函数地址,写入
FirstThunk指向的 IAT 对应项。
5.7.5.3 LdrpWalkImportDescriptor 与递归加载
c
// 伪代码:遍历 foo.dll 的导入表,逐项把依赖装入并 Snap
VOID LdrpWalkImportDescriptor(PVOID DllBase, PIMAGE_NT_HEADERS NtHeaders)
{
IMAGE_DATA_DIRECTORY *importDir =
&NtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
PIMAGE_IMPORT_DESCRIPTOR desc =
(PIMAGE_IMPORT_DESCRIPTOR)((PBYTE)DllBase + importDir->VirtualAddress);
for (; desc->Name; ++desc)
{
// 1. 装入依赖 DLL
PVOID depBase;
UNICODE_STRING depName;
RtlInitAnsiStringToUnicodeString(&depName,
(PCHAR)((PBYTE)DllBase + desc->Name), FALSE);
LdrLoadDll(NULL, NULL, &depName, &depBase);
// 2. 同步 Snap IAT
LdrpSnapIAT(depBase, // 提供导出的 DLL 基址
(PVOID)((PBYTE)DllBase + desc->OriginalFirstThunk), // INT
(PVOID)((PBYTE)DllBase + desc->FirstThunk)); // IAT
}
}
递归意味着什么?
kernel32.dll又依赖ntdll.dll------后者已经是最早被装入的 DLL 之一,在 5.6 节的LdrInitializeThunk阶段就完成了;当 LdrpWalkImportDescriptor 再次请求它时,只会在第 1 步返回已加载的条目,不会引发二次装载。
5.7.5.4 LdrpSnapIAT / LdrpSnapThunk
LdrpSnapIAT 负责把整个 IAT 数组「接通」。对每一项它都调用 LdrpSnapThunk:
c
NTSTATUS LdrpSnapThunk(PVOID ExportModule, // 提供导出函数的 DLL 基址
PIMAGE_THUNK_DATA32 OrdinalThunk, // INT 的一项
PIMAGE_THUNK_DATA32 AddressThunk) // IAT 对应位置
{
PVOID Function;
if (OrdinalThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG32)
{
// ① 按序号导入:ExtractOrdinal = OrdinalThunk & 0xFFFF
USHORT ordinal = IMAGE_ORDINAL32(OrdinalThunk->u1.Ordinal);
Function = LdrpGetProcAddressByOrdinal(ExportModule, ordinal);
}
else
{
// ② 按名字导入:OrdinalThunk 其实是一个 RVA,指向 IMAGE_IMPORT_BY_NAME
PIMAGE_IMPORT_BY_NAME ibn =
(PIMAGE_IMPORT_BY_NAME)((PBYTE)ExportModule
+ (ULONG_PTR)OrdinalThunk->u1.AddressOfData);
Function = LdrpGetProcAddressByName(ExportModule, ibn->Name);
}
// ③ 写入 IAT(这一步会把原本的 RVA 改写成实际地址)
InterlockedExchangePointer((PVOID *)&AddressThunk->u1.Function, Function);
return STATUS_SUCCESS;
}
为什么用
InterlockedExchangePointer写入?因为在 Bind/Delay Load 场景下,另一个线程可能同时对同一个 IAT 条目做 Snap。原子写入保证读到的值要么是旧值、要么是新值,不会读到被撕裂的中间态。
5.7.5.5 绑定导入(Bound Import)
如果在链接期就把依赖 DLL 的期望 ImageBase + 期望 TimeDateStamp 写进本 DLL 的 IAT,那么运行时若系统实际加载的依赖 DLL 的 ImageBase/TimeDateStamp 与写入的完全匹配,IAT 就已经是正确的函数地址,不需要再 Snap。这就是「绑定导入」。
它的机制是在导入描述符的 TimeDateStamp 字段填写一个非 0 的值。装载器的流程变成:
检查 TimeDateStamp 是否与 kernel32.dll 实际的 TimeDateStamp 相同?
├─► 相同 → 跳过 Snap(节省时间)
└─► 不同 → 回退到普通 Snap 流程
这是一种「编译期赌一把,运行期再兜底」的性能优化。在 ASLR 普及后,绑定导入的命中率下降,现代系统对它的依赖越来越小。
5.7.5.6 延迟加载(Delay Load)
延迟加载不是 PE 原生概念,而是编译器/链接器提供的上层机制 。当你在 VC 上用 /DELAYLOAD:foo.dll 链接时,链接器不会把 foo.dll 的函数塞进普通导入表,而是生成一个自定义的 thunk:
asm
; 第一次调用 delayhook!CreateFileW 时会跳转到这里
__delayLoadHelper:
push dword ptr [esp+4] ; 函数名
call __delayLoadHelper2 ; 在内部 LoadLibrary + GetProcAddress
; 之后把 thunk 指针改写成已解析的函数地址
jmp eax
这使得应用在真正需要 foo.dll 的函数前都不会去 LoadLibrary 它------对启动性能有益。整个延迟加载流程完全发生在用户态的 VC 运行时内部,ntdll 的 LDR 并不感知它。
5.7.5.7 导入转发(Forwarder Export)
一个 DLL 的导出表里可以写「这个名字其实在另一个 DLL 里」,装载器会透明地去查目标 DLL。典型例子:
- 早期的
KERNEL32.DecodePointer在 XP 以后是一个 Forwarder,真正实现在NTDLL.RtlDecodePointer; SHFOLDER.DLL的导出函数多为转发到SHELL32.DLL。
Forwarder 的检测与解析发生在 5.7.6 节 LdrpGetProcAddress 内部:当查到的函数地址落在目标 DLL 的 .edata 段内部时,它不是一个函数指针,而是一个 ASCII 字符串(形如 "NTDLL.RtlDecodePointer"),装载器需要把它拆成「DLL 名 + 函数名」两段再次查询。
源码位置 :dll/ntdll/ldr/ldrpe.c(file:///d:/reactos/dll/ntdll/ldr/ldrpe.c) 的 LdrpSnapIAT / LdrpSnapThunk / LdrpWalkImportDescriptor。
5.7.6 导出表与 GetProcAddress
5.7.6.1 IMAGE_EXPORT_DIRECTORY 的三数组设计
导出表(.edata 段)使用一个 IMAGE_EXPORT_DIRECTORY 头,外加三个平行数组描述所有导出符号:
c
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name; // DLL 名称字符串 RVA
DWORD Base; // 序数的「起始基数」
DWORD NumberOfFunctions; // Functions 数组的项数
DWORD NumberOfNames; // Names / NameOrdinals 数组的项数
DWORD AddressOfFunctions; // RVA → Function RVA 数组
DWORD AddressOfNames; // RVA → 函数名字符串 RVA 数组(已按字典序排序)
DWORD AddressOfNameOrdinals; // RVA → 每个名字对应的序数(-Base 后是 Functions 的索引)
} IMAGE_EXPORT_DIRECTORY;
这组设计是经典的「索引表 + 数据池」模式,可归纳为三条操作路径:
▌路径 A:按名字查 (GetProcAddress("CreateFileW"))
└─► 二分 AddressOfNames 字符串表 → 得到下标 i
└─► 读 AddressOfNameOrdinals[i] → 得到 ordinal(已是 0-based 索引)
└─► 读 AddressOfFunctions[ordinal] → 得到函数 RVA
▌路径 B:按序号查 (GetProcAddress((LPCSTR)MAKEINTRESOURCEA(42)))
└─► ordinal -= Base → 得到 0-based 索引 i
└─► 直接读 AddressOfFunctions[i] → 得到函数 RVA
▌路径 C:Forwarder 检测
└─► 若取得的 RVA 落在 .edata 段的 VirtualAddress~VirtualAddress+Size 范围内
└─► 它不是函数入口,而是 "DLLNAME.FunctionName" 的字符串 RVA
→ 对目标 DLL 再次执行整个 GetProcAddress 流程
为什么把 Names 数组排序而 Functions 数组不排序? 因为 Names 数组是查询入口:调用者只按名字或序号,不可能按函数地址查。把 Names 按字典序排序允许 O(log N) 二分查找;Functions 按「序号 - Base」随机访问,天然是 O(1)。若二者要求同序,就会让序号不再由链接者自由控制,破坏了「序数导出」的向后兼容语义。
5.7.6.2 GetProcAddress 的二分查找伪代码
c
FARPROC WINAPI GetProcAddress(HMODULE hModule, LPCSTR lpProcName)
{
PBYTE Base = (PBYTE)hModule;
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)Base;
PIMAGE_NT_HEADERS nth = (PIMAGE_NT_HEADERS)(Base + dos->e_lfanew);
IMAGE_DATA_DIRECTORY *expDir =
&nth->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
PIMAGE_EXPORT_DIRECTORY ed =
(PIMAGE_EXPORT_DIRECTORY)(Base + expDir->VirtualAddress);
DWORD *Names = (DWORD *)(Base + ed->AddressOfNames);
WORD *NameOrds = (WORD *)(Base + ed->AddressOfNameOrdinals);
DWORD *Functions = (DWORD *)(Base + ed->AddressOfFunctions);
// ① 按序号查找?(当 HIWORD(lpProcName) == 0 时,LOWORD 是序号)
if ((ULONG_PTR)lpProcName < 0xFFFF) {
WORD ordinal = (WORD)(ULONG_PTR)lpProcName;
if (ordinal < ed->Base || ordinal - ed->Base >= ed->NumberOfFunctions)
return NULL;
return (FARPROC)(Base + Functions[ordinal - ed->Base]);
}
// ② 按名字查找:对已按字典序排序的 Names 表做二分
int lo = 0, hi = (int)ed->NumberOfNames - 1;
while (lo <= hi)
{
int mid = (lo + hi) / 2;
int cmp = strcmp((char *)(Base + Names[mid]), (char *)lpProcName);
if (cmp == 0) {
WORD ord = NameOrds[mid]; // 0-based 索引
DWORD funcRva = Functions[ord];
return (FARPROC)(Base + funcRva);
}
if (cmp < 0) lo = mid + 1;
else hi = mid - 1;
}
return NULL;
}
上面代码省略了 Forwarder 的检测。实际做法是:在返回
(FARPROC)(Base + funcRva)之前,检查funcRva是否落在[expDir->VirtualAddress, expDir->VirtualAddress + expDir->Size)区间内------如果是,则Base + funcRva是一个形如"NTDLL.RtlDecodePointer"的字符串,需要递归解析。
5.7.6.3 序数导出 vs 命名导出
- 命名导出 :在 Names 表里存在对应条目。绝大多数 Win32 API 都以命名方式导出(
CreateFileW、LoadLibraryW、GetModuleHandleW......)。 - 序数导出 :只在 Functions 数组中有条目,但 Names 表里没有 对应的命名。旧版
comctl32.dll/ 第三方驱动安装器 DLL 有时使用纯序数导出。
性能方面:按序号查找 = O(1);按名字查找 = O(log N)。对于有几百~几千个导出的系统 DLL(例如 ntdll 有约 2000 个导出,kernel32 有约 1500 个导出),二分只需要十几轮 strcmp,实际开销在几十纳秒级------对大多数应用来说「完全感觉不到」。
源码位置 :dll/ntdll/ldr/ldrutils.c(file:///d:/reactos/dll/ntdll/ldr/ldrutils.c) 中的 LdrpGetProcedureAddress,以及 dll/kernel32/client/loader.c(file:///d:/reactos/dll/kernel32/client/loader.c) 中的 GetProcAddress。
5.7.7 DLL 入口点与初始化顺序
5.7.7.1 DllMain 的约定
每个 DLL 可选地提供一个入口点函数:
c
BOOL WINAPI DllMain(HINSTANCE hinstDll, // == DllBase
DWORD fdwReason, // DLL_PROCESS_ATTACH / _DETACH /
// DLL_THREAD_ATTACH / _DETACH
LPVOID lpvReserved);
C Runtime 提供的 _DllMainCRTStartup 会把标准 C 库(malloc/fopen/localtime)初始化好,再调用你自己实现的 DllMain。
fdwReason 四种通知的语义如下:
| 通知 | 触发时机 | 注意事项 |
|---|---|---|
DLL_PROCESS_ATTACH |
DLL 首次被本进程 LoadLibrary 时调用一次 | 在此做全局初始化;不要 LoadLibrary / CreateThread |
DLL_PROCESS_DETACH |
DLL 即将被 FreeLibrary 卸载、或进程本身退出 | 在此释放资源 |
DLL_THREAD_ATTACH |
本进程有新线程被创建时,在该线程上下文中调用 | 不要在这里做重量级工作(每创建一次线程都要调用一次) |
DLL_THREAD_DETACH |
本线程即将退出 | 释放 TLS 槽上的线程私有数据 |
5.7.7.2 InInitializationOrder 列表
关键问题:如果 A.dll 依赖 B.dll,应该先调用谁的 DllMain?
答案是:先 B 后 A。因为 A 的 DllMain 里可能调用 B 提供的函数,B 必须先完成初始化。
装载器在 LdrpMapAndSnap 之后会把「本次新装入的 DLL」收集到一个临时列表,然后按「被依赖的 DLL 排在前面」进行拓扑排序------具体做法非常朴素:
- 对每个新 DLL,递归地把它依赖的 DLL 插到
InInitializationOrderModuleList的前面; - 最后按列表顺序从头到尾调用
LdrpCallInitRoutine(DLL_PROCESS_ATTACH)。
若某个 DLL 在调用 DllMain 时返回 FALSE(表示「我拒绝被初始化」),装载器会把整个链按逆序 回滚:先调用已经初始化过的 DLL 的 DLL_PROCESS_DETACH,再 NtUnmapViewOfSection 卸载它们,最后返回失败。
5.7.7.3 LdrpCallInitRoutine 伪代码
c
NTSTATUS LdrpCallInitRoutine(LDR_DATA_TABLE_ENTRY *Entry,
ULONG Reason, PVOID Reserved)
{
if (Entry->EntryPoint == NULL)
return STATUS_SUCCESS; // 这个 DLL 没有 DllMain
DLLMAIN Proc = (DLLMAIN)Entry->EntryPoint;
BOOL ok = Proc((HMODULE)Entry->DllBase, Reason, Reserved);
if (Reason == DLL_PROCESS_ATTACH && !ok)
return STATUS_DLL_INIT_FAILED;
return STATUS_SUCCESS;
}
5.7.7.4 TLS:__declspec(thread) 是如何工作的
编译器/链接器通过 .tls 节 + TLS 目录告诉系统「这个 DLL 需要一块每个线程都有独立副本的内存」:
PEB.TlsBitmap ← 一张位图,TlsAlloc 从中分配一个位
PEB.TlsSlots[64] ← 每进程 64 个槽位;槽值由应用/CRT 自由定义
TlsAlloc():从TlsBitmap中分配一个未用位,返回它在TlsSlots中的索引(0~63);TlsSetValue(idx, val):把val存进TlsSlots[idx](其实是写在当前线程的TEB.TlsSlots中);TlsGetValue(idx):反过来读。
对用户声明的 __declspec(thread) int g_threadLocal;,VC 编译器会把它放进 .tls 节;装载器在 Snap 阶段调用 LdrpProcessStaticThreadLocalStorage,把 .tls 节的内容「模板」复制一份到每个新线程的 TEB 数据区。
这是「静态 TLS」。还有「动态 TLS」------即上面的
TlsAlloc/TlsSetValue/TlsGetValueAPI------由应用自行调用。
5.7.7.5 DllMain 的死锁陷阱
永远不要在 DllMain 中调用 LoadLibrary / CreateThread / SendMessage / CoCreateInstance......任何可能再次进入 Loader 的调用。
原因:DllMain 被调用时 LdrpLoaderLock 已经被 LdrpLoadDll 持有。若:
DllMain → LoadLibrary("bar.dll") → LdrpLoadDll → EnterCriticalSection(LdrpLoaderLock)
就会发生同一线程在同一临界区上递归等待(在 XP 上是死锁;Vista 之后 RTL_CRITICAL_SECTION 允许递归计数,但在 DllMain 的通知顺序上仍可能引发不可预期行为)。
更隐蔽的场景是:DllMain 创建了一个新线程 ,而系统在创建线程后立即 对本进程内所有 DLL 调用 DLL_THREAD_ATTACH------包括「正在执行 DllMain 的那个 DLL 自己」。此时新线程也要进入 LdrpLoaderLock,但主线程正拿着它等新线程退出,新线程又在等主线程释放锁------经典的交叉死锁。
5.7.8 卸载与引用计数
5.7.8.1 LoadCount
LDR_DATA_TABLE_ENTRY.LoadCount 是一个 16 位整数,记录本 DLL 被 LoadLibrary 命中的次数:
- 第一次 LoadLibrary:创建新 entry →
LoadCount = 1; - 再次 LoadLibrary(同一 DLL):
++LoadCount; - 每次
FreeLibrary:--LoadCount;当减到 0 时,真正卸载。
Windows 用
-1来标记「该 DLL 被钉死,永远不卸载」(例如被LoadLibraryEx(..., LOAD_LIBRARY_AS_DATAFILE)同时以数据文件方式打开)。ReactOS 也沿用了此语义。
5.7.8.2 LdrpUnloadDll 的流程
┌────────────────────────────────────────────────────────────────────┐
│ LdrpUnloadDll 主流程 │
│ │
│ 1. --entry->LoadCount;若仍 > 0,直接返回 │
│ 2. 按 InInitializationOrderModuleList 的**反序**调用 │
│ DllMain(DLL_PROCESS_DETACH) │
│ (被依赖者后初始化,所以先清理) │
│ 3. 把 entry 从三条链表中摘下(RemoveEntryList + RtlZeroMemory) │
│ 4. 释放 FullDllName / BaseDllName 字符串占用的堆内存 │
│ 5. NtUnmapViewOfSection(GetCurrentProcess(), entry->DllBase) │
│ └─► 回收整块虚拟地址范围 │
│ 6. 释放 LDR_DATA_TABLE_ENTRY 自身 │
└────────────────────────────────────────────────────────────────────┘
注意第 2 步的反序:若 A 依赖 B 依赖 C,则 A 的 DllMain 在 C 的 DllMain 之前被调用初始化;卸载时反过来------先通知 A 即将卸载,再通知 B,最后通知 C。
5.7.8.3 为什么 DLL 「卸载不掉」
调试时常见 FreeLibrary 返回成功,但 DLL 仍在进程里------多半是因为:
| 原因 | 检查方法 |
|---|---|
| LoadCount 仍 > 0 | 调用 GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN, ...) 或多线程多次 LoadLibrary 都未配对 FreeLibrary |
| DLL 被其它模块 forward | 例如 shfolder.dll 的导出全 forward 到 shell32.dll,系统不会卸载 shfolder 直到 shell32 先卸载 |
| DLL 本身是 KnownDLL | KnownDLL 的 Section 是全局共享的,不会因为 FreeLibrary 就释放物理内存 |
.reloc 被剥离但 ImageBase 冲突 |
/FIXED 但运行时基址不可用,DLL 根本就没有成功映射过,当然也无法卸载 |
源码位置 :dll/ntdll/ldr/ldrapi.c(file:///d:/reactos/dll/ntdll/ldr/ldrapi.c) 的 LdrUnloadDll、LdrpUnloadDll。
5.7.9 特殊装载与安全
5.7.9.1 SxS / Fusion(Manifest)
在 Windows XP 以后,「版本化的 DLL」不再用文件路径区分------而是通过 EXE/DLL 资源里内嵌的 XML manifest 指定依赖,例如:
xml
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32"
name="Microsoft.VC80.CRT"
version="8.0.50608.0"
processorArchitecture="x86"
publicKeyToken="1fc8b3b9a1e18e3b"/>
</dependentAssembly>
</dependency>
装载器在 LdrpLoadDll 搜索路径之前,会先查询 Fusion(sxss.dll)把「逻辑程序集名」解析为「实际 DLL 路径」。ReactOS 在这一层做得较精简(见 LdrpInitializeProcessCompat),但基本思路一致。
5.7.9.2 Loader Lock
LdrpLoaderLock 是 ntdll 内部的一个 RTL_CRITICAL_SECTION,保护:
- 三条模块链表的任何改写;
LdrpLoadDll/LdrpUnloadDll的全流程;LdrpCallInitRoutine调用 DllMain 的过程;DLL_THREAD_ATTACH/DETACH的线程通知。
它带来的好 :LDR 数据结构在多线程下是线程安全的。
它带来的坏 :任何调用 LoadLibrary / GetModuleHandle / TlsAlloc 的函数都是「Loader Lock 敏感」的,不能从 DllMain 里调用。
一个常见的死锁模式是:DllMain 调用
std::thread创建新线程;新线程启动时操作系统又会调用该 DLL 的DLL_THREAD_ATTACH通知,而 DllMain 所在线程仍持有 Loader Lock,导致子线程永远阻塞在EnterCriticalSection(LdrpLoaderLock)上。
5.7.9.3 ASLR / DEP / SafeSEH
| 技术 | 与 LDR 的关系 | 由谁开启 |
|---|---|---|
| ASLR | 装载器在每次进程启动时随机选择 ImageBase;依赖完整 .reloc |
链接开关 /DYNAMICBASE + 系统开启 |
| DEP(NX) | .text 节的属性在装载阶段由 PAGE_EXECUTE_READ 切换为 PAGE_EXECUTE_READWRITE 不再默认出现;.data/.bss 永远不可执行 |
链接开关 /NXCOMPAT;进程头的 DllCharacteristics 位 |
| SafeSEH | 装载器在异常分发时校验异常处理程序地址是否落在 PE 区段内、且列在 .sxdata 中;若未启用 SafeSEH 则拒绝把异常处理程序当合法 handler |
链接开关 /SAFESEH(仅限 x86) |
这些开关都在 OptionalHeader.DllCharacteristics 字段里:
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE(0x0040) = ASLR 启用IMAGE_DLLCHARACTERISTICS_NX_COMPAT(0x0100) = DEP 启用IMAGE_DLLCHARACTERISTICS_NO_SEH(0x0400) = 无异常处理(等价于 SafeSEH 的最强形式)
装载器在 LdrpMapAndSnap 阶段读这些位并把它们转成 NtSetInformationProcess 的调用。
5.7.10 为什么会这样------设计哲学问答(Q1~Q11)
本节把 5.7.1~5.7.9 中涉及到的关键决策抽出来,用「为什么这样设计,而不是别的方案?」的形式再问一次,帮助读者从架构层面理解 LDR 的取舍。
Q1. 为什么 HMODULE == DllBase,而不是一个真正的句柄?
LDR 是一个纯用户态子系统,内核不维护模块表。用「已在地址空间里唯一存在的映像基址」做句柄,省掉了一次「句柄表 → 指针」的查询,也避免了句柄耗尽的可能。代价是句柄和指针的概念被混在一起,FreeLibrary 必须单独提供。
Q2. 为什么 DLL 搜索顺序中「应用目录」最先?
因为许多应用(尤其是没有安装包的绿色应用)会把自己的 DLL 放在 EXE 同目录,期望它们优先于系统 DLL 被找到。把它放在最前兼顾了兼容性;把「当前目录」推到最后、启用 SafeDllSearchMode,则是后来者在这条基础规则上修补的安全补丁。
Q3. 为什么需要 KnownDLLs?
- 性能:避免每个进程对 kernel32 / ntdll 之类的基础 DLL 重复做文件打开/校验;
- 安全:第三方 DLL 无法覆盖 KnownDLLs 列表中的系统组件;
- 工程简化:系统启动后 Section 就挂在全局对象表里,LDR 不必每次去查文件系统。
Q4. 为什么 LdrpLoadDll 先查已加载链表,再创建 Section,还要再查一次?
第一次是按「文件名/路径」去重;第二次(在 Section 创建之后)按「底层文件对象」去重------这覆盖了「不同路径但指向同一份文件」「大小写不一致」「软/硬链接」等场景。两次检查加起来是 O(n) 的成本,但换来的是模块永远只会被映射一次。
Q5. 为什么重定位一定要在装载阶段做?链接时不能固定下来吗?
可以(/FIXED),但那样 DLL 就没有重定位表,一旦期望基址被占用就无法被装入。保留 .reloc 让 DLL 在任何地址都能运行;/DYNAMICBASE(ASLR)甚至主动要求每次运行都重定位到不同地址,以此防御内存破坏漏洞。
Q6. 为什么要有 INT 和 IAT 两份相同的表?
INT 是装载时需要查阅的「原始意图清单」------它记录每一项是按名字还是按序号导入;IAT 则是运行时的「函数指针表」。一旦装载器把 IAT 的第一项改写成实际函数地址,后续项就需要继续从 INT 读取原始信息;没有 INT 的话 IAT 一旦被改写就无法逆向恢复,也无法在调试器中展示导入符号名。
Q7. 为什么导出表用「三数组 + 二分查找」而不是哈希表?
哈希表需要在 PE 里存一个哈希函数和碰撞链,对 32 位小体积 PE 来说复杂度和文件尺寸都不划算。二分查找在 N ≈ 2000 时只需要 ~11 次 strcmp,已经足够快;而且是「链接时可预测、运行时 O(log n)」的设计,不需要装载器再做一次构建表的工作。
Q8. 为什么 DllMain 里不能再 LoadLibrary / CreateThread?
因为 LdrpLoadDll / 线程通知都在 LdrpLoaderLock 保护下运行。DllMain 被调用时这个锁已经被持有,再在同一个临界区上做等待------不是递归死锁就是顺序依赖紊乱。历史上这个设计取舍的代价是「DllMain 里能做的事被严格限制」,收益是 LDR 的线程安全几乎不需要其它同步原语。
Q9. 为什么 FreeLibrary 用引用计数而不是立即卸载?
因为 DLL 的「外部引用」可能来自多个地方:多次 LoadLibrary、COM 类工厂对象、回调函数指针、TLS 槽中存储的堆对象......如果 FreeLibrary 一上来就卸载,任何还在调用 DLL 代码的调用栈都会立刻 #PF。引用计数让每个调用者都能安全地 LoadLibrary/FreeLibrary 配对使用。
Q10. 为什么所有装载工作都在用户态的 ntdll 里完成,而不是交给内核?
把字符串比较、路径搜索、导入表解析等工作放在内核,会显著增加系统调用的体积与攻击面。ntdll 已经是每个进程必备的「系统 DLL」------由它承担装载任务,内核只需要提供 NtCreateSection/NtMapViewOfSection 等底层原语即可。这也允许用户态实现自定义装载器(例如 .NET 的 CLR Loader、浏览器里的 JS/WASM 引擎)而无需修改内核。
Q11. 章节标题里的「连接」到底指什么?为什么它不能在 link.exe 阶段一次做完?
「连接」就是 ntdll!Ldr 在进程运行时 把本 DLL 的 IAT(导入地址表)里每一项从「名字/RVA」改写成目标函数的真实地址的动作。它不能在 link.exe 阶段一次做完,有 4 个根本原因:
- DLL 的 ImageBase 只是期望值:运行时若被重定位到别的地址,所有函数的绝对地址在 link.exe 时根本不可知,只能在装载后再去查。
- DLL 被多进程共享物理页 :DLL 的
.text是只读共享页,内核与装载器都不会为某个 EXE 单独改写 DLL 内部。因此本模块对外部函数的引用必须留一个位于本模块内的、可写的中间槽位------即 IAT。 - 延迟加载与插件式装载:要加载哪个 DLL 可能由运行时输入决定,link.exe 根本不可能提前知道名称或地址。
- SxS 与 manifest:同名字的 DLL 在不同机器、不同配置下可能有不同版本(VC80.CRT、VC90.CRT......),必须由装载器在运行时根据 manifest 决定「是哪一个」,再去它的导出表里查。
把「装入」与「连接」合起来看,就是一句大白话:「装入」把 DLL 放到内存里,「连接」把它的每一根外接线缆插好。 没做完第二步的 DLL,就像一台还没插网线、电源线的新机器------看起来在那儿,但一调用外部函数就会崩溃。
源码位置索引(5.7 整节):
| 主题 | 文件 |
|---|---|
| PEB_LDR_DATA / _LDR_DATA_TABLE_ENTRY 定义 | sdk/include/ndk/ldrtypes.h(file:///d:/reactos/sdk/include/ndk/ldrtypes.h) |
| LDR 初始化与 KnownDLLs 处理 | dll/ntdll/ldr/ldrinit.c(file:///d:/reactos/dll/ntdll/ldr/ldrinit.c) |
LdrLoadDll / LdrUnloadDll / LdrpLoadDll |
dll/ntdll/ldr/ldrapi.c(file:///d:/reactos/dll/ntdll/ldr/ldrapi.c) |
LdrpSnapIAT / LdrpSnapThunk / LdrpWalkImportDescriptor |
dll/ntdll/ldr/ldrpe.c(file:///d:/reactos/dll/ntdll/ldr/ldrpe.c) |
LdrpFindLoadedDll / LdrpGetProcedureAddress |
dll/ntdll/ldr/ldrutils.c(file:///d:/reactos/dll/ntdll/ldr/ldrutils.c) |
LoadLibraryW / LoadLibraryExW / FreeLibrary / GetProcAddress |
dll/kernel32/client/loader.c(file:///d:/reactos/dll/kernel32/client/loader.c) |
| PE 结构与重定位/导入/导出常量 | sdk/include/reactos/pecoff.h(file:///d:/reactos/sdk/include/reactos/pecoff.h) |
| 内核级 SEC_IMAGE Section 管理 | ntoskrnl/mm/section.c(file:///d:/reactos/ntoskrnl/mm/section.c) |