嵌入式H.265录像:libmp4v2封装MP4实践指南

嵌入式H.265录像:libmp4v2封装MP4实践指南 简介面向嵌入式与多媒体开发者的 C 语言 MP4 录制实现基于 libmp4v2 库在 ARM 平台完成 H265/HEVC 视频与 AAC 音频的封装写入覆盖从编码帧到 MP4 文件落盘的完整链路直接回应低码率高清录像的常见需求。包体共 104 个文件以 99 个头文件为主体分别对应 MP4 封装、ISO 媒体格式、MPEG-4 节点与 OD 描述等模块另含 2 个静态库、2 个 C 写入器源码及 1 个工程说明文件压缩包仅 2.11MB结构紧凑便于在嵌入式环境中移植与二次开发。目前已有 2519 人学习下载。通过这份源码可以掌握 libmp4v2 写 MP4 的 API 调用流程、H265 关键参数设置、AAC 音频流的交织方式以及 ARM 平台上涉及的内存限制与性能优化技巧头文件分区清晰也适合作为学习 MP4 复用器内部结构的入门材料尤其推荐给需要在嵌入式设备中实现录像功能的工程师。 做嵌入式录像功能的时候最容易让人卡住的地方往往不是编码器而是封装。编码器把画面压成 H.265 裸流之后你总得把它装进一个播放器能认的容器里MP4 就是最常见的选择。而 libmp4v2 这个 C 库正是很多老牌嵌入式方案里承担封装工作的那个角色。这篇文章就从实际项目出发讲讲怎么用 C 语言配合 libmp4v2 把 H.265 的码流录制成 MP4 文件包括 API 调用顺序、参数设置、时间戳处理以及我踩过的几个坑。如果你正准备在 Linux 或嵌入式环境里搞一个不依赖 FFmpeg 的轻量录像模块这篇应该能省你不少时间。1. 为什么选 libmp4v2 而不是 FFmpeg先聊一个绕不开的问题现在 FFmpeg 几乎全能为什么还要用 libmp4v2原因很实际一是体积二是依赖。FFmpeg 为了解码、编码、过滤、协议栈全都支持静态库编出来动辄几十兆就算裁剪也得费不少功夫。libmp4v2 是专门做 MP4 封装的库静态库通常只有几百 KB非常适合嵌入式 Linux、RTOS 这类资源紧张的环境。另外libmp4v2 的 API 设计非常直白核心就是 Create、AddTrack、WriteSample、Close 这样一个线性流程学起来快调试也容易。它不关心你的视频是怎么编码出来的只负责把 H.265 的 NAL 单元按照 MP4 的规范装进文件里这种“只管封装、不乱掺和”的定位反而让它在定制化录像方案里特别好用。还有一点值得说很多老方案里H.264 的录像模块就是基于 libmp4v2 写的现在只是编码器从 H.264 换成了 H.265封装层的改动其实不大。如果你已经有现成的 H.264 录像代码迁移到 H.265 的工作量比想象中小得多这也是我推荐先掌握 libmp4v2 的原因之一。不过要提醒一下libmp4v2 的主线版本已经比较老了对 H.265 的原生支持不完整所以实际项目中我们通常会用打了补丁的版本或者自己在封装层做一点兼容处理。这个后面会详细说。2. H.264 和 H.265 在封装层面的关键差异在动手写代码之前一定要先搞清楚 H.265 和 H.264 在 MP4 封装上的区别。很多从 H.264 迁移到 H.265 的人第一个坑就栽在这。H.264 的 MP4 封装需要往文件里写的是 SPS 和 PPS 这两个参数集对应的盒子叫 avcC。而 H.265 除了 SPS、PPS还多了一个 VPSVideo Parameter Set封装时写入的盒子叫 hvcC。VPS 主要负责描述视频层的整体信息比如档次、层数这是 H.265 新增的概念H.264 里没有。另一个重要区别是 NAL 单元头的长度。H.264 的 NAL header 是 1 个字节而 H.265 的 NAL header 是 2 个字节。这意味着在把编码器输出的 Annex-B 格式裸流转换成 MP4 需要的 length-prefixed 格式时解析逻辑要做对应调整不能直接用 H.264 的老代码。还有一个 H.265 特有的细节在 hvcC 盒子里参数集的排列顺序通常是 VPS、SPS、PPS这个顺序不能乱。有些播放器要求严格顺序不对直接黑屏或者提示文件损坏。下面这个表格可以帮你快速对比两者的差别对比项H.264H.265参数集类型SPS、PPSVPS、SPS、PPS封装盒子avcChvcCNAL header 长度1 字节2 字节关键帧 NAL 类型IDR (5)IDR_W_RADL (19)、IDR_N_LP (20)时间戳基准通常 90000通常 90000这些差异直接影响到 libmp4v2 调用时的参数选择尤其是添加视频轨时怎么传 SPS、PPS、VPS决定了文件能不能被正常播放。3. libmp4v2 的编译与环境准备libmp4v2 的源码在 GitHub 上能找到推荐用带 H.265 支持的维护分支比如 mp4v2 2.1.0 之后的某些 fork或者自己集成社区补丁。编译过程不复杂典型的 autotools 流程./configure --prefix/usr/local --enable-static --disable-shared make sudo make install嵌入式交叉编译时只需要把./configure加上--hostarm-linux-gnueabihf这类交叉编译参数再指定交叉编译器的 sysroot 路径。我一般还会加--disable-option-checking来跳过一些无关的平台检测。编译安装之后头文件会出现在/usr/local/include/mp4v2/mp4v2.h链接时加-lmp4v2就行。如果你用的是打了 H.265 补丁的版本头文件里通常能看到MP4AddH265VideoTrack这个函数声明这是判断版本是否支持 H.265 的最直接方法。让我给你看一个简单的工程配置CC gcc CFLAGS -I/usr/local/include -Wall -O2 LDFLAGS -L/usr/local/lib -lmp4v2 target: recorder recorder: recorder.c $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS)这里要注意如果库是动态链接的运行时需要把/usr/local/lib加进LD_LIBRARY_PATH否则会报找不到共享库。嵌入式环境如果磁盘紧张建议静态链接。4. 核心 API 调用流程拆解libmp4v2 封装 MP4 文件的核心流程可以概括为四步创建文件、添加视频轨、写入样本数据、关闭文件。看起来简单但每一步都有不少细节。4.1 创建 MP4 文件MP4FileHandle mp4 MP4Create(record.mp4, 0); if (mp4 MP4_INVALID_FILE_HANDLE) { printf(create mp4 file failed\n); return -1; }MP4Create的第二个参数是 flags传 0 表示默认行为。这里有个小细节如果文件已经存在函数默认会直接覆盖不会报错。如果业务上要求防止误覆盖需要自己提前做文件存在性检查。如果你要往已有的 MP4 文件里追加内容用MP4Modify而不是MP4Create。不过在录像场景里一般是一个文件对应一段录像所以MP4Create用得最多。4.2 添加 H.265 视频轨添加视频轨是最核心的步骤直接决定后面的样本能不能正确关联到编码参数。支持 H.265 的 libmp4v2 版本会提供MP4AddH265VideoTrackMP4TrackId track MP4AddH265VideoTrack( mp4, 90000, // timeScale时间基准通常 90000 90000, // sampleDuration这里每个样本 1 秒实际按帧率填 1920, // width 1080, // height vps, vps_len, // H.265 的 VPS 参数集 sps, sps_len, // SPS 参数集 pps, pps_len, // PPS 参数集 0, // profile 由库内部推导通常填 0 0, // level 0, // compatibility 0, // chroma_format 0, // bit_depth_luma 0, // bit_depth_chroma 0, // video_profile 0, // video_level 0, // video_compatibility 0, // video_chroma_format 0, // video_bit_depth_luma 0, // video_bit_depth_chroma 0, // video_framerate 0 // video_timescale );参数虽多但关键是 VPS、SPS、PPS 这三个参数集要正确传入。它们是从编码器或者码流里提取出来的不能自己随便拼。什么顺序呢VPS 在最前然后 SPS然后 PPS和 H.264 只传 SPS、PPS 的 API 有明显区别。如果你的 libmp4v2 版本没有MP4AddH265VideoTrack那就只能退回MP4AddVideoTrack通用接口然后手动构造 hvcC 盒子。这个操作比较麻烦强烈建议直接换支持 H.265 的库版本。4.3 写入样本数据视频轨添加完成后每来一帧编码数据就调用一次MP4WriteSample写入。这一步是录像的高频操作性能直接影响帧率。MP4WriteSample( mp4, track, sample_data, // 转换后的 NAL 数据长度前缀格式 sample_size, // 数据长度 MP4_INVALID_DURATION, // 默认使用 track 的 duration 0, // 起始时间偏移通常为 0 1, // 是否关键帧IDR 帧传 1其余传 0 0); // 是否是中国标准 AVS 视频填 0这里最关键的是sample_data的格式。MP4 内部存储 H.265 样本时每个 NAL 单元前面要加 4 字节的大端长度字段而不是用 Annex-B 的起始码。也就是说编码器输出的00 00 00 01起始码要替换成实际的 NAL 长度。我在项目里专门写了一个转换函数static uint32_t convert_annexb_to_length_prefixed(uint8_t *dst, const uint8_t *src, uint32_t src_len) { uint32_t dst_len 0; uint32_t i 0; while (i src_len) { if (i 3 src_len src[i] 0 src[i1] 0 src[i2] 0 src[i3] 1) { // 找到起始码 00 00 00 01NAL 单元从 i4 开始 uint32_t nal_start i 4; uint32_t nal_end nal_start; while (nal_end src_len) { if (nal_end 3 src_len src[nal_end] 0 src[nal_end1] 0 src[nal_end2] 0 src[nal_end3] 1) { break; } nal_end; } uint32_t nal_len nal_end - nal_start; if (nal_len 0) { dst[dst_len] (uint8_t)((nal_len 24) 0xFF); dst[dst_len] (uint8_t)((nal_len 16) 0xFF); dst[dst_len] (uint8_t)((nal_len 8) 0xFF); dst[dst_len] (uint8_t)(nal_len 0xFF); memcpy(dst dst_len, src nal_start, nal_len); dst_len nal_len; } i nal_end; } else if (i 2 src_len src[i] 0 src[i1] 0 src[i2] 1) { // 兼容 00 00 01 这种三字节起始码 uint32_t nal_start i 3; uint32_t nal_end nal_start; while (nal_end src_len) { if (nal_end 2 src_len src[nal_end] 0 src[nal_end1] 0 src[nal_end2] 1) { break; } nal_end; } uint32_t nal_len nal_end - nal_start; if (nal_len 0) { dst[dst_len] (uint8_t)((nal_len 24) 0xFF); dst[dst_len] (uint8_t)((nal_len 16) 0xFF); dst[dst_len] (uint8_t)((nal_len 8) 0xFF); dst[dst_len] (uint8_t)(nal_len 0xFF); memcpy(dst dst_len, src nal_start, nal_len); dst_len nal_len; } i nal_end; } else { i; } } return dst_len; }这个函数因为要同时兼容四字节和三字节起始码所以写得稍微长一点。实际使用时如果你的编码器保证只输出四字节起始码可以简化很多。关键是要把长度字段转成大端字节序这个顺序错了播放器基本是秒崩。4.4 处理时间戳和关键帧标志MP4WriteSample的几个参数里最容易忽略的是时间戳相关的设置。视频帧率如果是 30fps而timeScale是 90000那么每帧的 duration 应该是90000 / 30 3000。你可以在添加 track 时直接把 duration 设为 3000然后在MP4WriteSample里传MP4_INVALID_DURATION库会自动沿用 track 的默认值。但如果你录制的是可变帧率视频比如摄像头在光线不足时自动降帧那就不能依赖默认值每帧都要显式传正确的 duration。H.265 编码器通常会给出每个样本的占用时长直接用它的值就好。关键帧标志isSyncSample同样重要。只有 IDR 帧H.265 中 type 为 19 或 20才能标记为 1其他帧一律 0。如果标记错误播放器的拖动和 seek 功能就会不正常某些播放器甚至会花屏。4.5 关闭文件录像完成后调用MP4CloseMP4Close(mp4);这里有个容易忽视的点MP4Close会把缓冲的数据刷到磁盘并写入 moov 等关键盒子。如果录制过程中断电或者进程崩溃这个动作没有机会执行文件就等于废了。所以生产环境建议采用“先写临时文件结束后用rename改名”的策略能有效降低文件损坏的概率。5. 完整示例一个最简 H.265 录像模块把上面的流程串起来这里写一个简化的示例。假设编码器通过回调函数get_encoded_frame提供一帧 H.265 裸流格式是 Annex-B并且回调返回帧类型和是否关键帧。#include stdio.h #include stdlib.h #include string.h #include stdint.h #include mp4v2/mp4v2.h typedef struct { uint8_t *data; uint32_t len; uint32_t is_key; } encoder_frame_t; typedef int (*get_frame_cb)(encoder_frame_t *frame, void *userdata); static uint32_t convert_to_length_prefixed(uint8_t *dst, const uint8_t *src, uint32_t src_len); static void extract_parameter_sets(const uint8_t *sps, uint32_t sps_len, const uint8_t *pps, uint32_t pps_len, const uint8_t *vps, uint32_t vps_len, MP4FileHandle mp4, MP4TrackId track) { // 实际项目中参数集一般在编码器初始化时获取这里只是演示如何传给 track (void)mp4; (void)track; printf(SPS len%u, PPS len%u, VPS len%u\n, sps_len, pps_len, vps_len); } int h265_mp4_record(const char *filename, int width, int height, int fps, get_frame_cb cb, void *userdata) { MP4FileHandle mp4 MP4Create(filename, 0); if (mp4 MP4_INVALID_FILE_HANDLE) { return -1; } // 这里用示例参数集实际项目里必须从编码器获取真实 VPS/SPS/PPS uint8_t vps[] {0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x5d, 0x99, 0x09, 0x04}; uint8_t sps[] {0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x5d, 0xa0, 0x08, 0x02, 0x10, 0xe9}; uint8_t pps[] {0x44, 0x01, 0xc0, 0xa5, 0x92}; uint32_t timescale 90000; uint32_t duration timescale / fps; MP4TrackId track MP4AddH265VideoTrack( mp4, timescale, duration, width, height, vps, sizeof(vps), sps, sizeof(sps), pps, sizeof(pps), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); if (track MP4_INVALID_TRACK_ID) { MP4Close(mp4); return -1; } encoder_frame_t frame; uint8_t *converted_buf (uint8_t *)malloc(1024 * 1024); if (!converted_buf) { MP4Close(mp4); return -1; } while (cb(frame, userdata) 0 frame.len 0) { uint32_t converted_len convert_to_length_prefixed( converted_buf, frame.data, frame.len); if (converted_len 0) { int rc MP4WriteSample( mp4, track, converted_buf, converted_len, MP4_INVALID_DURATION, 0, frame.is_key ? 1 : 0, 0); if (rc ! 0) { printf(write sample failed, rc%d\n, rc); break; } } } free(converted_buf); MP4Close(mp4); return 0; }这里有一个典型问题示例里的 VPS/SPS/PPS 是我随便写的占位数据真实项目里一定不能这样。参数集要从编码器初始化完成后获取并且要和实际编码分辨率、帧率、档次匹配。用错误的参数集写出来的 MP4播放器基本无法识别。另外示例里我一次性分配了 1MB 转换缓冲区如果一帧数据超过 1MB比如 4K 高码率转换就会溢出。实际代码里应该先根据frame.len动态分配或者把缓冲区设成至少frame.len (frame.len/64 1) * 4的大小这个公式考虑的是每一小段 NAL 都有长度字段的冗余。6. 常见问题与排查技巧实录下面这些坑都是我实际调试时遇到过的按频率排序你可以当成速查表来用。症状可能原因排查方法播放器直接提示文件损坏VPS/SPS/PPS 错误或顺序不对hvcC 盒子生成失败用mp4box -info或ffprobe查看轨道信息跟正常文件对比参数集能播但拖动不准确isSyncSample标记错误关键帧没标 1打日志确认每个 IDR 帧是否传 1画面花屏、马赛克NAL 长度字段字节序写反检查convert_to_length_prefixed确认用了大端播放时快进快退很慢MP4AddH265VideoTrack的 width/height 和真实分辨率不符对比 SPS 里的宽高信息一般可以从 SPS 解析出来写入中途程序崩溃文件全废MP4Close没被调用moov 没写进去用临时文件 rename策略崩溃也能保留前一段完整录像帧率不对播放像加速/慢放timeScale和duration不匹配默认用 90000每帧 duration 必须是90000 / 帧率的整数倍H.265 参数集提取不完整编码器可能在关键帧前面才输出 VPS开头几帧漏了在拿到第一个关键帧时把 VPS/SPS/PPS 单独存下来再创建 track这里单独说一下 MP4 大文件的问题。默认情况下MP4 的 moov 盒子在文件结尾这种方式叫 Non-FastStart适合录制场景因为边录边追加数据最方便。但如果做点播尤其是 Web 播放需要把 moov 挪到文件开头也就是 FastStart。libmp4v2 本身不直接支持这个操作通常要用MP4Optimize或者后续再用qt-faststart工具跑一遍。如果文件不大这点影响可以忽略。另外一个经验是MP4WriteSample不要在主线程里频繁调用导致卡顿。虽然它内部有缓冲但在磁盘 IO 慢的嵌入式设备上大量数据写入仍可能阻塞几十毫秒。建议在编码线程和封装线程之间加一个环形缓冲编码线程只负责把帧塞进队列封装线程负责写文件这样帧率更稳定。最后再分享一个我后来才意识到的重要细节libmp4v2 处理 H.265 时MP4WriteSample的最后一个参数是 AVS 标志很多 H.264 代码里这里写 0直接搬到 H.265 没问题但要小心不要随手填成 1。填了 1 的话播放器会试图按音视频编码标准 AVS 来解析几乎必然失败。7. 实际项目中的扩展建议如果你只是录一个固定分辨率的视频上面的示例已经够用了。但实际录像设备一般还有音频、分段、按时间索引这些需求。音频轨道用MP4AddAudioTrack添加AAC 音频的封装逻辑和视频类似只是参数换成采样率、声道数、采样点数。音视频同步的关键在于都用同一个timeScale基准。视频用 90000音频的timeScale直接用采样率比如 44100 或 48000然后根据 PTS 把音频样本写入对应的时间位置。libmp4v2 内部会维护 media time 和 movie time 的换算这个交给库就行前提是每次写入的 PTS 要单调递增不然同步就乱了。分段录制的话我的做法是每到一个分段点就MP4Close当前文件然后用新的文件名重新MP4Create。文件命名建议带时间戳形如REC_20250110_153000.mp4方便后面做索引。如果要求不间断连续录像可以考虑双文件交替写一个文件写另一个文件 Prepare切换时做一次原子 rename这样几乎不会丢帧。如果你的业务需要从裸流中实时提取 VPS/SPS/PPS那不能像我示例里那样写死。比较靠谱的做法是在编码器初始化完成后主动请求一次参数集如果编码器不支持主动获取那就从关键帧所在的 Annex-B 流里解析遇到 VPS、SPS、PPS 时复制一份保存起来后续创建 track 时用这组缓存。注意有些编码器在不同分辨率切换时参数集会动态变化这时候必须重新解析并重建 track。在没有 H.265 参数集动态切换支持的情况下干脆在分辨率切换时关闭文件开一个新文件逻辑简单也不容易出错。8. 踩坑记录时间戳引起的“高级事故”最后讲一个我印象特别深的排查经历。当时录出来的 H.265 MP4 文件在 VLC 里能正常播但放到某播放器平台上画面总是一顿一顿的像掉帧。一开始怀疑是编码码率不够后来发现码率曲线非常平稳排除了编码问题。反复对比之后问题出在了时间戳上。帧率虽然是 25fps但编码器的 PTS 并不严格等间隔帧间间隔偶尔会多加一个 tick。我当时的写法是每个样本都传了真实的 PTS但转成 duration 时用了整数除法导致某些样本的 duration 变成了 3599 而不是 3600累积下来出现周期性跳动。解决方式有两个要么用 libmp4v2 的时间戳接口直接设置样本的起始时间和时长要么在传给MP4WriteSample时把捕获到的duration四舍五入到整数 tick。这里有个小技巧用(pts_delta timescale / (2 * fps)) / (timescale / fps)做四舍五入误差最多只有半 tick人眼完全感知不到。这个案例也让我意识到封装这件事看起来简单但时间戳和参数集这种细节才是决定一个录像方案“能用”和“好用”差距的地方。越是底层的东西越不能只看表面流程。如果你想在 H.265 和 MP4 这条路上走得更远建议抽空把 MP4 的 box 结构、hvcC 盒子的二进制布局这两块啃一遍。虽然 libmp4v2 帮你干了大部分活但一旦遇到库不支持的特性知道盒子底层怎么组织你就能自己动手修补而不是被库的能力边界卡死。我自己就是在一次需要给 MP4 加自定义 metadata 盒子时被迫把 box 结构学了一遍后来排查问题明显顺手了很多。本文还有配套的精品资源点击获取