简介:这是一套面向嵌入式初学者与毕业设计学生的STM32实战项目资源,聚焦趣味性与工程性结合的打地鼠掌机开发,覆盖硬件驱动、人机交互与音视频反馈全流程。资源包含完整软硬件资料:103个文件,以42个.h头文件和36个.c源码为主(涵盖TIM、RCC、USART、I2C、ADC等标准外设驱动及OLED显示、蜂鸣器音乐控制逻辑),辅以原理图(.schdoc)、PCB设计(.pcbdoc)、Keil工程(.uvprojx)及可直接烧录的.hex固件,包体仅4.61MB,结构清晰、模块解耦,便于理解与二次开发。已有225人学习下载,适用于课程设计、毕设选题、电子竞赛立项及单片机入门练手。用户获取后无需PCB制板,按引脚定义用面包板+杜邦线+OLED/蜂鸣器模块即可快速复现;所有代码经实测运行稳定,并附详细说明文档与开发支持,助力高效上手与功能扩展。
1. 项目缘起:从零到一,打造一台属于自己的复古掌机
几年前,我在整理旧物时翻出了一台早已报废的Game Boy,看着它斑驳的外壳,一个念头突然冒了出来:能不能用现在手边最普及的微控制器,复刻那种简单纯粹的掌上游戏乐趣?不是模拟器,而是从硬件电路到游戏逻辑,完完全全自己实现一台。STM32系列MCU以其强大的性能、丰富的外设和极高的性价比,成为了我的首选。而“打地鼠”这个游戏,规则简单直观,交互反馈明确,非常适合作为嵌入式开发的入门综合项目,既能玩转GPIO、定时器、中断,又能接触到显示、音频等更高级的外设驱动。
于是,这个“基于STM32设计的打地鼠掌机”项目就诞生了。它不仅仅是一个玩具,更是一个完整的嵌入式系统开发实践。项目涵盖了从硬件选型、电路设计、PCB绘制,到软件层面的外设驱动、游戏逻辑、状态机设计,再到最后的整机调试与优化。最终实现的功能包括:一个完整的打地鼠游戏,带有随机出现的地鼠、得分与生命值系统;一块0.96寸的OLED屏幕,用于实时显示游戏状态、分数和菜单;一个蜂鸣器,用于播放简单的背景音乐和游戏音效(如击中、错过、游戏结束)。本文将毫无保留地分享整个项目的软硬件设计细节、开发过程中的关键决策,以及那些只有亲手做过才会知道的“坑”和技巧。
2. 硬件架构设计与核心元器件选型
一台掌机的硬件是它的骨骼。我的设计原则是在满足功能的前提下,力求简洁、可靠、易于制作。整个系统的核心是一块STM32F103C8T6,也就是常说的“蓝色药丸”核心板。选择它原因很简单:72MHz的Cortex-M3内核性能足够驱动游戏逻辑和显示;拥有足够的GPIO、定时器和中断资源;价格极其低廉,且社区资源丰富,遇到问题几乎都能找到答案。
2.1 主控与电源管理
STM32F103C8T6作为主控,负责一切计算与调度。其外围电路设计遵循最小系统原则:一个8MHz的外部晶振提供系统时钟,两个22pF的负载电容;一个复位电路(10k上拉电阻+0.1uF电容到地);以及必要的滤波电容。电源部分,我采用了一节3.7V的锂电池供电,通过一个AMS1117-3.3稳压芯片将电压稳定到3.3V,为MCU和所有外设供电。这里有个细节:AMS1117的输入和输出端都需要并联一个10uF的钽电容和一个0.1uF的陶瓷电容,分别用于储能和高频滤波,确保系统供电稳定,尤其在蜂鸣器鸣叫这种瞬时电流较大的场景下,电压不会出现剧烈波动导致MCU复位。
2.2 输入设备:按键与“地鼠”
游戏输入需要9个按键:8个对应8个“地洞”,1个作为开始/暂停/确认功能键。为了节省IO口和简化电路,我采用了矩阵键盘的设计。将8个游戏按键排成4行2列,功能键单独占用一个IO口(设置为上拉输入,按键接地)。矩阵键盘扫描使用经典的“行扫描,列读取”法,利用STM32的GPIO推挽输出和上拉输入模式,配合定时器中断进行周期性的扫描,既能可靠检测按键,又不会过多占用CPU时间。
“地鼠”本身是用LED来模拟的。在每个“地洞”位置,安装一颗普通的3mm草帽LED。LED的阴极接地,阳极通过一个220欧姆的限流电阻连接到STM32的GPIO口。当某个地洞需要“冒出地鼠”时,程序就控制对应的GPIO输出高电平,点亮LED。限流电阻的阻值需要计算:假设LED正向压降约为2V,GPIO高电平为3.3V,则电阻需要分担1.3V的电压。为了让LED亮度适中且不超过GPIO口的最大驱动电流(通常25mA),选择220欧姆,此时电流约为(3.3V-2V)/220Ω ≈ 5.9mA,非常安全。
2.3 输出设备:OLED显示与蜂鸣器
显示部分选择的是0.96寸的OLED屏幕,驱动芯片为SSD1306,分辨率128x64,I2C接口。选择I2C版本而非SPI,主要是为了节省IO口(仅需2根线:SCL和SDA),虽然刷新速度稍慢,但对于打地鼠这种刷新率要求不高的游戏完全足够。屏幕的供电同样是3.3V。在PCB布局时,需要将I2C的上拉电阻(通常4.7kΩ)放置得离屏幕接口近一些,以保证信号完整性。
音频部分采用了一个无源蜂鸣器。这里有关键区别:有源蜂鸣器内部自带振荡电路,给电就响,只能发出固定频率的声音;而无源蜂鸣器内部没有振荡源,需要外部输入PWM波才能发声,可以通过改变PWM频率来播放不同音调的音乐。为了实现背景音乐,必须选择无源蜂鸣器。我选用的是5V驱动的无源蜂鸣器,但STM32的GPIO是3.3V电平,直接驱动可能音量较小。因此,我增加了一个简单的三极管驱动电路:STM32的PWM输出引脚连接一个1kΩ的电阻到NPN三极管(如S8050)的基极,蜂鸣器接在集电极和5V电源之间,发射极接地。当PWM波为高电平时,三极管导通,蜂鸣器通电;低电平时关闭。这样就用3.3V的IO口控制了一个5V的负载。5V电源可以从AMS1117的输入端(锂电池电压)经过一个二极管降压后取得,大约4.3V,也足够驱动蜂鸣器。
3. 软件框架与核心驱动实现
软件部分采用裸机开发,基于标准外设库(Standard Peripheral Library)。整个程序由一个主循环和若干中断服务例程构成,采用前后台(超级循环)系统。
3.1 系统时钟与定时器调度
首先初始化系统时钟,将频率设置为72MHz。游戏的核心节奏由两个定时器控制:
- SysTick定时器:用于提供毫秒级的系统延时函数
delay_ms(),这个函数在驱动初始化、按键消抖等地方会用到。 - TIM2定时器:配置为1ms中断一次。这个中断是系统的“心跳”,所有基于时间的任务都在这里处理,例如:
- 游戏逻辑计时(地鼠出现持续时间、游戏总时长)。
- 矩阵键盘扫描(每10ms扫描一次,兼顾响应速度和防抖)。
- OLED屏幕刷新计时(可以设定每50ms刷新一帧,避免刷新过快导致I2C总线拥堵)。
- 蜂鸣器音乐播放的节拍控制。
这种集中式的定时调度,避免了在代码中到处使用delay函数导致CPU阻塞,让系统能够“同时”处理多项任务。
3.2 OLED显示驱动与图形界面
SSD1306的驱动代码需要自己编写或移植。核心是实现几个基本函数:OLED_Init()初始化、OLED_WR_Byte()写数据/命令、OLED_Set_Pos()设置光标、OLED_Clear()清屏。在此基础上,实现一个OLED_ShowChar()和OLED_ShowString()函数用于显示文字。
为了显示更丰富的游戏界面,我定义了一个128x64比特的显存数组OLED_GRAM[8][128](因为SSD1306是页寻址,每页8行像素,共8页)。所有绘图操作(画点、画线、显示字符)都先修改这个数组,然后通过OLED_Refresh()函数将整个数组通过I2C一次性刷到屏幕上。这种方式避免了频繁操作I2C,提高了效率。
游戏界面被设计为几个“页面”:
- 主页菜单:显示游戏标题、开始游戏、设置等选项。
- 游戏主界面:中央是8个地洞的布局(用方框或圆圈表示),地鼠出现时,对应位置的LED亮起,同时屏幕上该位置也显示一个地鼠图案。顶部显示当前分数(Score)和剩余生命(Life),底部显示时间或关卡。
- 结束界面:显示本次游戏得分、历史最高分,并提供重新开始选项。
界面的切换通过一个状态机(State Machine)来控制,状态包括MENU_STATE,PLAYING_STATE,GAME_OVER_STATE等。状态机保证了程序逻辑清晰,易于扩展。
3.3 蜂鸣器音乐播放引擎
这是项目的趣味点之一。让无源蜂鸣器播放音乐,本质就是按照乐谱的节拍和音调,动态改变输出PWM波的频率和开关时长。
首先,需要定义音调频率。我将蜂鸣器连接的定时器(如TIM3)配置为PWM输出模式,并预分频,使得计数周期可以灵活调整。然后定义一个音调-频率对照表:
// 定义中音C调各音符频率 (Hz) #define NOTE_DO 523 #define NOTE_RE 587 #define NOTE_MI 659 #define NOTE_FA 698 #define NOTE_SO 784 #define NOTE_LA 880 #define NOTE_SI 988 // 高音Do #define NOTE_DO_H 1047一首音乐可以表示为一个二维数组,每一行包含两个元素:音调(对应上面的宏定义)和持续时间(以系统节拍为单位,比如1拍=200ms)。
const uint16_t game_bgm[][2] = { {NOTE_DO, 2}, {NOTE_RE, 2}, {NOTE_MI, 2}, {NOTE_FA, 2}, {NOTE_SO, 4}, {NOTE_SO, 4}, // ... 更多音符 {0, 2} // 0表示休止符 };音乐播放引擎在TIM2的1ms中断里工作。它维护一个当前音符索引和持续时间计数器。每进入一次中断,计数器减1。当计数器减到0时,就切换到下一个音符:如果音调不为0,则根据音调值重新计算并设置TIM3的自动重装载值(ARR)以改变PWM频率,从而发出该音调;如果音调为0,则关闭PWM输出(静音)。同时,重置持续时间计数器。
通过这种方式,就可以播放简单的《小星星》、《欢乐颂》等旋律作为背景音乐。游戏音效(如击中声“叮”,错过声“噗”)也可以用短促的音符序列来实现。
注意:蜂鸣器音乐播放会占用一个定时器用于PWM生成,并且中断服务程序中的计算要尽可能快,避免影响其他定时任务。如果音乐比较复杂,可以考虑将频率计算(涉及除法)提前算好,在数组中直接存储ARR值,而不是频率值。
4. 游戏逻辑设计与状态机实现
打地鼠的游戏逻辑是项目的核心灵魂。它需要管理地鼠的随机出现、玩家的输入响应、得分计算和游戏难度 progression。
4.1 地鼠管理机制
我定义了一个结构体数组来管理8个地洞的状态:
typedef struct { uint8_t is_active; // 当前是否有地鼠,1=有,0=无 uint32_t active_ticks; // 地鼠已出现的时间(单位:系统节拍,如10ms) uint32_t max_ticks; // 地鼠最大出现时间(随难度递减) uint8_t led_pin; // 对应的LED引脚 uint8_t key_row; // 对应的按键矩阵行 uint8_t key_col; // 对应的按键矩阵列 } MoleHole_t; MoleHole_t moles[8];游戏主循环(或在一个定时任务中)会定期(比如每100ms)遍历这个数组:
- 对于
is_active为1的地洞,增加其active_ticks。如果active_ticks超过max_ticks,则判定玩家未击中,地鼠消失(is_active=0,LED熄灭),并扣除一条生命。 - 对于
is_active为0的地洞,根据一个随机算法决定是否生成新的地鼠。这个算法需要考虑游戏难度:在简单模式下,生成概率低,max_ticks长;在困难模式下,生成概率高,max_ticks短。可以使用STM32的硬件随机数发生器(RNG)或一个伪随机数函数来产生随机性。
4.2 输入响应与得分判定
在矩阵键盘扫描中断中,一旦检测到有按键按下,经过消抖确认后,就需要遍历moles数组,查找是否有地洞的key_row和key_col与按下的键匹配。
- 如果匹配且该地洞
is_active为1:恭喜,打中了!立即将该地洞状态置为无效(is_active=0,LED熄灭),播放击中音效,并根据地鼠已出现的时间(active_ticks)给予分数奖励(出现时间越短,得分越高,鼓励快速反应)。 - 如果匹配但该地洞
is_active为0:玩家打空了。可以播放一个搞笑的失败音效,并酌情扣一点分,或者只是忽略。 - 如果没有匹配(按下了功能键):则交给功能键处理程序,用于开始、暂停游戏或操作菜单。
4.3 游戏难度与进程
游戏难度可以设计为随时间或分数递增。例如,每过30秒或每获得100分,游戏进入下一关。关卡提升表现为:
- 地鼠生成的概率
spawn_probability增加。 - 地鼠最大出现时间
max_ticks减少。 - 可以同时出现的地鼠数量上限
max_concurrent增加。
这些参数的变化会让游戏节奏越来越快,挑战性越来越大。游戏进程(关卡、分数、生命值)需要实时显示在OLED屏幕上。
5. 系统整合、调试与深度优化
当所有模块的代码都准备好后,真正的挑战才刚刚开始:把它们整合成一个稳定、流畅运行的系统。
5.1 中断优先级与资源冲突管理
系统中存在多个中断源:SysTick、TIM2(系统心跳)、TIM3(PWM输出)、可能还有外部中断用于按键。必须合理配置它们的抢占优先级和子优先级。
- SysTick:通常设置为最低优先级,因为它只用于提供延时基准,不涉及关键操作。
- TIM2(1ms系统心跳):这是调度核心,优先级应设为较高,确保定时准确。但其中的任务执行要快,不能有长时间操作(如复杂的图形计算)。
- 矩阵键盘扫描:如果放在TIM2中断里,扫描算法必须高效。我采用的方法是每次中断只扫描一行,4行轮流扫描,这样每4ms才能完整扫描一次矩阵,但大大减少了单次中断的执行时间。
- I2C通信:OLED驱动使用轮询方式等待I2C事件标志,这些等待循环会阻塞CPU。绝对不能在中断服务程序中进行I2C通信。我的做法是,在TIM2中断里设置一个
oled_refresh_flag标志,然后在主循环中检查这个标志,如果置位,则调用OLED_Refresh()函数进行刷屏。这样就把耗时的I2C操作移出了中断。
5.2 功耗与性能平衡
虽然掌机一直插着电池,但优化功耗仍是一个好习惯。在软件上可以做的包括:
- 睡眠模式:在游戏菜单界面等待时,如果没有按键输入,可以让STM32进入睡眠(Sleep)模式。任何按键中断都可以将其唤醒。这需要将按键配置为外部中断唤醒源。
- 动态频率调整:在游戏进行时全速运行(72MHz)。在菜单或某些静态显示界面,可以适当降低系统时钟频率,以节省功耗。
- 外设管理:不使用时关闭外设时钟。例如,在播放完一段音乐后,可以关闭TIM3的时钟。
5.3 那些“踩过坑”才得到的经验
- OLED显示残影/乱码:初期调试时,屏幕经常出现奇怪的字符或残影。原因是
OLED_GRAM显存数组在初始化时没有全部清零,或者OLED_Refresh()函数在传输数据时,起始地址和传输长度设置错误,没有覆盖整个屏幕区域。解决方法是确保清屏函数被正确调用,并仔细核对I2C传输的从机地址、控制字节和数据序列。 - 蜂鸣器音乐卡顿或跑调:如果音乐播放不流畅,首先检查TIM2中断(系统心跳)的优先级是否被其他更高优先级的中断频繁打断。其次,检查音乐数据数组的索引和计时逻辑是否正确,确保在切换音符时,PWM频率的设置和关闭/开启操作是原子的,不会被中断。另外,计算音调频率对应的ARR值时,注意定时器时钟分频后的计数频率,公式为:
ARR = (TimClock / (PWM_Freq * Prescaler)) - 1。计算错误会导致音调不准。 - 按键连击或失灵:矩阵键盘的消抖算法很关键。单纯的延时消抖在中断里不合适。我采用的方法是:在扫描到按键按下后,记录该键值,并启动一个“确认计数器”。在接下来的几次扫描中(比如连续3次,每次间隔10ms),如果都检测到同一个键被按下,才确认为一次有效按键。释放判断同理。这样可以有效滤除抖动。
- 游戏逻辑“卡死”:检查主循环和中断中的while循环。确保没有任何地方存在“死等”某个条件的情况。所有等待都应使用超时机制,或者通过状态标志在循环中判断。例如,等待I2C事件标志位时,应该增加一个超时计数器,超过一定时间后跳出循环并报告错误,而不是无限等待。
- PCB布局的教训:第一版PCB打样回来,蜂鸣器声音特别小。排查后发现,驱动三极管基极的限流电阻我用成了10kΩ,导致基极电流太小,三极管没有完全饱和导通,蜂鸣器上的电压不足。换成1kΩ后问题解决。另外,电源走线要尽量粗,特别是给蜂鸣器供电的5V线路,避免大电流时产生压降。
6. 项目总结与扩展思考
完成这个打地鼠掌机项目,是一次非常扎实的STM32全栈开发体验。它强迫你从宏观的系统架构思考到微观的寄存器配置,从硬件的电流电压计算到软件的状态机设计,每一个环节都紧密相连。
这个项目本身还有很大的扩展空间:
- 增加更多游戏:同样的硬件平台,可以很容易地移植“贪吃蛇”、“俄罗斯方块”等经典游戏。只需要修改游戏逻辑和显示界面即可。
- 加入振动马达:在击中地鼠时,让掌机产生短促振动,体验更佳。只需要一个GPIO口驱动一个微型振动马达即可。
- 实现分数存储:利用STM32内部的Flash模拟EEPROM,存储历史最高分,即使断电也不会丢失。
- 无线对战:如果增加一个蓝牙模块(如HC-05),就可以实现两台掌机之间的联机对战,看谁打地鼠更快。
- 外壳与美化:使用3D打印为掌机制作一个漂亮的外壳,并设计个性化的贴纸,让它从一块开发板变成一个真正的消费级产品。
回过头看,嵌入式开发的乐趣就在于这种从无到有的创造过程,以及解决一个个具体问题后带来的成就感。这个项目用到的GPIO、中断、定时器、PWM、I2C通信、状态机等知识,是嵌入式开发的通用基础。掌握了它们,你就具备了去挑战更复杂项目(如智能小车、四轴飞行器、物联网设备)的底气。希望这份详细的分享,能为你点亮自己动手制作的道路。
本文还有配套的精品资源,点击获取