如果你一直在追这个系列,读到这一篇说明前面几篇的采集、编码、会话协商已经大致心里有数了。今天这篇我们正式进入 webrtc 核心引擎层里最贴近“网线”的部分——传输模块。这个模块负责的事可以用一句话概括:把编码器产出的 RTP 包,安全、有序、尽可能不丢地送到对端的解码器手里。它是整个 WebRTC 体系里最复杂、也最值得啃的一块,因为能不能做到低延迟、低卡顿、弱网可用,答案全藏在这一层的源码里。
我当前是基于 M114 左右分支做的走读,WebRTC 代码演进极快,部分类名和目录在版本间会有迁移,后面凡是涉及具体文件我都会同时给类名和路径,方便你在其他版本里快速找到对应实现。如果你还没下载源码,建议先通过官方 depot_tools 拉一个固定分支,不要在最新主干上边看边追,否则今天写的类明天可能就被重构了。这篇文章的主线只有一条:跟着一个 RTP 包走一遍发送和接收路径,把传输模块的家底翻个遍。
1. 传输模块在核心引擎层中的边界:从它开始,数据离开进程
很多第一次读 WebRTC 源码的人都会有个困惑:传输模块到底从哪里算起,到哪里结束?这个边界如果不先划清楚,后面翻代码会非常痛苦。我的理解是:传输模块的下游是操作系统 socket,上游是 RTP 层的数据进出接口。换句话说,凡是"把已经封装好的 RTP 包交给网络"和"从网络收到字节流后还原成 RTP 包"之间的所有逻辑,都属于传输模块的管辖范围。
1.1 传输模块的"户口本":核心类与文件分布
为了让你在源码里不迷路,我先给你一份传输模块的核心类清单,这也是我每次走读的索引:
| 类名 | 所在文件 | 核心职责 |
|---|---|---|
cricket::P2PTransportChannel |
p2p/base/p2p_transport_channel.cc |
ICE 状态机管理、连接选择、连通性检查、候选者配对 |
cricket::DtlsTransport |
p2p/base/dtls_transport.cc |
DTLS 握手、SRTP 密钥派生、加密数据收发 |
webrtc::SrtpTransport |
pc/srtp_transport.cc |
RTP/RTCP 的加解密、鉴权、序号维护 |
webrtc::RtpTransport |
pc/rtp_transport.h |
收发双向的统一入口,管理 send/receive 方向 |
RtpSenderEgress |
modules/rtp_rtcp/source/rtp_sender_egress.cc |
RTP 包组装、序号分配、发送前记录 |
TaskQueuePacedSender |
modules/pacing/task_queue_paced_sender.cc |
平滑发送,按码率预算控制发包节奏 |
RtpStreamReceiverController |
call/rtp_stream_receiver_controller.cc |
接收方向按 SSRC/mid 分发 RTP 包 |
RtpDemuxer |
call/rtp_demuxer.cc |
根据 SSRC、RSID、MID 做最终路由判定 |
这张表里的类并不是孤立存在的,而是严格分层嵌套的。你从下往上看,就是一条完整的网络栈:socket 数据先到 P2PTransportChannel,它负责从多条 ICE 连接里选一条;选完就把数据交给 DtlsTransport 解密(如果开了加密);解密后的明文 RTP 交给 SrtpTransport 做 SRTP 处理;再往上才是 RTP 层的分发和应用层回调。
1.2 为什么 WebRTC 要把传输拆成这么多层
这个分层一开始会让人觉得冗余,但你把它想成一次物流发货就通了:P2PTransportChannel 是物流公司的路线调度员,负责在地图上找一条能走的道(NAT 穿越);DtlsTransport 是保险箱厂家,负责给你提供一个只有收发双方能打开的箱子(加密信道);SrtpTransport 是打包员,负责给每个零件贴上防拆标签(SRTP 鉴权);RtpTransport 则是仓库门口的管理员,记录哪些货进来了、哪些货出去了。
每层只解决一个问题,这带来一个巨大的好处:组合灵活。比如 WebRTC 也可以跑在没有加密的信道上(本地测试时可用 cricket::BaseChannel 绕过 DTLS),或者跑在纯 P2P 没有 ICE 的中继模式下。分层清晰之后,你就能单独替换某一段实现而不影响其他层。
边界问题还要多说一句:jitter buffer(抖动缓冲)严格来说不属于传输模块,它属于 modules/video_coding,但它的输入来源和丢包反馈都跟传输模块强相关。所以第 5 章讲弱网时,我会把 jitter buffer 的等待逻辑和传输模块的重传逻辑放在一起讲,因为实际排障时这两块是分不开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一条 RTP 包从编码器到网卡:发送路径源码走读
很多文章介绍传输模块时喜欢按类逐个讲,我试过这种读法,效果很差,因为你不清楚这些类到底怎么协作。我建议反过来:先建立一条完整调用链,再回头看每个类里的细节。下面就是我梳理出的发送路径主线。
2.1 第一站:RtpSenderEgress 与 PacedSender 的配合
编码器出来的视频帧经过 RTP 打包器(RtpPacketizer)切成一个个 RTP 包后,并不会直接丢给网卡,而是先进入 RtpSenderEgress。这里有个很重要的设计:RtpSenderEgress 负责给每个 RTP 包分配序号(sequence number),并记录发送信息(比如发送时间、当前传输序号),这些记录是后续丢包重传、RTT 计算的依据。
分配完序号之后,包不会立刻发出去,而是交给 PacedSender。很多初学者第一次看到 pacer 都会觉得多余:既然编码器都控制码率了,为什么还要多一次平滑?
原因很简单:编码器控制的是"平均码率",但它产出的数据是突发的。一个关键帧可能瞬间产生几百 KB 数据,如果把这些数据一口气塞进网络,很容易打爆路由器缓存,造成 bufferbloat 和丢包。WebRTC 的做法是为 pacer 设置了"预算"机制,核心代码在 TaskQueuePacedSender::MaybeSendPacket 和 PacingController 里:
cpp复制// modules/pacing/pacing_controller.cc
void PacingController::ProcessPackets() {
int64_t now_us = clock_->TimeInMicroseconds();
int64_t elapsed_us = now_us - last_process_time_us_;
// 根据当前目标码率计算本轮可以发送多少字节
DataRate pacing_rate = pacing_bitrate_;
DataSize budget = pacing_rate * TimeDelta::Micros(elapsed_us);
...
}
这个预算公式是整条平滑发送逻辑的核心:每个处理周期能发送的字节数 = 目标码率 × 距上次处理的时间。它保证发送速率始终被压在目标码率附近,不会出现一瞬间疯狂发包的情况。pacing_bitrate_ 主要来自拥塞控制器(BitrateController / GoogCcNetworkController)的反馈估算,这部分我在第 5 章还会提到。
2.2 第二站:SrtpTransport 与 DtlsTransport 的加密封装
从 pacer 里出来的明文 RTP 包,接下来要经过两道安全处理。
第一道是 SrtpTransport 的 SRTP 加密。这里要特别注意:SRTP 不是简单地把 RTP 负载加密,而是包含了完整性校验、防重放(rollover counter 和 sequence number 校验)、密钥派生等多重操作。加解密接口在 srtp_transport.cc 里通过 libsrtp 库实现,核心函数是 ProtectRtp 和 UnprotectRtp。
第二道是 DtlsTransport。DTLS 在 WebRTC 里承担两个职责:一是完成双方的身份认证和密钥协商,二是存储协商出来的 SRTP 密钥。这就回答了一个很多人的疑问:为什么要先建 ICE 连接,再做 DTLS 握手,最后才允许发 RTP?因为 RTP 的加密密钥是通过 DTLS 握手中的 ExportKeyingMaterial 派生出来的,没有握手成功就没有密钥,没有密钥就无法发送加密媒体。
DTLS 握手的源码入口在 dtls_transport.cc 的 DtlsTransport::SetLocalCertificate 和 StartSsl。整个握手过程对上层基本透明,你只需要知道:DtlsTransport 内部维护了一个 SSL* 上下文,所有进出的加密数据都通过这个上下文处理。
2.3 第三站:P2PTransportChannel 选出最终连接并发包
加密后的数据包继续往下走,抵达 P2PTransportChannel。这个通道本身不发数据,它只做一件事:决定把数据交给哪一条 Connection。每一条 Connection 代表一对网络五元组(本端 IP:端口 → 对端 IP:端口),P2PTransportChannel 维护着一个候选连接表,SendPacket 方法会从表里选出当前优先级最高的可用连接进行发送。
cpp复制// p2p/base/p2p_transport_channel.cc
int P2PTransportChannel::SendPacket(const char* data, size_t len,
const rtc::PacketOptions& options,
int flags) {
...
Connection* connection = GetBestConnection();
if (connection == nullptr) {
// 选不出可用连接,直接丢弃并返回错误
RTC_LOG(LS_WARNING) << "No best connection";
return -1;
}
int sent = connection->Send(data, len, options);
...
}
GetBestConnection 的选路逻辑是传输模块里最容易出问题的地方之一。它不是简单选网络速度最快的,而是综合了候选者优先级、连接状态、是否 nominated、历史丢包率等因素。这个机制我们留到第 4 章专门讲。
到这里,一个 RTP 包就走完了从编码器到网卡的全过程。我把这条链路再压缩一下,方便你对照源码理解:
code复制编码器 → RtpPacketizer → RtpSenderEgress(分配序号)→ TaskQueuePacedSender(平滑限速)
→ RtpTransport::SendPacket → SrtpTransport(SRTP加密)→ DtlsTransport(DTLS封装)
→ P2PTransportChannel::SendPacket(选路)→ 具体 Connection → socket → 网卡
3. 接收路径:从网卡到解码器,传输模块的收尾工作
接收路径和发送路径不是简单反向,因为接收侧多了一个最复杂的活:分发。一个 WebRTC 连接里往往同时有多条媒体流(视频、音频、可能还有 data channel),这些流量复用在同一个 socket 上,怎么区分?怎么确保每个包都进到对应的接收器?这就是接收侧传输模块的核心价值。
3.1 第一道过滤:SrtpTransport 解包与 RtpDemuxer 路由
数据包从网卡进入后,走的是一条与发送相反但更复杂的路。由于加密,内核网卡收到的是一堆密文,谁都不知道里面是 RTP 还是 RTCP。因此第一步是让 DtlsTransport 做解密,拿到明文后交给 SrtpTransport::UnprotectRtp。
在 SrtpTransport::OnRtpPacket 里完成解保护和鉴权后,包会被交给接收侧的统一入口 RtpStreamReceiverController::OnRtpPacket。这里做的是多路分发:它有多个 RtpDemuxer,每个 demuxer 注册了不同的 SSRC、MID 或 RSID 映射规则,通过 RtpDemuxer::OnRtpPacket 遍历所有匹配规则,找到目标接收流。
这个路由过程很像小区里的快递柜:每个快递(RTP 包)到了之后,系统先根据收件人手机号后四位(SSRC)找柜子,找不到再根据地址(MID)匹配,总有一条规则能命中。没有命中的包会被直接丢弃,并且记一条 RTC_LOG(LS_WARNING),很多诡异收不到画面的 bug 最后都查到这里。
3.2 接收侧的第二站:VideoRtpReceiver 与 payload 组装
分发到具体接收器后,VideoRtpReceiver 接管数据。它负责把收到的一个个 RTP 包还原成视频帧。注意,这里说的"还原"并不是说 jitter buffer 就在这个类里面,真正的帧等待重新排序发生在 FrameBuffer 中,但 VideoRtpReceiver::OnReceivedPayloadData 是它们之间的交界点。
交界逻辑值得细看。OnReceivedPayloadData 拿到的是一个完整的 RTP payload(也就是一个 video packet),它要判断这个 packet 属于哪一帧、是否是关键帧的起始包、是否缺失了其他分片。这些判断最终都是为了回答一个问题:能不能把当前收到的这几个 packet 拼成一帧交给解码器,还是继续等后续包。
3.3 jitter buffer 的等待与投递:传输模块隔壁的"强制排队"
jitter buffer 不属于传输模块,但它履行的是传输模块上游的"最后一公里"职责。数据包到达时间受网络抖动影响,可能早到也可能晚到。FrameBuffer 会维护一个按帧序号排序的队列,只有从关键帧开始的完整帧才允许被投递给解码器。
这里有个和传输模块强相关的设计:jitter buffer 等待的时间上限(VideoReceiveStream::SetJitterBufferMinimumDelay),以及它向发送端反馈丢包信息的机制。当 FrameBuffer 发现后续帧已经到达但前序帧的某个分片始终缺失时,它会向 NACK 模块发起重传请求,请求会通过 RTCP 反馈给发送端。所以整个弱网防守环是闭环的:jitter buffer 发现问题 → NACK 请求 → 发送端重传 → jitter buffer 继续等待。第 5 章会展开讲。
接收路径完整链路由此可见:
code复制socket → P2PTransportChannel(选路)→ DtlsTransport(解密)
→ SrtpTransport(SRTP解保护/鉴权)→ RtpStreamReceiverController(分发)
→ RtpDemuxer(按SSRC/MID路由)→ VideoRtpReceiver → FrameBuffer(排序等待完整帧)
→ 解码器
4. ICE 连接管理:传输模块里最绕的一段源码
如果你问我 WebRTC 传输模块里哪个部分最值得花时间,我的答案一定是 P2PTransportChannel 和它背后的 ICE 控制器。这一块代码抽象层次高、状态多、并发回调多,第一次读很容易晕。我建议按三个层次依次攻破:先看候选者怎么来的,再看候选者怎么配对,最后看连接怎么被选中。
4.1 候选者收集:PortAllocator 与 Port
ICE 的第一步是收集本端所有可能的通信路径,也就是候选者。WebRTC 定义了三种类型:host(本机网卡地址)、srflx(NAT 映射后的地址)、relay(经过 TURN 服务器的地址)。对应源码里的 Port 基类,以及 UDPPort、StunPort、TurnPort 这些具体实现。
PortAllocator 是收集过程的调度者,它负责创建并管理这些 port。你可以把 Port 理解成一台电话分机,每个分机有自己独立的外部号码(候选者地址),也有独立的状态(是否可用、是否受限制)。候选者最终会通过 SignalCandidateReady 回调上报给上层,上层再通过信令发给对端。
这里要特别留意地址的优先级公式,ICE 协议里所有配对决策都建立在优先级之上。WebRTC 源码里按 RFC 8445 推荐的公式计算:
cpp复制// p2p/base/ice_credentials_iterator.cc / port.cc 中的计算逻辑
// priority = (2^24) * type_preference + (2^8) * local_preference + (2^0) * component_id
其中 type_preference 是 host > srflx > relay 的取值,这意味着在绝大多数情况下,直连优先于中继。源码里可以在 Port::GetPriority 附近打断点,观察不同网络环境下候选者优先级的实际值。
4.2 配对与排序:Connection 与 IceController
双方候选者交换完成后,传输模块要做的是把本端候选者和对端候选者做笛卡尔积,生成候选者配对(candidate pair)。每对就是一个 Connection 对象,存放在 P2PTransportChannel 里。到这里还只是"有机会通信",到底走哪条通道得靠连通性检查和排序。
排序逻辑在 BasicIceController::SortAndSwitchConnection 里。这里采取的是一种动态切换策略:不是永远用第一个连通成功的连接,而是每隔一段时间评估所有连接的状态,如果发现一条比当前连接更优的连接(优先级更高、RTT 更低、或者当前连接持续丢包),就做切换。
实际翻代码时有个判断容易绕晕:已经 nominated 的连接和可切换的新连接之间怎么取舍?答案在 IceControllerInterface::PingResult 和 UseCandidate 的交互逻辑里。简单说,一旦某条连接被选定为 nominated,它就会进入"稳定使用"状态,除非它失联,否则系统不会轻易切换到其他连接,这是为了减少因频繁切换导致的延迟抖动。
4.3 连通性检查和提名机制:一切以 STUN 实测为准
生成候选者配对不等于连接成功,必须经过连通性检查。P2PTransportChannel 会周期性地通过 Connection::Ping 往对端发 STUN binding 请求,收到 binding 成功响应后,这条连接的状态才会置为可写。这就是 STUN 的"以实测为准"思想:不管理论上优先级多高,网络不通就是不通。
提名机制是 ICE 里另一个容易看晕的点。WebRTC 的 ICE 实现默认采用 aggressive nomination,源码里能看到 Connection::set_state 和 nominate 的调用关系。简化的流程是:
- 本端通过连通性检查发现一条可用连接;
- 控制器决定将这条连接提名给对端;
- 对端认可后,双方同时在这条连接上启用媒体发送;
- 后续 ICE 重启或连接降级时,再回到第 1 步重新评估。
我在走读时踩过的一个坑是:候选者很多时,Connection 对象也会很多,每个连接都有独立的写状态和 ping 状态,这些状态更新是通过回调从网络线程抛到信令线程的。跟踪时如果忽略了线程切换,你会看到状态"莫名其妙"地变化。建议看代码时多留意 RTC_DCHECK_RUN_ON(network_thread()) 和 InvokeAsync 这类线程标记,它们能帮你理清状态更新的上下文。
5. 弱网卡顿的根源与传输模块的防守体系
网上搜"WeBRTC 弱网卡顿怎么优化"能搜出一堆参数指南,但大多数答案没讲清楚这些参数到底作用在传输模块的哪个环节。这篇我从源码层面把防守体系拆开讲,主线是:网络为什么会卡顿,主动权在接收端还是发送端。
5.1 丢包怎么被发现:接收端的 NACK 请求链
弱网最直接的表现是丢包。接收端怎么知道"这个包丢了"?答案在 RTCP 反馈和 jitter buffer 的等待逻辑里。
NackRequester(源码在 modules/video_coding/nack_requester.cc)负责维护一个已接收包序号的列表。每个新到的 RTP 包到来时,它会把包序号写入表里,同时检查是否存在"空洞"——也就是前序号缺失。一旦发现缺失,并且距离失真包的出现已经过了 kProcessIntervalMs 或重传次数没超上限,就生成一个 NACK 请求回传给发送端。
NACK 请求本身是打包在 RTCP 里的,发送路径走的是 RTCP 模块而非 RTP 模块。这里有典型参数控制:kNackHistorySize 决定发送端保存多少条历史包信息,max_nack_reordering_threshold 决定多大乱序算丢包。乱序不算丢包,但如果把乱序阈设太小,网络轻微乱序就会触发大量 NACK,反而增加拥塞。
5.2 RTX 重传与按需补包
发送端收到 NACK 后,会从 RtpSenderEgress 保留的发送历史里找到对应的 RTP 包,重新发送一次。这个重传不是简单地原样再发一遍,而是用 RTX 机制:将原始包封装进 RTX payload type,使用独立的 SSRC 发送,并在 OOB(original sequence number)字段里注明原始包的序号。
这里关键的设计是 SSRC 隔离。原始视频流和 RTX 重传流使用不同的 SSRC,接收端解包时才能明确知道"这个包是重传包,可以去掉 RTX 头还原成原始负载"。在 RtpSenderEgress::Process* 里加断点,能看到 RTX 重传包在发送历史中的查找过程。
RTX 看似多此一举,其实价值很大:接收端可以对重传包的到达时间做统计,如果大量包都是通过 RTX 补回来的,说明当前网络质量很差,这个信息会反馈给拥塞控制模块,让发送端降低码率。这也解释了为什么单纯调大重传次数不能解决问题——重传只是兜底,真正的药方是降码率。
5.3 FEC 与冗余的取舍
除了重传,WebRTC 还支持前向纠错(FEC),其中 RED+ULPFEC 用于视频,FlexFEC 是更灵活的变体。FEC 的源码在 modules/rtp_rtcp/source/ulpfec_generator.cc,它的核心思路是:用一组原始包算出冗余包,即使丢了其中一个原始包,也能通过冗余信息和收到的其他包恢复出来。
FEC 和 NACK 不是替代关系,而是互补。FEC 适合恢复突发性的连续丢包(恢复速度快,不用等一个 RTT),NACK 适合恢复稀疏丢包(冗余开销小)。实际传输模块会根据当前丢包率动态调节 FEC 冗余度,这个策略实现在 FecControllerDefault::UpdateFecRates 里。
但 FEC 绝不是越多越好。冗余包挤占带宽会导致编码码率下降,最终画面质量受损。还会引入额外的解码延迟。这也是为什么 FlexFEC 要同时考虑延迟容忍度——它更适合延迟不敏感的业务,实时通话场景要克制使用。
5.4 拥塞控制对传输模块的最终约束
防守体系的最后一个环节是拥塞控制,它决定上述所有机制的上限。WebRTC 默认的 GoogCcNetworkController 会综合丢包率、延迟梯度(delay gradient,源于 RemoteBitrateEstimatorAbsSendTime)和 Ack 反馈,产出一个目标码率。这个码率会影响两件事:一是编码器的目标码率,二是 pacer 的发送预算。
也就是说,传输模块里 pacer 的 pacing_bitrate_ 不是静态配置,而是随时跟着网络反馈走。弱网卡顿时,你可以观察 PacingController::SetPacingRates 的调用频率和数值变化,如果发现码率被压得很低,说明拥塞控制正在起效,此时再去看编码器和 pacer 是否遵守这个约束才有意义。
6. 给后来者的一份传输模块源码走读地图
最后这部分算是我的私货,也是我翻完这么多代码后最想分享的经验。传输模块源码量巨大,如果不掌握方法,很容易在 p2p/、modules/rtp_rtcp/、call/ 几个目录之间迷路。我自己摸索出三个行之有效的办法,供你参考。
6.1 从日志反查代码路径,而不是从入口盲翻
WebRTC 的日志系统非常完善,建议先开日志跑一次通话,观察实际打出来的日志走向。比如用 RTC_LOG(LS_INFO) 标注的 "Sending packet" "PacedSend" "Connection selected" 这些关键字,反查对应的源码位置。这种"日志→代码→调用链条"的顺序,比从抽象类往下盲翻要高效得多。
如果日志太多,可以用 RTC_LOG(LS_SENSITIVE) 级别做细粒度过滤。跑一段弱网模拟(比如用 Network Emulation 工具),观察 NACK 请求频率和 pacer 码率变化,再回源码里找对应开关和阈值,很快就能定位到瓶颈是发生在重传、FEC 还是拥塞控制。
6.2 四个值得打断点的关键位置
我读传输模块时踩了很多坑,有些逻辑绕了半天才明白。如果你时间有限,我建议优先在这四个位置打断点,它们几乎可以解释 80% 的传输问题:
P2PTransportChannel::GetBestConnection:看当前到底选了哪条 ICE 连接,以及切换时机;TaskQueuePacedSender::MaybeSendPacket:看 pacer 当前的实际发送预算和节奏;RtpSenderEgress::SendPacket:看 RTP 包在进入网络前最后一次被修改的状态;RtpDemuxer::OnRtpPacket:看接收端 RTP 包的路由和丢弃情况。
打这些断点的时候尽量用条件断点,比如只在 media SSRC 匹配的视频流上停,否则几秒钟就会因为音频包、RTCP 包、STUN 包刷屏。
6.3 版本漂移是源码走读最大的坑
WebRTC 的代码不适合"读一次管三年"。我前面提过 M114 分支,但不同版本的差异很可能不是微调而是整层重构。典型例子是 PacedSender 从老的 PacedSender 重构为 TaskQueuePacedSender,以及 RtpTransport 从旧的信道模型中独立出来。如果你在旧版本的笔记里看到某个类名,新版本里找不到,不要慌,先在 DEPS 和模块目录下搜一下相关功能的关键字,往往能看到对应替代类。
我自己的习惯是每篇文章都记录下走读时锚定的 git_commit 或分支号,这样过半年回头看发现代码对不上时,还能 checkout 到当时的版本做对照。尤其是 WebRTC 这种滚动发布的项目,这点格外重要。
传输模块我前前后后翻了三遍,每一遍的收获都不一样。第一遍是看调用链,把 SendPacket 从顶层追到 socket;第二遍是看选路和状态机,理解 ICE 为什么不总是选最快的连接;第三遍才是看弱网反馈,意识到 NACK、FEC、拥塞控制原来是一个整体。希望你读这篇走读时,也能建立属于自己的那份模块地图,那比记住任何一个具体类的实现都更有价值。
