RFC 3550-RTP学习:RFC 3550:RTP 实时传输协议
作者:Schulzrinne, Casner, Frederick, Jacobson(2003年7月)
本文档是对 RFC 3550 的完整中文解读,面向初学者,力求通俗易懂。
目录
- RTP 是什么?为什么需要它?
- 核心概念词汇表
- RTP 数据包头部详解
- RTCP 控制协议详解
- RTCP 报文类型(SR / RR / SDES / BYE / APP)
- RTCP 发送间隔算法
- 混合器与翻译器
- SSRC 标识符的分配与冲突处理
- 安全性
- 附录算法详解(含 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}}Tinterval≈rtcp_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=A−LSR−DLSR
其中:
- 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=expected−expected_prior
lost_interval=expected_interval−received_interval\text{lost\_interval} = \text{expected\_interval} - \text{received\_interval}lost_interval=expected_interval−received_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)=(Rj−Ri)−(Sj−Si)
其中 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(i−1)+16∣D(i−1,i)∣−J(i−1)
增益 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 | 邮件地址 | |
| 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步:确定 nnn 和 CCC
- 若发送者比例 ≤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=members−senders
- 若发送者比例 >25%> 25\%>25%:所有人平等,C=avg_rtcp_size/rtcp_bwC = \text{avg\_rtcp\_size} / \text{rtcp\_bw}C=avg_rtcp_size/rtcp_bw,n=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=e−1.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×(tn−tc)
tp′=tc−memberspmembers×(tc−tp)t_p' = t_c - \frac{\text{members}}{\text{pmembers}} \times (t_c - t_p)tp′=tc−pmembersmembers×(tc−tp)
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}}P≈1−e−N2/233
当 N=1000N=1000N=1000 时,P≈10−4P \approx 10^{-4}P≈10−4(万分之一)。
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 |
| 电子邮件 | 3 | |
| PHONE | 电话 | 4 |
| LOC | 地理位置 | 5 |
| TOOL | 工具名称 | 6 |
| NOTE | 状态消息 | 7 |
| PRIV | 私有扩展 | 8 |
13. 整体架构总结
+-------------------------+
| 多媒体会议应用 |
+-------------------------+
| |
+-----------+ +-----------+
| |
+---------+--------+ +----------+--------+
| 音频 RTP 会话 | | 视频 RTP 会话 |
| (独立 SSRC 空间) | | (独立 SSRC 空间) |
+------------------+ +-------------------+
| RTP 数据包 | | RTP 数据包 |
| (偶数端口) | | (偶数端口) |
+------------------+ +-------------------+
| RTCP 控制包 | | RTCP 控制包 |
| (奇数端口) | | (奇数端口) |
+------------------+ +-------------------+
| |
+---------------+---------------+
|
UDP / IP / 以太网
关键设计原则回顾:
- RTP 本身不保证可靠性,依赖 UDP
- SSRC 是每路流的唯一标识,随机生成,碰撞时需要检测和解决
- RTCP 的发送频率必须随参与者数量动态调整
- 不同媒体(音/视频)用不同 RTP 会话,通过 CNAME 关联同步
- 混合器改变流的本质(新 SSRC),翻译器保持流的本质(原 SSRC)
RFC 7826:RTSP 2.0 实时流媒体协议 — 从零理解
作者:Schulzrinne, Rao, Lanphier, Westerlund, Stiemerling(2016年12月)
本文档是对 RFC 7826(RTSP 2.0)的完整中文解读,面向初学者,力求通俗易懂。
目录
- RTSP 是什么?与 RTP 的关系
- 核心概念词汇表
- 协议消息结构
- 方法(Method)详解
- 状态机与会话生命周期
- 时间格式
- 媒体属性与内容类型
- 连接管理
- 能力协商与扩展机制
- 代理(Proxy)
- 缓存机制
- 安全性
- 完整交互示例(含时序图)
- 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://...= 要操作的资源 URIRTSP/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 个方法:
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
[会话销毁]
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 四种内容类型
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 的角色是"路线图"和"协调点",确保:
- WebRTC 所需的全部组成部分都"可被找到"(findable);
- 属于互联网协议族范畴的那些部分,都被完整规范化并走在正确的标准化轨道上。
也就是说,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)配合后端服务器去拼出完整业务逻辑。
注:图中虚线代表"线上协议"(实际在网络上跑的字节),实线代表浏览器内部的 API 调用关系。这是理解 WebRTC 分层的核心——HTTPS/WebSockets 这条线也是通过浏览器 API 暴露给 JS 应用的,但它走的是"高路径"(服务器中转);而 Browser RTC Function 对外发出的线上协议走的是"低路径"(直连对端)。
3.2 RTC 梯形图(原文 Figure 2:Browser RTC Trapezoid)
这是 WebRTC 领域最经典的一张图,描述了一种典型部署形态:
这张图的关键洞察在第七节其实已经点破:
- 媒体路径(实线粗箭头,“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);或专有信令 |
这说明信令层是一个"自由市场"——只要两端约定一致,可以套用现成协议,也可以完全自创。这恰恰是第六节"互操作性与创新"哲学在架构图上的具体体现。
四、六大功能分组及强制实现要求
文档将浏览器所需功能从下到上拆成六层,并明确指出前三层构成"媒体传输基础设施",后三层构成"媒体服务":
文档特别强调一种设计哲学:媒体传输基础设施(下三层)适合用统一规范、嵌入浏览器、通过标准接口访问;而媒体服务层(上三层)则鼓励"百花齐放"——但要做到不同实现之间真正互通,至少前五层(不含本地系统支持功能)必须被规范化。第六层(本地系统支持功能)则被刻意排除在强制互操作范围之外,原因下面会展开。
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)
这是六层中最复杂、与"信令"概念最纠缠的一层。文档列出三条设计原则:
- WebRTC 的媒体协商必须能够表达与 SIP 相同的 SDP offer/answer 语义(RFC 3264),从而使 SIP ↔ WebRTC 之间的信令网关在技术上可行。
- 应当能够(在不引入媒体网关的前提下)与支持 ICE 及相应 RTP/SDP机制、编解码器、安全机制的传统 SIP 设备打通——但信令网关(转换 Web 端信令与 SIP 信令)可能仍是必要的。
- 新增编解码器时,不应要求修改浏览器与 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):标准给互联网带来的价值在于互操作性——多个实现同一标准的产品能够协同工作。
文档把互联网上的通信抽象成两个阶段:
- 双方先通过某种机制,沟通彼此各自支持哪些功能;
- 基于共同支持的功能进行通信;如果找不到交集,则放弃通信。
强制实现功能集(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要满足的应用场景(背景文档) |
九、学习要点总结
- 本文档是"索引层",不是"协议层"。真正的技术细节分散在它引用的十几份 RFC 与两份 W3C 文档里;阅读 RFC 8825 的价值在于建立一张"地图",知道遇到具体问题该去查哪份文档。
- 媒体路径硬约束、信令路径软约束是整个架构最重要的设计分界线。媒体(浏览器到浏览器直连)必须严格遵守协议族;信令(经服务器中转)完全是应用层自由地带,这直接决定了哪些网络运维手段(如SIP ALG)在WebRTC场景下会失效。
- **“强制最小基线 + 自由协商扩展”**是贯穿全文的设计哲学:无论是编解码器选择(数据格式层)还是功能分组的取舍(哪几层设MUST,哪几层只是建议),背后都是"防止协商失败"这一单一目标在起作用。
- API规范与协议规范是镜像关系,但适用对象不同:浏览器(Browser)既要满足协议又要满足API;非浏览器端点(Non-Browser)只需满足协议中与网络行为相关的部分。读者在判断"我的实现需要做到什么程度"时,第一步永远是先确认自己是 Browser 还是 Non-Browser。
- **安全默认开启(SRTP强制)**是WebRTC相对于传统RTP应用的标志性差异,根源在于浏览器可以被任意网站脚本驱动发起通信,威胁模型与传统受控环境下的VoIP应用完全不同。
- 标准滞后于部署是真实存在的工程现实(ICE版本case),阅读规范性文档时不能假设"规范发布即代表现网行为",需要结合实际部署情况交叉验证。
更多推荐


所有评论(0)