简介:基于STM32的自动喂食器课程设计资源包,面向嵌入式相关专业的学生与开发者,适用于学习STM32外设驱动、定时器中断、PWM控制及Proteus仿真等典型嵌入式开发环节。资源共201个文件,压缩包约11.75MB,包含完整的Keil工程文件、ST官方HAL库源码(如stm32f4xx_hal_tim.c、stm32f4xx_hal_uart.c)、编译生成的hex/axf文件,以及Proteus仿真工程和说明文档,便于直接查看与二次修改。已有7576人学习下载。从中可掌握STM32F401定时器配置来设定喂食周期、利用LCD显示状态、按键输入和电机PWM调速等关键设计思路;仿真工程可在无硬件条件下先行验证逻辑,降低调试门槛。整体方案涵盖硬件选型、软件编程、系统集成与功能实现,对完成课程设计或入门智能家居类项目有实际参考价值。
1. 为什么用STM32做自动喂食器:一个嵌入式课程设计能落地的关键点
很多嵌入式课程设计选自动喂食器,因为它的功能链路足够完整:传感器采集、电机控制、RTC定时、人机交互、掉电存储,几乎把STM32的核心外设都过了一遍。但同样是这个课题,有人交出的是“能转的舵机+延时函数”,有人交的是“能跑一个月不出错的完整系统”,差别不在芯片型号,而在方案设计。这篇笔记从一个能直接复现的STM32自动喂食器方案入手,把硬件选型、状态机设计、驱动代码和实际踩坑串一遍,目标是让新手照着搭能点亮,让熟手直接换参数就能改造成自己的版本。
2. 硬件选型与模块接线:从舵机到称重传感器的整套搭配
2.1 主控选型:为什么默认选STM32F103C8T6而不是F407
自动喂食器的控制逻辑并不复杂,不需要浮点运算,不需要大量缓存,F103C8T6的72MHz主频、64KB Flash、20KB SRAM完全够用。更重要的是它的资源配套成熟,HAL库和标准库的例程多,课程设计阶段遇到问题搜起来快。F407的优势在更高速的ADC和更多定时器,但这个项目里用不上,成本还高一截。
选型时还要看引脚数量,C8T6是LQFP48封装,GPIO刚好够用。我一般会预留两个空闲引脚,一个接调试用的LED,一个留作后续扩展低功耗唤醒功能。如果手上的板子是C6T6(32KB Flash)也能跑,但代码编译完接近极限,不建议省这十几块钱。
2.2 执行机构对比:舵机、步进电机与直流减速电机怎么选
出粮机构是整个系统里最容易翻车的部分,选型要看食料形态和出粮精度。
- 舵机(SG90/MG995):适合旋转拨片式出粮,SG90扭矩不够,拨片插进粮仓经常卡死,我用的是MG995,扭矩大一些,但需要外部5V供电,不能直接从板子3.3V取电。
- 步进电机(28BYJ-48 + ULN2003驱动板):适合转盘式定量出粮,控制精准,每次转固定角度,缺点是速度和噪音,夜间喂食会吵醒人。
- 直流减速电机(带霍尔编码器):适合螺旋推进式出粮,但闭环控制代码复杂,课程设计周期内不建议碰。
从“能交差”和“能改进”两个角度,我建议用步进电机。它的逻辑清晰,出粮量可以通过脉冲数反复校准,写报告时也有数据可以展开。MG995舵机做备选,但要注意堵转电流能拉到1A以上,电源纹波会干扰ADC采样。
2.3 传感器与RTC模块:称重、时钟、显示一个都不能少
喂食器不能只“定时转一下电机”,至少要有三个反馈维度:
- 称重模块:HX711 + 压力传感器,放在粮仓底部或食盆下方,检测余粮量和是否已进食。HX711用24位ADC,两路差分输入,接传感器四根线(E+、E-、A+、A-),SCK和DOUT接GPIO。
- RTC模块:DS3231比DS1302贵,但温度补偿晶振的漂移量小很多,一个月误差几秒。DS1302便宜但走时漂移明显,如果喂食器连续运行两周,喂食时间会偏出二十分钟。课程设计如果周期短,DS1302能用;如果要做长期测试,还是DS3231靠谱。
- 显示与交互:0.96寸OLED(I2C)显示当前时间、下次喂食倒计时、余粮百分比。三个按键,分别是模式切换、手动出粮、参数设置。这块不做复杂菜单,三个按键足够覆盖所有操作路径。
2.4 接线表与电源分配:烧板子的坑都在电源这里
| 模块 | 引脚连接 | 供电 | 备注 |
|---|---|---|---|
| STM32F103C8T6 最小系统 | — | 3.3V | 用AMS1117-3.3稳压 |
| OLED SSD1306 | SCL→PB6, SDA→PB7 | 3.3V | I2C1 |
| DS3231 | SCL→PB6, SDA→PB7 | 3.3V | 与OLED共用I2C总线,地址不同 |
| HX711 | SCK→PA1, DOUT→PA0 | 3.3V | 传感器激励接5V也可,看模块说明 |
| 步进电机驱动ULN2003 | IN1→PA4, IN2→PA5, IN3→PA6, IN4→PA7 | 5V | 电机供电必须独立走线 |
| 三按键 | 分别接PB0/PB1/PB10,另一端接GND | 3.3V | 内部上拉 |
| 蜂鸣器 | PA8 | 3.3V | 喂食完成短鸣两次 |
| 继电器/电子开关 | PA9 | 5V | 控制食盆加热或照明(可选) |
电源是这里最大的坑。步进电机启动瞬间电流能到300mA以上,如果和STM32共用一条细杜邦线,电压会被拉低,MCU直接复位。我的做法是:5V适配器进来先分两路,一路给ULN2003驱动板和电机,另一路经过AMS1117给MCU供电,两路地在电源入口处单点汇合。
接完线先别上电,用万用表量一遍VCC和GND之间有没有短路,再量AMS1117输出是不是3.3V。这个习惯能救回一半以上的“上电冒烟”事故。
3. 状态机驱动的喂食逻辑:定时、手动与补食三种模式怎么切
3.1 为什么用状态机而不是一堆if-else
自动喂食器的行为看起来简单:到点转电机、按按钮转电机、余粮少了提示。但如果把这些动作写成线性if-else,会出现一个典型问题——用户按“手动出粮”时正好碰到定时喂食触发,两个流程同时操作电机,步进电机相位冲突,轻则卡转,重则烧驱动。
用状态机可以把系统行为收敛成三个稳定状态:待机(IDLE)、喂食中(FEEDING)、设置中(SETTING)。所有按键事件和定时器事件都先进入事件队列,状态机根据当前状态决定要不要响应,从根上避免了事件竞争。对课程设计来说,状态机还有一个额外的好处:答辩时画状态转移图非常直观,评审老师一眼就能看出系统结构,比看一堆散落的函数好讲得多。
3.2 核心状态定义与事件映射
这段是系统的骨架,状态枚举和事件枚举先定义清楚:
typedef enum { STATE_IDLE = 0, // 待机:显示时间,等待事件 STATE_FEEDING, // 喂食中:电机转动,屏蔽重复触发 STATE_SETTING // 设置中:循环修改喂食时间和手动出粮量 } SysState; typedef enum { EVENT_NONE = 0, EVENT_BTN_MODE, // 模式键,短按切换状态,长按进入设置 EVENT_BTN_FEED, // 手动出粮键 EVENT_BTN_SETUP, // 参数调整键 EVENT_TIMER_PLAN, // 定时喂食触发 EVENT_WEIGHT_LOW // 余粮不足 } SysEvent;事件产生后统一丢进一个环形缓冲,状态机主循环只从缓冲取事件,不在中断里直接处理业务逻辑。这样做的目的是让状态机永远只面对“一个事件”,不会出现中断嵌套导致的全局变量错乱。
参数说明:EVENT_BTN_MODE和EVENT_BTN_SETUP分开,是因为三个按键里有一个需要复用——短按切换状态,长按进入参数修改。如果按键逻辑直接写在状态机里,后面加“长按”功能时代码会很别扭,我这里把按键扫描独立成模块,只向上报事件,状态机不关心按键怎么检测的。
3.3 状态转移与喂食动作实现
状态转移逻辑放在主循环里,每10ms跑一次:
void SM_Process(SysEvent evt) { switch (state) { case STATE_IDLE: if (evt == EVENT_BTN_MODE) { state = STATE_SETTING; Display_EnterSetting(); } else if (evt == EVENT_BTN_FEED) { Feed_Once(MANUAL_FEED_AMOUNT); } else if (evt == EVENT_TIMER_PLAN) { Feed_Once(TIMER_FEED_AMOUNT); } else if (evt == EVENT_WEIGHT_LOW) { Buzzer_Alert(3); } break; case STATE_FEEDING: // 喂食中不响应任何新事件,防止重复出粮 if (evt == EVENT_TIMER_PLAN) { Log_AddBacklog(); // 记录一次未执行的定时喂食 } break; case STATE_SETTING: if (evt == EVENT_BTN_MODE) { state = STATE_IDLE; RTC_SaveConfig(); Display_BackToMain(); } else if (evt == EVENT_BTN_SETUP) { Setting_AdjustValue(1); // 每次+1分钟 } break; } }Feed_Once内部实现是:先检查当前是不是STATE_IDLE,是的话把状态切到STATE_FEEDING,然后以旋转步进电机N圈的方式出粮,完成后回到STATE_IDLE。喂食期间如果定时器又触发了一次,说明用户设置的间隔太短,系统只记录不执行,避免电机连续转。
这个“记录未执行事件”的思路是从实际操作里长出来的——测试时我把定时间隔设成1分钟,结果喂食还没结束下一次触发就来了,电机嗡嗡响个不停。加上Log_AddBacklog后,至少知道错过了一次喂食,后面做统计也有数据。
3.4 步进电机驱动与出粮量计算
28BYJ-48步进电机是减速电机,减速比1/64,步距角5.625°/64,转一圈需要4096个脉冲。出粮量不能直接按脉冲数算,得先实测:让电机每次转200个脉冲,称一次出粮重量,连续测5次取平均,得到“每脉冲出粮克数”,再反推目标喂食量对应的脉冲数。
#define PULSE_PER_REV 4096 // 电机转一圈的脉冲数 #define GRAMS_PER_CIRCLE 12.5f // 实测:转一圈出粮12.5g,需自己校准 #define TIMER_FEED_GRAMS 25.0f // 定时喂食目标量,可配置 void Feed_Once(float target_grams) { float need_revs = target_grams / GRAMS_PER_CIRCLE; uint32_t need_pulses = (uint32_t)(need_revs * PULSE_PER_REV); state = STATE_FEEDING; for (uint32_t i = 0; i < need_pulses; i++) { Stepper_Step(1); // 单步旋转 delay_us(800); // 速度控制,太快会失步 if ((i % 100) == 0) { Check_Weight(); // 每100步看一次称重,防止过量 } } state = STATE_IDLE; }参数说明:GRAMS_PER_CIRCLE这个值千万别照抄,我的粮仓、叶轮形状、粮食颗粒大小跟你不一样,出粮量差20%是正常的。校准方法:程序里写一个“测试模式”,按一次手动出粮转一圈,拿厨房秤称重,改宏定义,反复三次取平均值。
delay_us(800)对应步进频率约1.25kHz,28BYJ-48在这个速度下扭矩比较稳,再快容易失步。实际运行中如果听到电机“哒哒”声变脆但不转动,就是失步了,把延时拉长到1000us。
4. 驱动层与上层逻辑分离:HAL库代码组织与中断优先级处理
4.1 代码目录结构与模块划分
课程设计代码最容易写成“一个main.c走天下”,查bug时从头翻到尾。这个项目规模不大,但功能模块不少,我建议按这个结构组织:
User/ ├── main.c // 主循环,调度器 ├── sys_state.c // 状态机模块 ├── sensor_weight.c // HX711驱动与滤波 ├── motor_stepper.c // 步进电机驱动 ├── rtc_ds3231.c // 时钟模块 ├── oled_ssd1306.c // 显示模块 ├── key_scan.c // 按键扫描 ├── buzzer.c // 蜂鸣器 └── config_store.c // 参数存储(喂食时间、喂食量)每个模块提供两个接口:xxx_Init()和xxx_Process(),初始化在main最开始调用,Process在状态机需要时调用。这样做的边界价值在于——如果后面想换成别家的OLED屏,只需要改oled_ssd1306.c,上层调用接口完全不变。答辩时也能明确说出“驱动层与业务层解耦”,这是加分项。
4.2 主循环调度:10ms时基与事件轮询
不要用HAL_Delay做所有事情。HAL_Delay在中断里会出问题,而且多个模块各自延时会导致时间片混乱。我的做法是开一个SysTick中断,每1ms产生一次时基,用一个全局计数变量tick_ms累加,主循环里按时间片轮询各任务:
volatile uint32_t tick_ms = 0; void SysTick_Handler(void) { tick_ms++; } void main_loop(void) { uint32_t last_key = 0, last_disp = 0, last_weight = 0; while (1) { if (tick_ms - last_key >= 20) { // 每20ms扫一次按键 Key_Scan(); last_key = tick_ms; } if (tick_ms - last_disp >= 200) { // 每200ms刷新一次OLED Display_Update(); last_disp = tick_ms; } if (tick_ms - last_weight >= 500) { // 每500ms采样一次称重 Weight_Sample(); last_weight = tick_ms; } SM_Process(GetNextEvent()); // 事件队列取事件 } }时基参数说明:按键扫描20ms是防抖的经典窗口,小于10ms会漏掉快速按键,大于50ms手感肉。OLED刷新200ms是因为SSD1306是I2C接口,全屏刷新一次要传1KB数据,在72MHz主频下大概占用几毫秒,刷新太快主循环就卡了。称重500ms采样是因为HX711每次转换要100ms左右,而且压力传感器的噪声需要多次平均,太快反而波动大。
这是整个系统的主干,后面加功能都往这个循环里挂任务就行。
4.3 HX711称重读取:24位ADC的滤波为什么必须做
HX711读取本身不复杂,SCK拉低、DOUT变低表示数据准备好,然后连续读24个脉冲拿数据。但原始数据直接用的话,数字能跳几百甚至上千,因为电机的震动、桌面的晃动都会影响传感器。
#define HX711_SCK GPIO_PIN_1 #define HX711_DOUT GPIO_PIN_0 uint32_t HX711_Read(void) { uint32_t value = 0; HAL_GPIO_WritePin(GPIOA, HX711_SCK, GPIO_PIN_RESET); while (HAL_GPIO_ReadPin(GPIOA, HX711_DOUT) != GPIO_PIN_RESET); // 等待数据就绪 for (uint8_t i = 0; i < 24; i++) { HAL_GPIO_WritePin(GPIOA, HX711_SCK, GPIO_PIN_SET); value = value << 1; if (HAL_GPIO_ReadPin(GPIOA, HX711_DOUT) == GPIO_PIN_SET) { value++; } HAL_GPIO_WritePin(GPIOA, HX711_SCK, GPIO_PIN_RESET); } // 第25个脉冲表示通道A增益128,本项目不用切换 HAL_GPIO_WritePin(GPIOA, HX711_SCK, GPIO_PIN_SET); delay_us(1); HAL_GPIO_WritePin(GPIOA, HX711_SCK, GPIO_PIN_RESET); return value; } float Weight_GetGrams(void) { // 简单滑动平均滤波:取最近5次读取结果,去掉最大最小值再平均 uint32_t buf[5]; for (uint8_t i = 0; i < 5; i++) { buf[i] = HX711_Read(); HAL_Delay(100); } // 冒泡排序取中间三个值 for (uint8_t i = 0; i < 4; i++) { for (uint8_t j = i + 1; j < 5; j++) { if (buf[i] > buf[j]) { uint32_t tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp; } } } uint32_t sum = buf[1] + buf[2] + buf[3]; float avg = sum / 3.0f; // 标定系数,用已知砝码校准:系数 = 砝码克数 / (读取值 - 零点值) return (avg - zero_offset) / scale_factor; }滤波逻辑说明:HX711原始读数受噪声影响很大,滑动平均+去掉极值能有效抑制偶发尖峰。注意zero_offset和scale_factor两个参数需要标定,零点值就是空仓时读到的数据,系数用100g砝码压上去算。
常见误用是直接用HX711库返回的原始值当重量,这个值受传感器个体差异和安装位置影响极大,不标定的话余粮判断完全不靠谱。
4.4 RTC与掉电存储:断了一周电,喂食计划不能丢
DS3231自带电池备份,断电后时间不丢,但喂食计划和出粮量参数存在MCU的Flash里,断电就没了。课程设计阶段很多人忽略这个问题,实际使用中一次意外断电,所有配置清零,用户就得重新设一遍。
我把配置存在STM32内部Flash的最后一个扇区:
typedef struct { uint16_t feed_hour; // 定时喂食小时 uint16_t feed_min; // 定时喂食分钟 uint16_t feed_amount; // 每次出粮克数(乘以10存储) uint16_t crc16; // 校验值 } FeedConfig; void Config_Save(FeedConfig *cfg) { // 先擦除扇区,再写入 FLASH_EraseInitTypeDef erase = {0}; uint32_t sector_error = 0; erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.PageAddress = CONFIG_FLASH_ADDR; erase.NbPages = 1; HAL_FLASHEx_Erase(&erase, §or_error); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, CONFIG_FLASH_ADDR, cfg->feed_hour); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, CONFIG_FLASH_ADDR + 2, cfg->feed_min); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, CONFIG_FLASH_ADDR + 4, cfg->feed_amount); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, CONFIG_FLASH_ADDR + 6, cfg->crc16); }Flash写入次数有限(F103的数据闪存一般标称10k次擦写),所以只有用户修改参数时才调用Config_Save,喂食完成不写Flash。校验用sail16或简单累加和都行,目的是上电时检测配置有效,无效就恢复默认值。这里最容易翻车的点是Flash写入地址越界,我用的最后一个扇区是0x0800F800(64KB型号),不同容量芯片地址不一样,务必查手册再写。
5. 避坑记录:上电复位、粮食卡轴与RTC跑飞的典型排查
5.1 现象:一上电步进电机疯转,停不下来
第一次把代码烧进去,电机在初始化没完成时就开始转,而且完全不受控制,按停止键也停不下来。
原因排查:步进电机的四个控制引脚(IN1到IN4)在MCU复位期间是高阻态,ULN2003输入悬空时输出会乱跳,导致电机自由转动。另外我的初始化代码里先开了电机驱动器的使能,再初始化GPIO,顺序反了。
解决:把GPIO初始化放到系统时钟配置之后、任何外设使能之前,同时把四个控制引脚初始化为推挽输出低电平,确保电机上电处于锁止状态。代码顺序调整为:HAL_Init → 系统时钟 → GPIO初始化(电机引脚先拉低)→ 其他外设。从那以后我每次写新工程,都把执行机构引脚的电平状态列出来逐项检查,不再相信“默认低电平”这种话。
5.2 现象:粮食出口堵塞,电机持续转动但不出粮
测试时发现定时喂食触发后,电机在转,也能听到“咔咔”声,但出粮口几乎没有粮食落下。
原因排查:粮仓底部是平的,粮食堆在角落形成拱桥效应,叶轮转过去时没有粮食可带。这是机械结构问题,不是代码问题。
解决:把粮仓底部改成漏斗斜面(倾斜角度至少45度),在出粮口上方加一个竖直的搅拌棒,跟随电机一起旋转,把搭桥的粮食打散。这个改动之后,出粮重量稳定性从“每次差10g”改善到“每次差2g以内”。如果你用3D打印做外壳,设计时直接加这根搅拌结构,省得后面改造。
5.3 现象:DS3231时间偶尔跳变,喂食时间偏差超过半小时
系统运行两天后,RTC读取到的时间比实际快了半小时以上,而且不是稳定快,是突然跳变到某个错误值。
原因排查:我用的是I2C总线,OLED和DS3231挂在同一组引脚上。OLED刷新(每200ms一次)占用I2C总线时间,如果恰好被中断打断,DS3231的读操作可能读到半个字节,导致时间寄存器数据错乱。
解决:把I2C通信改用硬件I2C(HAL库的I2C1),并提高优先级。更彻底的方案是把OLED和DS3231分开,DS3231用软件模拟I2C挂在另外两个引脚上,OLED用硬件I2C挂原引脚。两者物理隔离后,干扰再没出现过。注意:软件模拟I2C要开编译优化,否则时序可能慢到DS3231不响应。
5.4 现象:手动按键偶尔触发两次喂食
按键按一下,有时会出粮两次。
原因排查:按键扫描周期是20ms,我用的机械按键没有做完整的“按下释放”状态检测,按下一次的抖动能被两个20ms周期各采样到一次,产生两个按键事件。
解决:按键扫描里增加状态机,识别“按下沿”而不是电平。按下时登记事件,释放前不再重复登记,同时加一个300ms的全局锁定时间,按键后300ms内的任何按键事件直接丢弃。代码上改一行判断,但效果明显。之前在实机上统计过,不带沿检测每秒能多触发0.5到2次误事件,带上之后基本为0。
5.5 现象:称重数值漂移,余粮误报
空粮仓时称重显示30g,跑了一会儿变成40g,又过了几分钟回到25g。
原因排查:HX711的参考电压受供电波动影响,电机启动时电流大,电源电压跌落了100mV左右,导致ADC读数偏移。另一个因素是温度漂移和机械结构的热胀冷缩。
解决:从两个方向处理。电源上,电机供电和MCU供电完全分开,两个5V入口接一个大电容(1000uF电解电容 + 0.1uF陶瓷电容)做储能。代码上,不要直接用单次称重结果,而是在电机停止运行后300ms再采样,避开电源波动窗口。同时做软件校准:每次喂食完成后,等待3秒,连续读5次,取中间值更新余粮显示。
6. 进阶玩法:加一个“无RTC离线喂食日志”功能
课程设计答辩时,评审经常会问“你怎么证明系统按时喂食了”。光靠“我看到的”不够有说服力,我给这个项目加了一个离线喂食日志模块,不依赖外部存储芯片,用STM32内部Flash剩余空间,记录最近50次喂食记录。每条记录的结构就四个字段:
typedef struct { uint32_t timestamp; // 从RTC读出的Unix时间 uint16_t feed_amount; // 实际出粮克数 uint8_t source; // 0=定时触发 1=手动触发 2=补食 uint8_t weight_after; // 喂食后食盆重量(单位:g) } FeedLogEntry;每条记录12字节,50条共600字节,加上前面配置项,Flash占用不到1KB。F103C8T6的13KB数据Flash有足够空间。写入用环形覆盖策略,满了就把最旧的一条重写掉。
void Log_AddEntry(FeedLogEntry *entry) { uint16_t next_idx = log_next_index; uint32_t addr = LOG_BASE_ADDR + (next_idx % MAX_LOG_COUNT) * sizeof(FeedLogEntry); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, *(uint32_t*)&entry->timestamp); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + 4, *(uint32_t*)&entry->feed_amount); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + 8, *(uint32_t*)&entry->weight_after); log_next_index++; }查询时用串口输出,或者用OLED翻页显示最近几条。这个功能在答辩前实测了三天,日志里有每一条喂食记录的时间戳和出粮重量,评审老师直接对着数据看,不用问“怎么证明”了。我后来把这个日志模块沿用到了其他嵌入式项目上,发现“给系统一个能自证的数据出口”比任何口头解释都省事。
在那以后,我每次做嵌入式课程设计都会先想一个问题——“如果评审只给我5分钟,我拿什么证明系统真的在按预期工作”。有这个功能兜底,心里踏实很多,希望帮到你。
本文还有配套的精品资源,点击获取