3、异常捕获与处理:HardFault分析、栈回溯技术、断言(assert)的使用、看门狗(WDT)原理与配置、系统复位原因分析

做飞控这些年,我最怕的就是飞机在天上突然“死机”。

不是怕炸机,是怕炸了之后还不知道为什么。你想想看,飞控跑着跑着突然不动了,日志也没来得及写,黑匣子一片空白——这种时候,异常捕获与处理就是你最后的救命稻草。

这一章,咱们就把这些“保命技能”挨个捋一遍。

3.1 HardFault分析——飞控崩溃后的第一现场

HardFault,说白了就是CPU遇到了它处理不了的错误。在Cortex-M内核里,这是最高优先级的异常。一旦触发,系统直接挂起。

常见的HardFault诱因:

  • 访问了非法地址(比如野指针、空指针解引用)
  • 执行了未定义的指令(代码区被意外改写)
  • 总线错误(比如外设时钟没开就去操作寄存器)
  • 栈溢出(局部变量太大,把栈给撑爆了)

核心思路:HardFault发生时,CPU会自动压栈,把当前上下文(R0-R3、R12、LR、PC、xPSR)保存到栈里。我们要做的,就是把这些寄存器的值捞出来,反推程序最后执行到了哪里。

我个人习惯在HardFault_Handler里直接写一段汇编,把栈指针保存到一个全局变量里,然后跳转到C函数做分析。

// HardFault_Handler 汇编入口(以GCC为例)
__attribute__((naked)) void HardFault_Handler(void) {
    __asm volatile(
        "TST LR, #4\n"          // 判断使用的是MSP还是PSP
        "ITE EQ\n"
        "MRSEQ R0, MSP\n"
        "MRSNE R0, PSP\n"
        "B HardFault_Analyze\n" // 跳转到C函数,R0就是栈指针
    );
}

// C语言分析函数
void HardFault_Analyze(uint32_t *stack) {
    uint32_t r0  = stack[0];
    uint32_t r1  = stack[1];
    uint32_t r2  = stack[2];
    uint32_t r3  = stack[3];
    uint32_t r12 = stack[4];
    uint32_t lr  = stack[5];
    uint32_t pc  = stack[6];
    uint32_t psr = stack[7];

    // 打印出来,或者存到日志缓冲区
    printf("HardFault! PC = 0x%08X, LR = 0x%08X\n", pc, lr);
    // 根据PC地址反查map文件,定位到具体函数
}

避坑指南:我曾经在调试一个四旋翼飞控时,发现HardFault总是随机出现。查了三天,最后发现是DMA中断里操作了一个未初始化的环形缓冲区。记住:中断服务函数里一定要做指针有效性检查。

3.2 栈回溯技术——顺着调用链摸回去

拿到PC地址只是第一步。很多时候,真正出问题的地方不是当前函数,而是它的“祖宗函数”。比如A调B,B调C,C里崩了——但根因可能是A传了个空指针给B。

栈回溯,就是顺着栈里的LR(链接寄存器)值,一层层往上找调用链。

手动栈回溯的步骤:

  1. 拿到当前栈指针(MSP或PSP)
  2. 读取栈顶的PC和LR值
  3. 根据LR值判断上一级函数的栈帧位置
  4. 重复步骤2-3,直到回溯到main函数或任务入口
// 简易栈回溯函数
void StackTrace(uint32_t *sp, uint32_t depth) {
    for (uint32_t i = 0; i < depth; i++) {
        uint32_t pc = sp[6];  // 压栈顺序:R0-R3,R12,LR,PC,xPSR
        uint32_t lr = sp[5];
        printf("[%d] PC=0x%08X, LR=0x%08X\n", i, pc, lr);
        
        // 判断上一级栈帧位置
        // 如果LR的第2位为1,说明使用的是PSP(任务栈)
        // 否则是MSP(主栈)
        if (lr & 0x04) {
            // 从PSP获取上一级栈指针,这里需要RTOS支持
            sp = (uint32_t *)GetTaskStackPointer();
        } else {
            sp = (uint32_t *)(sp + 8); // 跳过当前帧
        }
        
        if ((uint32_t)sp < 0x20000000) break; // 地址异常,停止
    }
}

嗯,这里要注意:在RTOS环境下,任务切换会改变栈空间。如果你用的是FreeRTOS,可以调用uxTaskGetStackHighWaterMark()来检查任务栈的使用情况。我建议在HardFault处理函数里,同时打印当前任务名和栈剩余空间。

3.3 断言(assert)的使用——把错误扼杀在摇篮里

断言这东西,很多人觉得可有可无。但我跟你说,在飞控开发里,assert是性价比最高的防御手段。

它的原理很简单:检查一个条件,如果为假,就触发断点或打印错误信息。说白了,就是“我假设这里不会错,如果错了,立刻告诉我”。

// 自定义断言宏
#define ASSERT(expr) \
    do { \
        if (!(expr)) { \
            printf("ASSERT FAILED: %s, file %s, line %d\n", \
                   #expr, __FILE__, __LINE__); \
            while(1); /* 或者触发软件中断 */ \
        } \
    } while(0)

// 使用示例
void SetMotorPWM(uint8_t motor_id, uint16_t pulse) {
    ASSERT(motor_id < MOTOR_COUNT);  // 电机ID不能越界
    ASSERT(pulse <= PWM_MAX);        // 脉宽不能超过最大值
    // ... 实际设置PWM的代码
}

重要提醒:断言只在Debug版本里启用。Release版本一定要用#ifdef DEBUG包起来,否则生产环境里一个assert就能让飞机掉下来。我见过有人把assert留在正式固件里,结果飞控上电自检时因为某个传感器数据异常直接死循环——那场面,啧啧。

我个人习惯在断言失败时,不光打印信息,还把当前的PC和LR值存到备份寄存器里。这样即使系统复位了,也能通过调试器读出最后一次断言失败的位置。

3.4 看门狗(WDT)原理与配置——最后的守护者

看门狗,说白了就是一个定时器。你必须在它溢出之前“喂狗”,否则它就认为系统卡死了,自动复位。

看门狗的工作流程:

  1. 启动看门狗,设置超时时间(比如1秒)
  2. 主循环里定期调用“喂狗”函数
  3. 如果系统卡死,没人喂狗,超时后WDT触发复位

在STM32上配置独立看门狗(IWDG)很简单:

// IWDG配置示例(超时约1秒,LSI=32kHz)
void IWDG_Init(void) {
    // 写保护解除
    IWDG->KR = 0x5555;
    // 设置预分频器:64分频,计数时钟=32kHz/64=500Hz
    IWDG->PR = IWDG_Prescaler_64;
    // 设置重装载值:500次 = 1秒
    IWDG->RLR = 500;
    // 启动看门狗
    IWDG->KR = 0xCCCC;
}

// 喂狗函数
void IWDG_Feed(void) {
    IWDG->KR = 0xAAAA;
}

经验之谈:喂狗的位置很有讲究。我曾经在一个项目里把喂狗放在中断里,结果主循环卡死了,中断还在跑,看门狗一直不复位——这等于白装了。正确的做法是:喂狗放在主循环的关键路径上,确保整个任务调度链是活的。

对于飞控来说,我建议使用窗口看门狗(WWDG)。它不光要求你在超时前喂狗,还要求你不能喂得太早。这样能检测出“主循环跑飞了但中断还在跑”的情况。

3.5 系统复位原因分析——搞清楚是谁干的

飞机莫名其妙复位了,是看门狗咬的?还是电源不稳?还是软件主动复位?

大部分MCU都有复位状态寄存器,能告诉你上次复位的原因。以STM32为例:

复位源 标志位 说明
上电复位 POR/PDR 刚上电,正常
外部复位 NRST引脚 被外部按键或调试器复位
看门狗复位 IWDG/WWDG 系统卡死,被狗咬了
软件复位 SFTRST 调用了NVIC_SystemReset()
低电压复位 LVD 电源电压低于阈值
// 读取并打印复位原因
void CheckResetCause(void) {
    uint32_t csr = RCC->CSR;
    
    if (csr & RCC_CSR_PORRSTF) {
        printf("复位原因:上电复位\n");
    } else if (csr & RCC_CSR_IWDGRSTF) {
        printf("复位原因:独立看门狗复位\n");
    } else if (csr & RCC_CSR_WWDGRSTF) {
        printf("复位原因:窗口看门狗复位\n");
    } else if (csr & RCC_CSR_SFTRSTF) {
        printf("复位原因:软件复位\n");
    } else if (csr & RCC_CSR_PINRSTF) {
        printf("复位原因:外部引脚复位\n");
    }
    
    // 清除标志位,避免下次误判
    RCC->CSR |= RCC_CSR_RMVF;
}

我建议在系统初始化的最开头就调用这个函数,把复位原因存到备份寄存器里。这样即使后续代码又把系统搞崩了,至少能知道上一次是怎么死的。

实战建议:把复位原因、HardFault时的PC值、最后一次断言失败的位置,这三个信息存到备份SRAM(BKP SRAM)里。这样无论怎么复位,数据都不会丢。下次上电时先读出来,打印到日志里——这就是飞控的“黑匣子”雏形。

好了,这一章的内容就这些。异常捕获和处理,说白了就是给飞控装上一套“事故调查系统”。平时可能用不上,但一旦出了问题,它就是帮你定位故障的唯一线索。下一章咱们聊聊内存管理——嗯,那又是另一个容易翻车的地方。