☰
GB28181 PS流解包:RTP到H.264的解析与避坑指南
2026/10/15 13:35:05 网站建设 项目流程

简介:面向 28181/PS 流解包场景,这份代码服务于 DVB 地面数字电视与流媒体开发场景,适合需要理解 MPEG-2 系统层、实现 PS 流解析模块的开发者。资源围绕 PS 流中的包头识别、PID 过滤、PES 包重建、基本流解码以及 PTS/DTS 时间同步等核心环节展开,能帮助解决从传输流中分离视频音频数据的问题。压缩包共 2 个文件,包含 1 个 cpp 和 1 个 h,整体约 3KB;头文件负责解析器类与接口声明,源文件承载核心解析逻辑和算法实现,代码紧凑,便于嵌入项目或对照规范研读。已有 853 人浏览学习,说明该主题在数字电视相关开发群体中有一定参考需求。通过学习这套代码,可以快速建立 28181 PS 流解包的整体思路,掌握如何按 PID 筛选数据、重组 PES 包并处理时间戳,为后续自研解析工具或调试现有播放链路提供直接的代码参考。

1. 28181 PS流解包:信令通了、RTP 来了,屏幕还是黑的

某安防项目联调现场,SIP 信令握手全部正常,RTP 媒体也持续到达,播放窗口却一直是黑屏。抓包一看,负载不是裸的 H.264,而是一串以 00 00 01 BA 开头的 PS 数据。28181 PS流解包要做的事,就是把 RTP 里一段段 PS/PES 包拆开,找回藏在 PES 负载里的 H.264 访问单元,重新拼成解码器认识的 AnnexB 码流。这个环节是平台接入、拉流网关、录像存储通用的前置工序,信令做得再漂亮,这里断了画面照样出不来。本文给一套可以直接改造的最小 C 解析器,把 RTP/PS 的边界、PES 的 PTS 时基、丢包与 Marker 的坑一次说清楚。

2. 先看懂 PS 流在 RTP 里的包扎顺序:从净荷到 PES 的一层层关系

2.1 28181 为什么选 PS 而不是 TS:先理解封装动机再写解析器

PS 与 TS 都源于 MPEG-2 系统层,但是两种不同的容器。TS 是 188 字节固定长度的小包,抗丢包、易同步,适合广播和有损链路;PS 是可变长包,按“一路节目”组织,比较适合存储与可靠通道。28181 的媒体通道跑在 SIP/RTP 会话上,链路通常是可控的局域网或专网,用 PS 封装既能记录完整访问单元,又方便录像回放时做索引。

很多新手会问,为什么不用更常见的裸 H.264 over RTP?因为国标要求信令协商完成后再拉流,而不同的设备厂商对裸流封装差异太大,统一成 PS 反而更容易兼容。规范里明确定义了媒体流使用 RTP/PS,所以设备侧出来的包必然是 PS 结构。写解包代码前先接受这个设定:不要期待 payload 直接是00 00 00 01 67,要先看到00 00 01 BA。

PS 与 TS 的差异直接决定了解包策略。

对比项PSTS
包长度可变,随 PES 内容变化188 字节固定
抗丢包弱,丢一个 RTP 包可能错位强,可重新同步
定位方式扫描起始码 00 00 01按 PID + 连续性计数
典型场景点播、录像、SIP 会话广播、数字电视、弱网
28181 使用媒体通道强制采用一般不直接使用

这个表也解释了挖坑的重点:解 PS 流时,任何异常丢包都可能让整段解析错位,所以解包器必须始终具备“重新找 00 00 01”的能力。

2.2 RTP 净荷里常见的三种装法:先判断形态再决定缓冲策略

PS 流作为一种流式封装,进入 RTP 后遵循的不是“一个 RTP 包 = 一个 PS 包”的简单关系。我在接入不同厂商设备时,实际见过三种装法。第一种是“一包一 PS 包”,常见于低分辨率小帧,PS 包本身不大,IP 分片都不需要;第二种是“多包一 PS 包”,一个 PS 包被拆成很多个 RTP 包连续发送,常见于 I 帧,因为 I 帧可能上百 KB,超过 MTU 后必须拆;第三种是“一包多 PS 包”,比较少见,一般是调试工具手动拼包导致的。

判断当前属于哪种形态,最直接的参考是 RTP 的 Marker 位。在 PS over RTP 的实践中,Marker 通常标识一个访问单元或一个 PS 包的结束,但不同厂商理解不同——有的把它放在每一帧的最后一个 RTP 包,有的放在每个 PES 包结束位置,还有的干脆不置位。因此解包器可以把 Marker 当作“尝试 flush 一次”的提示,但不能把它当成唯一边界。

这意味着解析器必须有一个跨 RTP 包的拼接缓冲区。到来的每个 RTP 净荷先抄进内部缓冲,再寻找00 00 01起始码,只有这样才能容忍“PS 包头在第 N 个 RTP 包中间、PS 包尾在第 N+3 个 RTP 包里”的常见情况。任何按单个 RTP 包直接尝试解析 NAL 的实现,面对大 I 帧都会翻车。

2.3 PS 包与 PES 包的最小骨架:起始码就是唯一可信的地标

PS 流里的每个重要结构都靠起始码标记。00 00 01 BA是 PS 包起始码;00 00 01 BB是系统头;00 00 01 BC是节目流映射 PSM;00 00 01 E0到00 00 01 EF是视频 PES;00 00 01 C0到00 00 01 DF是音频 PES;00 00 01 BD是私有流 PES。很多 28181 设备只强制发 PS 包和视频 PES,系统头与 PSM 可有可无。

实际解析时不需要把每个字节都按标准位拆出来。我习惯按“地标驱动”的方式写循环:先在缓冲区里找00 00 01,再看第 4 个字节是什么类型的起始码。如果是BA,就跳到下一个起始码,不去深究 pack_header 里的 SCR;如果是BB、BC,同样跳过;如果是E0或BD,进入 PES 解析。这样做的好处是抗错能力强——即使中间有个别字节被干扰,只要还能识别起始码,就能恢复同步。

PES 头有固定的几个字段先要拿到:stream_id在起始码后的第 1 字节;PES_packet_length是接下来的 2 字节大端整数;第 7 字节的高两位是固定10,低 4 位与第 8 字节共同描述了 PTS/DTS 是否存在以及头部长短。注意PES_packet_length在很多设备上并不可靠,尤其 GOP 特别大的时候,所以后面的实现里我只把它当参考值,不做硬边界。

3. 把解包落到代码:一个可直接改造的最小 PS Demux

3.1 先定接口:让上层只关心 H.264 输出,不关心 PS 结构

写任何解析器,我习惯先定义边界。解包器只做一件事:输入 RTP 净荷,输出 AnnexB 格式的 H.264/H.265 NAL 单元。至于 RTP 头剥离、SIP 信令、解码播放,都应该是外部的事情。因此最小接口可以设计成下面这个样子。

typedef struct ps_demux_ctx { uint8_t *buf; /* 内部跨包拼接缓冲区 */ size_t cap; /* 缓冲区容量 */ size_t len; /* 当前已缓存长度 */ uint64_t pending_pts; /* 最近一次解析到的 PTS,90kHz 时基 */ int has_pts; /* 是否已拿到 PTS */ int in_pes; /* 当前是否处于 PES 负载中 */ void (*on_nal)(const uint8_t *data, size_t size, uint64_t pts, void *opaque); void *opaque; /* 回调透传参数 */ } ps_demux_ctx;

接口的核心是on_nal回调。每切出一个完整的 NAL 单元,就调用一次回调,让上层自己去写文件、喂编码器或者统计长度。这样解包器不外泄内部状态,上层想接 FFmpeg 还是裸文件播放器都方便。

缓冲区cap按项目里最大 PS 包设置。普通 1080P 设备一个 I 帧拆出来的 PS 包可能到几百 KB,我一般预留 4MB 甚至 8MB,超出就复位缓冲而不是强制扩展,因为扩展的同时往往意味着同步已丢失。

3.2 跨 RTP 包拼缓冲:start code 永远是找回起点的尺子

接下来是解包器最核心的查找函数。它负责在可能跨多个 RTP 包拼接出来的数据里,找到第一个00 00 01起始码。

static int find_start_code(const uint8_t *p, size_t len, size_t *pos) { for (size_t i = 0; i + 3 < len; i++) { if (p[i] == 0x00 && p[i + 1] == 0x00 && p[i + 2] == 0x01) { *pos = i; return 1; } } return 0; }

这个函数不区分后面的起始码类型,只负责找到地标。找到后,调用方再去看p[*pos + 3]是BA、BC、E0还是BD,据此决定下一步动作。

参数上需要注意两点。一是起始码可能出现在任意字节边界,不能用memmem按“行”或按“包”去找;二是有些设备会在 PS 包之间填充00,此时00 00 01也可能被填充字节影响,需要在主循环里不断重复查找,直到找到可识别的 PS 头或 PES 头。

3.3 PES 头解析:PTS 90kHz 换算与长度容错

拿到 PES 后,先解头。PES 头从00 00 01 E0后开始,第 7 字节的低 4 位和标志位决定后面有没有 PTS。PTS 是 5 个字节的紧凑编码,解出来是 33 位整数,时基 90kHz。把它除以 90 就是毫秒。

static uint64_t read_pts(const uint8_t *p) { return (((uint64_t)(p[0] & 0x0E)) << 29) | (((uint64_t)p[1]) << 22) | (((uint64_t)(p[2] & 0xFE)) << 14) | (((uint64_t)p[3]) << 7) | (((uint64_t)p[4]) >> 1); } int pes_parse_header(const uint8_t *pes, size_t len, uint64_t *pts, int *has_pts, size_t *payload_off) { if (len < 9) return -1; if (pes[0] != 0x00 || pes[1] != 0x00 || pes[2] != 0x01) return -1; uint8_t f = pes[6]; int pts_dts_flags = (f >> 4) & 0x03; *has_pts = (pts_dts_flags == 0x02 || pts_dts_flags == 0x03); size_t hlen = pes[7]; *payload_off = 8 + hlen; if (*payload_off > len) return -1; if (*has_pts && *payload_off + 5 <= len) { *pts = read_pts(pes + 8); } return 0; }

pes[6]高位是固定的10,(f >> 4) & 0x03是 PTS_DTS_flags。0x02表示只有 PTS,0x03表示 PTS 和 DTS 都有。pes[7]是 PES 头数据长度,真正的负载要从8 + hlen开始,而不是从9 + hlen开始,这是新手最容易数错的地方。

PES_packet_length我在这个函数里故意没读。依赖它切负载是危险的,许多厂商实现会把该字段填成整包长度、填零、甚至填出一个超过实际数据的值。正确姿势是把payload_off当成起点,然后继续在负载里找00 00 01,让 NAL 起始码去验证边界。

3.4 从 PES 负载切 NAL:AnnexB 输出与 SPS/PPS 缓存

PES 的负载并不保证从 H.264 的起始码开始。有些设备会在负载最前面塞私有扩展头,有些会先放几个00填充字节。因此切 NAL 时要先找起始码,找到后把当前起始码到下一个起始码之间的数据当作一个 NAL 包输出。

static void emit_nal_units(ps_demux_ctx *ctx, const uint8_t *payload, size_t len) { size_t i = 0; while (i < len) { if (i + 3 >= len) break; if (payload[i] == 0x00 && payload[i + 1] == 0x00 && payload[i + 2] == 0x01) { size_t start = i; if (start > 0 && payload[start - 1] == 0x00) { start--; /* 保留 4 字节起始码,避免漏掉前缀 00 */ } size_t j = i + 3; while (j + 3 < len) { if (payload[j] == 0x00 && payload[j + 1] == 0x00 && payload[j + 2] == 0x01) { break; } j++; } ctx->on_nal(payload + start, j - start, ctx->pending_pts, ctx->opaque); i = j; } else { i++; } } }

这个函数有几个设计点值得说明。第一,start--用于处理四字节起始码00 00 00 01:三字节扫描会把第一个00当成起始码起点,导致输出丢失一个字节。第二,j停在下一个起始码位置,意思是“本 NAL 到下一个 NAL 之间”,正好符合 AnnexB 的边界定义。第三,回调里的pending_pts是上一段 PES 解析出来的时间戳,一个 PES 里多个 NAL 共享同一 PTS 是正常行为,不需要每个 NAL 都更新。

SPS/PPS 这一步常见做法是单独缓存。H.264 的 SPS 起始码后是67,PPS 是68;H.265 对应40、42。拿到这类 NAL 就先存起来,在下一个关键帧 NAL 输出前,把缓存的 SPS/PPS 先回调一次。原因放到下一章的坑里讲。此处在on_nal回调里判断data[4]即可,不要试图解析 SPS 内部比特,解码器会自己处理。

4. 避坑清单:RTP 序号翻转、PES 长度不可信、SPS/PPS 躲猫猫

4.1 黑屏:SPS/PPS 不在视频 PES 里,躲到了私有流

现象:解出来的out.h264文件打开全黑,用 ffprobe 看报Could not find codec parameters,或者解码器一直提示non-existing PPS。

原因:不少设备把 SPS/PPS 放在单独的一个私有 PES 里,常见的就是stream_id = 0xBD的包,里面先放一段私有数据,再带出00 00 00 01 67和00 00 00 01 68。如果解包器只处理0xE0视频流,这些参数就全部丢掉了。

解决:在 PES 识别处不要只认0xE0。遇到0xBD时,同样把负载丢进emit_nal_units试扫一遍。看到67或68开头的 NAL 就缓存,并在下一个 IDR 前把它附加上。这一条能救活很多平台的黑屏问题。

4.2 花屏:PES 长度字段被厂商写错,按它切包就错位

现象:画面能出来,但每隔几秒花一次屏,或者图像上部正常下部全马赛克,用播放器快速拖动时更明显。

原因:PES_packet_length在很多实现里等于整个 PS 包长度而不是 PES 负载长度,少数设备干脆写零。解析器如果按这个字段计算负载末尾,会把下一个 PS 包的开头误算成当前 NAL 的数据,导致 NAL 长度变化,解码器同步失败。

解决:代码里完全不依赖PES_packet_length作为硬边界,只把它当调试信息打印。PES 负载的结束位置由下一个00 00 01起始码决定。遇到BA、BB、BC、E0等任何新起始码,都说明上一个 PES 已经结束。

4.3 解包停滞:RTP 序号翻转与乱序导致错位

现象:拉流刚开始正常,过一段时间后解析器不再输出 NAL,但抓包发现 RTP 还在持续到达,日志里也没有报错。

原因:RTP 序号是 16 位,到0xFFFF后会回到0x0000。如果代码用seq > prev_seq + 1判断丢包,翻转瞬间会误判成大量丢包,直接把拼接缓冲区清空;如果网络再乱序一个包,缓冲区尾部就残留半包,永远等不到完整起始码。

解决:使用有符号差值判断。把两个uint16_t序号的差转成int16_t,比如(int16_t)(seq - prev_seq),正数表示正常递增,负数表示乱序或翻转。乱序超过一定包数再复位,单包乱序可以靠缓冲等待。同时给拼接缓冲区加一个最大滞留时间或最大长度,超过后强制丢弃。

4.4 首包半包:PS 头不在 RTP 净荷开头

现象:用本地 PS 文件测试一切正常,一接真实 RTP 流就解不出第一帧,出现的 NAL 不完整或者直接没有输出。

原因:RTP 净荷里可能带有私有的载荷头,某些设备还会在 PS 包前插入几个厂商自定义字段。更常见的情况是第一个到达的 RTP 包只是某个 PS 包的切片,PS 头在更早的包里已经发出。

解决:永远不要在emit_nal_units前假设payload[0] == 0x00。先把 RTP 净荷整体拼入缓冲,然后find_start_code从头扫描。Marker 置位时,把当前残留数据交给emit_nal_units做一次尝试,无论成功失败都清空缓冲,不然残留数据会污染下一个访问单元。

4.5 音频干扰:把 0xC0 音频 PES 当成视频解析导致丢帧

现象:带音频的通道接进来后,视频出现规律性卡顿,每几秒跳一次帧,去掉音频就不卡。

原因:音频 PES 的stream_id是0xC0开头的段,如果主循环只判断(code & 0xF0) == 0xE0,一看到0xC0就跳过,问题不大;但如果判断写反了,或者把0xBD一并当视频负载解析,音频私有数据就会占掉下一次视频 NAL 的输出位置。

解决:明确分流:(code & 0xF0) == 0xE0是视频,进入 NAL 切分;(code & 0xF0) == 0xC0是音频,直接跳过;code == 0xBD是私有流,只做 SPS/PPS 扫描后跳过。注意解析失败不要清空视频状态,跳过不等于重置。

5. H.265 与私有 PS 扩展:解包器要留的 3 个后门

5.1 先看节目流映射 PSM:不要用 stream_id 猜编码格式

很多 28181 设备在 PS 流里会周期性发送00 00 01 BC的 PSM,里面带stream_type字段。0x1B表示 H.264,0x24表示 H.265。如果一开始就把0xE0认定成 H.264,遇到 H.265 的设备时,解出来的 NAL 仍然以00 00 01开头,解码器却会因为 NAL 头语义不同而报错。

建议在解包器里加一个简单的 PSM 解析分支:遇到BC时,扫描其中的stream_type,记录每个stream_id对应的编码类型。后续回调on_nal时带上codec_type,上层就不会猜。没有 PSM 的老设备,再退回“默认 H.264 + 首个 VCL NAL 探测”的兼容逻辑。

5.2 H.265 的 NAL 类型判断:2 字节头与旧的缓存策略冲突

H.265 的 NAL unit header 是 2 个字节,NAL type 取((data[4] >> 1) & 0x3F),而 H.264 取(data[4] & 0x1F)。直接复用同一套 SPS/PPS 缓存逻辑时,H.265 的 VPS/SPS/PPS 分别是32、33、34,而不是 H.264 的7和8。

务实做法是解包器本身不判断 VCL 类型,只负责按起始码切分;但在缓存参数集时要按编码类型分开存。我的on_nal回调里通过codec_type分支,H.265 时缓存00 00 01 40、00 00 01 42、00 00 01 44,H.264 时缓存67和68。这样同一份解包代码可以同时服务两代编码标准,不用推倒重来。

5.3 私有流后门:0xBD 不只有 SPS/PPS,还有 OSD 与告警数据

部分设备会把 OSD 叠加信息、移动侦侧结果、报警信息塞进0xBD私有 PES。这些数据不是视频 NAL,但如果不扫描,就可能会漏掉前面说的 SPS/PPS;如果扫描后直接当视频输出,又会让解码器花屏。比较好的做法是增加一个开关位priv_passthrough,默认只扫参数集,不开透传。调试时可以打开透传,把0xBD负载单独写一份文件,确认里面到底有什么再决定保留还是丢弃。

这里要给解包器留一个配置接口,哪怕是宏开关也行。我一般在ps_demux_ctx里加一个options位域,至少包含:accept_private_for_parameter_set、priv_passthrough、fallback_to_rtp_ts。这三个后门在设备对接中能省掉大量沟通时间。

5.4 时间戳后门:PTS 异常时回退到 RTP 时间戳

PS 里的 PTS 理论上应该是单调的 90kHz 时钟,但我见过有些设备在码率切换或网络抖动后,PTS 直接跳变几千毫秒甚至回退。这时候如果用 PTS 驱动播放,画面会突然停顿或回放,用户观感非常差。

我习惯在解包器里保留最近一次 PTS 差值检查。当pending_pts - last_pts大于一个阈值(比如 2 秒)或小于 0 时,不再更新pending_pts,而是用当前 RTP 时间戳rtp_ts换算出一个平滑的 PTS。RTP 时间戳同样是 90kHz 基准,换算方式就是rtp_ts直接传给上层。这个兜底逻辑不完美,但至少能让画面保持连续,后续再由解码器做帧间隔修正。

6. 验证解包结果:一条 pipeline 和三个检查点

6.1 三步回归:抓 RTP → 解成 .h264 → 交给 ffprobe

拿到任何新设备,先用测试脚本做一轮快速回归。抓包时只抓媒体端口,导出 RTP 净荷,喂给解包器,然后立刻用 ffprobe 判断结果。

# 抓一段媒体流,端口按实际 SIP 协商结果调整 tcpdump -i any -s 0 -w gb28181.pcap udp port 20000 # 把 RTP 净荷导成 in.bin,执行解包器输出 out.h264 ./ps_demux_demo in.bin out.h264 # 检查编码名、分辨率、帧率 ffprobe -v error -select_streams v:0 \ -show_entries stream=codec_name,width,height,avg_frame_rate \ -of csv=p=0 out.h264

如果 ffprobe 能稳定读出h264,1920,1080,25,说明解包器已经拿到了正确的视频参数集和 VCL 数据。此时把out.h264再转封装成 mp4,然后ffmpeg -v error -i out.mp4 -f null -跑一遍,没有报错就说明文件可正常播放。

6.2 三个检查点:编码名、参数集、时间戳单调性

第一个检查点是codec_name。如果输出是h264,但实际设备配置是h265,说明 PSM 没有解析或者 PES 负载被错切了。第二个检查点是 SPS/PPS 是否在文件头出现。用xxd out.h264 | head看前几行,应当先看到00 00 00 01 67,再看到00 00 00 01 68,缺一个就要回到 0xBD 私有流的缓存逻辑里排查。第三个检查点是时间戳单调性。用ffprobe -show_frames导出pkt_pts,观察相邻帧 PTS 差值是否稳定在帧间隔附近;出现长时间停滞或倒挂,就要打开 5.4 节的 RTP 时间戳兜底。

6.3 一个让凌晨不必重启服务的习惯

踩过几次现场翻车后,我现在每个解包器里都会留一段最原始的调试日志:进入回调时打印首个 VCL 的 NAL type、PTS 和字节数。问题一旦复现,只看这行日志就能定位是“没找到起始码”还是“时间戳异常”,不用再凭感觉抓包重放。把这个习惯写进代码,而不是写进文档,是我最后想分享的一点。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询