24、序列化与视频处理:H.264 NALU的序列化结构
做嵌入式开发这么多年,我接触过不少视频相关的项目。从早期的MPEG-4到后来的H.264,再到现在的H.265,底层的数据结构其实都有共通之处。今天咱们就聊聊H.264里最核心的一个概念——NALU(Network Abstraction Layer Unit),以及它跟序列化到底有什么关系。
说白了,H.264编码出来的原始码流就是一串二进制数据。但你要直接把这串数据扔给解码器,它根本不知道从哪里开始、哪里结束。这时候就需要一种“打包”机制,把数据组织成一个个独立的小单元。这个小单元就是NALU。
NALU的基本结构
每个NALU由两部分组成:起始码和NALU数据。起始码是固定的0x00000001(4字节)或者0x000001(3字节)。NALU数据又分为NALU头和NALU载荷。
我刚开始做视频处理时,就踩过一个坑。当时以为起始码只有4字节的版本,结果遇到一些码流用的是3字节的,解析直接崩了。嗯,这里要注意:起始码的长度是可变的,具体看码流规范。
NALU头只有1个字节,结构如下:
| 位域 | 长度(bit) | 含义 |
|---|---|---|
| F | 1 | 禁止位,0表示正常,1表示语法错误 |
| NRI | 2 | 重要性指示,00~11,值越大越重要 |
| Type | 5 | NALU类型,1~5是编码片,6是SEI,7是SPS,8是PPS |
你看,就这么1个字节,包含了三个关键信息。我个人习惯在解析时先把这1个字节读出来,然后通过位运算提取各个字段。
// NALU头解析示例
typedef struct {
uint8_t forbidden_bit; // 1 bit
uint8_t nal_ref_idc; // 2 bits
uint8_t nal_unit_type; // 5 bits
} NALUHeader_t;
NALUHeader_t parse_nalu_header(uint8_t byte) {
NALUHeader_t hdr;
hdr.forbidden_bit = (byte >> 7) & 0x01;
hdr.nal_ref_idc = (byte >> 5) & 0x03;
hdr.nal_unit_type = byte & 0x1F;
return hdr;
}
序列化视角下的NALU
你想想看,序列化本质上就是把内存中的结构化数据转换成字节流。NALU的序列化,其实就是把NALU头、起始码、载荷数据按照固定格式拼接到一起。
反过来,反序列化就是从字节流中识别出起始码,然后提取NALU头,再根据类型解析载荷。这个过程在嵌入式设备上尤其重要,因为内存有限,你不能把整个码流都加载进来再解析。
我在项目中遇到过一个问题:码流中出现了0x00000001的模拟数据,导致解析器误判为起始码。后来我加了一个防竞争机制——检测到0x000000后,再检查下一个字节是不是0x01,如果是才确认是起始码。这其实就是序列化中的“帧同步”思想。
核心要点:NALU的序列化结构本质上是一种“自描述”的数据格式。每个NALU都包含了足够的信息让解码器独立处理,不需要依赖外部上下文。这也是H.264能适应各种网络传输环境的原因。
常见的NALU类型
实际项目中,你遇到最多的NALU类型是这几个:
- 类型7(SPS):序列参数集,包含分辨率、帧率等全局信息
- 类型8(PPS):图像参数集,包含编码模式、熵编码方式等
- 类型5(IDR):关键帧,解码器从这里开始刷新
- 类型1(非IDR):普通编码帧
- 类型6(SEI):补充增强信息,比如时间戳、字幕等
我曾经调试过一个视频卡顿的问题,发现是SPS和PPS没有在码流开头发送,解码器一直处于等待状态。后来我强制在每段码流前都插入SPS和PPS,问题就解决了。说白了,SPS和PPS就是解码器的“初始化参数”,少了它们解码器没法工作。
NALU序列化的实际应用
在嵌入式系统中,我们经常需要把摄像头采集的H.264码流通过网络发送出去。这时候NALU的序列化结构就派上用场了。
举个例子,一个典型的视频帧可能包含多个NALU:
// 一个视频帧的NALU序列
[0x00000001][NALU头:类型7][SPS数据] // 序列参数集
[0x00000001][NALU头:类型8][PPS数据] // 图像参数集
[0x00000001][NALU头:类型5][IDR数据] // 关键帧数据
在发送时,我会把每个NALU当作一个独立的数据包。接收端收到后,先找起始码,然后解析NALU头,最后根据类型处理载荷。这个过程其实就是序列化和反序列化的完整闭环。
小技巧:如果你在嵌入式设备上做H.264码流解析,建议用状态机来实现起始码检测。这样既高效又节省内存,不会因为一次读取大量数据而爆掉堆栈。
避坑指南
我曾经在解析NALU时犯过一个低级错误:直接memcpy整个NALU数据,没考虑字节对齐。结果在ARM平台上跑的时候,因为对齐访问异常导致程序崩溃。后来我改用逐字节拷贝,问题才解决。
还有一次,我遇到一个码流,它的起始码用的是3字节版本(0x000001),但我代码里写死了4字节。排查了半天才发现是起始码长度不匹配。所以我现在写解析器时,都会同时支持3字节和4字节两种起始码。
警告:不要假设所有H.264码流都遵循同样的起始码格式。有些编码器会在码流末尾添加填充字节,有些会省略起始码。写代码时一定要做容错处理。
NALU序列化的SVG结构图
下面这张图展示了NALU从原始数据到序列化字节流的完整过程。你可以看到,每个NALU都是独立打包的,起始码就像“胶水”一样把它们粘在一起。
从这张图可以看得很清楚:原始码流经过起始码检测后,被分割成一个个独立的NALU。每个NALU都包含起始码、NALU头和载荷。这种结构的好处是——任何一个NALU损坏,都不会影响其他NALU的解析。这就是序列化中“自包含”思想的典型应用。
好了,关于H.264 NALU的序列化结构就聊到这里。记住一句话:视频编码的本质,就是把连续的画面变成离散的数据包。而NALU,就是这些数据包的“集装箱”。
公众号:蓝海资料掘金营,微信deep3321