从决定做这个项目到把整套资料打包开源,前后花了我两个多月。这套基于STM32F103C8T6的家庭环境监测系统,硬件上就是一块最小系统板加几个常见传感器——DHT11温湿度、MQ-2烟雾检测、0.96寸OLED屏,外加一个蜂鸣器,听起来不复杂,但要把原理图、代码、仿真工程三样东西全部对齐、跑通、整理干净,工作量比想象中大不少。现在项目已经完整开源,代码可以直接编译烧录,原理图按图接线就能复现,Proteus仿真工程也调好了,从零开始也能一步步做出来。这篇文章我把整个设计和踩过的坑拆开讲清楚,适合准备做课设、入门嵌入式或者想给家里做个简单监测装置的读者参考。
1. 项目思路与方案设计
1.1 需求拆解:家庭环境监测到底要监测什么
动手画原理图之前,我先把需求一条条列出来。市面上的家庭环境监测产品很多,但自己用STM32做一套的意义在于完全可控——想加什么传感器加什么传感器,想怎么报警怎么报警,数据怎么处理也能自己决定。这套项目我锁定了三个最实用的监测点:
- 温湿度:家里是否潮湿、空调温度是否合适,DHT11一个传感器就能同时解决。
- 烟雾/可燃气体浓度:厨房燃气泄漏、室内烟味都可以通过MQ-2的模拟电压变化反映出来,这也是家庭安全里最值得关注的一点。
- 设备运行状态指示:用OLED实时显示当前读数,超阈值时蜂鸣器报警,同时通过串口把数据帧发出去,方便后续接ESP8266之类的WiFi模块做远程查看。
需求定下来之后,整个项目的功能边界就清楚了:采集、处理、本地显示、报警、串口输出。共五条线,每条线都对应硬件上的一个模块。这里我刻意没有上复杂的操作系统,而是用“主循环 + 定时器分时调度”的方式管理任务,原因很简单——传感器采集周期都在百毫秒到秒级,单片机主频72MHz,单线程轮询完全够用,上RTOS反而增加理解成本。
1.2 器件选型对比:为什么是F103C8T6加这些传感器
很多新手会问我为什么不用ESP32或者STM32F407,这里我把自己当时的选型思路分享出来。
主控方面,STM32F103C8T6有两个不可替代的优势:一是资料多到溢出,不管是中文手册还是B站视频,遇到问题基本一搜就有答案;二是价格低,拆机片几块钱一片,学习成本几乎可以忽略。ESP32虽然自带WiFi蓝牙,但配置外设的复杂度更高,第一次做项目容易被网络协议栈拖住精力。F407性能强但封装更大、引脚更多,对初学者画PCB并不友好。所以这个项目定位就是“用最经典的芯片把整个流程跑通”,F103C8T6是最合理的选择。
传感器选型我做过一张对比表,现在贴出来给大家参考:
| 功能 | 选型 | 备选 | 选择理由 |
|---|---|---|---|
| 温湿度 | DHT11 | DHT22、SHT30 | DHT11精度够用(±2℃、±5%RH),单总线协议适合学习 |
| 气体检测 | MQ-2 | MQ-135、SGP30 | MQ-2对丙烷/烟雾敏感,输出模拟量,ADC处理简单 |
| 显示 | 0.96寸OLED(I2C) | LCD1602 | OLED显示内容丰富,I2C只占两个IO,功耗更低 |
| 报警 | 有源蜂鸣器 | 无源蜂鸣器 | 有源蜂鸣器高低电平就能驱动,代码省事 |
DHT11和MQ-2这两个器件我用了一个很俗但准确的类比:DHT11像一个只报整数温度的电子温度计,精度不高但稳定;MQ-2像一个打火机气体探测器,它不告诉你具体浓度,但能告诉你“味儿大不大”。理解了这个,你就知道代码里数据处理该怎么做——DHT11读出来直接用,MQ-2的ADC值做阈值判断,不需要精确换算成ppm浓度。
1.3 系统整体架构与数据流向设计
系统架构我用一张逻辑图在心里先画清楚,用文字描述就是这种流向:
传感器(温湿度/气体)→ STM32 GPIO/ADC采集 → 软件滤波与阈值判断 → OLED显示 + 蜂鸣器报警 + UART发送
任务调度上,我设置了一个1ms的软件定时器作为时间基准,然后做标志位:每100ms读一次ADC并做滤波,每2秒读一次DHT11,每500ms刷新一次OLED。为什么DHT11要2秒才读一次?因为DHT11本身的采样周期就是1秒,读太快只会拿到旧数据,而且频繁发起始信号反而容易让数据线卡死。这个“给传感器留喘息时间”的思路,在整个项目里帮我省了很多事。
2. 硬件原理图设计与打板要点
2.1 最小系统电路:晶振电容计算、复位、下载接口
STM32F103C8T6虽然市场上有现成的核心板卖,但自己做项目我还是建议把最小系统画一遍,哪怕最后打板直接贴核心板模块。原因很简单:最小系统是所有STM32项目的底子,晶振、复位、Boot、下载电路这四件事搞懂了,后面换任何型号都不慌。
晶振部分是我这次特意想聊清楚的。外部8MHz晶振要配两个负载电容,很多原理图上直接抄20pF,但实际上电容值跟晶振的负载电容参数有关。计算逻辑是这样的:
CL = (C1 × C2) / (C1 + C2) + Cparasitic
其中CL是晶振手册里标明的负载电容,Cparasitic是引脚和PCB走线的寄生电容,一般取3到5pF。假设常用晶振的CL是18pF,寄生电容取4pF,那么C1和C2相等时:
(C × C) / (2C) + 4 = 18
解得C约等于28pF,实际取值22pF到30pF都可以。我最后选了22pF,实测系统时钟用逻辑分析仪校准,误差在0.1%以内,完全够用。
复位电路用的是最经典的10k上拉电阻配合100nF电容到地,上电瞬间电容充电,NRST引脚保持短暂低电平完成复位。BOOT0直接下拉到GND,让芯片从Flash启动,这样就不会出现下载完程序不运行的情况。
下载接口我强烈建议预留一个4针的SWD排针:SWDIO、SWCLK、3.3V、GND。SWD只需要两根数据线,比J-Link的JTAG节省IO。第一次画板子的人容易漏掉这个口,结果程序烧不进去,我只能说这种坑踩一次就长记性了。
2.2 传感器接口与信号调理电路
DHT11的接线非常考究,并不是直接把数据脚接到单片机上就行。数据脚和STM32之间需要接一个5.1kΩ上拉电阻到3.3V,这是为了让数据线空闲时保持高电平。如果省略上拉电阻,长线传输时DHT11的数据很容易受干扰,误码率会明显上升。另外DHT11供电我单独用3.3V,实测比5V供电稳定,虽然DHT11的说明书说3.3到5.5V都可以,但3.3V供电时输出的高电平正好是3.3V,STM32的GPIO识别起来最干净。
MQ-2这边要啰嗦几句。它的加热丝需要5V供电,所以这部分要从USB的5V取,不能直接挂在3.3V上。传感器有四个引脚,H-H是加热丝,A-B是测量电极,实际接线是把A端接5V,B端通过一个10kΩ负载电阻接地,然后从B端取电压送入STM32的ADC引脚。这就是经典的分压测气体浓度方案。
负载电阻的取值不是随便定的。MQ-2在洁净空气中的内阻大约是几十kΩ,有烟雾时内阻会下降,分压点的电压就会上升。10kΩ这个值能保证在大多数浓度范围内ADC读数有较好的分辨率。如果你想更灵敏,可以在信号输出端加一级运放跟随,比如LM358,但这种场景下没必要,直接ADC读取完全够用。
OLED这边用的是I2C接口,SDA和SCL各接一个4.7kΩ上拉到3.3V。蜂鸣器我选了有源的,用S8050三极管做开关驱动,单片机引脚输出高电平时通过1kΩ限流电阻驱动基极,蜂鸣器导通发声。为什么加三极管?因为蜂鸣器工作电流通常要20到30mA,STM32的GPIO直接驱动虽然也能响,但会拉低引脚电平,长期使用对芯片不友好。三极管在这里就是个小开关,成本几分钱,作用却很重要。
2.3 电源设计与功耗优化经验
供电方案我采用了“USB 5V进,AMS1117-3.3稳压”的方式。AMS1117的压差大约1.1V,5V输入稳定输出3.3V没问题。输入侧接一个10uF电解电容和100nF陶瓷电容,输出侧同样接一个10uF和一个100nF,作用是滤除低频纹波和高频噪声。
画原理图时一个容易忽略的点是传感器分区域供电。DHT11和OLED用3.3V,MQ-2加热丝用5V,两者必须共地。如果不共地,ADC读到的电压就是浮空的,数值会乱跳。我在PCB上把数字地和模拟地分成两片,然后用一颗0欧电阻在一点连接,这样能减少数字信号对模拟采样的干扰。
功耗方面,实测整套系统在正常工作时电流大约是80mA,其中MQ-2的加热丝占了差不多一半。如果后面想升级成电池供电,有两个思路:一是给MQ-2加一个MOS管开关,平时关掉,每两分钟开机加热采样一次;二是STM32进入Stop模式,用RTC闹钟唤醒。这两种方案我都预留了接口,但基础版本先把功能跑通,不折腾复杂的低功耗逻辑。
3. 仿真环境搭建与调试实践
3.1 仿真平台选择:Proteus和Wokwi怎么取舍
仿真这块我纠结了挺久,最后两个平台都用了,结论是各有分工。Proteus 8是目前最主流的单片机仿真软件,库里直接有STM32F103C8T6模型,可以搭建完整的原理图级别仿真,连DHT11、LCD、蜂鸣器这些外设都有对应的仿真模型。它的优势是贴近真实硬件,能够验证原理图是否正确。缺点是DHT11这种单总线传感器在Proteus里时序比较敏感,一旦模型时钟跑得不够快,就可能出现读不到数据的现象。
Wokwi则是个轻量级在线仿真平台,浏览器里就能写代码、搭电路,对STM32的支持也不错。我自己的习惯是:先用Wokwi快速验证代码逻辑,比如DHT11的驱动和OLED显示算法,再回到Proteus把整个电路连起来做系统的综合性验证。如果你只是为了验证某段代码的算法逻辑,Wokwi确实能帮你省下大量搭建工程的时间。
建议顺序是:先在Wokwi里调通所有传感器驱动,再把这个驱动原封不动地搬到Proteus工程里。这样分层验证的好处是,出了问题你能清楚地知道是代码逻辑的锅,还是纯仿真环境造成的问题。
3.2 Proteus下搭建仿真电路的完整步骤
Proteus里的搭建流程我走了一遍,算是把完整的操作步骤记录下来:
第一步,新建工程,从元件库搜索并放置STM32F103C8T6。Proteus在Device下拉框里直接输入型号就能找到,选中后放置到原理图区域。注意它默认带一个时钟源,需要在芯片属性里把晶振频率改为8MHz。
第二步,放置DHT11。Proteus的传感器库里能搜到DHT11模型,它的引脚有三个,VCC、DATA、GND,注意把DATA脚接上5.1kΩ上拉电阻到VCC,并连接到STM32的一个GPIO,比如PA0。
第三步,放置一个虚拟终端(Virtual Terminal)连接STM32的USART1收发脚PA9和PA10,波特率设成115200。虚拟终端在仿真的作用就相当于电脑上的串口助手,程序里通过串口打印的所有调试信息都能从这里看到。
第四步,放置一个LM016L液晶代替OLED。Proteus自带的OLED模型很少,我用LCD1602代替。虽然显示方式不同,但验证“显示的数值是否正确”这个核心逻辑完全够用。
第五步,放置电位器和按钮配合ADC调试。电位器可以模拟MQ-2的模拟电压变化,这是我在仿真阶段发现的一个特别好用的调试手法,转动电位器旋钮就能让ADC读数连续变化,不用真的去改传感器参数。
3.3 仿真联调与故障注入技巧
仿真调试里我最常用的工具是Proteus的变量监视窗口和波形窗口。打开Debug菜单下的Variables窗口,可以实时查看程序中全局变量的值。比如我设了一个adc_value变量存储MQ-2的读数,仿真运行时转动电位器,就能看到这个变量在0到4095之间变化,比用串口打印变量还要直观。
故障注入是仿真最大的价值所在。我可以通过直接修改仿真模型参数来模拟传感器的异常状态,比如把温度设定值调到50度,观察蜂鸣器是否按预期触发。这种“人为制造故障”的调试方式在真实硬件上做起来很麻烦,在仿真里改一个参数就行,所以在仿真阶段把报警阈值逻辑调得越透,实际打板后的返工率就越低。
仿真时一个非常常见的坑是LCD1602乱码或者完全不显示。排查方向一般是两个:一个是检查程序里是否正确配置了GPIO的输出模式,另一个是检查里时钟树配置。Proteus的STM32模型对时钟频率比较敏感,如果代码里配置的系统时钟跟仿真模型里的晶振频率不一致,外设的时序就会乱。我遇到过一次串口乱码,排查了半天发现是仿真模型里写的是8MHz,实际上我配置成了HSE直通没有锁相环倍频,这个大家要注意。
4. 代码工程结构解析
4.1 工程目录与模块划分
代码工程我是基于STM32标准外设库写的,目录结构如下:
STM32_EnvMonitor/ ├── Core/ │ ├── main.c │ └── stm32f10x_it.c ├── Hardware/ │ ├── dht11.c / dht11.h │ ├── mq2.c / mq2.h │ ├── oled.c / oled.h │ └── buzzer.c / buzzer.h ├── App/ │ ├── monitor.c / monitor.h │ └── data_filter.c / data_filter.h ├── User/ │ └── delay.c / delay.h └── Project/ ├── Keil工程文件 └── Proteus仿真工程我把驱动层和应用层分开,驱动层只负责“把传感器的寄存器/时序读出来”,应用层负责“这些数据怎么处理”。这样有个很直接的好处:以后从DHT11升级到DHT22,只需要改Hardware里的驱动,App层的报警和显示逻辑完全不用动。这也是我建议所有嵌入式初学者养成的习惯——写代码先想好哪里是“会变的”,把它隔离出去。
main.c里的主循环比较简洁:
int main(void) { SystemClock_Config(); Delay_Init(); USART1_Init(115200); OLED_Init(); Buzzer_Init(); MQ2_ADC_Init(); DHT11_GPIO_Init(); OLED_Clear(); OLED_ShowString(0, 0, "Env Monitor v1.0"); while(1) { if (timer_100ms_flag) { timer_100ms_flag = 0; mq2_value = MQ2_Read_Average(10); } if (timer_2s_flag) { timer_2s_flag = 0; dht11_ok = DHT11_ReadData(&humidity, &temperature); } if (timer_500ms_flag) { timer_500ms_flag = 0; Monitor_Update(); OLED_Update(); } } }主循环里做的事情很少,就是检查标志位、执行对应任务。有人可能觉得这种写法太简单,但对于这种采样周期远大于指令周期的场景,它就是最可靠、最容易调试的架构。你不需要去琢磨任务之间的优先级关系,只要保证每个任务在自己的时间片内执行完就行。
4.2 DHT11单总线时序驱动解析
DHT11的驱动是整个项目代码里技术含量最高的部分,因为它是单总线协议,读一个字节的每一位都要严格控制时序。完整读取过程分三步:主机发起起始信号、DHT11响应、主机读取40位数据。
起始信号是这样的:主机把数据线拉低至少18ms,然后释放。DHT11检测到这个低电平后,会先拉低80us再拉高80us作为响应信号,然后开始连续发送40位数据。每位的时序是:先拉低50us,再拉高,高电平持续26到28us表示“0”,持续70us表示“1”。代码里判断这个脉宽用的是延时采样法——在拉低结束后延时30us再去读引脚电平,如果读到高就是“1”,读到低就是“0”。
核心代码:
uint8_t DHT11_ReadData(uint8_t *humidity, uint8_t *temperature) { uint8_t data[5] = {0}; uint8_t i, j; DHT11_IO_OUT(); DHT11_DQ_LOW(); Delay_Ms(20); DHT11_DQ_HIGH(); Delay_Us(30); DHT11_IO_IN(); if (DHT11_DQ_READ() == 0) { while (DHT11_DQ_READ() == 0); while (DHT11_DQ_READ() == 1); for (i = 0; i < 5; i++) { for (j = 0; j < 8; j++) { while (DHT11_DQ_READ() == 0); Delay_Us(30); if (DHT11_DQ_READ() == 1) { data[i] = (data[i] << 1) | 1; } else { data[i] = (data[i] << 1); } while (DHT11_DQ_READ() == 1); } } if ((uint8_t)(data[0] + data[1] + data[2] + data[3]) == data[4]) { *humidity = data[0]; *temperature = data[2]; return 1; } } return 0; }这段代码有几个关键点需要说明。首先,延时函数必须用定时器或者系统时钟做校准,不能用空循环简单模拟,否则换个编译优化等级就不准了。我用的Delay_Us是基于SysTick实现,精度可以到1us量级。其次,每次操作数据线之前要切换GPIO方向,这是单总线协议最容易出问题的地方,一旦忘了切方向,读到的一直是同一个值。最后就是校验位,DHT11发完4字节数据后会再发一个校验字节,等于前四个字节的累加和,这个机制能在一定程度上过滤掉时序错误导致的脏数据。
4.3 数据处理、滤波与报警消抖
MQ-2的ADC原始值直接拿来做判断会有抖动,特别是刚上电的几分钟里,传感器预热过程中读数会缓慢漂移。我加了一个简单的滑动平均滤波,连续采样10次去掉最大值最小值再求平均:
uint16_t MQ2_Read_Average(uint8_t times) { uint16_t buf[10]; uint8_t i, min_idx = 0, max_idx = 0; uint32_t sum = 0; for (i = 0; i < times; i++) { buf[i] = ADC_Read(); if (buf[i] < buf[min_idx]) min_idx = i; if (buf[i] > buf[max_idx]) max_idx = i; } for (i = 0; i < times; i++) { if (i == min_idx || i == max_idx) continue; sum += buf[i]; } return (uint16_t)(sum / (times - 2)); }滤波后再做阈值判断,同时加了“连续越限计数”防抖逻辑。简单说就是被测数据需要连续3次超过阈值,系统才会报警。这个设计是为了避免偶然的脉冲干扰触发蜂鸣器,比如炒菜时一瞬间的油烟浓度升高,连续采样3次都超标才说明真的有持续泄漏。报警触发后,我让蜂鸣器以1Hz频率间歇鸣叫,同时OLED上显示“ALARM”字样,直到数据恢复到阈值以下并且持续5秒才消警。
4.4 OLED显示与按键交互
OLED驱动用的是经典的SSD1306 I2C协议,网上现成代码一大堆,我主要改了显示布局。屏幕分辨率是128x64,分成四行:第一行显示温度和湿度,第二行显示烟雾ADC值,第三行显示当前时间戳,第四行显示报警状态。因为I2C传输整屏数据需要一点时间,我做了局部刷新,只在数值变化时才更新对应区域,这样能减轻总线负担。
按键交互做的是最简单的红外感应模式,其实我只加了两个按钮,一个切换显示页面,一个静音报警。按键消抖用延时20ms的软件消抖,虽然简单但很实用。这里有个细节:按键检测放在主循环里,但静音标志位被中断函数引用时要注意加volatile修饰,不然编译器优化后可能出现“改了半天没反应”的情况。
5. 常见问题与排查技巧实录
5.1 问题速查表
把项目过程中遇到的所有印象深刻的问题整理成一张速查表,方便大家直接对照排查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 下载时提示no stm32 target found | ST-Link驱动未装或接线错误 | 检查SWD四线;按住复位键点击下载再松开 |
| DHT11一直读到0或数据不变化 | 起始信号时间不足;上拉电阻缺失 | 将拉低时间提到20ms;确认5.1k上拉到3.3V |
| OLED黑屏 | I2C地址不对或SCL/SDA接反 | 检查0.96寸屏的I2C地址是0x3C还是0x3D |
| MQ-2读数满量程4095 | ADC输入悬空或负载电阻开路 | 测量分压点对地电压,确认10k负载焊好 |
| 蜂鸣器声音沙哑 | 有源蜂鸣器当成无源驱动 | 有源蜂鸣器用直流电平驱动,无源需要PWM方波 |
| Proteus仿真LCD乱码 | 晶振频率与代码配置不一致 | 把仿真模型晶振设为8MHz,检查锁相环配置 |
5.2 印象最深的三个调试经历
第一个是DHT11数据线卡死的问题。前几版代码里,如果DHT11没有响应,数据线可能会被传感器长期拉低,程序会卡在while循环里出不来。解决方法是给等待循环加超时计数,超过一定时间直接返回读取失败,而不是无限等下去。这个经验后来我用到了所有带while等待的代码中,算是吃一堑长一智。
第二个是ST-Link下载失败的问题。那段时间换了台电脑,重新装完Keil和驱动后怎么都连不上芯片,报的就是no stm32 target found。折腾了很久发现是Debug设置里没有勾选Reset and Run,导致程序下载后没有自动运行,看起来就像“连不上”。后来我养成了一个习惯,新环境第一件事就是把Debug选项卡下的几项配置截图存起来,省得每次重装都重新踩一遍。
第三个是Proteus仿真卡顿。DHT11模型在Proteus里跑起来非常吃资源,因为它的VSM模型要按时序模拟电平变化。解决办法是把仿真运行速度调低一点,同时在代码里增加延时采样点的精度,让每个位的解析更稳定。仿真环境的怪问题往往不是代码错,而是模型限制,这时候要分清主次,别在仿真里死磕这种跟硬件无关的东西。
5.3 实操补充:代码和资料怎么高效复用
既然项目是开源的,最后说点资料使用的小建议。整套项目包括三样东西:原理图源文件、Keil工程代码、Proteus仿真工程。我的建议是复现时不要一上来就打开完整工程,而是按照“仿真验证→硬件接线→烧录调试”的顺序走。先跑Proteus仿真把系统逻辑理清楚,再根据原理图接真实硬件,这样即使接线出错,你也能快速判断是硬件问题还是代码逻辑问题。
硬件接线方面,我强烈建议先用面包板搭一套快速原型。STM32最小系统板、DHT11模块、OLED模块、蜂鸣器模块都有现成的,用杜邦线连接基本半小时就能搭好。芯片先不要焊在洞洞板上,等所有功能都验证OK了再画PCB,这个习惯能省掉很多重复焊接的时间。如果你打算自己做PCB,我开源包里给的原理图可以直接导入立创EDA,稍微整理一下封装就能转PCB。
代码复用方面,我的建议是驱动层和应用层拆开存成独立文件夹。比如DHT11的驱动、OLED的驱动,这些是“几年都不会大变”的代码,可以直接存进自己积累的代码仓库。下次做别的项目要显示温湿度,直接把这两个文件拖进去,改一下引脚宏定义就能用。长期下来这个“个人代码库”给你节省的时间,比你现在学的任何框架都实惠。
做到这,一套完整的STM32家庭环境监测系统就算从原理图到实物全部落地了。回看整个项目,我最有感触的一点是“开源”不等于“把代码扔出去”,真正麻烦的是把原理图、仿真环境、代码结构、踩坑记录全部对齐,让拿到资料的人不需要再来问“这个引脚接哪”。我在整理资料的过程中也重新梳理了一遍自己写过的逻辑,删掉了很多冗余代码,这本身就是一种提升。如果你在复现过程中卡在哪一步,建议先对照问题速查表排查,还是解决不了的话,打开仿真工程对比一下参数设置,多半是时钟配置或者上拉电阻这种细节没对齐。嵌入式这东西,只要硬件没焊错、时序没乱,系统就一定会按你想的方式跑起来。