☰
RTP发送H264实战:从NAL切分到FU-A分片与避坑指南
2026/10/28 15:55:20 网站建设 项目流程

简介:这份资源面向音视频开发初学者与流媒体传输实践者,聚焦用RTP协议封装并发送H264编码数据包这一核心问题,帮助读者打通从编码数据到VLC解码播放的完整链路。压缩包共21个文件、约929KB,包含264与h264视频样本、sdp会话描述文件、C++源码与头文件、VC6工程配置及编译产物等,覆盖从NAL单元处理到工程构建的各个环节。已有333人学习下载,可作为动手实验的参考素材。资源中附带的会话描述文件可直接供VLC读取以获取媒体类型、IP与端口等配置,配合NAL单元处理相关程序,读者能对照理解RTP头中时间戳、序列号等字段如何支撑接收端重组与播放,并借助现成工程快速复现发送端流程、排查端口与配置不一致等常见问题,适合作为RTP传输H264的入门实践样本。

1. 从一次花屏排查说起:RTP 发 H264 到底难在哪

去年帮一个做远程巡检的团队看问题,现象很典型:本地播放器打开 H264 文件一切正常,一旦走网络实时传输,画面就开始花屏、卡顿,偶尔还整段绿屏。抓包一看,RTP 包序号是连续的,时间戳也没跳变,问题出在打包环节——一个 I 帧被硬生生塞进单个 RTP 包,超过 MTU 后被中间设备分片,接收端重组失败。这就是 RTP 发送 H264 最容易翻车的地方:协议本身不复杂,难的是把 H264 的 NAL 单元按 RTP 的规矩切好、标好、发对。

这份资源围绕「使用 RTP 协议发送 H264 数据包」展开,核心解决的是把编码器产出的 H264 码流,按 RFC 6184 的打包规则封装成 RTP 包并通过 UDP 发出去。适合两类人:一是做音视频实时传输、需要自己实现发送端的开发者;二是手里有 H264 裸流、想搞明白 RTP 封装细节的工程师。下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆开讲,H264 和 RTP 这两个词会反复出现,因为整个项目的骨架就是它们俩的咬合。

2. 协议底座与工程结构:H264 的 NAL 怎么对上 RTP 的包

2.1 先理清两层封装关系

H264 码流的基本单位是 NAL 单元(Network Abstraction Layer Unit),每个 NAL 前面带一个起始码(常见0x00000001或0x000001),后面跟 NAL 头和载荷。RTP 则是面向实时传输的封装协议,固定头 12 字节,带序列号、时间戳、SSRC 等字段。发送端的任务就是把「起始码 + NAL」这种文件存储格式,转成「RTP 头 + NAL 载荷」的网络传输格式。

RFC 6184 定义了三种打包模式:单一 NAL 单元模式(一个 NAL 一个 RTP 包)、分片单元模式(FU-A,大 NAL 拆成多包)、聚合包模式(STAP-A,多个小 NAL 合成一包)。工程里最常用的是前两种,聚合包用得少。判断依据很简单:NAL 长度加 RTP 头不超过 MTU(一般 1400 字节左右)就走单一包,超了就走 FU-A 分片。这个判断逻辑是整个发送端的核心分支,后面代码会展开。

2.2 目录结构长什么样

这类项目的工程结构通常不复杂,按职责分层即可。下面是我拆过的同类项目里比较典型的一种组织方式,实际资源可能略有出入,但模块划分基本一致:

rtp_h264_sender/ ├── src/ │ ├── main.c # 入口,读文件/接编码器,驱动发送循环 │ ├── h264_parser.c # 从码流中切出 NAL 单元 │ ├── rtp_packet.c # RTP 头构造、打包、发送 │ ├── rtp_session.c # socket 初始化、目标地址管理 │ └── utils.c # 字节序、日志、时间戳工具 ├── include/ │ ├── h264_parser.h │ ├── rtp_packet.h │ └── rtp_session.h ├── test/ │ └── sample.h264 # 测试用裸流 ├── Makefile └── README.md

分层意图很清楚:h264_parser只负责「从字节流里找 NAL」,不关心网络;rtp_packet只负责「把 NAL 变成 RTP 包」,不关心 NAL 从哪来;rtp_session管 socket 和地址;main做编排。这种切法好处是单测好写——解析和打包都能脱离网络单独验证,排查问题时能快速定位是解析错了还是打包错了。

2.3 关键数据结构

发送端通常要维护一个会话上下文,把 socket、目标地址、序列号、时间戳、SSRC 都放进去。序列号每发一个 RTP 包加一,时间戳按帧率递增(同一帧的多个分片共享同一时间戳),SSRC 标识一路流。下面是一个常见的结构定义:

typedef struct { int sockfd; // UDP socket struct sockaddr_in dst; // 目标地址 uint16_t seq; // RTP 序列号,每包 +1 uint32_t timestamp; // 采样时间戳,按帧递增 uint32_t ssrc; // 流标识 uint8_t payload_type; // 载荷类型,H264 常用 96 } rtp_session_t;

seq和timestamp是最容易出问题的两个字段:seq回绕(65535 之后归零)要自然处理,timestamp必须保证同一帧的所有分片一致,否则接收端会把一帧拆成多帧渲染,表现为画面撕裂。payload_type用动态类型 96 是常见做法,接收端 SDP 里对应声明即可。

3. 核心链路拆解:从 NAL 切分到 RTP 发包的完整实现

3.1 第一步:从码流里切出 NAL 单元

H264 裸流里 NAL 之间用起始码分隔,解析的关键是找到每个起始码的位置,两个起始码之间的字节就是一个 NAL。注意起始码可能是 3 字节也可能是 4 字节,要统一处理。常见做法是扫描0x000001,然后判断前一个字节是否为0x00来决定是否算 4 字节起始码:

// 在 buf 中查找下一个起始码,返回起始码后第一个字节的偏移 static int find_start_code(const uint8_t *buf, int len, int from) { for (int i = from; i + 3 < len; i++) { if (buf[i] == 0x00 && buf[i+1] == 0x00 && buf[i+2] == 0x01) { return i + 3; // 跳过起始码,指向 NAL 头 } } return -1; } // 切分:返回 NAL 起始偏移和长度 int h264_next_nal(const uint8_t *buf, int len, int *offset, int *nal_len) { int start = find_start_code(buf, len, *offset); if (start < 0) return -1; int next = find_start_code(buf, len, start); *offset = start; *nal_len = (next < 0 ? len : next - 3) - start; // 减去下一个起始码 return 0; }

逻辑说明:find_start_code返回的是 NAL 头的位置(起始码之后),h264_next_nal用「当前 NAL 头」到「下一个起始码之前」的区间算长度。参数from用于迭代推进,避免每次从头扫。这里有个细节:最后一个 NAL 后面没有起始码,长度要用缓冲区末尾减,代码里next < 0分支就是处理这个。翻车点在于有些码流起始码前还有填充字节,如果长度算多了,NAL 尾部会带垃圾数据,接收端解码报错。

3.2 第二步:读 NAL 头,判断分片还是单包

NAL 头第一个字节的结构是F(1) | NRI(2) | Type(5),低 5 位是 NAL 类型。类型 1 是非 IDR 的编码切片,5 是 IDR 切片(关键帧),7 是 SPS,8 是 PPS,6 是 SEI。发送端要根据 NAL 总长度决定打包方式:

#define RTP_MAX_PAYLOAD 1400 // 留出 RTP 头和 UDP/IP 头的余量 void send_nal(rtp_session_t *s, const uint8_t *nal, int len) { uint8_t nal_type = nal[0] & 0x1F; if (len <= RTP_MAX_PAYLOAD) { send_single_nal(s, nal, len); // 单一 NAL 模式 } else { send_fu_a(s, nal, len); // FU-A 分片模式 } }

RTP_MAX_PAYLOAD取 1400 是血泪经验:以太网 MTU 1500,减去 IP 头 20、UDP 头 8、RTP 头 12,理论载荷 1460,但实际链路里 PPPoE、隧道封装还会再吃掉一些,留到 1400 比较稳。如果取满 1460,某些网络环境下照样被分片,前面说的花屏就是这么来的。nal_type这里其实只用于日志和调试,真正的分支依据是长度,但类型信息在 FU-A 打包时要用到。

3.3 第三步:FU-A 分片的头构造

FU-A 是重点。分片时,原始 NAL 头不能直接放进去,要拆成 FU indicator 和 FU header 两个字节。FU indicator 的高 3 位沿用原 NAL 的 F 和 NRI,低 5 位固定为 28(FU-A 类型);FU header 的 S 位标记起始分片,E 位标记结束分片,R 位保留,低 5 位放原始 NAL 类型:

void send_fu_a(rtp_session_t *s, const uint8_t *nal, int len) { uint8_t fu_indicator = (nal[0] & 0xE0) | 28; // 保留 F/NRI,类型改 28 int pos = 1; // 跳过原 NAL 头 int remain = len - 1; while (remain > 0) { int chunk = remain > RTP_MAX_PAYLOAD ? RTP_MAX_PAYLOAD : remain; uint8_t fu_header = nal[0] & 0x1F; // 原始 NAL 类型 if (pos == 1) fu_header |= 0x80; // S=1,首片 if (chunk == remain) fu_header |= 0x40; // E=1,末片 uint8_t pkt[RTP_MAX_PAYLOAD + 2]; pkt[0] = fu_indicator; pkt[1] = fu_header; memcpy(pkt + 2, nal + pos, chunk); rtp_send_payload(s, pkt, chunk + 2, 0); // marker 见下节 pos += chunk; remain -= chunk; } }

逻辑说明:fu_indicator用nal[0] & 0xE0保住 F 和 NRI 位,低 5 位或上 28;fu_header低 5 位是原始类型,首片置 S 位(0x80),末片置 E 位(0x40)。参数pos从 1 开始是因为原 NAL 头已经被拆进两个新头里,不再作为载荷。这里最容易错的是 S/E 位:如果首片没置 S,接收端不知道从哪开始重组;末片没置 E,接收端一直等,表现为画面卡住不动。还有一种情况是 NAL 只有 1 字节(极少见),remain为 0 循环不执行,要单独兜底。

3.4 第四步:RTP 头构造与发送

RTP 固定头 12 字节,字段按网络字节序写。marker 位用于标记一帧的最后一个包,接收端靠它判断帧边界。同一帧的多个分片共享时间戳,只有最后一个包 marker 置 1:

void rtp_send_payload(rtp_session_t *s, const uint8_t *payload, int len, int is_last_of_frame) { uint8_t hdr[12]; hdr[0] = 0x80; // V=2 hdr[1] = (is_last_of_frame ? 0x80 : 0x00) | s->payload_type; hdr[2] = s->seq >> 8; // 序列号高字节 hdr[3] = s->seq & 0xFF; hdr[4] = s->timestamp >> 24; // 时间戳,大端 hdr[5] = (s->timestamp >> 16) & 0xFF; hdr[6] = (s->timestamp >> 8) & 0xFF; hdr[7] = s->timestamp & 0xFF; hdr[8] = s->ssrc >> 24; hdr[9] = (s->ssrc >> 16) & 0xFF; hdr[10] = (s->ssrc >> 8) & 0xFF; hdr[11] = s->ssrc & 0xFF; uint8_t buf[1500]; memcpy(buf, hdr, 12); memcpy(buf + 12, payload, len); sendto(s->sockfd, buf, 12 + len, 0, (struct sockaddr *)&s->dst, sizeof(s->dst)); s->seq++; // 每包递增,自然回绕 }

逻辑说明:hdr[0]固定 0x80 表示版本 2;hdr[1]的 marker 位在帧末包置 1,低 7 位是载荷类型;序列号和时间戳都按大端写,这是 RTP 的硬性要求,写反了接收端解析出来是乱序。seq++对uint16_t自然回绕,不用特殊处理。发送用sendto走 UDP,不保证可靠,丢包由上层或接收端的 NACK/FEC 机制处理,发送端这里不做重传。

3.5 时间戳怎么推进

时间戳的推进规则是「同一帧共享,帧间递增」。假设帧率 25fps,时钟频率 90000Hz(视频常用),每帧增量就是 90000/25 = 3600。编码器输出一帧时,先给这一帧的所有 NAL 分片用同一个时间戳,发完再统一加增量:

#define CLOCK_RATE 90000 #define FPS 25 #define TS_STEP (CLOCK_RATE / FPS) // 每处理完一帧调用 s->timestamp += TS_STEP;

如果时间戳按包递增而不是按帧递增,接收端会认为每个包都是独立帧,播放速度直接起飞。这个坑我在早期项目里踩过,现象是画面像快进,查了半天才发现时间戳加错了位置。

4. 跑起来与联调:编译、发送、抓包验证一条龙

4.1 编译与依赖

这类工程一般只依赖系统 socket 库,Linux 下直接make即可,不需要额外第三方库。如果资源里带 Makefile,核心编译命令大致是:

make # 生成 rtp_h264_sender ./rtp_h264_sender -f test/sample.h264 -a 192.168.1.100 -p 5004

参数含义:-f指定 H264 裸流文件,-a是接收端 IP,-p是接收端口(RTP 常用 5004)。如果资源没有 Makefile,手动编译也就一行:

gcc -O2 -Wall -Iinclude src/*.c -o rtp_h264_sender

-Wall建议保留,NAL 解析里的有符号/无符号比较问题靠它兜底。-O2对发送循环有实际收益,尤其高码率场景。

4.2 接收端怎么验证

发送端跑起来后,最直接的验证是用 ffplay 或 VLC 接收。以 ffplay 为例,需要写一个 SDP 文件告诉它载荷类型和时钟频率:

m=video 5004 RTP/AVP 96 a=rtpmap:96 H264/90000 c=IN IP4 0.0.0.0

然后ffplay -protocol_whitelist file,udp,rtp test.sdp。如果画面正常,说明打包链路通了;如果花屏,先看是不是 FU-A 的 S/E 位错了;如果黑屏但抓包有数据,多半是 SPS/PPS 没发——很多实现只发编码切片,忘了把 SPS(类型 7)和 PPS(类型 8)也发出去,接收端没有参数集就没法解码。常见做法是在第一个 IDR 帧之前,把 SPS 和 PPS 各发一遍,或者用 STAP-A 聚合发送。

4.3 抓包看什么

Wireshark 抓 UDP 5004 端口,重点看三个地方:一是 RTP 序列号是否连续,有跳变说明发送端漏发或网络丢包;二是同一帧的分片时间戳是否一致,不一致就是时间戳推进逻辑错了;三是 FU-A 包的 S/E 位,首片 S=1、末片 E=1,中间片都为 0。Wireshark 自带 RTP 解析,能把 H264 载荷类型标出来,比肉眼看字节快得多。

提示:抓包时如果看到 RTP 包长度接近 1500,基本可以判定 MTU 没留余量,把RTP_MAX_PAYLOAD调到 1400 以下再试。

5. 避坑与排查:五个真实踩过的坑

5.1 现象:画面周期性花屏,抓包序列号连续

原因:单个 NAL 超过 MTU 却没走 FU-A,被 IP 层分片,中间设备重组失败。解决:在send_nal里严格按长度判断,超过RTP_MAX_PAYLOAD一律走分片,不要心存侥幸觉得「这个包应该能过」。

5.2 现象:接收端一直等,画面卡在第一帧

原因:FU-A 末片 E 位没置,接收端认为分片没结束。解决:检查chunk == remain的判断,注意remain是剩余载荷长度,不是总长度,边界条件容易写错。

5.3 现象:画面快进或慢放

原因:时间戳按包递增而非按帧递增,或时钟频率和 SDP 声明不一致。解决:确认同一帧所有分片共享时间戳,帧间增量等于CLOCK_RATE / FPS,SDP 里H264/90000的 90000 要和代码里CLOCK_RATE对上。

5.4 现象:黑屏但抓包有 RTP 数据

原因:SPS/PPS 没发送,接收端缺参数集无法解码。解决:在首个 IDR 帧前补发 SPS 和 PPS,或者确认编码器输出里带了这两个 NAL 且被正确切分。

5.5 现象:局域网正常,跨网段就丢包严重

原因:UDP 包太大触发路径 MTU 问题,或发送速率超过链路带宽。解决:把RTP_MAX_PAYLOAD降到 1200 左右,发送循环里加简单 pacing(按帧间隔 sleep),别一股脑全速发。

6. 二次开发:把发送端改成能接编码器的实时链路

拿到这份代码后,最常见的改造方向是把「读文件发送」改成「接实时编码器发送」。文件发送是离线节奏,读多快发多快;实时链路要按帧率节流,还要处理编码器输出不完整帧的情况。改造点集中在main.c的驱动循环:把文件读取换成编码器回调,每拿到一帧就切 NAL、打包、发送,发完按帧间隔 sleep。

// 伪代码:实时发送循环 while (encoder_running) { frame_t *f = encoder_get_frame(); // 阻塞或回调拿一帧 if (!f) continue; int off = 0, nal_len; while (h264_next_nal(f->data, f->size, &off, &nal_len) == 0) { send_nal(&session, f->data + off, nal_len); off += nal_len; } session.timestamp += TS_STEP; // 一帧发完才推进 usleep(1000000 / FPS); // 按帧率节流 }

改造时有两个验证方法很实用:一是用固定帧率的测试流跑,看接收端播放时长和源文件时长是否一致,偏差大说明节流或时间戳有问题;二是故意在发送循环里加 10ms 抖动,观察接收端缓冲行为,提前暴露时序问题。另外,如果要做多路流,ssrc必须每路不同,seq和timestamp各自独立维护,别共用全局变量——这个错误我在一个多路项目里犯过,两路流混在一起,接收端直接乱套。

从那以后我每次改发送端,都强制先用 Wireshark 抓一遍首帧的 SPS/PPS/IDR 三个包,确认类型、分片位、时间戳全对,再往下联调。这个习惯帮我省了太多来回。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询