# webrtc **Repository Path**: w3cmiui/webrtc ## Basic Information - **Project Name**: webrtc - **Description**: webrtc多人会议 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2026-01-11 - **Last Updated**: 2026-01-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # WebRTC SFU 工程实践 ## 目录索引 - [1. 背景说明](#1-背景说明) - [2. 日志场景概览](#2-日志场景概览) - [3. 为什么会出现两轮 Offer / Answer](#3-为什么会出现两轮-offer--answer) - [4. 时序关系说明](#4-时序关系说明) - [5. 客户端2加入时的详细代码流程](#5-客户端2加入时的详细代码流程) - [6. 为什么客户端1也需要重新协商](#6-为什么客户端1也需要重新协商) - [7. SDP 内容变化分析](#7-sdp-内容变化分析) - [8. 大规模会议中的性能问题](#8-大规模会议中的性能问题) - [9. 常见优化方案](#9-常见优化方案) - [10. 进一步优化:Transceiver 预分配](#10-进一步优化transceiver-预分配) - [11. WebRTC 状态机中最常见的三类死锁](#11-webrtc-状态机中最常见的三类死锁) - [12. WebRTC 关键概念深入解析](#12-webrtc-关键概念深入解析) - [13. ICE 异步性与 end-of-candidates 的真相](#13-ice-异步性与-end-of-candidates-的真相) - [14. 网络抖动、包乱序与 Jitter Buffer](#14-网络抖动包乱序与-jitter-buffer) - [15. RTCP 反馈机制](#15-rtcp-反馈机制) - [16. RTP 协议与 H.264 NALU 详解](#16-rtp-协议与-h264-nalu-详解) - [17. Offer/Answer 批量协商解析](#17-offeranswer-批量协商解析) - [18. RTP 与 RTCP 包结构深度解析](#18-rtp-与-rtcp-包结构深度解析) --- ## 1. 背景说明 本文基于一次真实的 WebRTC SFU 多端接入日志,对 **双轮 Offer/Answer 交换机制**进行系统分析,并进一步讨论在多人会议场景下的性能问题与工程优化方案。 适用读者: * WebRTC / SFU 实时音视频开发者 * 已理解 Offer / Answer / SDP / Track / PeerConnection * 正在实现或优化多人会议系统 --- ## 2. 日志场景概览 日志反映了一个典型的 SFU 房间中两位客户端的接入流程。 ``` 阶段一:客户端1 - 客户端1发送 Offer - 服务器返回 Answer - 服务器收到客户端1的视频轨道 阶段二:客户端2加入 - 客户端2加入房间 - 客户端2发送 Offer - 服务器返回 Answer - 服务器将客户端1的 track 添加到客户端2的 PeerConnection 阶段三:客户端2重新协商 - 客户端2触发 renegotiation - 服务器向客户端2发送新的 Offer - 客户端2返回 Answer 阶段四:客户端1重新协商 - 服务器收到客户端2的视频轨道 - 服务器将客户端2的 track 添加到客户端1 - 客户端1触发 renegotiation - 服务器向客户端1发送新的 Offer - 客户端1返回 Answer ``` --- ## 3. 为什么会出现两轮 Offer / Answer ### 3.1 第一轮:初始连接建立 第一轮 Offer/Answer 的目标是: * 建立客户端与 SFU 之间的 PeerConnection * 协商客户端自身发送的媒体能力 流程为: 1. 客户端加入房间 2. 客户端发送 Offer(描述自身媒体) 3. SFU 返回 Answer(确认接收能力) 此时: * PeerConnection 进入 `connected` 状态 * SDP 只包含客户端自身的媒体信息 * 不包含房间内其他用户的媒体 --- ### 3.2 第二轮:重新协商(Renegotiation) 第二轮 Offer/Answer 的出现是 WebRTC 状态机的必然结果。 触发原因: * SFU 调用了 `AddTrack()` 向 PeerConnection 添加新的远端媒体 * 新的媒体未包含在原 SDP 中 * SDP 结构发生变化 WebRTC 库行为: * `AddTrack()` 会自动触发 `OnNegotiationNeeded` * 应用层必须发起新的 Offer * 通过 Answer 完成 SDP 同步 --- ## 4. 时序关系说明 ``` Client1 SFU Client2 | | | |--- Offer ------>| | |<-- Answer ------| | | | | | |<--- Offer ---------| | |---- Answer ------->| | | | | | AddTrack(Client1) | | | | | |---- Offer -------->| | |<--- Answer --------| | | | | AddTrack(Client2) | | | | |<--- Offer ------| | |--- Answer ----->| | ``` --- ## 5. 客户端2加入时的详细代码流程 ``` 1. 客户端2发送 JOIN 2. SFU 创建 PeerConnection - 注册 onTrack - 注册 onICECandidate - 注册 onConnectionStateChange - 注册 onNegotiationNeeded 3. 客户端2发送 Offer 4. SFU: - SetRemoteDescription - CreateAnswer - SetLocalDescription 5. SFU 调用 AddExistingTracksToNewPeer - 向客户端2添加客户端1的 track - 触发 OnNegotiationNeeded 6. SFU 创建并发送新的 Offer 7. 客户端2返回 Answer ``` --- ## 6. 为什么客户端1也需要重新协商 当 SFU 接收到客户端2的媒体轨道时: 1. 触发 `onTrack` 回调 2. 创建 TrackLocal 副本 3. 调用 `broadcastTrackToOthers` 4. 向客户端1的 PeerConnection 调用 `AddTrack` 5. 客户端1触发 `OnNegotiationNeeded` 6. SFU 向客户端1发起新的 Offer 这是一个**完全对称的过程**。 --- ## 7. SDP 内容变化分析 ### 7.1 第一轮 SDP 客户端 Offer: ``` m=video a=ssrc: ``` SFU Answer: ``` m=video ``` 只描述单端媒体。 --- ### 7.2 第二轮 SDP SFU Offer: ``` m=video (客户端自身) m=video (其他用户) a=ssrc: ``` 客户端 Answer: ``` m=video m=video ``` 完成多路媒体同步。 --- ## 8. 大规模会议中的性能问题 ### 8.1 Renegotiation 数量分析 当第 N+1 个用户加入时: * 新用户:1 次 renegotiation * 现有 N 个用户:各 1 次 renegotiation 总计: ``` N + 1 次 ``` 100 人会议累计: ``` 0 + 2 + 3 + ... + 100 = 5049 次 ``` 这是 O(N²) 复杂度。 ### 8.2 本项目的优化实现:防抖批量协商 本项目实现了防抖批量协商机制,将 O(N²) 复杂度降低到 O(N)。 #### 核心原理 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 防抖批量协商原理 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 未优化(每次 AddTrack 立即触发协商): │ │ │ │ AddTrack(user1) ──> Renegotiation ──> SDP交换 │ │ AddTrack(user2) ──> Renegotiation ──> SDP交换 │ │ AddTrack(user3) ──> Renegotiation ──> SDP交换 │ │ ... │ │ N 个用户 = N 次协商 │ │ │ │ 优化后(防抖合并): │ │ │ │ AddTrack(user1) ──┐ │ │ AddTrack(user2) ──┼──> 等待 200ms ──> 1 次 Renegotiation ──> SDP交换 │ │ AddTrack(user3) ──┘ │ │ ... │ │ N 个用户 = 1 次协商 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` #### 复杂度对比 | 场景 | 未优化 | 优化后 | |------|--------|--------| | 第 N+1 人加入 | N+1 次协商 | 最多 2 次协商(新用户1次 + 现有用户批量1次) | | 100 人会议累计 | 5049 次 | ~200 次 | | 时间复杂度 | O(N²) | O(N) | --- ## 9. 常见优化方案 ### 9.1 防抖与批量协商 将多个 `AddTrack` 合并为一次 renegotiation。 ``` - 延迟触发 Offer - 合并短时间内的多次变更 ``` --- ### 9.2 批量 AddTrack 在一次协商周期内添加所有 Track,减少状态机切换次数。 --- ### 9.3 架构级优化 | 架构 | 特点 | 场景 | | --------- | ----- | ---- | | MCU | 服务器混流 | 超大会议 | | Simulcast | 多码率选择 | 中型会议 | | 主动订阅 | 按需转发 | 大型会议 | --- ## 10. 进一步优化:Transceiver 预分配 ### 10.1 核心思想 将“未来可能出现的媒体”提前写入 SDP,避免后续结构变化。 ### 10.2 实现方式 在 PeerConnection 创建时预分配 transceiver: 进行媒体切换,不触发 renegotiation。 --- ## 11. WebRTC 状态机中最常见的三类死锁 ### 11.1 Offer Glare 双方同时 CreateOffer,进入冲突状态。 解决方案: * 明确角色(SFU 为 impolite,客户端为 polite) * 冲突时按角色处理 --- ### 11.2 错误状态下 SetRemoteDescription Answer 只能在 `have-local-offer` 状态下设置。 解决方案: * 信令串行化 * 每个 PeerConnection 独立信令队列 --- ### 11.3 OnNegotiationNeeded 重入 在回调中立即 CreateOffer 导致递归触发。 解决方案: * 检查 signalingState == stable * 使用防抖和异步调度 --- ## 12. WebRTC 关键概念深入解析 ### 12.1 CNAME 是什么?有什么用? #### CNAME 的本质 CNAME = Canonical Name(规范名称) 它是 RTCP SDES 里的一种字段,用来: 把"不同 SSRC 的 RTP 流,标识为同一个发送者" 核心一句话: SSRC 会变,但 CNAME 应该稳定 --- #### 为什么需要 CNAME? SSRC 的问题 * SSRC 是 32 位随机数 * 会发生: * 冲突(SSRC collision) * 重协商 * 重启编码器 * RTX / FEC 使用不同 SSRC 同一个"人 / 设备"会用多个 SSRC --- CNAME 的作用 RTCP 里靠 CNAME 来表达: ``` SSRC 11111111 ┐ SSRC 22222222 ├── 同一个发送者 SSRC 33333333 ┘ ↑ CNAME = "user-1234" ``` 典型用途: | 场景 | 依赖 CNAME | | --------------------- | -------- | | 音视频同步(lip-sync) | ✅ | | RTX / FEC 关联 | ✅ | | 多编码层(Simulcast / SVC) | ✅ | | SFU 识别"同一个人" | ✅ | | SSRC 变化后的恢复 | ✅ | --- #### CNAME 在哪出现? RTCP SDES ``` RTCP SR / RR RTCP SDES - CNAME: user-1234 - NAME - TOOL ``` 每个 SSRC 都会带 SDES,但 CNAME 应相同 --- #### 为什么"不建议用 msid 当 CNAME"? msid 是什么?(先简单说) msid = MediaStream ID + Track ID 例如: ``` msid: stream123 video456 ``` 它是 WebRTC 内部"轨道级"概念 --- 用 msid 当 CNAME 的问题 | 问题 | 说明 | | ----------- | ------------------------- | | 语义不对 | CNAME 是"发送者",msid 是"轨道" | | 轨道变了就变 | 换 track / replaceTrack 就变 | | 多 track 会冲突 | 同一个人多个 msid | | SFU 不好聚合 | 无法判断"是不是同一个人" | 结论: ❌ msid ≠ 发送者身份 ✅ CNAME = 用户 / 设备级稳定 ID --- 推荐的 CNAME 设计 ```text userId uuid deviceId random-128bit ``` 例如: ``` CNAME = "u_9f2a8d73c1" ``` --- #### 如果 CNAME 乱用,会发生什么? * 音视频可能不同步 * RTX/FEC 找不到主流 * SFU 聚合失败 * SSRC 切换后播放器当成"新用户" --- ### 12.2 msid 是什么?有什么用? #### msid 的本质 msid = MediaStream Identification 它是 SDP / RTP Header Extension 里的字段,用来: 把 RTP 流映射回 WebRTC 的 MediaStream / Track --- #### msid 解决的问题 浏览器里是这样: ```js MediaStream ├── AudioTrack (id=a1) └── VideoTrack (id=v1) ``` 但 RTP 世界只有: ``` SSRC 1111 SSRC 2222 ``` msid 用来告诉浏览器: "这个 SSRC 属于哪个 stream / 哪个 track" --- #### msid 在哪出现? SDP ```sdp a=msid:stream123 video456 ``` RTP Header Extension ``` urn:ietf:params:rtp-hdrext:sdes:mid urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id ``` --- #### msid 的作用总结 | 用途 | 是否必须 | | ------------- | ---- | | WebRTC 轨道绑定 | ✅ | | replaceTrack | ✅ | | 多轨道区分 | ✅ | | 浏览器播放 | ✅ | | RTP / GB28181 | ❌ | GB28181 / 纯 RTP 完全不用 msid --- #### msid 与 CNAME 的根本区别 | 对比项 | CNAME | msid | | ---- | ------ | ------------ | | 层级 | 发送者级 | 轨道级 | | 位置 | RTCP | SDP / RTP 扩展 | | 稳定性 | 长期稳定 | 经常变化 | | 作用对象 | 人 / 设备 | Track | | 能否替代 | ❌ | ❌ | --- #### 图例表明几者的关系 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ WebRTC 媒体标识符的完整关系图 │ └─────────────────────────────────────────────────────────────────────────────┘ 用户 A 的浏览器 │ ├─ CNAME: user-a@laptop-123 (用户的规范名称 - RTCP SDES 中声明) │ │ │ ├─ StreamID: stream-user-a (媒体流 ID - 一个设备/用户的所有媒体) │ │ │ │ │ ├─ 视频轨道 (TrackID: video-track-1) │ │ │ │ │ │ │ ├─ msid: stream-user-a video-track-1 (SDP 中声明) │ │ │ ├─ SSRC: 1001 (RTP 包头的标识) │ │ │ ├─ RtxSSRC: 1101 (快速重传流的 SSRC) │ │ │ └─ Codec: H264 │ │ │ │ │ └─ 音频轨道 (TrackID: audio-track-1) │ │ │ │ │ ├─ msid: stream-user-a audio-track-1 (SDP 中声明) │ │ ├─ SSRC: 2001 (RTP 包头的标识) │ │ ├─ RtxSSRC: 2101 │ │ └─ Codec: OPUS ┌─────────────────────────────────────────────────────────────────────────────┐ │ 各标识符的作用范围 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ CNAME (user-a@laptop-123) │ │ ├─ 范围: RTCP 报文中 [长期稳定] │ │ ├─ 作用: 识别用户身份 │ │ ├─ 用途: 媒体同步、质量监测、接收端识别 │ │ └─ 跨媒体源: 同一用户的多个设备都共享一个 CNAME │ │ │ │ StreamID (stream-user-a) │ │ ├─ 范围: SDP 的 msid 属性 [相对稳定] │ │ ├─ 作用: 标识一个媒体源(通常是一个用户) │ │ ├─ 用途: 浏览器重建 MediaStream,关联音视频 │ │ └─ 包含: 可以有多个 Track(视频+音频) │ │ │ │ TrackID (video-track-1 / audio-track-1) │ │ ├─ 范围: SDP 的 msid 属性,RTP 扩展 [经常变化] │ │ ├─ 作用: 在一个 Stream 内标识具体轨道 │ │ ├─ 用途: 区分视频和音频,浏览器追踪具体轨道 │ │ └─ 关系: StreamID:TrackID = msid 的组合 │ │ │ │ SSRC (1001 / 2001 / ...) │ │ ├─ 范围: RTP 包头 + SDP 声明 [经常变化] │ │ ├─ 作用: 标识一个媒体源的 RTP 流 │ │ ├─ 用途: 请求关键帧、丢包恢复、接收端匹配 │ │ └- 关键: 每个 Track 有唯一 SSRC,不能跨轨道复用 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ SDP 协议中的完整声明 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ m=video 9 UDP/TLS/RTP/SAVPF 96 │ │ a=msid:stream-user-a video-track-1 ← streamID trackID 的组合 │ │ a=ssrc:1001 cname:user-a@laptop-123 ← SSRC 与 CNAME 的映射 │ │ a=ssrc:1001 msid:stream-user-a video-track-1 ← SSRC 与 msid 的映射 │ │ a=ssrc-group:FID 1001 1101 ← 主流(SSRC=1001) 和 RTX 流(RtxSSRC=1101) │ │ │ │ m=audio 9 UDP/TLS/RTP/SAVPF 97 │ │ a=msid:stream-user-a audio-track-1 │ │ a=ssrc:2001 cname:user-a@laptop-123 │ │ a=ssrc:2001 msid:stream-user-a audio-track-1 │ │ a=ssrc-group:FID 2001 2101 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ 多用户场景中的关系 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 用户 A 用户 B 用户 C │ │ ├─ CNAME: user-a ├─ CNAME: user-b ├─ CNAME: user-c │ │ ├─ StreamID: stream-a ├─ StreamID: stream-b ├─ StreamID: stream-c │ │ ├─ Video(SSRC=1001) │ ├─ Video(SSRC=1002) │ ├─ Video(SSRC=1003) │ │ └─ Audio(SSRC=2001) │ └─ Audio(SSRC=2002) │ └─ Audio(SSRC=2003) │ │ │ 服务器(SFU)接收所有轨道并保存: │ │ ├─ TrackInfo{Kind: video, CNAME: user-a, StreamID: stream-a, SSRC: 1001} │ │ ├─ TrackInfo{Kind: audio, CNAME: user-a, StreamID: stream-a, SSRC: 2001} │ │ ├─ TrackInfo{Kind: video, CNAME: user-b, StreamID: stream-b, SSRC: 1002} │ │ ├─ TrackInfo{Kind: audio, CNAME: user-b, StreamID: stream-b, SSRC: 2002} │ │ ├─ TrackInfo{Kind: video, CNAME: user-c, StreamID: stream-c, SSRC: 1003} │ │ └─ TrackInfo{Kind: audio, CNAME: user-c, StreamID: stream-c, SSRC: 2003} │ │ │ │ 转发给接收端时,保持原始的 msid + CNAME: │ │ ├─ 发给用户 B: {stream-a, stream-c} 的音视频(不包括 stream-b 自己的) │ │ ├─ 发给用户 C: {stream-a, stream-b} 的音视频(不包括 stream-c 自己的) │ │ └─ 发给用户 A: {stream-b, stream-c} 的音视频(不包括 stream-a 自己的) │ │ │ │ 接收端通过 CNAME 识别来自谁,通过 msid(streamID+trackID) 重建媒体流 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ### 12.3 结合你现在的系统给个"工程级结论" #### SSRC 变化 ✅ 正常 ❌ 不要强行固定 ✅ 用 CNAME 兜底 --- #### CNAME | 建议 | 原因 | | ------------------- | ----- | | 使用稳定 user/device ID | 发送者身份 | | 不用 msid | 语义错误 | | 不随重协商变化 | 保证聚合 | --- #### msid | 用 | 不用 | | ------------ | --------------- | | WebRTC 浏览器 | GB28181 | | 多 Track | RTP passthrough | | replaceTrack | SFU 纯转发 | --- 本质总结 CNAME = "你是谁" msid = "你身上哪条胳膊在动" --- ## 13. ICE 异步性与 end-of-candidates 的真相 ### 13.1 先给结论(纠偏版) ICE 候选的"产生"是异步的 但 SDP 本身是一次性快照 在 SDP 里写 `a=end-of-candidates`,只在"特定模式"下才有意义 你这句: "我在 SDP 添加 end-of-candidates 有什么用?" 在标准 WebRTC Trickle ICE 场景下,几乎没用,甚至是多余的 --- ### 13.2 ICE 到底"异步"在哪里?(这是关键) #### ICE 的三个层次,很多人混在一起了 | 层次 | 是否异步 | 说明 | | ---------------------- | ------------- | --------------- | | ICE agent 收集 candidate | ✅ 异步 | STUN/TURN 响应不可控 | | Candidate 通过信令发送 | ✅ 异步(Trickle) | 一条一条发 | | SDP Offer / Answer | ❌ 同步快照 | 描述"当前已知信息" | ICE 异步 ≠ SDP 异步 --- #### 标准 WebRTC 是怎么干的? 浏览器真实流程是: ``` createOffer() ↓ SDP (此时可能 0 个 candidate) ↓ setLocalDescription() ↓ ICE agent 在后台慢慢收集 ↓ onicecandidate() 一条条回调 ``` 这就是 Trickle ICE --- ### 13.3 那 `end-of-candidates` 到底是干嘛的? #### 它不是给"完整 SDP"用的 `a=end-of-candidates` 不是用来描述 SDP 完整性的 它是: "trickle ICE 信令流"的一个终止标志 --- #### 正确出现的位置(重点) 合理的几种情况 情况一:ICE-lite / 非 Trickle(一次性) 如果你不使用 Trickle ICE,而是: * 等所有 candidate 收集完成 * 一次性写入 SDP * 再发 Offer / Answer 那么: ```sdp a=candidate:... a=candidate:... a=candidate:... a=end-of-candidates ``` 这里是有意义的 表示: "这个 SDP 里已经包含全部 candidate,不会再有了" --- 情况二:自研 ICE / SFU 需要状态机 在一些 非浏览器实现中: * SDP 被多次更新 * Candidate 通过 SDP patch 发送 * 没有 onicecandidate 事件 `end-of-candidates` 用来: 显式驱动 ICE 状态机结束 gathering --- 情况三:标准 WebRTC + Trickle(你现在大概率是) * candidate 是通过信令消息单独发送的 * SDP 只是一张"结构图" 此时在 SDP 里加 `a=end-of-candidates` 结论是: ❌ 不起作用 ❌ 不代表 ICE 结束 ❌ 浏览器基本忽略 --- #### 浏览器里 ICE 结束是靠什么? 不是靠 SDP,而是: ```js pc.onicecandidate = e => { if (e.candidate === null) { // ICE gathering complete } } ``` null 才是"真正的 end-of-candidates" --- ### 13.4 为什么还"建议保持一致"?(工程角度) 你看到的那段建议,本质是偏"规范洁癖 / 实现一致性",不是功能必须。 | 角度 | 说明 | | ----- | ----------------- | | 规范一致 | RFC 8838 推荐 | | 自研实现 | 避免状态机悬空 | | 对等端兼容 | 防止老 ICE agent 卡死 | | 日志可读性 | 明确"candidate 已结束" | 但注意一句非常重要的工程结论: 它不是 WebRTC 连接能否建立的关键条件 --- ### 13.5 用一句"非常工程化"的话总结 ICE 的异步性,体现在 candidate 的"发现过程" 而 SDP 永远只是"某一时刻的静态描述" `a=end-of-candidates` 只有在 SDP 承担 candidate 传输职责时才有意义 --- ### 13.6 回到你原问题,逐条"判定对错" "ICE 是异步的?" ✅ 是(candidate gathering 层面) ❌ 不是 SDP 层面 --- "我在 SDP 里加 end-of-candidates 有什么用?" | 你的场景 | 作用 | | ----------------- | --------- | | 浏览器 + Trickle ICE | ❌ 几乎没用 | | 非 Trickle ICE | ✅ 有意义 | | 自研 ICE / SFU | 取决于状态机 | | RTP 透传 | ❌ 完全没用 | --- ## 14. 网络抖动、包乱序与 Jitter Buffer ### 14.1 网络传输中的三大问题 在实时音视频传输中,网络会导致三种典型问题: | 问题类型 | 描述 | 对播放的影响 | |---------|------|-------------| | **网络抖动** | 包到达时间不稳定 | 忽快忽慢,卡顿 | | **包乱序** | 包顺序错乱 | 解码失败,花屏 | | **包丢失** | 包彻底丢失 | 帧不完整,卡顿 | --- ### 14.2 网络抖动详解 #### 什么是网络抖动? 网络抖动是指数据包到达时间的**不稳定性/波动**。 #### 理想情况 vs 实际情况 假设发送端每 **40ms** 发送一个视频帧(25fps): ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 理想网络(无抖动) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 发送端: [包1] ---40ms--- [包2] ---40ms--- [包3] ---40ms--- [包4] │ │ ↓ ↓ ↓ ↓ │ │ 时间: 0ms 40ms 80ms 120ms │ │ │ │ 接收端: [包1] ---40ms--- [包2] ---40ms--- [包3] ---40ms--- [包4] │ │ ↓ ↓ ↓ ↓ │ │ 时间: 50ms 90ms 130ms 170ms │ │ (固定延迟 50ms,间隔稳定) │ │ │ │ 结果: 播放流畅 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ 实际网络(有抖动) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 发送端: [包1] ---40ms--- [包2] ---40ms--- [包3] ---40ms--- [包4] │ │ ↓ ↓ ↓ ↓ │ │ 时间: 0ms 40ms 80ms 120ms │ │ │ │ 接收端: [包1] --20ms-- [包2] ----80ms---- [包3] -10ms- [包4] │ │ ↓ ↓ ↓ ↓ │ │ 时间: 50ms 70ms 150ms 160ms │ │ 延迟变化不规律! │ │ │ │ 结果: 播放忽快忽慢,出现卡顿 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` #### 抖动的影响分析 | 时间点 | 预期收到 | 实际收到 | 问题 | |-------|---------|---------|------| | 70ms | - | 包2 | 提前到了 | | 90ms | 包2 | ❌ 已收 | - | | 130ms | 包3 | ❌ 还没到 | **播放器卡住!** | | 150ms | - | 包3 | 来晚了 | --- ### 14.3 包乱序 (Reordering) 详解 #### 什么是包乱序? 发送顺序是 1→2→3→4,但接收顺序变成了 1→3→2→4。 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 包乱序示意图 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 发送端: [1] → [2] → [3] → [4] │ │ ↓ ↓ ↓ ↓ │ │ 网络(不同路径、不同延迟) │ │ ↓ ↓ ↓ ↓ │ │ 接收端: [1] → [3] → [2] → [4] ← 顺序错乱! │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` #### 为什么会乱序? | 原因 | 说明 | |-----|------| | 多路径传输 | 包走了不同的网络路径 | | 路由器队列 | 不同队列处理速度不同 | | 并行处理 | 网络设备并行处理导致 | | 重传 | 重传的包到达时机不同 | #### 乱序对视频的影响 对于 H.264 等视频编码: * 一帧视频可能被分成多个 RTP 包 * 包必须按顺序重组才能解码 * 乱序会导致:帧不完整 → 解码失败 → 花屏/绿屏 --- ### 14.4 Jitter Buffer 详解 #### Jitter Buffer 是什么? Jitter Buffer(抖动缓冲区)像一个**蓄水池**: * 先积攒一定数量的包 * 对包按序列号排序 * 再按固定节奏输出 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Jitter Buffer 工作原理 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ Jitter Buffer │ │ ┌─────────────┐ │ │ 网络抖动的包 ───────>│ [5][4][3][2]│──────> 均匀输出的包 │ │ (到达时间不规律) │ 缓冲区 │ (固定间隔播放) │ │ (顺序可能错乱) └─────────────┘ (顺序正确) │ │ ↓ │ │ 自动排序 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ 处理过程示例 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 输入(乱序+抖动): │ │ t=0ms: 收到包 seq=101 │ │ t=5ms: 收到包 seq=103 ← 乱序,102 还没到 │ │ t=8ms: 收到包 seq=102 ← 终于到了 │ │ t=50ms: 收到包 seq=104 ← 延迟大 │ │ │ │ Jitter Buffer 内部: │ │ 缓冲: [101, 102, 103, 104] ← 自动排序 │ │ │ │ 输出(均匀+有序): │ │ t=100ms: 输出包 seq=101 │ │ t=140ms: 输出包 seq=102 │ │ t=180ms: 输出包 seq=103 │ │ t=220ms: 输出包 seq=104 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` #### Jitter Buffer 的核心参数 | 参数 | 说明 | 权衡 | |-----|------|------| | **最小缓冲包数** | 开始播放前需要缓冲的包数量 | 值大→延迟高但更平滑 | | **最大缓冲包数** | 缓冲区最大容量 | 值大→内存占用高 | | **超时时间** | 等待迟到包的最长时间 | 值大→延迟高 | --- ### 14.5 Jitter Buffer 能解决什么?不能解决什么? ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Jitter Buffer 能力边界 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 问题类型 │ 能解决? │ 原理 │ │ ───────────────┼─────────┼─────────────────────────────────────────────── │ │ 网络抖动 │ ✅ 是 │ 缓冲后均匀输出,抹平时间波动 │ │ 包乱序 │ ✅ 是 │ 按序列号排序后再输出 │ │ 包丢失 │ ❌ 否 │ 包根本没收到,无法凭空创造 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` #### 关键认知 **Jitter Buffer 只能处理"收到的包",无法处理"没收到的包"!** 当发生丢包时: 1. Jitter Buffer 根本收不到这些包 2. 序列号出现空洞 3. 视频帧不完整,解码器无法正确解码 4. 特别是 H.264 的 I 帧丢失会导致后续 P/B 帧全部无法解码 --- ### 14.6 丢包问题的解决方案 既然 Jitter Buffer 无法解决丢包,那需要其他机制: #### 方案一:NACK 接收端检测到丢包后,向发送端请求重传。 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ NACK 重传机制 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 发送端 接收端 │ │ │ │ │ │ │─── 包1 (seq=101) ────────────────>│ │ │ │─── 包2 (seq=102) ───────X │ ← 丢失! │ │ │─── 包3 (seq=103) ────────────────>│ │ │ │ │ │ │ │ │ 检测到 seq=102 缺失 │ │ │<── NACK (请求 seq=102) ──────────│ │ │ │ │ │ │ │─── 包2 (seq=102) 重传 ───────────>│ ← 重传成功 │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` | 优点 | 缺点 | |-----|------| | 节省带宽(按需重传) | 需要 RTT 时间 | | 实现相对简单 | 高丢包率时可能来不及 | --- #### 方案二:FEC(Forward Error Correction) 发送端额外发送冗余数据,接收端可以在一定丢包率下恢复原始数据。 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ FEC 前向纠错机制 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 发送端: │ │ 原始数据: [A] [B] [C] │ │ FEC 冗余: [A⊕B⊕C] │ │ 发送: [A] [B] [C] [FEC] │ │ │ │ 接收端(B 丢失): │ │ 收到: [A] [X] [C] [FEC] │ │ 恢复: B = A ⊕ C ⊕ FEC │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` | 优点 | 缺点 | |-----|------| | 无需等待 RTT | 始终消耗额外带宽 | | 低延迟恢复 | 高丢包率时冗余不够 | --- #### 方案对比 | 方案 | 延迟 | 带宽 | 适用场景 | |-----|------|------|---------| | NACK | 需要 1 RTT | 按需 | 低延迟网络 | | FEC | 无额外延迟 | 固定开销 | 高延迟网络 | | NACK + FEC | 综合 | 综合 | 通用场景 | --- ### 14.7 完整的 RTP 接收处理流程 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ RTP 接收端完整处理流程 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 网络 │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ │ │ RTP 接收 │ │ │ └────────┬────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ │ 丢包检测 │────>│ NACK 发送 │──> 请求重传 │ │ └────────┬────────┘ └─────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ │ │ Jitter Buffer │ ← 排序 + 缓冲 │ │ │ (抖动缓冲) │ │ │ └────────┬────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ │ │ 解包器 │ ← 重组 RTP 包为完整帧 │ │ │ (Depacketizer) │ │ │ └────────┬────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ │ │ 解码器 │ ← H.264/VP8/Opus 解码 │ │ │ (Decoder) │ │ │ └────────┬────────┘ │ │ │ │ │ ▼ │ │ 渲染/播放 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- ### 14.8 工程实践建议 #### Jitter Buffer 配置建议 | 场景 | 视频缓冲包数 | 音频缓冲包数 | 说明 | |-----|-------------|-------------|------| | 低延迟直播 | 20-30 | 10-15 | 牺牲平滑度换延迟 | | 普通视频会议 | 50-100 | 20-30 | 平衡延迟和质量 | | 弱网环境 | 100-200 | 30-50 | 优先保证质量 | #### 丢包处理建议 | 丢包率 | 建议方案 | |-------|---------| | < 2% | 仅 NACK | | 2% - 5% | NACK + 轻量 FEC | | > 5% | NACK + 强 FEC + 降低码率 | #### 关键认知总结 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ 核心认知 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ Jitter Buffer = 解决"包什么时候到"的问题(时间维度) │ │ NACK / FEC = 解决"包能不能到"的问题(可靠性维度) │ │ │ │ 二者缺一不可,必须配合使用! │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- ## 15. RTCP 反馈机制 ### 15.1 RTCP PLI #### PLI 是什么? 它是一种 **RTCP 反馈消息**,用于: **接收端告诉发送端:"我这边视频解码出问题了,请发一个关键帧(I帧)给我"** --- #### PLI 的使用场景 | 场景 | 说明 | |-----|------| | 丢包导致解码失败 | 关键帧丢失或 P/B 帧不完整 | | 新用户加入 | 需要从关键帧开始解码 | | 解码器重置 | 解码器状态丢失需要重新开始 | | 画面花屏/绿屏 | 参考帧损坏导致连锁错误 | --- #### PLI 包结构 PLI 是最简单的 RTCP 反馈消息,只有 12 字节(无额外负载): ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ RTCP PLI 包结构 (12 bytes) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 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| FMT=1 | PT=206 | length=2 | ← 头部 │ │ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ │ │ | SSRC of packet sender | ← 发送者 │ │ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ │ │ | SSRC of media source | ← 目标源 │ │ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ │ │ │ │ 总计: 12 字节 = 3 个 32-bit 字 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- #### 字段详解 ##### 第一个 32-bit 字(RTCP 通用头部) ``` 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| FMT=1 | PT=206 | length=2 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ └─┘ └┘ └─────┘ └───────┘ └───────────────┘ │ │ │ │ │ │ │ │ │ └─ length: 长度(32-bit 字数,不含头) │ │ │ └─ PT: Payload Type(PSFB = 206) │ │ └─ FMT: Feedback Message Type(PLI = 1) │ └─ P: Padding(填充标志) └─ V: Version(版本号 = 2) ``` | 字段 | 位数 | 值 | 说明 | |-----|------|-----|------| | **V** | 2 bits | `2` | RTP/RTCP 协议版本,固定为 2 | | **P** | 1 bit | `0` | 填充标志,PLI 通常为 0 | | **FMT** | 5 bits | `1` | 反馈消息类型,PLI = 1 | | **PT** | 8 bits | `206` | PSFB = 206 | | **length** | 16 bits | `2` | 包长度(不含头部 4 字节) | --- ##### 第二个 32-bit 字(发送者 SSRC) ``` +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of packet sender | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` | 字段 | 位数 | 说明 | |-----|------|------| | **SSRC of packet sender** | 32 bits | 发送这个 PLI 请求的 SSRC(通常是接收端的 SSRC) | **含义**:谁发送了这个 PLI 请求 --- ##### 第三个 32-bit 字(目标媒体源 SSRC) ``` +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of media source | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` | 字段 | 位数 | 说明 | |-----|------|------| | **SSRC of media source** | 32 bits | 请求发送关键帧的目标视频流的 SSRC | **含义**:向哪个视频流请求关键帧 --- #### PLI 工作流程 ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ PLI 工作流程 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ 发送端 (SSRC=1001) 接收端 (SSRC=2001) │ │ │ │ │ │ │─────── RTP 包 (I帧) ─────────────────────>│ │ │ │─────── RTP 包 (P帧) ─────────────────────>│ │ │ │─────── RTP 包 (P帧) ────────X │ ← 丢包! │ │ │─────── RTP 包 (P帧) ─────────────────────>│ │ │ │ │ │ │ │ │ 检测到解码失败 │ │ │ │ 需要新的关键帧 │ │ │ │ │ │ │<─────── RTCP PLI ────────────────────────│ │ │ │ sender=2001 │ │ │ │ media=1001 │ │ │ │ │ │ │ │ 收到 PLI,触发关键帧 │ │ │ │ │ │ │ │─────── RTP 包 (I帧) ─────────────────────>│ ← 新关键帧 │ │ │─────── RTP 包 (P帧) ─────────────────────>│ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- #### PLI vs FIR 对比 | 特性 | PLI | FIR | |-----|-----|-----| | **全称** | Picture Loss Indication | Full Intra Request | | **FMT 值** | 1 | 4 | | **语义** | "我这边解码出问题了" | "请立即发送完整 I 帧" | | **紧急程度** | 建议性 | 强制性 | | **使用场景** | 丢包恢复 | 新用户加入、录制开始 | | **发送端响应** | 可以延迟到下一个自然 I 帧 | 应立即生成 I 帧 | --- #### 工程实践建议 | 建议 | 说明 | |-----|------| | **限流发送** | 避免短时间内发送过多 PLI(建议间隔 > 100ms) | | **检测重复** | 收到多个 PLI 只响应一次关键帧 | | **优先级** | PLI 应比普通 RTCP 包优先发送 | | **结合 NACK** | PLI 用于严重丢包,NACK 用于轻微丢包 | | **监控统计** | 统计 PLI 频率可以反映网络质量 | ### 15.2 RTCP NACK --- ## 16. RTP 协议与 H.264 NALU 详解 ### 16.1 RTP 协议概述 RTP 是一种网络协议,用于在 IP 网络上传输音频和视频数据。它通常运行在 UDP 之上,提供时间戳、序列号等机制来支持实时媒体传输。 #### RTP 的特点 | 特点 | 说明 | |------|------| | 实时性 | 为实时应用设计,优先保证低延迟 | | 无连接 | 基于 UDP,不保证可靠传输 | | 序列号 | 用于检测丢包和重排序 | | 时间戳 | 用于同步和播放控制 | | SSRC | 同步源标识符,区分不同的媒体流 | #### RTP 与 RTCP 的关系 - RTP:传输实际的媒体数据(音视频) - RTCP:传输控制信息(如 SR/RR 报告、PLI 请求关键帧、NACK 请求重传等) --- ### 16.2 RTP 包结构 RTP 包由固定的 12 字节头部和可变长度的负载 (Payload) 组成。 #### RTP 固定头部结构(12 字节) ``` 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 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` #### 固定头部字段说明 | 字段 | 位数 | 说明 | |------|------|------| | V (Version) | 2 | RTP 版本号,固定为 2 | | P (Padding) | 1 | 填充标志,1 表示包末尾有填充字节 | | X (Extension) | 1 | 扩展标志,1 表示固定头后有扩展头 | | CC (CSRC Count) | 4 | CSRC 计数,表示后面有多少个 CSRC 标识符(0-15) | | M (Marker) | 1 | 标记位,对于视频通常表示一帧的最后一个包 | | PT (Payload Type) | 7 | 负载类型,标识编码格式(如 96-127 为动态类型) | | Sequence Number | 16 | 序列号,每发一个包加 1,用于检测丢包 | | Timestamp | 32 | 时间戳,采样时刻,用于同步 | | SSRC | 32 | 同步源标识符,随机生成,唯一标识一个媒体流 | #### CSRC 列表结构(可选) 当 CC 大于 0 时,紧跟在固定头部后面有 CC 个 CSRC 标识符,每个 4 字节: ``` 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 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | CSRC Identifier 1 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | CSRC Identifier 2 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | ... | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | CSRC Identifier N | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` CSRC 的用途: - CSRC = Contributing Source(贡献源) - 用于混音器 (Mixer) 场景,记录混音中包含的原始媒体源 - 在 WebRTC 中,通常 CC = 0(很少使用混音功能) #### RTP 扩展头部结构(可选) 当 X = 1 时,紧跟在 CSRC 列表后面(如果有的话)有扩展头部,格式如下: ``` 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 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Extension Profile ID (16 bits) | Extension Length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Extension Data ... | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` 扩展头部的字段: | 字段 | 位数 | 说明 | |------|------|------| | Extension Profile ID | 16 | 扩展的定义 ID(如 0xBEDE 表示 RFC 5285 One-Byte Header) | | Extension Length | 16 | 扩展数据的长度(以 4 字节为单位) | | Extension Data | 变长 | 具体的扩展数据(内容取决于 Profile ID) | 常见的 Extension Profile ID: - 0xBEDE:RFC 5285 One-Byte Header Extension(WebRTC 最常用) - 0x1000:RFC 5285 Two-Byte Header Extension 典型的 One-Byte Header Extension 格式: ``` 0 1 2 3 4 5 6 7 +-+-+-+-+-+-+-+-+ | ID | len | (每个扩展元素 1 字节头 + 数据) +-+-+-+-+-+-+-+-+ | Extension | | Payload | +-+-+-+-+-+-+-+-+ ``` WebRTC 中常见的 RTP 扩展: - AbsSendTime:发送时间戳(带宽估计) - TimestampOffset:时间戳偏移 - TransportSequenceNumber:传输序列号(丢包检测) - AudioLevel:音频级别指示 - VideoOrientation:视频方向指示 #### 完整的 RTP 包结构(包含所有可选部分) ``` +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 固定头部 (12 字节) | | - V(2bit) P(1bit) X(1bit) CC(4bit) M(1bit) PT(7bit) | | - Sequence Number (16bit) | | - Timestamp (32bit) | | - SSRC (32bit) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | CSRC 列表 (可选,CC * 4 字节) [仅当 CC > 0 时存在] | | - CSRC 1, CSRC 2, ... CSRC N | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | RTP 扩展头部 (可选,变长) [仅当 X = 1 时存在] | | - Extension Profile ID (2 字节) | | - Extension Length (2 字节) | | - Extension Data (可变) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Payload (RTP 负载数据) | | - 对于 H.264:NALU payload | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Padding (可选,0-255 字节) [仅当 P = 1 时存在] | | - 最后 1 字节表示填充总数 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` #### RTP 包结构的大小计算 示例: - 最小 RTP 包:12 字节头 + payload(如 1 字节) = 13 字节 - WebRTC 典型包:12 字节头 + 8 字节扩展 + 1000 字节 H.264 payload = 1020 字节 - 包含 CSRC 的包:12 + 4*3 + 1000 = 1024 字节 #### Marker 位的作用 对于 H.264 视频: - M = 1:表示这是一个访问单元(通常是一帧)的最后一个 RTP 包 - M = 0:表示后续还有属于同一帧的 RTP 包 这对于 FU-A 分片模式特别重要,接收端通过 Marker 位判断分片是否结束。 --- ### 16.3 H.264 NALU 概述 NALU 是 H.264 视频编码的基本单位。H.264 将视频编码数据封装成一个个 NALU,便于网络传输。 #### H.264 编码层次结构 ``` H.264 编码结构: 序列 (Sequence) GOP (Group of Pictures,图像组) 帧 (Frame) 片 (Slice) 宏块 (Macroblock) 块 (Block) ``` #### NALU 的作用 - 将编码后的视频数据分割成独立的传输单元 - 每个 NALU 可以独立传输和处理 - 便于错误恢复和随机访问 --- ### 16.4 NALU 头结构 每个 NALU 以 1 个字节的头部开始: ``` +---------------+ |0|1|2|3|4|5|6|7| +-+-+-+-+-+-+-+-+ |F|NRI| Type | +---------------+ ``` #### 字段说明 | 字段 | 位数 | 说明 | |------|------|------| | F (Forbidden) | 1 | 禁止位,正常应为 0,为 1 表示数据可能损坏 | | NRI (nal_ref_idc) | 2 | 参考帧指示符,表示该 NALU 的重要程度 | | Type (nal_unit_type) | 5 | NALU 类型,取值 0-31 | #### NRI 字段详解 NRI 值表示该 NALU 是否用作参考: | NRI 值 | 含义 | 典型用途 | |--------|------|----------| | 00 | 非参考帧 | B 帧、某些 SEI | | 01 | 低优先级参考 | 普通 P 帧 | | 10 | 高优先级参考 | 重要 P 帧 | | 11 | 最高优先级 | IDR 帧、SPS、PPS | 重要提示:NRI = 0 的帧仍然需要显示,只是不用于预测其他帧。不能因为 NRI = 0 就丢弃该帧! --- ### 16.5 NALU 类型详解 NALU Type 字段占 5 位,取值范围 0-31: #### 常用 NALU 类型 | Type | 名称 | 说明 | 重要性 | |------|------|------|--------| | 1 | Slice | 非 IDR 图像的编码切片(P 帧或 B 帧) | 核心数据 | | 5 | IDR Slice | IDR 图像的编码切片(关键帧) | 最重要 | | 6 | SEI | 补充增强信息(色彩信息、HDR 等) | 可选 | | 7 | SPS | 序列参数集(分辨率、帧率等) | 必需 | | 8 | PPS | 图像参数集(量化参数等) | 必需 | | 9 | AUD | 访问单元分隔符(帧边界标记) | 可选 | #### RTP 封装专用类型 | Type | 名称 | 说明 | |------|------|------| | 24 | STAP-A | 单时间聚合包,多个小 NALU 打包在一起 | | 25 | STAP-B | 带 DON 的聚合包(较少使用) | | 26 | MTAP16 | 多时间聚合包 16 位偏移(较少使用) | | 27 | MTAP24 | 多时间聚合包 24 位偏移(较少使用) | | 28 | FU-A | 分片单元 A,将大 NALU 分成多个 RTP 包 | | 29 | FU-B | 带 DON 的分片单元(较少使用) | #### SVC/MVC 扩展类型 | Type | 名称 | 说明 | |------|------|------| | 14 | Prefix NAL Unit | SVC 前缀单元,包含层间依赖信息 | | 15 | Subset SPS | 子集序列参数集 | | 20 | Slice Extension | 扩展切片(用于 SVC/MVC) | 注意:Chrome 浏览器发送的 H.264 流可能包含 Type 14,通常可以安全跳过。 #### 各类型 NALU 的详细说明 SPS(序列参数集,Type 7) - 包含整个视频序列的全局参数 - 分辨率(宽度、高度) - 帧率 - 色彩空间 - Profile 和 Level - 必须在 IDR 帧之前发送 PPS(图像参数集,Type 8) - 包含单个或多个图像的参数 - 量化参数 - 熵编码模式 - 去块滤波参数 - 可以有多个 PPS,通过 ID 引用 IDR(即时解码刷新,Type 5) - 关键帧,可独立解码 - 清除之前所有的参考帧 - 后续帧只能参考 IDR 之后的帧 - 是随机访问点(Seek 到此处) Slice(切片,Type 1) - 非关键帧的数据(P 帧或 B 帧) - P 帧:前向预测,参考前面的帧 - B 帧:双向预测,参考前后的帧 - 必须依赖之前的帧才能解码 SEI(补充增强信息,Type 6) - 可选的附加信息 - 时间码、色彩空间、HDR 元数据等 - 解码时不需要,但播放器可能使用 AUD(访问单元分隔符,Type 9) - 标记帧的边界 - 可选,用于帧同步 - 某些容器格式需要 --- ### 16.6 RTP 封装 H.264 的三种模式 RFC 6184 定义了三种 RTP 封装 H.264 的模式: #### Single NAL Unit 模式 适用场景:NALU 大小小于 MTU(通常约 1400 字节) 结构: ``` 一个 RTP 包 = 一个完整的 NALU RTP Header (12 bytes) | NALU Header (1 byte) | NALU Data ``` 特点: - 最简单的模式 - 一对一映射 - 适用于小帧 #### STAP-A 模式(聚合包) 适用场景:多个小 NALU 合并传输(如 SPS + PPS) 结构: ``` 一个 RTP 包 = 多个 NALU 聚合 RTP Header | STAP-A Header | NALU1 Size | NALU1 Data | NALU2 Size | NALU2 Data | ... (1 byte) (2 bytes) (2 bytes) ``` STAP-A 头: - 固定 1 字节 - Type = 24 - F 和 NRI 从第一个 NALU 继承 NALU Size 字段: - 2 字节,大端序 - 表示后面 NALU 数据的长度 典型用途: - Chrome 通常将 SPS + PPS 放在一个 STAP-A 包中发送 - 减少小包的传输开销 #### FU-A 模式(分片包) 适用场景:NALU 大于 MTU,需要分片传输 结构: ``` 一个大 NALU = 多个 RTP 包 第一个包(起始分片): RTP Header | FU Indicator | FU Header (S=1, E=0) | NALU Data Part 1 中间包: RTP Header | FU Indicator | FU Header (S=0, E=0) | NALU Data Part N 最后一个包(结束分片): RTP Header | FU Indicator | FU Header (S=0, E=1) | NALU Data Part Last ``` FU Indicator(1 字节): ``` +---------------+ |F|NRI| Type | +---------------+ Type = 28 (FU-A) ``` - F 和 NRI 来自原始 NALU - Type 固定为 28 FU Header(1 字节): ``` +---------------+ |S|E|R| Type | +---------------+ ``` | 字段 | 说明 | |------|------| | S (Start) | 1 = 这是分片的第一个包 | | E (End) | 1 = 这是分片的最后一个包 | | R (Reserved) | 保留位,必须为 0 | | Type | 原始 NALU 的类型 | 重组过程: 1. 收到 S=1 的包,开始新的 NALU,重建 NALU 头 2. 收到中间包,按序列号顺序拼接数据 3. 收到 E=1 的包,完成 NALU 重组 4. 如果序列号不连续,丢弃整个 NALU(防止花屏) --- ### 16.7 H.264 帧间依赖关系 #### GOP 结构 GOP (Group of Pictures) 是从一个 IDR 帧到下一个 IDR 帧之间的所有帧: ``` 典型 GOP 结构: IDR - P - P - P - P - IDR - P - P - ... | | | | | | 完整 参考 参考 参考 参考 完整 IDR 前帧 前帧 前帧 ``` #### 帧类型与依赖 | 帧类型 | 依赖关系 | 丢失影响 | |--------|----------|----------| | I 帧(IDR) | 不依赖任何帧 | 后续所有帧无法解码 | | P 帧 | 依赖前面的帧 | 后续 P 帧无法解码 | | B 帧 | 依赖前后的帧 | 只影响自己 | #### 丢包影响示意 ``` 正常情况: IDR - P1 - P2 - P3 - IDR - P4 - P5 OK OK OK OK OK OK OK 丢失 P2: IDR - P1 - [P2丢失] - P3 - P4 - P5 OK OK X X X X 花屏开始 丢失 IDR: [IDR丢失] - P1 - P2 - P3 X X X X 整个 GOP 都无法解码 ``` --- ### 16.8 Annex B 格式 Annex B 是 H.264 的一种字节流格式,常用于存储和传输。 #### 起始码 Annex B 使用起始码来分隔 NALU: | 起始码 | 用途 | |--------|------| | 0x00 0x00 0x00 0x01 | 4 字节,用于 SPS、PPS、IDR 等重要 NALU | | 0x00 0x00 0x01 | 3 字节,用于普通切片 | #### 文件结构 ``` [起始码][SPS][起始码][PPS][起始码][IDR][起始码][P帧][起始码][P帧]... 具体字节示例: 00 00 00 01 67 ... (SPS) 00 00 00 01 68 ... (PPS) 00 00 00 01 65 ... (IDR) 00 00 01 41 ... (P 帧) 00 00 01 41 ... (P 帧) ``` #### RTP 到 Annex B 的转换 将 RTP 包转换为 H.264 文件时: 1. 解包 RTP:去除 RTP 头 2. 重组分片:将 FU-A 分片重组为完整 NALU 3. 解聚合:将 STAP-A 拆分为单独的 NALU 4. 添加起始码:每个 NALU 前添加起始码 5. 写入文件:按顺序写入 --- ### 16.9 常见问题与花屏原因 #### 花屏的常见原因 | 原因 | 说明 | 解决方案 | |------|------|----------| | 丢包 | 网络丢失 RTP 包,导致帧不完整 | 启用 NACK 重传 | | 关键帧丢失 | IDR 帧丢失,后续帧无法解码 | 发送 PLI 请求新关键帧 | | SPS/PPS 缺失 | 解码器没有参数集信息 | 确保在 IDR 前发送 SPS/PPS | | 序列号跳跃 | FU-A 分片重组时检测到丢包 | 丢弃不完整的帧 | | 乱序 | 包到达顺序与发送顺序不一致 | 使用 Jitter Buffer 重排序 | #### 防止花屏的措施 1. 检测丢包:通过序列号检测是否连续 2. 请求重传:发送 NACK 请求丢失的包 3. 请求关键帧:发送 PLI 请求新的 IDR 帧 4. 丢弃损坏帧:检测到不完整的帧时丢弃,等待下一个关键帧 5. FEC 冗余:发送冗余数据,接收端可恢复丢失的包 #### FU-A 丢包处理 当 FU-A 分片中间丢包时: 1. 检测到序列号不连续 2. 丢弃当前缓冲区中的所有分片 3. 等待下一个以 S=1 开始的新分片 4. 如果丢弃的是 IDR 帧,后续 P 帧都会花屏 5. 应该发送 PLI 请求新的关键帧 --- ## 17. Offer/Answer 批量协商解析 ### 17.1 核心结论 SDP 的"最终内容几乎一样",变化的是生成 SDP 的"时机 + 次数 + 状态稳定性",而不是 SDP 语法本身。 以下从"未优化方案 → 防抖批量优化方案"的逻辑,结合真实 SDP 变化规律,完整解析两者的差异与核心价值。 --- ### 17.2 未优化方案:每次 AddTrack 都立刻协商 #### 行为特征 - 每执行一次 AddTrack() 方法 - 立即触发 createOffer() 生成 SDP Offer - SDP 内容随 AddTrack 次数逐步膨胀 - 多次 Offer / Answer 信令在网络中传输 - PeerConnection 状态机频繁切换 #### SDP 的演化过程(关键示例) 假设 SFU 向某个 Peer 推送 3 路视频流,SDP 会呈现"逐步追加"的演化特征: **第 1 次 AddTrack** Offer #1 ``` m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:0 a=sendrecv a=ssrc:1111 cname:sfu ``` Answer #1 ``` m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:0 a=recvonly ``` **第 2 次 AddTrack** Offer #2 ``` m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:0 a=sendrecv a=ssrc:1111 cname:sfu m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:1 a=sendrecv a=ssrc:2222 cname:sfu ``` Answer #2 ``` m=video ... mid:0 recvonly m=video ... mid:1 recvonly ``` **第 3 次 AddTrack** Offer #3 ``` m=video ... mid:0 m=video ... mid:1 m=video ... mid:2 ``` 核心特征:SDP 是"逐步追加"的,每增加一路流就新增一段对应的 m 行及相关属性。 #### 未优化方案的核心问题 注:SDP 本身语法正确,WebRTC 协议逻辑无问题,核心问题出在协商行为上。 | 问题 | 说明 | |------|------| | 协商次数爆炸 | 每个 AddTrack 操作对应一次完整的 Offer/Answer 协商 | | 状态机抖动 | PeerConnection 频繁在 stable → have-local-offer → stable 之间切换 | | 信令风暴 | 大量 Offer/Answer 信令导致 WebSocket/HTTP 信令服务器压力激增 | | 浏览器卡顿 | setRemoteDescription 是重操作,频繁执行会占用大量浏览器主线程资源 | | 时间复杂度高 | 新用户加入时,需与所有现有用户重新协商,时间复杂度为 O(N²) | --- ### 17.3 防抖批量协商:一次生成最终形态 SDP #### 核心行为变化 - 短时间内多次执行 AddTrack 操作 - 不立即触发 createOffer,而是设置防抖窗口期(如 200ms 或一个事件循环 tick) - 窗口期结束后,一次性生成包含所有待添加流的完整 SDP - 仅执行一次 Offer/Answer 协商流程 #### 优化后 SDP 形态(同 3 路视频示例) **Offer(一次生成,完整包含 3 路流)** ``` m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:0 a=sendrecv a=ssrc:1111 cname:sfu m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:1 a=sendrecv a=ssrc:2222 cname:sfu m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:2 a=sendrecv a=ssrc:3333 cname:sfu ``` **Answer(一次返回,完整响应 3 路流)** ``` m=video ... mid:0 recvonly m=video ... mid:1 recvonly m=video ... mid:2 recvonly ``` #### 与未优化方案的本质区别 | 对比项 | 未优化方案 | 防抖批量方案 | |--------|-----------|-------------| | SDP 最终内容 | 一样 | 一样 | | SDP 生成次数 | 多次 | 一次 | | Offer/Answer 对数 | N 次(N 为 AddTrack 次数) | 1 次 | | 状态机切换 | 频繁 | 稳定 | | 信令压力 | 巨大 | 可控 | | ICE/DTLS 干扰 | 高 | 低 | 核心结论:优化的是"协商行为",而非 SDP 语义本身。 --- ## 18. RTP 与 RTCP 包结构深度解析 在 WebRTC 中,通常启用 rtcp-mux,RTP 与 RTCP 复用同一个 UDP 传输通道,因此不能依赖端口号进行区分,必须通过数据包头部字段来判断包类型。 ### 18.1 RTP 与 RTCP 包结构对比 | 特征 | RTP 包 | RTCP 包 | |------|--------|----------| | 最小合法长度 | 12 字节 | 8 字节以上 | | 版本号(V) | 2(第 1 字节高 2 位) | 2(第 1 字节高 2 位) | | 第二字节含义 | Payload Type(PT) | Packet Type(PT) | | PT 取值范围 | 0-127 | 192-223 | | 是否承载媒体 | 是 | 否(控制与反馈) | --- ### 18.2 RTP / RTCP 区分算法 在接收到 UDP 数据包后,区分 RTP 与 RTCP 的判断流程如下: **1)检查数据包长度** - 若长度小于 4 字节,直接判定为非法数据 - RTP 包最小长度为 12 字节 - RTCP 固定头为 4 字节,但合法 RTCP 包通常不少于 8 字节 **2)检查协议版本号** - 读取第 1 字节的高 2 位 - 若版本号不为 2,则该包不是 RTP 或 RTCP **3)检查第二个字节(PT)** - 若第二字节数值在 0-127 之间,判定为 RTP 包 - 若第二字节数值在 192-223 之间,判定为 RTCP 包 - 其他取值范围视为非 RTP/RTCP 数据(如 STUN / DTLS / TURN) **4)综合判断** - 长度合法 + 版本号为 2 + PT 位于对应范围,即可准确区分 RTP 与 RTCP --- ### 18.3 RTP 包头格式详解 RTP 包头最小 12 字节: ``` 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 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` | 字段 | 位数 | 说明 | |------|------|------| | V | 2 bits | 版本号,固定为 2 | | P | 1 bit | 填充标志,若为 1 表示包尾含填充字节 | | X | 1 bit | 扩展标志,若为 1 表示固定头后存在头部扩展 | | CC | 4 bits | CSRC 计数,表示随后包含的 CSRC 标识个数(0-15) | | M | 1 bit | 标志位,语义由负载类型决定(视频可用于关键帧边界) | | PT | 7 bits | Payload Type(0-127) | | Sequence Number | 16 bits | 用于丢包与乱序检测 | | Timestamp | 32 bits | 用于播放与同步 | | SSRC | 32 bits | 同步源标识 | 补充说明: - 若 CC 大于 0,则在固定头(12 字节)之后紧跟 CC 个 32-bit 的 CSRC 列表 - 若 X = 1,则在 CSRC 列表之后存在 RTP 头部扩展 --- ### 18.4 RTCP 公共头格式 所有 RTCP 包共享相同的公共头格式(4 字节): ``` 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| C/F | Packet Type | length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` | 字段 | 位数 | 说明 | |------|------|------| | V | 2 bits | 版本号,固定为 2 | | P | 1 bit | 填充标志,若为 1 表示包尾有填充字节 | | C/F | 5 bits | 计数/格式字段,含义取决于包类型 | | Packet Type | 8 bits | RTCP 包类型(200-207 常用范围) | | length | 16 bits | 以 32-bit word 为单位的包长度,不含公共头前 4 字节 | #### C/F 字段说明 C/F 字段的含义根据 RTCP 包类型不同而不同: | 包类型 | C/F 字段含义 | 示例 | |--------|--------------|------| | SR (200) | Report Count (RC) | RC=0 表示无接收报告块 | | RR (201) | Report Count (RC) | RC=1 表示有 1 个接收报告块 | | RTPFB (205) | FMT (Feedback Message Type) | FMT=1 表示 NACK | | PSFB (206) | FMT (Feedback Message Type) | FMT=1 表示 PLI,FMT=4 表示 FIR | --- ### 18.5 RTCP 复合包与非复合包 #### RTCP 复合包(Compound RTCP Packet) 根据 RFC 3550 规定,标准 RTCP 传输使用复合包模式,即在一个 UDP 数据报中串联多个 RTCP 包: ``` +-------------------------------------+ | CommonHeader | |-------------------------------------| <- RR 包 | ...... | +-------------------------------------+ | CommonHeader | |-------------------------------------| <- NACK 包 | ...... | +-------------------------------------+ 单个 UDP 数据报 ``` **复合包规则(RFC 3550 Section 6.1)** 1. 第一个包必须是 SR 或 RR 2. 第二个包必须是 SDES(至少包含 CNAME 项) 3. 可追加其他 RTCP 包(BYE、APP、XR、RTPFB、PSFB 等) **解析 RTCP 复合包的流程** ``` while (剩余数据 >= 4 字节) { 1. 读取 CommonHeader (4 字节) 2. 从 CommonHeader 获取 Packet Type 和 length 3. 计算当前包总长度 = (length + 1) * 4 4. 根据 Packet Type 分发到对应处理函数 5. 移动指针到下一个包 } ``` #### RTCP 非复合包(Reduced-Size RTCP,RFC 5506) 为降低延迟和减少带宽消耗,RFC 5506 允许发送独立的 RTCP 包而无需遵循复合包规则: - 无需强制携带 SR/RR + SDES - 包体更小,传输延迟更低 - 适用于实时反馈场景(NACK、PLI、FIR、REMB、Transport-CC) - 需要 SDP 协商:`a=rtcp-rsize` --- ### 18.6 SR(Sender Report,PT=200) ``` 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| RC | PT=200 | length | Header +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of sender | +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+ | NTP timestamp, most significant word | Sender +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Info | NTP timestamp, least significant word | Block +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ (24 bytes) | RTP timestamp | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | sender's packet count | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | sender's octet count | +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+ | SSRC_1 (SSRC of first source) | Report +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Block 1 | fraction lost | cumulative number of packets lost | (24 bytes +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ each) | extended highest sequence number received | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | interarrival jitter | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | last SR (LSR) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | delay since last SR (DLSR) | +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+ ``` | 字段 | 说明 | |------|------| | NTP Timestamp | 64 bits,发送 SR 时的绝对时间(用于音视频同步和 RTT 计算) | | RTP Timestamp | 32 bits,与 NTP 时间对应的 RTP 时间戳 | | Sender's Packet Count | 32 bits,自会话开始发送的 RTP 包总数 | | Sender's Octet Count | 32 bits,自会话开始发送的有效载荷字节总数 | | Report Block | 每个 24 字节,RC 字段指示块数量(0-31) | --- ### 18.7 RR(Receiver Report,PT=201) ``` 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| RC | PT=201 | length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of packet sender | +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+ | SSRC_1 (SSRC of first source) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | fraction lost | cumulative number of packets lost | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | extended highest sequence number received | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | interarrival jitter | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | last SR (LSR) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | delay since last SR (DLSR) | +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+ ``` | 字段 | 位数 | 说明 | |------|------|------| | Fraction Lost | 8 bits | 自上次 RR/SR 以来的丢包率(0-255 映射 0%-100%) | | Cumulative Lost | 24 bits | 累计丢包数(有符号,可为负表示重复包) | | Extended Highest Seq | 32 bits | 高 16 位为周期数,低 16 位为最大序列号 | | Jitter | 32 bits | 到达时间间隔抖动(RTP 时间戳单位) | | LSR | 32 bits | 最近收到的 SR 的 NTP 时间戳中间 32 位 | | DLSR | 32 bits | 从收到 LSR 到发送此 RR 的延迟(1/65536 秒单位) | **RTT 计算公式** ``` RTT = 当前时间 - LSR - DLSR ``` --- ### 18.8 NACK(Generic NACK,PT=205,FMT=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| FMT=1 | PT=205 | length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of packet sender | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of media source | +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+ | PID | BLP | FCI Block +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` | 字段 | 位数 | 说明 | |------|------|------| | FMT | 5 bits | 反馈消息类型,NACK = 1 | | SSRC of packet sender | 32 bits | 发送此 NACK 包的 SSRC | | SSRC of media source | 32 bits | 被请求重传的媒体源 SSRC | | PID | 16 bits | 丢失的 RTP 包序列号(Packet ID) | | BLP | 16 bits | 位图,表示 PID+1 到 PID+16 的丢失情况 | **BLP 位图解释** ``` BLP = 0b1000000000000101 | || PID+16 PID+2, PID+1 表示丢失的包:PID, PID+1, PID+2, PID+16 ``` 一个 FCI 块最多表示 17 个连续/近连续的丢包。 --- ### 18.9 PLI(Picture Loss Indication,PT=206,FMT=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| FMT=1 | PT=206 | length=2 | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of packet sender | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of media source | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` | 字段 | 说明 | |------|------| | FMT=1 | 表示 PLI 类型 | | length=2 | PLI 固定长度为 2 个 32-bit words(不含首 4 字节) | | SSRC of packet sender | 发送 PLI 请求的端点 SSRC | | SSRC of media source | 被请求发送关键帧的视频源 SSRC | PLI 包总大小:12 字节 --- ### 18.10 FIR(Full Intra Request,PT=206,FMT=4) ``` 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| FMT=4 | PT=206 | length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of packet sender | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC of media source (unused) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | SSRC | FCI +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Seq nr | Reserved | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ``` | 字段 | 说明 | |------|------| | FMT=4 | 表示 FIR 类型 | | Seq nr | 8 bits,命令序列号,用于去重 | **PLI vs FIR** - PLI:通知解码端丢失了关键帧,请求发送端按需处理 - FIR:强制要求发送端立即发送完整 I 帧(用于新加入观众等场景) --- ### 18.11 SDES(Source Description,PT=202) SDES 是一种 RTCP 包类型,用于传输与 SSRC 相关的描述性信息。它不用于反馈网络状态,也不控制媒体,而是用于标识和管理参与者身份。 **SDES 的核心作用** - 将 SSRC 与逻辑身份(如 CNAME)绑定 - 用于 SSRC 冲突检测与恢复 - 支持多路 RTP 流的正确关联 - 为调试、日志、统计提供稳定标识 **CNAME 的特点** - 在一个 RTP 会话生命周期内必须保持稳定 - 不随 SSRC 变化而变化 - 常见形式:user@host、随机字符串(WebRTC 常见)、基于设备或用户的唯一标识 **为什么需要 CNAME** - 当 SSRC 发生冲突或重建时,接收端可通过 CNAME 识别是否是同一个发送者 - 在音视频多流场景中,用于将多个 SSRC 归属到同一端点 --- ### 18.12 RTCP 包类型速查表 | PT 值 | 类型 | FMT | 说明 | 大小 | |-------|------|-----|------|------| | 200 | SR | - | Sender Report | 28 + 24*RC 字节 | | 201 | RR | - | Receiver Report | 8 + 24*RC 字节 | | 202 | SDES | - | Source Description | 可变 | | 203 | BYE | - | Goodbye | 可变 | | 204 | APP | - | Application-defined | 可变 | | 205 | RTPFB | 1 | Generic NACK | 12 + 4*N 字节 | | 205 | RTPFB | 15 | Transport-CC | 可变 | | 206 | PSFB | 1 | PLI | 12 字节 | | 206 | PSFB | 4 | FIR | 20 字节 | | 206 | PSFB | 15 | AFB (REMB) | 可变 | | 207 | XR | - | Extended Report | 可变 | --- ### 18.13 各类型 RTCP 包处理说明 #### SR(Sender Report,PT=200) **用途**:发送端周期性上报发送状态,用于时间同步和 RTT 计算。 **处理逻辑** - 解析 SR 包 - 保存远端 NTP 与 RTP 时间戳 - 记录本地接收 SR 的时间 - 后续结合 RR 的 LSR / DLSR 计算 RTT #### RR(Receiver Report,PT=201) **用途**:接收端反馈媒体接收质量。 **作用**:为拥塞控制与码率调节提供依据。 #### NACK(RTPFB,PT=205) **用途**:请求发送端重传丢失的 RTP 包。 **处理流程** 1. 接收端检测到 RTP 序列号不连续 2. 生成丢失序列号列表 3. 通过 RTCP Feedback 发送给对端 4. 发送端选择性重传指定 RTP 包 #### PLI(PSFB,PT=206) **用途**:请求发送端立即发送关键帧(I 帧)。 **触发场景** - 解码失败 - 参考帧丢失 - 严重丢包导致画面不可恢复 #### XR(Extended Report,PT=207) **用途**:提供扩展统计信息,补充 SR / RR 能力不足的问题。 **常见扩展块** - RRTR(Receiver Reference Time) - DLRR(Delay since Last Receiver Report) - VoIP Metrics --- ### 18.14 QoS 调优方向 - RTT 计算与实时监控 - 丢包率与重传策略优化 - 抖动缓冲区动态调整 - 带宽估计(BWE) - 编码码率自适应控制 --- ### 18.15 总结 在 WebRTC 场景中,区分 RTP 与 RTCP 的唯一可靠方式是解析数据包头部字段: - 版本号必须为 2 - 第二字节范围决定类型 - 0-127 表示 RTP - 192-223 表示 RTCP 端口号、负载内容、包长度都不能作为唯一依据。