在VS2019里折腾静态库和动态库这件事,我本来以为自己已经很熟了,直到前段时间接了一个集成第三方C++库的活,链接器突然冒出一堆LNK2019,才老老实实把整个流程重新捋了一遍。后来发现,不管你是用VS2019做上位机、写Qt程序、接ONNX Runtime,还是给UG二次开发配环境,核心套路都差不多:把一堆.lib、.dll、.h文件按VS的脾气塞到正确的位置,让它能看见、能链接、能运行。这篇文章就是把我实际操作中总结下来的用法、踩过的坑、排查思路全部整理出来,作为一份可以直接照着操作的笔记。适合刚学会写一个main程序的入门者,也适合已经接触过库、但一直被各种链接错误折磨的同学。
1. 先把“静态库”和“动态库”的真实关系说清楚
1.1 “.lib”不一定代表静态库,这点最容易搞混
很多新手看到.lib文件就以为是静态库,其实不是,VS生成的动态链接库里也会带一个.lib文件,我们叫它“导入库(Import Library)”。这两个东西虽然都叫.lib,内部结构和使用方式完全不一样。
静态库的本质是一个“目标文件(.obj)的压缩包”。你在项目里写的那一堆.cpp文件经过编译后,会生成一个个.obj文件,链接器把可能用到的.obj挑出来,打包成一个.lib。最终做exe时,链接器从.lib里找到需要的.obj内容,直接复制到你的exe程序里。也就是说,静态库的代码是“物理拷贝”进目标程序的,之后你把这个.lib删了,exe照样能运行。
而动态库的.lib导入库里面没有真正的函数实现,只有“这个函数在哪个dll里、叫什么名字、参数列表长什么样”这类符号信息。真正干活的是.dll文件,里面才有函数对应的机器码。当exe被加载时,Windows系统会根据导入库记录的符号信息,去加载对应的dll并在内存中把函数地址和你的调用点对接上。这个dll文件和exe是分开的,exe运行时必须有它,否则直接报“找不到xxx.dll”。
理解了这一点,后面配置属性就不会再懵了。你说“我想用这个动态库”,其实有两件不同的事要做:链接期要让VS找到.lib(导入库),运行期要让Windows找到.dll,这俩文件缺一不可。
1.2 什么时候选静态库,什么时候选动态库
选型没有绝对标准,但我的经验是看“给谁用”和“怎么发布”。
如果库是你自己项目内部用,而且生成的是一个单独的可执行程序,优先用静态库。静态链接的程序发布时只需要拷贝exe一个文件,不担心目标机器上缺dll的问题。缺点是exe体积会膨胀,而且如果有多个exe共用同一份静态库,每个exe里都有一份代码副本,内存占用会大一些。
如果是给第三方提供SDK,或者想把公共模块拆出来给多个团队动态更新,用动态库更合适。动态库可以在不重新编译主程序的情况下单独替换dll(前提是你没有改动接口),升级bug和加功能都灵活。缺点是部署的时候漏一个dll程序就起不来,Windows上还得处理各种“运行库版本不一样”的问题,后面我会细说。
还有一个比较实际的选择标准:看你和别人约定的编译环境。用MSVC编译器生成的静态库,换一个编译器(比如MinGW)去链接通常很痛苦,因为C++的符号命名规则、标准库ABI都不一致。动态库虽然同样有ABI问题,但如果用extern "C"导出纯C接口,被各种语言和工具链调用的兼容性就会好很多。
1.3 VS2019环境准备:别只装了编辑器就开始
既然是VS2019的使用,环境本身先谈几句。很多人从网上下载VS2019离线安装包,装完发现新建项目里找不到C++相关模板,大概率是安装时没勾工作负载。要用静态库、动态库这些能力,至少需要在“使用C++的桌面开发”这个工作负载里把“适用于最新v142生成工具的C++生成工具”和“Windows 10 SDK”选上,否则编译器和Windows头文件都不存在。
另外建议在“单个组件”里勾一下“C++ MFC”或者“C++ ATL”——不是所有人都用得上,但万一后续接的第三方库依赖这些框架,缺了它编译时能给你整出几百行“无法打开包括文件: afxwin.h”的错误。VS2019的安装确实麻烦,装好了后面会省心很多。缺组件重装属于浪费时间,我第一次离线安装时没注意,后来为了一个MFC项目硬是又花了几个小时补环境。
2. 动手做一个静态库并在VS2019里调用
2.1 新建静态库项目的正确姿势
在VS2019里创建静态库,我建议的路径是:新建项目,搜索“静态库”,选“静态库项目”模板;如果没有单独这个模板,也可以选“Windows桌面向导”,在向导第二页的“应用程序类型”里勾选“静态库”。
这里有一个比较隐蔽的坑:如果你用的模板生成了一个pch.h和pch.cpp,说明这个项目默认启用了预编译头。预编译头会导致你每次写代码都要记得#include "pch.h",而且生成第三方静态库时,这个pch依赖还挺烦的。如果只是做一个小型库,我建议在项目属性 → C/C++ → 预编译头 中把“预编译头”改成“不使用预编译头”,再顺手把pch相关文件删掉。等以后你真正维护大型项目再启用它也不迟。
静态库项目本身不要写main函数。它编译出来不是可执行程序,而是一个.lib文件。有些新手在一个库工程里写了个int main(),然后连接器报“main已经定义”或者“无法解析外部符号main”,原因就在这里。库的代码就是一组函数、类的“零件仓库”,谁来链接谁负责main。
2.2 静态库源码、生成与产出物
我创建一个叫MathTools的静态库工程,放了两个很简单的函数:
// MathTools.h #pragma once namespace MathTools { int Add(int a, int b); int Multiply(int a, int b); }// MathTools.cpp #include "MathTools.h" namespace MathTools { int Add(int a, int b) { return a + b; } int Multiply(int a, int b) { return a * b; } }编译之后,到工程的输出目录,一般是x64/Debug或x64/Release,能看到一个MathTools.lib文件。整个静态库的产出物就它一个,头文件MathTools.h是给别人看接口用的,不算必须要交付的文件,但不带头文件的话别人根本不知道这个库里有啥函数。
这里顺便说一下配置问题。我强烈建议你一开始就确定好是生成Debug还是Release版,并把它跟调用方工程保持一致。用Debug版本的.lib去链接Release的exe,编译通常能过,但运行时时不时会冒出内存错误、堆损坏之类的诡异问题,因为这些库内部链接的是调试版C运行库,和发布版的布局不一样。排查起来特别让人头大。
2.3 调用静态库的三种方法和头文件组织
首先得把库的头文件给调用工程看到。最简单的一种做法:把MathTools.h和MathTools.lib拷到调用工程目录下,然后在源代码里用#include "../MathTools/MathTools.h"这种相对路径。我个人不太推荐,一旦目录结构变化,所有include路径全得改,维护成本高。
正规做法是通过项目属性配置目录,整个过程分三步:
第一步,C/C++ → 常规 → 附加包含目录,填MathTools.h所在的文件夹。 第二步,链接器 → 常规 → 附加库目录,填MathTools.lib所在的文件夹。 第三步,链接器 → 输入 → 附加依赖项,填MathTools.lib(写完整文件名)。
也有人用#pragma comment(lib, "MathTools.lib")来代替第三步。这个指示符的本质是告诉链接器“帮我去这个文件名对应的库里找符号”,省得你去属性页点那一堆窗口。我平时自己写demo时会用,因为快。但要注意一点:如果同一个库Debug版和Release版名字不同,比如MathTools_d.lib和MathTools.lib,你把名字写死在#pragma comment里会有点麻烦,这时候还是老老实实用属性页配置,用宏来做区分。
代码里的调用方式和平常写本地函数没有区别,引用了头文件之后直接用:
#include <iostream> #include "MathTools.h" int main() { int sum = MathTools::Add(10, 20); int product = MathTools::Multiply(3, 4); std::cout << "sum = " << sum << std::endl; std::cout << "product = " << product << std::endl; return 0; }如果你是在同一个解决方案里同时打开“MathTools”和“调用方”两个工程,还有更省事的方法:右键调用方工程 → 添加 → 引用 → 勾选MathTools项目。VS会自动帮你处理头文件路径和lib依赖。这种方式在调试时特别方便,你改了静态库源码,按F5生成时会自动先编译库再编译主程序。
3. 动态库从生成到调用完整手记
3.1 写一个带导出接口的动态库项目
动态库的创建其实和静态库类似,但关键多了一个“导出”。当别人调用你的dll时,系统得知道哪些函数是公开放出去的,这就要用到__declspec(dllexport)这个关键字。
通常一个库工程内部定义导出宏,像这样:
// StrTools.h #pragma once #ifdef STRTOOLS_EXPORTS #define STRTOOLS_API __declspec(dllexport) #else #define STRTOOLS_API __declspec(dllimport) #endif #include <string> class STRTOOLS_API StrTools { public: static std::string ToUpper(const std::string& input); static std::string ToLower(const std::string& input); };注意这个STRTOOLS_EXPORTS宏。VS2019创建动态链接库项目时,会自动为项目定义一个<项目名>_EXPORTS的预处理器宏。在这个dll工程内部编译时,STRTOOLS_EXPORTS是存在的,所以StrTools.h在编译自身源码看到的是dllexport,会把类的所有符号导出到dll中;而当外部工程包含这个头文件时,STRTOOLS_EXPORTS不存在,于是StrTools.h自动变成dllimport,告诉编译器“这些符号来自外部的dll”。
.cpp实现:
// StrTools.cpp #include "StrTools.h" #include <algorithm> #include <cctype> std::string StrTools::ToUpper(const std::string& input) { std::string result = input; std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return static_cast<char>(std::toupper(c)); }); return result; } std::string StrTools::ToLower(const std::string& input) { std::string result = input; std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); }); return result; }编译之后,去输出目录看看,里面有三个重要文件:StrTools.dll、StrTools.lib、StrTools.exp。其中.dll是运行时真正需要的,.lib是链接期需要的导入库,.exp是增量链接用的导出文件,一般不需要亲自部署。
3.2 隐式链接:三步配好include、lib、dll
调用动态库有两种方式,第一种叫“隐式链接”,也叫“静态加载”。它和调用静态库的配置很相似,工程属性里一样要填三个地方:附加包含目录填.h文件目录;附加库目录填.lib文件目录;附加依赖项填StrTools.lib。头文件和代码中直接调用:
#include <iostream> #include "StrTools.h" int main() { std::string s = "Hello, VS2019!"; std::cout << StrTools::ToUpper(s) << std::endl; return 0; }但仅仅这样还不够,编译能过,一运行会报“找不到StrTools.dll”。因为链接器从导入库里知道函数在哪个dll里,但系统加载exe时要在exe所在目录、系统PATH环境变量、或Windows系统目录里找这个dll。通常做法是把dll文件复制到exe的输出目录,也就是和exe同目录。如果嫌每次手动拷贝麻烦,可以在项目属性 → 生成事件 → 后期生成事件命令行里写一条拷贝命令:
copy /Y "$(SolutionDir)x64\$(Configuration)\StrTools.dll" "$(OutDir)"这样每次编译完自动把dll送到exe身边。之后F5运行就顺利了。
3.3 显式加载:插件式调用,跑通LoadLibrary
第二种调用方式叫“显式链接”,也叫“动态加载”。这种方式根本不用管.lib导入库,而是程序运行到某个时刻,自己调用Windows API 把dll加载进来,然后用函数指针找到函数地址再调用。相当于把“找函数”这个动作从程序启动延迟到了运行期间。
代码长这样:
#include <windows.h> #include <iostream> typedef std::string (*ToUpperFunc)(const std::string&); int main() { HMODULE hModule = LoadLibraryA("StrTools.dll"); if (hModule == NULL) { std::cerr << "LoadLibrary failed, code: " << GetLastError() << std::endl; return 1; } auto pFunc = reinterpret_cast<ToUpperFunc>(GetProcAddress(hModule, "ToUpper")); if (pFunc == NULL) { std::cerr << "GetProcAddress failed" << std::endl; FreeLibrary(hModule); return 1; } std::string result = pFunc("hello"); std::cout << result << std::endl; FreeLibrary(hModule); return 0; }这里有个大坑:上面的示例代码有一个很微妙的错误。C++类和成员函数在编译器眼里,符号名会被“改编(name mangling)”成乱七八糟的名字,GetProcAddress(hModule, "ToUpper")根本找不到导出符号。所以在设计动态库接口时,如果要支持显式加载和跨语言调用,我强烈建议用extern "C"导出一组普通函数作为C接口,然后库内部再用C++实现:
extern "C" { __declspec(dllexport) const char* ToUpperString(const char* input); __declspec(dllexport) const char* ToLowerString(const char* input); }这里还需要一个额外的函数申请缓存,用完后释放,否则调用者不知道释放谁的内存。其实动态库跨模块内存管理本身就是一大坑,最好的做法是导出“创建/销毁句柄”这样的成对函数。示例的话我可以再开一篇文章细讲,今天先点到为止。
用显式加载的好处是:程序启动时不检查dll是否存在,可以在运行时根据配置动态切换实现,适合插件架构。缺点是代码繁琐,每一次函数调用都要GetProcAddress,维护成本高。所以实际项目里,绝大多数情况用隐式链接就够了。
3.4 动态库运行库(/MT、/MD)与部署需要重点注意
新手经常忽略一个属性:项目属性 → C/C++ → 代码生成 → 运行库。常见值有/MT、/MTd、/MD、/Md(属性面板显示为“多线程(/MT)”和“多线程DLL(/MD)”几项)。
静态库通常用/MT,也就是把C/C++运行库静态链接,不依赖“VC++运行库.dll”;动态库则常用/MD,因为dll自身和exe都可以共享系统安装的“VC++运行库.dll”。问题来了,如果你做出来的动态库用/MT,调用方exe却用/MD,两者使用的堆分配可能不是同一个,跨dll边界new/delete、malloc/free的时候会出问题。所以同项目里发布的所有二进制和它依赖的第三方库,运行库设置尽量保持一致。
部署动态库还有个现实问题:调试版和发布版dll不能混着给别人。一个用Debug模式编译的dll很可能依赖VCRUNTIME140D.dll,这个调试版运行库平时开发机上有,目标机器上通常没有,不装VS的普通用户机器一跑就崩。所以给外部发包,必须用Release版,并且在配置里把运行库设为“多线程DLL(/MD)”,同时把VS2019对应版本的VC++运行库安装包(vcredist_x64.exe)一并给到对方。
4. 编译链接期最坑的报错与排查清单
4.1 常见错误速查表
下面是平时使用静态库和动态库时遇到概率最高的错误信息,我这里按“错误现象 → 可能原因 → 解决办法”整理成表。
| 错误现象 | 常见原因 | 解决办法 |
|---|---|---|
| LNK2019: 无法解析的外部符号“xxx” | 函数只找到声明,没找到实现;或lib没链接进来 | 确认附加依赖项已添加库名;别再纠结#pragma comment是否生效 |
| LNK2001: 无法解析的外部符号“main” | 库项目里出现了main,或控制台入口设置不对 | 静态库项目不要写main;检查链接器 → 系统 → 子系统 |
| LNK1104: 无法打开文件“xxx.lib” | 链接器找不到指定库文件 | 检查附加库目录是否正确,文件名是否大小写一致 |
| 运行时报0xC000007B | x86/x64架构不匹配 | 确保exe和dll都是同一平台目标,不要x64的exe加载x86的dll |
| 运行时找不到xxx.dll | dll部署了没 | 把dll放到exe同目录、PATH目录或系统目录 |
| C4996: ‘strcpy’ was declared deprecated | 用了旧的C函数,不是库本身问题 | 用安全版本,或在预处理加 _CRT_SECURE_NO_WARNINGS |
4.2 为什么“无法解析的外部符号”总是反反复复
链接错误里,最常见的就是“无法解析的外部符号”。我排查的时候一般按顺序问自己三个问题:第一,这个函数到底在哪个库里面?第二,我有没有告诉链接器去这个库里面找?第三,库的编译方式和我调用方匹配吗?
经验里“是否告诉链接器”这一项问题最多。有时候你已经加了附加依赖项,但还是报错,那多半是“附加库目录”和“附加依赖项”写错位了。有人把lib的完整路径直接写进“附加库目录”,然后附加依赖项却啥都不写,这当然找不到。另外,如果同一个函数在多个lib里都有定义,链接器还会给你报LNK2005重复定义。“附加依赖项”里的顺序也有讲究,较早列出的lib中如果有未解析符号,后面的lib可以补,反向就不行了。
对于C++库,还有一类特别讨厌的符号解析问题:调用方写#include "MathTools.h",但头文件里的函数没有放在extern "C"里,而库实际上是C语言写的(或者反过来)。C++编译器会生成修饰过的符号名,跟.lib里导出的符号名对不上,于是报“无法解析的外部符号”。遇到这种情况,检查头文件里是否缺少:
#ifdef __cplusplus extern "C" { #endif // 函数声明... #ifdef __cplusplus } #endif4.3 VS2019中文注释报错到底是怎么回事
之前有段时间我同事一直哀嚎“VS2019中的.cpp文件加入中文注释就报错”,比如在文件顶部写了句// 初始化参数,编译时就冒出error C2059: 语法错误:“...”之类的。其实问题不在中文字符本身,而是文件编码。
VS2019默认情况下,源码文件如果既包含中文,又保存成“无BOM的UTF-8”格式,MSVC编译器会自作主张按系统本地代码页(中文系统上是GBK/936)去解析这个UTF-8文件。某些汉字的UTF-8字节序列恰好撞上GBK里的引号、反斜杠等特殊字符,编译器就误会了,随即报出迷惑性很强的语法错误。
解决办法有几种:
第一,用VS2019菜单里的“文件 → 另存为”,点击“保存”按钮旁边的下拉箭头,选择“高级保存选项”,再把编码改成“Unicode (UTF-8 带签名) - 代码页 65001”,也就是给文件加BOM(字节序标记)。加BOM后MSVC能自动识别文件确实是UTF-8,中文注释就稳妥了。
第二,如果你更愿意用无BOM的UTF-8,可以在项目预处理或C/C++命令行里加一个编译选项/utf-8,它的意思是“源文件是UTF-8,执行字符集也设成UTF-8”,直接绕开本地代码页猜测。我个人推荐在项目属性 → C/C++ → 命令行 → 其他选项 中统一加上/utf-8,整个项目不用再管单个文件的保存格式。
4.4 排查库问题的一个核心顺序
只要你的程序牵扯到外部库,我的排查习惯永远是这种顺序:先确认生成成功 → 再确认链接成功 → 最后确认运行成功。编译报错优先瞪屏幕,去翻头文件路径。链接报错优先看附加依赖项和附加库目录。运行报错才轮到dll路径和运行库版本这些事。很多人一开始就在怀疑运行库、系统PATH,结果最后发现纯粹是“附加依赖项里忘了填lib文件名”,太浪费时间。
实际上我还遇到过更离谱的:方案平台是“x86”,但生成输出目录设置的路径是x64/Debug,VS把lib生成到了x64文件夹,调用工程却去x86目录找,怎么配都找不到。最后我把输出目录改成$(SolutionDir)$(Platform)\$(Configuration)\,问题一下就消失了。输出目录这种底层设置不要乱改,有时候一次愚蠢的改动会让你怀疑人生。
5. 几个高频场景串讲:Qt、ONNX Runtime、UG、STM32附近的库用法
5.1 在Windows Qt工程里用pro文件指定静态库
现在很多人喜欢用Qt + VS2019组合开发。在Qt这边,除了能直接从VS菜单跑Qt程序,也有不少朋友是用.qmake的.pro工程文件维护依赖的。如果要在.pro文件链接一个静态库,比如放在项目根目录libs文件夹下的MyStaticLib.lib,头文件放在include文件夹,通常这样写:
INCLUDEPATH += $$PWD/include win32 { CONFIG(debug, debug|release) { LIBS += -L$$PWD/libs/debug -lMyStaticLib } else { LIBS += -L$$PWD/libs/release -lMyStaticLib } }注意qmake中-l后面一般不带“lib”前缀也不带“.lib”后缀,比如文件名是MyStaticLib.lib,写-lMyStaticLib就行。-L后面是库目录的绝对路径。它和VS属性页里的“附加库目录+附加依赖项”是同一个逻辑,你理解了VS里那套,Qt这边只是换个语法。
如果你动态库的dll也是放在自己的目录里,Qt程序运行时默认不会从$$PWD/libs去找dll。Windows下还是老办法:要么把dll复制到exe输出目录;要么在起始处用QCoreApplication::addLibraryPath或者在系统环境变量PATH里把dll所在目录加进去。很多人Qt程序双击没反应,就是少了这一步。
5.2 ONNX Runtime动态库接入的通用打法
经常看我文章的朋友可能知道,我在博客里分享过ONNX Runtime相关实践。最近问得也多,因为ONNX Runtime官方发行版里就是典型的动态库形式:解压后是include目录、lib目录、bin目录,里面分别是头文件、导入库(比如onnxruntime.lib)和动态库本体(onnxruntime.dll)。你要做的就是把它们接到VS2019中去,原理和前面完全一致。
我的操作是:解压到C:\libs\onnxruntime,然后在VS工程属性中,把C:\libs\onnxruntime\include填入“附加包含目录”,把C:\libs\onnxruntime\lib填入“附加库目录”,在“附加依赖项”里加上onnxruntime.lib;代码里#include "onnxruntime_cxx_api.h"后正常调用。到最后,把onnxruntime.dll从bin目录拷到exe目录。这样跑起来九成没有问题。官方文档里还会特别提醒配置/MD,因为ONNX Runtime的动态库是绑定VS运行库的,这也呼应我前面说的运行库一致性问题。
5.3 UG二次开发和STM32静态库思路是相通的
热搜词里还有“UG二次开发环境配置 vs2019”和“stm32制作静态库”。UG(现在叫NX)的二次开发环境配置,本质上就是给VS指定一堆NX提供的.lib、.dll、.h文件路径。NX安装目录里通常有UGOPEN文件夹,里面含头文件;UGII文件夹里是动态库。你用VS2019建一个C++工程,把对应.lib和.h路径配置好,程序里调用NX Open C API,链接之后运行时需要把一些dll路径加进PATH或者拷贝到可访问目录。说到底还是VS引用第三方库那套基本功,没跑偏。
STM32那边有点不一样:STM32单片机工程常用的是ARM编译器,生成的静态库后缀是.a或者.lib,在Keil、IAR或STM32CubeIDE里操作,而不是用VS2019的MSVC编译。但核心理念依然成立:把你自己写的驱动、中间件源码编译成库,交付时只给头文件和库文件,保护源码不被直接看到。所以你在VS2019里学会的静态库知识,迁移到嵌入式工程里只需要换一套IDE界面和编译器命令行参数,不会白学。
5.4 一个小习惯:所有第三方库统一放进一个目录管理
最后分享一个我个人的工程目录习惯。我一般在项目根目录建一个third_party文件夹,下面按库名和版本建子目录:
third_party/ onnxruntime/ include/ lib/ bin/ nxsdk/ include/ lib/ bin/ spdlog/ include/ lib/VS属性页里的附加目录都指向这套固定的相对路径,比如用$(SolutionDir)third_party\onnxruntime\include,这样整个工程换电脑、拷贝给同事时,不需要改绝对路径。因为别人拿到源码后,把third_party目录一放,编译路径就全通了。很多新手会在自己的电脑上把库放到C盘乱建目录,然后路径写成C:\Users\me\Downloads\...,一旦换个环境或者用网盘同步,整条路径直接失效,排查又特别痛苦。
如果有条件,我建议每个第三方库附带一个说明文件,写下从哪个官网版本下载的、用的编译选项是/MD还是/MT、修复过哪些坑。三个月之后你再回到这个项目,会发现这些东西比写文档还管用。
我在实际项目中把这套“头文件目录、库目录、附加依赖项、dll部署”的统一流程跑顺之后,再接到任何需要集成C/C++库的活都变得很机械。静态库和动态库本身不复杂,难点在于理解不同文件在哪里生效,以及知道编译期、链接期、运行期三个阶段的报错分别该往哪里查。希望这篇整理能把你在VS2019里的库依赖问题一次解决。