STM32 FreeRTOS多任务实战:DHT11+OLED+ESP8266云端上传
发布时间:2026/9/19 10:45:07来源:尧图网络
1. 从裸机到多任务这个项目到底在解决什么问题做过STM32裸机开发的朋友应该都有体会一个while(1)大循环里塞满了各种事情温湿度传感器要定时读OLED屏幕要刷新串口要收发数据还得抽空处理按键。刚开始功能少的时候还能应付一旦任务多起来代码就变成了一锅粥——改一个地方另一个地方就出问题调试起来让人抓狂。这个项目的核心思路很明确用FreeRTOS把STM32上的三个主要功能拆成独立任务让它们各司其职、互不干扰。温湿度采集任务负责定时读取传感器数据OLED显示任务负责把数据刷新到屏幕上云端通信任务负责通过ESP8266把数据上传到云平台。三个任务通过FreeRTOS的队列和信号量进行通信由调度器统一管理CPU时间。为什么选择FreeRTOS而不是自己写时间片轮询因为FreeRTOS提供了抢占式调度、任务间通信、优先级管理等成熟机制代码量小、移植方便在Cortex-M3内核上跑起来非常流畅。STM32F103系列是Cortex-M3内核的经典代表主频72MHzSRAM有20KB到64KB不等跑FreeRTOS绰绰有余。这个项目适合谁如果你已经会用STM32的HAL库点灯、会配置串口和I2C但还没接触过RTOS那这就是一个非常好的入门实战。如果你已经在用FreeRTOS但总是遇到任务卡死、堆栈溢出、优先级反转这些问题这篇文章也会帮你理清思路。注意本文基于STM32F103C8T6 FreeRTOS HAL库 DHT11 SSD1306 OLED ESP8266AT固件的常见组合来展开其他型号的芯片和传感器思路一致只需调整底层驱动即可。2. 整体架构设计任务怎么划分、通信怎么做2.1 为什么是这三个任务任务划分是RTOS项目的第一步也是最关键的一步。划分得好代码清晰、调试方便划分得不好任务之间耦合严重还不如裸机。温湿度采集任务的核心诉求是“定时”——DHT11这类传感器对时序要求很严格读取间隔不能太短至少1秒以上否则数据会出错。把它做成独立任务用vTaskDelay控制采集周期既保证了时序又不占用CPU。OLED显示任务的核心诉求是“刷新”——SSD1306通过I2C通信一帧数据写下来大概需要十几毫秒。如果放在采集任务里做会阻塞采集的时序放在独立任务里用队列接收最新数据屏幕刷新和传感器读取互不影响。云端通信任务的核心诉求是“异步”——ESP8266通过AT指令和云端交互每次发送数据可能要等几百毫秒甚至几秒。这种耗时操作绝对不能放在其他任务里否则整个系统都会被拖慢。独立成任务后它可以慢慢等响应其他任务照常运行。三个任务的优先级怎么定我的经验是采集任务优先级最高保证数据实时性通信任务次之保证数据不丢显示任务最低屏幕刷新慢一点用户感知不明显。在FreeRTOS中优先级数值越大优先级越高所以可以设为任务名称优先级栈大小职责温湿度采集3256字定时读取DHT11发送到队列云端通信2512字接收队列数据通过ESP8266上传OLED显示1256字接收队列数据刷新屏幕栈大小这里用的是“字”word在32位MCU上1字4字节。通信任务栈要大一些因为AT指令拼接和解析需要更多局部变量。2.2 队列和信号量的选型逻辑任务之间传递数据最忌讳用全局变量。全局变量在裸机里勉强能用但在RTOS里多个任务同时读写不加保护就会出问题。FreeRTOS提供了几种通信机制这个项目里主要用到两种消息队列Queue用于传递温湿度数据。采集任务把数据打包成一个结构体发送到队列显示任务和通信任务分别从队列接收。这里有个细节一个队列的数据被多个任务接收时每个数据只能被一个任务取走。如果显示和通信都需要同一份数据要么用两个队列要么用“队列集”或者直接让采集任务分别发送到两个队列。我通常的做法是建两个队列采集任务往两个队列各发一份简单直接。二值信号量Binary Semaphore用于任务同步。比如通信任务需要等ESP8266回复“OK”后才能发下一条指令这时候可以用信号量来阻塞等待而不是用死循环去轮询。串口中断收到“OK”后释放信号量通信任务立刻被唤醒效率高且不浪费CPU。/* 数据结构定义 */ typedef struct { float temperature; float humidity; uint32_t timestamp; } SensorData_t; /* 队列句柄 */ QueueHandle_t xQueue_Display; QueueHandle_t xQueue_Cloud; /* 信号量句柄 */ SemaphoreHandle_t xSemaphore_ESP8266;提示队列的长度要根据实际消费速度来定。如果通信任务因为网络问题阻塞了采集任务还在往队列里发数据队列满了之后xQueueSend会返回失败。这时候可以选择丢弃旧数据或者阻塞等待具体策略取决于业务需求。2.3 系统整体数据流整个系统的数据流是这样的DHT11采集任务每2秒读取一次温湿度打包成SensorData_t结构体分别发送到显示队列和通信队列。显示队列的数据被OLED任务取出格式化后刷新到屏幕上。通信队列的数据被云端任务取出拼成AT指令通过串口发给ESP8266ESP8266再通过WiFi把数据传到云平台。这个架构的好处是任何一个环节出问题不会立刻影响其他环节。比如WiFi断了通信任务会阻塞在信号量上但采集和显示照常运行屏幕上依然能看到实时温湿度。等WiFi恢复了通信任务自动继续工作。3. 核心细节解析从驱动到调度的关键实现3.1 DHT11驱动与采集任务的配合DHT11是单总线器件时序要求比较严格。HAL库的HAL_Delay最小分辨率是1ms而DHT11的时序是微秒级的所以不能用HAL_Delay来做。我的做法是用__NOP()或者DWT计数器来做微秒级延时。/* 微秒级延时基于DWT */ void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }DHT11的读取过程分三步主机发送起始信号拉低至少18ms然后释放总线等待DHT11响应最后读取40位数据。每一位数据以50us低电平开始高电平的持续时间决定是0还是1——26-28us表示070us表示1。在FreeRTOS任务中调用DHT11读取函数时要注意关中断的问题。读取过程中需要关中断来保证时序不被其他中断打断但关中断时间不能太长否则会影响系统实时性。DHT11一次读取大概需要4-5ms这个时间关中断是可以接受的。void vTask_Sensor(void *pvParameters) { SensorData_t data; while (1) { taskENTER_CRITICAL(); DHT11_Read(data.temperature, data.humidity); taskEXIT_CRITICAL(); data.timestamp xTaskGetTickCount(); xQueueSend(xQueue_Display, data, 0); xQueueSend(xQueue_Cloud, data, 0); vTaskDelay(pdMS_TO_TICKS(2000)); } }注意taskENTER_CRITICAL()会关闭中断如果DHT11读取失败卡在里面系统会直接死机。建议在读取函数内部加超时机制比如检测到电平一直不变就强制退出。3.2 OLED的I2C驱动与显示任务SSD1306 OLED模块支持I2C和SPI两种接口0.96寸的小屏一般用I2C接线简单VCC、GND、SCL、SDA四根线。STM32的硬件I2C用起来有时候会卡死很多开发者更倾向于用软件I2C。软件I2C的好处是引脚灵活、调试方便坏处是速度慢一些。对于OLED这种刷新率要求不高的场景软件I2C完全够用。/* 软件I2C写字节 */ void I2C_WriteByte(uint8_t addr, uint8_t data) { I2C_Start(); I2C_SendByte(addr 1); I2C_WaitAck(); I2C_SendByte(data); I2C_WaitAck(); I2C_Stop(); }OLED的显存是128x64位也就是1KB。每次刷新可以先在MCU的RAM里建一个缓冲区修改完缓冲区后一次性写入OLED这样屏幕不会闪烁。SSD1306的I2C地址通常是0x78写地址或0x7A读地址具体看模块背后的电阻配置。显示任务从队列接收数据后用sprintf格式化字符串然后调用OLED显示函数。这里要注意sprintf的浮点数支持——Keil默认的C库可能不支持%f需要在工程设置里勾选“Use MicroLIB”或者启用浮点打印支持。void vTask_Display(void *pvParameters) { SensorData_t data; char buf[32]; OLED_Init(); OLED_Clear(); while (1) { if (xQueueReceive(xQueue_Display, data, portMAX_DELAY) pdTRUE) { OLED_ClearBuffer(); sprintf(buf, Temp: %.1f C, data.temperature); OLED_ShowString(0, 0, (uint8_t*)buf); sprintf(buf, Humi: %.1f %%, data.humidity); OLED_ShowString(0, 16, (uint8_t*)buf); OLED_Refresh(); } } }提示OLED刷新频率不要太高I2C写入1KB数据大概需要10-20ms如果每秒刷新几十次CPU会被大量占用。温湿度数据2秒更新一次显示任务跟着2秒刷新一次就足够了。3.3 ESP8266的AT指令与云端通信任务ESP8266出厂一般自带AT固件通过串口发送AT指令就能控制。和STM32连接很简单ESP8266的TX接STM32的RXRX接STM32的TX供电要注意ESP8266峰值电流可能达到300mA最好单独供电或者加个大电容。通信任务的流程是这样的先发送AT测试模块是否在线然后ATCWMODE1设置为Station模式ATCWJAP连接WiFiATCIPSTART建立TCP连接最后ATCIPSEND发送数据。每一步都要等模块回复“OK”才能进行下一步。void vTask_Cloud(void *pvParameters) { SensorData_t data; char cmd[128]; ESP8266_Init(); while (1) { if (xQueueReceive(xQueue_Cloud, data, portMAX_DELAY) pdTRUE) { sprintf(cmd, ATCIPSEND%d\r\n, strlen(payload)); ESP8266_SendCmd(cmd, , 1000); sprintf(payload, {\temp\:%.1f,\humi\:%.1f}, data.temperature, data.humidity); ESP8266_SendData(payload); ESP8266_WaitResponse(SEND OK, 5000); } } }这里的关键是ESP8266_WaitResponse的实现。最简单的做法是轮询串口接收缓冲区但这样会浪费CPU。更好的做法是用串口中断信号量串口每收到一个字节就存入缓冲区收到“OK”或“ERROR”时释放信号量通信任务阻塞在信号量上等待。/* 串口中断回调 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart huart2) { rx_buffer[rx_index] rx_byte; if (strstr((char*)rx_buffer, OK)) { xSemaphoreGiveFromISR(xSemaphore_ESP8266, NULL); } HAL_UART_Receive_IT(huart2, rx_byte, 1); } }注意xSemaphoreGiveFromISR的第二个参数用于任务切换如果中断中释放信号量后需要立刻切换任务要传入pdTRUE并调用portYIELD_FROM_ISR()。在Cortex-M3上FreeRTOS通过PendSV异常来实现任务切换这部分细节后面会展开。4. 实操过程从零搭建这个多任务系统4.1 开发环境搭建与FreeRTOS移植开发环境用Keil MDK5 STM32CubeMX是最省事的组合。CubeMX可以直接勾选FreeRTOS中间件自动生成初始化代码。但自动生成的代码有时候不够灵活我更喜欢手动移植FreeRTOS源码这样对内核的理解会更深入。手动移植的步骤从FreeRTOS官网下载源码包把FreeRTOS/Source目录下的核心文件tasks.c、queue.c、list.c、timers.c等和portable/RVDS/ARM_CM3目录下的移植文件复制到工程里。然后在Keil中添加头文件路径配置FreeRTOSConfig.h。FreeRTOSConfig.h是移植的核心几个关键配置#define configUSE_PREEMPTION 1 /* 抢占式调度 */ #define configUSE_IDLE_HOOK 0 /* 空闲钩子调试时可开 */ #define configUSE_TICK_HOOK 0 #define configCPU_CLOCK_HZ 72000000 /* 72MHz */ #define configTICK_RATE_HZ 1000 /* 1ms一个tick */ #define configMAX_PRIORITIES 5 /* 最大优先级数 */ #define configMINIMAL_STACK_SIZE 128 /* 空闲任务栈大小 */ #define configTOTAL_HEAP_SIZE (10 * 1024) /* 堆大小10KB */ #define configUSE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configCHECK_FOR_STACK_OVERFLOW 2 /* 栈溢出检测 */configTICK_RATE_HZ设为1000意味着每个tick是1ms这是最常用的配置。configTOTAL_HEAP_SIZE决定了xTaskCreate能分配多少内存STM32F103C8T6只有20KB RAM10KB给FreeRTOS堆剩下的给全局变量和栈刚好够用。提示如果编译时报错“undefined symbol”检查是否把port.c和heap_4.c加进了工程。heap_4.c支持内存碎片合并比heap_1.c和heap_2.c更适合长期运行的系统。4.2 任务创建与启动调度器所有任务创建完成后调用vTaskStartScheduler()启动调度器。这个函数不会返回除非内存分配失败。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_USART1_UART_Init(); MX_USART2_UART_Init(); /* 创建队列 */ xQueue_Display xQueueCreate(5, sizeof(SensorData_t)); xQueue_Cloud xQueueCreate(5, sizeof(SensorData_t)); /* 创建信号量 */ xSemaphore_ESP8266 xSemaphoreCreateBinary(); /* 创建任务 */ xTaskCreate(vTask_Sensor, Sensor, 256, NULL, 3, NULL); xTaskCreate(vTask_Display, Display, 256, NULL, 1, NULL); xTaskCreate(vTask_Cloud, Cloud, 512, NULL, 2, NULL); /* 启动调度器 */ vTaskStartScheduler(); while (1); }任务创建时传入的栈大小是“字”为单位256字1KB。如果任务里用了printf或sprintf栈要适当加大因为格式化函数会占用较多栈空间。我一般会在调试阶段把栈设大一些稳定后再逐步减小用uxTaskGetStackHighWaterMark查看栈使用峰值。4.3 串口中断与信号量的配合ESP8266的通信依赖串口中断。STM32的HAL库提供了HAL_UART_Receive_IT函数可以逐字节接收。在中断回调里判断是否收到关键字符串然后释放信号量。uint8_t rx_byte; uint8_t rx_buffer[256]; uint16_t rx_index 0; void ESP8266_Init(void) { HAL_UART_Receive_IT(huart2, rx_byte, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { if (rx_index sizeof(rx_buffer) - 1) { rx_buffer[rx_index] rx_byte; rx_buffer[rx_index] \0; } if (strstr((char*)rx_buffer, OK\r\n) ! NULL) { BaseType_t xHigherPriorityTaskWoken pdFALSE; xSemaphoreGiveFromISR(xSemaphore_ESP8266, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); rx_index 0; memset(rx_buffer, 0, sizeof(rx_buffer)); } HAL_UART_Receive_IT(huart2, rx_byte, 1); } }这里有个细节rx_buffer的清零时机。如果在收到“OK”后立刻清零那么通信任务可能还没来得及读取缓冲区内容。更好的做法是通信任务读取完后再清零或者用双缓冲区。我在实际项目中通常让通信任务自己管理缓冲区中断只负责填充和释放信号量。4.4 云端数据格式与上传策略云平台的数据格式一般是JSON比如{temp:25.3,humi:60.2}。ESP8266发送数据前要先发ATCIPSEND长度等模块回复后再发实际数据。void ESP8266_SendData(const char *data) { char cmd[32]; sprintf(cmd, ATCIPSEND%d\r\n, strlen(data)); ESP8266_SendCmd(cmd, , 1000); HAL_UART_Transmit(huart2, (uint8_t*)data, strlen(data), 1000); }上传策略上我建议不要每次采集都上传。温湿度变化缓慢30秒或1分钟上传一次就足够了。可以在通信任务里加一个计数器每收到N次数据才上传一次减少网络流量和服务器压力。void vTask_Cloud(void *pvParameters) { SensorData_t data; uint8_t count 0; while (1) { if (xQueueReceive(xQueue_Cloud, data, portMAX_DELAY) pdTRUE) { count; if (count 15) { /* 2秒一次15次就是30秒 */ count 0; Cloud_Upload(data); } } } }注意如果队列满了采集任务发送数据会失败。可以在发送时加一个超时比如xQueueSend(xQueue_Cloud, data, pdMS_TO_TICKS(100))超时后丢弃数据并记录错误计数。这样既不会阻塞采集任务也能通过错误计数发现通信是否异常。5. 常见问题与排查技巧实录5.1 任务卡死与栈溢出FreeRTOS项目最常见的问题就是任务卡死。表现是某个任务不再运行或者整个系统死机。原因通常有两个栈溢出和优先级反转。栈溢出检测在FreeRTOSConfig.h里开启configCHECK_FOR_STACK_OVERFLOW设为2时会在任务切换时检查栈指针是否越界。如果检测到溢出会调用vApplicationStackOverflowHook可以在这个函数里点亮LED或打印信息。void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { printf(Stack overflow in task: %s\r\n, pcTaskName); while (1); }排查栈溢出最直接的方法是用uxTaskGetStackHighWaterMark查看每个任务的栈使用峰值。如果某个任务的剩余栈空间长期低于20%就该加大栈了。问题现象可能原因排查方法任务不运行栈溢出查看HighWaterMark系统死机中断中调用了阻塞API检查中断回调数据错乱队列被多任务同时写检查队列使用通信超时信号量未释放检查串口中断5.2 优先级反转与互斥量优先级反转是RTOS里的经典问题低优先级任务持有互斥量高优先级任务等待互斥量中优先级任务抢占CPU导致高优先级任务被间接阻塞。FreeRTOS的互斥量支持优先级继承可以在一定程度上缓解这个问题。SemaphoreHandle_t xMutex xSemaphoreCreateMutex(); void vTask_High(void *pvParameters) { while (1) { xSemaphoreTake(xMutex, portMAX_DELAY); /* 访问共享资源 */ xSemaphoreGive(xMutex); vTaskDelay(pdMS_TO_TICKS(100)); } }提示互斥量和二值信号量的区别在于互斥量有优先级继承机制适合保护共享资源二值信号量适合任务同步。不要用二值信号量来保护共享资源否则可能出现优先级反转。5.3 ESP8266连接不稳定的处理ESP8266在实际使用中经常遇到连接不稳定、AT指令无响应的问题。我的经验是加三重保障硬件上加电容、软件上加超时重试、逻辑上加心跳检测。硬件方面ESP8266的VCC和GND之间并一个100uF电解电容和一个0.1uF陶瓷电容能有效滤除电源纹波。软件方面每条AT指令都要设超时超时后重试重试3次失败就重启ESP8266通过ATRST或控制EN引脚。uint8_t ESP8266_SendCmd(const char *cmd, const char *expect, uint32_t timeout) { uint8_t retry 3; while (retry--) { HAL_UART_Transmit(huart2, (uint8_t*)cmd, strlen(cmd), 1000); if (xSemaphoreTake(xSemaphore_ESP8266, pdMS_TO_TICKS(timeout)) pdTRUE) { if (strstr((char*)rx_buffer, expect) ! NULL) { return 0; /* 成功 */ } } } return 1; /* 失败 */ }心跳检测的做法是每隔一段时间发送AT指令如果连续3次无响应就重启模块。这个逻辑可以放在通信任务的空闲周期里做。5.4 OLED显示异常的排查OLED不亮或者显示乱码通常有三个原因I2C地址不对、初始化序列不完整、供电不足。0.96寸OLED的I2C地址一般是0x78但有些模块是0x7A可以用I2C扫描程序确认。初始化序列要严格按照SSD1306数据手册来特别是电荷泵设置0x8D, 0x14和显示开启0xAF。如果初始化序列少了某一条屏幕可能完全不亮或者显示异常。供电方面有些OLED模块标称3.3V供电但实际上5V也能用而且亮度更高。如果3.3V下屏幕很暗可以试试5V。但要注意I2C电平如果MCU是3.3V而OLED是5V供电SCL和SDA线上最好加电平转换。/* SSD1306初始化序列部分 */ OLED_WriteCmd(0xAE); /* 关闭显示 */ OLED_WriteCmd(0x20); /* 设置内存寻址模式 */ OLED_WriteCmd(0x10); /* 页寻址模式 */ OLED_WriteCmd(0xB0); /* 页起始地址 */ OLED_WriteCmd(0xC8); /* COM扫描方向 */ OLED_WriteCmd(0x8D); /* 电荷泵设置 */ OLED_WriteCmd(0x14); /* 开启电荷泵 */ OLED_WriteCmd(0xAF); /* 开启显示 */注意I2C通信速率不要设太高软件I2C的延时根据主频调整一般1-2us的延时对应100kHz左右。速率太高会导致通信失败太低则刷新慢。6. 调试技巧与性能优化经验6.1 用SEGGER SystemView分析任务调度FreeRTOS自带的vTaskList和vTaskGetRunTimeStats可以查看任务状态和CPU占用率但不够直观。SEGGER SystemView是一个免费的工具可以图形化显示任务切换、中断、队列操作的时间线对分析调度问题非常有帮助。使用SystemView需要在FreeRTOSConfig.h里开启configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS然后添加SEGGER的RTT和SystemView源码。通过J-Link连接STM32就能在电脑上看到实时的任务调度图。我一般用SystemView来检查三件事任务切换是否频繁频繁切换说明优先级设置不合理、中断执行时间是否过长过长会影响任务响应、空闲任务占比是否合理占比太低说明CPU负载过重。6.2 动态调整任务优先级任务优先级不是一成不变的。在实际运行中可以根据系统状态动态调整。比如WiFi断开时通信任务需要频繁重连可以临时提高优先级WiFi正常时通信任务优先级可以降低让采集和显示更流畅。void vTask_Cloud(void *pvParameters) { while (1) { if (WiFi_IsConnected()) { vTaskPrioritySet(NULL, 2); /* 正常优先级 */ } else { vTaskPrioritySet(NULL, 4); /* 提高优先级加快重连 */ } vTaskDelay(pdMS_TO_TICKS(1000)); } }提示动态调整优先级要谨慎提得太高可能导致其他任务饿死。一般只在系统异常时临时提高恢复正常后立刻降回来。6.3 低功耗优化思路如果这个项目要用电池供电低功耗就很重要了。FreeRTOS的空闲任务钩子可以用来进入低功耗模式。STM32F103支持Sleep、Stop、Standby三种低功耗模式Sleep模式唤醒最快Stop模式功耗更低但唤醒慢一些。void vApplicationIdleHook(void) { __WFI(); /* 等待中断进入Sleep模式 */ }但要注意进入Sleep模式后系统tick会停止vTaskDelay的计时会不准。解决办法是用一个定时器来提供tick或者在Sleep前计算好下一个任务唤醒时间用RTC闹钟唤醒。实际项目中如果ESP8266一直开着WiFi功耗很难降下来。可以考虑让ESP8266间歇性工作采集一段时间后上传数据然后进入深度睡眠定时唤醒。这样平均功耗可以降到毫安级。6.4 代码分层与可维护性最后说一个容易被忽视但很重要的点代码分层。很多初学者把所有代码都写在main.c里任务函数、驱动、通信混在一起后期维护非常痛苦。我的做法是分三层驱动层DHT11、OLED、ESP8266的底层驱动不依赖FreeRTOS服务层队列、信号量的创建和管理任务函数的实现应用层业务逻辑比如数据格式化、上传策略这样分层后换一个传感器只需要改驱动层换一个云平台只需要改应用层任务框架不用动。代码复用率大大提高调试也更容易定位问题。/* 目录结构示例 */ Project/ ├── Drivers/ │ ├── dht11.c/h │ ├── oled.c/h │ └── esp8266.c/h ├── Services/ │ ├── task_sensor.c/h │ ├── task_display.c/h │ └── task_cloud.c/h ├── App/ │ └── main.c └── FreeRTOS/ ├── Source/ └── portable/我在实际项目中踩过最大的坑就是一开始没分层后来加功能时改一处动全身。分层之后即使项目规模扩大几倍代码依然清晰可控。这个经验对任何RTOS项目都适用越早分层后期越省事。
网站建设高端定制企业官网