4.3 事件组与任务通知:轻量同步两板斧


4.3 事件组与任务通知:轻量同步两板斧

本节摘要:事件组与任务通知是 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()); } } }

结构对比:绕开对象的直达通路

结构对比:绕开对象的直达通路

边界与陷阱。任务通知的能力边界来自它的结构优势本身:因为直达单个任务,它不能广播(一群任务等同一个信号时,只有指名的那个收到),不能多读者(数据就绪信号若被两个消费者任务共享,通知会只落到其中一个头上),默认不排队(比特动作的多次发送会合并——比特只有置与未置;要计数语义就选自增动作,它天然累计)。此外通知要求发送方持有目标句柄,任务创建顺序上要先建消费者后建生产者,或用句柄查询接口过渡——句柄还没就绪就发送,是初版代码的常见死机点(发送到空句柄,断言拦截或未定义行为)。

三、四原语选型对照

把本章过半的原语放进一张表,选型时按表索骥:

需求 首选 备选 理由
搬运定长数据 队列 消息缓冲区(变长时) 复制语义,双方解耦
一对一信号(中断到任务) 任务通知 二值信号量 更快更省,中断有后缀版本
资源池计数 计数信号量 任务通知自增 多消费者只能用信号量
多方等条件凑齐 事件组 无可替代 广播语义加组合规则
保护共享资源 互斥量 队列传数据 继承防反转,锁外缩区间
变长消息流 消息缓冲区 队列放指针 下一节主角

⚠️ 选型最常犯的错不是"选了慢的",而是"混了语义的":用事件组当数据通道(比特当数据用,八位就撑爆)、用任务通知做广播(只有一个任务醒来,另一个饿死)、用互斥量当中断信号(中断根本不能取)。语义错位比性能差一个数量级地危险。

本节要点回顾

  • 事件组是唯一的广播原语:一组比特、两种等待规则、置位唤醒所有满足者;会合接口自带发令枪语义;
  • 事件组中断侧置位走守护任务代执行,多一跳延迟且依赖定时器功能,高频中断慎用;
  • 任务通知零创建、直写直达:信号量形态与携值形态覆盖大部分一对一同步,中断有专用后缀;
  • 通知三条边界:不能广播、不能多读者、比特动作不排队——结构优势的另一面;
  • 发送前句柄必须就绪:先建消费者,或用查询接口过渡;
  • 选型按语义不按性能:语义错位是并发缺陷的头号来源,性能差只是慢,语义差是错。

队列、锁、事件、通知各就各位。还剩最后一类数据通路:连绵不断的字节流与长短不一的消息——下一节的流缓冲区与消息缓冲区,是给高频数据流的专用高速车道。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U