简介基于STM32的空气质量监测系统完整工程包面向嵌入式初学者与物联网开发者提供从硬件搭建到软件调试的成套参考。方案以正点原子STM32F103 Mini开发板配合MQ135传感器实现空气质量的实时采集与显示覆盖ADC采样、气体浓度换算、LCD显示、串口与WiFi扩展等常见开发环节并给出温度补偿和多次采样滤波的处理思路。压缩包共155个文件以c源文件、h头文件为主同时包含编译生成的o、d、crf中间文件以及hex、axf烧录文件、uvprojx工程文件可直接打开工程重新编译或烧录到板卡验证整体大小约4.41MB。代码基于STM32CubeMX生成并采用HAL库编写参考正点原子例程外设初始化、中断处理和模块化工程结构清晰便于二次开发。已有3407人学习下载适合课程设计、毕业设计以及智能家居环境监测方向的实践学习。1. 空气质量监测系统能出数但你能信吗把一套基于STM32的空气质量监测系统接好线、烧完程序放到窗台上测了一小时PM2.5读数在35到89之间来回跳和旁边的手持检测仪并排对比最大偏差接近四成。这个场景几乎每个碰过空气质量项目的人都经历过——代码能编译、屏幕能点亮、数据能打印但真要拿它判断“今天能不能开窗”心里没底。《基于STM32的空气质量监测系统.zip》这类工程包里最有价值的从来不是能跑通的源码而是让数据从“能显示”变成“可信”的完整过程。下面要讲的不是某个特定压缩包而是这类系统的通用落地路径传感器怎么选、数据怎么读、显示和上报怎么接、有哪些坑躲不开。适合做课设、毕设的人也适合第一次把STM32和激光粉尘传感器组合起来的开发者。读完后你能复现一套完整的采集、显示、上报链路并理解那些看似玄学的数值漂移从哪来。2. 核心选型传感器与主控怎么搭才不会翻车先说结论主流且少折腾的搭配是STM32F103C8T6加SDS011激光粉尘传感器、SHT30温湿度传感器、0.96寸OLED、ESP8266-01S做上报。这套组合开发成本低资料也最好找。有人为了省钱换成GP2Y1010粉尘传感器和DHT22最后发现省下的钱全变成了调试时间。下面把每个器件为什么这样选拆开讲清楚。2.1 传感器与主控选型清单组件推荐型号接口说明PM2.5/PM10SDS011UART 9600bps激光散射自带风扇抽气直接输出质量浓度温湿度SHT30I2C 地址0x44精度高无需微秒级时序显示SSD1306 OLED 0.96寸I2C 地址0x3C128x64两行半信息够用WiFi上报ESP8266-01SUART 115200AT固件串口转MQTT/HTTP主控STM32F103C8T6—双UART加硬件I2C资源刚好SDS011和GP2Y1010的对比最值得说。GP2Y1010是模拟输出内部靠红外LED加光电二极管需要用脉冲驱动加运放放大信号还容易受环境光干扰想从电压换算到质量浓度得自己查曲线。SDS011内部是激光管加微型风扇传感器自己把空气抽进去对外直接输出折算好的质量浓度你不用调运放也不用画校准曲线。代价是功耗大持续工作电流能到80mA左右做电池供电时必须做间歇采集不能让它一直全速跑。温湿度这里DHT22看起来便宜又常见但它用的是单总线协议读时序要求微秒级精确。只要读的过程里串口中断插进来时序被拉长一截数据就全部错位常表现为湿度突然跳到87%或返回0。SHT30是标准I2C接口地址0x44两条数据线拉好就能读只要地址对、命令对基本不出幺蛾子。工程上我更愿意把DHT22留在线缆长、环境干扰少的老项目里新设计一律SHT30。如果还要监测CO2常见做法是加SGP30走I2C它输出的是估算的eCO2和TVOC不需要额外校准硬件但数值会随时间漂移得靠算法做基线修正。要求准确就换MH-Z14A这类串口红外传感器精度高但价格贵一个量级。我的习惯是先上SGP30把功能做出来再按需求决定要不要升级。主控选STM32F103C8T6的理由很朴素三个USART一个硬件I2CFlash 64KB、RAM 20KBCubeMX生成代码方便这类传感器组合的外设需求它刚好全部覆盖不用为资源不够而换F407。2.2 系统架构三个节奏一颗心脏空气质量监测这类系统常见做法是“采集、显示、上报”三件事分开节奏。我一般会让每1秒采一次SDS011和SHT30每500ms刷一次OLED每10秒拼一次JSON推到MQTT。如果在主循环里用HAL_Delay串行跑这些事OLED刷新会挡住传感器读取按键扫描再卡一下数据曲线就是锯齿状或者干脆丢帧。工程上最简单的做法是维护三个时间戳主循环空转检查uint32_t t_sensor 0, t_oled 0, t_upload 0; while (1) { if (HAL_GetTick() - t_sensor 1000) { t_sensor HAL_GetTick(); sensor_read_all(); // 采SDS011和SHT30非阻塞 } if (HAL_GetTick() - t_oled 500) { t_oled HAL_GetTick(); oled_refresh(); // 只刷变化区域不整屏重绘 } if (HAL_GetTick() - t_upload 10000) { t_upload HAL_GetTick(); mqtt_publish_all(); // 拼JSON并通过ESP8266发送 } }这段代码的核心是把三个节奏解耦每个函数内部不做长延时阻塞。HAL_GetTick()走的是SysTick毫秒精度用减法判断时间差能避开HAL_GetTick()回绕的问题。这种时间戳写法在裸机工程里是底线方案比一长串HAL_Delay要好维护得多。后面如果上了FreeRTOS其实也是同样的思路采集任务优先级最高显示和上报按周期执行只是把时间戳换成了任务调度。3. 从零接线到出数最小系统的代码骨架选型定了之后这一步把最小可复现的系统串起来从CubeMX配置到串口打印出第一组可信数据。整个链路只需要两个串口加一个I2C外设占用不多接线也简单。3.1 CubeMX配置与引脚分配先在CubeMX里建好工程按下面这张表分配外设外设引脚模式配置USART1PA9 TX / PA10 RX异步收发9600 8N1接SDS011USART2PA2 TX / PA3 RX异步收发115200 8N1接ESP8266或调试串口I2C1PB8 SCL / PB9 SDAI2C Fast Mode400k挂SHT30和OLEDSYS—Serial Wire保留SWD下载调试时钟用外部8MHz晶振PLL倍频到72MHz主频APB1设36MHz。这个配置下UART波特率误差在9600和115200两个档位都能控制在0.1%以内不用做波特率校准。这里有一个容易踩的小坑USART1的RX引脚PA10和I2C的SDA PB9在部分开发板上离得很近飞线时容易搭在一起接完线先量一下通断再上电。3.2 SDS011数据帧解析从字节流里抠出PM2.5SDS011主动上报模式会每秒自动吐一帧数据帧格式是AA C0 [PM2.5低] [PM2.5高] [PM10低] [PM10高] [ID低] [ID高] [校验和] AB。校验和是从C0开始到ID高字节这一共7个字节的累加和取低8位。解析时不能按固定偏移对齐缓冲区因为数据流随时可能从半截开始必须用状态机找帧头。// SDS011 主动上报帧解析状态机 // 帧格式: AA C0 [PM2.5L] [PM2.5H] [PM10L] [PM10H] [IDL] [IDH] [CS] AB #define SDS_BUF_LEN 10 uint8_t sds_buf[SDS_BUF_LEN]; uint8_t sds_len 0; uint8_t sds_step 0; // 0等0xAA, 1等0xC0, 2等数据, 3等校验和, 4等帧尾 int16_t pm25_x100 0; // 质量浓度放大100倍避开浮点 int16_t pm10_x100 0; void sds011_push(uint8_t b) { switch (sds_step) { case 0: sds_len 0; if (b 0xAA) { sds_buf[sds_len] b; sds_step 1; } break; case 1: if (b 0xC0) { sds_buf[sds_len] b; sds_step 2; } else if (b ! 0xAA) { // 丢掉非0xAA的字节重新等帧头 sds_step 0; } break; case 2: sds_buf[sds_len] b; // 6个数据字节 if (sds_len 8) sds_step 3; break; case 3: sds_buf[sds_len] b; // 校验和 sds_step 4; break; case 4: sds_buf[sds_len] b; // 帧尾 AB if (b 0xAB) { uint8_t cs 0; for (int i 1; i 8; i) cs sds_buf[i]; if (cs sds_buf[8]) { pm25_x100 (sds_buf[2] | (sds_buf[3] 8)) * 100; pm10_x100 (sds_buf[4] | (sds_buf[5] 8)) * 100; } } sds_step 0; sds_len 0; break; } }这段状态机的关键是只有同时满足校验和正确、帧尾是0xAB才认为是一帧有效数据。校验和能挡住大部分错位帧尾再兜底一次基本不会把乱码当数据用。接收到数据后我直接乘100存入全局变量后面显示和上报都按整数处理不引入浮点。串口采用逐字节中断接收在中断回调里把字节喂给状态机uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { sds011_push(rx_byte); HAL_UART_Receive_IT(huart1, rx_byte, 1); // 重新开启下一次接收 } }main函数里记得启动第一次接收HAL_UART_Receive_IT(huart1, rx_byte, 1);。逐字节中断在这个场景下够用9600波特率下每字节约1.04ms触发一次中断对CPU负载几乎无感。如果后续要加多路传感器再换DMA加空闲中断也不迟但要注意DMA缓冲区对齐和半帧处理逻辑复杂度会明显上升。3.3 用I2C读SHT30温湿度SHT30是I2C从机7位地址0x44发送测量命令后等待约7ms转换完成再读回6字节前两字节是温度原始值中间两字节是温度CRC后两字节是湿度原始值湿度CRC在最后一字节。演示工程可以省略CRC但产品级必须校验I2C总线受干扰时读错数据的概率不算低。#define SHT30_ADDR 0x44 uint8_t sht_cmd_measure[2] {0x2C, 0x06}; // 高重复性、时钟拉伸使能 uint8_t sht_raw[6]; int16_t temp_x100 0; int16_t humi_x100 0; void sht30_read(void) { HAL_I2C_Master_Transmit(hi2c1, SHT30_ADDR 1, sht_cmd_measure, 2, 100); HAL_Delay(20); // 留足转换时间 HAL_I2C_Master_Receive(hi2c1, SHT30_ADDR 1, sht_raw, 6, 100); uint16_t t_raw (sht_raw[0] 8) | sht_raw[1]; uint16_t h_raw (sht_raw[3] 8) | sht_raw[4]; // 换算公式见数据手册结果放大100倍存整数 temp_x100 (int16_t)((175.0f * t_raw / 65535.0f - 45.0f) * 100.0f 0.5f); humi_x100 (int16_t)((100.0f * h_raw / 65535.0f) * 100.0f 0.5f); }HAL库的I2C地址参数是要左移一位的SHT30地址0x44HAL函数里要传0x88。传错地址的表现是I2C通信偶尔成功偶尔超时因为总线上有这个地址的设备恰好响了一下。另外SHT30的命令有0x2C开头和0x24开头两种0x2C支持时钟拉伸读取时序更宽松我一般固定用这个。3.4 串口打印看到第一组数据的标准姿势开发阶段最直接验证方式就是串口打印。USART2这时候先不接ESP8266接到USB转串口调试器上按上面同样的逐字节中断或直接HAL_UART_Transmit输出void debug_print(void) { char line[64]; int len snprintf(line, sizeof(line), PM2.5: %d.%02d ug/m3, PM10: %d.%02d, T: %d.%02d, H: %d.%02d\r\n, pm25_x100 / 100, pm25_x100 % 100, pm10_x100 / 100, pm10_x100 % 100, temp_x100 / 100, temp_x100 % 100, humi_x100 / 100, humi_x100 % 100); HAL_UART_Transmit(huart2, (uint8_t *)line, len, 100); }注意这里所有数值都是放大100倍的整数打印时手动拆出整数部分和小数部分完全避开浮点格式化。STM32F103没有FPU软浮点printf库会吃掉大量Flash和CPU时间这种定点数方案在串口打印、JSON拼接、OLED显示三处都能复用。看到串口助手稳定输出PM2.5数值后最小系统就算跑通了。4. 显示与上报让数据不再只是串口里的数字数据能稳定打印之后下一步就是让它上屏幕、上网络。这两件事的核心都在于I2C和UART的通信时序以及数据从原始值到可展示值的转换。4.1 OLED显示SSD1306初始化与文本绘制0.96寸OLED用的SSD1306控制器I2C地址0x3C和SHT30挂在同一条I2C1总线上地址不冲突。上电后第一件事是发送初始化序列下面这段是能点亮大多数翻新屏的通用配置static void oled_cmd(uint8_t cmd) { uint8_t d[2] {0x00, cmd}; // 控制字节0x00表示写命令 HAL_I2C_Master_Transmit(hi2c1, 0x3C 1, d, 2, 50); } void oled_init(void) { static const uint8_t init_seq[] { 0xAE, // 关闭显示 0x20, 0x00, // 水平寻址模式 0xB0, // 页起始地址0 0xC8, // 扫描方向反转 0x00, 0x10, // 列地址低高字节清零 0x40, // 显示起始行0 0x81, 0xCF, // 对比度 0xA1, // 段重映射 0xA6, // 正常显示 0xA8, 0x3F, // 复用比1/64 0xD3, 0x00, // 显示偏移0 0xD5, 0x80, // 时钟分频 0xD9, 0xF1, // 预充电周期 0xDA, 0x12, // COM引脚硬件配置 0xDB, 0x40, // VCOMH电平 0x8D, 0x14, // 打开电荷泵 0xAF // 开启显示 }; for (unsigned i 0; i sizeof(init_seq); i) { oled_cmd(init_seq[i]); } }这段初始化序列里0x8D 0x14开启内部电荷泵是关键漏掉这步屏幕完全不亮0x20 0x00设成水平寻址后写数据会按行列自动递增简化连续文本绘制。显示文本时要自己准备一份6x8点阵ASCII字库绘制函数负责切页和切列void oled_draw_text(uint8_t x, uint8_t page, const char *s) { oled_cmd(0xB0 | (page 0x07)); // 页地址0~7 oled_cmd(0x00 | (x 0x0F)); // 列地址低4位 oled_cmd(0x10 | (x 4)); // 列地址高4位 while (*s) { // font6x8字符宽6像素高8像素正好一页 for (int i 0; i 6; i) { uint8_t d[2] {0x40, font6x8[*s][i]}; HAL_I2C_Master_Transmit(hi2c1, 0x3C 1, d, 2, 10); } s; } }这里控制字节0x40表示写数据和写命令的0x00区分开。页地址0xB0到0xB7对应屏幕从上到下的8行点阵一页高8像素。128x64的屏幕在纯文本模式下能显示大约4行字符一行约21个字符。OLED的I2C速率设成400k后这种逐字节发送在500ms刷新周期内完全没有压力。如果要在屏幕上看实时曲线就得在MCU内存里维护整屏帧缓冲再整块DMA发送那套逻辑比这个文本绘制复杂不少但大多数监测界面用文本加数字就足够了。4.2 整数定点数打包JSON上报数据前要把传感器数值拼成JSON字符串依然走整数路线。拼字符串用snprintf最稳缓冲区大小按字段算好防止越界char payload[128]; int build_payload(char *buf, int size) { return snprintf(buf, size, {\pm25\:%d.%02d,\pm10\:%d.%02d,\t\:%d.%02d,\h\:%d.%02d}, pm25_x100 / 100, pm25_x100 % 100, pm10_x100 / 100, pm10_x100 % 100, temp_x100 / 100, temp_x100 % 100, humi_x100 / 100, humi_x100 % 100); }这个函数返回snprintf实际写入的字符数发送时直接用返回值做长度不用strlen再算一遍。注意PM2.5的原始值来自SDS011时是正数如果把占空比换算成浓度可能算出负数拼接前要先夹紧到0。JSON字段名这里用单字母是为了省流量如果对接平台有固定字段要求改成pm25、temperature这样的全名就行但缓冲区也要相应加大。4.3 ESP8266 AT指令与MQTT上报ESP8266-01S模块接在USART2上开发阶段这个串口连的是调试器功能联调时再切到ESP8266。接线比较简单模块的TX接STM32的PA3、RX接PA2、VCC接3.3V、CH_PD拉高到3.3VGND共地。上电后先发AT确认模块响应再按顺序配WiFi和MQTTAT ATCWMODE1 ATCWJAPyour_ssid,your_password ATMQTTUSERCFG0,1,device01,mqtt_user,mqtt_pass,0,0, ATMQTTCONN0,192.168.1.100,1883,1 ATMQTTPUB0,/home/air,payload,1,0这套指令依赖带MQTT AT固件的ESP8266不同固件版本指令差异很大。如果你的模块不支持ATMQTT开头的指令常见做法是退回ATCIPSTART连TCP然后自己在数据流里拼MQTT报文工作量会上一个台阶。我一般会优先刷固件而不是改协议乐鑫官方AT固件对MQTT的支持已经比较成熟刷完记得ATRESTORE复位一次再配置。发送函数的实现要处理AT返回确认不能发完就不管void uart2_send_at(const char *cmd) { HAL_UART_Transmit(huart2, (uint8_t *)cmd, strlen(cmd), 500); HAL_Delay(500); // 等模块处理并返回OK简单粗暴但可靠 } void mqtt_publish_all(void) { char payload[128]; char cmd[192]; int len build_payload(payload, sizeof(payload)); snprintf(cmd, sizeof(cmd), ATMQTTPUB0,\/home/air\,\%s\,1,0\r\n, payload); uart2_send_at(cmd); }这里每个AT命令之间加了固定500ms延时是为了避免AT固件处理不过来导致丢参数。正规做法是读返回的OK再发下一条但裸机上做AT响应解析要加串口接收缓冲区代码量翻倍。固定延时虽然浪费一点时间但胜在稳。MQTT的topic里可以带设备ID比如/home/air/device01这样多台设备共用一个broker时不会互相覆盖。如果端口被封或者不想搭MQTT broker改HTTP POST到自己的服务器会更省事ESP8266的AT固件里ATHTTPPOST指令能直接完成只是数据需要URL编码。5. 避坑空气质量监测项目里的5个典型问题这一章讲的不是代码语法错误而是物理和时序层面的坑。每一条都是真实调过的血泪经验遇到现象对不上号的时候最先怀疑这几个位置。5.1 刚上电数值乱跳给传感器留足预热时间现象上电后PM2.5从20多慢慢爬到60又掉回30这样来回折腾好几分钟才勉强稳定如果直接取头几秒的数据做判断结论完全是错的。原因SDS011内部的激光管和风扇需要时间进入热稳定气流也要几十秒才能建立起来。激光管的发光强度在冷启动阶段一直在飘折算出来的浓度也跟着飘。解决开机后预留3分钟预热窗口丢弃前180个采样值。写成代码就是在采集函数入口加一个计数器static uint16_t warmup_cnt 180; void sensor_read_all(void) { if (warmup_cnt) { warmup_cnt--; return; // 预热期内直接丢弃不更新全局变量 } // 正常采集逻辑 }注意是真正丢弃不是在显示层隐藏。传感器内部稳定之前的原始值本身无效滤波和校准都救不回来。5.2 读数整体偏低外壳开孔和风道位置现象设备裸板的时候PM2.5读数正常装进外壳盖上盖子之后读数直接掉了三四成放到窗边也上不去。原因SDS011靠内部风扇形成负压把空气抽进来进风口被塑料壳挡住气流进不来顺便采样到的只是壳内滞留空气。开孔方向不对的话抽到的是死角里的空气。解决给传感器进风口和出风口留出至少1cm见方的直通风道开孔位置要避开板子背面最好让风扇出风方向直通外壳出风孔。不要用细密滤网罩住进风口滤网对PM2.5的拦截会直接变成测量误差。5.3 读DHT22读到一半被打断为什么我劝你换SHT30现象湿度偶尔跳成87%或者直接读回0重启后又正常。用手碰一下引脚还可能复现。原因DHT22的单总线协议要求微秒级精确延时读时序。读的过程里UART中断或者其他外设中断插进来延时被拉长数据位判定就错了。解决换SHT30I2C硬件控制器管理时序不依赖微秒级延时。如果非要用DHT22唯一的干净办法是读时序期间关全局中断__disable_irq(); // 这里执行DHT22的微秒级电平采样中间不能被中断打扰 __enable_irq();但关中断期间串口字节会丢如果正好在读SDS011数据帧的半截这个帧就废了。这种两难场景是我劝退DHT22的主要原因省下的那几块钱不够付调试时间。5.4 串口解析出负数和乱码状态机练的就是这个现象PM2.5偶尔显示负数或者突然跳出一个几百的异常大值几帧之后又恢复。原因数据流从帧中间开始接收按固定偏移去解析缓冲区整帧错位。这种错位会在传感器上电瞬间、串口线接触不良、波特率配错时反复出现。解决用前面3.2讲的状态机找帧头加校验和与0xAB帧尾双重校验。如果状态机校验完全正确还出错优先查硬件——SDS011的串口线超过30cm就会出这种问题把线剪短或换成屏蔽线。还有一个隐蔽原因RX引脚配置成了复用推挽而不是上拉输入会使电平很脆弱检查一下CubeMX里的GPIO配置。5.5 电源噪声让数值周期性跳变风扇和主控抢电现象PM2.5数值按一个固定周期跳几下波形像锯齿把传感器拿在手里晃动时周期不变。原因SDS011内部风扇启停导致电流波动和主控共用一个LDO供电。传感器供电脚上的电压被风扇拉低虽说不至于掉压到复位但模拟前端和参考电压都在跟着抖读出来的浓度就周期性地飚一下。解决把传感器电源和主控电源分开或者在传感器供电入口加100uF电解电容并联0.1uF陶瓷电容。电容要靠近传感器供电脚放远离主控那边才有用。同理ESP8266在WiFi发包瞬间电流会突然拉高它旁边也建议单独放一个100uF电容不然上报那一下会把OLED亮度带得抖一下。6. 进阶给数据做平滑滤波与零点校准系统能跑通之后真正拉开体验差距的是两条曲线太平滑会掩盖真实变化太毛糙又没法看。这里给一个我常用的低通滤波和校准组合。6.1 一阶低通滤波曲线不再抽风一阶惯性滤波本质上就是指数移动平均对每秒采一个点的场景特别合适static float ema_pm25 0; static uint8_t ema_started 0; float lowpass(float raw, float alpha) { if (!ema_started) { ema_started 1; ema_pm25 raw; return raw; } ema_pm25 alpha * (raw - ema_pm25); return ema_pm25; }alpha取0.2到0.4之间。按照每秒采样一次alpha0.2时时间常数约5秒能明显压住瞬时噪声又不掩盖真实趋势alpha0.4响应更快但曲线会毛一些。如果想看到有人点烟的秒级突变alpha要抬到0.6以上这时候就不要再加大滤波了。6.2 两点校准把“看着怪”变成“可比较”一阶低通只是平滑解决不了零点偏移。SDS011这类民用传感器出厂值本身就有个体差异用两点校准修正typedef struct { int16_t offset; // 零点偏移单位是原始采样值 float k; // 斜率修正默认1.0 } calib_t; calib_t cal {0, 1.0f}; int16_t pm25_display(int16_t raw) { return (int16_t)((raw - cal.offset) * cal.k); }校准步骤先把设备放到HEPA滤芯出风口等读数稳定后记下raw_zero把cal.offset设成这个值再把手持仪和系统放到同一个环境等两边稳定后记下系统原始值和手持仪参考值斜率k等于参考值除以(raw_ref - offset)。注意校准依赖一台相对准确的设备它本身有误差所以这个操作是把两台设备做到可比对不是变成计量级。我自己的习惯是每调完一套新的空气质量系统先裸板运行半小时存下原始曲线再拿手持仪做一次两点校准确认趋势对得上才算能交付。这个习惯帮我省掉了不少“事后发现数据不可信”的返工希望帮到你。本文还有配套的精品资源点击获取