// engineering · bitstream
一个漏读的位字段,让后面 32 位全部错位
解析出来的色度采样格式是 655392。合法取值只有 0 到 3。这种「大到离谱」的数几乎总是指向 同一件事:字段没对齐,你读到的其实是别人的比特。根因是漏读了参数集结构里的一段保留位, 导致后面所有字段整体错位 32 位。
一、症状:一个不可能出现的数
色度采样格式的合法域是 0..3(单色 / 4:2:0 / 4:2:2 / 4:4:4)。读出
655392 这种值,说明比特流读取器本身没坏——它忠实地读出了一个数,只是
起点偏了。
为什么这种错误不会当场报错?因为 H.264 / H.265 的参数集里几乎全是变长编码 (Exp-Golomb)字段。Exp-Golomb 有个讨厌的性质:从任意比特位置开始都能读出一个值, 永远不返回「非法」。所以错位之后不会抛异常,只会一路错下去——第一个错得离谱的值, 往往出现在离漏读点很远的地方。
二、根源:一个由三段拼成的结构
H.265 的 profile_tier_level(标准 7.3.3)不是一张平的字段表,而是
三段拼接:
| 段 | 内容 | 何时存在 |
|---|---|---|
| ① 标志位组 | 每个子层的 sub_layer_profile_present_flag[i] 与
sub_layer_level_present_flag[i],各 1 bit |
maxNumSubLayersMinus1 > 0 |
| ② 保留位组 | reserved_zero_2bits,共 8 − maxNumSubLayersMinus1 组 |
maxNumSubLayersMinus1 > 0 |
| ③ 逐子层主体 | 88 bit 的 profile 字段组 + 8 bit level,按 ① 的标志决定读不读 | 总是 |
漏掉第 ② 段,就少读 8 − maxNumSubLayersMinus1 组保留位。注意这个数字:
当 maxNumSubLayersMinus1 = 0 时,第 ② 段根本不存在。
这就是这个 bug 最坏的地方:我们平时测试用的素材恰好都是单时域子层, 于是「少读了一段」在开发期完全不显形。换一份带多个时域子层的素材, 立刻错位 32 bit。
同一族错误还有一个入口:视频参数集里某个 16 bit 保留字段之后,紧跟的是
完整的 profile_tier_level,不是简化版。以为那里可以少读一段,
结果一样。
三、同一族:一个「只读第 0 层」的循环
另一个字段 *_sub_layer_ordering_info_present_flag 为 1 时,必须逐个读完
全部子层(i = 0 .. maxNumSubLayersMinus1)。我们曾经写死成「读到第 0 层就退出」。
症状和上一节一样:不报错,只是错位。区别是这次错位发生在读第 1 个子层的位置—— 所以它同样是「测试素材只有一个子层时永远看不到」的 bug。
四、最隐蔽的一处:取决于上下文的字段
短参考图像集(short_term_ref_pic_set)里有两个「字段存不存在要看上下文」的语法元素:
-
inter_ref_pic_set_prediction_flag只在 非首个参考集(stRpsIdx != 0)时存在; -
delta_idx_minus1只在写在 slice 头里时存在—— 写在参数集里时没有这个字段。
走进预测分支之后,还要读每个参考项的 used_by_curr_pic_flag[j]
(只有为 0 才继续读 use_delta_flag[j]),而 j 的上界要取被参考那个集合的
全量正、负参考项数之和。
这三处任何一处漏消费,整个 slice header 就整体错位。代价是:帧类型、参考列表、POC 全部偏掉——而流水线上没有任何一个环节会报错。
五、为什么「自己写的解析器不能当真值」
这次最值得记下来的教训不是某个字段,而是验证方法本身:我们最初用同一个解析器的输出
去验证这个解析器。漏读保留位的时候,色度格式偏成 655392,
但参考列表「看起来」是合理的,回归测试全绿。
正解是找一个完全独立的来源建立真值。一条命令就够:
ffmpeg -i input.mp4 -c:v copy -bsf:v trace_headers -f null -
这个 bitstream filter 会把逐语法元素的解析结果打印出来,包括每个字段的取值与位置。 把自研解析器的输出和它逐字段对齐,第一个偏差点就是漏读点——不需要去啃标准原文。
| 真值手段 | 适合回答的问题 |
|---|---|
| 逐语法元素打印 | 参数集 / slice header 的字段错位——能直接定位到「第几个比特开始偏」 |
| 原生解码器 | 像素、参考列表这类「结果对不对」的问题 |
| 自研解析器 | 只能做回归:确认改动没破坏已有结论,不能用来证明结论正确 |
六、下次怎么更快定位
- 先抓「不可能的值」。合法域是 0~3 却读出 655392,不是算错,是起点错。 凡是域检查失败的字段,先怀疑对齐,别怀疑公式。
- 数「已消费比特数」。在每个字段之后打印累积位偏移,和独立真值对比。 第一个偏差点就是漏读点,比逐字段猜快得多。
-
覆盖「会让某段消失」的配置。本例是
maxNumSubLayersMinus1 = 0把整段保留位隐藏掉了。凡是标准里写「当条件 X 时才存在」的字段,都要专门造一份 不满足 X 的素材。 - 断言写成域检查,而不是依赖解析器报错。变长编码的读取器读穿后仍会返回数值, 所以唯一的早报警号是你自己的合法性断言。
七、代价与边界
参数集现在优先从容器里的带外副本(avcC / hvcC)读取,
取不到才回退到「扫描样本找参数集」,回退有次数上限,避免异常文件把加载拖慢。
最后提醒一句:这类错位的可见症状常常不在解析层。它可能表现为 「块分析结果为空」「参考列表只有一项」「POC 来回跳变」。排查时如果只盯着报错文案, 很可能找错方向。
相关:623 个关键帧一个都没认出来 · 269 / 271 帧参考链错位 · 技术原理 · 打开分析器
// engineering · bitstream
One skipped bit field shifted the next 32 bits
The parsed chroma format came out as 655392. The only legal values are 0 to 3. A number that far out of range almost always means the same thing: the read position is off, and you are reading someone else's bits. One segment of a parameter-set structure had been skipped, shifting every field after it by 32 bits.
1. The symptom: a value that cannot exist
Chroma format lives in 0..3 (monochrome / 4:2:0 / 4:2:2 / 4:4:4). Reading
655392 does not mean the bit reader is broken — it faithfully returned a number. It means
the starting position was wrong.
Why does nothing complain? Because almost every field in an H.264 / H.265 parameter set is variable-length (Exp-Golomb). Exp-Golomb has an awkward property: it will return a value from any bit position. It never reports "illegal". So a misalignment does not throw — it simply keeps going, and the first wildly wrong value usually appears far away from the actual mistake.
2. The root cause: a structure assembled from three parts
H.265's profile_tier_level (7.3.3 in the spec) is not a flat field table. It is
three concatenated parts:
| Part | Contents | Present when |
|---|---|---|
| ① Flag group | sub_layer_profile_present_flag[i] and
sub_layer_level_present_flag[i], one bit each per sub-layer |
maxNumSubLayersMinus1 > 0 |
| ② Reserved group | reserved_zero_2bits, repeated 8 − maxNumSubLayersMinus1 times |
maxNumSubLayersMinus1 > 0 |
| ③ Per-sub-layer body | An 88-bit profile field set plus an 8-bit level, gated by the flags in ① | Always |
Skipping part ② means missing 8 − maxNumSubLayersMinus1 reserved groups. Note the number
carefully: when maxNumSubLayersMinus1 = 0, part ② does not exist at all.
That is the nastiest part of this bug: the clips we happened to test with were all single-temporal-sublayer, so "one part missing" was invisible during development. Feed it a clip with several temporal sub-layers and everything shifts by 32 bits immediately.
The same family of mistake has a second doorway: after a 16-bit reserved field in the video parameter
set comes a complete profile_tier_level, not a shortened form. Assuming
you can skip a segment there fails exactly the same way.
3. Same family: a loop that read only sub-layer zero
Another field, *_sub_layer_ordering_info_present_flag, being 1 means you must read
every sub-layer (i = 0 .. maxNumSubLayersMinus1). Ours was pinned to "read
sub-layer 0, then break".
Identical symptom: no error, just misalignment. The difference is that this time the shift happens one sub-layer later — which is why it is equally invisible on single-sub-layer test clips.
4. The subtlest one: fields that depend on context
The short-term reference picture set has two syntax elements whose presence is contextual:
-
inter_ref_pic_set_prediction_flagexists only for a non-first reference set (stRpsIdx != 0); -
delta_idx_minus1exists only when the set is written inline in a slice header — it is not there when the set lives in the parameter set.
Once inside the prediction branch you must also consume used_by_curr_pic_flag[j] for each
entry (only when it is 0 do you continue to use_delta_flag[j]), and the upper bound for j
is the full count of negative plus positive pictures in the referenced set.
Miss any one of those three and the whole slice header shifts. The price: frame type, reference lists and POC all go wrong — with nothing in the pipeline raising an error.
5. Why your own parser can never be its own ground truth
The most valuable lesson here is not a field but the verification method. We originally
validated the parser with the parser's own output. With the reserved group skipped,
chroma format came out as 655392, but the reference lists "looked" plausible and the
regression suite was green.
The fix is an independent source of truth. One command is enough:
ffmpeg -i input.mp4 -c:v copy -bsf:v trace_headers -f null -
That bitstream filter prints the parse element by element, including each field's value and position. Align your parser's output against it field by field and the first divergence is the missing read — no need to work through the spec text.
| Ground truth | Questions it can answer |
|---|---|
| Element-by-element dump | Parameter set / slice header misalignment — pinpoints the exact bit where you diverge |
| Native decoder | "Is the result correct" questions: pixels, reference lists |
| Your own parser | Regression only: proves a change did not break earlier findings. It cannot prove a finding is right |
6. How to get there faster next time
- Hunt for impossible values first. A field whose legal domain is 0–3 reading 655392 is an alignment bug, not an arithmetic bug. Suspect the origin, not the formula.
- Count consumed bits. Print the cumulative bit offset after every field and diff it against independent ground truth. The first divergence is the missing read — far faster than guessing field by field.
-
Cover the configurations that make a part vanish. Here
maxNumSubLayersMinus1 = 0hid the entire reserved group. Any spec field written as "present when X" deserves a clip that deliberately fails X. - Assert on domains instead of relying on parser errors. A variable-length reader returns numbers even past the end of the data, so your own validity assertions are the only early warning you get.
7. Cost and boundaries
Parameter sets are now read from the out-of-band copy in the container
(avcC / hvcC) first, falling back to scanning samples only when that fails —
and the fallback is capped, so one malformed file cannot stall loading.
One last warning: the visible symptom of this class of bug is usually not in the parsing layer. It shows up as "block analysis comes back empty", "the reference list has one entry", or "POC jumps around". Chase the error message alone and you will look in the wrong place.
Related: 623 keyframes, zero detected · 269 of 271 frames had the wrong reference list · How it works · Open the analyzer