ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于STM32的智能环境监测系统:从传感器到云端的完整实践

基于STM32的智能环境监测系统:从传感器到云端的完整实践

做环境监测这类嵌入式项目,说难不难,说简单也真不简单。我最近把基于STM32的智能环境监测系统整个流程跑通了,温湿度、光照强度、空气质量、PM2.5这些参数全部采集上来,用OLED屏本地显示,同时通过ESP8266把数据推到云端,手机端随时能看。如果你正打算做类似的课设、毕设,或者单纯想在桌面上放一个自己写的“环境管家”,这篇文章就是给你准备的。

先说下这个系统的构成:STM32做主控,挂上DHT22温湿度传感器、BH1750光照传感器、MQ135空气质量传感器、GP2Y1010粉尘传感器,加上一块0.96寸OLED屏、几个按键、一个ESP8266 WiFi模块。所有传感器数据定时采集,经过滤波处理后,一方面在本地OLED上轮播显示,另一方面打包成JSON通过MQTT协议发到云平台。整个项目做下来踩了不少坑,从传感器时序到I2C上拉电阻,从ADC参考电压到WiFi重连策略,每一步都有值得记录的地方。这篇文章我把完整的设计思路、选型对比、代码要点、调试经历全部摊开讲,力求你照着走一遍就能跑起来。

1. 需求拆解与整体设计思路

1.1 这个项目到底要解决什么问题

环境监测系统的核心任务,就是把“环境状态”从物理量变成“可读数据”,再变成“可传播的数据”。听起来绕,其实拆开看就三件事:采集、处理、上报。

采集环节负责把温度、湿度、光照、气体污染这些模拟世界里的信号,通过传感器转换成电信号,再交给MCU的ADC或数字接口读出来。处理环节解决的是“数据能不能用”的问题——传感器原始输出往往带噪声、带漂移、带单位换算误差,不处理就显示或者上传,数据根本没法看。上报环节解决的是“数据给谁用”的问题,本地屏幕只能给现场的人看,想要远程查看、历史记录、异常告警,就必须走无线通信。

我见过不少同学做这类项目,一上来就买器件、接线、拷代码,结果传感器数值乱跳,WiFi连不上,最后一查是I2C地址都没确认对。所以我的建议是,先花半天把需求、选型、架构想明白,后面调试能省一大半时间。

1.2 为什么选STM32而不是51、Arduino、ESP32

这个问题几乎每个做课设的人都会问。我把几个常见方案摆一起对比,你就知道怎么选了。

方案优点缺点适合场景
51单片机教材多、上手简单主频低、资源少、外设弱纯入门教学,不适合多功能系统
Arduino生态丰富、库函数多代码效率低、板子偏贵、工程化弱快速原型验证、创客玩具
STM32性能强、外设丰富、价格低、资料多学习曲线稍陡、寄存器配置繁琐课设、毕设、实际产品原型
ESP32自带WiFi蓝牙、双核、性价比高引脚少、模拟外设不如STM32丰富直接做IoT节点、无线密集场景

我自己倾向于STM32F103C8T6,理由是:它是一颗Cortex-M3内核的芯片,72MHz主频,64KB Flash,20KB SRAM,片上有I2C、SPI、USART、ADC、定时器,几乎所有环境传感器都能直接挂。最关键的是价格便宜,几块钱一颗,资料铺天盖地,出了任何问题都能搜到解决方案。ESP32虽然自带WiFi,但环境监测系统要接的传感器种类多,STM32的引脚和外设资源更宽裕,而且学完STM32,后面换其他芯片几乎零成本。

1.3 系统架构:感知层、控制层、交互层、传输层

整个系统我习惯分成四层来看。

感知层就是所有传感器:DHT22负责温度和湿度,BH1750负责光照度,MQ135负责VOC和有害气体浓度,GP2Y1010负责PM2.5粉尘浓度。这四个传感器覆盖了室内环境监测的主要指标,而且接口类型刚好覆盖了单总线、I2C、模拟输出三种常用形态,做一遍就能把STM32的常见外设都练熟。

控制层由STM32F103C8T6承担。它负责定时唤醒传感器、读取数据、做滤波换算、驱动OLED显示、响应按键事件、封装数据帧、通过串口与WiFi模块交互。控制层的核心是一个定时中断驱动的状态机,保证所有任务都有确定的执行周期。

交互层由OLED屏和三个按键构成。OLED显示当前数据、历史趋势、设备状态;按键用来切换显示页面、设置告警阈值、手动触发上报。

传输层用ESP8266串口WiFi模块。STM32通过USART发AT指令配置模块连接路由器,通过MQTT协议往云平台推数据。传输层单独拎出来,是因为它的调试复杂度往往比感知层还高,WiFi掉线、MQTT断连、数据粘包是三大经典问题,后面会详细讲。

2. 硬件选型与电路设计细节

2.1 MCU最小系统和引脚规划

STM32F103C8T6是LQFP48封装,引脚不多,但做这个系统绰绰有余。我实际用到的引脚如下,你可以直接照抄。

功能引脚复用/备注
DHT22数据PA0普通GPIO输入输出切换
BH1750 SCLPB6I2C1时钟
BH1750 SDAPB7I2C1数据
MQ135模拟输出PA1ADC1通道1
GP2Y1010模拟输出PA2ADC1通道2
GP2Y1010 LED脉冲PA3普通GPIO输出
OLED SCLPB8I2C1(软件模拟)或硬件
OLED SDAPB9I2C1(软件模拟)或硬件
按键1/2/3PB0/PB1/PB10下拉输入
ESP8266 TXPA9USART1 TX
ESP8266 RXPA10USART1 RX
状态LEDPB12普通GPIO输出

最小系统需要注意三点:晶振我用的是8MHz无源晶振,两个20pF负载电容直接接地;复位引脚接10k电阻上拉到3.3V,再并一个100nF电容到地;BOOT0串一个10k电阻接地,保证从Flash启动。很多人画PCB容易漏掉VDDA和VSSA的滤波电容,ADC采样会莫名其妙地偏,特别是接MQ135这种模拟传感器的时候,一定要在VDDA引脚和GND之间加一个1uF和一个100nF的电容并联。

2.2 传感器选型对比与接口梳理

传感器选型是很多人纠结的地方,我直接给你一套组合方案,都是便宜、资料多、稳定性还不错的型号。

DHT22(AM2302)温湿度传感器,单总线协议,测量范围温度-40到80℃,湿度0到99.9%RH,精度分别是±0.5℃和±2%RH。相比DHT11,DHT22精度和采样周期都更好,价格也就多几块钱,强烈建议直接用DHT22,别在DHT11上省那几块钱然后后面折腾半天。

BH1750光照传感器,I2C接口,测量范围1到65535 lx,16位精度,自带光电二极管和ADC,输出直接是数字量,不需要自己算电压。I2C地址由ADDR引脚决定,ADDR接地时地址是0x23,接VCC时是0x5C,新手常常在这个地址上翻车。

MQ135空气质量传感器,模拟输出,对氨气、苯、烟雾、VOC等有害气体敏感。它的输出是一个电位器可以调节的电压信号,通过ADC读取。MQ系列传感器有个通病:上电后需要预热,前几分钟输出会漂移,我的做法是上电后等5分钟再开始采集,或者软件里加一个“预热忽略”的逻辑。

GP2Y1010粉尘传感器,模拟输出,专门测PM2.5。它的驱动电路比较特殊,LED需要周期性脉冲驱动:每10ms一个周期,其中0.32ms为脉冲宽度,采样点必须在脉冲开始后0.28ms左右取ADC值。这个时序要测试并调试好,否则读回来的电压是错的。

OLED屏0.96寸、128x64分辨率、I2C接口,驱动芯片是SSD1306。OLED功耗比LCD低得多,显示效果也好,唯一的缺点是长期显示同一画面会烧屏,所以我的固件里做了屏保逻辑,超过两分钟无操作自动切换成较低亮度的时钟显示。

2.3 电源和布线的实操经验

整个系统的供电我建议这样设计:USB 5V输入,经AMS1117-3.3稳压到3.3V给MCU、传感器、OLED和ESP8266供电。ESP8266的峰值电流可以达到300mA以上,所以在它的VCC引脚附近一定要放一个100uF电解电容和一个10uF陶瓷电容,否则WiFi发射瞬间会把电压拉低,导致MCU复位。

我用洞洞板搭建的时候发现一个问题:DHT22的线只要超过20cm,数据就容易读失败。这是单总线协议的固有毛病,线太长、寄生电容太大都会破坏时序。解决办法有三个:连线尽量短、数据线上拉一个4.7k电阻到VCC、实在要长距离传输就换I2C接口的SHT30传感器。

模拟传感器和数字电路之间的干扰也值得注意。MQ135和GP2Y1010的模拟输出线不要和ESP8266的天线区域、串口线靠太近,否则WiFi发射时的射频干扰会让ADC读数周期性跳动。如果PCB空间允许,最好把模拟地和数字地单点连接。

3. 软件架构与驱动实现

3.1 工程组织:裸机状态机还是RTOS

这种量级的项目用不上RTOS,裸机加定时器调度就够了。很多人一上来就上FreeRTOS,最后发现任务同步、优先级配置反而把自己绕进去了。

我的工程结构是:main.c负责初始化和主循环,bsp层放各外设驱动,app层放业务逻辑。具体划分如下:

Project/ ├── Core/ # 启动文件、系统时钟配置 ├── Drivers/ # HAL库或标准库文件 ├── BSP/ │ ├── dht22.c # DHT22单总线驱动 │ ├── bh1750.c # BH1750传感器驱动 │ ├── mq135.c # MQ135 ADC读取 │ ├── gp2y1010.c # 粉尘传感器驱动 │ ├── oled.c # OLED显示驱动 │ └── esp8266.c # WiFi模块AT指令封装 ├── APP/ │ ├── sensor_task.c # 采集任务 │ ├── filter.c # 滤波算法 │ ├── ui_task.c # OLED界面逻辑 │ └── upload_task.c # 数据上传逻辑 └── main.c

主循环里用一个1ms的软件定时器作为系统时基,然后根据标志位决定是否执行采集、显示、上传任务。比如采集周期设为5秒,显示刷新设为500ms,上传周期设为30秒。这些周期分开设置有个好处:采集太频繁会浪费功耗和WiFi流量,显示太频繁OLED会闪烁,上传太频繁会被云平台限流。

3.2 DHT22驱动:单总线时序到底怎么调

DHT22用单总线协议,只有一根数据线,时序要求相当严格。完整流程是这样的:

  1. 主机拉低数据线至少18ms,然后释放并拉高20-40us。
  2. DHT22响应:先把总线拉低80us,再拉高80us。
  3. 然后连续输出40位数据,每一位的格式是:先拉低50us,再拉高,高电平持续时间26-28us表示逻辑0,持续70us表示逻辑1。

40位数据的分布是:湿度高8位、湿度低8位、温度高8位(最高位为符号位)、温度低8位、校验和8位。校验和必须等于前四个字节之和的低8位,否则这组数据直接丢弃。

核心代码比较敏感的是时序延时部分,我用的标准库版本是这样写的:

uint8_t DHT22_ReadData(float *humidity, float *temperature) { uint8_t data[5] = {0}; uint8_t i, j; // 主机发送起始信号 GPIO_InitTypeDef gpio; GPIO_InitStructure_Output(&gpio, GPIO_Pin_0); // 配置为输出 GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_RESET); delay_us(18000); // 至少18ms GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_SET); delay_us(30); // 切换到输入模式 GPIO_InitStructure_Input(&gpio, GPIO_Pin_0); // 等待响应信号 while(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET); while(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_RESET); while(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET); // 读取40位数据 for(i = 0; i < 5; i++) { for(j = 0; j < 8; j++) { while(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_RESET); delay_us(40); if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET) { data[i] |= (0x80 >> j); while(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET); } } } // 校验 if((uint8_t)(data[0] + data[1] + data[2] + data[3]) != data[4]) return 1; *humidity = ((data[0] << 8) | data[1]) / 10.0f; *temperature = ((data[2] & 0x7F) << 8 | data[3]) / 10.0f; if(data[2] & 0x80) *temperature = -(*temperature); return 0; }

这里的delay_us函数建议用DWT计数器实现,精度比软件循环高得多。我踩过的坑是:用nop循环做延时,编译器开优化之后延时时间完全不对,DHT22死活读不到数据。如果你也遇到类似问题,先查一下是不是延时被优化掉了。

3.3 BH1750的I2C读取和注意事项

BH1750是I2C接口,STM32的硬件I2C在标准库时代口碑不太好,很多人宁愿用软件模拟I2C。我实测下来,只要初始化正确、加上超时处理,硬件I2C是没问题的。但新手图省事用软件模拟I2C也行,代码网上大把,时序对了就能跑。

需要注意的是BH1750的工作模式。它有好几种测量模式:连续高分辨率、连续低分辨率、一次高分辨率等。高分辨率模式测量时间约为120ms,低分辨率约为16ms,分辨率分别是1lx和4lx。我选的是一次高分辨率模式,每次测量前先发测量命令,然后延时120ms再读结果,读回来的是16位数据,除以1.2就得到光照度值,单位是lx。

void BH1750_Read(float *lux) { uint8_t buf[2]; // 发送一次高分辨率测量命令 BH1750_WriteCmd(0x20); // 0x20 = 一次高分辨率模式 delay_ms(180); // 留足转换时间 // 读取测量结果 BH1750_ReadData(buf, 2); uint16_t raw = (buf[0] << 8) | buf[1]; *lux = (float)raw / 1.2f; }

I2C通信失败十有八九是上拉电阻问题。I2C的SCL和SDA必须有上拉电阻,阻值通常在2.2k到10k之间。我的板子上两个线都用了4.7k上拉到3.3V,从来没出过问题。如果发现BH1750偶尔读出来是0xFFFF或者I2C卡死,优先检查上拉电阻是否焊好、地址是否正确。

3.4 数据滤波:中值滤波和滑动平均的组合拳

传感器原始数据的质量,直接决定了整个系统的下限。MQ135和GP2Y1010这种模拟输出传感器,ADC读数跳变个几十个LSB是家常便饭,直接拿来显示会让你怀疑人生。

我用的方案是两级滤波:第一级是中值滤波,每采集5次取中间值,用来剔除突发噪声和尖峰;第二级是滑动平均,把最近10个中值再做平均,平滑短时波动。中值滤波负责“去毛刺”,滑动平均负责“润色”,配合起来效果相当好。

#define FILTER_WINDOW 5 #define SMOOTH_WINDOW 10 float MedianFilter(float *buf, uint8_t len) { // 冒泡排序求中间值,注意不要改变原数组顺序 float tmp[FILTER_WINDOW]; for(uint8_t i = 0; i < len; i++) tmp[i] = buf[i]; for(uint8_t i = 0; i < len - 1; i++) for(uint8_t j = i + 1; j < len; j++) if(tmp[i] > tmp[j]) { float t = tmp[i]; tmp[i] = tmp[j]; tmp[j] = t; } return tmp[len / 2]; }

滑动平均的实现更简单:维护一个环形缓冲区,每次把新值加进去,总和减去最旧的值,再除以窗口长度。注意所有中间变量用float,不要用int去收敛,否则长期运行会出现累加误差。

4. 本地显示、按键交互与远程上云

4.1 OLED界面设计:一屏看全还是分页轮播

0.96寸的128x64屏幕,一屏最多显示五六行文字。我做了三个页面,通过按键切换:第一页显示温湿度和光照度,第二页显示PM2.5和空气质量评级,第三页显示WiFi连接状态、MQTT连接状态和最近一次上报时间。

OLED驱动要注意刷新策略。SSD1306的I2C速度上限是400kHz,全屏刷新一次大约需要几十毫秒,如果你每100ms刷新一次全屏,CPU大部分时间都花在刷屏上了。我的做法是:分成“静态区域”和“动态区域”,只刷新变化的部分。比如显示实时温度的位置单独刷新,其他区域保持不变,这样既流畅又能减少I2C总线负担。

字体方面建议准备两套字库,一套是12x6的小字体用于数据正文,一套是16x8的大字体用于数字高亮显示。我做界面时发现,数字用大字体加粗显示,视觉效果会好很多,甚至不需要额外的图形美化。

4.2 ESP8266接入:AT指令还是直接刷固件

ESP8266作为STM32的通信外设,有两种接入方式:一种是保留原厂AT固件,STM32通过串口发AT指令控制;另一种是给ESP8266刷NodeMCU或Arduino固件,让它自己跑逻辑。对于这个项目,我强烈建议用AT指令方案,原因很简单:STM32是主控,所有逻辑在本地,ESP8266只充当“透传管道”,调试定位问题方便得多。

AT指令的基本流程是:

AT # 测试模块是否响应 ATE0 # 关闭指令回显 AT+CWMODE=1 # 设置为Station模式 AT+CWJAP="SSID","password" # 连接路由器 AT+CIPSTART="TCP","broker地址",1883 # 建立TCP连接 AT+CIPSEND=长度 # 发送数据

每个AT指令发送后一定要等待模块返回“OK”或“ERROR”再执行下一步,不要盲目连续发送。而且ESP8266在连接WiFi的过程中会阻塞,发送AT+CWJAP后最好加一个10秒的超时等待,然后轮询AT+CWJAP?确认连接状态。

我实际使用中发现一个高频问题:ESP8266模块供电不足,表现为WiFi连不上、发送AT指令偶尔无响应。前面提到的100uF电容一定要加,另外串口线不要用太长太细的杜邦线,给模块单独供电比让STM32板载稳压器硬扛要靠谱得多。

4.3 MQTT数据上云:JSON打包和断线重连

数据上传我选的MQTT协议,它比HTTP更适合物联网场景:长连接、开销小、支持发布订阅模型。云平台选择上,你可以用各种公共MQTT broker,也可以自己搭一个Mosquitto服务器。项目的核心是STM32侧怎么把数据组织好、发出去。

STM32侧的数据帧我用JSON格式,简单、可读性强、云平台解析也方便:

char jsonBuffer[128]; snprintf(jsonBuffer, sizeof(jsonBuffer), "{\"temp\":%.1f,\"humi\":%.1f,\"lux\":%.1f,\"pm25\":%.1f,\"aqi\":%d,\"ts\":%d}", temperature, humidity, lux, pm25, aqi, (int)time(NULL));

这里有个小技巧:先把所有传感器数据按照固定格式拼成字符串,然后一次性通过串口发送给ESP8266。为什么要先拼好再发?因为ESP8266的AT指令发送数据时需要先声明数据长度(AT+CIPSEND=长度),你不可能一边测数据长度一边发送,所以必须先把完整的数据帧准备好,再计算长度并发送。

MQTT断线重连是必须做的逻辑,而且要有退避策略。我实测下来,如果服务器瞬间重启或者路由器断电,ESP8266模块可能处于半死状态,一直重连会导致日志刷屏、模块反复重启。我的方案是:连续3次重连失败后,等待60秒再发起下一次重连,同时在OLED上显示“网络异常”提示。

5. 调试排障与经验速查

5.1 经典问题:DHT22读到的数据全是0或恒定值

这个问题的出现频率极高,大概率是以下几点之一:

原因现象解决办法
延时被编译器优化数据全0或偶尔读通用DWT或SysTick做精确延时
上拉电阻缺失或太小通信波形不好,数据错误数据线和VCC之间加4.7k电阻
引脚模式切换错误读不到起始响应确认GPIO从输出切换到输入时CNF配置正确
线太长导致波形畸变数据不稳定缩短连线,或者改用SHT30
传感器损坏完全无响应换一个备用传感器测试

排查时我一般先用逻辑分析仪或示波器看波形。如果没有示波器,也可以在程序里加调试代码,比如连续读取5次并把每次的状态打印出来,看卡在哪个while循环里,能快速缩小问题范围。

5.2 MQ135输出漂移和温湿度耦合

MQ135是化学传感器,除了预热漂移之外,它的输出还会受温度和湿度影响。同样的气体浓度,在冬天和夏天读出来的电压可能差很多。虽然专业做法是查datasheet的温湿度补偿曲线做修正,但我实测下来,在室内场景把MQ135的输出粗略换算成“空气质量等级”(优、良、差三个档位)就够了,没必要纠结精确的PPM数值。

换算方法不复杂:ADC读到的电压经过一个电阻分压网络对应一个等效电阻值Rs,把这个Rs和新鲜空气中的基准值R0做比值,再查气体灵敏度特性曲线,就能估算出目标气体的浓度。我的做法更简单,直接根据电压范围划分等级:电压低(<1V)空气质量好,电压中(1V-2.5V)空气质量一般,电压高(>2.5V)有污染风险。这个思路适合家用场景,精度要求高的工业场景另说。

5.3 现场调试的几个“土办法”

这些调试技巧都是实操总结出来的,正规教程很少写。

第一个是串口调试配合“数据信息指纹”。在固件里每隔几十毫秒打印一条带时间戳的调试信息,信息里包含当前执行到哪个任务、各传感器原始值、转换后的物理量值。把这些信息存到电脑上,回放一遍就能看出问题发生在哪个环节。

第二个是用手机的收音机功能间接判断ESP8266是否在正常工作。WiFi模块工作时会产生可听见的射频噪声,把手机靠近模块,打开收音机调到没有电台的频段,能听到明显的“滋滋”声,这说明模块在发射数据。这个小技巧不需要任何仪器就能判断ESP8266是否真的在发包。

第三个是给所有传感器做“单点测试”,意思是一次只接一个传感器,把它的数据调通、确认无误,再接入下一个。很多人一上来把所有传感器都挂上,出问题根本不知道是哪个环节。我调试的时候先接OLED,然后接DHT22,然后是BH1750、MQ135、GP2Y1010,最后才接ESP8266,每一层都验证过了再往上叠。

第四个是给数据加“健康度”自检。每个传感器驱动里加一个错误计数,连续几次读取失败就把该传感器标记为“故障”,界面上提示“传感器异常”,而不是显示一个假的0值或者乱码。这个功能在系统长期运行时非常重要,不然别人看数据还以为环境真的极端到爆表了。

6. 后续扩展与个人体会

6.1 可以扩展的方向

这个系统的骨架做完之后,很多方向都可以低成本扩展。

硬件上,可以加一个蜂鸣器做本地声光告警,温度过高或PM2.5超标时直接响。可以加一个继电器控制加湿器、风扇或空气净化器,做成闭环控制。比如温湿度低于阈值就自动打开加湿器,PM2.5超标就自动启动净化器。还可以换成带电池的低功耗方案,用STM32L系列加RTC定时唤醒,采集完数据就睡,环境监测站的续航能做到几个月。

软件上,可以在云平台侧加历史数据存储和图表展示,用SQLite加一个简单的后端服务就能实现。还可以加数据导出功能,或者用Webhook接入微信/钉钉机器人做远程告警推送。如果有条件,还能接上语音交互,对着智能音箱问一句“当前室内空气质量怎么样”,让音箱去查平台数据返回结果。

6.2 个人体会

做这个项目最大的收获还不是熟悉了STM32和这些传感器,而是建立了“系统思维”。单看每个传感器驱动,代码量都不大,但把采集、滤波、显示、上传、异常处理全部串起来,才发现真正的复杂度在接口之间的衔接和边界情况的处理。

我踩坑最深的就是数据上报——单纯上报平均数据看起来没问题,但一旦WiFi断了、服务器压力大了、传感器偶尔报错,一条链路上任何一个环节不稳定,整个系统的可用性就崩了。后来加上数据格式校验、重发机制、断线退避、传感器故障标记,系统才真正能长期运行。这也是我建议你在做完基础版本之后,一定不要把“能跑”当“能交付”的原因。

如果你正准备做类似的系统,我的建议是:先搭好最简单的回路——STM32点灯、OLED显示、串口打印,然后一步一步往上加。每加一个模块就完整调试一次,不要想着一次全部接好再统一调。这样看着慢,实际是最快的路径。等你完整跑通一遍,再去想优化和扩展,你会发现这个项目能带给你的远不止一个“能显示数据的盒子”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进