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 Reflectiondlopen + 一个约定名字的导出数据符号 + 模块自己上报能力 + Core 统一发布到全局注册表 + 字符串查表拿函数指针。它不需要 IDL,因为函数指针本身就是接口,而 header 就是那份 IDL。
  • 四层结构(本文主干):① 动态加载(谁来找到 .so、找到入口);② 依赖注入(Core 怎么把能力交给模块,模块怎么反向依赖 Core);③ 服务注册(模块上报 vs Core 发布,两个阶段);④ 运行时查找与派发(查表、加锁、调用)。
  • 最反直觉的一处:Loader 先在主程序/libfreeswitch 里找符号,找不到才 dlopen 那个 .so。因为 CORE_PCM_MODULECORE_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 行写一个模块,编译成 .soload 进正在跑的 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)

这张图里有两次解耦,是整个设计的灵魂:

  1. ③ 和 ④ 之间:模块从来不碰全局注册表。它只填自己的结构体。发布与否,Core 说了算。
  2. ④ 和 ⑤ 之间:消费者只知道 "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                                                                   \
}

它只干两件事:

  1. 定义一个 static const char modname[](后面 load 函数会偷偷用到,见第二层);
  2. 导出一个数据符号 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_javaJNI_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 目前是 5switch_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。为什么?

因为有些"模块"根本不是 .sosrc/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_SOFTTIMERruntime 函数(非 0),会拿到一个独立线程;CORE_PCMmod_dptoolsNULL,纯被动。

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_luamod_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; \
    }

它只做三件事:

  1. switch_loadable_module_create_interface(*module_interface, SWITCH_APPLICATION_INTERFACE) —— 在模块自己的内存池上分配一个 switch_application_interface_t,挂到该模块的 application 链表尾部。注意不是全局注册表,这个区别是第三层的主题。
  2. 填六个字段:名字、函数指针、长短描述、语法提示、flags。
  3. 把新节点的指针写回调用方的 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_dptools139 次 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 chatplanmod_smschatplan_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; \
    }

modnameSWITCH_ADD_* 之外常用到的那个)来自 SWITCH_MODULE_DEFINITION 生成的 static const char modname[]

所以典型模块开头那句:

*module_interface = switch_loadable_module_create_module_interface(pool, modname);

三个标识符 —— module_interfacepoolmodname —— 一个是注入的出参,一个是注入的入参,一个是宏生成的文件级静态变量。全都不是你声明的。

这是好设计还是坏设计?我的看法:它是"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 from pre_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.confsqldb initmodules.confpost_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_sndfilemod_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_* 表里的 flagsSAF_ZOMBIE_EXEC 决定挂断后还跑不跑,SAF_SUPPORT_NOMEDIA 决定要不要先 pre_answerSAF_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_interfaceswitch_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_interfaceio_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]

modnamepoolmodule_interface 三个"我没声明的变量"都有值,注入生效。


三个实测出来的坑

热加载爽完了,说代价。下面三个都是在上面那个实例上实测的结果。

坑一:平坦命名空间,后来者静默劫持

接着上面的会话:

$ fs_cli -x "uptime"
HIJACKED by mod_walter

我的模块静默劫持mod_commandsuptime。没有警告,没有报错,日志里只有一行温柔的 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_NOUNLOADmod_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

setsysteminfo 全都双份?那 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 没告诉你的四件事》

加载层

  1. 入口用导出的数据符号,不要用导出函数。结构体能带版本号、能带 flags、能一次拿全生命周期回调;函数只能带一个入口。
  2. 符号名的推导规则要写进文档并且加构建期检查。"文件名必须等于模块名"这种隐式协议,第一次踩的人会浪费半天。
  3. 结构体第一个字段永远是 ABI 版本号。放在第一个,这样即使后面全变了你也能安全地读出它。
  4. 默认 RTLD_LOCAL,需要 global 的模块自己声明。并且接受"为了读 flags 得开两次"的丑陋。
  5. 默认不 dlclose。想支持卸载,先想清楚线程、TLS destructor、atexit、残留函数指针这四件事怎么办。

注入层

  1. 把 pool / allocator / logger / config 从 load 函数参数注入进去,不要让模块直接调全局单例。这是你以后能做多实例、能做测试替身的唯一机会。
  2. 注册宏偷偷捕获外层变量是可以的,但要在文档里明说"只能在 load 函数体内使用",并且给出无法抽 helper 时的解决方案(比如把 module_interface 显式传参的第二套宏)。

注册层

  1. 两阶段:模块申报,容器发布。 这是最值钱的一条。它让你可以后加门禁、后加审计、后加启动顺序编排,而不用改任何模块。
  2. 重名要报错,不要静默覆盖。 如果业务上确实需要覆盖,那就存链表 + 显式 qualifier,让"取哪一个"变成调用方的显式决定。FreeSWITCH 的坑二就是这条没做。
  3. 每张注册表想清楚是"单绑定"还是"多提供者",不要一开始全用 name → ptr,后面发现要多提供者时已经有 200 个调用点了。

运行时

  1. 不要把裸接口指针交给调用方。提供 execute(name, args) 而不是 get(name) + invoke()。C 没有 RAII,配对释放的责任放在调用方就一定会漏。
  2. 观测接口必须和内部注册表保持相同的类型维度。你的 show 命令是排障的第一现场,它一撒谎,后面全错。

回到最初那个问题

<action application="answer"/> 怎么找到 answer_function()

答案是:它不需要反射,因为 switch.h 就是那份 IDL。

Core 和模块编译时看同一份 header,所以双方都知道 switch_application_function_tvoid (*)(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"?评论区聊聊。