多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

STM32 FreeRTOS实战:多任务调度与队列通信优化

STM32 FreeRTOS实战:多任务调度与队列通信优化 1. 为什么单靠裸机轮询撑不住这个场景先把场景摆清楚一块STM32比如F103C8T6这种最常见的入门型号要同时干三件事——周期性地读温湿度传感器假设是DHT11或者SHT30把数据刷到0.96寸OLED上还要通过ESP8266把数据推到云端。听起来不难很多人的第一反应是写个while(1)大循环里面依次调用三个函数加几个delay就完事了。我最早也是这么干的结果问题一个接一个冒出来。DHT11读一次要占用将近20毫秒的阻塞时间因为它的单总线协议要求严格的时序主机拉低、释放、等待从机响应中间不能被打断太久。OLED刷一屏IIC数据按0.96寸128x64算全屏刷新大概要十几到几十毫秒取决于IIC速率。ESP8266发AT指令连云端等一个响应动辄几百毫秒甚至上秒级网络不好的时候更夸张。这三个时间量级完全不同的任务塞在一个循环里后果就是读传感器的时候OLED卡住不刷新刷OLED的时候网络响应被错过等网络的时候传感器采样周期已经飘了。你可能会说用定时器中断来触发采样但中断里不能干重活OLED刷新和网络通信这种耗时操作放中断里就是灾难。裸机轮询的本质问题是没有任务优先级的概念也没有阻塞时的让出机制。一个任务在等外设响应的时候CPU只能空转或者被delay占着别的任务干瞪眼。这就是为什么这个场景天然适合上RTOS——它要解决的核心矛盾是多个时间尺度差异巨大的任务如何在一个核上看起来同时运行。FreeRTOS在这里的价值不是用了显得高级而是它提供了任务调度、优先级抢占、阻塞让出这三样东西让温湿度采集、OLED显示、云端通信各自活在自己的节奏里互不拖累。下面我把整个实战过程拆开讲包括任务怎么划分、优先级怎么定、栈怎么算、队列怎么用以及我踩过的那些坑。2. 任务划分与优先级设计别拍脑袋定数字2.1 三个任务还是一个任务加状态机很多人一上来就想把功能拆得越细越好温湿度一个任务、OLED一个任务、ESP8266一个任务甚至OLED再拆成刷新任务和动画任务。拆得太细的代价是栈空间开销大、任务切换频繁、共享资源保护复杂。我的建议是按阻塞特性和实时性要求来划分而不是按功能模块。温湿度采集周期性触发对实时性要求中等但读取过程有严格时序不能被高优先级任务打断太久。独立成一个任务优先级中等。OLED显示纯输出对实时性要求低晚个几十毫秒刷新人眼根本看不出来。独立成一个任务优先级最低。ESP8266通信涉及AT指令交互等待响应时间长但一旦有数据要发希望尽快发出去。独立成一个任务优先级可以设得比显示高但比采集低或者相当。这样三个任务就够了。如果你还想加按键处理、LED指示可以再开一个低优先级任务或者干脆用软件定时器回调。2.2 优先级数字背后的逻辑FreeRTOS里优先级数字越大优先级越高注意和某些RTOS相反。我实际用的配置是任务优先级理由温湿度采集3时序敏感需要及时响应定时触发ESP8266通信2等待响应时可阻塞但发送要及时OLED显示1纯人机交互容忍延迟空闲任务0系统自带为什么采集优先级最高因为DHT11这类传感器对时序要求苛刻如果读的过程中被高优先级任务抢占太久读出来的就是校验错误的数据。而OLED晚刷新一会儿完全无所谓ESP8266在等AT响应的时候本来就该阻塞让出CPU。这里有个反直觉的点优先级不是越高越好高优先级任务如果频繁运行会饿死低优先级任务。我见过有人把OLED设成最高优先级结果屏幕刷得飞起传感器数据全是错的。优先级设计的原则是越接近硬件时序、越不能容忍延迟的任务优先级越高越偏向人机交互、越能容忍延迟的优先级越低。2.3 栈空间到底给多少栈溢出是FreeRTOS新手最容易踩的坑而且症状往往很诡异——不是直接崩溃而是某个变量莫名其妙被改、任务跑飞、HardFault。栈大小的单位在FreeRTOS里是word4字节不是字节这点一定要记清楚。我的经验值供参考STM32F103IAR/Keil无浮点打印温湿度采集任务128 words512字节如果里面用了sprintf浮点格式化加到256 wordsOLED显示任务256 words因为IIC驱动里可能有局部数组缓冲ESP8266通信任务512 wordsAT指令拼接、响应解析、JSON组包都在这里最吃栈空闲任务系统默认configMINIMAL_STACK_SIZE一般128 words怎么验证够不够开启configCHECK_FOR_STACK_OVERFLOW设为2实现vApplicationStackOverflowHook在里面点灯或者打印。更稳妥的办法是用uxTaskGetStackHighWaterMark()在运行时查询每个任务栈的历史最小剩余量跑一段时间后看剩余量如果小于20%就加。提示栈溢出检测只能在任务切换时检查如果任务内部一次性爆栈可能检测不到。所以高水位标记法比溢出钩子更可靠。3. 队列与信号量任务间通信的正确姿势3.1 为什么不用全局变量传数据三个任务之间要传数据采集任务读到温湿度要送给显示任务和通信任务。最偷懒的做法是定义全局变量采集任务写显示和通信任务读。单核情况下好像也没啥大问题但隐患在于读写不同步——显示任务可能读到一半数据被采集任务改了读到一个半新半旧的温湿度值。更严重的是如果以后加了DMA或者中断里也写这个变量就会出现数据竞争。FreeRTOS提供的队列Queue就是解决这个问题的标准工具它是任务安全的自带阻塞机制队列满时发送方可选择等待队列空时接收方可选择阻塞等待。3.2 温湿度数据的队列设计我定义了一个结构体来打包一次采样typedef struct { float temperature; float humidity; uint32_t timestamp; } SensorData_t; QueueHandle_t xSensorQueue;队列长度设多少这里有个设计取舍。如果显示任务和通信任务都要从同一个队列取数据那一个数据被取走另一个就没了。两种方案方案一用两个队列采集任务往两个队列各发一份。缺点是占内存优点是解耦。方案二用一个队列但配合事件组或者任务通知让一个任务取到后广播给另一个。复杂。方案三用长度为1的队列做最新值邮箱采集任务用覆盖写xQueueOverwrite显示和通信任务各自读。但普通队列不支持多读者。我实际用的是方案一的简化版定义一个全局的SensorData_t g_latestSensorData加一个二值信号量保护或者干脆用两个队列。对于这个项目的数据量两个队列各长度5完全够用内存开销可以忽略。xSensorQueueForDisplay xQueueCreate(5, sizeof(SensorData_t)); xSensorQueueForCloud xQueueCreate(5, sizeof(SensorData_t));采集任务里SensorData_t data; data.temperature read_temperature(); data.humidity read_humidity(); data.timestamp xTaskGetTickCount(); xQueueSend(xSensorQueueForDisplay, data, pdMS_TO_TICKS(10)); xQueueSend(xSensorQueueForCloud, data, pdMS_TO_TICKS(10));注意超时参数用pdMS_TO_TICKS(10)而不是0也不是portMAX_DELAY。用0的话队列满直接丢弃用portMAX_DELAY的话如果消费者卡死采集任务会永久阻塞。10毫秒是个合理的折中。3.3 二值信号量在ESP8266通信中的妙用ESP8266发AT指令的典型流程是发送指令字符串然后等待模块返回OK或ERROR。裸机下就是死等RTOS下应该用信号量。我的做法是串口接收中断里逐字节接收当检测到完整的一行以OK\r\n结尾时释放一个二值信号量。通信任务发送指令后用xSemaphoreTake(xATResponseSem, pdMS_TO_TICKS(2000))等待等到了说明成功超时了说明模块没响应。SemaphoreHandle_t xATResponseSem; // 串口中断中 void USART1_IRQHandler(void) { // ... 接收字节拼行 if (line_complete strstr(rx_line, OK)) { BaseType_t xHigherPriorityTaskWoken pdFALSE; xSemaphoreGiveFromISR(xATResponseSem, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } }这里的关键是xSemaphoreGiveFromISR和portYIELD_FROM_ISR的配合。如果释放信号量后唤醒了更高优先级的任务需要触发一次上下文切换否则要等到下一个tick才切换实时性打折扣。注意二值信号量适合事件通知场景不适合资源计数。如果你要保护一段临界区用互斥量Mutex而不是二值信号量。互斥量有优先级继承机制能缓解优先级翻转问题。4. 从DHT11到OLED各任务的实现细节与坑4.1 温湿度采集任务的微秒级延时怎么做DHT11的时序要求主机拉低至少18ms然后拉高20-40us然后释放总线等待从机响应。这个20-40us的延时用vTaskDelay是不行的因为FreeRTOS的tick周期一般是1ms最小延时就是1ms差了一个数量级。解决办法是用硬件定时器做微秒级延时或者用DWTData Watchpoint and Trace周期计数器。我用的是后者在Cortex-M3/M4上很方便void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t cycles us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) cycles); }初始化时使能DWTCoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk;这个延时是忙等的会占用CPU。但DHT11读取总共也就几毫秒而且采集任务优先级最高忙等这几毫秒可以接受。如果你觉得浪费可以在拉低18ms那段用vTaskDelay(pdMS_TO_TICKS(20))让出CPU只在微秒级等待时忙等。采集任务的主体void vSensorTask(void *pvParameters) { SensorData_t data; TickType_t xLastWakeTime xTaskGetTickCount(); const TickType_t xPeriod pdMS_TO_TICKS(2000); for (;;) { if (DHT11_Read(data.temperature, data.humidity) SUCCESS) { data.timestamp xTaskGetTickCount(); xQueueSend(xSensorQueueForDisplay, data, pdMS_TO_TICKS(10)); xQueueSend(xSensorQueueForCloud, data, pdMS_TO_TICKS(10)); } vTaskDelayUntil(xLastWakeTime, xPeriod); } }用vTaskDelayUntil而不是vTaskDelay是为了保证严格的2秒周期不受任务执行时间波动影响。这是周期性任务的标配写法。4.2 OLED的IIC驱动在RTOS下的注意事项0.96寸OLED用SSD1306驱动IIC接口。HAL库的HAL_I2C_Master_Transmit是阻塞式的默认超时时间可能很长。在RTOS任务里调用它如果IIC总线出问题比如从机没响应任务会阻塞到超时期间低优先级任务反而能跑但同优先级和更低优先级的显示相关逻辑会卡住。我的建议是把IIC超时时间改短比如100ms并且在调用后检查返回值。如果返回HAL_ERROR或HAL_TIMEOUT做一次总线恢复发送9个时钟脉冲让从机释放SDA。另一个坑是OLED刷新时的显存管理。SSD1306的显存是1KB128x64/8如果你每次刷新都全屏发送IIC速率400kHz下大概需要25ms。这个时间在显示任务里是阻塞的但因为显示任务优先级最低阻塞了也无所谓其他任务该跑跑。但如果你要做OLED动画比如开机logo、进度条频繁全屏刷新会占用大量CPU时间。优化方法是局部刷新只更新变化的页page或者用u8g2库的缓冲机制。void vDisplayTask(void *pvParameters) { SensorData_t data; char buf[32]; OLED_Init(); OLED_Clear(); for (;;) { if (xQueueReceive(xSensorQueueForDisplay, data, portMAX_DELAY) pdPASS) { OLED_ClearBuffer(); sprintf(buf, Temp: %.1f C, data.temperature); OLED_DrawString(0, 0, buf); sprintf(buf, Humi: %.1f %%, data.humidity); OLED_DrawString(0, 2, buf); OLED_Refresh(); } } }这里用portMAX_DELAY阻塞等待队列没数据时任务完全让出CPU不占一点资源。这就是RTOS相比轮询的优势——没事干的时候真的不干活。4.3 ESP8266的AT指令状态机ESP8266通过串口和STM32连接STM32发AT指令控制它连WiFi、连云平台、发数据。裸机下写AT指令交互很容易写成一大坨delay加判断RTOS下应该用状态机加信号量。我封装了一个简单的AT发送函数AT_Result_t AT_SendCommand(const char *cmd, const char *expect, uint32_t timeout_ms) { // 清空接收缓冲 rx_buffer_clear(); // 清空信号量 xSemaphoreTake(xATResponseSem, 0); // 发送指令 HAL_UART_Transmit(huart1, (uint8_t *)cmd, strlen(cmd), 100); HAL_UART_Transmit(huart1, (uint8_t *)\r\n, 2, 100); // 等待响应 if (xSemaphoreTake(xATResponseSem, pdMS_TO_TICKS(timeout_ms)) pdPASS) { if (strstr(rx_buffer, expect) ! NULL) { return AT_OK; } } return AT_TIMEOUT; }串口中断里做行缓冲检测到\r\n结尾就检查是否包含OK或ERROR然后释放信号量。这里有个细节ESP8266返回的数据可能分多次到达中断里要维护一个缓冲区不能收到一个字节就判断。连接OneNet或者阿里云的流程大概是AT测试、设置模式、连WiFi、连云平台、发数据。每一步都用上面的函数超时时间根据操作不同设置连WiFi给10秒发数据给5秒。提示ESP8266在连WiFi的时候会返回一堆WIFI CONNECTED、WIFI GOT IP之类的信息如果你的expect字符串匹配太严格可能误判。建议用宽松匹配比如只匹配OK或者CONNECT。5. 调试过程中那些让人抓狂的瞬间5.1 任务跑着跑着就HardFault了第一次跑起来串口打印正常OLED也亮了但过几十秒就HardFault。用调试器看调用栈发现死在prvCopyDataToQueue里。查了半天原因是队列项大小和实际发送的数据大小不匹配。我定义队列时写的是xQueueCreate(5, sizeof(SensorData_t))但发送时传的指针指向的是一个局部变量这没问题。问题出在另一个地方我在中断里也往同一个队列发数据但中断里用的是xQueueSendFromISR而队列项大小在创建时已经固定这也没问题。最后发现是栈溢出——通信任务的栈给少了JSON组包时局部数组越界把队列控制块的内存踩了。教训HardFault不一定是代码逻辑错很可能是栈或堆溢出。先把configCHECK_FOR_STACK_OVERFLOW打开再把configTOTAL_HEAP_SIZE加大用xPortGetFreeHeapSize()监控堆剩余。5.2 OLED显示的数字偶尔变成乱码现象是温湿度值偶尔显示成-0.0或者超大数字。排查发现是浮点数在任务间传递时的对齐问题。SensorData_t结构体里有float队列拷贝是按字节拷贝的本身没问题。但显示任务里用sprintf格式化float时如果栈空间不够格式化函数的内部缓冲会踩到其他数据。解决办法把显示任务的栈从128 words加到256 words并且改用snprintf限制长度。另外如果编译器支持开启-u _printf_floatKeil下是勾选Use MicroLIB并启用float打印。5.3 ESP8266发数据时好时坏有时候能发出去有时候超时。用逻辑分析仪抓串口波形发现STM32发送AT指令后ESP8266其实回了OK但STM32没收到。原因是串口接收中断优先级和FreeRTOS的tick中断优先级冲突。STM32的NVIC优先级分组下如果串口中断优先级低于或等于configMAX_SYSCALL_INTERRUPT_PRIORITY在中断里调用xSemaphoreGiveFromISR会触发断言。如果高于这个阈值又不能用FreeRTOS的API。正确做法是串口中断优先级设为高于configMAX_SYSCALL_INTERRUPT_PRIORITY但低于最高优先级具体数值取决于你的优先级分组。我用的配置是NVIC分组44位抢占优先级configMAX_SYSCALL_INTERRUPT_PRIORITY设为5串口中断抢占优先级设为6。这样串口中断可以调用FromISR版本的API同时不会被其他中断打断太久。5.4 系统跑久了堆内存越来越少FreeRTOS的堆heap在configSUPPORT_DYNAMIC_ALLOCATION为1时任务创建、队列创建都从堆里分配。如果程序里频繁创建删除任务或队列会产生碎片。我的项目里所有任务和队列都是启动时一次性创建运行中不动态分配所以堆用量是固定的。但如果你用了pvPortMalloc在运行中分配内存一定要记得vPortFree。更好的做法是用静态创建方式xTaskCreateStatic、xQueueCreateStatic完全不用堆内存用量在编译期就确定。StaticTask_t xSensorTaskBuffer; StackType_t xSensorStack[128]; xTaskCreateStatic(vSensorTask, Sensor, 128, NULL, 3, xSensorStack, xSensorTaskBuffer);静态创建的好处是链接时就能看到内存占用不会运行时失败。缺点是写起来啰嗦每个任务都要定义栈数组和TCB缓冲。6. 几个让系统更稳的进阶配置6.1 空闲任务钩子里的低功耗处理如果这个设备是电池供电可以在空闲任务钩子里让MCU进入睡眠模式。FreeRTOS的空闲任务在没其他任务就绪时运行此时调用__WFI()让CPU休眠等中断唤醒。void vApplicationIdleHook(void) { __WFI(); }但要注意如果用了vTaskDelaytick中断会定期唤醒CPU睡眠效果有限。真正的低功耗需要配置tickless模式configUSE_TICKLESS_IDLE让系统在空闲时关掉tick中断睡到下一个任务该运行的时间点再醒。6.2 用软件定时器替代延时循环有些周期性动作比如每500ms翻转一个LED没必要单独开任务。用FreeRTOS的软件定时器更省资源TimerHandle_t xLedTimer; xLedTimer xTimerCreate(LED, pdMS_TO_TICKS(500), pdTRUE, (void *)0, vLedTimerCallback); xTimerStart(xLedTimer, 0);软件定时器的回调是在定时器服务任务里执行的所以回调里不能阻塞也不能调用带FromISR的API。适合做轻量级的周期动作。6.3 优先级翻转与互斥量的使用如果两个任务都要访问IIC总线比如OLED和某个IIC传感器就需要用互斥量保护。假设低优先级的OLED任务持有IIC互斥量高优先级的传感器任务也要用IIC此时高优先级任务会阻塞等待。如果中间有个中优先级任务就绪它会抢占低优先级的OLED任务导致高优先级任务被中优先级任务间接阻塞——这就是优先级翻转。FreeRTOS的互斥量有优先级继承机制当高优先级任务等待低优先级任务持有的互斥量时低优先级任务的优先级会被临时提升到和高优先级任务一样避免被中优先级任务抢占。SemaphoreHandle_t xI2CMutex xSemaphoreCreateMutex(); // 使用IIC前 xSemaphoreTake(xI2CMutex, portMAX_DELAY); // ... IIC操作 xSemaphoreGive(xI2CMutex);注意互斥量不能在中断里使用中断里要用二值信号量。而且互斥量的Take和Give必须成对且不能在同一个任务里嵌套Take同一个互斥量除非用递归互斥量。7. 我实际跑通后的任务时间线把上面所有东西拼起来系统启动后的流程是main里初始化时钟、GPIO、IIC、UART、DWT创建三个队列、两个信号量、一个互斥量创建三个任务启动调度器采集任务每2秒读一次DHT11数据发两个队列显示任务阻塞等显示队列收到就刷OLED通信任务阻塞等通信队列收到就组JSON发ESP8266串口中断收ESP8266响应释放信号量空闲任务里__WFI休眠用uxTaskGetSystemState或者SEGGER SystemView抓一下时间线可以看到采集任务每2秒活跃几毫秒显示任务在采集后活跃二十几毫秒通信任务在显示后活跃几百毫秒等网络其余时间CPU都在空闲任务里休眠。三个任务在时间上错开互不阻塞这就是RTOS调度想要的效果。如果不用RTOS同样的功能用裸机状态机也能实现但代码会复杂得多而且任何一个环节的延时都会影响其他环节的响应。RTOS的价值在于用空间每个任务的栈换时间响应实时性和代码清晰度。对于这种多时间尺度任务并存的项目我认为上RTOS是值得的。最后分享一个我调试时的小技巧在vApplicationTickHook里翻转一个空闲GPIO用示波器看波形可以直观看到系统的tick是否正常以及CPU有多少时间在跑任务、多少时间在空闲。这个GPIO波形是我判断系统负载最直接的手段比任何软件统计都靠谱。
返回列表