简介:这套名为“使用RTP协议发送H264数据包”的下载资源,面向想掌握流媒体传输底层实现的开发者,重点解决从H264裸流到RTP打包、再经VLC解码播放的学习闭环。压缩包共21个文件,整体约929KB,主要包含H264测试序列、C++工程源码、可执行程序与一份会话描述文件,目录结构简洁便于快速定位。测试序列可直接用于验证打包效果,源码展示NAL单元起始码定位、负载分割、时间戳/序列号填充等关键逻辑;通过源码还能看清发送端如何从文件读取H264数据并组装RTP包。会话描述文件帮助快速配置VLC接收端参数。已有329人次浏览/学习,属于小型但完整的工程示例。通过学习可掌握RTP载荷H264的基本封装格式,理解发送端与接收端的协作机制,也能为后续在项目里改造视频发送流程、排查VLC花屏或无法播放等问题提供可运行的参考样板,适合作为网络视频传输方向课程设计或自学入门的配套资料。
1. 用 RTP 把 H264 推出去:这个发包器 Demo 到底解决了什么
做过视频监控或实时音视频的人应该都有体会:手里拿到一段 H264 裸流,或者在嵌入式板子上编码出 H264,下一步想让 VLC、WebRTC 或自研播放器流畅播放,中间隔着的不是网络带宽,而是“怎么把 H264 包装成 RTP 包”。直接往 UDP socket 里丢 H264 是行不通的,播放器会直接罢工。这个源码包做的事很具体:读取 H264 文件或实时编码帧,按规范切成 NAL 单元,用小包、FU-A 分片、STAP-A 聚合三种方式封装成 RTP 包,再通过 UDP 发出去。它不依赖流媒体服务器,自己就能跑通一条推流链路。适合正在做软硬件视频推流、调试播放兼容性或者研究 RFC 6184 的人。下面把实现逻辑、参数选择和踩过的坑拆开讲,照着复现一遍,你就能按自己的视频尺寸和帧率改了。
2. H264 与 RTP 的匹配:NAL 单元、载荷类型与三种打包模式
2.1 先分清 Annex B 和 AVCC:文件流与 RTP 载荷的差异
写 H264 RTP 发送器之前,第一步不是写 RTP 头,而是确认输入的 H264 是什么容器格式。H264 裸流有两套常见组织方式:一套是 Annex B,每个 NAL 单元前面有起始码00 00 00 01或00 00 01,摄像头裸流、TS 流、很多嵌入式编码器直接输出这种;另一套是 AVCC,也叫 Length-Prefixed,用 4 字节大端长度字段代替起始码,MP4、FLV 解封装后出来的就是这种。
这个 Demo 包默认按 Annex B 处理,因为推流场景里最常拿到的是起始码格式。如果你手里的流是 AVCC,必须先把长度前缀换掉,否则切出来的“NAL 单元”会把长度字节一起带进 RTP 载荷,播放端解码直接错乱。
// 查找下一个起始码,返回起点偏移与 NAL 长度 int find_next_start_code(const uint8_t *buf, int size, int offset, int *nal_start, int *nal_len) { int i = offset; while (i < size - 3) { if (buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1) { *nal_start = i; // 如果是 00 00 00 01,跳过 4 字节 if (i > 0 && buf[i - 1] == 0) { *nal_start = i - 1; } *nal_len = size - *nal_start; return *nal_start + 3; // 返回下一个搜索起点 } i++; } return -1; }这段代码是从文件缓冲区里扫起始码用的。参数offset是上一次找到的 NAL 末尾,nal_start是本次 NAL 的起始位置,这里的判断比直接memmem多了一步“回看前一字节”,是为了兼容 4 字节起始码。很多初学者只匹配00 00 01,遇到真正编码器输出的 4 字节起始码时,前面会多出一个00,导致 NAL 头部偏移了一位。切出 NAL 后记得把nal_data指向nal_start + 4(或 +3),而nal_len是 NAL 头加负载的总长度,RTP 打包时只关心 NAL 头里的 type 和 NRI,这两个字段就在 NAL 的第一个字节里。
2.2 RTP 载荷头与 H264 打包模式怎么选
RFC 6184 把 H264 的 RTP 打包分成了三种模式:单 NAL 单元模式(Single NAL Unit)、非交错模式(Non-Interleaved)、交错模式(Interleaved)。实际网络推流里,前面两种最常用。选型逻辑不复杂:
- NAL 单元总长度不超过 MTU 预算,直接单 NAL 打包,RTP 载荷就是 NAL 头加原始数据,没有额外头,效率最高;
- NAL 单元太大,比如一个 IDR 帧可能几十 KB,必须切成多个 RTP 包,用 FU-A 分片,每个分片带一个 FU 指示头和 FU 头,标记分片起始和结束;
- SPS/PPS 这种几十字节的参数集,单独发会浪费包头,而且容易丢,API 文档里经常要求“SPS/PPS 要伴随关键帧发送”,用 STAP-A 聚合包把它们合成一个 RTP 包发出去最稳。
这里有个容易误解的地方:RTP 包的payload_type(PT)不是固定的 96,96 只是 RFC 中动态类型的建议值,实际上你可以自己定。但必须保证发送端和接收端约定一致。这个 Demo 默认用 96,标头里加了个宏RTP_PAYLOAD_TYPE_H264 96,想换直接改。关键帧的 IDR 和普通 P 帧,在 RTP 层面没有类型区分,它们都写在 NAL 头的nal_unit_type里。RTP 头里的M标记位用来表示“这是当前访问单元(通常是一帧图像)的最后一个 RTP 包”,而不是视频关键帧的标记,别搞混。
2.3 发包器的结构体设计:从帧到 RTP 包的映射关系
先看这个 Demo 里用的最小结构体,不复杂,但每个字段都和 RFC 6184 对应:
typedef struct { uint16_t sequence; // RTP 序号,每发一个包 +1 uint32_t timestamp; // 90000Hz 时间戳 uint32_t ssrc; // 同步源标识 uint8_t payload_type; // 载荷类型,H264 一般用 96 } RtpHeader; typedef struct { uint8_t nal_type; // NAL 单元类型(5 = IDR, 7 = SPS, 8 = PPS) uint8_t nri; // NAL 参考优先级,2 比特 int size; // 原始 NAL 数据长度(含 nal_header) uint8_t *data; // 指向 NAL 原始数据 } NalUnit; typedef struct { RtpHeader hdr; uint8_t payload[1500]; // 一个 RTP 包的最大载荷区 } RtpPacket;RtpHeader对应 RTP 固定头的 12 字节。sequence是从一个会话开始连续递增的,不能每帧从 0 开始,否则接收端重新排序会混乱。timestamp的单位是时钟刻度,不是毫秒,H264 标准建议 90000Hz。ssrc每个发送会话必须唯一,不能所有路推流共用一个值。RtpPacket里payload预留 1500 字节,是因为以太网 MTU 1500,减掉 IP 头 20、UDP 头 8、RTP 头 12,RTP 载荷最多 1460 字节。包大没问题,但超过 MTU 会在 IP 层分片,增加丢包概率和重组开销,后面避坑章节会细说。
3. 从读流到发 UDP:H264 RTP 发送器的完整实现
3.1 按起始码切出 NAL 单元
有了结构体,下一步是把输入文件切成一个个 NAL。这个过程必须逐字节找起始码,不能假设每帧大小固定。H264 的 VCL 层数据是变长的,尤其 IDR 帧和 P 帧大小差很多。这里用 2.1 里找到起始码的逻辑,把每个 NAL 的头和负载存进NalUnit。
int extract_nal_from_h264(FILE *fp, NalUnit *nal) { static uint8_t buf[2 * 1024 * 1024]; static int buf_len = 0; static int scan_pos = 0; int bytes = fread(buf + buf_len, 1, sizeof(buf) - buf_len, fp); buf_len += bytes; int start, len; int next = find_next_start_code(buf, buf_len, scan_pos, &start, &len); if (next < 0 || buf_len < 4) return -1; // 数据不足 // 第一个 NAL:起始码后面才是 NAL 头 nal->data = buf + start + 4; nal->size = len - 4; nal->nal_type = nal->data[0] & 0x1F; nal->nri = (nal->data[0] >> 5) & 0x03; // 把扫描起点移到下一个 NAL 位置 scan_pos = next; return 0; }这段代码用了个静态缓冲区做流式读取,避免了每读一个 NAL 就做一次文件 seek。find_next_start_code返回的next是下一个起始码的位置,所以我们把当前 NAL 的起始码跳过 4 字节得到data。注意nal_type取的是第一字节低 5 位,7 是 SPS,8 是 PPS,5 是 IDR 关键帧。nri是高两位,表示这个 NAL 的参考等级,分片时原样保留,不能丢掉。实际从摄像头实时取流时,输入不是文件而是内存队列,这里换成从队列读即可,切 NAL 的逻辑完全相同。
这个函数有个坑:如果文件里存在00 00 01(3 字节起始码)和00 00 00 01(4 字节起始码)混用的情形,find_next_start_code里的回看逻辑能兼容,但nal->data的跳过字节数必须是 4 或 3,不能写死。你可以在切完后的第一字节& 0x1F判断一下是否是合法 type,如果不是,多半是跳了 4 但实际起始码只有 3 字节。遇到这种情况,把跳过数改成 3 再试。
3.2 普通 NAL 直接打包:RTP 头的序号和时间戳
NAL 切出来之后,先判断它是否需要分片。判断标准就是 NAL 总长度加上 RTP 头是否超过 MTU 预算。一个小于 1400 字节的 NAL,直接封装成单 NAL 包:
int rtp_send_single_nal(RtpContext *ctx, const NalUnit *nal) { RtpPacket pkt; int pkt_len; // 填充 RTP 固定头 pkt.hdr.sequence = ctx->seq++; pkt.hdr.timestamp = ctx->timestamp; pkt.hdr.ssrc = ctx->ssrc; pkt.hdr.payload_type = RTP_PAYLOAD_TYPE_H264; // 单 NAL 模式下,载荷直接就是 NAL 头 + EBSP memcpy(pkt.payload, nal->data, nal->size); pkt.payload_type = 0; // 最低位的 payload_type 仅用于 RTP 头,这里复用为 mark pkt.hdr.mark = 1; // 单包即一帧的结束 pkt_len = 12 + nal->size; int ret = udp_send(ctx->sock, &pkt.hdr, pkt.payload, pkt_len); if (ret < 0) { printf("send error, seq=%u\n", ctx->seq - 1); return -1; } return 0; }注意 RTP 固定头里M位(mark bit)在这个 Demo 中用hdr.mark表示,它在单 NAL 场景下一定置 1,因为一个 NAL 是一帧的最后一个包。seq是每个发送包自增的,哪怕同一帧里拆了 10 个 FU-A 包,这 10 个包的seq必须连续且各自不同。timestamp在该帧的第一个包设置好后,同一帧后续所有包都沿用同一个值,直到处理下一帧时更新。
这里没有处理扩展头和 CSRC,所以 RTP 固定头正好 12 字节。如果你的传输层用的不是简单 UDP,而是 SRTP 或自定义协议,记得在发送前对固定头做相应处理。发送函数udp_send是 Demo 里封装好的,本质上就是sendto加一个目标地址结构体,不再展开。
3.3 超过 MTU 用 FU-A:start/end 位与分片逻辑
当 NAL 单元超过 1400 字节时,必须用 FU-A 分片。比如一个 4096 字节的 IDR NAL,会被切成 3 个 RTP 包:第一个包带 FU 头的 S 位 = 1,最后一个包带 E 位 = 1,中间的包 S/E 都是 0。分片时每个 RTP 包的载荷里都包含“FU 指示头”和“FU 头”,以及 NAL 的一部分数据。
int rtp_send_fu_a(RtpContext *ctx, const NalUnit *nal, int max_payload, uint16_t frame_seq) { const uint8_t *nal_data = nal->data; int nal_size = nal->size; int offset = 0, remaining = nal_size; int payload_head = 2; // FU indicator + FU header 占 2 字节 int pkt_count = 0; while (remaining > 0) { int chunk = (remaining < max_payload - payload_head) ? remaining : max_payload - payload_head; RtpPacket pkt; // 构造 FU indicator,低 5 位 = 28,表示 FU-A uint8_t fu_indicator = (nal->nri << 5) | 28; // 构造 FU header,高 2 位为保留位,接下来是 S/E uint8_t fu_header = 0; if (pkt_count == 0) fu_header |= 0x80; // S = 1 if (chunk >= remaining) fu_header |= 0x40; // E = 1 fu_header |= (nal->nal_type & 0x1F); pkt.payload[0] = fu_indicator; pkt.payload[1] = fu_header; memcpy(pkt.payload + 2, nal_data + offset, chunk); pkt.hdr.sequence = ctx->seq++; pkt.hdr.timestamp = ctx->timestamp; pkt.hdr.ssrc = ctx->ssrc; pkt.hdr.mark = (fu_header & 0x40) ? 1 : 0; // 最后一个分片置 M int pkt_len = 12 + 2 + chunk; udp_send(ctx->sock, &pkt.hdr, pkt.payload, pkt_len); offset += chunk; remaining -= chunk; pkt_count++; } return pkt_count; }这里max_payload传入的是 RTP 载荷区的最大长度,比如 1400。remaining是还剩下的 NAL 字节数,初始等于nal_size。循环里先算chunk,再判断是否超出max_payload - 2,把 FU 头占的 2 字节留给载荷。fu_indicator里的nal->nri必须保留原始 NRI,有的播放器会用它判断丢包优先级,乱填会收到非致命错误。fu_header的 S 和 E 位只在对应分片置位,中间包都是 0,最后一个分片时M位置 1。
这段代码有两点容易翻车:第一,chunk >= remaining这种判断要在取 chunk 之后,不能先减 remaining 再判断;第二,FU-A 的nal_type放在 FU 头的低 5 位,但fu_indicator的低 5 位得固定写 28,不能把原始 NAL type 放进去。有的初学者会把fu_indicator写成(nri<<5)|(nal_type),那会把 FU-A 识别成普通 NAL,接收端直接解码失败。分片数量可以通过nal_size / (max_payload - 2) + 1预先算出来,用来分配发送队列,后面进阶章节再说。
3.4 SPS/PPS 聚合进 STAP-A,并在每个 IDR 前重发
SPS 和 PPS 是解码器初始化和关键帧解码的前提。多数播放器只会在流开始或关键帧前查找参数集,如果你只在文件开头发一次,某些 VLC 版本配合丢包严重的网络就会黑屏。这个 Demo 的做法是把 SPS 和 PPS 打包成一个 STAP-A 聚合包,在每发送一个 IDR 帧之前重发一次,成本只有几十字节,但兼容性提升非常明显。
int rtp_send_stap_a(RtpContext *ctx, const NalUnit *sps, const NalUnit *pps) { RtpPacket pkt; int payload_len = 1; // 第一个字节是 STAP-A 聚合类型 24 pkt.payload[0] = 24; // STAP-A // 加入 SPS:2 字节长度 + NAL 数据 pkt.payload[payload_len++] = (sps->size >> 8) & 0xFF; pkt.payload[payload_len++] = sps->size & 0xFF; memcpy(pkt.payload + payload_len, sps->data, sps->size); payload_len += sps->size; // 加入 PPS pkt.payload[payload_len++] = (pps->size >> 8) & 0xFF; pkt.payload[payload_len++] = pps->size & 0xFF; memcpy(pkt.payload + payload_len, pps->data, pps->size); payload_len += pps->size; pkt.hdr.sequence = ctx->seq++; pkt.hdr.timestamp = ctx->timestamp; // 与关联的关键帧同一时间戳 pkt.hdr.ssrc = ctx->ssrc; pkt.hdr.mark = 0; // 聚合包不是帧结束 return udp_send(ctx->sock, &pkt.hdr, pkt.payload, payload_len); }STAP-A的第一个字节类型固定是 24,后面每段是一个“2 字节大端长度 + NAL 数据”。SPS/PPS 的 NAL 头本身也要跟进去,因为解码端需要从 NAL 头里读 type 和 profile。这里注意长度字段是大端(网络字节序),不要用主机序直接转。Demo 里通常还有一层rtp_send_frame函数,内部先发送 STAP-A 参数集,再对该帧的 NAL 逐个判断是否分片,这里不再重复贴代码,但骨架是:判断nal_type为 7 或 8 时进入聚合发送,为 5 时先发参数集再发送 IDR,其他 NAL 走单包或 FU-A。
4. 避坑指南:H264 RTP 推流五个常见翻车现场
4.1 现象:VLC 播放黑屏,只有进度条在动
原因:最常见的是根本没有发送 SPS/PPS,或者只在文件开头发了一次。播放器通常先在关键帧位置解码,如果参数集丢了一个,后续所有关键帧都无法解码,表现就是黑屏。另一个原因是 SPS/PPS 使用了 STAP-A,但 STAP-A 里的长度字段写错,导致解析器找错位置。
解决:在发送每个 IDR 帧之前,强制重发一次 STAP-A 聚合包,并把 SPS/PPS 与 IDR 帧的时间戳保持一致。我习惯把这个行为放在帧级别调度里:
if (nal_type == NAL_IDR) { rtp_send_stap_a(ctx, &sps_nal, &pps_nal); // 再发送 IDR 本身的 NAL }这样即使网络丢了一个参数集,下一个关键帧还会再带一遍,播放器基本能恢复。
4.2 现象:画面花屏、条带错乱,解码报 “concealment” 错误
原因:切 NAL 时起始码判断出错,最典型的是把 4 字节起始码当 3 字节处理,导致 NAL 数据整体前移了一个字节,解码器拿到的 NAL 头完全不对。也可能是从 AVCC 格式转换时长度字段没有去掉,直接拿 MP4 的 length 前缀当数据发给了 RTP。
解决:先打印每个 NAL 的类型,看 IDR 是不是 5,SPS 是不是 7。如果nal_type第一次不是 5 而是一个很大的数,一定是起始码或者长度转换的问题。在切 NAL 后加一行断言:
assert((nal->data[0] & 0x1F) == 5 || (nal->data[0] & 0x1F) == 7);如果输入不是从文件而是从编码器 buffer 拿的,确认编码器输出的是AVCC还是Annex B,关键看 buffer 的开头是不是00 00 00 01。
4.3 现象:播放端越来越慢,延迟持续增大
原因:RTP 时间戳步进不对。H264 的 RTP 时间戳单位是 90000Hz,如果你打算推 30fps 的视频,每帧的时间戳间隔应该是 90000 / 30 = 3000。有些人直接把帧率当毫秒用,比如写 33 或 40,接收端按 90000Hz 换算成秒就会觉得视频时间走得比真实时间慢,缓冲堆积,延迟越来越大。
解决:在发送每一帧之前更新时间戳,更新量取决于“实际帧间隔”,而不是文件里的解码顺序。如果你从摄像头实时取流,可以用系统时间乘以 90000 转换:
ctx->timestamp += 90000 / fps;注意这里的 fps 是实际发送帧率,不是编码器配置的平均帧率。如果编码器丢帧或者网络拥塞主动丢帧,时间戳增量不能用固定值,更好的做法是取当前系统时间:
ctx->timestamp = (uint32_t)(get_monotonic_us() * 90);4.4 现象:FU-A 分片重组失败,解码器显示 “invalid NAL unit”
原因:FU-A 头里的 S/E 位写反,或者最后一个分片的 M 位没有置 1。也有的人把 FU 指示头的低 5 位写成了 28 以外的值,接收端拿到后认为它是一个普通 NAL 数据,整个解析路径就歪了。用 Wireshark 看包时,如果 FU-A 的 type 字段不是 28,基本就是这个原因。
解决:严格按照 RFC 6184 来。FU 指示头 =(NRI << 5) | 28,FU 头 =(S << 7) | (E << 6) | (R << 5) | type,其中 R 通常为 0。发送时用一个状态变量记录当前分片序号,第一个分片 S 置 1,最后一个分片 E 置 1 且 RTP 头 M 置 1。可以在接收端临时搭一个计数器,核对收到的第一个分片 S 是否为 1,最后一个分片 E 是否为 1。
4.5 现象:多路推流时 A 路画面混到 B 路
原因:多路视频会话复用了同一个RtpContext,或者序列号、SSRC、socket 都是全局唯一。RTP 接收端通过SSRC区分同步源,如果两路流用了同一个 SSRC,接收端会认为它们来自同一个源,把包混在一起重组,画面自然花掉。序列号也要按会话隔离,不能一路发完后共享从 0 计数。
解决:每路视频流单独创建RtpContext,里面的seq、timestamp、ssrc都独立。SSRC 可以用发送 IP 加端口哈希生成,或者直接用rand() | (time_now() << 16)这种临时值。每次发送前检查一下当前 socket 是否对应正确目标地址:
RtpContext *ctx_a = rtp_context_create(ssrc_a); RtpContext *ctx_b = rtp_context_create(ssrc_b);不要图省事把两路数据往同一个ctx里塞,后面改参数时会非常痛苦。
5. 参数调优与边界:MTU、时间戳频率、关键帧间隔怎么配
5.1 MTU 怎么定:以太网 1500 与 RTP 载荷上限
普通以太网链路 MTU 是 1500 字节,IP 头 20 字节,UDP 头 8 字节,RTP 头 12 字节,所以 RTP 载荷的最大安全值是 1460 字节。但很多实际链路还有 VLAN 标签(额外 4 字节),或者有 PPPoE 拨号(额外 8 字节)。为了保险,Demo 里默认把 RTP 载荷上限设为 1400,留了 60 字节余量。
| 参数 | 值 | 说明 |
|---|---|---|
| 以太网 MTU | 1500 B | 标准帧最大载荷 |
| IP 头 | 20 B | 无选项时 |
| UDP 头 | 8 B | source + dest + len + checksum |
| RTP 头 | 12 B | 固定头,无 CSRC/扩展 |
| 默认 RTP 载荷上限 | 1400 B | 1200 也行,越小丢包影响越小 |
如果走公网或跨运营商网络,建议把上限再调低到 1200 或 1300,避免 IP 分片。IP 分片后一旦某个分片丢了,整个 RTP 包直接作废,重传代价比多发几个小包高得多。FU-A 分片时用这个上限作为max_payload,传入 3.3 节的rtp_send_fu_a函数即可。注意这里的“上限”是包括 FU 头 2 字节的载荷总长度,所以实际 NAL 数据每片只有 1398 字节。
5.2 时间戳频率与帧率换算:90000Hz 不是随便定的
H264 的 RTP 时间戳时钟频率必须是 90000Hz,这是 RFC 6184 和 RFC 3551 共同规定的。这个数字能被 30、25、24、60 等常见帧率整除,方便计算。举个例子:
| 帧率 | 每帧时间戳增量 | 计算式 |
|---|---|---|
| 24 fps | 3750 | 90000 / 24 |
| 25 fps | 3600 | 90000 / 25 |
| 30 fps | 3000 | 90000 / 30 |
| 60 fps | 1500 | 90000 / 60 |
如果帧率不是整数,比如 29.97,常见做法是维持一个累加值:
double frame_duration = 90000.0 / 29.97; ctx->timestamp += (uint32_t)frame_duration;这种累加误差会随时间累积,可能导致音视频不同步。更好的做法是用系统单调时钟换算:发送第一帧时记录系统时间t0,每发一帧取当前时间t1,时间戳 =(uint32_t)((t1 - t0) * 90),这样不打乱真实时间关系。特别注意不要用gettimeofday的墙上时钟做累加,因为它会受系统时间调整影响,推流中途跳变一下,时间戳就乱了。
5.3 关键帧间隔与发送队列:低延迟、高可靠的取舍
编码器输出的 GOP(关键帧间隔)直接影响推流延迟和抗丢包能力。间隔越短,关键帧越多,视频流越容易在任何时刻接入播放器,但码率也会上去。H264 RTP 推流场景下我一般这样配:
- 内网低延迟:关键帧间隔设为 1~2 秒,即 30fps 下每 30~60 帧一个 IDR;
- 公网弱网:关键帧间隔设为 4~5 秒,让带宽多给一些给 P 帧,因为 P 帧丢几个还能靠后续 P 帧带过,IDR 丢一个就得等下个 IDR;
- 发送队列深度不要无限增长。当 UDP socket 发送缓冲区满时,常见做法是丢新帧而不是丢旧帧,因为旧帧可能已经被接收端部分解码,再等也没用。Demo 里维护一个环形队列,队列满时丢弃队尾的新帧,并记录丢帧数:
if (queue_full(ctx->q)) { drop_frame(ctx->q); ctx->stat_dropped_frames++; }这里的关键是让发送线程和采集线程解耦。采集线程只负责把切好的 NAL 塞进队列,发送线程从队列里按帧取,组装 RTP 包。这样即使发送端瞬时卡顿,队列能吸收几十到几百毫秒的抖动。如果真的需要极低延迟,队列长度可以设成 1,甚至直接改造成同步发送,但那样采集线程会被网络阻塞拖住,编码器缓冲容易爆。
6. 验证与进阶:先抓包,再写一个最小 RTP 接收端
6.1 用 Wireshark 快速验证封装是否合法
发送器写完后不要直接对着一堆日志猜,先用 Wireshark 抓包。抓自己的回环 UDP 包时,可以在发送端指定目标地址为 127.0.0.1,Wireshark 用udp.port == 5004过滤。重点看三处:
- 每个 RTP 包的
Sequence Number是否连续递增,有没有跳号,跳号说明你的发送循环里丢包了; - 同一帧的多个 FU-A 包是否时间戳相同,且
FU header的 S/E 位只有第一个和最后一个各出现一次; - 每个 IDR 关键帧前面是否跟着 STAP-A 包,STAP-A 的类型字段是不是 24,长度字段和实际 NAL 大小是否一致。
Wireshark 的 “Telephony -> VoIP Calls” 有时候能识别 H264 流,但大多数时候它只是按 RTP 通用协议展示。你可以在 RTP 包详情里看到 payload type 和 timestamp,这两个对排查已经够了。
6.2 最小接收端:把 RTP 包重新拼成 H264 帧
验证发送端至少需要看接收端能不能重组。最简单的接收端用 Python 写,几十行,UDP socket 收包,按 RTP 头解析出序号和时间戳,然后按 FU-A 或单包重组出 H264 裸流,写进文件后用 ffplay 播放。
import socket import sys sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(("127.0.0.1", 5004)) fout = open("recv.h264", "wb") buf = {} while True: data, addr = sock.recvfrom(2048) if len(data) < 12: continue rtp_sequence = int.from_bytes(data[2:4], "big") rtp_ts = int.from_bytes(data[4:8], "big") payload = data[12:] if payload[0] & 0x1F == 28: # FU-A fu_header = payload[1] nal_type = fu_header & 0x1F start_flag = (fu_header >> 7) & 1 end_flag = (fu_header >> 6) & 1 if start_flag: # 重建 NAL 头,这里用原始 NAL 类型伪造一个完整 NAL nal_header = bytes([(fu_header & 0xE0) | nal_type]) buf[rtp_sequence] = bytearray(nal_header) if start_flag or (rtp_sequence - 1 in buf): key = rtp_sequence if start_flag: key = rtp_sequence else: # 找到这个分片所属组:需要从 fw 标记记录起点 pass这段代码只展示了骨架。实际接收端更常见的做法是维护一个“当前重组帧”的字典,通过 FU-A 的 S 位找到起始包,E 位找到结束包,中间按序号顺序 append 到同一个 buffer。写完后直接把recv.h264丢给ffplay播放,能出画面就说明打包链路是通的。
6.3 性能优化:发送队列、内存池与避免频繁拷贝
发送端的性能瓶颈经常不是 socket,而是内存拷贝。RTP 包头只有 12 字节,但每次memcpy把 NAL 数据拷贝进payload,一个大 IDR 帧几十 KB,可能要被拷贝十几次。我后来用的是零拷贝方式:把 UDP 发送的iov数组指向 RTP 头、FU 头和 NAL 数据,用sendmsg一次性发送。这样避免把整包拼到一块连续内存里。
struct iovec iov[3]; struct msghdr msg = {0}; uint8_t fu_indicator[1], fu_header[1]; iov[0].iov_base = &rtp_header; iov[0].iov_len = 12; iov[1].iov_base = fu_indicator; iov[1].iov_len = 1; iov[2].iov_base = &nal_data[offset]; iov[2].iov_len = chunk; msg.msg_iov = iov; msg.msg_iovlen = 3; sendmsg(sockfd, &msg, 0);fu_indicator和fu_header作为局部变量,发送前分别写入,这样 NAL 数据不用再拷贝。对 1080p 视频,每次 IDR 帧能省下十几次大内存拷贝,CPU 占用肉眼可见地下降。另外,把RtpContext里的发送缓冲区用内存池提前分配好,避免频繁 malloc/free。每个 RTP 包大小不超过 1400,用固定大小的环形缓冲区线程安全地传递,效果会比链表好很多。
从那以后我每次写完发送器,不管多自信,都强制自己先跑一遍抓包确认 S/E 位和序号,再跑一遍接收端验收,最后才放到正式环境。这个习惯帮我少熬了不知道多少个夜。你也试试这套流程,有问题顺着 RTP 包头一眼就能定位。希望帮到你。
本文还有配套的精品资源,点击获取