
简介基于STM32F103C8T6与微信小程序的室内空气监控系统毕业设计源码及配套说明文档面向嵌入式、物联网及计算机相关专业的毕设学生与开发者用于解决室内空气质量实时监测、远程预警与通风控制问题特别适合作为毕业设计参考。压缩包共139个文件整体约8.51MB内容以微信小程序前端代码js/json/wxml/wxss和项目图片为主另有md说明文档辅助理解工程结构。目前已有700人学习/下载参考价值不错。完整代码覆盖设备端温湿度、可燃气体、PM2.5采集与OLED显示通过MQTT协议接入OneNET云平台小程序端实现数据可视化、历史曲线、报警通知及远程控制警报器与通风机文档说明包含系统设计方案与关键实现细节既可用于毕业设计答辩演示也适合物联网项目二次开发学习。1. 基于STM32和微信小程序的室内空气监控系统自己动手的完整路径装修完的屋子关窗一小时再进门TVOC 和 CO₂ 的浓度往往比嗅觉告诉你的更吓人。想知道室内实时空气质量与其买固定式检测仪不如用 STM32 接传感器自己采再交给微信小程序展示——扫开即用免装 App还能自由扩展监测项。「STM32微信小程序」是近两年基于 STM32 的毕业设计热门选题本质是把采集端和展示端拆开STM32 负责读温湿度、TVOC、PM2.5 并组帧发送小程序负责蓝牙扫描、连接、解析和实时刷新。两端协议自己定不依赖第三方云平台实验室里就能跑通。下文按拿到源代码后真正要动手的顺序讲传感器选型与 CubeMX 工程配置、小程序蓝牙通信与数据解析、帧协议与联调排错最后补上 SGP30 基线校准这个最容易翻车的环节。2. STM32 端空气采集传感器选型、I2C 读取与数据滤波一套能过答辩的室内空气监控系统传感器选型占一半权重。STM32 的 I2C 和串口外设都很成熟难点不在驱动而在传感器能不能给出稳定、可解释的数值。我的做法是先定指标再定传感器温湿度、TVOC、eCO₂、PM2.5 各一项宁可少接一个也不接一个讲不清的。2.1 传感器选型SHT30、SGP30 和 PMS5003 怎么分工温湿度最容易翻车。DHT11 便宜但 ±2℃ 的误差加上 1Hz 采样时的数值跳变演示屏上 28.6℃ 一秒后跳到 28.9℃答辩老师一眼就看出数据不稳。我一般用 SHT30I2C 接口±0.2℃带 CRC8 校验二十元上下是温湿度最稳的选择。空气质量部分选 SGP30 而不是 MQ-135。MQ-135 是模拟量输出只能定性说「空气有没有变差」给不出 TVOC 的 ppb 数值超标判断只能靠拍脑袋阈值。SGP30 直接输出 eCO₂ppm和 TVOCppb两个数字和市面检测仪口径一致演示时容易对照。传感器测量项接口典型精度参考价注意点SHT30温度/湿度I2C地址 0x44±0.2℃ / ±2%RH约 20 元建议启用 CRC 校验SGP30eCO₂ / TVOCI2C地址 0x58数量级准确约 40 元上电需预热基线会漂移CCS811eCO₂ / TVOCI2C地址 0x5A一般约 35 元老化快需湿度补偿PMS5003PM2.5 / PM10UART9600±10%约 50 元持续供电风扇有寿命MQ-135综合空气ADC只能定性约 8 元给不出具体浓度慎用PM2.5 传感器建议最后加。PMS5003 是激光散射方案输出走串口给 STM32 留一路 UART 就行。预算紧张时先砍掉 PM2.5保留 SHT30 加 SGP30帧协议里 PM2.5 字段填 0小程序端做空值显示即可。这套「温湿度TVOCCO₂PM2.5」的组合是大量 stm32 项目实例里的标配。2.2 用 STM32CubeMX 拉通 I2C1 和 USART2 的工程配置开发环境按 stm32 开发环境的主流路径来STM32CubeMX 生成初始化代码MDKKeil编译烧录。第一步是装芯片包——CubeMX 的 Help → Manage Embedded Software Packages 里安装 STM32F1 系列固件包Keil 侧装 STM32F1 的 DFP。如果电脑之前装过 C51 版 KeilMDK 的 DFP 和 C51 互不影响双击 pack 装完就能用这也是 stm32 芯片包安装里最常见的疑问。选型用 STM32F103C8T664KB Flash资源对这个系统绰绰有余。CubeMX 里只需要开三个外设I2C1 挂两颗空气传感器USART2 接蓝牙透传模块USART1 留作串口日志。外设引脚连接对象说明I2C1_SCLPB6SHT30/SGP30 SCL两芯片挂同一条总线靠地址区分I2C1_SDAPB7SHT30/SGP30 SDA需要 4.7kΩ 上拉USART2_TXPA2蓝牙模块 RX波特率以模块为准常见 9600USART2_RXPA3蓝牙模块 TX只收不发时可留空USART1PA9/PA10ST-Link VCOM调试打印不参与业务SWDIO/SWCLKPA13/PA14ST-Link调试口必须保留2.2.1 CubeMX 里最影响下载和通信的三个设置第一个是 SYS → Debug 必须选 Serial Wire。默认 No Debug 会把 SWD 引脚释放成普通 IO第一次烧完程序第二次就下载不了只能把 BOOT0 拉高用串口擦除很折腾。第二个是时钟树。外部晶振 8MHz 时把 PLL 配到 72MHzHAL_GetTick 和串口波特率都依赖这个主频。F103 用内部 HSI 也能跑但波特率误差会变大和蓝牙模块长时间通信时容易丢字节。第三个是 I2C 速度。飞线阶段我建议选 Standard Mode 100kHzSHT30 和 SGP30 对慢速 I2C 完全没意见跳到 400kHz 后线一长就容易 NACK排查成本远高于省下的那点时间。注意CubeMX 每次重新生成代码都会覆盖 main.c 的用户区业务代码记得放在 /* USER CODE BEGIN/ 和 /USER CODE END */ 注释之间。PMS5003 的接法单独说9600 波特率下它每 200ms 出一帧 32 字节的 PM2.5 数据建议挂 USART3 并用 DMA 接收避免阻塞主循环。飞线调试阶段可以先不接先把 SHT30 加 SGP30 的链路跑通。2.3 SHT30 与 SGP30 的 I2C 读取代码加 5 点滑动滤波主循环按 1Hz 调用一次采集读到的原始值先过 CRC 校验再进滑动平均// sensor_air.c —— I2C1 读取 SHT30 和 SGP30带 CRC 校验 #include sensor_air.h #define SHT30_ADDR 0x44 #define SGP30_ADDR 0x58 // CRC-8/MAXIM多项式 0x31SHT30 和 SGP30 通用 static uint8_t crc8(const uint8_t *data, uint8_t len) { uint8_t crc 0xFF; for (uint8_t i 0; i len; i) { crc ^ data[i]; for (uint8_t b 0; b 8; b) crc (crc 0x80) ? (crc 1) ^ 0x31 : (crc 1); } return crc; } static uint8_t sht30_read(float *temp, float *humi) { uint8_t cmd[2] {0x2C, 0x06}; // 高重复性单次测量 uint8_t buf[6]; if (HAL_I2C_Master_Transmit(hi2c1, SHT30_ADDR 1, cmd, 2, 100) ! HAL_OK) return 1; HAL_Delay(20); // 内部转换约 14ms留足余量 if (HAL_I2C_Master_Receive(hi2c1, SHT30_ADDR 1, buf, 6, 100) ! HAL_OK) return 2; // 6 字节排列温度2 CRC1 湿度2 CRC1 if (crc8(buf, 2) ! buf[2] || crc8(buf 3, 2) ! buf[5]) return 3; *temp -45.0f 175.0f * (((buf[0] 8) | buf[1]) / 65535.0f); *humi 100.0f * (((buf[3] 8) | buf[4]) / 65535.0f); return 0; } static uint8_t sgp30_read(uint16_t *eco2, uint16_t *tvoc) { uint8_t cmd[2] {0x20, 0x08}; // measure_air_quality uint8_t buf[6]; if (HAL_I2C_Master_Transmit(hi2c1, SGP30_ADDR 1, cmd, 2, 100) ! HAL_OK) return 1; HAL_Delay(25); // 芯片内部积分约 10~12ms if (HAL_I2C_Master_Receive(hi2c1, SGP30_ADDR 1, buf, 6, 100) ! HAL_OK) return 2; // 6 字节排列eCO2高/低 CRC1 TVOC高/低 CRC1 *eco2 (buf[0] 8) | buf[1]; // 大端单位 ppm *tvoc (buf[3] 8) | buf[4]; // 单位 ppb return 0; }两段读函数都走「发命令—等转换—收 6 字节」三步。SHT30 每 2 字节数据夹 1 字节 CRC校验失败直接返回非 0上层丢弃整帧避免坏数据进滤波。SGP30 返回的 6 字节中间各夹一个 CRC 字节这里简化处理CRC 统一在组帧前兜底。温度换算公式来自 SHT30 数据手册16 位原始值线性映射到 -45~175℃湿度映射到 0~100%都按 65535 归一化。返回非 0 表示本帧不可信我在主循环里给它计数连续失败超过 10 次才亮故障灯单次失败不打扰用户。滤波采用 5 点滑动平均// filter.c —— 1Hz 采样下 5 秒窗口的滑动平均 #define FILTER_N 5 static float ring[FILTER_N]; static uint8_t idx; float air_moving_average(float v) { ring[idx] v; idx (idx 1) % FILTER_N; float sum 0; for (uint8_t i 0; i FILTER_N; i) sum ring[i]; return sum / FILTER_N; }N 的取值和采集频率强相关1Hz 采样时 N5 就是 5 秒窗口对 SGP30 的随机波动刚好如果把刷新率提到 5HzN 最好提到 15~25否则尖峰滤不干净。注意这个函数不是线程安全的别放进中断回调——主循环正在读写时被中断打断会出现半更新状态。3. 微信小程序端BLE 通信流程与实时数据刷新小程序端的核心难点不在 UI而在 wx 蓝牙 API 的状态机。微信小程序项目实例里凡是卡住的十有八九是没按 open→scan→connect→notify 的顺序走或者不知道这些 API 只能在真机调。下面按最常用的 BLE 直连方案讲。3.1 通信方案对比BLE 直连、Wi-FiMQTT 和局域网 TCP先定链路再写代码三种方案各有利弊方案链路延迟基础设施适用场景BLE 直连STM32蓝牙模块 → 手机100ms无实验室演示、答辩Wi-FiMQTTSTM32ESP8266 → MQTT broker → 小程序 WebSocket300ms~1s需要 broker远程查看、多设备局域网 TCPESP8266 开 TCP server → 小程序~50ms同一路由器家里自用我一般选 BLE 直连不需要租服务器不需要域名备案答辩现场打开手机蓝牙就能跑。注意 HC-05 是蓝牙 2.0不支持小程序 BLE要用 HM-10、JDY-18 这类 BLE 4.0 模块。它们默认服务 UUID 是 FFE0数据特征值是 FFE1支持 notify。将来要上远程把协议帧从 USART2 挪到 ESP8266 的串口帧格式一行不用改。3.2 微信小程序蓝牙扫描与连接的四步流程第一步初始化蓝牙适配器。常见错误是用户没开蓝牙就直接调所以 fail 里要区分 errCode 10001未打开和其他错误。第二步扫描用 services 参数过滤能大幅减少无关广播但前提是模块广播里带了 FFE0 服务如果手机搜得到模块却一直拿不到回调可以先去掉 services 全量扫描再用设备名过滤。// ble.js —— 适配器初始化与设备发现 const BLE_NAME_KEY HMSoft // HM-10 默认广播名前缀可用 AT 指令修改 initBle() { wx.openBluetoothAdapter({ success: () { wx.startBluetoothDevicesDiscovery({ services: [FFE0], // 按服务 UUID 过滤减少回调量 allowDuplicatesKey: false, // 同一设备只上抛一次 success: () { wx.onBluetoothDeviceFound(res this.onDeviceFound(res)) }, fail: (e) this.toast(扫描启动失败: e.errCode) }) }, fail: (e) { if (e.errCode 10001) this.toast(请先打开手机蓝牙) else this.toast(蓝牙初始化失败: e.errCode) } }) } onDeviceFound(res) { const dev res.devices[0] if (dev.name dev.name.indexOf(BLE_NAME_KEY) ! -1) { wx.stopBluetoothDevicesDiscovery({}) wx.createBLEConnection({ deviceId: dev.deviceId, success: () this.setupNotify(dev.deviceId), fail: (e) this.toast(连接失败: e.errMsg) }) } }参数说明allowDuplicatesKey 设 false 表示同一设备只回调一次够用若要做信号强度列表可以设 true 拿到多次广播。services 过滤对 HM-10 有效对某些广播里不带服务 UUID 的模块无效遇到搜不到的情况优先去掉这个参数再排查。3.3 打开 notify 接收数据并刷新页面连接建立后不能直接读数据要先取服务列表再取特征值确认 FFE1 支持 notify 属性后打开通知然后通过 onBLECharacteristicValueChange 被动接收。wx 蓝牙 API 都是回调风格我习惯包一层 Promise 让流程线性化// ble.js —— 服务发现与 notify 订阅 const promisify (fn) (opts {}) new Promise((resolve, reject) fn({ ...opts, success: resolve, fail: reject })) const getServices promisify(wx.getBLEDeviceServices) const getChars promisify(wx.getBLEDeviceCharacteristics) const setNotify promisify(wx.notifyBLECharacteristicValueChange) async setupNotify(deviceId) { const { services } await getServices({ deviceId }) const svc services.find(s s.uuid.toUpperCase().indexOf(FFE0) 0) if (!svc) return this.toast(未找到 FFE0 服务) const { characteristics } await getChars({ deviceId, serviceId: svc.uuid }) const ch characteristics.find(c c.uuid.toUpperCase().indexOf(FFE1) 0) if (!ch || !ch.properties.notify) return this.toast(FFE1 不支持 notify) await setNotify({ deviceId, serviceId: svc.uuid, characteristicId: ch.uuid, state: true }) wx.onBLECharacteristicValueChange(res { this.parseFrame(new Uint8Array(res.value)) }) }注意三件事UUID 大小写在不同手机上不一致统一 toUpperCase 再查找安卓返回小写无横线iOS 返回大写带横线用 indexOf 而不是全等比较最稳onBLECharacteristicValueChange 要在 notify 成功后注册注册早了会漏掉前几帧。拿到字节数组后就是解析加 setData 更新。UI 我放三块顶部当前浓度卡片中间温湿度两个大数字底部一条历史折线。如果你习惯用 HBuilderX 开发微信小程序这套 wx 蓝牙 API 在 uni-app 里也有对应封装uni.openBluetoothAdapter 等回调参数结构基本一致页面代码可以直接平移。4. 空气数据协议帧设计STM32 组帧与微信小程序解帧两端代码都写完连接也建立了剩下的问题是「怎么保证数据不错」。BLE 透传本身不保证帧边界STM32 连续发两帧小程序一次回调可能收到半帧也可能两帧粘在一起。所以协议帧设计才是这套系统真正的核心。4.1 简易帧协议起始符、长度、类型与 CRC8自定义帧最少要有四要素起始符防误判、长度字段处理粘包、类型字段支撑扩展、校验字段丢弃污染帧。具体格式偏移字段长度说明00xAA1帧头 110x551帧头 2双字节降低误判2LEN1整帧字节数固定 0x0F3TYPE10x01实时数据0x02校准命令4~5温度2int16 大端实际值×106~7湿度2int16 大端×108~9TVOC2uint16 大端ppb10~11eCO₂2uint16 大端ppm12~13PM2.52uint16 大端µg/m³14CRC81多项式 0x31覆盖字节 2~13温度用 int16 且扩大 10 倍是为了表达零下温度且不丢小数位湿度同样×10 后按有符号处理。CRC 覆盖从 LEN 到 PM2.5 低字节共 12 个字节用 CRC-8/MAXIM 多项式 0x31两端代码一致串口调试助手也能手工验证。提示BLE 默认 MTU 为 23单次通知最多携带 20 字节。这个 15 字节帧正好卡在安全线内以后加传感器先算总长别让一帧超过 20 字节否则要拆包或协商 MTU。4.2 STM32 组帧代码与小程序解帧代码STM32 侧组帧// frame.c —— 1Hz 组帧并经 USART2 发送 uint8_t crc8(const uint8_t *data, uint8_t len); // 复用 sensor_air.c 的实现 void air_frame_build_and_send(const air_data_t *d) { uint8_t f[15]; int16_t t (int16_t)(d-temp * 10); // 25.6℃ - 256 uint16_t h (uint16_t)(d-humi * 10); f[0] 0xAA; f[1] 0x55; f[2] 0x0F; // 整帧 15 字节 f[3] 0x01; // 实时数据 f[4] t 8; f[5] t 0xFF; f[6] h 8; f[7] h 0xFF; f[8] d-tvoc 8; f[9] d-tvoc 0xFF; f[10] d-eco2 8; f[11] d-eco2 0xFF; f[12] d-pm25 8; f[13] d-pm25 0xFF; f[14] crc8(f[2], 12); // 覆盖 LEN 到 PM2.5 低字节 HAL_UART_Transmit(huart2, f, sizeof(f), 20); }大端序的理由C 端移位直观JS 端(b1 8) | b2直接还原两边都不用做字节序转换。HAL_UART_Transmit 的 timeout 给 20ms1Hz 发 15 字节对蓝牙模块透传毫无压力如果帧长超过 20 字节就需要拆包或协商 MTU。小程序侧解帧// ble.js —— 按帧格式解析并回写页面 crc8(arr) { let crc 0xFF for (const b of arr) { crc ^ b for (let i 0; i 8; i) crc (crc 0x80) ? ((crc 1) ^ 0x31) 0xFF : (crc 1) 0xFF } return crc } parseFrame(bytes) { if (bytes[0] ! 0xAA || bytes[1] ! 0x55) return // 帧头不符丢弃 const len bytes[2] if (bytes.length len) return // 半帧等下一段拼上 if (bytes[3] ! 0x01) return // 非实时数据 if (this.crc8(bytes.slice(2, 14)) ! bytes[14]) { this.dropCount // 统计丢帧定位用 return } let tRaw (bytes[4] 8) | bytes[5] if (tRaw 0x7FFF) tRaw - 0x10000 // int16 负数还原 this.setData({ temp: (tRaw / 10).toFixed(1), humi: ((bytes[6] 8 | bytes[7]) / 10).toFixed(1), tvoc: bytes[8] 8 | bytes[9], eco2: bytes[10] 8 | bytes[11], pm25: bytes[12] 8 | bytes[13], }) }解帧的容错顺序值得注意先验帧头再比长度最后做 CRC。半帧问题靠 LEN 字段挡掉粘包问题靠「解析完当前帧后把剩余字节继续喂给 parseFrame」解决。我建议在页面里放一个隐藏的 dropCount 数值联调时盯着它看。4.3 联调阶段最容易翻车的四个问题连接不上设备。先别怀疑代码用 nRF Connect 这类调试工具扫一下模块广播名确认是 HMSoft 还是别的名字再改小程序里的 BLE_NAME_KEY。也可能是模块停留在 AT 配置模式重新上电退出 AT 再试。notify 打开成功却没有回调。优先查 FFE1 特征值是否支持 notifyHM-10 的 FFE1 支持再看是不是用了开发者工具模拟器——wx 蓝牙 API 在开发者工具里只有部分可用必须真机调试。若真机仍无数据检查 notify 是否在 createBLEConnection 的 success 里立刻调用建议延迟 300ms 再注册。数据乱码。九成是波特率不一致。HM-10 出厂默认 9600如果 STM32CubeMX 里把 USART2 配成 115200收到的就全是花屏。先和模块厂家确认默认波特率再进 CubeMX 改两端对齐后再测。蓝牙透传数据不进网络栈Charles 这类抓包工具看不到只能靠串口助手和 console.log 双端对照。Keil 调试时报「当前不会命中断点」。多半是优化级别太高或下载后没复位运行。把 Options for Target → C/C → Optimization 调到 Level 0全编译重新下载还不行就确认程序是否真的执行到了那一行查看反汇编比干瞪眼快。5. 进阶SGP30 基线校准与源码交付规范5.1 SGP30 基线校准把 12 小时预热压缩到秒级SGP30 的 eCO₂ 是算法算出来的不是直接测出来的算法内置基线会随环境漂移。数据手册的说法是在 400ppm 干净空气中连续运行 12 小时基线自动回归。问题就在这——交付给用户后第一天开机eCO₂ 很可能显示 1100ppm 甚至更高界面上的「超标」红灯直接亮起来体验很差。解决思路是把「干净空气下的基线」存进 STM32 内部 Flash每次上电用 set_baseline 写回。先读基线// sgp30_cal.c —— 读取当前基线并保存到 F103 最后一页 Flash #define BASE_SAVE_ADDR 0x0800FC00 // 64KB Flash 的最后 1KB 页 void sgp30_baseline_save(void) { uint8_t cmd[2] {0x20, 0x0E}; // get_baseline 命令 uint8_t buf[6]; HAL_I2C_Master_Transmit(hi2c1, 0x58 1, cmd, 2, 100); HAL_Delay(20); HAL_I2C_Master_Receive(hi2c1, 0x58 1, buf, 6, 100); uint16_t eco2_base (buf[0] 8) | buf[1]; // 高 4 字节是 eCO₂ 基线 uint16_t tvoc_base (buf[3] 8) | buf[4]; // 低 4 字节是 TVOC 基线 FLASH_Unlock(); FLASH_ErasePage(BASE_SAVE_ADDR); FLASH_ProgramHalfWord(BASE_SAVE_ADDR, eco2_base); FLASH_ProgramHalfWord(BASE_SAVE_ADDR 2, tvoc_base); FLASH_Lock(); }上电初始化时反向操作读这两个 HalfWord非 0xFFFF 说明存过有效基线就发 set_baseline 写回。set_baseline 命令是 0x2015后面跟 eCO₂ 基线 2 字节加 CRC8 1 字节再跟 TVOC 基线 2 字节加 CRC8 1 字节CRC 算法和第 2 章同一个 0x31 多项式。这样重启后几十秒内数值就落到正常区间不用等 12 小时。注意 F103C8T6 页大小是 1KB0x0800FC00 是最后一块用户页别把程序代码顶到这里。5.2 源代码与文档的交付目录规范标题里的「源代码文档说明」如果散成一堆文件接手的人根本没法编译。我习惯按四块组织目录内容对应操作Doc/传感器数据手册 PDF、接线表、协议帧说明先看协议再改代码Firmware/STM32/CubeMX 的 .ioc、MDK 工程、各模块源码Keil 打开直接编译MiniProgram/小程序完整工程app.js 里配 BLE 名称和 UUID开发者工具导入预览Test/串口助手的测试帧样本、CRC 校验脚本复现问题、验收协议帧说明务必在写代码时同步维护一张表格就够。烧录步骤用 TXT 放在根目录写清 ST-Link 四根线、BOOT0 跳线、下载算法选择。文档的价值不在厚而在「另一个工程师不看你的电脑也能复现」。交付前我会做一次 2 小时稳定性跑测串口助手记录 STM32 发出的全部帧小程序前台挂着实时刷新统计两边的 CRC 丢帧计数任何一个超过 5 就回头查蓝牙模块供电电容和透传缓冲区而不是先怪代码。这个数字是这套系统能不能拿去答辩的底线。本文还有配套的精品资源点击获取