4、软件异常检测:看门狗超时、任务死锁、内存溢出、堆栈溢出、优先级反转

软件异常,说白了就是代码跑飞了。你想想看,硬件再可靠,软件一崩,整个系统就瘫了。我在运动控制项目里,至少有一半的现场故障,最后都定位到软件异常上。今天咱们就聊聊最常见的五种:看门狗超时、任务死锁、内存溢出、堆栈溢出、优先级反转。

4.1 看门狗超时:最后的救命稻草

看门狗(Watchdog)是什么?就是一个定时器。你得定期去“喂”它。如果它超时了,就认为系统卡死了,直接复位。

为什么会超时?

  • 任务死循环,没机会喂狗
  • 中断服务程序执行太久,阻塞了喂狗任务
  • 喂狗任务优先级太低,被其他任务饿死

核心原则:喂狗的位置要选对。别在主循环里喂,要在最高优先级的周期性任务里喂。这样一旦高优先级任务卡住,狗立刻叫。

我个人习惯的做法是:

// 喂狗任务,优先级最高,周期10ms
void WatchdogTask(void *param) {
    while(1) {
        // 先检查关键任务是否还活着
        if (CheckTaskAlive()) {
            HAL_Watchdog_Refresh();
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

我在项目中遇到过一件事:现场设备偶尔死机,复位后又能跑。查了三天,发现是喂狗任务被一个低优先级的串口打印任务阻塞了。嗯,从那以后,我所有喂狗任务都设成最高优先级,并且禁止任何阻塞调用。

避坑指南:我曾经在喂狗函数里加了一个printf调试信息,结果串口缓冲区满的时候,喂狗也被阻塞了。记住:喂狗路径上,不要有任何可能阻塞的代码。

4.2 任务死锁:两个任务互相等

死锁,说白了就是两个任务互相占着对方需要的资源,谁也不让谁。比如任务A持有锁1,等锁2;任务B持有锁2,等锁1。结果两个都卡死了。

死锁的四个必要条件:

条件 说明
互斥 资源一次只能被一个任务使用
持有并等待 任务拿着一个资源,还去等另一个
不可剥夺 资源不能被强制拿走
循环等待 形成等待环路

你只要打破任意一个条件,死锁就解了。我常用的方法是:统一锁的顺序。所有任务都按同样的顺序申请锁,就不会形成环路。

// 错误做法:顺序不一致
void TaskA() {
    Lock(Mutex1);
    Lock(Mutex2);  // A先拿1再拿2
    // ...
}

void TaskB() {
    Lock(Mutex2);
    Lock(Mutex1);  // B先拿2再拿1 → 可能死锁
}

// 正确做法:统一顺序
void TaskA() {
    Lock(Mutex1);
    Lock(Mutex2);
}

void TaskB() {
    Lock(Mutex1);  // 也先拿1
    Lock(Mutex2);  // 再拿2
}

小技巧:实在避免不了复杂锁依赖,可以用“超时锁”。申请锁时设个超时,拿不到就放弃并释放已有资源。虽然效率低点,但至少不会死锁。

4.3 内存溢出:不知不觉就崩了

内存溢出,就是动态分配的内存没释放,或者碎片太多,导致后续分配失败。运动控制里,内存泄漏特别隐蔽——可能跑几个小时才出问题。

常见场景:

  • 每次中断都malloc,但忘了free
  • 环形缓冲区写指针追上了读指针,数据覆盖
  • 任务栈里定义了超大局部变量

我建议:运动控制里尽量不用动态内存分配。所有缓冲区、任务栈、消息队列,都在初始化时静态分配好。如果非要用,那就用内存池,提前划分好固定大小的块。

// 静态分配,避免动态malloc
#define MAX_PACKET_SIZE 256
#define PACKET_POOL_SIZE 10

static uint8_t packet_pool[PACKET_POOL_SIZE][MAX_PACKET_SIZE];
static uint8_t pool_used[PACKET_POOL_SIZE] = {0};

void* PacketAlloc() {
    for (int i = 0; i < PACKET_POOL_SIZE; i++) {
        if (!pool_used[i]) {
            pool_used[i] = 1;
            return packet_pool[i];
        }
    }
    return NULL;  // 池满了,不会溢出,但会返回NULL
}

避坑指南:我曾经在中断里用malloc分配一个很小的缓冲区,结果中断频繁触发,内存碎片越来越多,最后系统在运行8小时后突然崩溃。从那以后,中断里我绝对不用动态分配。

4.4 堆栈溢出:函数调用太深了

堆栈溢出,就是函数调用层数太多,或者局部变量太大,把栈空间撑爆了。后果很严重——可能覆盖其他任务的数据,或者直接触发硬件异常。

为什么会溢出?

  • 递归调用没有终止条件
  • 中断嵌套太深
  • 局部数组太大(比如在函数里定义了一个10KB的数组)

我常用的检测方法:

// 在任务创建时,给栈填充固定模式
#define STACK_FILL_PATTERN 0xDEADBEEF

void CreateTaskWithGuard() {
    StackType_t *stack = pvPortMalloc(STACK_SIZE * 4);
    // 填充栈空间
    for (int i = 0; i < STACK_SIZE; i++) {
        stack[i] = STACK_FILL_PATTERN;
    }
    xTaskCreateStatic(TaskFunc, "Task", STACK_SIZE, 
                      NULL, PRIORITY, stack, &task_buf);
}

// 定期检查栈使用情况
void CheckStackUsage() {
    for (int i = STACK_SIZE - 1; i >= 0; i--) {
        if (stack[i] != STACK_FILL_PATTERN) {
            // 找到实际使用的栈顶
            uint32_t used = (STACK_SIZE - 1 - i) * 4;
            if (used > STACK_SIZE * 0.8) {
                // 使用率超过80%,报警
                ReportStackWarning(used);
            }
            break;
        }
    }
}

经验之谈:我一般给每个任务分配栈空间时,会多给30%的余量。别抠那点内存,栈溢出导致的故障,排查成本远高于那几KB内存。

4.5 优先级反转:高优先级任务被低优先级任务拖死

优先级反转,是实时系统里最坑的问题之一。简单说:高优先级任务在等一个资源,但这个资源被低优先级任务占着。而低优先级任务又被中等优先级任务抢占了CPU,导致高优先级任务迟迟拿不到资源。

经典场景:

  • 任务H(高优先级)要访问共享资源
  • 任务L(低优先级)正拿着资源的锁
  • 任务M(中等优先级)抢占了L,L无法释放锁
  • H只能干等,直到M跑完,L释放锁

解决办法:优先级继承。当低优先级任务持有高优先级任务需要的锁时,临时把低优先级任务的优先级提升到高优先级任务的级别。这样中等优先级任务就抢不过它了。

// 使用支持优先级继承的互斥量
// FreeRTOS中,使用互斥量(Mutex)而非二值信号量
// 互斥量自带优先级继承机制

// 正确做法:用互斥量
SemaphoreHandle_t mutex = xSemaphoreCreateMutex();

void HighPriorityTask() {
    xSemaphoreTake(mutex, portMAX_DELAY);  // 如果被阻塞,会触发优先级继承
    // 访问共享资源
    xSemaphoreGive(mutex);
}

void LowPriorityTask() {
    xSemaphoreTake(mutex, portMAX_DELAY);
    // 持有锁期间,如果高优先级任务在等,L的优先级会被临时提升
    // 这样中等优先级任务就无法抢占L了
    xSemaphoreGive(mutex);
}

注意:别用二值信号量(Binary Semaphore)保护共享资源。它不支持优先级继承,遇到优先级反转就傻眼了。我见过一个项目,就是因为用信号量代替互斥量,导致电机控制任务偶尔抖动。换成互斥量后,问题消失。

4.6 知识体系总览

下面这张图,把五种软件异常的核心逻辑串起来了。你可以把它当作排查故障的路线图。

软件异常检测知识体系 软件异常检测 看门狗超时 喂狗任务被阻塞 中断执行太久 任务死锁 循环等待资源 锁顺序不一致 内存溢出 动态分配未释放 内存碎片化 堆栈溢出 递归调用太深 局部变量过大 优先级反转 高优先级被阻塞 低优先级被抢占 常用解决方案 ● 看门狗: 高优先级喂狗,避免阻塞调用 ● 死锁: 统一锁顺序,使用超时锁 ● 内存溢出: 静态分配优先,内存池管理 ● 堆栈溢出: 栈填充检测,预留30%余量 ● 优先级反转: 使用互斥量,启用优先级继承

这五种异常,说白了都是资源管理的问题。要么是时间资源(CPU、喂狗周期),要么是空间资源(内存、栈),要么是锁资源(互斥量、信号量)。你只要把资源管好了,软件异常就能减少一大半。

最后说一句:别等到出问题了才去查。在代码里埋好检测点——栈使用率监控、内存泄漏检测、死锁超时机制。这些防御性编程的习惯,能帮你省下无数个通宵排查的夜晚。


公众号:蓝海资料掘金营,微信deep3321