ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

FreeRTOS事件组源码解析:从API到三任务栅栏同步实战

FreeRTOS事件组源码解析:从API到三任务栅栏同步实战 第一次翻开源码里的event_groups.c我盯着eventEVENT_BITS_CONTROL_BYTES这个宏愣了几秒一个事件组说白了就是一堆位为什么高 8 位还要被内核自己吃掉后来把xEventGroupSetBits的循环读完才明白那 8 位根本不是给用户用的而是内核用来记录这个等待者是在等全部位还是任意位满足条件后要不要顺手清位这类控制信息的。搞懂这一点事件组Event Group就从会调 API变成了知道它在干什么。这篇东西写给正在啃 FreeRTOS 基础和源码的人。如果你手上是 STM32又习惯用 STM32CubeMX 起工程那节奏会更快——CubeMX 能帮你把事件组对象、堆、任务骨架都生成出来你省下的时间正好用来读源码。我给自己定过一个两周的速通计划核心思路不是把 FreeRTOS 的每个模块平均用力而是挑一个麻雀虽小五脏俱全的模块当锚点把任务阻塞、列表管理、调度器唤醒、临界区、中断延迟处理这些东西一次性串起来。事件组就是我认为最合适的那个锚点它比队列简单但比信号量多出多对多同步和位运算逻辑这两层源代码不到两千行读起来有成就感。下面这份内容就是我当时那两周的完整复盘包括 CubeMX 里的配置取舍、API 参数背后的行为差异、event_groups.c的逐段拆解、一个能直接跑的三任务栅栏同步例子以及几个我在实际调试中翻过车的地方。基础一般的可以照着抄配置和代码有一定经验的可以直接跳到源码那两节。1. 我把事件组当成两周速通 FreeRTOS 的锚点1.1 从任务切换开始啃多半会卡在第三天大部分人学 FreeRTOS 的路径是这样的先看任务创建再看调度器然后一头扎进tasks.c里想搞明白 PendSV 和 SysTick 是怎么切栈的。这个顺序不能说错但体验很差。原因是任务切换这条线上有太多必须同时理解才能理解的东西栈帧布局、PSP/MSP 的切换、xPortPendSVHandler的汇编、就绪列表的优先级位图、tick 中断里的补时逻辑。任何一个环节卡住后面全是黑盒读着读着就变成抄注释。我后来换了个思路先找一个模块它用到了调度器的核心能力但自身逻辑足够短短到能在一两天内完整读完。这样你能用调用方的视角反推被调用方在做什么而不是硬啃。队列、信号量、事件组、任务通知都符合这个条件而这几个里我推荐事件组因为它同时踩中了三个机制任务的阻塞与唤醒、列表List数据结构的实际用法、以及从中断里做延迟处理的那条链路。1.2 事件组在这套体系里到底解决什么问题用一句话描述事件组它是一组二进制标志位bit任务可以等这些位里的任意一个或全部被置起来可以设置满足条件后自动清零也可以让多个任务同时等同一批位。听起来和信号量像差别在于语义。计数信号量解决的是有多少个资源可用它的值是一个数字事件组解决的是哪些条件已经具备它的值是一组不重复的标志。举个我实际做过的例子一块板子上有三个任务一个负责读 ADC一个负责做滤波一个负责往外发数据。这三个任务必须一轮一轮地推进谁都不许跑太快——这就是典型的栅栏同步。用三个信号量也能凑出来但你要小心地做你发我、我发他的连环释放一旦某个任务超时退出链路就断了恢复起来很麻烦。用事件组的话每个任务在自己那一位上置位然后等三位全齐逻辑上是平的谁掉队一眼就能看出来。还有一个常被忽略的用法事件组可以做一次性开关。比如系统初始化完成、网络模块就绪、传感器校准完毕这些条件各自占一位业务任务用xEventGroupWaitBits等任意一位或者全部位条件不具备就老实阻塞不占用 CPU。这种多条件汇聚的场景用信号量做会变成一堆xSemaphoreTake串在一起读代码的人根本看不出业务意图。1.3 两周时间怎么切一份可执行的日程表时间分配上我不建议按模块等分而是按理解深度分层前一周把调用层的 API 用熟同时把常用的数据结构过一遍后一周专门读源码从短文件向长文件推进。下面这张表是我自己跑过一遍之后修正过的版本比最初那版现实很多。时间主线任务落地产出第 1-2 天用 STM32CubeMX 建工程跑通第一个任务串口能打印一份能编译、能下载、能看日志的模板工程第 3 天任务状态、优先级、vTaskDelay与阻塞唤醒三个不同优先级的任务交替打印能解释顺序第 4 天队列收发阻塞超时一个生产者两个消费者的小例子第 5 天二值信号量与计数信号量中断通知任务的标准写法第 6 天互斥量与优先级继承复现一次优先级翻转再用互斥量解决第 7 天事件组API 全部过一遍本节第 3 节的验证代码第 8-9 天读list.c和event_groups.c手写注释版能画出等待链表的结构第 10 天tasks.c里的阻塞/唤醒路径跟着xEventGroupSetBits走一遍调用栈第 11 天临界区、FromISR系列、延迟中断处理搞明白守护任务的角色第 12 天heap_1 到 heap_5 的差异算清楚自己的工程还剩多少堆第 13-14 天三任务栅栏同步综合例程 复盘一份可复现的工程和调试记录这张表的关键在于第 7 天之后才开始读源码。先用一周把感觉建立起来读源码时脑子里有具体场景效率完全不一样——你会知道某个判断分支是为了应对哪种调用方式而不是机械地逐行翻译。2. 用 STM32CubeMX 把事件组生出来2.1 接口选择CMSIS 封装层与原生 API 的分叉口在 CubeMX 里打开Middleware and Software Packs勾上FREERTOS第一个要做的决定是 Interface 选什么CMSIS_V1、CMSIS_V2还是保持原生。这个选择会影响后面所有代码的写法而且改起来不像改个宏那么轻松。选 CMSIS_V1 或 V2CubeMX 会生成一层封装事件组对应的接口是osEventFlagsCreate/osEventFlagsSet/osEventFlagsWait这一套参数语义被重新包装过比如等待任意位或全部位是通过options里的标志位表达的而不是原生 API 里那个xWaitForAllBits参数。选原生接口的话生成的骨架更薄你直接#include event_groups.h用xEventGroupCreate、xEventGroupSetBits、xEventGroupWaitBits这些名字。我的建议分两种情况。如果你是在做产品、团队里有人只熟 CMSIS 那一套那就跟着 CMSIS 走一致性比什么都重要。如果你是拿这个工程来学习、目标是看懂 FreeRTOS 源码那就用原生 API——因为源码里的函数名和文档里的名字是一一对应的你在 CubeMX 生成的freertos.c里看到的调用直接就能去event_groups.c里搜到定义中间的映射层次少一层读起来省心。还有一点要留意走 CMSIS-RTOS v2 的osEventFlags接口时业务位最好从 bit0 开始往低排不要去碰最高那几位官方文档里对高位有保留说明具体到你的版本以头文件注释为准。2.2 Events 标签页里的字段与生成结果对照配置界面里的Events标签页就是用来建事件组的。点Add给它起个名字比如evtSysReady然后要注意下面这几个概念因为它们决定了生成代码长什么样名字CubeMX 会用它生成变量名和初始化函数的参数命名上建议带上前缀区分类型比如事件组用evt开头队列用que开头信号量用sem开头。这在工程大起来之后能省很多翻文件的时间。分配方式动态分配走堆静态分配需要提前打开configSUPPORT_STATIC_ALLOCATION并且你要自己提供静态存储区。学习阶段直接用动态分配省事。创建时机CubeMX 生成的初始化代码会放在MX_FREERTOS_Init()里在调度器启动之前执行。也就是说所有任务被创建出来的时候事件组已经就绪了你不用担心某个任务先跑起来然后取到空指针。生成完之后事件组的句柄通常会出现在Core/Src/freertos.c的头部或者在 CMSIS 模式下是osEventFlagsId_t类型。这里有个小坑我踩过句柄是文件作用域的静态变量如果你想在别的.c文件里访问得自己在头文件里加extern声明或者写一个访问函数。别直接在外面extern猜类型接口一变类型就错了。2.3 FreeRTOSConfig.h 被覆盖这件事以及三种应对方案CubeMX 生成FreeRTOSConfig.h是好事也是麻烦事。好处是所有内核开关集中在一处坏处是你手工加的宏在下次重新生成代码时会被抹掉。我见过有人在里面加了一堆自定义宏结果改了个引脚重新生成全没了排查了半天才发现。三种应对方式我按推荐度排第一种用生成文件里的USER CODE区如果你的 CubeMX 版本在FreeRTOSConfig.h里预留了的话把自定义内容塞在BEGIN和END之间。这是最省心的缺点是只能加内容不能改 CubeMX 已经写好的那部分。第二种新建一个FreeRTOSConfig_app.h在FreeRTOSConfig.h的USER CODE区里把它#include进来自己的宏、断言、调试开关全放这个文件。这样即使FreeRTOSConfig.h被覆盖被覆盖的也只是那一行 include重新加回来就行。第三种等你对移植流程熟了之后干脆把 FreeRTOS 源码作为普通源文件加进工程脱离 CubeMX 的生成体系配置完全自己掌控。这条路灵活度最高但你要自己处理stm32f1xx_it.c里的SVC_Handler、PendSV_Handler、SysTick_Handler的宏定义冲突属于进阶操作。注意切换接口、修改内核开关之后一定要执行一次清理并重新编译只增量编译经常会出现符号缺失或者旧目标文件残留的问题。2.4 不开 CubeMX 也要会的手工移植要点CubeMX 帮你省掉的步骤你最好知道一遍否则出了问题连查的方向都没有。手工移植 FreeRTOS 到 Cortex-M3 上动作其实就这几件把Source目录下所有.c加进工程把portable/RVDSKeil或portable/GCC/ARM_CM3加进来加好头文件路径准备一份FreeRTOSConfig.h然后处理三个异常处理函数的归属。SysTick 被 FreeRTOS 接管之后你自己的 HAL 延时不能再依赖它HAL_Delay会不准甚至卡死这个坑几乎所有新手都踩过。正确做法是用vTaskDelay替代任务里的延时初始化阶段如果需要短延时用HAL_Delay但要在调度器启动之前调用。3. 事件组 API 逐个拆参数背后的行为差异3.1xEventGroupWaitBits的四个参数分别在管什么函数原型是EventBits_t xEventGroupWaitBits(EventGroupHandle_t xEventGroup, const EventBits_t uxBitsToWaitFor, const BaseType_t xClearOnExit, const BaseType_t xWaitForAllBits, TickType_t xTicksToWait)。第二个参数是要等的位掩码这个好理解。真正需要想清楚的是后面三个。xWaitForAllBits决定与还是或。传pdTRUE表示掩码里的位必须全部为 1 才唤醒传pdFALSE表示任意一位为 1 就唤醒。这一步的选择取决于业务语义你要是做系统所有子系统就绪才能启动业务那是与你要是做任何一个报警源出现就要响应那是或。xClearOnExit决定条件满足后要不要把等的那些位清零。传pdTRUE的时候清零动作是在返回之前由内核完成的而且是原子的你不需要再手动调xEventGroupClearBits。这一点很关键因为如果你手动清就有可能在条件满足和清位之间插入别的任务或者中断造成状态不一致。xTicksToWait是超时。这里有两个细节值得记牢一是传portMAX_DELAY会一直等下去但前提是INCLUDE_vTaskSuspend打开否则它只是一个很大的数二是传 0 表示只检查一次当前状态不阻塞这在中断上下文或者时间敏感的巡检逻辑里很有用。顺手把其余几个常用 API 的行为差异列个表读代码的时候对照着看会清楚很多。API能等吗会改位吗中断里能用吗典型场景xEventGroupSetBits不等置位并唤醒等待者不行任务里通报条件达成xEventGroupSetBitsFromISR不等延迟置位可以中断里通报事件xEventGroupWaitBits可以可选清零不行等一个或多个条件xEventGroupClearBits不等清零有对应版本复位状态xEventGroupSync可以先置位后清位不行多任务栅栏同步xEventGroupGetBits不等不改有对应版本调试与状态查询3.2 返回值不是你想要的位这个细节坑过很多人xEventGroupSetBits返回的是条件满足那一刻、执行清零之前的事件组位值。为什么强调清零之前因为如果你设了xClearOnExit位在返回前就被清掉了但返回值里仍然带着它们。这个设计是有意的调用方需要知道当时到底有哪些位是 1用返回值去做判断比再查一次状态更可靠。由此引出一个实际的坑。很多人会这样写EventBits_t bits xEventGroupWaitBits(grp, EVT_A | EVT_B, pdTRUE, pdFALSE, pdMS_TO_TICKS(100)); if( bits EVT_A ) { /* 认为是 A 触发的 */ }如果 A 和 B 同时被置起来返回值会是EVT_A | EVT_B那个判断直接失效。正确写法是用按位与if( ( bits EVT_A ) ! 0 )。这个错误在实验室里不一定暴露因为事件往往不同时发生但到了现场多路信号一叠加就出问题了。还有一个更隐蔽的场景想判断是不是超时了不能只靠返回值因为超时返回的是当时的事件位值可能是 0也可能是某个别的位。稳妥的做法是判断我等的位是否齐了齐了说明等到了没齐说明超时。3.3xEventGroupSync的失步问题与循环里的正确姿势xEventGroupSync做的是栅栏同步先把自己负责的位置起来然后等所有人都在场。内核在最后一个位被设置的那一刻会把所有等待者唤醒并把这次同步涉及的位清掉。它的实现细节里有两个必须知道的东西。第一唤醒是按优先级来的不是严格的先来后到。内核在设置位的时候遍历等待链表把匹配的项取下来放回就绪列表至于谁先真正跑起来由调度器按优先级决定。这意味着如果几个参与同步的任务优先级差得很多高优先级那个可以在低优先级任务还没从等待里返回的时候就跑完一轮又回来了虽然位的清零是原子的、不会串轮但你的业务逻辑如果假设同步完成后所有任务同时开始干活那这个假设不成立。第二超时退出时自己置的那一位不会被清掉它会留在事件组里。这在循环里就是一颗雷这一轮你没等到人超时退出了但你置的位还在下一轮别人置位的时候条件会提前满足看起来同步成功了其实有一个任务根本没参与。我的处理方式是每次xEventGroupSync返回后都显式检查掩码EventBits_t bits xEventGroupSync(evt_grp, EVT_ME, EVT_ALL, pdMS_TO_TICKS(200)); if( ( bits EVT_ALL ) ! EVT_ALL ) { /* 有人掉队先记账再决定是复位链路还是让系统进安全态 */ sync_fail_cnt; }这里还有个尺度问题超时之后要不要顺手把残留位清掉如果还有其他任务正在等这批位你清位会把它们的状态搞乱。稳妥的做法是让所有参与同步的任务都走同一条失败处理分支比如统一回到初始化状态重新入同步而不是某一个任务擅自去清别人的位。3.4 中断里的置位为什么要绕一圈xEventGroupSetBitsFromISR不是直接把位置起来就完事它做的是挂一条消息到定时器守护任务也叫 RTOS 守护任务的命令队列里真正的置位动作是守护任务稍后执行的。所以它的返回值只代表消息有没有成功入队不代表位已经置上了。入队失败通常是因为模块没使能或者命令队列满了。既然绕了一圈你在配置上就要满足条件需要在FreeRTOSConfig.h里打开INCLUDE_xTimerPendFunctionCall某些版本还要configUSE_TRACE_FACILITY具体以你手上那份源码的#if判断为准。更要紧的是守护任务的优先级configTIMER_TASK_PRIORITY。如果这个优先级比某个长期占着 CPU 的任务低那这个置位就要等很久才被处理表现出来就是中断明明来了任务半天没反应。我在一个项目里就吃过这个亏把守护任务优先级从默认值调上去之后响应延迟从几十毫秒掉到了几百微秒。4. 打开event_groups.c事件组的源码其实只做三件事4.1EventGroup_t的结构与那 8 个控制位先看数据结构定义非常精简typedef struct xEventGroupDefinition { EventBits_t uxEventBits; List_t xTasksWaitingForBits; } EventGroup_t;uxEventBits就是那组标志位xTasksWaitingForBits是等在这个事件组上的任务链表。真正的门道在EventBits_t的定义和那几个控制位宏上#define eventCLEAR_EVENTS_ON_EXIT_BIT 0x01000000UL #define eventUNBLOCKED_DUE_TO_BIT_SET 0x02000000UL #define eventWAIT_FOR_ALL_BITS 0x04000000UL #define eventEVENT_BITS_CONTROL_BYTES 0xff000000UL当configUSE_16_BIT_TICKS为 0 时EventBits_t等价于 32 位的无符号数低 24 位留给用户做标志位高 8 位被内核拿去做控制。为什么这么设计因为等待链表里每个挂起项都需要记录这个等待者在等哪些位等全部还是任意满足后要不要清位内核把这些信息直接塞进了链表项的值字段里省掉了额外的结构体分配。这也是为什么用户最多只有 24 个位可用——不是抠门是位运算的代价。一旦你把configUSE_16_BIT_TICKS设成 1EventBits_t变成 16 位可用位就只剩 8 个。这个宏一般是为了兼容 16 位架构的移植层设计的在 STM32 上完全没有必要打开它而且打开之后 tick 计数范围也变小很容易溢出。我建议你在FreeRTOSConfig.h里确认它是 0。4.2 等待链表为什么是无序的内核在把任务挂到等待链表上时用的是vTaskPlaceOnUnorderedEventList而不是带优先级排序的那个版本。函数名里的 Unordered 就是这个意思任务被简单地插到链表尾部不按优先级排序。为什么不排序因为事件组的唤醒逻辑不是取链表中优先级最高的那个而是把链表中所有满足条件的项一次性摘下来。既然要遍历整个链表排序就没有意义了反而白白增加插入时的开销。这一步的设计很值得琢磨——它说明数据结构的组织方式永远服务于访问模式而不是为了看起来整齐。另一个细节是整个遍历和摘链的过程包在vTaskSuspendAll()和xTaskResumeAll()之间也就是挂起调度器但不开中断。这样一来链表操作不需要额外的临界区保护同时又能保证位操作的原子性。代价是在这段时间里中断仍然可以进来如果中断里也去操作同一个事件组走的是挂消息那条路不会破坏链表结构。这个设计思路在 FreeRTOS 里反复出现理解了它对读其他模块也有帮助。4.3xEventGroupSetBits的分解循环逐行读这个函数是事件组的心脏。它的流程是这样断言uxBitsToSet非零且不包含控制位。这两条断言很有价值因为传 0 进去是逻辑错误传了控制位则会污染内部状态。挂起调度器把uxBitsToSet用按位或的方式并进uxEventBits。从等待链表头开始遍历对每一项做三件事把链表项的值拆成控制位和等待位两部分根据控制位判断是等任意还是等全部检查条件是否满足满足的话把这一项从等待链表摘下来连同当前的事件位值一起交给xTaskRemoveFromUnorderedEventList同时把这一项里满足后要清的位累加到uxBitsToClear。遍历结束后统一清零uxBitsToClear恢复调度器。第 4 步的统一清零是重点。它保证一次置位操作引发的所有清位动作是批量的、原子的不会出现清了一部分、另一个任务又看到了半清状态的情况。另外注意第 3 步传给唤醒任务的返回值是uxEventBits | eventUNBLOCKED_DUE_TO_BIT_SET那个eventUNBLOCKED_DUE_TO_BIT_SET就是给xEventGroupWaitBits用来判断我是被位唤醒的还是超时退出的。顺着这条线xEventGroupWaitBits的逻辑就很好懂了调用时先检查当前位是否已经满足条件满足就直接返回同时做清位不满足就把自己的等待信息构造好挂到等待链表上去阻塞超时时间到了会自动从链表上摘下来。掌握这两个函数之后事件组的其余 API 基本都是在这套机制上的组合xEventGroupSync也无非是先置位再按等全部的方式等一次。4.4 一个事件组到底吃掉多少堆这个问题在实际项目里很重要尤其是堆本来就紧张的时候。算一遍你就清楚了以 32 位机、关闭链表完整性校验为例组成大小EventBits_t uxEventBits4 字节List_t计数器 4 索引指针 4 尾部迷你项 1624 字节结构体合计28 字节heap_4 分配的块头开销8 字节单次创建实际消耗约 36 字节看起来不多但你要乘以创建次数再加上每个任务自己的栈和 TCB。任务栈在 STM32F103 这种只有 20KB SRAM 的片子上特别致命configMINIMAL_STACK_SIZE在 Cortex-M3 上通常按字算默认 128 就是 512 字节起步加上局部变量和函数调用深度一个带printf的任务栈给到 1KB 都不算宽裕。我的习惯是在heap_4之外额外开一个空闲堆监控任务定期打印xPortGetFreeHeapSize()和xPortGetMinimumEverFreeHeapSize()后者记录历史最低水位线比当前值有用得多——它能告诉你最坏情况下还剩多少而不是此刻还剩多少。5. 一个能跑的实战场景三任务栅栏同步加中断置位5.1 需求拆解与任务划分为了把上面这些概念串起来我做了一个小例程模拟一条数据流水线采集、滤波、上报三个任务必须一轮一轮地同步推进另外加一个按键中断按下去之后让系统进入需要重新校准的状态。整条链路上事件组承担两个职责一是三位栅栏同步二是中断事件的一次性通知。任务划分上要注意参与同步的任务里不能有阻塞操作。如果采集任务里塞了一个等串口的阻塞调用它就会长期不在同步点上其他两个任务只能靠超时兜底整个节拍就崩了。我的做法是把耗时操作拆成非阻塞的准备工作和同步点同步点之后再干重活。5.2 代码骨架先定义位集中放一个头文件避免位定义散落在各处这是维护成本最低的做法/* event_demo.h */ #define EVT_SENSOR_DONE ( 1UL 0 ) #define EVT_FILTER_DONE ( 1UL 1 ) #define EVT_UPLOAD_DONE ( 1UL 2 ) #define EVT_NEED_CALIB ( 1UL 3 ) #define EVT_ALL_SYNC ( EVT_SENSOR_DONE | EVT_FILTER_DONE | EVT_UPLOAD_DONE )三个同步任务的骨架几乎一样只是环节不同。以采集任务为例void SensorTask(void *argument) { EventBits_t bits; for( ;; ) { sample_sensor(g_raw); /* 非阻塞纯读写耗时可控 */ bits xEventGroupSync(evt_grp, EVT_SENSOR_DONE, EVT_ALL_SYNC, pdMS_TO_TICKS(200)); if( ( bits EVT_ALL_SYNC ) ! EVT_ALL_SYNC ) { sync_fail_cnt; /* 掉队处理交给监控任务统一决策不在这里清别人的位 */ continue; } /* 同步成功做本轮收尾工作 */ log_round(g_round); } }按键中断那条线单独处理中断里只负责置位不做任何耗时动作void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { BaseType_t xHigherPriorityTaskWoken pdFALSE; if( GPIO_Pin KEY_Pin ) { xEventGroupSetBitsFromISR(evt_grp, EVT_NEED_CALIB, xHigherPriorityTaskWoken); portYIELD_FROM_ISR( xHigherPriorityTaskWoken ); } }负责响应的任务用xEventGroupWaitBits等这一位并且设置xClearOnExit为pdTRUE这样一次校准请求只会被消费一次void MonitorTask(void *argument) { for( ;; ) { EventBits_t bits xEventGroupWaitBits(evt_grp, EVT_NEED_CALIB, pdTRUE, pdTRUE, portMAX_DELAY); if( ( bits EVT_NEED_CALIB ) ! 0 ) { do_calibration(); } } }如果你用的是 CMSIS-RTOS v2 的那套接口同样的逻辑会写成osEventFlagsWait(ef_id, EVT_NEED_CALIB, osFlagsWaitAll, osWaitForever)options里的osFlagsNoClear不设就相当于自动清位。语义是一致的换汤不换药。5.3 怎么验证它真的在工作同步这种东西光看现象跑起来了是不够的你必须能证明每一轮三个任务都参与了。我在调试阶段加了三样东西一是让每个任务在同步成功后打印自己的轮次编号三个编号必须总是相等。只要出现不相等就说明有任务在某轮掉队了而超时返回值的检查逻辑把这一轮吞掉了。二是用xEventGroupGetBits在监控任务里周期性地读一下当前位值。正常运行时这个值大部分时间应该是 0因为同步完成就清零了如果长期停着某一位说明那个任务卡住了。三是打开configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS用vTaskList打印任务状态。哪个任务卡在阻塞态、哪个在就绪态跑得飞起一眼就能看出来。这个手段比打断点高效得多因为断点会破坏实时性有些时序相关的问题一打断点就消失了。5.4 换成信号量或者队列会不会更好这个问题值得认真回答因为很多人学完事件组就什么都想用它。我的判断标准是这样如果信息本身有数量含义需要用计数信号量或者队列。事件组不能计数同一个位置两次和置一次效果一样。你的中断如果可能在一轮之内来两次用事件组就会丢事件。如果要在任务之间传数据用队列。事件组只传状态不传内容。用位编码信息是可行的但一旦超过三四个含义代码就没法看了。如果只是条件具备通知一声而且接收方可能不止一个事件组最合适。它天生支持多播一个置位能把所有等待者一起唤醒这是队列和信号量做不到的——队列的一条消息只能被一个接收者拿走。我自己的经验是事件组用来做启动条件检查和多任务节拍对齐这两个场景最顺手其他场景老实排队列和信号量。6. 踩坑集中营事件组最容易翻车的几种情况6.1 一轮排查第二轮等待为什么立刻返回现象是这样的工程跑起来第一轮同步完全正常从第二轮开始某个任务的xEventGroupWaitBits几乎立刻返回日志里看不到任何超时提示。这个现象很有迷惑性因为立刻返回通常意味着条件已经满足而你认为位应该已经被清了。我的排查链路是这样的第一步确认清位动作是否真的发生。检查调用参数发现等待时用的xClearOnExit是pdFALSE。改代码不方便先在监控任务里打印xEventGroupGetBits(evt_grp)果然第一轮结束后那几位一直是 1。第二步考虑另一个可能是不是超时退出后残留的位。把超时值从portMAX_DELAY改成 200ms人为制造掉队观察残留位是否出现。结果是超时退出后自己置的位确实留在那里第二轮别人一置位就假同步了。这是两个独立问题叠在一起。第三步修复。第一个问题直接把xClearOnExit改成pdTRUE第二个问题在同步返回值检查里加分支一旦发现我等的位不齐就进入统一的异常处理而不是继续往下走。提示判断是不是超时最可靠的方式是检查返回值里你等的位是否齐全而不是去猜返回值是不是 0。返回值可能是别的位也可能是部分位。6.2configUSE_16_BIT_TICKS打开后位不够用这个坑的特征是编译能过运行到某一位就不对。比如你用了1UL 10这个位在 16 位 tick 配置下EventBits_t只有低 8 位可用你的位直接被算进了控制位区域行为完全不可预测。排查方法很直接在FreeRTOSConfig.h里搜configUSE_16_BIT_TICKS确认是 0。如果确实是 0 但现象还在那就检查是不是位定义写错了比如用了1 24这种左移在 32 位无符号数上刚好碰到控制位区。位定义一律用1UL nn不超过 23这是硬约束。6.3 中断置位后的响应延迟大得离谱现象是按键按下去理应立即响应的任务要等上几十毫秒才动。第一反应通常是中断没进去或者中断优先级配错了但打断点一看中断函数执行得很快。定位过程分两步走。第一步确认xEventGroupSetBitsFromISR走的是延迟处理路径置位动作由守护任务完成所以延迟取决于守护任务的调度时机。第二步打印各任务的执行情况发现有一个优先级比守护任务高的任务在长时间忙等守护任务根本抢不到 CPU。修复就两件事把configTIMER_TASK_PRIORITY调到比所有业务任务都高或者至少高过那些会长时间占用 CPU 的任务同时把忙等的任务改造掉用阻塞延时替代轮询。前者解决优先级关系后者解决根源两个都要做。顺便说一句守护任务栈的大小configTIMER_TASK_STACK_DEPTH如果配得太小在命令队列积压的时候可能直接溢出这类问题在调试器里表现为莫名其妙的 HardFault很难和事件组联系起来。我一般会把configCHECK_FOR_STACK_OVERFLOW设成 2并实现vApplicationStackOverflowHook让栈溢出能被及时发现。6.4 把事件组当数据通道用我见过有人用 8 个位编码一个传感器编号然后把编号从任务 A 传给任务 B。这个做法在小规模演示里能跑但一旦并发起来就会出问题如果 A 连续两次置位不同的组合B 可能只看到最后一次的状态中间那次彻底丢了因为事件组没有队列语义。判断标准很简单信息需要排队、需要保序、需要计数就用队列只是表达某个状态成立才用事件组。6.5 删除事件组时还有任务在等vEventGroupDelete在还有人等着的时候调用会把这些任务一并处理掉这在运行时看起来没问题但如果你之后再去访问那些任务的句柄或者期望它们继续执行就会出问题。更常见的情况是删除了之后又重新创建新句柄分配到了同一块内存老任务还在等旧地址行为变得完全不可预测。我的做法是给事件组加一个引用计数或者用一个独立的系统状态位来保证删除动作发生在所有相关任务都进入挂起态之后。听起来麻烦但比事后查一个随机崩溃要省时间得多。6.6 优先级不平等的任务硬用栅栏前面提过xEventGroupSync的唤醒是按优先级来的。如果参与同步的任务优先级差异很大而且某个任务的高优先级还带着长耗时的循环那低优先级任务可能长时间得不到执行栅栏的一轮周期被拉长看起来像是同步变慢了。这种时候要么把参与同步的任务优先级拉平要么把重活挪到同步点之后由独立的低优先级任务去做。设计阶段想清楚比运行起来之后调参数要有效得多。7. 啃完event_groups.c之后我建议顺着这几条线继续读第一遍读源码不要贪多。我从事件组这条线延伸出去最先去的是list.c和list.h因为等待链表的所有操作都在那里。把vListInsert、vListInsertEnd、uxListRemove三个函数读透你会发现 FreeRTOS 里所有需要把阻塞任务挂起来的地方都是同一个套路队列、信号量、事件组、任务通知全都复用这套链表只是挂进去的信息不同。这一步走完读queue.c会轻松很多。第二条线是tasks.c里的阻塞与唤醒。重点看vTaskPlaceOnUnorderedEventList和xTaskRemoveFromUnorderedEventList的实现搞清楚任务挂起时状态怎么变、阻塞时间怎么记账、唤醒时怎么插回就绪列表。顺着再看xTaskResumeAll里的补时逻辑——挂起调度器期间 tick 中断照常发生恢复时要补上漏掉的延时这段代码不长但很精妙。第三条线是中断相关的部分。portYIELD_FROM_ISR展开之后是什么、xHigherPriorityTaskWoken这个变量为什么必须初始化成pdFALSE、为什么它只有在中断返回时才起作用这些问题在读过一次移植层的汇编之后会变得非常清晰。顺便留意一下 Cortex-M3 上PendSV_Handler和SysTick_Handler的分工这是理解抢占式调度怎么落地的关键。最后一条线是内存管理。把heap_4的分配和释放逻辑读懂再回头算一遍你工程里每个对象吃掉的堆空间你对为什么任务栈不能随便给这件事会有完全不同的感受。我自己的习惯是在项目里保留一个可供调试的堆水位打印任务上线前再从发布版本里裁掉——在开发阶段多打印一行日志往往能省掉半天定位时间。
返回列表