简介:这份资源面向学习STM32嵌入式开发的学生与电子爱好者,提供一套可直接参考的环境温湿度监测系统完整工程。系统以STM32为主控,通过DS18B20采集温度、DHT11采集湿度,数据实时显示于LCD液晶屏;当温湿度超出设定范围时触发声光报警,声音部分由无源蜂鸣器播放音乐,光源部分采用LED闪烁,并支持手动设置温湿度上下限,适合课程设计、毕业设计与单片机综合实训场景。压缩包共195个文件,约4.34MB,以46个c源文件与46个h头文件为核心,配合o、d、crf等编译中间文件及uvprojx、uvoptx工程配置、hex与axf可执行输出,完整保留了Keil工程结构与构建产物,便于直接打开编译和二次修改。目前已有8727人学习下载,读者可从中获取传感器驱动、LCD显示、报警逻辑与阈值设置等模块的实现思路,对照工程目录快速理解系统组织方式,并在此基础上调整报警音乐、闪烁次数等细节,完成个性化设计。
1. 从一块吃灰的开发板说起:这个温湿度监测项目到底能干什么
手头有块 STM32 开发板,配套的 DHT11 或 SHT30 传感器也在抽屉里躺了小半年,想做个能真正跑起来的环境监测小系统,却卡在“传感器时序怎么读、LCD 怎么刷、报警阈值怎么设”这些具体环节上——这大概是很多嵌入式新手共同的卡点。这份基于 STM32 的环境温湿度监测系统设计,正好把这条链路完整串了起来:从传感器采集、数据处理、本地显示,到超限报警和串口上报,是一个可以直接烧录验证、也能继续往上叠功能的工程骨架。它适合两类人:一类是刚学完 GPIO 和定时器、需要一个综合小项目练手的同学;另一类是手里有现成硬件、想找个干净底子快速改出自己版本的老手。下面我按“能干什么、怎么跑、怎么改”的顺序,把这个工程拆开讲清楚。
2. 系统架构与工程结构:先看清模块边界再动手
拿到一个 STM32 工程,最忌讳上来就编译烧录。先花十分钟把目录和模块边界理清楚,后面改代码才不会牵一发动全身。这个项目的整体思路是典型的分层结构:底层是 MCU 外设驱动,中间是传感器与显示的业务封装,上层是主循环里的调度逻辑。
2.1 硬件组成与信号链路
整套系统的硬件不复杂,但每一环都有讲究。核心控制器用 STM32F103 系列(常见的是 C8T6 最小系统板),温湿度采集用 DHT11 或 DHT22 这类单总线数字传感器,本地显示用 0.96 寸 OLED(SSD1306 驱动,I2C 接口)或 1602 LCD,报警用蜂鸣器加 LED,数据上报走 USART 串口打到电脑串口助手。
信号链路是这样的:传感器通过单根数据线跟 MCU 通信,MCU 用 GPIO 模拟时序读出 40 位数据,解析出湿度和温度;主循环把解析结果送到显示模块刷新屏幕;同时拿当前值和预设阈值比较,超限就拉高蜂鸣器引脚;串口则按固定格式把数据帧发出去,方便上位机或串口助手查看。
| 模块 | 典型器件 | 接口方式 | 关键引脚 |
|---|---|---|---|
| 主控 | STM32F103C8T6 | — | — |
| 温湿度 | DHT11 / DHT22 | 单总线 | PA0(可改) |
| 显示 | SSD1306 OLED | I2C | PB6/PB7 |
| 报警 | 有源蜂鸣器 + LED | GPIO | PB0 / PC13 |
| 上报 | USART1 | 串口 | PA9/PA10 |
这张表不是让你照抄,而是提醒你:改硬件时先对照引脚,别把 I2C 的 SCL/SDA 接反,也别把单总线传感器接到带复用功能的引脚上导致时序被干扰。
2.2 目录结构与模块职责
工程一般按 Keil MDK 的组织方式分几个文件夹,理解每个文件夹的职责,改代码时才知道该往哪放。
Project/ ├── Core/ │ ├── Inc/ # 头文件:引脚宏、函数声明 │ └── Src/ │ ├── main.c # 主循环与初始化调度 │ ├── dht11.c # 单总线时序与数据解析 │ ├── oled.c # SSD1306 显存刷新 │ ├── usart.c # 串口收发与 printf 重定向 │ └── delay.c # 微秒/毫秒延时 ├── Drivers/ │ ├── CMSIS/ # 内核头文件 │ └── STM32F1xx_HAL_Driver/ # HAL 库 └── MDK-ARM/ └── Project.uvprojx # Keil 工程文件Core/Src是你要动的地方,Drivers基本不动。main.c负责把各模块初始化串起来,dht11.c封装了最磨人的时序,oled.c管显示,usart.c管上报。这种划分的好处是:换传感器只改dht11.c,换屏幕只改oled.c,主循环逻辑几乎不用碰。
2.3 主循环调度逻辑
主循环是整个系统的心跳,写法直接决定系统稳不稳。常见做法是用一个非阻塞的节拍,而不是在循环里塞HAL_Delay死等。
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_USART1_UART_Init(); OLED_Init(); DHT11_Init(); uint32_t last_tick = 0; while (1) { /* 每 2 秒采集一次,避免 DHT11 读取过于频繁 */ if (HAL_GetTick() - last_tick >= 2000) { last_tick = HAL_GetTick(); uint8_t temp = 0, humi = 0; if (DHT11_Read(&temp, &humi) == DHT11_OK) { OLED_ShowTempHumi(temp, humi); /* 刷新显示 */ Alarm_Check(temp, humi); /* 阈值判断 */ USART_Report(temp, humi); /* 串口上报 */ } } } }这段代码的关键点有三个。第一,用HAL_GetTick()做时间片,主循环不会被HAL_Delay卡死,后续加按键扫描或看门狗都方便。第二,DHT11_Read返回状态码,只有读成功才刷新显示和上报,避免把错误数据当真实值。第三,采集周期设成 2 秒,是因为 DHT11 本身采样率低,读太快会返回旧值甚至失败,这是很多人第一次做时容易忽略的地方。
3. 核心模块拆解:从单总线时序到显示刷新
这一章是整篇最该细看的部分。传感器读取和显示刷新是两个最容易翻车的模块,代码不长,但每一行都有它的道理。
3.1 DHT11 单总线时序与数据解析
DHT11 用一根线完成双向通信,靠的是高低电平持续时间来区分 0 和 1。MCU 先拉低至少 18ms 作为起始信号,然后释放总线,传感器回应 80us 低电平加 80us 高电平,接着连续输出 40 位数据。每一位以 50us 低电平开始,随后高电平持续 26~28us 表示 0,持续 70us 表示 1。
uint8_t DHT11_Read(uint8_t *temp, uint8_t *humi) { uint8_t buf[5] = {0}; uint8_t i, j; /* 主机发送起始信号:拉低至少 18ms */ DHT11_SetPinOutput(); DHT11_LOW(); HAL_Delay(20); DHT11_HIGH(); Delay_us(30); /* 切换为输入,等待传感器响应 */ DHT11_SetPinInput(); if (DHT11_WaitLevel(0, 100) != OK) return DHT11_ERR; /* 等待低电平 */ if (DHT11_WaitLevel(1, 100) != OK) return DHT11_ERR; /* 等待高电平 */ if (DHT11_WaitLevel(0, 100) != OK) return DHT11_ERR; /* 响应结束 */ /* 连续读 40 位,每 8 位组成一个字节 */ for (i = 0; i < 5; i++) { for (j = 0; j < 8; j++) { if (DHT11_WaitLevel(1, 100) != OK) return DHT11_ERR; Delay_us(40); /* 40us 后仍为高电平则是 1 */ buf[i] <<= 1; if (DHT11_ReadPin() == 1) buf[i] |= 1; if (DHT11_WaitLevel(0, 100) != OK) return DHT11_ERR; } } /* 校验:前四字节之和的低 8 位应等于第五字节 */ if ((uint8_t)(buf[0] + buf[1] + buf[2] + buf[3]) != buf[4]) return DHT11_ERR; *humi = buf[0]; /* 湿度整数部分 */ *temp = buf[2]; /* 温度整数部分 */ return DHT11_OK; }逐段看。起始信号那段,拉低 20ms 是给传感器一个明确的“我要开始读了”的握手,时间不够传感器不响应。DHT11_WaitLevel是个带超时的电平等待函数,参数分别是目标电平和超时微秒数,返回失败就立刻退出,防止某个环节卡死导致整个系统假死——这是血泪经验,早期版本没加超时,传感器接触不良时主循环直接挂住。
读位那段,Delay_us(40)是判据核心:低电平结束后高电平若只持续 26~28us,40us 时已经变低,读到 0;若持续 70us,40us 时还是高,读到 1。这个 40us 是介于两者之间的经验值,不同批次传感器略有差异,但一般都能覆盖。
最后的校验不能省。DHT11 偶尔会因干扰读出错位数据,校验和能挡掉大部分脏数据。buf[0]是湿度整数,buf[2]是温度整数,小数部分在buf[1]和buf[3],DHT11 精度有限,很多工程直接取整数用。
3.2 OLED 显示刷新与数据格式化
显示模块的坑不在驱动本身,而在刷新策略。SSD1306 是页寻址的,整屏刷新一次要传 1KB 数据,I2C 速率不高时会有明显闪烁。常见做法是只刷新数值变化的区域,或者用局部刷新函数。
void OLED_ShowTempHumi(uint8_t temp, uint8_t humi) { char str[16]; /* 固定标签只刷一次,这里每次只更新数值区 */ sprintf(str, "%2d C", temp); OLED_ShowString(0, 2, (uint8_t *)str, 16); sprintf(str, "%2d %%RH", humi); OLED_ShowString(0, 4, (uint8_t *)str, 16); }OLED_ShowString的四个参数分别是起始列、起始页、字符串指针和字号。页坐标 0~7 对应屏幕的 8 行,字号 16 表示每个字符占两页。sprintf里用%2d而不是%d,是为了让数值始终占两位,避免从 9 变 10 时字符宽度变化导致残影——这个细节不处理,屏幕上会看到旧数字的尾巴。
如果用的是 1602 LCD,思路类似,但要注意它只支持 ASCII,%和C这些符号没问题,中文就别想了。刷新频率也别太高,200ms 一次足够人眼阅读,刷太快反而增加总线负担。
3.3 阈值报警与串口上报
报警逻辑简单,但阈值怎么设、报警怎么解除,值得说两句。常见做法是把阈值定义成宏,方便改。
#define TEMP_HIGH_LIMIT 30 #define HUMI_HIGH_LIMIT 80 void Alarm_Check(uint8_t temp, uint8_t humi) { if (temp > TEMP_HIGH_LIMIT || humi > HUMI_HIGH_LIMIT) { HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); /* 低电平点亮 */ } else { HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } }注意 LED 的极性,很多板子低电平点亮,写反了就是“正常时亮、报警时灭”,逻辑正好颠倒。蜂鸣器分有源和无源,有源给高电平就响,无源需要 PWM 驱动,代码里默认按有源处理,换器件时要改。
串口上报用printf重定向最省事,在usart.c里实现fputc即可,然后主循环里printf("T:%d,H:%d\r\n", temp, humi);。格式建议带固定前缀和换行,方便串口助手或上位机按行解析。
4. 编译烧录与联调:把工程真正跑起来
代码看懂了,下一步是让它在你手上跑起来。这一章按实际操作顺序走一遍,每一步都说明失败时该看什么。
4.1 开发环境搭建与工程导入
工具链用 Keil MDK 5 加 STM32F1 的器件支持包(Device Family Pack),这是这类工程最常见的组合。装好 MDK 后先装 DFP,否则打开工程会提示找不到器件。用 ST-Link 下载的话还要装 ST-Link 驱动,用串口 ISP 下载则要装 CH340 之类的一键下载芯片驱动。
打开MDK-ARM/Project.uvprojx,先别急着编译,检查两个地方:一是Options for Target里的 Device 是否选对了具体型号(比如 STM32F103C8),二是 Debug 页里下载器是否选成 ST-Link 并识别到了芯片。这两处不对,后面全是白忙。
编译前确认 HAL 库路径没丢。工程如果是从别处拷来的,Drivers文件夹可能不完整,编译报一堆stm32f1xx_hal.h: No such file就是路径问题,在 C/C++ 的 Include Paths 里把Drivers/STM32F1xx_HAL_Driver/Inc和Drivers/CMSIS/Device/ST/STM32F1xx/Include加进去。
4.2 烧录与串口验证
编译通过后点下载,ST-Link 会先擦除再写入。如果提示“No target connected”,先查排线是否插反、板子是否供电、ST-Link 的 SWDIO/SWCLK 是否接对。烧录成功后程序自动运行,OLED 应该先显示初始化画面,然后每两秒刷新一次温湿度。
串口验证是确认数据链路是否通的最快方式。用 USB 转 TTL 接 PA9(TX)和 PA10(RX),注意 TX 接 RX、RX 接 TX,共地。串口助手设成 9600 波特率、8 数据位、1 停止位、无校验,应该能看到类似T:25,H:60的行。看不到数据先查三件事:波特率是否和代码里MX_USART1_UART_Init一致、接线是否交叉、printf重定向是否真的生效(有些工程忘了实现fputc,编译不报错但串口没输出)。
4.3 联调顺序与观察点
联调别一上来就全功能一起测,按“显示→采集→报警→上报”的顺序逐层验证。先烧一个只刷固定字符的显示测试,确认屏幕和 I2C 没问题;再接传感器,看数值是否随呼吸变化(对着传感器哈气,湿度应该上升);然后改低阈值触发报警,听蜂鸣器、看 LED;最后接串口看数据帧。
每一步只引入一个新变量,出问题时范围就小。这个习惯看着笨,但比“全接上再猜哪里坏了”快得多。
5. 避坑与常见问题排查:那些让我返工的细节
这一章全是实际调试中踩过的坑,按“现象→原因→解决”写,遇到对应症状直接对照。
5.1 传感器读数一直是 0 或 255
现象:串口和屏幕上的温湿度恒定不变,要么全 0 要么全 255。原因通常是三类:单总线引脚配置错(该输入时还是输出)、上拉电阻缺失导致总线拉不高、延时函数精度不够导致时序判据失效。解决:先确认DHT11_SetPinInput真的把 GPIO 模式切成了输入;DHT11 数据线需要 4.7k~10k 上拉电阻,很多模块自带,裸传感器要自己加;Delay_us用 SysTick 或定时器实现,别用空循环凑,主频一变时序就偏。
5.2 OLED 花屏或只显示一半
现象:屏幕出现乱码、条纹,或者只有上半屏有内容。原因多是 I2C 速率过高、初始化序列不完整,或者显存刷新函数越界。解决:把 I2C 时钟降到 100kHz 试试;确认OLED_Init里发了完整的初始化命令;检查OLED_ShowString的页坐标没超过 7,字号 16 时起始页只能是 0、2、4、6,写到 7 就越界了。
5.3 程序跑一会儿就卡死
现象:运行几十秒到几分钟后主循环不再刷新,串口也停了。原因基本是某个等待循环没有超时,传感器偶发不响应时死等。解决:所有while等待电平的地方都加超时计数,超时返回错误码,主循环拿到错误码就跳过本次采集。另外可以开独立看门狗(IWDG),喂狗放在主循环里,真卡死了自动复位。
5.4 串口有输出但数据错乱
现象:串口助手收到乱码或断帧。原因:波特率不匹配、时钟配置错误导致实际波特率偏移、或者printf重定向时没处理\n。解决:核对SystemClock_Config里系统时钟和串口时钟源,F103 的 USART1 挂在 APB2 上;重定向里把\n转成\r\n,否则某些助手不换行;长数据帧加帧头和校验,别裸发。
5.5 报警阈值改了不生效
现象:改了宏定义重新编译,行为没变。原因:改的是头文件里的宏,但实际参与编译的是另一份副本,或者 Keil 没触发重新编译。解决:改完执行 Rebuild 而不是 Build;确认宏定义没有被局部变量覆盖;如果阈值要做成可调,别用宏,改成全局变量并在初始化时赋值,后续接按键或串口命令动态改。
6. 二次开发改造:从能跑到好用
工程跑通只是起点,真正体现价值的是往上叠功能。这一章给几个改造方向,都是在这个骨架上改动量不大、但实用性提升明显的。
第一个方向是换传感器。DHT11 精度只有 ±2℃、±5%RH,做室内监测够用,但要做数据记录就偏粗。换成 SHT30(I2C 接口)或 AHT20,精度上一个台阶,改动集中在传感器模块:新建sht30.c,实现SHT30_Read(float *temp, float *humi),主循环里把uint8_t换成float,显示格式化改成%.1f。接口统一成“返回状态码 + 输出参数”的形式,主循环几乎不用动。
第二个方向是加数据存储。用 W25Q64 这类 SPI Flash 或板载 EEPROM,把每次采集的时间和数值存下来,掉电不丢。关键是把存储操作做成非阻塞:采集到数据先塞进环形缓冲区,主循环空闲时再写 Flash,避免写操作拖慢采集节拍。时间戳可以用 RTC 外设,没有 RTC 就用上电计时。
第三个方向是加无线上报。ESP8266 走 AT 指令接 USART2,把数据帧转发到局域网内的服务端。这里有个容易翻车的点:AT 指令的响应是异步的,别用HAL_Delay死等,用状态机加超时重试。串口缓冲区要够大,WiFi 模块启动时打印的日志不少,缓冲区小了会丢响应。
第四个方向是把阈值和采集周期做成可配置。用几个按键加一个简单菜单,或者串口发命令改参数,参数存 EEPROM。这样同一套固件能适配不同场景,不用每次改代码重烧。
| 改造方向 | 改动文件 | 工作量 | 注意点 |
|---|---|---|---|
| 换高精度传感器 | 新增传感器驱动 + main.c | 小 | 数据类型从整型改浮点 |
| 加 Flash 存储 | 新增存储模块 + 主循环 | 中 | 写操作非阻塞 |
| 加 WiFi 上报 | 新增 AT 状态机 | 中 | 异步响应加超时 |
| 参数可配置 | 按键/串口 + EEPROM | 中 | 参数校验防越界 |
验证改造是否成功,别只看“功能出来了”,要回归测试:连续跑两小时看会不会卡死、断电重启后参数是否还在、传感器拔掉再插上能否自动恢复。我一般会在主循环里加一个心跳计数,串口定期打印,跑长时间测试时看计数是否连续增长,断了就说明某处卡住了。
说个自己的教训。早期做这类项目,我总想着先把功能堆全再统一测试,结果传感器、显示、无线三处问题搅在一起,排查花了两天。从那以后我每次加新模块,都强制走一遍“单独验证→接入主循环→长跑观察”的流程,宁可慢一点,也不让问题叠着来。希望这些拆解能帮你少走点弯路,把这块板子真正用起来。
本文还有配套的精品资源,点击获取