RTP H.264 保存为 .h264:拆包重组与 Annex-B 写盘避坑

发布时间:2026/10/28 3:41:38
RTP H.264 保存为 .h264:拆包重组与 Annex-B 写盘避坑 简介这份代码包聚焦RTP承载H264的解析与落盘面向具备C和网络编程基础、正在做监控视频、流媒体转发或UDP实时取流的开发者可用于把摄像头RTP数据重组为可播放的H264文件。压缩包共14个文件以6个h头文件和4个cpp源文件为主体另含Visual Studio工程、筛选器、用户配置及说明文档整体约9KB体量轻便便于导入IDE查阅和调试。已有1032人学习下载适合作为协议解析与工程实现的对照参考。代码围绕服务端接收与公共工具模块展开涵盖RTP包头解析、NAL单元边界识别与重组、UDP数据接收以及H264 Annex B写入等环节读者可据此理解分包处理、顺序恢复与丢失标记修复思路并在此基础上改造成自己的取流落盘工具。对于需要排查花屏、丢包与时间戳错位问题的读者也能从代码结构中获取可复用的处理线索。1. RTP 数据保存为 .h264 文件先分清收到的是流还是帧把 UDP 上的 rtp h264 数据保存成一个能双击播放的 .h264 文件看起来只是收包加写文件实际最容易出问题的地方在载荷层RTP 头、FU-A 分片、STAP-A 聚合、Annex-B 起始码任何一处对不上文件就会“有体积、没画面”。这份资源适合两类人一是做 IPC、录播、无线图传的嵌入式或后端工程师手里有 RTP 流但需要一个最小可用的保存工具二是刚接触 H.264 封装、想从抓包一路走到可播放裸流的学习者。下面按“拆包、重组、写盘、验收”的顺序把可参考的脚本和实际踩过的坑一次讲清。2. 拆 RTP 载荷12 字节头、三种封装与 NAL 边界RTP 收到的是“包”H.264 解码器要的是“NAL 单元”。中间这层转换并不复杂但字段含义必须抠准。我见过最常见的误操作是把recvfrom收到的整段 buffer 直接追加到文件末尾结果文件里混进了 RTP 头、FU-A 分片头播放器只能靠运气解出第一帧。2.1 RTP 固定头里真正要用的四个字段RTP 固定头最少 12 字节后面还可能跟 CSRC 列表、扩展头、填充字节。写保存工具时不需要实现完整协议栈但下面几个字段必须读对字段位宽实际用途版本 V2 bit必须为 2不是 2 直接丢包填充 P1 bit末尾有填充最后一个字节是填充长度扩展 X1 bit固定头后有扩展长度按 32 位字计算CSRC 计数 CC4 bit固定头实际长度 12 4×CC标记 M1 bit常用来标一帧的最后一个包但不能完全依赖载荷类型 PT7 bit动态类型 96–127 常见要和 SDP 对上序列号16 bit判丢包、乱序回绕按 16 位处理时间戳32 bit同一帧内相同帧间递增H.264 常见 90 kHzSSRC32 bit同一端口多路流时用来过滤计算载荷偏移时先算12 cc * 4如果X1再读扩展头前 4 个字节其中后 2 字节是扩展长度单位是 32 位字所以还要加4 ext_len * 4。如果P1最后一个字节是填充长度载荷末尾要减掉。这个偏移算错后面所有 NAL 解析都会错位。序列号是 16 位无符号数从 65535 回到 0 是正常回绕。判断丢包不要写seq ! last_seq 1而要用((seq - last_seq) 0xFFFF) ! 1。时间戳也不是毫秒H.264 常用 90 kHz 时钟同一帧的多个分片时间戳相同换帧才递增。标记位 M 在 H.264 里通常表示一个接入单元结束但有些发送端按切片设置不能把它当成唯一的帧边界。2.2 单 NAL、STAP-A、FU-A三种封装怎么识别H.264 的 RTP 载荷格式由 NAL 头低 5 位决定常见就三类nal_type名称处理方式1–23单 NAL直接加起始码写文件24STAP-A一个 RTP 里聚合多个 NAL按 2 字节长度切25STAP-B带 DON少见不要按 STAP-A 硬解26MTAP16交织封装少见27MTAP24交织封装少见28FU-A分片首字节是指示第二字节含 S/E/类型29FU-B带 DON 的分片少见单 NAL 最好处理载荷本身就是完整 NAL前面补00 00 00 01就能写。STAP-A 的载荷第 1 字节是聚合头后面重复“2 字节大端长度 NAL 数据”要循环切完。FU-A 最容易写错第 1 字节的低 5 位是 28高 3 位是原始 NAL 的 F 和 NRI第 2 字节最高位 S 表示分片开始次高位 E 表示分片结束低 5 位才是原始 NAL 类型。重组时原始 NAL 头是(fu_ind 0xE0) | (fu_hdr 0x1F)不是直接把两个字节都塞进去。FU-A 分片只在第一片写一次 NAL 头中间分片只追加数据最后一片收齐后整体加起始码写文件。很多人给每个分片都加起始码解码器会把每个分片当成独立 NAL第一帧之后基本就废了。STAP-A 里如果带 SPS/PPS也要按顺序写出来不要只取第一个 NAL。2.3 用 Python 解出第一层UDP 收包与 RTP 头解析先不写文件只把 RTP 头解析出来打印确认序列号、时间戳、标记位和 NAL 类型符合预期。这一步花十分钟后面能省掉大量“文件为什么不能播”的排查。import socket import struct def parse_rtp(pkt): if len(pkt) 12: return None b0, b1 pkt[0], pkt[1] version b0 6 if version ! 2: return None padding (b0 5) 0x01 extension (b0 4) 0x01 cc b0 0x0F marker (b1 7) 0x01 pt b1 0x7F seq, ts, ssrc struct.unpack(!HII, pkt[2:12]) offset 12 cc * 4 if extension: if len(pkt) offset 4: return None ext_len struct.unpack(!H, pkt[offset 2:offset 4])[0] offset 4 ext_len * 4 end len(pkt) if padding: pad_len pkt[-1] end - pad_len payload pkt[offset:end] if not payload: return None nal_type payload[0] 0x1F return { seq: seq, ts: ts, ssrc: ssrc, pt: pt, marker: marker, nal_type: nal_type, payload: payload, } sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) sock.bind((0.0.0.0, 5004)) while True: pkt, addr sock.recvfrom(65535) info parse_rtp(pkt) if not info: continue print( info[seq], info[ts], info[marker], info[pt], info[nal_type], len(info[payload]) )这段代码的重点不在print而在三个边界cc决定固定头后面有没有 CSRCextension决定要不要跳过扩展头padding决定末尾要不要减掉填充。少算一个payload[0] 0x1F拿到的就不是 NAL 类型。SO_RCVBUF设在bind之前才生效Linux 下内核会把设置值翻倍实际可用大小可以用getsockopt再看一眼。跑起来后观察输出如果nal_type长期是 28说明这路流大量使用 FU-A 分片如果出现 24说明有 STAP-A 聚合如果一直是 1–23说明是单 NAL 打包。marker1的包通常是一帧的最后一个包但先别急着依赖它下一步写文件时还要结合时间戳变化。3. 写 Annex-B起始码、SPS/PPS 与写盘顺序RTP 拆出来的 NAL 不能直接拼成.h264。裸流文件需要 Annex-B 格式每个 NAL 前面有起始码而且 SPS/PPS 必须出现在 IDR 之前。很多“文件有大小、播放器不识别”的问题根源不是收包而是写盘格式不对。3.1 裸流为什么必须是 Annex-BH.264 有两种常见封装Annex-B 用00 00 00 01或00 00 01分隔 NALAVCC 用 4 字节大端长度前缀。.h264裸流一般按 Annex-B 处理.mp4里的视频轨才用长度前缀。你把 AVCC 格式直接写成.h264ffplay 可能报“Invalid NAL unit size”因为解码器把长度字段当成起始码在找。起始码用 4 字节更稳00 00 00 01。3 字节也合法但和某些 NAL 数据开头的00 00 01容易混淆调试时不好判断。写文件时统一用 4 字节读的时候两种都认能减少一半玄学问题。还有一个细节不要在每个 RTP 包前加起始码而要在每个完整 NAL 前加。单 NAL 包好办FU-A 必须等最后一片收齐再写STAP-A 要拆成多个 NAL 分别写。这个顺序错了文件看起来很大实际解码器只能认出第一帧。3.2 SPS/PPS 从哪来SDP、带内与首包缓存SPS/PPS 是解码器的“说明书”没有它们IDR 帧也解不出来。常见来源有三个SDP 里的sprop-parameter-sets。例如afmtp:96 packetization-mode1;profile-level-id42e01f;sprop-parameter-setsZ0LgH...,aM48g...逗号前是 SPS 的 Base64逗号后是 PPS。带内发送。有些编码器在 IDR 前先发 SPS/PPSRTP 里能看到nal_type7和nal_type8。抓包工具里手动提取。只有前两种都拿不到时才用容易抄错。从 SDP 解参数集可以这样写import base64 # 从 SDP 的 fmtp 行里取逗号前后分别是 SPS 和 PPS 的 Base64 sprop base64_sps,base64_pps sps_b64, pps_b64 sprop.split(,) sps base64.b64decode(sps_b64) pps base64.b64decode(pps_b64) with open(out.h264, wb) as f: f.write(b\x00\x00\x00\x01 sps) f.write(b\x00\x00\x00\x01 pps)如果 SDP 里没有sprop-parameter-sets就在收流时缓存nal_type7和nal_type8等第一个 IDR 到来前补写。带内参数集可能会变比如分辨率切换遇到新的 SPS/PPS 要重新写不要只认第一次。提示profile-level-id只能说明档次和级别不能代替 SPS/PPS。只写这个字段解码器仍然没有完整的参数集。3.3 完整的收流写文件脚本下面这个脚本把 UDP 收包、RTP 解析、FU-A 重组、STAP-A 拆分、Annex-B 写盘串在一起。它不依赖第三方库适合先跑通最小链路再按项目需要加队列、切片和重连。import socket import struct import sys START_CODE b\x00\x00\x00\x01 class H264Writer: def __init__(self, path, spsNone, ppsNone): self.f open(path, wb) self.sps sps self.pps pps # 如果 SDP 已经给了参数集先写文件头 if self.sps and self.pps: self.f.write(START_CODE self.sps) self.f.write(START_CODE self.pps) self.param_written True else: self.param_written False self.fu_buf None self.last_seq None def _write_nal(self, nal): if not nal: return nal_type nal[0] 0x1F if nal_type 7: self.sps nal elif nal_type 8: self.pps nal # 没有 SDP 参数集时在第一个 IDR 前补写缓存的 SPS/PPS if nal_type 5 and not self.param_written and self.sps and self.pps: self.f.write(START_CODE self.sps) self.f.write(START_CODE self.pps) self.param_written True self.f.write(START_CODE nal) # 带内收到 SPS/PPS 也算已写参数集 if nal_type in (7, 8) and self.sps and self.pps: self.param_written True def push(self, pkt): if len(pkt) 12: return b0, b1 pkt[0], pkt[1] if (b0 6) ! 2: return cc b0 0x0F extension (b0 4) 0x01 padding (b0 5) 0x01 marker (b1 7) 0x01 seq, ts, ssrc struct.unpack(!HII, pkt[2:12]) offset 12 cc * 4 if extension: if len(pkt) offset 4: return ext_len struct.unpack(!H, pkt[offset 2:offset 4])[0] offset 4 ext_len * 4 end len(pkt) if padding: end - pkt[-1] payload pkt[offset:end] if not payload: return # 序列号不连续只记录不在这里直接丢流 if self.last_seq is not None: gap (seq - self.last_seq) 0xFFFF if gap ! 1: print(fseq gap: {self.last_seq} - {seq}, gap{gap}, filesys.stderr) self.last_seq seq nal_type payload[0] 0x1F if nal_type 28: # FU-A fu_ind payload[0] fu_hdr payload[1] start (fu_hdr 7) 0x01 end_flag (fu_hdr 6) 0x01 real_type fu_hdr 0x1F if start: self.fu_buf bytearray() # 重建原始 NAL 头F/NRI 来自 fu_ind类型来自 fu_hdr self.fu_buf.append((fu_ind 0xE0) | real_type) if self.fu_buf is None: return self.fu_buf.extend(payload[2:]) if end_flag: self._write_nal(bytes(self.fu_buf)) self.fu_buf None elif nal_type 24: # STAP-A i 1 while i 2 len(payload): size struct.unpack(!H, payload[i:i 2])[0] i 2 nal payload[i:i size] i size if nal: self._write_nal(nal) elif 1 nal_type 23: self._write_nal(payload) if marker: self.f.flush() def close(self): # 不完整的 FU-A 直接丢弃避免写坏帧 self.fu_buf None self.f.close() if __name__ __main__: listen_ip 0.0.0.0 listen_port 5004 out_path out.h264 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 * 1024 * 1024) sock.bind((listen_ip, listen_port)) # 参数集可从 SDP 解出来后传进来这里留空表示依赖带内 SPS/PPS writer H264Writer(out_path) print(flistening {listen_ip}:{listen_port} - {out_path}) try: while True: pkt, _ sock.recvfrom(65535) writer.push(pkt) except KeyboardInterrupt: pass finally: writer.close() sock.close()代码里的参数不多但每个都影响结果参数作用建议listen_ip监听网卡本机测试用0.0.0.0生产可绑定具体网卡listen_portRTP 端口常用偶数端口5004 只是示例SO_RCVBUF内核收包缓冲2–8 MB先设再 bindout_path输出裸流先用短文件名避免路径权限问题sps/pps参数集能从 SDP 拿就从 SDP 拿拿不到靠带内_write_nal里做了两件事遇到 SPS/PPS 就缓存遇到 IDR 且参数集还没写就补写。这样即使发送端没有在 IDR 前发参数集只要之前收到过 SPS/PPS文件头也能补上。STAP-A 分支按 2 字节长度循环注意i 2和i size不能漏否则会死循环或切出错误 NAL。FU-A 分支只在start1时新建 bufferend1时写盘中间分片只追加数据。marker1时 flush是为了让文件在录制过程中也能被播放器读到完整帧不是必须但调试方便。4. 避坑与排查文件有大小却没画面的五种现场4.1 文件几十 MB播放器只显示第一帧现象.h264文件体积不小ffplay 能出第一帧之后黑屏或卡住。原因最常见的是把 RTP 头一起写进了文件或者给每个 FU-A 分片都加了起始码。解码器在第一帧碰巧能同步后面读到的是“起始码 FU-A 指示字节 分片数据”NAL 类型对不上直接停止。解决用十六进制工具看文件头正常应该是00 00 00 01 67或00 00 00 01 65开头。如果看到80 60这类 RTP 头字节说明偏移算错如果每个分片前都有00 00 00 01把起始码移到 FU-A 重组完成之后。4.2 画面下半部绿块几秒后花屏现象能播但画面局部绿块、马赛克运动场景更明显。原因FU-A 丢包或乱序。一个 FU-A 帧少一个分片整帧数据不完整如果脚本还把不完整的分片写进文件解码器会继续用坏帧预测后面几帧也跟着花。解决按序列号做小窗口重排窗口不用大2–4 个包足够发现丢包时当前 FU-A 直接丢弃等到下一个start1或下一个 IDR 再继续写。宁可少一帧不要把半帧写进文件。日志里记录seq gap方便判断是网络丢包还是处理太慢。4.3 ffplay 报 Invalid NAL unit size 或直接退出现象ffplay 打不开文件ffprobe 报 “Invalid data found when processing input”。原因写成了 AVCC 长度前缀或者起始码字节序写错。比如把00 00 00 01写成01 00 00 00或者把 NAL 长度当成起始码写进去。解决确认每个 NAL 前是00 00 00 01不是 4 字节长度。用xxd -l 64 out.h264看一眼前几个字节应该是0000 0001 67或0000 0001 68。如果是0000 001f 67这种说明写的是长度字段。4.4 收流几分钟后开始丢包文件时间轴断裂现象刚开始正常几分钟后花屏增多日志里seq gap密集出现。原因recvfrom处理太慢或者收包循环里做了阻塞写盘、打印、加锁。UDP 没有重传内核缓冲区一满就丢包。SO_RCVBUF设得太小也会提前丢。解决收包线程只做recvfrom和入队写盘放到单独线程减少收包循环里的printSO_RCVBUF调到 4–8 MB并在bind之前设置。如果还丢检查网卡中断和 CPU 占用不要先怀疑协议。4.5 同一端口换一路流文件里混进别的画面现象一个端口上跑多路 RTP保存出来的文件画面跳来跳去。原因没有按 SSRC 或载荷类型过滤。RTP 允许同一端口多个 SSRC动态 PT 也可能不同。脚本如果见包就收自然会把两路流拼在一起。解决从 SDP 或首包确定目标 SSRC后续只处理相同 SSRC 的包同时校验 PT 是否在预期范围。多路流最好分端口实在要复用就在脚本里加过滤条件别等文件坏了再回头查。5. 验收用 ffprobe 和 ffmpeg 把“能播”拆成可检查项文件写完不等于任务完成。我一般会跑三步验收ffprobe 看流参数ffmpeg 解码看错误抓包对照看包数。三步都过再交给别人用。5.1 ffprobe 看 profile、分辨率、帧数先看基础信息ffprobe -v error -select_streams v:0 \ -show_entries streamcodec_name,profile,width,height,pix_fmt,level \ -of defaultnw1 out.h264正常输出会包含codec_nameh264、profileHigh或Constrained Baseline、width/height、pix_fmtyuv420p。如果 ffprobe 直接报错优先查起始码和 SPS/PPS如果 profile 显示unknown说明参数集没写对。再看帧数ffprobe -v error -count_frames -select_streams v:0 \ -show_entries streamnb_read_frames -of csvp0 out.h264这个数字要和发送端大致对得上。如果只有 1 帧说明 IDR 之后的数据没被识别如果帧数明显偏少回查 FU-A 重组和丢包处理。裸流没有容器时间戳ffprobe 的帧率是猜的所以不要拿它判断实际帧率帧数更有参考价值。5.2 ffmpeg 转 mp4 与解码核对把裸流转成 mp4能快速判断是文件问题还是播放器问题ffmpeg -fflags genpts -r 25 -i out.h264 -c copy out.mp4-r 25是给裸流一个输入帧率因为.h264本身不携带帧率。实际帧率不是 25 就换成对应的值否则 mp4 的时长会不准。-c copy不重新编码只换容器能播说明码流基本正常不能播再回头查 Annex-B。再做一次完整解码专门看错误输出ffmpeg -v error -i out.h264 -f null -这条命令不生成文件只解码。如果终端不断刷 “error while decoding” 或 “no frame”说明码流有坏帧或参数集缺失。少量错误可能是丢包导致大量错误就要查重组逻辑。检查项命令或观察点异常含义文件头xxd -l 32 out.h264不是00000001开头起始码错流参数ffprobe 看 profile/分辨率unknown 或缺失SPS/PPS 没写帧数-count_frames只有 1 帧FU-A 重组或 IDR 丢失解码错误ffmpeg -v error大量报错坏帧或丢包容器转换转 mp4 后播放裸流能播、mp4 不能播查时间戳或帧率5.3 抓包对照用 tshark 验证 RTP 包数有 pcap 的话直接对比 RTP 序列号和标记位tshark -r rtp.pcap -Y rtp \ -T fields -e rtp.seq -e rtp.timestamp -e rtp.marker -e rtp.p_type \ | head -n 20看三件事序列号是否连续时间戳是否按帧递增标记位是否在预期位置。序列号缺口和脚本日志里的seq gap对得上说明丢包发生在网络或内核缓冲区对不上可能是脚本解析偏移错了。rtp.p_type用来确认动态载荷类型别把 96 和 97 两路流混在一起。6. 把脚本变成工具丢包、乱序与长流的三个硬约束最小脚本跑通之后真正决定能不能长期用的是三个约束丢包策略、乱序窗口、长流切片。这三个点不处理录十分钟可能没问题录两小时一定出状况。约束常见做法代价丢包丢到下一个 IDR或记录空洞继续写前者少一段画面后者可能花屏乱序2–4 个包的小重排窗口延迟增加几毫秒长流按大小或时长切片每片重写 SPS/PPS文件变多但可定位、可恢复丢包策略没有标准答案。做监控回放我一般选择“丢到下一个 IDR”因为花屏帧进了文件后面几秒都跟着坏回放体验更差做链路质量分析反而要保留空洞并记录序列号方便定位是哪一段网络出了问题。关键是别默默把半帧写进去至少打一条日志。乱序窗口不要开太大。RTP 走 UDP真正乱序通常发生在几包之内2–4 个包足够窗口太大收包线程要等延迟上来实时性就没了。实现上可以用一个按序列号排序的小字典收到连续包就往上写超时或缺口太大就跳过。长流切片建议按 200–500 MB 或 10–30 分钟切一次每片重新写 SPS/PPS。这样即使某一片损坏其他片还能单独播。切片时注意不要在 FU-A 中间切等一个完整帧写完再换文件。文件名带上序号或时间后面排查会轻松很多。从那以后我每次接一路新流都先跑一遍 ffprobe 看 profile 和分辨率再抽前 200 帧做完整解码最后才把保存脚本挂到长时间任务上。这个习惯不高级但能挡住大部分“文件有大小、画面不对”的返工。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询