CppCon 2017 学习:Everything You Ever Wanted to Know about DLLs
这是关于 DLL(Dynamic Link Library,动态链接库) 的简明介绍。以下是内容的中文整理与理解:
什么是 DLL?
DLL 是 “动态链接库(Dynamic Link Library)” 的缩写,其本质是一个可重用的 代码和数据容器。特点如下:
- 是一种库文件,通常以
.dll
为扩展名。 - 可以在程序运行时动态加载(不是编译时静态链接)。
- 多个程序可以共享其中的代码和数据,无需各自保存副本。
为什么使用 DLL?
使用 DLL 有许多优势,尤其在大型系统开发中非常重要:
✳ 共享资源
- 多个程序共享相同的库文件,节省 磁盘空间和内存使用。
延迟加载(Lazy Loading)
- 某些功能不是一直都需要,可以在运行时决定是否加载。
- 示例:插件系统、模块化组件。
可维护性与可扩展性
- 组件化架构(Componentization):将功能分成多个 DLL,结构更清晰。
- 独立更新:修补 bug 或安全问题时,只需替换单独的 DLL 文件,无需重新发布整个程序。
- 支持插件系统或动态扩展(如浏览器插件、图形引擎模块等)。
为什么不使用 DLL?
虽然 DLL 很强大,但也存在一些劣势或复杂性:
分发复杂
- 安装分发变复杂,不如单个 EXE 文件简单易用。
DLL 地狱(DLL Hell)
- 版本冲突问题:系统中多个程序依赖不同版本的同一个 DLL,容易出错或程序崩溃。
性能限制
- 跨 DLL 调用不能进行编译器优化:
- 函数调用是间接跳转(indirect call),比静态调用慢。
- 无法内联(inline)优化或跨模块优化。
总结
优点 | 缺点 |
---|---|
节省资源 | 部署更复杂 |
延迟加载 | 存在兼容性风险 |
组件化结构 | 性能不可控 |
更容易维护和升级 | 调试更难 |
从零开始构建和使用 DLL 的完整示例。我们一起来逐步解析这个过程,并附上中文说明,帮助你彻底理解。
目标
创建一个简单的 DLL(Hello.dll
),它暴露一个函数:
extern "C" char const* __cdecl GetGreeting()
{return "Hello, C++ Programmers!";
}
步骤讲解(含中文注释)
第一步:编写源文件 Hello.cpp
// Hello.cpp
extern "C" char const* __cdecl GetGreeting()
{return "Hello, C++ Programmers!";
}
extern "C"
:避免 C++ 名字修饰(name mangling),使函数名在 DLL 中可以被其他语言或程序识别。__cdecl
:调用约定,说明栈的清理方式(Windows 常用)。
第二步:编译成目标文件(.obj)
在命令行输入:
cl /c Hello.cpp
cl
是微软的 C++ 编译器命令。/c
表示只编译,不链接。- 生成的文件是
Hello.obj
。
第三步:链接为 DLL
link Hello.obj /DLL /NOENTRY /EXPORT:GetGreeting
含义解释:
/DLL
:告诉链接器要创建一个 DLL。/NOENTRY
:告诉链接器没有DllMain
入口点(我们只导出函数,不做初始化)。/EXPORT:GetGreeting
:导出函数名GetGreeting
供外部调用。
生成结果:Hello.dll
:动态链接库。Hello.lib
:用于静态链接的导入库。Hello.exp
:导出符号表。
第四步(误区):尝试直接运行 DLL
A:\> Hello.dll
The system cannot execute the specified program.
解释:
DLL 不能直接运行!
DLL 是供程序“调用”的,不是用来独立执行的可执行文件(EXE)。
正确使用:创建一个调用 DLL 的程序
编写 PrintGreeting.cpp
:
// PrintGreeting.cpp
#include <windows.h>
#include <iostream>
typedef const char* (__cdecl* GetGreetingFunc)();
int main()
{// 加载 DLLHMODULE hDLL = LoadLibrary("Hello.dll");if (!hDLL) {std::cerr << "无法加载 DLL" << std::endl;return 1;}// 获取函数地址GetGreetingFunc GetGreeting = (GetGreetingFunc)GetProcAddress(hDLL, "GetGreeting");if (!GetGreeting) {std::cerr << "无法获取函数地址" << std::endl;return 1;}// 调用函数std::cout << GetGreeting() << std::endl;// 卸载 DLLFreeLibrary(hDLL);return 0;
}
编译运行:
cl PrintGreeting.cpp
PrintGreeting.exe
输出:
Hello, C++ Programmers!
总结
步骤 | 操作 | 说明 |
---|---|---|
1 | 写 Hello.cpp | DLL 中的函数 |
2 | cl /c Hello.cpp | 编译成对象文件 |
3 | link Hello.obj /DLL /EXPORT:函数名 | 链接为 DLL |
4 | 写 PrintGreeting.cpp | 调用 DLL 中函数 |
5 | 编译运行 | 加载 DLL 并调用函数 |
下面是一个 简单的 CMake 配置,用于构建你刚才提到的 Hello.dll
和 PrintGreeting.exe
程序。
目录结构建议
MyProject/
├── CMakeLists.txt
├── Hello/
│ ├── CMakeLists.txt
│ └── Hello.cpp
└── PrintGreeting/├── CMakeLists.txt└── PrintGreeting.cpp
根目录 CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(MyDLLDemo)
add_subdirectory(Hello)
add_subdirectory(PrintGreeting)
Hello/CMakeLists.txt
—— 构建 DLL
add_library(Hello SHARED Hello.cpp)
# 设置导出函数名(Windows 平台需要明确)
# 你可以用 DEF 文件或者直接导出函数(这例中直接导出)
target_compile_definitions(Hello PRIVATE EXPORTING_HELLO_DLL
)
Hello/Hello.cpp
// Hello.cpp
extern "C" __declspec(dllexport) const char* __cdecl GetGreeting()
{return "Hello, C++ Programmers!";
}
注意
__declspec(dllexport)
:用于明确告诉编译器导出这个函数(不使用/DEF
文件时)。
PrintGreeting/CMakeLists.txt
—— 构建 EXE
add_executable(PrintGreeting PrintGreeting.cpp)
# 链接 DLL 的 import lib
target_link_libraries(PrintGreeting PRIVATE Hello)
PrintGreeting/PrintGreeting.cpp
#include <windows.h>
#include <iostream>
typedef const char* (__cdecl* GetGreetingFunc)();
int main() {HMODULE hDLL = LoadLibraryA("Hello.dll"); // 注意是 .dll 文件名if (!hDLL) {std::cerr << "无法加载 DLL" << std::endl;return 1;}GetGreetingFunc GetGreeting = (GetGreetingFunc)GetProcAddress(hDLL, "GetGreeting");if (!GetGreeting) {std::cerr << "无法获取函数地址" << std::endl;return 1;}std::cout << GetGreeting() << std::endl;FreeLibrary(hDLL);return 0;
}
构建命令
mkdir build
cd build
cmake ..
cmake --build .
构建结果说明
Hello.dll
会在build/Hello/
生成PrintGreeting.exe
会在build/PrintGreeting/
生成- 运行
PrintGreeting.exe
前,请确保Hello.dll
和它在同一目录下,或设置好 PATH
你可以用以下命令拷贝 DLL:
copy Hello/Hello.dll PrintGreeting/
CMakePresets.json
{"version": 3,"configurePresets": [{"name": "windows-base","hidden": true,"generator": "Ninja","binaryDir": "${sourceDir}/out/build/${presetName}","installDir": "${sourceDir}/out/install/${presetName}","cacheVariables": {"CMAKE_C_COMPILER": "cl.exe","CMAKE_CXX_COMPILER": "cl.exe"},"condition": {"type": "equals","lhs": "${hostSystemName}","rhs": "Windows"}},{"name": "x64-debug","displayName": "x64 Debug","inherits": "windows-base","architecture": {"value": "x64","strategy": "external"},"cacheVariables": {"CMAKE_BUILD_TYPE": "Debug"}},{"name": "x64-release","displayName": "x64 Release","inherits": "x64-debug","cacheVariables": {"CMAKE_BUILD_TYPE": "Release"}},{"name": "x86-debug","displayName": "x86 Debug","inherits": "windows-base","architecture": {"value": "x86","strategy": "external"},"cacheVariables": {"CMAKE_BUILD_TYPE": "Debug"}},{"name": "x86-release","displayName": "x86 Release","inherits": "x86-debug","cacheVariables": {"CMAKE_BUILD_TYPE": "Release"}}]
}
PS C:\Users\16956\Documents\game\CppCon\out\build\x64-debug\day163\code> tree /F
卷 OS 的文件夹 PATH 列表
卷序列号为 4C98-E53B
C:.
├─Hello
│ │ cmake_install.cmake
│ │ Hello.dll
│ │ Hello.exp
│ │ Hello.ilk
│ │ Hello.lib
│ │ Hello.pdb
│ │
│ └─CMakeFiles
│ └─Hello.dir
│ embed.manifest
│ Hello.cpp.obj
│ intermediate.manifest
│ manifest.rc
│ manifest.res
│ vc140.pdb
│
└─PrintGreeting│ cmake_install.cmake│ PrintGreeting.exe│ PrintGreeting.ilk│ PrintGreeting.pdb│└─CMakeFiles└─PrintGreeting.dirembed.manifestintermediate.manifestmanifest.rcmanifest.resPrintGreeting.cpp.objvc140.pdb
PS C:\Users\16956\Documents\game\CppCon\out\build\x64-debug\day163\code>
你构建了一个动态链接库(DLL) Hello.dll
并且它提供了一个函数:
extern "C" char const* __cdecl GetGreeting()
{return "Hello, C++ Programmers!";
}
这个 DLL 编译之后提供一个导出函数 GetGreeting()
,返回一个字符串。
然后你编写了一个主程序 PrintGreeting.cpp
它使用 Windows API 手动加载 DLL 并调用其中的函数:
#include <stdio.h>
#include <Windows.h>
int main() {// 加载 DLLHMODULE const HelloDll = LoadLibraryExW(L"Hello.dll", nullptr, 0);// 定义函数指针类型using GetGreetingType = char const* (__cdecl*)();// 获取函数地址GetGreetingType const GetGreeting = reinterpret_cast<GetGreetingType>(GetProcAddress(HelloDll, "GetGreeting"));// 调用函数并打印结果puts(GetGreeting());// 卸载 DLLFreeLibrary(HelloDll);
}
运行 PrintGreeting.exe
成功输出:
Hello, C++ Programmers!
所以你已经成功实现了:
- 动态链接库
Hello.dll
- 导出函数
GetGreeting
- 主程序
PrintGreeting.exe
动态加载该 DLL 并调用其函数
这演示了 动态链接(DLL)机制在 Windows 下的实际使用,也是 DLL 编程的最小可运行示例之一。
后续建议(可选):
如果你要写更大项目,建议使用 CMake 管理构建流程,你可以这样写:
CMakeLists.txt
示例
cmake_minimum_required(VERSION 3.15)
project(HelloDLL)
add_library(Hello SHARED Hello.cpp)
set_target_properties(Hello PROPERTIES PREFIX "" SUFFIX ".dll")
add_executable(PrintGreeting PrintGreeting.cpp)
target_link_libraries(PrintGreeting PRIVATE Hello)
注意:在使用
LoadLibraryExW
手动加载 DLL 的时候,CMake 不需要负责链接 DLL,但你可以通过target_link_libraries
来声明依赖(适合直接链接.lib
的情况)。
这是在探索 Windows 可执行文件格式(PE 格式),特别是 .dll
文件的结构。我们来一步一步解释你看到的内容:
你看到了什么?
你输入了命令:
type Hello.dll
结果显示一堆乱码,开头是:
MZ...
This program cannot be run in DOS mode.
...
Rich...
PE..
这说明你正在“用文本方式”查看一个二进制文件(DLL)。这些乱码其实是有结构的数据,它是符合 PE(Portable Executable)文件格式的。
DLL 文件结构解析(PE 文件格式)
每一个 .dll
或 .exe
都是基于 PE 文件格式,其结构如下:
[ DOS Stub ]
[ PE Signature ]
[ COFF File Header ]
[ Optional Header ]
[ Section Table ]
[ Sections (.text, .data, .rdata, etc.) ]
1. MZ
— DOS 头(DOS Stub)
- 所有 Windows 可执行文件的开头是
MZ
(0x4D 0x5A),表示这是一个可执行文件。 - 紧随其后是一个 DOS 模拟程序,它只是简单打印一句:
This program cannot be run in DOS mode.
2. Offset 0x3C
处的值(通常是 C8 或类似)是一个偏移量:
- 它告诉你 PE Header(真正的 Windows 结构体)从哪里开始。
- 在你的示例中,这个偏移处的值为
C8
,所以 PE header 从偏移0xC8
开始。
3. PE\0\0
— PE Signature
- 从 offset
0xC8
开始应该看到:50 45 00 00
,即PE\0\0
- 这就是 PE 文件的“正式入口”。
Section Table 与内容
从 PE Header 开始,后面有一系列的部分(节):
.text
:代码段(你的GetGreeting()
函数就在这里).rdata
:只读数据段(字符串文字如"Hello, C++ Programmers!"
).edata
:导出表(DLL 的导出函数名就存这).idata
:导入表(如果有调用别的 DLL).rsrc
:资源段(图标、对话框等)
你在十六进制 dump 中看到的.text
,.rdata
,.edata
,这些其实都是这些段的标记。
为啥看到 Rich
?
这个是微软的 Visual Studio linker 添加的 “Rich Header”,介于 DOS Stub 和 PE Signature 之间的一段非文档化结构,存储编译器工具链信息。对程序运行无影响,只是调试工具能用。
总结:你做了什么?
你从字节层面 分析了 DLL 文件结构,识别出了:
MZ
标志- DOS stub
PE
签名位置.text
,.rdata
,.edata
等节的名称GetGreeting
函数名的存在(导出表)"Hello, C++ Programmers!"
字符串也在.rdata
中
如果你想继续深入:
你可以使用更专业的工具查看 DLL 内部结构,比如:
dumpbin /exports Hello.dll
(查看导出函数)Dependency Walker
(分析依赖)PE Explorer
、CFF Explorer
(图形化 PE 查看器)objdump
或 IDA(反汇编器)
给出的 dumpbin /headers Hello.dll
输出内容,涉及到PE文件(Portable Executable,Windows可执行文件格式)结构的几个关键点,我帮你用中文总结和解释一下:
1. PE签名(PE signature found)
- 表示这是一个合法的PE文件。
2. 文件类型(File Type: DLL)
- 这是一个动态链接库(DLL)文件。
3. 文件头(FILE HEADER VALUES)
- 机器类型(machine):
8664
,代表这是x64架构的文件(64位)。 - 节(sections)数量: 2个节(sections),节是PE文件中存放代码、数据等的单位。
- 时间戳: 编译时间,Sat Sep 16 20:04:17 2017。
- 可选头大小: F0(十六进制)即240字节大小。
- 特性(characteristics):
- 可执行文件(Executable)
- 支持大于2GB的地址空间(Large Address Aware)
- 是一个DLL文件(DLL)
4. 可选头(OPTIONAL HEADER VALUES)
- Magic # (魔数):
20B
,代表这是PE32+格式,即64位PE格式。 - 入口点(Entry Point): 0,可能表示DLL的入口点为0(通常DLL入口函数)。
- 镜像基址(Image Base):
70000000
,这是DLL被加载时的默认基址(内存地址)。 - 节对齐(Section Alignment): 0x1000,即4096字节。
- 文件对齐(File Alignment): 0x200,即512字节。
- 镜像大小(Size of Image): 0x3000(12KB),整个映像大小。
- 头大小(Size of Headers): 0x400(1KB)。
- DLL特性(DLL Characteristics):
- 支持高熵虚拟地址空间(High Entropy Virtual Addresses)
- 支持动态重定位(Dynamic Base)
- 支持NX保护(NX Compatible,即防止执行非代码区)
5. 目录表(Data Directories)
- 导出表(Export Directory):
RVA=2040
,大小48字节。DLL对外导出的函数信息。 - 导入表(Import Directory):
RVA=2020
,大小0,表示没有导入(不一定准确,可能需要进一步确认)。 - 资源目录(Resource Directory)、异常目录(Exception Directory)等: 大多为0,说明该DLL可能不包含资源或异常目录。
6. 地址空间示意
- 这个DLL被加载到虚拟地址
0x70000000
开始的空间。 - 这里也有堆(Heap)、线程堆栈、程序等不同的虚拟地址空间划分。
总结
这个DLL是一个64位的Windows动态链接库,符合PE32+格式,具有现代的安全特性(如NX保护和高熵地址空间支持)。它有2个节,体积较小(12KB)。导出目录里有函数对外暴露的信息,但导入目录是空的或者没有明显导入其他DLL的函数。
如果你想深入理解PE文件结构,这个输出展示了从DOS头到PE头,再到COFF头,最后到可选头的详细信息。
你的这段描述是关于进程虚拟地址空间中各个区域的排布,特别是DLL和线程栈在内存中的相对位置。下面帮你用中文详细讲解这两段内容的含义和区别:
1. 第一张内存布局图
0xF..F Some Other Data
----------------------------------------
----------------------------------------Thread T0 StackThread T1 Stack
----------------------------------------
----------------------------------------Hello.dll 0x7000’0000
----------------------------------------PrintGreeting.exe
----------------------------------------
----------------------------------------Heap
0x0..0
- Heap(堆) 在虚拟地址空间的低端,地址从
0x0..0
开始向上。 - PrintGreeting.exe 是主程序,位于堆上方。
- Hello.dll 期望加载在
0x7000’0000
这个基址。 - Thread T0 Stack 和 T1 Stack 在 DLL 上方,说明线程的栈空间占用这个区域。
- Some Other Data 处于地址空间的最高端(0xF…F 表示虚拟地址的高位段),可能是其他系统数据、映射内存等。
重点: 这幅图显示的是DLL的默认加载基址0x70000000
,但它的上方有线程栈已经占用空间,意味着不能在这里加载DLL,因为地址冲突。
2. 第二张内存布局图
0xF..F Some Other Data
----------------------------------------
----------------------------------------Hello.dll 0x7000’0000
----------------------------------------
----------------------------------------Thread T0 StackThread T1 StackThread T2 StackThread T3 Stack
----------------------------------------PrintGreeting.exe
----------------------------------------
----------------------------------------Heap
0x0..0
- 这张图展示的情况是,DLL仍然在
0x70000000
加载,但线程栈分布变化了:- 线程栈的数量从2个增加到了4个(T0~T3)。
- 线程栈区域现在位于 DLL 的下方。
- 注意: 线程栈和 DLL 之间空间的调整表明系统在虚拟地址空间里重新规划了线程栈和DLL的加载位置。
你的理解重点
- 进程的虚拟地址空间是动态划分和调整的。
- DLL 默认基址是
0x70000000
,如果有冲突(比如线程栈占用了这个位置),系统会重定位 DLL到别的地址。 - 线程栈(Thread Stack)可能在 DLL 上方或者下方,根据系统和线程创建情况有所变化。
- 堆(Heap) 总是在较低地址空间。
- Some Other Data 代表其它内核或系统占用的高地址空间。
这里是一个简洁的示意图,帮你快速理解DLL和线程栈在进程虚拟地址空间中的相对位置:
虚拟地址空间(高地址 ↓)
+--------------------------+
| Some Other Data (系统数据) |
+--------------------------+
| Thread T3 Stack |
+--------------------------+
| Thread T2 Stack |
+--------------------------+
| Thread T1 Stack |
+--------------------------+
| Thread T0 Stack |
+--------------------------+
| Hello.dll | ← DLL默认加载基址(如 0x70000000)
+--------------------------+
| PrintGreeting.exe | ← 主程序映像
+--------------------------+
| Heap | ← 堆空间(低地址方向)
+--------------------------+
虚拟地址空间(低地址 ↑)
重点提示:
- DLL一般加载在某个固定的基址(例如0x70000000),但如果有地址冲突,可能会被重定位。
- 线程栈在DLL附近,有时在线程多时,栈的空间会扩展,可能在DLL上下都有。
- 堆位于低地址区,主程序映像在堆和DLL之间。
- 最高地址区(Some Other Data)是系统占用空间。
你的这段内容详细解释了DLL的地址布局、RVA(Relative Virtual Address,相对虚拟地址)的意义,以及DLL在内存中的映射和节(section)结构。下面帮你用中文整理理解重点:
RVA(相对虚拟地址)的含义
- RVA = 内存地址 - DLL基址(Image Base)
- 也就是说,RVA是从DLL加载基址开始算起的偏移量。
- 计算公式:
- 内存地址 = DLL基址 + RVA
- 例如:函数
GetGreeting()
的RVA是0x2000
,DLL加载地址是0x70000000
,那么函数地址就是0x70002000
。
DLL加载后的内存布局和节(Section)结构
Optional Header (可选头部)
- Magic = 20B 表示是PE32+格式(64位)
- Entry point = 0(无明确入口点,DLL通常没有主入口)
- Image base = 0x70000000(DLL期望加载的基址)
- Section alignment = 0x1000(4KB对齐)
- File alignment = 0x200(512字节对齐)
- Size of image = 0x3000(DLL整体占用内存大小3页,每页4KB)
- Number of directories = 10(包含导出表、导入表、资源表、调试表等)
主要节区(Sections)
节名称 | 虚拟大小 | 虚拟地址 (RVA) | 文件中偏移 | 权限描述 |
---|---|---|---|---|
.text | 8 字节 | 0x1000 (0x70001000起) | 0x400 | 代码区,执行+读 |
.rdata | 0xD8字节 | 0x2000 (0x70002000起) | 0x600 | 只读数据区 |
- DLL在内存中占用3页:
- 1页放头部(headers)
- 1页放.text节(代码)
- 1页放.rdata节(只读数据,包括导出目录和调试目录等元数据)
额外信息
- 导出目录(Export Directory)和调试目录(Debug Directory)数据包含在.rdata节中。
- 这两项目录是DLL的重要元数据,分别用于导出函数地址和调试信息。
总结
- RVA是相对基址的偏移,定位DLL内存中的函数和数据位置。
- DLL由多个节组成,常见有代码节(.text)和只读数据节(.rdata)。
- 加载时,DLL整体映射成连续的虚拟内存页,包含头部、代码、数据等。
- 导出目录和调试目录存放在.rdata节内,便于外部程序查找函数和调试信息。
这段内容是一个用 Windows 工具 dumpbin
分析 DLL 文件结构的示例,重点说明了 DLL 的各个部分如何映射到内存地址,以及如何通过 RVA 访问函数和数据。帮你总结理解要点:
1. DLL 文件结构关键部分
- DOS Stub
这是为了兼容旧的 DOS 程序,能显示“这个程序不能在 DOS 下运行”之类的信息,实际现在没什么用。 - PE Signature(PE标记)
4字节,固定为 “PE\0\0”,表示这是个 Windows Portable Executable 文件。 - COFF File Header
标准的文件头,包含机器类型、节数等信息。 - Optional Header(可选头部)
包含镜像基址、入口点、节对齐等重要参数。 - Section Headers(节头)
描述各节的位置、大小、权限,.text是代码段,.rdata是只读数据段等。 - Sections(节)
包含代码、数据和资源等内容。
2. 示例中主要节的内容和分析
.text
节(代码段)
- RVA = 0x1000,实际加载地址 = Image Base + RVA = 0x70000000 + 0x1000 = 0x70001000。
- 代码示例:
70001000: 48 8D 05 F9 0F 00 00 lea rax,[70002000h] 70001007: C3 ret
- 这段代码是
GetGreeting()
函数,执行一个lea
(装载有效地址)指令,把0x70002000
地址加载到寄存器rax
,然后返回。
.rdata
节(只读数据段)
- RVA = 0x2000,加载后地址 = 0x70002000。
- 包含导出目录、调试目录和字符串等数据。
- 示例数据是字符串
"Hello, C++ Programmers!\0"
,即DLL导出的问候语。
3. 导出目录与函数地址
- 通过
dumpbin /exports Hello.dll
得到导出函数列表。 GetGreeting()
的RVA是0x1000
,实际地址是0x70001000
。- 结合上面的反汇编代码,函数会返回指向
.rdata
段中字符串的指针。
4. 结合理解
- DLL的基址为
0x70000000
。 - 函数在
.text
节,数据(如字符串)在.rdata
节。 - 函数使用RVA寻址数据,实现功能。
- 通过工具能看到文件的二进制内容和反汇编结果。
总结
- RVA 是相对DLL基址的偏移,方便定位函数和数据。
- 代码节
.text
包含函数实现,.rdata
包含只读数据如字符串和导出目录。 - 导出表 显示DLL暴露的函数名及其RVA。
- 反汇编 能看函数实现细节,比如
GetGreeting()
返回字符串指针。 - dumpbin工具 是查看PE结构和内容的强大工具。
你提供的这段代码示例,是典型的 显式链接(Explicit Linking) 用法,用来演示如何动态加载 DLL 并调用其中的函数。帮你详细讲解一下:
显式链接(Explicit Linking)
代码重点
#include <stdio.h>
#include <Windows.h>
int main()
{// 1. 动态加载 DLLHMODULE const HelloDll = LoadLibraryExW(L"Hello.dll", nullptr, 0);// 2. 定义函数指针类型(返回 const char*,调用约定 __cdecl)using GetGreetingType = char const* (__cdecl*)();// 3. 获取 DLL 中函数地址GetGreetingType const GetGreeting =reinterpret_cast<GetGreetingType>(GetProcAddress(HelloDll, "GetGreeting"));// 4. 调用函数,并打印返回的字符串puts(GetGreeting());// 5. 卸载 DLLFreeLibrary(HelloDll);
}
逐步解析
LoadLibraryExW
- 这个函数动态加载名为
"Hello.dll"
的 DLL 文件。 - 成功时返回一个模块句柄(
HMODULE
),失败返回NULL
。
- 这个函数动态加载名为
- 定义函数指针类型
- DLL中的
GetGreeting()
函数返回一个const char*
字符串,调用约定是__cdecl
。 - 这里用
using
定义了对应的函数指针类型。
- DLL中的
GetProcAddress
- 通过模块句柄和函数名
"GetGreeting"
获取函数在 DLL 内的地址。 - 然后用
reinterpret_cast
把它转换成定义好的函数指针类型。
- 通过模块句柄和函数名
- 调用函数和打印结果
- 通过函数指针调用
GetGreeting()
,返回字符串指针。 - 用
puts
输出字符串(如前面分析,应该是"Hello, C++ Programmers!"
)。
- 通过函数指针调用
FreeLibrary
- 卸载 DLL,释放资源。
这就是 显式链接 的典型流程:
- 应用程序运行时才决定是否加载 DLL。
- 需要手动调用
LoadLibrary
和GetProcAddress
。 - 优点是灵活,可以根据情况加载不同的 DLL 或不同的函数。
- 缺点是代码更复杂,需要管理 DLL 句柄和函数指针。
相比之下:隐式链接(Implicit Linking)
- 编译时就链接
.lib
导入库文件。 - 程序启动时操作系统自动加载 DLL。
- 程序可以直接调用 DLL 函数,不需要手动加载和获取函数指针。
显式链接(Explicit Linking) 和 隐式链接(Implicit Linking),以及它们在实际程序中表现出的依赖关系。帮你总结和讲解一下:
1. 显式链接(Explicit Linking)回顾
- 代码示例:
#include <stdio.h>
#include <Windows.h>
int main()
{HMODULE const HelloDll = LoadLibraryExW(L"Hello.dll", nullptr, 0);using GetGreetingType = char const* (__cdecl*)();GetGreetingType const GetGreeting =reinterpret_cast<GetGreetingType>(GetProcAddress(HelloDll, "GetGreeting"));puts(GetGreeting());FreeLibrary(HelloDll);
}
- 这里程序 自己调用
LoadLibraryExW
和GetProcAddress
来动态加载 DLL 和获取函数地址。 - 依赖关系仅限于系统 DLL(如
KERNEL32.dll
),你的程序对Hello.dll
只是通过运行时加载,不是链接时绑定。
2. 通过 dumpbin /dependents PrintGreeting.exe
查看依赖:
Image has the following dependencies:
KERNEL32.dll
- 只有系统 DLL
KERNEL32.dll
被列为依赖,说明Hello.dll
并非隐式链接的依赖。 - 这是显式链接的典型特征:运行时才加载 DLL,编译时不需要知道 DLL。
3. 查看导入函数(dumpbin /imports PrintGreeting.exe
):
- 看到的都是来自
KERNEL32.dll
的函数,如LoadLibraryExW
、GetProcAddress
、FreeLibrary
等。 - 说明程序是通过调用系统 API 来实现显式加载。
4. 隐式链接(Implicit Linking)示例
#include <stdio.h>
extern "C" char const* __cdecl GetGreeting();
int main()
{puts(GetGreeting());
}
- 这里程序声明了 DLL 中的
GetGreeting
函数,编译时链接器会用到 DLL 的导入库(.lib
文件)。 - 运行时 Windows 会自动加载
Hello.dll
,不需要程序员手动调用LoadLibrary
。
5. 隐式链接的依赖关系
- 通过
dumpbin /dependents
,会看到程序除了系统 DLL,还会依赖Hello.dll
。 - 因为链接时绑定了 DLL,所以运行时必须能找到 DLL,否则程序启动失败。
6. 导入库(Import Libraries)
- 隐式链接用到的
.lib
文件实际上只是“导入库”,不包含代码,只告诉链接器:- 该函数在某个 DLL 中
- 运行时系统帮你加载 DLL 和绑定函数地址
- 你编译时链接这个导入库,程序启动时由系统自动加载 DLL。
总结
特点 | 显式链接 | 隐式链接 |
---|---|---|
依赖声明 | 只依赖系统 DLL | 依赖系统 DLL + 目标 DLL |
加载时间 | 运行时由程序调用 LoadLibrary 加载 | 程序启动时自动加载 DLL |
函数调用 | 通过 GetProcAddress 获取函数地址调用 | 直接调用函数,链接器解析 |
灵活性 | 高,可动态选择加载的 DLL | 低,必须有 DLL 否则启动失败 |
编写复杂度 | 高,需要手动管理 DLL 加载卸载和函数地址 | 低,写法简单,像普通函数调用 |
你这段流程非常完整地展示了 隐式链接(Implicit Linking) 的典型过程和细节,我帮你梳理总结一下,方便你更清晰理解:
1. 创建 DLL 和导入库
link Hello.obj /DLL /NOENTRY /EXPORT:GetGreeting
- 这条命令:
- 生成了
Hello.dll
动态库 - 并且生成了
Hello.lib
,这其实是一个导入库(Import Library),它不包含代码,只包含符号和指向 DLL 的信息。
- 生成了
dumpbin /exports Hello.lib
显示了导出的函数GetGreeting
。
2. 导入库的结构(dumpbin /all Hello.lib
)
- 可以看到导入库里:
- 定义了导入符号
__imp_GetGreeting
- 它是一个“魔法”指针,链接器和加载器用它来定位实际 DLL 中的函数
GetGreeting
- 定义了导入符号
- 伪代码表示:
extern "C" char const* __cdecl GetGreeting()
{return __imp_GetGreeting(); // 实际调用 DLL 中函数
}
3. 隐式链接调用示例
#include <stdio.h>
extern "C" char const* __cdecl GetGreeting();
int main()
{puts(GetGreeting());
}
- 这个程序直接调用
GetGreeting
,编译时链接器用到了Hello.lib
导入库。 - 运行时,系统自动加载
Hello.dll
,绑定GetGreeting
的地址。
4. 编译和链接程序:
link PrintImplicit.obj Hello.lib
- 把导入库链接进程序,生成的
PrintImplicit.exe
会自动依赖Hello.dll
。
5. 运行和依赖检查
- 运行程序,输出:
Hello, C++ Programmers!
dumpbin /dependents PrintImplicit.exe
显示依赖:
Hello.dll
KERNEL32.dll
dumpbin /imports PrintImplicit.exe
显示导入的函数符号:
Hello.dllGetGreeting
KERNEL32.dll...
总结
过程 | 说明 |
---|---|
使用 /DLL /EXPORT 生成 DLL 和导入库 | 导出函数供隐式链接使用 |
导入库 .lib 是链接器的桥梁 | 不含代码,描述 DLL 符号和地址 |
程序引用导入库链接 | 运行时 Windows 自动加载 DLL |
运行时动态绑定 DLL 中函数 | 程序内直接调用函数,和调用本地函数一样 |
依赖工具显示 | 程序隐式依赖 DLL,系统帮你加载 |
你这段操作展示了DLL 导出符号的多种方式,特别是如何用链接器参数控制导出函数的名字、顺序、以及可见性。下面帮你总结和解释:
1. 基本导出 /EXPORT
extern "C" int GetOne() { return 1; }
extern "C" int GetTwo() { return 2; }
extern "C" int GetThree() { return 3; }
link Numbers.obj /DLL /NOENTRY /EXPORT:GetOne /EXPORT:GetTwo /EXPORT:GetThree
- 通过
/EXPORT
告诉链接器这些符号要导出,生成的Numbers.dll
中这三个函数都可被外部调用。 dumpbin /exports Numbers.dll
会显示导出函数和它们的序号(ordinal)。
2. 导出重命名(Renamed Exports)
link Numbers.obj /DLL /NOENTRY /EXPORT:GetOne /EXPORT:GetTwo /EXPORT:GetOnePlusTwo=GetThree
- 这里
/EXPORT:GetOnePlusTwo=GetThree
表示:- DLL 中实际导出函数名是
GetOnePlusTwo
- 但是它对应的函数实现是
GetThree
- DLL 中实际导出函数名是
- 导出表中会出现
GetOnePlusTwo
,外部调用时用这个名字,但内部其实调用GetThree
。 - 可以用这种方式隐藏或重命名符号。
3. 设定导出为私有(Private Export)
link Numbers.obj /DLL /NOENTRY /EXPORT:GetOne /EXPORT:GetTwo /EXPORT:GetThree,PRIVATE
,PRIVATE
标记该导出为私有,不出现在导出表中,但依然存在于库里(链接器可以内部用)。dumpbin /exports
只会列出公开的GetOne
和GetTwo
,GetThree
不显示。- 这用于控制导出符号的可见性,避免暴露不想被外部调用的符号。
总结
导出方式 | 用法示例 | 作用 |
---|---|---|
基本导出 | /EXPORT:GetOne | 直接导出函数,外部可调用 |
导出重命名 | /EXPORT:NewName=OldName | 用新名字导出已有函数,方便符号隐藏或重命名 |
私有导出 | /EXPORT:GetName,PRIVATE | 不在导出表显示,防止外部调用,符号仅供内部或链接时用 |
你这段展示了如何使用 模块定义文件(DEF 文件) 来控制 DLL 的导出符号。下面是详细解释:
什么是 DEF 文件?
DEF 文件(Module Definition File)是一个文本文件,向链接器明确说明一个 DLL 应该导出哪些函数、库名是什么等。
它的作用类似于在链接命令中写 /EXPORT
,但更清晰、集中、可维护,特别是在导出多个符号时。
示例:Numbers.def
LIBRARY Numbers
EXPORTSGetOneGetTwo PRIVATEGetOnePlusTwo=GetThree
含义解释:
行 | 含义 |
---|---|
LIBRARY Numbers | 指定 DLL 名称为 Numbers.dll |
GetOne | 正常导出 GetOne 函数 |
GetTwo PRIVATE | 导出 GetTwo ,但标记为 PRIVATE,不会在导入库中显示 |
GetOnePlusTwo=GetThree | 把 GetThree 函数导出为新的名字 GetOnePlusTwo (对外公开的是这个名字) |
链接命令
link Numbers.obj /DLL /NOENTRY /DEF:Numbers.def
这条命令:
- 把 OBJ 文件变成 DLL
- 使用
Numbers.def
文件来决定导出内容 - 自动生成
Numbers.lib
作为导入库
验证结果
dumpbin /exports Numbers.dll
输出大致是:
Ordinal | RVA | Name |
---|---|---|
1 | 00001000 | GetOne |
2 | 00001020 | GetOnePlusTwo |
3 | 00001010 | GetTwo |
dumpbin /exports Numbers.lib
输出只包含:
GetOne
GetOnePlusTwo
(因为GetTwo
是 PRIVATE,所以不在导入库中出现)
使用 DEF 文件的优势
优点 | 描述 |
---|---|
更可读 | 比多个 /EXPORT: 命令行参数更清晰 |
支持重命名导出 | PublicName=InternalName |
支持隐藏导出 | PRIVATE 标志不会出现在 .lib 中 |
版本控制友好 | 可放入源码仓库,方便多人协作和维护 |
总结
特性 | 方式 |
---|---|
正常导出 | GetOne |
重命名导出 | GetOnePlusTwo=GetThree |
私有导出 | GetTwo PRIVATE |
使用 DEF 文件 | link ... /DEF:filename.def |
DEF 文件是构建大型 DLL 项目的专业工具,建议在需要精细控制导出接口时使用它。 |
展示的是使用 __declspec(dllexport)
导出函数的方法,这是一种最常用、最直接的导出 DLL 接口的方式。以下是详细解释:
什么是 __declspec(dllexport)
?
__declspec(dllexport)
是 Microsoft 的扩展关键字,用来告诉编译器“这个符号需要被导出为 DLL 的接口”,用于自动生成 .dll
和 .lib
文件中的导出符号。
示例代码回顾:
extern "C" __declspec(dllexport) int GetOne() { return 1; }
extern "C" __declspec(dllexport) int GetTwo() { return 2; }
extern "C" __declspec(dllexport) int GetThree() { return 3; }
extern "C"
:关闭 C++ 的名称修饰(name mangling),确保导出名称是简单的GetOne
而不是_Z7GetOnev
之类。__declspec(dllexport)
:告诉编译器这些函数应导出。
编译链接命令
cl /c Numbers.cpp
link Numbers.obj /DLL /NOENTRY
/DLL
: 构建 DLL/NOENTRY
: 表示没有DllMain
,对简单 DLL 有用
这会创建:Numbers.dll
: 实际的动态库Numbers.lib
: 导入库,用于链接依赖于此 DLL 的其他模块
dumpbin 查看导出结果
dumpbin /exports Numbers.dll
输出类似:
Ordinal | RVA | Name |
---|---|---|
1 | 00001000 | GetOne |
2 | 00001020 | GetThree |
3 | 00001010 | GetTwo |
说明 DLL 中已经正确导出了这些函数。 |
dumpbin /directives Numbers.obj
这个命令会显示 .obj
文件中的“编译器内嵌指令”。
输出结果:
/EXPORT:GetOne
/EXPORT:GetTwo
/EXPORT:GetThree
说明编译器 自动地在 OBJ 文件中嵌入了 /EXPORT
指令,链接器看到这些指令时,会自动导出这些函数,无需写 DEF 文件或 /EXPORT:
命令行参数。
优点总结:__declspec(dllexport)
优点 | 描述 |
---|---|
简洁 | 只需加个关键字,不需要额外的 DEF 文件 |
自动导出 | 编译器帮你写好导出指令 |
支持导入(与 __declspec(dllimport) 配合) | |
好维护 | 接口一目了然 |
缺点/注意事项
限制 | 描述 |
---|---|
不支持 PRIVATE 导出 | 所有 __declspec(dllexport) 的符号都会出现在 .lib 中 |
不能重命名导出 | 不支持 ExportedName=RealFunctionName 的语法(DEF 文件可以) |
不适合导出类时的 ABI 稳定性控制 | 导出类建议用更复杂的导出控制机制 |
建议使用场景
- 小型 DLL 或实验项目
- 简单函数导出
- 不需要重命名、隐藏、复杂控制的项目
对比 DEF 文件导出
方式 | 特点 |
---|---|
__declspec(dllexport) | 快捷、简单、编译时控制 |
DEF 文件 | 精细控制导出名称、是否隐藏、序号等更强大 |
已经成功演示了使用 #pragma comment(linker, "/export:...")
来控制 DLL 导出符号的方法。下面是对这个用法的完整解释:
#pragma comment(linker, "...")
是什么?
这是 Microsoft 编译器提供的一种 在源代码中插入链接器指令 的方式。它等效于在链接命令行中加参数。
示例代码解释
extern "C" int GetOne() { return 1; }
extern "C" int GetTwo() { return 2; }
extern "C" int GetThree() { return 3; }
#pragma comment(linker, "/export:GetOne")
#pragma comment(linker, "/export:GetTwo")
#pragma comment(linker, "/export:GetThree")
#pragma comment(linker, "...")
会将指定的参数传给链接器。- 这就像在
link
命令中写了/EXPORT:GetOne
一样。 - 与
__declspec(dllexport)
不同,这种方式可以不修改函数声明本身。
编译和链接命令
cl /c Numbers.cpp
link Numbers.obj /DLL /NOENTRY
即使在 link
命令中没有手动指定 /EXPORT
,这些指令已经在 .obj
文件里生效。
你用 dumpbin /directives Numbers.obj
查看到了:
Linker Directives----------------/export:GetOne/export:GetTwo/export:GetThree
查看导出函数(验证)
dumpbin /exports Numbers.dll
输出说明这 3 个函数被正确导出:
Ordinal | RVA | Name |
---|---|---|
1 | 00001000 | GetOne |
2 | 00001020 | GetThree |
3 | 00001010 | GetTwo |
优点总结
优点 | 描述 |
---|---|
不需要改函数声明(不像 __declspec(dllexport) ) | |
可以导出静态库中的函数 | |
可写在 .cpp 中集中管理导出列表 | |
支持重命名导出(/export:Alias=RealName ) | |
支持条件编译灵活控制 |
缺点或注意事项
限制 | 描述 |
---|---|
与函数分离,不够直观 | |
不支持 PRIVATE 关键字(需要 DEF 文件) | |
不如 __declspec(dllexport) 显式,维护大项目时难管理 |
适用场景
- 想把导出逻辑放在
.cpp
而非.def
文件中 - 不想污染函数签名(如第三方源代码)
- 编写跨平台代码时,希望保留平台相关逻辑在
#pragma
中
高级技巧:重命名导出
#pragma comment(linker, "/export:AliasName=RealFunctionName")
这样,用户通过 AliasName
使用,实际调用的是 RealFunctionName
。
总结
#pragma comment(linker, "/export:...")
是一种介于命令行和 __declspec(dllexport)
之间的中间方案,适合对链接过程有细节控制需求的开发者。你已经正确掌握了它的使用方式
以下是 加载 DLL(例如 Hello.dll
)时发生的全过程 的中文解释,按照操作系统实际执行顺序分步骤说明,便于你更深入理解 Windows 的 DLL 加载机制。
加载 Hello.dll
时发生了什么?
第一步:查找 Hello.dll
系统会按照一定的搜索顺序去寻找 DLL 文件:
- 应用程序所在目录
- 系统目录(如
C:\Windows\System32
) - Windows 目录
- 当前工作目录
- PATH 环境变量中指定的目录
如果找不到文件,LoadLibrary()
调用会失败,返回ERROR_MOD_NOT_FOUND
。
第二步:将 Hello.dll
映射到内存
系统不会直接将整个 DLL 文件复制到内存,而是使用内存映射文件(memory-mapped file)技术,将 DLL 映射进进程地址空间中。
- 会根据 DLL 的 PE 文件结构,将
.text
(代码段)、.data
(数据段)、.rdata
(常量)等加载到指定的虚拟地址中。 - 这一步类似“注册 DLL 到当前进程”。
第三步:加载依赖的其他 DLL
如果 Hello.dll
引用了其他 DLL(例如 KERNEL32.dll
中的函数),系统会递归地去加载这些被依赖的 DLL。
可用 dumpbin /imports Hello.dll
查看这些依赖项。
如果任何一个依赖 DLL 无法加载,整个加载过程将失败。
第四步:绑定导入函数(Import Binding)
系统接着会解析 DLL 中的导入表(Import Table),把里面的函数名映射成实际内存地址,并填充到 导入地址表(IAT) 中。
举例:
extern "C" __declspec(dllimport) int GetGreeting();
这类函数在编译时只是声明,实际地址是运行时由系统通过这个步骤填入的。
如果导入的函数找不到,系统会返回 ERROR_PROC_NOT_FOUND
。
第五步:调用入口函数(DllMain)
如果 DLL 中定义了入口函数 DllMain()
,系统会调用它,通知 DLL 它已经被加载:
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{if (fdwReason == DLL_PROCESS_ATTACH) {// 初始化代码}return TRUE;
}
如果链接时用了 /NOENTRY
参数,那么 DLL 中没有入口函数,此步骤会被跳过。
可选高级知识
概念 | 说明 |
---|---|
延迟加载(Delay-load) | 使用 __declspec(dllimport) 延迟到函数真正调用时再加载 DLL。 |
重定位(Relocation) | 如果 DLL 不能被加载到默认基地址,系统会进行地址修正。 |
安全机制(安全加载) | Windows 有 DLL 搜索顺序安全限制、防 DLL 劫持策略等。 |
并行组件(SxS) | 支持多个版本的 DLL 共存,使用清单(manifest)管理版本。 |
总结表格
步骤 | 描述 |
---|---|
1 | 查找 DLL 文件位置 |
2 | 将 DLL 映射到内存 |
3 | 加载其依赖的 DLL |
4 | 解析并绑定导入函数地址 |
5 | 调用 DllMain(如果有)完成初始化 |
小结一句话:
加载 DLL 就像打开一本书,系统先找到这本书,翻开封面,把里面需要的章节(函数)贴上书签(函数地址),最后提醒作者你已经“打开”了这本书(调用
DllMain
)。
你这段代码展示了 Windows 系统中 DLL 是引用计数(Reference Counted)管理的,下面是详细的中文解读和你应该理解的关键点:
你的代码分析
#include <Windows.h>
int main()
{HMODULE Hello1 = LoadLibraryExW(L"Hello.dll", nullptr, 0); // Hello.dll refcount: 1HMODULE Hello2 = LoadLibraryExW(L"Hello.dll", nullptr, 0); // Hello.dll refcount: 2// Hello1 and Hello2 will be the sameFreeLibrary(Hello1); // refcount: 1 (not unloaded)FreeLibrary(Hello2); // refcount: 0 (now unloaded)
}
理解重点:DLL 的引用计数机制
在 Windows 中,每个进程对每个 DLL 维护一个“加载计数”:
1. LoadLibrary
增加计数
每次你调用 LoadLibrary
(或 LoadLibraryEx
),即使是同一个 DLL 名称,系统会:
- 检查是否已经加载过这个 DLL(路径一致,名字大小写忽略);
- 如果已经加载,不会重新加载,而是:
- 增加引用计数(refcount += 1)
- 返回已有的模块句柄(
HMODULE
)
所以在你代码中,
Hello1 == Hello2
,都是指向已经映射到内存中的Hello.dll
。
2. FreeLibrary
减少计数
每次你调用 FreeLibrary(HMODULE)
,系统会:
- 将引用计数减 1
- 当引用计数变成 0 时:
- 执行
DllMain(..., DLL_PROCESS_DETACH)
,清理资源 - 从进程地址空间卸载该 DLL
- 执行
模拟执行过程
操作 | 引用计数 | 说明 |
---|---|---|
LoadLibraryEx("Hello.dll") | 1 | 加载 DLL |
LoadLibraryEx("Hello.dll") | 2 | 未重新加载,只是加引用计数 |
FreeLibrary(Hello1) | 1 | DLL 仍驻留内存 |
FreeLibrary(Hello2) | 0 | 现在才真正卸载 DLL |
实用补充知识
概念 | 说明 |
---|---|
DllMain | 在 DLL 加载和卸载时会被系统自动调用 |
HMODULE | 本质上是 DLL 加载后在内存中的地址(模块基地址) |
GetModuleHandle | 获取已经加载的模块,不增加引用计数 |
FreeLibrary | 必须匹配 LoadLibrary 次数调用,否则 DLL 永远不会卸载 |
小结一句话:
在 Windows 中,DLL 加载是引用计数式的共享资源管理 —— 只有最后一次
FreeLibrary()
才会真正卸载 DLL。
这段内容详细说明了 Windows 加载 DLL 的查找机制,尤其是使用 LoadLibraryExW
时,系统是如何决定加载哪个 DLL 的。下面是清晰的中文解读,帮助你系统性理解:
问题:系统是如何找到正确的 Hello.dll 的?
HMODULE HelloDll = LoadLibraryExW(L"Hello.dll", nullptr, 0);
一、绝对路径加载(推荐、最清晰)
HMODULE HelloDll = LoadLibraryExW(LR"(A:\Hello.dll)", nullptr, 0);
- 如果
A:\Hello.dll
已经加载过了,系统就返回现有的HMODULE
,不会重复加载。 - 如果还没加载,才会真的加载这个 DLL,并分配内存。
结论:用绝对路径(如A:\
)可以同时加载多个同名但不同路径的 DLL。
HMODULE A = LoadLibraryExW(LR"(A:\Hello.dll)", nullptr, 0);
HMODULE B = LoadLibraryExW(LR"(B:\Hello.dll)", nullptr, 0);
// A ≠ B:它们分别是不同的 DLL
二、使用 DLL 名称加载
HMODULE HelloDll2 = LoadLibraryExW(L"Hello.dll", nullptr, 0);
此时不指定路径,系统会开始按以下顺序搜索 DLL:
DLL 搜索顺序(标准搜索路径)
- 应用程序所在目录(即
.exe
所在目录) C:\Windows\System32\
(64位系统使用)C:\Windows\SysWOW64\
(32位程序在 64 位系统上使用)C:\Windows\System\
(16位系统遗留)C:\Windows\
(Windows 根目录)- 当前工作目录(从 XP SP2 起不再优先)
- 所有在
%PATH%
环境变量中的路径
如果你写的是LoadLibraryExW(L"Hello.dll", nullptr, 0);
,系统将按上面顺序找这个 DLL,并使用第一个找到的有效版本。
三、Known DLL(已知 DLL)
Windows 系统有一类特殊的 DLL,叫做 Known DLLs,是系统事先在注册表里注册的,性能更优。
示例:
HMODULE h1 = LoadLibraryExW(L"kernel32.dll", nullptr, 0);
HMODULE h2 = LoadLibraryExW(L"ole32.dll", nullptr, 0);
这些 DLL 被认为是系统核心组件,系统会直接从 C:\Windows\System32\
加载它们(甚至可能是内核共享内存中映射过来的),不会去别处找。
注册表位置如下:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs
四、同名 DLL 加载顺序示例
LoadLibraryExW(LR"(A:\Hello.dll)", nullptr, 0); // 首次加载,加载A盘的版本
LoadLibraryExW(LR"(B:\Hello.dll)", nullptr, 0); // 不同路径,也加载成功
LoadLibraryExW(L"Hello.dll", nullptr, 0); // 会选择已加载的A盘版本
注意:只要有同名 DLL 已被加载,LoadLibrary 会复用已有模块,除非你用绝对路径强制加载其他的。
总结一句话:
Windows 加载 DLL 时,除非你指定绝对路径,否则系统会按搜索顺序查找第一个匹配的 DLL,或使用已加载版本。Known DLL 则直接使用系统内置映射。
如果你想查看系统实际加载了哪个 DLL,可以使用:
tasklist /m Hello.dll
或者在程序中用:
GetModuleFileName(hDll, buffer, sizeof(buffer));
这段列出了 Windows DLL 加载过程中的各种自定义搜索机制,说明 DLL 加载行为不仅依赖默认搜索顺序,还可以通过一些机制或标志加以控制或修改。下面我为你详细中文解释每一项的含义和作用:
DLL 加载搜索过程是可自定义的(The Search Process is Customizable)
Windows 提供多种方式改变或精确控制 DLL 的加载路径,以提高安全性、兼容性或满足应用需求。
1. DLL 重定向 (.local 文件)
如果你创建一个名为 YourApp.exe.local
的空文件,并放在你的可执行文件旁边:
- Windows 将只从该目录加载 DLL,不再搜索系统路径或 PATH。
- 常用于开发和调试,使程序优先使用本地版本 DLL。
- Windows Vista 起,该方法只对
.exe
是非系统路径加载的进程有效。
2. Side-by-Side (SxS) 组件
Windows 提供 SxS 技术用于解决“DLL Hell”(DLL 冲突)问题。
- 应用程序可以指定使用某个特定版本的共享 DLL。
- DLL 安装在
C:\Windows\WinSxS\
下。 - 使用清单文件(
.manifest
)定义依赖项。 - 示例:多个应用可以共存不同版本的
comctl32.dll
。
3. 环境变量 %PATH%
系统会从环境变量 %PATH%
中列出的目录中查找 DLL。
- 放置 DLL 的路径可被加入系统或用户 PATH。
- 但这也存在安全风险:恶意 DLL 被放入高优先级路径中。
4. AddDllDirectory
函数
- 允许程序动态添加 DLL 搜索目录。
- 配合
SetDefaultDllDirectories
使用后,只搜索你显式指定的目录。 - 安全性好,推荐在现代应用中使用。
AddDllDirectory(L"C:\\MySafeDLLs");
5. LoadLibraryEx
的 Flags(标志)
LoadLibraryExW
允许你传入额外参数,控制搜索行为:
◾ LOAD_WITH_ALTERED_SEARCH_PATH
- 修改搜索顺序:将 DLL 所在目录排在最前。
- 用于加载依赖 DLL 的 DLL 文件时非常有用。
◾ LOAD_LIBRARY_SEARCH_APPLICATION_DIR
- 只从应用程序所在目录加载 DLL。
◾ LOAD_LIBRARY_SEARCH_DEFAULT_DIRS
- 仅从默认安全目录(不含当前目录)中查找 DLL。
◾ LOAD_LIBRARY_SEARCH_DLL_LOADER_DIR
- 从当前加载的 DLL 所在目录开始查找依赖项。
- 用于防止依赖项从错误位置加载。
◾ LOAD_LIBRARY_SEARCH_SYSTEM32
- 仅从
C:\Windows\System32
加载。 - 非常安全,常用于加载系统 DLL。
◾ LOAD_LIBRARY_SEARCH_USER_DIRS
- 从
AddDllDirectory()
添加的自定义目录中查找。
6. Windows Store / UWP 应用中的 DLL 加载
- UWP 应用是沙盒环境,DLL 加载机制被进一步限制。
- 不允许访问传统系统 DLL 搜索路径。
- 必须通过声明依赖项、使用 API Contract 等方式。
为什么这么复杂?
为了兼顾:
- 兼容性(老程序)
- 安全性(防止 DLL 劫持)
- 灵活性(允许自定义逻辑)
总结
Windows 提供了一系列工具(.local、SxS、AddDllDirectory、LoadLibraryEx Flags 等)来让开发者灵活、安全地控制 DLL 加载路径,避免冲突或劫持。
你提到的内容是关于 DLL 文件在磁盘上的大小和其加载到内存后占用空间的差异,这是 Windows PE(Portable Executable)文件格式的一个重要特性。下面我用中文为你详细解释:
主题:为什么 DLL 文件磁盘上才 2KB,内存中却占用了 12KB?
1. 磁盘大小 vs 内存映射大小
属性 | 值 |
---|---|
磁盘文件大小 | 2,048 字节(2KB) |
加载到内存后的大小 | 0x3000 = 12,288 字节(12KB) |
这就是为什么你看到: |
A:\>dir Hello.dll
2,048 Hello.dll
A:\>dumpbin /headers Hello.dll
3000 size of image
2. 原因:文件对齐(File Alignment)与内存对齐(Section Alignment)不同
PE 文件格式定义了:
属性 | 含义 |
---|---|
File Alignment | 文件在磁盘上的最小对齐单位(比如 512 字节) |
Section Alignment | 映射进内存时的对齐单位(通常是 0x1000 = 4KB) |
举个例子(实际情况):
你 DLL 中可能有 2 个段(section):
段名 | 实际内容大小 | 在文件中大小(按 512B 对齐) | 内存映射大小(按 4KB 对齐) |
---|---|---|---|
.text | 300 字节 | 512 字节 | 4KB(0x1000) |
.data | 100 字节 | 512 字节 | 4KB(0x1000) |
加上可能还有 .rdata 等段,每个段无论内容多少,加载到内存后都必须占据一个完整的页面(通常是 4KB),所以: |
总内存大小 = 各段的 Section Alignment 加总 ≈ 0x3000 = 12KB
但:
总文件大小 = 各段的 File Alignment 加总 ≈ 2048 字节
3. dumpbin /headers 的关键信息
OPTIONAL HEADER VALUES3000 size of image ; 内存中总共占用 0x3000 字节1000 section alignment ; 每个段对齐到 4KB200 file alignment ; 每个段在文件中对齐到 512B
4. 操作系统为什么这么做?
- 页对齐(4KB) 是为了配合虚拟内存的页面管理机制。
- 加载时,每段直接映射到内存的独立页面。
- 减少页故障,提高加载效率。
总结
项 | 原因 |
---|---|
DLL 文件小(2KB) | 因为文件中的内容被 512 字节对齐,仅存放实际指令和数据 |
DLL 内存映像大(12KB) | 因为每个段都被对齐到 4KB 页面边界以便映射进内存 |
dumpbin 显示 size of image = 0x3000 | 表示操作系统会分配 3 个内存页来加载这个 DLL |
你的这段内容解释了 Windows 加载 DLL 到内存的过程,尤其强调了内存对齐和虚拟内存使用的机制。我们来用中文逐步解释:
主题:为什么 DLL 文件体积小,但加载后内存占用大?
一、每个段(Section)在内存中必须按页面(Page)对齐
比如你运行的:
A:\>dumpbin /headers Hello.dll
输出类似:
SECTION HEADER #1.textVirtual Size: 8Virtual Address: 1000Size of Raw Data: 200File Pointer: 400
SECTION HEADER #2.rdataVirtual Size: D8Virtual Address: 2000Size of Raw Data: 200File Pointer: 600
说明每个段都被强制映射到新的内存页地址上,即使内容很小也如此:
.text
段只用了 8 字节代码,但分配在从0x1000
开始的整页(4KB);.rdata
段只用了 D8 字节(约 216 字节),也被映射到0x2000
页。
为什么这样做?
这是因为 Windows 使用分页机制(页通常为 4KB),每个内存段都需要页对齐才能设置不同的访问权限(如.text
是可执行的,.data
是可写的)。
二、为什么大数组没有出现在文件中?
你写了这段代码:
char GlobalBuffer[1024 * 1024]; // 1MB
但生成的 DLL 文件:
A:\>dir HelloBuffer.dll
2048 bytes
并没有变大,这是为什么?
原因:
- 这个数组虽然声明了 1MB 大小,但没有初始化;
- 编译器会将它放到
.bss
或.data
段中; - 如果没有初始值,链接器不需要在文件中存储内容,只需要告诉 loader:“这段内存需要预留,但你不需要读文件内容”。
你看到的:
SECTION HEADER #3.dataVirtual Size: 100000 (= 1MB)Size of Raw Data: 0File Pointer: 0Flags: Read Write
说明:
- 内存中会分配 1MB;
- 文件中根本不包含这部分内容;
- loader 会初始化它为全零(标准行为);
三、加载 DLL 的完整过程是这样的:
- 打开 DLL 文件;
- 读取 “SizeOfImage” 字段(比如
103000
); - 申请一块连续的、按页对齐的内存空间(比如 0x103000 字节 ≈ 1MB + 一些段);
- 依次把
.text
,.rdata
,.data
等段从文件复制到内存相应位置; - 对每段设置访问权限(例如
.text
是Execute | Read
,.data
是Read | Write
); - 处理导入表、重定位表等。
四、总结:我们不需要把所有东西都存到 DLL 文件里
内容 | 是否写入文件 | 内存中是否存在 | 原因 |
---|---|---|---|
初始化数据(如字符串) | 是 | 是 | 放入 .rdata 或 .data |
未初始化数据(如大数组) | 否 | 是 | .bss ,由 loader 分配并清零 |
小函数(如 GetGreeting) | 是 | 是 | 编译生成在 .text 段 |
核心概念小结
- 文件中的内容 ≠ 内存中的内容;
- 未初始化变量只占虚拟内存,不占磁盘空间;
- 每个段都必须按页(4KB)对齐加载到内存;
- Windows 加载器(loader)根据 PE 头中的信息,分配空间并完成映射。
如果你还想深入了解如.bss
段、导入表、重定位表或 PE 文件格式,我可以继续讲解。是否要继续深入?
你展示的代码和输出,讲的是 DLL 里的 全局变量指针和重定位(Relocation) 概念。让我帮你用中文详细解释一下:
你给出的代码(PointerGlobal.cpp)
extern "C" __declspec(dllexport) const size_t Two = 2;
extern "C" __declspec(dllexport) const size_t* const PointerToTwo = &Two;
Two
是导出的一个常量变量,值为 2。PointerToTwo
是指向Two
的指针,也被导出。
DLL 导出情况(dumpbin /exports PointerGlobal.dll
)
ordinal hint RVA
0 00001008 PointerToTwo
1 00001000 Two
Two
地址是0x1000
PointerToTwo
地址是0x1008
通过 dumpbin /rawdata:8 /section:.rdata PointerGlobal.dll
看 .rdata
段的数据:
0000000070001000: 0000000000000002 0000000070001000
- 前 8 字节:
0000000000000002
—— 代表Two
变量的值 2 - 后 8 字节:
0000000070001000
—— 指针PointerToTwo
的值,是Two
的地址(0x70001000
)
这体现了什么?
1. 指针地址是“重定位地址”
PointerToTwo
是个指针,指向变量Two
的地址。- 这个地址在 DLL 文件里是“虚拟地址”(RVA + Image Base)。
- 加载时,操作系统 loader 需要把这些地址“重定位”成进程中真实的内存地址。
2. 为什么要重定位?
- DLL 在文件里的地址是相对于 ImageBase 的(假设是
0x70000000
)。 - 实际加载时,DLL 可能被加载到不同的内存地址。
- 指针里面存的地址必须修正(relocate)成 DLL 真正的加载基址 + 偏移。
3. 所以这个指针并不是简单的“写死”的地址
- 它的值是 DLL 的加载基址 +
Two
的 RVA。 - 当 DLL 加载时,系统会调整(修正)这个地址,使它正确指向内存中的
Two
。
总结:
- 你写的
PointerToTwo
是指向Two
的指针,二者都导出; - DLL 文件中指针的值是相对于 DLL ImageBase 的“虚拟地址”;
- 加载时操作系统 loader 会对这些地址进行重定位(Relocation);
- 这就是为什么 DLL 中能正确让指针指向同一个模块内的变量。
你这个讲解非常清楚,重点是重定位(Relocation)机制如何修正 DLL 中指针的值,我帮你总结下核心要点,用中文说得更明白些:
核心问题
- DLL 文件内部的指针(比如
PointerToTwo
指向Two
)存的是基于 DLL 首选加载地址(Preferred Base Address,比如0x70000000
)的绝对地址。 - 但实际上,DLL 不一定能加载到这个地址,它可能被加载到一个不同的内存地址(比如
0x90000000
)。 - 这时,指针里面的地址就不对了,因为它还指向旧的“首选地址”空间。
重定位机制的作用
- DLL 文件里有一张 重定位表(Relocation Table),列出了文件中哪些地址是需要动态调整的。
- 操作系统 Loader 会根据:
来修正指针。新指针 = 旧指针 - 首选加载地址 + 实际加载地址
你的例子
- DLL 的首选加载地址(Preferred Base Address)是
0x70000000
。 - 文件中
PointerToTwo
的指针值是0x70001000
(指向Two
)。 - DLL 实际被加载到了
0x90000000
。 - Loader 重新计算:
0x70001000 - 0x70000000 + 0x90000000 = 0x90001000
- 所以,
PointerToTwo
的值被修正成0x90001000
,指向正确的内存位置。
为什么这重要?
- 如果没有重定位,DLL 里面指针指向的地址会是错误的,导致程序崩溃或数据错乱。
- 重定位保证了无论 DLL 加载到哪里,内部的指针和引用都能正确指向对应对象。
补充说明
- 这个过程是系统 loader 自动完成的,程序员一般不需要手动操作。
- 但理解这个机制有助于调试 DLL 加载问题,或者设计支持重定位的模块。
如果你想,我可以帮你详细讲解: - PE 文件中重定位表的结构
- 载入时 loader 如何逐条处理重定位
- 重定位和 ASLR(地址随机化)之间的关系
这段内容很好地说明了 DLL 依赖加载和导入函数绑定的流程,我帮你总结成简单易懂的几个关键点:
DLL 依赖加载和导入绑定过程
- 检查 DLL 依赖
- 通过
dumpbin /imports Hello.dll
可以看到当前 DLL 依赖哪些外部 DLL 和函数。 - 你演示的最初
Hello.dll
没有依赖,但后来你添加了调用MultiByteToWideChar
(属于KERNEL32.dll
),这就产生了依赖。
- 通过
- 加载依赖的 DLL
- 对于每个依赖的 DLL(比如
KERNEL32.dll
),操作系统 loader 会调用LoadLibraryExW
去加载它。 - 如果依赖 DLL 已经加载过,则返回已有模块句柄。
- 对于每个依赖的 DLL(比如
- 绑定导入的函数地址
- 依赖 DLL 加载后,loader 通过
GetProcAddress
找到 DLL 中对应的函数地址(如MultiByteToWideChar
)。 - 将这些函数地址写入导入地址表(IAT),使程序调用时直接跳转到正确地址。
- 依赖 DLL 加载后,loader 通过
- 如果加载失败或找不到函数
- 返回失败,加载整个 DLL 失败。
你的伪代码意思就是:
for (auto& DllDependency : DllDependencies)
{DllDependency.Handle = LoadLibraryExW(DllDependency.Name, nullptr, 0);if (!DllDependency.Handle){// 加载失败,返回错误}for (auto& Import : DllDependency.Imports){Import.Address = GetProcAddress(DllDependency.Handle, Import.Name);if (!Import.Address){// 找不到函数,返回错误}}
}
简单理解
- DLL 会有一张“依赖表”,列出所有需要的外部 DLL 和函数。
- 载入时,系统帮你把所有依赖都加载进来,并把函数地址“绑”好。
- 这样,程序调用 DLL 的导入函数时,就可以顺利跳转执行。
如果你需要,我还可以帮你详细讲下: - 导入地址表(Import Address Table,IAT)的结构
- Delay Loading(延迟加载)机制
- LoadLibrary 和 GetProcAddress 的细节
- 动态链接失败时的常见问题和调试技巧
你这段内容讲得很清楚,DLL 的入口点函数 DllMain
是 DLL 被加载和卸载时的关键回调,具体说明了:
DLL 的入口点:DllMain
BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved);
instance
:DLL 模块的句柄(和LoadLibrary
返回的一样)。reason
:调用原因,常见值:DLL_PROCESS_ATTACH
:进程加载 DLL 时调用一次。DLL_PROCESS_DETACH
:进程卸载 DLL 时调用一次。DLL_THREAD_ATTACH
:新线程启动时调用(线程附加)。DLL_THREAD_DETACH
:线程退出时调用(线程分离)。
reserved
:额外信息。- 对于
PROCESS_ATTACH
,如果 DLL 是通过LoadLibrary
显式加载,reserved
是NULL
,如果是隐式加载(EXE 的依赖),非NULL
。 - 对于
PROCESS_DETACH
,如果是FreeLibrary
卸载,reserved
是NULL
,如果是进程退出,非NULL
。
- 对于
返回值
TRUE
表示初始化成功,DLL 可以正常加载。FALSE
表示失败,会导致 DLL 加载失败。
同步机制
- 系统对调用
DllMain
使用一个全局的“Loader Lock”来同步,防止同时多个线程竞争加载/卸载 DLL。
不是所有 DLL 都需要实现 DllMain
- 你举例的
Hello.cpp
就没有DllMain
,直接导出函数即可。 - 没有定义
DllMain
,系统会使用默认入口,正常加载。
如果你想,我可以帮你演示写一个典型的DllMain
,包括打印日志、初始化资源、释放资源,或者解释Loader Lock
可能带来的死锁问题。
你这一部分讲的是 DLL 的入口点 (DllMain
) 的有无与配置,具体流程和背后的机制如下:
不含入口点的 DLL
extern "C" char const* __cdecl GetGreeting() {return "Hello, C++ Programmers!";
}
构建命令:
link Hello.obj /DLL /NOENTRY /NODEFAULTLIB /EXPORT:GetGreeting
/NOENTRY
:告诉链接器 不要添加 DLL 的入口点(不生成 DllMain)/NODEFAULTLIB
:不要链接默认的 CRT(避免隐式依赖)
验证:
dumpbin /headers Hello.dll
输出中会显示:
0 entry point
表示此 DLL 没有 DllMain
,这在某些轻量级场景下是合法的。
添加入口点的 DLL 示例
示例代码:
#include <stdio.h>
#include <Windows.h>
extern "C" BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) {switch (reason) {case DLL_PROCESS_ATTACH:puts("DllMain called for DLL_PROCESS_ATTACH");break;case DLL_PROCESS_DETACH:puts("DllMain called for DLL_PROCESS_DETACH");break;}return TRUE;
}
构建命令:
link DllWithEntryPoint.obj /DLL /ENTRY:DllMain
这告诉链接器:
/DLL
:生成 DLL/ENTRY:DllMain
:将DllMain
设置为入口点函数
当你用LoadLibrary
加载这个 DLL 或FreeLibrary
卸载时,就会看到控制台输出提示,例如:
DllMain called for DLL_PROCESS_ATTACH
总结对比
情况 | 是否有入口点 | 构建参数 | 说明 |
---|---|---|---|
Hello.dll | 否 | /DLL /NOENTRY | 纯导出函数,不含初始化逻辑 |
DllWithEntryPoint.dll | 有 | /DLL /ENTRY:DllMain | 带生命周期钩子处理逻辑 |
如你希望,我也可以解释: |
- 什么时候避免使用
DllMain
DllMain
中不能安全做哪些事(比如调用LoadLibrary
等)- 如何正确地初始化/清理资源
演示 DLL 的入口点 (DllMain) 的行为
测试程序代码:
#include <stdio.h>
#include <Windows.h>
int main() {printf("About to load DLL...\n");HMODULE const TestDll = LoadLibraryExW(L"DllWithEntryPoint.dll", nullptr, 0);printf("DLL loaded. About to unload DLL...\n");FreeLibrary(TestDll);printf("DLL unloaded.\n");
}
程序运行输出:
About to load DLL...
DllMain called for DLL_PROCESS_ATTACH
DLL loaded. About to unload DLL...
DllMain called for DLL_PROCESS_DETACH
DLL unloaded.
说明:
- 加载时触发
DLL_PROCESS_ATTACH
- 卸载时触发
DLL_PROCESS_DETACH
MSDN 的建议:DllMain 中要非常小心
出自:
“Dynamic-Link Library Best Practices”
MSDN DLL Best Practices
✳ 推荐实践摘要:
- 尽可能少做事:
- 不要在
DllMain
里执行复杂逻辑或初始化大量资源。
- 不要在
- 避免调用其他 DLL 函数:
- 在
DllMain
中调用LoadLibrary
或其他 DLL 的导出函数是危险的,可能导致死锁。
- 在
- 不要和其他线程同步:
- 不要加锁、等待事件等,因为
DllMain
被调用时是持有 Loader Lock 的。
- 不要加锁、等待事件等,因为
C/C++ 中默认入口点处理
大多数时候你 不需要手动写 DllMain,特别是在使用 C++ 的构造/析构全局对象等机制时,C 运行时 (CRT) 会自动安排这些初始化工作。手动指定入口点只在特殊情况下需要,比如:
- 你需要控制 DLL 初始化顺序
- 或者你不想链接 CRT(如
/NODEFAULTLIB
)
想深入了解的话,我还可以讲解:
- Windows 如何处理 TLS (Thread Local Storage) 回调 vs.
DllMain
- 在 C++ DLL 中如何正确使用构造函数/析构函数
- 如何避免常见的
DllMain
死锁陷阱