写 FreeSWITCH module 这件事,套路比 API 数量重要得多

Posted on in Tech

Abstract 写 FreeSWITCH module 这件事,套路比 API 数量重要得多
Authors Walter Fan
Category Tech
Version v1.0
Updated 2026-11-11
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 一句话:FreeSWITCH module 开发就四件事——搭骨架、选对钩子拿到你要的东西、核心逻辑里绝不阻塞、管好内存和线程的生命周期。学会这四件事,换什么业务场景都是改细节。
  • 骨架:SWITCH_MODULE_DEFINITION + LOAD/SHUTDOWN 是每个模块都躲不开的三件套;注册方式分 APP(dialplan 可调用,有 session 上下文)和 API(fs_cli 命令,没有 session),选错了后面全白搭。
  • 钩子:想摸到原始音频,用 switch_core_media_bug_add;想做 ASR,有自己管流式连接和实现 switch_asr_interface 两条路,看是接云服务还是本地引擎。
  • 核心逻辑:media bug 的回调跑在核心媒体线程里,任何网络 I/O、磁盘写、长时间持锁都会让通话卡顿甚至被看门狗杀线程——回调只搬数据,重活丢给独立 worker 线程。
  • 生命周期:session 内存池在挂断流程结束后整体销毁,worker 线程绝不能攥着挂在这个池子上的指针跨 hangup 存活;bug 用完要显式 remove。
  • 验证:编译期 modules.conf 决定编译什么,运行期 autoload_configs/modules.conf.xml 或 fs_cli -x "load mod_xxx" 决定加载什么,这是新手最容易搞混的两处配置。

网上讲 FreeSWITCH module 开发的文章,翻来覆去就两种写法:一种是把官方的 mod_skel.c 贴出来,告诉你"这就是骨架",然后没有下文;另一种是挑一个具体模块,逐行解读它的源码,看完你明白了这个模块怎么工作,但换一个需求还是不知道从哪下手。

问题不在于 API 太多记不住,而在于没人把"套路"单独摘出来讲。 FreeSWITCH 的 C API 确实庞大,但真正写一个 module,用到的核心机制就那么几类,排列组合一下就能覆盖录音、转写、变声、通知、路由这些常见需求。

这篇文章用"通话实时录音,同时把语音转成文字"这个具体场景,把这套通用方法走一遍。选这个场景不是因为它多特殊,而是因为它刚好把骨架、钩子、线程、生命周期这四件事全用上了——学会这个,其他 module 基本就是把"钩子"换个种类、把"核心逻辑"换成自己的业务。文中所有函数签名和代码片段都对照 FreeSWITCH 1.6.19 开源代码核实过(github.com/signalwire/freeswitch,标签 v1.6.19),可以直接下载源码对照着看。


第一件事:搭骨架

模块的最小组成

不管你要写什么功能,一个 FreeSWITCH module 的骨架都是这三样:SWITCH_MODULE_DEFINITION 声明模块、LOAD 函数在模块加载时跑一次、SHUTDOWN 函数在卸载时跑一次。对照官方的框架演示模块 mod_skel.c:

#include <switch.h>

SWITCH_MODULE_LOAD_FUNCTION(mod_rectx_load);
SWITCH_MODULE_SHUTDOWN_FUNCTION(mod_rectx_shutdown);
SWITCH_MODULE_DEFINITION(mod_rectx, mod_rectx_load, mod_rectx_shutdown, NULL);

SWITCH_MODULE_LOAD_FUNCTION(mod_rectx_load)
{
    switch_application_interface_t *app_interface;
    *module_interface = switch_loadable_module_create_module_interface(pool, modname);

    SWITCH_ADD_APP(app_interface, "rectx_start",
                    "Start recording+transcribe", "Start recording+transcribe",
                    rectx_start_function, "[stop]", SAF_NONE);
    return SWITCH_STATUS_SUCCESS;
}

SWITCH_MODULE_SHUTDOWN_FUNCTION(mod_rectx_shutdown)
{
    return SWITCH_STATUS_SUCCESS;
}

LOAD 函数里 *module_interface = switch_loadable_module_create_module_interface(...) 这一行是固定写法,相当于告诉 FreeSWITCH 核心"我是谁";真正决定模块能做什么的,是后面注册了哪种接口。

APP 还是 API:第一个容易选错的岔路口

mod_skel.c 演示的是 SWITCH_ADD_API,注册出来是一个 fs_cli 命令;mod_oreka.c 用的是 SWITCH_ADD_APP,注册出来是一个 dialplan 里能调用的应用。两者的函数签名也不一样:

/* API:没有 session 上下文,适合查状态、改全局配置这类不依赖某通电话的操作 */
SWITCH_STANDARD_API(skel_function)
{
    ...
    return SWITCH_STATUS_SUCCESS;
}

/* APP:第一个参数就是 session,能拿到 channel,才摸得到音频 */
SWITCH_STANDARD_APP(rectx_start_function)
{
    switch_channel_t *channel = switch_core_session_get_channel(session);
    ...
}

判断标准很简单:需不需要绑定到一通具体的电话。 查全局队列长度、改配置、触发一次批量任务,用 API;要摸某通电话的音频、要在某个 channel 上挂钩子,必须用 APP——这也是为什么"录音转写"这种业务只能走 APP 这条路,API 函数根本拿不到 session。

APP 注册好之后,在 dialplan 里就能这样调用(<action application="..." data="..."/> 是 FreeSWITCH dialplan 的标准语法):

<extension name="record_and_transcribe">
  <condition field="destination_number" expression="^(1000)$">
    <action application="answer"/>
    <action application="rectx_start" data=""/>
    <action application="bridge" data="user/1001"/>
  </condition>
</extension>

构建系统:两份叫"modules.conf"的文件别搞混

新手最容易卡壳的地方,是 FreeSWITCH 里有两份名字很像、作用完全不同的配置:

  • 编译期 的 modules.conf(仓库根目录):决定 ./configure && make 的时候编译哪些模块。把你自己模块的路径(比如 applications/mod_rectx)加进去,取消注释,重新 make 才会生成 .so。
  • 运行期 的 conf/autoload_configs/modules.conf.xml:决定 FreeSWITCH 进程启动时自动加载哪些模块,靠 <load module="mod_xxx"/> 这一行。

模块文件已经编译出来、但没写进这份自动加载清单,不想重启整个 FreeSWITCH 进程的话,直接在 fs_cli 里敲 load mod_rectx 热加载即可。模块自己的 Makefile.am 走统一的构建规则(对照 mod_oreka 的写法):

include $(top_srcdir)/build/modmake.rulesam
MODNAME=mod_rectx
mod_LTLIBRARIES = mod_rectx.la
mod_rectx_la_SOURCES  = mod_rectx.c
mod_rectx_la_LIBADD   = $(switch_builddir)/libfreeswitch.la
mod_rectx_la_LDFLAGS  = -avoid-version -module -no-undefined -shared

第二件事:选对钩子

骨架只是个空壳,真正决定模块能干什么的,是你挂上去的"钩子"——FreeSWITCH 提供了好几类可以挂载的接口,常见的几种:

钩子类型 拿到什么 典型用途
Application(SWITCH_ADD_APP) dialplan 执行流程的控制权 应答、放音、桥接、自定义业务逻辑
API(SWITCH_ADD_API) 一个 fs_cli 命令入口,无 session 查状态、改配置、触发批量任务
Event Handler 核心事件总线上的事件(挂断、DTMF、注册…) 订阅事件做联动,不碰音频本身
Media Bug(switch_core_media_bug_add) 原始 PCM 音频帧 录音、变声、降噪、实时转写这类要"摸到声音"的场景
ASR/TTS Interface(switch_asr_interface) FreeSWITCH 原生语音识别抽象层 本地 ASR 引擎想复用 detect_speech 这类已有 app

录音转写要摸到原始音频,用的是 Media Bug;转写结果怎么拿,又分出两条路,后面细说。

Media Bug:怎么拿到原始音频

关键函数签名(核对自 src/include/switch_core.h):

switch_status_t switch_core_media_bug_add(
    switch_core_session_t *session,
    const char *function,
    const char *target,
    switch_media_bug_callback_t callback,   /* switch_bool_t (*)(switch_media_bug_t*, void*, switch_abc_type_t) */
    void *user_data,
    time_t stop_time,
    switch_media_bug_flag_t flags,
    switch_media_bug_t **new_bug);

回调函数会在不同阶段被反复调用,switch_abc_type_t 这个枚举(src/include/switch_types.h)标记了当前是哪个阶段:

阶段 发生时机 该干什么
INIT bug 刚建好,第一次回调 判断要不要重采样,起 worker 线程,建 ASR 连接
READ_REPLACE / READ_PING 每收到一帧 拿 PCM16 帧,非阻塞入队
WRITE_REPLACE 对端方向每帧 同上,区分读/写方向
CLOSE 主动 remove 或 hangup 触发 停 worker 线程、flush 队列、关 ASR 连接

flags(SMBF_*)决定数据怎么交给你,对照 mod_oreka.c 的真实用法:REPLACE 系列把要被替换的帧直接摆在你面前(switch_core_media_bug_get_read_replace_frame),不改 frame->data 只读一下,效果就是纯旁路采集;真要变声、降噪这类修改音频内容的场景才需要改这块数据再返回。PING 系列是"帧已就绪"的通知,再调 switch_core_media_bug_read 去已经积累的缓冲区里取。录音转写这种只读场景,两种都能用:

flags = SMBF_READ_STREAM | SMBF_WRITE_STREAM | SMBF_READ_PING | SMBF_ANSWER_REQ;
status = switch_core_media_bug_add(session, "rectx_read", NULL,
                                    rectx_audio_callback, ctx, 0, flags, &bug);

SMBF_ANSWER_REQ 这个 flag 值得单独说一句:加上它,bug 要等 channel 真正 answer 之后才开始喂数据,省得录到振铃音。

拿到帧之后:别漏了重采样

多数云 ASR 要 16kHz 单声道 PCM16,但 channel 实际编解码可能是 8kHz G711。每次拿到帧,先核对 switch_core_session_get_read_impl 返回的 actual_samples_per_second,不一致就用 switch_resample_create / switch_resample_process / switch_resample_destroy(INIT 阶段建、CLOSE 阶段销毁)。漏了这一步不会报错,只会让 ASR 把 8kHz 的波形当 16kHz 解读——转写质量莫名其妙地差,排查起来还特别容易被当成"ASR 模型不行"背锅。

转写走哪条路:自己管连接,还是接 FreeSWITCH 原生 ASR 抽象层

拿到干净的 PCM 之后,喂给 ASR 有两种设计:

方式 怎么接 适合场景 代价
自己管流式连接 worker 线程维护一条到外部 ASR 的 WebSocket/gRPC 长连接,批量发送、异步收结果 对接云 ASR 或自建服务,协议自定义、要签名鉴权、要自己控制延迟和重试 重连、背压、顺序保证全部自己管
实现 switch_asr_interface 注册 asr_open/asr_feed/asr_check_results/asr_get_results/asr_close,通过 switch_loadable_module_create_interface(*module_interface, SWITCH_ASR_INTERFACE) 挂上去 本地引擎,想复用 FreeSWITCH 原生 ASR 生命周期,配合已有的 detect_speech/play_and_detect_speech app 要吃透这套抽象的语义(grammar 加载、check_results 轮询节奏),灵活度不如自己写

官方的 mod_pocketsphinx 就是第二条路的真实例子:它在 LOAD 函数里调用 switch_loadable_module_create_interface(*module_interface, SWITCH_ASR_INTERFACE) 把自己注册成一个 ASR 提供者,之后任何用到 detect_speech app 的 dialplan 都能直接用上它,不用单独写调用代码。如果你对接的是外部云服务,协议和鉴权方式都是自定义的,走第一条路会更直接。


第三件事:核心逻辑绝不阻塞

这是整个设计里最容易出错、也是真正决定模块质量的地方。

flowchart TB
    Caller[/呼叫方 RTP/] --> Core["FreeSWITCH core<br/>编解码 read/write"]
    Core --> Channel["switch_channel_t"]
    Channel -->|switch_core_media_bug_add| Bug["media bug 回调<br/>跑在核心媒体线程"]
    Bug -->|switch_core_media_bug_read 拿 PCM16 帧| Check{"采样率匹配 ASR 要求?"}
    Check -->|否:先 resample| Queue["非阻塞入队<br/>switch_buffer(带 mutex)"]
    Check -->|是:直接入队| Queue
    Queue --> Worker["独立 worker 线程<br/>switch_thread_create"]
    Worker --> Disk["落盘录音文件"]
    Worker --> ASR["流式 ASR 客户端<br/>WebSocket/gRPC 或 switch_asr_interface"]
    ASR --> Result["转写结果"]
    Result --> Event["fire 自定义 event /<br/>写 channel 变量"]

media bug 的回调跑在核心媒体读写循环里,这个循环按帧周期跑(常见 20ms 一帧),任何阻塞都是在抢这个时间片——拖久了通话卡顿,严重时整个媒体线程被看门狗判定超时。这条分界线是整篇文章唯一要记住的东西:回调只做"内存拷贝 + 入队",网络 I/O、磁盘写、ASR 调用全部丢给独立 worker 线程。

队列可以用 switch_buffer_create + switch_buffer_add_mutex(FreeSWITCH 自带的线程安全环形缓冲区),线程生命周期参考官方 mod_shout 的写法:switch_threadattr_create + switch_thread_create 启动,保存 switch_thread_t*,收尾用 switch_thread_join 等它退出——不要 detach 之后就不管了。


第四件事:管好生命周期

session 内存池:最容易出事的坑

switch_core_session_alloc 分配的内存挂在 session 的内存池上,session 挂断流程走完(hangup hook、CDR 写完)之后这个池子整个销毁。worker 线程如果还攥着从这个池子分配的指针继续跑,挂断一发生就是经典的 use-after-free,而且复现概率和挂断时机强相关,排查起来很折磨人。

躲法两种:

  1. 用 switch_core_session_read_lock 占一个读锁,保证池子不会被销毁,干完活记得 switch_core_session_rwunlock。官方头文件里这个函数的注释写得很直白:"Acquire a read lock on the session … return success if it is safe to read from the session"。
  2. 更干净的做法:bug 的 user_data 自己 malloc 或用独立的 switch_memory_pool_t,不挂在 session 池上;CLOSE 回调里设一个停止标志,switch_thread_join 等 worker 线程彻底退出,再释放这块内存。

bug 用完要显式 remove

挂断或业务层主动 stop 时,一定要调用 switch_core_media_bug_remove(session, &bug)。忘记 remove,channel 销毁时核心会强制清理残留的 bug,但清理时机可能和你的 worker 线程读写窗口重叠,是另一类不好排查的崩溃源。


编译、加载、验证

  1. 把模块目录加进仓库根目录的 modules.conf,./configure && make && make install,或者单独进模块目录 make。
  2. 决定要不要写进 conf/autoload_configs/modules.conf.xml 自动加载;调试阶段更推荐 fs_cli -x "load mod_rectx" 热加载,改完代码重新编译、unload/load 一下就能测,不用反复重启整个 FreeSWITCH 进程。
  3. 验证 tap 和重采样逻辑,优先用固定的 wav 文件回放(originate 配合 loopback endpoint,或 uuid_record 录一段标准素材反复播放),而不是每次靠真实外呼人工听结果——音频问题人耳很难量化,用同一份素材才能比较"改了之后是不是真的好了"。

上手前过一遍:Checklist

骨架

  • [ ] SWITCH_MODULE_DEFINITION + LOAD/SHUTDOWN 齐全,编译期 modules.conf 加了路径。
  • [ ] 需要摸音频/挂 session,用 SWITCH_ADD_APP;只是查状态改配置,用 SWITCH_ADD_API,两者别用混。

media bug

  • [ ] flags 按需要选:纯旁路用 REPLACE 或 STREAM+PING 都行,要等接通再录加 SMBF_ANSWER_REQ。
  • [ ] 回调里按 switch_abc_type_t 分三段:INIT 建资源,READ_* 搬数据,CLOSE 收尾,不要堆在一起。
  • [ ] 拿到帧先核对 switch_core_session_get_read_impl 的采样率,不一致就过 switch_resample_*。

线程与队列

  • [ ] 确认回调函数里没有任何网络调用、没有同步磁盘写、没有长时间持锁。
  • [ ] 独立 worker 线程处理落盘和 ASR 发送,用 switch_buffer(带 mutex)做交接,设好容量上限和丢弃策略。
  • [ ] worker 线程退出路径清楚:CLOSE 设停止标志 → switch_thread_join → 再释放资源,不是甩手 detach。

生命周期

  • [ ] 想清楚 worker 线程持有的指针是不是挂在 session 池上;是的话 read_lock 占住,否则换成独立分配。
  • [ ] hangup/stop 路径一定调用 switch_core_media_bug_remove。
  • [ ] ASR 连接异常(断网、鉴权失败)要有重连/降级逻辑,不能让异常卡死 worker 线程进而堵塞队列。

结果回传

  • [ ] 想清楚结果怎么交给业务层:写 channel 变量 / fire 自定义 event / 直接回调外部系统,这决定调用方怎么拿到转写文本。
  • [ ] 分清"实时流式转写"和"挂断后批量转写"——不需要毫秒级实时性就别选流式,录音文件 + 离线批量 ASR 省掉一整套长连接+重连+背压的复杂度。

最佳实践

  • 回调函数开头就判断 type,用清晰的 switch 分支;以后要加视频 tap(SMBF_READ_VIDEO_PING 之类)时好改。
  • 转写结果别直接写完 channel 变量就算完,fire 一个自定义 event(switch_event_create_subclass),让外部系统订阅,module 和业务逻辑解耦。
  • 模块额外注册一个 SWITCH_ADD_API 命令,暴露队列长度、ASR 连接状态、已处理帧数,排障时 fs_cli 直接查,不用翻日志。
  • ASR 服务地址、采样率、缓冲区大小做成 XML 配置项(对照官方模块里 switch_xml_open_cfg 的用法),不要写死在代码里。

常见陷阱

陷阱 现象 根因 怎么躲
回调里做阻塞网络 I/O 通话卡顿断续,严重时媒体线程被看门狗判超时杀掉 回调跑在核心媒体读写循环里,没有独立线程 回调只做内存拷贝入队,网络/磁盘丢给 worker 线程
worker 线程持有 session 池指针跨 hangup 偶发崩溃,复现概率和挂断时机强相关 session 内存池在挂断流程结束后整体销毁 read_lock 占住,或 user_data 用独立分配不挂 session 池
漏做重采样 ASR 转写质量差、延迟异常,但没有任何报错 channel 实际采样率和 ASR 要求不一致,帧被当成别的采样率解读 INIT 阶段按 actual_samples_per_second 判断是否要建 resampler
忘记 switch_core_media_bug_remove hangup 后偶发崩溃,或进程内存缓慢增长 channel 销毁时核心会强制清理残留 bug,清理时机可能和 worker 线程读写窗口重叠 stop/hangup 路径显式调用 switch_core_media_bug_remove
队列没有背压策略 ASR 网络抖动时内存暴涨,极端情况 OOM 队列只管入队,没有"满了就丢最旧帧"或限速策略 switch_buffer 设上限,满了明确丢策略,不能无限增长
把实时流式转写用在不需要实时的场景 为一个"挂断后看文字"的需求背上长连接+重连+背压的全套复杂度 需求本身是批量,选型选成了流式 先问清楚要不要毫秒级实时性,多数场景录音文件 + 离线批量 ASR 更简单可靠

总结:分界线画在回调和 worker 线程之间

这类 module 真正的设计难度不在"怎么调 ASR 接口",而在"怎么不让 ASR 调用拖慢通话"。把这条分界线画清楚——回调只搬数据,worker 线程扛所有慢操作和外部依赖——剩下的坑(采样率、内存池生命周期、bug 清理)都是这条线没画对时才会冒出来的次生问题。换一个需求,骨架和这条分界线原则不变,变的只是"钩子"挂哪种接口、"核心逻辑"里塞什么业务代码。

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* FreeSWITCH module 开发套路
** 骨架
*** DEFINITION/LOAD/SHUTDOWN
*** APP vs API:要不要 session
*** 编译期 vs 运行期 modules.conf
** 选对钩子
*** media bug 摸音频
*** switch_abc_type_t 各阶段
*** SMBF flags:REPLACE vs PING
*** ASR:自管连接 vs asr_interface
** 核心逻辑不阻塞
*** 回调只搬数据
*** worker 线程扛慢操作
*** 采样率要对齐
** 生命周期
*** session 内存池何时销毁
*** read_lock 或独立分配
*** bug 用完要 remove
** 验证
*** 两份 modules.conf 别搞混
*** fs_cli load 热加载
*** 固定 wav 回放测试
@endmindmap

写 FreeSWITCH module 这件事,套路比 API 数量重要得多 - 思维导图


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。

相关文章