
本文以一个实际工程中的ffmpegRtspPusher类为例按打开输入 → 准备输出 → 建立连接 → 循环推流四个阶段逐行剖析 FFmpeg 推流 H.264 到 RTSP 服务器的完整流程。所有代码均为可直接编译运行的生产级实现。一、整体流程概览先给一张流程图方便建立全局认知text┌─────────────────────────────────────────────────────────┐ │ init() 初始化阶段主线程 │ │ ├─ openInput() │ │ │ ├─ avformat_open_input 打开输入文件 │ │ │ ├─ avformat_find_stream_info 读取流信息 │ │ │ ├─ av_find_best_stream 找视频流 │ │ │ └─ 判断 AVCC/Annex-B → 建 bsf 码流格式适配 │ │ └─ openOutput() │ │ ├─ avformat_alloc_output_context2 创建 RTSP 输出│ │ ├─ avformat_new_stream 新建输出流 │ │ ├─ avcodec_parameters_copy 流复制 │ │ └─ avformat_write_header 建立 RTSP 会话 │ ├─────────────────────────────────────────────────────────┤ │ run() 循环推流阶段子线程 │ │ while (running) │ │ ├─ av_read_frame 读一帧包 │ │ ├─ pts 节流 控制推流速度 │ │ ├─ av_packet_rescale_ts 时间基转换 │ │ ├─ av_interleaved_write_frame 写包 → RTP 发包 │ │ └─ EOF → 循环重开输入 │ ├─────────────────────────────────────────────────────────┤ │ closeAll() 资源释放 │ └─────────────────────────────────────────────────────────┘核心思想只有一句打开输入拿到码流 → 建立 RTSP 输出通道 → 把输入包按时间基转换后写进输出通道。听起来简单但每一步都藏着坑。二、第一步打开输入文件2.1 打开容器cppint ret avformat_open_input(m_inFmtCtx, m_inputFile.toUtf8().constData(), nullptr, nullptr);avformat_open_input是入口函数它做了三件事打开文件/URL根据路径自动探测输入格式探测封装格式内部调用av_probe_input_format识别 MP4、MKV、FLV、裸 H.264 等分配并填充AVFormatContext把m_inFmtCtx指向一个新的上下文这里m_inputFile.toUtf8().constData()的写法值得注意Qt 的 QString 是 UTF-16FFmpeg 期望的是 UTF-8 的const char*。如果直接.toStdString().c_str()在 Windows 中文路径下会乱码。2.2 读取流信息cppret avformat_find_stream_info(m_inFmtCtx, nullptr);这一句对裸 H.264 文件尤其关键。裸.h264文件没有容器头分辨率、帧率、SPS/PPS 都藏在码流里。avformat_find_stream_info会预读一段数据从 NAL 单元里解析出分辨率width × height帧率framerateprofile / level是否存在 B 帧影响 dts/pts 关系这一步不调用后面的codecpar里全是 0推流出去服务器会直接拒绝。2.3 查找视频流cppm_inVideoIdx av_find_best_stream(m_inFmtCtx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);av_find_best_stream比手写遍历streams[]更聪明它会优先选择码率最高、分辨率最大的视频流排除封面图、附加缩略图等非主视频流返回流索引赋给m_inVideoIdx对于只有一路视频的文件返回 0对多路的 MP4可能返回主视频流。2.4 判断码流格式AVCC vs Annex-B这是推 H.264 到 RTSP 最容易翻车的地方。H.264 有两种常见的封装格式格式别名特征常见场景AVCCLength-Prefixed每个 NAL 前是 4 字节长度SPS/PPS 存在extradata里MP4、MKV、FLVAnnex-BByte-Stream每个 NAL 前是00 00 00 01起始码SPS/PPS 混在码流里裸.h264、TS 流RTSP 传输 RTP 包时要求的是Annex-B 格式或者说SPS/PPS 需要作为独立 NAL 出现。所以 MP4 输入的 H.264 必须先转换cppconst AVCodecParameters *par inStream-codecpar; bool needAnnexB false; if (par-codec_id AV_CODEC_ID_H264 par-extradata par-extradata_size 0 par-extradata[0] 1) { // extradata[0] 1 是 AVCC 的标识 needAnnexB true; } if (needAnnexB) { const AVBitStreamFilter *bsf av_bsf_get_by_name(h264_mp4toannexb); av_bsf_alloc(bsf, m_bsfCtx); avcodec_parameters_copy(m_bsfCtx-par_in, par); m_bsfCtx-time_base_in inStream-time_base; // 必设否则 init 失败 av_bsf_init(m_bsfCtx); }为什么extradata[0] 1就能判断 AVCC因为 AVCC 格式的extradata是AVCDecoderConfigurationRecord其首字节是configurationVersion标准固定为 1。而 Annex-B 的extradata通常以00 00 00 01开头首字节是 0。为什么time_base_in必须设置bsf 内部需要基于时间基做时间戳换算。如果不设av_bsf_init会返回AVERROR(EINVAL)代码里直接 return false。三、第二步准备 RTSP 输出3.1 创建输出上下文cppint ret avformat_alloc_output_context2(m_outFmtCtx, nullptr, rtsp, m_rtspUrl.toUtf8().constData());avformat_alloc_output_context2会根据第二个参数这里传nullptr表示按格式自动推断第三个参数显式指定rtsp覆盖推断结果第四个参数URL用于再兜底推断来创建一个 RTSP 输出格式的AVFormatContext。小技巧虽然传了rtsp就足够但把 URL 也传进去可以保证内部字段完整避免个别 FFmpeg 版本走默认分支。3.2 新建输出流并做流复制cppm_outVideoStream avformat_new_stream(m_outFmtCtx, nullptr); m_outVideoStream-id m_outFmtCtx-nb_streams - 1; ret avcodec_parameters_copy(m_outVideoStream-codecpar, inStream-codecpar); m_outVideoStream-codecpar-codec_tag 0; m_outVideoStream-time_base inStream-time_base;这里做的是stream copy流复制——不重新编码直接把输入流的编解码参数拷贝到输出流。这样做的好处CPU 占用几乎为零只做包的搬运画质无损延迟最低不经过解码-编码环路三个关键操作操作作用不做的后果avcodec_parameters_copy拷贝 SPS/PPS、分辨率、profile 等服务器收到流不知道编码参数拒绝codec_tag 0RTSP 不需要 mp4a/avc1 这类 tag部分服务器会报 unknown codectime_base对齐输入保持时间基一致时间戳错乱画面跳帧m_outVideoStream-id m_outFmtCtx-nb_streams - 1是一个约定流 id 与索引保持一致。这个字段在 RTSP 里用于区分不同媒体轨道多个流时尤其重要。3.3 设置 RTSP 连接参数cppAVDictionary *opts nullptr; av_dict_set(opts, rtsp_transport, tcp, 0); av_dict_set(opts, stimeout, 5000000, 0); // 旧版超时µs av_dict_set(opts, timeout, 5000000, 0); // 新版超时µs av_dict_set(opts, muxdelay, 0.1, 0); av_dict_set(opts, buffer_size, 1024000, 0); av_dict_set(opts, rtsp_flags, prefer_tcp, 0); av_dict_set(opts, protocol_whitelist, file,udp,tcp,rtp,rtsp, 0);逐条解释选项含义工程意义rtsp_transporttcpRTP 走 TCP 交织穿 NAT、抗丢包局域网可换 udp 降延迟stimeout5000000连接超时 5 秒旧字段服务器挂了不会卡死主线程timeout5000000连接超时新字段兼容不同 FFmpeg 版本muxdelay0.1打包延迟 0.1 秒降低首帧延迟buffer_size1024000TCP 缓冲 1MB大分辨率流不爆缓冲rtsp_flagsprefer_tcpRTP 优先 TCP双保险防止协商成 UDPprotocol_whitelist放开协议白名单部分编译版默认禁 rtsp防Protocol not found踩坑实录stimeout和timeout在不同 FFmpeg 版本中名字不一样两个都设是最省事的做法。看到Protocol not found报错时八成是链接的 FFmpeg 库没编译 rtsp 协议试试protocol_whitelist也能排掉一部分情况。3.4 建立 RTSP 会话cppret avformat_write_header(m_outFmtCtx, opts); av_dict_free(opts);这是整个推流的关键一步很多人以为它只是写个头其实内部完成了完整的 RTSP 握手OPTIONS查询服务器支持的方法ANNOUNCE告诉服务器我要推什么流带上 SDP 描述SETUP协商 RTP/RTCP 端口建立传输通道RECORD告诉服务器我开始推流了握手完成后FFmpeg 内部会启动 RTP 打包逻辑后续av_interleaved_write_frame写的每个包都会被自动封装成 RTP 包。重要提醒RTSP 的AVOutputFormat带AVFMT_NOFILE标志不能手动avio_open2。很多网上代码抄来抄去都在 write_header 之前调avio_open2(m_outFmtCtx-pb, ...)结果报Protocol not found。正确做法就是本文这样——所有选项交给avformat_write_header。四、第三步循环推流4.1 读包cppint ret av_read_frame(m_inFmtCtx, m_pkt);从输入读一个包可能是视频、音频、字幕。如果只推视频cppif (m_pkt-stream_index ! m_inVideoIdx) { av_packet_unref(m_pkt); continue; }4.2 pts 节流让推流速度等于播放速度如果不节流本地文件会以光速灌进 RTSP 服务器服务器要么丢弃、要么缓冲爆掉、要么客户端卡成 PPT。cppif (m_pkt-pts ! AV_NOPTS_VALUE) { if (firstPkt) { firstPts m_pkt-pts; wallTimer.restart(); firstPkt false; } double ptsSec (m_pkt-pts - firstPts) * av_q2d(m_inTimeBase); double wallSec wallTimer.elapsed() / 1000.0; if (ptsSec wallSec) { QThread::msleep((ptsSec - wallSec) * 1000.0); } }算法思想用首包 pts作为时间原点每一帧的目标时刻 (当前 pts - 首包 pts) × 时间基实际消耗的墙钟时间 wallTimer.elapsed()目标落后于实际 → 睡一会av_q2d(m_inTimeBase)把AVRational如 1/1200000转成 double得到每个 pts 单位代表多少秒。4.3 时间基转换易错点cppav_packet_rescale_ts(m_pkt, m_inTimeBase, m_outVideoStream-time_base);这一步叫时间戳重缩放。为什么必须做输入流的pts是基于输入时间基比如 MP4 里的 1/90000或裸 H.264 的 1/1200000输出流期望的pts是基于输出时间基本例中与输入一致但理论上可能不同av_packet_rescale_ts内部就是做pts * new_tb / old_tb的换算。漏了这一步服务器收到的时间戳可能错得离谱表现为画面忽快忽慢、直接卡死或不被接受。4.4 修正流索引和位置cppm_pkt-stream_index m_outVideoStream-index; m_pkt-pos -1;stream_index输入是 0输出也应该是 0但要显式赋值防止多流场景错位pos表示该包在文件中的字节偏移量输出到网络时无意义置为 -1 让 muxer 忽略4.5 写包发送 RTPcppret av_interleaved_write_frame(m_outFmtCtx, m_pkt);这个函数是整个推流的出口。内部做的事按时间戳排序interleaved就是交错的意思保证 dts 单调递增RTP 封装把 H.264 NAL 单元按照 MTU通常 1400 字节切片加上 RTP 头发送通过 TCP 交织通道或 UDP socket 发到服务器内部释放函数返回时m_pkt已被 unref不需要再手动释放为什么用av_interleaved_write_frame而不是av_write_frameav_write_frame直接写不管时间戳顺序适合单流场景av_interleaved_write_frame内部缓冲并排序确保单调递增多流场景必须用RTSP 推流一般只有一个视频流两者都能用。但用 interleaved 更保险因为 PTS/B 帧场景下能自动处理乱序。4.6 完整循环代码cppwhile (m_running) { int ret av_read_frame(m_inFmtCtx, m_pkt); if (ret 0) { if (ret AVERROR_EOF) { if (!m_loop) break; if (!reopenInput()) break; // 裸 H.264 不可 seek重开输入 firstPkt true; continue; } break; // 其他错误 } // 过滤非视频流 if (m_pkt-stream_index ! m_inVideoIdx) { av_packet_unref(m_pkt); continue; } // pts 节流 // ...见 4.2 // 走 bsf 或直接写 if (m_bsfCtx) { av_bsf_send_packet(m_bsfCtx, m_pkt); av_packet_unref(m_pkt); while (true) { ret av_bsf_receive_packet(m_bsfCtx, m_filteredPkt); if (ret AVERROR(EAGAIN) || ret AVERROR_EOF) break; if (ret 0) { m_running false; break; } av_packet_rescale_ts(m_filteredPkt, m_bsfCtx-time_base_out, m_outVideoStream-time_base); m_filteredPkt-stream_index m_outVideoStream-index; m_filteredPkt-pos -1; ret av_interleaved_write_frame(m_outFmtCtx, m_filteredPkt); av_packet_unref(m_filteredPkt); if (ret 0) { m_running false; break; } } continue; } // 裸 H.264 直接写 av_packet_rescale_ts(m_pkt, m_inTimeBase, m_outVideoStream-time_base); m_pkt-stream_index m_outVideoStream-index; m_pkt-pos -1; ret av_interleaved_write_frame(m_outFmtCtx, m_pkt); av_packet_unref(m_pkt); if (ret 0) break; }4.7 尾部收尾cpp// flush bsf把内部缓存的包吐出来 if (m_bsfCtx) { av_bsf_send_packet(m_bsfCtx, nullptr); // NULL 没有更多输入 while (av_bsf_receive_packet(m_bsfCtx, m_filteredPkt) 0) { av_packet_rescale_ts(...); av_interleaved_write_frame(m_outFmtCtx, m_filteredPkt); av_packet_unref(m_filteredPkt); } } // 发送 RTSP TEARDOWN优雅断开 if (m_outFmtCtx m_outFmtCtx-pb) { av_write_trailer(m_outFmtCtx); }av_write_trailer对 RTSP 输出会内部发TEARDOWN请求让服务器释放会话资源。不调用的话服务器会一直挂着一个死会话重推时可能冲突。