十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

VC2015下DLL开发全流程:导出、调用与调试实战

VC2015下DLL开发全流程:导出、调用与调试实战 简介VC动态链接库编程一直是Windows平台开发的常见难点。这份配套源码来自CSDN博文《VC动态链接库(DLL)编程深入浅出(VS2015)》面向有一定C基础、希望系统掌握DLL创建与调用技巧的开发者。压缩包共281个文件核心包括头文件(h)、实现文件(cpp)、工程文件(vcxproj/sln)、资源描述(rc)以及def导出定义、lib导入库和dll动态库等从工程配置到生成产物均有覆盖便于对照不同调用方式。资源体积仅770KB轻量但内容紧凑。目前已有344人浏览学习。源码中可见SharedDllCall、RegularDllCall、MfcExpendDll等多个示例工程涉及标准DLL、MFC常规DLL和MFC扩展DLL的编写与调用并附带SXButton等自定义控件示例适合边读博文边动手验证也可作为以后DLL项目开发的参考模板。 写DLL的时候我通常不是在写代码而是在处理一堆“约定”调用约定、导出名约定、链接方式约定还有最常见的坑——约定双方版本不一致。这个标题里的VC2015不是重点重点是“深入浅出”这四个字。DLL不难难的是把导出、调用、依赖、调试这条链路一次走通。这篇文章我用一个带源码的完整示例把VC2015环境下写DLL全流程拆开讲一遍适合刚接触Windows桌面开发的初学者也适合准备把既有模块拆成DLL复用的工程师。看完你至少能回答三个问题为什么DLL导出要用extern C、为什么链接库叫.lib而运行文件叫.dll、以及“找不到指定的模块”这种事该怎么几分钟内定位。1. 为什么拆DLL以及项目结构的整体设计1.1 DLL解决了什么实际问题DLL全称Dynamic Link Library动态链接库。对应的静态库.lib在链接时会把代码编进EXE里而DLL是在程序运行时才加载多个进程如果要用同一份代码系统会只加载一份DLL镜像到内存并共享。这不是节省那几百KB磁盘的问题而是代码组织和运维模式的区别更新一个模块只要替换DLL不用重新编译整个EXE团队并行开发时各模块独立编译接口统一互不阻塞。我自己做过一个实际项目主程序调用了算法模块、采集模块和UI模块最初全写在一个EXE里。每次算法调整整个程序要重新编译、分发、拷给客户。后来把算法抽成独立DLL接口固定成几个函数算法团队只交付DLL主程序一行不改。这就是DLL最核心的价值模块边界 编译边界 交付边界。1.2 模块划分和接口设计的思路拆DLL之前要先把接口想清楚。接口设计不当DLL会变成另一个“大杂烩”。我认为核心原则就两条第一接口要少宁可一个函数干三件事也别暴露三十个细粒度函数第二接口参数尽量用C/C基础类型除非你确定调用方和DLL用同一套编译器、同一个运行时。真正到了设计环节模块内聚度是首要考虑的和业务相关的算法、数据处理逻辑可以进DLL而流程控制、界面渲染尽量留在主程序。传给DLL的数据结构我倾向于定义成纯数据结构POD不要直接传STL容器跨模块原因后面调试部分会解释——跨模块传STL容器踩内存堆问题的概率极高。2. 核心原理导出、调用约定和名字修饰2.1 导出函数__declspec(dllexport)与.def文件VC中导出DLL函数最常见方式是在函数声明前加__declspec(dllexport)。工程里通常会配套定义一个宏让同一个头文件在DLL内部和外部调用时做到自动切换这个宏的经典写法#ifdef MATHLIB_EXPORTS #define MATHLIB_API extern C __declspec(dllexport) #else #define MATHLIB_API extern C __declspec(dllimport) #endifMATHLIB_EXPORTS这个宏一般由工程属性里的预处理器定义自动加上DLL工程编译时导出的函数带上dllexport外部引用头文件时自动变成dllimport不用手动区分。除了__declspec还可以用.def文件控制导出。.def文件能精确指定导出序号和导出名称即使函数经过重载、修改了参数导出符号仍然保持稳定。当你的DLL要给第三方用、且接口需要长期兼容时.def文件是个更稳妥的方案。一个简单的.def示例LIBRARY MathLib EXPORTS Add 1 Subtract 2用.def文件时函数定义本身不用加dllexport编译器会按照.def的设置导出。两种方式各有利弊互联网上一堆“二选一”的争论我的实际经验是内部项目用宏方式因为改动灵活对外提供SDK用.def因为接口稳定性更可控。2.2 extern C和调用约定最容易踩的名字修饰坑C因为支持重载函数名在编译时会带上参数类型信息形成修饰名mangled name。如果你把DLL导出函数写成普通的C全局函数不修饰处理外部看到的导出函数名可能是一长串像?AddYAHHHZ这种东西C程序根本没法正常调用。解决方式就是extern C告诉编译器“这个名字按C的规则来”导出后函数名保持不变。调用约定本身也会影响导出名。VC默认的cdecl导出函数在32位下名字前面会加下划线比如_Add如果指定了__stdcall导出名会带参数字节数后缀比如_Add8。这也是很多“无法定位程序输入点”报错的原因——DLL里导出的函数名和调用方期望的不一致。稳定的做法是统一约定#ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #include windows.h extern C { MATHLIB_API int __stdcall Add(int a, int b); MATHLIB_API int __stdcall Subtract(int a, int b); }这里__stdcall让被调用方清理栈配合extern C后导出名会变成_Add8形式调用方头文件里的函数指针类型也必须是__stdcall约定两边配齐才不会出问题。2.3 隐式链接和显式加载两条完全不同的使用路线DLL调用方式分两种。隐式链接就是application工程里链入DLL工程生成的.lib文件再加上头文件声明。程序启动时系统会按照固定搜索顺序找DLL应用程序目录、系统目录、PATH目录等。找不到就直接弹出“程序无法启动缺少XXX.dll”。这种方式开发方便、语法直观但启动时所有依赖一次加载个别DLL出问题整个程序进不去。显式加载则是运行时手动加载DLL用LoadLibraryGetProcAddress FreeLibrary这套Windows API代码大致如下typedef int(__stdcall *AddFunc)(int, int); HMODULE hMod LoadLibrary(LD:\\MathLib.dll); if (!hMod) return -1; AddFunc pAdd (AddFunc)GetProcAddress(hMod, _Add8); if (pAdd) { int result pAdd(2, 3); } FreeLibrary(hMod);显式加载的优点是按需加载某个DLL失败只会影响对应功能不拖垮整个进程缺点是写起来费事、类型检查少、维护麻烦。实际项目两种方式经常会混合使用底层公共库用隐式链接业务插件等按需模块用显式加载。3. 实操过程VC2015环境下从新建工程到完成调用3.1 创建DLL工程与编译源码打开Visual Studio 2015新建项目选择“Win32项目”向导里应用程序类型选“DLL”附加选项留空或选“空项目”都行。工程建好后在工程属性-预处理器定义里确认能看到MATHLIB_EXPORTS这个宏是后续头文件里自动切换的开关。先创建头文件MathLib.h#pragma once #ifdef MATHLIB_EXPORTS #define MATHLIB_API extern C __declspec(dllexport) #else #define MATHLIB_API extern C __declspec(dllimport) #endif MATHLIB_API int __stdcall Add(int a, int b); MATHLIB_API int __stdcall Subtract(int a, int b); MATHLIB_API int __stdcall Multiply(int a, int b);再创建MathLib.cpp#include MathLib.h int __stdcall Add(int a, int b) { return a b; } int __stdcall Subtract(int a, int b) { return a - b; } int __stdcall Multiply(int a, int b) { return a * b; }编译后会得到MathLib.dll和MathLib.lib两个文件。.lib在这时不是静态库而是“导入库”只记录导出符号的位置信息真正的代码在.dll里。很多新人对这个双文件结构困惑记住一句话运行时要.dll链接时要.lib。3.2 调用工程配置目录、依赖项和平台一致新建一个控制台或窗口应用程序作为调用端在使用MathLib.h之前需要把DLL工程里MathLib.h所在目录加入“附加包含目录”把.lib所在目录加入“附加库目录”然后在“附加依赖项”里写入MathLib.lib。这三个配置缺一不可。实际项目里我更推荐直接把mathlib工程加到当前解决方案中通过“项目引用”来管理依赖这样编译顺序会自动处理头文件和库路径也无需手工配置。多人协作时手工配置路径常常出现“我机器上能跑你机器上不能跑”的情况最终都是路径不对导致的。调用代码示例#include iostream #include MathLib.h int main() { std::cout 3 5 Add(3, 5) std::endl; std::cout 10 - 7 Subtract(10, 7) std::endl; return 0; }编译运行前一定要确认两件事调用程序是Debug/Release哪种配置DLL工程也必须对应同一配置调用程序是x86还是x64DLL也必须是同一个位数。Debug版DLL用的是调试版运行时Release版程序去加载通常会出错。为了让生成的DLL运行时能被调用程序找到最简单的做法是把MathLib.dll复制到可执行文件目录下。嫌手动复制麻烦可以在DLL工程的“生成后事件”里加一条命令copy /Y $(OutDir)MathLib.dll $(SolutionDir)Debug\具体路径按实际工程调整这种方式对调试实践非常省事。3.3 调试技巧设断点、附加进程和模块窗口编写DLL代码时可以直接把DLL工程设为启动项目但需要指定宿主程序。方法是在工程属性-调试-命令里填上调用程序EXE的完整路径F5启动后就能直接在DLL代码里下断点。更灵活的方式是用“调试-附加到进程”选中加载了DLL的目标进程。这种方式特别适合排查“正常运行时出问题但直接启动不报错”的场景。附加进程后在“调试-窗口-模块”里能找到MathLib.dll是否成功加载、加载的是哪个路径的版本这一步能快速发现“版本替换没生效”或者“X86的DLL被X64进程加载”这类问题。3.4 一个踩坑实录导出名和调用端对不上我写上面示例代码时实际踩过一次__stdcall导致调用失败。起因是DLL里函数用了__stdcall调用端头文件里也声明了__stdcall但我在使用GetProcAddress显式加载时写的是Add结果GetProcAddress返回空指针程序空转半天找不出原因。用dumpbin /exports MathLib.dll一看真实导出名是_Add8说明调用约定参与了导出名的生成。这种问题的定位方法很简单Visual Studio自带的开发人员命令行里执行dumpbin /exports D:\MathLib.dll输出会列出所有导出的函数名和序号。调用端用GetProcAddress时参数必须和这个输出保持一字不差。排查导出问题我第一个动作永远是跑dumpbin比猜得快太多。4. 常见问题与排查技巧实录4.1 链接时报LNK2019或无法解析的外部符号LNK2019这一类链接错误大盘原因有两个一是调用方没有链上.lib检查“附加依赖项”配置二是头文件里声明了某个函数但DLL实际没导出。这里我遇到过一种特殊情形DLL里函数定义加了extern C和__declspec(dllexport)但调用方的头文件没有放到“附加包含目录”结果编译器按旧声明处理生成了修饰名和.lib里等着的导出名对不上。解决方式是统一用项目引用或者检查所有调用点是否引用了同一份头文件——头文件一致性是链接成功的隐形前提。4.2 运行时“找不到指定的模块”但文件明明在弹窗提示“无法启动此程序因为计算机中丢失MathLib.dll”但你去目录里看文件就在。这种情况多数不是DLL本身缺失而是依赖链条断了。DLL可能依赖了某个VC运行时库MSVCP140.dll、VCRUNTIME140.dll运行库里某个组件缺失系统只报告你需要的那层依赖。排查依赖用dumpbin /dependents MathLib.dll能列出它依赖的所有模块。看到依赖的是MSVCP140.dll这类运行库时解决办法就是确认目标机器装没装对应版本的“Visual C Redistributable for Visual Studio 2015”。分发程序时我习惯把程序集做成安装包安装环境检查里把VC运行库作为前置条件基本能避免这类弹窗。4.3 x86/x64不匹配和“DLL地狱”另一个高频坑是位数不匹配。曾经有个同事把32位的DLL放到64位程序目录下还专门拷贝了一份文件程序一运行就报“应用程序无法正常启动0xc000007b”。这个错误码几乎成了位数不匹配的代名词。确认方法是“调试-窗口-模块”里看DLL加载状态或者直接用dumpbin /headers MathLib.dll看machine字段如果显示x86而进程是x64就说明找错DLL版本了。“DLL地狱”这个词指的就是不同程序需要不同版本的DLL替换一个导致另一个崩掉。日常开发降低这种风险的办法是修改DLL接口后递增版本号导出函数名尽量带版本因子比如MathLib_Add_v2这种对外发布的DLL不要放系统目录放在程序私有目录通过目录隔离减少冲突。4.4 跨模块分配内存与CRT不匹配这点经常被新手忽略。假如DLL里这么写MATHLIB_API char* GetName() { char* p (char*)malloc(64); strcpy_s(p, 64, MathLib); return p; }调用方安全释放应该是在DLL内部提供释放函数而不是直接free。因为DLL和EXE如果用了不同的运行库副本堆管理器都不一样free一个不是本堆管理器的指针轻则卡死重则崩溃。这不是逻辑问题是运行时边界问题。我自己的习惯是谁分配谁释放跨边界一定提供成对函数。同理跨模块传STL容器也可能因为分配器不一致而出错接口层尽量用POD结构体或者封装好的自定义类。5. 一个完整的实战场景将数学运算模块拆成DLL把这个道理落地到实际项目里你可能会遇到一个典型场景主程序里有一组数学运算函数多个模块都在调用后续可能还要用其他语言调用同一套计算逻辑。这时候拆DLL比较合理。模块功能包括加减乘除和阶乘设计导出接口前我先问自己调用方需要的是C风格函数还是C类如果只是计算任务C风格函数就够。头文件里这样定义extern C { MATHLIB_API int __stdcall Add(int a, int b); MATHLIB_API int __stdcall Subtract(int a, int b); MATHLIB_API int __stdcall Multiply(int a, int b); MATHLIB_API int __stdcall Divide(int a, int b); MATHLIB_API long long __stdcall Factorial(int n); }DLL实现里注意做除零保护和阶乘越界检查返回码约定用0表示成功负数表示错误码调用方通过返回码统一判断异常比返回特殊值更规范。实现除法时我会用浮点运算而不是整数除法避免调用方误解MATHLIB_API int __stdcall Divide(int a, int b) { if (b 0) return -1; // return error code return (int)((double)a / b); // to avoid truncation issues in older code test }实际开发中对外的DLL代码要额外增加版本函数GetApiVersion调用方可以动态判断所加载DLL是不是自己期望的版本。这个函数在显式加载场景下尤其实用先加载DLL再问它版本号不匹配就直接卸载比依赖系统报错要温和得多。6. 更进一步的封装方向导出类和资源管理当你要导出的不是一个函数而是一个完整的对象时C风格接口就不够用了。 VC2015下可以导出整个类class MATHLIB_API CalcEngine { public: int Add(int a, int b); int Subtract(int a, int b); };MATHLIB_API在类上相当于所有成员函数都加了导出属性。这种方式的优点是调用方用起来和本地类一模一样但跨模块会有坑——如果类里面有STL成员导出类时编译器会生成成员函数的修饰名两个模块必须保证同一个编译选项和运行时否则接口容易错位。我的建议是类导出只用于“长期固定、只在公司内部使用”的场景对外闭源SDK仍优先用C风格平面接口内部再包一层类这样接口稳定且不会暴露实现细节。资源管理上面提到的“谁分配谁释放”原则在导出类上同样适用。类对象跨DLL边界new和delete时如果两边CRT版本不一致在调用方delete一个DLL里new出来的对象也会因为堆不一致崩溃。所以许多成熟SDK会导出成对的CreateXXX和DestroyXXX函数让对象在模块内完成构造和析构最大限度地避开跨堆问题。我在DLL开发实践里体会最深的一点是DLL的核心不是代码复用而是接口稳定。写过DLL的人应该都体验过真正费时间的不是实现逻辑而是保证导出名、调用约定、运行时和数据布局在两边完全对齐。这些对齐工作在没有统一规范的项目里几乎全靠踩坑积累——所以开头我说写DLL其实是在处理约定。如果你刚开始写DLL建议先把示例里的MathLib工程完整跑通再用dumpbin看一次导出表最后再尝试改成显式加载。把这三步走完你对DLL的认识会比看十篇文章都扎实。最后分享一个小习惯所有对外DLL我都在同一份头文件里固定导出宏、调用约定和基础类型别名一劳永逸地避免团队内部各写各的风格差异。本文还有配套的精品资源点击获取
返回列表