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的处理方式是加一个等待窗口:

  1. 初始等待:发现丢包后,不立即发NACK,等一小段时间(默认10ms左右)
  2. 重试间隔:如果等了一次还没收到,再发一次NACK,间隔翻倍(类似TCP的超时重传)
  3. 最大重试次数:默认3次,超过就放弃

我的经验:在嵌入式设备上,10ms的等待窗口可能太短。如果CPU主频低,任务调度延迟大,建议把初始等待调到20-30ms,否则容易误判乱序为丢包,造成不必要的重传。

触发NACK的完整流程我画了一张图:

NACK触发判断流程 收到RTP包 检查序列号连续性 序列号连续 → 正常处理 序列号不连续 → 丢包检测 启动等待窗口(10ms) 超时仍未收到? 发送NACK报文 注意:乱序到达的包 会在等待窗口期内补上 不会触发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默认的策略是按序列号从小到大重传。但我在实际项目中做了改进:

  1. 关键帧优先:如果丢失的包属于关键帧(I帧),优先重传。因为关键帧丢了,后续的P帧全废了。
  2. 时间敏感优先:即将超过播放时间戳的包,优先重传。否则传到了也来不及解码。
  3. 批量重传:把多个连续的丢包合并成一次重传,减少小包发送的开销。

我建议:在嵌入式设备上,可以再加一层「带宽感知」策略。如果当前上行带宽已经满了,就放弃部分非关键帧的重传,优先保证音频和关键帧的传输。这个逻辑我在一个视频门禁项目里用过,效果很好。

优先级策略的决策树:

重传优先级决策树 收到NACK请求 是否属于关键帧? 最高优先级,立即重传 检查播放时间戳 即将超时?→ 高优先级 普通优先级,排队重传 带宽充足?→ 执行重传

最后说一句:NACK重传不是万能的。如果网络丢包率超过20%,NACK反而会加剧拥塞。这时候就要靠FEC前向纠错或者码率自适应了。那些内容我们后面章节再聊。