简介围绕在内存中把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 的类型和长度。正常输出里应该看到类型 7SPS、8PPS、5IDR交替出现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::vectorNalu 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 byte00 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 位编码 ID7 out[i] seq_header ? 0x00 : 0x01; // 0AVC sequence header1AVC 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 才能publishpublish之后才能发音视频数据。任何一步抢跑服务端都是直接断连而且不给明确错误码。4.1 握手C0/C1/C2 与 S0/S1/S2握手是固定的三段式客户端的 C2 必须原样回服务端的 S1。这份代码里没有做复杂校验只保证时序正确。bool RtmpClient::handshake() { uint8_t c0c1[1 1536] {0}; c0c1[0] 0x03; // C0版本号非加密固定 3 // C14 字节时间 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 20stream id 为 0。推流只需要四个命令AMF0 也只需要实现字符串、数字、对象、null 四种类型。// 极简 AMF0只覆盖推流必需的类型 static void amf_str(std::vectoruint8_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::vectoruint8_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::vectoruint8_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用 1createStream用 2这样能对上返回的_result。参数说明app要和推流地址里的路径对上rtmp://host:1935/live/stream里app是live、流名是stream写错了服务端会返回NetStream.Publish.BadName。tcUrl就是完整的应用地址别漏端口。发完connect后要等_result里面会带一个Window Acknowledgement Size之类的控制消息处理后才能继续。publish之后如果服务端返回NetStream.Publish.Start链路就通了。之后要么先发 metadatasetDataFrameonMetaData里面写宽高、帧率、编码信息要么直接发音视频。metadata 不是必须的但发一下播放器起播会快一点某些播放器没有它会拒绝播放。4.3 chunk 分片与时间戳fmt0/fmt3 头和 128 字节切块一个 RTMP message 可能要切成多个 chunk 发默认 chunk size 是 128 字节。第一个 chunk 用 fmt011 字节头后续用 fmt31 字节头。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; // fmt0高 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视频 18metadata 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); // fmt3 高 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 超过 0xFFFFFFfmt0 里的 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 日志里有没有 type9 且长度很小的那条那就是 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 Messagetype 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_tagrtmp_client用 csid4、type8 发送ffplay 有声音ffprobe看到两条流断线重连rtmp_client.cpp加状态机重连成功后重发两个 sequence header手动 kill 服务端再起画面自动恢复低延迟优化rtmp_chunk.cpp把 chunkSize 提到 4096main.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 过一遍再上推流——一步两秒钟比对着黑屏猜半小时划算太多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?