简介:基于STM32U575RIT6的智能除湿器项目源码,面向嵌入式开发者和智能家居应用场景,提供一套不依赖阿里云、可本地运行的除湿器控制方案。工程覆盖时钟与中断初始化、湿度传感器ADC采集、阈值比较与除湿启停控制、LCD显示及用户交互,并利用STM32U5的安全特性增强可靠性,整体设计适合学习Cortex-M33平台从底层驱动到业务逻辑的开发流程。压缩包共216个文件,以C源文件、头文件以及STM32 HAL库外设驱动为主,包含LCD屏显驱动和定时器、ADC、I2C、UART、DMA等HAL模块,另含Keil工程配置及Hex/Axf等编译产物,包体约3.04MB。目前已有277人学习下载。通过该工程,可掌握STM32U5系列常用外设的配置方法,理解传感器数据从采集、处理、判定到执行控制的完整链路,也能借鉴无云依赖的本地化交互设计,便于后续扩展蓝牙或Wi-Fi控制,对于中高级嵌入式开发者有较高参考价值。
1. 无云除湿器为什么选 U575RIT6:本地闭环才是可靠性的大头
梅雨季的衣柜里湿度冲到 82%RH,除湿机转了一整夜,早晨开门还是一股霉味。问题往往不在机器功率,而在控制逻辑:目标湿度定了 50%,机器要么一口气除到 45% 还在跑,要么停在 51% 就停机,反复启停之后压缩机寿命和体验一起垮掉。基于 STM32U575RIT6 的智能除湿器,而且是没有阿里云版本的那一版,恰恰把最值钱的部分留在了本地——采样、判断、输出全部由 MCU 独立完成。选 U575RIT6 不是追求新潮,是因为 Cortex-M33 主频 160MHz 的同时还保留着极低功耗,2MB Flash 足够放下 OLED 字库、运行日志和参数掉电存储,整机不依赖网络也能形成一个完整的闭环控制系统。这套做法适合做小家电、衣柜与鞋柜除湿、暖通周边设备的工程师,也适合想把手头“联网演示样机”改造成“离线也稳产品”的团队。
2. 先把工程骨架立起来:CubeMX 配置、传感器读取与主循环
2.1 硬件选型先把执行器想清楚:帕尔贴方案和压缩机方案差在哪
智能除湿器的执行器决定整个硬件架构。压缩机除湿量大、单位能耗高,适合 20㎡ 以上空间,但它有启动冲击、需要继电器驱动、停机后必须延时三分钟才能重新启动,控制逻辑里要额外做“强制等待”。桌面型和衣柜型除湿器更常用的做法是半导体制冷片加风机,也就是帕尔贴方案,冷端凝水、热端散热,整机安静、无启动冲击,控制起来简单得多。我一般会优先选帕尔贴方案,把继电器那套东西省掉,用一个 GPIO 控制 TEC 通断、一个 PWM 控制风机转速就够了。
湿度传感器方面,常见做法是 SHT30 或 AHT20 这类 I2C 数字传感器,而不是 DHT11。DHT11 用 GPIO 模拟时序,采样周期长、湿度一高就漂,拿到 128%RH 这种近乎玄学的读数是常有的事,闭环控制根本没法用。SHT30 自带 CRC 校验,标称精度 ±2%RH,对除湿器这种慢变化系统来说绰绰有余。OLED 显示屏用 0.96 寸 SSD1306,同样走 I2C,和 SHT30 挂在同一条 I2C 总线上,两个器件地址分别是 0x3C 和 0x44,不存在冲突。
按键安排三颗:开关键、加键、减键。加键和减键负责调节目标湿度,开关键负责启动和停止,部分版本还可以通过长按进入设置页。帕尔贴冷端需要贴一枚 NTC 温度传感器,用来检测冷端是否低于露点温度,防止结冰——这一点在后面避坑章节会展开讲。
2.2 CubeMX 配置清单:时钟、I2C、低功耗选项别省
U575RIT6 的 CubeMX 配置里,最容易被忽略的是时钟树的低功耗模式选择。除湿器大部分时间工作在“除湿-停止-除湿”的间歇状态,停止时整机应该进入 STOP 模式,只保留 RTC 和按键唤醒。我的做法是:LSE 接 32.768kHz 晶振给 RTC 用,系统时钟用 MSI 自动校准,运行时不追求 160MHz 满速,控制在 64MHz 左右,I2C 和 PWM 外设时钟够用就行。这样可以显著降低运行功耗,同时保证 CPU 有余量处理 OLED 刷新和 PID 计算。
I2C1 配置成 400kHz 快速模式,这里有个容易翻车的点:STM32CubeMX 里填了 400000 不代表时序一定对,U5 系列 I2C 的 TimingR 寄存器需要按总线上拉电阻和负载电容计算上升沿时间。常规做法是先用 CubeMX 自动计算,再把 rise time 设置得保守一点,比如默认计算值再乘 1.5,给线缆和接插件留出余量。否则在低温环境或长排线场景下,I2C 偶发 ACK 失败会让你排查到怀疑人生。
除此之外,TIM2 输出 PWM 驱动风扇,TIM3 可以用来做按键扫描的时基,或者直接用 SysTick。ADC1 分配两个通道,一个接 NTC 冷端温度,一个接热端温度。Flash 的 EEPROM 模拟区在配置里预留出来,后面第 4 章掉电记忆要写目标湿度参数。调试接口保留 SWD,别为了省功耗把调试口全关了,否则量产阶段出问题没法救。
2.3 SHT30 读取与 OLED 驱动:最小可用的传感器代码
SHT30 的读取命令和数据处理是智能除湿器最基础的代码。SHT30 支持单次测量和周期测量两种模式,除湿器这种慢变化场景用单次测量就够了。单次测量命令是 0x2C 加测量命令字,其中 0x06 表示中重复性,测量时间大约 4.5 毫秒,读取一次温湿度大约需要 6 个字节返回数据。
/* sht30.c - 基于 I2C1 读取 SHT30 温湿度,带 CRC 校验 */ #define SHT30_ADDR 0x44 #define I2C_TIMEOUT 50 HAL_StatusTypeDef sht30_read(float *temp, float *hum) { uint8_t cmd[2] = {0x2C, 0x06}; /* 单次测量,中重复性 */ uint8_t buf[6] = {0}; if (HAL_I2C_Master_Transmit(&hi2c1, (SHT30_ADDR << 1) | 0, cmd, 2, I2C_TIMEOUT) != HAL_OK) { return HAL_ERROR; } HAL_Delay(20); /* 留足转换时间 */ if (HAL_I2C_Master_Receive(&hi2c1, (SHT30_ADDR << 1) | 1, buf, 6, I2C_TIMEOUT) != HAL_OK) { return HAL_ERROR; } /* 校验字节分别为 buf[2] 和 buf[5],此处省略 CRC8 实现 */ uint16_t rawT = ((uint16_t)buf[0] << 8) | buf[1]; uint16_t rawH = ((uint16_t)buf[3] << 8) | buf[4]; *temp = -45.0f + 175.0f * (float)rawT / 65535.0f; *hum = 100.0f * (float)rawH / 65535.0f; return HAL_OK; }这段代码的逻辑是:I2C 发送测量命令后延时 20 毫秒,确保转换完成;再读取 6 字节数据,前两个字节是温度原始值,中间两个字节是湿度原始值,最后是各一个 CRC 校验字节。真实项目中必须把 CRC 校验加上,否则偶尔读到坏数据会直接驱动除湿器误动作。参数上需要注意:SHT30 地址是 7 位地址 0x44,HAL 库的地址参数需要左移一位,这是初学者最容易踩的坑。
OLED 驱动同样走 I2C,SSD1306 的写命令和写数据用控制字节区分,0x00 表示后续是命令,0x40 表示后续是数据。除湿器界面不需要复杂的图形渲染,只需要显示温湿度、目标湿度和运行状态,一个精简驱动就够用。
/* oled_min.c - 极简 SSD1306 I2C 写入 */ static void oled_write(uint8_t control, uint8_t *data, uint16_t len) { uint8_t buf[len + 1]; buf[0] = control; /* 0x00 命令,0x40 数据 */ memcpy(&buf[1], data, len); HAL_I2C_Master_Transmit(&hi2c1, (0x3C << 1) | 0, buf, len + 1, 50); } void oled_show_humidity(uint8_t hum, uint8_t target) { char text[16]; uint8_t page_buf[128]; /* 把 sprintf 结果按 8x16 点阵查表转成 page buffer,这里省略取模过程 */ sprintf(text, "HUM:%d%% TGT:%d%%", hum, target); oled_write(0x00, init_cmd, sizeof(init_cmd)); oled_write(0x40, page_buf, sizeof(page_buf)); }OLED 刷屏不需要太频繁,500 毫秒刷一次就够了,刷太快反而影响 I2C 总线上 SHT30 的读取时序。显示数字时用 sprintf 生成字符串然后查点阵表,注意 SSD1306 的 page 寻址方式,写一屏需要按 8 个 page 逐页写入,中间不要跨页。
2.4 主循环框架:无 RTOS 也可以,但一定要有“低功耗意识”
除湿器是否需要 RTOS?我的习惯是不需要。整个系统只有湿度采集、按键扫描、OLED 刷新、控制输出几个任务,前后台大循环完全能覆盖,用 RTOS 反而增加调试复杂度。但循环里必须留出休眠入口,否则 STOP 模式形同虚设。
/* main.c - 无 RTOS 主循环,按时间片调度 */ int main(void) { uint32_t tick_100ms = 0, tick_500ms = 0, tick_1s = 0; float temp = 0, hum = 0; uint8_t target = 55; target = mem_load_target(); /* 掉电记忆读目标湿度 */ while (1) { key_scan(); /* 按键扫描与防抖,10ms 周期 */ if (HAL_GetTick() >= tick_100ms) { tick_100ms = HAL_GetTick() + 100; if (sht30_read(&temp, &hum) == HAL_OK) { hum = hum_filter(hum); /* 滑动平均滤波 */ control_update(hum, &target); } } if (HAL_GetTick() >= tick_500ms) { tick_500ms = HAL_GetTick() + 500; oled_show_humidity(hum, target); } if (HAL_GetTick() >= tick_1s) { tick_1s = HAL_GetTick() + 1000; mem_periodic_save(); /* 周期落盘,防止频繁擦写 Flash */ } /* 无任务时进入 STOP 模式,RTC 或按键 EXTI 唤醒 */ HAL_PWR_EnterSTOPMode(0, 1); SystemClock_Reconfig(); /* 唤醒后重建时钟 */ } }这个循环的核心思想是时间片轮转,100 毫秒做一次采样与控制,500 毫秒刷新一次显示,1 秒做一次参数的周期落盘。进入 STOP 模式前要确保 I2C 总线处于空闲状态,否则唤醒后 I2C 可能直接挂死。唤醒后第一件事是重建时钟,U5 从 STOP 模式唤醒后 MSI 时钟需要重新配置,漏掉这一步系统会跑在未知频率上,串口打印和定时器全部错乱——这个问题在避坑章节会再提。
3. 目标湿度控制怎么做:滞回 + PID 边界的完整实现
3.1 为什么小功率除湿器不适合纯 PID,滞回区间才是主力
网上很多智能除湿器教程上来就写 PID,但实际上帕尔贴除湿器的被控对象非常不友好。第一,湿度传感器读数本身有波动,在 50% 附近来回跳;第二,除湿过程是热力学过程,制冷片从启动到冷端达到露点需要几十秒,系统存在很大的纯滞后;第三,除湿器工作环境的密闭程度不稳定,开门、人体呼吸都会让湿度突变。
纯 PID 在这些因素叠加下会表现出积分饱和、微分冲击,最终结果就是制冷片频繁启停,凝露都还没来得及形成就被关掉了,除湿效率极低。我实际项目里的做法是“滞回区间为主,PID 作为边界修正”,只看长期趋势,不被短时波动带偏。
滞回控制的思路很简单:目标湿度 55%,滞回宽度 5%,那么湿度上升到 60% 时启动除湿,下降到 50% 时停止。启动阈值和停止阈值分开,中间 55% 到 60% 这段区间机器维持上次状态,这样就避免了在目标值附近反复横跳。
/* control.c - 滞回控制,状态机输出 */ typedef struct { uint8_t target; /* 目标湿度 40~70% */ uint8_t hyst; /* 滞回宽度,默认 5 */ uint8_t state; /* 0=停止 1=除湿 */ } hyst_ctrl_t; void hyst_update(hyst_ctrl_t *c, uint8_t hum) { if (c->state == 0 && hum >= (uint8_t)(c->target + c->hyst)) { c->state = 1; /* 超过上限,启动除湿 */ } if (c->state == 1 && hum <= (uint8_t)(c->target - c->hyst)) { c->state = 0; /* 到达下限,停止除湿 */ } }这段代码的判断逻辑是:停止状态下只关注上限,湿度超过 target + hyst 才启动;除湿状态下只关注下限,湿度降到 target - hyst 才停止。参数上需要注意 target 和 hyst 都是整数,hyst 取 4 到 6 比较合适,太小会导致启停频繁,太大则湿度的波动幅度让用户体感不好。配合 100 毫秒采样周期,这个状态机天然带上了滤波效果。
3.2 PID 边界补偿:只在误差方向明确时介入
滞回控制已经很稳了,为什么还需要 PID?因为滞回控制没有“强度”概念,一旦启动就是满功率,湿度从 70% 降到 60% 和从 61% 降到 60% 都是同一个输出。在空间较大或密闭性差的环境里,需要让除湿速度随误差变化,这就是 PID 边界补偿的价值。
我的方案是:误差大于滞回宽度时,PID 输出直接限幅到 100,全速除湿;误差进入滞回区间时,PID 输出线性减小,让制冷片在低功率下工作;误差小于 2% 时输出强制为 0,避免在目标值附近微调导致温度波动。
/* control.c - PID 边界补偿,输出范围 -100~100 */ typedef struct { float kp, ki, kd; float integral, last_err; uint32_t last_time; } pid_t; int16_t pid_update(pid_t *p, int16_t err, uint32_t now_ms) { float dt = (float)(now_ms - p->last_time) / 1000.0f; if (dt > 1.0f) dt = 1.0f; /* 防止卡死导致的微分爆炸 */ p->last_time = now_ms; p->integral += err * dt; if (p->integral > 80.0f) p->integral = 80.0f; if (p->integral < -80.0f) p->integral = -80.0f; float d = (err - p->last_err) / dt; p->last_err = err; float out = p->kp * err + p->ki * p->integral + p->kd * d; if (out > 100) out = 100; if (out < -100) out = -100; return (int16_t)out; }参数标定方面,我第一版用的是 kp=1.2、ki=0.05、kd=0.3,dt 固定为 0.1 秒。积分限幅 80 是必须的,否则长时间开门导致湿度暴涨时,积分会积累到一个很大的值,门关掉后系统要花很久才能恢复。微分项在湿度传感器本身有噪声时会放大抖动,所以先在湿度数据上做滑动平均滤波,再做微分。
边界补偿合入最终控制的代码:
/* control.c - 滞回状态机 + PID 补偿合成最终输出 */ void control_update(uint8_t hum, uint8_t target) { static hyst_ctrl_t hyst = {55, 5, 0}; static pid_t pid = {1.2f, 0.05f, 0.3f, 0, 0, 0}; int16_t err = (int16_t)hum - (int16_t)target; uint8_t tec_pwm = 0; hyst.target = target; hyst_update(&hyst, hum); if (hyst.state == 1) { int16_t pid_out = pid_update(&pid, err, HAL_GetTick()); if (err > 5) { tec_pwm = 100; /* 湿度远超目标,全速 */ } else if (err < -2) { tec_pwm = 0; /* 已到目标附近,停机 */ } else { tec_pwm = (uint8_t)(pid_out + 100) / 2; } } actuator_set_pwm(tec_pwm); }这里有个容易被忽视的逻辑:PID 在滞回状态机启动后才介入,停止状态下 PID 输出强制为 0,同时要清掉积分项。不清积分的话,下次启动时残留积分会让输出跳变,系统会有一次明显的“冲过头”。我习惯在 state 从 0 变 1 时调用 pid.ip 清零函数。
3.3 帕尔贴驱动与风机联动,停机后记得吹干
帕尔贴制冷片不适合用 PWM 连续调节功率,因为工作在非额定电流下时热端散热不足,制冷效率反而下降,甚至可能热倒灌。所以常见做法是“通断控制”,即满功率运行一段时间,通过控制运行/停止的比例来等效调节除湿能力。这里的 PWM 控制的是继电器或 MOS 管的开断,周期 10 秒,占空比对应除湿强度。
风机则不一样,风机需要连续调速。湿度高时全速送风,湿度接近目标时降速到 40%,一方面降低噪音,另一方面减小风速对传感器读数的扰动。
/* actuator.c - 帕尔贴与风机联动 */ void actuator_set(int16_t pwm, uint8_t mode) { /* mode 0=停止,1=除湿运行,2=吹干模式 */ if (mode == 0) { HAL_GPIO_WritePin(TEC_GPIO_Port, TEC_Pin, 0); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0); fan_duty = 0; } else if (mode == 1) { HAL_GPIO_WritePin(TEC_GPIO_Port, TEC_Pin, 1); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pwm); fan_duty = (pwm > 50) ? 100 : 40; /* 高负载全速,低负载低速 */ } else if (mode == 2) { HAL_GPIO_WritePin(TEC_GPIO_Port, TEC_Pin, 0); /* 吹干时不制冷 */ __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 100); } }吹干模式是很容易被忽略的细节。帕尔贴冷端在停机后表面还挂着冷凝水,如果不吹干,两三天后就会长出霉斑,用户一打开除湿器闻到的不是清新而是霉味。我一般会在每次除湿停止后强制风机全速转 60 秒,吹干冷端凝水,然后再进入待机状态。这个细节做好了,整机口碑会有明显提升。
4. 去云端之后的交互层:OLED 菜单、按键状态机与掉电记忆
4.1 OLED 菜单组织:主页面、设置页、系统页三层就够了
去掉云平台之后,原本要放到手机 App 里的目标湿度设置、运行模式选择、设备状态查看,全部要落到本地 OLED 和按键上。菜单层级不能设计得太深,不然用户每次调节目标湿度要点五六下,体验会很差。我一般把界面压成三个页面:主页面显示当前温湿度和目标湿度,设置页调节目标湿度和运行模式,系统页显示累计运行时间和传感器异常次数。
页面切换采用“循环翻页 + 停留超时自动返回主页”的策略。用户在设置页停留超过 10 秒没有按键操作,强制跳回主页面,防止用户调完湿度忘在设置页,第二天看到屏幕停留在奇怪界面上。
/* menu.c - OLED 页面状态机 */ #define PAGE_HOME 0 #define PAGE_TARGET 1 #define PAGE_MODE 2 #define PAGE_SYSTEM 3 static uint8_t page = PAGE_HOME; static uint32_t last_key_time = 0; void menu_on_key(uint8_t key) { last_key_time = HAL_GetTick(); switch (page) { case PAGE_HOME: if (key == KEY_MODE) page = PAGE_TARGET; break; case PAGE_TARGET: if (key == KEY_MODE) page = PAGE_MODE; if (key == KEY_UP) target_hum = MIN(70, target_hum + 1); if (key == KEY_DOWN) target_hum = MAX(40, target_hum - 1); break; case PAGE_MODE: if (key == KEY_MODE) page = PAGE_SYSTEM; if (key == KEY_UP || key == KEY_DOWN) work_mode ^= 1; break; case PAGE_SYSTEM: if (key == KEY_MODE) page = PAGE_HOME; break; } }这里按键的处理方式是短按一次响应一次,没有做长按连续加减,避免用户按键卡住时目标湿度一路冲到最大值。目标湿度范围限制在 40% 到 70%,40% 以下对帕尔贴除湿器来说很难达到,70% 以上则失去了除湿意义。每次按键修改目标后,标记一个“待存储”的脏标志,而不是立即写入 Flash。
菜单渲染和按键扫描在两个不同的时间片里跑,按键扫描 10 毫秒一次,菜单渲染跟随 OLED 刷新 500 毫秒一次。渲染函数只负责把当前 page 对应的内容画出来,不参与逻辑修改,这样即使渲染卡顿也不会丢按键事件。
4.2 按键扫描:防抖、边沿检测和长按识别一次说清
按键扫描在除湿器里看似简单,但做不好会出现“按一次跳两格”“长按变成连续触发”这类翻车现场。机械按键的抖动时间通常在 5 到 15 毫秒,扫描周期取 10 毫秒时,需要用超过一个周期的连续读值来确认稳定电平。我用的是移位滤波法,连续三次读到同一电平才认为按键状态稳定。
/* key.c - 移位滤波 + 边沿检测 */ #define KEY_UP_PIN GPIO_PIN_0 #define KEY_DOWN_PIN GPIO_PIN_1 #define KEY_MODE_PIN GPIO_PIN_2 typedef struct { GPIO_TypeDef *port; uint16_t pin; uint8_t state; /* 稳定后的电平,1=按下 */ uint8_t filt_reg; /* 移位滤波寄存器 */ } key_t; void key_scan(void) { uint8_t level, i; for (i = 0; i < 3; i++) { level = (HAL_GPIO_ReadPin(keys[i].port, keys[i].pin) == GPIO_PIN_RESET) ? 1 : 0; keys[i].filt_reg = (keys[i].filt_reg << 1) | level; if ((keys[i].filt_reg & 0x07) == 0x07) { if (keys[i].state == 0) { keys[i].state = 1; menu_on_key(i); /* 按下瞬间触发一次 */ } } else if ((keys[i].filt_reg & 0x07) == 0x00) { keys[i].state = 0; } } }扫描逻辑里值得说明的是:三个按键共用一个 10 毫秒定时中断,每次进入中断把三个按键的电平读一遍。只有当连续三次读到的低电平才会触发一次按键事件,松开时同样要连续三次读到高电平才确认释放,这样抖动窗口内的毛刺不会产生多余触发。长按识别没有在这个函数里做,因为除湿器的需求场景里,短按调节已经足够,长按功能容易误触。
按键事件处理上还有个细节:按键中断只置标志位,不在中断里做菜单切换、Flash 写入这类耗时操作。菜单处理放在主循环时间片里执行,避免按键中断打断 I2C 传输导致数据错乱。这个看起来很简单的规则,能避免掉一大部分诡异 bug。
4.3 Flash 掉电记忆:目标湿度不能靠用户每次重设
除湿器断电后重新上电,目标湿度必须恢复上次设定的值,这是产品级体验的基本要求。U575RIT6 的 2MB Flash 可以模拟 EEPROM,不需要外挂存储芯片。但直接往 Flash 里写数据有很多坑,最大的坑是擦写寿命和擦写时机。
STM32U5 的 Flash 擦除最小单位是页,2MB Flash 按 4KB 一页计算,如果用户每按一次按键就写一次 Flash,按每天操作 20 次计算,几千次擦写后这一页就磨损了。所以我的策略是:按键修改目标湿度只更新 RAM 中的变量,标一个脏标志,当满足以下任一条件时才真正写入 Flash:一是机器检测到关机信号进入待机流程时;二是每 5 分钟周期性写一次;三是用户连续快速调节结束后 30 秒无新操作。
读取侧则简单得多,上电时直接从固定地址读取,如果读到的值超出了传感器的合理范围,比如湿度目标大于 70% 或小于 40%,说明 Flash 内容是无效数据,回退到默认值 55%。
/* mem.c - 内部 Flash 模拟 EEPROM */ #define MEM_ADDRESS 0x080FF000 /* 最后一页,避开代码区 */ #define MEM_TARGET_OFFSET 0 #define MEM_CRC_OFFSET 4 uint8_t mem_load_target(void) { uint32_t page_addr = MEM_ADDRESS; uint32_t valid = 0, crc = 0; page_addr += MEM_CRC_OFFSET; crc = *(uint32_t *)page_addr; page_addr -= MEM_CRC_OFFSET; valid = *(uint32_t *)page_addr; if (valid >= 40 && valid <= 70 && crc == mem_crc32(&valid, 4)) { return (uint8_t)valid; } return 55; } void mem_save_target(uint8_t target) { FLASH_EraseInitTypeDef erase = {0}; uint32_t page_error = 0, data = target; uint32_t crc = mem_crc32(&data, 4); HAL_FLASH_Unlock(); __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS); erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.Page = MEM_ADDRESS; erase.NbPages = 1; HAL_FLASHEx_Erase(&erase, &page_error); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, MEM_ADDRESS, data); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, MEM_ADDRESS + MEM_CRC_OFFSET, crc); HAL_FLASH_Lock(); }这段代码里有一个关键点:把目标湿度和 CRC 校验值分两个 4 字节字存储,页内偏移分别是 0 和 4。写入顺序是先写数据后写 CRC,读取时先读 CRC 再读数据,这样如果写入过程中掉电,读到的一定是旧数据加旧 CRC,或者新数据加旧 CRC,两种情况都能被校验揪出来。如果反过来先写 CRC 后写数据,掉电瞬间可能出现新 CRC 配旧数据的脏组合,校验通过但数据错误。
5. 调试避坑与排查:湿度跳动、凝露结冰、I2C 挂死与唤醒异常
5.1 湿度值偶尔跳到 99% 或 0%,OLED 上数字“神一会鬼一会”
现象:除湿器运行一段时间后,OLED 上湿度值毫无规律地出现 99% 或 0%,持续一两个采样周期又恢复正常。用户开了几小时除湿机,显示湿度像抽风一样跳变。
原因:第一,SHT30 的 CRC 校验没有做,I2C 传输偶尔受干扰时拿到错误字节,直接把错值当真实湿度显示;第二,SHT30 没有做数据合理性判断,物理上不可能在 10 秒内从 50% 跳到 99% 再跳回 50%;第三,OLED 和 SHT30 共用 I2C 总线,OLED 刷屏期间 SHT30 的读取被延迟,传感器测量本身的时序余量不足。
解决:在 sht30_read 函数里补上 CRC8 校验,校验失败返回 HAL_ERROR,丢弃本次数据不进入控制逻辑。然后对湿度数据做一阶低通滤波,截止频率设 0.1Hz,用hum = 0.7 * hum_last + 0.3 * hum_new这种形式,这样即使偶尔混入一个坏数据,对输出的影响也被吸收掉。补上校验和滤波之后,湿度显示稳定,除湿器启停次数也明显下降,这一步血泪经验确实值得记录。
5.2 帕尔贴冷端结冰,除湿器变成“制冰机”
现象:连续运行两三个小时后,帕尔贴冷端出现白色冰霜,除湿效率骤降,风扇吹出的风也有了酸味,停机后冰块融化在机器内部,地面一滩水。
原因:帕尔贴冷端温度降到了冰点以下,空气中的水蒸气在冷端结霜而不是凝露,霜层隔热后制冷片继续工作,热端热量散不出去,冷端越结越厚。核心原因是没有对冷端温度做保护,制冷片满功率运行不休息。
解决:在冷端贴 NTC 温度传感器,纳入控制逻辑。当冷端温度低于 8°C 时,降低制冷片的运行占空比,从 100% 降到 50%;温度继续降到 5°C 以下,直接关闭制冷片,只让风机继续吹。这样使冷端温度保持在露点之上、冰点之下,凝露持续产生但不会结冰。加热丝方案也能解决,但增加功耗和成本,对桌面除湿器来说,NTC 降占空比是最经济的做法。
补充一点:半导体制冷片的热端散热效率下降也会导致冷端过冷,所以要监测热端温度,超过 50°C 时同样要降功率。热端散热器积灰是长期的隐患,吹干模式和定期提醒用户清洗散热器,能减少这类返修。
5.3 I2C 总线挂死,整机只有断电重启才能恢复
现象:除湿器运行几天后,OLED 和湿度显示全部停止刷新,按按键无反应,看门狗复位后恢复,但隔几小时又复现。串口打印看到 HAL_I2C_Master_Transmit 一直返回 HAL_BUSY。
原因:I2C 从设备在某些异常时序下会拉低 SCL 或 SDA,导致总线进入 BUSY 状态。HAL 库的 I2C 驱动在检测到 BUSY 时只会返回错误,不会主动释放总线。常见触发条件是:I2C 传输过程中系统进入了 STOP 模式,总线传输被中断;或者 OLED 刷新和 SHT30 读取在时间上重叠,两次传输之间间隔太短,从设备还没释放总线。
解决:给 I2C 总线加软件恢复机制。在检测到 HAL_BUSY 时,先把 SCL 引脚手动拉出 9 个脉冲,让从设备复位内部状态,然后重新初始化 I2C 外设。同时确保主循环在进入 STOP 模式前,确认没有未完成的 I2C 传输。
/* i2c_recover.c - I2C 总线异常恢复 */ void i2c_bus_recover(void) { GPIO_InitTypeDef gpio = {0}; HAL_I2C_DeInit(&hi2c1); /* 把 SCL 和 SDA 配成开漏输出,手动拉 9 个时钟脉冲 */ gpio.Pin = I2C1_SCL_PIN | I2C1_SDA_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(I2C1_SCL_PORT, &gpio); for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(I2C1_SCL_PORT, I2C1_SCL_PIN, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(I2C1_SCL_PORT, I2C1_SCL_PIN, GPIO_PIN_SET); HAL_Delay(1); } /* 恢复 I2C 外设配置 */ MX_I2C1_Init(); }调用时机上,我建议在主循环每次 I2C 操作失败后连续重试三次,三次都失败才执行总线恢复。不要在第一次失败就立刻恢复,因为从设备可能只是在忙,马上就能恢复通信。总线恢复期间要暂停 OLED 刷新,避免两个任务同时操作 GPIO 引脚。
5.4 湿度到目标后停机,但停几分钟湿度又回升,控制反复“抽搐”
现象:除湿器运行一段时间后湿度降到目标值附近,机器停机。停机不到五分钟,显示的湿度又回升到超过启动阈值,机器再次启动。如此反复,压缩机或帕尔贴的启停次数明显增加。
原因:湿度回升不一定是真实的环境湿度变化,很可能是传感器位置离出风口太近。停机后机器内部残留的湿气还在缓慢蒸发,或者出风口附近的微环境湿度回升,传感器感知到的是局部小环境的湿度,而不是整个空间的平均湿度。另外,滞回区间设得太窄,比如只有 2%,也给这种局部波动留了触发空间。
解决:把传感器位置移到远离出风口和冷端的进风侧,让传感器测量的是环境吸入空气的湿度,而不是机器排出的湿气。滞回区间加宽到 5% 左右,同时增加“最小停机时间”逻辑:除湿器停止后 30 秒内即使湿度再次超过启动阈值,也不允许立即启动。这个最小间隔对帕尔贴尤其重要,因为制冷片需要时间让冷端重新降温,频繁启停会加速器件老化。
5.5 低功耗唤醒后系统跑飞,看门狗频繁复位
现象:除湿器进入待机模式后,按按键有时能唤醒,有时直接复位,看门狗日志显示复位原因是低功耗唤醒后的异常。
原因:从 STOP 模式唤醒后,系统时钟需要重新配置。U575RIT6 的 MSI 在高频段唤醒后不会自动恢复到之前的状态,如果不重新调用 SystemClock_Config,外设时钟频率可能不对,串口波特率、定时器周期、I2C 时序全部混乱。另外,唤醒事件标志没有清除,下次进入 STOP 模式时会立即被同一个事件唤醒,形成不断唤醒的死循环。
解决:在每次HAL_PWR_EnterSTOPMode之后,立即重新配置系统时钟,并清除 PWR 唤醒标志。同时,进入 STOP 模式前关闭所有不必要的外设中断,只保留按键 EXTI 和 RTC 闹钟唤醒源。调试阶段我用了一个辅助手段:每次唤醒后在 Flash 里写一条日志,记录唤醒原因,这样能区分是 EXTI 唤醒、RTC 唤醒还是复位后的首次启动。
/* power.c - 安全进入与退出 STOP 模式 */ void system_enter_stop(void) { /* 确保 I2C 总线空闲 */ while (HAL_I2C_IsDeviceReady(&hi2c1, 0x44, 5, 10) != HAL_OK) { i2c_bus_recover(); } HAL_SuspendTick(); HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI); /* 唤醒后第一件事:重建时钟 */ SystemClock_Config(); HAL_ResumeTick(); __HAL_PWR_CLEAR_FLAG(PWR_FLAG_WU); }这里有一个在实际调试中让我花掉不少时间的细节:进入 STOP 模式前必须挂起 SysTick。HAL 库的延时函数依赖 SysTick,而 SysTick 在 STOP 模式下会停止,唤醒后如果没有恢复,所有 HAL_Delay 都会卡死。另外,如果调试器在低功耗唤醒后连接不上芯片,检查是否只有 SWDIO 和 SWCLK 两个引脚没被封住——有些低功耗代码会把调试端口也配置成 GPIO,导致开发工具直接离线,只能按复位键重连,这种情况下可以通过先用 BOOT0 拉高进入系统 Bootloader 的方式救回来。
6. 整机验证与更耐用的技巧:本地日志、72 小时长稳与功耗测量
无云版本虽然没有远程日志上报,但本地日志必须保留。我在 Flash 里划分了专门的一页做环形日志,按时间戳记录关键事件:启动、停机、传感器异常、I2C 恢复、目标湿度修改。日志用环形覆盖的方式,每 10 分钟最多写一条,异常时刻立即插写。这个日志在售后阶段价值极高,用户反馈“除湿机半夜自己停了”,连上调试器读日志,马上能看到是冷端过温保护触发还是 I2C 挂死恢复。没有日志的产品,售后排查全靠猜,成本完全不是一个量级。
验证方法上,我习惯按下面的测试表逐项过测。每项都有明确的通过标准,不能只看“能不能运行”,要看“边界条件下是否稳定”:
| 测试项 | 方法 | 通过标准 |
|---|---|---|
| 湿度阶跃响应 | 放入 90%RH 密封箱,设目标 50% | 60 分钟内降至 55% 以下,无大于 8% 的过冲 |
| 凝露结冰保护 | 持续运行 12 小时,监控冷端 NTC | 冷端温度不低于 3°C |
| 低功耗待机 | 待机模式串电流计测量 | 整机电流小于 2mA |
| 掉电记忆 | 运行中随机断电 10 次 | 每次上电目标湿度不丢 |
| 长稳运行 | 连续运行 72 小时 | 无 I2C 卡死、无看门狗复位 |
| 按键防抖 | 快速连按 50 次加减键 | 数值变化不超过 50 次,无跳变 |
功耗测量有一个容易测错的地方:不要直接串联万用表测整机,因为除湿器运行时的电流是安培级,万用表量程不够,而且运行/待机切换瞬间的电流尖峰会打掉保险丝。我一般把系统分成两路供电,控制板一路用微安表测待机电流,帕尔贴和风机一路单独测运行电流。控制板设计时就要想到这一点,在 PCB 上留跳线位置,把数字地和功率地分开,最后单点连接。
量产阶段还有一个技巧值得做:出厂整机测试时,在 Flash 里写入一个出厂校准标志和测试日期。用户使用过程中如果出现异常,售后读到的日志里能同时看到“出厂测试日期”和“最近一次异常时间”,能快速判断是出厂不良还是使用环境问题。这个小功能代码量不到 20 行,但能显著减少售后沟通的来回扯皮。
我在这类项目上最大的教训是:不要为了“看起来智能”而把控制逻辑做得太复杂。某次我在湿度控制里加了模糊推理,结果难调参、难复现,最后全部删掉,换成滞回加简单 PID,效果反而更稳。硬件成本不变,真正拉开产品差距的是长期运行不抽风、不掉参数、不结冰这些基础能力。希望这篇笔记能帮你把除湿器的本地闭环一次做扎实,少走我这轮调参的弯路。
本文还有配套的精品资源,点击获取