TS封装实战:从裸流到可播放字节流的打包机制与避坑指南
简介面向音视频开发初学者与流媒体传输方向工程师这份资源聚焦将H.264视频裸流与AAC音频数据封装为TS传输流的实现帮助读者理解NAL单元切分、起始码添加、PID分配与CRC校验等关键环节并衔接RTSP实时传输场景。压缩包共3个文件以2个C源文件和1个头文件为主分别承载TS打包核心逻辑、主流程调用与接口声明整体约13KB体量轻便便于直接阅读与移植调试。目前已有2129人学习下载适合作为流媒体封装的入门参考。读者可从中获得一套可运行的TS打包代码骨架对照理解视频与音频数据如何按188字节分组、如何区分PID并插入填充字节增强容错从而为IPTV、在线直播或视频会议等场景的传输链路开发打下基础。1. 裸流为什么必须被“装进容器”才能播把一段 H.264 裸流和一段 AAC 裸流直接拼在一起丢给播放器大概率是黑屏或者只有画面没声音。原因不在编码本身而在于裸流里缺少“时间”和“边界”这两样东西H.264 的 NAL 单元只告诉你这一帧怎么解却不告诉你它该在第几毫秒出现AAC 的 ADTS 帧只带采样率信息却不告诉你它和哪一帧画面同步。TSMPEG-2 Transport Stream要解决的正是这个打包问题——用固定 188 字节的包、用 PTS/DTS 时间戳、用 PID 区分音视频把两路互不相干的裸流缝成一条可被任意播放器顺序消费的字节流。搞懂这套打包机制你才能明白为什么改一个 PCR 间隔、错一个 continuity_counter播放器就会卡顿、花屏甚至直接拒绝解码这些坑在真实项目里踩一次就够记一辈子。2. TS 包的固定长度188 字节到底在保护什么2.1 定长包与同步字节的设计动机TS 最反直觉的一点是它坚持每个包固定 188 字节而不是像很多现代容器那样用变长结构。这不是历史包袱而是一个明确的工程取舍传输层要能在丢包、错位、中途接入的场景下快速重新同步。每个 TS 包以 0x47 开头这个同步字节让解复用器在字节流里滑动窗口搜索时只要连续命中几个 0x47 且间隔恰好 188就能判定“我找到了包边界”。变长容器做不到这一点一旦中间丢了一段后面全部错位只能整段丢弃。固定长度还带来另一个好处包边界和内存对齐天然吻合硬件解复用器可以用极简单的状态机处理。代价是有效载荷被头部挤占——4 字节包头之后最多 184 字节遇到自适应字段还要再让出空间。所以打包器的核心工作之一就是决定每个包里塞多少 payload、要不要带自适应字段、PCR 放在哪。常见的实现思路是视频 PES 包通常填满 184 字节 payload只有在需要插入 PCR 或做字节填充对齐时才启用 adaptation_field。音频包因为帧小往往一个 PES 里放多帧 AAC再切成若干 TS 包。2.2 TS 包头字段的逐位拆解理解打包先把 4 字节包头吃透。下面这段结构体定义基本对应了所有实现里的头部布局typedef struct { uint8_t sync_byte; // 固定 0x47同步锚点 uint8_t transport_error; // 1 表示该包传输中出错解码器应丢弃 uint8_t payload_unit_start; // 1 表示本包是 PES 包的起始携带 PES 头 uint8_t transport_priority; // 优先级一般置 0 uint16_t pid; // 13 位区分音视频/PMT/PAT 等不同流 uint8_t scrambling; // 加扰控制明文流置 0 uint8_t adaptation_field_ctrl; // 00 仅payload 01 仅自适应 10 自适应payload 11 保留 uint8_t continuity_counter; // 4 位循环计数同一 PID 内递增 } ts_header_t;payload_unit_start_indicatorPUSI是最容易被忽视却最关键的位它标记“这个 TS 包的开头是一个新 PES 包的起点”。解复用器靠它判断 PES 边界如果打包时漏置或错置播放器会把两个 PES 拼成一个时间戳全乱。continuity_counter则是丢包检测的依据同一 PID 的包该从 0 到 15 循环递增一旦跳变就说明中间丢了包解码器可以选择丢帧而不是拿错位数据硬解。adaptation_field_ctrl决定这个包有没有自适应字段。当 payload 不足 184 字节时用自适应字段里的 stuffing 字节补齐保证包长恒为 188。这也是为什么音频包经常出现“payload 只有几十字节、剩下全是填充”的现象。2.3 PID 与 PSI让播放器知道哪路是视频哪路是音频裸流里没有“这是视频、那是音频”的标签TS 用 PID 来区分。约定俗成的做法是 PAT 用 PID 0x0000PMT 用 0x1000 左右视频用 0x0100音频用 0x0101但这不是强制的真正让播放器找到它们的是 PSI 表。PATProgram Association Table把 program_number 映射到 PMT 的 PIDPMTProgram Map Table再列出该节目下每个 elementary stream 的 stream_type 和 PID。H.264 的 stream_type 是 0x1BAACADTS是 0x0F。播放器先读 PAT 找到 PMT再读 PMT 找到音视频 PID之后才能按 PID 分流。这套间接寻址看起来啰嗦好处是打包器可以自由分配 PID只要 PSI 表如实描述即可。打包时 PSI 表要周期性重复插入通常每 100ms 左右一次保证中途接入的播放器也能在短时间内拿到节目结构。这也是为什么一个合格的 TS 文件开头几十毫秒内必然能看到 PAT 和 PMT。3. PES 层时间戳和帧边界藏在哪里3.1 从 NAL 单元到 PES 包的封装H.264 裸流是一串 NAL 单元每个单元以起始码 0x000001 或 0x00000001 分隔。打包成 TS 之前要先把它封装成 PESPacketized Elementary Stream包。PES 包承担两件事携带 PTS/DTS 时间戳以及把若干 NAL 单元组织成一个可独立解码的访问单元access unit。一个 PES 包通常对应一帧视频。PES 头里最关键的是 PTS 和 DTSPTS 是显示时间DTS 是解码时间。对没有 B 帧的流两者相等一旦有 B 帧DTS 必须早于 PTS否则解码顺序和显示顺序对不上。时间戳以 90kHz 为时钟单位33 位表示这也是为什么很多实现里时间换算要乘 90000。// 把一帧 H.264 数据封装成 PES 头简化示意 void write_pes_header(uint8_t *buf, int payload_len, uint64_t pts, uint64_t dts, int has_dts) { buf[0] 0x00; buf[1] 0x00; buf[2] 0x01; // PES 起始码 buf[3] 0xE0; // stream_id: 视频 int pes_len payload_len (has_dts ? 10 : 5) 3; buf[4] (pes_len 8) 0xFF; buf[5] pes_len 0xFF; buf[6] 0x80; // 10 标志位 buf[7] has_dts ? 0xC0 : 0x80; // PTS_DTS_flags buf[8] 0x00; // PES header data length // 之后按 33 位格式写入 PTS必要时写 DTS }PES_packet_length在视频包里常被置 0表示长度不受限允许超过 65535 字节由 TS 层的 PUSI 来界定边界。这是视频大帧场景下的常见做法音频包则通常写实际长度。3.2 PTS/DTS 的 33 位编码与常见错位PTS 和 DTS 不是简单按 33 位整数写进去的而是拆成 5 字节、带标记位的格式高 4 位、中 15 位、低 15 位各占一段中间插入 marker bit。这种编码在手工实现时极易写错位一旦错位播放器解出的时间戳会变成天文数字表现为“音视频不同步且越拉越远”。我一般会这么理解把 33 位时间戳看成三个片段按0010仅 PTS或0011PTSDTS前缀写入每个片段之间用 marker bit 隔开。写完后最好用工具 dump 出来核对别指望肉眼数位。3.3 音频 PES 的特殊处理一包多帧AAC 的 ADTS 帧很小一帧通常只有几百字节如果每帧都封一个 PESPES 头开销占比过高。常见做法是一个 PES 里放多帧 AACPES 头只写第一个音频帧的 PTS后续帧的时间由采样率推算。这样既降低开销又保证音频时间线连续。需要注意的是音频 PES 的 PTS 必须和视频 PTS 落在同一时间基准上否则同步从源头就错了。打包器通常维护一个全局的 90kHz 时钟音视频各自按自己的帧率推进写入时统一换算。4. 打包流程从两路裸流到一条 TS 字节流4.1 整体流水线的阶段划分把 H.264 和 AAC 打成 TS本质是一条四阶段流水线解析裸流得到访问单元、封装 PES、切分并加 TS 头、插入 PSI 和 PCR。每个阶段都有明确的输入输出契约任何一段出错都会在最终文件里留下痕迹。第一阶段解析 H.264 时要按起始码切 NAL识别 SPS/PPS/IDR/P 帧把属于同一帧的 NAL 聚成一个访问单元。AAC 则按 ADTS 头里的 frame_length 切帧。第二阶段给每个访问单元加 PES 头和时间戳。第三阶段把 PES 字节流按 184 字节切片每片加 TS 头首片置 PUSI。第四阶段周期性插入 PAT/PMT并在视频 PID 上按间隔插入 PCR。4.2 切片与填充184 字节的分配逻辑切片阶段最考验细节。一个 PES 包可能几百 KB要切成上千个 TS 包。第一个包带 PES 头PUSI1后续包 PUSI0。最后一个包如果 payload 不足 184 字节用自适应字段填充。填充不是随便补零而是要在 adaptation_field 里写 stuffing bytes长度字段如实反映。// 把一个 PES 缓冲切成 TS 包核心循环示意 int offset 0; while (offset pes_len) { int remain pes_len - offset; int payload remain 184 ? 184 : remain; int need_adapt (payload 184) || (pcr_due_here); write_ts_header(buf, pid, offset 0, need_adapt, cc); if (need_adapt) { int adapt_len 183 - payload; // 自适应字段长度 buf[4] adapt_len; if (pcr_due_here) write_pcr(buf 5, pcr_base); // 其余字节作为 stuffing } memcpy(buf 4 (need_adapt ? 1 adapt_len : 0), pes_buf offset, payload); offset payload; }这里cc是 continuity_counter同一 PID 内循环递增。pcr_due_here由 PCR 间隔策略决定通常每 40ms 左右插一次。注意自适应字段长度字段本身占 1 字节所以实际可用 payload 要相应减少这个 off-by-one 是手工实现时的高频翻车点。4.3 PCR让解码器知道“现在几点”PCRProgram Clock Reference是 TS 里的系统时钟参考放在自适应字段里42 位同样以 90kHz 为基准另有 27MHz 扩展。它的作用是让解码器校准本地时钟保证音视频按正确速率播放。PCR 间隔过大解码器时钟漂移表现为播放速度忽快忽慢间隔过小浪费带宽。常见策略是每 40ms 插一个 PCR且必须插在视频 PID 上或专门的 PCR PID。PCR 值要和 PTS 保持合理关系通常 PCR 略早于对应帧的 DTS给解码器留出缓冲时间。4.4 PSI 表的周期插入与版本管理PAT 和 PMT 不是写一次就完事要周期性重复。实现上通常维护一个“距离上次插入的字节数或包数”计数器达到阈值就插一组。表里的 version_number 在节目结构变化时才递增播放器靠它判断是否需要重新解析。如果打包器每次都改 version 却不改内容某些严格的播放器会反复重载造成卡顿。5. 避坑与排查那些让播放器沉默的细节5.1 现象有画面无声音PMT 里找不到音频 PID原因通常是 PMT 只写了视频流条目或者音频 stream_type 写错把 AAC 写成 0x0F 之外的値。解决方法是 dump 出 PMT 逐字段核对确认音频条目的 stream_type0x0F、elementary_PID 与实际音频 TS 包的 PID 一致。很多打包器把 PID 分配和 PMT 写入分成两处代码改了一处忘了另一处。5.2 现象播放几秒后卡住日志显示 continuity 跳变原因是同一 PID 的 continuity_counter 没有正确递增或者多线程写同一个 PID 时计数器竞争。解决方法是把计数器绑定到 PID 上下文写入路径串行化排查时用工具统计每个 PID 的 cc 序列看跳变点对应哪段代码。5.3 现象音视频不同步且偏差随时间增大原因是音频和视频用了不同的时间基准或者音频 PTS 按帧数累加时用了错误的采样率。解决方法是统一用 90kHz 全局时钟音频每帧推进1024 * 90000 / sample_rate视频按帧率推进写入前打印两端 PTS 对比。5.4 现象文件能被某些播放器打开另一些直接报错原因是 PSI 表插入频率太低或者文件开头没有立即出现 PAT/PMT。严格的播放器在开头若干包内找不到 PSI 就放弃。解决方法是在文件起始处强制先写一组 PAT/PMT再开始写音视频包。5.5 现象大帧视频花屏小帧正常原因是 PES 包超过 65535 字节时PES_packet_length处理不当或者切片时最后一个包的填充长度算错导致越界。解决方法是视频 PES 长度置 0 走无界模式切片循环里严格用remain控制拷贝长度填充字节单独 memset。6. 设计取舍为什么 TS 至今仍是直播与封装的主力TS 的设计处处体现“为不可靠传输和随机接入让路”的思路定长包换同步能力PSI 间接寻址换 PID 分配自由PCR 换时钟恢复能力PES 层换时间戳表达力。代价是头部开销明显高于很多现代容器一个 188 字节包里有效载荷常常只有 170 多字节带宽利用率并不漂亮。但在需要中途接入、丢包可恢复、硬件解复用成本低的场景里这套取舍依然划算。验证一个打包实现是否正确我习惯走三步先用工具把 TS 拆回音视频裸流比对帧数和时间戳再用严格模式的播放器从头拖到尾观察是否卡顿最后人为删掉中间几个包看解复用器是否能靠 0x47 重新同步而不是整段崩溃。这三步能覆盖绝大多数打包错误。有个血泪经验别在写完打包器后只看“能播”一定要 dump 出前 20 个包的十六进制逐字节核对同步字节、PUSI、PID、cc 和 PSI 内容。很多玄学问题在十六进制里一目了然。从那以后我每次做 TS 封装都强制先跑一遍包头 dump 再谈功能希望帮到你。本文还有配套的精品资源点击获取