简介:面向嵌入式课程设计的一份STM32F103俄罗斯方块完整工程,针对计算机、软件工程、通信工程等专业学生,可作为单片机综合实践、嵌入式入门与毕业设计参考。资源共177个文件、压缩包7.05MB,以C语言源工程为主,包含.h头文件、.c源文件、.uvprojx工程文件、.hex烧录文件、.axf调试文件、.map映射文件及.ioc初始化配置等,覆盖LCD屏幕显示、定时器控制、外部中断输入与游戏逻辑算法,便于直接编译烧录和二次开发。工程基于STM32CubeMX与Keil环境搭建,清晰展示GPIO、FSMC、定时器等外设的嵌入式编程流程,帮助理解硬件接口抽象与实时响应控制。已有1122人学习下载,适合希望快速上手STM32游戏开发、巩固中断与定时器应用的初学者,亦可用于课程设计答辩与项目演示。
1. 从工程文件看STM32F103俄罗斯方块怎么拆
拿到这套基于STM32F103运行的俄罗斯方块源码,值得花点时间先读文件列表。stm32f1xx_ll_fsmc.c说明LCD走的是FSMC总线而不是GPIO模拟时序,stm32f1xx_hal_tim.c和stm32f1xx_hal_tim_ex.c说明下落节奏由硬件定时器中断驱动,lcd_header.c是显示层与游戏逻辑之间的分界。整个工程基于HAL库,没有引入RTOS,游戏循环靠「FSMC搬像素、定时器给心跳、主循环算逻辑」完成。在小内存MCU上做图形游戏,这种架构比GPIO模拟省CPU,比套RTOS省维护成本,是课程设计和入门练手最均衡的方案。适合正在做嵌入式课程设计,或者想把HAL中断、FSMC外设和裸机状态机串起来的开发者,下面按显示、定时、算法、输入验证四层拆解。
2. FSMC总线地址映射与LCD驱动:NE1和A0如何决定代码结构
2.1 为什么显示层要选FSMC而不是GPIO模拟
ST7789、ILI9341 这类 LCD 控制器,写像素本质是往它的 GRAM 里写数据。最简单的方案是拿 GPIO 一根一根翻转,但 240x320 分辨率下每帧有 76800 个像素,单色刷一遍都要做十几万次引脚操作,CPU 占用率会非常难看。FSMC 把 LCD 映射成外部 SRAM 设备,CPU 执行一次 16 位写指令,FSMC 自动完成地址建立、片选拉低、读写信号时序,像素搬运不再需要逐位翻转引脚。工程里出现stm32f1xx_ll_fsmc.c,说明作者把数据总线(PD0-PD15 或 PE0-PE15 区域)、控制线(NE1 片选、NOE 读、NWE 写)接进了 FSMC 的 Bank1,这是 STM32F103 上做图形应用最典型的接法。
2.2 FSMC初始化参数与HAL库配置
2.2.1 引脚分配参考
| 信号 | 常见引脚 | 说明 |
|---|---|---|
| D0-D15 | PD0-PD7、PE0-PE7 | 16位数据总线 |
| NOE | PD4 | 读使能,接LCD的RD |
| NWE | PD5 | 写使能,接LCD的WR |
| NE1 | PD7 | Bank1片选,接LCD的CS |
| A0 | PF0 或任意FSMC地址线 | 接LCD的RS,区分命令/数据 |
| RESET | 普通GPIO | LCD复位,初始化时序控制 |
A0只是示范接法,FSMC 地址线有多条,RS 也可以接到 A16,区别只在于命令端口与数据端口的偏移地址不同。很多开发板为了布线方便把 RS 接到 A16,代码里的偏移量就得跟着改,这一点在移植工程时最容易忽略。
2.2.2 FSMC初始化代码
static void MX_FSMC_Init(void) { FSMC_NORSRAM_TimingTypeDef Timing = {0}; FSMC_NORSRAMInitTypeDef NORSRAMInit = {0}; __HAL_RCC_FSMC_CLK_ENABLE(); NORSRAMInit.FSMC_Bank = FSMC_Bank1_NORSRAM1; /* 选中NE1 */ NORSRAMInit.FSMC_DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE; NORSRAMInit.FSMC_MemoryType = FSMC_MEMORY_TYPE_SRAM; /* 把LCD当SRAM */ NORSRAMInit.FSMC_MemoryDataWidth = FSMC_NONDATAWIDTH_16BIT; /* 16位总线 */ NORSRAMInit.FSMC_BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE; NORSRAMInit.FSMC_WriteOperation = FSMC_WRITE_OPERATION_ENABLE; NORSRAMInit.FSMC_ReadWriteTimingStruct = &Timing; NORSRAMInit.FSMC_WriteTimingStruct = &Timing; Timing.FSMC_AddressSetupTime = 8; /* 地址建立时间 */ Timing.FSMC_AddressHoldTime = 1; /* 地址保持时间 */ Timing.FSMC_DataSetupTime = 12; /* 数据建立时间 */ Timing.FSMC_AccessMode = FSMC_ACCESS_MODE_A; HAL_SRAM_Init(&NORSRAMInit, &Timing); }配置目标很明确:让 FSMC 的 Bank1 NE1 区域以 16 位 SRAM 模式访问外设。AddressSetupTime = 8和DataSetupTime = 12的单位是 HCLK 周期,72MHz 下约 111ns 和 166ns,ST7789 这类控制器的读写时序要求基本都在这个量级。如果你的 LCD 波形不正常,第一反应是加大这两个参数,而不是去改 GPIO 翻转频率。不同 HAL 版本里FSMC_NONDATAWIDTH_16BIT这个宏名可能有差异,编译报错时去 stm32f1xx_hal_fsmc.h 里搜NONDATAWIDTH就能找到对应定义。
2.2.3 命令与数据地址的偏移定义
#define LCD_BASE ((uint32_t)0x60000000) /* Bank1 NE1 首地址 */ #define LCD_CMD (*(volatile uint16_t *)(LCD_BASE)) /* A0=0: 写命令 */ #define LCD_DATA (*(volatile uint16_t *)(LCD_BASE + 2)) /* A0=1: 写数据 */A0 接 RS 时,访问LCD_BASE对应 A0 为低,LCD 认为当前是命令寄存器;访问LCD_BASE + 2对应 A0 为高,LCD 把值写入 GRAM 或数据寄存器。这里加 2 而不是加 1,是因为 16 位总线模式下地址线 A0 对应 CPU 地址的 bit[1]。很多从 8 位单片机转过来的开发者在这里踩坑,写成 +1,结果命令和数据混在一起,屏幕花屏或完全无反应。
提示:如果你的开发板把 RS 接到了 A16,数据地址就要换成
LCD_BASE + 0x20000,不要照抄偏移量。
2.3 lcd_header.c 中接口的封装思路
lcd_header.c属于底层驱动文件,核心是把「写命令」「写数据」做成短函数,再向上提供设置窗口和填充颜色的接口。通常封装为:
static void LCD_WriteReg(uint16_t reg, uint16_t val) { LCD_CMD = reg; /* 先写寄存器地址 */ LCD_DATA = val; /* 再写数据 */ } void LCD_FillRect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { LCD_SetWindow(x, y, x + w - 1, y + h - 1); /* 设置GRAM窗口 */ for (uint32_t i = 0; i < (uint32_t)w * h; i++) LCD_DATA = color; /* 连续刷同一颜色 */ }LCD_FillRect里的循环是 FSMC 方案省 CPU 的关键:循环体只有一条赋值语句,每次赋值 FSMC 自动完成整条写时序。如果换成 GPIO 模拟,循环里至少要夹带五六条引脚拉高拉低操作,同样的填色操作耗时相差三倍以上。游戏区域的网格底色、下一个方块预览、分数刷新都依赖这个函数,它的效率直接决定刷屏时能不能看到一个完整画面,还是看到明显的行扫残影。
3. HAL定时器中断驱动的下落节奏:从TIM配置到速度分级
3.1 定时器选型:为什么用TIM2而不是SysTick硬等
俄罗斯方块的方块下落不能靠主循环里HAL_Delay硬等。HAL_Delay依赖 SysTick,如果工程里还有别的模块改过 SysTick 中断优先级或重装周期,游戏节奏就会漂移。更重要的是它会让按键响应和屏幕刷新卡在同一个延时里,下落慢时按键也要等,手感很差。工程里出现stm32f1xx_hal_tim.c和stm32f1xx_hal_tim_ex.c,说明作者至少用了一个通用定时器(TIM2-TIM5)做独立的游戏节拍。
我一般把 TIM2 配成 100Hz 更新中断,每 10ms 递增一个全局 tick。所有游戏逻辑都以这个 tick 为时间基准,而不是到处调用延时。好处是主循环只判断「当前 tick 是否到了下落的点」,判断极快;速度调整只需要改一张表,不涉及打断任何任务。以后想移植 FreeRTOS 到这套工程上,这个 tick 思路可以直接映射为软件定时器回调,游戏逻辑不需要大改。
3.2 预分频与重装载值计算
TIM_HandleTypeDef htim2; static void MX_TIM2_Init(void) { htim2.Instance = TIM2; htim2.Init.Prescaler = 7200 - 1; /* 72MHz / 7200 = 10kHz */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 100 - 1; /* 10kHz / 100 = 100Hz */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start_IT(&htim2); /* 开启更新中断 */ }预分频器把 72MHz 总线时钟先降到 10kHz,计数器从 0 计到 99,每 100 个计数产生一次更新事件,即 100Hz。Prescaler和Period都要减 1,因为寄存器从 0 开始计数。AutoReloadPreload打开的含义是:运行中修改Period不会立刻生效,要等下一次更新事件才装载影子寄存器。这保证游戏运行中动态调速时不会出现一次异常长的半格下落。如果只在一个固定速度下运行,这个开关开不开差别不大,但做等级加速就建议保持使能。
3.3 中断回调与下落间隔判断
volatile uint32_t g_tick; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) g_tick++; } /* 主循环中的下落判断 */ uint32_t last_drop_tick; uint8_t level = 1; #define TICK_TO_MS(t) ((t) * 10) const uint16_t level_drop_ms[] = { [1] = 800, [2] = 600, [3] = 450, [4] = 320, [5] = 200, [6] = 120, }; while (1) { uint32_t interval = TICK_TO_MS(level_drop_ms[level]); if (g_tick - last_drop_tick >= interval) { last_drop_tick = g_tick; try_drop(); /* 尝试下落一格,撞底则固化并消行 */ } render_all(); /* 刷新屏幕 */ }用g_tick - last_drop_tick而不是g_tick >= last_drop_tick + interval,是为了防止 tick 回绕溢出后比较出错。两个无符号数相减,在回绕场景下依然能得到正确差值,这是嵌入式时间判断的常见做法,比直接比较安全。下落间隔表决定难度曲线:第 1 级 800ms 一格,到第 6 级缩短到 120ms。等级可以根据消除行数递增,比如每消 10 行升一级,这个节奏在 240x320 屏幕上肉眼观察比较合理。
3.4 速度分级表与动态调整边界
| 等级 | 下落间隔(ms) | 每格tick数 | 典型体验 |
|---|---|---|---|
| 1 | 800 | 80 | 新手适应期 |
| 2 | 600 | 60 | 正常节奏 |
| 3 | 450 | 45 | 开始有紧迫感 |
| 5 | 200 | 20 | 高等级手速 |
| 6+ | 120 | 12 | 接近街机加速上限 |
等级提升时只需要改level变量,主循环下一次判断自动取新间隔,不用重启定时器。有些同学会去改htim2.Init.Period再调用HAL_TIM_Base_Init重新初始化,其实没必要,查表的方式更干净,也避免了重装定时器期间漏掉一个更新中断。另外 120ms 以下继续加速意义不大,玩家按键反应时间本身就有 150-200ms,间隔再短只会增加挫败感,这也是很多街机版俄罗斯方块最终速度上限停在 100ms 左右的原因。
4. 方块旋转、碰撞检测与消行的实现路径:不依赖动态内存的游戏状态管理
4.1 数据结构选择:4x4位矩阵最省存储
俄罗斯方块的核心状态拆成三块:当前方块(形状+位置+旋转状态)、游戏面板(已固化的方块网格)、下一个方块(预告)。F103 的 SRAM 有限,高配型号也只有 64KB,跑 C 语言虽然够,但主循环里频繁 malloc/free 会产生碎片,游戏长时间运行后稳定性不可控。我习惯用uint8_t shape[4][4]表示单一方块,1 表示有格子,0 表示空。7 种基本方块各存一份基础形态,旋转在运行时直接变换矩阵,不需要把所有旋转状态都预先存下来,省 flash 也省逻辑判断。
| 方块 | 基础形状(4x4) | 颜色惯例 |
|---|---|---|
| I | 0000 / 1111 / 0000 / 0000 | 0xFFF 白 |
| O | 0000 / 0110 / 0110 / 0000 | 0xFF0 黄 |
| T | 0000 / 0100 / 1110 / 0000 | 0xF0F 紫 |
| S | 0000 / 0110 / 1100 / 0000 | 0x0F0 绿 |
| Z | 0000 / 1100 / 0110 / 0000 | 0xF00 红 |
| L | 0000 / 1000 / 1110 / 0000 | 0xF80 橙 |
| J | 0000 / 0010 / 1110 / 0000 | 0x08F 蓝 |
颜色值仅作参考,具体取决于 LCD 控制器是 RGB565 还是 RGB888 模式。需要强调的是,O方块旋转后形状不变,所以代码里遇到O时可以直接跳过旋转矩阵运算,省几次无意义的拷贝。
4.2 旋转:矩阵转置与水平翻转组合
static void rotate_right(uint8_t shape[4][4]) { uint8_t tmp[4][4]; for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) tmp[j][3 - i] = shape[i][j]; /* 顺时针90度 */ memcpy(shape, tmp, sizeof(tmp)); }顺时针旋转的核心是tmp[j][3 - i] = shape[i][j]:把原矩阵第 i 行第 j 列的格子搬到新矩阵第 j 行第 3-i 列。这一行等号同时完成了转置和左右翻转,两步并一步,旋转后不需要再单独处理行列映射。旋转完成后要立刻做边界与碰撞检测,如果旋转后的位置与已有面板重叠或越界,就回滚这次旋转。更进阶的做法是加「踢墙」逻辑:旋转失败后尝试左右各偏移一格再检查,这是 Tetris Guideline 里 SRS 旋转系统的思路,课程设计做到基础版本即可,踢墙逻辑需要多维护一套偏移表,对新手性价比不高。
4.3 碰撞检测的边界顺序
#define COLS 10 #define ROWS 20 uint8_t board[ROWS][COLS]; /* 1表示已有方块 */ static int can_place(const uint8_t shape[4][4], int ox, int oy) { for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (!shape[r][c]) continue; /* 空格不参与检测 */ int gx = ox + c; int gy = oy + r; if (gx < 0 || gx >= COLS) return 0; /* 左右越界 */ if (gy >= ROWS) return 0; /* 底部越界 */ if (gy >= 0 && board[gy][gx]) return 0; /* 与固化方块重叠 */ } } return 1; }oy允许小于 0,因为方块从屏幕顶部上方出生,前几行在面板之外是正常状态,所以上方越界不拦截,只在gy >= 0时才查询面板。初学经常把这个写反,直接在gy < 0时返回 0,导致方块永远卡在顶部进不来。左右越界则无条件拦截,因为横向移动不能超出屏幕边界。board的类型选择uint8_t而不是int,一方面省 RAM,另一方面能为后续消行时的memcpy提供紧凑的内存块。
4.4 消行:从底部向上扫描并整体下移
static int clear_lines(void) { int cleared = 0; for (int row = ROWS - 1; row >= 0; ) { int full = 1; for (int col = 0; col < COLS; col++) { if (!board[row][col]) { full = 0; break; } } if (full) { for (int r = row; r > 0; r--) memcpy(board[r], board[r - 1], COLS); memset(board[0], 0, COLS); cleared++; /* row不自减:上面行下移后,当前行位置出现了新行,需要重新检查 */ } else { row--; } } return cleared; }消行最容易出错的地方就是row的自减逻辑。如果消掉一行后无条件row--,会漏掉连续满行的情况,比如一次消掉两行时第二行不会被处理。正确做法是消行时保持row不变,等上面的行移下来后原地检查一遍。memcpy(board[r], board[r - 1], COLS)每次拷 10 字节,72MHz 下开销只有几十个周期,整个消行动作即便 20 行全清也远快于一次屏幕刷新,不会产生可感知的卡顿。返回的cleared用于计分:消 1 行 100 分、2 行 300 分、3 行 600 分、4 行 1000 分,这是俄罗斯方块通用的得分斜率,能区分「一次消四行」和「分四次消一行」的操作差异。
4.5 主循环状态机与游戏结束判定
游戏循环本质是一个有限状态机:生成方块、等待下落、碰撞后固化、消行、检查顶部是否溢出。固化逻辑放在try_drop()返回失败时触发:把当前方块的位置按 4x4 矩阵写入board,然后执行clear_lines(),再生成下一个方块。游戏结束判定不能只看新方块出生点是否被占用,还要考虑出生点上方可能已经有方块,俄罗斯方块标准判定是「新方块生成时立即做一次碰撞检测,失败则游戏结束」。这里我建议把生成和判定写进同一个函数:
static int spawn_block(uint8_t shape[4][4]) { memcpy(shape, preview_shape, sizeof(preview_shape)); int spawn_ok = can_place(shape, SPAWN_X, SPAWN_Y); if (!spawn_ok) return 0; /* 游戏结束 */ cur_x = SPAWN_X; cur_y = SPAWN_Y; return 1; }SPAWN_X和SPAWN_Y需要根据你的面板列数算,10 列面板通常让方块出现在第 4 列(索引从 0 开始),SPAWN_Y设为 -2 让方块在屏幕上方半个方块的位置等待进入。这个设计比「出生点必须在面板内」更符合真实游戏手感,也让玩家有零点几秒的时间在方块完全进入视野前调整位置。
5. 按键EXTI消抖、串口重定向与FSMC花屏排错
5.1 用EXTI下降沿触发按键,tick追加减消抖
按键接在 PE0-PE3,内部上拉,按下接地触发下降沿。EXTI 配合 tick 消抖比HAL_Delay更合适,因为延时消抖会把中断阻塞住,期间玩家再按其他键就会丢事件,游戏响应感明显变差。
volatile uint32_t key_tick; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (g_tick - key_tick < 3) return; /* 30ms以内忽略 */ key_tick = g_tick; switch (GPIO_Pin) { case GPIO_PIN_0: move_left(); break; case GPIO_PIN_1: move_right(); break; case GPIO_PIN_2: rotate_right(); break; case GPIO_PIN_3: drop(); break; } }3 个 tick 对应 30ms,足以滤掉机械按键抖动脉冲。中断回调里不要调用HAL_Delay,也不要把clear_lines这类循环较多的操作直接塞进来,中断要短。drop()硬降可以在这里只标记事件,真正执行固化与消行放到主循环里做,这样按键重复触发时不会重入消行逻辑。
5.2 串口重定向printf验证游戏状态
int fputc(int ch, FILE *f) { while (!(USART1->SR & USART_FLAG_TXE)); /* 等待发送寄存器空 */ USART1->DR = (uint8_t)ch; return ch; }使用 MDK 的 microlib 时,这段重定向即可让printf("level=%d,score=%d\n", level, score);从串口 1 输出。配合 USB 转 TTL 模块,可以实时观察消行得分是否按预期累加。如果工程里同时用了串口 3 接其他模块,先确认fputc操作的是哪个 USART。串口 1 和串口 3 的时钟源、GPIO 复用配置都不一样,USART1->SR写成USART3->SR但时钟没打开,会直接卡死在发送等待循环里。
5.3 FSMC花屏排查顺序
LCD 全白或全黑时,先确认最小系统本身工作正常:晶振起振、BOOT0 拉低、复位电路完好,再怀疑时序。然后看 NE1 片选是否正常,LCD_CMD和LCD_DATA的地址偏移是否与 RS 实际接的 FSMC 地址线一致。用示波器抓 WR 写时序最直接:没有仪器就把时序参数放宽,AddressSetupTime = 15、DataSetupTime = 30能显示,再逐步缩小到性能可接受的最小值。如果只是偶尔闪烁,检查 RESET 引脚时序,LCD 必须先复位完成再初始化内部寄存器,上电瞬间 FSMC 总线上的杂散信号可能被 LCD 当成命令,所以初始化函数里要在写第一批寄存器前加长延时。
本文还有配套的精品资源,点击获取