资讯动态

国标音视频二进制拆包:从RTP到PS/PES与PTS/DTS解析

发布时间:2026/10/26 4:24:46 来源:尧图企业网站定制
对接过国标协议的人大概都遇到过这种场面SIP 信令一路畅通INVITE 发出去200 OK 回来RTP 端口也谈好了抓包工具上数据包一行一行地往下滚包长正常、包数正常、SSRC 也只有一个可预览窗口就是一片黑。日志里没有任何报错设备那边也说自己推得好好的。这种时候能救命的只有一件事——把 UDP 载荷里的那一串二进制字节一个字节一个字节地拆开看清楚。国标协议的音视频承载链路本质上是把 H.264/H.265 和音频塞进 PS 容器再塞进 RTP最后塞进 UDP四层套娃任何一层的字段理解错一位画面就出不来。这篇内容就是围绕拆解二进制网络包这件事展开的从字节序、起始码、PS 包头、PES 头、PTS/DTS 还原一路讲到 RTP 重组和用 Python 落一个能跑起来的拆包器。适合正在做音视频接入、平台对接、流媒体服务端开发的同行看也适合刚接触国标协议、被一堆十六进制数劝退的朋友按顺序啃下来。1. 这条链路上到底跑着几种包很多人一开始会把国标协议理解成一个协议,实际上它是一套组合拳,信令和媒体走的是完全不同的两条路,连承载协议都不一样。搞清楚这一层,后面的拆包才有目标感,否则你抓了一堆包,都不知道该点开哪一条。1.1 SIP 信令不搬运画面它只负责牵线信令部分走的是 SIP over UDP默认 5060实际部署里经常被改成别的端口负责的事情是注册、心跳、目录查询、实时点播的 INVITE、BYE 这些。这些报文是纯文本的你能直接肉眼读里面有From、To、Call-ID、CSeq还有一段 SDP。SDP 才是关键它告诉对端我这边准备在 192.168.x.x 的 30000 端口收 RTP编码是 PS/90000视频是 H.264。 这一段如果没看仔细你后面拆包会完全找错方向——因为媒体端口是协商出来的不是固定的有的设备第一次用 30000第二次可能就跳到 40000 多了。我见过一种情况某开发者抓包时写的过滤条件是udp port 5060然后抱怨说只有信令没有媒体。这不是设备不推流是媒体根本不走 5060。正确做法是先看 SDP 里的mvideo那一行拿到端口号再用那个端口过滤。SDP 里还有两个字段值得盯住y行SSRC和f行描述媒体参数比如v/1/PS/90000。SSRC 是后面做多路分流的关键一路视频流一个 SSRC如果你同时点播了多路全混在一个缓冲区里拼包必然乱套。1.2 RTP 是信封PS 是箱子PES 是格子把整个媒体链路想象成寄快递UDP 是卡车RTP 是信封PS 是大纸箱PES 是纸箱里分格的隔板最里面那件货才是 H.264 的 NAL 单元。信封上写着第几封序列号和什么时候寄的时间戳纸箱上写着这箱里装的是什么节目PS 头隔板上写着这一格是视频还是音频、什么时候该拿出来播PES 头。拆包的方向和打包的方向是反的所以顺序要倒过来先按 SSRC 把 RTP 包分组按序列号排好序把载荷拼成一整段连续的字节流然后在这段字节流里找00 00 01 BA这样的起始码按起始码切出一个个 PS 包再在每个 PS 包里找00 00 01 E0、00 00 01 C0这样的 PES 起始码切出 PES 包最后从 PES 载荷里拿 NAL 单元或者音频帧。这个顺序听起来直白但每一步都有坑。比如序列号会回绕16 位最大 65535跑满一圈从 0 重新开始如果你直接用sorted()排回绕那一刻顺序就全反了。再比如 PS 包不一定对齐到 RTP 包的边界一个 PS 包可能被切进三个 RTP 包也可能一个 RTP 包里塞了两个半 PS 包。这些后面都会展开讲。2. 拆二进制包之前先把两条规矩立住动手写解析代码之前有两个底层约定必须先钉死否则你写出来的解析器在别人机器上跑就是一堆乱码。这两条规矩不涉及业务逻辑但属于错一次就全错的类型。2.1 所有多字节字段一律按大端序读PS、PES、RTP 这几个容器的规范都来自 MPEG 体系全部采用大端序Big-Endian也就是高位字节在前。举一个具体的例子PES 包的长度字段占 2 字节如果抓到的两个字节是0x01 0x2C那么长度就是0x012C等于 300 字节而不是0x2C01。Python 里读这种字段用int.from_bytes(b, big)或者struct.unpack(H, b)都行。用前缀明确写出来比依赖默认值安全得多因为不同平台的默认字节序不一样同一段代码在 x86 上跑对了换到别的地方可能就翻车。C 语言里更容易出问题因为很多人习惯直接做指针强转uint16_t len *(uint16_t*)(buf 4); // 错没有处理字节序和内存对齐正确写法应该是手工拼字节uint16_t len (buf[4] 8) | buf[5];或者用ntohs()。手工拼虽然啰嗦但意图清晰还避开了内存对齐的问题——在某些 CPU 架构上非对齐访问会直接触发异常。还有一点容易忽略网络抓包拿到的原始字节流与内存里struct的布局是两回事。像 PS 包头这种3 位 1 位 15 位 1 位的位域结构别指望用#pragma pack加结构体定义能一次对上编译器对位域的实现细节差异很大。老老实实写一个位读取器从高位往低位一位一位地取最稳。2.2 用起始码当路标而不是用长度当路标MPEG-PS 这套格式里绝大多数包都以00 00 01开头后面跟一个字节的流 ID 或者类型码。这三个零加一个一就是你的路标。相比之下长度字段反而不可靠原因有两个。第一PES 层里视频包的PES_packet_length经常被填成 0。规范里对这种情况的解释是长度未定义一直到下一个起始码为止。设备端这么做的原因很实在一帧 1080P 的 I 帧可能有几十 KB已经超过 16 位长度字段能表达的上限65535干脆填 0 让解析方自己找边界。所以你的解析器必须支持长度字段为 0 时靠扫描下一个起始码定界这条分支不支持就会在 I 帧上卡死。第二TS 流里有一种叫自适应字段的东西会把长度撑大PS 流里也有pack_stuffing_length这种填充机制。如果只信长度字段一路加很容易对不上。所以我一般的做法是先用起始码做粗定位切出候选区间再用长度字段做交叉校验长度对得上就信长度对不上就以起始码为准同时打一条告警日志。这个策略在对接过的十几种设备上都没出过问题。注意扫描起始码时一定要防止越界。00 00 01可能刚好出现在缓冲区末尾被截断这时候不能直接判定为没有更多包要保留最后 2 个字节到下一批数据里继续拼。3. PS 包头把 14 个字节逐位对上号PS 包是国标媒体流里最外层的容器也是最容易被跳过的一层。很多人拿到 RTP 载荷直接往里找00 00 00 01 67这种 NAL 起始码找不到就以为流有问题。实际上中间还隔着 PS 头和 PES 头要先剥掉这两层才能看到真正的编码数据。3.1 从00 00 01 BA开始的 14 个字节一个完整的 PS pack header 固定占 14 字节结构如下偏移长度字段说明04pack_start_code固定00 00 01 BA41位域 12 位固定值01 SCR[32:30] 3 位 marker 1 位 SCR[29:28] 2 位51SCR 续SCR[27:20]61位域 2SCR[19:15] 5 位 marker 1 位 SCR[14:13] 2 位71SCR 续SCR[12:5]81位域 3SCR[4:0] 5 位 marker 1 位 保留 2 位91SCR 尾位保留 2 位 program_mux_rate[21:16] 6 位101mux_rateprogram_mux_rate[15:8]111位域 4program_mux_rate[7:0]121位域 5marker 1 位 marker 1 位 保留 5 位 pack_stuffing_length[2:0] 3 位13可变stuffing填充字节长度由上面 3 位决定SCR 是系统参考时钟占 33 位单位和 PTS 一样是 90kHz。它的作用相当于整个复用流的总时钟用来同步音视频的基准。实际调试里SCR 的绝对值很少需要关心但两个相邻 PS 包的 SCR 差值能反映码率的稳定程度——如果差值忽大忽小说明设备端缓存调度有问题后面很可能会出现抖动。program_mux_rate 是复用速率单位是 50 字节/秒。这个字段在解复用的时候大多数场景用不上但如果要估算码率可以用它做个粗算mux_rate * 50 * 8得到大致比特率。pack_stuffing_length这个字段坑过不少人。它表示 PS 头后面还有多少个填充字节取值 0 到 7。很多简化写法直接假设它是 0然后从偏移 14 开始读下一个起始码。绝大多数设备确实是 0但少数设备在码率突变的场景会填非零值填充对齐。一旦遇到你的解析器就会把填充字节当成 PES 起始码的一部分导致后面全部错位。稳妥的处理是从偏移 13 取出低 3 位算出实际 payload 起点header_len 14 stuffing_len。3.2 system header 和 program stream map 什么时候冒出来在第一个 PES 包之前通常会先出现两种特殊包00 00 01 BBsystem header和00 00 01 BCprogram stream map。它们不是每一帧都带一般只在流开头出现一次或者在关键参数变化时重新出现。system header记录的是整个复用流的整体参数比如音视频个数上限、缓冲大小。program stream map更关键它描述了这个 PS 流里有哪些基本流、各自的 stream_id 和 stream_type 是什么。有些严格的解复用器靠它来建立流索引缺了它可能直接拒绝解码。实际对接中最常见的现象是平台侧抓包时只抓到了中途的几十个包恰恰没抓到最开始那个包含 map 的包结果解析器建不起流表。这时候要做的就是放弃依赖 map改为动态发现——看到一个未经登记的 stream_id先用 stream_id 的范围判断它是视频还是音频0xE0 到 0xEF 是视频0xC0 到 0xDF 是音频然后边收边登记。这个策略兼容性更好也是我在实际项目里默认采用的方式。另外还要注意00 00 01 B9program end code、00 00 01 BEpadding stream、00 00 01 BFprivate stream 2。这几个包没有 PES 扩展头长度字段直接跟在流 ID 后面。BF 在国标里有时候被用来传设备自定义信息比如抓拍图片或者私有控制数据看到它不要慌按长度 裸数据跳过就行。4. PES 层判断这是视频还是音频的分水岭剥掉 PS 头之后接下来就是 PES 包。PES 是整条链路里信息密度最高的一层区块编码类型、时间戳、数据长度全在这儿。4.1 stream_id 是分流的第一依据PES 包的开头永远是00 00 01加一个字节的 stream_id然后是 2 字节的包长度。常见取值stream_id类型说明0xE0 - 0xEF视频0xE0 最常见多路时依次往后排0xC0 - 0xDF音频0xC0 常见0xBD私有流 1国标里常见于透传音频或其他私有数据0xBE填充无扩展头0xBF私有流 2无扩展头0xB9节目结束无扩展头国标场景下视频基本固定在 0xE0。音频稍微复杂一点如果走 G.711A有的设备把它放在 0xC0直接走音频流也有的设备把它塞进 0xBD 私有流里前面还加了几字节私有头标注编码格式。曾经为了这个差异排查了整整两天——平台侧配置的是 G.711A设备也确实在推但音频一直没声音最后发现 0xC0 里根本没有包数据全在 0xBD 里需要按私有流的方式解析。判断方法很简单解析完之后统计一下各个 stream_id 的包数量如果一个都没有 0xC0 但有大量 0xBD基本可以确定音频走的是私有流路径。4.2 长度字段为 0 时必须靠起始码兜底PES 包结构如下00 00 01 stream_id 2 字节 PES_packet_length 可选PES 扩展头 payload对于视频流扩展头PES header extension是必须存在的因为它携带 PTS/DTS。扩展头开头第一个字节是0x80 | 各种标志位其中高 2 位固定是10。接着第二字节是各种 flag再后面一字节是PES_header_data_length表示扩展头剩余部分的长度。所以视频 PES 的 payload 起点 起始码位置 6 3 PES_header_data_length。当PES_packet_length为 0 时真实长度需要这样算从 payload 起点开始一直往后找下一个00 00 01 xx且 xx 是合法的流 ID两者之间的距离就是 payload 长度。这个往后找的动作要小心因为 payload 里本身就可能包含00 00 01这样的字节序列——H.264 的 NAL 起始码就是这个。如果 payload 里出现了00 00 01后面跟着一个恰好落在 0xE0 到 0xEF 范围内的字节就会被误判成新的 PES 包。规避方法是加一层约束先检查00 00 01之后是不是已知的 stream_id同时对 H.264 数据做 NAL 长度合法性校验比如后续 4 字节长度不超过剩余缓冲区。实践中还有一种更粗暴但有效的办法既然 PES 包是以 PS pack header 为超级边界的那就先在 PS 层按00 00 01 BA切好块PES 的搜索范围就被限制在单个 PS 包内越过边界的误判直接排除。4.3 把 33 位的 PTS/DTS 从 5 个字节里抠出来PTS 和 DTS 是这套体系里最有技术含量的部分。它们各占 5 个字节但中间塞了 4 个 marker 位实际有效位只有 33 位。结构是这样字节 0: 0010 或 00114 位标识 PTS[32:30] marker(1) 字节 1: PTS[29:22] 字节 2: PTS[21:15] marker(1) 字节 3: PTS[14:7] 字节 4: PTS[6:0] marker(1)还原公式def read_pts(b): return (((b[0] 1) 0x07) 30) | \ (b[1] 22) | \ (((b[2] 1) 0x7F) 15) | \ (b[3] 7) | \ ((b[4] 1) 0x7F)几个细节必须注意。第一字节 0 的高 4 位是标识0010表示只有 PTS0011表示 PTS 和 DTS 都有实际取值时要把高 4 位屏蔽掉只取低 3 位参与运算所以是(b[0] 1) 0x07。第二marker 位一定要屏蔽取值是 1后再 0x7F不能直接把整个字节左移否则多出来的那一位会让时间戳凭空大出一大截。第三PTS 单位是 90kHz换算成秒要除以 90000且 33 位会在约 26.5 小时后回绕长时间运行的设备必须处理回绕。DTS 紧跟在 PTS 后面同样是 5 字节结构一致。当 PES 扩展头标志位是0b11时先读 5 字节 PTS再读 5 字节 DTS。这时候如果你把 DTS 当成 PTS 用画面会出现诡异的抖动或者音画不同步——我就见过一次同一个流在某个播放器上正常在自研服务上就音画差了两秒原因就是解析器漏读了 DTS导致后续所有包的时间戳全部偏了一个包的偏移量。5. RTP 封装PS 包被切开以后怎么拼回去到这一步你已经能在 RTP 载荷里找到 PS 包了。但真实网络里PS 包几乎不可能恰好装进一个 RTP 包。5.1 RTP 头的 12 个字节里哪几个真正有用标准 RTP 头固定 12 字节偏移长度字段关注点01版本/填充/扩展/CSRC 计数版本应为 211标记位 载荷类型国标视频常见 PT96音频 PCMA 常见 PT822序列号16 位用于排序和丢包判断44时间戳90kHz与 PTS 对应但不一定相等84SSRC多路分流的核心依据序列号是 16 位无符号数从 0 到 65535 循环。判断乱序不能直接比大小要算相对差值def seq_diff(a, b): # 返回 a 在 b 之后多少个包考虑 16 位回绕 return (a - b) 0xFFFF如果seq_diff的结果大于 32768说明 a 其实在 b 前面只是回绕了一圈。这个技巧在处理长时间流的时候是必备的否则回绕那一刻你的缓冲区里会出现几万个乱序包全被丢掉画面瞬间卡死。SSRC 是 32 位SDP 里的y行会给出这个值。用它分组比用端口分组更靠谱因为有的设备会把音频和视频推到同一个端口上只有 SSRC 不同。5.2 一个 PS 包跨多个 RTP 包时的重组策略国标里 RTP 载荷最大一般控制在 1400 字节左右为了避开 MTU 1500 的分片而一个 1080P 的 I 帧 PS 包能做到几十 KB跨几十个 RTP 包是常态。重组的基本流程按 SSRC 分桶每个桶维护一个按序列号排序的缓冲区收到包后计算相对序列号插入到正确位置检测连续性如果序号断层说明中间丢包了连续的一段就拼接成一个字节块交给 PS 扫描器丢包发生时把当前段直接截断丢弃到下一个00 00 01 BA处重新对齐。最后一步极其重要。丢包之后拼接出来的字节流是残缺的如果直接喂给解析器会读到一半的 PS 头后面全错。正确做法是抛弃当前不完整的 PS 包从下一个完整的 pack header 开始重新解析。这就是为什么很多解码器在丢包之后画面会先花一下再恢复——它正在等下一个完整帧。还有一个细节如果丢的是 I 帧的包光靠 P 帧是恢复不了的必须等下一个 I 帧。所以缓冲区里应该记录每个 GS 段的完整性对于不完整的 I 帧直接整帧丢弃别硬着头皮送给解码器。5.3 判定丢包、乱序和重复包实际网络里这三种情况都会出现处理策略不一样现象判断依据处理方式乱序相对序列号小于当前最大值但在窗口内插入缓冲区正确位置等待缺口补齐丢包相对序列号出现跳跃缺口超过一定时长截断当前段跳到下一个包起始码重新对齐重复序列号与缓冲区已有包相同直接丢弃不覆盖已有数据回绕序列号从 65535 跳到 0用相对差值算法视为正常连续窗口大小是个权衡。窗口太大等待时间变长延迟升高窗口太小稍微乱序就误判丢包。我一般设置成 300 毫秒的等窗超过就认定前面那些包不会来了直接放过。还有个大坑是接收缓冲区过大导致累积延迟。UDP 是流式的如果你的消费速度跟不上生产速度socket 缓冲会越积越多表现出来就是画面延迟越来越高。解决办法是限制单个 SSRC 的缓冲深度超过阈值就主动丢弃最老的完整帧。6. 用 Python 落一个能跑的拆包器理论讲完接下来是能直接抄去用的东西。整个拆包器分三块RTP 重组、PS 扫描、PES 提取。6.1 位读取器与基础工具PS 头里的位域太碎先写个简单的位读取器从高位往低位顺序取class BitReader: def __init__(self, data, bitpos0): self.data data self.pos bitpos def read(self, n): val 0 for _ in range(n): byte self.data[self.pos 3] bit (byte (7 - (self.pos 7))) 1 val (val 1) | bit self.pos 1 return val def skip(self, n): self.pos n这个实现不考虑性能但胜在绝对直观调试的时候对着规范一条一条比对就行。生产环境如果要跑高码率可以换成用查表或者整字节预读的方式优化。6.2 PS 头与 PES 头的解析函数def parse_pack_header(data): data 以 00 00 01 BA 开头返回 (scr, mux_rate, stuffing_len) assert data[:4] b\x00\x00\x01\xba r BitReader(data, 32) r.read(2) # 固定值 01 scr_hi r.read(3) r.read(1) # marker scr_mid r.read(15) r.read(1) scr_lo r.read(15) r.read(1) mux_rate r.read(22) r.read(1) r.read(1) r.read(5) stuffing_len r.read(3) scr (scr_hi 30) | (scr_mid 15) | scr_lo return scr, mux_rate, stuffing_lenPES 头的解析要分支处理因为 0xBE、0xBF、0xB9 这些包没有扩展头def read_pts(b): return (((b[0] 1) 0x07) 30) | (b[1] 22) | \ (((b[2] 1) 0x7F) 15) | (b[3] 7) | \ ((b[4] 1) 0x7F) NO_EXT_IDS {0xB9, 0xBE, 0xBF} def parse_pes(data, offset): 从 offset 处解析 PES返回 dict stream_id data[offset 3] pes_len (data[offset 4] 8) | data[offset 5] info { stream_id: stream_id, length: pes_len, pts: None, dts: None, payload_offset: offset 6, } if stream_id in NO_EXT_IDS: return info flag1 data[offset 6] flag2 data[offset 7] header_data_len data[offset 8] info[payload_offset] offset 9 header_data_len pts_dts_flag (flag2 6) 0x03 if pts_dts_flag 0b10: info[pts] read_pts(data[offset 9: offset 14]) elif pts_dts_flag 0b11: info[pts] read_pts(data[offset 9: offset 14]) info[dts] read_pts(data[offset 14: offset 19]) return info注意payload_offset在长度字段为 0 的时候只是起点终点还得靠扫描确定。6.3 主扫描循环与输出验证PS_PACK b\x00\x00\x01\xba def scan_ps_stream(buf): 传入拼接好的连续字节流产出 (stream_id, pts, payload) idx 0 while True: idx buf.find(b\x00\x00\x01, idx) if idx 0 or idx 4 len(buf): return code buf[idx 3] if code 0xBA: idx 14 continue if code in (0xB9, 0xBE, 0xBF): length (buf[idx 4] 8) | buf[idx 5] idx 6 length continue if 0xC0 code 0xEF: info parse_pes(buf, idx) start info[payload_offset] if info[length] 0: end idx 6 info[length] else: end buf.find(b\x00\x00\x01, start) if end 0: return if end len(buf): yield code, info[pts], buf[start:end] idx end continue idx 4拿到 payload 之后就可以按编码类型处理了。H.264 的话payload 里是一串 NAL 单元每个前面带00 00 00 01或00 00 01起始码取起始码后那一字节的低 5 位就是 NAL 类型5 是 IDR 帧1 是非 IDR 帧7 是 SPS8 是 PPS。第一件事建议先全部 dump 成一个.ps文件然后直接丢给播放器验证。这一步能跑通说明你的重组和切分逻辑是对的剩下的才轮到排查时间戳和音画同步。如果.ps文件拖进播放器卡死或者黑屏说明容器层还有问题别急着去调 NAL。7. 调试期最容易卡住的几个点以下这些坑都是真实踩过的按出现频率从高到低排现象可能原因验证方式有包无画面只解析了 RTP没剥 PS/PESdump 前 200 字节看是否有00 00 01 BA画面花屏且持续I 帧丢包后续全是残帧检查每个 IDR 帧的 PS 包是否完整音画不同步且逐渐扩大PTS 解析漏了 marker 位或误读 DTS打印相邻帧 PTS 差值应为固定值解析器在 I 帧卡住未处理PES_packet_length 0检查卡住位置的 2 字节长度是否为 0音频无声音频走的是 0xBD 私有流统计 stream_id 分布长时间运行后突然断流序列号回绕导致排序错乱打印序列号处理逻辑是否用相对差值延迟越来越高接收缓冲未做深度限制监控缓冲区堆积数量重点说说音画不同步逐渐扩大这个。有一回对接某款设备刚连上时提示音和画面基本对得上跑了半小时之后差了将近 3 秒。查了半天发现是音频 PTS 的解析函数把 marker 位算进去了导致时间戳整体偏大而视频侧没这个问题。因为音频和视频分别计算延迟误差被不断放大。修掉这个 1 0x7F的细节之后立刻恢复。还有一种更隐蔽的情况有的设备把 RTP 时间戳和 PTS 设成同一个值有的设备把它们设成不同基准只保证增量一致。所以做同步的时候不要跨路直接比较绝对值要各自维护自己的基准比较相对增量。8. 打通一次之后才会明白的几件事第一件事抓包一定要抓全。很多人用-c 100只抓 100 个包结果刚好跳过第一个 PS pack header后面解析一路报错还以为是代码写错了。媒体流的头部信息往往只出现一次没抓到就要靠动态发现兜底。我现在的习惯是点播建立之后先抓 5000 个包再看。第二件事别迷信工具。有些抓包工具能把 RTP 载荷自动解码成 PS但解码器对于国标里那些不太规范的私有流处理得并不好遇到 0xBD 里塞音频的情况直接显示成 raw data。这时候还是要靠自己写脚本来拆工具只是辅助。第三件事日志打印要打到点子上。与其打印解析失败这种没信息量的话不如直接打印00 00 01之后那个字节的十六进制值再打印前后各 16 个字节的 hex dump。看一眼就知道是漏了哪一层。第四件事做解析器的时候永远保留一个原始字节落盘的开关。线上出问题一线人员抓回来的包可能只有几十秒本地复现不了。有了落盘能力把原始流拉下来慢慢拆比远程反复沟通高效得多。最后分享一个定位小技巧如果怀疑是 PS 层的问题把连续的字节流存成.ps文件先用播放器试如果播放器能放而你的解析器不能说明是代码问题如果播放器也不能放说明是数据本身的问题。这一招能把到底该改代码还是该找设备这个问题在三分钟内定下来比读任何文档都管用。

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

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

免费获取报价 →
↑