简介:这是一份面向网络视频开发者的RTP转H264实战代码资源,聚焦将UDP传输的RTP数据包解析并保存为H264文件这一典型场景,适用于监控系统、在线会议、远程教育等需要实时读取摄像头数据的项目。资源包共14个文件,约9KB,以6个.h头文件与4个.cpp源文件为核心,辅以vcxproj工程文件、filters过滤配置及ReadMe说明,代码围绕RTP解封装、H264 NAL单元重组与文件写入展开,涵盖rtpdecode、MyUdp、CommonCode等模块,便于读者理解RTP头部解析、NAL边界识别与Annex B格式落盘流程。目前已有1032人学习下载,适合具备一定网络编程与视频编码基础、希望快速搭建RTP接收端并落地H264存储的开发者参考,可据此梳理UDP收包、NAL重组到文件输出的完整链路与排错思路。
1. 从一堆 UDP 包里捞出能播的 H264:这个项目到底在干什么
抓过流的人都有体会:Wireshark 里 RTP 包刷得飞快,Payload Type 明明白白写着 96,可你把这堆包原样存成.h264,播放器要么黑屏要么花屏,因为 RTP 是传输层封装,H264 是编码层裸流,中间隔着 RTP 头、分片标记、时间戳乱序这几道坎。这个项目干的事就一句话:把 UDP 上收到的 RTP 包,按 H264 的 NAL 单元重新拼装,落成一个能直接被播放器或 FFmpeg 吃进去的.h264文件。它适合两类人——一类是做安防、直播、录播,手里有裸 RTP 流但没有现成录制工具的开发者;另一类是想搞懂 RTP 拆包组包到底怎么回事,拿一份能跑的最小实现来改的工程师。核心关键词就俩:rtp、h264,但真正值钱的是中间那层「去头、排序、拼分片」的逻辑。
2. 拆开看结构:UDP 收包、RTP 解析、H264 落盘三段式
2.1 整体数据流与模块划分
这类工具的结构其实很固定,不管用什么语言写,骨架都是三段:收包层负责从 UDP socket 拿数据报,解析层负责剥 RTP 头、判断 NAL 类型、处理 FU-A 分片,落盘层负责把拼好的 NAL 前面补上00 00 00 01起始码写进文件。中间通常还会插一个缓冲队列,因为 UDP 不保证顺序,网络抖动时包会乱序到达,直接写文件必然花屏。
我一般会把目录拆成这几个文件,方便二次开发时替换其中一层:
rtp2h264/ ├── main.py # 入口,解析命令行参数、启动收包循环 ├── udp_receiver.py # UDP socket 封装,负责 recvfrom ├── rtp_parser.py # RTP 头解析 + H264 分片重组 ├── h264_writer.py # NAL 落盘,补起始码 └── config.py # 端口、输出路径、超时等参数这么拆的好处是:换传输协议只动udp_receiver.py,换编码格式只动rtp_parser.py,落盘策略要改(比如按时间切片)只动h264_writer.py。新手最容易犯的错是把这三件事全塞进一个while True里,跑是能跑,但一改就崩。
2.2 RTP 头到底长什么样
要拼包,先得会读 RTP 头。RTP 固定头 12 字节,字段分布是这样的:
| 字段 | 位数 | 含义 |
|---|---|---|
| V | 2 | 版本号,一般是 2 |
| P | 1 | 填充位 |
| X | 1 | 扩展头标志 |
| CC | 4 | CSRC 计数 |
| M | 1 | 标记位,一帧最后一个包置 1 |
| PT | 7 | 负载类型,H264 通常是 96 |
| Sequence | 16 | 序列号,用来排序和检测丢包 |
| Timestamp | 32 | 时间戳,同一帧的包时间戳相同 |
| SSRC | 32 | 同步源标识 |
解析代码大概是这样,用 Python 的struct直接按位取:
import struct def parse_rtp_header(data): """解析 RTP 固定头,返回字段字典和负载起始偏移""" if len(data) < 12: return None # !BBHII 对应:版本/填充/扩展/CC 一个字节,M/PT 一个字节,序列号,时间戳,SSRC b1, b2, seq, ts, ssrc = struct.unpack('!BBHII', data[:12]) version = b1 >> 6 padding = (b1 >> 5) & 0x01 extension = (b1 >> 4) & 0x01 cc = b1 & 0x0F marker = b2 >> 7 pt = b2 & 0x7F # 头部长度 = 12 + CSRC*4 + 扩展头(若有) header_len = 12 + cc * 4 if extension: # 扩展头前 4 字节是 profile 和长度 ext_len = struct.unpack('!H', data[header_len + 2:header_len + 4])[0] header_len += 4 + ext_len * 4 return { 'version': version, 'marker': marker, 'pt': pt, 'seq': seq, 'ts': ts, 'ssrc': ssrc, 'payload': data[header_len:] }这里几个参数要盯死:marker决定一帧什么时候结束,seq用来判断丢包和乱序,ts用来区分不同帧。header_len的计算别偷懒,带 CSRC 或扩展头的流如果按固定 12 字节切,负载会整体错位,出来的文件全是绿的。
2.3 H264 的 NAL 与 FU-A 分片
剥完 RTP 头,负载第一个字节是 NAL 头,格式是F(1) | NRI(2) | Type(5)。Type 决定这个包是什么:
- Type 1~23:单个 NAL 单元,直接落盘
- Type 24:STAP-A 聚合包,一个 RTP 包里塞了多个 NAL
- Type 28:FU-A 分片,一个 NAL 被切成多个 RTP 包
- Type 29:FU-B 分片,带 DON 字段
实际网络里最常见的是 FU-A,因为一个 I 帧动辄几十 KB,远超 MTU,必须分片。FU-A 的负载结构是:第一个字节是 FU indicator(和 NAL 头同格式),第二个字节是 FU header,其中 S 位表示分片开始,E 位表示分片结束,Type 是原始 NAL 类型。重组逻辑就是:遇到 S 位开新缓冲,中间包往缓冲里追加,遇到 E 位把缓冲补上真正的 NAL 头再落盘。
class H264Reassembler: def __init__(self): self.buffer = b'' self.in_fragment = False def feed(self, payload): """喂入一个 RTP 负载,返回完整的 NAL 列表(可能为空)""" nal_type = payload[0] & 0x1F out = [] if nal_type == 28: # FU-A fu_header = payload[1] start = fu_header & 0x80 end = fu_header & 0x40 orig_type = fu_header & 0x1F if start: # 用 FU indicator 的高 3 位 + 原始 type 重建 NAL 头 self.buffer = bytes([(payload[0] & 0xE0) | orig_type]) + payload[2:] self.in_fragment = True elif self.in_fragment: self.buffer += payload[2:] if end and self.in_fragment: out.append(self.buffer) self.buffer = b'' self.in_fragment = False elif nal_type in (1, 5, 7, 8): # 单包 NAL out.append(payload) return out(payload[0] & 0xE0) | orig_type这行是精髓:FU indicator 的高 3 位(F 和 NRI)要保留,低 5 位换成原始 NAL 类型,这样重建出来的 NAL 头才合法。漏了这步,SPS/PPS 能过,但 I 帧解码直接报错。
3. 跑起来:从命令行到第一个能播的文件
3.1 环境与依赖
这个项目对依赖极其克制,Python 3.7+ 标准库就够,socket、struct、argparse全在标准库里,不需要pip install任何东西。这也是我推荐用 Python 做原型的原因——先把逻辑跑通,性能不够再换 C++ 或 Go。如果你要处理 4K 高码率流,Python 的 GIL 会成为瓶颈,那时候再考虑用asyncio或者直接上 Go 的net.UDPConn。
运行前确认两件事:一是防火墙放行目标 UDP 端口,二是确认发送端确实在往这个端口推 RTP。我见过太多人代码没问题,卡在端口没开或者发流端根本没启动。
3.2 启动命令与参数
入口用argparse接参数,典型调用长这样:
python main.py --port 5004 --output out.h264 --timeout 30参数含义:
--port:监听的 UDP 端口,要和发流端一致--output:输出文件路径,建议带.h264后缀--timeout:多久没收到包就退出,避免程序挂死
对应的入口代码:
import argparse, socket from rtp_parser import parse_rtp_header, H264Reassembler from h264_writer import H264Writer def main(): ap = argparse.ArgumentParser() ap.add_argument('--port', type=int, default=5004) ap.add_argument('--output', default='out.h264') ap.add_argument('--timeout', type=int, default=30) args = ap.parse_args() sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(('0.0.0.0', args.port)) sock.settimeout(args.timeout) writer = H264Writer(args.output) reassembler = H264Reassembler() last_seq = None while True: try: data, _ = sock.recvfrom(65535) except socket.timeout: print('超时退出') break rtp = parse_rtp_header(data) if rtp is None: continue # 丢包检测:序列号不连续就记一笔 if last_seq is not None and rtp['seq'] != (last_seq + 1) & 0xFFFF: print(f"丢包: {last_seq} -> {rtp['seq']}") last_seq = rtp['seq'] for nal in reassembler.feed(rtp['payload']): writer.write_nal(nal) writer.close() if __name__ == '__main__': main()recvfrom(65535)的缓冲区给到最大,避免大包被截断。settimeout是保命用的,不然发流端一停,程序就永远阻塞在那。丢包检测那几行不是必须的,但调试阶段非常有用,能立刻告诉你花屏是丢包引起的还是拼包逻辑写错了。
3.3 落盘:起始码不能少
H264 裸流里每个 NAL 前面要有起始码,3 字节的00 00 01或 4 字节的00 00 00 01。写文件时统一用 4 字节,兼容性最好:
class H264Writer: def __init__(self, path): self.fp = open(path, 'wb') def write_nal(self, nal): """写入一个完整 NAL,前面补 4 字节起始码""" self.fp.write(b'\x00\x00\x00\x01') self.fp.write(nal) def close(self): self.fp.close()别小看这 4 个字节,漏了它文件大小对得上,但播放器识别不出 NAL 边界,直接判定为损坏文件。另外 SPS 和 PPS(Type 7 和 8)必须在第一个 I 帧之前写进去,否则解码器不知道分辨率、profile 这些参数。有些发流端会把 SPS/PPS 放在带内周期性重发,有些只在开头发一次,如果你的文件播出来是灰的,八成是 SPS/PPS 没抓到。
4. 避坑排查:那些让你怀疑人生的花屏和黑屏
4.1 文件能播但满屏马赛克
现象:文件用 VLC 能打开,但画面大面积绿块、马赛克,声音正常。原因:丢包导致 I 帧不完整,或者 FU-A 重组时中间少了包,缓冲里拼出来的 NAL 是残缺的。解决:在feed里加完整性校验,FU-A 的 start 和 end 必须成对出现,如果收到新的 start 时上一个还没 end,说明中间丢了包,直接丢弃当前缓冲重新开始,别硬拼。同时把丢包日志打开,确认是不是网络本身在丢。
4.2 播放器直接报「无法识别」
现象:文件有大小,但播放器打不开,FFmpeg 报Invalid data found。原因:起始码写错了,或者 NAL 头被破坏。常见的是 FU-A 重组时忘了重建 NAL 头,直接把 FU indicator 当 NAL 头写进去了。解决:用ffprobe out.h264看能不能识别出流信息,再用十六进制工具看文件开头是不是00 00 00 01 67(67 是 SPS 的 NAL 类型)。如果开头是00 00 00 01 7C,那就是 FU indicator 没换掉。
4.3 只有第一帧,后面全黑
现象:第一帧画面正常,之后一直黑屏。原因:marker 位没处理,或者时间戳判断有误,导致后续帧的 NAL 被当成同一帧的延续丢掉了。解决:检查parse_rtp_header里 marker 的取值,b2 >> 7拿到的是 0 或 1。一帧结束时 marker 为 1,此时应该 flush 缓冲。如果发流端不规范,marker 一直是 0,那就只能靠时间戳变化来判断帧边界。
4.4 程序跑一会儿就卡死
现象:收了几百个包后recvfrom不再返回。原因:UDP 接收缓冲区满了,内核开始丢包,而你的处理速度跟不上。解决:把sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4*1024*1024)加上,把接收缓冲区调大。如果还不行,说明单线程处理不过来,得把落盘放到独立线程,收包线程只管往队列里塞。
4.5 换了个流就拼不对
现象:同一个程序,A 流正常,B 流全是花屏。原因:不同发流端的 RTP 封装习惯不同,有的用 STAP-A 聚合,有的带扩展头,有的 PT 不是 96。解决:别硬编码 PT 和 NAL 类型判断,把parse_rtp_header里的 PT 检查去掉,改成看负载第一个字节的 NAL 类型。STAP-A 要单独处理,它的负载是[STAP-A头][NAL1长度][NAL1][NAL2长度][NAL2]...,得循环拆。
5. 二次开发:把它改成能按时间切片、能接 RTSP 的录制器
5.1 按时间切片落盘
原始实现是单文件写到死,实际项目里往往要按分钟切片,方便回放和清理。改造点在H264Writer,加一个时间判断:
import time, os class SegmentWriter: def __init__(self, out_dir, segment_seconds=60): self.out_dir = out_dir self.segment_seconds = segment_seconds self.start_time = time.time() self.fp = None self._open_new() def _open_new(self): if self.fp: self.fp.close() name = time.strftime('%Y%m%d_%H%M%S') + '.h264' self.fp = open(os.path.join(self.out_dir, name), 'wb') self.start_time = time.time() def write_nal(self, nal): if time.time() - self.start_time > self.segment_seconds: self._open_new() self.fp.write(b'\x00\x00\x00\x01') self.fp.write(nal)这里有个坑:切片不能随便切,必须切在 I 帧之前,否则新文件开头是 P 帧,解码器没有参考帧,前几秒全是花的。稳妥做法是维护一个「当前 NAL 是不是 I 帧」的标志,只有遇到 I 帧且超过切片时间才真正切。判断 I 帧看 NAL 类型是不是 5。
5.2 接 RTSP 流而不是裸 UDP
很多场景下你拿不到裸 RTP,只有 RTSP 地址。这时候不用重写解析层,只要把收包层换掉,用 RTSP 的DESCRIBE/SETUP/PLAY协商出 RTP 通道,后续收到的还是 RTP 包,直接喂给现有的H264Reassembler就行。协商部分可以用ffmpeg拉流转推,也可以自己实现 RTSP 客户端。我一般先用ffmpeg -i rtsp://... -c copy -f h264 out.h264验证流本身没问题,再决定要不要自己写。
5.3 验证改造是否成功
改完别急着上生产,用这套流程验一遍:
| 验证项 | 命令 | 预期 |
|---|---|---|
| 文件可识别 | ffprobe out.h264 | 输出 H264 流信息,含分辨率 |
| 帧数完整 | ffprobe -count_frames out.h264 | 帧数与发流端大致吻合 |
| 无解码错误 | ffmpeg -i out.h264 -f null - | 无 error 输出 |
| 切片边界 | 检查每个切片首 NAL | 类型为 5(I 帧) |
ffmpeg -i out.h264 -f null -这条最有用,它会把整个文件解一遍,任何 NAL 结构错误都会打出来。我每次改完拼包逻辑,都强制走一遍这个命令,比肉眼盯着播放器靠谱得多。
5.4 一个容易忽略的细节
时间戳处理。RTP 的 timestamp 是 32 位无符号数,会回绕,单位是 90kHz 时钟。如果你要做音视频同步或者精确切片,得把它换算成秒:ts / 90000.0。但注意回绕,两个相邻包的 ts 差如果超过 2^31,说明发生了回绕,要特殊处理。这个坑我在做多路录制对齐时踩过,两路流的时间戳一绕,切片就对不上了。从那以后我每次处理 RTP 时间戳,都强制先做回绕归一化再参与任何计算。希望帮到你。
本文还有配套的精品资源,点击获取