C++ 插件加载器:

从目录扫描、动态库加载、实例创建,到安全卸载的设计思路与实现细节。

一、整体架构概览

这段代码实现了一个完整的运行时插件系统(Runtime Plugin System)。所谓插件系统,就是让主程序在编译完成后,仍然能够在运行时动态扩展功能——把新的逻辑打包成动态库(.dll / .so / .dylib),放入指定目录,主程序便会自动发现并载入。

核心思想:在运行时加载外部模块,并把它们当作对象使用。主程序不依赖任何具体插件实现,二者通过抽象接口 PluginAPI 通信。

整套系统的完整调用链如下:

扫描目录 → 找到动态库 → dlopen/LoadLibrary → 获取函数指针
→ CreatePlugin() → Initialize() → 业务使用
→ Shutdown + Destroy → dlclose/FreeLibrary

二、核心数据结构

跨平台句柄抽象

代码通过条件编译统一了不同平台的动态库句柄类型,用 LibraryHandle 这一名字将平台差异隐藏起来:

#ifdef _WIN32
#include <windows.h>
typedef HMODULE LibraryHandle;   // Windows
#else
#include <dlfcn.h>
typedef void* LibraryHandle;     // Linux / macOS
#endif

PluginEntry:插件档案袋

每一个已加载的插件,都用一个 PluginEntry 结构体来描述。它就像一张"插件身份证",记录了与该插件相关的所有运行时信息:

字段 类型 含义
path std::string 插件动态库的文件路径
handle LibraryHandle 动态库的操作系统句柄
instance PluginAPI* 插件对象实例(指向抽象接口)
createFunc 函数指针 指向 CreatePlugin() 工厂函数
destroyFunc 函数指针 指向 DestroyPlugin() 析构函数

值得注意的是,插件实例的类型是抽象基类指针 PluginAPI*,主程序对具体插件类一无所知,只通过接口与插件交互。这是解耦的关键所在。


三、目录扫描与过滤

LoadPlugins(directory) 是整个流程的入口。它的职责是"找到候选文件",具体加载逻辑委托给 LoadPlugin(path)——职责分离,各司其职。

递归遍历

代码使用了 C++17 引入的 std::filesystem::recursive_directory_iterator,这意味着插件可以放在目录的任意层级子文件夹里,加载器都能找到:

for (const auto& entry :
    std::filesystem::recursive_directory_iterator(directory)) {
    if (entry.is_regular_file()) {  // 只处理普通文件
        std::string ext = entry.path().extension().string();
        // 按平台判断是否是动态库...
    }
}

平台差异化过滤

不同操作系统的插件后缀名不同,代码通过预处理宏在编译期做了区分:

平台 动态库后缀 系统 API
Windows .dll LoadLibraryA / GetProcAddress
Linux .so dlopen / dlsym
macOS .dylib.so dlopen / dlsym

四、插件生命周期详解

LoadPlugin(path) 是代码中最核心、逻辑最密集的函数。它将一个动态库文件转化为一个可用的插件对象,分为以下七个阶段:

① 创建局部 PluginEntry 记录对象

先创建一个局部档案,逐步填充字段。只有全程成功,才最终推入全局列表,防止中途失败污染状态

② 加载动态库(dlopen / LoadLibraryA)

.so / .dll 文件映射到进程地址空间,获得操作系统句柄。失败时输出详细错误信息并立即返回。

// Linux / macOS
entry.handle = dlopen(path.c_str(), RTLD_LAZY);
if (!entry.handle) {
    LOG(ERROR) << "Failed to load plugin: " << dlerror();
    return false;
}

// Windows
entry.handle = LoadLibraryA(path.c_str());
if (!entry.handle) {
    // FormatMessageA 获取可读错误信息...
    return false;
}

③ 查找导出函数(dlsym / GetProcAddress)

在已加载的动态库中,按名称查找 CreatePluginDestroyPlugin 两个符号地址,并转换为对应类型的函数指针。

entry.createFunc  = (PluginAPI*(*)())   dlsym(entry.handle, "CreatePlugin");
entry.destroyFunc = (void(*)(PluginAPI*))dlsym(entry.handle, "DestroyPlugin");

④ 验证插件规范性

检查两个函数指针是否均非空。若缺少任一导出函数,判定该动态库不是合法插件,立即卸载并返回失败。

if (!entry.createFunc || !entry.destroyFunc) {
    LOG(ERROR) << "Plugin does not export required functions: " << path;
    dlclose(entry.handle);
    return false;
}

⑤ 调用工厂函数创建实例

entry.instance = entry.createFunc();

这是插件从"代码"变成"对象"的关键时刻,等价于在插件内部执行 new Plugin()

⑥ 调用 Initialize() 初始化插件

插件在此完成内部准备工作:读取配置、申请资源、建立连接等。初始化失败则执行完整回滚:

if (!entry.instance->Initialize()) {
    LOG(ERROR) << "Plugin initialization failed: " << path;
    entry.destroyFunc(entry.instance);  // 销毁实例
    dlclose(entry.handle);              // 卸载动态库
    return false;
}

⑦ 注册至 m_plugins 列表

一切就绪后,将完整的 PluginEntry 推入全局插件向量。至此,该插件正式进入"已加载"状态,可供主程序调用。

m_plugins.push_back(entry);
LOG(INFO) << "Loaded plugin: " << entry.instance->GetName()
          << " v" << entry.instance->GetVersion();

为什么用 CreatePlugin / DestroyPlugin?

不直接 new / delete,而是用插件自己提供的工厂函数,是插件系统设计的惯用法:

ABI 兼容性:跨模块(主程序 ↔ 插件动态库)的 new / delete 极易因编译器版本、标准库实现、调用约定不同而引发崩溃。由插件自己负责内存的分配与释放,可以彻底规避这类 ABI 问题。原则是:谁分配,谁释放。


五、安全卸载流程

析构函数与 RAII

析构函数体内只有一行:

PluginsLoader::~PluginsLoader() {
    UnloadPlugins();  // 对象销毁时自动卸载所有插件
}

这正是 RAII(Resource Acquisition Is Initialization) 的体现——资源的生命周期与对象绑定。即使调用方忘记手动卸载,只要 PluginsLoader 对象离开作用域,所有插件都会被自动清理,不会发生内存或句柄泄漏。

单个插件的卸载顺序

卸载时的操作顺序有严格约束,不能颠倒:

void PluginsLoader::UnloadPlugin(PluginEntry& entry) {
    if (entry.instance) {
        entry.instance->Shutdown();         // ① 先让插件释放自身资源
        entry.destroyFunc(entry.instance);  // ② 通过工厂函数销毁对象
        entry.instance = nullptr;           // ③ 清空指针,避免悬空
    }
    if (entry.handle) {
        dlclose(entry.handle);              // ④ 最后才卸载动态库
        entry.handle = nullptr;
    }
}

⚠ 顺序不能颠倒:必须先执行 Shutdown()destroyFunc(),最后才能 dlclose()。一旦动态库被卸载,插件类的代码段可能从内存中消失,此时再调用对象方法会引发段错误(Segfault)。


总结

这套插件系统流程可以抽象为一个标准模板,之后遇到类似设计基本都是这个范式:

扫描目录
  └─ 找动态库文件
       └─ dlopen / LoadLibrary          ← 库加载进进程
            └─ dlsym / GetProcAddress   ← 查找导出符号
                 └─ CreatePlugin()      ← 创建插件对象
                      └─ Initialize()  ← 插件自身初始化
                           └─ 业务使用
                                └─ Shutdown()       ← 插件收尾
                                     └─ DestroyPlugin() ← 销毁对象
                                          └─ dlclose / FreeLibrary ← 卸载库

用一句话总结:在运行时把外部模块装进来,当作对象用,用完再按规范拆干净。


Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐