OBS Studio libobs callback 模块深度解析:calldata、signal 与 procedure handler 的底层机制与实战用法
在 OBS Studio 的 libobs 核心库中,callback 模块是实现"事件驱动"编程的基础设施:source 的创建/销毁/重命名、音量变化、输出启动/停止等几乎所有事件回调,都经由 calldata_t 传递参数、经由 signal_handler_t 分发事件、经由 proc_handler_t 实现无声明的直接函数调用。本文以 API 参考文档 reference-libobs-callback.rst 为骨架,逐一讲清三类对象的完整 API 语义与生命周期约束,并结合 libobs/callback 目录下的真实源码(calldata.h、signal.c、proc.c)剖析其内存模型、线程安全设计,以及 libobs 内核自身如何使用这些 API。
calldata_t:类型化参数容器
文档定义 calldata_t 对象用于"从信号处理器或向过程处理器传递参数"。它是三类对象共同的参数载体,对应头文件 calldata.h。
生命周期:init / free 的配对约束
API 参考给出的生命周期函数只有两个,但约束非常严格:
| 函数 | 签名 | 语义 |
|---|---|---|
calldata_init |
void calldata_init(calldata_t *data) |
初始化(清零)一个栈上 calldata 结构 |
calldata_free |
void calldata_free(calldata_t *data) |
释放一个 calldata 结构;仅当使用过 calldata_init() 时才可调用 |
第二条约束是实践中最容易踩的坑:如果 calldata_t * 是作为回调参数传入的(例如 signal_callback_t 收到的 cd),它由信号分发方拥有生命周期,回调内部不得调用 calldata_free。从 calldata.c 同目录的头文件实现 可以看到,calldata_free 实际只负责 bfree(data->stack),而是否执行取决于 fixed 标志位——固定栈模式的 calldata 不会释放内存,这正与 OBS 内核中大量"栈上分配 calldata"的用法相对应。
源码中还有一个文档未展开的补充 API calldata_init_fixed(calldata.h),它让 calldata 直接复用调用者提供的字节缓冲区,避免堆分配。libobs 内核触发 source 信号时的真实写法(obs-internal.h)正是这种模式:
static inline void obs_source_dosignal(struct obs_source *source, const char *signal_obs,
const char *signal_source)
{
struct calldata data;
uint8_t stack[128];
calldata_init_fixed(&data, stack, sizeof(stack));
calldata_set_ptr(&data, "source", source);
if (signal_obs && !source->context.private)
signal_handler_signal(obs->signals, signal_obs, &data);
if (signal_source)
signal_handler_signal(source->context.signals, signal_source, &data);
}
可以推断这一模式是性能驱动的:每帧视频都可能触发 video_tick、pre_tick 等信号,用 128 字节栈缓冲替代动态扩容,可以避免高频事件路径上的堆分配。
五种参数类型的读写 API
文档将读写函数分为 setter 组与 getter 组,类型覆盖 int / float / bool / ptr / string 五种:
Setter 组(向 calldata 中写入参数):
| 函数 | 签名 | 说明 |
|---|---|---|
calldata_set_int |
void (calldata_t *data, const char *name, long long val) |
写入整型参数 |
calldata_set_float |
void (calldata_t *data, const char *name, double val) |
写入浮点参数 |
calldata_set_bool |
void (calldata_t *data, const char *name, bool val) |
写入布尔参数 |
calldata_set_ptr |
void (calldata_t *data, const char *name, void *ptr) |
写入指针参数 |
calldata_set_string |
void (calldata_t *data, const char *name, const char *str) |
写入字符串参数 |
Getter 组(从 calldata 中读取参数):
| 函数 | 签名 | 说明 |
|---|---|---|
calldata_int |
long long (const calldata_t *data, const char *name) |
读取整型,不存在时返回 0 |
calldata_float |
double (const calldata_t *data, const char *name) |
读取浮点,不存在时返回 0.0 |
calldata_bool |
bool (const calldata_t *data, const char *name) |
读取布尔,不存在时返回 false |
calldata_ptr |
void * (const calldata_t *data, const char *name) |
读取指针,不存在时返回 NULL;返回值无需 free |
calldata_string |
const char * (const calldata_t *data, const char *name) |
读取字符串,不存在时返回 NULL |
文档对 calldata_ptr 的说明尤其重要:核心信号处理器(如 source 的信号)中声明为 ptr source 的参数,必须用 calldata_ptr 取出,并强转为 obs_source_t * 使用。这一点在 OBS 内核中被大量验证,例如音量控制模块连接 source 的 volume / destroy 信号时(obs-audio-controls.c):
signal_handler_connect(sh, "volume", fader_source_volume_changed, fader);
signal_handler_connect(sh, "destroy", fader_source_destroyed, fader);
从 calldata.h 的实现还能看到文档未提及的一层细节:calldata_int 等便捷函数是"乐观读取"——内部先调用返回 bool 的 calldata_get_int(参数存在且类型匹配才成功),失败时静默返回零值。而 calldata_get_string 的字符串指针指向 calldata 内部缓冲区,其有效期与 calldata 的生命周期绑定,跨调用缓存该指针是未定义行为(从实现结构推断:stack 扩容时会整体重新分配)。
signal_handler_t:事件分发中枢
Signals 是 libobs 中"所有基于事件的回调"的承载者(头文件 signal.h,实现 signal.c)。
类型与回调签名
typedef void (*signal_callback_t)(void *data, calldata_t *cd);
data:连接信号时通过signal_handler_connect传入的私有数据,在回调中被原样传回,用于在 C 语言中模拟"方法绑定 this";cd:由signal_handler_signal触发时传入的参数对象,所有权属于分发方,回调内只读、不可释放。
完整生命周期 API
| 函数 | 签名 | 语义与返回值 |
|---|---|---|
signal_handler_create |
signal_handler_t *(void) |
创建新的信号处理器,初始引用计数为 1(见 signal.c) |
signal_handler_destroy |
void (signal_handler_t *handler) |
销毁;引用计数归零才真正释放(见下文) |
signal_handler_add |
bool (signal_handler_t *handler, const char *signal_decl) |
按声明字符串添加信号;声明非法或重名时返回 false |
signal_handler_add_array |
bool (signal_handler_t *handler, const char **signal_decls) |
批量添加,数组以 NULL 结尾;任一条失败则返回 false |
signal_handler_connect |
void (signal_handler_t *handler, const char *signal, signal_callback_t callback, void *data) |
连接回调;(signal, callback, data) 组合已存在时什么都不做 |
signal_handler_connect_ref |
void (signal_handler_t *handler, const char *signal, signal_callback_t callback, void *data) |
连接回调并递增引用计数,使 handler 在断开前不会被真正销毁;即使组合已存在,引用计数仍会递增 |
signal_handler_disconnect |
void (signal_handler_t *handler, const char *signal, signal_callback_t callback, void *data) |
断开回调;组合不存在时什么都不做 |
signal_handler_signal |
void (signal_handler_t *handler, const char *signal, calldata_t *params) |
触发信号,按连接顺序调用所有已连接回调 |
信号声明字符串
signal_handler_add 接受一个"信号声明字符串",其文法由 decl.c 解析:函数名后跟零或多个 类型 名称 参数对,可用类型即 calldata 的五种(int、float、bool、ptr、string)以及无参数的 void。libobs 内核的真实声明示例(obs.c):
signal_handler_add(obs->signals, "void deduplication_changed(ptr source)");
从 signal.c 的实现 可以看到两条运行时约束:
- 重名检测:
getsignal遍历信号链表,若同名信号已存在,打印LOG_WARNING并返回 false——因此插件注册自定义信号时必须避免与内核信号(如signal的 source 信号集)撞名; - 两级锁:handler 持有全局 mutex 保护信号链表,每个
signal_info另持有一个递归 mutex 保护回调数组(signal.c),这使得"在回调内部 disconnect 当前信号"成为合法操作——断开会延迟到本轮分发结束后才真正执行。
引用计数与延迟移除:connect_ref 的深意
文档对 signal_handler_connect_ref 的描述——"递增处理器内部引用计数,阻止其在信号断开前被销毁"——在 signal.c 中逐行得到印证:
if (keep_ref)
os_atomic_inc_long(&handler->refs);
idx = signal_get_callback_idx(sig, callback, data);
if (keep_ref || idx == DARRAY_INVALID)
da_push_back(sig->callbacks, &cb_data);
注意一个微妙差异:signal_handler_connect 对已存在的 (signal, callback, data) 组合不重复入队(幂等),而 connect_ref 每次都入队且每次递增引用计数。与之配对,signal_handler_disconnect 在删除一个 keep_ref 回调时执行原子递减,归零时立即调用真正销毁逻辑(signal.c)。
此外,signal.c 还实现了文档未收录的两个内部能力,值得插件开发者了解:
signal_handler_remove_current:回调内部调用即可"自杀",通过线程局部变量current_signal_cb标记当前正在执行的回调;signal_handler_connect_global:连接一个接收 handler 上所有信号的观察回调(void (*)(void *data, const char *signal, calldata_t *cd)),适合日志或调试探针场景。
触发阶段的完整流程(signal.c)是:加锁 → 置 signalling = true → 顺序调用所有未标记 remove 的回调(期间设置线程局部指针)→ 逆序遍历删除被标记的回调 → 再执行全局回调 → 扣减因 keep_ref 回调被移除而累积的引用计数。这一设计保证了信号分发期间的回调增删不会造成数组遍历失效。
proc_handler_t:无声明的函数调用
Procedure handlers 用于"在无法直接访问函数声明或回调指针的情况下调用函数"(头文件 proc.h,实现 proc.c)。典型场景是 Lua/Python 脚本层或 IPC 层需要通过字符串名调用 C 侧注册的行为,而不必持有函数指针。
| 函数 | 签名 | 语义 |
|---|---|---|
proc_handler_create |
proc_handler_t *(void) |
创建过程处理器 |
proc_handler_destroy |
void (proc_handler_t *handler) |
销毁过程处理器 |
proc_handler_add |
void (proc_handler_t *handler, const char *decl_string, proc_handler_proc_t proc, void *data) |
按声明字符串注册一个过程及其回调、私有数据 |
proc_handler_call |
bool (proc_handler_t *handler, const char *name, calldata_t *params) |
按名字调用过程;找不到时返回 false |
回调签名与信号回调同构:void (*proc_handler_proc_t)(void *data, calldata_t *cd)。decl_string 的文法与信号声明完全一致(复用 decl.c 的 parse_decl_string),声明非法时打印 LOG_ERROR 并放弃注册(proc.c);重名注册打印 LOG_WARNING 并保持旧注册不变。
从 proc.c 的实现结构 可以推断 proc_handler_call 的线程安全策略:先在互斥锁内把 proc_info 结构(含回调指针与 data)整体拷贝到栈上,解锁后再调用——调用过程不持锁,因此过程回调内部可以再向同一 handler 注册新过程而不会死锁(mutex 为递归锁,双重保险)。源码头注释中还留有一行 /* TODO: replace with hash table lookup? */,说明当前查找是线性扫描,过程数量多时调用方应有感知。
端到端实战:声明、连接、触发
把三块 API 组合起来,一个完整的事件链路如下(用法与 libobs 内核模式一致,参数命名对齐 obs-internal.h 的内核惯例):
#include <callback/signal.h>
static void on_volume_changed(void *data, calldata_t *cd)
{
// 回调参数里的 calldata 只读,绝不能 calldata_free
double vol = calldata_float(cd, "volume");
(void)data;
// 业务逻辑...
}
int main(void)
{
// 1. 创建并声明(重名或文法错误返回 false)
signal_handler_t *sh = signal_handler_create();
if (!signal_handler_add(sh, "void volume_changed(double volume)")) {
// 处理声明冲突
}
// 2. 连接
signal_handler_connect(sh, "volume_changed", on_volume_changed, NULL);
// 3. 触发:栈上 calldata + 固定缓冲,避免每次事件堆分配
struct calldata params;
uint8_t stack[128];
calldata_init_fixed(¶ms, stack, sizeof(stack));
calldata_set_float(¶ms, "volume", 0.75);
signal_handler_signal(sh, "volume_changed", ¶ms);
// 4. 断开并销毁(引用计数归零才真正释放)
signal_handler_disconnect(sh, "volume_changed", on_volume_changed, NULL);
signal_handler_destroy(sh);
return 0;
}
要点回顾:
- 触发方负责 calldata 的生命周期(栈上
calldata_init_fixed或堆上calldata_create),回调方只读; (signal, callback, data)三元组是连接的唯一键,重复 connect 幂等、重复 disconnect 安全(signal.c);- 若 handler 可能被多个模块共享(例如插件注册观察回调后宿主先销毁),应使用
connect_ref/disconnect配对,让引用计数替代人工时序约束。
源码地图:继续阅读路径
| 文件 | 内容 |
|---|---|
| libobs/callback/calldata.h | calldata_t 结构体、五类型读写内联实现、call_param_type 枚举 |
| libobs/callback/calldata.c | calldata_get_data / calldata_set_data 的扩容与存储实现 |
| libobs/callback/decl.c / decl.h | 信号/过程声明字符串的解析器 |
| libobs/callback/signal.c | 两级锁、引用计数、延迟移除、全局回调分发 |
| libobs/callback/proc.c | 过程注册表与无锁调用 |
| libobs/obs-internal.h | 内核 obs_source_dosignal 触发模式 |
| libobs/obs-audio-controls.c | 连接 source volume / destroy 信号的实例 |
| docs/sphinx/reference-libobs-callback.rst | 本文所依据的 API 参考原文档 |
小结
libobs 的 callback 模块用约三个轻量对象撑起整个事件体系:calldata_t 是类型安全(以字符串为键、五种类型为值)的参数信封,signal_handler_t 是带引用计数与线程安全延迟移除的事件总线,proc_handler_t 则是按名字寻址的函数注册表。对插件与脚本开发者而言,掌握三元组连接键、"回调内 calldata 只读"的生命周期约定、connect_ref 的引用计数语义这三点,即可安全地在 OBS 内核事件流上挂接自定义逻辑,而无需担心并发触发与对象生命周期竞争。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00