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

资讯详情

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

VS下C程序静态库LIB与动态库DLL生成方法详解

VS下C程序静态库LIB与动态库DLL生成方法详解 做Windows开发的人早晚会撞上这么一件事手里有一堆写好的C函数独立编译、反复自测都没问题结果同事来一句这个功能怎么集成到主程序里——这时候你就得认真想想怎么把手里的.c文件变成能被别人一起编译的静态库LIB或者能在运行时再加载的动态库DLL。这个话题在Visual StudioVS环境下尤其绕不开因为VS的工程配置、生成流程和GCC那套完全是两个世界。这篇文章我围绕VS下生成C程序静态库LIB及动态库DLL的方法这个主题把整个流程、背后的原理、常见的坑一次说透。适合刚接触VS工程管理的新手也适合写了几年C代码但没怎么碰过库封装的人参考。1. 先搞清楚LIB和DLL到底是干什么的1.1 静态库LIB的本质编译期的“代码复制”静态库说白了就是把一堆目标文件.obj打包成一个.lib文件。你在链接的时候链接器会把LIB中你用到的那个目标模块真正“复制”进你的可执行程序里你的exe因此不依赖外部的lib文件就能独立运行。这里有个很多人误解的点LIB不是“库头文件”而是编译后的二进制代码集合。你写了一个math_utils.c里面有几个数学计算函数用VS编译出来一个math_utils.obj再用lib.exe把这些.obj捆在一起就成了math_utils.lib。后面别人拿到这个LIB和对应的头文件就可以直接调用里面的函数。因为代码是复制进最终程序的所以一旦编译完成这个LIB就“功成身退”了程序运行时完全不需要它。这也意味着静态库有几个很实在的劣势一是多个程序用同一个静态库每个exe里都会有一份代码磁盘和内存都有冗余二是库的代码更新后所有用到它的程序都要重新链接发布时得重新出一个exe。它的优势同样明显部署简单没有DLL缺失的烦恼程序启动时也不存在动态加载的额外开销。1.2 动态库DLL的本质运行时的“按需加载”动态库的机制和静态库完全不同。DLL里也放着一堆编译好的函数、资源和数据但这个二进制模块不会在链接期被复制进exe而是由Windows系统在程序启动时或者程序运行过程中显式加载时把它们映射到进程的地址空间里来使用。链接器在处理一个依赖DLL的程序时会把必需的信息记录在exe的导入表Import Table里比如“这个程序依赖mymath.dll需要调用里面的math_add函数”。程序启动时Windows的加载器会找到mymath.dll把它加载进来再把导入表中记录的每个函数地址填到程序里的调用位置。这个过程叫“动态链接”。这种机制带来的直接好处是多个程序可以共享内存里的同一份DLL代码更新库时不需要重新编译所有调用方只要保持接口不变替换DLL文件就能完成升级。坏处也明显一旦系统中找不到这个DLL、或者DLL的位数、依赖版本不匹配程序就会报错最常见的错误就是“找不到xxx.dll”或者“无法定位程序输入点”。1.3 到底选LIB还是DLL别再凭感觉拍板选型不是越高级越好完全看应用场景。我见过不少项目明明一个独立的小工具非要硬拆成三四个DLL结果部署时DLL互相依赖、版本对不上升级一次鸡飞狗跳。反过来有些系统级组件需要支持插件机制却把代码全写进静态库导致每加一个插件就得重新编译整个主程序。从实际经验看这几条选型规律比较靠谱如果代码模块只给一个exe用且后续不太可能拆出独立更新的优先用静态库省事、稳定、好调试。如果模块需要被多个exe共享或者需要独立升级、热插拔比如游戏模组、工具插件做DLL更合适。如果模块涉及C和C混合调用或者将来可能被其他语言Python、C#等通过外部接口调用那DLL几乎是唯一的合理选择。如果项目本身交付给客户的是“主程序若干组件DLL”的形态那从一开始就按DLL规划最稳。2. 用VS创建库项目的完整步骤2.1 创建静态库项目非常顺几乎不用改配置打开Visual Studio我这边演示用的是VS2022旧版本2019/2017流程几乎一样通过菜单“文件 → 新建 → 项目”打开新建项目对话框在搜索框里输入“静态库”就能看到“静态库项目”模板。如果你用的是C语言项目也可以先建一个“控制台应用”再改项目属性但直接用静态库模板最干净。项目名称我给的是MyStaticLib创建完成之后VS会生成一个空项目里面没有代码文件。接下来的操作在解决方案资源管理器里右键“源文件”目录 → 添加 → 新建项选“C文件(.cpp)”但注意文件名的扩展名要手动改成.c。如果你不改扩展名编译器会按C语法编译。对纯C代码来说虽然大多数情况下VS的C编译器也能处理但保留.c后缀能避免很多莫名其妙的问题。写几个演示函数。比如我写一个简单的加法函数和乘法函数// my_static_math.c int math_add(int a, int b) { return a b; } int math_mul(int a, int b) { return a * b; }点击菜单“生成 → 生成解决方案”。如果没有意外你会在输出窗口看到“已成功生成”然后在项目目录下的Debug或Release文件夹里找到MyStaticLib.lib。到这里一个静态库就出来了。整个过程没有需要特别修改的配置项因为静态库项目的编译目标就是lib.exeVS已经替你配置好了。唯一要注意的是如果项目里有多个.c文件VS会全部参与编译并打包进LIB所以不要在静态库项目里放一些不需要对外发布的测试代码。2.2 创建DLL项目模板是“动态链接库(DLL)”DLL项目建起来也不复杂。新建项目时搜索“动态链接库”选中“动态链接库(DLL)”模板项目名我用MyDll。同样源文件里新建.c文件并添加需要导出的函数。但这里有个关键区别既然是DLL你必须在函数声明上告诉编译器“这些函数是要导出到DLL外部让别人使用的”。在Windows平台上最常用的方式是用__declspec(dllexport)修饰符。我一般会配合一个宏来写这样同一个头文件既能用于DLL内部编译又能用于外部调用后面细说。先看简单的写法// my_dll_math.c #include stdio.h __declspec(dllexport) int dll_add(int a, int b) { return a b; } __declspec(dllexport) void dll_print_sum(int a, int b) { printf(sum %d\n, a b); }这样编译后VS会生成两个关键文件MyDll.dll运行时期需要的动态库和MyDll.lib链接期需要的导入库。注意这个.lib并不是真正的静态库它里面不包含函数实现代码只记录了DLL的导出符号和跳转信息方便外部程序在链接时找到对应的DLL函数名。很多新手容易把DLL项目生成的.lib和静态库项目生成的.lib混为一谈这两个东西是完全不同的。如果某个函数不想被外部调用那就不加__declspec(dllexport)它就是DLL内部的私有函数。按我的经验开发DLL时最好明确区分“导出接口”和“内部实现”否则把一个内部辅助函数不小心导出了后续维护接口时会非常被动。2.3 同一个头文件兼容LIB和DLL的写法实际工程里你的库代码很可能既要支撑静态库场景也要支撑动态库场景或者你希望自己写的库能被C程序调用。这时像我前面说的最好把导出声明做成宏。看下面这个头文件// math_lib.h #ifndef MATH_LIB_H #define MATH_LIB_H #ifdef __cplusplus extern C { #endif #ifdef MATHLIB_EXPORTS #define MATHLIB_API __declspec(dllexport) #else #define MATHLIB_API __declspec(dllimport) #endif MATHLIB_API int math_add(int a, int b); MATHLIB_API int math_mul(int a, int b); #ifdef __cplusplus } #endif #endif解释一下这里的几个细节MATHLIB_EXPORTS这个宏通常是DLL项目的预处理器配置里自动定义的。VS创建的DLL项目模板会在“项目属性 → C/C → 预处理器 → 预处理器定义”中自动加上MyDll_EXPORTS不同版本名字会带项目名前缀。你在自己的头文件里可以把这个宏设为MATHLIB_EXPORTS或者干脆在项目属性里改。只要编译器看到这个宏就会把MATHLIB_API展开成__declspec(dllexport)函数就从DLL里导出外部程序包含这个头文件时没有定义这个宏就会展开成__declspec(dllimport)告诉链接器“这些函数来自外部的DLL”。extern C的作用是告诉C编译器这些函数按C的命名规则处理。C语言里函数名和符号名基本一致但C有函数重载编译器会对函数名进行“名字改编”name mangling生成一串很奇怪的符号。如果DLL是给C语言程序用的或者要跨语言调用就必须避免C风格的名字改编所以用extern C包裹起来。如果你不需要考虑C调用extern C可以省略但写上它属于“有百利而无一害”。我建议哪怕你天天写C也顺手加上保不齐哪天你的头文件就被C工程引用了。3. 编译、生成与链接的实操细节3.1 四个必查配置Debug/Release、x86/x64、运行库、字符集很多问题的根源不在代码而在配置。生成库和调用库的工程之间配置不匹配会引发一堆诡异错误。我总结出四个最关键的维度配置类型Debug和Release。这里指的不是“调试信息有没有”这么简单而是库本身用了什么运行库、优化选项、是否定义_DEBUG宏。外部程序链接一个Debug版本的LIB通常也应该是Debug版本否则很容易出现堆内存分配释放混乱、assert失效等问题。平台位数x86/x64/ARM64。32位的LIB只能在32位程序里链接64位程序必须链64位的库这是硬性的。DLL也同样位数不匹配在“加载DLL”阶段就会直接失败。运行库这是最容易被忽视的坑。VS里“运行库”选项有/MT多线程静态运行时、/MD多线程动态运行时以及带Debug标志的/MTd、/MDd。如果库是用/MDd编译的调用方却用/MTd链接链接阶段就会报LNK2038错误RuntimeLibrary不匹配或者编译通过但运行时崩溃。一个工程内部所有参与编译的模块最好统一这个选项。字符集项目属性里“字符集”一般默认是“使用Unicode字符集”也就是定义_UNICODE和UNICODE宏。如果你的库用了char*和宽字符API混用或者外部程序用char字符串传参最好在头文件里明确函数参数的类型避免因字符集不同导致行为不一致。以我自己的开发习惯会先在“属性管理器”里给Debug|x64、Release|x64等每个配置统一设置好运行库和预处理器再把库工程和调用工程放到同一个解决方案里。这样整体配置同步起来少很多事。3.2 静态库的产物长什么样怎么用生成一个静态库后在输出目录里你会看到MyStaticLib.lib这就是全部产物还会有一个.pdb调试数据库用于提供调试信息。发布给别人的时候需要的文件是.lib文件和对应的头文件比如my_static_math.h。头文件里不需要__declspec(dllexport)直接声明函数原型就行。外部程序使用静态库时有两种配置方式方式A在项目属性里配置。右键调用项目 → 属性 → “C/C → 常规 → 附加包含目录”把头文件所在目录加进去再到“链接器 → 常规 → 附加库目录”添加LIB所在目录最后在“链接器 → 输入 → 附加依赖项”里写入MyStaticLib.lib。这种方式适合正式工程配置所有设置都固化在项目文件里。方式B在代码里用#pragma comment(lib, MyStaticLib.lib)。把这个指令写在.c文件开头或头文件里链接器会在编译时自动链入该LIB。这种方式对于快速测试很方便但不推荐在正式项目里大面积使用因为库路径、位数等环境差异会导致它不够灵活。补充一个重点静态库里的函数被链接进exe后exe运行时不需要带MyStaticLib.lib文件。所以交付的时候给到开发者的东西是“头文件 .lib”给到最终用户的东西就只有exe非常干净。3.3 DLL的产物长什么样怎么用一个DLL项目编译成功后输出目录里通常能看到MyDll.dll程序运行时要加载的动态库本体。MyDll.lib导入库只包含导出符号的索引信息。MyDll.h或者你自定义的头文件声明导出接口。MyDll.pdb调试符号发布Release版时一般不给客户。外部程序使用这个DLL的方法按环节区分链接期编译自己的程序时需要MyDll.lib和头文件。配置方式跟静态库几乎一模一样附加包含目录指向头文件附加库目录指向MyDll.lib所在目录附加依赖项填MyDll.lib。注意这里链接器不是把函数代码复制进exe而是解析导入表中的符号信息。运行期用户运行exe时需要MyDll.dll在正确的搜索路径中。Windows的DLL搜索顺序大致是exe所在目录 → 系统目录System32→ Windows目录 → 当前目录 → PATH环境变量中的目录。所以最简单的做法是把MyDll.dll复制到exe旁边。正式部署时建议建立好目录结构把exe和DLL集中管理而不是依赖设置PATH。如果你的DLL没有提供导入库MyDll.lib比如某个DLL不是你自己编译的只有一份.dll和头文件说明外部程序也可以不依赖导入库改用运行时显式加载。后面我会专门讲这两种方式。3.4 隐式链接与显式加载LoadLibrary方式的区别使用DLL有两种完全不同的加载策略隐式链接Load-Time Dynamic Linking就是上面说的编译时用导入库程序启动时由Windows自动加载DLL。这种方式代码最简单直接调用导出函数名就跟调用普通函数一样。缺点是程序启动时如果找不到DLL进程直接无法启动Windows会弹一个“由于找不到MyDll.dll无法继续执行代码”的报错。显式加载Run-Time Dynamic Linking程序运行到需要某功能时再调用LoadLibrary或者LoadLibraryEx加载DLL用GetProcAddress获取函数地址再通过函数指针调用。这种方式的关键优势是DLL不存在时程序可以先提示错误而不至于闪退而且可以在运行中决定加载不同版本的DLL非常灵活。代价是代码变繁琐所有导出函数要一个个用函数指针取地址。举个显式加载的例子#include windows.h #include stdio.h typedef int (*MathAddFunc)(int, int); int main() { HMODULE hMod LoadLibraryW(LMyDll.dll); if (!hMod) { printf(加载DLL失败错误码: %lu\n, GetLastError()); return 1; } MathAddFunc math_add (MathAddFunc)GetProcAddress(hMod, math_add); if (math_add) { printf(math_add(3, 5) %d\n, math_add(3, 5)); } else { printf(找不到函数, 错误码: %lu\n, GetLastError()); } FreeLibrary(hMod); return 0; }需要注意如果使用显式加载那你就不需要那个.lib导入库文件了这也是很多DLL只提供.h和.dll给客户、不提供导入库时的通用做法。但你的DLL导出函数名必须和GetProcAddress里写的一致若你在C编译器下导出了函数却没加extern C函数名会被改编GetProcAddress大概率找不到符号。这也是我前文一直强调extern C的原因。4. 常见问题与排查技巧实录4.1 LNK2019无法解析的外部符号这是链接器报的经典错误几乎每个做Windows库开发的人都遇到过。错误信息长这样error LNK2019: 无法解析的外部符号 _math_add该符号在函数 _main 中被引用出现这个错误通常有以下几种原因忘了链接库调用方没有在“附加依赖项”里写MyStaticLib.lib或MyDll.lib。检查方式是在命令行加/VERBOSE:LIB看看链接器到底搜了哪些库不过这信息量太大日常用不到一般直接看属性配置就行。函数名不匹配库里的导出函数名和声明不一致。C语言函数如果改成C编译符号名会被改编比如math_add可能变成?math_addYAHHHZ。这时调用方的声明与库的导出符号对不上链接器自然找不到。解决办法就是在头文件里加extern C。调用约定不一致__cdecl、__stdcall、__fastcall都会影响符号名Windows上__stdcall会在函数名前加下划线、后面附加参数字节数比如_math_add8。如果库的内外声明不一致符号也会对不上。我在做32位程序的时候遇到过很多次64位下调用约定对符号名影响较小但还是要统一。LIB文件没传给链接器如果代码里没有#pragma comment(lib, ...)属性里的附加依赖项也没有写那链接器压根不知道要去找这个LIB。排查LNK2019最高效的思路是先用dumpbin /exports xxx.dll看DLL导出了哪些符号再去调用方确认声明、调用约定、库路径是否一致。4.2 LNK1104无法打开文件xxx.lib这个错误一般是链接器找不到具体的库文件。常见的坑路径配置不对附加库目录没写或者写的是Debug目录实际库在Release目录。如果你在项目属性里看到“附加库目录 $(OutDir)”而$(OutDir)解析出来根本不存在也会报这个错。建议不要手动拼绝对路径多用VS的宏比如$(SolutionDir)、$(OutDir)。文件还没生成先编译库工程再去链接调用方。一个解决方案里有多个项目时记得确认编译顺序右键解决方案 → “配置依赖项”把调用工程设为依赖库工程这样VS会自动先编译库。文件被占用另一个VS进程正在调试且加载了该DLL、或者杀毒软件在扫描该LIB都会导致无法打开。这时先把VS全部关掉再试或者直接删除该LIB由编译器重新生成。路径中含中文或特殊字符某些编译器、链接器对非英文目录处理不友好建议工程路径一律用英文。4.3 运行时提示“找不到DLL”或“无法定位程序输入点”这种情况多见于隐式链接。程序编译都通过了一运行就报“由于找不到MyDll.dll无法继续执行代码”。重点排查DLL没有部署到exe目录最简单的验证方法是把DLL复制到exe同目录再运行。DLL依赖的其他DLL缺失你编译的DLL可能依赖VS的运行库比如vcruntime140.dll、msvcp140.dll或者其他第三方DLL。检查方式是下载一个“Dependency Walker”之类的工具或者直接用VS自带的dumpbin /dependents看看DLL的依赖列表。如果缺少VC运行库可以在目标机器上安装对应的“Visual C Redistributable”或者把DLL项目的运行库改为/MT静态链接运行时这样DLL内部自带VC运行库不再依赖外部组件。这一点在给客户交付DLL时特别重要。版本不匹配如果DLL导出函数的参数结构体大小改变了或者导出了在头文件里没定义的新函数加载时会报“无法定位程序输入点xxx于动态链接库xxx.dll上”。这类问题只能从接口版本管理上解决改接口时务必同时更新DLL和所有调用方或者使用版本号机制防止误用。位数不对在64位程序里加载32位DLLLoadLibrary会返回ERROR_BAD_EXE_FORMAT错误码193。关于网上的“DLL修复工具”我的建议是谨慎使用。它们大多是把常用的VC运行库、DirectX组件重新装上能解决一部分环境问题但对自有业务DLL的缺失和接口不匹配基本无能为力。真正稳定的做法是部署前把DLL放到exe目录测试机器的环境VC运行库、系统版本和客户环境尽量保持一致。4.4 我的排查工具箱dumpbin是个好东西VS自带的dumpbin.exe是排查库问题的利器。打开“开发者命令提示符Visual Studio Developer Command Prompt”可以直接用这些命令:: 查看DLL导出了哪些函数 dumpbin /exports MyDll.dll :: 查看exe依赖哪些DLL dumpbin /dependents MyApp.exe :: 查看LIB中有哪些目标文件和公共符号 dumpbin /symbols MyStaticLib.lib :: 查看导入库信息 dumpbin /headers MyDll.lib用dumpbin /exports查看你的DLL导出符号你就能立刻确认导出名是不是符合预期。如果里面有类似?math_addYAHHHZ这种名字说明C名字改编生效了你需要加上extern C重新编译。再看一个很实用的小窍门编译时把”链接器 → 命令行“里加上/VERBOSE链接器会把搜索库的每一个路径、找到没找到哪些符号都打印出来。这个信息虽然冗长但遇到顽固的“符号冲突”“选错了库文件”问题它往往能直接告诉你答案。用完之后记得把/VERBOSE去掉否则每次编译输出窗口都是海量信息。4.5 别忘了运行库和CLR/UCRT的问题有几次我的DLL在开发机跑得好好的部署到一台较旧的Windows机器上就报“找不到VCRUNTIME140.dll”。问题在于我的DLL工程用的是/MD它依赖系统的vcruntime140.dll和ucrtbase.dll。Windows 10 1703之后系统自带UCRTUniversal C Runtime但较旧的系统就需要手动装vc_redist.x64.exe。如果你的DLL对系统环境有要求解决思路有几个项目属性里把“运行库”从/MD改成/MT让VC运行时静态链接到你的DLL中或者部署的时候连同VC运行库安装包一起交给对方或者明确要求对方先装某个更新。我个人更倾向于做插件型DLL时用/MT因为插件经常被塞进各种复杂环境的主程序里外部依赖越少越稳。不过/MT会让DLL体积明显增大而且如果主程序和插件都用了静态运行时堆边界问题需要特别注意不要在主程序里malloc、在插件里用别的C运行时free。5. 一套从零到交付的完整实操回顾我把这一整轮“生成LIB/DLL”的流程用一个完整的场景串起来方便你照着操作。假设我要做一个简单的calc计算模块既发布静态库也发布DLL。建工程新建一个静态库项目CalcLib源文件加calc.c头文件加calc.h。再新建一个DLL项目CalcDll源文件也加calc.c头文件用同一个calc.h通过宏来区分导出和导入。两个工程可以放在同一个解决方案里。写公共头文件// calc.h #ifndef CALC_H #define CALC_H #ifdef __cplusplus extern C { #endif #ifdef CALC_EXPORTS #define CALC_API __declspec(dllexport) #else #define CALC_API __declspec(dllimport) #endif CALC_API int calc_add(int a, int b); CALC_API int calc_sub(int a, int b); #ifdef __cplusplus } #endif #endif写实现文件// calc.c #include calc.h CALC_API int calc_add(int a, int b) { return a b; } CALC_API int calc_sub(int a, int b) { return a - b; }配置DLL工程右键CalcDll项目 → 属性 → C/C → 预处理器在“预处理器定义”中添加CALC_EXPORTS如果模板没有自动加的话。然后分别编译两个工程。查看生成结果CalcLib\Debug\CalcLib.libCalcDll\Debug\CalcDll.dll和CalcDll\Debug\CalcDll.lib。写一个调用程序测试新建一个控制台应用CalcConsumer在项目的“附加包含目录”指向calc.h所在目录在“附加库目录”分别指向两个库的输出目录在“附加依赖项”中按需填写CalcLib.lib或CalcDll.lib二选一或者都链。然后#include stdio.h #include calc.h int main() { printf(add %d\n, calc_add(20, 22)); printf(sub %d\n, calc_sub(30, 12)); return 0; }编译运行。如果一切正常你会看到控制台输出add 42、sub 18。如果链接报错按上一章节的排查思路来。这整个流程里我最建议你亲自试一次的地方是“同一个头文件既能编DLL又能被外部程序引用”。很多教程把dllexport和dllimport混在一起讲自己不动手写一遍很难真正理解宏前后切换的效果。你可以在calc.c里故意不写CALC_API把导出修饰符去掉然后重新生成DLL再用dumpbin /exports看看导出表——你会发现函数还在但符号名可能已经变了C编译时变化不大C编译时变化极其明显。这个实验做完你对extern C、导出标记这些概念的理解会扎实很多。6. 想少踩坑记住这几条就够了文章写到这里实用内容差不多讲完了。回看我这些年做库封装的实际经历想再分享几条最核心的体会。第一接口文件头文件是库的“门面”设计头文件比设计实现更花心思。一个库如果要给别人用头文件里放哪些函数、每个函数的参数类型和返回值、是否跨语言调用在写第一版代码前就要想清楚。接口一旦发布出去后续改动的成本是指数级上升的C语言的接口兼容性比想象中脆弱参数顺序换一下、结构体字段增删一下可能就会让所有调用方崩溃。第二生成库之后的“自测”不能省。不要等外部程序链接时再去发现问题。我习惯在库工程里保留一个小的测试入口比如通过#ifdef包起来只在这个工程单独编译时启用直接调用库里的导出函数和内部函数做一轮冒烟测试。这样能保证库本身的逻辑是对的后面调用方出问题就专心查链接、配置不会两头猜。第三命名和版本管理要做在前面。DLL文件、LIB文件、头文件的名字尽量稳定不要今天mylib.dll明天my_lib.dll后天mylib2.dll同一套接口在同一个产品里反复改文件名的维护成本极高。DLL如果持续迭代建议用版本号文件名比如calc_v1.dll、calc_v2.dll或者在接口设计中加入版本查询函数避免把调用方绑死在一个具体文件名上。最后再提醒一句网上流行的“万能DLL修复工具”大多数时候只是把VC运行库装一遍治标不治本。真正做项目的人应该从源头理解LIB和DLL的生成、链接、加载机制遇到报错能自己定位、自己解决。这篇文章覆盖了VS下生成C程序静态库LIB及动态库DLL的完整路径如果你能照着把这套流程完整走一遍我相信以后再做库相关的开发心里会稳很多。
返回列表