很多做视频流媒体的人接触到的第一个协议就是RTSP,尤其是买了个海康、大华的摄像头,第一件事就是找RTSP地址往VLC里一贴,画面出来了。但如果你只是停留在"拿地址播放"这个阶段,一旦要自己动手写一个RTSP服务器,或者接到RK3588这类板子上把USB摄像头转成RTSP流,就很容易被一堆看似零散的细节卡住。这篇是系列文章的第一篇,我不打算堆概念,而是从"我要亲手实现一个RTSP服务器"的角度,把协议本身讲透。你看完这篇文章,再去看代码或者抓包,会觉得整个流程都是通的。
1. 先搞清楚RTSP在整个视频链路里的位置
1.1 你天天挂嘴边的"RTSP地址",其实只是入口
很多人在讲"RTSP地址"的时候,默认它和HTTP网址是同类东西——浏览器里输一个URL就拉回一个页面。但视频不一样。你往播放器里输入rtsp://192.168.1.64:554/Streaming/Channels/101,这串地址本质上只是"控制会话的入口"。真正在网络上跑的图像数据,走的是RTP协议,不是RTSP本身。
这就是第一件需要建立认知的事:RTSP是"带外控制协议"。它管的是"从哪个通道取流""用UDP还是TCP传数据""暂停还是继续播放",而音视频帧的裸数据,通过另外一个明确的媒体通道(RTP)在流动。我见过很多刚入门的人对着抓包工具找"为什么RTSP包里没图像数据",找半天找不到,就是这个概念没转过来。
有了这个底层概念,你再看海康、大华的取流地址,就更清楚了:
海康威视新版地址格式通常是:
rtsp://用户名:密码@IP:554/Streaming/Channels/101大华的一般是:
rtsp://用户名:密码@IP:554/cam/realmonitor?channel=1&subtype=0这些地址的路径部分其实就是你的RTSP服务器需要自己解释的"资源标识"。我写服务器的时候会把它叫做rtspUrlPath,解析出来后映射到具体的视频源(文件、摄像头、采集卡),在DESCRIBE阶段返回对应的媒体描述信息。
1.2 RTSP、RTP、RTCP三兄弟怎么分工
一个常见的误区是把RTSP当成"传视频的协议"。实际上一个完整的流媒体会话,往往同时跑三个协议:
- RTSP(实时流协议):控制面。负责会话建立、播放、暂停、停止。
- RTP(实时传输协议):数据面。承载真正的音视频载荷,比如H.264的NALU、AAC的音频帧。
- RTCP(实时传输控制协议):伴随RTP的控制协议。提供时间戳同步、丢包统计、往返时延等辅助数据。
用快递来打比方比较直观:RTSP是客服(下单、催单、取消),RTP是快递车(真正把货拉到你家),RTCP是配送App后台(让你知道货到哪了、跑了多久)。你写一个RTSP服务器,核心工作其实是两件事:第一,正确实现客服流程(RTSP的状态机和消息交互);第二,把货装车并发出去(用RTP打包视频数据)。第二件事往往才是真正的性能瓶颈。
1.3 为什么说RTSP不像HTTP那样"一次请求一次响应"
我用node.js写过HTTP服务,也用C写过RTSP服务,最大的体感差异就是RTSP存在会话概念。HTTP是无状态的,每次请求服务器不用记住你是谁;但RTSP里,你发出SETUP请求时,服务器会返回一个Session ID,后续的PLAY、PAUSE、TEARDOWN都要带着这个Session ID来,服务器凭它知道你在说哪一路会话。
这个差异直接决定了你的服务器内存里必须维护一张会话表,而不是像HTTP handler那样处理完就忘了。我后面写代码时会重点讲这个会话表的设计,但现在你要先在脑子里画一张图:每一个RTSP客户端从连接到断开期间,是沿着"初始化 -> 准备就绪 -> 正在播放 -> 准备就绪 -> 结束"这条轨道走的,这就是协议的状态机,也是整个RTSP服务器的骨架。
2. RTSP的状态机:写服务器前必须先背熟这张图
2.1 状态迁移是RTSP的骨架
RFC 2326(老版,2016年还有个RFC 7826更新)明确把RTSP定义的设备状态划成了两类:服务器侧的会话状态和客户端侧的播放状态。我们写服务器主要关注会话状态,待会儿我会分别讲。
会话状态的典型迁移过程如下:
INIT(初始状态) | | SETUP v READY(就绪状态,媒体已建立可传输的资源但尚未播放) | | PLAY v PLAYING(播放状态) | | PAUSE / 播放完毕 v READY | | TEARDOWN v INIT(会话销毁)这是所有RTSP服务器实现都必须遵守的骨架。你也许在某些设备上见过一套奇怪的交互流程——比如客户端不发SETUP直接发PLAY,那服务器就该返回状态码455 "Method Not Valid in This State"。这是协议用于"防呆"的手段,在实现的时候,你不能假设客户端按规范来,必须主动校验当前状态允许哪些方法。
说到状态感知,RFC 2326和更新的RFC 7826都把状态分了两个视角。实际调试中90%以上的冲突,都出在客户端拿到的状态和服务器自身维护的状态不一致上。
2.2 SETUP为什么要先于PLAY:会话不是你想建就建
很多初学者会觉得SETUP和PLAY两步有点冗余,为什么不能一步到位直接播?这是设计者故意的。SETUP阶段的职责有二:
第一,客户端和服务器要就"怎么传数据"达成一致。具体来说,就是Transport头。客户端告诉服务器:"我想用UDP,我的RTP端口是5000,RTCP端口是5001",服务器如果同意,会在响应里告诉客户端"我的RTP端口是5002,RTCP端口是5003"。这个握手如果放在PLAY里做,就会把"协商"和"开播"混在一起,出错时很难判断到底卡在哪一步。
第二,SETUP阶段服务器要做很多资源准备工作,比如打开摄像头、打开文件句柄、初始化编码器。这些操作如果不成功,服务器应该在SETUP阶段就报错,而不是等客户端发来PLAY才说"抱歉我起不来"。
我在实现单路转发服务器时还发现,有的客户端(某些老版本ffplay)如果收到SETUP的响应慢了一点,就会一直傻等。这提醒我们:SETUP阶段耗时操作较多时,可以考虑异步准备资源,但响应时机和媒体数据的可用时间,必须靠实现时权衡。
这里引出一个实现细节:RFC 2326还定义了服务器聚合控制(aggregate control)和单流控制的差异。如果你有一个媒体流同时包含视频和音频,那么你既可以为整个会话建一个Session,也可以为每个媒体流独立建Session。实际中大多数播放器的做法是:对每个媒体流发一个SETUP,但复用同一个Session ID。例如视频流SETUP返回Session: 1234567890,音频流SETUP也带同样的Session ID但不重建会话。你的服务器要能够识别"这是同一个会话的第二个流",而不是傻乎乎地新建会话。
2.3 这套状态机,和海康/大华设备有什么对应关系
你拿来一个海康摄像头,用VLC播放它的RTSP流时,抓包能看到一条很标准的交互链:
OPTIONS -> DESCRIBE -> SETUP -> PLAY -> RTP数据... -> TEARDOWN这个流程和状态机完全吻合。海康设备在收到SETUP之后才真正开始采集编码送流,收到PLAY后才把RTP流推到客户端端口。如果你只是DESCRIBE完就停在那,设备啥也不会干。
我拿RK3588的USB摄像头转RTSP流举例(这也是最近很常见的需求)。你通常会先用V4L2拿到摄像头原始帧,再用硬件编码器编码成H.264帧,最后封装成RTP发出去。整个过程对应到RTSP服务器里的位置大概是:
- DESCRIBE阶段:返回一条固定的SDP,声明"H.264视频流,用96号RTP载荷类型"。
- SETUP阶段:初始化V4L2和编码器,绑定RTP发送端口。
- PLAY阶段:开始从摄像头取帧编码并发送RTP包。
- TEARDOWN阶段:停止编码、释放设备。
所以你会发现,状态机不是协议圈子里的"纸上谈兵",它直接决定了你的硬件资源何时打开、何时关闭。我写第一版的时候偷懒在DESCRIBE里就打开摄像头,结果一个客户端连过来看个信息,摄像头就被占用了,别人再也连不上。正确的做法是资源初始化尽量推迟到SETUP,资源释放尽量提前到TEARDOWN。
3. 抓包级别的协议交互:从OPTIONS到TEARDOWN一镜到底
3.1 方法逐个拆解:每个方法到底是干嘛的
RTSP的标准方法列表不算多,但当服务器的话,每个方法的处理逻辑都得认真写。我按实际交互顺序挨个说:
- OPTIONS:客户端问服务器"你会哪些方法"。服务器响应时要在
Public头里列出自己支持的方法,比如Public: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER。这个方法是廉价的握手,很多播放器用它探路。 - DESCRIBE:客户端向服务器要"媒体描述信息",即SDP。响应消息的
Content-Type是application/sdp,正文是SDP。这一步不建立媒体传输通道,纯信息获取。 - SETUP:协商传输参数。客户端要带上Transport头,指定传输协议(RTP/AVP UDP,或RTP/AVP/TCP),自己的端口或Channel号。服务器回复时补上自己的端口。
- PLAY:真正开始推流。服务器收到PLAY后就开始往客户端发RTP包。PLAY的响应里可以带上
Range字段,比如Range: npt=0.000-表示从开头播到结束。 - PAUSE:暂停推流。服务器应该停止发送RTP数据,但保持会话。有些设备不支持PAUSE,会在
Public里就不声明它。 - TEARDOWN:终止会话。服务器收到后应释放资源,RTP停止,会话表删除记录。
- GET_PARAMETER/SET_PARAMETER:用来做保活或参数查询。很多摄像头客户端没事会发一个
GET_PARAMETER,服务器回一个200即可,不用真的实现复杂的参数拉取。
可以把这个流程理解成去图书馆借书:OPTIONS是问"请问你们有什么服务",DESCRIBE是查"这本书的目录是什么",SETUP是"锁定座位并确认借阅规则",PLAY是"把书翻开开始读",PAUSE是"歇一会儿",TEARDOWN是"合上书走人"。
3.2 CSeq为什么必须逐条递增:一个真实踩坑
CSeq(Sequence Number)是RTSP消息里最容易忽视又最容易出事的字段。每个请求都必须带一个CSeq整数,服务端响应时必须原样返回同一个数字。播放器通常从1开始逐条递增,一旦发现响应里的CSeq对不上,就会认为通信已乱,直接断开。
我在实现音视频同步时踩过一个印象很深的坑:当时为了调试方便,在日志里打印了每一条请求的CSeq,但响应却是按"我自己的处理顺序"拼接的,导致响应里的CSeq和客户端请求里的CSeq错位。客户端是VLC,表现非常奇怪:偶尔画面能出来,偶尔直接停在"正在缓冲"。抓包后才发现,VLC同时发出了DESCRIBE和SETUP,而我处理线程把顺序打乱了,先回SETUP后回DESCRIBE,而且CSeq错位。从那以后,我把"请求-响应匹配"写成了一个全局规则:回响应的时候,直接用请求里的CSeq和URI,不做任何二次修改。这样即使并发请求乱序到达,也能保证响应和请求是配对的。
这也是为什么写RTSP服务器时要特别小心多线程:不是每个请求都要立刻处理,但每个响应都必须匹配到原始请求,否则协议层就是错的。
3.3 Session头和超时:很多单路Demo漏掉了它
SETUP成功后的响应里,服务端必须带上Session头,比如:
Session: 98987432;timeout=60分号后面的timeout可选,表示会话空闲多少秒后服务端会主动销毁。客户端在后续所有请求里都要在头部带上Session: 98987432,服务端则靠它定位会话。
很多只跑单路Demo的代码根本不校验Session头,反正一个客户端只有一个会话。但只要你打算支持多个客户端同时连接,Session就变得不可忽略。我之前遇到一个场景:同一路摄像头,两个窗口同时在VLC里播放。第一个窗口正常,第二个窗口一直黑屏。查了半天,发现Demo实现里用的是全局变量存"当前会话",第二个客户端的PLAY请求把第一个会话覆盖了。改成会话表管理之后,立刻恢复。
服务端空闲会话一定记得超时回收。VLC这类播放器平时不发保活包,如果你加了timeout=60却不在60秒内回收会话,时间久了系统里会堆满僵尸状态的会话,句柄和端口都被占着。保活机制这一块,GET_PARAMETER是常用手段,部分客户端会周期发空参请求,服务端回复200即可。
这里插一个和最新规范的细节:RFC 7826里对"SERVER"是否必须在SETUP响应中才会返回Session字段做了一致性确认,同时也提醒客户端必须在各方法请求里带会话ID。实现的时候,我倾向于对所有方法(除OPTIONS、DESCRIBE和TEARDOWN)都校验Session存在性。TEARDOWN特殊情况是:即使一个无效Session的TEARDOWN,最好也回200,方便客户端无脑清理本地资源。
3.4 抓包验证:标准交互的完整账目
下面直接给出一套完整的RTSP交替消息,你对照着抓包看就能对上号。
这里是客户端(C)向服务器(S)发起的典型交互:
C->S: OPTIONS rtsp://192.168.1.100:554/live/test RTSP/1.0 CSeq: 1 S->C: RTSP/1.0 200 OK CSeq: 1 Public: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER C->S: DESCRIBE rtsp://192.168.1.100:554/live/test RTSP/1.0 CSeq: 2 Accept: application/sdp S->C: RTSP/1.0 200 OK CSeq: 2 Content-Type: application/sdp Content-Length: 188 v=0 o=- 1699999999999999 1 IN IP4 192.168.1.100 s=Live Stream t=0 0 m=video 0 RTP/AVP 96 a=control:rtsp://192.168.1.100:554/live/test a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1;profile-level-id=42001F C->S: SETUP rtsp://192.168.1.100:554/live/test RTSP/1.0 CSeq: 3 Transport: RTP/AVP;unicast;client_port=5000-5001 S->C: RTSP/1.0 200 OK CSeq: 3 Session: 123456789;timeout=60 Transport: RTP/AVP;unicast;client_port=5000-5001;server_port=5002-5003 C->S: PLAY rtsp://192.168.1.100:554/live/test RTSP/1.0 CSeq: 4 Session: 123456789 S->C: RTSP/1.0 200 OK CSeq: 4 Session: 123456789 (此时服务端开始向5000端口发RTP视频包,并向5001端口周期发送RTCP SR包) C->S: TEARDOWN rtsp://192.168.1.100:554/live/test RTSP/1.0 CSeq: 5 Session: 123456789 S->C: RTSP/1.0 200 OK CSeq: 5这一段你看完应该能发现几个规律:每个响应都回显CSeq;会话ID在SETUP之后一路跟随;Transport头写明了RTP端口五元组。照这个账目去写,出错概率会小很多。
4. 关键细节:Transport、SDP和时间戳
4.1 Transport头:RTP over UDP还是TCP,一个字段决定整个架构
Transport头是整个RTSP协商里技术含量最高的部分。常见的取值:
Transport: RTP/AVP;unicast;client_port=5000-5001 Transport: RTP/AVP/TCP;unicast;interleaved=0-1 Transport: RTP/AVP;multicast第一种是UDP传输,视频数据直接由服务器往客户端的5000端口发UDP包。好处是实时性高、延迟低;坏处是NAT、防火墙环境下客户端收不到包,连接秒断。
第二种是TCP传输(也叫RTP over RTSP),视频数据不单独占用端口,而是复用在RTSP的TCP连接里,通过interleaved=0-1表示RTP数据走通道0,RTCP数据走通道1。每个RTP包在TCP流里会被加一个4字节头:第一个字节是通道号(0或1),接着是2字节的数据长度,再往后才是RTP包头和数据。这是我在实现时最需要小心的二进制细节。
为什么实际开发中要优先支持TCP?因为跨网络环境太多了。海康、大华的很多设备默认会同时开放UDP和TCP两种方式,但VLC连不上的时候,手动把"rtsp传输"改成TCP往往能通,就是这个道理。
我们的服务器在SETUP阶段收到的Transport头决定了后续数据发送的方式,不能看着客户端说"我用TCP"结果服务器还在往UDP端口扔数据包。必须在SETUP响应之后把数据通路固化下来,后头PLAY就照着这条路走。
4.2 SDP不归RTSP管,但没有它DESCRIBE就是空壳
SDP(Session Description Protocol)是独立的RFC 2327/8866协议,RTSP只是用它作为"媒体描述"的载荷格式。但你的RTSP服务器在DESCRIBE阶段必须产出正确的SDP,否则播放器无法知道接收到的比特流该怎么解析。
最基本的SDP里,m=video这一行描述媒体的传输方式;a=control这一行标明后续SETUP请求的URL;a=rtpmap说明RTP载荷编号与编码格式的对应关系。我常见到的错误是把a=rtpmap:96 H264/90000里的90000写错。H.264的RTP时间戳时钟固定是90000Hz,不是9000,也不是摄像头帧率。这是RTP规范里定死的,PS流和H.265同样使用90000Hz。
举个例子,海康设备的SDP里经常出现多个m=行,分别对应视频和音频。你如果用VLC播放主码流,选择的是m=video部分,音频流会在m=audio里。我们自研服务器时,如果只想转发视频,SDP里只描述一路视频即可。
还有一个小讲究:a=control里既可以是完整的rtsp://URL,也可以是相对路径。老设备经常用a=control:trackID=0这种相对形式,也就是说后续SETUP的URI是用前面DESCRIBE的URL加上/trackID=0拼出来的。实现时最好在SDP解析后,把"trackID=0"当成一层路径挂到会话资源上。
4.3 时间戳和NTP时间:播放器不傻,你不喂它就卡
RTP包头里有三个字段对播放端至关重要:timestamp(时间戳)、sequence number(序列号)、SSRC(同步源标识)。
其中时间戳不是随便填的,它必须以SDP里声明的时钟频率为单位。H.264是90000Hz,所以每秒钟要增加90000个计数。假设摄像头是30fps,那么平均每帧的时间戳增量是90000/30 = 3000。你可以让每帧的时间戳在上一帧基础上增加3000,也可以严格根据采样时刻换算,但总的来说,平滑递增比精确演算更符合播放器预期。
我在做第一版的时候踩过一个坑:帧率在动态变化(比如USB摄像头在暗光下掉到15帧),但我还按固定3000来发,播放器VLC没崩,但画面会出现"时快时慢"的抖动。后来我改成用采集时刻的微秒时间戳除以1000000再乘以90000,结果卡顿瞬间消失。
扩展一下对于RTP over TCP的收包缓冲问题,很多播放器在TCP模式下要求RTP包尽量定长连续。如果你把每个H.264帧拆成好几个RTP包,且每个包大小差距悬殊,播放端必须依赖sequence number重排,如果重排缓冲区太小,丢帧率会明显变高。做服务器的时候,最好对超过MTU的NALU(比如H.264的IDR帧,常大于几KB)做分片,用RTP的FU-A分片方式拆成不超过~1200字节的小包,确保尽力不要分得太碎。
4.4 RTCP SR报文:RTP流的"报时员"
很多自制RTSP服务器会忽略RTCP SR包,觉得播放器不依赖它也能出画面。实际上,对于长时间播放的场景,没有RTCP SR包,播放器在做音视频同步和延迟估计时会有问题。
RTCP SR(Sender Report)报文的核心作用是把RTP时间戳和NTP时间对应起来。播放器通过它知道"我收到一个RTP时间戳为90000的包,对应的是几点几分几秒"。没有这个对应关系,播放器只能靠猜,音画同步一般无法保证。
写服务器的时候,我建议哪怕只是视频流,也至少每2秒发一个RTCP SR包。一个SR包的构造并不复杂:包头里,版本号是2,包类型是200,长度字段按32位字计算;紧接着是发报者的SSRC;然后是一个64位的NTP时间戳和一个32位的RTP时间戳;再后面是发送的包计数和字节计数。对应到RTCP的Sender Info部分,对于仅推流的会话,最后那个统计字段可以不写0,但至少给一个粗粒度的计数,也能让VLC等多数字播放器更稳定。
如果你在SETUP时协商的是interleaved TCP,RTCP包依然可以通过对应的channel(通常是奇数channel)发出去。一些播放器要求客户端和服务器都必须往对方的RTCP端口发RR包,服务器如果收到RR包,其实可以忽略,但依然要能识别。
5. 实战热身:用现成工具验证协议实现
5.1 先不用自己写,用VLC和ffplay看标准实现
你不需要先把自己写的服务器跑起来才能"看懂RTSP"。我建议你用免费的网络测试流,把标准实现的交互流程跑一遍,建立"正确感"。
最经典的方式是VLC打开:
rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov这是WOWZA提供的公开测试流,虽然时好时坏,但通常能用。打开后,把VLC的"工具 -> 媒体信息"打开,能看到编解码器和码率信息,再配合Wireshark看交互过程。
ffplay也是离线验证的好帮手:
ffplay -rtsp_transport tcp rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov参数-rtsp_transport tcp会强制播放器走RTSP over TCP通道,这在防火墙环境里很实用。这些工具的作用是帮你形成"正常交互长什么样"的直觉,之后再去看自己服务器返回的东西,是否同频一照便知。
5.2 自己模拟一个"假服务器"去骗过客户端测试
在正式写服务器前,有个特别好的练习:先用Python写一个只回固定响应的"假RTSP服务器",骗过ffplay或VLC的早期协商阶段。
具体思路是监听554端口,收到请求后把请求打印出来,再按标准的响应格式回固定文本。例如,收到DESCRIBE就回一条写好的SDP,收到SETUP就回Session: 1000和Transport信息,收到PLAY后就先不真正推流(或者推一点测试RTP包)。这个练习能让你非常直观地理解每条消息的作用,也比直接啃代码更容易上手。
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("0.0.0.0", 554)) sock.listen(5) while True: conn, addr = sock.accept() data = conn.recv(4096).decode("utf-8", errors="ignore") print("-----------") print(data) if "OPTIONS" in data: response = "RTSP/1.0 200 OK\r\nCSeq: 1\r\nPublic: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN\r\n\r\n" elif "DESCRIBE" in data: sdp = ( "v=0\r\n" "o=- 0 0 IN IP4 127.0.0.1\r\n" "s=Test Stream\r\n" "t=0 0\r\n" "m=video 0 RTP/AVP 96\r\n" "a=control:rtsp://127.0.0.1:554/live\r\n" "a=rtpmap:96 H264/90000\r\n" ) response = ( "RTSP/1.0 200 OK\r\n" "CSeq: 2\r\n" "Content-Type: application/sdp\r\n" f"Content-Length: {len(sdp.encode())}\r\n\r\n" + sdp ) elif "SETUP" in data: response = ( "RTSP/1.0 200 OK\r\n" "CSeq: 3\r\n" "Session: 12345;timeout=60\r\n" "Transport: RTP/AVP;unicast;client_port=5000-5001;server_port=5002-5003\r\n\r\n" ) elif "PLAY" in data: response = "RTSP/1.0 200 OK\r\nCSeq: 4\r\nSession: 12345\r\n\r\n" else: response = "RTSP/1.0 200 OK\r\nCSeq: 999\r\n\r\n" conn.send(response.encode("utf-8")) conn.close()这段代码里有个地方我故意不写完整:CSeq是从请求里提取出来的,而不是硬编码。上面只是为了演示格式,真正做的时候,你需要从请求行里解析出CSeq。VLC会在OPTIONS、DESCRIBE、SETUP之间用连续的CSeq递增,如果响应里CSeq对不上,会直接判定协议错误。
5.3 用GStreamer构造测试管线,给自己后面的实现做基准
GStreamer和RTSP的搭配非常常见。你用下面的命令可以把一个本地视频变成RTSP流:
gst-launch-1.0 -v v4l2src device=/dev/video0 ! videoconvert ! x264enc tune=zerolatency ! rtph264pay pt=96 ! rtspclientsink location=rtsp://127.0.0.1:8554/livertspclientsink本质是RTSP客户端插件,它会和服务器之间完成完整的RTSP协商。如果你没有自己的服务器,也可以先用rtsp-server库提供的test-launch命令建一个标准服务器:
./test-launch "( v4l2src ! videoconvert ! x264enc tune=zerolatency ! rtph264pay name=pay0 pt=96 )"然后VLC拉取:
rtsp://127.0.0.1:8554/live这样做的好处是:你能先跑通一条基准链路,观察标准服务器返回的SDP和Transport头是什么样子,作为你后面手写实现的对标。如果用Raspberry Pi或RK3588这类带硬编的平台,把x264enc换成v4l2h264enc,就能验证USB摄像头转RTSP的最常见工程路径。这里的核心还是在协议层:不同编码器产生的H.264流如何封装成RTP,如何用SDP描述。
5.4 抓包看什么:几个必看的关键点
打开Wireshark,过滤rtsp || rtp || rtcp,重点观察这几点:
- 请求和响应是否一一对应,CSeq是否匹配。
- SETUP响应里的Transport头是否覆盖了客户端请求的transport和端口,server_port是否是服务器真正在监听的端口。
- PLAY之后,是否真的从server_port对应的RTP端口发来了UDP包(UDP模式);如果是TCP模式,RTSP的同一个TCP连接里是否出现了
interleaved通道数据,也就是RTP包被正确的4字节头包装。 - RTCP SR包是否周期性出现,里面的NTP和RTP时间戳是否在合理范围。
如果不看抓包,你写出来的协议栈永远是在"猜"。我在写自己的RTP推流模块时,靠抓包发现了3个问题:端口写反了、时间戳时钟算错了、RTCP SR的包类型长度字段到了网络字节序没做字节序转换。抓包是RTSP开发里最趁手的调试工具。
6. 动手之前,这八个坑你先记下来
6.1 只支持UDP还是只支持TCP,都会赶走一半客户端
我见过不少新人写的服务器,SETUP只实现了UDP分支,遇到Transport: RTP/AVP/TCP就直接回461 "Unsupported Transport"。这在局域网内可能没问题,到了公网环境或跨运营商,UDP包经常被黑洞,客户端根本连不上。
建议第一版就两个分支都做,因为TCP分支的实现并不复杂——SETUP时记录interleaved=0-1,PLAY后在同一个TCP连接里按帧格式发送RTP包即可。不同的是,TCP模式下没有端口协商和防火墙穿透问题,很多B端场景用它反而省事。
6.2 方法名必须大小写敏感,别用"不区分大小写"的解析
RTSP方法名是大小写敏感的,options不是OPTIONS,Describe也不是DESCRIBE。规范的实现是:解析请求行时,方法名取完直接和字符串表精确匹配,不对就回400 Bad Request或501 Not Implemented。我见过一个实现用strcasecmp做方法名匹配,结果客户端发setup居然能过,后面的交互就全乱了。
6.3 Range头不是可选项:很多播放器依赖它定位
PLAY请求里,客户端经常带Range头,例如:
Range: npt=0.000-这表示从文件开头播到结尾。如果你的服务器要支持点播文件(比如MP4或录像文件),就必须解析Range并精确控制播放起点。VLC和ffplay都会带这个头,如果服务器忽略了range里面的起点时间戳,画面就会从头开始而不是从用户拖动的位置开始。
对流媒体类型的直播源,Range通常是npt=0.000-,服务器可以直接忽略;但对点播场景,Range的实现直接影响播放体验。
6.4 帧率切换、码率突变的场景,seq和时间戳要持续平滑
摄像头在暗光环境下自动降低帧率是常态。你的RTP发送模块如果等着"每来一帧发一帧",并且每次都从0开始累计时间戳,播放端会出现严重的花屏或卡顿。
一个经验做法是:帧率波动时保持RTP sequence number连续递增,绝不跳跃;时间戳在帧间隔变化时,严格按照"上一帧时间戳 + 当前帧间隔对应的时钟计数"来加。举例来说,上一帧到这一帧实际耗时0.033秒,时间戳就加0.033 * 90000 = 2970。这样即使帧率从30fps跳到15fps,时间戳也是平滑的。
6.5 不要忘了网络字节序
RTP头、RTP扩展头、RTCP头都要求按网络字节序(大端)传输。写C/C++或Go代码时,SSRC、timestamp、length这些字段都记得使用htonl/htons(或对应语言的字节序转换函数)。我这里特别强调,是因为在x86上直接memcpy结构体最容易出这个问题:本地看起来是对的,抓包看却是反的,VLC连会话都建立不起来。
6.6 Digest认证不是救命稻草,但很多人问
海康的RTSP地址里带用户名密码(如rtsp://admin:12345@192.168.1.64:554/...),那么这个"密码"是不一定走Digest认证的,而且很多消费级设备协商时用的是Basic或Digest。我们自写服务器时,如果只在内网使用且没有用户隔离需求,可以只在DESCRIBE/PLAY等关键方法上做简单Token校验,或者支持Basic + Base64即可。要想严格安全,需要完整实现RFC 2069/2617摘要认证,牵扯到MD5、nonce、realm等逻辑。这个不急,放到后面的"认证"文章里专门展开。
6.7 会话表的key别只存IP和端口
如果用"客户端IP + 客户端端口"当会话表的key,会遇到一个很尴尬的情况:同一台机器上两个播放器软件同时连过来,源IP相同,源端口很可能不同,没问题;但如果是NAT内网多台设备,到你的服务器看来源IP可能完全不同,自然也没问题。真正出问题的是,同一个VLC进程播放同一路媒体时,它会连续发多个SETUP(视频和音频各一个),这几个SETUP来自同一个IP和端口,会话key就冲突了。
正确做法是:SETUP成功时生成一个全局唯一ID(自增整数或UUID),作为Session ID,后续PLAY/PAUSE/TEARDOWN都靠Session ID来查找会话。不要用IP端口二元组做会话主键。
6.8 错误响应别乱给:状态码要和场景对上
RTSP状态码的语义比HTTP更严格:
400 Bad Request:请求语法错误401 Unauthorized:需要认证404 Not Found:URI不存在405 Method Not Allowed:方法不允许454 Session Not Found:会话ID无效455 Method Not Valid in This State:当前状态下方法不被允许(比如还没SETUP就去PLAY)461 Unsupported Transport:Transport头不支持的组合500 Internal Server Error:服务器内部错误
很多新手实现遇到SETUP但Transport不支持时,直接回500,调试起来特别痛苦。正确的状态码能帮你和客户端把问题定位到具体环节。
结尾分享:第一次跑通自己实现时的感受
等你把上面这些点过完一遍,第一次用自己写的服务器把画面送出去的时候,你会明显感觉到"协议"不是一堆死板的文字,而是一套真正在网络上跑着的规则。我自己的经验是:不要急着追求支持所有特性,先把无认证、单视频流、RTP over UDP/ TCP的经典链路做通,能过VLC、ffplay、gst-launch三件套,就已经超过大多数Demo水平了。下一篇文章我会讲具体的数据结构设计和代码骨架,到时候咱们就在代码层面把这一篇的每个坑都踩一遍。