简介:一套面向摄像头实时取流场景的C++工程实现,提供基于UDP的RTP流抽取与H264落盘方案,适合网络视频传输、协议分析及流媒体开发人员参考。工程围绕RTP解封装这一核心任务,实现了RTP包头解析、H264 NAL单元边界识别、分片NAL重组、起始码修复以及Annex B格式文件写入等关键流程,并提供了服务端接收模块与通用工具代码,帮助开发者快速搭建从网络流到本地H264文件的处理链路。资源压缩包仅9KB,共14个文件,以6个.h头文件和4个.cpp源文件为主,另含vcxproj工程配置和ReadMe说明文档,整体结构简短清晰,便于逐模块阅读。目前已有1031人学习阅览,代码中涉及的UDP通信、RTP时间戳与序列号处理、NAL分片重组等细节,对理解在线视频传输底层原理和自行扩展H264存储功能具有直接参考价值,适合具备C++和网络编程基础的开发者学习实践。
1. 把网络里的 RTP 流落成 .h264 文件:这件事比想象中多一层
最近在调一套视频采集链路:摄像头通过 RTSP 推流,我需要在另一台机器上把 UDP 里的 RTP 数据保存成 h264 文件,留着离线做码流分析。起初我以为不就是“抓包存文件”,结果用 Wireshark 导出原始数据之后,VLC 一个都打不开。检查才发现,UDP 数据里装的是 RTP 包,RTP 载荷里才是 H264 码流,而 H264 在 RTP 里还会被分片、聚合、加头,一层没对上就全白搭。这篇文章就沿着“rtp 转 h264 文件”这条链路把方案讲透:先确认 RTP 怎么装 H264,再给出 ffmpeg 最快落地路径,最后给一份自写保存程序的核心代码和避坑清单。适合正在做 UDP 网络调试、视频采集或码流分析的人参考。
2. RTP 是怎么把 H264 塞进 UDP 的:先认清三种打包方式
2.1 为什么 RTP 跑在 UDP 上:TCP 的重传机制对实时流是负资产
很多第一次接触 RTP 的开发者会问:为什么要用 UDP 而不是 TCP?毕竟 TCP 可靠、有序、有重传。但实时视频恰恰不能等重传——一帧数据如果因为网络抖动重传了 200 毫秒,播放端早就该显示下一帧了,结果就是画面卡住、延迟滚雪球。RTP 的设计哲学是“宁可丢一帧,也不要让整条流卡死”,所以它的事实标准承载层就是 UDP。这也是 UDP 和 TCP 协议的区别里最核心的一条:TCP 保证可靠但引入队头阻塞,UDP 不保证可靠但延迟可控,RTP 的序号和时间戳机制让接收端自己能发现丢包并决定怎么处理。
在动手保存 h264 文件之前,脑子里要有这条链路的概念:摄像头编码出 H264 裸流,打包成 RTP 包,再装进 UDP 数据报,经网络到达你的机器。反过来,你要做的是把 UDP 载荷里的 RTP 头剥掉,把 H264 载荷重新拼成带起始码的 Annex B 码流,写进文件。这里有两个关键点:一是 RTP 头占 12 字节,二是 H264 载荷不总是“一个包就是完整一帧”,它可能被分片或聚合。理解这两点,后面的代码才不会写歪。
2.2 RTP 头结构:前 12 字节决定你怎么拆包
RTP 头最小 12 字节,固定部分如下表。抓包时看到的每个 RTP 包,前 12 字节都是这个结构,之后的字节才是真正的 H264 载荷。
| 字段 | 位宽 | 说明 |
|---|---|---|
| Version | 2 bit | RTP 版本,固定为 2 |
| Padding | 1 bit | 是否带填充字节 |
| Extension | 1 bit | 是否有扩展头 |
| CSRC Count | 4 bit | 贡献源个数,单播流通常为 0 |
| Marker | 1 bit | 帧边界标记,H264 场景常在帧尾置 1 |
| Payload Type | 7 bit | 载荷类型,H264 用动态值如 96、97 |
| Sequence Number | 16 bit | 包序号,网络序,丢包检测靠它 |
| Timestamp | 32 bit | RTP 时间戳,H264 按 90000 Hz 计数 |
| SSRC | 32 bit | 流标识,同一条流内不变 |
解析时只需要读三个地方:pkt[0]的高 2 位确认版本,pkt[1]的低 7 位拿载荷类型,pkt[2]和pkt[3]拼出 16 位序号。代码写起来很简单:
uint8_t version = (pkt[0] >> 6) & 0x03; uint8_t pt = pkt[1] & 0x7F; uint16_t seq = (pkt[2] << 8) | pkt[3];注意pkt[2] << 8 | pkt[3]就是网络序转主机序的等价写法,因为 RTP 头里所有多字节字段都是大端。很多新手在这里直接按小端读,导致序号错乱,重组出来的画面全是花的。
2.3 三种打包方式:单一 NALU、STAP-A、FU-A
H264 的 NALU(Network Abstraction Layer Unit)是基本单位,SPS、PPS、IDR 帧、普通帧都是不同类型的 NALU。RFC 6184 定义了 RTP 封装 H264 的三种方式,看载荷的第一个字节低 5 位就能区分:
- 1 到 23:单一 NALU 包,载荷就是一个完整 NALU。这是最常见的情况,小尺寸的 SPS、PPS、SEI 和大部分视频帧都走这条路。
- 24:STAP-A 聚合包,多个小 NALU 被拼进一个 RTP 包。典型场景是 SPS + PPS + SEI 打包在一起,跟随关键帧一起发布。
- 28:FU-A 分片包,一个大的 NALU 被切成多个 RTP 包传输。当一帧数据超过 MTU(典型 1500 字节左右)时就会触发,H264 的 IDR 帧往往很大,几乎必然走 FU-A。
判断代码只需要一行:
uint8_t nal_type = rtp_payload[0] & 0x1F;拿到 24 就进入聚合包解析,拿到 28 就进入分片重组,其他值直接当作完整 NALU 写入文件。后面第 4 章的自写程序就是围绕这个分发逻辑展开的。
2.4 用 Wireshark 先验证打包方式,再决定方案
动手写代码前,我习惯先抓一段包,用 Wireshark 确认流的真实形态。过滤表达式这样写:
rtp && rtp.ssrc == 0x12345678如果不知道 SSRC,可以先过滤rtp && udp.port == 5000,再在 RTP 详情里找到 SSRC 值替换进去。打开一个分片包的详情,你会看到 RTP payload 的第一个字节是0x7C或0xFC这类值,低 5 位是 28,这就是 FU-A;如果看到聚合包,第一个字节低 5 位是 24。这一步的价值在于:它直接决定了接收端处理逻辑的复杂度。如果抓包显示你的流全是单一 NALU,处理程序可以砍掉大半逻辑;如果全是 FU-A,那么重组逻辑是核心,不能省。
3. 用 ffmpeg 把 RTP/UDP 存成 h264:最快落地的命令路径
3.1 源是 RTSP:ffmpeg 一行命令落盘
大部分网络摄像头的推流是 RTSP,底层传输可以选择 UDP 或 TCP。保存文件时我一般强制走 UDP,避免 TCP 叠加缓冲带来额外延迟:
ffmpeg -rtsp_transport udp -i rtsp://192.168.1.10/live/ch0 -t 60 -c copy out.h264参数拆开讲:-rtsp_transport udp让 RTSP 的 RTP 走 UDP 传输;-i指定 RTSP 地址;-t 60表示只录制前 60 秒,适合测试时先用短时长验证;-c copy是关键,直接复制编码流,不做转码,所以速度快、CPU 占用低,而且输出就是 Annex B 格式的 .h264 文件。这里不要加-bsf:v h264_mp4toannexb,那是从 MP4 抽取时才需要的 filter,RTSP 来的 RTP 流本来就是 Annex B。
这个命令适合“源端已经提供 RTSP”的情况。很多监控项目到这一步就结束了,但如果你面对的是裸 RTP 推流——没有 RTSP 会话、没有 SDP 文件,只有一台设备往固定端口发 UDP 包——上面的命令就失效了,需要进入下一节。
3.2 源是裸 RTP/UDP:先写一个 SDP 文件再喂给 ffmpeg
ffmpeg 解析 RTP 流时,必须知道载荷类型和编码名,这些信息在 RTSP 场景由 SIP/RTSP 协议协商好,在裸流场景就得靠手动写 SDP。只要发流端告诉你“往 5000 端口推 H264,PT 是 96”,就可以建一个live.sdp:
v=0 m=video 5000 RTP/AVP 96 c=IN IP4 0.0.0.0 a=rtpmap:96 H264/90000然后执行:
ffmpeg -protocol_whitelist file,udp,rtp -i live.sdp -c copy out.h264-protocol_whitelist是 ffmpeg 安全机制要求的,显式允许读取本地文件和 UDP/RTP 协议,否则新版 ffmpeg 会拒绝打开非白名单协议。SDP 里的90000是 H264 RTP 时间戳的标准采样频率,必须写对这个值,否则 ffmpeg 解时间戳会乱。
这里有个容易踩的坑:SDP 里的端口必须和发流端完全一致,PT 值也必须一致。如果发流端用的是 PT 100,你写成 96,ffmpeg 会丢弃所有包,日志里只有一条“timestamp discontinuity”之类的信息,文件输出为空。先确认这些参数再启动命令,能省很多无意义的排查。
3.3 源是抓包文件:ffmpeg 能抽,但不能迷信
如果你手里只有一个 pcap 抓包文件,没有实时流,也可以尝试让 ffmpeg 直接从 pcap 里抽 H264:
ffmpeg -f pcap -i dump.pcap -map 0:v:0 -c copy extracted.h264这个命令在流单一、RTP 包干净的情况下能跑通,但我不建议把它当主力方案。ffmpeg 的 pcap demuxer 实现很“浅”,它假设 pcap 里只有一条 RTP 流且 RTCP 交错不严重;一旦抓包里混着 RTCP 包、重传包或 RTSP-over-TCP 的封装,它就会串包或者直接丢掉大量数据,导出的文件要么花屏要么时长不对。遇到这种情况,改用 tshark 或者第 4 章的自写程序按 SSRC 精确抽取更可靠。
3.4 GStreamer 备选方案:适合要顺手处理流的场景
如果不想写 C 代码,又觉得 ffmpeg 的 SDP 方式不够灵活,GStreamer 的命令行也能完成同样的事:
gst-launch-1.0 udpsrc port=5000 caps="application/x-rtp, media=(string)video, encoding-name=(string)H264, payload=(int)96" ! rtph264depay ! h264parse ! filesink location=out.h264这条管线的思路很直白:udpsrc接收 UDP 包,rtph264depay剥掉 RTP 头并重组 FU-A,h264parse修正码流并补起始码,filesink写文件。它的优势是每个环节都可以替换,比如把filesink换成autovideosink就能实时预览。劣势是 GStreamer 的 Caps 协商严格,PT、编码名、时钟频率任何一个不匹配都会直接报not negotiated错误,排错难度比 ffmpeg 高。
4. 自己写一个 RTP 转 H264 保存程序:核心是 FU-A 重组
4.1 主循环骨架:socket 接收、RTP 头解析、载荷分类
ffmpeg 和 GStreamer 能处理大多数情况,但如果你需要对丢包做统计、对时间戳做日志、或者加私有协议头,就得自己写。下面这份 C 代码是一个能跑通核心逻辑的最小实现,只依赖标准 socket 接口。
#include <stdio.h> #include <stdint.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define RTP_PORT 5000 #define MAX_PKT 65536 #define RTP_PT_H264 96 int main(void) { int fd = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(RTP_PORT); if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } /* 调大接收缓冲区,抗短暂突发丢包 */ int rcvbuf = 4 * 1024 * 1024; setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf)); FILE *fp = fopen("out.h264", "wb"); if (!fp) { perror("fopen"); return 1; } uint8_t pkt[MAX_PKT]; while (1) { ssize_t n = recv(fd, pkt, sizeof(pkt), 0); if (n < 12) continue; /* 小于RTP头,丢弃 */ uint8_t pt = pkt[1] & 0x7F; if (pt != RTP_PT_H264) continue; /* 只处理H264载荷 */ uint16_t seq = (pkt[2] << 8) | pkt[3]; uint8_t payload_type = pkt[12] & 0x1F; /* 载荷第一个字节低5位 */ /* 三种分发:单一NALU / STAP-A / FU-A */ /* 下一小节填充具体处理 */ } return 0; }这段代码里有两个容易被忽略的细节。一是SO_RCVBUF要主动调大,Linux 默认的 UDP 接收缓冲区只有几百 KB,码率稍微一高就丢包,后面第 5 章还会展开讲。二是recv返回n必须和 RTP 头长度做比较,解析前不检查长度会导致越界读,轻则花屏,重则段错误。实际工程里还应该在解包时记录总包数、丢包数、SSRC 变化次数,方便事后判断文件质量。
4.2 三类载荷的分流处理:完整代码
在上面的主循环里补上三种处理分支。单一 NALU 直接加起始码写文件;STAP-A 拆出多个 NALU 分别写;FU-A 按起始分片、中间分片、结束分片重组。
/* 起始码,Annex B格式 */ static const uint8_t sc[4] = {0x00, 0x00, 0x00, 0x01}; /* FU-A 重组状态 */ static uint8_t fua_buf[1500 * 8]; static size_t fua_len = 0; static uint16_t fua_expected_seq = 0; /* 下一个期望的RTP序号 */ uint16_t seq; /* 分支一:单一NALU,type 1~23 */ if (payload_type <= 23) { fwrite(sc, 1, 4, fp); fwrite(pkt + 12, 1, n - 12, fp); continue; } /* 分支二:STAP-A,type 24 */ if (payload_type == 24) { size_t off = 13; /* RTP头12字节 + STAP-A类型1字节 */ while (off + 2 <= n) { uint16_t nal_len = (pkt[off] << 8) | pkt[off + 1]; off += 2; if (off + nal_len > n) break; /* 防止越界 */ fwrite(sc, 1, 4, fp); fwrite(pkt + off, 1, nal_len, fp); off += nal_len; } continue; } /* 分支三:FU-A,type 28 */ if (payload_type == 28) { uint8_t fu_header = pkt[13]; int start = (fu_header >> 7) & 1; int end = (fu_header >> 6) & 1; if (start) { /* 新NALU:还原NALU头 = FU indicator的NRI + FU header的type */ fua_buf[0] = (pkt[12] & 0xE0) | (fu_header & 0x1F); fua_len = 1; memcpy(fua_buf + 1, pkt + 14, n - 14); fua_len += n - 14; fua_expected_seq = seq + 1; } else if (fua_len > 0 && seq == fua_expected_seq) { /* 中间或结束分片:按顺序拼接 */ memcpy(fua_buf + fua_len, pkt + 14, n - 14); fua_len += n - 14; fua_expected_seq++; } if (end && fua_len > 1) { fwrite(sc, 1, 4, fp); fwrite(fua_buf, 1, fua_len, fp); fua_len = 0; /* 复位,等待下一个NALU */ } continue; }这段代码里最关键的是 FU-A 重组逻辑。start位为 1 表示这是 NALU 的第一片,此时要从FU indicator的高 3 位取 NRI,从FU header的低 5 位取原始 NALU type,拼出真正的 NALU 头。中间分片必须严格按 RTP 序号递增拼接,我用fua_expected_seq记录下一个期望序号,收到分片时先判断序号是否连续,不连续的说明中间有丢包,这个 NALU 已经残缺,不应写入文件。
4.3 写文件策略:SPS/PPS 前置 + 去重,文件体积能小三分之一
处理完 NALU 后,写文件不是无脑 fwrite 就行。H264 文件首帧之前必须要有 SPS 和 PPS,否则播放器根本解不出分辨率。很多发流端每个关键帧都会带一次 SPS/PPS,如果全写进去,文件能播放但冗余很大,体积可能膨胀 30%。我一般这样处理:
static uint8_t sps_cache[64]; static size_t sps_len = 0; static uint8_t pps_cache[64]; static size_t pps_len = 0; /* 在单一NALU分支中,单独截获SPS和PPS */ if (payload_type == 7 && n - 12 < sizeof(sps_cache)) { memcpy(sps_cache, pkt + 12, n - 12); sps_len = n - 12; } if (payload_type == 8 && n - 12 < sizeof(pps_cache)) { memcpy(pps_cache, pkt + 12, n - 12); pps_len = n - 12; }收到首帧 IDR(payload_type 为 5)之前,先把缓存的 SPS/PPS 写入文件,再写 IDR。后续如果同一份 SPS/PPS 再次出现,判断sps_len > 0且内容相同时跳过不写。这样文件开头干净,播放器兼容性最好,文件体积也更合理。
不过这个策略有一个前提:抓流必须从流中间开始也没关系,只要首个完整 IDR 之前出现过 SPS/PPS。如果网络丢包把前几秒的 SPS 丢了,文件头就缺参数集,此时播放器会直接报 “无法解码”。更严格的方案是在内存里缓冲到首个 IDR 出现再决定怎么开头,但绝大多数场景下,上面的缓存策略配合丢包统计已经够用。
5. 避坑:RTP 转 H264 保存常见的 5 个翻车现场
5.1 播放器打不开文件,提示缺少 SPS/PPS:文件头参数集没写对
现象:生成的文件用 ffprobe 能看到流信息但 VLC 打开黑屏,或者开头几秒花屏后恢复正常。原因:文件头没有 SPS/PPS,或 SPS/PPS 写在第一个 IDR 之后。解决:确保在写入第一帧 IDR 前,先把 SPS(NALU type 7) 和 PPS(type 8) 按“起始码 + NALU”的格式写入文件。用第 4 章的缓存逻辑即可。还要检查发流端是否在关键帧前携带参数集,如果流本身不带,你需要从 SDP 或 RTSP 描述里解析 sprop-parameter-sets 字段,把 base64 解码后写入文件头部。
5.2 花屏和绿块:FU-A 分片丢了中间的包
现象:文件能播放,但画面频繁出现花屏、绿块、撕裂感。原因:FU-A 分片在网络上丢了中间包,重组出来的 NALU 不完整,解码器拿到残缺数据无法恢复。解决:两条路。治标——在代码里检测序号不连续后直接丢弃整个 NALU,不写入文件,让播放器显示上一帧而不是碎块;治本——检查网络丢包率,确认 UDP 缓冲区是否够大。用netstat -su查看RcvbufErrors计数,如果持续增长,说明内核缓冲区不足。
5.3 UDP 接收缓冲区太小:不是程序 bug 而是协议栈参数
现象:通过 pcap 抓包看数据是通的,但程序写出来的文件时长明显比实际短,日志里recv频繁返回 EAGAIN。原因:Linux 的 UDP 接收缓冲区默认值(通常几百 KB)跟不上码流到达速率,内核直接丢包。解决:程序内setsockopt(SO_RCVBUF)调大到 2 到 8 MB,同时把系统级参数也调大:
sysctl -w net.core.rmem_max=8388608 sysctl -w net.core.rmem_default=8388608注意SO_RCVBUF设多大,内核还会翻倍,所以 4 MB 的设置实际约 8 MB 的接收能力。另外,如果是抓包分析时发现重传或乱序,不要把锅都推给协议栈,先确认网络环境和交换机的 buffer 配置。
5.4 用 nc / socat 收流到文件再转,得到的却是“乱码”
现象:有人习惯先nc -u -l 5000 > raw.bin把 UDP 流落盘,再拿 ffmpeg 转成 h264,结果 ffmpeg 不认。原因:raw.bin里存的是完整的 UDP 载荷,也就是 RTP 包,而 ffmpeg 的-f h264demuxer 期望的是没有 RTP 头的裸码流。解决:不要在收流环节直接落盘 RTP,要么用第 3 章的 SDP + ffmpeg 直转方式,要么用第 4 章的自写程序边解析边写。用 nc 做原始数据捕获只适合调试,不适合作为生产录制链路。
5.5 ffmpeg 读 pcap 抽 H264:RTCP 包把 demuxer 搞晕
现象:用第 3.3 节的命令抽 pcap 里的 H264,输出文件时长只有几秒,或报Packet corrupt后停止。原因:ffmpeg 的 pcap demuxer 对 RTCP 包和不同 SSRC 的 RTP 流处理不健壮,一旦 RTP 与 RTCP 交错(UDP 端口相邻),或者抓包文件里有多条视频流,它就可能串流。解决:不要依赖这个命令做正式工具。先 Wireshark 里过滤rtp.ssrc明确目标流,再用 tshark 提取载荷:
tshark -r dump.pcap -Y "rtp && rtp.ssrc == 0x12345678 && udp.port == 5000" -T fields -e rtp.payload > payloads.txt然后按 RTP 头去掉每行前几个字节,再用脚本拼出 NALU。这个路径能绕过 ffmpeg demuxer 的各种幺蛾子,代价是脚本要自己写,但可控性强得多。
6. 进阶:找回时间戳,并验证输出文件确实可播放
6.1 裸 H264 文件丢失了时间信息:三种应对思路
RTP 头里 32 位的时间戳是 90000 Hz 计数,但你把 NALU 写进 .h264 裸流时,Annex B 格式不存在时间戳字段,播放器只能按固定帧率猜测显示。文件如果只是给人看,影响不大;但如果后面要做剪辑、同步音轨或分析帧间隔,这个损失就不可接受了。应对思路有三条:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 裸流 + 固定帧率 | 不记录时间戳,播放器按帧率推算 | 监控回放、快速预览 |
| RTP 时间戳落侧车文件 | 自写程序对每个 NALU 记录 timestamp,输出timestamp.txt | 需要精确帧间隔分析 |
| 直接封装 MP4 | 转存时按 RTP 时间戳换算生成 PTS,封装进容器 | 需要音视频同步或剪辑 |
如果你确定需要精确时间,建议在自写程序里顺手把每个 NALU 的 RTP timestamp 和写入偏移记录成两列文本,再按需合成每秒帧数统计。对于相对简单的需求,直接用固定帧率封装 MP4 也能接受:
ffmpeg -f h264 -framerate 25 -i out.h264 -c copy out.mp4注意-framerate要按实际帧率填,填错会导致播放速度不对。如果你知道 RTP 流是 30 fps,就填 30。
6.2 用 ffprobe 做出口检查:确认你的文件是合格的 H264
保存完成后,不要只拿 VLC 看一眼就结束。我用 ffprobe 检查输出已经是习惯,因为能一次性看出分辨率、帧率、帧数、编码 profile 是否异常:
ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,profile,width,height,pix_fmt,r_frame_rate,nb_frames -of default=noprint_wrappers=1 out.h264输出示例:
codec_name=h264 profile=High width=1920 height=1080 pix_fmt=yuv420p r_frame_rate=25/1 nb_frames=1500我一般逐一核对:codec_name是 h264;profile是 High 还是 Main,和源流一致;width/height能对上才算文件头 SPS/PPS 正确;nb_frames要和你期望的时长基本吻合,如果明显偏少,说明丢包导致大量 NALU 被丢弃。这个检查做完,才算真正完成了一次“rtp 转 h264 文件”的落地。写文件这件事,我吃了不少亏,有时候是 SPS 没放对位置,有时候是时间戳算错导致封装进度不准;后来规矩定下来,每次都按“先抓包看打包方式,再选工具,最后 ffprobe 验证”的流程走,翻车率就低了很多。希望这套流程也能帮到你。
本文还有配套的精品资源,点击获取