简介:面向单片机课程设计与电子设计初学者的完整项目文档,内容围绕基于80C51/AT89S52单片机的简易计算器设计展开,从系统开发背景、设计目的到硬件选型与软件编程均有系统说明。文档重点介绍LCD1602液晶显示、4*4矩阵键盘以及AT89S52最小系统电路,并选用C语言实现键盘扫描、运算解析与结果显示,同时给出PROTEUS模拟仿真思路,方便读者在制作实物前验证逻辑。包体为1个docx文件,共1.64MB,适合作为课程报告、毕业设计参考或实践入门资料。已有732人学习下载,文档结构完整,包含目录、摘要、系统总体框图、源程序代码等模块,可直接对照学习,帮助理解单片机原理与软硬件协同设计方法。
1. 基于51单片机的简易计算器:先搞清楚要做什么
把计算器这个题目拆开来看,它并不是在做一个加法器,而是解决“资源有限的外设如何协作”的问题:4x4矩阵键盘占用8个IO口,4位共阴数码管需要8个段选信号和4个位选信号,如果不做动态显示,IO口立刻不够用。用STC89C52这类51单片机,正好可以练习定时器中断、行列扫描、动态刷新和状态机,这些能力放到其他产品里同样适用。下面从最小系统搭建入手,给出矩阵键盘和数码管的硬件连接,然后用C语言实现一个能完成加减乘除、带清零和连续运算的简易计算器。全程使用Keil C51和Proteus,读者可以照着搭电路、写代码,也可以直接把软件框架抽出来复用。
2. 硬件设计:最小系统、按键矩阵和51单片机引脚分配
2.1 单片机最小系统的构成与元件选型
计算器电路并不需要复杂的引脚映射,先把最小系统搭建正确,后面的IO分配才有意义。STC89C52的最小系统包含电源、晶振、复位和下载电路。使用12MHz晶振时,两个22pF负载电容分别接到晶振两端,如果使用11.0592MHz晶振,初值计算会方便串口通信,但计算器这种只需要数码管和按键的应用,12MHz更常见,因为机器周期刚好是1us,定时器初值好算。
复位电路的上电复位按经典RC方式设计:10uF电解电容接在VCC和RST之间,10kΩ电阻接在RST和GND之间。这样上电瞬间RST为高电平,单片机复位,随后电容充电,RST降为低电平开始运行。手动复位按键并联在电容两端,按下时强制把RST拉到高电平。
关键参数可以归纳成一张表,方便在Proteus中直接查用。这里给出的数值对应STC89C52、AT89S52等兼容芯片,按表选即可。
| 元件 | 参数 | 作用 |
|---|---|---|
| 晶振 | 12MHz | 提供时钟,机器周期1us |
| 负载电容 | 22pF x2 | 与晶振匹配,稳定振荡 |
| 复位电容 | 10uF | 上电保持RST高电平 |
| 复位电阻 | 10kΩ | 为电容提供放电回路 |
| P0口上拉 | 10kΩ排阻 | 为P0开漏结构提供高电平 |
下载电路方面,如果用STC单片机,串口TXD、RXD直接通过CH340模块和USB接电脑即可。有些开发板还会把P3.0和P3.1接上两个LED,调试时观察收发信号,这在计算器电路里不是必须的,但不妨碍保留。
2.2 4x4矩阵键盘的电路连接与键值映射
矩阵键盘相比独立按键,能用4+4根线实现16个按键,IO占用直接减半。行线接P1.0到P1.3,列线接P1.4到P1.7。扫描时把当前扫描的行拉低,其余行输出高,列线作为输入读取。只要某列读到低电平,就能确定按键所在行和列。
在设计时我建议直接把按键功能排列成下表的结构,然后用查表法把行列号翻译成功能码。键值表的排布越接近物理布局,后面的软件越不容易错。
| 行/列 | 列0 (P1.4) | 列1 (P1.5) | 列2 (P1.6) | 列3 (P1.7) |
|---|---|---|---|---|
| 行0 (P1.0) | 1 | 2 | 3 | + |
| 行1 (P1.1) | 4 | 5 | 6 | - |
| 行2 (P1.2) | 7 | 8 | 9 | * |
| 行3 (P1.3) | C | 0 | = | / |
C键用来清零,等号键放在右下角则太远,所以放在第3行第2列。如果后续要扩展小数点、正负号,可以把C键挪走,但矩阵只有16个按键,扩功能建议换编码键盘或加Shift键。
接线时注意键盘和单片机之间是否共地。在Proteus中,按键一端接IO,另一端接GND,按下时IO被拉低,这种写法比外接上拉电阻更直观。实物制作用万用板时,行线和列线尽量短,不要在键盘排线和单片机IO之间形成过长回路,否则按键扫描容易出现错误触发。
2.3 数码管动态显示电路与段码表
显示部分使用4位共阴极数码管。把4个数码管的a-g和dp引脚全部并联到P0口,位选端com分别接P2.0到P2.3。P0口是开漏结构,因此必须外接10kΩ排阻到VCC,不然高电平段码驱动能力不足。位选端是共阴极公共端,只要P2输出高电平,对应位就会选通。
动态显示的基本思路是逐位扫描:先把要显示的某一位段码送到P0,再把对应位选拉高,保持1-2ms后关闭,接着显示下一位。只要循环速度足够快,人眼会把4个依次点亮的位置合并成完整显示效果。如果P0口在切换时没有先清零,会出现“拖影”,也就是前一位的残影叠在当前位置后面。
一个简单的段码表如下,注意数字8的段码是0x7F,小数点对应的dp段是0x80。共阴极数码管和共阳极的段码是取反关系,用错的话整个显示会错乱得很规律。
#include <reg52.h> unsigned char code SEG_CODE[] = { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; void DelayMS(unsigned int ms) { unsigned int i; while (ms--) { for (i = 0; i < 125; i++); } } sbit P20 = P2^0; sbit P21 = P2^1; sbit P22 = P2^2; sbit P23 = P2^3;代码里的SEG_CODE用code关键字存到程序存储器,省掉RAM空间。P20到P23四个位定义用于位选控制,后面写显示函数时用P20=1; P21=0; P22=0; P23=0;这种形式让第一个数码管工作,代码直观。如果使用锁存器,还需要额外的锁存引脚,这里为了降低入门门槛,直接把P2当位选用,不做锁存。
3. 软件实现:定时器调度、矩阵键盘扫描与运算状态机
3.1 用Timer0产生1ms时基,统一调度显示与按键
软件部分最忌讳的是让主循环又扫描键盘又刷新显示又做运算,这样一旦有长延时,显示就会闪烁。常见做法是让Timer0产生固定时基,中断里只放显示刷新和按键扫描标记,主循环根据标志决定要不要处理按键。这样程序结构清晰,时序误差也小。
Timer0工作于方式1时,是16位定时器,溢出后必须软件重装初值。12MHz晶振下机器周期为1us,定时1ms需要计数1000次,初值为65536-1000=64536,也就是0xFC18。考虑到中断响应和指令重装的消耗,实际取0xFC67作为修正值,用示波器或Proteus的虚拟示波器观察位选波形时,偏差可以控制在可接受范围内。
void Timer0_Init(void) { TMOD &= 0xF0; TMOD |= 0x01; // Timer0方式1,16位 TH0 = 0xFC; TL0 = 0x67; ET0 = 1; EA = 1; TR0 = 1; }这段初始化里,TMOD &= 0xF0先把Timer0的模式位清零,保留Timer1已有配置,再通过|= 0x01设置成方式1,这是一种不会影响其他定时器的写法。ET0和EA分别打开Timer0中断和总中断,TR0才真正启动计时。如果顺序反了,先让TR0=1再开中断,可能产生一次不可预期的中断请求。
中断服务函数里负责重装初值、刷新数码管,并设置按键扫描标志。不要把按键扫描函数整个放在中断里,因为矩阵扫描需要延时消抖,放在中断里会拖慢整个中断,导致显示刷新周期不稳定。正确做法是每隔20次中断,设置一次扫描标志,分频策略见下表。
| 任务 | 触发频率 | 实现方式 |
|---|---|---|
| 显示刷新 | 约500Hz | 每次中断切换一位数码管 |
| 按键扫描 | 50Hz | 每20次中断置位扫描标志 |
| 运算处理 | 事件触发 | 主循环查询到键值后处理 |
3.2 矩阵键盘扫描函数:逐行拉低与消抖实现
键盘扫描函数返回按键编号,没有按键时返回0xFF。先逐行把P1口写成~(0x01 << row)的形式,也就是让当前行输出0,其他行输出1,然后读取P1的高四位。读取回来的高四位如果某一列是0,说明该行该列按键被按下。
消抖处理采用软件延时20ms。第一次检测到按键后等待20ms再读一次,如果两次状态一致,才认为是有效按下。有的工程师会把消抖放在主循环里,用状态机记录按键前后状态,这样性能更好,但可读性不如直接延时。计算器的按键操作频率低,20ms阻塞延时完全可接受。
unsigned char KeyScan(void) { unsigned char row, col = 0; for (row = 0; row < 4; row++) { P1 = ~(0x01 << row); col = P1 & 0xF0; if (col != 0xF0) { DelayMS(20); col = P1 & 0xF0; if (col == 0xE0) return row * 4 + 0; if (col == 0xD0) return row * 4 + 1; if (col == 0xB0) return row * 4 + 2; if (col == 0x70) return row * 4 + 3; while ((P1 & 0xF0) != 0xF0); } } return 0xFF; }注意这里的列值判断:0xE0表示P1.4那列被拉低,0xD0对应P1.5,0xB0对应P1.6,0x70对应P1.7。P1寄存器的低四位是行输出,高四位是列输入,所以返回值row * 4 + col正好对应第二章键值表中的行列位置。
这个函数有一个潜在问题:while等待释放会阻塞主循环,如果用户按住按键不松手,程序会一直停在这里。对于简易计算器来说,可以接受,但如果要增加长按连续输入的功能,就必须改成非阻塞式判断。更稳健的方式是记录按键释放事件,在状态机里只处理“按下到释放”的完整流程。
3.3 数码管动态刷新:中断里完成位选切换
显示缓冲区由主程序写入,中断程序只负责把缓冲区内容刷新到数码管。这样主程序在修改显示数据时,不会因为等待显示周期而浪费时间。缓冲区可以定义为4字节的unsigned char数组,每一位存放0到9的数字。
unsigned char disp_buf[4] = {0, 0, 0, 0}; void Timer0_ISR(void) interrupt 1 { static unsigned char pos = 0; TH0 = 0xFC; TL0 = 0x67; P0 = 0x00; P2 = 0x00; switch (pos) { case 0: P2 = 0x01; break; case 1: P2 = 0x02; break; case 2: P2 = 0x04; break; case 3: P2 = 0x08; break; } P0 = SEG_CODE[disp_buf[pos]]; pos++; pos &= 0x03; }这段中断服务程序里,P0=0x00; P2=0x00;是消隐动作,目的是关闭上一次选通的数码管,然后再选通下一位。如果没有这两条语句,高位和低位之间会发生数据串扰,显示会出现重影。pos & 0x03比if (pos >= 4) pos = 0;的执行效率更高,51单片机对位运算的编译结果更紧凑。
中断里访问disp_buf时,主程序可能正好在修改某个元素。最坏情况下显示会闪一帧,但对于计算器这种慢速应用,影响很小。如果想彻底避免,可以用一个短暂关中断的代码段保护,或者把显示缓冲区改成双缓冲。
3.4 运算状态机:输入、运算符、等号的完整切换
计算器核心逻辑放在ProcessKey函数里,它接收键值,内部维护数字、运算符和状态。按键分为三类:数字键0-9,运算符键 + - * /,功能键 = 和 C。状态机定义一个state变量,值为0时等待第一个操作数输入,值为1时正在输入操作数,值为2时已经确定运算符等待第二个操作数,值为3时正在输入第二个操作数。
以数字键为例,输入逻辑是:current = current * 10 + digit。但要注意当前结果是否溢出。16位无符号整数的最大值是65535,如果按5位数加操作,很容易超过这个范围。把current定义成unsigned long类型,最大可显示到99999999,然后显示时逐位拆开。这样虽然会消耗较多RAM,但计算器场景足够用。
unsigned long last_operand = 0; unsigned long current_operand = 0; unsigned char current_operator = '+'; unsigned char state = 0; void ProcessKey(unsigned char key) { if (key >= 0 && key <= 9) { current_operand = current_operand * 10 + key; if (current_operand > 99999999) { current_operand = 0; error_flag = 1; } state = 1; } else if (key == '+') { last_operand = current_operand; current_operator = '+'; current_operand = 0; state = 2; } // 其他运算符分支逻辑相同 }等号键的处理则根据current_operator执行运算。减法需要判断结果是否为负数,如果为负数,把current_operand求补码存入显示缓冲区,同时给负号标志。乘法和除法的处理方式在第四章继续说明。C键把所有变量复位,error_flag清零。
4. 从Proteus仿真到51单片机实物烧录:排错与参数调整
4.1 Proteus仿真中的晶振和时序问题
Proteus仿真的计算器项目,最常见的异常是数码管亮度不均和扫描频率异常。原因大多是定时器初值或者晶振频率设置不对。在12MHz晶振下,如果使用的是11.0592MHz的初值,定时器溢出时间会偏约10%,显示扫描周期变长,闪烁就会暴露出来。Proteus左侧的“Instrument”工具里放一个虚拟示波器,直接测量P2.0的波形,查看频率是否接近1kHz。
另一种时序问题出现在中断服务函数内部。如果中断里调用了延时函数,显示刷新必然被阻断。中断里只能做耗时极短的操作,像20ms消抖延时绝对不能放进中断。把延时移出中断后,仿真立刻恢复正常。
4.2 实物按键抖动和压键粘连的排错
实物按下去会经历多次抖动,每次抖动可能长达10ms左右。仿真里按键模型是理想开关,没有抖动,所以仿真跑得正常的代码,烧录到实物上可能出现按一次数字却连续输入多个相同数字的现象。消抖延时20ms只能滤掉大部分抖动,如果还出现连续触发,需要检查按键释放检测。
常见的错误代码是while((P1&0xF0)!=0xF0);只能等到所有按键都释放后才返回。如果此时两个键同时被按下,这个循环会一直卡住。更实用的做法是加一个超时计数,在循环里累加超时变量,超过一定次数就放弃等待释放并返回。超时机制可以帮助排查粘连按键,也能防止程序死锁。
unsigned char wait_release_count = 0; while ((P1 & 0xF0) != 0xF0) { wait_release_count++; if (wait_release_count > 200) { wait_release_count = 0; break; // 超时退出,避免死循环 } DelayMS(1); }这里把wait_release_count限制在200ms左右,超过则认为当前按键被长按或粘连,退出等待。参数可以根据手感调整,200ms不会影响正常连按,又能挡住异常状态。注意这个超时计数变量要用volatile或局部变量,避免在中断和主循环之间出现同步问题。
4.3 负数、溢出和除零保护的具体处理
显示负数时,数码管需要显示负号。共阴极数码管的负号段码是0x40,也就是只点亮g段。显示缓冲区需要5位,第一位放负号标志,后面4位放数字。如果数值是负数,先取其绝对值再逐位取模,然后把段码填到缓冲区。不要直接对负数取模,C语言中负数取模结果在不同编译器上可能不一致,Keil C51会把负数余数处理成负值,放到段码表里会产生越界读取。
溢出保护不能省。用户连续输入8位数字后还要再加一位,缓冲区会崩。在状态机里,每次数字输入后检查current_operand > 999999,如果溢出就置error_flag,显示“E”。除零检测放在除法分支里,遇到除数为0时不执行除法,直接显示错误。清零键必须能清除错误状态,这是最简单的可用性要求。
long temp_result; temp_result = (long)last_operand * current_operand; if (temp_result > 99999999L) { error_flag = 1; } else { current_operand = (unsigned long)temp_result; error_flag = 0; }这里的强制类型转换是关键,先转换成long再乘,结果不会截断。如果直接在两个16位整数之间用*,乘法结果丢进位,再做溢出判断也发现不了问题。
4.4 烧录过程中ISP的连接问题
使用STC单片机时,烧录软件点击“Download/Program”后,需要给目标板重新上电。如果点击后一直卡在“检测单片机”状态,先核对串口号,再看P3.0、P3.1有没有接错。很多开发板的CH340模块和单片机之间带有自动下载电路,如果计算器是自己搭的电路,就手动断电再上电。上电时序是:先把ISP软件打开,选择好固件文件,再点击下载,然后给板子通电。
烧录完成后如果数码管全亮或乱码,先把P1口断开,单独测试显示部分。这种隔离法可以快速定位是IO驱动问题还是键盘干扰。使用万用板焊接时,晶振引脚不要留太长,晶振紧挨单片机放置,否则容易导致单片机起振失败,烧录时也会报读不到芯片。
5. 扩展:让51单片机简易计算器支持连续运算和负数显示
5.1 把状态机改成“按运算符立即计算”
目前的设计在按等号后才计算,用户输入“3 + 4 + 5”时必须按两次等号才能看到结果。要想支持连续运算,只要在按下运算符键时,检查之前是否已经有操作数和运算符,如果是,就先把当前结果算出并保存,然后把新运算符存入。状态机从“等待第二个操作数”变回“持续运算”。这样“3 + 4 + 5 = 12”只需按一次等号。
有一个小技巧:运算符优先级可以做得很浅,只处理从左到右。如果后续要支持先乘除后加减,就要在状态机里加入“栈”结构。对于51单片机,栈深只有几个字节,同时保留两个操作数和一个运算符就足够。建议先用简单的从左到右,再考虑优先级。
5.2 扩展8位数码管与符号位
在缓冲区中增加负号和空格,数码管位数不够就得扩到8位。P0口段选数据不需要变,位选从P2.0到P2.7即可。软件上把位选控制从switch改成变量掩码,简化扩展。时间基准仍用1ms定时器,刷新8位数码管时每位显示1ms,一帧8ms约125Hz,完全够用。
扩展后的显示函数可以这样组织:先把结果按十进制拆分为8位,存入disp_buf[8],然后由中断里的display_index从0到7循环。位选控制改成P2 = (1 << display_index),省掉switch。这样做还能顺便支持小数点,只要把某个显示位的段码或上0x80。
5.3 模块化文件组织与可移植性
把按键扫描、显示刷新、运算逻辑拆成key.c、display.c、calc.c三个模块,主文件只做初始化。51单片机工程不一定非要组织成多文件,但当你的课程设计需要改成交通灯、电子时钟时,模块化能把显示和按键部分直接复用。比如把display.c里的位选引脚用宏定义,换一块板子只改一行头文件即可。
这种做法的实际收益是调试效率。键盘扫描出问题时只看key.c,显示闪烁只看display.c,运算逻辑出错则在calc.c里单测。每个模块内部封装好公开函数,比如GetKey()、DisplayNumber(long num)、ProcessKey(unsigned char key),对外只暴露接口。加上串口调试时,把DebugPrint(long num)放到calc.c的等号分支里,就能在PC端观察每一步的中间结果,而不需要动显示和键盘代码。
本文还有配套的精品资源,点击获取