尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RTP打包H264数据:NALU分片、STAP-A聚合与FU-A实现详解

RTP打包H264数据:NALU分片、STAP-A聚合与FU-A实现详解 简介面向流媒体开发者和网络编程学习者这份资源演示了使用RTP协议封装并发送H264编码数据配合VLC播放器完成接收解码的完整流程。包体共21个文件、约929KB核心包含Visual C工程源码.cpp/.h、可执行程序、w.sdp会话描述文件以及多段测试码流.264/.h264并附带编译中间产物便于直接检查工程结构并运行验证。已有325人学习。借助NALDecoder示例代码与SDP配置读者可以梳理H264 NAL单元分割、RTP打包、时间戳与序列号填充、IP/端口设定和VLC会话建立等关键环节既能理解RTP头字段对视频同步与重组的作用也能掌握从原始H264码流到远端画面呈现的协议交互流程。这份资料适合作为RTP视频传输入门实践也可作为排查VLC播放问题时的参考实现。1. RTP与H264为什么视频传输离不开这两个老伙计做音视频开发这几年我越来越觉得RTP协议和H264编码这对组合就像视频传输世界里的“面粉”和“水”——单看各自都不稀奇揉在一起才能做出真正能用的东西。我记得刚入行那次联调经历特别惨痛摄像头那边的H264裸流明明在VLC里能正常播放但一到网络上传输就花屏、绿屏、卡顿折腾了整整三天最后才发现是RTP打包时的NALU边界划分错了。这篇文章就以“使用RTP协议发送H264数据包”为主线把我踩过的坑、验证过的方法、整理过的代码全部摊开来讲。不管你是刚开始接触音视频传输的新手还是被WebRTC、视频监控、直播推流折磨过的老手这篇文章都能让你少走几个月的弯路。核心围绕三个问题展开H264编码出来的数据长什么样、RTP协议用什么姿势把它装进去、装完之后怎么验证和对端能不能顺利解出来。先说清楚一个前提本文讨论的是H264裸流通过RTP协议进行封装传输的通用方法不涉及具体某个商业SDK也不依赖特定的流媒体服务器。你自己写代码、或者用开源库比如FFmpeg、Live555、GStreamer做二次开发都能用这套思路。代码示例我用纯C语言手写不依赖任何第三方库方便你迁移到嵌入式设备、Android NDK或者PC端。在开始之前得先建立两个基础概念。第一个是H264的编码原理。H264编码器输出的不是一帧帧完整的图像数据而是一个个NALUNetwork Abstraction Layer Unit网络抽象层单元。每个NALU有Header头和RBSP原始字节序列载荷两部分Header里记录了NALU的类型——是I帧的关键数据、P帧的预测数据还是SPS/PPS这种参数集。第二个概念是RTP协议。RTP的全称是Real-time Transport Protocol实时传输协议它跑在UDP之上专门负责给音视频数据加上时间戳、序号、负载类型等信息让接收端能按正确的顺序和节奏把数据还原成画面。很多人疑惑TCP那么可靠为什么不用TCP传视频因为视频传输对实时性要求极高TCP的重传机制在网络抖动时会产生严重的延迟累积用户看到的就是画面突然卡住几秒然后又跳帧。RTP选择UDP允许丢包靠接收端的缓冲和纠错策略来平衡这就是“宁可画面轻微花一下也不要声画严重不同步”的取舍逻辑。2. 传输前的准备工作理解H264的NALU边界与封装格式2.1 从编码器出来的数据到底是什么样子H264编码器输出的码流有两种常见的存储/封装格式Annex-B和AVCC。这两种格式在RTP打包前必须先搞清楚因为它们的NALU边界标记方式完全不一样。Annex-B格式是视频监控、广电领域最常见的特征是在每个NALU前面加上起始码Start Code通常是00 00 00 01或者00 00 01。起始码的作用就是告诉解析器“从这里开始是一个新的NALU”有点像邮政包裹外面的封条。FFmpeg的AVPacket在默认情况下annexb格式的数据每个NALU前都有起始码。AVCC格式则是MP4、FLV这些容器里常见的格式特征是每个NALU前面用4个字节大端序记录这个NALU的长度而不是用起始码。这种格式对RTP打包来说不太方便因为你需要先把长度字段解析出来再逐个切片。所以做RTP发送前我一般会先把AVCC转成Annex-B或者直接在过程中解析长度字段来定位NALU。这里有一个非常关键的细节SPS序列参数集和PPS图像参数集这两个NALU包含了解码器需要的分辨率、帧率、编码配置等关键信息。解码器必须先拿到SPS/PPS才能开始解码。如果RTP打包时把这两个参数丢了或者发送顺序不对对端直接黑屏或者报“no decoder available”错误。我记得有次在某个平台上调试画面一直出不来排查了很久才发现是SPS/PPS只在会话开始时发了一次中途有客户端加入错过了这个关键信息后续就再也解不出来了。2.2 RTP头部与负载类型的映射规则RTP协议的头部固定是12字节结构如下0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 -------------------------------- |V2|P|X| CC |M| PT | sequence number | -------------------------------- | timestamp | -------------------------------- | synchronization source (SSRC) identifier | 版本号V固定为2表示RTP版本2填充位P一般置0扩展位X一般置0CSRC计数CC一般置0标记位M对于H264视频一般在一帧的最后一个RTP包置1用来告诉接收端“这一帧的数据结束了”辅助接收端判断是否需要等待更多分片负载类型PT对于H264动态负载类型通常使用96到127之间的值常见的是96或者97。这个值在SDP协商中对应rtpmap:96 H264/90000序列号sequence number每个RTP包加1接收端用它检测丢包和排序时间戳timestamp反映帧的采样时间H264的RTP时间戳频率固定为90000Hz时间戳是很多新手最容易搞错的地方。H264的RTP时间戳单位是90kHz也就是说1秒的时间被分成了90000个刻度。如果视频是25fps那么每帧的时间戳增量就是 90000 / 25 3600。千万不能用毫秒直接当时间戳用否则对端播放速度会变成“快进”或者“慢放”。我见过有人用timestamp 40来模拟25帧结果画面以极快的速度播放解码器直接崩掉。3. H264数据打包进RTP的三种模式单包、聚合包、分片包3.1 什么时候用单包模式Single NALU PacketRFC 6184以及前身RFC 3984规定了H264 over RTP的三种打包模式。第一种是单包模式就是把一个完整的小NALU直接塞进RTP的负载里RTP头之后紧跟着就是这个NALU的数据。单包模式适用于NALU总长度小于MTU的场景。MTU最大传输单元在以太网上通常是1500字节减去IP头20字节、UDP头8字节、RTP头12字节留给负载的实际空间大约是1460字节。所以只要NALU总长包括1字节的NALU头不超过1460字节就可以用单包模式。实际应用中哪些NALU适合单包SPS、PPS、SEI这些参数集和辅助信息一般都很小通常只有几十到几百字节完全可以用单包模式发送。还有一些小的P帧如果编码器配置了较小的关键帧间隔或者较低的码率也可能落在1460字节以内。单包模式下RTP负载的第一个字节就是NALU头。这里需要注意NALU头本身的低5位是NALU类型类型1-23是RFC 6184定义的而24-31是RTP聚合包和分片包的类型标识。单包模式下NALU头保持原样接收端直接根据NALU头就能判断这是I帧还是P帧。3.2 STAP-A聚合包把多个小NALU装进一个RTP包第二种模式是聚合包模式常见的是STAP-ASingle-Time Aggregation Packet单时间聚合包。这种模式解决的是“小NALU太多导致RTP包太多、带宽利用率低”的问题。举个例子一个视频帧里面可能有多个SEI、多个小片段的NALU如果每个都用独立的RTP包发送包数量会很多而且每个包都要重复带12字节的RTP头浪费带宽也容易触发网络设备的PPS每秒包数限制。STAP-A的解决方案是把多个属于同一时间戳的NALU合并到一个RTP包里每个NALU前面加上2字节的长度字段。STAP-A的负载结构是第一个字节是STAP-A的类型标识24后面依次是2字节长度 NALU数据、2字节长度 NALU数据……STAP-A适用于多个NALU的总长度加起来不超过1400字节的场景。如果超过了就不能用聚合包必须走分片包路线。现实中最常见的使用场景是把SPS和PPS放在同一个STAP-A包里发出去这样接收端在一个RTP包内就能同时拿到解码所需的两组参数集非常高效。3.3 FU-A分片包大NALU的正确切分方式第三种模式是分片包模式也是最常用、最核心的模式。当一个NALU超过1460字节比如常见的I帧可能达到几十KB甚至更大就需要把NALU切成多个片段用多个RTP包传输。这个模式叫FU-AFragmentation Unit A分片单元A。FU-A的负载结构是这样的第一个字节是FU指示字节FU indicator高3位是NALU头的F和NRI位低5位固定为28表示FU-A类型。第二个字节是FU头FU header高3位是NALU类型低5位分别是S开始、E结束、R保留。每次分片发送时第一个FU-A包需要置S位为1最后一个需要置E位为1中间的包S和E都为0。接收端通过S位和E位来组装完整的NALU。这里有个容易搞错的地方FU指示字节的高3位必须和原始NALU头的高3位保持一致即F和NRI位不能变只有低5位变成28而原始NALU的类型被拆出来放到FU头的第3到第7位。我画一个直观的对照原始NALU头[F][NRI][Type]FU-A的第一个字节FU indicator[F][NRI][28]FU-A的第二个字节FU header[S][E][R][Type]分片时需要注意原始NALU头本身不需要再单独发送了因为FU指示字节和FU头已经包含了重组所需的所有信息。接收端重组时会把FU头里的Type取出来加上FU indicator的高3位拼回原来的NALU头再把各分片的负载按顺序拼接。4. 手写RTP H264发送代码从协议到落地4.1 基础数据结构的定义下面直接上代码。我用C语言手写一个最简版的RTP H264发送模块核心函数包括初始化RTP头、发送单个NALU、发送FU-A分片。先定义RTP头结构体和相关常量#include stdint.h #include string.h #include arpa/inet.h // RTP固定头12字节 typedef struct { uint16_t cc : 4; // CSRC count uint16_t x : 1; // extension uint16_t p : 1; // padding uint16_t v : 2; // version uint16_t pt : 7; // payload type uint16_t m : 1; // marker uint16_t seq; // sequence number uint32_t ts; // timestamp uint32_t ssrc; // synchronization source } __attribute__((packed)) rtp_header_t; // NALU头相关常量 #define NALU_TYPE_SPS 7 #define NALU_TYPE_PPS 8 #define NALU_TYPE_IAL 5 // IDR帧 #define NALU_TYPE_SEI 6 // RTP负载类型 #define RTP_PT_H264 96 // FU-A相关类型值 #define FU_A_INDICATOR 28 #define STAP_A_INDICATOR 24这里需要注意结构体使用了__attribute__((packed))来防止编译器对齐填充保证RTP头正好是12字节这在发送时要拼进UDP报文里字节必须严格对齐。4.2 RTP包发送的核心实现接下来是发送函数。先写一个通用函数负责填充RTP头并通过UDP socket发出static int rtp_send_packet(int sockfd, struct sockaddr_in *dst, uint16_t seq, uint32_t ts, uint8_t marker, uint8_t payload_type, uint8_t *payload, int payload_len, uint32_t ssrc) { uint8_t packet[1500]; rtp_header_t *rtp (rtp_header_t *)packet; memset(packet, 0, sizeof(packet)); rtp-v 2; rtp-p 0; rtp-x 0; rtp-cc 0; rtp-m marker ? 1 : 0; rtp-pt payload_type; rtp-seq htons(seq); rtp-ts htonl(ts); rtp-ssrc htonl(ssrc); memcpy(packet 12, payload, payload_len); return sendto(sockfd, packet, 12 payload_len, 0, (struct sockaddr *)dst, sizeof(*dst)); }这个函数里有一个初学者非常容易忽略的细节RTP头里的seq和ts必须转换成网络字节序大端序而ssrc也要转。很多平台是小端序如果不转接收端的Wireshark里看到的序列号会是反的对端解析会完全错乱。我当时排查这种问题花了不少时间。4.3 单包、STAP-A、FU-A三种发送逻辑下面是对外暴露的发送函数分别对应三种模式// 发送单个NALU适用于小NALU int rtp_send_h264_nalu(int sockfd, struct sockaddr_in *dst, uint16_t *seq, uint32_t *ts, uint8_t *nalu, int nalu_len, uint32_t ssrc, int marker) { return rtp_send_packet(sockfd, dst, (*seq), *ts, marker, RTP_PT_H264, nalu, nalu_len, ssrc); } // 发送STAP-A聚合包把SPS和PPS放在一起 int rtp_send_stap_a(int sockfd, struct sockaddr_in *dst, uint16_t *seq, uint32_t *ts, uint8_t *nalu1, int len1, uint8_t *nalu2, int len2, uint32_t ssrc) { uint8_t payload[1500]; int payload_len 0; payload[payload_len] STAP_A_INDICATOR; payload[payload_len] (len1 8) 0xFF; payload[payload_len] len1 0xFF; memcpy(payload payload_len, nalu1, len1); payload_len len1; payload[payload_len] (len2 8) 0xFF; payload[payload_len] len2 0xFF; memcpy(payload payload_len, nalu2, len2); payload_len len2; return rtp_send_packet(sockfd, dst, (*seq), *ts, 0, RTP_PT_H264, payload, payload_len, ssrc); } // 发送FU-A分片包适用于大NALU int rtp_send_h264_fua(int sockfd, struct sockaddr_in *dst, uint16_t *seq, uint32_t *ts, uint8_t *nalu, int nalu_len, uint32_t ssrc) { const int max_payload 1400; // 留一些余量给IP/UDP头 uint8_t nalu_header nalu[0]; uint8_t *nalu_data nalu 1; int nalu_data_len nalu_len - 1; uint8_t payload[1500]; int offset 0; int ret 0; while (offset nalu_data_len) { int chunk_len (nalu_data_len - offset max_payload) ? max_payload : (nalu_data_len - offset); int start (offset 0) ? 1 : 0; int end (offset chunk_len nalu_data_len) ? 1 : 0; payload[0] (nalu_header 0xE0) | FU_A_INDICATOR; payload[1] (start 7) | (end 6) | (nalu_header 0x1F); memcpy(payload 2, nalu_data offset, chunk_len); ret rtp_send_packet(sockfd, dst, (*seq), *ts, end, RTP_PT_H264, payload, 2 chunk_len, ssrc); if (ret 0) return ret; offset chunk_len; } return 0; }这段代码里有几个细节值得展开。第一FU指示字节保留了原NALU头的高3位nalu_header 0xE0这对应我之前说的F和NRI位。第二时间戳在整个NALU的所有分片中保持不变只有当发送下一帧的时候才递增这是接收端组装的关键依据。第三最后一个分片的marker位置1接收端以此判断一帧结束。4.4 组合调用从编码器数据到网络报文现在写一个完整的调用流程模拟从H264编码器拿到一帧、逐NALU发送的过程void send_h264_frame(int sockfd, struct sockaddr_in *dst, uint16_t *seq, uint32_t *ts, uint8_t *frame_data, int frame_len) { // 假设frame_data是Annex-B格式每个NALU前有00 00 00 01起始码 uint8_t *p frame_data; uint8_t *end frame_data frame_len; const uint8_t start_code[4] {0x00, 0x00, 0x00, 0x01}; while (p end) { // 查找起始码 if (memcmp(p, start_code, 4) ! 0) { p; continue; } // 定位NALU数据 uint8_t *nalu_start p 4; uint8_t *q nalu_start; // 查找下一个起始码确定NALU长度 while (q end) { if (memcmp(q, start_code, 4) 0) { break; } q; } int nalu_len q - nalu_start; uint8_t nalu_type nalu_start[0] 0x1F; // 根据NALU类型和长度选择打包方式 if (nalu_type NALU_TYPE_SPS || nalu_type NALU_TYPE_PPS) { // 这里做简化处理单独发送不聚合 rtp_send_h264_nalu(sockfd, dst, seq, *ts, nalu_start, nalu_len, ssrc, 0); } else if (nalu_len 1400) { rtp_send_h264_nalu(sockfd, dst, seq, *ts, nalu_start, nalu_len, ssrc, 1); } else { rtp_send_h264_fua(sockfd, dst, seq, *ts, nalu_start, nalu_len, ssrc); } p q; } // 每帧时间戳递增25fps对应增量3600 *ts 3600; }代码中特意做了简化处理实际项目中SPS和PPS应该用STAP-A聚合发送这样能减少一次UDP报文交互而且在某些网络环境下更稳定。FFmpeg的ff_rtp_send_h264_hevc内部就是类似的逻辑只不过它考虑了更多边界情况。5. 实操中的坑与排查方案用Wireshark和VLC验证你的RTP流5.1 Wireshark抓包验证RTP包长什么样才算发对了代码写完之后第一件事不是直接接到播放器上而是先用Wireshark抓包验证。启动抓包后过滤rtp或者udp.port 5004然后看你的RTSP或者自定义发送程序发出的包。重点检查以下几项RTP头的版本是否为2负载类型是否为96或者你在SDP里声明的值序列号是否从0开始连续递增时间戳是否按照90000/帧率递增25fps就是3600FU-A分片时同一个NALU的时间戳是否保持不变S位和E位是否正常如果时间戳出现不规律跳变多半是你在代码里把ms直接当ts用或者帧率计算错误。如果序列号跳变说明中间有包没发出来或者seq变量被重复初始化了。Wireshark本身有RTP解析器抓包后选中任意一个RTP包状态栏会显示RTP PT96, SSRC..., Seq..., Time...。右击包选择“RTP Stream Analysis”还能看到丢包率、抖动等统计信息非常实用。5.2 用VLC当接收端验证播放效果自己写接收端解析RTP并喂给解码器是一个大工程验证阶段可以直接用VLC。VLC内置了H264 over RTP的解码能力但需要一个SDP文件描述媒体格式。创建一个文本文件内容如下v0 o- 0 0 IN IP4 127.0.0.1 sH264 RTP Stream cIN IP4 127.0.0.1 t0 0 mvideo 5004 RTP/AVP 96 artpmap:96 H264/90000 afmtp:96 packetization-mode1; sprop-parameter-setsZ0IAK5YFAFu0Q,aM4yyA这里packetization-mode1表示支持非交错模式即我们使用的FU-A分片sprop-parameter-sets是Base64编码的SPS和PPS。如果你没有现成的SPS/PPS可以用FFmpeg提取ffmpeg -i input.mp4 -c:v copy -bsf:v h264_mp4toannexb -f h264 output.h264然后用工具从output.h264里抠出SPS和PPS再Base64编码填入SDP。启动VLCvlc test.sdp然后运行你的发送程序如果能出画面说明整个链路OK。如果画面花屏大概率是FU-A分片的顺序或者重组逻辑有误如果没有画面检查SDP里的PT和fmtp参数是否和发送端一致。5.3 高频排查点速查表现象原因排查方法黑屏无画面缺少SPS/PPS或发送顺序错误抓包看是否有类型7和8的NALU确认在IDR帧前发送花屏、马赛克FU-A分片重组错误或丢包检查S位/E位确认分片顺序确认时间戳一致画面快进时间戳单位错误确认使用90000Hz25fps时每帧增量3600画面卡顿但CPU低marker位未设置确认每帧最后一个包M1对端报Decode errorNALU头被破坏检查FU indicator高3位是否保留原值Wireshark看不到RTP端口或发送地址错误确认sendto的目标IP和端口5.4 一个提高发送稳定性的补充技巧最后分享一个我自己的做法在发送端维护一个静态的序列号计数器每次发送前检查一下seq是否回绕超过65535归零回绕后SSRC可以保持不变但接收端要注意处理序号回绕的情况。另外对于UDP发送socket的发送缓冲区要适当调大否则突发I帧时大量分片来不及发出去直接丢在用户态缓冲区里表现为对端画面卡顿。int send_buf_size 512 * 1024; setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, send_buf_size, sizeof(send_buf_size));这个调整在实际推流时非常管用。I帧往往瞬间产生几十个分片默认的发送缓冲区通常只有几十KB根本扛不住直接在协议栈就丢包了对端画面看着就像“整个I帧都丢了”一样。我自己在这个领域摸爬滚打这么些年最大感受是RTP H264链路的核心不在于“把数据发出去”而在于“对端能按原样还原”。每次调试先抓包看协议细节再谈画面效果这个顺序千万不要颠倒。希望这篇文章能让你少交点学费。本文还有配套的精品资源点击获取
返回列表