相信不少做过嵌入式课设或实际小项目的朋友都有过这种体会:功能需求其实不复杂,但把传感器、电机、显示、控制逻辑全部整合到一块 STM32 上时,硬件接线、驱动移植、状态切换、调试排错很容易把人绕晕。尤其是“自动收衣服”这种带实时判断的场景,既要处理传感器数据,又要控制电机动作,还要兼顾手动和自动两种模式,网上资料又比较零散。
这篇文章就围绕“基于 STM32F103 的智能晾衣架控制系统”展开,从系统需求、硬件选型、CubeMX 初始化、模块驱动到完整控制逻辑,一步步讲清楚。代码会按模块拆分,方便直接复制到工程里验证。无论你是做课程设计、电子竞赛,还是想自己动手做一个阳台晾衣架控制器,这篇文章都能提供一个完整的参考。
1. 项目背景与功能概述
1.1 为什么需要智能晾衣架
传统的晾衣架核心操作就是“手动伸出、手动收回”。天气突变时,如果家里没人,衣服很容易被雨淋湿;白天出门上班,衣服晒在外面又怕突然刮风、下雨。即使人在家里,也可能因为忙于其他事情而忘记收衣服。
智能晾衣架要解决的核心问题,就是把“晾晒”这件事从人工判断变成自动决策。系统通过传感器感知当前是否下雨、光照是否充足、环境温湿度是否适合晾晒,然后自动控制电机把晾衣杆伸出或收回。同时还保留手动控制能力,方便用户随时干预。
从这个角度看,智能晾衣架并不需要多高深的人工智能,它本质上是“传感器数据采集 + 逻辑判断 + 电机驱动”的典型嵌入式控制系统。
1.2 功能需求分析
一个完整的智能晾衣架控制系统,通常包含以下功能:
- 自动模式:根据雨滴传感器、光敏传感器、温湿度传感器的数据,自动判断是否伸出晾衣杆或收回晾衣杆。
- 手动模式:通过按键主动控制晾衣杆伸出、收回、停止。
- 状态显示:实时显示当前工作模式、传感器数值、电机状态,方便调试和确认。
- 声光提示:在模式切换、动作执行、异常报警时,通过蜂鸣器或 LED 提示用户。
- 安全保护:电机运行到极限位置时停止,避免机械结构损坏;预留限位开关接口。
1.3 技术选型
主控选用 STM32F103C8T6,这是意法半导体推出的基于 ARM Cortex-M3 内核的经典芯片,主频最高 72MHz,拥有丰富的 GPIO、定时器、ADC、USART、I2C、SPI 外设。它价格便宜、资料多、上手门槛低,非常适合这类中小型控制系统。
传感器方面:
- 雨滴传感器模块,输出模拟量和数字量,用于检测是否下雨。
- 光敏电阻模块,输出模拟量,用于检测光照强度。
- DHT11 温湿度传感器,单总线协议,用于获取环境温湿度。
- LCD1602 液晶屏,用于显示数据和状态。
- 直流减速电机与 L298N 驱动模块,用于带动晾衣杆伸缩。
2. 系统总体设计与硬件选型
2.1 系统整体架构
整个系统可以划分为四层:
- 感知层:雨滴传感器、光敏传感器、DHT11 温湿度传感器。
- 主控层:STM32F103C8T6,负责数据采集、状态判断、电机控制逻辑。
- 执行层:L298N 电机驱动模块、直流减速电机、蜂鸣器、LED 指示灯。
- 交互层:LCD1602 显示屏、独立按键。
数据流向如下:
传感器把物理量转换成电信号,STM32 通过 ADC 或 GPIO 读取数据;主控根据预设的阈值和当前工作模式,决定是伸出还是收回晾衣杆;电机驱动模块接收控制信号后驱动直流电机正转或反转;同时 LCD 实时刷新显示内容,蜂鸣器在动作完成或异常时提醒用户。
2.2 硬件器件清单
| 器件名称 | 型号/规格 | 数量 | 说明 |
|---|---|---|---|
| STM32 最小系统板 | STM32F103C8T6 | 1 | 主控核心板 |
| 雨滴传感器模块 | LM393 比较器输出 | 1 | 检测雨水,模拟量+数字量 |
| 光敏电阻模块 | 4 针输出 | 1 | 检测环境光照强度 |
| 温湿度传感器 | DHT11 | 1 | 单总线数字温湿度 |
| LCD 液晶屏 | LCD1602 + IIC 转接板 | 1 | 显示模式与状态 |
| 电机驱动模块 | L298N | 1 | 驱动直流电机正反转 |
| 直流减速电机 | 3V-6V 减速电机 | 1 | 带动晾衣杆伸缩 |
| 独立按键 | 轻触按键 | 3 | 手动控制 |
| 蜂鸣器 | 有源蜂鸣器 | 1 | 声音提示 |
| 电源 | 5V/2A 适配器 | 1 | 系统供电 |
这里说明一下,LCD1602 常见有直接并口和 I2C 转接板两种。为了节省 GPIO,推荐使用 I2C 转接板版本,只占用 SCL 和 SDA 两根线。下面代码也按照 I2C LCD1602 编写。
2.3 引脚分配规划
引脚分配直接影响代码编写和接线,建议先在表格里规划好。
| 功能 | 引脚 | 外设/说明 |
|---|---|---|
| 雨滴传感器 AO | PA0 | ADC1_IN0,读取模拟量 |
| 光敏电阻 AO | PA1 | ADC1_IN1,读取模拟量 |
| DHT11 数据 | PA4 | GPIO 输出/输入切换,模拟单总线 |
| L298N IN1 | PA6 | GPIO 输出,电机正转 |
| L298N IN2 | PA7 | GPIO 输出,电机反转 |
| L298N ENA | PB0 | TIM3_CH3,PWM 调速 |
| I2C LCD SCL | PB8 | I2C1_SCL |
| I2C LCD SDA | PB9 | I2C1_SDA |
| 蜂鸣器 | PB5 | GPIO 输出 |
| 手动/自动切换按键 | PB3 | GPIO 输入,下拉/外部中断 |
| 伸出按键 | PB4 | GPIO 输入 |
| 收回/停止按键 | PB6 | GPIO 输入 |
| 板载 LED | PC13 | 状态指示 |
需要注意,PB3 和 PB4 默认是 JTAG 引脚(JTDO、JNTRST),如果要在普通 GPIO 模式下使用,必须在 STM32CubeMX 中将调试接口配置为 Serial Wire,否则这两个引脚无法作为普通输入使用。这也是新手比较容易踩的坑。
3. 开发环境准备与工程配置
3.1 开发工具说明
本项目使用 ST 官方推出的 STM32CubeMX 进行初始化配置,它可以自动生成底层初始化代码,包括时钟树、GPIO、ADC、定时器、I2C 等外设配置,大大减少手写寄存器或繁琐库函数初始化的时间。
主要开发环境:
- STM32CubeMX:用于生成工程代码,版本可以根据自己的情况选择,新版界面差别不大。
- Keil MDK-ARM:用于代码编写、编译和下载,推荐使用 Keil MDK 5 以上版本。
- ST-Link 下载器:用于给 STM32 下载程序。
- 串口调试助手:可选,用于打印调试信息。
关于库函数,本文使用 HAL 库。HAL 库的优势是代码分层清晰,外设句柄封装完整,配合 CubeMX 生成的工程可以直接往里添加业务代码,很适合项目开发和学习。
3.2 STM32CubeMX 创建工程的基本步骤
打开 STM32CubeMX,点击 New Project,在 MCU 选择界面输入 STM32F103C8,选择具体芯片 STM32F103C8Tx 即可。
进入配置界面后,重点做以下几项设置:
3.2.1 RCC 时钟配置
在 System Core -> RCC 中,将 HSE 设置为 Crystal/Ceramic Resonator。如果核心板上没有外部晶振,也可以直接使用内部 HSI 时钟,但为了串口波特率准确,建议优先使用外部高速晶振。
3.2.2 SYS 调试接口配置
在 System Core -> SYS 中,将 Debug 设置为 Serial Wire。这一步一定要做,否则 PB3、PB4 不能被正常使用,同时也会影响后续 ST-Link 调试。
3.2.3 GPIO 配置
在 GPIO 界面中,根据引脚分配表,把以下引脚配置为输出模式:
- PA6、PA7、PB5、PC13
输入模式:
- PB3、PB4、PB6
这里要注意,按键引脚建议使能内部上拉或下拉电阻,具体取决于按键外部接法。如果按键一端接 GND,按下为低电平,则配置为内部上拉;如果按键一端接 3.3V,按下为高电平,则配置为内部下拉。
3.2.4 ADC 配置
在 Analog -> ADC1 中,使能两个通道:
- IN0 对应 PA0,采集雨滴传感器
- IN1 对应 PA1,采集光敏传感器
配置为独立模式,扫描模式可以根据需要开启,采样分辨率选择 12 位,与其他默认参数保持一致即可。
3.2.5 定时器 PWM 配置
在 Timers -> TIM3 中:
- Channel3 选择 PWM Generation CH3
- Prescaler 设置为 71
- Counter Period 设置为 999
这样在 72MHz 主频下,PWM 频率为 72MHz / (71+1) / (999+1) = 1kHz,占空比可以在 0% 到 100% 之间调节,适合直流电机调速。
3.2.6 I2C 配置
在 Connectivity -> I2C1 中:
- 选择 I2C 模式
- 默认速度 100kHz 即可
LCD1602 的 I2C 转接板通常采用 PCF8574 芯片,地址默认为 0x27 或 0x3F。写代码时可以用 I2C 扫描方法确认地址。
3.3 生成代码
配置完成后,在 Project Manager 中设置工程名称、保存路径、工具链为 MDK-ARM V5,然后点击 GENERATE CODE。生成的代码会自动包含所有初始化函数,接下来就是在 main.c 和自定义模块文件中添加业务逻辑。
如果你使用的是 Keil MDK,还需要在魔术棒 Options for Target 中选择正确的 Debug 工具(ST-Link Debugger),并在 Settings 中确认能识别到芯片。
4. 核心模块驱动实现
这一节是整个项目的核心。每个模块我都会说明驱动原理,并给出可直接移植到 HAL 库工程中的代码。
4.1 雨滴传感器驱动
雨滴传感器模块通过 PCB 上裸露的叉指电极检测水滴。没有水时,电极之间的电阻很大,输出电压较高;有水时,电极之间被水桥接,电阻变小,输出电压降低。
模块通常有两个输出:
- AO:模拟电压输出,连接到 STM32 的 ADC 引脚。
- DO:数字电平输出,可以通过电位器调节灵敏度阈值。
在本项目中,我们读取 AO 的模拟电压,阈值可在代码里灵活调整,这样比固定数字输出更稳定。
ADC 读取代码可以直接使用 HAL 库函数:
// 文件路径:Src/adc_utils.c #include "adc_utils.h" uint16_t ADC_GetValue(ADC_HandleTypeDef *hadc, uint32_t channel) { ADC_ChannelConfTypeDef sConfig = {0}; uint16_t adc_value = 0; sConfig.Channel = channel; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_55CYCLES_5; HAL_ADC_ConfigChannel(hadc, &sConfig); HAL_ADC_Start(hadc); HAL_ADC_PollForConversion(hadc, 10); adc_value = HAL_ADC_GetValue(hadc); HAL_ADC_Stop(hadc); return adc_value; }在这个函数中,每次读取前都重新配置通道,方便在多通道之间切换。雨滴传感器的电压值经过 STM32 ADC 转换后,是一个 0 到 4095 的数值。数值越低,表示雨水越多。
在主循环中,可以这样获取雨滴值:
uint16_t rain_value = ADC_GetValue(&hadc1, ADC_CHANNEL_0);注意,HAL_ADC_PollForConversion 的超时时间不能太短,第一次采集时 ADC 可能需要几个时钟周期完成稳定,一般 10ms 足够。
4.2 光敏电阻驱动
光敏电阻模块同样是输出电压随着光照强度变化。光线越强,光敏电阻阻值越低,AO 输出电压越低;光线越弱,电阻越大,输出电压越高。不同厂家的模块电压范围会有差异,所以最好实际测试一下。
光敏电阻 ADC 读取方式与雨滴传感器完全一致,只是通道不同:
uint16_t light_value = ADC_GetValue(&hadc1, ADC_CHANNEL_1);两种传感器都使用 ADC 后,主循环里读取数据时要注意通道切换顺序。每次调用 ADC_GetValue 函数内部都会重新配置通道,因此不会产生冲突。
4.3 DHT11 温湿度传感器驱动
DHT11 是单总线数字传感器,数据引脚一次传输 40 位数据,包括湿度整数部分、湿度小数部分、温度整数部分、温度小数部分和校验和。它的时序要求比较严格,使用 HAL 库时通常直接用 GPIO 模拟时序,不依赖硬件外设。
下面是一个基于 HAL 库 GPIO 模拟 DHT11 采集的完整驱动。
先看头文件:
// 文件路径:Inc/dht11.h #ifndef __DHT11_H #define __DHT11_H #include "main.h" typedef struct { uint8_t humidity_int; uint8_t humidity_deci; uint8_t temperature_int; uint8_t temperature_deci; uint8_t checksum; uint8_t is_valid; } DHT11_Data_t; void DHT11_Init(void); uint8_t DHT11_ReadData(DHT11_Data_t *data); #endif再看源文件:
// 文件路径:Src/dht11.c #include "dht11.h" #include "delay.h" #define DHT11_PORT GPIOA #define DHT11_PIN GPIO_PIN_4 static void DHT11_Start(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); // 配置为推挽输出 GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); // 主机拉低至少 18ms,然后释放总线 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); delay_us(30); } static uint8_t DHT11_ReadByte(void) { uint8_t value = 0; uint8_t i; for (i = 0; i < 8; i++) { while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET); // 等待低电平结束 delay_us(40); if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { value |= (0x80 >> i); } while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET); // 等待高电平结束 } return value; } static uint8_t DHT11_ReadRaw(DHT11_Data_t *data) { GPIO_InitTypeDef GPIO_InitStruct = {0}; uint8_t buffer[5] = {0}; uint8_t i; // 配置为浮空输入 GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_NOPULL; HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { return 0; } // 等待 DHT11 响应低电平 while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET); while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET); // 读取 40 位数据 for (i = 0; i < 5; i++) { buffer[i] = DHT11_ReadByte(); } // 配置为输出模式,便于下一次启动 GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); >__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 800);这里 800 对应 80% 占空比。电机启动时,可以先用比较低的占空比,再逐步升高,避免瞬间冲击电流导致复位。
电机动作控制函数:
// 文件路径:Src/motor.c #include "motor.h" void Motor_Forward(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 800); } void Motor_Backward(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 800); } void Motor_Stop(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 0); }在实际使用中,电机伸缩到极限位置后必须马上停止,否则机械结构会卡死,堵转电流还可能烧坏驱动模块或电机。最简单的办法是加限位开关。
4.5 按键输入驱动
按键用于手动控制。考虑到实时性,可以使用查询方式扫描,也可以使用外部中断。对于课程设计项目,查询方式已经足够。
按键扫描要考虑“软件消抖”。机械按键在按下和释放的瞬间会产生抖动,通常持续 5ms 到 20ms。最简单的消抖方法是检测到低电平后延时 10ms 到 20ms,再确认一次电平状态。
下面是一个简单的按键扫描示例:
// 文件路径:Src/key.c #include "key.h" #include "delay.h" uint8_t Key_Scan(void) { if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_3) == GPIO_PIN_RESET) { delay_ms(10); if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_3) == GPIO_PIN_RESET) { while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_3) == GPIO_PIN_RESET); return KEY_MODE; // 手动/自动切换 } } if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_4) == GPIO_PIN_RESET) { delay_ms(10); if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_4) == GPIO_PIN_RESET) { while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_4) == GPIO_PIN_RESET); return KEY_OUT; // 伸出 } } if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_6) == GPIO_PIN_RESET) { delay_ms(10); if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_6) == GPIO_PIN_RESET) { while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_6) == GPIO_PIN_RESET); return KEY_IN; // 收回 } } return KEY_NONE; }上面扫描函数是阻塞式的,按下后要等按键释放才会返回,好处是逻辑清晰,坏处是长按或干扰时主循环会被卡住。实际项目中如果按键数量多,建议改成状态机扫描方式。
4.6 I2C LCD1602 显示驱动
LCD1602 是 16 字符 x 2 行的字符型液晶屏。配合 I2C 转接板后,只需要两根信号线就能控制。常见的 I2C 转接板使用 PCF8574 芯片,寄存器地址默认是 0x27,也有一部分是 0x3F。
以下代码基于 HAL 库 I2C 实现基本的 LCD1602 显示功能。
// 文件路径:Inc/lcd1602_i2c.h #ifndef __LCD1602_I2C_H #define __LCD1602_I2C_H #include "main.h" void LCD1602_Init(I2C_HandleTypeDef *hi2c); void LCD1602_SetCursor(uint8_t row, uint8_t col); void LCD1602_PrintString(char *str); void LCD1602_Clear(void); #endif// 文件路径:Src/lcd1602_i2c.c #include "lcd1602_i2c.h" static I2C_HandleTypeDef *lcd_i2c; static uint8_t lcd_addr = 0x27 << 1; static void LCD1602_WriteCmd(uint8_t cmd); static void LCD1602_WriteData(uint8_t data); static void LCD1602_PulseEnable(uint8_t data); static void LCD1602_SendByte(uint8_t data, uint8_t mode); static void LCD1602_SendByte(uint8_t data, uint8_t mode) { uint8_t high_nibble = (data & 0xF0) | mode | 0x08; uint8_t low_nibble = ((data << 4) & 0xF0) | mode | 0x08; uint8_t buf[4]; buf[0] = high_nibble | 0x04; // EN 拉高 buf[1] = high_nibble; // EN 拉低 buf[2] = low_nibble | 0x04; // EN 拉高 buf[3] = low_nibble; // EN 拉低 HAL_I2C_Master_Transmit(lcd_i2c, lcd_addr, buf, 4, 100); } static void LCD1602_WriteCmd(uint8_t cmd) { LCD1602_SendByte(cmd, 0x00); } static void LCD1602_WriteData(uint8_t data) { LCD1602_SendByte(data, 0x01); } void LCD1602_Init(I2C_HandleTypeDef *hi2c) { lcd_i2c = hi2c; HAL_Delay(50); // 4 位模式初始化时序 LCD1602_SendByte(0x30, 0); HAL_Delay(5); LCD1602_SendByte(0x30, 0); HAL_Delay(5); LCD1602_SendByte(0x30, 0); HAL_Delay(5); LCD1602_SendByte(0x20, 0); HAL_Delay(5); LCD1602_WriteCmd(0x28); // 4 位模式,2 行,5x7 点阵 LCD1602_WriteCmd(0x0C); // 显示开,光标关 LCD1602_WriteCmd(0x01); // 清屏 HAL_Delay(2); LCD1602_WriteCmd(0x06); // 光标右移,整体不移动 } void LCD1602_SetCursor(uint8_t row, uint8_t col) { uint8_t addr = 0x80 + (row * 0x40) + col; LCD1602_WriteCmd(addr); } void LCD1602_PrintString(char *str) { while (*str) { LCD1602_WriteData((uint8_t)(*str)); str++; } } void LCD1602_Clear(void) { LCD1602_WriteCmd(0x01); HAL_Delay(2); }I2C LCD 驱动有几个细节要注意:
- PCF8574 的 P0 到 P3 对应数据线 D4 到 D7,P4 对应 RS,P5 对应 RW,P6 对应 E,P7 对应背光。
- 发送数据时,RS 位要区分是命令还是数据。
- E 引脚(使能)需要一高一低的脉冲,LCD 才会采样数据。
- 初始化可以只用 4 位模式,但必须先按照 HD44780 规定的初始化时序发送 0x30 和 0x20。
如果你拿到的是 0x3F 地址的模块,只需要把lcd_addr改成0x3F << 1即可。如果屏幕没有任何反应,优先确认 I2C 地址和接线。
5. 完整控制逻辑与主程序
5.1 主程序总体框架
主程序负责把各个模块串起来。整体流程如下:
- 系统初始化:HAL 初始化、时钟、GPIO、ADC、定时器、I2C、LCD、DHT11。
- 进入主循环:
- 读取传感器数据。
- 判断当前模式(自动/手动)。
- 自动模式下,根据传感器状态决定是否伸出或收回。
- 手动模式下,根据按键控制电机。
- 刷新 LCD 显示。
来看 main.c 中的主循环代码:
// 文件路径:Src/main.c 主循环片段 #include "main.h" #include "adc_utils.h" #include "dht11.h" #include "motor.h" #include "key.h" #include "lcd1602_i2c.h" I2C_HandleTypeDef hi2c1; TIM_HandleTypeDef htim3; ADC_HandleTypeDef hadc1; typedef enum { MODE_AUTO, MODE_MANUAL } WorkMode_t; static WorkMode_t g_mode = MODE_AUTO; static DHT11_Data_t g_dht11; static uint16_t g_rain_value = 0; static uint16_t g_light_value = 0; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_TIM3_Init(); MX_I2C1_Init(); LCD1602_Init(&hi2c1); DHT11_Init(); LCD1602_PrintString("Smart Clothes Rack"); HAL_Delay(1000); LCD1602_Clear(); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3); while (1) { // 每 2 秒读取一次 DHT11,其他传感器每次循环读取 g_rain_value = ADC_GetValue(&hadc1, ADC_CHANNEL_0); g_light_value = ADC_GetValue(&hadc1, ADC_CHANNEL_1); if (HAL_GetTick() - last_dht_time > 2000) { DHT11_ReadData(&g_dht11); last_dht_time = HAL_GetTick(); } uint8_t key = Key_Scan(); if (key == KEY_MODE) { if (g_mode == MODE_AUTO) { g_mode = MODE_MANUAL; } else { g_mode = MODE_AUTO; } HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); LCD1602_Clear(); } if (g_mode == MODE_AUTO) { AutoRackControl(); } else { ManualRackControl(key); } UpdateDisplay(); HAL_Delay(50); } }主循环的节奏控制在 50ms 左右一次,既能及时响应按键,又不会因为传感器读取太频繁导致系统卡顿。
5.2 自动收衣/晾衣逻辑
自动模式是整个项目的核心。判断逻辑需要结合雨滴、光照、湿度三个因素,并考虑“防误动作”。
这里有一个工程上很关键的思想:不要因为传感器的一次快速跳变就立刻执行电机动作,而应该加上抖动判断和延时确认。
来看自动控制函数:
// 文件路径:Src/control.c static uint8_t rain_count = 0; static uint8_t no_rain_count = 0; void AutoRackControl(void) { // 雨滴 ADC 值越低表示雨越大,这里定义阈值为 2000 int is_raining = (g_rain_value < 2000); // 光线 ADC 值越低表示光照越强,这里定义阈值为 2500 int is_daylight = (g_light_value < 2500); // DHT11 湿度大于 75% 时认为环境潮湿,可能正在下雨或刚下过雨 int is_humid = (g_dht11.humidity_int > 75); if (is_raining || (is_humid && !is_daylight)) { rain_count++; no_rain_count = 0; if (rain_count >= 5) { // 收回晾衣杆 Motor_Backward(); HAL_Delay(3000); Motor_Stop(); LCD1602_Clear(); LCD1602_SetCursor(0, 0); LCD1602_PrintString("Auto: Raining !"); LCD1602_SetCursor(1, 0); LCD1602_PrintString("Retract..."); rain_count = 0; } } else { no_rain_count++; rain_count = 0; if (no_rain_count >= 5 && is_daylight) { // 伸出晾衣杆 Motor_Forward(); HAL_Delay(3000); Motor_Stop(); LCD1602_Clear(); LCD1602_SetCursor(0, 0); LCD1602_PrintString("Auto: Sunny !"); LCD1602_SetCursor(1, 0); LCD1602_PrintString("Extend..."); no_rain_count = 0; } } }在这个逻辑中,连续 5 次判断满足条件才会执行动作,大约相当于 250ms 的确认时间。这样即使雨滴传感器误触发一次,或者光敏模块被飞鸟短暂遮挡,也不会导致电机频繁动作。
5.3 手动控制与停止保护
手动模式下,用户通过按键控制电机。每次按键触发一次动作,电机运行固定时间后自动停止,避免持续运行损坏电机。
void ManualRackControl(uint8_t key) { if (key == KEY_OUT) { Motor_Forward(); HAL_Delay(3000); Motor_Stop(); LCD1602_Clear(); LCD1602_SetCursor(0, 0); LCD1602_PrintString("Manual: Extend"); } else if (key == KEY_IN) { Motor_Backward(); HAL_Delay(3000); Motor_Stop(); LCD1602_Clear(); LCD1602_SetCursor(0, 0); LCD1602_PrintString("Manual: Retract"); } }这种固定时间控制实现起来最简单,但是精度不高。如果晾衣杆已经到位,电机还在转,就容易堵转。更稳妥的做法是在伸缩轨道两端安装限位开关,行程到位后立刻断开电机控制信号。
使用限位开关后,电机动作逻辑会变成:
- 执行伸出动作。
- 检查限位开关是否触发。
- 触发后立刻停止。
- 主程序根据开关状态决定后续动作。
5.4 LCD 实时信息显示
显示函数把当前模式、传感器数据和电机状态实时刷新到 LCD1602 上。每行显示的内容需要格式化。
void UpdateDisplay(void) { char line1[16]; char line2[16]; if (g_mode == MODE_AUTO) { snprintf(line1, sizeof(line1), "AUTO R:%d", g_rain_value); snprintf(line2, sizeof(line2), "L:%d %d.%dC", g_light_value, g_dht11.temperature_int, g_dht11.temperature_deci); } else { snprintf(line1, sizeof(line1), "MANUAL R:%d", g_rain_value); snprintf(line2, sizeof(line2), "L:%d H:%d%%", g_light_value, g_dht11.humidity_int); } LCD1602_SetCursor(0, 0); LCD1602_PrintString(line1); LCD1602_SetCursor(1, 0); LCD1602_PrintString(line2); }由于 LCD1602 只有 16 个字符宽度,字符串要控制在 16 字节以内。使用 snprintf 时要注意目标数组大小,避免越界。
6. 功能调试与实物验证
6.1 编译与下载
代码写完后,在 Keil 中点击 Build 按钮编译。常见错误包括:
- 头文件路径没有添加,导致找不到 dht11.h、motor.h 等文件。
- HAL_TIM_PWM_Start 没有被调用,导致 PWM 引脚无输出。
- ADC 多通道配置不正确,导致读取值始终为 0。
编译通过后,用 ST-Link 连接 STM32 最小系统板,点击 Download 下载程序。下载成功后再按一下复位按键,程序开始运行。
6.2 传感器阈值调试
传感器阈值不建议直接照抄,因为不同模块输出电压范围不同。我的经验是先用串口把 ADC 原始值打印出来,观察无雨、小雨、大雨时的数值范围,再确定阈值。
比如我测试时:
| 场景 | 雨滴传感器 ADC 值 |
|---|---|
| 干燥 | 3900 左右 |
| 滴少量水 | 2500 左右 |
| 水量增大 | 1200 以下 |
所以把下雨阈值定在 2000 比较合适。光敏模块也采用同样方式。
6.3 功能验证清单
以下是建议的完整测试项:
| 测试项 | 操作 | 预期结果 |
|---|---|---|
| 手动伸出 | 手动模式,按伸出键 | 电机正转 3 秒后停止,LCD 显示 Extend |
| 手动收回 | 手动模式,按收回键 | 电机反转 3 秒后停止,LCD 显示 Retract |
| 模式切换 | 按模式切换键 | LCD 在 AUTO 和 MANUAL 之间切换 |
| 雨天自动收回 | 自动模式,在雨滴传感器上滴水 | 电机收回,LCD 显示 Raining |
| 晴天自动伸出 | 自动模式,遮挡光敏后移开 | 电机伸出,LCD 显示 Sunny |
| 断电重启 | 重新上电 | 系统回到默认自动模式 |
7. 常见问题与排查思路
7.1 LCD1602 不显示
这个问题出现的频率非常高。可以先排查以下几个方面:
- 确认 I2C 地址:用 I2C 扫描程序打印设备地址,确认是 0x27 还是 0x3F。
- 确认接线:SCL 接 PB8,SDA 接 PB9,VCC 接 5V,GND 接 GND。
- 确认对比度电位器:部分 LCD1602 模块上有一个可调电阻,需要旋转它调整对比度,否则即使程序正常也可能看不到字。
- 确认背光:如果转接板上没有跳线或默认不使能背光,屏幕会非常暗,看起来像没显示。
7.2 DHT11 读取失败或一直返回 0
DHT11 时序比较敏感。如果信号线过长,或者使用了过长的杜邦线,就可能导致波形失真。排查思路:
- 缩短 DHT11 与 STM32 之间的连线,最好控制在 20cm 以内。
- 数据引脚和 VCC 之间加上 4.7k 上拉电阻。部分模块自带电阻,但独立传感器可能需要外接。
- DHT11 两次读取之间必须间隔至少 1 秒,连续快速读取容易失败。
- 校验失败时可以连续重试,代码里已经做了 5 次重试,如果还失败,检查接线和供电。
7.3 电机不转或只单方向转
电机不转通常有三个原因:
- 电源功率不够:STM32 和电机共用电源时,电机启动瞬间电流很大,可能拉低电压导致系统复位。建议电机单独使用 5V/2A 适配器,并与 STM32 共地。
- PWM 没启动:确认调用过 HAL_TIM_PWM_Start。
- IN1/IN2 电平组合不对:用万用表测量 PA6、PA7 输出电平,确认代码执行到了电机动作分支。
7.4 系统频繁复位
系统复位多数是电源问题。电机堵转电流能达到 1A 以上,如果电源输出能力不足,电压跌落会让 STM32 掉电复位。解决方法是:
- 使用稳压能力更好的 5V/2A 以上电源。
- 在电机电源两端并联 100uF 或 470uF 电解电容。
- 将电机地和单片机地单点共地,减少干扰。
7.5 按键误触发
按键误触发主要原因是没有消抖,或者引脚悬空。处理办法:
- 软件消抖延时从 10ms 增加到 20ms。
- 如果按键一端悬空,要检查外部上拉/下拉是否正确。
- 使用短一点的杜邦线,减少外界电磁干扰。
8. 最佳实践与工程建议
8.1 代码分层设计
虽然在课程设计中主程序可以直接写一大段,但为了项目可维护性和后续扩展,建议按照模块拆分:
- app层:主循环、模式切换、控制逻辑。
- driver层:dht11.c、lcd1602_i2c.c、motor.c、key.c。
- 公共层:delay.c、adc_utils.c。
这样以后换传感器或者增加新功能,只需要修改对应模块,不影响主程序框架。
8.2 阈值做成可配置参数
雨滴阈值、光敏阈值、电机运行时间这些参数,不要硬编码在业务逻辑里。建议统一放到一个头文件中,或者使用结构体保存。
// 文件路径:Inc/config.h #ifndef __CONFIG_H #define __CONFIG_H #define RAIN_THRESHOLD 2000 #define LIGHT_THRESHOLD 2500 #define HUMID_THRESHOLD 75 #define MOTOR_RUN_TIME_MS 3000 #define SENSOR_CONFIRM_COUNT 5 #endif这样后期调试只需要改配置头文件,不需要在控制逻辑里到处找数字。
8.3 增加看门狗提高稳定性
如果项目需要长时间运行,建议开启独立看门狗 IWDG。主循环正常运行时,周期性喂狗;一旦程序跑飞或卡死,看门狗会自动复位系统。
// 在主循环中调用 HAL_IWDG_Refresh(&hiwdg);开启看门狗后,调试时要注意 HAL_Delay 或者 while 等待按键释放不能太久,否则喂狗超时会导致系统不断重启。
8.4 安全边界与电气防护
本项目属于低压电子制作,但电机驱动模块和电源部分仍然要注意安全:
- 所有接线先断开电源再进行,避免带电操作。
- 直流电机没有堵转保护,必须有行程限位或超时停止机制。
- 如果以后扩展到 220V 交流电机或烘干加热模块,必须增加隔离电路,并且严格遵循强电安全规范,在产品化设计中需要由专业电气工程师参与。
- 传感器线缆和电机线缆不要捆扎在一起,避免电机启停时产生的电磁干扰影响传感器读数。
8.5 数据记录与调试接口
建议在开发阶段固定保留一个串口输出。通过串口打印完整的传感器数据和状态切换信息,调试起来会轻松很多。
printf("rain=%d light=%d temp=%d hum=%d mode=%d\r\n", g_rain_value, g_light_value, g_dht11.temperature_int, g_dht11.humidity_int, g_mode);使用串口打印时,记得在 CubeMX 中把 USART1 打开,并将 printf 重定向到串口。
9. 开源与后续扩展方向
9.1 项目开源的意义
这个项目非常适合作为开源项目发布。一方面,STM32F103 生态非常成熟,大量学生和入门工程师都需要完整的、可运行的示例,而不是零散的代码片段;另一方面,智能晾衣架虽然是课程设计常见题目,但要把传感器融合、电机控制、模式切换、显示交互做好,仍然需要不少工程细节。
开源代码时,建议在仓库中同时提供:
- STM32CubeMX 工程文件。
- Keil MDK 工程源码。
- README 文档,写明硬件接线表、引脚分配、阈值配置方法。
- 实物演示视频或图片。
许可证可以选择 MIT 或 GPL 等常见协议。如果希望代码被更多项目复用,MIT 更宽松;如果希望后续改进也保持开源,可以选择 GPL。
9.2 可扩展方向
在设计这个系统时,我把所有可扩展的接口都预留出来了,后续可以继续完善这些方向:
9.2.1 增加物联网远程控制
使用 ESP8266 或 ESP32 模块,把 STM32F103 的传感器数据和控制状态通过 Wi-Fi 上传到物联网平台。用户可以在手机 App 上远程查看天气数据、手动控制晾衣架。通信方式可以是串口 AT 指令,也可以是 MQTT 协议。
9.2.2 使用 FreeRTOS 重构
当前主循环是前后台系统。如果功能变多,比如增加网络协议栈、多个传感器任务、屏幕刷新任务,前后台系统的实时性会变差。使用 FreeRTOS 可以把传感器采集、控制逻辑、显示刷新拆成独立任务,系统结构更清晰。
9.2.3 增加步进电机方案
直流减速电机加上限位开关虽然简单,但无法精确控制晾衣杆位置。如果改成步进电机,配合丝杆机构,可以实现位置闭环控制。STM32F103 的定时器可以输出多路 PWM,驱动步进电机也比较方便。
9.2.4 增加太阳能供电和低功耗设计
如果晾衣架放在室外,可以考虑太阳能电池板加锂电池供电方案。平时系统处于低功耗模式,定时唤醒采集传感器数据;检测到下雨再立刻唤醒电机执行收回动作。ST 官方提供了 STM32 的 STOP 模式低功耗方案,可以进一步降低系统平均功耗。
9.2.5 增加烘干和消毒模块
部分高端智能晾衣架在收回晾衣杆之后,会自动开启烘干热风、紫外线消毒灯。这就需要在控制系统中增加继电器模块,并且考虑温控保护,设置温度上限,避免过热风险。
9.3 如何参与和改进
如果你按这篇文章完成了基础版本,建议继续做以下几件事:
- 把调试过程中遇到的坑和解决方法记录下来,补充到项目 README 中。
- 测试不同品牌型号传感器模块的输出差异,记录实际阈值。
- 做 100 次连续伸缩测试,验证机械结构和电机运行可靠性。
- 尝试把自动判断逻辑优化成更平滑的策略,例如根据湿度变化趋势预测天气,而不是只看瞬时值。
一个开源项目的价值,往往不只是“代码可用”,而是“其他人拿到之后能复现、能理解、能改进”。这也正是我整理这篇教程的初衷:让每个拿到这个项目的开发者,都能快速跑起来,再根据自己的场景改造成更合适的产品原型。
如果你在搭建过程中遇到问题,建议先对照引脚分配表检查接线,再对照本节调试方法排查软件逻辑。抓住“传感器输入 -> 主控判断 -> 电机输出 -> 结果显示”这条主线,问题基本都能定位出来。代码在项目开源仓库中也会持续维护,当然如果你在使用中发现了可以优化的细节,也欢迎继续完善,这也是开源社区最有意思的地方。