十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

FreeRTOS实战指南:任务调度、队列、信号量与内存排查要点

FreeRTOS实战指南:任务调度、队列、信号量与内存排查要点 FreeRTOS这个系列写到第三篇我想换个讲法。前两篇主要把任务创建、调度状态和基础配置梳理了一遍那是“跑起来”的阶段这一篇我要聊的是真正让FreeRTOS项目变得可靠的那些东西——任务之间怎么通信、中断里怎么调API、内存栈怎么排查以及多任务划分时那些一不留神就翻车的设计。很多朋友把Demo跑通后就开始往工程里堆功能结果发现任务动不动卡死、串口数据错乱、或者复位后查不出原因。这篇文章就是奔着这些问题去的适合已经建好工程、准备把FreeRTOS真正用到产品代码里的开发者。我尽量把每个主题都落到可操作层面不堆概念该给配置给配置该贴代码贴代码。遇到我踩过的坑我会直接点出来省得你再走一遍弯路。1. 任务调度的两个隐藏开关抢占优先级与时间片轮转1.1 优先级不是“越高越好”是“按实时性需求排”很多第一次从裸机切到FreeRTOS的人第一反应是给关键任务设最高优先级比如把LCD刷新、按键扫描全都提到最高结果系统反而变得不稳定。原因不复杂FreeRTOS的优先级数字越大越优先而调度器一旦发现有更高优先级的任务进入就绪态会立刻抢占当前任务。如果高优先级任务里塞了一堆不太紧急的逻辑CPU资源和中断响应就会被它拖住真正需要时刻响应的采集任务反而被耽误。我自己的习惯是先按“对时间敏感程度”分三档。比如一个STM32F103上的数据采集项目ADC采样和硬件保护逻辑放最高优先级串口/Modbus协议解析放中等优先级数据显示和参数设置放最低优先级。这样即使UI层写得再啰嗦也不会影响核心采样链路。一个常见的反例是把电机控制任务优先级设成configMAX_PRIORITIES - 1同时又在里面做浮点运算、打印日志、刷新屏幕结果一个控制周期被硬生生拉长电机跑起来一顿一顿的。记住高优先级意味着“抢占别人的能力”不意味着“可以做任意多的事”。1.2 同优先级任务之间时间片轮转到底怎么转大多数人知道FreeRTOS是抢占式调度但忽略了同优先级任务之间的切换靠的是时间片轮转。configUSE_TIME_SLICING这个宏控制这个开关默认是1也就是打开的。#define configUSE_PREEMPTION 1 #define configUSE_TIME_SLICING 1 #define configTICK_RATE_HZ 1000当两个相同优先级的任务都处于就绪态时系统会在每个tick中断时切换任务。比如configTICK_RATE_HZ是1000就有1ms一个时间片。注意时间片轮转不等于“并行”它只是把一个CPU时间切成碎片分给多个任务。如果某两个同优先级任务都是重计算型任务频繁切换会带来额外的上下文保存/恢复开销甚至因为Cache命中率下降而变慢。这里有个比较隐蔽的坑如果把大量任务都设成同一个优先级任务数量一多单个任务拿到CPU的时间片可能少得可怜。比如8个同优先级任务都在跑每个任务可能要等7个时间片才能再运行这对某些需要周期性短时处理的任务来说是不可接受的。所以我的建议是同优先级任务控制在2~3个以内如果有周期性采集需求尽量用定时器中断配合信号量唤醒任务而不是纯靠时间片轮转。1.3 低功耗Tickless模式别急着开configUSE_TICKLESS_IDLE这个功能能让系统在空闲时停止周期性的tick中断从而降低功耗。听起来很好但它会在唤醒时补偿tick计数导致vTaskDelay和超时判断出现微小偏差。如果你的项目里有严格的超时逻辑比如Modbus通信的超时判断我不建议一上来就开Tickless。先把功能跑稳再考虑低功耗否则排查问题时会多一个变量干扰你。2. 队列从“传消息”到“解耦任务”的设计骨架2.1 队列的本质是“复制”不是“引用”FreeRTOS队列的核心机制是发送方把数据拷贝进队列接收方从队列里把数据拷走。这就像快递柜发送方把物品放进格子接收方取走时柜子就空了。好处是数据不需要共享内存两个任务之间没有竞态条件。QueueHandle_t xQueue; xQueue xQueueCreate(5, sizeof(uint16_t)); if (xQueue ! NULL) { // 创建成功 }发送方uint16_t val read_sensor(); xQueueSend(xQueue, val, pdMS_TO_TICKS(100));接收方uint16_t rxValue; if (xQueueReceive(xQueue, rxValue, portMAX_DELAY) pdPASS) { process_sensor_value(rxValue); }注意xQueueSend其实等价于xQueueSendToBack也就是说新数据总是放到队尾。第三个参数是阻塞时间如果队列满了发送任务会在这个时间内挂起等待如果设为portMAX_DELAY则会一直等下去。2.2 队列项到底传结构体还是传指针很多初学者会纠结队列项大小设多少合适是直接传结构体还是传指针我建议分场景数据量小、比如几个字节或者一个状态码直接传结构体。拷贝开销可以忽略且不涉及内存生命周期问题。数据量大、比如一帧128字节的传感器数据包传指针更高效。但你必须保证指针指向的内存始终有效。第二种情况有个我踩过的坑在中断服务函数里定义了一个局部变量然后把它的地址通过xQueueSendFromISR发送出去。等中断退出后局部变量已经失效接收任务拿到的是被栈复用的垃圾数据。解决办法是中断里要用静态变量、全局变量或者预先分配好的缓冲区绝对不能指向中断内的局部变量。static uint8_t buf[64]; // 在ISR中填充buf后 xQueueSendFromISR(xQueue, buf, xHigherPriorityTaskWoken);2.3 从ISR里向队列发数据的标准姿势中断里不能调用普通的xQueueSend必须用带FromISR后缀的版本。原因稍后在中断章节展开这里先给一个串口接收的经典写法void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; uint8_t byte; while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_RXNE)) { byte (uint8_t)(huart1.Instance-DR 0xFF); xQueueSendFromISR(xModbusRxQueue, byte, xHigherPriorityTaskWoken); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }xHigherPriorityTaskWoken是个关键变量它会被API内部置为pdTRUE表示有更高优先级的任务因为队列变为非空而被唤醒。portYIELD_FROM_ISR会检查这个值如果为真就触发一次上下文切换让那个被唤醒的任务立刻执行。不要忘了这个变量必须每次从pdFALSE开始否则会产生无意义的任务切换。2.4 队列不只是传数据更是任务解耦的工具裸机开发里最常见的通信方式是全局变量加标志位。比如UART收到数据后置位一个flag主循环里看到flag就处理。这种方式在简单程序里没问题但一旦任务多了你很难判断“这个变量到底是谁改的、谁在读、改了之后多久会被消费”。用队列之后生产者只管放消费者只管取两端都阻塞在自己的节奏里互相不用知道对方在干什么。我的建议是把项目里所有跨任务的数据交互都收敛到队列里不要再用裸全局变量。至少把“数据通路”和“控制状态”分开数据用队列控制状态用事件组或任务状态机。3. 信号量与互斥量别再为选错同步工具买单3.1 二值信号量、计数信号量、互斥量三者的适用场景完全不同很多教程把二值信号量和互斥量混在一起讲导致实际工程里经常有人用错。我做一个简单的区分同步工具典型用途是否支持优先级继承获取/释放是否必须同一任务二值信号量事件通知、任务与中断同步不支持不要求计数信号量资源计数、有限缓冲槽管理不支持不要求互斥量保护共享资源、临界区互斥支持必须由获取它的任务释放二值信号量最常见的用法是作为“事件标志”比如外部中断触发后用xSemaphoreGiveFromISR通知一个任务去处理事件任务里用xSemaphoreTake等待事件。它像一个门铃按一下响一声。互斥量则是用来保护共享资源的比如多个任务都要操作同一块外部Flash或者多个任务都要往同一个串口打印日志。这时候用互斥量锁住整个操作过程防止串数据。3.2 优先级继承是怎么生效的互斥量比二值信号量多了一个关键能力优先级继承。普通二值信号量不提供这个能力这是很多人踩坑的根源。设想一个场景低优先级任务L持有互斥量正在写Flash。高优先级任务H想获取同一个互斥量发现被占用于是阻塞等待。此时如果有个中优先级任务M就绪了它会不会抢占L如果用的是二值信号量会。L被M抢走CPU无法快速完成写Flash操作H就得一直等L但L又在等M运行完H的优先级形同虚设。这就是“优先级反转”。互斥量的优先级继承机制会在H等待互斥量时把L的优先级临时提升到H的优先级。这样M就无法抢占LL能快速完成临界区并释放互斥量然后L再降回原优先级。这个机制能大幅缓解优先级反转但不能完全消除所以关键临界区还是要短。3.3 死锁的典型现场与规避办法死锁在FreeRTOS项目里很常见只是很多人不叫它死锁叫“程序卡死了”。最经典的是两个任务互相持有对方需要的资源任务A持有互斥量1等待互斥量2。任务B持有互斥量2等待互斥量1。如果调度器刚好让两个任务都停在等待点谁也不会释放手里的锁系统就僵在那里。规避死锁有几个可落地的习惯多个互斥量必须按固定顺序获取。比如A、B两个任务都先取锁1再取锁2就能避免“交叉等待”。尽量一个任务在一个时刻只持有一个互斥量。使用带超时的xSemaphoreTake(xMutex, pdMS_TO_TICKS(50))取不到就放弃而不是无限期等下去。如果只靠超时还不够可以在超时后把已经持有的互斥量全部释放做一次“回滚”。代码层面推荐这种写法if (xSemaphoreTake(xMutexA, pdMS_TO_TICKS(50)) pdPASS) { if (xSemaphoreTake(xMutexB, pdMS_TO_TICKS(50)) pdPASS) { // 同时拿到了两把锁执行临界区 xSemaphoreGive(xMutexB); } xSemaphoreGive(xMutexA); }先把能拿到的锁都拿到再处理业务只要有一把锁超时就释放已经持有的锁避免长期占着资源不放。4. 中断安全API的边界这些函数不能乱调4.1 中断优先级和FreeRTOS内核的配合关系这个坑几乎每个FreeRTOS新手都会踩。在Cortex-M内核上中断优先级数值越小越紧急。FreeRTOS通过configMAX_SYSCALL_INTERRUPT_PRIORITY这个宏划定“安全调API”的边界。#define configMAX_SYSCALL_INTERRUPT_PRIORITY 5这句配置的意思是优先级数值为0到4的中断属于“高于内核屏蔽级别”的中断这些中断里绝对不能调用任何FreeRTOS API优先级数值5及以上的中断可以调用带FromISR后缀的API。为什么因为FreeRTOS的临界区保护机制是通过屏蔽中断实现的而它只会屏蔽到configMAX_SYSCALL_INTERRUPT_PRIORITY对应的级别。如果某个中断的优先级比这个级别还高内核无法屏蔽它如果该中断再去调用xQueueSendFromISR之类的API就可能破坏内核的临界区数据导致任务卡死或随机复位。用STM32 HAL库设置外部中断时要确保优先级数值不小于这个宏HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn);这里把优先级设为5才和上面的configMAX_SYSCALL_INTERRUPT_PRIORITY匹配。4.2 为什么必须用FromISR版本在中断里调用API必须使用xQueueSendFromISR、xSemaphoreGiveFromISR、xTaskNotifyFromISR这类带FromISR后缀的函数。普通版API在获取队列锁或进入临界区时可能调用vTaskSuspendAll和xTaskResumeAll这些操作依赖任务上下文不能在中断里执行。中断上下文里不允许阻塞更不允许让调度器挂起。FromISR版本还有一个额外参数BaseType_t *pxHigherPriorityTaskWoken它把“是否有高优先级任务被唤醒”的状态通过参数传出来。最后用portYIELD_FROM_ISR(xHigherPriorityTaskWoken)判断是否要立即切换任务。这一段代码我在前面的串口中断例子里给过建议直接把那个模式套进自己的工程不要自己发明新写法。4.3 中断服务函数里只做“最小必要操作”即使允许调用API中断服务函数里也不能写太多业务逻辑。中断里做长除法、浮点运算、循环拷贝大数据都会拉长中断响应时间等于变相抬高系统延迟。正确做法是“中断只搬数据任务做处理”。一个比较典型的场景是Modbus从站的串口接收。串口中断每收到一个字节就把数据放进队列具体组帧、CRC校验、寄存器读写都在协议解析任务里完成。这样串口中断保持非常短同时长帧也不会阻塞其他中断。void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; uint8_t byte; while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_RXNE)) { byte (uint8_t)(huart1.Instance-DR 0xFF); xQueueSendFromISR(xModbusRxQueue, byte, xHigherPriorityTaskWoken); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }任务端void ModbusParseTask(void *arg) { uint8_t byte; uint8_t frame[256]; uint16_t len 0; for (;;) { if (xQueueReceive(xModbusRxQueue, byte, portMAX_DELAY) pdPASS) { if (len sizeof(frame)) { frame[len] byte; } // 判断帧结束、解析地址、校验CRC、执行读写 } } }这样至少保证两个原则中断路径短、协议处理不阻塞其他中断。如果你在做FreeRTOS FreeModbus的移植这个框架可以直接用。5. 任务通知更轻的同步机制与它的适用边界5.1 任务通知到底快在哪任务通知Task Notification是FreeRTOS提供的一套轻量同步机制。每个任务内部都有一个32位的通知值和状态位发送通知时直接修改目标任务的TCB字段不需要像信号量那样经过队列管理器。因此它的执行速度比二值信号量快RAM占用也更小。// 发送方 xTaskNotifyGive(xHandlingTaskHandle); // 接收方 uint32_t count ulTaskNotifyTake(pdTRUE, portMAX_DELAY);xTaskNotifyGive会给目标任务的通知值加1ulTaskNotifyTake(pdTRUE, portMAX_DELAY)会在通知值不为0时取出并清零计数。它本质上是把一个“计数信号量”内嵌到了任务里。5.2 什么时候可以用任务通知代替信号量和队列任务通知适合的场景中断通知任务“有事件发生了”这时候只需要一个唤醒信号不需要传递具体数据。一个任务向另一个任务发送一个32位以内的简单数值。多个发送者向同一个接收者发送“唤醒”通知。不适合的场景任务间需要传递一包数据即使数据只有几个字节也建议走队列因为队列天然带缓冲。多个任务同时等待同一个唤醒信号。任务通知是点对点的直接指定目标任务不能像信号量那样让多个任务竞争同一个信号量。需要互斥保护的共享资源。任务通知没有优先级继承机制不能替代互斥量。我给一个小项目里常用的判断表需求推荐机制外部中断唤醒一个任务做处理任务通知两个任务间传一帧数据队列多个任务保护同一个外设互斥量统计某种事件发生的次数计数信号量多个条件同时满足后触发动作事件组5.3 任务通知的几个隐蔽注意点任务通知虽然快但有几个坑要注意。第一个是通知计数的溢出不明显。ulTaskNotifyTake返回的是通知值减去1之前的值如果发送方发了100次接收方只处理了3次计数会增长但如果你不关心计数只关心“是否唤醒”可能没有感知。需要根据业务决定每次接收后是否清除计数或者用xTaskNotifyWait的ulBitsToClearOnEntry参数显式控制。第二个是接收方必须清楚自己消费的是“事件”还是“计数”。如果把通知当作事件标志那接收方应该用xTaskNotifyWait并指定清除标志位如果当作计数信号量用ulTaskNotifyTake(pdTRUE, ...)更直观。第三个是任务通知必须在目标任务存在时才能发。如果目标任务被删除了发送给它的通知就会静默丢失而且不会报错。所以在动态创建/删除任务的场景里要确认目标任务的句柄仍然有效。6. 内存配置与堆栈溢出排查从原理到现场6.1 heap_1到heap_5到底怎么选FreeRTOS提供几种不同的内存堆实现它们都叫heap_*.c但你只能把一个heap_N.c源文件加入工程。实现分配释放碎片处理适用场景heap_1只分配不释放不支持无碎片任务创建后永不删除的简单工程heap_2支持分配和释放支持不合并相邻空闲块容易碎片化已不推荐在新项目中使用heap_3包装C库malloc/free支持依赖C库需要启动文件堆足够大heap_4支持分配和释放支持按地址合并相邻空闲块大多数项目的默认选择heap_5同heap_4支持同heap_4内存分布在多个不连续区域的芯片我通常选heap_4。它实现简单、可靠释放时会把相邻空闲块合并不容易出现碎片堆积。heap_5主要在芯片有外部RAM、需要把内存池分散到多个区域时才用用法是在初始化时调用vPortDefineHeapRegions。堆大小由这个宏控制#define configTOTAL_HEAP_SIZE ( ( size_t ) 20 * 1024 )具体设多大取决于你的任务数量、队列数量和每个任务的栈大小。一个STM32F103RCT648KB RAM的工程我通常设20KB到30KB之间。如果任务创建失败、返回NULL一是查堆大小是否足够二是查栈大小是否合理。6.2 栈溢出检测到底该怎么开FreeRTOS提供了两种栈溢出检测机制由configCHECK_FOR_STACK_OVERFLOW控制。#define configCHECK_FOR_STACK_OVERFLOW 2设为1时只在任务切换时检查任务栈末尾的“标记字”是否被破坏。开销小但可能滞后只会发现“曾经溢出过”并且不一定能定位到是哪个操作导致的溢出。设为2时任务运行时会把栈中未使用的区域填充成特定值任务切换时检查整段栈空间能更早发现溢出问题但开销较大。不管哪种方式都要实现这个钩子函数void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { // 正式代码里可以记录任务名然后触发软复位或进入错误态 for (;;); }这个钩子里不要做复杂操作因为系统已经处于异常状态。通常我会在调试阶段把pcTaskName存到一个全局变量里再在循环里打断点这样能看到是哪个任务溢出。6.3 一次真实的内存排查过程我之前做过一个数据采集项目跑了一段时间后偶尔出现HardFault。一开始查了很久没思路因为vApplicationStackOverflowHook从来没被触发过。后来我在每个任务里周期打印栈余量uint32_t minFree uxTaskGetStackHighWaterMark(xTaskHandle); printf(task free stack: %u words\r\n, minFree);这里注意返回值单位是“字”不是字节。在STM32上一个字等于4字节。打印出来的结果很快暴露了问题协议解析任务的HighWaterMark从正常情况下的400多字降到了只有12个字说明这个任务曾经几乎把栈用尽。我核对代码后发现任务里定义了一个很大的局部结构体数组又调用了带格式化的snprintf处理JSON字符串这两个操作叠加后栈开销远超预期。解决方法是把大缓冲区从任务局部变量改为静态变量并适当增加任务栈大小。修改后再跑HighWaterMark稳定在150字以上HardFault不再出现。这个案例说明了为什么不能只靠栈溢出钩子uxTaskGetStackHighWaterMark是更细的观察手段。建议在开发阶段定期把每个任务的栈余量打印出来记录峰值再用这个数据反向调整任务栈大小。不要一上来就把所有任务栈设成很大那样堆内存会被吃掉任务可能创建失败。7. 多任务工程的任务划分复盘一个Modbus项目的教训7.1 任务不是“一个函数一个任务”很多从裸机转过来的第一版工程会把每个外设驱动包成一个任务按键任务、LED任务、UART任务、Flash任务。结果任务数量爆炸优先级不够分通信全靠全局变量整个系统变成一个披着RTOS外衣的裸机轮询。我建议按“事件源 处理路径”来划分任务。同一个数据链路的事情用队列串起来而不是一个数据搞三个任务。以Modbus从站项目为例最多拆三个任务串口接收与协议解析任务负责从队列取字节、组帧、CRC校验、执行寄存器读写。Modbus控制/状态机任务负责根据解析后的命令更新控制参数和执行状态。日志/显示任务只在需要时才运行优先级最低。串口中断只做搬数据不解析协议。这样整个链路清晰出问题时定位也快。7.2 多个条件同时满足用事件组而不是多个信号量有时候一个任务要等好几个条件比如“串口帧已收完整”和“超时时间已到”和“使能开关打开”三个条件都满足后才执行某项操作。用三个信号量分别等待代码会很难组织用事件组更自然。EventGroupHandle_t xEventGroup; xEventGroup xEventGroupCreate(); #define EVT_RX_DONE (1 0) #define EVT_TIMEOUT (1 1) #define EVT_ENABLE (1 2) // 某个任务/中断里 xEventGroupSetBits(xEventGroup, EVT_RX_DONE | EVT_TIMEOUT); // 等待的任务 EventBits_t bits xEventGroupWaitBits( xEventGroup, EVT_RX_DONE | EVT_TIMEOUT | EVT_ENABLE, pdTRUE, // 退出时清除事件位 pdTRUE, // 等待所有指定事件位 portMAX_DELAY );事件组特别适合“多个事件汇聚成一个动作”的场景。用起来比一堆二值信号量清爽很多而且能直接表达“同时满足”的逻辑。7.3 我踩过的一个经典坑忙循环把低优先级任务饿死有一次在项目里加了一个任务里面有一个while(1)循环循环里做了一个状态查询但忘了加任何阻塞操作也没有vTaskDelay。结果这个任务因为优先级高于其他任务又永远处于就绪态CPU被它完全占住其他任务和空闲任务全都得不到执行最终看门狗超时复位。这类问题很隐蔽因为代码逻辑看起来完全正常循环体也确实在执行“有用的事”。但RTOS和裸机不一样任务必须主动让出CPU。解决方法是轮询逻辑里至少加一个vTaskDelay(1)或者改成用队列/信号量阻塞等待事件。养成一个习惯任何任务的主循环都不能是空转轮询必须有阻塞点。另一个相关坑是多个任务同时向同一个串口打印日志。我在一个调试阶段把所有任务都加了printf结果日志交错乱码还以为是任务调度出了问题。后来统一用一个日志任务消费队列所有任务想打印时往队列里塞字符串日志任务负责统一输出问题立刻解决。顺带也避免了多任务并发操作UART外设的隐患。我自己在多个STM32和S32K144项目里验证下来觉得比较顺手的一套做法是中断只做“搬数据”同步用任务通知或二值信号量数据传递走队列共享资源访问用互斥量。任务栈先给一个偏保守的值跑起来后用uxTaskGetStackHighWaterMark回看再收窄。这套组合起初搭建时要多花点时间把通信骨架理清但后续加任务、调优先级、排查问题都会省很多事。如果你现在正要把FreeRTOS往正式工程里引建议别急着堆功能先把这套同步和通信骨架搭对后面填什么任务都不会太慌。
返回列表