资讯动态

H.264 over RTP封装实战:NALU拆分、时间戳与SDP协商

发布时间:2026/10/7 1:56:28 来源:尧图企业网站定制
简介本资源是一份面向音视频开发工程师与网络协议学习者的RTPH264实时流传输实践项目聚焦于如何将H264编码视频流按RTP协议规范封装、发送并由VLC播放器接收解码解决多媒体流媒体开发中常见的协议适配与端到端调试问题。压缩包共21个文件含2个H264原始码流test.264、my.h264、1个关键SDP会话描述文件w.sdp、1个C实现的NAL单元处理程序NALDecoder.cpp及对应可执行exe、头文件h264.h/avc.h264等以及VC6工程相关编译产物obj、pdb、dsp等完整覆盖从NAL分割、RTP头封装、IP/端口配置到VLC播放验证的全流程代码与运行环境。资源大小929KB结构紧凑便于快速编译调试。已有327人学习下载提供可直接运行的Windows平台示例工程、配套SDP配置模板及基础NAL解析逻辑助开发者深入理解RTP载荷格式、时间戳与序列号机制并快速搭建H264 over RTP测试链路。1. 为什么用 RTP 发 H.264 不是“加个头就完事”而是音视频传输里最常翻车的黑匣子你手上有 H.264 编码器输出的 NALU比如 x264 或 FFmpeg 的-c:v libx264想实时推到远端解码播放——这时很多人第一反应是“RTP 就是个 UDP 包头把 H.264 数据塞进去发出去不就行了”结果一跑起来花屏、卡顿、解码器报invalid NAL unit type、甚至完全收不到帧。这不是代码写错了而是你跳过了 RTP 承载 H.264 的三重契约NALU 必须按 RFC 6184 拆分与封装、时间戳必须严格对齐 PTS/DTS、负载类型Payload Type和 SPS/PPS 必须在 SDP 中显式协商。这三件事没做对哪怕字节流完全正确接收端也会当垃圾丢掉。本文面向已能生成 H.264 码流如从摄像头、FFmpeg 编码器或文件读取、但卡在“发出去却播不了”的一线开发者——不讲抽象协议栈只拆你实际要写的 C/Python 代码、要填的 SDP 字段、要校验的每个 NALU 类型以及我踩过三次才记住的 timestamp 生成逻辑。2. 从原始 H.264 码流到可传输 RTP 包四步不可跳过的封装链H.264 码流不是裸数据而是一串以0x00000001或0x000001开头的 NALUNetwork Abstraction Layer Unit。RTP 不允许直接塞整个 Annex B 流必须按 RFC 6184 拆解、打时间戳、加 RTP 头、再组包。常见错误是直接把00 00 00 01 65 ...整块塞进 RTP payload——这会导致接收端根本无法识别起始位置。下面用 Python基于rtp库 手动构造和 C基于libsrtp或裸 socket两种路径说明核心逻辑重点落在你必须自己控制的环节。2.1 第一步剥离 Annex B 起始码提取原始 NALU 字节H.264 编码器输出如 FFmpeg 的avcodec_encode_video2默认是 Annex B 格式即每个 NALU 前有0x000000014 字节或0x0000013 字节起始码。RTP 要求 payload 是 raw NALU去掉起始码保留第一个字节的 nal_ref_idc nal_unit_type。必须手动剥离def extract_nalus_from_annexb(data: bytes) - list[bytes]: 从 Annex B 码流中提取原始 NALU不含起始码 nalu_list [] start 0 while start len(data): # 查找下一个起始码0x00000001 或 0x000001 pos data.find(b\x00\x00\x00\x01, start) if pos -1: pos data.find(b\x00\x00\x01, start) if pos -1: break if pos start: # 提取上一个 NALU从 start 到 pos nalu data[start:pos] if nalu: # 非空才加入 nalu_list.append(nalu) start pos (4 if data[pos:pos4] b\x00\x00\x00\x01 else 3) # 处理末尾剩余数据无起始码结尾时 if start len(data): tail data[start:] if tail: nalu_list.append(tail) return nalu_list # 示例从 h264 文件读取并提取 with open(input.h264, rb) as f: annexb_data f.read() nalu_list extract_nalus_from_annexb(annexb_data) print(f提取到 {len(nalu_list)} 个 NALU首字节{[n[0] for n in nalu_list[:3]]})逻辑说明extract_nalus_from_annexb不是简单按固定长度切分而是逐个定位起始码位置。关键点在于起始码可能是 3 字节或 4 字节必须分别判断最后一段可能无起始码结尾如文件末尾需单独处理返回的每个nalu是 raw bytes首字节为nal_unit_type如0x65 IDR,0x61 non-IDR,0x07 SPS,0x08 PPS。参数说明nalu[0] 0x1F即nal_unit_type这是后续判断是否需要 STAP-A/FU-A 封装的核心依据。2.2 第二步按 NALU 大小决定封装方式——FU-A 还是 Single NAL UnitRFC 6184 规定单个 NALU 若 ≤ 1400 字节典型 MTU 减去 IP/UDP/RTP 头可直接作为 Single NAL Unit Packetpayload type 96~127具体值由 SDP 定义若 1400 字节如高分辨率 IDR 帧必须分片为 FU-AFragmentation Unit A。不能凭感觉设阈值——必须用实际网络 MTU 减去 28 字节IPv4 header 20B UDP header 8B再减 12 字节RTP header 最小长度即max_payload mtu - 40。常见误用是硬编码1400但在某些内网或 IPv6 环境下会出错。def should_fragment(nalu: bytes, max_payload: int 1400) - bool: 判断 NALU 是否需 FU-A 分片 return len(nalu) max_payload def make_fu_a_header(nalu: bytes, start: bool, end: bool) - bytes: 构造 FU-A header1字节bit0forbidden, bit1-2nri, bit3start, bit4end, bit5-7type original_type nalu[0] 0x1F nri (nalu[0] 5) 0x03 fu_indicator (nri 5) | 28 # type 28 FU-A fu_header 0 if start: fu_header | 0x80 # bit3 if end: fu_header | 0x40 # bit4 fu_header | original_type # bit5-7 original nal_unit_type return bytes([fu_indicator, fu_header]) def fragment_nalu(nalu: bytes, max_payload: int 1400) - list[bytes]: 将 NALU 分片为 FU-A 包payload 不含原始 NALU 头 if len(nalu) max_payload: return [nalu] fragments [] payload nalu[1:] # 去掉原始 NALU 头保留类型信息在 FU header 中 offset 0 while offset len(payload): is_start (offset 0) is_end (offset max_payload len(payload)) chunk payload[offset:offset max_payload] fu_header make_fu_a_header(nalu, is_start, is_end) fragments.append(fu_header chunk) offset max_payload return fragments # 示例对一个大 IDR 帧分片 large_idr b\x65 b\x00 * 2000 # 模拟 2001 字节 IDR fragments fragment_nalu(large_idr, max_payload1400) print(f分片数{len(fragments)}, 各片长度{[len(f) for f in fragments]})逻辑说明fragment_nalu返回的是纯 payload 列表每个元素是FU-A header payload chunk。注意FU-A 的fu_indicator固定为nri5 | 28fu_header的 bit5-7 必须填原始nal_unit_typestart和end标志位必须严格对应分片顺序首片start1,end0中间start0,end0末片start0,end1接收端靠这两个标志重组错一个就会解码失败。参数说明max_payload必须与实际网络环境匹配。若用 Wireshark 抓包发现大量 ICMP Fragmentation Needed说明此值过大。2.3 第三步生成严格单调递增的 RTP 时间戳RTP 时间戳不是系统时间而是媒体采样时钟的 ticks。H.264 的采样率是 90kHzRFC 3551 规定即每毫秒 90 ticks。关键陷阱时间戳必须与帧显示时间PTS对齐且同一 GOP 内所有帧必须用相同时间戳基线。常见错误是每发一个包就ts 90导致 B 帧时间戳乱序解码器丢弃。class RtpTimestampGenerator: def __init__(self, clock_rate: int 90000): self.clock_rate clock_rate self.last_pts 0 self.base_ts 0 self.ts_offset 0 def update_base(self, pts_ns: int) - int: 根据新帧 PTS 更新时间戳基线单位纳秒 if pts_ns 0: return self.base_ts # 转换为 tickspts_ns * clock_rate / 1e9 ts_ticks int(pts_ns * self.clock_rate / 1_000_000_000) if ts_ticks self.base_ts: self.base_ts ts_ticks return self.base_ts def get_timestamp(self, pts_ns: int) - int: 获取当前帧 RTP timestamp base self.update_base(pts_ns) # 对于同一 PTS 的多个 NALU如 SPSPPSIDR用相同 timestamp return base # 实际使用从 FFmpeg AVFrame 获取 pts单位AV_TIME_BASE 1e6 ns # frame.pts * av_q2d(codec.time_base) * 1e9 → 得到纳秒级 pts ts_gen RtpTimestampGenerator() pts_ns 123456789000 # 示例123.456789 秒 rtp_ts ts_gen.get_timestamp(pts_ns) print(fPTS {pts_ns} ns → RTP TS {rtp_ts})逻辑说明RtpTimestampGenerator的核心是update_base——它确保时间戳只增不减且同一 PTS 的所有 NALU 共享同一base_ts。这是因为SPS/PPS 通常随 IDR 帧一起发送它们的 PTS 相同一个帧可能被拆成多个 FU-A 包所有包必须用相同 timestamp若用frame_count * 90会忽略编码器实际 PTS导致音画不同步。参数说明clock_rate90000是 H.264 的强制值不可改pts_ns必须来自编码器输出的精确 PTS如 FFmpeg 的AVFrame.pts而非系统time.time()。2.4 第四步构造 RTP 包头并填充 payloadRTP 头共 12 字节结构固定。重点字段version2,padding0,extension0,csrc_count0,marker1仅最后一个包置 1如 FU-A 的 end 片payload_typeSDP 中协商值常用 96sequence_number严格递增timestamp上步生成ssrc随机 32 位标识源。import struct import random def build_rtp_packet(payload: bytes, seq_num: int, timestamp: int, ssrc: int, pt: int 96, marker: bool False) - bytes: 构造 RTP 包12 字节头 payload # RTP header layout: V2, P0, X0, CC0, Mmarker, PTpt, SEQseq_num, TStimestamp, SSRCssrc v_p_x_cc 0x80 # version2, padding0, extension0, csrc_count0 if marker: v_p_x_cc | 0x10 # set marker bit header struct.pack( !BBHII, v_p_x_cc, # 1 byte: version, padding, extension, csrc_count (pt 0x7F), # 1 byte: payload type (7 bits) seq_num 0xFFFF, # 2 bytes: sequence number timestamp 0xFFFFFFFF, # 4 bytes: timestamp ssrc 0xFFFFFFFF # 4 bytes: ssrc ) return header payload # 示例构造一个 Single NALU 包 ssrc random.randint(0, 0xFFFFFFFF) seq_num 1 rtp_ts 123456789 payload b\x65\x00\x01\x02 # IDR NALU rtp_pkt build_rtp_packet(payload, seq_num, rtp_ts, ssrc, pt96, markerTrue) print(fRTP 包长度{len(rtp_pkt)} 字节头前 12 字节{rtp_pkt[:12].hex()})逻辑说明build_rtp_packet用struct.pack(!BBHII)保证网络字节序Big Endian。关键点marker位必须只在该帧最后一个 RTP 包置 1如 Single NALU 包、FU-A 的 end 片seq_num必须每包 1不可重复或跳变ssrc在会话生命周期内保持不变重启后可重选。参数说明pt96是动态 payload type实际值由 SDP 中artpmap:96 H264/90000指定若用静态 PT如 96~127需确保两端一致。3. SDP 协商让接收端知道“你发的是什么”而不是靠猜RTP 是无状态协议接收端无法从包中反推编码参数。SPS/PPS、payload type、clock rate、FMT如 profile-level-id必须通过 SDPSession Description Protocol提前告知。常见错误是省略 SDP 或字段填错导致接收端用默认参数解码失败如把 baseline 当成 high profile。3.1 SDP 必填字段详解从 SPS/PPS 解析出 profile-level-idSPSSequence Parameter Set和 PPSPicture Parameter Set是 H.264 解码必需的初始化数据。它们以 Annex B 格式存在需从中提取profile_idc、level_idc、constraint_set_flags组合成profile-level-id6 字符 hex 字符串。例如 SPS67 42 00 29→profile_idc0x42,level_idc0x29→profile-level-id42e029e0是 constraint_set0_flag 7。def parse_sps_pps(sps_bytes: bytes, pps_bytes: bytes) - dict: 从 SPS/PPS raw bytes 解析 SDP 所需参数 if len(sps_bytes) 4: raise ValueError(SPS too short) # SPS 第一字节profile_idc profile_idc sps_bytes[0] # 第二字节constraint_set0_flag ~ constraint_set5_flag reserved_zero_2bits constraint_byte sps_bytes[1] constraint_flags constraint_byte 0xFC # bit0-1 reserved, so use bit2-7 # 第三字节level_idc level_idc sps_bytes[2] # profile-level-id profile_idc 16 | constraint_flags 8 | level_idc # 转为 6 字符 hex如 42e029 profile_level_id f{profile_idc:02x}{constraint_flags:02x}{level_idc:02x} # SPS/PPS base64 编码用于 SDP afmtp 行 import base64 sps_b64 base64.b64encode(sps_bytes).decode() pps_b64 base64.b64encode(pps_bytes).decode() return { profile_level_id: profile_level_id, sps_b64: sps_b64, pps_b64: pps_b64, packetization_mode: 1, # 1 non-interleaved mode (RFC 6184) sprop_parameter_sets: f{sps_b64},{pps_b64} } # 示例从实际 SPS/PPS 计算 sps_raw bytes([0x67, 0x42, 0x00, 0x29, 0x68, 0x40, 0x00, 0x00, 0x00, 0x00]) # baseline profile, level 3.0 pps_raw bytes([0x68, 0xee, 0x06, 0xe2]) sdp_params parse_sps_pps(sps_raw, pps_raw) print(fSDP 参数{sdp_params})逻辑说明parse_sps_pps输出的profile_level_id是afmtp行的关键。例如afmtp:96 packetization-mode1;profile-level-id42e029;sprop-parameter-setsZ2QAMqwa...。其中packetization-mode1表示非交错模式即 FU-A/STAP-A必须与发送端一致sprop-parameter-sets是逗号分隔的 base64 SPS/PPS接收端用它初始化解码器profile-level-id错一位如42e029写成42e028会导致解码器拒绝初始化。参数说明sps_raw[0]是profile_idc66baseline, 77main, 100highsps_raw[2]是level_idc如 0x293.0, 0x324.0constraint_flags由sps_raw[1]的 bit2-7 组成。3.2 完整 SDP 示例包含媒体行、属性行、连接信息SDP 是文本协议必须严格遵循格式。缺失acontrol:或arange:会导致某些播放器如 VLC无法 seekcIN IP4 0.0.0.0会被视为无效地址。v0 o- 1234567890 1 IN IP4 127.0.0.1 sH264 Stream t0 0 atool:libavformat 59.16.100 mvideo 5004 RTP/AVP 96 cIN IP4 224.0.0.1 # 多播地址或单播用具体 IP artpmap:96 H264/90000 afmtp:96 packetization-mode1;profile-level-id42e029;sprop-parameter-setsZ2QAMqwaEoQAAAMAAQAAAwBhAAAAaA,aO48gA acontrol:trackID1 aframerate:30.0 acliprect:0,0,1920,1080 aframesize:96 1920-1080关键字段说明mvideo 5004 RTP/AVP 96媒体类型 video端口 5004传输协议 RTP/AVPpayload type 96cIN IP4 224.0.0.1必须填实际发送地址多播或单播不能是0.0.0.0afmtp:96 ...必须包含packetization-mode1和正确的profile-level-idacontrol:trackID1RTSP 场景必需否则 VLC 无法建立控制通道aframerate和aframesize是可选但强烈建议帮助接收端预分配缓冲区。4. 避坑RTP 发 H.264 的 5 个血泪经验第 3 条让 70% 的人重写 timestamp 逻辑RTP/H.264 的坑不在协议复杂而在细节违反 RFC 会导致静默失败包能发能收但解码器直接丢弃。以下是我在安防 IPC、WebRTC 网关、自研推流 SDK 中踩出的 5 个高频问题每条都附现象、根因和验证方法。4.1 现象Wireshark 显示 RTP 包正常但 VLC 播放花屏日志报invalid NAL unit type原因NALU 首字节未正确剥离 Annex B 起始码导致 payload 以0x00开头非法 nal_unit_type。例如00 00 00 01 65 ...被整体当 payload首字节0x00不是合法 NALU 类型。解决用extract_nalus_from_annexb严格提取 raw NALU并打印nalu[0]验证。合法值0x07(SPS),0x08(PPS),0x05(IDR),0x01(non-IDR)。若看到0x00或0x01说明起始码未剥离干净。验证Wireshark → 右键 RTP 包 → “Decode As” → “RTP” → 查看 payload 第一字节。4.2 现象首帧能解后续帧全黑Wireshark 显示大量 FU-A 包但无 Single NALU原因SPS/PPS 未随 IDR 帧发送或afmtp中sprop-parameter-sets的 base64 错误如少字符、换行符未去除。接收端缺少初始化参数无法解后续帧。解决确保每个 IDR 帧前发送 SPS/PPS作为独立 RTP 包marker1且 SDP 中sprop-parameter-sets值与实际 SPS/PPS base64 完全一致可用在线 base64 解码器验证。验证Wireshark 过滤rtp.payload contains Z2QSPS base64 前缀确认其出现在首个 IDR 前。4.3 现象播放卡顿解码器频繁 flush日志提示timestamp discontinuity原因RTP timestamp 未与编码器 PTS 对齐或同一帧的多个包用了不同 timestamp。例如 FU-A 分片用了ts0,ts1,ts2而标准要求所有分片用相同 timestamp。解决必须用RtpTimestampGenerator类以编码器 PTS 为基准生成 timestamp且同一 PTS 的所有 NALU包括 SPS/PPS/IDR 分片共享同一base_ts。验证Wireshark → 右键 RTP 包 → “Protocol Preferences” → “RTP” → 勾选 “Analyze RTP streams”查看 timestamp delta 是否恒定如 270030fps。4.4 现象UDP 包发送成功但接收端收不到netstat -s | grep -i udp.*drop显示丢包原因socket 发送缓冲区不足SO_SNDBUF默认仅 212992 字节而 H.264 码率高时 burst 发送导致内核丢包。解决发送前设置 socket 缓冲区int sndbuf_size 4 * 1024 * 1024; // 4MB setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, sndbuf_size, sizeof(sndbuf_size));验证cat /proc/sys/net/core/wmem_max查看系统上限确保sndbuf_size≤ 此值。4.5 现象SDP 交换成功VLC 显示“Streaming…”但无画面Wireshark 无 RTP 包原因防火墙或 NAT 阻断了 RTP 端口如 SDP 中mvideo 5004但 5004 端口未开放。解决检查发送端iptables -L -n | grep 5004接收端nc -uvz sender_ip 5004测试连通性若用多播确认route add -net 224.0.0.0 netmask 240.0.0.0 dev eth0。验证在发送端tcpdump -i any udp port 5004 -w rtp.pcap确认 pcap 中有 UDP 包。5. 验证与调优用 Wireshark VLC 构建最小闭环以及三个必调参数写完代码只是开始真正落地必须用真实工具验证端到端链路。我习惯用 Wireshark VLC 搭建最小闭环Wireshark 抓包确认协议合规VLC 播放验证解码效果。下面给出具体步骤、关键过滤表达式以及三个影响稳定性的核心参数调优指南。5.1 Wireshark 抓包分析五步定位协议层问题Wireshark 是 RTP/H.264 的终极诊断工具。不要只看“有没有包”要验证每个字段是否符合 RFC。启动抓包Capture → Options → Interfaceeth0 → Capture Filterudp port 5004替换为你实际端口。过滤 RTP 流输入过滤表达式rtp ip.addr192.168.1.100替换为发送端 IP。检查 NALU 类型右键任一 RTP 包 →Decode As → RTP展开RTP Payload查看NAL Unit Type是否为IDR Picture、SPS等合法值。验证 timestamp 连续性右键 →Follow → RTP Stream查看Time Delta列是否稳定如 30fps 应为 ~33ms。检查分片完整性对 FU-A 包确认Start bit和End bit成对出现如Start1,End0→Start0,End0→Start0,End1。提示Wireshark 默认不解析 H.264需手动指定Edit → Preferences → Protocols → RTP → Add填入96payload type和H264codec。5.2 VLC 播放验证从 SDP 文件启动而非直接 URLVLC 对 RTP 支持依赖 SDP 描述。直接rtp://:5004会失败必须用 SDP 文件。将上节 SDP 内容保存为stream.sdp。VLC →Media → Open Network Stream → File→ 选择stream.sdp。若播放失败VLC 日志Tools → Messages会明确提示missing SPS、invalid timestamp或unknown payload type。注意VLC 默认禁用 RTP over TCP确保Preferences → Input/Codecs → RTP/RTCP → Use RTP over RTSP未勾选。5.3 三个必调参数影响 90% 场景的稳定性参数默认值推荐值作用调优依据MTU / max_payload1400min(1400, path_mtu - 40)控制 FU-A 分片阈值用ping -M do -s 1472 dst测实际 MTU1472281500若丢包降为 1300RTP send interval无限制1000 / fpsms控制发送节奏避免 burst30fps → 33ms 间隔过高导致卡顿过低增加延迟SPS/PPS 发送频率仅初始每 10 秒 每个 IDR 前防止接收端失同步网络抖动时丢失 SPS/PPS 会导致整段黑屏调参实操max_payload若 Wireshark 显示ICMP Destination Unreachable (Fragmentation Needed)立即降低send interval用usleep(33000)控制 C 发送循环Python 用time.sleep(0.033)SPS/PPS 频率在发送 IDR 帧前插入 SPS/PPS 包并用定时器每 10 秒重发一次alarm(10)。最后说个血泪教训我曾为赶工期跳过 SDP 验证直接硬编码profile-level-id42e029结果客户现场用海思芯片解码器它要求420029constraint_set0_flag0死活播不出。后来加了一行日志打印实际 SPS 字节5 分钟定位。所以永远相信 RFC不信文档更不信“应该没问题”——把print(fSPS first byte: {sps[0]:02x})写进初始化函数比写一百行注释都管用。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑