OBS Studio 动态字符串工具 dstr 全解析:libobs 的 C 语言字符串核心 API 与实现机制
OBS Studio 的核心渲染与输出层 libobs 是一个纯 C 库,它没有 std::string 可用,取而代之的是自己实现的动态字符串结构 struct dstr 与一组字符串辅助函数。本文基于官方 API 参考文档 reference-libobs-util-dstr.rst,逐节覆盖 #include <util/dstr.h> 提供的全部接口,并结合 libobs/util/dstr.h、libobs/util/dstr.c 的真实实现与 plugins/obs-outputs/mp4-output.c 中的实战调用,讲清每个函数的语义、边界行为和内存约定,帮助你在开发 OBS 插件或阅读 libobs 源码时安全、高效地使用这套字符串设施。
一、struct dstr:三字段动态字符串
文档将 struct dstr 描述为“与 std::string 大致等价的字符串辅助结构”。其定义见 libobs/util/dstr.h#L36-L40:
struct dstr {
char *array; /* 底层缓冲区(bmalloc/brealloc 分配) */
size_t len; /* 字符数,不含结尾的 null 终止符 */
size_t capacity; /* 缓冲区已分配的容量 */
};
三个成员各管一件事:array 是堆上缓冲区,len 精确记录当前字符串长度(不含 '\0'),capacity 记录缓冲区可容纳的空间。从源码结构看,libobs 维持一条清晰的不变量:只要 array 非空,array[dst->len] 始终是 '\0',且 len + 1 <= capacity。所有修改长度的函数(dstr_ncat、dstr_insert、dstr_remove 等)在结尾都会显式补上 null 终止符,这保证了你可以随时把 dst->array 当普通 C 字符串传给 printf、strcmp 等标准库函数。
缓冲区扩张由头文件中的内联函数 dstr_ensure_capacity 统一完成:
static inline void dstr_ensure_capacity(struct dstr *dst, const size_t new_size)
{
size_t new_cap;
if (new_size <= dst->capacity)
return;
new_cap = (!dst->capacity) ? new_size : dst->capacity * 2;
if (new_size > new_cap)
new_cap = new_size;
dst->array = (char *)brealloc(dst->array, new_cap);
dst->capacity = new_cap;
}
可以推断其设计意图:首次分配按实际需求给足容量,之后按倍增(capacity * 2)扩容,必要时再一次性拉大到 new_size。这与 std::string 的几何扩容策略一致,使 dstr_cat 等追加操作的均摊复杂度为 O(1)。注意它使用的是 libobs 的分配器 brealloc(见 libobs/util/bmem.h),因此配套的释放函数必须用 bfree,而不是直接 free。
二、通用字符串辅助函数(General String Helper Functions)
文档第一组 API 面向裸 C 字符串,与 dstr 无关但同头文件提供。逐个对照实现:
大小写不敏感比较:astrcmpi / wstrcmpi
EXPORT int astrcmpi(const char *str1, const char *str2);
EXPORT int wstrcmpi(const wchar_t *str1, const wchar_t *str2);
大小写不敏感的 strcmp / wcscmp 等价物,返回负数、0、正数。实现位于 dstr.c#L38-L56:逐字符用 toupper 归一化后比较。关键细节:两个参数都是 NULL 安全的——任何一侧为 NULL 都会被替换为静态空串 astrblank,因此 astrcmpi(name, NULL) == 0 不会崩溃,而是等价于与空串比较。宽字符串版本 wstrcmpi 使用 towupper,语义相同。
限定长度比较:astrcmp_n / wstrcmp_n / astrcmpi_n / wstrcmpi_n
int astrcmp_n(const char *str1, const char *str2, size_t n);
int wstrcmp_n(const wchar_t *str1, const wchar_t *str2, size_t n);
int astrcmpi_n(const char *str1, const char *str2, size_t n);
int wstrcmpi_n(const wchar_t *str1, const wchar_t *str2, size_t n);
只比较前 n 个字符(任一字符串提前结束则停止)。n == 0 时直接返回 0,其余细节(NULL 安全、比较方向)与全量版本一致,实现见 dstr.c#L78-L164。
大小写不敏感的子串查找:astrstri / wstrstri
char *astrstri(const char *str, const char *find);
wchar_t *wstrstri(const wchar_t *str, const wchar_t *find);
strstr / wcsstr 的大小写不敏感版本。实现(dstr.c#L166-L198)并不逐字符做大小写归一化扫描,而是复用 astrcmpi_n:以 strlen(find) 为长度,在每个候选起点做定长比较,命中即返回指向该起点的指针,未命中返回 NULL。NULL 入参(str 或 find 为空)同样返回 NULL 而非崩溃。
去除首尾填充:strdepad / wcsdepad
char *strdepad(char *str);
wchar_t *wcsdepad(wchar_t *str);
原地删除字符串首尾的填充字符。文档明确填充字符集合为 tab、空格、CR、LF,对应实现中的判定函数:
static inline bool is_padding(int ch)
{
return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r';
}
(见 dstr.c#L200-L232)。注意两点:一是它只处理首尾,不折叠中间空白;二是返回的是原指针 str(前导填充通过 memmove 前移),NULL 或空串原样返回。这类函数常用于解析 INI 配置、日志行首尾裁剪等场景。
字符串切分:strlist_split / strlist_free
char **strlist_split(const char *str, char split_ch, bool include_empty);
void strlist_free(char **strlist);
把字符串按分隔符 split_ch 切分为以 NULL 结尾的子串指针数组;若分隔符不存在,第一个子串就是原串本身。参数说明:
str:待切分字符串;split_ch:单个分隔字符;include_empty:相邻分隔符产生的空子串是否保留。设为true时"a,,b"切出 3 项("a"、""、"b"),设为false时切出 2 项。
文档给出的示例用法:
char **words = strlist_split("OBS Studio", ' ', false);
int count = 0;
for (char **word = words; *word; ++word) {
count++;
blog(LOG_DEBUG, "%s", *word);
}
strlist_free(words);
// count == 2
多个连续空格会被跳过(因为 include_empty 为 false),最终 count == 2。从源码看,strlist_split 采用单次分配设计:先扫一遍统计段数与总字节数,再用一次 bmalloc(total_size) 同时分配出指针表和全部子串缓冲区(指针表在前,子串数据紧随其后)。这也解释了 strlist_free 为什么只需一行 bfree(strlist)——整个数组只有一个分配块。对 NULL 输入,它返回 NULL,调用方可以安全地跳过。
三、生命周期管理:init / move / free 家族
初始化
void dstr_init(struct dstr *dst); // 全部清零
void dstr_init_move(struct dstr *dst, struct dstr *src);
void dstr_init_move_array(struct dstr *dst, char *str);
void dstr_init_copy(struct dstr *dst, const char *src);
void dstr_init_copy_dstr(struct dstr *dst, const struct dstr *src);
实现都是 dstr.h 中的 static inline 函数:
dstr_init仅把三字段清零(array=NULL, len=0, capacity=0),零成本;dstr_init_move把src整体拷贝给dst后将src归零——所有权转移,不拷贝数据,是 C 版“移动语义”;dstr_init_move_array直接认领一个由 bmalloc 分配的字符串,只strlen一次计算len,capacity记为len+1,无拷贝、无重分配;dstr_init_copy/dstr_init_copy_dstr则是“init + copy”的组合,产生真实数据拷贝。
释放与数组批量释放
void dstr_free(struct dstr *dst);
dstr_free 用 bfree 释放 array 并归零三字段。头文件中还提供了文档未单列、但同族常用的 dstr_array_free(array, count)(逐个释放 dstr 数组,dstr.h#L185-L190)以及 dstr_move / dstr_move_array(先释放旧值再接管新所有权)。约定:dstr_free 释放的必须是 bmalloc/brealloc 系分配的内存,这也是整个 libobs util 层统一的内存纪律。
拷贝族
void dstr_copy(struct dstr *dst, const char *array);
void dstr_copy_dstr(struct dstr *dst, const struct dstr *src);
void dstr_ncopy(struct dstr *dst, const char *array, const size_t len);
void dstr_ncopy_dstr(struct dstr *dst, const struct dstr *src, const size_t len);
dstr_copy:整串拷贝,语义是“替换 dst 当前内容”。实现(dstr.c#L345-L358)先dstr_free旧缓冲区,若源为空串则直接置空;非空时ensure_capacity(len+1)后memcpy连 null 一起拷贝。dstr_copy_dstr:内联实现(dstr.h#L219-L228),同样先释放 dst 再按需拷贝,src->len为 0 时不分配。dstr_ncopy/dstr_ncopy_dstr:只拷贝前len个字符。dstr_ncopy_dstr 会用size_min(len, str->len)截断越界请求,并用bmemdup(array, newlen+1)精确分配newlen+1字节、补 null——即capacity恰好等于len+1,不留冗余。
容量控制:dstr_resize 与 dstr_reserve
void dstr_resize(struct dstr *dst, const size_t num);
void dstr_reserve(struct dstr *dst, const size_t num);
二者分工对应 std::string 的 resize 与 reserve:
dstr_resize改变逻辑长度len。放大时新字符被置零(array[num]=0),缩小时直接截短;num==0时退化为dstr_free完全释放。实现见 dstr.h#L239-L249。dstr_reserve只扩不缩容量,不改变len。dstr_reserve 中capacity <= dst->len时直接返回——文档中“值小于当前保留大小时不生效”的说法由此得到源码印证:它不会缩小缓冲区。
判断空串则用内联的 dstr_is_empty(dstr.h#L251-L259):array 为空、len 为 0 或首字符为 '\0' 均视为空。
四、拼接、插入与删除:修改类 API
拼接
void dstr_cat(struct dstr *dst, const char *array);
void dstr_cat_dstr(struct dstr *dst, const struct dstr *str);
void dstr_cat_ch(struct dstr *dst, char ch);
void dstr_ncat(struct dstr *dst, const char *array, const size_t len);
void dstr_ncat_dstr(struct dstr *dst, const struct dstr *str, const size_t len);
dstr_cat(内联,dstr.h#L261-L269):空指针或空串直接返回,否则委托dstr_ncat;dstr_cat_ch(dstr.h#L271-L276):单字符追加的快路径,一次扩容、一次写字符、一次补 null;- dstr_ncat:核心追加逻辑——
new_len = dst->len + len,ensure_capacity(new_len+1)后把len字节memcpy到尾部并补 null; dstr_ncat_dstr同样用size_min(len, str->len)防越界。
按索引插入
void dstr_insert(struct dstr *dst, const size_t idx, const char *array);
void dstr_insert_dstr(struct dstr *dst, const size_t idx, const struct dstr *str);
void dstr_insert_ch(struct dstr *dst, const size_t idx, const char ch);
在 idx 处插入字符串/单字符。三个函数都含同一优化(以 dstr_insert 为例):当 idx == dst->len 时直接走 dstr_cat 快路径,避免 memmove;否则扩容后用 memmove 把 [idx, len] 整体后移 len 字节、再 memcpy 写入新内容。dstr_insert_ch 的移位量为 1,是插入路径上最轻量的形态。
按索引删除
void dstr_remove(struct dstr *dst, const size_t idx, const size_t count);
从 idx 起删除 count 个字符(dstr.c#L508-L525)。两个边界优化值得注意:count == dst->len 时整个字符串被删,直接 dstr_free;删除区段正好抵达串尾(idx+count == len)时只需在 idx 处打一个 null,无需移动。中间删除才用 memmove 前移尾部。
五、格式化输出:dstr_printf / dstr_catf 及 v 变体
void dstr_printf(struct dstr *dst, const char *format, ...);
void dstr_vprintf(struct dstr *dst, const char *format, va_list args);
void dstr_catf(struct dstr *dst, const char *format, ...);
void dstr_vcatf(struct dstr *dst, const char *format, va_list args);
dstr_printf 将 dst 内容替换为格式化结果,dstr_catf 则追加;v 版本接收 va_list,便于再封装可变参函数。可格式化的输出正是这套 API 在 libobs 中高频出现的原因。实现采用标准两遍 vsnprintf 策略(dstr_vprintf):
va_list args_cp;
va_copy(args_cp, args);
int len = vsnprintf(NULL, 0, format, args_cp); // 第一遍:只算长度
va_end(args_cp);
if (len < 0)
len = 4095; // 出错时的回退容量
dstr_ensure_capacity(dst, ((size_t)len) + 1); // 按需扩容
len = vsnprintf(dst->array, ((size_t)len) + 1, format, args); // 第二遍:真正写入
...
dst->len = len < 0 ? strlen(dst->array) : (size_t)len;
第一遍传 NULL 缓冲只获取所需长度,据此扩容,第二遍再写入,杜绝截断风险;vsnprintf 返回负数时按 4095 字节兜底。头文件中 dstr_printf / dstr_catf 还挂了 PRINTFATTR(2, 3) 宏(非 MSVC 下即 __attribute__((format(printf, 2, 3))),见 dstr.h#L42-L46),让编译器在编译期检查格式串与实参匹配——这是使用这些函数时应顺带利用的免费安全网。
六、查找、替换与比较
查找
const char *dstr_find_i(const struct dstr *str, const char *find); // 大小写不敏感
const char *dstr_find(const struct dstr *str, const char *find); // 区分大小写
二者均为内联(dstr.h#L278-L286):分别把 str->array 交给 astrstri 或标准库 strstr,命中返回指向首处出现位置的指针(该指针指向 dstr 内部缓冲,随后续修改可能失效),未命中返回 NULL。
替换
void dstr_replace(struct dstr *str, const char *find, const char *replace);
替换 find 的所有出现。dstr_replace 按 replace_len 与 find_len 的长短分三条路径优化:替换串更短时在原地 memmove 收缩;更长时先统计出现次数、一次性 ensure_capacity 再逐个替换;等长时逐段 memcpy。replace 传 NULL 按空串处理(即删除所有 find)。它也是 dstr_safe_printf 的基础——后者把格式串中的 $1~$4 占位符安全地替换为给定值,常用于把用户输入代入模板而不经 printf。
比较
int dstr_cmp(const struct dstr *str1, const char *str2);
int dstr_cmpi(const struct dstr *str1, const char *str2);
int dstr_ncmp(const struct dstr *str1, const char *str2, const size_t n);
int dstr_ncmpi(const struct dstr *str1, const char *str2, const size_t n);
将 dstr 与 C 字符串比较,相等返回 0,否则返回非 0。内联实现(dstr.h#L288-L308)中 dstr_cmp 对两侧 NULL 都做了兜底(空 dstr 按 "" 比较),dstr_cmpi / dstr_ncmpi 则复用第二节的 astrcmpi / astrcmpi_n。注意文档对返回值约定只承诺“0 相等、非 0 不等”,不承诺符号方向——比较结果只应用于等值判断。
七、截取、去填充、宽字符转换与大小写转换
左/中/右截取
void dstr_left(struct dstr *dst, const struct dstr *str, const size_t pos);
void dstr_mid(struct dstr *dst, const struct dstr *str, const size_t start, const size_t count);
void dstr_right(struct dstr *dst, const struct dstr *str, const size_t pos);
dstr_left:把str的前pos个字符拷入dst(dst先被 resize 到pos再memcpy,dstr.c#L686-L691);dstr_mid:从start起拷count个字符,实现为“整体拷贝到临时 dstr 再dstr_ncopy偏移段”(dstr.c#L693-L700);dstr_right:从pos索引开始拷到串尾(dstr.c#L702-L709)。
三者都以 dst 为目标写,且 dst 与 str 可以是同一对象。
去填充
void dstr_depad(struct dstr *dst);
对 dst->array 调用第二节讲的 strdepad 后重算 len(dstr.c#L675-L684);若去完后变成空串则直接 dstr_free。这解释了裸函数与 dstr 版本的分工:strdepad 只动缓冲区,dstr_depad 额外维护长度不变量。
宽字符串互转
void dstr_from_wcs(struct dstr *dst, const wchar_t *wstr); // wchar_t* -> UTF-8 存入 dstr
wchar_t *dstr_to_wcs(const struct dstr *str); // dstr -> 新分配的宽字符串
dstr_from_wcs(dstr.c#L731-L741)先用 wchar_to_utf8(wstr, 0, NULL, 0, 0) 探长度,再 dstr_resize 后一次性转换写入;dstr_to_wcs 委托平台转换函数 os_utf8_to_wcs_ptr 返回新分配的宽字符串,文档明确要求用 bfree() 释放——因为分配来自 bmem 体系。libobs 内部统一以 UTF-8 为存储形态,这对处理 OBS 的本地化文本与跨平台路径尤为重要(头文件中另有 dstr_from_mbs / dstr_to_mbs 处理系统多字节编码,文档未单列,可参考 dstr.h#L133-L134)。
大小写转换与尾部字符
void dstr_to_upper(struct dstr *str);
void dstr_to_lower(struct dstr *str);
char dstr_end(const struct dstr *str);
dstr_to_upper / dstr_to_lower(dstr.c#L743-L787)走的是“UTF-8 → wchar_t → 逐字符 towupper/towlower → 转回 UTF-8”的往返路径,因此对 Unicode 文本的大小写转换是安全的,而不仅仅是 ASCII 区间;空串直接返回。dstr_end 返回最后一个字符,空串返回 0(dstr.h#L310-L316)。
八、C++ 侧的 RAII 封装:DStr 与 strref
libobs 是 C 库,但 OBS 前端大量 C++ 代码同样需要安全地管理 dstr。libobs/util/dstr.hpp 提供了一个极简 RAII 包装类 DStr:
class DStr {
dstr str;
DStr(DStr const &) = delete; // 禁拷贝构造
DStr &operator=(DStr const &) = delete;
public:
inline DStr() { dstr_init(&str); }
inline DStr(DStr &&other) : DStr() { dstr_move(&str, &other.str); } // 移动构造
inline DStr &operator=(DStr &&other) { dstr_move(&str, &other.str); return *this; }
inline ~DStr() { dstr_free(&str); } // 析构即释放
inline operator dstr *() { return &str; }
inline operator const dstr *() const { return &str; }
inline operator char *() { return str.array; }
inline operator const char *() const { return str.array; }
inline dstr *operator->() { return &str; }
};
它禁用了拷贝(防止两份对象 bfree 同一缓冲的双重释放),支持移动,析构自动 dstr_free;同时通过转换算子让你既能 dstr *ds = DStrVar; 传指针给 C API,也能直接当 const char * 用,或 ds->array、ds->len 访问字段。这与第三节的 dstr_move 所有权转移语义是一脉相承的设计。
配合 libobs/util/lexer.h#L30-L33 中的 struct strref(array + len,指向既有缓冲区的无拷贝段),头文件还提供了 dstr_init_copy_strref、dstr_copy_strref、dstr_cat_strref 等 strref 接口(dstr.h#L69-L91),用于在词法分析等零拷贝场景与 dstr 之间传递文本片段。
九、实战案例:mp4 录制的文件路径拼装
dstr 的典型用法在 plugins/obs-outputs/mp4-output.c 的 generate_filename 中一目了然(L388-L408):
dstr_copy(dst, dir); // 以设置里的目录为起点
dstr_replace(dst, "\\", "/"); // 统一 Windows 反斜杠
if (dstr_end(dst) != '/')
dstr_cat_ch(dst, '/'); // 按需补分隔符
dstr_cat(dst, filename); // 追加格式化文件名
而 find_best_filename(L361-L386)则展示了“循环探测 + 移动所有权”的完整模式:
struct dstr testpath;
dstr_init_copy_dstr(&testpath, path); // 拷贝一份用于试探
for (;;) {
dstr_resize(&testpath, extstart); // 截掉序号,保留扩展名前缀
dstr_catf(&testpath, space ? " (%d)" : "_%d", num++);
dstr_cat(&testpath, ext);
if (!os_file_exists(testpath.array)) {
dstr_free(path); // 释放原路径
dstr_init_move(path, &testpath); // 零拷贝接管 testpath
break;
}
}
注意 testpath.array 可直接当 char * 传给 os_file_exists 的“dstr 即 C 字符串”不变量,以及 dstr_init_move 如何避免最后一次成功探测结果的二次拷贝。这正是 dstr API 在 libobs 各插件中反复出现的标准组合。
十、使用要点与内存纪律小结
结合文档与源码,把关键约定归纳如下:
- 所有权单一:每个缓冲区只有一个属主。
dstr_init_move/dstr_move转移后源对象即归零,切勿再用源对象。 - 配对分配器:dstr 内部走
bmalloc/brealloc,释放必须用bfree(dstr_free已封装好);dstr_to_wcs返回的宽串同样以bfree释放。 - len 与 capacity 的区别:
dstr_reserve只扩容不改len且不可缩小;dstr_resize改len,放大置零、缩到 0 则完全释放。 - NULL 安全边界:比较/查找/拼接类函数对
NULL入参普遍做了空串兜底,但dstr_mid、dstr_right的索引类参数仍需调用方保证合法。 - 返回指针生命周期:
dstr_find系列返回的是内部缓冲指针,任何后续修改操作都可能使其失效,只应视为瞬时句柄。 - C++ 代码优先用
DStr:让析构接管释放,配合移动语义传递,可避免绝大多数手工dstr_free遗漏。
这套 API 覆盖的函数清单与本文小节一一对应,均出自 docs/sphinx/reference-libobs-util-dstr.rst 文档所列接口;实现细节可对照 libobs/util/dstr.h(内联函数与 EXPORT 声明)与 libobs/util/dstr.c(跨平台导出函数)逐条验证。理解 struct dstr 的三字段不变量与倍增扩容策略后,OBS 源码中成百上千处 dstr_* 调用的行为都可以直接推演,这也是阅读 libobs、obs-outputs、obs-websocket 等模块源码的一把基础钥匙。
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 StartedRust0622
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