简介:面向音视频流媒体开发者的RTP协议发送H264数据包工程示例,围绕H264 NAL单元的处理与RTP封装展开,覆盖从编码码流解析到VLC播放器接收解码的完整链路,适合需要动手实践流媒体传输的中级工程师。压缩包共21个文件,包括C++主程序与头文件、Visual C++工程配置文件、可执行程序、多个264/h264格式测试视频、w.sdp会话描述文件及README说明等,总体积仅929KB,文件结构清晰,便于快速浏览、编译和实验。已有325人学习/下载。借助该工程,可以掌握H264起始码定位、RTP时间戳与序列号填充、目的IP与端口设置等核心环节,结合VLC加载w.sdp实时接收,能够直观检验RTP封装是否正确,理解NALDecoder模块中编码数据转RTP包的转换逻辑,对开展视频实时传输相关开发提供可直接复用的工程模板。
使用RTP协议发送H264数据包
大概一年前,我做了一个嵌入式视频传输模块,板子上跑着H264编码器,编码出来的数据需要通过网口发送到PC端显示。当时第一反应是上GStreamer或者FFmpeg推流,但板子资源紧张,项目诉求也特别单一——拿到一帧编码好的数据,打好RTP包从网口发出去。花了两天啃完RFC 6184之后,我决定自己写这个打包模块,整个过程踩了不少坑,也对RTP和H264这对组合有了扎实的理解。这篇文章把核心知识点、封包逻辑和实测经验梳理出来,适合那些正在做或者准备做RTP-H264传输的开发者参考。
1. 一个真实需求:为什么我放弃了现成框架,决定自己打包RTP
1.1 这个场景有什么特殊性
很多人看到RTP传输第一反应是“用FFmpeg推流不就行了”,确实,PC端、服务端场景下FFmpeg是非常成熟的方案,几条命令行就能把H264封装成RTP推出去。但在我这个场景里,数据源不是媒体文件,而是编码器芯片实时输出的内存缓冲;目的地也不是RTMP或RTSP服务器,而是若干个局域网内的定制播放器。引入FFmpeg库意味着要处理AVFormatContext、AVIOContext等一系列抽象层,内存占用和代码复杂度都上去了,而真正需要的关键能力其实只有一个——把NALU序列变成符合RFC 6184的RTP包。
1.2 啃RFC 6184的收获与判断标准
RFC 6184这个文档全称是“RTP Payload Format for H.264 Video”,它规定了H264视频在RTP里怎么打包、怎么解包。读完之后我发现协议本身并不复杂,核心就三件事:怎么切NALU、用哪种封包模式、RTP头里那些字段怎么填。当这些细节完全掌握之后,后续排查问题就有了底气——比如播放器黑屏,你能判断是SPS/PPS没发过去,还是FU-A分片重组不对,而不是对着黑屏瞎猜。我的判断标准是:当你的数据源不是标准媒体文件、目标播放器也不是现成通用播放器时,手写一个精简的打包模块往往比引入重框架更划算。
2. H264码流剖析:打包之前必须认识的NALU
2.1 NALU是H264码流的基本单元
H264编码器输出的是一串NAL Unit(NALU),每个NALU由起始码加NAL负载组成。起始码有两种:4字节的0x00000001和3字节的0x000001,编码器通常在码流开头用4字节起始码,后续用3字节。NAL负载的第一个字节是NAL Header,它包含三个字段:
- F(1bit):forbidden_zero_bit,必须为0
- NRI(2bit):nal_ref_idc,表示这个NALU的重要性,0表示不用于参考,非0表示可能被参考
- TYPE(5bit):nal_unit_type,表示NALU的类型
常见的NALU类型见下表:
| 类型值 | 含义 | 是否关键 |
|---|---|---|
| 1 | 非IDR图像的Slice | 编码数据 |
| 5 | IDR图像的Slice | 关键帧,解码器从这里开始解码 |
| 6 | SEI(补充增强信息) | 可丢弃 |
| 7 | SPS(序列参数集) | 必须 |
| 8 | PPS(图像参数集) | 必须 |
| 9 | AUD(访问单元分隔符) | 可选 |
2.2 SPS/PPS为什么重要
SPS和PPS存放着分辨率、帧率、编码档次、参考帧数量等解码器初始化必须的参数。接收端如果没收到SPS/PPS,后面的所有slice数据都解不出来,表现出来就是播放器一直黑屏或者花屏。这也是RTP打包时必须格外关注SPS/PPS传输时机的原因。在裸流文件里,SPS/PPS通常出现在IDR帧前面,但有些编码器只在码流最开始输出一次。如果只发一次,新加入的接收端就会因为错过参数集而看不了画面。
2.3 提取NALU时容易忽略的起始码细节
从H264裸流中分离NALU,最稳妥的方式是查找0x000001作为分隔符。有个细节我踩过坑:如果直接按3字节起始码去匹配,当NAL负载内部恰好出现0x000001序列时会发生误切。虽然这种情况在H264码流里很少发生,但为了稳妥,解析时最好优先匹配4字节起始码。还有一个容易忽略的点是,NALU负载的末尾并没有填充对齐,起始码之后紧跟的就是下一个NAL Header,不能用固定长度去读取,只能按起始码来切分。
3. 三种RTP封包模式:单包、聚合与分片
3.1 单一NALU:最简单的场景,但也有条件
把一个完整的NALU(不含起始码)直接作为RTP载荷发送,就是单一NALU模式。此时RTP头的payload type字段必须设置为96到127之间的动态值,通常我固定用96。这个模式适合长度较小的NALU,比如SPS、PPS、SEI,以及大多数非IDR帧的slice。但注意,NALU一旦超过网络的MTU(最大传输单元),就不能用这种模式了,否则IP层会做分片,而IP分片在网络传输中是被路由器丢弃的高发区。
3.2 STAP-A聚合:让SPS/PPS集体出发
STAP-A(Single-Time Aggregation Packet)把多个NALU聚合到同一个RTP包中,载荷结构是:STAP-A的NAL Header(type=24)+ 每个NALU的2字节长度前缀 + NALU内容。典型用法是把SPS、PPS、AUD这三个小NALU打包成一个RTP包发送,接收端一次性就能拿到完整的解码参数,效率高且逻辑清晰。
这里要特别注意STAP-A包的NAL Header中NRI字段的取值,应该取聚合的所有NALU中NRI最大的那个。因为接收端要根据NRI判断丢包的影响程度,取最大值最保守也最合理。
3.3 FU-A分片:对付大IDR帧的主力
IDR帧的Slice数据量通常非常大,动辄几十KB,远超MTU,这时就要用FU-A(Fragmentation Unit)分片模式。FU-A的基本思路是把一个NALU分割成多个RTP包:第一个分片的RTP载荷由2字节的FU指示头加FU头加第一个分片数据组成;中间分片同样携带2字节头;最后一个分片要置E位表示结束。
FU指示头和FU头的构造规则:
- FU indicator:F位为0,NRI取原NALU的NRI,Type固定为28
- FU header:S位(1表示分片开始)、E位(1表示分片结束)、R位(必须为0)、Type取原NALU的类型
举个例子,一个IDR帧的NALU Header是0x65(NRI=11,Type=5),拆成FU-A后,每一片的第一个字节是(NRI<<5)|28 = 0x6C,第一个分片的第二个字节是0x80|5 = 0x85,最后一个分片的第二个字节是0x40|5 = 0x45。接收端看到第一个字节等于0x6C就知道这是FU-A分片,再根据S位和E位判断分片的起止位置。
3.4 封包决策逻辑与MTU计算
实际处理一个NALU时的决策逻辑非常简单:
- 读取NALU,获取NAL Header和负载长度
- 如果负载长度小于等于MTU阈值,且不是SPS/PPS/AUD这种可以和其它小包聚合的类型,用单一NALU模式发送
- 如果负载长度超过MTU阈值,用FU-A分片发送
- 如果遇到SPS/PPS,可以和紧跟着的AUD先聚合再一次性发送
MTU怎么算?标准以太网MTU是1500字节,减去20字节IP头、8字节UDP头、12字节RTP头,留给RTP载荷的只有1460字节。为了保险,通常把阈值设在1400字节左右。如果阈值设置得比实际可用值还大,路由器就会把RTP包再次拆成IP分片传输,一旦分片丢失,整个RTP包就废了,画面直接出现马赛克或花屏。
4. 手写发送器:代码拆解与关键设计
4.1 环境准备与模块划分
我用Python写了一个最小可运行的发送器,语言只是手段,换成C、Go或Java逻辑完全一致。Python的好处是struct库处理字节序很直观,适合把协议逻辑讲清楚。整个程序划分为三个模块:NALU读取模块、RTP打包模块、UDP发送模块。
import socket import struct # 配置参数 DEST_IP = "127.0.0.1" DEST_PORT = 5000 MTU_THRESHOLD = 1400 SSRC = 0x12345678 PAYLOAD_TYPE = 96 FPS = 25 CLOCK_RATE = 90000 TIMESTAMP_OFFSET = 04.2 提取与识别NALU
从H264裸流中逐个提取NALU是第一步,这里用起始码搜索实现。为了方便调试,我先从文件里读取H264数据,然后切分成NALU列表,实际项目中换成编码器输出的内存缓冲即可。
def extract_nalus_from_file(filepath): with open(filepath, "rb") as f: data = f.read() nalus = [] i = 0 while i < len(data) - 3: # 检测起始码 if data[i] == 0 and data[i+1] == 0 and data[i+2] == 1: # 区分3字节还是4字节起始码 nalu_start = i + 3 if i + 4 < len(data) and data[i+1] == 0 and data[i+2] == 0 and data[i+3] == 1: nalu_start = i + 4 # 从NALU起点找下一个起始码 j = nalu_start next_start = -1 while j < len(data) - 3: if data[j] == 0 and data[j+1] == 0 and data[j+2] == 1: next_start = j break j += 1 if next_start == -1: nalu = data[nalu_start:] nalus.append(nalu) break else: nalu = data[nalu_start:next_start] nalus.append(nalu) i = next_start else: i += 1 return nalus这个实现是教学级别的,有几个边界情况没有做最严密的处理,比如当起始码出现在文件末尾时。真实项目中建议用状态机解析,把“查找起始码”和“查找下一个起始码”两个状态解耦,能处理各种畸形码流。
4.3 RTP头构造与单一封包
RTP头的12字节结构是固定的:版本号V=2、填充位P、扩展位X、CSRC计数CC、标记位M、载荷类型PT、序列号、时间戳、SSRC。我用struct以网络字节序打包。
def build_rtp_header(seq, timestamp, ssrc, marker, pt=PAYLOAD_TYPE): # 版本号2,无填充,无扩展,无CSRC -> 第一个字节 0x80 first_byte = 0x80 # Marker位 + 载荷类型 second_byte = (marker << 7) | pt return struct.pack("!BBHII", first_byte, second_byte, seq & 0xFFFF, timestamp & 0xFFFFFFFF, ssrc)单一NALU发送就很容易了,直接把NALU拼在RTP头后面。这里有个容易被忽略的细节:NALU的起始码要去掉,RTP包里不允许出现起始码,接收端是根据RTP头的payload type和封包模式来判断NALU边界的。
4.4 FU-A分片发送
分片逻辑是第二个复杂度较高的地方,重点在于S位和E位的设置。第一个分片S位为1,最后一个分片E位为1,中间分片S位和E位都为0。每片都携带完整的2字节FU头,所以每片实际可用的数据长度是MTU_THRESHOLD - 2。
def send_fua(nalu, seq, timestamp, sock, marker_last=True): nalu_type = nalu[0] & 0x1F nri = (nalu[0] >> 5) & 0x03 payload_data = nalu[1:] # FU indicator: NRI + 28 fu_indicator = (nri << 5) | 28 max_fua_size = MTU_THRESHOLD - 2 # 减去FU indicator和FU header offset = 0 total_len = len(payload_data) while offset < total_len: is_first = (offset == 0) current_size = min(max_fua_size, total_len - offset) is_last = (offset + current_size == total_len) # FU header: S位 + E位 + 原NALU类型 fu_header = 0 if is_first: fu_header |= 0x80 if is_last: fu_header |= 0x40 fu_header |= nalu_type # 一帧的最后一个RTP包才置M位 marker = 1 if (is_last and marker_last) else 0 rtp_header = build_rtp_header(seq, timestamp, SSRC, marker) packet = rtp_header + bytes([fu_indicator, fu_header]) + payload_data[offset:offset + current_size] sock.sendto(packet, (DEST_IP, DEST_PORT)) offset += current_size seq = (seq + 1) & 0xFFFF return seq注意这里的时间戳和序列号管理逻辑:同一个NALU的所有分片共享同一个时间戳,序列号连续递增,但只有最后一个分片的M位设置为1。M位的作用是告诉接收端“这一帧的数据结束了”,接收端可以据此判断一帧是否收齐,够不够交给解码器。
4.5 时间戳与序列号的管理
H264在RTP中的时间戳时钟频率固定为90000Hz,不是视频帧率,也不是1/1000秒的毫秒。计算方式:每帧的时间戳增量 = 90000 / 帧率。25帧每秒时增量为3600,30帧每秒时增量为3000。如果收到一个24帧每秒的视频,增量就是3750,是整数,因为90000能被24整除。
序列号永远是每个发送的RTP包递增1,不管是不是分片。时间戳则是每帧递增固定值,同一帧的所有RTP包共享同一个时间戳。接收端就是靠这两个字段的组合来判断网络丢包和帧边界的。
需要注意初次启动时不要把时间戳设为0。虽然协议允许,但有些解码器对全0的时间戳有特殊处理。用一个随机初始值或者(当前毫秒 * 90) & 0xFFFFFFFF都行,重点是确保递增节奏正确。32位时间戳大约26小时回绕一次,一般场景不用处理,但长时间不间断传输时要有回绕处理的意识。
5. 接收端验证:两条路线与真实踩坑记录
5.1 路线一:用SDP描述文件配合ffplay快速验证
写完发送器之后,最迫切的问题是“我发的包到底对不对、能不能解出来”。最快的验证方式是用ffplay配合SDP描述文件。
SDP的内容如下,保存为test.sdp:
v=0 o=- 0 0 IN IP4 127.0.0.1 s=H264 RTP Stream c=IN IP4 127.0.0.1 t=0 0 m=video 5000 RTP/AVP 96 a=rtpmap:96 H264/90000然后在终端执行:
ffplay -protocol_whitelist file,udp,rtp -i test.sdp启动发送器之后,如果能看到正常的视频画面,说明RTP头、时间戳、M位、封包格式全都正确。如果黑屏,优先用Wireshark抓包确认RTP包是否到达、payload type是否为96、时间戳是否按预期递增。这一步能快速区分问题出在发送端还是接收端。
5.2 路线二:自己写接收端重组FU-A分片
ffplay验证完之后,我建议至少写一次接收端,因为只有从接收端视角处理过FU-A分片重组,才算真正理解协议。接收端的核心逻辑:
- 从UDP socket读取RTP包
- 解析RTP头中的payload type、序列号、时间戳、M位
- 如果载荷第一个字节的低5位等于28,说明是FU-A分片,根据S位和E位把分散的NALU片段拼接成一个完整NALU
- 如果载荷第一个字节的低5位是24,说明是STAP-A聚合包,按2字节长度前缀依次拆分出各个NALU
- 如果载荷类型是1到23之间的值,说明是单一NALU包,去掉RTP头就是完整NALU
拼接完成后,把这些NALU写回H264裸流文件,再用ffprobe检查或者ffplay播放,就能验证分片重组是否准确。我测试时在发送端故意跳过一个分片模拟丢包,接收端重组出来的NALU解码后画面出现花屏,但程序没有崩溃,说明解码器的容错机制在正常工作。
5.3 实测中最容易翻车的三个细节
第一个坑是时间戳用帧号直接代替。我最初把时间戳设置为帧计数,没有乘以90000/FPS,结果ffplay播放时画面飞快。因为接收端拿到的时间戳增量只有1,解码器以为每帧间隔是1/90000秒,自然播放得像幻灯片快进。记住,时间戳增量 = 90000 / FPS,这个公式不能省。
第二个坑是每帧都发SPS/PPS。这样接收端确实随时能启动,但浪费带宽,特别是码流分辨率低时,SPS/PPS占的比例不容忽视。正确的做法是只在关键帧之前发送一次SPS/PPS,或者每隔几秒周期性补发一次,兼顾效率和容错。
第三个坑是MTU设置不当。局域网内把MTU调大到1600可能暂时没出问题,但跨路由器后IP分片被丢弃的概率明显上升,视频直接花屏。从架构上说,RTP层分片的意义就是为了避免IP层分片,阈值设在1400甚至更保守是稳妥的选择。
最后再分享一个调试小技巧:Wireshark里对RTP流进行过滤,输入rtp.payload_type == 96,可以看到每个RTP包的序列号和时间戳。如果你看到M位为1的包后面紧跟的包序列号不连续,说明发送端丢包了;如果序列号连续但画面黑屏,问题多半在SPS/PPS或者封包格式上。每次改动协议逻辑后,用抓包结果和ffplay的画面双重确认,基本能把RTP-H264传输过程中九成的问题定位出来。这套流程我现在做任何视频传输项目都会复用一遍,省时省心。
本文还有配套的精品资源,点击获取