Skip to content

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 的样例来自不同构建版本,地址不可互相比对):

text
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 快照(必须在清标志之前,否则读到的复位源是空的),然后按记录是否有效分流:

c
// 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:

c
// atk-f103/Core/Src/stm32f1xx_it.c(HardFault 现场)
noinit_save(NOINIT_CAUSE_HARDFAULT, frame[6]);  /* 先于打印: 打印二次 fault 也不丢 */

【关键】

这一行的位置决定了可靠性:先落盘、再打印。如果打印过程中发生二次 fault(例如栈已被写坏),NOINIT 里的死因仍然完整。

普通主循环则只刷心跳,不碰死因字段:

c
// 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 行:心跳语义 ​

text
  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 行:唯一能落到代码的字段 ​

text
  pc_hint: 0x08002D7C

这是整份记录里**唯一能翻译成"函数 + 源码行"**的字段,取值规律:

取值含义
0x0800xxxxFlash 里的合法代码地址,可直接反查(见 LV020)
0x00000000本次没有自报路径(消费时被归零),不是"崩溃在地址 0"

为什么会有 0x00000000:记录被消费(或冷启动)之后,cause / pc_hint / magic 一起归零,之后 noinit_tick() 只重写 uptime_ms / magic / check,不碰这两个字段。于是"心跳记录"天然带着 cause: none + pc_hint: 0x00000000 这一对自洽的组合。

反查的前提:必须用当时那份固件的 .axf。地址随每次重新构建变化,固件重编之后旧日志里的地址会翻到别处去。

2.4 reset src 行:复位源逐位解读 ​

text
  reset src: IWDG PIN (RCC->CSR = 0x24000003)

这行把 RCC->CSR 的复位标志位逐个翻译成名字,再附上原始十六进制值。STM32F1 的位定义:

位名称含义工程判读
31LPWRRSTF低功耗管理复位进了 Standby/Stop 后被唤醒复位
30WWDGRSTF窗口看门狗复位本项目未用 WWDG
29IWDGRSTF独立看门狗复位狗咬,死机链路最常见
28SFTRSTF软件复位NVIC_SystemReset(),例如 reboot 命令
27PORRSTF上电/掉电复位冷启动、拔电重插
26PINRSTFNRST 引脚复位按复位键、调试器复位,也是内部复位的伴随位
24RMVF标志清零位打印时通常为 0:快照取在 __HAL_RCC_CLEAR_RESET_FLAGS() 之前
1LSIRDYLSI 就绪IWDG 的时钟源状态
0LSIONLSI 使能IWDG 一旦启用过,复位后 LSI 仍保持使能

实测三个取值对照:

RCC->CSR置位情况结论
0x24000003IWDG + PIN + LSI狗咬复位(PIN 为伴随位)
0x14000003SFT + PIN + LSI软件复位(reboot 命令、NVIC_SystemReset())
0x04000003PIN + LSI真正按了复位键/调试器复位

【易错点】

STM32F1 的内部复位(IWDG、软件复位)会同时把 NRST 引脚拉低,所以 PINRSTF 经常和 IWDGRSTF / SFTRSTF 一起置位。判读要看"主因位",不要看到 PIN 就认为有人按了复位键。

2.5 末尾的 commands 提示行 ​

text
NOINIT: commands: 'scene' inspect record, 'deadloop' test IWDG reset

纯提示,无分析价值,但它标志着一轮启动报告结束——下面再出现 crash: / deadloop: 这类业务输出,就是本次运行中的动作了。

3. scene 命令的输出 ​

事后想复核现场,在串口敲 scene:

text
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 里的对应分析价值
PCpc_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~7IACCVIOL取指访问违规MPU 配置错误
DACCVIOL数据访问违规MPU 配置错误
MSTKERR入栈失败栈指针被写坏
BFSR 8~15IBUSERR取指总线错误跳进未映射区域
PRECISERR精确数据总线错误访问未映射地址(BFAR 给出地址)
IMPRECISERR不精确数据总线错误写缓冲延迟报错,BFAR 不可用
STKERR入栈总线错误栈溢出
UNSTKERR出栈总线错误异常返回时栈指针被写坏
BFARVALID故障地址有效标志与上面配合读 BFAR
UFSR 16~31UNDEFINSTR未定义指令跳飞、数据被当指令执行
INVSTATE非法状态Thumb/BLX 目标非法
UNALIGNED非对齐访问指针类型转换错误
DIVBYZERO除零DIV_0_TRP 置位后触发 UsageFault(UsageFault 未使能则升级 HardFault);默认商置 0、无 fault

HFSR 的 FORCED(bit30)不代表故障更严重,只说明 MemManage / BusFault 没有单独使能,可升级故障被统一升级成了 HardFault——本例就属这种情况。

5. 第五步:算时间线 ​

用 uptime 拼时间线:

text
记录里的 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:手动复位,记录有效但无人自报 ​

text
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: PIN0x04000003 = PINRSTF + LSION/LSIRDY,没有 IWDG/SFT/POR:手动复位或调试器复位

【结论】

这一次不是故障,是"热复位 + 心跳记录"的正常形态。它对分析的价值在于验证了保留机制本身:记录确实穿越了复位,并且字段是自洽的。

2. 案例 B:HardFault 后看门狗复位 ​

text
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):

text
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 技能辅助生成