本节摘要:事件组与任务通知是 FreeRTOS 工具箱里两件"降本武器"。事件组把一串事件压进一组比特位,支持"全部到齐"与"任一到达"两种等待逻辑,还附带多方会合的原生接口——一对多对齐的唯一选择。任务通知则绕过一切内核对象,把信号直写到目标任务的私有字段,速度快、零创建成本,代价是只能一对一。本节讲清两者的机制、代码范式与边界,给出与队列、信号量的完整选型对照。
上一节的原语各有开销:信号量内部是队列结构,创建要吃内存、操作要走完整路径。FreeRTOS 的设计者在两条路上砍掉了成本:多方对齐的场景给事件组(一个对象服务一群等待者),一对一的场景给任务通知(连对象都不建,直达任务本体)。先说结论:新写的代码里,一对一的信号同步几乎都该用任务通知;而"等一群条件凑齐"只有事件组能干。
事件组就是一组标志位(可用位数取决于滴答类型配置:16 位滴答的配置给 8 个可用位,32 位配置给 24 个),每个比特代表一个事件。等待方声明"等哪几个比特、按什么规则等":全部到齐才醒,或任一到达就醒。设置方把比特置位,内核扫描等待链表,把规则满足的等待者全部唤醒——注意是全部:事件组的置位是广播语义,一喊一屋子人都听见,这使它成为全族原语里唯一的"一对多"同步点。
三个典型用法各有代码范式。用法一,启动对齐:系统里三个模块(网络、传感器、存储)各自初始化,主控任务等三块牌子都挂上才开始业务:
#define EV_NET_READY (1UL << 0) #define EV_ADC_READY (1UL << 1) #define EV_FS_READY (1UL << 2) EventGroupHandle_t xBootEvents; void master_task(void *arg) { /* 全部到齐才醒;超时兜底防某个模块永远起不来 */ EventBits_t bits = xEventGroupWaitBits( xBootEvents, EV_NET_READY | EV_ADC_READY | EV_FS_READY, pdFALSE, /* 醒来不清位(状态型) */ pdTRUE, /* 全部到齐规则 */ pdMS_TO_TICKS(3000)); if ((bits & (EV_NET_READY | EV_ADC_READY | EV_FS_READY)) != (EV_NET_READY | EV_ADC_READY | EV_FS_READY)) { boot_degrade(bits); /* 超时降级:带病启动缺哪个功能禁哪个 */ } run_business(); }
用法二,任一故障即停机:等一组报警位里的任何一个点亮,等待规则换任一即可。用法三,多方会合:三个任务必须互相等都到齐再同时起跑(赛跑的发令枪),内核提供专门的会合接口,每个任务声明自己的位并等待其他位,内部处理了"先后到达"的窗口竞争,比手工用等待加置位拼装可靠得多。
一个务必知道的中断侧细节:事件组的置位操作内部要扫描等待链表,这个动作在中断里直接做不安全,所以中断版置位接口的真正实现是"把置位请求投递给定时器守护任务,由任务上下文代为执行"(守护任务机制见第 6 章)。代价有二:置位与唤醒之间多了一跳延迟;依赖定时器功能开启。中断频率高的场合,这一跳会累积——那是改用任务通知的信号。
任务通知的思路极其朴素:每个任务的控制块里本来就有个通知字段与通知状态,直接往那里写信号,不建任何对象。发送方指名道姓(目标任务句柄),接收方用专门的等待接口睡去或醒来。因为不经过队列结构、不扫描公共链表,开销显著低于信号量(官方口径下是更快、更省内存的替代品),而且零创建成本——字段是任务与生俱来的。
两种使用形态对应两类需求。形态一,模拟信号量:接收方用"取走通知值"的接口,发送方用"给出通知"的接口(中断里有对应后缀版本),行为就像一个专属的二值或计数信号量——2.2 节按键改造实验用的正是它。形态二,携带信息:通知值本身是 32 位,可以按"置某些比特""自增一""覆写整个值"等动作发送,接收方睡醒后读到值与上次的旧值——比特当事件用,自增当计数用,覆写当"最新状态指针"用。
/* 中断到任务的数据就绪通知:值字段传缓冲区序号 */ void DMA_IRQHandler(void) { BaseType_t woken = pdFALSE; vTaskNotifyGiveFromISR(xConsumer, &woken); /* 自增动作:计件式唤醒 */ portYIELD_FROM_ISR(woken); } void consumer_task(void *arg) { for (;;) { /* 取走累计值并清零:一次醒来处理积压的几帧 */ uint32_t pending = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); for (uint32_t i = 0; i < pending; i++) { process_frame(next_buffer()); } } }

边界与陷阱。任务通知的能力边界来自它的结构优势本身:因为直达单个任务,它不能广播(一群任务等同一个信号时,只有指名的那个收到),不能多读者(数据就绪信号若被两个消费者任务共享,通知会只落到其中一个头上),默认不排队(比特动作的多次发送会合并——比特只有置与未置;要计数语义就选自增动作,它天然累计)。此外通知要求发送方持有目标句柄,任务创建顺序上要先建消费者后建生产者,或用句柄查询接口过渡——句柄还没就绪就发送,是初版代码的常见死机点(发送到空句柄,断言拦截或未定义行为)。
把本章过半的原语放进一张表,选型时按表索骥:
| 需求 | 首选 | 备选 | 理由 |
|---|---|---|---|
| 搬运定长数据 | 队列 | 消息缓冲区(变长时) | 复制语义,双方解耦 |
| 一对一信号(中断到任务) | 任务通知 | 二值信号量 | 更快更省,中断有后缀版本 |
| 资源池计数 | 计数信号量 | 任务通知自增 | 多消费者只能用信号量 |
| 多方等条件凑齐 | 事件组 | 无可替代 | 广播语义加组合规则 |
| 保护共享资源 | 互斥量 | 队列传数据 | 继承防反转,锁外缩区间 |
| 变长消息流 | 消息缓冲区 | 队列放指针 | 下一节主角 |
⚠️ 选型最常犯的错不是"选了慢的",而是"混了语义的":用事件组当数据通道(比特当数据用,八位就撑爆)、用任务通知做广播(只有一个任务醒来,另一个饿死)、用互斥量当中断信号(中断根本不能取)。语义错位比性能差一个数量级地危险。
队列、锁、事件、通知各就各位。还剩最后一类数据通路:连绵不断的字节流与长短不一的消息——下一节的流缓冲区与消息缓冲区,是给高频数据流的专用高速车道。