3. 任务管理与调度:周期性任务、事件驱动任务、任务优先级分配策略与死锁预防

各位同学,今天我们聊一个嵌入式系统里最核心、也最容易被忽视的话题——任务调度。

说实话,我见过太多EEC项目翻车,最后查下来,不是算法不对,也不是硬件选型失误,而是任务调度出了问题。任务跑飞了、优先级反转了、死锁了……这些坑,我年轻时都踩过。今天咱们就把这块硬骨头啃下来。

3.1 周期性任务:心脏的节拍

EEC控制器里,周期性任务是最常见的。说白了,就是每隔固定时间,必须执行一次的任务。

举个例子:发动机转速采集。你想想看,转速信号每10ms就得读一次,晚1ms都不行。为什么?因为控制算法依赖这个数据做闭环。数据一滞后,控制输出就乱套了。

我个人习惯,把周期性任务分成三类:

  • 硬实时周期任务:比如点火角度控制,错过一个周期,发动机可能爆震。这类任务必须严格按时执行。
  • 软实时周期任务:比如温度采集,偶尔延迟几十毫秒,系统还能忍,但不能频繁丢数据。
  • 非实时周期任务:比如日志记录、自检统计,晚几秒问题不大。

在代码里,我通常这样定义周期性任务:

// 周期性任务结构体
typedef struct {
    uint32_t period_ms;      // 周期,单位毫秒
    uint32_t last_run_tick;  // 上次执行的时间戳
    void (*task_func)(void); // 任务函数指针
    uint8_t priority;        // 优先级
} PeriodicTask_t;

// 示例:转速采集任务,10ms周期
PeriodicTask_t rpm_task = {
    .period_ms = 10,
    .last_run_tick = 0,
    .task_func = RPM_Acquire,
    .priority = 5
};

这里有个细节:last_run_tick 一定要用硬件定时器的时间戳,别用软件延时。我在项目中遇到过,用软件延时做周期,结果中断一多,时间全偏了。嗯,血的教训。

3.2 事件驱动任务:随叫随到的响应

周期性任务像心跳,事件驱动任务就像警报——平时不干活,一旦触发,立刻响应。

EEC里典型的事件驱动任务有哪些?

  • 故障中断:比如传感器短路,必须立即进入故障保护模式。
  • CAN报文到达:收到新的控制指令,需要解析并更新控制参数。
  • 看门狗超时:系统快挂了,得赶紧复位。

事件驱动任务的设计要点,我个人总结就两条:

  1. 事件处理要快:中断服务程序里只做标记,具体处理放到任务里。我曾经见过有人直接在中断里做浮点运算,结果系统直接崩了。
  2. 事件优先级要明确:故障事件必须最高优先级,日志事件可以排最后。

代码实现上,我习惯用事件标志组:

// 事件标志定义
#define EVENT_FAULT      (1 << 0)
#define EVENT_CAN_MSG    (1 << 1)
#define EVENT_WDT_RESET  (1 << 2)

// 事件处理任务
void Event_Handler_Task(void) {
    uint32_t events = Get_Event_Flags();
    
    if (events & EVENT_FAULT) {
        Enter_Fault_Protection();  // 立即进入故障保护
    }
    if (events & EVENT_CAN_MSG) {
        Parse_CAN_Message();       // 解析CAN报文
    }
    if (events & EVENT_WDT_RESET) {
        System_Reset();            // 系统复位
    }
}

你想想看,如果故障事件和CAN报文事件同时发生,先处理哪个?当然是故障。这就是优先级的意义。

3.3 任务优先级分配策略:别让高优先级饿死低优先级

优先级分配,说白了就是给每个任务排个队。谁先跑,谁后跑,得有个规矩。

我常用的策略是速率单调调度(RMS)。简单说:周期越短的任务,优先级越高。为什么?因为周期短的任务对时间更敏感,跑慢了容易出问题。

举个例子,EEC里的典型任务优先级分配:

任务名称 周期/触发条件 优先级 说明
点火控制 1ms 周期 最高(0) 硬实时,错过周期发动机抖动
转速采集 10ms 周期 高(1) 控制闭环的关键输入
故障处理 事件驱动 高(2) 安全相关,必须快速响应
CAN通信 20ms 周期 中(3) 数据交换,允许少量延迟
温度采集 100ms 周期 低(4) 变化慢,优先级最低
日志记录 1s 周期 最低(5) 非实时,有空再跑

这里有个坑:优先级反转。什么意思?就是低优先级任务占着资源不放,高优先级任务只能干等。

我曾经在一个项目中,CAN通信任务(优先级3)占着共享缓冲区,结果点火控制任务(优先级0)想写数据,死活写不进去。最后发动机转速直接失控。嗯,从那以后,我所有共享资源都加了优先级继承协议。

核心原则:优先级分配不是越高越好,而是越合理越好。高优先级任务太多,低优先级任务可能永远得不到CPU时间,这叫「饿死」。

3.4 死锁预防:别让任务互相掐架

死锁,是嵌入式系统里最头疼的问题之一。两个任务互相等对方释放资源,结果谁都不让,系统直接卡死。

死锁发生的四个必要条件,大家应该都听过:

  1. 互斥:资源一次只能被一个任务占用。
  2. 持有并等待:任务拿着一个资源,还等另一个资源。
  3. 不可剥夺:资源不能被强制拿走。
  4. 循环等待:任务A等任务B的资源,任务B等任务A的资源。

怎么预防?我分享几个实战经验:

  • 统一资源获取顺序:所有任务按相同顺序申请资源。比如先拿信号量A,再拿信号量B。这样就不会出现循环等待。
  • 使用超时机制:申请资源时设置超时时间。拿不到就放弃,别死等。
  • 尽量少用互斥锁:能用消息队列传递数据,就别用共享内存加锁。

代码示例:带超时的资源申请

// 尝试获取信号量,超时100ms
if (xSemaphoreTake(semaphore, pdMS_TO_TICKS(100)) == pdTRUE) {
    // 成功获取资源,执行操作
    Process_Data();
    // 释放资源
    xSemaphoreGive(semaphore);
} else {
    // 超时未获取,记录错误并重试
    Log_Error("Semaphore timeout");
    Retry_Later();
}

避坑指南:我曾经在一个项目中,两个任务都用了死循环等待资源。结果系统一启动就死锁,连调试器都连不上。后来我强制要求所有资源申请必须带超时,再也没出过类似问题。

3.5 实战总结:调度器的选择

说了这么多,最后聊聊调度器的选择。EEC控制器里,我推荐两种方案:

  • 裸机+定时器轮询:适合任务少、逻辑简单的系统。优点是代码透明,缺点是可扩展性差。
  • RTOS(实时操作系统):比如FreeRTOS、uC/OS-III。适合任务多、优先级复杂的系统。我目前用的就是FreeRTOS,配合优先级继承和互斥信号量,基本没出过调度问题。

你想想看,一个EEC控制器里可能有十几个任务,如果全靠裸机轮询,代码维护起来得多痛苦?所以,我个人建议:只要任务超过5个,果断上RTOS。

好了,这一章的内容就到这里。任务调度是EEC软件的骨架,骨架搭不好,肌肉再强壮也没用。下一章我们聊聊内存管理,那又是另一个大坑。

一句话总结:周期性任务定节拍,事件驱动任务快响应,优先级分配要合理,死锁预防靠规范。