一、开发环境准备
本文基于以下环境开发测试,可根据实际场景调整:
- 操作系统:Ubuntu 22.04 LTS
- FFmpeg 版本:4.4.3(支持 H.264 解析器和比特流过滤器)
- 编译工具:GCC 11.3.0
- 依赖库:libavformat-dev、libavcodec-dev、libavutil-dev、libcjson-dev
- 硬件平台:X86 PC 可直接编译测试,ARM 嵌入式平台交叉编译流程一致
二、SEI 核心原理

2.1 SEI 标准定义与结构
根据 H.264/AVC 标准,SEI(补充增强信息)属于非核心解码单元,不影响视频帧的正常解码,专门用于传输业务自定义辅助数据,常见场景包括时间戳同步、位置信息嵌入、告警标记等。
H.264 码流中 SEI 对应 NALU 类型为 6,未注册用户自定义 SEI 的结构如下:
bash
SEI NALU(类型6)
├─ SEI payload类型(未注册用户数据固定为5)
├─ SEI payload长度
└─ SEI payload内容
├─ 16字节UUID(用于区分业务类型)
└─ 用户自定义负载
2.2 FFmpeg 默认处理逻辑
FFmpeg 的 SEI 解码核心逻辑位于libavcodec/h264_sei.c源码中,默认处理流程为:
- 入口函数
ff_h264_decode_sei拆分 SEI payload - 根据 payload 类型调用对应处理函数,未注册用户数据对应
decode_unregistered_user_data - 默认实现仅读取 UUID 后直接跳过自定义负载,不会将数据暴露给应用层,这就是默认解码流程无法获取自定义 SEI 的核心原因。
2.3 关键规则:防竞争字节处理
H.264 使用0x00 0x00 0x01作为 NALU 起始码,为避免 SEI 负载中出现相同序列导致解析错误,编码时会在0x00 0x00后插入0x03字节:
- 原始
0x00 0x00 0x01→ 编码后0x00 0x00 0x03 0x01 - 原始
0x00 0x00 0x02→ 编码后0x00 0x00 0x03 0x02
解析时需要反向移除插入的0x03字节,还原原始负载数据。
2.4 容器对 SEI 解析的影响
MP4 等封装容器会移除原始 NALU 起始码,重排 NALU 顺序,导致直接从容器提取 SEI 容易失败。此类场景需要通过h264_mp4toannexb比特流过滤器,还原为 Annexb 格式的原始码流后再解析。
三、前置准备:生成测试 SEI 码流
使用 FFmpeg 内置的h264_metadata比特流过滤器,可以快速注入自定义 SEI 用于测试:
bash
ffmpeg -i input.mp4 -c:v copy \
-bsf:v h264_metadata=sei_user_data='086f3693-b7b3-4f2c-9653-21492feee5b8+{"timestamp":1717200000,"lon":116.397,"lat":39.908}' \
test.h264
该命令将携带时间戳、经纬度的 JSON 数据注入 SEI,生成可用于测试的 H.264 裸流文件。

四、核心代码实现
4.1 防竞争字节处理函数
以下是完整的防竞争字节移除实现,包含边界检查和异常处理:
objectivec
/**
* @brief 移除SEI负载中的防竞争字节
* @param src 原始负载数据
* @param src_len 原始数据长度
* @param dst 输出缓冲区
* @param dst_len 输出缓冲区最大长度
* @return 处理后的数据长度
*/
int remove_emulation_bytes(uint8_t *src, int src_len, uint8_t *dst, int dst_len) {
int i = 0, j = 0;
if (!src || !dst || src_len <= 0 || dst_len <= 0) {
return -1;
}
while (i < src_len && j < dst_len) {
// 检测到 0x00 0x00 0x03 序列,跳过第三个字节
if (i + 2 < src_len && src[i] == 0x00 && src[i+1] == 0x00 && src[i+2] == 0x03) {
dst[j++] = src[i++];
dst[j++] = src[i++];
i++; // 跳过插入的防竞争字节0x03
} else {
dst[j++] = src[i++];
}
}
return j;
}
4.2 SEI 解析主流程
以下是完整的 SEI 筛选与解析核心代码:
objectivec
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <cjson/cJSON.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
// 目标业务UUID,与注入时保持一致
static const uint8_t TARGET_UUID[16] = {
0x08, 0x6f, 0x36, 0x93, 0xb7, 0xb3, 0x4f, 0x2c,
0x96, 0x53, 0x21, 0x49, 0x2f, 0xee, 0xe5, 0xb8
};
// 防竞争处理函数声明见上文
int remove_emulation_bytes(uint8_t *src, int src_len, uint8_t *dst, int dst_len);
/**
* @brief 处理SEI NALU,提取目标自定义数据
*/
void process_sei(uint8_t *data, int size) {
// 跳过起始码(3字节)+ NALU头(1字节)
if (size < 4) return;
uint8_t *sei_ptr = data + 4;
int sei_remaining = size - 4;
// 解析SEI payload类型
int payload_type = 0;
while (sei_remaining > 0 && *sei_ptr == 0xFF) {
payload_type += 0xFF;
sei_ptr++;
sei_remaining--;
}
if (sei_remaining <= 0) return;
payload_type += *sei_ptr++;
sei_remaining--;
// 解析SEI payload长度
int payload_size = 0;
while (sei_remaining > 0 && *sei_ptr == 0xFF) {
payload_size += 0xFF;
sei_ptr++;
sei_remaining--;
}
if (sei_remaining <= 0 || payload_size > sei_remaining) return;
payload_size += *sei_ptr++;
sei_remaining--;
// 仅处理未注册用户数据,且必须包含16字节UUID
if (payload_type != 5 || payload_size < 16) return;
// 匹配目标UUID,不匹配则跳过
if (memcmp(sei_ptr, TARGET_UUID, 16) != 0) return;
// 提取自定义负载
uint8_t *payload = sei_ptr + 16;
int payload_len = payload_size - 16;
if (payload_len <= 0) return;
// 申请输出缓冲区,处理防竞争字节
uint8_t *dest = av_malloc(payload_len);
if (!dest) return;
int dest_len = remove_emulation_bytes(payload, payload_len, dest, payload_len);
if (dest_len <= 0) {
av_free(dest);
return;
}
// 解析JSON格式的位置数据
cJSON *root = cJSON_ParseWithLength((char*)dest, dest_len);
if (!root) {
av_free(dest);
return;
}
// 提取字段并输出
cJSON *timestamp = cJSON_GetObjectItemCaseSensitive(root, "timestamp");
cJSON *lon = cJSON_GetObjectItemCaseSensitive(root, "lon");
cJSON *lat = cJSON_GetObjectItemCaseSensitive(root, "lat");
if (cJSON_IsNumber(timestamp) && cJSON_IsNumber(lon) && cJSON_IsNumber(lat)) {
printf("✅ SEI解析成功: 时间戳=%.0f, 经度=%.3f, 纬度=%.3f\n",
timestamp->valuedouble, lon->valuedouble, lat->valuedouble);
}
// 资源释放
cJSON_Delete(root);
av_free(dest);
}
五、实战技术流程:无人机 GPS SEI 提取
5.1 场景问题描述
某嵌入式无人机巡检项目,飞控端将 GPS 位置信息注入 H.264 裸流的 SEI 单元,要求地面站解码端实时提取位置信息并与视频帧同步输出,且 SEI 解析错误不能影响正常视频解码流程。

5.2 完整实现步骤
步骤 1:环境配置与基础解码验证
首先安装 FFmpeg 开发依赖,编写最小化解码程序,验证基础解码流程正常:
bash
sudo apt install libavformat-dev libavcodec-dev libavutil-dev libcjson-dev
基础解码初始化代码:
objectivec
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("用法: %s <h264文件路径>\n", argv[0]);
return -1;
}
// 注册FFmpeg所有组件
av_register_all();
avcodec_register_all();
// 初始化解码器
const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_H264);
AVCodecContext *codec_ctx = avcodec_alloc_context3(codec);
if (!codec_ctx) return -1;
if (avcodec_open2(codec_ctx, codec, NULL) < 0) {
avcodec_free_context(&codec_ctx);
return -1;
}
// 初始化H.264解析器
AVCodecParserContext *parser = av_parser_init(AV_CODEC_ID_H264);
AVPacket *pkt = av_packet_alloc();
FILE *fp = fopen(argv[1], "rb");
if (!fp || !parser || !pkt) {
// 资源释放略
return -1;
}
步骤 2:循环读取码流拆分 NALU
循环读取文件数据,通过 FFmpeg 解析器拆分出独立 NALU:
objectivec
uint8_t buf[4096];
int buf_size;
while ((buf_size = fread(buf, 1, sizeof(buf), fp)) > 0) {
uint8_t *data_ptr = buf;
int remaining = buf_size;
while (remaining > 0) {
int ret = av_parser_parse2(parser, codec_ctx, &pkt->data, &pkt->size,
data_ptr, remaining, AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
if (ret < 0) {
// 解析错误,跳过当前数据块,不中断解码
break;
}
data_ptr += ret;
remaining -= ret;
// 筛选SEI类型NALU(类型6)
if (pkt->size > 0 && (parser->unit_type & 0x1F) == 6) {
process_sei(pkt->data, pkt->size);
}
// 送入解码器正常解码视频帧
if (pkt->size > 0) {
avcodec_send_packet(codec_ctx, pkt);
AVFrame *frame = av_frame_alloc();
if (avcodec_receive_frame(codec_ctx, frame) == 0) {
// 此处可添加视频帧处理逻辑
av_frame_free(&frame);
}
}
}
}
步骤 3:资源释放与退出
objectivec
{
fclose(fp);
av_packet_free(&pkt);
av_parser_close(parser);
avcodec_free_context(&codec_ctx);
return 0;
}
5.3 结果验证
编译运行程序后,输出结果如下:
✅ SEI解析成功: 时间戳=1717200000, 经度=116.397, 纬度=39.908
测试验证结论:
- 注入的 SEI 数据 100% 解析成功,与原始数据完全一致
- 单帧 SEI 解析耗时小于 1ms,满足嵌入式实时性要求
- 注入无效 SEI 数据时,解析错误被正常捕获,不会中断视频解码流程
- 包含防竞争序列的负载可以正确解析,还原后数据无错误
六、实战总结
6.1 两种提取方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 手动解析 NALU | 兼容性好,不依赖 FFmpeg 内部实现,可处理任意格式码流 | 开发量略大 | 裸流输入场景、嵌入式资源受限场景 |
| Hook 解码回调 | 集成度高,无需自行处理 NAL 拆分 | 依赖 FFmpeg 版本,兼容性差 | 已接入完整 FFmpeg 解码、版本固定的项目 |
6.2 常见问题总结
- SEI 数据提取不全:多为多 SEI 合并场景未循环处理所有 payload,或比特流过滤器配置错误
- SEI 解析失败 :多为未做防竞争字节处理,或 MP4 容器未通过
h264_mp4toannexb还原 NAL 边界 - 解码被 SEI 错误打断:未做错误隔离,需确保 SEI 解析异常不影响核心解码流程
6.3 开发经验
自定义 SEI 优先使用标准未注册 UUID 格式,避免与标准 SEI 冲突;负载长度建议控制在 1KB 以内,避免被编码器截断;必须添加数据校验,避免无效数据影响主流程;使用 NVIDIA 硬件解码时,FFmpeg 5.0 以上版本已支持 SEI 提取,可直接通过回调获取。
6.4 延伸说明
H.265 SEI 与 H.264 SEI 核心结构类似,仅 NALU 类型不同(H.265 为 39),解析逻辑可直接复用,只需修改 NALU 类型判断和解析器初始化参数即可。
