ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

H.265/HEVC分层码流结构详解:NAL、参数集与实战排障

H.265/HEVC分层码流结构详解:NAL、参数集与实战排障 调试视频播放器或者做流媒体传输的时候最怕遇到的就是明明H265编码出来的码流能解码可一到弱网或者切流场景就翻车花屏、卡顿、首帧时间太长。搞了几年音视频之后我越来越觉得问题往往不在编码器本身而在于对H265/HEVC的分层码流结构缺乏系统认知。这篇文章想把视频分层码流中那些最核心的语义元素掰开揉碎讲清楚——它们在哪里、由谁定义、怎么读取、以及实际排查问题时怎么用得上。适合刚接触H265编码原理、或者正在做播放器、推流服务时被码流问题折磨过的同学参考。1. H.265码流为什么要分层先理解设计意图1.1 从H.264到H.265码流组织方式的继承与变化在H.264时代NALU网络抽象层单元这个概念被正式引入核心思想是把编码后的视频数据切成一个个“带标签”的包交给传输和存储去处理。H.265/HEVC完全继承了这条路线但把VCL视频编码层和NAL网络抽象层的职责切分得更彻底。VCL只负责“怎么把像素变成比特”也就是预测、变换、量化、熵编码这些脏活累活NAL负责“比特怎么装进包”包括类型标记、分层标识、传输封装。我习惯把VCL比作仓库里的货物把NAL比作包装箱和运单。货物本身是什么并不影响快递公司怎么运输但运单上写清楚了“这是什么类型的货物、要发到哪一层配送中心、需要什么特殊处理”。H.265的NAL Header就是这张运单只有2个字节却是理解整个码流的索引地图。和MPEG-2那种把分辨率、帧率、量化参数都堆在一个头部的老方案不同H.265把信息按作用范围分层放置整个视频序列级别的信息放在VPS/SPS里单张图像级别的信息放在PPS里每个切片的信息放在Slice Header里像素残差和运动矢量等真正的大块头数据放在CTU编码树单元里。这样设计的好处非常直接——哪个层级的信息过期了就只替换哪个层级的NALU不需要把整个头部重写一遍。1.2 分层结构带来的三个实际收益分层设计的第一个收益是网络自适应。H.265在NAL Header里用nuh_temporal_id_plus1字段直接标注了当前帧处于时间子层的哪个位置网络发生拥塞时中间节点可以按照“先丢最高时间层”的原则直接丢弃部分NALU不需要重新编码。低层帧的参考不依赖高层帧所以丢完之后解码器依然能出画面只是帧率暂时下降。第二个收益是并行处理与容错。一帧图像可以被切成多个Slice或者用Tile、WPP波前并行处理技术让多核解码器并行工作。分层结构在语法层面划清了边界不同slice的数据相互独立即便某个slice的比特流在传输中损坏错误也不会无限扩散到整帧。第三个收益是可扩展性。多视角视频、可伸缩编码都建立在分层的思想上。基础层给老解码器解码增强层提供更高分辨率或更高画质。就算终端不支持增强层也不至于完全黑屏。所以做码流分析时第一动作永远是看NAL Header里的那两个字节它告诉你后面这个包是什么角色、属于哪一层、优先级多高。2. NAL单元与语义元素码流的基本构成2.1 NAL Header的语义两个字节里的秘密H.265的NAL头固定2字节共16位具体位域分布如下表所示。很多初学者拿到码流不知道怎么下手其实只要把这16个bit拆出来NALU的角色基本就清楚了。位域长度语义forbidden_zero_bit1 bit必须为0非0说明比特流异常或损坏nal_unit_type6 bitNAL类型决定这个单元是参数集、关键帧还是普通帧nuh_layer_id6 bit层ID基础层固定为0nuh_temporal_id_plus13 bit时间层ID加1temporal_id 该值减1若等于0则非法nal_unit_type的取值是整个H.265语义体系的地基。下面的表列出了我在实际工作中最高频遇到的一些类型建议直接存在脑子里nal_unit_type含义典型场景0~9VCL单元包括TRAIL、TSA、STSA、RADL、RASL等普通的I/P/B帧数据16~18BLA帧随机访问点切流、开播时的关键帧19~20IDR帧解码立即刷新关键帧遇到它解码器可以清空参考帧21CRA帧开放GOP随机访问点比IDR更灵活的随机接入点32VPS 视频参数集描述多层流的整体关系33SPS 序列参数集分辨率、档次、CTU尺寸等序列级信息34PPS 图像参数集初始化QP、并行编码方式等图像级信息35AUD 访问单元分隔符标记一个访问单元的开始39/40SEI 补充增强信息附加信息如HDR元数据、时间码理解了这些类型你就能回答一个很常见的问题“这段H265码流里哪些NALU是关键帧”答案是看nal_unit_type19、20是IDR21是CRA16~18是BLA。只要统计出这些类型NALU的位置GOP边界就画出来了。实际操作中解析NAL Header只需要几行代码。我经常写一个Python小脚本扫裸流下面这段可以直接拿去跑import sys def split_annexb(data): 按起始码切分Annex B格式的NALU返回每个NALU的起始偏移和结束偏移 nalus [] i 0 n len(data) while i n - 3: if data[i] 0 and data[i1] 0: if data[i2] 0 and data[i3] 1: nalus.append([i, -1]) i 4 continue if data[i2] 1: nalus.append([i, -1]) i 3 continue i 1 for j in range(len(nalus) - 1): nalus[j][1] nalus[j1][0] nalus[-1][1] n return nalus def parse_nal_header(nal_data): 解析H.265 NAL Header返回类型、层ID、temporal_id h0 nal_data[0] h1 nal_data[1] forbidden_bit (h0 7) 0x01 nal_type (h0 1) 0x3F layer_id ((h0 0x01) 5) | ((h1 3) 0x1F) temporal_id_plus1 h1 0x07 return forbidden_bit, nal_type, layer_id, temporal_id_plus1 with open(sys.argv[1], rb) as f: data f.read() nalus split_annexb(data) for start, end in nalus: # 跳过起始码本身 if data[start:start3] b\x00\x00\x01: offset start 3 else: offset start 4 if end - offset 2: continue fb, nt, lid, tid_plus1 parse_nal_header(data[offset:end]) print(foffset0x{start:08x} size{end-start:6d} type{nt:2d} layer_id{lid} temporal_id{tid_plus1 - 1})运行之后你会看到典型的码流开头是VPS32、SPS33、PPS34、SEI39或40然后才是IDR19或20再往后是普通的非IDR帧。这个“参数集先行”的结构是自己动手验证H265码流语义的第一步强烈建议亲手跑一遍。2.2 参数集SPS和PPS是怎么被消费的SPS和PPS是解码器能否正常工作的前提丢一个都起不来。SPS里承载的是序列级别的全局信息下面这几个语义元素最关键字段作用sps_video_parameter_set_id关联到哪个VPSsps_max_sub_layers_minus1最大时间子层数减1判断是否开启时间分层general_profile_idc / general_level_idc档次和级别决定解码能力要求chroma_format_idc色度格式0/1/2/3分别对应4:0:0、4:2:0、4:2:2、4:4:4pic_width_in_luma_samples图像宽度单位是亮度样本pic_height_in_luma_samples图像高度单位是亮度样本bit_depth_luma_minus8亮度位深减80就是8bit2就是10bitlog2_max_pic_order_cnt_lsb_minus4POC字段位宽影响帧序重建log2_min_luma_coding_block_size_minus3最小编码块尺寸相关常见为3表示8x8log2_diff_max_min_luma_coding_block_size与上一项一起决定CTU尺寸有个细节值得专门强调H.265分辨率字段不叫width/height而是叫pic_width_in_luma_samples和pic_height_in_luma_samples。如果你做转封装想从码流里拿准确分辨率必须去SPS里读这两个字段。容器头里的宽高可能被muxer写错但SPS里的值是解码器实际消费的信息它错了码流根本解不出来所以永远以它为准。PPS则更偏向图像级解码配置。init_qp_minus26控制初始量化步长直接影响码率波动entropy_coding_sync_enabled_flag和tiles_enabled_flag决定播放器能不能用WPP或Tile做并行解码deblocking_filter_control_present_flag控制环路滤波相关参数。这些字段平时不起眼但一旦和播放器内部的优化逻辑产生冲突就会出现“同样的文件在手机播放器上没问题在电视上花屏”的诡异bug。排查时把SPS/PPS完整dump出来逐项比对比瞎猜配置管用得多。2.3 Slice层与CTU从帧到编码块的层级单个slice的码流组织是Slice Header开头后面跟着一大串CTU数据。Slice Header里值得关注的字段包括first_slice_segment_in_pic_flag这个slice是不是一帧里的第一个片段slice_typeI、P、B类型和NAL类型配合判断参考属性slice_pic_order_cnt_lsb帧序号的低bit位重建显示顺序用short_term_ref_pic_set_sps_flag短期参考帧集是引用SPS定义还是内联传输slice_qp_delta当前slice相对PPS初始QP的偏移CTU是HEVC引入的核心概念由SPS里的log2_min_luma_coding_block_size_minus3和log2_diff_max_min_luma_coding_block_size共同决定尺寸常见配置是最大64x64、最小8x8。CTU会继续递归切分成CU编码单元、PU预测单元、TU变换单元帧内预测模式、帧间运动矢量、变换系数这些真正的“编码结果”就分布在这些单元里。这部分才是H265编码原理里最耗算力的地方。不过从码流分析的角度大多数人其实不需要逐bit解析CTU内部所有语法元素。真正常遇到的排查场景是确认NAL类型、检查参数集、计算POC和时间层。把CTU的结构搞清楚是为了在看到大段不可读的熵编码数据时不慌知道它内部有层次、有边界、在必要时可以用工具深入拆解。3. 视频分层码流时间、空间、质量三轴怎么在码流里体现3.1 时间分层GOP结构里temporal_id的作用先澄清一个概念层不等于帧类型。H.265在NAL Header中用nuh_temporal_id_plus1表示当前帧在时域依赖链路里的位置。最简单的IPPP结构所有P帧都直接参考前一帧temporal_id全部为0这叫单层流。而分层B帧结构里I帧或关键参考帧的temporal_id为0后续B帧按依赖深度逐级递增常见的3层结构就是0、1、2、3这样的分布。时间分层之所以重要是因为它在实时传输里提供了“丢帧保流畅”的手段。网络拥塞时码率自适应模块可以直接丢弃temporal_id最高的NALU降低码率却不影响已解码部分的连续播放只是帧率下降。这比通知编码器重新编码快得多也稳得多是很多低延迟直播系统的底层策略之一。实际检测一段码流是否真正开了时间分层靠的不是看SPS里的sps_max_sub_layers_minus1而是把一段时间内的VCL NALU全部扫一遍统计temporal_id分布。如果全是0说明编码器实际输出的是单层流如果出现0、1、2混合说明时域可伸缩确实在工作。我自己的脚本会输出一张聚合表按temporal_id统计帧数和码率一眼就能看出分层情况和每层占比。3.2 空间分层与质量分层nuh_layer_id在什么场合会被置非0HEVC标准本身支持空间分层和质量分层实现方式是在基础层之上叠加增强层用nuh_layer_id来区分。典型场景是基础层720p加上增强层1080p或者基础层用较粗量化、增强层补充残差提高信噪比。解码端能力够就两层一起解画质更高能力不够只解基础层也能正常出画面。但说句实在话在普通直播和点播业务里严格遵循SHVC标准做多层编码的产品非常少。原因很直接多层编码对编码器算力、码流管理、播放器支持都有额外要求部署成本偏高。行业里更常见的做法是“双流”策略——编码器同时输出一路高分辨率主码流和一路低分辨率子码流播放端按网络情况切换。这是应用层的分流不是码流内部的分层但产品效果接近空间可伸缩。这里有一个容易踩的坑解析时看到nuh_layer_id非0不要直接判定“码流异常”。普通单层流里它确实应该等于0但标准允许可伸缩流使用非0的层ID只要层间依赖关系描述正确非0就是合法数据。判断码流是不是真的多层要看VPS里的层次描述和实际VCL NALU的layer_id分布而不是单看某一个包。3.3 分层码流的实际分析流程我自己拿到一段陌生H.265裸流会按下面的流程走一遍全程半小时内就能把码流的基本底细摸清楚第一步先用ffprobe看基础信息ffprobe -v error -show_entries streamcodec_name,width,height,pix_fmt,profile,level -select_streams v:0 -of defaultnoprint_wrappers1 input.h265第二步用前面那段Python脚本做NALU分类统计输出每个NALU的偏移、类型、temporal_id和大小。这一步能快速识别码流里有没有VPS/SPS/PPS、关键帧间隔多大、时间分层长什么样。第三步如果需要对SPS/PPS做深入解析直接用Python的h265nal库比自己手写位解析靠谱得多。安装很简单pip install h265nal然后调用它解析出SPS/PPS里各个字段的值再对关键字段做判断。比如确认chroma_format_idc是不是14:2:0确认位深是不是8确认CTU尺寸是不是64x64。第四步把时间层统计输出成聚合表。按temporal_id分组统计帧数和字节数能直接看出每一层的码率占比。如果最高时间层占了大量码率但实际网络带宽不够那就轮到时域裁剪策略出场了。还要提醒一个封装层面的问题裸流最常见的封装是Annex B格式用00 00 00 01或00 00 01做起始码但MP4文件内部用的是长度前缀格式也就是每个NALU前面是4字节大端长度。解析前必须先确认输入格式否则切分NALU会全错。判断方法很简单Annex B流的开头一定是00 00 00 01或00 00 01而长度前缀流的开头前四个字节是一个合法的长度值。这个点很基础但我在技术群里见过不少人卡在这里。4. 我在实战中踩过的坑码流解析常见问题与排查4.1 解码花屏或绿屏SPS/PPS缺失或错误现象非常典型播放器几秒黑屏接着花屏再慢慢恢复。日志里往往会出现“no VPS/SPS/PPS”或者“first frame is not keyframe”之类的提示。我遇到的原因大概有三类推流端只在第一个关键帧携带参数集中途接入的播放器拉不到RTSP的sprop-parameter-sets字段配置错误base64解出来内容对不上从TS流解复用转存时参数集在PES封装里被错误拼接或丢弃。排查思路是先用ffmpeg观察解码器初始化时到底有没有拿到参数集ffmpeg -v trace -i issue.ts -f null - 21 | grep -E VPS|SPS|PPS|extradata如果日志里参数集缺失就去源头看推流端是否在每个关键帧的访问单元里都带上了参数集。RTSP场景重点检查SDP里的sprop-parameter-sets用base64解码后和裸流里提取的SPS/PPS逐字节比较任何一位不一致都会导致解码失败。经验之谈很多编码器默认只在IDR前带一次SPS/PPS这在点播文件里没问题但在直播业务里中途接入的播放器不一定能等到下一个IDR如果参数集又没有通过会话控制协议传过去就会出现“某个时间段内所有新进用户都起播失败”的事故。解决方案是二选一要么缩短参数集重发间隔比如每1到2秒重复插入一次要么确保会话控制协议里正确携带参数集。4.2 POC乱跳与画面卡顿时间层参考链断裂有次排查一个低延迟直播项目用户反馈画面每隔几分钟卡一下但服务器端码率、帧率看起来都正常。最后抓了接收端的码流日志发现传输阶段丢了一些非参考B帧但丢包不是按时间层精确丢弃的把低层参考帧也一起丢了。播放端尝试解码参考帧缺失后运动补偿全部出错画面就短暂花屏。从码流语义上看问题会体现在POC跳变和某个temporal_id层帧数突然变少。验证方法是把接收端收到的码流按序存下来跑NALU扫描脚本统计每个temporal_id层的帧数再和编码器输出的原始GOP结构做比对。差别一出来问题就锁定了。这里也提醒一个容易忽略的坑H.265的POC不是简单从0开始累加的。它在slice header里通过slice_pic_order_cnt_lsb承载有效位宽由SPS的log2_max_pic_order_cnt_lsb_minus4决定。如果这个字段设置得太小GOP很长时POC会发生回绕解码器按无符号数处理就会把帧序算错。我的习惯是长GOP、高帧率场景下保证这个字段的位宽至少比最大GOP长度多1个bit。省这几个bit省不出多少码率但踩进去就是个大坑。4.3 裁剪时间层后无法解码参考帧完整性和语义自洽不少做边缘转码的同学想通过直接丢NALU的方式把8层时间分层流裁成4层结果发现播放偶尔花屏甚至直接不能起播。原因在于时间层裁剪不是“丢掉高层NALU”这么简单它要同时解决两个问题。第一是要保证删掉高层帧之后剩余帧的参考链是完整的。有些B帧虽然temporal_id最高但它同时可能被同层或低层帧参考简单粗暴地按ID过滤会把参考链打断。第二是要同步修改SPS中的sps_max_sub_layers_minus1、profile_tier_level里的子层字段以及VUI/HRD相关参数否则解码器按原字段初始化却收到一个被裁剪过的码流语义上不自洽就会拒绝解码或者输出怪异画面。遇到这类需求我的建议是在编码器侧做时域可伸缩输出。提前告诉编码器目标子层数让它产出带有正确子层描述的码流比事后裁剪可靠一个数量级。我早期也写过按temporal_id过滤的脚本看着简单但测试视频里一旦出现跨层参考就露馅后来果断放弃改用编码器配置从源头解决。4.4 常见问题速查现象可能原因排查方向播放器黑屏/花屏SPS/PPS缺失或错误检查关键帧前是否携带参数集比对sprop-parameter-sets画面卡顿但码率正常POC回绕或时间层参考链断裂检查log2_max_pic_order_cnt_lsb_minus4统计temporal_id分布裁剪时间层后无法解码参考帧不完整或SPS子层字段未同步更新编码器侧输出可伸缩流不要事后硬裁同一个文件不同设备表现不一致SPS/PPS中并行编码、滤波字段差异dump参数集逐项比对这张表是我日常排查时用来对症状的工具不一定覆盖所有情况但大部分常见问题都能定位到对应方向。5. 常用工具与个人心得最后分享几个我日常分析H.265码流真正用得上的工具组合。ffprobe负责第一层侦查命令简单高效看编码器信息、分辨率、档次级别、码率几秒钟出结果。h265nal这个Python库用来深入解析NALU里的语义元素能直接输出SPS/PPS以及slice header的结构树写自动化脚本时比手写位解析省事得多。ffmpeg的-trace日志是用来排查SPS/PPS缺失问题的利器特别是问题码流是TS封装或RTSP流时它能把你解码器初始化的每一步都打出来。再加上前面那个自写NALU扫描脚本我基本上靠这四个工具就能覆盖绝大多数码流分析场景。个人心得方面我想强调三点。第一遇到一个陌生码流不要一上来就陷进某个语法元素的细节里。先跑一遍NALU类型统计把VPS/SPS/PPS/IDR/普通帧的分布看清整体结构就有数了。细节排查是后面的事第一轮侦查要的是全局视图。第二修改参数集、裁剪分层流这类操作一定留好原始码流备份每做一步就用ffprobe或者播放器解码验证一次。很多诡异问题不是语义本身导致的而是操作路径不对。改了SPS没改PPS或者改了PPS没同步VUI这种错误在日志里看起来就像“解码器疯了”其实只是中间某一步没做全。第三H.265的NAL类型定义和sub_layer字段位置都在标准文档的Table 7-1、7-2附近遇到任何第三方资料和标准冲突的情况以标准为准。如果要从零开始学H265编码原理我建议的顺序是先看码流长什么样理解NAL和起始码再看参数集说了什么吃透SPS/PPS里的关键字段最后才进入Slice和CTU的细节。这个路径比直接啃标准的语法定义平滑得多也更容易在实际排查中派上用场。希望这篇文章能帮你少走我走过的弯路。
返回列表