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

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

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

文件格式与命名约定

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

平台 文件格式 扩展名 典型命名
Windows PE(Portable Executable) .dll mylib.dll
Linux ELF(Executable and Linkable Format) .so libmylib.solibmylib.so.1
macOS Mach-O .dylib libmylib.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属性。

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

cpp 复制代码
// 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:
    int add(int a, int b);
    int multiply(int a, int b);
};

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

运行时加载

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

加载API

cpp 复制代码
// 跨平台运行时加载
#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)
    inline const char* get_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)
    inline const char* get_load_error() {
        return dlerror();
    }
#endif

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

安全的跨平台封装

cpp 复制代码
#include <memory>
#include <string>
#include <stdexcept>

class DynamicLibrary {
public:
    explicit DynamicLibrary(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++接口(纯虚类),插件实现这些接口并以动态库的形式发布。应用程序在运行时扫描插件目录,加载每一个动态库,从中获取工厂函数来创建实现了特定接口的对象。

基于纯虚接口的插件架构

cpp 复制代码
// plugin_interface.hpp --- 平台无关的插件接口
#pragma once
#include <string>

class IPlugin {
public:
    virtual ~IPlugin() = default;

    // 每个插件必须实现的方法
    virtual std::string name() const = 0;
    virtual std::string version() const = 0;
    virtual bool initialize() = 0;
    virtual void shutdown() = 0;
};

// 每个插件必须导出这些C链接函数
// 使用 extern "C" 避免C++名称修饰导致的符号名不一致
extern "C" {
    using CreatePluginFunc = IPlugin* (*)();
    using DestroyPluginFunc = void (*)(IPlugin*);
}
cpp 复制代码
// my_plugin.cpp --- 一个具体的插件实现
#include "plugin_interface.hpp"
#include <iostream>

class MyPlugin : public IPlugin {
public:
    std::string name() const override { return "MyPlugin"; }
    std::string version() const override { return "1.0.0"; }

    bool initialize() override {
        std::cout << "MyPlugin initialized" << std::endl;
        return true;
    }

    void shutdown() override {
        std::cout << "MyPlugin shutdown" << std::endl;
    }
};

// 导出工厂函数(C链接,防止名称修饰)
extern "C" {

MYLIB_API IPlugin* create_plugin() {
    return new MyPlugin();
}

MYLIB_API void destroy_plugin(IPlugin* plugin) {
    delete plugin;
}

} // extern "C"
cpp 复制代码
// plugin_manager.cpp --- 插件管理器
#include "plugin_interface.hpp"
#include "DynamicLibrary.hpp"  // 前面的跨平台封装
#include <filesystem>
#include <vector>
#include <memory>

class PluginManager {
public:
    void load_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());
            }
        }
    }

    void shutdown_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:

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

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