简介:面向VC++开发者的动态链接库编程配套源码包,以VS2015工程形式整理,完整演示动态链接库的创建、函数导出、加载调用与对话框交互等常见场景,适合刚接触DLL的本科生、初级工程师以及希望系统梳理DLL机制的开发者。压缩包共有281个文件,约770KB,以77个头文件、69个C++源文件为主体,辅以23个vcxproj工程文件、12个sln解决方案、9个def模块定义文件、7个lib导入库、1个dll动态库及RC资源脚本、ICO图标等,完整呈现不同DLL工程的项目结构与生成产物。资源实际涵盖Win32 DLL、MFC常规DLL、MFC扩展DLL等典型类型,每个示例都包含调用端与被调用端的完整代码,可直接编译运行,便于对照观察函数导出、调用约定、资源管理、链接配置等关键细节。目前已有347人学习或浏览,适合配合原博文边读边调试,快速掌握DLL编程的基础实现与工程组织方式。
1. DLL不是黑匣子:为什么几十KB的DLL能撑起整个程序的骨架
很多开发者把DLL想象成一个"会被复制进EXE的模块",这个直觉完全反了。DLL里的机器码从头到尾只有一份,躺在磁盘上;EXE里留下的是一张"购物清单"——导入表。系统加载器在进程启动时拿着这张清单,去磁盘上一一找到DLL,把它们映射进进程地址空间,再把导出函数的真实地址填回EXE预留的槽位。正是因为这一整套映射、引用、地址修补的机制,几十KB的DLL才能被上百个程序共享同一份物理内存,DLL更新时也无须重编译EXE。
但同一套机制也带来了一大堆看起来像玄学的问题:为什么导出名改一下,程序启动就报"找不到过程入口"?为什么Debug版和Release版表现完全不一样?为什么跨DLL传递一个std::string就崩?这些现象全都落在导出表、导入表、IAT、DllMain和堆边界这几个具体机制上。这篇把这块底层拆开讲透,讲完你再看DLL报错,基本能靠机制推理而不靠猜。
2. DLL的导出与导入:程序启动时,加载器是怎么把模块"拼"起来的
2.1 导出表:__declspec(dllexport)和.def文件在PE里留下了什么
一个DLL要被别人用,前提是它愿意把某些函数"亮"出来。这个"亮"的动作,落到PE文件里就是导出目录(Export Directory)。VC2015里你写的__declspec(dllexport),本质上是让编译器在目标文件里记录一条导出元数据,链接器再把这些元数据汇总成导出表,写进最终DLL的.edata段。
先看最简单的导出写法:
// dll.cpp —— 一个最简单不过的导出函数 extern "C" __declspec(dllexport) int Add(int a, int b) { return a + b; // 真正的可执行代码,编译后放在 .text 段 }extern "C"在这里非常关键。C++编译器默认会做名字修饰(name mangling),也就是把函数名和参数类型、调用约定搅在一起,变成?Add@@YAHHH@Z这种鬼名字。加上extern "C"之后,导出名就只是朴素的Add。如果你在32位工程里把函数声明成__stdcall,名字还会变成_Add@8,末尾的@8表示参数总字节数——这是32位年代留下的约定,x64下不存在这种后缀,因为x64只有一种调用约定。
导出表的内部结构是一组平行数组,核心字段如下:
| 字段 | 作用 |
|---|---|
| AddressOfFunctions | 按序号排列的函数入口地址表(RVA) |
| AddressOfNames | 按字典序排列的导出名字符串表 |
| AddressOfNameOrdinals | 名字表到序号表的映射(下标对齐) |
| Base | 序号基数,ActualOrdinal = Base + Index |
所以导出表不是一张简单的"名字→地址"哈希表,而是"名字→序号→地址"的两级索引。GetProcAddress(hLib, "Add")要先在AddressOfNames里二分查找"Add",拿到它在AddressOfNameOrdinals里对应的下标,换算成真实序号,再用序号去AddressOfFunctions里取地址。
如果你想跳过名字搜索,可以用.def文件强制指定序号:
; MyLib.def —— 在链接器选项里指定 /DEF:MyLib.def EXPORTS Add @1 NONAMENONAME的意思是"不导出名,只导出序号"。这样调用方只能通过GetProcAddress((HANDLE)1)来拿地址,查找过程退化成一次数组下标访问,省掉字符串比较。代价是——DLL的"自描述性"没了,别人看导出表只能看到一堆序号,不知道哪个是哪个。这属于典型的"性能换可维护性"。
写完DLL之后,最该做的一件事是用VC2015自带的工具验证导出表:
dumpbin /exports MyLib.dll输出里会列出每个导出函数的序号、Hint和名字。养成这个习惯之后,很多"看不到函数"的诡异问题会第一时间现形。
2.2 导入表与IAT:EXE启动时,系统加载器替程序员做了什么
EXE这边对应的机制叫导入表(Import Directory)。链接器在链接阶段读导入库.lib,把每个DLL的名字、需要的函数名字(或序号)写进PE的.idata段,形成一个个IMAGE_IMPORT_DESCRIPTOR结构。每个描述符对应一个被依赖的DLL,里面最关键的三项是:
OriginalFirstThunk指向导入名表(INT,Import Name Table),这是一串IMAGE_THUNK_DATA,每个保存着一个函数名字的指针或序号;FirstThunk指向导入地址表(IAT,Import Address Table),结构上跟INT一样长;Name是DLL的文件名字符串。
进程启动时,系统加载器的执行流程大致是:读EXE导入表 → 按名字在搜索路径里找到DLL → 把DLL映射进进程地址空间 → 递归处理DLL自己的依赖 → 对INT里每个函数,在目标DLL导出表里查到地址 → 把地址写进IAT对应的槽位。这一步做完之后,EXE里所有"我调用这个DLL函数"的指令,就全部变成了"通过IAT槽位间接跳转"。
所以IAT在磁盘上只是一堆占位值,在内存里被加载器改写后才变成真正的函数地址。同一个DLL被两个不同进程加载,两个进程里的IAT槽位数值大概率不一样——因为DLL可能被重定位到不同基址。这个差异也是"为什么调试器里看到的地址跟链接时对不上"的根源。
VC2015还有一个默认打开的行为:Debug工程的增量链接(/INCREMENTAL)。增量链接会给每个导入函数生成一层跳转桩(ILT,Incremental Linking Table),IAT最终指向桩,桩再跳向真实函数。好处是改DLL后局部重链接更快,坏处是调试单步时你会多看到一层"多余"跳转。Release默认关掉增量链接,所以Release下的IAT直接指向真实函数,这也是Debug和Release行为差异的一个隐藏来源。
2.3 导入库.lib:它不是DLL,而是链接器的"书签目录"
新手最容易混淆的概念:xxx.lib到底是个啥。静态链接库的.lib里装的是整段整段的机器码,链接时直接合并进EXE;而DLL配套的导入库.lib里没有任何实现代码,它只是薄薄一层导入描述符:告诉你"这个函数叫Add,住在名叫MyLib.dll的模块里"。
具体到符号层面,链接器在链接EXE时看到代码里调用了Add,就去.lib里找,找到的其实是__imp_Add这个特殊符号。__imp_前缀的意思是"这是一个通过IAT间接引用的导入符号"。链接器把call Add改写成一个间接调用:先从IAT槽位取出目标地址,再跳过去,同时把MyLib.dll写进EXE的导入表。
这就是为什么你光有DLL没有.lib,根本没法做隐式链接:VC2015编译时直接报unresolved external symbol __imp_Add。这个报错信息不是在说"你的代码里有未定义的函数",而是在说"链接器没找到这个函数在哪个导入库里"。
3. 显式加载:LoadLibrary与GetProcAddress的运行时决策
3.1 三个API的分工与一个最小插件的加载骨架
隐式链接把"DLL存在且函数齐全"这件事放到了编译期承诺里——链接通过,启动就必须加载。显式加载则把决策推迟到运行期:程序跑起来了,才知道要加载哪个DLL、调哪个函数、甚至加载的是不是最新版本。这套玩法由三个API支撑:LoadLibrary负责映射、GetProcAddress负责寻址、FreeLibrary负责卸载。
// main.cpp —— 一个最小但完整的显式加载骨架 #include <windows.h> #include <cstdio> typedef int (__cdecl *AddFn)(int, int); // 函数指针类型,必须和导出方一致 int main() { // 1. 把MyLib.dll映射进当前进程,返回的HMODULE其实是模块基址 HMODULE hLib = LoadLibraryA("MyLib.dll"); if (!hLib) { printf("加载失败: %lu\n", GetLastError()); return 1; } // 2. 到导出表里按名字找函数,返回真实代码地址 AddFn add = (AddFn)GetProcAddress(hLib, "Add"); if (!add) { FreeLibrary(hLib); return 2; } // 3. 解引用函数指针,直接调用 printf("3 + 4 = %d\n", add(3, 4)); // 4. 引用计数减一,减到零才真正卸载 FreeLibrary(hLib); return 0; }LoadLibraryA这个名字值得解释一下:带A的是ANSI版本,带W的是宽字符版本。VC2015里如果你直接写LoadLibrary,编译器会通过UNICODE宏替你选择A还是W。返回的HMODULE不是操作系统意义上的句柄,它就是个指针,值等于DLL映射到进程地址空间后的基址。
LoadLibrary的内部流程里有一个不少文档一笔带过的机制:系统会先在"进程已加载模块列表"里查一遍,如果这个名字的DLL已经在内存里,它不会重新从磁盘读,而是直接把这个模块的引用计数加一,返回已有基址。这意味着同一进程里,同名DLL只会有一份代码副本——前提是两次加载用的文件路径解析到同一个文件。
搜索路径的顺序也解释了开发期最常见的"加载到旧版本DLL"问题:系统会依次看KnownDLLs缓存、系统目录、应用程序所在目录、当前工作目录、PATH目录。如果你开发目录下放了一个MyLib.dll,系统目录里恰好也有一个老版本的MyLib.dll,到底加载哪个完全取决于当年目录顺序的设定。这种问题排查起来很费劲,后面避坑章节会专门讲。
3.2 GetProcAddress的三张表:名字查序号,序号查地址
GetProcAddress(hLib, "Add")执行时做的事,正好是第2章导出表结构的逆过程。它拿到名字后,先按字典序在AddressOfNames指向的字符串数组里二分查找,找到名字后通过AddressOfNameOrdinals拿"名字槽位对应序号表里的第几个",换成真实序号,再用序号的偏移量索引AddressOfFunctions,拿到函数入口RVA,最后加上模块基址转换成进程内的虚拟地址。
这个过程里有一个需要留意的边界:导出表的Base字段不是0。如果Base是10000,序号为10005的函数,它在AddressOfFunctions里的下标是10005 - 10000。所以有些老代码会用GetProcAddress(hLib, (LPCSTR)10005)按序号查找,这时传入的不是字符串指针,而是序号值,查找直接跳过名字表,用序号减Base去索引。
正因为GetProcAddress的整个查找基于模块基址,同一个函数地址在进程里是唯一的、确定性的。这也是为什么显式加载适合做插件架构:你可以让插件DLL在运行期才被扫描到,插件接口用一组固定的extern "C"导出函数暴露,宿主进程根本不需要在编译期就认识每个插件。
3.3 显式加载与隐式链接:运行时决策和编译期承诺的取舍
两种方式的取舍不是"哪个更好",而是"哪个阶段给出承诺"。隐式链接的承诺是编译期的:链接过,启动就必须成功。它的好处是DLL缺失、函数缺失都会在进程创建早期暴露,出问题很直接;坏处是哪怕你只用到DLL里的一个函数,整个DLL都得跟着进程启动一起加载。
显式加载的承诺是运行期的:DLL可以不在,程序照样跑,只在真正需要时失败。坏处也很明确——调用方必须自己做空指针检查、错误码处理,而且链接器帮不了任何忙。比如你拼错一个导出名字,隐式链接会编译期报错,显式加载直接GetProcAddress返回NULL,程序运行到一半才暴露问题。
所以插件系统、按需加载的场景几乎都走显式加载;而核心模块、启动就必须在的依赖,走隐式链接反而更省心。VC2015还有一种"折中"玩法叫延迟加载(/DELAYLOAD),它把隐式链接的编译期检查能力和显式链接的运行时加载推迟结合到了一起,第6章会专门讲。
注意:
LoadLibrary返回的句柄就是模块基址,不要用CloseHandle去关闭它。对应函数是FreeLibrary,且必须成对出现。
4. DllMain与模块边界:初始化时机、堆边界和那些看不见的状态
4.1 DllMain:不是构造函数,而是一次发生在loader lock里的系统调用
每个DLL都可以有一个可选入口函数DllMain。它不负责"构造"DLL,它只是在特定时机收到系统的四个通知:
| 通知原因 | 触发时机 |
|---|---|
| DLL_PROCESS_ATTACH | 进程第一次加载该DLL时 |
| DLL_PROCESS_DETACH | 进程卸载该DLL或进程退出时 |
| DLL_THREAD_ATTACH | 进程里创建新线程时(可关闭通知) |
| DLL_THREAD_DETACH | 线程退出时(可关闭通知) |
VC2015的链接器会把DLL入口点默认指向CRT提供的_DllMainCRTStartup,它先初始化C运行库、构造全局静态对象,再调你写的DllMain。所以你在DllMain里看到的只是一个回调,真正的模块初始化比你想的早一步。
DllMain里有一个极其隐蔽的规则:当DLL_PROCESS_ATTACH发生时,进程加载器正拿着内部的一把排他锁——loader lock。在这把锁的持有期间,你不应该调用任何会触发其他模块加载或卸载的API。换句话说,LoadLibrary、FreeLibrary、GetProcAddress这些动作一旦在DllMain里出现,就可能因为锁的排队顺序不同而直接死锁。
// 错误示范:DllMain里等待别的线程结束,结果把进程一起锁死 BOOL WINAPI DllMain(HINSTANCE hInst, DWORD reason, LPVOID reserved) { if (reason == DLL_PROCESS_ATTACH) { // 危险:这里发等待事件给工作线程,而工作线程可能正等着loader lock // WaitForSingleObject(hWorkerDone, INFINITE); } return TRUE; }// 正确示范:DllMain里只做轻量、自包含的记录 BOOL WINAPI DllMain(HINSTANCE hInst, DWORD reason, LPVOID reserved) { if (reason == DLL_PROCESS_ATTACH) { g_hInstance = hInst; // 保存模块句柄,供资源查找用 g_bInitialized = FALSE; // 预留状态,真正的初始化交给导出函数 } return TRUE; }如果你的DLL确实需要在加载后立刻启动后台线程,正确做法是把"创建线程但不等待"放进来,线程体内部等loader lock释放后再去做那些重活。这条规则是DLL编程里最容易被忽略的死锁源头,很多人排查到抓狂,最后发现就是因为在DllMain里做了不该做的事。
4.2 堆边界:谁分配、谁释放,是DLL编程的第一戒律
DLL和EXE共享同一个地址空间,但这不意味着它们共享同一个堆。每个模块在链接时选择的C运行时库方式,直接决定了它手里的全局new和delete被编译成对哪个堆的调用。VC2015提供了/MT(静态链接CRT)和/MD(动态链接CRT)两种模式,如果一个EXE是/MD构建的,而某个DLL是/MT构建的,两边各自持有一套CRT状态,各自维护自己的堆数据结构。
这就是跨DLL传递对象经常崩溃的根本原因。你在DLL里写了new char[size]返回给EXE,EXE用delete[]释放——如果两边CRT不同,delete[]会去EXE的堆里找这块内存的堆信息,而它根本不在那里,轻则内存泄漏,重则堆校验崩溃。这跟你代码写得对不对没关系,地基就不是同一个地基。
一个可复现的典型例子是DLL导出函数返回std::string:
// 危险接口:DLL里构造的std::string,交给EXE去销毁 extern "C" __declspec(dllexport) std::string GetNameBad() { std::string s = "hello"; return s; // 拷贝、析构、堆操作会跨模块发生 }// 稳定接口:把所有权留在DLL内,同时提供成对的分配/释放 extern "C" __declspec(dllexport) const char* GetNameSafe(int* len) { static std::string s = "hello"; if (len) *len = (int)s.size(); return s.c_str(); // c_str()指向的数据生命周期归DLL管 } extern "C" __declspec(dllexport) void FreeBuffer(char* p) { delete[] p; // 谁分配,谁释放,永远成对 }这条规则的推论是:跨模块接口尽量只传原语类型(int、char*、void*),或者用工厂模式——创建和销毁都由同一个模块完成。MFC扩展DLL里导出类容易出问题,本质上也是这个原因:类的new发生在EXE里,但类内部的CDC*、HINSTANCE资源句柄可能指向DLL的边界。
4.3 模块的全局状态:静态对象、资源句柄和跨模块生命周期
每个DLL在进程里都有自己独立的一坨全局状态:全局变量、静态成员、模块内的static std::string。这坨状态的生命周期由DLL_PROCESS_ATTACH和DLL_PROCESS_DETACH框定。如果你在EXE里声明了一个static std::string,随后又加载了一个DLL,再卸载它——注意,DLL里的静态对象析构发生在DllMain收到DETACH通知之前,而EXE里的静态对象析构发生在进程退出时。两者顺序完全由加载卸载顺序决定,没有任何标准保证。
资源加载也是一个高频踩坑点。很多人在DLL里写AfxFindResourceHandle或者直接FindResource,拿到NULL之后怀疑资源被弄丢了。原因往往是代码里用了GetModuleHandle(NULL)拿资源句柄——这个NULL代表的是EXE模块的HINSTANCE,而不是DLL的。正确姿势是在DllMain的DLL_PROCESS_ATTACH里把HINSTANCE hInst参数保存成模块全局变量,后续所有资源查找都用它。
模块边界还影响一个隐蔽行为:DLL里的全局单例。如果两个DLL都链了同一个静态库,而且静态库里定义了全局单例,那这个单例在进程里会存在两份——每个模块一份。你用A DLL注册了东西,跑到B DLL里查,查不到,因为根本是两个"世界"。这种问题查起来非常费劲,但理解了"模块即边界"之后,就很容易推断出该用什么机制去共享跨模块状态:显式传入共享上下文指针,而不是依赖模块内全局量。
5. DLL编程避坑实录:五条血泪经验,每条都对应一个底层机制
5.1 Release正常、Debug就崩:调用约定对不上,名字也变了
典型现象:同一个DLL,Release下运行完全正常,Debug配置一编译就崩,或者反过来。初学者第一反应是"Debug宏打开了某些断言",但更常见的原因是调用约定不一致。
原因:32位工程里,__cdecl和__stdcall既影响参数栈的清理方,也影响导出名修饰。DLL里用__stdcall导出,函数名变成_Add@8;EXE里按默认的__cdecl去找Add,两边对不上。Debug和Release的默认工程设置还可能因为_DEBUG宏影响结构体布局、调试堆行为,叠加在一起让问题更难定位。
解决:在共享头文件里显式声明调用约定,导出和导入都从同一个头文件拿声明:
// shared.h —— 所有人必须从这一个头文件取值 extern "C" __declspec(dllexport) int __cdecl Add(int a, int b);同时在调用方用对应约定的函数指针类型装配。排查时用dumpbin /exports和dumpbin /imports对比两边实际看到的函数名,一秒钟就能发现名字串了。
5.2 GetProcAddress总返回NULL:编译器在导出名上加了私货
典型现象:LoadLibrary成功,模块句柄非空,但GetProcAddress返回NULL,GetLastError是127(找不到指定的过程)。
原因:C++名字修饰。你明明在.cpp里写的函数名是Add,但工程里没加extern "C"。在VC2015的x86目标下,修饰后可能变成?Add@@YAHHH@Z;如果还用了__stdcall就变成_Add@8。调用方按"Add"去找,导出表里压根没这个名字。
解决:第一步永远是用dumpbin /exports MyLib.dll看真实的导出名是什么。第二步,给所有跨模块接口加extern "C",或者用.def文件的EXPORTS段显式指定导出名。第三步,如果是C++类导出(MFC扩展DLL那种),别用GetProcAddress去按名字找,而是通过类工厂导出函数拿对象指针。
5.3 跨模块传STL容器:不是代码错,是堆不是同一个堆
典型现象:EXE调DLL返回一个std::vector<int>,运行到函数返回时还正常,在调用方代码里一析构就崩;或者偶发崩溃,Release版尤其难复现。
原因:第4章讲的堆边界。STL容器默认用new分配内部缓冲区,new的行为归属于链接CRT的那个模块。EXE和DLL的CRT配置不同(比如EXE是/MD,DLL是/MT)时,容器内部的内存操作跨了堆边界。
解决:所有跨模块接口只传原语类型或固定大小结构体;如果必须返回复杂数据,要么用"写入者分配、释放者同一个模块"的工厂函数,要么让调用方传入缓冲区,DLL只负责往里写。经验法则:谁能delete谁就有资格new,中间人不要越俎代庖。
5.4 在DllMain里等待另一个线程:整个进程跟着一起等
典型现象:DLL一加载,程序就卡死在启动阶段,调试器暂停后,调用栈停在DllMain里的一个WaitForSingleObject上,另一个线程停在LoadLibrary里——谁也等不到谁。
原因:loader lock串了。加载器在DLL_PROCESS_ATTACH阶段持有全局锁,你的DllMain等一个线程结束,那线程恰好也在等这把锁被释放,锁的持有者又在等你返回,形成循环等待。
解决:DllMain里绝对不做跨线程同步、不加载其他模块、不创建需要等待的工作线程。把它当成一个"你只有几微秒来做记录"的回调。真需要在DLL加载后干重活,导出一个Init()函数,让调用方在第一个业务调用前手动触发——这也顺便解决"DLL加载就干活"对启动速度的隐性伤害。
5.5 同名DLL被加载了两份:模块状态各说各话
典型现象:程序里日志时而走到A路径的DLL,时而走到B路径的DLL;或者一个DLL里的静态变量在"两个世界"里被分别修改。用工具看加载模块列表,发现MyLib.dll出现了两个不同路径的副本。
原因:加载路径不唯一。前文提到LoadLibrary会在已加载模块列表里查重,但"查重"是按解析后的文件路径来的。如果你的程序当前目录、系统目录、PATH目录里各有一份同名DLL,同一个名字可能解析到不同文件;一旦哪个模块用绝对路径加载,另一个用相对路径加载,两个副本就同时存在了。
解决:核心模块一律用绝对路径或相对于进程文件路径拼接的固定路径加载;次要模块在开发阶段就要保证工作目录干净。排查时用GetModuleHandle(name)查已加载基址,或者用调试器的模块窗口看两个副本的基址,再定向清理。
6. 延迟加载与设计取舍:为什么有的团队宁可静态编进EXE
6.1 /DELAYLOAD的底层:把LoadLibrary+GetProcAddress伪装成一次普通调用
延迟加载是VC2015链接器提供的一个折中方案:在编译期写正常的隐式链接代码,但链接器不把DLL写进传统导入表,而是写在延迟描述表里,并为每个延迟导入函数生成一个跳转桩。第一次调用这个函数时,桩会跳进系统提供的__delayLoadHelper2辅助函数,它在内部做的事和显式加载完全一样:
// 延迟加载辅助函数的机制等价物(代码为示意,非真实实现) void DelayLoadStub() { if (!pfnSlot) // 首次调用时槽还是空的 { HMODULE h = ::LoadLibraryA("MyLib.dll"); // 即时加载 pfnSlot = (void*)::GetProcAddress(h, "Add"); // 即时查找 if (!pfnSlot) throw "Dll not found"; // 失败可走hook } pfnSlot(); // 之后每次调用直通目标函数 }这次调用之后,桩已经把函数地址缓存在自己的槽位里,后续再调用就是一次普通的间接跳转,几乎不会有额外开销。比起手动显式加载,延迟加载保留了你正常的Add(3,4)调用写法,代码可读性好得多;DLL缺失的报错也从"启动即失败"推迟到"第一次调用的那一瞬间"——这个失败的时机,恰好就是你真正需要这个函数的时机。
6.2 三种链接方式的权衡与我的固定动作
| 链接方式 | 函数地址确定时机 | DLL缺失何时暴露 | 更新DLL是否免重编译 | 调试复杂度 |
|---|---|---|---|---|
| 静态链接 | 编译期 | 不会缺 | 要重新链接EXE | 最简单 |
| 隐式动态链接 | 进程启动时 | 启动即失败 | 换DLL文件即可 | 简单 |
| 显式/延迟加载 | 首次调用时 | 运行到该路径才失败 | 换DLL文件即可 | 较高 |
静态链接的好处是在交付时可以只给一个EXE,没有版本地狱,客户不会拷错DLL;坏处是二进制大、任何库代码改动都要整包重发。动态链接的更新成本最低,但"运行时才告诉你缺了什么"这件事,在面向外部客户时是个不小的负担。所以很多团队对外发布用静态链接,内部插件体系用显式加载或延迟加载,把"更新高频"和"启动必保"彻底分开。
我自己后来养成了一套固定的动作:凡是导出的函数,头文件里一律extern "C"加显式调用约定,调用方统一用typedef声明的函数指针装配;链接完用dumpbin /exports把实际导出名核对一遍;Debug和Release各跑一遍"该DLL目录下运行"的最小用例。这三步看似机械,但几乎把所有DLL相关的"玄学崩溃"都提前拦在了门外。每当我急着调功能想跳过这些检查时,总会想起那些在loader lock和堆边界上耗掉的深夜——从那以后,我再也没让"差不多能用"的DLL从我手上交出去过。希望帮到你。
本文还有配套的精品资源,点击获取