前两天一个朋友发来一份抓包文件,说家里IPTV机顶盒播放偶尔花屏还带卡顿,让我帮忙看看RTP包到底哪里出了问题。文件打开一看,Wireshark对RTP流的识别倒是很顺利,SSRC、PT、时间戳都标得清清楚楚,可再往负载层拆,就看不出个所以然了。
这个场景太典型了。大多数人对RTP包解析的理解停留在“能认出RTP协议”这个层面,但认出来和真正解析明白之间,隔着一条巨大的鸿沟。RTP包解析这件事,难的不是把12字节固定头读出来,而是搞清楚RTP在整个传输链路中的位置、负载格式跟编码帧的对应关系,以及各种边界条件下的异常处理。
这篇文章我想把RTP包解析这件事从里到外捋一遍。不会只停留在协议规范层面,更多是分享我在实际抓包、分析、二次开发过程中踩过的坑和验证过的做法。内容适合刚接触抓包分析和音视频传输的工程师,也适合已经在做流媒体服务但偶尔被RTP细节撂倒的运维同学。
1. 解析RTP之前的链路认知:协议位置决定解析方向
先说一个很多初学者会搞错的概念。RTP全称是Real-time Transport Protocol,但它在协议栈里的位置并不是传输层。真正干传输层活的是它底下的UDP或TCP,RTP本人寄生在这些传输层协议之上,属于应用层协议。
这个认知直接影响你的解析思路。RTP不负责把数据从A点送到B点,它只解决一个核心问题:给实时媒体数据打上时间和顺序的标记,让接收端能够按正确的时序把这些数据重组出来。所以解析RTP包,重点看的是时间戳、序列号、SSRC这些控制信息,而不是指望从RTP里找到类似TCP那样的可靠传输机制。
在实际网络交互中,RTP几乎总是跟几个兄弟协议一起出现:
- RTCP:RTP的控制通道,周期性发送,携带丢包率、抖动、往返时延等统计信息。
- RTSP/SIP:常用于建立和释放媒体会话,协商编码格式和传输端口。
- SDP:描述媒体参数,比如视频用H.264还是H.265、音频用Opus还是G.711、RTP端口是多少、动态负载类型对应什么编码。
抓包里最常见的链路是这样的:先是RTSP或SIP信令交互,紧接着SDP把媒体参数敲定,随后RTP数据流从协商好的端口涌出来,RTCP时不时冒个泡。IPTV场景尤其明显,机顶盒先跟流媒体服务器做RTSP协商,服务器返回SDP描述,之后视频流才会从特定端口源源不断到达。
有朋友总说抓了一堆IP包,但看不出哪些是RTP。问题往往就出在没结合信令协商阶段一起看。如果只盯着RTP本身,不知道SDP里约定的动态负载类型,Wireshark就算帮忙标了RTP,后面负载解析也走不下去。
顺带说一个搜索时容易撞车的事。"RTP"这个词在不同领域意义完全不同。搜RTP偶尔会出现RPG Maker VX Ace的Runtime Package相关结果,那是游戏引擎的运行库,跟网络协议没有半点关系。本文讨论的RTP是网络实时传输协议,所有解析内容都基于RFC 3550以及H.264、H.265的RTP负载格式规范。
搞清楚协议位置之后,要结合自己的目的定解析策略。解析RTP包通常就三种需求:
- 定位网络问题:卡顿、花屏、音画不同步。重点看序列号连续性、时间戳抖动、RTCP反馈的丢包率。
- 协议逆向分析:想知道某个设备发出的流是什么编码、什么参数。重点看SDP协商和负载头部特征。
- 媒体处理中转:抓流下来做录制、转码、转发。重点是把负载里的音频帧、视频帧完整解析还原。
目的不同,解析侧重点天差地别。单纯查网络问题,看序列号和重传就够了;要做转码,必须把H.264/H.265的NALU边界、访问单元边界、参数集结构全部搞清楚。这就是为什么后面章节会重点讲视频负载和RTP的映射关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RTP固定头逐位拆解:12字节里的信息量
RTP包头设计得非常紧凑,固定头只有12字节,后面可能跟着CSRC列表和扩展头。这12字节包含的每一位都有明确用途,解析的时候最好从位级别一层层看,而不是把一个16位或32位字段当成简单的整数。
2.1 前两个字节:版本、填充、扩展、CSRC计数、标记位、负载类型
先看第一个字节:
- bit 0-1:版本号。固定为2,二进制是10。抓到版本号不对的包,基本就能判断这不是RTP。
- bit 2:填充标志P。为1时负载末尾有填充字节,填充长度由负载最后一个字节指明。加密类负载偶尔会用到。
- bit 3:扩展标志X。为1时CSRC列表后面会跟一个扩展头。
- bit 4-7:CSRC计数CC。表示CSRC列表有几项,每一项占4字节。
第二个字节:
- bit 0:标记位M。具体含义由负载类型决定。视频流里通常用来标记一个访问单元(也就是一帧画面)的最后一个RTP包,音频流里有时用来标记说话人开始说话。
- bit 1-7:负载类型PT。7位,范围0到127。0到95是静态分配的,比如PT 8是PCMA、PT 0是PCMU、PT 9是G.722;96到127是动态分配的,具体的编码格式要看SDP协商结果。
这里有个特别容易翻车的点:H.264、H.265、VP8、VP9、Opus这些编解码器几乎都用动态PT。同样一个PT值,在一个会话里是H.264,在另一个会话里可能是Opus。解析器绝对不能写死PT到编码的映射表,必须回到SDP里的a=rtpmap字段找答案。
2.2 序列号、时间戳、SSRC、CSRC列表
紧接着的4个字段是解析重点:
- 序列号:16位,发送端每发一个包加1,初始值随机。接收端靠它检测丢包和乱序。注意最大值65535,发完就回绕到0。
- 时间戳:32位,反映媒体采样时刻,单位是采样频率的倒数。音频通常是8kHz或48kHz,视频固定使用90kHz。同一帧画面的所有分片,时间戳必须一致。
- SSRC:32位,同步源标识。同一个RTP会话里,不同媒体源的SSRC不同。视频流和它的伴音音频流通常各自有独立的SSRC。
- CSRC列表:只在混音场景出现,比如多方会议中多个混音信号被合并到一个RTP流。列表长度由第一个字节的CC字段决定。点播类和一对一的流,CSRC几乎总是0。
还有一个字段在大多数规范说明里讲得不够清楚,就是RTP扩展头。当第一个字节的X标志为1时,CSRC列表之后跟一个扩展头。扩展头结构是16位的profile字段加16位的长度字段,注意这个长度字段的单位是4字节,不是1字节。长度字段为1,实际扩展数据就是4字节。这个单位问题坑过不少人,算错的话负载偏移就全错了。
WebRTC场景下,几乎每个RTP包都带扩展头,里面装了abs-send-time、transport-wide sequence number这些信息。做WebRTC抓包分析时,扩展头解析逻辑必须写对,否则根本定位不到负载起始位置。
2.3 一个具体十六进制包的解析演示
光讲字段太抽象,拿一个实际构造的RTP包演示一下。以下前12字节:
code复制80 60 05 A3 00 2A 3F 52 0A 1B 3C 4D
逐字节拆解:
0x80:二进制10000000,版本2、P=0、X=0、CC=0。0x60:二进制01100000,M=0、PT=96。0x05 0xA3:序列号1443。0x00 0x2A 0x3F 0x52:时间戳2770770。0x0A 0x1B 0x3C 0x4D:SSRC 169093197。
假设SDP里写着a=rtpmap:96 H264/90000,那这就是一个H.264视频流,时间戳单位是1/90000秒。2770770除以90000约等于30.78秒,说明这条流已经跑了大约31秒。
手动数十六进制太累,写个小脚本才是正路。下面这段Python代码可以解任意RTP包的前12字节并返回负载起始偏移:
python复制import struct
def parse_rtp_header(data: bytes):
if len(data) < 12:
raise ValueError("数据不足12字节,不能构成完整RTP头")
first = data[0]
second = data[1]
version = (first >> 6) & 0x03
padding = (first >> 5) & 0x01
extension = (first >> 4) & 0x01
csrc_count = first & 0x0F
marker = (second >> 7) & 0x01
payload_type = second & 0x7F
seq, timestamp, ssrc = struct.unpack("!HII", data[2:12])
header = {
"version": version,
"padding": padding,
"extension": extension,
"csrc_count": csrc_count,
"marker": marker,
"payload_type": payload_type,
"sequence": seq,
"timestamp": timestamp,
"ssrc": ssrc,
}
offset = 12 + csrc_count * 4
if extension:
if len(data) < offset + 4:
raise ValueError("RTP扩展头数据不足")
ext_profile, ext_len = struct.unpack("!HH", data[offset:offset + 4])
header["ext_profile"] = ext_profile
header["ext_length"] = ext_len
offset += 4 + ext_len * 4
return header, offset
struct.unpack("!HII", ...)里的!表示网络字节序,也就是大端序。网络协议里所有多字节整数基本都是大端序,这个习惯从IP头、TCP头一直延续到RTP,解析时不用怀疑。
这里的核心价值不只是解出几个字段,而是拿到offset之后,可以精确定位负载的起始位置。后续的音频帧解析、视频NALU解析,全部需要依赖这个偏移。
3. 实操流程:用tshark和Wireshark把RTP流完整解析出来
理论讲完,说点动手的。抓RTP包最常用的还是Wireshark和它的命令行版tshark。刚开始接触RTP解析时不必自己从零写协议解析器,先用现成工具把流程走通,再按需做二次开发。
3.1 抓包阶段:端口、混杂模式和过滤条件
抓RTP包前先想清楚抓包位置。普通的单播点播流在自己机器上抓就行,双向通话或者跨网段的流,最好在路由器的镜像口或者宿主机虚拟网卡上抓。抓包位置选错了,后面分析做得再精细也白搭。
用tshark抓包时建议同时开启混杂模式,否则交换网络里看不到其他主机的流量:
bash复制tshark -i eth0 -f "udp port 5004 or udp port 5005" -w rtp_capture.pcap
先按端口抓下来存成文件,不要边抓边实时分析,容易漏包。如果一开始不知道端口,就先抓宽泛的UDP流量,之后再用RTP特征过滤。
怎么判断一个UDP包是不是RTP?几个信号叠加:端口是否符合惯例(很多实现用偶数端口发RTP,奇数端口发RTCP);负载头两个字节是不是0x80开头;相同的SSRC是否反复出现,并且时间戳保持有规律递增。Wireshark自带启发式协议识别,大部分情况下能自动把符合特征的流标成RTP,但千万别把这个识别结果当成唯一依据。
3.2 用过滤器快速定位流
抓到pcap之后,在Wireshark里用rtp过滤器就能看到所有识别为RTP的包。按具体流来分,用rtp.ssrc条件:
code复制rtp && rtp.ssrc == 0x0A1B3C4D
想看整条流的丢包、抖动、乱序情况,Wireshark的Telephony -> RTP -> RTP Streams面板最方便。它会列出每条流的SSRC、目的地址、丢包数、最大抖动、平均抖动。很多视频卡顿问题在这一步就能定位个大概。
命令行版也可以输出流统计:
bash复制tshark -r rtp_capture.pcap -q -z rtp,streams
我习惯先用这个命令把所有RTP流扫一遍,找到丢包率异常的那条流,再用-Y过滤条件单独提取它的负载做深挖。
3.3 导出负载:看RTP实际上传了什么
只分析包头信息往往不够,经常需要把负载抠出来交给解码器还原。Wireshark有内置功能,Telephony -> RTP -> Stream Analysis,然后点"Save Payload"或者"Play Streams"可以直接播放音频流。但要注意,这个功能对编码类型有要求,G.711这种裸PCM可以直接播,H.264如果是分片单元,Wireshark虽然会重新组包,但要真正解码成视频画面,还是导出后交给ffmpeg更靠谱。
用tshark导出RTP负载的常见做法是把每包的负载变成十六进制文本:
bash复制tshark -r rtp_capture.pcap -Y "rtp.ssrc == 0x0A1B3C4D" -T fields -e rtp.payload > payload_hex.txt
拿到十六进制文本之后,再写脚本组织数据,做NALU组帧或者音频帧拼接。这里特别提醒一句:**UDP包大小受MTU限制,通常1500字节左右,一个大的关键帧根本塞不进单个RTP包里面。**直接导出负载不等于拿到完整视频帧,必须先把分片NALU重组,才能交给解码器。这一步后面细说。
3.4 别把Wireshark的自动识别当成权威
Wireshark识别RTP有个前提:它需要先看到会话建立过程中的信令,比如SIP或者RTSP,才能确定动态PT对应的实际编码。如果pcap文件里没有信令包,Wireshark可能把所有像RTP的UDP流量都标成RTP,负载部分自然就是一堆无法解读的乱码。
遇到这种状况,不要怀疑是自己抓错了包,要回到SDP协商结果,找到实际的PT值,再用自定义过滤条件验证。真正的解析工作,恰恰是从Wireshark识别不了的时候才刚开始。
4. RTP与视频帧的层级关系:访问单元、NALU和封装模式
回到之前那个被反复问到的问题:“如何理解RTP access unit和一个NALU?”这个问题特别有代表性,因为它直接戳中了视频流RTP解析的灵魂。很多人把RTP包和视频帧画等号,认为一个RTP包就是一帧,一帧就是一个NALU,这显然不对。
4.1 三个层级必须分清:编码层、NALU层、RTP包层
视频从摄像头到屏幕,中间经历好几层封装:
- 编码层:视频编码器输出VPS(仅H.265有)、SPS、PPS、SEI、IDR帧、非IDR帧这些语法元素。
- NALU层:这些语法元素被打包成一个个NAL Unit,每个单元有头部标识类型和重要性等级。
- RTP层:NALU被装进RTP包的负载部分,RTP给它加上序列号和时间戳。
**访问单元(Access Unit,简称AU)就是一次采样时间内所有编码数据的集合,通俗理解就是“一帧画面”的所有数据。**在H.264里,一个AU通常由一个IDR切片或非IDR切片组成,但在某些编码配置下也可能包含多个切片。在H.265里,AU的边界规定得更严格,VPS、SPS、PPS参数集会随AU周期性出现。
所以一句话回答那个问题:**一个RTP包的负载里装的是单个NALU或者NALU组合,而一个AU(一帧)可能由一个RTP包承载,也可能被拆成几十个RTP包承载。**AU和NALU是两个完全不同维度的概念。
4.2 单NALU、聚合包和分片:三种典型封装模式
RFC 6184定义了H.264在RTP中的标准封装方式,H.265对应的RFC 7798思路类似。实际抓包最常见的三种模式:
| 封装模式 | 特点 | 常见场景 |
|---|---|---|
| 单NALU模式 | 一个RTP包装载一个NALU | 体积小的SPS、PPS、SEI,以及小尺寸切片 |
| 聚合包STAP-A/STAP-B | 一个RTP包装载多个NALU | 多个参数集合并发送,或小切片合并 |
| 分片单元FU-A/FU-B | 一个NALU拆成多个RTP包 | 大尺寸关键帧切片 |
判断一个RTP包属于哪种封装,看负载第一个字节的type字段就够了。H.264的NAL header结构是:
- bit 0-1:F,禁止位,一般为0
- bit 2-3:NRI,重要性标识
- bit 4-7:Type,NALU类型
Type为1到23是普通NALU,Type 24是STAP-A,Type 25是STAP-B,Type 28是FU-A,Type 27是FU-B。分片单元的第二个字节里还有S和E标志位,S表示分片开始,E表示分片结束。
H.265和H.264思路一致但细节不同:H.265的NAL header长度是2字节,Type字段占6位,同样定义了AP聚合模式和FU分片模式。所以在解析时第一件事永远是确认负载类型是H.264还是H.265,再决定用哪套解析逻辑。
4.3 流中的VPS、SPS、PPS、SEI、I帧、P帧
解析视频负载时,重点看这几类NALU:
| NALU类型(H.264) | 作用 | 在RTP中的常见封装 |
|---|---|---|
| Type 7 SPS | 序列参数集,定义分辨率、帧率、码率等序列级参数 | 单NALU或STAP |
| Type 8 PPS | 图像参数集,定义编码细节 | 单NALU或STAP |
| Type 9 AUD | 访问单元分隔符,可选 | 单NALU |
| Type 6 SEI | 补充增强信息,如字幕、HDR元数据 | 单NALU |
| Type 5 IDR | 关键帧切片,解码的基础 | FU分片或单NALU |
| Type 1 非IDR切片 | 普通P帧/B帧 | FU分片或单NALU |
H.265的VPS(Type 32)是H.264没有的,描述整个视频序列的整体层级信息。H.265的SPS是Type 33,PPS是Type 34,SEI是Type 39,IDR_W_RADL是Type 19,普通非IDR切片是Type 1之类的值。
实际抓包场景里能看到一个稳定规律:每个关键帧开头,编码器会重新发送VPS、SPS、PPS,这些参数集可能聚合在同一个RTP包里用STAP模式发出。随后的关键帧切片通常体积很大,会被拆成多个FU分片,分片包的RTP时间戳完全相同,序列号连续递增,直到最后一个分片把M位置1表示帧结束。之后的P帧也是类似逻辑,只是切片体积小很多,经常单包直接发完。
4.4 帧边界的判定:时间戳、序列号、M位三合一
既然AU可能被拆成多个RTP包,接收端怎么知道一帧在哪里结束?答案不是单靠一个字段,而是靠时间戳、序列号、M位三个信号共同判断。
同一帧的所有分片,RTP时间戳必须完全一致。看时间戳变化,就能把不同帧大致分开。但时间戳相同不一定就是同一帧,极端情况下可能有两帧落在同一个时间戳上(虽然十分少见),这时候要靠序列号和M位辅助判断。按照RFC 6184的约定,分片单元最后一包的M位为1,表示当前AU的RTP承载到此结束。
实际分析中我的判断逻辑是:
- 当前包时间戳与上一包不同,说明新AU开始。
- 当前包时间戳相同,说明还是同一个AU。
- 当前包M位为1,说明这个AU的RTP包流到此结束。
- 但M位并非绝对可信,不同编码器实现可能不遵守约定,最终还得回到码流内部看AUD分隔符或者slice头部的
first_mb_in_slice字段确认。
4.5 从RTP负载恢复H.264码流的最小实现
讲完理论,给一个可用的最小代码。下面这个类负责接收H.264 RTP负载,自动处理FU-A分片重组,输出完整NALU:
python复制class H264RtpAssembler:
def __init__(self):
self.buffer = bytearray()
self.nal_type = None
def feed(self, payload: bytes):
nal_header = payload[0]
nal_type = nal_header & 0x1F
nalus = []
if 1 <= nal_type <= 23:
# 单NALU模式,直接返回
nalus.append(payload)
elif nal_type == 28:
# FU-A分片单元
fu_header = payload[1]
start = fu_header & 0x80
end = fu_header & 0x40
original_type = fu_header & 0x1F
# 重建原始NALU头
new_header = (nal_header & 0xE0) | original_type
if start:
self.buffer = bytearray()
self.buffer += bytes([new_header])
self.buffer += payload[2:]
else:
self.buffer += payload[2:]
if end:
nalus.append(bytes(self.buffer))
self.buffer = bytearray()
self.nal_type = None
return nalus
这段代码没有处理STAP聚合包和RTCP,但已经覆盖了单NALU和FU分片两种最常见的情况。在这个基础上扩展STAP-A的解析也很简单,无非是解析聚合包的NAL header和长度字段,再逐个拆出NALU。
5. 解析现场容易翻车的四个细节:回绕、动态PT和标记位
写RTP解析脚本最烦的往往不是大逻辑,而是各种边界条件。下面这几个细节是我实际踩过坑之后才补上的,每一个不做处理,解析结果都会在特定条件下崩掉。
5.1 16位序列号回绕:长流必现的数学问题
序列号16位,最大值65535。视频流每秒几十甚至上百个包,几分钟就能跑完一整圈。如果直接用差值判断连续性:
python复制diff = current_seq - last_seq
在回绕边界上会得到一个离谱的负数或者大数。正确做法是对16位序号做模运算:
python复制def seq_distance(newer: int, older: int) -> int:
return (newer - older + 65536) % 65536
丢包统计、乱序检测、RTT计算全都依赖这个基础函数。长时视频流分析里处理不好回绕,累计丢包率会变成负数,抖动会变成几千毫秒。这些看似不起眼的计算,恰恰是最容易出错的地方。
5.2 32位时间戳回绕:跑够时间就出事
时间戳32位,视频采样频率一般是90kHz,所以最大支持时间约4294967296/90000秒,大概13.25小时。长时间录制或者7x24小时监控的IPTV流,跑一天下来时间戳必然回绕。回绕瞬间时间戳从4294967295跳回0,如果解析程序不做模运算处理,音视频同步计算就会错得彻底。
音频也有同样问题,48kHz采样下回绕时间约24.85小时。处理方式跟序列号一致,按32位模运算计算差值。另外要注意,时间戳相减的结果是采样周期数,要换算成秒必须除以对应采样率,视频除以90000,音频根据实际编码格式可能是8000或48000,不能套同一个值。
5.3 动态负载类型:写死映射表是大忌
PT字段0到95有官方静态定义,96到127完全由SDP协商决定。同样PT 97,一个会话里是H.264,另一个会话里可能是G.722。如果写死一张全局映射表,换条流解析必挂。
正确的流程是:在解析RTP包之前先解析SDP,拿到a=rtpmap和a=fmtp字段,动态构建PT到编码的映射字典,之后再解析RTP包时靠这个字典转换。我的脚本里这个字典是会话级对象,每个会话单独维护,不会跨会话复用。这是保证解析器通用性的关键设计。
5.4 M位不是铁律:负载类型定义才是根本
RFC 3550对M位的定义是“与负载类型相关的标记”,具体语义由RTP profile决定。视频负载习惯上用它标帧边界,但音频负载里它可能表示静音抑制、音量突发。更麻烦的是,不少编码器实现根本不设置M位,全程为0。如果解析逻辑把M位当成帧边界的唯一依据,遇到这种流就会直接错乱。
我的经验是:M位只能做辅助信号,帧边界判断必须以负载内部结构为准。H.264就看RTP负载的NAL header和slice头,H.265同样逐个NALU解析。用Wireshark的RTP分析面板看视频流时,你会发现它显示的帧边界标记偶尔也会不准,原因就是它某种程度上信任了M位。
5.5 乱序和重传:RTP不是TCP
RTP基于UDP,没有TCP的重传机制,但实际网络里仍然会出现乱序和重复包。IP层分片重组可能造成包乱序,某些网关设备还可能对UDP做重发。解析时看到序列号跳变,先不要
