简介:基于STC89C52单片机设计的自动售货机系统资料,面向单片机/嵌入式课程设计、毕业设计及入门学习者。整套方案围绕商品选购、货币识别、余额计算、找零与LCD实时显示等功能展开,采用3×3矩阵键盘作为输入,并用C/C++实现核心控制逻辑;文档从绪论、总体设计方案到关键芯片选型、硬件电路连接、软件模块划分、系统联调与结论,均按课程设计报告思路完整呈现,还单独附有源码,可直接参考移植或二次开发。资源内容集中在一个docx文档中,压缩包约427KB,体量虽小但结构完整,便于快速通读和打印归档。当前已有339人学习,适合需要完成同类课题、理解自动售货机工作流程,或希望掌握STC89C52外设控制的读者重点参考,尤其可借鉴矩阵键盘扫描、货币识别处理、货道驱动和显示刷新等模块化设计方式。
1. 一台STC89C52售货机值不值得做
自动售货机的课设项目做了这么多年,从 51 到 STM32 再到 ESP32 方案都有人在传,但回到 8 种商品、3 档价格、投币累计、余额不足退回这一组需求,STC89C52 依然是门槛最低、最容易把逻辑讲清楚的选择。8K Flash 装下全部 C 源码,512B RAM 跑状态机够用,32 个 I/O 接完矩阵键盘、LCD1602 和出货指示之后还有剩余。很多人觉得 51 太老,但真正做产品时,低路数售货机、扭蛋机、盲盒机里这类单片机仍然大量存在。这篇按硬件选型、C 代码组织、Keil 编译调试这条线,把整台机器从原理图到源码怎么落地拆开讲,附录里的主函数、显示驱动和提示程序三份源码可以直接拼起来跑。
2. 硬件选型与STC89C52周边电路
2.1 为什么是STC89C52:资源盘点与选型逻辑
STC89C52 的本质是增强型 MCS-51,指令集和引脚与 Intel 8052 兼容,但内部整合了 ISP 引导、看门狗、4KB EEPROM 和三路 16 位定时器。8K Flash 装这个项目的 C 代码绰绰有余,512B RAM 中 data 区虽然只有 128B,但配合code段放常量表,跑一个售货机交易状态机完全够用。相比直接上 STM32F103,STC89C52 的成本优势非常明显,散买几块钱一片,直插封装手工洞洞板就能搭,不需要画四层 PCB,这对课程设计和前期原型验证都是实打实的便利。
选型上有一个容易被忽略的点:I/O 数量。这个系统要占用的引脚包括 3×3 键盘 6 个、LCD1602 的数据线 8 个加 RS/RW/EN 三个控制线,P2.0~P2.7 还要留给出货指示,总计超过 20 个 I/O。STC89C52 提供 32 个 I/O,接完之后还能剩下 P3.0/P3.1 给串口做调试和 ISP 下载,不用像某些小封装 MCU 那样在引脚复用上做取舍。
2.2 3×3矩阵键盘:逐行扫描的电路与电平逻辑
键盘模块是用户唯一的输入途径。3×3 矩阵键盘有 9 个交叉点,0~7 号对应 8 种商品,8 号对应退币。行线接 P1.0~P1.2,列线接 P1.3~P1.5,每个交叉点放一个轻触按键。STC89C52 的 P1 口内部有准双向上拉,但排线稍长时建议在列线上再并 10K 外部上拉,避免按下时电平抖动导致误判。
扫描原理是逐行输出低电平:先把第 0 行拉低,其余行保持高,再读列线。如果某一列读到低电平,说明该行该列的交叉点被按下。三行各扫一遍,就能唯一确定按键位置。这个方案用 6 个 I/O 换来 9 个按键,比独立按键省了 3 个引脚。具体实现代码在第 3 章给出,这里只需要理解一个关键点:行线必须逐行拉低,不能同时把三行都拉低,否则只能判断有无按键,无法定位行号。
2.3 TC1602AL显示模块:8位并口接线与对比度调整
TC1602AL 是标准的 16×2 字符型液晶模块,接口在原文表 3.1 中列得很清楚。D0~D7 接 P0 口,RS 接 P2.6 选择数据/指令寄存器,RW 接 P2.5 选择读/写,EN 接 P2.7 作为使能信号,下降沿锁存数据。Vss 接地,Vdd 接 +5V,V0 是液晶驱动电压输入,串一个 10K 电位器到地用于调节对比度。
这里有一个非常常见的坑:P0 口是开漏输出,内部没有上拉,直接接 LCD 数据线会出现高电平驱动不足,表现为屏幕亮但显示乱码或完全不显示。解决方法是给 P0 外接 4.7K~10K 上拉排阻,这是焊接阶段就要确认的细节,等代码写完再查硬件就浪费时间了。
2.4 蜂鸣器提示电路与出货指示
提示模块是一个 NPN 三极管驱动的蜂鸣器,基极串 330Ω~1KΩ 保护电阻接 I/O,集电极接蜂鸣器负极,发射极接地。R4 的阻值决定基极电流:330Ω 时约 10mA,1KΩ 时约 4mA,S8050 或 BC547 都能饱和导通。需要注意的是,这个电路驱动的是有源蜂鸣器,I/O 给高电平就响;如果换用无源蜂鸣器,必须输出 2~4kHz 方波才有声音,代码逻辑完全不同。
出货状态用发光二极管代替真实货道,P2.0~P2.7 每个引脚接一个 LED 串 470Ω 限流电阻到 GND,对应 8 个商品位。商品被选中且余额足够时,对应引脚拉低,LED 点亮表示货道动作。这样做的意义在于先把控制逻辑跑通,后续换真实电机驱动模块时,I/O 语义不用改。
2.5 MDB货币识别接口:接线与使用边界
货币识别模块按原文设计只处理 1 元硬币,采用 MDB 接口。MDB 是全双工主从式串行接口,标准 6 针插头,4 号口接 P3.0 作为数据接收,5 号口接 P3.1 作为数据发送,单片机是主机,硬币器是从机。顾客投入硬币后,硬币器通过 MDB 上报币值和数量,单片机累加余额;退币时单片机发退币命令,硬币器执行退币动作后返回结果。
硬币识别的物理原理是高频振荡器的电感变化:不同材质和直径的硬币通过线圈时引起振荡频率偏移,与预设值比对后确定面值。这些判断在硬币器内部完成,51 这边只需要处理 MDB 协议帧。原文特别提到货币识别器核心内容保密,可参考资料少,所以软件设计里不做协议栈展开,只把它当作一个串口数据源处理。
提示:P3.0/P3.1 同时又是 ISP 下载用的串口引脚。调试阶段如果发现下载失败,先确认硬币器没有占用这两个引脚,或者下载时把 MDB 插头拔掉。
3. C语言状态机与键盘、显示程序设计
3.1 工程文件划分与主循环结构
C 源码按模块拆分是比较合理的做法:main.c 放主循环和交易状态机,keypad.c 放键盘扫描,lcd1602.c 放显示驱动,beep.c 放提示音控制,头文件统一声明接口。这样后面替换货道驱动或者改 LCD 型号时,不需要在整个 main.c 里翻找。
主程序结构是上电初始化 LCD、定时器和余额变量,然后进入 while(1) 死循环。循环体只做三件事:扫描键盘、根据按键更新交易状态、刷新显示。这个结构看似简单,但它是状态机的最简形态,交易逻辑集中在主循环的按键分支里,不会出现中断服务函数改全局变量导致的竞态问题。原文 4.2 节的流程图展示的正是这个流程:投币后显示余额,选货后比较金额,不足则提示,足够则出货并找零。
3.2 矩阵键盘扫描与消抖实现
键盘扫描的核心是逐行拉低 + 回读列线。下面的代码把行接 P1.0~P1.2,列接 P1.3~P1.5,返回值 0~8 对应 9 个按键,0xFF 表示无按键。
// keypad.c #include <REG52.H> #define KEY_MASK 0x38 // 0b00111000,对应 P1.3~P1.5 // 返回0~8:商品按键0~7,按键8为退币;0xFF表示无按键 unsigned char KeyScan(void) { unsigned char row, col, key = 0xFF; // 三行对应的行输出值:P1.0/P1.1/P1.2 分别拉低 unsigned char code row_low[3] = {0xFE, 0xFD, 0xFB}; for (row = 0; row < 3; row++) { P1 = row_low[row]; // 当前行拉低,其余保持高 if ((P1 & KEY_MASK) != KEY_MASK) { // 有列被拉低,说明有按键 delay_ms(10); // 软件消抖 if ((P1 & KEY_MASK) == KEY_MASK) continue; // 抖动,重新扫描 for (col = 0; col < 3; col++) { if ((P1 & (0x08 << col)) == 0) { key = row * 3 + col; // 按行优先编号 break; } } break; // 一次只认一个键 } } return key; }逻辑说明:row_low数组中 0xFE 对应 1111 1110,表示 P1.0 输出低电平;0xFD 对应 1111 1101,P1.1 拉低;0xFB 对应 1111 1011,P1.2 拉低。KEY_MASK为 0x38 用于屏蔽列位,(P1 & KEY_MASK) != KEY_MASK成立说明至少有一列被拉低。列判断用0x08 << col依次检查 P1.3、P1.4、P1.5 是否读到 0。
消抖时间取 10ms 是机械按键的常见值,轻触开关的抖动周期一般在 5~20ms。delay_ms用定时器或空循环实现,空循环要注意 Keil C51 在 O0 优化级别下延时时间符合预期,开高优化后空循环可能被优化掉。如果后续把键盘扫描放到定时器中断里做,可以省掉阻塞式消抖,改为连续采样 3 次一致的边沿触发。
3.3 交易状态机:投币、选货、退币的流转
交易逻辑用状态变量来组织。定义两个关键数据:price_tab是 8 种商品的价格常量表,balance是当前投币累计金额。主循环根据按键值分流处理。
// main.c #include <REG52.H> #include "lcd1602.h" #include "keypad.h" #include "beep.h" // 8种商品价格:3个1元、3个2元、2个3元,对应按键0~7 unsigned char code price_tab[8] = {1, 1, 1, 2, 2, 2, 3, 3}; unsigned char balance = 0; // 投币累计金额,单位:元 // 出货指示:P2.0~P2.7对应8个货道,LED亮表示出货 void GoodsOut(unsigned char idx) { P2 = ~(0x01 << idx); // 对应位拉低 delay_ms(300); // 模拟出货过程 P2 = 0xFF; // 复位,LED熄灭 } void main(void) { unsigned char key; LcdInit(); // LCD初始化为8位模式、2行 LcdWriteStr(0, "VENDING MACHINE"); LcdWriteStr(1, "MONEY:0 RMB"); balance = 0; P2 = 0xFF; // 出货口全部置高,LED全灭 while (1) { key = KeyScan(); if (key < 8) { // 商品按键0~7 if (balance >= price_tab[key]) { // 余额足够 balance -= price_tab[key]; GoodsOut(key); // 对应货道动作 BeepOnce(200); // 短响:交易成功 } else { BeepOnce(500); // 长响:余额不足 LcdWriteStr(0, "NOT ENOUGH"); } } else if (key == 8) { // 退币键 // 退币执行后余额清零,硬币器通过MDB接口驱动 balance = 0; LcdWriteStr(0, "COIN RETURN"); } // 刷新余额显示,只更新数字部分,避免全屏闪烁 LcdWriteStr(1, "MONEY:"); LcdWriteNum(6, balance); LcdWriteStr(11, " RMB"); delay_ms(2); } }逻辑说明:Price_tab用code关键字放在 Flash,不占宝贵的 data 区,这是 C51 与标准 C 的一个关键差异。按键 0~7 直接作为商品索引,余额足够时扣款并拉低对应 P2 口模拟出货;余额不足时长响提示并刷新屏幕提示信息。8 号键是退币操作,这里只做余额清零和界面切换,真实退币动作依赖 MDB 硬币器执行。
状态流转的核心是余额比较那一行:balance >= price_tab[key]。这个判断覆盖了三种情况:余额恰好等于价格、余额大于价格(此时差额就是找零)、余额不足。找零逻辑在原文流程图中对应"投入货币数多于设定价格时实现找钱功能",此处没有真实硬币器时,用余额清零模拟退币即可。
3.4 LCD 驱动:初始化、写指令与刷新策略
TC1602AL 的驱动本质上是对三个控制引脚和一个数据口进行操作。初始化序列固定为 8 位模式、2 行显示、5×7 点阵。写指令时 RS 拉低,写数据时 RS 拉高,EN 产生一个下降沿把数据锁存进液晶模块。
// lcd1602.c #include <REG52.H> sbit RS = P2^6; // 数据/指令选择:0指令,1数据 sbit RW = P2^5; // 读/写选择:0写,1读 sbit EN = P2^7; // 使能信号,下降沿锁存 void LcdWriteCmd(unsigned char cmd) { RW = 0; RS = 0; // 指令寄存器 P0 = cmd; EN = 1; delay_us(2); EN = 0; // 下降沿锁定指令 delay_us(100); } void LcdWriteData(unsigned char dat) { RW = 0; RS = 1; // 数据寄存器 P0 = dat; EN = 1; delay_us(2); EN = 0; delay_us(100); } void LcdInit(void) { delay_ms(20); // 上电等待液晶内部复位 LcdWriteCmd(0x38); // 8位总线,2行,5×7点阵 LcdWriteCmd(0x08); // 显示关 LcdWriteCmd(0x01); // 清屏 LcdWriteCmd(0x06); // 写入后地址自动加1 LcdWriteCmd(0x0C); // 显示开,光标关 } void LcdWriteStr(unsigned char line, unsigned char *str) { unsigned char addr = (line == 0) ? 0x80 : 0xC0; LcdWriteCmd(addr); // 设置DDRAM地址:第一行0x80,第二行0xC0 while (*str) { LcdWriteData(*str++); } } void LcdWriteNum(unsigned char col, unsigned char num) { LcdWriteCmd(0x80 | 0x40 | col); // 第二行第col列 LcdWriteData('0' + num); // 单位数直接转字符 }逻辑说明:LCD 的 DDRAM 地址从 0x00 开始,第一行对应 0x80~0x8F,第二行对应 0xC0~0xCF,这是 1602 的行首地址规则。LcdWriteCmd中的 EN 下降沿是数据锁存的关键,时序要求 EN 高电平保持至少 450ns,delay_us(2)在这个频率下足够。LcdWriteStr用 while 循环逐个输出字符,遇到字符串结束符\0停止。LcdWriteNum只刷新单个字符,避免整屏重写造成闪烁,这在余额变化频繁的售货场景里体验差异明显。
提示:如果显示第一行正常、第二行乱码,先检查
0xC0这条地址指令。很多移植问题不是时序,而是第二行首地址写错。
4. Keil C51下的编译、烧录与常见坑
4.1 C51和标准C的几个关键差异
Keil C51 编译器和桌面端 GCC/Clang 的 C 标准有差异,5 年以上 C 经验的人第一次写 C51 最容易踩这几个点,整理成下表。
| C51 关键字/概念 | 含义 | 与标准C的差异 |
|---|---|---|
code | 常量存放在程序 Flash | 标准 C 无此概念,等价于只读数据段 |
data/xdata | 内部 RAM / 外部扩展 RAM | 决定变量访问速度,data 快但只有 128B |
bit/sbit | 位变量 / 特殊功能寄存器位 | 标准 C 没有位寻址类型 |
sfr | 特殊功能寄存器定义 | 用于访问定时器、串口等硬件寄存器 |
| 中断函数 | void Timer0_ISR(void) interrupt 1 | 不能有参数和返回值,不能直接调用重入函数 |
| 函数默认不可重入 | 需要加reentrant | 中断和主循环共享函数时必须处理 |
其中data区只有 128B,局部变量和全局变量都在里面。一个常见报错是DATA SEGMENT TOO LARGE,解决办法是把大数组或常量表挪到code段,或者改小内存模型。原项目里 8 个商品价格、LCD 字符串常量都用code修饰,根本原因就在这里。
4.2 EEPROM做价格表持久化
STC89C52 内部 4KB EEPROM 可以用来存价格表、库存数量和累计营收,掉电不丢失。这比外部接 24C02 省一根 I2C 线。EEPROM 操作要用 STC 特有的 IAP 寄存器组,先定义再调用。
// eeprom.c #include <REG52.H> // STC89C52 系列 IAP 寄存器地址定义 sfr IAP_DATA = 0xC2; // IAP数据寄存器 sfr IAP_ADDRH = 0xC3; // 地址高字节 sfr IAP_ADDRL = 0xC4; // 地址低字节 sfr IAP_CMD = 0xC5; // 命令寄存器 sfr IAP_TRIG = 0xC6; // 触发寄存器 sfr IAP_CONTR = 0xC7; // 控制寄存器 // 扇区擦除:EEPROM写前必须擦除所在扇区,STC89C52扇区大小512B void EepromErase(unsigned char sector) { IAP_CONTR = 0x80; // 使能IAP IAP_CMD = 0x03; // 扇区擦除命令 IAP_ADDRH = 0x00; IAP_ADDRL = sector * 2; // 每个扇区512B,地址按扇区取 IAP_TRIG = 0x5A; // 触发序列 IAP_TRIG = 0xA5; delay_ms(20); // 等待擦除完成 IAP_CONTR = 0; // 关闭IAP } // 读取一个字节 unsigned char EepromRead(unsigned int addr) { unsigned char dat; IAP_CONTR = 0x80; IAP_CMD = 0x01; // 字节读 IAP_ADDRH = (addr >> 8) & 0xFF; IAP_ADDRL = addr & 0xFF; IAP_TRIG = 0x5A; IAP_TRIG = 0xA5; dat = IAP_DATA; IAP_CONTR = 0; return dat; }逻辑说明:IAP 操作统一走0x5A和0xA5触发序列,这是 STC 单片机的保护机制,防止误写。写字节前必须先擦除整个扇区,这是一个非常容易踩的坑:改一个字节要重写整个扇区。实际应用时通常把价格表放在固定地址,上电先从 EEPROM 读,读不到或校验失败再用默认价格表初始化,这样运营人员改价只需要通过串口下发新表。
4.3 串口打印调试:printf重定向
LED 指示和 LCD 显示能看到的运行状态有限,串口打印是单片机调试效率最高的手段。STC89C52 的 UART 在模式 1 下用定时器 1 产生波特率,11.0592MHz 晶振配合 TH1=0xFD 得到 9600bps,这是最常用的组合。
// uart.c #include <REG52.H> // 初始化串口:模式1,8位UART,波特率9600@11.0592MHz void UartInit(void) { SCON = 0x50; // 0101 0000:模式1,REN=1使能接收 TMOD &= 0x0F; TMOD |= 0x20; // 定时器1方式2,8位自动重装 TH1 = 0xFD; // 9600波特率重装值 TL1 = 0xFD; TR1 = 1; // 启动定时器1 } void UartSendByte(unsigned char dat) { SBUF = dat; // 写入发送缓冲,硬件自动发送 while (!TI); // 等待发送完成标志 TI = 0; // 手动清标志 } // 重定向putchar,printf即可直接使用 char putchar(char c) { UartSendByte((unsigned char)c); return c; }逻辑说明:SCON = 0x50中的 0x50 拆开看是 0101 0000,SM0/SM1 为 01 即模式 1,REN 置 1 使能接收。TH1 = 0xFD这个值是固定套路,换晶振要重算:波特率 = 晶振频率 / 12 / 32 / (256 - TH1)。putchar重定向之后,printf("balance=%d\r\n", balance)就能直接往串口助手输出,按键状态、余额变化、状态机跳转全部可视。注意 C51 的 printf 默认支持%d和%x,但%f需要额外配置,嵌入式调试里尽量不用浮点格式。
5. 从课设到产品:看门狗、真实货道与ISP升级
5.1 看门狗兜底
售货机是无人值守设备,程序跑飞没人按复位键。STC89C52 内置看门狗定时器,通过WDT_CONTR寄存器控制,一旦使能,主循环必须周期性喂狗,否则芯片自动复位。喂狗代码放在主循环末尾,溢出时间按最大交易流程估算,一般 1~2 秒足够。
sfr WDT_CONTR = 0xE1; #define EN_WDT 0x30 // 使能看门狗 + 清看门狗 WDT_CONTR = 0x35; // EN=1, CLR=1, PS=101 约2秒溢出5.2 从LED到ULN2003驱动真实货道
原设计用 LED 模拟出货,真实货道通常是 12V 或 24V 直流电机推杆,51 的 I/O 直接驱动不了。常规方案是 ULN2003 达林顿管阵列,I/O 高电平经内部达林顿管放大后驱动继电器或电机,P2 口的 8 个引脚正好对应 8 路货道,软件层GoodsOut函数不用改,只换硬件驱动板。电机动作时间要单独测,实物出货加上限位开关后,GPIO 控制逻辑需要从延时改成检测到位信号。
5.3 STC-ISP串口下载与量产
STC89C52 支持 ISP 串口下载,不需要专用编程器。下载时先点 STC-ISP 软件的下载按钮,再给板上电,芯片上电瞬间进入引导区完成烧录。量产时把 P3.0/P3.1 引出到测试点,配合 STC-ISP 的序列号加密功能,可以防止固件被直接读走。下载速度不是越快越好,遇到下载失败先降到最低波特率,同时确认 MDB 硬币器没有占用串口引脚。
本文还有配套的精品资源,点击获取