4、通知值的位操作:利用通知值的32位实现多事件标志,按位与/或/取反操作

4.1 32位通知值,不只是个计数器

好,咱们继续聊任务通知。前面几节我们一直在用通知值做“信号量”或者“消息邮箱”,说白了就是把它当个整数在用。但FreeRTOS给每个任务分配的这个32位通知值,其实远不止这么简单。

我个人习惯把它看作一个“32位的寄存器”。每一位都可以独立地代表一个事件标志。你想想看,一个任务可以同时等待32种不同的事件,而且判断逻辑可以非常灵活——是“全部满足”才唤醒,还是“任意一个满足”就唤醒?用传统的二进制信号量或者事件组,你得额外写不少代码。但用通知值的位操作,几行代码就搞定了。

我在项目中遇到过这样一个场景:一个传感器数据采集任务,需要等待“数据就绪”、“DMA传输完成”、“定时器超时”这三个事件中的任意一个。如果用队列或者信号量,我得创建三个同步对象。但用通知值的位操作,我只用一个通知值,三个事件分别对应bit0、bit1、bit2。清爽得很。

4.2 核心API:位操作的“三剑客”

要实现位操作,我们主要用这三个函数:

  • xTaskNotify() —— 发送通知,可以指定操作类型
  • xTaskNotifyWait() —— 等待通知,可以指定退出条件和清除哪些位
  • ulTaskNotifyTake() —— 这个我们之前用过,但它只适合做计数信号量,位操作场景下我们更常用前两个

关键就在 xTaskNotify() 的第三个参数 eAction。它支持以下几种模式:

eAction 值 含义 典型用途
eNoAction 不修改通知值,只唤醒任务 单纯的通知,不携带数据
eSetBits 将通知值与传入的值按位或 设置事件标志位
eIncrement 通知值加1 信号量模式
eSetValueWithOverwrite 直接覆盖通知值 消息邮箱模式
eSetValueWithoutOverwrite 不覆盖,如果通知值未消费则返回失败 带保护的消息邮箱

嗯,这里要注意:位操作场景下,我们只用 eSetBits。它会将你传入的值的对应位,与任务当前的通知值做“按位或”操作。说白了就是:你想置位哪一位,就把那一位写成1传进去。

4.3 实战:多事件标志的“与”和“或”逻辑

假设我有一个控制任务,它需要等待以下三个事件:

  • bit0: 按键A被按下
  • bit1: 按键B被按下
  • bit2: 定时器溢出

场景A:任意一个事件发生就唤醒(或逻辑)

// 发送方:中断或其它任务
// 按键A按下时
xTaskNotify(controlTaskHandle, (1 << 0), eSetBits);
// 按键B按下时
xTaskNotify(controlTaskHandle, (1 << 1), eSetBits);
// 定时器溢出时
xTaskNotify(controlTaskHandle, (1 << 2), eSetBits);

// 接收方:controlTask
uint32_t ulNotifiedValue;
BaseType_t xResult;

// 等待任意一个事件,不清除任何位
xResult = xTaskNotifyWait(
    0x00,                    // 进入函数时不清除位
    0x00,                    // 退出函数时不清除位(我们自己手动清)
    &ulNotifiedValue,        // 保存当前通知值
    portMAX_DELAY            // 一直等
);

if(xResult == pdPASS) {
    // 检查是哪一位被置位了
    if(ulNotifiedValue & (1 << 0)) {
        // 处理按键A
        // 处理完后清除该位
        xTaskNotify(controlTaskHandle, (1 << 0), eSetBits); // 注意:这里用eSetBits无法清除!
    }
}
⚠️ 我曾经在这里踩过一个坑: 上面代码中,我试图用 eSetBits 来清除位,这是错误的!eSetBits 只会置位,不会清零。要清除特定位,你需要用 eSetValueWithOverwrite 直接写入一个值,或者更优雅的做法是在 xTaskNotifyWait 的第二个参数中指定要清除的位。

正确的做法是这样的:

// 接收方:等待任意一个事件,并在退出时自动清除所有位
xResult = xTaskNotifyWait(
    0x00,                    // 进入时不操作
    0xFFFFFFFF,              // 退出时清除所有位(也可以只清除你关心的位)
    &ulNotifiedValue,
    portMAX_DELAY
);

if(xResult == pdPASS) {
    // 此时ulNotifiedValue保存的是进入函数时的通知值
    // 退出后通知值已经被清0了
    if(ulNotifiedValue & (1 << 0)) {
        // 处理按键A
    }
    if(ulNotifiedValue & (1 << 1)) {
        // 处理按键B
    }
}

场景B:所有事件都发生才唤醒(与逻辑)

这个稍微复杂一点。你不能直接让FreeRTOS帮你做“与”判断,因为 xTaskNotifyWait 本身不支持“所有位都置位才唤醒”。但我们可以自己实现:

// 接收方:等待所有事件
uint32_t ulExpectedBits = (1 << 0) | (1 << 1) | (1 << 2); // 期望bit0、bit1、bit2都为1

for(;;) {
    // 先获取当前通知值,但不等待
    xResult = xTaskNotifyWait(
        0x00,
        0x00,                // 不自动清除
        &ulNotifiedValue,
        0                    // 不等待,立即返回
    );

    // 检查是否所有期望位都置位了
    if((ulNotifiedValue & ulExpectedBits) == ulExpectedBits) {
        // 所有事件都发生了!
        // 处理完后,清除这些位
        xTaskNotify(controlTaskHandle, ~ulExpectedBits, eSetBits); // 错误!eSetBits不能取反清除
        // 正确做法:用xTaskNotifyWait清除
        xTaskNotifyWait(0x00, ulExpectedBits, NULL, 0);
        break;
    }

    // 如果条件不满足,阻塞等待任意一个事件
    xTaskNotifyWait(
        0x00,
        0x00,
        &ulNotifiedValue,
        portMAX_DELAY
    );
}
💡 我的个人经验: 上面这个“与逻辑”的实现,其实有一个更简洁的变种。你可以把 xTaskNotifyWait 的第三个参数(退出时清除的位)和你的判断逻辑结合起来。但说实话,我更喜欢用FreeRTOS自带的事件组(Event Groups)来做“与”逻辑,因为那是原生支持的。通知值的位操作,最适合的场景是“或”逻辑——任意一个事件唤醒,然后你再去判断具体是哪个。

4.4 取反操作:巧用“非”来清除特定位

你可能会问:那我想清除某一位,但不想影响其他位,怎么办?

嗯,这里有个小技巧。FreeRTOS的 xTaskNotify 没有提供“按位与”或者“按位取反”的操作。但我们可以用 eSetValueWithOverwrite 配合位运算来实现:

// 假设当前通知值是 0b00000111 (bit0、bit1、bit2都置位了)
// 我想清除bit1,保留bit0和bit2

uint32_t currentValue;
// 先读取当前值(通过xTaskNotifyWait的ulNotifiedValue参数获取)
// 假设我们已经获取到了 currentValue = 0x07

// 构造新值:清除bit1
uint32_t newValue = currentValue & (~(1 << 1)); // 0x07 & 0xFFFFFFFD = 0x05

// 用覆盖模式写入
xTaskNotify(controlTaskHandle, newValue, eSetValueWithOverwrite);

说白了,就是“先读-再改-再写”三步走。但要注意:如果在这三步之间,有其他任务或中断修改了通知值,你的修改可能会覆盖掉别人的操作。所以,如果你的系统中有多个发送者,建议用临界区保护一下:

taskENTER_CRITICAL();
uint32_t currentValue;
xTaskNotifyWait(0x00, 0x00, &currentValue, 0); // 读取但不等待
uint32_t newValue = currentValue & (~(1 << 1));
xTaskNotify(controlTaskHandle, newValue, eSetValueWithOverwrite);
taskEXIT_CRITICAL();

4.5 避坑指南:我踩过的三个坑

  1. 不要用eSetBits来清除位:这个我前面提过。eSetBits只会做“按位或”,你传0进去它也不会清除任何位。要清除,要么用xTaskNotifyWait的退出清除参数,要么用eSetValueWithOverwrite覆盖。
  2. 注意中断中的使用:在ISR中,你必须用 xTaskNotifyFromISR 而不是 xTaskNotify。而且ISR中不能调用 xTaskNotifyWait。我刚开始做项目时,在中断里调了 xTaskNotifyWait,结果系统直接挂掉。
  3. 32位的限制:通知值只有32位,所以最多支持32个独立事件。如果你需要更多,就得用事件组(Event Groups),它支持24位(在FreeRTOS中)。不过话说回来,32个事件对于绝大多数嵌入式应用来说,绰绰有余了。

4.6 小结

通知值的位操作,说白了就是把一个32位的整数当成32个开关来用。每个开关代表一个事件,你可以用“或”逻辑(任意一个唤醒),也可以用“与”逻辑(全部唤醒),还可以手动取反来清除特定位。相比传统的事件组,它的优势在于不需要额外创建对象,内存开销几乎为零。但缺点也很明显:没有原生的事件组API那么直观,尤其是“与”逻辑需要自己实现。

我个人建议:如果你的场景是“多个事件中任意一个触发就干活”,那通知值的位操作是最优雅的方案。如果是“多个事件全部触发才干活”,那还是用事件组吧,省心。