☰
H.264裸流转RTMP推流:FLV封装与NAL解析实战解析
2026/10/16 3:28:12 网站建设 项目流程

简介:一份基于FFmpeg的H264转RTMP推流源码包,面向具备C语言基础、希望深入音视频开发与直播推流方向的开发者。示例演示如何在内存中直接处理H264编码数据,将其封装成FLV标签并通过RTMP协议实时推送,避免磁盘读写带来的延迟。源码包仅包含一个C语言源文件,压缩包约三KB,代码量精简却覆盖完整推流链路:从初始化全局库、解析H264基本单元,到创建FLV封装上下文、建立RTMP连接,再到写入头部、持续发送帧数据以及最后的清理释放。目前已有963人学习下载。该实现特别适合用作学习蓝本,帮助快速理清FFmpeg调用顺序;也适合在二次开发中参考内存处理与网络推送的结合方式。对想上手FFmpeg、弄清H264到FLV封装细节或自主搭建直播推流示例的开发者来说,这份源码提供了清晰的参考路径,能串联起内存处理、格式封装与网络传输的完整概念。

1. H264tortmp:把裸 H.264 封装成 RTMP 推流包的 C 工程

H264tortmp 这个源码包解决的是直播推流里最磨人的一段:摄像头编码器直接吐出来的是裸 H.264(Annex-B 起始码格式),而 RTMP 服务器只认 FLV 封装的视频 Tag,中间这层转换没有现成的小工具可抄,很多人在这里花屏、黑屏、时间戳乱跳。它把 .264 文件解析成一个个 NAL 单元,识别 SPS、PPS、IDR 帧,再按 FLV 规范组装 Tag,最终交给 RTMP 推流,链路短、依赖少。适合做推流客户端、嵌入式设备直播、或者想搞懂 FLV 与 RTMP 封装细节的开发者。

2. 整体架构:四层链路与模块边界

这个工程按「读取 → 解析 → 封装 → 传输」四层来做,每一层只对着前一层定义的字节格式干活,不越界。拆完源码你会发现,它没有把所有逻辑堆在一个 main 里,而是拆成了四个源文件加一个 Makefile,编译产物是一个独立的命令行推流程序。

2.1 包内文件布局与模块职责

拿到源码包先看目录,典型划分是下面这张表。如果你手里的版本文件命名不一样,按职责对应即可,不影响理解。

文件职责对外关键接口
main.c解析命令行参数、创建各模块实例、串起整个推流流程main()
h264_parser.c在码流里扫描起始码、切分 NAL 单元、识别 NAL 类型、缓存 SPS/PPSh264_next_nal(), h264_parse_nal_type()
flv_muxer.c构造 AVCDecoderConfigurationRecord、组装 FLV Video Tag Body、维护时间戳flv_muxer_append_tag(), flv_write_avc_sequence_header()
rtmp_pusher.c封装 librtmp,完成 RTMP 握手、createStream、发送数据rtmp_pusher_create(), rtmp_pusher_send()
Makefile指定编译参数,链接 librtmpmake

常见做法是直接链接系统自带的 librtmp 库,编译参数里加上-lrtmp。如果目标平台是嵌入式,也可以把 librtmp 源码编进工程。main.c 只负责串流程,不掺业务细节,这样后续想加音频或者接摄像头,都不用动推流核心。

2.2 数据流设计:四层转换链路

从文件输入到 RTMP 服务器,数据经过的四层转换分别是:

  1. 读取层:按字节流读入 .264 文件,寻找 00 00 00 01 或 00 00 01 起始码,把一段完整码流切成一帧。
  2. 解析层:读 NAL Header 的低 5 位,判断当前是 SPS(7)、PPS(8)、IDR(5)、普通 Slice(1) 还是其他类型。SPS/PPS 单独缓存,不直接推。
  3. 封装层:把 NAL 单元改成 FLV 的 Video Tag Body,加上 5 字节的 VideoTagHeader;在第一个 IDR 帧前先发一个 AVCPacketType=0 的序列头,告诉播放器解码参数。
  4. 传输层:通过 librtmp 把 Tag Body 打包成 RTMP 消息并发送。注意 RTMP 推流只发 Tag Body,不需要发文件级别的 9 字节 FLV Header。

我第一次看这个设计时有个疑惑:为什么 FLV 封装不生成 .flv 文件头?想清楚就明白了——RTMP 服务器收到的是连续的 VideoTag 和 AudioTag,服务器自己会补文件头、维护索引,客户端只需要按 Tag 解析。这个差异是 FLV 文件封装和 RTMP 推流最容易混淆的地方。

2.3 关键数据结构与全局状态

解析器、封装器、推流器各维护一个上下文结构体,避免用全局变量。代码里核心结构大概是这样的:

typedef struct { uint8_t sps[64]; int sps_len; uint8_t pps[32]; int pps_len; int seq_header_sent; FILE *fp; } h264_parser_t; typedef struct { uint8_t *tag_buf; int tag_len; uint32_t pts_ms; } flv_muxer_t; typedef struct { RTMP *rtmp; int connected; int chunk_size; } rtmp_pusher_t;

sps_len和pps_len分别记录两个参数集的长度,seq_header_sent用来保证序列头只在首个 IDR 前发一次,避免每一帧都重复发送浪费带宽。flv_muxer_t里的tag_buf是复用缓冲区,每次组完 Tag 就交给推流器发送,发送完再覆盖,这样避免频繁 malloc。chunk_size是协商后的 RTMP 分块大小,后续大帧发送依赖这个值。

这些结构体的设计思路是:解析器只管吃文件和吐 NAL,封装器只管把 NAL 变成 Tag,推流器只管把 Tag 发出去。想加音频,新开一个 audio 解析模块就行,不用改动其他结构体。

3. 核心代码拆解:从 NAL 解析到 FLV Tag 的完整实现

源码里最值得读的是四条主线:入口装配、SPS/PPS 处理、NAL 转 Tag、时间戳与推流循环。逐段拆开讲。

3.1 入口流程:解析参数并初始化推流会话

main 函数的职责很纯粹:校验参数、创建解析器和封装器、建立 RTMP 连接、进入推流循环。代码骨架如下:

int main(int argc, char *argv[]) { if (argc < 3) { fprintf(stderr, "Usage: %s <input.264> <rtmp://host/live/stream>\n", argv[0]); return -1; } FILE *fp = fopen(argv[1], "rb"); if (!fp) { perror("open input failed"); return -1; } h264_parser_t *parser = h264_parser_create(fp); flv_muxer_t *muxer = flv_muxer_create(); rtmp_pusher_t *pusher = rtmp_pusher_create(argv[2]); if (rtmp_pusher_connect(pusher) < 0) { fprintf(stderr, "rtmp connect failed\n"); return -1; } int ret = push_loop(parser, muxer, pusher, 25); rtmp_pusher_destroy(pusher); flv_muxer_destroy(muxer); h264_parser_destroy(parser); fclose(fp); return ret; }

参数说明:argv[1]是输入的裸 H.264 文件路径,argv[2]是 RTMP 推流地址,形如rtmp://127.0.0.1/live/stream。fps=25我直接写死在调用里,实际工程建议抽成命令行参数,方便适配 15、30、60 帧的源。

这段逻辑里值得注意的一点是连接顺序:先建立 RTMP 会话,再进入推流循环。如果先解析文件再连接,遇到网络抖动会导致已解析的 NAL 数据堆积,内存不必要地膨胀。连接成功后才开始读文件,是最稳的顺序。

3.2 SPS/PPS 捕获与 AVCDecoderConfigurationRecord 构造

H.264 解码器必须拿到 SPS 和 PPS 才能工作,这两个参数集必须封装成 FLV 的序列头发送。我们先看 NAL 解析函数:

int h264_parse_nal_type(const uint8_t *nal, size_t size) { size_t offset = 0; if (size > 3 && nal[0] == 0 && nal[1] == 0 && nal[2] == 1) { offset = 3; /* 3 字节起始码 */ } else if (size > 4 && nal[0] == 0 && nal[1] == 0 && nal[2] == 0 && nal[3] == 1) { offset = 4; /* 4 字节起始码 */ } else { return -1; /* 非法起始码 */ } uint8_t nal_type = nal[offset] & 0x1F; return nal_type; }

这里的核心是nal[offset] & 0x1F。NAL Header 一个字节,低 5 位是类型,高 3 位是forbidden_zero_bit和nal_ref_idc,必须用位与把高 3 位屏蔽掉。返回 7 就是 SPS,返回 8 是 PPS,返回 5 是 IDR。

拿到 SPS/PPS 后,需要构造 FLV 的AVCDecoderConfigurationRecord,这段是源码里最容易写错的部分:

static int flv_write_avc_sequence_header(flv_muxer_t *muxer, const uint8_t *sps, int sps_len, const uint8_t *pps, int pps_len) { int body_len = 16 + sps_len + pps_len; uint8_t *body = calloc(1, body_len); uint8_t *p = body; *p++ = 0x17; /* 1:关键帧 7:AVC */ *p++ = 0x00; /* AVCPacketType=0, 表示序列头 */ *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; /* CTS 置 0 */ *p++ = 0x01; /* configurationVersion */ *p++ = sps[0]; /* profile_idc, 取 SPS payload 第 1 字节 */ *p++ = sps[1]; /* profile_compatibility */ *p++ = sps[2]; /* level_idc */ *p++ = 0xfc; /* lengthSizeMinusOne=3,NAL 长度用 4 字节 */ *p++ = 0xe1; /* 保留位 + numOfSPS=1 */ *p++ = (sps_len >> 8) & 0xff; *p++ = sps_len & 0xff; memcpy(p, sps, sps_len); p += sps_len; *p++ = 0x01; /* numOfPPS=1 */ *p++ = (pps_len >> 8) & 0xff; *p++ = pps_len & 0xff; memcpy(p, pps, pps_len); p += pps_len; flv_muxer_append_tag(muxer, 9, body, body_len, 0); free(body); return 0; }

body_len = 16 + sps_len + pps_len的 16 字节构成是:VideoTagHeader 固定 5 字节,AVCDecoderConfigurationRecord 固定 11 字节。0xfc表示lengthSizeMinusOne = 3,也就是后续每个 NAL 前面用 4 字节存长度,因为 3 对应 4 字节。0xe1的高 3 位是保留位,低 5 位是 SPS 个数。这里传进去的 sps 和 pps 已经去掉了 NAL Header,直接从 payload 起始位置取参数。

3.3 NAL 单元转 FLV Tag 的组装逻辑

序列头只有一份,真正一帧一帧推的是 NAL 数据 Tag。组装函数如下:

static int flv_write_h264_nal(flv_muxer_t *muxer, const uint8_t *nal, int nal_len, uint32_t dts_ms) { uint8_t *body = malloc(5 + 4 + nal_len); uint8_t *p = body; *p++ = 0x27; /* 普通帧: 2=b帧? 实际 1=关键帧 2=普通帧, 7=AVC */ *p++ = 0x01; /* AVCPacketType=1, NALU 数据 */ *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; /* CTS 暂填 0, 有 B 帧时改为 pts - dts */ *p++ = (nal_len >> 24) & 0xff; *p++ = (nal_len >> 16) & 0xff; *p++ = (nal_len >> 8) & 0xff; *p++ = nal_len & 0xff; memcpy(p, nal, nal_len); flv_muxer_append_tag(muxer, 9, body, 5 + 4 + nal_len, dts_ms); free(body); return 0; }

注意第一字节我写了0x27代表普通帧,如果当前 NAL 是 IDR,这个字节要改成0x17,即关键帧标志位加 AVC CodecID。很多实现里直接用nal[0] & 0x1F == 5来判断是不是关键帧,但更稳妥的是用前面解析器已经返回的 NAL 类型。

compositionTime是 FLV 里最容易被忽略的字段。当视频流不含 B 帧时,PTS 等于 DTS,这个字段填 0 没问题;一旦输入流有 B 帧,这里必须填(pts_ms - dts_ms),否则播放器画面会一顿一顿。

3.4 时间戳换算与推流主循环

前几个函数都是为这个循环服务的。push_loop负责把整条链路串起来:

static int push_loop(h264_parser_t *parser, flv_muxer_t *muxer, rtmp_pusher_t *pusher, int fps) { uint32_t dts = 0; int frame_duration = 1000 / fps; while (1) { uint8_t *nal = NULL; int nal_len = h264_next_nal(parser, &nal); if (nal_len <= 0) break; int nal_type = h264_parse_nal_type(nal, nal_len); if (nal_type == 7 || nal_type == 8) { h264_store_sps_pps(parser, nal_type, nal, nal_len); continue; } if (nal_type == 5 && !parser->seq_header_sent) { flv_write_avc_sequence_header(muxer, parser->sps, parser->sps_len, parser->pps, parser->pps_len); rtmp_pusher_send(pusher, muxer->tag_buf, muxer->tag_len, dts); parser->seq_header_sent = 1; } if (nal_type == 5 || nal_type == 1) { flv_write_h264_nal(muxer, nal, nal_len, dts); rtmp_pusher_send(pusher, muxer->tag_buf, muxer->tag_len, dts); dts += frame_duration; } } return 0; }

这个循环最妙的点在continue:SPS/PPS 只进缓存不发,等遇到第一个 IDR 帧时马上组序列头发出去。顺序绝对不能反过来——先发数据帧后补序列头,播放器会直接黑屏。

时间戳这里用了简单累加:dts += 1000 / fps。如果输入文件自带 PTS 信息,更专业的做法是读真实时间戳并让首帧归零。RTMP 时间戳单位是毫秒,所有时间戳都必须做归一化,否则服务器会拒绝接收。

4. 避坑记录:跑通推流最容易翻车的四个点

这套代码我自己跑过很多遍,每次都有人在前四个坑里反复踩。逐个记下来,现象、原因、解决办法都写清楚。

4.1 推流成功但播放器黑屏

现象:RTMP publish 之后,拉流地址能连上,但播放器黑屏,等了十几秒也没画面。用 ffprobe 拉流能看到流信息,但画面就是出不来。

原因:绝大多数情况是序列头没有在第一个 IDR 前发送。RTMP 服务器会把 AVCPacketType=0 的 Tag 当作视频参数缓存起来,播放器起播时必须先拿到这个 Tag 才能初始化解码器。如果代码里直接把所有 NAL 一视同仁地发出去,或者解析器没缓存 SPS/PPS,序列头就丢失了。

解决:严格按 3.4 节的顺序执行——先缓存 SPS/PPS,遇到 IDR 时先发序列头再发帧数据。另外要确认seq_header_sent的逻辑只在首个 IDR 前触发,别在循环里每帧都发。

4.2 时间戳回退导致画面卡顿

现象:播放器能出画面,但每隔一两秒卡顿一次,日志里频繁出现 DTS 乱序的警告。换一个视频源又正常,玄学得很。

原因:大概率是输入流有 B 帧。带 B 帧的 H.264 里,PTS 和 DTS 不一样,如果直接把解码顺序的 DTS 当成展示时间戳发送,RTMP 服务器会收到时间戳回退的帧序列,触发丢帧或等待逻辑。

解决:两个方案。一是输入阶段就用-profile:v baseline禁用 B 帧,省去一切麻烦;二是按 FLV 规范把compositionTime填成pts - dts,并且保证发送给服务器的 DTS 是严格递增的。我一般会选择前者,裸流推流场景没有必须用 B 帧的理由。

4.3 推流中途连接断开

现象:开局正常,推到 20 秒或 1 分多钟时连接突然断开,服务端日志显示收到非法数据。重跑一遍可能又正常,问题不稳定。

原因:大尺寸 IDR 帧超过了 RTMP 协商的 chunk size,而发送侧没有按 chunk 分块。librtmp 库本身有 chunk 分块能力,但如果自己在 RTMP 之上包了一层缓冲,缓冲区的边界处理不当就会把超大帧拆坏。

解决:显式设置 chunk size,常见做法是RTMP_Write前用RTMP_GetChunkSize查看当前值,确保发送缓冲区至少能容纳一个完整 chunk。另外给 RTMP 连接设置合理的超时时间,RTMP_SetTimeout设成连接 5 秒、读 10 秒,避免网络抖动直接掐断会话。

4.4 首帧延迟太长

现象:推流端一切正常,但拉流端要等好几秒才出画面,快的 3 秒,慢的要 5 秒以上。检查编码器没发现异常。

原因:GOP(关键帧间隔)设置过大。如果编码器配置-g 250,24 帧率下就是 10 秒一个关键帧。播放器要等到最近一个 IDR 才能起播,所以延迟几乎等于 GOP 长度。

解决:把 GOP 控制在 1 到 2 秒,H.264 编码参数里对应-g 25到-g 50。如果输入源不由自己控制,可以在推流链路里加一层关键帧缓存,缓存最近一个 IDR,收到新的 IDR 时先发缓存的序列头,尽可能缩短首帧时间。

5. FFmpeg 衔接:测试流生成、对照验证与封装差异

这个工程不依赖 FFmpeg,但 FFmpeg 是调试它最顺手的工具。无论是造测试流还是验证推流结果,FFmpeg 都能省掉大量手工造数据的时间。

5.1 用 FFmpeg 生成符合输入的测试码流

裸 H.264 输入不好找,直接用 FFmpeg 合成一条,代码用testsrc发生器就行:

ffmpeg -f lavfi -i testsrc=duration=10:size=640x360:rate=25 \ -c:v libx264 -preset veryfast -g 50 -profile:v baseline \ -pix_fmt yuv420p -f h264 test.264

参数说明:-f lavfi -i testsrc是 FFmpeg 内置的视频测试源,生成一个带滚动时间条的彩色画面;-g 50表示每 50 帧一个关键帧,即 2 秒一个 GOP;-profile:v baseline禁止 B 帧;-f h264强制输出裸 H.264 格式,也就是 Annex-B 起始码结构。生成出来的 test.264 可以直接喂给上面的main函数。

5.2 用 ffprobe 验证输入不可靠的地方

跑通之前先确认输入流的参数,避免把锅甩给推流代码:

ffprobe -show_streams test.264

重点看profile是不是 Baseline,has_b_frames是不是 0,level数值是否合理。如果has_b_frames大于 0,说明输入带 B 帧,你的compositionTime必须按 4.2 节处理。这个检查我每次都会做,因为不同版本的 libx264 默认参数差异很大,依赖经验判断容易翻车。

5.3 与 FFmpeg 推流做对照验证

工程跑通后,可以用 FFmpeg 的标准实现在同一个地址上推流,对比服务器端收到的数据差异:

ffmpeg -re -i test.264 -c copy -f flv rtmp://127.0.0.1/live/stream

-re让 FFmpeg 按时钟速率发送;-c copy不做转码,直接把原始码流封装进 FLV;-f flv指定输出封装格式。这台标准实现的意义在于:如果 FFmpeg 推流正常、你的代码推流异常,问题基本出在封装细节,比如序列头、时间戳或 chunk size。用ffprobe分别拉两条流对比时间戳序列间隔,很快能定位差异点。

6. 二次开发:加音频、接摄像头、改造成库

这套源码的扩展点集中在三个方向:加音频推流、接入摄像头实时流、把工具改成可嵌入式调用。选一个方向动手,比从头读协议快得多。

6.1 加 AAC 音频推流的接入位置

音频最省事的路线是接收 AAC 裸流。解析 ADTS 头、去掉音频解复用层,封装成 FLV AudioTag,把采样率、声道数填入 SoundFormat 字段。接入点放在push_loop里,视频和音频 Tag 按时间戳交错发送。注意首帧必须发 AudioSpecificConfig,对应 AVCPacketType=1 的序列头,顺序不能反。

6.2 支持 V4L2 摄像头实时输入

把h264_parser的文件读取替换成 V4L2 设备读取,循环里不再等h264_next_nal返回文件数据,而是从read()拿摄像头驱动吐出的编码帧。V4L2 的 H.264 输出通常是 Annex-B 格式,能直接复现有解析逻辑。要处理的是帧率自适应,摄像头不是按时钟节拍出帧,时间戳用内核传入的V4L2_BUF_FLAG_TIMESTAMP换算成毫秒更稳。

6.3 抽取成可二次封装的库函数

把push_loop改成回调式接口,对外只暴露一个h264_push_frame(uint8_t *nal, int len, uint32_t pts_ms),内部自己做 NAL 分类与串行化。这样下游无论是采集卡还是视频文件,都只需要把数据丢进来。记得给每个公共函数加状态机保护,防止重复调用rtmp_pusher_connect导致会话泄漏。

有次我在调 6.2 的摄像头采集时,没检查 V4L2 输出的 SPS/PPS 是否在每次开播时都会重发,结果播放端一律黑屏到第二秒。从那以后每次改完推流采集,我都强制在本地先推一路,用 ffprobe 拉回来确认首帧是 IDR,再切到正式地址。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询