🎬 第三章 RTP与RTCP流媒体协议
📄 一句话概括

JRTPLIB 的用法永远是五步:建会话、设参数、加目的、发、收。 三个新手必错点全在细节里:不释放包、时间戳单位、只调一次 PollData。

📝 本节和前面几节的关系

3.4.1 把库编出来了,这一节把它用起来。 后面 3.4.5 要发 H.264 码流,用的就是这五步骨架。

一、五步骨架

不管做什么,JRTPLIB 的调用顺序都是这五步: ① 建对象:RTPSession sess;——这一步还没占端口。 ② 建会话:sess.Create(params)——bind 端口,定 MTU 与时间戳单位。 ③ 设默认值:SetDefaultPayloadType(96)、SetDefaultMark(false)。这两行决定每包头上的 PT 与 M 位。 ④ 加目的地址:AddDestination(addr, port)——发的时候用。 ⑤ 发与收:SendPacket 发,PollData + GetNextPacket 收。

五步里有四步都是配置,只有第⑤步真干活。 这也是封装型库的标准形态:把「状态」收进对象,把「动作」压成两三个函数。

为什么第①步不占端口? 因为 RTPSession 的构造函数不做任何系统调用,真正的 socket 是在 Create 里建的。 这个设计是为了「先构造后配置」——你可以先把参数准备好再一次建起来。

二、发与收是两个方向

SendPacket 是主动的,调一次发一包。 PollData 是被动的,它只是「去系统缓冲区看看有没有新包」。

所以收包必须写成循环: ① PollData():把网卡上到的包搬进库的内部队列。 ② 遍历源:GotoFirstSource / GotoNextSource。 ③ 取包:GetNextPacket 取出一个,用完 DeletePacket。 ④ 回到①:直到 PollData 返回 0 为止。

这个结构决定了线程模型: 发包在一个线程、收包在另一个线程,或者用库自带的后台收包线程(RTPSessionParams::SetPollThreadActive)。单线程「发完再收」在真实场景里会卡死,因为对端也在等你发。

三、三个必错点

① 收完不释放包。 GetNextPacket 返回的是堆上的对象,用完必须 DeletePacket。 漏一行就稳定泄漏——而这一行恰好是官方示例里最容易被跳过的。 ② 时间戳单位没设。 RTPSessionParams 里有个 SetTimestampUnit(1.0 / 90000.0)。 忘了设,默认按「1 秒一个单位」算,时间戳会差出 90000 倍。 现象极难查:收包计数完全正常,但画面一帧都放不出来。 ③ 只调一次 PollData。 它一次可能只搬进来几个包,剩下的还在系统缓冲区。 正确写法是一直循环到它返回 0。 少写这个循环的表现是「能收到包,但总比实际少」——丢包率统计会莫名其妙偏高。

三个错误的共同点:都不报错。 JRTPLIB 的接口几乎不抛异常,参数错了就是「行为不对」,所以调试全靠对照现象。

四、一次会话的完整生命周期

按时间顺序看一遍,就知道每一步该放哪: ① 准备:定本地端口、对端地址、PT、时钟频率、MTU。 ② 建立:Create 一次,之后所有包共用这个会话。 ③ 运行:发线程按帧节奏调 SendPacket,收线程循环 PollData。 ④ 反馈:库内部会自动发 RTCP 的 SR 与 RR,不用你管——这是它比自己写省事的又一地方。 ⑤ 结束:BYEDestroy 发一个 BYE 再销毁。 直接 delete 也行,只是对端要等超时才知你走了。

注意第④步:RTCP 是自动的。 很多人以为要自己拼 RR 包,其实库默认就开了,只有要读接收端的丢包统计时才需要自己处理 RTCP 回调。

五、动手 Demo:搭一个会话——30 个包,边收边排

照五步骨架跑一遍:一端发 30 个包,另一端按序号排好。 关键看时间戳序列:每三个包一组,同组时间戳相同。

import socket, struct

# ② Create:两端各 bind 一个端口
snd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
snd.bind(("127.0.0.1", 0))
rcv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rcv.bind(("127.0.0.1", 0))
rcv.settimeout(2.0)
dest = rcv.getsockname()      # ④ AddDestination

N = 30
for i in range(N):            # ⑤ SendPacket
    # 每 3 个包算一帧,同帧时间戳相同
    hdr = struct.pack("!BBHII", 0x80, 96, 5000 + i, (i // 3) * 3600, 0x1A2B3C4D)
    snd.sendto(hdr + bytes([0x41]) + b"p" * 60, dest)

buf = {}
while len(buf) < N:           # ⑤ PollData 循环
    try:
        d, _ = rcv.recvfrom(2048)
    except socket.timeout:
        break
    b0, b1, seq, ts, ssrc = struct.unpack("!BBHII", d[:12])
    buf[seq] = (ts, len(d))

seqs = sorted(buf)
print("收到 %d 个包,序号 %d ~ %d" % (len(seqs), seqs[0], seqs[-1]))
print("时间戳前 6 个:%s" % [buf[s][0] for s in seqs[:6]])
print("平均每包 %d 字节" % (sum(v[1] for v in buf.values()) // len(buf)))

实跑输出:

--------------------------------------------------------------------
搭一个会话——收发包的骨架只有五步
--------------------------------------------------------------------
**① 真跑一遍:一端发 30 个包,另一端边收边排**

  收到 30 个包,序号 5000 ~ 5029
  按序号排好后,时间戳序列的前 6 个:0、0、0、3600、3600、3600
  平均每包 73 字节(含 12 字节 RTP 头)

**② 五步骨架,逐句对应**

  步     JRTPLIB 里的写法               这一步真正在做什么
  ------------------------------------------------------------------------
  1      RTPSession sess;               建对象,还没占端口
  2      Create(RTPSessionParams)       bind 端口,定 MTU 与时间戳单位
  3      SetDefaultPayloadType(96)      把 PT 写进每个发出的包头
  4      AddDestination(addr, port)     记下对端,发的时候用
  5      SendPacket / PollData          发与收,收完要释放包

**③ 三个新手最容易错的地方**

  **① 收完不释放包。**
  **JRTPLIB 的 `GetNextPacket` 返回的是堆上的对象,用完必须 `DeletePacket`。**
  **漏一行就稳定泄漏,跑几小时内存爆掉——而这是官方示例里最容易被跳过的一行。**
  **② 时间戳单位没设。**
  **构造 `RTPSessionParams` 时有个 `SetTimestampUnit(1/90000.0)`。**
  **忘了设它就默认按 1 秒算,时间戳会差出 90000 倍。**
  **现象是画面一帧都放不出来,但收包计数完全正常——很难查。**
  **③ 只调一次 PollData。**
  **接收是个循环:`PollData()` → 遍历源 → 取包。只调一次只能拿到一个包,剩下的堆在系统缓冲区里。**
  **正确的写法是一个 while 循环,直到取不出为止。**

30 个包全部收到,序号 5000 到 5029——沙箱环回上 UDP 几乎不丢,真实网络不会这么干净。 时间戳序列是 0、0、0、3600、3600、3600——三个包一帧,这就是「序号按包、时间戳按帧」的直接证据。 平均 73 字节 = 12 字节头 + 1 字节 NAL 头 + 60 字节载荷,头占了六分之一——小包传输时开销比例非常难看。

30 秒实验

  1. 把 i // 3 改成 i,看时间戳序列变成什么——变成「一包一帧」,同帧共享时间戳的关系就消失了。
  2. 把 30 个包里的 seq 打乱顺序再发,看排序逻辑还对不对——这就是 PollData 内部排队窗口在做的事。
  3. 把 rcv 的 settimeout 改成 0.01 秒,看会不会收不全——超时太短会在「包还没搬完」时就退出循环。

常见误区

  1. 「GetNextPacket 拿到的包不用管」——必须 DeletePacket,否则稳定泄漏。
  2. 「时间戳单位用默认的就行」——默认是秒,视频要设成 1/90000,否则差 90000 倍。
  3. 「PollData 调一次就够」——要循环到返回 0,否则收包数总比实际少。
自测三问(点开对答案) 1. 五步骨架里,哪一步才真正占端口? —— **第②步 `Create`。**构造函数不碰系统调用。 2. 为什么收包必须写成循环? —— **一次 `PollData` 只搬一部分包。**剩下的还在系统缓冲区里,不循环就会「收得到但收不全」。 3. RTCP 的 SR、RR 要自己发吗? —— **不用,库默认自动发。**只有要读丢包统计时才处理 RTCP 回调。

相关笔记