C 语言的动态加载、依赖注入与服务注册:把 FreeSWITCH 的模块机制拆到底
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 |
大纲
展开看看
- 核心问题:C 没有反射、没有运行时类型发现。那
<action application="answer"/>这一行 XML,凭什么能在运行时找到answer_function()这个 C 函数? - 核心结论:FreeSWITCH 手搓了一套 Manual Reflection:
dlopen+ 一个约定名字的导出数据符号 + 模块自己上报能力 + Core 统一发布到全局注册表 + 字符串查表拿函数指针。它不需要 IDL,因为函数指针本身就是接口,而 header 就是那份 IDL。 - 四层结构(本文主干):① 动态加载(谁来找到
.so、找到入口);② 依赖注入(Core 怎么把能力交给模块,模块怎么反向依赖 Core);③ 服务注册(模块上报 vs Core 发布,两个阶段);④ 运行时查找与派发(查表、加锁、调用)。 - 最反直觉的一处:Loader 先在主程序/
libfreeswitch里找符号,找不到才dlopen那个.so。因为CORE_PCM_MODULE、CORE_SOFTTIMER_MODULE这些"模块"根本不是.so,它们编在 core 里面 —— 同一套注册协议同时服务静态和动态模块。实测nm能在libfreeswitch.dylib里看到 4 个CORE_*_MODULE_module_interface。 - 注册不是模块干的:模块的 load 函数只往自己的私有链表挂接口;真正写进全局 hash 的是 Core 的
switch_loadable_module_process()。实测日志里Successfully Loaded [mod_dptools]打在Adding Application 'answer'前面,就是这个两阶段的铁证。这也是 interface allowlist 门禁能生效的位置。 SWITCH_ADD_APP逐行拆解:七个参数各是什么、回调签名为什么是void、六个字段填进哪个结构体、for(;;){...break;}的用意,以及 7 个SAF_*flag 各自的实际检查点(SAF_SUPPORT_NOMEDIA不设,core 会先替你pre_answer)。附SWITCH_ADD_APP/SWITCH_ADD_API/SWITCH_ADD_CHAT_APP三兄弟对照表。- 两种注册表形态:application / api 是平坦 hash,后来者覆盖;codec / file / database 是链表 + modname 消歧。这正好对应现代 DI 的"单绑定"与"multibinding + qualifier"。
- 三个实测出来的坑:① 我写了个模块注册同名 API
uptime,热加载后uptime返回HIJACKED by mod_walter;② 卸载我的模块后,uptime彻底消失(-ERR uptime Command not found!),而 mod_commands 是NOUNLOAD的,重启进程才能救回来;③show application把 chat application 混显成 application,观测层把类型信息丢了。 - 动手环节:50 行写一个模块,编译成
.so,load进正在跑的 FreeSWITCH,全过程实测输出都贴出来了。含 ABI 版本守卫和"符号名必须等于文件名"两个失败实验。 - 收尾:想在自己的 C 项目里抄这套设计,一张 12 条的 checklist。
你去读 FreeSWITCH 的 dialplan,会看到这么一行:
<action application="answer"/>
字符串 "answer"。然后 FreeSWITCH 就调用了 mod_dptools.so 里的 answer_function()。
停一下,这件事在 C 里其实挺离谱的。Java 有反射,Python 有 getattr,Go 有 reflect,C 有什么?C 编译完之后,函数名连符号表都不一定留得下来。标准 C 甚至不允许你把 void * 合法地转成函数指针(dlsym 返回 void * 这事本身就是 POSIX 对 ISO C 的一次公开违规)。
所以 FreeSWITCH 必须自己造一套反射。它造得非常朴素,也非常有效 —— 朴素到我认为任何写 C/C++ 长期项目的人都该读一遍:这是一套用 dlopen + 函数指针 + 内存池 + 一个约定俗成的符号名拼出来的 Plugin Framework、Service Registry 和 Dependency Injection 容器。
本文把这条链路拆成四层来讲。代码引用来自 FreeSWITCH 1.11.3-dev(git ea429c9),实测输出来自 macOS 上运行的实例。
全景:五步,两次解耦
先把地图摊开:
① 发现 modules.conf.xml → "mod_dptools"
│
② 加载 dlopen → dlsym("mod_dptools_module_interface")
│ ↓
│ { api_version, load, shutdown, runtime, flags }
│
③ 上报 mod_dptools_load(&module_interface, pool)
│ 模块把 139 个 app / 6 个 api / 4 个 endpoint
│ 挂到“自己的”私有链表上
│
④ 发布 switch_loadable_module_process()
│ Core 遍历私有链表,插入全局 hash
│ (门禁在这一步)
│
⑤ 派发 "answer" → hash lookup → 函数指针 → answer_function(session, data)
这张图里有两次解耦,是整个设计的灵魂:
- ③ 和 ④ 之间:模块从来不碰全局注册表。它只填自己的结构体。发布与否,Core 说了算。
- ④ 和 ⑤ 之间:消费者只知道
"answer",不知道mod_dptools。dialplan 里没有任何一处写着模块名。
第 1 点让你能加门禁、能审计、能在 sqldb 没起来的时候把事件缓存住;第 2 点让 dialplan 完全不依赖模块布局。现代 DI 容器承诺的,无非也是这两件事。
下面逐层拆。
第一层:动态加载 —— dlopen 只是开始,难的是“找入口”
SWITCH_MODULE_DEFINITION 到底展开成什么
一个 FreeSWITCH 模块的全部"元数据"就一行(mod_dptools.c:44):
SWITCH_MODULE_DEFINITION(mod_dptools, mod_dptools_load, mod_dptools_shutdown, NULL);
宏的真身在 switch_types.h:2640:
#define SWITCH_MODULE_DEFINITION_EX(name, load, shutdown, runtime, flags) \
static const char modname[] = #name ; \
SWITCH_MOD_DECLARE_DATA switch_loadable_module_function_table_t name##_module_interface = { \
SWITCH_API_VERSION, \
load, \
shutdown, \
runtime, \
flags \
}
它只干两件事:
- 定义一个
static const char modname[](后面 load 函数会偷偷用到,见第二层); - 导出一个数据符号
mod_dptools_module_interface,类型是:
typedef struct switch_loadable_module_function_table {
int switch_api_version;
switch_module_load_t load;
switch_module_shutdown_t shutdown;
switch_module_runtime_t runtime;
switch_module_flag_t flags;
} switch_loadable_module_function_table_t;
注意 SWITCH_MOD_DECLARE_DATA —— 在 GCC/Clang 下它是 __attribute__((visibility("default")))(switch_platform.h:192),Windows 下是 __declspec(dllexport)。也就是说,模块的公开 ABI 面积被刻意压到了一个符号。
实测一下 mod_dptools.so 到底导出了什么:
$ nm -gU ~/fs/lib/freeswitch/mod/mod_dptools.so
0000000000002c34 T _att_thread_run
000000000000483c T _call_monitor_thread
000000000001c740 S _error_endpoint_interface
000000000001c1a0 D _error_io_routines
000000000001c748 S _group_endpoint_interface
000000000001c260 D _group_io_routines
00000000000006a8 T _mod_dptools_load
000000000001c000 D _mod_dptools_module_interface ← 唯一真正必需的
0000000000002bac T _mod_dptools_shutdown
0000000000004610 T _page_thread
000000000001c758 S _pickup_endpoint_interface
000000000001c0e8 D _pickup_event_handlers
000000000001c028 D _pickup_io_routines
000000000001c750 S _user_endpoint_interface
000000000001c320 D _user_io_routines
一共 15 个全局符号(那几个 endpoint_interface/io_routines 是漏出来的实现细节,static 忘加了)。Loader 只关心其中一个。六千八百行的模块,对外的契约面积是一个结构体。
顺便解掉开头那个 ISO C 的小尴尬:因为入口是数据符号而不是函数符号,loader 用的是 switch_dso_data_sym()(void * → void *,完全合法),全程不需要把 void * 转成函数指针。函数指针都藏在结构体字段里,由编译器保证类型。switch_dso.c 里那个需要 (switch_dso_func_t)(intptr_t) 双重强转的 switch_dso_func_sym(),全树只有 mod_java 拿 JNI_CreateJavaVM 时用过一次。"入口用数据符号"不只是为了塞版本号和 flags,它顺手把这个 UB 边缘的转换也绕掉了。
陷阱一:符号名 = 文件名 + _module_interface
Loader 怎么知道要找哪个符号?看 switch_loadable_module.c:1718:
struct_name = switch_core_sprintf(pool, "%s_module_interface", filename);
filename 是去掉扩展名的文件名,不是宏里写的 name。这两个必须一致,否则模块加载失败。我把 mod_walter.so 复制成 mod_renamed.so 试了一下:
freeswitch@fs> load mod_renamed
-ERR [module load file routine returned an error]
# 日志:
[CRIT] switch_loadable_module.c:1815 Error Loading module .../mod_renamed.so
**dlsym(0x8251ea80, mod_renamed_module_interface): symbol not found**
这就是为什么 FreeSWITCH 模块的文件名从来不能随便改。它不是风格约定,是符号查找协议。
陷阱二:switch_api_version 是穷人版 ABI 检查
SWITCH_API_VERSION 目前是 5(switch_types.h:2604)。Loader 拿到结构体第一件事就是(switch_loadable_module.c:1757):
if (interface_struct_handle && interface_struct_handle->switch_api_version != SWITCH_API_VERSION) {
err = "Trying to load an out of date module, please rebuild the module.";
break;
}
我把自己模块的表手写成 api_version = 4,实测:
freeswitch@fs> load mod_walter2
-ERR [module load file routine returned an error]
# 日志:
[CRIT] Error Loading module .../mod_walter2.so
**Trying to load an out of date module, please rebuild the module.**
这个设计很土,但它解决的问题真实存在:In-process plugin 共享同一个地址空间和同一套结构体布局,一旦 header 变了而模块没重编,你得到的不是编译错误,而是运行时随机内存踩踏。 把版本号放在结构体第一个字段,是能想到的最便宜的保险。
代价也很明显:粒度太粗。SWITCH_API_VERSION 只有一个,任何一个结构体加字段都得全体重编。所以 FreeSWITCH 又加了第二道保险 —— 后面讲 padding[10]。
最反直觉的一处:先在主程序里找符号
这段是我读这个 loader 时最意外的(switch_loadable_module.c:1721):
#ifdef WIN32
dso = switch_dso_open("FreeSwitch.dll", load_global, &derr);
#elif defined (MACOSX) || defined(DARWIN)
{
char *lib_path = switch_mprintf("%s/libfreeswitch.dylib", SWITCH_GLOBAL_dirs.lib_dir);
dso = switch_dso_open(lib_path, load_global, &derr);
switch_safe_free(lib_path);
}
#else
dso = switch_dso_open(NULL, load_global, &derr); /* dlopen(NULL) = 主程序全局作用域 */
#endif
if (!derr && dso) {
interface_struct_handle = switch_dso_data_sym(dso, struct_name, &derr);
}
...
if (!interface_struct_handle) {
if (dso) switch_dso_destroy(&dso);
dso = switch_dso_open(path, load_global, &derr); /* 才轮到真正的 .so */
}
它先在 core 自己身上找这个符号,找不到才去开 .so。为什么?
因为有些"模块"根本不是 .so。src/switch_pcm.c:42 里有这么一行:
SWITCH_MODULE_DEFINITION(CORE_PCM_MODULE, core_pcm_load, core_pcm_shutdown, NULL);
这是编在 libfreeswitch 里面的。启动时 core 会"加载"它(switch_loadable_module.c:2340):
switch_loadable_module_load_module_ex("", "CORE_SOFTTIMER_MODULE", ...);
switch_loadable_module_load_module_ex("", "CORE_PCM_MODULE", ...);
switch_loadable_module_load_module_ex("", "CORE_SPEEX_MODULE", ...);
目录是空串。于是走的正是上面那个"先在自己身上找"的分支。
我写了个 30 行的程序把这个过程复现出来:
/* peek.c —— 手动重放 FreeSWITCH loader 的符号查找 */
typedef struct { int api_version; void *load, *shutdown, *runtime; uint32_t flags; } mod_table_t;
int main(int argc, char **argv) {
void *core = dlopen(argv[1], RTLD_NOW | RTLD_GLOBAL); /* libfreeswitch.dylib */
peek(core, "CORE_PCM_MODULE_module_interface");
peek(core, "CORE_SOFTTIMER_MODULE_module_interface");
peek(core, "mod_dptools_module_interface");
void *m = dlopen(argv[2], RTLD_NOW | RTLD_LOCAL); /* mod_dptools.so */
peek(m, "mod_dptools_module_interface");
}
实测输出:
$ ./peek ~/fs/lib/libfreeswitch.dylib ~/fs/lib/freeswitch/mod/mod_dptools.so
-- symbols found inside libfreeswitch itself --
CORE_PCM_MODULE_module_interface api=5 load=0x1096f97b8 shutdown=0x1096faf88 runtime=0x0 flags=0x0
CORE_SOFTTIMER_MODULE_module_interface api=5 load=0x1096f5c24 shutdown=0x1096f5db0 runtime=0x1096f5ef4 flags=0x0
mod_dptools_module_interface NOT FOUND (symbol not found)
-- now dlopen mod_dptools.so --
mod_dptools_module_interface api=5 load=0x1095c46a8 shutdown=0x1095c6bac runtime=0x0 flags=0x0
和 loader 的分支逐字对应:core 里的三个 CORE_* 找得到,mod_dptools 找不到 → 落到 dlopen 那条路。
顺手也能验证 nm:
$ nm -gU ~/fs/lib/libfreeswitch.dylib | grep MODULE_module_interface
00000000002f9f88 D _CORE_PCM_MODULE_module_interface
00000000002f9f58 D _CORE_SOFTTIMER_MODULE_module_interface
00000000002f9fb0 D _CORE_SPEEX_MODULE_module_interface
00000000002fa090 D _CORE_VPX_MODULE_module_interface
这是很漂亮的设计:静态编入和动态加载走同一套注册协议、同一套生命周期、同一套注册表。你想把某个模块编进 core?改构建,代码一行不动。上面输出里还能顺手读出:CORE_SOFTTIMER 有 runtime 函数(非 0),会拿到一个独立线程;CORE_PCM 和 mod_dptools 是 NULL,纯被动。
RTLD_LOCAL 还是 RTLD_GLOBAL:为什么要重开一次
switch_dso.c 的 Unix 实现很短:
switch_dso_lib_t switch_dso_open(const char *path, int global, char **err)
{
void *lib;
if (global) {
lib = dlopen(path, RTLD_NOW | RTLD_GLOBAL);
} else {
lib = dlopen(path, RTLD_NOW | RTLD_LOCAL);
}
...
}
默认 RTLD_LOCAL —— 模块的符号不进全局命名空间,避免模块之间互相污染(想想两个模块各自静态链了不同版本的 zlib)。
但有些模块必须 RTLD_GLOBAL:脚本语言模块(mod_lua、mod_python)要让解释器再去 dlopen 的扩展能找到自己的符号。FreeSWITCH 的处理办法有点憨但很实用(switch_loadable_module.c:1762):
if (!load_global && interface_struct_handle && switch_test_flag(interface_struct_handle, SMODF_GLOBAL_SYMBOLS)) {
load_global = SWITCH_TRUE;
switch_dso_destroy(&dso);
interface_struct_handle = NULL;
dso = switch_dso_open(path, load_global, &derr);
switch_log_printf(..., "Loading module with global namespace at request of module\n");
continue;
}
先用 RTLD_LOCAL 打开,读出 flags,发现模块要求全局,dlclose 掉重新用 RTLD_GLOBAL 打开一次。 因为 flags 存在模块自己身上,不读进来就不知道该怎么打开它 —— 一个鸡生蛋问题,用"开两次"暴力解决。modules.conf.xml 里也可以外部指定 global="true"。
还有一处很小但值得注意:
void switch_dso_destroy(switch_dso_lib_t *lib)
{
if (lib && *lib) {
#ifndef HAVE_FAKE_DLCLOSE
dlclose(*lib);
#endif
*lib = NULL;
}
}
HAVE_FAKE_DLCLOSE —— 在某些平台上,根本不 dlclose。这是 in-process plugin 的通用现实:只要有一个残留的函数指针、一个还没 join 的线程、一个 atexit 注册、一个 TLS destructor 指向被卸载的代码段,dlclose 就是一颗定时炸弹。很多项目最后的结论都是"卸载即泄漏,泄漏比崩溃好"。
第二层:依赖注入 —— 两个方向都在注入
这一层是我认为最值得单独拎出来讲的,因为大部分讲 FreeSWITCH 模块的文章都跳过了。
反向依赖:链接器就是 DI 容器
模块调用 core 的几百个函数,这些函数从哪来?
$ nm -u ~/fs/lib/freeswitch/mod/mod_dptools.so | grep -c switch_
288
$ nm -u ~/fs/lib/freeswitch/mod/mod_dptools.so | grep loadable_module
_switch_loadable_module_create_interface
_switch_loadable_module_create_module_interface
_switch_loadable_module_get_limit_interface
288 个未定义符号。 模块编译时不链接任何 core 库(macOS 上用 -undefined dynamic_lookup,Linux 上默认就允许),这些符号在 dlopen 那一刻由动态链接器绑定到已经加载的 libfreeswitch。
这就是最原始的依赖注入:模块声明"我需要这些能力"(未定义符号),运行时由容器(动态链接器 + 宿主进程)填上。它甚至满足 DI 的核心属性 —— 模块代码里没有任何一处决定 switch_core_alloc 的实现来自哪里。
顺手一提:这也是为什么第一层那个 ABI 版本检查非可选。288 个符号的调用约定、参数布局、结构体偏移,全靠"你和我编译时看的是同一份 header"这个假设撑着。
正向注入:load 函数的签名就是构造函数
看 switch_types.h:2605:
#define SWITCH_MODULE_LOAD_ARGS (switch_loadable_module_interface_t **module_interface, \
switch_memory_pool_t *pool)
#define SWITCH_MODULE_LOAD_FUNCTION(name) switch_status_t name SWITCH_MODULE_LOAD_ARGS
Core 调用模块时注入两样东西:
module_interface(出参):一个"你要把能力填到这里"的槽位。pool(入参):一块内存池。这才是重点 —— 这个 pool 是switch_loadable_module_load_file()里switch_core_new_memory_pool(&pool)新建的,它的生命周期就是模块的生命周期。模块所有长期存活的对象都挂在它上面,模块卸载时整块销毁。
这叫生命周期注入(lifetime injection)。你不用管谁 free,你只需要用 core 给你的那块池子。(APR 内存池这套思路我在《APR 入门》里单独写过,这里不展开。)
SWITCH_ADD_APP 逐行拆解
一句话:SWITCH_ADD_APP 把一个 C 函数挂成一条 dialplan application。注册之后,XML 里就能写 <action application="名字" data="..."/>。
宏本体(switch_loadable_module.h:401):
#define SWITCH_ADD_APP(app_int, int_name, short_descript, long_descript, funcptr, syntax_string, app_flags) \
for (;;) { \
app_int = (switch_application_interface_t *)switch_loadable_module_create_interface(*module_interface, SWITCH_APPLICATION_INTERFACE); \
app_int->interface_name = int_name; \
app_int->application_function = funcptr; \
app_int->short_desc = short_descript; \
app_int->long_desc = long_descript; \
app_int->syntax = syntax_string; \
app_int->flags = app_flags; \
break; \
}
它只做三件事:
- 调
switch_loadable_module_create_interface(*module_interface, SWITCH_APPLICATION_INTERFACE)—— 在模块自己的内存池上分配一个switch_application_interface_t,挂到该模块的 application 链表尾部。注意不是全局注册表,这个区别是第三层的主题。 - 填六个字段:名字、函数指针、长短描述、语法提示、flags。
- 把新节点的指针写回调用方的
app_int。
七个参数
| 参数 | 类型 / 取值 | 作用 |
|---|---|---|
app_int |
switch_application_interface_t * 局部变量 |
出参,宏给它赋值 |
int_name |
字符串 | 应用名。dialplan 和 show application 里看到的就是它 |
short_descript |
字符串 | 短描述,show application 的第二列 |
long_descript |
字符串 | 长描述 |
funcptr |
switch_application_function_t |
真正执行的函数 |
syntax_string |
字符串 | 参数语法提示,如 "<file>",show application 第三列 |
app_flags |
SAF_* 位掩码 |
行为约束,core 在调用你之前会读它 |
app_int 是出参不是入参。这就是为什么 mod_dptools 里 139 次 SWITCH_ADD_APP 只声明了 1 个 app_interface 变量:
$ grep -c 'SWITCH_ADD_APP(' src/mod/applications/mod_dptools/mod_dptools.c
139
$ grep -c 'switch_application_interface_t \*app_interface' src/mod/applications/mod_dptools/mod_dptools.c
1
每次调用都新建一个节点挂到链表尾,变量只是被反复覆盖,最后指向最新那一项。基本没人会去读它 —— 它存在只是因为 C 的宏没法返回值。
回调长什么样
typedef void (*switch_application_function_t) (switch_core_session_t *, const char *);
#define SWITCH_STANDARD_APP(name) static void name (switch_core_session_t *session, const char *data)
session 是当前这通电话,data 是 dialplan 里 data="..." 的原文 —— 而且是已经展开过 ${变量} 的,展开发生在 switch_core_session_exec() 里(switch_core_session.c:2870 附近的 switch_channel_expand_variables)。
返回值是 void,所以 application 没法用返回值报错。要往外传结果只能设通道变量,惯例是 SWITCH_CURRENT_APPLICATION_RESPONSE_VARIABLE。
填进了哪个结构体
struct switch_application_interface {
const char *interface_name; /* ← 宏填 */
switch_application_function_t application_function; /* ← 宏填 */
const char *long_desc; /* ← 宏填 */
const char *short_desc; /* ← 宏填 */
const char *syntax; /* ← 宏填 */
uint32_t flags; /* ← 宏填 */
switch_thread_rwlock_t *rwlock; /* ↓ create_interface 填 */
int refs;
switch_mutex_t *reflock;
switch_loadable_module_interface_t *parent;
struct switch_application_interface *next;
};
宏只填前六个。后面五个由 create_interface() 负责:parent 指回模块,next 串链表,rwlock/refs/reflock 是第四层要讲的引用计数和读写锁。
SAF_* flags 不是装饰,是 core 的执行前置条件
七个 flag 全在 switch_types.h:1715。我把每个 flag 的实际检查点都定位了一遍:
| flag | 值 | core 在哪检查 | 含义 |
|---|---|---|---|
SAF_NONE |
0 | — | 默认:需要媒体,只在 execute 阶段跑 |
SAF_SUPPORT_NOMEDIA |
1<<0 |
switch_core_session.c:2785 |
无媒体也能跑。不设的话 core 会先替你 pre_answer 把媒体建起来 |
SAF_ROUTING_EXEC |
1<<1 |
mod_dialplan_xml.c:68 |
允许 routing 阶段 inline 执行;不设则报 "This application cannot be executed inline" |
SAF_MEDIA_TAP |
1<<2 |
switch_ivr_async.c:5724 |
媒体旁路类(eavesdrop),强制 nomedia = 0 |
SAF_ZOMBIE_EXEC |
1<<3 |
switch_core_session.c:2753 |
通道已挂断仍允许执行(清理、计费类) |
SAF_NO_LOOPBACK |
1<<4 |
mod_loopback.c:468 |
不能在 loopback 腿上跑,遇到就 bowout |
SAF_SUPPORT_TEXT_ONLY |
1<<5 |
switch_core_session.c:2821 |
纯文本通道(MSRP)可用;不设则挂断并报 SERVICE_NOT_IMPLEMENTED |
按位或组合,upstream 的例子:
SWITCH_ADD_APP(app_interface, "eval", "Do Nothing", "Do Nothing", eval_function, "",
SAF_SUPPORT_NOMEDIA | SAF_ROUTING_EXEC | SAF_ZOMBIE_EXEC);
eval 什么都不做,所以三个约束全放开:不要媒体、routing 阶段能跑、挂断了也能跑。
这张表是"注册表不只存怎么调,还存调之前要做什么"的具体化。 第四层那条完整调用链里的一大段前置校验,读的全是这里填进去的 flags。
为什么是 for (;;) { ... break; }
和常见的 do { ... } while (0) 作用一样:把多条语句合成一条语句,这样 if (x) SWITCH_ADD_APP(...); 展开后不会和 else 粘错,而且后面必须跟分号。for (;;) 跑一次就 break,没有循环语义。我个人还是偏好 do-while(0),更符合大家的肌肉记忆。
三个兄弟宏,别用混
| 宏 | 注册到 | 由谁派发 | 回调签名 |
|---|---|---|---|
SWITCH_ADD_APP |
application_hash |
dialplan <action application=…/> |
void (switch_core_session_t *, const char *) |
SWITCH_ADD_API |
api_hash |
fs_cli / ESL / switch_api_execute() |
switch_status_t (const char *cmd, switch_core_session_t *, switch_stream_handle_t *) |
SWITCH_ADD_CHAT_APP |
chat_application_hash |
chatplan(mod_sms 的 chatplan_hunt()),和通话无关 |
switch_status_t (switch_event_t *, const char *) |
三点区别值得记住:
- App 挂在 session 上,API 挂在 CLI/ESL 上,chat app 挂在 message event 上。 第一个参数的类型就说明了一切。
- 只有 API 能往调用方写输出(
stream->write_function)。App 返回void,想给出反馈只能打日志或设通道变量。 - 三张 hash 表互相独立。 所以同一个名字可以既是 application 又是 chat application 而不冲突 —— 后面"坑三"里我就是被这一点骗了。
宏偷偷捕获了上下文
回头再看一眼上面那个宏展开,里面藏着 C 里最"魔法"的一处。
看见 *module_interface 了吗?它不是参数。 宏直接引用了外层函数作用域里那个叫 module_interface 的变量 —— 也就是 SWITCH_MODULE_LOAD_ARGS 注入进来的那个。SWITCH_ADD_CODEC 更狠,同时用了 pool 和 (*module_interface)->module_name:
#define SWITCH_ADD_CODEC(codec_int, int_name) \
for (;;) { \
codec_int = (switch_codec_interface_t *)switch_loadable_module_create_interface(*module_interface, SWITCH_CODEC_INTERFACE); \
codec_int->modname = switch_core_strdup(pool, (*module_interface)->module_name); \
codec_int->interface_name = switch_core_strdup(pool, int_name); \
codec_int->codec_id = switch_core_codec_next_id(); \
break; \
}
而 modname(SWITCH_ADD_* 之外常用到的那个)来自 SWITCH_MODULE_DEFINITION 生成的 static const char modname[]。
所以典型模块开头那句:
*module_interface = switch_loadable_module_create_module_interface(pool, modname);
三个标识符 —— module_interface、pool、modname —— 一个是注入的出参,一个是注入的入参,一个是宏生成的文件级静态变量。全都不是你声明的。
这是好设计还是坏设计?我的看法:它是"C 里没有 DI 容器时的必然选择",代价是可读性。这些宏只能在 SWITCH_MODULE_LOAD_FUNCTION 体内使用,一旦你想抽个 helper 函数出来批量注册,就会撞上"module_interface 未定义"的编译错误,然后一头雾水。第一次写 FreeSWITCH 模块的人几乎都踩过。
第三层:服务注册 —— 模块只"申报",Core 才"发布"
这是全文的核心洞察,也是最容易被讲错的地方。
很多文章说"模块把自己注册到 Core 的注册表"。这不准确。 模块的 load 函数从来没碰过全局注册表。
create_interface 只是往私有链表尾插
switch_loadable_module_create_interface() 的实现(switch_loadable_module.c:3233):
#define ALLOC_INTERFACE(_TYPE_) { \
switch_##_TYPE_##_interface_t *i, *ptr; \
i = switch_core_alloc(mod->pool, sizeof(switch_##_TYPE_##_interface_t)); \
switch_assert(i != NULL); \
for (ptr = mod->_TYPE_##_interface; ptr && ptr->next; ptr = ptr->next); /* 走到尾 */ \
if (ptr) { ptr->next = i; } else { mod->_TYPE_##_interface = i; } \
switch_thread_rwlock_create(&i->rwlock, mod->pool); \
switch_mutex_init(&i->reflock, SWITCH_MUTEX_NESTED, mod->pool); \
i->parent = mod; \
return i; }
三件事:从模块自己的 pool 分配、挂到模块自己的链表尾部、建好 rwlock 和 reflock、回填 parent。
全局 hash?没提。
所以 switch_loadable_module_interface_t 的真身就是 17 条链表的表头(switch_loadable_module.h:64):
struct switch_loadable_module_interface {
const char *module_name;
switch_endpoint_interface_t *endpoint_interface;
switch_timer_interface_t *timer_interface;
switch_dialplan_interface_t *dialplan_interface;
switch_codec_interface_t *codec_interface;
switch_application_interface_t *application_interface;
switch_chat_application_interface_t *chat_application_interface;
switch_api_interface_t *api_interface;
switch_json_api_interface_t *json_api_interface;
switch_file_interface_t *file_interface;
switch_speech_interface_t *speech_interface;
switch_directory_interface_t *directory_interface;
switch_chat_interface_t *chat_interface;
switch_say_interface_t *say_interface;
switch_asr_interface_t *asr_interface;
switch_management_interface_t *management_interface;
switch_limit_interface_t *limit_interface;
switch_database_interface_t *database_interface;
switch_thread_rwlock_t *rwlock;
int refs;
switch_memory_pool_t *pool;
};
用现代术语说,这是一份 capability manifest:模块的 load 函数其实是在填一份申报表,而不是在写注册表。
日志里的铁证
发布发生在 switch_loadable_module_process()(switch_loadable_module.c:209),由 load_module_ex() 在 load_file() 返回之后调用。这个顺序在日志里看得一清二楚 —— 下面是我这台机器 freeswitch.log 第 601 行起:
2026-09-07 22:04:20.287186 [CONSOLE] switch_loadable_module.c:1833 Successfully Loaded [mod_dptools]
2026-09-07 22:04:20.287502 [NOTICE] switch_loadable_module.c:225 Adding Endpoint 'error'
2026-09-07 22:04:20.288374 [NOTICE] switch_loadable_module.c:225 Adding Endpoint 'group'
2026-09-07 22:04:20.289214 [NOTICE] switch_loadable_module.c:225 Adding Endpoint 'user'
2026-09-07 22:04:20.290067 [NOTICE] switch_loadable_module.c:225 Adding Endpoint 'pickup'
...
2026-09-07 22:04:20.317709 [NOTICE] switch_loadable_module.c:384 Adding Application 'answer'
...
2026-09-07 22:04:20.402339 [NOTICE] switch_loadable_module.c:384 Adding Application 'bridge'
Successfully Loaded 打在 Adding Application 前面。 如果模块直接写全局 hash,顺序必然反过来。这一行日志顺序,就是两阶段设计的证据。
这一段区间里 mod_dptools 一共申报了:
$ sed -n '601,757p' freeswitch.log | grep -c "Adding Application"
139
$ sed -n '601,757p' freeswitch.log | grep -c "Adding API Function"
6
139 个 application + 6 个 API + 4 个 endpoint。一个模块,一次注册。
为什么这个两阶段值钱:门禁装在这里
上游 PR #3086 加了一个 interface-allowlist,实现位置就在 process() 里(switch_loadable_module.c:382):
} else if (!switch_loadable_module_interface_allowed(key, ptr->interface_name, "app")) {
switch_log_printf(..., "Skipping Application '%s' from %s: not permitted by interface allowlist\n",
ptr->interface_name, key);
} else {
switch_log_printf(..., "Adding Application '%s'\n", ptr->interface_name);
...
switch_core_hash_insert(loadable_modules.application_hash, ptr->interface_name, (const void *) ptr);
}
配置支持三级精度(源码注释写得挺清楚):
"mod_commands" — 整个模块的全部接口
"mod_commands.system" — 任何类型的、叫 system 的接口
"mod_commands.system.api" — 只有 api 类型的 system
这是个真实的安全需求:mod_dptools 有个 system application 能执行 shell 命令。在某些部署里你想放行 bridge 但禁掉 system。因为发布权在 Core 手上,这个门禁只需要改一个地方,对所有模块(含第三方模块)自动生效。 如果当初让模块自己写 hash,这个 feature 就得改 200 个模块。
两阶段的另一个红利藏在 switch_loadable_module_init() 里:注册事件不是立刻 fire 的,而是先塞进 event_hash,等 switch_core_sqldb_init() 成功之后再一起发(switch_loadable_module.c:2391)。源码注释很直白:
switch_core_sqldb_init()is not yet ready and is executed after starting modules frompre_load_modules.conf. Modules loading procedure generates events used by sqldb. This is why we should hold those events (storing in the event_hash) not firing them until sqldb is ready.
这是个经典的循环依赖:模块加载要发事件,事件消费者是 sqldb,sqldb 又是个模块。解法是把启动切成 pre_load_modules.conf → sqldb init → modules.conf → post_load_modules.conf 四段,中间那段的事件缓冲起来。加上 critical="true" 时失败直接 abort(),这就是一套手写的启动依赖图。
两种注册表:平坦 hash vs 多提供者链表
全局注册表长这样(switch_loadable_module.c:81):
struct switch_loadable_module_container {
switch_hash_t *module_hash;
switch_hash_t *endpoint_hash;
switch_hash_t *codec_hash;
switch_hash_t *dialplan_hash;
switch_hash_t *timer_hash;
switch_hash_t *application_hash;
switch_hash_t *chat_application_hash;
switch_hash_t *api_hash;
switch_hash_t *json_api_hash;
switch_hash_t *file_hash;
...
switch_hash_t *interface_allowlist;
switch_mutex_t *mutex;
};
17 张接口注册表 + 3 张辅助表 + 一把大锁。但这 17 张表不是一种东西,这点很关键。
类型 A:命令型(application / api / json_api / chat_application / dialplan / timer / limit / asr / speech / chat / directory)
switch_core_hash_insert(loadable_modules.application_hash, ptr->interface_name, ptr);
name → interface,一对一。而 switch_core_hash_insert 的语义是(switch_core_hash.c:144):
switch_hashtable_insert_destructor(hash, dkey, data, HASHTABLE_FLAG_FREE_KEY | HASHTABLE_DUP_CHECK, destructor)
HASHTABLE_DUP_CHECK 会先 _switch_hashtable_remove(h, k, ...) 再插。后来者覆盖前者。 记住这句,第五节我会用一个实验把它变成一个坑。
类型 B:提供者型(codec / file / database)
这些用的是 node 链表(switch_loadable_module.c:2678):
SWITCH_DECLARE(switch_file_interface_t *) switch_loadable_module_get_file_interface(const char *name, const char *modname)
{
switch_file_interface_t *i = NULL;
switch_file_node_t *node, *head;
switch_mutex_lock(loadable_modules.mutex);
if ((head = switch_core_hash_find(loadable_modules.file_hash, name))) {
if (modname) {
for (node = head; node; node = node->next) {
if (!strcasecmp(node->interface_name, modname)) {
i = (switch_file_interface_t *) node->ptr;
break;
}
}
} else {
i = (switch_file_interface_t *) head->ptr; /* 默认取头部 */
}
}
switch_mutex_unlock(loadable_modules.mutex);
if (i) PROTECT_INTERFACE(i);
return i;
}
name → 提供者链表,可以按 modname 消歧,不指定就取头部(也就是最后注册的那个)。这解决的是真实需求:.wav 可以由 mod_sndfile 或 mod_av 处理,OPUS 可以有多个实现,你需要能指定。
这两类恰好对应现代 DI 的两个概念:
| FreeSWITCH | Spring | Guice/Dagger |
|---|---|---|
| 平坦 hash(app/api) | @Bean 单绑定 |
bind(X.class).to(...) |
| node 链表 + modname | @Qualifier + List<X> 注入 |
multibinding + @Named |
区别在于:Spring 遇到重复 bean 会启动失败,FreeSWITCH 遇到重复 application 会静默覆盖。这是设计取舍,不是遗漏 —— 覆盖能力让你可以写一个模块去替换核心行为。但代价是真金白银的,见第五节。
活体注册表长什么样?在我这台跑着的实例上:
$ fs_cli -x "show interfaces" | awk -F, 'NR>1 && NF>1 {print $1}' | sort | uniq -c | sort -rn
226 api
181 application
72 file
29 codec
13 endpoint
8 json_api
6 dialplan
5 chat
3 database
2 limit
1 timer
1 say
1 management
1 generic
547 个接口实例,来自 43 个模块。这就是那个"注册表"的规模。
第四层:运行时查找与派发 —— 查表容易,管生命周期难
11 个 getter 是一个宏生成的
switch_loadable_module.c:2760:
#define HASH_FUNC(_kind_) SWITCH_DECLARE(switch_##_kind_##_interface_t *) switch_loadable_module_get_##_kind_##_interface(const char *name) \
{ \
switch_##_kind_##_interface_t *i = NULL; \
if (loadable_modules._kind_##_hash && (i = switch_core_hash_find_locked(loadable_modules._kind_##_hash, name, loadable_modules.mutex))) { \
PROTECT_INTERFACE(i); \
} \
return i; \
}
HASH_FUNC(dialplan)
HASH_FUNC(timer)
HASH_FUNC(application)
HASH_FUNC(chat_application)
HASH_FUNC(api)
HASH_FUNC(json_api)
HASH_FUNC(speech)
HASH_FUNC(asr)
HASH_FUNC(directory)
HASH_FUNC(chat)
HASH_FUNC(limit)
11 行代码生成 11 个函数。C 没有泛型,宏就是泛型。
PROTECT_INTERFACE:查找即加锁
这是整个机制里技术含量最高的一块。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); }
#define UNPROTECT_INTERFACE(_it) if (_it) { \
switch_mutex_lock(_it->reflock); \
_it->refs--; _it->parent->refs--; \
switch_mutex_unlock(_it->reflock); \
switch_thread_rwlock_unlock(_it->rwlock); \
switch_thread_rwlock_unlock(_it->parent->rwlock); }
每一次成功的 lookup 都会拿两把读锁并加两个引用计数。 这是 C 里做"服务定位器"绕不过去的问题:你把一个指向 .so 里代码的函数指针交给调用方,那这个 .so 在调用返回前就不能被卸载。
所以调用方必须配对释放。看 switch_api_execute()(switch_loadable_module.c:3157):
if (cmd_used && (api = switch_loadable_module_get_api_interface(cmd_used)) != 0) {
if ((status = api->function(arg_used, session, stream)) != SWITCH_STATUS_SUCCESS) {
stream->write_function(stream, "COMMAND RETURNED ERROR!\n");
}
UNPROTECT_INTERFACE(api); /* ← 忘了这行就是永久锁死 */
} else {
stream->write_function(stream, "INVALID COMMAND!\n");
}
Application 侧用 goto done 保证配对(switch_core_session.c:2834):
exec:
switch_core_session_exec(session, application_interface, arg);
done:
UNPROTECT_INTERFACE(application_interface);
return status;
这套 API 的形状很危险 —— 它把生命周期管理外包给了调用方,而 C 没有 RAII、没有 defer。如果我今天设计,会把 lookup 和 invoke 合成一个函数,让调用方拿不到裸接口指针。 FreeSWITCH 也确实提供了 switch_api_execute() / switch_core_session_execute_application() 这样的包装,但裸的 getter 也是公开 API,谁都能调。
卸载:等读者排空,然后放弃
对应的卸载侧(switch_loadable_module.c:1289-1300):
for (ptr = old_module->module_interface->application_interface; ptr; ptr = ptr->next) {
if (ptr->interface_name) {
switch_log_printf(..., "Deleting Application '%s'\n", ptr->interface_name);
switch_core_session_hupall_matching_var(SWITCH_CURRENT_APPLICATION_VARIABLE, ptr->interface_name, SWITCH_CAUSE_MANAGER_REQUEST);
switch_log_printf(..., "Write lock interface '%s' to wait for existing references.\n", ptr->interface_name);
if (switch_thread_rwlock_trywrlock_timeout(ptr->rwlock, 10) == SWITCH_STATUS_SUCCESS) {
switch_thread_rwlock_unlock(ptr->rwlock);
} else {
switch_log_printf(..., SWITCH_LOG_ERROR, "Giving up on '%s' waiting for existing references.\n", ptr->interface_name);
}
...
流程是:先挂断所有正在执行这个 application 的通话,再尝试拿写锁等最多 10 秒让读者排空,等不到就打个 ERROR 然后继续卸载。
"Giving up ... 然后继续" —— 这是诚实的工程妥协。等不到就硬拆,可能崩;不拆就永远卸不掉。选了前者,并且留了日志。
完整调用链
把四层串起来,<action application="answer"/> 的完整旅程:
XML dialplan
│ application="answer", data=NULL
▼
switch_core_session_execute_application_get_flags() switch_core_session.c:2749
│
├─ switch_loadable_module_get_application_interface("answer")
│ │ HASH_FUNC(application) 生成
│ ├─ switch_core_hash_find_locked(application_hash, "answer", mutex)
│ └─ PROTECT_INTERFACE(i) ← 2 把读锁 + 2 个 refcount
│
├─ 前置校验(都读自 interface->flags)
│ SAF_ZOMBIE_EXEC 通道已挂断还能不能跑
│ SAF_SUPPORT_NOMEDIA 要不要先 pre_answer 建媒体
│ SAF_SUPPORT_TEXT_ONLY 纯文本通道能不能跑
│
├─ switch_core_session_exec() switch_core_session.c:2841
│ ├─ switch_channel_expand_variables(arg) ${var} 展开
│ ├─ fire CHANNEL_EXECUTE 事件
│ ├─ set CURRENT_APPLICATION 通道变量
│ ├─ application_interface->application_function(session, expanded); ← 真正的调用,:2970
│ └─ fire CHANNEL_EXECUTE_COMPLETE 事件
│
└─ UNPROTECT_INTERFACE(application_interface)
中间那一大段前置校验,读的全是第二层那张 SAF_* 表里的 flags:SAF_ZOMBIE_EXEC 决定挂断后还跑不跑,SAF_SUPPORT_NOMEDIA 决定要不要先 pre_answer,SAF_SUPPORT_TEXT_ONLY 决定纯文本通道上直接挂断还是放行。这就是 metadata 驱动的行为 —— 注册表不只存"怎么调",还存"调之前要做什么"。
mod_dptools.c:6674 的那一行:
SWITCH_ADD_APP(app_interface, "answer", "Answer the call", "Answer the call for a channel.",
answer_function, "", SAF_SUPPORT_NOMEDIA);
两种 interface pattern,以及 padding[10]
FreeSWITCH 的 17 种接口其实只有两种形状。
Pattern A:Command —— name → 单个函数指针
typedef void (*switch_application_function_t) (switch_core_session_t *, const char *);
#define SWITCH_STANDARD_APP(name) static void name (switch_core_session_t *session, const char *data)
application / api / json_api / chat_application / dialplan 都是这个形状。签名固定,注册表里存的就是一个入口。
Pattern B:Service VTable —— name → 函数表
endpoint / codec / file / asr / speech 是这个形状。看 switch_endpoint_interface(switch_module_interfaces.h:182):
struct switch_endpoint_interface {
const char *interface_name;
switch_io_routines_t *io_routines; /* ← VTable */
switch_state_handler_table_t *state_handler; /* ← 又一个 VTable */
void *private_info;
switch_thread_rwlock_t *rwlock;
int refs;
switch_mutex_t *reflock;
switch_loadable_module_interface_t *parent;
struct switch_endpoint_interface *next;
switch_core_recover_callback_t recover_callback;
};
而 switch_io_routines 就是一张手写的虚函数表(switch_module_interfaces.h:144):
struct switch_io_routines {
switch_io_outgoing_channel_t outgoing_channel;
switch_io_read_frame_t read_frame;
switch_io_write_frame_t write_frame;
switch_io_kill_channel_t kill_channel;
switch_io_send_dtmf_t send_dtmf;
switch_io_receive_message_t receive_message;
switch_io_receive_event_t receive_event;
switch_io_state_change_t state_change;
switch_io_read_video_frame_t read_video_frame;
switch_io_write_video_frame_t write_video_frame;
switch_io_read_text_frame_t read_text_frame;
switch_io_write_text_frame_t write_text_frame;
switch_io_state_run_t state_run;
switch_io_get_jb_t get_jb;
void *padding[10]; /* ← 这行是重点 */
};
void *padding[10]。 这是 in-process plugin 的 ABI 保险:给未来预留 10 个槽位,以后加 read_dtmf_frame 之类的新方法时,结构体大小不变,已编译的模块不用重编。COM 靠"接口不可变,只能加新接口"解决同一个问题,C++ 靠 pimpl,C 就靠留白。
这也解释了为什么 SWITCH_API_VERSION 至今才 5 —— 大改一次是全生态重编,能靠 padding 混过去就靠 padding。
顺手也能理解 mod_dptools 那 4 个 endpoint(error / group / user / pickup)为什么在 nm 里是全局符号:它们是文件级的 switch_endpoint_interface 和 io_routines 静态实例,注册时只是把地址填进去。
动手:50 行写一个模块,热加载进正在跑的 FreeSWITCH
上面全是读代码。现在把它跑起来 —— 我认为这一步比读十遍源码有用。
/* mod_walter.c */
#include <switch.h>
SWITCH_MODULE_LOAD_FUNCTION(mod_walter_load);
SWITCH_MODULE_SHUTDOWN_FUNCTION(mod_walter_shutdown);
SWITCH_MODULE_DEFINITION(mod_walter, mod_walter_load, mod_walter_shutdown, NULL);
SWITCH_STANDARD_API(walter_ping_function)
{
stream->write_function(stream, "pong from mod_walter, arg=[%s]\n", zstr(cmd) ? "" : cmd);
return SWITCH_STATUS_SUCCESS;
}
/* 故意注册一个 mod_commands 已经占了的名字 */
SWITCH_STANDARD_API(uptime_function)
{
stream->write_function(stream, "HIJACKED by mod_walter\n");
return SWITCH_STATUS_SUCCESS;
}
SWITCH_STANDARD_APP(walter_shout_function)
{
switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_WARNING,
"walter_shout says: %s\n", zstr(data) ? "(nothing)" : data);
}
SWITCH_MODULE_LOAD_FUNCTION(mod_walter_load)
{
switch_api_interface_t *api_interface;
switch_application_interface_t *app_interface;
*module_interface = switch_loadable_module_create_module_interface(pool, modname);
switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_NOTICE,
"mod_walter_load: modname=[%s] pool=%p module_interface=%p\n",
modname, (void *)pool, (void *)*module_interface);
SWITCH_ADD_API(api_interface, "walter_ping", "ping demo", walter_ping_function, "[text]");
SWITCH_ADD_API(api_interface, "uptime", "shadow demo", uptime_function, "");
SWITCH_ADD_APP(app_interface, "walter_shout", "shout", "log a line",
walter_shout_function, "<text>", SAF_NONE);
return SWITCH_STATUS_SUCCESS;
}
SWITCH_MODULE_SHUTDOWN_FUNCTION(mod_walter_shutdown)
{
switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_NOTICE, "mod_walter shutting down\n");
return SWITCH_STATUS_SUCCESS;
}
编译。注意 -undefined dynamic_lookup(macOS)—— 就是那 288 个未定义符号需要的开关:
$ cc -shared -fPIC -o mod_walter.so mod_walter.c \
-I$HOME/fs/include/freeswitch -L$HOME/fs/lib -lfreeswitch \
-undefined dynamic_lookup
$ nm -gU mod_walter.so
0000000000000600 T _mod_walter_load
0000000000008018 D _mod_walter_module_interface
00000000000007b8 T _mod_walter_shutdown
三个符号。Loader 只要中间那个。
装进模块目录,在已经跑起来的 FreeSWITCH 上热加载:
$ cp mod_walter.so ~/fs/lib/freeswitch/mod/
$ fs_cli -x "uptime"
215 ← mod_commands 的原始 uptime,单位毫秒
$ fs_cli -x "load mod_walter"
+OK
$ fs_cli -x "walter_ping hello"
pong from mod_walter, arg=[hello]
从写代码到线上可调用,进程没有重启。日志:
[NOTICE] mod_walter.c:34 mod_walter_load: modname=[mod_walter] pool=0xa3d25e028 module_interface=0xa3d25e150
[CONSOLE] switch_loadable_module.c:1833 Successfully Loaded [mod_walter]
modname、pool、module_interface 三个"我没声明的变量"都有值,注入生效。
三个实测出来的坑
热加载爽完了,说代价。下面三个都是在上面那个实例上实测的结果。
坑一:平坦命名空间,后来者静默劫持
接着上面的会话:
$ fs_cli -x "uptime"
HIJACKED by mod_walter
我的模块静默劫持了 mod_commands 的 uptime。没有警告,没有报错,日志里只有一行温柔的 Adding API Function 'uptime'。
原因就是第三层讲的 HASHTABLE_DUP_CHECK:先删再插,后来者覆盖。
FreeSWITCH 自己怎么规避的?靠纪律。我扫了整个 src/mod:
$ grep -rh "SWITCH_ADD_APP(" src/mod --include='*.c' \
| sed -E 's/.*SWITCH_ADD_APP\([^,]+,[[:space:]]*"([^"]+)".*/\1/' \
| sort | uniq -d
(无输出)
in-tree 的几百个 application 名字零重复。 但这是社区 review 挡住的,不是代码挡住的。第三方模块随时能踩。
坑二:卸载之后,核心命令永久消失
这个比坑一严重得多:
$ fs_cli -x "unload mod_walter"
+OK
$ fs_cli -x "uptime"
-ERR uptime Command not found!
uptime 没了。不是回到 mod_commands 的版本,是彻底消失。
因为卸载逻辑是"按名字从 hash 里删"。我的模块把 uptime 这个 key 覆盖成了自己的接口,卸载时把这个 key 删掉 —— mod_commands 那个 switch_api_interface_t 还好好地挂在它自己的私有链表上,但全局 hash 里再没有任何东西指向它了。
能救回来吗?不能:
$ fs_cli -x "show interfaces" | grep -i uptime
(无输出)
$ fs_cli -x "module_exists mod_commands"
true
$ fs_cli -x "reload mod_commands"
-ERR unloading module [Module is not unloadable]
-ERR loading module [Module already loaded]
mod_commands 的 load 函数返回 SWITCH_STATUS_NOUNLOAD(mod_commands.c:8002),loader 会给它 module->perm++(switch_loadable_module.c:1799),从此永久驻留、拒绝卸载(:1962)。
于是形成一个死结:它不能重载,所以它注册的接口一旦被别人的 load/unload 循环从 hash 里抹掉,就只能重启整个进程。
这不是"我构造了一个恶意场景"。任何两个第三方模块撞名,再卸掉其中一个,就会命中。我会把它算作一个真实的设计缺陷 —— 修法也不难:hash 里存链表(像 codec 那样)、或者插入时检测重复并拒绝、或者卸载时只删"key 确实指向我"的项。三种都比现在好。
坑三:观测层丢了类型信息
我一开始以为发现了一堆命名冲突:
$ fs_cli -x "load mod_sms"
+OK
$ fs_cli -x "show application" | grep -E "^(set|system|info),"
info,Display Call Info,,mod_dptools
info,Display Call Info,,mod_sms
set,Set a channel variable,<varname>=<value>,mod_dptools
set,set a variable,,mod_sms
system,Execute a system command,<command>,mod_dptools
system,execute a system command,,mod_sms
set、system、info 全都双份?那 dialplan 里的 set 到底走谁?
翻源码才发现是误报。mod_sms.c:641 用的是 SWITCH_ADD_CHAT_APP,不是 SWITCH_ADD_APP:
SWITCH_ADD_CHAT_APP(chat_app_interface, "set", "set a variable", "set a variable", set_function, "", SCAF_NONE);
它进的是 chat_application_hash,一张完全独立的表,由 switch_core_execute_chat_app() 派发。跟 dialplan 的 set 井水不犯河水。
那为什么 show application 显示成一样的?因为 process() 里给 chat application 发的事件写的 type 是 "application"(switch_loadable_module.c:418):
switch_log_printf(..., "Adding Chat Application '%s'\n", ptr->interface_name);
if (switch_event_create(&event, SWITCH_EVENT_MODULE_LOAD) == SWITCH_STATUS_SUCCESS) {
switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, "type", "application"); /* ← 丢了类型 */
日志层是对的(Adding Chat Application 'set'),事件层把类型压平了,而 show interfaces 是从事件灌进去的 SQL 表读的。注册表本身分得清清楚楚,观测面把两个命名空间混成了一个。
这个坑的杀伤力在于:它会让你在排查真实冲突时得出完全错误的结论。教训很通用 —— 注册表的观测接口必须暴露和内部一致的类型维度,否则观测本身会制造 bug。
横向对照:FreeSWITCH / GStreamer / COM / 现代 DI
| 维度 | FreeSWITCH | GStreamer | COM | Spring / Guice |
|---|---|---|---|---|
| 发现机制 | 文件名 → 约定符号名 | plugin registry 缓存 + plugin_init |
注册表 CLSID | classpath 扫描 / 编译期 |
| 入口 | 导出数据符号 X_module_interface |
导出 gst_plugin_desc |
DllGetClassObject |
注解 / 显式绑定 |
| 接口描述 | C header 就是 IDL | GObject 类型系统(真运行时类型) | .idl → typelib |
反射 / 编译期代码生成 |
| 注册表键 | 字符串 → 函数指针 or VTable | 字符串 → ElementFactory → GType | GUID → 类工厂 | 类型(+ qualifier) |
| 产出 | 函数指针(无对象) | 对象实例 | 对象实例 | 对象实例 |
| 重名处理 | 静默覆盖 | rank 排序 | GUID 不会撞 | 启动报错 |
| ABI 演进 | 全局版本号 + padding[10] |
GObject 属性内省 | 接口不可变,只加新接口 | JVM 处理 |
| 生命周期 | rwlock + 双层 refcount + memory pool | GObject refcount | AddRef/Release |
容器托管 |
| 跨进程 | 不支持(同地址空间) | 不支持 | 支持(marshalling) | 不支持 |
我的看法:
- FreeSWITCH 用最少的机制解决了同一类问题。 没有 IDL、没有类型系统、没有代码生成,只有
dlopen+ 一个结构体 + 一张 hash。代价是重名不报错、生命周期靠调用方配对、ABI 演进靠留白。 - GStreamer 多花的钱买到了"运行时类型"。它有 GObject,所以能做属性内省、能自动协商、能
gst-inspect出完整接口。FreeSWITCH 的fs_cli只能告诉你名字和一句描述。这不是懒,是当年(2005)不想在 core 里塞一整套 GObject。 padding[10]和switch_api_version是同一个问题的两种答案。 前者细粒度、便宜、有上限(用满了就没了);后者粗粒度、无上限、代价是全生态重编。真实项目往往两者都要。
如果你要在自己的 C 项目里抄这套:12 条 checklist
我在 C++/C 项目里做过两次类似的东西,踩过的和上面重合度很高。整理成清单:
这一节只列要点。完整的通用做法 —— 五种入口点 pattern 的对比、
RTLD_*flags 怎么选、Linux/macOS 差异、一份 220 行可编译运行的参考实现、以及 10 个带崩溃现场的常见错误 —— 我单独写在了《C 插件系统实战指南:dlopen 没告诉你的四件事》。
加载层
- 入口用导出的数据符号,不要用导出函数。结构体能带版本号、能带 flags、能一次拿全生命周期回调;函数只能带一个入口。
- 符号名的推导规则要写进文档并且加构建期检查。"文件名必须等于模块名"这种隐式协议,第一次踩的人会浪费半天。
- 结构体第一个字段永远是 ABI 版本号。放在第一个,这样即使后面全变了你也能安全地读出它。
- 默认
RTLD_LOCAL,需要 global 的模块自己声明。并且接受"为了读 flags 得开两次"的丑陋。 - 默认不
dlclose。想支持卸载,先想清楚线程、TLS destructor、atexit、残留函数指针这四件事怎么办。
注入层
- 把 pool / allocator / logger / config 从 load 函数参数注入进去,不要让模块直接调全局单例。这是你以后能做多实例、能做测试替身的唯一机会。
- 注册宏偷偷捕获外层变量是可以的,但要在文档里明说"只能在 load 函数体内使用",并且给出无法抽 helper 时的解决方案(比如把
module_interface显式传参的第二套宏)。
注册层
- 两阶段:模块申报,容器发布。 这是最值钱的一条。它让你可以后加门禁、后加审计、后加启动顺序编排,而不用改任何模块。
- 重名要报错,不要静默覆盖。 如果业务上确实需要覆盖,那就存链表 + 显式 qualifier,让"取哪一个"变成调用方的显式决定。FreeSWITCH 的坑二就是这条没做。
- 每张注册表想清楚是"单绑定"还是"多提供者",不要一开始全用
name → ptr,后面发现要多提供者时已经有 200 个调用点了。
运行时
- 不要把裸接口指针交给调用方。提供
execute(name, args)而不是get(name)+invoke()。C 没有 RAII,配对释放的责任放在调用方就一定会漏。 - 观测接口必须和内部注册表保持相同的类型维度。你的
show命令是排障的第一现场,它一撒谎,后面全错。
回到最初那个问题
<action application="answer"/> 怎么找到 answer_function()?
答案是:它不需要反射,因为 switch.h 就是那份 IDL。
Core 和模块编译时看同一份 header,所以双方都知道 switch_application_function_t 是 void (*)(switch_core_session_t *, const char *)。运行时唯一缺的信息只有一条 —— 名字到指针的映射。而这条信息,模块在 mod_dptools_load() 里主动交出来了。
剩下的全是工程:一个约定的符号名解决"入口在哪",一个版本号解决"ABI 对不对",一次两阶段解决"谁有权发布",一对 rwlock 加 refcount 解决"调用期间别卸载",一个 padding[10] 解决"以后还能不能加方法"。
没有一处是聪明的技巧。全是二十年里被同一个问题咬过之后留下的疤。这也是我建议每个写长期 C/C++ 项目的人都读一遍它的原因 —— 你迟早会遇到同样的五个问题,而这里有一份已经在生产环境跑了二十年的参考答案,连它的缺陷都标注得很清楚。
源码索引(1.11.3-dev)
想自己走一遍,按这个顺序读:
# 加载
src/include/switch_types.h:2604-2660 SWITCH_API_VERSION / function_table / SWITCH_MODULE_DEFINITION
src/switch_dso.c:89-148 dlopen / dlsym 的薄封装
src/switch_loadable_module.c:1697 switch_loadable_module_load_file() ← 从这里开始
src/switch_loadable_module.c:1845 switch_loadable_module_load_module_ex()
src/switch_loadable_module.c:2226 switch_loadable_module_init() ← 启动顺序与事件缓冲
# 注册
src/include/switch_loadable_module.h:64 switch_loadable_module_interface_t(18 条链表)
src/include/switch_loadable_module.h:373+ SWITCH_ADD_API / SWITCH_ADD_APP / SWITCH_ADD_CODEC ...
src/switch_loadable_module.c:3219 create_module_interface()
src/switch_loadable_module.c:3233 ALLOC_INTERFACE 宏(私有链表尾插)
src/switch_loadable_module.c:209 switch_loadable_module_process() ← 发布 + 门禁
# 查找与派发
src/switch_loadable_module.c:2760 HASH_FUNC 宏(11 个 getter)
src/include/switch_module_interfaces.h:864 PROTECT_INTERFACE / UNPROTECT_INTERFACE
src/switch_loadable_module.c:3119 switch_api_execute()
src/switch_core_session.c:2749 execute_application_get_flags()
src/switch_core_session.c:2970 application_interface->application_function(...) ← 终点
# 两种 pattern
src/include/switch_module_interfaces.h:144 switch_io_routines(VTable + padding[10])
src/include/switch_module_interfaces.h:788 switch_application_interface(Command)
# 一个真实模块
src/mod/applications/mod_dptools/mod_dptools.c:44 SWITCH_MODULE_DEFINITION
src/mod/applications/mod_dptools/mod_dptools.c:6529 load 函数入口
src/mod/applications/mod_dptools/mod_dptools.c:6674 SWITCH_ADD_APP(..., "answer", ...)
你在自己的 C/C++ 项目里做过插件系统吗?是选了 dlopen + 约定符号,还是上了 COM/GObject 这种更重的方案?重名冲突和卸载安全这两件事你是怎么处理的 —— 尤其是,你有没有像 FreeSWITCH 这样,最后决定"干脆不 dlclose"?评论区聊聊。