GB28181国标PS流解包实战:从RTP重组到H.264/H.265裸流提取

发布时间:2026/9/9 4:35:46
GB28181国标PS流解包实战:从RTP重组到H.264/H.265裸流提取 简介面向DVB地面数字电视广播中的节目流解析任务这套代码以两个文件构成一个可用的解包模块一个主解析源文件作为算法主体负责识别包头、过滤指定节目标识、重建分组基本流包并处理时间戳配套头文件则声明解析器类、接口函数与常量定义方便集成进其他工程。节目流是DVB体系中面向存储和低误码率环境的一种数据组织方式与用于实时广播的传送流相比它更适合本地节目封装压缩包总大小仅三KB只包含这两个源文件虽然体量小却覆盖了节目流解包的关键链条。开发者可以对照MPEG-2系统层规范逐段理解从多路复用包中分离出视频、音频基本流再到还原原始像素与声音样本的整个过程。视频部分通常对应MPEG-2视频编码音频部分则可能采用MPEG-1音频第二层或AC-3编码解析时还需考虑错误标记、丢包及网络层恢复等实际问题代码正好可作为快速验证和二次开发的试验台。目前已有八百五十二人学习适合数字电视接收机、机顶盒、流媒体网关等方向的嵌入式工程师和协议研究者参考。 做国标GB28181接入的时候我最头疼的其实不是SIP信令而是视频流那头。信令顶多是消息交互调试几轮就能通。真正让人掉头发的是海康、大华这些设备推过来的PS流你拿RTP包收到手之后面对的是一坨没有文档、没有注释、只有二进制的大块头。标题里说“HK 28181 PS流解包代码”说白了就是要把这一坨PS流按照MPEG-2系统层的规范拆开把里面的H.264或H.265裸流抽出来再喂给播放器或者转码服务。这篇文章我直接把我写过的解包思路、代码关键点、踩过的坑全部倒出来希望能帮到正在做GB28181接入的人。我最早接到这个需求是在做一个市级视频汇聚平台的接入网关。设备侧用的是海康的摄像头GB28181协议对接媒体传输走RTPpayload类型是96封装格式就是PS流。目标很明确把RTP收到的PS包在内存里重组解包出标准H.264的Annex-B码流然后推给内部的流媒体服务做分发和录像。整个过程看起来就是“收包、拼包、拆包”三步但实际代码写起来完全是另一回事。1. 项目概述为什么要写这个解包器1.1 项目从哪来解决什么问题很多做安防平台的人第一次接触GB28181时都会有个疑问为什么国标的视频流不用裸的H.264RTP封装非要套一层PS流答案很简单GB/T 28181-2016标准里明确规定了媒体流封装格式为PS封装视频编码为H.264或H.265。之所以这么定是因为PS流是MPEG-2定义的程序流容器它能把视频、音频、私有数据等多种数据流按时间戳复用到一个流里面尤其适合像安防这种需要音视频同步、需要按帧准确切分、需要支持回放定位的场景。如果只是要“看个画面”那直接解析RTP负载里的H.264 NAL单元也不是不能出图但会遇到一个问题设备端如果开了音频音频和视频是分开的RTP流你要自己做音视频同步如果前端是抓拍机每帧之间要自己判断关键帧。而这些麻烦事PS流在容器层已经帮你解决了一部分。PS包里有系统时钟参考SCR、节目流映射表PSM、每个PES包的DTS/PTS时间戳parse出来之后视频帧边界、音视频同步信息都是现成的。但PS流也有它的毛病它是面向存储或本地传输设计的不太适合网络丢包环境。一旦RTP包在网络里丢了几个后面一整段PS包的解码都会受影响轻则花屏、重则卡住。所以解包器不能只做“按部就班”的解析还得考虑丢包容错、乱序重排、非法数据跳过这些现实问题。1.2 项目核心目标和适用人群我做的这个解包器核心目标有三条接收RTP流完成PS流数据的重组和缓存。解析PS层的头部、PSM、PES头部逐级剥离容器信息。提取内部封装的H.264/H.265码流以帧为单位输出给下游。这个需求适合谁如果你正在做GB28181的SIP网关、视频接入平台、安防设备SDK的上层应用或者你想弄明白PS流内部结构、想自己写一个轻量级的PS解复用器那这篇文章应该能帮你省掉不少查规范和试错的成本。我下面讲的代码逻辑是可以用C/C或Java、Go来复现的核心是数据结构解析的思路不绑定具体语言。2. PS流的结构解析与解包思路2.1 PS流和TS流的区别为什么选PS在MPEG-2系统层里有两个容器格式TS流和PS流。TS流用在数字电视、流媒体切片这些场景包长固定188字节抗丢包能力强PS流则长度不固定面向可靠存储比如DVD里的VOB文件、安防设备的录像文件。GB28181选PS流而不是TS流还有个现实原因PS流里的PSM信息可以指明两个不同编码的流比如H.264视频流和G.711音频流分别对应哪个ES流ID解析器拿到PSM之后就能知道后面哪些PES包该走视频解码器、哪些该走音频解码器。这个机制对电视直播场景也适用但在安防场景里PS流往往是单一视频流PSM里就一个流反而让PSM看起来“可有可无”但标准里仍然要求封装而且对接过会发现海康的设备其实会发PSM大华也会只是内容繁简有差异。2.2 从RTP到PS再到裸码流的完整链路整个解包链路可以拆成四段RTP接收与重组RTP按序号排序去掉RTP头得到PS流的分片数据。PS包头解析按起始码0x000001BA找到PS包起始位置解析SCR、复用标志等。PSM解析按起始码0x000001BC定位PSM读取流类型和ES流ID映射。PES解析按起始码0x000001E0~0x000001EF定位PES包剥掉PES头拿到编码裸流。这里最关键的是第二个和第四个环节。PS包头长度不固定得先读取固定的14字节头部再根据33位的标志位判断后面有没有额外的扩展字段。PES头也是一样头部长度是可变的只有读完PES头长度字段之后你才知道后面真正的负载从哪个字节开始。我记得第一次写的时候直接把PS包的起始码当做固定位置去读PES结果解析出来的数据全乱画面满屏马赛克。后面仔细查了规范才发现PS包结构里先有系统头可选或者PSM再才是PES。如果漏掉了PSM后面的数据定位全都会偏。3. 核心代码实现与关键细节3.1 收到的第一手数据RTP负载的缓存与组包从RTP到完整的PS数据包这个过程叫“去RTP化”。在GB28181的SIP会话协商里视频RTP的payload type一般协商为96封装格式就是PS流。RTP包本身不保证按序到达所以收包端要有一个以sequence number为key的排序缓冲区。我的实践经验是不要把所有数据都放进一个无限大的重排缓冲区里要设置一个最大等待时间比如80ms或者120ms。因为设备发送的RTP包之间间隔很小一帧高速视频大概会分成7到12个RTP包序号不连续的情况主要是丢包和网络抖动。如果等太久解码延迟就会变大如果不等乱序会导致PS头解析失败。我这里给的阈值是参考值实际要根据你的网络环境调。伪代码逻辑大致是// RTP包按序号内存管理 struct RtpPacket { uint16_t seq; uint8_t* payload; int payload_len; }; // 缓存区按seq索引处理乱序和丢包 void on_rtp_packet(RtpPacket* pkt) { insert_into_jitter_buffer(pkt); // 按seq插入 // 判断连续性当前接收到的最大seq - 起始seq 阈值时触发组帧 if (jitter_buffer.continuous_until(cache_seq)) { PsPacket ps assemble_from_rtp(jitter_buffer); parse_ps_packet(ps); } }这里有个隐藏细节RTP头里的timestamp字段一个PS包可能对应多个RTP包这些RTP包的时间戳是一样的。所以组帧的时候不能用时间戳变化来做分包依据要用序号连续区间。这也解释了为什么很多新手用时间戳切RTP流会切出半个PS包的情况。3.2 PS包头解析不变的0x000001BA拿到一包完整的PS数据后第一步就是找PS包的起始码0x000001BA。起始码是PS流里的同步标识所有PS包头都从这个三个字节开始。PS包头部的标准结构是字段长度说明起始码32bit0x000001BASCR42bit系统时钟参考高33位低9位节目复用速率22bit实际用处不大解析时可忽略保留字段5bit恒为1打包长度16bit通常为0表示长度未定义严格按标准解析的时候开销不小。以一段典型的海康视频PS包为例PS包头后面紧跟的往往不是系统头而是PSM或者直接就是PES包。所以解析PS包头时我建议不要依赖“固定结构体”去强转内存因为这涉及字节序和位域填充问题平台一换就出错。最好用逐字节读取的函数比如// 读取N位的函数示例 uint32_t read_bits(uint8_t* buf, int bit_pos, int n) { uint32_t val 0; for (int i 0; i n; i) { val (val 1) | ((buf[bit_pos / 8] (7 - bit_pos % 8)) 1); bit_pos; } return val; }这种位读取方式虽然看起来慢一点但接口非常稳。PS流里有大量位级字段比如marker_bit、program_mux_rate、stuffing_length用位运算写一个通用bit reader所有解析都能复用出错率低。3.3 PSM解析知道里面装的到底是什么PSM的起始码是0x000001BC。它的作用是描述这个PS流里有哪些元素流以及每条流的流类型和ES流ID。对于安防设备来说最常见的组合就是视频流ES流ID为0xE0流类型0x1BH.264或0x24H.265音频流ES流ID为0xC0流类型0x90G.711或0x03MP3。但要注意不同厂家对音频流类型的定义存在私有扩展你不一定总能靠PSM判断出音频编码有时候还需要根据RTP payload type或SIP信令里的媒体描述来辅助判断。我在解包时会把PSM的内容缓存下来因为在同一个PS流里PSM一般是周期性重复发送的每次出现都解析一遍没有意义直接保存解析结果更高效。常见的做法是维护一个全局的psm_info结构体包括struct PsmInfo { int stream_count; std::vectorStreamInfo streams; }; struct StreamInfo { uint8_t stream_type; // 0x1B H264 0x24 H265 uint8_t stream_id; // 0xE0 video 0xC0 audio uint16_t es_info_length; };还有一个需要注意的点PSM的前面是PS包头两者中间可能要跳过program_stream_info。所以定位PSM时不要直接拿起始码之后第2个字节来读流数量要先判断有无program_stream_info_length字段跳过了再读当前流信息长度。3.4 PES头解析真正的视频数据在这里PES包的起始码是0x000001E0到0x000001EF对应16个基本流ID。视频流的起始码我见到最多的是0x000001E0也就是stream_id0xE0。PES头结构比较长我简化成几个关键字段字段长度/位置说明起始码前缀8bit16bit0x000001流ID8bit0xE0视频 0xC0音频PES包长16bit从下个字节起到PES包结束的长度标志位8bit是否含PTS/DTSPES头数据长度8bit后面附加头的字节数PTS可选40bit33bit基础值3bit标志位DTS可选40bit同上解析PES时最容易忽略的是“PES头数据长度”。它不是固定值因为标准允许插入PES扩展字段。我写的解析逻辑会先读PES_header_data_length然后直接跳过这个长度再判断下一个字节如果是0x00 0x00 0x01开头的就说明这个是视频帧的开始Access Unit Delimiter或Slice NAL。从PES负载得到的H.264数据可能是多个NALU连在一起。常用的NALU分隔符有00 00 01和00 00 00 01为了避免把数据切错我习惯在解析完PES头之后先搜一遍负载中第一个00 00 01的位置从那里开始当作真正的码流输出。因为你不能保证H.264编码器输出的第一字节就是NAL头万一PES头长度算错了不从NAL边界开始后面解码器就一定报错。下面是我实现里最核心的PES解析循环bool parse_pes(uint8_t* buf, int len, PesPacket out) { if (len 9) return false; if (!(buf[0] 0x00 buf[1] 0x00 buf[2] 0x01)) return false; out.stream_id buf[3]; out.packet_length (buf[4] 8) | buf[5]; uint8_t flags buf[6]; out.header_data_length buf[8]; int pes_header_size 9 out.header_data_length; if (pes_header_size len) return false; // 解析PTS如果存在 if (flags 0x80) { // 从buf[9]开始是PTS uint32_t pts extract_pts(buf[9]); out.pts pts; } // 负载起始偏移 int payload_offset pes_header_size; out.payload buf payload_offset; out.payload_len len - payload_offset; return true; }3.5 视频帧重组把分片PES合成一帧PES包对应的是单个访问单元对应到视频领域基本上就是一帧。但一帧图像的数据量很大往往会跨越多个PS包也就是说同一个PES包的payload会分散在多个PS包里。我处理这个问题的方法是用一个累积缓冲区每当PES包的stream_id是视频流且当前解析器的状态是“等待帧起始”时就把PES负载追加到缓冲区当检测到下一个PES包起始码到来时说明上一帧数据结束了这时把累积缓冲区里的数据作为完整的一帧交给下游。这个“靠PES包边界判断帧边界”的思路在信令封装正确时是可行的。但实际遇到某些设备在一个PES包里塞了多帧数据或者一个视频帧被拆成了两个PES包这时候如果还按PES包来切帧就会出问题。更稳妥的切帧方式是根据H.264的NALU类型来判断。帧边界通常在SPS/PPS之后第一个非视频编码层的NALU比如SEI、关键帧的IDR或者ACCESS_UNIT_DELIMITER。我的做法是在拿到PES负载后继续解析NAL头通过nal_unit_type为5IDR或1非IDR来判断当前Slice是否属于新的访问单元。虽然多了解析步骤但换来的帧边界可靠性是值得的。4. 实际排查常见问题与调试技巧4.1 花屏、卡顿丢包和乱序怎么处理做GB28181接入网络不是你控制的。设备端可能在弱网环境下也可能在跨公网传输时产生大量丢包。PS流本身没有前向纠错丢了包这一帧基本就废了。我的处理经验是放弃整帧而不是试图修复。当检测到某个PS包的序号区间不连续时直接丢弃当前在累积缓冲区里的半个帧同时清空PES状态等到下一个起始码重新开始解析。这样做虽然会丢一帧画面但能避免解码器因为数据错位而一路解码失败到花屏。还有一个容易被忽视的点RTP包头有padding位和扩展位。如果RTP头里padding位为1RTP负载末尾有几个字节是填充数据要跳过如果extension位为1头部后面还有扩展头。做PS解析前一定要正确计算RTP payload的起始位置和长度。这个错误很隐蔽因为大多数时候设备不填充一旦某个设备开了扩展你就会被多出来的几个字节搞乱解析。4.2 时间戳错乱PTS解析后要除以90PS容器里的PTS/DTS单位是90kHz。这个单位来源于MPEG-2标准一秒钟有90000个时间刻度。换算成毫秒要除以90。而RTP时间戳默认也是90kHz理论上一帧的PTS和RTP timestamp是同一个值可以直接用RTP的时间戳做帧的时间戳。但实际项目中海康的某些固件在RTP和PS层的时间戳单位处理上不完全一致偶尔会出现毫秒级偏移。所以我建议如果下游播放器或录像模块对时间戳敏感优先用PS里的PTS因为它更贴近标准定义而不要直接用RTP时间戳做帧的播放时刻。4.3 解析了半天没输出先检查PSM和系统头如果代码写完但下游始终拿不到数据最常见的原因是PS起始码找错位置起始码搜索时没有按字节对齐。PSM里的流类型判断不对导致PES的stream_id被跳过。设备配置里开了音频但你的解析器只认视频流的0xE0没认0xC0。我的调试技巧是用一段录制好的真实PS流做离线测试逐字节打印数据。先用Hex Fiend或010 Editor打开录制的文件手动找到起始码对照规范计算偏移看代码里输出的是否一致。这一步能省掉大量联调时间。等离线数据全部解析正确再上线和真机联调。下面是排查问题速查表现象可能原因解决办法画面花屏RTP丢包导致PS数据不连续检测序号连续区间断开重同步只出音频不出视频PSM流类型/ES ID匹配错误打印PSM字段核对stream_id画面延迟越来越大重排缓冲设置过长调低RTP重排等待时间解码器报NAL错误PES头长度解析错误逐字节对比PES头字段偶尔卡顿一下起始码搜索吃掉了负载字节保证只在PES边界搜索不在负载内搜索4.4 关于H.265和音频流的扩展国标目前对H.265的支持已经很普遍了PSM里对应的stream_type是0x24。如果你的代码里只写了H.264的分支遇到H.265设备就会解析出PES数据但无法解码。建议解析时不要写死编码把stream_type传下去由解码器去判断。PS解包这层逻辑对H.264和H.265是通用的唯一区别是最终输出的裸流格式从AVCC转Annex-B时基本不用转因为你已经是复制的NAL单元。但H.265的VPS/SPS/PPS解析要单独处理切帧逻辑也要兼容nal_unit_type的HEVC定义。音频这一块如果平台业务不需要存储音频可以在PES解析阶段直接丢弃0xC0开头的PES包这样能省点内存。但如果需要音视频同步就一定要解析音频PES的PTS并和视频PTS做对齐否则播放端声音和画面能差出几百毫秒。5. 解包代码之外的几点经验最后说几个我踩过几轮坑之后沉淀下来的经验。第一PS流解析一定要做成有状态机。不要试图用一个函数把整包解析完而是维护一个PS_PARSE_STATE找起始码、解析PS头、找PSM、解析PES头、提取负载每来一个RTP包就往状态机里喂数据。这样代码清晰而且遇到异常数据时状态机可以退回“找起始码”状态很快重新同步。我第一次写的时候用了一个大循环硬着头皮遍历整个包后来在弱网环境下一遇丢包就崩溃。第二解包器的输出要设计成帧级回调。也就是解析完一帧H.264数据后回调一个带nalu_list、pts、key_frame标志的结构体给上层。不要直接把PS原始字节抛给上层否则上层还要重新做一遍解包等于白干。第三做接入平台时一定要给解包器加监控指标。我通常会统计每秒收到的PS包数、解析失败的包数、RTP丢包率、PES重组成功帧数。这些指标在排查问题时比日志好用得多。很多偶发性的花屏靠看日志看不出来但一看丢包率统计问题基本就明白了。说到底PS流解包是一个典型的“看着简单、做起来全是细节”的活。只要把起始码、PS头、PSM、PES头这一条链路吃透了再配合RTP组的鲁棒处理和帧重组最后落地其实并不难。希望这篇分享能让你在对接国标设备的时候少走两步弯路。本文还有配套的精品资源点击获取