
前两天一个做直播的朋友问我ffmpeg推流到SRS播放端延迟越滚越大画面时不时卡一下是不是推流端的写法有问题这问题我太熟了RTMP推流端Publisher和RTMP拉流播放器Player虽然底层都是FFmpeg那套库但真正动手做的时候两边要关注的东西完全不一样。这篇文章就把这条链路完整拆开协议怎么走、命令行怎么起、C接口怎么封装、时间基time base怎么设最后重点聊为什么ffmpeg推流到SRS会存在延迟、怎么一层层把问题揪出来。内容偏实战命令可以直接复制跑C代码思路也可以照着改适合刚接触流媒体的人也适合已经在用FFmpeg做项目但被延迟和卡顿折磨的朋友。1. RTMP推流与拉流本质上是两条不同的数据链路很多刚接触FFmpeg的人有个误区觉得推流端和播放端就是同一个API反着用。实际上推流端走的是“往RTMP服务器写FLV数据”的逻辑拉流播放器走的是“从RTMP服务器读FLV数据再解码渲染”的逻辑。方向不同侧重点自然也不同。1.1 先理解RTMP在FFmpeg里的真实角色RTMPReal-Time Messaging Protocol本身是基于TCP的流媒体协议默认端口1935。它在传输层之上承载的并不是裸码流而是FLV封装格式的数据。换句话说FFmpeg推流时做的事情是把音视频数据封装成FLV Tag然后通过RTMP协议打包发送拉流时则相反先从RTMP流里还原出FLV Tag再交给解封装器和解码器。我见过不少人直接把rtmp://当成一个普普通通的输出URL然后发现推出去的流播放器打不开。原因就是没搞明白FFmpeg在遇到-f flv和rtmp://这两个条件时内部走的是flvmuxrtmpmux的组合逻辑。输出封装必须是FLVRTMP只是承载FLV的传输通道。类似地拉流时rtmpdemux把RTMP通道还原成FLV数据流再交给flvdemux解封装。理解这一层后面很多报错信息就都能看懂了。1.2 推流是“实时写入”拉流是“持续读取”推流端的要求是稳定、匀速地把数据写出去不能因为CPU瞬间忙就把数据瞬间吐出一大堆这会让播放端很快堆积缓冲延迟就上来了。所以命令行推流通常都要加-re让FFmpeg按原始帧率来读文件、发数据。拉流端的要求则是及时地读取并解码显示同时要在网络抖动时保留一小段缓冲以防卡顿。这两者的矛盾在延迟问题上体现得特别明显推流端想要尽量存数据保证不卡播放端想要尽量少缓冲降低延迟。实际项目中推流端、服务器、播放器三方的缓冲策略往往各自独立延迟问题很难只靠改一个环节解决。1.3 FFmpeg用一套API处理两种场景的原因这里要提一下FFmpeg的架构设计。无论推流还是拉流核心都是AVFormatContext这个结构体。对推流而言它代表一个输出上下文用avformat_alloc_output_context2创建对拉流而言它代表一个输入上下文用avformat_open_input创建。两者都包含了输入/输出的URL、流信息、封装格式等。但注意AVFormatContext是共用的不代表逻辑可以照搬。比如推流时要调用avformat_write_header写FLV头拉流时要调用avformat_find_stream_info去探测流信息推流结束要av_write_trailer拉流结束直接avformat_close_input就行。这些调用顺序上的差异就是两个方向最核心的代码分水岭。2. 推流端Publisher怎么实现从命令行到C封装如果说看懂封装关系是“热身”那推流端的实现才是第一个硬骨头。这里我从一行命令开始讲再过渡到C代码封装大家可以根据自己的使用场景各取所需。2.1 一行命令把本地文件推到RTMP服务器最简单的推流本地测试可以直接这么跑ffmpeg -re -i input.mp4 -c copy -f flv rtmp://127.0.0.1:1935/live/stream这条命令的意思是按原样复制输入文件的编码数据封装成FLV格式通过RTMP协议推到本机的SRS服务器上流名称是stream位于应用live下。如果输入文件是循环播放或者需要实时编码常见做法是ffmpeg -re -stream_loop -1 -i input.mp4 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -c:a aac -b:a 128k \ -f flv rtmp://127.0.0.1:1935/live/stream这里有几个参数值得多说两句。-stream_loop -1表示无限循环输入文件适合做24小时在线测试-preset ultrafast让x264编码速度最快CPU负担小-tune zerolatency是专门针对低延迟场景的x264调优选项关闭了额外缓冲和延迟优化。对于实时性要求高的场景这两个参数基本是标配。2.2 -re、-c copy、-f flv这三个参数的底层逻辑-re的本质是让FFmpeg以“读入源文件的实时速率”来读取而不是拼命读。如果不加-reFFmpeg会以最快速度把input.mp4读空然后瞬间把几千个包通过网络发出去播放器根本来不及处理服务器上也很快就看到流结束。-c copy是把输入文件的音视频编码数据原封不动地搬到输出端。前提是源文件的编码格式和目标服务器兼容比如源码是H264AAC。如果不兼容比如源文件是MPEG4编码就必须重新编码。用-c copy时封装格式变化时间戳也会被转换但不能添加服务器需要的SEI等信息。-f flv是强制输出FLV封装。RTMP流的标准载荷就是FLV不指定这个参数的话FFmpeg可能因为无法从URL后缀判断封装格式而直接报错Unable to find a suitable output format。2.3 C封装推流端从avformat_alloc_output_context2开始命令行适合测试项目里一般还是用C来封装推流器。核心链路并不复杂伪代码如下AVFormatContext* oc nullptr; // 1. 通过输出格式和URL创建输出上下文 avformat_alloc_output_context2(oc, nullptr, flv, url); // 2. 给RTMP服务器设置连接参数 AVDictionary* opts nullptr; av_dict_set(opts, rtmp_live, live, 0); // 直播模式 av_dict_set(opts, rw_timeout, 5000000, 0); // 读写超时5秒 av_dict_set(opts, flvflags, no_duration_filesize, 0); // 3. 打开网络IO if ((ret avio_open2(oc-pb, url, AVIO_FLAG_WRITE, nullptr, opts)) 0) { // 处理推流地址连接失败 }rtmp_live这个参数建议设置成live否则FFmpeg默认可能以录制的模式连接RTMP服务器部分服务器会把它当录像流处理行为不一样。紧接着就是创建流对象、设置编码器参数。如果是转码后推流需要创建视频和音频两个流AVStream* vs avformat_new_stream(oc, nullptr); vs-codecpar-codec_type AVMEDIA_TYPE_VIDEO; vs-codecpar-codec_id AV_CODEC_ID_H264; vs-codecpar-width 1280; vs-codecpar-height 720; // 赋值编码器参数后用avcodec_parameters_from_context同步这里有个很多人会踩的坑直接手动填codecpar而不调用avcodec_parameters_from_context会导致后续封装时SPS/PPS信息缺失播放器黑屏。正确做法是先初始化AVCodecContext、avcodec_open2然后用avcodec_parameters_from_context(vs-codecpar, codec_ctx)把参数同步过去。写完头之后进入推流主循环avformat_write_header(oc, nullptr); // 写FLV头 while (true) { AVStream* in_stream ifmt_ctx-streams[packet-stream_index]; AVStream* out_stream oc-streams[packet-stream_index]; // 重新计算时间戳统一到输出流的时间基 packet-pts av_rescale_q_rnd(packet-pts, in_stream-time_base, out_stream-time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_1); packet-dts av_rescale_q_rnd(packet-dts, in_stream-time_base, out_stream-time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_1); packet-duration av_rescale_q(packet-duration, in_stream-time_base, out_stream-time_base); packet-stream_index out_stream-index; av_interleaved_write_frame(oc, packet); }每次写入前必须做的av_rescale_q_rnd目的就是把AStream的时间基单位换算成BStream的时间基单位。很多人在封装RTMP推流时遇到“时间戳跳变”“播放进度不对”的问题十有八九是这里没做转换。2.4 推流端为什么要特别关注time base热词里有一个是“ffmpeg codec time base”这说明时间基确实是高发问题点。在FFmpeg中time_base是一个分数比如{1, 1000}表示单位是1/1000秒也就是毫秒{1, 90000}表示单位是1/90000秒常见于视频时间戳的高精度计算。RTMP协议里FLV的时间戳单位是毫秒。所以在推RTMP流时我通常把输出流的time_base设置为{1, 1000}然后确保写入前每个packet的pts/dts都换算成毫秒。若直接把视频源的{1, 25}或编码器默认的{1, 12800}丢进去会出现播放器无法正确换算时间轴、声音不同步或卡顿的现象。稍不注意还会混淆AVStream::time_base和AVCodecContext::time_base。前者是封装层的时间基后者是编解码器内部的时间基。推流的时候读的是AVStream.time_base解码时关心的是AVCodecContext.time_base。这个区别在拉流播放器里更加重要。3. 拉流播放器Player怎么实现不是把推流反过来那么简单拉流播放器看着像是推流端的“镜面操作”实际上解码、缓冲、同步、渲染这些环节每个都藏着坑。先从命令行验证开始再给C封装的思路。3.1 命令行低延迟拉流与录像验证FFmpeg自带的ffplay可以直接播放RTMP流想要低延迟体验至少要这样写ffplay -fflags nobuffer -analyzeduration 0 -rtbufsize 0 -i rtmp://127.0.0.1:1935/live/stream-fflags nobuffer是关闭播放器的输入缓冲-analyzeduration 0是取消流的探测分析时长-rtbufsize 0是限制实时缓冲大小。这三个参数能明显减少从“打开流”到“出画面”之间的等待时间但代价是网络稍微抖动就可能卡顿。如果只是想验证推流内容是否正确不追求低延迟也可以用FFmpeg把RTMP流存成文件ffmpeg -i rtmp://127.0.0.1:1935/live/stream -c copy output.flv注意这里不能加-re拉流本来就该按实时到达的速度接收加了-re反而会限制读取速度导致录像文件越存越空。3.2 C播放器封装的完整调用链播放器端用FFmpeg库拉流主干调用如下AVFormatContext* ifmt_ctx nullptr; AVDictionary* opts nullptr; av_dict_set(opts, fflags, nobuffer, 0); av_dict_set(opts, rw_timeout, 5000000, 0); av_dict_set(opts, timeout, 5000000, 0); // 1. 打开RTMP输入 if (avformat_open_input(ifmt_ctx, rtmpUrl, nullptr, opts) 0) { // 连接失败处理 } // 2. 探测流信息找到音视频流 if (avformat_find_stream_info(ifmt_ctx, nullptr) 0) { // 无法获取流信息 } // 3. 找到解码器并打开 for (int i 0; i ifmt_ctx-nb_streams; i) { const AVCodec* codec avcodec_find_decoder(ifmt_ctx-streams[i]-codecpar-codec_id); AVCodecContext* dec_ctx avcodec_alloc_context3(codec); avcodec_parameters_to_context(dec_ctx, ifmt_ctx-streams[i]-codecpar); avcodec_open2(dec_ctx, codec, nullptr); }打开解码器之后通常不会直接在读取线程里解码而是维护一个队列AVPacket* pkt; while (av_read_frame(ifmt_ctx, pkt) 0) { // 将packet放入队列或者直接交给解码线程 if (pkt-stream_index video_stream_index) { video_packet_queue.push(pkt); } else if (pkt-stream_index audio_stream_index) { audio_packet_queue.push(pkt); } av_packet_unref(pkt); }这里的核心问题是缓冲策略读帧的速度快于解码速度时队列会越积越长延迟自然变大队列太短网络抖动时又会断流。一个常见做法是按时间戳控制队列深度比如让视频packet队列里的数据量保持在200毫秒到500毫秒的水平超过上限就丢弃旧的packet。这样能在卡顿和延迟之间找一个平衡点。3.3 GOP与关键帧在拉流中的影响RTMP推流时视频编码器会按GOPGroup of Pictures生成关键帧I帧和普通帧P帧/B帧。播放器只有拿到关键帧才能开始解码出完整画面所以播放器打开RTMP流时必须等到下一个关键帧到达才能出画面。这就是SRS服务器上有一个“GOP cache”概念的原因服务器会缓存最近一个GOP的数据新播放器一连接就能立刻从关键帧开始推送而不必等待推流端生成新关键帧。本地验证时如果播放器迟迟不出画面可以先用ffprobe看推流端的GOP间隔ffprobe -v error -select_streams v:0 -show_entries streamwidth,height,r_frame_rate,avg_frame_rate,time_base -of defaultnoprint_wrappers1 input.mp4热点里的“ffmpeg视频信息查询与逐帧导出”也是类似的思路用ffprobe拿到流信息用ffmpeg -vf selectnot(mod(n\,30))逐帧导出画面。排查流格式问题时多借助这些工具比肉眼盯着黑屏猜要高效得多。3.4 Windows下硬解码选型d3d11va还是dxva2很多播放器项目跑在Windows上拉到RTMP流后默认用CPU软解一路1080P或者多路拉流时CPU占用立刻飙升这时候就要上硬件解码。FFmpeg在Windows上的两个主要硬解API就是dxva2和d3d11va。简单来说dxva2是DirectX 9时代的接口使用起来需要处理IDirect3DSurface9在FFmpeg里对应-hwaccel dxva2而d3d11va是基于DirectX 11的接口对应-hwaccel d3d11va。实际开发中我更推荐d3d11va原因有三可以配合-hwaccel_output_format d3d11直接把解码结果留在显存里后续做滤镜或渲染更顺手。D3D11的多线程和资源管理比D3D9更现代多路解码时不容易互相阻塞。新版本FFmpeg对d3d11va的支持更积极新显卡驱动下兼容性更好。调用方式上在打开解码器前设置AVCodecContext的get_format回调并指定AV_PIX_FMT_D3D11或者在命令行里简单写ffplay -hwaccel d3d11va -i rtmp://127.0.0.1:1935/live/stream需要注意硬解不是免费午餐。使用d3d11va时如果后面要做逐帧分析或二次编码往往需要调用av_hwframe_transfer_data把数据从显存拷回内存。这个拷贝过程本身有开销对于低延迟播放器反而要权衡是否值得。3.5 音视频同步的起点RTMP拉流播放器真正做到“声音对得上画面”需要依靠时间戳计算。FLV流的音频时间戳和视频时间戳都是毫秒单位播放器拿到每帧数据后以音频时钟为主基准进行同步是业界最常见的做法。一套简化逻辑是维护audio clock即当前正在播放的音频帧的pts值视频帧渲染时计算视频帧pts与audio clock的差值如果视频快了就延迟显示视频慢了就尽快丢帧追上。FFmpeg里有一个默认的同步机制但在C封装时很多人会重写这个逻辑以便更精细地控制延迟。4. 延迟问题的排查链路从推流到播放逐层找原因热搜里那句“ffmpeg推流到srs存在延迟”我几乎每月都能看到类似的提问。必须承认延迟是RTMP链路里最让人头疼的问题因为它从来不是一个点造成的。我这里分享一套排查路径大家可以对着自己的场景逐层排除。4.1 延迟到底来自哪几层延迟就像水管里的水每一节管子都会存水。RTMP链路至少分成四层推流端编码和封装的缓冲。TCP发送和内核缓冲。服务器接收、缓存、转发策略。播放器接收缓冲和解码显示。想定位问题先记住一个结论延迟表现为“播放器里的画面时间比真实时间慢”所以最直接的手段是给推流画面打上时间水印然后播放器截图和本地时钟对比。这样可以量化延迟数值而不是凭感觉觉得“有点卡”。4.2 推流端最容易引入延迟的三个参数推流端最隐蔽的延迟源是编码器和封装缓冲。编码器方面preset越慢单帧编码耗时越长同时可能引入B帧重排。B帧会打乱帧显示顺序因此增加延迟。想低延迟尽量用-preset ultrafast和-tune zerolatency并关注-bf 0禁止B帧。封装方面av_interleaved_write_frame内部有一个交错缓冲默认可能会攒数据到一定量才往网络发送。FFmpeg提供一个关键参数-flush_packets 1加上这个参数可以让muxer尽量不缓存packet写一帧发一帧。命令行中也可以放到输出公用参数位置。另外-max_interleave_delta默认值在某些场景下也会导致缓冲可以调小比如-max_interleave_delta 0强制即时交错。推流端还有一个容易忽略的点如果你用了-re并配合-stream_loop -1循环输入源文件本身的GOP间隔会直接决定播放端加入延迟。H264源文件10秒一个I帧那播放器连接后最长可能要等10秒才能出画面。4.3 SRS服务器侧关注gop_cache和最小延迟模式SRS作为RTMP服务器默认开启GOP cache这会增加新播放器接入时的延迟。对于低延迟直播SRS有对应的配置项vhost __defaultVhost__ { tcp_nodelay on; min_latency on; play { gop_cache off; queue_length 10; mw_latency 100; } publish { mr off; mr_latency 150; } }gop_cache off关闭关键帧缓存新播放器要等到推流端下一个I帧才能出画面但延迟更低mr off关闭合并读取数据到得越早发得越早。实际操作时我会先在服务器上打开SRS控制台默认8080端口观察推流端的码率和播放端的连接状态确认服务器本身没有积压大量缓冲。4.4 播放器端nobuffer不是万能的播放器的缓冲策略对延迟影响非常直接。前面提到-fflags nobuffer能关掉大部分输入缓冲但如果项目里自己实现了packet队列那要主动控制队列长度。常见经验值网络稳定、本地局域网测试队列目标100到200毫秒。公网拉流队列目标500毫秒到1秒防止抖动卡顿。对延迟极度敏感比如连麦需要结合服务端和推流端的整体优化播放器单点压不了多少。还有一种容易被忽略的情况播放器的音频设备缓冲。即使FFmpeg侧已经把音频帧送到系统声卡输出本身也可能带来100到200毫秒的延迟。所以做低延迟播放器时要关注音频输出的缓冲设置必要时要选用低延迟音频API。4.5 实测可用的低延迟推流参数组合分享一套我经常用于局域网RTMP低延迟测试的命令组合。视频源用循环文件模拟实时推流ffmpeg -re -stream_loop -1 -i input.mp4 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -g 30 -keyint_min 30 -sc_threshold 0 \ -bf 0 \ -b:v 2500k -maxrate 2500k -bufsize 500k \ -c:a aac -b:a 128k -ar 44100 \ -f flv -flvflags no_duration_filesize \ -flush_packets 1 \ rtmp://127.0.0.1:1935/live/stream参数含义分别是-g 30每30帧一个关键帧即1秒一个I帧假设25fps。-keyint_min 30强制最小关键帧间隔也为30帧避免编码器提前插入I帧。-sc_threshold 0关闭场景切换检测的自动插I帧功能。-bf 0禁用B帧避免重排延迟。-bufsize 500kVBV缓冲控制防止码率波动太大。-flvflags no_duration_filesize直播流没有时长和文件大小加这个防止FLV头信息写入不合法数据。配合播放端的ffplay -fflags nobuffer -analyzeduration 0 -rtbufsize 0在局域网内一般能把端到端延迟控制在1秒以内。如果还想更低就需要依赖SRS的集群和协议优化那就是直播架构层面的东西了。5. 自己搭一套可复现的RTMP测试环境光看理论不跑通一次知识永远是半吊子。这里给一套自测环境搭建流程覆盖RTMP推流服务器、测试地址、常见问题验证三个环节。5.1 用Docker快速起一个SRSSRSSimple Realtime Server是目前用得比较多的开源RTMP流媒体服务器对FFmpeg的兼容性也比较好。本地测试时用Docker最省事docker pull ossrs/srs:5 docker run -it --rm -p 1935:1935 -p 1985:1985 -p 8080:8080 ossrs/srs:5启动后1935端口是RTMP服务端口8080端口是SRS自带控制台1985是HTTP API端口。看到日志里出现start server相关字样就说明SRS已经起来了。如果网上不方便拉镜像也可以从源码编译但编译是另一套话题。日常调试Docker版本已经够用。5.2 一套完整的推拉流验证命令本地推流ffmpeg -re -stream_loop -1 -i input.mp4 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -c:a aac -f flv rtmp://localhost:1935/live/test本地拉流播放ffplay -fflags nobuffer -analyzeduration 0 -rtbufsize 0 \ -i rtmp://localhost:1935/live/test这里的RTMP测试地址就是rtmp://localhost:1935/live/testlive是SRS里的应用名test是流名称。换成其他流名就是一路新流推流方和播放方使用同一个rtmp://localhost:1935/live/xxx就能联通。5.3 常见的三个坑和建议解决方式第一个坑是地址写错。很多人把live/test写成live/test/尾部多一个斜杠SRS会报Invalid stream path。第二个坑是推流端编码格式不支持。SRS对H264AAC支持最稳遇到Unsupported video codec时检查输入文件编码必要时增加-c:v libx264强制转码。第三个坑是忘记加-f flv。少了这个参数FFmpeg会从URL后缀推断格式而对rtmp://这种协议URL经常推断失败报Unable to find a suitable output format。验证流是否正常除了用播放器看画面还可以用ffprobe拉取流信息ffprobe -v error -show_streams rtmp://localhost:1935/live/test能输出视频流和音频流的编码参数说明服务器分发正常如果卡在探测阶段多半是网络不通或服务器没有正常收到数据。5.4 从推拉到完整分发配置扩展思路RTMP推流端和拉流播放器只是链路的两端。中间服务器还可以做更多事比如SRS收到RTMP流后转封装输出HLS流、录制FLV到磁盘、转推给其他RELAY服务器。这些能力在SRS的配置里就是一个简单的vhost块设置实际生产环境中一套RTMP推流可以同时给播放器、网页H5、短视频录制系统供给内容。我自己做项目时通常会把推流端封装成独立的进程拉流播放器封装成带重连和缓冲策略的模块。每次测试新版本前先用上面这套命令验证一遍链路通畅再处理业务逻辑。最后说一点个人体会RTMP推拉流这个域最忌讳“出了一行命令就往生产环境搬”。延迟、卡顿、黑屏、断流这些现象背后往往是多环节共同作用的结果。先把命令行跑通再逐步替换成C封装每替换一步就验证一次时间戳和GOP参数这套流程虽然慢但能让你少踩掉一大半的坑。如果你的推送对象是公网服务器网络环境更复杂那还要增加对muxer queue、TCP缓冲和播放器队列深度的持续监控那个话题就留给下一篇了。