
简介这份资源面向音视频开发初学者与流媒体传输实践者聚焦用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[i1] 0x00 buf[i2] 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 是 SPS8 是 PPS6 是 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 位固定为 28FU-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; // S1首片 if (chunk remain) fu_header | 0x40; // E1末片 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 位或上 28fu_header低 5 位是原始类型首片置 S 位0x80末片置 E 位0x40。参数pos从 1 开始是因为原 NAL 头已经被拆进两个新头里不再作为载荷。这里最容易错的是 S/E 位如果首片没置 S接收端不知道从哪开始重组末片没置 E接收端一直等表现为画面卡住不动。还有一种情况是 NAL 只有 1 字节极少见remain为 0 循环不执行要单独兜底。3.4 第四步RTP 头构造与发送RTP 固定头 12 字节字段按网络字节序写。marker 位用于标记一帧的最后一个包接收端靠它判断帧边界。同一帧的多个分片共享时间戳只有最后一个包 marker 置 1void 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; // V2 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 表示版本 2hdr[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 文件告诉它载荷类型和时钟频率mvideo 5004 RTP/AVP 96 artpmap:96 H264/90000 cIN 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 位首片 S1、末片 E1中间片都为 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 / FPSSDP 里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 三个包确认类型、分片位、时间戳全对再往下联调。这个习惯帮我省了太多来回。希望帮到你。本文还有配套的精品资源点击获取