简介:这是一套面向嵌入式初学者与毕业设计学生的STM32实战项目资源,聚焦趣味性与工程性结合的打地鼠掌机开发,覆盖硬件驱动、人机交互与音视频反馈全流程。资源包含完整软硬件资料:42个头文件(.h)定义外设寄存器与模块接口,36个C源文件(.c)实现OLED显示驱动、TIM定时控制、GPIO按键扫描、蜂鸣器PWM音乐播放及游戏逻辑调度;另有原理图(.schdoc)、PCB设计(.pcbdoc)、Keil工程(.uvprojx)、烧录固件(.hex)及说明文档等共103个文件,总大小4.61MB。已有225人学习下载,适用于课程设计、毕设选题、电子实训及大创项目立项,既可一键复现运行,也支持基于现有架构扩展多级难度、计分系统或蓝牙联机功能。所有代码经实测验证,结构清晰、注释完整,并附带bat一键清理脚本与调试配置文件,显著降低移植与排错门槛。
1. 项目概述与核心价值
最近翻看硬盘,找到了一个几年前做的很有意思的小玩意儿——一个基于STM32的掌上打地鼠游戏机。这可不是一个简单的软件模拟,而是包含了完整的硬件设计、PCB打样、嵌入式软件编程,甚至还有OLED屏幕显示和蜂鸣器播放背景音乐。整个项目从画原理图、焊板子到调代码,都是自己一手搞定的,现在回想起来,里面踩过的坑和积累的经验,对于想入门STM32嵌入式开发,或者想做个完整小项目的朋友来说,应该挺有参考价值的。这个项目麻雀虽小,五脏俱全,它本质上是一个典型的微控制器综合应用案例,涵盖了GPIO输入输出、定时器中断、SPI/I2C通信、PWM驱动以及简单的状态机游戏逻辑设计。如果你正想找一个能串联起STM32多个外设的实战项目,或者单纯想复刻一个怀旧小游戏来练手,那这个“打地鼠掌机”会是个非常不错的选择。
2. 硬件系统设计与核心器件选型
硬件是整个项目的骨架,设计得好不好直接决定了后续软件开发的难易度和最终产品的稳定性。我的设计思路是:以一颗STM32F103C8T6(俗称“蓝桥杯”或“最小系统板”核心芯片)作为主控,负责处理所有游戏逻辑、驱动显示和发声;用一个0.96寸的OLED显示屏(SSD1306驱动)作为游戏界面和状态显示;用几个轻触按键和LED灯模拟“地鼠”和“锤子”;最后用一个无源蜂鸣器来播放简单的游戏音效和背景音乐。
2.1 主控MCU:为什么是STM32F103C8T6?
在众多STM32型号里选择F103C8T6,是基于成本、性能、生态和项目需求的综合考量。首先,打地鼠游戏对算力要求不高,不需要跑复杂的操作系统或图形界面,Cortex-M3内核的F103系列完全够用。其次,C8T6拥有64KB Flash和20KB RAM,对于我这个游戏的代码量和变量存储绰绰有余。最关键的是它的外设资源:我需要多个定时器(用于游戏计时、蜂鸣器PWM)、SPI或I2C接口(驱动OLED)、以及足够多的GPIO(连接按键、LED和蜂鸣器)。F103C8T6正好满足这些需求,而且价格极其亲民,开发板和资料满天飞,社区支持强大,对于学习和原型开发非常友好。
注意:STM32F1系列现在有国产替代型号(如GD32),引脚和库函数基本兼容,成本可能更低,但在时钟配置和某些外设细节上可能有微小差异,移植时需要注意。
2.2 显示模块:0.96寸OLED (SSD1306) 的驱动选择
显示部分我选择了0.96寸、128x64分辨率的OLED屏,驱动芯片是SSD1306。选择它有几个原因:一是自发光,对比度高,在掌机这种小设备上显示效果非常清晰锐利,比LCD屏更省电且视角好;二是接口简单,支持I2C和SPI两种方式。为了节省IO口,我选择了I2C接口,只需要SCL和SDA两根线,再加上电源和地,一共四根线就能驱动,极大简化了硬件连接和PCB布线。
在实际焊接和调试中,有一个细节很容易被忽略:OLED模块的I2C地址。大部分模块的默认地址是0x78(写地址)或0x7A,但有些厂家可能会设置为0x7A或通过电阻配置为其他地址。如果你的屏幕初始化后死活不亮,除了检查接线和电源,第一件事就是用逻辑分析仪或者写个简单的I2C扫描程序确认一下从机地址是否正确。
2.3 输入与“地鼠”:按键与LED的电路设计
游戏的核心交互是“打地鼠”,我用9个轻触按键来代表9个“地鼠洞”,同时每个洞上方配一个LED灯。当游戏随机让某个“地鼠”出现时,对应的LED灯会点亮,玩家需要迅速按下该洞对应的按键,视为“打中”。
这里的关键是GPIO的配置。对于LED,配置为推挽输出即可。对于按键,我采用了最常用的“上拉输入”模式。STM32的GPIO内部可以配置上拉电阻,这样按键一端接地,另一端接GPIO口。当按键未按下时,GPIO口被内部上拉到高电平;按下时,电平被拉低到地。这种设计省去了外部上拉电阻,简化了电路。
但是,机械按键存在“抖动”问题,按下和松开瞬间会产生一系列毛刺信号。如果直接读取电平,可能会误判为多次按下。我的解决方案是在软件中做“消抖”。不是用简单的延时,而是在定时器中断里(比如每10ms一次)对每个按键的当前状态进行采样,并记录历史状态。只有当连续几次采样都检测到稳定的按下或松开状态时,才认为是一次有效的按键事件。这种方法更可靠,且不阻塞主程序运行。
2.4 声音输出:无源蜂鸣器与PWM驱动
为了增加游戏的趣味性,我加入了一个无源蜂鸣器。无源蜂鸣器内部没有振荡源,需要外部输入一定频率的方波信号才能发声,改变频率就能改变音调。这正是STM32定时器PWM功能的用武之地。
我使用了一个通用定时器(如TIM3)的一个通道,配置为PWM输出模式,连接到蜂鸣器的信号引脚。通过改变定时器的“自动重装载值”(ARR)和“预分频器”(PSC)来设定PWM的频率(即音调),通过改变“捕获/比较寄存器”(CCR)的值来设定占空比(影响音量,但通常设为50%即可获得最大响度)。
播放一段音乐,其实就是按照乐谱,在不同时间点输出对应的频率。我事先将一些简单的旋律(比如游戏开始音效、击中音效、背景音乐)的音符频率和节拍时长做成两个数组,存储在代码中。游戏运行时,在一个定时器中断里按照节拍切换PWM的频率,就能实现音乐的播放。这是一个非常经典的“软件音效发生器”实现方式。
3. 软件架构与核心逻辑实现
软件部分是整个项目的灵魂,需要将分散的硬件模块有机地组合起来,实现一个流畅、有趣的游戏。我的软件架构基于“超级循环”(Super Loop)和“中断驱动”的混合模式,这是裸机嵌入式开发中最常见且高效的模式。
3.1 系统初始化与外设配置
一切始于main函数里的初始化。这里的顺序有讲究,我一般是:系统时钟配置 -> GPIO初始化 -> 外设(定时器、I2C)初始化 -> OLED初始化并显示开机画面 -> 初始化游戏状态变量 -> 开启中断 -> 进入主循环。
int main(void) { // 1. 系统初始化 HAL_Init(); SystemClock_Config(); // 2. 外设初始化 MX_GPIO_Init(); MX_I2C1_Init(); MX_TIM3_Init(); // 用于蜂鸣器PWM MX_TIM4_Init(); // 用于游戏逻辑定时 // 3. 模块初始化 OLED_Init(); Buzzer_Init(); Game_Init(); // 4. 开启定时器中断 HAL_TIM_Base_Start_IT(&htim4); // 游戏主循环定时器 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 启动蜂鸣器PWM,但先静音 // 5. 进入超级循环 while (1) { Game_MainTask(); // 游戏主状态机 OLED_RefreshTask(); // OLED显示刷新(非持续刷新,有变化才更新) Key_ScanTask(); // 按键扫描(在定时中断中标记,在主循环中处理) } }3.2 游戏核心状态机设计
打地鼠游戏的核心逻辑可以用一个有限状态机(FSM)来清晰描述。我设计了以下几个状态:
- MENU状态:显示游戏菜单(开始游戏、最高分等)。等待玩家按键选择。
- READY状态:游戏开始前的准备,倒计时“3, 2, 1, Go!”。
- PLAYING状态:游戏进行中。这是最复杂的状态。
- 定时(如每1秒)随机选择一个“地鼠洞”(点亮一个LED)。
- 启动一个“地鼠存活计时器”(比如0.8秒),如果玩家在规定时间内按下正确按键,则加分,播放击中音效;如果超时未击中,地鼠消失,无分。
- 游戏总时长(如60秒)由一个倒计时器控制。
- SCORE状态:游戏结束,显示本次得分,并与历史最高分比较、更新。等待玩家按键返回菜单。
这个状态机在Game_MainTask()函数中实现,通过一个gameState全局变量来记录当前状态,并根据状态执行不同的代码块和状态转移。
3.3 随机“地鼠”生成算法
如何让地鼠的出现看起来是随机的?我使用了STM32的硬件随机数发生器(RNG),但F103C8T6没有这个外设。因此,我采用了一个经典的软件伪随机数生成方法——线性同余发生器(LCG)。在系统初始化时,用一个变化的值(如定时器计数器的值)作为“种子”。每次需要生成随机地鼠位置时,就调用一次随机数函数,将结果对9取模,得到0-8的数字,对应9个地鼠洞。
// 一个简单的伪随机数生成器 static uint32_t random_seed = 12345; uint32_t game_rand(void) { random_seed = random_seed * 1103515245 + 12345; return (random_seed / 65536) % 32768; } // 获取一个0-8的随机位置 uint8_t get_random_hole() { return game_rand() % 9; }实操心得:这个随机数算法对于游戏来说足够用了,但如果你发现游戏模式有规律可循,可以尝试在每次游戏开始时,用ADC读取一个悬空引脚的电平(噪声)作为更随机的种子,这样随机性会好很多。
3.4 OLED显示界面的分层设计
OLED显示内容分为几个层,避免全屏频繁刷新导致闪烁:
- 静态层:如游戏边框、标题文字。只在进入相应状态时绘制一次。
- 动态层:如分数、倒计时、地鼠位置(LED点亮对应在屏幕上的图标)。只有这些部分变化时,才局部更新对应的屏幕区域。
- 菜单层:独立的界面,切换时清屏重绘。
我移植了一个轻量级的OLED驱动库,提供了画点、画线、显示字符串和数字的基本函数。在游戏逻辑中,我封装了DrawScore()、DrawTimer()、DrawMole()等函数,这些函数内部会判断内容是否真的发生了变化,只有变化了才去调用底层的OLED驱动函数更新显存,最后统一调用OLED_Refresh()将显存刷新到屏幕上。这种“脏矩形”或“差异更新”的思想能极大提高显示效率。
4. 关键功能模块的深度实现与调试
4.1 蜂鸣器音乐播放器的实现
让蜂鸣器唱歌是项目里最有成就感的部分之一。原理很简单:每个音符对应一个频率。我定义了两个数组,一个是音符频率表,另一个是乐谱数组,乐谱里存储了音符索引和节拍时长。
// 定义中音C、D、E、F、G、A、B的频率(Hz) const uint16_t tone_freq[] = {262, 294, 330, 349, 392, 440, 494, 523}; // 示例乐谱:《小星星》前几个音,{音符索引, 节拍时长} const uint8_t melody[][2] = {{0,4}, {0,4}, {4,4}, {4,4}, {5,4}, {5,4}, {4,8}, ...};播放音乐需要一个定时器来驱动节拍。我使用了另一个基本定时器(如TIM2),设置中断时间为一个“节拍”的基本单位(如125ms)。在中断服务函数里,维护一个乐谱索引和节拍计数器。每进入一次中断,节拍计数器减一。当计数器减到0时,就切换到乐谱的下一个音符,根据音符索引从tone_freq表中取出频率,然后通过函数去设置TIM3的ARR和PSC值,从而改变PWM输出频率,蜂鸣器就发出了新的音调。同时,重置节拍计数器为新的时长。
注意事项:改变定时器频率时,如果操作不当(比如在PWM输出过程中直接修改ARR),可能会导致输出毛刺或短暂静音。我的做法是,在改变频率前,先暂时关闭PWM输出(将CCR设为0),修改ARR和PSC后,再恢复CCR的值。这样切换更平滑。
4.2 多任务协同与中断管理
在这个裸机系统中,有多个需要“同时”执行的任务:游戏逻辑更新、按键扫描、显示刷新、音乐播放。我是这样协调它们的:
- 游戏逻辑定时器:TIM4设置为10ms中断一次。在这个中断里,我主要做两件事:
- 按键消抖采样:读取9个按键的当前电平,并更新它们的“历史状态寄存器”。
- 更新游戏和音乐的节拍计数器:这些计数器是
volatile类型的全局变量,在主循环中会检查它们。
- 主循环任务:
Key_ScanTask():检查由中断更新的按键历史状态,识别出“按下”和“释放”事件,并设置相应的按键事件标志。Game_MainTask():检查游戏状态和各类计数器(地鼠存活倒计时、游戏总倒计时、音乐节拍计数器),驱动状态机运转,并处理按键事件标志。OLED_RefreshTask():检查显示内容是否有“脏”标记,有则刷新局部区域。
这种设计确保了即使游戏逻辑复杂,按键响应也能保持灵敏,因为按键采样是在精确的定时中断中完成的,不受主循环执行时间的影响。
4.3 低功耗与电源管理考虑
虽然作为掌机,我主要使用USB或电池供电,没有刻意追求极低功耗,但一些好习惯能让设备更稳定。例如,在初始化后,将不用的GPIO口设置为模拟输入模式,这是STM32上功耗最低的状态。在游戏菜单界面长时间无操作时,可以自动降低OLED屏幕的亮度(通过调整SSD1306的对比度命令),甚至进入睡眠模式,等待按键中断唤醒。对于电池供电版本,这些措施能显著延长游玩时间。
5. 硬件焊接、组装与调试实录
5.1 PCB设计与焊接要点
我当时用的是立创EDA画的板子,双面板,把所有元件(MCU、OLED接口、按键、LED、蜂鸣器、电源接口)都集成在了一块板子上,尺寸正好握在手里。焊接顺序很重要:先焊贴片元件(STM32芯片、电阻电容),再焊插接件(排针、按键、蜂鸣器)。焊接STM32时,一定要先对齐,固定对角线的两个引脚,检查无误后再焊接其他脚。可以使用焊锡丝和烙铁,配合助焊剂,会轻松很多。
焊接完成后,不要急着上电。先用万用表蜂鸣档,仔细检查电源和地之间是否短路,这是最基本的,也是防止烧芯片的关键一步。然后检查各个关键网络,比如OLED的I2C线、蜂鸣器信号线是否连通。
5.2 上电调试与“三板斧”
第一板上电,心情总是忐忑的。我的调试“三板斧”是:
- 电源与芯片:上电后,首先摸一下主控芯片是否异常发烫。然后用万用表测量芯片供电引脚(VDD)是否为3.3V。如果正常,接着用示波器或逻辑分析仪检查芯片的晶振是否起振(OSC_IN/OUT引脚是否有正弦波或方波)。这是芯片工作的第一步。
- 调试接口:连接ST-Link调试器,尝试用IDE(如Keil或STM32CubeIDE)连接并读取芯片的IDCODE。如果能成功连接,说明芯片内核和调试模块基本正常,可以开始下载程序了。
- GPIO测试:写一个最简单的测试程序,让一个连接LED的GPIO口以1秒间隔翻转。下载运行,观察LED是否闪烁。这是验证程序下载、系统时钟、GPIO配置是否正常的最直观方法。
5.3 外设模块的逐一调试
当芯片能跑简单程序后,就开始逐个攻破外设。
- OLED调试:首先确保I2C总线物理连接正确(SCL和SDA有没有接反?上拉电阻是否焊上?)。然后写一个I2C扫描程序,看能否扫描到OLED的地址(0x78或0x7A)。如果扫不到,检查电源和接线。扫到地址后,发送初始化命令序列,然后尝试在一个固定位置画一个点或显示一个字符。如果屏幕上有任何反应,哪怕是一个乱点,都说明通信成功了,剩下的就是驱动函数的问题。
- 蜂鸣器调试:先不播放音乐,写一段代码,固定输出一个频率(比如1kHz)的PWM。用示波器探头测量蜂鸣器信号引脚,看是否有规整的方波。如果没有,检查定时器配置和GPIO复用功能是否正确。有方波但蜂鸣器不响?检查蜂鸣器是有源还是无源?我用的无源蜂鸣器,需要一定驱动电流,确认STM32的GPIO输出电流是否足够(可以尝试降低PWM频率到几百Hz试试,有些蜂鸣器对低频更敏感)。
- 按键与LED矩阵调试:将所有LED设置为流水灯模式,测试每个LED是否能被单独控制。对于按键,编写一个程序,在按下某个键时,点亮或熄灭对应的LED。通过这个简单的交互测试,可以验证按键电路和扫描逻辑是否正确。
6. 开发中遇到的典型问题与解决方案
6.1 OLED显示花屏、乱码或完全不亮
这是最常见的问题,可能的原因和排查步骤:
- 电源问题:0.96寸OLED模块通常需要3.3V供电。用万用表测量模块的VCC引脚电压是否稳定在3.3V。电流是否足够?可以尝试单独给OLED模块供电测试。
- I2C通信问题:占90%以上的原因。首先确认硬件:SCL和SDA线是否接对?是否接了上拉电阻(通常4.7K或10K)到3.3V?可以用示波器抓一下I2C总线的波形,看起始信号、地址和数据信号是否正常。软件上,检查I2C初始化时钟频率是否过高(对于这种小OLED,100kHz或400kHz都可以,初次调试建议用100kHz),检查发送的从机地址是否正确(7位地址左移一位后,写操作通常是0x78,读操作是0x79)。
- 初始化序列错误:SSD1306有一长串初始化命令,顺序或参数错误可能导致显示异常。确保完全按照数据手册或已验证的驱动代码中的命令序列发送。特别注意发送命令(0x00)和发送数据(0x40)的控制字节别搞混。
- 显存更新逻辑错误:你的程序可能正确更新了显存数组,但忘记调用
OLED_Refresh()或类似的函数将显存内容发送到屏幕。或者刷新区域设置错误,导致只更新了部分屏幕。
6.2 蜂鸣器声音小、破音或不响
- 驱动能力不足:STM32的GPIO引脚输出电流有限(通常几mA到20mA)。无源蜂鸣器需要一定的电流驱动才能发出足够响的声音。解决方案是增加一个简单的三极管(如S8050)驱动电路。GPIO引脚通过一个限流电阻连接到三极管基极,蜂鸣器接在三极管的集电极回路中,由VCC供电。这样GPIO只提供控制信号,大电流由VCC通过三极管提供,声音会洪亮很多。
- PWM频率不对:无源蜂鸣器有其谐振频率范围,通常在2kHz-5kHz之间声音最响亮。频率太低(如几百Hz)声音沉闷且可能不响;频率太高(如10kHz以上)可能超出人耳听觉范围或蜂鸣器响应范围。用示波器确认实际输出的PWM频率。
- 占空比问题:占空比太小(如10%)声音会很小;占空比50%时音量最大。但注意,有些蜂鸣器长期在50%占空比下大音量工作可能会发热。可以尝试调整在40%-50%之间找到最佳点。
- 乐曲数据错误:乐谱数组中的频率值或节拍值有误,导致生成的频率超出蜂鸣器有效范围或切换太快/太慢。可以先用一个固定的、已知正确的频率(如1kHz)测试蜂鸣器硬件是否正常,再调试音乐播放逻辑。
6.3 按键响应不灵或连击
- 消抖算法不佳:如果消抖时间设置太短(如小于5ms),可能无法滤除抖动;如果太长(如大于50ms),会影响按键响应速度。我的经验是10-20ms的消抖时间比较合适。确保你的消抖逻辑是在定时中断中稳定执行的。
- GPIO配置错误:确认按键GPIO配置为上拉输入模式。如果错误配置为浮空输入,在按键未按下时引脚电平是不确定的,极易受干扰。
- 硬件问题:按键本身质量差,或焊接有虚焊,导致接触电阻不稳定。可以用万用表电阻档,在按下和松开按键时测量引脚间电阻,应该是0欧姆和无穷大(或上拉电阻值)。如果阻值跳动或不为0,更换按键。
- 中断与主循环冲突:如果你是在主循环中轮询按键并做消抖,要注意主循环其他任务不能有长时间阻塞(如不当的延时)。否则会错过按键状态的变化。这就是为什么我把采样放在定时中断里,把事件处理放在主循环里的原因。
6.4 游戏运行卡顿或计时不准
- 系统时钟配置错误:这是根本原因。STM32的时钟树比较复杂,如果HCLK(系统主时钟)配置不对,所有基于时钟的定时器、延时都会出问题。务必使用STM32CubeMX工具生成时钟配置代码,并核对
SystemClock_Config()函数中,HCLK是否是你期望的频率(如72MHz)。可以在代码中翻转一个GPIO,用示波器测量其周期来间接验证系统时钟频率。 - 定时器中断过于频繁:如果游戏逻辑定时器中断(如我的10ms中断)服务函数执行时间过长,或者内部有调用耗时的函数(如
printf),可能导致中断嵌套或丢失,影响系统整体响应。中断服务函数一定要短小精悍,只做最必要的标志设置或计数器更新,复杂的处理放到主循环中。 - 主循环中有阻塞操作:避免在
while(1)主循环中使用HAL_Delay()这样的忙等待延时函数。这会让整个程序停在那里。所有需要计时的操作,都应该基于一个全局的定时器Tick(由定时器中断更新)来判断是否超时。 - 随机数算法开销大:如果随机数生成函数非常复杂,且被频繁调用(比如每生成一个地鼠调用一次),可能会消耗可观的时间。确保你的随机数函数足够轻量。前面提到的LCG算法就非常快。
这个基于STM32的打地鼠掌机项目,虽然功能看起来简单,但真正从零到一实现下来,几乎把STM32的常用外设都摸了一遍。它更像是一个微型的“练兵场”,把GPIO、中断、定时器、PWM、I2C这些知识点从手册上的文字,变成了手里一个会响会亮、可以交互的实物。调试过程中,示波器和逻辑分析仪是你的眼睛,万用表是你的触手,而耐心和逻辑思维则是你最强的大脑。当最后游戏流畅运行,音效响起的那一刻,所有的折腾都值了。如果你也准备动手,我的建议是:分模块击破,调通一个再搞下一个;勤用调试工具,靠猜是解决不了问题的;代码版本管理要做好,调乱了能快速回退。希望这份详细的复盘,能帮你少走些弯路。
本文还有配套的精品资源,点击获取