纯Python从零实现SIP协议栈,我踩过的坑
为什么不用 Sofia-SIP / PJSIP?
做GB28181国标视频平台,绕不开SIP协议栈。市面上主流选择有两个:
一是商业中间件,稳定但贵,一套授权18万起步,而且是个黑盒。二是开源C库,Sofia-SIP、PJSIP、eXosip,功能强大,但代码量巨大,学习曲线陡峭,出了问题调试门槛极高。
我两个都试过。用PJSIP封装了一套,跑是能跑,但每次想改一个信令逻辑,都要翻半天C代码。排查一个注册失败,抓包、对照RFC、翻源码,运气不好还得啃几天。最崩溃的是,编译环境换个版本,链接就报错。
后来我做了个决定:用纯Python从零写一套。
项目叫PyGBSentry,基于GB/T 28181-2022标准,向下兼容2016版。今天不聊功能,只聊技术实现——纯Python写SIP协议栈,到底行不行,踩了哪些坑。
SIP消息解析器:别用正则
SIP消息格式在RFC 3261里定义得很清楚:起始行、头域、空行、消息体。看起来简单,但真写解析器的时候,坑很多。
第一个坑:头域可以折叠。 一个头域的值可以分成多行,续行以空格或Tab开头。用正则匹配\r\n会直接断掉。
第二个坑:头域名大小写不敏感,但值敏感。 Contact和contact是同一个头域,但sip:34020000001320000001@3402000000里的设备ID大小写不能乱。
第三个坑:消息体可能是二进制。 GB28181的MANSRTSP消息、SDP消息,编码方式不同,不能统一按文本处理。
我的解析器设计是逐行状态机,不用正则:
class SIPParser:
def parse(self, data: bytes) -> SIPMessage:
lines = data.split(b'\r\n')
msg = SIPMessage()
起始行
msg.start_line = lines[0].decode('utf-8', errors='ignore')
头域解析(处理折叠)
idx = 1
while idx < len(lines) and lines[idx]:
line = lines[idx]
if line.startswith((b' ', b'\t')):
续行,追加到上一个头域
msg.headers[-1].value += b' ' + line.strip()
else:
name, _, value = line.partition(b'😂
msg.headers.append(Header(
name=name.strip().decode('ascii').lower(),
value=value.strip()
))
idx += 1
消息体
if idx + 1 < len(lines):
msg.body = b'\r\n'.join(lines[idx+1:])
return msg
这个解析器跑下来,单条REGISTER消息解析耗时在微秒级。但性能不是关键,关键是可调试。哪条消息解析不对,直接断点打进去,看lines数组,一目了然。
事务层与对话层:RFC 3261的状态机
SIP协议栈的核心是两层:事务层(Transaction) 和对话层(Dialog)。
事务层管的是请求和响应的匹配。INVITE事务分ICT(Invite Client Transaction)和IST(Invite Server Transaction),非INVITE事务分NICT和NIST。RFC 3261里画了状态机图,但真写的时候,超时定时器是最麻烦的。
A定时器:UDP下重传INVITE,初始500ms,翻倍,直到32s。
B定时器:INVITE事务超时,32s。
D定时器:UDP下响应重传,32s。
E定时器:非INVITE事务重传,500ms起,翻倍,直到4s。
F定时器:非INVITE事务超时,32s。
G定时器:INVITE响应重传,500ms。
H定时器:ACK等待,32s。
I定时器:ACK重传,500ms。
J定时器:非INVITE请求重传,4s。
K定时器:非INVITE请求超时,32s。
这些定时器,一个都不能少。尤其是GB28181设备,很多是嵌入式,网络差,丢包多,重传机制不做好,注册就会随机失败。
我用的是asyncio + 定时器堆,每个事务一个定时器任务,超时回调触发重传或销毁。
对话层管的是会话生命周期。RFC 3261的Dialog需要维护:Call-ID、From tag、To tag、本地/远端CSeq、路由集、状态(Early / Confirmed / Terminated)。
GB28181的对话还有特殊之处:设备注册维持的是长连接,不是标准SIP Dialog。 设备REGISTER成功后,平台要记录设备在线状态,超时没收到心跳就标记离线。这层逻辑不能直接套RFC 3261的Dialog模型,得单独做设备会话管理。
我的做法是:SIP Dialog层保持标准,GB28181设备会话层单独抽象。 这样既兼容标准SIP流程,又能处理国标的特殊逻辑。
GB28181扩展:从REGISTER到PTZ
GB28181在SIP基础上扩展了一套XML消息体。REGISTER、MESSAGE、SUBSCRIBE、NOTIFY、INVITE,每个方法都有对应的XML schema。
比如设备注册成功后,平台要发Catalog查询:
纯Python的好处在这里体现得淋漓尽致。哪台设备返回的XML解析不对,打断点,看原始字节,看编码,看字段。改一行兼容代码,保存,重启,完事。不用编译C库,不用等厂商排期。
GB/T 28181-2022的a=track:SDP解析的坑
2022版新增了a=track属性,用来标识媒体轨道。SDP里的格式是:
m=video 6000 RTP/AVP 96
a=rtpmap:96 PS/90000
a=track:1 video primary
a=track:2 video secondary
这个属性的作用是区分主码流和子码流。2016版没有这个,平台切换码流只能靠重新INVITE或者猜。2022版有了a=track,切换码流就精确多了。
但坑在于:不是所有2022版设备都实现这个属性。 有的设备标称支持2022版,但SDP里根本不带a=track。这时候平台得降级到2016版的逻辑,靠y=字段或者媒体端口号来区分。
我的处理方式是:先解析a=track,如果有,用track ID匹配;如果没有,回退到端口号匹配。 这层兼容逻辑,用Python写就是几个if,用C写就得改库、编译、测试。
性能优化:Python慢?瓶颈不在语言
所有人听到“纯Python写SIP栈”,第一反应都是:性能行不行?
我的回答是:信令处理的瓶颈不在语言,在架构。
SIP信令的数据量很小。一条REGISTER消息几百字节,一条INVITE消息一两千字节。即使1000路设备同时注册,每秒也就几MB的信令流量。Python的asyncio处理这种量级,绰绰有余。
真正吃性能的是消息分发和状态管理。我做了四层优化:
第一层:对象池。 SIP消息对象、事务对象、对话对象,预分配2000+个,热路径零GC压力。
第二层:线程池。 XML解析、SDP解析这些CPU密集操作,扔到线程池里多核并行。asyncio主循环只负责IO和状态机调度。
第三层:预过滤Handler。 不是所有消息都需要走完整流程。比如心跳MESSAGE,只要更新设备在线时间就行,不需要走事务层。预过滤Handler在分发前砍掉70%的无效消息。
第四层:分片DialogManager。 对话管理是锁竞争最激烈的地方。我把DialogManager按设备ID哈希分片,每个分片独立锁。锁竞争降低16倍。
实测数据:500路设备同时在线,并发点播200路,首帧平均480ms,持续运行4小时无掉线,内存稳定。
调试体验:这是纯Python最大的价值
说一千道一万,纯Python最大的优势不是性能,是调试体验。
信令流程出问题,C库方案你得抓包、对照RFC、翻源码、猜。纯Python方案,打开日志,调到DEBUG,看消息进来、解析、分发、状态机流转。哪一步不对,断点打上去,变量值一目了然。
想加一个自定义信令逻辑?写个Python函数,注册到钩子点,完事。不用编译,不用链接,不用等排期。
项目现状
PyGBSentry已经开源,AGPL v3.0协议,GitHub和Gitee同步维护。支持GB/T 28181-2022,向下兼容2016版。设备注册、实时预览、录像回放、云台PTZ、语音对讲、平台级联,功能完整。
部署三条命令:
git clone https://gitee.com/suoten/PyGBSentry.git
cd PyGBSentry/editions/open-source
python tools/generate_env.py --docker && docker compose up -d
Gitee: suoten/PyGBSentry
GitHub: suoten/PyGBSentry
如果你对SIP协议栈实现感兴趣,或者正在找一个能深度定制的国标视频平台,欢迎来看源码。代码本身就是一份很好的学习资料。

浙公网安备 33010602011771号