简介:这份压缩包面向希望打通 H.264 到直播推流链路的中初级音视频开发者,聚焦如何借助 FFmpeg API 在内存中完成 H.264 NAL 单元到 FLV 封装的转换,并通过 RTMP 协议推送至流媒体服务器,可作为理解直播推流核心流程的轻量参考。包内共 1 个 C 源文件,压缩包约 3KB,体量虽小,却集中呈现了网络库初始化、FLV 封装、RTMP 连接创建、帧数据持续写入以及连接关闭与资源释放等关键环节,便于读者对照梳理时间戳处理、内存 IO 与推流接口之间的衔接方式。这份源码已有 964 人学习,适合用来拆解 H.264 到 FLV 再到 RTMP 的调用关系,理解封装与推流的边界,并积累实时音视频应用开发中的接口使用经验和排错思路。
1. 从一路裸 H264 到可播放的 RTMP 流:这份源码补的正是中间那层
摄像头模组、硬件编码器、录屏 SDK 吐出来的,通常是一段段 Annex-B 裸 H264,每个 NALU 前面顶着00 00 00 01的起始码,没有容器、没有时间戳、没有帧类型标记。而播放器和 CDN 要的,是一条 RTMP 连接里一个个带毫秒时间戳的 FLV Tag。两头对不上,中间就缺一层封装。这份 H264tortmp 源码做的就是这层:切 NALU、抽 SPS/PPS 生成 AVCDecoderConfigurationRecord、按 FLV 规矩打上帧类型和 AVCPacketType,再经 ffmpeg 的 libavformat 从rtmp://推出去。它不做编码也不做解码,纯搬运和重组,适合手上有裸流、又不想在服务端多转一道的开发者——IPC、录屏推流、图传、内网低延迟直播这类场景,用得上。
2. 工程骨架拆解:解析、封装、推流三层怎么分工
拿到压缩包解压后第一眼会有点意外:代码量不大,核心逻辑集中在一只手数得过来的几个.c文件里。这其实是好事,说明作者没把东西搅在一起。整个工程按职责切成三层,层与层之间只靠结构体和环形队列通信,任何一层想换实现都不牵动另外两层。
2.1 目录结构与各模块职责
先看目录,心里有个地图再动手编译,能省掉一半的迷茫。
h264_rtmp/ ├── src/ │ ├── main.c 入口:解析命令行、拉起推流线程、信号处理 │ ├── h264_parser.c/.h Annex-B 切分、SPS/PPS 提取、NAL 类型判定 │ ├── flv_muxer.c/.h 组 avcC、填 AVPacket、写 FLV Tag │ ├── rtmp_sender.c/.h URL 拼接、连接、断线重连、发送循环 │ └── ring_buffer.c/.h 定长环形队列,解耦收流和发送 ├── include/ │ └── config.h 码率、GOP、超时、重连间隔等宏 ├── third_party/ffmpeg/ 预编译头文件与静态库(有些包只留了 include) ├── conf/push.conf 推流地址与参数,方便改配置不用重编 └── build.shh264_parser.c只认字节流,输入一段 buffer,输出若干{nal_ptr, nal_len, nal_type},它不知道 FLV 是什么,也不知道 RTMP 是什么。flv_muxer.c拿到 NAL 之后负责两件事:首帧到来时把 SPS/PPS 塞进extradata并写 sequence header tag;之后每个 NAL 加 4 字节长度前缀、包成 AVPacket 交给 libavformat。rtmp_sender.c是唯一碰网络的一层,负责avio_open、写包、出错重连。ring_buffer.c存在的意义是把「读多快」和「发多快」解耦——上游是文件时读得飞快,上游是摄像头时又可能被阻塞,中间没有缓冲,时间戳一定乱。
这套分层带来的直接好处是:想换成 UDP 收流,只改main.c的数据源;想换成 HTTP-FLV 输出,只改rtmp_sender.c里的 URL 和 muxer 名,其余代码一行不动。
2.2 一条 NALU 从读入到发出的完整链路
把主循环摊开看,整条链路一共六个阶段,每个阶段的输入输出都很明确:
| 阶段 | 输入 | 输出 | 负责模块 |
|---|---|---|---|
| 读原始字节 | 文件/管道/UDP | 字节 buffer | main.c |
| 切 NAL | 字节 buffer | NAL 数组 | h264_parser.c |
| 判类型 | NAL header | SPS/PPS/IDR/普通帧 | h264_parser.c |
| 组 extradata | SPS + PPS | avcC 字节串 | flv_muxer.c |
| 打包 | NAL + pts | AVPacket | flv_muxer.c |
| 发送 | AVPacket | RTMP chunk | rtmp_sender.c |
主循环的骨架大致是这个样子,看懂了它,后面所有细节都只是往里填空:
/* 推流主循环:读 → 切 → 打包 → 发,失败就退避重连 */ for (;;) { int n = source_read(src, buf, sizeof(buf)); /* 阻塞读,文件源返回 0 表示 EOF */ if (n <= 0) break; nal_t nals[MAX_NAL_PER_AU]; int cnt = h264_split_nals(buf, n, nals, MAX_NAL_PER_AU); /* 按起始码切开 */ if (cnt <= 0) continue; for (int i = 0; i < cnt; i++) { if (nals[i].type == 7) { save_sps(&nals[i]); continue; } /* SPS 不进画面 */ if (nals[i].type == 8) { save_pps(&nals[i]); continue; } /* PPS 同上 */ if (!muxer->header_written) { muxer_write_sequence_header(muxer); /* 第一个非 SPS/PPS 到达前必须先写 avcC */ } int64_t pts = next_pts(src); /* 见 4.3,按帧率或源时间戳推 */ if (muxer_write_frame(muxer, &nals[i], pts) < 0) { sender_reconnect(sender); /* 发不出去就地重连,不丢循环 */ } } }这段代码里有两个容易看漏的点。一是 SPS/PPS 必须continue掉,不能当普通帧写进去——它们会被flv_muxer单独塞进 extradata,再当画面帧写一遍,播放器解析 avcC 时就会拿到重复的 parameter set,行为不可预期。二是header_written的判断位置,必须放在第一个真正的画面帧之前,不能放在连接建立之后:RTMP 连上了但还没写 avcC 就发帧,服务端会直接吐一个 "Missing sequence header" 把连接掐了。
2.3 选型对比:libavformat 一把梭 vs 手写 RTMP
工程里用的是 libavformat 走完整封装路径。有人会问,既然只是把 NAL 塞进 FLV 再走 RTMP,为什么不干脆手写?做个对比就清楚了:
| 维度 | libavformat | 手写 RTMP + FLV |
|---|---|---|
| 代码量 | 约 300 行 | 2000 行起 |
| 握手与 chunk 分片 | 库内部处理 | 自己做 C0/C1/C2、S0/S1/S2 |
| 时间戳管理 | 有 time_base 体系 | 全靠自己算 |
| 排错手段 | AV_LOG_DEBUG一把梭 | 只能加 printf |
| 依赖体积 | 静态链接后 2~3 MB | 几乎为零 |
| 可控性 | 中,遇到怪问题要看库源码 | 高,每一字节都是自己的 |
结论其实很朴素:除非目标环境对二进制体积有硬性限制,或者需要对接某些非标准 RTMP 变种,否则没有理由手写。这个项目选 libavformat,代价是包里必须带 ffmpeg 的头和库,好处是协议层的坑别人已经替你踩过一遍了。
注意:
third_party/ffmpeg在很多流传的包里只保留了include,库文件是空的。编译前先ls一下lib/目录,空的就去用系统 ffmpeg 的pkg-config路径,别对着链接错误干瞪眼。
3. 编译与跑通:从解压到 ffplay 看到画面
这一章按我自己的习惯顺序走:先确认依赖,再编译,再对参数,最后用三处证据确认推流真的成功了。整个过程在一台普通开发机上十分钟能走完,前提是别跳步骤。
3.1 依赖版本与编译参数
ffmpeg 的 API 在 4.x 到 6.x 之间有过几轮清理,av_register_all这类函数早没了。这个工程的调用方式偏新(用avformat_alloc_output_context2而不是手工av_alloc_format_context),所以拿 ffmpeg 4.2 以上的版本比较稳妥。先确认系统里有什么:
# 看系统 ffmpeg 版本和三件套是否齐全 pkg-config --modversion libavformat libavcodec libavutil # 编译:注意 -lpthread,工程里用了独立推流线程 gcc -O2 -g -Wall -Wextra \ -Iinclude -Ithird_party/ffmpeg/include \ src/*.c -o h264_rtmp \ -Lthird_party/ffmpeg/lib \ -lavformat -lavcodec -lavutil -lpthread如果包里的库文件缺失,把-I和-L换成pkg-config --cflags --libs libavformat libavcodec libavutil的输出即可。想自己裁一份干净的 ffmpeg,只保留必需组件,configure 长这样:
./configure \ --disable-everything \ --enable-protocol=file,rtmp \ --enable-demuxer=h264,flv \ --enable-muxer=flv \ --enable-parser=h264 \ --enable-avformat --enable-avcodec --enable-avutil \ --disable-shared --enable-static \ --prefix=/opt/ffmpeg-min--disable-everything会关掉所有组件,后面再逐个打开。这样编出来的库通常只有几百 KB,静态链接进去整个可执行文件也就 2 MB 出头,塞进嵌入式设备很舒服。代价是以后想临时加个 mp4 输出,得回头重编 ffmpeg——所以项目还在验证阶段时,我一般不这么干,等形态定了再裁剪。
3.2 配置项与命令行参数
工程支持命令行和conf/push.conf两种配置方式,后者优先级更高。参数不多,但每一个都影响最终能不能播:
| 参数 | 默认值 | 说明 |
|---|---|---|
-i | 无 | 输入源,可以是裸 h264 文件、udp://收流地址、-表示 stdin |
-o | 无 | 输出,格式rtmp://host:1935/app/stream |
-r | 25 | 帧率,源没有时间戳时按帧数硬推 PTS |
-g | 50 | 关键帧间隔(帧数),影响服务端 GOP 缓存和首屏速度 |
-reconnect | 1 | 断线是否自动重连,0 关闭 |
-timeout | 5000 | avio_open超时毫秒数,内网可以调小 |
配置文件长这样,改完不用重编:
# conf/push.conf input = /data/test.h264 output = rtmp://127.0.0.1:1935/live/test fps = 25 keyint = 50 reconnect = 1 timeout_ms = 5000一个常见的误区是把-g当成编码参数调。这工程不做编码,-g只是告诉 muxer「每多少帧应该出现一个关键帧」,用来在推流侧打标签、在丢包时决定从哪儿重新对齐。真正的 GOP 是编码器决定的,如果上游摄像头输出的是 100 帧一个 I 帧,你把-g设成 25 也不会让关键帧变多。
3.3 三段验证:ffplay、服务端日志、抓包
推流程序返回 0 不代表推成功了,它只代表进程没崩。我一般按三步验证,任何一步没对上就停在那里查。
第一步,本机拉流。用 ffplay,加上低延迟参数,否则默认缓冲会让你误以为延迟很大:
# 启动推流 ./h264_rtmp -i test.h264 -o rtmp://127.0.0.1:1935/live/test -r 25 -g 50 # 另一终端拉流,关掉缓冲看真实延迟 ffplay -fflags nobuffer -flags low_delay -framedrop \ -probesize 32 -analyzeduration 0 \ rtmp://127.0.0.1:1935/live/test第二步,看服务端日志。不管用的是哪个流媒体服务,publish成功时一定会打一行类似publish stream=test的记录,并且随后的bytes_in会持续增长。如果只有connect没有publish,说明 RTMP 握手过了但推流名不对,多半是 URL 里app/stream拼错。
第三步,抓包。前两步都正常但画面还是有问题时,抓包是唯一能看清真相的手段:
# 抓 RTMP 端口,存下来用 wireshark 看握手和 chunk sudo tcpdump -i any -s0 -w rtmp.pcap port 1935 # 或者直接看 ffmpeg 自己的协议日志 ./h264_rtmp -i test.h264 -o rtmp://127.0.0.1:1935/live/test 2>&1 | grep -i rtmp在 wireshark 里过滤rtmp,重点看两处:connect之后服务端返回的_result里 code 是不是NetConnection.Connect.Success,以及第一个 video tag 的 AVCPacketType 是不是 0。这两处对上了,剩下的问题基本都在时间戳和帧类型上。
4. 核心代码逐段拆解:SPS/PPS、AVCC 转换与时间戳
真正决定能不能播、延迟大不大的,就是下面这四段。它们加起来不到两百行,但每一行都对应一个具体的坑。
4.1 从裸流里抠出 SPS/PPS 并生成 avcC
H264 的 NAL header 只有 1 个字节,低 5 位是类型:7 是 SPS,8 是 PPS,5 是 IDR 帧,1 是非 IDR 帧。先写一个在字节流里按类型找 NAL 的函数:
/* 在 Annex-B 字节流里找指定类型的 NAL,成功返回 0,out 指向 NAL 载荷(不含起始码) */ static int find_nal(const uint8_t *buf, int len, int want_type, const uint8_t **out, int *out_len) { for (int i = 0; i + 4 < len; ) { int sc = 0; if (buf[i] == 0 && buf[i+1] == 0 && buf[i+2] == 1) sc = 3; else if (buf[i] == 0 && buf[i+1] == 0 && buf[i+2] == 0 && buf[i+3] == 1) sc = 4; if (!sc) { i++; continue; } /* 不是起始码,往后挪一字节 */ const uint8_t *nal = buf + i + sc; int type = nal[0] & 0x1F; /* 低 5 位才是 NAL 类型 */ int j = i + sc + 1; while (j + 2 < len && !(buf[j] == 0 && buf[j+1] == 0 && buf[j+2] == 1)) j++; int n = j - (i + sc); while (n > 1 && nal[n-1] == 0) n--; /* 4 字节起始码多带一个 0,回退掉 */ if (type == want_type) { *out = nal; *out_len = n; return 0; } i = j; /* 跳到下一个起始码,别逐字节扫 */ } return -1; }i = j那一句看着不起眼,其实是性能关键。如果写成i++,一段 2 MB 的 IDR 帧会被从头到尾扫一遍找起始码,在嵌入式设备上直接卡住。跳到下一个起始码位置,复杂度从 O(帧长×NAL数) 降到 O(帧长)。
拿到 SPS 和 PPS 之后,拼 avcC。这段字节序列是 FLV 里 AVCPacketType=0 那个 tag 的载荷,也是 ffmpeg 里AVCodecParameters::extradata的内容:
/* 按 ISO/IEC 14496-15 拼 AVCDecoderConfigurationRecord,返回写入字节数 */ static int build_avcc(const uint8_t *sps, int sps_len, const uint8_t *pps, int pps_len, uint8_t *out) { int p = 0; out[p++] = 0x01; /* configurationVersion,规范写死 1 */ out[p++] = sps[1]; /* AVCProfileIndication,来自 SPS 第 2 字节 */ out[p++] = sps[2]; /* profile_compatibility */ out[p++] = sps[3]; /* AVCLevelIndication,常见 0x1F/0x28/0x32 */ out[p++] = 0xFC | 0x03; /* 高 6 位保留全 1,低 2 位 lengthSizeMinusOne=3,即 4 字节长度前缀 */ out[p++] = 0xE0 | 0x01; /* 高 3 位保留全 1,低 5 位 numOfSPS=1 */ out[p++] = (sps_len >> 8) & 0xFF; out[p++] = sps_len & 0xFF; memcpy(out + p, sps, sps_len); p += sps_len; out[p++] = 0x01; /* numOfPPS=1 */ out[p++] = (pps_len >> 8) & 0xFF; out[p++] = pps_len & 0xFF; memcpy(out + p, pps, pps_len); p += pps_len; return p; /* 一般是 30~50 字节,取决于 SPS 长度 */ }参数说明一下:lengthSizeMinusOne这个字段决定了后面每个 NAL 前面挂几个字节的长度。填 3 表示 4 字节大端长度,这是绝大多数编码器的默认值;如果上游用的是 2 字节长度(少见),这里得改成 1,同时 4.2 那段也要跟着改,两处必须一致,否则播放器按错误的位宽读长度,画面立刻花掉。
4.2 Annex-B 转 AVCC:起始码换成 4 字节长度前缀
裸流里 NAL 之间用起始码分隔,FLV 里用长度前缀分隔,这一步就是格式转换的全部内容:
/* 一个 NAL 载荷 → [4 字节大端长度][NAL 载荷] */ static int nal_to_avcc(const uint8_t *nal, int nal_len, uint8_t *dst, int dst_cap) { if (nal_len <= 0 || nal_len + 4 > dst_cap) return -1; dst[0] = (nal_len >> 24) & 0xFF; /* 大端,高位在前 */ dst[1] = (nal_len >> 16) & 0xFF; dst[2] = (nal_len >> 8) & 0xFF; dst[3] = nal_len & 0xFF; memcpy(dst + 4, nal, nal_len); return nal_len + 4; }看起来简单,但有两个细节必须注意。第一,长度是大端,x86 上直接memcpy(&len, dst, 4)会得到小端结果,画面会花成一片,这个错我见过不止一次。第二,nal_len传进来的必须是去掉起始码之后的长度,多算一个00就会让播放器把这 1 字节当成下一个 NAL 的长度高位,整个帧就废了。4.1 里那个while (n > 1 && nal[n-1] == 0) n--就是干这个的。
还有一个容易忽略的问题:一帧(access unit)里往往不止一个 NAL。低分辨率编码器一般一帧一个 NAL,上了 1080p 之后,一个 I 帧常被切成 4 个 slice,也就是 4 个 NAL。这 4 个 NAL 要拼在同一个 AVPacket 里发出去,不能拆成 4 个包——拆了之后时间戳相同、帧数翻倍,播放器会认为是 4 帧,帧率直接乱掉。正确做法是先算总长,再一次性写进同一个 buffer。
4.3 时间戳换算与首帧归零
时间戳是最容易出玄学问题的地方。FLV 的时间戳单位是毫秒,AVStream::time_base是1/1000;而摄像头和 MPEG-TS 常见的是 90 kHz。两套体系之间换算错了,表现是播放器进度条乱跳或者干脆不播。
/* src_tb 常见 1/90000(TS)或 1/1000(毫秒级采集),base 是首帧原始时间戳 */ static int64_t ts_to_ms(int64_t ts, int num, int den, int64_t base) { int64_t ms = (ts - base) * 1000 * num / den; /* 先减基准再换算,避免大数溢出 */ return ms < 0 ? 0 : ms; }base这个基准值是血泪经验。很多采集卡上电后时间戳从几百万甚至几十亿开始,直接换算成毫秒,第一个包的时间戳就是几十万毫秒,服务端会认为你推了一个已经播了很久的流,GOP 缓存直接失效,首屏要么很慢要么黑屏。正确做法是记住第一帧的原始时间戳当基准,后面全部减掉它,让流从 0 开始。
填 AVPacket 的时候,dts和pts的关系也要明确:
pkt->pts = pkt->dts = ts_to_ms(raw_ts, tb_num, tb_den, base); pkt->stream_index = st->index; pkt->flags = is_key ? AV_PKT_FLAG_KEY : 0; /* 关键帧必须打上,否则服务端没法做 GOP 缓存 */ pkt->duration = 1000 / fps; /* 毫秒,按帧率均分 */ /* 如果内部 time_base 不是 1/1000,还要再转一次 */ av_packet_rescale_ts(pkt, (AVRational){1, 1000}, st->time_base);duration很多人不填,短时间看不出问题,跑久了时钟漂移会累积。服务端做 GOP 缓存和切片时依赖这个字段,缺失的话拉流端可能出现音画不同步或者切片时长不准。裸 H264 没有 B 帧,pts恒等于dts;如果哪天把输入换成了带 B 帧的码流,这里就得单独算 dts,否则 muxer 会直接报 "non-monotonous dts"。
4.4 推流循环与断线重连
发送层最怕的不是连不上,是连上了又断、断了没清干净。RTMP 走 TCP,网络抖一下就会断,重连逻辑写不好,跑一晚上文件描述符就耗光了。
for (;;) { if (!ctx->connected) { if (rtmp_connect(ctx) < 0) { /* 内部做 alloc_output_context2 + avio_open */ av_log(NULL, AV_LOG_WARNING, "connect failed, retry in %d ms", ctx->retry_ms); av_usleep(ctx->retry_ms * 1000); /* 指数退避,封顶 8 秒,避免服务端重启时被疯狂重连打满 */ ctx->retry_ms = ctx->retry_ms * 2 > 8000 ? 8000 : ctx->retry_ms * 2; continue; } ctx->retry_ms = 500; /* 连上就复位 */ } int n = ring_pop(ctx->rb, ctx->buf, sizeof(ctx->buf)); if (n <= 0) { av_usleep(2000); continue; } /* 队列空,让出 CPU,别忙等 */ if (push_one_frame(ctx, ctx->buf, n) < 0) { rtmp_close(ctx); /* 关键:avio_closep + avformat_free_context 都要调 */ ctx->connected = 0; } }rtmp_close里必须把三样东西都释放掉:avio_closep(&oc->pb)、avformat_free_context(oc)、以及oc->pb背后的 TCP socket。少放一样,重连一百次就泄漏一百个 fd,进程迟早挂在Too many open files。指数退避也很必要——服务端重启那几十秒,如果按 10 ms 间隔重连,几千次握手请求会把服务端的 accept 队列打爆,反而谁都连不上。
提示:重连成功后第一件事是重写 sequence header。新连接在服务端看来是一个全新的会话,不会继承上一次的 extradata,只发画面帧过去,播放器依然黑屏。
5. 常见问题排查:花屏、延迟、连不上、内存涨
下面这五条基本覆盖了这个工程 90% 的现场问题,每条都按现象、原因、解决三段写。遇到问题时按顺序对号入座,比漫无目的地翻代码快得多。
5.1 播放器黑屏,日志一直刷 "no extradata"
现象:ffplay 能连上,窗口出来了但一直是黑的,控制台反复打印缺少 extradata 或者无法确定编码格式。
原因:sequence header 没写出去,或者写的时间点不对。常见两种情况,一是代码里把 SPS/PPS 当普通帧发了、extradata 一直是空的;二是avformat_write_header调用了但AVCodecParameters::extradata在调用之后才赋值,头信息已经发完了,后面的修改不生效。
解决:确保extradata在avformat_write_header之前就av_malloc好、build_avcc填好、长度写进extradata_size。如果输入源是「先来一段 IDR,SPS/PPS 夹在中间」这种非标准顺序,得在读完第一帧后回退去扫 SPS/PPS,别指望它一定在前面。
5.2 画面花屏、绿块,I 帧之后恢复正常
现象:每隔一两个 GOP 就出现一次花屏,持续几百毫秒,下一个 I 帧到来后自动恢复,如此循环。
原因:丢包。RTMP 走 TCP 理论上不丢,但服务端在缓冲溢出时会主动丢弃非关键帧切片,或者上游编码器切了 slice 而代码只发了第一个 slice。
解决:先确认是不是 slice 问题——打日志统计每个 access unit 里的 NAL 数量,1080p 下正常应该有多个。如果确实只发了一个,就是 4.2 里说的「一帧多 NAL 要拼进同一个包」没做对。如果 NAL 数量正常,就去看服务端的丢帧日志,通常是客户端推得太快、服务端消费不过来,把ring_buffer的容量调大或者降码率能缓解。
5.3 端到端延迟三秒以上,越推越慢
现象:刚推上去延迟还行,跑十几分钟后延迟慢慢涨到三五秒,重启推流进程立刻恢复。
原因:发送队列只进不出。push_one_frame是阻塞的,但生产端没有背压,队列越堆越长,延迟就是这么攒出来的。另一种可能是avio_open时没设置缓冲区大小,默认缓冲在弱网下会吃掉几百毫秒。
解决:给环形队列设上限,满了就丢最老的非关键帧,关键帧永远保留——丢关键帧的代价比丢普通帧大得多。同时在avio_open之后把 socket 的TCP_NODELAY打开,禁用 Nagle 算法,能省掉几十毫秒的粘连延迟。这两招在局域网里效果立竿见影。
5.4 推流十几小时后进程被 OOM kill
现象:跑一晚上,第二天发现进程没了,dmesg里能看到 oom-killer 的记录,内存曲线是缓慢爬升的锯齿形。
原因:重连路径漏释放。最常见的是AVDictionary用完没av_dict_free,以及avformat_alloc_output_context2在重连时又 alloc 了一次,旧的 context 没 free。锯齿形说明每次重连泄漏一点,累积到阈值就爆。
解决:把重连的释放逻辑收进一个函数,rtmp_close()里集中处理avio_closep、avformat_free_context、av_dict_free三件事,别分散在多个if分支里。有条件的话用 valgrind 跑一遍模拟断网,泄漏点一目了然。
5.5 连不上 RTMP,握手直接断
现象:avio_open返回失败,或者连上之后马上被服务端断开,日志里出现NetConnection.Connect.Rejected。
原因:三种可能。URL 拼错(app和stream之间少了个斜杠);服务端需要鉴权参数而 URL 里没带;tcUrl和实际 URL 不一致,某些服务端会校验这个字段。
解决:先把 URL 拿去用 ffmpeg 命令行推一遍,ffmpeg -re -i test.h264 -c copy -f flv rtmp://...,命令行能推成功说明 URL 和鉴权没问题,问题在代码里;命令行也推不进去,就是服务端配置或者网络策略的事,跟这个工程无关。这一步能把排查范围砍掉一半。
6. 二次开发:把它接进自己的业务链路
这份代码最值钱的地方不是它能推流,而是三层切得干净,接进现有业务几乎不用动核心。我的做法是先在外面包一层接口,把main.c里的命令行逻辑剥掉,让业务侧只关心「喂一帧、发一帧」:
/* 对业务暴露的最小接口,隐藏 ffmpeg 和线程细节 */ typedef struct { void (*on_connect)(void *user, const char *url); void (*on_disconnect)(void *user, int err); void (*on_stats)(void *user, int64_t bytes, int64_t frames); void *user; } pusher_ops_t; int pusher_start(const char *url, const pusher_ops_t *ops); int pusher_send(pusher_handle_t h, const uint8_t *nal, int len, int64_t pts_ms, int key); void pusher_stop(pusher_handle_t h);包好之后,几个常见改造方向就都很顺了:
| 改造点 | 改哪里 | 注意 |
|---|---|---|
| 接摄像头实时裸流 | main.c换成 V4L2 读线程,PTS 用采集时间戳 | 别用帧计数推 PTS,采集抖动会累积成漂移 |
| 多路并发推流 | 每路一个pusher_handle_t,共享一个 ffmpeg 初始化 | avformat_network_init全局只调一次 |
| 加 SEI 透传业务数据 | flv_muxer.c里 type=6 的 NAL 原样保留 | 别自己拼 SEI,直接透传编码器打好的 |
| 输出改 HTTP-FLV | rtmp_sender.c里 muxer 名换成flv,URL 换成 http | 需要服务端支持 chunked 输出 |
| 按需启停 | pusher_stop里 join 推流线程 | 别用pthread_cancel,资源释放会漏 |
有一个改造我踩过坑:想加一路「推流的同时本地存一份」,最省事的写法是在flv_muxer里复制一份 AVPacket 往第二个 context 写。结果是两个 muxer 共享同一个AVPacket的buf,一个av_packet_unref之后另一个读到野指针,画面随机崩。正确做法是av_packet_ref出独立引用再交给第二个 context,两个 muxer 各管各的引用计数。这种引用计数的问题在单路推流时永远暴露不出来,一上多路就原形毕露。
从那以后我每次给这类封装层加旁路输出,都强制先跑一遍「单路推流 + 本地存盘」的对照测试,两边的时间戳和帧数必须完全对齐才敢往上叠功能。裸流封装这活儿,真正难的不是把字节塞进容器,而是把时间戳、引用计数、重连释放这三件事在所有分支上都摆平。希望帮到你。
本文还有配套的精品资源,点击获取