MultiNet6 空闲崩溃:EXCVADDR=0x8 后又出现 CORRUPT HEAP
没人说唤醒词,ESP32-S3 仍在给 MultiNet6 喂环境声,设备突然报 LoadProhibited。看到 EXCVADDR=0x8,工程师很容易立刻下结论:空指针,补一个 NULL 检查就行。ESP-SR issue #226 的后续记录使这个判断没那么简单。报告者最初贴出九次异常,随后补充三次;第 11 次没有走到非法读取,而是堆毒化在释放 CTC 路径时报告 CORRUPT HEAP。
本文要回答的是:这两类证据各能证明什么,下一轮应记录什么,才能把问题交给维护者定位?以下都是公开用户报告与官方文档的分析。本文没有 ESP32-S3 板卡复现、完整堆跟踪或闭源 ctc_decoder.c 源码核验。

同一 ELF 的第 7--9 次,落在两个读取栈
报告环境为 ESP32-S3-WROOM1-N16R8、ESP-IDF 5.3.4、中文量化 MultiNet6。报告者称 ESP-SR 2.4.7 与 2.3.1 均复现。detect() 每次接收 512 个采样点,约 32 ms 一帧;没有唤醒词,状态多为 ESP_MN_STATE_DETECTING。这限定了案例,不能直接外推到其他芯片、模型或运行模式。
最初九次中,有八次 EXCVADDR 为 0x0、0x8、0xfffffffc 等零附近值;第 6 次是不同的高地址。第 7--9 次使用相同 ELF SHA,却分别在约 57、2.4、5.6 分钟崩溃。第 8 次经 ctc_path_copy,另外两次经 ctc_beam_search_with_fst。同一构建出现不同等待时间与栈位点,削弱了"固定运行到某个时间就坏"的解释,却不证明触发纯随机:环境声、内部状态与任务调度仍可能不同。公开日志展示的是 ELF 标识的短前缀与报告者关于同一构建的说明;自己的记录应另保留完整哈希和构建产物,不能将短前缀当成完整身份核验。

ESP-IDF 5.3.4 致命异常文档说明,LoadProhibited 是无效地址读取;EXCVADDR=0 通常提示空指针,接近零则可能是经空对象访问成员。0x8 因而是定位线索,不能单凭它反推第一次出错的是分配失败、数组越界还是更早的对象损坏。那次高地址异常也应单列,不要硬并入近零读取。

同一构建,是比较起点而不是相同执行过程
某行源码显示读取空成员,并不告诉我们对象为什么为空。创建失败、生命周期结束、字段被更早写坏,都可能需要检查。若只是添加条件返回,崩溃可能减少,错误状态仍存在。候选处理应记录被拦截的情况及其上下文,再确认是否合理恢复,不能用"没再 panic"证明内部状态已正确。这里是一般诊断推导,不建议修改闭源库的未知代码。
第 11 次改变了排查优先级
作者后续补充中的第 10、12 次仍是近零地址读取;第 11 次则报 CORRUPT HEAP: Bad head。回溯从 multi_heap_free、heap_caps_free 进入 ctc_path_free:38、ctc_beams_append:212 与 ctc_beams_add_to_finished:423。堆结构错误是在释放一条 beam path 时被发现的,比"读取了地址 0x8"更直接地指向需要检查的生命周期路径。
报告者注意到损坏值像内部 SRAM 指针,提出重复释放或释放后使用的模型:一条路径先被释放,后来又被复制、访问或再释放。这个模型可能同时解释两类崩溃,但目前仍是报告者假说。公开材料没有给出同一对象分配、第一次释放、第二次释放的完整堆跟踪;维护者也未确认闭源库的首个错误动作。第 11 次不能自动证明全部 12 次同源。2026 年 10 月 13 日重新读取 issue 正文和后续评论,页面仍为 open,未见可以引用的新增定位或已验证修复;本文不把报告者提出的所有权模型变成确定结论。

相邻的 issue #225 是 ESP32-P4/MultiNet7 的存活分配增长,#226 是 ESP32-S3/MultiNet6 的间歇崩溃。两者有 CTC 函数名重叠,却不是同一块板、同一模型或同一种故障。不能把 #225 的清理路径推断直接写成 #226 的已确认根因。
堆损坏把关注点前移,但仍要找到首次动作
释放时发现坏 head,提示该块或周围结构已不符合预期。释放函数恰好处在发现点,并不自动证明这次 free 就是第一次错误。可能更早写坏边界,也可能对象已经释放过后被再次使用。区分它们需要同一对象的分配、访问与释放顺序,而不是只数栈里出现多少个 free 名称。
假设路径指针 P 在某次分配后,第一次释放完成,稍后又作为存活路径参与复制。这条完整顺序能够支持释放后使用。若只看到一次释放 panic,而没有前一次释放证据,则仍不能把假设写成事实。对象地址还可能被分配器复用,所以跟踪应绑定分配生命周期,不能仅用地址重复出现判断双重释放。
损坏值看起来像某种指针,是追问方向;它不证明具体哪位所有者写入。维护者需要的是与目标固件对应的指令、对象和事件记录。库内部看不见时,可以准确报告最后一个可观察边界,将内部首错保留未知。这样比补齐一个看似完整的内部故事更便于后续核查。
阴性检查为何没结束排查
报告者崩溃附近的内部空闲内存约 58--66 KiB;早先的 heap_caps_check_integrity_all(true) 没报错,开启了轻量堆毒化;第 11 次才在释放时抓到堆结构损坏。这并不矛盾:检查时点可能早于损坏,或释放路径才暴露错误。总 free 也不等于某种能力堆的最大连续可分配块,无法证明每次分配成功。ESP-IDF 堆调试文档提供最大连续块、分配失败回调与堆跟踪等工具。
输入合同方面,报告者每次记录的输入长度与模型块长均为 512;换成 16 字节对齐的内部 RAM 缓冲仍崩;跨任务修改阈值的调用加锁后仍复现。补充评论还称,12 次运行期间没有销毁或重建模型。这些对照降低若干简单解释的可能性,不能排除更早的应用层内存写坏或解码器内部对象状态错误。
报告者也加过"连续 DETECTING 很久就调用 clean()"的守卫,但连续计数约 25--105 帧就重置,守卫日志一次没有触发。崩溃继续,不能据此评价 clean() 作为规避方案是否有效;更不能推荐它为修复。
未发现问题,必须附上检查覆盖了什么
对齐与加锁实验属于有限反证。改成内部对齐缓冲仍崩,降低该缓冲条件作为唯一解释的可能;某个阈值调用加锁仍崩,降低那一处无锁更新作为唯一解释的可能。它们不证明其他共享状态都安全。报告每个实验实际改变什么,才能让下一位工程师知道哪些假设已有依据,哪些仍未覆盖。
从未触发的守卫,没有接受效果检验
守卫代码存在,不等于运行进入该分支。必须记录条件成立次数、调用次数与影响,否则崩溃前未触发的守卫根本没有机会改变行为。本文保留报告中的计数重置事实,不把它解释成 clean 有效或无效。若要检验这种规避,先确认设计允许并能观察实际执行,再用同条件输入对照;没有这些证据,就保持方案未验证。
另外,守卫即使真正执行,也可能改变检测状态与命令窗口。回归应同时观察异常和识别行为,避免定期清理掩盖故障却使长口令中断。本文没有给定守卫阈值,也不建议用户凭文章设置一个未经目标模型验证的计数。
先固定版本,再追首次损坏
版本声明必须读实际解析结果。报告者最初把依赖写成 ^2.3.1,前五次实际使用 2.4.7;固定 2.3.1 后才有后四次的对照。IDF Component Manager 版本规则中的 ^ 是兼容范围,不是精确锁定;dependencies.lock记录解析后的精确版本。对照不同异常,至少同时保存 lock 文件、ELF SHA、模型和输入合同。

下一轮给每次崩溃留同一张记录,而不只贴 panic 末尾:
| 同轮记录 | 要回答的问题 |
|---|---|
| 板卡、IDF、lock 中 ESP-SR 版本、模型、ELF SHA | 两次运行是否真是同一二进制? |
AFE 配置、缓冲内存与对齐、get_samp_chunksize()、每帧样本数 |
输入合同有没有持续成立? |
帧计数、mn_state、环境声类型、启动后时间、清理/超时事件 |
触发前的模式与输入是否相同? |
| 完整 panic、PC、EXCVADDR、回溯与对应 ELF 符号 | 近零读取、高地址读取、释放路径错误是否分组? |
| 当前 free、最大连续块、失败分配回调、堆检查时点 | 是否遗漏分配失败或更早的堆损坏? |
| 可行时的分配/释放堆跟踪,尤其同一 path 指针的先后事件 | 能否找到首次损坏或重复释放,而不只看最后一次异常? |
固定 ELF 后,先比较安静环境与稳定噪声输入,再比较持续空闲送帧与唤醒后短时识别。ESP-SR MultiNet 文档描述的是唤醒后启动命令识别、超时后退出;issue 的模式是在未唤醒时持续运行 detect()。这个对照可以检验模式边界是否影响复现,不能据此宣称切换模式就修好了库。每次只改一个条件,保留能和前一轮比较的 ELF、输入及日志。

复制运行:先决定哪些崩溃记录能放在一起
这里给出一个只读数据的标准库最小例。它解析构造 JSONL,验证有限字段,把合格记录按实际构建与输入模式分组,再保留异常形态和栈名差异。它不会加载模型、触发 panic、访问无效地址或写任何模拟堆;输入中的 exception 和地址只是字符串,运行成功只说明整理规则和反例断言成立。
输入合同是一行一个归一化诊断对象,栈已经提取成一个代表性函数名,而不是完整 panic 日志解析器。真实接入应先保留原始 panic 与对应 ELF,再整理字段;不能用本例的 panic_complete=true 替代检查实际原文是否完整。构造的板卡、模型名称与重复字母组成的完整哈希都是教学值,不是从 issue 下载的固件身份。
保存为 crash_record_triage.py,用 Python 3.8 或以上运行,没有额外依赖。断言自检不要加 -O。
bash
python3 crash_record_triage.py
python
"""Parse constructed JSONL diagnostics; never trigger a panic or memory error."""
import json
import re
from collections import Counter, defaultdict
from copy import deepcopy
REQUIRED = ("id", "board", "idf", "sr_resolved", "elf_sha", "symbol_elf_sha",
"model", "mode", "chunk_expected", "chunk_supplied", "exception", "stack")
def triage(jsonl):
groups = defaultdict(list)
withheld, ids = [], set()
for line_number, line in enumerate(jsonl.splitlines(), 1):
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
withheld.append((f"line-{line_number}", "invalid JSON"))
continue
if not isinstance(row, dict):
withheld.append((f"line-{line_number}", "not an object"))
continue
missing = [k for k in REQUIRED if row.get(k) is None or row.get(k) == ""]
if missing:
withheld.append((row.get("id", f"line-{line_number}"), "missing:" + ",".join(missing)))
continue
if row["id"] in ids:
raise ValueError("duplicate record id")
ids.add(row["id"])
if not re.fullmatch(r"[0-9a-f]{64}", row["elf_sha"]):
reason = "incomplete ELF SHA"
elif row["symbol_elf_sha"] != row["elf_sha"]:
reason = "symbols belong to another ELF"
elif row["chunk_supplied"] != row["chunk_expected"]:
reason = "input chunk mismatch"
elif row.get("panic_complete") is not True:
reason = "incomplete raw panic"
else:
reason = None
if reason:
withheld.append((row["id"], reason))
continue
if row["exception"] == "CORRUPT HEAP":
kind = "HEAP_CHECK_FAILURE"
elif row["exception"] == "LoadProhibited":
try:
address = int(row["excaddr"], 0)
except (KeyError, TypeError, ValueError):
withheld.append((row["id"], "missing or invalid EXCVADDR"))
continue
# Only these example values get this label; not a general pointer validator.
kind = "NEAR_ZERO_EXAMPLE" if address in {0, 8, 0xfffffffc} else "OTHER_READ_ADDRESS"
else:
kind = "OTHER_EXCEPTION"
identity = tuple(row[k] for k in ("board", "idf", "sr_resolved", "elf_sha", "model", "mode"))
identity += (row["chunk_expected"],)
groups[identity].append({**row, "kind": kind})
return dict(groups), withheld
def record(record_id, exception="LoadProhibited", excaddr="0x8", stack="ctc_beam_search_with_fst"):
return {"id": record_id, "board": "demo-S3", "idf": "5.3.4",
"sr_declared": "^2.3.1", "sr_resolved": "2.4.7", "elf_sha": "a" * 64,
"symbol_elf_sha": "a" * 64, "model": "demo-mn6-cn", "mode": "idle",
"chunk_expected": 512, "chunk_supplied": 512, "exception": exception,
"excaddr": excaddr, "stack": stack, "panic_complete": True, "guard_calls": 0}
def encode(rows):
return "\n".join(json.dumps(r) for r in rows)
def main():
rows = [record("demo-1"), record("demo-2", excaddr="0x0", stack="ctc_path_copy"),
record("demo-3", "CORRUPT HEAP", None, "ctc_path_free"),
record("demo-4", excaddr="0x98765432")]
other_build = record("demo-5")
other_build.update(elf_sha="b" * 64, symbol_elf_sha="b" * 64, sr_resolved="2.3.1")
groups, withheld = triage(encode(rows + [other_build]))
assert len(groups) == 2 and not withheld
same_build = groups[next(iter(groups))]
counts = Counter(r["kind"] for r in same_build)
assert counts == {"NEAR_ZERO_EXAMPLE": 2, "HEAP_CHECK_FAILURE": 1, "OTHER_READ_ADDRESS": 1}
assert {r["stack"] for r in same_build} == {"ctc_beam_search_with_fst", "ctc_path_copy", "ctc_path_free"}
print("build groups=2; first build: near-zero=2 heap-check=1 other-address=1")
print("first build stacks=['ctc_beam_search_with_fst', 'ctc_path_copy', 'ctc_path_free']")
assert all(r["guard_calls"] == 0 for r in same_build)
print("guard_calls=0: workaround effectiveness NOT_EVALUATED")
assert rows[0]["sr_declared"] != rows[0]["sr_resolved"]
print("declared=^2.3.1 resolved=2.4.7: group by resolved build identity")
for key, value, expected in (
("elf_sha", "a" * 9, "incomplete ELF SHA"),
("symbol_elf_sha", "b" * 64, "symbols belong to another ELF"),
("chunk_supplied", 256, "input chunk mismatch"),
("panic_complete", False, "incomplete raw panic"),
("excaddr", None, "missing or invalid EXCVADDR")):
bad = deepcopy(rows[0])
bad[key] = value
accepted, held = triage(encode([bad]))
assert not accepted and held == [("demo-1", expected)]
print(f"{expected}: WITHHELD")
missing = deepcopy(rows[0])
del missing["sr_resolved"]
assert triage(encode([missing]))[1] == [("demo-1", "missing:sr_resolved")]
assert triage("{broken")[1] == [("line-1", "invalid JSON")]
print("missing resolved version / malformed JSON: WITHHELD")
try:
triage(encode([rows[0], rows[0]]))
except ValueError:
print("duplicate record id: REJECTED")
else:
raise AssertionError("duplicate event counted twice")
print("PASS: record grouping and comparison-eligibility assertions")
if __name__ == "__main__":
main()
2026 年 10 月 13 日本地实际运行,退出码 0,stdout 为:
text
build groups=2; first build: near-zero=2 heap-check=1 other-address=1
first build stacks=['ctc_beam_search_with_fst', 'ctc_path_copy', 'ctc_path_free']
guard_calls=0: workaround effectiveness NOT_EVALUATED
declared=^2.3.1 resolved=2.4.7: group by resolved build identity
incomplete ELF SHA: WITHHELD
symbols belong to another ELF: WITHHELD
input chunk mismatch: WITHHELD
incomplete raw panic: WITHHELD
missing or invalid EXCVADDR: WITHHELD
missing resolved version / malformed JSON: WITHHELD
duplicate record id: REJECTED
PASS: record grouping and comparison-eligibility assertions
第一组四条记录共享构造的完整 ELF、解析版本、板卡、IDF、模型、模式和每帧长度,异常仍分为两个近零样例、一个堆检查失败、一个其他读地址。第二个 ELF 和解析版本进入另一组,程序没有把它们混成"同一构建的五次崩溃"。组内保留 ctc_beam_search_with_fst、ctc_path_copy 与 ctc_path_free 三个栈名,既便于比较,又不以共享 CTC 前缀声称同一个根因。
NEAR_ZERO_EXAMPLE 只对这份教学数据的 0、8 与 0xfffffffc 标签生效,它不是地址有效性检查器,没有检查芯片内存地图,也不声称所有接近零的地址只能由 NULL 对象产生。其他读取地址留在 OTHER_READ_ADDRESS,堆检查失败单列为 HEAP_CHECK_FAILURE;后者没有 EXCVADDR,也不会因缺少读地址而被硬套进近零读取分支。
构造数据的 guard_calls 都为 0,所以输出规避效果 NOT_EVALUATED。它既没有证明守卫无效,也没有证明增加调用能避免崩溃;只是阻止把未进入过的分支纳入效果评价。真实记录应给出条件成立、实际调用和观察范围,尤其守卫可能中断命令窗口时,还要看语音行为。用一行"已经加过 clean"概括这些字段,会把代码存在与运行执行混淆。
声明 ^2.3.1、解析 2.4.7 的例子不会自动发出"升级修复"或"版本错了"的结论。程序按实际解析结果与 ELF 分组,声明保留作上下文。IDF Component Manager 版本规则允许 caret 范围在兼容主版本内变化,因而两者不同可以是正常解析结果;诊断时要查 lock 和真实构建,不靠声明猜测。
缺字段、错符号与重复记录,各自会造成什么误判
把完整 ELF 改成九位前缀,记录先被 withheld,不参与该例的精确构建分组。这不是说短标识没有调查价值,而是它不能满足这里的身份门槛。手头只有旧日志时,保留原记录并补找固件归档;找不到就注明比较资格不足,不能替它填一个看起来正确的完整哈希。
符号 ELF 与运行 ELF 不同也被 withheld。错误符号可能把同一个 PC 映射到另一函数或行号,一张看似完整的回溯仍会误导调查。在真实采集层,两个匹配的字符串还不够:需要验证符号文件确实来自该构建。这段函数只检查记录关系,不打开 ELF、不重新解码 PC,因此不能宣布已经完成机器地址核验。归档时将运行 ELF、对应符号、模型身份与完整 panic 放在同一轮次目录,避免跨版本选错文件。
每帧供给 256、模型期望 512 的记录退出当前输入合同组;截断 panic、缺解析版本或缺读取地址也会留在 withheld。它们仍是待补充材料,不等于无效故障,更不等于异常不存在。程序没有把缺地址填成 0,没有把缺版本从依赖声明推算出来,也没有把未看见的栈补成最常见函数;这能保留原先最容易被乐观汇总掩掉的缺口。
重复 record id 直接拒绝,避免同一次异常被拷到两份日志之后算成两次。真实诊断可用设备与启动会话、事件序号形成唯一身份,不能只看两段文本不同就增加计数。若无法辨别是否同事件,保持记录关联待核实,不用重复材料制造高频故障证据。坏 JSON 则按行记录问题,其他正常记录仍可处理;修复原输入格式之后再重新整理。
分组通过以后,也没有得到因果答案
这份输出只完成比较资格与观察形态整理。相同二进制并不使输入波形、调度或内部状态相同,跨构建分组也不代表两个版本的故障机制不同。接下来的对照仍须固定可重放音频、帧节奏和观察条件,同时记录结束原因;在观察窗口内没崩,不能改写成永远不崩。
如果第一组既有读取异常又有释放检查失败,可以优先收集同一对象的分配和释放事件,但不能从三个栈名逆推出那两次 free 的发生顺序。与 P4/MultiNet7 的存活增长报告关联时,也应保留芯片、模型和症状差异。这里没有对象所有权证据,也没有推荐修改闭源库或周期重建模型。
本地程序的可带走产物是一份保留分组与待补名单的记录格式。用它检查维护者资料是否完整,比先写一个"疑似 NULL 已修复"的结论更有用;真正的首个错误对象动作,仍需要目标固件、输入和生命周期跟踪来捕获。本文的新实跑证据限于安全数据整理,板卡异常复现与修复验证仍未执行。
固定输入用于定位,真实环境用于补充覆盖
如果现场环境声无法重现,先捕获合法可留存的输入,再在目标接口合同下重放。重放要保持帧长、顺序和运行模式;整段文件无节奏快速投喂,与实时采样不是同一压力。若重放可以复现,就保留它作为最小输入;若无法复现,也不要宣布环境声无关,因为现场还有调度和采集链路差异。
安静与稳定噪声两组对照,需要相同观察方式和构建身份。某组较短时间未崩,只能说明在这段范围没有观察到;间歇故障的负结果尤其不能写成绝对消除。比较实际帧数与运行时长,并注明每组的结束原因。因崩溃结束的样本和人为提前停止的样本,不能当作同样完成了观察窗口。
追踪计划围绕一个对象关系提出问题
可以先问:导致非法读取的路径对象,最后一次可证实的创建和释放是什么时候?或问:释放时损坏的块,最后一个完整性检查何时仍正常?选择一个能够收敛的目标,比一次性在全系统打印所有指针更实际。加入的调试负担也需要记录,因为日志与追踪可能改变时序;调试构建不能用于报告生产性能。
若已有稳定坏地址和可控区间,再按官方堆调试方法捕获写入;若对象动态变化,先改善分配生命周期记录。发现点与首次写入不重合时,继续收缩区间,不根据最终栈做越权内部推断。本文没有闭源解码器对象定义,因而不会补写一个看似确定的所有权图。
交付给维护者的结论保留分组与反例
候选修复后重跑原可复现输入,并重新看完整性、分配失败与异常分类。新的版本没有再报近零读取,但出现另一种堆错误,不能归为完全成功;原故障暂未出现,也应写完成的时长和帧数。最终定位要跨过症状层,拿到第一个错误对象动作。文章提供的是走向该证据的路径,没有本机测试或维护者确认的新修复结果。