资讯详情

资讯详情

动态库与静态库全解析:从原理到Qt与ONNX Runtime集成实践

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 CodePIC也就是编译时生成的代码不依赖固定的加载地址运行时可以被加载到内存的任何位置。做一个最简单的动态库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.1SONAME。SONAME是嵌入在.so文件里的一个标识符在链接时通过-Wl,-soname,libutils.so.1指定它决定了运行时的库接口兼容级别。举例来说libutils.so.1.2.3发布后如果只是修bugSONAME保持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下的动态库叫DLLDynamic 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 manglingC的函数名会被编成带参数类型信息的形式如果导出时不加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_librariesadd_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 DLLLinux下则可以用ldd查看依赖链手动拷贝或者用打包工具。5. 集成onnxruntime动态库的实战心得5.1 为什么用onnxruntime动态库而不是静态库现在AI推理落地很常见。onnxruntime是微软开源的跨平台推理引擎它既提供静态库也提供动态库。我实际做项目时基本都选用动态库版本原因很现实onnxruntime体积大Release版单个库动辄几十MB静态链接会让每个使用方程序都膨胀一圈推理引擎跟CUDA、TensorRT、DNNL等底层加速库耦合度很高这些加速库本身就以动态库形式存在静态链接反而容易造成底层符号冲突不同业务方可能用不同版本的onnxruntime动态库各带各的互不影响。onnxruntime官方发布包里可以找到libonnxruntime.so或onnxruntime.dllWindows下是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与rpathLinux下运行程序时反复遇到的一个报错就是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下可以用版本脚本或-fvisibilityhiddenC里用__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这些底层的设计理念。做几个小项目亲手编一编、跑一跑比看多少篇文档都管用。尤其是第一次遇到运行时缺库报错、第一次排查符号冲突这些看起来折磨人的经历反而是对链接原理理解最深的时候。希望这篇内容能帮你少走几步弯路也希望你在踩坑之后能有自己的心得沉淀下来。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →