FreeRTOS 指南(二):软件定时器、事件标志组与任务通知

已经掌握基础的同学,可以通过本篇文章,深入的了解 FreeRTOS 实时操作系统的软件定时器、事件标志组与任务通知部分,完成对于 FreeRTOS 的进阶。相信阅读这篇文章一定会对你很有帮助,希望点个赞哦

前言:超越基础的利器

在上一篇指南中,我们已经牢固掌握了 FreeRTOS 的核心——任务、队列、信号量和互斥锁。这些是构建多任务系统的基石。然而,在真实的、更复杂的应用场景中,我们常常会遇到一些棘手的问题:

  • 如何周期性地执行一个非紧急的任务,而又不想为此单独创建一个高开销的硬件定时器或一个常驻内存的任务?
  • 一个任务需要等待多个事件中的任意一个或全部发生后才能继续执行,如何优雅地实现这种复杂的同步逻辑?
  • 任务间的通信和同步,除了队列和信号量,还有没有更轻量、更快速的方式?

为了解决这些问题,FreeRTOS 提供了三个高级功能:软件定时器 (Software Timers)事件标志组 (Event Groups)任务通知 (Task Notifications)。它们是 FreeRTOS 工具箱中的瑞士军刀,为开发者提供了更高效、更灵活的编程范式。


第一章:软件定时器

在很多应用中,我们需要执行一些周期性或一次性的延时任务,例如:每隔 20ms 检测一次按键状态,超时 5 秒后关闭屏幕背光,或者在设备启动 1 分钟后执行一次自检。

最直接的想法是使用硬件定时器。但微控制器的硬件定时器资源是有限且宝贵的,通常需要留给对时间精度要求极高的任务(如 PWM 输出、电机控制等)。另一个想法是创建一个专门的任务,在循环中使用 vTaskDelay() 来实现定时。但这会为每个定时需求都创建一个独立的任务和其完整的上下文堆栈,当定时需求很多时,会造成极大的内存浪费。

软件定时器正是为了解决这一痛点而设计的。它允许你创建大量的、对时间精度要求不那么苛刻的定时器,而所有这些定时器都由一个统一的、低优先级的定时器服务任务 (Timer Service Task) 来管理。

1.1 工作原理
  1. 定时器服务任务 (Timer Daemon Task): 当你在 FreeRTOSConfig.h 中启用软件定时器 (configUSE_TIMERS 设置为 1) 时,FreeRTOS 在启动调度器时会自动创建一个名为 Tmr Svc 的任务。这个任务的优先级由 configTIMER_TASK_PRIORITY 决定,通常建议设置为一个相对较高的优先级,以保证定时器的准时性。
  2. 定时器命令队列 (Timer Command Queue): FreeRTOS 还会创建一个专用的队列,称为定时器命令队列。所有对软件定时器的操作,如创建、启动、停止、复位等,都不是立即执行的,而是将一个对应的命令和参数打包,发送到这个命令队列中。
  3. 统一处理: 定时器服务任务是唯一一个从该队列中读取命令并执行的任务。它在其主循环中等待命令。收到命令后,它会执行相应的操作,比如将一个定时器添加到活动定时器列表中,或者从列表中移除一个定时器。
  4. 滴答中断驱动: 软件定时器的计时基础是系统的 Tick 滴答中断。每个 Tick 中断发生时,定时器服务任务会检查所有活动定时器的超时时间是否到达。如果某个定时器超时,它就会调用该定时器预先设定的回调函数 (Callback Function)

核心优势:

  • 资源高效: 无需为每个定时器创建单独的任务,极大地节省了 RAM。
  • 使用简单: API 接口直观,易于使用。
  • 可扩展性强: 理论上可以创建任意数量的软件定时器,仅受内存限制。

重要提示: 由于所有回调函数都是在定时器服务任务的上下文中执行的,因此它们必须遵循以下原则:

  • 执行时间必须短: 不能在回调函数中执行耗时操作或任何可能导致阻塞的 API 调用(如带较长等待时间的 xQueueReceivevTaskDelay)。否则,会阻塞整个定时器服务任务,导致其他所有软件定时器都无法准时触发。
  • 共享定时器堆栈: 所有回调函数共享定时器服务任务的堆栈。该堆栈大小由 configTIMER_TASK_STACK_DEPTH 定义,必须确保足够大以容纳所有可能的回调函数调用链。
1.2 API 函数详解
  • 创建定时器: xTimerCreate()

    C

    TimerHandle_t xTimerCreate(
        const char * const pcTimerName,     // 定时器名称
        const TickType_t xTimerPeriodInTicks, // 定时器周期 (单位: Ticks)
        const UBaseType_t uxAutoReload,     // 是否自动重装载 (pdTRUE / pdFALSE)
        void * const pvTimerID,             // 定时器 ID
        TimerCallbackFunction_t pxCallbackFunction // 回调函数指针
    );
    
    • pcTimerName: 用于调试的名称。
    • xTimerPeriodInTicks: 定时器的周期。pdMS_TO_TICKS() 宏在这里非常有用。
    • uxAutoReload:
      • pdTRUE: 创建一个周期定时器 (Auto-reload Timer)。每次超时后,它会自动重新开始计时。
      • pdFALSE: 创建一个单次定时器 (One-shot Timer)。超时后即进入休眠状态,需要手动重新启动。
    • pvTimerID: 一个 void 指针,可以赋给它任何值(如一个整数 ID,或一个指向某个结构体的指针)。这个 ID 会作为参数传递给回调函数,用于在同一个回调函数中区分不同的定时器实例。
    • pxCallbackFunction: 指向回调函数的指针。回调函数原型为 void vCallbackFunction( TimerHandle_t xTimer )
    • 返回值: 成功则返回定时器句柄,失败(通常是堆内存不足)则返回 NULL
  • 启动定时器: xTimerStart()

    C

    BaseType_t xTimerStart(TimerHandle_t xTimer, TickType_t xTicksToWait);
    

    将一个定时器置于活动状态。xTicksToWait 是指如果定时器命令队列已满,该函数愿意等待的时间。

  • 停止定时器: xTimerStop()

    C

    BaseType_t xTimerStop(TimerHandle_t xTimer, TickType_t xTicksToWait);
    

    将一个活动定时器置于休眠状态。

  • 复位定时器: xTimerReset()

    C

    BaseType_t xTimerReset(TimerHandle_t xTimer, TickType_t xTicksToWait);
    

    重新开始计时。对于活动定时器,它会重新计算超时时间;对于休眠定时器,它会激活定时器并开始计时。等效于 xTimerStop() + xTimerStart() 的组合,但更高效。

  • 修改周期: xTimerChangePeriod()

    C

    BaseType_t xTimerChangePeriod(
        TimerHandle_t xTimer,
        TickType_t xNewPeriod,
        TickType_t xTicksToWait
    );
    

    在运行时改变一个定时器的周期。

  • ISR 安全版本: 同样存在 xTimerStartFromISR(), xTimerStopFromISR(), xTimerResetFromISR(), xTimerChangePeriodFromISR() 等版本,用于在中断服务程序中操作定时器。

1.3 应用实例:按键消抖与长按检测

这是一个非常经典的软件定时器应用场景。我们将使用一个单次定时器来实现按键的软件消抖,并用另一个单次定时器来检测长按事件。

C

#include "FreeRTOS.h"
#include "timers.h"

// 假设按键连接到某个 GPIO,并已配置为下降沿触发中断

TimerHandle_t debounce_timer; // 消抖定时器
TimerHandle_t long_press_timer; // 长按定时器

// 定时器回调函数
void timer_callback(TimerHandle_t xTimer)
{
    // 读取按键引脚的当前状态
    if (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) == GPIO_PIN_RESET) { // 按键仍然被按下
        if (xTimer == debounce_timer) {
            // 消抖定时器超时,确认是有效单击
            printf("Event: Short Press Detected!\n");

            // 按下后,启动长按检测定时器
            xTimerStart(long_press_timer, 0);

        } else if (xTimer == long_press_timer) {
            // 长按定时器超时
            printf("Event: Long Press Detected!\n");
        }
    }
}


// 按键中断服务程序
void EXTI15_10_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(B1_Pin); // 清除中断标志

    // 在中断中,我们只做一件事:复位(启动)消抖定时器
    // 不要在这里做任何 GPIO 读取或复杂逻辑
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xTimerResetFromISR(debounce_timer, &xHigherPriorityTaskWoken);

    // 请求上下文切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 主函数中的初始化部分
void setup_timers_and_tasks(void)
{
    // 创建一个 50ms 的单次定时器用于消抖
    debounce_timer = xTimerCreate("DebounceTimer", pdMS_TO_TICKS(50), pdFALSE, (void *)0, timer_callback);

    // 创建一个 2000ms 的单次定时器用于长按检测
    long_press_timer = xTimerCreate("LongPressTimer", pdMS_TO_TICKS(2000), pdFALSE, (void *)1, timer_callback);

    // ... 其他任务创建 ...
}

// 按键抬起的中断(如果需要)
// 在抬起中断中,我们需要停止长按定时器,以防止误判
void GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == B1_Pin) {
        // 这是一个伪代码示例,实际应通过 GPIO 状态判断是按下还是抬起
        // 如果是抬起事件:
        if (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) == GPIO_PIN_SET) {
             xTimerStop(long_press_timer, 0);
             printf("Event: Key Released.\n");
        }
    }
}

执行流程分析:

  1. 按下瞬间: 按键按下,触发下降沿中断 EXTI15_10_IRQHandler。由于机械抖动,这个中断可能会在短时间内被触发多次。
  2. 消抖处理: 每次中断,我们都调用 xTimerResetFromISR() 来复位 debounce_timer。这意味着只有在最后一次抖动结束、中断稳定后,这个 50ms 的定时器才能完整地计时。
  3. 单击确认: 50ms 后,debounce_timer 超时,timer_callback 被调用。在回调函数中,我们再次检查按键引脚。如果此时它仍然是按下的状态,我们就确认这是一次有效的按下事件(即“单击”),并打印信息。
  4. 长按检测启动: 在确认单击后,我们立即启动 long_press_timer,它设定了 2 秒的超时。
  5. 长按事件: 如果用户持续按住按键 2 秒,long_press_timer 就会超时,再次调用 timer_callback。此时,我们可以通过判断传入的句柄 xTimer 来确定是长按定时器触发了,从而执行长按对应的操作。
  6. 提前释放: 如果用户在 2 秒内释放了按键,按键抬起事件(例如,通过轮询或上升沿中断检测)会调用 xTimerStop() 来停止 long_press_timer,这样就不会错误地触发长按事件了。

这个例子完美地展示了如何用软件定时器以极低的系统开销实现复杂的时序逻辑。


第二章:事件标志组

在复杂的系统中,一个任务的执行往往需要依赖多个条件。例如,一个数据上传任务,可能需要同时满足“Wi-Fi 连接成功”和“传感器数据准备就绪”这两个条件后才能开始上传。或者,它可能只需要等待“用户按下上传按钮”或“接收到远程服务器命令”这两个事件中的任意一个即可。

使用信号量或队列来实现这种“多对一”的同步关系会非常复杂和笨拙。你需要为每个事件创建一个信号量,然后在任务中嵌套地等待它们,代码会变得难以理解和维护。

事件标志组就是为了解决这类问题而生的。它是一个集合,包含了多个(在 32 位架构下最多 24 个,因为高 8 位保留)二进制的标志位(Event Bit)。任务可以等待这个组中的一个或多个标志位被置位。

2.1 工作原理
  • 一个 32 位变量: 每个事件标志组本质上是一个 32 位的无符号整型变量。每一位(bit 0 ~ bit 23)都可以作为一个独立的“事件标志”。
  • 置位 (Set): 其他任务或中断服务程序可以将组中的一个或多个标志位置为 1,表示某个事件已经发生。
  • 等待 (Wait): 一个或多个任务可以阻塞地等待组中的标志位出现它们感兴趣的组合。等待的条件可以非常灵活:
    • 等待指定的所有标志位都被置位 (AND 逻辑)。
    • 等待指定的任意一个标志位被置位 (OR 逻辑)。
    • 可以选择在等待成功后,是否自动清除被置位的标志位。
2.2 API 函数详解
  • 创建事件标志组: xEventGroupCreate()

    C

    EventGroupHandle_t xEventGroupCreate( void );
    

    非常简单,无需参数。成功则返回句柄,失败(堆内存不足)则返回 NULL

  • 置位标志位: xEventGroupSetBits()

    C

    EventBits_t xEventGroupSetBits(
        EventGroupHandle_t xEventGroup,
        const EventBits_t uxBitsToSet
    );
    
    • uxBitsToSet: 一个位掩码,指定要置 1 的标志位。例如,要置位 bit 0 和 bit 2,此参数应为 ( 1 << 0 ) | ( 1 << 2 )
    • 这是一个“或”操作,它不会影响其它位。
    • 可以在中断中调用其安全版本 xEventGroupSetBitsFromISR()
  • 等待标志位: xEventGroupWaitBits()

    C

    EventBits_t xEventGroupWaitBits(
        EventGroupHandle_t xEventGroup,
        const EventBits_t uxBitsToWaitFor,
        const BaseType_t xClearOnExit,
        const BaseType_t xWaitForAllBits,
        TickType_t xTicksToWait
    );
    

    这是事件标志组最核心、最强大的函数。

    • uxBitsToWaitFor: 一个位掩码,指定任务感兴趣的标志位。
    • xClearOnExit:
      • pdTRUE: 如果等待成功,在函数返回前,会将 uxBitsToWaitFor 中指定的那些位置 0。这是一个原子操作,非常适合处理一次性事件。
      • pdFALSE: 等待成功后,不改变标志位的状态。适合用于表示持续的状态(如“Wi-Fi已连接”)。
    • xWaitForAllBits:
      • pdTRUE: AND 逻辑。任务将一直阻塞,直到 uxBitsToWaitFor 中指定的所有位都被置位。
      • pdFALSE: OR 逻辑。只要 uxBitsToWaitFor 中指定的任意一个位被置位,任务就会解除阻塞。
    • xTicksToWait: 最长等待时间。
    • 返回值: 函数返回时事件标志组的当前值。如果超时,返回值中对应的位将不会被置位,可以通过检查返回值来判断等待是否成功。
  • 清除标志位: xEventGroupClearBits()

    C

    EventBits_t xEventGroupClearBits(
        EventGroupHandle_t xEventGroup,
        const EventBits_t uxBitsToClear
    );
    

    手动将指定的标志位置 0

2.3 应用实例:多源数据同步与处理

假设我们有一个物联网设备,它需要从两个不同的传感器(例如温度和湿度)收集数据,并且还要等待 Wi-Fi 连接成功后,才能将这两份数据打包上传到云端。

C

#include "FreeRTOS.h"
#include "event_groups.h"

// 定义事件标志位
#define WIFI_CONNECTED_BIT  (1 << 0)
#define TEMP_DATA_READY_BIT (1 << 1)
#define HUMID_DATA_READY_BIT (1 << 2)

// 定义事件标志组句柄
EventGroupHandle_t sensor_event_group;

// Wi-Fi 连接任务
void wifi_connect_task(void *pvParameters)
{
    while(1) {
        // ... 模拟尝试连接 Wi-Fi 的过程 ...
        // if (wifi_is_connected())
        {
            printf("WiFi Task: WiFi Connected!\n");
            // 连接成功,置位 WIFI_CONNECTED_BIT
            xEventGroupSetBits(sensor_event_group, WIFI_CONNECTED_BIT);
            // 此任务可以挂起自身或转为低频心跳检测
            vTaskSuspend(NULL);
        }
        // vTaskDelay(pdMS_TO_TICKS(5000)); // 连接失败则 5 秒后重试
    }
}

// 温度传感器读取任务
void temperature_task(void *pvParameters)
{
    while(1) {
        // ... 模拟读取温度传感器 ...
        printf("Temp Task: Temperature data is ready.\n");
        // 数据就绪,置位 TEMP_DATA_READY_BIT
        xEventGroupSetBits(sensor_event_group, TEMP_DATA_READY_BIT);
        vTaskDelay(pdMS_TO_TICKS(5000)); // 每 5 秒读取一次
    }
}

// 湿度传感器读取任务
void humidity_task(void *pvParameters)
{
    while(1) {
        // ... 模拟读取湿度传感器 ...
        printf("Humid Task: Humidity data is ready.\n");
        // 数据就绪,置位 HUMID_DATA_READY_BIT
        xEventGroupSetBits(sensor_event_group, HUMID_DATA_READY_BIT);
        vTaskDelay(pdMS_TO_TICKS(7000)); // 每 7 秒读取一次
    }
}

// 数据上传任务
void data_upload_task(void *pvParameters)
{
    const EventBits_t BITS_TO_WAIT_FOR = (WIFI_CONNECTED_BIT | TEMP_DATA_READY_BIT | HUMID_DATA_READY_BIT);

    while(1)
    {
        printf("Upload Task: Waiting for WiFi, Temp, and Humid data...\n");

        EventBits_t bits = xEventGroupWaitBits(
            sensor_event_group,     // 事件标志组句柄
            BITS_TO_WAIT_FOR,       // 等待所有三个标志位
            pdTRUE,                 // 成功后清除这些位 (Temp和Humid位)
            pdTRUE,                 // 必须所有位都满足 (AND)
            portMAX_DELAY           // 无限期等待
        );

        if ((bits & BITS_TO_WAIT_FOR) == BITS_TO_WAIT_FOR) {
            // 所有条件都满足
            printf("Upload Task: All conditions met! Uploading data...\n");
            // ... 执行数据上传操作 ...

            // 注意:因为上面 xClearOnExit 是 pdTRUE,TEMP 和 HUMID 位已被清除
            // 但 WIFI 连接状态是持续的,我们需要手动把它重新置位
            // 更好的做法是将 WIFI 状态和其他一次性事件分开处理
            // 这里为了简化,我们假设上传后可以重新等待所有事件
        } else {
            // 如果设置了超时,这里可以处理超时情况
            printf("Upload Task: Wait timed out.\n");
        }
    }
}

// 主函数中的初始化
void setup_event_groups_and_tasks(void)
{
    // 创建事件标志组
    sensor_event_group = xEventGroupCreate();

    if (sensor_event_group != NULL) {
        // 创建所有任务
        xTaskCreate(wifi_connect_task, "WiFi", 256, NULL, 2, NULL);
        xTaskCreate(temperature_task, "Temp", 256, NULL, 1, NULL);
        xTaskCreate(humidity_task, "Humid", 256, NULL, 1, NULL);
        xTaskCreate(data_upload_task, "Upload", 256, NULL, 3, NULL); // 上传任务优先级最高
    }
}

执行流程分析:

  1. data_upload_task 启动后,立即调用 xEventGroupWaitBits 进入阻塞状态,它在等待三个标志位同时被置位。
  2. wifi_connect_task, temperature_task, humidity_task 三个任务并发执行。
  3. wifi_connect_task 连接成功后,会将 WIFI_CONNECTED_BIT1,然后挂起自己。
  4. temperature_taskhumidity_task 分别以自己的周期读取数据,并在完成后将 TEMP_DATA_READY_BITHUMID_DATA_READY_BIT1
  5. 只有当这三个标志位全部变为 1 时,data_upload_taskxEventGroupWaitBits 条件才满足,它会从阻塞中唤醒。
  6. 由于 xClearOnExit 设置为 pdTRUEdata_upload_task 唤醒后,这三个标志位会自动被清零。这非常方便,使得下一次数据采集和上传的循环可以重新开始。
  7. data_upload_task 执行上传操作,然后再次进入 while 循环,重新等待下一次所有条件满足。

这个例子清晰地展示了事件标志组在协调多个异步事件方面的强大能力,代码逻辑非常清晰直观。


**第三章:任务通知

任务通知是 FreeRTOS V8.2.0 版本之后引入的一个强大特性。它被设计成一种非常轻量级、非常快速的任务间通信和同步机制。很多情况下,它可以替代二进制信号量、计数信号量、队列(传递单个整数时)。

3.1 工作原理:每个任务自带的“信箱”
  • 任务控制块 (TCB) 内置: 与队列、信号量等需要单独分配内存的内核对象不同,任务通知的值和状态是直接存储在任务自己的任务控制块 (TCB) 中的。这意味着使用任务通知完全没有额外的 RAM 开销
  • 一对一通信: 每个任务都有一个 32 位的通知值 (ulNotifiedValue) 和一个通知状态 (ucNotifyState)。任务通知是定向的,发送通知时必须指定接收任务的句柄。一个任务发出的通知只能被一个特定的目标任务接收。这是一种“直接投递”的消息。
  • 速度极快: 因为不涉及对共享内核对象的加锁和解锁操作,也不需要内存拷贝(除了写入一个 32 位值),发送任务通知的速度比操作队列和信号量要快得多。
3.2 API 函数与灵活的用法

任务通知的 API 设计得非常灵活,一个函数 xTaskNotify()xTaskNotifyWait() 就可以模拟多种行为。

  • 发送通知: xTaskNotify()

    C

    BaseType_t xTaskNotify(
        TaskHandle_t xTaskToNotify,
        uint32_t ulValue,
        eNotifyAction eAction
    );
    
    • xTaskToNotify: 目标任务的句柄。
    • ulValue: 一个 32 位的值,其含义取决于 eAction
    • eAction: 指定通知的行为,这是最关键的参数:
      • eSetBits: 按位或 (OR)ulValue 会与目标任务当前的通知值进行“或”运算。TCB.ulNotifiedValue |= ulValue。这可以用来模拟事件标志组。
      • eIncrement: 计数加一。目标任务的通知值加 1,ulValue 参数被忽略。TCB.ulNotifiedValue++。这可以用来模拟计数信号量。
      • eSetValueWithOverwrite: 强制覆盖。无论目标任务是否已经处理了上一个通知,都用 ulValue 强制覆盖其通知值。这是最常用的方式,可以用来通过队列发送一个 32 位整数。
      • eSetValueWithoutOverwrite: 不覆盖写入。只有当目标任务的通知状态是“未通知”时,才用 ulValue 设置其通知值。如果目标任务还有一个待处理的通知,本次发送将失败。这可以用来模拟一个长度为 1 的队列。
    • 同样有 ISR 安全版本 vTaskNotifyGiveFromISR()xTaskNotifyFromISR()vTaskNotifyGiveFromISR 是一个高度优化的 eIncrement 动作的宏,专门用于模拟从 ISR 中释放一个二进制信号量。
  • 等待通知: xTaskNotifyWait()

    C

    BaseType_t xTaskNotifyWait(
        uint32_t ulBitsToClearOnEntry,
        uint32_t ulBitsToClearOnExit,
        uint32_t *pulNotificationValue,
        TickType_t xTicksToWait
    );
    
    • ulBitsToClearOnEntry: 在进入等待状态(阻塞)之前,将任务当前通知值中与该掩码匹配的位清零。通常设置为 0
    • ulBitsToClearOnExit: 在退出等待(唤醒)之后,将任务当前通知值中与该掩码匹配的位清零。通常设置为 0xFFFFFFFF (即 ULONG_MAX),表示接收通知后,将通知值清零,准备接收下一个。
    • pulNotificationValue: 一个 uint32_t 指针,用于接收通知的值。
    • xTicksToWait: 等待超时时间。
3.3 用法模式与对比

任务通知的强大在于它的“多态性”。

  1. 模拟二进制信号量 (最快):
    • 发送方 (ISR): vTaskNotifyGiveFromISR(xTaskHandle, &xHigherPriorityTaskWoken);
    • 接收方: xTaskNotifyWait(0, ULONG_MAX, NULL, portMAX_DELAY);
    • 这比使用真正的二进制信号量快很多,且没有 RAM 开销。是“中断发信号,任务做处理”模式的最佳选择。
  2. 模拟计数信号量:
    • 发送方: xTaskNotify(xTaskHandle, 0, eIncrement);
    • 接收方: xTaskNotifyWait(0, ULONG_MAX, &received_count, portMAX_DELAY);
  3. 模拟队列 (传递 32 位数据):
    • 发送方: xTaskNotify(xTaskHandle, data, eSetValueWithOverwrite);
    • 接收方: xTaskNotifyWait(0, ULONG_MAX, &received_data, portMAX_DELAY);
    • 这比创建一个长度为 1、数据项大小为 4 字节的队列要高效得多。
3.4 应用实例:用任务通知重构 ADC 采集与处理

让我们重构一个经典的场景:一个任务通过中断方式启动 ADC 转换,在 ADC 转换完成中断中通知另一个任务去读取和处理数据。

C

// ADC 处理任务的句柄
TaskHandle_t adc_handler_task_handle;

// ADC 转换完成中断服务程序
void ADC1_2_IRQHandler(void)
{
    // 检查是否是转换完成中断
    if (__HAL_ADC_GET_FLAG(&hadc1, ADC_FLAG_EOC)) {
        BaseType_t xHigherPriorityTaskWoken = pdFALSE;

        // 获取 ADC 转换结果
        uint32_t adc_value = HAL_ADC_GetValue(&hadc1);

        // 使用任务通知,将 ADC 值直接发送给处理任务
        xTaskNotifyFromISR(adc_handler_task_handle, // 目标任务句柄
                           adc_value,             // 要发送的值
                           eSetValueWithOverwrite,  // 覆盖旧值
                           &xHigherPriorityTaskWoken);

        // 请求上下文切换
        portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }
}


// ADC 数据处理任务
void adc_handler_task(void *pvParameters)
{
    uint32_t received_adc_value;
    while(1)
    {
        // 等待 ADC 中断发来的通知
        BaseType_t result = xTaskNotifyWait(0x00,          // 不清除进入位
                                            ULONG_MAX,     // 退出时清除所有位 (清零通知值)
                                            &received_adc_value, // 接收通知值
                                            portMAX_DELAY); // 无限期等待

        if (result == pdPASS) {
            // 成功接收到通知
            printf("ADC Handler: Received value = %lu\n", received_adc_value);
            // ... 在这里对 ADC 值进行处理,例如滤波、计算等 ...
        }
    }
}


// 触发 ADC 转换的任务(或软件定时器)
void adc_trigger_task(void *pvParameters)
{
    while(1)
    {
        // 启动一次 ADC 转换(中断模式)
        HAL_ADC_Start_IT(&hadc1);
        vTaskDelay(pdMS_TO_TICKS(1000)); // 每秒触发一次
    }
}

// 主函数中的初始化
void setup_notification_and_tasks(void)
{
    // 创建 ADC 处理任务,并保存其句柄
    xTaskCreate(adc_handler_task, "ADCHandler", 256, NULL, 3, &adc_handler_task_handle);
    xTaskCreate(adc_trigger_task, "ADCTrigger", 256, NULL, 1, NULL);
}

代码分析与对比:

  • 高效: ADC1_2_IRQHandler 直接将 32 位的 ADC 结果作为通知值发送出去。这个过程非常快,因为它只是向目标任务的 TCB 写入一个整数。
  • 零开销: 我们没有创建任何队列或信号量,节省了宝贵的 RAM。
  • 对比传统方法:
    • 如果用队列: 我们需要 xQueueCreate(1, sizeof(uint32_t)),会消耗队列结构体和数据存储区的内存。在 ISR 中需要调用 xQueueSendFromISR,在任务中调用 xQueueReceive
    • 如果用信号量: 信号量只能同步,不能传递数据。我们不得不在 ISR 中释放信号量,然后在 adc_handler_task 中等待信号量,成功后再去读取一个全局变量来获取 ADC 值。使用全局变量会引入竞态条件,需要额外考虑保护。
  • 结论: 在这个“一对一”、“事件同步 + 单个数据传递”的场景中,任务通知无疑是最优的选择,它集高效、简洁和零资源开销于一身。

如何选择合适的工具

场景描述 推荐工具 为什么?
需要周期性或一次性延时执行一个简短的操作 软件定时器 资源开销极低,专门为此设计,避免了创建完整任务的浪费。
一个任务需要等待多个事件的复杂组合 (AND/OR) 事件标志组 API 设计直观地解决了多事件同步问题,代码逻辑清晰,可维护性高。
中断需要唤醒一个任务,且不传递数据 任务通知 (模拟二进制信号量) 最快的同步方式,零 RAM 开销,是中断与任务同步的黄金标准。
一个任务需要向另一个特定任务传递一个整数或指针 任务通知 (模拟队列) 比队列快,零 RAM 开销。是轻量级一对一数据传递的首选。
数据需要在多个任务之间传递(一对多,多对一) 队列 任务通知只能一对一,队列提供了更灵活的、解耦的通信模式。
需要传递一个复杂的数据结构或大数据块 队列 队列可以按值拷贝整个结构体,而任务通知只能传递一个 32 位值(通常是指针)。
需要保护共享资源,防止优先级反转 互斥锁 只有互斥锁具备优先级继承机制,是资源保护的唯一正确选择。
需要管理多个相同的资源实例(如N个缓冲区) 计数信号量 概念模型与此场景完美匹配。
Logo

「智能机器人开发者大赛」官方平台,致力于为开发者和参赛选手提供赛事技术指导、行业标准解读及团队实战案例解析;聚焦智能机器人开发全栈技术闭环,助力开发者攻克技术瓶颈,促进软硬件集成、场景应用及商业化落地的深度研讨。 加入智能机器人开发者社区iRobot Developer,与全球极客并肩突破技术边界,定义机器人开发的未来范式!

更多推荐