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 怎么不崩;④ 卸载怎么办。传统方法的全部工程量都在这四个问题上。
  • Whatdlopen/dlsym/dlclose/dlerror 加两个扩展,以及一张"它管什么、不管什么"的对照表。
  • Why:先给一份该不该用插件化的决策清单,以及五种替代方案(静态注册 / dlopen / 子进程+IPC / 嵌入脚本 / WASM)的对比。我的立场是:大多数团队想要的其实是静态注册,不是 dlopen。
  • How:四个问题的传统答案。入口点五种 pattern(含各自的真实使用者:CPython、PAM、httpd、nginx、Redis、OpenSSL 3、QEMU);回调宿主两派做法;ABI 三层守卫;卸载的三个最低配。
  • Example:一份 357 行、可编译可运行的完整参考实现(plugin_api.h 51 行 + loader.c 225 行 + 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

全部满足才划算:

  1. 第三方要在不改你代码、不重编你的宿主的前提下扩展功能。这是唯一的硬需求。
  2. 扩展点数量多、变化频繁,编进主程序会让构建时间和二进制体积失控(FreeSWITCH 有 90+ 模块)。
  3. 需要按部署裁剪:同一份宿主,客户 A 装 3 个模块,客户 B 装 30 个。
  4. 需要热加载(不重启加载新功能)。注意:热卸载比热加载难一个数量级,别混为一谈。
  5. 性能上必须同进程:音视频、数据平面这类每秒几万次调用的场景,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_initRTLD_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 是我见过最讲究的:它不只有版本号,还带 funcmaxargsnamedatalenfloat8byval 这些编译期配置项 —— 因为配置不一致会导致结构体布局不同,光版本号对上也没用。

代价:粒度太粗,加一个字段就全生态重编。所以要有第二层。

第二层:结构体尾部留白

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_NODELETEdlclose 不真正 unmap,所以读得到。这是一个诚实的中间态:逻辑上卸载,物理上不还内存。

现场二:detached 线程

插件起了个线程,pthread_detach 之后宿主没法 join:

    [plugin] 起了一个 detached 线程,代码位于本 .so
  dlclose(线程还在跑)...
  dlclose 返回了,睡 300ms 看线程还能不能活
(exit=139  139=SIGSEGV)

线程的代码页被 unmap,下一次时钟中断回来就死。这是 dlclose 的头号杀手。

残留指向 .so 的东西,完整清单

  1. 函数指针(注册表里没清干净的)
  2. 字符串/常量指针(现场一,最阴,因为看起来只是个 const char *
  3. 还在跑的线程(现场二,dlclose 不会等它)
  4. atexit 注册的回调 —— 见下面的辟谣
  5. TLS destructor
  6. C++ 的 vtable 指针 / typeinfo / 异常表
  7. 别的库持有的回调(你给 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 上 SIGSEGVRTLD_NOWdlopen 那一刻就给你一句人话。

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.sog2.so 各自定义了 shared_helperRTLD_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_encrypt1MD5_InitRC4 全在外面,那些本该是内部实现。

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 + 入口点显式 defaultMP_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

要点:

  1. dlsym 返回的是符号的地址。 查一个函数指针变量,你拿到的是"那个变量的地址",不是它存的函数指针 —— 要再解引用一层。这是最常见的实际错误。
  2. dlerror() 读一次即清空。 正确姿势是"先清空、再调用、再判断":
dlerror();                       /* 清掉残留 */
void *p = dlsym(h, name);
char *e = dlerror();             /* 用它判错,不看 p */
if (e) { /* 真的找不到 */ }
  1. ⚠️ 关于"上一次的错误会污染下一次判断":这条在 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_commandsNOUNLOAD 的,只能重启进程救回来。

三种修法,按推荐度排:

  1. 插入时检测重复,报错拒绝(本文参考实现的做法)
  2. 注册表存链表 + 显式 qualifier("取 mod_xfoo"),让覆盖变成调用方的显式决定 —— FreeSWITCH 的 codec/file 注册表就是这样
  3. 卸载时只删"确实指向我"的项(比指针,不比名字)—— 这条必须做,不管你选 1 还是 2

Best Practice:12 条

加载

  1. 入口用导出的数据符号(Pattern B),第一个字段是 ABI 版本。顺手也避开了 void*→函数指针的 UB,还对 C++ 更宽容。
  2. 符号名由文件名推导(Pattern D),多插件共存不撞名。把这个规则写进文档,并在构建里加检查。
  3. RTLD_NOW,永远不要 RTLD_LAZY
  4. RTLD_LOCAL 默认,需要 GLOBAL 的插件在 flags 里自己声明,加载器读到之后重开一次。
  5. 先在宿主自身 dlsym,再 dlopen —— 静态编入和动态加载走同一套协议,切换只需改构建。
  6. -fvisibility=hidden + 入口点显式 default,用宏封装避免遗漏。nm -gU 的输出应该是个位数。

注入

  1. 核心能力走 vtable(带 abi + size),工具函数才走未定义符号。Linux 宿主别忘 -rdynamic
  2. pool / logger / config 从 init 参数注入,不让插件摸全局单例。插件依赖库用 $ORIGIN / @loader_path,不用环境变量。
  3. 宿主和插件共享的库一律动态链,不要静态链进两边。

注册

  1. 两阶段:插件申报 → 宿主发布。 门禁、审计、冲突检查、启动顺序编排全靠这一层。这是最值钱的一条。
  2. 名字冲突报错,不静默覆盖;卸载时只删指针相符的项;注册表里的字符串一律复制到宿主内存。

运行时

  1. 对外只给 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_SYMBOLS flag 支持重开

构建

  • [ ] 插件:-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(),见上面“现场一”的例子

总结:三句话

  1. dlopen 只负责把代码映射进地址空间。 入口协议、回调机制、ABI 守卫、卸载安全,四件事全归你。传统方法的全部工程量都在这里。
  2. 默认方案是明确的:Pattern B 数据符号入口 + Pattern D 文件名推导 + RTLD_NOW|RTLD_LOCAL + vtable 注入核心能力 + 三层 ABI 守卫 + 两阶段注册 + 不支持热卸载。上面那份参考实现就是这套。
  3. 但先问一句:静态注册表行不行? 如果第三方不需要在不重编你的宿主的前提下扩展你,那 dlopen 带来的全是成本。

想看这套机制在真实的大型项目里长成什么样、以及它二十年积累下来的疤,可以接着读上一篇:把 FreeSWITCH 的模块机制拆到底


你的项目里插件系统是怎么设计的?入口用的是函数还是数据符号?ABI 出过事吗 —— 我最想听的是"升级宿主之后所有插件挂了"那一类故事,那种坑往往比教科书更有信息量。