第二十一章:安全与异常处理:看门狗应用、任务超时检测、安全状态恢复

做运动控制这些年,我踩过最大的坑是什么?

不是算法精度不够,也不是电机选型失误。而是——系统跑着跑着,突然死机了。

你想想看,一台五轴雕刻机正在加工模具,刀尖离工件还有0.1mm,主控芯片挂了。电机停在那,刀还插在工件里。等看门狗复位完,机械位置早就丢了。这种事故,轻则报废工件,重则撞坏主轴。

所以这一章,咱们专门聊聊运动控制里的安全底线。说白了,就是怎么让系统在出问题时,能体面地「死」,而不是暴毙。

21.1 看门狗:最后的防线

看门狗这玩意儿,我刚开始做嵌入式时觉得它可有可无。直到有一次,我在一个四轴平台上调试插补算法,一个死循环没处理好,电机直接冲过限位开关,「咔」一声撞上硬限位。从那以后,我再也不敢省看门狗了。

在RTOS环境下,看门狗的使用有个讲究。你不能简单地在主循环里喂狗,因为RTOS的任务调度是抢占式的。一个任务卡住了,其他任务可能还在跑,这时候喂狗反而掩盖了问题。

我个人习惯的做法是:在空闲任务里喂狗。为什么?因为空闲任务只有在所有其他任务都正常运行时才会被执行。如果某个高优先级任务死锁了,空闲任务就得不到CPU时间,看门狗自然超时复位。

核心原则:看门狗不是用来「防止」系统死机的,而是用来「检测」系统死机并自动恢复的。喂狗的位置决定了你能检测到什么级别的故障。

来看一个实际代码片段:

// 空闲任务中的看门狗喂狗实现
void vApplicationIdleHook(void)
{
    // 只有在所有任务都正常运行时,才会进入这里
    // 如果某个任务卡死,空闲任务得不到执行,看门狗就会超时
    HAL_IWDG_Refresh(&hiwdg);
    
    // 顺便检查一下各个关键任务的运行状态
    static uint32_t last_check = 0;
    if (HAL_GetTick() - last_check > 1000)
    {
        // 每秒检查一次任务的心跳
        check_task_heartbeat();
        last_check = HAL_GetTick();
    }
}

注意:不要在中断服务函数里喂狗!我见过有人把喂狗放在SysTick中断里,结果主程序死循环了,看门狗照样被定时刷新,系统永远无法复位。这种「假活」状态比死机更危险。

21.2 任务超时检测:别让一个任务拖垮整个系统

运动控制里,每个任务都有严格的时序要求。插补任务必须在1ms内完成一次计算,否则下一个周期的位置指令就发不出去。通信任务如果超时,可能导致上位机误判设备状态。

我常用的方法是任务心跳检测。每个关键任务在每次循环中更新一个时间戳,然后由一个监控任务定期检查这些时间戳是否超时。

具体实现是这样的:

// 任务心跳结构体
typedef struct {
    TaskHandle_t task_handle;   // 任务句柄
    uint32_t last_heartbeat;    // 上次心跳时间
    uint32_t timeout_ms;        // 超时阈值
    const char* task_name;      // 任务名称
    uint8_t fault_count;        // 连续故障次数
} TaskHeartbeat_t;

// 关键任务列表
TaskHeartbeat_t g_task_heartbeats[] = {
    {NULL, 0, 2,   "Interpolation", 0},   // 插补任务,2ms超时
    {NULL, 0, 5,   "ServoUpdate",   0},   // 伺服更新,5ms超时
    {NULL, 0, 20,  "Communication", 0},   // 通信任务,20ms超时
    {NULL, 0, 100, "HMI_Update",    0},   // 人机界面,100ms超时
};

// 每个任务在循环中调用此函数
void Task_HeartbeatUpdate(const char* task_name)
{
    for (int i = 0; i < sizeof(g_task_heartbeats)/sizeof(g_task_heartbeats[0]); i++)
    {
        if (strcmp(g_task_heartbeats[i].task_name, task_name) == 0)
        {
            g_task_heartbeats[i].last_heartbeat = HAL_GetTick();
            g_task_heartbeats[i].fault_count = 0;
            break;
        }
    }
}

// 监控任务:每秒检查一次所有任务的心跳
void vTaskMonitor(void *pvParameters)
{
    while(1)
    {
        uint32_t now = HAL_GetTick();
        
        for (int i = 0; i < sizeof(g_task_heartbeats)/sizeof(g_task_heartbeats[0]); i++)
        {
            uint32_t elapsed = now - g_task_heartbeats[i].last_heartbeat;
            
            if (elapsed > g_task_heartbeats[i].timeout_ms)
            {
                g_task_heartbeats[i].fault_count++;
                
                // 连续超时3次,判定为严重故障
                if (g_task_heartbeats[i].fault_count >= 3)
                {
                    // 触发安全状态恢复
                    Safety_EnterSafeState(g_task_heartbeats[i].task_name);
                }
            }
        }
        
        vTaskDelay(pdMS_TO_TICKS(100));  // 每100ms检查一次
    }
}

经验之谈:超时阈值不要设得太紧。我曾经把插补任务的超时设为1ms,结果偶尔的调度抖动就触发了误报警。后来改成2ms,配合连续3次超时才判故障,既保证了灵敏度,又避免了误报。

21.3 安全状态恢复:怎么「优雅地死」

检测到故障之后,下一步就是怎么恢复。这里有个原则:宁可停机,不可乱动

运动控制的安全状态恢复,我一般分三个等级:

等级 触发条件 恢复动作
一级(警告) 单次超时、通信偶发错误 记录日志,继续运行,降低速度
二级(严重) 连续超时、位置偏差超限 急停所有轴,保持当前位置,等待人工干预
三级(致命) 看门狗复位、硬件故障 切断动力电源,保存故障现场,自动重启

二级恢复是我最常用的。具体做法是:

void Safety_EnterSafeState(const char* fault_task)
{
    // 1. 立即停止所有运动指令
    Motion_StopAllAxes();
    
    // 2. 记录故障现场
    FaultRecord_t record;
    record.timestamp = HAL_GetTick();
    record.fault_task = fault_task;
    record.axis_positions[0] = Motion_GetActualPosition(0);
    record.axis_positions[1] = Motion_GetActualPosition(1);
    // ... 记录所有轴的位置
    
    FaultLog_Save(&record);
    
    // 3. 通知上位机
    Communication_SendFault(&record);
    
    // 4. 保持伺服使能,但力矩归零
    //    这样电机可以自由转动,但不会突然抱死
    for (int i = 0; i < AXIS_COUNT; i++)
    {
        Servo_SetTorque(i, 0);
        Servo_KeepEnabled(i);  // 保持使能,维持位置环
    }
    
    // 5. 点亮故障指示灯
    GPIO_WritePin(FAULT_LED_PIN, GPIO_PIN_SET);
    
    // 6. 挂起所有非关键任务
    vTaskSuspend(g_task_heartbeats[2].task_handle);  // 通信任务
    vTaskSuspend(g_task_heartbeats[3].task_handle);  // HMI任务
    
    // 7. 进入安全循环,等待复位
    while(1)
    {
        // 只保留监控任务和心跳检测
        vTaskDelay(pdMS_TO_TICKS(100));
        
        // 检查是否收到复位指令
        if (Safety_CheckResetCommand())
        {
            Safety_SystemReset();
        }
    }
}

重要提醒:安全状态下,千万不要直接切断伺服使能。我见过有人急停时直接关使能,结果垂直轴直接掉下来,砸坏了工作台。正确的做法是:先让力矩归零,再保持位置环,最后才考虑是否断电。

21.4 看门狗与任务监控的协同设计

这两个机制不是孤立的。我习惯把它们设计成一个分层防御体系

运动控制系统分层安全防御体系 第一层:应用层安全(任务级) 任务心跳检测 → 超时报警 → 降速运行 → 任务重启 第二层:系统层安全(RTOS级) 空闲任务喂狗 → 任务挂起 → 安全状态进入 → 故障记录 第三层:硬件层安全(芯片级) 独立看门狗(IWDG) → 硬件复位 → 系统重启 → 故障恢复 设计原则:上层失效由下层兜底,每层都有独立的故障检测和恢复机制

这个分层设计的好处是:每一层只管自己的事。应用层只管任务是否超时,系统层只管看门狗是否溢出,硬件层只管芯片是否复位。上层解决不了的问题,自然由下层兜底。

举个例子:插补任务因为一个bug陷入死循环,应用层的心跳检测首先发现超时,尝试重启任务。如果重启失败,系统层的空闲任务得不到执行,看门狗超时触发硬件复位。整个过程不需要人为干预,系统自己就能恢复。

一个小技巧:我在每个任务的循环开头都会加一个「喂狗检查点」。如果任务检测到看门狗即将超时,会主动放弃当前计算,先喂狗再继续。这样既保证了实时性,又不会因为长时间计算导致看门狗误触发。

21.5 实际项目中的避坑指南

做了这么多年运动控制,我总结了几条血泪教训:

  • 看门狗超时时间要留余量。我曾经把看门狗设为500ms,结果有一次系统启动时初始化外设花了600ms,还没进主循环就被复位了。后来我改成2秒,等系统稳定后再缩短到1秒。
  • 故障恢复后要检查机械位置。看门狗复位后,电机的位置可能已经丢失。我习惯在启动时先让所有轴回零,确认位置无误后再进入工作模式。
  • 不要依赖单一的安全机制。光有看门狗不够,光有心跳检测也不够。两者配合使用,才能覆盖大多数故障场景。
  • 故障日志要保存到非易失存储器。看门狗复位后,RAM里的数据全丢了。如果不把故障信息存到Flash或EEPROM,你永远不知道系统为什么死机。

嗯,安全这块就聊这么多。说白了,运动控制系统的安全设计,就是一场「与不确定性共舞」的游戏。你永远不知道下一秒哪个任务会卡死,哪个中断会丢失。但只要你把防御体系搭好了,系统就能在绝大多数情况下自己站起来,继续干活。

记住一句话:好的安全设计,是让用户感觉不到安全设计的存在。