简介:围绕在内存中把H264裸流直接封装成FLV并通过RTMP推流这一典型直播场景,这份C语言示例源码给出了可运行的完整调用链路,适合已掌握FFmpeg基础、希望打通编码数据到网络推流的初中级音视频开发者参考。压缩包内共有1个文件,为单个c源文件,体积约3KB,聚焦核心流程而不掺杂工程脚手架,便于逐行阅读和移植到自己的项目中。目前已有964人学习。源码覆盖FFmpeg库初始化与网络模块注册、H264 NAL单元解析、FLV Tag与时间戳同步信息的组装、AVIO内存上下文的打开与写入、RTMP连接的建立和流信息获取,以及调用write_header、write_trailer完成的推流收尾,涉及libavformat的调用顺序、内存IO与网络编程的配合。读者可以借此理清解封装、封装与推流的整体时序,理解NAL打包与时间戳的对应关系,也能为自建直播推流、低延迟传输改造提供可直接调试的起点。
1. 从一路裸流说起:H264 转 RTMP 到底卡在哪
有个同学拿着摄像头录像的例程来找我,程序能把数据写成.h264文件,用播放器本地播没问题;一改成往服务端推流就黑屏,而服务端日志里publish明明成功了。问题不在编码,卡在两处:FLV 封装里那二十几个字节的解码器配置记录,以及 RTMP 每帧时间戳怎么算。
这份 H264 转 RTMP 的源码包,做的就是把这条链路拆开给你看:H264 裸流按起始码切成 NALU、拼成 FLV VideoTag、再走 RTMP 握手和 chunk 分片发出去,ffmpeg 只承担取流和解码编码。适合手里已经有裸流或摄像头数据、想把画面推到自建服务端的人;也适合受够了avformat_write_header这个黑盒子、想弄明白 flv 和 rtmp 到底在字节层面干了什么的人。
2. 项目骨架与技术栈:三条线程和六个文件的职责边界
解压之后最直观的感受是文件不多,但每个文件都只干一件事。这份代码没有把「读流 + 编码 + 封装 + 推流」塞进一个main里,而是按数据流向切成了读取、封装、协议三层,中间用队列和线程做解耦。这种拆法谈不上多高明,但对二次开发特别友好:你要换采集源,只动 reader;你要换封装格式,只动 muxer;你要换成别的协议,只动 client。
2.1 目录分工:六个文件各管一段
h264_rtmp/ ├── src/ │ ├── main.cpp // 参数解析、线程启动、生命周期 │ ├── h264_reader.cpp/.h // Annex-B 裸流读取、起始码切分、SPS/PPS 缓存 │ ├── flv_muxer.cpp/.h // AVCDecoderConfigurationRecord 与 VideoTag 拼装 │ ├── rtmp_client.cpp/.h // 握手、AMF0 命令、publish 状态机 │ ├── rtmp_chunk.cpp/.h // chunk 头编码、分片发送、时间戳 │ └── av_queue.cpp/.h // 有界阻塞队列,生产者消费者共用 ├── test/ │ └── sample.h264 // 一段 25fps 的测试裸流 └── Makefileh264_reader是唯一和数据源打交道的地方,它读文件也好、读摄像头也好,对外只暴露「给我一帧 NALU 列表 + 这一帧是不是关键帧」。flv_muxer是纯函数式的,输入 NALU、输出一段可以直接塞进 RTMP message 的字节流,不持有任何网络状态——这一点很关键,封装层不碰 socket,你在本地单测里就能把它跑穿,不用起服务端。
rtmp_client负责连接生命周期,rtmp_chunk负责把一个逻辑 message 切成 128 字节的 chunk。两者拆开的原因在于 chunk 层还会被命令消息复用,connect、createStream这些 AMF0 命令和音视频数据走的是同一套分片逻辑,只是 csid 和 message type 不同。av_queue在中间做缓冲,容量一般是 8 到 30 帧,满了就丢非关键帧,这是抗网络抖动最省事的一招。
2.2 三条线程:读取、发送和一条保活
主线程解析完参数就把控制权交出去,实际干活的是三条线程。读取线程按帧率节奏从源里取数据,切好 NALU 丢进队列,同时在首帧记录一个steady_clock::now()作为时间戳基准;发送线程从队列里取,调flv_muxer拼包,再调rtmp_chunk发出去;还有一条轻量线程专门处理服务端发回来的协议控制消息,比如窗口确认大小和 ping 响应。
时间戳的算法值得单独说一句,血泪经验都在这里。RTMP 的时间戳单位是毫秒,而且要求单调递增。常见做法是拿「当前时刻减去首帧时刻」算,听起来没问题,但一旦读取线程被调度卡了一下,时间戳就会突然跳几十毫秒,播放器那边表现为画面一顿或者倍速。我一般用帧序号兜底:ts = frame_index * 1000 / fps,需要更平滑的时候再用「上一帧时间戳 + 理论帧间隔」做累加,只在累计偏差超过阈值时才校正到系统时间。
队列的设计也有个细节——只对关键帧做「必须入队」,非关键帧在队列水位超过 80% 时直接丢。丢一帧 P 帧,播放端最多花一下;要是队列无限增长,内存会一路涨到 OOM,而且延迟越拖越大,最后推上去的流已经没有实时性可言了。
2.3 依赖与编译:FFmpeg 只用其中三个库
这份源码对 ffmpeg 的依赖很轻,只用到libavcodec(编码或解码)、libavutil(日志和图像工具)、libswscale(需要缩放时才用),FLV 和 RTMP 部分全部自己实现,不链libavformat。这样编译出来的二进制小,也不需要跟着 ffmpeg 的版本升级反复适配。
# 常见做法是先确认 pkg-config 能看到 ffmpeg 的开发包 pkg-config --modversion libavcodec libavutil # 缺的话在常见的发行版上补一下开发包 sudo apt install build-essential pkg-config \ libavcodec-dev libavutil-dev libswscale-devMakefile 里对应的两行大致是这样,-Wl,-rpath那行是给运行时找不到动态库准备的后悔药:
CFLAGS += $(shell pkg-config --cflags libavcodec libavutil libswscale) LDFLAGS += $(shell pkg-config --libs libavcodec libavutil libswscale) -lpthread第一个参数说明:--cflags拿到的是头文件搜索路径,--libs拿到的是-lavcodec -lavutil这一串链接参数。第二个注意点:如果编译时用的是静态库,链接顺序必须把-lavcodec放在-lavutil前面,否则会出现一堆undefined reference,这是新手最容易翻车的地方。第三个点,-lpthread不能省,av_queue里用的是std::mutex和std::condition_variable,虽然现代 GCC 大多会自动带上,但显式写出来更稳。
编译完成后先别急着推流,用test/sample.h264跑一遍空转模式,看看切出来的 NALU 数量和关键帧间隔对不对:
make -j4 ./h264_rtmp -i test/sample.h264 --dry-run --dump-nalu 20--dry-run只走读取和封装,不建连接;--dump-nalu 20打印前 20 个 NALU 的类型和长度。正常输出里应该看到类型 7(SPS)、8(PPS)、5(IDR)交替出现,IDR 的间隔和你设定的 GOP 长度一致。这一步过了,后面再出问题基本就锁定在网络层,排查范围能砍掉一半。
3. FLV 封装拆解:SPS/PPS 与 VideoTag 的字节级拼装
flv 这层是整份代码里最容易写错、也最值得抄的部分。播放器能不能出第一帧,几乎全看你有没有把AVCDecoderConfigurationRecord拼对;画面花不花,看的是 NALU 前面的长度前缀写了几字节。这两件事都没有 API 提示,错了就是黑屏,日志里一个字都没有。
3.1 Annex-B 切分:起始码、NALU 类型与 SPS/PPS 缓存
H264 裸流是 Annex-B 格式,NALU 之间用00 00 01或00 00 00 01分隔。切分的第一步是找起始码,第二步是读 NALU 头一个字节的低 5 位拿到类型。
// 返回指向起始码的指针,找不到返回 nullptr static const uint8_t* find_start_code(const uint8_t* p, const uint8_t* end) { while (p + 3 <= end) { if (p[0] == 0 && p[1] == 0 && p[2] == 1) return p; // 3 字节起始码也能匹配 4 字节的情况 ++p; } return nullptr; } // 从一段 Annex-B 内存里切出所有 NALU int split_annexb(const uint8_t* buf, int len, std::vector<Nalu>& out) { const uint8_t* end = buf + len; const uint8_t* p = find_start_code(buf, end); while (p && p < end) { const uint8_t* nal = p + 3; // 跳过 00 00 01 const uint8_t* next = find_start_code(nal, end); int nal_len = (next ? next : end) - nal; if (nal_len > 0) { Nalu n; n.type = nal[0] & 0x1F; // 低 5 位 = NALU 类型 n.data = nal; n.len = nal_len; // 含 NALU 头字节 out.push_back(n); } p = next; } return (int)out.size(); }逻辑说明:find_start_code从当前位置往后扫,命中00 00 01就返回。4 字节起始码00 00 00 01会被它从第二个零开始匹配上,所以不用单独判断,代价是 NALU 数据里如果恰好出现00 00 01会误判——真正的实现要处理 emulation prevention byte(00 00 03),这份源码在读取阶段做了简单过滤,够用但不严谨,二次开发时建议补上。
参数说明:out里的data指向原始缓冲区,是借用不是拷贝,所以缓冲区在封装完成前不能释放;len包含 NALU 头字节,后面拼长度前缀时要注意这个长度是「头 + 载荷」的总长。类型 7 是 SPS、8 是 PPS、5 是 IDR 关键帧、1 是非关键帧,剩下几种(SEI、AUD)在推流场景可以直接丢掉,AUD 尤其容易让某些播放器提前断帧。
拿到 SPS 和 PPS 之后别急着发,先把它们缓存起来。原因很简单:解码器配置记录只在流开头发一次,但 SPS/PPS 在裸流里可能只在文件头出现,也可能每个 IDR 前都重复一遍,缓存一份能保证任何时刻要重建连接都拿得出来。
3.2 AVCDecoderConfigurationRecord:二十几个字节里的字段
这个结构就是 flv 里的 AVC sequence header,播放器读到它才知道视频的 profile、level、分辨率,以及后面每个 NALU 前面有几个字节的长度前缀。它由 SPS 和 PPS 拼出来,字段全是定长,错一个字节就整条流废掉。
// out 至少需要 11 + sps_len + pps_len 字节 int build_avc_config(uint8_t* out, const uint8_t* sps, int sps_len, const uint8_t* pps, int pps_len) { int i = 0; out[i++] = 0x01; // configurationVersion,固定 1 out[i++] = sps[1]; // AVCProfileIndication,直接抄 SPS 第 2 字节 out[i++] = sps[2]; // profile_compatibility out[i++] = sps[3]; // AVCLevelIndication out[i++] = 0xFF; // 高 6 位保留全 1,低 2 位 lengthSizeMinusOne = 3 → 4 字节长度前缀 out[i++] = 0xE1; // 高 3 位保留全 1,低 5 位 numOfSPS = 1 out[i++] = (sps_len >> 8) & 0xFF; out[i++] = sps_len & 0xFF; memcpy(out + i, sps, sps_len); i += sps_len; out[i++] = 0x01; // numOfPPS = 1 out[i++] = (pps_len >> 8) & 0xFF; out[i++] = pps_len & 0xFF; memcpy(out + i, pps, pps_len); i += pps_len; return i; // 实际写入的字节数 }逻辑说明:前四个字节是版本和 profile 信息,直接从 SPS 里抄,不用解析。第五个字节的0xFF是硬编码的重灾区——低两位表示 NALU 长度前缀字节数减一,写0xFF就是 4 字节。第六个字节0xE1表示后面跟一组 SPS。如果你把0xE1错写成0x01,部分播放器会认为保留位不合法直接拒绝解析,表现就是黑屏。
参数说明:sps和pps传的是不含起始码的数据,也就是split_annexb切出来的data。sps_len至少是 4,因为要读sps[1]到sps[3]。返回的字节数后面要作为 VideoTag 的载荷长度写进 RTMP message 头。这段拼完之后,把它当成一个「特殊的视频帧」发出去,标志位设成 sequence header,且必须排在所有视频数据前面。
3.3 VideoTag 封装:帧类型、包类型与时间戳
flv 的 VideoTag 结构是「1 字节帧类型和编码 ID + 1 字节包类型 + 3 字节合成时间 + 数据」。AVC 的编码 ID 固定是 7,所以关键帧是0x17,非关键帧是0x27。
// 把一帧 NALU 封成 flv VideoTag 载荷,返回写入长度 int flv_video_tag(uint8_t* out, const uint8_t* nalu, int nalu_len, uint32_t ts, bool keyframe, bool seq_header) { int i = 0; out[i++] = keyframe ? 0x17 : 0x27; // 高 4 位帧类型(1=关键帧,2=非关键帧),低 4 位编码 ID=7 out[i++] = seq_header ? 0x00 : 0x01; // 0=AVC sequence header,1=AVC NALU out[i++] = 0x00; // composition time 高字节 out[i++] = 0x00; out[i++] = 0x00; // 无 B 帧时恒为 0 if (seq_header) { memcpy(out + i, nalu, nalu_len); // 这里传的是 build_avc_config 的结果 return i + nalu_len; } // 普通帧:NALU 前面补 4 字节大端长度,替换掉 Annex-B 的起始码 out[i++] = (nalu_len >> 24) & 0xFF; out[i++] = (nalu_len >> 16) & 0xFF; out[i++] = (nalu_len >> 8) & 0xFF; out[i++] = nalu_len & 0xFF; memcpy(out + i, nalu, nalu_len); return i + nalu_len; }逻辑说明:前 5 个字节是 flv 的固定头,后面才是视频数据。关键区别在于普通帧的数据不再是 Annex-B,而是 AVCC——每个 NALU 前面用 4 字节大端整数表示长度,这个长度必须和build_avc_config里声明的lengthSizeMinusOne = 3严格对应。合成时间只在有 B 帧时才有意义,推流场景一般不做 B 帧,写三个零就行。
参数说明:ts在这层其实用不到,因为 flv tag 的时间戳是写在 tag 头里而不是载荷里,这里保留参数是给落本地 flv 文件用的。nalu_len是 NALU 头加载荷的总长,不含起始码。如果你偷懒把起始码一起拷进去,播放器会把这 3 个字节当成长度前缀的一部分,结果是画面花屏或者直接卡死,这个坑我见过太多次了。
最后别忘了 tag 头:类型 9(视频)、3 字节数据长度、3 字节时间戳、1 字节时间戳扩展、3 字节 stream id。时间戳超过 0xFFFFFF 之后要写扩展字节,一小时以上的流必然会碰到,写漏了表现为播到某个时间点画面直接乱掉。
4. RTMP 推流链路:握手、publish 与 chunk 分片的完整时序
RTMP 这条链路本身不复杂,但步骤多且强顺序:握手完成才能发命令,connect收到_result才能createStream,拿到 stream id 才能publish,publish之后才能发音视频数据。任何一步抢跑,服务端都是直接断连,而且不给明确错误码。
4.1 握手:C0/C1/C2 与 S0/S1/S2
握手是固定的三段式,客户端的 C2 必须原样回服务端的 S1。这份代码里没有做复杂校验,只保证时序正确。
bool RtmpClient::handshake() { uint8_t c0c1[1 + 1536] = {0}; c0c1[0] = 0x03; // C0:版本号,非加密固定 3 // C1:4 字节时间 + 4 字节零 + 1528 字节随机数 uint32_t now = (uint32_t)time(nullptr); c0c1[1] = (now >> 24) & 0xFF; c0c1[2] = (now >> 16) & 0xFF; c0c1[3] = (now >> 8) & 0xFF; c0c1[4] = now & 0xFF; for (int i = 9; i < 1537; ++i) c0c1[i] = rand() & 0xFF; if (!sendAll(c0c1, sizeof(c0c1))) return false; uint8_t s0s1s2[1 + 1536 + 1536]; if (!recvAll(s0s1s2, sizeof(s0s1s2))) return false; if (s0s1s2[0] != 0x03) return false; // 服务端版本必须也是 3 // C2:把 S1 原样回过去 return sendAll(s0s1s2 + 1, 1536); }逻辑说明:C0 一个字节版本,C1 固定 1536 字节,S0S1S2 一共 3073 字节,读完再一次把 S1 的 1536 字节回过去当 C2。有些精简实现会省掉随机数填充,用全零,多数服务端能接受,但少数会校验,所以稳妥起见还是填随机值。
参数说明:sendAll和recvAll必须写成循环发送/接收,因为 TCP 是流式的,一次send只发出去部分数据是常态。新手常犯的错是直接send(fd, buf, len, 0)就当成功了,小包的时候测不出问题,一旦 C1 分片就卡在握手阶段,表现是「连上了但没反应」。
4.2 命令交互:connect → createStream → publish 的 AMF0 参数
RTMP 的命令消息用 AMF0 编码,type 20,stream id 为 0。推流只需要四个命令,AMF0 也只需要实现字符串、数字、对象、null 四种类型。
// 极简 AMF0:只覆盖推流必需的类型 static void amf_str(std::vector<uint8_t>& b, const std::string& s) { b.push_back(0x02); // 类型标记:string b.push_back((s.size() >> 8) & 0xFF); b.push_back(s.size() & 0xFF); // 2 字节长度 b.insert(b.end(), s.begin(), s.end()); } static void amf_num(std::vector<uint8_t>& b, double v) { b.push_back(0x00); // 类型标记:number uint64_t bits; memcpy(&bits, &v, 8); for (int i = 7; i >= 0; --i) b.push_back((bits >> (i * 8)) & 0xFF); // 大端双精度 } // 组装 connect 命令 std::vector<uint8_t> body; amf_str(body, "connect"); amf_num(body, 1); // transaction id amf_str(body, "app"); amf_str(body, "live"); // 应用名要和推流地址路径一致 amf_str(body, "tcUrl"); amf_str(body, "rtmp://127.0.0.1:1935/live"); amf_str(body, "flashVer"); amf_str(body, "FMLE/3.0"); body.push_back(0x00); body.push_back(0x00); body.push_back(0x09); // object end 标记逻辑说明:AMF0 是类型标记打头的,字符串是0x02 + 两字节长度 + 内容,数字是0x00 + 八字节大端 IEEE754。对象的结尾用00 00 09三个字节表示,漏了这三个字节服务端会一直等更多数据,表现为连接挂着不返回。transaction id递增,connect用 1,createStream用 2,这样能对上返回的_result。
参数说明:app要和推流地址里的路径对上,rtmp://host:1935/live/stream里app是live、流名是stream;写错了服务端会返回NetStream.Publish.BadName。tcUrl就是完整的应用地址,别漏端口。发完connect后要等_result,里面会带一个Window Acknowledgement Size之类的控制消息,处理后才能继续。
publish之后如果服务端返回NetStream.Publish.Start,链路就通了。之后要么先发 metadata(@setDataFrame+onMetaData,里面写宽高、帧率、编码信息),要么直接发音视频。metadata 不是必须的,但发一下播放器起播会快一点,某些播放器没有它会拒绝播放。
4.3 chunk 分片与时间戳:fmt0/fmt3 头和 128 字节切块
一个 RTMP message 可能要切成多个 chunk 发,默认 chunk size 是 128 字节。第一个 chunk 用 fmt0(11 字节头),后续用 fmt3(1 字节头)。
bool RtmpClient::sendMessage(uint8_t csid, uint8_t type, uint32_t streamId, uint32_t ts, const uint8_t* data, int len) { uint8_t hdr[16]; int h = 0; hdr[h++] = csid & 0x3F; // fmt=0,高 2 位 00 hdr[h++] = (ts >> 16) & 0xFF; // 3 字节时间戳 hdr[h++] = (ts >> 8) & 0xFF; hdr[h++] = ts & 0xFF; hdr[h++] = (len >> 16) & 0xFF; // 3 字节 message 长度 hdr[h++] = (len >> 8) & 0xFF; hdr[h++] = len & 0xFF; hdr[h++] = type; // 8=音频 9=视频 18=metadata 20=命令 for (int i = 0; i < 4; ++i) // 4 字节小端 stream id hdr[h++] = (streamId >> (i * 8)) & 0xFF; if (!sendAll(hdr, h)) return false; int off = 0; while (off < len) { int n = std::min(chunkSize_, len - off); if (off > 0) { // 后续 chunk 用 fmt3 头 uint8_t f3 = 0xC0 | (csid & 0x3F); // fmt=3 高 2 位 11 if (!sendAll(&f3, 1)) return false; } if (!sendAll(data + off, n)) return false; off += n; } return true; }逻辑说明:fmt0 头 11 字节,之后还有 4 字节 stream id,所以缓冲区给 16 字节。fmt3 头的低 2 位是 11,高位补 csid。切块只影响传输,不影响 message 边界,服务端会按长度字段重组。如果开了扩展时间戳(ts 超过 0xFFFFFF),fmt0 里的 3 字节时间戳要写0xFFFFFF,真值放到后面的 4 字节扩展里,fmt3 也要带扩展,这块漏写是高时长推流出问题的头号原因。
参数说明:csid建议视频用 6、音频用 4、命令用 3,固定分配能避免和服务端的控制通道撞车。chunkSize_可以在connect之后用Set Chunk Size控制消息调大,常见做法是调到 4096 或 60000,能明显减少小包数量。
联调的时候别一上来就推视频,先只推 sequence header 加一个 IDR 帧,看看能不能出画面。命令如下,-v debug会把每个 RTMP 消息的 type 和长度打出来,对着看有没有发漏:
./h264_rtmp -i test/sample.h264 -o rtmp://127.0.0.1:1935/live/stream -v debug -g 25 ffplay rtmp://127.0.0.1:1935/live/stream-g 25是 GOP 长度,25fps 下等于一秒一个关键帧,测试阶段短一点更容易看出问题。ffplay 那头如果一直转圈没有画面,先看 debug 日志里有没有 type=9 且长度很小的那条(那就是 sequence header),没有的话就是第 3 章的问题,别在网络上浪费时间。
5. 常见问题排查:黑屏、花屏、断流和延迟的五种现场
这份代码跑通不难,难的是出错时没有报错。RTMP 和 FLV 都是「发错了也照发」的协议,服务端收下就返回成功,问题全在播放端表现。下面五个现象基本覆盖了我踩过的绝大部分坑。
5.1 画面类:黑屏、花屏、首帧慢
现象一:服务端显示 publish 成功,播放器黑屏无画面。原因是 sequence header 没发、发晚了,或者发的时候没标成关键帧。播放器要先解析AVCDecoderConfigurationRecord才知道怎么解后面的 NALU,顺序反了就直接丢弃。解决方式是强制约定:发送线程启动后第一件事就是发 sequence header,并且flv_video_tag的seq_header参数置 true,同时帧类型标成0x17。发完再发第一个 IDR,两个之间不允许插入任何其他帧。
现象二:画面能出,但满屏马赛克或者局部撕裂。八成是 NALU 前的长度前缀写错了。要么是字节序写成了小端,要么是把 Annex-B 的起始码也拷进了载荷,要么是AVCDecoderConfigurationRecord里声明了 4 字节前缀而实际写了 2 字节。解决方式是拿一帧数据用十六进制看前几个字节:00 00 00 1C 65 ...这样才对,65是 IDR 的 NALU 头。对不上就回头查flv_video_tag。
现象三:起播要等两三秒,之后才正常。原因是 GOP 太长或者关键帧间隔不固定。播放器必须等到 IDR 才能解码,如果 GOP 是 250 帧、25fps 就是十秒才一个关键帧。解决方式是把 GOP 压到 1 到 2 秒,编码端强制 IDR 对齐,同时确保 sequence header 之后紧跟的第一个视频帧就是 IDR。
5.2 连接类:断流、被拒、时间戳跳变
现象四:推流二三十秒后连接被服务端断开。多半是没回 ping 或者没处理窗口确认。RTMP 服务端会周期性发User Control Message(type 4)里的 Ping Request,客户端要在 5 秒内回 Pong,不回就断。这份代码把控制消息放在单独线程里处理,rtmp_client的收包循环要一直跑着,别只在要发数据时才读。解决方式是给 socket 设个较短的读超时,循环里检查有没有控制消息。
现象五:播放器画面一顿一顿,或者明显倍速。时间戳问题。要么起始时间戳没归零,服务端或播放器按第一个包的时间戳做基准,导致开头一段被跳过;要么是单位用错,把微秒当毫秒发;要么是时间戳回退,某些情况下用系统时间算会往回跳。解决方式统一用「帧序号 × 1000 / fps」算,保证严格单调,首帧从 0 开始,超过 0xFFFFFF 时正确写扩展时间戳。
再补一个容易忽略的:如果服务端返回NetStream.Publish.BadName,先别怀疑协议,检查推流地址里的 app 和流名有没有多余空格、有没有把rtmp://写成rtmps://。协议层没错,配置错一样连不上。
6. 二次开发:加音频、断线重连和低延迟的三处改造
这份代码只做了视频,实际项目里最先要加的就是音频。改造点很集中:编码端产出 AAC 的 raw 帧后,flv 音频 tag 的结构是0xAF打头(高 4 位格式 10 表示 AAC,低 4 位采样率索引和位深),第二字节0x00表示 AudioSpecificConfig、0x01表示普通帧,后面跟数据。AudioSpecificConfig 要在所有音频帧之前发一次,和视频的 sequence header 一个道理。
| 改造点 | 改动文件 | 验证方式 |
|---|---|---|
| 增加 AAC 音频轨 | flv_muxer.cpp加flv_audio_tag,rtmp_client用 csid=4、type=8 发送 | ffplay 有声音,ffprobe看到两条流 |
| 断线重连 | rtmp_client.cpp加状态机,重连成功后重发两个 sequence header | 手动 kill 服务端再起,画面自动恢复 |
| 低延迟优化 | rtmp_chunk.cpp把 chunkSize 提到 4096,main.cpp开 TCP_NODELAY 并把 GOP 降到 1 秒 | 端到端延迟从 2 秒降到 400ms 以内 |
断线重连里最容易被忽略的是「重连后必须重发 sequence header」。很多人以为连接断了重连上就能接着发,结果播放端黑屏——新的会话在服务端看来是一条全新的流,解码器配置得重新给。我一般的做法是在onConnected回调里把sps_、pps_、aac_config_三个缓存原样重发一遍,再恢复正常推流,这样重连逻辑就是一个纯粹的状态复位,不用管上层。
低延迟这块还有个细节:send之后别急着把数据从队列里删掉,先确认发出去了。TCP 写缓冲满了之后send会阻塞,你要是按帧率节奏取数据、不做背压,队列会越堆越长。我一般把 socket 设成非阻塞,send返回EAGAIN就把数据留在队头等下一次可写,同时用select或poll等可写事件——虽然多写几十行,但比事后调延迟参数省心得多。
验证方法很简单,推一路流到本地服务端,用ffprobe看封装是否规范,再掐表看延迟:
# 落一份本地 flv 便于离线分析封装是否正确 ./h264_rtmp -i test/sample.h264 -o out.flv -d 30 ffprobe -show_streams -show_format out.flvffprobe能正常读出 codec 为 h264、宽高帧率和你设定的一致,就说明 FLV 这层没问题,剩下的都是网络层的事。落本地文件这个习惯帮我省了大量时间,因为它把「封装错」和「推流错」两类问题彻底分开了。
从那以后我每次改完封装或者时间戳相关的代码,都强制先落一遍本地 flv、用 ffprobe 过一遍,再上推流——一步两秒钟,比对着黑屏猜半小时划算太多。希望帮到你。
本文还有配套的精品资源,点击获取