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(链接寄存器)值,一层层往上找调用链。
手动栈回溯的步骤:
- 拿到当前栈指针(MSP或PSP)
- 读取栈顶的PC和LR值
- 根据LR值判断上一级函数的栈帧位置
- 重复步骤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秒)
- 主循环里定期调用“喂狗”函数
- 如果系统卡死,没人喂狗,超时后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)里。这样无论怎么复位,数据都不会丢。下次上电时先读出来,打印到日志里——这就是飞控的“黑匣子”雏形。
好了,这一章的内容就这些。异常捕获和处理,说白了就是给飞控装上一套“事故调查系统”。平时可能用不上,但一旦出了问题,它就是帮你定位故障的唯一线索。下一章咱们聊聊内存管理——嗯,那又是另一个容易翻车的地方。