你是不是也这样:拿到一台新设备,心里想着赶紧把流拉出来,一头扎进去直接发 SETUP。结果服务器半天不吭声,或者甩给你一个 405 Method Not Allowed。这不是设备坏了,而是你把 RTSP 里最基本的礼仪给省略了——你连对方会什么、不会什么都没问,上来就指望人家给你干活。
RTSP 协议里专门给双方设计了一个“自我介绍”的时机,就是 OPTION 方法。它不需要建立会话,不需要传递媒体参数,只要一句话就能问出服务器支持哪些方法。我手头做流媒体接入和协议调试的时候,第一步永远是它,而不是 DESCRIBE 或 SETUP。这篇文章会把这最简单的一个方法掰开揉碎:先讲它为什么在 RTSP 会话里占有这么特殊的位置,再拆请求和响应的原始报文,接着手写一个能跑的 Python 客户端,最后聊聊那些协议文档上没写着、但在实际设备上经常让你掉坑的细节。
这套内容适合谁?一个是像我一样正在写 RTSP 客户端、服务端协议的开发者,另一个是天天跟摄像头、NVR 这些设备打交道的集成工程师。即便是刚接触流媒体协议的新人,只要顺着章节往下读,也能把这一个方法彻底吃透。
1. 为什么第一步永远是“探路”:OPTION在RTSP会话中的角色定位
1.1 没有能力清单的协议,就像没有菜单的餐厅
HTTP 协议之所以让人舒服,是因为它有一套大家都认的固定方法:GET、POST、PUT、DELETE 就这么几个,服务端支不支持一目了然。RTSP 不一样。标准里定义了 DESCRIBE、SETUP、PLAY、PAUSE、TEARDOWN、GET_PARAMETER、SET_PARAMETER、REDIRECT 等一大堆方法,但没有任何强制条款要求设备必须全都实现。
这就有意思了。有的设备不支持暂停,有的设备不支持录像回放,还有不少设备把 GET_PARAMETER 当私有扩展用,用来查询音量、编码器参数之类的信息。如果你连对方能力都没搞清楚,直接调用一个它不支持的方法,服务器只能回你一个 405 Method Not Allowed——请求到了,但这事儿人家确实干不了。
OPTION 存在的意义,就是让你在正式下单之前先把菜单拿过来看看。它本身不携带任何媒体信息,只是向服务器问一句:你能做什么?服务器接到后会根据自己的实现,把支持的方法列表通过 Public 响应头告诉你。这就像去一家不熟悉的餐厅,你先找服务员要菜单,而不是直接大喊“来一份牛排”然后发现人家是家火锅店。
1.2 OPTION 在 RTSP 会话中的坐标:八仙过海前的开场
一个标准的 RTSP 会话,大体上遵循下面的步骤:
| 顺序 | 方法 | 作用 | 是否必须 | 前置条件 |
|---|---|---|---|---|
| 1 | OPTION | 能力探测 | 建议执行 | 建立 TCP 连接后 |
| 2 | DESCRIBE | 获取媒体描述(SDP) | 是 | 最好先完成 OPTION |
| 3 | SETUP | 建立媒体传输通道 | 是 | DESCRIBE 成功 |
| 4 | PLAY | 开始播放 | 是 | SETUP 成功 |
| 5 | PAUSE | 暂停播放 | 可选 | PLAY 状态下 |
| 6 | TEARDOWN | 结束会话并拆除通道 | 是 | 任何状态 |
光看这张表你可能会问:既然 OPTION 是“建议执行”而不是“必须执行”,那我跳过它行不行?答案是可以,但风险你自己扛。我之前对接过一批设备,它们对 OPTION 的响应是正常的,但如果你跳过 OPTION 直接发 DESCRIBE,它们也能正常响应。可是另一批设备就完全不同——它们要求客户端必须先发 OPTION,否则后面对任何方法的请求都直接返回错误。
更实际的原因是,OPTION 是最便宜的探路石。它几乎没有成本,一旦连上 TCP 就能发,但如果你的客户端连 OPTION 都收到 401、403、405 这类异常响应,后面 DESCRIBE 和 SETUP 大概率也会有问题。与其耗费精力去解析一个没有任何结果的 DESCRIBE 响应,不如先用 OPTION 把服务器的脾气摸清楚。
1.3 别把 OPTION 写成 OPTIONS:一个埋了二十年的雷
这是我见过最离谱也最常见的低级错误。RTSP 在 RFC 2326 中定义的方法是 OPTION,单数;而 SIP 协议里的能力询问方法是 OPTIONS,复数。两个协议都走文本格式,请求行长得还特别像,所以很多人从 SIP 那边转过来,或者凭记忆照抄,直接在 RTSP 里写了一个 OPTIONS。
服务器一看方法名不在它能识别的集合里,立刻回 400 Bad Request。你拿着抓包文件翻来覆去看半天,报文格式也没毛病,最后才发现是方法名多了个“S”。这种错误一次两次是粗心,每次对接都犯就得长点记性了。RTSP 里所有方法名都是单数占用——DESCRIBE、SETUP、PLAY、PAUSE 这些动词本身也没有复数形式,直接把 OPTIONS 这类习惯性拼写从脑袋里删掉就好。
2. OPTION的原始报文长什么样:请求行、CSeq与Public字段的字节级拆解
2.1 一个 OPTION 请求的核心:请求行 + CSeq + 空行
RTSP 是典型的文本协议,和 HTTP 一样用 CRLF(也就是 \r\n)来分隔每一行。一个最简单的 OPTION 请求长这样:
OPTION rtsp://192.168.1.100:8554/live RTSP/1.0\r\n CSeq: 1\r\n \r\n拆开看,三行暴露了三个核心要素。
第一行是请求行,格式是“方法 + 空格 + URI + 空格 + 协议版本”。方法顶格写,必须是大写的 OPTION。URI 填的是你要操作的流地址,这里要注意,OPTION 的 URI 不一定非得和真实资源完全对应,标准没有强制要求服务器校验它,但实践中最稳妥的做法还是填你准备访问的那一路流的完整地址。协议版本一般是 RTSP/1.0,有些新设备支持 RTSP/2.0,但直到今天,我在嵌入式设备上见到的绝大多数实现还是 1.0。
第二行是 CSeq 头。CSeq(sequence number)是由客户端维护的一个整数序号,每个请求都要递增。它的作用是让服务器在返回响应时带上同一个序号,这样客户端就能把响应和请求一一对应起来。这里有个容易被忽略的铁律:CSeq 的递增只跟客户端相关,服务器是无脑回显的。你发 1,它就回 1;你发 5,它就回 5。千万不要以为服务器会自己维护序号——它不会。
最后一定要有一个空行,也就是 \r\n\r\n 结尾。这空行在抓包里看着不起眼,但它代表“头部字段到此结束”。很多初学的朋友在代码里用字符串拼接请求时,忘了最后加这一对 \r\n,结果服务器一直等不到头部结束,客户端也一直等不到响应,两边就这么僵住了。
2.2 响应的灵魂:状态行、CSeq、Public
服务器收到 OPTION 请求后,会回一个类似下面的响应:
RTSP/1.0 200 OK\r\n CSeq: 1\r\n Public: OPTION, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER\r\n \r\n第一行状态行,RTSP/1.0 加状态码加原因短语。200 OK 表示一切都好,后面会专门讲其他状态码。
第二行 CSeq 回显,这里就必须和请求里的 CSeq 一致。如果服务器返回的 CSeq 和请求对不上,客户端应该直接丢弃这个响应,因为这不是对当前请求的应答,可能是历史上某个乱序响应残留。虽然 TCP 里不太容易出现这种乱序,但协议就是这么设计的,客户端代码也应该按这个规则处理。
第三行 Public 是整个 OPTION 响应里信息量最大的字段。它用逗号分隔列出服务器支持的方法名。注意,这里不区分大小写,但为了解析方便,建议一律转成大写再比较。另外,响应里还可能出现 Date、Session、Server 等字段,但那些都不是 OPTION 关心的重点。
2.3 一个完整抓包样本:长成什么样子才算对
我模拟一个典型交互,客户端 192.168.1.50 向服务器 192.168.1.100 的 8554 端口发起 OPTION 请求。在线路上实实在在跑的字节就是下面这几行:
OPTION rtsp://192.168.1.100:8554/live RTSP/1.0\r\n CSeq: 2\r\n User-Agent: StreamTestClient/1.0\r\n \r\n服务器回:
RTSP/1.0 200 OK\r\n CSeq: 2\r\n Public: DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, OPTION\r\n Date: Thu, 01 Jan 2025 10:00:00 GMT\r\n \r\n看到几个关键点了:请求里的 CSeq 是 2,响应里也是 2;Public 的方法列表顺序每次可能不一样,解析时千万别依赖顺序;响应的头部之间可以有多个字段,顺序更没有强制要求。只要你的代码正确处理这些“不固定”,它就是一个合格的 RTSP 客户端。如果你用抓包工具去看,报文里的 \r\n 是显示为实际回车的,所以看起来会是一行一个字段的样子,和上面这个格式完全一致。
3. 三分钟手写一个OPTION客户端:Python socket实现与响应解析
3.1 最简实现:连接、发送、接收
我不喜欢一上来就搬一大堆现成库的调用方式,那样你永远不知道底层在做什么。既然标题叫“手撕”,我们就从 socket 开始。Python 标准库的 socket 模块足够胜任这个任务:
import socket HOST = "192.168.1.100" PORT = 8554 URI = "rtsp://192.168.1.100:8554/live" def build_option_request(cseq=1): request = ( f"OPTION {URI} RTSP/1.0\r\n" f"CSeq: {cseq}\r\n" "\r\n" # 头部结束,必须有这个空行 ) return request.encode("utf-8") sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) try: sock.connect((HOST, PORT)) sock.sendall(build_option_request()) response = sock.recv(4096) print(response.decode("utf-8", errors="replace")) finally: sock.close()这段代码里最容易被忽略的是 settimeout(5)。RTSP 服务器如果一直没收到完整的请求头部,它可能会无限等待,服务端不关闭连接,客户端 recv 也就会一直阻塞下去。设置超时是基本保命措施。
另外注意,发送请求时我用的是 sendall 而不是 send。send 可能只发送了部分字节,sendall 会循环发送直到全部发出,对这类小报文来说更可靠。
3.2 解析响应:状态码、CSeq 和 Public 一个都不能少
上面只是把原始字节打印出来,真正干活还得把响应结构化。RTSP 响应格式是固定的:第一行状态行,然后若干头部字段,空行,再是可能的正文。OPTION 一般没有正文,所以我们只需要解析到空行就够了。
下面这段代码会解析并校验响应:
def parse_option_response(response_text: str): lines = response_text.split("\r\n") status_line = lines[0].split(" ", 2) version = status_line[0] status_code = int(status_line[1]) reason = status_line[2] headers = {} for line in lines[1:]: if ":" in line: key, value = line.split(":", 1) headers[key.strip().lower()] = value.strip() cseq = int(headers.get("cseq", -1)) public_list = [ m.strip().upper() for m in headers.get("public", "").split(",") if m.strip() ] return { "version": version, "status_code": status_code, "reason": reason, "cseq": cseq, "public": public_list, } resp = sock.recv(4096).decode("utf-8", errors="replace") parsed = parse_option_response(resp)这里解析头部时有个细节:字段名统一转小写存 key,字段值里的空格要 strip 掉。因为 Windows 后台、Linux 服务端对空格的处理不一样,有的发CSeq: 1,有的发CSeq:1,写成宽松解析才能兼容两类实现。
拿到 public_list 之后,你的客户端就可以做决策了:列表里有没有 DESCRIBE?有没有 SETUP?如果服务器连 DESCRIBE 都不列,那就别往下走了,这设备很可能不支持标准 RTSP 拉流。
3.3 判断失败:状态码不对,一切分析都白搭
OPTION 请求发出后,除了 200,你还会遇到一堆其他状态码。每种状态码传达的信息完全不同,我建议把解析这块做成一个小工具函数,每次调试都能直接打印出结论:
if parsed["status_code"] == 200: print("OPTION succeeded, methods:", parsed["public"]) elif parsed["status_code"] == 401: print("Authentication required") elif parsed["status_code"] == 403: print("Forbidden: maybe IP restricted or credential missing") elif parsed["status_code"] == 404: print("URI not found: check stream path") elif parsed["status_code"] == 405: print("Method not allowed: check spelling (OPTION not OPTIONS)") else: print("Unexpected status:", parsed["status_code"], parsed["reason"])不要小看这个分支,它其实把大半的设备对接问题都定位得明明白白了。我在做联调的时候,先把 OPTION 跑通,后面 DESCRIBE、SETUP、PLAY 的成功率会高很多。OPTION 这一步如果拿到奇怪的响应,后面大概率也是一路红灯。
4. 服务器不会直接告诉你的事:状态码、鉴权挑战与能力回退策略
4.1 读懂服务器用状态码“说话”
协议文档不会把不同状态码在真实设备上的表现写给你看,但实际联调时状态码就是服务器唯一的信息渠道。下面这张表格是我在大量设备对接后整理的“状态码翻译表”:
| 状态码 | 原因短语 | 常见触发场景 | 后续动作 |
|---|---|---|---|
| 200 | OK | 一切正常 | 读取 Public 列表 |
| 400 | Bad Request | 方法名拼错、CSeq 缺失、空行缺失、版本错误 | 检查报文格式 |
| 401 | Unauthorized | 需要鉴权 | 按 4.2 处理 |
| 403 | Forbidden | IP 限制、账号无权限 | 检查网络策略和账号 |
| 404 | Not Found | URI 路径错误、流不存在 | 核对媒体流 ID |
| 405 | Method Not Allowed | 服务器未实现 OPTION | 尝试 DESCRIBE |
| 505 | RTSP Version Not Supported | 服务端只支持 RTSP/2.0 | 改用 RTSP/2.0 |
注意 400 和 405 的区别。400 表示服务器根本不认你这个请求,多半是报错级别的问题;405 表示服务器认识这个方法但不允许在该 URI 上使用。如果 OPTION 都收到 405,说明这台设备可能不支持 RTSP 标准方法,你得考虑其他接入方式了。
4.2 用 OPTION 触发“鉴权挑战”,主动把登录参数套出来
很多带用户认证的设备,并不会在收到第一个请求时就返回数据,而是先扔一个 401。这个 401 不是拒绝,而是在向你发起“鉴权挑战”。
具体过程是:你发 OPTION,服务器回 401,同时在响应头里带一个 WWW-Authenticate 字段。这个字段指定了鉴权方式,比如WWW-Authenticate: Digest realm="StreamServer", nonce="abc123..."。客户端需要读取这个 realm 和 nonce,用账号密码算出一个摘要值,再重新发起请求,在 Authorization 头里带上计算结果。
聪明的做法是,策略性地先用 OPTION 去“碰壁”。因为 OPTION 是最轻量的请求,用触发 401 拿到的 realm 和 nonce,可以被后续所有请求复用。等你发 DESCRIBE 的时候,直接带上 Authorization 头,服务器就不会再卡你一次了。
有些设备的鉴权还区分“匿名访问”和“授权访问”。匿名 OPTION 可能返回 200,但 DESCRIBE 返回 401。这种情况下,你照样可以用 OPTION 建立一条 TCP 连接,先拿到服务器的能力列表,再在后续请求中加入鉴权头。
4.3 服务器不回 Public 字段?别慌,按依赖链回退
RFC 2326 里说得很清楚,服务器可以合法地选择不返回 Public 字段。也就是说,OPTION 返回 200 OK 却没有 Public,是完全正常的。不少设备就是这么干的,响应干净得只有状态行和 CSeq。
遇到这种情况,你的客户端不能直接放弃。正确的回退策略是用“依赖链探测”代替“方法表探测”:
先发 DESCRIBE,它不依赖任何前置条件,只要服务器实现了标准 RTSP 就会回 200 或 401。如果 DESCRIBE 成功了,再发 SETUP;如果 SETUP 成功,再发 PLAY。每走一步都是对上一步的验证,同时也是对服务器能力的间接探测。
这条链比 Public 字段更可靠的地方在于,Public 只是服务器宣称的能力,不代表它一定能按你的网络条件完成传输。而依赖链里每一步都是真实请求,每个响应都是实际结果。宁可多走两步,也比你拿着一个看似完整的方法列表瞎猜要靠谱。
5. 把OPTION当“心跳”——保活探测与实践中踩过的坑
5.1 为什么 OPTION 是最适合做保活的方法
RTSP 会话建立之后,播放可能会持续很久。服务器在会话期间如果长时间收不到客户端任何请求,部分实现会认为客户端已经失联,主动把会话关掉。尤其是经过一些状态防火墙或 NAT 的场景,运维侧可能因为“连接空闲”把 TCP 会话清理掉,导致播放到一半画面卡住,重新建立会话又要从 DESCRIBE 走一遍。
OPTION 作为保活手段有一个天然优势:它不改变会话状态,也不传递媒体数据,服务器只要回一个 200,整个会话的时间戳和状态都纹丝不动。哪怕你正处于 PLAY 状态,发 OPTION 也不会导致暂停或跳帧。
实践上,我一般把心跳间隔设置为 30 秒到 60 秒之间。具体取值取决于你对服务器空闲策略的掌握,如果不知道,先按 30 秒来。间隔太短会刷出一堆没有意义的报文,间隔太长又可能保不住连接。发送时 CSeq 照常递增,不需要重新从 1 开始。
5.2 那些年踩过的坑,按影响排序给你列一遍
第一个坑是 CSeq 不连续。有些初学者在保活线程里单独维护一个序号变量,和主线程的请求序号互相独立,结果服务器收到的 CSeq 一会儿 1 一会儿 3,反而把自己的状态搞乱了。正确的做法是让所有请求共享同一个递增序列,保活请求也要正常计数。
第二个坑是拼写错误,前面提到过的 OPTIONS。这个问题在保活场景里尤其隐蔽,因为服务器回 400 后不会主动断连,你的程序可能一直不知道保活失败,直到会话被服务端清理才发现。
第三个坑是接收响应后没有消费干净。OPTION 响应很小,但同一时刻服务器也可能处理了其他请求。如果你的客户端没有按 CSeq 对响应做关联,直接把第一个收到的响应当成 OPTION 的响应,很容易把其他请求的响应错误关联到当前状态上。所以在多请求并发的客户端里,解析响应时一定要校验 CSeq。
第四个坑是重试逻辑写得太激进。OPTION 超时后立刻重发,并不会让网络变好。比较合理的策略是:第一次超时后等 1 秒重试,连续失败 3 次以上就主动断开会话并重建。很多服务器在 TCP 断连之后会清理对应的 RTSP 会话,你死命向一个已经半开的连接上写数据,除了浪费资源没有别的用处。
5.3 什么情况下可以直接跳过 OPTION?
不是所有场景都非要先跑一次 OPTION。如果你的客户端只需要四个标准方法——DESCRIBE、SETUP、PLAY、TEARDOWN,而且你已经确认目标服务器支持这些方法,那么跳过 OPTION、直接从 DESCRIBE 开始是完全可以接受的。
但有一个例外:当你第一次接入某款新设备、或者服务器行为发生异常变化时,OPTION 依然是定位问题的最短路径。我之前遇到过一种情况,某设备在新固件版本里悄悄增加了对 RTSP/2.0 的支持,但默认仍然监听 RTSP/1.0 的端口。此时直接发 RTSP/1.0 的 DESCRIBE 竟然能正常响应,可一旦触发某些特定字段,服务器会突然改用 2.0 语义,导致报文互不兼容。后来还是靠 OPTION 探测出服务器实际声明支持的版本,才找到沟通的共同语言。
所以我的建议是:平时可以跳过,但你的客户端底层必须保留 OPTION 能力。遇到莫名其妙的 405、505、连接中断,第一步就是用 OPTION 重新探一下服务器能力,再根据响应调整策略。
写到这里,我自己对这个方法的感受是很矛盾的——它看上去简单到只有一句话,但真正把这个方法的请求、响应、状态码和边界问题全部搞清楚之后,你对整个 RTSP 协议的把握都会上一个台阶。OPTION 是整套协议里最小的一个齿轮,可偏偏就是这个齿轮,能让后续所有交互都顺畅转动起来。下次再接入新设备,先发一个 OPTION,看看它回你什么,你会少走很多冤枉路。