简介:这份资源围绕RTP协议封装与发送H264视频流这一主题,面向具备一定网络编程与音视频基础、希望打通编码封装到解码播放完整链路的开发者。包内共21个文件,以264、h264视频样本、cpp与h源码、obj与pdb等编译中间产物、exe可执行程序及sdp会话描述文件为主,压缩包约929KB,体积轻量却覆盖了从NAL单元处理到VLC接收播放的关键环节。资源中附带的会话描述文件可直接供VLC读取以获取媒体类型、IP与端口等配置,配套的解码处理模块则展示了NAL单元分割与RTP头封装的实现逻辑,便于读者对照理解时间戳、序列号等字段在接收端重组播放中的作用。目前已有333人学习下载,适合作为实时视频传输入门与联调排错的参考样例。
1. 从一次花屏排查说起:RTP 发 H264 到底难在哪
去年帮一个做远程巡检的团队看问题,现场画面每隔十几秒就糊一片,日志里 RTP 包序号却是连续的。抓包一看,发送端把一整帧 H264 塞进一个 RTP 包,超过 MTU 直接被分片,接收端重组失败,解码器拿到半帧数据只能花屏。这个场景很典型:H264 编码器吐出来的是 NALU 流,RTP 是面向报文、有 MTU 上限的传输协议,两者之间隔着一层必须自己写的打包逻辑。这份资源围绕的就是这层逻辑——怎么把 H264 的 NALU 按 RTP 规范切分、加头、打时间戳,再通过 UDP 发出去。适合正在做音视频推流、监控回传、屏幕共享这类功能,又不想一上来就搬整个 WebRTC 栈的开发者。下面按「协议怎么对应 → 代码怎么组织 → 怎么跑起来 → 怎么改」的顺序拆。
2. 协议映射与工程结构:NALU 到 RTP 包的完整链路
2.1 先理清 H264 的 NALU 和 RTP 的对应关系
H264 码流的基本单位是 NALU,每个 NALU 前面通常带 4 字节起始码00 00 00 01(或 3 字节00 00 01)。NALU 头第一个字节里藏着nal_ref_idc和nal_unit_type,后者决定这是 SPS、PPS、IDR 还是普通 P 帧切片。RTP 这边,每个包有 12 字节固定头,关键字段是序列号sequence number、时间戳timestamp、标记位marker和负载类型payload type。
两者对接的核心规则有三条。第一,小 NALU 直接塞进单个 RTP 包,去掉起始码,NALU 头原样保留。第二,大 NALU 超过 MTU 时要拆成 FU-A 分片,每个分片有自己的 FU indicator 和 FU header,首片置 S 位、末片置 E 位。第三,同一个视频帧的所有 RTP 包共享同一个时间戳,帧最后一个包的 marker 置 1,接收端靠这个判断帧边界。
提示:MTU 一般按 1500 字节算,减去 IP 头 20 字节、UDP 头 8 字节、RTP 头 12 字节,单个 RTP 负载实际可用约 1460 字节,工程上常取 1400 留余量。
2.2 工程目录怎么划分
这类发送端项目结构不复杂,但职责边界要清楚,否则后面加音频、加 RTCP 会乱。常见做法是按「采集/读取 → 编码 → 打包 → 发送」四层拆:
h264_rtp_sender/ ├── src/ │ ├── main.c # 入口,参数解析与主循环 │ ├── h264_reader.c # 从文件或编码器读 NALU │ ├── rtp_packet.c # RTP 头构造、FU-A 分片 │ ├── rtp_sender.c # UDP socket 封装与发送 │ └── timestamp.c # 90kHz 时钟与帧时间戳 ├── include/ │ └── rtp_packet.h # 对外结构体与函数声明 ├── test/ │ └── recv_dump.c # 简易接收端,落盘验证 └── Makefileh264_reader只负责吐出一个完整的 NALU(含类型和长度),不关心怎么发;rtp_packet是纯函数式的打包层,输入 NALU 和帧序号,输出一个或多个待发缓冲;rtp_sender只管sendto。这样分层的好处是,后面把文件源换成摄像头编码器,只动h264_reader,打包和发送逻辑一行不用改。
2.3 RTP 头构造的关键字段
RTP 固定头 12 字节,逐字段填错一个就会导致接收端解析失败。下面这段是构造单包 RTP 头的典型写法:
/* 构造 12 字节 RTP 固定头,返回写入长度 */ int build_rtp_header(uint8_t *buf, uint16_t seq, uint32_t ts, uint32_t ssrc, int marker, int payload_type) { buf[0] = 0x80; /* V=2, P=0, X=0, CC=0 */ buf[1] = (marker ? 0x80 : 0x00) | (payload_type & 0x7F); buf[2] = (seq >> 8) & 0xFF; /* 序列号高字节,网络序 */ buf[3] = seq & 0xFF; buf[4] = (ts >> 24) & 0xFF; /* 时间戳,90kHz 时钟 */ buf[5] = (ts >> 16) & 0xFF; buf[6] = (ts >> 8) & 0xFF; buf[7] = ts & 0xFF; buf[8] = (ssrc >> 24) & 0xFF; /* 同步源标识,会话内唯一 */ buf[9] = (ssrc >> 16) & 0xFF; buf[10] = (ssrc >> 8) & 0xFF; buf[11] = ssrc & 0xFF; return 12; }buf[0]固定0x80表示版本 2、无填充、无扩展、无 CSRC;buf[1]高位置 marker,低 7 位是 payload type,H264 动态负载类型常用 96~127 区间,双方约定一致即可。序列号和时间戳都必须按大端写入,这是新手最容易翻车的地方——本机小端直接 memcpy 过去,接收端读出来就是乱序。ssrc用来区分同一地址上的多路流,单路场景随便给个固定值也行,但多路复用时必须唯一。
2.4 FU-A 分片:大 NALU 的拆包规则
一个 NALU 超过约 1400 字节就得走 FU-A。FU-A 的负载结构是 1 字节 FU indicator 加 1 字节 FU header,再接分片数据。indicator 的 type 字段固定为 28,header 里 S、E、R 三位控制首末片,低 5 位保留原始 NALU 类型:
/* 将一个大 NALU 拆成 FU-A 分片逐个发送 */ void send_fu_a(int sock, const uint8_t *nalu, int len, uint16_t *seq, uint32_t ts, uint32_t ssrc) { int max_payload = 1400; int offset = 1; /* 跳过原始 NALU 头 */ uint8_t nal_type = nalu[0] & 0x1F; uint8_t nri = nalu[0] & 0x60; while (offset < len) { int chunk = len - offset; if (chunk > max_payload) chunk = max_payload; int is_first = (offset == 1); int is_last = (offset + chunk >= len); uint8_t pkt[1500]; int hdr = build_rtp_header(pkt, (*seq)++, ts, ssrc, is_last, 96); pkt[hdr] = nri | 28; /* FU indicator: type=28 */ pkt[hdr + 1] = (is_first ? 0x80 : 0) | (is_last ? 0x40 : 0) | nal_type; memcpy(pkt + hdr + 2, nalu + offset, chunk); sendto(sock, pkt, hdr + 2 + chunk, 0, ...); offset += chunk; } }offset从 1 开始,因为原始 NALU 头已经被拆进 FU indicator 和 FU header,不能再重复发送。nri保留原始 NALU 的参考级别,接收端重组时要用。首片 S 位置 1、末片 E 位置 1,中间片两位都是 0。marker 只在末片置 1,表示这一帧结束。序列号每发一个分片自增一次,时间戳整帧不变——这两条是接收端能正确重组的前提。
3. 跑通发送端:从编译到抓包验证
3.1 编译与依赖
这个发送端只依赖系统 socket 和标准 C 库,没有第三方库,Makefile里gcc -O2 -Wall编出可执行文件即可。如果h264_reader要接编码器,才需要额外链接对应编码库,文件源场景不需要。编译命令:
make clean && make # 产物:./h264_rtp_sender编译时留意-Wall的告警,尤其是sendto返回值没处理、uint16_t序列号溢出这类,编译器会提示隐式截断,别忽略。
3.2 准备测试码流
手头没有现成 H264 文件的话,用 ffmpeg 生成一段带 SPS/PPS 的裸流最省事:
# 生成 10 秒 640x480 的 H264 裸流,关闭 B 帧便于时间戳对齐 ffmpeg -f lavfi -i testsrc=size=640x480:rate=25 -t 10 \ -c:v libx264 -profile:v baseline -x264-params keyint=25 \ -bsf:v h264_mp4toannexb -f h264 test.h264-profile:v baseline去掉 B 帧,时间戳单调递增,排查时少一层干扰;keyint=25让每秒一个关键帧,方便观察 SPS/PPS 和 IDR 的发送时机。裸流里每个 NALU 前带起始码,h264_reader就是按起始码切分的。
3.3 启动发送并抓包确认
发送端启动参数一般包括目标地址、端口、码流文件和帧率:
./h264_rtp_sender -f test.h264 -h 192.168.1.50 -p 5004 -r 25-r 25告诉发送端按 25fps 的节奏推进时间戳,时间戳增量是90000 / 25 = 3600。发出去之后,最直接的验证是在接收侧抓包:
# 在接收机上抓 UDP 5004,导出 pcap 后用分析工具看 tcpdump -i eth0 -w rtp.pcap udp port 5004打开 pcap 重点看三件事:序列号是否连续无跳变、同一帧内时间戳是否一致、大帧是否出现 FU-A 的 S/E 配对。如果序列号在某个点突然回绕,检查uint16_t自增有没有做溢出处理;如果时间戳每包都在变,说明时间戳被错误地按包递增而不是按帧递增。
3.4 用简易接收端落盘验证
抓包只能看头,验证负载是否正确还得把 RTP 还原成 H264 文件再播放。项目里test/recv_dump.c就是干这个的:收包、按序列号排序、去掉 RTP 头、FU-A 重组、补回起始码、写文件。跑通后:
./recv_dump -p 5004 -o out.h264 ffplay out.h264能正常播放且不花屏,说明打包链路是通的。这一步是整个项目最值得先做的验证,比盯着日志猜有效得多。
4. 避坑与排查:五个真实踩过的坑
4.1 花屏但序列号连续
现象:接收端画面周期性花屏,抓包看序列号连续、无丢包。原因:单个 RTP 包负载超过 MTU,被 IP 层分片,接收端按 RTP 边界解析时拿到的是不完整 NALU。解决:打包前判断 NALU 长度,超过 1400 字节一律走 FU-A,不要图省事直接塞。
4.2 首帧黑屏或解码器报错
现象:播放器起播黑屏几秒,或直接报 SPS/PPS 缺失。原因:发送端从文件中间开始读,或者关键帧前的 SPS/PPS 没跟着 IDR 一起发。解决:每个 IDR 帧前强制重发 SPS 和 PPS,接收端才能随时加入解码。这也是为什么测试码流要设keyint,方便观察这个时机。
4.3 时间戳跳变导致卡顿
现象:画面能播但每隔一阵卡一下。原因:时间戳用了系统毫秒时钟,没有换算成 90kHz,或者按包递增而非按帧递增。解决:统一用 90kHz 时钟,每帧时间戳 = 帧序号 × (90000 / fps),整帧所有分片共享同一个值。
4.4 序列号回绕后接收端乱序
现象:连续发几十分钟后接收端开始乱序重组。原因:uint16_t序列号到 65535 后回绕到 0,接收端排序逻辑没处理回绕。解决:接收端比较序列号时用带符号差值判断新旧,发送端自增本身没问题,问题在接收侧的比较方式。
4.5 多路流互相串包
现象:同一台机器发两路流到同一端口,接收端画面错乱。原因:两路流用了相同ssrc或相同端口,接收端无法区分。解决:每路流分配唯一ssrc,端口也分开;如果必须同端口,接收端按ssrc分流。
5. 二次开发:把文件源换成实时编码器并加 RTCP 反馈
5.1 替换数据源的改造点
文件源换成实时编码器,改动集中在h264_reader这一层。编码器回调里拿到的是带起始码的 NALU 缓冲,接口保持「吐出一个完整 NALU + 类型 + 是否关键帧」不变,上层打包和发送完全不用动。我一般会在这个接口上加一个帧序号参数,让时间戳模块直接按帧序号算,避免依赖系统时钟导致抖动。
/* 编码器回调适配:把编码输出转成统一的 NALU 结构 */ typedef struct { const uint8_t *data; /* 含起始码的 NALU */ int len; int is_key; /* 是否 IDR,决定要不要补发 SPS/PPS */ uint32_t frame_idx; /* 帧序号,用于时间戳计算 */ } nalu_frame_t; int on_encoded(nalu_frame_t *f, void *ctx) { if (f->is_key) { send_sps_pps(ctx); /* 关键帧前补参数集 */ } return rtp_send_nalu(ctx, f->data, f->len, f->frame_idx * (90000 / g_fps)); }frame_idx由编码器侧维护,g_fps是采集帧率,两者相乘得到 90kHz 时间戳。这样即使编码器偶尔丢帧,时间戳仍然按真实帧序推进,接收端不会因为时间戳跳变而卡顿。
5.2 加 RTCP 接收报告做码率自适应
单向发送没有反馈,网络一差就只能硬扛。加 RTCP 的 RR 报文后,发送端能拿到丢包率和往返时延,据此调编码码率。改造思路是开一个 RTCP 接收线程,解析 RR 里的fraction lost字段,超过阈值就通知编码器降码率。这部分不需要改 RTP 打包逻辑,属于旁路增强。
| 改造项 | 改动文件 | 验证方法 |
|---|---|---|
| 实时编码源 | h264_reader.c | 接摄像头,ffplay 实时播放 |
| 关键帧补参数集 | rtp_packet.c | 中途加入接收端能正常起播 |
| RTCP 反馈 | 新增 rtcp.c | 人为限速,观察码率是否下降 |
5.3 一个容易忽略的验证习惯
改完打包逻辑后,别只看「能播」,要拿 pcap 逐帧核对 FU-A 的 S/E 配对和 marker 位置。我有次改分片大小,把末片判断写成offset + chunk > len,边界帧少发一个字节,播放器偶发报错,查了大半天。从那以后每次动分片逻辑,我都强制走一遍「抓包 → 核对首末片 → 落盘播放」三步,不省这一步。希望帮到你。
本文还有配套的精品资源,点击获取