三个现象,三条根因
同一个工程重构后连续踩了三个坑,最危险的误判是把它们当成同一个根因。
| 现象 | 直接原因 | 真正的回归点 |
|---|---|---|
上电进 HardFault_Handler | BOOT 遗留 SysTick 在调度器初始化前触发 | APP 提前执行了 cpsie i |
| 修复后持续复位 | BOOT 遗留 UART0 中断打进弱 handler | Board_Init 早于 UART 驱动安装 |
OTA 收到 0xF0 后不回 BOOT | 升级标志未写入就复位 | 同步命令改成了队列异步 |
一条被推翻的假设:
0x8000升级标志地址曾出现在可正常工作的039092d版本里,所以它不能单独作为 OTA 失败根因。地址是背景条件,不是回归点。
为什么重构前一切正常
重构前 APP 是裸机轮询模型:
AppTask_Poll()
-> 1ms 任务
-> 10ms 任务
-> 100ms 任务
-> 1s 任务
SysTick 在这里只服务裸机 tick、UserTimer_Handler()、软件定时器和延时复位。它不会碰 pxCurrentTCB、xTickCount、pxDelayedTaskList、pxReadyTasksLists。所以即使 SysTick 在启动早期到来,走的是裸机处理链,不会访问尚未初始化的内核对象。
OTA 也是同步语义:ComTask 收到 0xF0 后在同一上下文里完成密码校验、更新 IAP 状态、写升级标志、调用 IAP_Reboot() 设置延时复位。交棒动作没有额外的时序窗口。
引入 FreeRTOS 后,硬件状态一个都没变,但同一批中断源被交给了另一套软件模型。原来的"遗留状态"从可处理变成了致命。
HardFault:SysTick 打进未初始化的内核
调用链
BOOT 用裸机 SysTick 并把它交给了 APP。APP 引入 FreeRTOS 后,启动代码在调度器初始化之前就执行了 cpsie i。此时 BOOT 遗留的 SysTick 仍然使能,甚至已经 pending。APP 已把向量表复制到 RAM,于是中断通过 APP 的向量表进入:
SysTick
-> SysTick_Handler
-> xTaskIncrementTick
-> 访问尚未初始化的延时链表
内核链表和 pxCurrentTCB 都还是空值,精确总线错误最终被升级为 HardFault。
证据一:异常寄存器
J-Link 抓到的值:
| 寄存器 | 值 | 解读 |
|---|---|---|
IPSR | 3 | 当前正在处理 HardFault |
CFSR | 0x00008200 | BFSR = 0x82,即 BFARVALID 加 PRECISERR |
HFSR | 0x40000000 | FORCED = 1,底层 BusFault 被升级 |
BFAR | 0x0847704B | 非法的总线访问地址 |
PRECISERR 说明发生了精确数据访问错误,BFAR 有效,可以直接拿地址去对源码。
证据二:栈帧映射回源码
Cortex-M 的异常压栈帧固定为 8 个字:
| 偏移 | 内容 | 偏移 | 内容 |
|---|---|---|---|
| 0 | R0 | 16 | R12 |
| 4 | R1 | 20 | LR |
| 8 | R2 | 24 | PC |
| 12 | R3 | 28 | xPSR |
调试器读出 stacked PC = 0x00012C56、stacked LR = 0x00013709,再映射:
arm-none-eabi-addr2line -e xxx.elf -f -C 0x12C56 0x13709
arm-none-eabi-objdump -d -S xxx.elf
0x12C56 -> xTaskIncrementTick()
0x13709 -> SysTick_Handler 调用返回点
到这里调用链才真正闭环:不是"某处野指针",而是"中断进错了处理函数"。
证据三:内核对象与 SysTick 状态
pxCurrentTCB = 0
pxDelayedTaskList = 0
xTickCount = 1
SysTick 已经跑过一次,但调度器还没完成初始化。
SysTick CTRL = 0x00010007 /* ENABLE | TICKINT | CLKSOURCE */
SysTick LOAD = 0x0001387F /* 79999,80 MHz 下正好 1 ms */
往前查 BOOT:MCU_SystemClock_Init() 调用了 SysTick_Config(),而跳转 APP 前只执行了 __disable_irq()。
__disable_irq()只置PRIMASK,不等于关闭 SysTick、NVIC 或外设中断源。APP 一旦cpsie i,遗留中断立刻恢复。
持续复位:遗留的 UART0 中断源
清掉 SysTick 和 NVIC pending 后 HardFault 消失,但新的中断源浮出水面:BOOT 用 UART0 做调试、I2C0 做升级通信,NVIC pending 清了,外设本身仍可能保持中断源。
证据
NVIC_ISER0 = 0x86650001
NVIC_ISPR0 = 0x80000000 /* bit31 -> IRQ31 -> UART0 */
NVIC_IABR0 = 0x80000000
在 APP 弱 handler 地址 0xDE24 下断点,命中的是 UART0_IRQHandler,实际路径为:
UART0_IRQHandler
-> DefaultISR
-> SystemSoftwareReset
这就是"不断复位"的直接原因。
为什么进了弱 handler
APP 的初始化顺序有问题:
Board_Init
-> INT_SYS_ConfigInit
-> 使能 UART0 IRQ /* 此时 handler 还不存在 */
...
Mcal_Init
-> MCU_Uart_Init /* 真正的 UART_DRV_IRQHandler 在这里才安装 */
中间这段窗口里,只要 UART0 中断源有效,就会掉进默认弱处理函数,而它的实现是软件复位。使能中断必须先于安装 handler 这条顺序被打破了。
OTA 失败:同步改异步留下的窗口
差异只有一处
039092d 版本:
ComTask
-> IAP_UpgradeCommand()
-> 校验密码
-> 写升级标志
-> 设置延时复位
FreeRTOS 重构版:
ComTask
-> IAP_UpgradeCommand()
-> xQueueSend() /* 到这里就返回了 */
-> IAP_TaskLoop()
-> IAP_ProcessEvents()
-> IAP_ProcessUpgradeCommand()
-> 写升级标志
窗口在哪
0xF0 的本质是 BOOT / APP 交棒,对顺序的要求很强:
- 写升级标志必须完成。
- 标志必须回读成功。
- 才允许设置复位。
- 复位后 BOOT 才能读到
0xA5A55A5A。
改成队列异步后,ComTask 只负责入队。如果在这个窗口里发生复位或异常,0x8000 仍是擦除态 0xFFFFFFFF,BOOT 判定"没有升级请求",直接跳回 APP。
修复
恢复关键交棒命令的同步语义,同时保留 FreeRTOS 架构:
void IAP_UpgradeCommand(UpgradeCommand_t cmd, uint8_t *key)
{
IAP_ProcessUpgradeCommand(cmd, key);
}
原则是:只有非关键数据处理才适合异步队列化。凡是"复位前置条件"的动作,状态机上的顺序必须显式且不可跳过。
寄存器速查
| 寄存器 | 地址 | 关键位 | 用途 |
|---|---|---|---|
IPSR | xPSR 低 9 位 | 0 线程 / 3 HardFault / 15 SysTick / 14 PendSV / 11 SVCall | 判断当前在普通代码还是异常里 |
CFSR | 0xE000ED28 | MMFSR / BFSR / UFSR | 一次性看全 MemManage、BusFault、UsageFault |
HFSR | 0xE000ED2C | bit1 VECTTBL / bit30 FORCED / bit31 DEBUGEVT | 判断是不是被升级上来的 |
BFAR | 0xE000ED38 | 配合 BFARVALID | 精确总线错误地址 |
MMFAR | 0xE000ED34 | 配合 MMARVALID | MemManage 故障地址 |
DFSR | 0xE000ED30 | 单步 / 断点 / 外部调试 | 区分调试器造成的假故障 |
ICSR | 0xE000ED04 | bit25 PENDSTCLR / bit26 PENDSTSET | 查 pending,清 SysTick / PendSV |
BFSR 常用的五位:IBUSERR(8) 取指错误、PRECISERR(9) 精确数据错误、IMPRECISERR(10) 非精确数据错误、UNSTKERR(11) 出栈错误、STKERR(12) 入栈错误、BFARVALID(15) 地址有效。
NVIC 侧只看五组就够:
| 地址 | 名称 | 作用 |
|---|---|---|
0xE000E100 | ISER | 中断使能 |
0xE000E180 | ICER | 中断禁能 |
0xE000E200 | ISPR | 置挂起 |
0xE000E280 | ICPR | 清挂起 |
0xE000E300 | IABR | 当前 active |
注意
PENDSTCLR和PENDSTSET同时置位会重新挂起 SysTick。清 pending 时别写错寄存器。
可复用的三步流程
HardFault
- 暂停 CPU,读
IPSR,确认真的在异常里。 - 读
CFSR/HFSR/BFAR/MMFAR,判 fault 类型。 - 按
EXC_RETURN的 bit2 选择 MSP 或 PSP。 - 读栈帧里的 PC / LR / xPSR。
addr2line加objdump映射到源码行。- 用 LR 恢复调用链。
- 检查相关 NVIC、SysTick、外设状态。
- 做最小判别实验(关一处、看现象是否消失)。
- 修复后复查
CFSR/HFSR是否归零。
复位
- 查复位源寄存器。
- 在
SystemSoftwareReset/NVIC_SystemReset/DefaultISR下断点。 - 读 NVIC 的 ISER / ISPR / IABR。
- 查 UART / I2C / SPI / WDG 的中断使能与状态。
- 判定是主动复位、看门狗、异常复位还是电源复位。
BOOT 到 APP 交棒检查表
- 关全局中断。
- 关 SysTick。
- 清 SysTick / PendSV pending。
- 关全部 NVIC 中断。
- 清全部 NVIC pending。
- 去初始化 UART / I2C 等外设中断源。
- 设置 APP 的 MSP。
DSB/ISB。- 跳转 APP。
本次修改与验证
BOOT 交棒前补齐:关闭 SysTick、清 SysTick 与 PendSV pending、禁用全部 NVIC 并清 pending、UART0 与 I2C0 从机去初始化。APP 侧把 0xF0 恢复为立即处理。
修复后 J-Link 读到:
CFSR = 0
HFSR = 0
PSP = 0x200036A0
CONTROL = 2
pxCurrentTCB = 0x200036C8
xTickCount = 0xA584
任务心跳同步递增:
Power heartbeat = 0xA585
Comm heartbeat = 0xA585
SPI heartbeat = 0xA585
IAP heartbeat = 0x108E
没有 HardFault、已进入任务模式、SysTick 正常、多任务正常。
经验教训
- 异常是结果,不是根因。
HardFault_Handler只是最终落点,真正的原因在更早的 BOOT 交棒、未初始化的 RTOS 对象、遗留 NVIC pending,或者一次从同步改成异步的重构。 - 三类问题必须分开处理。 CPU 异常、软件复位循环、功能失败(协议状态机 / 队列时序 / Flash 写入时序)是三条独立的排查线。
- 别被单步地址误导。
_IVT_RAM_loop是复位阶段用固定模式初始化 IVT RAM 的正常循环,只是次数多,看起来像卡死。判定要靠IPSR/CFSR/HFSR和真实调用链。 - 硬件状态交接必须显式化。 BOOT 和 APP 是两套镜像、两套中断模型,一句
__disable_irq()代表不了状态已清理,至少要覆盖内核状态、NVIC 状态、外设中断源和软件运行模型四层。 - 同步语义不能随意异步化。 交棒、复位、写标志这类前置条件,在状态机上必须保持"写标志、回读、允许复位、复位"的固定顺序。
结论
- 重构前能跑,是因为 APP 用裸机调度和裸机中断模型,BOOT 遗留的 SysTick 进的是裸机处理链。
- 引入 FreeRTOS 后,同一个 SysTick 在调度器初始化前进了
SysTick_Handler,访问未初始化任务链表,触发 HardFault。 - 修好 HardFault 后又暴露 BOOT 遗留 UART0 中断源:弱 handler 指向
DefaultISR,最终调用软件复位。 - OTA 失败的回归点是 IAP 命令从同步执行改成队列异步,复位可能发生在写标志之前;与
0x8000这个地址无关。 - 定位方法是一条证据链:异常寄存器 → 栈帧 → 源码映射 → NVIC 与外设所有权 → 最小判别实验。