1. 项目缘起与整体设计思路
1.1 为什么我要做这个 RoomSense Monitor
去年冬天我搬进一间朝北的小书房,白天写代码还行,一到晚上就总觉得闷得慌——不知道是二氧化碳浓度高了,还是湿度太低,又或者是温度悄悄降到了让人犯困的区间。市面上能同时测温度、湿度、光照、空气质量,还能本地显示、带历史趋势的成品监测仪,要么贵得离谱,要么数据锁在厂商云里,想自己接个继电器联动风扇都费劲。于是我就动了念头:用STM32加FreeRTOS,自己搭一个多传感器房间监测节点,数据本地处理、本地显示,顺便把FreeRTOS的实战流程完整走一遍。
这个项目我给它起名RoomSense Monitor,核心定位很明确:一块STM32F407最小系统板,跑FreeRTOS实时操作系统,挂载温湿度、光照、空气质量、超声波测距等多路传感器,通过OLED本地显示,同时预留串口和USB接口把数据吐给上位机。它解决的不是什么高精尖问题,而是“我想知道自己房间到底舒不舒服,并且想怎么改就怎么改”这个朴素需求。适合已经学过STM32裸机开发、想找个完整项目把FreeRTOS用起来的嵌入式爱好者,也适合拿它当毕业设计或课程设计的同学——因为它的模块划分清晰,扩展点足够多,答辩时能讲的东西不少。
1.2 整体架构:为什么选FreeRTOS而不是裸机大循环
裸机写多传感器采集不是不行,但你会很快遇到几个绕不开的痛点。第一,不同传感器的采样周期不一样:温湿度可能2秒读一次就够,光照500毫秒刷新一次体验更好,超声波测距则要避开回波干扰、间隔至少60毫秒以上。裸机大循环里用一堆软件延时去凑这些节奏,CPU大部分时间都在空转,而且一旦某个传感器I2C通信卡住,整个系统全跟着卡。第二,OLED刷新、串口上报、按键响应这些任务对实时性要求不同,裸机里揉在一起,代码会迅速变成一团乱麻。
FreeRTOS的价值在这里就体现出来了:我把每个传感器采集封装成独立任务,用vTaskDelayUntil保证固定周期,任务之间通过队列传递数据,显示任务和上报任务各跑各的。某个传感器坏了,最多是它自己的任务超时,不会拖垮整个系统。这就是我选FreeRTOS最核心的理由——任务隔离带来的健壮性,而不是单纯为了“用上RTOS显得高级”。
整体任务划分如下:
| 任务名称 | 优先级 | 周期 | 职责 |
|---|---|---|---|
| SensorTask_TempHum | 3 | 2000ms | 读取SHT30温湿度 |
| SensorTask_Light | 3 | 500ms | 读取BH1750光照 |
| SensorTask_Air | 2 | 3000ms | 读取空气质量(模拟MQ系列) |
| SensorTask_Ultra | 2 | 200ms | 超声波测距(房间占用检测) |
| DisplayTask | 1 | 300ms | OLED刷新 |
| ReportTask | 1 | 1000ms | 串口/USB上报 |
| KeyTask | 4 | 事件驱动 | 按键处理 |
优先级的设计逻辑:按键响应要求最快,给最高;温湿度和光照是核心舒适度指标,给中等偏上;空气质量和超声波可以稍慢;显示和上报属于消费型任务,给最低。这个分配不是拍脑袋,而是按“数据新鲜度对用户体验的影响程度”倒推出来的。
1.3 硬件选型与踩坑预判
主控我用的STM32F407VET6,168MHz主频、192KB SRAM、512KB Flash,跑FreeRTOS加几个传感器任务绰绰有余。选F4而不是F1,主要考虑是后续想加LVGL做图形界面时RAM够用——F1的20KB SRAM在FreeRTOS任务栈加LVGL缓冲面前会很紧张。传感器方面:SHT30走I2C测温湿度,BH1750同样I2C测光照,超声波用HC-SR04(后续可换US-100串口版),空气质量先用MQ-135模拟输出接ADC。OLED用0.96寸SSD1306 I2C版。
这里提前说一个坑:SHT30和BH1750都是I2C设备,如果挂在同一条I2C总线上,地址不能冲突。SHT30默认地址0x44,BH1750默认0x23,SSD1306默认0x3C,三者不冲突,可以共用I2C1。但要注意I2C总线上拉电阻——很多模块自带上拉,如果你再并上开发板的上拉,阻值会偏小,波形上升沿变陡但功耗增加,一般不影响功能,但如果你发现通信偶发失败,先量一下上拉电阻总阻值,低于2.2K就要考虑去掉一路。
2. FreeRTOS移植与核心配置细节
2.1 从零把FreeRTOS塞进STM32工程
网上FreeRTOS移植教程很多,但不少是直接复制CubeMX生成的代码,知其然不知其所以然。我这里走一遍手动移植的关键步骤,把每个文件的作用讲清楚。
首先去FreeRTOS官网下载源码包,解压后只需要FreeRTOS/Source目录下的内容。核心文件包括:tasks.c(任务调度)、queue.c(队列)、list.c(链表)、timers.c(软件定时器)、event_groups.c(事件组)。portable目录下根据编译器选:Keil用RVDS/ARM_CM4F,IAR用IAR/ARM_CM4F。FreeRTOSConfig.h需要自己写,这是整个系统的“控制面板”。
移植时最容易出错的是三个地方。第一,FreeRTOSConfig.h里configTOTAL_HEAP_SIZE设多大。F407有192KB SRAM,我一般给FreeRTOS堆留20KB到30KB,剩下的给全局变量和栈。如果你开了很多任务,每个任务栈1KB到2KB,20KB大概能撑8到10个任务。第二,configMAX_PRIORITIES别设太大,我设成7,够用且省RAM。第三,中断优先级配置——FreeRTOS的configKERNEL_INTERRUPT_PRIORITY和configMAX_SYSCALL_INTERRUPT_PRIORITY必须和STM32的NVIC优先级分组匹配。我习惯用NVIC_PriorityGroup_4,全部4位用于抢占优先级,这样configMAX_SYSCALL_INTERRUPT_PRIORITY设成5,意味着优先级高于5的中断不能调用FreeRTOS的API。
// FreeRTOSConfig.h 关键配置片段 #define configUSE_PREEMPTION 1 #define configUSE_IDLE_HOOK 0 #define configUSE_TICK_HOOK 0 #define configCPU_CLOCK_HZ (168000000UL) #define configTICK_RATE_HZ ((TickType_t)1000) #define configMAX_PRIORITIES (7) #define configMINIMAL_STACK_SIZE ((unsigned short)128) #define configTOTAL_HEAP_SIZE ((size_t)(20 * 1024)) #define configMAX_TASK_NAME_LEN (16) #define configUSE_16_BIT_TICKS 0 #define configIDLE_SHOULD_YIELD 1 #define configUSE_MUTEXES 1 #define configUSE_RECURSIVE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configUSE_QUEUE_SETS 0 #define configUSE_TASK_NOTIFICATIONS 1 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1注意:
configCHECK_FOR_STACK_OVERFLOW设成2比设成1更严格,它会在任务切换时检查栈指针是否越界,代价是稍微多一点开销,但调试阶段强烈建议开2。
2.2 堆栈溢出检测:我踩过的真实坑
说到栈溢出,我必须分享一个血泪教训。项目初期我给DisplayTask只分配了256字(注意FreeRTOS里栈深度单位是字,不是字节,256字在32位MCU上是1KB)。结果OLED刷新时调用snprintf格式化浮点数,栈直接爆了,现象是系统跑几分钟就HardFault,用调试器看调用栈完全乱掉。后来把configCHECK_FOR_STACK_OVERFLOW打开,在vApplicationStackOverflowHook里打个断点,才发现是DisplayTask溢出了。
解决办法有两个:一是把DisplayTask栈加到512字,二是避免在任务里用snprintf格式化浮点——浮点格式化在newlib里很吃栈。我最后改成先把浮点转成整数和小数两部分,用整数格式化,栈占用立刻降下来。这个技巧在资源紧张的MCU上非常实用。
// 栈溢出钩子函数,调试时在这里下断点 void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { (void)xTask; (void)pcTaskName; taskDISABLE_INTERRUPTS(); for(;;); // 停在这里,用调试器看pcTaskName是哪个任务溢出了 }另外,configUSE_MALLOC_FAILED_HOOK也要打开,如果FreeRTOS堆不够,创建任务时会失败,这个钩子能告诉你。
2.3 任务间通信:队列、信号量与事件组的选择
多传感器项目里,任务间通信的设计直接决定代码的可维护性。我的原则是:数据流用队列,状态同步用信号量,多条件等待用事件组。
温湿度、光照、空气质量、超声波四个采集任务各自把结果打包成一个结构体,通过队列发给DisplayTask和ReportTask。这里有个细节:如果DisplayTask和ReportTask都要同一份数据,你有两个选择——用两个队列各发一份,或者用一个队列但只发给一个任务,另一个任务通过共享内存加互斥锁读取。我选的是前者,因为队列会复制数据,虽然多占一点RAM,但避免了锁竞争,逻辑更简单。每个传感器数据结构体大概16字节,队列深度5,总共也就几百字节。
typedef struct { float temperature; float humidity; uint32_t timestamp; } TempHumData_t; QueueHandle_t xQueueTempHum; xQueueTempHum = xQueueCreate(5, sizeof(TempHumData_t));按键任务用信号量通知DisplayTask切换页面,这里用二值信号量就够了。事件组我用来做“系统就绪”判断——当所有传感器任务完成第一次采集后,各自置位一个事件位,DisplayTask等待所有位都置位后才开始正常刷新,避免开机时显示一堆无效数据。
实操心得:队列传递结构体时,尽量让结构体大小是4字节的整数倍,这样内存对齐更好,复制效率更高。如果结构体里有float,注意STM32F4支持硬件浮点,但FreeRTOS任务切换时FPU寄存器的保存需要额外配置,在
FreeRTOSConfig.h里确保configENABLE_FPU相关设置正确(CubeMX生成的port文件一般已处理)。
3. 多传感器驱动与数据采集实操
3.1 SHT30温湿度:I2C时序与CRC校验
SHT30是我用过比较省心的温湿度传感器,I2C接口,精度够用,但它的CRC校验和命令格式需要仔细看手册。测量命令有两种模式:单次测量和周期测量。我选单次测量,因为FreeRTOS任务本身就是周期性的,没必要让传感器一直跑周期模式增加功耗。
单次测量命令是0x2C06,发送后等待15毫秒左右,再读6个字节:温度高8位、温度低8位、温度CRC、湿度高8位、湿度低8位、湿度CRC。转换公式:
- 温度 = -45 + 175 × (raw_temp / 65535)
- 湿度 = 100 × (raw_hum / 65535)
CRC校验多项式是0x31,初始值0xFF。我一开始偷懒没做CRC校验,结果偶尔读到0xFFFF的异常值,显示温度直接跳到100多度。加上CRC校验后,异常值被过滤掉,任务里判断CRC失败就跳过本次更新,保留上次有效值。
uint8_t SHT30_CheckCRC(uint8_t *data, uint8_t len) { uint8_t crc = 0xFF; for (uint8_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x80) crc = (crc << 1) ^ 0x31; else crc <<= 1; } } return crc; }注意:SHT30的I2C地址0x44是7位地址,HAL库函数需要左移一位变成0x88。这个坑我见过太多人踩,现象就是HAL_I2C_Master_Transmit返回HAL_ERROR。
3.2 BH1750光照:连续测量模式与量程选择
BH1750光照传感器支持连续测量和单次测量,我选连续测量模式(命令0x10),因为它一次启动后自动按周期更新,任务里直接读就行,省去每次发命令的等待。测量结果是一个16位值,除以1.2就是勒克斯值。
BH1750有个量程问题:默认测量范围0到54612勒克斯,分辨率1勒克斯。如果房间光照很强,比如靠窗中午可能超过这个范围,读数会饱和。这时可以发命令0x11切换到高分辨率模式2,范围0到54612但分辨率0.5勒克斯,或者用0x13低分辨率模式范围更大。我书房光照一般不超过2000勒克斯,默认模式足够。
读取时连续读2个字节,高字节在前。这里有个小技巧:如果读到的值是0或者65535,大概率是通信失败,直接丢弃。正常室内光照应该在几十到几千之间。
3.3 超声波测距:定时器捕获与温度补偿
HC-SR04超声波模块用起来简单,但想测准需要花点心思。基本流程:Trig引脚给10微秒高电平,然后Echo引脚变高,高电平持续时间就是声波往返时间。距离 = 时间 × 声速 / 2。
我用STM32的定时器输入捕获来测Echo高电平时间,而不是用HAL_GPIO_ReadPin轮询——轮询会阻塞任务,而且精度差。具体做法:TIM2通道1设为输入捕获,上升沿触发时记录计数器值,下降沿触发时再记录,差值乘以计数周期就是时间。TIM2挂在APB1上,时钟84MHz,预分频84-1后计数频率1MHz,即每计数1微秒,分辨率足够。
声速受温度影响,公式是 声速 = 331.4 + 0.607 × 温度。我在超声波任务里读取最新的温度值做补偿,常温25度时声速约346.6米/秒,0度时331.4米/秒,差了4.5%,在2米距离上就是9厘米误差,不能忽略。
float Ultra_GetDistance(float temperature) { float speed = 331.4f + 0.607f * temperature; // 米/秒 float time_us = capture_diff; // 微秒 float distance_cm = (speed * 100.0f * time_us) / (2.0f * 1000000.0f); return distance_cm; }实操心得:HC-SR04测距时,如果房间里有窗帘、软包等吸声材料,回波会很弱,读数偶尔跳变。我的处理是连续测3次取中值,而不是平均值——中值能有效过滤掉偶发的异常大值或小值。另外,两次测量之间至少间隔60毫秒,否则前一次的回波会干扰下一次。
3.4 空气质量与ADC采样:滑动平均滤波
MQ-135空气质量传感器输出模拟电压,我接在STM32的ADC1通道上。MQ-135需要预热,刚上电时读数漂移很大,我一般让它预热3到5分钟再采信数据。软件上,ADC采样用DMA加定时器触发,每100毫秒采一次,采16次做滑动平均,这样既能平滑数据又不会太滞后。
滑动平均的实现我用一个环形缓冲区,每次新数据覆盖最旧数据,然后求平均。相比一阶低通滤波,滑动平均对突变响应更快,适合空气质量这种缓慢变化的量。
#define FILTER_LEN 16 static uint16_t adc_buf[FILTER_LEN]; static uint8_t adc_idx = 0; uint16_t ADC_MovingAverage(uint16_t new_val) { adc_buf[adc_idx] = new_val; adc_idx = (adc_idx + 1) % FILTER_LEN; uint32_t sum = 0; for (uint8_t i = 0; i < FILTER_LEN; i++) sum += adc_buf[i]; return sum / FILTER_LEN; }空气质量等级我简单分三档:ADC值低于1500算优,1500到2500算良,高于2500算差。这个阈值不是标准,只是我根据自己房间实测调的,你可以根据实际传感器和房间情况重新标定。
4. 显示、上报与系统联调
4.1 OLED多页面显示设计
0.96寸OLED只有128×64像素,要显示温度、湿度、光照、空气质量、距离五类数据,一屏肯定放不下。我设计了三个页面,按键切换:页面1显示温湿度和光照,页面2显示空气质量和距离,页面3显示系统状态(各任务栈使用情况、FreeRTOS堆剩余)。页面切换用按键中断加信号量通知DisplayTask,响应很快。
显示刷新我用的是u8g2库的页缓冲模式,每次只刷新变化的部分。但实测下来,300毫秒全屏刷新一次对I2C OLED来说压力不大,所以后来改成全屏刷新,代码更简单。这里要注意I2C速率:SSD1306支持400KHz,我把I2C1设成400KHz,全屏刷新大概20毫秒,完全不影响其他任务。
注意:OLED刷新时如果和SHT30、BH1750共用I2C总线,要加互斥锁。我一开始没加,结果DisplayTask刷新到一半被SensorTask的I2C传输打断,OLED显示花屏。加互斥锁后问题消失。互斥锁用
xSemaphoreCreateMutex创建,I2C操作前xSemaphoreTake,操作后xSemaphoreGive。
4.2 串口与USB上报:协议设计与上位机对接
数据上报我做了两套:串口和USB CDC。串口用UART1,115200波特率,每秒发一帧JSON格式的数据,方便用串口助手或Python脚本解析。USB CDC用STM32F4自带的USB OTG FS,枚举成虚拟串口,插上电脑就能看到数据,不需要额外转换芯片。
JSON帧格式如下:
{"temp":24.5,"humi":58.2,"lux":320,"air":1800,"dist":85,"ts":123456}这里有个细节:FreeRTOS任务里调用USB发送函数时,如果USB主机没打开串口,发送会阻塞。我的处理是设置一个发送超时,超时就丢弃本帧,避免ReportTask卡死。串口发送同理,用HAL_UART_Transmit带超时参数。
USB CDC的移植稍微麻烦一点,需要把STM32的USB库和FreeRTOS结合。关键点是USB中断优先级要低于configMAX_SYSCALL_INTERRUPT_PRIORITY,否则中断里调用FreeRTOS API会触发断言。我把USB中断优先级设成6,configMAX_SYSCALL_INTERRUPT_PRIORITY设成5,这样USB中断不能调用FreeRTOS API,但可以通过任务通知的方式唤醒ReportTask处理数据。
4.3 系统联调与性能观察
所有任务跑起来后,我用FreeRTOS的vTaskGetRunTimeStats和vTaskList来观察系统状态。这两个功能需要开启configGENERATE_RUN_TIME_STATS和configUSE_TRACE_FACILITY,并且提供一个高精度计时器——我用TIM6做运行时统计的时基,每10微秒中断一次。
通过vTaskList打印各任务状态,我发现KeyTask大部分时间处于阻塞态(等信号量),CPU占用率不到1%;SensorTask_TempHum因为要等SHT30转换,实际占用也很低;DisplayTask占用最高,大概8%左右。整体CPU占用不到15%,说明F407跑这个项目绰绰有余,后续加LVGL还有很大空间。
| 任务 | CPU占用 | 栈使用峰值 | 状态 |
|---|---|---|---|
| KeyTask | <1% | 180字 | 阻塞 |
| SensorTask_TempHum | 3% | 220字 | 阻塞 |
| SensorTask_Light | 2% | 200字 | 阻塞 |
| SensorTask_Air | 1% | 190字 | 阻塞 |
| SensorTask_Ultra | 4% | 240字 | 阻塞 |
| DisplayTask | 8% | 480字 | 就绪 |
| ReportTask | 2% | 300字 | 阻塞 |
栈使用峰值这个数据非常有用,它告诉你每个任务实际用了多少栈。我一般留50%余量,比如DisplayTask峰值480字,我分配512字,余量刚好。如果某个任务峰值接近分配值,就要赶紧加栈。
5. 常见问题排查与避坑经验
5.1 FreeRTOS相关故障速查
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 系统跑几分钟HardFault | 任务栈溢出 | 开configCHECK_FOR_STACK_OVERFLOW,在钩子函数下断点 | 加大栈或减少局部变量 |
| 创建任务返回失败 | FreeRTOS堆不足 | 检查configTOTAL_HEAP_SIZE,用xPortGetFreeHeapSize查看剩余 | 加大堆或减少任务数 |
| 中断里调用API触发断言 | 中断优先级高于configMAX_SYSCALL_INTERRUPT_PRIORITY | 检查NVIC优先级分组和中断优先级 | 降低中断优先级或改用任务通知 |
| 任务不切换 | 调度器未启动或优先级配置错误 | 确认vTaskStartScheduler被调用,检查任务优先级 | 修正优先级或启动调度器 |
| 队列发送失败 | 队列满或超时 | 检查队列深度和接收任务处理速度 | 加大队列深度或提高接收任务优先级 |
5.2 I2C通信偶发失败的处理
I2C总线在长导线或干扰环境下容易偶发失败。我的处理策略是三层:第一层,HAL库函数返回错误时重试3次;第二层,重试仍失败则复位I2C外设(HAL_I2C_DeInit加HAL_I2C_Init);第三层,如果复位后还失败,标记该传感器为故障状态,显示任务显示“--”而不是错误数据。
uint8_t I2C_WriteWithRetry(uint8_t addr, uint8_t *data, uint16_t len) { for (uint8_t i = 0; i < 3; i++) { if (HAL_I2C_Master_Transmit(&hi2c1, addr, data, len, 100) == HAL_OK) return 0; HAL_I2C_DeInit(&hi2c1); HAL_I2C_Init(&hi2c1); } return 1; // 失败 }实操心得:I2C总线上拉电阻用4.7K比较稳,太小功耗大,太大上升沿慢。如果总线上设备多,可以适当减小到3.3K。另外,I2C走线尽量短,远离电机、继电器等干扰源。
5.3 超声波测距的干扰与误触发
HC-SR04最大的问题是容易受环境干扰,比如房间里有其他超声波源(某些驱鼠器、加湿器),或者回波被软材料吸收。我的处理:连续测3次,如果3次差值超过20厘米,判定为无效,丢弃本次数据;如果连续5次无效,标记传感器故障。另外,超声波任务优先级设低一点,避免它频繁触发影响其他任务。
还有一个细节:HC-SR04的Echo引脚是5V输出,STM32的GPIO是3.3V耐受,直接接会长期损伤引脚。我用一个电阻分压(1K加2K)把5V降到3.3V再接入。这个硬件细节很多人忽略,短期能用,长期有隐患。
5.4 系统长时间运行的稳定性观察
项目连续跑了72小时,我记录了以下数据:FreeRTOS堆剩余从初始的18KB缓慢降到17.2KB后稳定,说明没有内存泄漏;各任务栈使用峰值稳定;温湿度读数与商用温湿度计对比误差在0.5度以内;光照读数与手机APP对比趋势一致。唯一出现的问题是第48小时左右,空气质量读数突然跳到最大值,检查发现是MQ-135传感器附近有人喷了酒精,传感器敏感度很高,几分钟后恢复正常。这说明传感器数据要结合场景判断,不能盲目相信单次读数。
这个项目后续还可以扩展的方向:加SD卡做历史数据记录,加WiFi模块上传到本地服务器,加继电器控制风扇或加湿器做闭环调节。但那是下一步的事了,当前版本已经能满足“知道自己房间舒不舒服”这个核心需求。