RTP协议发送H264数据包:NALU、FU-A分片与实战解析

发布时间:2026/9/8 11:14:19
RTP协议发送H264数据包:NALU、FU-A分片与实战解析 简介面向音视频流媒体开发者的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. 一个真实需求为什么我放弃了现成框架决定自己打包RTP1.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码流剖析打包之前必须认识的NALU2.1 NALU是H264码流的基本单元H264编码器输出的是一串NAL UnitNALU每个NALU由起始码加NAL负载组成。起始码有两种4字节的0x00000001和3字节的0x000001编码器通常在码流开头用4字节起始码后续用3字节。NAL负载的第一个字节是NAL Header它包含三个字段F1bitforbidden_zero_bit必须为0NRI2bitnal_ref_idc表示这个NALU的重要性0表示不用于参考非0表示可能被参考TYPE5bitnal_unit_type表示NALU的类型常见的NALU类型见下表类型值含义是否关键1非IDR图像的Slice编码数据5IDR图像的Slice关键帧解码器从这里开始解码6SEI补充增强信息可丢弃7SPS序列参数集必须8PPS图像参数集必须9AUD访问单元分隔符可选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-ASingle-Time Aggregation Packet把多个NALU聚合到同一个RTP包中载荷结构是STAP-A的NAL Headertype24 每个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-AFragmentation Unit分片模式。FU-A的基本思路是把一个NALU分割成多个RTP包第一个分片的RTP载荷由2字节的FU指示头加FU头加第一个分片数据组成中间分片同样携带2字节头最后一个分片要置E位表示结束。FU指示头和FU头的构造规则FU indicatorF位为0NRI取原NALU的NRIType固定为28FU headerS位1表示分片开始、E位1表示分片结束、R位必须为0、Type取原NALU的类型举个例子一个IDR帧的NALU Header是0x65NRI11Type5拆成FU-A后每一片的第一个字节是(NRI5)|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[i1] 0 and data[i2] 1: # 区分3字节还是4字节起始码 nalu_start i 3 if i 4 len(data) and data[i1] 0 and data[i2] 0 and data[i3] 1: nalu_start i 4 # 从NALU起点找下一个起始码 j nalu_start next_start -1 while j len(data) - 3: if data[j] 0 and data[j1] 0 and data[j2] 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字节结构是固定的版本号V2、填充位P、扩展位X、CSRC计数CC、标记位M、载荷类型PT、序列号、时间戳、SSRC。我用struct以网络字节序打包。def build_rtp_header(seq, timestamp, ssrc, marker, ptPAYLOAD_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_lastTrue): 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帧每秒时增量为360030帧每秒时增量为3000。如果收到一个24帧每秒的视频增量就是3750是整数因为90000能被24整除。序列号永远是每个发送的RTP包递增1不管是不是分片。时间戳则是每帧递增固定值同一帧的所有RTP包共享同一个时间戳。接收端就是靠这两个字段的组合来判断网络丢包和帧边界的。需要注意初次启动时不要把时间戳设为0。虽然协议允许但有些解码器对全0的时间戳有特殊处理。用一个随机初始值或者(当前毫秒 * 90) 0xFFFFFFFF都行重点是确保递增节奏正确。32位时间戳大约26小时回绕一次一般场景不用处理但长时间不间断传输时要有回绕处理的意识。5. 接收端验证两条路线与真实踩坑记录5.1 路线一用SDP描述文件配合ffplay快速验证写完发送器之后最迫切的问题是“我发的包到底对不对、能不能解出来”。最快的验证方式是用ffplay配合SDP描述文件。SDP的内容如下保存为test.sdpv0 o- 0 0 IN IP4 127.0.0.1 sH264 RTP Stream cIN IP4 127.0.0.1 t0 0 mvideo 5000 RTP/AVP 96 artpmap: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传输过程中九成的问题定位出来。这套流程我现在做任何视频传输项目都会复用一遍省时省心。本文还有配套的精品资源点击获取