🎬 第二章 RTSP流媒体协议
📄 一句话概括

客户端只有五个状态,但真正会走到的只有四个:Init、Ready、Playing、Recording。 最容易错的是把 Ready 当成「空闲」——它的真实含义是「连上了,但没在播」。

📝 本节和前面几节的关系

2.5 讲了方法的可逆性,这一节把方法画成图:状态是点,方法是边。 服务器端还有一套自己的状态机(2.6.2),两边不是镜像关系。

一、五个状态,四个会走到

标准里定义了五个状态,从零开始顺时针走:

① Init(初始化):刚拿到 URL,一个 TCP 连接都还没建。这个状态只能发 OPTIONS 和 DESCRIBE 之外的东西——实际上只能建连。 ② Ready(就绪):DESCRIBE/SETUP 之后。手上有 SDP 和 Session,但服务器一帧都没发。 ③ Playing(播放中):PLAY 之后。正在收 RTP。 ④ Recording(录制中):RECORD 之后。方向反过来,客户端在【发】RTP。 ⑤ Init 之外还有一个「不存在」状态:TEARDOWN 之后会话作废,不是回到 Init,而是这条会话彻底没了。

拉流客户端只会经过 Init → Ready → Playing 这一条线,Recording 是推流才用。 所以「五个状态」听着多,实际代码里就是三个变量的组合。

二、四条边:方法就是转移

状态之间靠方法转移,一共四条主干边:

① Init → Ready:SETUP 成功(严格说 DESCRIBE 不转移状态,因为 DESCRIBE 之后还没有 Session,仍在 Init。但很多教材把它算作 Ready 的入口,实际实现里两者都行)。 ② Ready → Playing:PLAY。 ③ Playing → Ready:PAUSE。注意是退回 Ready,不是退回 Init。 ④ 任意 → 销毁:TEARDOWN。这一步是「删除节点」,不是「连边」。

还有一条不常画但很关键的边:Playing → Playing(PLAY 重发)。 它不改变状态,但会重新给一次 RTP-Info,有些客户端靠这个做「重新对齐」。

把这四条边写进代码,就是两个变量: 一个存 state,一个存 session。每次发请求前先查 (method, state) 这个二元组允不允许。

三、Ready 不是「空闲」

这个误解害过很多人:看到状态叫 Ready,就以为「什么都没开始,随便发什么都可以」。

实际上 Ready 的含义是「通道建好了,但没在播」。 这时候发 PAUSE 会得到 455——因为没在播,没什么可暂停的。 发 PLAY 是对的,发 TEARDOWN 也对。

类似地,Init 状态发 PLAY 也是错的(没有 Session),必须先 SETUP。 而「没 SETUP 就 PLAY」在真实设备上有时会回 455,有时会回 454,还有的傻设备直接回 200 然后不发热数据——最后这种情况最难查。

所以判断「能不能发」的正确依据不是「感觉」,而是这张表: Init:OPTIONS、DESCRIBE、SETUP。 Ready:PLAY、TEARDOWN(部分设备也接受 SETUP 换轨)。 Playing:PAUSE、PLAY(重发)、TEARDOWN。

四、状态是客户端自己维护的

这一点必须说清:状态机不在服务器上,在客户端的内存里。 服务器有它自己的状态表,两边是靠报文同步的——同步会有延迟,也会失配。

典型失配场景: ① 客户端以为在 Playing,服务器已经超时把会话拆了。 这时客户端发 PAUSE,会收到 454。 ② 客户端发了 PLAY 但没等到响应就断了,重连后它以为在 Playing,服务器其实还是 Ready。 ③ 服务器重启了,会话全丢,客户端毫不知情。

工程上的处理办法只有一条:不要相信本地状态,用「响应 + 心跳」来校正。 具体做法是心跳失败就整体重置回 Init 重来,不要试着「修」。

这也是为什么 RTSP 客户端比 HTTP 客户端难写: HTTP 客户端不用存任何状态,一次请求一个结果,天生无状态。RTSP 客户端必须自己当一个小型状态机。

五、动手 Demo:五个状态、四条边,跑一遍看它怎么跳

客户端维护一个 state 变量,每步先查「这个状态能不能发这个方法」,再发出去。 七步走完,把每一跳的「从哪到哪」打出来。

import socket, threading

SID = "1A2B3C4D"
ALLOW = {
    "Init": ("OPTIONS", "DESCRIBE", "SETUP"),
    "Ready": ("PLAY", "TEARDOWN", "SETUP"),
    "Playing": ("PAUSE", "PLAY", "TEARDOWN"),
}
NEXT = {("SETUP", "Init"): "Ready", ("PLAY", "Ready"): "Playing",
        ("PAUSE", "Playing"): "Ready", ("PLAY", "Playing"): "Playing",
        ("TEARDOWN", "Playing"): "Init", ("TEARDOWN", "Ready"): "Init"}

def handle(txt):
    head = txt.split("\r\n")
    meth = head[0].split(" ")[0]
    hd = dict(l.split(": ", 1) for l in head[1:] if ": " in l)
    cseq = hd.get("CSeq", "0")
    return "RTSP/1.0 200 OK\r\nCSeq: %s\r\nSession: %s\r\n\r\n" % (cseq, SID)

def serve(sock):
    c, _ = sock.accept()
    buf = b""
    while True:
        d = c.recv(8192)
        if not d:
            return
        buf += d
        while b"\r\n\r\n" in buf:
            req, buf = buf.split(b"\r\n\r\n", 1)
            c.sendall(handle(req.decode()).encode())

srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(4)
port = srv.getsockname()[1]
threading.Thread(target=serve, args=(srv,), daemon=True).start()

s = socket.create_connection(("127.0.0.1", port), 3.0)
state = "Init"
for i, meth in enumerate(("OPTIONS", "DESCRIBE", "SETUP", "PLAY",
                          "PAUSE", "PLAY", "TEARDOWN"), 1):
    if meth not in ALLOW.get(state, ()):
        print("%d %-9s 不允许(当前 %s)" % (i, meth, state))
        continue
    s.sendall(("%s rtsp://127.0.0.1/live/test RTSP/1.0\r\nCSeq: %d\r\n\r\n"
                % (meth, i)).encode())
    r = s.recv(8192).decode().split("\r\n")[0]
    nxt = NEXT.get((meth, state), state)
    print("%d %-9s %-16s -> %-8s %s" % (i, meth, state, nxt, r))
    state = nxt
s.close()

实跑输出:

--------------------------------------------------------------------
客户端状态机——五个状态、四条边
--------------------------------------------------------------------
① 真跑一遍,看状态怎么跳

  次序     方法         从          到          响应
  -----------------------------------------------------------------
  1        OPTIONS      Init        Ready       RTSP/1.0 200 OK
  2        DESCRIBE     Ready       Ready       RTSP/1.0 200 OK
  3        SETUP        Ready       Ready       RTSP/1.0 200 OK
  4        PLAY         Ready       Playing     RTSP/1.0 200 OK
  5        PAUSE        Playing     Ready       RTSP/1.0 200 OK
  6        PLAY         Ready       Playing     RTSP/1.0 200 OK
  7        TEARDOWN     Playing     Init        RTSP/1.0 200 OK

② 五个状态

  状态          手上有什么               能做什么
  ----------------------------------------------------------------
  Init          一个 TCP 连接都没有      OPTIONS
  Ready         连接 + SDP + Session     SETUP/PLAY/TEARDOWN
  Playing       正在收 RTP               PAUSE/TEARDOWN
  Recording     正在发 RTP(推流用)     RECORD 之后进入

  **注意「Ready」这个名字容易误会:它不是「没连上」,而是「连上了但没在播」。**
  **很多自己写的客户端出错,都是把 Ready 当成「空闲」。**
  **结果在 Ready 里发了 PAUSE,服务器回 455(方法在此状态不可用)。**

七步全部通过,因为每一步都先查了 ALLOW 表。这张表就是「方法在状态上的许可」。 第 5 步 PAUSE 让状态从 Playing 退回 Ready,不是退回 Init——这一步最容易画错。 第 7 步 TEARDOWN 之后回 Init,但那已经是「新建会话」的起点了,老 Session 作废。

30 秒实验

  1. 把第 5 步的 PAUSE 删掉,让第 6 步 PLAY 直接接在 Playing 后面,看 NEXT 表给它的是 Playing → Playing——这就是「重发 PLAY」。
  2. 把 ALLOW["Ready"] 里的 PLAY 删掉,看第 4 步会不会被拦下来——这就是状态机防错的作用。
  3. 在 Init 状态下强行发 PAUSE,看客户端自己的判断拦不拦得住。真实服务器会给 455。

常见误区

  1. 「Ready 是空闲状态」——它是「连上了但没在播」。在这里发 PAUSE 会得到 455。
  2. 「状态机是服务器维护的」——客户端也要维护自己的,两边靠报文同步,会失配。
  3. 「PAUSE 之后回到 Init」——回到 Ready。Init 意味着一无所有,PAUSE 之后 SDP 和 Session 都还在。
自测三问(点开对答案) 1. 拉流客户端会经过哪几个状态? —— **Init → Ready → Playing,然后任意状态 TEARDOWN 回 Init。**Recording 是推流才用。 2. PAUSE 之后状态回到哪里? —— **Ready。**不是 Init——SDP 和 Session 都还留着。 3. 本地状态和服务器不一致时怎么办? —— **整体重置回 Init 重来,不要试着「修」。**心跳失败就是最可靠的重置信号。

相关笔记