
简介这份资源聚焦音视频封装中的关键环节面向从事流媒体开发、IPTV或实时传输的工程师与学习者解决如何把H.264视频裸流与AAC音频数据按规范打包成TS传输流的问题。压缩包共3个文件以C语言源码与头文件为主包含封装逻辑实现与调用入口整体约13KB体量轻便便于直接阅读、编译和嵌入现有工程。资源已有2129人学习下载说明其在音视频打包方向具有一定参考价值。读者可从中了解NAL单元切分与起始码添加、TS包188字节结构、PID分配区分音视频、CRC校验与填充容错等要点并对照源码理解从裸流到TS、再衔接RTSP传输的完整链路适合作为封装模块的学习样例或二次开发起点。1. 从两段裸流到一个能播的 TS这项目到底解决什么问题手头有一路 H.264 裸流和一路 AAC 裸流单独拿出来都能被播放器认可一旦要往播放器、机顶盒或者流媒体服务里送问题就来了播放器要的是一个容器把视频、音频、时间戳、节目信息全打包在一起。这个项目干的就是这件事——把 H.264 视频裸流和 AAC 音频数据按 MPEG-TS 规范封装成一个.ts文件让两路原本互不相干的 ES 流变成一路可播放、可切片的传输流。适合谁做音视频采集、录屏、监控回传、直播推流前处理的开发者尤其是手里已经有编码后的裸数据、只差最后一步封装的场景。TS 这层封装看着简单真动手写PAT/PMT、PES 分包、PCR 插入、时间戳基准一个都绕不过去。2. 技术选型与整体架构为什么是 TS而不是 MP42.1 容器选型的现实考量把 H.264 和 AAC 装进容器常见选择是 MP4 和 TS。MP4 的 moov box 结构适合点播、文件存储索引集中随机 seek 方便但它的短板在于流式场景——moov 要么前置要么后置边生成边播放时处理起来别扭而且一旦流中断文件尾部不完整整个文件可能打不开。TS 则是为传输设计的固定 188 字节的包每个包自带 PIDPAT/PMT 周期性重复接收端从任意位置切入都能重新同步。这也是为什么直播、监控、数字电视广播几乎清一色用 TS。这个项目选 TS本质上是选了「流式友好 抗损坏」这条路。代价是 TS 本身不压缩、开销大每个包有 4 字节包头加上 PES 头和适配字段实际开销在 5%10% 量级而且没有全局索引seek 要靠解析 PAT/PMT 和时间戳。对录制回传、边采边传这类场景这些代价完全值得。技术栈上这类封装器通常用 C/C 实现直接操作字节缓冲区性能可控也有用 Python 做原型验证的方便调试。下面讲结构时以通用的分层思路展开语言不是重点重点是每一层负责什么。2.2 分层架构从 ES 到 TS 的四层转换整个封装流程可以拆成四层每层职责清晰ES 层H.264 NAL / AAC ADTS ↓ 去掉/保留起始码切分访问单元 PES 层Packetized Elementary Stream ↓ 加 PES 头写入 PTS/DTS TS 层188 字节固定包 ↓ 加 TS 头分配 PID必要时插适配字段 复用层PAT / PMT / PCR ↓ 周期性插入节目表和时钟参考第一层是 ES 处理。H.264 裸流通常是 Annex-B 格式NAL 单元之间用00 00 00 01或00 00 01起始码分隔AAC 裸流常见的是 ADTS 格式每个帧头 7 或 9 字节带采样率、声道数信息。这一层要把连续字节流切成一个个访问单元AU视频一个 AU 通常是一帧一个 SPS/PPS 加若干 slice NAL音频一个 AU 就是一帧 ADTS。第二层是 PES 打包。每个 AU 前面加 PES 头写入 PTS显示时间戳和 DTS解码时间戳。视频因为有 B 帧DTS 和 PTS 可能不同音频没有重排序PTS 等于 DTS。PES 头里还要写 stream_id视频一般是0xE0音频0xC0。第三层是 TS 分包。PES 包可能远大于 184 字节188 减去 4 字节 TS 头所以要切成多个 TS 包。每个 TS 包 4 字节头同步字节0x47、PID、payload_unit_start_indicator标识是不是 PES 起始、adaptation_field_control 等。PES 起始的那个 TS 包payload 前面要放 PES 头后续包直接续数据。第四层是复用与时钟。PATPID 0告诉解复用器有哪些节目PMTPID 由 PAT 指定常见 0x1000告诉它每个节目的视频、音频各用哪个 PID。PCRProgram Clock Reference要周期性插入通常放在视频 PID 的适配字段里间隔不超过 100ms接收端靠它恢复系统时钟。2.3 关键参数与 PID 分配封装器里有一组参数必须一开始就定好中途改会出乱子参数典型值说明TS 包长188 字节固定不可变PAT PID0x0000规范固定PMT PID0x1000可自定义需与 PAT 一致视频 PID0x0100可自定义音频 PID0x0101可自定义stream_typeH.2640x1B规范定义stream_typeAAC0x0FADTS AACPCR PID同视频 PID常见做法PAT/PMT 重复间隔100ms 左右保证快速切入PID 分配没有强制规定除了 PAT 必须是 0但工程上习惯把视频放低、音频紧随其后PMT 用 0x1000 是很多实现的默认。PCR 放在视频 PID 上是因为视频帧间隔稳定便于接收端按固定节奏取时钟。3. 核心模块拆解从 NAL 切分到 TS 包输出3.1 H.264 裸流解析起始码切分与 AU 组装H.264 Annex-B 裸流没有长度前缀靠起始码分隔 NAL。解析的第一步是扫描起始码把每个 NAL 单元切出来同时识别 NAL 类型nal_unit_type byte 0x1F。类型 7 是 SPS8 是 PPS5 是 IDR 帧的 slice1 是非 IDR slice。// 扫描 Annex-B 起始码切出 NAL 单元 // buf: 输入裸流; len: 长度; out: 输出 NAL 列表 int split_annexb(const uint8_t *buf, size_t len, NalUnit *out, int max) { int count 0; size_t i 0; while (i 3 len) { // 找 00 00 01 或 00 00 00 01 if (buf[i] 0 buf[i1] 0 buf[i2] 1) { size_t start i 3; size_t j start; // 找下一个起始码作为当前 NAL 的结束 while (j 3 len) { if (buf[j] 0 buf[j1] 0 buf[j2] 1) break; j; } if (count max) { out[count].data buf start; out[count].size j - start; out[count].type buf[start] 0x1F; // NAL 类型 count; } i j; } else { i; } } return count; }这段代码的关键点起始码可能是 3 字节也可能是 4 字节扫描时统一按00 00 01匹配前面的额外00会被归到上一个 NAL 的尾部实际封装时通常要剥掉尾部零字节。out[count].type取 NAL 头低 5 位用来判断是不是 SPS/PPS/IDR。参数max防止越界实际工程里 NAL 列表用动态数组更稳妥。切出 NAL 之后要组装访问单元AU。一个 AU 是一帧的完整数据边界判断规则是遇到 VCL NAL类型 15且上一个 NAL 也是 VCL或者遇到 SPS/PPS/SEI 等非 VCL NAL 时认为新 AU 开始。简化实现里很多封装器按「SPS 出现即新帧」或「IDR 出现即新帧」来切对没有 B 帧的流够用但有 B 帧时可能切错需要结合first_mb_in_slice判断。3.2 AAC ADTS 解析帧头提取与帧长计算AAC 裸流如果是 ADTS 格式每帧开头有固定头里面藏着帧长和采样信息。ADTS 头 7 字节无 CRC或 9 字节有 CRC关键字段// 解析 ADTS 头返回帧长失败返回 -1 int adts_frame_len(const uint8_t *p) { if (p[0] ! 0xFF || (p[1] 0xF0) ! 0xF0) return -1; // 同步字校验 int protection_absent p[1] 0x01; // 1 表示无 CRC int profile (p[2] 6) 0x03; // AAC profile int sample_rate_idx (p[2] 2) 0x0F; int channel_cfg ((p[2] 0x01) 2) | ((p[3] 6) 0x03); int frame_len ((p[3] 0x03) 11) | (p[4] 3) | ((p[5] 5) 0x07); (void)protection_absent; (void)profile; (void)sample_rate_idx; (void)channel_cfg; return frame_len; // 整个 ADTS 帧的长度含头 }frame_len是包含 ADTS 头的整帧长度封装时有两种做法一是把整个 ADTS 帧含头作为 PES payloadPMT 里 stream_type 写 0x0F二是剥掉 ADTS 头只送 raw AACstream_type 写 0x11但后者需要额外传 AudioSpecificConfig实现更复杂。这个项目走的是前者简单直接播放器兼容性也好。采样率索引到实际采样率的映射是固定表0→960001→882002→640003→480004→441005→32000……计算 PTS 时要用到它。AAC 一帧固定 1024 个采样点所以帧间隔 1024 / sample_rate 秒累加即可得到每个音频帧的 PTS。3.3 PES 打包PTS/DTS 写入与长度字段PES 头结构起始码00 00 01 stream_id PES_packet_length 标志位 头长度 PTS/DTS。视频 PES 长度常写 0表示不定长由 TS 层界定音频可以写实际长度。// 构造 PES 头写入 PTS和可选 DTS // pts/dts 单位是 90kHz 时钟 int write_pes_header(uint8_t *buf, int stream_id, int payload_len, uint64_t pts, uint64_t dts, int has_dts) { int i 0; buf[i] 0x00; buf[i] 0x00; buf[i] 0x01; buf[i] stream_id; int pes_len payload_len (has_dts ? 10 : 5) 3; buf[i] (pes_len 8) 0xFF; buf[i] pes_len 0xFF; buf[i] 0x80; // marker 10 buf[i] has_dts ? 0xC0 : 0x80; // PTS_DTS_flags buf[i] has_dts ? 10 : 5; // PES header data length // PTS: 4bit 前缀 3 组 15bit buf[i] 0x21 | ((pts 29) 0x0E); buf[i] (pts 22) 0xFF; buf[i] 0x01 | ((pts 14) 0xFE); buf[i] (pts 7) 0xFF; buf[i] 0x01 | ((pts 1) 0xFE); if (has_dts) { buf[i] 0x11 | ((dts 29) 0x0E); buf[i] (dts 22) 0xFF; buf[i] 0x01 | ((dts 14) 0xFE); buf[i] (dts 7) 0xFF; buf[i] 0x01 | ((dts 1) 0xFE); } return i; }PTS 的编码是 33 位拆成 3 段 15 位每段前面加 marker 位。上面代码里0x21、0x01、0x01这些是 marker 和前缀位的组合写错一位播放器就会把时间戳解析成天文数字表现为音画不同步或者直接不播。pes_len对视频常写 0这里为了通用算了实际值注意 PES_packet_length 最大 65535超长要写 0。时间戳基准PTS/DTS 用 90kHz 时钟即 1 秒 90000。视频帧的 PTS 按帧率累加比如 25fps 每帧 3600音频按 1024/采样率 换算。两路流的起始 PTS 要对齐到同一个基准否则一上来就音画错位。3.4 TS 分包与适配字段188 字节的拼装规则PES 包切成 TS 包时每个 TS 包 188 字节头 4 字节剩下 184 字节放 payload 或适配字段。PES 起始包要带 PES 头所以 payload 空间更小如果剩余空间不足 184用适配字段填充。// 把一个 PES 包切成 TS 包写出 // pid: 目标 PID; pcr: 可选 PCR 值仅视频包用 void write_ts_packets(FILE *fp, const uint8_t *pes, int pes_len, int pid, int64_t pcr, int with_pcr) { int offset 0; int first 1; while (offset pes_len) { uint8_t pkt[188]; memset(pkt, 0xFF, 188); int payload_cap 184; int af_len 0; // 需要插 PCR 时适配字段至少 8 字节1 长度 1 标志 6 PCR if (with_pcr first) af_len 8; int payload_len pes_len - offset; if (payload_len payload_cap - af_len) payload_len payload_cap - af_len; int stuffing payload_cap - af_len - payload_len; if (stuffing 0) af_len stuffing; // 用适配字段填充剩余 pkt[0] 0x47; pkt[1] (first ? 0x40 : 0x00) | ((pid 8) 0x1F); pkt[2] pid 0xFF; int afc (af_len 0) ? 0x30 : 0x10; // 有适配字段: 11, 仅 payload: 01 pkt[3] (afc 4) | 0x00; // continuity_counter 简化写 0 int pos 4; if (af_len 0) { pkt[pos] af_len - 1; if (with_pcr first) { pkt[pos] 0x10; // PCR_flag // PCR: 33bit base 6bit reserved 9bit ext pkt[pos] (pcr 25) 0xFF; pkt[pos] (pcr 17) 0xFF; pkt[pos] (pcr 9) 0xFF; pkt[pos] (pcr 1) 0xFF; pkt[pos] ((pcr 0x01) 7) | 0x7E; pkt[pos] 0x00; } else { pkt[pos] 0x00; // 无 PCR仅填充 } pos 4 af_len; // 跳到 payload 起点 } memcpy(pkt pos, pes offset, payload_len); fwrite(pkt, 1, 188, fp); offset payload_len; first 0; } }几个容易翻车的点continuity_counter每个 PID 独立递增015 循环写死 0 在简单播放器上能过但严格解复用器会报错适配字段长度字段是「长度减一」写错会导致整个包解析偏移PCR 的 6 位保留位必须全 10x7E那部分否则时钟恢复失败。with_pcr只在视频 PES 起始包上置位间隔按 40ms 左右插一次比较稳。3.5 PAT/PMT 与 PCR让解复用器找到流PAT 和 PMT 是 TS 的目录。PAT 在 PID 0内容极简一个节目号对应一个 PMT PID。PMT 列出该节目下所有流的 PID 和 stream_type。// 构造 PAT 包单节目 void write_pat(FILE *fp, int pmt_pid) { uint8_t sec[16]; int i 0; sec[i] 0x00; // table_id: PAT sec[i] 0xB0; // section_syntax_indicator 长度高位 sec[i] 0x0D; // section_length 13 sec[i] 0x00; sec[i] 0x01; // transport_stream_id sec[i] 0xC1; // version current_next sec[i] 0x00; sec[i] 0x00; // section_number / last sec[i] 0x00; sec[i] 0x01; // program_number 1 sec[i] 0xE0 | ((pmt_pid 8) 0x1F); sec[i] pmt_pid 0xFF; // CRC32 省略实际必须计算 write_ts_section(fp, 0x0000, sec, i); }PMT 结构类似多了节目信息长度和每个流的描述stream_type、elementary_PID、ES_info_length。H.264 写 0x1BAAC ADTS 写 0x0F。CRC32 是 MPEG-2 变体多项式 0x04C11DB7初值 0xFFFFFFFF不取反很多实现直接查表算。CRC 写错严格播放器会直接丢弃 PAT/PMT表现为「有数据但黑屏」。PAT/PMT 要周期性重发间隔 100ms 左右保证接收端随时切入都能拿到节目表。PCR 则挂在视频 PID 的适配字段里值取自系统时钟和 PTS 同基准接收端用它锁相恢复 27MHz 时钟。4. 跑起来编译、输入准备与验证4.1 环境与编译这类封装器依赖很少标准 C 库加文件 IO 就够。Linux 下用 gcc 直接编gcc -O2 -o ts_mux ts_mux.c -lm如果代码拆了多个文件把源文件都列上。Windows 下用 MinGW 或 MSVC 同理注意fopen的二进制模式要加rb/wb否则换行符会被转换TS 包直接错位——这是血泪经验第一次在 Windows 上跑出来文件大小对不上查了半天是文本模式惹的祸。4.2 输入准备裸流的来源与格式确认输入要求两路一路 H.264 Annex-B 裸流.h264/.264一路 AAC ADTS 裸流.aac。如果手里是 MP4先用 ffmpeg 拆# 拆出 H.264 裸流Annex-B ffmpeg -i input.mp4 -c:v copy -bsf:v h264_mp4toannexb -f h264 video.h264 # 拆出 AAC ADTS ffmpeg -i input.mp4 -c:a copy -f adts audio.aach264_mp4toannexb这个 bitstream filter 是关键MP4 里的 H.264 是 AVCC 格式长度前缀不转成 Annex-B起始码扫描会全部失败。拆完用ffprobe确认帧数和时长和原文件对得上再往下走。4.3 运行与参数典型调用形式./ts_mux video.h264 audio.aac output.ts如果封装器支持参数常见的有帧率、音频采样率、PID 配置。帧率影响视频 PTS 步长必须和编码时一致写错就是快放慢放。音频采样率从 ADTS 头读一般不用手填但有些实现允许覆盖用于流本身头信息损坏的情况。4.4 验证三层检查法封装完别急着扔播放器按三层查第一层看文件结构。ffprobe output.ts应该能列出 video 和 audio 两路流编码分别是 h264 和 aac时长接近输入。如果只识别出一路多半是 PMT 里漏了流或者 PID 对不上。第二层看包完整性。用ffmpeg -i output.ts -f null -跑一遍解码没有报错、没有大量「Packet corrupt」才算过。TS 包错位、PES 长度错、CRC 错都会在这一步暴露。第三层看播放。VLC 或 ffplay 打开重点听音画同步、看有没有花屏和卡顿。音画不同步查 PTS 基准花屏查 NAL 切分和 AU 边界卡顿查 PCR 间隔和 continuity_counter。5. 避坑与排查封装 TS 时最容易翻车的五件事5.1 播放器黑屏但有数据现象文件大小正常ffprobe能识别流但播放器黑屏或提示无节目。原因PAT/PMT 的 CRC32 算错或者 PMT 里 stream_type 写错比如 H.264 写成 0x01。严格解复用器校验 CRC 失败会直接丢弃节目表自然找不到流。解决单独抓一个 PAT 包用工具或手算校验 CRC对照规范确认 stream_typeH.264 是 0x1BAAC ADTS 是 0x0FAAC raw 是 0x11。改完重发间隔保持 100ms 以内。5.2 音画不同步越播越偏现象开头对得上播几分钟后音频超前或滞后。原因两路 PTS 用了不同基准或者音频帧间隔算错。AAC 一帧 1024 采样44.1kHz 下每帧约 23.22ms对应 90kHz 时钟是 2089.8取整累积会漂移视频按帧率累加如果用了浮点再截断也会偏。解决统一用整数累加音频 PTS 按frame_index * 1024 * 90000 / sample_rate算避免逐帧加固定值视频同理按帧序号算。两路起始 PTS 对齐到同一个值比如都从 0 或都从某个偏移开始。5.3 Windows 下生成的文件大小不对现象Linux 跑正常Windows 上输出文件比预期大或小播放器打不开。原因fopen没加b文本模式把0x0A转成0x0D 0x0A188 字节包被撑大同步字节全乱。解决所有二进制读写一律rb/wb。这个坑不分语言Python 里open(..., wb)同理少个 b 就翻车。5.4 大文件播到中途卡死现象短文件正常长文件播到某个点卡住或解码报错。原因continuity_counter没递增或者 PES 长度字段超过 65535 没处理。前者让解复用器认为丢包后者导致 PES 头解析越界。解决每个 PID 维护独立计数器015 循环视频 PES 长度直接写 0不定长音频如果单帧超长极少见也要写 0 并靠 TS 层界定。5.5 PCR 缺失导致时钟恢复失败现象某些机顶盒或硬件解码器上播放卡顿、音画漂移软件播放器却正常。原因PCR 没插或间隔太大。规范要求 PCR 间隔不超过 100ms实际工程 40ms 左右更稳。软件播放器容错强硬件解码器依赖 PCR 锁相缺了就出问题。解决在视频 PID 的 PES 起始包适配字段里插 PCR值取当前系统时钟和 PTS 同基准按帧间隔判断超过 40ms 就插一次。注意 PCR 的 6 位保留位写全 1。6. 二次开发把封装器改成能接实时流的模块拿到这个项目多数人不会只满足于命令行转文件真正要的是把它嵌进采集或推流链路里。改造的核心是把「读文件 → 封装 → 写文件」拆成「喂数据 → 封装 → 吐包」的流式接口。第一步把输入从文件读取改成回调。原来fread的地方换成on_video_data(buf, len)和on_audio_data(buf, len)内部维护一个缓冲区因为 NAL 和 ADTS 帧可能跨多次回调到达要攒够一个完整 AU 再封。缓冲区用环形队列注意处理起始码被切断的情况——这是实时场景最常见的翻车点半个起始码到了就急着切NAL 全乱。第二步把输出从fwrite改成回调吐 TS 包。每个 188 字节包生成后直接交给下游socket、推流库、环形缓冲不再落盘。这样封装器就变成了一个纯转换模块输入 ES、输出 TS和上下游解耦。第三步时间戳改成外部注入。实时场景的 PTS 来自采集时钟不能自己从 0 累加。接口上加set_base_pts(uint64_t)视频和音频各自按采集时间换算保证两路同基准。如果上游给的是相对时间封装器内部加偏移即可。第四步PAT/PMT 和 PCR 的插入改成按时间触发而不是按包计数。用一个单调时钟判断「距上次发 PAT 超过 100ms」「距上次插 PCR 超过 40ms」这样不管码率怎么变周期都稳。改造后的验证方法拿一路实时采集的裸流喂进去同时用 ffplay 直接播输出的 TS走本地 socket看延迟和同步。再拿同一个输入分别走「文件模式」和「流式模式」对比输出包内容除了时间戳基准结构应该完全一致。我一般会写个小脚本把两种模式的 TS 包按 PID 统计数量和类型对不上就说明流式路径有状态没清干净。最后说个习惯每次改封装逻辑我都会先用一个 10 秒的短裸流跑一遍ffprobe加ffmpeg -f null双验证再上长流和实时流。TS 这层的问题往往在几百个包之后才暴露短流能快速筛掉大部分低级错误省得在长流里大海捞针。希望帮到你。本文还有配套的精品资源点击获取