STM32温湿度监控系统设计:从DHT11驱动到EEPROM存储实战
2026/8/29 22:37:24 网站建设 项目流程

1. 项目背景与核心需求解析

最近在整理过往的嵌入式项目资料,翻到了当年参加蓝桥杯嵌入式大赛的决赛作品——温湿度监控设备。这个项目虽然过去有些年头了,但其中涉及到的传感器数据采集、人机交互、数据存储与通信等核心模块,依然是当下许多物联网和智能硬件项目的基石。无论是学生备战竞赛,还是工程师入门实战,这个案例都极具参考价值。它不是一个简单的“点亮LED”的Demo,而是一个功能完整、逻辑闭环的小型系统,涵盖了从底层驱动到上层应用逻辑的完整链条。

这个“温湿度监控设备”的核心任务非常明确:实时监测环境中的温度和湿度,并将数据直观地显示出来,同时具备一定的数据记录和告警能力。听起来简单,但要在资源有限的竞赛开发板(通常是基于STM32系列MCU)上稳定、优雅地实现,需要考虑的细节非常多。比如,如何保证传感器读数的准确性?如何在有限的屏幕空间上清晰地展示信息和菜单?如何设计一个稳定可靠的数据存储机制?这些都是在实际开发中必然会遇到的“坎”。接下来,我就结合当年的实现思路和后续的工程经验,把这个项目的里里外外拆解清楚,希望能给正在学习嵌入式或准备类似项目的朋友一些实实在在的参考。

2. 硬件平台与核心器件选型分析

工欲善其事,必先利其器。做嵌入式开发,硬件是绕不开的第一环。蓝桥杯嵌入式竞赛有指定的官方开发平台,通常是基于意法半导体(ST)的STM32系列微控制器。我当年参加第七届决赛时,使用的平台是CT117E开发板,其核心是一颗STM32F103系列的MCU(具体型号可能因届次略有不同,但均为Cortex-M3内核)。这块板子可以看作是竞赛的“标准答案”硬件环境,上面集成了LED、按键、EEPROM、液晶屏接口等必要外设。

对于温湿度监控这个主题,核心的传感器选型至关重要。竞赛中常用的温湿度传感器是DHT11。这里就涉及到一个关键的选型思考:为什么是DHT11,而不是更精确的SHT30或者更简单的DS18B20(仅温度)?

首先从竞赛场景看,DHT11的优势非常明显:

  1. 成本与普及度:DHT11价格极其低廉,是学生竞赛和入门项目的首选,这也符合蓝桥杯竞赛贴近教学、注重基础的原则。
  2. 接口简单:它采用单总线(1-Wire)协议进行通信,只需要一个GPIO引脚即可完成数据读写,节省了宝贵的IO资源。对于IO引脚数量固定的竞赛板来说,这一点很重要。
  3. 集成度:一颗芯片同时输出温度和湿度,简化了硬件连接和软件驱动逻辑。

当然,DHT11的缺点也很突出:测量精度相对较低(温度±2°C,湿度±5%RH),响应速度慢(每次测量耗时约2秒)。但在室内环境监控、数据趋势观察的竞赛场景下,这些缺点是可以接受的。它的存在,恰恰要求开发者必须处理好传感器读取的时序、数据校验以及可能的读取失败重试机制,这些都是嵌入式开发的基本功。

除了传感器,另一个核心是显示单元。开发板通常搭载一块128x64像素的LCD液晶屏(驱动芯片多为ST7567或兼容型号)。如何在这么小的点阵屏上,合理地布局实时数据、历史曲线、设置菜单等信息,是对UI设计能力的考验。按键(通常为4个独立按键)作为主要输入设备,需要实现短按、长按等不同功能,菜单的导航逻辑设计也是重点。

注意:在实际连接DHT11时,务必记得接一个4.7KΩ - 10KΩ的上拉电阻到数据线,以确保单总线在空闲时处于高电平状态。这是很多新手容易忽略,导致传感器无法正常通信的“坑”。

3. 系统软件架构设计与模块划分

面对一个功能明确的项目,最忌讳的就是一开始就埋头写代码。一个好的软件架构能让开发过程事半功倍,也便于后期的调试和维护。对于这个温湿度监控设备,我采用了分层模块化的设计思想,将系统自上而下划分为几个清晰的层次。

应用层:这是最顶层,直接面向用户功能。它包含几个核心状态:

  1. 主显示界面:实时刷新并显示当前温度、湿度值,通常以大字体的形式呈现,一目了然。
  2. 历史数据界面:可以查看过去一段时间(如24小时)内温湿度的变化曲线或列表。这里涉及到数据存储和检索逻辑。
  3. 参数设置界面:允许用户设置温湿度的报警阈值、屏幕背光时间、数据记录间隔等。
  4. 报警提示界面:当监测值超过设定阈值时,触发声光报警(蜂鸣器、LED闪烁),并在屏幕显示报警信息。

业务逻辑层:这一层承上启下,是系统的“大脑”。它负责调度各个模块,处理核心业务流程。例如:

  • 数据采集调度:以固定的时间间隔(如每2秒)触发一次传感器读取任务。
  • 报警判断逻辑:每次获取到新的温湿度数据后,立即与用户设定的上下限阈值进行比较,判断是否触发或解除报警状态。
  • 数据存储管理:决定何时将一次有效的采样数据存入非易失存储器(如板载EEPROM或模拟的Flash区域)。通常不会每次采样都存,而是间隔一段时间(如每5分钟)存储一个数据点,以节省存储空间。

驱动层:这是最底层,直接与硬件打交道,封装了所有硬件操作细节。关键驱动模块包括:

  • DHT11驱动:实现了单总线协议的严格时序控制,包含初始化、发送开始信号、读取40位数据、校验和验证等函数。这里的时序必须非常精确,微秒级的延迟错误都可能导致读取失败。
  • LCD驱动:提供了基本的画点、画线、显示字符和字符串的函数。为了便于应用层调用,通常会在其上再封装一层“GUI”层,提供更高级的接口,如GUI_ShowFloat()(显示浮点数)、GUI_DrawChart()(绘制简单曲线)等。
  • EEPROM驱动:提供了跨页读写、数据擦除等函数。由于EEPROM有擦写寿命限制(通常10万次),驱动层要设计合理的写均衡策略,或者由业务逻辑层来控制写入频率。
  • 按键驱动:采用扫描方式,实现按键消抖,并区分单击、长按等事件,以事件通知的形式上报给业务逻辑层。

这种架构的好处是高内聚、低耦合。例如,如果未来想把传感器从DHT11换成I2C接口的SHT30,你只需要替换或重写dht11.c/.h驱动文件,上层业务逻辑和数据展示界面几乎不需要改动。这种可移植性和可维护性,在真实的工程项目中至关重要。

4. 核心驱动实现与避坑细节

有了架构,我们来深入最核心、也是最容易出错的驱动层实现细节。这里以DHT11驱动按键驱动为例,分享几个关键的代码实现点和踩过的“坑”。

DHT11单总线通信详解DHT11的通信时序要求严苛,必须严格按照数据手册来。其一次完整的数据读取过程如下:

  1. 主机(MCU)发送开始信号:将数据线拉低至少18毫秒,然后拉高20-40微秒,等待传感器响应。
  2. 传感器响应:传感器接收到开始信号后,会将数据线拉低80微秒,再拉高80微秒,表示准备发送数据。
  3. 数据传输:随后传感器连续发送40位数据(16位湿度整数+16位湿度小数+16位温度整数+16位温度小数,实际DHT11小数部分为0)。每一位数据都以一个50微秒的低电平起始位开始,随后的高电平持续时间决定数据是0(26-28微秒)还是1(70微秒)。

这里最大的“坑”在于时序的精确控制。在STM32上,通常有两种实现方式:

  • 阻塞式延时:使用delay_us()函数进行微秒级延时。这种方式简单,但在延时期间CPU被完全占用,无法执行其他任务,不适合在实时操作系统中使用。
  • 定时器计时:配置一个基本定时器,在输入捕获模式下,捕获数据线电平变化的时间点,通过计算时间差来解码数据。这种方式更精确,不占用CPU,但实现稍复杂。

在竞赛的裸机环境下,我通常采用阻塞式延时,但会特别注意关闭中断,防止延时被中断打乱。下面是一个读取一位数据的函数示例:

// 假设数据线连接在GPIOB的PIN5上 #define DHT11_DATA_PIN GPIO_Pin_5 #define DHT11_DATA_PORT GPIOB uint8_t DHT11_ReadBit(void) { uint8_t bit_val = 0; while(GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == 1); // 等待低电平起始位结束 delay_us(40); // 延时40us,跳过起始位的低电平,并到达数据位判断点 if(GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == 1) { bit_val = 1; } while(GPIO_ReadInputDataBit(DHT11_DATA_PORT, DHT11_DATA_PIN) == 1); // 等待高电平结束 return bit_val; }

注意:delay_us(40)这个值非常关键。它需要大于数据“0”的高电平时间(26-28us),小于数据“1”的高电平时间(70us)。这样延时结束后再读取引脚电平,高即为1,低即为0。这个延时值需要根据你的系统主频和延时函数精度进行微调,最好用逻辑分析仪抓取波形来校准。

按键扫描与事件处理开发板通常有4个独立按键。简单的扫描是判断引脚电平,但为了稳定,必须加入消抖事件识别

typedef enum { KEY_EVENT_NONE, KEY_EVENT_SHORT_PRESS, KEY_EVENT_LONG_PRESS } KeyEvent_TypeDef; KeyEvent_TypeDef Key_Scan(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin) { static uint8_t key_state = 0; // 状态:0-释放,1-消抖中,2-按下确认,3-长按确认 static uint32_t press_tick = 0; // 按下时刻的tick值 uint32_t current_tick = GetSystemTick(); // 获取系统时间戳 if(GPIO_ReadInputDataBit(GPIOx, GPIO_Pin) == 0) { // 按键被按下(低电平有效) switch(key_state) { case 0: // 首次检测到按下,进入消抖状态 key_state = 1; press_tick = current_tick; break; case 1: // 消抖中 if(current_tick - press_tick > 20) { // 消抖时间20ms key_state = 2; // 确认为有效按下 } break; case 2: // 按下确认,等待释放或进入长按 if(current_tick - press_tick > 1000) { // 按下超过1秒 key_state = 3; return KEY_EVENT_LONG_PRESS; // 触发长按事件 } break; case 3: // 长按已触发,等待释放 break; } } else { // 按键释放 switch(key_state) { case 2: // 之前是短按确认状态 key_state = 0; return KEY_EVENT_SHORT_PRESS; // 触发短按事件 break; case 1: // 消抖期间释放,视为抖动,忽略 case 3: // 长按后释放 key_state = 0; break; } } return KEY_EVENT_NONE; }

这个状态机模型清晰地处理了消抖、短按和长按,并且将“事件”返回给上层,上层应用只需在循环中调用Key_Scan()并根据返回的事件执行相应功能(如切换界面、调整数值),实现了驱动与业务的解耦。

5. 应用层功能实现与UI交互逻辑

驱动稳定了,上层应用的构建就是搭积木。我们重点看数据显示菜单导航这两个核心交互。

实时数据显示优化在128x64的小屏幕上显示“温度:25.6°C 湿度:60.5%RH”,如果只用小字体,会显得不够直观。常见的优化方法是:

  1. 分区域显示:将屏幕划分为上下或左右区域。上半部分用大号字体(甚至自定义点阵)显示当前温湿度数值,下半部分用小字体显示历史曲线或状态信息。
  2. 使用图标:在数值旁边绘制一个小的温度计和水滴图标,增强识别度。
  3. 颜色/反显提示:虽然单色屏没有颜色,但可以通过反白显示(背景黑色,字体白色)来高亮报警数据。当温度或湿度超限时,对应的数值区域反白显示,并伴随闪烁效果,视觉警示非常有效。

绘制大字体通常需要取模软件。你可以用PC软件将需要的数字和单位符号生成对应的字模数组,存储在代码中。例如,一个32x48像素的数字“2”,就是一个长度为(32/8 * 48) = 192字节的数组。

多级菜单系统设计这是整个项目软件逻辑中最考验设计能力的部分。一个典型的菜单结构可能是:

  • 一级界面:主显示
  • 按“设置”键进入二级菜单:包含“阈值设置”、“记录间隔”、“屏幕设置”、“关于”
  • 在“阈值设置”项上按“确认”键进入三级菜单:分别设置“温度上限”、“温度下限”、“湿度上限”、“湿度下限”

我推荐使用状态机(State Machine)来管理菜单。为每个界面定义一个唯一的状态ID,并维护一个当前状态变量。

typedef enum { STATE_MAIN_DISPLAY, STATE_MENU_LIST, STATE_SET_TEMP_HIGH, STATE_SET_TEMP_LOW, // ... 其他状态 } SystemState_TypeDef; SystemState_TypeDef g_current_state = STATE_MAIN_DISPLAY; void System_ProcessKeyEvent(KeyEvent_TypeDef event, uint8_t key_id) { switch(g_current_state) { case STATE_MAIN_DISPLAY: if(event == KEY_EVENT_SHORT_PRESS && key_id == KEY_SET) { g_current_state = STATE_MENU_LIST; // 进入菜单列表 GUI_DrawMenuList(); // 重绘菜单 } break; case STATE_MENU_LIST: if(event == KEY_EVENT_SHORT_PRESS) { if(key_id == KEY_UP) { /* 光标上移 */ } else if(key_id == KEY_DOWN) { /* 光标下移 */ } else if(key_id == KEY_OK) { // 根据当前光标位置,进入不同的子设置状态 if(menu_cursor == 0) g_current_state = STATE_SET_TEMP_HIGH; // ... } else if(key_id == KEY_SET) { g_current_state = STATE_MAIN_DISPLAY; // 返回主界面 } } break; case STATE_SET_TEMP_HIGH: // 在这个状态下,UP/DOWN键用于增减数值,OK键保存并返回上一级,SET键取消 // ... break; // ... 处理其他状态 } }

主循环中,不断扫描按键,将产生的事件System_ProcessKeyEvent(),状态机根据当前状态和输入事件,决定下一个状态并执行相应的界面绘制和逻辑处理。这种方法逻辑清晰,易于扩展新的菜单项。

6. 数据存储方案与EEPROM高效使用

数据存储是让设备具备“记忆”功能的关键。开发板通常自带一片I2C接口的EEPROM(如AT24C02,256字节)。如何利用这有限的空间,稳定地存储历史数据和系统参数,需要精心设计。

参数存储系统参数(报警阈值、背光时间等)占用空间小,但需要频繁读写和修改。我们可以定义一个结构体来管理所有参数:

typedef struct { float temp_high_limit; float temp_low_limit; float humidity_high_limit; uint16_t log_interval_sec; uint8_t screen_timeout_min; uint8_t checksum; // 校验和 } SystemParams_TypeDef;

存储时,可以将整个结构体写入EEPROM中一个固定的起始地址(例如0x00)。校验和非常重要,它用于检测数据在EEPROM中是否因异常断电等原因被破坏。可以在每次写入前,计算结构体中所有数据的和(或CRC),存入checksum字段。读取时重新计算并比对,如果不一致,则使用默认参数。

历史数据存储历史数据量大,且是只增不减的。256字节的EEPROM可能只够存几十条记录(每条记录包含时间戳、温度、湿度)。这里的关键是循环存储索引管理

我们可以把EEPROM划分为一个环形缓冲区。定义一个固定的记录结构,并预留一个区域存储“写指针”。

  1. 初始化:读取“写指针”,如果发现是无效值(如0xFF),则将其初始化为0,表示从第一条记录开始写。
  2. 存储一条记录:将当前记录写入“写指针”指向的地址。然后更新“写指针”为下一个记录的起始地址。如果“写指针”到达缓冲区末尾,则绕回开头(地址0)。最后,将新的“写指针”值存回固定的索引存储区。
  3. 读取历史数据:要读取最近N条记录,需要从“写指针”的前一个位置开始逆向读取,并处理回绕到缓冲区末尾的情况。这需要一点简单的地址运算。

注意:EEPROM有写寿命(约10万次)。频繁地更新“写指针”本身也会消耗寿命。一个优化技巧是:不要每次存数据都更新索引。可以设定存满10条记录,或者每隔一段时间,才将当前的“写指针”更新到索引区。虽然意外断电可能会丢失最近几条未提交索引的记录,但保证了索引区的寿命,在竞赛场景下是可行的权衡。

7. 系统整合、调试与性能优化

当所有模块都准备好后,将它们整合到一个流畅运行的系统里,并确保稳定可靠,是最后也是最考验人的一步。

主循环与任务调度在裸机系统中,主循环while(1)是核心调度器。一个结构清晰的主循环应该像这样:

int main(void) { // 1. 硬件初始化 System_Init(); // 时钟、中断 GPIO_Init(); LCD_Init(); DHT11_Init(); EEPROM_Init(); Timer_Init(); // 用于产生系统tick和定时采样 // 从EEPROM加载参数和上次的历史数据索引 // 2. 主循环 while(1) { // 2.1 按键扫描与处理(最高优先级,保证响应) KeyEvent_TypeDef evt = Key_Scan(KEY1_PORT, KEY1_PIN); if(evt != KEY_EVENT_NONE) System_ProcessKeyEvent(evt, KEY_ID_1); // ... 扫描其他按键 // 2.2 定时任务检查(如每秒检查一次) if(GetSystemTick() - last_check_tick > 1000) { last_check_tick = GetSystemTick(); // 检查是否到达数据存储时间 // 更新屏幕上的时钟(如果有) // 检查背光超时 } // 2.3 传感器数据采集(由定时器中断触发标志位,非阻塞式) if(dht11_data_ready_flag) { dht11_data_ready_flag = 0; float temp, humi; if(DHT11_ReadData(&temp, &humi) == SUCCESS) { // 更新显示 GUI_UpdateCurrentData(temp, humi); // 报警判断 Alarm_Check(temp, humi); // 更新历史数据缓冲区(不一定立即存EEPROM) History_AddRecord(temp, humi); } } // 2.4 其他低优先级任务... // 例如:缓慢的动画效果、蜂鸣器报警音控制(用状态机管理)等 } }

调试技巧与常见问题

  1. 传感器读值异常:首先用逻辑分析仪或示波器抓取数据线波形,对照DHT11时序图,检查MCU发出的开始信号和传感器返回的数据波形是否符合规范。最常见的问题是延时函数不准确。
  2. 屏幕显示乱码或花屏:检查LCD初始化序列是否正确,特别是复位时序。确保每次发送命令或数据前,已经完成了上一次的传输。如果局部花屏,可能是帧缓存数组越界。
  3. 按键不灵敏或连击:调整按键消抖的时间阈值(通常15-30ms)。检查硬件上拉电阻是否接好。长按检测的时间阈值(如1秒)也需要根据实际手感调整。
  4. EEPROM读写失败:检查I2C总线地址是否正确(AT24C02通常是0xA0)。注意I2C通信的时钟速度不能太快(通常用100kHz或400kHz)。写入后需要等待几毫秒的写入周期(delay_ms(5))再进行下一次操作。

性能与资源优化

  • 减少全局变量:合理使用静态局部变量和函数参数,减少全局变量的使用,提高代码可读性和可重入性。
  • 优化显示刷新:不要每次循环都全屏刷新。只刷新数据变化的区域。例如,只有温度值变化了,才重绘温度数字所在的那一小块区域。
  • 使用查表法:对于频繁使用的数据,如字模、菜单字符串,使用const数组存储在Flash中,而非在运行时计算。
  • 合理利用中断:将传感器读取的严格时序部分放在定时器中断中完成,或者使用外部中断来检测传感器响应,可以解放主循环。

回顾整个项目,从解读需求到硬件选型,从模块设计到代码实现,再到最后的调试整合,每一步都充满了嵌入式开发特有的挑战与乐趣。这个“温湿度监控设备”麻雀虽小,五脏俱全,它强迫你去思考系统架构、时序精度、资源管理和用户体验。我个人的体会是,把这样一个完整的项目吃透,远比做十个零散的小实验收获更大。它让你建立起一个完整的“项目观”,知道如何将书本上的知识点串联起来解决实际问题。如果你正在学习STM32或准备嵌入式竞赛,不妨按照这个思路,亲手实现一遍,过程中遇到的每一个问题和解法,都会成为你宝贵的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询