简介:面向音视频开发初学者与流媒体传输实践者的RTP发送H264数据包示例工程,聚焦H264的NAL单元如何封装进RTP包并通过网络送达VLC播放器解码播放这一核心问题,涉及RTP头字段、时间戳与序列号、目的IP与端口配置以及SDP会话描述等关键环节。压缩包共21个文件、约929KB,包含264与h264裸流样本、cpp与h源码、VC6工程文件、编译产物及sdp描述文件,覆盖从编码数据到可播放流的完整链路。已有333人学习下载,可作为动手验证RTP封装逻辑的参考。读者可从中获得NAL单元分割与RTP封包的实现思路、可直接运行的发送端程序、配套SDP配置样例以及用VLC接收验证的排错线索,便于对照源码理解协议细节并快速搭建本地实验环境。
1. RTP 发 H264:为什么“能播”和“发对”是两回事
很多人第一次用 RTP 发 H264,都会遇到一个反直觉的现象:本地文件播放完全正常,用 FFmpeg 一条命令推出去也能看到画面,可一旦自己写发送端,接收端要么花屏、要么卡住、要么干脆黑屏。问题往往不在编码器,而在“打包”这一层——H264 的 NAL 单元和 RTP 的包边界根本不是一一对应的。RTP 是面向实时传输的协议,它假设网络会丢包、会乱序,因此把一帧 H264 拆成多个 RTP 包时,必须带上足够的信息让接收端能重组、能判断帧边界、能在丢包后继续解码。搞懂 RTP 发 H264 的底层机制,本质上就是搞懂“NAL 单元如何映射到 RTP 负载”这件事。这也是为什么面试里问“H264 over RTP 怎么分片”能筛掉一批只会调 API 的人。
2. NAL 单元与 RTP 负载:为什么不能直接塞进去
2.1 H264 的 NAL 单元长什么样
H264 码流的基本单位是 NAL 单元(Network Abstraction Layer Unit)。每个 NAL 单元开头有一个字节的 NAL Header,结构如下:
+---------------+ |0|1|2|3|4|5|6|7| +-+-+-+-+-+-+-+-+ |F|NRI| Type | +---------------+F 是 forbidden_zero_bit,正常码流里必须为 0;NRI 是 nal_ref_idc,表示这个 NAL 的重要性,参考帧的 NRI 非零,非参考帧为 0;Type 是 nal_unit_type,取值 1 到 12 常见,其中 1 是非 IDR 的编码切片,5 是 IDR 切片,6 是 SEI,7 是 SPS,8 是 PPS,9 是访问单元分隔符。SPS 和 PPS 是解码器初始化必须的参数集,IDR 是立即刷新帧,这三类东西在 RTP 发送时都有特殊处理。
一个常见的误解是“一帧就是一个 NAL 单元”。实际上一个编码帧可能被切成多个 slice,每个 slice 是一个独立 NAL 单元;反过来,SPS、PPS、SEI 这些非图像数据也各自是 NAL 单元。RTP 发送端要做的第一件事,就是从码流里正确切出 NAL 单元,而不是按固定字节数切。
2.2 RTP 头里哪些字段和 H264 有关
RTP 固定头 12 字节,和 H264 打包强相关的字段有三个:Marker 位(M)、Payload Type(PT)、Sequence Number。Marker 位在视频里通常标记“这是当前帧的最后一个 RTP 包”,接收端靠它判断一帧是否收齐;Payload Type 在动态范围里协商,比如 96 表示 H264;Sequence Number 每发一个 RTP 包加一,接收端靠它检测丢包和乱序。
时间戳字段同样关键。RTP 时间戳不是墙钟时间,而是采样时刻,单位是 90kHz 时钟。同一帧拆出的多个 RTP 包必须共享同一个时间戳,不同帧的时间戳按帧率递增。很多花屏问题的根因就是同一帧的多个分片时间戳不一致,接收端把它们当成不同帧处理,重组逻辑直接崩掉。
提示:RTP 时间戳的步进值 = 90000 / 帧率。25fps 时每帧步进 3600,这个值不要用毫秒去凑,否则音视频同步会慢慢漂移。
2.3 三种打包模式:单 NAL、分片、聚合
RFC 6184 定义了三种 H264 RTP 负载模式,实际发送端必须都考虑到:
| 模式 | 适用场景 | 负载头特征 |
|---|---|---|
| 单 NAL 单元模式 | NAL 小于 MTU | 负载直接以 NAL Header 开头 |
| 分片单元模式(FU-A) | NAL 大于 MTU | 负载头两字节,Type=28 |
| 聚合包模式(STAP-A) | 多个小 NAL 合并 | 负载头一字节,Type=24 |
单 NAL 模式最简单:RTP 负载就是完整的 NAL 单元,第一个字节就是 NAL Header。接收端看到 Type 在 1 到 23 之间,就知道这是完整 NAL。这种模式只适合小 NAL,比如 SPS、PPS、SEI,或者低分辨率下的单个 slice。
分片单元模式是重点。当一个 NAL 单元超过网络 MTU(通常 1400 字节左右,留出 IP 和 UDP 头),必须拆成多个 RTP 包。FU-A 的负载头是两个字节:
+---------------+ |0|1|2|3|4|5|6|7| +-+-+-+-+-+-+-+-+ |S|E|R| Type | +---------------+S 是起始位,标记这是分片的第一个包;E 是结束位,标记最后一个包;R 是保留位;Type 固定为 28,表示 FU-A。第二个字节才是原始 NAL Header 的 F、NRI、Type 部分。接收端重组时,把第一个分片的第二字节当作 NAL Header,后续分片只取负载数据,拼起来还原原始 NAL。
聚合包模式用于把多个小 NAL 塞进一个 RTP 包,减少包头开销。STAP-A 的负载头一字节 Type=24,后面跟着若干“2 字节长度 + NAL 数据”的条目。SPS 和 PPS 经常一起聚合发送,因为解码器需要成对拿到它们才能初始化。
3. 分片与重组:FU-A 的起始位和结束位为什么不能错
3.1 发送端分片逻辑
分片的核心是把一个 NAL 单元按 MTU 切成若干段,每段加上 FU-A 头。下面是一段典型的发送端分片逻辑:
def packetize_nal(nal_bytes, mtu=1400): """把一个 NAL 单元拆成若干 RTP 负载,返回负载列表""" nal_header = nal_bytes[0] nal_type = nal_header & 0x1F nri = nal_header & 0x60 payloads = [] # 小于 MTU 直接单 NAL 模式 if len(nal_bytes) <= mtu: return [nal_bytes] # FU-A 分片:每片负载 = 2 字节 FU 头 + 数据 max_data = mtu - 2 offset = 1 # 跳过原始 NAL Header first = True while offset < len(nal_bytes): chunk = nal_bytes[offset:offset + max_data] fu_header = 0 if first: fu_header |= 0x80 # S 位 if offset + max_data >= len(nal_bytes): fu_header |= 0x40 # E 位 # FU indicator: F=0, NRI 保留, Type=28 fu_indicator = (nri | 28) payloads.append(bytes([fu_indicator, fu_header | nal_type]) + chunk) offset += max_data first = False return payloads这段代码里有两个容易翻车的点。第一,FU indicator 的 NRI 必须从原始 NAL Header 继承,不能清零,否则接收端可能把参考帧当非参考帧处理,影响解码质量。第二,FU header 的低 5 位要填原始 NAL 的 Type,不是填 28,28 只出现在 FU indicator 里。很多实现把这两个 Type 搞混,接收端重组出来的 NAL Header 类型错误,解码器直接报错。
3.2 接收端重组逻辑
接收端看到 Type=28 的负载,就知道这是分片。它需要维护一个重组缓冲区,遇到 S 位开始新 NAL,遇到 E 位结束并交付解码器。重组时把第一个分片的 FU header 低 5 位还原成 NAL Header 的 Type,再拼上后续数据:
def depacketize_fu_a(payloads): """按顺序传入同一 NAL 的 FU-A 负载,返回完整 NAL""" nal = bytearray() for i, p in enumerate(payloads): fu_indicator = p[0] fu_header = p[1] nri = fu_indicator & 0x60 nal_type = fu_header & 0x1F if i == 0: # 还原原始 NAL Header nal.append(nri | nal_type) nal.extend(p[2:]) return bytes(nal)重组逻辑本身不复杂,难的是“按顺序”和“同一 NAL”这两个前提。RTP 走 UDP,包可能乱序、可能丢。接收端必须靠 Sequence Number 排序,靠 S/E 位判断边界。如果中间丢了一个分片,整个 NAL 就废了,这时候正确的做法是丢弃这个 NAL 并等待下一个 S 位,而不是硬拼一个残缺的 NAL 送给解码器——后者就是花屏的直接来源。
3.3 Marker 位与帧边界
一帧图像可能包含多个 NAL 单元(多个 slice),每个 NAL 又可能被分成多个 RTP 包。接收端怎么知道一帧结束了?靠 Marker 位。发送端在“当前帧最后一个 RTP 包”上置 M=1,接收端收到 M=1 的包,就把之前攒的所有 NAL 组成一个访问单元送给解码器。
这里有个细节:如果一帧只有一个 NAL 且没分片,那这个 RTP 包既是单 NAL 模式又要置 M=1。如果一帧有多个 slice,只有最后一个 slice 的最后一个分片置 M=1,前面的包 M 都是 0。Marker 位错乱的后果是接收端要么把半帧送进解码器,要么把两帧拼成一帧,画面会出现撕裂或跳帧。
4. 参数集与关键帧:SPS/PPS 为什么必须重复发
4.1 解码器初始化的依赖链
H264 解码器要解第一帧,必须先拿到 SPS 和 PPS。SPS 里有分辨率、帧率、profile、level 等全局参数,PPS 里有熵编码模式、切片组等参数。没有这两个,解码器连码流结构都解析不了。RTP 发送端如果只在开头发一次 SPS/PPS,中途加入的接收端或者丢包后的接收端就永远解不了码。
常见的做法是:每个 IDR 帧之前都重发一次 SPS 和 PPS。IDR 是立即刷新帧,它不依赖之前的帧,是接收端重新同步的锚点。把 SPS、PPS、IDR 放在同一个时间戳下发送,接收端收到这一组就能立刻重建解码上下文。这也是为什么很多流媒体服务器配置里有一个“关键帧间隔”参数,间隔太大会导致新观众等待时间过长。
4.2 用 STAP-A 聚合 SPS 和 PPS
SPS 和 PPS 通常都很小,几十字节,单独发一个 RTP 包浪费包头。用 STAP-A 把它们聚合在一起:
def pack_stap_a(nals): """把多个小 NAL 聚合成一个 STAP-A 负载""" payload = bytearray([24]) # STAP-A type for nal in nals: length = len(nal) payload.append((length >> 8) & 0xFF) payload.append(length & 0xFF) payload.extend(nal) return bytes(payload)STAP-A 的负载头只有一字节 Type=24,后面每个 NAL 前面加两字节大端长度。接收端按长度逐个切出 NAL。注意 STAP-A 只能聚合类型 1 到 23 的 NAL,不能把分片包再聚合,也不能聚合 FU-A 的分片。
4.3 关键帧请求与丢包恢复
实时场景里,接收端发现丢包导致无法解码时,会通过 RTCP 发 PLI(Picture Loss Indication)或 FIR(Full Intra Request)请求发送端立即产生一个关键帧。发送端收到后要强制编码器输出 IDR,并重发 SPS/PPS。这套反馈机制是 RTP 能“自愈”的关键,纯靠 RTP 单向发送而不处理 RTCP 反馈,弱网下体验会很差。
注意:PLI 是接收端告诉发送端“我丢了参考帧,给我一个关键帧”,不是“重传刚才那个包”。重传是另一个机制(如 NACK),两者不要混用。关键帧请求的响应延迟直接影响卡顿恢复时间。
5. 避坑排查:RTP 发 H264 最常见的五个翻车点
5.1 现象:接收端黑屏,日志显示“no SPS/PPS”
原因:发送端只在流开始时发了一次参数集,接收端中途加入或参数集包丢失,解码器无法初始化。
解决:每个 IDR 帧前重发 SPS/PPS,用 STAP-A 聚合减少开销;同时确认 SPS/PPS 的 RTP 时间戳和后续 IDR 一致,不要单独给它们递增时间戳。
5.2 现象:画面花屏但声音正常,花屏区域随时间移动
原因:FU-A 分片中间丢了包,接收端把残缺 NAL 送进了解码器,解码器用错误数据继续预测,错误扩散到后续帧。
解决:接收端重组时检查 Sequence Number 连续性,发现缺口直接丢弃当前 NAL 并等待下一个 S 位;同时通过 RTCP PLI 请求关键帧,让解码器尽快恢复。
5.3 现象:一帧被拆成两帧显示,画面上下错位
原因:同一帧的多个 RTP 包时间戳不一致,或者 Marker 位提前置 1,接收端把半帧当成完整帧交付。
解决:发送端严格保证同一帧所有分片共享同一时间戳,Marker 只在当前帧最后一个 RTP 包上置位;用抓包工具核对每个包的 M 位和时间戳字段。
5.4 现象:大分辨率下必现卡顿,小分辨率正常
原因:NAL 单元超过 MTU 但没有走 FU-A 分片,直接塞进一个 UDP 包,IP 层分片后丢一个分片整个包报废,且路径 MTU 不一致时更容易丢。
解决:发送端按 MTU 主动分片,MTU 取值留出 IP/UDP/RTP 头余量,常见 1400 字节;不要依赖 IP 分片,IP 分片对丢包极其敏感。
5.5 现象:接收端能解码但延迟越来越大
原因:发送端没有做拥塞控制,RTP 包发得比网络能承载的快,接收端缓冲区越积越多;或者时间戳步进用了墙钟时间而非 90kHz 采样时钟。
解决:时间戳严格按 90000/帧率 步进;结合 RTCP RR 里的丢包率和抖动调整发送码率,必要时降帧率或降分辨率,而不是硬推。
6. 设计取舍:为什么 RTP 不帮你做重传和排序
RTP 的设计哲学是“把复杂度留给应用层”。它只提供时间戳、序列号、负载类型这些元信息,不保证送达、不保证顺序、不负责重传。这不是偷懒,而是实时音视频的取舍:等一个丢失的包重传过来,可能已经过了播放窗口,重传的包到了也没用,反而增加延迟。所以 RTP 把“要不要重传、要不要等、丢了怎么办”这些决策交给上层,让应用根据场景选择——直播可以容忍少量花屏换低延迟,会议可以牺牲一点延迟换完整性。
这个取舍直接影响了 H264 over RTP 的实现方式。发送端必须自己维护分片状态、自己决定关键帧间隔、自己处理 RTCP 反馈;接收端必须自己做重组、自己做丢包判断、自己决定何时请求关键帧。理解了这一点,就不会再指望“调一个库就万事大吉”,而是会去核对每个 RTP 包的 Marker 位、时间戳和 FU-A 头。
我自己的习惯是:任何 RTP 发送端上线前,先用抓包工具把前 100 个包导出来,逐包核对 NAL 类型、S/E 位、时间戳和 M 位,确认分片边界和帧边界都对得上,再去看接收端画面。这个笨办法帮我省掉了很多“看起来能播但就是不对”的玄学排查。希望帮到你。
本文还有配套的精品资源,点击获取