ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RTP over UDP抓取H264码流:从抓包到生成可播放文件的实战指南

RTP over UDP抓取H264码流:从抓包到生成可播放文件的实战指南 简介面向需要处理RTP实时视频流并落盘H264文件的研发人员这份资源适合监控系统、在线会议、远程教育等场景中的视频数据传输与保存需求可用于摄像头数据读取、网络视频传输的学习和原型验证。示例工程基于UDP通信包含RTP服务器、通用解析工具与UDP封装模块核心逻辑覆盖从RTP包头解析、H264 NAL单元重组到按Annex B等格式写入文件的完整链路同时涉及RTCP配合下的服务质量管理帮助读者理解实时传输与编码存储的衔接。资源包共14个文件以C源码为主含6个头文件、4个cpp实现文件另有工程配置文件、过滤器与ReadMe说明文档压缩包仅9KB代码体量较小、结构清晰适合快速阅读与二次改造。实际使用时可将其中UDP接收与H264封装思路迁移到自己的项目中。已有1028人学习下载对于想掌握RTP/RTCP与H264编码交互、解决视频流保存或实时读取问题的人员是一份轻量而实用的参考。 干过音视频抓包的人基本都遇到过这个需求设备通过RTP over UDP把H264码流传出来但你要做分析、转封装、喂给播放器手里却只有一个pcap包或者一个不断在跑的UDP socket。网上一搜全是“用ffmpeg转一下”这种一句话回答真到自己写代码把RTP里的H264抠出来存成文件时才发现坑一个接一个。这篇就把我自己从抓包到最终得到能播放的H264文件的完整链路捋一遍包括原理、代码、还有我踩过的那些坑。核心关键词就四个rtp、h264、udp但要把它们串起来需要的细节远比想象中多。1. 为什么不能直接“把UDP数据保存成H264”很多刚接触这块的人第一反应是既然UDP载荷里就是H264数据那把收到的UDP payload整个写进文件不就行了这个思路对一半但直接这么做得到的文件大概率是废的。先说结论RTP是H264的传输层包装不是H264文件的存储格式。H264文件无论是裸流.264/.h264还是MP4要求的是连续的NALU字节流每个NALU有Start Code00 00 00 01或者00 00 01分隔而在RTP传输场景下H264被按照RFC 3984/6184的规则拆成了多种RTP负载类型单NAL模式Single NAL Unit一个RTP包完整携带一个小NALU比如SPS、PPS、SEI这些包可以直接提取负载后加上Start Code就还原。分片模式Fragmentation Units, FU-A一个大的NALU通常是I帧被拆成多个RTP包发送每个RTP包只带这个NALU的一部分需要按顺序等所有分片到齐后重组。聚合模式STAP-A多个小的NALU合并到一个RTP包里需要拆开还原成多个NALU。所以从UDP流到H264文件中间至少要做三步从UDP剥出RTP头、从RTP头判断负载类型、按负载类型还原NALU并拼接。这三步任何一步出错出来的文件都是花屏、绿屏、或者干脆打不开。1.1 UDP、RTP、H264三者之间的关系用一个类比来理解这三层H264码流是货物本身RTP是给货物贴的快递单标明这是什么货、分了几箱、每箱的顺序UDP则是那辆只管送货不管货物完好的卡车。RTP头里有一堆字段但真正做转存时你必须关注的是这几个payload typePT7字节负载类型H264通常固定为96-127之间的动态值也有约定为96的。sequence number序号每发一个包加1用于检测丢包和排序。timestamp时间戳同一帧的包时间戳相同不同帧时间戳不同是判断帧边界的核心依据。marker bitM位标记者一帧的最后一个RTP包配合时间戳一起判断帧结束。我的经验是先把tshark或者Wireshark抓到的包结构研究透了再动手写代码否则你都不知道自己解析对没对。后面我会展示怎么验证。2. RTP封装H264的负载结构手写解析前必须先搞懂的三张“表”写代码之前先把RFC 6184里最关键的负载类型结构记牢。我不建议对着RFC啃但下面这三张结构图是必须刻在脑子里的否则代码写出来就是瞎猜。2.1 单NAL模式直接提取这种模式最简单RTP payload 的第一个字节就是NALU header。NALU header的构成是--------------- |0|1|2|3|4|5|6|7| -------- |F|NRI| Type | ---------------F1 bitforbidden_zero_bit错误位正常为0NRI2 bit nal_ref_idc重要性指示非0表示该NALU被参考Type5 bitNALU类型。H264里常见的类型有1非IDR的slice、5IDR关键帧、7SPS、8PPS、9AUD、6SEI当Type在1-23之间且不是聚合/分片标识类型时这就是一个完整的单NALU直接把RTP payload原样拷贝前面加上Start Code即可。2.2 FU-A分片模式需要重组当RTP payload第一个字节的低5位是28即0x1C时说明这是一个分片包。结构是0 1 2 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 ------------------------ | FU indicator | FU header | 数据载荷... | ------------------------FU indicator字节F、NRI保留低5位Type28FU header字节Sstart1表示分片起始、Eend1表示分片结束、R保留、Type5位表示原始NALU的真实类型重组规则很简单遇到S1的包时开一个新缓冲区把FU indicator的高3位F和NRI和FU header的低5位真实Type拼成一个字节作为新NALU的header然后把所有分片的载荷从RTP payload的第3字节开始按RTP序号顺序追加直到E1时结束最后整体加Start Code写入。这里有一个必须注意的细节同一个NALU所有分片的RTP时间戳必须相同这是判断分片归属的最可靠依据比单纯依赖S/E标记靠谱得多因为网络抖动可能导致乱序。2.3 STAP-A聚合模式需要拆包当第一个字节低5位是24即0x18时这是聚合包。结构是--------------------------------------------- | STAP-A header | NALU size(2B)| NALU data... | ... ---------------------------------------------每个子NALU由2字节长度字段大端 NALU数据组成一个STAP包里可能包含多个。这种模式多用于传输SPSPPSIDR的组合包解析时就是循环读取长度、切分数据每个子NALU单独加Start Code写入。下表是我在代码里写的类型分支速查写代码时放在最前面负载类型值低5位含义处理方式1、5单Slice帧直接提取6SEI直接提取7SPS直接提取需在文件头附近8PPS直接提取需在文件头附近24STAP-A拆包后再逐个提取28FU-A缓存分片重组为完整NALU其他保留/未知忽略或打印日志3. 实操链路用tshark快速验证抓到的RTP流真正动手写Python脚本之前我建议你先用Wireshark/tshark快速验证一下流里的RTP封装格式。因为不同设备厂商的RTP实现有差异有的喜欢用FU-A分片大I帧有的喜欢用STAP-A聚合SPS/PPS和IDR先看清楚再写解析器能省一半调试时间。3.1 抓包的常用姿势如果你是直接在服务器上抓取某个端口上的UDP流用tshark很合适。比如设备往本机9000端口发RTP流# 抓取指定端口数据写入pcap文件 tcpdump -i eth0 udp port 9000 -w rtp_raw.pcap # 用tshark解析pcap只看RTP信息 tshark -r rtp_raw.pcap -Y rtp -T fields -e rtp.payload -e rtp.seq -e rtp.timestamp -e rtp.markertshark的rtp.payload字段输出就是去掉RTP头之后的负载内容以十六进制冒号分隔显示。这一步能让你直观看到单包是一个什么NALU类型、分片包的FU头长什么样、包序号和Marker的变化规律。我们在Windows上调试时也用Wireshark直接看效果一样而且图形界面排查人眼更直观。重点过滤表达式可以写成udp.port 9000 rtp.payload_type 96如果设备RTP动态端口不是默认值Wireshark里需要在“编辑→首选项→Protocols→H264/RTP”里把动态PT值对应的编码类型显式指定为H264否则抓包工具不识别显示出来的RTP负载都是一串裸十六进制。3.2 从pcap提取RTP负载的另一种方法如果不想安装tsharkPython的scapy库也能直接读pcap文件并解析RTP层。不过scapy对RTP的解析支持有限很多时候它只把RTP当作Raw load处理我自己更习惯用tshark导出明文后再处理tshark -r rtp_raw.pcap -Y rtp.payload_type 96 -T fields -e rtp.payload rtp_payloads.txt然后写Python读这个文本文件按行分割、去掉冒号hex解码后就是RTP负载原始字节。这个流程简单粗暴但对于快速验证解析逻辑非常高效——你在本地就能对标自己代码的输出结果。4. 手写RTP转H264的Python脚本从零拼接出可播放的.264文件现在进入正题。下面的脚本我实测跑通过处理过手头一个IP Camera的RTP流从UDP socket直接读取并落盘成H264裸流文件。核心设计思路是用UDP socket绑定端口收取RTP包。剥离12字节RTP头判断负载类型。单NALU直接加Start Code写入FU-A分片缓存重组后再写入STAP-A拆包后逐个写入。SPS/PPS等参数集单独保留在文件头附近保证播放器能初始化解码器。4.1 基础代码框架import socket import struct RTP_HEADER_LEN 12 START_CODE b\x00\x00\x00\x01 # NALU类型常量 NALU_SPS 7 NALU_PPS 8 NALU_FU_A 28 NALU_STAP_A 24 # FU-A分片重组缓冲区 fu_buffer b fu_started False fu_expected_seq None # 输出文件 output_file open(output.h264, wb) def write_nalu(nalu_data: bytes): global output_file if not nalu_data: return output_file.write(START_CODE) output_file.write(nalu_data) def parse_rtp_payload(payload: bytes, seq: int, timestamp: int, marker: bool): global fu_buffer, fu_started, fu_expected_seq if len(payload) 2: return nal_header payload[0] nal_type nal_header 0x1F if nal_type NALU_FU_A: fu_header payload[1] start_flag (fu_header 7) 0x01 end_flag (fu_header 6) 0x01 original_nal_type fu_header 0x1F if start_flag: # 重组新的NALU取FU indicator高3位 FU header低5位 reconstructed_header bytes([(nal_header 0xE0) | original_nal_type]) fu_buffer reconstructed_header payload[2:] fu_started True fu_expected_seq seq 1 elif fu_started: # 中间分片按序号判断连续性 if seq ! fu_expected_seq: print(f[WARN] 分片乱序或丢包: 期望seq{fu_expected_seq}, 实际seq{seq}) fu_buffer payload[2:] fu_expected_seq seq 1 if end_flag: write_nalu(fu_buffer) fu_buffer b fu_started False elif nal_type NALU_STAP_A: # 聚合包循环拆分 index 1 # 跳过STAP-A头 while index len(payload): if index 2 len(payload): break nalu_size struct.unpack(H, payload[index:index2])[0] index 2 nalu_data payload[index:indexnalu_size] index nalu_size write_nalu(nalu_data) else: # 单NALU但要注意潜在的乱序问题 write_nalu(payload) def main(): udp_port 9000 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((0.0.0.0, udp_port)) print(f监听UDP端口 {udp_port} ...) last_flush_time time.time() while True: data, addr sock.recvfrom(65535) if len(data) RTP_HEADER_LEN: continue # RTP头解析 version (data[0] 6) 0x03 marker (data[1] 7) 0x01 payload_type data[1] 0x7F seq struct.unpack(H, data[2:4])[0] timestamp struct.unpack(I, data[4:8])[0] if payload_type ! 96: # 动态PT按需修改 continue rtp_payload data[RTP_HEADER_LEN:] parse_rtp_payload(rtp_payload, seq, timestamp, marker) if __name__ __main__: main()这段代码的骨架已经很完整了但离“生产可用”还有距离。下面几个细节是我实际调试中被卡过比较久的地方逐个说清楚。4.2 时间戳与Marker判断帧边界的正确姿势很多网上代码喜欢用Marker位来判断一帧是否结束。实际抓包你会发现很多设备的Marker位乱标甚至不标这时候必须靠时间戳兜底。同一帧的RTP包时间戳一定相同不同帧的时间戳会跳变跳变值通常是90000/帧率例如25fps就是3600。所以在写文件时可以考虑在时间戳跳变的位置插入分隔标记方便后续按帧切割last_timestamp None def handle_frame_boundary(new_timestamp: int): global last_timestamp if last_timestamp is not None and new_timestamp ! last_timestamp: # 时间戳变化说明新的一帧开始 pass last_timestamp new_timestamp另外建议在I帧前补写SPS/PPS。如果你在文件里一直没有看到SPS/PPS很多设备只会在RTP流开始和关键帧前发一次播放器很容易报“无解码器初始化信息”。我在脚本里加了一个简单逻辑如果连续抓到一片数据里没有SPS就在遇到第一个IDR帧时把之前缓存过的SPS/PPS重新写一遍到文件里。4.3 FU-A重组时的乱序与丢包兜底UDP本身不保证顺序和可靠所以在FU-A重组时丢包很容易出现。一旦中间某个分片丢了你硬拼出来的NALU是坏的写进文件可以直接导致这一帧花屏甚至整个解码器崩溃。我的处理策略是记录fu_expected_seq发现不连续时直接丢弃整个FU-A帧不写入文件。同时设一个超时机制比如2秒内没等到结束分片就清空fu_buffer。很多播放器对单个坏帧的容忍度可以但对跨帧的“脏数据”非常敏感所以宁可丢一帧也别写半帧。5. 实测排错手记从花屏、绿屏到正常播放脚本写完只能说万里长征走了一半接下来跑真实数据调试才是重头戏。我把整个调试过程中遇到最典型的三个问题拉出来讲讲——如果你也照着这个流程做大概率会撞上。5.1 问题一ffplay能播放但画面全绿现象ffplay output.h264能出画面但全屏绿色或者花屏带条纹。排查链路用ffprobe output.h264看文件信息发现SPS、PPS都有说明参数集解析没问题。用十六进制工具打开文件头对比Wireshark里抓到的SPS内容发现文件里SPS后面的NALU类型和抓包里不一致早期的几个NALU顺序乱了。进一步定位发现设备在开始推流时前几个UDP包发送顺序不是RTP序号顺序而是SPS、PPS、SEI、IDR交错发出但我的代码在遇到SPS/PPS时立即写入文件而SEI和IDR的分片重组还在缓冲区里没完成导致文件里先写入了后面的NALU。原因没有按RTP时间戳排序也没有在帧边界做正确的顺序控制。解决方案把SPS/PPS/SEI这类参数集先缓存起来等第一个IDR完整重组后再统一写入文件头保证解码器初始化参数在I帧之前。改完之后画面立刻正常。5.2 问题二文件播放时中间出现长时间卡顿现象开头正常播放到某个时间点开始卡住过几秒又恢复。排查链路用Wireshark看抓包记录发现这个时间段有连续丢包RTP序号跳变。我的代码对单NAL单元类型是直接写文件的没有做丢包检查所以一个丢包导致的“半帧”被直接写进了H264文件解码器试图解码一个不完整帧导致卡顿。修复方式对单NAL单元也做序号连续性检查。如果两个连续写入的RTP包序号之间跳变超过一个阈值比如100就不再直接写后续单NAL数据而是寻找下一个关键帧的SPS/PPS重新同步。说白了裸流文件不像MP4有索引和损坏掩盖机制写进去的每一个坏字节都会被解码器放大所以“宁缺毋滥”这个原则在RTP转H264时特别适用。5.3 问题三收流时文件末尾总是缺半个分片现象程序CtrlC停止后output.h264文件末尾经常有残缺数据导致播放器提示“file ended unexpectedly”。原因FU-A分片还没有结束收到E1时我就强制终止了Python进程缓冲区里的半个NALU也来不及写入但此前可能已经把一些中间分片写进去了。解决在退出前增加一个收尾函数把未完成的fu_buffer直接丢弃确保最后写入文件的内容是完整的NALUdef flush_and_close(): global fu_buffer, output_file fu_buffer b # 丢弃未完成分片 output_file.flush() output_file.close() print(文件已关闭未完整分片已丢弃)同时最好加上signal.signal(SIGINT, handler)处理CtrlC这样优雅退出不会留下脏数据。6. 进阶技巧把RTP流同时落盘和实时预览有些场景下你不只想把UDP数据保存成H264文件还想在保存的同时实时预览画面。我常用的方案是解析脚本把H264裸流数据同时写到标准输出管道再喂给ffmpeg做解码显示python3 rtp_to_h264.py | ffplay -f h264 -i -Python端只需要在write_nalu里同时sys.stdout.buffer.write(START_CODE nalu_data)然后对外层标准输出做无缓冲处理import sys sys.stdout os.fdopen(sys.stdout.fileno(), wb, buffering0)这一步操作在调试相机角度、云台转动时特别实用眼睛看实时画面的同时文件也在后台完整落地一举两得。另一个建议是直接把收到的RTP包序号打印到日志里或者写入一个独立文件这样出问题时你能拿日志和pcap包做对照分析。我见过不少同事卡在一个奇怪的现象上很久最后发现是设备侧发送逻辑有bug多发了一个RTP包或者时间戳跳变异常这种问题如果没有序号日志基本无从查起。7. 从“能出文件”到“稳定可靠”代码层面还没完的事上面的流程已经能让你在本机把RTP数据落盘成H264文件但如果是长期收流、无人值守的环境下面几个点建议提前考虑。7.1 按文件大小或时长自动切片长时间收流时单个H264文件会越来越大裸流格式又没法像MP4那样做定位最好按一定条件切分文件。我一般按2GB大小或者1小时时长切一次max_file_size 2 * 1024 * 1024 * 1024 # 2GB if output_file.tell() max_file_size: flush_and_close() output_file open(foutput_{file_index}.h264, wb)切片时同样要选择帧边界最好等当前帧的Marker位或时间戳跳变后再切换文件避免把一个完整的帧切成两半。7.2 前端丢包检测与统计实际生产环境尤其无线图传、公网传输丢包是常态。脚本最好实时输出丢包率统计信息total_packets 0 lost_packets 0 last_seq None def update_seq_stats(seq): global total_packets, lost_packets, last_seq total_packets 1 if last_seq is not None: diff (seq - last_seq) 0xFFFF # RTP序号是16位循环的 if diff 1: lost_packets diff - 1 last_seq seq这里有一个特别容易踩的坑RTP序号是16位无符号数会循环回绕。如果你直接用seq - last_seq算差值在回绕的瞬间会出现负数或者巨大的差异。必须用 0xFFFF处理成无符号差值或者判断差值大于32767时就当它是回绕。7.3 数据来源适配pcap文件UDP socket两种模式我在开发时习惯做两种输入模式方便调试和交付离线pcap模式读pcap文件模拟UDP包便于回归测试。用scapy读包后把IP/UDP/Raw层内容复原成字节流走同一套解析逻辑。在线UDP模式绑定端口实时收包上文已经给出。一个解析函数通吃两种模式调试离线数据时可以直接对照Wireshark结果这比直接在线收流一步步试错要高效得多。你也可以把pcap里的UDP载荷整体导出来然后直接喂进同样的解析逻辑from scapy.all import rdpcap, UDP, Raw packets rdpcap(rtp_raw.pcap) for pkt in packets: if UDP in pkt and Raw in pkt: udp_payload bytes(pkt[Raw]) # 手动剥离RTP头走相同逻辑 parse_udp_payload(udp_payload)8. 验证H264文件正确性的几个可执行手段代码写完、文件生成后验证环节别省。我的验证顺序是二进制头检查用Hex Fiend或010 Editor打开文件确认文件开头是00 00 00 01 67SPS或00 00 00 01 65IDR slice。如果第一个NALU是SEI或者别的播放器也可能能容忍但SPS在文件前面是最稳的。ffprobe查看信息ffprobe output.h264关注输出的Stream #0:0: Video: h264 (High 4:4:4 Predictive)有没有正确识别编码和Profile以及SPS里的分辨率和帧率是否和预期一致。抽帧验证用ffmpeg抽几帧关键帧导出为图片ffmpeg -i output.h264 -ss 00:00:01 -frames:v 1 frame1.png如果第一秒就能抽出正常画面说明SPS/PPS和首个IDR帧数据基本没问题。如果抽出来的图是花的大概率是FU-A重组时某处分片没有对齐回到第4.3节检查丢掉的分片判断逻辑。全程播放一遍别拖进度条从第一帧到最后一帧完整播放留意中间有没有花屏段落。花屏段落对应的RTP序号区间回到Wireshark交叉检查很大概率是丢包或者乱序没处理好。这些验证手段做完基本可以放心地把这个程序用到实际采集环境里去了。RTP转H264这件事本身不复杂但真正用起来细节都在“怎么处理异常”上。UDP不保证可靠设备厂家不保证规范所以你的解析器必须有足够宽容度和容错策略。我现在的做法是先保证“不产生坏数据”再追求“尽量多还原数据”这个优先级尤其在长时间无人值守的收流场景里非常关键。希望这份手记能让你少踩几个我已经踩过的坑。本文还有配套的精品资源点击获取
返回列表