资讯动态

Visual Studio 2019 C++动态库(DLL)创建、使用与实战避坑指南

发布时间:2026/8/12 11:19:02 来源:尧图企业网站定制
1. 从“为什么需要动态库”说起如果你在Windows上用Visual Studio 2019以下简称VS2019做C开发迟早会碰到“动态库”这个概念。很多人第一次接触它可能是在某个开源项目的编译说明里看到“请先编译xxx.dll”或者是在链接自己项目时被一个“无法解析的外部符号”错误搞得焦头烂额最后发现是没正确链接某个.lib文件。动态库或者说动态链接库DLL在Windows开发里就像空气一样无处不在但它的创建和使用对新手来说却常常像隔着一层雾。简单来说动态库是一种“代码共享”机制。你把一些功能函数、类封装成一个独立的.dll文件主程序.exe在运行时才去加载它、调用它。这和我们更熟悉的静态库.lib有本质区别静态库的代码在编译链接阶段就被“复制”并“缝合”进了你的可执行文件里最终生成一个胖胖的、独立的.exe而动态库的代码是“外挂”的主程序文件本身比较苗条运行时再“召唤”外部的.dll来干活。那么为什么要自找麻烦用动态库呢我总结下来主要是三个核心需求模块化与解耦这是工程层面的刚需。当一个项目膨胀到几十万、上百万行代码如果全部编译链接成一个单体exe每次改一行代码都要重新编译链接整个工程那编译等待的时间足够你泡好几杯咖啡了。把相对独立的功能模块比如网络通信、图像处理、数据解析做成动态库那么只要接口不变你修改和编译这个模块时主程序和其他模块完全不受影响开发效率直线上升。模块之间通过清晰的接口头文件通信耦合度大大降低。节省内存与磁盘空间这是系统资源层面的优化。想象一下系统里同时运行着10个不同的程序它们都用了同一个数学计算库。如果是静态链接那么这个数学库的代码会在内存中出现10份副本。如果是动态链接系统中只需要加载一份这个数学库的.dll所有程序共享它内存利用率显著提高。对于磁盘上的安装包也是如此多个程序可以共享同一套系统动态库比如Windows自带的那些User32.dll,Kernel32.dll。动态更新与插件化这是运行时灵活性的体现。你的软件发布了后来发现某个算法有bug或者需要性能优化。如果这个算法在动态库里你只需要替换一个新的.dll文件当然要保证接口兼容用户重启程序甚至热加载就能生效无需重新下载和安装整个庞大的软件。插件系统更是动态库的典型应用主程序定义好插件接口第三方开发者可以按照接口编写自己的.dll实现功能的无限扩展。理解了“为什么”我们再来看“怎么做”。在VS2019里折腾动态库核心就是两件事如何正确地“造”出一个动态库创建以及如何正确地“用”上这个动态库使用。下面我就结合自己踩过的无数个坑把这两件事掰开揉碎了讲清楚。2. 手把手创建你的第一个动态库项目在VS2019中创建一个动态库项目本身并不复杂但里面的几个关键配置项如果理解不透后面使用时就会问题百出。我们从头开始。2.1 项目创建与基础配置打开VS2019选择“创建新项目”在语言中选择C然后找到“动态链接库(DLL)”模板。给它起个名字比如MyFirstDLL选择好存放位置。项目创建好后你会看到解决方案资源管理器里生成了几个文件pch.h预编译头、pch.cpp、dllmain.cpp和framework.h。对于简单的动态库pch.h和framework.h我们暂时可以不用太关心重点是dllmain.cpp。// dllmain.cpp : 定义 DLL 应用程序的入口点。 #include framework.h BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: case DLL_THREAD_ATTACH: case DLL_THREAD_DETACH: case DLL_PROCESS_DETACH: break; } return TRUE; }这个DllMain函数就像是动态库的“生命周期回调函数”。当进程加载DLL、卸载DLL或者线程创建、销毁时系统会调用它并传入ul_reason_for_call来告知原因。对于绝大多数纯功能性的动态库你不需要在这个函数里写任何代码。除非你的库需要在加载时初始化一些全局资源如申请内存、建立连接或在卸载时进行清理如释放资源、断开连接否则就让它空着。乱写DllMain是导致DLL加载死锁的常见原因之一新手切记。2.2 导出函数让外部世界看到你的功能动态库的核心是“导出”功能。你的库里有成百上千个函数和类但哪些是允许外部程序调用的“公共接口”呢这就需要通过“导出”来声明。在VS2019的DLL项目中默认的导出方式是通过预定义宏来实现的。查看项目属性 - C/C - 预处理器 - 预处理器定义你会发现有一个MYFIRSTDLL_EXPORTS宏你的项目名_EXPORTS。这个宏只会在编译这个DLL项目本身时被定义。而在其他包含你头文件的项目比如调用方中这个宏是未定义的。利用这个特性我们可以在头文件中这样写// MyMathLib.h #pragma once #ifdef MYFIRSTDLL_EXPORTS #define MYMATHLIB_API __declspec(dllexport) // 编译DLL时导出 #else #define MYMATHLIB_API __declspec(dllimport) // 调用方包含时导入 #endif // 导出一个简单的函数 MYMATHLIB_API int Add(int a, int b); // 导出一个类 class MYMATHLIB_API MyCalculator { public: MyCalculator(); int Multiply(int a, int b); double Divide(double a, double b); private: // ... 私有成员 };关键点解析__declspec(dllexport)这是一个微软特有的扩展关键字告诉编译器“这个函数/类需要被导出到DLL中”编译器会为它生成导出符号和对应的入口点。__declspec(dllimport)同样是微软扩展告诉编译器“这个函数/类是从外部DLL导入的”编译器会生成不同的调用代码通过导入表进行间接调用这比调用普通函数有一点点性能开销但更重要的是能正确链接。为什么需要这个“宏开关”为了让同一份头文件既能被DLL项目使用导出也能被调用方项目使用导入。这是Windows动态库编程的一个经典模式。然后在.cpp文件中实现它们// MyMathLib.cpp #include pch.h #include MyMathLib.h int Add(int a, int b) { return a b; } MyCalculator::MyCalculator() { // 构造函数实现 } int MyCalculator::Multiply(int a, int b) { return a * b; } double MyCalculator::Divide(double a, double b) { if (b 0.0) { // 处理除零错误这里简单返回一个特殊值 return 0.0; } return a / b; }编译这个DLL项目选择Debug或Releasex86或x64注意平台要匹配在输出目录通常是项目名\x64\Debug\下你会得到两个关键文件MyFirstDLL.dll动态库本体包含了编译后的二进制代码。MyFirstDLL.lib导入库文件。这是一个非常关键但常被误解的文件。它不包含实际的函数代码只包含了DLL中导出函数的符号和地址“存根”信息。在链接阶段调用方需要这个.lib文件来告诉链接器“这些函数在某个DLL里运行时去找”。注意很多新手会疑惑为什么动态库还需要一个.lib文件可以这样理解.lib导入库是“链接时的导航图”.dll是“运行时的资源库”。没有导航图链接器不知道你的函数调用该指向哪里没有资源库程序运行时找不到实际的代码。两者缺一不可对于隐式链接方式而言。2.3 配置管理Debug/Release与x86/x64的坑这是动态库创建和使用中最容易踩坑的地方之一。你必须保证DLL的编译配置与调用它的应用程序的编译配置完全一致。运行时库Runtime Library在项目属性 - C/C - 代码生成 - 运行时库中有四个选项/MT静态多线程、/MTd静态多线程调试、/MD动态多线程、/MDd动态多线程调试。/MT和/MTd将C运行时库CRT静态链接到你的DLL中。这样生成的DLL体积较大但可以不依赖msvcrt.dll等系统CRT DLL。/MD和/MDd动态链接CRT。这是推荐的方式特别是当你的DLL需要被不同编译器版本或配置的程序调用时。使用/MD你的DLL和调用程序共享同一份系统CRT避免了内存分配和释放跨模块一个模块new另一个模块delete导致的致命错误。黄金法则DLL和调用它的EXE必须使用相同的运行时库设置。如果DLL用/MDd编译调试版而EXE用/MD编译发布版那么在链接时就会报LNK2038或LNK2005错误检测到运行时库不匹配。更糟糕的是如果强行忽略链接错误运行时可能会出现内存崩溃因为调试版和发布版的堆管理器数据结构不同。平台x86/x6432位程序x86不能加载64位x64的DLL反之亦然。在VS2019顶部的工具栏中务必为DLL项目和应用程序项目选择相同的“解决方案平台”如x64。一个常见的错误是默认新建的项目是x86的而你的主程序是x64的导致运行时出现“%1 不是有效的 Win32 应用程序”错误。3. 隐式链接最常用的动态库使用方式创建好了DLL和对应的LIB接下来就是在另一个应用程序项目中使用它。最主流、最方便的方式是“隐式链接”。3.1 项目设置三步走假设我们有一个控制台应用程序项目MyApp要使用上面创建的MyFirstDLL。告诉编译器头文件在哪包含目录 在MyApp的项目属性 - C/C - 常规 - 附加包含目录中添加MyFirstDLL项目头文件MyMathLib.h所在的路径。或者更简单的方法直接把头文件复制到MyApp的源码目录下。告诉链接器库文件在哪库目录 在MyApp的项目属性 - 链接器 - 常规 - 附加库目录中添加MyFirstDLL.lib文件所在的路径通常是DLL项目的输出目录如..\MyFirstDLL\x64\Debug\。告诉链接器要链接哪个库附加依赖项 在MyApp的项目属性 - 链接器 - 输入 - 附加依赖项中添加MyFirstDLL.lib。你也可以在代码中使用#pragma comment(lib, MyFirstDLL.lib)来达到同样效果但项目属性设置是更清晰、更可移植的做法。完成这三步后你就可以在MyApp的代码中像使用普通函数和类一样使用DLL导出的内容了// MyApp.cpp #include iostream #include MyMathLib.h // 包含DLL的头文件 int main() { std::cout Add(5, 3) Add(5, 3) std::endl; MyCalculator calc; std::cout Multiply(4, 6) calc.Multiply(4, 6) std::endl; std::cout Divide(10.0, 2.0) calc.Divide(10.0, 2.0) std::endl; return 0; }编译并运行MyApp。如果一切配置正确程序会成功运行并输出结果。这里有一个至关重要的细节MyApp.exe在运行时必须能找到MyFirstDLL.dll文件。3.2 DLL搜索路径与“无法启动因为找不到xxx.dll”这是隐式链接模式下最常见的运行时错误。系统会按照以下顺序搜索DLL应用程序所在的目录。系统目录如C:\Windows\System32 64位程序在SysWOW64。Windows目录C:\Windows。当前工作目录。PATH环境变量中列出的目录。最稳妥、最推荐的做法将编译好的MyFirstDLL.dll文件复制到你的应用程序MyApp.exe所在的同一个目录下。这样在开发、调试和最终发布时都不会出现找不到DLL的问题。实操心得我习惯在DLL项目的“生成后事件”中添加一个命令行复制操作自动将编译好的DLL复制到测试用的EXE目录下。这样每次编译DLL后测试程序都能立刻用上最新版本省去手动拷贝的麻烦。设置路径DLL项目属性 - 生成事件 - 生成后事件 - 命令行添加类似xcopy /Y $(OutDir)$(TargetName).dll $(SolutionDir)MyApp\$(OutDir)的命令。4. 显式链接更灵活的运行时加载隐式链接虽然方便但它要求程序启动时所有DLL都必须就位。有时候我们需要更灵活的控制比如根据用户选择加载不同的功能模块插件。程序某个功能依赖的DLL可能不存在我们希望优雅地降级处理而不是直接崩溃。实现热更新在不重启主程序的情况下替换DLL。这时就需要“显式链接”。显式链接完全通过Windows API在代码中动态操作不需要在链接时指定.lib文件。4.1 核心APILoadLibrary, GetProcAddress, FreeLibrary显式链接三部曲LoadLibrary/LoadLibraryEx将指定的DLL文件加载到当前进程的地址空间。成功则返回一个HMODULE句柄失败返回NULL。GetProcAddress根据DLL句柄和函数名或导出序号获取该函数在内存中的地址。返回一个函数指针。FreeLibrary减少DLL的引用计数当计数为零时从进程地址空间中卸载该DLL。下面是一个使用显式链接调用我们之前Add函数的例子// MyApp_Explicit.cpp #include iostream #include windows.h // 必须包含用于API声明 // 定义函数指针类型必须与DLL中的函数签名完全一致 typedef int (*PFN_ADD)(int, int); int main() { HMODULE hDll LoadLibrary(TEXT(MyFirstDLL.dll)); if (hDll NULL) { DWORD err GetLastError(); std::cerr Failed to load DLL! Error code: err std::endl; return 1; } // 获取函数地址 PFN_ADD pfnAdd (PFN_ADD)GetProcAddress(hDll, Add); if (pfnAdd NULL) { std::cerr Failed to get function address! std::endl; FreeLibrary(hDll); return 1; } // 通过函数指针调用 int result pfnAdd(5, 3); std::cout Add(5, 3) result std::endl; // 使用完毕后卸载 FreeLibrary(hDll); return 0; }4.2 显式链接的注意事项与进阶技巧函数名修饰Name Mangling对于C函数特别是类成员函数编译器会对函数名进行修饰添加命名空间、类名、参数类型等信息以确保重载等功能。GetProcAddress只认修饰后的名字。你可以通过dumpbin /exports MyFirstDLL.dll命令查看DLL实际导出的函数名。你会发现Add函数可能被导出为?AddYAHHHZ这样的乱码。为了让GetProcAddress能找到通常有两种做法在DLL中使用extern C来声明导出函数这会禁止C的名称修饰但同时也意味着你不能重载该函数。// 在DLL头文件中 extern C MYMATHLIB_API int Add(int a, int b);在GetProcAddress中使用修饰后的名称。但这非常不灵活且依赖于编译器。导出类显式链接很难直接处理导出的C类因为类的构造、析构、虚函数表等非常复杂。通常的实践是在DLL中导出一个C风格的工厂函数来创建和销毁类实例并返回一个不透明的句柄void*或纯虚接口指针。调用方通过这个句柄调用其他导出的C风格函数来操作对象。错误处理LoadLibrary失败的原因很多比如DLL文件不存在、依赖的其它DLL缺失可以用Depends或Dependencies工具查看、位数不匹配、DLL本身损坏等。务必检查GetLastError()并给出友好提示。资源释放确保每次成功的LoadLibrary都有对应的FreeLibrary调用尤其是在循环或条件分支中避免资源泄漏。5. 实战排坑那些年我踩过的动态库大坑理论讲完了下面分享几个我实际开发中遇到的典型问题和解决方案。这些问题在官方文档里往往一笔带过但却是项目能否顺利运行的关键。5.1 坑一跨模块内存管理这是动态库编程的“头号杀手”。问题场景在DLL中分配了一块内存比如用new或malloc然后在EXE中释放它用delete或free或者反过来。为什么是坑在Windows上如果DLL和EXE使用不同的堆管理器最常见的原因就是运行时库设置不同比如一个用/MD一个用/MT那么它们各自维护自己独立的堆。在DLL的堆上分配的内存拿到EXE的堆上去释放会导致堆损坏程序崩溃而且错误信息往往令人费解。解决方案最佳实践确保DLL和所有调用它的模块使用相同的运行时库都使用/MD或都使用/MDd。这是最根本的解决方法。谁分配谁释放如果必须跨模块传递内存那么约定好内存的分配和释放必须在同一个模块内完成。例如DLL导出一个函数CreateBuffer用于分配内存同时必须导出另一个函数FreeBuffer用于释放这块内存。调用方绝不能自己用delete去释放DLL返回的指针。使用操作系统提供的跨进程堆如GlobalAlloc/GlobalFree但效率较低一般用于进程间通信(IPC)。传递标准库容器如std::vector,std::string要极度小心这些容器内部会动态管理内存。如果DLL和EXE链接了不同版本或不同配置的C标准库那么一个模块中构造的std::string在另一个模块中析构几乎100%会崩溃。安全的做法是跨模块接口尽量使用C风格的基本类型、指针和简单结构体或者使用COM接口等二进制兼容的规范。5.2 坑二静态变量与单例的初始化顺序在DLL中有全局静态变量或静态单例对象在EXE中也有。它们的构造和析构顺序是不确定的依赖于加载顺序和编译器实现。问题场景DLL_A的全局对象构造函数中使用了DLL_B导出的某个函数。如果DLL_B尚未被加载或初始化就会导致访问违规。同样在程序退出时如果DLL_A的全局对象析构函数被调用时DLL_B已经卸载也会崩溃。解决方案惰性初始化将全局静态变量改为函数内的局部静态变量Meyers‘ Singleton模式利用C11的魔法静态变量线程安全特性确保在第一次访问时才被初始化。// 在DLL中 MyGlobalObject GetGlobalObject() { static MyGlobalObject instance; // 线程安全初始化 (C11及以上) return instance; }显式初始化/反初始化函数DLL导出Initialize()和Uninitialize()函数。EXE在确保所有依赖就绪后手动调用Initialize在程序退出、卸载DLL前手动调用Uninitialize。这给了开发者明确的控制权。避免在DLL的全局对象构造函数/析构函数中进行复杂的、有依赖的跨模块操作。保持它们的构造和析构尽可能简单。5.3 坑三符号重复与版本冲突当你的程序依赖多个第三方动态库而这些库又依赖同一个基础库比如某个特定版本的OpenSSL或zlib的不同版本时就会发生“DLL地狱”。问题表现程序运行时崩溃错误指向某个基础库的内部但你的代码并没有直接调用它。或者出现一些匪夷所思的数据错误。解决方案静态链接冲突库如果可能将冲突的第三方库以静态库.lib的形式链接到你的各个DLL中这样每个DLL都有一份自己的副本互不干扰。但这会增加最终程序的大小。重命名与私有化修改有冲突的第三方库的导出符号名或者将其编译为不导出符号的静态库然后链接到你的DLL中。这需要你有该库的源码和一定的编译知识。使用manifest文件与Side-by-Side Assembly这是Windows推荐的现代解决方案。通过清单文件.manifest精确指定每个DLL依赖的特定版本的系统或第三方组件。VS2019在编译时通常会为使用/MD的项目自动生成清单。对于自定义的共享库管理起来比较复杂。运行时动态加载显式链接对于非核心依赖可以考虑使用显式链接在需要时才加载特定版本的DLL并小心管理其生命周期避免与其他模块的版本冲突。5.4 坑四调试动态库调试动态库比调试普通EXE要麻烦一点因为你需要同时启动调试宿主EXE并让调试器附着到DLL上。在VS2019中的正确姿势将DLL项目和EXE项目放在同一个解决方案中。右键单击EXE项目 - “属性” - “调试”。在“调试器要启动的可执行文件”中浏览并选择你的EXE文件例如MyApp.exe。关键一步在“命令参数”或“工作目录”中确保它们指向正确的位置使得EXE运行时能找到DLL通常工作目录设为$(OutDir)即可。在解决方案配置管理器中将DLL项目和EXE项目的“启动项目”都设置为EXE项目或者将EXE设为启动项目并确保DLL项目是其依赖项。在DLL的源代码中设置断点。按F5开始调试。VS会启动EXE当EXE加载DLL并执行到断点时调试器会自动中断你就可以像调试普通代码一样单步执行、查看变量了。如果断点显示为空心圆并提示“当前不会命中断点。未加载任何符号”说明调试器没有加载DLL的符号文件.pdb。请检查DLL是否是用Debug配置编译的DLL的.pdb文件是否和.dll文件在同一目录在VS的“模块”窗口调试 - 窗口 - 模块中查看DLL是否已加载符号状态是否为“已加载符号”。6. 进阶话题从导出到接口设计当你熟练掌握了基础的动态库创建和使用后可以思考一些更深入的问题这关系到你库的健壮性、易用性和可维护性。6.1 导出C接口 vs 导出C类导出C接口使用extern C优点二进制兼容性好。不同编译器VC, GCC, Clang、甚至同一编译器的不同版本生成的C接口DLL通常可以互相调用。因为C的ABI应用二进制接口非常简单和稳定。函数名不会被修饰GetProcAddress查找方便。是系统API和许多跨平台库如OpenGL, SQLite的选择。缺点无法直接导出C的类、重载函数、模板等特性。需要手动管理对象生命周期工厂函数接口设计上会更繁琐。导出C类使用__declspec(dllexport/dllimport)优点面向对象使用自然。可以直接new/delete对象调用成员函数。缺点二进制兼容性极差。不同编译器、甚至同一编译器不同版本或不同编译设置如异常处理、RTTI开关下生成的DLL其类的内存布局、虚函数表、名称修饰规则都可能不同导致无法混用。这严格限制了DLL的使用范围通常要求调用方和DLL用完全相同的编译环境和设置。个人建议对于内部模块、或明确知道调用方环境完全可控的情况可以使用导出C类享受其便利。对于需要公开发布、供第三方使用的SDK或者追求最大兼容性的场景强烈建议使用纯C接口。你可以用C在DLL内部实现但对外只暴露一组C风格的函数。6.2 设计稳定的ABI应用程序二进制接口ABI定义了调用约定、数据结构布局、名称修饰等底层细节。一个稳定的ABI是动态库长期可用性的基石。使用PIMPLPointer to IMPLementation模式这是隐藏实现细节、保持ABI稳定的经典技巧。在头文件中只声明一个前置声明的实现类指针和一个包装类。// MyStableLib.h (对外公开的头文件) #ifdef MYSTABLELIB_EXPORTS #define MYSTABLELIB_API __declspec(dllexport) #else #define MYSTABLELIB_API __declspec(dllimport) #endif // 前置声明实现类 class MyClassImpl; // 对外暴露的接口类 class MYSTABLELIB_API MyClass { public: MyClass(); ~MyClass(); void DoSomething(); int GetValue() const; private: MyClassImpl* pImpl; // 指向实际实现的指针 };在DLL内部MyClass的实现全部在MyClassImpl中完成。这样无论MyClassImpl如何修改增加成员变量、私有函数等只要公共接口MyClass的成员函数签名不变头文件就不需要变调用方的代码也无需重新编译。这完美地实现了接口与实现的分离。避免在接口中直接使用STL容器或复杂类型如前所述不同模块的STL实现可能不兼容。接口参数和返回值尽量使用基本类型、原始指针配合明确的 ownership 语义或PODPlain Old Data结构体。明确版本号在DLL的导出函数名或文件名中嵌入版本号如MyLib_v1.dll当ABI发生不兼容变更时提供新版本的DLL让旧程序可以继续使用旧版本。6.3 资源如图标、字符串的管理动态库不仅可以包含代码还可以包含资源如图标、位图、对话框模板、字符串表。这些资源被编译进DLL的.rsrc段。在DLL中使用资源在DLL项目中添加.rc资源文件就像在EXE项目中一样。在DLL的代码中使用AfxGetResourceHandle()MFC或GetModuleHandle(NULL)来获取EXE的资源句柄可能找不到DLL自己的资源。正确的方法是使用GetModuleHandle传入DLL本身的模块名或者更简单在资源相关API如LoadString,LoadIcon中指定资源实例句柄为DLL的模块句柄可以在DllMain的DLL_PROCESS_ATTACH中保存hModule。从EXE访问DLL中的资源使用LoadLibraryEx加载DLL时可以指定LOAD_LIBRARY_AS_DATAFILE或LOAD_LIBRARY_AS_IMAGE_RESOURCE标志将DLL作为资源文件加载。然后使用FindResource,LoadResource等API并指定资源模块句柄为DLL的句柄来访问其资源。动态库是Windows平台开发的基石技术之一从系统API到大型软件架构都离不开它。理解其创建、使用、以及背后的原理和陷阱是每个C/Windows开发者必须掌握的技能。希望这篇长文能帮你拨开迷雾少走弯路。记住多动手实践多踩坑总结才是掌握它的唯一捷径。当你能够游刃有余地设计和使用动态库时你的软件架构能力也就上了一个新的台阶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价