简介:这份资源提供了一套面向28181地面数字电视标准的PS流解包实现,核心为programstreamparser源码模块,适合需要处理DVB/MPEG-2系统层数据的开发者。压缩包共2个文件,包含1个cpp与1个h文件,cpp负责PS流包解析、PID过滤、PES重组等核心逻辑,h文件声明解析器接口与常量,结构简洁,便于直接集成或二次修改。通过该代码可掌握从TS/PS流中分离视频、音频及字幕数据的关键流程,理解PTS/DTS时间同步与MPEG-2视频、MPEG-1 Audio Layer II等编码格式的对接方式。资源包仅3KB,代码量精炼,适合有一定多媒体基础、希望快速搭建PS流解析模块的工程师参考。已有853人学习,实用性得到一定验证。
1. 国标对接里绕不开的 PS 流:HK 28181 PS流解包代码到底在拆什么
对接过 GB/T 28181 的人都有这个体会:摄像头注册成功了,RTP 包也抓到了,但播放器就是黑屏。原因多半不是网络,而是 RTP 载荷里装的是 MPEG PS 流,不是播放器认识的裸 H.264。这份 HK 28181 PS流解包代码,就是专门把 PS 流从 RTP 载荷里剥出来、拆成 H.264/H.265 裸流的工具。它解决的是国标对接里最“夹在中间”的那一层:上游是设备的 SIP/PS 封装,下游是播放器、GB 平台和录像服务。适合正在做下级平台接入、国标网关、流媒体服务的人,也适合刚接触 28181 想搞懂码流结构的开发者。
2. PS 流结构拆解:从 RTP 载荷到 H.264 裸流的完整链路
2.1 为什么国标视频走 PS 而不走 TS
TS 流的包长固定 188 字节,适合广播和丢包率高的链路,代价是每个包都要带上 PES 头信息,冗余大。PS 流相反,包长可变,头部信息集中在 PS 头里,后续数据紧跟 PES 净荷,整体开销小得多。国标 28181 的视频推流是设备直连平台,链路通常是专网或公网 UDP,丢包相对可控,所以选 PS 更合理。
PS 流的定位方式也有意思:整段流里散布着00 00 01起始码前缀,后面跟一个字节的流 ID。常见流 ID 包括:
| 起始码 | 流 ID 含义 |
|---|---|
| 0xBA | Program Stream Header,PS 包头 |
| 0xBB | System Header,系统头 |
| 0xBC | Program Stream Map,PSM 节目映射 |
| 0xE0 | 视频流(H.264/H.265/MPEG) |
| 0xC0 | 音频流(G711/AAC 等) |
音频流 ID 是0xC0开头,视频是0xE0开头,后面跟的0xE1通常表示额外的一路视频流,有些老设备会拿它做多路复用。PSM 的作用是把流 ID 映射到具体的编码格式和流类型,后面讲解包时你会看到它有多重要。
2.2 抓一个 RTP 载荷,用起始码定位 PS 包边界
假设你已经从 RTP 载荷里拿到一段 PS 数据,第一步是找 PS 包头。PS 包头格式固定:00 00 01 BA之后是 6 字节头部字段,再往后是可变长的系统头。我一般把查找起始码写成独立函数,因为设备在 PS 包之间会插一些填充字节,不能用固定偏移硬读。
def find_start_code(data: bytes, offset: int = 0) -> int: """在字节流中搜索 00 00 01 起始码 data: 待搜索的缓冲区 offset: 从哪个位置开始搜 返回: 起始码所在下标, 搜不到返回 -1 """ while offset < len(data) - 3: if data[offset] == 0x00 and data[offset + 1] == 0x00 and data[offset + 2] == 0x01: return offset offset += 1 return -1逻辑说明:这个函数从指定偏移开始,逐个字节检查00 00 01三连字节,一旦命中就返回位置。没搜到就返回 -1,调用方把它当作“当前缓冲还不够一个完整起始码”,继续等下一轮 RTP 数据。
参数说明:offset通常不是 0,因为一次拿到的 RTP 载荷可能横跨两个 PS 包,上一次解析完剩下的残包要从尾部继续搜。这是个很容易踩的细节,后面避坑章节会再讲。
找到起始码之后,再读起始码加 3 之后的流 ID:
STREAM_PS_HEADER = 0xBA STREAM_SYS_HEADER = 0xBB STREAM_PSM = 0xBC STREAM_VIDEO_E0 = 0xE0 STREAM_AUDIO_C0 = 0xC0 def detect_stream_type(data: bytes, pos: int) -> str: """根据起始码后面的流 ID 判断这一段是什么类型""" stream_id = data[pos + 3] if stream_id == STREAM_PS_HEADER: return 'PS_HEADER' if stream_id == STREAM_PSM: return 'PSM' if stream_id >= 0xE0 and stream_id < 0xF0: return 'VIDEO' if stream_id >= 0xC0 and stream_id < 0xE0: return 'AUDIO' return 'UNKNOWN'逻辑说明:起始码之后紧跟的字节就是流 ID,0xE0~0xEF是视频,0xC0~0xDF是音频。为什么视频识别用范围判断而不是只判0xE0?因为某些厂商会启用第二路视频流,ID 是0xE1,按范围判断不会丢。
参数说明:data[pos+3]中的pos必须是find_start_code返回的位置。如果 PS 数据被中途截断,pos+3可能越界,调用前要先判断len(data) - pos >= 4。
2.3 RTP 时间戳到 PTS:九万分之一的坑
国标视频流的时间基准是 90kHz,RTP 头里的 timestamp 字段单位也是 90kHz,所以 RTP 时间戳可以直接作为 PS 包里的 PTS 参考。但 PS 包内的 PTS 是 33 位,而 RTP 时间戳是 32 位,两者对比时要做回绕处理:
def wrap90k(ts: int, bits: int = 33) -> int: """将时间戳按 90kHz 基准做 33 位回绕处理 ts: RTP 头或 PS 包里的时间戳 bits: PS PTS 是 33 位, RTP 是 32 位 """ return ts & ((1 << bits) - 1) def calc_pts(base_pts: int, delta90k: int) -> int: """基座 PTS 加上增量, 做 33 位回绕, 得到当前帧 PTS""" return wrap90k(base_pts + delta90k)逻辑说明:设备侧每隔一段会重置时间戳基准,播放侧如果直接用原始差值算延迟,遇到回绕点会突然出现一个巨大的负数,画面表现就是卡一下再跳。wrap90k把差值限制在 33 位以内,保证时间戳单调递增。
参数说明:bits=33是 PS 标准里 PTS 的位宽,RTP 头的时间戳是 32 位。在实现代码里这两个位宽容易混,建议统一先转成 64 位再算差值。
3. 解包代码的核心实现:状态机与三个可复用模块
有了起始码定位和时间戳链路,就可以看代码怎么把 PS 流变成裸流。这套代码最值得参考的是它的状态机设计,比网上很多一梭子读到底的脚本稳。
3.1 状态机总览:IDLE、HEADER、PES 三态循环
PS 解包本质上是个“找头、读头、抠数据”的循环。状态机设计成三态:IDLE等待起始码,HEADER解析0xBA或0xBB,PES解析0xE0/0xC0。每次 feed 进来一段新数据,状态机推进一次,直到当前包处理完或缓冲区不够再停下。
class PsDemuxer: def __init__(self): self.buf = bytearray() self.state = 'IDLE' self.video_start = -1 self.audio_start = -1 self.sps = b'' self.pps = b'' self.pts = 0 def feed(self, chunk: bytes): """feed 一段 RTP 载荷, 内部维护跨包缓冲区""" self.buf.extend(chunk) while True: if self.state == 'IDLE': pos = find_start_code(self.buf, 0) if pos < 0: break self.state = 'HEADER' elif self.state == 'HEADER': self._parse_next_header() elif self.state == 'PES': self._parse_pes_packet() if len(self.buf) < 4: break逻辑说明:feed是外部唯一入口,每个 RTP 包载荷进来都会先延长内部缓冲区,然后在 while 循环里反复推进状态机。设计的关键是break条件:要么缓冲不足以解析完整头,要么当前 PS 包已经处理完、等待下一个起始码。
参数说明:buf建议设置上限,比如 10MB。如果一段时间内缓冲一直不满,直接清零防内存涨。设备在码率突变时会连发大包,不加限制很容易爆内存。
3.2 PS 包头解析:读 system_header 和 stuffing 字节
0xBA开头那段是整个 PS 流最核心的定位信息。PS 包头长度可变,前 6 字节里包含 MPEG 版本、program_mux_rate、pack_stuffing_length。这些字段在对接不同厂商设备时差异很大,所以代码里解析头的函数一定要带容错。
def parse_ps_header(self): if len(self.buf) < 14: return False marker = (self.buf[4] >> 4) & 0x07 stuffing_len = self.buf[13] & 0x07 # 跳过头本身 + stuffing 填充 skip = 14 + stuffing_len if len(self.buf) < skip + 4: return False del self.buf[:skip] self.state = 'IDLE' return True逻辑说明:marker是 MPEG 版本标识,stuffing_len表示 PS 头后面额外填充了几个字节。如果直接用固定 14 字节跳,到了填充多的设备上会把填充字节当成起始码,解析直接错位。
参数说明:这里skip = 14 + stuffing_len假设的是去掉00 00 01 BA后 10 字节的基础头,加上 4 字节起始码和 6 字节头部后的stuffing_length字段在第 14 字节。不同国标实现可能在这个字段上有偏差,建议先抓包确认。
3.3 PSM 解析:拿到音视频编码类型
PSM(0xBC)里写明了当前流有哪些音视频轨道、编码类型是什么。很多解包代码不解析 PSM,直接用默认 H.264 处理,遇到 H.265 就翻车。正确流程是先读 PSM,拿到stream_type,再决定按 H.264 还是 H.265 处理。
def parse_psm(self): psm_start = find_start_code(self.buf, 0) stream_id = self.buf[psm_start + 3] if stream_id != 0xBC: return False psm_len = (self.buf[psm_start + 4] << 8) | self.buf[psm_start + 5] es_info_len = (self.buf[psm_start + 6 + 22] << 8) | self.buf[psm_start + 7 + 22] # ES 基本流描述从 index 8 + 22 开始 idx = psm_start + 8 + 22 end = psm_start + 4 + psm_len while idx + 2 < end: es_type = self.buf[idx] es_stream_id = self.buf[idx + 1] if es_stream_id == 0xE0: self.video_codec = es_type idx += 2 del self.buf[:end - psm_start] return True逻辑说明:PSM 里有 ES 描述列表,每条是两个字节:stream_type和elementary_stream_id。代码里把stream_type暂存,后续 PES 解析时用它判断是 H.265 还是 H.264。PSM 还有一个es_info_length字段,不同设备位宽不一致,当前索引用的是固定偏移,所以先写死通配,再针对手头设备调整。
参数说明:psm_len是 PSM 包除起始码和长度字段之外的总长度,包含 ES 列表本身。es_info_len是附加的描述信息长度,这个字段在有些国标设备上是 0,后续的 ES 列表起点就跟着变。建议先用手上的抓包样本跑一遍确定偏移。
3.4 PES 净荷提取与视频参数集收集
PES 包是真正的音视频数据载体。PES 头后的前 6 字节包含 PTS/DTS 标志和头长度,拿到头长度之后整个净荷就是纯 H.264/H.265 数据。
def parse_pes_packet(self): pos = find_start_code(self.buf, 0) if pos < 0: return stream_id = self.buf[pos + 3] if stream_id not in (0xE0, 0xE1): self.buf.clear() self.state = 'IDLE' return pes_len = (self.buf[pos + 4] << 8) | self.buf[pos + 5] flags = self.buf[pos + 7] header_data_len = self.buf[pos + 8] payload_start = pos + 9 + header_data_len if len(self.buf) < payload_start + pes_len: return payload = bytes(self.buf[payload_start:payload_start + pes_len]) if stream_id == 0xE0: collect_video_nal(payload) del self.buf[:payload_start + pes_len] self.state = 'IDLE'逻辑说明:先读 PES 长度字段,再通过 flags 判断有没有 PTS/DTS 附加字段,header_data_len表示 PTS 标志区域的实际长度。拿到 payload 之后直接按 NAL 处理。这里可以看到,我没有把整包一直留在缓冲里,处理完就删掉,避免缓冲无限膨胀。
参数说明:pes_len对 PS 标准来说是 PES 包长度,某些非标实现会填 0,表示一直读到下一个起始码。如果你手头有这种设备,解析时就要以find_start_code的下一个00 00 01为边界。
4. 参数调试与输出对接:把解出来的裸流喂给播放器
解包只是第一步,输出和对接才是真正花时间的部分。
4.1 输出为 Annex-B 裸流:SPS/PPS 前置注入
播放器要能立即出画,关键帧前的 SPS/PPS 必须完整送到解码器。PS 流里 SPS/PPS 通常在 IDR 帧之前,但如果播放器从 IDR 帧中段才接入,没有参数集就只能花屏。所以解包代码里我会额外维护一个sps/pps缓存,每当收到 IDR 帧时把参数集拼在帧前面。
def build_annexb_frame(nal_units: bytes, sps_pps: bytes) -> bytes: """把参数集和关键帧拼成标准 Annex-B 帧 sps_pps: 之前缓存好的 SPS/PPS 字节序列 nal_units: 当前帧所有 NAL 的字节序列(不含长度) """ start_code = b'\x00\x00\x00\x01' return start_code + sps_pps + start_code + nal_units逻辑说明:Annex-B 格式就是每个 NAL 之间用00 00 00 01或00 00 01分隔。把缓存好的 SPS/PPS 拼在 IDR 前,播放器一进流就能拿到参数,出画速度可以从几十秒缩到一两秒。
参数说明:sps_pps可以是 SPS + PPS 连在一起的 NAL 流,但必须保证顺序:SPS 在前,PPS 在后。H.265 还要在前面插 VPS,顺序是 VPS -> SPS -> PPS,顺序错了解码器直接报致命错误。
4.2 缓冲阈值与超时参数推荐
解包代码里最影响稳定性的参数是“缓冲区上限”和“等待 RTP 重组的最长时间”。给份参考参数表:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 缓冲上限 | 4~8MB | 超过直接清空,防设备码率突变 |
| 单包重组超时 | 200ms | RTP 乱序超过 200ms 就丢弃 |
| PTS 回绕阈值 | 2^31 | 超过说明时间戳回绕,做跳变修正 |
| 关键帧检测间隔 | 每 1s 检查一次 | 长时间无 IDR 要补发参数集 |
逻辑说明:码率波动时缓冲爆掉不如丢包,丢包最多花屏一帧,缓冲爆了整条流直接断。这个思想贯穿整个解码模块设计。
参数说明:以上都是我在多个 28181 网关项目上磨合出来的值,设备型号不同会有偏差,但大致在这个量级。调试时先用默认值跑通,再慢慢收紧超时。
4.3 对接 ffmpeg 和流媒体服务的常见接法
解出来的裸流和 ffmpeg 对接有两种方式:直接写文件,或者通过管道喂给 ffmpeg。写文件简单,管道方式适合推流。
ffmpeg -f h264 -i - -c copy -f flv rtmp://127.0.0.1/live/test逻辑说明:左边-f h264告诉 ffmpeg 输入是裸 H.264 Annex-B 流,中间-c copy跳过转码直接封装,输出到任意流媒体服务。如果你解的是 H.265,把-f h264换成-f hevc即可。
参数说明:管道里传的是纯二进制流,不能用类似tee的命令行处理,建议在代码里对每个 IDR 帧结束后 flush,否则 ffmpeg 缓冲可能一直等不到关键帧。
5. 避坑:28181 PS 流解包最常见的五个翻车现场
以下五条都是我在实际对接中踩过的坑,按出现频率排序。
5.1 首帧黑屏:关键帧丢失在重组缓冲区外
现象:摄像头推流正常,平台播放器一直不出画面,抓包看 RTP 有持续发送,但解出来的首帧 H.264 数据就是打不开。
原因:RTP 包乱序导致 IDR 帧被重组缓冲区丢弃,或者 PS 包太大被拆到多个 RTP 包,第一个 RTP 包还没来得及到达就超时了。
解决:把重组超时从 50ms 放宽到 200ms;同时对 IDR 帧做单独标记,收到 IDR 后先把已缓存的 SPS/PPS 拼上去再交给解码器。
5.2 花屏:把 RTP 分片直接当成完整 PS 包解析
现象:画面偶发花屏,马赛克在一帧里固定出现,不影响整体播放,但录像回看时非常明显。
原因:设备把一个 PS 包拆到 3 到 5 个 RTP 包里,每个 RTP 载荷只有部分 PS 字节,直接按包解析会碰到半截起始码。
解决:解包代码里所有解析都先放临时缓冲,必须凑齐整包起始码和长度字段再进状态机。你可以用find_start_code找到前一个 PS 包起始码,再把整个区间拼起来。
5.3 解析阻塞:00 00 01 00 被当成起始码
现象:代码跑起来后卡在某个循环不往前走,控制台看起来像是死循环。
原因:PS 流的 VCL 层经常会出现00 00 01开头的填充字节,尤其是老固件。解包逻辑遇00 00 01就当作新包,结果误判导致状态机错乱。
解决:在 PS 头解析层加一个校验,起始码后如果跟的是0x00或0xFF这类非法流 ID,直接跳过当前三个字节继续扫。这类填充在标准文档里叫padding_stream。
5.4 音频无声或噪音:G711 被识别成 AAC
现象:视频正常,但音频全是沙沙声或完全没有声音。
原因:有些国标设备把音频流 ID 发成0xC0,但 PSM 里的 stream_type 标成 G711;要么相反。解包时如果没解析 PSM,默认当 AAC 处理,G711 的原始 PCM 数据被误解码。
解决:强制在 PSM 里读 stream_type,G711 分 U-law(0x90)和 A-law(0x91),采样率字段在 PES 头里再确认一遍。音频帧不带 ADTS 头,不要套 AAC 解析。
5.5 H.265 解出但播放器花屏:VPS 被丢弃
现象:H.264 正常,H.265 花屏,有时连花屏都没有,直接绿屏。
原因:H.265 的参数集不止 SPS/PPS,还有 VPS。初期代码只缓存 SPS/PPS,播放器拿到 H.265 流根本没有 VPS,解码器无法初始化。
解决:解析时把 NAL type 32、33、34 分别归类为 VPS/SPS/PPS,三个都要缓存,且在 IDR 前按 VPS->SPS->PPS 顺序输出。这一条在第六章的工具里加一行打印就能查到。
6. 进阶:把解包代码压成抓包调试工具,一台设备三分钟定位问题
每次对接新摄像头都重新写一遍抓包解析脚本,太折腾。我习惯把解包代码的输入抽象成字节流,这样既能吃 RTP 实时流,也能吃 pcap 文件。做一个小工具,把网卡抓到的 pcap 直接重组成 PS 流,然后逐包打印关键诊断信息,设备侧问题一眼就能定位。
import dpkt def pcap_to_ps_stream(pcap_path: str, pt: int = 96): """读取 pcap 抓包文件, 把 RTP payload 按 SSRC 拼接成 PS 流 pcap_path: 抓包文件路径 pt: RTP payload type, 需要和 SDP 里协商的一致 """ with open(pcap_path, 'rb') as f: pcap = dpkt.pcap.Reader(f) streams = {} for _, buf in pcap: eth = dpkt.ethernet.Ethernet(buf) ip = eth.data udp = ip.data rtp = dpkt.rtp.RTP(udp.data) if rtp.type != pt: continue ssrc = rtp.ssrc streams.setdefault(ssrc, bytearray()) streams[ssrc].extend(rtp.data) return bytes(streams[ssrc])逻辑说明:pcap 读取后先剥以太网、IP、UDP,再读 RTP 头。rtp.data就是 PS 载荷,按 SSRC 分成多个流,再把所有载荷按时间顺序拼进同一个缓冲区。这样就能把原始设备码流完整还原出来。
参数说明:pt通常等于 96,但不同设备协商出来的动态 RTP payload type 不一样,建议先从 SDP 里解析 PT 值,再跑这个函数。
这个工具的实际用法是:设备侧画面异常时,我先抓三秒 pcap,跑一遍这个脚本,转成 PS 流后用之前的解包代码解析,打印出每一帧的类型、大小、PTS 和 SPS/PPS 存在情况。对比一下就能知道问题到底在推流端、网络端还是解码端。
有一次某厂商设备死活黑屏,抓包下来发现它的 PS 流里 SPS/PPS 只在最开始出现一次,后面 IDR 前都不带参数集,播放器从中途接入自然黑屏。正常解码器会在 SPS/PPS 改变时重新赋值,但这台设备就是不给。后来我在解包代码里加了“每遇到 IDR 就补发一次缓存参数集”的逻辑,问题直接消失。
从那以后我每次对接新摄像头,都强制先走一遍抓包、重组、解包、诊断这条流程,差不多能把联调时间从半天压到半小时。希望帮到你。
本文还有配套的精品资源,点击获取