简介:RTP协议与H264编码的配合常用于实时视频流传输。压缩包围绕“使用RTP发送H264数据包”提供了一套可运行的工程与测试文件,面向网络流媒体开发者或需要在VLC中验证H264 RTP接收的读者。共21个文件,整体约929KB,包含C++源码(cpp/h)、Visual C++工程文件(dsp/dsw)、编译好的exe程序、调试符号(obj/pdb/ilk)、多段264/h264测试码流,以及sdp会话描述文件,从编码数据到网络承载配置一应俱全。已有329人学习下载。借助NALDecoder源码与w.sdp,可以直观理解NAL单元拆分、RTP头时间戳与序列号填写、目的IP与端口设置等关键点,并在VLC中直接打开sdp完成解码播放,便于对照工程验证发送与接收全流程,省去重复搭建环境的时间,也有助于排查RTP时间戳不同步、NAL分片长度不匹配等常见问题。
1. RTP打包H264:视频推流第一道封装关
之前在某跨平台推流项目里排障,编码器输出了标准H264裸流,直接用UDP丢出去,结果播放器要么黑屏、要么花屏,换了几套播放参数都没用。最后定位到根因:H264在网络上不是“裸字节流”,要按RTP载荷规范重新封包。RTP负责携带媒体数据,H264负责描述视频内容,这两者之间隔着NALU切分、分片策略、时间戳规则三道坎。这套资源把“用RTP协议发送H264数据包”的完整链路拆开了讲,覆盖从码流解析、打包模式选择、socket发送到抓包验证。适合两类人:刚接触流媒体协议栈、想知道封包规则的开发者,以及调试推流端黑屏花屏、需要快速定位问题的从业者。
2. 先解析H264裸流:NALU切分与SPS/PPS定位
2.1 NALU结构:1字节类型头决定了大包如何切
编码器输出的H264一般是Annex B格式,也就是“起始码 + NALU”的排列。起始码有两种:00 00 01和00 00 00 01,用来标记每个NALU的边界。起始码之后第一字节是NALU头,它只占1个字节,但信息密度很高:高3位是forbidden_bit和nal_ref_idc,低5位是nal_unit_type。解码器认的就是这低5位:7代表SPS,8代表PPS,5代表IDR关键帧,1到4是非IDR的slice,6是SEI,9是AUD访问单元分隔符。
很多新手把NALU头当成数据的一部分直接塞进RTP载荷,这是第一个坑。RTP打包时NALU头要参与运算:单一NALU包直接整段发没问题,分片时要把NALU头拆开、重组到FU indicator和FU header里,不能只拷数据。SPS和PPS对解码器来说是“启动参数”,没有它们,关键帧就算完整送到也解不出来。
2.2 写一个起始码切分器:代码与边界说明
拿到一份H264裸流,第一步是把它切成一个个独立的NALU。我一般在工程里用一个不大的Python函数做原型验证,逻辑清晰、方便对照抓包,验证完后翻译成C。
def annexb_nalus(data: bytes): """从Annex B裸流中切出NALU,返回[(offset, size)],不含起始码""" nalus = [] n = len(data) i = 0 while i + 2 < n: # 只匹配 00 00 01;碰到 00 00 00 01 时匹配点落在第二个00上, # 数据起点自动平移,两种起始码不需要分开写分支 if not (data[i] == 0 and data[i+1] == 0 and data[i+2] == 1): i += 1 continue start = i + 3 # 当前NALU的终点 = 下一个起始码的位置 end = start while end + 2 < n: if data[end] == 0 and data[end+1] == 0 and data[end+2] == 1: break end += 1 # 搜到文件尾部都没有下一个起始码,说明这是最后一个NALU if end + 2 >= n: end = n nalus.append((start, end - start)) i = end return nalus这段代码的关键思路是“只认00 00 01,不单独处理00 00 00 01”。因为4字节起始码在字节流里会天然命中“第二个00 + 00 + 01”这个三字节模式,起点跟着偏移一位,数据区位置反而是对的。start指向NALU头所在偏移,end是下一个起始码的位置,两者相减得到长度。返回的偏移可直接用data[offset:offset+size]取出完整NALU,包含那个1字节类型头。
注意一个边界:如果文件末尾没有起始码,end最终会停在接近文件尾部的位置,必须手动让end等于文件长度,否则最后一个NALU会被截掉几个字节。这个分支看起来不起眼,很多人漏掉后帧率一高就出花屏。
2.3 关键帧定位与SPS/PPS自动缓存
切出NALU之后,先做两件事:识别IDR关键帧,缓存SPS和PPS。H264的I帧在RTP流里不是孤立存在的,解码器要用SPS拿到分辨率、帧率、profile,用PPS拿到熵编码模式、切片组配置,然后才能处理IDR里的实际像素数据。
def collect_sps_pps(nalus, data): sps, pps = [], [] for off, size in nalus: nal_type = data[off] & 0x1F if nal_type == 7: sps.append(data[off:off+size]) elif nal_type == 8: pps.append(data[off:off+size]) return sps, ppsdata[off] & 0x1F就是NALU类型。7和8分别对应SPS、PPS,把它们缓存到一个列表里,后续发送关键帧前先补发这两个小包。判定关键帧的写法一样:nal_type == 5。很多播放器允许“仅音频流”或者“纯IBP不带SPS”的畸形流,但标准解码器不会为这种流买账,所以这一步不能省。
3. RTP载荷三选一:单包、STAP-A还是FU-A分片
3.1 RTP固定头12字节:这次推流必须背下来的字段
RTP头固定12字节,发送端要把每个字段填对。H264这类视频载荷的时钟频率统一按90000Hz计算,载荷类型PT通常用动态值96到127之间,我一般固定填96,接收端通过SDP协商或写死来对齐。
| 字段 | 位数 | 本次推流取值 | 说明 |
|---|---|---|---|
| V | 2 | 2 | RTP版本号 |
| P | 1 | 0 | 无填充 |
| X | 1 | 0 | 无扩展头 |
| CC | 4 | 0 | CSRC计数为0 |
| M | 1 | 分片结束或关键帧时置1 | 标记位,播放器用它判断帧边界 |
| PT | 7 | 96 | 动态载荷类型,标记H264 |
| SEQ | 16 | 每包+1 | 序列号,检测丢包乱序 |
| TS | 32 | 90000/fps递增 | 时间戳 |
| SSRC | 32 | 随机值 | 同步源标识 |
序列号必须对每个RTP包递增,包括同一个NALU拆出来的所有分片。时间戳则不同:同一帧的所有分片共享同一个时间戳,只有切到下一帧才增加。SSRC建议随机生成,用来区分不同RTP流,多路推流时尤为重要。
3.2 选用规则:按NALU大小决定单包、聚合还是分片
H264的NALU大小差距很大:SPS、PPS可能只有几十字节,普通P帧可能几百字节,IDR帧轻松超过10KB。RFC 3984(更新版是RFC 6184)给出了三种打包模式,选型规则可以简化为一张表。
| 模式 | 适用场景 | 优势 | 载荷结构 |
|---|---|---|---|
| 单一NALU | NALU总长小于MTU | 简单,解码器最容易接受 | 直接放完整NALU |
| STAP-A | 多个小NALU(如SPS+PPS) | 合并发送,省RTP头开销 | 2字节长度前缀+NALU |
| FU-A | 大NALU超过MTU | 分片传输,避免IP层二次分片 | 2字节FU头+分片数据 |
单一NALU是把完整NALU原封不动放进RTP载荷,适用于绝大多数P帧。STAP-A适合把SPS、PPS这类小NALU拼成一个RTP包,每个单元前加2字节大端长度。FU-A是重点,超过MTU的IDR帧必须拆片。别用“大包直接丢给IP层分片”的思路,TCP可以这么干,UDP+RTP在IP分片后只要丢一片,整帧就废了。
3.3 FU-A分片发送:Python代码与头细节
FU-A把原始NALU拆成若干slice,每个slice放一个RTP包,同时在包头写入分片信息。我写发送端习惯先出Python版,跑通逻辑后再翻译成C,下面这段是核心:
def send_nalu_fu_a(sock, dst, nalu, seq, ts, ssrc, mtu=1400): header = nalu[0] nal_type = header & 0x1F payload = nalu[1:] chunk_size = mtu - 12 - 2 # 12字节RTP头 + 2字节FU头 total = (len(payload) + chunk_size - 1) // chunk_size for idx in range(total): part = payload[idx * chunk_size:(idx + 1) * chunk_size] fu_indicator = (header & 0xE0) | 28 s_bit = 1 if idx == 0 else 0 e_bit = 1 if idx == total - 1 else 0 fu_header = (s_bit << 7) | (e_bit << 6) | nal_type rtp_payload = bytes([fu_indicator, fu_header]) + part rtp_header = struct.pack("!BBHII", 0x80, (e_bit << 7) | 96, seq & 0xFFFF, ts & 0xFFFFFFFF, ssrc) sock.sendto(rtp_header + rtp_payload, dst) seq += 1 return seq, totalfu_indicator的低5位固定填28,表示这是FU-A分片包,高3位保留原始NALU头的forbidden_bit和nal_ref_idc。fu_header的bit7是起始标记S、bit6是结束标记E、低5位是原始NALU类型。第一个分片S置1、最后一个分片E置1,中间包S和E都是0。RTP头里的M位只在分片结束时置1,这样接收端一看到M位就知道这一帧的所有分片都到齐了。
chunk_size减去了12字节RTP头和2字节FU头,这步很关键。MTU填1400,是因为以太网MTU是1500,要扣掉20字节IP头、8字节UDP头、12字节RTP头,余量留到1458。为了避免路径上额外开销,实战里再把负载上限压到1380到1400都是安全的。
3.4 STAP-A聚合发送:SPS和PPS的最佳搭档
STAP-A的载荷类型是24,结构是一串“2字节长度+NALU”的拼接。SPS、PPS、SEI这类小NALU单独发太浪费RTP头,聚合后既减少了UDP包数量,又保证关键信息顺序到达。
def send_stap_a(sock, dst, nalus, seq, ts, ssrc): body = b"" for n in nalus: body += struct.pack("!H", len(n)) + n rtp_payload = b"\x18" + body # 0x18 = 24,STAP-A类型 rtp_header = struct.pack("!BBHII", 0x80, 0 | 96, seq & 0xFFFF, ts & 0xFFFFFFFF, ssrc) sock.sendto(rtp_header + rtp_payload, dst) return seq + 1注意每个NALU前用!H即2字节大端序写入长度,接收端按长度边界逐个拆出NALU。STAP-A里不要混入大包,聚合后的总长一旦超过MTU就退化成IP分片,得不偿失。我一般只把SPS、PPS、SEI聚合在一起,最多再带一两个很小的NALU。
4. RTP发送实现:从socket到完整参数调优
4.1 关键参数表:MTU、负载上限、端口与PT
进入正式实现前,把参数全部定下来。这些参数不是随便取的,每一项都要能说清楚为什么。
| 参数 | 推荐值 | 原因 |
|---|---|---|
| RTP负载上限 | 1400字节 | 1500-20-8-12,留出余量 |
| 分片负载 | 1380字节 | 再扣2字节FU头 |
| RTP端口 | 5004或偶数端口 | 惯例使用偶数,RTCP用+1端口 |
| PT | 96 | 动态载荷类型起点 |
| SSRC | 随机32位值 | 多流场景避免冲突 |
| 起始序列号 | 随机偶数 | 防串流重启后混乱 |
端口这块有个惯例:RTP通常用偶数的UDP端口,相邻奇数端口预留给RTCP。PT填96是动态载荷类型,如果接收端SDP里协商的是97或98,发送端要跟着改。SSRC别固定死,每次会话重新随机生成。
4.2 C语言发送核心:一条NALU分发函数
工程实现时我用C,直接操作UDP socket更可控。下面的函数处理“一个NALU怎么发”,根据长度自动选择单一NALU还是FU-A:
// 发送一个NALU,自动选择单一包或FU-A分片 // 返回处理后的RTP序列号 static uint16_t rtp_send_nalu(int fd, const struct sockaddr_in *dst, const uint8_t *nalu, size_t size, uint16_t seq, uint32_t ts, uint32_t ssrc) { const uint8_t header = nalu[0]; const uint8_t nal_type = header & 0x1F; const uint8_t *body = nalu + 1; const size_t body_len = size - 1; const int fu_payload = RTP_MTU - 12 - 2; if (size <= RTP_MTU - 12) { // 单一NALU:RTP头 + 完整NALU uint8_t pkt[12 + RTP_MTU]; pkt[0] = 0x80; pkt[1] = 96; // PT=96, M位先置0 pkt[2] = (seq >> 8) & 0xFF; pkt[3] = seq & 0xFF; pkt[4] = (ts >> 24) & 0xFF; pkt[5] = (ts >> 16) & 0xFF; pkt[6] = (ts >> 8) & 0xFF; pkt[7] = ts & 0xFF; pkt[8] = (ssrc >> 24) & 0xFF; pkt[9] = (ssrc >> 16) & 0xFF; pkt[10] = (ssrc >> 8) & 0xFF; pkt[11] = ssrc & 0xFF; memcpy(pkt + 12, nalu, size); send(fd, pkt, 12 + size, 0); return seq + 1; } // FU-A分片 int cnt = (body_len + fu_payload - 1) / fu_payload; for (int i = 0; i < cnt; i++) { uint8_t pkt[12 + 2 + fu_payload]; size_t n = (i == cnt - 1) ? (body_len - i * fu_payload) : fu_payload; uint8_t fu_indicator = (header & 0xE0) | 28; uint8_t fu_header = 0; if (i == 0) fu_header |= 0x80; if (i == cnt - 1) fu_header |= 0x40; fu_header |= nal_type; // 填充RTP头,M位只在最后一个分片置1 pkt[0] = 0x80; pkt[1] = ((i == cnt - 1) ? 0x80 : 0x00) | 96; pkt[2] = (seq >> 8) & 0xFF; pkt[3] = seq & 0xFF; // 时间戳、SSRC填充逻辑同上,省略重复代码 pkt[12] = fu_indicator; pkt[13] = fu_header; memcpy(pkt + 14, body + i * fu_payload, n); send(fd, pkt, 12 + 2 + n, 0); seq++; } return seq; }这段代码里,单一NALU分支要求整包长度不超过RTP_MTU - 12,也就是给RTP头留出空间。FU-A分片循环里,cnt通过除法向上取整,保证最后一片即使不满也能发出。i == cnt - 1时M位置1,同时把结束片的FU header bit6置1。实际工程里建议用htons()或手动移位写RTP头,因为很多嵌入式平台是大小端混用的,直接按结构体拷贝会有字节序隐患。
4.3 时间戳计算:90000Hz时钟与常见帧率换算
H264在RTP里的时间戳不是毫秒,不是帧号,而是以90000Hz为单位的采样计数。每次编码完一帧,时间戳增加90000 / fps。
| 帧率 fps | 每帧时间戳增量 | 常见场景 |
|---|---|---|
| 15 | 6000 | 低码率监控 |
| 25 | 3600 | 安防、教育录播 |
| 30 | 3000 | 视频会议、直播 |
| 60 | 1500 | 高帧率游戏直播 |
如果源帧率不是整数,比如29.97fps,直接用90000 / 29.97取整会累积误差。稳妥做法是拿一个变量累加“名义帧数”,每帧做四舍五入,或者干脆按实际采集时刻换算成90000Hz单位。还有一个容易忽略的点:如果B帧需要重排序,发送顺序要按解码时间戳DTS走,不能直接按显示顺序PTS发。RTP时间戳对应的是编码器输出的顺序,不是播放顺序。
4.4 发送循环与SPS/PPS补发策略
主循环的骨架是:读NALU → 判断类型 → 缓存SPS/PPS → 遇到IDR先补发 → 分片发送 → 控制帧间隔。文件推流时用sleep控制节奏,直播时由采集线程的帧时间戳驱动。
// 伪代码:完整帧发送主循环 while (get_next_frame(&frame, &fps)) { for (int i = 0; i < frame.nalu_count; i++) { uint8_t type = frame.nalus[i][0] & 0x1F; if (type == 7) cache_sps(frame.nalus[i], frame.sizes[i]); if (type == 8) cache_pps(frame.nalus[i], frame.sizes[i]); if (type == 5) { // IDR关键帧 rtp_send_nalu(fd, dst, sps, sps_len, &seq, ts, ssrc); rtp_send_nalu(fd, dst, pps, pps_len, &seq, ts, ssrc); } rtp_send_nalu(fd, dst, frame.nalus[i], frame.sizes[i], &seq, ts, ssrc); } ts += 90000 / fps; sleep_ms(1000 / fps); }SPS/PPS不仅要在会话开始时发一次,建议每个IDR关键帧前都发一遍。原因很现实:新观众中途加入时,如果刚好错过开头的SPS,后面全是关键帧也解不出来。多花几十字节换取快速起播,这笔账值得。ts在每帧结束后增加,同一帧的多个NALU使用同一个值。
5. RTP发送避坑与问题排查:五类高频翻车场景
5.1 黑屏:关键帧之前没有SPS/PPS
现象:播放器有码流、有包计数,但画面一直是黑的,换播放器也一样。抓包能看到RTP包在持续发送,甚至能看到IDR分片完整到达。
原因:发送端只在会话开始时发了一次SPS/PPS,或者压根没发。播放器启动后先接收的是非关键帧,等IDR到达时又恰好错过了SPS,解码器没有初始化参数。
解决:强制在每个IDR前发送SPS和PPS。判定条件就是nal_type == 5,发送前把缓存的7、8类型NALU各发一遍。如果网络丢包率较高,可以周期性地每隔2秒重复一次SPS/PPS,播放器能容忍这种冗余。
5.2 花屏:FU-A负载填满MTU导致IP分片
现象:画面偶尔正常,偶尔大范围色块花屏,丢包率数字不高但损坏面积很大。Wireshark里能看到RTP包比预期大,IP层出现fragment偏移。
原因:分片大小按MTU=1500直接填,没有扣掉IP、UDP、RTP的头部开销。RTP包到达网络层加上20字节IP头和8字节UDP头后超过1500,底层又把大包切碎,一个分片丢失就拖垮整个NALU。
解决:RTP包总长控制在1500 - 20 - 8 = 1472以内,实际再保守一点,让RTP载荷不超过1400字节。FU-A场景还要再扣2字节FU头,取1380或更小。修改chunk_size后重测,Wireshark里不应再看到IP层为同一个UDP包生成多个fragment。
5.3 播放器缓冲困难:时间戳和序列号没遵守规则
现象:画面一顿一顿,播放器缓冲时间越来越长,甚至出现“慢动作回放”效果。抓包看包序没错,但时间戳增量忽大忽小。
原因:发送端把时间戳按毫秒填了,或者每帧累加的值是随便选的。H264 RTP要求基于90000Hz时钟,毫秒换算过来的增量会严重不均匀。序列号方面,有人把同一NALU的分片序列号也复制一遍,导致接收端判定乱序。
解决:每帧时间戳增量写死为90000 / fps的整数结果,有B帧时按DTS排序。分片包的SEQ每片都要递增,同一帧所有分片共享TS。检查起来很简单:Wireshark的RTP分析面板会直接标出delta和jitter,看到异常波动基本就是这两类问题。
5.4 多路流互相干扰:SSRC与PT冲突
现象:两路推流同时发送时,播放器一会儿是一路画面,一会儿跳到另一路,甚至两个声音叠加。抓包过滤单个端口能看到两个不同IP地址的SSRC相同或相近。
原因:SSRC写死成同一个常数,或者PT在SDP协商里与实际发送不一致。RTP接收端靠SSRC识别同步源,靠PT识别payload类型,两者一旦冲突,播放器会尝试把两路流当作一个流来解码。
解决:SSRC每次会话用随机数生成,避免使用固定值或取模容易碰撞的序列号。开发环境里强制以“端口+SSRC”二元组区分会话,联调时先确认SDP里的PT和发送端实际填的PT一致。
5.5 自测兜底:抓包显示正常但播放仍异常
现象:Wireshark看RTP包的header都对,序列号平滑、时间戳稳定,可播放器就是不出画面。
原因:载荷细节出错。比如FU-A分片的原始NALU头被误改,NRI或type被覆盖;或者STAP-A聚合包里长度前缀写成了小端;又或者IDR帧的M位没有按分片结束位置置位。
解决:把抓包文件保存下来,用发送端的NALU日志逐帧对比载荷。多花五分钟写个重组校验脚本,比抱着播放器猜问题高效得多。
6. 验证与进阶:用Wireshark当裁判,写一个重组脚本把关
验证发送正确的最快路径是抓包加脚本比对。先抓一段推流,用tshark过滤出RTP包:
tshark -r capture.pcap -Y "rtp && ip.src==192.168.1.100 && udp.srcport==5004"重点看RTP分析面板中的sequence error和Delta时间。出现大量sequence error说明发送端序列号不连续,Delta抖动过大说明时间戳或帧间隔控制有问题。FU-A分片是否重组完整,可以通过一个简单的重组脚本校验,把同一SSRC、同一时间戳、S位和E位之间的所有包拼回去:
def rebuild_fu_a(packets): frame = {} for pkt in packets: if pkt.payload[0] & 0x1F != 28: continue s = (pkt.payload[1] >> 7) & 1 e = (pkt.payload[1] >> 6) & 1 nalu_type = pkt.payload[1] & 0x1F if s: frame['buf'] = bytearray([(pkt.payload[0] & 0xE0) | nalu_type]) frame['buf'] += pkt.payload[2:] if e: yield bytes(frame['buf']) frame.clear()重组出的NALU长度如果与发送端日志一致,这一帧的封包就可信。这个脚本平时调试不一定要跑,但遇到“抓包正常却播不了”的诡异问题,它就是最后的裁判。
这套工程里还有几个可扩展的方向:把发送端接入RTSP的SDP协商,让播放器通过rtsp://地址来拉流;外部SRTP加密传输;以及针对高丢包链路增加前向纠错或RTP重传。先保证裸的RTP/H264链路能稳定播放,再往上叠这些功能。从那以后我每次推流调试都强制走一遍“切NALU、核对时间戳、抓包重组”三步流程,五分钟内能筛掉九成问题,希望帮到你。
本文还有配套的精品资源,点击获取