第三章 软件故障定位:程序跑飞与看门狗复位分析
软件故障,说白了就是代码跑偏了。你写的时候觉得逻辑完美,一上电就各种抽风。我做了十几年单片机,最头疼的不是硬件短路,而是这种“看起来没问题,跑起来全乱套”的软故障。这一章,咱们就专门啃这块硬骨头。
3.1 程序跑飞与看门狗复位分析
程序跑飞,就是CPU执行了不该执行的指令。指针跳到未初始化区域、堆栈被破坏、中断嵌套乱掉,都会导致跑飞。看门狗复位,是系统最后的救命稻草——但如果你连它怎么复位的都搞不清,那这根稻草就是根草绳。
核心判断方法:
- 检查MCU复位标志寄存器(RCC_CSR、RSTSRC等),确认复位源
- 在main函数入口处设置GPIO翻转,看复位后是否执行到该点
- 利用空闲中断或HardFault_Handler捕获异常现场
我遇到过最典型的一个案例:某款智能锁产品,用户反映偶尔死机。我查了三天,发现是看门狗喂狗位置不对——喂狗代码放在了一个条件分支里,某些路径根本执行不到。嗯,这种坑,你不看汇编是发现不了的。
看门狗复位分析流程
- 确认复位源:读取复位状态寄存器,看是POR、BOR还是IWDG/WWDG复位
- 定位跑飞点:在HardFault_Handler中保存LR、PC、SP到备份寄存器或RAM保留区
- 分析堆栈回溯:通过SP指针回溯调用栈,找到跑飞前的最后函数调用
- 检查喂狗路径:确认所有主循环路径、中断路径、低功耗路径都覆盖了喂狗
注意:看门狗复位后,MCU会重新初始化。如果你在初始化代码里把复位标志清掉了,那就永远不知道是谁复位的。我建议在启动代码最前面就保存复位标志,等系统稳定后再处理。
// 示例:STM32复位源检测
void CheckResetSource(void)
{
uint32_t reset_src = RCC->CSR;
if (reset_src & RCC_CSR_LPWRRSTF) {
// 低功耗管理复位
Error_Handler("LPWR Reset");
}
if (reset_src & RCC_CSR_WWDGRSTF) {
// 窗口看门狗复位
Error_Handler("WWDG Reset");
}
if (reset_src & RCC_CSR_IWDGRSTF) {
// 独立看门狗复位
Error_Handler("IWDG Reset");
}
if (reset_src & RCC_CSR_SFTRSTF) {
// 软件复位
Error_Handler("SW Reset");
}
// 清除复位标志
RCC->CSR |= RCC_CSR_RMVF;
}
3.2 堆栈溢出检测与定位
堆栈溢出,是嵌入式开发里最隐蔽的杀手。你想想看,局部变量、函数调用、中断嵌套,全挤在那一小块RAM里。一旦溢出,数据就写到隔壁变量家里去了——表现就是各种莫名其妙。
我个人习惯,在每个任务或主循环里都做堆栈水位检测。不是等到溢出了再查,而是提前预警。
堆栈检测三板斧:
- 填充法:初始化时用0xDEAD或0x5A5A填充整个堆栈区,运行时从栈底往上扫描,看还有多少没被覆盖
- 边界哨兵:在堆栈底部和顶部各放一个哨兵变量,定时检查是否被改写
- MPU保护:如果MCU支持MPU,直接给堆栈区域设置访问权限,溢出就触发异常
我曾经在一个RTOS项目里,发现某个任务总是跑着跑着就挂了。用填充法一查,好家伙,堆栈用了95%。那个任务里有个递归函数,深度一深就爆了。后来我把递归改成了迭代,问题解决。
// 堆栈填充检测示例
#define STACK_SIZE 512
#define STACK_FILL 0x5A5A5A5A
uint32_t stack_area[STACK_SIZE];
void Stack_Init(void)
{
for (int i = 0; i < STACK_SIZE; i++) {
stack_area[i] = STACK_FILL;
}
}
uint32_t Stack_GetUsage(void)
{
uint32_t used = 0;
for (int i = STACK_SIZE - 1; i >= 0; i--) {
if (stack_area[i] != STACK_FILL) {
used = STACK_SIZE - i;
break;
}
}
return used;
}
3.3 中断优先级配置错误排查
中断优先级配置错了,系统表现就是“偶尔卡死”、“按键不灵”、“通信丢包”。说白了,就是高优先级中断把低优先级的活给抢了,或者两个中断互相抢导致死锁。
我见过最离谱的配置:有人把定时器中断和串口中断设成同一优先级,结果两个中断同时触发时,CPU不知道先处理谁,直接进HardFault。
| 错误类型 | 现象 | 排查方法 |
|---|---|---|
| 优先级相同冲突 | 中断嵌套时系统卡死 | 检查NVIC配置,确保关键中断优先级唯一 |
| 高优先级中断耗时过长 | 低优先级中断响应延迟 | 在中断服务函数里加GPIO翻转,用示波器测时长 |
| 中断优先级分组不一致 | 中断行为异常 | 确认所有模块使用相同的优先级分组(如NVIC_PriorityGroup_4) |
| 未使能中断 | 外设不工作 | 检查NVIC_EnableIRQ和中断向量表 |
避坑指南:我曾经在一个多中断项目里,把SysTick中断优先级设成了0(最高),结果所有其他中断都被它堵死了。SysTick只是用来做系统滴答,优先级设成最低反而更合理。记住:越频繁的中断,优先级应该越低,除非你有实时性要求。
3.4 死循环与任务挂起分析
死循环,就是代码卡在一个地方出不来了。任务挂起,是RTOS里任务被阻塞了,但没人去唤醒它。这两种情况,表现都是“系统不响应了”。
怎么区分?看电流。死循环的CPU还在跑,电流正常;任务挂起的CPU可能进了低功耗模式,电流会降下来。
死循环定位方法
- 断点法:在可疑循环前后加断点,看能不能跑过去
- LED闪烁法:在关键路径上放LED翻转,看哪个灯不闪了
- 串口打印法:在循环体里打印计数器,看输出是否停止
- JTAG/SWD暂停法:用调试器暂停CPU,看PC指针停在哪里
任务挂起分析步骤
- 检查任务状态寄存器,看是READY、BLOCKED还是SUSPENDED
- 如果是BLOCKED,检查等待的信号量、消息队列、互斥锁是否被释放
- 如果是SUSPENDED,检查是否有其他任务调用了vTaskSuspend()
- 检查任务优先级,看是否有更高优先级的任务一直占用CPU
实战技巧:我习惯在每个任务里加一个“心跳计数器”,任务每执行一次就加1。如果某个任务的心跳计数器长时间不变,那它肯定挂了。这个办法在RTOS调试里特别好用。
// 任务心跳检测示例
uint32_t task_heartbeat[10] = {0};
void Task1(void *param)
{
while(1) {
task_heartbeat[0]++;
// 任务实际代码
vTaskDelay(10);
}
}
void Task2(void *param)
{
while(1) {
task_heartbeat[1]++;
// 任务实际代码
vTaskDelay(10);
}
}
// 监控任务
void MonitorTask(void *param)
{
uint32_t last_beat[10] = {0};
while(1) {
for (int i = 0; i < 2; i++) {
if (task_heartbeat[i] == last_beat[i]) {
// 任务挂起,记录错误
Error_Handler("Task %d hung", i);
}
last_beat[i] = task_heartbeat[i];
}
vTaskDelay(100);
}
}
软件故障定位,说白了就是“猜-验证-再猜”的过程。你经验越多,猜得越准。但不管经验多丰富,工具和方法才是根本。这一章讲的方法,我建议你在项目里都试一遍,找到最适合自己的那一套。