1. 项目概述:在RT-Spark开发板上实现一个“完美时机”游戏
最近在玩一块基于STM32的RT-Spark开发板,心血来潮想在上面复刻一个经典的游戏——“完美时机”。这个游戏的核心玩法很简单:屏幕上会有一个快速移动的指示器或目标,玩家需要在最精确的时刻按下按键,系统会根据你按键的时机与目标完美时刻的偏差来打分,偏差越小,分数越高。听起来简单,但要做到“完美”却非常考验手眼协调和节奏感。这不仅仅是一个小游戏,更是一个绝佳的嵌入式系统综合实践项目,它巧妙地串联起了STM32的GPIO输入、定时器精准计时、中断响应以及可能的I2C外设驱动(比如用来驱动OLED屏幕显示分数和动画)等多个核心知识点。对于想要深入理解实时系统、人机交互和微控制器外设协同工作的开发者来说,自己动手实现一遍,比看十遍手册都管用。
RT-Spark开发板通常搭载了像STM32F103这类经典的Cortex-M3内核芯片,资源丰富,社区支持完善,非常适合进行这类综合性实验。在这个项目中,我们将完全从零开始,不依赖复杂的游戏引擎,仅用HAL库或标准外设库,一步步构建整个游戏逻辑。你会接触到如何配置GPIO为输入模式并启用中断来捕获按键动作,如何利用STM32强大的定时器实现微秒级甚至更高精度的时间戳记录,以及如何管理游戏状态(如等待、进行中、评分)。如果板载了OLED屏幕,我们还会涉及到通过软件或硬件I2C来驱动显示,实时渲染游戏界面。整个过程就像在解一道有趣的应用题,每一个技术点都是解题所需的公式,最终整合成一个可以实际运行、充满成就感的作品。
2. 硬件平台与核心外设选型解析
2.1 RT-Spark开发板与STM32F103资源盘点
我手头这块RT-Spark开发板,主控芯片是STM32F103C8T6,也就是大家常说的“蓝色药丸”核心型号。它拥有72MHz的主频、64KB Flash、20KB RAM,对于我们这个游戏项目来说性能绰绰有余。板载资源通常包括用户按键、LED指示灯,以及一个通过I2C接口连接的0.96寸OLED显示屏,这为我们提供了完美的输入和输出设备。游戏的核心输入就是一个按键,我们将其连接到某个具有外部中断功能的GPIO引脚上,例如PA0。输出则分为两部分:一是通过OLED屏幕显示移动的目标、倒计时和最终分数,提供视觉反馈;二是可以通过板载LED(例如PC13)的闪烁或PWM调光,来提供额外的状态提示或光效,增强游戏体验。
选择STM32F103系列除了其性价比高,更因为它的外设功能非常典型和全面。我们需要用到的几个核心外设包括:通用输入输出接口(GPIO)用于按键检测、高级控制定时器(TIM1)或通用定时器(TIM2/3/4)用于生成精确的时间基准和测量时间间隔、I2C接口用于驱动OLED屏幕。这些外设在STM32全系列中概念相通,掌握了它们,再迁移到其他型号或其他品牌的ARM Cortex-M芯片上也会非常容易。
2.2 关键外设的工作原理与项目中的角色
GPIO与外部中断:这是玩家与游戏交互的桥梁。GPIO可以配置为上拉输入模式,当按键按下时,引脚电平被拉低。如果仅仅采用轮询方式检测按键状态,会大量消耗CPU资源且无法精确捕获按下瞬间。因此,我们必须将其配置为外部中断模式。当按键按下产生下降沿(或按下/释放产生双边沿)时,会触发中断服务函数。在这个中断里,我们不能做复杂操作(比如刷新屏幕),而是应该立刻记录一个时间戳。这个时间戳的精度直接决定了游戏评分的公平性。
定时器:它是本项目的“心脏”,负责提供高精度的时间度量。我们有多种使用方式:
- 系统滴答定时器(SysTick):通常用于操作系统任务调度,其精度为1ms(如果系统时钟为72MHz,重装载值为72000-1)。对于需要毫秒级精度的游戏节奏控制(比如目标移动速度)是足够的。
- 通用定时器:如TIM2、TIM3、TIM4。我们可以将其配置为自由运行模式,时钟源选择内部时钟,预分频器设置到最小,让计数器尽可能快地累加。例如,系统时钟72MHz,预分频器设为71,则计数器时钟为1MHz,即每微秒计数一次。在按键中断中读取该定时器的计数器值(CNT),即可获得一个微秒级精度的时间戳。通过比较两次中断的时间戳差值,就能得到精确的按键间隔或与目标时间的偏差。
- 输入捕获功能:这其实是更专业的做法。可以将定时器的一个通道配置为输入捕获模式,直接捕获按键引脚的电平变化边沿,并自动记录计数器值。这避免了外部中断与定时器读取之间的微小延迟,精度最高。但对于初学者,先从“外部中断+读取定时器CNT”的方式入手更易于理解。
I2C与OLED驱动:OLED屏幕用于提供丰富的视觉反馈。STM32的硬件I2C配置相对繁琐,特别是中断和DMA方式。在资源允许的情况下,为了代码的简单和可调试性,我强烈建议在项目初期使用软件模拟I2C。即用两个普通的GPIO引脚(例如PB6, PB7)模拟SCL和SDA的时序。这样避免了硬件I2C复杂的配置和潜在的“卡死”问题。市面上有大量成熟的OLED驱动库(如SSD1306),我们只需要提供底层的I2C_Start,I2C_Stop,I2C_SendByte等函数即可轻松驱动。
注意:GPIO模式选择:用于按键的GPIO必须配置为上拉输入(Input Pull-up)或下拉输入,并启用对应的外部中断线。如果配置为浮空输入,在按键未按下时引脚电平不确定,极易误触发中断。通常板子外部有上拉电阻,配置为上拉输入是稳妥的选择。
3. 游戏系统架构与状态机设计
3.1 游戏逻辑分解与状态定义
一个完整的“完美时机”游戏流程可以分解为几个清晰的状态。使用有限状态机来管理这些状态是嵌入式开发中非常有效的方法,它能让程序逻辑清晰,避免复杂的if-else嵌套。我们可以定义以下几个核心状态:
- READY(准备):游戏初始状态或一轮结束后的状态。屏幕上显示“Press Key to Start”之类的提示。在此状态下,按下按键将触发状态转移。
- COUNTDOWN(倒计时):游戏开始后,先进行一个短暂的倒计时(如3、2、1),让玩家准备。倒计时期间,目标可能尚未开始移动,或者以固定位置显示。这个状态通常由定时器中断驱动,每秒更新一次显示。
- PLAYING(进行中):这是游戏的核心状态。一个视觉目标(如一个移动的光标、一个收缩的圆圈或一个滚动的进度条)开始按照预设的模式运动。同时,一个“目标时间点”被秘密设定(可以是固定的,也可以是随机的)。玩家需要预估并在自认为最接近目标时间点的时刻按下按键。
- JUDGING(判定中):玩家按键后,立即进入此状态。系统计算按键时间戳与目标时间戳的绝对差值。在此状态,可以播放一个简短的动画(如目标闪烁、LED快速闪烁),增加戏剧效果。
- RESULT(结果显示):根据差值计算出分数(例如,差值越小分数越高,超过某个阈值则失败),并将分数和评价(如“Perfect!”,“Good”,“Miss”)显示在OLED屏幕上。停留几秒后,自动跳转回READY状态。
使用一个全局变量game_state来记录当前状态,在主循环或定时器中断中根据这个变量执行不同的函数。状态之间的转移由事件触发,如按键事件、定时器超时事件。
3.2 时间管理:心跳、动画与精准测时
游戏需要多个不同周期的时间基准:
- 系统心跳:可以使用SysTick,每1ms产生一次中断,用于更新一个全局的毫秒计时器
uwTick(HAL库已提供)。这个计时器用于控制非高精度要求的时序,如状态停留时间、倒计时。 - 目标移动与动画刷新:为了让目标移动平滑,我们需要一个更高的刷新率,比如60Hz(约16.7ms一帧)。可以配置一个通用定时器(如TIM4),使其每16.7ms产生一次更新中断。在这个中断服务程序中,根据当前游戏状态和经过的时间,计算目标的新位置,并调用OLED刷新函数。这里有个关键点:中断服务函数必须尽可能短小。不要在中断里进行复杂的屏幕绘制操作。正确的做法是,在中断里仅设置一个标志位,如
refresh_screen_flag = 1,然后在主循环中检测到这个标志位,再执行实际的屏幕刷新逻辑。 - 精准时间戳获取:这是游戏的精髓。我们需要一个尽可能跑得快的定时器来作为“秒表”。如前所述,配置一个定时器(如TIM2)为自由运行模式,时钟源为72MHz,预分频器设为71,则计数器每1微秒加1。当玩家按下按键触发外部中断时,我们在中断函数中立刻读取
TIM2->CNT的值,存入变量key_press_timestamp。同时,我们需要在目标时间点到来时(比如在PLAYING状态下的某个特定时刻),也记录一个target_timestamp。两者的差值delta = abs(key_press_timestamp - target_timestamp),就是我们的测量结果,单位是微秒。
实操心得:中断优先级与冲突管理:当你有多个中断(SysTick, 定时器中断, 外部中断)时,必须合理设置它们的优先级。通常,用于捕获按键的外部中断优先级应设为最高(数值最小),以确保时间戳记录的即时性。而用于屏幕刷新的定时器中断优先级可以设低一些。同时,要避免在中断函数和主循环中同时访问共享变量(如
game_state,key_press_timestamp)。对于简单的uint32_t类型变量,在32位ARM架构上通常是原子操作的,但为了代码清晰和可移植性,可以在访问前后暂时关闭全局中断(__disable_irq()和__enable_irq()),或者使用C11的原子操作(如果编译器支持)。
4. 核心模块实现与代码剖析
4.1 GPIO与外部中断配置
我们以按键连接PA0,采用下降沿触发为例,使用STM32CubeMX或直接寄存器配置。
首先,在初始化代码中配置PA0为上拉输入模式,并开启外部中断:
// GPIO初始化片段 GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿中断 GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 配置NVIC(嵌套向量中断控制器) HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0); // 设置高优先级 HAL_NVIC_EnableIRQ(EXTI0_IRQn);接着,实现中断服务函数。切记,函数内操作要快!
// 在stm32f1xx_it.c中 extern volatile uint32_t key_pressed_timestamp; // 声明为全局变量 extern volatile uint8_t key_pressed_flag; // 按键事件标志 void EXTI0_IRQHandler(void) { if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); // 清除中断标志 // 只有在PLAYING状态下的按键才用于计时 if(game_state == STATE_PLAYING) { key_pressed_timestamp = TIM2->CNT; // 读取高精度定时器值 key_pressed_flag = 1; // 设置标志,主循环处理 game_state = STATE_JUDGING; // 立即切换状态 } // 在READY状态下的按键用于开始游戏 else if(game_state == STATE_READY) { game_state = STATE_COUNTDOWN; countdown_value = 3; // 开始3秒倒计时 } } }这里,我们将复杂的评分逻辑放到主循环中,中断里只做最必要的记录和状态切换。
4.2 高精度定时器TIM2配置与时间戳获取
配置TIM2为1MHz计数频率,即1微秒计数一次。
TIM_HandleTypeDef htim2; void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 72MHz / (71+1) = 1MHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFFFFFF; // 最大计数值,让其自由运行 htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; if (HAL_TIM_Base_Init(&htim2) != HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; if (HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig) != HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig) != HAL_OK) { Error_Handler(); } HAL_TIM_Base_Start(&htim2); // 启动定时器 }之后,在任何需要获取高精度时间的地方,直接读取TIM2->CNT即可。计算时间差时要注意计数器溢出问题。由于我们设置了最大周期,溢出周期约为4294秒(2^32微秒),远大于一局游戏时间,所以在本项目中可以忽略。但在更通用的代码中,应该使用__HAL_TIM_GET_COUNTER(&htim2)宏来读取,并处理溢出。
4.3 软件I2C驱动OLED显示
假设我们使用PB6模拟SCL,PB7模拟SDA。
// 软件I2C引脚定义 #define OLED_I2C_SCL_PIN GPIO_PIN_6 #define OLED_I2C_SCL_PORT GPIOB #define OLED_I2C_SDA_PIN GPIO_PIN_7 #define OLED_I2C_SDA_PORT GPIOB // 简单的延时函数,用于产生I2C时序 void I2C_Delay(void) { for(uint16_t i=0; i<10; i++); // 具体循环次数需根据主频调整 } // 起始信号 void I2C_Start(void) { OLED_SDA_HIGH(); OLED_SCL_HIGH(); I2C_Delay(); OLED_SDA_LOW(); I2C_Delay(); OLED_SCL_LOW(); } // 发送一个字节 void I2C_SendByte(uint8_t byte) { uint8_t i; for(i=0; i<8; i++) { if(byte & 0x80) OLED_SDA_HIGH(); else OLED_SDA_LOW(); I2C_Delay(); OLED_SCL_HIGH(); I2C_Delay(); OLED_SCL_LOW(); byte <<= 1; } // 发送应答位(这里我们不检测从机应答,简单置高SDA) OLED_SDA_HIGH(); I2C_Delay(); OLED_SCL_HIGH(); I2C_Delay(); OLED_SCL_LOW(); }有了这些底层函数,就可以集成一个现成的SSD1306驱动库,调用其初始化、清屏、画点、显示字符串等函数,来绘制游戏界面。
4.4 游戏主循环与状态机实现
主循环是整个游戏的控制中心,它不断检查各种标志位并执行相应的状态函数。
int main(void) { // 硬件初始化:HAL_Init, 系统时钟配置, GPIO, TIM2, I2C, OLED... System_Init(); OLED_Init(); OLED_Clear(); game_state = STATE_READY; show_start_screen(); while (1) { switch(game_state) { case STATE_READY: // 显示准备界面,等待按键(在中断中处理) break; case STATE_COUNTDOWN: handle_countdown(); // 处理倒计时,更新显示 break; case STATE_PLAYING: // 目标移动逻辑在定时器中断中更新标志位,这里只检查刷新标志 if(refresh_screen_flag) { update_target_position(); draw_game_screen(); refresh_screen_flag = 0; } // 检查按键标志(由外部中断设置) if(key_pressed_flag) { key_pressed_flag = 0; // 状态已在中断中切换为JUDGING } break; case STATE_JUDGING: calculate_score(); // 计算分数 play_feedback_animation(); // 播放判定动画 game_state = STATE_RESULT; break; case STATE_RESULT: show_result_screen(); // 显示分数和评价 HAL_Delay(3000); // 结果显示3秒 game_state = STATE_READY; show_start_screen(); break; } // 其他后台任务,如LED呼吸灯效果等 LED_Process(); } }handle_countdown,update_target_position,calculate_score等函数都需要根据游戏设计具体实现。例如,目标移动可以是匀速直线、变速圆周运动,或者一个进度条从0%增长到100%。目标时间点可以设定在进度条到达75%的时刻。
5. 调试技巧、性能优化与深度扩展
5.1 调试技巧与常见问题排查
在开发过程中,你肯定会遇到各种问题。以下是一些实用的调试技巧和常见坑点:
按键抖动与误触发:机械按键在按下和释放时会产生电平抖动,可能导致多次触发中断。解决方法有硬件消抖(并联电容)和软件消抖。在中断服务函数中,触发后先禁用该外部中断,然后启动一个定时器(如基本定时器TIM6)延时10-20ms,在定时器中断中再去读取按键电平状态并处理逻辑,最后再重新使能外部中断。这是一种更可靠的方式。
OLED显示乱码或不显示:首先检查I2C地址是否正确(通常是0x78或0x7A)。用逻辑分析仪或示波器抓取I2C波形是最直接的排查方法。如果没有仪器,可以编写一个简单的测试程序,只发送初始化命令序列,并检查每个ACK是否正常。确保
I2C_Start,I2C_Stop,I2C_SendByte的时序符合规范,特别是SCL高电平期间SDA必须保持稳定。时间测量不准:这是最影响游戏体验的问题。首先确认定时器的时钟源和预分频设置是否正确。其次,检查中断优先级。如果用于刷新的定时器中断优先级高于按键外部中断,且刷新中断执行时间过长,就会导致按键响应延迟。确保按键中断优先级最高。另外,在中断服务函数中,读取
TIM2->CNT的操作应该放在最前面,避免其他语句造成延迟。游戏卡顿或反应迟钝:检查主循环和中断服务函数的执行时间。避免在中断中进行浮点运算、
printf或复杂的屏幕绘制。将耗时的操作移到主循环中,并通过标志位触发。使用__HAL_TIM_GET_COUNTER这类宏而不是直接操作寄存器,可以提高代码可移植性。
5.2 性能优化与体验提升
当基础功能实现后,可以考虑以下优化来提升游戏品质:
使用DMA刷新OLED:软件I2C通过循环拉低拉高引脚模拟时序,会占用大量CPU时间。如果使用硬件I2C,可以配置DMA来传输显示数据缓冲区。这样,CPU只需要准备好一帧图像数据,然后启动DMA传输,就可以在传输过程中处理其他逻辑,极大提高效率。
加入动画与音效:利用PWM控制LED或蜂鸣器,可以在“Perfect”命中时让LED快速闪烁或发出悦耳的音效。使用定时器输出PWM波,通过改变占空比可以实现简单的音乐播放(如8位机时代的蜂鸣器音乐)。
多种游戏模式:增加游戏的可玩性。例如:
- 经典模式:目标匀速移动,目标时间点固定。
- 随机模式:目标时间点随机,增加不确定性。
- 反应模式:目标突然出现,测量玩家的纯粹反应时间。
- 连击模式:连续多次命中“Perfect”可以获得分数加成。
数据持久化:利用STM32内部的Flash(需谨慎操作,避免损坏)或外接EEPROM(通过I2C),保存最高分记录。每次游戏结束后比较当前分数与历史最高分,并更新。
更复杂的图形与物理:可以尝试在OLED上绘制更复杂的图形,如贝塞尔曲线运动的靶心。甚至可以引入简单的物理模拟,比如目标移动受到“重力”或“摩擦力”影响,让游戏更具挑战性。
5.3 从项目到产品:思维延伸
完成这个项目后,你掌握的远不止一个游戏。你实践了一个完整的嵌入式系统开发流程:需求分析、硬件选型、外设驱动、中断管理、状态机设计、调试优化。这套方法论可以平移到无数实际产品中:
- 智能家居的遥控器:按键中断用于检测用户输入,I2C驱动OLED显示状态,定时器用于红外编码发射或省电休眠。
- 工业数据采集器:高精度定时器用于精确计时采样,GPIO中断用于捕获传感器信号,状态机管理整个采集、存储、上传流程。
- 穿戴设备:低功耗定时器唤醒系统,检测动作(类似我们的按键中断),驱动小型显示屏。
我个人在实现这个项目时,最大的体会是模块化和调试优先的重要性。一开始就把GPIO、定时器、I2C、游戏逻辑分成独立的.c/.h文件,并编写简单的测试函数验证每个模块是否工作正常(比如,写个测试程序让定时器每1秒通过串口打印一个数)。这比把所有代码堆在main.c里,最后出了问题无从下手要高效得多。另外,不要害怕使用printf重定向到串口进行调试,它虽然效率不高,但在逻辑调试阶段是无价之宝。当你看到“Key Pressed at TIM2=1234567”这样的信息打印出来时,心里就踏实了一大半。