简介:面向嵌入式与多媒体开发者的C语言实现工具包,聚焦在ARM平台上借助libmp4v2将H265视频与AAC音频封装为MP4文件,解决录制高压缩比视频时的音视频同步与文件容器构造问题。包内共104个文件,以99个头文件、2个静态库文件、2个C++源文件和1个项目配置文件为主,涵盖MP4封装接口、H265编码适配以及AAC音频处理相关实现,便于直接嵌入到现有C工程或进行二次开发,降低自行解析MP4格式的复杂度。已有2519人学习下载。其中完整展示了libmp4v2的写入流程,还涉及HEVC编码原理、音视频复用时间戳处理、嵌入式平台内存限制与性能调优等关键经验,适合正在做视频监控、车载录像、物联网视频采集等项目的开发者参考。源码结构清晰,配合文档可快速理解MP4文件构造、音视频流复用逻辑及跨平台移植思路,是学习多媒体封装与ARM平台C编程的实用资料。 最近在给一台嵌入式设备做录像功能,需求很直接:编码器输出H.265裸流,通过C语言封装成MP4录像文件存到SD卡。一开始第一反应是用FFmpeg,但评估完资源占用和交叉编译复杂度之后,最终选了libmp4v2这套老牌C库。折腾几天跑通之后,发现整套流程其实并不复杂,只是细节特别多,尤其是H.265和MP4容器之间的“适配层”——参数集、NALU长度转换、时间戳处理,任何一个不对,播放器就罢工。这篇文章就把这套方案完整记录下来,从选型逻辑到代码实现,再到实测踩坑,给同样在做C/C++方向视频录制或者MP4封装的朋友做个参考。
1. 方案选型:面对H.265录像需求,为什么我选了libmp4v2
1.1 和FFmpeg相比,差距在哪
做视频录像,最纠结的就是封装库选型。FFmpeg确实功能全,但代价也大。先看一组直观对比:
| 对比项 | libmp4v2 | FFmpeg |
|---|---|---|
| 静态库体积 | 几百KB级别 | 十几MB甚至更大 |
| 编译依赖 | 基本无第三方依赖 | 需要x264/x265等多个编解码库 |
| 功能范围 | 只做MP4封装/解封装 | 转码、滤镜、协议、封装全都有 |
| API复杂度 | 简单,接口数量少 | 层次多,抽象复杂 |
| 内存占用 | 很低 | 较高 |
| 适用场景 | 嵌入式单点封装 | PC工具、转码服务、播放器 |
如果你只需要把已经编码好的H.265码流封装成MP4,FFmpeg的编解码能力完全用不上。而且嵌入式设备就那么大Flash和内存,塞一个完整的FFmpeg库进去,性价比太低了。libmp4v2本身就是专门处理MP4容器格式的,API设计也很简单,调用逻辑基本是“创建文件 -> 加轨道 -> 写样本 -> 关闭”,没有太多抽象层次,出了问题也容易定位。
1.2 什么场景适合用libmp4v2
我自己的判断标准非常直接:
- 编码器已经固定了,不需要转码。比如硬件编码器直接输出H.265,程序只需要负责组织数据结构。
- 目标设备资源吃紧。CPU算力、内存、Flash都严格受限,扛不起重型框架。
- 需要精细控制MP4文件结构。比如手动控制关键帧写入、自定义moov box位置、或者做低延迟边录边存。
如果你的需求不在这个范围,比如需要转码或者推流,那还是上FFmpeg更省事。但单就“录制H.265裸流到MP4”这个动作,libmp4v2是充分且足够的。
2. H.265封装MP4之前,必须搞懂的底层细节
2.1 H.265和H.264在码流结构上的差异
很多人会觉得:H.264和H.265不都是视频编码吗,为什么封装库还要单独写处理逻辑?因为两者的码流结构差异非常大。
最直观的区别是NALU(网络抽象层单元)的组织方式。H.264用4字节起始码00 00 00 01,H.265虽然也兼容这种起始码,但它更常用的是00 00 01或者直接在封装层使用4字节长度前缀。在MP4封装时,需要把裸流里的起始码全部替换成“4字节大端长度 + NALU数据”的格式,这个转换逻辑虽简单,但必须细致处理三字节和四字节起始码混用的情况。
另一个核心点是参数集。H.264只有SPS(序列参数集)和PPS(图像参数集),H.265则多了一个VPS(视频参数集,NALU type为32)。MP4文件里的hvcC box必须同时包含VPS、SPS、PPS三种参数集,缺一个,很多播放器直接拒绝解码。这也是H.265封装最容易出错的地方。
2.2 MP4文件的三个核心box
MP4本质上是ISO基础媒体文件格式(ISO BMFF),内部由一个个box(也叫atom)串联组成。和录像封装最相关的有三个:
ftyp:文件类型声明,告诉播放器这是一个MP4文件,以及兼容性版本。moov:元数据区域,包含轨道信息、编码参数、总时长、时间戳刻度表。播放器解析文件时先读这个部分,才知道怎么解码后面的数据。mdat:真正的媒体数据区域,存的是压缩后的视频帧样本。
录制流程可以理解为:先创建文件写出ftyp和moov骨架,然后不断往mdat里追加样本数据,最后关闭文件时更新moov里的时长等字段。听起来简单,但实际操作中,moov box的大小预分配、关键帧定位、样本时间戳计算,每一步都有坑。
2.3 时间戳与关键帧的基本功
录像模块最容易出问题的就是时间戳。先从概念说起:PTS是显示时间戳,决定这一帧什么时候显示;DTS是解码时间戳,决定这一帧什么时候进入解码器。当码流里存在B帧时,PTS和DTS往往不相等。如果编码器输出的帧顺序不是按显示顺序排列的,而你按接收顺序直接写MP4,播放的时候就会跳帧、卡顿甚至花屏。
我的实践建议:
- 时间戳从0开始计算,单位统一用毫秒,便于调试和定位问题。
- 轨道初始化时把timeScale设成1000,这样每个时间戳单位就是1ms。
- 写入时以DTS为基准保证解码顺序,同时记录PTS偏移。如果项目对画质要求不太极端,最简单粗暴的解法是直接关闭B帧,让PTS等于DTS,省掉一整套复杂度。
关键帧(IDR帧)是录像分段的天然边界。做自动切分文件、断点续录时,每个分片必须从关键帧开始,否则播放器在文件衔接处会黑屏或花屏。这个经验值后面会细说。
3. C语言实现H.265录像:从API到代码的完整落地
3.1 核心API调用流程和版本选择
libmp4v2的调用模式非常固定,用一句话概括就是“打开文件 -> 创建轨道 -> 写样本 -> 关闭文件”。核心API有这么几个:
MP4Create:创建MP4文件,返回文件句柄。MP4AddVideoTrack:添加一个视频轨道。老版本没有专门的H.265接口,需要配合手动创建hvcC box。MP4AddH265Track:一些较新的分支或打补丁的版本提供的接口,可以直接创建H.265轨道。MP4WriteSample:写入一帧样本数据。MP4Close:关闭文件,更新元数据。
这里需要特别提醒:libmp4v2官方老版本(比如2.0.0)并没有MP4AddH265Track这个接口,只有MP4AddH264Track。我当前用的版本是带H.265支持补丁的分支。如果你们环境里没有这个接口,就得用MP4AddVideoTrack + 手动配置hvcC,步骤会多一些,但原理完全相同:把VPS/SPS/PPS拼成一个DecoderConfigurationRecord,通过MP4SetTrackESConfiguration挂到轨道上。
3.2 关键参数的选择与推导
初始化轨道时,几个参数是绕不开的,我直接给出一份推荐配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 视频宽度/高度 | 和编码器输出一致 | 不一致会导致播放器拉伸或黑屏 |
| timeScale | 1000 | 时间戳单位=1ms,便于换算 |
| frameDuration | 1000 / fps | 每帧持续时长,如25fps时为40 |
| profileLevel | 0x7F | 不强制指定,让播放器自行解析 |
| 关键帧间隔 | 2秒~4秒 | 太长会增大seek难度,太短则压缩率下降 |
关于timeScale,一个经验值:如果用90000,时间戳精度更高,但换算起来容易出错;用1000,精度虽然只有毫秒级,但对一般录像场景完全够用,而且调试时一眼能看懂数值对应多少秒。
MP4WriteSample的签名大致是这样的:
MP4WriteSample( MP4FileHandle hFile, MP4TrackId trackId, const uint8_t* pBytes, uint32_t numBytes, MP4Duration duration, MP4Duration renderingOffset, bool isSyncSample );其中renderingOffset就是PTS相对DTS的偏移量,我的建议是:如果编码器没有B帧,直接传0,省事且稳定;如果有B帧,需要计算当前帧的延迟值。
3.3 Annex-B裸流转Length-Prefixed格式
编码器直接给出来的H.265裸流,一般是Annex-B格式,每个NALU前面带起始码。而libmp4v2写样本时,期望的是每个NALU前用4字节大端长度标识。所以必须先做格式转换,不然MP4文件里数据全是错位的。
转换逻辑其实很朴素,就是扫描起始码,分割出每个NALU,替换成“4字节长度 + NALU数据”。需要注意混用三字节和四字节起始码的情况——最安全的做法是统一按00 00 01起始码为基准识别NALU边界,同时向前多读一个字节判断是不是四字节起始码。下面这个函数就是我实际在用的解析逻辑:
static int annexb_to_lengthprefixed(uint8_t* buf, size_t size) { size_t src = 0; size_t dst = 0; while (src < size) { // 查找起始码 00 00 01 或 00 00 00 01 if (src + 3 < size && buf[src] == 0 && buf[src+1] == 0 && buf[src+2] == 1) { src += 3; } else if (src + 4 < size && buf[src] == 0 && buf[src+1] == 0 && buf[src+2] == 0 && buf[src+3] == 1) { src += 4; } else { src++; continue; } // 现在src指向NALU数据的起点,先记录位置 // 实际实现中需要先找到NALU结束位置,再回填长度 // 这里省略具体的NALU结束判定 } return 0; }这是一个示意框架,实际完整实现需要循环扫描并维护NALU边界,处理完所有NALU之后,原来起始码占用的字节被替换成4字节长度值,数据整体长度会发生变化,所以需要先估算输出缓冲区大小,或者原地处理时从后往前移位。我的做法是在编码器输出缓冲外再申请一块大内存来存放转换后的数据,避免原地移动带来的麻烦。
3.4 一个可运行的完整封装示例
下面是经过简化但能直接跑通的录制模块核心代码。先定义录制器结构体:
#include <stdio.h> #include <stdint.h> #include <string.h> #include <mp4v2/mp4v2.h> typedef struct { MP4FileHandle mp4; MP4TrackId track; uint32_t timeScale; uint32_t frameDuration; } H265Recorder;然后是初始化和关闭函数:
int recorder_init(H265Recorder* rec, const char* path, int width, int height, int fps) { rec->mp4 = MP4Create(path, 0); if (rec->mp4 == MP4_INVALID_FILE_HANDLE) { return -1; } rec->timeScale = 1000; rec->frameDuration = rec->timeScale / fps; // 如果能直接用H265接口,推荐这个 rec->track = MP4AddH265Track(rec->mp4, width, height); if (rec->track == MP4_INVALID_TRACK_ID) { MP4Close(rec->mp4); return -1; } MP4SetVideoProfileLevel(rec->mp4, 0x7F); return 0; } void recorder_close(H265Recorder* rec) { if (rec->mp4) { MP4Close(rec->mp4); rec->mp4 = NULL; rec->track = MP4_INVALID_TRACK_ID; } }写样本的接口是关键。实际调用前,先把Annex-B转为Length-Prefixed格式,然后传给MP4WriteSample。关键帧的判断由编码器告诉你,isKeyFrame为1时传true:
int recorder_write_frame(H265Recorder* rec, uint8_t* annexbData, size_t dataSize, int isKeyFrame) { // 这里需要调用 annexb_to_lengthprefixed 进行格式转换 // 转换结果会得到 lengthData 和 lengthSize // 然后: uint8_t* lengthData = /* 转换后的数据 */; size_t lengthSize = /* 转换后的数据长度 */; MP4Duration duration = rec->frameDuration; MP4Duration renderingOffset = 0; int ret = MP4WriteSample(rec->mp4, rec->track, lengthData, lengthSize, duration, renderingOffset, isKeyFrame ? true : false); return ret; }喂完所有帧之后,调用recorder_close收尾。生成的MP4文件用VLC、PotPlayer这类支持HEVC解码的播放器打开,就能正常播放。
3.5 关键帧样本里的参数集处理
上面代码里有一个核心细节没展开:当isKeyFrame为1时,必须在样本数据的开头带上VPS、SPS、PPS,顺序必须是VPS → SPS → PPS → IDR。
通常编码器在关键帧之前会输出这三类参数集NALU,你可以缓存住最近一组VPS/SPS/PPS,然后在写关键帧样本时把它们拼到IDR前面。如果漏掉这一步,第一种情况是播放器打开就报“无法识别的格式”;第二种情况更隐蔽,打开有画面但拖动进度条会花屏,因为播放器没拿到SPS/PPS就不知道如何随机访问。
这里有个小技巧:可以在初始化时就记录VPS/SPS/PPS的偏移和长度,之后每个关键帧样本直接复用,不需要每次都重新解析。但注意,编码器的参数集不是永远不变的,如果中途分辨率或帧率变化,参数集会重新输出,所以缓存时要带上“是否更新过”的标记。
4. 实测中踩过的坑与排查经验
4.1 播放器提示“无法识别的视频格式”
这是最容易遇到的错误,原因几乎都指向参数集缺失或顺序不对。排查顺序建议是:
- 用16进制工具打开MP4,定位hvcC box,检查里面VPS、SPS、PPS是否完整。
- 检查第一个样本,确认关键帧样本确实把参数集放在了IDR前面。
- 确认hvcC里的数据和外部的参数集一致。
我遇到过一次很奇怪的现象:用VLC能播,用系统自带播放器不能播。最后发现是因为hvcC里只放了SPS和PPS,没有VPS。VLC的容错性比较好,自动忽略;但系统播放器严格要求,直接拒绝。
4.2 录出来的文件有时长但没画面
这个现象通常指向NALU长度转换出错。Annex-B的起始码可能是4字节00 00 00 01,也可能混着3字节00 00 01。如果解析逻辑只处理了4字节起始码,遇到3字节编码的裸流就会算错NALU长度,整个mdat数据错位,播放器读到的全是无效数据。
排查技巧:把转换前后的数据用hexdump对比,看每个NALU的长度前缀是否合理。正常H.265的IDR帧大小应该在几KB到几十KB,异常时会出现长度数值特别大或者特别小的现象。
4.3 带B帧的视频播放时卡顿跳变
B帧本身不是问题,问题在于编码器输出顺序和写MP4时的顺序必须一致。如果编码器输出的帧是DTS顺序(显示顺序和DTS不同),而你不加处理直接把PTS填到renderingOffset,播放器就会在B帧处卡顿。
我建议的解法有两种:
- 关闭B帧。在编码器配置里把
bframes=0,代价是压缩率稍微下降,但省掉一整套帧重排逻辑。在嵌入式低功耗设备上,编码器本来也倾向于不开B帧,因为B帧增加编码延迟和内存消耗。 - 或者在应用层做帧缓存队列,按DTS大小排序后再往MP4WriteSample喂数据。
实测下来,低时延场景直接关B帧是最省心的方案,画质差距肉眼几乎看不出来。
4.4 长时间录制后文件损坏
录制几小时甚至几十小时后,文件打不开或者拷出来播放不了。主要原因有两个:一是SD卡写入速度跟不上码流峰值,数据积压;二是异常断电时moov box没有正常更新。
我的应对策略:
- 按固定时长切分文件,比如每5分钟一个录像文件,单文件损坏影响范围可控。
- 录制过程中定期调用
MP4Update或触发文件同步,把缓冲数据落盘。 - 结束录制时检查
MP4Close返回值,确保moov正常完成。
另外强调一个很多人忽略的点:SD卡长时间录像会产生严重碎片化,写入性能持续下降。长期运行的设备建议每周格式化一次存储卡,或者换成支持自动均衡磨损的工业级卡。
4.5 不同播放器兼容性差异
录完的MP4,最好用多个播放器同时验证。我实测的结论是:VLC和MPC-HC兼容性最好,几乎都能直接播;Android的ExoPlayer要强制指定MediaFormat.MIMETYPE_VIDEO_HEVC;iOS的AVPlayer在部分系统版本上对HEVC的支持也有差异。
如果目标场景是微信、网页播放,还得注意播放端是否支持HEVC。目前Web端Safari和Edge对HEVC支持较好,Chrome默认不支持(在大多数平台上)。如果你需要最大化兼容性,可以考虑封装时同时输出H.264版本,或者做好转码桥接方案。
5. 后记与一点经验
这套模块我实测下来最稳的配置组合是:编码器固定25fps、关闭B帧、码率控制选CBR,timeScale设1000,每帧duration固定40ms,关键帧间隔2秒。这样录出来的文件兼容性最好,拖动进度条流畅,各种播放器都能顺畅打开。
如果后续要加音频,只需要再往MP4里加一条AAC音轨,调用MP4AddAudioTrack传采样率和声道数,写入音频样本时保证和视频在同一个时间轴上即可。音画同步的核心还是时间戳口径统一,用毫秒做基准就够用。
再分享一个小技巧:调试阶段可以给录制模块加一个--dump参数,把每次写完的样本的PTS、DTS、大小、关键帧标记全部打出来。如果录像异常,先看日志里时间戳是否单调递增,再看关键帧间隔是否均匀,能省去大量排查时间。MP4封装这块,只要理解了box结构、NALU格式、时间戳机制三件事,剩下就是不断踩坑和填坑的过程。
本文还有配套的精品资源,点击获取