1. 先搞清楚:动态库和静态库到底解决了什么问题
做C/C++开发的人,几乎绕不开链接(link)这个过程。你写了一堆源文件,编译器把它们编译成目标文件(.o或.obj),最后需要把这些目标文件组合成可执行程序。这时候就会出现一个选择:是把代码直接“焊死”进可执行文件里,还是单独打成一个“零件包”让程序按需取用?前者就是静态库(Static Library),后者就是动态库(Dynamic Library)。
拿生活里的例子打比方,静态库像你去超市买了一个一体式榨汁机,电机、刀片、杯子全封装在一个壳子里,用起来方便,但坏了得整个换;动态库则像分体式蓝牙音箱,主机和喇叭分开,想升级喇叭就单独换个模块,线接对了就能用。对应到项目里,静态库在链接时把代码复制进可执行文件,动态库则在运行时才被加载。这个差异看似简单,背后却牵扯到可执行文件体积、内存占用、升级维护、第三方依赖等一连串实际问题。
我一开始接触库的时候,总觉得“反正能跑就行”。直到有一次接手一个老项目,所有功能都静态编译进了一个几十MB的二进制文件,每次客户要一个小改动,都得重新发布整个程序,传输慢不说,还容易出版本错乱。后来把公共模块拆成动态库,升级就只需要替换一个.so或.dll,体感完全不一样。这篇文章我会把静态库和动态库各自的制作方法、使用方式、适用场景讲透,再结合Qt工程和onnxruntime动态库集成的真实案例,把我踩过的坑一并倒出来。
2. 静态库的“焊死”哲学:制作与使用全流程
2.1 用ar命令打包静态库:本质是归档
静态库在Linux下本质上就是一个归档文件,通常以.a结尾。你可以用ar命令把一堆.o目标文件打包在一起,同时生成索引方便链接器查找符号。这个过程并不涉及真正的链接,只是做了“打包”。
给你一个最简单的示例。假设你有两个源文件math_utils.c和string_utils.c,声明放在公共头文件utils.h里:
// math_utils.c int add(int a, int b) { return a + b; } // string_utils.c #include <string.h> void copy_str(char *dst, const char *src) { strcpy(dst, src); }先把它们编译成目标文件:
gcc -c math_utils.c -o math_utils.o gcc -c string_utils.c -o string_utils.o然后打包成静态库:
ar rcs libutils.a math_utils.o string_utils.o这里的r代表插入或替换文件,c代表创建库,s代表生成索引。生成索引这步很关键,后续链接时链接器要靠索引快速定位符号,你可以用ranlib命令单独给.a文件生成索引,但ar rcs已经顺带做了。
我见过不少人直接在命令行写ar rc libutils.a,漏掉了s参数。库文件当时用着没事,但换到一些旧的链接器版本上就可能报“could not read symbols”之类的错误,因为索引缺失。所以习惯上我都会写成ar rcs,一步到位。
2.2 链接静态库时的顺序陷阱
静态库的使用方式很直观,就是把.a文件放到链接命令行里:
gcc main.c -L./lib -lutils -o app-L指定库搜索路径,-lutils表示链接libutils.a。注意这里-l后面跟的名字去掉lib前缀和.a后缀。
但静态库的链接顺序是很多人栽跟头的地方。链接器对静态库的处理方式是“扫描式”的:从左到右逐个检查目标文件,只抽取那些能解决当前未解析符号的模块。如果库A引用库B的符号,而命令行先写了-lB再写-lA,链接器在扫描B时发现没有任何目标文件引用B的符号,B就被整个跳过了,等扫描A时才发现缺符号,再回头找B已经来不及。
处理办法有三个:一是调整库的排列顺序,被依赖的库放在后面;二是使用链接组的写法,gcc支持-Wl,--start-group -lA -lB -Wl,--end-group,让链接器反复扫描这些库;三是在CMake等构建系统里用target_link_libraries声明依赖关系,构建工具会自动处理顺序。我自己写Makefile习惯了,顺序就是“用户代码在前,库按依赖层级从上层到下层排列”,比如gcc main.o -lui -lcore -lutils。
2.3 静态库的天然缺陷:体积膨胀与重复代码
静态库最大的优点是可执行文件不依赖外部环境,拷过去就能跑,部署简单。但缺点同样明显:
- 每个可执行文件都拷贝了一份库代码,磁盘和内存浪费严重。比如三个程序都用到libutils.a,代码段各有一份。
- 库里有改动,所有引用它的程序都需要重新链接。我经历过一次底层协议库更新,整整重编了六个服务,每次全量构建半小时起步。
- 如果静态库内部用到了动态库(比如libcurl.a依赖系统的libssl.so),那静态库的“自包含”优势就被打了折扣,部署时还是得带上对应的动态库。
所以在很多公司内部,公共基础库首选动态库,只有对部署极度敏感、目标环境不可控的场景才会考虑静态库。比如嵌入式设备、容器镜像里的单一二进制工具,静态编译能把依赖彻底锁死,这个取舍要心里有数。
3. 动态库的制作与使用:把“零件包”做到极致
3.1 Linux下用-fPIC和-shared生成.so
动态库在Linux下以.so结尾,全称Shared Object。它的核心是“地址无关代码”(Position Independent Code,PIC),也就是编译时生成的代码不依赖固定的加载地址,运行时可以被加载到内存的任何位置。
做一个最简单的动态库:
gcc -fPIC -c math_utils.c -o math_utils.o gcc -fPIC -c string_utils.c -o string_utils.o gcc -shared -o libutils.so math_utils.o string_utils.o-fPIC这个参数很多人一开始不理解为什么不加也能编过,但生成的.so加载到进程里可能出各种诡异崩溃。原因是现代操作系统的进程地址空间有ASLR(地址空间布局随机化),动态库每次加载的位置都不一样,如果代码里写死了绝对地址,一跳转就崩。加了-fPIC之后,代码通过全局偏移表(GOT)和过程链接表(PLT)间接访问函数和数据,加载器在运行时重定位,这才扛得住随机化。
还有一个细节:动态库的链接命令也可以直接写成gcc -shared -fPIC -o libutils.so math_utils.c string_utils.c,省掉中间目标文件的步骤。但项目稍微大一点,我还是习惯分开编译,毕竟重新编译单个文件比重链整个库快得多。
3.2 动态库的命名规则与SONAME机制
Linux动态库的版本管理有一套标准命名,刚接触的人往往被一堆软链接绕晕。典型的形态是这样的:
libutils.so -> libutils.so.1 libutils.so.1 -> libutils.so.1.2.3 libutils.so.1.2.3链接时用的是libutils.so这个不带版本号的软链接,运行时程序实际加载的往往是libutils.so.1(SONAME)。SONAME是嵌入在.so文件里的一个标识符,在链接时通过-Wl,-soname,libutils.so.1指定,它决定了运行时的库接口兼容级别。
举例来说,libutils.so.1.2.3发布后,如果只是修bug,SONAME保持libutils.so.1不变,新版直接覆盖即可,已经编译好的程序不用重新链接;如果接口有破坏性变更,SONAME改成libutils.so.2,新的程序就用so.2,老程序还继续使用so.1,大家互不干扰。这个机制非常优雅,我在实际项目里一开始图省事,直接生成不带头版本号的.so,结果每次升级都得同时给所有调用方重编,深挖SONAME机制之后才明白正确玩法。
3.3 运行时动态加载:dlopen和dlsym的进阶用法
除了链接期依赖动态库,Linux还支持运行时手动加载动态库,用到的是dlopen和dlsym。这种模式常被用在插件系统里,核心程序不关心插件的具体实现,只要插件导出约定的函数签名就能被加载。
#include <dlfcn.h> void *handle = dlopen("./libplugin.so", RTLD_LAZY); if (!handle) { fprintf(stderr, "dlopen error: %s\n", dlerror()); return -1; } typedef int (*plugin_init_t)(void); plugin_init_t init_fn = (plugin_init_t)dlsym(handle, "plugin_init"); if (!init_fn) { fprintf(stderr, "dlsym error: %s\n", dlerror()); dlclose(handle); return -1; } init_fn(); dlclose(handle);编译时要加-ldl(较新glibc里dlopen已并入libc,但兼容起见仍建议显式链接)。
这种方式最大的价值是“热插拔”。我之前维护过一个告警网关,告警渠道有短信、邮件、Webhook,每种渠道都是一个.so插件,新增渠道只需要丢一个新的.so进目录,程序运行中周期性扫描目录即可实现插件加载,完全不用重启服务。缺点也很直接:所有通过dlsym拿到的函数指针都是void*,编译器帮不上忙,参数个数或类型错了只能在运行时炸,所以插件接口的约定要非常严谨,一般定义一个统一的接口结构体。
3.4 Windows下的DLL制作与导出声明
Windows下的动态库叫DLL(Dynamic Link Library),制作方式跟Linux有点差异。最常用的方式是使用__declspec(dllexport)和__declspec(dllimport)关键字,通常配合宏定义:
#ifdef _WIN32 #ifdef MYLIB_EXPORTS #define MYLIB_API __declspec(dllexport) #else #define MYLIB_API __declspec(dllimport) #endif #else #define MYLIB_API #endif MYLIB_API int add(int a, int b);在编译DLL时,通过预处理器定义MYLIB_EXPORTS,这样函数会被导出;在调用方工程里不定义它,头文件里的函数就变成了dllimport声明,告诉编译器这些符号来自外部DLL。这里有个常见坑:Windows下DLL的导出符号默认会经过“名字修饰”(name mangling),C++的函数名会被编成带参数类型信息的形式,如果导出时不加extern "C",用C写的调用方就根本找不到add这个名字。所以跨语言边界导出时,记得统一加extern "C"。
还有一个文件叫导入库(Import Library),后缀通常是.lib。很多新手误以为.lib就是静态库,其实MSVC生成的.lib分两种:静态库直接包含代码,DLL对应的导入库只包含跳转到DLL的桩代码。链接程序时用的是导入库,运行时才真正去找DLL文件。这也解释了为什么有时候链接通过了,但运行时报“找不到xxx.dll”——链接期和运行期的搜索路径不是一回事。
4. Qt工程里编写和调用动态库:QMake与CMake两种路线
4.1 Qt Creator的动态库模板:从Wizard开始
Qt Creator提供了现成的库工程模板。新建项目时选择Library -> C++ Library,会让你选“共享库”(Shared Library)还是“静态链接库”(Static Linked Library),同时会问是否需要Qt模块支持。这个模板已经把导出宏帮你写好了,生成的库头文件通常长这样:
#include <QtCore/qglobal.h> #if defined(MYSHAREDLIB_LIBRARY) # define MYSHAREDLIB_EXPORT Q_DECL_EXPORT #else # define MYSHAREDLIB_EXPORT Q_DECL_IMPORT #endif class MYSHAREDLIB_EXPORT MySharedLib { public: MySharedLib(); int compute(int value); };Q_DECL_EXPORT和Q_DECL_IMPORT本质上就是前面说的dllexport和dllimport的跨平台包装,Qt的qglobal.h已经做好了平台判断,你只要关心业务逻辑。
我建议新手直接用模板生成一个项目跑通整个流程,再抽掉模板里的demo代码替换成自己的类。模板的价值不在于代码量,而在于它把pro文件里的CONFIG += dll、DEFINES += MYSHAREDLIB_LIBRARY这些配置都提前设置好了,你可以照着看到底哪一行配置对应哪个行为。
4.2 CMake方式:add_library与导出宏的配合
如果说QMake是Qt的亲儿子,那么CMake就是跨平台构建的事实标准,目前Qt6也在推CMake方向。CMake里定义一个动态库非常简单:
cmake_minimum_required(VERSION 3.16) project(mysharedlib LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(mysharedlib SHARED mysharedlib.cpp mysharedlib.h ) target_include_directories(mysharedlib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ) target_compile_definitions(mysharedlib PRIVATE MYSHAREDLIB_LIBRARY )这里有个细节,导出宏的编译定义我用PRIVATE而不是PUBLIC,因为MYSHAREDLIB_LIBRARY只有库本身编译时需要,调用方不需要也不应该定义。如果你不加这个定义,库编译时头文件里的导出宏走的是Q_DECL_IMPORT分支,MSVC下会告警“dllimport被用于定义”。
怎么验证生成的动态库符号是否导出正确?Windows下用Visual Studio的dumpbin /exports查看,Linux下用nm -D查看动态符号表:
nm -D libmysharedlib.so如果导出函数的名字没有经过修饰,或者你期望的符号缺失,第一眼就能看出来,不用等运行时报链接错误。
4.3 Qt调用动态库的两种方式:链接期与运行时
在Qt项目里调用自己写的动态库,同样分两条路。
第一条是链接期依赖,在QMake的pro文件里这么写:
LIBS += -L$$PWD/../build-lib -lmysharedlib INCLUDEPATH += $$PWD/../mysharedlib在CMake里则用target_link_libraries:
add_executable(myapp main.cpp) target_link_libraries(myapp PRIVATE mysharedlib)链接期依赖的好处是类型安全,头文件里的类和方法编译时就能检查。缺点是程序启动时就必须能找到动态库,库不存在就起不来。
第二条是运行时加载,Qt提供了QLibrary类,专门用来加载动态库并解析符号:
#include <QLibrary> #include <QDebug> typedef int (*compute_fn_t)(int); QLibrary lib("./libmysharedlib.so"); if (!lib.load()) { qWarning() << "load failed:" << lib.errorString(); return -1; } auto compute = (compute_fn_t)lib.resolve("compute"); if (!compute) { qWarning() << "resolve failed:" << lib.errorString(); return -1; } int result = compute(42); lib.unload();这个方案天然支持插件架构,程序启动时不依赖任何具体库,运行过程中按需加载。我通常会把QLibrary封装成一个“插件加载器”,用一个接口类统一不同插件的函数指针集合。
不管用哪种方式,发布Qt程序时都得把依赖的Qt动态库一起带过去。Windows上常见做法是用windeployqt自动搜集需要的Qt DLL,Linux下则可以用ldd查看依赖链,手动拷贝或者用打包工具。
5. 集成onnxruntime动态库的实战心得
5.1 为什么用onnxruntime动态库而不是静态库
现在AI推理落地很常见。onnxruntime是微软开源的跨平台推理引擎,它既提供静态库也提供动态库。我实际做项目时,基本都选用动态库版本,原因很现实:
- onnxruntime体积大,Release版单个库动辄几十MB,静态链接会让每个使用方程序都膨胀一圈;
- 推理引擎跟CUDA、TensorRT、DNNL等底层加速库耦合度很高,这些加速库本身就以动态库形式存在,静态链接反而容易造成底层符号冲突;
- 不同业务方可能用不同版本的onnxruntime,动态库各带各的,互不影响。
onnxruntime官方发布包里可以找到libonnxruntime.so或onnxruntime.dll(Windows下是onnxruntime.dll),直接拿来链接即可。如果你想自己编译,官方脚本支持--build_shared_lib选项,打开后会额外生成动态库版本。
5.2 链接onnxruntime动态库的配置实战
在CMake里集成onnxruntime动态库,关键是头文件和库路径都要指对。假设把onnxruntime解压到third_party/onnxruntime目录:
set(ONNXRUNTIME_ROOT ${CMAKE_SOURCE_DIR}/third_party/onnxruntime) # 头文件 target_include_directories(myapp PRIVATE ${ONNXRUNTIME_ROOT}/include ) # 库文件(以Linux为例) target_link_directories(myapp PRIVATE ${ONNXRUNTIME_ROOT}/lib ) target_link_libraries(myapp PRIVATE onnxruntime)这段代码里有一个易错点:target_link_directories是在CMake 3.13才引入的,如果你还在用老版本CMake,链接器的搜索路径可能拿不到,需要改成find_library先找到库路径再传。另一个易错点,如果你下载的是带版本号的库文件如libonnxruntime.so.1.16.0,且没有对应的软链接libonnxruntime.so,链接器会直接报找不到库,这时候需要手动创建软链接。
onnxruntime导航C++ API的使用本身不复杂,大体流程是构造Ort::Env,创建SessionOptions,再加载模型文件得到Ort::Session,最后用Run方法做推理。整个过程里最影响成功率的是SessionOptions的配置,比如启用CPU并行时设置线程数、开启或关闭内存优化模式。这些配置项直接决定模型能不能发挥硬件性能。
5.3 动态库加载失败的典型场景:LD_LIBRARY_PATH与rpath
Linux下运行程序时反复遇到的一个报错就是:
error while loading shared libraries: libonnxruntime.so: cannot open shared object file: No such file or directory这个报错几乎成了动态库集成路上的“第一道门槛”。原因无非是程序运行时的动态库搜索路径里没有包含onnxruntime的lib目录。Linux下动态库搜索顺序大致是:ELF文件里记录的rpath或runpath、LD_LIBRARY_PATH环境变量、系统默认路径(/lib、/usr/lib等)。
临时解决办法是:
export LD_LIBRARY_PATH=/path/to/onnxruntime/lib:$LD_LIBRARY_PATH ./myapp但依赖环境变量不是长久之计,用户不会每次开终端都先export一遍。更稳妥的做法是在链接时写入rpath。在CMake里可以这样写:
set_target_properties(myapp PROPERTIES BUILD_RPATH "$ORIGIN/../lib" INSTALL_RPATH "$ORIGIN/../lib" )$ORIGIN是一个特殊的动态库路径关键字,表示当前可执行文件所在目录。这样无论程序被安装到哪个前缀目录,它都会自动到同级的lib目录下去找运行时库,不需要用户手工配置任何环境变量。Windows下类似机制是“同目录查找”,默认DLL优先在exe所在目录找,所以发布时把onnxruntime.dll跟exe放在一起通常最省事。
5.4 符号冲突:两个动态库之间的“暗战”
onnxruntime依赖了不少底层基础库,比如protobuf、absl、Eigen等。如果你的程序里同时链接了一个也用了protobuf的静态库,很可能出现两个protobuf符号定义共存的情况。Linux动态链接默认采用全局符号介入(symbol interposition)机制,运行时可能会把调用解析到错误的那一份实现上,轻则行为诡异,重则直接崩溃。
我之前遇到过一个问题:程序加载了onnxruntime动态库,又链接了一个内部自研的算法静态库,算法库内部也打包了旧版protobuf。运行时不定期出现“pure virtual method called”的崩溃,排查了很久才发现是protobuf版本冲突,onnxruntime里的新符号被旧版实现抢先介入了。
要避免这类问题,一是尽量别在同一个进程里混用不同版本的公共库;二是控制符号导出范围,Linux下可以用版本脚本或-fvisibility=hidden,C++里用__attribute__((visibility("default")))显式导出所需符号。onnxruntime官方对符号可见性做了不少隔离工作,我们自己写动态库时也应该有意识地把内部符号藏起来,只暴露API层。Windows下DLL的符号默认不外泄,反而很少踩这个坑,Linux的全局可见性默认策略坑了不少人。
6. 常见问题速查表与排查思路
6.1 六类高频问题与解法
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 链接时报undefined reference | 库顺序不对、库路径没指定、函数符号未导出 | 调整-l顺序;加-L指定路径;nm检查导出符号 |
| Windows下编译DLL后C程序找不到函数 | C++名字修饰导致导出符号带修饰名 | 导出函数加extern "C";用dumpbin /exports查看 |
| 运行时报cannot open shared object文件 | rpath未设置、LD_LIBRARY_PATH没配置、软链接缺失 | 设置rpath=$ORIGIN;创建.so的未版本号软链接 |
| 程序启动时提示DLL缺失 | 导入库链接成功但运行目录无DLL | 把DLL拷贝到exe同目录或用延迟加载 |
| 加载onnxruntime后崩溃或结果异常 | protobuf、absl等符号冲突 | 隐藏动态库内部符号;统一依赖版本;分开进程 |
| 同一动态库被重复加载,全局状态各持一份 | 静态成员变量、单例会在每个动态库实例里各自存在 | 明确全局状态归属;用显式加载并统一入口;避免跨库传递单例 |
排查动态库问题我最常用的三板斧就是:ldd查看依赖是否完整,nm -D/objdump -p查看导出符号,strace或gstrace跟踪打开文件的路径。这三个工具解决掉绝大多数“找不到”“链接不上”类问题。
6.2 排查一个真实案例:Qt程序调onnxruntime诡异崩溃
最后分享一个我自己印象深刻的排查过程。Qt程序里通过QLibrary加载onnxruntime动态库做图像分类,第一次推理正常,第二次必然崩溃,报错在Qt的QImage相关代码里。
一开始怀疑是内存管理问题,反复检查代码也没发现问题。后来用gdb调试,bt命令查看调用栈,发现onnxruntime内部线程在退出时触发了某个全局单例的清理,而这个单例与Qt的插件系统有间接关系。真正的根因是myplugin.dll内部也嵌入了一份Qt的运行时,和主程序的Qt动态库符号冲突,两个Qt实例抢同一套全局状态。
解决方法是把插件里的Qt依赖全部改为主程序导出,插件编译时不再链接Qt动态库,而是直接从主进程解析。这也再次印证了一个动态库设计的核心原则:一个进程内,强依赖库最好只有一份实例。做插件系统时尤其要严格约束插件不能把Qt、protobuf这类重量级库再重新打包一份。
6.3 按场景选库类型的决策参考
| 场景 | 推荐选择 | 理由 |
|---|---|---|
| 嵌入式环境、单一交付物 | 静态库 | 部署最简,不依赖系统环境 |
| 频繁迭代的公共基础模块 | 动态库 | 升级替换.so/.dll即可 |
| 插件系统 | 动态库+显式加载 | 支持热插拔,核心框架稳定 |
| CUDA/OpenCV等重量级第三方 | 动态库 | 依赖链复杂,各取所需 |
| 中小企业内部多个服务共用代码 | 动态库 | 节省内存,统一维护 |
| 安全审查严格的交付 | 静态库或容器镜像 | 依赖锁死,减少供应链风险 |
这个表不是绝对标准,但要记住一个判断原则:静态库换的是部署确定性和代码隐私性,代价是体积和升级成本;动态库换的是灵活性和共享效率,代价是运行依赖管理和符号隔离的复杂度。两者没有谁绝对优于谁,项目所处阶段、团队维护能力、目标运行环境才是真正的决定因素。
7. 关于库设计的一些个人习惯和建议
做了这么多年库的封装和维护,有几条经验几乎每个项目都能用上,也当是给看到这里的读者一些参考。
第一,头文件即契约。一个库的对外接口一定要小而清晰,能用纯函数就不要上类,能少暴露一个类型就少暴露一个。我见过很多团队把内部结构体直接写进公共头文件,导致一改内部实现,所有调用方全要重编。正确姿势是公开头文件只放API声明和不透明指针,实现在cpp内部藏好。
第二,版本管理从第一天就开始。不管是.so的SONAME还是Windows DLL的版本资源,都要在项目初始化时定好规则。等到客户已经在生产环境用上了再补这套机制,迁移成本会高很多。
第三,动态库的回调函数和跨模块内存管理要特别小心。让调用方传入一个函数指针或std::function,库内部保存并在异步线程里调用,这种情况要明确规定线程模型和对象生命周期。跨模块分配和释放内存更是重灾区,一个模块new出来的对象不能交给另一个模块delete,除非两边用的确实是同一个运行时堆。所以我通常建议动态库接口用值类型或库自己负责内存分配与释放,并提供对应的销毁函数。
第四,构建产物里务必保留带符号信息的版本。尤其是动态库,线上出问题需要gdb或Visual Studio调试时,没有符号文件,调用栈里全是地址,排查效率会大打折扣。Linux下的.so本身可以带调试符号,发布时用strip去掉,同时留一份未strip的副本;Windows下把.pdb文件完整归档,这些细节平时用不上,一旦出问题能救命。
动静态库的制作和使用并不是一个“背命令”就能掌握的知识,它背后是链接模型、运行时加载机制、平台ABI这些底层的设计理念。做几个小项目亲手编一编、跑一跑,比看多少篇文档都管用。尤其是第一次遇到运行时缺库报错、第一次排查符号冲突,这些看起来折磨人的经历,反而是对链接原理理解最深的时候。希望这篇内容能帮你少走几步弯路,也希望你在踩坑之后能有自己的心得沉淀下来。