LV030-NOINIT实例
LV025 把 NOINIT 段怎么搭起来讲完了:固定地址 section、RAM 划分让位、魔数 + 校验和、看门狗闭环。但机制搭好只是第一步——真出现死机时,面对屏幕上那五六行打印,怎么在几十秒内说出"是狗咬的、死在什么时候、死在哪条指令上",是另一件事。
本文只做一件事:把 NOINIT 这套打印逐行拆开,说清每一行的字面含义、背后的数据来源、它可疑的地方,最后给出一套固定顺序的判读流程。目标是看完本文后,拿到任意一段 NOINIT 打印都能独立分析,不需要回头翻实现。
一、 打印解决什么问题
1. 与另外三种取证形态的分工
同一个死机现场,工程上有四种取法,各有盲区:
| 形态 | 载体 | 盲区 | 对应文档 |
|---|---|---|---|
| 串口当场打印 | printf 到硬错误现场 | 只在 CPU 还能跑、串口还能发的时候有用 | LV005 |
| NOINIT 热复位保留 | 一段不清零的 SRAM | 掉电即丢,只覆盖热复位 | LV025 |
| Flash 落盘 | 内部/外部 Flash | 要写盘、寿命有限,实时性差 | LV001 |
| 调试器读栈 | 仿真器在线 | 需要人守在电脑前,现场问题复现不了 | LV015 |
NOINIT 的不可替代之处在于:看门狗自动重启之后,现场已经在 RAM 里等着了,不需要人守在设备旁,也不依赖当次通讯链路。
2. 本文要解读的输入
分析的根本依据是两类输出,它们经常连在一起出现:
(1)启动时的 NOINIT 报告——每行都以 NOINIT: 或两个空格缩进开头,由 noinit_init() 在串口就绪后第一时间打印。
(2)崩溃瞬间的 HardFault dump——由 hardfault_dump() 打印,与(1)里的 pc_hint 互为印证。
一段完整样例(同一块板子,先手动复位一次、再人为触发硬错误;下例与 LV005 的样例来自不同构建版本,地址不可互相比对):
NOINIT: scene kept across reset (addr 0x2000FC00)
cause : none (inferred from heartbeat + reset flags)
uptime : 10695 ms (last heartbeat before hang)
pc_hint: 0x00000000
reset src: PIN (RCC->CSR = 0x04000003)
NOINIT: commands: 'scene' inspect record, 'deadloop' test IWDG reset
...
crash: reading unmapped address 0x30000000 ...
================ HardFault Dump ================
MSP(entry) = 0x20005D8C, frame offset = -4
R0 = 0x30000000
R1 = 0x08002DB0
R2 = 0x08002DB1
R3 = 0x0800423D
R12 = 0x00000000
LR = 0x080008ED (return address of faulting context)
PC = 0x08002D7C <-- faulting instruction, locate it in disassembly
xPSR = 0x61000000
-----------------------------------------------
CFSR = 0x00008200
HFSR = 0x40000000
MMFAR = 0x30000000
BFAR = 0x30000000
-----------------------------------------------
BUF: precise data bus error (PRECISERR)
BUF: BFAR valid, fault addr = 0x30000000
HFS: configurable fault escalated to HardFault (FORCED)
===============================================
NOINIT: scene kept across reset (addr 0x2000FC00)
cause : hardfault
uptime : 9211 ms (last heartbeat before hang)
pc_hint: 0x08002D7C
reset src: IWDG PIN (RCC->CSR = 0x24000003)下面第 3 节逐行拆这两段,第 4 节给判读流程,第 5 节把这段样例完整走一遍。
二、 每一行是谁在什么时候打印的
判读之前先要清楚打印的时机,否则会把不同时刻的数据混在一起看。
1. 启动阶段:noinit_init() 的两条分支
启动时它先取 RCC->CSR 快照(必须在清标志之前,否则读到的复位源是空的),然后按记录是否有效分流:
// atk-f103/Drivers/BSP/NoInit/noinit.c
if (scene_valid(&g_noinit_scene))
{
printf("NOINIT: scene kept across reset (addr 0x%08X)\r\n", NOINIT_BASE);
printf(" cause : %s\r\n", cause_name(g_noinit_scene.cause));
printf(" uptime : %u ms (last heartbeat before hang)\r\n", ...uptime_ms);
printf(" pc_hint: 0x%08X\r\n", ...pc_hint);
print_reset_flags(csr);
}
else
{
printf("NOINIT: no valid scene (cold boot, first run, or already consumed)\r\n");
print_reset_flags(csr);
}两条分支都会打印 reset src 行——这条信息不依赖记录有效性,永远可信,是判读的第一步依据。
2. 崩溃阶段:noinit_save() 与 HardFault dump
"已知要死"的路径会在进死循环前写入死因与 PC:
// atk-f103/Core/Src/stm32f1xx_it.c(HardFault 现场)
noinit_save(NOINIT_CAUSE_HARDFAULT, frame[6]); /* 先于打印: 打印二次 fault 也不丢 */【关键】
这一行的位置决定了可靠性:先落盘、再打印。如果打印过程中发生二次 fault(例如栈已被写坏),NOINIT 里的死因仍然完整。
普通主循环则只刷心跳,不碰死因字段:
// atk-f103/Core/Src/main.c(主循环)
HAL_IWDG_Refresh(&hiwdg); /* 喂狗 */
noinit_tick(HAL_GetTick()); /* 心跳与喂狗同源: 卡死即停 */3. 运行阶段:noinit_tick() 与 scene 命令
noinit_tick() 每 500ms 重写 uptime_ms / magic / check,不打印任何东西;scene 命令则是按需把记录原样打印出来,用于事后复核(尤其是设备还在跑、想把现场留个底的时候)。
三、 逐行解读
1. 启动报告的首行:记录是否有效
1.1 NOINIT: scene kept across reset (addr 0x2000FC00)
| 要点 | 说明 |
|---|---|
| 成立条件 | magic 等于约定值 且 check 等于前 5 项之和,两者同时满足才走这条分支 |
| 地址含义 | 0x2000FC00 是 NOINIT 段的基址(SRAM 64KB 的最后 1KB),可用来确认没有改错 RAM 划分 |
| 直接结论 | 本次是热复位(SRAM 未被断电擦除),且上次运行确实写过记录 |
1.2 NOINIT: no valid scene (cold boot, first run, or already consumed)
三个成因都印在同一句里,要靠 reset src 区分:
(1)冷启动/掉电:SRAM 内容随机,魔数对不上——看 reset src 里的 POR。
(2)首次运行或刚才烧录过:同上,属于正常现象,不是故障。
(3)已被消费:上一次启动已经读过并报告过,读完把 magic 写 0("消费即失效"),避免同一次死亡反复刷屏。
【注意】
这句不代表没有故障。它只说明"记录不可用",故障有无要看 reset src 与业务日志。
2. 字段行逐条解读
2.1 cause 行:死因从哪来
取值由 cause 字段翻译而来,一共三种语义:
| 打印 | 字面 | 真实含义 |
|---|---|---|
hardfault | 硬错误 | 崩溃前执行过 noinit_save(CAUSE_HARDFAULT, PC),自报 |
deadloop (cmd: stop feeding IWDG) | 死循环 | deadloop 测试命令的自报,用于验证狗咬链路 |
none (inferred from heartbeat + reset flags) | 无 | 没有走过自报路径,只能靠心跳 + 复位源推断,不是"没死过" |
判读时先看这一行:有具体名字 = 自报,可信度最高;none = 心跳记录,此时 pc_hint 必然为 0(见 2.3 小节),分析要靠 uptime + reset src。
2.2 uptime 行:心跳语义
uptime : 9211 ms (last heartbeat before hang)| 要点 | 说明 |
|---|---|
| 数据来源 | 主循环里的 noinit_tick(HAL_GetTick()),与喂狗同一个位置 |
| 语义 | 最后一次确认活着的时刻,不是运行总时长 |
| 怎么用 | 卡死瞬间心跳停更,所以这个值就是"死前跑到哪一刻";配合 IWDG 超时(本例 3s)可反推"从卡死到被狗咬"的时长 |
| 为什么同源 | 心跳与喂狗放在同一处,心跳停 = 喂狗停,两个信号不会互相矛盾 |
两个边界必须记住:
(1)心跳只在主循环刷。如果卡死在初始化阶段(iwdg_start() 之前),uptime 还是上一次运行留下的值,不能据此判断"死前跑到哪一步"。
(2)跳转到 APP 后不再刷新。bootloader 跳走之后 noinit_tick() 停跑,记录里保留的是 bootloader 最后一次心跳——此时 uptime 描述的不是 APP 的崩溃时刻。
2.3 pc_hint 行:唯一能落到代码的字段
pc_hint: 0x08002D7C这是整份记录里**唯一能翻译成"函数 + 源码行"**的字段,取值规律:
| 取值 | 含义 |
|---|---|
0x0800xxxx | Flash 里的合法代码地址,可直接反查(见 LV020) |
0x00000000 | 本次没有自报路径(消费时被归零),不是"崩溃在地址 0" |
为什么会有 0x00000000:记录被消费(或冷启动)之后,cause / pc_hint / magic 一起归零,之后 noinit_tick() 只重写 uptime_ms / magic / check,不碰这两个字段。于是"心跳记录"天然带着 cause: none + pc_hint: 0x00000000 这一对自洽的组合。
反查的前提:必须用当时那份固件的 .axf。地址随每次重新构建变化,固件重编之后旧日志里的地址会翻到别处去。
2.4 reset src 行:复位源逐位解读
reset src: IWDG PIN (RCC->CSR = 0x24000003)这行把 RCC->CSR 的复位标志位逐个翻译成名字,再附上原始十六进制值。STM32F1 的位定义:
| 位 | 名称 | 含义 | 工程判读 |
|---|---|---|---|
| 31 | LPWRRSTF | 低功耗管理复位 | 进了 Standby/Stop 后被唤醒复位 |
| 30 | WWDGRSTF | 窗口看门狗复位 | 本项目未用 WWDG |
| 29 | IWDGRSTF | 独立看门狗复位 | 狗咬,死机链路最常见 |
| 28 | SFTRSTF | 软件复位 | NVIC_SystemReset(),例如 reboot 命令 |
| 27 | PORRSTF | 上电/掉电复位 | 冷启动、拔电重插 |
| 26 | PINRSTF | NRST 引脚复位 | 按复位键、调试器复位,也是内部复位的伴随位 |
| 24 | RMVF | 标志清零位 | 打印时通常为 0:快照取在 __HAL_RCC_CLEAR_RESET_FLAGS() 之前 |
| 1 | LSIRDY | LSI 就绪 | IWDG 的时钟源状态 |
| 0 | LSION | LSI 使能 | IWDG 一旦启用过,复位后 LSI 仍保持使能 |
实测三个取值对照:
RCC->CSR | 置位情况 | 结论 |
|---|---|---|
0x24000003 | IWDG + PIN + LSI | 狗咬复位(PIN 为伴随位) |
0x14000003 | SFT + PIN + LSI | 软件复位(reboot 命令、NVIC_SystemReset()) |
0x04000003 | PIN + LSI | 真正按了复位键/调试器复位 |
【易错点】
STM32F1 的内部复位(IWDG、软件复位)会同时把 NRST 引脚拉低,所以 PINRSTF 经常和 IWDGRSTF / SFTRSTF 一起置位。判读要看"主因位",不要看到 PIN 就认为有人按了复位键。
2.5 末尾的 commands 提示行
NOINIT: commands: 'scene' inspect record, 'deadloop' test IWDG reset纯提示,无分析价值,但它标志着一轮启动报告结束——下面再出现 crash: / deadloop: 这类业务输出,就是本次运行中的动作了。
3. scene 命令的输出
事后想复核现场,在串口敲 scene:
NOINIT: scene at 0x2000FC00 (SRAM tail 1KB, kept across reset)
valid : no (cold boot, first run, or already consumed)
raw : magic = 0x00000000, check = 0x00000000
reset src: PIN (RCC->CSR = 0x04000003)与启动报告的区别有两处,都很有用:
(1)valid : yes 时会多打一行 rcc_csr: 0x?????? —— 注意这是上次启动时存进记录的快照,不是本次复位源;本次复位源看末尾那行实时读取的 RCC->CSR。
(2)valid : no 时多打一行 raw,把 magic 与 check 的原值亮出来:全 0 或完全随机说明记录从未被写过(冷启动/已消费);magic 对但 check 不齐说明写了但被复位打断(半写)。这是判断"是没写过还是写坏了"的唯一手段。
4. HardFault dump 与 NOINIT 的字段对应
两段输出覆盖的字段有重叠也有互补:
| dump 字段 | NOINIT 里的对应 | 分析价值 |
|---|---|---|
PC | pc_hint | 互为印证:两者一致说明自报路径完整;只有 dump 有说明这次没落盘 |
CFSR / HFSR | 无 | 故障类型,NOINIT 记录里没有,只能当场抓或事后增强 |
MMFAR / BFAR | 无 | 被非法访问的数据地址 |
R0 ~ R3 | 无 | 寄存器残留值,可信度低(见下) |
MSP(entry) | 无 | 崩溃时的栈指针,与 __initial_sp 对照可查栈溢出 |
frame offset | 无 | 异常帧定位的自校验结果(-4 表示入口 MSP 比帧基址高 4 字节) |
【寄存器可信度】
只有 PC 与故障状态寄存器能当证据,通用寄存器和 LR 常常是上一次函数调用的残骸。实测样例里:R1 / R2 落在 cmd_crash 的格式串区(printf 的参数残骸),R3 = 0x0800423D 是 fputc|1(函数指针残骸),LR 的 DWARF 反查显示为启动文件区域(误导归属,实际在库函数 _printf_char_file 内,见 LV020 1.4 节陷阱二)——都不是调用链。
原因:被调用的静态函数只被调用一次时会被 -O3 内联,编译器不再维护 LR,寄存器里留着的就是上一条 printf 用完的垃圾。所以不要用 LR 反推调用栈。
四、 判读流程
拿到打印后按固定顺序走五步,每步只回答一个问题。
1. 第一步:定复位类型
只看 reset src 行(这行永远可信):
| 主因位 | 结论 |
|---|---|
POR | 冷启动/掉电,记录无效属正常,不是死机 |
PIN 单独 | 手动复位或调试器复位 |
SFT | 软件复位(reboot 命令、程序主动重启) |
IWDG | 狗咬,进入死机分析 |
WWDG | 窗口看门狗异常,本项目未用 |
2. 第二步:定死因来源
看 cause 行,区分"自报"与"推断":
(1)hardfault / deadloop:已经知道死因类别,直接进第四步。
(2)none:记录只是心跳,需要靠 uptime + 复位源 + 业务日志推断,且 pc_hint 必然是 0。
3. 第三步:定位代码位置
(1)pc_hint 非 0:直接用 LV020 的一条龙脚本反查,得到函数名与源码行;反查不出来先检查 .axf 是不是当次构建的那份。
(2)pc_hint 为 0 但手边有同一轮的 HardFault dump:用 dump 里的 PC 反查,效果等同。
(3)两者都没有:只能到"复位类型 + 死前时刻"这一层,此时退回到 uptime 与业务日志,配合在关键路径上加打印/落盘(LV005)。
4. 第四步:定故障类型
只有 dump 才能给出这一步(NOINIT 记录不含状态寄存器):
| CFSR 分组 | 典型位 | 含义 | 常见成因 |
|---|---|---|---|
| MMFSR 0~7 | IACCVIOL | 取指访问违规 | MPU 配置错误 |
DACCVIOL | 数据访问违规 | MPU 配置错误 | |
MSTKERR | 入栈失败 | 栈指针被写坏 | |
| BFSR 8~15 | IBUSERR | 取指总线错误 | 跳进未映射区域 |
PRECISERR | 精确数据总线错误 | 访问未映射地址(BFAR 给出地址) | |
IMPRECISERR | 不精确数据总线错误 | 写缓冲延迟报错,BFAR 不可用 | |
STKERR | 入栈总线错误 | 栈溢出 | |
UNSTKERR | 出栈总线错误 | 异常返回时栈指针被写坏 | |
BFARVALID | 故障地址有效标志 | 与上面配合读 BFAR | |
| UFSR 16~31 | UNDEFINSTR | 未定义指令 | 跳飞、数据被当指令执行 |
INVSTATE | 非法状态 | Thumb/BLX 目标非法 | |
UNALIGNED | 非对齐访问 | 指针类型转换错误 | |
DIVBYZERO | 除零 | DIV_0_TRP 置位后触发 UsageFault(UsageFault 未使能则升级 HardFault);默认商置 0、无 fault |
HFSR 的 FORCED(bit30)不代表故障更严重,只说明 MemManage / BusFault 没有单独使能,可升级故障被统一升级成了 HardFault——本例就属这种情况。
5. 第五步:算时间线
用 uptime 拼时间线:
记录里的 uptime(死前最后一次心跳) → 卡死时刻
+ 看门狗超时(本例 3s) → 复位时刻
+ 本次启动后打印的顺序 → 重启后多久报告若 uptime 与业务日志里"最后一条有效打印"的时刻相差很大,说明卡死点在那条打印之后、下一次心跳之前的代码里,可以据此圈定范围。
6. 五步汇总表
| 打印组合 | 结论 | 下一步 |
|---|---|---|
no valid scene + POR | 冷启动,正常 | 无需分析 |
cause: none + PIN 单独 | 正常复位,记录是心跳 | 看业务日志 |
cause: none + IWDG | 卡死但没自报 | 用 uptime 圈定卡死范围;考虑加自报点 |
cause: deadloop + IWDG | 测试用例或人为死循环 | 看 deadloop 命令前的日志 |
cause: hardfault + IWDG + pc_hint 非 0 | 硬错误现场完整 | 反查 pc_hint,结合 dump 的 CFSR/BFAR |
五、 两个案例逐行走查
用第 1 节那段样例,按上面的流程走一遍。
1. 案例 A:手动复位,记录有效但无人自报
NOINIT: scene kept across reset (addr 0x2000FC00)
cause : none (inferred from heartbeat + reset flags)
uptime : 10695 ms (last heartbeat before hang)
pc_hint: 0x00000000
reset src: PIN (RCC->CSR = 0x04000003)| 行 | 判读 |
|---|---|
kept across reset | 记录有效,热复位,RAM 划分正确 |
cause: none | 没走过自报路径 |
uptime: 10695 ms | 上一次运行的心跳停在约 10.7s 处;reset src 是 PIN 说明这是正常复位,不是死机 |
pc_hint: 0x00000000 | 与 cause: none 自洽——归零机制生效,字段里没有 SRAM 随机残留 |
reset src: PIN | 0x04000003 = PINRSTF + LSION/LSIRDY,没有 IWDG/SFT/POR:手动复位或调试器复位 |
【结论】
这一次不是故障,是"热复位 + 心跳记录"的正常形态。它对分析的价值在于验证了保留机制本身:记录确实穿越了复位,并且字段是自洽的。
2. 案例 B:HardFault 后看门狗复位
NOINIT: scene kept across reset (addr 0x2000FC00)
cause : hardfault
uptime : 9211 ms (last heartbeat before hang)
pc_hint: 0x08002D7C
reset src: IWDG PIN (RCC->CSR = 0x24000003)配合前一段 dump(PC = 0x08002D7C、CFSR = 0x00008200、BFAR = 0x30000000),完整证据链是:
(1)reset src: IWDG → 狗咬复位;PIN 是 F1 内部复位拉低 NRST 的伴随位;0x24000003 的低两位说明 LSI 仍在使能(IWDG 的时钟)。
(2)cause: hardfault → 崩溃前执行过自报,不是推断;对应 hardfault_dump() 里的 noinit_save(...)。
(3)pc_hint: 0x08002D7C 与 dump 的 PC 完全一致 → 自报值与当场打印互相印证。
(4)uptime: 9211 ms → 死前最后一次心跳,卡死发生在开机约 9.2s。
(5)CFSR 的 PRECISERR + BFARVALID + BFAR = 0x30000000 + R0 = 0x30000000 → 一次精确的数据总线错误,被访问地址是 0x30000000(F103 保留区,未映射)。
(6)HFSR 的 FORCED → 总线错误被升级为 HardFault;while(1) 死等后主循环停摆,约 3s 被 IWDG 咬复位。
3. 反查结果与可信字段
用记录与 dump 里的地址反查(方法见 LV020):
PC 0x08002D7C -> cmd_crash+0xC ../Core/Src/main.c:78 <-- 出错指令
R1 0x08002DB0 -> cmd_crash+0x40 ../Core/Src/main.c:93
R2 0x08002DB1 -> cmd_crash+0x41 ../Core/Src/main.c:93
R3 0x0800423D -> fputc+0x1 ../Core/Src/usart.c:601
LR 0x080008ED -> ...startup_stm32f103xe.s:347| 字段 | 可信度 | 依据 |
|---|---|---|
PC | 高 | 与 BFAR(0x30000000)完全对应,落在 main.c:78 那条读未映射地址的语句上 |
R1/R2 | 低 | 落在 cmd_crash 的格式串区,是前一条 printf 的参数残骸 |
R3 | 低 | `fputc |
LR | 低 | DWARF 反查显示为启动文件区域(误导归属,实际是库函数 _printf_char_file 里 BL ferror 的返回地址);内联后编译器不再维护 LR,残留值与调用链无关 |
【结论】
本例是人为构造的非法访问(crash 命令主动读 0x30000000),验证的是诊断链路:自报 → 狗咬复位 → 读回 → 地址反查 → 落到源码行,全链路闭合。真实 bug 的排查流程与此完全相同,只是 PC 会落在业务代码上。
六、 常见误读与边界
1. 八种典型误读
| 打印现象 | 错误理解 | 正确解读 |
|---|---|---|
pc_hint: 0x00000000 | "崩在地址 0" | 本次没有自报路径,消费时归零;判据看 cause |
cause: none | "没死过 / 记录无效" | 记录有效、热复位成立,只是没走自报路径 |
LR 反查结果 | 调用链上一级 | 内联/尾调用后 LR 不可靠,只看 PC |
拿 MMFAR/BFAR 反查函数 | 出错指令地址 | 那是被访问的数据地址 |
| 固件重编后用旧日志反查 | 地址仍然有效 | 必须用当时那份 .axf |
看到 PIN 就认为按了复位键 | 人为复位 | F1 内部复位也会拉低 NRST,PIN 常是伴随位 |
uptime 当"运行总时长" | 程序跑了这么久 | 它是最后一次心跳;跨到 APP 阶段时是 bootloader 的时刻 |
scene 里的 rcc_csr 当本次复位源 | 本次复位来源 | 那是上次启动的复位源快照,本次看末尾实时读的 RCC->CSR |
2. 记录的硬边界
- 只对热复位有效:狗咬、软复位、NRST 之后 SRAM 保持;拔电重插后
magic不匹配自动丢弃,安全但没有现场。 - 不替代 Flash 落盘:需要跨上电周期溯源的场景仍然要写 Flash(LV001)。
- 半写靠校验和兜底:策略是"校验不齐即无效",而不是原子写。
- 没有狗就没有自动重启:若卡死发生在看门狗启动之前(本项目在初始化链末尾才
iwdg_start()),芯片只会挂住,记录要等手动复位才读得到——记录仍然有效,但不会自动重启。 - 记录共 6 个字段:
magic/cause/uptime_ms/rcc_csr/pc_hint/check(其中check仅用于完整性校验),不含故障状态寄存器与调用栈(CFSR/BFAR/SP都没有)。 - RAM 划分必须让位:NOINIT 段的地址要从链接器分配的 RW/ZI 区里排除出去,否则启动时会被
__main清零,现象就是永远打印no valid scene(见 LV025)。
3. 可选增强
按"事后取证够不够用"排优先级:
(1)把 cfsr 与 bfar 也写进记录:目前故障类型只能靠当次串口,一旦串口没接或走的是网络调试,记录里就只剩一个 PC,失去"是总线错误还是栈错误"的关键信息。
(2)加崩溃计数:便于识别"连续复位",区分"偶发一次"与"每次都挂"。
(3)存 LR 与 SP:LR 在优化后不可靠,价值有限;SP 更有用——与链接脚本的 __initial_sp 对照可直接判断栈溢出。
(4)把记录同步到 Flash:热复位之外还想跨上电周期溯源时,在 noinit_init() 里读回后异步落盘,兼顾实时性与持久性。
七、 小结
NOINIT 打印一共就那么几行,但每行回答一个独立问题:首行答"记录能不能用",cause 答"死因是自报还是推断",uptime 答"死前跑到哪一刻",pc_hint 答"死在哪条指令",reset src 答"怎么活过来的"。五者串起来就是一份完整的死机报告。
固定流程是:先看复位类型,再看死因来源,有地址就反查代码,有 dump 就定故障类型,最后用心跳时间线圈定范围。其中只有 PC 与故障状态寄存器能当证据,寄存器残骸和 LR 不要当调用链。
最后记住三个"只能到这"的边界:只有 PC 没有 axf 时定位不到源码行;崩在狗启动之前时不会自动重启,但记录依然有效;拔电之后一切归零,那种场景要靠 Flash 落盘。
本文档由 markdowncli 技能辅助生成