☰
基于STM32自制除湿器:从DHT11驱动到迟滞控制完整方案
2026/10/14 17:37:51 网站建设 项目流程

简介:这款基于STM32设计的除湿器源码包,适合嵌入式学习者、竞赛备赛者及家电控制开发人员,用于理解湿度采集、控制算法与实际产品设计的融合。包内共212个文件,以.h头文件、.c源文件、.o/.axf编译输出及.uvprojx工程文件为主,完整呈现基于STM32G0系列的HAL库驱动、LCD显示与ADC采样等模块结构,压缩包约10.15MB,整体结构清晰,便于快速下载与对照研读。已有489人学习。源码涵盖GPIO、定时器、PWM、ADC等外设初始化,以及基于湿度传感器的闭环控制逻辑、除湿器工作模式切换与参数设置,以维持目标湿度稳定;还可看到用户按键、显示屏状态刷新等交互设计,部分任务体现RTOS调度思想,能帮助开发者从驱动层面一路追踪到应用层,系统性掌握STM32嵌入式项目从工程配置到调试的完整脉络。

1. 基于STM32自己做除湿器:一个值得跑通的完整嵌入式项目

基于STM32自己做一台除湿器,听起来像是有现成方案可以直接抄,但真动手你会发现核心难点不在代码量,而在传感器时序、控制策略和电源这三个互相牵连的问题上。这篇文章讲的是一套完整的STM32除湿器设计方案,附可直接参考的源码逻辑,适合正在做智能家居项目或者实验室环境控制的人。

它不是让你去买一台成品除湿机拆开研究,而是用一块最小系统板加湿度传感器、半导体致冷片,自己搭出能根据环境湿度自动通断的设备,把传感器驱动、控制逻辑、掉电存储、电源设计这几个嵌入式核心环节一次跑通。项目做完,你会对单总线时序、迟滞控制、Flash读写这些平时只在文档里看到的概念有真正的体感。

文章按选型、源码、工程化、避坑、验证的顺序展开,每一步都有能直接落地的代码和参数。已经在跑但卡在传感器读不到数据、继电器乱跳、湿度不准这几个问题上的人,可以直接跳去第5章对照排查。

2. 硬件选型与电路设计:主控、传感器和除湿机构怎么定

2.1 STM32F103C8T6与外设分配:一张引脚表讲清楚

做除湿器这类控制项目,主控不需要很强的算力,但外设要够用、资料要够多。STM32F103C8T6是Cortex-M3内核,72MHz主频,64KB Flash、20KB RAM。跑DHT11时序、OLED刷新、继电器控制加一块都绰绰有余,Flash空间放HAL库和应用代码也够。最关键的是这个型号的例程数量在STM32家族里是最多的,遇到问题基本搜索引擎都能找到相似案例。

我的外设分配长期固定如下,直接照抄即可:

外设引脚用途
GPIO输出PA1控制继电器(经三极管/MOSFET)
GPIO输入PA2DHT11数据线
I2C1PB8 / PB9SSD1306 OLED屏
GPIO输入PA0 / PA3按键:模式切换、湿度加
备份寄存器或Flash0x0800FC00保存目标湿度配置
IWDG内部LSI独立看门狗

引脚规划上有一个容易忽视的点:PA1和PA2不要挨着布置在同一个排针座上。继电器开关瞬间的电流变化会通过排针耦合到DHT11的数据线上,导致传感器读数偶发跳变。如果你用洞洞板飞线,这两根线尽量分开走,不要平行贴在一起。

电源设计比引脚规划更容易翻车。Peltier半导体制冷片12V供电,工作电流4到6A,STM32是3.3V供电。如果你用一个线性稳压器同时给主控和制冷片供电,稳压器会烫到没法碰,压差大、电流大的场合线性稳压的效率低得可怜。常见做法是双电源:12V直接给制冷片和风扇,经过DC-DC降压模块到5V,再经AMS1117-3.3给主控板。继电器触点侧的12V走线和逻辑侧的3.3V走线在PCB上要分开,留足爬电距离,不然打火和干扰会一起找上门。

2.2 湿度传感器选型:DHT11、SHT30还是模拟模块

传感器选型决定你后面写代码的难度和控制精度,这是整个项目最值得先想清楚的一步。市面上百元以内能买到的湿度传感器大致分三类。

DHT11:单总线数字输出,精度±5%RH,量程20%到90%RH。优点是便宜,几块钱一颗,驱动逻辑简单,数据直接是数字量不用查表。缺点是精度一般,响应慢,采样周期至少1到2秒,而且单总线时序要求严格,读取期间被打断就容易失败。

SHT30:I2C接口,精度±2%RH,带CRC校验,尺寸小。价格十几块到几十块不等。用HAL库的I2C读写很顺手,不需要像DHT11那样精确卡微秒级时序,代码健壮性明显高一个档次。

模拟湿度模块:靠湿敏电阻分压或NE555振荡电路输出频率,需要ADC采集加查表换算,标定麻烦,不建议新手从这套起步。

我的建议很直接:做要长期稳定运行的除湿器,选SHT30,多花的十几块换来的是精度和代码省心。但网上流传的例程和源码大部分还是DHT11版本,而且DHT11的驱动写法是锻炼时序处理能力的好教材,所以这篇文章以DHT11为主线讲解。选DHT11的话,把读取失败重试逻辑写好,可靠性也能接受,只是预期的控制精度要放低一些。

2.3 除湿执行机构:Peltier制冷片、继电器驱动与电源

DIY除湿器最常见的执行机构是Peltier半导体制冷片。一块12V供电的制冷片,一面制冷一面发热,空气经过冷面时水蒸气凝结成水滴,流进集水盒。这个方案结构简单、没有压缩机、噪音小,制冷片加散热器加风扇总共三四十块成本。缺点也很明显:除湿量很小,适合衣柜、鞋柜、小型密封箱,指望它给卧室除湿不现实。

驱动电路是这里最容易出问题的地方。STM32的GPIO最大输出电流20mA左右,继电器线圈吸合需要30到70mA,直接接GPIO会烧引脚。标准做法是用一颗NPN三极管或者N-MOSFET做开关,我常用的拓扑是:

STM32 PA1 --[1kΩ电阻]-- NPN三极管基极 三极管发射极 -- GND 三极管集电极 -- 继电器线圈一端 继电器线圈另一端 -- 12V 线圈两端并联1N4007二极管(反向续流) 继电器触点 -- 串联在Peltier供电回路中

基极电阻不能省,它把GPIO电流限制在安全范围。线圈两端反并联的续流二极管更是必须的——没有它,继电器断开瞬间线圈产生的反向电动势可能直接击穿三极管。如果用MOSFET替代三极管,栅极要加10kΩ下拉电阻到地,防止上电瞬间栅极悬空导致执行器误启动。这个细节后面第5章还会专门讲。

3. 源码实现:DHT11驱动、迟滞控制和显示按键

3.1 DHT11驱动代码:单总线时序、微秒延时和校验位

DHT11单总线协议的关键是严格的时间配合。MCU先拉低数据线至少18ms发起始信号,然后拉高20到40us;DHT11收到后拉低80us作为应答,再拉高80us;之后连续输出40位数据,每一位都以50us低电平开头,随后高电平持续26到28us表示0,持续70us表示1。

40位数据依次是:湿度整数、湿度小数、温度整数、温度小数、校验和。校验和等于前四个字节之和的低8位。校验一定要写,因为DHT11在时序抖动时经常出现数据错位,没有校验你拿到的就是一堆乱数据。

下面是常用的DHT11驱动核心代码,基于HAL库:

// dht11.c - 基于HAL库的DHT11读取 #include "dht11.h" #include "delay_us.h" #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_1 static GPIO_InitTypeDef GPIO_InitStruct = {0}; // 切换GPIO为推挽输出模式 static void DHT11_Set_Output(void) { GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); } // 切换GPIO为浮空输入模式 static void DHT11_Set_Input(void) { GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_NOPULL; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); } // 读取一次DHT11数据,成功返回0,失败返回1 // humidity: 湿度值(0~100%) temperature: 温度值(0~50℃) uint8_t DHT11_Read(uint8_t *humidity, uint8_t *temperature) { uint8_t data[5] = {0}; uint32_t timeout = 0; // 1. 发送起始信号:拉低18ms以上 DHT11_Set_Output(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 2. 拉高20~40us,释放总线 HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); Delay_us(30); // 3. 切输入模式,等待DHT11应答 DHT11_Set_Input(); // 等待低电平(应答信号开始) timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) if (++timeout > 1000) return 1; // 超时约1ms // 等待高电平(应答信号结束) timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET) if (++timeout > 1000) return 1; // 等待第一位数据前的低电平 timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) if (++timeout > 1000) return 1; // 4. 读取40位数据 for (int i = 0; i < 40; i++) { // 每一位都是以50us低电平开始 timeout = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET) if (++timeout > 1000) return 1; // 高电平持续时间:26~28us为0,70us为1 uint32_t high_time = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { if (++high_time > 1000) return 1; Delay_us(1); } data[i / 8] <<= 1; if (high_time > 40) data[i / 8] |= 1; // 阈值取40us左右 } // 5. 校验:前4字节之和低8位应等于第5字节 if ((uint8_t)(data[0] + data[1] + data[2] + data[3]) != data[4]) return 1; *humidity = data[0]; // 湿度整数部分 *temperature = data[2]; // 温度整数部分 return 0; }

这段代码有两个关键参数。第一,起始信号低电平用HAL_Delay(20),DHT11要求至少18ms,给20ms留了余量;拉高后必须用Delay_us(30)而不能用HAL_Delay,因为HAL_Delay最短单位是1ms,根本表达不了30us这个量级。第二,判断0和1的阈值取40us,不能设在26us或70us附近,DHT11本身时序误差很大,取中间值才有容错空间。

Delay_us的实现不能写成空循环,编译器开优化后空循环可能被直接优化掉。我一般用DWT计数器实现,精度高且不受中断影响:

// delay_us.h - 基于DWT的微秒延时 #ifndef __DELAY_US_H #define __DELAY_US_H #include "stm32f1xx_hal.h" static inline void Delay_us(uint32_t us) { if (us == 0) return; // 使能DWT计数器 CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; uint32_t ticks = us * (SystemCoreClock / 1000000U); while (DWT->CYCCNT < ticks); } #endif

DWT方案依赖SystemCoreClock正确配置。如果你改了系统时钟但没更新系统时钟变量,这个延时就会整体偏移,DHT11会疯狂超时。这个坑后面第5章会再提。

3.2 迟滞控制算法:除湿器继电器不再频繁通断的核心代码

很多第一次做除湿器的人,控制逻辑写得很简单:湿度大于目标值就开机,小于就关机。跑起来你会发现继电器在目标值附近疯狂抖动——传感器有噪声、湿度本身在小范围波动,一旦湿度在目标值上下震荡,继电器可能几秒就通断一次。继电器触点寿命是有限的,常见型号标称10万次动作,更严重的是Peltier制冷片频繁冷热冲击会加速老化。

正确的做法是迟滞控制,也叫滞回控制。设定一个目标湿度和一个迟滞带,湿度超过目标加迟滞才开机,低于目标减迟滞才关机,在迟滞带内保持原状态。

// dehumidifier_control.c - 迟滞控制核心逻辑 #include "dehumidifier_control.h" typedef struct { uint8_t target_humidity; // 目标湿度,单位% uint8_t hysteresis; // 迟滞宽度,单位% uint8_t current_humidity; // 当前湿度 uint8_t relay_on; // 当前继电器状态:1=开启 0=关闭 } Dehumidifier_t; Dehumidifier_t dehumidifier = { .target_humidity = 55, .hysteresis = 3, .current_humidity = 0, .relay_on = 0 }; // 每秒调用一次,内部根据当前湿度决定继电器动作 void Dehumidifier_Task(void) { if (dehumidifier.relay_on) { // 当前是开启状态:只有降到目标-迟滞以下才关闭 if (dehumidifier.current_humidity <= dehumidifier.target_humidity - dehumidifier.hysteresis) { Relay_SetOff(); dehumidifier.relay_on = 0; } } else { // 当前是关闭状态:只有升到目标+迟滞以上才开启 if (dehumidifier.current_humidity >= dehumidifier.target_humidity + dehumidifier.hysteresis) { Relay_SetOn(); dehumidifier.relay_on = 1; } } }

这个逻辑的精髓是当前状态决定比较方向:开着的时候只关心下限,关着的时候只关心上限。迟滞宽度设3%是我针对DHT11常用的值,因为DHT11精度只有±5%,太小会抖动,太大会让湿度超调过多。用SHT30的话迟滞可以缩到2%。

另外一个隐藏好处:这个状态机天然形成了启动间隔保护。关机后湿度必须重新升高超过迟滞带才会再次开机,不会出现刚关机又立刻启动的情况。如果你用的是压缩机式除湿机构,启动间隔还要拉长,压缩机频繁启停是大忌。

3.3 OLED显示与按键扫描:SSD1306和下降沿检测的配合

除湿器再小也得有显示和设置功能,不然每次调目标湿度都要重新烧程序。我用0.96寸SSD1306 OLED加两个按键,实现三行显示和湿度加减。

SSD1306的驱动代码网上有大量现成版本,这里只提两个和本项目强相关的点。第一,I2C地址默认是0x78,有些屏是0x7A,上电前确认模块背面的地址电阻配置。驱动里定义了错误地址,结果是初始化成功但写内容全无反应,排查起来很迷惑。第二,OLED有内部显存,哪个区域变了就只更新哪个区域,不要每次全屏刷新,不然I2C流量大,屏幕还会闪。

按键处理我推荐"定时扫描加边沿检测"的方式,10ms扫描一次,检测下降沿作为按键事件。不能用简单延时消抖,因为主循环里还跑着DHT11读取和OLED刷新,任何阻塞延时都会让按键反应变卡。正确做法是在定时器中断里做扫描,用标志位通知主循环:

// key.c - 10ms定时器中断里调用的按键扫描 #define KEY_MODE_PORT GPIOA #define KEY_MODE_PIN GPIO_PIN_0 #define KEY_UP_PORT GPIOA #define KEY_UP_PIN GPIO_PIN_3 volatile uint8_t key_mode_pressed = 0; // 模式键按下标志 volatile uint8_t key_up_pressed = 0; // 加湿键按下标志 void Key_Scan(void) // 每10ms调用一次 { static uint8_t last_mode_level = 1; static uint8_t last_up_level = 1; uint8_t level = HAL_GPIO_ReadPin(KEY_MODE_PORT, KEY_MODE_PIN); if (level == 0 && last_mode_level == 1) key_mode_pressed = 1; // 下降沿 last_mode_level = level; level = HAL_GPIO_ReadPin(KEY_UP_PORT, KEY_UP_PIN); if (level == 0 && last_up_level == 1) key_up_pressed = 1; last_up_level = level; }

按键标志位由主循环消费,每次只处理一次:

// main.c 主循环中的按键处理 if (key_mode_pressed) { key_mode_pressed = 0; // 短按模式键:在显示模式和设置模式间切换 ui_mode = (ui_mode == UI_MODE_DISPLAY) ? UI_MODE_SETTING : UI_MODE_DISPLAY; } if (key_up_pressed) { key_up_pressed = 0; if (dehumidifier.target_humidity < 80) dehumidifier.target_humidity++; // 目标湿度上限80% SaveConfigToFlash(); // 掉电保存 }

边沿检测的关键是只比较当前电平和上一个周期的电平,只有从高到低的跳变才算一次按下。如果你直接判断"电平为0就执行",按住按键时这个动作会重复执行几十次,配合OLED刷新会乱成一团。

4. 掉电保存与看门狗:断电重启后除湿器还能按原配置干活

4.1 目标湿度掉电保存:Flash擦写注意点与代码

目标湿度这种用户设置,掉电不能丢。STM32F103C8T6的Flash有64KB,地址从0x08000000开始,对中容量型号每页1KB。我的做法是把配置结构体写到最后一页0x0800FC00,应用代码编译出来一般到不了这么靠后的位置,永远不会覆盖到配置页。

写Flash有三个必须记住的点。第一,写之前必须先擦除整页,Flash不能像RAM那样直接改写某个字节。第二,写入操作期间CPU不能从Flash取指,所以擦写函数本身如果在Flash里执行会有风险,HAL库的函数内部已经做了处理,但你自己写的代码要注意不要在擦写期间调用其他Flash里的复杂函数。第三,配置写入后要读回校验,防止写入中途掉电导致配置损坏。

// flash_config.c - 配置的保存与读取 #include "flash_config.h" #include "dehumidifier_control.h" #define CONFIG_ADDR 0x0800FC00 // 最后一页起始地址 typedef struct { uint8_t target_humidity; uint8_t hysteresis; uint8_t magic; // 魔术字节,用于判断配置是否有效 } AppConfig_t; AppConfig_t g_config = {0}; // 保存配置到Flash uint8_t SaveConfigToFlash(void) { AppConfig_t config = { .target_humidity = dehumidifier.target_humidity, .hysteresis = dehumidifier.hysteresis, .magic = 0xA5 }; HAL_FLASH_Unlock(); // 解锁Flash // 擦除配置页(1KB) FLASH_EraseInitTypeDef erase = { .TypeErase = FLASH_TYPEERASE_PAGES, .PageAddress = CONFIG_ADDR, .NbPages = 1 }; uint32_t page_error = 0; if (HAL_FLASHEx_Erase(&erase, &page_error) != HAL_OK) { HAL_FLASH_Lock(); return 1; } // 写入配置结构体(按16位半字写入) uint16_t *p = (uint16_t *)&config; for (int i = 0; i < sizeof(AppConfig_t) / 2; i++) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, CONFIG_ADDR + i * 2, p[i]) != HAL_OK) { HAL_FLASH_Lock(); return 1; } } HAL_FLASH_Lock(); return 0; } // 上电时读取配置 void LoadConfigFromFlash(void) { AppConfig_t *cfg = (AppConfig_t *)CONFIG_ADDR; if (cfg->magic == 0xA5) // 魔术字节正确,说明上次保存过 { dehumidifier.target_humidity = cfg->target_humidity; dehumidifier.hysteresis = cfg->hysteresis; } else { // 首次上电或配置损坏,使用默认值 dehumidifier.target_humidity = 55; dehumidifier.hysteresis = 3; } }

这里有两个现实中的坑。一个是C语言结构体对齐:如果结构体里有uint16_t或uint32_t成员,编译器会插入填充字节,结构体大小可能比成员之和多出几个字节。我上面的结构体成员全是uint8_t,正好3字节,但写入时按半字写入第2次就跨越了字节边界,HAL_FLASH_Program要求地址半字对齐,所以实际项目中我会加__attribute__((packed))强制紧凑排布,并补一个保留字段把结构体凑成偶数字节。第二个坑是Flash擦写寿命在1万次左右,如果每次按键都保存一次,寿命很快会耗尽。我通常的做法是"退出设置模式时保存一次",而不是每次按键都写Flash。

4.2 独立看门狗:DHT11读取卡死时除湿器如何自恢复

DHT11时序读取要求严格,一旦程序里出现死循环,比如某个while等待条件永远不满足,或者中断优先级配置不当导致主循环饿死,整个除湿器就卡死了。最坏情况是继电器卡在开启状态,制冷片一直工作,集水盒满了溢出。独立看门狗IWDG由LSI低速内部时钟驱动,即使主时钟故障也能工作,非常适合兜底。

// iwdg.c - 独立看门狗初始化与喂狗 void IWDG_Init(void) { // LSI时钟约40kHz,分频64,重装载值625 // 超时时间 = 625 * 64 / 40000 ≈ 1秒 IWDG->KR = 0x5555; // 解锁写保护 IWDG->PR = 0x03; // 预分频:64分频 IWDG->RLR = 625; // 重装载值 IWDG->KR = 0xCCCC; // 启动看门狗 } void IWDG_Feed(void) // 喂狗 { IWDG->KR = 0xAAAA; }

喂狗的位置有讲究。放在主循环开头是最常见的做法,但如果某个任务因为传感器没插好而阻塞超时,主循环根本走不到喂狗那行,系统会复位重启。复位后再次读取又超时,又复位,形成复位循环。这样至少系统能恢复,不会一直保持错误状态。另一种做法是放在每个任务函数结尾:DHT11读取函数、OLED刷新函数、控制任务各喂一次,能精确定位是哪个任务卡住。调试期我用后者,量产时改成主循环喂狗。

另外一个容易忽略的坑:HAL_Delay的实现基于SysTick中断,如果上层代码关闭了全局中断,HAL_Delay会永远卡死。看门狗能兜底,但调试时你会非常困惑,因为程序总是莫名其妙复位。项目里所有长时间阻塞的位置都要检查是否有全局中断关闭操作。

5. 避坑指南:STM32除湿器从能跑到稳定的5个踩坑记录

5.1 DHT11读数一直为0或固定值:GPIO模式与延时的坑

现象:代码编译烧录后,OLED上湿度显示0%,温度显示0℃,或者每次都是同一个固定值。用示波器看数据线波形是正常的,程序却读不对。

原因有三层。第一层是GPIO输入模式配置错误:DHT11数据线靠外部上拉电阻维持高电平,如果你把输入模式配成GPIO_PULLDOWN,内部下拉电阻把电平拉低,应答信号判断全反了。第二层是微秒延时不准:空循环被编译器优化后,读到的时序全部错位。第三层是中断干扰:DHT11读取期间来了定时器中断,中断处理超过几十微秒,位判断就会出错。

解决:输入模式改回GPIO_NOPULL并确认外部有4.7kΩ上拉电阻到3.3V;Delay_us改用DWT实现;DHT11读取函数放到临界区执行,或者读数据前关中断读完后开中断。按这个顺序排查,大部分读不到数据的问题都能解决。

5.2 继电器频繁通断,制冷片一个小时就异常

现象:湿度在目标值附近时,继电器快速通断,声音密集。几个小时后制冷片制冷效果明显下降,甚至继电器触点烧黑。

原因:没有迟滞控制,或迟滞宽度设得太小。DHT11本身有±5%误差,加上1秒采集周期,湿度数据在目标值附近抖动是常态。迟滞低于2%一定会抖动。

解决:用第3.2节的迟滞控制,DHT11至少设3%,SHT30可以缩到2%。同时增加启动间隔保护,继电器关闭后强制冷却30秒才允许再次开启:

// 启动间隔保护:关闭后至少冷却30秒 if (dehumidifier.relay_on == 0 && cooldown_timer > 0) { cooldown_timer--; return; // 冷却期间不执行任何开启判断 } // 在Relay_SetOff()时设置 cooldown_timer = 30;

这个软保护对半导体制冷片和压缩机的寿命都非常重要。

5.3 OLED花屏与显示错乱:I2C上拉和刷新策略

现象:OLED上电显示正常,运行几分钟后花屏、乱码,或者屏幕亮度不均、有残影。

原因:花屏的首要原因是I2C总线上没有上拉电阻。STM32的I2C引脚是开漏输出,必须有外部上拉才能输出高电平。很多最小系统板在PB6/PB7或PB8/PB9上已经带了上拉,但如果你用的是其他引脚复用I2C,就没有上拉,总线信号混乱,数据自然乱。第二个原因是刷新策略不对:SSD1306全屏刷新要传几KB数据,在400kHz I2C速率下要几十毫秒,频繁整屏刷新会挤占DHT11的时序。

解决:确认I2C引脚上已经接了4.7kΩ上拉电阻。刷新改成区域更新,湿度区、目标值区、状态区分开,只有数值变化才更新对应区域,没变化不刷屏。这样I2C流量降一个量级,画面也不闪。

5.4 湿度测不准:传感器放错位置才是最大误差源

现象:除湿器运行一小时后,OLED显示湿度60%,旁边放一个独立湿度计测出50%,差了10%。把传感器重新插拔后读数又变了。

原因:传感器位置问题。把DHT11放在制冷片冷面旁边,局部湿度会非常低;放在出风口,被吹过的空气湿度也偏低;放在外壳角落,空气不流通,微环境湿度偏高。此外DHT11自发热也会让读数偏高。

解决:传感器放在能代表环境湿度的位置,我通常固定在进气口上方5到10厘米处,不要和外壳之间形成密闭腔体,有条件就在传感器附近开通风孔。调试时把参考湿度计放在传感器正旁边比对,而不是放在房间中间——两者的读数差异会误导你判断传感器好坏。

5.5 上电瞬间执行器误启动:GPIO默认电平的坑

现象:每次上电,风扇都会转一下然后停住,即使目标湿度远低于当前湿度,理论上不该启动。用示波器看PA1引脚,上电瞬间有一个短暂的高电平脉冲。

原因:STM32的GPIO复位后默认是浮空输入状态,引脚电平由外部电路决定。如果继电器驱动是"高电平开启"的拓扑,PA1在浮空状态下可能被外部噪声拉高,三极管导通一瞬间,继电器吸合又释放。

解决:给PA1加一个10kΩ下拉电阻到地,确保GPIO还没初始化前引脚电平确定为低。同时初始化顺序上,main函数开头先把PA1配成推挽输出并写低电平,再初始化其他外设:

void GPIO_Init_WithSafety(void) { __HAL_RCC_GPIOA_CLK_ENABLE(); // 先配置继电器控制引脚并拉低,再配置其他 GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_1; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &gpio); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); // 再初始化传感器、I2C、按键等其他外设 }

如果你用的是低电平开启的继电器模块,则反着来,上电时先确认引脚为高电平。原则就一条:执行器控制引脚在上电瞬间的默认状态必须对应"执行器关闭"。

6. 验证校准与进阶用法:从能跑到好用

6.1 湿度校准:双点标定和偏移补偿

DHT11出厂标定精度是±5%RH,但每一颗的误差方向是固定的。你可以做一次简单的双点标定:用饱和盐水密封容器制造约75%RH的稳定环境,用干燥剂制造约20%RH的环境,记录DHT11实际读数,算出偏移量。

// calibrate.c - 单点偏移补偿 int16_t humidity_offset = 0; // 标定后的偏移量 void Calibrate_Start(void) { // 让设备在稳定环境中运行30分钟 // 读取100次DHT11湿度,取平均 int32_t sum = 0; uint8_t h = 0, t = 0; for (int i = 0; i < 100; i++) { if (DHT11_Read(&h, &t) == 0) sum += h; HAL_Delay(50); } int16_t avg = sum / 100; // 假设参考湿度计在这个环境里的读数是61% humidity_offset = 61 - avg; }

注意单点偏移只对某一湿度区间有效,DHT11在20%到90%全量程的误差不是线性关系。误差要求高于1%的应用请直接换SHT30,DHT11不适合做精密控制。

6.2 采样周期、低功耗与串口调试

除湿器的控制频率不需要很高。典型设计里,DHT11每2秒读一次,控制任务每1秒跑一次,OLED每5秒刷新一次。三个周期分开,代码结构更清晰,执行器也不会因为采样过快而频繁动作。电池供电场景下,采样间隔可以拉到10秒,两次读取之间让主控进入待机模式,实测可以把平均功耗从几十毫安降到几毫安。降低采样频率还有额外收益:DHT11连续上电工作时自发热会抬高读数,低频采样能减小这个误差。

最后建议给项目加一个串口输出,把湿度、温度、目标值、继电器状态、DHT11读取失败次数通过USART1每隔5秒打印一次。这一步调试价值巨大,你可以用串口助手记录数据画出湿度曲线,直观看出迟滞控制效果和传感器异常规律。

我的习惯是在项目里保留一个调试模块,里面放一个状态打印函数,所有关键事件——开机、传感器读取失败、继电器动作、配置保存——都打一行带时间戳的日志。开发板上的代码转到自制PCB上出问题时,这些日志就是最快的定位手段。这个习惯帮我省掉了无数次拿示波器到处戳的麻烦。以上这套做完,你的STM32除湿器才真正从能跑变成了可靠,希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询