ARTICLE DETAIL

资讯详情

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

UDP抓包到H264文件:RTP解析与拼接实战

UDP抓包到H264文件:RTP解析与拼接实战 简介面向网络视频传输开发者的RTP转H264处理方案解决的是将UDP数据包中的RTP流解析成H264编码数据并保存为本地文件这一典型需求资源以工程形式打包共14个文件包括6个头文件、4个C源文件以及工程配置和说明文档压缩包约9KB代码量不大但完整覆盖了网络收包、协议解析、数据重排、落地写文件的核心步骤适合正在调试视频监控收流逻辑的初中级开发者参考。从实现层面看资源对UDP通信、RTP包头解析、时间戳与序列号处理、H264 NAL单元重组等关键环节均做了模块化拆分针对RTP分包传输H264数据的特点代码给出了在乱序或跨包边界情况下重组NAL单元的修复思路并比较了Annex B与MP4两种文件格式的写入差异方便根据播放器或存储需求选用。此外工程中还提供了可复用的通用工具能够帮助减少在项目嵌入时的重复开发工作目前已有1031人学习下载对于需要快速搭建UDP视频接收落盘原型、或想深入理解RTP与H264封装关系的使用者具有直接的参考价值。1. 从 UDP 抓包到 H264 文件先搞清楚 RTP 里到底装了什么做安防摄像头接入或者流媒体调试的人大概率都碰到过这种需求设备只给一路 UDP 裸流文档上写着「rtp转h264文件将 udp 的数据保存成 h264」但真正上手抓包后你会发现UDP 数据段里并不是干净的 H264 裸流而是带着 12 字节 RTP 头的分包。很多新手拿着 tshark 直接导 payload拼出来的文件要么花屏、要么播放器根本不认卡在「抓包有数据、存文件打不开」这一步。这个标题真正要解决的问题是把 UDP 载荷里的 RTP 头剥离、按 H264 的 Annex-B 格式重新拼装再处理 SPS/PPS、时间戳和分片顺序最终得到一份能被 ffplay / VLC / ffmpeg 直接读取的 .h264 文件。这篇文章就是沿着这条链路把原理、命令、参数和最容易翻车的几个细节一次讲透。2. 抓 RTP 流之前的功课SDP 里的 PT、SSRC 和时间戳先解码2.1 从 SDP 拿到 payload type 与编解码参数在没有 SIP 信令、也没有 RTSP 描述的情况下裸 UDP 上的 RTP 会话最容易踩的第一个坑就是不知道 payload typePT对应的是什么编码、采样率是多少。RTP 头里的 PT 是一个 7 bit 字段0-95 是 RFC 3551 固定的静态类型比如 PT96 到 127 属于动态类型具体是什么编码必须从 SDP 的 rtpmap 属性里读。如果你从摄像头 RTSP 取流VLC 的「媒体信息」或者 ffmpeg 的 verbose 输出都能看到 SDP如果是纯 UDP 打流那通常得靠设备手册或者抓 SIP 的 INVITE 报文来拿。SDP 里最关键的三行信息是SDP 字段含义解析要点mvideo 5004 RTP/AVP 96视频流媒体端口和 PTPT 一定是 96-127 区间artpmap:96 H264/90000编码名与时钟频率H264 的 RTP 时钟固定 90000 Hzafmtp:96 packetization-mode1; sprop-parameter-setsZ2...,aP...H264 的打包模式与 SPS/PPSsprop 是 base64 编码解码后是 Annex-B 格式的 SPS/PPS我一般会用 ffmpeg 先拉一次流看 SDPffmpeg -rtsp_transport udp -i rtsp://192.168.1.64/stream1 -v verbose -t 5 -f null /dev/null 21 | grep -E rtpmap|fmtp|SDP这段命令的意思是用 UDP 传输方式打开 RTSP 流verbose 模式输出 5 秒然后丢弃画面不写文件把控制台里包含 rtpmap / fmtp / SDP 的行过滤出来。-rtsp_transport udp指定走 UDP-t 5控制只拉 5 秒避免一直开着浪费流量-f null /dev/null表示不落盘等价于丢弃。输出里你会看到Payload type 96: H264以及完整的一行 fmtp里面那串Z2...和aP...就是后面存文件时必须要处理的东西。注意一个细节很多纯 UDP 打流场景没有 SDP 可看设备手册只写了「H26490000」。那 PT 就当成黑匣子处理先用 96 试抓包后看负载第一个字节的低 5 位是不是 0x1CFU-A或 0x67SPS用这个反过来验证 PT 猜得对不对。这个过程不玄学是排除法。2.2 用 tshark 把 UDP 抓包还原成 RTP 会话拿到 SDP 之后下一步是在交换机的镜像口或者本机网卡上抓 UDP 包。目标端口如果在 SDP 里是 5004抓包命令是tshark -i eth0 -f udp port 5004 -w rtp_raw.pcapng -P参数说明-i eth0指定抓包网卡-f udp port 5004是 BPF 过滤规则只保留源或目的端口为 5004 的 UDP 报文-w rtp_raw.pcapng写 pcapng 文件-P在抓包的同时打印摘要。如果摄像头用的源端口不固定可以去掉端口过滤改成只过滤 IP-f host 192.168.1.64这样会把 RTCP 也一起抓下来后续做时间戳校准时反而有用。抓完包先别急着导出 payload先用tshark -r看 RTP 层信息tshark -r rtp_raw.pcapng -Y rtp -T fields -e rtp.ssrc -e rtp.payload_type -e rtp.timestamp -e rtp.seq -e rtp.payload这里-Y rtp是显示过滤器只看 RTP 包-T fields表示只输出指定字段-e rtp.ssrc是同步源标识同一个视频流的所有包 SSRC 相同-e rtp.payload_type验证 PT-e rtp.timestamp看时间戳是否连续-e rtp.seq看序列号有没有丢包-e rtp.payload把 RTP 负载以十六进制形式打出来这是后面拼 H264 的原始素材。这一步输出的每一行都是「SSRC PT timestamp seq payload 十六进制」的完整记录。如果一个会话里出现两个不同 SSRC说明要么是音频和视频混在同一端口要么设备中途重启导致 SSRC 变了——这两种情况要用不同的策略去拼文件绝不能混在同一个输出文件里。3. 把 RTP 负载拼成 H264 流的两个关键点h264 分片和 PPS/SPS3.1 单包、FU-A 分片、STAP-A 聚合三种装载格式H264 的 NAL 单元大小通常超过 MTU所以 RTP 打包不能总是把整个 NAL 塞进一个 UDP 包。RFC 6184 定义了三种常见装箱模式抓包时你会看到负载的第一个字节低 5 位类型字段有几种固定值NAL 类型值含义处理方式1-23普通 NAL单包装载整个 NAL 就在一个 RTP 包里直接去掉 RTP 头后加 Annex-B 起始码28FU-A一个大 NAL 被拆成多个 RTP 包需要先拼 FU payload 再还原 NAL24STAP-A多个小 NAL 聚合在一个 RTP 包里需要拆分聚合单元逐个输出25-27STAP-B / MTAP很少见多路聚合安防设备基本不用最常见的是 28 和 24。判断方法是读负载第一个字节的二进制0xFC first_byte也就是把最高两位的 forbidden_zero_bit 和 nal_ref_idc 抹掉剩下的就是 type。Type 为 28 时第二个字节高 3 位是 FU 的 start/end 标记低 5 位是真实 NAL 类型拼法比较特殊第一个分片的 payload 从第二个字节开始但要把第二个字节的低 5 位作为 NAL 头的高 5 位输出实际操作是(FU_INDICATOR 0xE0) | (FU_HEADER 0x1F)拼出那个 NAL 的第一个字节。中间的包去掉前两个字节直接拼最后一个分片直接拼剩余部分。用 Python 伪代码描述这个逻辑def rtp_to_h264(rtp_payload): nal_header rtp_payload[0] nal_type nal_header 0x1F if nal_type 28: # FU-A fu_header rtp_payload[1] real_nal_type fu_header 0x1F start_bit (fu_header 0x80) 7 if start_bit: # 起始分片重建 NAL 头 后续数据 reconstructed bytes([(nal_header 0xE0) | real_nal_type]) rtp_payload[2:] else: reconstructed rtp_payload[2:] elif nal_type 24: # STAP-A # 聚合包循环读 2 字节长度 NAL 数据 reconstructed b offset 1 while offset len(rtp_payload): nal_len int.from_bytes(rtp_payload[offset:offset2], big) offset 2 reconstructed rtp_payload[offset:offsetnal_len] offset nal_len else: reconstructed rtp_payload return reconstructed逻辑说明nal_type判断走哪条拼装路线FU-A 要按 start 位决定是否重建 NAL 头STAP-A 则是循环解析每个子 NAL 的 2 字节长度前缀拆出来依次写入。这里最容易出错的点是 FU-A 的 start 分片有人直接把rtp_payload[2:]写到文件里前面的 NAL 头丢了一个字节结果 SPS/PPS 少一截花屏是必然的。3.2 纳秒级时间戳换算与去 jitter 缓存H264 over RTP 的时间戳时钟频率是 90000 Hz这不是随便定的而是 MPEG 系统标准里视频采样时钟的惯例。RTP 时间戳的单位是「采样周期」转成播放用的毫秒要除以 90。例如时间戳从 360000 跳到 450000差值为 90000对应 1 秒。在 UDP 网络里RTP 包不是严格按顺序到达的jitter 会造成某个分片比前一个分片晚到几毫秒如果收到一个 FU-A 分片就直接写文件很可能出现「中间包先到、起始包后到」的乱序。常见做法是在拼 FU-A 时维护一个按序列号排序的小缓存比如 20 个包的窗口import collections pending collections.OrderedDict() def add_fu_packet(seq, payload): pending[seq] payload while len(pending) 20: oldest pending.popitem(lastFalse)[1] # 超窗的包按顺序输出到文件 write_to_annexb(oldest) if len(pending) 20: # 触发一次顺序刷写 for key in sorted(pending.keys()): write_to_annexb(pending.pop(key))参数说明20是对应约 5-10 ms 的 jitter 容忍度如果设备网络质量差可以调大到 50OrderedDict用来保证插入顺序和刷写顺序一致截到len(pending) 20时按sorted重排确保乱序包在写入前恢复正确顺序。但注意窗口填满才刷写会引入固定延迟不能无限等超时没凑满窗口也要把已有数据落盘否则实时流会被拖死。如果你只是离线把抓包存成文件那根本不需要这层缓存直接按 ts 排序一次即可。4. 用 Python 把 UDP 数据保存成 H264最小可运行脚本4.1 脚本骨架UDP 接收 RTP 解析 Annex-B 拼接前面的原理落成代码最直接的方式是写一个 Python 脚本UDP socket 绑定设备的发送端口收到 RTP 包后拆分拼接边收边写文件。下面是一个我实际用过的最小骨架import socket import sys UDP_IP 0.0.0.0 UDP_PORT 5004 OUT_FILE stream.h264 # Annex-B 起始码 00 00 00 01 START_CODE b\x00\x00\x00\x01 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((UDP_IP, UDP_PORT)) received_frames 0 def get_nal_type(payload: bytes) - int: return payload[0] 0x1F with open(OUT_FILE, wb) as f: while True: data, addr sock.recvfrom(65535) if len(data) 12: continue # RTP 头前 12 字节 # 0: V2, P, X, CC # 1: M, PT # 2-3: sequence number # 4-7: timestamp # 8-11: SSRC pt data[1] 0x7F if pt ! 96: # PT 不对可能是别的流丢弃 continue seq int.from_bytes(data[2:4], big) timestamp int.from_bytes(data[4:8], big) rtp_payload data[12:] nal_type get_nal_type(rtp_payload) if nal_type 28: # FU-A 分片解析并拼装简化版省去乱序处理 fu_header rtp_payload[1] start_bit (fu_header 0x80) 7 real_type fu_header 0x1F if start_bit: f.write(START_CODE) f.write(bytes([(rtp_payload[0] 0xE0) | real_type])) f.write(rtp_payload[2:]) elif nal_type 24: # STAP-A 聚合拆出每个子 NAL 分别加起始码 offset 1 while offset len(rtp_payload): nal_len int.from_bytes(rtp_payload[offset:offset2], big) offset 2 f.write(START_CODE) f.write(rtp_payload[offset:offsetnal_len]) offset nal_len else: # 单包 NAL直接加起始码 f.write(START_CODE) f.write(rtp_payload) received_frames 1 if received_frames % 1000 0: print(f已接收 {received_frames} 个 RTP 包最近 seq{seq}, ts{timestamp})逻辑说明脚本从 UDP 端口 5004 接收数据判断 PT 是否为 96然后按 NAL 类型分别处理。单包和聚合包直接写 Annex-B 起始码加数据FU-A 分片在起始分片时重建 NAL 头。START_CODE是 H264 Annex-B 的帧分隔符VLC 和 ffmpeg 靠这个识别 NAL 边界。received_frames % 1000打印进度作用是确认流在持续接收而不是网卡没抓到数据。一个必需参数是pt不同设备可能是 96、100、112必须和 SDP 里的 rtpmap 对齐。另一个可选参数是端口如果设备是 RTSP 动态分配端口得先把 SDP 里所有m行的端口都拿下来再启动脚本。注意这个脚本没有做丢包重排只适合网络质量好的局域网环境Wi-Fi 或者跨三层网络丢包严重时产生的 H264 文件会有明显花屏。4.2 Annex-B 与 AVCC 的差别什么场景必须转上面脚本输出的是 Annex-B 格式也就是起始码00 00 01或00 00 00 01分隔的字节流。MP4 / MKV 这类容器内部用的是 AVCC 格式也就是「NALU 长度前缀 NALU 数据」SPS/PPS 放在容器的 extradata 里而不是混在帧数据之间。两者不能直接互相改名复用必须做转换。格式结构适合场景Annex-B00 00 00 01 NAL100 00 00 01 NAL2裸 .h264 文件、实时流、RTP 存储AVCC4 字节长度 NAL14 字节长度 NAL2MP4、MKV、FLV 容器如果目标文件是 .h264直接用 Annex-B 就行但如果最终要进播放器做逐帧 seek强烈建议用 ffmpeg 转一次封装ffmpeg -f h264 -i stream.h264 -c copy -movflags faststart output.mp4-f h264告诉 ffmpeg 输入是裸 H264 流-c copy是流拷贝不做重编码-movflags faststart把 moov 原子挪到文件头这样网页播放器可以未下载完就预览同时避免转码造成画质损失。这一步是纯封装层面的转换速度是实时的几十倍。但前提是 stream.h264 的 Annex-B 格式足够干净——如果 SPS/PPS 丢了ffmpeg 大概率直接报「Could not find codec parameters」。5. 存储 H264 常见的 4 个坑花屏、绿屏、时间戳跳变、PT 识别错误5.1 花屏SPS/PPS 只出现在关键帧前面现象拿拼好的 .h264 文件用 ffplay 播放画面大范围马赛克但偶尔能看清一帧完整的画面。原因H264 解码器必须先拿到 SPS序列参数集和 PPS图像参数集才能开始解码。在 RTP 流里SPS/PPS 通常只在 IDR 关键帧前面出现一次之后几十秒内都不会再发。如果你抓包的起始位置不是关键帧或者存储时把 SPS/PPS 对应的 RTP 包丢了解码器就一直处于「等参数集」的状态画面只能花。解决一是保证抓包从关键帧开始抓抓包前半段可以等几秒再落盘二是在拼文件时如果发现 SPS/PPS 在中间段出现把它重复写在每一段 GOP 的开头。写文件时这样处理# 假设 sps_pps_bytes 是从 RTP 流里提取的 Annex-B 格式 SPSPPS f.write(START_CODE) f.write(sps_bytes) f.write(START_CODE) f.write(pps_bytes)关键是把 SPS/PPS 写成每个关键帧前的固定前导。更省事的方法是丢给 ffmpeg 处理ffmpeg -f h264 -i input.h264 -vcodec copy -bsf:v h264_mp4toannexb output.h264这个滤镜会自动补参数集。5.2 绿屏或灰屏sprop-parameter-sets 没有被正确还原现象文件能打开但画面是纯绿或纯灰没有任何细节。原因SDP 的sprop-parameter-sets是 base64 编码的字符串比如Z2QAH6wUEQeAqFg。有人直接把这个字符串当二进制写进文件写入的是 ASCII 字符解码器当然不认。必须 base64 解码后前面加起始码再写入。解决用 Python 解码后拼入文件import base64 sps_b64 Z2QAH6wUEQeAqFg pps_b64 aP4sCAAA # 示例实际以设备 SDP 为准 sps base64.b64decode(sps_b64) pps base64.b64decode(pps_b64) # 写入 Annex-B 格式 f.write(b\x00\x00\x00\x01 sps b\x00\x00\x00\x01 pps)注意有的设备 SDP 里 sprop-parameter-sets 用逗号分隔两个字符串前面的解析要按逗号 split然后分别 base64 解码顺序不能颠倒SPS 在前 PPS 在后。这里曾经是我排查最久的玄学问题——抓包文件里明明有 NAL 类型 7 和 8单独用十六进制编辑器看数据也对后来一查 base64 字符串里有多余的换行符解码多了一段现在处理时都会先strip()。5.3 时间戳跳变播放速度异常模糊现象拼出的文件用 VLC 播放画面一顿一顿或者明显加速、减速。原因RTP 时间戳单位是 90000 Hz但对纯存储文件来说播放器会以文件里的时间戳作为 PTS 给解码器调度。如果中间丢了包时间戳差值不是恒定 3000/帧25fps 时每帧差 360030fps 时每帧差 3000播放器会认为发生了跳变导致帧率抖动。还有一种可能是设备的 RTP 时间戳初始值不是 0导致文件开头出现一个很大的 PTS 偏移。解决用 ffprobe 检查帧间隔分布ffprobe -select_streams v -show_frames -show_entries framepkt_pts_time,pts_time stream.h264 21 | grep -E pkt_pts_time | awk {printf %s , $0}发现时间戳差值不均匀时可以用-vsync vfr或者-use_wallclock_as_timestamps 1重新封装ffmpeg -f h264 -use_wallclock_as_timestamps 1 -i stream.h264 -c copy fixed.mp4-use_wallclock_as_timestamps 1的作用是忽略文件里的 PTS改用系统墙钟时间作为时间戳基准能掩盖一部分 RTP 时间戳不连续的问题但这是治标不治本。真正的治本是在 RTP 接收端做 jitter buffer用 RTCP SR 报文里的 NTP 时间戳校准 RTP 时间戳到绝对时刻。5.4 PT 识别错误抓回来的包其实是 H265 或音频现象脚本没有输出任何文件或者输出的一堆 00 00 00 01 后全是乱码。原因动态 PT 不是全局固定值设备 A 的 96 可能是 H264设备 B 的 96 可能是 H265。同一台设备上音频流的 PT 也可能和视频流共用 96-110 区间。抓包时如果端口过滤只用了udp port 5004音频和视频可能都跑在 5004 或相邻端口导致你存下来的文件混入了音频 RTP。解决用 tshark 按 SSRC 分流输出只取视频流 SSRC 对应的包tshark -r rtp_raw.pcapng -Y rtp.ssrc 0x2a3b4c5d -T fields -e rtp.payload video_payload.txtrtp.ssrc 0x2a3b4c5d是十六进制格式的 SSRC 过滤条件按第 2.2 节tshark -e rtp.ssrc输出的值替换即可。H264 的负载特征也很容易区分视频流 NAL 类型值不是 28 就是 24 或者 1-23而音频比如 G711负载第一个字节通常是 0x80 或 0x90 之类不存在 FU-A 逻辑。如果你发现 payload 第一个字节 0x1F得到的值一直在 30 以上且没有 28/24 出现那多半是 H265H265 的 NAL 类型在高位需要换 H265 解析逻辑。5.5 补充注意RTCP 包混入存储流现象输出文件里有大段看不懂的 ASCII 字符播放时从某个点开始黑屏。原因UDP 端口 5004 上不仅有 RTP 数据包还有 RTCP 控制包RTCP 的目标端口通常是 RTP 端口 1但发送端可能把 RTCP 发到同一个端口。RTCP 包的第一个字节是 0x80 或 0x81版本 2、无扩展PT 字段是 200-207SR/RR/SDES/BYE不在有效视频 PT 范围内。解决在脚本的 PT 判断里把 200-207 区间显式排除if pt 200: # RTCP 包跳过不写入文件 continue这个过滤应该放在if pt ! 96: continue之前因为有些设备的 RTCP 包 PT 恰好是 96 的补码或随机值先按 RTCP 判断更稳。排查时可以看抓包文件里 RTP 和 RTCP 的包数比例RTP 包有几百包、RTCP 只有几包是正常的反之就是端口抓错了。6. 验证与进阶用 ffprobe/VLC 判断文件可用性再用 ffmpeg 转封装 MP4拼完的 H264 文件先别急着交差用 ffprobe 做个快速体检比用播放器拖进度条靠谱得多ffprobe -v error -select_streams v:0 -show_streams -show_entries streamcodec_name,profile,width,height,r_frame_rate,pix_fmt stream.h264正常输出应该能看到codec_nameh264、profileHigh或Main、有明确的宽高和r_frame_rate。如果 ffprobe 报Stream #0:0: Video: h264 ... none说明文件尾部缺少结束标记播放器还能勉强兼容但不建议交付。接下来用 ffmpeg 做一次到 MP4 的封装转换ffmpeg -f h264 -i stream.h264 -c:v copy -an -movflags faststart output.mp4-c:v copy不重编码所以速度快-an丢弃音频避免没有音频流时报错faststart优化在线播放。如果这一步报「Could not find codec parameters for stream」或者「Invalid NAL unit」基本可以断定上面某个坑没避开。进阶一点的做法是处理多 SSRC 的合并场景。有的摄像头视频和音频会分别用两个 SSRC 发到同一个端口如果你只想要 H264抓包后先按 SSRC 分组统计tshark -r rtp_raw.pcapng -Y rtp -T fields -e rtp.ssrc -e rtp.payload_type | sort | uniq -c输出里哪个 SSRC 组合的包数量最多、PT 对应 H264就用哪个。如果是长时间录像需求不建议直接拼一个大文件我一般按 GOP 切段每遇到一个 IDR 帧就新开一个文件命名带上 RTP 时间戳。这样即使后续某个分片丢包也只影响一段视频不会污染整个文件。判断 IDR 帧看 NAL 类型 5 或者 FU-A 的 start 位 真实类型为 5 即可。这个做法的另一个好处是配合时间戳转换可以把分片还原成接近真实的秒级时间轴。我早期做这类转换时吃过一次亏直接拿 UDP socket 收包落盘没做 FU-A 乱序重排结果局域网环境也花了十分钟排查才发现是交换机缓冲导致的两个分片对调。后来惯例是先抓 pcap离线把 RTP 包按ts seq排序后再拼实测比实时落盘的文件可用率高得多。如果你只是做间断性录制这个离线排序的思路值得保留——它能用很小的代价避免绝大多数花屏。希望帮到你。本文还有配套的精品资源点击获取
返回列表