我把整套工程开源了:硬件原理图(含立创EDA工程)、STM32的Keil工程源码、Proteus仿真文件,全部打包放出。这篇文章会把设计思路、代码结构、仿真搭建和实物调试的坑一次性讲透,让你拿到文件后能直接复现,而不是对着原理图干瞪眼。
先说清楚这套系统做什么:基于STM32F103C8T6最小系统板,外接DHT11温湿度传感器、MQ-2烟雾浓度传感器、光敏电阻模块,实时采集环境数据并显示在LCD1602上。四项环境指标里的温湿度和烟雾浓度一旦超过预设阈值,蜂鸣器自动报警,同时板载LED闪烁提示。三个按键用来调整报警阈值,掉电不丢失。整套系统核心逻辑跑在定时器中断和状态机上,代码结构清晰,非常适合作毕设原型、智能家居入门项目,或者给刚学完STM32外设想练综合项目的人。
1. 我从一个“翻车”开始聊:为什么坚持做这块板子
去年帮一个师弟改毕设,他拿某宝买来的“STM32毕业设计全家桶”方案,主控是F103ZET6正点原子板子,外挂一堆模块,杜邦线飞得跟蜘蛛网似的。最离谱的是他想测厨房烟雾浓度,MQ-2模块就放在板子旁边,蜂鸣器一响,他第一时间不是去看数据,而是先找是哪根线松了。那套东西本质上就是外设模块的罗列,谈不上系统设计。
后来我把这套家用环境监测重新捋了一遍,从传感器选型到电源树,再到代码调度全部重构,定下几个硬指标:
- 单块小PCB完成所有功能,不依赖开发板,不飞线。
- 任何模块坏掉,系统其余部分照样工作,不能因为DHT11卡死导致蜂鸣器失效。
- 报警阈值必须能通过按键现场修改,不能每次改参数都重新烧程序。
- 原理图、代码、仿真三件套齐全,能先在电脑上验证逻辑再动烙铁。
1.1 传感器选型:该省的地方省,该花的钱不能省
很多初学者一上来就想用SHT30、BME280这种高精度数字传感器,觉得DHT11精度差丢人。我反而坚持用DHT11做温湿度采集,理由很实在:这套系统监测的是“家里舒不舒服、安不安全”,不是做计量校准。湿度精度±5%RH、温度精度±2℃,对判断“要不要开加湿器”“厨房是不是水汽太大”完全够用。更关键的是DHT11是单总线协议,市面教程资料多到泛滥,学它的时序解析远比I2C读SHT30更能锻炼底层能力。
MQ-2这个模块争议更大,很多人说它需要预热、需要校准、输出不准确,我承认这些毛病都存在。但注意,家庭环境监测的本质是“趋势判断和阈值告警”,不是精确测PPM浓度。MQ-2对液化气、丙烷、氢气的灵敏度高,响应时间在10秒以内,放在厨房测燃气泄漏完全胜任。我真正看重的是它的模拟电压输出——STM32的ADC正好派上用场,这比纯数字接口的传感器更能讲清楚“模拟量采集→量化→标定”这条完整的信号链。
| 传感器 | 接口 | 精度/量程 | 成本 | 适合场景 |
|---|---|---|---|---|
| DHT11 | 单总线 | 温度±2℃,湿度±5%RH | 约3元 | 室内舒适度判断,学习单总线时序 |
| SHT30 | I2C | 温度±0.3℃,湿度±2%RH | 约10元 | 对精度有要求且愿意写I2C协议 |
| DHT22 | 单总线 | 温度±0.5℃,湿度±2%RH | 约8元 | 想要DHT11的简单又想稍微准一点 |
| MQ-2 | 模拟量 | 300-10000ppm可燃气体 | 约5元 | 燃气/烟雾趋势监测 |
| MQ-135 | 模拟量 | 空气质量 | 约6元 | 监测甲醛、氨气等空气污染物 |
光照部分我选了最朴素的光敏电阻模块——就是LM393比较器小板,上面有个可调电阻,光照变强时电压输出跳变。有些方案会用BH1750数字光照传感器,但对一个“白天亮、晚上暗”的判断逻辑来说,杀鸡用牛刀了。光敏模块锦上添花的作用是晚上自动打开报警指示灯。如果你愿意多花两块钱,把光敏电阻换成贴片光敏二极管焊在PCB上,还能省掉一个模块的钱。
1.2 为什么是STM32F103C8T6,而不是Arduino或者51
这个问题的答案直接决定了整套工程的代码风格。我知道用Arduino写这个项目两小时就能调完,但Arduino的高度封装会把DHT11的时序、ADC的采样保持、定时器中断这些核心机理全部藏起来。51单片机倒是对底层友好,但主频12MHz、没有硬件除法器,跑起浮点运算和稍复杂的按键菜单就开始吃力了。
STM32F103C8T6在这类项目里是性价比最平衡的选择:
- 主频72MHz,处理DHT11时序和LCD刷新绰绰有余。
- 2个12位ADC,采样光敏和MQ-2不用外挂ADC芯片。
- 3个通用定时器加1个高级定时器,做多路PWM和精确延时都方便。
- 20KB RAM、64KB Flash,塞下RTOS都够了,裸机状态机更宽裕。
- 最关键的是市面上所有资料都围绕F103,遇到问题一搜一堆解决方案。
封装上选LQFP48的C8T6而非F103ZET6,理由是四层板小尺寸,C8T6只需两层板就能布通,打样成本低。C8T6的引脚更紧凑,对新手焊接也友好一些。实话说,这些资源用在这个项目上有富余,但富余的算力正好支撑后面我说的模块化代码设计——你不必为了省资源把代码写到没法维护。
2. 原理图设计的几个关键取舍,不是把线连通就完事
这套系统的原理图初看很简单,主控加传感器加显示加报警,但细节全藏在你看不见的地方。我在设计原理图时踩过几个跟头,这里把核心节点梳理出来。
2.1 IO资源分配:先把每个引脚的“第二功能”逼出来
画原理图前最忌直接拉线,要把所有外设占用的引脚在Excel或者纸上列清楚,检查有没有和调试口、启动配置冲突。
| 功能模块 | STM32引脚 | 说明 |
|---|---|---|
| DHT11数据线 | PA0 | 开漏输出兼输入,外部上拉4.7kΩ |
| MQ-2模拟输出 | PA1 | ADC1_IN1,采样烟雾浓度 |
| 光敏模块输出 | PA2 | ADC1_IN2,或者作为数字量输入 |
| LCD1602数据线 | PB0-PB3 | 四线模式,只占4个数据脚 |
| LCD1602 RS | PB10 | 寄存器选择 |
| LCD1602 EN | PB11 | 使能信号 |
| 蜂鸣器驱动 | PA8 | TIM1_CH1 PWM输出可以驱动不同响度 |
| 报警指示灯 | PA9 | 普通推挽输出 |
| 按键1/2/3 | PB12/PB13/PB14 | 内部上拉输入,按下接地 |
| 预留串口 | PA9/PA10 | 注意和指示灯引脚冲突时二选一 |
这里有个很容易踩的坑:PA9和PA10同时是USART1的收发引脚。如果你既想用串口打印调试信息又想点灯,就会打架。我最终把指示灯挪到PA8配合TIM1做PWM呼吸灯效果,PA9/PA10留给串口作为调试口。这就是画原理图之前必须做的“功能复用仲裁”工作。
2.2 电源系统:从USB取电到3.3V稳压的讲究
整套系统用USB线供电——手机充电头或者电脑USB口都能带起来。5V进入板子后分成两路:一路直接给LCD1602背光和MQ-2加热丝供电,另一路经过AMS1117-3.3稳压给STM32、DHT11和光敏模块供电。为什么MQ-2要单独吃5V?因为MQ-2内部那个加热电阻需要150mA左右的电流把敏感层加热到工作温度,AMS1117的压差和散热扛不住这种持续负载。
电源部分的去耦电容设计要严格按照芯片手册来:
- AMS1117输入端放一个100μF电解电容加一个0.1μF陶瓷电容做高频去耦,稳定输入电压。
- 输出端放一个22μF钽电容加上0.1μF陶瓷电容,确保3.3V纹波够小。
- STM32每个VDD引脚旁边放一个100nF陶瓷电容,且要尽可能靠近引脚,走线要先过电容再进芯片。
- VDDA引脚单独接一个1μF电容到地,这个直接影响ADC采样精度,偷懒不得。
我第一次改版的时候省了VDDA的1μF电容,结果ADC采样出来的值波动有几十个LSB,光敏值跳得像随机数。后来老老实实补上电容瞬间安静了。原理图上已经帮你摆好了这些电容,你打样时千万别删除。
2.3 传感器采样电路里的电阻分压与滤波设计
MQ-2模块的标准接法是模拟输出直接进ADC引脚,但如果直接把模块插到板子上,模块自带的电位器已经把输出调到了0-5V范围。STM32的ADC输入范围是0-3.3V,直接怼5V会烧引脚。所以我用了一个电阻分压网络把MQ-2输出衰减到0-3.3V。
具体计算是:假设MQ-2输出最大值4V,分压后要变成3.3V,那么R1/(R1+R2) = 3.3/4 = 0.825。选R1=10kΩ、R2=2.2kΩ,分压比约0.82,刚好满足。这里注意别用精确的3.3/5=0.66去算,因为MQ-2的模拟输出不会真到5V,按4V设计余量更合适。
分压之后我加了一级RC低通滤波,R=1kΩ,C=100nF,截止频率约1.6kHz。MQ-2这个传感器的响应带宽本身只有几赫兹,真正有用的信号都在低频段,滤掉高频噪声后ADC采样数据稳定得多。没有这级滤波,你在LCD上看到的烟雾值末尾两位会一直抖。
光敏模块是数字量输出方案就简单了,直接进GPIO。但如果你买的是不带LM393的裸光敏电阻,需要用10kΩ电阻做分压:光敏电阻和10kΩ电阻串联,中间节点接ADC引脚。光照强时光敏电阻阻值降到几kΩ,节点电压升高;光照弱时阻值升到几百kΩ,节点电压趋近于0。这个电路在原理图上也有备用接法,你可以自己切换。
2.4 报警电路:为什么蜂鸣器用三极管而不是直接接GPIO
蜂鸣器分为有源和无源两种,无源蜂鸣器需要PWM方波驱动才有声音,有源蜂鸣器内部有振荡电路,给高电平就响。这套系统里我用的是5V有源蜂鸣器,但这又带来一个问题——STM32的GPIO只能输出3.3V高电平,直接驱动5V蜂鸣器电流不够。
所以蜂鸣器驱动电路用了一个S8050三极管做开关管。具体接法是:蜂鸣器正极接5V,负极接三极管集电极,三极管发射极接地,基极通过一个1kΩ电阻接到STM32的PA8引脚。当PA8输出高电平时三极管导通,蜂鸣器回路通电开始响。这个1kΩ基极电阻是限流电阻,防止基极电流过大烧坏IO口,计算方式是(3.3V-0.7V)/1kΩ,基极电流约2.6mA,足够让S8050饱和导通。基极一定要串这个电阻,直接接会拉低IO电压甚至烧引脚。
指示灯也类似思路,但LED电流很小,直接串一个330Ω限流电阻就行。330Ω怎么算出来的?LED正向压降按2V算,3.3V-2V=1.3V落在电阻上,要限流到4mA左右,电阻值就是1.3V/4mA约325Ω,取标称值330Ω。
2.5 按键电路的上拉设计与去抖硬件方案
三个按键如果像开发板那样外接上拉电阻当然没问题,但STM32内部自带上拉电阻,原理图上我直接用内部上拉,GPIO配置为输入上拉模式,按键一端接IO,另一端接地。按键按下时IO读到低电平,松开时上拉电阻保证IO稳定在高电平。
内部的这个上拉电阻一般在30-50kΩ之间,这对按键检测够了,但如果你的按键引线很长(超过10cm),建议还是外部加一个10kΩ上拉,防止工频干扰让IO误触发。原理图上预留了外部上拉电阻的位置,做实物时可以按需焊接。
硬件去抖我没额外加电容,而是在软件里做了20ms的延时消抖——这个后面代码部分细说。
3. 这套系统的主控代码,是怎么把“同时干很多事”变成现实的
单片机编程最容易犯的毛病是while(1)里一堆delay平铺直叙:读DHT11延时500ms,再刷新LCD延时20ms,再读烟雾ADC延时50ms,只要中间任何一个延时卡住,整个系统就“假死”了。按键按下没反应、蜂鸣器该响的时候不响,都源于这种落后的时间管理方式。
这套代码改用了“时间片轮询”加“状态机”的架构,系统内部分了三层:底层驱动层负责操作具体硬件,中间业务逻辑层处理状态转换,最上层的App层关心“当前该干什么”。这套架构往下能跑裸机,往上以后想接RTOS,代码迁移成本极低。
3.1 定时器产生的“心跳”:1毫秒时间基准
我用TIM2来做系统心跳,配置72MHz时钟的预分频器72分频得到1MHz计数频率,自动重装载值设999,就得到了1kHz的更新中断——也就是1毫秒的基准节拍。整个系统所有需要延时的场景都不再阻塞CPU,而是看这个节拍计数器走到哪了。
TIM2的中断服务函数里放了几个全局的节拍变量:
volatile uint32_t uwTick = 0; // 系统运行毫秒计数 volatile uint8_t dht11_tick = 0; // DHT11读取周期标志 volatile uint16_t sensor_tick = 0; // ADC轮询周期计数 volatile uint16_t lcd_tick = 0; // LCD刷新周期计数 1s volatile uint16_t key_tick = 0; // 按键扫描周期计数 20ms主循环的执行逻辑变成这样:
int main(void) { HAL_Init(); SystemClock_Config(); GPIO_Init(); TIM2_Start(); DHT11_Init(); LCD1602_Init(); Buzzer_Init(); Key_Init(); ADC_Init(); Menu_Init(); while (1) { if (dht11_tick || key_tick || sensor_tick || lcd_tick) { if (dht11_tick) { dht11_tick = 0; DHT11_Poll(); // 非阻塞方式启动读取 } if (sensor_tick) { sensor_tick = 0; Adc_Poll(); // 采集烟雾和光照 } if (key_tick) { key_tick = 0; Key_Poll(); // 20ms扫描一次按键 } if (lcd_tick) { lcd_tick = 0; Lcd_Poll(); // 周期性刷新显示 } Menu_Process(); // 状态机处理用户界面逻辑 Alert_Process(); // 报警逻辑判断 } } }这里有几个读者注意到的妙点:我在系统心跳里分别设置了DHT11每2秒采样一次(不是500ms,DHT11数据手册要求的读取间隔本来就大于1秒)、按键每20毫秒检测一次、ADC每200毫秒采样一次取平均、LCD每500毫秒刷新一次显示。每个任务按照自己的节奏运行,互不阻塞。DHT11如果因为时序原因卡了,最多就是几毫秒的毛刺,不会让整个系统崩溃。
定时器初始化代码里最核心的就是计算预分频系数和重装载值:
static void TIM2_Init(void) { TIM_HandleTypeDef htim2; __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 72MHz/72 = 1MHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 1000 - 1; // 1MHz/1000 = 1kHz中断 htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0); HAL_NVIC_EnableIRQ(TIM2_IRQn); HAL_TIM_Base_Start_IT(&htim2); }72MHz除以72等于1MHz,也就是计数器每微秒计一次数;计数器从0计到999后溢出触发中断,就是每1000微秒,也就是1毫秒触发一次。这个计算逻辑建议背下来,往后做任何STM32定时器项目都绕不开。
3.2 状态机支撑的“菜单”:按键设置阈值就是这样实现的
按键设置报警阈值听起来简单——按一下加一,长按快加就行。但如果不规划状态机,代码写起来非常乱:你如何在“显示温度”和“设置湿度上限”之间切换?如何在“设置温度上限”状态下让按温度加键只加温度不加别的?
我把系统的界面和逻辑分成了几个状态:
typedef enum { STATE_MAIN_DISPLAY = 0, // 主界面:轮流显示温湿度/烟雾 STATE_SET_TEMP_HIGH, // 设置温度上限 STATE_SET_HUMI_HIGH, // 设置湿度上限 STATE_SET_SMOKE_HIGH, // 设置烟雾报警阈值 } MenuState_t;在Menu_Process()里判断当前状态,再根据按键事件切换到对应处理函数。比如在“设置温度上限”状态下,每按一次UP键,温度阈值加1℃并立即存储到Flash,按DOWN键减1℃,按OK键退出当前设置并回到主显示界面。这个架构有个直观的好处——以后想加一个“温度下限”报警,只需要增加一个枚举成员,再在菜单循环里补一个状态转移分支,不需要惊天动地改整个逻辑。
菜单里最容易被忽略的是存储在Flash里的阈值参数,掉电了也要保留上次设置。STM32内部Flash的最后一个扇区(0x0800FC00之后)正好可以放参数。我写了一个Erase_Write_Flash()函数来操作内部Flash——写入前要先擦除扇区,因为Flash只能把1写成0,而擦除能让所有位变回1。
void Flash_SaveParams(void) { uint32_t addr = 0x0800FC00; HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase; erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.PageAddress = addr; erase.NbPages = 1; uint32_t PageError = 0; HAL_FLASHEx_Erase(&erase, &PageError); // 按顺序写入校准后的阈值参数 uint32_t temp = (uint32_t)g_envParams.tempHigh; HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, temp); uint32_t humi = (uint32_t)g_envParams.humiHigh; HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + 4, humi); uint32_t smoke = (uint32_t)g_envParams.smokeHigh; HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + 8, smoke); HAL_FLASH_Lock(); }3.3 串口调试:写代码的时候这条“生命线”至关重要
这套工程我把USART1通过PA9/PA10引出来了,板上留了一个4针的调试排针。系统运行过程中的温湿度值、ADC原始值、状态机跳到哪个状态,全部可以打上串口,用串口助手查看。建议你调试实物时先别急着看LCD,打开串口调试助手,每500ms打印一次当前采集的数据。哪个传感器值不对一眼就能定位,不用反复猜。
void Debug_Print(void) { printf("Temp:%d.%d Humi:%d.%d SmokeADC:%d SmokePct:%d\r\n", (int)g_dht11.temp_int, (int)g_dht11.temp_dec, (int)g_dht11.humi_int, (int)g_dht11.humi_dec, g_mq2.adc_value, g_mq2.percent); }4. DHT11时序代码的底层细节,就是那些网上讲不清楚的位数
DHT11是这套系统里唯一一个需要你手写时序的地方。它的通信协议是单总线,总共只有一根数据线,同时做供电外的双向通信。主机先拉低总线18ms以上发起开始信号,然后释放总线,让上拉电阻把总线拉高。接下来DHT11响应,先拉低80μs再拉高80μs,然后开始传输40bit数据。
40bit数据包含:8bit湿度整数部分、8bit湿度小数部分、8bit温度整数部分、8bit温度小数部分、8bit校验和。前四组数据加起来如果等于校验和,这帧数据才算有效。数据位本身以不同的高电平持续时间区分0和1:54μs高电平是0,而124μs高电平是1。读数据的核心代码就是精确测量引脚保持高电平的时间。
实际代码我使用HAL库的定时器延时函数完成时序转换,但重点在于GPIO模式的切换——读取DHT11数据时引脚要设为输入模式,发送起始信号时要切到输出模式,不能一直保持输出。
void DHT11_Start(void) { // 把PA0设置为输出模式 GPIO_InitTypeDef gpio; gpio.Pin = GPIO_PIN_0; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET); delay_us(20000); // 拉低至少18ms HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET); // 等10-40us后切输入模式 delay_us(30); gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_NOPULL; // 外部已有4.7k上拉 HAL_GPIO_Init(GPIOA, &gpio); } uint8_t DHT11_ReadByte(void) { uint8_t value = 0; for (int i = 0; i < 8; i++) { while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); // 等待低电平结束 delay_us(50); // 高电平后延时50us if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) { value |= (uint8_t)(0x80 >> i); // 高电平持续超过50us说明是1 // 剩余的约40us高电平,要等信号自己结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET); } } return value; }读取位数据的逻辑核心是:如果数据位是0,高电平只有约54μs,你延时50μs后再读引脚,此时高电平已经结束,读到的应该是低电平;如果数据位是1,高电平约124μs,延时50μs后依然读得到高电平。判断引脚状态的那个瞬间,就把0和1区分开了。
这个50μs的延时参数非常关键。设短了会把0误判成1(还没到判断时刻,低电平已经退场不算,主要会错位判断),设长了会把1误判成0(高电平已经把124μs走完了)。我实测下来在所有STM32F103型号上用delay_us(50)都稳定,但如果你换成别的型号单片机,需要微调这个参数。工程实现时我做了个HAL_GetTick()超时保护——读取循环里如果超过200ms还没等到低电平开始的那一位,直接判定传感器无响应并返回错误码,这样DHT11坏了或者线松了不会卡死主程序。这个超时机制建议你不要删掉。
DHT11每次读取间隔要大于1秒,因为传感器内部的测温频率实际只有约0.5Hz。工程里我把DHT11的采样周期设成2秒,保证每次读取前总线处于空闲状态。如果你读出来的数据一直是0或者湿度非常离谱,优先检查上拉电阻焊接和读时序延时参数,这两块扛了七成故障原因。
5. 完整ADC采集链路:从模拟电压到“读得懂的百分比”
烟雾和光照的采集都是走ADC。STM32F103的ADC是12位的,也就是采样的数字范围是0到4095。直接显示这个原始值用户根本看不懂,我写了一层换算逻辑把它变成“0到100”的百分比值。
ADC初始化按常规来,但有几个细节容易出错:一是ADC的采样时间要设置得长一些,因为传感器输出的是高阻信号,采样时间太短电容充不满会导致读数偏小;二是连续采样多次取平均,我一次采样16次去掉最大最小值,剩下的取平均,抖动最少。
校准思路是这样的:烟雾传感器在清洁空气中的输出值应该是最低的,记作baseline。实测我发现MQ-2在不通电预热10分钟后,输出约0.5V,对应ADC原始值约620。随着烟雾浓度上升,输出电压接近2V以上,ADC值超过1800。我用动态范围的方式做归一化:
#define SMOKE_BASELINE (uint16_t)600 #define SMOKE_MAX_ADC (uint16_t)2600 uint8_t Smoke_GetPercent(void) { uint16_t adc = Get_AdcAverage(); if (adc < SMOKE_BASELINE) return 0; if (adc >= SMOKE_MAX_ADC) return 100; return (uint8_t)(((uint32_t)(adc - SMOKE_BASELINE) * 100) / (SMOKE_MAX_ADC - SMOKE_BASELINE)); }百分比阈值默认设在40,也就是说ADC值超过1400就开始报警。这样“0到100百分比”直观易懂,不会看到一长串ADC原始值发怵。工程里报警判断用的是这个百分数而不是ADC原始值——即使换了一块传感器,只要重新标定baseline和max,整套报警逻辑不需要动。
电源电压波动对ADC的影响不能忽略。AMS1117的3.3V输出虽然稳压了,但如果电池供电会有压降。我在PCB上把VDDA通过一个磁珠和一个1μF电容单独隔离,尽可能让ADC基准电压稳定。[注意]:如果你想追求更加精准的绝对电压值,可以增加一个基准源芯片如REF3033,但家用监测场景真没必要。
6. 用Proteus把整套逻辑先跑通:能验证什么、不能验证什么
这套工程附带的Proteus仿真文件是基于Proteus 8.9及以上版本创建的。仿真文件里选用的STM32F103C8T6模型、DHT11模型、LM016L液晶模型,这几个模型在Proteus库里全部内置,不需要额外装元件库。打开就能跑,前提是你的Proteus装了STM32相关库并配置了芯片的固件文件。
仿真工程里我在RCC配置中把晶振频率设成8MHz,让代码里SystemClock_Config初始化后的实际主频是72MHz。这里几个常见的仿真问题说一下:
- 仿真里双击STM32芯片,必须把Program File指向编译出来的hex文件,否则芯片不会跑任何代码。KEIL里要勾选Output选项卡的Create HEX File才能生成hex。
- DHT11模型双击可以设置温度和湿度值,你用鼠标拖动滑条就能模拟环境变化,这个很方便。
- MQ-2在Proteus里没有现成模型,我是用可调电阻连接到ADC引脚模拟烟雾浓度。旋转电位器改变电压,看ADC采集百分比是否跟着变。这在逻辑验证层面已足够。
- LCD1602用LM016L模型替代,这个模型四线模式兼容性很好,总线上注意LCD的VO对比度引脚接一个电位器到地。
仿真文件能验证代码逻辑层面的问题:按键状态机流转是否正确、菜单字段有没有写反、LCD显示刷新有没有乱码、报警阈值是否正常触发,这些在改实物之前就能定位掉。但它验证不了真实世界的问题:DHT11真实时序的微妙偏差、MQ-2传感器预热的漫长等待、5V电源线压降、LCD1602的对比度调节。这些必须上实物才能调清楚。
如果你手头还没有实物,完全可以通过仿真先把这套系统的代码逻辑全部过一遍。我强烈建议你把仿真当作第一道测试关卡,代码先过仿真再烧实物,效率高很多。等代码逻辑验证熟了,再开始焊接PCB,到那时出问题的只会是硬件层面,而不会是状态机逻辑的Bug。
7. 实物调试的“血泪清单”,照着这个步骤三天内搞定
我把自己调试实物时遇到的坑整理成了一份清单。按这个顺序排查,你基本不会出现坐在那满头问号的情况。
7.1 电源上电前先测短路
第一次上电是最紧张的。我用万用表蜂鸣档直接测3.3V和GND之间的阻值,如果接近0说明有短路,立刻断电检查。正常情况应该看到几百欧到上千欧的阻值(板上有电阻电容充电过程),确认无短路后再上USB供电。上电后手摸一下AMS1117、STM32芯片表面温度,微温正常,烫手就有问题。
7.2 供电顺序:先点亮主控,再挂传感器
第一次调试别把所有传感器都焊到板上,我习惯第一步只焊最小系统加调试串口,烧一个简单的LED闪烁程序,确认主控能跑;第二步焊LCD1602,点亮显示内容;第三步焊DHT11;第四步再焊MQ-2。每次增加一个模块,出问题了排查范围就小一轮。如果一次全焊上去不工作,你真的很难判断是哪颗电容虚焊还是哪个引脚连锡。
7.3 LCD显示乱码的常见原因
LCD1602用四线模式时,数据位D4-D7要接在同一个GPIO端口的连续引脚上(工程里用了PB0-PB3),RS和EN也不能乱接。如果屏幕显示整行方块,大概率初始化时序不对或者对比度没调好。开发板上LCD的VO脚很多直连电位器,我板子上也放了这电位器的位置。调整电位器让屏幕出现“淡淡的黑影”,这个状态就是对比度刚好的状态,比肉眼可见字符更重要。
7.4 DHT11数据异常的排查路径
如果串口打印出来的湿度是255或者温度是不正常的负值,先用逻辑分析仪或者示波器抓PA0引脚波形,看看起始信号后传感器有没有拉低80μs响应。没有响应就检查4.7kΩ上拉电阻是否焊接正常;有响应但数据全是FF,检查50μs的延时参数是否跑偏(有些编译器优化等级设置不一样会改变延时循环,开启优化后延时变短,参数需要重新标定)。我的工程代码里用了HAL库的微秒延时函数而不是裸循环,就是为了避免编译器优化把延时吃掉。
7.5 蜂鸣器经常不响或者异常响
蜂鸣器驱动电路如果三极管型号不对或者基极电阻虚焊,会表现为有源蜂鸣器直接静音。用万用表测量三极管基极-发射极电压,如果烧程序给高电平后电压接近0.7V,说明驱动正常,问题大概率在蜂鸣器本身供电。还有一种容易被忽略的情况:PA8默认复用TIM1_CH1,如果你只是要数字高低电平驱动蜂鸣器,记得在GPIO初始化时把引脚配置成推挽输出而不是复用功能,否则初始化后引脚被定时器外设占用,普通HAL_GPIO_WritePin控制无效。
7.6 阈值写好Flash但还是乱恢复默认值
这大概率不是因为Flash写入失败,而是你每次烧录程序后做了全片擦除,把Flash里存的阈值参数一并擦掉了。建议调试阶段先把“读取Flash失败就写入默认值”这段逻辑加上,保证第一次烧录后能正常使用,后期每次改代码重新烧录不上电时不要全片擦除,就不影响参数。
8. 代码架构的一个精妙点:如何做到“传感器坏了系统不死”
这是整套代码最值得揣摩的地方。DHT11单总线协议有个天然的缺点——如果传感器没有物理响应,读时序的代码可能卡在while循环里出不来。我在DHT11读取函数的每一次循环等待上都加了超时判断:
uint32_t timeout = HAL_GetTick(); while (HAL_GPIO_ReadPin(...) == GPIO_PIN_RESET) { if (HAL_GetTick() - timeout > 100) { DHT11_Reset(); return NULL_VALUE; } }在Alert_Process报警逻辑里判断所有传感器数据当前是否有效,如果DHT11连续3次返回NULL,就只在LCD上报“传感器未连接”但不会触发报警鸣叫。这样避免了一个灵异场景:厨房燃气泄漏的同时DHT11恰好坏了,系统如果卡死在DHT11读时序,烟雾报警就永远没机会执行了。
为了让每个模块的耦合度降到最低,我用结构体把所有传感器状态封装起来:
typedef struct { int8_t temp_int; uint8_t temp_dec; uint8_t humi_int; uint8_t humi_dec; uint8_t is_valid; } DHT11_Data_t; typedef struct { uint16_t adc_value; uint8_t percent; uint8_t is_alert; } MQ2_Data_t; typedef struct { int8_t tempHigh; uint8_t humiHigh; uint8_t smokeHigh; } EnvParams_t;代码的每个模块只管自己的数据更新。报警逻辑只读这些结构体和阈值,不关心数据是来自DHT11还是后续你要换成SHT30。以后想升级传感器,只需要封装一个同样是输出DHT11_Data_t的新驱动函数,底层实现随便改,上层逻辑一行都不用动。我实际在后续版本里把DHT11换成DHT22就只改了驱动层——这验证了这种分层设计的价值。
9. 整套开源文件的组织方式,拿到手之后从哪里看起
开源包里的文件分成五个目录:
01_Datasheet/:所有关键元器件的数据手册,包括STM32F103C8T6、DHT11、MQ-2、LCD1602、AMS1117的PDF。02_Schematic/:立创EDA格式的原理图工程和Gerber文件,直接就能投板。03_Firmware/:完整Keil MDK工程,打开就能编译。如果你用STM32CubeIDE,Core/Src下的源码也可以直接粘进新工程,依赖的HAL库文件在官方固件包里都有。04_Simulation/:Proteus仿真工程,双击就能跑。05_Doc/:这份项目的完整说明文档和调试手册。
拿到资料后的第一步建议不是去开Keil疯狂看代码,而是打开05_Doc里的设计文档,从系统框图开始理解模块划分,然后对照原理图看硬件设计,最后才看代码。如果直接陷进某段DHT11时序代码里,很容易迷失在底层细节里忽略整棵树的架构。
我自己做嵌入式这些年最大的体会是:一个“完整”的开源项目难的不是某个技术点,而是“传感器采集、用户交互、数据展示、报警决策、参数存储”这五个环节都能可靠地协调工作。单看DHT11时序、单看ADC采样、单看菜单状态机,网上都有大量单独教程,但把它们像齿轮一样啮合到一起,才是做真实产品的原型阶段的日常。
本项目的代码你完全可以改造成自家的应用。比如把LCD1602换成OLED屏,把蜂鸣器换成继电器控制排风扇,把烟雾报警逻辑改成“浓度超过阈值自动打开窗户电机”——那已经不是在做课程作业,而是在做产品原型了。过程中如果卡在某个环节,欢迎给我留言交流。