资讯动态

STM32俄罗斯方块完整实战:C语言算法与PCB设计全流程解析

发布时间:2026/9/17 2:09:57 来源:尧图企业网站定制
简介面向STM32单片机开发者的俄罗斯方块游戏设计资料完整集成源程序、电路原理图与PCB文件。项目以STM32为控制核心C语言源码涵盖系统时钟及中断初始化、游戏方块生成/旋转/移动与消行算法、按键输入扫描以及LCD/OLED显示驱动并预留音频反馈接口可清晰看出嵌入式设备中逻辑处理与硬件驱动的配合方式。整个压缩包内共102个文件其中41个h头文件和39个c源文件构成工程主体配合uvprojx工程可直接在Keil MDK中编译调试hex文件便于烧录验证pcbdoc、prjpcb等文件则对应电路板设计与制板参考。压缩包共7.82MB轻量易取已有2769人学习下载。适合正在学习STM32外设编程、想做课设或竞赛作品或希望从无到有掌握软硬件设计流程的电子爱好者参照压缩包内容可快速完成一个可运行的俄罗斯方块作品并延伸出更多创意玩法。1. 基于STM32单片机的俄罗斯方块从源程序到PCB的完整闭环一个完整的STM32单片机俄罗斯方块项目交付物通常就是三样源程序、原理图、PCB。很多人拿到之后只看源程序跑通了就放下而原理图与PCB恰恰是决定这个项目能不能从“开发板漂移”变成“自己打样”的分水岭。这篇文章把这三样连起来讲先看C语言怎么描述方块形状与碰撞再看SPI接口怎么把游戏画面推到TFT屏上最后落到原理图引脚分配和PCB布线里那些不标出来就会翻车的细节。无论你是想把按键换成摇杆还是想把屏幕从ILI9341换成ST7735底层逻辑都不会变。2. 俄罗斯方块核心代码用C语言把形状、旋转、消行写清楚2.1 用4×4矩阵描述7种方块O方块为什么要单独处理写俄罗斯方块C语言代码第一件事不是画界面而是定义数据模型。标准玩法是10列×20行的网格每个格子有“有方块/无方块”两种状态。我习惯用一个二维数组uint8_t board[20][10]表示整个游戏区域为1的格子表示已被占用。方块本身用4×4的矩阵表示最直观。7种基础形状I、O、T、S、Z、J、L都可以放进一个4×4网格中有效格子填1其余为0。typedef struct { int8_t cell[4][4]; // 4x4 模式矩阵 uint8_t size; // 实际边长O方块是2I方块是4 } Tetromino; static const Tetromino SHAPES[7] { // I {{{0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0}}, 4}, // O —— 注意边长只有2旋转没有意义 {{{1,1,0,0},{1,1,0,0},{0,0,0,0},{0,0,0,0}}, 2}, // T {{{0,1,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}, 3}, // S {{{0,1,1,0},{1,1,0,0},{0,0,0,0},{0,0,0,0}}, 3}, // Z {{{1,1,0,0},{0,1,1,0},{0,0,0,0},{0,0,0,0}}, 3}, // J {{{1,0,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}, 3}, // L {{{0,0,1,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}, 3} };size字段决定了旋转时遍历的范围。O方块严格来说根本不需要旋转如果你对O方块做转置加翻转的变换结果虽然一样但会浪费CPU周期。常规做法是O方块直接跳过旋转函数I、T、S、Z、J、L这6种做同一套变换。C语言里矩阵旋转用转置加行翻转实现static void rotate_clockwise(int8_t m[4][4]) { int8_t tmp[4][4]; for (int r 0; r 4; r) for (int c 0; c 4; c) tmp[c][3 - r] m[r][c]; // 顺时针旋转的核心公式 memcpy(m, tmp, sizeof(tmp)); }转置公式tmp[c][3-r] m[r][c]的作用是把矩阵逆时针翻转90度这里的3-r是对4×4网格做水平翻转。如果只处理3×3的子矩阵就把3换成size-1。参数说明r是原矩阵行号c是列号tmp[c][3-r]表示旋转后第c行、第3-r列写入原值。这段代码的边界条件很关键size为4的I方块在旋转时只有中间两行有数据旋转后正好变成竖条不会越界。2.2 碰撞检测与消行先算状态再画屏幕碰撞检测分两步先判断是否出界再判断是否与已有方块重叠。出界判断包括左右越界和底部越界这里有个容易踩的坑方块矩阵的空白行是0不能把0当作实际占用格必须跳过。static int collides(const int8_t shape[4][4], int row, int col) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (!shape[r][c]) continue; // 不检查空格 int br row r; int bc col c; if (bc 0 || bc 10 || br 20) return 1; // 越界 if (br 0 board[br][bc]) return 1; // 与已落定方块重叠 } } return 0; }函数入参row是方块左上角在游戏区域中的行号col是列号。br和bc是将矩阵内的相对坐标转换为全局坐标。br 0的判断避免对负数行做数组访问否则你可能看到屏幕上莫名其妙闪一下。消行逻辑比碰撞更值得注意。常规写法是从下往上扫描每一行发现满行就执行“从该行开始逐行向下整体移动”。注意移动完成后要重新检查当前行因为上方掉下来的行也可能组成新满行。int clear_lines(void) { int cleared 0; for (int r 19; r 0; r--) { int full 1; for (int c 0; c 10; c) { if (!board[r][c]) { full 0; break; } } if (full) { for (int rr r; rr 0; rr--) memcpy(board[rr], board[rr - 1], 10); memset(board[0], 0, 10); cleared; r; // 重新检查当前行 } } return cleared; }消行的计分规则没有标准答案但常见做法是1行100分2行300分3行500分4行800分。等级每上升一级下落周期缩短一档。以下是我常用的参数表消行数得分等级下落周期(ms)11001800230026003500345048004300cleared的作用除了返回分数还要触发“一次消多行”的动画。执行消行时屏幕会同步刷新如果消行操作和绘制函数共享同一个缓冲区要在消行期间关闭定时器中断或者用标志位延迟重绘否则会出现残影。2.3 游戏主循环定时器驱动下落按键查询改变方向俄罗斯方块的状态机本质是“当前方块 游戏区域”每帧做三件事处理按键输入、尝试下落、检查是否需要锁定方块。我一般用定时器中断产生一个10ms的时基主循环里用计数器累加判断是否到达下落周期。volatile uint16_t tick_10ms; uint16_t drop_counter; void main(void) { // ... 初始化 GPIO、SPI、LCD、定时器 while (1) { uint8_t key key_scan(); // 读取按键 if (key KEY_LEFT) try_move(0, -1); if (key KEY_RIGHT) try_move(0, 1); if (key KEY_ROTATE) try_rotate(); if (key KEY_DOWN) try_move(1, 0); if (tick_10ms - drop_counter drop_interval) { drop_counter tick_10ms; if (!try_move(1, 0)) { // 下落失败则锁定 lock_piece(); int n clear_lines(); add_score(n); spawn_next(); } } } }try_move(1, 0)的第一个参数是行偏移第二个是列偏移。下落失败意味着当前方块撞到已落定的方块或底部这时执行lock_piece()把当前方块写进board然后消行、生成新方块。drop_interval就是上表中的下落周期按下硬降键时把它临时改小实现“快速落地”效果。3. 源程序怎么和设备配合SPI驱动ILI9341 LCD与按键扫描3.1 引脚分配表与SPI初始化源程序中除了游戏逻辑剩下的大部分代码都在和LM显示屏通信。C8T6的SPI1最高能跑到36MHz驱动ILI9341这类240×320的面板SPI时钟8MHz到12MHz就足够再高反而可能出现屏线串扰。关键引脚分配要提前定死否则后面画原理图对应不上信号MCU引脚说明LCD_SCKPA5SPI1_SCKLCD_MOSIPA7SPI1_MOSILCD_CSPA4片选低有效LCD_DCPA2数据/命令选择LCD_RSTPA3复位低有效LCD_BLPA8背光控制KEY_LEFTPA0左移KEY_RIGHTPA1右移KEY_ROTATEPA15旋转KEY_DOWNPB3加速下落BUZZERPB6可选提示音PA15和PB3在STM32F103上默认是JTAG调试引脚直接用会失败。在main初始化时严格来说需要先关闭JTAG保留SWD__HAL_AFIO_REMAP_SWJ_NOJTAG();SPI配置建议直接在CubeMX里生成。模式选Full-Duplex总是没错的数据宽度8位CPOL LowCPHA 1Edge。MISO悬空不接因为屏没有回读需求。初始化后写数据函数如下static void lcd_write(uint8_t type, uint16_t data) { HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, type); // type0命令, 1数据 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); uint8_t buf[2] { data 8, data 0xFF }; // 大端字节序 HAL_SPI_Transmit(hspi1, buf, 2, 10); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); }type参数决定DC引脚电平0表示当前传输的是命令字节1表示像素数据。ILI9341的命令和参数都是8位格式但颜色数据是16位RGB565必须拆成高字节先发的顺序。HAL_SPI_Transmit的最后一个参数10是超时毫秒数设为10足够如果经常超时说明总线有问题不要把它改成1000掩盖硬件故障。3.2 绘制流程填充色块与消除行的前后顺序屏幕刷新频率和游戏帧率是两回事。ILI9341刷新一次全屏大约需要几十毫秒如果每移动一格就全屏重绘一定闪得厉害。更好的做法是只重绘变化的区域。void draw_cell(int row, int col, uint16_t color) { int x COL_OFFSET col * CELL_SIZE; int y TOP_OFFSET row * CELL_SIZE; LCD_SetWindow(x, y, x CELL_SIZE - 1, y CELL_SIZE - 1); for (int i 0; i CELL_SIZE * CELL_SIZE; i) lcd_write(1, color); }LCD_SetWindow是ILI9341的特有指令通过设定X、Y坐标范围把后续像素数据连续写入该矩形区域。CELL_SIZE我取8这样显示区域是80×160刚好位于屏幕的上半部分居中下半部分留给分数和下一个方块预览。COL_OFFSET和TOP_OFFSET用于偏移居中这个值要在原理图时算好后面调试不需要改代码。消行时不建议逐格刷新而是让整行区域统一变色。做法是在clear_lines()后把消除的那一行整体设为背景色然后把上方所有行往下复制最后重绘整个游戏区域。这个操作在10ms定时器里做会卡顿我一般把它放到主循环里配合一个dirty标志位。3.3 按键输入内部上拉加10ms消抖注意JTAG复用引脚按键扫描我通常不用电平触发而是用“下降沿检测”。初始化GPIO时把模式设为输入上拉按键一端接地。这样不按键时读1按下读0。20ms消抖是基准因为机械抖动一般持续5~10ms20ms能覆盖绝大多数情况uint8_t key_scan(void) { uint8_t keys[5] {KEY_LEFT, KEY_RIGHT, KEY_ROTATE, KEY_DOWN, KEY_HARD_DROP}; for (int i 0; i 5; i) { GPIO_PinState s HAL_GPIO_ReadPin(key_pin[i].port, key_pin[i].pin); HAL_Delay(20); GPIO_PinState s2 HAL_GPIO_ReadPin(key_pin[i].port, key_pin[i].pin); if (s GPIO_PIN_RESET s2 GPIO_PIN_RESET) return keys[i]; // 两次均为低电平才认为按下 } return KEY_NONE; }这段代码有两个细节第一按键定义建议用struct { GPIO_TypeDef *port; uint16_t pin; }数组这样循环扫描不用写5遍重复代码第二HAL_Delay(20)在循环里会阻塞整个主循环如果你同时希望下落动画不卡顿就要把消抖时间缩短到10ms然后在主循环中限制按键最大判断频率。还有关于JTAG复用引脚前面提到PA15和PB3。如果你用ST-Link下载程序把PA15和PB3留给按键下载一次就必须按住复位键否则连接失败。解决办法就是先烧录一版空程序确认SWD可用后再焊接按键电路或者干脆把旋转键放到PB5避开这三个复用脚。4. 原理图设计STM32F103C8T6最小系统和LCD/按键接口4.1 最小系统原理图8MHz晶振、复位、BOOT0和电源引脚原理图的第一页永远是最小系统。网上搜“stm32f103c8t6原理图”能找到大量参考但有几处细节抄板子的时候特别容易翻车。第一个是晶振电路。8MHz晶振两侧的负载电容不是随便选的C8T6的晶振负载电容要求12pF到22pF我经验值是用两个22pF电容如果焊上去频率偏差超过5%换成18pF再试。元件参数用途Y18MHz 无源晶振主时钟源C1/C222pF配合晶振谐振R110kΩ复位下拉电阻R210kΩBOOT0下拉确保从Flash启动C3100nFNRST去耦复位电路是另一个高频出错点。STM32的NRST引脚内部已经集成了上拉电阻外部只要接一个10kΩ到3.3V和100nF到地即可。你可以加一个按键到地用于手动复位但注意按键按下时NRST会被拉低这时如果芯片正在向Flash写数据会直接写入失败。BOOT0必须通过一个10kΩ电阻下拉到地。直接悬空在某些批次芯片上能启动但量产时可能会随机失败而你在原理图阶段看不出来只有焊完批量测试时才会发现。BOOT1即PB2在C8T6上不需要处理保持悬空即可。4.2 LCD排座和按键接口背光限流与IO上拉LCD的连接方式取决于你买的是模块还是裸屏。模块一般自带排针或FPC座裸屏需要设计配套的PCB封装。这里以常见的2.4寸ILI9341模块为例它的接口是8根线VCC、GND、CS、RESET、DC、SDIMOSI、SCK、LED。其中LED是背光引脚模块上通常已经串联了限流电阻你可以直接接到3.3V或者用一个GPIO控制实现“屏幕休眠”功能。VCC引脚要注意很多模块标注是5V兼容但内部逻辑电平是3.3V。STM32的IO是3.3V驱动模块也工作在3.3VVCC接3.3V最稳。如果你把VCC接5VSPI逻辑电平就变成5VC8T6的IO会过压。按键电路更简单每个按键一端接IO另一端接地。前面代码里配置了内部上拉所以外部不需要再放上拉电阻。但有个例外如果你用的是开漏模式GPIO内部上拉失效必须在按键两端并联一个10kΩ到3.3V。蜂鸣器不能直接接IO。C8T6的IO最大灌电流约20mA无源蜂鸣器正常工作要30mA以上。常规做法是NPN三极管驱动PB6 ---- 1kΩ ---- 2N2222 Base Emitter ---- GND Collector ---- Buzzer负端 Buzzer正端 ---- 3.3V注意蜂鸣器是感性负载两点建议一是务必在蜂鸣器两端反向并联一个二极管比如1N4148否则关闭瞬间的反向电动势可能打坏三极管二是PB6初始化时默认输出低电平保证上电不响。4.3 电源输入AMS1117-3.3 的输入输出电容电源部分如果你的板子用USB供电5V最常用的是AMS1117-3.3线性稳压器。输入端接10μF电解电容加100nF陶瓷电容输出端接10μF钽电容或电解电容加100nF陶瓷电容。为什么两个电容都要大电容滤低频纹波小电容滤高频噪声缺一个在某些负载突变场景下会有电压跌落。C8T6的VDD引脚每对VDD1/VSS1、VDD2/VSS2、VDD3/VSS3都要就近放一个100nF去耦电容。这里说的“就近”是物理距离不超过3mm。三对引脚共用3.3V但电容不能共用因为每个引脚的高频电流路径必须独立。原理图画法和PCB布局直接影响EMC这一点在仿真和串口调试时看不出来只有过认证或高低温测试时才会暴露。VDDA引脚接3.3V滤波后电压通过一个10Ω电阻加10nF电容到地。这个引脚给内部ADC模块供电如果你后续想用ADC做音量控制或随机种子VDDA不干净会直接体现在采样值上。5. PCB设计从原理图到打样的布线规则和技巧5.1 布局顺序先定屏排座和按键位置再放MCUPCB布局的最终目标是什么是把“功能连线”变成“物理可安装”。带着源程序的引脚分配表去布局你会发现很多不合理的地方比如把按键放在LCD排座背面按下去正好顶住屏幕又比如把MCU放中间SPI走线绕一圈再到排座。我的习惯顺序是先放LCD排座定在板子一端旁边留出至少3mm的屏幕安装空间再放5个按键放在板子另一端或侧边按键中心间距不小于7.5mm方便手指操作MCU放中间偏屏幕一侧让SPI信号线最短电源电路放在电源输入座旁边距MCU位置适中即可最后排复位按键、下载端口SWD 4pin排针、调试串口相邻按键之间的地铜皮不要挖空保持连续地平面对减少按键抖动和ESD干扰都有帮助。如果按键下方必须走线要加地线隔离。5.2 双层板布线参数线宽、间距、过孔、铺铜STM32F103C8T6搭配TFT屏双面敷铜通常叫双层板完全够用没必要上四层。以下是我在嘉立创打样时常用的设计规则这些参数兼顾了过孔成本、可靠性和布线难度项目最小/推荐值说明信号线宽8mil (0.2mm)SPI数据线、按键线电源线宽15mil (0.4mm)3.3V、5V走线线间距8mil (0.2mm)安全间距高压勿参考过孔外径24mil/孔径12mil普通板厂都能加工地铜皮整面铺地网格或实心均可丝印字宽5mil以上太细看不清SPI走线的核心不是“看起来整齐”而是控制走线长度和回流路径。SCK和MOSI两根线要尽量平行且靠近避免形成大的回路面积。DC和CS可以稍微绕一下但不要跨过晶振。晶振下面那一层禁止铺铜否则寄生电容会把频率拉偏几百KHz串口通信会偶发乱码。电源走线有个技巧3.3V从LDO出来之后先经过一个10μF电容的位置再分发给MCU和LCD。这个电容要放在LDO输出引脚旁边中间不要有长走线。MCU每个VDD引脚的100nF去耦电容要放在对应VDD引脚正下方过孔直接连接到地平面。5.3 DRC检查与Gerber导出让板厂一次看懂画完布线别急着导出Gerber先跑两遍DRC。第一遍对着原理图做电气规则检查看有没有悬空引脚、短路、未连接的飞线。第二遍对着PCB加工能力做物理规则检查最小线宽、最小间距、过孔孔径、丝印距焊盘距离都要覆盖。最容易漏检的是丝印压在焊盘上。板厂会在生产时报“丝印开窗矛盾”轻则帮你自动删丝印重则要求重新提交。我一般建一个丝印规则丝印到焊盘边缘距离≥8mil。Gerber导出时注意两点一是选择RS-274-X格式这是所有板厂的通用格式二是钻孔文件要单独导出默认的“Excellon钻孔文件”选项要勾上。导出之后用CAM350或嘉立创EDA自带的Gerber预览功能打开一眼确认层数正确、没有缺丝印再提交打样。6. 成品级体验消行动画、随机块和下落加速的配套实现游戏能跑和“玩起来舒服”是两码事。最后一个环节我从三个最常被问到的地方入手把体验补上来。第一个是硬降。按住下方向键不只是让方块快一点掉而是每帧执行一次下移。实现时把drop_interval临时改为20ms松开后恢复原等级周期。注意要在按键释放时恢复变量否则会把该等级的节奏一直带到下一级。if (key KEY_HARD_DROP || key KEY_DOWN) { if (key KEY_HARD_DROP) drop_interval 20; else drop_interval FAST_DROP_INTERVAL; // 例如50ms } else { drop_interval level_interval[level]; }第二个是随机块。很多人直接用C库的rand()它默认的种子是1于是每次上电后出现的方块顺序都一样。用ADC读取浮空引脚的值做种子或者直接用系统滴答计数器的低16位srand(tick_10ms ^ 0xA5A5); int next rand() % 7;如果想让玩家觉得“随机”还要避免同一个形状连续出现三次以上。简单做法记录上次和上上次的形状索引如果抽到三次相同则强制换下一个。这个规则不需要很复杂体验提升却非常明显。第三个是消行动画。消行瞬间整行变白再消失观感比直接消失好太多。实现方案是在clear_lines()返回后画一行白色HAL_Delay(30)再把后面所有行下移。注意这个延时不长30ms不会让人觉得卡但足够产生视觉残留。最后说一个验证技巧把游戏区域的大小临时改成5×5手动填充指定格子跑消行逻辑观察计数和得分。这个测试在源程序调试阶段比任何仿真都直观。测试完记得恢复尺寸。这样一套做下来你已经不是在“跑Demo”而是有一个逻辑、硬件、体验都可控的完整作品了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价