1. 把 RTP 里的 H264 拆开看:三层包装决定你能发多稳
第一次把一段本地 H264 文件用 FFmpeg 推到网络上的时候,我以为一条命令就完事了,结果接收端打开是一片花绿相间的方块,停了半分钟才勉强出画面,再往后就是卡住不动。折腾了一整天才搞清楚:FFmpeg 发送 RTP 封包的 H264,难点根本不在命令怎么写,而在于你要知道 FFmpeg 在背后替你做了哪几层封装,每一层出问题会表现成什么现象。标题看着像是一个简单的推流操作,实际上是 RTP 协议头、H264 的 NAL 单元打包规则、以及 FFmpeg 的 muxer 参数三者叠在一起的事情。
这一段先把包装拆开。你发出去的每一个 UDP 包里,结构是这样叠的:最外层是 UDP 头(8 字节),往里面是 RTP 固定头(12 字节,有 CSRC 会更多),再往里才是 H264 的负载数据。H264 的负载有两种形态,一种是完整的 NAL 单元,另一种是被切成碎片的 NAL 单元片段。接收端要先把碎片拼回完整 NAL,再从 NAL 流里还原出图像帧。这条链路上任何一环对不上,画面就会出问题。
1.1 RTP 固定头里真正影响画面的那五个字段
RTP 头一共 12 字节起步,字段不少,但日常调试真正要盯的只有五个。搞混了它们,看到的抓包数据就是一串看不出意义的十六进制。
| 字段 | 位宽 | 在 H264 场景下的含义 | 出问题时的现象 |
|---|---|---|---|
| M(Marker) | 1 bit | 标记一帧图像的最后一个 RTP 包 | 接收端迟迟不组帧,画面延迟或不出图 |
| PT(Payload Type) | 7 bit | 负载类型,H264 一般用 96 到 127 的动态值 | 接收端不认识负载,直接丢弃 |
| Sequence Number | 16 bit | 包序号,每发一个包加一,到 65535 回绕 | 丢包检测失效、乱序重组失败 |
| Timestamp | 32 bit | 视频用 90000Hz 时钟,同一帧内所有包相同 | 播放节奏错乱、卡顿或快进 |
| SSRC | 32 bit | 同步源标识,区分不同的流 | 多路流混在一起互相干扰 |
M 位是最容易被忽略的一个。H264 一帧图像通常要拆成多个 RTP 包发出去,接收端靠什么判断"这一帧的包收齐了"?就是靠 M 位为 1 的那个包。如果 M 位没设对,或者干脆一直是 0,接收端要么一直等、要么提前认为帧结束,两种都会导致画面异常。FFmpeg 的 rtp muxer 会自动处理 M 位,但如果你自己写代码组包,这是必须手动打的标记。
Timestamp 的坑在于它的时钟频率。RTP 视频固定用 90000Hz,跟帧率没关系。一帧 25fps 的视频,相邻两帧的时间戳差值是 90000/25 = 3600。同一帧拆出来的所有包,时间戳必须完全相同,一个都不能差。我见过有人按包序号递增时间去算时间戳,结果接收端把一帧的多个包当成了不同时刻的数据,画面就像抽帧一样一顿一顿的。
SSRC 平时不用管,FFmpeg 会给一个随机值。但如果同一台机器上同时推多路 H264 到同一个接收端,并且端口配置有交叉,SSRC 冲突就会让接收端把两路流的数据混在一起解析,表现是画面出现完全不相干的内容交错闪现。这种问题极难从现象反推原因,所以多路场景下建议手动指定不同的 SSRC。
1.2 单 NAL、STAP-A、FU-A:H264 打进 RTP 的三种方式
H264 编码器输出的是一串 NAL 单元,每个单元前面有起始码(通常是 00 00 00 01 或 00 00 01),后面跟着 NAL 头和负载。要把这些单元塞进 RTP,标准定义了三种打包模式,FFmpeg 会根据 NAL 大小自动选择。
单 NAL 单元模式是最简单的,一个 NAL 单元装进一个 RTP 包,NAL 头直接放在 RTP 负载的第一个字节。这种方式开销最小,但它要求 NAL 单元的大小不超过 MTU 减去各层头部之后剩下的空间。对于 I 帧那种动辄几十 KB 的 NAL,这种方式根本装不下。
STAP-A 模式是把多个小 NAL 单元打包进同一个 RTP 包。它的负载开头是一个 1 字节的 NAL 头(类型值为 24),后面跟着若干个"2 字节长度 + NAL 内容"的组合。这个模式主要用来把 SPS、PPS 和 SEI 这类小单元捆在一起发,省掉一些包头开销。FFmpeg 在发送参数集时经常会用到它。
FU-A 模式是分片单元,专门处理超大 NAL。它把一个 NAL 切成多片,每片前面加 2 个字节:第一个字节是 FU indicator,类型字段固定填 28;第二个字节是 FU header,高 3 位分别是起始位 S、结束位 E 和保留位 R,低 5 位存原始 NAL 的类型。接收端靠 S 位知道分片开始,靠 E 位知道分片结束,中间按顺序拼接。
分片大小的计算很关键。假设你设的pkt_size是 1200,那么 RTP 负载的最大长度是 1200 减去 12 字节 RTP 头,等于 1188 字节。在 FU-A 模式下,每个分片还要再扣掉 2 字节的 FU 头,实际能装 1186 字节原始数据。一个 40KB 的 I 帧 NAL 大约要切成 35 个分片。这 35 个分片里只要丢了任意一个,整个 I 帧就废了,接收端要么丢弃这一帧等下一个 I 帧,要么用残缺数据解码出一片马赛克。这就是为什么花屏往往集中出现在 I 帧位置。
1.3 SPS 和 PPS 不跟着走,接收端就是一片绿
SPS(序列参数集)和 PPS(图像参数集)是 H264 解码的前提,里面存着分辨率、profile、level、熵编码方式这些信息。解码器如果不先拿到这两个东西,就算收到完整的 IDR 帧也解不出来,只能输出一片绿或者一片灰。
在文件容器里,SPS 和 PPS 通常存在 extradata 里,播放器读文件头就拿到了。但 RTP 是裸流传输,没有文件头这个概念,参数集必须以带内方式跟着码流一起发。具体做法有两种:一是在 SDP 里用sprop-parameter-sets字段把 base64 编码的 SPS/PPS 提前告诉接收端;二是在每个 IDR 帧之前,把 SPS 和 PPS 作为独立的 NAL 单元发一遍。
FFmpeg 这两种都会做。用-sdp_file或者把 stdout 重定向成文件时,生成的 SDP 里会带上sprop-parameter-sets。同时在 RTP 流里,如果输入的 extradata 里有参数集,muxer 会在流开始时把它们作为 STAP-A 包或者单独包发出去。
坑在于:如果你用-c:v copy从某个已经剥离了参数集的裸流复制,或者上游给的 extradata 是空的,那带内就不会有 SPS/PPS。这时候接收端要么靠 SDP 里的字段,要么就是一片绿。我就遇到过接收端只吃带内参数集、不解析 SDP 的情况,最后只能在编码时强制加上-bsf:v dump_extra这种 bitstream filter,把 extradata 里的参数集重新插回码流。还有一个更稳妥的思路,编码时把关键帧间隔设小一点,参数集在每个 IDR 前重复发,即使中途加入的接收端也能在较短时间内拿到参数集开始解码。
2. 命令行拼装:从本地文件到 RTP 输出的最小可用链路
搞清楚了包的内部结构,接下来就是把命令拼对。这一部分我会给两条路径:一条是-c:v copy直通,适合手里已经有编码好的码流、只想换个封装;另一条是重新编码,适合需要控制码率、关键帧间隔和延迟的场景。两条路的参数重点完全不同,混着用很容易出问题。
2.1 输入侧先做减法:-re、-an 与 -c:v copy 的取舍
-re这个参数的作用是让 FFmpeg 按输入文件的原始帧率读取数据,而不是能读多快读多快。不加它的时候,一个 10 秒的视频可能 0.2 秒就推完了,网络瞬间被打满,接收端缓冲区直接溢出,表现是大量丢包和花屏。加上-re之后,发送节奏贴近实时,接收端才有机会正常接收和播放。
ffmpeg -re -i input.mp4 \ -an \ -c:v copy \ -f rtp -pkt_size 1200 -payload_type 96 -ssrc 0x1a2b3c4d \ rtp://192.168.1.100:5004-an是关掉音频。RTP 输出时音视频通常走不同的端口和不同的负载类型,如果只是想验证视频链路,先把音频去掉能排除一大半干扰。等视频通了再加音频,出问题时也容易定位是哪一路的问题。
-c:v copy是直接复制编码后的码流,不重新编码,CPU 占用极低。但它有几个前提:输入必须是 H264,不能是 H265 或者 AV1;输入的时间戳必须适合 RTP 输出;输入码流的分辨率和码率必须已经在你的带宽预算内。我见过有人拿一个 20Mbps 的 4K 文件直接 copy 推到百兆网络里,那必然是收不住的。
复制的另一个隐患是 B 帧。B 帧的解码顺序和显示顺序不一致,容器里存的是 DTS 和 PTS 两套时间戳。RTP 只传 PTS,如果直接用复制模式,遇到 B 帧时 PTS 会出现回退,RTP 时间戳跟着往回跳,接收端的抖动缓冲会误判成乱序包,画面就会卡住或者反复重排。所以如果输入里有 B 帧,要么加-bsf:v h264_mp4toannexb之类的处理,要么干脆重新编码并禁用 B 帧,后者更省心。
2.2 输出侧的参数取舍:pkt_size、payload_type、ssrc 与 RTCP
-pkt_size决定每个 RTP 包的最大字节数,这个值直接决定了分片数量。FFmpeg 的默认值是 1472,理论上以太网 MTU 1500 减去 IP 头 20、UDP 头 8、RTP 头 12,正好剩 1460,1472 是留了一点余量的值。但现实网络里,PPPoE 拨号会吃掉 8 字节,VLAN 标签会吃掉 4 字节,某些隧道封装吃掉的更多。一旦实际 MTU 小于你的包大小,IP 层就会做分片,而 IP 分片里丢任意一片,整个 UDP 包就废了,等于一次丢了一个完整的 RTP 包里的所有分片数据。
我的经验值是1200 到 1300之间,公网传输甚至可以压到 1000 到 1100。代价是包数量变多、头部开销变大,但换来的是对 MTU 变化的容忍度。1000 字节负载的包,头部开销大约是 2% 到 3%,对带宽的影响远小于花屏带来的体验损失。
-payload_type指定 RTP 头的 PT 字段。H264 没有静态分配的负载类型,只能用 96 到 127 的动态范围,常见约定是 96。这个值必须和 SDP 里的a=rtpmap行完全对应,否则接收端不知道怎么解析。
-ssrc手动指定同步源标识。单路推流可以不管,多路推流到同一台接收机时强烈建议手动指定,比如0x1a2b3c4d这种固定值,方便抓包时区分。
还有一个特别容易忽略的点:FFmpeg 的 RTP muxer 默认会启动 RTCP,周期性往 RTP 端口加 1 的端口发送发送端报告。如果你的目标端口 5004 是 RTP,它就会往 5005 发 RTCP。有些接收端或者防火墙没开这个端口,会报错或者刷一堆警告。如果不需要 RTCP 统计,可以用-rtpflags skip_rtcp关掉;如果需要但想换端口,用-rtcp_port指定。
ffmpeg -re -i input.mp4 -an -c:v copy \ -f rtp -pkt_size 1200 -payload_type 96 -ssrc 0x1a2b3c4d \ -rtpflags skip_rtcp \ rtp://192.168.1.100:5004另外补充一个-rtpflags h264_mode0的用法。这个开关会让 muxer 只发单一 NAL 模式,不做 FU-A 分片。听着好像能简化接收端处理,实际上在现代接收端上开了它基本是自找麻烦——大 NAL 要么被截断,要么直接发不出去。这个选项是给那些只认 RFC 6184 早期的模式 0、不支持分片的老设备准备的。除非你明确知道对端有这个限制,否则别碰。
2.3 SDP 不是可选项:让接收端知道你发的是什么
纯 RTP 流本身不带任何"我是什么编码、什么分辨率"的描述信息,接收端全靠 SDP 来获取这些元数据。少了 SDP,接收端只能靠猜,猜不中就是黑屏。
生成 SDP 最省事的方式是用-sdp_file:
ffmpeg -re -i input.mp4 -an -c:v copy \ -f rtp -pkt_size 1200 -payload_type 96 \ -sdp_file stream.sdp \ rtp://192.168.1.100:5004生成的 SDP 长这样:
v=0 o=- 0 0 IN IP4 127.0.0.1 s=No Name c=IN IP4 192.168.1.100 t=0 0 m=video 5004 RTP/AVP 96 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1; sprop-parameter-sets=Z0IAH5WoFAFuQA==,aM48gA==m=video 5004 RTP/AVP 96这一行说明是视频流、端口 5004、负载类型 96。a=rtpmap里的H264/90000是编码格式和时钟频率。a=fmtp里的packetization-mode=1表示支持 FU-A 分片模式,等于 0 只支持单一 NAL。后面的sprop-parameter-sets就是 base64 编码的 SPS 和 PPS,逗号分隔。
接收端拿到这个 SDP 文件就能起播。有些接收端不读sprop-parameter-sets,只认带内参数集,那就要保证码流里确实带了 SPS/PPS,前面说过用-bsf:v dump_extra可以处理。
还有个小细节:c=IN IP4那一行是 FFmpeg 根据输出 URL 自动填的。如果你要发给多台机器,或者接收端和发送端不在同一网段,这一行的地址可能不对,需要手动改。我一般会让接收端自己指定 SDP 里的连接地址,而不是完全信任生成的文件。
3. 编码侧的调优:别让发送端自己制造问题
用-c:v copy能把链路跑通之后,接下来多半要面对重新编码的需求——因为源文件的码率、GOP 结构未必适合实时传输。重新编码这一块,参数设错了,后面的网络调优全是白搭,因为问题从源头就产生了。
3.1 零延迟预设与关键帧间隔的配合
实时传输场景下,-tune zerolatency基本是必加的。它会让 x264 关掉前瞻缓冲、关掉 B 帧、把切片做成可以在编码完一帧之后立刻输出的结构。不加这个参数,编码器默认会缓冲几十帧再开始输出,接收端那边的延迟直接就上去了,而且是不可消除的固有延迟。
ffmpeg -re -i input.mp4 -an \ -c:v libx264 -preset veryfast -tune zerolatency \ -profile:v baseline -level 3.1 -bf 0 \ -g 25 -keyint_min 25 -sc_threshold 0 \ -b:v 1500k -maxrate 1500k -bufsize 3000k \ -pix_fmt yuv420p \ -f rtp -pkt_size 1200 -payload_type 96 -ssrc 0x1a2b3c4d \ rtp://192.168.1.100:5004-g 25是关键帧间隔,25fps 的话等于每秒一个 I 帧。这个值直接影响两件事:一是新加入的接收端多久能出画面,二是丢包后多久能恢复。GOP 太长,丢包后黑屏时间长;GOP 太短,I 帧多、码率浪费。实时场景常用 1 到 2 秒,也就是 25 到 50 帧。
-keyint_min 25配合-sc_threshold 0一起用,作用是强制关键帧按固定间隔出现,不让编码器因为场景变化自己插入额外 I 帧。不加-sc_threshold 0的话,画面一有剧烈变化就插一个 I 帧,码率会突然冲高,弱网下特别容易造成瞬时拥塞。
-profile:v baseline是保守选择。baseline 不支持 B 帧和 CABAC,压缩效率低一些,但兼容性最好,老设备、硬解码器都能解。如果确定对端支持,用 main 或者 high 能省 10% 到 20% 的码率,那就把 profile 提上去,但记得保持-bf 0。
3.2 B 帧与 PTS 回退:RTP 时间戳最隐蔽的坑
前面提过 B 帧会导致 PTS 回退,这里展开说为什么它这么致命。
假设一段 25fps 视频,编码顺序是 I、P、B、B,显示顺序是 I、B、B、P。PTS 是 0、2、3、1,DTS 是 0、1、2、3。RTP 的 timestamp 字段从 PTS 换算而来,于是你会发出时间戳为 0、7200、10800、3600 的包。接收端收到时间戳 10800 之后又收到 3600,按 RTP 序列号来看包序是连续的,但时间戳倒退了。
标准的抖动缓冲会把这种包当成"迟到的乱序包"处理,要么丢掉,要么插到已经播过的位置,结果是画面回溯或者卡顿。更糟的是某些实现会认为时间戳回绕了,直接重置整个时间线,画面就彻底乱了。
排查方法很直接:抓包看 RTP 时间戳序列,如果出现非单调递增,十有八九就是 B 帧没关。解决方式有两种,一是在编码时加-bf 0,二是用-vsync cfr强制恒定帧率输出,让 PTS 重新按顺序生成。前者更彻底,我一般直接用前者。
顺带说一个相关的坑:如果输入是可变帧率(VFR)的视频,PTS 间隔不均匀,转换成 90kHz 之后时间戳也是不均匀的。RTP 本身允许这种情况,但很多接收端的抖动缓冲是按固定帧率假设做的,会表现出一顿一顿。加-vsync cfr把帧率统一能避免这个问题。
3.3 码率、maxrate 与 bufsize:突发流量怎么把网络打崩
-b:v是平均码率,-maxrate是瞬时上限,-bufsize是码率控制器的缓冲窗口。这三个值配合不好,画面可能没问题,但网络会被瞬时突发打爆。
打个比方,-bufsize相当于一个水箱,编码器可以在这个水箱容量范围内自由调节输出速度。水箱越大,编码器越敢在简单画面上攒下大量"码率额度",等到复杂画面一次性释放出来。这时候瞬时码率可能是平均码率的好几倍,交换机端口或者无线链路承受不住就丢包。
实时传输场景下我通常把bufsize设为maxrate的 1 到 2 倍,比如 maxrate 1500k、bufsize 2000k 到 3000k。这样编码器没有太多攒额度的空间,输出相对平滑。代价是画质波动大一些,复杂画面会糊一点,但换来的是稳定的传输。
真要追求平滑,可以考虑 x264 的 CBR 模式,加上-x264-params nal-hrd=cbr:force-cfr=1,让输出码率严格贴近设定值。不过要提醒一句,严格的 CBR 会牺牲不少画质,不是所有场景都值得。
还有一个容易被忽略的参数是-max_delay。它控制 muxer 的缓冲时长,单位是微秒。默认值在实时场景下偏大,设成-max_delay 0或者一个很小的值能让数据尽快发出去,减少发送端引入的延迟。
4. 收流验证与抓包:怎么确认你发出去的包是对的
发送端命令跑起来不报错,不代表接收端能正常收。这一节讲三套验证手段,按从粗到细的顺序来:先看能不能播,再看包结构对不对,最后核对时间戳。
4.1 本机回环:用播放器加 SDP 做最小验证
最省事的验证是在本机起一个接收端。把 SDP 文件保存好,然后:
ffplay -protocol_whitelist file,udp,rtp stream.sdp-protocol_whitelist这个参数不能省。新版 FFmpeg 出于安全考虑,默认不允许播放器打开任意协议,SDP 里引用了 UDP 和 RTP,必须显式放行,否则会报 "Protocol not on whitelist" 之类的错误。这个报错信息不算直观,第一次遇到容易以为是 SDP 写错了。
如果播放器能出画面,说明 RTP 封装、负载类型、SDP 描述这三块基本正确。如果黑屏但没报错,先看播放器的日志输出,加上-loglevel debug能看到收包情况和解析过程。如果报 "could not find codec parameters",那多半是 SPS/PPS 没送到,回到 1.3 节检查参数集。
本机验证有个前提:发送端和接收端在同一台机器上,用127.0.0.1或者本机网卡地址。这样能排除网络因素,把问题锁定在封装层面。等本机通了再去跨机测试,出问题时至少知道不是封装的问题。
4.2 抓包看 FU-A 的 S/E 位与序号连续性
本机通了但跨机不通,或者跨机播放花屏,就得抓包了。抓包工具过滤udp.port == 5004,看 RTP 包的内部结构。
判断分片是否正常,主要看三个地方。第一是 FU indicator 的类型字段,值应该是 28。如果不是 28 而是 1 到 23 之间的值,说明这个包是完整的单 NAL,没问题。如果看到 24,那是 STAP-A 聚合包。
第二是 FU header 的 S 和 E 位。一个被分片的 NAL,第一个分片的 S 位为 1、E 位为 0,中间分片两位都是 0,最后一个分片 S 为 0、E 为 1。如果抓包里看到某个 NAL 只有 S 没有 E,说明分片没发完,要么是发送端中途断了,要么是网络丢了后面的包。
第三是序列号的连续性。正常的 RTP 流序列号应该是严格递增的,允许乱序到达(UDP 本身不保证顺序),但不应该有缺口。如果有缺口,那就是丢包。缺口的位置很关键:如果缺口集中在某个 I 帧的分片区间内,那花屏就有了明确解释。
还有一种情况是序列号连续但内容不对。这时候要看负载里的 NAL 类型,如果一帧里混进了别的帧的 NAL,说明发送端的时间戳或者组包逻辑有问题。这种在自研发送端里比较常见,用 FFmpeg 命令行基本不会遇到。
4.3 时间戳与抖动:从 PTS 到 90kHz 的换算核对
时间戳核对是个细致活。换算公式是:RTP timestamp = PTS(以秒为单位)× 90000。反过来说,相邻两帧的时间戳差值 = 90000 / 帧率。
25fps 的话,差值应该是 3600。30fps 是 3000。如果抓包看到的差值不是这个数,先确认帧率是不是真的恒定,再确认有没有 B 帧导致的回退,最后检查输入的时间基转换是否正确。
还有一个容易忽略的现象是时间戳抖动。理论上同一帧的所有包时间戳完全一致,不同帧之间严格按帧间隔递增。实际抓包时,如果看到时间戳有小幅抖动(比如 3600 变成 3598 或者 3602),那多半是输入时间戳本身不够精确,FFmpeg 在换算时做了取整。这种级别的抖动一般不影响播放,接收端的抖动缓冲能吸收掉。
真正影响体验的是到达时间的抖动,也就是网络抖动。RTP 时间戳描述的是内容的播放时刻,而包实际到达的时刻受网络影响。接收端要维持一个抖动缓冲,把早到的包压住、等晚到的包。缓冲深度设小了,网络一抖就丢包;设大了,延迟就上去了。这部分调节在接收端做,发送端能做的就是尽量让输出平滑,别让编码器攒出突发流量。
5. 排查链路:花屏、绿屏、卡顿、断流的定位顺序
前面都是准备工作,这一节是真正出问题时的处置流程。我按现象分了四类,每类的排查顺序不一样,但共同原则是:从最可能的原因开始,每步都能排除一类可能性,不要跳步。
5.1 花屏:先怀疑分片丢失,再怀疑 MTU
花屏是最常见的现象,表现是画面局部出现彩色方块或者条纹,过一会儿又恢复正常。根因基本可以锁定在数据不完整上。
第一步看花屏出现的时机。如果只在画面剧烈变化时出现,而且很快恢复,那是码率突增导致的丢包。回到 3.3 节调 bufsize 和 maxrate。如果花屏持续到下一个关键帧才恢复,那说明丢的是 I 帧,问题更严重。
第二步抓包确认丢包位置。如果丢的是单个完整 RTP 包(序列号有缺口,但缺口前后的包大小正常),说明是网络随机丢包。如果丢的是连续的多个包,而且这些包的大小都接近pkt_size设置值,那基本可以确定是 MTU 问题——大包被 IP 层分片,分片丢一个就整包丢失。
第三步验证 MTU 假设。把-pkt_size降到 1000 甚至 800 再试,如果花屏明显减少或者消失,假设成立。这时候不需要纠结具体 MTU 是多少,找一个稳定工作的值就行。实际链路的 MTU 可能因为各种封装而小于理论值,实测比计算可靠。
还有一种花屏是"从上到下滚动"的,这种通常是 SPS/PPS 里的分辨率信息和实际码流不一致,或者接收端缓存的参数集过期了。检查 SDP 里的sprop-parameter-sets和实际码流是否匹配,尤其是换过编码参数之后没重新生成 SDP 的情况。
5.2 绿屏或纯黑屏:九成是参数集没送到
绿屏、灰屏、纯黑但播放器不报错,这类现象的典型成因是解码器拿到了码流但没有参数集,只能输出一帧占位图像。
排查顺序是这样。先确认 SDP 里的sprop-parameter-sets字段有没有内容。如果为空或者缺失,说明 FFmpeg 没能从输入里提取到参数集,这种情况在输入是裸 H264 流时很常见,因为裸流没有 extradata 这个概念。
再确认带内有没有参数集。抓包看流最开始的部分,应该能看到类型为 7(SPS)和 8(PPS)的 NAL,或者一个 STAP-A 包里同时包含这两个。如果流开头没有,后面每个 IDR 之前也没有,那就是彻底没发。
修复方式是在编码命令里加-bsf:v dump_extra:
ffmpeg -re -i input.h264 -an \ -c:v copy -bsf:v dump_extra \ -f rtp -pkt_size 1200 -payload_type 96 \ -sdp_file stream.sdp \ rtp://192.168.1.100:5004这个 filter 会把 extradata 里的参数集重新插回码流。如果输入本身连 extradata 都没有,那就只能重新编码,让编码器生成参数集。
还有一种绿屏是"只有第一帧绿,后面正常"。这种情况通常是接收端在拿到参数集之前就开始解码了,第一帧解不出来,等参数集到了就恢复了。如果接收端支持在 SDP 里读参数集,确保 SDP 正确传递就能解决。如果不支持,那就要保证每个 IDR 前都重发参数集,让接收端有机会补上。
5.3 卡顿与延迟累积:缓冲、GOP 与网络抖动
画面能动但一卡一卡的,或者延迟越来越大,这类问题跟丢包关系不大,更多是节奏问题。
延迟累积的典型表现是:刚连上时延迟 1 秒,播了 5 分钟变成 10 秒。这说明接收端的缓冲在持续增长,消费速度跟不上生产速度。原因可能是接收端解码性能不足,也可能是 RTP 时间戳和实际到达节奏不匹配,导致缓冲算法判断失误。
发送端能做的改进有两点。一是确认时间戳严格单调递增且间隔均匀,回到 3.2 节检查 B 帧。二是确认-re真的生效了,如果没加这个参数,发送速度远快于实时,接收端缓冲会被灌满。检查方法是看 FFmpeg 输出的进度,正常应该是 1x 速度左右。
如果是周期性卡顿,比如每隔几秒卡一下,那要怀疑 GOP 太长。GOP 是 250 帧的话,10 秒才有一个 I 帧,中间任何一次丢包都要等 10 秒才能恢复,表现就是每隔一段时间卡一下。把-g调到帧率的 1 到 2 倍能明显改善。
网络抖动导致的卡顿比较难从发送端解决,能做的是降低码率、减小包大小、开启前向纠错(如果链路支持)。RTP 层面还可以考虑加冗余编码,把关键帧重复发一份,代价是带宽翻倍,只在关键场景用。
6. 从 demo 走向工程:几处必须改造的地方
命令行能跑通之后,如果要把这套东西做成服务,还有几处需要改造。这一节讲三个方向,都是我实际做过的。
6.1 用 libavformat 的 API 替代命令行
命令行适合验证,不适合集成。真要做服务,得用 libavformat 的 API 自己组包。核心流程是:分配输出上下文、创建流、写文件头、循环写包。
AVFormatContext *ofmt = NULL; avformat_alloc_output_context2(&ofmt, NULL, "rtp", url); AVStream *ost = avformat_new_stream(ofmt, NULL); ost->codecpar->codec_type = AVMEDIA_TYPE_VIDEO; ost->codecpar->codec_id = AV_CODEC_ID_H264; ost->time_base = (AVRational){1, 90000}; AVDictionary *opts = NULL; av_dict_set(&opts, "pkt_size", "1200", 0); av_dict_set(&opts, "payload_type", "96", 0); av_dict_set_int(&opts, "ssrc", 0x1a2b3c4d, 0); avio_open(&ofmt->pb, url, AVIO_FLAG_WRITE); avformat_write_header(ofmt, &opts); /* SDP 会打印到 stdout */avformat_write_header调用之后,SDP 内容会输出到标准输出,需要自己重定向收集。写包的时候,pkt->pts要用av_rescale_q换算到流的 time_base(也就是 1/90000),并且要保证单调递增。每写完一帧调一次av_interleaved_write_frame,不要自己攒包。
有一点要特别注意:avformat_write_header之后再改pkt_size之类的参数是无效的,所有选项必须在写头之前通过字典传进去。我一开始不知道这个,写头之后再设参数,怎么都不生效,查了半天源码才明白。
6.2 多路分发与会话管理
一台机器要给多个接收端推流,不是简单地循环调avformat_write_header。每路接收端需要独立的输出上下文、独立的序列号空间、独立的 SSRC。共享同一份编码数据是可行的,但每路要单独组 RTP 包。
端口分配也要规划。RTP 端口通常是偶数,RTCP 用下一个奇数端口。如果开了 RTCP,每路要占两个端口。10 路流就是 20 个端口,加上防火墙规则,很容易配错。我的做法是关掉 RTCP(skip_rtcp),只用 RTP 单向发送,端口管理简单很多,代价是拿不到接收端的接收质量报告。
会话管理还包括接收端的动态加入和退出。新加入的接收端需要尽快拿到关键帧和参数集,做法是在它加入时主动触发一次 IDR 编码,而不是等下一个 GOP 周期。这需要在编码器层面配合,用avcodec_send_frame时传一个带AV_PKT_FLAG_KEY的标记,或者在 x264 里用x264_encoder_encode的强制 IDR 参数。
6.3 弱网下的码率自适应
公网传输绕不开弱网。固定码率在链路好的时候浪费带宽,链路差的时候直接崩。自适应码率需要接收端的反馈,比如 RTCP 的接收报告里带丢包率,发送端根据丢包率调整编码码率。
实现上分两层。上层是策略,丢包率超过 5% 就降一档码率,低于 1% 就升一档,中间加个迟滞避免抖动。下层是执行,动态修改编码器的码率参数。libx264 支持运行时改码率,用av_opt_set_int(enc_ctx->priv_data, "bitrate", new_bitrate, 0)之类的接口,但要注意改完之后需要重新配置,不是所有编码器都支持热更新。
更稳妥的做法是预备几档编码参数,切换时重建编码器。代价是切换瞬间会有一个 IDR,画面会闪一下,但实现简单、行为可预期。我一般用三档:高码率 2M、中码率 1M、低码率 500k,阈值分别设在丢包率 1% 和 8%。
最后提一个实际经验:弱网下降低分辨率比降低码率效果更好。同样是 500k 码率,720p 压到 500k 会糊成一片,降到 360p 反而清晰。所以自适应策略里,分辨率也应该作为一档可调参数,而不是只动码率。
这套东西我断断续续调了小半年,最深的体会是:FFmpeg 发送 RTP 封包的 H264 这条链路,命令本身十分钟就能学会,真正花时间的是理解每一层封装在什么情况下会出问题、出了问题从哪个现象能反推到哪一层。上面这些参数和排查顺序,都是被花屏和绿屏教育出来的,直接抄过去用能省不少时间。