1. 为什么说PLAY才是“播放”的真正开始
1.1 前面几步做了什么
把这个系列写到这里,前面几条命令其实都是铺垫。DESCRIBE负责从服务器拿到SDP,里面写了这个流是什么编码、有没有音视频、用什么端口承载;SETUP则是把传输通道定下来,RTP走UDP还是走TCP,端口号是多少,SSRC在服务器侧怎么分配。做完这两步,RTSP会话其实还停在“准备好了”的状态,服务器虽然知道你来了,但不会主动推给你任何一个媒体包。
这个状态在RTSP标准里叫Ready。你去看状态机,从Init到Ready,只是完成了会话建立和资源预留。真正让数据流动起来的,是今天要说的这条PLAY命令。很多刚接触RTSP的开发者会在这个地方产生一个错觉,以为SETUP完就开始收RTP了,等自己在客户端反复等包等不到,才意识到还差一条PLAY。这个错觉在我最早的调试过程里也出现过,后来抓包比对才彻底理解状态切换的意义。
1.2 PLAY请求的本质:状态机从Ready到Playing
从状态机的角度理解,PLAY就是告诉服务器:你准备好了,我也准备好了,现在开始往我这边发RTP/RTCP数据。服务器收到合法PLAY请求后,整个会话状态从Ready切换到Playing,此时服务器才真正创建发送任务、装载媒体数据、按时间戳调度发送。
我用一个生活场景来类比:DESCRIBE相当于你站在餐厅门口看菜单,SETUP相当于你告诉服务员“我要坐靠窗那张桌”,而PLAY就是你说“可以上菜了”。上菜之前,后厨即使已经把食材切好,也不会把菜端出来。RTP包就是那盘菜,服务器把它封装好放在那里,只有你发了PLAY,它才端到你面前。
这也就解释了为什么我在前面几篇里反复强调,构造RTSP客户端时不要跳过任何一条命令。很多人为了省事,上来就发PLAY,省了SETUP,结果服务器返回455 Method Not Valid In This State。这是服务器在告诉你,当前状态根本不允许执行PLAY。这个错误码后面会专门讲,现在先记住一个结论:PLAY必须建立在SETUP成功的基础上,这是硬性前提。
2. 构造一个健壮的PLAY请求
2.1 请求头三项:请求行、CSeq和Session
PLAY请求本身比DESCRIBE和SETUP要简洁,但这不代表你可以随意写。一个标准的PLAY请求需要包含三个必备元素:请求行、CSeq、Session。
请求行的格式是固定的,比如:
PLAY rtsp://192.168.1.100:554/live RTSP/1.0这里的URL必须跟前面DESCRIBE、SETUP用的是同一个。我见过有人在SETUP阶段用的是带具体track的URL,到了PLAY又写裸URL,结果服务器按照会话维度匹配的时候找不到对应的资源,直接返回404或454。稳妥的做法是,所有请求里的URL都保持跟DESCRIBE一致,PLAY就用基础流地址,不要画蛇添足加track后缀。
CSeq这个是老生常谈,每个请求都要递增。SESSION头则必须回带服务器在SETUP响应里给你的Session ID。如果服务器返回的是:
Session: 1234567890;timeout=60那么你在PLAY请求里就写:
CSeq: 7 Session: 1234567890这里有个细节需要注意:SETUP响应里Session字段后面可能带分号属性,比如timeout参数。你在回带时只需要取分号前的核心ID,不要把timeout一起带上。有些新手会把整个字符串原样贴回去,服务端解析时并不一定能容忍这种写法,我在实际调试中就遇到过因为多带了分号属性导致服务器无法识别Session的情况。所以解析时最好先用分号做切割,只保留第一段。
2.2 进阶参数:Range、Scale、Speed
除了三个必备字段,PLAY请求里最常带的一个可选字段是Range。它用于指定从什么时间点开始播放。最常见的是从零开始:
Range: npt=0.000-也可以指定从某个秒数开始,这通常用于seek操作:
Range: npt=10.500-npt的全称是Normal Play Time,也就是我们熟悉的媒体播放时间轴。这里要特别提醒一点:npt的时间是相对于媒体流内部的timestamp,不是你电脑上的墙钟时间。两者没有任何换算关系,别搞混。
还有一个字段叫Scale,用于控制播放速率。正常速度是1.0,2.0就是两倍速放,0.5就是半速,负数表示倒放。听起来很方便,但实际开发中我强烈不建议你一上来就支持负数Scale,因为大量服务器和摄像头设备对倒放的支持很差,有的直接返回错误,有的虽然返回200但实际表现完全不受控。如果产品需求里确实需要倍速播放,优先考虑从1.0、2.0、4.0这类正向整数倍开始测试。速度控制从协议上看非常美好,但放到真实设备上会暴露出大量时间戳和缓冲问题,这是我在多个设备上踩出来的教训。
Speed字段在较新版本的协议文档里被定义出来,它用于跟Scale配合表达更精确的播放速度。但在实际处理时,很多服务端并不强制要求这个字段,甚至有的设备压根不认识它。对于客户端开发来说,你不需要在第一个版本里自己主动发起Speed字段,先保持最简请求,把基础功能跑通再说。
2.3 一个能直接用的请求拼接示例
如果你是自己手写socket发送RTSP请求,这里给一个通用的拼接模板:
char play_req[512]; snprintf(play_req, sizeof(play_req), "PLAY %s RTSP/1.0\r\n" "CSeq: %d\r\n" "Session: %s\r\n" "Range: npt=0.000-\r\n" "\r\n", url, cseq++, session_id);发送之后,等待服务器响应。看到RTSP/1.0 200 OK,说明服务器已经接受PLAY请求,接下来它就会开始往你之前SETUP确定的端口或通道推RTP包。如果返回超时或者非200状态码,就要进入排查流程。别急着看RTP包有没有到,先把响应码看清,这是解决问题的第一步。
关于Session字段我再说一句,有些高仿实现里服务器返回的Session头可能不在SETUP响应里,而是在PLAY响应里才给。这不是主流做法,你也不需要刻意兼容。如果发现SETUP响应确实没有Session,而PLAY响应里带了新的Session,那就把这个作为会话标识继续使用。原则是:一切以服务端响应为准。
3. 响应解析:RTP-Info和RTCP SR是关键
3.1 RTP-Info不是附属品,它是起播同步的锚点
服务器返回200 OK之后,响应里通常会带一个RTP-Info头。这个头很多客户端开发者没当回事,但它恰恰是决定你能不能平稳起播的关键信息。
一个典型的RTP-Info长这样:
RTP-Info: url=rtsp://192.168.1.100:554/live/track1;seq=26000;rtptime=3618956362如果这条流有音视频两个track,那RTP-Info里就会用逗号分隔两组信息:
RTP-Info: url=.../track1;seq=26000;rtptime=3618956362, url=.../track2;seq=28012;rtptime=388320171这里面的seq和rtptime分别代表了你在收到第一个RTP包之前,需要预先知晓的两个初始值。seq是RTP序列号,表示服务器将从哪个序列号开始发送;rtptime是RTP时间戳,表示这个流在服务器的时间戳基准里从哪个点开始播。
乍一听好像很容易理解,但你真放到代码里,会发现这两个初始值在音视频同步里至关重要。客户端在解析出RTP-Info后,应该把每个track的起始seq和rtptime记录下来,后续收到的RTP包都要基于这两个值去做序列号连续性和时间戳推进的判断。
我在实际项目里吃过一个亏:当时使用的某个摄像头固件版本,PLAY响应的RTP-Info里其实没返回rtptime字段,只给了seq。我当时没有做兼容处理,直接拿着初始rtptime=0去计算,结果画面一开始就疯狂卡顿。后来才反应过来,只剩一个办法,就是等待第一条RTCP SR包,从SR包里取RTP时间戳和NTP时间的对应关系,才能算出正确的播放基准。
3.2 用RTCP SR完成时间戳映射
RTCP SR全称是Sender Report,由服务器周期性发出,里面带了一组非常有价值的映射关系:NTP时间戳和RTP时间戳的对应关系。它相当于服务器在告诉你:我的RTP时间戳走到这个数值时,对应的真实墙钟时间是几点几分。
这个映射为什么重要?因为RTP时间戳本身是基于采样率的单调递增数值。对H.264视频来说,默认是90kHz。也就是说每过一秒钟,RTP时间戳会增加90000。如果服务器写入的rtptime是3618956362,那么你需要在播放的时候把它映射到你的系统时间轴上去。
最简单的换算方式是维护一个偏移量:
int64_t ntp_time; // 从RTCP SR中获得 uint32_t rtp_time; // 从RTCP SR或RTP-Info中获得 int64_t rtp_to_ntp_offset = ntp_time - rtp_time / 90;后续收到一个RTP包,它的rtptime是X,那么它在NTP时间轴上对应的值是:
ntp = rtp_to_ntp_offset + X / 90;这个换算关系搞懂之后,音视频同步就只剩数学计算了。音频的采样率可能是44100或者48000,换算系数不同,但原理完全一样。你只要分别维护视频和音频各自的时间戳映射,然后在播放循环里对比两条流当前应该播放到的时间点,就能决定先播放谁、谁需要等一下。
这里有一个操作上的重点:RTCP SR不是只发一次,而是周期性发送,通常几秒一条。在实际解码播放过程中,你会发现SR包里的NTP和RTP映射可能会有轻微漂移。这是因为发送端和接收端的时钟频率不完全一致。所以客户端不能一直拿第一条SR的换算结果用到底,每隔一段时间需要用新的SR重新校准一次。实现时可以保留最近一条SR的映射数据,每来一条新的就更新一次。
3.3 多流场景的对应关系
当音视频分别用两个track传输时,PLAY响应里的RTP-Info会同时包含两条流的初始信息。你解析时要按url字段把它们对应到各自的track上去。一个常见的错误是只解析第一个,或者按顺序硬编码track0和track1,结果音频视频的初始seq和rtptime对不上,导致后续解码器收到的包时间戳混乱,画面和声音完全不同步。
正确做法是解析RTP-Info时,把url作为关联键,用它去跟DESCRIBE阶段解析出的SDP里的track地址做匹配。这样即使服务器返回的track顺序发生改变,你也能准确映射到解码器使用的track ID上。这在多摄像头、多track复用同一会话的场景下尤其重要。
4. 从收到PLAY响应到画面稳定输出
4.1 建立抖动缓冲区
服务器返回200 OK后,RTP包通常会很快到达。但这不意味着你要把每个包直接丢给解码器。网络传输天然存在抖动,同一个流的不同包延迟不同。如果“来一个播一个”,画面会像抽风一样一顿一顿的。
正确的做法是客户端维护一个抖动缓冲区,也叫Jitter Buffer。它的作用是把进入的RTP包先攒在一个队列里,按照时间戳排序,等累积到一定阈值后,再按照时间轴均匀地取出、送解。
实际实现时,视频track通常需要缓存约200到500毫秒的数据,音频可以稍微短一些。具体多少需要根据网络状况和设备的编码GOP大小调节。缓存太短起不到抗抖动作用,缓存太长又会明显增加画面延迟。对于实时监控场景来说,200毫秒左右是一个不错的起点,之后根据丢包率和卡顿反馈去动态调整。
这里有个工程上的小技巧:不要攒到固定数量才启动输出,而是按时间戳阈值判断。比如当前时间戳超过首包时间戳300毫秒后,才开始从缓冲区里按时间戳递增顺序取出RTP包。这样做的好处是无论帧率高低,起播行为都是稳定的。
4.2 丢包识别与乱序处理
RTP序列号是连续的,但网络传输中可能发生丢包,也可能发生乱序。判断丢包的标准很简单:如果新到的包序号比期望序号跳了一大段,那就说明中间这些包已经丢了,或还在路上。
处理策略是:小窗口的乱序可以靠等待解决。比如来了序号100的包,当前期望是102,那100大概率是迟到的旧包,直接丢弃即可。如果期望是100却收到105,那属于大跨度跳变,基本可以断定丢包了。这时候你的选择要么是等待RTCP反馈后要求重传,要么就是跳过这一段,让解码器用前一个关键帧继续硬扛。
在RTSP over TCP的方式下,RTP包会被封装在interleaved frame里,每个包前面有4个字节的通道标记和长度信息。这种方式下丢包概率远比UDP低,但一旦出现序号跳变,你发现问题的路径跟UDP是一致的。依然要靠序号判断连续性,靠时间戳决定是否丢弃。
很多摄像头设备在长期工作之后,RTP时间戳可能会出现跳变,比如从正常的90000Hz采样率突然跳到另一个值。如果发现时间戳增量异常大,建议直接把当前缓冲区的数据清空,重新对齐到最近的关键帧,否则解码器很可能出一片绿屏马赛克。
4.3 播放时钟如何均匀推进
播放时钟的推进方式直接决定了观感。很多初版RTSP客户端播出来的效果卡顿,原因不在解码,而在于没有用时间戳去驱动播放,而是用“收到包就立即显示”的节奏在被动播放。
正确思路是,以RTCP SR建立的时间戳映射为基准。假设视频帧的时间戳对应NTP时间是T0,当前帧对应T1,那么帧与帧之间的显示间隔就是T1减去T0。你的播放循环应该按照这个时间间隔去等待,然后丢给渲染器。而不是等网络包慢慢飘过来才决定什么时候显示。
音频的推进方式类似,只不过音频一般用设备的播放时钟来驱动,因为声音的连续性非常敏感。通常做法是先把音频PCM数据写入声卡缓冲区,以系统声卡时钟为准推进,视频则根据音频时钟做追帧或丢帧的处理。这样即使网络抖动,音频也能保持连续,视频偶发抖动人眼反而不易察觉。
5. 抓包验证与常见错误排查
5.1 一次完整PLAY交互的抓包时间线
这里我把自己在一台测试摄像头上的完整交互过程整理了一下,方便你对照自己的实现梳理排查。
| 方向 | 请求/响应摘要 | 关键字段 |
|---|---|---|
| 客户端→服务器 | OPTIONS | CSeq: 1 |
| 服务器→客户端 | 200 OK | Public: DESCRIBE, SETUP, TEARDOWN, PLAY, ... |
| 客户端→服务器 | DESCRIBE | CSeq: 2, Accept: application/sdp |
| 服务器→客户端 | 200 OK | Content-Type: application/sdp |
| 客户端→服务器 | SETUP | CSeq: 3, Transport: RTP/AVP;unicast;client_port=5000-5001 |
| 服务器→客户端 | 200 OK | Transport: ...;server_port=7000-7001;ssrc=0xAABBCC |
| 客户端→服务器 | PLAY | CSeq: 7, Session: 1234567890, Range: npt=0.000- |
| 服务器→客户端 | 200 OK | RTP-Info: url=...;seq=26000;rtptime=3618956362 |
| 服务器→客户端 | RTP包 | seq=26000, timestamp=3618956362 |
| 服务器→客户端 | RTCP SR | NTP时间戳与RTP时间戳映射 |
你可以看到,RTP的起始seq和rtptime正好跟RTP-Info里声明的一致。如果你的客户端抓包发现第一个RTP包的序列号跟RTP-Info对不上,先不要急着处理,确认一下这个包是不是来自旧连接的数据。有一种情况是服务器端旧会话的包还在链路里排队,新会话的包被挤在后面,导致你捕获到的第一个包并不是新会话的包。
5.2 错误码速查:454/455/456/459/461
PLAY阶段最常碰到的几个错误码,我总结成了下面这张速查表。
| 状态码 | 含义 | 常见触发原因 | 排查方向 |
|---|---|---|---|
| 200 | OK | 正常 | 无需处理 |
| 454 | Session Not Found | Session ID错误或已过期 | 检查SETUP响应中的Session值,确认没有多带timeout |
| 455 | Method Not Valid In This State | 当前状态不允许PLAY | 确认已先完成SETUP,且状态机处于Ready |
| 456 | Header Field Not Valid | 请求头参数非法 | 检查Session字段格式、Range时间格式是否合法 |
| 459 | Aggregate Operation Not Allowed | 不允许聚合操作 | 检查是否误带了多个track的URL在同一PLAY里 |
| 461 | Unsupported Transport | 传输方式不支持 | 检查SETUP阶段协商的传输方式与PLAY请求是否一致 |
454是第一个高频问题。它通常不在你第一次写PLAY时出现,而是在你重启客户端重连时出现。老会话在服务器侧还没过期,而你新起的会话因为某些原因没有拿到新的Session ID,导致服务器认为你用的是旧会话。解决办法是在客户端代码里维护一套完整的会话生命周期,每次重新DESCRIBE之后,检查SETUP响应里的Session是否有变化。
455也经常遇到,尤其是跳步操作。服务器在Init或Ready状态之外收到PLAY请求,直接拒绝。有经验的调试方法是在每个请求发送前都打印当前客户端所处的状态,确认它是否符合RTSP状态机的流转顺序。
5.3 源码级排错经验
最后分享几个我在实际调试中反复踩过的坑。
第一个坑是关于Range格式。标准写法是npt=0.000-,但有些设备对小数点后位数很敏感,写npt=0-会直接420或456。我的处理是固定生成三位小数的格式,即整数部分加三位小数加横线。这个格式在Linux下的某个摄像头和Windows服务端实现上都能稳定工作,建议你直接抄过去。
第二个坑是Socket读取超时。很多客户端在发送PLAY后立即进入阻塞式读取,但如果服务器因为某些原因迟迟不返回200 OK,线程就会卡死在recv上。我的做法是把接收超时设置成5秒,在超时后先检查底层连接是否仍然存活,若存活则再等待一个RTP到达事件。这样可以有效避免因为服务器端处理慢导致的客户端假死。
第三个坑是RTCP SR的parse字节序。SR包里的NTP时间戳是64位大端整数,高32位是秒,低32位是微秒部分。很多人直接按32位读,算出来的时间完全错乱。正确解析方式是先读高32位作为秒数,再读低32位并除以2的32次方转换成小数秒。如果这块写错了,时间戳映射会整体漂移几十分钟,表现在视频上就是起播后频繁跳帧。
第四个坑是我个人最深的体会:播放器起播前一定要等待RTP-Info落地,甚至等第一条RTCP SR到位后再送数据给解码器。我之前有个版本是收到200 OK就直接开解码线程,结果前几个RTP包的解码时间基准完全错乱,画面要卡两三秒才能恢复正常。后来改成“等SR就绪再起播”,整个起播过程变得非常顺滑。你如果也在做类似功能,建议在代码里加一个“播放就绪”信号,由RTP-Info解析和SR解析共同触发。
最后再说一点关于工程取舍的经验。RTSP这个协议看起来简单,真正落到产品上,细节多得像筛子。PLAY这条命令本身不长,但围绕它的状态管理、时间映射、缓冲策略,才是一个RTSP客户端能不能用的分水岭。如果你也在写播放器,不妨先把今天的参数和解析逻辑跑通,再去想界面和交互。协议这东西,基础牢了,上层怎么写都顺手。