多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MPEG-2 Systems标准解析:从TS包到PAT/PMT的码流分析指南

MPEG-2 Systems标准解析:从TS包到PAT/PMT的码流分析指南 简介这份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它规定的正是视频、音频怎么被打成 PESPES 怎么复用成 TS 和 PSPAT/PMT 这些节目专用信息表怎么排以及 PTS/DTS 和 PCR 怎么让音画对上。适合谁用 FFmpeg 排查直播流、写码流分析工具、或者做播控系统的工程师。这份英文版 PDF 有 305 页但真正天天用到的只占一小部分。下文按“先懂结构、再照着拆包”的顺序把它过一遍。2. 先确认版本再开读2019 版到底比老版多改了些什么2.1 版本链路从 H.222.0 到 ISO/IEC 13818-1:2019ISO/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-213818-2、H.26414496-10、HEVC23008-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 头第一遍不要从头读到尾按数据流的顺序读。一条最关键的闭环是TS 包是 188 字节的载体先读包语法sync_byte、transport_error_indicator、PUSIpayload_unit_start_indicator、PID、adaptation_field_control、continuity_counter。PATtable_id 0x00告诉你 PMT 的 PID 在哪儿。节目号为 0 时对应的不是节目而是 network_PID。PMTtable_id 0x02告诉你每一路 ES 的 PID 和 stream_type视频、音频、字幕各是什么编码以及 PCR_PID 指向哪个流。PES 头里是 packet_start_code_prefix 和 PTS/DTS。走到这里你就把“容器 → 包 → 节目 → 基本流”这一整条解复用链路走通了。做这四步时我会把标准里的原始表格抄到编辑器里一行一个字段边抄边想“如果我现在从字节流里读这个字段偏移量是多少”。这一步比直接跑代码更能暴露理解上的空洞。3.3 边读边做字段表把“黑匣子”变成可查清单标准是给人查的不是给人背的。最好的读法是把高频字段整理成自己的速查表。以 PES 头为例我一般会做这样一张表字段位宽关键取值/含义packet_start_code_prefix24固定 0x000001stream_id80xE0 视频0xC0~0xDF 音频PES_packet_length16后面载荷字节数PTS_DTS_flags200 无10 仅 PTS11 PTSDTSPES_header_data_length8可选字段总字节数做这张表时你会发现一个重要习惯标准里的“保留”字段也占位解析时不能跳过。很多解析器翻车就是因为把 reserved 当垃圾丢了导致后续位偏移整体错位。这也是我建议第一遍就手写字段表的原因抄一遍比划三遍管用。4. 用自己的解析器过一遍188 字节的 TS 包能拆到哪个粒度4.1 先拿工具看包xxd 与第一眼的 0x47标准语法图是拿来翻译成代码的不是拿来背的。从抓到的 TS 文件里截出前 188 字节先用工具看一眼最直观dd iflive.ts bs188 count1 offirst_packet.bin 2/dev/null xxd -l 188 -c 16 first_packet.bin第一行第一个字节必须是 0x47这是 sync_byte。如果第一字节不是 0x47说明文件不是从包边界开始需要做同步搜索在字节流里反复找 0x47 并尝试按 188 字节切片连续几包都对上才算找到包边界。dd的bs188 count1就是取一个完整 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_counter4 位 offset 4 af {} # AFC2 表示仅 adaptation fieldAFC3 表示 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的取值决定了后面是否还有 payload01 表示仅载荷10 表示仅 adaptation field11 表示两者都有。4.3 解析 PAT/PMTPSI 表的最小实现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 位第 pos2 字节低 5 位 第 pos3 字节全部 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_nextd[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常见取值包括 0x01MPEG-2 视频、0x1BH.264、0x24HEVC、0x0FAAC具体要对照标准的注册表。4.4 跨包 section 拼接真实码流一定会遇到的问题上面两个函数假设传入的是完整 section但真实 TS 流的 PMT 往往超过一个包的 payload 容量。工程上我一般用状态机处理遇到payload_unit_start_indicator1的包时通过 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 位的 base90kHz和 9 位的 extension27MHz组成整个计数器会回绕编码器也可以在某些场景下重置计数器。解析时如果把 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 还是 1adaptation_field_control 等于几载荷从第几个字节开始。这样一来“188 字节的包里每个字段从哪到哪”就成了身体记忆。下一步再把载荷交给第 4 节的解析函数看输出和手工推算是否一致。6.2 四个自测问题如果身边没有真实码流拿下面四个问题自测也能看出水平自测问题应该答出的关键点PAT 里 program_number 为 0 时对应的 PID 是什么network_PID不是节目 PMTPMT 的 PCR_PID 一般指向什么节目的时钟参考流通常是视频 PIDPTS 和 DTS 能否取相同值可以常见于视频 I 帧CC 什么时候不递增无载荷包、重复传输包前两题考的是“表结构背后的语义”后两题考的是“字段约束”这四个点都答得清楚说明你对 MPEG-2 Systems 的容器层已经有可迁移的理解换一种编码格式也不怕。6.3 对照参考实现做复核最后一步用现成工具交叉验证。ffprobe能列出每个节目的 PID 和编码信息tsduck这类工具也能展示 PSI 表详情。我会把同一个流跑三遍手工推算一遍、自己的解析脚本一遍、参考工具一遍三方结果一致才算完。之前有段时间我总跳过语义章节只看语法图结果 PMT 拼接断了半个 section 都没意识到后来是拿参考工具的输出逐字段对才定位到是指针字段没处理。现在无论是 305 页的 ISO-IEC 13818-1:2019还是别的什么容器标准我都坚持“看完语法必须手拆一个包”的习惯拆过一遍那些位宽和偏移才真正是自己的。希望帮到你。本文还有配套的精品资源点击获取
返回列表