首页 / 资讯中心 / 文章详情

H.264裸流转RTMP:基于FFmpeg的轻量转封装C实现

H.264裸流转RTMP:基于FFmpeg的轻量转封装C实现 ★ FEATURED ARTICLE
简介一份面向音视频开发者的FFmpeg直播推流示例源码使用C语言实现了在内存中读取H264编码数据、封装为FLV容器并通过RTMP协议推送到服务器的完整流程。代码量极小仅1个c文件整体3KB却覆盖了FFmpeg从H264 NAL单元解析、FLV Tag封装到RTMP连接与推流的关键API调用。适合具备一定FFmpeg基础、希望快速理解H264到RTMP直播链路的学习者研读或作为简易参考模板加以改造。目前已有963人学习下载其小而实用的特点获得不少开发者关注。虽未附带工程文件与注释但核心逻辑集中紧凑结合官方文档逐段分析能有效提升对音视频封装、流媒体协议和内存转码等知识的掌握。1. 项目定位一套把H.264裸流送到RTMP直播间的C源码H264tortmp 源码改写自我手头一个嵌入式设备的直播模块。场景很常见摄像头编码器直接吐出 H.264 裸流文件没有封装、没有音轨、只有一帧帧 NALU而直播平台只认 RTMP 推流。这个项目做的事就是把这种裸流高效推到 RTMP 服务器核心是“转封装”而不是“转码”。它读取 H.264 Annex B 文件解析出 SPS/PPS 和帧数据用 FFmpeg 的 libavformat 封装成 FLV 并推给 RTMP 地址。整个过程不做转码、不处理音频、不做画面增强所以 CPU 占用很低适合跑在盒子、开发板上。适合两类人一是视频后端想给直播系统接采集源二是想在 FFmpeg 之外自己掌握 H.264 流封装细节的开发者。2. 架构与目录FFmpeg 之外的轻量 H.264 搬运管线如果你手写过 librtmp 推流一定经历过为了一帧数据补时间戳、算 tag 长度、拆 chunk 的崩溃感。这套源码的思路是不重复造轮子用 FFmpeg 的 libavformat 负责 FLV 封装和 RTMP 协议自己只处理 H.264 裸流解析。整条链路只有三块业务解析器、参数构造、写帧循环其余全部交给 FFmpeg。2.1 整体数据流从 Annex B 文件到 FLV 标签输入是摄像头常见的 Annex B 格式。它里面没有帧长度只有 0x00000001 这样的起始码。第一步先扫描起始码把一段段 NALU 切出来同时区分 SPS、PPS、IDR 和普通 P 帧。SPS 和 PPS 不是画面数据它们是解码器的“说明书”必须在推流开始之前发给 RTMP 服务器FLV 容器规定这套参数要放在 extradata 里后续由 avformat_write_header 写进 AVC sequence header。做完这一步所有普通帧按原始顺序交给 AVPacket由 FFmpeg 封装成 FLV tag走 RTMP 协议发出去。数据流大致是下面这样H.264 文件读入缓冲区扫描起始码切割 NALU分类 NALU7 号是 SPS8 号是 PPS5 号是 IDR 关键帧1 号是 P 帧用 SPS/PPS 拼成 AVCodecParameters.extradataavformat_new_stream 创建视频流把参数复制过去avformat_write_header 输出 FLV 头循环读帧填充 AVPacketav_interleaved_write_frame 写出这段流程里最容易被忽略的是 H.264 裸流的时间基。文件本身不带时间戳所以推流前必须让用户指定帧率或者从文件名读取否则服务器收到的时间戳全是 0播放端就会表现成花屏或快速跳帧。2.2 源码目录每个文件干一件具体的事先看完整的目录结构后面逐个分析每个文件的职责。h264tortmp/ ├── CMakeLists.txt ├── README.md ├── include/ │ ├── h264_parser.h │ └── rtmp_muxer.h └── src/ ├── main.c ├── h264_parser.c └── rtmp_muxer.cmain.c 负责解析命令行参数把输入文件路径、RTMP 地址、帧率三个值传给 rtmp_muxer。它本身不写任何业务逻辑只做参数校验和调用入口这样换一种输入源比如 UDP 收流时不用改推流部分。h264_parser.c 是项目里唯一需要关心裸流字节的模块。它把缓冲区分成若干 NALU并返回每个 NALU 的类型和长度。SPS/PPS 在这里被单独提出来因为后面需要做特殊处理。rtmp_muxer.c 才是推流主循环它负责创建 AVFormatContext、初始化视频流、写 header、循环写帧。这两个文件通过 include 目录里的头文件对接接口只暴露“解析 NALU”和“推流循环”两个函数方便替换实现。CMakeLists.txt 负责找 FFmpeg 开发库。注意它依赖的不是 FFmpeg 命令行而是 libavformat、libavcodec、libavutil 这三个开发库因此编译机器上必须装对应的 dev 包。2.3 用 FFmpeg 的 AVFormatContext 而不是 librtmp网上很多 H.264 转 RTMP 的教程都直接调 librtmp 接口需要自己拼 FLV tag还要处理 channel、chunk size、时间戳扩展。这套源码选择绕开 librtmp让 FFmpeg 做 FLV 复用器RTMP 只是输出协议。创建一个输出上下文只需要一行AVFormatContext *ofmt_ctx NULL; avformat_alloc_output_context2(ofmt_ctx, NULL, flv, rtmp_url);这里第三个参数“flv”告诉 FFmpeg 用 FLV 复用器第四个参数是 RTMP 地址FFmpeg 会识别出 url 前缀是 rtmp自动选择对应的 RTMP 协议处理。这么设计的好处是如果以后要输出成 HLS 或者本地文件只需要改这个字符串推流逻辑完全不用动。在 rtmp_muxer.c 里还有一段负责写 headerif (avformat_write_header(ofmt_ctx, NULL) 0) { av_log(NULL, AV_LOG_ERROR, write header failed\n); return -1; }avformat_write_header 会在后台做几件事写 FLV 文件头、把构造好的 SPS/PPS 塞进 AVC sequence header、把 RTMP 的 connect 和 publish 握手走完。如果你调用之前 extradata 没填好这次握手会把视频参数发错播放端拿到一个没有解码信息的流表现就是黑屏或者直接拒绝播放。所以“把 H.264 转 RTMP”这件事在这套项目里被拆成了两块自己写 H.264 解析别的交给 FFmpeg。这也符合我做流媒体模块的习惯协议面能用现成库就用现成库自己只处理业务语义相关的部分。3. 核心代码拆解H.264 转 RTMP 的关键三步前面讲完了架构这一章把代码逐段拆开。核心代码集中在 h264_parser.c 和 rtmp_muxer.c 里我按“解析 NALU、构造 extradata、循环写帧”三步来讲。3.1 解析 Annex B把起始码和 NAL 类型分干净H.264 裸流最常见的封装是 Annex B它用起始码区分 NALU。起始码常见的是 0x00000001有些流会混用 0x000001。解析函数要做的事从缓冲区开头扫描找到起始码后把起始码之后到下一个起始码之前的字节完整切下来然后读第一个字节的低 5 位拿到 NAL 类型。#include stdio.h #include stdint.h #include string.h typedef struct { uint8_t *data; size_t size; uint8_t type; } nal_unit_t; static uint8_t get_nal_type(const uint8_t *nal) { // H.264 第一个字节低5位是类型7表示SPS8表示PPS5表示IDR return nal[0] 0x1F; } int h264_parse_annexb(const uint8_t *buf, size_t len, nal_unit_t *out, int max_out) { int count 0; size_t pos 0; while (pos 4 len) { if (!(buf[pos] 0 buf[pos1] 0 buf[pos2] 0 buf[pos3] 1)) { pos; continue; } size_t nal_start pos 4; size_t nal_end nal_start; while (nal_end len) { if (nal_end 3 len buf[nal_end] 0 buf[nal_end1] 0 buf[nal_end2] 0 buf[nal_end3] 1) { break; } if (nal_end 2 len buf[nal_end] 0 buf[nal_end1] 0 buf[nal_end2] 1) { break; } nal_end; } if (count max_out) { out[count].data (uint8_t *)buf nal_start; out[count].size nal_end - nal_start; out[count].type get_nal_type(buf nal_start); } count; pos nal_end; } return count; }这段代码里有几个关键点。第一内层循环判断下一个起始码时我同时兼容了四字节和三字节两种起始码避免有的流在帧中间用 0x000001 做分界。第二max_out 限制一次最多解析多少 NALU防止调用方给的数组越界实际项目里我会一次性读整个文件解析结果数组设成 1024 足够。第三返回的是 NALU 数量调用方拿到后遍历根据 type 做不同处理。实际使用中不能只靠起始码切帧就完事。SPS 和 PPS 必须单独收起来IDR 关键帧在推流时应当标记 AV_PKT_FLAG_KEY否则 FLV 播放器可能每几秒就卡一下因为播放器等不到关键帧就解不出画面。3.2 构造 AVCodecParameters让 FLV 认识 H.264FLV 容器里的 H.264 封装格式叫 AVCC它和 Annex B 最大的差别是Annex B 用起始码分隔 NALUAVCC 用 4 字节长度前缀。FFmpeg 在写 FLV 时会把每个 AVPacket 里的数据当成 AVCC 配置单元来写所以推流之前必须把 SPS/PPS 透传给 FLV 复用器否则服务器和播放器都不知道该用什么 profile、什么分辨率去解码。代码里这一步在 rtmp_muxer.c 的 init_output_params 里AVCodecParameters *par avcodec_parameters_alloc(); par-codec_type AVMEDIA_TYPE_VIDEO; par-codec_id AV_CODEC_ID_H264; par-width 1920; par-height 1080; // 宽高直接从 SPS 解析更准demo 里先写死 par-format AV_PIX_FMT_YUV420P; uint8_t extradata[128]; uint8_t *p extradata; *p 0x01; // configurationVersion *p sps[0]; // profile取SPS第一个字节 *p sps[1]; // compatibility *p sps[2]; // level *p 0xfc | 0x03; // lengthSizeMinusOne3表示4字节长度前缀 *p 0xe0 | 0x01; // numOfSPS1 *p (sps_len 8) 0xff; *p sps_len 0xff; memcpy(p, sps, sps_len); p sps_len; *p 0x01; // numOfPPS1 *p (pps_len 8) 0xff; *p pps_len 0xff; memcpy(p, pps, pps_len); p pps_len; par-extradata av_malloc(p - extradata); memcpy(par-extradata, extradata, p - extradata); par-extradata_size p - extradata;这里有几个容易踩坑的地方。configurationVersion 固定为 1profile、compatibility、level 三个字节必须直接取自 SPS 的原始字节不能自己猜否则解码器会用错误的级别去适配。lengthSizeMinusOne 表示后续每个 NALU 长度前缀占几个字节通常填 3 表示 4 字节长度。numOfSPS 和 numOfPPS 字段的布局不一样SPS 前面有 0xe0 保留位而 PPS 没有。曾经我在这里写错过导致 FFmpeg 打出来的流在 ffprobe 里能看到 SPS/PPS但播放端始终黑屏。构造完 extradata 后这个 par 还要复制给流AVStream *st avformat_new_stream(ofmt_ctx, NULL); st-time_base (AVRational){1, 1000}; // 时间戳以毫秒为单位 avcodec_parameters_copy(st-codecpar, par);avcodec_parameters_copy 会做深拷贝所以后面释放 par 不会影响 st 里的参数。很多做转封装的工程在这里手写 extradata 容易漏掉 SPS 或 PPS 的数量字段尤其是当 SPS/PPS 个数不为 1 时。实际摄像头流通常只有一个 SPS 和一个 PPS但稳妥做法还是跟着标准循环写而不是写死两个字段。3.3 推流循环PTS/DTS 和 RTMP 写包的配合初始化完成之后剩下的逻辑就是一个循环读一帧裸流解析出一个或多个 NALU转成 AVCC 格式放进 AVPacket填时间戳写进 RTMP。int64_t pts 0; for (int i 0; i nal_count; i) { if (nal[i].type 7 || nal[i].type 8) { continue; // SPS/PPS 已经在 extradata 里跳过 } AVPacket pkt {0}; av_init_packet(pkt); // Annex B 转 AVCC每个 NALU 前补 4 字节长度 uint8_t *buf av_malloc(nal[i].size 4); AV_WB32(buf, nal[i].size); memcpy(buf 4, nal[i].data, nal[i].size); pkt.data buf; pkt.size (int)nal[i].size 4; pkt.stream_index st-index; pkt.pts pts; pkt.dts pts; if (nal[i].type 5) { pkt.flags | AV_PKT_FLAG_KEY; } av_interleaved_write_frame(ofmt_ctx, pkt); av_freep(buf); pts 40; // 按 25fps 计算约 40ms 一帧 }这段代码最关键的是把 Annex B 转成了 AVCC。FLV 的 muxer 要求每个 AVPacket 里已经带长度前缀而不是起始码。如果直接把 Annex B 数据塞进去FFmpeg 会把起始码当成画面数据的一部分播放端就会花屏。AV_WB32 是 FFmpeg 提供的大端写宏保证长度字节序正确。pts 的增量写死在循环里换成 30fps 就得手动改。我在 main.c 里把它做成了命令行参数传入 fps 后通过pts (int64_t)(1000.0 / fps)计算。av_interleaved_write_frame 内部会做缓冲和排序它要求传入的 pts 是单调递增的所以这里的 pts 用 int64_t 累加不会出现回绕问题。RTMP 的时间戳以毫秒为单位st-time_base 设置为 {1,1000} 正好对应。不要直接把像素时钟或视频流里的 dts 填进去那样服务器会认为时间戳跳变很大导致播放端不断缓冲。如果从别处读取 H.264 流源数据里带了 PTS需要先按源时间基转成毫秒再赋值不能原样提交。4. 编译与运行一条命令把本地 H.264 推上 RTMP读完核心代码接下来把它跑起来。这一章会覆盖依赖安装、编译选项、运行参数和本地验证。4.1 依赖环境准备dev 库和 CMake 配置项目依赖三个 FFmpeg 开发库libavformat、libavcodec、libavutil。在 Ubuntu 或 Debian 上安装很简单sudo apt-get install pkg-config cmake libavformat-dev libavcodec-dev libavutil-devlibavformat-dev 提供 FLV 复用器和 RTMP 协议实现libavcodec-dev 提供 H.264 编解码器相关的 AVCodecID 定义libavutil-dev 提供 AVRational、AVPacket 等基础结构。这三个库是编译依赖也是运行依赖缺一个都会在链接时报一堆未定义符号。CMakeLists.txt 里使用 pkg-config 定位 FFmpeg 库cmake_minimum_required(VERSION 3.10) project(h264tortmp C) find_package(PkgConfig REQUIRED) pkg_check_modules(FFMPEG REQUIRED libavformat libavcodec libavutil) include_directories(${FFMPEG_INCLUDE_DIRS}) add_library(h264_core STATIC src/h264_parser.c src/rtmp_muxer.c) target_include_directories(h264_core PUBLIC include) add_executable(h264tortmp src/main.c) target_link_libraries(h264tortmp PRIVATE h264_core ${FFMPEG_LIBRARIES})pkg_check_modules 会自动生成 FFMPEG_LIBRARIES 和 FFMPEG_INCLUDE_DIRS 两个变量。如果你的 FFmpeg 不是装在系统默认路径比如安装到了 /home/user/ffmpeg_build记得在执行 cmake 前设置 PKG_CONFIG_PATH 环境变量。4.2 编译参数理解三个命令行参数编译并运行mkdir -p build cd build cmake .. make -j4 ./h264tortmp ../test.h264 rtmp://127.0.0.1/live/stream 25第一个参数是 H.264 裸流文件路径第二个是 RTMP 推流地址第三个是帧率。帧率这个参数很关键因为 H.264 裸流文件里没有时间信息所有 PTS 都要靠这个值推算出来。在 main.c 里会这样解析if (argc 4) { fprintf(stderr, usage: %s input.h264 rtmp://host/app/stream fps\n, argv[0]); return 1; } int fps atoi(argv[3]); if (fps 0 || fps 120) { fprintf(stderr, invalid fps\n); return 1; }这里检查 fps 范围是为了防止有人把 0 传进来导致后面除零。实际生产环境我通常限制在 1 到 60超过 60 多半是手滑。4.3 本地验证用 ffplay 回看推出去的流推流之前需要先在本地起一个支持 RTMP 的服务端。如果只是验证代码不需要买服务器用带 rtmp 模块的 nginx 在本地起一个就行。启动后执行上面的推流命令然后打开另一个终端ffplay rtmp://127.0.0.1/live/stream如果能看到画面说明整条链路正常。想确认服务器是否真的收到了视频参数可以用 ffprobeffprobe -v error -show_streams rtmp://127.0.0.1/live/stream | grep -E codec_name|width|height输出里有 h264 和分辨率信息就说明 FLV sequence header 里的 extradata 解析成功了。这一步是我每次改完代码必做的验证比直接看画面更早发现参数错误。常见的问题是本地推流成功但公网播放失败这种一般不是代码问题而是服务器没有开放 RTMP 端口或者签名鉴权没通过。调试时可以先在本地确认无误再考虑网络因素。5. 避坑排查黑屏、断流、时间戳跳跃的常见翻车点这一章是我自己调试这类推流器时踩过的坑整理了四类典型问题。每条按照“现象 → 原因 → 解决”给出。5.1 推流成功但播放端黑屏现象是 ffplay 能连上但画面一直黑着偶尔还能看到时间在走。原因多半是 SPS/PPS 没有正确放进 extradata或者 extradata 里 SPS 和 PPS 的顺序反了。FLV 的解码参数在 sequence header 里固定是 SPS 在前 PPS 在后如果把 PPS 写在前面解码器会按错误的参数去解析码流结果就黑屏。解决方法是先把 h264_parser 固化成单元测试单独验证解析结果。我习惯在测试文件里放一个 10 秒的 test.h264用 ffprobe -show_frames 检查每个关键帧的 NAL 类型确认 type 7 和 type 8 出现顺序正确再跑推流。5.2 推几秒后服务器断开连接现象是推流日志里出现类似“write error”或“connection reset”的错误而且每次都在推送关键帧之后。原因是时间戳回绕或者重复发送了 SPS/PPS。有的服务器会认为重复的 SPS/PPS 是非正常数据流直接断开。解决方法是推流循环里严格跳过 type 7 和 type 8同时确认 pts 变量一直在单调递增。你可以把每一帧的 pts 打印到日志里用 awk 统计是否出现下降如果下降就说明代码里时间戳累加有误。5.3 延时越来越大现象是刚开始只有 1 秒延迟观看一小时后变成 20 秒。原因通常是推流端在 av_interleaved_write_frame 后就把数据丢了没有做背压控制当服务器或网络卡顿时推流端仍以恒定速度把帧塞进 socket导致缓存堆积。解决方法是给写帧加一个阈值每次写之前检查已经写出的数据量如果超过服务器可消费的范围就主动丢一帧或 sleep 一毫秒。这个阈值在不同网络条件下差异很大我一般通过观察服务器日志里的 RTMP chunk feedback 来决定。5.4 自己拼 FLV tag 总是花屏现象是如果你不依赖 libavformat自己动手给 NALU 加 FLV tag 头播放端容易花屏或快进。原因是 FLV tag 里的 DataSize 必须等于标签头后实际数据长度而 H.264 在 FLV 里必须用 AVCC 长度前缀而不是起始码。解决方式其实很简单别自己拼。用 avformat_write_header 和 av_interleaved_write_frame 即可代码量少了不止一半。除非你要给非常老的播放器做兼容否则手写 FLV tag 属于重复造轮子容易在边界数据上翻车。这四条坑互相之间还有关联extradata 写错会导致黑屏黑屏后你重连服务器可能因为重复参数断开时间戳跳跃会加剧缓冲再叠加上推流端没做背压最终就是又花又卡又断。排查顺序我一般先看 extradata再看时间戳最后看网络背压能少走很多弯路。6. 二次开发把它从文件推流改成 UDP 接收再推流接下来这个改造思路是我在实际项目里把文件推流改成 UDP 裸流接收时的做法可以直接套用。现在的 main.c 是读本地文件改造成 UDP 接收后核心思想是把“读一个 NALU”的过程替换成“从 socket 缓冲区里切 NALU”。UDP 收到的数据可能一个包就是一帧也可能半个帧所以不能直接丢给 h264_parse_annexb要先做简单的重组。我一般维护一个动态缓冲把不完整的数据留着等下一个包到来再拼接。ssize_t n recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr *)peer, plen); if (n 0) continue; memcpy(recv_buf recv_len, buffer, n); recv_len n; int cnt h264_parse_annexb(recv_buf, recv_len, nals, MAX_NALS); // 留最后一个不完整的 NALU 给下一次 if (cnt 0) { memmove(recv_buf, nals[cnt-1].data, nals[cnt-1].size); recv_len nals[cnt-1].size; cnt--; write_nals(nals, cnt, ctx); }这段代码的关键在于每次解析后把最后一个 NALU 留着等下一个 UDP 包到达再追加解析。原因是一个完整 NAL 可能被拆在两个包里如果每次都清空缓冲区就会把中间断开的帧误判成完整帧导致花屏。另一个改造方向是加音频流。裸流项目里没有音频但直播场景经常要求音视频同步。做法是在 AVFormatContext 里再 new 一个 AudioStream设置 AAC 的 codecpar然后把 AAC 的 ADTS 头解析出来和视频帧交错写入。注意音频和视频的 pts 不能用同一套递增逻辑要分别维护。每次改完核心逻辑我都会强制跑一遍本地 ffplay 验证再写一个小脚本用 ffprobe 对比原始文件和推流出帧的时间戳。从那以后我拿到任何一个设备流都会先确认编码格式和 SPS/PPS 顺序再决定要不要复用这套 H.264 搬运管线。希望这些踩出来的经验能帮到你至少别在 extradata 上熬到凌晨两点。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站