简介:这是一份面向数字电视与视频监控开发者的PS流解包模块,基于DVB 28181标准实现MPEG-2系统层数据的解析与节目分离。资源压缩包共含2个源文件,其中programstreamparser.cpp负责PS流头部识别、PID过滤、PES包重组、视频音频基本流提取以及PTS/DTS时间戳同步处理等核心算法,programstreamparser.h则声明了解析器类结构、公开接口函数与相关常量定义,整体仅3KB,代码紧凑且依赖少,适合深入学习并按需裁剪移植到具体项目中。目前已有853人学习使用。通过研读该实现,开发者可以完整掌握从原始PS流到可分路音视频数据的解包链条,明确包头字段含义、PES负载拼接方式以及时间同步机制,并可直接在28181国标接入网关、数字电视接收软件或流媒体分析工具中复用这套解析逻辑,也可借此排查实际码流封装异常问题,是理解数字电视复用与解复用技术很有价值的参考代码。
1. 做 28181 平台接入,为什么绕不开 PS 流解包这一步
做 GB/T 28181 视频接入的工程师,几乎没人能绕开 PS 流解包这一步。SIP 信令把端口和媒体参数协商好之后,前端设备推过来的不是干净的 H.264 原始码流,而是把视频 ES 流按 MPEG-2 系统层标准重新打包成的 PS 流。你要在平台侧还原出能直接送进解码器或转封装服务的视频帧,就必须自己动手把 PS 包一层层剥开。
这篇笔记围绕 PS 流里三个最关键的起始码——0x000001BA、0x000001BC、0x000001E0,把从 RTP 负载到完整 ES 流的整条链路完整过一遍,给出可直接移植的 C 代码和参数说明。读完你能应付绝大多数设备厂商的 28181 码流接入,也知道哪里容易翻车、该在什么地方做容错。
2. 从 SIP 会话到 RTP 数据:PS 流是怎么交到你手里的
2.1 信令协商里的三个关键参数:ps、ssrc 和 RTP 负载类型
28181 的媒体协商走 SIP INVITE 和 200 OK 响应里的 SDP。平台作为 SIP 客户端去 invite 设备,设备回过来的 SDP 里m=行会明确告诉你要往哪个端口收流、用什么格式封装。我遇到过不少刚从 WebRTC 转过来的同事,上来去找 H264 的fmtp参数,结果发现 SDP 里根本没有,而是一行MP4V-ES/PS或者PS/90000之类的东西。
m=video 27268 RTP/AVP 96 a=rtpmap:96 PS/90000 a=ssrc:3183325089 a=control:rtsp://...这段 SDP 是某厂商 28181 设备回包的真实样子,关键信息就三样。第一是媒体端口27268,说明 RTP 流会打到你本机的 UDP 27268 端口,端口由 SIP 交互过程中平台从本机端口池里协商出去,不是固定的。第二是ssrc,这个 32 位整数用来标识一路 RTP 流,多路并发接入时靠它区分不同通道。第三是 RTP 负载类型,老国标设备基本都用96,且用的是动态负载类型,表示承载数据是 PS 流。
这里有一个容易理解错的地方:rtpmap里写的PS/90000,指的是 RTP 时间戳时钟频率是 90000Hz,不是说你收到的 payload 里第一个字节就是0x000001BA。有些设备会先在 RTP payload 前面塞几个自定义字节或一个 4 字节头,解包前不切掉就会导致起始码定位失败,后面第 5 章会细说。
2.2 用 UDP 收下 RTP 包并按序号重排:一个最小实现
SIP 协商完成后,设备就开始向目标端口推 RTP 包。多数 28181 设备的 RTP 封装方式是每帧一个 RTP 包,即一个完整的 PS 包正好塞进一个 RTP payload;但大码流的高清 IPC 可能把一帧拆成多个 RTP 分片,包长超过 MTU 后会走 IP 分片或者在应用层分片。所以收流端不能假设一个 RTP 包就是一个完整 PS 包。
// rtp_receiver.c: 28181 媒体流接收最小逻辑 // 只处理 UDP+RTP, 假设已经通过 SIP 协商拿到 port 和 ssrc int sockfd = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in addr = { .sin_family = AF_INET, .sin_addr.s_addr = INADDR_ANY, .sin_port = htons(media_port) // 信令协商出的本地媒体端口 }; bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)); uint8_t rtp_buf[65536]; struct sockaddr_in peer; while (running) { socklen_t peer_len = sizeof(peer); ssize_t n = recvfrom(sockfd, rtp_buf, sizeof(rtp_buf), 0, (struct sockaddr *)&peer, &peer_len); if (n < 12) continue; // RTP 固定头至少 12 字节 uint32_t ssrc = (rtp_buf[8] << 24) | (rtp_buf[9] << 16) | (rtp_buf[10] << 8) | rtp_buf[11]; if (ssrc != expected_ssrc) continue; // 只收协商好的那一路 uint8_t pt = rtp_buf[1] & 0x7F; if (pt != 96) continue; // 28181 PS 流负载类型为 96 // rtp payload 从第 12 字节开始, 交给后续的 PS 解包器处理 ps_demux_feed(rtp_buf + 12, n - 12, rtp_timestamp(rtp_buf), rtp_seq(rtp_buf)); }这段代码把 RTP 头里最关键的字段先剖开:第 1 字节的低 7 位是负载类型,8 到 11 字节是 SSRC,第 2 到 3 字节是序号,第 4 到 7 字节是时间戳。rtp_timestamp(rtp_buf)返回值需要按大端拼出来,因为 RTP 头完全是网络字节序。
UDP 收包本身不需要处理丢包和乱序,Linux 内核 UDP 协议栈会把到达的数据按端口送上来,但 RTP 序号不会保证递增。同一个视频帧拆成多个 RTP 分片时,分片可能乱序到达,后面ps_demux_feed里要做按照序号重排的缓冲,否则 PS 头解析会直接把包丢弃。
2.3 先做一个小缓冲池:把乱序分片还原成完整的 PS 包
常见做法是用一个环形缓冲加 RTP 序号检查。假设每帧最多拆成 4 个 RTP 分片,缓冲池维护一个起点序号和已收片数。
// ps_assembler.c: 按 RTP 序号重排分片的简化结构 #define MAX_FRAG_PER_FRAME 8 typedef struct { uint16_t first_seq; // 当前帧第一个分片的 RTP seq int count; // 已收分片个数 int total_len; // 累计 payload 长度 uint8_t data[256 * 1024]; // 帧缓冲, 放多个 PS 分片拼接结果 } frame_buffer_t; int rtp_seq_is_contiguous(uint16_t seq1, uint16_t seq2) { return (uint16_t)(seq1 + 1) == seq2; // 考虑 16bit 回绕 }收到新分片时,用它的 RTP seq 与first_seq比较。若是下一片,直接追加到data尾部;若是跳跃序号,说明丢片了,此时不等齐,直接把已有分片拼出的部分数据交给 PS 解包器做容错解析,让解包器去识别完整 PS 包。很多平台做法是"丢片就丢帧",但 PS 包里一个视频帧的数据可能横跨多个 RTP 包,丢一片后整帧无法还原,解包器若不清理内部残留状态,下一帧还会连带出错。
我一般把分片拼接和 PS 解包分开:分片只负责把数据按序串好,解包器只关心字节流里能不能找到0x000001BA开头的完整 PS 包。这样两者可以单独测试,出问题时也容易定位。
3. 拆开一个 PS 包:从 Pack Header 到 PES 的完整路径
3.1 PS 包固定头:以 0x000001BA 为入口
PS 流是 MPEG-2 系统层里 Program Stream 的具体实现,很多行业老工程师叫它"节目流"。0x000001BA是 Pack Header 起始码,一个 PS 包必然以这 4 个字节开头。拿到 RTP payload 后,第一步是在字节流里扫描这个起始码,定位包的起点。
// ps_demux.c: 在字节流中扫描 PS 包头 #define PS_PACK_START 0x000001BA #define PS_SYS_HEADER 0x000001BB #define PS_MAP_START 0x000001BC #define PS_PES_VIDEO_BASE 0x000001E0 int ps_find_start_code(const uint8_t *buf, int len, int *pos) { uint32_t code = 0; for (int i = 0; i < len; i++) { code = ((code << 8) | buf[i]) & 0xFFFFFF; if (code == 0x000001) { if (i + 1 < len) { uint8_t suffix = buf[i + 1]; if (suffix == 0xBA || suffix == 0xBB || suffix == 0xBC || (suffix >= 0xBD && suffix <= 0xFF)) { *pos = i - 3; // 起始码起点在 i-3 处 return suffix; // 返回起始码类型 } } } } return -1; }ps_find_start_code这种写法没有一次读 4 字节对齐,而是逐字节滑窗,好处是能容忍 payload 前面有厂商自定义头的情况。扫描到0x000001后读下一字节判断类型,返回0xBA就是找到了 PS 包边界。
这个函数是后面一切的入口。得到 PS 包起始位置后,还需要知道 PS 包的总长度。PS 包有几种做法:有些厂商在 PES 头的PES_packet_length里给了后续负载长度,加起来就能算到包尾;但更多厂商写0,表示长度不限制,这时只能靠下一个0x000001BA来切边界。所以实际解析器内部维护的是一段足够大的字节流缓冲,一边扫描新包头一边把上一个完整包吐出去。
3.2 越过 System Header 和 PSM,定位真正的 PES 流
找到0x000001BA之后,Pack Header 后面不一定马上就是视频 PES 包。常见布局是:
00 00 01 BA ... Pack Header(可变长) 00 00 01 BB ... System Header(可选, 部分设备会发) 00 00 01 BC ... Program Stream Map(PSM, 通常在视频PES之前) 00 00 01 E0 ... 视频 PES 数据 00 00 01 C0 ... 音频 PES 数据 00 00 01 BA ... 下一个 Pack HeaderPSM 里有一张流映射表,表里每个流条目包含stream_id和stream_type。stream_type的取值沿用了 MPEG-2 的约定,0x1B表示 H.264,0x24表示 HEVC,0x20可能表示 MPEG4。很多 28181 设备的 PSM 会在视频流之前发一次,之后间隔若干帧再插入,它不是每个 PS 包都有。
// ps_demux.c: 从 PSM 里读出 stream_type, 判断编码格式 int ps_parse_map(const uint8_t *buf, int len, int *video_stream_type, int *audio_stream_type) { // buf 指向 0x000001BC 起始码之后位置 int pos = 0; if (buf[pos] != 0x000001BC) return -1; pos += 4; // 跳过起始码本身 // program_stream_map_length 占 2 字节 int map_len = (buf[pos] << 8) | buf[pos + 1]; pos += 2; // 当前/下一刻流条目的起始点在第 10 字节之后 int entries = (buf[pos + 2] << 8) | buf[pos + 3]; pos += 4; for (int i = 0; i < entries && pos + 4 < len; i++) { uint8_t stream_id = buf[pos]; // 1 字节 stream_id uint8_t stream_type = buf[pos + 2]; // 1 字节 stream_type if (stream_id >= 0xE0) { *video_stream_type = stream_type; } else if (stream_id >= 0xC0 && stream_id <= 0xDF) { *audio_stream_type = stream_type; } pos += 4; // 每个映射条目 4 字节 } return 0; }PSM 的每个流映射条目固定 4 字节:stream_id、reserved半字节加 2 位ES_info_length高 4 位、stream_type、最后 1 字节是ES_info_length低 8 位。所以紧凑遍历就是每 4 字节一跳。若你发现解析出的stream_type对不上视频编码格式,先别怀疑代码,去看 PSM 里有没有额外 ES_info 字节,有些厂商会在映射条目后面附加私有数据,这时要按ES_info_length跳过去。
PSM 解析出来最大的价值是告诉你要不要走 H.264 分帧逻辑。老国标设备视频基本是 H.264,但个别厂商能配成 MPEG4,还有符合 SVAC 国标的设备会推送 SVAC 流。不要在拿到 PSM 前就默认是 H.264,否则后面分帧全错。
3.3 PES 头怎么读:stream_id、长度、PTS/DTS
真正承载视频数据的是 PES 包。视频 PES 起始码是0x000001E0到0x000001EF,音频是0x000001C0到0x000001DF。PES 头的基本结构固定九字节打底,然后根据 flags 追加扩展字段。
// ps_demux.c: 解析一个 PES 包的基本头, 返回 PES 负载起点 typedef struct { int stream_id; int pes_len; // PES_packet_length, 0 表示不限制 int64_t pts; // 解码时间戳, 无则返回 -1 int64_t dts; int payload_offset; // 负载数据在 buf 中的偏移 } pes_header_t; static inline int64_t read_pts_33(const uint8_t *p) { int64_t pts = 0; pts = ((int64_t)(p[0] >> 1) & 0x07) << 30; // 高 3 bit pts |= ((int64_t)p[1]) << 22; pts |= ((int64_t)p[2] >> 1) << 15; pts |= ((int64_t)p[3]) << 7; pts |= p[4] >> 1; return pts; } int ps_parse_pes_header(const uint8_t *buf, int len, pes_header_t *h) { if (len < 9) return -1; h->stream_id = buf[3]; h->pes_len = (buf[4] << 8) | buf[5]; uint8_t flags1 = buf[6]; uint8_t flags2 = buf[7]; int pts_dts_flag = (flags2 >> 6) & 0x03; int header_len = buf[8]; // 解析 PTS/DTS 可选项 h->pts = -1; h->dts = -1; int off = 9; if (pts_dts_flag == 0x02 || pts_dts_flag == 0x03) { h->pts = read_pts_33(buf + off); off += 5; } if (pts_dts_flag == 0x03) { h->dts = read_pts_33(buf + off); off += 5; } h->payload_offset = 9 + header_len; if (h->payload_offset > len) return -1; return 0; }read_pts_33是按 MPEG-2 标准里 PTS 的位分布实现的:33 位 PTS 被分成 3 个片段,分别放在 PES 头可选字段的 5 个字节里,每一段里都混有marker_bit,取值时要右移 1 位把它们过滤掉。这个细节经常被无视,导致解析出来的 PTS 比真实值大了一倍左右,音画不同步的 bug 就出在这里。
PES_packet_length为 0 时表示 PES 负载长度未知。要找到负载边界,只能通过后续扫描下一个起始码来完成,这也是为什么 PS 解包器必须维护一个滑窗缓冲而不是简单按长度切包。
4. 把 PES 负载还原成能解码的视频帧:NALU 边界与时间戳
4.1 在视频 PES 负载里切 H.264 的 NALU 边界
拿到视频 PES 的 payload 之后,里面的数据已经是 H.264 的 ES 流,采用 Annex B 格式,也就是 NALU 之间用00 00 00 01或00 00 01分隔。一帧视频对应的 PES 负载里,可能只有一个 NALU(比如只装了 IDR 帧切片),也可能包含 SPS、PPS 和 IDR 三个 NALU 挤在一起。
// ps_demux.c: 在 PES 负载里切出所有 NALU void ps_split_nalus(const uint8_t *payload, int len, void (*on_nalu)(const uint8_t *nal, int size, void *ctx), void *ctx) { int i = 0; while (i < len - 4) { if (payload[i] == 0 && payload[i+1] == 0 && payload[i+2] == 0 && payload[i+3] == 1) { // 3字节起始码 00 00 01 也兼容 if (i > 0 && payload[i-1] == 0) { // 已通过 4 字节分支处理, 跳过 } } i++; } // 上面是示意, 真正实现请看下面的分帧循环 }上面这段只是抛砖引玉。实际分帧循环应当这样做:记录每个起始码的位置,在两个起始码之间切出一个 NALU;起始码可能是 4 字节也可能是 3 字节;NALU 的第一个字节是nal_header,低 5 位是类型。把切出的 NALU 逐个送进解码器或转封装模块,往往比一次性整体送更稳。
int find_next_start_code(const uint8_t *buf, int len, int start) { for (int i = start; i + 3 < len; i++) { if (buf[i] == 0 && buf[i+1] == 0 && (buf[i+2] == 1 || (buf[i+2] == 0 && buf[i+3] == 1))) { return (buf[i+2] == 1) ? i : i; // 3或4字节都在这里命中 } } return -1; } void extract_nalus(const uint8_t *pes_payload, int payload_len, void (*handler)(const uint8_t*, int, void*), void *ctx) { int cur = find_next_start_code(pes_payload, payload_len, 0); while (cur >= 0 && cur + 4 < payload_len) { int step = (pes_payload[cur+2] == 1) ? 3 : 4; int nal_start = cur + step; int next = find_next_start_code(pes_payload, payload_len, nal_start); int nal_len = (next < 0) ? payload_len - nal_start : next - nal_start; handler(pes_payload + nal_start, nal_len, ctx); cur = next; } }find_next_start_code同时兼容00 00 01和00 00 00 01两种起始码。判断方法是看位置 i+2 是否为 1,若是 3 字节起始码;若是 0,再看 i+3 是否为 1,就是 4 字节起始码。代码里的step变量用来跳过起始码本身,让 NALU 数据从nal_header开始。
切出来的 NALU 可以先不拼装,直接以长度为前缀的方式传给解码器。如果用 FFmpeg 的avcodec,需要把 Annex B 格式转成 AVCC(length-prefixed),或者保持 Annex B 用AV_CODEC_FLAG2_CHUNKS推进。这里踩过的坑是:切分时如果不跳过 3 字节和 4 字节起始码的差异,第一个 NALU 会带上前一个起始码的一个 0 字节,导致解码器把 NALU 边界看错,出现一下块花屏一下块正常的现象。
4.2 时间戳同步:RTP 时钟、PTS 和解码器时钟的关系
视频 RTP 时间戳使用 90000Hz 时钟,PTS 同样也以 90kHz 为单位。理论上可以把 RTP 时间戳直接用,但实际我从来不用 RTP 时间戳做主同步源,因为分片重排后 RTP ts 中间有跳变,而且多个 RTP 分片属于同一个 PS 包时它们的时间戳相同。更可靠的时间基准是 PS 包内解析出来的 PTS。
音频的情况比较特别,很多设备音频用 G.711A,采样率 8000Hz,RTP 时间戳以 8000 为步进递增。视频 90kHz、音频 8kHz,两边直接相加等于比较秒和毫秒。统一的换算方法是以微秒(µs)为中间轴:
| 来源 | 单位 | 换算到微秒 |
|---|---|---|
| 视频 PTS | 90kHz tick | pts_us = pts * 1000000 / 90000 |
| 音频 PTS | 90kHz tick | pts_us = pts * 1000000 / 90000 |
| 音频 RTP ts | 8kHz tick | ts_us = rtp_ts * 1000000 / 8000 |
| 视频 RTP ts | 90kHz tick | ts_us = rtp_ts * 1000000 / 90000 |
换算成微秒后再对齐两个轨道的起点,就能得到相对播放的偏移量。假如视频第一帧 PTS 是 3000(90kHz),音频第一帧换算后的时间比视频晚 40ms,那么 mux 时音频要等待 40ms 再写。这个数值来回跳动超过 ±10ms,就说明 PTS 解析的位没有对,回到 3.3 节的read_pts_33查下marker_bit是否过滤干净。
// timestamp.c: 时间戳统一换算为微秒 static inline int64_t tus(int64_t tick, int frequency) { return tick * 1000000 / frequency; // frequency 90k 或 8k }解码器要求 DTS 单调递增,但 I 帧的 PTS 可能和 DTS 不同,尤其 B 帧存在时。28181 设备大多传 low-latency 配置,B 帧很少,但你不能假设没有。推进解码器之前做一个检查:如果dts > -1且dts < prev_dts,要么丢弃当前帧,要么做重排序缓冲。直接往解码器硬灌乱序 DTS,代价是解码器内部参考帧错乱,后面的画面全碎。
4.3 解析结果怎么用:裸流验证与后续处理
解出来的 H.264 裸流最常见的使用路径有三个:直接送软/硬解码器播放、重新封装成 FLV/MP4、提取关键帧做AI分析。第一步验证一定要走裸流文件加播放器,这一步能最快把解包逻辑和解码逻辑隔离开。
# 把解包输出的 H.264 裸流存成文件后, 用 ffmpeg 转换成 mp4 验证 ffmpeg -f h264 -i video_only.h264 -c:v copy video_check.mp4 # 若原始流里有 PS 数据残留, 通常会有大片花屏, 可抓一帧分布看 ffprobe -show_packets video_check.mp4 | head -40裸流能正常播放,说明 NALU 分帧是对的、SPS/PPS 完整;再回头看时间戳。如果裸流能放但封装后音画不同步,问题在 PTS 换算;如果裸流也是花的,说明 PS 解包的负载切分有问题,别再往时间戳上花时间排查。
5. PS 流解包常见问题与避坑:排查这 5 处,能省下半个通宵
5.1 现象:解析出来的视频花屏、有规律地跳帧
原因:视频 PES 负载里存在00 00 00 01和00 00 01混用,而分帧代码只处理了其中一种。
解决:分帧逻辑统一用find_next_start_code兼容两种起始码。还要注意 H.264 里00 00 01是合法的 Annex B 起始码,00 00 00 01才是标准推荐写法。如果只认 4 字节,会把00 00 00 01里的第三个 0 切进 NALU 数据,导致 NALU 长度全部错位。
5.2 现象:只有前几帧能解码,之后一直出不来图
原因:设备视频 PES 里携带的 SPS/PPS 只在关键帧前出现,而且不是每个 PS 包都有。有些平台在自己的转封装逻辑里只往输出轨道写了一次 SPS/PPS,后面来的 IDR 帧没有同步头,解码器找不到参数集。
解决:在解包器内部保留一份 SPS 和 PPS 缓存,每次遇到类型 7(SPS)和类型 8(PPS)的 NALU 就更新缓存,并在输出 IDR 帧前把缓存里的 SPS/PPS 拼到帧数据头部。这样即使设备不在每个关键帧前重复 SPS/PPS,下游解码器也不会丢参数集。
5.3 现象:音频正常但视频一直出不来,或者反过来
原因:多半是 stream_id 判断写死了。我见过一份代码里把0xBD(private_stream_1)当作视频流,实际上某厂商的 PS 流用0xBD表示私有扩展,里面可能装的是 OSD 叠加信息或 JSON 元数据,不是视频。
解决:按区间判断,视频stream_id在0xE0到0xEF,音频在0xC0到0xDF,不要只判断== 0xE0。另外在调试时打一条日志把每个 PES 的 stream_id、长度、第一个字节打出来,很快能看出厂商是不是塞了私有流。
5.4 现象:PTS 一直在涨但播放器音画严重不同步
原因:read_pts_33没有处理marker_bit。标准里 PTS 的 33 位数据分布在 5 个字节,每个字节的低 1 位是固定 1 的 marker bit,解析时都要右移过滤。漏掉一个位过滤,PTS 会偏大约几千个 tick,且偏移量和时间成正比,看起来就是"越来越不同步"。
解决:严格按(p[0] >> 1 & 0x07) << 30的写法提取。解析完以后和 RTP 时间戳做交叉验证:PTS 与 RTP ts 的差值应当在一个常数附近波动,差值如果同方向持续变大,就是 PTS 位取错了。
5.5 现象:同一个 PS 包测试环境正常,现场偶发解析崩溃
原因:现场网络抖动导致 RTP 丢包,PS 包的PES_packet_length又为 0,解包器误把残缺的数据当作完整包处理,缓冲区的读到越界。
解决:走"扫描下一个0x000001BA才确认当前包结束"的安全路径,并且在切片、拷贝负载时始终带长度上限检查。解包器内部使用一个不低于 1MB 的环形缓冲,发现两个 PS 包间距超过缓冲容量时直接清空、重新同步,不回补旧数据。
6. 用最小验证快速确认解包器可用,再决定自研到哪一步
用解包器输出的 ES 流做一套自动回归,是投入自研 PS 解包器前最值得做的事。我自己的做法是在测试目录里放三个样本:一个 H.264 视频无音频、一个 H.264 加 G.711A 音频、一个 HEVC 视频。每个样本跑一遍解包,把输出目录里的 ES 流统一过一遍 ffmpeg 转换,转换失败或输出时长偏差超过设定的阈值就上报,相当于一个打更的守在门口,防止改一帧代码把全流程带崩。
# 自动回归脚本片段: 三路样本全部检查 for f in sample_h264 sample_h264_g711 sample_hevc; do ./ps_demux -i ${f}.pcap -o ${f}_video.h264 -o ${f}_audio.g711a ffmpeg -f h264 -i ${f}_video.h264 -t 2 -f null - || echo "FAIL: $f" done自研解包器做到能稳定解出 ES 流、能用 PTS 对齐、能容错丢包,工程量不大,约 2000 行 C 代码就能覆盖。真正烧钱的是后续维护:不同厂商在 PSM 里塞私有字段、在不同分辨率下修改 stuffing 长度、在音频里混入非标准采样率。所以我的建议是核心解析逻辑自研,这样能完全掌控性能与内存;往外送数据接口统一走 FFmpeg 的AVPacket格式,这样下游解码器不用为每家厂商写适配。
我吃过一个亏:某项目用现成的 FFmpeg 解复用器跑 28181 流,遇到某厂商设备的 PS 包头带了 4 字节自定义扩展,FFmpeg 直接拒绝识别。那时才补了一截预处理逻辑把它剥离。从那以后我养成了习惯——解包器的入口永远保留一个 probe 函数,专门打印第一个 PS 包的偏移和起始码位置,修改一个字节都能立刻看到变化。这个习惯帮我挡掉了不少现场定位的活。
如果你只是验证别人的码流能不能解,直接抓包丢给ffmpeg -f mpegps -i input.pcap就行;如果要自己接一路 24 小时不间断运行的接入服务,那就按上面这套流程把分帧、容错、回归验证全部做扎实,后面会很省心。希望帮到你。
本文还有配套的精品资源,点击获取