做嵌入式这些年,我陆陆续续接触过不少入门项目,但说句实话,大部分教程要么只讲代码不讲硬件,要么给了原理图却没有仿真实例,学生和刚转行的朋友学起来特别容易卡在半路。这个 STM32 环境质量监测系统,算是我见过的比较“完整”的开源项目之一——主控用 STM32,采集温湿度和空气质量数据,LCD 显示,超阈值报警,还配套了原理图、源码和 Proteus 仿真工程。也就是说,你拿到手之后,可以先在电脑上把整个系统跑通,再照着原理图去焊实物板,从软件到硬件一条线打通,非常适合拿来练手或者做课设参考。这篇文章我就把这个项目的设计思路、硬件细节、代码逻辑和仿真调试过程完整拆一遍,尽量把每个关键点都讲透。
1. 项目整体设计与方案选型解析
1.1 为什么选择 STM32 作为主控
很多朋友第一次看到这类项目会问:测个温湿度、看个空气质量,用 51 单片机不就行了,干嘛非得用 STM32?
这个问题的答案其实取决于你想学什么。如果只是跑通一个 demo,51 确实够用,但代价是你基本学不到现代嵌入式开发的常规套路。STM32 的 Cortex-M 内核、标准外设库 / HAL 库、中断系统、ADC 多通道采集、定时器 PWM 输出,这些都是目前工业界和消费电子领域最常用的技能点。环境质量监测系统看起来是个“小项目”,但它涉及到的外设资源其实不少:GPIO 模拟时序、ADC 采样、串口打印、LCD 显示控制,再加上蜂鸣器报警和按键交互,正好能把 STM32 的常用外设都过一遍。
选型上比较推荐 STM32F103C8T6,也就是大家常说的“蓝丸”核心板。它便宜、资料多、Proteus 里也有对应的仿真模型,无论是做实物还是搭仿真都很方便。Flash 64KB、RAM 20KB 对于这个项目来说完全够用,而且主频 72MHz 跑 DHT11 这种低速传感器,时序余量非常充足。
1.2 传感器选型:DHT11 与 MQ 系列的组合逻辑
环境质量监测听起来是个很大的概念,但具体到嵌入式这种小系统上,核心就两件事:温湿度和空气污染程度。
温湿度这部分,DHT11 是绕不开的入门选择。它的优势是便宜、单总线协议、代码实现简单,一个 GPIO 引脚就能读完温湿度数据。虽然精度一般(湿度 ±5%RH、温度 ±2℃),但对于环境监测这种场景已经足够。如果你手头预算充足,也可以换成 DHT22 / AM2302,代码底层时序基本兼容,只需要改一下数据换算的公式。
空气质量部分,常见方案有两种:一种是 MQ 系列模拟输出传感器,比如 MQ-2(可燃气体/烟雾)、MQ-135(空气质量/VOC),它们输出的是模拟电压信号,直接接到 STM32 的 ADC 引脚就能读取;另一种是所谓的“数字模块”,板载了 LM393 比较器,输出的是开关量,只能判断“超标/不超标”,读不到具体浓度趋势。从项目完整度考虑,我更推荐用模拟输出的 MQ 传感器,这样你能在屏幕上看到 ADC 值的变化曲线,而不是只看到“正常/报警”两个状态。
1.3 功能拆分与整体数据链路
整个系统的数据流可以分成四段:传感器采集 → 主控处理 → 显示/报警 → 用户交互。
传感器采集端,DHT11 通过单总线协议把 40bit 数据发给 STM32,MQ 传感器把气体浓度转换成电压信号送进 STM32 的 ADC。主控拿到这些原始数据之后,先做滤波和换算,把 ADC 原始值映射成可读的浓度百分比,同时跟预设的报警阈值做比较。显示端,我用的是 LCD1602(带 IIC 转接板),一来 Proteus 里有现成模型,二来 IIC 只需要两根线,能省下不少 GPIO。报警端是一个无源蜂鸣器,超过阈值就通过 PWM 输出不同频率的提示音。用户交互则保留了一颗按键,用来切换显示页面或手动消音。
这套链路覆盖了嵌入式系统中最典型的“输入-处理-输出-交互”闭环,而且是完全模块化的——每一部分拆出来都能单独写一篇笔记,合在一起又是一个五脏俱全的小系统。
2. 硬件电路设计:原理图核心模块拆解
2.1 最小系统与电源树设计
既然是开源项目,原理图部分就必须经得起推敲。STM32F103C8T6 的最小系统并不复杂:一颗 8MHz 晶振作为 HSE 时钟源,两个 20pF 负载电容,一个 10K 上拉电阻接 NRST 复位引脚,再加上 BOOT0/BOOT1 引脚的启动模式配置,基本就是全部内容了。很多新手容易忽略的是 VDDA/VSSA 引脚,它给 ADC 提供模拟参考电压,必须单独接一个 1uF 和 0.1uF 的去耦电容,否则 ADC 采出来的数据会跳动得非常厉害。
电源部分,我建议采用 USB 5V 供电,然后通过 AMS1117-3.3 线性稳压降到 3.3V。为什么不用开关电源?因为这个系统整体功耗很低,线性稳压的纹波更小,对 ADC 采样更友好。AMS1117 输入输出各加一个 10uF 钽电容和 0.1uF 陶瓷电容做滤波,能在负载变化时稳住电压。注意 MQ 传感器的加热丝需要 5V 供电,所以整个系统其实是两路电源轨:5V 给传感器加热和蜂鸣器驱动,3.3V 给 MCU 和 LCD。两路电源之间不需要隔离,但 5V 转 3.3V 的转换点一定要放在 MQ 传感器之后,避免加热丝的大电流波动直接干扰 MCU。
2.2 DHT11 与 MQ 传感器的接口电路
DHT11 是单总线器件,数据引脚需要外接一个 4.7K~10K 的上拉电阻到 VCC。有些便宜的 DHT11 模块板上已经集成了上拉电阻和滤波电容,直接用就行,但如果你是自己买裸传感器焊,这个电阻千万别省。DHT11 的上拉电阻不是随便选的:阻值太小,总线拉低时的灌电流会偏大;阻值太大,上升沿会变缓,在高温高湿环境下容易造成时序读取失败。4.7K 是一个在功耗和时序之间折中的值。
MQ 系列传感器模块上电之后有个特点——加热丝需要至少 30~60 秒预热,输出电压才会逐渐稳定。所以原理图上我建议在 MQ 的模拟输出端和 STM32 的 PA 引脚之间串一个 1K 电阻,再并联一个 0.1uF 电容到地,构成一个简单的 RC 低通滤波器。这能滤掉传感器输出上的高频噪声,让 ADC 采样值更平滑。但要注意,RC 滤波会引入少量延迟,对于空气质量监测这种慢变信号来说毫无影响,但如果以后你要做快速响应类项目,这个滤波就不能随意加了。
2.3 显示、报警与按键电路设计
LCD1602 IIC 模块的四根线(VCC、GND、SDA、SCL)直接接到 STM32 的 PB6/PB7 即可,IIC 上拉电阻一般模块上已经带了。如果没有,需要在总线上各加一个 4.7K 上拉电阻。很多人问 IIC 和 SPI 的 LCD 该选哪个,我的建议是 IIC,原因只有一个——省引脚。STM32F103C8T6 一共才 37 个可用 GPIO,省下来的引脚可以留给串口调试、按键和扩展功能。
蜂鸣器电路是比较容易翻车的地方。有源蜂鸣器内部有振荡源,给高电平就响,但声音频率固定;无源蜂鸣器需要外部提供 PWM 方波才能发出声音,好处是可以调节音调。这个项目我用的是无源蜂鸣器,通过三极管 S8050 驱动。GPIO 输出高电平到三极管基极,三极管导通,蜂鸣器通电发声。基极串一个 1K 限流电阻,蜂鸣器两端反并联一个 1N4148 续流二极管——蜂鸣器本质上是感性负载,断电瞬间会产生反向电动势,没有这个二极管容易击穿三极管。
按键电路更简单,一个 10K 下拉电阻加一个轻触开关,单片机引脚读到高电平表示按下。这里有个细节:按键要不要加硬件消抖?如果只做一个按键,软件里用 20ms 延时消抖就够了,不需要额外加 RC 电路,省事且可靠。
3. 软件架构与核心代码实现
3.1 分层结构与代码目录组织
如果这只是一篇“抄代码就能跑”的教程,那我确实不用花篇幅讲架构。但这个项目既然定位为开源项目,代码结构就得对得起“开源”这两个字——别人拿到手应该在五分钟内知道每个文件是干什么的,而不是在一坨 main.c 里翻几百行。
我采用的目录结构比较常规:
Core/ Inc/ main.h gpio.h tim.h adc.h i2c.h Src/ main.c gpio.c tim.c adc.c i2c.c Drivers/ BSP/ BSP_DHT11.c/h BSP_MQ135.c/h BSP_Buzzer.c/h BSP_Key.c/h BSP_LCD1602_I2C.c/h Middlewares/ lcd1602_i2c_lib.c/h delay.c/h用标准库还是 HAL 库?我选择了标准库,原因有两个:第一,Proteus 仿真加载标准库编译出的 hex 文件兼容性更好;第二,DHT11 这种对时序敏感的外设,标准库的寄存器操作更直接,能让你更清楚地看到时钟翻转的过程。当然,如果你已经在用 CubeMX + HAL 库,这个逻辑一样成立,核心改动只在外设初始化部分。
3.2 DHT11 驱动时序与代码实现
DHT11 的通信协议是单总线,但它的时序和 DS18B20 并完全不一样。完整的读取流程分四步:主机拉低总线 ≥18ms 发起起始信号 → 释放总线等待 DHT11 响应 → DHT11 拉低 80us 响应信号 → 连续输出 40bit 数据,高位在前。
数据位的高电平持续时间决定了它是 0 还是 1:26~28us 的窄脉冲是 0,70us 左右的宽脉冲是 1。所以代码的核心就是循环读取这 40 个 bit,每次都测量高电平持续的时间,超过某个阈值就判定为 1。
我基于 HAL 库写的核心代码如下:
uint8_t DHT11_ReadData(uint8_t *temperature, uint8_t *humidity) { uint8_t data[5] = {0}; // 拉低总线,发起起始信号 DHT11_DATA_GPIO_PORT->BSRR = DHT11_DATA_PIN; HAL_Delay(1); DHT11_DATA_GPIO_PORT->BRR = DHT11_DATA_PIN; // 释放总线,切换为输入模式 GPIO_InitTypeDef gpioInit = {0}; gpioInit.Pin = DHT11_DATA_PIN; gpioInit.Mode = GPIO_MODE_INPUT; gpioInit.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_DATA_GPIO_PORT, &gpioInit); // 等待响应信号(低电平) uint32_t timeout = 10000; while (HAL_GPIO_ReadPin(DHT11_DATA_GPIO_PORT, DHT11_DATA_PIN) == GPIO_PIN_SET) { if (--timeout == 0) return 1; } // 等待响应结束(高电平) timeout = 10000; while (HAL_GPIO_ReadPin(DHT11_DATA_GPIO_PORT, DHT11_DATA_PIN) == GPIO_PIN_RESET) { if (--timeout == 0) return 1; } // 读取 40bit 数据 for (int i = 0; i < 40; i++) { while (HAL_GPIO_ReadPin(DHT11_DATA_GPIO_PORT, DHT11_DATA_PIN) == GPIO_PIN_RESET); uint32_t t_high = 0; while (HAL_GPIO_ReadPin(DHT11_DATA_GPIO_PORT, DHT11_DATA_PIN) == GPIO_PIN_SET) { t_high++; } if (t_high > 30) { data[i / 8] |= (0x80 >> (i % 8)); } } // 校验 if ((data[0] + data[1] + data[2] + data[3]) == data[4]) { *humidity = data[0]; *temperature = data[2]; return 0; } return 1; }这里有一个我踩过很多次的坑:DHT11_ReadData 这个函数里涉及到的延时和循环变量,依赖 CPU 主频和编译优化等级。如果你换了一块主频不同的板子,t_high 的阈值参数需要重新标定。这也是为什么推荐在 Proteus 里先用默认 72MHz 跑,确认逻辑通了再上实物。
3.3 MQ 传感器 ADC 采集与浓度换算
MQ 传感器的模拟输出引脚出来的电压,和气体浓度之间并不是完全线性的,它更像是一个指数关系——浓度越高,输出电压越低(或者越高,取决于模块上的负载电阻接法)。所以代码里我做了两个处理:一是多次采样求平均,滤波掉传感器固有的噪声;二是把电压值映射到一个 0~100 的“污染指数”上,方便 LCD 显示。
ADC 部分的初始化用 CubeMX 生成就没问题,配置 PA0 为 ADC1_IN0,采样周期拉到最长(239.5 cycles),这样采样电容充电更充分,数据更稳。连续采样 10 次取平均值:
#define MQ_SAMPLES 10 uint16_t MQ_GetAverage(void) { uint32_t sum = 0; for (uint8_t i = 0; i < MQ_SAMPLES; i++) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 100); sum += HAL_ADC_GetValue(&hadc1); HAL_Delay(10); } return sum / MQ_SAMPLES; }然后换算成电压,再映射成污染指数:
float voltage = (float)adc_value * 3.3f / 4096.0f; uint8_t air_quality = (uint8_t)(voltage / 3.3f * 100.0f);严格来讲,要获得真实的 PPM 浓度,需要查 MQ 系列的数据手册里那张灵敏度特性曲线,然后做对数拟合。但在这个项目里,我们只需要一个相对值来判断空气质量,所以线性映射足够了。如果后续要扩展,可以把拟合公式写进一个MQ_Calibration.c里。
3.4 主循环与系统状态切换
主循环我采用了“轮询式调度”:每个循环周期内依次读取传感器、刷新显示、检测按键、判断报警。这个项目没有复杂的状态机,用超级循环就够了,不需要上 RTOS。
报警逻辑需要一点小设计。如果只是简单地在污染指数超过阈值时响蜂鸣器,那系统会非常吵。我给蜂鸣器加了一个“间歇报警”的逻辑:超标时 PWM 持续输出 1 秒,然后停 1 秒,循环;同时用按键可以临时消音 5 分钟。这种体验在实物演示时非常有用,也是从“能跑”到“好用”的一个小改进。
LCD 显示方面,我用两行来布局:第一行显示温度和湿度,第二行显示空气质量指数。配合按键切换还能显示 ADC 原始值和报警阈值,方便调试时观察数据变化趋势。
4. Proteus 仿真环境搭建与联调步骤
4.1 仿真工程的创建与原件的选用
在 Proteus 里搭这个系统,不需要像画实物原理图那样讲究封装和 3D 模型,但元件选型必须准确。我用的版本是 Proteus 8 Professional,元件清单如下:
| 元件 | Proteus 搜索关键字 | 参数/型号 |
|---|---|---|
| 主控 | STM32F103C8 | 蓝丸芯片,选 DIP 封装 |
| 温湿度传感器 | DHT11 | 自带模型 |
| 气体传感器 | MQ-2 / MQ-135 | 选带模拟输出的型号 |
| LCD 显示器 | LM044L / LCD 1602 | 带 IIC 的模型 |
| 电位器 | POT-HG | 用于模拟传感器电压变化 |
| 晶振 | CRYSTAL | 8MHz |
| 电容 | CAP | 20pF / 0.1uF / 10uF |
| 电阻 | RES | 按原理图取值 |
| 蜂鸣器 | BUZZER | 无源,选 ACTIVE 类型 |
| 按键 | BUTTON | 轻触开关 |
元件放置好后,关键是连线。STM32F103C8 在 Proteus 里的引脚名称和实物一致,PA0 对应引脚名 PA0,PB6/PB7 对应 IIC 接口。建议先在原理图上把所有引脚标号写清楚,再在 Proteus 里对照连线,能省下大量查错时间。
4.2 固件编译与烧录流程
用 Keil5 编译工程之前,需要确认 Target 选项里 Device 是 STM32F103C8,并且在 Utilities 设置里勾选“Use Debug Driver”或直接输出 hex 文件。输出路径默认在工程目录的 Objects 文件夹下。
编译通过后,双击 Proteus 里的 STM32F103C8 元件,在 Program File 一栏选择生成的 hex 文件,然后点击左下角的运行按钮。如果代码逻辑没有低级错误,系统会立刻开始工作,LCD 上会显示温湿度。
这里要特别提醒:Proteus 里的 DHT11 模型和实物 DHT11 在时序上不完全一致。仿真的响应时间非常快,几乎瞬间就返回 40bit 数据,实物的响应则需要几百毫秒。所以从仿真移植到实物时,主循环里读取 DHT11 的间隔建议设置在 1 秒以上,否则会连续读到同一个数据甚至读取失败。
4.3 仿真调试时的特殊技巧
仿真调试有一个实物没有的优势——你可以直接操作外部元件来观察系统反应。比如用电位器代替 MQ 传感器的模拟输出,旋转电位器就能看到 ADC 值变化,继而观察报警逻辑是否触发。这种方式调逻辑比烧录实物快得多。
另一个技巧是给 ADC 采样加上观察窗口。Keil 的 Debug 模式配合 Proteus 的 VSM 仿真,可以直接在代码里设置断点,然后打开 Peripherals 里的 ADC 窗口,实时查看各个通道的转换值。这比串口打印更直观,尤其适合排查 ADC 初始化配置错误导致一直读 0 的问题。
Proteus 仿真时晶振参数不用太精确,8MHz 或 72MHz 都能跑,因为仿真器使用的是理想时钟模型。但如果你在代码里用了 HAL_Delay 做时序(比如 DHT11 的 18us 起始信号),务必保证仿真主频设置和 Keil 工程里 HSE_VALUE 一致,否则延时比例会偏。
5. 常见问题与排查技巧实录
5.1 现象、原因与解决对照表
这部分内容不是网上抄来的,是我在这个项目调试过程中真实遇到的问题整理。每一条背后都对应着一次从“一脸懵”到“盯了半小时电路图”的经历。
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| LCD 白屏无字符 | IIC 地址错误 | LCD1602 IIC 模块地址一般是 0x27,但也有 0x3F 的,扫描一遍确认 |
| DHT11 读取失败,显示 0% / 0℃ | 起始信号时长不足 | 把 DHT11_ReadData 里的延时从 1ms 改成 18ms,再试 |
| ADC 值跳动巨大 | 缺少滤波或采样周期过短 | 改采样周期为最慢,连续采样 10 次取平均 |
| 蜂鸣器不响,但有电流 | 三极管基极限流电阻太大 | 基极电阻 1K 比较稳,10K 可能驱动不足 |
| 仿真运行但按下按键无反应 | 按键引脚上下拉不对 | Proteus 里按键默认接 VCC,需要下拉电阻,或代码里改成内部下拉 |
| 空气质量指数始终 100 或 0 | 传感器电压换算方向反了 | MQ 模块输出电压随浓度升高而降低,换算公式需要取反 |
| 程序烧录后系统跑飞 | 主频配置和晶振不匹配 | 检查 HSE_VALUE,Proteus 8MHz 对应源码里要设置为 8000000 |
5.2 几个容易被忽视的坑
第一个大坑是 DHT11 的上拉电阻。很多便宜模块上自带上拉电阻,但如果你自己焊最小系统板,上拉电阻忘接的话,DHT11 的数据引脚会一直处于浮空状态,读取结果时好时坏。排查办法也简单,用万用表量数据线对地电压,正常空闲状态下应该是 3.3V 左右,如果接近 0 或者抖动,大概率就是上拉没接。
第二个坑是 MQ 传感器的预热时间。实物上电后前 30 秒读数会是满偏的,看起来像“空气质量极差”,实际上只是加热丝还没热稳定。代码里加一个开机 30 秒倒计时提示,让系统在预热完成之后才开始报警判断,这个细节能避免很多误报警。
第三个坑是 Proteus 仿真和实物的 LCD IIC 时序差异。Proteus 里的 PCF8574 模型对 IIC 时序要求非常严格,如果你用了软件模拟 IIC,延时差一点点就可能显示乱码。解决办法是把这个项目里 IIC 时钟的延时值调大一点,优先保证仿真能过;到实物上时再根据实测微调,通常实物对时序的容忍度反而比仿真更高。
5.3 从仿真到实物移植的三点建议
虽然“仿真通过”能证明系统逻辑基本正确,但从 Proteus 搬到洞洞板或 PCB 上依然会遇到新问题。我的经验是,从仿真到实物要经历三个层面的变化:时序层面、电气层面和干扰层面。
时序层面刚才已经提到了,DHT11 和高精度延时相关的参数都要重新标定。电气层面,仿真里所有器件都是理想模型,不存在压降和电流限制,但实物上蜂鸣器和 LCD 的背光会拉低电源电压,如果 AMS1117 的输入输出电压差不足,MCU 会频繁复位。干扰层面最明显的就是 ADC 读数,实物板走线不合理时,电机或继电器动作瞬间,ADC 值会瞬间跳到最大,这时候就需要在硬件上加强滤波,或者在软件里加一个限幅滤波(一阶惯性滤波)来抑制突变。
我个人在实际焊板子的时候,最深刻的一个体会是:不要一次把所有外设都焊上去。先焊最小系统 + 串口,通过串口打印确认 MCU 和时钟跑起来了;再焊 LCD,确认显示正常;最后才上 DHT11 和 MQ 传感器。这样每一层都有明确的验证标准,出了问题也能快速定位到具体模块,而不是对着完整板子发愁。这种“分步点亮”的调试思路,比任何一条调试技巧都管用。
另外再分享一个小技巧:针对 MQ 传感器的校准,环境监测项目在首次上电时,可以在“干净环境”下记录一个 ADC 基准值,然后把这个基准值存储到 STM32 的内部 Flash 备用区域。后面再判断空气质量时,用实时值和基准值做差值,而不是直接用绝对电压算百分比。这样即使换了传感器模块,或者环境湿度发生变化,系统的报警阈值也不会漂移得太离谱。这个小改动虽然只花十分钟,但能让项目的实用性和稳定性提升一个档次。