简介这份压缩包围绕海康、国标PS流Program Stream解析提供完整源码工程面向音视频开发、流媒体技术研究及安防平台对接的工程师旨在解决从PS流中分离视频/音频数据、提取PTS/DTS时间戳等实际问题。资源同时收录两条解析路径一是基于ffmpeg的解析版本通过API调用逐帧读取码流信息适合快速集成到现有播放或转码流程二是不依赖第三方库的直接解析版本从PS流包结构、header到payload逐层拆解适合深入理解MPEG-2系统层协议并实现定制化解析。资源共27个文件以cpp与h源码为主体配套Visual Studio解决方案与工程文件sln、vcxproj、filters另有编译过程生成的pdb、obj、tlog等中间产物压缩包大小约2.24MB。工程目录清晰划分为ffmpeg_demux_ps与直接解析PS流两个部分源码结构便于对照调试、功能裁剪和二次开发。已有1096人学习下载对想掌握PS流底层解析、或需要移植到国标GB28181等流媒体场景的开发者颇具参考价值。1. 国标PS流码流明明到手播放器却打不开抓到一路国标设备的RTP包时很多人会觉得已经成功了一大半PTS有了、H.264起始码也找到了接下来把RTP负载直接存成.264文件丢给播放器就该出画面。真实情况往往是黑屏、花屏或者解码器一路报错回头看抓包文件又找不出明显问题。问题不在编码而在封装——国标GB/T 28181的媒体面把PS流塞进了RTP但协议只规定了“负载是PS流”具体怎么装、前面有没有扩展头各家实现并不一致。对某主流安防厂商的设备来说RTP负载开头还有几字节私有扩展把整个码流往后推了一段。解析者必须先把这层黑匣子剥掉再按PS容器找起始码、拆PSM、拆PES最后才能拿到能被解码器直接消费的裸码流。搞懂这条链路比抄一段“能跑通”的代码重要得多因为所有翻车现场都发生在这条标准没有写死的边界上。2. 信令与媒体分离SIP只负责接通RTP才负责搬运2.1 一次国标拉流背后的完整状态流国标GB/T 28181的会话模型和RTSP有本质区别。RTSP里一条RTSP连接同时承担了“说我要看哪路流”和“把流数据传回来”两件事而国标从一开始就把信令和媒体拆成了两条独立通道信令走SIP媒体走RTP。这个拆分不是拍脑袋它的直接影响是媒体服务器可以在信令完全不通的情况下只要拿到SDP描述就能收流、解流反之亦然。一次标准的国标拉流过程大致是下面六步平台向设备发起SIP REGISTER设备返回401挑战平台带认证信息完成注册这一步建立的是“设备在不在线、归属哪个平台”的信任关系。平台向设备发送INVITE请求请求体里携带平台侧SDP描述平台期望收到的媒体类型、IP和端口。设备收到INVITE后向平台返回200 OK响应体里携带设备侧SDP说明设备会用哪个IP、哪个端口、什么编码格式推流。平台回复ACK确认信令握手完成。设备开始向SDP协商出的媒体地址和端口发送RTP流这就是PS流的入口。需要结束播放时任一方发送BYE会话释放。真正承载画面的只有第5步前面四步全在“约时间地点”。理解这个流程的关键在于SIP的每个消息里都没有一帧视频数据RTP流里也没有任何“我要停”的控制字段。信令是电话本和预约登记媒体才是画面本身两者通过SDP里的IP、端口、payload type隐式绑定。2.2 SDP里到底协商了什么SDP是信令和媒体之间的桥。看一段典型的设备侧SDP就能明白PS流是怎么被“绑定”到RTP上的SDP字段典型值含义mvideo16000 RTP/AVP 96视频走RTP动态负载类型96artpmap:96PS/9000096号负载里装的是PS流时钟90kHzmaudio16002 RTP/AVP 98音频走RTP动态负载类型98artpmap:98G711/800098号负载里装的是G.711音频asendonly无设备只推流不收流这里最值得注意的就是artpmap:96 PS/90000。PS流没有在RTP标准静态负载类型表里占一个固定编号所以国标沿用了“动态RTP类型”的通用做法用96和98这种动态值告诉对端“我这个负载类型对应的实际格式是PS”。90kHz是RTP视频常用的时钟频率它和后面PS头里SCR用的90kHz基准同源这是后续音视频同步能对齐的基础。设备侧SDP只是一张“菜单”真正决定解析方式的是RTP包里的payload type字段进入解析器时先看RTP头第2字节的低7位是96就走PS视频解析流程是98就走音频流程。很多半路接手别人代码的开发者往往忽略了这一点直接把所有负载都按视频PS处理音频自然就丢了。2.3 为什么国标选SIP而不是RTSP这个问题几乎每个做国标接入的人都会问。RTSP对单路点播非常合适一个URL对应一路流PLAY/PAUSE用起来也很直观可它有一个隐含假设信令双方是点对点的“播放器-服务器”关系。国标的场景是平台级联上级平台要管理成千上万的设备设备要注册、要鉴权、要能被跨域转发还要支持平台之间的“接力”传输。SIP天然是为“用户寻址”和“会话管理”设计的设备以纯数字编码作为地址可以注册到某个域下支持多级转发和重定向这和国标里“设备编码-平台域”的模型完全吻合。RTSP按URL找流SIP按设备编码找人所以国标选择“SIP做信令 SDP做能力协商 RTP做媒体传输”这个组合本质上是把电信网里成熟的话音会话模型搬到了视频监控场景里。信令通道长期保活媒体通道按需建立这正是平台化接入和点对点播放最大的设计分水岭。3. PS流内部从0x000001BA开始的层层嵌套3.1 起始码的出场顺序PS流全称Program Stream是MPEG-2系统层定义的两种码流格式之一。它的组织方式和TS流的“固定188字节小包”完全不同PS流更像一串大小不定的“节目包”每个包以起始码开头包内可以塞一整个GOP甚至更长的数据。PS流里的起始码全部以00 00 01开头第四字节区分类型起始码名称出现位置0x000001BAPS头每个PS包的起点必须出现0x000001BB系统头PS头之后可选0x000001BCPSM节目流映射通常在关键帧附近出现0x000001E0视频PES承载H.264视频0x000001C0音频PES承载G.711等音频一个PS包的典型布局是先是一段PS头接着可选的系统头和PSM然后是若干PES包PES包交替存放视频和音频数据。值得注意的是0x000001B9是节目结束码只在流末尾出现一次。解析器的核心任务就是顺着这一串起始码把每段数据切成有意义的单元。3.2 PS头里的SCR系统时钟PS头的结构是所有层里最容易被忽略、却直接决定音画同步的字段。它里面有一个42位的SCR字段由33位基准和9位扩展组成基准部分的单位是90kHz扩展部分单位是27MHz。SCR的含义是“这个PS包该在解码器的系统时钟的哪个时刻到达”相当于给每一段码流盖了一个绝对时间戳。解析PS头时可以先跳过嵌套细节直接读取关键信息def read_ps_header(buf, offset): if buf[offset:offset 4] ! b\x00\x00\x01\xBA: raise ValueError(PS header not found) # PS头固定载荷从第4字节开始前9字节包含SCR和mux_rate # SCR占43位33位base 9位extension 1位marker # 这里为了可读性用简化方式提取SCR的高低位 scr_base int.from_bytes(buf[offset 5:offset 9], big) scr_base 3 scr_ext buf[offset 9] 5 scr (scr_base 9) | scr_ext # 从第9字节开始还有program_mux_rate字段 prog_mux_rate int.from_bytes(buf[offset 10:offset 12], big) return scr, prog_mux_rate这段代码把SCR拆成基准和扩展再合并成完整值。真正解析时还要逐一处理marker位和填充字节逻辑不难难的是大多数文档只给比特级定义不给解析示例导致很多人在这里对着位运算发愣。SCR的价值不在它本身而在于它是PTS/DTS的参考时钟后面的PES包里的PTS要和SCR做差才能算出这一帧真正的展示时间。同时注意PS头里的pack_stuffing_length字段它表示PS头后面跟着多少填充字节。有的设备为了保证PS包对齐RTP包会在PS头里塞几个0xFF解析时如果漏跳填充字节PSM和PES的起始码就永远对不齐。3.3 PSM决定“后面那段PES是什么”PSM全称Program Stream Map是PS流里最关键的“目录索引”。它指定了当前PS流包含哪些基本流每个基本流的ID是什么、用什么编码格式。国标设备里最常见的是两种stream_typeelementary_stream_id对应编码0x1B0xE0H.264/AVC视频0x900xC0G.711 A-law音频0x910xC0G.711 μ-law音频PSM通常在关键帧附近出现因为解码器拿到一个新特殊流时需要先知道“这个流里面有哪些成分”才能初始化解码器。这也是PS流和裸H.264流的一个关键区别裸流里SPS/PPS已经携带了编码信息而PS流里PSM才是编码信息的入口。def parse_psm(buf, offset): if buf[offset:offset 4] ! b\x00\x00\x01\xBC: return None psm_len int.from_bytes(buf[offset 4:offset 6], big) end offset 6 psm_len p offset 6 2 # 跳过PSM_id和program_stream_length等基础字段 stream_map {} while p end - 4: stream_type buf[p] es_id int.from_bytes(buf[p 1:p 3], big) es_info_len int.from_bytes(buf[p 3:p 5], big) # 记录该基本流的类型和ID stream_map[es_id] stream_type p 5 es_info_len return stream_map解析PSM时最需要注意的是每个ES信息段后面跟着长度可变的描述符es_info_len就是为跳过描述符而设计的漏跳会让后面的解析全部错位。拿到stream_map后解析器才知道遇到0xE0起始码时该按H.264处理遇到0xC0时该按G.711处理。只按起始码拆解、不参考PSM的解析器就是“玄学播放器”——时好时坏流一变就崩。3.4 PES从容器到裸帧的最后一跳PESPacketized Elementary Stream是把编码数据装进PS包前的最后一级封装它直接携带PTS/DTS和编码数据。def parse_pes(buf, offset): if buf[offset:offset 3] ! b\x00\x00\x01: return None sid buf[offset 3] # PES_packet_length为0时表示长度不限定需要靠下一个起始码判断边界 pes_len int.from_bytes(buf[offset 4:offset 6], big) flags buf[offset 7] pts_dts_flag (flags 6) 0x03 header_len buf[offset 8] pts None if pts_dts_flag 0x02: # PTS占用5字节按位拼出33位值 b buf[offset 9:offset 14] pts (((b[0] 1) 0x07) 30 | (b[1] 22) | ((b[2] 1) 0x3F) 15 | (b[3] 7) | ((b[4] 1) 0x7F)) payload_offset offset 9 header_len return sid, pes_len, pts, payload_offsetPES头里藏着一个坑视频PES的PES_packet_length经常是0表示“这个PES到底多长我不写死”解析器必须向后扫描下一个0x000001起始码来确定边界而音频PES则通常老老实实填了长度因为音频帧定长、好算。另外PTS/DTS并不常驻每个PES很多设备只在I帧PES里放PTS其余帧靠解码器根据帧率和缓冲推算。拆完PES之后拿到的payload才是真正要交给解码器的数据视频PES的负载是带00 00 00 01起始码的H.264 Annex-B流音频PES的负载是连续的PCM采样。到这一步“PS流解析”的目标其实已经完成了一半——剩下的一半是绕过厂商塞在最前面的私有扩展。4. 厂商私有扩展头把PS流整体往后推的几个字节4.1 RTP负载的真实排布某主流安防厂商的国标设备RTP负载里的排布并非直接从PS头开始。抓包展开后负载分布是下面这个顺序标准RTP头固定12字节。厂商私有扩展头常见为4字节部分固件版本为8字节。PS流数据以00 00 01 BA开头。那4字节私有头是标准之外的存在但它携带了接入端最需要的两类信息关键帧标记和包类型。按常见的实现思路解读这4字节可以拆成这样位含义第1字节[7:4]私有头版本解析器用它确认扩展头长度和语义第1字节[3]加密标记为1时负载需要解密第2字节[7]关键帧标记1表示本包属于I帧是GOP起点第2字节[6]同一帧内的分包序号标记用于组合帧第1字节[1:0]帧结束标记指示当前包是否是一帧的最后一个RTP包具体字段偏移随固件版本变化所以写解析器时最稳的做法是抓包实测而不是背一个固定的结构体——这也是我第一次做国标接入时血泪换来的教训。当时我对着一个固定偏移写死了解析换了台新版固件的设备直接花屏回头看私有头第一字节的版本号早就不一样了。4.2 关键帧标记位为什么不能省很多第一次接触国标流的人会问PS容器里的PSM已经能标识关键帧为什么厂商还要在RTP层单独放一个标记位答案在于解析成本。要识别PSM解析器得先把整个PS包从RTP里拼出来、拆了PS头、找到PSM才发现“哦这原来是关键帧”。而RTP层的私有头只有4字节一帧数据到没到齐、是不是关键帧读两个bit就知道了。H.264解码器必须从SPS/PPSIDR开始才能出图如果接入端不知道哪个包是关键帧它只能从收到的第一帧开始喂解码器万一第一帧是P帧解码器成功初始化也要黑屏到下一个关键帧。私有头里一个bit就能让接入端做到“关键帧之前不初始化解码器关键帧到了直接喂”起播速度和稳定性都是指数级提升。这就是厂商设计私有头时最实际也最精明的取舍。4.3 核心解析骨架剥私有头、定位PS流把整条链路串起来一个最小可用的RTP负载解析函数长这样RT_PT_VIDEO 96 RT_PT_AUDIO 98 def extract_ps_from_rtp(buf): if len(buf) 16: raise ValueError(rtp payload too short) # RTP固定头第1字节低7位是payload type pt buf[1] 0x7F if pt not in (RT_PT_VIDEO, RT_PT_AUDIO): raise ValueError(funexpected payload type: {pt}) # 读厂商私有扩展头第13~16字节 ver (buf[12] 4) 0x0F if ver ! 1: raise ValueError(unknown private header version) encrypted (buf[12] 3) 0x01 if encrypted: raise ValueError(encrypted stream, not supported) key_frame (buf[13] 7) 0x01 frame_end buf[12] 0x03 # 第17字节开始才是PS流,但个别固件有对齐填充 ps_offset 16 while ps_offset len(buf) - 4: if buf[ps_offset:ps_offset 4] b\x00\x00\x01\xBA: break ps_offset 1 if ps_offset len(buf) - 4: raise ValueError(PS start code not found) return key_frame, frame_end, buf[ps_offset:]这段代码有三处细节值得解释。第一版本号校验是判断私有头长度的关键版本为1时私有头固定4字节为2时可能是8字节。解析器必须根据版本动态决定偏移量而不是写死16字节否则换固件就翻车。第二从16字节开始用滑动窗口搜00 00 01 BA而不是直接断言buf[16:20] PS头是因为少量固件在私有头后加了对齐填充字节。滑动窗口范围通常不超过8字节超过就说明版本判断错了。第三frame_end和key_frame这两个标记在拼帧阶段会用到一个RTP包只是帧的一个切片解析器需要等frame_end出现才认为整个帧的数据攒齐了才能把完整帧交给解码器。忽略这个标记、来一个包解一个包丢包时解码器必崩。5. 常见问题排查花屏、不同步、静音都在哪里埋雷5.1 花屏私有头长度判断错位现象抓包里能看到完整的PS起始码但把RTP负载导出来存成文件后播放器要么直接花屏要么报错“no frame sync”。原因设备的固件版本私有扩展头是8字节而解析器按4字节剥头PS流从第17字节开始整体偏移了4字节。偏偏这4字节里藏着00 00 00 01的一部分导致解析器搜不到00 00 01 BA或者更隐蔽地搜到了一个伪起始码。解决抓包后用Wireshark导出一段RTP负载的hexdump人工找到00 00 01 BA的真实位置反推私有头长度。解析代码里用版本字段动态决定扩展头长度再用滑动窗口扫描PS头做二次校验两层都过了才往下走。从那以后我写国标解析器第一行代码永远是“确认版本号”。5.2 音画不同步SCR和PTS基准不一致现象画面正常声音超前或滞后几百毫秒且偏移量固定。原因视频PES和音频PES各自的PTS起点不同有的设备视频从0起步音频从当前绝对时间起步同一个RTP会话里两者本来就不保证对齐。如果直接拿RTP时间戳做音画同步就会被带偏——RTP时间戳只对同一路流有效跨音视频流算同步是典型的误用。解决在解析器里记录收到的第一个视频PTS和第一个音频PTS算出固定偏移diff v_pts0 - a_pts0后续每一帧做同步判断前都先把PTS减掉这个偏移再比较。这比对齐任何外部时钟都可靠因为它消除的是设备端自己的封装差异。5.3 静音编码协商和实际码流不一致现象视频正常音频完全没有声音但SDP里明明写的G.711。原因一是设备音频编码配置改成了AAC但SDP里的rtpmap没同步更新解析器按G.711解AAC输出全是有规律噪声或静音二是PSM里的stream_type写了0x0FAAC而RTP的payload type仍是98两个信息源打架解析器不知道该信谁。还有第三种容易忽略的情况设备静音时按帧周期发全零数据G.711解出来就是数字静音这不算故障。解决排查时把三个地方的编码描述对齐SDP的rtpmap、PSM的stream_type、PES的stream_id。三者一致才确定解码策略。G.711还要分清A-law和μ-law选错会有明显噪声而不是静音。5.4 丢包暴击不能逐包喂解码器现象网络丢包率不高但丢一个包之后解码器连续报错后续几十帧全废。原因PS/PES靠长度和起始码切分丢一个RTP包会让整个PES载荷不连续。直接把残缺数据喂给H.264解码器错误的起始码会让解码器进入错误状态后面的好帧也被连带废掉。解决解析器先按RTP序号排序再用frame_end标记拼帧等一个完整帧的数据齐了才传给解码器。关键帧不完整时整帧丢弃不尝试修补——H.264解码器自己有能力在下一个IDR处恢复强行修补反而破坏GOP结构。加一个20到40毫秒的jitter buffer这既是网络缓冲也是拼帧窗口。6. 设计取舍为什么厂商不给裸H.264非要裹这么多层从接入端看裸H.264直接用RTP封装是最简单的方案RFC 6184定义的FUA分片和STAP组合包就能搞定解析成本比PS流低一个量级。国标不这么做是因为GB/T 28181定位的是平台间媒体交换而不是单路播放。一个流里要同时承载视频、音频、私有数据还要支持回放、预录、加密和平台转发PS容器提供的是PES级别的多路复用能力。TS流同样能做到但TS面向的是易错广播链路188字节固定包粒度意味着每个包都要独立解析、独立加时间戳在专网传输下开销偏高。PS流按PES组织、按GOP切分天然适合相对可靠的IP网络和存储回放场景。所以国标选PS不是技术落后而是场景匹配下的合理取舍。厂商私有扩展头的存在则是历史包袱与实用主义的折中。设备要兼容旧固件标准层又不能随便改于是把关键帧标记、加密标志这些“播放器最需要的信息”提前到RTP层用一个版本字段控制扩展头长度。代价是码流变成了“私有头标准PS”的混合体但换来的是接入端能用O(1)的判断替代整包扫描起播速度和丢帧策略都会明显变好。验证这套解析是否正确的办法也很简单抓一段实际设备的流手动逐层标记——先标RTP头再标私有头然后标PS头、PSM、PES最后把视频PES里的H.264喂给播放器。每一层都能对上解析器才算真正写完。从那以后我每次做国标接入都强制走一遍这个流程先抓包确认私有头版本再核对PSM里的编码类型最后才动手写代码。这个习惯帮我躲过了无数次屏幕全绿的尴尬希望帮到你。本文还有配套的精品资源点击获取