拓冰建站拓冰建站
首页 / 资讯中心 / 正文

用C语言和libmp4v2将H.265裸流封装为MP4的实践

简介面向嵌入式与多媒体开发者的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录像需求为什么我选了libmp4v21.1 和FFmpeg相比差距在哪做视频录像最纠结的就是封装库选型。FFmpeg确实功能全但代价也大。先看一组直观对比对比项libmp4v2FFmpeg静态库体积几百KB级别十几MB甚至更大编译依赖基本无第三方依赖需要x264/x265等多个编解码库功能范围只做MP4封装/解封装转码、滤镜、协议、封装全都有API复杂度简单接口数量少层次多抽象复杂内存占用很低较高适用场景嵌入式单点封装PC工具、转码服务、播放器如果你只需要把已经编码好的H.265码流封装成MP4FFmpeg的编解码能力完全用不上。而且嵌入式设备就那么大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 01H.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文件的三个核心boxMP4本质上是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 关键参数的选择与推导初始化轨道时几个参数是绕不开的我直接给出一份推荐配置参数推荐值说明视频宽度/高度和编码器输出一致不一致会导致播放器拉伸或黑屏timeScale1000时间戳单位1ms便于换算frameDuration1000 / fps每帧持续时长如25fps时为40profileLevel0x7F不强制指定让播放器自行解析关键帧间隔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[src1] 0 buf[src2] 1) { src 3; } else if (src 4 size buf[src] 0 buf[src1] 0 buf[src2] 0 buf[src3] 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时传trueint 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帧。在编码器配置里把bframes0代价是压缩率稍微下降但省掉一整套帧重排逻辑。在嵌入式低功耗设备上编码器本来也倾向于不开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_HEVCiOS的AVPlayer在部分系统版本上对HEVC的支持也有差异。如果目标场景是微信、网页播放还得注意播放端是否支持HEVC。目前Web端Safari和Edge对HEVC支持较好Chrome默认不支持在大多数平台上。如果你需要最大化兼容性可以考虑封装时同时输出H.264版本或者做好转码桥接方案。5. 后记与一点经验这套模块我实测下来最稳的配置组合是编码器固定25fps、关闭B帧、码率控制选CBRtimeScale设1000每帧duration固定40ms关键帧间隔2秒。这样录出来的文件兼容性最好拖动进度条流畅各种播放器都能顺畅打开。如果后续要加音频只需要再往MP4里加一条AAC音轨调用MP4AddAudioTrack传采样率和声道数写入音频样本时保证和视频在同一个时间轴上即可。音画同步的核心还是时间戳口径统一用毫秒做基准就够用。再分享一个小技巧调试阶段可以给录制模块加一个--dump参数把每次写完的样本的PTS、DTS、大小、关键帧标记全部打出来。如果录像异常先看日志里时间戳是否单调递增再看关键帧间隔是否均匀能省去大量排查时间。MP4封装这块只要理解了box结构、NALU格式、时间戳机制三件事剩下就是不断踩坑和填坑的过程。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门