Firmware//18 分钟

FreeRTOS 迁移后的 HardFault、复位循环与 OTA 失败:一条完整证据链

BOOT 遗留的 SysTick 和 UART0 中断,在 APP 换成 FreeRTOS 后从「可处理」变成「致命」;OTA 失败的回归点也不在 Flash 地址,而在同步命令被改成了异步队列。

FreeRTOSHardFaultCortex-MOTABOOTJ-Link

三个现象,三条根因

同一个工程重构后连续踩了三个坑,最危险的误判是把它们当成同一个根因。

现象直接原因真正的回归点
上电进 HardFault_HandlerBOOT 遗留 SysTick 在调度器初始化前触发APP 提前执行了 cpsie i
修复后持续复位BOOT 遗留 UART0 中断打进弱 handlerBoard_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 抓到的值:

寄存器值解读
IPSR3当前正在处理 HardFault
CFSR0x00008200BFSR = 0x82,即 BFARVALID 加 PRECISERR
HFSR0x40000000FORCED = 1,底层 BusFault 被升级
BFAR0x0847704B非法的总线访问地址

PRECISERR 说明发生了精确数据访问错误,BFAR 有效,可以直接拿地址去对源码。

证据二:栈帧映射回源码

Cortex-M 的异常压栈帧固定为 8 个字:

偏移内容偏移内容
0R016R12
4R120LR
8R224PC
12R328xPSR

调试器读出 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 交棒,对顺序的要求很强:

  1. 写升级标志必须完成。
  2. 标志必须回读成功。
  3. 才允许设置复位。
  4. 复位后 BOOT 才能读到 0xA5A55A5A。

改成队列异步后,ComTask 只负责入队。如果在这个窗口里发生复位或异常,0x8000 仍是擦除态 0xFFFFFFFF,BOOT 判定"没有升级请求",直接跳回 APP。

修复

恢复关键交棒命令的同步语义,同时保留 FreeRTOS 架构:

void IAP_UpgradeCommand(UpgradeCommand_t cmd, uint8_t *key)
{
    IAP_ProcessUpgradeCommand(cmd, key);
}

原则是:只有非关键数据处理才适合异步队列化。凡是"复位前置条件"的动作,状态机上的顺序必须显式且不可跳过。

寄存器速查

寄存器地址关键位用途
IPSRxPSR 低 9 位0 线程 / 3 HardFault / 15 SysTick / 14 PendSV / 11 SVCall判断当前在普通代码还是异常里
CFSR0xE000ED28MMFSR / BFSR / UFSR一次性看全 MemManage、BusFault、UsageFault
HFSR0xE000ED2Cbit1 VECTTBL / bit30 FORCED / bit31 DEBUGEVT判断是不是被升级上来的
BFAR0xE000ED38配合 BFARVALID精确总线错误地址
MMFAR0xE000ED34配合 MMARVALIDMemManage 故障地址
DFSR0xE000ED30单步 / 断点 / 外部调试区分调试器造成的假故障
ICSR0xE000ED04bit25 PENDSTCLR / bit26 PENDSTSET查 pending,清 SysTick / PendSV

BFSR 常用的五位:IBUSERR(8) 取指错误、PRECISERR(9) 精确数据错误、IMPRECISERR(10) 非精确数据错误、UNSTKERR(11) 出栈错误、STKERR(12) 入栈错误、BFARVALID(15) 地址有效。

NVIC 侧只看五组就够:

地址名称作用
0xE000E100ISER中断使能
0xE000E180ICER中断禁能
0xE000E200ISPR置挂起
0xE000E280ICPR清挂起
0xE000E300IABR当前 active

注意 PENDSTCLR 和 PENDSTSET 同时置位会重新挂起 SysTick。清 pending 时别写错寄存器。

可复用的三步流程

HardFault

  1. 暂停 CPU,读 IPSR,确认真的在异常里。
  2. 读 CFSR / HFSR / BFAR / MMFAR,判 fault 类型。
  3. 按 EXC_RETURN 的 bit2 选择 MSP 或 PSP。
  4. 读栈帧里的 PC / LR / xPSR。
  5. addr2line 加 objdump 映射到源码行。
  6. 用 LR 恢复调用链。
  7. 检查相关 NVIC、SysTick、外设状态。
  8. 做最小判别实验(关一处、看现象是否消失)。
  9. 修复后复查 CFSR / HFSR 是否归零。

复位

  1. 查复位源寄存器。
  2. 在 SystemSoftwareReset / NVIC_SystemReset / DefaultISR 下断点。
  3. 读 NVIC 的 ISER / ISPR / IABR。
  4. 查 UART / I2C / SPI / WDG 的中断使能与状态。
  5. 判定是主动复位、看门狗、异常复位还是电源复位。

BOOT 到 APP 交棒检查表

  1. 关全局中断。
  2. 关 SysTick。
  3. 清 SysTick / PendSV pending。
  4. 关全部 NVIC 中断。
  5. 清全部 NVIC pending。
  6. 去初始化 UART / I2C 等外设中断源。
  7. 设置 APP 的 MSP。
  8. DSB / ISB。
  9. 跳转 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 状态、外设中断源和软件运行模型四层。
  • 同步语义不能随意异步化。 交棒、复位、写标志这类前置条件,在状态机上必须保持"写标志、回读、允许复位、复位"的固定顺序。

结论

  1. 重构前能跑,是因为 APP 用裸机调度和裸机中断模型,BOOT 遗留的 SysTick 进的是裸机处理链。
  2. 引入 FreeRTOS 后,同一个 SysTick 在调度器初始化前进了 SysTick_Handler,访问未初始化任务链表,触发 HardFault。
  3. 修好 HardFault 后又暴露 BOOT 遗留 UART0 中断源:弱 handler 指向 DefaultISR,最终调用软件复位。
  4. OTA 失败的回归点是 IAP 命令从同步执行改成队列异步,复位可能发生在写标志之前;与 0x8000 这个地址无关。
  5. 定位方法是一条证据链:异常寄存器 → 栈帧 → 源码映射 → NVIC 与外设所有权 → 最小判别实验。