简介:一份面向音视频与流媒体开发者的PS流解析示例工程,聚焦海康设备及国标PS流场景,分别提供基于ffmpeg库和纯手工解析的两种实现方案。包内共27个文件,以C++源码(cpp/h)为主,附带Visual Studio解决方案(sln/vcxproj)及编译生成物,整体约2.24MB,适合直接打开工程对照学习。已有1096人学习下载。其中ffmpeg版展示如何调用解码API快速提取视频音频帧;直接解析版则从底层拆解MPEG-2 PS容器结构,手动分离音视频数据包并处理PTS/DTS时间戳。两个工程并排阅读,既能理解主流框架的封装思路,也能掌握自研解析器的核心算法,对需要定制流媒体协议或优化解码链路的开发者很有参考价值。
1. 拿到海康国标PS流先别急着喂解码器:这层壳不剥,后面全白干
做过安防流媒体接入的开发者应该都有同感:从国标平台或海康设备拉回来的码流,直接用播放器解码,十有八九是黑屏或花屏。原因很简单——RTP里装的根本不是裸H.264,而是经过PS封装的节目流。PS流在MPEG-2系统层里被定义为“面向存储与本地传输”的封装格式,它有自己的一套包头、PES包结构、时间戳体系,如果不先把这一层壳拆干净,解码器根本找不到帧边界。这份“海康、国标ps流解析”资源,核心就是解决这个拆壳问题:从RTP或文件中恢复PS结构,提取PES,再还原成可解码的H.264裸流,顺便把PTS/DTS对齐、SPS/PPS注入这些配套问题一并处理掉。适合正在做GB/T 28181国标对接、海康设备SDK二次开发,或者自研流媒体网关的开发者。
2. PS流结构拆解:为什么海康国标场景要套三层壳
2.1 从RTP到裸流的三层封装关系
海康国标设备输出的媒体流,按GB/T 28181的规定,走的是“SIP会话协商 + RTP承载 + PS封装”这套链路。SIP负责信令协商,RTP负责网络传输,而PS封装则是把编码器输出的H.264基本流按帧封装成PES包,再组装进PS包。
有人会问:为什么要多此一举?直接RTP封装H.264不是更省事吗?这就要看PS流的设计背景了。MPEG-2标准定义了两种系统层封装:TS流和PS流。TS流是为广播设计的,容忍丢包,每188字节一个包,自带同步信息;而PS流是为本地存储和可靠传输设计的,数据密度高,适合在丢包率低的网络环境下传输。国标选PS流而不是TS流,主要原因是PS流的包结构更紧凑,RTP打包效率更高,也更适合安防场景下的录像存储与回放。
海康很多设备的码流默认就是PS over RTP。这意味着你从网络上抓到包之后,解包路径是:RTP负载 -> PS包 -> PES包 -> H.264 NALU。每一层都有独立的头部信息,漏掉任何一层,后面的数据都无法正确解释。
2.2 PS头的起始码家族与关键字段
PS流最核心的特征是起始码(Start Code)。和H.264的起始码一样,PS流也用了类似的设计。整个PS流里你只需要识别这几个起始码:
0x000001BA:PS包起始码,每个PS包的开头标志0x000001BB:系统头起始码,描述节目流的系统信息0x000001BD:私有流起始码,常用于存放扩展数据0x000001E0~0x000001EF:PES包起始码,按stream_id区分不同的PES流
解析PS流的入口就是扫描0x000001BA。但这里有个容易栽跟头的细节:很多设备的PS头并不总是一个固定长度。标准PS头有14字节的固定部分,后面还跟着可选的扩展字段。如果不小心按固定偏移去跳,很容易在遇到带扩展头的设备时直接解析错位。
PES头的结构更需要仔细处理。标准PES头开头是3字节的起始码前缀0x000001,接着1字节stream_id,再接着2字节PES_packet_length,之后是可选标志位字段。PES_packet_length有一个特殊值——如果它等于0xFFFF,表示这个PES包的长度没有写进头部,解析器必须通过查找下一个起始码来确认边界。这个机制在一些海康老型号上特别常见,处理不好就会出现“越解越乱”的问题。
2.3 软解壳与硬解壳两条路线怎么选
拆PS壳有两种主流做法。第一种是软解壳:自己写解析代码,按起始码逐层剥离PS头和PES头,直接把H.264 NALU提出来。第二种是硬解壳:把PS流交给FFmpeg这类成熟库,让库内部的demuxer去处理。两条路各有适用场景,不是谁替代谁的关系。
软解壳的优势是无第三方依赖,适合嵌入式平台、国标网关这类对内存和延迟敏感的场景。我自己做的国标接入服务用的就是软解壳。硬解壳的FFmpeg方案适合快速验证和PC端产品,但FFmpeg默认不支持PS流注进去直接喂解码器,需要手动处理AVFormatContext和AVStream的时间基。
选择标准其实很实际:如果你的平台允许用FFmpeg,而且对延迟要求不那么极端,直接走FFmpeg方案能省不少事;如果你的服务是C++写的常驻进程,要对接多路视频、要求低延迟,软解壳状态机更可控。这份资源里给的实现就是软解壳路线,最后画了接入FFmpeg解码器的桥接层,两条路都占住了。
3. 把PS头剥干净:解析器的状态机与起始码定位
3.1 先写一个离线版的PS解析原型
拿到新资源,我习惯先用离线文件验证逻辑,再搬到在线RTP流上。因为你手上如果有一段抓包导出的PS流文件,可以直接跑脚本验证解析结果,不用连设备调试。Python写原型最顺手,处理二进制流的struct、切片都很直接。
import struct PS_HEADER_START = b'\x00\x00\x01\xba' PES_START_PREFIX = b'\x00\x00\x01' NALU_START = b'\x00\x00\x00\x01' def parse_ps_file(filepath): with open(filepath, 'rb') as f: data = f.read() pos = 0 pes_count = 0 nalu_count = 0 pts_list = [] while pos < len(data) - 4: idx = data.find(PS_HEADER_START, pos) if idx == -1: break # PS头之后第一个起始码的位置即PES包的起点 pes_idx = data.find(PES_START_PREFIX, idx + 4) if pes_idx == -1 or pes_idx >= idx + 256: pos = idx + 4 continue stream_id = data[pes_idx + 3] if 0xE0 <= stream_id <= 0xEF: # 视频PES pts, payload_offset = extract_pts(data, pes_idx) if pts is not None: pts_list.append(pts) # 跳过PES头,payload就是H.264接入码前的数据 payload_start = pes_idx + payload_offset pos = payload_start nalu_count += scan_nalu_count(data, payload_start, min(idx + 32768 * 10, len(data))) pes_count += 1 continue pos = pes_idx + 4 print(f'PES packets: {pes_count}') print(f'NALU count: {nalu_count}') print(f'First 5 PTS: {pts_list[:5]}') def extract_pts(data, pes_start): # PES头从pes_start开始,跳过起始码、stream_id、length共6字节 flags = data[pes_start + 7] header_len = data[pes_start + 8] pts = None offset = 9 + header_len if flags & 0x80: # PTS标志位 five = data[pes_start + 9: pes_start + 14] pts = ((five[0] & 0x0E) << 29) | ((five[1] & 0xFF) << 22) | \ ((five[2] & 0xFE) << 14) | ((five[3] & 0xFF) << 7) | \ ((five[4] & 0xFE) >> 1) offset = 9 + 5 + header_len - 5 if header_len >= 5 else 9 + header_len return pts, offset def scan_nalu_count(data, start, end): count = 0 pos = start while pos < end - 4: idx = data.find(NALU_START, pos) if idx == -1: break count += 1 pos = idx + 4 return count这段原型代码的逻辑分三层。第一层find循环负责定位PS包起始码,找到后不是直接进入解析,而是先向下一个起始码段找PES起点。第二层判断stream_id,只有落在0xE0~0xEF范围的才是视频PES,音频PES的stream_id是0xC0开头,需要分开处理。第三层extract_pts函数从PES头的可选字段里抠出33位PTS。
这里必须说明一个关键设计:PTS的提取方式。PES标准头长度是6字节(起始码3字节+stream_id 1字节+length 2字节),第7字节是标志位,第8字节是PES_header_data_length。判断PTS是否存在要看第7字节的0x80位,33位PTS分5字节存放,每字节各留了标记位,拼装时必须做掩码移位,否则时间戳会差几十倍。
scan_nalu_count函数统计的是PES payload里出现0x00000001的次数。H.264的Annex-B格式以这个作为NALU边界,统计它能验证“PS解出后帧数对不对”,也可以粗略估算关键帧间隔。
3.2 在线C++状态机:从RTP流到NALU的实时剥壳
离线原型跑通后,上生产就要换成C++状态机。在线流的难点不是解析逻辑本身,而是数据到达的随机性:一个RTP包可能只包含半个PES头,下一个包才是剩余部分。如果每次收包都从头扫描,浪费CPU是小,状态错乱才是大问题。
enum PS_STATE { STATE_PS_HEADER = 0, STATE_PES_HEADER, STATE_PES_PAYLOAD }; struct PSDemuxer { PS_STATE state = STATE_PS_HEADER; std::vector<uint8_t> buffer; int pes_payload_remaining = -1; uint32_t last_pts = 0; void push_rtp_data(const uint8_t* data, size_t len) { buffer.insert(buffer.end(), data, data + len); while (true) { size_t pos = find_start_code(buffer, 0x000001BA); if (pos == std::string::npos) { break; // 当前缓冲没有完整PS头,等下一包 } // 剥离PS头:标准PS头14字节,跳过扩展字段 pos += 14; state = STATE_PES_HEADER; // 从PS头之后找PES起始码 size_t pes_start = find_start_code(buffer, pos, 0x000001E0); if (pes_start == std::string::npos) { // 缓冲不足,继续积累数据 break; } // 解析PES头长度,计算payload起点 size_t payload_start = pes_start + 9 + buffer[pes_start + 8]; if (payload_start >= buffer.size()) { break; } // 以起始码为边界输出NALU size_t nalu_end = find_next_start_code(buffer, payload_start); // 在这里把buffer[payload_start..nalu_end]交给解码器 last_pts = extract_pts_from_pes(buffer, pes_start); buffer.erase(buffer.begin(), nalu_end); state = STATE_PS_HEADER; } } };这个状态机只有三个状态:找PS头、找PES头、切payload。每次从RTP收到数据先塞进内部缓冲,然后循环执行状态跳转。核心设计思想是“宁可多等一个包,也不在半个包头时猜长度”——所有解析动作都建立在缓冲里找到完整体之后再执行。
find_start_code函数按0x000001xx家族搜索,不区分具体类型,找到后统一交给上层做类型判断。这样做的原因是RTP分包时起始码可能被拆成两截,如果每次都从开头重新找0xBA,跨包场景必翻车。
参数上比较讲究的是pes_payload_remaining。遇到PES_packet_length等于0xFFFF时,这个字段没法用,只能靠“找下一个起始码”来确认payload边界。而正常长度PES可以用长度字段直接跳到下一个包,省一次扫描。两种模式都要支持,因为同一个设备的不同通道可能同时出现这两种封装。
3.3 解析失败时先排查什么
写完状态机,第一件要做的事是拿抓包文件实测。如果输出日志里PES数量为0,先查RTP负载类型。PS over RTP的动态负载类型一般是96或98,而有些设备把负载类型写成标准H.264的96,这时候RTP包里的数据其实是PS流,直接按H.264解必然失败。
第二个常见现象是能解析出PES但NALU数量异常偏少。这时要检查当前只统计了视频PES,而PS流里混着系统头0xBB和私有流0xBD。H.264的SPS/PPS和SEI常常放在私有流里而不是视频PES里,统计NALU时如果只扫视频PES,会漏掉这些关键配置帧。
第三个排查点是时间戳。如果PTS列表全部是0,检查PES头的flags位是不是没读到。有的设备在私有流扩展里放自定义时间戳,而不是标准PES的PTS字段。
4. 解析之后怎么喂给播放器:PTS对齐与SPS/PPS注入
4.1 解析出的AUB不能直接送解码器
很多第一次写PS解析的开发者容易产生一个误解:解析出NALU送进解码器就能出画面。实际上,解码器要正常工作需要先拿到SPS/PPS,否则连分辨率都不知道。而SPS/PPS在PS流里的位置很不规律——有些设备在第一个PS包就带上,有些则跟着第一个IDR帧一起出现,还有一些只在一开始的私有流里带一次。
正确的做法是单独维护一个序列参数缓存。在解析阶段遇到SPS/PPS类型NALU(type 7和8),不管在哪个位置,都要单独保存。初始化解码器之前先把SPS/PPS塞进extradata。这里给一段用FFmpeg API做注入的代码骨架:
AVCodecParameters* codecpar = avcodec_parameters_alloc(); codecpar->codec_type = AVMEDIA_TYPE_VIDEO; codecpar->codec_id = AV_CODEC_ID_H264; codecpar->width = width; // 从SPS解析得到 codecpar->height = height; // 从SPS解析得到 // 把SPS和PPS拼接成extradata std::vector<uint8_t> extradata; if (!sps.empty()) { extradata.insert(extradata.end(), {0x00, 0x00, 0x00, 0x01}); extradata.insert(extradata.end(), sps.begin(), sps.end()); } if (!pps.empty()) { extradata.insert(extradata.end(), {0x00, 0x00, 0x00, 0x01}); extradata.insert(extradata.end(), pps.begin(), pps.end()); } codecpar->extradata_size = extradata.size(); codecpar->extradata = (uint8_t*)av_mallocz(extradata.size() + AV_INPUT_BUFFER_PADDING_SIZE); memcpy(codecpar->extradata, extradata.data(), extradata.size());注意FFmpeg的H.264解码器要求extradata按Annex-B格式拼接,也就是每个SPS/PPS前都要带00 00 00 01起始码,中间不能有多少分隔符之类的额外字节。很多播放器黑屏问题都出在这——SPS/PPS本身解析出来了,但extradata拼接格式不对。
4.2 PTS和DTS的时间基准换算
PS流里的时间戳基准是90kHz,这是MPEG-2系统层的固定频率。而FFmpeg解码器内部用不同的时间基表示——AVStream的time_base通常是1/90000,但AVPacket的pts也要设置在同一个时间基下。
最常见的坑是直接把PTS原始值塞给FFmpeg而不做任何换算,因为PTS是从PES头里原样抠出来的,单位是90kHz的tick。如果你把90000当作time_base,那PTS直接塞进去是对的。但如果播放器内部按毫秒处理,就必须做一个换算:
// PTS原始tick -> 毫秒 int64_t pts_ms = (int64_t)(pts * 1000.0 / 90000.0); // 毫秒 -> PTS tick(回写时用) int64_t pts_tick = (int64_t)(pts_ms * 90000.0 / 1000.0);音画同步依赖的是PTS/DTS相对关系,不是绝对值。PS流里视频PTS是单调递增的,但如果中间出现B帧,DTS和PTS的差值不能丢。有些解析器只提取PTS、把DTS置为-1,遇到B帧多的码流就会出现播放器缓存抖动。保守做法是:解析PTS时同时解析DTS(PES头flags里0x40位),没有DTS时才退回用PTS替代。
4.3 验证解码链路:先转封装MP4再上实时播放
我自己的习惯是在接播放器之前,先做一次离线验证。把解析出来的H.264裸流封装成MP4文件,用播放器打开看能不能正常快进、拖拽、暂停。这一步能隔离问题:如果MP4能播,说明PS解析逻辑没问题,问题出在直播链路的实时性上;如果MP4也花屏,说明SPS/PPS或帧边界有问题。
转封装可以直接调FFmpeg命令行验证。先用你自己写的解析器把PS流转成Annex-B格式的H.264文件,然后再用FFmpeg封装成MP4:
ffmpeg -i extracted.h264 -c copy output.mp4这条命令不转码,只是重新封装。如果output.mp4能正常播放,整个PS解析链路就算验证通过了。注意extracted.h264必须是Annex-B格式——每帧前带00 00 00 01起始码,而FFmpeg默认输出格式就是Annex-B,所以直接转封装即可。
5. PS流解析避坑指南:五个真实翻车点
5.1 现象:RTP包完整但PS头扫描不到
现象:从Wireshark抓包里能看到RTP payload以00 00 01 BA开头,但代码里find了一整包数据都找不到这个起始码。
原因:RTP包在传输过程中被IP分片。网络层分片后,PS头可能在第一个RTP包尾部,而payload部分在下一个包。你的解析器如果只按单包扫描,永远看不到完整的起始码。还有一种情况是设备开了RTP打包模式,把多个PS包塞进一个RTP包,起始码被新包头挤到后面去了。
解决:解析器内部必须维护跨包缓冲。每次收到RTP包先append进buffer,再从头循环扫描。扫描到的数据如果没有消费完,下一次再来数据时不能从0开始扫,要从上次消费的位置继续扫。推荐的缓冲大小是单个RTP包最大长度加PS最大扩展头长度,我一般用1500字节起步。
5.2 现象:能出画面但首帧延迟十几秒
现象:解析器把NALU交给播放器后,画面能出来,但从播放器请求到第一帧画面的间隔长达十几秒。监控场景下这个延迟不可接受。
原因:播放器在等IDR帧。PS流里IDR帧之前通常有P帧和B帧,解码器收到非IDR帧没法出帧,于是一直缓存等待。如果你的解析器按“来一帧吐一帧”的方式输出,播放器会把它收到的非IDR帧全部丢弃,直到等到IDR帧。海康设备的I帧间隔可能是半秒到几秒,这意味着首帧延迟等于I帧间隔。
解决:解析层维护一个关键帧队列,只有遇到IDR帧才通知播放器解码。或者往播放器侧传一个参数,告诉它跳过非IDR帧缓存。另外,需要确认PS流里有没有I帧间隔配置选项——海康有些设备的I帧间隔默认设得很大,接国标平台时适当调小有帮助。
5.3 现象:代码解析出的NALU数量和解码器日志对不上
现象:自己写的统计脚本数出100个NALU,解码器日志却显示只处理了80个,帧号明显对不上。
原因:H.264有nal_ref_idc为0的非参考帧,还有SEI帧。SEI帧是补充增强信息,解码器通常直接丢弃但解析脚本把每个起始码都算作一帧。另外,解码器如果配置了错误隐藏模式,会把损坏的slice跳过,计数自然对不上。
解决:统计NALU时按nal_unit_type分类统计,不要只看起始码数量。Type 1是P帧Slice,Type 5是IDR,Type 6是SEI,Type 7是SPS,Type 8是PPS。如果Type 6、7、8占比异常高,检查是不是PS流里把配置数据重复发送了。正常H.264码流里SEI不应该超过总帧数的5%,SPS/PPS只应该在开头出现。
5.4 现象:某型号设备能解,另一型号花屏
现象:同一套解析代码,A厂家的设备接入正常,B厂家的设备接入后花屏、马赛克、偶发黑屏。两边都宣称“标准PS封装”。
原因:不同厂家对“标准”的理解不一样。有的设备在PS扩展头里塞了额外的私有字节,有的设备把SPS/PPS的nal_unit_type写成9(Access Unit Delimiter),有的设备PS头长度不是标准14字节而是带扩展字段。这些偏差在解码器层面无感知,但你的解析器一旦假设“PS头固定14字节”就翻车。
解决:起始码强校验优先于长度字段。无论PS头还是PES头,解析时都先按起始码定位,再校验长度字段是否一致;长度不一致时以起始码边界为准。绝对不要按固定偏移跳转——只要有一次跳错,后面整个流就全乱了。
5.5 现象:网上有人说“PS流不用解析,直接交给FFmpeg就能解”
现象:开发者在论坛看到有人说FFmpeg的h264解码器自带PS解复用,于是跳过了手写解析器,直接把RTP payload塞给FFmpeg的AV_CODEC_ID_H264解码器,结果花屏。
原因:FFmpeg虽然有demuxer能解PS,但它默认的h264解码器输入需要的是裸H.264流,不是PS流。除非显式注册AVInputFormat的ps。demuxer并且按AVFormatContext流程走,直接塞AVPacket是解不出来的。
解决:要么就走完整的FFmpeg demuxer流程:avformat_open_input指定ps格式,让FFmpeg内部做解复用;要么自己写解析器把PS剥干净后喂给h264解码器。两条路选一条,不要混着来。这条不是玄学,是API的使用边界问题。
6. 验证先行:一份离线PS文件的自我体检清单
拿到一份PS流文件,不要急着跑解码器,先做一轮离线体检。我自己固定走这套流程,大约需要十分钟,能省掉后面接播放器时的排查时间。
第一步,十六进制查看文件头。用xxd或010 Editor直接看前几行,必须出现00 00 01 BA这样的起始码。如果文件头是RTP头,先把RTP头剥掉再验证:
xxd captured_ps.bin | head -20第二步,跑一遍自己写的解析统计脚本。记录三个关键数字:PS包总数、PES包总数、NALU总数。这三个数字的比例应该接近1:1:帧数。如果PS包数远大于PES包数,说明可能在错误的位置重复找头;如果NALU数远大于PES数,说明payload边界切偏了,把一个PES切成了多段。
第三步,验证SPS/PPS的位置和内容。用解析器输出所有SPS/PPS出现的位置和nal_unit_type。正常应满足:SPS和PPS各出现一次或几次(重复发送是允许的),且必须在第一个IDR帧之前。如果在第一个IDR帧之后才发现SPS/PPS,播放器大概率起播失败。
第四步,验证时间戳单调性。把所有PTS按序排列,检查是否单调递增。PTS回退超过一个阈值(我一般用10秒)说明解析有误,或者码流本身存在B帧乱序——前者要修代码,后者要在播放器侧容忍乱序。
第五步,用一个已知能播的PS流做对照。如果你手头有一个确认能正常播放的PS文件,把两个文件的解析统计结果放在一起比。这一步最有价值——不怕不知道对错,就怕没有参照物。
从那以后,每次接新设备型号的码流,我都强制走一遍“先离线抓包,再做十六进制验证,最后跑在线解析”的流程。这十分钟的检查看着琐碎,但能让后面调试省下几个晚上的时间。希望帮到你。
本文还有配套的精品资源,点击获取