FreeRTOS多任务实战:STM32温湿度采集与云端上报系统设计

发布时间:2026/9/19 9:55:42
FreeRTOS多任务实战:STM32温湿度采集与云端上报系统设计
1. 项目缘起与整体架构设计1.1 为什么单任务轮询撑不住这个场景最早做温湿度采集的时候我用的是最朴素的方式主循环里依次读DHT11、刷OLED、发ESP8266中间塞几个delay凑时序。单跑一个功能没问题三个凑一起就露馅了。DHT11的时序对时间极其敏感读一次要占用几毫秒的阻塞窗口OLED走IIC刷一屏按页写下来又是好几毫秒ESP8266发AT指令连云端等响应动辄几百毫秒甚至超时重试。这三件事串在一起温湿度采样周期被拉长到秒级OLED刷新肉眼可见地卡顿云端上报更是时快时慢。问题的本质是阻塞式编程把CPU时间绑死在等待上。DHT11等电平跳变、IIC等ACK、串口等回包这些等待期间CPU其实啥也干不了。单任务轮询模型下你只能靠delay硬凑任务之间互相拖累实时性无从谈起。FreeRTOS给出的解法是把这三件事拆成独立任务每个任务有自己的栈空间和优先级由内核调度器按抢占式规则分配CPU。高优先级任务就绪时立刻抢占低优先级任务等待类操作通过阻塞让出CPU让其他任务顶上。这样温湿度采集的时序精度、OLED的刷新流畅度、云端通信的响应速度可以同时得到保障。1.2 任务划分与优先级怎么定任务划分不是拍脑袋得按实时性要求和阻塞特性两个维度来切。我最终拆成四个任务任务名称优先级栈大小周期/触发方式核心职责TempHumiTask3最高256字2秒周期读DHT11写共享数据CloudTask2512字事件驱动组包、发AT指令、收响应DisplayTask1384字500毫秒周期刷OLED显示LedTask0最低128字1秒周期心跳灯兼做系统存活指示优先级排序的逻辑很直接DHT11时序最娇气必须最高云端通信涉及网络超时不能让它在关键路径上卡住别人但响应速度又比刷屏重要放中间OLED刷新慢一点用户能忍放低心跳灯纯装饰最低。栈大小的估算有个经验公式局部变量占用 函数调用深度 × 每层开销 中断嵌套余量。DHT11任务里我用了不少局部数组存时序数据256字STM32上1字4字节即1KB够用。CloudTask要拼AT指令字符串、解析JSON512字是留了余量的。实际调试时用uxTaskGetStackHighWaterMark()查剩余栈哪个任务水位低于20%就往上加。1.3 任务间通信队列还是全局变量温湿度数据从采集任务传到显示任务和云端任务最省事的做法是定义全局结构体但这样有隐患采集任务写的时候显示任务可能正在读读到半新半旧的数据。FreeRTOS的队列天然解决这个问题——队列的入队出队是原子操作内核保证不会读到中间态。我定义了一个结构体typedef struct { float temperature; float humidity; uint32_t timestamp; } SensorData_t;然后创建一个长度为4的队列QueueHandle_t xSensorQueue xQueueCreate(4, sizeof(SensorData_t));采集任务读完传感器后xQueueSend显示任务和云端任务各自xQueueReceive。这里有个细节两个接收者会竞争同一个队列项谁先抢到谁拿走。如果显示和云端都需要同一份数据要么建两个队列分别投递要么用xQueuePeek让其中一个只读不取。我选的是建两个队列采集任务往两个队列各发一份逻辑清晰不打架。提示队列长度别设太小。我一开始设长度为1采集任务发得快、显示任务取得慢队列满了之后xQueueSend返回失败数据就丢了。改成4之后配合超时等待基本不丢包。2. 核心细节解析与实操要点2.1 DHT11时序与任务阻塞的平衡DHT11是单总线器件一次完整读取的时序是这样的主机拉低至少18毫秒、拉高20-40微秒、释放总线然后DHT11依次回传40位数据每位以50微秒低电平开始、26-28微秒高电平表示0、70微秒高电平表示1。整个读取过程约4毫秒期间必须关中断或至少保证不被高优先级中断打断太久否则时序错乱读到全0或全1。在FreeRTOS里直接关中断4毫秒是不可接受的会破坏系统节拍。我的做法是把DHT11读取放在一个临界区里但临界区只包住最敏感的位采样部分前面的18毫秒起始信号用vTaskDelay让出CPU后面的数据解析放到临界区外。具体实现上我用的是HAL库的微秒延时delay_us配合GPIO直接操作。起始信号阶段// 主机发送起始信号 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); vTaskDelay(pdMS_TO_TICKS(20)); // 让出CPU 20ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); delay_us(30); // 切换为输入模式准备接收数据采样阶段用taskENTER_CRITICAL()包住但只包住40位读取的循环大约3-4毫秒。实测下来STM32F103在72MHz下这个临界区对系统节拍的影响可以接受因为系统节拍中断只是被延迟不会丢失。注意临界区里绝对不能调用任何带阻塞的API包括vTaskDelay、xQueueSend带超时的版本。我踩过这个坑在临界区里调了带超时的队列发送结果系统直接卡死。2.2 OLED的IIC驱动与HAL库适配OLED我用的是0.96寸SSD1306IIC接口。HAL库的IIC硬件驱动有个众所周知的毛病在某些STM32型号上硬件IIC容易卡死在等待ACK的状态。我试过F103的硬件IIC刷屏时偶尔会死锁后来果断换成软件模拟IIC用两个GPIO口手动翻转时序稳定得一塌糊涂。软件IIC的时序关键点起始条件SCL高时SDA拉低、停止条件SCL高时SDA拉高、数据位在SCL高电平期间保持稳定。我用delay_us控制半周期约2微秒对应约250kHz的速率SSD1306完全吃得下。驱动层封装成几个函数void OLED_WriteCmd(uint8_t cmd); void OLED_WriteData(uint8_t data); void OLED_Init(void); void OLED_Refresh(void);显示任务里维护一个显存缓冲区uint8_t OLED_GRAM[8][128]所有绘制操作先改缓冲区最后调OLED_Refresh一次性刷到屏幕。这样做的好处是避免频繁IIC通信占用CPU刷一屏128×64像素大约需要十几毫秒放在低优先级任务里完全不影响其他任务。汉字显示是个绕不开的坎。我用的方案是取模软件生成字模数组每个16×16汉字占32字节存在Flash里。显示时按页写入注意SSD1306的显存是按页组织的每页8行像素一个16×16汉字要跨两页写入坐标换算要仔细。2.3 ESP8266的AT指令状态机ESP8266通过串口和STM32连接跑的是AT固件。直接发指令等回包是最容易出问题的做法因为网络操作的不确定性太大——可能秒回可能几秒后才回也可能根本不回。我的方案是用状态机超时重试来管理通信流程。状态机定义几个状态空闲、发送AT、等待OK、发送CIPSEND、等待、发送数据、等待SEND OK、等待关闭。每个状态有对应的超时时间超时后回到空闲并记录错误次数连续失败三次就重启ESP8266。串口接收用中断环形缓冲区的方式中断里只把字节塞进缓冲区解析工作放到CloudTask里做。这样中断服务函数极短不会影响系统实时性。环形缓冲区大小我设的512字节够存一帧完整的HTTP响应。// 串口中断回调 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart huart2) { RingBuffer_Write(esp_rb, rx_byte); HAL_UART_Receive_IT(huart2, rx_byte, 1); } }CloudTask里用xQueueReceive等待采集任务发来的数据收到后组包、发AT指令、等响应。整个流程用vTaskDelay做超时等待不阻塞其他任务。实操心得ESP8266的AT固件版本差异很大有的版本ATCIPSTART返回CONNECT有的返回OK后跟CONNECT。解析响应时别写死字符串匹配用strstr找关键字更稳。另外波特率建议用115200低了传输慢高了容易丢包。3. 实操过程与核心环节实现3.1 工程搭建与FreeRTOS移植开发环境我用的是Keil MDK5芯片包装的是STM32F1系列。FreeRTOS源码从官网下载版本选的V10.4.3稳定且资料多。移植步骤不复杂但有几个关键点容易出错。第一步是把FreeRTOS源码里的Source文件夹复制到工程目录包含tasks.c、queue.c、list.c、timers.c、portable文件夹。portable里只需要保留RVDS/ARM_CM3对应Cortex-M3内核和MemMang内存管理。第二步是在Keil里建分组把源文件加进去。注意portable里的port.c和heap_4.c必须加前者是内核移植层后者是内存管理方案。heap_4支持碎片合并比heap_1和heap_2更适合长期运行的系统。第三步是配置FreeRTOSConfig.h这个文件决定了内核的行为。几个关键配置#define configUSE_PREEMPTION 1 // 抢占式调度 #define configUSE_TIME_SLICING 1 // 同优先级时间片轮转 #define configCPU_CLOCK_HZ 72000000 #define configTICK_RATE_HZ 1000 // 1ms节拍 #define configMAX_PRIORITIES 5 #define configMINIMAL_STACK_SIZE 128 #define configTOTAL_HEAP_SIZE (10 * 1024) #define configCHECK_FOR_STACK_OVERFLOW 2 // 栈溢出检测 #define configUSE_MALLOC_FAILED_HOOK 1configTICK_RATE_HZ设1000意味着每个节拍1毫秒vTaskDelay(1)就是延时1毫秒。节拍太高中断开销大太低延时精度差1000是个平衡点。第四步是写SysTick_Handler和PendSV_HandlerFreeRTOS要求这两个中断服务函数由内核接管。在stm32f1xx_it.c里把原来的函数体删掉改成调用xPortSysTickHandler和xPortPendSVHandler。这一步漏了的话系统跑不起来我当初找了半天才发现是中断向量没接对。3.2 任务创建与启动流程main函数里的初始化顺序有讲究先初始化HAL库、时钟、外设再创建任务最后启动调度器。创建任务用xTaskCreatexTaskCreate(TempHumiTask, TempHumi, 256, NULL, 3, xTempHumiHandle); xTaskCreate(CloudTask, Cloud, 512, NULL, 2, xCloudHandle); xTaskCreate(DisplayTask, Display, 384, NULL, 1, xDisplayHandle); xTaskCreate(LedTask, Led, 128, NULL, 0, xLedHandle); vTaskStartScheduler();vTaskStartScheduler之后代码就不会返回了调度器接管CPU。如果调度器启动失败比如堆内存不够会调用vApplicationMallocFailedHook我在这个钩子里点亮一个错误指示灯方便排查。任务函数的标准写法是无限循环循环里必须有阻塞点void TempHumiTask(void *pvParameters) { SensorData_t data; while (1) { if (DHT11_Read(data.temperature, data.humidity) 0) { data.timestamp xTaskGetTickCount(); xQueueSend(xSensorQueue1, data, 0); xQueueSend(xSensorQueue2, data, 0); } vTaskDelay(pdMS_TO_TICKS(2000)); } }vTaskDelay的参数是节拍数pdMS_TO_TICKS(2000)把2000毫秒转成节拍数。这个宏在configTICK_RATE_HZ为1000时就是2000但用宏更规范改节拍频率时不用改代码。3.3 云端通信的完整实现云端我用的是OneNet平台HTTP协议上报。ESP8266的AT指令流程如下// 1. 连接WiFi ATCWJAPssid,password // 2. 建立TCP连接 ATCIPSTARTTCP,api.heclouds.com,80 // 3. 设置发送长度 ATCIPSEND长度 // 4. 发送HTTP请求 POST /devices/xxx/datapoints HTTP/1.1 api-key: xxx Host: api.heclouds.com Content-Length: xx {datastreams:[{id:temp,datapoints:[{value:25.3}]}]} // 5. 等待响应每一步都要等ESP8266回OK或提示符。我把这些步骤封装成一个函数Cloud_SendData(float temp, float humi)内部用状态机驱动超时时间设5秒。连续失败三次就调ESP8266_Reset()拉低使能引脚重启模块。数据组包用snprintf拼JSON字符串注意转义和长度计算。HTTP请求头里的Content-Length必须和实际body长度一致否则服务器会截断或等待超时。我一开始手算长度算错了调了半天才发现是这里的问题。提示ESP8266的供电要足。我用STM32的3.3V直接给它供电发数据时电流瞬间拉到200mA以上电压跌落导致模块重启。后来加了个100微法的电容在模块电源脚旁边问题解决。如果还不行就得用独立的LDO供电。3.4 显示任务的刷新策略显示任务每500毫秒刷一次屏但并不是每次都全屏重绘。我做了个简单的脏标记机制温湿度数据变化超过0.1度或1%时才更新对应区域否则跳过。这样大部分时间IIC通信量很小CPU占用率低。OLED显示内容分三行第一行显示温度第二行显示湿度第三行显示云端连接状态。状态用图标表示连接成功显示对勾失败显示叉号。图标也是取模生成的16×16像素。刷新函数里先清空显存缓冲区再逐行写入内容最后调OLED_Refresh。OLED_Refresh内部按页循环每页发128字节数据。软件IIC下刷一屏约15毫秒在1毫秒节拍的系统里会占用15个节拍但因为DisplayTask优先级最低高优先级任务就绪时会立刻抢占所以不影响实时性。4. 常见问题与排查技巧实录4.1 系统跑飞与栈溢出FreeRTOS最常见的崩溃原因是栈溢出。任务栈设小了函数调用深一点或者局部数组大一点就踩到别的任务的内存。表现是随机死机、数据错乱、或者进HardFault。排查手段把configCHECK_FOR_STACK_OVERFLOW设为2内核会在任务切换时检查栈末尾的魔术字是否被改写。被改写就调用vApplicationStackOverflowHook我在这个钩子里让LED快闪一眼就能看出是栈的问题。然后用uxTaskGetStackHighWaterMark查每个任务的剩余栈UBaseType_t watermark uxTaskGetStackHighWaterMark(xCloudHandle); printf(Cloud task stack watermark: %d\n, watermark);返回值是历史最小剩余栈单位是字如果小于20就说明栈紧张得往上加。我最初CloudTask设的256字水位只剩8加到512后水位稳定在100以上。4.2 队列满导致数据丢失队列满的时候xQueueSend返回errQUEUE_FULL如果不处理数据就静默丢了。我的处理方式是发送时带一个短超时比如pdMS_TO_TICKS(10)超时后记录一次丢包计数并通过串口打印警告。if (xQueueSend(xSensorQueue1, data, pdMS_TO_TICKS(10)) ! pdPASS) { drop_count; printf(Queue full, drop count: %d\n, drop_count); }丢包计数在显示任务里显示出来方便观察系统健康度。如果丢包频繁说明队列长度不够或者接收任务处理太慢需要针对性优化。4.3 ESP8266通信超时与重试ESP8266的AT指令超时是家常便饭尤其是网络信号不好的时候。我的重试策略是指数退避第一次超时等1秒重试第二次等2秒第三次等4秒三次都失败就重启模块。重启模块用硬件复位拉低ESP8266的EN引脚100毫秒再拉高然后等2秒让它启动完成。启动完成后重新发AT测试通了再继续业务流程。实操心得ESP8266的AT固件里ATCIPSTART如果目标服务器不可达可能卡住十几秒才返回错误。可以在发指令前先发ATCIPCLOSE确保之前的连接已关闭减少卡死概率。另外ATCWMODE1设为Station模式别用AP模式省电且稳定。4.4 常见问题速查表现象可能原因排查方法解决措施系统随机死机栈溢出开栈溢出检测查水位增大任务栈队列发送失败队列满打印丢包计数增大队列长度或加快接收OLED花屏IIC时序不稳示波器看SCL/SDA波形降低IIC速率加延时ESP8266无响应供电不足万用表测模块电压加电容或独立供电温湿度读数全0时序被打断检查临界区保护关中断保护采样段调度器启动失败堆内存不足查MallocFailed钩子增大configTOTAL_HEAP_SIZE任务不切换优先级配置错误查任务优先级确保有阻塞点让出CPU串口丢数据中断优先级冲突查NVIC配置FreeRTOS中断优先级设为5以上4.5 中断优先级与FreeRTOS的配合Cortex-M3的中断优先级数值越小优先级越高FreeRTOS要求调用内核API的中断优先级必须不低于configMAX_SYSCALL_INTERRUPT_PRIORITY数值上大于等于。我设的是5意味着优先级0-4的中断不能调用xQueueSendFromISR这类API。串口中断我设的优先级是6可以安全调用xQueueSendFromISR。如果设成4调用内核API时可能触发断言失败或者更隐蔽的错误。这个坑我在移植初期踩过串口中断里发队列导致系统卡死查了两天才定位到优先级问题。注意STM32的HAL库默认把SysTick优先级设得最高但FreeRTOS需要接管SysTick所以要在HAL_Init之后、vTaskStartScheduler之前重新配置SysTick和PendSV的优先级为最低数值最大。这一步在FreeRTOSConfig.h里通过configKERNEL_INTERRUPT_PRIORITY和configMAX_SYSCALL_INTERRUPT_PRIORITY控制移植时务必确认。5. 系统优化与扩展思路5.1 CPU占用率与功耗优化系统跑起来之后我用空闲任务钩子统计CPU占用率。原理很简单空闲任务运行时对一个计数器累加每秒读一次计数值和理论最大值比较得出空闲比例。void vApplicationIdleHook(void) { idle_count; }实测下来四个任务加上中断CPU占用率约35%空闲65%。还有不少优化空间。比如把DHT11的读取周期从2秒延长到5秒温湿度变化本来就不快OLED刷新从500毫秒改成1秒肉眼几乎看不出差别。调整后CPU占用率降到20%以下。功耗方面如果做电池供电的版本可以在空闲时调__WFI()指令让CPU进入睡眠等中断唤醒。FreeRTOS的空闲钩子里加这一句就行void vApplicationIdleHook(void) { __WFI(); }但要注意如果用了软件IIC的延时循环__WFI可能被频繁唤醒省电效果打折扣。硬件IIC或者DMA方式更适合低功耗场景。5.2 从HTTP到MQTT的协议升级HTTP上报是短连接每次都要建TCP、发请求、收响应、关连接开销大。如果上报频率高建议换成MQTT长连接。ESP8266的AT固件支持MQTT指令集连上Broker之后保持心跳发数据只需一个ATMQTTPUB省去了反复建连的开销。MQTT的AT指令流程ATMQTTUSERCFG0,1,client_id,username,password,0,0, ATMQTTCONN0,broker_address,1883,1 ATMQTTPUB0,topic,data,0,0心跳间隔设60秒Broker一般要求90秒内必须有报文否则断开。我在CloudTask里加了个心跳计时器超时没发数据就发一个空报文保活。5.3 用LVGL做更丰富的界面如果OLED显示内容复杂比如要画曲线、做动画裸写SSD1306驱动会很痛苦。LVGL是个轻量级图形库移植到STM32上配合FreeRTOS跑效果不错。移植的关键是提供一个flush_cb回调LVGL把渲染好的缓冲区交给你你负责刷到屏幕。FreeRTOS下跑LVGL要注意线程安全LVGL的API不是线程安全的多个任务同时调会出问题。标准做法是用一个互斥量保护所有LVGL调用或者把所有UI操作都放到同一个任务里其他任务通过队列发消息给UI任务。// UI任务里处理消息 void UiTask(void *pvParameters) { while (1) { UiMsg_t msg; if (xQueueReceive(xUiQueue, msg, portMAX_DELAY) pdPASS) { lv_label_set_text(msg.label, msg.text); } lv_task_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }lv_task_handler要定期调用5毫秒一次比较合适太频繁浪费CPU太慢动画卡顿。5.4 固件升级的预留设计产品化的话OTA升级是刚需。STM32的OTA一般分两种一种是BootloaderApp双区方案Bootloader负责接收新固件写入App区另一种是利用ESP8266的透传功能把固件通过串口传给STM32。我预留的方案是第二种ESP8266从服务器下载固件到自己的Flash然后通过串口分包发给STM32STM32收到后写入内部Flash的App区重启后Bootloader跳转到新固件。这个方案对STM32的Flash空间要求不高但传输速度受串口限制115200波特率下传100KB固件约需10秒。Bootloader里要做的事检查App区固件有效性比如校验魔术字和CRC有效则跳转无效则留在Bootloader等待升级。跳转前要关中断、设栈指针、设向量表偏移这几步顺序不能错。// 跳转到App typedef void (*pFunction)(void); pFunction JumpToApp; uint32_t app_addr 0x08004000; if (((*(uint32_t*)app_addr) 0x2FFE0000) 0x20000000) { JumpToApp (pFunction)(*(uint32_t*)(app_addr 4)); __set_MSP(*(uint32_t*)app_addr); SCB-VTOR app_addr; JumpToApp(); }这段代码检查App区栈顶地址是否合法在SRAM范围内合法才跳转。SCB-VTOR设置向量表偏移让中断向量指向App区。漏了这一步App里的中断会跳到Bootloader的向量表直接跑飞。6. 写在最后的一些实操体会这个项目从最初的单任务轮询到FreeRTOS多任务最大的感受是实时性问题本质上是时间管理问题。单任务模型下你只能靠delay硬凑任务之间互相绑架多任务模型下每个任务有自己的节奏内核帮你做时间切片和优先级仲裁你只需要关心每个任务内部的逻辑。FreeRTOS的API不多常用的就任务创建、队列、信号量、延时这几个但用好不容易。优先级怎么定、栈给多大、队列多长、超时设多少这些参数没有标准答案得根据实际负载调。我的经验是先跑起来再用工具测根据数据调。uxTaskGetStackHighWaterMark查栈、空闲钩子算CPU占用、丢包计数看队列健康度这几个手段配合起来系统状态一目了然。ESP8266这个模块便宜是真便宜坑也是真多。供电要足、AT固件版本要确认、超时重试要做好、状态机要写严谨。把它当成一个不可靠的通信管道来对待所有操作都假设可能失败加上重试和降级逻辑系统才能稳定跑下去。最后分享一个调试小技巧在串口打印里加上任务名和节拍时间戳比如[CloudTask][12345] Send OK这样出问题时能清楚看到是哪个任务、在什么时间点出的错。FreeRTOS有个pcTaskGetName函数可以获取当前任务名配合xTaskGetTickCount打时间戳排查时序相关问题特别有用。