运行时动态加载:LoadLibrary 与 dlopen —— 打开插件的大门 / Runtime Dynamic Loading with LoadLibrary and Dlopen
📅 创建时间:2026-07-13 🏷️ 标签:#动态加载 #dlopen #LoadLibrary #插件入门 #跨平台 📚 前置知识:windows build outputs, linux build outputs, static vs dynamic linking
📋 本章目标
- 理解"运行时动态加载"和"加载时链接"的本质区别
- 掌握 Windows 的 LoadLibrary / GetProcAddress / FreeLibrary 完整用法
- 掌握 Linux 的 dlopen / dlsym / dlclose / dlerror 完整用法
- 理解 dlopen 的关键 flag:RTLD_LAZY vs RTLD_NOW、RTLD_GLOBAL vs RTLD_LOCAL
- 能够用跨平台宏封装出统一的动态加载接口
- 实现第一个简单的插件雏形:一个可扩展的计算器
第1部分:两种加载方式——本质区别
┌─────────────────────────────────────────────────────────────────────────────┐
│ 加载时链接 vs 运行时动态加载 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 加载时链接(Load-time Linking) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • 程序启动时,操作系统的加载器自动加载所有依赖的 .dll/.so │ │
│ │ • 如果某个 .dll 找不到 → 程序启动失败 │ │
│ │ • 依赖关系在链接时就已经固化(写在 PE/ELF 的导入表中) │ │
│ │ • 例子:你在 CMake 里写的 target_link_libraries │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 运行时动态加载(Runtime Dynamic Loading) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • 程序运行时,由你自己的代码决定何时加载哪个库 │ │
│ │ • 库不存在 → 你的代码收到错误,可以优雅处理(弹窗、降级、跳过)│ │
│ │ • 依赖关系不在 PE/ELF 中固化——对操作系统是"看不见的" │ │
│ │ • 例子:VS Code 加载扩展、Photoshop 加载滤镜插件 │ │
│ │ • 核心意义:**程序不需要在编译时知道所有功能——可以在运行时扩展** │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 用加载时链接:插件必须在编译时就确定 → 增加插件需要重新编译主程序 │
│ 用运行时加载:插件可以在编译后任意时候添加 → 新增插件只需放入插件目录 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第2部分:Linux —— dlopen / dlsym / dlclose
2.1 核心 API
#include <dlfcn.h>
// 打开动态库
void* dlopen(const char* filename, int flags);
// 获取符号地址
void* dlsym(void* handle, const char* symbol);
// 关闭动态库
int dlclose(void* handle);
// 获取最近一次错误信息
char* dlerror(void);2.2 dlopen 的 flags —— 这很重要
┌─────────────────────────────────────────────────────────────────────────────┐
│ dlopen flags 详解 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ RTLD_LAZY(延迟解析): │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 符号在首次使用时才解析 │ │
│ │ 优点:加载快,没用到就不会报错 │ │
│ │ 缺点:如果符号不存在,用到时才报错 → 可能程序跑到一半才崩溃 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ RTLD_NOW(立即解析): │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ dlopen 返回时就解析所有符号 │ │
│ │ 优点:早发现错误(dlopen 时就知道哪些符号缺失) │ │
│ │ 缺点:加载稍慢 │ │
│ │ 推荐:开发时用 RTLD_NOW,避免不稳定的延迟错误 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ RTLD_GLOBAL: │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 这个库的符号对后续 dlopen 的其他库可见 │ │
│ │ 场景:后续加载的插件需要引用当前插件的符号 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ RTLD_LOCAL(默认): │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 这个库的符号只对自己可见 │ │
│ │ 推荐:通常用 LOCAL,避免符号污染和意外冲突 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 推荐组合:RTLD_NOW | RTLD_LOCAL(立即解析 + 隔离符号) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘2.3 完整示例
// ====== plugin_math.cpp — 编译为 libmath_plugin.so ======
#include <cstdio>
extern "C" {
int add(int a, int b) {
printf("[plugin] add(%d, %d) called\n", a, b);
return a + b;
}
int multiply(int a, int b) {
printf("[plugin] multiply(%d, %d) called\n", a, b);
return a * b;
}
}# 编译插件
g++ -fPIC -shared plugin_math.cpp -o libmath_plugin.so// ====== host.cpp — 宿主程序 ======
#include <dlfcn.h>
#include <cstdio>
#include <cstdlib>
// 定义函数指针类型(必须和插件导出的函数签名一致!)
typedef int (*calc_func)(int, int);
int main() {
// 1. 加载动态库
void* handle = dlopen("./libmath_plugin.so", RTLD_NOW);
if (!handle) {
fprintf(stderr, "dlopen failed: %s\n", dlerror());
return 1;
}
printf("Library loaded successfully\n");
// 2. 获取函数地址
calc_func add = (calc_func)dlsym(handle, "add");
if (!add) {
fprintf(stderr, "dlsym 'add' failed: %s\n", dlerror());
dlclose(handle);
return 1;
}
calc_func multiply = (calc_func)dlsym(handle, "multiply");
if (!multiply) {
fprintf(stderr, "dlsym 'multiply' failed: %s\n", dlerror());
dlclose(handle);
return 1;
}
// 3. 调用!
printf("add(3, 5) = %d\n", add(3, 5));
printf("multiply(3, 5) = %d\n", multiply(3, 5));
// 4. 关闭
dlclose(handle);
printf("Library unloaded\n");
return 0;
}# 编译宿主(不需要 -lmath_plugin!不需要链接插件!)
g++ host.cpp -o host -ldl # -ldl = libdl, 提供 dlopen/dlsym
# 运行
./host
# 输出:
# Library loaded successfully
# [plugin] add(3, 5) called
# add(3, 5) = 8
# [plugin] multiply(3, 5) called
# multiply(3, 5) = 15
# Library unloaded关键点:宿主编译时完全不知道 math_plugin 的存在!-ldl 只是链接 libdl(提供 dlopen API),和插件本身无关。你可以编译完宿主后,再单独开发新插件。
2.4 dlerror 的正确用法
// ❌ 错误:dlerror 不清空就不变,可能拿到之前的错误
void* handle = dlopen("./plugin.so", RTLD_NOW);
if (!handle) {
printf("Error: %s\n", dlerror()); // 正确用法
}
// ⚠️ 陷阱:dlerror 返回上一次调用至今的错误
// 正确的错误检查流程:
dlerror(); // 先清空错误状态
void* sym = dlsym(handle, "some_function");
char* err = dlerror();
if (err != nullptr) {
printf("dlsym error: %s\n", err); // 这才是本次 dlsym 的错误
}第3部分:Windows —— LoadLibrary / GetProcAddress / FreeLibrary
3.1 核心 API
#include <windows.h>
// 加载 DLL
HMODULE LoadLibraryA(LPCSTR lpLibFileName); // ANSI 版本
HMODULE LoadLibraryW(LPCWSTR lpLibFileName); // Unicode 版本
// 获取函数地址
FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName);
// 释放 DLL
BOOL FreeLibrary(HMODULE hModule);
// 获取错误信息
DWORD GetLastError();3.2 Windows 侧的完整示例
// ====== plugin_math.cpp — 编译为 math_plugin.dll ======
// 定义导出宏
#ifdef MATH_PLUGIN_EXPORTS
#define MATH_API __declspec(dllexport)
#else
#define MATH_API __declspec(dllimport)
#endif
extern "C" {
MATH_API int add(int a, int b) {
return a + b;
}
MATH_API int multiply(int a, int b) {
return a * b;
}
}# 编译插件 (MSVC Developer Command Prompt)
cl /LD plugin_math.cpp /DMATH_PLUGIN_EXPORTS /Fe:math_plugin.dll
# /LD = 生成 DLL// ====== host.cpp — 宿主程序 ======
#include <windows.h>
#include <cstdio>
typedef int (*calc_func)(int, int);
int main() {
// 1. 加载 DLL
HMODULE handle = LoadLibraryA("math_plugin.dll");
if (!handle) {
fprintf(stderr, "LoadLibrary failed: %lu\n", GetLastError());
return 1;
}
printf("DLL loaded successfully\n");
// 2. 获取函数地址
calc_func add = (calc_func)GetProcAddress(handle, "add");
calc_func multiply = (calc_func)GetProcAddress(handle, "multiply");
if (!add || !multiply) {
fprintf(stderr, "GetProcAddress failed\n");
FreeLibrary(handle);
return 1;
}
// 3. 调用
printf("add(3, 5) = %d\n", add(3, 5));
printf("multiply(3, 5) = %d\n", multiply(3, 5));
// 4. 释放
FreeLibrary(handle);
return 0;
}# 编译宿主(不需要 math_plugin.lib!不需要导入库!)
cl host.cpp /Fe:host.exe重要:和 Linux 一样,Windows 上运行时动态加载也不需要导入库(.lib)!直接用 LoadLibrary + GetProcAddress,完全绕过了编译时的符号检查。
3.3 GetProcAddress 也可以按序号获取
// 按函数名(最常用)
auto func = (MyFunc)GetProcAddress(handle, "add");
// 按序号(如果 DLL 用 .def 文件导出了序号)
auto func = (MyFunc)GetProcAddress(handle, MAKEINTRESOURCEA(1));第4部分:跨平台封装 —— write once, compile everywhere
// dynamic_loader.h
#pragma once
#ifdef _WIN32
#include <windows.h>
#define PLUGIN_HANDLE HMODULE
#define PLUGIN_LOAD(path) LoadLibraryA(path)
#define PLUGIN_GETSYM(h, n) GetProcAddress(h, n)
#define PLUGIN_CLOSE(h) FreeLibrary(h)
#define PLUGIN_EXT ".dll"
inline const char* plugin_error() {
static char buf[256];
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM, NULL,
GetLastError(), 0, buf, sizeof(buf), NULL);
return buf;
}
#else
#include <dlfcn.h>
#define PLUGIN_HANDLE void*
#define PLUGIN_LOAD(path) dlopen(path, RTLD_NOW | RTLD_LOCAL)
#define PLUGIN_GETSYM(h, n) dlsym(h, n)
#define PLUGIN_CLOSE(h) dlclose(h)
#define PLUGIN_EXT ".so"
inline const char* plugin_error() {
return dlerror();
}
#endif// 使用跨平台封装
#include "dynamic_loader.h"
typedef int (*calc_func)(int, int);
void load_and_call(const char* plugin_path) {
PLUGIN_HANDLE h = PLUGIN_LOAD(plugin_path);
if (!h) {
fprintf(stderr, "Load failed: %s\n", plugin_error());
return;
}
auto func = (calc_func)PLUGIN_GETSYM(h, "add");
if (func) {
printf("Result: %d\n", func(3, 5));
}
PLUGIN_CLOSE(h);
}第5部分:第一个插件雏形 —— 可扩展计算器
设计思路:宿主程序定义好"运算操作"的函数签名 int (int, int),每个插件提供一组运算(add、subtract、multiply、divide 等),宿主编译好后,任何人写出符合签名的 .so 放到 plugins 目录就能被加载。
┌─────────────────────────────────────────────────────────────────────────────┐
│ 可扩展计算器架构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ host(主程序) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 1. 扫描 plugins/ 目录 │ │
│ │ 2. 对每个 .so/.dll: │ │
│ │ a. dlopen / LoadLibrary │ │
│ │ b. dlsym / GetProcAddress 获取 add/sub/mul/div │ │
│ │ c. 注册进函数表 │ │
│ │ 3. 用户输入 "3 + 5" → 查表 → 调用对应函数 │ │
│ │ 4. 程序退出时 dlclose / FreeLibrary │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │basic.so │ │advanced │ │custom.so │ ← 第三方可随时添加! │
│ │ add/sub │ │ .so │ │ 自定义 │ │
│ │ mul/div │ │ pow/sqrt │ │ 运算 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘这个雏形将在第 07 篇(接口设计)和第 08 篇(CMake 构建)中扩展为完整的插件系统。
核心总结
┌─────────────────────────────────────────────────────────────────────────────┐
│ 运行时动态加载 核心速查 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Linux:dlopen / dlsym / dlclose / dlerror │
│ Windows:LoadLibrary / GetProcAddress / FreeLibrary / GetLastError │
│ │
│ RTLD_NOW:dlopen 时解析所有符号(失败早发现) │
│ RTLD_LAZY:用到时才解析(加载快但可能迟到崩溃) │
│ RTLD_LOCAL:符号隔离(推荐) │
│ RTLD_GLOBAL:符号对其他库可见 │
│ │
│ 关键优势: │
│ • 编译时不需要知道插件的存在 │
│ • 插件可以独立开发和分发 │
│ • 插件不存在 → 优雅降级,不是启动失败 │
│ │
│ -ldl:Linux 上需要链接 libdl(提供 dlopen 系列函数) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘章节测试
测试1:运行时 vs 加载时
运行时动态加载相比加载时链接,最核心的优势是什么?
测试2:dlopen flags
RTLD_LAZY 和 RTLD_NOW 有什么区别?为什么推荐开发时用 RTLD_NOW?
测试3:错误处理
// 这段代码有什么潜在问题?
void* handle = dlopen("./plugin.so", RTLD_LAZY);
void* sym = dlsym(handle, "some_func");
printf("Error: %s\n", dlerror());请写出正确的错误处理写法。
测试4:GetProcAddress 按序号
除了按函数名获取函数地址,GetProcAddress 还有什么方式?什么场景下有用?
测试5:跨平台封装
为什么跨平台封装中,Linux 侧用 RTLD_NOW | RTLD_LOCAL 组合,而不是 RTLD_LAZY | RTLD_GLOBAL?
测试6:编译依赖
用运行时动态加载的方式使用一个插件,宿主程序编译时需要链接那个插件的 .lib(Windows)或 .so(Linux)吗?为什么?
参考答案
测试1答案
答案:(1) 插件可在编译后任意添加,不需要重新编译宿主;(2) 插件不存在时程序可以优雅降级(弹窗、跳过、使用备用方案),而不是直接启动失败;(3) 实现了真正的"可扩展"架构——宿主定义接口,任何人可以实现。
测试2答案
答案:RTLD_LAZY 延迟解析符号(使用时才解析),RTLD_NOW 立即解析(dlopen 时就解析)。推荐开发时用 RTLD_NOW 因为可以在 dlopen 时立即发现符号缺失问题——如果用了 LAZY,可能程序运行到一半调用某个不存在的函数时才崩溃,更难排查。
测试3答案
答案:问题在于 dlerror() 的返回值是"自上次调用 dlerror 以来的错误",而非"最近一次 dl* 调用的错误"。正确的写法:
void* handle = dlopen("./plugin.so", RTLD_NOW);
if (!handle) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return;
}
dlerror(); // 清空旧错误
void* sym = dlsym(handle, "some_func");
char* err = dlerror();
if (err) {
fprintf(stderr, "dlsym: %s\n", err);
}测试4答案
答案:还可以按序号(ordinal)获取:GetProcAddress(handle, MAKEINTRESOURCEA(1))。这在以下场景有用:(1) DLL 使用 .def 文件按序号导出,函数名可能被故意隐藏(反逆向);(2) 避免函数名字符串比较的性能开销;(3) 处理没有公开函数名只有序号的系统 DLL。
测试5答案
答案:
RTLD_NOW:插件开发场景下,宁可加载时慢一点也要早发现符号问题——如果插件缺少承诺的函数,应该立即报错而非运行时随机崩溃。RTLD_LOCAL:隔离符号——防止插件 A 和插件 B 导出同名内部函数时发生符号冲突。每个插件的符号只对自己可见,互不干扰。
测试6答案
答案:不需要链接插件库文件。因为:(1) 编译时宿主程序中没有对插件函数的"符号引用"——它们通过函数指针间接调用;(2) 对操作系统来说,这些动态库不是宿主的"依赖",而是程序运行时自己管理的;(3) 宿主只需要链接提供 dlopen/LoadLibrary 的系统库(Linux 上的 -ldl),不涉及任何具体插件。
相关笔记
- windows build outputs - Windows LoadLibrary 的底层(IAT、PE 加载)
- linux build outputs - Linux dlopen 的底层(ld.so、GOT/PLT)
- static vs dynamic linking - 加载时链接 vs 运行时加载
- plugin interface design - 从函数指针到完整的插件接口设计
下一步学习
- [ ] 阅读 07 - 插件接口设计 —— 从 C 函数指针到 C++ 纯虚接口
学习状态:🟡 开始学习