最近在调一套基于 Kamailio 的 VoIP 语音路由,需要在 SIP 应答里动态改写 SDP,结果和 re.subst 这个函数硬碰硬地折腾了一次。先纠正一个常见误区:Kamailio 的 re 模块里并没有 re.substr 这个函数,正确拼法是 re.subst,它是 substitute 的缩写,不是 substring。网上不少文章把标题写成 re.substr,我第一次照抄的时候就报了函数不存在,还以为是编译少了模块。但真正让我头疼的不是名字,而是在配置脚本里用 re.subst 做正则替换时遇到的一个隐蔽问题——替换结果变成了乱码,路由流程莫名中断,查了小半天才定位到原因。这篇就把问题、排查过程和正确的用法完整记录下来,给正在做 Kamailio 脚本里 SIP 头域/SDP 文本改写、或者打算用 rtpproxy/rtpengine 配合做媒体代理的同学一个参考。
1. 先把函数名捋清楚:re.subst 不是 re.substr
1.1 re 模块到底提供了哪些函数
Kamailio 的 re 模块是基于 PCRE 的,提供的函数不多但非常实用。除了 re.subst,日常最常用的还有 re.match、re.match_meta、re.subst_uri、re.subst_hf。它们的区别一句话就能说清:re.match 只做布尔匹配,不产生输出;re.subst 是对指定字符串做正则替换,把结果写到目标变量里;re.subst_uri 是直接对当前请求的 Request-URI 做替换;re.subst_hf 则是对指定头域做处理。如果一个功能能用 re.subst_uri 或 re.subst_hf 完成,我建议优先使用它们,因为参数更简单、语义更清晰,不用先把头域值取出来再塞回去。
为什么我要特意说函数名?因为当你去搜 re.subst 的资料时,能看到不少文章写作 re.substr。这个笔误很容易误导第一次上手的人。我当年第一反应是检查模块有没有加载,后来又怀疑是不是版本问题,翻到官方文档才确认是拼写问题。如果你在 Kamailio 日志里看到 unknown command re.substr 之类的报错,八成不是模块编译问题,而是函数名拼错了,改成 re.subst 就好。
1.2 为什么需要手动用 re.subst,而不是全靠 rtpproxy/rtpengine
刚接触 Kamailio 媒体代理的同学可能会问:既然都上了 rtpproxy 或 rtpengine,SDP 里的 IP 和端口它们都会改,为什么还要用 re.subst 去改?实际项目里理由往往很务实。rtpproxy 和 rtpengine 主要改的是 connection line(c=)、media line(m=)和部分 a= 属性,但对 SDP 里的自定义属性、带宽字段、或者某些对端厂商私有的扩展字段并不感知。比如我就遇到过对接线路要求把 b=AS 改成 b=CT,或者需要在 a= 行里写入自定义参数,这种时候 rtpproxy/rtpengine 不会替你操作,只能在 Kamailio 脚本里自己处理 SDP 文本。
另一个更常见的场景是取地址而不是改地址。信令经过 Kamailio 时,有时要把 SDP 里的 IP 提取出来记到日志里,或者写入数据库用于后续计费、风控判断。这时候用 re.match 或 re.subst 提取字段就非常顺手。可以说,rtpproxy/rtpengine 负责媒体流的搬家和改址,re.subst 负责信令文本的精细化改写,两者是互补关系,不是替代关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. re.subst 参数与转义:真正的雷区在这里
2.1 参数顺序和返回值先说清楚
re.subst 的调用格式是:
kamailio复制re.subst("/regex/replacement/flags", subject, dst);
第一个参数是替换规则,第二个参数是源字符串,第三个参数是结果变量名。三个参数的顺序和很多人习惯的目标字符串放最前面相反,初次使用容易写反。写反之后 Kamailio 不一定会报错,但替换结果就是不对,这种问题排查起来很耗时。
返回值也要注意。函数匹配成功并完成替换时返回正数;如果没有匹配到任何文本,或者执行出错,会返回负数。Kamailio 的路由脚本有一个非常容易踩的行为:裸调用一个函数时,如果这个函数返回负数,当前 route 块会直接终止,后面的代码不再执行。这意味着一个看起来只是没替换到的正则,可能让你后续的媒体处理、日志记录全部失效。更隐蔽的是它不会报错,你只会发现某个 xlog 没打出来,某个变量没被赋值,查了半天才发现是 re.subst 悄悄把流程掐断了。所以推荐的做法是不要裸调,而是用条件包裹:
kamailio复制if (re.subst("/(.*)@(.*)/\\1@\\2/", "$ru", "$var(x)")) {
xlog("L_INFO", "replace ok: $var(x)\n");
} else {
xlog("L_WARN", "replace failed, keep original\n");
$var(x) = $ru;
}
这样即使替换失败,也不会中断业务逻辑。
2.2 三层解析:双引号、反斜杠和变量展开的套娃式转义
这是我认为 re.subst 最核心也最隐蔽的雷区。Kamailio 配置脚本里的字符串要经过三层加工,然后才轮到正则引擎干活。第一层是配置解析器对字符串字面量的转义处理,第二层是 Kamailio 对 $var、$ru 等脚本变量的展开,第三层才是 PCRE 对替换规则中 \1、\2 等语法的解析。这三层叠加起来,就是典型的套娃式转义。
最常见的翻车现场是反向引用。很多人在 cfg 里写:
kamailio复制re.subst("/(sip:.*)@(.*)$/\\1@\\2/", "$ru", "$var(x)");
注意上面这行我特意用了双反斜杠。如果你只写一个反斜杠,比如:
kamailio复制re.subst("/(sip:.*)@(.*)$/\1@\2/", "$ru", "$var(x)");
那么在配置解析阶段,\1 会被当成八进制 ASCII 码转义,变成一个不可见控制字符(SOH,也就是 ASCII 1),\2 变成 STX。等 PCRE 拿到替换规则时,看到的并不是捕获组 1 和捕获组 2,而是两个控制字符,于是替换结果里就会混进乱码。我当时第一次遇到时,xlog 打出来是一堆奇怪的符号,还以为是编码问题,后来才知道是字符串转义先于正则解析执行了。
规避的办法有两种。一是凡是在双引号字符串里写正则反向引用,一律写双反斜杠,也就是 \1 写成 \1。二是干脆用单引号包裹替换规则,单引号字符串在 Kamailio 里不会处理转义和变量展开,正则里的 \1 就能原样传给 PCRE,省心很多。但要注意,单引号里用不了 $var(),所以如果替换规则里要拼接变量值,还是得回到双引号并小心转义。
2.3 分隔符和 $ 锚点也要小心
替换规则的第一项是分隔符,默认常用斜杠 /,但也可以用其他字符。为什么要换分隔符?因为当你替换的内容里本身就带斜杠时,比如 SIP URI 的 sip:user@host:port 后面还跟着路径参数,用 / 做分隔符就会提前截断规则。此时换成 ! 或者 # 这类不冲突的字符会更安全,例如:
kamailio复制re.subst("!c=IN IP4 192.168.1.1!c=IN IP4 10.0.0.1!", "$var(sdp)", "$var(sdp2)");
另外 $ 锚点也有坑。Kamailio 配置里 $ 是变量前缀,如果正则里写了 $,后面又紧跟字母,配置解析器会把它当成变量展开,导致正则结构被破坏。比如你写 "/abc$/\1/" 里的 $/ 组合一般没事,但如果写 "/abc$d/.../" 这种,$d 会被尝试解析成变量,如果变量不存在,脚本可能直接报错。最稳妥的做法是:需要匹配行尾时,把 $ 换成显式的 (?=\r?$),或者用 [\r\n]* 结尾,避免和变量解析打架。实测下来,处理带 CRLF 的 SDP 文本时这个技巧非常有用。
3. 问题复现:一次 SDP 改写失效的完整排查过程
3.1 现场还原:替换结果变成控制字符
说回我这次踩的坑。业务场景是收到对端 200 OK 应答后,要从 SDP 里提取 c= 行的地址,同时把 b=AS 改成 b=CT。当时为了方便,我直接在 onreply_route 里写了一段。实际代码比这复杂,但核心问题是:我一开始把替换规则里的反向引用写成了单反斜杠,并且没有检查 re.subst 的返回值。结果现象是,xlog 打出来的 SDP 里某些位置出现了肉眼几乎看不见的乱码,而且后续本来要执行的 rtpproxy_answer() 调用直接没跑到。
为了定位,我先在 re.subst 前后各加了一行 xlog,把替换前后的字符串和函数返回值都打出来。这一步很关键,因为 Kamailio 脚本不像普通程序可以断点调试,你唯一能依赖的就是日志输出。如果你的 Kamailio 编译时带了 debug 模式,也可以开 core 日志,但日常排查还是 xlog 最快。
3.2 定位过程:先打日志再查语法
排查第一步永远是把现场打印出来。我在代码里临时加了这样的日志:
kamailio复制xlog("L_INFO", "before subst: [$var(sdp)]\n");
$var(rc) = re.subst("/(c=IN IP4 [0-9.]+)\r?\n/b=AS 64\r\n/", "$var(sdp)", "$var(sdp2)");
xlog("L_INFO", "after subst rc=[$var(rc)] sdp2=[$var(sdp2)]\n");
xlog 一打出来,问题立刻很清楚:rc 的值是负数,说明替换没有成功;sdp2 是空字符串。再仔细看替换规则,发现里面的 \r\n 在双引号字符串里被解析成了真正的回车换行,这个其实没问题,关键的是反向引用部分因为写成了单反斜杠,导致整个规则不是预期的 PCRE 表达式。我把规则改成单引号包裹,并且用 $var(rc) 先接收返回值、再做判断之后,替换就正常了。所以这次的问题本质是两个坑叠加:一是字符串转义破坏了正则规则,二是没检查返回值导致后续路由被静默中断。
3.3 额外收获:无匹配时用条件包裹,避免路由中断
定位过程中我还发现一个 Kamailio 脚本行为值得单独说:裸调用 re.subst 时,如果返回负数,当前路由块会直接结束。很多人写脚本时不会专门把 re.subst 放到 if 条件里,遇到没匹配的场景,后面代码就都不执行了。这个行为在 onreply_route 里尤其危险,因为你可能已经很自然地调用了 rtpproxy_answer(),但实际永远不会执行,媒体就建立不起来。后来我总结的写法是:所有 re.subst 调用都用 if/else 包一层,或者至少先执行再立刻对返回值做判断,不要裸调。
4. 与 rtpproxy、rtpengine 配合时的正确写法
4.1 rtpproxy 和 rtpengine 各管哪一段,别抢活
先说清分工。rtpproxy 是外部媒体代理,Kamailio 通过 rtpproxy.so 模块和它通信,配置里一般这样启用:
kamailio复制loadmodule "rtpproxy.so"
modparam("rtpproxy", "rtpproxy_sock", "udp:127.0.0.1:22222")
然后在请求路由里调用 rtpproxy_offer(),在应答路由里调用 rtpproxy_answer(),它会自动把 SDP 里的 c=、m= 地址端口替换成自己的中继地址。rtpengine 是更现代的替代方案,启用方式类似,模块换成 rtpengine.so,路由里用 rtpengine_manage() 一把梭。两者都能解决 NAT 穿透和媒体中继,但它们的替换范围是有限的。
我踩过的场景是:rtpproxy 只改 c= 和 m=,并不会把 o= 行的地址同步改掉。有些严苛的对端会校验 o= 行地址,发现地址对不上就会断开媒体。这时候就需要自己在脚本里用 re.subst 把 o= 也改成一致。rtpengine 有 replace-origin 之类的 flags 可以处理一部分,但如果是自定义属性,仍然得靠脚本改。总之我的经验是:让 rtpproxy/rtpengine 干它们擅长的事,特殊字段的精细化改写交给 re.subst,但两者千万不要改同一个字段,否则后执行的会覆盖前面的结果,反而制造出更隐蔽的故障。
4.2 实战示例:应答路由里安全改写 SDP
一个相对完整的示例我贴在这里,可以直接对着改:
kamailio复制onreply_route {
if (status =~ "200" && has_body("application/sdp")) {
$var(sdp) = $rb;
$var(rc) = re.subst('!c=IN IP4 [0-9.]+!c=IN IP4 10.0.0.1!', "$var(sdp)", "$var(sdp_new)");
if ($var(rc) < 0) {
xlog("L_WARN", "c-line rewrite failed, keep original\n");
$var(sdp_new) = $var(sdp);
}
$var(rc) = re.subst('!o=- [0-9]+ [0-9]+ IN IP4 [0-9.]+!o=- 1 1 IN IP4 10.0.0.1!', "$var(sdp_new)", "$var(sdp_new2)");
if ($var(rc) < 0) {
xlog("L_WARN", "o-line rewrite failed, keep original\n");
$var(sdp_new2) = $var(sdp_new);
}
$rb = $var(sdp_new2);
rtpproxy_answer();
}
}
这里有几个细节:替换规则用了单引号,避免反向引用和转义问题;每个替换都用 if 判断返回值,失败时保持原值不中断路由;SDP 文本处理完再赋回给 $rb,最后调 rtpproxy_answer()。如果你用的是 rtpengine,把最后的 rtpproxy_answer() 换成 rtpengine_manage() 即可。
4.3 提取 SDP 地址做日志或记账的例子
除了改写,re.subst 还常用来提取信息。比如把一个 SDP 里的 c= 地址提取出来,可以配合 re.match 或者用替换为空串的方式。我日常更推荐 re.match 做提取,因为语义清晰:
kamailio复制if (re.match("c=IN IP4 ([0-9.]+)", "$rb", "$var(ip)")) {
xlog("L_INFO", "sdp ip is $var(ip)\n");
}
注意 re.match 的第三个参数也是输出变量,如果匹配失败同样返回负数。这种提取在计费、日志、按 IP 做策略路由时很常用。处理带 \r\n 的 SDP 时,正则里尽量用 ([0-9.]+) 而不是 (.*),因为后者会把回车符也吞进去,后面拼接字符串时容易多出看不见的符号。
5. re.subst 常见问题速查与避坑清单
5.1 问题速查表
把这次踩坑以及平时常见的问题整理成表,方便快速对照。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 报错 unknown command re.substr | 函数名拼写错误 | 改为 re.subst |
| 替换结果里出现乱码/控制符 | 双引号里 \1 被转义成控制字符 | 写 \1,或用单引号包裹规则 |
| re.subst 之后后续代码不执行 | 无匹配返回负数,裸调用导致路由中断 | 用 if 包裹并检查返回值 |
| 只替换了第一处,后面没动 | 默认不全局替换 | 在规则末尾加 g 标志,如 /a/b/g |
| 替换规则里有 / 被截断 | / 作为分隔符与内容冲突 | 换成 ! 或 # 做分隔符 |
| 正则里 $ 被当成变量解析 | Kamailio 变量前缀冲突 | 用 (?=\r?$) 代替行尾锚点 |
| 替换后 SDP 多了回车符 | (.*) 贪婪匹配把 \r\n 吞进来 | 用 ([0-9.]+) 等精确字符集 |
| 和 rtpproxy 改同一字段,媒体不通 | 手动替换覆盖了 rtpproxy 结果 | 避免重复修改 c=/m= |
5.2 我的四条实操心得
第一,写正则前先在 PCRE 工具里验证。Kamailio 的 re 模块就是 PCRE 语义,我用 regex101 选 PCRE 模式验证完,再搬到 cfg 里,能省掉大量试错时间。第二,xlog 是你的第一排查工具。凡是涉及 re.subst 的地方,前后都打日志,把源字符串、规则、返回值和结果都打出来,不要靠猜。第三,能用 rtpproxy/rtpengine 自动处理的字段,不要自己用正则去改。手动改得越多,后续排查越痛苦,尤其是 SDP 这种带 \r\n、字段上下文敏感的格式。第四,所有 re.subst 调用统一写成带 if/else 的防御式写法。虽然代码会多几行,但遇到对端发来不标准的 SDP 时,防御式写法至少不会让整个路由静默中断。
5.3 如果只记住一句话
在 Kamailio 配置脚本里,re.subst 的正则规则优先用单引号包裹、反向引用不要省略反斜杠、调用后一定检查返回值,这三个习惯能帮你避开九成以上的坑。
这次排查让我重新调整了写 Kamailio 脚本的一个习惯:以前我总把配置脚本当声明式来写,觉得一行函数调用没匹配到最多是变量为空,这次才意识到在 Kamailio 的路由脚本里,函数返回负数真的会截断流程。现在我所有的 re.subst 调用都统一走了先接收返回值、再判断、再决定要不要覆盖原值的模板,虽然代码看起来啰嗦了一点,但线上出问题的概率低了很多。如果你也经常和 SDP、和 rtpproxy/rtpengine 打交道,建议下次写正则前先想清楚这三层解析,能省下不少排查时间。
