音视频学习(八十五):FU-A

为什么需要 FU-A?

在实时音视频传输(WebRTC, RTSP, SIP)中,H.264 码流通常以 NALU (Network Abstraction Layer Unit) 为基本单位。然而,网络传输层存在 MTU(Maximum Transmission Unit,最大传输单元) 的限制。

  • MTU 限制:通常以太网的 MTU 为 1500 字节。
  • 协议开销:一个标准的 RTP 包包含:IP 头(20B)+ UDP 头(8B)+ RTP 固定头(12B)= 40 字节。
  • 有效载荷 :留给视频数据的空间约为 1500 − 40 = 1460 1500 - 40 = 1460 1500−40=1460 字节。

对于 H.264 的 I 帧(关键帧),其 NALU 大小往往达到几十甚至上百 KB,远超 MTU。如果直接让底层的 IP 层进行分片,一旦丢失其中一个 IP 分片,整个视频帧都无法组装,导致严重的解码花屏。

因此,RFC 6184 规定了在应用层(RTP 层)进行分片的机制,其中最常用的就是 FU-A。

FU-A 报文结构

当一个 NALU 超过 MTU 时,我们会剥离 NALU 原有的 Header,将其 Payload 切分为多个分片,并在每个分片前添加两个字节的控制头:FU Indicator 和 FU Header。

FU Indicator (1 字节)

其格式与 NALU Header 类似,起到指示"这是一个分片包"的作用:

  • F (1 bit): 禁止位,必须为 0。
  • NRI (2 bits): 重要性指示。取自原始 NALU Header,指示该片对解码的重要性(I 帧通常为 11)。
  • Type (5 bits) : 固定为 28 (二进制 11100),表示该包是 FU-A 类型。

FU Header (1 字节)

其作用是描述该分片在原始 NALU 中的位置:

  • S (Start bit) : 置 1 表示 NALU 的第一个分片。
  • E (End bit) : 置 1 表示 NALU 的最后一个分片。
  • R (Reserved): 保留位,必须为 0。
  • Type (5 bits) : 原始 NALU 的类型(如:若原始是 IDR 帧,则此处为 5)。

封包逻辑全流程

将一个大 NALU 转换为多个 FU-A 包的逻辑步骤如下:

1. 拆解原始 NALU

读取原始 NALU 的第一个字节(Header),提取其 NRI 和 Type。随后将指针向后移动 1 字节,指向真正的视频数据负载。

2. 循环切片

假设有效载荷大小为 PayloadSize,最大切片长度为 MAX_PAYLOAD_LEN(通常设为 1400):

  • 起始包 (Start Unit) :
    • S=1, E=0。
    • Payload 包含 NALU 数据的前 1400 字节。
    • RTP Header 的 Marker 位通常为 0。
  • 中间包 (Intermediate Units) :
    • S=0, E=0。
    • Payload 包含接下来的数据。
  • 结束包 (End Unit) :
    • S=0, E=1。
    • Payload 包含剩余数据。
    • 关键点 :此时 RTP Header 的 Marker 位应置为 1,表示这一帧(Access Unit)结束。

3. RTP 序列号与时间戳

  • 序列号 (Sequence Number) :每个 FU-A 分片包的序列号必须连续递增(如 101, 102, 103)。
  • 时间戳 (Timestamp) :属于同一个 NALU 的所有分片包,必须使用完全相同的时间戳。

解包与组装逻辑 (Receiver Side)

接收端在收到 RTP 包后,根据 Type=28 判定为 FU-A:

  1. 识别 S 位:创建一个缓冲区,跳过 FU 指示器和头部,存入第一个分片数据。
  2. 连续性校验:检查序列号是否连续。如果中间跳号(如收到了 101 和 103),则该 NALU 损坏,丢弃当前正在组装的所有分片。
  3. 识别 E 位:收到 E 位后,根据原始 Type 和 NRI 还原 NALU Header,将其与所有分片数据拼接。
  4. 递交解码器:将组装好的完整 NALU 传给 H.264 解码器(如 FFmpeg)。

常见问题与优化经验

1. Marker 位 (M) 的处理

很多开发者容易混淆 FU Header 的 E位 与 RTP Header 的 M位。

  • E=1 仅仅代表这一个 NALU 结束了。
  • M=1 代表这一 帧 (Frame) 结束了。
  • 注意 :在 H.264 中,一个帧可能包含多个 NALU(如 SPS, PPS, SEI, IDR)。通常做法是仅在最后一个 NALU 的最后一个 FU-A 分片上将 M 置为 1。

2. 内存拷贝优化

在高性能流媒体服务器中,频繁的 memcpy 会消耗 CPU。

  • 零拷贝建议:预留 14 字节的 Header 空间(12B RTP + 2B FU),直接在原始数据缓冲区上进行封包发送。

3. 花屏现象排查

如果出现"小范围正常,大范围花屏",通常是因为:

  • 分片丢失:UDP 丢包导致中间分片缺失,由于没有检查序列号连续性,接收端拼出了错误的 NALU。
  • MTU 设置过大:设置了 1460 导致加上 IP 头后超过了链路层限制,触发了 IP 层分片。

代码实现(c++)

封包流程 (Packer)

c++ 复制代码
#include <vector>
#include <cstring>
#include <cstdint>

// 模拟发送函数指针
typedef void (*RtpSendCallback)(const uint8_t* data, int len, uint32_t ts, bool marker);

/**
 * @brief H.264 NALU 封包为 FU-A
 * @param nalu       原始NALU数据(包含Header)
 * @param nalu_len   数据长度
 * @param mtu        最大传输单元 (通常1400左右)
 * @param timestamp  RTP时间戳
 * @param send_cb    发送回调
 */
void rtp_h264_pack_fu_a(const uint8_t* nalu, int nalu_len, int mtu, uint32_t timestamp, RtpSendCallback send_cb) {
    if (nalu_len <= mtu) {
        // 单包发送逻辑 (Single NALU Unit)
        send_cb(nalu, nalu_len, timestamp, true);
        return;
    }

    uint8_t nalu_header = nalu[0];
    uint8_t fu_indicator = (nalu_header & 0xE0) | 28; // Type 28 for FU-A
    uint8_t fu_header_base = (nalu_header & 0x1F);   // 原始 Type

    const uint8_t* payload = nalu + 1; // 跳过原始Header
    int payload_len = nalu_len - 1;
    int max_chunk = mtu - 2; // 扣除 FU indicator 和 FU header

    int offset = 0;
    while (offset < payload_len) {
        int remaining = payload_len - offset;
        int current_chunk = (remaining > max_chunk) ? max_chunk : remaining;
        
        uint8_t fu_header = fu_header_base;
        bool is_last = (offset + current_chunk == payload_len);

        if (offset == 0) 
            fu_header |= 0x80; // S = 1 (Start)
        else if (is_last)
            fu_header |= 0x40; // E = 1 (End)

        // 构造临时 RTP 载荷 (实际项目中建议使用预分配的 buffer 避免拷贝)
        std::vector<uint8_t> rtp_payload;
        rtp_payload.push_back(fu_indicator);
        rtp_payload.push_back(fu_header);
        rtp_payload.insert(rtp_payload.end(), payload + offset, payload + offset + current_chunk);

        // 发送分片,只有最后一个分片 Marker 置 1
        send_cb(rtp_payload.data(), (int)rtp_payload.size(), timestamp, is_last);

        offset += current_chunk;
    }
}

解包流程 (Unpacker)

c++ 复制代码
#include <iostream>

// 用于保存组装状态的全局/类变量
std::vector<uint8_t> g_nalu_buffer;

/**
 * @brief 处理收到的 RTP 载荷并解包
 * @param rtp_payload  RTP报文中的负载部分(不含RTP Header)
 * @param payload_len  负载长度
 */
void rtp_h264_unpack_fu_a(const uint8_t* rtp_payload, int payload_len) {
    if (payload_len < 2) return;

    uint8_t fu_indicator = rtp_payload[0];
    uint8_t fu_header = rtp_payload[1];
    uint8_t type = fu_indicator & 0x1F;

    if (type == 28) { // FU-A 分片
        bool start_bit = (fu_header & 0x80) != 0;
        bool end_bit = (fu_header & 0x40) != 0;
        uint8_t original_nalu_type = fu_header & 0x1F;

        if (start_bit) {
            // 第一个分片:还原 NALU Header 并开始组装
            g_nalu_buffer.clear();
            uint8_t reconstructed_header = (fu_indicator & 0xE0) | original_nalu_type;
            g_nalu_buffer.push_back(reconstructed_header);
        }

        // 拷贝负载数据(跳过 FU indicator 和 FU header)
        g_nalu_buffer.insert(g_nalu_buffer.end(), rtp_payload + 2, rtp_payload + payload_len);

        if (end_bit) {
            // 最后一个分片:组装完成,交付给解码器
            std::cout << "完整 NALU 组装完成,大小: " << g_nalu_buffer.size() << std::endl;
            // Decode(g_nalu_buffer.data(), g_nalu_buffer.size());
            g_nalu_buffer.clear();
        }
    } else {
        // 单包 NALU(非分片)
        std::cout << "收到单包 NALU,类型: " << (int)type << std::endl;
        // Decode(rtp_payload, payload_len);
    }
}

总结

FU-A 是 H.264 网络传输的基石。它巧妙地在 RTP 负载层解决了大数据块传输的问题,兼顾了协议的鲁棒性与解析的简便性。

核心组件 关键作用
FU Indicator 定义协议类型(Type 28)
FU Header 标记分片的首尾位置及原始 NALU 类型
Timestamp 保持同帧同步,不可在分片间递增
Marker Bit 标志完整图像帧的结束
相关推荐
AI视频工具指南5 小时前
AI短剧怎么做?一个矛盾怎样接着讲三集
人工智能·aigc·音视频
酷虎软件6 小时前
视频加字幕怎么制作
java·前端·音视频
码点滴6 小时前
67000 星的开源语音引擎:用 VoiceStudio 搭建本地语音克隆与多语言配音流水线
开源·音视频
开开心心就好7 小时前
手机虚拟化学实验室,混合燃烧都能模拟
jupyter·智能手机·ffmpeg·计算机外设·电脑·音视频·visual studio
VidDown8 小时前
上线三个月后的第一次事故:一个 Django 服务的日志、监控与故障复盘
数据库·python·django·wpf·音视频·视频编解码
donk13148 小时前
React Native for OpenHarmony 实战:三方库 react-native-toast-message 的鸿蒙化适配指南
react native·react.js·音视频·harmonyos
VidDown8 小时前
用户说“卡顿“,服务器一切正常:播放质量监测与 QoE 指标
运维·服务器·ffmpeg·音视频·视频
一楼的猫8 小时前
第十一届中国教育后勤展览会杭州开幕 夏拾光影像为阿里、淘宝闪购提供云相册直播与视频服务 - 夏拾光影像
大数据·人工智能·音视频
开开心心就好9 小时前
手机电池健康怎么查?看容量温度循环次数
spring·智能手机·ffmpeg·计算机外设·电脑·maven·音视频
林墨聊AIGC12 小时前
AI短剧分镜图生成后下一步:从AI到成片的完整制作流程高效策略
大数据·人工智能·ai作画·aigc·音视频·ai写作