H.264与AAC裸流封装为MPEG-TS:原理、实现与避坑指南
简介:这份资源聚焦音视频封装中的关键环节,面向从事流媒体开发、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,而不是 MP4
2.1 容器选型的现实考量
把 H.264 和 AAC 装进容器,常见选择是 MP4 和 TS。MP4 的 moov box 结构适合点播、文件存储,索引集中,随机 seek 方便;但它的短板在于流式场景——moov 要么前置要么后置,边生成边播放时处理起来别扭,而且一旦流中断,文件尾部不完整,整个文件可能打不开。TS 则是为传输设计的:固定 188 字节的包,每个包自带 PID,PAT/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 头;后续包直接续数据。
第四层是复用与时钟。PAT(PID 0)告诉解复用器有哪些节目,PMT(PID 由 PAT 指定,常见 0x1000)告诉它每个节目的视频、音频各用哪个 PID。PCR(Program Clock Reference)要周期性插入,通常放在视频 PID 的适配字段里,间隔不超过 100ms,接收端靠它恢复系统时钟。
2.3 关键参数与 PID 分配
封装器里有一组参数必须一开始就定好,中途改会出乱子:
| 参数 | 典型值 | 说明 |
|---|---|---|
| TS 包长 | 188 字节 | 固定,不可变 |
| PAT PID | 0x0000 | 规范固定 |
| PMT PID | 0x1000 | 可自定义,需与 PAT 一致 |
| 视频 PID | 0x0100 | 可自定义 |
| 音频 PID | 0x0101 | 可自定义 |
| stream_type(H.264) | 0x1B | 规范定义 |
| stream_type(AAC) | 0x0F | ADTS AAC |
| PCR 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 是 SPS,8 是 PPS,5 是 IDR 帧的 slice,1 是非 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[i+1] == 0 && buf[i+2] == 1) { size_t start = i + 3; size_t j = start; // 找下一个起始码作为当前 NAL 的结束 while (j + 3 < len) { if (buf[j] == 0 && buf[j+1] == 0 && buf[j+2] == 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(类型 1~5)且上一个 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 payload,PMT 里 stream_type 写 0x0F;二是剥掉 ADTS 头只送 raw AAC,stream_type 写 0x11,但后者需要额外传 AudioSpecificConfig,实现更复杂。这个项目走的是前者,简单直接,播放器兼容性也好。
采样率索引到实际采样率的映射是固定表:0→96000,1→88200,2→64000,3→48000,4→44100,5→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 独立递增,0~15 循环,写死 0 在简单播放器上能过,但严格解复用器会报错;适配字段长度字段是「长度减一」,写错会导致整个包解析偏移;PCR 的 6 位保留位必须全 1(0x7E那部分),否则时钟恢复失败。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 写 0x1B,AAC 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_type:H.264 是 0x1B,AAC ADTS 是 0x0F,AAC 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 0x0A,188 字节包被撑大,同步字节全乱。
解决:所有二进制读写一律"rb"/"wb"。这个坑不分语言,Python 里open(..., 'wb')同理,少个 b 就翻车。
5.4 大文件播到中途卡死
现象:短文件正常,长文件播到某个点卡住或解码报错。
原因:continuity_counter没递增,或者 PES 长度字段超过 65535 没处理。前者让解复用器认为丢包,后者导致 PES 头解析越界。
解决:每个 PID 维护独立计数器,0~15 循环;视频 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 这层的问题往往在几百个包之后才暴露,短流能快速筛掉大部分低级错误,省得在长流里大海捞针。希望帮到你。
本文还有配套的精品资源,点击获取