1. 项目背景与核心需求拆解
1.1 这个网关到底解决什么问题
做过安防监控或者视频会议系统集成的朋友大概率都遇到过这种局面:手头一堆网络摄像机、编码器,它们清一色只认RTSP协议,拉流地址就是那种rtsp://ip:554/stream的形式;可对接的上层平台或者调度系统偏偏走的是SIP信令,要求你注册成SIP终端,通过INVITE来建立会话。两边协议对不上,中间就缺一个"翻译官"。
这个项目要做的,就是这么一个翻译官——RTSP-SIP流媒体网关服务器。它一头用RTSP协议去跟摄像机、NVR或者流媒体服务器打交道,把音视频流拉过来;另一头用SIP协议跟软交换、调度台或者会议平台通信,把拉到的流按SIP会话的要求推出去。整个过程中,SIP负责"打电话"式的会话控制(谁呼谁、什么时候接通、什么时候挂断),RTSP负责"取数据"式的媒体传输,网关负责把这两套逻辑缝合起来。
适合看这篇内容的人,我大致分三类:一是做视频监控平台对接、需要在异构协议之间做桥接的工程师;二是做视频会议终端、需要把RTSP源接入SIP会议室的开发者;三是对流媒体协议栈感兴趣、想拿一个真实项目练手live555和SIP协议栈的技术爱好者。不管你是哪一类,只要跟着把思路理清楚,这套东西是能跑起来的。
1.2 为什么选live555做RTSP侧
RTSP客户端/服务端的开源实现不少,但live555在这个领域基本是"老大哥"级别的存在。它的定位很明确:一套C++写的流媒体库,支持RTP/RTCP、RTSP、SIP(对,它自己也带了一部分SIP支持),跨平台,代码量不算夸张,而且被VLC、ffmpeg等一大堆项目间接或直接使用过,稳定性经过长期验证。
选它的核心理由有三点。第一,RTSP客户端能力成熟,RTSPClient类封装好了DESCRIBE、SETUP、PLAY、TEARDOWN这一整套流程,你只需要处理回调,不用自己拼RTSP报文。第二,RTP打包/解包现成,H.264、H.265、AAC、G.711这些常见编码的RTP负载格式它都有对应的类,省掉了大量底层工作。第三,事件循环模型清晰,TaskScheduler+BasicUsageEnvironment这套组合,配合select多路复用,天然适合做网关这种需要同时处理多路会话的场景。
当然它也有坑,比如默认单线程、内存管理需要自己小心、文档偏少,这些后面会专门讲。
1.3 SIP侧的角色定位
SIP这块,网关扮演的是UAC(用户代理客户端)的角色——主动向SIP服务器注册,收到INVITE后应答,建立会话后通过RTP推流。为什么不自己从零写SIP栈?因为SIP虽然看起来只是文本协议,但涉及事务状态机、认证、SDP协商、会话定时器等等,自己写容易在边界情况上翻车。实际项目里通常用成熟的SIP栈,比如PJSIP、eXosip、或者resiprocate,把信令部分交给它们,网关只负责在会话建立后把媒体流对接上。
这里有个关键设计点:SIP的媒体描述走SDP,SDP里会协商编解码、端口、传输方式。网关需要根据SDP里的信息,决定往哪个IP、哪个端口、用什么负载类型发RTP包。而RTSP拉过来的流,编码格式是摄像机决定的,所以网关往往还要做一层转封装甚至转码——如果SDP协商的编码和RTSP源一致,直接转发RTP即可;不一致就得解码再编码,这个开销就大了。
2. 整体架构设计与模块划分
2.1 三层结构:信令层、媒体层、控制层
我把整个网关拆成三层来设计,这样职责清晰,调试的时候也容易定位问题。
信令层负责SIP和RTSP两套协议的会话管理。SIP侧处理REGISTER注册、INVITE呼入/呼出、BYE挂断、以及SDP的解析和生成;RTSP侧处理与摄像机的DESCRIBE、SETUP、PLAY、TEARDOWN。这一层不碰具体的音视频数据,只管"会话"这个抽象概念。
媒体层负责RTP包的收发、缓存、转发。从RTSP客户端收到RTP包后,根据会话映射关系,把包重新打包(或直接透传)发到SIP会话对应的目标地址。这一层要处理时间戳、序列号、SSRC这些RTP字段的映射,还要处理RTCP的反馈。
控制层是大脑,维护一张会话映射表,记录每个SIP会话对应哪路RTSP源、当前状态是什么、媒体参数是什么。它还负责资源管理,比如限制最大并发会话数、超时清理僵尸会话。
三层之间通过回调或者消息队列通信。我倾向于用live555自带的事件循环作为主循环,SIP栈如果有自己的线程,就通过线程安全的队列把事件投递到主循环里处理,避免多线程直接操作共享数据带来的竞态问题。
2.2 会话映射表的设计
这张表是整个网关的核心数据结构,设计得好不好直接决定代码好不好维护。我用一个结构体来描述单个会话:
struct GatewaySession { std::string sessionId; // 网关内部唯一ID std::string sipCallId; // SIP Call-ID std::string rtspUrl; // RTSP源地址 std::string remoteRtpIp; // SIP对端RTP接收IP uint16_t remoteRtpPort; // SIP对端RTP接收端口 uint16_t localRtpPort; // 网关本地RTP发送端口 PayloadType payloadType; // 负载类型 SessionState state; // 状态机 RTSPClient* rtspClient; // live555客户端句柄 MediaSink* sink; // 媒体输出 // ... 其他字段 };状态机我定义了这么几个状态:IDLE(初始)、RTSP_SETUP(正在建立RTSP)、SIP_NEGOTIATING(SIP协商中)、ACTIVE(媒体转发中)、TEARDOWN(正在拆除)。状态迁移必须严格受控,比如只有ACTIVE状态才允许转发RTP包,避免会话还没建好就开始发数据。
提示:会话ID的生成建议用"时间戳+自增序号"的组合,既保证唯一性又方便排查问题时按时间排序。
2.3 为什么用单线程事件循环
live555默认是单线程的,所有网络事件都在一个select循环里处理。有人会想,SIP栈单独开线程、RTSP单独开线程、媒体转发再开线程,性能不是更好?理论上是的,但实际项目里我强烈建议先用单线程跑通,再考虑优化。
原因很实在:多线程下,SIP会话状态和RTSP会话状态的一致性极难保证。比如SIP那边收到BYE要挂断,同时RTSP那边正好在重连,两个线程同时改会话状态,不加锁就出乱子,加锁又容易死锁。单线程模型下,所有状态变更都在一个线程里串行执行,逻辑简单得多。等业务稳定了,如果确实有性能瓶颈(比如单机要跑几百路),再考虑把媒体转发这种纯数据搬运的部分拆出去。
实测下来,单线程处理几十路720p的转发是没问题的,瓶颈通常不在CPU,而在网络IO和内存拷贝。
3. RTSP客户端实现细节
3.1 拉流流程与关键回调
用live555做RTSP客户端,标准流程是这样的:创建RTSPClient对象,调用sendDescribeCommand,在continueAfterDESCRIBE回调里拿到SDP,解析出媒体轨道,然后对每条轨道调用sendSetupCommand,在continueAfterSETUP里拿到会话信息,最后sendPlayCommand开始播放。
这里有个容易踩的坑:DESCRIBE返回的SDP里,媒体轨道可能是多条(比如一路视频一路音频),每条都要单独SETUP。而且SETUP的时候要指定传输方式,通常用RTP_UDP,让live555自动分配本地端口。
// 简化的DESCRIBE回调逻辑 void continueAfterDESCRIBE(RTSPClient* client, int resultCode, char* resultString) { if (resultCode != 0) { // 处理失败,可能是401需要认证 return; } MediaSession* session = MediaSession::createNew(*env, resultString); // 遍历subsession,逐个SETUP MediaSubsessionIterator iter(*session); MediaSubsession* subsession; while ((subsession = iter.next()) != nullptr) { client->sendSetupCommand(*subsession, continueAfterSETUP); } }认证这块要特别注意,很多摄像机要求Digest认证。live555的RTSPClient支持在收到401后自动重试,但需要你正确设置用户名密码。如果第一次DESCRIBE返回401,authenticator会自动处理,你只要保证continueAfterDESCRIBE里对resultCode的判断逻辑正确就行。
3.2 媒体数据的接收与转发
SETUP完成后,live555会为每条subsession创建FramedSource,你需要把它接到一个MediaSink上。默认的FileSink是写文件的,我们要做的是自定义一个Sink,在afterGettingFrame回调里拿到完整的帧数据,然后打包成RTP发出去。
这里有个关键选择:是透传RTP还是重新打包。如果SIP侧协商的编码和RTSP源完全一致,理论上可以直接把收到的RTP包转发出去,只改一下目标地址和端口。但实际操作中,RTSP源的RTP时间戳基准、序列号、SSRC跟SIP会话的要求可能不一致,直接透传容易导致对端播放异常。所以我更推荐重新打包:从FramedSource拿到原始帧(比如一个完整的H.264 NALU),按照RFC 6184的规则重新分片、打时间戳、编序列号。
重新打包的代价是多一次内存拷贝和CPU开销,但换来的是对RTP流的完全控制,时间戳可以按SIP会话的时钟基准重新生成,序列号从0开始,SSRC自己指定,对端接收起来更干净。
3.3 断线重连与保活
网络摄像机掉线是家常便饭,网关必须能自动重连。我的做法是在RTSPClient的回调里监听错误,一旦发现连接断开(比如sendPlayCommand失败或者收到onConnectionClose),就把会话状态置为RTSP_SETUP,启动一个定时器,隔几秒重试一次DESCRIBE。
重连的时候要注意不要重复创建RTSPClient对象,先清理旧的,再建新的,否则会泄漏。live555的对象生命周期管理比较原始,RTSPClient、MediaSession、MediaSubsession这些都需要手动delete或者用Medium::close()。
保活方面,RTSP本身有OPTIONS心跳,但很多摄像机不强制要求。更可靠的是靠RTP/RTCP的接收来判断——如果连续几秒收不到RTP包,就认为流断了,触发重连。这个超时时间设3到5秒比较合适,太短容易误判,太长恢复慢。
4. SIP信令对接与SDP协商
4.1 注册与呼叫流程
SIP侧的第一步是REGISTER,把网关注册到SIP服务器上,这样别人才能呼进来。注册要带认证信息,服务器返回401/407挑战,网关用用户名密码算出响应再发一次。注册成功后要定期刷新,通常服务器会在响应里带Expires头,按那个时间的一半去刷新比较稳妥。
呼入流程是这样的:服务器发INVITE过来,网关回100 Trying,解析SDP,然后回180 Ringing(如果需要振铃),接着根据SDP里的媒体信息准备RTP接收端口,准备好后回200 OK,收到ACK后会话正式建立,开始推流。呼出流程反过来,网关主动发INVITE,等对方200 OK后回ACK。
这里有个细节:SDP的offer/answer模型。INVITE里带的SDP是offer,网关回的200 OK里带的SDP是answer。answer里必须包含网关实际使用的媒体参数,比如RTP端口、负载类型、编码格式。如果网关不支持offer里的编码,要么拒绝(回488),要么在answer里只保留自己支持的。
4.2 SDP解析与媒体参数提取
SDP是纯文本,格式不复杂,但字段多。核心要提取的是m=行(媒体描述)、a=rtpmap(负载类型到编码的映射)、a=fmtp(编码参数)、c=行(连接地址)。
举个例子,一段典型的视频SDP:
m=video 50000 RTP/AVP 96 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1;profile-level-id=42e01f c=IN IP4 192.168.1.100这告诉我们:视频流,端口50000,负载类型96,编码H.264,时钟频率90000,打包模式1,对端接收地址是192.168.1.100。网关就要把RTP包发到192.168.1.100:50000,负载类型填96,时间戳按90000Hz的时钟来算。
解析SDP我建议用现成的库,比如PJSIP自带的SDP解析器,或者libsdp。自己写正则匹配容易在格式变体上翻车,比如有的设备a=fmtp里参数顺序不一样,有的用c=行在会话级别而不是媒体级别。
4.3 媒体协商的取舍策略
实际项目里最头疼的是编码不匹配。摄像机出的是H.265,SIP对端只支持H.264,怎么办?三个选择:一是拒绝会话,告诉对方不支持;二是转码,解码H.265再编码H.264,CPU开销大;三是协商时只声明支持的编码,让对方来适配。
我一般优先选第三个,在answer里只列网关能处理的编码,如果对端也不支持,那就只能转码或者拒绝。转码这块如果真要做,建议用硬件编码器或者专门的转码服务,纯软件转码在x86上跑几路就到顶了。
还有一个坑是负载类型动态分配。96到127是动态负载类型,不同会话可以用不同的值。网关在生成answer时,要确保自己选的负载类型和对端offer里的不冲突,通常直接沿用offer里的值最省事。
5. 媒体转发核心实现
5.1 RTP打包与时间戳处理
重新打包RTP,核心是处理好三个字段:序列号、时间戳、SSRC。序列号每个包加1,溢出回绕;时间戳按帧的采样时刻乘以时钟频率计算;SSRC每个会话随机生成一个,保证不同会话不冲突。
以H.264为例,一帧可能很大,超过MTU(通常1500字节)就要分片。RFC 6184定义了三种打包模式:单NALU模式、STAP-A模式(聚合多个小NALU)、FU-A模式(分片大NALU)。实际转发时,最常用的是FU-A,把一个大NALU切成多个RTP包,每个包带FU indicator和FU header。
// FU-A分片示意 void sendH264Nalu(uint8_t* nalu, int len) { int maxPayload = 1400; // 留出RTP头空间 if (len <= maxPayload) { // 单NALU模式,直接发 sendRtpPacket(nalu, len, marker); } else { // FU-A模式,分片 int offset = 1; // 跳过NALU头 while (offset < len) { int chunk = std::min(maxPayload - 2, len - offset); // 构造FU indicator + FU header + 数据 // ... offset += chunk; } } }时间戳这块,H.264的时钟频率是90000Hz,一帧的时间戳增量是90000 / 帧率。比如25帧每秒,增量就是3600。要注意的是,时间戳应该基于采集时刻而不是发送时刻,否则网络抖动会导致播放端时间轴错乱。
5.2 RTCP反馈与丢包处理
RTP负责传数据,RTCP负责传控制信息。接收端会定期发RTCP RR(Receiver Report),里面包含丢包率、抖动等信息。网关收到RR后,可以根据丢包情况调整发送策略,比如降低码率或者重传关键帧。
不过实际项目里,很多SIP终端对RTCP的支持并不完整,有的甚至不发RR。这种情况下,网关只能靠自己的发送统计来粗略判断链路质量。如果要做可靠的丢包重传,需要实现RTCP NACK和PLI/FIR,这个复杂度就上去了,一般项目里可以先不做。
注意:RTCP的端口通常是RTP端口加1,但SDP里如果明确指定了RTCP端口,要以SDP为准。有的设备RTCP走单独的端口,不能想当然。
5.3 缓冲区管理与背压
媒体转发最怕的是生产快、消费慢。RTSP源以固定帧率推流,如果SIP对端的网络带宽不够,RTP包发不出去,缓冲区就会堆积,最后内存爆掉。
我的做法是给每个会话设一个固定大小的环形缓冲区,比如存200个RTP包。新包来了如果缓冲区满,就丢弃最老的包(或者直接丢新包,看策略)。同时监控缓冲区的填充程度,如果持续超过阈值,就主动降低发送码率,比如丢弃非关键帧,只发I帧和P帧。
背压的另一个手段是控制RTSP侧的拉流速度。live555的FramedSource支持暂停和恢复,如果发送缓冲区满了,可以暂时停止从RTSP源取数据,等缓冲区空了再恢复。这个机制叫flow control,live555里有现成的接口,用好了能有效防止内存暴涨。
6. 常见问题与排查实录
6.1 会话建立失败排查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| SIP注册失败 | 用户名密码错、服务器地址错、网络不通 | 抓包看401挑战后的响应,检查Authorization头 |
| INVITE后无响应 | 服务器未转发、网关未监听端口 | 确认网关SIP端口(通常5060)是否可达 |
| 200 OK后无媒体 | SDP协商失败、RTP端口不通 | 检查SDP里的IP端口,用抓包工具看RTP是否发出 |
| 播放花屏 | 时间戳错乱、分片错误 | 检查RTP时间戳增量是否恒定,FU-A分片是否正确 |
| 声音视频不同步 | 音视频时间戳基准不一致 | 统一用RTCP SR对齐,或按采集时刻重算 |
6.2 几个我踩过的坑
第一个坑:live555的MediaSession析构顺序。如果你先删了RTSPClient再删MediaSession,会崩溃。正确顺序是先Medium::close(session),再Medium::close(client)。这个在文档里没写清楚,我是调试了半天才发现的。
第二个坑:SIP的Contact头。注册的时候Contact头里的IP必须是网关对外可达的地址,如果你填了内网地址,服务器回INVITE的时候可能找不到你。多网卡环境下尤其要注意,得指定用哪个网卡的IP。
第三个坑:RTP端口冲突。多个会话如果都用live555自动分配的端口,偶尔会撞上。我的做法是维护一个端口池,手动分配,用完回收,避免冲突。
第四个坑:H.264的SPS/PPS。重新打包的时候,SPS和PPS必须定期发送,否则对端解码器可能因为丢失参数集而花屏。我一般是在每个I帧前都带上SPS/PPS,虽然浪费点带宽,但稳定性好很多。
6.3 性能调优经验
单机跑几十路的时候,瓶颈往往在内存拷贝。从RTSP收到数据,到打包成RTP发出去,中间如果拷贝好几次,CPU就上去了。优化手段是用零拷贝,让FramedSource直接操作接收缓冲区,打包的时候也尽量复用内存。
另一个优化点是减少锁竞争。即使是单线程,如果用了线程安全的队列,每次入队出队都有锁开销。可以改成无锁队列,或者干脆用环形缓冲区加原子指针。
网络层面,增大socket缓冲区能减少丢包。SO_RCVBUF和SO_SNDBUF都设大一点,比如256KB。UDP本身不保证可靠,缓冲区大一点能吸收突发流量。
7. 部署与扩展思路
7.1 编译与依赖管理
live555的编译比较原始,它自带一个genMakefiles脚本,根据平台生成Makefile。Linux下一般是./genMakefiles linux然后make。生成的库是静态库,链接到你的项目里就行。
SIP栈如果用PJSIP,编译稍微麻烦点,要配置config_site.h,开启需要的功能模块。建议把依赖都做成子模块或者用包管理器管理,避免版本混乱。
整个项目的目录结构我习惯这么组织:
gateway/ ├── src/ │ ├── sip/ # SIP信令处理 │ ├── rtsp/ # RTSP客户端封装 │ ├── media/ # RTP打包转发 │ ├── core/ # 会话管理与状态机 │ └── main.cpp ├── third_party/ │ ├── live555/ │ └── pjsip/ ├── config/ │ └── gateway.conf # 配置文件 └── CMakeLists.txt7.2 配置化与运维接口
网关的配置项不少:SIP服务器地址、账号密码、RTSP源地址列表、RTP端口范围、日志级别等等。我建议用配置文件(比如JSON或者INI)管理,启动时加载,支持热重载。
运维接口方面,至少要有状态查询和会话管理两个功能。状态查询返回当前活跃会话数、每路会话的收发字节数、丢包率等;会话管理支持手动挂断某路会话、强制重连某路RTSP源。这些可以通过HTTP接口暴露,方便集成到监控系统里。
7.3 后续可扩展的方向
这套网关跑通之后,能扩展的方向挺多。比如支持更多协议,在RTSP之外加上GB28181、WebRTC的接入;增加录制功能,把转发的流同时存一份到本地或者对象存储;做集群,多台网关通过注册中心协调,实现负载均衡和故障转移。
还有一个实用的扩展是AI分析接入。在媒体转发的同时,把视频帧抽出来送给AI模型做检测,检测结果通过单独通道回传。这个在安防场景里需求很大,实现上就是在Sink里加一个分支,把帧数据复制一份给分析模块。
我个人在实际操作中的体会是,这类网关项目最难的不是某个协议本身,而是状态管理和异常处理。协议栈的API再复杂,看文档总能搞明白,但会话在什么情况下该迁移到什么状态、网络抖动时该重试还是该放弃、资源什么时候释放,这些才是真正考验设计的地方。我的建议是先把状态机画清楚,把每个状态的进入条件、退出条件、允许的操作都列出来,代码照着状态机写,出问题的概率会小很多。
最后再分享一个小技巧:调试这类网关,抓包是王道。Wireshark里同时看SIP、RTSP、RTP三种流,把时间轴对齐,一眼就能看出是哪一层出了问题。比看日志猜快得多。