
简介面向监控系统、在线会议与远程教育等实时视频场景的开发人员这份RTP转H264工程代码可解决从UDP网络流中接收RTP数据、解析并重装H264 NAL单元最终保存为视频文件的问题便于摄像头数据的及时读取。资源包共14个文件其中6个C头文件、4个C源文件构成核心逻辑分别实现UDP通信、RTP解包与NAL重组等功能另有vcxproj工程文件、filters源列表、ReadMe文档及用户配置文件压缩后仅9KB便于快速导入Visual Studio进行学习与二次开发。已有1031人学习下载适合对RTP协议、H264编码及UDP实时传输感兴趣的开发者。通过阅读rtpserver与CommonCode等模块可掌握RTP包的解析流程、NAL单元重组与H264起始码补齐以及Annex B或MP4文件格式的写入方法工程内附带的UDP收发示例也能帮助理解摄像头数据实时获取与落盘的完整链路是一份小而精的实战参考。1. 把 UDP 里的 RTP 流落成 .h264先搞清你要的是什么如果你是做网络摄像头对接、视频流录制或者 IPTV 调试的八成会遇到这个场景UDP 上跑着 RTP 包里面是 H.264 码流你需要把它存成一个能双击播放的 .h264 文件。别急着写代码先记住一句话RTP 是信封H.264 是信纸UDP 是邮局。直接把 UDP 载荷拼起来不是不行但出来的文件大概率花屏、卡顿、无法拖拽。这篇就讲清楚 RTP 转 H264 到底在做什么以及我踩过的坑、绕过的弯。适合正在抓包排错、要写录制工具、或者被 ffmpeg 那条-f h264参数折磨过的人。信我搞懂封装比你多写一百行代码有用。2. RTP 与 H.264 的封装关系为什么不能直接存 UDP 载荷2.1 RTP 头里藏着什么序列号、时间戳、SSRCRTP 固定头只有 12 字节但信息密度极高。前两个字节分别是版本号2 bit、填充标志1 bit、扩展标志1 bit、CSRC 计数4 bit以及标记位1 bit和负载类型7 bit。后两个字节是序列号接着是 4 字节时间戳再后面是 4 字节 SSRC 同步源标识。序列号不是随便编号的它解决的是网络丢包和乱序问题。你从 UDP 里抓到包后如果发现序列号从 100 直接跳到 105中间 4 个包丢了。如果乱序你得先按序列号排好再重组否则视频帧就是错位的。时间戳则是播放同步的关键H.264 的视频时间戳通常用 90kHz 时钟也就是说 1 秒被切成 90000 份每帧时间戳差多少就代表这一帧该在什么时候显示。SSRC 是干什么的它用来区分同一个 UDP 端口上不同的媒体流。比如摄像头同时推了视频和音频音频用 5000 端口视频用 5004 端口但有时候多路视频复用一个端口SSRC 就是它们的身份证。取流的时候只抓你需要的那个 SSRC能省掉一堆脏数据。很多人一开始直接读 UDP 载荷跳过 RTP 头解析。结果收进来的数据前面多了 12 个字节的垃圾花屏不说还可能把 RTP 扩展头也当成数据了。正确的做法是先解出 RTP 头确认负载类型PT是 96 还是 97确认 SSRC 一致再进 H.264 解包流程。2.2 H.264 分包方式单包、FU-A、STAP-AH.264 的码流是由 NALNetwork Abstraction Layer单元组成的每个 NAL 单元都有个一字节的头部。一个 NAL 单元的大小差异很大SPS/PPS 才几十字节IDR 帧可能几百 KB。RTP 的 MTU 通常限制在 1500 字节左右所以大 NAL 必须被拆成多个 RTP 包传输。RTP 封装 H.264 有三种常见模式。第一种是单包模式NAL 单元小于 MTU整个塞进一个 RTP 包。第二种是 STAP-A 聚合模式多个小 NAL 打包成一个 RTP 包降低包数量。第三种是 FU-A 分片模式一个大的 NAL 被切成多个 RTP 包这是最常见的模式也是普通人最容易被坑的地方。FU-A 的格式说一下。RTP 负载的第一个字节是 FU indicator和 NAL 头有直接关系。它的结构是F1 bit、NRI2 bit、Type5 bit当 Type 等于 28 时表示这是 FU-A 分片。紧接着第二个字节是 FU header包含S起始位、E结束位、R保留位和Type5 bit这里的 Type 才是真正的 NAL 单元类型比如 5 表示 IDR 帧1 表示非 IDR 帧。重组 FU-A 的时候算法是当S1时这个包是分片的第一包要取出 FU indicator 和 FU header 合成一个完整的 NAL 头通常是FU indicator 的高三位 FU header 的低五位然后拼接后续数据。中间S0 E0的包直接取出负载数据追加。当E1时这是最后一包追加完数据后一个完整的 NAL 单元才算拼好。如果你不管 S/E 位直接按顺序把所有 UDP 载荷拼在一起那出来的文件里全是假 NAL 头ffplay 能放但满屏绿色马赛克或者干脆报no frame!。这里没有捷径老老实实判断每一位。2.3 NAL 头与 AnnexB 格式0x00000001 从哪里来RTP 负载里的 NAL 单元是裸的没有起始码。而常见的 .h264 裸流文件国内叫 AnnexB 格式每个 NAL 前面要加 3 或 4 字节的起始码0x000001或0x00000001。为什么要加因为播放器需要一个地方找到 NAL 的边界。RTP 用分片号、长度等信息标记边界文件里就只能靠起始码。我一般建议用 4 字节起始码0x00000001因为某些老解码器不认 3 字节。如果你在写 splice 或合并流还要注意避免码流内部出现伪起始码。这个概率极低但在长视频里不是不可能。如果你发现解码器偶尔抽风可以用 h264_mp4toannexb 之类的 filter 或者自己做个防伪检测把00 00 01前面再补一个零。另外NAL 头的 Type 字段要保留。Type 7 是 SPSType 8 是 PPSType 5 是 IDR 帧Type 1 是普通 P/B 帧Type 6 是 SEI。这些 Type 值在写到文件时不用改但你要用来判断关键帧位置和是否注入参数集。2.4 时间戳与 PTS/DTS 的关系RTP 时间戳和播放器里的 PTS 不完全是一回事。RTP 时间戳是采样时钟频率由协商决定一般是 90000。播放器渲染时要有 PTS/DTS但 .h264 裸流本身并不携带 PTS 信息它只靠 NAL 的帧类型和帧率暗示来推算。很多人在 RTP 转 H264 时不会去写时间戳因为裸流格式根本存不了 PTS。播放器拿到一个裸流文件默认按 25fps 或 30fps 固定帧率播放。如果你的源流是可变帧率哪怕码流再正确播放器也会显示忽快忽慢。解决办法是你要么固定帧率要么把裸流封装成 MP4 或 MKV 容器写入真实的 PTS。所以我的习惯是如果只是验证码流转 .h264 够用如果要交付给别人就多走一步封装 MP4。后面第五章会给一个稳定方案。3. 抓包到落盘把 RTP 流转成 H.264 文件的可执行方案3.1 用 Wireshark 确认流信息目标端口、SSRC、Payload Type第一步是确认你要处理的流长什么样。用 Wireshark 抓 UDP 包过滤条件先写udp.port5004以实际端口为准。等抓到几十个包后点开任意一个 RTP 包看两个地方一是 RTP 头里的 Payload type通常 96 或 97 表示 H.264二是 SSRC 值记下来。Wireshark 里可以直接按rtp.ssrc0x12345678过滤这样能筛掉同端口上的其他流。如果你看到Payload type是动态的比如 96去 RTSP 的 SDP 里查artpmap:96 H264/90000确认是 H.264 无误。这里还要确认一个信息RTP 是源地址直发还是经过了 NAT 或中间设备。如果经过转发IP 会变但 SSRC 不变。所以强烈建议全程用 SSRC 而不是 IP 来标识流。另外如果你用的抓包工具是 tcpdump可以用udp port 5004 -w rtp.pcap写文件回头再用 Wireshark 分析。但如果是实时转换不建议 tcpdump 落盘再读直接用 Python 收包更直接。给一个 Wireshark 常用过滤参考过滤表达式用途udp.port5004抓指定端口rtp.ssrc0x1234abcd筛指定 SSRCrtp.payload_type96筛指定 PTrtp.marker1看帧边界marker 置位3.2 写一个 Python 解包脚本从 UDP 载荷到 H.264 AnnexB当你确认了端口和 SSRC 后就可以写脚本来实时接收并转存。我用 Python 的socket收 UDP再自己解析 RTP 头然后按 FU-A 重组。下面这个脚本可以直接跑它读取一个已经抓好的 pcap 文件用scapy解析或者你也可以改成 socket 收包。import socket import struct import sys # 配置参数 UDP_IP 0.0.0.0 # 监听地址 UDP_PORT 5004 # RTP 端口 TARGET_SSRC None # 设为 None 表示接收该端口所有流建议设置成实际 SSRC out_file open(output.h264, wb) sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((UDP_IP, UDP_PORT)) # 用于 FU-A 重组的缓冲区 fu_buffer b # 当前 RTP 序列号用于检测丢包 last_seq None while True: data, addr sock.recvfrom(2048) if len(data) 12: continue # 包太短不是完整 RTP 头 # 解析 RTP 固定头 version (data[0] 6) 0x03 if version ! 2: continue # 不是 RTP 包 pt data[1] 0x7F seq struct.unpack(!H, data[2:4])[0] timestamp struct.unpack(!I, data[4:8])[0] ssrc struct.unpack(!I, data[8:12])[0] if TARGET_SSRC is not None and ssrc ! TARGET_SSRC: continue # 跳过不匹配的流 if last_seq is not None and (seq - last_seq) % 65536 1: print(f[警告] 检测到丢包: last_seq{last_seq}, seq{seq}) fu_buffer b # 丢包必须丢弃当前分片缓存否则会拼接出坏帧 last_seq seq # 传统 RTP 头 12 字节如果有扩展头要跳过 offset 12 if data[0] 0x10: # X bit 置位表示有扩展头 ext_len struct.unpack(!H, data[offset2:offset4])[0] offset 4 ext_len * 4 payload data[offset:] if len(payload) 0: continue # 检查 NAL 头类型 nal_type payload[0] 0x1F if nal_type 28: # FU-A 分片 fu_s (payload[1] 0x80) 7 fu_e (payload[1] 0x40) 6 real_type payload[1] 0x1F # 重建 NAL 头FU indicator 的高 3 位 FU header 的低 5 位 nal_header bytes([(payload[0] 0xE0) | real_type]) if fu_s: fu_buffer nal_header payload[2:] else: fu_buffer payload[2:] if fu_e: # 分片结束写入完整 NAL out_file.write(b\x00\x00\x00\x01 fu_buffer) fu_buffer b elif nal_type in [1, 5, 6, 7, 8]: # 常见的单包 NAL out_file.write(b\x00\x00\x00\x01 payload) else: # 其他类型如 24(STAP-A)、25 等暂不处理可自行扩展 pass这个脚本的逻辑分三步先解析 RTP 头拿序列号和负载类型然后判断负载里的 NAL 类型是不是 FU-A最后把分片重组后加上 4 字节起始码写入文件。有几个地方我要重点说明。recvfrom(2048)这个缓冲值必须大于 MTU 加上 RTP 头否则可能一次收不全一个 RTP 包。Wireshark 抓到的是完整包但 socket 默认收的是 IP 包载荷一般 1500 字节够用但保险起见给 2048。timestamp变量这里只用来跟踪写裸流文件时用不到但如果你后面要封装 MP4就必须把它记下来。另外丢包处理我直接丢了当前分片缓存。没有别的选择因为一个 NAL 单元的任何一部分缺失重组出来都是坏帧。如果你的场景丢包率很高可以考虑用 RTP 重传或 FEC但那是另外的话题。3.3 处理 SPS/PPS要么从 SDP 里拿要么从码流里抽上面脚本写出来的文件很多播放器打开会报错因为文件开头没有 SPS/PPS。H.264 解码器必须知道分辨率、帧率、参考帧数量等信息这些信息就存在 SPS 里。PPS 则包含熵编码模式、片组数等参数。没有它们解码器不知道画布多大自然没法渲染。SPS/PPS 有两个来源。一个是 RTSP 协商时 SDP 里的sprop-parameter-sets字段它用 Base64 编码了两个 blob逗号分隔第一个是 SPS第二个是 PPS。解析方式很简单import base64 # 假设从 SDP 里拿到这一行 sprop Z0IAH5NoFAFu,AAAMnA sps_b64, pps_b64 sprop.split(,) sps base64.b64decode(sps_b64) pps base64.b64decode(pps_b64) with open(output.h264, wb) as f: f.write(b\x00\x00\x00\x01 sps) f.write(b\x00\x00\x00\x01 pps)另一个来源是直接从码流里抽取。在 RTP 流转过程中SPS/PPS 会周期性地出现在关键帧前面NAL Type 是 7 和 8。你可以把脚本里遇到 Type 7/8 的 NAL 单独缓存下来等文件写完后先写 SPS/PPS再写全部帧数据。注意顺序必须是 SPS 在前PPS 在后不能调换。我自己的经验是只在文件头部注入 SPS/PPS 还不够。因为录像文件可能被编辑剪切或者播放器在关键帧处 seek如果剪切点丢了参数集seek 后还是黑屏。所以我会在每 5 秒或每个 IDR 帧前重复写一次 SPS/PPS。AnnexB 格式完全允许重复插入只要能解码器能识别。这算是一种后悔药。3.4 验证输出文件ffprobe / ffplay写完 .h264 文件第一件事不是直接播放而是用ffprobe看它认不认。ffprobe -v error -show_streams -select_streams v:0 output.h264正常输出里你会看到codec_nameh264、profileHigh、level3.1等。如果 ffprobe 报Invalid data found when processing input多半是你文件头没写好可能是 SPS/PPS 缺失或者起始码错误。再用ffplay output.h264试试。如果画面有节奏地闪、但全是灰块说明 NAL 重组出了问题回去检查 FU-A 的 S/E 逻辑。如果画面正常但不流畅那大概率只是帧率推断错误并不代表解码失败。另一个有用的验证工具是h264_parseffmpeg -v error -i output.h264 -f null - 21 | head -20它会逐帧解析和你直接播放的结论互相印证。我一般先h264_parse再ffplay两步都过了才敢说文件没问题。4. 避坑RTP 转 H264 的常见翻车现场4.1 花屏漏了 FU-A 分片重组现象生成的 .h264 文件用 ffplay 播放图像像打码一样全是绿色和紫色方块。偶尔几帧能看清大部分时间花屏。原因我把 RTP 负载直接当成了 NAL 单元没有处理 FU-A 分片。大帧被拆成多个 RTP 包我只取了第一个分片的数据后面的分片被当成独立的 NAL 写进了文件。解决严格按照 FU-A 的S和E位做重组。我还在重组逻辑里加了保护当遇到新的S1包但当前fu_buffer还没被E1闭合时强制清空缓冲区。这是防止丢包后错位拼接的最粗暴也最有效的方法。4.2 文件开头黑屏几秒SPS/PPS 没写头现象文件播放后前 3 秒黑屏然后突然正常显示画面。或者用某些播放器从头播放直接黑屏拖到中间又能看。原因文件的第一个关键帧之前没有写入 SPS/PPS。解码器启动时没有参数集无法解码 IDR 帧等到码流里重新出现 SPS/PPS 时才能恢复。解决抓包时把 Type 7 和 Type 8 的 NAL 单独提取出来缓存。写入文件时先写 SPS接着写 PPS然后才是第一个 IDR 帧。如果你是实时录制建议每遇到一次关键帧都把它前面的 SPS/PPS 一起重复写入这样不管从哪个位置开始播放解码器都能快速找到参数集。4.3 ffprobe 报 unknown decoder起始码写错了现象ffprobe 报Invalid data found when processing input或者could not find codec parameters文件大小明显比实际小。原因写入 AnnexB 时用了\x00\x00\x01三字节起始码某些解析器把它当成了伪起始码。另外有人会把 RTP 扩展头也算进负载导致 NAL 前面混入了 4-8 字节的垃圾数据。解决统一用\x00\x00\x00\x01四字节起始码。同时检查 RTP 扩展位如果Xbit 置位记得跳过扩展头再取负载。我在脚本里已经写了这个判断你自己写的时候别漏。4.4 慢放卡顿没处理时间戳时钟频率现象文件名是 .h264但在播放器里播放速度忽快忽慢音频对不上如果有音频的话。仔细看码流内容帧确实都在但帧间时间间隔不稳定。原因RTP 时间戳是 90kHz而播放器解释裸流时按固定 fps 来猜比如默认 25fps。如果源流是可变帧率全部按 25 播放自然不准。解决如果你只是检查内容可以忽略这个问题。如果要交付就把裸流封装到 MP4 容器里用 RTP 时间戳换算成 PTS。封装代码可以看第五章。4.5 实时录制时长不准marker 位没利用现象录制出来的文件时长比实际少了 1-2 秒或者每段 GOP 衔接处有跳帧感。原因RTP 的 marker 位标记了一个访问单元AU的结束也就是一帧结束。我没仔细看 marker 位直接把 FU-A 的E位当成帧边界。很多时候一个帧确实只有一个 NAL但 B 帧或包含多个 NAL 的帧FU-A 结束并不代表帧结束。解决帧边界要用marker位判断不能只靠E位。在脚本里你可以用if data[1] 0x80:判断 marker 置位然后做帧计数或分段处理。这也是为什么有些人录出来的文件按帧数算时长总对不上的原因。5. 让文件能播时间戳补齐、SPS/PPS 注入与流式写入技巧最后这一步我建议你把 RTP 流转 H264 的思路再往前推一层不要写完 .h264 就停顺手把它封装成 MP4 或把时间戳信息保留下来。裸流能播但体验差尤其你要做录像回放、按秒定位、倍速播放时裸流就是一场灾难。如果要封装 MP4关键是把 RTP 时间戳换算成 PTS。H.264 视频的 RTP 时间戳频率是 90kHz所以 PTS 毫秒数 timestamp / 90。给一个最小实现思路# 在 3.2 脚本基础上增加一个 pts 列表 pts_list [] last_pts None # 在你处理完一个完整的 NAL 并准备写入时做如下操作 current_pts timestamp / 90 # 毫秒 if last_pts is not None and current_pts last_pts: # 防止时间戳回绕或乱序强制递增 current_pts last_pts 1 pts_list.append(current_pts) last_pts current_pts接着用一个 muxer 库或者干脆调 ffmpeg 命令行把你已经写好的 .h264 加上时间戳列表生成 MP4。这里不展开具体代码因为不同语言库差异太大。但核心思想就一句话裸流只适合中间验证交付一定要带时间戳容器。SPS/PPS 注入我前面提过再补充一个细节不要只写一次。我遇到过多次用户下载的录像文件前 5 秒黑屏原因就是 SPS/PPS 只在文件最初写了一次后来被人用工具剪切过参数集被切丢了。从那以后我每次写入都采取关键帧前重复注入策略每检测到一个 IDR 帧的 FU-A 起始包就把 SPS/PPS 再写一遍。这个做法在文件体积上多占几十字节换来了播放器的绝对兼容。还有一点是流式写入的缓冲控制。如果你直接边收边写磁盘 IO 可能成为瓶颈。我一般把 NAL 写入放在一个独立线程用队列缓冲。收包线程只负责解包和重组重组完的 NAL 放进队列写入线程从队列取数据写文件。这样即使网络突发丢数据也不会阻塞接收。最后分享一个我自己的习惯收到任何 RTP 流先确认三个数字——Payload Type 、SSRC、时间戳频率。三个数字对上了再谈转码。如果对不上后面全白做。希望这篇文章能帮你少走几段弯路把 UDP 里的 RTP 流稳稳落成一个能播的 .h264 文件。本文还有配套的精品资源点击获取