C 插件系统实战指南:dlopen 没告诉你的四件事
Posted on 一 07 9月 2026 in Tech
| Abstract | C 插件系统实战指南 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Status | v1.0 |
| Updated | 2026-09-07 |
| License | CC-BY-NC-ND 4.0 |
大纲
展开看看
- 一句话:
dlopen有四个函数,半小时学完。难的是它没管的四件事:① 入口点怎么找;② 插件怎么回调宿主;③ ABI 怎么不崩;④ 卸载怎么办。传统方法的全部工程量都在这四个问题上。 - What:
dlopen/dlsym/dlclose/dlerror加两个扩展,以及一张"它管什么、不管什么"的对照表。 - Why:先给一份该不该用插件化的决策清单,以及五种替代方案(静态注册 / dlopen / 子进程+IPC / 嵌入脚本 / WASM)的对比。我的立场是:大多数团队想要的其实是静态注册,不是 dlopen。
- How:四个问题的传统答案。入口点五种 pattern(含各自的真实使用者:CPython、PAM、httpd、nginx、Redis、OpenSSL 3、QEMU);回调宿主两派做法;ABI 三层守卫;卸载的三个最低配。
- Example:一份 357 行、可编译可运行的完整参考实现(
plugin_api.h51 行 +loader.c225 行 + 3 个插件 + Makefile),实现了两阶段注册、ABI 门禁、名字冲突拒绝、allowlist 门禁、引用计数。 - 10 个常见错误,每个都有崩溃现场:
RTLD_LAZY的延迟 SIGSEGV、-fvisibility=hidden把入口藏了、Linux 忘-rdynamic、C++ 忘extern "C"(函数会 mangle、全局变量不会、namespace 里的变量会)、存.so里的指针、detached 线程遇上dlclose、静态库被链两份导致两套全局状态、RTLD_GLOBAL符号劫持(我用-flat_namespace在 macOS 上复现了 ELF 的行为)。 - 反 folklore 一条:
atexit+dlclose崩溃是个历史传说,现代 glibc 和 dyld 都通过__cxa_atexit的 DSO handle 修好了。我实测没崩。 - 收尾:12 条 best practice + 一份分组 checklist + 排障命令速查。
上一篇拆的是 FreeSWITCH 一家的实现。但那套机制里大概 80% 的东西不是 FreeSWITCH 特有的 —— Apache httpd、nginx、CPython、PAM、Redis、OpenSSL、QEMU 全都在解同一批问题,答案也高度雷同。
这篇把通用部分单独拎出来,写成一份能直接抄的指南。
先说个观察。dlopen 的 man page 很短,四个函数,半小时能看完。所以大部分人的第一版插件系统长这样:
void *h = dlopen(path, RTLD_LAZY);
void (*init)() = dlsym(h, "plugin_init");
init();
这四行代码里有三个 bug,而且都不会立刻暴露。它们会在半年后的生产环境里,以"随机崩溃""插件加载后某个功能失灵""升级宿主后所有插件挂掉"的形式出现。
下面的代码和输出均在 macOS + Apple clang 上实测。凡是本机无法验证的(主要是 ELF/Linux 特有行为),文中都会明确标出。
What:dlopen 管什么,不管什么
全部 API
#include <dlfcn.h>
void *dlopen (const char *file, int mode);
void *dlsym (void *handle, const char *name);
int dlclose(void *handle);
char *dlerror(void); /* 唯一错误通道,读一次就清空 */
外加两个常用扩展(POSIX 之外,glibc 和 macOS 都有):
int dladdr(const void *addr, Dl_info *info); /* 地址 → 哪个 .so、哪个符号 */
void *dlvsym(void *h, char *sym, char *ver); /* 按符号版本查,glibc only */
三个特殊 handle:
| handle | 含义 |
|---|---|
NULL(传给 dlopen) |
主程序的全局作用域,不加载任何新东西 |
RTLD_DEFAULT |
按默认顺序全局搜索 |
RTLD_NEXT |
从当前库之后开始搜(写 wrapper/hook 时用) |
dlopen(NULL, ...) 这个用法很关键 —— 它让你能在宿主自己身上查符号,从而实现"静态编入和动态加载走同一套协议"。上一篇里 FreeSWITCH 的 CORE_PCM_MODULE 就是靠这个。
它管什么、不管什么
dlopen 管吗 |
谁来管 | |
|---|---|---|
把 .so 映射进地址空间 |
✅ | — |
| 解析未定义符号 | ✅ | 但能不能解析成功取决于你的构建参数 |
跑 constructor / .init_array |
✅ | — |
引用计数(同一个 .so 多次 dlopen) |
✅ | — |
| 入口点叫什么名字 | ❌ | 你自己定协议 |
| 插件怎么调用宿主的函数 | ❌ | 你自己设计 |
| 版本/ABI 是否兼容 | ❌ | 你自己检查 |
| 卸载时的安全性 | ❌ | 你自己保证 |
| 插件之间的符号隔离 | 半 | 你选 RTLD_LOCAL/GLOBAL,剩下的自己扛 |
下面四个 How 章节,就是这四个 ❌。
Why:先想清楚要不要用
这一节比技术细节重要。我见过太多"为了解耦而插件化",最后既没得到灵活性,又背上了一身 ABI 债。
什么时候该用 dlopen
全部满足才划算:
- 第三方要在不改你代码、不重编你的宿主的前提下扩展功能。这是唯一的硬需求。
- 扩展点数量多、变化频繁,编进主程序会让构建时间和二进制体积失控(FreeSWITCH 有 90+ 模块)。
- 需要按部署裁剪:同一份宿主,客户 A 装 3 个模块,客户 B 装 30 个。
- 需要热加载(不重启加载新功能)。注意:热卸载比热加载难一个数量级,别混为一谈。
- 性能上必须同进程:音视频、数据平面这类每秒几万次调用的场景,IPC 的开销不可接受。
什么时候别用
- 只是想让代码分层解耦 → 用静态注册表就够了。同样是"名字 → 函数指针",但编译期链在一起,没有任何 ABI 问题、没有符号可见性问题、没有卸载问题。这是最被低估的方案。
- 扩展逻辑是业务规则、配置、模板 → 嵌入 Lua / JS 引擎,或者干脆读配置。别为了跑一段 if-else 引入
.so。 - 插件不可信 → 同进程 = 同一份地址空间,插件一个野指针就带走整个进程。必须用子进程 + IPC,或者 WASM 沙箱。
- 只有 3 个扩展点,且都是自己团队写的 → 静态注册。
- 团队里没人愿意长期维护 ABI 契约 → 这条最现实。ABI 是需要人守的,没人守它就会烂。
五种方案对比
| 隔离性 | 调用开销 | ABI 负担 | 热加载 | 跨语言 | 适合 | |
|---|---|---|---|---|---|---|
| 静态注册表 | 无 | 零 | 无 | ❌ | ❌ | 内部分层解耦(大多数人真正需要的) |
dlopen 插件 |
无 | 零 | 重 | ✅ | 难 | 第三方扩展 + 同进程性能要求 |
| 子进程 + IPC | 强 | 高 | 轻(协议即契约) | ✅ | ✅ | 不可信插件、语言无关 |
| 嵌入脚本引擎 | 中 | 中 | 轻 | ✅ | ✅ | 业务规则、配置化逻辑 |
| WASM | 强 | 中 | 轻 | ✅ | ✅ | 不可信 + 需要性能,但生态还年轻 |
我的立场:如果你说不出"为什么必须是
.so,静态注册为什么不行",那答案大概是静态注册。dlopen的收益只有一个 —— 让别人在不重编你的程序的前提下扩展你;代价是下面整整四章。
How 之一:入口点怎么找
.so 装进来了,你得 dlsym 一个名字。传统上有五种做法,全都在生产项目里活着。
Pattern A:固定函数名
/* 插件 */
__attribute__((visibility("default")))
int plugin_init(const host_api_t *host) { ... }
/* 宿主 */
int (*init)(const host_api_t *) =
(int(*)(const host_api_t*))(intptr_t)dlsym(h, "plugin_init");
真实用户:OpenSSL 3 provider、Redis module、PostgreSQL extension。
本机验证 OpenSSL:
$ nm -gU /opt/homebrew/opt/openssl@3/lib/ossl-modules/legacy.dylib | grep provider_init
0000000000001564 T _OSSL_provider_init
缺点:一个名字只带一个入口。想加版本号、卸载回调、能力标志,就得再约定更多名字,协议越来越散。而且你拿到函数指针的那一刻,已经错过了"调用之前检查"的机会。
Pattern A':固定函数名集合(约定式 VTable)
把一组固定名字当虚函数表。PAM 是教科书例子,本机验证:
$ nm -gU /usr/lib/pam/pam_permit.so.2
0000000000000300 T _pam_sm_acct_mgmt
00000000000002f0 T _pam_sm_authenticate
0000000000000318 T _pam_sm_chauthtok
0000000000000310 T _pam_sm_close_session
0000000000000308 T _pam_sm_open_session
00000000000002f8 T _pam_sm_setcred
六个符号,一个不多。 "缺失即不支持"是它的语义 —— 你实现哪几个,PAM 就认为你支持哪几个操作。
优点是极简。缺点是没地方放版本号,PAM 只能靠"pam_sm_* 签名二十年没变过"来维持兼容。
Pattern B:导出一个描述符数据符号 ← 推荐
typedef struct plugin_desc {
int abi_version; /* ← 必须是第一个字段 */
size_t size; /* ← 细粒度兼容 */
const char *name;
int (*init)(const host_api_t *host, pool_t *pool, registry_t *reg);
void (*fini)(void);
unsigned flags;
void *reserved[8]; /* ← 留白 */
} plugin_desc_t;
__attribute__((visibility("default")))
plugin_desc_t plugin_descriptor = { PLUGIN_ABI, sizeof(plugin_desc_t), ... };
真实用户:Apache httpd(<name>_module)、nginx(ngx_<name>_module)、GStreamer(gst_plugin_desc)、PHP(get_module() 返回 zend_module_entry*)、sudo plugin(sudoers_policy)、FreeSWITCH(<name>_module_interface)。
这些项目独立收敛到同一个方案,不是巧合。三个额外好处,通常没人讲:
① 版本号有地方放,而且能在调用任何插件代码之前检查。 见后面的 Example。
② 顺手绕掉一个 ISO C 的坑。 标准 C 不允许 void* ↔ 函数指针互转(dlsym 返回 void* 是 POSIX 对 ISO C 的一次公开开洞,POSIX 后来在 rationale 里专门讨论过这个不一致)。数据符号是 void* → struct*,完全合法;函数指针都藏在结构体字段里,由编译器保证类型。所以严谨项目的加载器取的是数据符号,不是函数符号。
③ 对 C++ 更友好。 这条很意外,见"常见错误 #4"。
Pattern C:__attribute__((constructor)) 自注册
插件不暴露任何入口,在加载时自己跑起来去调宿主的注册函数:
static void hello(void) { ... }
__attribute__((constructor)) static void selfreg(void) {
host_register("hello", hello);
}
实测,注意输出顺序:
=== dlopen("./p_ctor.so") ===
[host_register] 'p_ctor:hello' 注册进来了(此刻还在 dlopen 里)
dlopen 返回 0x74d9f2d0(注意上面 host_register 的输出出现在这一行之前)
constructor 在 dlopen 返回之前就执行完了。 宿主一行 dlsym 都没写,服务已经注册好了。
真实用户:QEMU 的 module_init() 宏。
代价很大,我一般不推荐:
- 注册发生在你无法控制的时刻。宿主的全局变量还没初始化、日志还没起来,你也拦不住。
- 出错没法返回错误码,只能
abort()或者悄悄失败。 - 多个 constructor 之间顺序不可控。
- ABI 检查没地方放 —— 等你想检查的时候,插件代码已经跑过了。
Pattern D:符号名由文件名推导
snprintf(sym, sizeof sym, "%s_plugin", basename_without_ext);
真实用户:CPython、glibc NSS、FreeSWITCH。
CPython 是最干净的例子,本机验证:
$ nm -gU .../lib-dynload/_socket.cpython-313-darwin.so
0000000000000a28 T _PyInit__socket
整个 .so 只导出一个符号,名字 = PyInit_ + 模块名。glibc 的 NSS 更极端,_nss_files_getpwnam_r 这种 _nss_<service>_<function> 全靠拼字符串。
- 好处:一个进程里能同时加载 N 个插件而不撞名。Pattern A/B 那个固定的
plugin_init在RTLD_GLOBAL下是会互相干扰的。 - 坏处:文件名成了协议的一部分。改名即加载失败,而且报错信息很不直观。
Pattern E:链接器 section 收集
不用 dlsym,把注册项塞进自定义 section,加载后整段遍历:
#define REGISTER(x) static void *__reg_##x \
__attribute__((used, section("myregs"))) = &x;
真实用户:Linux 内核(initcall、__param)、U-Boot、Zephyr。
慎用:ELF 上要靠 __start_myregs/__stop_myregs 或链接脚本;Mach-O 上得用 getsectiondata();PE 上又是另一套。移植成本极高,除非你只跑一个平台。
五种对比
| 版本号放哪 | 入口数 | 移植性 | 谁在用 | |
|---|---|---|---|---|
| A 固定函数名 | 没地方 | 1 | 好 | OpenSSL provider, Redis, PostgreSQL |
| A' 固定名集合 | 没地方 | N(固定) | 好 | PAM, ODBC |
| B 描述符结构体 | 第一个字段 | N(可扩展) | 好 | httpd, nginx, GStreamer, PHP, FreeSWITCH |
| C constructor | 放不了 | 任意 | 好 | QEMU |
| D 文件名推导 | 同 A/B | 同 A/B | 好 | CPython, NSS, FreeSWITCH |
| E section 收集 | 结构体里 | 任意 | 差 | Linux kernel, U-Boot |
默认选 B,需要多插件共存时叠加 D。 这也是 FreeSWITCH 的选择。
How 之二:插件怎么回调宿主
这个方向经常被忽略,但坑更多。两派做法。
路线一:靠未定义符号(隐式)
插件直接写 host_report(...),编译时不链接宿主,留个未定义符号,dlopen 时由动态链接器绑定。
$ nm -u p_a.so | grep host
_host_report
FreeSWITCH 走的这条路,mod_dptools.so 有 288 个未定义的 switch_* 符号。
平台大坑,必须讲清楚:
| 宿主符号默认可见吗 | 怎么办 | |
|---|---|---|
| Linux / ELF | 不可见(可执行文件默认不进 .dynsym) |
宿主必须加 -rdynamic(= -Wl,--export-dynamic) |
| macOS / Mach-O | 可见(可执行文件符号默认可被 dlopen 的库解析) | 插件侧需 -undefined dynamic_lookup |
本机验证 macOS 默认导出:
$ nm -gU host | grep -E "host_report|host_register"
0000000100000588 T _host_register
0000000100000548 T _host_report
"我在 macOS 上跑通了,搬到 Linux 插件加载就报 undefined symbol" —— 十有八九是宿主忘了
-rdynamic。这是这条路线最常见的事故。
另一个隐性代价:你的宿主整个符号表都成了 ABI 契约。哪天你把一个内部函数改了签名,所有第三方插件都得重编,而且编译期看不出来。
路线二:显式传 vtable(推荐)
宿主定义一个结构体,init 时传进去:
typedef struct host_api {
int abi;
size_t size; /* 让插件能判断字段有没有 */
void (*log)(const char *lvl, const char *fmt, ...);
void*(*palloc)(pool_t *pool, size_t n);
int (*declare_cmd)(registry_t *reg, const char *name, ...);
void *reserved[8];
} host_api_t;
Redis module 是这一派的极致:RedisModule_Init(ctx, name, ver, APIVER) 会把上百个函数指针填进插件自己的静态变量里。结果是 Redis 模块的 .so 一个宿主符号都不依赖,nm -u 干净得像个纯计算库。OpenSSL 3 的 provider 用 OSSL_DISPATCH 表做双向协商,也是这一派。
我的建议:两条都用,但分工明确
| 走 vtable | 走未定义符号 |
|---|---|
| 核心能力(log / alloc / 注册 / 事件) | 几百个工具函数(字符串、时间、容器) |
| 需要按插件差异化的(测试替身、多租户) | 宏和 inline 函数(vtable 传不了) |
| 需要带版本协商的 | 编译期常量 |
vtable 的好处是"插件需要什么"变成了显式声明,而未定义符号是隐式的、编译期看不出来的。而且 vtable 能带 size 字段做前向兼容,未定义符号不能。
How 之三:ABI 怎么不崩
In-process 插件最阴的地方:header 改了而插件没重编,你得到的不是编译错误,是随机内存踩踏。 那种 bug 找一周都正常。
三层守卫,建议全上。
第一层:描述符里的版本号(粗粒度守门)
if (d->abi != MP_ABI) {
log("插件 abi=%d,宿主要求 %d,请重新编译插件", d->abi, MP_ABI);
return -1;
}
放在结构体第一个字段,这样即使后面全变了你也能安全读出它。
真实例子:FreeSWITCH SWITCH_API_VERSION(目前是 5)、Apache MODULE_MAGIC_NUMBER、Python PYTHON_API_VERSION。
PostgreSQL 的 PG_MODULE_MAGIC 是我见过最讲究的:它不只有版本号,还带 funcmaxargs、namedatalen、float8byval 这些编译期配置项 —— 因为配置不一致会导致结构体布局不同,光版本号对上也没用。
代价:粒度太粗,加一个字段就全生态重编。所以要有第二层。
第二层:结构体尾部留白
typedef struct switch_io_routines {
switch_io_outgoing_channel_t outgoing_channel;
/* ...14 个方法... */
void *padding[10]; /* ← FreeSWITCH 真这么写的 */
} switch_io_routines_t;
以后加新方法时占用 padding,结构体大小不变,已编译的插件不用重编。
- 优点:加方法不破 ABI
- 缺点:有上限,用完就没了
第三层:结构体自带 size
typedef struct host_api { int abi; size_t size; /* ... */ } host_api_t;
/* 插件侧:判断这个字段存不存在 */
if (host->size >= offsetof(host_api_t, new_fn) + sizeof(void*) && host->new_fn) {
host->new_fn(...);
}
Windows API 大量用的 cbSize 就是这个思路。优点是双向兼容、无上限(新宿主 + 旧插件、旧宿主 + 新插件都能工作);缺点是每处都要判,啰嗦。
三层对比
| 粒度 | 上限 | 双向兼容 | 代码负担 | |
|---|---|---|---|---|
| 版本号 | 粗(全量重编) | 无 | ❌ | 极低 |
padding |
细(加方法) | 有 | 单向 | 低 |
size |
细(加字段) | 无 | ✅ | 中 |
建议:版本号做粗粒度守门 + 关键结构体带
size+ VTable 留padding。 三个都要。这不是过度设计 —— 你的 ABI 契约会活很多年,而它出错的代价是无法调试的随机崩溃。
How 之四:卸载怎么办
dlclose 是这套机制里最危险的部分。先看崩溃现场。
现场一:存了 .so 里的指针
我只是把 .so 里一个字符串常量的指针存下来,dlclose 之后读它:
=== 普通 dlclose ===
卸载前 name = 'p_b'
dlclose ... done
dladdr: 地址已经不属于任何映像了 —— 读它就是 use-after-unmap
尝试读取 saved_name ... (exit=139) ← SIGSEGV
=== RTLD_NODELETE ===
卸载前 name = 'p_b'
dlclose ... done
尝试读取 saved_name ... 'p_b'
(exit=0)
RTLD_NODELETE 让 dlclose 不真正 unmap,所以读得到。这是一个诚实的中间态:逻辑上卸载,物理上不还内存。
现场二:detached 线程
插件起了个线程,pthread_detach 之后宿主没法 join:
[plugin] 起了一个 detached 线程,代码位于本 .so
dlclose(线程还在跑)...
dlclose 返回了,睡 300ms 看线程还能不能活
(exit=139 139=SIGSEGV)
线程的代码页被 unmap,下一次时钟中断回来就死。这是 dlclose 的头号杀手。
残留指向 .so 的东西,完整清单
- 函数指针(注册表里没清干净的)
- 字符串/常量指针(现场一,最阴,因为看起来只是个
const char *) - 还在跑的线程(现场二,
dlclose不会等它) atexit注册的回调 —— 见下面的辟谣- TLS destructor
- C++ 的 vtable 指针 / typeinfo / 异常表
- 别的库持有的回调(你给 libcurl 设过 write callback 吗?给 OpenSSL 设过 BIO 吗?)
辟谣:atexit + dlclose 已经不崩了
这条 folklore 传了很多年。我实测:
=== dlclose 之后退出 ===
[plugin] init: 注册 atexit(my_cleanup)
dlclose ...
[plugin] atexit 回调跑了(代码可能已被 unmap) ← 在 dlclose 期间就跑了
main return,接下来 libc 会跑 atexit 链
(exit=0)
现代 runtime 会在 dlclose 时就把该镜像注册的 handler 跑掉并摘链。 glibc 靠 __cxa_atexit 记录 DSO handle 做到这一点,macOS 的 dyld 同理。
所以这条不再是坑(除非你在很老的 libc 上)。我把它写出来是因为:照抄十年前的博客会让你防错方向。
三个最低配
如果你确实要支持热卸载:
① 引用计数 + 读写锁。 每次从注册表拿到接口就加读锁 + 计数,调完释放;卸载时拿写锁等读者排空。FreeSWITCH 的做法(switch_module_interfaces.h:864):
#define PROTECT_INTERFACE(_it) if (_it) { \
switch_thread_rwlock_rdlock(_it->parent->rwlock); /* 模块级 */ \
switch_thread_rwlock_rdlock(_it->rwlock); /* 接口级 */ \
switch_mutex_lock(_it->reflock); \
_it->refs++; _it->parent->refs++; \
switch_mutex_unlock(_it->reflock); }
卸载时 switch_thread_rwlock_trywrlock_timeout(ptr->rwlock, 10) 等 10 秒,等不到就打 ERROR 硬拆。诚实的工程妥协。
② 注册表里的字符串必须复制到宿主内存。 不能直接存 .so 里的 const char *。现场一那个崩溃就是这条没做。
③ 接受"最终会有一次泄漏或崩溃",并写进文档。 保守派的做法:
- FreeSWITCH 有
HAVE_FAKE_DLCLOSE编译选项,某些平台上根本不调dlclose - CPython 对多数扩展不再 unmap
- 我在下面的参考实现里也故意不
dlclose
热加载和热卸载是两件事,难度差一个数量级。 大多数场景只需要热加载(加新功能不重启),不需要热卸载。别把不需要的复杂度背上。
flags 怎么选:只有两个真需要思考
dlopen(path, RTLD_NOW | RTLD_LOCAL); /* 插件系统的默认答案 */
RTLD_NOW vs RTLD_LAZY:插件系统请务必 RTLD_NOW
我造了个引用不存在符号的插件:
--- RTLD_NOW ---
dlopen("./p_bad.so", RTLD_NOW) ... 失败
dlopen(./p_bad.so, 0x0006): symbol not found in flat namespace
'_this_symbol_does_not_exist_anywhere'
--- RTLD_LAZY ---
dlopen("./p_bad.so", RTLD_LAZY) ... 成功 (0x74d9ca40)
dlsym 成功 = 0x104994008,此时还没崩
现在调用那个引用了不存在符号的 init() ...
(exit=139) ← SIGSEGV
RTLD_LAZY 让你加载成功、dlsym 成功、然后在半夜某个 code path 上 SIGSEGV。RTLD_NOW 在 dlopen 那一刻就给你一句人话。
RTLD_LAZY 唯一的价值是启动快一点点,和"插件版本对不上时死在你看不见的地方"这个代价完全不成比例。Apache、FreeSWITCH、Python 全都用 RTLD_NOW。
RTLD_LOCAL vs RTLD_GLOBAL:默认 LOCAL
RTLD_GLOBAL 把插件符号放进全局作用域,后续加载的库能看见它。
需要它的场景很具体:脚本语言插件。mod_python 得让 Python 解释器再 dlopen 的扩展能找到 libpython 符号,不 GLOBAL 就炸。FreeSWITCH 的做法是让插件在 flags 里声明 SMODF_GLOBAL_SYMBOLS,加载器读到之后 dlclose 重开一次 —— 因为 flags 存在插件身上,不读进来就不知道该怎么打开它,一个鸡生蛋问题用"开两次"暴力解决。
不需要却用了它,后果是符号互相污染。我在 macOS 上用 -flat_namespace(模拟 ELF 的扁平作用域)复现出来了:
=== flat namespace + RTLD_LOCAL ===
g1_entry 调用 shared_helper:
-> g1 的 shared_helper
g2_entry 调用 shared_helper:
-> g2 的 shared_helper ← 各调自己的,正常
=== flat namespace + RTLD_GLOBAL ===
g1_entry 调用 shared_helper:
-> g1 的 shared_helper
g2_entry 调用 shared_helper:
-> g1 的 shared_helper ← 劫持!g2 调到了 g1 的实现
g1.so 和 g2.so 各自定义了 shared_helper,RTLD_GLOBAL 下先加载的赢,后加载的悄悄用了别人的实现。
想象一下这是两个插件各自静态链了不同版本的 zlib —— 版本不匹配、结构体布局不一样、随机崩溃,而且日志里什么都看不出来。
⚠️ 平台差异:macOS 默认 two-level namespace,.so 内部调用在链接期就绑定,所以不加 -flat_namespace 复现不出来:
$ otool -hv c2.so | tail -1
... NOUNDEFS DYLDLINK TWOLEVEL NO_REEXPORTED_DYLIBS
$ nm -u c2.so
_printf
dyld_stub_binder ← shared_helper 根本不在未定义符号表里
ELF/Linux 默认就是扁平的,所以上面那个劫持是 Linux 上的默认行为。这一条我是用 -flat_namespace 模拟验证的,没有真的 Linux 环境跑过。
其他 flags(用到才管)
| flag | 作用 | 平台 |
|---|---|---|
RTLD_NODELETE |
dlclose 不真正 unmap |
POSIX 2008 |
RTLD_NOLOAD |
只查"是否已加载",不加载 | POSIX 2008 |
RTLD_DEEPBIND |
插件优先用自己的符号(缓解上面的污染) | glibc only,和 ASan 冲突,慎用 |
RTLD_FIRST |
dlsym 只在该库找,不搜依赖链 |
macOS only |
编译链接必修项
# 插件
cc -shared -fPIC -fvisibility=hidden -o p.so p.c # Linux
cc -shared -fPIC -fvisibility=hidden -undefined dynamic_lookup -o p.so p.c # macOS
# 宿主
cc -rdynamic -o host host.c -ldl # Linux
cc -o host host.c # macOS
平台差异速查
| Linux | macOS | |
|---|---|---|
| 插件扩展名 | .so |
.dylib 或 .so(插件习惯用 .so) |
| 编译 | -shared -fPIC |
-shared -fPIC 或 -bundle |
| 未定义符号 | 默认允许 | 需 -undefined dynamic_lookup |
| 宿主导出符号 | 需 -rdynamic |
默认导出 |
| 符号命名空间 | 扁平 | two-level(默认) |
需要 -ldl |
老 glibc 需要;2.34+ 已并入 libc | 不需要 |
| 找同目录依赖 | -Wl,-rpath,'$ORIGIN' |
-Wl,-rpath,@loader_path |
| 搜索路径变量 | LD_LIBRARY_PATH |
DYLD_LIBRARY_PATH(SIP 下常失效) |
| 加载调试 | LD_DEBUG=libs,bindings |
DYLD_PRINT_LIBRARIES=1 |
$ORIGIN / @loader_path 值得单独强调:插件要找同目录的依赖库,靠的是这个,不是环境变量。 用 LD_LIBRARY_PATH 的方案在容器和 macOS SIP 下都会翻车。
Example:一份可编译运行的完整参考实现
举一个实际可运行的例子,一共 357 行:plugin_api.h 51 行、loader.c 225 行、3 个插件 39 行、main.c 27 行、Makefile 15 行。它实现了本文所有推荐做法:Pattern B + D 的入口、vtable 注入、pool 生命周期注入、三层 ABI 守卫、两阶段注册、名字冲突拒绝、allowlist 门禁、引用计数、不 dlclose。
plugin_api.h —— 宿主与插件共享的唯一 header,这就是那份"IDL"
#ifndef PLUGIN_API_H
#define PLUGIN_API_H
#include <stddef.h>
#define MP_ABI 1 /* 粗粒度守门:结构体不兼容变更时 +1 */
/* ---------- 宿主提供给插件的能力(vtable 注入) ---------- */
typedef struct mp_pool mp_pool_t; /* 不透明:生命周期容器 */
typedef struct mp_registry mp_registry_t; /* 不透明:注册暂存区 */
typedef int (*mp_cmd_fn)(void *ctx, const char *arg, char *out, size_t outlen);
typedef struct mp_host {
int abi;
size_t size; /* 细粒度兼容:调用方判 size */
void (*log)(const char *lvl, const char *fmt, ...);
void*(*palloc)(mp_pool_t *pool, size_t n); /* 只从注入的 pool 分配 */
char*(*pstrdup)(mp_pool_t *pool, const char *s);
/* 申报能力:只写暂存区,不进全局表 */
int (*declare_cmd)(mp_registry_t *reg, const char *name,
const char *desc, mp_cmd_fn fn, void *ctx);
void *reserved[8];
} mp_host_t;
/* ---------- 插件暴露给宿主的描述符(Pattern B 的数据符号) ---------- */
typedef struct mp_plugin {
int abi; /* ← 必须是第一个字段 */
size_t size;
const char *name;
const char *version;
int (*init)(const mp_host_t *host, mp_pool_t *pool, mp_registry_t *reg);
void (*fini)(void);
unsigned flags;
void *reserved[8]; /* ← 给未来留白,加字段不破 ABI */
} mp_plugin_t;
#define MP_FLAG_NONE 0u
#define MP_FLAG_GLOBAL_SYMBOLS 1u /* 需要 RTLD_GLOBAL(脚本语言插件) */
#define MP_FLAG_NO_UNLOAD 2u /* 声明自己不可卸载 */
/* Pattern B + D:符号名 = <文件名>_plugin,宿主按文件名推导 */
#define MP_PLUGIN_DEFINE(modname, ver, initfn, finifn, fl) \
__attribute__((visibility("default"))) \
mp_plugin_t modname##_plugin = { \
MP_ABI, sizeof(mp_plugin_t), #modname, ver, \
initfn, finifn, fl, {0} \
}
#endif
loader.c 的关键部分 —— 加载与门禁
int mp_load(const char *path){
char key[128], sym[160];
basename_noext(path, key, sizeof key);
snprintf(sym, sizeof sym, "%s_plugin", key); /* Pattern D */
for (module_t *m = R.modules; m; m = m->next)
if (!strcmp(m->key, key)) { log("模块已加载"); return -1; }
/* ① 先在宿主自身找(支持静态编入),找不到再 dlopen */
void *dso = dlopen(NULL, RTLD_NOW | RTLD_LOCAL);
dlerror(); /* 先清空错误 */
mp_plugin_t *d = (mp_plugin_t *)dlsym(dso, sym);
int from_self = (d != NULL);
if (!d) {
if (dso) dlclose(dso);
dso = dlopen(path, RTLD_NOW | RTLD_LOCAL); /* ② NOW + LOCAL */
if (!dso) { log("dlopen: %s", dlerror()); return -1; }
dlerror();
d = (mp_plugin_t *)dlsym(dso, sym);
char *e = dlerror(); /* ③ 用 dlerror 判错,不看返回值 */
if (!d) { log("dlsym: %s", e ? e : "not found"); dlclose(dso); return -1; }
}
/* ④ 门禁:abi → size → 必填字段,全部在调用任何插件代码之前 */
if (d->abi != MP_ABI) {
log("ABI 不匹配: 插件 %d, 宿主 %d —— 请重新编译插件", d->abi, MP_ABI);
dlclose(dso); return -1;
}
if (d->size < offsetof(mp_plugin_t, flags)) { log("描述符被截断"); ... }
if (!d->init) { log("缺 init"); ... }
/* ⑤ 需要 RTLD_GLOBAL?重开一次(flags 在 .so 里,不读进来不知道) */
if ((d->flags & MP_FLAG_GLOBAL_SYMBOLS) && !from_self) {
dlclose(dso);
dso = dlopen(path, RTLD_NOW | RTLD_GLOBAL);
d = (mp_plugin_t *)dlsym(dso, sym);
}
module_t *m = calloc(1, sizeof(*m));
m->key = strdup(key); m->dso = dso; m->desc = d;
m->pool = pool_create(); /* ⑥ pool 生命周期 = 模块生命周期 */
m->staging.owner = m;
/* ⑦ 阶段 1:插件申报(只写暂存区,不进全局表) */
if (d->init(&HOST, m->pool, &m->staging) != 0) { /* 清理并返回 */ }
/* ⑧ 阶段 2:宿主发布(门禁、冲突检查、审计都在这一步) */
publish(m);
m->next = R.modules; R.modules = m;
return 0;
}
发布阶段是整个设计最值钱的地方 —— 模块只申报,宿主决定发布:
static int publish(module_t *m){
pthread_mutex_lock(&R.lock);
for (cmd_entry_t *e = m->staging.head; e; e = e->next_in_module) {
/* 门禁:allowlist */
if (!permitted(m->key, e->name)) {
printf(" [SKIP ] '%s' 被 allowlist 拒绝\n", e->name); continue;
}
/* 冲突要报错,不静默覆盖 */
int dup = -1;
for (int i=0;i<R.n;i++) if (!strcmp(R.tab[i].name, e->name)) { dup = i; break; }
if (dup >= 0) {
printf(" [ERROR] 命令 '%s' 已被 %s 注册,%s 的注册被拒绝\n",
e->name, R.tab[dup].e->owner->key, m->key);
continue;
}
R.tab[R.n].name = e->name; R.tab[R.n].e = e; R.n++;
}
pthread_mutex_unlock(&R.lock);
}
注意 declare_cmd 里这一句 —— 字符串必须复制到宿主的 pool,否则卸载后注册表里全是野指针:
static int h_declare_cmd(mp_registry_t *reg, const char *name, const char *desc,
mp_cmd_fn fn, void *ctx){
mp_pool_t *pool = reg->owner->pool;
cmd_entry_t *e = pool_alloc(pool, sizeof(*e));
e->name = pool_strdup(pool, name); /* ← 不存 .so 里的指针 */
e->desc = pool_strdup(pool, desc);
e->fn = fn; e->ctx = ctx; e->owner = reg->owner;
e->next_in_module = reg->head; reg->head = e;
return 0;
}
派发不把裸指针交给调用方,并且加引用计数:
int mp_exec(const char *name, const char *arg, char *out, size_t outlen){
cmd_entry_t *e = NULL;
pthread_mutex_lock(&R.lock);
for (int i=0;i<R.n;i++) if (!strcmp(R.tab[i].name, name)) { e = R.tab[i].e; break; }
if (e) e->refs++; /* 防止调用期间被卸载 */
pthread_mutex_unlock(&R.lock);
if (!e) { snprintf(out, outlen, "-ERR 未知命令 '%s'", name); return -1; }
int rc = e->fn(e->ctx, arg, out, outlen);
pthread_mutex_lock(&R.lock); e->refs--; pthread_mutex_unlock(&R.lock);
return rc;
}
卸载时只删"确实指向我"的项 —— 这一句是上一篇里 FreeSWITCH 那个"卸载后核心命令永久消失"的 bug 的修法:
for (int i=0;i<R.n;i++)
if (R.tab[i].e == e) { /* 比指针,不比名字 */
R.tab[i] = R.tab[--R.n]; break;
}
一个插件长什么样
#include "plugin_api.h"
#include <stdio.h>
#include <string.h>
#include <ctype.h>
static const mp_host_t *H; /* 保存注入的 host vtable */
static int cmd_hello(void *ctx, const char *arg, char *out, size_t n){
(void)ctx; snprintf(out, n, "+OK hello, %s!", arg && *arg ? arg : "world"); return 0;
}
static int cmd_upper(void *ctx, const char *arg, char *out, size_t n){
(void)ctx; size_t i; snprintf(out, n, "+OK ");
for (i = 0; arg && arg[i] && i + 5 < n; i++)
out[4+i] = (char)toupper((unsigned char)arg[i]);
out[4+i] = 0; return 0;
}
static int init(const mp_host_t *host, mp_pool_t *pool, mp_registry_t *reg){
H = host;
if (host->abi != MP_ABI) return -1; /* 双向检查:插件也验宿主 */
host->log("INFO", "mod_hello: host abi=%d size=%zu", host->abi, host->size);
host->declare_cmd(reg, "hello", "打个招呼", cmd_hello, NULL);
host->declare_cmd(reg, "upper", "转大写", cmd_upper, NULL);
(void)pool; return 0;
}
static void fini(void){ if (H) H->log("INFO", "mod_hello: fini"); }
MP_PLUGIN_DEFINE(mod_hello, "1.0.0", init, fini, MP_FLAG_NONE);
Makefile
CFLAGS = -std=c11 -Wall -Wextra -O2 -I.
PLUGIN_CFLAGS = $(CFLAGS) -fPIC -fvisibility=hidden
UNAME := $(shell uname -s)
ifeq ($(UNAME),Darwin)
PLUGIN_LDFLAGS = -shared -undefined dynamic_lookup
HOST_LDFLAGS =
else
PLUGIN_LDFLAGS = -shared
HOST_LDFLAGS = -rdynamic -ldl # ← Linux 必须 -rdynamic
endif
PLUGINS = mod_hello.so mod_dup.so mod_old.so
all: host $(PLUGINS)
host: main.c loader.c ; $(CC) $(CFLAGS) -o $@ $^ $(HOST_LDFLAGS) -lpthread
%.so: %.c ; $(CC) $(PLUGIN_CFLAGS) $(PLUGIN_LDFLAGS) -o $@ $<
clean: ; rm -f host $(PLUGINS)
实际运行输出
$ make && ./host
>>> load ./mod_hello.so (查符号 'mod_hello_plugin')
符号来自 该 .so
[INFO ] mod_hello: host abi=1 size=112
plugin 'mod_hello' v1.0.0 申报完成
[ADD ] 命令 'upper' (转大写)
[ADD ] 命令 'hello' (打个招呼)
>>> load ./mod_old.so (查符号 'mod_old_plugin')
符号来自 该 .so
[ERROR] ABI 不匹配: 插件 0, 宿主 1 —— 请重新编译插件
>>> load ./mod_dup.so (查符号 'mod_dup_plugin')
plugin 'mod_dup' v0.1 申报完成
[ADD ] 命令 'dup' (我自己的命令)
[ERROR] 命令 'hello' 已被 mod_hello 注册,mod_dup 的注册被拒绝
>>> load ./mod_hello.so (查符号 'mod_hello_plugin')
[ERROR] 模块已加载
=== 注册表(3 条) ===
upper 转大写 [mod_hello]
hello 打个招呼 [mod_hello]
dup 我自己的命令 [mod_dup]
=== 派发 ===
hello walter -> +OK hello, walter!
upper abi guard -> +OK ABI GUARD
dup -> +OK from mod_dup
nope -> -ERR 未知命令 'nope'
>>> unload mod_hello
[DEL ] 命令 'upper'
[DEL ] 命令 'hello'
[INFO ] mod_hello: fini
模块 mod_hello 已卸载(dso 保留映射,不 dlclose)
=== 注册表(1 条) ===
dup 我自己的命令 [mod_dup]
=== 派发(mod_hello 已卸载)===
hello walter -> -ERR 未知命令 'hello'
dup -> +OK from mod_dup ← 别人的命令没被连带删掉
对照上一篇里 FreeSWITCH 的行为:
| FreeSWITCH | 这份参考实现 | |
|---|---|---|
| 名字冲突 | 静默覆盖 | 报错拒绝 |
| 卸载后 | 可能连带删掉别人的注册 | 只删指针相符的项 |
| 重复加载 | 拒绝 | 拒绝 |
| ABI 不匹配 | 拒绝 | 拒绝 |
每个插件的导出面积
$ for f in mod_hello.so mod_dup.so mod_old.so; do printf "%-14s " $f; nm -gU $f; done
mod_hello.so 0000000000008010 D _mod_hello_plugin
mod_dup.so 0000000000008008 D _mod_dup_plugin
mod_old.so 0000000000004000 D _mod_old_plugin
一个符号。 -fvisibility=hidden + 入口点显式 default 的效果。对照 OpenSSL 的 legacy provider —— 209 个导出符号,DES_encrypt1、MD5_Init、RC4 全在外面,那些本该是内部实现。
allowlist 门禁(两阶段的红利)
因为发布权在宿主手里,加门禁只需要改一个地方,对所有插件(含第三方)自动生效:
=== 开启 allowlist:只放行 mod_hello.hello ===
>>> load ./mod_hello.so
plugin 'mod_hello' v1.0.0 申报完成
[SKIP ] 'upper' 被 allowlist 拒绝
[ADD ] 命令 'hello' (打个招呼)
>>> load ./mod_dup.so
plugin 'mod_dup' v0.1 申报完成
[SKIP ] 'dup' 被 allowlist 拒绝
[SKIP ] 'hello' 被 allowlist 拒绝
=== 注册表(1 条) ===
hello 打个招呼 [mod_hello]
如果当初让插件自己写全局表,这个 feature 就得改所有插件。
Frequent Mistakes:10 个坑,每个都有崩溃现场
#1 用了 RTLD_LAZY
已在上文实测:RTLD_NOW 报清晰错误,RTLD_LAZY 延迟到调用时 SIGSEGV。改成 RTLD_NOW,一行的事。
#2 -fvisibility=hidden 把入口点也藏了
我自己在写这篇的实验时就踩了:
$ nm -gU p_a.so
(空 —— plugin_init 被 -fvisibility=hidden 藏起来了,dlsym 找不到)
$ nm -gU p_a2.so # 加了 __attribute__((visibility("default")))
0000000000000558 T _plugin_init
规矩:全局 hidden + 入口点显式 default。 用 MP_PLUGIN_DEFINE 这种宏把 visibility("default") 封进去,人就不会忘。
#3 Linux 上宿主忘了 -rdynamic
症状:插件在 macOS 上加载正常,搬到 Linux 报 undefined symbol: host_xxx。
原因:ELF 可执行文件默认不把符号放进 .dynsym,插件的未定义符号无处解析。
排查:
nm -D --dynamic host | grep host_report # Linux:空 = 忘了 -rdynamic
#4 C++ 忘了 extern "C" —— 但没你想的那么简单
大家都知道要加 extern "C"。但具体哪些符号会被 mangle,规则比想象中细。实测:
$ c++ -shared -fPIC -o t_mangle.so t_mangle.cpp
$ nm -gU t_mangle.so
0000000000000598 T __Z11plugin_initv ← 函数:被 mangle 了!
0000000000008008 D _plugin_descriptor ← 全局命名空间的变量:没有 mangle
0000000000008018 D _plugin_descriptor_ok
00000000000005b8 T _plugin_init_ok
再细一层:
$ nm -gU t_mangle2.so | while read a b c; do printf "%-32s -> %s\n" "$c" "$(echo $c|c++filt)"; done
__ZN6Holder8cls_descE -> Holder::cls_desc ← 类静态成员:mangle
__ZN6myplug7ns_descE -> myplug::ns_desc ← namespace 里的变量:mangle
_global_desc -> _global_desc ← 全局命名空间变量:不 mangle
结论(Itanium ABI):
| 符号 | 是否 mangle | dlsym 能找到吗 |
|---|---|---|
| 全局命名空间的函数 | ✅ mangle | ❌ |
| 全局命名空间的变量 | ❌ 不 mangle | ✅ |
| namespace 里的变量 | ✅ mangle | ❌ |
| 类静态成员 | ✅ mangle | ❌ |
static 的任何东西 |
不导出 | ❌ |
这就是 Pattern B(数据符号)对 C++ 更友好的原因 —— 一个放在全局命名空间的描述符变量,即使忘了 extern "C" 也能被 dlsym 找到。
但别指望这个运气。规矩还是:入口一律包在 extern "C" 里,因为你不知道下一个人会不会把它挪进 namespace。
#5 用 dlsym 的返回值判断错误
三个相关的误解,实测:
=== 坑 1:dlerror() 读一次就清空 ===
第一次 dlerror(): dlsym(0x74d9ca40, no_such_symbol_1): symbol not found
第二次 dlerror(): (NULL —— 已经被清空了)
=== 对照:dlsym 返回的是“变量的地址”,不是变量的值 ===
dlsym 给的地址 = 0x102734008,*地址 = 0x0(变量值确实是 0)
=== dlsym 返回 NULL 的真实合法场景:未解析的弱符号 ===
&maybe_missing 直接取址 = 0x0
dlsym(RTLD_DEFAULT,"maybe_missing") = 0x0
dlerror() = dlsym(RTLD_DEFAULT, maybe_missing): symbol not found
要点:
dlsym返回的是符号的地址。 查一个函数指针变量,你拿到的是"那个变量的地址",不是它存的函数指针 —— 要再解引用一层。这是最常见的实际错误。dlerror()读一次即清空。 正确姿势是"先清空、再调用、再判断":
dlerror(); /* 清掉残留 */
void *p = dlsym(h, name);
char *e = dlerror(); /* 用它判错,不看 p */
if (e) { /* 真的找不到 */ }
- ⚠️ 关于"上一次的错误会污染下一次判断":这条在 glibc 上成立(成功的
dlsym不清错误字符串),但我在 macOS 上没复现,dyld 会在成功调用后清掉。所以"先清空"这个习惯还是要有。
#6 把 .so 里的指针存进宿主的数据结构
已在上文实测 SIGSEGV。最阴的是它看起来无害 —— 只是个 const char *name。
规矩:跨越 .so 边界存下来的任何东西,字符串一律 strdup/pstrdup 到宿主内存。
#7 插件起了 detached 线程
已在上文实测 SIGSEGV。
规矩:插件不许自己 detach 线程。 要么把线程句柄交给宿主管理(宿主在卸载时 join),要么由宿主提供 host->spawn_worker(),线程的生命周期归宿主。FreeSWITCH 的 runtime 回调就是这个思路 —— 线程由 core 起、core 管、core 停。
#8 静态库被链了两份 → 两套全局状态
这个坑很隐蔽,杀伤力很大。宿主和插件都静态链接了 libcounter.a(里面有个 static int counter):
宿主和插件都静态链接了 libcounter.a(同一份 static int counter)
[host] counter_bump() = 1 (&counter = 0x1046b0000)
[host] counter_bump() = 2 (&counter = 0x1046b0000)
[plugin] counter_bump() = 1 (&counter = 0x1046e0010) ← 另一个地址!
[plugin] counter_bump() = 2 (&counter = 0x1046e0010)
[host] counter_bump() = 3 (&counter = 0x1046b0000)
两个地址、两个独立的计数器。 现实中这会表现为:
- 插件调
SSL_library_init(),但用的是自己那份 OpenSSL 状态,宿主设置的 CA 路径全失效 - 日志库有两份配置,插件的日志级别怎么调都不生效
- 某个"单例"变成了两个
- 内存池的统计数字对不上
规矩:宿主和插件共享的库必须是动态库,不能静态链。 或者反过来:插件完全不链,全部能力走 vtable(Redis 那条路)。
排查:
nm -gU plugin.so | grep <可疑符号> # 插件是不是自带了一份?
#9 不该用 RTLD_GLOBAL 却用了
已在上文用 -flat_namespace 实测符号劫持。默认 RTLD_LOCAL,需要 GLOBAL 的插件自己在 flags 里声明。
#10 名字冲突静默覆盖
上一篇实测过 FreeSWITCH 的行为:我写个模块注册同名 API uptime,热加载后 uptime 返回 HIJACKED by mod_walter;卸载我的模块后,uptime 彻底消失,而 mod_commands 是 NOUNLOAD 的,只能重启进程救回来。
三种修法,按推荐度排:
- 插入时检测重复,报错拒绝(本文参考实现的做法)
- 注册表存链表 + 显式 qualifier("取
mod_x的foo"),让覆盖变成调用方的显式决定 —— FreeSWITCH 的 codec/file 注册表就是这样 - 卸载时只删"确实指向我"的项(比指针,不比名字)—— 这条必须做,不管你选 1 还是 2
Best Practice:12 条
加载
- 入口用导出的数据符号(Pattern B),第一个字段是 ABI 版本。顺手也避开了
void*→函数指针的 UB,还对 C++ 更宽容。 - 符号名由文件名推导(Pattern D),多插件共存不撞名。把这个规则写进文档,并在构建里加检查。
RTLD_NOW,永远不要RTLD_LAZY。RTLD_LOCAL默认,需要 GLOBAL 的插件在 flags 里自己声明,加载器读到之后重开一次。- 先在宿主自身
dlsym,再dlopen—— 静态编入和动态加载走同一套协议,切换只需改构建。 -fvisibility=hidden+ 入口点显式default,用宏封装避免遗漏。nm -gU的输出应该是个位数。
注入
- 核心能力走 vtable(带
abi+size),工具函数才走未定义符号。Linux 宿主别忘-rdynamic。 - pool / logger / config 从
init参数注入,不让插件摸全局单例。插件依赖库用$ORIGIN/@loader_path,不用环境变量。 - 宿主和插件共享的库一律动态链,不要静态链进两边。
注册
- 两阶段:插件申报 → 宿主发布。 门禁、审计、冲突检查、启动顺序编排全靠这一层。这是最值钱的一条。
- 名字冲突报错,不静默覆盖;卸载时只删指针相符的项;注册表里的字符串一律复制到宿主内存。
运行时
- 对外只给
execute(name, args),不给get(name)返回裸指针。 C 没有 RAII,配对释放的责任放在调用方就一定会漏。要支持卸载则加引用计数 + 读写锁 + 超时策略;不支持卸载也行,但要写进文档并考虑RTLD_NODELETE。
Checklist
协议设计
- [ ] 入口是数据符号,第一个字段是 ABI 版本
- [ ] 描述符有
size字段,VTable 有reserved/padding - [ ] 符号名推导规则已写文档,并有构建期检查
- [ ] 入口包在
extern "C"里(即使现在是纯 C 项目) - [ ] 宿主 vtable 也带
abi+size,插件也校验宿主
加载器
- [ ]
RTLD_NOW | RTLD_LOCAL - [ ]
dlerror()使用"先清空、再调用、再判断"三步 - [ ] 门禁顺序:符号存在 → abi → size → 必填字段 → 才调
init - [ ] 先查宿主自身,再
dlopen - [ ] 重复加载被拒绝
- [ ]
GLOBAL_SYMBOLSflag 支持重开
构建
- [ ] 插件:
-shared -fPIC -fvisibility=hidden - [ ] macOS 插件:
-undefined dynamic_lookup - [ ] Linux 宿主:
-rdynamic - [ ] rpath 用
$ORIGIN/@loader_path - [ ]
nm -gU plugin.so只有入口符号 - [ ] 没有任何库被同时静态链进宿主和插件
注册表
- [ ] 两阶段:暂存区申报 → 宿主发布
- [ ] 名字冲突报错
- [ ] 每张表明确是"单绑定"还是"多提供者"(后者要有 qualifier)
- [ ] 注册表里的字符串已复制到宿主内存
- [ ] 观测接口(
show plugins)暴露和内部一致的类型维度
生命周期
- [ ] 对外只暴露
execute(),不暴露裸接口指针 - [ ] 插件不许自己 detach 线程
- [ ] 卸载时只删指针相符的项
- [ ] 卸载策略已明确:refcount+rwlock+超时 /
RTLD_NODELETE/ 不支持卸载 - [ ] 卸载后跑一遍 ASan + 压测,确认没有 use-after-unmap
排障命令速查
# 这个 .so 需要谁?(排查 undefined symbol)
nm -u plugin.so | grep -v '^_\?dyld' # macOS
nm -D --undefined-only plugin.so # Linux
# 这个 .so 给出什么?(排查 dlsym 找不到、导出面积过大)
nm -gU plugin.so # macOS
nm -D --defined-only plugin.so # Linux
nm -gU plugin.so | c++filt # C++ 看 demangle 后的名字
# 宿主到底导出了哪些符号(排查 -rdynamic 漏加)
nm -D --dynamic host | grep ' T ' # Linux
# 依赖链
otool -L plugin.so # macOS
ldd plugin.so # Linux
# 加载过程发生了什么(最有用的一招)
DYLD_PRINT_LIBRARIES=1 ./host # macOS
LD_DEBUG=libs,bindings,symbols ./host # Linux
# 符号被谁劫持了(排查 RTLD_GLOBAL 污染,几乎是唯一手段)
LD_DEBUG=bindings ./host 2>&1 | grep shared_helper # Linux
# 运行期反查地址属于哪个 .so
# 代码里用 dladdr(),见上面“现场一”的例子
总结:三句话
dlopen只负责把代码映射进地址空间。 入口协议、回调机制、ABI 守卫、卸载安全,四件事全归你。传统方法的全部工程量都在这里。- 默认方案是明确的:Pattern B 数据符号入口 + Pattern D 文件名推导 +
RTLD_NOW|RTLD_LOCAL+ vtable 注入核心能力 + 三层 ABI 守卫 + 两阶段注册 + 不支持热卸载。上面那份参考实现就是这套。 - 但先问一句:静态注册表行不行? 如果第三方不需要在不重编你的宿主的前提下扩展你,那
dlopen带来的全是成本。
想看这套机制在真实的大型项目里长成什么样、以及它二十年积累下来的疤,可以接着读上一篇:把 FreeSWITCH 的模块机制拆到底。
你的项目里插件系统是怎么设计的?入口用的是函数还是数据符号?ABI 出过事吗 —— 我最想听的是"升级宿主之后所有插件挂了"那一类故事,那种坑往往比教科书更有信息量。