Wokwi 仿真 STM32 上跑 FreeRTOS 多传感器房间监测系统
发布时间:2026/11/4 1:01:33 锦皓数字建站

1. 为什么要在 Wokwi 上跑 FreeRTOS 多传感器房间监测嵌入式开发有个很现实的痛点手头没有硬件或者硬件还在快递路上但代码逻辑想先跑通。尤其是 FreeRTOS 这种涉及多任务调度、队列通信、信号量同步的实时操作系统光看文档不动手永远不知道任务优先级设反了会出什么问题。STM32 加 FreeRTOS 的组合是嵌入式领域最经典的搭配之一但传统开发方式需要 Keil、CubeMX、烧录器、串口线一整套工具链环境搭建就能劝退一批人。Wokwi 这个在线仿真平台解决的就是这个问题。它支持 STM32 系列芯片的仿真能直接跑 FreeRTOS 代码还能挂载多种传感器外设。你只需要一个浏览器就能把多任务房间监测系统跑起来看到任务调度、传感器数据采集、OLED 显示刷新的完整过程。这篇文章要拆解的就是这样一个项目在 Wokwi 上用 STM32 跑 FreeRTOS接多个传感器做房间环境监测。这个项目适合谁如果你正在学 FreeRTOS 的任务管理、队列、信号量或者想做一个基于 STM32 的毕业设计但硬件还没到位又或者你只是想快速验证一个多传感器采集方案是否可行这套东西都能直接拿来用。核心关键词 FreeRTOS、STM32、Wokwi、Multisensor、Room Monitor 会贯穿全文我会把任务划分、优先级设计、队列通信、传感器驱动、仿真配置这些环节全部拆开讲清楚。2. 系统整体设计与任务划分思路2.1 为什么选 FreeRTOS 而不是裸机轮询裸机写多传感器采集最直接的方式是主循环里依次读温度、读湿度、读光照、刷新显示每个环节加延时。这种写法在传感器少、实时性要求低的时候没问题但一旦传感器数量上去问题就来了某个传感器响应慢会拖住整个循环显示刷新不及时按键响应卡顿。更麻烦的是你没法给不同任务分配不同的优先级紧急事件比如有人按了报警按钮必须等当前循环跑完才能处理。FreeRTOS 的价值在这里就体现出来了。它把每个功能拆成独立任务由调度器根据优先级决定谁先跑。传感器采集任务可以设低优先级慢慢跑按键处理任务设高优先级保证即时响应显示刷新任务设中等优先级维持视觉流畅。任务之间通过队列传递数据通过信号量做资源保护整个系统结构清晰扩展也方便。后面想加一个传感器只需要新建一个任务不用动其他代码。2.2 任务划分与优先级设计这个房间监测系统我划分了五个任务优先级从高到低排列任务名称优先级栈大小职责调度周期KeyTask4最高128 words按键扫描与事件分发20msSensorTask3256 words多传感器数据采集500msDisplayTask2512 wordsOLED 显示刷新200msAlarmTask2128 words阈值判断与报警事件驱动MonitorTask1最低256 words串口输出监测数据1000ms优先级设计的逻辑是这样的按键响应必须最快人手按下去到系统反应不能超过 50ms否则体验很差所以给最高优先级 4。传感器采集需要一定实时性但不需要毫秒级响应500ms 采集一次足够给优先级 3。显示刷新和报警判断给优先级 2显示刷新周期 200ms 是为了视觉上不闪烁报警任务由队列事件触发平时处于阻塞状态不占 CPU。串口监测数据输出最不重要给最低优先级 1被其他任务抢占也无所谓。栈大小的分配也有讲究。DisplayTask 栈给到 512 words 是因为 OLED 驱动里可能有浮点运算和字符串格式化这些操作吃栈。SensorTask 给 256 words 是因为 I2C 读写和简单的数据转换不需要太多栈空间。KeyTask 逻辑简单128 words 够用。这些数值不是拍脑袋定的后面我会讲怎么用 FreeRTOS 的栈溢出检测来验证和调整。2.3 任务间通信机制选型任务之间不能直接共享全局变量那样会有竞态条件。FreeRTOS 提供了几种通信机制这个项目里我用到了三种队列Queue用于传感器任务向显示任务和报警任务传递数据。SensorTask 采集完一组数据后把数据打包成一个结构体通过队列发送出去。DisplayTask 和 AlarmTask 分别从队列接收。队列的好处是自带阻塞机制队列空的时候接收任务自动挂起不浪费 CPU。二值信号量Binary Semaphore用于按键事件通知。KeyTask 检测到按键按下后释放信号量AlarmTask 获取信号量后执行相应操作。这里不用队列是因为按键事件只需要通知“发生了”不需要传递具体数据。互斥量Mutex用于保护 I2C 总线。传感器任务和显示任务都可能访问 I2C 总线OLED 也是 I2C 接口如果两个任务同时发起 I2C 传输数据就会乱。互斥量保证同一时刻只有一个任务能占用 I2C 总线。注意互斥量和二值信号量长得很像但互斥量有优先级继承机制能防止优先级翻转问题。I2C 总线保护必须用互斥量不能用二值信号量。3. Wokwi 仿真环境搭建与 STM32 配置3.1 Wokwi 项目创建与芯片选型Wokwi 的 STM32 仿真支持几种型号我选的是 STM32F103C8T6也就是常说的“蓝板”芯片。选它的原因很简单这是 STM32 入门最普遍的型号资料多社区支持好Wokwi 对它的外设仿真也最完整。创建项目时在 Wokwi 首页选择 “STM32” 模板然后从芯片列表里挑 STM32F103C8T6。项目创建后你会看到两个核心文件sketch.ino或者main.c取决于你选的框架和diagram.json。diagram.json是 Wokwi 的硬件连接描述文件所有传感器、显示屏、按键的接线都在这里定义。这个文件用 JSON 格式描述元件和连线刚开始看可能有点懵但结构其实很清晰。3.2 diagram.json 硬件连接配置这个项目用到的外设包括DHT22 温湿度传感器、BH1750 光照传感器、SSD1306 OLED 显示屏、一个按键模块、一个 LED 报警灯。DHT22 用单总线协议接在 PA0BH1750 和 SSD1306 都是 I2C 设备共用 PB6SCL和 PB7SDA按键接在 PA1LED 接在 PA2。diagram.json的关键配置片段长这样{ parts: [ { type: wokwi-dht22, id: dht1, attrs: { temperature: 25, humidity: 60 } }, { type: wokwi-bh1750, id: bh1, attrs: { lux: 300 } }, { type: wokwi-ssd1306, id: oled1, attrs: { address: 0x3C } } ], connections: [ [ dht1:VCC, board:3V3, red, [] ], [ dht1:GND, board:GND, black, [] ], [ dht1:SDA, board:PA0, green, [] ], [ bh1:SCL, board:PB6, blue, [] ], [ bh1:SDA, board:PB7, green, [] ], [ oled1:SCL, board:PB6, blue, [] ], [ oled1:SDA, board:PB7, green, [] ] ] }这里有个细节要注意BH1750 和 OLED 共用 I2C 总线它们的 SCL 和 SDA 都接到 PB6 和 PB7。Wokwi 支持这种总线式连接多个设备挂同一条 I2C 线上没问题只要地址不冲突。BH1750 的默认地址是 0x23SSD1306 是 0x3C不冲突。3.3 FreeRTOS 在 Wokwi 上的引入方式Wokwi 的 STM32 仿真环境默认不包含 FreeRTOS 库需要手动引入。有两种方式一种是把 FreeRTOS 源码文件直接放进项目里另一种是用 Wokwi 支持的库管理机制。我推荐第一种因为 FreeRTOS 的配置文件FreeRTOSConfig.h需要根据项目需求定制直接放源码更灵活。具体操作是在项目里新建一个FreeRTOS文件夹把 FreeRTOS-Kernel 的核心文件拷进去包括tasks.c、queue.c、list.c、timers.c、port.c和对应的头文件。然后修改FreeRTOSConfig.h关键配置项如下#define configUSE_PREEMPTION 1 #define configUSE_IDLE_HOOK 0 #define configUSE_TICK_HOOK 0 #define configCPU_CLOCK_HZ ( 72000000UL ) #define configTICK_RATE_HZ ( 1000 ) #define configMAX_PRIORITIES ( 7 ) #define configMINIMAL_STACK_SIZE ( 128 ) #define configTOTAL_HEAP_SIZE ( ( size_t ) ( 10 * 1024 ) ) #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1configCPU_CLOCK_HZ设成 72MHz 是因为 STM32F103 的主频是 72MHz。configTICK_RATE_HZ设成 1000 表示系统节拍是 1ms这个精度对大多数任务够用。configCHECK_FOR_STACK_OVERFLOW设成 2 表示开启栈溢出检测方法 2 比方法 1 更严格能在任务切换时检查栈指针是否越界。configTOTAL_HEAP_SIZE给 10KB这个项目里五个任务加上队列和信号量10KB 堆空间够用但如果你后面加更多功能可能需要调大。提示Wokwi 仿真环境下configCPU_CLOCK_HZ必须和实际芯片主频一致否则vTaskDelay的延时时间会不准。STM32F103 在 Wokwi 里默认跑 72MHz如果你改了时钟配置这里也要同步改。4. 核心任务实现与传感器驱动细节4.1 SensorTask 多传感器采集实现SensorTask 是整个系统的数据源头它负责周期性读取 DHT22 和 BH1750 的数据打包后通过队列发送。DHT22 是单总线协议时序要求比较严格读一次需要 5ms 左右。BH1750 是 I2C 接口读一次大概 1ms。两个传感器都读一遍加上数据处理整个任务执行时间在 10ms 以内500ms 的调度周期绰绰有余。任务主体代码结构是这样的void vSensorTask(void *pvParameters) { SensorData_t sensorData; TickType_t lastWakeTime xTaskGetTickCount(); while (1) { // 读取 DHT22 温湿度 if (DHT22_Read(sensorData.temperature, sensorData.humidity) ! DHT22_OK) { sensorData.temperature -99.0f; sensorData.humidity -99.0f; } // 获取 I2C 互斥量后读取 BH1750 if (xSemaphoreTake(xI2CMutex, pdMS_TO_TICKS(100)) pdTRUE) { sensorData.lux BH1750_ReadLux(); xSemaphoreGive(xI2CMutex); } else { sensorData.lux -1.0f; } sensorData.timestamp xTaskGetTickCount(); // 发送到队列超时 0 表示不等待 xQueueSend(xSensorQueue, sensorData, 0); vTaskDelayUntil(lastWakeTime, pdMS_TO_TICKS(500)); } }这里用vTaskDelayUntil而不是vTaskDelay是有原因的。vTaskDelay是相对延时从调用时刻开始算如果任务执行时间有波动采集周期就会漂移。vTaskDelayUntil是绝对延时保证每次循环的起始时刻间隔固定采集周期稳定在 500ms。对于周期性采集任务必须用vTaskDelayUntil。DHT22 读取失败时我把温度湿度设成 -99这是一个错误标志。显示任务收到 -99 就知道这次采集失败了可以在屏幕上显示 “Sensor Error” 而不是显示一个假数据。这种错误处理在实际项目里很重要仿真环境下 DHT22 一般不会失败但真实硬件上传感器接触不良、线路干扰都可能导致读取失败。4.2 DisplayTask 与 OLED 驱动DisplayTask 从队列接收传感器数据格式化后刷新 OLED 屏幕。SSD1306 的驱动我用的是一款轻量级的开源库只保留了核心的初始化、清屏、写字符、写字符串函数去掉了图形绘制等用不到的功能减少代码体积和栈消耗。显示任务的关键在于刷新策略。OLED 刷新整屏需要传输 1024 字节的数据I2C 速率 400kHz 的情况下大概需要 25ms。如果每 200ms 刷新一次CPU 占用率大概 12%可以接受。但如果你把刷新周期缩到 50msCPU 占用率就上去了会影响其他任务。所以刷新周期不能太短200ms 是视觉流畅和 CPU 负载之间的平衡点。void vDisplayTask(void *pvParameters) { SensorData_t data; char lineBuf[22]; OLED_Init(); while (1) { if (xQueueReceive(xSensorQueue, data, portMAX_DELAY) pdTRUE) { if (xSemaphoreTake(xI2CMutex, pdMS_TO_TICKS(200)) pdTRUE) { OLED_Clear(); if (data.temperature -90.0f) { sprintf(lineBuf, Temp: %.1f C, data.temperature); } else { sprintf(lineBuf, Temp: Error); } OLED_WriteString(0, 0, lineBuf); if (data.humidity -90.0f) { sprintf(lineBuf, Humi: %.1f %%, data.humidity); } else { sprintf(lineBuf, Humi: Error); } OLED_WriteString(0, 2, lineBuf); sprintf(lineBuf, Lux: %.0f lx, data.lux); OLED_WriteString(0, 4, lineBuf); xSemaphoreGive(xI2CMutex); } } } }注意xQueueReceive的超时参数用的是portMAX_DELAY意思是队列空的时候任务无限期阻塞直到有数据到来。这样 DisplayTask 在没有新数据时完全不占 CPU调度器会自动切换到其他就绪任务。这就是 RTOS 的优势用阻塞代替轮询CPU 利用率高。sprintf在嵌入式里要慎用它比较吃栈而且浮点格式化在有些编译器上需要额外配置。我这里 DisplayTask 栈给了 512 words2KB跑sprintf没问题。如果你的栈给得小可以用整数运算代替浮点格式化比如把温度乘以 10 后用整数显示。4.3 AlarmTask 阈值判断与报警逻辑AlarmTask 的设计有点特殊它不是周期性执行而是由队列事件驱动。SensorTask 每采集一次数据就往队列里发一份AlarmTask 从队列接收后判断是否超阈值。如果温度超过 35 度或者湿度低于 20%就点亮 LED 报警灯。void vAlarmTask(void *pvParameters) { SensorData_t data; uint8_t alarmActive 0; while (1) { if (xQueueReceive(xAlarmQueue, data, portMAX_DELAY) pdTRUE) { alarmActive 0; if (data.temperature 35.0f) { alarmActive 1; } if (data.humidity 20.0f data.humidity -90.0f) { alarmActive 1; } if (data.lux 1000.0f) { alarmActive 1; } if (alarmActive) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET); } } } }这里有个细节SensorTask 的数据要同时发给 DisplayTask 和 AlarmTask但一个队列只能有一个接收者。解决办法是创建两个队列SensorTask 往两个队列各发一份。或者用 FreeRTOS 的队列集Queue Set但队列集配置起来麻烦一些。我选的是两个独立队列的方案简单直接多占一点内存但逻辑清晰。注意往两个队列发送时如果其中一个队列满了xQueueSend会返回失败。这个项目里队列长度设的是 5SensorTask 每 500ms 发一次DisplayTask 和 AlarmTask 都能及时消费不会积压。但如果你的任务处理速度跟不上采集速度就要考虑加大队列长度或者做丢帧处理。4.4 KeyTask 按键处理与信号量通知KeyTask 的职责是扫描按键检测到按下后释放信号量通知 AlarmTask 执行消音操作。按键扫描需要消抖我在任务里用了一个简单的状态机检测到低电平后延时 20ms 再确认如果还是低电平就认为是有效按下。void vKeyTask(void *pvParameters) { uint8_t lastState 1; uint8_t currentState; while (1) { currentState HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1); if (lastState 1 currentState 0) { vTaskDelay(pdMS_TO_TICKS(20)); if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) 0) { xSemaphoreGive(xKeySemaphore); } } lastState currentState; vTaskDelay(pdMS_TO_TICKS(20)); } }KeyTask 的优先级最高但它大部分时间都在vTaskDelay里阻塞不会霸占 CPU。只有按键真正按下时才会短暂运行。这种设计既保证了响应速度又不影响其他任务。5. 调试、优化与常见问题排查5.1 栈溢出检测与任务栈调整FreeRTOS 的栈溢出检测是调试阶段最有用的工具之一。configCHECK_FOR_STACK_OVERFLOW设成 2 后每次任务切换时内核会检查栈指针是否越过了任务栈的边界。如果越界会调用vApplicationStackOverflowHook函数你可以在里面打印出错的任务名或者直接点亮一个错误 LED。void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { (void)xTask; printf(STACK OVERFLOW in task: %s\n, pcTaskName); taskDISABLE_INTERRUPTS(); for (;;); }调试阶段我建议把每个任务的栈先设大一点跑一段时间后通过uxTaskGetStackHighWaterMark查看栈的实际使用峰值然后根据峰值调整到合理值。比如 DisplayTask 初始给 512 words跑下来高水位线是 380 words那就可以缩到 400 words省出 112 words 给其他任务。UBaseType_t watermark uxTaskGetStackHighWaterMark(NULL); printf(DisplayTask stack watermark: %u\n, watermark);这个函数返回的是栈剩余的最小值单位是 word。返回值越小说明栈越紧张如果接近 0 就危险了随时可能溢出。5.2 优先级翻转与互斥量使用前面提到 I2C 总线要用互斥量保护这里展开说一下为什么。假设 SensorTask优先级 3先获取了 I2C 互斥量正在读 BH1750。这时 KeyTask优先级 4抢占了 SensorTask也要用 I2C 读按键扩展芯片。如果用的是二值信号量KeyTask 会一直等 SensorTask 释放但 SensorTask 优先级低被 KeyTask 抢占后根本跑不了就形成了死锁。互斥量的优先级继承机制会把 SensorTask 的优先级临时提升到 4让它先跑完释放互斥量然后 KeyTask 再获取。这样就避免了优先级翻转导致的系统卡死。提示Wokwi 仿真环境下优先级翻转问题不容易复现因为仿真器的调度和真实硬件有差异。但这个问题在真实硬件上很常见写代码时就要按正确的方式用互斥量不要等到出问题了再改。5.3 常见问题速查表现象可能原因排查方法解决方案系统启动后卡死堆空间不足pvPortMalloc返回 NULL检查configTOTAL_HEAP_SIZE在任务创建后打印返回值增大堆空间或减少任务数量某个任务不执行优先级设置过低被高优先级任务饿死用vTaskList打印任务状态调整优先级或增加阻塞时间OLED 显示乱码I2C 总线冲突两个任务同时访问检查是否所有 I2C 访问都加了互斥量保护统一用互斥量包裹 I2C 操作传感器数据不更新队列满了新数据发不进去打印队列剩余空间uxQueueSpacesAvailable加大队列长度或提高消费任务优先级编译报错 undefined reference tovApplicationStackOverflowHook开启了栈溢出检测但没实现钩子函数检查FreeRTOSConfig.h配置实现钩子函数哪怕是个空函数Wokwi 仿真运行缓慢任务调度周期太短仿真器跟不上降低任务频率或减少任务数量把非关键任务周期调大5.4 仿真与真实硬件的差异Wokwi 仿真很方便但有些东西和真实硬件不一样心里要有数。第一仿真环境下传感器数据是固定的或者按你设定的值变化不会像真实传感器那样有噪声和漂移。第二仿真器的时序精度不如真实硬件vTaskDelay的实际延时可能有偏差。第三Wokwi 不支持某些 STM32 外设的高级功能比如 DMA 和某些定时器模式。所以这个项目在 Wokwi 上跑通后移植到真实硬件时要做几件事把传感器驱动里的延时根据实际时序要求调整把 I2C 速率从仿真默认值改成实际值检查堆空间是否够用真实硬件的 RAM 可能比仿真环境小。但任务划分、队列通信、优先级设计这些架构层面的东西不需要改直接能用。6. 项目扩展与个人实操体会6.1 可以继续加什么功能这套框架搭好之后扩展很方便。想加一个 CO2 传感器只需要在 SensorTask 里加一段读取代码在SensorData_t结构体里加一个字段在 DisplayTask 里加一行显示。任务划分不用动队列通信机制不用动。想加一个 WiFi 模块上传数据到云端可以新建一个 CommTask从队列接收数据后通过串口发给 WiFi 模块。想加数据记录功能可以新建一个 LogTask把数据写到 SD 卡或者 Flash 里。FreeRTOS 的软件定时器也可以利用起来。比如你想每 10 秒做一次数据平均可以创建一个软件定时器回调函数里计算平均值。软件定时器不占用独立任务栈由定时器服务任务统一管理省资源。6.2 我在这个项目里踩过的坑第一个坑是configTOTAL_HEAP_SIZE设小了。一开始给了 5KB创建了五个任务和两个队列后堆就快用完了再创建信号量时pvPortMalloc返回 NULL系统直接卡死。后来改成 10KB 才够用。教训是堆空间要留余量不要卡着算。第二个坑是 DHT22 的读取时序。Wokwi 的 DHT22 仿真模型对时序要求比较宽松但真实 DHT22 对微秒级延时很敏感。我在仿真里跑通的代码移植到真实硬件上读出来的数据全是错的。后来用逻辑分析仪抓了时序发现延时函数精度不够换成了硬件定时器做微秒延时才解决。第三个坑是 OLED 刷新和传感器采集抢 I2C 总线。一开始没加互斥量OLED 显示偶尔会花屏传感器数据偶尔会读错。加了互斥量后问题消失。这个坑在仿真环境下不容易发现因为仿真器的 I2C 冲突表现不明显但真实硬件上一定会出问题。6.3 给初学者的建议如果你刚开始学 FreeRTOS不要一上来就搞这么复杂的项目。先从两个任务开始一个闪 LED一个串口打印把任务创建、调度、延时这几个基本概念搞清楚。然后加一个队列让两个任务通过队列传数据。再加一个信号量做任务同步。一步一步来每加一个机制就写代码验证比一次性写一大堆然后调不通要好得多。STM32 的 FreeRTOS 移植本身不复杂关键是FreeRTOSConfig.h的配置要理解每一项的含义。不要直接抄别人的配置文件要搞清楚每个宏定义是干什么的。比如configUSE_PREEMPTION设成 1 是抢占式调度设成 0 是协作式调度两者的区别在任务切换时机上。这些概念理解了调起问题来才有方向。Wokwi 是个很好的练习平台但它不能完全替代真实硬件。仿真跑通后一定要找一块真实的 STM32 板子把代码跑一遍。真实硬件上你会遇到仿真里遇不到的问题电源噪声、信号干扰、时序偏差、内存限制。这些问题解决的过程才是真正长经验的时候。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。