资讯详情

资讯详情

VS2010下DLL与LIB的隐式加载和显示加载详解:原理、配置与实战避坑

1. 为什么在VS2010里搞明白DLL加载方式这么重要如果你现在还在维护一些老项目尤其是工业控制、医疗设备上位机、或者某些产线测试软件大概率绕不开VS2010这个老伙计。它虽然年纪大了但胜在稳定很多底层库、驱动接口、硬件SDK至今仍然只提供VS2010能直接编译的版本。而一旦涉及到模块化开发DLL和LIB的加载方式就成了必须迈过去的坎。我见过太多人在这上面栽跟头明明代码写对了编译也过了一运行就弹窗说找不到某某DLL或者链接阶段直接报一堆“无法解析的外部符号”查了半天也不知道是LIB没配对还是函数声明写错了。更麻烦的是有些场景必须用显示加载有些场景隐式加载更省事选错了方案后期维护成本会成倍增加。这篇文章就是要把VS2010下DLL和LIB的两种加载方式彻底讲透。显示加载也就是运行时通过API动态获取函数地址隐式加载也就是编译链接期就把LIB绑进去。我会从原理、操作步骤、参数配置、常见报错、排查思路几个维度展开尽量让刚接触这块的人也能跟着做出来同时也会分享一些只有实际踩过坑才知道的细节。适合谁看如果你正在用VS2010做Windows平台C/C开发需要调用第三方库或者自己封装模块这篇文章基本能覆盖你90%以上的场景。即使你用的是更高版本的VS底层逻辑也是一样的只是界面位置略有差异。2. 先把DLL和LIB的关系理清楚不然后面全是坑2.1 DLL是运行时用的LIB是链接时用的很多人一开始分不清DLL和LIB到底谁是谁。简单说DLL是动态链接库里面装着编译好的机器码程序运行起来之后才会被加载到内存里执行。LIB是静态库或者导入库它本身不包含可执行代码静态库除外主要作用是告诉链接器“你要调用的那个函数在哪个DLL里它的编号是什么”。在VS2010里如果你拿到一个第三方库通常会看到这样的文件组合一个.h头文件一个.lib文件一个.dll文件。头文件负责声明函数原型LIB负责提供链接信息DLL负责实际执行。三者缺一不可。注意有些库只提供DLL不提供LIB这时候就只能用显示加载。反过来如果只提供LIB不提供DLL那可能是静态库直接编进exe里不涉及动态加载。2.2 隐式加载和显示加载的本质区别隐式加载也叫加载时链接。程序启动的时候Windows加载器会自动把依赖的DLL加载到进程空间里。如果DLL不存在或者导出函数对不上程序直接启动失败连main函数都进不去。这种方式的好处是调用起来跟普通函数一样不需要额外的代码坏处是灵活性差DLL必须放在系统能找到的路径下。显示加载也叫运行时链接。程序启动的时候不加载DLL等到真正需要用了才通过LoadLibrary或者LoadLibraryEx把DLL加载进来然后用GetProcAddress拿到函数地址通过函数指针调用。用完还可以FreeLibrary卸载。这种方式灵活可以控制加载时机、可以处理加载失败、可以做插件式架构但代码量会多一些而且函数指针的类型声明必须和DLL里的导出一致否则调用就会崩溃。我个人的经验是如果是自己项目内部的模块DLL位置固定用隐式加载最省事如果是需要热插拔的插件、或者DLL可能不存在需要优雅降级的场景必须用显示加载。2.3 VS2010下两种方式的工程配置差异在VS2010里隐式加载需要在工程属性里配置两样东西附加包含目录指向头文件所在文件夹附加库目录指向LIB所在文件夹然后在链接器输入里加上LIB文件名。显示加载则完全不需要这些配置只需要头文件或者自己声明函数原型LIB和DLL都不需要在工程里引用。这里有个细节隐式加载时LIB文件必须和DLL的导出符号完全匹配。如果LIB是旧的DLL是新的链接可能通过但运行会崩。显示加载则没有这个问题因为函数地址是运行时动态查的只要DLL里确实有这个导出函数就行。3. 隐式加载的完整操作流程与配置细节3.1 头文件、LIB、DLL三件套的放置策略假设你拿到一个第三方库文件结构是这样的include\mylib.h、lib\mylib.lib、bin\mylib.dll。在VS2010里我习惯把这三个文件夹放在解决方案目录旁边然后用相对路径去引用这样整个工程拷贝到别的机器上也能直接编译。具体操作右键工程 - 属性 - 配置属性 - C/C - 常规 - 附加包含目录填入$(SolutionDir)..\include。然后链接器 - 常规 - 附加库目录填入$(SolutionDir)..\lib。最后链接器 - 输入 - 附加依赖项填入mylib.lib。提示$(SolutionDir)是VS2010的宏指向解决方案文件所在目录。用宏的好处是路径不会因为工程移动而失效。DLL文件则要放到exe生成目录下或者放到系统PATH能找到的目录。我一般会在工程属性里配置生成后事件用xcopy把DLL自动拷贝到输出目录省得每次手动复制。3.2 链接器输入项的填写规则与顺序问题附加依赖项里填LIB文件名时多个LIB之间用空格或者分号隔开。这里有个坑LIB的顺序有时候会影响链接结果。如果LIB之间有依赖关系被依赖的LIB要放在后面。比如A.lib依赖B.lib那顺序应该是A.lib B.lib。VS2010的链接器是从左到右扫描的顺序反了就可能报“无法解析的外部符号”。另外如果LIB文件名和系统库重名比如你自己编了一个kernel32.lib那链接器可能会优先找到系统的。这种情况极少见但一旦遇到排查起来很头疼。解决办法是给LIB改个独特的名字或者用完整路径。3.3 导出函数的声明方式__declspec(dllexport)和.def文件隐式加载能成功的前提是DLL正确导出了函数。VS2010下有两种导出方式一种是在函数声明前加__declspec(dllexport)另一种是使用.def模块定义文件。__declspec(dllexport)写起来简单直接在头文件里加就行。但有个问题C编译器会对函数名进行名称修饰name mangling导致导出的函数名变成一堆乱码。如果调用方也是C并且用同样的头文件那没问题但如果调用方是C语言或者别的编译器就会找不到函数。这时候.def文件就更可靠。它明确指定导出函数的名称和序号不受名称修饰影响。我一般推荐对外发布的库用.def内部使用的库用__declspec(dllexport)。// mylib.h #ifdef MYLIB_EXPORTS #define MYLIB_API __declspec(dllexport) #else #define MYLIB_API __declspec(dllimport) #endif extern C MYLIB_API int Add(int a, int b);上面这段代码是标准写法。MYLIB_EXPORTS宏在生成DLL的工程里定义调用方不定义这样同一个头文件既能用于导出也能用于导入。extern C是为了避免C名称修饰让函数名保持为Add。3.4 运行时报“找不到DLL”的排查链路隐式加载最常见的报错就是程序启动时弹窗“无法启动此程序因为计算机中丢失mylib.dll。尝试重新安装该程序以解决此问题。”排查步骤我一般按这个顺序来确认DLL文件确实存在于exe同级目录下。有时候VS的调试工作目录和输出目录不是同一个导致你以为拷过去了其实没有。用Dependency Walker或者VS自带的dumpbin /dependents查看exe依赖了哪些DLL。如果列表里有mylib.dll说明链接没问题纯粹是路径问题。检查DLL的位数是否和exe一致。32位exe不能加载64位DLL反之亦然。VS2010默认生成32位程序如果第三方DLL是64位的就会加载失败。检查DLL是否依赖了其他缺失的DLL。用dumpbin /dependents mylib.dll可以看到它的依赖树有时候mylib.dll本身没问题但它依赖的另一个DLL不在。注意在Win7及以上系统如果DLL放在System32目录下32位程序实际上会去SysWOW64找。这个重定向机制经常让人迷惑建议不要把第三方DLL放到系统目录。4. 显示加载的代码实现与函数指针管理4.1LoadLibrary和GetProcAddress的基本用法显示加载的核心就两个APILoadLibrary负责把DLL加载到内存返回一个模块句柄GetProcAddress负责根据函数名找到函数地址返回一个函数指针。#include windows.h #include stdio.h typedef int (*AddFunc)(int, int); int main() { HMODULE hModule LoadLibraryA(mylib.dll); if (hModule NULL) { printf(加载DLL失败错误码%d\n, GetLastError()); return -1; } AddFunc add (AddFunc)GetProcAddress(hModule, Add); if (add NULL) { printf(找不到Add函数错误码%d\n, GetLastError()); FreeLibrary(hModule); return -1; } int result add(3, 5); printf(结果%d\n, result); FreeLibrary(hModule); return 0; }这段代码是最基础的显示加载模板。LoadLibraryA是ANSI版本LoadLibraryW是宽字符版本。VS2010默认工程可能是Unicode字符集这时候直接写LoadLibrary会映射到LoadLibraryW传字符串要用Lmylib.dll或者_T(mylib.dll)。4.2 函数指针类型定义与调用约定匹配函数指针的类型必须和DLL里导出的函数签名完全一致包括调用约定。Windows下常见的调用约定有__cdecl、__stdcall、__fastcall。如果DLL里用的是__stdcall你在typedef里没写调用的时候栈就会不平衡轻则结果错误重则程序崩溃。typedef int (__stdcall *AddFunc)(int, int);怎么知道DLL用的是什么调用约定看头文件里的声明。如果没有头文件用dumpbin /exports mylib.dll看导出函数的修饰名。__stdcall的函数名会变成_Add8这种形式__cdecl则是_Add或者Add。提示64位程序只有一种调用约定不需要考虑这个问题。但VS2010默认生成32位程序所以调用约定必须匹配。4.3 用GetProcAddress按序号导出的特殊情况有些DLL导出函数时只给了序号没有给名字。这时候GetProcAddress的第二个参数要传MAKEINTRESOURCE(序号)。AddFunc add (AddFunc)GetProcAddress(hModule, MAKEINTRESOURCE(1));按序号导出的好处是加载速度快一点点而且不受名称修饰影响。坏处是可读性差而且不同版本的DLL序号可能变化。我一般只在没有头文件、又不想折腾名称修饰的时候才用这种方式。4.4 显示加载的异常处理与资源释放显示加载最大的优势就是可以优雅地处理失败。LoadLibrary返回NULL时用GetLastError拿到错误码常见的错误码有错误码含义可能原因126找不到指定的模块DLL文件不存在或路径不对193不是有效的Win32应用程序DLL位数不对或文件损坏998内存位置访问无效DLL依赖的其他模块缺失127找不到指定的程序导出函数名写错或不存在拿到错误码之后可以用FormatMessage把它转成可读的字符串方便日志记录。资源释放方面每次LoadLibrary成功之后引用计数会加一。对应的FreeLibrary会让引用计数减一。如果加载了多次必须释放相同次数。我习惯用一个RAII风格的封装类来管理构造时加载析构时释放避免忘记。class DllLoader { public: DllLoader(const char* name) : m_hModule(NULL) { m_hModule LoadLibraryA(name); } ~DllLoader() { if (m_hModule) FreeLibrary(m_hModule); } bool IsValid() const { return m_hModule ! NULL; } HMODULE Handle() const { return m_hModule; } private: HMODULE m_hModule; };5. 两种加载方式在实际项目中的选型对比5.1 启动速度与内存占用的权衡隐式加载在程序启动时就把所有依赖的DLL加载进来了启动速度会慢一些尤其是依赖链很长的时候。但好处是运行期间调用函数没有额外开销直接就是一次跳转。显示加载把加载时机推迟到了第一次使用的时候启动速度快。但每次调用函数都要通过函数指针比直接调用多一次间接寻址。不过在现代CPU上这点开销基本可以忽略。内存占用方面隐式加载的DLL在程序整个生命周期内都驻留在内存里。显示加载可以在不用的时候FreeLibrary卸载掉释放内存。对于内存紧张的嵌入式环境这个差异可能很关键。5.2 部署灵活性与版本管理的差异隐式加载的部署比较死板DLL必须放在exe能找到的路径下而且版本必须和编译时用的LIB匹配。如果用户机器上已经有一个同名但不同版本的DLL可能会加载到错误的版本导致崩溃。显示加载可以指定完整路径比如LoadLibraryA(C:\\MyApp\\Plugins\\mylib.dll)这样就不会被系统路径里的同名DLL干扰。而且可以在运行时判断DLL版本决定加载哪个。我做过一个项目需要同时支持两个版本的硬件SDK。用显示加载根据硬件型号加载对应的DLL代码里用同一套函数指针类型切换起来非常干净。如果用隐式加载就得编译两个exe维护成本高很多。5.3 调试难度与错误定位效率隐式加载的调试相对简单因为链接器会在编译期检查符号是否存在。如果LIB里没有这个函数直接编译不过问题暴露得早。显示加载的问题往往在运行时才暴露而且错误信息不如隐式加载直观。比如函数指针类型写错了编译期不会报错运行到调用那一行才崩溃堆栈信息可能指向一个完全无关的地方。我的经验是显示加载的代码一定要加详细的日志记录每次LoadLibrary和GetProcAddress的结果。一旦出问题看日志就能定位到是哪个DLL、哪个函数出的错。5.4 插件式架构为什么必须用显示加载如果你要做插件系统显示加载是唯一的选择。主程序在启动时扫描插件目录遍历所有DLL文件尝试加载并查找约定的入口函数比如GetPluginInterface。找到就调用找不到就跳过。整个过程主程序不需要知道任何插件的具体信息。隐式加载做不到这一点因为它在编译期就确定了要加载哪些DLL没法在运行时动态发现。// 插件入口函数类型 typedef IPlugin* (*GetPluginFunc)(); // 遍历目录下的所有DLL foreach (dllFile in pluginDir) { HMODULE h LoadLibraryA(dllFile); if (!h) continue; GetPluginFunc getPlugin (GetPluginFunc)GetProcAddress(h, GetPluginInterface); if (getPlugin) { IPlugin* plugin getPlugin(); pluginManager.Register(plugin); } }这种模式在音频处理、图像滤镜、协议解析等场景非常常见。6. 那些年我踩过的坑与排查经验6.1 LIB文件与DLL不匹配导致的链接错误有一次我拿到一个第三方库头文件、LIB、DLL都有。编译通过了但链接时报“无法解析的外部符号 _Add8”。我检查了头文件函数声明是int Add(int, int)没有__stdcall。但LIB里的符号是_Add8说明DLL是用__stdcall编译的。解决办法是在头文件里加上__stdcall或者用.def文件重新生成LIB。这个问题的根源是LIB和DLL的编译选项不一致但第三方库的文档没写清楚。提示拿到第三方库时先用dumpbin /exports看DLL的导出符号再用dumpbin /symbols看LIB里的符号两者对不上就说明有问题。6.2 路径问题工作目录与exe目录不一致VS2010调试的时候默认工作目录是工程目录而不是exe输出目录。如果你把DLL放在exe旁边但工作目录是工程目录隐式加载就会失败。解决办法工程属性 - 调试 - 工作目录改成$(OutDir)。或者把DLL也拷贝一份到工程目录。我一般用前者更干净。6.3 32位与64位DLL混用的典型报错32位程序加载64位DLLLoadLibrary会返回NULL错误码193。反过来也一样。这个错误信息很明确但新手容易忽略。在VS2010里默认平台是Win32。如果你拿到的DLL是64位的需要把工程配置管理器里的平台改成x64。但注意改成x64之后所有依赖的LIB也必须是64位的否则链接会报错。6.4 显示加载时函数指针类型写错的隐蔽后果函数指针类型写错是最难查的问题之一。比如DLL里是int Add(int, int)你typedef成了int (*AddFunc)(int)。编译没问题GetProcAddress也能拿到地址但调用的时候参数传递就乱了可能返回一个莫名其妙的值也可能直接崩溃。我的做法是typedef之后用static_assert或者编译期检查确认参数个数和类型。VS2010不支持static_assert但可以用一些模板技巧做检查。更简单的办法是把typedef和头文件里的声明放在一起改的时候一起改。6.5 依赖DLL缺失导致的加载失败排查有时候LoadLibrary失败不是因为目标DLL不存在而是它依赖的某个DLL不存在。比如mylib.dll依赖了msvcr100.dll而目标机器上没装对应的运行库。排查方法用dumpbin /dependents mylib.dll列出所有依赖项然后逐个检查是否存在。或者用Process Monitor监控文件系统访问看加载器在找哪个文件时失败了。注意VS2010编译的程序默认依赖msvcr100.dll和msvcp100.dll。如果目标机器没装VS2010运行库需要把这两个DLL一起打包或者改成静态链接运行库工程属性 - C/C - 代码生成 - 运行库 - 多线程(/MT)。7. 工程配置的自动化与可复现实践7.1 用属性表统一管理包含目录和库目录如果一个解决方案里有多个工程都要用同一个第三方库每个工程单独配置一遍很麻烦而且容易漏。VS2010的属性表.props文件可以解决这个问题。创建一个mylib.props文件在里面配置好包含目录、库目录、附加依赖项。然后在每个工程里添加这个属性表。这样改一处所有工程都生效。属性表的位置视图 - 属性管理器 - 右键工程 - 添加现有属性表。7.2 生成后事件自动拷贝DLL到输出目录隐式加载需要DLL在exe旁边。手动拷贝容易忘用生成后事件自动处理最靠谱。工程属性 - 生成事件 - 生成后事件 - 命令行xcopy /Y /D $(SolutionDir)..\bin\mylib.dll $(OutDir)/Y表示覆盖时不提示/D表示只拷贝源文件比目标文件新的情况。这样每次编译完DLL都会自动同步到输出目录。7.3 用dumpbin和Dependency Walker做依赖检查dumpbin是VS自带的命令行工具在VS2010命令提示符下可以直接用。常用命令dumpbin /exports mylib.dll查看导出函数列表dumpbin /dependents mylib.dll查看依赖的DLL列表dumpbin /headers mylib.dll查看文件头信息包括位数Dependency Walker是图形化工具可以直观地看到依赖树还能标出缺失的模块。不过它对新版Windows的API集支持不太好有时候会误报。我一般用dumpbin做初步检查用Dependency Walker做深入分析。7.4 版本控制中该提交哪些文件如果用隐式加载LIB文件必须提交到版本控制里因为链接的时候需要它。DLL文件可以提交也可以不提交但建议提交方便新同事拉下代码就能跑。如果用显示加载LIB和DLL都不需要提交到代码仓库但需要在文档里写清楚去哪里获取。我一般会在仓库里放一个third_party目录里面放一个README.md说明依赖的库版本和下载方式。头文件无论哪种方式都必须提交因为编译的时候需要。8. 从隐式加载迁移到显示加载的实操建议8.1 迁移的触发条件与收益评估什么时候该考虑从隐式加载迁移到显示加载我总结了几种情况需要支持多个版本的同一个库根据运行环境动态选择需要做插件式架构主程序不依赖具体实现需要优雅处理DLL缺失而不是直接崩溃需要控制DLL的加载时机比如延迟到用户点击某个按钮才加载迁移的收益是灵活性和健壮性提升代价是代码量增加、调试复杂度上升。如果项目规模不大DLL位置固定隐式加载完全够用没必要为了“看起来高级”而迁移。8.2 逐步替换的步骤与回归测试要点迁移的时候不要一次性把所有调用都改掉容易出乱子。我的做法是先封装一个DLL加载器类负责LoadLibrary、GetProcAddress、FreeLibrary。把原来的直接函数调用改成通过加载器获取函数指针再调用。每改一个模块就跑一遍回归测试确保功能没受影响。全部改完之后移除工程里的LIB依赖和附加包含目录配置。回归测试的重点是DLL加载失败的场景、函数指针为空的场景、多次加载和释放的场景。这些在隐式加载下不会出现但显示加载必须处理。8.3 迁移后如何保持代码可读性显示加载的代码容易写得又臭又长全是GetProcAddress和强制类型转换。我的经验是把函数指针的获取封装到一个结构体或者类里初始化的时候一次性获取所有需要的函数。用宏或者模板减少重复代码。给每个函数指针起一个和原函数一样的名字调用的时候看起来和普通函数差不多。struct MyLibApi { AddFunc Add; SubFunc Sub; bool Load(HMODULE h) { Add (AddFunc)GetProcAddress(h, Add); Sub (SubFunc)GetProcAddress(h, Sub); return Add Sub; } };这样调用的时候就是api.Add(3, 5)可读性和直接调用差不多。8.4 兼容旧代码的混合加载策略有些老代码已经用隐式加载写好了不想大改但又想在某些场景下用显示加载。这时候可以混合使用主程序用隐式加载保证基本功能插件部分用显示加载做扩展。需要注意的是同一个DLL如果既被隐式加载又被显示加载引用计数会累加。FreeLibrary的时候要确保释放次数和加载次数一致否则DLL会一直驻留在内存里。我一般建议要么全隐式要么全显示混合使用只适合过渡期长期来看还是统一一种方式更好维护。9. 一些零散但很关键的实操心得9.1 关于extern C和名称修饰的取舍extern C可以避免C名称修饰让导出函数名保持简洁。但代价是失去了函数重载能力。如果你的库需要导出重载函数就不能用extern C只能接受名称修饰或者用.def文件手动指定导出名。我个人的选择对外发布的C接口用extern C内部C模块用名称修饰反正调用方也是C用同一个头文件就行。9.2 DLL入口点DllMain里不要做复杂操作DllMain是DLL的入口函数在加载和卸载的时候被调用。这里面能做的事情非常有限不要在里面调用LoadLibrary、CreateThread、CoInitialize等函数否则可能导致死锁或者加载失败。我见过有人在DllMain里初始化网络连接结果程序启动直接卡死。正确的做法是在DllMain里只做最简单的标记真正的初始化放到一个单独的Init函数里由调用方显式调用。9.3 导出变量和导出类的注意事项除了导出函数DLL还可以导出变量和类。导出变量用__declspec(dllexport)修饰变量声明调用方用__declspec(dllimport)导入。但导出变量有个问题如果DLL和exe的CRT版本不一致可能会导致访问违规。导出类更复杂因为涉及到虚函数表、构造函数、析构函数的跨模块调用。我一般不建议跨DLL导出C类除非你能保证两边用完全相同的编译器和编译选项。更安全的做法是导出纯虚接口用工厂函数创建对象。9.4 用#pragma comment(lib, xxx.lib)简化链接配置除了在工程属性里配置LIB还可以在代码里用#pragma comment(lib, mylib.lib)。这样链接器会自动链接这个LIB不需要手动配置附加依赖项。好处是代码自包含拷贝到别的工程里也能直接编译。坏处是LIB的搜索路径还是要在工程里配置而且如果LIB不存在报错信息不如手动配置的直观。我一般在写示例代码或者小型工具的时候用#pragma comment大型项目还是用属性表统一管理。9.5 调试DLL加载问题的几个命令行工具除了前面提到的dumpbin和Dependency Walker还有几个工具很有用where mylib.dll在PATH里查找DLL的位置快速确认系统能不能找到tasklist /m mylib.dll查看哪些进程加载了mylib.dllProcess Explorer图形化工具可以看进程加载的所有模块还能直接卸载DLL这些工具在排查“DLL被谁占用了”“为什么加载的是旧版本”这类问题时特别高效。9.6 关于VS2010的一些历史遗留问题VS2010毕竟是2010年的产品有些行为和新版VS不太一样。比如默认字符集是Unicode但很多老代码是ANSI的混用的时候要注意L和的区别不支持C11的大部分特性写代码的时候不能用auto、nullptr、std::function这些调试器的表达式求值功能比较弱复杂一点的STL容器看起来很不方便如果条件允许我建议尽量升级到更高版本的VS。但如果项目必须用VS2010那就接受这些限制把精力放在代码质量和架构设计上。9.7 一个真实的排查案例DLL加载成功但函数调用崩溃最后分享一个我实际遇到的案例。有个项目用显示加载调用第三方DLLLoadLibrary成功GetProcAddress也成功但一调用函数就崩溃。排查过程先用dumpbin /exports确认函数名没错然后用dumpbin /headers检查DLL位数发现是32位的和exe一致。接着检查调用约定发现DLL用的是__stdcall但我的typedef里写的是默认的__cdecl。改成__stdcall之后问题解决。这个问题的隐蔽之处在于GetProcAddress不检查调用约定所以能拿到地址。但调用的时候栈不平衡导致崩溃。如果DLL的导出函数恰好没有参数可能不会崩溃只是返回值不对那就更难查了。所以我的建议是显示加载的时候一定要用dumpbin /exports看清楚导出函数的修饰名根据修饰名判断调用约定。_Add8这种形式就是__stdcall_Add或者Add就是__cdecl。不要凭感觉写typedef一定要以实际导出符号为准。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →