资讯动态

海康、国标PS流解析:从裸码流到可播放视频的完整拆包指南

发布时间:2026/10/20 17:18:09 来源:尧图企业网站定制
简介这份资源面向音视频开发与流媒体方向的初中级工程师聚焦海康设备与国标场景下的PS流解析提供基于ffmpeg与直接解析两条实现路线的完整工程代码。包内共27个文件以cpp与h源码为核心辅以sln、vcxproj等Visual Studio工程配置以及tlog、obj、pdb等编译中间产物压缩包约2.24MB可直接在Windows下打开工程对照调试。内容覆盖PS流结构、PTS/DTS提取、音视频数据包分离等关键环节ffmpeg版本借助内置解码器快速解析直接解析版本则手写代码深入MPEG-2语法便于读者对比两种方案的性能与定制性差异。目前已有1096人学习下载适合需要理解PS流封装细节、搭建流媒体处理链路的开发者参考借鉴。1. 海康、国标PS流解析从裸码流到可播放视频中间到底卡在哪很多人第一次接触海康设备或国标级联平台导出的录像拿到的不是 MP4而是一坨.ps后缀或者干脆没后缀的裸流文件。用播放器打开要么黑屏要么只有声音要么直接报错。这时候搜索框里敲下「海康、国标ps流解析」其实想问的是三件事这堆字节到底是什么、怎么把它变成能播的视频、为什么我按网上的代码跑出来是花屏。PS 流Program Stream是 MPEG-2 系统层定义的一种复用格式国标 GB/T 28181 在媒体传输里大量使用 PS 封装来承载 H.264/H.265 视频和 G.711/AAC 音频。海康的设备在国标对接、录像备份、SDK 取流这几个场景下吐出来的原始数据基本都是 PS 包。它和 MP4 最大的区别是MP4 有全局索引moovPS 没有PS 是「边读边解」的流式结构每个包自带时间戳和长度信息。所以解析 PS 的核心不是「解码」而是「拆包」——把 PS 容器拆开取出里面的裸 H.264/H.265 和音频帧再交给解码器。这篇文章面向的是需要自己写代码处理这类流的后端或嵌入式工程师不是教你怎么装播放器。我会把 PS 的包结构、拆包代码、时间戳处理、花屏排查这几件事讲透代码可以直接抄。2. PS 流的包结构从 pack header 到 PES 包一层层剥开2.1 PS 的三层结构pack、system header、PESPS 流的最小单位是 pack每个 pack 以00 00 01 BA开头这是 pack start code。一个 pack 里包含 pack header、可选的 system header以及若干个 PES 包。PES 包以00 00 01加上 stream_id 开头stream_id 决定了这个包里装的是什么0xE0是视频0xC0是音频0xBD是私有流国标里常用来传 PS 封装的扩展信息。理解这个层级关系很重要因为很多人拆包时只找00 00 01 E0忽略了 pack header 里的 SCRSystem Clock Reference和时间戳结果拼出来的 H.264 没有正确的时间基准播放器要么快放要么慢放。pack header 的结构是这样的start code 4 字节然后是 6 字节的 SCR 和时间信息再往后是 program_mux_rate 等字段。SCR 是 33 位精度 90kHz它决定了整个流的系统时钟。PES 包里的 PTS/DTS 也是 33 位、90kHz解码器靠这两个时间戳做音视频同步。2.2 用 Python 拆出第一个 PES 包先写一个最小可用的拆包脚本把 PS 流里的 PES 包逐个提取出来打印 stream_id 和包长度。这一步不涉及解码纯粹是验证你对包结构的理解。import struct def parse_ps_stream(file_path): with open(file_path, rb) as f: data f.read() pos 0 pes_count 0 while pos len(data) - 4: # 查找 pack start code: 00 00 01 BA if data[pos:pos4] b\x00\x00\x01\xba: # pack header 固定 14 字节含 start code pack_len 14 # 检查是否有 system header: 00 00 01 BB if data[pos14:pos18] b\x00\x00\x01\xbb: sys_hdr_len struct.unpack(H, data[pos18:pos20])[0] pack_len 3 sys_hdr_len # 3 是 start code length 字段 pos pack_len continue # 查找 PES start code: 00 00 01 xx if data[pos:pos3] b\x00\x00\x01: stream_id data[pos3] if stream_id in (0xE0, 0xC0, 0xBD): # 视频、音频、私有流 pes_pkt_len struct.unpack(H, data[pos4:pos6])[0] print(fPES: stream_id0x{stream_id:02X}, len{pes_pkt_len}, offset{pos}) pes_count 1 pos 6 pes_pkt_len continue pos 1 print(ftotal PES packets: {pes_count}) parse_ps_stream(test.ps)这段代码的逻辑是先找 pack start code跳过 pack header 和 system header然后找 PES start code根据 stream_id 判断类型读取 PES 包长度后跳过整个包。参数说明0xE0是视频流0xC0是音频流0xBD是私有流。PES 包长度字段是 2 字节大端值为 0 时表示长度不固定常见于视频流这时候需要靠下一个 start code 来定位边界。实际跑的时候你会发现视频 PES 包的长度字段经常是 0因为 H.264 帧大小不固定编码器不好预填。这时候就不能靠长度跳转得改成「搜索下一个00 00 01前缀」的方式。这是第一个坑后面避坑章节会细说。2.3 PES 包头的解析PTS/DTS 怎么取PES 包在 start code 和长度字段之后是 PES header。PES header 的前几个字节包含标志位决定了是否有 PTS、DTS。结构如下10开头的是 PES 扩展标志后面跟 PTS_DTS_flags2 位10表示有 PTS11表示 PTS 和 DTS 都有。def parse_pes_header(data, offset): # offset 指向 PES start code 后的长度字段之后 # PES header 起始标志字节 flags1 data[offset] flags2 data[offset1] header_len data[offset2] pts_dts_flags (flags2 6) 0x03 pts None dts None if pts_dts_flags 0x02: # 只有 PTS pts ((data[offset3] 1) 0x07) 30 | \ (data[offset4] 22) | \ ((data[offset5] 1) 0x7F) 15 | \ (data[offset6] 7) | \ ((data[offset7] 1) 0x7F) elif pts_dts_flags 0x03: # PTS 和 DTS pts ((data[offset3] 1) 0x07) 30 | \ (data[offset4] 22) | \ ((data[offset5] 1) 0x7F) 15 | \ (data[offset6] 7) | \ ((data[offset7] 1) 0x7F) dts ((data[offset8] 1) 0x07) 30 | \ (data[offset9] 22) | \ ((data[offset10] 1) 0x7F) 15 | \ (data[offset11] 7) | \ ((data[offset12] 1) 0x7F) return pts, dts, header_lenPTS 的拼接方式是 MPEG 标准的「分散字节」编码33 位被拆成 3 段每段前面有填充位。这段代码里 1和 0x07、 0x7F就是在剥离填充位。PTS 单位是 90kHz要转成秒就是pts / 90000.0。DTS 是解码时间戳当 B 帧存在时 DTS 和 PTS 不同H.264 里 B 帧常见所以不能忽略 DTS。3. 从 PS 到 H.264拆出裸流并处理时间戳3.1 提取 H.264 NAL 单元去掉 PES 头保留裸数据PES 包的 payload 部分就是 H.264 数据但要注意一个 PES 包可能包含多个 NAL 单元也可能一个 NAL 单元跨多个 PES 包。H.264 的 NAL 单元以00 00 01或00 00 00 01开头这是 Annex B 格式的起始码。提取逻辑是把 PES payload 拼起来然后按起始码切分 NAL 单元。但这里有个细节PES payload 里可能包含 emulation prevention bytes00 00 03这是为了防止数据里出现和起始码相同的字节序列。解码前需要把00 00 03还原成00 00。def extract_h264_from_pes(pes_payload): # 去掉 emulation prevention bytes cleaned bytearray() i 0 while i len(pes_payload): if i 2 len(pes_payload) and \ pes_payload[i] 0 and pes_payload[i1] 0 and \ pes_payload[i2] 3: cleaned.append(0) cleaned.append(0) i 3 else: cleaned.append(pes_payload[i]) i 1 # 按起始码切分 NAL 单元 nals [] start 0 i 0 while i len(cleaned) - 3: if cleaned[i] 0 and cleaned[i1] 0 and \ (cleaned[i2] 1 or (cleaned[i2] 0 and i3 len(cleaned) and cleaned[i3] 1)): if i start: nals.append(bytes(cleaned[start:i])) start i i 4 if cleaned[i2] 0 else 3 else: i 1 if start len(cleaned): nals.append(bytes(cleaned[start:])) return nals这段代码先做 emulation prevention 还原再按起始码切分。注意起始码有两种3 字节的00 00 01和 4 字节的00 00 00 01切分时要都处理。切出来的 NAL 单元第一个字节是 NAL header低 5 位是 NAL type0x67是 SPS0x68是 PPS0x65是 IDR 帧0x61是非 IDR 帧。3.2 时间戳对齐为什么你的视频播放速度不对PS 流里的 PTS 是 90kHz 的 33 位值但实际传输中它会回绕wraparound。33 位最大值是 2^33 - 1约 95443 秒也就是 26.5 小时回绕一次。如果你直接拿 PTS 做差值遇到回绕就会算出负数播放器直接卡死。处理方式是维护一个基准 PTS每次拿到新 PTS 时判断是否回绕如果当前 PTS 比上一个 PTS 小很多比如超过 2^32就认为回绕了加上 2^33 再算差值。class PTSAligner: def __init__(self): self.last_pts None self.wrap_offset 0 self.base_pts None def align(self, pts): if self.last_pts is not None: if pts self.last_pts and (self.last_pts - pts) (1 32): self.wrap_offset (1 33) self.last_pts pts adjusted pts self.wrap_offset if self.base_pts is None: self.base_pts adjusted return adjusted - self.base_pts这个类的逻辑是检测到 PTS 突然变小且差值超过 2^32 时认为发生了回绕累加 2^33 的偏移。返回的是相对于第一个 PTS 的毫秒数需要再除以 90。实际用的时候音频和视频各维护一个 aligner然后用对齐后的时间戳做同步。3.3 封装成 MP4用 FFmpeg 做最后一步拆出 H.264 裸流和音频裸流之后最省事的封装方式是交给 FFmpeg。但直接喂裸流给 FFmpeg 有个问题没有时间戳FFmpeg 会按帧率猜猜错了就音视频不同步。正确做法是生成一个带时间戳的中间格式或者用 FFmpeg 的-fflags genpts让它自己生成。更可控的方式是先把 H.264 存成.h264文件音频存成.pcm或.aac然后用 FFmpeg 合并ffmpeg -fflags genpts -r 25 -i video.h264 -ar 8000 -ac 1 -f s16le -i audio.pcm \ -c:v copy -c:a aac -strict experimental output.mp4参数说明-fflags genpts让 FFmpeg 自动生成 PTS-r 25指定视频帧率这个值要和实际编码帧率一致否则播放速度不对-ar 8000 -ac 1 -f s16le是 G.711 音频的常见参数国标里音频多为 8kHz 单声道。如果音频是 AAC直接-c:a copy就行。4. 国标 GB/T 28181 场景下的 PS 流特殊处理4.1 国标 PS 封装的私有信息RTP 扩展头国标场景下PS 流通常不是直接存文件而是通过 RTP 传输。RTP 包头上有一个扩展字段里面包含了 PS 流的长度信息。很多人在国标对接时发现「收到的 RTP 包拼不成完整的 PS 包」就是因为忽略了 RTP 扩展头里的长度字段。RTP 扩展头的结构是4 字节的扩展头标识然后是扩展数据。国标里扩展数据的前 4 字节是 PS 流的长度大端后面才是 PS 数据。解析时要把这部分剥掉否则 PS 解析器会在扩展数据里找 start code直接跑飞。def parse_rtp_payload(rtp_data): # RTP 头固定 12 字节 if len(rtp_data) 12: return None version (rtp_data[0] 6) 0x03 if version ! 2: return None has_extension (rtp_data[0] 4) 0x01 csrc_count rtp_data[0] 0x0F header_len 12 csrc_count * 4 if has_extension: ext_len struct.unpack(H, rtp_data[header_len2:header_len4])[0] header_len 4 ext_len * 4 return rtp_data[header_len:]这段代码先解析 RTP 头跳过 CSRC 列表和扩展头返回纯 PS payload。注意扩展长度字段的单位是 4 字节所以要乘 4。国标里扩展头通常只有 4 字节数据PS 长度所以ext_len一般是 1。4.2 海康设备的 PS 流特点I 帧间隔和 SPS/PPS 位置海康设备输出的 PS 流有一个特点SPS/PPS 不一定在每个 I 帧前都出现。有些型号只在第一个 I 帧前发一次 SPS/PPS后面的 I 帧直接是 IDR 帧。如果你用「每个 I 帧前找 SPS/PPS」的逻辑去解析后面的 I 帧会因为缺少参数集而解码失败。处理方式有两种一是缓存第一个 SPS/PPS后续所有 I 帧都用这个缓存二是检测到 IDR 帧但没有 SPS/PPS 时主动插入缓存的参数集。第二种更稳妥因为有些解码器要求每个 IDR 前必须有参数集。class H264ParamCache: def __init__(self): self.sps None self.pps None def feed_nal(self, nal): nal_type nal[0] 0x1F if nal_type 7: # SPS self.sps nal elif nal_type 8: # PPS self.pps nal elif nal_type 5: # IDR # 如果缓存里有参数集先输出参数集再输出 IDR result [] if self.sps: result.append(self.sps) if self.pps: result.append(self.pps) result.append(nal) return result return [nal]这个缓存类的逻辑是遇到 SPS/PPS 就存下来遇到 IDR 帧就把缓存的参数集和 IDR 一起输出。这样即使原始流里 IDR 前没有参数集输出流里也会补上。4.3 音频处理G.711 到 AAC 的转码参数国标里的音频大多是 G.711A 或 G.711U8kHz 采样、单声道、16 位。FFmpeg 可以直接解码 G.711但封装成 MP4 时通常要转成 AAC因为 MP4 对 G.711 的支持不好。转码命令ffmpeg -f s16le -ar 8000 -ac 1 -i audio.pcm -c:a aac -b:a 64k audio.aac参数说明-f s16le指定输入是 16 位小端 PCM-ar 8000是采样率-ac 1是单声道-b:a 64k是 AAC 码率8kHz 音频用 64k 足够。如果原始音频是 G.711A需要先用ffmpeg -f alaw -ar 8000 -ac 1 -i audio.g711a audio.pcm转成 PCM。5. 避坑与排查花屏、音画不同步、解析卡死的 5 个真实原因5.1 花屏PES 包长度字段为 0 导致的边界错误现象拆出来的 H.264 用播放器打开前几秒正常后面开始花屏越往后越严重。原因视频 PES 包的长度字段经常是 0表示长度不固定。如果代码里直接pos 6 pes_pkt_len当pes_pkt_len为 0 时pos 只前进了 6 字节然后继续在 PES payload 里找 start code把 payload 里的00 00 01误判为新的 PES 包导致数据错位。解决当pes_pkt_len 0时不要用长度跳转改成搜索下一个00 00 01前缀并且要判断这个前缀后面跟的 stream_id 是否是合法的 PES stream_id0xE0、0xC0、0xBD等。如果不是说明这是 payload 里的数据继续往后找。5.2 音画不同步PTS 回绕没处理现象播放到 26 小时左右或者某些设备上更早音视频突然不同步视频跳到开头或者卡住。原因PTS 是 33 位26.5 小时回绕一次。如果代码里直接用pts - last_pts算差值回绕时会得到一个大负数播放器按这个负数做同步就乱了。解决用前面说的PTSAligner类检测回绕并累加偏移。另外音频和视频要用同一个基准时间不能各自算各自的。5.3 解析卡死在错误的位置搜索 start code现象解析脚本跑着跑着 CPU 占用 100%不输出任何结果也不退出。原因在 PES payload 里搜索00 00 01时如果 payload 里恰好有00 00 01但不是 PES 起始码代码可能会陷入死循环反复在同一个位置搜索。解决搜索时要加步进每次至少前进 1 字节并且要判断找到的 start code 后面跟的 stream_id 是否合法。另外给循环加一个最大迭代次数保护超过就报错退出。5.4 SPS/PPS 丢失海康设备只在第一个 I 帧前发参数集现象用 VLC 播放拆出来的 H.264只有第一帧能显示后面全是绿屏或灰屏。原因海康某些型号的 PS 流只在第一个 I 帧前发 SPS/PPS后面的 I 帧没有参数集。解码器在解码第二个 I 帧时找不到参数集直接报错。解决用H264ParamCache缓存第一个 SPS/PPS遇到 IDR 帧时自动插入。如果解码器支持也可以在 SDP 里带sprop-parameter-sets但本地文件解析用缓存更直接。5.5 时间戳跳变设备重启或网络抖动导致的 PTS 突变现象播放到中间某段画面突然快进或者倒退几秒。原因设备重启或网络抖动时PTS 可能突然跳变比如从 100000 跳到 5000000。如果代码里直接用 PTS 做同步播放器会认为这段时间没有数据然后突然收到大量数据导致快进。解决检测 PTS 跳变如果相邻两个 PTS 差值超过阈值比如 10 秒就认为发生了跳变重置基准时间。阈值要根据实际场景调网络抖动频繁的场景可以设小一点。6. 进阶用 FFmpeg 的 libavformat 直接解析 PS 流如果你不想自己维护拆包代码FFmpeg 的 libavformat 已经内置了 PS 解析器mpegpsdemuxer。用 C 或 Python 的av库可以直接打开 PS 流拿到 AVPacket 后自己处理。import av container av.open(test.ps, formatmpegps) for packet in container.demux(): if packet.stream.type video: # packet.data 是 H.264 裸数据 # packet.pts 是 PTS单位是 stream.time_base pts_sec float(packet.pts * packet.stream.time_base) print(fvideo packet: pts{pts_sec:.3f}s, size{len(packet.data)}) elif packet.stream.type audio: pts_sec float(packet.pts * packet.stream.time_base) print(faudio packet: pts{pts_sec:.3f}s, size{len(packet.data)})这段代码用 PyAVFFmpeg 的 Python 绑定打开 PS 流demux()返回的是已经拆好的 AVPacket。packet.pts是时间戳乘以stream.time_base得到秒数。这种方式省去了自己处理 PES 头和 PTS 回绕的麻烦FFmpeg 内部都处理好了。但用 libavformat 也有代价它对某些国标私有扩展的兼容性不如自己写的解析器。比如 RTP 扩展头里的 PS 长度字段FFmpeg 的 mpegps demuxer 不认需要先用 RTP 解析器剥掉扩展头再喂给 demuxer。另外FFmpeg 的 PS 解析器对损坏流的容错性一般遇到花屏流可能会直接报错退出而自己写的解析器可以跳过坏包继续。我一般会这样选如果是做国标平台对接RTP 拆包和 PS 解析都自己写因为要处理私有扩展和容错如果是做录像文件转码直接用 FFmpeg 命令行或 libavformat省事且稳定。两条路都走过自己写解析器最大的收获是遇到花屏时知道该看哪个字段而不是对着 FFmpeg 的报错干瞪眼。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑