简介一套基于海康威视SDK和C开发的实用DEMO面向正在学习海康二次开发的开发者解决从设备回调获取标准H264裸码后实时预览、保存.h264文件并封装MP4文件的问题。工程代码包含播放控制、MP4封装、设备接入等模块关键参数可自行修改便于结合自己的设备和场景调试。资源共118个文件压缩包约42.62MB主要包含42个dll运行库、20个头文件、10个lib库、3个cpp源码及可执行程序目录结构完整可直接打开工程编译运行。dll用于SDK运行环境头文件和lib提供接口声明与链接cpp为具体实现另附示例h264和mp4输出文件便于核对封装效果。目前已有1843人学习下载。适合有C基础、希望快速上手海康SDK裸码处理的开发者对照源码可理解回调获取码流、封装MP4和本地落盘的完整流程也可参考作者另一份H264文件封装MP4的DEMO加深对容器封装细节的认识。1. 项目定位一包 H264 裸码如何同时变成 MP4、.h264 和实时预览手头这个任务来自一个安防设备项目设备网络摄像机SDK 实时回调给出一包一包的 H264 裸码流我需要把这些裸码同时变成三种产物——标准 MP4 录像文件、可以直接丢给播放器播放的 .h264 文件以及屏幕上的实时预览。听起来不复杂但真正踩完一圈才发现SDK 回调里的数据处于“能看不能播”的状态回调拿到的不是完整 H264而是按片切好的码流要先做 NALU 解析和重组再决定每一块去哪里任何一个环节出错都会导致产出的文件打不开。这个项目的主线就是把“裸码工程化”的标准套路走通一遍适合正在做网络摄像头接入、C 视频录制、安防产品二次开发的工程师拿来当底稿改。和很多同类项目不同的是这里没有一上来就依赖 FFmpeg 全家桶而是自己实现了轻量 MP4 封装器只解析 H264 一种格式。这样做的收益是回调线程里发生什么、封装线程里写什么字节全程可观测问题定位成本很低。下面我从整体架构讲起再逐个拆解核心代码。2. 整体架构与数据结构三路输出共享一路裸码流2.1 架构选型为什么不能直接把裸码写进 MP4项目里最初的做法非常简单回调收到 H264 就直接往文件里写改个后缀名 mp4 就拿去给验收人。结果十有八九是打不开。原因是标准 MP4 并不直接存 Annex-B 格式的 H264而是要求把码流包装成每个 NALU 前面带长度前缀的 AVCC 格式并且在 moov 里带上 SPS、PPS 组成的 avcC 描述。而 .h264 文件却正好相反保存的时候需要的就是带起始码的原始字节流最好保留完整的 SPS/PPS。同一份裸码要同时喂给这两种格式就必须在中间层做一次“流整形”。所以这个项目的架构不是“写一个 MP4 写入器”而是一条管线SDK 回调 → H264 NALU 解析 → 三路分发MP4 封装器转换成 AVCC 格式并生成容器 Box裸码保存器Annex-B 原样写入 .h264解码渲染器交给解码器做实时预览很多团队到这里会直接引入 FFmpeg我没有上来就引而是先自己实现了一个轻量 MP4 Muxer。为什么这么做因为接入厂商 SDK 时回调线程非常敏感FFmpeg 的avformat_write_header和av_interleaved_write_frame内部有锁和缓冲如果直接在回调里调用一旦出错容易干扰取流另一方面轻量封装器只处理 H264 一种码流代码只有几百行出问题可以直接从 Box 字节偏移定位。如果项目工期紧、只想省事用 FFmpeg 也无妨第 6 章我会给一个兼容替换的思路。2.2 线程模型不要让回调线程做任何阻塞操作整套链路一共三条线程线程 ASDK 回调线程负责把码流数据转成 MediaFrame 后压入队列。线程 B封装写入线程负责从队列取帧写 MP4 和 .h264。线程 C预览渲染线程负责从队列取帧送解码器和渲染器。线程间共享的数据结构是核心先定义它// 定义一帧裸码数据跨线程传递的最小单位 struct MediaFrame { uint8_t* data; // 帧数据含起始码 uint32_t size; // 帧大小不含仅供解码使用的 padding uint64_t pts; // 显示时间戳毫秒 uint64_t dts; // 解码时间戳毫秒 bool isKeyFrame; // 是否为关键帧 bool isSpsPps; // 是否包含 SPS/PPS bool isConfig; // 是否属于配置头 };逻辑说明MediaFrame 是三个下游模块共享的数据格式。回调只做“拷贝数据塞入队列”的操作不写文件、不调用解码器这样能保证 SDK 的实时取流不被磁盘 I/O 拖垮。字段中isSpsPps标记 SPS/PPS 属于配置信息MP4 封装器拿到它时不会当作普通帧写入 mdat而是收集起来等待写 moov.h264保存器则可以原样把这几个字节追加到文件头部保证纯裸流文件能被播放器解码。参数说明pts/dts 使用毫秒因为后面 MP4 的 mvhd 时间基会设为 1000避免换算误差。isKeyFrame用于决定是否写关键帧索引没有关键帧索引的 MP4 在拖动播放进度条时会失效。isConfig在拿到系统头时置为 true这样封装线程可以区分“需要写入 moov 的配置数据”和“需要写入 mdat 的视频帧”。跨线程队列我用的是带互斥锁和条件变量的环形队列class FrameQueue { public: bool Push(const MediaFrame frame) { std::lock_guardstd::mutex lock(mtx_); if (frames_.size() max_size_) { // 队列满了丢弃最老的 P 帧而不是丢关键帧 DropOlderPFrames(); } frames_.push_back(frame); cv_.notify_one(); return true; } bool Pop(MediaFrame* frame) { std::unique_lockstd::mutex lock(mtx_); cv_.wait(lock, [this] { return !frames_.empty() || closed_; }); if (frames_.empty()) return false; *frame frames_.front(); frames_.pop_front(); return true; } private: std::mutex mtx_; std::condition_variable cv_; std::dequeMediaFrame frames_; int max_size_ 128; };逻辑说明DropOlderPFrames的实现是先从队头开始扫找到第一帧isKeyFrame false的帧直接丢弃因为在视频编码里丢掉一个 P 帧只影响局部画面但如果你丢了关键帧解码器会一直等到下一个 IDR 才能恢复画面卡顿时间不可控。队列容量 128 是按 4Mbps 码流估算的一帧最坏情况约 64KB128 帧大约是 8MB 内存完全可接受。队列设计踩过的关键点条件变量必须配合closed_标志否则线程退出时Pop会一直阻塞Push里不能拷贝整个缓冲区的长度只压入指针和内存偏移数据本身由上层管理否则内存拷贝开销会吃掉 CPU。3. 核心模块拆解H264 解析、MP4 Box 封装与 .h264 落盘3.1 从回调缓冲区到 NALU 划分解析 H264 基本流的起点厂商 SDK 回调返回的缓冲区通常是按复合流格式给的常见的布局是“头部字段 payload”头部里会标明这次数据是系统头还是视频数据。拿到视频数据后第一件事就是把连续字节流切成 NALU。H264 的 Annex-B 格式中NALU 之间通过起始码分隔起始码有两种长度00 00 013 字节和00 00 00 014 字节常见做法是统一按 4 字节识别遇到 3 字节就跳过。下面这个函数负责把回调缓冲区分割成 NALU// 传入回调缓冲区解析出一个个 H264 NALU 并交给回调处理器 void ParseH264Stream(uint8_t* data, uint32_t size, std::functionvoid(NaluType, uint8_t*, uint32_t) onNalu) { uint32_t i 0; uint32_t start 0; bool hasPrefix false; while (i 4 size) { if (data[i] 0x00 data[i1] 0x00 data[i2] 0x01) { if (hasPrefix i - start 0) { // 上一个 NALU 结束于 i长度为 i - start DispatchNalu(data start, i - start, onNalu); } start i 3; // 跳过起始码若后续是 00 00 00则 re-align if (i 0 data[i-1] 0x00) { start i; // 实际是 4 字节起始码 } hasPrefix true; i 3; continue; } i; } // 处理最后一段 if (hasPrefix size - start 0) { DispatchNalu(data start, size - start, onNalu); } }逻辑说明函数每次扫描到起始码就认为前一个 NALU 已经结束把[start, i)区间交给DispatchNalu。4 字节起始码的处理方式是通过检查当前位置前一个字节是否为00来决定 start 偏移这样可以在不额外分配内存的情况下完成边界划分。参数说明onNalu是回调函数NALU 类型通过首字节的低 5 位判断——1 表示非 IDR 切片5 表示 IDR 关键帧7 表示 SPS8 表示 PPS。在DispatchNalu里我会做这样一件事void DispatchNalu(uint8_t* data, uint32_t size, std::functionvoid(NaluType, uint8_t*, uint32_t) onNalu) { uint8_t type data[0] 0x1F; if (type 7 || type 8) { // 收集 SPS/PPS供 MP4 的 avcC box 使用 config_buffer_.Append(data, size); onNalu(NALU_CONFIG, data, size); } else if (type 5) { // IDR 帧保留到 MP4 中作为随机访问点 onNalu(NALU_KEYFRAME, data, size); } else { onNalu(NALU_OTHER, data, size); } }这段代码是整个项目的分流核心SPS/PPS 走NALU_CONFIG分支IDR 走NALU_KEYFRAME分支其余 P 帧、B 帧走普通分支。实际工作上遇到过部分厂商 SDK 会隐藏 SPS/PPS不通过回调给出只在连接初始化时给一次这种情况下就必须在缓存里持久缓存一份配置数据否则 MP4 写出来就是废文件。3.2 MP4 封装实现ftyp、moov、mdat 三个 Box 的写入时机轻量 MP4 Muxer 只要处理四个 Boxftyp文件类型、mdat媒体数据、moov包含 mvhd、trak、mdia、minf、stbl 等子 Box、以及 stbl 里的 stts、stsz、stco时间戳、帧大小、帧偏移。标准播放器能容忍的 Box 顺序是 ftyp 在最前moov 可以在 mdat 之前也可以在 mdat 之后。但实测 VLC 和系统原生播放器对 moov 在后容忍度更高的是 VLCWindows Media Player 反而严格要求 moov 在前。稳妥做法是边写边落 mdat文件结束时回填 moov。代码里的核心封装器定义如下class Mp4Muxer { public: bool Open(const char* filename, int width, int height) { fp_ fopen(filename, wb); // 先写 32 字节的 ftyp boxmajor_brand 为 isom WriteBoxHeader(ftyp, 24); WriteFourCC(isom); WriteUint32(0x200); // minor version WriteFourCC(isom); WriteFourCC(iso2); WriteFourCC(avc1); mdat_offset_ ftell(fp_); WriteBoxHeader(mdat, 0); // 先预留 8 字节头 return true; } void WriteH264Frame(const MediaFrame frame) { if (frame.isSpsPps) { // 暂存 avcC 信息不写入 mdat config_.AppendAvcC(frame.data, frame.size); return; } // 把 Annex-B 起始码去掉替换为 4 字节长度前缀 uint32_t nal_size frame.size - (frame.isKeyFrame ? 4 : 3); WriteUint32(nal_size); fwrite(frame.data (frame.isKeyFrame ? 4 : 3), nal_size, 1, fp_); sample_count_; total_bytes_ 4 nal_size; } bool Finalize() { // 回到 mdat 头部写入真实长度 uint64_t mdat_size ftell(fp_) - mdat_offset_; fseek(fp_, mdat_offset_, SEEK_SET); WriteUint32((uint32_t)mdat_size); fseek(fp_, 0, SEEK_END); // 写 moov box内部包含 mvhd/trak/mdia/minf/stbl WriteMoovBox(); fclose(fp_); return true; } private: FILE* fp_; uint64_t mdat_offset_; int sample_count_; uint64_t total_bytes_; };逻辑说明WriteH264Frame完成的是 Annex-B 到 AVCC 的转换。Annex-B 里每个 NALU 前是起始码AVCC 里每个 NALU 前是一个 32 位大端长度字段。isKeyFrame判断起始码长度时用了不同的偏移是因为 IDR 帧前往往是 4 字节起始码而普通 P 帧经常是 3 字节起始码为了严谨应该在解析阶段记录每个 NALU 的起始码长度而不是依赖类型猜测。参数说明mdat_offset_在写入前记录 mdat Box 的头部位置Finalize时先回填 mdat 真实大小再写 moov。total_bytes_用于计算 stsz Box 里的样本总大小stco Box 记录每个样本在文件中的绝对偏移这个偏移必须在所有 mdat 数据写完、且确定没有其他写入之后才能计算所以 stco 通常放在 moov 最后生成。WriteMoovBox 内部有一个细节标准 MP4 要求 stts 表里时间戳是 delta 序列也就是“这一帧和前一个帧的时间差”而不是绝对时间戳。你从 SDK 拿到的是绝对毫秒时间必须先转成差值void Mp4Muxer::WriteStts(std::vectoruint64_t dts_list) { // dts_list 里是累积的毫秒时间戳 uint32_t last dts_list[0]; for (size_t i 1; i dts_list.size(); i) { uint32_t delta (uint32_t)(dts_list[i] - last); // 写入在线时间表中delta 单位为毫秒 last dts_list[i]; } }逻辑说明如果直接把绝对时间戳写进 stts播放器会认为第一帧时间就是 1000 毫秒、第二帧是 4000 毫秒于是视频刚打开时会出现几秒黑屏。MP4 里所有时间信息都基于增量绝对时间只出现在 mvhd 的 creation_time 等字段中。3.3 保存 .h264直接落 Annex-B 字节流就够了和 MP4 的 AVCC 转封装相反保存 .h264 文件只需要把回调缓冲区里的 NALU 还原成“起始码 数据”的 Annex-B 字节流直接追加写入文件。这个模块非常简单但也最容易忽略一点SPS/PPS 必须写在文件开头且不能只写一次很多播放器打开文件后直接抽第一帧数据如果第一帧不是 SPS/PPS解码器会找不到上下文画面一直黑。实现如下class H264FileSaver { public: bool Open(const char* filename) { fp_ fopen(filename, wb); return fp_ ! nullptr; } void WriteConfig(const uint8_t* sps, uint32_t sps_len, const uint8_t* pps, uint32_t pps_len) { WriteAnnexB(sps, sps_len); WriteAnnexB(pps, pps_len); } void WriteFrame(const MediaFrame frame) { // 回调里已经是带起始码的数据直接原样写 fwrite(frame.data, frame.size, 1, fp_); fflush(fp_); } private: void WriteAnnexB(const uint8_t* data, uint32_t len) { const uint8_t startcode4[4] {0x00, 0x00, 0x00, 0x01}; fwrite(startcode4, 1, 4, fp_); fwrite(data, 1, len, fp_); } FILE* fp_; };逻辑说明这里有个微妙点厂商回调在传送 IDR 帧时有的会带上 SPS/PPS有的不会。安全做法是在连接成功、第一次收到系统头时就把 SPS/PPS 存入内存之后每个关键帧写入前都判断一下文件里是否已有配置数据没有就把缓存的 SPS/PPS 补写入。用fflush是会话完成时的及时落盘如果每次帧都 flush磁盘速度跟不上 25 帧编码需注意频率。参数说明len是数据部分的长度起始码是 4 字节实际验证中有播放器只认 4 字节起始码3 字节的在某些拉流播放器中会导致首帧解析不到 NALU 边界。4. 实时预览链路解码、缩放与渲染的线程同步4.1 实时预览的关键路径解码器与渲染器的选择实时预览和录像最大的不同是录像可以接受 200ms 延迟预览要求从摄像头采集到屏幕显示的延迟控制在 100ms 以内。我用的是厂商 SDK 自带的解码库它直接把 H264 码流解码成 YUV 数据省去自己集成 x264/FFmpeg 解码器的复杂度。拿到 YUV 之后有两个去向一是直接把 YUV 交给渲染器显示二是先缩放再显示。渲染器选了 DirectX 的纹理渲染路径因为 GDI 直接画 YUV 开销大且容易出现撕裂。预览线程的循环结构如下void PreviewLoop(FrameQueue* queue) { DecoderHandle dec CreateDecoder(640, 480, AV_PIX_FMT_YUV420P); while (running_) { MediaFrame frame; if (!queue-Pop(frame)) break; // 解码一帧输出到 YUV 帧缓冲 YUVFrame* yuv DecodeFrame(dec, frame.data, frame.size); if (!yuv) continue; // 非关键帧或解码失败则丢弃 // 上传纹理并绘制 RenderYUVTexture(yuv-data[0], yuv-linesize[0], yuv-width, yuv-height); } DestroyDecoder(dec); }逻辑说明DecodeFrame每帧只回写一次IDR 帧在解码器里会触发参考帧重建耗时明显长于 P 帧。为了不让渲染线程阻塞代码里会在解码前判断frame.isKeyFrame如果为 true 就先清理解码器缓存避免上一帧残留造成马赛克。参数说明640, 480是预览分辨率实际项目中应与主码流或子码流对应。很多设备有主码流和子码流双路输出预览阶段可以拉子码流降低解码负载录像阶段用主码流保证清晰度这个策略在移动端尤其常见。如果设备只给一路码流那就把预览和录像共用同一个解码器输出通过克隆 YUV 数据避免两路同时解码。4.2 三路输出的同步帧缓冲队列与丢帧策略实时预览、MP4 封装、H264 保存共用同一个 MediaFrame 队列但消费速度不同。封装线程写磁盘最慢渲染线程需要实时显示如果封装线程排队太久渲染线程拿到的都是旧帧延迟就会拉高。我的处理方案是放弃“完全同步”而是给每个下游一个独立的消费指针struct FrameMeta { uint64_t pts; bool isKeyFrame; bool isConfig; }; // 封装线程和渲染线程各自记录已消费的 pts FrameMeta last_mux_pts_, last_render_pts_;逻辑说明当封装线程落后渲染线程超过 1 秒时就把封装线程的读指针直接跳到渲染线程当前帧丢弃中间帧。对 MP4 文件来说被跳过的帧会成为解码异常点但因为监控录像本身预览价值高于逐帧完整性这种做法在安防场景里可接受。如果是执法记录仪或需要逐帧回放的项目则不能跳帧应该在入队时限制封装线程带宽比如把 MP4 和 .h264 的写入改成内存缓冲池异步刷新。参数说明跳帧后需要更新 stts 的时间戳序列否则时间戳断档会让播放器认为视频有停顿。具体做法是在WriteH264Frame里记录本次跳帧的dts_delta累积到下一帧写入时一并补偿。5. 避坑与排查五个常见异常背后的真实踩坑记录5.1 黑屏MP4 缺少 SPS/PPS 的 avcC 信息现象生成的 MP4 文件在 VLC 里能打开但画面一直黑屏只有声音用 ffprobe 看有视频流但流信息里没有profile和level。原因MP4 的 avcC Box 没有写进去。我的 Muxer 只在isSpsPps为 true 时保存配置但厂商 SDK 在回调里不会每次都带上 SPS/PPS只有连接建立后第一次推流时给一次。而我的 OpenMP4 是在收到第一帧 IDR 后才调用这时候配置数据已经过去导致 avcC 缺失。解决在接收回调的第一个系统头里立刻调用Mp4Muxer::SetSpsPps即使文件还没开始写也先把配置缓存住Open MP4 文件时先判断缓存是否为空为空则阻塞等待配置数据超时 3 秒再放弃。5.2 时长翻倍时间戳单位混用现象播放器显示总时长为 1 分 40 秒实际视频只有 50 秒且播放速度明显偏慢。原因MP4 中 mvhd 的时间基我设置的是 1000但 stts 里写入的时间戳增量直接用了 SDK 回传的 90000 单位数值。90000 除以 1000 等于 90 倍所以播放时长变成 1.8 倍。解决在入队前统一时间戳换算所有 MediaFrame 进入队列之前就换算成毫秒MP4 封装和渲染线程只能消费毫秒。这样排查问题时只要检查队列入口一个位置的时间戳即可不用在多个模块里各自换算。5.3 内存泄漏回调里直接做了写文件现象程序运行 2 小时内存占用从 80MB 涨到 800MB最后设备取流卡死。原因回调线程里直接调用了fwrite和Mp4Muxer::WriteH264Frame而fwrite不是线程安全的内存的扩容发生在 fwrite 内部缓冲同时 muxer 也在这样一个线程里工作导致一个回调周期内出现大量分配碎片。解决回调线程只把数据拷贝到 MediaFrame 塞进队列封装操作完全交给队列消费者线程。内存曲线从“持续上升”变成“稳定上下波动”之后这个问题才算真正收口。5.4 预览延迟 3 秒缓冲区堆栈过大现象画面从摄像头实际变化到屏幕显示延迟明显超过 3 秒手动把摄像头转向后要等好几秒画面才跟随。原因FrameQueue 容量 128 帧但每一帧在 IP Camera 上是按 25fps 进的128 帧相当于 5 秒数据。渲染线程处理不过来时队列一直是满的新帧不断被丢掉渲染线程只能消费 5 秒之前的老帧。解决把队列 max_size 降到 16同时预览渲染线程检测到队列满时直接Clear()清空保证只缓存最近 0.6 秒的帧。实际操作后延迟降到 300ms 左右人眼基本无感。5.5 .h264 文件开头花屏起始码没有处理干净现象用某播放器打开保存的 .h264 文件前 2 秒花屏之后恢复正常。原因写入 .h264 时有的帧在回调里已经带了起始码有的帧带的是长度前缀厂商 SDK 的复合流格式不一致我无脑fwrite(frame.data, frame.size, 1, fp_)导致这些帧的起始码只有 3 字节甚至没有起始码解码器前 2 秒一直在找边界。解决在DispatchNalu阶段把所有 NALU 都统一转换为起始码 数据格式再写入 .h264 文件。转换时统一使用 4 字节起始码这样常规播放器都能快速定位。6. 二次开发把录像模块改造成可配置的多路录制框架6.1 扩展点把裸码保存和 MP4 封装拆成独立策略类现在整个项目的核心类Mp4Muxer、H264FileSaver、PreviewLoop都是相互独立的。二次开发第一步我建议把它们统一到一个RecorderInterface下class RecorderInterface { public: virtual bool Open(const RecordOptions opt) 0; virtual bool WriteFrame(const MediaFrame frame) 0; virtual bool Close() 0; };这样后续加 TS 封装器、FLV 封装器、甚至直接转发到 RTMP都只需要新增一个实现类不需要动回调线程和队列逻辑。我一般会在每个实现类里维护自己的错误码比如WriteFrame返回REC_ERR_TIMEOUT时上层回收资源避免错误扩散。6.2 改造示例增加分段录制与定时备份分段录制是安防场景最常加的改造。最简单的方式是让Mp4Muxer::Finalize触发时判断当前文件时长超过设定值就关闭当前文件新建下一个文件。关键点是分段点必须落在关键帧上不能在 P 帧处硬切否则会出现首帧不可解码。void RecorderLoop(RecorderInterface* rec, FrameQueue* queue) { int segment_ms 5 * 60 * 1000; // 5 分钟一段 uint64_t seg_start_pts 0; while (running_) { MediaFrame frame; if (!queue-Pop(frame)) break; if (frame.pts - seg_start_pts segment_ms frame.isKeyFrame) { rec-Close(); // 关闭当前段 rec-Open(NextSegmentName()); // 打开新段 seg_start_pts frame.pts; } rec-WriteFrame(frame); } }逻辑说明通过frame.isKeyFrame保证切换点一定是关键帧避免播放器在段首等待参考帧。NextSegmentName可以按日期加序号生成比如rec_20250615_140001.mp4方便后续对接定时备份脚本。验证封装修正是否到位我习惯用 ffprobe 检查ffprobe -v error -select_streams v:0 \ -show_entries streamcodec_name,pix_fmt,profile,level \ -of defaultnoprint_wrappers1 rec_20250615_140001.mp4输出中有h264、yuv420p和正确的 profile/level 时这个容器基本是健康的接着用ffprobe -count_frames对比帧数确认没丢关键帧。6.3 改造后的验证与习惯多路录制改造完成后我会强制自己走一遍验证先单路跑 10 分钟检查内存曲线再双路跑 1 小时检查文件分段时间戳连续性最后用播放器拖动进度条确认 seek 正常。从那以后我每次重构录像模块都会把 MP4 写入挪到独立线程先用 ffprobe 验证容器结构再交给业务联调。这个项目拆下来最大的教训是裸码处理没有玄学所有黑屏、花屏、卡顿都能对应到一个具体字节层面的决策上只要把起始码、时间戳、avcC 三件事管好H264 转 MP4 保存裸码 实时预览这套链路就不会翻车。希望这篇笔记能帮你少踩几个坑顺利把你手头的 SDK 裸码流变成能交付的录像能力。本文还有配套的精品资源点击获取