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 知识体系总览
下面这张图,把五种软件异常的核心逻辑串起来了。你可以把它当作排查故障的路线图。
这五种异常,说白了都是资源管理的问题。要么是时间资源(CPU、喂狗周期),要么是空间资源(内存、栈),要么是锁资源(互斥量、信号量)。你只要把资源管好了,软件异常就能减少一大半。
最后说一句:别等到出问题了才去查。在代码里埋好检测点——栈使用率监控、内存泄漏检测、死锁超时机制。这些防御性编程的习惯,能帮你省下无数个通宵排查的夜晚。
公众号:蓝海资料掘金营,微信deep3321