2. NACK重传机制原理
NACK重传,说白了就是接收方告诉发送方:「嘿,我丢了一个包,赶紧再发一次」。这是WebRTC对抗网络丢包最基础、最核心的手段。我在做嵌入式WebRTC移植时,第一个优化的就是NACK模块——因为嵌入式设备的CPU和内存都有限,NACK处理不好,整个通话就卡死了。
2.1 RTCP NACK报文格式
NACK报文走的是RTCP通道,不是RTP。这一点很多初学者会搞混。RTCP NACK的正式名称是Generic NACK,定义在RFC 4585中。
报文结构其实很简单,我直接给你看格式:
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 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
关键字段就三个:
| 字段 | 长度 | 说明 |
|---|---|---|
| PID | 16 bit | 丢失的第一个RTP包的序列号 |
| BLP | 16 bit | 位掩码,标记PID之后16个包的丢失情况 |
| SSRC | 32 bit × 2 | 发送者和媒体源的同步标识 |
重点理解BLP:BLP的每一位对应PID+1到PID+16的包。比如BLP=0b0000000000000101,表示PID+2和PID+4也丢了。这样一条NACK报文最多能报告17个丢包(1个PID + 16个BLP位)。
我在项目中遇到过一个问题:嵌入式设备的网络缓冲区很小,如果一次丢包太多,BLP根本覆盖不完。后来我调整了NACK生成策略,把大范围的丢包拆成多条NACK报文发送,才解决了问题。
2.2 NACK触发条件
什么时候发NACK?不是随便丢一个包就发。WebRTC有一套严格的触发逻辑。
核心判断依据是序列号不连续。接收端每收到一个RTP包,都会检查序列号。如果发现跳号了,比如收到100号包后,下一个是103号包,那101和102就丢了。
但这里有个坑:乱序到达。包可能只是晚到了,不是真丢了。WebRTC的处理方式是加一个等待窗口:
- 初始等待:发现丢包后,不立即发NACK,等一小段时间(默认10ms左右)
- 重试间隔:如果等了一次还没收到,再发一次NACK,间隔翻倍(类似TCP的超时重传)
- 最大重试次数:默认3次,超过就放弃
我的经验:在嵌入式设备上,10ms的等待窗口可能太短。如果CPU主频低,任务调度延迟大,建议把初始等待调到20-30ms,否则容易误判乱序为丢包,造成不必要的重传。
触发NACK的完整流程我画了一张图:
2.3 重传缓冲区(NACK Module)设计
发送端收到NACK后,不能直接从编码器拿数据——编码器可能已经把那个包丢了。所以必须有一个重传缓冲区,专门保存最近发送过的RTP包。
这个缓冲区在WebRTC源码里叫 NackModule,但我觉得叫「重传缓冲区」更直白。它的设计要点有三个:
| 设计维度 | 说明 | 嵌入式注意事项 |
|---|---|---|
| 缓冲区大小 | 默认保存最近1000个RTP包 | 嵌入式内存有限,建议根据码率和延迟要求缩减到200-500个 |
| 存储结构 | 环形缓冲区(ring buffer),按序列号索引 | 环形缓冲区内存分配固定,避免碎片 |
| 淘汰策略 | 超过缓冲区大小的旧包自动丢弃 | 注意:如果缓冲区太小,NACK可能请求到已被淘汰的包 |
我曾经踩过的坑:在ARM Cortex-M4上做移植时,我把缓冲区设成了100个包。结果网络抖动一大,NACK请求的包早就被淘汰了,导致接收端一直收不到重传,视频直接卡死。后来我改成动态调整缓冲区大小,根据当前RTT和码率自动计算,才稳定下来。
缓冲区查找的核心代码逻辑大概是这样的:
// 伪代码:从重传缓冲区查找指定序列号的包
RtpPacket* FindPacketInNackBuffer(uint16_t seq_no) {
// 检查序列号是否在有效范围内
if (!IsInBufferRange(seq_no)) {
return NULL; // 包已被淘汰
}
// 计算环形缓冲区索引
int index = seq_no % kBufferSize;
// 检查该位置存储的包序列号是否匹配
if (buffer_[index].seq_no == seq_no) {
return &buffer_[index];
}
return NULL; // 未找到
}
2.4 重传优先级策略
如果同时收到多个NACK请求,先重传哪个?这个问题在嵌入式设备上尤其重要——因为CPU和带宽都有限。
WebRTC默认的策略是按序列号从小到大重传。但我在实际项目中做了改进:
- 关键帧优先:如果丢失的包属于关键帧(I帧),优先重传。因为关键帧丢了,后续的P帧全废了。
- 时间敏感优先:即将超过播放时间戳的包,优先重传。否则传到了也来不及解码。
- 批量重传:把多个连续的丢包合并成一次重传,减少小包发送的开销。
我建议:在嵌入式设备上,可以再加一层「带宽感知」策略。如果当前上行带宽已经满了,就放弃部分非关键帧的重传,优先保证音频和关键帧的传输。这个逻辑我在一个视频门禁项目里用过,效果很好。
优先级策略的决策树:
最后说一句:NACK重传不是万能的。如果网络丢包率超过20%,NACK反而会加剧拥塞。这时候就要靠FEC前向纠错或者码率自适应了。那些内容我们后面章节再聊。