1. 项目缘起与整体方案拆解
1.1 为什么选STM32而不是51单片机做洗衣机主控
每年毕业季,做单片机方向的同学十有八九会碰到家电模拟控制类题目,洗衣机算是其中最有代表性的一类。原因很简单:它同时涉及电机正反转、水位检测、定时控制、人机交互、声光提示这几个典型模块,一个项目能把大学四年学的东西串起来大半。但真正动手的时候,第一个绕不开的问题就是主控选型——用51还是用STM32。
我当年第一次做类似项目时用的是STC89C52,做到一半就发现资源捉襟见肘。洗衣机模拟控制需要同时处理按键扫描、OLED刷新、定时器计时、继电器逻辑输出、蜂鸣器提示音,还要留出串口调试的口子。51单片机只有两个定时器,一个被系统滴答占用,另一个要分给洗涤倒计时,PWM调速基本没戏,IO口也不够宽裕。更麻烦的是51的RAM只有128字节到256字节,状态机变量一多就得精打细算,代码写起来非常憋屈。
STM32F103C8T6这类最小系统板就舒服多了。72MHz主频、20KB RAM、64KB Flash,三个通用定时器加一个高级定时器,随便分配。关键是HAL库把外设初始化封装得很规整,CubeMX点几下就能生成工程骨架,省下来的时间可以花在业务逻辑上。对于毕业设计这种时间紧、任务重、还要写论文的场景,STM32的性价比明显更高。当然如果你学校要求必须用51,那也不是做不了,只是代码要更精简,后面我会提一下51方案的取舍。
1.2 系统整体架构与模块划分
这个项目的本质是一个有限状态机驱动的时序控制系统。整机可以拆成五个功能块:
- 主控核心:STM32F103C8T6最小系统板,负责所有逻辑调度
- 人机交互:OLED显示模块(I2C接口)+ 独立按键(模式、启动/暂停、加减)
- 执行机构:继电器模块驱动洗涤电机和排水阀,蜂鸣器做提示音
- 状态反馈:水位传感器(模拟或数字)、门盖开关(可选)
- 电源部分:12V输入经降压模块给继电器和电机供电,再经AMS1117-3.3给MCU供电
工作流程大致是这样:上电后OLED显示待机界面,用户通过按键选择洗涤模式(标准、轻柔、快速),按下启动键后系统进入进水状态,水位到达设定值后关闭进水阀,启动电机正转一段时间、停一会儿、再反转,如此循环若干次完成洗涤;洗涤结束后打开排水阀,排空后进入脱水状态,电机单向高速运转;脱水完成蜂鸣器响三声提示结束。整个过程用状态机管理,每个状态有独立的超时保护,防止卡死。
注意:真实洗衣机的水位检测用的是压力传感器,输出模拟电压。毕业设计里为了简化,常用浮球开关或者电极式水位探针,输出开关量。这两种方案在代码里的处理逻辑完全不同,选型时要先想清楚。
1.3 关键设计取舍与踩坑预判
做这个项目最容易翻车的地方有三个。第一是继电器干扰,电机启停瞬间会产生反向电动势,如果续流二极管没加或者继电器模块质量差,MCU很容易复位。我实测下来,继电器线圈两端必须并一个1N4148或者1N4007,触点侧如果驱动的是感性负载,最好再加RC吸收电路。第二是电源共地问题,电机和MCU如果共用一组电源,电机启动时电压跌落会导致OLED花屏甚至MCU死机,稳妥做法是电机单独供电,两地之间只连信号线。第三是状态机超时,如果排水阀堵了或者水位传感器坏了,程序会永远停在某个状态,所以每个状态都要有最大等待时间,超时后强制跳转到故障提示。
这些坑我在后面章节会逐个展开,先把整体框架讲清楚,后面再抠细节。
2. 硬件选型与电路设计要点
2.1 主控板与最小系统搭建
STM32F103C8T6最小系统板市面上大概十几块钱,板载8MHz晶振和32.768kHz晶振,带复位按键和BOOT跳线,拿来就能用。供电范围2.0V到3.6V,通常用3.3V。下载方式推荐SWD,只需要SWDIO、SWCLK、GND、3.3V四根线,比串口下载稳定得多。如果你用的是ST-Link,记得在Keil或者CubeIDE里把Debug模式设成SWD,否则第一次下载可能会锁芯片。
最小系统板上一般会引出所有IO口,但要注意PA13和PA14默认是SWD功能,如果代码里把它们配成普通IO,下次就下载不进去了。解决办法是在CubeMX里把Debug设成Serial Wire,这样PA13/PA14会被保留给调试口,不会冲突。另外PC13接的是板载LED,低电平点亮,可以用来做运行指示灯,调试的时候很方便。
2.2 OLED显示模块的接口与驱动
OLED选0.96寸I2C接口的最省事,四根线(VCC、GND、SCL、SDA)就能点亮。I2C地址通常是0x78或0x3C,具体看模块背面的标注。STM32的硬件I2C在F103上有点小毛病,容易卡死,所以很多人用软件模拟I2C。软件I2C的好处是引脚随便选,时序自己控制,缺点是刷屏速度慢一点,但对于128x64的OLED来说完全够用。
驱动方面,如果你用HAL库,可以找现成的OLED驱动库,比如SSD1306的移植版本。核心就是写命令和写数据两个函数,然后在此基础上封装清屏、显示字符、显示数字、显示中文等接口。显示中文需要取模,用PCtoLCD2002这类软件生成字模数组,注意取模方式要选对(阴码、逐列式、顺向),否则显示出来是乱码。
提示:OLED的VCC建议接3.3V,虽然有些模块标称支持5V,但长期用5V供电容易烧。I2C总线上拉电阻一般模块自带了4.7k,如果通信不稳定,可以外接2.2k到4.7k的上拉。
2.3 继电器驱动电路与电机控制
继电器是这个小项目里最容易出问题的环节。常见的5V继电器模块有光耦隔离和直接驱动两种。光耦隔离的模块输入端只需要很小的电流就能触发,对MCU的IO口很友好;直接驱动的模块可能需要5mA到10mA的驱动电流,STM32的IO口最大输出20mA,勉强够用但不推荐。
接线的时候,继电器模块的VCC接5V,GND和MCU共地,IN引脚接STM32的GPIO。代码里控制继电器就是拉高或拉低对应的IO口。但要注意,有些继电器模块是低电平触发,有些是高电平触发,买的时候要看清楚,否则逻辑会反。
电机控制方面,洗涤电机需要正反转,所以要用两个继电器组成H桥,或者用一个双刀双掷继电器。排水阀只需要单向控制,一个继电器就够了。如果电机是220V的,强烈建议用继电器模块而不是自己搭电路,安全第一。毕业设计里也可以用12V直流电机模拟,这样更安全,调试也方便。
| 模块 | 供电 | 控制信号 | 注意事项 |
|---|---|---|---|
| 继电器模块 | 5V | GPIO高低电平 | 注意触发极性,加续流二极管 |
| OLED | 3.3V | I2C | 上拉电阻,地址确认 |
| 蜂鸣器 | 3.3V或5V | GPIO | 有源直接驱动,无源需PWM |
| 水位传感器 | 3.3V | GPIO或ADC | 注意防抖和滤波 |
2.4 蜂鸣器与按键电路
蜂鸣器分有源和无源两种。有源蜂鸣器内部带振荡电路,给电就响,控制简单,一个GPIO拉高就行。无源蜂鸣器需要外部给方波,频率决定音调,可以用定时器PWM驱动。毕业设计里用有源蜂鸣器就够了,响三声提示结束,代码里就是拉高延时拉低循环三次。
按键电路更简单,一端接GPIO,一端接GND,GPIO配置成上拉输入。按下时读到低电平,松开是高电平。但机械按键有抖动,必须做消抖处理。最简单的办法是延时20ms再读一次,确认状态。更好的办法是用定时器每10ms扫描一次,连续三次读到相同状态才认为有效。我一般用后者,不占用主循环时间。
3. 软件架构与状态机实现
3.1 状态机设计思路
洗衣机的控制逻辑用状态机写最清晰。我一般定义这几个状态:待机、进水、洗涤、排水、脱水、结束、故障。每个状态有进入动作、运行动作、退出条件。比如进水状态,进入时打开进水阀,运行中检测水位,水位到了就关闭进水阀并跳转到洗涤状态,如果超过30秒还没到水位就跳转到故障状态。
状态机的实现方式有两种:switch-case和状态表。switch-case直观,适合状态少的场景;状态表扩展性好,加状态不用改主循环。这个项目状态不多,用switch-case就够了。关键是每个状态都要有超时计数器,用系统滴答定时器每毫秒加一,状态切换时清零。
typedef enum { STATE_IDLE, STATE_FILL, STATE_WASH, STATE_DRAIN, STATE_SPIN, STATE_DONE, STATE_FAULT } WashState; WashState currentState = STATE_IDLE; uint32_t stateTimer = 0; void WashStateMachine(void) { switch(currentState) { case STATE_IDLE: // 等待启动按键 if (startKeyPressed) { currentState = STATE_FILL; stateTimer = 0; OpenInletValve(); } break; case STATE_FILL: if (WaterLevelReached()) { CloseInletValve(); currentState = STATE_WASH; stateTimer = 0; } else if (stateTimer > 30000) { currentState = STATE_FAULT; } break; // 其他状态类似 } }3.2 定时器与倒计时显示
洗涤和脱水都需要倒计时。我用TIM2做1ms中断,在中断里更新stateTimer和各个倒计时变量。OLED每200ms刷新一次,显示剩余时间。注意不要在中断里做OLED刷新,I2C通信耗时太长,会阻塞其他中断。正确做法是在中断里置标志位,主循环里检测标志位再刷新。
倒计时的显示格式一般是“分:秒”,比如“02:35”。转换的时候用整除和取余就行。如果剩余时间小于10秒,可以让蜂鸣器每秒响一下,提示用户快结束了。这个功能不难,但很提升体验。
3.3 按键处理与菜单逻辑
按键处理我习惯用状态机加定时扫描。每10ms调用一次按键扫描函数,用移位寄存器的方式记录连续状态,稳定20ms后才确认。短按和长按要区分,短按切换模式,长按进入设置。模式选择界面用OLED显示三个选项,当前选中的反白显示。
菜单逻辑不要写得太复杂,毕业设计展示的时候老师不会让你操作太多层。一般两层就够了:主界面显示模式和剩余时间,设置界面调洗涤时长和脱水时长。用两个按键加减,一个按键确认,一个按键返回。
实操心得:按键的IO口最好选带外部中断的,但不要真的用中断处理按键,中断里只置标志位。我见过有人在中断里做延时消抖,结果整个系统响应都变慢了。
4. 关键功能模块的代码实现
4.1 进水与水位检测
水位检测用数字传感器最简单,比如浮球开关,水位到了输出低电平,没到输出高电平。代码里就是读GPIO。但浮球开关有抖动,水位波动时会在临界点反复跳变,所以要做滤波。我的做法是连续读到5次相同状态才确认,每次间隔10ms。
如果用模拟水位传感器,就要用ADC采集电压,然后跟阈值比较。STM32的ADC配置成单次转换模式,需要的时候启动转换,读结果。注意ADC的参考电压要稳定,最好用外部3.3V基准,不要用VDDA直接当基准,否则电池供电时读数会飘。
uint8_t CheckWaterLevel(void) { static uint8_t stableCount = 0; uint8_t currentLevel = HAL_GPIO_ReadPin(WATER_GPIO_Port, WATER_Pin); if (currentLevel == 0) { // 低电平表示水位到 if (stableCount < 5) stableCount++; } else { stableCount = 0; } return (stableCount >= 5); }4.2 电机正反转与间歇控制
洗涤阶段电机要正转、停、反转、停,循环进行。正转和反转通过两个继电器实现,停止就是两个都断开。间歇时间一般正转5秒、停2秒、反转5秒、停2秒,循环10次。这个参数可以做成可调的,存在变量里。
控制代码用一个子状态机实现:
typedef enum { MOTOR_STOP, MOTOR_FORWARD, MOTOR_REVERSE } MotorState; void WashMotorControl(void) { static MotorState motorState = MOTOR_STOP; static uint32_t motorTimer = 0; static uint8_t cycleCount = 0; motorTimer++; switch(motorState) { case MOTOR_STOP: MotorStop(); if (motorTimer >= 2000) { motorTimer = 0; if (cycleCount % 2 == 0) { motorState = MOTOR_FORWARD; } else { motorState = MOTOR_REVERSE; } } break; case MOTOR_FORWARD: MotorForward(); if (motorTimer >= 5000) { motorTimer = 0; motorState = MOTOR_STOP; cycleCount++; } break; case MOTOR_REVERSE: MotorReverse(); if (motorTimer >= 5000) { motorTimer = 0; motorState = MOTOR_STOP; cycleCount++; } break; } }注意正反转切换之间一定要有停止时间,否则继电器同时吸合会造成电源短路。这个停止时间至少1秒,我一般给2秒。
4.3 排水与脱水逻辑
排水就是打开排水阀,等水位传感器检测到无水后关闭。脱水是在排水完成后启动电机单向高速运转。脱水时间一般3到5分钟,倒计时结束后停止电机,蜂鸣器提示。
脱水阶段有个细节:如果门盖没关好,电机不应该启动。所以最好加一个门盖开关,检测到门盖打开就暂停脱水并报警。这个功能在真实洗衣机上是强制要求的,毕业设计里加上会显得更完整。
4.4 蜂鸣器提示音设计
蜂鸣器提示音分几种:按键音、开始提示、结束提示、故障报警。按键音短促,响50ms就行。结束提示响三声,每声200ms,间隔200ms。故障报警连续响,直到用户按键复位。
代码里用一个非阻塞的蜂鸣器控制函数,避免用HAL_Delay阻塞主循环:
void BeepTask(void) { static uint32_t beepTimer = 0; static uint8_t beepCount = 0; if (beepCount > 0) { beepTimer++; if (beepTimer < 200) { HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_SET); } else if (beepTimer < 400) { HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_RESET); } else { beepTimer = 0; beepCount--; } } }5. 调试过程中的常见问题与排查
5.1 程序下载失败与芯片锁死
STM32下载失败最常见的原因是SWD引脚被占用。如果你在代码里把PA13或PA14配成了普通IO,下次下载时SWD就连不上了。解决办法是把BOOT0拉高,BOOT1拉低,进入系统存储器启动模式,然后用STM32CubeProgrammer擦除芯片,再恢复BOOT0拉低重新下载。
还有一种情况是芯片读保护了,下载时会提示“Flash read protection”。这时候也只能用CubeProgrammer解除保护,但会擦除整个Flash,代码要重新烧。
避坑技巧:调试阶段在代码开头加一句延时,比如HAL_Delay(1000),给下载器留出连接时间。这样即使SWD引脚被复用,上电后1秒内还能连上。
5.2 OLED不显示或花屏
OLED不显示先查三件事:供电、地址、初始化。供电用万用表量VCC和GND之间是不是3.3V。地址用I2C扫描程序确认,如果扫不到设备,说明接线错了或者模块坏了。初始化不对会花屏,检查初始化命令序列是否完整,特别是对比度和显示模式设置。
如果显示内容错位或者乱码,多半是取模方式不对。中文取模要选“阴码+逐列式+顺向”,英文和数字用自带的字库就行。还有一点,OLED的GRAM是按页组织的,写数据时要先设置页地址和列地址,否则会写到错误的位置。
5.3 继电器吸合导致MCU复位
这是最典型的干扰问题。现象是电机一启动,OLED就闪一下或者直接重启。原因有两个:一是电源跌落,二是电磁干扰。解决办法:电机和MCU分开供电,继电器线圈加续流二极管,触点侧加RC吸收,MCU的复位引脚加0.1uF电容,电源入口加100uF电解和0.1uF陶瓷电容。
如果条件允许,用光耦隔离继电器模块,MCU和继电器之间只通过光耦传递信号,电气上完全隔离,干扰会小很多。
5.4 状态机卡死与超时处理
状态机卡死通常是因为某个状态的退出条件永远不满足。比如水位传感器坏了,进水状态永远等不到水位信号。所以每个状态都要有超时保护,超时后跳转到故障状态,OLED显示错误代码,蜂鸣器报警。
故障代码可以这样定义:E01进水超时,E02排水超时,E03脱水超时,E04门盖未关。用户看到代码就知道问题在哪,维修也方便。
| 故障代码 | 含义 | 可能原因 | 处理方法 |
|---|---|---|---|
| E01 | 进水超时 | 水龙头没开、进水阀坏 | 检查水源和阀门 |
| E02 | 排水超时 | 排水管堵、排水阀坏 | 清理排水管 |
| E03 | 脱水超时 | 电机过载、电容坏 | 检查电机 |
| E04 | 门盖未关 | 门盖开关坏 | 更换开关 |
6. 项目扩展与进阶玩法
6.1 加入蓝牙模块实现手机控制
用HC-05或HC-06蓝牙模块,接在STM32的串口上,手机端用蓝牙调试助手发指令。比如发“START”启动,发“STOP”停止,发“MODE1”切换模式。代码里就是串口中断接收,解析字符串,然后修改状态机变量。这个扩展不难,但很加分,答辩的时候演示手机控制会很亮眼。
6.2 用FreeRTOS重构任务调度
如果想把项目做得更“高级”,可以上FreeRTOS。把按键扫描、OLED刷新、状态机、蜂鸣器控制分别做成独立任务,用队列和信号量通信。这样代码结构更清晰,也更能体现你对RTOS的理解。但要注意,FreeRTOS会占用一部分RAM和Flash,STM32F103C8T6的20KB RAM够用,但不要开太多任务,栈空间要算好。
6.3 增加称重模块实现自动投放
真实洗衣机有自动投放洗衣液的功能,用称重传感器加HX711模块就能模拟。称重数据通过串口或IO口读进来,根据衣物重量计算投放量。这个扩展涉及传感器标定和滤波算法,工作量不小,但做出来很有含金量。
6.4 数据记录与故障追溯
加一个EEPROM或者Flash模拟EEPROM,记录每次洗涤的模式、时长、故障代码。出问题的时候可以翻看历史记录,方便排查。STM32F103内部Flash可以模拟EEPROM,但擦写次数有限,不要频繁写。可以只在洗涤结束时写一次,这样寿命足够。
7. 写在最后的一些实操体会
这个项目我从画原理图到代码调试完,前后花了大概两周时间,其中一半时间耗在继电器干扰和OLED驱动上。如果让我重新做一遍,我会先把电源和继电器部分单独搭出来测试,确认电机启停不会影响MCU,再往上加其他模块。这样能避免很多联调时的玄学问题。
另外,代码里一定要加足够的调试信息。我习惯用串口打印状态切换和关键变量,出问题的时候看串口日志比用调试器单步快得多。OLED也可以显示当前状态和倒计时,方便观察。
最后说一个细节:洗涤和脱水的倒计时不要用HAL_Delay,那样会阻塞整个系统。用定时器中断加计数器的方式,主循环里只判断计数值,这样按键和显示都不会卡。这个习惯在写任何嵌入式项目时都适用,越早养成越好。