GB28181流媒体实战:PS流解包分离H264与AAC并RTMP推流

发布时间:2026/10/14 11:21:19
GB28181流媒体实战:PS流解包分离H264与AAC并RTMP推流 1. 从PS流里把H264和AAC抠出来为什么这一步是整个链路最容易翻车的地方做GB28181流媒体接入的朋友大概率都有过这样的经历设备注册、心跳、INVITE信令都跑通了RTP包也哗哗地往回调端口上打结果一到把PS流解成裸流再推RTMP这一步画面要么花屏、要么卡成PPT、要么干脆黑屏日志里还看不到明显报错。我前后在三个项目里踩过这个坑最离谱的一次是调试了整整两天最后发现是PS包头的program_stream_map里有个字段没跳过导致后面所有偏移全错位。这篇内容就专门聊第六步——解包RTP里的PS流把H264视频和AAC音频分离出来再封装成RTMP推出去。前面几步SIP信令交互、RTP收包、PS流缓存默认你已经跑通了这里只聚焦解包和推流这两个动作。适合已经用JavaSpringBoot搭好GB28181接入框架、卡在媒体处理环节的开发者也适合想搞清楚PS封装格式到底长什么样的同学。整篇会从PS流的字节结构讲起一路讲到RTMP封包中间穿插我实际调试时总结的偏移计算、时间戳对齐、关键帧判断这些细节。先说结论PS解包的核心不是解析而是偏移量管理。PS流本质是一层套一层的TLV结构每一层都有自己的长度字段你只要保证每一层的偏移都算对剩下的就是按图索骥。真正难的是边界情况——一个RTP包里的PS数据可能被截断、一个PS包可能跨多个RTP包、PES包的长度字段可能是0表示长度未知。这些才是让程序在实验室跑得好好的、一上生产就崩的元凶。2. PS流字节结构拆解从 pack_header 到 PES 包的完整偏移链路2.1 PS流的三层结构PS头、系统头、PES包PSProgram Stream流是MPEG-2系统层的一种封装格式GB28181里设备把H264和AAC打包成PS流再切成RTP包发送。一个完整的PS流由若干单元组成每个单元都以00 00 01 XX这样的起始码开头。XX的不同取值代表不同类型的单元起始码单元类型说明00 00 01 BAPack HeaderPS包头每个PS包开头都有00 00 01 BBSystem Header系统头描述流的基本信息可选00 00 01 E0PES Video视频PES包承载H264数据00 00 01 C0PES Audio音频PES包承载AAC/G711数据00 00 01 BDPES Private私有流GB28181里有时用来传PSM00 00 01 BCProgram Stream MapPSM描述节目流映射解析的时候就是不断在缓冲区里找这些起始码找到之后根据类型读取对应的头部长度跳过头部取出payload。听起来简单但坑就在头部长度这四个字上——不同类型的头部长度不一样而且有些头部里还有可变长度字段。2.2 Pack Header 的14字节固定结构Pack Header是PS流里最规整的部分固定14字节不含起始码的3字节。结构如下字节0-3: 00 00 01 BA (起始码) 字节4: 0010 MPEG2标志 SCR高3位 字节5-9: SCR剩余部分 program_mux_rate 字节10-13: program_mux_rate剩余 pack_stuffing_length实际解析时我一般直接跳过这14字节因为Pack Header里没有我们需要的信息。但要注意有些设备的Pack Header后面会跟stuffing字节长度由pack_stuffing_length的低3位决定。如果你不跳过这些stuffing字节后面找起始码就会错位。// 跳过Pack Header int packHeaderLen 14; int stuffingLen psBuffer[13] 0x07; int totalSkip packHeaderLen stuffingLen;这个stuffingLen就是第一个容易翻车的地方。我见过某厂商的设备Pack Header后面固定跟8字节stuffing但pack_stuffing_length字段写的是0如果你信了字段值后面全乱。所以稳妥的做法是跳过14字节后直接往后找下一个00 00 01起始码而不是盲目相信长度字段。2.3 System Header 与 PSM 的处理策略System Header00 00 01 BB的长度是可变的紧跟在起始码后面的2字节表示header_length然后跳过这个长度即可。PSM00 00 01 BC同理也是2字节长度字段。这两个单元对解包来说都是噪音直接跳过就行。但跳过的逻辑要写对if (isStartCode(psBuffer, offset, 0xBB) || isStartCode(psBuffer, offset, 0xBC)) { int headerLen ((psBuffer[offset 4] 0xFF) 8) | (psBuffer[offset 5] 0xFF); offset 6 headerLen; // 3字节起始码 2字节长度 headerLen continue; }注意这里offset 4和offset 5的取值因为起始码占3字节长度字段从第4字节开始。这个偏移我一开始写成offset 3结果长度读出来永远是错的排查了半天才发现是起始码占了3字节不是2字节。2.4 PES包真正承载音视频数据的地方PES包是我们要重点处理的对象。它的结构比前面几个复杂因为PES头部有可变长度字节0-2: 00 00 01 E0/C0 (起始码E0视频C0音频) 字节3-4: PES_packet_length (PES包总长度可能为0) 字节5: 10 PES_scrambling PES_priority ... 字节6: PTS_DTS_flags ESCR_flag ... 字节7: PES_header_data_length 字节8-...: PTS/DTS 等可选字段 ...: payload (H264 NALU 或 AAC 数据)关键点在于PES_header_data_length这个字段它告诉你从第8字节开始有多少字节是PES头部扩展主要是PTS/DTS跳过后才是真正的payload。int pesHeaderDataLen psBuffer[offset 8] 0xFF; int payloadStart offset 9 pesHeaderDataLen;PTS的提取也要看PTS_DTS_flagsint ptsDtsFlags (psBuffer[offset 7] 0xC0) 6; long pts 0; if (ptsDtsFlags 0x02 || ptsDtsFlags 0x03) { // 有PTS pts ((long)(psBuffer[offset 9] 0x0E) 29) | ((long)(psBuffer[offset 10] 0xFF) 22) | ((long)(psBuffer[offset 11] 0xFE) 14) | ((long)(psBuffer[offset 12] 0xFF) 7) | ((long)(psBuffer[offset 13] 0xFE) 1); }这段PTS提取代码看着吓人其实就是把5个字节里分散的33个bit拼起来。我建议直接抄这段因为手写很容易把移位搞错。PTS的单位是90kHz后面推RTMP的时候要转成毫秒。3. 缓冲区管理为什么你的解包代码在实验室能跑、上生产就崩3.1 RTP包与PS包的边界问题这是整个解包环节最核心的难点。RTP包大小一般不超过1500字节受MTU限制而一个PS包可能远大于这个值所以一个PS包会被切分到多个RTP包中。反过来一个RTP包里也可能包含多个完整的PS单元。你不能假设一个RTP包就是一个完整的PS包也不能假设PS单元一定在RTP包边界对齐。正确的做法是维护一个字节缓冲区把收到的RTP payload不断追加进去然后从缓冲区头部开始解析解析完一个单元就移动读指针解析不完整就等下一个RTP包。public class PsBuffer { private byte[] buffer new byte[1024 * 1024]; // 1MB缓冲 private int writePos 0; private int readPos 0; public void append(byte[] data, int len) { // 如果剩余空间不够先compact if (writePos len buffer.length) { compact(); } System.arraycopy(data, 0, buffer, writePos, len); writePos len; } private void compact() { int remaining writePos - readPos; System.arraycopy(buffer, readPos, buffer, 0, remaining); readPos 0; writePos remaining; } }缓冲区大小设1MB是个经验值。太小了容易频繁compact太大了浪费内存。GB28181场景下一个I帧的PS包可能几百KB1MB足够容纳。3.2 起始码扫描不要用indexOf很多人第一反应是用Arrays.indexOf或者字符串查找来找00 00 01这是大忌。原因有两个一是00 00 01可能出现在payload数据里H264的NALU里完全可能出现这个序列二是效率低。正确的做法是从readPos开始逐字节扫描找到起始码后判断类型private int findStartCode(int from) { for (int i from; i writePos - 3; i) { if (buffer[i] 0x00 buffer[i1] 0x00 buffer[i2] 0x01) { return i; } } return -1; }但这里有个细节H264的NALU本身也可能以00 00 01开头作为NALU分隔符。所以你不能一找到00 00 01就当成PS单元起始码还要看第4个字节是不是BA/BB/BC/E0/C0/BD这些合法值。private boolean isValidPsStartCode(int pos) { if (pos 3 writePos) return false; int code buffer[pos 3] 0xFF; return code 0xBA || code 0xBB || code 0xBC || code 0xE0 || code 0xC0 || code 0xBD; }这个校验能过滤掉绝大部分误判。但还有一种情况H264的NALU里恰好出现00 00 01 E0这样的序列。这种概率极低但理论上存在。彻底解决的办法是先解析PES包的长度字段按长度跳过payload而不是在payload里继续找起始码。这就是为什么PES包的PES_packet_length字段很重要——虽然它可能为0但非0的时候一定要用它来定位下一个单元。3.3 PES_packet_length 为0时的处理GB28181里视频PES包的PES_packet_length经常是0表示长度未知直到下一个起始码为止。这时候你只能靠扫描下一个起始码来确定当前PES包的结束位置。int pesLen ((buffer[offset 4] 0xFF) 8) | (buffer[offset 5] 0xFF); int payloadEnd; if (pesLen 0) { // 长度未知找下一个PS起始码 int nextStart findNextPsStartCode(payloadStart); payloadEnd (nextStart -1) ? writePos : nextStart; } else { payloadEnd offset 6 pesLen; }这里有个坑如果nextStart -1说明当前缓冲区里还没有下一个起始码意味着这个PES包还没收完要等更多数据。这时候不能直接处理要保留数据等下一个RTP包。4. H264与AAC的分离NALU边界判断和AAC帧头处理4.1 从PES payload里提取H264 NALUPES视频包的payload就是H264的NALU序列。H264的NALU以00 00 00 01或00 00 01开头4字节或3字节起始码后面跟NALU头和NALU数据。private void extractH264(byte[] payload, int start, int end) { int pos start; while (pos end - 4) { // 找NALU起始码 int naluStart -1; if (payload[pos] 0 payload[pos1] 0 payload[pos2] 0 payload[pos3] 1) { naluStart pos 4; } else if (payload[pos] 0 payload[pos1] 0 payload[pos2] 1) { naluStart pos 3; } if (naluStart -1) { pos; continue; } // 找下一个起始码确定NALU结束 int naluEnd findNextNaluStart(payload, naluStart, end); if (naluEnd -1) naluEnd end; int naluType payload[naluStart] 0x1F; // naluType 5 是IDR帧关键帧 // naluType 7 是SPS // naluType 8 是PPS // naluType 1 是普通帧 pos naluEnd; } }NALU类型判断是推流的关键。RTMP推流时每个关键帧前面必须带SPS和PPS否则播放器解不出来。有些设备的PS流里SPS/PPS是单独发的有些是跟IDR帧一起发的你要根据实际情况缓存SPS/PPS。4.2 AAC帧的ADTS头处理GB28181里的音频一般是AACPES音频包的payload是ADTS格式的AAC帧。ADTS头7字节无CRC或9字节有CRC结构如下字节0-1: syncword (0xFFF) MPEG版本 Layer protection_absent 字节2-3: profile sampling_frequency_index channel_configuration 字节4-5: frame_length (包含ADTS头的整帧长度) 字节6: buffer fullness推RTMP的时候AAC需要的是不带ADTS头的裸AAC数据加上RTMP的AAC头。所以你要把ADTS头剥掉private void extractAac(byte[] payload, int start, int end) { int pos start; while (pos end - 7) { if ((payload[pos] 0xFF) 0xFF (payload[pos1] 0xF0) 0xF0) { int protectionAbsent payload[pos1] 0x01; int headerLen protectionAbsent 1 ? 7 : 9; int frameLen ((payload[pos3] 0x03) 11) | ((payload[pos4] 0xFF) 3) | ((payload[pos5] 0xE0) 5); // 裸AAC数据从 posheaderLen 开始长度 frameLen-headerLen byte[] rawAac Arrays.copyOfRange(payload, pos headerLen, pos frameLen); // 推RTMP... pos frameLen; } else { pos; } } }frameLen的计算是ADTS里最容易写错的地方三个字节的bit分布很反直觉。我建议直接抄上面这段自己推导容易出错。4.3 时间戳对齐PTS转RTMP时间戳RTMP的时间戳单位是毫秒而PS流里的PTS单位是90kHz。转换公式long ptsMs pts / 90;但直接除会有累积误差而且音视频要保证同步。我的做法是以第一个视频PTS为基准后续所有时间戳都减去这个基准值if (basePts -1) basePts pts; long relativePts pts - basePts; long timestampMs relativePts / 90;音频同理用同一个basePts。这样音视频的时间戳就是相对同一个起点的播放器能正确同步。注意有些设备的音频PTS和视频PTS不是同一个时钟源差值可能很大。如果发现音视频不同步先打印两边的PTS看看差值是否稳定。如果稳定可以在音频时间戳上加一个固定偏移量来对齐。5. RTMP封包与推流从裸流到可播放的最后一公里5.1 RTMP连接建立与握手RTMP推流的第一步是建立连接。Java里可以用现成的库比如flazr或者rtmp-client但如果你想像我一样自己控制细节也可以手写。握手过程是客户端发C01字节版本号固定3 C11536字节随机数服务端回S0 S1 S2客户端发C2握手完成开始发AMF0命令// 简化版握手 socket.getOutputStream().write(new byte[]{0x03}); byte[] c1 new byte[1536]; new Random().nextBytes(c1); socket.getOutputStream().write(c1); // 读S0S1S2...握手之后要发connect、createStream、publish三个AMF0命令。这部分比较繁琐建议用现成库。我用的是自己封装的一个轻量RTMP客户端核心就是AMF0编码和chunk分块。5.2 RTMP chunk分块为什么你的大帧推不出去RTMP的默认chunk size是128字节意味着一个大的视频帧要切成很多个chunk发送。每个chunk有1字节的基本头格式chunk stream id和3字节的消息头时间戳消息长度类型。private void writeChunk(byte[] data, int type, long timestamp, int streamId) { int chunkSize 128; int offset 0; boolean first true; while (offset data.length) { int len Math.min(chunkSize, data.length - offset); // 写chunk头 if (first) { // 完整的11字节消息头 out.write(0x03); // fmt0, csid3 writeInt24(out, (int)timestamp); writeInt24(out, data.length); out.write(type); writeInt32LE(out, streamId); first false; } else { // 1字节头表示继续上一个chunk out.write(0xC3); // fmt3, csid3 } out.write(data, offset, len); offset len; } }这里有个优化点发送connect命令后服务端会返回Window Acknowledgement Size和Set Chunk Size你可以把chunk size调大到4096或更大这样大帧的chunk数量会少很多推流效率更高。// 收到Set Chunk Size后更新 chunkSize 4096;5.3 视频帧封装AVC sequence header 与 NALURTMP推H264视频需要两种类型的消息AVC sequence header包含SPS和PPS在推流开始时发一次之后关键帧前也可以重发AVC NALU实际的视频帧数据AVC sequence header的格式字节0: 0x17 (frameType1关键帧, codecId7 AVC) 字节1: 0x00 (AVCPacketType0, 表示sequence header) 字节2-4: 0x000000 (composition time) 字节5-...: AVCDecoderConfigurationRecordAVCDecoderConfigurationRecord的结构是字节0: 0x01 (configurationVersion) 字节1: profile 字节2: profile_compatibility 字节3: level 字节4: 0xFF (6位保留 2位lengthSizeMinusOne) 字节5: 0xE1 (3位保留 5位numOfSPS) 字节6-7: SPS长度 字节8-...: SPS数据 ...: numOfPPS PPS长度 PPS数据这个结构我第一次写的时候漏了lengthSizeMinusOne导致播放器解析SPS失败。lengthSizeMinusOne一般是3表示NALU长度用4字节表示。视频帧的封装字节0: 0x17 (关键帧) 或 0x27 (普通帧) 字节1: 0x01 (AVCPacketType1, 表示NALU) 字节2-4: composition time (一般填0) 字节5-...: NALU数据每个NALU前加4字节长度注意RTMP里的NALU不带起始码而是用4字节长度前缀。所以你要把H264的00 00 00 01起始码替换成4字节的NALU长度。private byte[] wrapVideoFrame(Listbyte[] nalus, boolean isKeyFrame, long timestamp) { ByteArrayOutputStream baos new ByteArrayOutputStream(); baos.write(isKeyFrame ? 0x17 : 0x27); baos.write(0x01); baos.write(new byte[]{0, 0, 0}); // composition time for (byte[] nalu : nalus) { baos.write(intToBytes(nalu.length)); baos.write(nalu); } return baos.toByteArray(); }5.4 音频帧封装AAC raw 与 AudioSpecificConfig音频的sequence header是AudioSpecificConfig格式字节0: 0xAF (soundFormat10 AAC, soundRate3 44kHz, soundSize1, soundType1) 字节1: 0x00 (AACPacketType0, 表示sequence header) 字节2-3: AudioSpecificConfig (一般是0x12 0x10表示AAC-LC 44.1kHz 立体声)音频帧字节0: 0xAF 字节1: 0x01 (AACPacketType1, 表示raw AAC) 字节2-...: 裸AAC数据不带ADTS头AudioSpecificConfig的两个字节编码了音频的采样率、声道数等信息。如果你不知道具体值可以从ADTS头里推导// 从ADTS头推导AudioSpecificConfig int profile (adts[2] 0xC0) 6; // AAC profile int sampleRateIndex (adts[2] 0x3C) 2; int channelConfig ((adts[2] 0x01) 2) | ((adts[3] 0xC0) 6); int audioSpecificConfig (profile 1) 11 | sampleRateIndex 7 | channelConfig 3;6. 实测中的几个典型故障与排查思路6.1 画面花屏90%是SPS/PPS没发对花屏是最常见的故障。我遇到过的花屏原因按概率排序SPS/PPS没发或发错播放器拿不到解码参数只能瞎解。排查方法是抓RTMP包看第一个视频消息是不是0x17 0x00开头的sequence header。NALU长度前缀写错RTMP要求4字节大端长度如果你写成小端或者2字节播放器会读错边界。关键帧判断错误把普通帧当关键帧发或者反过来。NALU类型5是IDR帧类型1是普通帧类型7/8是SPS/PPS。PES包截断一个PES包跨多个RTP包但缓冲区管理没做好导致payload不完整。排查花屏我一般用两个工具一是抓RTMP流用ffmpeg -i rtmp://... -c copy out.flv存成文件然后用ffprobe看流信息二是打印每个视频帧的NALU类型和时间戳看关键帧间隔是否正常。6.2 音视频不同步时间戳基准没对齐音视频不同步的表现是画面和声音差几百毫秒到几秒。根因通常是音视频用了不同的时间戳基准。我的排查步骤打印每个视频帧和音频帧的原始PTS看差值是否稳定如果差值稳定说明是基准问题统一减去第一个视频PTS即可如果差值不稳定说明设备端音视频时钟不同源需要在音频时间戳上加动态补偿// 动态补偿示例 long audioOffset 0; if (Math.abs(audioPts - videoPts) 90000) { // 差超过1秒 audioOffset videoPts - audioPts; } long audioTimestamp (audioPts audioOffset - basePts) / 90;6.3 推流卡顿chunk size 和缓冲区大小没调优推流卡顿一般是两个原因chunk size太小导致发送次数过多或者socket缓冲区太小导致阻塞。socket.setSendBufferSize(1024 * 1024); // 1MB发送缓冲 socket.setTcpNoDelay(true); // 禁用Nagle算法降低延迟setTcpNoDelay(true)这个设置很关键。默认情况下TCP会攒一批数据再发虽然提高了吞吐量但增加了延迟。对于实时流媒体宁可牺牲一点吞吐量也要降低延迟。6.4 内存泄漏缓冲区没释放长时间运行后OOM大概率是缓冲区没释放。我见过最典型的是ByteArrayOutputStream在循环里不断创建但没重置或者PsBuffer的compact逻辑写错导致writePos一直增长。排查方法是用jmap -histo看对象数量如果byte[]数量持续增长就是缓冲区问题。修复方法是在compact后确保readPos和writePos正确重置private void compact() { int remaining writePos - readPos; if (remaining 0) { System.arraycopy(buffer, readPos, buffer, 0, remaining); } readPos 0; writePos remaining; }7. 几个让代码更稳的工程化细节7.1 用状态机管理PS解析流程PS解析本质是一个状态机等待Pack Header → 跳过System Header → 解析PES包 → 提取payload → 回到等待。用状态机写比一堆if-else清晰得多也更容易处理数据不完整的情况。enum PsState { WAIT_PACK_HEADER, WAIT_PES_HEADER, READ_PES_PAYLOAD }每个状态下如果数据不够就返回并等待更多数据下次从同一个状态继续。这样即使一个PS单元跨10个RTP包也能正确解析。7.2 日志要打关键信息不要打全量数据调试的时候很容易犯的错是把每个RTP包的hex都打出来结果日志文件几分钟就几个G。正确的做法是只打关键信息每个PS单元的类型和长度每个PES包的PTS和payload长度每个NALU的类型和长度异常情况起始码找不到、长度字段异常log.debug(PES video: pts{}, payloadLen{}, naluCount{}, pts, payloadLen, naluCount);7.3 异常要兜底不能让一个坏包搞崩整个服务生产环境里设备发来的流不可能100%规范。遇到异常数据时正确的做法是丢弃当前PS单元从下一个起始码重新开始而不是抛异常终止。try { parsePesUnit(); } catch (Exception e) { log.warn(Parse PES failed, skip to next start code, e); skipToNextStartCode(); }这个兜底逻辑能极大提高服务的稳定性。我在一个项目里加了这段之后服务连续跑了三个月没重启过。7.4 性能优化避免频繁的内存分配解包过程中会频繁创建byte[]如果每帧都newGC压力会很大。优化方法是复用缓冲区private final ThreadLocalbyte[] naluBuffer ThreadLocal.withInitial(() - new byte[1024 * 1024]);或者用Netty的ByteBuf它支持引用计数和池化性能比原生byte[]好很多。如果你的项目已经引入了Netty强烈建议用ByteBuf来管理PS流缓冲区。8. 写在最后这套方案的实际表现和后续可扩展的方向这套PS解包RTMP推流的方案我在两个GB28181接入项目里实际用过。单路1080P25fps的流CPU占用在5%左右4核机器延迟从设备到播放器大概1.5秒其中解包环节占不到100毫秒。稳定性方面加了异常兜底之后连续运行一个月没有出现服务崩溃。有几个后续可以优化的点如果你也在做类似的东西可以参考一是支持多路并发。单路跑通之后多路并发的瓶颈一般在RTMP连接数和线程模型上。我的做法是每路流一个独立线程用线程池管理避免线程数爆炸。二是支持H265。现在越来越多的设备支持H265PS流里的起始码和NALU类型跟H264不一样但整体解析框架可以复用主要是NALU类型判断和RTMP封装格式要改。三是加一个流状态监控。记录每路流的帧率、码率、丢包数方便排查问题。我用的是一个简单的滑动窗口统计每5秒输出一次对定位问题帮助很大。最后分享一个调试小技巧如果怀疑是PS解包的问题可以先用ffmpeg -i input.ps -c copy output.mp4把PS流存成文件然后用ffprobe看能不能正常解析。如果ffmpeg都解不出来那肯定是PS流本身有问题不用怀疑自己的代码。如果ffmpeg能解但你的代码不行那就是偏移量或者边界处理的问题对着本文的偏移表逐字节核对就行。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询