☰
基于51单片机的宠物投喂系统:从硬件选型到代码调试全解析
2026/10/14 1:23:19 网站建设 项目流程

我很久没做过这么“复古”的项目了,前阵子帮一位刚入门嵌入式方向的朋友调试他的课程设计,选的题目就是“基于51单片机的宠物投喂系统(简易版)”。本以为又要面对那种千篇一律的流水账代码,结果实际看下来,这个看似简单的题目里藏着不少值得深挖的细节。

很多新手拿到这类题目,第一反应是“不就是定时转舵机嘛”,但真正把硬件搭起来、代码烧进去、放在桌上跑一周之后,你会发现从按键消抖到舵机抖动,从掉电存储到粮口卡粮,每一个环节都在考验你对单片机这门手艺的基本功。这篇文章我就把这个系统从需求拆解、芯片选型、电路设计、代码实现到最后的调试排坑,完整地梳理一遍。已经做完的同学可以对照检查有没有遗漏的坑,还没动手的可以直接按这套思路抄作业。

1. 需求拆解:这个系统到底在解决什么问题

先说结论:简易版宠物投喂系统的核心价值,不是“远程控制”“App联动”这些花哨功能,而是解决一个非常具体的痛点——主人短期不在家时,宠物能否定时、定量地吃上饭。

很多第一次做这个题目的同学容易把需求想复杂,一上来就琢磨加摄像头、加语音识别、加手机App。但“简易版”三个字已经划定了边界:本地自动出粮、手动补喂、参数可设置、掉电不丢参数,做到这四件事,这个项目就已经合格了。再往上加功能属于进阶玩法,后面我会单独说。

我把需求拆成四层:

功能模块核心需求实现方式优先级
定时出粮每天在设定时间自动出粮一次或多次实时时钟计时 + 舵机转动出粮必须
手动补喂主人随时按键触发一次出粮独立按键检测 + 舵机转动出粮必须
参数设置设置/修改定时时间、出粮时长按键 + 显示屏 + 菜单逻辑建议
掉电保存断电重启后设置不丢失片外EEPROM存储建议

这里我说一个容易被忽视的点:“定量”不是精确到克。简易版系统通过控制舵机转动角度和停留时间来控制出粮量,而不是通过称重传感器做闭环。原因很简单,51单片机做闭环称重不是不行,而是成本和复杂度都会大幅上升——你需要HX711称重模块、需要标定、需要处理动态称重的抖动,这些对一个入门级项目来说负担过重。

所以,“定量”在这个项目里应该被理解为“固定时长出粮”或“固定角度出粮”。比如舵机每次转到120°停留1.5秒再归位,放出的粮量是相对固定的。这已经能满足绝大多数临时喂食场景。

另外一个细节是喂食时间的颗粒度。很多同学想把时间精确到秒,但实际上宠物喂食只需要精确到分钟即可。这意味着你不需要外挂DS1302或DS3231实时时钟芯片——用单片机内部的定时器做一个累计计时就可以,每天误差控制在几十秒内完全够用。当然,如果确实想要断电后时间不丢,可以加一个DS1302,但在简易版里这不是必需项,AT24C02存定时参数就够了。

2. 硬件选型与电路设计:从元件清单到接线细节

2.1 为什么选51单片机,以及选哪一颗

在这个项目里,51单片机依然是性价比最高的选择。原因有三:一是这类芯片的IO口都是5V电平,和舵机、LCD1602这些外设直接对接,不需要额外做电平转换逻辑;二是资料极多,遇到问题搜“某某模块 + 51示例”基本都能找到参考;三是成本极低,整板做下来元件花费通常不超过30块钱。

芯片具体型号,常见的有AT89S52和STC89C52RC。我个人更推荐STC89C52RC,原因很实际:它支持ISP下载,一根USB转串口线就能烧录程序,不需要额外的编程器。AT89S52虽然经典,但下载程序需要ISP下载器,对新手来说多一道门槛。如果你用的是普中、天祥这类开发板,上面集成的一般就是STC89C52RC,直接用板载接口烧录即可。

晶体振荡器建议直接用11.0592MHz,而不是12MHz。虽然两种都能跑,但11.0592MHz能让串口波特率计算无误差。如果后面你想加蓝牙模块、ESP8266做远程控制,这个晶振选择会让你少踩一个大坑。定时器计时方面,11.0592MHz不是整数,做1ms中断需要做重装值补偿,这个后面代码部分细说。

2.2 核心外设清单

模块具体型号作用备注
主控STC89C52RC逻辑控制核心DIP40封装
显示屏LCD1602显示时间、状态、设置项可选择I2C版本省IO
舵机SG90 9g出粮执行机构扭矩5~7kg·cm够用
存储AT24C02保存定时参数I2C接口
按键独立按键 x4设置、加减、确认、手动喂食需软件消抖
蜂鸣器有源蜂鸣器操作反馈、喂食提示选配但建议加
电源5V/2A USB电源整机供电舵机启动瞬时电流大

这里重点说舵机。SG90有几种规格,常见的有“SG90 9g普通舵机”和“MG90S金属齿舵机”。简易系统用SG90完全够,但有一点必须注意——SG90在工作时电流峰值能到700mA左右,堵转时甚至更高。这意味着你的供电不能只靠单片机开发板的USB口直接拖,舵机启动瞬间很可能把开发板上的电压拉低,导致单片机复位。

我的做法是:舵机的电源从USB 5V主电源直接取,地线还是和单片机共地。在舵机电源引脚旁边并联一个470uF的电解电容和两个104陶瓷电容,一个滤低频纹波,一个滤高频干扰。这个电容组合在舵机频繁动作时能明显减少电压跌落。

2.3 电路接线表与关键注意事项

以下是我实测可用的接线方案,LCD1602走的是4位数据线模式,可以省四个IO口:

引脚连接对象说明
P0口LCD1602 D4~D7 + RS + ENP0需外接10K上拉排阻或软件开漏处理
P2.0舵机信号线定时器/PWM输出
P2.1蜂鸣器高电平触发(看模块类型)
P2.2按键1(菜单/确认)接地触发,需消抖
P2.3按键2(加)同上
P2.4按键3(减)同上
P2.5按键4(手动喂食)同上
P2.6AT24C02 SDA需接上拉电阻
P2.7AT24C02 SCL需接上拉电阻

有几个新手容易忽略的接线细节,我单独拎出来说:

  1. P0口必须接上拉电阻。这是51单片机架构决定的,P0口是开漏输出,不接上拉就是高阻态,LCD1602会显示乱码甚至不亮。如果你用洞洞板自己焊,记得预留8个10K排阻的位置。

  2. AT24C02的SDA和SCL必须要外部上拉。虽然51单片机大多数IO口是准双向口,能内部弱上拉,但I2C总线的时序对上升沿要求比较高,建议在SDA和SCL上各加一个4.7K到10K的上拉电阻到VCC。我一开始偷懒没加,结果写数据时不时失败,查了半天。

  3. 按键的一端接IO口,另一端接GND。这样配置IO为高电平,按键按下拉低。注意不要接VCC,否则按键误触时如果IO口配置为输出状态,容易引起短路或误触发。

  4. 舵机信号线最好单独走线,不要和LCD1602的使能线、数据线扎在一起。舵机PWM信号和1602的数据信号都在高频翻转,线扎太近容易串扰,导致显示偶尔花屏。我用的是杜邦线,把舵机信号线单独甩开一段距离,问题立刻消失。

2.4 PCB或洞洞板的布局建议

如果是课程设计要用洞洞板焊接,布局上有一条经验值得参考:把系统按“数字部分”和“电机部分”分区。数字部分包括单片机、LCD1602、按键、AT24C02,这些器件工作电流小、信号逻辑强;电机部分就是舵机及其电源滤波电容。

两个部分的地线最终在电源入口处单点汇合,不要长距离平行走线。这样做可以避免舵机大电流在PCB地线上形成压差,干扰单片机的逻辑电平判断。用洞洞板飞线的时候,尽量把舵机的三根线(GND、VCC、Signal)拧在一起走,这样电磁干扰最小。

3. 出粮执行机构:舵机、粮仓与机械结构怎么配合

这个环节是很多作品“看起来能转,实际喂不了粮”的重灾区。电路代码都对,舵机也转了,但粮食要么堵住不下,要么一次出一大堆。问题往往出在机械结构上,而不是程序上。

3.1 粮食储仓设计

粮食储仓就是装狗粮猫粮的容器。最省事的方案是用一个矿泉水瓶或亚克力圆筒,瓶口朝下,底部开一个口。但这里面有个物理常识需要重视——颗粒粮的流动性取决于颗粒大小、表面摩擦力和容器出口的角度。

我踩过的坑是这样的:第一次用普通的矿泉水瓶,瓶口直接朝下,结果颗粒粮在瓶口拱起来,形成“拱桥效应”,舵机开门那一刻没有粮落下来,等门关上了粮又塌下来,出粮量完全随机。

后来换了方案:瓶口朝下但底部加装一个锥形漏斗过渡段,漏斗角度在45°到60°之间,出口直径要略大于一颗粮的三倍直径。这个设计保证了出粮口附近始终有一段“自由下落区”,粮不会在出口处堆积成拱形。

如果你的宠物粮颗粒直径大约8mm,出口直径做得24~30mm左右较为合适。用亚克力筒的话,把底部用3D打印的漏斗接头过渡一下,效果很好。用矿泉水瓶的话,可以把瓶口上部切掉,倒扣在漏斗口上方,瓶内放粮。

3.2 挡板式出粮 vs 转盘式出粮

这是目前公认比较适合51单片机项目的两种出粮机构:

挡板式:舵机臂连接一块挡板,平时挡板封住出粮口,舵机转动时拉开挡板,粮靠重力落下。优点是结构简单、容易实现;缺点是挡板拉开的瞬间出粮量大,关闭时又可能有粮卡在挡板边缘,导致挡板闭不严,一直漏粮。

转盘式:用一个圆盘,圆盘上挖两个对称的孔洞。粮食通过底部漏斗进入圆盘孔,舵机转动90°,带孔的圆盘把粮转到出粮口下方,粮落下。这种结构每次出粮量更均匀,更接近“定量”的概念。

对于简易版,我建议用挡板式起步,因为舵机控制逻辑和调试门槛都更低。但要注意一点——挡板的关闭力方向要和重力方向垂直。也就是说挡板是水平抽拉的,靠舵机臂把挡板横向拉出和推回。如果挡板是竖直方向开合,粮粒容易卡在挡板铰链的位置。

3.3 舵机臂与挡板的连接方式

舵机臂和挡板的连接,最可靠的方式是“销钉+套环”,而不是热熔胶直接粘。热熔胶在冬天低温下会变脆,多次动作后可能脱落。

我自己用过的一个简单有效方案:用一块2mm厚的PVC板裁成矩形,一端打一个和舵机臂配套的孔,舵机臂用随机附带的螺丝锁紧,挡板的另一端插入一个固定在粮仓出粮口的导轨槽里。PVC板有一定的弹性,挡板在导轨槽里滑动顺畅又不会松动。

舵机安装角度也要提前算好。SG90默认0°时挡板完全关闭,转到60°~90°时挡板拉开约出粮口宽度的60%~80%,已经是比较合适的出粮行程。不要每次设置180°,挡板拉太大,粮仓内的粮会一次性大量涌出,也很难收回原位。

3.4 防卡粮的机械措施

就算漏斗角度做对了,也不能保证100%不卡粮。我的经验是加两重保险:

第一,出粮口内侧加一根竖直的拨片。这颗拨片可以是一根直径1.5mm左右的钢丝,固定在挡板运动方向上。当挡板拉动时,钢丝跟着横向移动,破坏粮粒之间的拱形结构,把结拱的粮拨散。

第二,舵机动作采用“往复抖动”。不要在挡板打开后保持不动等待落粮,而是让舵机在开到位和关到位之间小幅往复运动两三次,比如每次停留200ms左右。这个动作有点像人摇动抽屉帮助米粒落下去,实测能大幅降低卡粮概率。

这个抖动逻辑不要放在中断里做,直接在出粮函数里用循环+延时实现即可。51单片机在做这个操作时会阻塞其他逻辑,但出粮过程本身只有一两秒,不至于影响整个系统的响应。

4. 软件核心逻辑:状态机、定时控制与数据保存

硬件搭好后,软件就是重头戏了。我不建议上来就写流动的代码,先把逻辑框架梳理清楚,写起来会顺很多。

4.1 系统状态机的设计

整个系统可以划分为三个运行状态:

  1. 状态A:正常运行。LCD1602显示当前累计时间和下次定时喂食倒计时。此时系统循环检测按键,如果有按键按下做短暂响应,然后用“非阻塞”的方式继续计时。
  2. 状态B:参数设置。用户按下设置键进入,此时可以调整定时喂食的时间(时、分)和出粮时长。设置状态有一个“闪烁”的当前设置项,提示用户当前在改哪个参数。
  3. 状态C:手动喂食。用户直接按手动喂食键,立即启动出粮流程。出粮期间可以允许再次按键触发,但必须在一次出粮结束后才能开始下一次。

这组状态切换逻辑用switch-case实现就非常清晰:

typedef struct { unsigned char mode; // 0=正常 1=设置 2=喂食中 unsigned char hour; unsigned char minute; unsigned char feed_duration; // 出粮时长(0.1秒为单位) } SysConfig;

这里有个新手容易犯的设计错误——把“喂食中”当临时变量而不是状态。如果喂食期间来了按键,或者定时触发了,又进入另一个喂食流程,就可能导致舵机指令打架,两个位置同时被写。把“喂食中”定义成一个独立状态,每次进入这个状态前记录触发源,流程结束后统一退出,很多隐患自然就消除了。

4.2 定时喂食的判断逻辑

定时喂食的逻辑分为两部分:计时和触发。

计时部分,用定时器0产生1ms中断,在中断里累加计数达到1000次就是1秒钟。秒数累加60次得到分钟。主循环里,只需要读取“当前分钟”和“设置分钟”做比较,如果相等且不等于上次触发时的分钟,就触发喂食。

这里有一个关键点:要防止重复触发。比如设置的是8:30喂食,到8:30触发了一次,但主循环继续执行,下个循环又读到8:30,如果没做标记,就又触发一次。我的做法是加一个“今日已喂”标志位,每次触发后置1,到第二天零点清零。更保守一点的做法是记录“最后一次喂食的分钟值”,在当前分钟值改变时才重新判断。

触发后的出粮流程建议用状态机的子步骤实现:

步骤动作时间
步骤1蜂鸣器响一声(50ms),提示开仓100ms
步骤2舵机转到开仓角度500ms
步骤3挡板保持打开,等待出粮设置的时长
步骤4舵机转回关闭角度500ms
步骤5蜂鸣器再响一声,提示关仓完成100ms

步骤3的等待时长是“出粮时长”参数,初值我建议设为1.0~1.5秒。如果粮仓设计得好,1秒的出粮量大约在5~10g之间,每天喂两次的话,10g/次的量基本够一只成年猫或者小型犬吃一天。

4.3 舵机PWM控制与角度映射

SG90舵机的控制标准是50Hz的PWM信号,周期20ms,高电平宽度决定角度。不同品牌的舵机对角度-脉宽的映射略有差异,我实测的SG9典型范围是:

角度脉宽
0°0.5ms
90°1.5ms
180°2.5ms

单片机要产生这个PWM,做法是用一个定时器,20ms周期内,前N微秒输出高电平,其余时间输出低电平。为了保证角度准确率,我一般不用阻塞延时来做PWM(那样会卡死整个系统),而是用定时器0做1ms基准,再在主循环里用标志位累加实现。

具体思路:定时器0每1ms中断一次,中断里计数++。当计数到0(新周期开始)时,PWM引脚输出高;当计数等于“角度对应的毫秒数”时,输出低。比如90°对应1.5ms,那么计数到2时拉低,然后计数到20时归零重来。这样一个周期正好20ms,1ms的分辨率对应0.5°的角度换算关系,简化到千分之一精度,对喂食这种场景完全够用。

我的实测经验是:SG90的线性度不算好,程序里写了90°不等于真的转了90°,不同舵机个体之间也有偏差。所以代码里不应该写死具体的角度值,而应该提供“校准接口”——在设置菜单里增加一项“舵机开度微调”,允许用户用加减键微调开仓角度,每次增减1°。这个微调值存到AT24C02里,调试时特别有用。

4.4 AT24C02掉电存储的实现要点

AT24C02是2Kb(256字节)的EEPROM,通过I2C总线读写。51单片机没有硬件I2C外设(STC89C52确实没有),所以必须软件模拟I2C时序。

软件模拟I2C的要点:

  1. SDA和SCL必须配置为推挽输出。在开漏模式下要加上拉电阻,而51的IO口准双向模式配合外部上拉电阻是可以工作的。
  2. 起始条件,SCL高电平时,SDA从高跳变到低;停止条件则是SCL高电平时,SDA从低跳变到高。这两个时序是I2C的基础,不能搞反。
  3. 每写一个字节后要从地址0x50读回确认。AT24C02写入一个字节后需要约5ms的内部写周期。写多个字节时,两个字节之间的间隔不能少于这个时间。

我在代码里封装了两个函数组:

// 模拟I2C基础 void I2C_Start(void); void I2C_Stop(void); void I2C_SendByte(unsigned char dat); unsigned char I2C_RecvByte(void); bit I2C_WaitAck(void); // AT24C02操作 void AT24C02_WriteByte(unsigned char addr, unsigned char dat); unsigned char AT24C02_ReadByte(unsigned char addr);

用户参数在AT24C02里的地址规划:

地址内容
0x00定时喂食时(0~23)
0x01定时喂食分(0~59)
0x02出粮时长(0.1秒为单位)
0x03舵机开仓角度微调
0x10参数有效标志(固定值0x5A)

这里有个很实用的技巧:用“参数有效标志”来区分首次上电和正常上电。首次上电时,AT24C02是空的,读出来的可能是0xFF,如果直接把0xFF当成小时设置,喂食时间就变成0xFF小时,闹钟逻辑直接崩溃。我的做法是:上电先读0x10地址,如果读到0x5A说明参数有效,正常加载;否则使用默认参数,并马上把默认参数+标志位写回EEPROM。这样芯片出厂状态、程序重烧状态都不会出问题。

4.5 按键消抖与长按逻辑

按键消抖最简单的方法是“扫描+延时20ms确认”。在主循环里,检测到按键电平变化后,延时20ms再读一次,如果还是变化后的电平,才确认有效。这个延时要不要阻塞主循环?我的建议是不要。用状态机方式处理按键扫描:检测到按下时记录时间,20ms后在中断里再检查一次电平状态。

如果你用的是开发板自带的独立按键,一般已经做了硬件消抖或用了比较稳定的轻触开关,软件上即使不做消抖也明显好于裸接。但我见过一些同学用面包板飞线按键,面包板的接触很松,软件消抖没做好,设置参数时数字会跳好几格。所以消抖这步不能省。

关于长按设置:简易版可以不支持长按加速,加减键每个按键只处理一次步进就行。如果想让用户体验好一点,每次按键步进1分钟,按住1秒后可以步进5分钟,这样可以快速设置时间。但长按逻辑的“1秒”检测需要额外的时间计数,会增加状态机的复杂度,初学者可以先不做,用一个“按一下加1分钟”的简单逻辑完成功能即可。

5. 核心代码实现与模块拆解

代码这块我会按照模块来讲,各个模块的代码是独立的,方便直接搬进自己的工程。

5.1 定时器0:产生1ms基准中断

定时器0的工作模式设置为模式1(16位定时器),1ms中断一次。使用11.0592MHz晶振时,需要计算出定时器初值。

11.0592MHz晶振,12T模式(STC89C52默认),机器周期是12 ÷ 11.0592MHz ≈ 1.085µs。1ms需要约921.6个机器周期,定时器是16位,最大65535,所以初值是65536-922=64614,即0xFC66。

void Timer0_Init(void) { TMOD &= 0xF0; TMOD |= 0x01; // 定时器0,模式1,16位 TH0 = 0xFC; // 1ms 定时初值高8位 TL0 = 0x66; // 1ms 定时初值低8位 ET0 = 1; EA = 1; TR0 = 1; } unsigned int ms_tick = 0; void Timer0_ISR(void) interrupt 1 { TH0 = 0xFC; TL0 = 0x66; ms_tick++; }

这段代码用重装初值的方式产生1ms中断。注意:中断里重装TH0/TL0有大约几个机器周期的误差,但累积误差在一天内也就几百毫秒,对这个项目完全可以接受。如果你对精度有追求,可以用“预置法”——在进入中断前预先计算好下一个初值,这样更精确。但代码复杂度会增加,不划算。

5.2 正确实现背靠背任务调度

51单片机不是操作系统,没有真正的多任务。但可以用一个简单的“时间任务表”来组织主函数里的逻辑。思路是:主循环不断轮询各个时间段,每个任务检查自己的时间计数是否到了,到达就执行。

void main(void) { unsigned int last_sec = 0; unsigned char last_minute = 0xFF; Timer0_Init(); Lcd_Init(); I2C_Init(); EEPROM_LoadConfig(); while(1) { // 1ms任务:处理按键扫描(去抖) if(ms_tick - key_scan_tick >= 20) { key_scan_tick = ms_tick; Key_Scan(); } // 1s任务:更新时间显示 if(ms_tick - sec_tick >= 1000) } sec_tick = ms_tick; sys_sec++; Update_Display(); } // 定时喂食触发 if(sys_minute != last_minute) { last_minute = sys_minute; if(sys_minute == config.feed_minute && config.enable_feed) Start_Feed(); } // 出粮过程持续更新 if(sys_status == STATUS_FEEDING) { Feed_Process(); } } }

这个写法的精髓是用“非阻塞”的方式让所有任务都有执行的机会。相比用delay死等,这样的代码系统响应性更好,按键在任何时候都能被响应,哪怕正在出粮过程,只是出粮状态机内部有阶段判断而已。

需要特别说明:ms_tick是一个unsigned int变量,它在溢出时(65535毫秒/65秒)会自动回绕。上面的写法用“ms_tick - 上次时间 >= 间隔”来判断,而不是“ms_tick == 上次时间 + 间隔”,可以让回绕问题自动化解。

5.3 舵机PWM:用时间片轮询生成

我用的是“1ms时间片”方式在中断里驱动舵机PWM。思路是这样的:把PWM周期定为20ms,用一个“pwm_counter”变量从0加到20,中断每1ms走一格。0代表周期开始,输出高电平;当pwm_counter到达“高电平毫秒数”时输出低电平。

volatile unsigned char pwm_counter = 0; volatile unsigned char pwm_level = 0; // 0低 1高 volatile unsigned char pwm_target_ms = 1; // 高电平持续时间(ms) void Timer0_ISR(void) interrupt 1 { TH0 = 0xFC; TL0 = 0x66; pwm_counter++; if(pwm_counter >= 20) { // 20ms周期 pwm_counter = 0; pwm_level = 1; SERVO = 1; } else if(pwm_counter >= pwm_target_ms) { if(pwm_level) { pwm_level = 0; SERVO = 0; } } }

高电平毫秒数pwm_target_ms和角度的对应关系:0.5ms对应0°,1.5ms对应90°,2.5ms对应180°。因为定时器是1ms中断,所以角度值只能映射到整数毫秒。为了更精细,可以把中断改成0.1ms粒度,但那样定时器初值就不是整数了,计算量有点大。我的经验是1ms粒度配合“微调角度”功能已经足够平滑,因为舵机本身都有机械死区,并未要求精确到0.5°以内。

舵机状态标志位:如果舵机正在转(正在从old角度转向new角度),应该启动一个舵机状态机,用一个循环把角度值从当前值逐步过渡到目标值,每间隔20ms改变一次高电平毫秒数,每次改变0.05ms(50µs),这样舵机转动过程大致持续1/20*步数秒。

要点是:不要让pwm_target_ms瞬间从0.5跳到2.5,那样舵机会猛转,粮食容易从仓口飞出且容易堵住。

5.4 LCD1602显示逻辑

LCD1602有两行×16字符,显示内容规划很明确:

第一行显示当前系统时间(累计运行的时/分)和状态(SET/FEED/RUN):

Time: 08:30 RUN

第二行显示定时喂食时间或提示信息:

Feed: 08:30 2.0s

需要处理好的是“时间显示”和“状态提示”的混合显示。注意1602一次写入一个字符,从printf直接写会有重复闪烁问题,我建议用LCD_SetCursor先定位,再写固定字符串,而不是一行整写重刷。

void Update_Display(void) { char buf[17]; sprintf(buf, "Time:%02d:%02d %s", sys_hour, sys_minute, sys_status == STATUS_SET ? "SET" : "RUN"); Lcd_ClearLine(0); Lcd_WriteString(0, 0, buf); // 第二行显示设置时间 sprintf(buf, "Feed %02d:%02d %.1fs", config.hour, config.minute, config.feed_duration * 0.1); Lcd_ClearLine(1); Lcd_WriteString(1, 0, buf); }

我用的是比较常规的4位数据线模式。注意4位模式下,初始化时要连续发送3次0x03再切到0x02,这是LCD1602数据手册里规定的时序要求,很多新手在这里卡住。

5.5 出粮状态机的完整实现

“出粮过程”分为多个子步骤,用状态机逐步推进:

typedef enum { FEED_IDLE, FEED_ALERT_OPEN, // 蜂鸣器提示开仓 FEED_MOVING_OPEN, // 舵机转到开仓角度 FEED_HOLDING, // 保持开仓,等待出粮 FEED_MOVING_CLOSE, // 舵机转回关闭角度 FEED_ALERT_CLOSE, // 提示关仓完成 FEED_DONE } FeedState; unsigned char feed_step = FEED_IDLE; unsigned int feed_step_timer = 0; void Start_Feed(void) { if(sys_status != STATUS_FEEDING) { sys_status = STATUS_FEEDING; feed_step = FEED_ALERT_OPEN; } } void Feed_Process(void) { switch(feed_step) { case FEED_ALERT_OPEN: Buzzer_On(); feed_step_timer = ms_tick; feed_step = FEED_MOVING_OPEN; break; case FEED_MOVING_OPEN: if(ms_tick - feed_step_timer >= 100) { Buzzer_Off(); Servo_SetTarget(OPEN_DEGREE); // 舵机转到角度 feed_step_timer = ms_tick; feed_step = FEED_HOLDING; } break; case FEED_HOLDING: if(ms_tick - feed_step_timer >= config.feed_duration * 100) { // 抖动出粮:往复一次 Servo_SetTarget(OPEN_DEGREE - 8); // 延时200ms后将回到打开角度,再延时300ms后关 feed_step_timer = ms_tick; feed_step = FEED_MOVING_CLOSE; } break; case FEED_MOVING_CLOSE: if(ms_tick - feed_step_timer >= 800) { Servo_SetTarget(CLOSE_DEGREE); feed_step_timer = ms_tick; feed_step = FEED_ALERT_CLOSE; } break; case FEED_ALERT_CLOSE: if(ms_tick - feed_step_timer >= 100) { Buzzer_On(); feed_step_timer = ms_tick; feed_step = FEED_DONE; } break; case FEED_DONE: if(ms_tick - feed_step_timer >= 50) { Buzzer_Off(); sys_status = STATUS_RUN; feed_step = FEED_IDLE; } break; } }

这个状态机的最大好处是:整个出粮过程中,主循环依然在跑,按键依然可以响应。即使你在出粮过程中按了手动喂食键,也只会置一个“待喂食”标志位,等当前出粮流程结束再执行下一次,避免了舵机指令冲突。

5.6 AT24C02读写代码

AT24C02的I2C地址是0xA0(写)/0xA1(读),具体取决于A0/A1/A2引脚接地还是接VCC。默认全接地,写地址就是0xA0,读地址0xA1。

void AT24C02_WriteByte(unsigned char addr, unsigned char dat) { I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); I2C_SendByte(dat); I2C_WaitAck(); I2C_Stop(); Delay5ms(); // 必须等待内部写周期完成 } unsigned char AT24C02_ReadByte(unsigned char addr) { unsigned char dat; I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); I2C_Start(); I2C_SendByte(0xA1); I2C_WaitAck(); dat = I2C_RecvByte(); I2C_SendNack(); // 读最后一个字节后发NAK I2C_Stop(); return dat; }

读取AT24C02的时序是“伪读”流程:先写地址,然后重新发送起始条件,再切到读模式。如果漏掉中间重复的Start信号,数据就读不出来。这是我见过的最常见I2C bug之一。

上电加载参数的逻辑:

void EEPROM_LoadConfig(void) { if(AT24C02_ReadByte(0x10) != 0x5A) { // 首次上电,写入默认参数 config.hour = 8; config.minute = 30; config.feed_duration = 10; // 1.0秒 config.open_degree = 60; // 开仓角度 EEPROM_SaveConfig(); AT24C02_WriteByte(0x10, 0x5A); } else { config.hour = AT24C02_ReadByte(0x00); config.minute = AT24C02_ReadByte(0x01); config.feed_duration = AT24C02_ReadByte(0x02); config.open_degree = AT24C02_ReadByte(0x03); } }

建议把小时/分钟/时长的合法性检查加上,比如小时大于23或分钟大于59时,强制恢复默认值。这样即使EEPROM数据被意外改写,系统也不会跑飞设置。

6. 调试实录:那些不实测就发现不了的坑

我自己的项目包括帮朋友调过的几个版本,前前后后遇到过大概十几个问题。挑最有代表性的四个记录下来,给后来者当参考。

6.1 舵机堵转发烫,电流过大导致单片机复位

第一次通电测试,舵机转到设定角度后,用手轻触舵机臂,感觉阻力很大,几分钟后舵机外壳明显烫手,随后单片机重启。

排查过程:先用万用表测试舵机供电电压,空载时5V正常,转动瞬间跌到3.8V左右。这说明电源带不动舵机的瞬时电流。再看代码,发现舵机在到角度后没有做“角度释放”处理,舵机仍然在持续输出力矩,相当于堵转状态,长期堵转发热是必然的。

修复方案:舵机到达目标角度后,程序应该停止PWM输出或把PWM信号置为高阻状态,让舵机“断电保持”。但这样挡板会自行下落。折中做法是:舵机到位后继续输出低电平而不是PWM波形,利用舵机电路的机械自锁特性维持位置。

我最终的方案是:舵机到位后保存角度值,然后每100ms输出一次PWM“刷新信号”保持力矩,其余时间信号线拉低。实测舵机温度明显下降,也不影响挡板闭锁。

其实更简单的办法是换用“锁止舵机”或“蜗杆减速舵机”,但简易版成本和调试复杂度都要控制,我的做法已经够用。

6.2 LCD1602不显示或花屏:P0上拉电阻的教训

有同学遇到LCD1602只有背光亮,无字符。查原理图发现他P0口直接连LCD数据线,没加上拉电阻。51的P0口是高阻态,没有内部上拉,LCD数据引脚浮空导致初始化时序无法正确解析。

我确认了这一点之后,给P0口加上10K排阻,字符立刻正常。这里也提醒一下:如果你的开发板已经有P0上拉电阻,那么直接接即可;如果自己焊板,一定不要省这8个电阻。

6.3 掉电后参数丢失:静态RAM误当成EEPROM

一个同学反馈“设置好的时间,断电重启后变回8:30”,他用的AT24C02,但是排查发现他的AT24C02地址线A0/A1/A2全都接到了VCC,造成器件地址变成0xAE/0xAF,而代码里用的是默认地址0xA0/0xA1。读写当然无效。 还有一个更隐蔽的问题——有些人把“外部RAM”和“EEPROM”混为一谈,用MOVX指令扩展的6264静态RAM当EEPROM用,断电当然丢数据。这道题要求的是掉电保存,所以must用EEPROM或者带电池的RAM方案。

6.4 定时喂食不触发,误差累积

正常上电运行,等了一整天没到设置的8:30触发。排查发现:程序里定时器0用11.0592MHz晶振,但我用的定时器初值是假设12MHz算出来的,导致1ms定时实际变成约1.085ms,一天累积误差约1.2秒左右。虽然不至于差太久,但连续运行一周后误差累积到几分钟,喂食时间明显偏晚。

更致命的错误是:代码里用“if(当前分钟 == 设置分钟)”作为触发条件,但如果系统开机时正好在设置时刻前后,会漏掉第一次触发。比如8:29开机,8:30应该触发但系统可能因为初始化耗时几十毫秒错过了8:30:00这一帧,而判断只检查分钟相等,于是当分钟从30跳到31时,再也不会触发。要避免这个,应该用“如果当前分钟 == 设置分钟,且距上次触发超过了一分钟,才触发”,或者判断“当前分钟刚刚变化”,保证只要8:30出现,无论精确到哪一秒都会触发。

7. 用了一个月后的实测体验与扩展思路

整机装好后,我实际用了一个多月,每天定时喂两次(8:30和18:00),出粮时长设定1.2秒。实测下来,平均每次出粮重量误差大概在±2克左右,偶尔遇到颗粒格外大的猫粮(比如冻干),出粮量会明显偏少,靠“往复抖动”基本能兜底。电池(用充电宝5V供电)连续运行一周没有问题,AT24C02里的参数一直没有丢过。

这段使用中我发现的几个值得改进的地方:

  1. 定时器的时间精度。单片机内部计时在长时间运行后会有漂移,且每次开机时间从0开始,需要重新设置。如果要求更准,建议加一个DS3231实时时钟模块,时间精度到秒级,断电时间也不会丢。

  2. 粮仓余量检测。简易版没有余量检测,粮空了系统也不会提示。一个低成本的方案是在粮仓底部贴一个对射式红外传感器或者光电开关,检测粮食是否见底。当红外接收端检测不到遮挡(即无粮遮挡)时,触发报警提示。接线也只有两根线加一个上拉电阻,逻辑在代码里就三五行,做起来不难。

  3. 物联网远程控制。如果你做完简易版还有余力,可以加ESP8266模块。核心思路是用一个串口转WiFi模块,51单片机通过串口和它通信,手机端用MQTT或者HTTP接口下发指令。注意51的串口波特率建议用9600,稳定可靠。但要注意,51的串口只有一个,如果你还要用串口下载调试程序,就需要在调试时和运行时切换模块,建议做一个小跳线开关来切换。

  4. 定量出粮升级。如果你想让“定量”名副其实,加一个HX711称重模块。出粮过程中,实时读取重量变化,达到设定重量就立刻关仓。这样从“按时长出粮”升级为“按重量出粮”,是质的飞跃。但HX711的称重数据读取需要SPI或者额外引脚模拟时序,51的IO口资源要提前规划。

做这个项目的过程中,我最大的体会是:一个“简易版”的题目,不等于可以草率地糊弄过去。恰恰因为设备简单,更能逼着你把底层问题想透彻——为什么用定时器而不是delay,为什么I2C要等5ms,为什么舵机不能一上来就满行程跑。把这些搞明白之后,以后再玩STM32、ESP32,很多概念都是无缝迁移的。

最后分享一个小技巧:如果调试时遇到出粮不顺畅,先别急着改电路。试着把粮仓抬高一点,让重力势能更强一些,出粮效果会有明显改善。等到基本功能都稳定了,再慢慢把高度降下来,测试极限位置。这种“由高到低找边界”的调试顺序,比一上来就放到最低位置慢慢等卡粮要高效得多。

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

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

立即咨询