简介在FFmpeg开发实战中将H264编码的视频帧从内存中直接封装为FLV格式并通过RTMP协议推送至直播服务器是实时音视频领域常见的核心需求。这份C语言源码以单个.c文件完整演示了上述流程适合具备一定FFmpeg基础、正在学习音视频封装与流媒体传输的开发者参考。压缩包内仅包含1个源文件大小约3KB代码短小而集中没有多余工程配置便于直接打开逐行阅读核心逻辑。目前已有963人学习下载常常被作为入门级推流示例使用。通过研读源码可理清FFmpeg库的初始化、H264数据的解析与NAL单元处理、内存IO上下文创建、FLV Tag封装写入以及RTMP连接管理和推流收尾等关键环节其中涉及av_register_all、avformat_open_input、avio_open2、av_interleaved_write_frame等API的配合方式也展示了时间戳与同步信息如何嵌入FLV结构。对于希望快速上手FFmpeg推流开发、理解内存与网络协同工作的开发者而言这份精简源码具有很好的拆解和模仿价值。1. H264toRTMP源码包把编码器输出推到直播服务器的最小闭环做直播推流最容易遇到的问题是编码器出来的H264裸流和RTMP服务器要求的数据格式之间隔着一层FLV封装和RTMP协议栈。用ffmpeg命令行推流虽然方便但想把这个能力嵌进自己的程序里直接调ffmpeg命令行往往太重自己从零写RTMP握手又容易踩坑。这份H264tortmp源码.rar把链路拆得很干净H264解析、FLV封装、RTMP发送三段完全分离不依赖庞大的ffmpeg库用纯C就能实现推流。适合两种人一种是服务端需要集成推流功能的开发者另一种是想搞清楚RTMP协议内部状态、想要一份能读懂能修改的参考实现的从业者。2. 先搞懂封装链路H264裸流、FLV标签和RTMP消息是怎么串起来的打开压缩包之后不要急着编译先顺一遍代码里那三条链路。H264裸流本身只是一串NALURTMP服务器看不懂这种流格式。服务器期望的是经过FLV封装的数据而RTMP协议负责把FLV标签切成消息块发到对端。这个源码包没有把协议栈做成黑匣子而是在H264解析、FLV封装、RTMP发送三个文件里分别留有调试入口理解这三步后面看日志就能知道问题到底出在封装还是握手阶段。2.1 H264裸流处理的起点SPS/PPS与NALU边界H264编码器输出的数据通常有两种封装方式。一种叫AnnexB格式每个NALU前面带有起始码00 00 00 01或00 00 01另一种叫AVCC格式每个NALU前面用4字节表示长度。RTMP里的FLV视频数据采用AVCC形态所以h264_parser.c的第一个动作就是把AnnexB转成AVCC如果输入已经是AVCC则跳过转换。真正影响播放器能否首帧出画的是SPS和PPS这两个参数集。它们不随每一帧发送而是单独提取出来在FLV的AVC sequence header标签里发送之后每个关键帧前面都不再重复携带。// 从AVCC格式的H264数据中识别SPS/PPS并缓存 // data[0..3] 是NALU长度data[4] 是NALU头 int nalu_len (data[0] 24) | (data[1] 16) | (data[2] 8) | data[3]; uint8_t header data[4]; uint8_t nal_type header 0x1F; if (nal_type 7) { // SPS memcpy(sps_cache, data 4, nalu_len); sps_cache_len nalu_len; } else if (nal_type 8) { // PPS memcpy(pps_cache, data 4, nalu_len); pps_cache_len nalu_len; }这里默认data是AVCC格式长度字段是大端序。nalu_type取NALU头低5位7代表SPS8代表PPS5代表IDR关键帧1代表非关键帧。实际注意编码器可能会把SPS和PPS放到同一次回调里也可能分两次回调所以两个缓存要独立保存直到两者都齐了才能生成sequence header。如果拿到的输入是AnnexB需要先找到起始码再跳过源码包里也有对应的剥离函数。2.2 FLV封装的核心Tag头与数据体FLV是一个TAG序列每个TAG头固定11字节分别承载类型、数据长度、时间戳、流ID。视频Tag的数据部分不是直接塞NALU而是先写1字节视频参数再写AVCPacketType然后是CompositionTime。视频参数的高4位是帧类型低4位是编码ID对于H264来说关键帧写0x17非关键帧写0x27。AVCPacketType为0表示这是AVC sequence header里面放SPS/PPS为1表示普通NALU帧。这些信息在代码的flv_mux.c里已经拼好但建议自己写一遍因为很多服务器对CompositionTime为0的处理有差异。// 构造FLV tag头返回已填充的字节数 int fill_flv_tag(uint8_t *buf, uint8_t tag_type, uint32_t data_size, uint32_t timestamp, uint32_t stream_id) { buf[0] tag_type; // 8音频, 9视频, 18脚本 buf[1] (data_size 16) 0xff; buf[2] (data_size 8) 0xff; buf[3] data_size 0xff; buf[4] (timestamp 16) 0xff; buf[5] (timestamp 8) 0xff; buf[6] timestamp 0xff; buf[7] (timestamp 24) 0xff; // 超过24位时使用扩展时间戳 buf[8] (stream_id 16) 0xff; buf[9] (stream_id 8) 0xff; buf[10] stream_id 0xff; return 11; }这段代码的要点是时间戳按毫秒为单位当时间戳超过0xFFFFFF时前置位要置1并在tag数据后面额外补充4字节扩展时间戳。很多自研推流器只做24位毫秒时间戳推流超过24小时就会出问题。虽然直播场景很少这么长时间但录播文件推流时会遇到源码包里这一块是按扩展时间戳处理的。2.3 RTMP消息块与AMF0握手、chunk size与协议状态FLV TAG准备好之后RTMP发送不只是往socket里写数据。RTMP先要完成握手客户端发C0、C1服务端回S0、S1客户端再发C2服务端回S2。然后还需要通过AMF0格式发送一串命令connect、createStream、publish只有进入publish状态之后才能把FLV标签包装成RTMP消息发送。这个源码包的rtmp_push.c里用状态机管理这几个阶段每次状态变化都会打一行日志方便对照抓包。C0: 0x03 C1: 1536字节随机数据时间戳 S0: 0x03 S1: 1536字节服务端信息 C2: 回显S1的时间戳和随机数 S2: 回显C1的时间戳和随机数流程里最容易漏的是connect命令之后的等待。connect是一个AMF0命令包含命令名、事务ID、对象信息等需要等服务端返回_result然后才能继续进行。有些服务器对chunk size很敏感默认chunk size是128字节如果你把一个大FLV标签直接整个塞进去没有按chunk size拆分服务器可能直接断开。源码包在rtmp_socket.c里实现了chunk size协商发送前会根据对端通告的chunk size拆分。选型上有一个值得说的点这个源码包没有选择直接依赖libavformat而是自己实现了RTMP发送。这样做的好处是依赖少工程里只需要一个线程调用发送接口不需要引入复杂的FFmpeg转封装配置。坏处是如果要支持HEVC或其他编码需要自己扩展FLV的codec ID这算是一个边界。对于只推H264的场景这个包的代码量刚好能通读。3. 把源码跑起来编译、配置与第一次推流把压缩包解压之后目录里能看到h264_parser.c、flv_mux.c、rtmp_socket.c、rtmp_push.c以及一个简单的示例调用程序test_push.c。这个包不强制依赖ffmpeg编译时只需要标准C库和pthread线程库。我们要做的是先把它跑通再把输入源换成自己的编码器数据。3.1 工程结构与依赖先直接编译一遍验证工具链没问题。这里的gcc命令没有加优化参数排错阶段尽量保留符号信息等调试完再上-O2。gcc -c h264_parser.c -o h264_parser.o gcc -c flv_mux.c -o flv_mux.o gcc -c rtmp_socket.c -o rtmp_socket.o gcc -c rtmp_push.c -o rtmp_push.o gcc -o test_push test_push.c h264_parser.o flv_mux.o rtmp_socket.o rtmp_push.o -lpthread编译时可能会看到关于gettimeofday和struct timeval的提示不同系统需要加-D_DEFAULT_SOURCE或者-D_GNU_SOURCE。我的习惯是在Makefile里统一加-Wall -D_GNU_SOURCE -lpthread这样在多数Linux环境上都能一次过。另外rtmp_socket.c里用了封装socket读写的小函数如果要在Windows上跑需要改用Winsock API压缩包注释里提到跨平台适配不是这版的重点所以纯Linux工程直接用更省事。3.2 初始化推流器示例代码中初始化推流器的接口签名是rtmp_push_init(push, param)。param结构体里要填推送地址、流名称、宽高和码率等。注意这里的推送地址有两种写法一种是完整的rtmp://host/app/stream另一种是只写rtmp://host/app然后把stream_key单独填。rtmp_push_t *push rtmp_push_create(); rtmp_push_param_t param {0}; param.url rtmp://your-server.example.com/live; param.stream_key stream123; param.width 1280; param.height 720; param.fps 30; param.video_bitrate 2000000; param.connect_timeout_ms 3000; int ret rtmp_push_init(push, param); if (ret ! 0) { fprintf(stderr, init failed, ret%d\n, ret); return -1; }这里的url和stream_key关系要注意很多开源服务端在收到publish命令时会以app/stream_name作为发布路径。若直接把完整流地址填进urlstream_key却又不为空最终拼接出来的发布点可能不对。我一般会先看代码里publish命令的构造逻辑再决定填法一般默认写成urlrtmp://host/app、stream_keystream。param里的connect_timeout_ms是连接超时设为0表示无限等待建议设一个3到5秒的初值避免DNS解析卡死整个线程。3.3 喂入H264数据初始化成功后就可以把编码器输出的每一帧数据推过来。一个容易混淆的点是这里的H264数据应该是一个完整的AUAccess Unit也就是包含所有NALU的编码帧而不是单个NALU。如果你把SPS、PPS和IDR帧分拆成三次调用那么FLV封装出来的时间戳就乱了。// 编码器每输出一帧就调用一次 // pts_ms 是单调递增的毫秒时间戳 // is_keyframe 为1表示IDR关键帧 int ret rtmp_push_send_video(push, h264_frame, frame_size, pts_ms, is_keyframe); if (ret RTMP_PUSH_OK) { // 正常发送 } else if (ret RTMP_PUSH_BLOCK) { // socket发送缓冲满需要稍等再继续 }要留意pts_ms的语义。RTMP要求视频时间戳是毫秒单位并且差值不要剧烈跳变。如果你沿用编码器里的PTS而编码器基于90kHz时钟那必须先除以90才能填入。源码包里没有做这个换算它假设调用方已经转换好。另外is_keyframe这个参数很重要关键帧在FLV里要标记为frame type 1非关键帧标记为2。如果传错服务端可能把关键帧当作非关键帧处理导致播放器花屏。3.4 停止与清理推流结束后不要直接destroy要先断开RTMP连接再释放资源。顺序反了可能让服务端以为流异常中断。rtmp_push_disconnect(push); rtmp_push_destroy(push);disconnect函数内部会发送FCUnpublish和deleteStream命令并关闭socket。如果服务器对异常断开不及时清理过一段时间再去重复推同一个流名可能报“already publishing”。更稳妥的做法是在destroy之前设置一个FLV end tag让播放器那边知道视频流正常结束。源码包里没有提供自动发end tag的接口我习惯在停止时自己拼一个FLV End标签再走disconnect。4. 避坑指南从黑屏到断流四个最常见问题这套推流链路里坑大多集中在SPS/PPS处理、时间戳、RTMP握手和发送缓冲四个方面。下面每一条都是实际推流调试中反复出现的问题按照现象、原因、解决的顺序记下来排查时可以直接对照。4.1 推流黑屏SPS/PPS没有在关键帧前发送现象是播放器能连接上画面一直黑屏但能看时长在增加用ffplay打开时提示“no frame!”。原因是FLV封装的视频序列头没发播放器拿不到SPS/PPS就无法初始化解码器。很多实现只把编码器输出的IDR关键帧当成普通视频tag发送而SPS/PPS被漏掉了。H264的参数集不会出现在每一帧里必须用AVC sequence header标记发送。解决方法是第一次发送关键帧之前先调用flv_mux内部的avc_sequence_header()接口把缓存的SPS/PPS拼成一个FLV视频Tag。同时检查发送顺序第一帧必须是AVC sequence header第二个Tag才允许是关键帧。压测时我还见过一种情况关键帧里本身携带了参数集播放器能出画面但换成不携带的编码器就黑屏。源码包的处理是统一缓存SPS/PPS不依赖关键帧内部是否带参数集。4.2 时间戳跳变H264 PTS单位与RTMP时间戳单位不一致现象是推流后播放器卡顿画面一帧一帧跳或者播放器提示“Non-monotonic DTS”。原因是编码器输出的PTS通常基于90kHz时钟而RTMP要求毫秒时间戳。如果不转换直接把PTS丢进去两个帧的时间差就会放大90倍。比如30fps的帧间隔应该是33毫秒用90kHz的PTS填进去就成了3000毫秒播放器自然认为是边录边放。解决方法是给耗时统计函数加一个时间戳换算器pts_ms pts_90khz / 90。还有一个容易被忽略的点如果你喂入的是文件读取的H264文件里可能带着旧的DTS直接重编码时要把起始时间戳归零。我一般会在推流器内部记录第一个时间戳之后每个时间戳都减去它保证从0开始。4.3 连接被服务器断开AMF0握手和chunk size设置问题现象是推流几秒到几十秒后服务端主动断开日志显示“Session has been closed”。抓包发现已经正常connect但发送大帧时连接被重置。原因是RTMP消息超过chunk size时没有正确拆分。默认chunk size是128字节如果一次写入几千字节服务端按128字节块读取就会出现粘包和解析错乱。另一个原因是握手时C1里的随机数格式太简单被某些服务器拒绝。解决方法是先协商chunk size一般在connect命令之后读取服务端返回的_result同时解析服务端建议的chunk size修改本地发送块大小。源码包在rtmp_socket.c里已经有rtmp_set_chunk_size()接口关键是发送的每个消息块都要先写基本头再根据chunk size循环写入扩展头和负载。如果服务器要求4字节对齐记得在最后补足。至于握手随机数直接用当前时间戳加固定随机序列填充别全填0。4.4 内存持续增长发送缓冲没有限流现象是长时间推流后进程内存稳步上升最终OOM或被监控杀掉。用valgrind查不出明显泄漏但内存曲线随着帧率上升。原因是推流端接到编码器高码率数据时如果服务器或网络出现波动socket发送缓冲会积压。代码里如果只把数据拷入队列而不做队列上限控制就会一直增长。遇到卡顿后恢复队列里积压的旧帧又会被立刻发出去造成播放器端突发延迟。解决方法是给发送队列加长度上限超过上限时丢弃非关键帧只保留最新的关键帧和之后的非关键帧。这个源码包里发送是同步阻塞的RTMP_PUSH_BLOCK返回值就是留给调用方做背压的。我处理的方式是如果返回BLOCK当前线程的循环直接sleep 10毫秒同时丢到当前队列里最老的一帧。注意丢帧要丢非关键帧关键帧最好保留否则下一个IDR之前全是花屏。5. 进阶把源码改造成自己的转推服务基础推流跑通后很多人会想要一个更通用的服务从文件读视频流推流或者从摄像头编码器实时回调推流。这个源码包本身的输入是内存帧意味着它可以很轻松地挂到ffmpeg或者其他输入源上。这里记录几个我实际改造过的方向。5.1 从文件输入改造为实时编码器输入压缩包里的示例test_push.c是直接从一个内存数组里循环读帧真实项目里更多是从ffmpeg编码器回调获取数据。以ffmpeg为例编码器输出AVPacket后取出packet-data和packet-size转成H264裸流即可送入推流器。要注意的是ffmpeg的AVPacket里可能带side data如SEI信息有些服务端对SEI不敏感但播放器会用SEI渲染时间轴所以要不要保留SEI需要根据服务端做取舍。// 伪代码ffmpeg编码回调接入推流器 AVPacket *pkt av_packet_alloc(); int got_packet 0; ret avcodec_encode_video2(ctx, pkt, frame, got_packet); if (got_packet) { uint8_t *h264_data pkt-data; int h264_size pkt-size; int64_t pts_ms av_rescale_q(pkt-pts, ctx-time_base, (AVRational){1, 1000}); int key (pkt-flags AV_PKT_FLAG_KEY) ? 1 : 0; rtmp_push_send_video(push, h264_data, h264_size, pts_ms, key); }这里av_rescale_q是推荐用法避免手算除法产生舍入误差。编码器的time_base可能是1/90000转成毫秒后再交给推流器。注意如果编码器开启了B帧packet的pts和dts会不一样RTMP没有B帧显示顺序的复杂标记通常要保证发送顺序按照解码顺序显示时间使用CompositionTime补偿。源码包里的CompositionTime需要外部传入默认传0对于有B帧的场景要扩展接口。5.2 断线重连与码率自适应RTMP推流在弱网环境下非常容易断开。基础版源码包断开了就直接返回错误实际应用里需要重连。我加了一个简单的状态机发送返回-1时关闭socket等1秒重新执行rtmp_push_init、重新发送SPS/PPS序列头然后接着从当前帧数据继续推。这里注意不要从头开始重推而是从最近一个关键帧之后开始否则会有一段花屏。码率自适应更复杂一点通常依赖上游编码器调节码率。我在推流器外部加了一个统计窗口统计最近5秒的实际发送码率如果小于配置码率的80%说明正在拥塞可以给编码器回调降低目标码率。这个源码包不直接控制编码器但它的发送返回值可以反映拥塞状态在调用方做限速即可。5.3 多路推流与线程模型如果需要同时推多路流建议每个推流器实例独占一个线程并且每个实例拥有独立的socket和发送队列。源码包里没有全局锁所以同一实例不被多线程并发调用就不会有问题。它内部的发送是阻塞式的多路推流时要保证每个线程的循环不会被一路卡的断流拖住。我在实际部署时用了一个简单的线程池主线程从队列里拿帧按流ID分发到对应推流器的锁队列推流线程只负责从队列取帧发送。如果某一帧发送超时只丢弃该帧而不影响其他流。这种方法比在推流器内部做异步发送简单得多也能满足几十路以内的并发需求。6. 最后收尾用ffprobe验证你的推流结果推流服务起来之后不要只看“连接成功”的日志要站在播放器视角验证。最直接的工具是ffprobe它可以拉取RTMP流并打印封装和编码信息。ffprobe -v error -show_streams rtmp://your-server.example.com/live/stream123正常输出里会看到codec_nameh264并且有profile、level和SPS/PPS信息。如果只有codec_nameh264但没有extradata说明SPS/PPS没发出去。接着再用ffplay拉流重点观察拉流启动时间和首帧画面。我在上线前会固定走一遍这个验证流程先ffprobe再ffplay然后连推15分钟看日志里有没有断连或用时突增。这样做的好处是推流确认是源码包的问题而不是播放器兼容性问题。从那以后我每次改推流器都会强制走一遍ffprobe检查确认SPS/PPS和时间戳序列正常再交出去希望帮到你。本文还有配套的精品资源点击获取