行业资讯
📅 2026/7/31 3:21:46
STM32俄罗斯方块实战:MCU游戏开发与嵌入式系统设计
1. 项目概述在MCU上重温经典俄罗斯方块这个诞生于上世纪80年代的经典游戏几乎刻在了每一位电子爱好者的DNA里。它规则简单却蕴含着无穷的策略与乐趣。今天我们不做PC版也不做手机App而是要把它“塞进”一块小小的STM32F103开发板里。我手头这块是正点原子的“指南者”开发板它搭载的STM32F103VET6芯片拥有512KB的Flash和64KB的RAM性能对于实现一个基础版的俄罗斯方块来说绰绰有余。这个项目的核心价值远不止于“复刻一个游戏”那么简单。它是一次对嵌入式系统软硬件综合能力的绝佳演练。你需要驱动LCD屏幕来绘制图形管理矩阵键盘或独立按键来接收玩家输入利用定时器产生精确的游戏时钟节拍还要设计高效的数据结构和算法来管理不断下落的方块。整个过程你会深刻理解到在资源受限的微控制器MCU环境中如何平衡功能、性能和代码可维护性。无论你是刚学完STM32基础外设的新手想找一个综合项目练手还是有一定经验的开发者希望深入理解实时系统与游戏逻辑的结合这个项目都能让你收获满满。2. 整体设计与核心思路拆解在STM32上做游戏和你在电脑上写程序有本质区别。这里没有操作系统替你管理任务和内存一切都需要你亲手安排。我的设计思路是围绕“实时性”和“资源管理”两个核心展开。2.1 系统架构与模块划分首先我把整个游戏系统分解成几个松耦合的模块这是保证代码清晰、易于调试的关键。硬件驱动层这是最底层直接与STM32的片上外设打交道。包括LCD我用的板子是TFT-LCD驱动IC是ILI9341的初始化、画点、画线、填充矩形、显示字符等函数按键扫描模块负责定时检测矩阵键盘或独立按键的状态并去抖后生成清晰的事件如按下、释放定时器模块我用一个基本定时器如TIM2产生固定的时间基准比如每50ms中断一次作为整个游戏的“心跳”。游戏逻辑层这是核心大脑。它定义游戏的核心数据结构如地图、当前方块、下一个方块实现方块的旋转、移动、碰撞检测、消行判断、分数计算等所有规则。这一层应该尽可能与硬件无关理论上可以移植到其他平台。显示与交互层作为硬件驱动层和游戏逻辑层的桥梁。它调用驱动层的函数将游戏逻辑层中的抽象数据比如一个值为1的格子代表有方块转化为屏幕上具体的像素和颜色。同时它把驱动层上报的原始按键事件映射为游戏逻辑层能理解的命令如左移、右移、旋转、加速下落。为什么要这样分层想象一下如果你把画屏幕的代码和判断方块能不能旋转的代码混在一起将来想换一块不同驱动的屏幕或者把按键从矩阵键盘改成摇杆你就会发现需要在一大团乱麻里找代码改起来心惊胆战。分层之后你只需要替换或修改驱动层游戏逻辑几乎不用动。2.2 核心数据结构设计数据结构是程序的骨架设计得好逻辑就清晰效率也高。游戏地图我用一个二维数组uint8_t map[20][10]来表示。为什么是20行10列这是经典俄罗斯方块的规格。数组元素为0表示该格子为空为1-7的不同数字可以表示不同颜色/类型的方块残留部分。地图的顶部以上数组索引为负在逻辑上用于生成新方块。当前活动方块这是一个结构体至少包含以下信息typedef struct { int8_t shape[4][4]; // 4x4的矩阵描述方块的形状1表示有0表示无 int16_t x, y; // 方块左上角在地图坐标系中的位置 uint8_t type; // 方块类型I, J, L, O, S, T, Z uint8_t color; // 方块颜色对应LCD的颜色值 } Tetromino;所有7种经典方块都可以用一个4x4的矩阵来表示其旋转状态。预定义好所有方块所有旋转形态的模板数组是通用的做法。下一个方块同样用一个Tetromino结构体表示在游戏初始化时随机生成并在侧边或顶部预览区域显示。2.3 游戏主循环与时间管理这是嵌入式游戏编程的精华所在。你不能用while(1)里加delay这种阻塞方式因为你需要同时响应按键和让方块自动下落。我采用的是一个基于定时器中断的非阻塞主循环。系统初始化后进入一个while(1)主循环。主循环里只做三件事检查按键事件调用按键扫描函数如果检测到有有效按键动作立即处理比如立刻左移。这保证了操作的即时性。检查游戏时钟设置一个全局变量game_tick在定时器中断服务函数里定期如每50ms加1。在主循环里检查如果game_tick达到了下落一格所需的时间阈值比如每10个tick下落一次即500ms就执行一次“方块下落一步”的逻辑并重置计数器。这保证了游戏进程的匀速进行。根据需要刷新局部显示不是每一帧都重画整个屏幕。只有在方块移动、旋转、消行后才刷新受影响的那部分屏幕区域。这能极大提高效率避免屏幕闪烁。定时器中断就像节拍器稳定地打着拍子主循环就像乐手在每个拍子间隙检查是否需要弹奏音符响应按键以及何时该进入下一小节方块下落。两者配合实现了多任务的错觉。3. 核心模块实现与难点解析有了顶层设计我们来深入几个关键模块的实现细节和容易踩坑的地方。3.1 方块旋转算法的精妙之处旋转听起来简单就是把一个4x4矩阵旋转90度。但难点在于旋转中心和碰撞检测。1. 旋转模板法最可靠的方法。我为7种方块I, J, L, O, S, T, Z各自预定义了4个4x4的矩阵分别对应0、90、180、270度四种旋转状态。当玩家按下旋转键时当前方块的type和rotation_index索引加1对4取模然后直接从模板数组里取出新的形状矩阵替换旧的。O型方块正方形的四个模板其实是一样的这是一个特例。2. 旋转后的碰撞预测与墙踢方块旋转后新的形状可能会嵌进墙里或其他方块里。这时不能简单地禁止旋转体验会很差。俄罗斯方块有一个“墙踢”规则如果旋转后发生碰撞系统会尝试将方块向左、右或上轻微移动一个单位根据标准规则有固定的测试位置顺序如果某个偏移位置下不碰撞就允许旋转并同时施加这个偏移。实现这个功能需要你在check_collision()函数中不仅检查当前位置还要能接受一个偏移量参数进行预测检查。注意自己推导旋转公式和墙踢测试向量很容易出错且不标准。强烈建议参考官方的“标准旋转系统”SRS数据。网上可以找到这7种方块所有旋转状态以及墙踢测试的偏移量表直接做成常量数组用到代码里这是最专业和兼容性的做法。3.2 高效碰撞检测的实现碰撞检测是调用最频繁的函数之一效率至关重要。它主要检查两类碰撞与墙地图边界的碰撞与已固定方块的碰撞。// 检测一个方块在给定位置cx, cy和给定形状shape[4][4]是否会碰撞 bool check_collision(int16_t cx, int16_t cy, const int8_t shape[4][4]) { for (int8_t i 0; i 4; i) { for (int8_t j 0; j 4; j) { if (shape[i][j]) { // 只检查形状矩阵中为1的格子 int16_t map_x cx j; int16_t map_y cy i; // 1. 检查是否超出左右边界或底边界 if (map_x 0 || map_x MAP_WIDTH || map_y MAP_HEIGHT) { return true; // 碰撞 } // 2. 检查是否与地图中已固定的方块重叠注意map_y0才检查 if (map_y 0 game_map[map_y][map_x]) { return true; // 碰撞 } } } } return false; // 无碰撞 }这个函数在方块尝试移动、旋转前被调用。关键点在于map_y 0这个判断。地图数组的索引0对应的是屏幕最顶端一行。当新方块在顶部生成时其cy可能是负值比如-2表示一部分在可视区域上方。这时我们只检查它是否撞到左右墙而不检查与地图数组的碰撞因为数组索引为负的部分是合法的生成区。3.3 消行与地图更新优化当一行被填满时需要消除该行并将上面的所有行整体下移。一个直观但低效的做法是发现满行后从该行开始将上一行的数据逐行复制到下一行。更优化的做法是使用“双指针”或“行标记”法从地图底部向上扫描用一个write_row指针指向当前准备写入的行从底部开始。用另一个read_row指针从底部向上扫描。如果read_row指向的行是满的就跳过它read_row上移write_row不动。如果read_row指向的行不是满的就将这行数据复制到write_row指向的位置然后write_row和read_row都上移。扫描结束后write_row以上的所有行都应该被清空。 这样做只需要一次遍历和必要的数据移动效率更高。同时在消行过程中要记录消除了几行用于分数计算消除行数越多单次得分系数越高。3.4 显示优化局部刷新与双缓冲思想STM32F103的刷屏速度有限全屏刷新尤其是320*240分辨率会有明显的闪烁和延迟。我们必须进行局部刷新。1. 方块移动的刷新当方块移动或旋转时我们只需要擦除方块在旧位置留下的痕迹然后在新位置绘制方块。这就需要保存方块上一次的位置和形状。在每次成功移动后将旧位置对应的地图区域根据旧形状计算出的4x4格子覆盖的所有地图格子重绘为背景色或地图状态再绘制新位置的方块。2. 消行时的刷新消行后从消除行到顶部所有方块的位置都变了。最直接的方法是重绘整个游戏区域。但我们可以优化计算出消行后哪些行区域发生了变化通常是从消除行到顶部的一个矩形区域只刷新这个区域。虽然计算稍复杂但在观感上提升明显。3. 双缓冲的模拟在资源足够的MCU上可以开辟两块屏幕大小的显存在一块“后台”显存上完成所有绘图操作然后一次性DMA传输到LCD这能完全杜绝闪烁。但STM32F103指南者的RAM可能不够3202402字节150KB 64KB。我们可以采用“区域双缓冲”只为频繁更新的游戏区域比如10列20行每个格子88像素开辟一块小缓存更新在这个缓存里进行然后一次性刷到屏幕对应位置。这需要更精细的内存管理。4. 详细实现步骤与代码剖析让我们一步步搭建起这个游戏。这里我会给出关键代码片段和配置思路。4.1 硬件初始化与驱动准备首先确保你的开发环境Keil、IAR或STM32CubeIDE和硬件连接正确。LCD和按键的引脚定义需要根据你的“指南者”底板原理图来修改。// 示例基于STM32Cube HAL库的初始化框架 int main(void) { HAL_Init(); SystemClock_Config(); // 配置系统时钟推荐72MHz // 初始化外设 MX_GPIO_Init(); MX_TIM2_Init(); // 用于游戏时钟的定时器 MX_SPI1_Init(); // 假设LCD使用SPI接口 // ... 其他必要外设 // 初始化LCD LCD_Init(); LCD_Clear(WHITE); // 清屏为白色背景 LCD_ShowString(10, 10, “STM32 Tetris”, BLACK, WHITE, 16, 0); // 初始化按键假设使用扫描方式 Key_Init(); // 初始化游戏 Game_Init(); // 启动游戏时钟定时器 HAL_TIM_Base_Start_IT(htim2); // 开启定时器中断 // 游戏主循环 while (1) { Game_ProcessInput(); // 处理按键 Game_Update(); // 更新游戏逻辑 Game_Render(); // 渲染画面局部刷新 // 可以加入一个短延时避免主循环空跑耗电但非必须 // HAL_Delay(1); } }4.2 游戏逻辑核心代码实现我们聚焦于游戏逻辑层最核心的几个函数。游戏初始化Game_Init()void Game_Init(void) { // 清空游戏地图 memset(game_map, 0, sizeof(game_map)); // 初始化随机数种子可以用ADC读取悬空引脚噪声 srand(HAL_GetTick()); // 生成当前方块和下一个方块 current_block Create_Random_Block(); next_block Create_Random_Block(); // 重置游戏状态变量 game_score 0; game_level 1; drop_speed INITIAL_SPEED; game_tick_counter 0; game_over false; // 绘制初始界面边框、下一个方块预览区、分数显示区 Draw_Game_Border(); Draw_Next_Block(next_block); Draw_Score(); }方块移动处理Game_ProcessInput()void Game_ProcessInput(void) { uint8_t key Key_GetValue(); // 获取按键值0表示无按键 if (key 0 || game_over) return; Tetromino test_block current_block; bool moved false; switch(key) { case KEY_LEFT: test_block.x--; if (!check_collision(test_block.x, test_block.y, test_block.shape)) { current_block.x--; moved true; } break; case KEY_RIGHT: test_block.x; if (!check_collision(test_block.x, test_block.y, test_block.shape)) { current_block.x; moved true; } break; case KEY_ROTATE: // 获取下一个旋转状态的形状 int8_t next_shape[4][4]; get_next_rotation(¤t_block, next_shape); // 尝试墙踢 if (try_wallkick(¤t_block, next_shape)) { moved true; } break; case KEY_DOWN: // 加速下落 drop_speed_temp FAST_DROP_SPEED; break; } if (moved) { // 记录旧位置用于擦除然后更新显示 Block_Erase_Previous(); Block_Draw_Current(); } }游戏状态更新Game_Update()void Game_Update(void) { if (game_over) return; // 检查是否该自动下落了 game_tick_counter; if (game_tick_counter drop_speed) { game_tick_counter 0; // 尝试下落一格 if (!check_collision(current_block.x, current_block.y 1, current_block.shape)) { current_block.y; Block_Erase_Previous(); Block_Draw_Current(); } else { // 无法下落固定到地图 Lock_Block_To_Map(¤t_block); // 检查并消除满行 int lines_cleared Check_and_Clear_Lines(); if (lines_cleared 0) { Update_Score(lines_cleared); Draw_Score(); // 可能需要根据分数提升等级和速度 drop_speed Calculate_Speed(game_level); } // 生成新的当前方块即原来的下一个方块 current_block next_block; current_block.x SPAWN_X; // 重置到出生点 current_block.y SPAWN_Y; // 生成新的下一个方块 next_block Create_Random_Block(); Draw_Next_Block(next_block); // 检查游戏是否结束新方块出生即碰撞 if (check_collision(current_block.x, current_block.y, current_block.shape)) { game_over true; Draw_GameOver(); } } } }4.3 显示渲染模块示例以绘制一个方块格子和绘制下一个方块预览区为例// 绘制一个地图格子 (x, y 为地图数组坐标 color为颜色) void Draw_Cell(uint8_t x, uint8_t y, uint16_t color) { uint16_t screen_x GAME_AREA_OFFSET_X x * CELL_SIZE; uint16_t screen_y GAME_AREA_OFFSET_Y y * CELL_SIZE; // 留出1像素的间隙使方块之间有空隙更美观 LCD_Fill(screen_x1, screen_y1, screen_xCELL_SIZE-1, screen_yCELL_SIZE-1, color); // 可以给方块加一个浅色的边框增加立体感 LCD_DrawRectangle(screen_x, screen_y, screen_xCELL_SIZE, screen_yCELL_SIZE, color_light); } // 绘制“下一个方块”预览区 void Draw_Next_Block(Tetromino *block) { // 1. 清空预览区域 LCD_Fill(NEXT_BLOCK_AREA_X, NEXT_BLOCK_AREA_Y, NEXT_BLOCK_AREA_X 4*CELL_SIZE, NEXT_BLOCK_AREA_Y 4*CELL_SIZE, BACKGROUND_COLOR); // 2. 绘制预览方块 for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (block-shape[i][j]) { uint16_t screen_x NEXT_BLOCK_AREA_X j * CELL_SIZE; uint16_t screen_y NEXT_BLOCK_AREA_Y i * CELL_SIZE; LCD_Fill(screen_x1, screen_y1, screen_xCELL_SIZE-1, screen_yCELL_SIZE-1, block-color); } } } }5. 调试技巧、常见问题与优化建议在实际焊接代码和调试的过程中你肯定会遇到各种问题。这里分享一些我踩过的坑和解决思路。5.1 典型问题排查清单问题现象可能原因排查思路与解决方案屏幕无显示或花屏1. SPI/I2C/FSMC时序配置错误。2. 初始化序列寄存器配置不正确或遗漏。3. 背光未开启。4. 电源或复位引脚问题。1. 用逻辑分析仪或示波器抓取LCD初始化时的通信波形与ILI9341数据手册对比。2. 逐行核对LCD驱动代码的初始化函数确保发送了正确的命令和数据序列。网上找的驱动代码可能因屏的批次不同而略有差异。3. 检查背光控制引脚BL是否被正确拉高。4. 测量LCD模块的VCC和GND电压是否稳定。按键无反应或连击1. 按键扫描频率不当太快或太慢。2. 消抖算法失效。3. 引脚配置错误如上拉电阻未启用。4. 中断与主循环冲突。1. 将扫描间隔调整到10-20ms左右。在按键中断或定时器中断中设置标志在主循环中处理。2. 实现稳定的消抖检测到按下后延时10ms再读一次如果仍是按下状态则确认为有效按下。释放同理。3. 检查GPIO初始化按键引脚应配置为输入上拉模式。4. 避免在中断服务函数中进行耗时操作或调用可能重入的函数。方块移动/旋转卡顿1. 碰撞检测函数效率低下或逻辑错误导致死循环。2. 屏幕刷新区域过大或全屏刷新。3. 主循环被其他阻塞任务如长延时拖慢。4. 定时器中断频率过高处理负担重。1. 优化check_collision函数使用循环展开或减少不必要的判断。用调试器测量函数执行时间。2. 确保只刷新方块移动前后变化的区域而不是整个游戏区。3. 移除主循环中不必要的HAL_Delay()。4. 将游戏时钟定时器中断频率降低比如从1ms改为50ms在中断里只设标志逻辑在主循环处理。游戏随机性差随机数种子固定导致每次游戏方块序列都一样。使用一个不固定的值作为随机种子如HAL_GetTick()系统滴答时钟上电后一直在变或者读取一个未连接的ADC通道的噪声值。消行后显示错乱1. 消行后地图数据更新逻辑错误。2. 屏幕刷新逻辑未覆盖所有变化区域。3. 内存越界破坏了其他数据。1. 单步调试Check_and_Clear_Lines函数观察地图数组每一步的变化是否正确。2. 在消行后强制重绘整个游戏区域看是否正常。如果正常说明是局部刷新逻辑问题。3. 检查所有数组访问的索引是否都在有效范围内如map[20][10]的索引是0-19和0-9。5.2 性能与体验优化建议当基础功能实现后可以考虑以下优化来提升游戏品质加入动画效果方块固定时的闪烁效果、消行时的逐行消失动画。这可以通过在定时器中断中快速切换颜色状态来实现增加视觉反馈。实现“幽灵方块”在游戏区域底部用一个半透明的轮廓显示方块如果立即下落会到达的位置。这能极大提升玩家的策略性。实现方法就是从当前方块位置向下模拟下落直到碰撞然后在那个位置画一个半透明或虚线框的方块。添加音效利用STM32的PWM或DAC结合一个简单的蜂鸣器可以发出不同频率的响声用于移动、旋转、消行、游戏结束等事件。哪怕只是“嘀嘀”声体验也提升一个档次。设计更友好的UI用不同颜色区分方块绘制平滑的游戏边框添加分数、等级、下一个方块的文字标签让界面更完整。使用状态机管理游戏流程将游戏划分为“开始画面”、“游戏中”、“暂停”、“游戏结束”等状态。用状态机来管理可以使代码逻辑更清晰更容易扩展新功能比如暂停功能。5.3 进阶挑战移植与扩展如果你已经完美实现了基础版本可以尝试以下更有挑战性的任务移植到其他屏幕尝试把你的驱动层抽象得更好然后移植到OLEDI2C/SPI或者另一种TFT屏上。这能检验你代码的模块化程度。使用RTOS尝试在FreeRTOS上重构这个游戏。创建一个按键扫描任务、一个游戏逻辑更新任务、一个显示渲染任务。你会对任务间通信队列、信号量有更深的理解。添加存储功能利用STM32的内部Flash或外接EEPROM保存最高分记录。制作手持设备设计一个3D打印外壳将指南者核心板、电池、小屏幕和按键组装在一起做成一个真正的掌上俄罗斯方块游戏机。这个项目从点亮屏幕到流畅游戏每一步都需要你仔细思考硬件特性和软件逻辑的配合。调试过程可能充满挫折但当屏幕上第一个方块受控落下当第一行被成功消除时那种成就感是无与伦比的。它不仅仅是一个游戏更是你嵌入式开发能力的一个坚实里程碑。