FreeRTOS 事件组与任务通知实战:等”三个条件齐了才开工”的逻辑,最后我用 1 个 32 位通知变量就写完了

2026年9月23日 0 By admin

上一期讲 CAN 错误帧的时候文末预告了这篇。拖了两周,原因很实在:事件组和任务通知这俩东西,API 就那么几个,十分钟就能”会用”,但要讲清楚什么时候该用、什么时候是给自己埋雷,必须把我在项目里踩过的几个坑翻出来晒一晒。

事情还是从那个多协议采集网关说起。设备启动后,上传线程要等三个条件同时满足才允许开工:配置加载完成、网络就绪、RTC 时间同步成功。这三个条件分别由三个不同的任务/ISR 置位,谁先谁后完全随机。

我第一版的写法丑得没法看:三个二值信号量 + 一个全局标志数组 + 轮询。那段代码跑了三个多月,每次新增一个条件就要动四处地方。后来借着一次大版本重构,把这套东西彻底换掉了——先换事件组,再进一步换成任务通知,同步相关的 RAM 开销省了近 200 字节,代码行数砍了一半。这一期就把这次重构里学到的、踩到的,一次讲完。

一、先说事件组是什么,30 秒版

事件组(Event Group)本质上就是一个可以被多个任务原子地”置位”和”等待”的位集合。核心 API 就四个:

#include "event_groups.h"

EventGroupHandle_t xEventGroupCreate( void );

/* 置位:任务上下文 */
EventBits_t xEventGroupSetBits( EventGroupHandle_t xEventGroup,
                                const EventBits_t uxBitsToSet );

/* 等待:可以阻塞,可等"任一位"或"全部位" */
EventBits_t xEventGroupWaitBits( EventGroupHandle_t xEventGroup,
                                 const EventBits_t uxBitsToWaitFor,
                                 const BaseType_t xClearOnExit,      /* 退出时自动清除等待的位 */
                                 const BaseType_t xWaitForAllBits,   /* pdTRUE = 逻辑与 */
                                 TickType_t xTicksToWait );

/* 清除 */
EventBits_t xEventGroupClearBits( EventGroupHandle_t xEventGroup,
                                  const EventBits_t uxBitsToClear );

回到开头那个场景,三个条件对应三个位,上传线程一行等待搞定:

#define EV_CONFIG_READY   ( 1 << 0 )
#define EV_NET_UP         ( 1 << 1 )
#define EV_TIME_SYNCED    ( 1 << 2 )
#define EV_ALL_READY      ( EV_CONFIG_READY | EV_NET_UP | EV_TIME_SYNCED )

/* 配置任务、网络任务、SNTP 任务里各自置位 */
xEventGroupSetBits( xSysEvents, EV_CONFIG_READY );

/* 上传线程:等三个条件全部满足才往下走 */
EventBits_t uxBits = xEventGroupWaitBits( xSysEvents, EV_ALL_READY,
                                          pdFALSE,     /* 不自动清除,后续断线重连还要用 */
                                          pdTRUE,      /* 全部满足 */
                                          portMAX_DELAY );

替代掉的是原来那段”轮询三个信号量 + 标志数组”的代码,而且 xEventGroupWaitBits 的置位、判断、唤醒是在内核里原子完成的——不存在”刚查完网络标志、配置标志就置位了”这种窗口。这是它比手动组合信号量强的地方,也是我认为多条件同步应该无脑选事件组的原因。

二、坑一:事件位就那么几个,多一位都不给

这是我重构时差点翻车的第一个地方。我想顺手把”低电量告警””SD 卡满”也塞进同一个事件组,随手写了 1 << 30,结果 xEventGroupWaitBits 直接触发了 configASSERT。

翻源码才确认:事件组可用位宽由 configUSE_16_BIT_TICKS 决定——

配置 EventBits_t 类型 可用事件位
configUSE_16_BIT_TICKS = 1 16 位 低 8 位(bit 0~7)
configUSE_16_BIT_TICKS = 0(常见) 32 位 低 24 位(bit 0~23)

高位不是随便占的,它们留给内核做控制用途。所以事件组适合表达的是“少数几个系统级状态标志”,不是你的业务消息队列。我的做法是建一张位分配表放在头文件里,像分配 GPIO 一样严肃地分配事件位:

/* sys_events.h —— 事件位分配表,谁用哪位写死,避免各处魔数 */
#define EV_CONFIG_READY   ( 1UL << 0 )   /* 配置模块  */
#define EV_NET_UP         ( 1UL << 1 )   /* 网络管理  */
#define EV_TIME_SYNCED    ( 1UL << 2 )   /* SNTP 任务 */
#define EV_FAULT_ALARM    ( 1UL << 3 )   /* 故障管理  */
/* bit 4 ~ 23 预留 */

另外注意 uxBitsToWaitFor 不允许为 0(FreeRTOS V10.4.0 之前传 0 是未定义行为,之后会 assert),也不要傻到一次等 24 位全查一遍——事件组表达的是条件,不是日历。

三、坑二:ClearOnExit 清掉的可能是别人在等的位

事件组没有”位的所有权”概念,xClearOnExit = pdTRUE 用起来很爽,但爽着爽着就出事了。

我的上传线程当时是这么写的:

/* 错误示范:等网络恢复事件,退出时顺手清掉 */
xEventGroupWaitBits( xSysEvents, EV_NET_UP, pdTRUE, pdTRUE, portMAX_DELAY );

问题在于 EV_NET_UP 这一位不止上传线程关心——链路监控任务也要等它做日志。上传线程 ClearOnExit 一跑,监控任务就永久阻塞在那儿了。这种 bug 的恶心之处在于:概率性、跨模块,两个任务单测各自都是对的。

最后我们定了条铁规矩,写进了代码规范:

每一位的清除权归属固定:要么”生产者设位、唯一的消费者清位”,要么统一由事件位分配表里注明的 owner 清除。其他任何人只读不清。

落到代码上,就是 ClearOnExit 尽量别用,显式 xEventGroupClearBits 并且只出现在 owner 的代码里。多花一行,换来的是 grep 一下就知道谁有资格动这个位。

四、坑三:ISR 版本置位是”异步”的,返回成功 ≠ 已置位

这是事件组 API 里最容易想当然的一个点。中断里置位必须用:

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

关键在于:这个函数并不直接改事件组。它把”调用 xEventGroupSetBits”这个动作塞给定时器守护任务(Tmr Svc)去执行——因为中断不能阻塞,而事件组置位内部可能唤醒任务、需要加锁,这在 ISR 里做不安全。

由此推出两个实际影响:

  • 返回 pdPASS 只代表请求成功递交给守护任务,不代表位已经置上。如果你在 ISR 返回后立刻在另一个任务里查这个位,它可能还没变。
  • 从置位到生效的延迟,取决于守护任务的优先级。默认的 configTIMER_TASK_PRIORITY = 1(低于很多任务),如果你的事件唤醒的是个高优先级任务,中间会多一次优先级倒挂式的绕行。实时性敏感的置位路径,我会把 configTIMER_TASK_PRIORITY 适当调高,或者干脆用下面要讲的任务通知直通。

同样的道理,pxHigherPriorityTaskWoken 置位后记得退出 ISR 前做一次上下文切换,这个套路和队列的 FromISR 一致,不展开。

五、事件组的两个天生局限:不能计数、不能带数据

上面三个坑修完,事件组在那个项目里跑得很好。但重构继续往下走,我又遇到了两类它管不了的需求:

  1. 串口任务收完一包数据,要”唤醒 + 告诉解析任务数据在哪、多长”——事件组只能喊一声”来活了”,不能带货;
  2. ADC 任务每个周期完成都发通知,解析任务慢了的时候不能丢累积——事件组同一个位置 100 次还是 1 位,没有计数语义。

这两个需求恰好是任务通知的主场。先从简单的讲起。

六、任务通知第一层:不要钱的信号量

任务通知(Task Notification)是 FreeRTOS V8.2 引入的机制:每个任务自带一个 32 位通知值和一套通知状态,不需要 xQueueCreate,不占额外内核对象。最常见的用法是模拟信号量:

/* 发送端(可以是 ISR) */
vTaskNotifyGiveFromISR( xParserTaskHandle, &xHigherPriorityTaskWoken );

/* 接收端:二值信号量行为 */
uint32_t ulCount = ulTaskNotifyTake( pdFALSE,     /* pdFALSE: 每次取走 1,减 1 */
                                     portMAX_DELAY );

/* 接收端:计数信号量行为 */
uint32_t ulCount = ulTaskNotifyTake( pdTRUE,      /* pdTRUE: 返回前清零,一次拿到累计值 */
                                     portMAX_DELAY );

ulTaskNotifyTake(pdTRUE, ...) 这个组合我个人非常喜欢:发送方连发 10 次、接收方慢一步也没关系,10 次累积在通知值里,一次全取走。这就是”不能计数”的事件组做不到的事。我的 ADC 采集任务就是这么通知解析任务的,采样周期抖动再也不丢拍。

成本对比很直白:一个二值信号量要 xSemaphoreCreateBinary(),在 32 位 MCU 上占一个队列对象(约 76~80 字节)+ 创建开销;任务通知是零对象、零创建,发送侧就一条 vTaskNotifyGive。

七、任务通知第二层:eSetBits——把事件组塞进 32 位值里

重头戏在这。任务通知不只能”加一”,它还能按 5 种动作处理那 32 位通知值:

eAction 行为 等价于
eNoAction 只把状态置为 pending 二值信号量(give)
eIncrement 通知值 +1 计数信号量(give)
eSetBits 通知值 按位或 轻量事件组
eSetValueWithOverwrite 覆盖写 旧邮箱
eSetValueWithoutOverwrite 不覆盖写(上次没读走则失败) 深度为 1 的队列

eSetBits 就是本文标题的答案。重构后的网关里,我把”系统就绪事件组”直接砍掉,三个条件源改成向同一个任务发通知:

/* 各条件源(任务/ISR):按位或置位 */
#define SYS_EVT_CONFIG   ( 1UL << 0 )
#define SYS_EVT_NET      ( 1UL << 1 )
#define SYS_EVT_RTC      ( 1UL << 2 )

xTaskNotify( xUploadTaskHandle, SYS_EVT_NET, eSetBits );

/* 上传任务:等待并取出事件位 */
uint32_t ulValue;
if ( xTaskNotifyWait( 0,                  /* 进入时不屏蔽 */
                      0xFFFFFFFFUL,       /* 退出时清除全部位 */
                      &ulValue,
                      portMAX_DELAY ) == pdPASS )
{
    if ( ( ulValue & ( SYS_EVT_CONFIG | SYS_EVT_NET | SYS_EVT_RTC ) )
         == ( SYS_EVT_CONFIG | SYS_EVT_NET | SYS_EVT_RTC ) )
    {
        start_upload();
    }
}

事件组做的事它全能干:多源置位、原子或运算、按位等待。而且置位是直通的,不经过守护任务——xTaskNotifyFromISR 是真正在 ISR 上下文里完成的,没有第四节那层绕行。

这里顺便提一句版本红利:FreeRTOS V10.4.0 起,每个任务可以配置 configTASK_NOTIFICATION_ARRAY_ENTRIES 个通知”槽位”(默认 1 个),API 多一个 uxIndexToNotify 参数。我的习惯是索引 0 给通用事件(eSetBits),索引 1 给计数信号(eIncrement),分工固定,代码里看索引就知道语义。

八、坑四:任务通知是”一对一专线”,这三点想不清楚别用

任务通知省 RAM 省到飞起,但它是把双刃剑,三个限制必须刻在脑子里:

  1. 接收方是写死的某个任务。信号量、队列、事件组都是”对象”,谁都能来拿;任务通知绑在具体的任务句柄上。如果你发现有三个任务都要消费同一个事件,那就老老实实用事件组——通知发不过去第二个任务。
  2. 不排队,只有一个槽。任务正忙没调 xTaskNotifyWait 时,eSetValueWithOverwrite 会覆盖旧值,eSetBits 会累积——但 eNoAction 只是把状态置 pending,连发两次和发一次没区别。需要”一个都不能丢”的语义时,要么用 eIncrement/eSetBits 的累积性,要么直接上队列。判断方法很简单:问自己”丢了这次通知会出什么事故”,答不上来就别赌。
  3. 只能带一个机器字(32 位)。传指针可以(配合内存池或静态缓冲),传结构体不行。传指针时所有权规则和队列那期讲的一样:谁负责释放要提前约定死。

还有个隐蔽的:xTaskNotify() 用 eSetValueWithoutOverwrite 时,如果上一个值没被取走,它返回 pdFAIL 而不是阻塞——这是”非阻塞队列”行为,发送端必须检查返回值。我见过有人拿它当邮箱却从不看返回值,消息丢了都不自知。

九、最终落地:一张”事件路由表”

重构完成后,整个固件的事件同步收敛成一张表,贴在 sys_events.h 顶部,评审时对着过:

事件 位掩码 生产者 消费者 通知方式 清除者
配置就绪 1<<0 配置任务 上传任务 eSetBits, idx 0 上传任务(消费即清)
网络就绪 1<<1 网络任务 上传任务 eSetBits, idx 0 同上
RTC 同步 1<<2 SNTP 任务 上传任务 eSetBits, idx 0 同上
采样完成 — ADC ISR 解析任务 eIncrement, idx 1 解析任务(pdTRUE 清零)
串口整包 — UART ISR 协议任务 eSetValueWithOverwrite(传缓冲指针) 协议任务(用完还缓冲)

这张表解决的不只是代码问题,更是协作问题:新增事件先加表、再写代码;谁生产、谁消费、谁清除、能不能丢,四问都能在表上找到答案,code review 时一眼扫完。

十、选型对照:信号量、事件组、任务通知怎么选

把三者的适用场景压成一张表,下次纠结时对着查:

维度 信号量/队列/事件组 任务通知
内核对象 需要 xCreate,占 RAM 无对象,每个任务自带
发送开销 涉及队列锁,较重 极轻,直通
多发送者 ✔ 任意任务/ISR ✔(都拿到接收者句柄即可)
多接收者 ✔ ✘ 只能一对一
计数语义 ✔(计数信号量/队列深度) ✔(eIncrement + pdTRUE)
多位条件同步 ✔ 事件组 ✔(eSetBits,但要所有源都通知同一个任务)
携带数据 ✔ 队列可传任意大小 ✘ 最多一个 32 位值(可传指针)
ISR 置位直通 事件组走守护任务(异步) ✔ FromISR 直通
有序性/不丢失 ✔ 队列 FIFO ✘ 覆盖/累积规则复杂

我的选型口诀:一对一 → 任务通知;多对一的条件位 → eSetBits 通知;多对多、或需要广播 → 事件组;要带数据且不能丢 → 队列。实在拿不准,用队列或事件组——它们贵一点,但语义显式,不容易错。

十一、自查清单

同步机制评审时过一遍,每一条都是真金白银换来的:

  • ☐ 事件位分配表存在且每个位都有唯一 owner(生产者/消费者/清除者三栏齐全)
  • ☐ 没有使用超过事件组位宽的位(16 位 tick 配置 ≤ bit7,32 位 tick 配置 ≤ bit23)
  • ☐ xEventGroupWaitBits 的 uxBitsToWaitFor 不为 0
  • ☐ xClearOnExit 只出现在位 owner 的代码里,其他调用点为 pdFALSE
  • ☐ ISR 中未直接调用 xEventGroupSetBits(用的是 FromISR 版本)
  • ☐ 使用 xEventGroupSetBitsFromISR 的路径已评估定时器守护任务优先级带来的延迟
  • ☐ pxHigherPriorityTaskWoken 初始化为 pdFALSE,且退出 ISR 前完成一次 yield
  • ☐ 任务通知的所有通知源都持有正确的任务句柄(句柄来自创建时保存,而非 xTaskGetCurrentTaskHandle 的误用)
  • ☐ 需要不丢失的场合没有使用 eNoAction(改用 eIncrement / eSetBits / 队列)
  • ☐ eSetValueWithoutOverwrite 的发送端检查了返回值
  • ☐ 通知值传指针的路径有明确的所有权与释放约定
  • ☐ 使用 V10.4+ 多通知槽位时,索引-语义的映射在头文件中有注释说明
  • ☐ configASSERT 已开启(默认关闭时上述一切 assert 都是摆设)
  • ☐ 每个同步路径在”发送方比接收方快 100 倍”的假设下推演过一次丢失行为

写在最后

这次重构让我对 FreeRTOS 的同步原语有了一个更朴素的认知:事件组和任务通知不是”高级版信号量”,而是两种不同拓扑的答案——事件组是”多对多的公告板”,任务通知是”一对一的专线”。选型错了,API 写得再漂亮也是埋雷。

把 3 个信号量换成 1 个通知变量省下的那 200 字节,对有 FPU 栈区和 lwIP 的板子来说可能只是零头;但省下来的那半屏代码和一条清晰的位分配表,是后来每一次加功能都实打实在还的债。

下一期预告:回归硬件,讲《DDR3/SDRAM 控制器初始化与训练实战》——上次讲完 SDRAM 布线,有老铁问”布线没问题还是跑不过 166MHz 怎么办”,那就要进控制器里看初始化流程、ZQ 校准和读写训练了。感兴趣的关注一下,更新不定期但不断更。

(上一篇:CAN 总线错误帧排查实战)