作者:Schulzrinne, Casner, Frederick, Jacobson(2003年7月)
本文档是对 RFC 3550 的完整中文解读,面向初学者,力求通俗易懂。


目录

  1. RTP 是什么?为什么需要它?
  2. 核心概念词汇表
  3. RTP 数据包头部详解
  4. RTCP 控制协议详解
  5. RTCP 报文类型(SR / RR / SDES / BYE / APP)
  6. RTCP 发送间隔算法
  7. 混合器与翻译器
  8. SSRC 标识符的分配与冲突处理
  9. 安全性
  10. 附录算法详解(含 C++ 完整代码)

1. RTP 是什么?为什么需要它?

1.1 生活中的类比

想象你在打电话。声音是实时的——你说话,对方立刻听到。如果网络把你的声音包裹成数据包,就需要一套协议来保证:

  • 接收方知道每个包的播放顺序(序列号)
  • 接收方知道每个包的播放时间点(时间戳)
  • 接收方能判断丢了多少包(统计)
    这就是 RTP 存在的意义。

1.2 RTP 的位置

+---------------------------+
|   应用层(音视频应用)      |
+---------------------------+
|   RTP(实时传输协议)       |  <-- 我们研究的主角
+---------------------------+
|   RTCP(控制协议)          |  <-- RTP 的搭档
+---------------------------+
|   UDP(用户数据报协议)     |
+---------------------------+
|   IP(网络层)              |
+---------------------------+
|   以太网/WiFi 等           |
+---------------------------+

1.3 RTP 能做什么,不能做什么


能做 不能做
给数据包编号(序列号) 保证数据包到达(不可靠)
给数据包打时间戳 保证数据包顺序(可能乱序)
标识数据类型(音频/视频) 预留带宽(需要 RSVP 等)
多播支持 保证服务质量 QoS
配合 RTCP 做质量监控 加密(需配合 SRTP)

1.4 RTP 的设计哲学

RTP 遵循 “应用层帧定界 + 集成层处理” 原则(ALF/ILP),意思是:

  • RTP 故意设计得不完整,留给应用层自己定制
  • 通过 Profile(配置文件)Payload Format(负载格式) 来扩展
  • 例如:音视频会议用 RFC 3551 作为配套 Profile

2. 核心概念词汇表

2.1 基础术语


术语 英文 通俗解释
RTP 负载 RTP payload 包里真正的音视频数据
RTP 数据包 RTP packet 固定头 + 可选 CSRC 列表 + 负载
RTCP 数据包 RTCP packet 控制信息包,用于质量监控
端口 Port 区分同一台机器上不同应用的数字标识
传输地址 Transport address IP地址 + 端口,唯一确定一个通信端点
RTP 会话 RTP session 一组参与者用 RTP 通信,共享同一个 SSRC 空间
多媒体会话 Multimedia session 多个 RTP 会话的集合(如音频会话+视频会话)

2.2 重要标识符

SSRC(同步源):32位随机数,标识一个"发送源"
     例如:你的摄像头是一个 SSRC,你的麦克风是另一个 SSRC
CSRC(贡献源):混合器混合多路音频时,
     把原始各路的 SSRC 记录在 CSRC 列表里
     例如:3人会议,混音器把3路声音合成1路,
           CSRC 里记录这3个人的 SSRC

2.3 中间节点

翻译器(Translator):
  - 转发 RTP 包,但保持原始 SSRC 不变
  - 可能改变编码格式(但不混合流)
  - 用途:穿越防火墙、协议转换
混合器(Mixer):
  - 把多路输入合并成一路输出
  - 用自己的 SSRC 标识输出流
  - 把原始来源放进 CSRC 列表
  - 用途:电话会议混音、低带宽链路

3. RTP 数据包头部详解

3.1 头部结构图

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|V=2|P|X|  CC   |M|     PT      |       sequence number         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           timestamp                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           synchronization source (SSRC) identifier            |
+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
|            contributing source (CSRC) identifiers             |
|                             ....                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
字段说明:
  V=2   版本号,固定为 2(2 bits)
  P     填充标志(1 bit)
  X     扩展头标志(1 bit)
  CC    CSRC 数量,0-15(4 bits)
  M     标记位,应用自定义含义(1 bit)
  PT    负载类型,标识音视频格式(7 bits)
  seq   序列号(16 bits)
  ts    时间戳(32 bits)
  SSRC  同步源标识符(32 bits)
  CSRC  贡献源列表,每项 32 bits,最多 15 项

3.2 每个字段详解

V(版本号,2 bits)

当前版本固定为 2。历史上:

  • 版本 0:最早的 vat 工具使用
  • 版本 1:第一个草稿使用
  • 版本 2:当前标准
P(填充位,1 bit)

某些加密算法需要数据对齐到固定块大小,这时在包末尾加填充字节。若 P=1,包末尾最后一个字节记录填充了多少字节(含自身)。

不填充时:[头部][负载]
有填充时:[头部][负载][0][0][0][3]  <- 最后字节=3,表示填充了3字节
X(扩展位,1 bit)

若 X=1,固定头部之后紧跟一个扩展头

+-------------------------------+---------------+
|  profile 定义的 16 位标识     |  扩展长度 L   |
+-------------------------------+---------------+
|         扩展数据(L 个 32 位字)               |
+-----------------------------------------------+

注意:只允许有一个扩展头,且长度字段不计扩展头自身的4字节。

CC(CSRC 计数,4 bits)

表示后面跟了多少个 CSRC 标识符(0-15)。

M(标记位,1 bit)

含义由 Profile 定义。典型用法:

  • 音频:标记"谈话爆发"的开始(静音后第一包)
  • 视频:标记一帧的最后一个包
PT(负载类型,7 bits)

标识音视频的编码格式。例如(来自 RFC 3551):

PT 值 格式 说明
0 PCMU G.711 μ-law 音频
8 PCMA G.711 A-law 音频
26 JPEG JPEG 视频
31 H261 H.261 视频
96-127 动态分配 由协商决定

sequence number(序列号,16 bits)

每发一个包加 1,范围 0~65535,溢出后回绕到 0。
用途:

  • 检测丢包
  • 重排乱序包
  • 初始值应随机,防止明文攻击
    期望收到的包数=最高序列号−初始序列号+1\text{期望收到的包数} = \text{最高序列号} - \text{初始序列号} + 1期望收到的包数=最高序列号初始序列号+1
timestamp(时间戳,32 bits)

记录包中第一个采样点的采集时刻,单位是媒体时钟的 tick(而非秒)。
关键特性:

  • 单调递增,线性增长
  • 初始值随机
  • 音频举例:采样率 8000 Hz,每 20ms 一包含 160 个样本,则时间戳每包增加 160
    时间戳增量=采样率×包时长(秒)\text{时间戳增量} = \text{采样率} \times \text{包时长(秒)}时间戳增量=采样率×包时长(秒)
    不同媒体流的时间戳不能直接比较,因为它们可能有不同的时钟频率和随机偏移。多媒体同步需要通过 RTCP SR 包中的 NTP 时间戳配对完成。
SSRC(同步源,32 bits)

随机选取的32位数,全局唯一标识一个发送源。若发生冲突,需要通过碰撞检测机制解决(详见第8节)。

4. RTCP 控制协议详解

4.1 RTCP 的四大功能

功能1(主要):质量反馈
  接收方定期发送接收报告(RR)告诉发送方:
  "我丢了多少包?延迟抖动是多少?"
功能2:CNAME 身份绑定
  SSRC 可能因冲突而改变,但 CNAME(规范端点名)不变
  用于关联同一参与者的音频和视频流
功能3:规模控制
  所有参与者都发 RTCP,因此必须限制 RTCP 的总带宽
  默认:RTCP 占会话总带宽的 5%
功能4(可选):会话控制信息
  传递参与者名字、邮件等信息(SDES 包)

4.2 RTCP 带宽分配

RTCP 总带宽 = 会话带宽 × 5%,其中:

  • 1/4(即 1.25%)分给活跃发送者
  • 3/4(即 3.75%)分给接收者
    这样设计的原因:新加入的参与者能更快收到发送者的 CNAME(从而同步媒体流)。
    Tinterval≈avg_rtcp_size×nrtcp_bwT_{\text{interval}} \approx \frac{\text{avg\_rtcp\_size} \times n}{\text{rtcp\_bw}}Tintervalrtcp_bwavg_rtcp_size×n
    其中 nnn 是参与者数,rtcp_bw\text{rtcp\_bw}rtcp_bw 是分配给该类参与者的 RTCP 带宽。

4.3 复合 RTCP 包

多个 RTCP 包可以拼接在一起,用一个 UDP 包发出:

一个 UDP 包内容:
+----------+----------+--------+-------+
|  SR/RR   |  SDES    |  BYE   |  APP  |
| (必须首位) | (必须含)  | (可选) | (可选)|
+----------+----------+--------+-------+
            ^
            至少这两个必须同时出现

规则:

  • 复合包必须以 SR 或 RR 开头(即使没有数据要报告,也要发空 RR)
  • 紧跟其后必须有包含 CNAME 的 SDES 包
  • BYE 应该放在最后

5. RTCP 报文类型详解

5.1 SR:发送者报告(Sender Report,PT=200)

发送者用 SR 告诉接收者"我发了多少数据",同时也包含接收报告块。

+----------------------------------------------------------+
| V=2 | P | RC(5) | PT=200 |         length               |
+----------------------------------------------------------+
|                    SSRC of sender                        |
+----------------------------------------------------------+
|         NTP 时间戳(高32位)发送此报告的墙钟时间          |
+----------------------------------------------------------+
|         NTP 时间戳(低32位)                             |
+----------------------------------------------------------+
|         RTP 时间戳(与 NTP 对应,媒体时钟单位)           |
+----------------------------------------------------------+
|         发送的包总数                                      |
+----------------------------------------------------------+
|         发送的字节总数(不含头部和填充)                   |
+----------------------------------------------------------+
|         接收报告块 1(对某个源的接收情况统计)              |
|         接收报告块 2                                      |
|         ...                                              |
+----------------------------------------------------------+

NTP 时间戳的用途:
接收者收到 SR 后,记录当前时间 AAA,利用 SR 中的 NTP 时间戳计算往返时延:
RTT=A−LSR−DLSR\text{RTT} = A - \text{LSR} - \text{DLSR}RTT=ALSRDLSR
其中:

  • AAA = 收到 RR 回报时的当前时间
  • LSR\text{LSR}LSR = 上一次 SR 的 NTP 时间戳(中间32位)
  • DLSR\text{DLSR}DLSR = 收到 SR 到发出 RR 的延迟

5.2 接收报告块(Reception Report Block)

每个接收报告块描述对一个特定来源的接收质量:

字段 大小 说明
SSRC_n 32 bits 被报告的源的 SSRC
fraction lost 8 bits 上次报告以来的丢包率(0-255,255表示100%)
cumulative packets lost 24 bits 从开始接收以来的总丢包数(有符号,可为负表示重复包)
extended highest seq 32 bits 高16位=回绕计数,低16位=最高序列号
interarrival jitter 32 bits 到达抖动估计值(媒体时钟单位)
LSR 32 bits 最近收到的 SR 中 NTP 时间戳的中间32位
DLSR 32 bits 从收到最近 SR 到发出本 RR 的延迟(1/65536 秒为单位)

丢包率计算:
fraction_lost=lost_intervalexpected_interval×256\text{fraction\_lost} = \frac{\text{lost\_interval}}{\text{expected\_interval}} \times 256fraction_lost=expected_intervallost_interval×256
其中:
expected_interval=expected−expected_prior\text{expected\_interval} = \text{expected} - \text{expected\_prior}expected_interval=expectedexpected_prior
lost_interval=expected_interval−received_interval\text{lost\_interval} = \text{expected\_interval} - \text{received\_interval}lost_interval=expected_intervalreceived_interval
到达抖动估计(Interarrival Jitter):
对第 iii 个包,定义"相对传输时间":
D(i,j)=(Rj−Ri)−(Sj−Si)D(i, j) = (R_j - R_i) - (S_j - S_i)D(i,j)=(RjRi)(SjSi)
其中 RiR_iRi 是包 iii 的接收时间,SiS_iSi 是包 iii 的 RTP 时间戳(发送时间)。
抖动估计用指数移动平均更新:
J(i)=J(i−1)+∣D(i−1,i)∣−J(i−1)16J(i) = J(i-1) + \frac{|D(i-1, i)| - J(i-1)}{16}J(i)=J(i1)+16D(i1,i)J(i1)
增益 1/161/161/16 在噪声抑制和收敛速度之间取得了良好平衡。

5.3 RR:接收者报告(Receiver Report,PT=201)

结构与 SR 相同,但省去了20字节的"发送者信息"部分。非活跃发送者用 RR 报告接收质量。

5.4 SDES:源描述(PT=202)

+-------------------+-------------------+
|  V=2|P| SC | 202  |      length       |
+-------------------+-------------------+
|           SSRC/CSRC_1                 |  <- chunk 1
+---------------------------------------+
|  CNAME=1 | len |  user@host.example   |
+---------------------------------------+
|  NAME=2  | len |  张三                |
+---------------------------------------+
|  0 (END) |  (填充到4字节边界)         |
+---------------------------------------+
|           SSRC/CSRC_2                 |  <- chunk 2
+---------------------------------------+
|  ...                                  |
+---------------------------------------+

SDES 条目类型:

类型值 名称 说明
0 END 列表结束标志
1 CNAME 规范端点名,必须包含,格式 user@host
2 NAME 用户真实姓名,如"张三"
3 EMAIL 邮件地址
4 PHONE 电话号码,如"+1 908 555 1212"
5 LOC 地理位置,如"北京市朝阳区"
6 TOOL 应用程序名称及版本
7 NOTE 临时状态消息,如"正在开会"
8 PRIV 私有扩展

CNAME 的重要性:
CNAME 是唯一不变的标识符。当 SSRC 因为冲突而改变时,接收者通过 CNAME 将新旧 SSRC 关联起来,不会把同一个人当两个人。CNAME 格式建议为 user@host

5.5 BYE:再见包(PT=203)

参与者离开时发送,告知其他人"我要走了"。

+---+---+------+-------+--------+
|V=2| P |  SC  |  203  | length |
+---+---+------+-------+--------+
|         SSRC/CSRC 列表        |
+-------------------------------+
| (可选) length | 离开原因文字  |
+-------------------------------+

防洪水机制: 当会话成员超过50人时,若大量成员同时离开,使用退避算法(back-off)控制 BYE 包的发送速率,避免 BYE 洪水。

5.6 APP:应用自定义包(PT=204)

+---+---+--------+-------+--------+
|V=2| P | subtype|  204  | length |
+---+---+--------+-------+--------+
|              SSRC/CSRC          |
+---------------------------------+
|    name(4个ASCII字符)          |
+---------------------------------+
|    应用自定义数据(可变长)       |
+---------------------------------+

用于实验性功能,不需要在 IANA 注册即可使用。正式推广后应注册为新的 RTCP 包类型。

6. RTCP 发送间隔算法

6.1 为什么需要动态调整间隔?

如果每个参与者都以固定速率发送 RTCP,那么 1000 个参与者时 RTCP 带宽 = 1000 × 单人 RTCP 带宽,线性增长,会把网络撑爆。
解决方案: 让 RTCP 发送间隔随参与者数量线性增长,保证所有人的 RTCP 总量恒定。

6.2 计算发送间隔

计算过程分5步:
第1步:确定 nnnCCC

  • 若发送者比例 ≤25%\leq 25\%25%
    • 若自己是发送者:C=avg_rtcp_size/(0.25×rtcp_bw)C = \text{avg\_rtcp\_size} / (0.25 \times \text{rtcp\_bw})C=avg_rtcp_size/(0.25×rtcp_bw)n=sendersn = \text{senders}n=senders
    • 若自己是接收者:C=avg_rtcp_size/(0.75×rtcp_bw)C = \text{avg\_rtcp\_size} / (0.75 \times \text{rtcp\_bw})C=avg_rtcp_size/(0.75×rtcp_bw)n=members−sendersn = \text{members} - \text{senders}n=memberssenders
  • 若发送者比例 >25%> 25\%>25%:所有人平等,C=avg_rtcp_size/rtcp_bwC = \text{avg\_rtcp\_size} / \text{rtcp\_bw}C=avg_rtcp_size/rtcp_bwn=membersn = \text{members}n=members
    第2步:最小间隔 TminT_{min}Tmin
    Tmin={2.5 秒初始发送(还没发过 RTCP)5 秒正常状态T_{min} = \begin{cases} 2.5 \text{ 秒} & \text{初始发送(还没发过 RTCP)} \\ 5 \text{ 秒} & \text{正常状态} \end{cases}Tmin={2.5 5 初始发送(还没发过 RTCP正常状态
    第3步:确定性间隔
    Td=max⁡(Tmin, n×C)T_d = \max(T_{min},\ n \times C)Td=max(Tmin, n×C)
    第4步:随机化
    T=Uniform(0.5×Td, 1.5×Td)T = \text{Uniform}(0.5 \times T_d,\ 1.5 \times T_d)T=Uniform(0.5×Td, 1.5×Td)
    随机化的目的:避免所有参与者同时发送 RTCP(惊群问题)。
    第5步:补偿因子
    T=Te−1.5=T1.21828T = \frac{T}{e - 1.5} = \frac{T}{1.21828}T=e1.5T=1.21828T
    这个补偿是因为"定时器重考虑"算法会导致实际发送间隔略低于预期,乘以这个因子校正。

6.3 状态机

初始化:
  tp = 0, tc = 0, senders = 0
  pmembers = 1, members = 1
  we_sent = false, initial = true
  tn = 计算出的第一个发送时间
  收到 RTP 包
       |
       v
  更新 members/senders 表
       |
       v
  收到 RTCP BYE 包
       |
       v
  从表中删除该 SSRC,更新 members
  若 members < pmembers,执行"逆向重考虑"
       |
       v
  定时器到期(tn 时刻)
       |
       v
  重新计算 T
  若 tp + T <= tc:发送 RTCP,更新 tp
  否则:重设 tn = tp + T,不发送

6.4 定时器重考虑(Timer Reconsideration)

问题场景: 1000人同时加入一个新会话,每人估计的 members=1,算出的间隔极短,导致 RTCP 洪水。
解决方案: 在发包前重新计算间隔,若 tnt_ntn 还没到就推迟发送,如此反复"后退",直到间隔收敛到正确值。

6.5 逆向重考虑(Reverse Reconsideration)

问题场景: 大量人离开,members 骤减,但每人的下次发包时间 tnt_ntn 还在未来很久,导致响应太慢。
解决方案: 收到 BYE 后,按比例缩短 tnt_ntn
tn′=tc+memberspmembers×(tn−tc)t_n' = t_c + \frac{\text{members}}{\text{pmembers}} \times (t_n - t_c)tn=tc+pmembersmembers×(tntc)
tp′=tc−memberspmembers×(tc−tp)t_p' = t_c - \frac{\text{members}}{\text{pmembers}} \times (t_c - t_p)tp=tcpmembersmembers×(tctp)

7. 混合器与翻译器

7.1 翻译器(Translator)

翻译器保持 SSRC 不变,转发各路流,但可以改变编码格式:

[发送者 A]  SSRC=10 --->  [翻译器 T] ---> [接收者 X]
                         (改变编码格式      SSRC=10(不变)
                          但不合并流)

用途示例:

  • 穿越防火墙(多播 -> 单播)
  • H.264 转 H.265(改变编码但不混合)

7.2 混合器(Mixer)

混合器合并多路流为一路,用自己的 SSRC 标记输出,原始 SSRC 放入 CSRC 列表:

[发送者 A]  SSRC=10 \
                     \
[发送者 B]  SSRC=20 ---> [混合器 M] ---> [接收者 X]
                     /    SSRC=99
[发送者 C]  SSRC=30 /    CSRC=[10,20,30]

RTCP 处理规则:
翻译器:

  • 转发 SR 包(修改编码相关字段)
  • 转发 RR 包
  • 转发 SDES 和 BYE
    混合器:
  • 转发 SR(因为混音后原始数据特征消失)
  • 为自己的输出流生成新的 SR
  • 为每个输入方向分别生成 RR(不跨方向转发)
  • 转发 SDES 中的 CNAME(需要支持冲突检测)

7.3 网络拓扑示意

                           [E6]
                            |
 [E1]                       | E6:15
  | E1:17                   v        E6:15
  v     M1:48(1,17)    M1:48(1,17)   M1:48(1,17)
 (M1)------------>[T1]---------->[T2]---------->[E7]
  ^                ^   E4:47      ^   E4:47
  | E2:1           | E4:47        |   M3:89(64,45)
  |                |              |
 [E2]             [E4]            |
                                  |
 [E3]--->(M2)--->(M3)------------>+
         E3:64  M2:12(64)
                   ^
                   | E5:45
                   |
                  [E5]
图例:
  [Ex] = 端系统(End system)
  (Mx) = 混合器(Mixer)
  [Tx] = 翻译器(Translator)
  M1:48(1,17) = M1 发出的包,SSRC=48,CSRC=[1,17]

8. SSRC 标识符的分配与冲突处理

8.1 为什么用随机 SSRC 而不用 IP 地址?

  • IP 地址在经过 NAT 或混合器后可能重复
  • 同一台机器可能有多个 RTP 源
  • 随机 32 位数冲突概率很低
    冲突概率公式:
    NNN 个源同时启动时,两两冲突的概率约为:
    P≈1−e−N2/233P \approx 1 - e^{-N^2 / 2^{33}}P1eN2/233
    N=1000N=1000N=1000 时,P≈10−4P \approx 10^{-4}P104(万分之一)。

8.2 冲突检测与解决算法

系统维护一张"已知源"表,每收到一个包就查表:

流程图(文字版):
  收到带有 SSRC 的包
         |
         v
    SSRC 在表中?
      /        \
    否           是
    |             |
  创建新条目   源传输地址匹配?
                /        \
              是           否(冲突!)
              |             |
            正常处理    是自己的 SSRC?
                          /       \
                         是        否(第三方冲突)
                         |         |
                    发 RTCP BYE   忽略该包
                    选新 SSRC

C++ 实现(伪C代码翻译为C++):

// 冲突检测核心逻辑(概念性伪代码,可实际运行)
#include <cstdint>
#include <unordered_map>
#include <string>
#include <iostream>
// 记录每个源的状态
struct SourceEntry {
    uint32_t ssrc;
    std::string rtp_source_addr;   // RTP包的源地址
    std::string rtcp_source_addr;  // RTCP包的源地址
    std::string cname;             // CNAME 标识
    bool rtp_seen;
    bool rtcp_seen;
};
// 模拟冲突检测
enum class PacketOrigin { RTP, RTCP };
bool handle_ssrc(
    std::unordered_map<uint32_t, SourceEntry>& table,
    uint32_t my_ssrc,
    uint32_t packet_ssrc,
    const std::string& packet_source_addr,
    PacketOrigin origin)
{
    auto it = table.find(packet_ssrc);
    if (it == table.end()) {
        // 新来源,创建条目
        SourceEntry entry;
        entry.ssrc = packet_ssrc;
        if (origin == PacketOrigin::RTP) {
            entry.rtp_source_addr = packet_source_addr;
            entry.rtp_seen = true;
            entry.rtcp_seen = false;
        } else {
            entry.rtcp_source_addr = packet_source_addr;
            entry.rtcp_seen = true;
            entry.rtp_seen = false;
        }
        table[packet_ssrc] = entry;
        return true; // 正常处理
    }
    SourceEntry& entry = it->second;
    // 检查源地址是否一致
    bool addr_match = false;
    if (origin == PacketOrigin::RTP) {
        if (!entry.rtp_seen) {
            entry.rtp_source_addr = packet_source_addr;
            entry.rtp_seen = true;
            addr_match = true;
        } else {
            addr_match = (entry.rtp_source_addr == packet_source_addr);
        }
    } else {
        if (!entry.rtcp_seen) {
            entry.rtcp_source_addr = packet_source_addr;
            entry.rtcp_seen = true;
            addr_match = true;
        } else {
            addr_match = (entry.rtcp_source_addr == packet_source_addr);
        }
    }
    if (!addr_match) {
        // 冲突!
        if (packet_ssrc != my_ssrc) {
            // 第三方冲突:忽略新来的包,保留已有来源
            std::cout << "[警告] 第三方 SSRC 冲突,忽略新来源" << std::endl;
            return false;
        } else {
            // 自己的包被回环!发送 BYE,选新 SSRC
            std::cout << "[严重] 检测到自身包回环,需要更换 SSRC" << std::endl;
            // 实际实现中:发送 RTCP BYE,选择新的随机 SSRC
            return false;
        }
    }
    return true; // 地址匹配,正常处理
}
int main() {
    std::unordered_map<uint32_t, SourceEntry> source_table;
    uint32_t my_ssrc = 0xDEADBEEF;
    // 模拟:正常收到一个来自 SSRC=12345 的包
    bool ok = handle_ssrc(source_table, my_ssrc, 12345,
                          "192.168.1.10:5004", PacketOrigin::RTP);
    std::cout << "包1处理结果:" << (ok ? "正常" : "丢弃") << std::endl;
    // 模拟:同一 SSRC 来自相同地址(正常)
    ok = handle_ssrc(source_table, my_ssrc, 12345,
                     "192.168.1.10:5004", PacketOrigin::RTP);
    std::cout << "包2处理结果:" << (ok ? "正常" : "丢弃") << std::endl;
    // 模拟:同一 SSRC 来自不同地址(冲突!)
    ok = handle_ssrc(source_table, my_ssrc, 12345,
                     "10.0.0.5:5004", PacketOrigin::RTP);
    std::cout << "包3处理结果:" << (ok ? "正常" : "丢弃") << std::endl;
    return 0;
}

9. 安全性

9.1 保密性(Confidentiality)

RFC 3550 描述了基于 DES-CBC 的加密方案(主要为了向后兼容),但明确指出:

  • DES 已经太弱,推荐使用 3DES 或更强算法
  • 更好的选择是 SRTP(安全实时传输协议,RFC 3711),基于 AES
    加密范围:
RTP 包:整个包(含头部)一起加密
        初始向量(IV)= 序列号 + 时间戳(较弱,不推荐)
RTCP 包:整个复合包一起加密
         在包前加32位随机数作为随机前缀

部分加密: 可以将 RTCP 包拆成两部分,一部分加密(含 SDES),一部分明文(含接收报告),方便第三方监控网络质量。

9.2 认证与完整性

RFC 3550 不在 RTP 层面提供认证,依赖下层协议(IPsec)或 SRTP。

10. 附录算法详解

10.1 序列号有效性检验(Appendix A.1)

一个新来源首次发包时,不能立即信任它(可能是错误路由的无关流量)。需要连续收到 MIN_SEQUENTIAL(默认=2)个序号连续的包才认为该来源有效。
状态机:

新来源出现
    |
    v
probation = MIN_SEQUENTIAL = 2
    |
    v
收到包,序号连续?
  /         \
是            否
 |             |
probation--    重置 probation
probation==0?
  /     \
是       否
 |        |
来源有效  继续等待

C++ 完整实现(基于 RFC 3550 Appendix A.1):

#include <cstdint>
#include <iostream>
#include <cstring>
// RFC 3550 定义的常量
static const uint32_t RTP_SEQ_MOD = (1 << 16);  // 序号空间大小 65536
// 每个来源维护的状态结构
struct RtpSource {
    uint16_t max_seq;         // 已见过的最大序列号
    uint32_t cycles;          // 序号回绕次数(左移16位存储)
    uint32_t base_seq;        // 起始序列号
    uint32_t bad_seq;         // 最近的"坏"序列号 + 1
    uint32_t probation;       // 剩余需要连续包数(来源验证期)
    uint32_t received;        // 已收到的包数
    uint32_t expected_prior;  // 上次报告时期望的包数
    uint32_t received_prior;  // 上次报告时已收到的包数
    uint32_t transit;         // 前一个包的相对传输时间
    double   jitter;          // 抖动估计(浮点,用于精度)
};
// 初始化序列号状态
void init_seq(RtpSource* s, uint16_t seq) {
    s->base_seq = seq;
    s->max_seq  = seq;
    // bad_seq 初始化为不可能等于任何 seq 的值
    s->bad_seq  = RTP_SEQ_MOD + 1;
    s->cycles   = 0;
    s->received = 0;
    s->received_prior = 0;
    s->expected_prior = 0;
}
// 更新序列号状态,返回 1 表示包有效,0 表示无效(丢弃)
int update_seq(RtpSource* s, uint16_t seq) {
    // 无符号差值,自动处理回绕
    uint16_t udelta = seq - s->max_seq;
    const int MAX_DROPOUT  = 3000;  // 允许的最大"超前"序号差
    const int MAX_MISORDER = 100;   // 允许的最大"落后"序号差
    const int MIN_SEQUENTIAL = 2;   // 验证期需要的最小连续包数
    if (s->probation > 0) {
        // ---- 来源验证期 ----
        if (seq == (uint16_t)(s->max_seq + 1)) {
            // 序号连续,通过一关
            s->probation--;
            s->max_seq = seq;
            if (s->probation == 0) {
                // 验证完成!重新初始化并接受这个包
                init_seq(s, seq);
                s->received++;
                return 1;
            }
        } else {
            // 序号不连续,重置验证期
            s->probation = MIN_SEQUENTIAL - 1;
            s->max_seq   = seq;
        }
        return 0;  // 验证期内的包暂不接受
    } else if (udelta < (uint16_t)MAX_DROPOUT) {
        // ---- 正常情况:序号在允许的前向跳跃范围内 ----
        if (seq < s->max_seq) {
            // 序号回绕(如从 65535 跳到 0)
            s->cycles += RTP_SEQ_MOD;
        }
        s->max_seq = seq;
    } else if (udelta <= RTP_SEQ_MOD - MAX_MISORDER) {
        // ---- 序号跳变极大:可能是重启 ----
        if (seq == (uint16_t)(s->bad_seq & 0xFFFF)) {
            // 连续两个"跳变大"的包 -> 认为对端重启,重同步
            init_seq(s, seq);
        } else {
            s->bad_seq = (seq + 1) & (RTP_SEQ_MOD - 1);
            return 0;  // 丢弃这个包
        }
    } else {
        // ---- 重复包或轻微乱序:忽略(但不作为错误) ----
    }
    s->received++;
    return 1;  // 包有效
}
int main() {
    RtpSource src;
    memset(&src, 0, sizeof(src));
    // 模拟:新来源首次出现,从序号 1000 开始
    init_seq(&src, 999);  // 比实际第一包少1,模拟"等待第一包"
    src.max_seq  = 999;
    src.probation = 2;    // 需要2个连续包才验证
    std::cout << "=== RTP 序列号验证演示 ===" << std::endl;
    // 发送几个包
    uint16_t test_seqs[] = {1000, 1001, 1002, 5000, 1003};
    for (uint16_t seq : test_seqs) {
        int valid = update_seq(&src, seq);
        std::cout << "序号 " << seq
                  << " -> " << (valid ? "有效" : "丢弃")
                  << "  (received=" << src.received << ")" << std::endl;
    }
    return 0;
}

10.2 计算丢包数(Appendix A.3)

#include <cstdint>
#include <iostream>
// 计算期望包数和丢包数
struct LossStats {
    // 从 RtpSource 中读取的值
    uint32_t cycles;          // 序号回绕计数(已左移16位)
    uint16_t max_seq;         // 最高序号
    uint32_t base_seq;        // 初始序号
    uint32_t received;        // 已收到包数
    uint32_t expected_prior;  // 上次报告时的期望包数
    uint32_t received_prior;  // 上次报告时的已收包数
};
struct ReportResult {
    uint32_t expected;      // 总期望包数
    int32_t  lost;          // 总丢包数(有符号,重复包导致可为负)
    uint8_t  fraction_lost; // 本周期丢包率(0-255,255=100%)
};
ReportResult compute_loss(LossStats& s) {
    ReportResult r;
    // 期望包数 = 扩展最高序号 - 初始序号 + 1
    uint32_t extended_max = s.cycles + s.max_seq;
    r.expected = extended_max - s.base_seq + 1;
    // 总丢包数(24位有符号数,需要做截断)
    int32_t lost = (int32_t)r.expected - (int32_t)s.received;
    // 截断到24位有符号范围
    if (lost > 0x7FFFFF)       lost = 0x7FFFFF;   // 最大正丢包
    else if (lost < -0x800000) lost = (int32_t)0xFF800000; // 最大负(重复包)
    r.lost = lost;
    // 本周期丢包率
    uint32_t expected_interval = r.expected - s.expected_prior;
    uint32_t received_interval = s.received - s.received_prior;
    int32_t  lost_interval     = (int32_t)expected_interval
                                 - (int32_t)received_interval;
    s.expected_prior = r.expected;
    s.received_prior = s.received;
    if (expected_interval == 0 || lost_interval <= 0) {
        r.fraction_lost = 0;
    } else {
        // 定点数:左移8位后除以期望数,等价于乘以 256
        r.fraction_lost = (uint8_t)((lost_interval << 8) / expected_interval);
    }
    return r;
}
int main() {
    LossStats stats = {
        .cycles          = 0,
        .max_seq         = 1099,
        .base_seq        = 1000,
        .received        = 95,
        .expected_prior  = 0,
        .received_prior  = 0
    };
    ReportResult res = compute_loss(stats);
    std::cout << "期望包数:" << res.expected << std::endl;
    std::cout << "总丢包数:" << res.lost << std::endl;
    std::cout << "丢包率:" << (int)res.fraction_lost
              << "/256 = "
              << (res.fraction_lost * 100.0 / 256.0) << "%" << std::endl;
    return 0;
}

10.3 抖动估计(Appendix A.8)

#include <cstdint>
#include <cstdlib>   // abs
#include <iostream>
// 更新抖动估计
// r_ts     = 包中的 RTP 时间戳(发送时刻,媒体时钟单位)
// arrival  = 包到达时刻(同样用媒体时钟单位表示)
// transit  = 上一个包的相对传输时间(引用传递,会被更新)
// jitter   = 当前抖动估计(引用传递,会被更新)
void update_jitter(uint32_t r_ts, uint32_t arrival,
                   int32_t& transit, double& jitter) {
    // 计算本包的相对传输时间
    // "相对传输时间" = 到达时刻 - RTP时间戳
    // 如果网络延迟恒定,这个值不变;变化量反映抖动
    int32_t cur_transit = (int32_t)arrival - (int32_t)r_ts;
    // D = 本包与前包的相对传输时间之差
    int32_t d = cur_transit - transit;
    transit = cur_transit;
    // 取绝对值(抖动只关心偏差大小,不关心方向)
    if (d < 0) d = -d;
    // 指数移动平均(增益 = 1/16)
    // J(i) = J(i-1) + (|D(i-1,i)| - J(i-1)) / 16
    jitter += (1.0 / 16.0) * ((double)d - jitter);
}
// 整数版本(避免浮点,通过放大16倍减少舍入误差)
void update_jitter_int(uint32_t r_ts, uint32_t arrival,
                       int32_t& transit, int32_t& jitter_x16) {
    int32_t cur_transit = (int32_t)arrival - (int32_t)r_ts;
    int32_t d = cur_transit - transit;
    transit = cur_transit;
    if (d < 0) d = -d;
    // jitter 放大了16倍存储,所以:
    // jitter_x16(i) = jitter_x16(i-1) + d - ((jitter_x16(i-1) + 8) >> 4)
    jitter_x16 += d - ((jitter_x16 + 8) >> 4);
}
int main() {
    int32_t transit   = 0;
    double  jitter    = 0.0;
    int32_t jitter_x16 = 0;
    int32_t transit2  = 0;
    // 模拟收到5个包,到达时刻有轻微抖动
    struct Packet { uint32_t ts; uint32_t arrival; };
    Packet pkts[] = {
        {1000, 1050},  // 延迟 50
        {1160, 1215},  // 延迟 55(抖动+5)
        {1320, 1370},  // 延迟 50(无抖动)
        {1480, 1540},  // 延迟 60(抖动+10)
        {1640, 1685},  // 延迟 45(抖动-5)
    };
    std::cout << "=== 抖动估计演示 ===" << std::endl;
    for (auto& p : pkts) {
        update_jitter(p.ts, p.arrival, transit, jitter);
        update_jitter_int(p.ts, p.arrival, transit2, jitter_x16);
        std::cout << "到达时刻=" << p.arrival
                  << "  浮点抖动=" << jitter
                  << "  整数抖动=" << (jitter_x16 >> 4) << std::endl;
    }
    return 0;
}

10.4 生成随机 SSRC(Appendix A.6 简化版)

RFC 原版使用 MD5 混合多种系统信息生成随机 SSRC,以下是 C++ 简化实现思路:

#include <cstdint>
#include <chrono>
#include <random>
#include <iostream>
// 生成随机 32 位 SSRC
// RFC 3550 要求:必须用多种系统熵源混合,不能直接调用 random()
uint32_t generate_ssrc() {
    // 使用 C++11 真随机设备(如果可用)+ 系统时间作为种子
    std::random_device rd;
    uint64_t seed = rd();
    // 混入当前时间(纳秒)
    auto now = std::chrono::high_resolution_clock::now();
    auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
        now.time_since_epoch()).count();
    seed ^= (uint64_t)ns;
    // 使用 Mersenne Twister 生成均匀分布的随机数
    std::mt19937 rng(static_cast<uint32_t>(seed));
    std::uniform_int_distribution<uint32_t> dist(0, 0xFFFFFFFF);
    return dist(rng);
}
int main() {
    std::cout << "生成3个随机 SSRC:" << std::endl;
    for (int i = 0; i < 3; i++) {
        std::cout << "  SSRC[" << i << "] = 0x"
                  << std::hex << generate_ssrc() << std::dec << std::endl;
    }
    return 0;
}

10.5 RTCP 发送间隔计算(Appendix A.7)

#include <cstdlib>    // drand48
#include <algorithm>  // std::max
#include <cmath>      // exp
#include <iostream>
// 计算 RTCP 下一次发送间隔(秒)
// members       = 会话成员估计数
// senders       = 活跃发送者估计数
// rtcp_bw       = RTCP 带宽(字节/秒),通常为会话带宽的5%
// we_sent       = 最近我们是否发送过 RTP 数据
// avg_rtcp_size = 平均 RTCP 复合包大小(字节)
// initial       = 是否是第一次发送 RTCP
double rtcp_interval(int members, int senders,
                     double rtcp_bw, bool we_sent,
                     double avg_rtcp_size, bool initial)
{
    // 最小间隔(秒)
    const double RTCP_MIN_TIME = 5.0;
    // 发送者份额:25%
    const double RTCP_SENDER_BW_FRACTION = 0.25;
    // 接收者份额:75%
    const double RTCP_RCVR_BW_FRACTION   = 1.0 - RTCP_SENDER_BW_FRACTION;
    // 补偿因子 = e - 1.5
    const double COMPENSATION = 2.71828 - 1.5;
    double rtcp_min_time = RTCP_MIN_TIME;
    // 第一次发送:最小间隔减半,让新参与者更快被发现
    if (initial) {
        rtcp_min_time /= 2.0;
    }
    // 确定 n(计算对象的数量)和有效带宽
    int n = members;
    if (senders <= (int)(members * RTCP_SENDER_BW_FRACTION)) {
        // 发送者比例不超过 25%,分开计算
        if (we_sent) {
            rtcp_bw *= RTCP_SENDER_BW_FRACTION;
            n = senders;
        } else {
            rtcp_bw *= RTCP_RCVR_BW_FRACTION;
            n -= senders;
        }
    }
    // 否则:发送者超过 25%,所有人平分带宽,n 保持 members 不变
    // 确定性间隔:所有人发一次 RTCP 需要多长时间
    // = (平均包大小 × 人数) / 带宽
    double t = avg_rtcp_size * n / rtcp_bw;
    if (t < rtcp_min_time) t = rtcp_min_time;
    // 随机化到 [0.5t, 1.5t],避免所有人同步发送(惊群)
    // drand48() 返回 [0.0, 1.0) 的均匀随机数
    t = t * (drand48() + 0.5);
    // 除以补偿因子,修正"定时器重考虑"带来的系统性偏低
    t = t / COMPENSATION;
    return t;
}
int main() {
    // 模拟:100人会话,10人是发送者,会话带宽 128kbps
    int members      = 100;
    int senders      = 10;
    // RTCP 带宽 = 会话带宽 × 5% = 128000 × 0.05 / 8 = 800 字节/秒
    double rtcp_bw   = 800.0;
    bool we_sent     = false;  // 我是接收者
    double avg_size  = 120.0;  // 平均 RTCP 包大小 120 字节
    bool initial     = true;   // 第一次发送
    std::cout << "=== RTCP 发送间隔计算 ===" << std::endl;
    for (int i = 0; i < 5; i++) {
        double interval = rtcp_interval(members, senders, rtcp_bw,
                                        we_sent, avg_size, initial);
        std::cout << "计算间隔[" << i << "] = " << interval << " 秒" << std::endl;
        initial = false;  // 只有第一次是 initial
    }
    return 0;
}

11. 端口分配规则

RTP 和 RTCP 使用相邻端口对

RTP  端口:偶数(如 5004)
RTCP 端口:RTP端口 + 1(如 5005)
分层编码时(n 层):
  数据端口 = P + 2n
  控制端口 = P + 2n + 1
例如三层编码,基础端口 P = 5000:
  第0层:RTP=5000, RTCP=5001
  第1层:RTP=5002, RTCP=5003
  第2层:RTP=5004, RTCP=5005

12. 协议常量汇总

RTCP 包类型


缩写 名称 数值
SR 发送者报告 200
RR 接收者报告 201
SDES 源描述 202
BYE 再见 203
APP 应用自定义 204

SDES 条目类型


缩写 名称 数值
END 列表结束 0
CNAME 规范端点名 1
NAME 用户名 2
EMAIL 电子邮件 3
PHONE 电话 4
LOC 地理位置 5
TOOL 工具名称 6
NOTE 状态消息 7
PRIV 私有扩展 8


13. 整体架构总结

                    +-------------------------+
                    |    多媒体会议应用        |
                    +-------------------------+
                           |       |
               +-----------+       +-----------+
               |                               |
     +---------+--------+           +----------+--------+
     |   音频 RTP 会话   |           |   视频 RTP 会话   |
     |  (独立 SSRC 空间) |           |  (独立 SSRC 空间) |
     +------------------+           +-------------------+
     | RTP 数据包        |           | RTP 数据包         |
     | (偶数端口)        |           | (偶数端口)         |
     +------------------+           +-------------------+
     | RTCP 控制包       |           | RTCP 控制包        |
     | (奇数端口)        |           | (奇数端口)         |
     +------------------+           +-------------------+
               |                               |
               +---------------+---------------+
                               |
                   UDP / IP / 以太网

关键设计原则回顾:

  1. RTP 本身不保证可靠性,依赖 UDP
  2. SSRC 是每路流的唯一标识,随机生成,碰撞时需要检测和解决
  3. RTCP 的发送频率必须随参与者数量动态调整
  4. 不同媒体(音/视频)用不同 RTP 会话,通过 CNAME 关联同步
  5. 混合器改变流的本质(新 SSRC),翻译器保持流的本质(原 SSRC)

RFC 7826:RTSP 2.0 实时流媒体协议 — 从零理解

作者:Schulzrinne, Rao, Lanphier, Westerlund, Stiemerling(2016年12月)
本文档是对 RFC 7826(RTSP 2.0)的完整中文解读,面向初学者,力求通俗易懂。


目录

  1. RTSP 是什么?与 RTP 的关系
  2. 核心概念词汇表
  3. 协议消息结构
  4. 方法(Method)详解
  5. 状态机与会话生命周期
  6. 时间格式
  7. 媒体属性与内容类型
  8. 连接管理
  9. 能力协商与扩展机制
  10. 代理(Proxy)
  11. 缓存机制
  12. 安全性
  13. 完整交互示例(含时序图)
  14. C++ 代码示例

1. RTSP 是什么?与 RTP 的关系

1.1 生活类比

把流媒体系统比作点播电影

RTP  = 电影胶卷本身(负责把视频/音频帧传输给你)
RTSP = 遥控器(负责告诉服务器"播放"、"暂停"、"快进")
SDP  = 节目单(描述这部电影有哪些音轨、分辨率等)

RTSP 全称 Real-Time Streaming Protocol(实时流媒体协议),它是一个"控制协议",本身不传输媒体数据,只负责控制流媒体的投递过程。

1.2 RTSP 在协议栈中的位置

+-----------------------------------------------+
|            用户应用(视频播放器)                |
+-----------------------------------------------+
|  RTSP 2.0(控制:播放/暂停/跳转)               |
|  SDP(会话描述:告诉客户端媒体格式)              |
+------------------------+----------------------+
|  RTP(实际传输音视频帧)  | RTCP(传输质量反馈) |
+------------------------+----------------------+
|           TCP / TLS(RTSP 消息传输)            |
|           UDP(RTP/RTCP 媒体传输)              |
+-----------------------------------------------+
|                  IP 网络                        |
+-----------------------------------------------+

1.3 RTSP 1.0 vs RTSP 2.0

RTSP 2.0(RFC 7826)废弃了 RTSP 1.0(RFC 2326),主要改进:

方面 RTSP 1.0 RTSP 2.0
IPv6 支持 不完整 完整支持
流水线请求 有限 全面改进
PLAY 状态下再次 PLAY 行为不一致 明确定义
扩展机制 头部语法不完整 统一规范
UDP 传输 RTSP 消息 可选但未定义清楚 明确不支持


2. 核心概念词汇表

2.1 重要术语


术语 英文 通俗解释
聚合控制 Aggregate control 用一个命令同时控制音频+视频两路流
聚合控制 URI Aggregate control URI 代表整个"节目"的地址,区别于单条流的地址
表现描述 Presentation description 节目单(SDP 文件),告知客户端有哪些流、用什么格式
媒体流 Media stream 一路 RTP 流,如一个音频轨道
会话 RTSP session 客户端与服务器建立的有状态上下文,包含一条或多条媒体流
原始服务器 Origin server 真正存放资源的服务器
功能标签 Feature tag 标识一种具体功能是否支持,如 play.basic
NPT Normal Play Time 标准播放时间,相对于内容开始的偏移
SMPTE 时间码 SMPTE Timestamps 帧级精度的时间格式,格式为 时:分:秒:帧

2.2 URI 方案

RTSP 定义了三种 URI 方案:

rtsp://   使用 TCP,默认端口 554
           例:rtsp://media.example.com:554/twister/audio
rtsps://  使用 TCP + TLS(加密),默认端口 322
           例:rtsps://secure.example.com/movie
rtspu://  历史遗留,RTSP 2.0 中服务器必须返回 501 拒绝

3. 协议消息结构

3.1 消息格式

RTSP 消息格式与 HTTP 非常相似,是纯文本协议(UTF-8 编码):

请求格式:
+-----------------------------------------+
|  方法  请求URI  RTSP/2.0  CRLF          |  <- 请求行
|  头部名: 头部值  CRLF                   |  <- 头部(零个或多个)
|  CRLF                                   |  <- 空行(头部结束标志)
|  消息体(可选)                          |  <- 消息体
+-----------------------------------------+
响应格式:
+-----------------------------------------+
|  RTSP/2.0  状态码  原因短语  CRLF       |  <- 状态行
|  头部名: 头部值  CRLF                   |  <- 头部
|  CRLF                                   |  <- 空行
|  消息体(可选)                          |
+-----------------------------------------+

3.2 典型请求示例

C->S: PLAY rtsp://audio.example.com/audio RTSP/2.0
      CSeq: 835
      Session: ULExwZCXh2pd0xuFgkgZJW
      Range: npt=10-25
      Seek-Style: RAP
      User-Agent: PhonyClient/1.2

各行解释:

  • PLAY = 方法名(告诉服务器"开始播放")
  • rtsp://... = 要操作的资源 URI
  • RTSP/2.0 = 协议版本
  • CSeq: 835 = 序列号,用于配对请求与响应
  • Session: ... = 会话 ID,标识哪个会话
  • Range: npt=10-25 = 从第 10 秒播放到第 25 秒
  • Seek-Style: RAP = 跳转策略:从前一个随机访问点开始

3.3 重要头部汇总


头部名 作用 示例
CSeq 请求序列号,配对请求响应 CSeq: 302
Session 会话 ID,超时参数 Session: abc123;timeout=60
Transport 媒体传输参数(协议/端口等) Transport: RTP/AVP;unicast;dest_addr=":4588"/":4589"
Range 播放范围 Range: npt=10-25
Scale 播放速度比例(快进/慢放) Scale: 2.0
Speed 传输速率倍数 Speed: 1.5
RTP-Info RTP 同步信息 RTP-Info: url="...";ssrc=0D12F123:seq=14783;rtptime=2345962545
Media-Properties 媒体属性(点播/直播/可否跳转) Media-Properties: Random-Access=3.2, Time-Progressing
Media-Range 当前可用媒体范围 Media-Range: npt=0-2893.23
Seek-Style 跳转策略 Seek-Style: RAP
Require 要求接收方支持某功能 Require: play.basic
Supported 声明自己支持哪些功能 Supported: play.basic, play.scale


4. 方法(Method)详解

4.1 方法总览

RTSP 2.0 定义了 10 个方法:

RTSP 方法

会话建立类

播放控制类

参数管理类

服务器主动类

OPTIONS
查询服务器能力

DESCRIBE
获取媒体描述

SETUP
建立会话/协商传输

PLAY
开始播放

PAUSE
暂停

TEARDOWN
结束会话

GET_PARAMETER
读取参数

SET_PARAMETER
设置参数/保活

PLAY_NOTIFY
服务器通知客户端

REDIRECT
重定向到其他服务器

4.2 各方法方向与必要性


方法 方向 作用对象 服务器必须实现 客户端必须实现
OPTIONS C->S 或 S->C 表现/流
DESCRIBE C->S 表现/流 推荐 推荐
SETUP C->S
PLAY C->S 表现/流
PLAY_NOTIFY S->C 表现/流
PAUSE C->S 表现/流
TEARDOWN C->S 或 S->C 表现/流
GET_PARAMETER C->S 或 S->C 表现/流 可选 可选
SET_PARAMETER C->S 或 S->C 表现/流 是(保活用) 可选
REDIRECT S->C 表现/流 可选 是(需能接收)

4.3 OPTIONS — 查询能力

类似"你支持哪些功能?"

C->S:  OPTIONS rtsp://server.example.com RTSP/2.0
       CSeq: 1
       User-Agent: PhonyClient/1.2
       Supported: play.basic
S->C:  RTSP/2.0 200 OK
       CSeq: 1
       Public: DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, OPTIONS
       Supported: play.basic, setup.rtp.rtcp.mux, play.scale
       Server: PhonyServer/1.1

Public 头部列出服务器支持的所有方法。

4.4 DESCRIBE — 获取媒体描述

类似"给我看这部电影的节目单"。服务器返回 SDP 格式的媒体描述。

C->S: DESCRIBE rtsp://server.example.com/fizzle/foo RTSP/2.0
      CSeq: 312
      Accept: application/sdp
S->C: RTSP/2.0 200 OK
      CSeq: 312
      Content-Type: application/sdp
      Content-Length: 358
      v=0
      o=MNobody 2890844526 2890842807 IN IP4 192.0.2.46
      s=SDP Seminar
      m=audio 3456 RTP/AVP 0
      a=control:audio
      m=video 2232 RTP/AVP 31
      a=control:video

SDP 中 m=audio 行描述音频流,a=control:audio 指出用于控制该流的 URI 后缀。

4.5 SETUP — 建立会话与传输协商

类似"我要看这部电影,我的电视接口是这样的,你用哪种方式给我送信号?"

C->S: SETUP rtsp://example.com/foo/bar/baz.rm RTSP/2.0
      CSeq: 302
      Transport: RTP/AVP;unicast;dest_addr=":4588"/":4589",
                 RTP/AVP/TCP;unicast;interleaved=0-1
      Accept-Ranges: npt, clock
S->C: RTSP/2.0 200 OK
      CSeq: 302
      Session: QKyjN8nt2WqbWw4tIYof52;timeout=60
      Transport: RTP/AVP;unicast;dest_addr="192.0.2.53:4588"/
                 "192.0.2.53:4589"; src_addr="198.51.100.241:6256"/
                 "198.51.100.241:6257"; ssrc=2A3F93ED
      Accept-Ranges: npt
      Media-Properties: Random-Access=3.2, Time-Progressing,
                        Time-Duration=3600.0
      Media-Range: npt=0-2893.23

客户端提供了两种传输备选方案(按优先级排列),服务器选择 RTP/UDP 并填写了具体的地址和端口。
timeout=60 表示:如果 60 秒内没有任何保活信号,服务器会关闭会话。

4.6 PLAY — 开始播放

C->S: PLAY rtsp://audio.example.com/audio RTSP/2.0
      CSeq: 836
      Session: ULExwZCXh2pd0xuFgkgZJW
      Range: npt=3.52-
      User-Agent: PhonyClient/1.2
S->C: RTSP/2.0 200 OK
      CSeq: 836
      Range: npt=3.51-324.39
      Seek-Style: First-Prior
      Session: ULExwZCXh2pd0xuFgkgZJW
      RTP-Info: url="rtsp://example.com/audio"
         ssrc=0D12F123:seq=14783;rtptime=2345962545

注意:客户端请求从 3.52 秒开始,服务器实际从 3.51 秒开始(因为那是最近的关键帧位置),并在 RTP-Info 里告知 RTP 层的同步信息。

Scale 与 Speed 的区别

这是 RTSP 里容易混淆的两个概念:

  • Scale(比例):控制"媒体内容时间"相对于"播放时间"的比例,即快进/慢放
    • Scale=2.0\text{Scale} = 2.0Scale=2.0:每播放 1 秒,消耗 2 秒的媒体内容(2 倍快进)
    • Scale=−1.0\text{Scale} = -1.0Scale=1.0:倒放
    • Scale=0.5\text{Scale} = 0.5Scale=0.5:慢放(每播放 1 秒,消耗 0.5 秒媒体内容)
  • Speed(速度):控制"播放时间"相对于"墙钟时间"的比例,即传输速率
    • Speed=1.5\text{Speed} = 1.5Speed=1.5:比正常快 50% 发送数据(用于提前填充缓冲区)
    • Speed=0.8\text{Speed} = 0.8Speed=0.8:慢速传输,减少缓冲区
      实际数据传输速率∝Scale×Speed\text{实际数据传输速率} \propto \text{Scale} \times \text{Speed}实际数据传输速率Scale×Speed
      举例:Scale=2.0, Speed=1.0 意味着用正常带宽发送 2 倍速的内容(服务器丢掉 B/P 帧,只发 I 帧)。

4.7 PAUSE — 暂停

C->S: PAUSE rtsp://example.com/fizzle/foo RTSP/2.0
      CSeq: 835
      Session: OccldOFFq23KwjYpAnBbUr
S->C: RTSP/2.0 200 OK
      CSeq: 835
      Range: npt=21-30
      Session: OccldOFFq23KwjYpAnBbUr

响应中的 Range: npt=21-30 表示"已暂停在第 21 秒,原请求范围是到第 30 秒"。客户端用这个信息来显示进度条和支持续播。

4.8 PLAY_NOTIFY — 服务器主动通知

这是 RTSP 2.0 中服务器到客户端方向的主动推送,有三种通知原因:

4.8.1 end-of-stream(流结束)
S->C: PLAY_NOTIFY rtsp://example.com/fizzle/foo RTSP/2.0
      CSeq: 854
      Notify-Reason: end-of-stream
      Request-Status: cseq=853 status=200 reason="OK"
      Range: npt=-145
      RTP-Info: url="rtsp://example.com/fizzle/foo/audio"
         ssrc=0D12F123:seq=14783;rtptime=2345962545, ...
      Session: CDtUJfDQXJWtJ7Iqua2xOi
      Date: Mon, 08 Mar 2010 13:37:16 GMT
C->S: RTSP/2.0 200 OK
      CSeq: 854
      Session: CDtUJfDQXJWtJ7Iqua2xOi

Range: npt=-145 表示"即将在第 145 秒结束"(当前还在传输中,客户端可以提前准备)。RTP-Info 中的 seq 参数是最后一个 RTP 包的序列号,客户端收到这个序号后知道可以清空缓冲区了。

4.8.2 media-properties-update(媒体属性更新)

对于时间进展型内容(直播+录制),服务器每约 5 分钟通知一次当前的媒体范围:

S->C: PLAY_NOTIFY rtsp://example.com/fizzle/foo RTSP/2.0
      Notify-Reason: media-properties-update
      Session: CDtUJfDQXJWtJ7Iqua2xOi
      Media-Properties: Time-Progressing,
            Time-Limited=20080415T153919.36Z, Random-Access=5.0
      Media-Range: npt=00:00:00-01:37:21.394
      Range: npt=01:15:49.873-
      Date: Tue, 14 Apr 2008 15:48:06 GMT
4.8.3 scale-change(播放速率被强制改变)

当直播流追上了"当前直播前沿"时,无法继续 2 倍速播放,服务器自动降为 1 倍速并通知:

S->C: PLAY_NOTIFY rtsp://example.com/fizzle/foo RTSP/2.0
      Notify-Reason: scale-change
      Range: npt=01:37:21.394-
      Scale: 1
      Media-Range: npt=00:00:00-01:37:21.394
      Session: CDtUJfDQXJWtJ7Iqua2xOi

4.9 TEARDOWN — 结束会话

C->S: TEARDOWN rtsp://example.com/fizzle/foo RTSP/2.0
      CSeq: 892
      Session: OccldOFFq23KwjYpAnBbUr
S->C: RTSP/2.0 200 OK
      CSeq: 892

响应中没有 Session 头部,表示会话已被销毁。

4.10 SET_PARAMETER — 设置参数(常用于保活)

保活请求(不含消息体):

C->S: SET_PARAMETER rtsp://example.com/fizzle/foo RTSP/2.0
      CSeq: 421
      Session: iixT43KLc
      Date: Mon, 08 Mar 2010 14:45:04 GMT

这是推荐的 RTSP 保活方式(也可用 GET_PARAMETER 或 OPTIONS,但 SET_PARAMETER 最简洁)。

4.11 REDIRECT — 服务器重定向

服务器要维护,把客户端迁移到另一台服务器:

S->C: REDIRECT rtsp://example.com/fizzle/foo RTSP/2.0
      CSeq: 732
      Location: rtsp://s2.example.com:8001/fizzle/foo
      Terminate-Reason: Server-Admin ;time=19960213T143205Z
      Session: uZ3ci0K+Ld-M
      Date: Thu, 13 Feb 1996 14:30:43 GMT
C->S: RTSP/2.0 200 OK
      CSeq: 732
      Session: uZ3ci0K+Ld-M

time=... 参数指定了一个截止时间,客户端必须在这之前完成迁移。

5. 状态机与会话生命周期

5.1 三态状态机

RTSP 会话有三个基本状态:

                  SETUP 成功
      +------+  ──────────>  +-------+
      | Init |               | Ready |
      +------+  <──────────  +-------+
                TEARDOWN         |
                             PLAY |  ^ PAUSE
                                 v  |
                             +------+
                             | Play |
                             +------+
                                 |
                           TEARDOWN
                                 |
                                 v
                            [会话销毁]

SETUP 成功

PLAY

PAUSE

TEARDOWN

TEARDOWN

PLAY(更新播放范围)

Init

Ready

Play

5.2 会话生命周期详解

客户端                                服务器
  |                                     |
  |------- OPTIONS(可选,查询能力)----> |
  |<------ 200 OK + Public 方法列表 ---- |
  |                                     |
  |------- DESCRIBE(获取节目单)------> |
  |<------ 200 OK + SDP 内容 ---------- |
  |                                     |
  |------- SETUP(协商传输参数)-------> |
  |<------ 200 OK + Session ID -------- |
  |                                     |
  |------- PLAY(开始播放)-----------> |
  |<------ 200 OK + RTP-Info ---------- |
  |                                     |
  |<======= RTP 音视频数据 ============>|
  |<======= RTCP 质量反馈 =============>|
  |                                     |
  |------- SET_PARAMETER(保活)------> |
  |<------ 200 OK -------------------- |
  |                                     |
  |------- PAUSE(暂停)-------------> |
  |<------ 200 OK + 暂停点 ----------- |
  |                                     |
  |------- PLAY(续播)--------------> |
  |<------ 200 OK -------------------- |
  |                                     |
  |<---- PLAY_NOTIFY(即将结束) ------- |
  |----> 200 OK ---------------------- |
  |                                     |
  |------- TEARDOWN(结束)-----------> |
  |<------ 200 OK(无Session头) ------ |
  |                                     |

6. 时间格式

RTSP 支持三种时间格式,用于 Range 头部:

6.1 NPT(Normal Play Time,标准播放时间)

最常用的格式,表示相对于内容开始的偏移:

npt=123.45-125       从 123.45 秒播到 125 秒
npt=12:05:35.3-      从 12小时05分35.3秒播到结束
npt=now-             直播模式:从当前时刻开始
npt=10-              从第 10 秒播到结束(开放结束点)
npt=-30              隐式起始点,到第 30 秒结束(续播用)

特殊符号 now 只能用于直播,表示"此刻的直播前沿"。

6.2 SMPTE 时间码

帧级精度,格式为 时:分:秒:帧.子帧

smpte=10:12:33:20-               从 10小时12分33秒第20帧开始
smpte=10:07:00-10:07:33:05.01    精确范围
smpte-25=10:07:00-10:07:33:05.01 25fps 模式

默认帧率 29.97fps(SMPTE 30 drop format)。

6.3 绝对时间(UTC)

格式:YYYYMMDDThhmmss.ssZ(ISO 8601 基本格式)

clock=19961108T143720.25Z-19961108T144725.25Z
      1996年11月8日 14:37:20.25 UTC 到 14:47:25.25 UTC

注意:必须是 UTC 时间,不支持时区偏移。

7. 媒体属性与内容类型

7.1 四种内容类型

内容类型

点播 On-Demand

动态点播
Dynamic On-Demand

直播 Live

直播+录制
Live with Recording

直播+时移
Live with Time-Shift

7.2 媒体属性三个维度

每种内容类型由三个维度描述:
维度1:随机访问(Random Access)

属性值 含义
Random-Access=N 可以跳转,最大关键帧间距为 N 秒
Beginning-Only 只能跳回开头
No-Seeking 完全不能跳转(纯直播)

维度2:内容变化(Content Modifications)

属性值 含义
Immutable 内容固定不变(普通点播)
Dynamic 内容可被外部修改(如播放列表)
Time-Progressing 内容随时间持续增长(直播录制)

维度3:保留策略(Retention)

属性值 含义
Unlimited 内容永久保留
Time-Limited=时间 保留到某个时刻
Time-Duration=秒数 只保留最近 N 秒(滑动窗口)

7.3 各类型对应的属性组合


内容类型 随机访问 内容变化 保留策略
点播 Random-Access=5.0 Immutable Unlimited/Time-Limited
动态点播 Random-Access=3.0 Dynamic Unlimited/Time-Limited
纯直播 No-Seeking Time-Progressing Time-Duration=0.0
直播+录制 Random-Access=3.0 Time-Progressing Time-Limited/Unlimited
直播+时移 Random-Access=3.0 Time-Progressing Time-Duration=7200.0


8. 连接管理

8.1 连接类型

RTSP 2.0 只支持 TCP(明确放弃 UDP 传输 RTSP 消息):

持久连接(Persistent):一个 TCP 连接用于多次请求/响应
临时连接(Transient):每次请求用一个 TCP 连接

推荐使用持久连接,原因:

  • 可以做流水线请求(pipelining)
  • 服务器可以主动发送请求(PLAY_NOTIFY, REDIRECT 等)

8.2 超时机制


场景 等待时间 行为
响应超时下限 10 秒 客户端等待至少 10 秒才认为无响应
响应超时上限建议 5 秒内 服务器应在 5 秒内响应或发送 100 Continue
100 Continue 间隔 每 5 秒 长时间处理时每 5 秒发一个
连接关闭等待 至少 10 秒 TEARDOWN 后服务器等 10 秒再关闭
会话超时默认值 60 秒 60 秒无活动信号则服务器可关闭会话

8.3 保活机制(优先级由高到低)

1. RTCP(若使用 RTP 传输媒体)
   最佳选择,RTCP 接收报告顺便证明客户端还在
2. SET_PARAMETER(推荐 RTSP 层保活方式)
   C->S: SET_PARAMETER rtsp://... RTSP/2.0
         Session: xxx
   (无消息体,纯保活)
3. GET_PARAMETER
   C->S: GET_PARAMETER rtsp://... RTSP/2.0
         Session: xxx
4. OPTIONS
   最不推荐,服务器处理开销最大

8.4 流水线请求(Pipelining)

客户端可以连续发送多个请求而不等待每个响应:

客户端连续发送:
  SETUP (CSeq=1) -->
  SETUP (CSeq=2) -->
  PLAY  (CSeq=3) -->
服务器按顺序响应:
  200 OK (CSeq=1) <--
  200 OK (CSeq=2) <--
  200 OK (CSeq=3) <--

CSeq 头部用于将响应与对应的请求配对。

9. 能力协商与扩展机制

9.1 功能标签(Feature Tags)

功能标签是唯一字符串,标识一种特定功能是否支持:

  • play.basic:支持本规范的基本播放功能(最小实现标志)
  • setup.rtp.rtcp.mux:支持 RTP 和 RTCP 在同一端口传输
  • play.scale:支持快进/慢放

9.2 能力相关头部

Supported: play.basic, play.scale
  -> 我支持这些功能(双方都可以用)
Require: play.scale
  -> 你必须支持这个功能,否则拒绝这个请求
Proxy-Require: gzipped-messages
  -> 代理必须支持这个功能
Unsupported: play.scale
  -> 我不支持这个功能(出现在 551 响应里)

9.3 RTSP 扩展的三个层次

层次1(最轻量):
  在已有方法中增加新的头部参数
  只要接收方能忽略不认识的头部就安全
层次2(中等):
  增加全新的方法
  接收方不支持时返回 501 Not Implemented
层次3(最重大):
  定义新的协议版本
  必须通过标准化文档(Standards Track RFC)注册

10. 代理(Proxy)

10.1 代理的五种类型

+--------------------+        +-------------------+
|  缓存代理           |        | 翻译代理            |
| Caching Proxy      |        | Translator Proxy   |
| 缓存媒体内容,减少   |        | 协议转换/地址转换   |
| 服务器负载          |        |                   |
+--------------------+        +-------------------+
+--------------------+        +-------------------+
|  访问代理           |        | 安全代理            |
| Access Proxy       |        | Security Proxy     |
| 跨越 NAT/防火墙,    |        | 帮助开放防火墙端口   |
| 地址转换            |        |                   |
+--------------------+        +-------------------+
+--------------------+
|  审计代理           |
| Auditing Proxy     |
| 记录/审计流量,不    |
| 修改内容            |
+--------------------+

10.2 代理对消息的处理

消息流向(示意):
客户端 <--> [代理] <--> 服务器
              |
              |-- 必须添加 Via 头部(标记经过此代理)
              |-- 必须处理 Proxy-Require 中的功能标签
              |-- 必须转发不认识的 RTSP 方法(审计代理)
              |-- 不得改变 Via 之外的响应顺序

10.3 多路复用(CSeq 重写)

代理汇聚多个客户端的请求到一个服务器连接时,需要重写 CSeq:

客户端A  CSeq=1 -->  代理 重写为 CSeq=100 --> 服务器
客户端B  CSeq=1 -->  代理 重写为 CSeq=101 --> 服务器
服务器  CSeq=100 --> 代理 映射回客户端A CSeq=1 --> 客户端A
服务器  CSeq=101 --> 代理 映射回客户端B CSeq=1 --> 客户端B

11. 缓存机制

11.1 RTSP 缓存特点

RTSP 缓存是"直通式(cut-through)"的:

传统 HTTP 缓存:            RTSP 缓存:
先把整个文件下载 ->          边下载边转发 ->
存入本地 ->                  同时存副本 ->
再发给客户端                 无额外延迟
(有延迟)                   (零延迟)

只有 DESCRIBE 的响应(SDP 文件)可以被缓存;媒体数据通过缓存时可以被"顺手保存"。

11.2 缓存验证器

RTSP 使用两种方式验证缓存是否过期:
方式1:Last-Modified 时间戳

  • 弱验证器:精度 1 秒,可能不精确
    方式2:MTag(Message Body Tag)
  • 类似 HTTP 的 ETag,是一个不透明字符串
  • 内容变化则 MTag 变化(强验证器)
    使用 If-Match 头部做条件请求:
C->S: DESCRIBE rtsp://example.com/foo RTSP/2.0
      If-Match: "abc123"    <- 如果 MTag 匹配则返回完整内容,否则 412

12. 状态码

12.1 状态码分类


范围 类别 说明
1xx 信息 处理中,继续等待
2xx 成功 请求已被成功接受
3xx 重定向 需要进一步操作
4xx 客户端错误 请求有问题
5xx 服务器错误 服务器无法完成请求

12.2 RTSP 特有状态码


状态码 含义
451 参数无法理解
453 带宽不足
454 会话未找到
455 当前状态不允许此方法
457 无效的 Range 值
459 聚合操作不允许
460 只允许聚合操作
461 不支持的传输方式
462 目标地址不可达
464 数据传输尚未就绪
465 通知原因未知
551 选项不支持
553 代理不可用


13. 完整交互示例(含时序图)

13.1 单播点播流程

客户端(C)                              服务器(S)
    |                                      |
    |--OPTIONS * RTSP/2.0 CSeq:1--------->|  查询能力
    |<-200 OK; Public:DESCRIBE,SETUP,...--|
    |                                      |
    |--DESCRIBE rtsp://.../movie CSeq:2-->|  获取节目单
    |<-200 OK; Content-Type:app/sdp; SDP--|
    |                                      |
    |--SETUP .../movie/audio CSeq:3 ----->|  建立音频流
    |  Transport: RTP/AVP;unicast;        |
    |  dest_addr=":5004"/":5005"          |
    |<-200 OK; Session:S1; Transport:...--|
    |                                      |
    |--SETUP .../movie/video CSeq:4 ----->|  建立视频流
    |  Session:S1                         |  (加入同一会话)
    |<-200 OK; Session:S1; Transport:...--|
    |                                      |
    |--PLAY .../movie CSeq:5 ------------>|  开始播放
    |  Session:S1; Range:npt=0-           |  (聚合控制URI)
    |<-200 OK; Range:npt=0-3723;         |
    |  RTP-Info:url=.../audio ssrc=...,  |
    |           url=.../video ssrc=...   |
    |                                      |
    |<====== RTP 音视频数据持续传输 ======>|
    |<====== RTCP 质量反馈 =============>|
    |                                      |
    |  ... 55分钟后 ...                    |
    |                                      |
    |<--PLAY_NOTIFY .../movie CSeq:10-----|  即将结束通知
    |   Notify-Reason:end-of-stream       |
    |   Range:npt=-3723                   |
    |-->200 OK CSeq:10 ----------------->|
    |                                      |
    |--TEARDOWN .../movie CSeq:11-------->|  结束会话
    |  Session:S1                         |
    |<-200 OK (无Session头) -------------|
    |                                      |

13.2 直播+时移场景(Pipelining 示例)

C->S: SETUP rtsp://live.example.com/news RTSP/2.0
      CSeq: 1
      Transport: RTP/AVP;unicast;dest_addr=":5004"/":5005"
      Accept-Ranges: npt
S->C: RTSP/2.0 200 OK
      CSeq: 1
      Session: LiveS1
      Media-Properties: Random-Access=30.0, Time-Progressing,
                        Time-Duration=3600.0
      Media-Range: npt=0-1523.4
      Accept-Ranges: npt
C->S: PLAY rtsp://live.example.com/news RTSP/2.0
      CSeq: 2
      Session: LiveS1
      Range: npt=now-
S->C: RTSP/2.0 200 OK
      CSeq: 2
      Range: npt=1523.4-
  ... 稍后 ...
S->C: PLAY_NOTIFY rtsp://live.example.com/news RTSP/2.0
      CSeq: 50
      Notify-Reason: media-properties-update
      Session: LiveS1
      Media-Range: npt=0-2100.0
      Range: npt=2100.0-

14. C++ 代码示例

14.1 RTSP 消息解析器(基础版)

#include <iostream>
#include <string>
#include <unordered_map>
#include <sstream>
#include <stdexcept>
// RTSP 消息类型
enum class RtspMessageType {
    REQUEST,
    RESPONSE
};
// RTSP 请求方法枚举
enum class RtspMethod {
    OPTIONS,
    DESCRIBE,
    SETUP,
    PLAY,
    PLAY_NOTIFY,
    PAUSE,
    TEARDOWN,
    GET_PARAMETER,
    SET_PARAMETER,
    REDIRECT,
    UNKNOWN
};
// 将字符串转换为方法枚举
RtspMethod parseMethod(const std::string& s) {
    if (s == "OPTIONS")       return RtspMethod::OPTIONS;
    if (s == "DESCRIBE")      return RtspMethod::DESCRIBE;
    if (s == "SETUP")         return RtspMethod::SETUP;
    if (s == "PLAY")          return RtspMethod::PLAY;
    if (s == "PLAY_NOTIFY")   return RtspMethod::PLAY_NOTIFY;
    if (s == "PAUSE")         return RtspMethod::PAUSE;
    if (s == "TEARDOWN")      return RtspMethod::TEARDOWN;
    if (s == "GET_PARAMETER") return RtspMethod::GET_PARAMETER;
    if (s == "SET_PARAMETER") return RtspMethod::SET_PARAMETER;
    if (s == "REDIRECT")      return RtspMethod::REDIRECT;
    return RtspMethod::UNKNOWN;
}
std::string methodToString(RtspMethod m) {
    switch (m) {
        case RtspMethod::OPTIONS:       return "OPTIONS";
        case RtspMethod::DESCRIBE:      return "DESCRIBE";
        case RtspMethod::SETUP:         return "SETUP";
        case RtspMethod::PLAY:          return "PLAY";
        case RtspMethod::PLAY_NOTIFY:   return "PLAY_NOTIFY";
        case RtspMethod::PAUSE:         return "PAUSE";
        case RtspMethod::TEARDOWN:      return "TEARDOWN";
        case RtspMethod::GET_PARAMETER: return "GET_PARAMETER";
        case RtspMethod::SET_PARAMETER: return "SET_PARAMETER";
        case RtspMethod::REDIRECT:      return "REDIRECT";
        default:                        return "UNKNOWN";
    }
}
// RTSP 消息结构体
struct RtspMessage {
    RtspMessageType type = RtspMessageType::REQUEST;
    // 请求字段
    RtspMethod method = RtspMethod::UNKNOWN;
    std::string requestUri;
    std::string version;   // 如 "RTSP/2.0"
    // 响应字段
    int statusCode = 0;
    std::string reasonPhrase;
    // 通用字段
    std::unordered_map<std::string, std::string> headers;
    std::string body;
    // 获取特定头部(不区分大小写)
    std::string getHeader(const std::string& name) const {
        // 简单实现:直接查找(生产代码应做大小写转换)
        auto it = headers.find(name);
        if (it != headers.end()) return it->second;
        return "";
    }
    // 打印消息摘要
    void print() const {
        if (type == RtspMessageType::REQUEST) {
            std::cout << "[请求] " << methodToString(method)
                      << " " << requestUri
                      << " " << version << std::endl;
        } else {
            std::cout << "[响应] " << version
                      << " " << statusCode
                      << " " << reasonPhrase << std::endl;
        }
        for (auto& h : headers) {
            std::cout << "  " << h.first << ": " << h.second << std::endl;
        }
        if (!body.empty()) {
            std::cout << "  [消息体] " << body.size() << " 字节" << std::endl;
        }
    }
};
// 简单的 RTSP 消息解析器
// 注意:这是教学用简化版,生产代码需要处理更多边界情况
class RtspParser {
public:
    // 解析完整的 RTSP 消息字符串
    // 消息格式:请求行/状态行 + 头部 + 空行 + 消息体
    RtspMessage parse(const std::string& raw) {
        RtspMessage msg;
        std::istringstream stream(raw);
        std::string line;
        // 跳过空行(RFC 要求忽略消息开头的空行)
        while (std::getline(stream, line)) {
            // 去掉行尾的 \r
            if (!line.empty() && line.back() == '\r') {
                line.pop_back();
            }
            if (!line.empty()) break;
        }
        // 解析第一行(请求行 or 状态行)
        parseFirstLine(line, msg);
        // 解析头部
        while (std::getline(stream, line)) {
            if (!line.empty() && line.back() == '\r') line.pop_back();
            if (line.empty()) break; // 空行 = 头部结束
            // 头部格式:Name: Value
            size_t colon = line.find(':');
            if (colon != std::string::npos) {
                std::string name = line.substr(0, colon);
                std::string value = line.substr(colon + 1);
                // 去掉值开头的空格
                size_t start = value.find_first_not_of(' ');
                if (start != std::string::npos) value = value.substr(start);
                msg.headers[name] = value;
            }
        }
        // 读取消息体(如果有 Content-Length 头部)
        std::string contentLengthStr = msg.getHeader("Content-Length");
        if (!contentLengthStr.empty()) {
            int contentLength = std::stoi(contentLengthStr);
            if (contentLength > 0) {
                msg.body.resize(contentLength);
                stream.read(&msg.body[0], contentLength);
            }
        }
        return msg;
    }
private:
    // 判断第一行是请求还是响应,并解析
    void parseFirstLine(const std::string& line, RtspMessage& msg) {
        std::istringstream lineStream(line);
        std::string first, second, third;
        lineStream >> first >> second >> third;
        // 判断:若第一个 token 以 "RTSP/" 开头,则是响应
        if (first.substr(0, 5) == "RTSP/") {
            msg.type = RtspMessageType::RESPONSE;
            msg.version = first;
            msg.statusCode = std::stoi(second);
            // 原因短语可能有空格,重新读取
            size_t pos = line.find(second);
            pos = line.find(' ', pos + second.size());
            if (pos != std::string::npos) {
                msg.reasonPhrase = line.substr(pos + 1);
            }
        } else {
            // 否则是请求:Method URI Version
            msg.type = RtspMessageType::REQUEST;
            msg.method = parseMethod(first);
            msg.requestUri = second;
            msg.version = third;
        }
    }
};
// 构建 RTSP 请求字符串
class RtspRequestBuilder {
public:
    RtspRequestBuilder(RtspMethod method, const std::string& uri)
        : method_(method), uri_(uri), cseq_(1) {}
    // 设置 CSeq 序列号
    RtspRequestBuilder& cseq(int seq) {
        cseq_ = seq;
        return *this;
    }
    // 添加头部
    RtspRequestBuilder& header(const std::string& name, const std::string& value) {
        headers_[name] = value;
        return *this;
    }
    // 设置消息体
    RtspRequestBuilder& body(const std::string& b, const std::string& contentType) {
        body_ = b;
        headers_["Content-Type"] = contentType;
        headers_["Content-Length"] = std::to_string(b.size());
        return *this;
    }
    // 构建最终字符串
    std::string build() const {
        std::ostringstream oss;
        // 请求行
        oss << methodToString(method_) << " " << uri_ << " RTSP/2.0\r\n";
        // CSeq(必须有)
        oss << "CSeq: " << cseq_ << "\r\n";
        // 其他头部
        for (auto& h : headers_) {
            oss << h.first << ": " << h.second << "\r\n";
        }
        // 空行(头部结束)
        oss << "\r\n";
        // 消息体
        if (!body_.empty()) {
            oss << body_;
        }
        return oss.str();
    }
private:
    RtspMethod method_;
    std::string uri_;
    int cseq_;
    std::unordered_map<std::string, std::string> headers_;
    std::string body_;
};
// ========== 演示 ==========
int main() {
    std::cout << "=== RTSP 消息解析演示 ===" << std::endl;
    // 1. 构建一个 PLAY 请求
    std::string playRequest = RtspRequestBuilder(
        RtspMethod::PLAY,
        "rtsp://audio.example.com/audio"
    )
    .cseq(836)
    .header("Session", "ULExwZCXh2pd0xuFgkgZJW")
    .header("Range", "npt=3.52-")
    .header("User-Agent", "PhonyClient/1.2")
    .build();
    std::cout << "\n[构建的 PLAY 请求]" << std::endl;
    std::cout << playRequest;
    // 2. 解析这个请求
    RtspParser parser;
    RtspMessage parsed = parser.parse(playRequest);
    std::cout << "\n[解析结果]" << std::endl;
    parsed.print();
    // 3. 解析一个响应
    std::string response =
        "RTSP/2.0 200 OK\r\n"
        "CSeq: 836\r\n"
        "Session: ULExwZCXh2pd0xuFgkgZJW\r\n"
        "Range: npt=3.51-324.39\r\n"
        "Seek-Style: First-Prior\r\n"
        "RTP-Info: url=\"rtsp://example.com/audio\" "
            "ssrc=0D12F123:seq=14783;rtptime=2345962545\r\n"
        "\r\n";
    std::cout << "\n[解析 PLAY 响应]" << std::endl;
    RtspMessage parsedResp = parser.parse(response);
    parsedResp.print();
    std::cout << "\n  -> CSeq = " << parsedResp.getHeader("CSeq") << std::endl;
    std::cout << "  -> Range = " << parsedResp.getHeader("Range") << std::endl;
    std::cout << "  -> Session = " << parsedResp.getHeader("Session") << std::endl;
    // 4. 构建一个 SET_PARAMETER 保活请求
    std::string keepAlive = RtspRequestBuilder(
        RtspMethod::SET_PARAMETER,
        "rtsp://example.com/fizzle/foo"
    )
    .cseq(999)
    .header("Session", "ULExwZCXh2pd0xuFgkgZJW")
    .header("User-Agent", "PhonyClient/1.2")
    .build();
    std::cout << "\n[构建的保活请求(SET_PARAMETER,无消息体)]" << std::endl;
    std::cout << keepAlive;
    return 0;
}

14.2 NPT 时间格式解析器

#include <iostream>
#include <string>
#include <sstream>
#include <cmath>
#include <stdexcept>
// NPT 时间点结构
// NPT 格式有两种:
//   npt-sec:    纯秒数,如 123.45
//   npt-hhmmss: 时:分:秒,如 12:05:35.3
struct NptTime {
    double seconds = -1.0;  // -1 表示未指定(开放端点)
    bool isNow = false;      // 特殊值 "now"(仅用于直播)
    bool isSpecified() const { return seconds >= 0.0 || isNow; }
    // 将秒数格式化为 hh:mm:ss.sss
    std::string toHHMMSS() const {
        if (isNow) return "now";
        if (seconds < 0) return "(未指定)";
        int totalSec = (int)seconds;
        int hours   = totalSec / 3600;
        int minutes = (totalSec % 3600) / 60;
        int secs    = totalSec % 60;
        double frac = seconds - totalSec;
        // 格式化输出
        std::ostringstream oss;
        oss << (hours < 10 ? "0" : "") << hours << ":"
            << (minutes < 10 ? "0" : "") << minutes << ":"
            << (secs < 10 ? "0" : "") << secs;
        if (frac > 1e-6) {
            // 取小数部分,最多3位
            int ms = (int)std::round(frac * 1000);
            oss << "." << (ms < 100 ? "0" : "") << (ms < 10 ? "0" : "") << ms;
        }
        return oss.str();
    }
    std::string toSeconds() const {
        if (isNow) return "now";
        if (seconds < 0) return "";
        // 去掉无意义的小数部分
        if (std::abs(seconds - (int)seconds) < 1e-6) {
            return std::to_string((int)seconds);
        }
        std::ostringstream oss;
        oss << seconds;
        return oss.str();
    }
};
// NPT 时间范围结构(表示 Range: npt=start-end)
struct NptRange {
    NptTime start;   // 起始点(可以是未指定 = 隐式起始点)
    NptTime end;     // 结束点(可以是未指定 = 播到结束)
    // start 未指定则为"隐式起始"(如 npt=-30 中,起始=当前暂停点)
    bool hasImplicitStart() const { return !start.isSpecified(); }
    // end 未指定则为"播到媒体结束"
    bool hasOpenEnd() const { return !end.isSpecified(); }
    // 计算时长(若两端都已知)
    double duration() const {
        if (start.isSpecified() && !start.isNow
         && end.isSpecified() && !end.isNow) {
            return end.seconds - start.seconds;
        }
        return -1.0; // 无法计算
    }
};
// 解析单个 NPT 时间点字符串
// 支持:纯秒数(如 "123.45")、时分秒(如 "12:05:35.3")、"now"
NptTime parseNptTime(const std::string& s) {
    NptTime t;
    if (s.empty()) return t; // 未指定
    if (s == "now") {
        t.isNow = true;
        return t;
    }
    // 判断是否含有冒号(时分秒格式)
    if (s.find(':') != std::string::npos) {
        // 格式:hh:mm:ss[.sss]
        std::istringstream iss(s);
        int h = 0, m = 0;
        double sec = 0.0;
        char colon;
        iss >> h >> colon >> m >> colon >> sec;
        t.seconds = h * 3600.0 + m * 60.0 + sec;
    } else {
        // 格式:纯秒数
        t.seconds = std::stod(s);
    }
    return t;
}
// 解析 NPT 范围字符串(Range 头部值中的 npt= 部分)
// 格式:npt=start-end
// 例如:npt=123.45-125  /  npt=12:05:35.3-  /  npt=now-  /  npt=-30
NptRange parseNptRange(const std::string& rangeStr) {
    NptRange range;
    // 找到 "npt=" 前缀
    std::string s = rangeStr;
    size_t eqPos = s.find("npt=");
    if (eqPos != std::string::npos) {
        s = s.substr(eqPos + 4); // 去掉 "npt=" 前缀
    }
    // 以 "-" 分割 start 和 end
    // 注意:隐式起始是 "-end"(连字符在开头)
    size_t dashPos = s.find('-');
    if (dashPos == std::string::npos) {
        // 只有 start,没有 end(极少见)
        range.start = parseNptTime(s);
        return range;
    }
    std::string startStr = s.substr(0, dashPos);
    std::string endStr   = s.substr(dashPos + 1);
    range.start = parseNptTime(startStr); // 空字符串 -> 隐式起始
    range.end   = parseNptTime(endStr);   // 空字符串 -> 开放结束
    return range;
}
// ========== 演示 ==========
int main() {
    std::cout << "=== NPT 时间格式解析演示 ===" << std::endl;
    // 测试用例
    struct TestCase {
        std::string input;
        std::string description;
    };
    TestCase cases[] = {
        {"npt=123.45-125",      "普通范围(秒格式)"},
        {"npt=12:05:35.3-",     "从12分05分35.3秒到结束"},
        {"npt=now-",            "直播:从当前时刻开始"},
        {"npt=0-",              "从头开始到结束"},
        {"npt=-30",             "隐式起始,到第30秒"},
        {"npt=10-25",           "播放第10秒到第25秒"},
        {"npt=01:37:21.394-",   "从1小时37分21.394秒开始"},
    };
    for (auto& tc : cases) {
        std::cout << "\n输入:" << tc.input << std::endl;
        std::cout << "说明:" << tc.description << std::endl;
        NptRange r = parseNptRange(tc.input);
        if (r.hasImplicitStart()) {
            std::cout << "  起始:[隐式,从当前暂停点]" << std::endl;
        } else if (r.start.isNow) {
            std::cout << "  起始:now(直播当前点)" << std::endl;
        } else {
            std::cout << "  起始:" << r.start.seconds << " 秒"
                      << " = " << r.start.toHHMMSS() << std::endl;
        }
        if (r.hasOpenEnd()) {
            std::cout << "  结束:[开放,播到媒体结束]" << std::endl;
        } else if (r.end.isNow) {
            std::cout << "  结束:now" << std::endl;
        } else {
            std::cout << "  结束:" << r.end.seconds << " 秒"
                      << " = " << r.end.toHHMMSS() << std::endl;
        }
        double dur = r.duration();
        if (dur >= 0) {
            std::cout << "  时长:" << dur << " 秒" << std::endl;
        }
    }
    return 0;
}

14.3 会话状态机实现

#include <iostream>
#include <string>
#include <stdexcept>
// RTSP 会话状态
enum class RtspState {
    INIT,   // 初始状态:无会话
    READY,  // 就绪:SETUP 完成,未播放
    PLAY    // 播放中:媒体数据正在传输
};
std::string stateToString(RtspState s) {
    switch (s) {
        case RtspState::INIT:  return "Init";
        case RtspState::READY: return "Ready";
        case RtspState::PLAY:  return "Play";
        default:               return "Unknown";
    }
}
// RTSP 会话状态机
class RtspSession {
public:
    RtspSession() : state_(RtspState::INIT), sessionId_("") {}
    // 处理 SETUP 方法
    // 可在 Init / Ready / Play 状态下调用
    void onSetup(const std::string& newSessionId) {
        if (state_ == RtspState::INIT) {
            // 首次 SETUP:创建会话
            sessionId_ = newSessionId;
            state_ = RtspState::READY;
            std::cout << "[状态转换] Init -> Ready  (会话ID: "
                      << sessionId_ << ")" << std::endl;
        } else if (state_ == RtspState::READY || state_ == RtspState::PLAY) {
            // 追加 SETUP:向会话添加新媒体流(聚合会话)
            std::cout << "[状态保持] " << stateToString(state_)
                      << " -> " << stateToString(state_)
                      << " (添加新流到聚合会话)" << std::endl;
        }
    }
    // 处理 PLAY 方法
    // 只能在 Ready 或 Play 状态下调用
    void onPlay(const std::string& rangeStr = "") {
        if (state_ == RtspState::INIT) {
            throw std::runtime_error("错误455: PLAY 不能在 Init 状态下调用");
        } else if (state_ == RtspState::READY) {
            state_ = RtspState::PLAY;
            std::cout << "[状态转换] Ready -> Play"
                      << (rangeStr.empty() ? "" : "  Range: " + rangeStr)
                      << std::endl;
        } else if (state_ == RtspState::PLAY) {
            // Play 状态下的 PLAY 是"更新播放范围"
            std::cout << "[状态保持] Play -> Play  (更新播放范围)"
                      << (rangeStr.empty() ? "" : "  Range: " + rangeStr)
                      << std::endl;
        }
    }
    // 处理 PAUSE 方法
    // 只能在 Play 状态下调用(Ready 状态调用也返回 200,但状态不变)
    void onPause(double pausePoint) {
        if (state_ == RtspState::INIT) {
            throw std::runtime_error("错误455: PAUSE 不能在 Init 状态下调用");
        } else if (state_ == RtspState::PLAY) {
            state_ = RtspState::READY;
            std::cout << "[状态转换] Play -> Ready  (暂停点: "
                      << pausePoint << " 秒)" << std::endl;
        } else {
            // Ready 状态下的 PAUSE 返回 200 OK,附当前暂停点
            std::cout << "[状态保持] Ready -> Ready  (已在暂停状态,当前点: "
                      << pausePoint << " 秒)" << std::endl;
        }
    }
    // 处理 TEARDOWN 方法
    // 可在 Ready 或 Play 状态下调用
    void onTeardown() {
        if (state_ == RtspState::INIT) {
            throw std::runtime_error("错误454: 会话不存在");
        }
        std::string prev = stateToString(state_);
        state_ = RtspState::INIT;
        sessionId_ = "";
        std::cout << "[状态转换] " << prev
                  << " -> Init  (会话已销毁)" << std::endl;
    }
    RtspState getState() const { return state_; }
    std::string getSessionId() const { return sessionId_; }
private:
    RtspState state_;
    std::string sessionId_;
};
// ========== 演示 ==========
int main() {
    std::cout << "=== RTSP 会话状态机演示 ===" << std::endl;
    RtspSession session;
    std::cout << "初始状态:" << stateToString(session.getState()) << std::endl;
    try {
        std::cout << "\n--- 正常流程 ---" << std::endl;
        // SETUP 音频流
        session.onSetup("QKyjN8nt2WqbWw4tIYof52");
        // SETUP 视频流(加入同一会话)
        session.onSetup("QKyjN8nt2WqbWw4tIYof52");
        // 开始播放
        session.onPlay("npt=0-");
        // 更新播放范围(Play 状态下再次 PLAY)
        session.onPlay("npt=-25");
        // 暂停
        session.onPause(14.3);
        // 续播
        session.onPlay("npt=14.3-");
        // 结束
        session.onTeardown();
        std::cout << "\n--- 错误情况演示 ---" << std::endl;
        // 尝试在 Init 状态下 PLAY
        session.onPlay("npt=0-");
    } catch (const std::runtime_error& e) {
        std::cout << "[异常捕获] " << e.what() << std::endl;
    }
    return 0;
}

15. 总结对比:RTSP 与 RTP 的分工

                   +-----------------------+
                   |   流媒体应用(播放器)  |
                   +-----------+-----------+
                               |
              +----------------+-----------------+
              |                                  |
    +---------v---------+             +----------v--------+
    |  RTSP 2.0          |             |  SDP              |
    |  "遥控器协议"      |             |  "节目单"         |
    |                    |             |  描述流的格式、   |
    |  OPTIONS   PLAY    |             |  端口、编码方式   |
    |  DESCRIBE  PAUSE   |             +-------------------+
    |  SETUP     TEARDOWN|
    |  SET_PARAM ...     |
    +--------------------+
              |
              | TCP(控制信令)
              |
    +---------v---------+
    |  服务器             |
    |  (保存媒体数据)      |
    +---------+---------+
              |
     UDP(媒体数据)
              |
    +---------v---------+
    |  RTP               |              RTCP
    |  实际传输音视频帧   |  <---------> 质量反馈(丢包率、抖动)
    +--------------------+

RTSP 和 RTP 是互补的,不是替代关系:

  • RTP 负责"送货",RTSP 负责"下单和控制"
  • 没有 RTSP,RTP 仍可以传输(如 WebRTC),但缺少标准化的播放控制
  • 没有 RTP,RTSP 也可以控制其他传输协议,但 RTP 是最常见的配套

本文档基于 RFC 7826(RTSP 2.0,2016年12月)整理。
所有 C++ 代码使用 C++11 标准,可用 g++ -std=c++11 文件名.cpp -o 输出名 编译运行。

RFC 8825《面向浏览器应用的实时通信协议综述》学习笔记

原文:Harald Alvestrand, Overview: Real-Time Protocols for Browser-Based Applications, RFC 8825, IETF, 2021年1月
文档性质:适用性声明(Applicability Statement)——本文不定义任何协议本身,而是规定一组协议规范的"组合方式",符合这套组合的实现即可声称兼容 WebRTC。

一、文档定位:它解决的是什么问题

WebRTC 这件事看似只是"浏览器里能不能打电话/开视频会议",但 RFC 8825 关心的是更底层的工程问题:互联网上从未存在过一套各方都认可、应当被广泛部署的实时通信协议族,也没有类似电话号码、邮箱地址那样的统一标识体系。早期的网络电话/视频会议依赖专用网络、专用硬件和定制软件,成本高、质量差。随着带宽与算力提升,在通用计算设备上获得满意的实时通信体验才变得可行。
WebRTC 的特殊性在于它把目标锚定在浏览器宿主环境:以前这类能力要靠插件(下载、安装、维护成本高),而 HTML5 时代的目标是把这些能力做成浏览器原生的、可通过 JavaScript 调用的标准接口。
RFC 8825 的角色是"路线图"和"协调点",确保:

  1. WebRTC 所需的全部组成部分都"可被找到"(findable);
  2. 属于互联网协议族范畴的那些部分,都被完整规范化并走在正确的标准化轨道上。
    也就是说,RFC 8825 本身不规定任何比特如何在网络上传输,它只是告诉你:要实现一个合规的 WebRTC 端点,必须去实现哪些其他 RFC。这是理解全文的关键——后续每一节列出的"MUST implement [RFCxxxx]"才是真正具有约束力的内容,本文档是把这些约束力收集、组织起来的索引层。

二、核心概念与术语体系

2.1 协议与API的关系——两条独立但呼应的标准化路线

WebRTC 整体工作分为两大并行的标准化工作:

部分 标准化主体 内容
协议规范 IETF 浏览器之间(无中间服务器介入时)如何直接通信的"线上协议"
JavaScript API 规范 W3C W3C.WD-webrtc(PeerConnection 等)与 W3C.WD-mediacapture-streams(媒体采集)

这两者的设计目标是互为镜像:协议规范中的每一个选项/特性,都应该能明确对应到该调用哪个 API;反过来,任意一段 API 调用序列,也应该能明确推断出会触发哪些协议层面的行为。
需要特别注意一个容易被误解的点:协议规范并不假设通信对端必然是浏览器。也就是说,一个完全不实现 JS API、只实现底层协议的"WebRTC Non-Browser"(比如一个用 C++ 库做的本地客户端、一个嵌入式网关)同样可以与浏览器互通——只要它遵循协议规范。这正是文中区分以下几个术语的原因:

术语 定义要点
WebRTC Browser / WebRTC UA 同时实现协议规范 W3C JS API 的实体
WebRTC Non-Browser 只实现协议规范,不声称实现 JS API(如设备、原生应用)
WebRTC Endpoint Browser 与 Non-Browser 的统称,二者均遵循协议规范
WebRTC-Compatible Endpoint 能与 WebRTC Endpoint 成功通信,但可能不完全满足其全部要求的实体(受限场景,例如可能影响安全保证或可接入网络的位置)
WebRTC Gateway 一种 WebRTC-Compatible Endpoint,专门负责把媒体流转给非 WebRTC 实体

逻辑关系是一个包含层级:WebRTC Browser ⊂ WebRTC Endpoint(所有浏览器都是端点,但端点不一定是浏览器);WebRTC-Compatible Endpoint 是更宽松的外圈概念,不受本规范强制约束,只是在涉及与合规端点的互操作时会被提及。

2.2 其他关键术语辨析


术语 含义 容易混淆的地方
Media 仅指音频和视频内容 不要与"transmission media"(传输介质,如电缆)混淆
Real-Time Media 生成与展示在时间上紧密贴合(数量级:不超过数百毫秒)的媒体 这是"交互性"定义的数值基础
Interactive 一方的动作能引发另一方可观察到的反应,且"动作-反应-观察"全过程在数百毫秒量级以内 这是一个性能约束而非功能约束,是 WebRTC 与"非实时"音视频传输(如点播)的本质区分
Signaling 为建立、管理、控制媒体路径和数据路径所做的通信 信令本身不在本文档强制规范的范围内(见第三节)
Signaling Path / Media Path 分别是信令和媒体实际流经的通信通道 二者在部署中可能完全不同,经过的中间实体数量也可能不同(信令路径通常更长,经过服务器)
ICE Agent / SDP Agent ICE Agent 是 ICE 协议(RFC 8445)的实现;SDP Agent 是参与 SDP offer/answer 交换(RFC 3264)的协议实现 二者不是子集关系:存在不使用 SDP 的 ICE Agent(例如使用 Jingle/XMPP 的实现),所以一个 ICE Agent 未必是 SDP Agent

2.3 关键字(Key Words)的规范性

本文档使用 BCP 14(RFC 2119 / RFC 8174)定义的大写关键字(MUST、SHOULD、MAY 等)。只有全部大写时才具有规范含义——这是阅读所有 IETF 标准文档时都需要带着的阅读习惯。

三、整体架构:浏览器模型与 RTC 梯形图

3.1 浏览器内部模型(原文 Figure 1)

文档的基本假设是:浏览器不需要把"电话"或"视频会议"这种完整应用功能都内置进去;浏览器只需暴露底层能力,由 Web 应用(JS/HTML/CSS)配合后端服务器去拼出完整业务逻辑。

HTTPS / WebSockets

RTC APIs

Other APIs

Native OS 调用

线上协议
(On-the-wire Protocols)

线上协议
(On-the-wire Protocols,直连对端浏览器)

服务器
(Servers)

JavaScript / HTML / CSS
(Web 应用层)

浏览器 RTC 功能
(Browser RTC Function)

其他浏览器 API
(Other APIs)

本机操作系统服务
(Native OS Services)

注:图中虚线代表"线上协议"(实际在网络上跑的字节),实线代表浏览器内部的 API 调用关系。这是理解 WebRTC 分层的核心——HTTPS/WebSockets 这条线也是通过浏览器 API 暴露给 JS 应用的,但它走的是"高路径"(服务器中转);而 Browser RTC Function 对外发出的线上协议走的是"低路径"(直连对端)。

3.2 RTC 梯形图(原文 Figure 2:Browser RTC Trapezoid)

这是 WebRTC 领域最经典的一张图,描述了一种典型部署形态:

信令路径
(应用自定义协议,
可选用 SIP/XMPP 等)

应用自定义协议
HTTPS/WebSockets

应用自定义协议
HTTPS/WebSockets

媒体路径
(Media Path,
必须遵循 WebRTC 协议族)

Web 服务器 A

Web 服务器 B

JS/HTML/CSS
(运行于浏览器A)

JS/HTML/CSS
(运行于浏览器B)

浏览器 A

浏览器 B

这张图的关键洞察在第七节其实已经点破:

  • 媒体路径(实线粗箭头,“low path”):浏览器与浏览器之间直接传输音视频/数据,因此必须严格遵守 WebRTC 协议族规范——这部分是"硬约束",因为对端互不相识,唯有靠协议一致性才能互通。
  • 信令路径(“high path”):经由 Web 服务器中转,可以被任意修改、翻译、加工——这部分完全不受本文档约束,可以用 SIP、XMPP、专有协议,甚至双方服务器之间私有协商。
    这也解释了为什么传统电信网络里"在网络中插入设备解析/改写信令"的运维手段(SIP ALG、靠 DHCP 发现 SIP 服务器等)在 WebRTC 场景下会失效:因为 WebRTC 的信令本质上是应用层自定义的,跑在 TLS 之上,网络中间设备根本无法假设信令的格式。文档第一节专门提到的 RFC 8155(TURN 服务器自动发现)就是在这种背景下,作为"网络协助型"方案对 RFC 3361(靠 DHCP 找 SIP 服务器)的替代。

3.3 双服务器互联的可能形态举例

如果服务器 A、B 分属不同运营方,互联协议需要双方协商。文档给了两个对称的例子:

场景 服务器间协议 服务器到浏览器的信令
双方都用 SIP 体系 SIP 标准方式:SIP over WebSockets;或专有信令
双方都用 XMPP 体系 XMPP 标准方式:XMPP over WebSockets,或 BOSH(XEP-0124);或专有信令

这说明信令层是一个"自由市场"——只要两端约定一致,可以套用现成协议,也可以完全自创。这恰恰是第六节"互操作性与创新"哲学在架构图上的具体体现。

四、六大功能分组及强制实现要求

文档将浏览器所需功能从下到上拆成六层,并明确指出前三层构成"媒体传输基础设施",后三层构成"媒体服务"

媒体服务

媒体传输基础设施

1. 数据传输
Data Transport
(TCP/UDP + 拥塞控制等)

2. 数据封装与安全
Data Framing and Securing
(RTP/SCTP/DTLS)

3. 数据格式
Data Formats
(音视频编解码器、SDP)

4. 连接管理
Connection Management
(SDP/SIP/Jingle协商)

5. 呈现与控制
Presentation and Control
(画面布局、发言权控制)

6. 本地系统支持功能
Local System Support
(回声消除、本地鉴权)

文档特别强调一种设计哲学:媒体传输基础设施(下三层)适合用统一规范、嵌入浏览器、通过标准接口访问;而媒体服务层(上三层)则鼓励"百花齐放"——但要做到不同实现之间真正互通,至少前五层(不含本地系统支持功能)必须被规范化。第六层(本地系统支持功能)则被刻意排除在强制互操作范围之外,原因下面会展开。

4.1 强制实现清单速查表

下表把正文第4–9节中所有出现"MUST implement"的条目汇总在一起,这是整篇文档真正具有约束力的核心:

功能分组 必须实现的规范 适用对象 备注
数据传输 RFC 8835(WebRTC 传输) WebRTC Endpoint 涵盖拥塞控制、重传、有序投递等
数据封装与安全 RFC 8834(RTP 使用规范)、RFC 8826(安全考虑)、RFC 8827(安全架构) WebRTC Endpoint 媒体必须用 RTP,且 SRTP(RFC 3711)为所有实现强制要求
数据封装与安全(非RTP数据) RFC 8831(数据通道)、RFC 8832(数据通道建立协议) WebRTC Endpoint 用于 DataChannel
数据格式 RFC 7874(音频编解码与处理要求)、RFC 7742(视频编解码要求) 支持音频/视频的 WebRTC Endpoint 划定了互通所需的最小编解码器基线
连接管理(完整API) RFC 8829(JSEP) WebRTC Browser 仅浏览器需要完整实现
连接管理(网络层部分) RFC 8829 中与网络层相关部分(BUNDLE/RFC 8843、rtcp-mux/RFC 5761、Trickle ICE/RFC 8838) 所有 WebRTC Endpoint Non-Browser 不需要 API 部分,但网络行为必须一致
呈现与控制 W3C.WD-webrtc(PeerConnection API)、W3C.WD-mediacapture-streams(媒体采集API) WebRTC Browser W3C 一侧的规范
本地系统支持功能 RFC 7874 全文的处理函数部分 WebRTC Endpoint 与第6节的编解码要求"合并"后等于整份 RFC 7874 都必须实现

值得留意的细节:RFC 7874 出现了两次(第6节的编解码格式要求 + 第9节的音频处理函数要求),文档原文专门点出"结合这两节的要求,意味着 WebRTC Endpoint 必须实现 RFC 7874 全文"——这是一个容易被读漏的交叉引用陷阱。

五、各功能组详细解析

5.1 数据传输(Data Transport)

定义范围:通过网络接口发送/接收数据、两端网络层地址的选择,以及与不修改数据的中间实体(如 TURN 中继)的交互。包含拥塞控制、重传、有序投递所需的功能。
强制规范:RFC 8835。这一层关心的是"怎么把字节可靠/受控地送到对端",是整个协议栈的地基。

5.2 数据封装与安全(Data Framing and Securing)


子项 规范 强制性
媒体传输格式 RTP(RFC 3550) 格式基准,必须使用
媒体加密 SRTP(RFC 3711) REQUIRED(对所有实现)
RTP/SRTP 在WebRTC场景的详细用法 RFC 8834 MUST
WebRTC 安全考虑 RFC 8826 MUST
WebRTC 安全架构(衍生的安全功能) RFC 8827 MUST
非RTP数据传输(DataChannel) RFC 8831 MUST
DataChannel 建立协议 RFC 8832 MUST

这里体现出 WebRTC 的一个重要设计立场:安全不是可选项。无论你是浏览器还是非浏览器实现,SRTP 加密是强制的——这与传统 RTP 应用(很多时候加密是可选的)形成鲜明对比,反映出"浏览器可被任意网站脚本驱动去发起音视频通信"这一威胁模型,使得默认加密成为必要前提。

5.3 数据格式(Data Formats)

文档的立场是务实的折中:理想情况下每次通信都该用最适合该场景的格式,但要保证互通,必须有一个所有实现都支持的最小基线,剩下的编解码器留给实现者自由扩展。
强制基线:

  • 音频与处理要求:RFC 7874
  • 视频处理与编解码器要求:RFC 7742
    这意味着无论你的应用想用什么"花式"编解码器,只要双方都至少支持这条基线,协商总能找到交集——这正是第六节里"mandatory-to-implement function set 的目的是防止协商失败,而不是阻止协商"这一哲学的具体落地。

5.4 连接管理(Connection Management)

这是六层中最复杂、与"信令"概念最纠缠的一层。文档列出三条设计原则:

  1. WebRTC 的媒体协商必须能够表达与 SIP 相同的 SDP offer/answer 语义(RFC 3264),从而使 SIP ↔ WebRTC 之间的信令网关在技术上可行。
  2. 应当能够(在不引入媒体网关的前提下)与支持 ICE 及相应 RTP/SDP机制、编解码器、安全机制的传统 SIP 设备打通——但信令网关(转换 Web 端信令与 SIP 信令)可能仍是必要的。
  3. 新增编解码器时,不应要求修改浏览器与 JS 应用之间的 API。只要浏览器支持了新编解码器,旧应用代码应该能"零修改"自动用上新编解码器(只要场景合适)——这条原则保证了协议层的演进不会绑架应用层代码。
    具体落地规范是 RFC 8829(JSEP,JavaScript Session Establishment Protocol)
  • WebRTC Browser 必须完整实现 JSEP;
  • WebRTC Endpoint(包括非浏览器)只需实现 JSEP 中与网络层相关的部分——具体列出三项:BUNDLE(RFC 8843,多媒体流复用进单一传输)、rtcp-mux(RFC 5761,RTP/RTCP 共用一个端口)、Trickle ICE(RFC 8838,候选地址增量式提供)。非浏览器端点不需要支持 JSEP 描述的 API 行为本身。
    这个"分层豁免"的设计很关键:它承认 API 层语义只对浏览器有意义(因为只有浏览器需要被 JS 驱动),但网络上跑的比特格式必须所有端点一致,否则两个非浏览器设备或浏览器-设备之间照样无法互通。

关于 ICE 版本的历史遗留问题(第一节提及):本文档形式上依赖较新的 RFC 8445,但截至发布时,大多数 WebRTC 实现实际跑的是更早的 RFC 5245,并使用 Trickle ICE 的"准标准"前身版本(即 RFC 8838 定稿前的草案行为)。RFC 8445 定义的 ice2 属性被设计成一种"版本探测"机制,用来识别对端到底支持哪个版本,从而实现从旧版本到新版本的平滑过渡。这是一个很实际的"标准滞后于部署"案例,值得在评估任何"协议规范"与"现网实现"差异时留意。

5.5 呈现与控制(Presentation and Control)

定义范围:确保多方交互"行为不出人意料",比如发言权控制(floor control)、屏幕布局、语音激励画面切换等——这类功能往往需要参与方之间的协作。
文档坦诚地指出:历史上 XCON(Centralized Conferencing,RFC 6501)和 Cisco/Tandberg 的 TIP(Telepresence Interoperability Protocol)都尝试规范过这类功能,但很多应用实际上是在没有标准化接口的情况下被构建出来的——也就是说,这一层目前更多依赖各厂商私有方案,而非强制标准。
控制部分中"用户对浏览器与输入输出设备/通信通道交互的控制权"被列为最重要的子项:用户必须有办法知道自己的音视频/文字被发到哪里、出于什么目的、对方做出了哪些保证。这部分落在 PeerConnection API 和媒体采集 API 上:

  • WebRTC Browser 必须实现这两份 W3C 规范。

5.6 本地系统支持功能(Local System Support Functions)

这一层的特点是:实现质量强烈影响用户体验,但具体算法不需要跨实现协调——也就是说,它不影响"线上的比特",因此不需要统一规范,各参与方可以按自己的方式实现。典型例子:回声消除(某些形式)、本地鉴权/授权机制、操作系统访问控制、本地录制能力。
文档给出三条"应当达成"的性质(不是强制互操作要求,而是质量期望):

期望性质 具体描述
回声消除质量 应当把声学反馈环路抑制到感知不到的水平
隐私保护 MUST 满足:如果提供摄像头远程控制能力,必须有API让本地用户知道是谁在控制摄像头,并可撤销该权限
自动增益控制(AGC) 如果存在,应把说话音量归一化到合理的 dB 范围内

注意其中"隐私保护"那一条是正式的 MUST(不是单纯的质量建议),这是本节里唯一一条具有规范强制力的条目——其余两条用的是 “should”(小写,不具规范效力,只是期望)。
强制规范:RFC 7874 全文(与第6节的编解码要求合并后的结果,如前所述)。

六、互操作性与创新的平衡哲学(第6节核心论证)

这一节看似"务虚",实际是理解整篇文档为什么要划出"强制基线"的思想根源。文档引用 IETF 使命声明(RFC 3935):标准给互联网带来的价值在于互操作性——多个实现同一标准的产品能够协同工作。
文档把互联网上的通信抽象成两个阶段:

  1. 双方先通过某种机制,沟通彼此各自支持哪些功能;
  2. 基于共同支持的功能进行通信;如果找不到交集,则放弃通信。
    强制实现功能集(mandatory-to-implement)存在的目的,是防止"协商失败",而不是阻止双方协商出更高级的功能。 它给市场提供了一种保证:只要你遵循规范,并且对方也愿意接受规范定义的基线通信,那么通信一定能成功。
    文档接着指出一个反面情形:如果设强制基线,并不意味着无法通信,而是意味着——要加入这个通信圈子,你必须实现"标准 + 额外的东西(profile)"。文档用一种近乎讽刺的笔法点出最糟糕的版本:那个"额外的东西"退化成"必须使用某个特定厂商的产品"——这正是 IETF 一直试图避免的供应商锁定局面。
    这一节解释了为什么前面六层里,有些层给出了硬性的 MUST(数据传输、安全、最小编解码器集),而呈现与控制、本地系统支持这两层却相对宽松:强制基线只设在"不设就会导致协商彻底失败"的层面,其余层面留给市场自由发展

七、安全考虑总览(第11节)

文档把 Web 化实时通信的安全拆成三个维度:

安全维度 关注点
组件安全 浏览器与相关服务器本身的安全——浏览器是"目标最丰富"的攻击面,目标是引入这些组件不应带来额外漏洞
通信通道安全 参与者应能方便地自行核实通信链路的加密参数,并从对方获得"已采取适当措施"的保证
身份安全 验证参与者身份属实(需要可识别身份时),或确保身份不可被反推(需要匿名性时)

具体的安全分析与由此衍生的要求,全部托管在 RFC 8826(已在数据封装与安全一节中列为 MUST)。同时提醒读者也要参阅两份 W3C 规范各自的安全章节。
可以看到,安全这一节本身没有引入新的规范性要求,它是一个"索引/总结",把散落在前文各处的安全相关 MUST 项重新串了一遍逻辑线索。

八、关键 RFC / 规范速查表


编号 标题 在本文档中的角色
RFC 8835 WebRTC 传输 数据传输层强制规范
RFC 3550 RTP 媒体格式基准
RFC 3711 SRTP 媒体加密,REQUIRED
RFC 8834 WebRTC中RTP的媒体传输与使用 数据封装与安全层强制规范
RFC 8826 WebRTC安全考虑 安全分析与要求总纲
RFC 8827 WebRTC安全架构 衍生安全功能规范
RFC 8831 / 8832 WebRTC数据通道 / 建立协议 DataChannel 强制规范
RFC 7874 WebRTC音频编解码与处理要求 数据格式 + 本地系统支持功能,两节交叉引用,全文MUST
RFC 7742 WebRTC视频处理与编解码要求 数据格式层强制规范
RFC 8829 JSEP 连接管理核心规范(Browser全实现,Endpoint部分实现)
RFC 8445 ICE 连接建立的NAT穿透基础(文档依赖此版本,但现网多用RFC 5245)
RFC 5245 ICE(旧版) 实际部署中广泛使用的版本
RFC 8838 Trickle ICE 候选地址增量提供机制
RFC 8843 BUNDLE 多媒体流复用进单一传输
RFC 5761 rtcp-mux RTP/RTCP共用端口
RFC 3264 SDP Offer/Answer模型 连接管理的语义基础,需与SIP兼容
RFC 7478 WebRTC用例与需求 定义WebRTC要满足的应用场景(背景文档)

九、学习要点总结

  1. 本文档是"索引层",不是"协议层"。真正的技术细节分散在它引用的十几份 RFC 与两份 W3C 文档里;阅读 RFC 8825 的价值在于建立一张"地图",知道遇到具体问题该去查哪份文档。
  2. 媒体路径硬约束、信令路径软约束是整个架构最重要的设计分界线。媒体(浏览器到浏览器直连)必须严格遵守协议族;信令(经服务器中转)完全是应用层自由地带,这直接决定了哪些网络运维手段(如SIP ALG)在WebRTC场景下会失效。
  3. **“强制最小基线 + 自由协商扩展”**是贯穿全文的设计哲学:无论是编解码器选择(数据格式层)还是功能分组的取舍(哪几层设MUST,哪几层只是建议),背后都是"防止协商失败"这一单一目标在起作用。
  4. API规范与协议规范是镜像关系,但适用对象不同:浏览器(Browser)既要满足协议又要满足API;非浏览器端点(Non-Browser)只需满足协议中与网络行为相关的部分。读者在判断"我的实现需要做到什么程度"时,第一步永远是先确认自己是 Browser 还是 Non-Browser。
  5. **安全默认开启(SRTP强制)**是WebRTC相对于传统RTP应用的标志性差异,根源在于浏览器可以被任意网站脚本驱动发起通信,威胁模型与传统受控环境下的VoIP应用完全不同。
  6. 标准滞后于部署是真实存在的工程现实(ICE版本case),阅读规范性文档时不能假设"规范发布即代表现网行为",需要结合实际部署情况交叉验证。
Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐