4、任务调度与管理:任务优先级设计、任务间通信(队列/信号量/事件标志组)、看门狗策略

好,咱们接着聊任务调度与管理。这部分内容,说白了就是嵌入式系统的“交通指挥中心”。你想想看,一个电池化成设备里,几十个通道同时工作,有的在充电,有的在放电,有的在采集数据,有的在响应告警。如果调度不好,系统就会乱成一锅粥。

我个人习惯,在设计任务调度之前,先画一张“任务关系图”。把每个任务的功能、触发条件、执行周期、依赖关系都列清楚。这张图,比任何代码都重要。

4.1 任务优先级设计:别让“饿死”和“反转”找上门

优先级设计,是任务调度的核心。我见过不少新手,上来就把所有任务都设成同一个优先级,或者把计算密集型任务设成最高优先级。结果呢?系统要么响应迟钝,要么低优先级任务永远得不到执行。

⚠️ 避坑指南: 我曾经在一个项目中,把通信任务设成了最高优先级。结果通信数据量一大,CPU 全被通信任务占满了,控制任务根本跑不起来。电池电压都过充了,系统还没反应过来。嗯,那次教训很深刻。

我的设计原则是这样的:

  • 实时性要求高的任务,优先级高:比如过压保护、过流保护、温度告警。这些任务必须在毫秒级内响应,否则电池会出问题。
  • 周期性任务,优先级适中:比如电压采集、电流采集、SOC 计算。这些任务有固定的执行周期,优先级可以设在中档。
  • 后台任务,优先级最低:比如日志记录、数据上传、UI 刷新。这些任务可以“有空再做”,优先级最低。

我常用的优先级分配表,大概是这样的:

优先级等级 任务类型 典型任务 响应时间要求
最高(0-1) 紧急保护 过压保护、过流保护、温度告警 < 1ms
高(2-3) 实时控制 PID 调节、PWM 输出、通道切换 < 10ms
中(4-5) 数据采集 电压/电流/温度采集、ADC 读取 < 100ms
低(6-7) 通信与日志 CAN/RS485 通信、SD 卡写入 < 1s

这里要注意一个经典问题:优先级反转。什么意思呢?就是低优先级任务占用了高优先级任务需要的资源,导致高优先级任务被阻塞。我遇到过最典型的情况:一个低优先级的日志任务正在写 SD 卡,占用了互斥锁。此时一个高优先级的保护任务也要写 SD 卡,结果被阻塞了。保护任务迟迟得不到执行,电池就危险了。

💡 我的解决方案: 使用优先级继承协议,或者干脆把互斥锁换成信号量。如果资源访问时间很短,关中断也是可以的。但关中断时间不能太长,否则会影响系统实时性。

4.2 任务间通信:队列、信号量、事件标志组

任务之间怎么“说话”?这是嵌入式系统设计的另一个关键点。我常用的通信方式有三种:队列、信号量、事件标志组。每种方式都有自己的适用场景。

4.2.1 队列:数据传递的“快递员”

队列,说白了就是一个先进先出的缓冲区。一个任务往里放数据,另一个任务往外取数据。我特别喜欢用队列,因为它天然解决了“生产者-消费者”问题。

在电池化成设备里,队列的典型应用场景是:

  • ADC 采集任务 → 数据处理任务:ADC 采集任务把原始电压/电流数据放入队列,数据处理任务从队列中取出数据,进行滤波、校准、计算。
  • 通信接收任务 → 命令解析任务:通信接收任务把收到的 CAN 或 RS485 报文放入队列,命令解析任务从队列中取出报文,解析并执行。

我常用的队列 API 是这样的(以 FreeRTOS 为例):

// 创建队列,长度为 10,每个元素大小为 sizeof(ADC_Data_t)
QueueHandle_t xAdcQueue = xQueueCreate(10, sizeof(ADC_Data_t));

// 发送任务:将采集到的数据放入队列
ADC_Data_t adcData;
adcData.channel = 1;
adcData.voltage = 3.3;
adcData.current = 0.5;
xQueueSend(xAdcQueue, &adcData, portMAX_DELAY);

// 接收任务:从队列中取出数据
ADC_Data_t receivedData;
if (xQueueReceive(xAdcQueue, &receivedData, pdMS_TO_TICKS(100)) == pdPASS) {
    // 处理数据
    ProcessADCData(&receivedData);
}
⚠️ 注意: 队列的长度要合理设置。太短了容易丢数据,太长了浪费内存。我一般根据数据产生的最大速率和消费速率来估算。比如 ADC 采集频率是 1kHz,数据处理频率是 100Hz,那队列长度至少要有 10 个元素。

4.2.2 信号量:资源管理的“红绿灯”

信号量,说白了就是一个计数器。它用来管理共享资源的访问权限。我常用的信号量有两种:

  • 二值信号量:只有 0 和 1 两种状态。用来实现“互斥访问”或“任务同步”。比如,一个任务完成初始化后,释放信号量,另一个任务等待信号量,然后开始工作。
  • 计数信号量:可以计数。用来管理多个相同资源。比如,系统有 4 个 DMA 通道,每个通道用一个信号量表示。任务申请 DMA 时,先获取信号量,用完后释放。

我遇到过的一个典型场景:电池化成设备有多个通道,每个通道的 PID 控制任务都需要访问同一个“电流基准值”变量。如果不加保护,多个任务同时写这个变量,数据就乱了。这时候,用二值信号量做互斥锁,是最简单的办法。

// 创建二值信号量
SemaphoreHandle_t xMutex = xSemaphoreCreateBinary();
xSemaphoreGive(xMutex);  // 初始化为可用状态

// 任务 A:获取信号量,修改基准值
if (xSemaphoreTake(xMutex, pdMS_TO_TICKS(10)) == pdPASS) {
    g_CurrentReference = 5.0;  // 修改全局变量
    xSemaphoreGive(xMutex);    // 释放信号量
}

// 任务 B:获取信号量,读取基准值
if (xSemaphoreTake(xMutex, pdMS_TO_TICKS(10)) == pdPASS) {
    float ref = g_CurrentReference;  // 读取全局变量
    xSemaphoreGive(xMutex);
}
⚠️ 避坑指南: 我曾经犯过一个错误:在中断服务函数里调用 xSemaphoreTake()。结果系统直接死机了。记住,中断里只能调用“FromISR”版本的 API,比如 xSemaphoreGiveFromISR()。

4.2.3 事件标志组:多条件触发的“开关矩阵”

事件标志组,说白了就是一个位图。每个位代表一个事件。任务可以等待多个事件同时发生,或者任意一个事件发生。这种机制特别适合“多条件触发”的场景。

在电池化成设备里,事件标志组的典型应用是:

  • 启动充电流程:需要同时满足“电池已连接”、“通信已建立”、“温度正常”三个条件。每个条件对应一个事件位。当三个位都置 1 时,充电任务才开始工作。
  • 故障处理:过压、过流、过温,任何一个事件发生,都要触发保护动作。这时候用“或”模式等待,任何一个位置 1 就执行保护。
// 定义事件位
#define EVENT_BATTERY_CONNECTED  (1 << 0)
#define EVENT_COMM_ESTABLISHED   (1 << 1)
#define EVENT_TEMP_NORMAL        (1 << 2)

// 创建事件标志组
EventGroupHandle_t xEventGroup = xEventGroupCreate();

// 充电任务:等待所有条件满足
EventBits_t uxBits = xEventGroupWaitBits(
    xEventGroup,
    EVENT_BATTERY_CONNECTED | EVENT_COMM_ESTABLISHED | EVENT_TEMP_NORMAL,
    pdTRUE,  // 退出时自动清除事件位
    pdTRUE,  // 等待所有位都置 1
    portMAX_DELAY
);

if ((uxBits & (EVENT_BATTERY_CONNECTED | EVENT_COMM_ESTABLISHED | EVENT_TEMP_NORMAL)) == 
    (EVENT_BATTERY_CONNECTED | EVENT_COMM_ESTABLISHED | EVENT_TEMP_NORMAL)) {
    // 开始充电
    StartCharging();
}
💡 我的经验: 事件标志组特别适合“状态机”驱动的任务。每个状态对应一个事件位,任务根据事件位的组合来切换状态。这样代码结构清晰,也容易调试。

4.3 看门狗策略:系统的“最后一道防线”

看门狗,说白了就是一个“定时炸弹”。如果系统在规定时间内没有“喂狗”,看门狗就会复位系统。这是防止系统死锁或跑飞的最后一道防线。

我见过很多工程师,把看门狗当成“万能药”。不管什么问题,都靠看门狗复位解决。其实这是不对的。看门狗应该用来处理“预期之外的异常”,而不是“预期之内的错误”。

我的看门狗设计原则是:

  • 独立看门狗(IWDG)用于硬件级保护:如果 CPU 完全死掉,连中断都进不去,独立看门狗还能工作。我一般把独立看门狗的喂狗周期设在 1-2 秒。
  • 窗口看门狗(WWDG)用于软件级保护:窗口看门狗要求必须在特定时间窗口内喂狗。太早或太晚都不行。这可以检测出“任务执行时间异常”的情况。
  • 多级看门狗:在一些高可靠性项目中,我会用两级看门狗。第一级是“任务级看门狗”,每个关键任务都有自己的超时计数器。第二级是“系统级看门狗”,如果任务级看门狗没有及时复位,系统级看门狗就会触发。

我常用的喂狗策略是这样的:

// 主循环中喂狗
void main_loop(void) {
    while(1) {
        // 执行所有任务
        Task_ADC_Collect();
        Task_PID_Control();
        Task_Comm_Process();
        Task_Log_Write();
        
        // 所有任务执行完毕后,喂狗
        HAL_IWDG_Refresh(&hiwdg);
    }
}

// 或者,在空闲任务中喂狗(FreeRTOS)
void vApplicationIdleHook(void) {
    HAL_IWDG_Refresh(&hiwdg);
}
⚠️ 避坑指南: 我曾经在一个项目中,把喂狗放在了中断服务函数里。结果主循环死锁了,但中断还能正常触发,看门狗一直没复位。系统就这么“半死不活”地运行着,直到用户发现设备不响应了。从那以后,我坚持只在主循环或空闲任务中喂狗。

还有一个细节要注意:调试时关闭看门狗。如果你在调试模式下还开着看门狗,单步执行时系统会不断复位,根本没法调试。我一般会在代码里加一个宏定义,调试时自动关闭看门狗。

#ifdef DEBUG_MODE
    // 调试模式,关闭看门狗
    #define DISABLE_WDT()  do { /* 空操作 */ } while(0)
#else
    // 发布模式,开启看门狗
    #define DISABLE_WDT()  HAL_IWDG_Refresh(&hiwdg)
#endif

好了,任务调度与管理这部分,我就讲这么多。总结一下:优先级设计要合理,避免反转;任务间通信要选对工具,队列传数据,信号量管资源,事件标志组做多条件触发;看门狗是最后防线,但别滥用。嗯,这些经验,都是我在项目里一点点踩坑踩出来的。希望对你有帮助。