简介:一套面向网络视频开发者的RTP/H.264抓包转存实用工程,解决从UDP接收RTP流中提取H.264 NAL单元并落盘为文件的问题。工程基于Visual Studio,包含RTP服务器端与公共代码库,涉及RTP解封装、NAL单元重组、Annex B等格式转换等关键逻辑,适合需要自建摄像头视频实时读取、监控推流或协议学习的中高级开发者参考。压缩包共14个文件,以6个h头文件和4个cpp源文件为主,辅以工程配置(vcxproj、user)、筛选器及说明文档,整包仅9KB,代码精简、结构清晰,便于直接打开工程研读或移植。截至当前已有1032人学习使用。读者可从中获得一套可运行的rtpserver核心框架、UDP收发与RTP解析的模块拆分思路,以及将H264数据按NAL边界正确写入文件的处理细节,对理解实时传输与编码存储有直接帮助。
1. rtp转h264文件,先搞明白你要面对的是什么样的流
做视频接入和流处理的同行,应该都碰到过这个需求:设备端用 RTP over UDP 把 H.264 视频流推到某个端口,你知道数据就在这些 UDP 包里,抓包工具里能看到一长串 payload type 为 96 的包,但把它们按顺序拼接成文件,播放器就是不认。原因在于 H.264 码流在发送侧不是按字节连续丢进 UDP 的,它先被切成 NAL 单元,再由 RTP 协议分包或聚合,UDP 载荷里混着 RTP 头、聚合头和分片头。“rtp转h264文件”要做的就是把这层皮剥掉,把拆散的 NAL 单元按 Annex B 格式落盘。本文适合正在做摄像头接入、嵌入式推流、协议抓包还原的人,按“载荷格式 → 解包重组 → 落盘 → 排错 → 验证”的顺序走一遍。
2. RTP与H.264的载荷关系:为什么不能直接把UDP载荷按顺序写入文件
要还原一个可播放的 h264 文件,必须先把 RTP 载荷的封装规则捋清楚。发送端拿到编码器输出的 H.264 裸流后,先按 NAL 单元切分,再根据 MTU 大小决定怎么装进 RTP 包。一个大的 IDR slice 常被拆成多个 RTP 分片,而 SPS、PPS 这种几十字节的小参数集又会被塞进同一个聚合包里,所以 RTP 包的载荷不是一段连续 H.264 字节流,而至少是三种形态之一:单 NAL、STAP-A 聚合、FU-A 分片。跳过这个认知直接写文件,产出的东西大概率只有抓包工具能看懂。
2.1 先分清RTP头、RTCP和数据包
在接收循环里拿到的 UDP 数据,如果端口上只有一路 RTP 视频,绝大多数包是 RTP 数据包,但仍会有 RTCP 包混在同一端口。RTCP 和 RTP 的版本位都是 2,用同一个 socket 收的时候不会自动分开。工程上判断 RTCP 的办法是解析 RTP 固定头里的 payload type:RTCP 的 SR、RR、SDES 等包类型对应到 7 位 payload type 上是 72 到 76,而 H.264 一般使用 96 到 127 之间的动态 PT,常见的是 96 或 97。先把 PT 判断写对,后面处理就干净了。
import socket import struct def parse_rtp_header(packet): if len(packet) < 12: return None version = (packet[0] >> 6) & 0x03 if version != 2: return None cc = packet[0] & 0x0F # CSRC 个数,影响载荷起始偏移 payload_type = packet[1] & 0x7F # 低 7 位才是真正的 PT seq = struct.unpack('!H', packet[2:4])[0] ts = struct.unpack('!I', packet[4:8])[0] ssrc = struct.unpack('!I', packet[8:12])[0] offset = 12 + cc * 4 return { 'payload_type': payload_type, 'seq': seq, 'timestamp': ts, 'ssrc': ssrc, 'payload': packet[offset:] }这里需要注意几个参数:cc 表示 CSRC 个数,如果非 0,载荷起点要从 12 字节之后推 cc 个 4 字节,很多设备不发 CSRC,所以容易被人忽略。RTP 头里 seq 和 timestamp 都是网络字节序,必须用!H、!I解析,直接用小端解出来会得到完全错误的序号,后续丢包判断全部作废。另外如果 RTP 扩展位 X 置 1,CC 之后还有一个 4 字节扩展头,我一般会在解析函数里顺手判断一下,遇到就跳过,避免把扩展当载荷写进文件。
还要考虑一种情况:同一端口上同时复用视频和音频,靠 payload type 区分。H.264 视频的 PT 一般是动态分配的 96 或 97,音频可能是 8、0 或其他动态值。转 h264 文件时,在主循环里只处理目标 PT,其余包直接丢弃。我见过有人把所有 PT 的载荷都送去拆 NAL,结果文件里混入音频数据,解码器直接疯掉,报错毫无规律。
2.2 H.264的三种RTP载荷形态
RTP payload 的第一个字节,和 H.264 NAL 头格式相同:最高位 forbidden_zero_bit,接下来两位 nal_ref_idc,低 5 位是 nal_unit_type。这个低 5 位决定了载荷是哪种形态。真正需要处理的类型可以归成三类:1 到 23 是单 NAL 单元,24 是 STAP-A 聚合包,28 是 FU-A 分片包。STAP-B、MTAP、FU-B 属于带 DON 的扩展用法,现在大多数设备不用,遇到可以先忽略。
单 NAL 单元最好处理,RTP payload 本身就是一个完整 NAL,直接把整个 payload 当成 NAL 写入文件就行。STAP-A 则不同,payload 第一个字节是 24,后面是若干段“2 字节长度 + NAL 单元”,每一段独立。常见场景是 SPS、PPS、SEI 打包进一个 STAP-A 发出去,如果你只按单 NAL 处理,这些参数集就全部丢了,文件会缺少解码必需的信息。FU-A 是给大 NAL 分片用的,payload 前两个字节是 FU indicator 和 FU header,S 位为 1 表示分片开始,E 位为 1 表示分片结束,真实 NAL 头需要由 FU indicator 的高 3 位和 FU header 的低 5 位拼出来。
| 载荷形态 | type 值 | 外观特征 | 对写文件的影响 |
|---|---|---|---|
| 单 NAL | 1~23 | 完整 NAL 单元 | 加起始码后直接写 |
| STAP-A | 24 | 多个小 NAL 聚合 | 拆开后分别写成多个 NAL |
| FU-A | 28 | 一个大 NAL 分片 | 按序拼齐后再写成一个 NAL |
有个误区值得单独说:有人图省事把每个 RTP payload 按序写入文件,理由是 FU-A 分片顺序写基本连贯。但 STAP-A 段会被写成“长度字段 + NAL 内容”的混合体,播放器完全不认;FU-A 的每个分片都带两个字节的 FU 头和 FU indicator,直接写等于在每个切片中间插入垃圾字节,NAL 长度和内容全错。所以解析、重组、再写是绕不开的三步,别指望播放器自己纠错。
3. 从UDP数据到完整NAL单元:解析与重组的最小可运行实现
这一章给出一个可以直接跑起来的最小实现,重点不是写得多优雅,而是把“收包 → 去 RTP 头 → 重组 NAL → 落盘”这条链路走通。我习惯先写一个调度器,把解析、分发、重组分开,这样后面加音频、加 RTSP 会话管理都不至于改到核心逻辑。下面所有代码以 Python 3 为例,思路同样可以翻译成 C 或 Go。
3.1 先搭一个能接UDP且不丢帧的接收循环
接收循环本身很简单,但参数设置直接影响长时间抓流的稳定性。socket 接收缓冲区默认可能只有几百 KB,设备码率一高,用户态处理来不及,内核缓冲区一满就直接丢包,UDP 没有重传机制,丢一个分片就毁一个 NAL。我一般把SO_RCVBUF设到 4MB 以上,再大一点到 8MB 也行,具体看本机内存。
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(('0.0.0.0', 5004)) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) while True: data, addr = sock.recvfrom(65535) # 每个 UDP 包交给 RTP 解析函数处理 rtp = parse_rtp_header(data) if rtp is not None: dispatch(rtp)0.0.0.0表示监听本机所有网卡,实际使用时改成设备所在网段的地址更稳妥。端口 5004 是测试用的,接入真实设备时长按设备配置写。recvfrom(65535)给足长度上限,虽然以太网 MTU 通常是 1500,但 UDP 理论上可以承载更大数据报,留大不亏。如果是从 pcap 文件离线还原,就把recvfrom换成按 pcap 记录逐条读取,提取 IPv4 层之后的 UDP payload,后面的解析逻辑完全相同。
3.2 解析RTP头并把载荷按类型分发
拿到 RTP 包之后,先过滤 RTCP,再取 payload 第一个字节的低 5 位判断 NAL 类型。注意一个容易混淆的点:STAP-A 和 FU-A 的第一个字节,它的低 5 位分别对应 24 和 28,这是 RTP 载荷类型,不是真实 NAL 类型。对 FU-A 来说,真实 NAL 类型在第二个字节的低 5 位里。
def dispatch(rtp): if 72 <= rtp['payload_type'] <= 76: return # RTCP 包,直接丢弃 payload = rtp['payload'] if not payload: return nal_type = payload[0] & 0x1F if 1 <= nal_type <= 23: handle_single(rtp, payload) elif nal_type == 24: handle_stap_a(rtp, payload) elif nal_type == 28: handle_fu_a(rtp, payload) else: # STAP-B、MTAP、FU-B 等暂不处理 passRTCP 判断放在最前面,是因为 RTCP 包的载荷格式和 RTP 完全不同,硬按 NAL 类型解析会得到随机结果。视频 PT 的白名单可以在这里一起做:如果端口上还跑着音频,判断payload_type不等于目标 PT 就 return。这个方法对多路复用场景特别重要,别等写完文件才发现混了音频。
3.3 FU-A分片重组与STAP-A聚合拆包的具体代码
FU-A 重组要维护两个状态:上一个分片的序列号,当前正在拼装的 NAL 字节串。核心逻辑是:收到 start 包就开始新 NAL,收到中间分片必须验证序列号连续,不连续就丢弃整个 NAL,收到 end 包才把拼好的 NAL 写文件。这段逻辑等价于“要么拿到完整 NAL,要么什么都不要写”。
last_seq = None partial_nal = None def handle_fu_a(rtp, payload): global last_seq, partial_nal if len(payload) < 2: return fu_header = payload[1] nal_type = fu_header & 0x1F start = fu_header & 0x80 end = fu_header & 0x40 cur_seq = rtp['seq'] if start: # 新分片开始,旧分片如果没结束直接丢弃 partial_nal = bytearray() partial_nal.append((payload[0] & 0xE0) | nal_type) last_seq = cur_seq elif partial_nal is None: # 没收到 start 就收到中间分片,说明已经丢了开头 return else: diff = (cur_seq - last_seq) & 0xFFFF if diff != 1: # 中间缺包,当前 NAL 不可修复 partial_nal = None return last_seq = cur_seq partial_nal += payload[2:] if end and partial_nal is not None: write_annexb(bytes(partial_nal)) partial_nal = None这段代码里(cur_seq - last_seq) & 0xFFFF是处理 16 位序列号回绕的关键写法,直接拿cur_seq - last_seq比较会遇到负数或 65535 的误判,按位与之后变成无符号回绕差值,diff == 1才是严格连续。start 分支里没有检查partial_nal是否为空,这是有意为之:一旦收到新的 start,旧的半截 NAL 不可能再被拼完整,直接覆盖是当前最安全的恢复策略。
STAP-A 拆包逻辑相对简单,但同样有边界坑。payload 从第 2 个字节开始,先读 2 字节长度,再取一段 NAL,循环下去。异常情况是长度字段为 0 或长度越界,这种情况直接跳出循环,别继续读下去,否则会死循环或把乱码当 NAL。
def handle_stap_a(rtp, payload): idx = 1 while idx + 2 <= len(payload): length = struct.unpack('!H', payload[idx:idx + 2])[0] idx += 2 if length == 0 or idx + length > len(payload): break nal = payload[idx:idx + length] idx += length write_annexb(nal)拆出来的每一个 NAL 都是完整独立的小 NAL,常见的就是 SPS、PPS、SEI 这些。把它们直接写入文件没问题,但如果后续要做 SPS/PPS 缓存,最好在这里也走一遍handle_single里的参数集缓存逻辑,避免同一个 SPS 既在 STAP-A 里出现又在单 NAL 里出现时漏存。
4. 把NAL单元落盘成h264文件:写Annex B的时序与字节序
重组成完整的 NAL 单元之后,剩下的工作就是落盘。这一步看似简单,但文件格式的约定、写入时机、SPS/PPS 的注入顺序,决定了最终文件是“能播放”还是“能解码但到处报错”。h264 裸流保存成文件,业界默认用 Annex B 字节流格式:每个 NAL 前面加起始码,NAL 之间连续排列,不写长度前缀。
4.1 写文件的时机:拿到一个完整NALU就写,不攒着
我见过有人先把所有 NAL 攒在内存列表里,等程序结束再统一写文件,结果抓了半小时视频,内存涨到几个 GB,最后进程被杀,啥也没留下。正确做法是每重组出一个完整 NAL 就立刻写盘,这样内存占用恒定,崩溃时也至少保留了之前的流。
out_file = open('output.h264', 'wb') def write_annexb(nal): out_file.write(b'\x00\x00\x00\x01') out_file.write(nal)这里有两个关键约定。第一,起始码固定用 4 字节00 00 00 01,虽然 3 字节00 00 01也被 Annex B 允许,但不少解析器对 3 字节起始码的容忍度不一致,统一用 4 字节可以少踩一个玄学坑。第二,写的是纯 NAL 字节,绝对不能带 RTP 头、FU 头、聚合长度字段。如果发现文件里出现大量 0x60、0x61 开头的乱码,多半是某个环节把 RTP 载荷原样写了进去。
写入性能方面,Python 的open('wb')默认有缓冲,每个 NAL 写一次不会触发频繁系统调用,性能足够。如果怕进程崩溃丢数据,可以在每写完一个 IDR 帧后调用out_file.flush(),把用户态缓冲强制落到内核,这个习惯在长时间抓流时能当后悔药用。不要用buffering=0逐字节写,那是把性能扔进垃圾桶。
4.2 SPS/PPS的保存与注入策略
SPS 和 PPS 是解码器最先要拿到的参数集,没有它们,后面的 slice 数据无法解析。很多设备只在会话开始时发一次 SPS/PPS,如果你启动程序时机不对,整个文件里就完全没有参数集,播放器打开就是黑屏或直接报错。常见做法是:一边收流一边缓存最新 SPS/PPS,在每个 IDR 帧前把缓存写进去,确保任何一个关键帧都能独立解码。
sps = None pps = None def handle_single(rtp, payload): global sps, pps nal_type = payload[0] & 0x1F if nal_type == 7: sps = payload elif nal_type == 8: pps = payload elif nal_type == 5: if sps and pps: write_annexb(sps) write_annexb(pps) write_annexb(payload) else: write_annexb(payload)这段代码把缓存和注入做在了一起:普通帧直接写文件,遇到 IDR(nal_type 为 5)时先补写 SPS/PPS 再写 IDR 本身。这样即使文件是从流中间开始抓的,只要缓存到了参数集,前面几帧也能被正确解码。更保守的做法是启动后先等 SPS/PPS 缓存成功再开始写文件,坏处是开头会丢一小段数据,好处是文件肯定从头可播。我习惯两个策略一起用:先等参数集,再每个 IDR 前注入。另外,遇到 6 号 SEI、9 号 AUD 这些类型,不需要特殊处理,原样写入即可,保留它们对后续转封装有帮助。
需要注意写入顺序:SPS/PPS 必须在 IDR 之前,不能先写 IDR 再补 SPS/PPS,否则解码器拿到 IDR 时参数集还没到,这一帧就废了。还有一种情况是注入后下一个 NAL 不是 IDR 而是普通 P 帧,这通常说明 IDR 在传输中丢失,不是注入逻辑的问题,不用过度处理。
5. rtp转h264的常见问题与排查:丢包、花屏、参数集丢失
这个方案跑通不难,难在遇到问题时不迷茫。下面是我在实际调试中遇到的四类高发问题,按“现象 → 原因 → 解决”的顺序写,方便你对照排查。
5.1 文件能写但播放花屏:多半是FU-A丢包没做丢弃
现象:文件能打开,也能看到画面,但画面出现大面积马赛克、绿块,或者解到一半突然卡死。原因:FU-A 分片在传输过程中丢了一两个包,但程序没做序列号连续性检查,把缺了中间分片的半截 NAL 当成完整 NAL 写进文件。解码器拿到不完整的 slice 数据,熵解码进入错误状态,后续无论多正确的数据都会跟着错乱。
解决:严格按 3.3 的 FU-A 逻辑做丢包判断,中间分片不连续就丢弃整个 NAL。遗漏一个 NAL 只影响当前帧,把错误的半截 NAL 写进去影响的是后续所有帧,两害相权必须取前者。判断代码就是diff = (cur_seq - last_seq) & 0xFFFF; if diff != 1: return,这是整个方案里性价比最高的一行代码。
5.2 文件开头黑屏:SPS/PPS没有及时注入
现象:播放器打开文件,前几秒黑屏,拖到后面又能正常显示。原因:文件开头的访问单元缺少 SPS/PPS,解码器没有参数集,无法初始化分辨率等信息,直到后面收到周期性 SPS/PPS 才能开始解码。原因也可能是程序启动时刚好错过设备发送参数集的时刻,而设备后续不再重复发送。
解决:用 4.2 的注入策略,启动时先缓存 SPS/PPS,再开始写文件,保证文件第一个 NAL 是参数集。如果抓包是从会话中间开始的,文件中没有 SPS/PPS 很正常,不要纠结是哪一行代码写错了,直接用“等参数集再落盘”的方式重抓一次。
5.3 解码器报 broken bitstream:起始码与字节序问题
现象:用工具解析文件时,日志刷出一堆 corrupted NAL、broken bitstream 之类的错误,或者时长、帧率信息完全不对。原因:起始码用了 3 字节或写成了00 00 00 00,又或者某些 NAL 前漏写了起始码。另一个隐蔽原因是 RTP 头字段用错字节序,解析出的 seq、长度全部错位,导致 STAP-A 里的长度字段读到乱值。
解决:统一用00 00 00 01四个字节做起始码,并且用struct.unpack('!H', ...)解析所有 RTP 多字节字段。不要相信“00 00 01 也能播”这句话,它在某些解码器上能跑,在另一些解码器上就是 random error,统一 4 字节最省事。
5.4 RTP序列号回绕与时间戳溢出:别把旧包当成新包
现象:程序刚启动时一切正常,跑了几个小时后突然开始疯狂丢包,或者画面卡顿,但网络明明是好的。原因:RTP 序列号是 16 位,跑到 0xFFFF 后会回到 0x0000,如果代码直接比较cur_seq > last_seq,回绕那一刻会把新包判断成旧包,导致一次误丢。时间戳是 32 位,同样存在回绕。
解决:用回绕安全的方式计算差值:
def seq_diff(a, b): # 返回 a 相对 b 的回绕差值,正常连续为 1 return (a - b) & 0xFFFF32 位时间戳回绕同理,(new_ts - old_ts) & 0xFFFFFFFF即可。这是长时间抓流必须处理的问题,很多人忽略它,直到线上日志里出现莫名其妙的断流才回来补课。
6. 验证你的h264文件与进一步优化
写完文件先别急着交付,第一步是确认裸流本身可解析。h264 裸流没有容器头,工具不会知道帧率和时长,必须显式指定格式。我一般先跑一条探测命令,确认关键帧和分辨率都在:
ffprobe -f h264 -show_frames output.h264 2>&1 | grep -E 'pict_type|key_frame|width|height' | head -40如果能看到 key_frame=1、pict_type=I 的记录,说明 IDR 被正确识别,SPS/PPS 注入顺序也没问题。再进一步转成封装格式,用常规播放器能播才真正放心:
ffmpeg -f h264 -i output.h264 -c:v copy output.mp4这条命令走完不报错,基本可以认定文件是健康的。如果转封装报错,回头检查第 5 章的四个坑,尤其是 SPS/PPS 是否缺失、FU-A 是否把半截 NAL 写进去了。
进阶一点,我建议在写文件的同时把 RTP 时间戳记下来,成本很低但价值很大:
ts_log = open('rtp_ts.log', 'w') def log_timestamp(rtp, nal_type): ts_log.write(f"{rtp['timestamp']} {nal_type}\n")这个日志能用来验证帧间隔是否均匀,定位网络抖动;将来想把裸流转成带 PTS 的 MP4,直接按这个日志映射时间戳即可,不用重新解析。另一个优化方向是保持当前“每个 IDR 前注入 SPS/PPS”的策略不变,但把写入单位从单个 NAL 升级为访问单元,也就是把一个关键帧的所有 slice 写完之后再 flush 一次,这样文件在任意位置切入都能更快同步。
我现在的习惯是:每次抓完流,第一件事跑探测命令看关键帧数量和分辨率,第二件事转一个 MP4 确认可播,这两步通过后再往下游分发。这个动作帮我挡掉过不少“文件能打开但实际有隐伤”的假健康情况。希望帮到你。
本文还有配套的精品资源,点击获取