51单片机篮球比赛模拟系统:数码管动态扫描与定时器中断实战
说起篮球比赛模拟系统我最初的想法其实很朴素学校班级联赛的时候裁判一只手举着手机看倒计时另一只手比划着24秒进攻时间场边记分牌还是老式的翻页板一比分咬得紧就翻得手忙脚乱。当时我手头正好有一块吃灰的51单片机开发板就想着干脆自己做一个篮球比赛模拟系统用STC89C52做主控8位数码管显示两队比分和比赛剩余时间用独立按键控制加分、减分、开始暂停再用蜂鸣器提示24秒违例。这个项目做下来之后发现它比想象中更适合练手麻雀虽小但定时器、中断、动态扫描、按键消抖、状态机这些嵌入式基础全练了一遍。这篇文章我就把完整的设计过程掰开揉碎讲一遍包括功能怎么定、硬件怎么搭、代码怎么写、坑怎么踩适合学过51基础但还没做过完整小项目的人参考。1. 一块51单片机怎么撑起整场篮球赛需求拆分与功能边界1.1 真实比赛中的三个痛点一场普通的篮球赛最容易乱的就是三件事比赛总时间、24秒进攻计时、比分记录。专业场馆有电子计分牌但班级赛、训练赛、单位内部的友谊赛大多还是靠人肉计时。手机计时的尴尬我遇到过好几次正盯着倒计时突然来一条消息屏幕一弹时间就停了手动翻比分牌更是在双方紧咬时最容易出错翻错一次要把翻走的分数翻回来容易引起争议至于24秒进攻计时基本靠场边有人专门盯着表大声喊喊快了喊慢了都不好。这些问题归结起来其实是一个需求需要一个能同时承担计时和记分两种职责的设备还得响应快、操作简单、不会因为外界消息打扰而中断。51单片机正好合适。它的实时性够用IO口能直接驱动数码管和按键成本又低即使做砸了也不心疼。我做的是一个模拟系统不是专业电子计分牌的完全复刻重点是把核心流程跑通让裁判能通过按键完成所有操作。1.2 功能清单够用且不失控一个项目最容易死在功能越加越多。开局之前我先把功能限定在下面这张表里所有后续开发都以它为准不加需求、不移除需求这叫需求冻结。对于练手项目这一步能帮你避免做完硬件发现软件塞不下。功能项说明比赛倒计时单节12分钟可配置显示为分:秒24秒进攻倒计时独立倒计时剩5秒时每秒短鸣归零长鸣两场比赛比分主队2位、客队2位支持1和-1节次管理4节制每节结束自动暂停并进入下一节开始/暂停单个按键切换运行与暂停状态24秒复位抢到篮板或犯规后裁判按一下重新计时蜂鸣器提示区分进攻剩5秒和比赛结束两种声音这套功能剔除了犯规次数统计、球员号码显示等扩展项因为那些会让数码管资源立刻吃紧。8位数码管正好能显示“主队比分2位 客队比分2位 比赛时间4位”一屏就能看全。后期想加功能留到第5章再说。1.3 为什么选51系列而不是ESP32或Arduino很多人第一反应是用Arduino库函数一大堆几行代码就能搞定。但我还是选了51原因很简单资源少反而逼你理解底层。用Arduino可能只需要调用一个millis函数而51需要自己配置定时器、计算重装值、处理中断优先级这些恰恰是嵌入式开发里最核心的东西。同样的项目在51上做完再看其他单片机的数据手册基本一通百通。成本也是一个现实因素。一片STC89C52加几个数码管、按键、电阻电容总共几十块钱属于练手项目里的低成本方案。它的资料多、问得多网上随便一搜就有大量例程遇到问题不愁查不到参考。2. 硬件搭建里的关键选择显示方案、引脚分配与驱动电路2.1 主控、晶振与下载电路入门配置的稳妥选择主控选STC89C52这是一颗经典得不能再经典的芯片。5V供电32个IO口内置Flash程序存储器支持通过串口下载程序不需要额外的编程器。我用的是DIP封装的40脚芯片方便在面包板上搭建和修改。晶振的选择比很多人想的更有讲究。我一开始用的是11.0592MHz因为它对串口波特率特别友好可以精确得到9600等常用波特率。但后来发现一个问题在51单片机的12T模式下11.0592MHz对应的机器周期不是整数微秒定时器重装值会出现小数取整的情况长期累计会有误差。如果项目既要串口又要精确计时就得在两者之间取舍。这个项目我最后定在12MHz。原因非常简单12T模式下12MHz晶振的机器周期正好是1微秒定时器计算完全整数化2ms就是计数2000个机器周期50ms就是计数50000个机器周期重装值一点误差都没有。串口的数据上报功能我在第5章会专门说明怎么绕过波特率问题。下载电路上STC芯片只需要在P3.0和P3.1引出两根线接USB转TTL模块冷启动下载程序即可。这里有个小细节下载时要把单片机断电再上电否则串口工具可能识别不到芯片。我第一次做的时候不知道这个特性卡了半天。2.2 显示选型8位数码管动态扫描的性价比分析显示方案是硬件设计的核心决策。当时摆在我面前的有三个选项数码管、LCD1602、OLED显示屏。我把它们放在一起对比过结论很明确。显示方案优势劣势8位共阳数码管字大、远距离可见、视角广、有手动实现动态扫描的价值引脚多、驱动复杂、信息量少LCD1602接口占用少、可显示字母和符号、驱动简单远距离看不清、白天在室外亮度不够OLED小屏信息量大、可显示中文和图形尺寸小、太阳底下看不清、价格略高我最终选择8位数码管理由是篮球赛场上的计分设备最重要的是远距离可读。OLED和LCD都做不到在几米外一眼看清比分。数码管的另一个好处是让自己逼着实现动态扫描这是中断和定时器的重要实战场景。数字占位结构是8位从左到右依次为主队十位、主队个位、客队十位、客队个位、比赛分钟十位、比赛分钟个位、比赛秒十位、比赛秒个位。这样“23 15 10:28”一眼就能看出主队23分、客队15分比赛还剩10分28秒。2.3 驱动电路细节从段选限流到位选三极管数码管驱动有两个容易翻车的地方段选电流和位选极性。段选这边P0口要接4.7k到10k的上拉电阻。原因是P0口在51里是开漏输出内部没有上拉如果直接接数码管段选高电平状态输出能力很弱显示会变暗甚至乱码。我用的是一体化的10k排阻接P0口做上拉然后用330欧姆电阻串联到数码管的各个段引脚限制每段电流在5到10毫安。这样一次点亮一位的时候数码管亮度适中不会过流也不会暗到看不清。位选这边我用的是共阳数码管公共端接高电平某一段要亮就需要公共端为高、段选为低。所以位选不能直接用IO口灌电流我用PNP三极管来解决每个数码管的公共阳极接到三极管的集电极三极管的发射极接5V基极通过一个1k电阻接到P2口对应引脚。当P2输出低电平时三极管导通给这一位数码管供电输出高电平时截止这一位不亮。为什么不用ULN2003因为ULN2003内部是达林顿管输出端是集电极开路结构适合做电流灌入也就是共阴数码管的位选驱动。做共阳数码管的高端位选PNP三极管更直观。如果你手上只有共阴数码管那反过来用ULN2003反而更合适。蜂鸣器方面我选的是有源蜂鸣器内部自带振荡电路IO口给高电平就能响不需要用定时器去产生频率。但是它的工作电流大概有二三十毫安直接用IO口推会超负荷所以我加了一个NPN三极管驱动蜂鸣器接在三极管集电极和5V之间基极串联一个1k电阻接到P1.0口。IO输出高电平时三极管导通蜂鸣器发声。完整的引脚分配表我贴在下面方便你把电路连起来。模块引脚连接说明段码输出P0.0-P0.7经330欧姆限流电阻接8位数码管a-g和dp位选控制P2.0-P2.7经PNP三极管接各数码管公共阳极按键P3.2-P3.7一端接GND按下拉低蜂鸣器P1.0经NPN三极管驱动串口下载P3.0、P3.1接USB转TTL模块这里注意P3.0和P3.1默认是串口的RXD和TXD。我这个项目需要串口下载程序但平时使用这两个引脚不会冲突下载的时候才用串口。按键我放在了P3.2到P3.7绕开了串口引脚避免下载和按键产生物理冲突。3. 软件核心双定时器协作下的倒计时与动态扫描3.1 双定时器分工2ms照亮数码管50ms攒出一秒软件设计是整个项目的灵魂。如果只有一个定时器既要做数码管动态扫描又要做秒计时中断服务函数会变得很乱而且两个任务的周期完全不同。数码管保证不闪烁需要每位至少几十赫兹的刷新率8位轮流刷新一次的总周期不能超过16毫秒左右也就是每2毫秒点亮一位比赛计时精度到秒就够了。两个任务周期相差25倍硬揉在一起谁将就谁都不合适。所以我用了双定时器方案定时器0负责2毫秒中断做数码管动态扫描定时器1负责50毫秒中断累计20次得到1秒的时间基准。两个定时器都工作在方式1也就是16位定时器模式溢出时触发中断。初始化代码如下void Timer_Init() { TMOD 0x11; // T0方式1T1方式1 // T02ms12MHz机器周期1us计数2000个周期 TH0 (65536 - 2000) / 256; TL0 (65536 - 2000) % 256; // T150ms计数50000个周期 TH1 (65536 - 50000) / 256; TL1 (65536 - 50000) % 256; ET0 1; ET1 1; EA 1; TR0 1; TR1 1; }为什么是2毫秒而不是1毫秒8位数码管逐位扫描完一轮需要2毫秒乘以8等于16毫秒刷新率约62。5赫兹高于人眼的闪烁融合频率看起来就是连续点亮。如果把扫描周期拉长到5毫秒刷新率降到25赫兹肉眼就会明显看到闪烁感。这算是一个典型的工程折中刷新率越高越不闪但CPU开销也越大2毫秒在51上足够从容主循环还有很多空闲时间做按键扫描和逻辑处理。3.2 显示缓冲区与动态扫描你看到的稳定其实是高速轮换显示的代码设计要点是显示缓冲区这个概念。主程序把所有要显示的数值换算成段码写入一个全局数组定时器0中断只需要负责把数组里的内容轮流搬到数码管上。主循环和中断之间不互相等待这叫做生产者消费者模型只是这里的生产者和消费者都很轻量。unsigned char code seg_table[10] { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, // 0 1 2 3 4 0x92, 0x82, 0xF8, 0x80, 0x90 // 5 6 7 8 9 }; unsigned char display_buf[8] {0, 0, 0, 0, 0, 0, 0, 0}; unsigned char scan_pos 0; void Timer0_ISR() interrupt 1 { TH0 (65536 - 2000) / 256; TL0 (65536 - 2000) % 256; // 关键先消隐再送段码最后开位选 P2 0xFF; // 全部位选无效 P0 0x00; // 段码清零 P0 seg_table[display_buf[scan_pos]]; P2 ~(1 scan_pos); // 共阳位选低电平选中 scan_pos; if (scan_pos 8) { scan_pos 0; } }这里有三行代码的顺序特别重要先消隐再送段码最后打开位选。如果不先关闭所有位选直接把下一段段码送进P0口当前正在显示的位会在一瞬间用新的段码亮一下形成毛刺和鬼影。先全部熄灭再更新内容视觉上才能干净。这段代码里的段码表是共阳数码管的0对应0xC01对应0xF9依次类推。3.3 比赛逻辑状态机开始、暂停、节切换和24秒违例比赛逻辑我把它设计成一个简单的状态机。全局变量game_state表示系统状态取值为RUNNING、PAUSED、END三种。还有一个quarter变量表示当前是第几节game_time_second表示当前节剩余的秒数attack_second表示进攻剩余秒数attack_running表示进攻计时是否在跑。定时器1中断每50毫秒把tick加1累计20个tick就是1秒于是置位second_flag。主循环只需要轮询这个标志位发现置位就执行一次秒级逻辑。这样做的好处是中断服务函数非常短只更新计数和标志位避免在中断里做复杂判断导致定时器下一次溢出时间漂移。核心的秒处理逻辑如下void second_process() { if (game_state RUNNING) { if (game_time_second 0) { game_time_second--; } if (game_time_second 0) { game_state PAUSED; quarter; if (quarter 4) { game_state END; } else { game_time_second 12 * 60; } attack_second 24; attack_running 0; } } if (attack_running) { if (attack_second 0) { attack_second--; } if (attack_second 5 attack_second 0) { buzzer_short(); // 剩5秒每秒短鸣 } if (attack_second 0) { attack_running 0; buzzer_long(); // 24秒违例 } } update_display_buffer(); }24秒复位是独立按键触发的。裁判在抢到篮板、犯规或者死球之后只要按一次复位键attack_second就被重新赋值为24attack_running置1。开始暂停键切换game_state让比赛倒计时的秒级更新走还是不走。这个状态机看起来简单但它是整个项目最核心的逻辑枢纽所有按键操作最后都是改这些状态变量的值。3.4 按键扫描与蜂鸣器正确处理人的动作按键模块我用了定时扫描加边沿检测的方法。定时器中断每20毫秒扫一次按键读取当前电平状态和上一次保存的状态比较只有检测到从未按下变成按下这个边沿才触发一次动作。这样既能消抖又能避免按住不放时重复触发加分。unsigned char key_prev 0xFF; void key_scan() { unsigned char key_now read_keys(); // 按位读取P3口 unsigned char press_edge key_now (~key_prev); if (press_edge KEY_HOME_ADD) { home_score; if (home_score 99) home_score 99; } if (press_edge KEY_HOME_SUB) { if (home_score 0) home_score--; } // 其他按键类似处理 key_prev key_now; }蜂鸣器模块不能简单地在按键响应里用延时去响。理由是主循环里一旦出现大延时按键扫描的实时性就受影响。我的做法是设置一个buzzer_count变量置一个固定值之后每隔一段时间在主循环里递减减到0就关闭蜂鸣器。这样一个短鸣不需要任何延时代码就是一个异步的定时变量控制。比赛结束时的长鸣则是把buzzer_count置成较大的值响得更久。4. 逐段排错的三种典型病鬼影、连击与时间漂移4.1 数码管鬼影先送段码还是先送位选顺序有讲究我第一次把程序烧进去之后数码管虽然整体都能亮但每个数字右边都拖着一小段淡淡的残影数字切换的时候尤其明显。这个现象在动态扫描里叫鬼影。鬼影根本原因就是段码和位选的切换顺序出了问题。我的初始代码是先打开位选再送段码结果某一位还在导通的瞬间P0口已经被改成了下一位要显示的段码这位就会以错误的内容额外亮一小段时间。虽然时间极短但反复出现就形成了残影。正确做法是先在位选全部关闭的情况下对P0口送上段码然后再打开目标位选保证每一位亮起来的时候端口上已经是对应的段码。这也是第3章那段中断代码里把P2置0xFF放在最前面的原因。另外还有一个容易被忽略的技巧如果鬼影不是恒定出现在一个位置而是像水波一样流动通常是位选三极管的开关速度不一致。检查一下各位的基极限流电阻是否一致PNP三极管型号是否统一接线长度差别过大也会有影响。4.2 按键连击消抖延迟该放在哪里按键连击的典型表现是按一次加分键比分跳了2分甚至3分。刚开始我以为是硬件问题换了好几个按键还是这样后来才意识到是软件消抖没做好。机械按键在按下的瞬间触点会因为弹跳产生多次高低电平变化每次变化如果都被当作用户的一次操作就会触发多次加分。我采用的方案是所有按键通过一个20毫秒扫描周期统一处理。读取当前电平后把现在的值与上一次的值做异或得到变化位再和当前电平取与就得到了“刚被按下”的边沿。这个边沿每20毫秒才检测一次触点弹跳时间远小于20毫秒在两次扫描之间就被过滤掉了。这样无论按键怎么抖动一个完整按下过程只产生一次上升沿触发。还有一点要提一下用延时函数在按键按下时等待10毫秒再判断一次的消抖方法在这个项目里也能用但会带来一个副作用主循环里执行delay的时候虽然定时器中断不受影响但按键的连续快速点按会丢失边沿。比赛过程中裁判有可能快速连按两次加分延时消抖如果处理不好第二次按下的边沿会被错过。所以定时扫描加边沿检测是更合适的方案。4.3 计时漂移重装值是整数还是小数比赛倒计时慢这个问题我在测试的时候发现得很明显。设置一节12分钟模拟跑一遍实际时间多出十几秒。一开始我怀疑是晶振不行换了新晶振还是一样。后来用逻辑分析仪抓P2口的位选波形发现2毫秒的定时周期并不完全是2毫秒而是略微偏多一点最后定位到重装值的计算上。我最初实验串口功能时用了11.0592MHz晶振后来换成12MHz时中断里的重装值还沿用了旧计算。11。0592MHz在12T模式下机器周期约1。085微秒定时2毫秒需要大约1843个计数存在小数误差。虽然单个2毫秒的偏差很小但累计形成秒级信号之后就会明显偏慢。换回12MHz并重新计算重装值后2毫秒对应计数2000个机器周期50毫秒对应50000个机器周期数值都是整数漂移立刻消失。这里我要强调一个经验晶振频率改变时不仅要改初始化代码里的重装值还要检查中断服务函数里是否有一些补偿计算。比如有的例程会在重装值上多减几个数去做补偿这些补偿量换晶振后必须重新计算。另外还有一个隐蔽的漂移源那就是中断服务函数本体执行时间过长。如果T1中断里既处理秒逻辑又调用蜂鸣器延时整个中断占用时间会显著拉长下一次计时的起点就被推迟了。正确做法是中断里只做计数和置标志其余事情全部放主循环。这也是我第3章坚持中断短小的原因。4.4 蜂鸣器不响和声音怪异蜂鸣器的问题不如前三类那么隐蔽但也会让人摸不着头脑。有源蜂鸣器只要给高电平就响但直接接IO口常常出现声音很轻的情况这是因为51的IO输出驱动能力有限蜂鸣器内部线圈需要电流IO口带不动。接三极管驱动之后声音正常了但如果声音发闷、不干脆多半是三极管工作在放大区而不是饱和区基极电流不够。减小基极串联电阻到470欧姆到1k之间通常能解决。声音模式上我做了短鸣和长鸣两个函数但它们的本质都是设置那个buzzer_count计数器。如果直接在按键响应里调用一个循环延时的长鸣比赛中就会感觉到主机卡了一下因为主循环在执行延时期间无法响应新的按键。用计数器异步控制之后主循环继续跑声音自己会在后台结束操作响应和声音提示就完全分开了。5. 进阶玩法遥控加分、OLED信息屏与串口数据上报5.1 从拿线走到拿遥控红外遥控加分做完基础版本后我遇到一个很实际的问题加分按键在主机上裁判得从边线走到桌前才能操作。后来我加了一个红外遥控模块用一体化红外接收头加一个普通电视遥控器把遥控器上的数字键映射到加分、减分、暂停等功能。这样一来裁判可以拿着遥控器在场边按主机放在计分桌上。红外解码的原理不复杂接收头把红外信号转换成引脚上的串行波形主循环以较高频率采样引脚电平解析出遥控码再查表映射到对应的操作。难点在于红外解码对时序敏感如果采样频率太低就会漏帧。我的做法是把红外采样的读引脚操作放在tick里用2毫秒中断之外的另一个短延时服务去读取确保每个脉冲宽度都能准确解析。没有逻辑分析仪的情况下可以先抓几个稳定按键的波形用串口打印原始码值来做映射。5.2 显示升级数码管远看OLED近看数码管的优势是远距离可见但它的信息量有限。如果还想显示犯规次数、球员号码、当前节次的中文提示就需要第二块屏幕。我加了一个OLED小屏放在主机旁边负责显示细节数码管继续显示大号比分和总时间。OLED的驱动用的是I2C接口只用两个IO口几乎不占用资源。显示布局上第一行显示比赛状态第几节、是否暂停、24秒剩余第二行显示犯规次数和球权归属。和数码管的显示缓冲区类似OLED也维护一个显示缓冲区主循环在数据有变化的时候才刷新避免无谓的I2C写入拖慢系统。这样两套显示各司其职远距离看比分用数码管讲解和复盘时看OLED。5.3 串口数据上报让比赛数据回流电脑串口功能是我后期加的目的是把比赛过程实时输出到电脑方便赛后做数据统计。51单片机用串口发送数据很简单但要处理波特率。如果用11.0592MHz晶振9600波特率可以做到高精度但计时的整数重装值问题就又回来了。所以我做了个取舍主时钟仍然用12MHz保证计时精确串口波特率的误差通过软件方式修正。具体做法是接一个USB转TTL模块程序初始化串口时用一个波特率发生器产生的近似频率。12MHz产生9600波特率有百分之几的误差短时间发几个字节问题不大因为我上报数据的频率低每秒只发一行文本数据内容包含当前节数、比赛剩余时间、两队比分、进攻剩余时间。串口助手打开就能看到类似如下的数据流Q1 10:28 HOME23 GUEST15 ATTACK08 Q1 10:27 HOME23 GUEST15 ATTACK07 Q1 10:26 HOME23 GUEST15 ATTACK06如果对波特率精度要求高另一个思路是换用11.0592MHz晶振然后给计时模块做软件补偿。补偿方法是在计数器里设定一个校准值比如每500个秒信号补偿一次重装值把5毫秒级别的误差消化掉。两种方案我都试过对于这个项目的用途来说12MHz加低频率上报更省心。5.4 三条给新手的工程建议项目做完之后我总结了几条经验对做类似小项目的人应该有用。第一条先画功能清单再动手别焊完板子再想功能。我一开始就是边焊边想造成P3口按键和串口下载引脚冲突最后不得不飞线改接线。如果一开始就列好接口分配表能省不少事。第二条模块化不是大工程的专利。main.c只做调度把定时器、显示、按键、蜂鸣器、比赛逻辑分别放到独立文件里。51开发工具建工程也支持多文件编译这样做的好处是每块代码都能单独测试。我调试的时候经常只写一个timer.c测试定时器确认稳定后再加display。第三条用最小验证代替全功能一次跑通。点亮一位数码管再扫描多位然后接按键测试边沿检测最后才加比赛逻辑。每一步都验证通过再继续避免最后出错时不知道是哪一环的问题。这个过程看着慢实际是最快的路径。最后再分享一个小技巧调试定时器有没有跑不需要接仿真器找一个空闲IO口在秒中断里翻转电平接一个LED用手机秒表看它闪烁频率就够了。这比看代码猜问题直观得多。做这个项目最大的收获其实不是真的把比分显示清楚了而是理解了什么叫中断驱动、什么叫时间分片。所有重要的事情都不能在关键路径上阻塞这句话以前是口号做完这个项目之后变成了习惯。这套架构换成羽毛球比分器、棋钟、秒表也就是把按键和显示内容换一换的事想扩展什么全看你的需求了。