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 列表、扩展头、填充字节。写保存工具时不需要实现完整协议栈,但下面几个字段必须读对:
| 字段 | 位宽 | 实际用途 |
|---|---|---|
| 版本 V | 2 bit | 必须为 2,不是 2 直接丢包 |
| 填充 P | 1 bit | 末尾有填充,最后一个字节是填充长度 |
| 扩展 X | 1 bit | 固定头后有扩展,长度按 32 位字计算 |
| CSRC 计数 CC | 4 bit | 固定头实际长度 = 12 + 4×CC |
| 标记 M | 1 bit | 常用来标一帧的最后一个包,但不能完全依赖 |
| 载荷类型 PT | 7 bit | 动态类型 96–127 常见,要和 SDP 对上 |
| 序列号 | 16 bit | 判丢包、乱序,回绕按 16 位处理 |
| 时间戳 | 32 bit | 同一帧内相同,帧间递增,H.264 常见 90 kHz |
| SSRC | 32 bit | 同一端口多路流时用来过滤 |
计算载荷偏移时,先算12 + cc * 4,如果X=1,再读扩展头前 4 个字节,其中后 2 字节是扩展长度,单位是 32 位字,所以还要加4 + ext_len * 4。如果P=1,最后一个字节是填充长度,载荷末尾要减掉。这个偏移算错,后面所有 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 | 直接加起始码写文件 |
| 24 | STAP-A | 一个 RTP 里聚合多个 NAL,按 2 字节长度切 |
| 25 | STAP-B | 带 DON,少见,不要按 STAP-A 硬解 |
| 26 | MTAP16 | 交织封装,少见 |
| 27 | MTAP24 | 交织封装,少见 |
| 28 | FU-A | 分片,首字节是指示,第二字节含 S/E/类型 |
| 29 | FU-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决定固定头后面有没有 CSRC;extension决定要不要跳过扩展头;padding决定末尾要不要减掉填充。少算一个,payload[0] & 0x1F拿到的就不是 NAL 类型。SO_RCVBUF设在bind之前才生效,Linux 下内核会把设置值翻倍,实际可用大小可以用getsockopt再看一眼。
跑起来后观察输出:如果nal_type长期是 28,说明这路流大量使用 FU-A 分片;如果出现 24,说明有 STAP-A 聚合;如果一直是 1–23,说明是单 NAL 打包。marker=1的包通常是一帧的最后一个包,但先别急着依赖它,下一步写文件时还要结合时间戳变化。
3. 写 Annex-B:起始码、SPS/PPS 与写盘顺序
RTP 拆出来的 NAL 不能直接拼成.h264。裸流文件需要 Annex-B 格式,每个 NAL 前面有起始码,而且 SPS/PPS 必须出现在 IDR 之前。很多“文件有大小、播放器不识别”的问题,根源不是收包,而是写盘格式不对。
3.1 裸流为什么必须是 Annex-B
H.264 有两种常见封装:Annex-B 用00 00 00 01或00 00 01分隔 NAL;AVCC 用 4 字节大端长度前缀。.h264裸流一般按 Annex-B 处理,.mp4里的视频轨才用长度前缀。你把 AVCC 格式直接写成.h264,ffplay 可能报“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。例如a=fmtp:96 packetization-mode=1;profile-level-id=42e01f;sprop-parameter-sets=Z0LgH...,aM48g...,逗号前是 SPS 的 Base64,逗号后是 PPS。 - 带内发送。有些编码器在 IDR 前先发 SPS/PPS,RTP 里能看到
nal_type=7和nal_type=8。 - 抓包工具里手动提取。只有前两种都拿不到时才用,容易抄错。
从 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_type=7和nal_type=8,等第一个 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, sps=None, pps=None): 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(f"seq gap: {self.last_seq} -> {seq}, gap={gap}", file=sys.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(f"listening {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_port | RTP 端口 | 常用偶数端口,5004 只是示例 |
SO_RCVBUF | 内核收包缓冲 | 2–8 MB,先设再 bind |
out_path | 输出裸流 | 先用短文件名,避免路径权限问题 |
sps/pps | 参数集 | 能从 SDP 拿就从 SDP 拿,拿不到靠带内 |
_write_nal里做了两件事:遇到 SPS/PPS 就缓存,遇到 IDR 且参数集还没写就补写。这样即使发送端没有在 IDR 前发参数集,只要之前收到过 SPS/PPS,文件头也能补上。STAP-A 分支按 2 字节长度循环,注意i += 2和i += size不能漏,否则会死循环或切出错误 NAL。FU-A 分支只在start=1时新建 buffer,end=1时写盘,中间分片只追加数据。marker=1时 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 直接丢弃,等到下一个start=1或下一个 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和入队,写盘放到单独线程;减少收包循环里的print;SO_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 stream=codec_name,profile,width,height,pix_fmt,level \ -of default=nw=1 out.h264正常输出会包含codec_name=h264、profile=High或Constrained Baseline、width/height、pix_fmt=yuv420p。如果 ffprobe 直接报错,优先查起始码和 SPS/PPS;如果 profile 显示unknown,说明参数集没写对。
再看帧数:
ffprobe -v error -count_frames -select_streams v:0 \ -show_entries stream=nb_read_frames -of csv=p=0 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 帧做完整解码,最后才把保存脚本挂到长时间任务上。这个习惯不高级,但能挡住大部分“文件有大小、画面不对”的返工。希望帮到你。
本文还有配套的精品资源,点击获取