简介:本资源面向电子信息、自动化及嵌入式相关专业的课程设计学习者,提供一套基于STM32的篮球比赛计时记分器完整实现方案,采用HAL库开发并配套Proteus仿真与Keil工程代码。方案覆盖LCD液晶显示本节剩余时间与进攻剩余时间、按键暂停/继续/复位、两队比分显示、一分两分三分进球区分,以及蜂鸣器与发光二极管模拟得分声光提示,矩阵按键扫描功能可自行定义,适合作为单片机课程设计或嵌入式入门实战参考。压缩包共183个文件,约7.7MB,包含24个c源文件、53个h头文件及o、d、crf等编译中间文件,另有uvprojx工程、ioc配置、hex固件与map映射文件,完整呈现从外设驱动到工程构建的目录结构。目前已有2625人学习下载,可帮助读者快速理解赛题需求、复用HAL库外设配置并对照仿真验证计时计分逻辑。
1. 从一块 LCD 和九个按键说起:篮球计时记分器到底难在哪
很多人第一次看到「基于 STM32 的 LCD 篮球计时记分器」这个题目,第一反应是「不就是个数码管计时器吗」。真动手做才发现,翻车点根本不在计时逻辑,而在三件事:LCD 驱动能不能稳定刷出来、矩阵按键扫描会不会丢键或连击、Proteus 仿真里 STM32 模型和真实芯片的时序差异。篮球比赛的计时规则比普通秒表苛刻——24 秒进攻倒计时和比赛总计时要并行跑,犯规、暂停、节次切换都要即时响应,按键必须在毫秒级被捕获,否则裁判按了没反应就是事故。
这个方案适合三类人:正在做单片机课程设计、需要一份能跑通的 Proteus 仿真加 Keil 工程的学生;想从标准库迁移到 HAL 库、拿一个完整小项目练手的嵌入式新手;以及需要快速验证矩阵按键扫描算法、不想每次都烧板子的工程师。核心链路是 STM32 通过 GPIO 或 FSMC 驱动 LCD 显示比分与时间,矩阵按键负责输入,定时器中断负责计时精度。下面按「先跑通仿真、再抠参数、最后避坑」的顺序拆开讲。
2. 硬件选型与 Proteus 仿真环境搭建:先把电路跑起来
2.1 为什么选 LCD 而不是数码管,选矩阵按键而不是独立按键
篮球记分器要显示的信息量不小:主队比分、客队比分、比赛节次、总计时(通常 MM:SS)、24 秒进攻时间。用数码管至少需要 6 到 8 位,走线复杂、占用 IO 多,而且 Proteus 里数码管模型刷新时容易闪烁。LCD 一块 1602 或 12864 就能把全部信息排布清楚,1602 成本低、驱动成熟,12864 能显示中文队名,看需求选。
按键方面,如果每个功能一个独立按键,至少需要 9 个以上 IO(主队加/减、客队加/减、开始/暂停、24 秒复位、节次切换、犯规计数)。矩阵按键用 3×3 或 4×4 就能把 9 到 16 个功能压缩到 6 到 8 个 IO,代价是扫描逻辑复杂一点。这个项目里 3×3 矩阵刚好够用,行线 3 根、列线 3 根,共 6 个 IO。
| 方案 | IO 占用 | 显示信息量 | Proteus 仿真稳定性 | 适合场景 |
|---|---|---|---|---|
| 数码管 + 独立按键 | 14+ | 少,需分时复用 | 一般,易闪烁 | 极简计时 |
| LCD1602 + 独立按键 | 9+ | 中 | 好 | 课程设计 |
| LCD1602 + 3×3 矩阵 | 6+6 | 中 | 好 | 本项目推荐 |
| LCD12864 + 4×4 矩阵 | 8+8 | 多,可显示中文 | 好 | 功能扩展 |
2.2 Proteus 里搭最小系统:元件库对照与连线
Proteus 元件库对照是新手第一个卡点。STM32 模型在 Proteus 里叫STM32F103R6或STM32F103C8,具体看你的元件库版本。LCD1602 搜LM016L,矩阵按键没有现成 3×3 元件,常见做法是用 9 个BUTTON手动排成矩阵,或者用KEYPAD-PHONE改。晶振用CRYSTAL,复位电路用RES加CAP-ELEC。
连线要点:STM32 的 PA0 到 PA2 接矩阵行线,PA3 到 PA5 接列线,行线配置为输出、列线配置为输入下拉。LCD1602 的 RS、RW、E 接 PB0、PB1、PB2,数据线 D4 到 D7 接 PB4 到 PB7(4 位模式省 IO)。注意 Proteus 里 STM32 的电源引脚默认隐藏,需要在属性里确认 VDD 和 VSS 已连接,否则仿真直接不跑。
提示:Proteus 8.17 之后的版本对 STM32 时钟模型有更新,如果仿真里定时器计时偏慢,先检查晶振频率属性是否设成 8MHz,而不是默认的 1MHz。
2.3 Keil 工程创建与 HAL 库配置的最小步骤
Keil 这边,新建工程后先装 STM32F1 的芯片包(Keil.STM32F1xx_DFP),否则器件列表里找不到型号。用 STM32CubeMX 生成 HAL 库初始化代码是最省事的路径:选好芯片、配好时钟树(HSE 8MHz,PLL 倍频到 72MHz)、把用到的 GPIO 和定时器勾上,生成 Keil MDK 工程。
# CubeMX 生成后,Keil 里需要确认的编译选项 # 1. Options for Target -> C/C++ -> Define 里加上 USE_HAL_DRIVER, STM32F103xB # 2. Debug 选项卡选 Proteus VSM Simulator(如果装了插件) # 3. Output 里勾上 Create HEX File,Proteus 加载的就是这个 hex生成完先别急着写业务代码,编译一次确认没有报错。常见 keil 错误是cannot open source input file "stm32f1xx_hal.h",原因是头文件路径没加全,在 C/C++ 的 Include Paths 里把Drivers/STM32F1xx_HAL_Driver/Inc和Drivers/CMSIS/Device/ST/STM32F1xx/Include都加进去。
3. LCD 驱动与显示刷新:让比分和时间稳定刷出来
3.1 LCD1602 的 4 位模式初始化时序
LCD1602 用 4 位模式能省 4 个 IO,但初始化时序必须严格按手册来,否则 Proteus 里就是一片空白。关键顺序是:上电延时 15ms 以上,发 0x33、0x32 切到 4 位模式,再发 0x28 设两行显示、0x0C 开显示关光标、0x06 设光标右移、0x01 清屏。每一步之间要留足够延时,尤其是清屏指令要等 1.52ms 以上。
/* lcd1602.c - 4位模式初始化,基于HAL库 */ #include "lcd1602.h" static void LCD_WriteNibble(uint8_t nibble, uint8_t rs) { HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, rs ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_RW_PORT, LCD_RW_PIN, GPIO_PIN_RESET); // 只写模式 // D4-D7 接在 PB4-PB7 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, (nibble & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, (nibble & 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, (nibble & 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, (nibble & 0x08) ? GPIO_PIN_SET : GPIO_PIN_RESET); // 使能脉冲,宽度要大于450ns HAL_GPIO_WritePin(LCD_EN_PORT, LCD_EN_PIN, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(LCD_EN_PORT, LCD_EN_PIN, GPIO_PIN_RESET); HAL_Delay(1); } void LCD_Init(void) { HAL_Delay(20); // 上电等待,别省这个延时 LCD_WriteNibble(0x03, 0); // 第一次强制8位 HAL_Delay(5); LCD_WriteNibble(0x03, 0); HAL_Delay(1); LCD_WriteNibble(0x03, 0); LCD_WriteNibble(0x02, 0); // 切4位模式 LCD_WriteCmd(0x28); // 两行,5x7点阵 LCD_WriteCmd(0x0C); // 开显示,关光标 LCD_WriteCmd(0x06); // 写入后光标右移 LCD_WriteCmd(0x01); // 清屏 HAL_Delay(2); }逻辑说明:LCD_WriteNibble负责把一个字节的低 4 位送到数据线并产生使能脉冲,rs参数区分指令(0)和数据(1)。初始化里连续三次发 0x03 是为了兼容不同上电状态,这是 LCD1602 手册里明确要求的「软复位」流程。参数上,HAL_Delay(1)在 72MHz 下实际延时约 1ms,对 LCD 来说绰绰有余,但如果你把系统时钟改低了,这个延时可能不够,使能脉冲宽度必须保证大于 450ns。
3.2 比分与倒计时的显示格式设计
篮球记分器的显示布局要一眼能读。常见排法是第一行显示HOME 88 GUEST 76,第二行显示Q3 08:24 24S:12。这里有个细节:24 秒倒计时和比赛总计时刷新频率不同,总计时每秒变一次,24 秒倒计时也是每秒变,但两者独立计数。如果每次刷新都整屏重写,LCD 会闪,正确做法是只更新变化的字符位置。
/* 只更新比分数字,避免整屏刷新造成闪烁 */ void LCD_UpdateScore(uint8_t home, uint8_t guest) { char buf[4]; // 主队比分显示在第1行第6列 LCD_SetCursor(0, 5); sprintf(buf, "%3d", home); LCD_WriteString(buf); // 客队比分显示在第1行第14列 LCD_SetCursor(0, 13); sprintf(buf, "%3d", guest); LCD_WriteString(buf); }参数说明:LCD_SetCursor(row, col)里 row 取 0 或 1,col 取 0 到 15。比分用%3d右对齐,这样从 9 变到 10 时不会把后面的字符挤掉。如果你用的是 LCD12864 并想显示中文队名,注意 STM32 的 GBK 转 UTF8 问题——Keil 默认源文件编码可能是 GBK,而 LCD 字库如果是 UTF8 索引就会显示乱码,常见做法是统一用 GBK 编码存字库,或者用取模软件生成点阵数组直接写。
3.3 用定时器中断驱动计时,别在 while 里数延时
计时精度是这个项目的命门。如果在主循环里用HAL_Delay(1000)来减秒,按键响应会被阻塞,裁判按暂停要等一秒才生效,体验极差。正确做法是用一个硬件定时器(比如 TIM2)配成 1ms 中断,在中断里维护毫秒计数器,满 1000 就减一秒。
/* tim.c - 1ms定时器中断里维护计时 */ volatile uint32_t g_ms_tick = 0; volatile uint8_t g_game_sec = 0; // 比赛总计时秒 volatile uint8_t g_shot_sec = 24; // 24秒进攻时间 volatile uint8_t g_running = 0; // 开始/暂停标志 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { g_ms_tick++; if (g_ms_tick % 1000 == 0 && g_running) { if (g_game_sec > 0) g_game_sec--; if (g_shot_sec > 0) g_shot_sec--; } } }逻辑说明:g_ms_tick每毫秒加一,取模 1000 得到秒边界。g_running控制计时启停,按键中断或扫描里改这个标志就行。参数上,TIM2 的预分频器设成 71、自动重装载值设成 999,在 72MHz 下正好 1ms 中断一次。注意中断里不要做 LCD 刷新这种耗时操作,只改数据,刷新放到主循环里根据标志位做。
4. 矩阵按键扫描:3×3 键盘不丢键、不连击的写法
4.1 行扫描法的原理与 HAL 库实现
矩阵按键的核心是「逐行拉低、读列线」。3×3 矩阵有 3 根行线(输出)和 3 根列线(输入上拉)。扫描时依次把一行拉低,读三根列线,哪根被按下就是低电平,就能定位到具体按键。HAL 库下要注意读引脚用HAL_GPIO_ReadPin,而且列线必须配成上拉输入,否则按下时读不到稳定的低电平。
/* keypad.c - 3x3矩阵按键扫描,带消抖 */ #include "keypad.h" const uint8_t key_map[3][3] = { {1, 2, 3}, // 行0:主队+、主队-、客队+ {4, 5, 6}, // 行1:客队-、开始/暂停、24秒复位 {7, 8, 9} // 行2:节次切换、犯规+、预留 }; uint8_t Keypad_Scan(void) { for (uint8_t row = 0; row < 3; row++) { // 先把三行都拉高,再拉低当前行 HAL_GPIO_WritePin(ROW0_PORT, ROW0_PIN|ROW1_PIN|ROW2_PIN, GPIO_PIN_SET); switch(row) { case 0: HAL_GPIO_WritePin(ROW0_PORT, ROW0_PIN, GPIO_PIN_RESET); break; case 1: HAL_GPIO_WritePin(ROW1_PORT, ROW1_PIN, GPIO_PIN_RESET); break; case 2: HAL_GPIO_WritePin(ROW2_PORT, ROW2_PIN, GPIO_PIN_RESET); break; } HAL_Delay(1); // 等电平稳定 for (uint8_t col = 0; col < 3; col++) { if (HAL_GPIO_ReadPin(COL_PORT, COL_PIN[col]) == GPIO_PIN_RESET) { HAL_Delay(10); // 消抖 if (HAL_GPIO_ReadPin(COL_PORT, COL_PIN[col]) == GPIO_PIN_RESET) { while (HAL_GPIO_ReadPin(COL_PORT, COL_PIN[col]) == GPIO_PIN_RESET); return key_map[row][col]; } } } } return 0; // 无按键 }逻辑说明:外层循环遍历三行,每次只拉低一行,内层循环读三列。HAL_Delay(1)是给行线电平建立时间,HAL_Delay(10)是消抖。最后的while等待按键释放,防止一次按下被识别成多次。参数上,消抖时间 10ms 是经验值,如果你的按键质量差可以加到 20ms,但太大会影响连按手感。
4.2 消抖与长按处理:为什么你的按键会「连击」
按键连击是矩阵扫描最常见的翻车点。现象是按一下比分加了 3 分。原因通常是扫描周期太快,机械按键抖动被多次识别。除了上面的延时消抖,更稳的做法是「状态机消抖」:记录每个按键的当前状态和上次状态,只有从「未按下」变到「按下」且持续超过阈值才触发一次。
/* 状态机消抖,适合放在定时器里每10ms调用一次 */ typedef struct { uint8_t last_state; uint8_t stable_cnt; uint8_t pressed_event; } KeyState; KeyState keys[9] = {0}; void Keypad_ScanStateMachine(void) { uint8_t raw = Keypad_ReadRaw(); // 只读原始电平,不延时 for (int i = 0; i < 9; i++) { uint8_t cur = (raw >> i) & 0x01; if (cur != keys[i].last_state) { keys[i].stable_cnt = 0; keys[i].last_state = cur; } else { if (keys[i].stable_cnt < 3) keys[i].stable_cnt++; if (keys[i].stable_cnt == 3 && cur == 1) { keys[i].pressed_event = 1; // 稳定按下,产生一次事件 } } } }参数说明:stable_cnt到 3 表示连续三次扫描(每次 10ms)都是同一状态,即 30ms 稳定期。pressed_event标志在主循环里读取后清零,保证一次按下只处理一次。这种写法比HAL_Delay阻塞式消抖更适合多按键同时按的场景,比如裁判同时按主队加分和开始。
4.3 把按键事件映射到比赛逻辑
扫描到键值只是第一步,关键是把键值翻译成比赛状态变化。比如按「开始/暂停」要切换g_running,按「24 秒复位」要把g_shot_sec重置为 24,按「节次切换」要清空比分并进入下一节。这里要注意边界:比分不能减到负数,节次最多到 4 节,24 秒复位只在死球时有效。
void HandleKey(uint8_t key) { switch(key) { case 1: if (g_home_score < 199) g_home_score++; break; case 2: if (g_home_score > 0) g_home_score--; break; case 3: if (g_guest_score < 199) g_guest_score++; break; case 4: if (g_guest_score > 0) g_guest_score--; break; case 5: g_running = !g_running; break; case 6: g_shot_sec = 24; break; case 7: if (g_quarter < 4) { g_quarter++; g_home_score = 0; g_guest_score = 0; } break; case 8: if (g_foul < 5) g_foul++; break; } }逻辑说明:每个 case 都做了边界检查,避免比分溢出或负数。g_running取反实现开始暂停切换。节次切换时清空比分是篮球规则要求。参数上,比分上限设 199 是因为 LCD 一行放得下三位数,实际比赛不会超过这个值。
5. 避坑与排查:Proteus 仿真和 Keil 编译里的那些坑
5.1 仿真里 LCD 不显示或显示乱码
现象:Proteus 跑起来 LCD 一片空白,或者显示方块乱码。原因通常是三类:初始化时序不对、数据线接反、仿真时钟设置和代码里假设的不一致。解决方法是先用最简单的「显示一个字符」测试,确认硬件连线无误,再逐步加初始化指令。如果显示方块,检查 4 位模式切换是否成功,可以在初始化后读一次忙标志验证。Proteus 里 STM32 的 GPIO 输出速度属性如果设成 Low,LCD 使能脉冲可能建立不起来,改成 High 试试。
5.2 定时器计时偏快或偏慢
现象:仿真里 24 秒倒计时实际跑了 30 秒。原因一般是时钟树配置和 Proteus 模型不匹配。Proteus 的 STM32 模型默认外部晶振按你放置的 CRYSTAL 元件频率算,如果你在 CubeMX 里配了 8MHz HSE 但 Proteus 里晶振属性是 1MHz,定时器就会慢 8 倍。解决方法是双击 Proteus 里的晶振元件,把频率改成和代码一致,或者干脆用内部时钟 HSI 并确认 Proteus 模型支持。
5.3 矩阵按键在仿真里按了没反应
现象:Proteus 里点按键,比分不动。原因可能是列线没配上拉、按键元件接法不对、或者扫描函数被阻塞。Proteus 的 BUTTON 元件默认是瞬时开关,按下时导通,松开断开,接法要保证行线拉低时列线能被拉到低电平。如果列线配的是浮空输入,读到的电平不确定,必须改成上拉输入。另外确认主循环里确实在调用扫描函数,别被 LCD 刷新的延时卡住了。
5.4 Keil 编译报错 cannot open source input file
现象:编译时提示找不到stm32f1xx_hal.h或main.h。原因是头文件搜索路径没配全。解决方法是打开 Options for Target,在 C/C++ 选项卡的 Include Paths 里把 HAL 驱动目录、CMSIS 目录、用户代码目录都加进去。如果用的是 CubeMX 生成的工程,这些路径通常自动配好了,手动新建工程才容易漏。另外确认芯片包版本和 HAL 库版本匹配,F1 系列用STM32F1xx_DFP,别装成 F4 的。
5.5 仿真跑一段时间后卡死
现象:Proteus 仿真运行几十秒后界面无响应或计时停止。原因可能是中断里做了耗时操作,或者 while 等待按键释放时死循环。解决方法是检查所有while等待是否都有超时退出,中断服务函数里只改标志位不做 LCD 操作。Proteus 仿真本身性能有限,如果主循环里刷新 LCD 太频繁,可以降低刷新率到 10Hz 左右,肉眼看不出来但仿真流畅很多。
6. 进阶技巧:把仿真代码平滑迁到真实硬件
仿真跑通只是第一步,真正有价值的是这套代码能直接烧到板子上。从 Proteus 迁到真实 STM32 最小系统板,要改的地方其实不多,但有几个细节不注意就会「仿真好好的,上板就翻车」。
第一个是 LCD 的供电和对比度。真实 LCD1602 的 V0 引脚需要接一个 10K 电位器调对比度,Proteus 里这个引脚经常被忽略但仿真照样显示。上板后如果屏幕一片白或者一片黑,先调电位器。第二个是按键的硬件消抖。仿真里按键是理想开关,真实按键抖动更严重,建议在列线上并 0.1uF 电容到地,配合软件消抖双保险。第三个是电源。STM32 最小系统板如果从 USB 取电,LCD 背光一开可能拉低电压导致复位,背光限流电阻别省。
迁移时我一般会保留一个「调试模式」宏,在仿真里打开串口打印按键值和计时状态,上板后关掉。这样出问题能快速定位是扫描逻辑还是显示逻辑。
| 迁移项 | 仿真环境 | 真实硬件 | 注意点 |
|---|---|---|---|
| LCD 对比度 | 忽略 V0 | 接 10K 电位器 | 上板先调对比度 |
| 按键消抖 | 软件延时够 | 加硬件电容 | 双保险更稳 |
| 供电 | 理想电源 | USB 或外部 5V | 背光电流要算 |
| 时钟 | 晶振属性设对 | 实际晶振频率 | 用示波器量一下 |
| 调试输出 | 可加串口 | 保留 SWD | 别占用 LCD 引脚 |
最后一个技巧是关于 24 秒倒计时的显示优化。真实比赛里 24 秒到 5 秒以下时,裁判和球员需要更醒目的提示。可以在代码里加一个判断,当g_shot_sec <= 5时让 LCD 对应位置闪烁,闪烁用定时器每 500ms 翻转一次显示开关就行,不用重写整行。这个功能在仿真里也能验证,上板后效果更明显。
我自己做这类项目最大的教训是:别一上来就写完整逻辑,先用最小代码点亮 LCD、再单独测按键、最后合在一起。每次只验证一个模块,出问题范围小、好定位。这套流程帮我省了无数个熬夜调时序的晚上。希望帮到你。
本文还有配套的精品资源,点击获取