ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FreeRTOS事件组源码剖析与两周学习路线:从任务间通信到STM32实战

FreeRTOS事件组源码剖析与两周学习路线:从任务间通信到STM32实战 搞嵌入式开发的朋友对FreeRTOS这三个字应该都不陌生。它是目前市场占有率最高的轻量级实时操作系统内核小到传感器节点、电机驱动板大到复杂的物联网网关都能看到它的影子。而事件组作为FreeRTOS任务间通信的重要机制之一在实际工程中出现频率极高。它的本质就是一组二进制位每个位可以独立地表示某个事件是否发生多个任务可以对这些位进行“与”“或”等逻辑操作从而实现精准的多事件同步。这次想分享的是我整理的一套两周学习路线核心路径是先跑通FreeRTOS基础再顺着源码把事件组彻底吃透最后用STM32CubeMX快速搭建一个可运行的工程。这篇博文会完整走一遍这个流程适合刚接触RTOS、想系统入门又想看源码的读者也适合已经在用FreeRTOS但总感觉对事件机制理解不透的开发者。我会把原理、源码路径、配置方法和坑点全部串起来工程量不小但跟着走下来你对FreeRTOS的掌握会明显比那些只调API的人深一个层次。1. 学习路线设计为什么把事件组当源码切入口先聊一个很多人会问的问题FreeRTOS源码那么多task.c、queue.c、timers.c、event_groups.c光文件名就快十个为什么我偏偏建议从事件组切入这不是随手指的背后有明确的教学逻辑。1.1 事件组在FreeRTOS机制里的特殊位置事件组Event Group和队列、信号量并列是FreeRTOS任务间通信的三大手段。队列适合搬运数据信号量适合做资源互斥或简单同步而事件组解决的是“多条件组合触发”的问题——比如一个任务要等三个传感器都出数据后再开始处理或者要等网络和显示两个子模块都就绪后才进入工作状态。从源码阅读的角度看event_groups.c是这堆内核文件里学习曲线最友好的。它没有队列那么长的链表逻辑也没有任务调度器那么复杂的上下文切换核心结构体就一个常驻的函数也就十来个。但麻雀虽小五脏俱全事件组的实现里有任务阻塞、有临界区保护、有仅唤醒匹配任务的精确唤醒逻辑这些恰恰是RTOS内核最核心的元素。把event_groups.c读透相当于用最小的代价把FreeRTOS的“内核感觉”建立起来再回头看task.c和queue.c会轻松很多。1.2 两周时间的分配节奏我的建议是严格按“基础-进阶-源码-实战”四步走不要跳步。第一周聚焦基础把任务创建、调度、队列、信号量这些跑熟不求深究每个源码细节先会用第二周进入事件组专题一半时间用CubeMX搭工程、跑实验一半时间拿源码对照实际行为去读。时间段学习内容产出物第1-2天任务创建/删除/挂起/恢复优先级配置跑通一个多任务点灯程序第3-4天队列收发数据理解阻塞等待串口数据在任务间流转第5-6天二值信号量、互斥量、递归互斥量用信号量解决共享资源冲突第7天复习整理笔记画任务关系图一张手绘任务通信结构图第8-9天事件组原理CubeMX创建事件组工程两个任务通过事件位同步第10-12天精读event_groups.c源码能讲清楚事件匹配与唤醒机制第13-14天事件组实战多条件触发超时处理一个完整的多传感数据同步例程第一周如果卡在某个地方比如任务优先级翻转不要死磕标记一下继续走。第二周读到源码时那些在第一周积累的“雾”很自然会散开。1.3 为什么选用STM32CubeMX作为起步工具说实话老工程师很多是直接手写寄存器启动FreeRTOS的那句话怎么说来着——“自己移植过一次才不会慌”。但现在是2024年了CubeMX的成熟度已经非常高它生成的FreeRTOS整合代码既规范又可靠足以支撑绝大多数真实产品。用CubeMX的好处是你不用一开始就被启动文件、堆栈设置、PendSV中断这些环境细节绊住能把全部精力放在学习RTOS本身的机制上。同时我强调一下CubeMX生成的代码是基于CMSIS-RTOS封装层的它调用的是osEventFlagsNew、osEventFlagsWait这类API而不是直接调用xEventGroupCreate。这个封装层非常薄本质上就是把FreeRTOS原生API包了一层弄清两者的对应关系你既能看到工程级别封装的样子又不会被它挡住源码视线。2. 第一周主线先把FreeRTOS核心机制跑熟这一节我会带你过一遍第一周必须拿下的基础点。不是教科书式的罗列而是从“运行视角”讲清楚每件事是怎么发生的。这些认知尤其是任务状态和阻塞机制是理解事件组源码的前提。2.1 任务调度本质谁在跑谁在等首先要理解一个朴素的事实在单核MCU上所谓“多任务”只是错觉任意时刻CPU只执行一个任务。FreeRTOS干的事情就是维护一张任务列表通过优先级和Tick中断来决定“当前该谁跑”。每个任务都有就绪、运行、阻塞、挂起四种状态。当一个任务调用vTaskDelay、等待信号量、等待队列消息时它会进入阻塞态也就是“自愿放弃CPU”。调度器从就绪列表里找最高优先级的任务来执行。Tick是系统的心跳比如配置成1ms一次每次Tick中断到来调度器就有机会重新计算“现在该哪个任务上场了”。这批认知固定下来后事件组的“等事件”就很好懂了——本质上就是让任务进入阻塞态在事件没有满足条件时不占CPU直到指定事件被置位后才回到就绪队列。2.2 队列和信号量事件组的“近亲”队列是FreeRTOS任务间传递数据的通道。它的实现方式是一个环形缓冲区加上等待列表如果队列满了发送方可以选择阻塞等待也可以选择永久等待如果队列空了接收方会阻塞等待接收。信号量本质上就是队列的一个特化——二值信号量是长度为1的队列互斥量是在此基础上增加了优先级继承机制。在学事件组之前我建议一定先动手跑几个队列和信号量的实验。因为事件组的源码里对待TCB任务控制块列表的操作、对阻塞超时的处理和队列的实现高度相似。你在第一周建立的“阻塞等待-唤醒-超时”心智模型在第二周会被反复调用。我的实验建议是用CubeMX建个项目创建两个发送任务和一个接收任务接收任务同时等两个队列的数据——如果只来一个就继续等两个都来才处理。做完这个练习后再去用事件组实现一模一样的逻辑你会立刻体会到事件组在“多条件组合等待”时的优雅代码可读性和实时性都提升一个档次。3. STM32CubeMX图形化配置从零搭建事件组工程现在进入实操部分。我假设你已经安装了STM32CubeMX和对应的MCU支持包IDE用Keil MDK或者STM32CubeIDE都可以。我下面以STM32F103C8T6为例这颗芯片便宜、资料多、群友人手一块非常适合做这个实验。3.1 创建基础工程并打开FreeRTOS中间件打开CubeMX后选择芯片型号STM32F103C8Tx开始配置工程。先做最基本的外设设置RCCHSE选择Crystal/Ceramic Resonator让系统使用外部高速晶振SYSDebug选择Serial Wire保证板载ST-Link能正常调试Clock Configuration把系统时钟设到72MHzAPB1分频后36MHz这是F103最标准的配置第三步是关键点击Middleware and Software Packs在列表里选中FREERTOS。界面会出现一个配置面板这里主要关注Interface。CMSIS_V1是旧版封装对应CMSIS-RTOS老规范API命名是osXXX前缀加不同参数CMSIS_V2是新的封装对应CMSISOS2规范API更规范支持动态内存分配等特性。现在新项目直接用CMSIS_V2就好它的osEventFlagsNew、osEventFlagsSet接口写起来更舒服API风格也比较接近标准POSIX思路。这里CLI等配置都不需要动默认参数足够我们的实验。3.2 添加事件组并创建两个任务在FreeRTOS的配置页里左侧有Tasks and Queues、Timers and Semaphores、Mutexes、Event Groups这些分类入口。Event Groups就是事件组添加的地方操作很简单点Add新建一个名字命名为EvtGroupTest即可。虽然CubeMX里可以什么都用图形化配置但事件组这个对象在初始化时并不需要指定事件位的个数——事件组内部的EventBits_t是一个16位或32位整数每一位独立对应一个事件一般用宏定义去约定哪个位代表哪件事。接着创建两个任务任务ATask_A优先级osPriorityNormal负责每100ms置位事件组的bit0任务BTask_Ack优先级osPriorityNormal负责等待bit0被置位后打印信息如果你还想看“多事件同步”效果可以再加一个任务C置位bit1然后让任务Ack等待bit0和bit1同时满足。这在源码阶段会展开说。创建完成后点击Generate Code生成工程用Keil打开。此时main.c里已经有系统初始化和任务创建代码了接下来只需要编写任务函数体。3.3 编写事件组的置位与等待代码在CMSIS_V2封装下事件组对应的API非常直白osEventFlagsId_t EvtGroupHandle; // 事件组句柄 // 某任务中置位事件bit0 osEventFlagsSet(EvtGroupHandle, 0x01); // 某任务中等待bit0被置位 uint32_t flags osEventFlagsWait(EvtGroupHandle, 0x01, osFlagsWaitAny, 1000);注意这里的细节osEventFlagsWait的第三个参数是等待模式osFlagsWaitAny表示任意一个事件出现就返回osFlagsWaitAll表示所有指定位都置位才返回第四个参数是超时时间单位是tick1000表示1秒如果tick配置为1ms。返回值表示实际获取到的事件位。如果超时返回值是osFlagsErrorTimeout。我在第一个练习里建议用osFlagsWaitAll因为事件组最典型的场景就是“多个条件全部满足”这个模式体现出的逻辑表达能力是信号量做不到的。完整代码大致如下#define EVT_BIT0 (1UL 0) #define EVT_BIT1 (1UL 1) void Task_A(void *argument) { for (;;) { osEventFlagsSet(EvtGroupHandle, EVT_BIT0); osDelay(100); } } void Task_C(void *argument) { for (;;) { osEventFlagsSet(EvtGroupHandle, EVT_BIT1); osDelay(200); } } void Task_Ack(void *argument) { uint32_t flags; for (;;) { flags osEventFlagsWait(EvtGroupHandle, EVT_BIT0 | EVT_BIT1, osFlagsWaitAll, osWaitForever); if (flags (EVT_BIT0 | EVT_BIT1)) { // 两个事件都到位执行真正的工作 } } }这个例程跑起来后Task_Ack只有等到Task_A和Task_C都置过位之后才会执行一次。第一次跑通这个逻辑你就已经掌握事件组最核心的用法了。4. 深度剖析event_groups.c从数据结构到任务唤醒CubeMX帮你把API用起来了但这只是第一步。要想真正掌握事件组必须在源码层面搞清楚两件事事件位是如何保存的等待事件的任务是如何被阻塞、被唤醒、被筛选的。下面进入event_groups.c的内部。4.1 核心结构体与事件位的存储方式打开event_groups.c先看结构体EventGroup_t它的定义在event_groups.h里typedef struct EventGroupDef_t { EventBits_t uxEventBits; List_t xTasksWaitingForBits; } EventGroup_t;就这么简单。EventBits_t就是之前提过的一个unsigned型整数在32位MCU上通常是32位宽但FreeRTOS留出最高8位做内部控制真正可给用户用的事件位一般是低24位。每一位的值是1或0表示事件有没有发生。xTasksWaitingForBits是一个链表保存的是“正在等待该事件组的任务”。当一个任务调用xEventGroupWaitBits时如果事件条件没满足它就被挂到这条链表上同时任务状态改成阻塞态。任务控制块TCB会被放入这个链表节点的pxContainer中这一步和队列中阻塞任务的处理手法完全一致。4.2 xEventGroupCreate如何初始化xEventGroupCreate做的事情极简分配一个EventGroup_t结构体动态内存方式需要heap_4.c等堆方案支持把uxEventBits初始化为0表示没有任何事件发生调用vListInitialise初始化任务等待链表如果返回值是NULL不用怀疑别的就是内存不够。前面我在准备章节特别强调过FreeRTOS的堆配置就是这个原因。在CubeMX里如果动态内存不足通常表现为xEventGroupCreate返回NULL系统直接断言失败。这时候把FreeRTOS heap size调大一点CubeMX里configTOTAL_HEAP_SIZE默认值可能偏小即可解决。4.3 xEventGroupSetBits的置位与精确唤醒流程事件置位走的是xEventGroupSetBits。它的流程是进入临界区禁止任务调度防止操作被中断打乱把uxEventBits按位或上你传入的bitsToSet遍历xTasksWaitingForBits链表上的每个任务逐个判断事件条件是否满足对满足条件的任务调用xTaskRemoveFromUnorderedEventList将其从等待链表中摘除并放入就绪链表退出临界区如果需要触发一次任务调度这个“逐个判断是否满足条件”的过程是事件组源码中最精妙的部分。FreeRTOS为了做到“精确唤醒”在每个等待任务的事件列表项中保存了两类信息一是要等待的事件位掩码如EVT_BIT0 | EVT_BIT1二是等待类型全等待还是任一等待。只有当实际置位结果对每个等待任务都满足其个人条件时任务才会被唤醒。这种设计比“每次置位就把所有等待任务全部唤醒”要高效得多也更能表达实际业务需求。你在真实产品中经常遇到的“这个中断只想唤醒特定任务”的需求事件组原生就支持。4.4 xEventGroupWaitBits的阻塞、超时与临界区保护再看等待侧。xEventGroupWaitBits的处理逻辑分两条路径如果事件条件已满足函数直接返回任务不阻塞如果条件不满足函数把当前任务挂到xTasksWaitingForBits链表上设置超时时间然后调用vTaskSuspendAll暂停调度器让出CPU关键点在于等待的模式。xEventGroupWaitBits的参数xWaitForAllBits用来区分“等待全部”和“等待任一”。在内部实现中等待全部对应条件表达式(uxCurrentEventBits xBitsToWaitFor) xBitsToWaitFor而等待任一只需要(uxCurrentEventBits xBitsToWaitFor) ! 0这两个条件就是事件组一切行为的总根源。源码里还有个细节是xTicksToWait传0的情况——此时任务会立即检查一次条件不满足就直接返回超时错误绝不让任务进入阻塞这个特性在中断服务函数里特别有用因为ISR不能阻塞任务。4.5 为什么事件组操作要关中断读event_groups.c的时候你会发现几乎每个API都被taskENTER_CRITICAL()和taskEXIT_CRITICAL()包起来。这背后的原因是任务等待链表的操作、事件位的新值和在置位函数里读取的任务状态都不允许被并发操作破坏。如果两个任务同时调用xEventGroupSetBits或者在置位过程中调度器切换了任务哪怕只有一个旧数据被覆盖系统的行为就会错乱。临界区保护也有代价进入临界区会屏蔽中断包括Tick定时器中断所以临界区里的代码不能太长。FreeRTOS的写法是精心控制的每个操作时间复杂度都是常数级不会因为等待任务多而线性膨胀过大。4.6 源码阅读实战xEventGroupSync的同步屏障读完上面的四个核心函数后建议接着读xEventGroupSync。这个函数名字叫“同步”实现的是一次性同步屏障所有任务都必须到达某个点才能一起往下走。它的行为本质上是“set和wait的合并”将一个事件位设为置位同时等待其他事件位满足条件。这个API非常适合多任务启动前对齐、多阶段流水线同步等场景。你会发现它用的底层辅助函数和单独调用set、wait时完全相同——这说明FreeRTOS的内核代码复用度很高读通一个API就能顺藤摸瓜理解五个API。5. 事件组实战案例多条件数据采集与执行原理看懂后肯定需要放到真实场景里验证一遍。我设计的这个案例非常贴近生产一个传感器采集系统有三路数据源——温湿度传感器、光照传感器、按键触发信号采集完成后经过简单的汇总处理再交给“上传任务”。5.1 业务逻辑与事件位规划我在代码开始定义三个事件位#define TASK_EVT_TEMP_HUMI (1UL 0) #define TASK_EVT_LIGHT (1UL 1) #define TASK_EVT_KEY (1UL 2)每一路采集任务完成后置位对应事件位上传任务必须等待三个位全部置位后才开始执行。这意味着任何一路数据没有就绪上传任务都不会被唤醒不会产生“半成品数据”。5.2 代码实现采集任务的结构都差不多以温湿度采集为例void vTask_TempHumi(void *argument) { for (;;) { // 读取温湿度传感器并缓存到全局共享变量 humi sht30_read_humidity(); temp sht30_read_temperature(); // 数据就绪置位事件位 osEventFlagsSet(EvtGroupHandle, TASK_EVT_TEMP_HUMI); // 本周期结束挂起等待下一轮 osDelay(500); } }光照任务和按键任务的逻辑一模一样只是事件位不同、采集周期不同。上传任务是核心void vTask_Upload(void *argument) { uint32_t flags; uint8_t upload_buf[64]; for (;;) { // 等待三个事件位全部置位永不超时 flags osEventFlagsWait(EvtGroupHandle, TASK_EVT_TEMP_HUMI | TASK_EVT_LIGHT | TASK_EVT_KEY, osFlagsWaitAll, osWaitForever); // 校验确实是全部到位 if ((flags (TASK_EVT_TEMP_HUMI | TASK_EVT_LIGHT | TASK_EVT_KEY)) (TASK_EVT_TEMP_HUMI | TASK_EVT_LIGHT | TASK_EVT_KEY)) { // 在这里打包上传数据 format_payload(upload_buf, sizeof(upload_buf)); uart_send_bytes(upload_buf, sizeof(upload_buf)); // 上传完成后清除本次使用过的事件位 osEventFlagsClear(EvtGroupHandle, TASK_EVT_TEMP_HUMI | TASK_EVT_LIGHT | TASK_EVT_KEY); } } }这段代码有一个细节容易踩坑我要重点说明osEventFlagsWait默认不会清除事件位。如果不清除下一次循环时事件位依然是置位状态上传任务会立即被唤醒完全没有等新数据。所以在上传完成后必须调用osEventFlagsClear把本轮已经消费的事件位清零。这是一个真实项目中非常常见的bug来源。5.3 加入超时保护后的健壮性设计上面的代码已经能跑但如果某个传感器故障了比如光照传感器I2C总线卡住那光照采集任务永远提不上来上传任务就会一直等下去系统相当于“停摆”了。在生产环境里这种情况绝不能接受。改进方法是给等待加一个超时并在超时后进行异常处理flags osEventFlagsWait(EvtGroupHandle, TASK_EVT_TEMP_HUMI | TASK_EVT_LIGHT | TASK_EVT_KEY, osFlagsWaitAll, 2000); /* 2秒超时 */ if (flags osFlagsErrorTimeout) { // 超时记录故障传感器尝试重启采集任务 handle_sensor_timeout(); continue; }有了超时保护即使某个数据源坏了系统至少能感知到异常不至于“死等”。这个模式在实际产品中极为常用——所有对多个条件的等待都要有超时或者说“看门狗”机制。5.4 从ISR中置位事件再补充一个高频场景在中断服务函数里置位事件。FreeRTOS对ISR专用API的命名规则是带“FromISR”后缀void EXTI0_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; // 清除EXTI中断标志等操作 // 在ISR里置位事件位并通知事件组 xEventGroupSetBitsFromISR(EvtGroupHandle, TASK_EVT_KEY, xHigherPriorityTaskWoken); // 如果有更高优先级的任务被唤醒切换任务 portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }因为在ISR里不能调用阻塞API所以一定使用FromISR版本。CubeMX的CMSIS_V2封装在这块也提供了配套接口例如osEventFlagsSet其实也能在ISR中用但最好还是用原生版本避免理解上的混乱。6. 常见问题与排查技巧实录事件组学习过程中我整理了一些反复出现的坑和对应的排查思路做成一个速查表后面再逐个展开。现象可能原因排查手段事件组创建失败返回NULL堆内存不足调大configTOTAL_HEAP_SIZE任务等不到事件事件位定义冲突或等待模式用错打印事件位当前值核对等待模式任务被反复立即唤醒忘记清零事件位检查是否有osEventFlagsClear中断中调用等待API导致崩溃ISR中调用了阻塞版本函数改成FromISR版本两个任务同时操作事件组数据错乱缺少临界区保护检查是否在任务中互斥访问共享区6.1 事件组创建返回NULL这几乎是最常见的问题而且容易误导人。xEventGroupCreate依赖FreeRTOS的动态内存分配也就是heap_x.c默认是heap_4。如果堆大小不够分配这个事件组结构体函数会返回NULL。CubeMX的默认堆大小可能较小特别是当你不可靠地增加任务、队列、互斥量之后。我建议新建工程时直接把configTOTAL_HEAP_SIZE设置成8KB以上跑通后再按实际内存占用优化。6.2 任务永远等不到事件位这种情况排查最有效的方法是看现场。在阻塞等待之前打印一次当前事件位值printf(event bits: 0x%08X\r\n, osEventFlagsGet(EvtGroupHandle));如果打印出来一直是0说明置位那边就没跑如果是0x01而你等的是0x02说明你的事件位定义和置位的对不上如果一直等于0x07但不满足那就要检查你是用osFlagsWaitAll还是osFlagsWaitAny这个参数搞反了会误导你很久。6.3 任务被立即唤醒完全不等事件这个坑我在实战章节已经提醒过等待函数返回后如果没有清除事件位下一轮循环条件依然成立任务自然被立即唤醒。程序行为看起来像是“没阻塞”其实是逻辑错误。要养成一个习惯每次消费完事件立即清除对应位。尤其是那些需要周期性采集的场景不清除的后果会特别明显——任务执行的频率远高于预期。6.4 调试事件组的小技巧除了printf之外我强烈建议学会在调试器里直接观察EventGroup_t结构体。在Keil或IDE的Watch窗口添加EvtGroup的地址展开结构体时能看到uxEventBits的实时值这个值以十六进制显示每一位对应一个事件。我在开发时还会在关键位置设置断点当某个任务被唤醒时查看等待链表里的节点数量变化能直观感受FreeRTOS对阻塞任务的管理方式。这种源码级别的调试体验比任何文档都能帮你建立系统性的认知。6.5 关于FreeRTOS版本差异的提醒最后提醒一下版本问题。FreeRTOS V10.x和旧版本V8.x及更低之间的API差异很大尤其在TCB结构体和触发唤醒的内部实现上。CubeMX生成的FreeRTOS内核版本取决于你安装的Pack版本目前主流是V10.x。如果你在网上查到的是V8源码看xEventGroupSetBits内部逻辑时会发现变量名和结构体都不一样不要慌核心思想是完全一致的——每个任务等待列表项里保存着期望的事件位和等待条件。抓住这一点任何版本的源码都能读通。写在最后带了两周时间把FreeRTOS从任务调度到事件组源码走完一整轮我的最大感受是真正的内功从来不在API怎么调上而在事件组那几行结构体和链表操作里。如果你能看到xTasksWaitingForBits这个链表、uxEventBits这个整型变量以及它们之间的匹配逻辑那么恭喜你你已经拿到了阅读所有RTOS内核源码的钥匙。我个人实际经验中还有一个小技巧想分享给正在学这块的朋友读完event_groups.c后别急着去看task.c的全量源码先去看vTaskDelay的实现。你会发现事件组里见过的列表操作、阻塞挂起、超时唤醒逻辑在那里再次出现。这种“熟悉的套路再次出现”的感觉就是你把FreeRTOS内核从“黑盒”变成“白盒”的时刻。到时你回头再看任何项目里的OS层代码都会觉得它不过如此。
返回列表