4、事件标志组置位操作:xEventGroupSetBits()、xEventGroupSetBitsFromISR()、置位原理

好,咱们接着聊事件标志组的核心操作——置位。

说白了,置位就是给某个事件标志位「打个勾」。你想想看,任务A在等某个条件成立,任务B或者中断服务程序把对应的位设成1,任务A就被唤醒了。这个机制在实战中非常常用,我个人几乎每个项目都会用到。

4.1 xEventGroupSetBits():任务级置位

这个函数是我们在任务代码里用的。原型长这样:

EventBits_t xEventGroupSetBits(
    EventGroupHandle_t xEventGroup,
    const EventBits_t uxBitsToSet
);

参数就两个:事件标志组的句柄,以及你要置哪些位。第二个参数是个位掩码,比如你想置位第0位和第3位,就传 0x09(二进制 0000 1001)。

返回值是调用函数那一刻的事件标志组值。嗯,这里要注意,返回值不是设置之后的值,而是设置之前的值。我刚开始用的时候也搞混过。

核心要点:xEventGroupSetBits() 不能在中断服务程序里调用。如果你在ISR里需要置位,请用它的中断版本。

来看个实际例子。我在做一款工业数据采集器时,需要等三个传感器都采集完毕才能做数据融合。代码大概是这样的:

// 定义事件位
#define SENSOR1_READY  (1 << 0)  // 位0
#define SENSOR2_READY  (1 << 1)  // 位1
#define SENSOR3_READY  (1 << 2)  // 位2

// 传感器采集任务
void vSensor1Task(void *pvParameters) {
    for (;;) {
        // 采集传感器1数据
        ReadSensor1();
        // 置位,通知数据已就绪
        xEventGroupSetBits(xDataEventGroup, SENSOR1_READY);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// 类似地,Sensor2和Sensor3任务也各自置位

你看,每个传感器采集完就置自己的位,简单直接。

4.2 xEventGroupSetBitsFromISR():中断级置位

这个函数是给中断用的。原型:

BaseType_t xEventGroupSetBitsFromISR(
    EventGroupHandle_t xEventGroup,
    const EventBits_t uxBitsToSet,
    BaseType_t *pxHigherPriorityTaskWoken
);

多了一个参数 pxHigherPriorityTaskWoken。这个参数很重要,它告诉调用者:有没有更高优先级的任务因为这次置位而被唤醒?如果有,你需要在中断退出后做一次上下文切换。

我曾经踩过的坑:有一次在ISR里调用了xEventGroupSetBitsFromISR(),但忘了检查pxHigherPriorityTaskWoken。结果被唤醒的高优先级任务一直没得到执行,系统响应变得很慢。排查了半天才发现是这个问题。所以,千万别偷懒,该检查的一定要检查。

正确的用法是这样的:

void vTimerISR(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    // 在中断中置位
    xEventGroupSetBitsFromISR(
        xDataEventGroup,
        TIMER_EXPIRED,
        &xHigherPriorityTaskWoken
    );
    
    // 如果有更高优先级的任务被唤醒,请求切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

为什么中断版本和任务版本不一样?因为中断上下文里不能阻塞,不能等待。所以中断版本实际上是把置位请求放到了一个「守护任务」的队列里,由守护任务在任务上下文中真正执行置位操作。

4.3 置位原理:到底发生了什么?

好,咱们深入一点,看看置位操作内部是怎么工作的。

事件标志组本质上就是一个 EventBits_t 类型的变量,通常是32位的无符号整数。每个位代表一个事件。

置位操作的核心逻辑,说白了就是一次「按位或」运算:

// 伪代码,实际实现更复杂
eventGroup->uxEventBits |= uxBitsToSet;

但FreeRTOS不会这么简单粗暴。它还要做几件重要的事:

  1. 关中断:防止在置位过程中被中断打断,造成数据不一致
  2. 执行按位或:把要置的位设成1
  3. 检查等待任务:遍历所有等待在这个事件标志组上的任务,看看有没有任务的条件满足了
  4. 唤醒任务:如果有任务的条件满足,把它从阻塞态移到就绪态
  5. 开中断:恢复中断状态

这里有个细节值得注意:置位操作会一次性检查所有等待的任务。不管你是用「与」模式还是「或」模式等待,都会检查一遍。所以如果你有多个任务在等不同的事件组合,一次置位可能唤醒多个任务。

我的经验:在设计事件标志位时,尽量让每个位代表一个独立且明确的事件。不要把多个含义混在一个位里。比如「数据就绪」和「错误发生」最好用不同的位。这样代码可读性高,调试也方便。

4.4 置位操作的注意事项

场景 使用函数 注意事项
任务中置位 xEventGroupSetBits() 不能在ISR中使用
ISR中置位 xEventGroupSetBitsFromISR() 必须检查pxHigherPriorityTaskWoken
置位后立即读取 返回值或xEventGroupGetBits() 注意返回值是置位前的值

嗯,这里还要提一点。有些新手会问:我能不能在中断里直接调用 xEventGroupSetBits()?答案是:绝对不行。这个函数内部会调用可能引起任务切换的API,在中断里调用会导致不可预知的行为。我见过有人因为这个导致系统死锁,排查起来特别痛苦。

另外,置位操作本身是原子性的,你不用担心多个任务同时置位会出问题。FreeRTOS内部已经用关中断的方式保证了这一点。

好了,关于置位操作就聊这么多。下一节咱们聊聊怎么等待这些事件位,也就是「取位」操作。到时候你会看到,置位和取位是如何配合起来,构建出灵活的事件驱动机制的。