4、FFmpeg RTP封装:架构、打包流程与时间戳写入
RTP封装,说白了就是把编码后的音视频数据,切成合适大小的包,再塞上RTP头、时间戳、SSRC这些信息,扔到网络上去。FFmpeg的RTP muxer干的就是这个活。我最早接触这块时,以为就是个简单的“数据分包”,后来踩了几个坑才发现,里面的门道比想象中多得多。
4.1 RTP muxer的整体架构
FFmpeg的RTP muxer位于libavformat/rtpenc.c和libavformat/rtpenc_chain.c。它的核心思路是:一个输出流对应一个RTP会话。如果你同时推视频和音频,FFmpeg会创建两个独立的RTP muxer实例,各自维护自己的序列号、时间戳基线和SSRC。
核心组件一览:
- RTPMuxContext:每个muxer实例的上下文,存着序列号、时间戳、SSRC、payload type、包计数等。
- ff_rtp_enc_init():初始化函数,设置默认参数,分配上下文。
- rtp_write_packet():入口函数,接收AVPacket,根据编码类型分发到不同的打包函数。
- rtp_write_header():写头部时触发,发送SRTP/SDP信息(如果配置了)。
- rtp_write_trailer():结束时发送BYE包。
我个人习惯把RTP muxer的架构想象成三层:
- 输入层:接收AVPacket,拿到原始编码数据。
- 打包层:根据编码类型(H.264、AAC、Opus等)调用对应的打包函数,生成RTP负载。
- 发送层:组装RTP头,写入时间戳、序列号,通过UDP socket发出去。
下面这张图能帮你快速理解整体流程:
4.2 RTP packetization流程
打包流程,我习惯叫它“分包逻辑”。不同的编码格式,分包规则完全不同。举个例子:
- H.264/H.265:支持STAP-A(聚合包)、FU-A(分片单元)。单个NALU小于MTU时用STAP-A,大于MTU时用FU-A。
- AAC:每个AU(Access Unit)就是一个RTP包,前面加上AU-Header-Length和AU-Header。
- Opus:每个Opus包直接作为RTP负载,最多可以塞多个帧(取决于配置的packetization-mode)。
- 原始流(如PCM):按时间戳间隔切包,每个包固定时长。
我的经验: 我在做H.264 RTP推流时,遇到过NALU刚好等于MTU边界的情况。FU-A分包时,最后一个分片如果太小(比如只有几个字节),某些解码器会丢帧。后来我强制要求分包时,最后一个分片至少占MTU的20%,问题就解决了。
具体的打包流程,我整理成了一张表:
| 编码类型 | 打包模式 | 关键函数 | 注意事项 |
|---|---|---|---|
| H.264 | STAP-A / FU-A | ff_rtp_send_h264_hevc() | NALU类型判断、FU-A起始/结束标记 |
| H.265 | AP / FU | ff_rtp_send_h264_hevc() | 与H.264类似,但NAL头不同 |
| AAC | AU-Header模式 | ff_rtp_send_aac() | 每个AU独立打包,注意AU-Header长度字段 |
| Opus | 帧模式 | ff_rtp_send_opus() | 支持多帧聚合,需配置packetization-mode |
| MPEG-TS | 直接封装 | ff_rtp_send_mpegts() | 每个RTP包包含一个或多个TS包 |
你想想看,如果所有编码都用同一套分包逻辑,那肯定不行。FFmpeg的做法是:在rtp_write_packet()里根据st->codecpar->codec_id做switch-case,分发到对应的打包函数。每个打包函数内部再根据MTU大小决定是否分包。
4.3 时间戳写入逻辑
时间戳是RTP同步的核心。FFmpeg的RTP muxer写入时间戳时,遵循一个关键原则:RTP时间戳 = (PTS × 采样率) / 时间基。
具体来说,流程是这样的:
- 从AVPacket中拿到PTS(以AVStream的time_base为单位)。
- 根据编码类型确定RTP时间戳的时钟频率:
- 视频:通常90000 Hz(H.264、H.265、MPEG-4等)。
- 音频:等于采样率(如AAC 44100 Hz、Opus 48000 Hz)。
- 计算:
rtp_ts = (pts * clock_rate * time_base.num) / time_base.den。 - 写入RTP头部的timestamp字段。
我曾经踩过的坑: 有一次做音视频同步,视频时间戳总是比音频慢半拍。查了半天发现,视频的time_base是1/90000,但PTS是从0开始的,而音频的time_base是1/44100,PTS也是从0开始。两者直接计算出来的RTP时间戳,起始值不同,导致接收端认为音视频不同步。解决方案是:在muxer初始化时,记录第一个包的PTS作为基准,后续所有时间戳都减去这个基准值。
FFmpeg在rtpenc.c中有一个关键函数ff_rtp_get_timestamp(),它的实现大致如下:
static uint32_t ff_rtp_get_timestamp(AVFormatContext *s, AVStream *st, AVPacket *pkt)
{
RTPMuxContext *rtp_ctx = s->priv_data;
int64_t pts = pkt->pts;
// 如果pts是AV_NOPTS_VALUE,用dts代替
if (pts == AV_NOPTS_VALUE)
pts = pkt->dts;
// 计算RTP时间戳
// clock_rate 来自 rtp_ctx->clock_rate,或从编码器信息推导
return av_rescale_q(pts, st->time_base, (AVRational){1, rtp_ctx->clock_rate});
}
这里有个细节:av_rescale_q()是FFmpeg提供的精确时间基转换函数,它会处理整数溢出和舍入问题。我个人习惯在调试时打印一下转换前后的值,确保时间戳没有跳变。
4.4 序列号与SSRC的管理
除了时间戳,RTP头里还有两个关键字段:序列号(sequence number)和SSRC。
- 序列号:每个RTP包递增1,用于检测丢包和重排序。FFmpeg在
rtp_write_packet()中每次发送前自增rtp_ctx->seq。 - SSRC:同步源标识符,用于区分不同的RTP流。FFmpeg默认使用随机数生成,也可以通过
ssrc选项手动指定。
避坑指南:
- 序列号不要从0开始,建议从随机值开始(FFmpeg默认就是随机的),防止接收端误判。
- SSRC在同一个RTP会话中必须唯一。如果你同时推视频和音频,两个流的SSRC必须不同。
- 如果使用TCP传输(如RTP over RTSP),序列号依然递增,不受传输层影响。
4.5 关键配置参数
FFmpeg的RTP muxer支持一些常用选项,我列几个最实用的:
| 参数名 | 默认值 | 说明 |
|---|---|---|
| payload_type | 动态分配 | RTP负载类型,如96、97等 |
| ssrc | 随机生成 | 手动指定SSRC,方便调试 |
| clock_rate | 编码默认 | 强制指定时钟频率,视频通常90000 |
| pkt_size | 1200 | 目标RTP包大小(含头部),影响分包策略 |
| rtpflags | 0 | RTP标志位,如skip_rtcp、h264_mode0等 |
举个例子,如果你想手动控制H.264 RTP打包的MTU大小:
ffmpeg -i input.mp4 -c:v libx264 -f rtp rtp://127.0.0.1:5004?pkt_size=1400
这里pkt_size=1400告诉muxer,每个RTP包的目标大小是1400字节(含RTP头)。如果NALU超过这个值,就会触发FU-A分包。
4.6 小结
FFmpeg的RTP muxer架构清晰,分层合理。打包流程根据编码类型动态选择,时间戳写入遵循标准公式。嗯,这里要注意:时间戳的时钟频率必须与接收端约定一致,否则音视频同步会出问题。我在项目中习惯在SDP中明确声明a=rtpmap:96 H264/90000,这样接收端就知道视频时间戳是90000 Hz的。
序列号和SSRC的管理看似简单,但一旦出现冲突或跳变,排查起来非常头疼。建议你在测试阶段,用Wireshark抓包确认一下RTP头的字段是否正确。