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

各位同学,咱们今天聊点硬核的。任务调度与管理,说白了就是让芯片里的多个任务「有序地打架」。我做了这么多年嵌入式,见过太多系统因为优先级设计不合理,直接死机或者跑飞。嗯,这节课咱们就把这块彻底讲透。

4.1 任务优先级设计——别让高优先级任务「饿死」低优先级

任务优先级,是RTOS调度器的核心。我个人习惯把优先级分成三档:

  • 紧急响应型(最高优先级):比如按键中断、通信接收。这类任务必须立刻响应,延迟几毫秒可能丢数据。
  • 周期执行型(中等优先级):比如数据采集、显示刷新。可以容忍几十毫秒的抖动。
  • 后台处理型(最低优先级):比如日志存储、统计计算。有空就跑,没空就等着。

我在项目中遇到过一个问题:一个电表里同时有脉冲采集(高优先级)和LCD刷新(低优先级)。结果脉冲任务一直占用CPU,LCD刷新任务永远得不到执行。你想想看,屏幕卡死,用户肯定投诉。

避坑指南:我曾经在一个智能水表项目里,把通信任务优先级设得比计量任务还高。结果通信频繁时,计量数据丢失,导致计费错误。后来我强制规定:计量类任务优先级必须高于通信类,因为数据丢了就是钱的问题。

优先级设计还有一个原则:不要超过系统支持的最大优先级数。比如FreeRTOS默认支持32级,你硬要设成64级,调度器会直接崩溃。嗯,这里要注意,很多新手会犯这个错。

4.2 任务间通信——队列、信号量、事件组

任务之间怎么传数据?直接访问全局变量?别闹,那会引发资源竞争。RTOS提供了三种标准机制:

4.2.1 队列(Queue)——最常用的数据管道

队列就像一根水管,一个任务往里灌水,另一个任务从另一头接水。数据是拷贝传递的,安全可靠。

// 创建队列,每个元素4字节,队列深度10
QueueHandle_t xQueue = xQueueCreate(10, sizeof(uint32_t));

// 发送任务
uint32_t data = 100;
xQueueSend(xQueue, &data, portMAX_DELAY);

// 接收任务
uint32_t received;
xQueueReceive(xQueue, &received, portMAX_DELAY);

我个人习惯用队列传递结构体指针,而不是直接拷贝大数据块。比如电表里传递一个「计量数据包」,包含电压、电流、功率,用指针传递效率更高。

小技巧:队列的等待时间别设成portMAX_DELAY,除非你确定对方一定会发数据。否则一旦发送任务挂了,接收任务就永远卡死。我一般设成100ms超时,超时后打印错误日志。

4.2.2 信号量(Semaphore)——轻量级同步工具

信号量分两种:二值信号量和计数信号量。二值信号量就像一把钥匙,谁拿到谁干活。计数信号量就像停车场空位,每来一辆车减一,每走一辆车加一。

我在电表项目里常用信号量做「事件通知」。比如:

  • 计量任务完成一次采集后,释放信号量
  • 通信任务等待信号量,收到后立即发送数据

这样通信任务不用轮询,省电又高效。

避坑指南:我曾经在中断服务函数里调用xSemaphoreGiveFromISR(),但忘了检查返回值。结果信号量队列满了,中断直接丢失。后来我强制要求:中断中释放信号量必须检查返回值,如果失败就记录错误

4.2.3 事件组(Event Group)——多条件同步利器

事件组可以同时等待多个事件。比如电表需要「电压稳定」和「电流正常」两个条件都满足,才启动计量。用事件组就非常方便:

EventGroupHandle_t xEventGroup = xEventGroupCreate();

// 任务A:检测电压稳定后,设置bit0
xEventGroupSetBits(xEventGroup, 0x01);

// 任务B:检测电流正常后,设置bit1
xEventGroupSetBits(xEventGroup, 0x02);

// 计量任务:等待两个条件都满足
EventBits_t uxBits = xEventGroupWaitBits(
    xEventGroup,       // 事件组句柄
    0x03,              // 等待bit0和bit1
    pdTRUE,            // 退出时清除这些位
    pdTRUE,            // 等待所有位都置位
    portMAX_DELAY);    // 超时时间

你想想看,如果用信号量实现,得搞两个信号量,还得加一个任务去轮询。事件组一行代码搞定,多清爽。

4.3 看门狗与任务监控——系统最后的防线

看门狗(Watchdog)是嵌入式系统的「最后一道防线」。一旦系统卡死,看门狗会强制复位。但问题来了:如果某个任务卡死了,但看门狗还在被其他任务喂,系统就永远不会复位。

所以,我建议用任务级看门狗,而不是简单的硬件看门狗。具体做法:

  1. 每个关键任务都有一个「心跳计数器」
  2. 一个监控任务定期检查所有任务的心跳
  3. 如果某个任务超过3个周期没更新心跳,就触发复位
核心思想:看门狗不是用来「喂」的,而是用来「监控」的。每个任务都要证明自己还活着。

我在一个智能电表项目里,用FreeRTOS的软件定时器实现了任务监控:

// 每个任务的心跳变量
volatile uint32_t g_task_heartbeat[TASK_COUNT] = {0};

// 任务主循环中更新心跳
void task_metrology(void *pvParameters) {
    while(1) {
        g_task_heartbeat[TASK_METROLOGY]++;
        // ... 执行计量逻辑 ...
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// 监控任务
void task_watchdog(void *pvParameters) {
    uint32_t last_heartbeat[TASK_COUNT] = {0};
    while(1) {
        for(int i = 0; i < TASK_COUNT; i++) {
            if(g_task_heartbeat[i] == last_heartbeat[i]) {
                // 任务卡死了,触发复位
                printf("Task %d is dead! Reset...\n", i);
                NVIC_SystemReset();
            }
            last_heartbeat[i] = g_task_heartbeat[i];
        }
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}
小技巧:监控任务的优先级要设成最高,确保它不会被其他任务阻塞。另外,看门狗复位后,最好在启动代码里记录复位原因,方便调试。

4.4 实战经验总结

好了,讲了这么多,我总结几条铁律:

场景 推荐做法 常见坑
高优先级任务 只做最紧急的事,处理完就阻塞 高优先级任务里做耗时计算
队列通信 用指针传递大数据,用值传递小数据 队列深度设太小导致丢数据
信号量同步 中断中用FromISR版本 忘记检查返回值
事件组 多条件同步首选 事件位冲突(多个任务用同一位)
看门狗 任务级监控 + 硬件看门狗双重保险 只喂狗不监控任务状态

最后说一句:任务调度没有银弹。每个项目都有自己的特点,你需要根据实际需求调整优先级和通信方式。我做了十几年嵌入式,每次新项目还是会花大量时间在任务设计上。嗯,这步走对了,后面就顺了。

下一章咱们聊聊「内存管理与堆栈优化」,这可是嵌入式开发的另一个大坑。到时候见。