FreeRTOS 事件组与任务通知实战:等”三个条件齐了才开工”的逻辑,最后我用 1 个 32 位通知变量就写完了
上一期讲 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 一致,不展开。
五、事件组的两个天生局限:不能计数、不能带数据
上面三个坑修完,事件组在那个项目里跑得很好。但重构继续往下走,我又遇到了两类它管不了的需求:
- 串口任务收完一包数据,要”唤醒 + 告诉解析任务数据在哪、多长”——事件组只能喊一声”来活了”,不能带货;
- 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 省到飞起,但它是把双刃剑,三个限制必须刻在脑子里:
- 接收方是写死的某个任务。信号量、队列、事件组都是”对象”,谁都能来拿;任务通知绑在具体的任务句柄上。如果你发现有三个任务都要消费同一个事件,那就老老实实用事件组——通知发不过去第二个任务。
- 不排队,只有一个槽。任务正忙没调
xTaskNotifyWait时,eSetValueWithOverwrite会覆盖旧值,eSetBits会累积——但eNoAction只是把状态置 pending,连发两次和发一次没区别。需要”一个都不能丢”的语义时,要么用 eIncrement/eSetBits 的累积性,要么直接上队列。判断方法很简单:问自己”丢了这次通知会出什么事故”,答不上来就别赌。 - 只能带一个机器字(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 总线错误帧排查实战)