这是单片机实操系列的第六篇。到这一篇终于可以把前面分散的知识点整合成一个完整的小系统C51定时器驱动数码管刷新独立按键控制加减计数硬件平台用亚龙236实验箱。这个题目在51单片机教学、课程设计和各类实训里出现频率极高它看起来简单实际动手却会碰到定时器初值算错、数码管闪烁、按键连击等一系列问题。做完这个项目你对定时器中断、动态扫描、按键消抖、前后台程序结构的理解会明显上一个台阶。这篇文章按我实际调试的顺序来写从设计思路到参数计算再到完整代码、实测记录和避坑经验写完你拿同一套思路无论换到哪块开发板都能快速迁移。1. 项目整体设计思路定时器刷新加按键计数的前后台结构1.1 题目核心把定时器、数码管刷新和按键加减拧成一个小系统先说清楚这个题目到底在练什么。表面看是做按键加一减一、数码管显示数字这么个简单功能实际上它把三个链路模块拧在了一起定时器产生稳定的时间基准数码管靠动态扫描不断刷新按键检测负责接收人机交互。三者不是顺序执行的关系而是同时进行的关系。也就是说数码管要一直亮着、不能闪同时按键必须随时能被响应这两个需求在单线程的51单片机上是互相抢CPU的。如果显示刷新占用了太多时间按键就会迟钝如果按键消抖的延时阻塞了主循环显示刷新就会卡顿。解决这个矛盾的经典方案就是用定时器中断去扛显示刷新让主循环专心处理按键和业务逻辑。这正是很多实时性要求不高的嵌入式系统普遍采用的前后台结构前台是中断服务程序后台是主循环。这套思路一旦建立起来后面再做秒表、温湿度计、电子密码锁其实都是换汤不换药定时器管周期性的任务主循环管事件和逻辑。1.2 为什么选定时器中断刷新而不是延时函数刷新对刚开始接触51单片机的人来说最容易想到的做法是在主循环里用delay_ms延时刷新数码管。比如先写个延时1ms的函数然后切换一次位选再延时再切下一位。这样做功能上好像能跑但有两个致命问题。第一个问题是CPU被白白占用。延时函数本质是让CPU空转在延时期间按键扫描、计数判断全都被堵住。第二个问题是时间节奏很难精确控制。数码管动态扫描要求每一位的点亮时间尽量均匀一旦主循环里还要执行其他逻辑每一位显示的时间就会忽长忽短表现出来就是亮度不均甚至闪烁。定时器中断就不一样。一旦把T0配置成固定1ms产生一次中断中断服务程序定时切换显示位这个周期是硬件保证的不管主循环在干什么都不会打断。主循环里加延时消抖就算阻塞个10ms显示也只少刷新十次而已肉眼根本分辨不出来。这种用硬件定时器把周期任务从主循环里摘出来的思路是这个项目最值得学会的东西。1.3 亚龙236实验箱资源盘点与端口确认亚龙236这类教学实验箱的配置大同小异一般以STC89C52或AT89S52为主控板上集成了8位数码管、8个LED、独立按键、矩阵键盘、蜂鸣器、LCD接口等模块。做这个题目你需要先用到的资源有三个一组数码管、两个独立按键、一个定时器。动手之前最重要的一件事是先看实验箱的原理图确认三个信息。第一数码管的段选接在哪个IO口位选接在哪个IO口。有的实验箱段选走P0口有的走P2口位选可能经过译码器或者三极管驱动。第二独立按键是低电平有效还是高电平有效IO口是否已经接了上拉或下拉电阻。第三板上晶振频率是多少这直接决定定时器初值的计算是12MHz还是11.0592MHz算出来的数完全不同。不要嫌这一步麻烦。我在实际指导这个题目时见过太多次这样的场景代码写得很完整逻辑看着没问题结果上板后数码管全灭或者乱亮最后发现只是某个跳线帽没插、某个端口映射和代码里写的不一样。花五分钟把原理图看清楚比后面盲调一小时更省时间。2. 硬件原理与开发准备数码管扫描、按键消抖与Keil工程配置2.1 数码管动态扫描与消隐原理数码管内部是把8个发光二极管的阳极或阴极接在一起引出公共端剩下8个引脚分别控制a到dp八个段。按公共端形式分为共阴和共阳两种。实验箱上用的多位数码管如果每一位都独立接数据线四位就需要32根线单片机IO口根本不够。动态扫描就是为了解决这个IO数量矛盾而出现的。动态扫描的做法是把所有位数的相同段引脚并联到同一组数据线上然后用位选信号轮流导通每一位的公共端。比如要显示1234先让第一位的位选有效、数据线上给数字1的段码点亮第一位数码管其他位全灭过1ms切换到第二位点亮数字2再1ms切到第三位再到第四位然后回到第一位重新开始。只要轮流速度足够快完整扫描周期低于人眼视觉暂留阈值人眼看到的就是四个数字同时亮着。这个项目用1ms切换一位四位数码管完整刷一遍只要4ms对应的刷新频率是250Hz远超不闪烁的要求。这里有个关键细节叫消隐。在切换位选之前必须先把段码数据清掉或者直接把所有位选全部关闭。否则切换瞬间旧的段码残留在下一位置会产生拖影严重时还能看到鬼影一样的残留笔段。很多第一次写动态扫描的人显示效果脏十有八九就是少了消隐这一步。2.2 独立按键的机械抖动与软件消抖独立按键的电路本身很简单一个开关、一个上拉电阻按键按下时IO口被拉低松开时恢复高电平。麻烦在于机械触点按下和松开的瞬间会有几毫秒的抖动期电平在这个窗口里反复跳变。如果单片机在抖动期里采样一次按键可能被识别成好几次。软件消抖的常用策略是先采样后确认加延时再采样。检测到IO电平变化时先延时10ms左右跳过抖动窗口再读一次电平。如果两次读到的状态一致才判定这次按键有效。这个做法简单可靠是这个项目里最好的选择因为只有两个按键代码量可控。还有一个容易忽视的环节是等待按键释放。如果在确认按键按下后立刻执行计数但按键还没松开主循环下次扫描又会测到同一个按键于是按下一次会连续加好几次。解决办法是在确认按键有效后用while循环等待IO口恢复到未按下状态再返回键值。这样每次按键只触发一次计数。代价是长按不会连续计数但对按一次加一的需求这正是我们想要的。2.3 Keil工程搭建与HEX下载流程开发环境方面C51基本绕不开Keil C51。新建工程的流程我先走一遍方便第一次接触的同学直接照做。打开Keil后点击Project菜单中选择New uVision Project给工程取个名字保存好。芯片型号选择窗口里如果用的是STC89C52标准Keil不一定直接列出STC系列通常做法是先选Atmel的AT89C52或Generic 8051 CPU不影响编译也可以单独安装STC官方提供的器件库让Keil认识STC芯片。接下来把源码文件加入工程然后在Options for Target的Output选项卡里勾选Create HEX File这一步不能漏否则编译结果只是.axf文件没法下载到单片机。编译无误后打开STC-ISP下载工具选择对应的串口号和芯片型号加载编译生成的HEX文件设置好波特率。STC单片机下载需要冷启动先让实验箱断电点击下载按钮后再给实验箱上电程序才能正确写入。传统AT系列芯片则用专用编程器或者并口下载线现在的实验箱大多已经是STC方案串口下载这一步基本通用。3. 核心代码实现定时初值计算、扫描函数与加减计数逻辑3.1 定时器1ms中断初值从哪来晶振与计数器的换算定时器初值的计算是整个项目里最需要动脑筋的地方但只要掌握了机器周期的概念就很简单。51单片机的机器周期等于12个晶振周期。用12MHz晶振时机器周期就是12除以12MHz等于1微秒。定时器T0工作在方式1是16位计数器计数范围从0到65535再数一个就溢出触发中断。要想让它每1ms溢出一次1ms等于1000微秒也就是需要计数1000次。那么定时器启动值就是65536减1000等于64536。把十进制64536换算成十六进制是0xFC18所以TH0赋0xFCTL0赋0x18。晶振频率机器周期定时1ms所需计数定时器初值(十进制)TH0/TL012MHz1us100064536FCh / 18h11.0592MHz约1.085us约92264614FCh / 66h如果实验箱用的是11.0592MHz晶振机器周期大约是1.085微秒定时1ms需要约922次计数初值就是0xFC66。误差千分之一级别对数码管刷新完全无感。方式1是手动重载模式进入中断后必须重新给TH0和TL0赋初值否则下一次定时器就是从0开始数65536个机器周期显示节奏会突然变慢。重载初值这个动作一定要写在中断服务程序里我见过有人写在主循环里结果中断只触发了一次显示完全错乱。3.2 段码表、位选与显示缓冲区的切分数码管显示数字需要准备一个段码表。以共阴数码管为例数字0点亮a、b、c、d、e、f这6段小写g段和dp段熄灭对应的段码二进制是00111111也就是0x3F。数字1只有b、c两段段码是0x06。完整的0到9共阴段码表如下// 共阴数码管段码表0~9 unsigned char code seg_code[10] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F };如果实验箱用的是共阳数码管段码表整体取反就行比如0变0xC01变0xF9。到底用哪套看实验箱原理图标注。位选部分假设四位数码管位选接在P2口低四位且低电平有效对应的位选码是unsigned char code bit_code[4] {0xFE, 0xFD, 0xFB, 0xF7};数组的第一项0xFE二进制是11111110表示第一位被选中0xFD表示第二位以此类推。如果电路采用高电平驱动的位选就要反过来。显示缓冲区用长度为4的数组保存当前要显示的每一位数字。比如计数器的值是1234我们要让缓冲区依次存1、2、3、4这样中断扫描时直接按位取段码就能显示不需要在中断里做除法运算。把数值拆成4个单数字的动作放在主循环里中断只管显示这个设计能避免中断服务程序过长也避免按键更新计数值的瞬间显示读到中间状态。3.3 按键状态判断消抖、释放检测与加减逻辑按键函数需要完成三个动作确认按下、消抖、等待释放。用两个独立按键分别接P1.0和P1.1低电平有效扫描逻辑这样写unsigned char key_scan(void) { if (key_add 0) // 加键被按下 { delay_ms(10); // 延时跳过抖动窗口 if (key_add 0) // 再次确认确实按下 { while (key_add 0); // 等待松开防止连击 return 1; } } if (key_sub 0) // 减键同理 { delay_ms(10); if (key_sub 0) { while (key_sub 0); return 2; } } return 0; }为什么确认按下后还要等松开因为如果不等待按键按下去的几十毫秒里全速运行的主循环可能已经扫描到十几次按下状态计数会瞬间加上好几次这就是常说的按键连击。加一个while等待释放把一次物理按压限制成一次逻辑触发。加减计数的业务逻辑用无符号整型count保存当前数值控制在0到9999的范围。加键按下时count加一超过9999就回到0减键按下时如果count已经是0就绕回9999否则减一。这里最需要注意的就是减键的边界count是unsigned int类型如果直接写count--当count等于0时会变成65535然后显示出来就是FFFF根本不是预期效果。3.4 完整工程代码与注释下面给出整个项目的完整C51代码。IO映射用的是P0做段选、P2低四位做位选、P1.0和P1.1做按键这套常见接法如果你的实验箱端口定义不同只需要改开头的sbit和位选定义。#include reg52.h sbit key_add P1^0; // 加数按键低电平有效 sbit key_sub P1^1; // 减数按键低电平有效 #define uchar unsigned char #define uint unsigned int // 共阴数码管段码表 0~9 uchar code seg_code[10] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; // 位选码低电平有效依次选通第1~4位 uchar code bit_code[4] {0xFE, 0xFD, 0xFB, 0xF7}; uchar display_buf[4]; // 显示缓冲区 uchar current_bit 0; // 当前正在刷新的位 uint count 0; // 计数值 // 毫秒级延时12MHz下粗略值 void delay_ms(uint ms) { uint i, j; for (i ms; i 0; i--) for (j 110; j 0; j--); } // 定时器T0初始化方式11ms中断 void timer0_init(void) { TMOD 0xF0; // 只修改T0相关位不影响T1 TMOD | 0x01; // T0方式116位定时 TH0 0xFC; // 初值高8位12MHz1ms TL0 0x18; // 初值低8位 ET0 1; // 开T0中断 EA 1; // 开总中断 TR0 1; // 启动T0 } // T0中断服务程序数码管动态扫描 void timer0_isr(void) interrupt 1 { TH0 0xFC; // 重载初值 TL0 0x18; P2 0xFF; // 先消隐关闭所有位选 P0 seg_code[display_buf[current_bit]]; // 送段码 P2 bit_code[current_bit]; // 选通当前位 current_bit; // 切换到下一位 if (current_bit 4) current_bit 0; } // 按键扫描消抖 释放检测 uchar key_scan(void) { if (key_add 0) { delay_ms(10); if (key_add 0) { while (key_add 0); return 1; } } if (key_sub 0) { delay_ms(10); if (key_sub 0) { while (key_sub 0); return 2; } } return 0; } // 把计数值拆成4个十进制位刷新显示缓冲区 void display_update(void) { display_buf[0] count % 10; // 个位 display_buf[1] count / 10 % 10; // 十位 display_buf[2] count / 100 % 10; // 百位 display_buf[3] count / 1000; // 千位 } void main(void) { timer0_init(); while (1) { uchar key key_scan(); if (key 1) // 加键 { count; if (count 9999) count 0; // 上限回0 } else if (key 2) // 减键 { if (count 0) count 9999; // 下限绕回最大值 else count--; } display_update(); // 更新显示内容 } }代码本身不复杂但它体现了前面说的分层思想中断服务程序只管扫描主循环只管按键和业务逻辑。中断函数越短越好不要在里面放延时、不要做除法更不要调用可能很长的函数。你以后写更大项目维护这个主循环加中断的结构会非常顺手。4. 实操过程记录亚龙236接线、下载与实测4.1 实验箱接线与程序下载步骤在亚龙236实验箱上实操最关键的一步不是写代码而是接线和确认端口。我第一次拿这个题目时认为全代码没问题就直接下载结果数码管亮度极低还乱码最后查出来是位选脚接错。正确流程是这样的。第一步翻开实验箱的数码管模块原理图确认段选线和位选线的具体引脚号。本例假设段选对应P0口、位选对应P2口低四位如果原理图显示不一样改代码开头的sbit和位选数组就行其余逻辑不用动。第二步检查独立按键模块找到加键和减键对应的IO口本例接P1.0和P1.1。第三步插好所有跳线帽确认数码管模块和按键模块的电源跳线、信号跳线都已经接通。第四步在Keil里把工程编译通过确认勾选了生成HEX文件的选项。下载时按STC芯片的冷启动方式处理打开STC-ISP工具选择串口和波特率加载HEX文件点击下载按钮再给实验箱上电。如果实验箱本身有板载串口切换开关还要把开关拨到下载位置。下载完成后复位实验箱数码管上电默认显示0000这个现象正常。4.2 按键功能与计数边界实测记录我把这个项目的完整测试过程记录一下方便你对标检查自己的实验结果。初始上电数码管显示0000。按一次加键显示0001再按一次0002。连续按到0009后再按一次加键显示0010说明十位正确进位。一路加到9999再按一次加键显示0000说明上限回绕逻辑生效。从0000按减键显示9999说明下限回绕生效。从9999连续按减键可以一直递减到0。测试快速连续按键时每按一次显示只变化1没有出现跨2跳数说明消抖有效。按住加键不放显示只加一次后不再变化松开再按才继续加说明释放检测在工作。这个测试表整理出来是下面这样。测试操作预期显示实测结果上电复位0000正常按加键一次0001正常0009时按加键0010正常进位9999时按加键0000正常回绕0000时按减键9999正常回绕快速连按每次加1无跳变长按加键只加1次无连击4.3 调试中踩过的两个典型坑调试过程不可能一帆风顺我挑两个很有代表性的问题说说排查过程。第一个是数码管所有位同时微弱发亮数字边缘带着毛刺。这个现象其实是位选切换时没有消隐造成的。仔细看我的代码进入中断后先把P2全部置高关闭位选然后才送段码再选通目标位。如果调换了送段码和消隐的顺序切换瞬间上一位的段码会残留到这一位上就会出现亮斑和毛边。当时我为了省两行代码去掉消隐结果显示效果立刻变差后来老老实实加回去画面才干净。第二个是按下加键后显示从0001直接跳到0003甚至0004。这是按键释放等待缺失导致的连击。第一次写扫描函数时我只做了10ms消抖确认按下后就返回键值没有while等待松开。主循环速度飞快按下一次在抖动和持续按压期间被扫描到多次于是出现了跳数。加上while等待释放后问题立刻消失。这两个问题如果靠玄学式的改参数半天也调不好回到原理上逐条对很快就能定位。5. 常见问题与排查技巧速查5.1 数码管闪个不停或亮度不均匀数码管闪烁优先怀疑刷新频率不够。1ms切换一位四位完整扫描周期4ms250Hz正常不会闪。如果你的刷新周期增加到了10ms以上比如定时器初值算错或者不小心在中断里加了延时函数刷新率掉到100Hz以下人眼就能感觉到闪烁。解决办法是拉高刷新频率把切换周期控制在2ms以内。亮度不均匀的问题常见原因是每一位被点亮的时间不一致。比如扫描代码用if判断当前位而不是循环切换某些位会被跳过。或者主循环里加了较重的其他逻辑占用了定时器中断的触发间隔导致某些次的刷新被推迟。我建议扫描顺序固定用current_bit递增切换保证每一位的点亮机会均等。还有一个非常容易忽略的地方段数据变化时会存在短暂时间此时如果不消隐后显示的位容易比先显示的位亮。所有位都在切换前关闭位选能够明显改善亮度不均。5.2 按键按一下跳好几个数按键跳数九成是消抖不彻底或者缺少释放检测。10ms消抖对于大多数拨码开关和轻触开关够用如果按键质量差、抖动时间超过10ms可以加大到15ms到20ms。要注意消抖延时是在确认之后才等待释放顺序不能反否则没办法过滤长按。还有一类情况与硬件有关。如果IO口悬空、上拉电阻缺失按键未按下时电平是浮动的主循环扫描时偶尔读到低电平就会误触发。这种问题代码里怎么弄都效果有限检查实验箱按键模块的上拉电阻是否焊好、跳线是否松动才是关键。用示波器或万用表量一下按键脚位静态电平低于2V的待机状态基本就有问题。5.3 显示的内容不对或不断变乱数码管显示的数字与预期不一致常见原因有三个。段码表格式不对比如共阴管用了共阳段码显示出来的笔画完全对不上。位选顺序出错数字被换位显示比如按下加键后个位应该在右数第一位结果在左边。显示缓冲区更新和中断访问冲突主循环在写入缓冲区时中断正好去读读到了半个新值半个旧值。前两个问题看原理图对齐端口定义就能解决。第三个问题在这个项目里不明显因为四位数更新只需要几条赋值指令速度很快但如果数据量更大就需要考虑关中断更新缓冲区、或者用双缓冲结构。我习惯把display_update放在主循环靠前的位置每次按键处理后立即更新减少和中断冲突的窗口时间。5.4 编译下载环节的顽固问题编译阶段最常报错的是头文件或sbit定义。reg52.h里面已经定义了P0到P3的SCON等寄存器自定义的sbit不能和这些名字冲突。还有sbit只能对可位寻址的寄存器操作如果你试图给一个普通内存地址定义sbit编译器会直接报错。代码里所有sbit都定义在函数外面不要写进main。下载失败方面STC芯片最常见的表现是STC-ISP提示握手失败或者单片机无应答。逐个排查法串口号选没选对波特率过高导致通信不稳定先降到9600或4800USB转串口线驱动是否正常实验箱上的下载模式跳线是否打对是不是真的先点了下载再上电。冷启动这个时序问题新手几乎都要踩一次。如果代码在别的实验箱上能跑换到自己的板子后数码管不亮先看是否需要给P0口加上拉电阻。P0口内部没有上拉用作输出有时需要外接10k排阻或直接接数码管驱动芯片。亚龙实验箱基本已经处理好了自搭板特别注意。6. 经验心得与玩法扩展6.1 这套程序结构能长到什么程度做完这个题目后你可以站在更高的角度看它本质上它已经是一个微型前后台系统。定时器中断当作心跳负责所有周期性的硬件刷新主循环当作业务层处理按键、更新数据、控制逻辑。这种结构往小了说51单片机上一堆显示类题目都是这个套路往大了说很多RTOS应用里的时间片调度也是类似思路。理解了这个结构你再看别的例程时就不会慌。不管是温湿度传感器采集后显示还是电子称、频率计、转速表核心永远是定时刷新显示主循环响应事件。这个统一模式一旦练熟遇到新的题目第一反应不是到处找例程而是先划分谁管周期、谁管事件、数据怎么交接。6.2 从加减计数到秒表、倒计时、多位数扩展这个项目改造成其他功能相当方便。改成秒表只需要增加一个运行状态标志位用一个按键控制启停定时器中断里不仅刷新数码管同时累加一个毫秒计数器主循环把毫秒值换成时分秒送显示缓冲区。改成倒计时同样不难设置一个初值每秒钟减一次减到0时让蜂鸣器响一下。这些改动都不需要推翻现有代码在中断服务程序和主循环里加状态分支就行。想做长按连续计数就把while等待释放改成闭合计时记录按键持续按下的时间超过500ms后每50ms触发一次累加。这个功能对后面做电子设置类菜单很有用。想扩展到8位数码管把扫描位数数组改成8个元素位选码扩展到8个扫描循环周期相应拉长一些就能跑。还有一个小改造思路把两个独立按键换成矩阵键盘扩展出更多功能键比如加一个清零键、一个保存键。按键扫描的逻辑会从独立IO直接读取改成行列扫描加按键编码这也是这个小系统可以继续深入的方向。6.3 几条掏心窝的调试建议最后分享几条我在反复调试这类题目后总结出的建议比较零碎但每一条都是真金白银换来的。第一写动态扫描程序时先把显示固定成一段测试数据比如让四位数码管显示8888确认扫描和位选全部正常后再接按键和计数逻辑。一次只引入一个变量问题好定位。第二怀疑定时器不工作时不要反复看代码在中断服务程序里翻转一个LED引脚比如P1.2取反然后用示波器看波形。有方波说明中断在跑没波形再查初始化三段式TMOD、TH/TL初值、ET0/EA/TR0。第三Keil的软件仿真可以观察P0、P2引脚的电平时序虽然速度慢但排查静态逻辑错误比反复上下板烧录快得多。我自己在教这个题目时经常强调一句话数码管类项目八成问题在初值计算和消隐顺序按键类项目八成问题在消抖和释放检测。把这两块做扎实这个综合题就已经拿到了大部分分数。这套代码和思路你只要完整跑通一次再回头去看那些LED点亮、蜂鸣器报警的简单例程会发现硬件操作其实都是那点事真正值钱的是你脑子里形成的系统怎么组织的感觉。
阅读完成 · 觉得有帮助?