为什么需要 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:
- 识别 S 位:创建一个缓冲区,跳过 FU 指示器和头部,存入第一个分片数据。
- 连续性校验:检查序列号是否连续。如果中间跳号(如收到了 101 和 103),则该 NALU 损坏,丢弃当前正在组装的所有分片。
- 识别 E 位:收到 E 位后,根据原始 Type 和 NRI 还原 NALU Header,将其与所有分片数据拼接。
- 递交解码器:将组装好的完整 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 | 标志完整图像帧的结束 |