乐于分享
好东西不私藏

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

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

动态库:跨平台开发的必争之地

动态库(Dynamic Library)是现代软件工程的基石之一。它实现了代码共享(多个程序共用同一份库文件,减少磁盘和内存占用)、模块化部署(更新库文件不需要重新编译整个应用)、以及插件架构(运行时按需加载功能模块)。然而动态库在三大平台上的实现机制差异巨大——从文件格式到加载API、从符号可见性到搜索路径、从版本管理到ABI兼容性,每一项都可能成为跨平台项目的难题。

文件格式与命名约定

不同平台使用完全不同的动态库文件格式:

平台
文件格式
扩展名
典型命名
Windows
PE(Portable Executable)
.dllmylib.dll
Linux
ELF(Executable and Linkable Format)
.solibmylib.so
 或 libmylib.so.1
macOS
Mach-O
.dyliblibmylib.dylib

Linux的命名约定最复杂:libmylib.so是编译时链接名(soname),libmylib.so.1是运行时链接名,libmylib.so.1.2.3是带有完整版本号的实际文件。多个版本可以共存,ldconfig管理符号链接。macOS使用install_name机制,可以在编译时将库的绝对路径或@rpath相对路径嵌入到可执行文件中。Windows最简单——.dll文件名就是唯一的标识。

符号导出与导入

Windows采取"显式导出"策略:默认情况下符号是隐藏的,必须用__declspec(dllexport)显式标记需要导出的函数和类。使用该库的可执行文件则需要用__declspec(dllimport)声明导入——这不是必须的,但能让编译器生成更高效的代码(避免一次间接跳转)。

Linux在GCC下采取"默认导出"策略:所有符号都对外可见,除非用-fvisibility=hidden隐藏。现代CMake项目推荐设置CMAKE_CXX_VISIBILITY_PRESET=hidden,然后用__attribute__((visibility("default")))显式导出需要公开的符号——这模拟了Windows的思维模式,并减小了.so文件的符号表大小,加快了加载速度。

macOS的行为接近Linux(默认导出),但Clang支持与GCC相同的visibility属性。

跨平台项目的标准做法是定义一个宏:

// export.hpp#pragma once#if defined(_WIN32) || defined(__CYGWIN__)    #ifdef MYLIB_BUILDING        #define MYLIB_API __declspec(dllexport)    #else        #define MYLIB_API __declspec(dllimport)    #endif#else    #if __GNUC__ >= 4 || defined(__clang__)        #define MYLIB_API __attribute__((visibility("default")))    #else        #define MYLIB_API    #endif#endif// 使用class MYLIB_API Calculator {public:    intadd(int a, int b);    intmultiply(int a, int b);};

关键点是MYLIB_BUILDING:构建库时定义此宏(在CMake中用target_compile_definitions),它激活dllexport;使用库时不定义此宏,它激活dllimport。在Linux/macOS上,此宏总是扩展为visibility属性。

运行时加载

除了编译时链接外,动态库的核心价值在于运行时加载——可以在程序运行期间按需加载库并调用其中的函数。这是插件系统的基础。

加载API

// 跨平台运行时加载#ifdef _WIN32    #include<windows.h>    using LibHandle = HMODULE;    #define LOAD_LIBRARY(path)   LoadLibraryW(path)    #define GET_SYMBOL(handle, name) GetProcAddress(handle, name)    #define UNLOAD_LIBRARY(handle)  FreeLibrary(handle)    inlineconstcharget_load_error(){        static char buf[256];        FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM, nullptr,                       GetLastError(), 0, buf, sizeof(buf), nullptr);        return buf;    }#else    #include<dlfcn.h>    using LibHandle = void*;    #define LOAD_LIBRARY(path)   dlopen(path, RTLD_NOW | RTLD_LOCAL)    #define GET_SYMBOL(handle, name) dlsym(handle, name)    #define UNLOAD_LIBRARY(handle)  dlclose(handle)    inlineconstcharget_load_error(){        return dlerror();    }#endif

dlopen的flags需要关注:RTLD_NOW在加载时立即解析所有符号(类似Windows的行为),RTLD_LAZY则延迟到首次使用时。RTLD_LOCAL使加载的符号不暴露给后续加载的库(防止符号冲突),RTLD_GLOBAL则相反——后者是实现插件间相互调用的必要选择。

安全的跨平台封装

#include<memory>#include<string>#include<stdexcept>class DynamicLibrary {public:    explicitDynamicLibrary(const std::string& path){        handle_ = LOAD_LIBRARY(path.c_str());        if (!handle_) {            throw std::runtime_error(                "Failed to load library: " + path +                " — " + get_load_error());        }    }    ~DynamicLibrary() {        if (handle_) {            UNLOAD_LIBRARY(handle_);        }    }    // 不可复制    DynamicLibrary(const DynamicLibrary&) = delete;    DynamicLibrary& operator=(const DynamicLibrary&) = delete;    // 可移动    DynamicLibrary(DynamicLibrary&& other) noexcept        : handle_(other.handle_) {        other.handle_ = nullptr;    }    template<typename FuncType>    FuncType* get_function(const std::string& name) {        auto sym = GET_SYMBOL(handle_, name.c_str());        if (!sym) {            throw std::runtime_error("Failed to find symbol: " + name);        }        return reinterpret_cast<FuncType*>(sym);    }private:    LibHandle handle_ = nullptr;};

插件系统设计模式

插件系统是动态库的主要应用场景。一个可扩展的应用程序定义一套C++接口(纯虚类),插件实现这些接口并以动态库的形式发布。应用程序在运行时扫描插件目录,加载每一个动态库,从中获取工厂函数来创建实现了特定接口的对象。

基于纯虚接口的插件架构

// plugin_interface.hpp — 平台无关的插件接口#pragma once#include<string>class IPlugin {public:    virtual ~IPlugin() = default;    // 每个插件必须实现的方法    virtual std::string name()const0;    virtual std::string version()const0;    virtualboolinitialize()0;    virtualvoidshutdown()0;};// 每个插件必须导出这些C链接函数// 使用 extern "C" 避免C++名称修饰导致的符号名不一致extern "C" {    using CreatePluginFunc = IPlugin* (*)();    using DestroyPluginFunc = void (*)(IPlugin*);}
// my_plugin.cpp — 一个具体的插件实现#include"plugin_interface.hpp"#include<iostream>class MyPlugin : public IPlugin {public:    std::string name()constoverridereturn "MyPlugin"; }    std::string version()constoverridereturn "1.0.0"; }    boolinitialize()override{        std::cout << "MyPlugin initialized" << std::endl;        return true;    }    voidshutdown()override{        std::cout << "MyPlugin shutdown" << std::endl;    }};// 导出工厂函数(C链接,防止名称修饰)extern "C" {MYLIB_API IPlugin* create_plugin(){    return new MyPlugin();}MYLIB_API voiddestroy_plugin(IPlugin* plugin){    delete plugin;}// extern "C"
// plugin_manager.cpp — 插件管理器#include"plugin_interface.hpp"#include"DynamicLibrary.hpp"// 前面的跨平台封装#include<filesystem>#include<vector>#include<memory>class PluginManager {public:    voidload_plugins(const std::filesystem::path& plugin_dir){        namespace fs = std::filesystem;        for (const auto& entry : fs::directory_iterator(plugin_dir)) {            if (!entry.is_regular_file()) continue;            auto ext = entry.path().extension().string();            // 根据平台检查正确的扩展名#ifdef _WIN32            if (ext != ".dll"continue;#elif defined(__APPLE__)            if (ext != ".dylib"continue;#else            if (ext != ".so"continue;#endif            try {                auto lib = std::make_unique<DynamicLibrary>(                    entry.path().string());                auto create_fn = lib->get_function<CreatePluginFunc>(                    "create_plugin");                auto destroy_fn = lib->get_function<DestroyPluginFunc>(                    "destroy_plugin");                auto* plugin = create_fn();                if (plugin && plugin->initialize()) {                    plugins_.push_back({                        std::move(lib),                        plugin,                        destroy_fn                    });                }            }            catch (const std::exception& e) {                // 记录错误,继续加载其他插件                log_error("Failed to load plugin: " +                          entry.path().string() + " — " + e.what());            }        }    }    voidshutdown_all(){        for (auto& entry : plugins_) {            entry.plugin->shutdown();            entry.destroy(entry.plugin);        }        plugins_.clear();    }private:    struct PluginEntry {        std::unique_ptr<DynamicLibrary> library;        IPlugin* plugin;        DestroyPluginFunc destroy;    };    std::vector<PluginEntry> plugins_;};

extern "C" 的重要性

C++支持函数重载,因此编译器会对函数名进行名称修饰(Name Mangling)——create_plugin()可能被修饰为_Z13create_pluginv(GCC/Clang)或?create_plugin@@YAPEAXZ(MSVC)。不同编译器甚至同一编译器的不同版本之间的名称修饰方案不同。

extern "C"阻止名称修饰,使函数名在二进制中保持为原始的create_plugin。这是跨编译器(甚至同编译器不同版本)插件二进制兼容的基础。代价是extern "C"函数不能重载、不能是成员函数、不能是模板函数。

但需要注意extern "C"能保证符号名一致,但无法保证ABI兼容性。不同编译器(GCC vs MSVC)编译的C++对象布局可能不同,std::string的内部表示也可能不同。因此对于跨编译器的插件系统,需要进一步限制接口——要么所有插件用同一编译器同一版本编译,要么接口只使用纯C类型(如const char*代替std::string,裸指针代替引用)。

这点在Windows的COM(Component Object Model)中可以看到最彻底的示范。IUnknown接口只使用纯虚函数和C兼容类型,配合引用计数管理生命周期。这使得用Visual Basic写的组件能被C++调用,用Delphi写的组件能被.NET调用——在C的ABI边界上,COM提供了一套亘古的稳定契约。

RPATH与运行库搜索路径

编译时成功链接动态库只是第一步,运行时操作系统必须能找到实际的库文件。各平台的搜索路径规则截然不同:

Windows按照以下顺序搜索.dll

  1. 可执行文件所在目录
  2. 当前工作目录
  3. C:\Windows\System32\
    (系统目录)
  4. C:\Windows\System\
  5. PATH
    环境变量中的目录

Linux按照以下规则搜索:

  1. 可执行文件中嵌入的RPATH(编译时通过-rpath或CMake的INSTALL_RPATH设置)
  2. LD_LIBRARY_PATH
    环境变量
  3. /etc/ld.so.conf
    中配置的目录 + /etc/ld.so.conf.d/
  4. 默认系统路径(/lib/usr/lib等)

macOS类似Linux但更复杂:

  1. 库的install_name中指定的路径(可以是@rpath@loader_path@executable_path等占位符)
  2. DYLD_LIBRARY_PATH
    环境变量
  3. DYLD_FALLBACK_LIBRARY_PATH
  4. 默认路径

跨平台CMake项目通常这样设置RPATH:

# 让可执行文件在同目录或 ../lib 中查找 .so/.dylibset(CMAKE_INSTALL_RPATH "$ORIGIN:$ORIGIN/../lib")  # Linuxset(CMAKE_INSTALL_RPATH "@loader_path:@loader_path/../lib")  # macOS# Windows 不需要特殊设置(默认搜索exe同目录)

$ORIGIN(Linux)和@loader_path(macOS)是指向可执行文件所在目录的占位符,让应用做到"可重定位"——打包目录可以放在任意位置而不需要安装到系统路径。