☰
MPEG-2 Systems标准解析:从TS包到PAT/PMT的码流分析指南
2026/10/9 16:19:51 网站建设 项目流程

简介:这份ISO/IEC 13818-1:2019第七版标准(对应ITU-T H.222.0)是运动图像及伴随音频通用编码中的系统层规范,面向数字电视、流媒体传输及多媒体设备互操作等领域的研发、测试与教学人员。PDF完整英文版共305页,详细规定了系统层总体架构、传送流与节目流的编码和复用、PES与TS分组结构、系统时钟恢复、音视频同步与时序控制、音频与视频基本流在系统层的承载方式,以及节目特定信息和描述符;同时收录了2018年修订内容。资源为单个PDF文件,整包约20.93MB,便于离线阅读与按需检索。该版本目前已有328人学习下载;对于需要逐条对照标准原文、厘清复用与时序机制、开展协议实现或合规性验证的读者,这份完整英文版是可靠的一手资料。

1. 拿到 ISO-IEC 13818-1:2019 这份 305 页 PDF,先别急着从第 1 页开始读

做播放器、做拉流服务、做码流分析工具的人,大概率都经历过这种现场:TS 包一个不少,PID 都能抓到,但 PAT/PMT 一解析就乱,要么节目数是负的,要么音频 PID 指向一个不存在的流,最后画面不是花屏就是卡在加载。这时候翻标准比翻日志有用。ISO-IEC 13818-1:2019,也就是常说的 MPEG-2 Systems,它规定的正是视频、音频怎么被打成 PES,PES 怎么复用成 TS 和 PS,PAT/PMT 这些节目专用信息表怎么排,以及 PTS/DTS 和 PCR 怎么让音画对上。适合谁?用 FFmpeg 排查直播流、写码流分析工具、或者做播控系统的工程师。这份英文版 PDF 有 305 页,但真正天天用到的只占一小部分。下文按“先懂结构、再照着拆包”的顺序把它过一遍。

2. 先确认版本再开读:2019 版到底比老版多改了些什么

2.1 版本链路:从 H.222.0 到 ISO/IEC 13818-1:2019

ISO/IEC 13818-1 的历史可以追溯到 ITU-T H.222.0,两边文本长期保持一致。1996 年是第一版,后来又陆续出过 2000 版、2007 版、2010 版、2013 版,到 2019 年就是第六版。很多老博客和开源代码写的注释是“ISO/IEC 13818-1:2000”,甚至直接写 MPEG-2 Systems,导致新手以为这标准十年没动过——其实 2019 版已经把 2013 年以后的修正案合并进正文了。

比如对 HEVC、AC-4、绿色访问单元等新编码和新特性的适配,老版本里完全没有。做合规开发时,比如对接运营商、播控方的验收入库标准,条款号对不上会非常被动,所以新项目我一般直接以 2019 版为准。老版本不是不能用来干活,但你得清楚自己手里是哪一版,才不会拿 2000 年的语法图去解析 2019 年的新描述符。

提示:拿到 PDF 后第一件事,看封面和标题页的出版年份。ISO 标准首页通常会标注 Edition 编号,2019 版是第六版(Edition 6)。如果封面写着 2013 或者连年份都没有,后面所有条款对照都会带着不确定性。

2.2 Generic coding 与 Systems:这一层管什么、不管什么

标准标题里的 Generic coding 经常被误解。它不是“通用编码”,而是“通用打包”:视频编码可以来自 MPEG-2(13818-2)、H.264(14496-10)、HEVC(23008-2),系统层不管编码复杂性,只要给我 ES(基本流),它就按固定语法把 ES 切成 PES、复用成 TS 或 PS。这也是为什么今天视频编码换代了好几轮,TS 容器仍然能存活下来的底层原因。

Systems 这个词强调的是“系统视角”。这一层要解决三件事:字节序级的语法布局、时间同步(DTS/PTS/PCR)、缓冲上溢下溢(STD 模型)。网络丢包不在它的管辖范围,那是 RTP/TS over UDP 或 HTTP 传输层的事。很多排障误区就是把容器层问题误判成编码层问题:花屏先去翻编码器参数,结果发现 PMT 里 PID 都指错了,这在我们的行话里叫“系统层翻车”。

2.3 305 页不必全背:高频区与低频区的划分

把 305 页按使用频度分个级,心里就有底了。高频区是 TS 包语法、adaptation field、PAT/PMT/CAT、PES 头、PTS/DTS/PCR 字段定义,这部分在抓包和排障时几乎天天见。中频区是各种 descriptor、PSI 表的扩展、DSM-CC 相关表。低频区是注册表、商标声明、解码器 STD 模型细节、一致性测试向量。如果只是写工具或做码流分析,低频区可以等工作真正遇到再回头查。

有一点要提醒:不要跳过“语义”章节直接抄语法图。语法图只告诉你字段有多长,语义才告诉你字段在什么条件下存在,以及取值代表什么。常见做法是把两个章节对照着读:左边是语法表,右边是逐字段解释。我见过同事直接按语法图写 PMT 解析,不理解 section_length 的计数起点,结果表头总差一个字节,这种玄学错误最后都得靠翻语义段落才能定位。

3. 按 305 页的条款结构找路:第一遍只读一条闭环

3.1 条款地图:语法在哪儿、语义在哪儿

拿到 PDF 先别从头翻,先看目录和书签。标准正文的条款编号在每次重印时会有偏移,但整体结构基本稳定。我按版块做了一个速查表,方便定位:

版块内容优先级
开篇(范围、引用、术语)定义与缩略语,检索用备查
系统层模型TS/PS/PES 的关系与 STD 缓冲模型高
语法章节TS 包头、adaptation、PES、PSI 的 bit 级布局最高
语义章节字段含义、约束、取值先后关系最高
描述符章节各种 descriptor 的 tag 与内容按需
注册与一致性私有标识、一致性声明低

定位语法段落有个小技巧:正文里凡是出现“The syntax of …”的小节,后面跟的就是表格或伪代码式的字段布局;凡是出现“semantics”的小节,是逐字段解释。旧版 PDF 的章节编号和 2019 版会有出入,精确定位靠 PDF 书签,千万别靠页码。这里就是“305 页”这个信息最容易坑人的地方:页数指的是 PDF 的物理页数,不等于条款编号,后面避坑章节会展开讲。

3.2 一条闭环阅读路线:TS 包头 → PAT → PMT → PES 头

第一遍不要从头读到尾,按数据流的顺序读。一条最关键的闭环是:

  1. TS 包是 188 字节的载体,先读包语法:sync_byte、transport_error_indicator、PUSI(payload_unit_start_indicator)、PID、adaptation_field_control、continuity_counter。
  2. PAT(table_id 0x00)告诉你 PMT 的 PID 在哪儿。节目号为 0 时对应的不是节目,而是 network_PID。
  3. PMT(table_id 0x02)告诉你每一路 ES 的 PID 和 stream_type:视频、音频、字幕各是什么编码,以及 PCR_PID 指向哪个流。
  4. PES 头里是 packet_start_code_prefix 和 PTS/DTS。走到这里,你就把“容器 → 包 → 节目 → 基本流”这一整条解复用链路走通了。

做这四步时我会把标准里的原始表格抄到编辑器里,一行一个字段,边抄边想“如果我现在从字节流里读这个字段,偏移量是多少”。这一步比直接跑代码更能暴露理解上的空洞。

3.3 边读边做字段表:把“黑匣子”变成可查清单

标准是给人查的,不是给人背的。最好的读法是把高频字段整理成自己的速查表。以 PES 头为例,我一般会做这样一张表:

字段位宽关键取值/含义
packet_start_code_prefix24固定 0x000001
stream_id80xE0 视频,0xC0~0xDF 音频
PES_packet_length16后面载荷字节数
PTS_DTS_flags200 无,10 仅 PTS,11 PTS+DTS
PES_header_data_length8可选字段总字节数

做这张表时你会发现一个重要习惯:标准里的“保留”字段也占位,解析时不能跳过。很多解析器翻车就是因为把 reserved 当垃圾丢了,导致后续位偏移整体错位。这也是我建议第一遍就手写字段表的原因,抄一遍比划三遍管用。

4. 用自己的解析器过一遍:188 字节的 TS 包能拆到哪个粒度

4.1 先拿工具看包:xxd 与第一眼的 0x47

标准语法图是拿来翻译成代码的,不是拿来背的。从抓到的 TS 文件里截出前 188 字节,先用工具看一眼最直观:

dd if=live.ts bs=188 count=1 of=first_packet.bin 2>/dev/null xxd -l 188 -c 16 first_packet.bin

第一行第一个字节必须是 0x47,这是 sync_byte。如果第一字节不是 0x47,说明文件不是从包边界开始,需要做同步搜索:在字节流里反复找 0x47 并尝试按 188 字节切片,连续几包都对上才算找到包边界。dd的bs=188 count=1就是取一个完整 TS 包;xxd -c 16让每行正好展示十六个字节,方便按十六进制手工推算字段。这种小命令是后面所有解析工作的基础,先把包边界搞对,后续才不会被“半个包”干扰。

4.2 Python 拆 TS 包头与 adaptation field

接下来把 TS 包头和 adaptation field 用 Python 拆开。这是最常用的排障工具,代码量不大但位操作很容易出错:

def parse_ts_header(pkt: bytes) -> dict: # 一个合法的 TS 包固定 188 字节,且以同步字节 0x47 开头 if len(pkt) < 188 or pkt[0] != 0x47: raise ValueError("packet must start with sync 0x47") b = pkt[1:4] # PID 是 13 位:第 1 个字节的低 5 位 + 第 2 个字节的全部 8 位 pid = ((b[0] & 0x1F) << 8) | b[1] tei = (b[0] & 0x80) >> 7 # transport_error_indicator pusi = (b[0] & 0x40) >> 6 # payload_unit_start_indicator # 第 2 个字节的高 2 位是 adaptation_field_control afc = (b[2] >> 4) & 0x03 cc = b[2] & 0x0F # continuity_counter,4 位 offset = 4 af = {} # AFC=2 表示仅 adaptation field,AFC=3 表示 adaptation field + 载荷 if afc in (2, 3): af_len = pkt[offset] # 注意:af_len 不包含长度字节本身,偏移要 +1 af = { "length": af_len, "PCR_flag": (pkt[offset + 1] & 0x10) != 0, "random_access": (pkt[offset + 1] & 0x40) != 0, } offset += 1 + af_len payload = pkt[offset:188] if afc in (1, 3) and offset < 188 else b"" return { "pid": pid, "tei": tei, "pusi": pusi, "afc": afc, "cc": cc, "adaptation": af, "payload": payload, }

这段代码的关键点是 PID 的位拼接方式,以及 adaptation field 的长度语义。PID 是 13 位,跨了两个字节,不能按字节直接读;af_len表示的是“后面还有多少字节”,不包含长度字节自身,所以offset += 1 + af_len,漏掉这个 1 会导致整个包偏移错位。adaptation_field_control的取值决定了后面是否还有 payload:01 表示仅载荷,10 表示仅 adaptation field,11 表示两者都有。

4.3 解析 PAT/PMT:PSI 表的最小实现

PSI section 可能跨包,188 字节的 TS 包通常装不下完整的 PMT,所以工程上必须先做 section 拼接,再解析。下面先给出解析完整 section 的函数,假设传入的已经是拼好的 section 字节流:

def parse_section(data: bytes): # PSI section 至少 12 字节,且传入的不是 TS 包头 if len(data) < 12 or data[0] == 0x47: raise ValueError("pass the payload, not TS packet") table_id = data[0] # section_length 是 12 位:第 1 字节低 4 位 + 第 2 字节全部 section_length = ((data[1] & 0x0F) << 8) | data[2] if 3 + section_length > len(data): raise ValueError("section not complete") if table_id == 0x00: return parse_pat(data) elif table_id == 0x02: return parse_pmt(data) return {"table_id": table_id}

PAT 的结构比较固定,头 8 字节之后就是 program_number 和 PID 的循环:

def parse_pat(d: bytes): section_length = ((d[1] & 0x0F) << 8) | d[2] tsid = int.from_bytes(d[3:5], "big") # 8 字节头:table_id(1) + length(2) + tsid(2) + version/current(1) # + section_number(1) + last_section_number(1) end = 3 + section_length - 4 # 去掉最后的 CRC_32 res = [] for pos in range(8, end, 4): prog = int.from_bytes(d[pos:pos + 2], "big") # PID 是 13 位:第 pos+2 字节低 5 位 + 第 pos+3 字节全部 pid = ((d[pos + 2] & 0x1F) << 8) | d[pos + 3] res.append((prog, pid)) return {"transport_stream_id": tsid, "entries": res}

PMT 的头部比 PAT 多 PCR_PID 和 program_info_length 两个字段:

def parse_pmt(d: bytes): section_length = ((d[1] & 0x0F) << 8) | d[2] program_number = int.from_bytes(d[3:5], "big") # d[5] 是 version/current_next,d[6] 是 section_number, # d[7] 是 last_section_number,这里不继续校验,按单 section 处理 # PCR_PID 13 位:d[8] 低 5 位 + d[9] 全部 pcr_pid = ((d[8] & 0x1F) << 8) | d[9] # program_info_length 12 位:d[10] 低 4 位 + d[11] 全部 program_info_length = ((d[10] & 0x0F) << 8) | d[11] pos = 12 + program_info_length # 跳过节目级 descriptor end = 3 + section_length - 4 # 去掉 CRC_32 streams = [] while pos + 5 <= end: stream_type = d[pos] es_pid = ((d[pos + 1] & 0x1F) << 8) | d[pos + 2] es_info_length = ((d[pos + 3] & 0x0F) << 8) | d[pos + 4] # 每个 ES 记录 = 5 字节头 + descriptor 数据 pos += 5 + es_info_length streams.append({"stream_type": stream_type, "pid": es_pid}) return {"program_number": program_number, "pcr_pid": pcr_pid, "streams": streams}

这段代码的逻辑说明:section_length统计的是从它自身之后到 CRC 结束的全部字节数,所以整体 section 长度是3 + section_length,而循环解析有效数据时要去掉最后 4 字节 CRC。PMT 的 PCR_PID 和 PAT 里 PID 一样是 13 位,但偏移不同,PAT 里可以从第 8 字节起按 4 字节步进,PMT 需要先处理头部,再按“5 字节 ES 头 + descriptor 长度”循环跳转。参数说明:stream_type常见取值包括 0x01(MPEG-2 视频)、0x1B(H.264)、0x24(HEVC)、0x0F(AAC),具体要对照标准的注册表。

4.4 跨包 section 拼接:真实码流一定会遇到的问题

上面两个函数假设传入的是完整 section,但真实 TS 流的 PMT 往往超过一个包的 payload 容量。工程上我一般用状态机处理:遇到payload_unit_start_indicator=1的包时,通过 pointer_field 找到 section 起点,开始积累字节;后续包如果 PUSI 为 0,就把整个 payload 追加进缓冲区;等积满3 + section_length字节后再交给解析函数。这块不做,你会发现解析结果时好时坏,偶尔某张表能出来,换一个流就全乱——这就是典型的“半截 section”问题。

4.5 PTS/DTS 提取:33 位时间戳怎么取

PES 头里的 PTS 是 33 位,被拆在 5 个字节里,还夹杂着 marker 位,不能直接当整数读。手动提取时我最常用的写法:

def parse_pts(b: bytes) -> int: # b 是 PTS 字段的 5 个字节,按标准位布局取值 pts = (((b[0] >> 1) & 0x07) << 30) # PTS[32..30] pts |= (b[1] << 22) # PTS[29..22] pts |= ((b[2] & 0x7F) << 15) # PTS[21..15] pts |= (b[3] << 7) # PTS[14..7] pts |= (b[4] & 0x7F) # PTS[6..0] return pts

逻辑说明:PTS 的 33 个有效位分散在 5 字节里,每字节的最高位或最低位是 marker bit,用掩码 0x7F 或移位把它们滤掉。b[0]低 4 位是固定前缀0010,实际有效位是 bit3..bit1,也就是(b[0] >> 1) & 0x07。参数说明:PTS 单位是 90kHz,回绕周期约 26.5 小时,所以比较两个 PTS 要用差值并考虑回绕,不能直接比大小。DTS 的布局和 PTS 相同,只是它在 PES 头里的偏移不同。

5. 解读这份标准 PDF 的避坑笔记:版本、页码与解析器的四个常见翻车点

5.1 现象:PDF 标注 305 页,和官网下载的页数对不上

原因:不同渠道的 PDF 附加页不同。有的带封面、目录和法律声明,有的事先把修正案合并进去导致正文边距、页码都偏移。解决:核对版本不要看总页数,看条款树。翻到正文里“Systems layer model”这一章,确认条款编号结构和目录一致;再抽一个 descriptor 定义,看它的字段布局和已知码流是否吻合。那个 305 页更多是“这份文件完整”的背书,不是精确定位工具。

5.2 现象:拿 2000 版语法图去套 2019 新码流

原因:2019 版并入了 2013 年之后的多个修正案,涉及 HEVC 时间戳、AC-4 配置等新内容。2000 版里根本没有这些表,硬套的结果就是 descriptor 解析错位。解决:先确认手里的码流是哪种编码,再在标准里查对应条款;如果是 HEVC 的 TS 封装,务必以 2019 版为准,老版本只能参考容器骨架。

注意:做合规项目时,验收方通常会指定标准版本。建议把“使用的标准版本 + 章节号”直接写进设计文档,避免几个月后对问题时说不清。

5.3 现象:PMT 里 descriptor 长度多算一个字节,整张表全乱

原因:descriptor 的结构是tag(8) + length(8) + data[length],这里的 length 指 data 的字节数,不含 tag 和 length 本身。很多解析器按“整段长度”去跳,结果多跳一个字节,后面所有字段都错位。解决:跳转距离固定用2 + length,并在写完解析器后用真实码流验证。这是最容易被忽略的细节,也是一些解析器“时好时坏”的根源。

5.4 现象:PCR 值“不递增”,被当 bug 报给编码器厂商

原因:PCR 由 33 位的 base(90kHz)和 9 位的 extension(27MHz)组成,整个计数器会回绕,编码器也可以在某些场景下重置计数器。解析时如果把 PCR 当普通整数看绝对值,一定会误判。解决:判断 PCR 正常与否,看相邻取样点的差值,做差时对 2^33 取模;只有差值接近负数或有跳变时,才需要怀疑码流问题。

5.5 现象:CC 校验误报“continuity_counter 不连续”

原因:continuity_counter只在包携带 payload 时才递增,纯 adaptation field 的包不递增;标准还允许重复传输,同一个包连续出现时 CC 保持不变。如果校验逻辑写成“每个包必须 +1”,直播流里带 adaptation field 的包一多就会误报。解决:按 PID 分别计数,先判断adaptation_field_control是否有载荷,再判断是否重复包,最后才做连续性校验。

5.6 快速校验 PDF 完整性的两种办法

拿到 PDF 后,我习惯先做两个小动作。一是用pdftotext把封面页解出来,确认标题行写的是 ISO/IEC 13818-1:2019 而不是某个中间版本;二是跳到正文里找一个 descriptor 定义,比如 ISO 639 语言描述符或 registration descriptor,对照它的字段布局,确认和已知的公开结构一致。这两个动作加起来不到五分钟,能省掉后面几小时的定位成本。说到底,PDF 只是标准的载体,真正可信的是条款内容和版本标识。

6. 验证读没读懂:把第一个 TS 包手工拆完,再对照一次

6.1 手工拆一个包

读标准的效果,最好的验证方式不是默写条款,而是拿真实码流手工拆一个包。用前面说的dd和xxd抓第一包,然后在纸上或编辑器里写出:sync_byte 是多少,PID 是多少,PUSI 是 0 还是 1,adaptation_field_control 等于几,载荷从第几个字节开始。这样一来“188 字节的包里每个字段从哪到哪”就成了身体记忆。下一步再把载荷交给第 4 节的解析函数,看输出和手工推算是否一致。

6.2 四个自测问题

如果身边没有真实码流,拿下面四个问题自测也能看出水平:

自测问题应该答出的关键点
PAT 里 program_number 为 0 时对应的 PID 是什么network_PID,不是节目 PMT
PMT 的 PCR_PID 一般指向什么节目的时钟参考流,通常是视频 PID
PTS 和 DTS 能否取相同值可以,常见于视频 I 帧
CC 什么时候不递增无载荷包、重复传输包

前两题考的是“表结构背后的语义”,后两题考的是“字段约束”,这四个点都答得清楚,说明你对 MPEG-2 Systems 的容器层已经有可迁移的理解,换一种编码格式也不怕。

6.3 对照参考实现做复核

最后一步,用现成工具交叉验证。ffprobe能列出每个节目的 PID 和编码信息,tsduck这类工具也能展示 PSI 表详情。我会把同一个流跑三遍:手工推算一遍、自己的解析脚本一遍、参考工具一遍,三方结果一致才算完。之前有段时间我总跳过语义章节只看语法图,结果 PMT 拼接断了半个 section 都没意识到,后来是拿参考工具的输出逐字段对,才定位到是指针字段没处理。现在无论是 305 页的 ISO-IEC 13818-1:2019,还是别的什么容器标准,我都坚持“看完语法必须手拆一个包”的习惯,拆过一遍,那些位宽和偏移才真正是自己的。希望帮到你。

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

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

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

立即咨询