C++跨平台(十):动态库与插件系统

📅 2026/7/1 9:39:16
C++跨平台(十):动态库与插件系统
动态库跨平台开发的必争之地动态库Dynamic Library是现代软件工程的基石之一。它实现了代码共享多个程序共用同一份库文件减少磁盘和内存占用、模块化部署更新库文件不需要重新编译整个应用、以及插件架构运行时按需加载功能模块。然而动态库在三大平台上的实现机制差异巨大——从文件格式到加载API、从符号可见性到搜索路径、从版本管理到ABI兼容性每一项都可能成为跨平台项目的难题。文件格式与命名约定不同平台使用完全不同的动态库文件格式平台文件格式扩展名典型命名WindowsPEPortable Executable.dllmylib.dllLinuxELFExecutable and Linkable Format.solibmylib.so或libmylib.so.1macOSMach-O.dyliblibmylib.dylibLinux的命名约定最复杂libmylib.so是编译时链接名sonamelibmylib.so.1是运行时链接名libmylib.so.1.2.3是带有完整版本号的实际文件。多个版本可以共存ldconfig管理符号链接。macOS使用install_name机制可以在编译时将库的绝对路径或rpath相对路径嵌入到可执行文件中。Windows最简单——.dll文件名就是唯一的标识。符号导出与导入Windows采取显式导出策略默认情况下符号是隐藏的必须用__declspec(dllexport)显式标记需要导出的函数和类。使用该库的可执行文件则需要用__declspec(dllimport)声明导入——这不是必须的但能让编译器生成更高效的代码避免一次间接跳转。Linux在GCC下采取默认导出策略所有符号都对外可见除非用-fvisibilityhidden隐藏。现代CMake项目推荐设置CMAKE_CXX_VISIBILITY_PRESEThidden然后用__attribute__((visibility(default)))显式导出需要公开的符号——这模拟了Windows的思维模式并减小了.so文件的符号表大小加快了加载速度。macOS的行为接近Linux默认导出但Clang支持与GCC相同的visibility属性。跨平台项目的标准做法是定义一个宏// export.hpp#pragmaonce#ifdefined(_WIN32)||defined(__CYGWIN__)#ifdefMYLIB_BUILDING#defineMYLIB_API__declspec(dllexport)#else#defineMYLIB_API__declspec(dllimport)#endif#else#if__GNUC__4||defined(__clang__)#defineMYLIB_API__attribute__((visibility(default)))#else#defineMYLIB_API#endif#endif// 使用classMYLIB_APICalculator{public:intadd(inta,intb);intmultiply(inta,intb);};关键点是MYLIB_BUILDING构建库时定义此宏在CMake中用target_compile_definitions它激活dllexport使用库时不定义此宏它激活dllimport。在Linux/macOS上此宏总是扩展为visibility属性。运行时加载除了编译时链接外动态库的核心价值在于运行时加载——可以在程序运行期间按需加载库并调用其中的函数。这是插件系统的基础。加载API// 跨平台运行时加载#ifdef_WIN32#includewindows.husingLibHandleHMODULE;#defineLOAD_LIBRARY(path)LoadLibraryW(path)#defineGET_SYMBOL(handle,name)GetProcAddress(handle,name)#defineUNLOAD_LIBRARY(handle)FreeLibrary(handle)inlineconstchar*get_load_error(){staticcharbuf[256];FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM,nullptr,GetLastError(),0,buf,sizeof(buf),nullptr);returnbuf;}#else#includedlfcn.husingLibHandlevoid*;#defineLOAD_LIBRARY(path)dlopen(path,RTLD_NOW|RTLD_LOCAL)#defineGET_SYMBOL(handle,name)dlsym(handle,name)#defineUNLOAD_LIBRARY(handle)dlclose(handle)inlineconstchar*get_load_error(){returndlerror();}#endifdlopen的flags需要关注RTLD_NOW在加载时立即解析所有符号类似Windows的行为RTLD_LAZY则延迟到首次使用时。RTLD_LOCAL使加载的符号不暴露给后续加载的库防止符号冲突RTLD_GLOBAL则相反——后者是实现插件间相互调用的必要选择。安全的跨平台封装#includememory#includestring#includestdexceptclassDynamicLibrary{public:explicitDynamicLibrary(conststd::stringpath){handle_LOAD_LIBRARY(path.c_str());if(!handle_){throwstd::runtime_error(Failed to load library: path — get_load_error());}}~DynamicLibrary(){if(handle_){UNLOAD_LIBRARY(handle_);}}// 不可复制DynamicLibrary(constDynamicLibrary)delete;DynamicLibraryoperator(constDynamicLibrary)delete;// 可移动DynamicLibrary(DynamicLibraryother)noexcept:handle_(other.handle_){other.handle_nullptr;}templatetypenameFuncTypeFuncType*get_function(conststd::stringname){autosymGET_SYMBOL(handle_,name.c_str());if(!sym){throwstd::runtime_error(Failed to find symbol: name);}returnreinterpret_castFuncType*(sym);}private:LibHandle handle_nullptr;};插件系统设计模式插件系统是动态库的主要应用场景。一个可扩展的应用程序定义一套C接口纯虚类插件实现这些接口并以动态库的形式发布。应用程序在运行时扫描插件目录加载每一个动态库从中获取工厂函数来创建实现了特定接口的对象。基于纯虚接口的插件架构// plugin_interface.hpp — 平台无关的插件接口#pragmaonce#includestringclassIPlugin{public:virtual~IPlugin()default;// 每个插件必须实现的方法virtualstd::stringname()const0;virtualstd::stringversion()const0;virtualboolinitialize()0;virtualvoidshutdown()0;};// 每个插件必须导出这些C链接函数// 使用 extern C 避免C名称修饰导致的符号名不一致externC{usingCreatePluginFuncIPlugin*(*)();usingDestroyPluginFuncvoid(*)(IPlugin*);}// my_plugin.cpp — 一个具体的插件实现#includeplugin_interface.hpp#includeiostreamclassMyPlugin:publicIPlugin{public:std::stringname()constoverride{returnMyPlugin;}std::stringversion()constoverride{return1.0.0;}boolinitialize()override{std::coutMyPlugin initializedstd::endl;returntrue;}voidshutdown()override{std::coutMyPlugin shutdownstd::endl;}};// 导出工厂函数C链接防止名称修饰externC{MYLIB_API IPlugin*create_plugin(){returnnewMyPlugin();}MYLIB_APIvoiddestroy_plugin(IPlugin*plugin){deleteplugin;}}// extern C// plugin_manager.cpp — 插件管理器#includeplugin_interface.hpp#includeDynamicLibrary.hpp// 前面的跨平台封装#includefilesystem#includevector#includememoryclassPluginManager{public:voidload_plugins(conststd::filesystem::pathplugin_dir){namespacefsstd::filesystem;for(constautoentry:fs::directory_iterator(plugin_dir)){if(!entry.is_regular_file())continue;autoextentry.path().extension().string();// 根据平台检查正确的扩展名#ifdef_WIN32if(ext!.dll)continue;#elifdefined(__APPLE__)if(ext!.dylib)continue;#elseif(ext!.so)continue;#endiftry{autolibstd::make_uniqueDynamicLibrary(entry.path().string());autocreate_fnlib-get_functionCreatePluginFunc(create_plugin);autodestroy_fnlib-get_functionDestroyPluginFunc(destroy_plugin);auto*plugincreate_fn();if(pluginplugin-initialize()){plugins_.push_back({std::move(lib),plugin,destroy_fn});}}catch(conststd::exceptione){// 记录错误继续加载其他插件log_error(Failed to load plugin: entry.path().string() — e.what());}}}voidshutdown_all(){for(autoentry:plugins_){entry.plugin-shutdown();entry.destroy(entry.plugin);}plugins_.clear();}private:structPluginEntry{std::unique_ptrDynamicLibrarylibrary;IPlugin*plugin;DestroyPluginFunc destroy;};std::vectorPluginEntryplugins_;};extern C的重要性C支持函数重载因此编译器会对函数名进行名称修饰Name Mangling——create_plugin()可能被修饰为_Z13create_pluginvGCC/Clang或?create_pluginYAPEAXZMSVC。不同编译器甚至同一编译器的不同版本之间的名称修饰方案不同。extern C阻止名称修饰使函数名在二进制中保持为原始的create_plugin。这是跨编译器甚至同编译器不同版本插件二进制兼容的基础。代价是extern C函数不能重载、不能是成员函数、不能是模板函数。但需要注意extern C能保证符号名一致但无法保证ABI兼容性。不同编译器GCC vs MSVC编译的C对象布局可能不同std::string的内部表示也可能不同。因此对于跨编译器的插件系统需要进一步限制接口——要么所有插件用同一编译器同一版本编译要么接口只使用纯C类型如const char*代替std::string裸指针代替引用。这点在Windows的COMComponent Object Model中可以看到最彻底的示范。IUnknown接口只使用纯虚函数和C兼容类型配合引用计数管理生命周期。这使得用Visual Basic写的组件能被C调用用Delphi写的组件能被.NET调用——在C的ABI边界上COM提供了一套亘古的稳定契约。RPATH与运行库搜索路径编译时成功链接动态库只是第一步运行时操作系统必须能找到实际的库文件。各平台的搜索路径规则截然不同Windows按照以下顺序搜索.dll可执行文件所在目录当前工作目录C:\Windows\System32\系统目录C:\Windows\System\PATH环境变量中的目录Linux按照以下规则搜索可执行文件中嵌入的RPATH编译时通过-rpath或CMake的INSTALL_RPATH设置LD_LIBRARY_PATH环境变量/etc/ld.so.conf中配置的目录 /etc/ld.so.conf.d/默认系统路径/lib、/usr/lib等macOS类似Linux但更复杂库的install_name中指定的路径可以是rpath、loader_path、executable_path等占位符DYLD_LIBRARY_PATH环境变量DYLD_FALLBACK_LIBRARY_PATH默认路径跨平台CMake项目通常这样设置RPATH# 让可执行文件在同目录或 ../lib 中查找 .so/.dylib set(CMAKE_INSTALL_RPATH $ORIGIN:$ORIGIN/../lib) # Linux set(CMAKE_INSTALL_RPATH loader_path:loader_path/../lib) # macOS # Windows 不需要特殊设置默认搜索exe同目录$ORIGINLinux和loader_pathmacOS是指向可执行文件所在目录的占位符让应用做到可重定位——打包目录可以放在任意位置而不需要安装到系统路径。