资讯动态

RT-Thread实战:用贪吃蛇和俄罗斯方块吃透RTOS核心机制

发布时间:2026/10/14 2:17:30 来源:尧图企业网站定制
1. 为什么在RT-Thread上做小游戏从跑马灯到玩法逻辑把一个跑着RT-Thread的开发板从点亮LED、打印日志的水平推到能玩贪吃蛇和俄罗斯方块的程度这个过程会逼你把线程、消息队列、时钟节拍、内存管理这些概念全部实打实用一遍。很多初学者学RTOS文档看得很明白一动手就卡住线程到底该建几个按键抖动怎么处理两段逻辑同时访问同一个变量怎么办小游戏恰恰把这些矛盾集中暴露了出来而且做完之后成就感特别直接——你写的系统终于能玩了。我最早是在一块Cortex-M3内核的小开发板上做的这个项目屏幕用的是SPI接口的小尺寸TFT屏。动手之前我也犹豫担心MCU性能不够、显存不够实际做完发现这类游戏逻辑其实很轻真正费心思的是数据结构、显示刷新策略和线程之间怎么传数据。这篇文章就把贪吃蛇、俄罗斯方块的实现思路、线程分工、以及我踩过的坑完整过一遍适合刚把RT-Thread跑起来、想找个有点挑战但又不至于劝退的项目练手的开发者。1.1 小游戏是把RTOS知识串起来的最好练习题如果你只是按照例程把两个线程跑起来、打印几句日志你对RTOS的理解其实还停留在会用API的层面。做小游戏之后你才会发现一个看起来简单的游戏循环背后藏着好几组需要认真权衡的问题按键是中断扫描还是轮询扫描扫描出来的信号怎么变成游戏能理解的事件游戏画面多久刷新一次刷太快刷屏跟不上刷太慢操作有延迟感这个帧周期怎么定玩家在一帧之内连按了好几个方向游戏怎么处理才不会出现直接掉头撞死这种反直觉操作游戏状态被按键改了一半另一半逻辑正好读到会不会出问题这些问题的答案并不是RTOS给我提供了某个函数所以我调一下就行而是需要你根据游戏的实时性要求自己设计一套分工。贪吃蛇和俄罗斯方块恰好是两种典型一个强调方向变化的连续性一个强调状态切换和矩阵运算两个都做完你对线程化游戏逻辑的把握会扎实很多。1.2 两个游戏把RT-Thread哪些能力用满了整套项目里RT-Thread的组件我用得并不花哨但每个都用到了关键位置上RT-Thread能力在本项目中的实际用途线程调度器划分输入扫描线程和游戏主线程按优先级运行消息队列按键事件从扫描线程传到游戏线程解决谁在什么时候改数据的同步问题时钟节拍/定时器控制蛇的步进周期、方块下落周期避免用循环空转计时设备驱动框架统一操作GPIO按键和SPI屏幕把应用逻辑和硬件解耦FinSH控制台加调试命令复位游戏、生成食物、查看棋盘状态静态/动态内存管理线程栈、队列缓冲区的大小权衡这里要特别说一句我这个项目没有上Lua、没有上轻量级GUI框架渲染就是自己往屏上画格子。很多人一听说在RT-Thread上做游戏就以为必须配一套图形库其实对这种单屏小游戏自己的渲染代码往往更可控、更省内存排查问题也更快。2. 显示和输入方案选型先决定游戏跑在哪个屏幕上动手写游戏逻辑之前我建议先把游戏显示在哪、用什么操作定下来。这不是可后补的事情因为它直接决定了你的帧缓冲怎么做、按键怎么读。2.1 三种显示输出怎么选我见过不少人在显示方案上纠结半年其实就三条路各有各的适用场景方案成本优点缺点适合场景串口终端显示最低调试方便逻辑验证最快画面体验一般刷新一屏要发大量字符先跑通游戏逻辑单色OLED如128x64低帧缓冲小刷屏压力小功耗低只有单色俄罗斯方块观感一般贪吃蛇、简单UISPI接口小TFT中等彩色格子能涂色观感最好色彩像素多显存和刷屏要精心设计两个游戏的最终形态我实际做的时候是先走串口再上TFT。串口版用字符画格子比如把蛇身画成#、食物画成逻辑全部验证没问题之后再做显示层这样调试成本低得多。等逻辑稳定了再往TFT上搬搬运过程中还能顺手把逻辑层的结构问题暴露出来。2.2 帧缓冲和局部刷新小内存设备的必修课这一步是能不能流畅跑起来的关键。单色OLED的帧缓冲很小128x64一共才1KB整屏刷完全没压力所以可以用最简单的整屏重绘每次游戏状态变了就重算一帧写进显存再一次性刷出去。但TFT就不一样了一块320x240的屏RGB565格式一帧要150KB绝大多数MCU根本塞不下整屏的帧缓冲。这时候一定要放弃整屏重绘改成按游戏格子刷新。我的做法是把游戏盘面抽象成一张逻辑格子图比如贪吃蛇用20列x15行每个格子映射到屏幕上的一个16x16像素区域。每次逻辑更新后只把状态发生变化的格子重绘一遍没变的格子一个像素都不碰。这样一来一帧最多只画蛇头、蛇尾、食物这几个格子SPI刷屏的数据量从150KB降到了几百字节帧率一下就上去了。这里有一个经验刷TFT屏的时候尽量把设置显示窗口连续写像素做成一个原子操作不要每画几个像素就重新发一次命令。屏幕驱动芯片对连续窗口写是有优化的窗口设置得越少SPI总线的利用率越高画面就越稳。2.3 按键输入事件化而不是电平化按键扫描如果只做读到高/低电平就立刻处理你会发现两个问题一是机械按键抖动会让一次按下变成好几次触发二是游戏逻辑正在更新画面时按键状态突变容易产生半帧状态。所以我的建议是把电平转成事件游戏里只认事件不认电平。比如贪吃蛇需要四个方向加暂停俄罗斯方块需要左移、右移、软降、旋转、硬降、暂停。扫描线程每隔10~20ms读一次GPIO经过消抖后只把某个键从松开变按下这件事打包成一个事件发给游戏线程。事件结构也很简单struct key_event { uint8_t code; /* KEY_UP / KEY_DOWN / KEY_LEFT / KEY_RIGHT / KEY_ROTATE ... */ uint8_t press; /* 1 按下沿0 松开沿 */ };这样游戏线程在处理事件时永远面对的是一瞬间发生了什么而不是现在哪个键是高的。后面讲到线程分工时你会看到这个设计让代码干净了多少。3. 贪吃蛇逻辑拆解方向队列、步进移动与碰撞判定贪吃蛇看起来简单真写起来最容易翻车的反而是方向控制和移动步进这两件事。这块我展开说。3.1 蛇身的数据结构与方向防反蛇身我用的是定长数组而不是链表。原因很简单MCU上动态分配链表节点既费堆又难调试而一条蛇的长度上限是已知的直接用数组加长度字段就够了。#define BOARD_W 20 #define BOARD_H 15 #define SNAKE_MAX_LEN (BOARD_W * BOARD_H) struct snake_node { uint8_t x; uint8_t y; }; struct snake { struct snake_node body[SNAKE_MAX_LEN]; /* body[0]是蛇头 */ uint16_t len; uint8_t dir; /* 0上 1右 2下 3左 */ uint8_t dir_queue[4]; /* 方向缓冲队列 */ uint8_t dir_head, dir_tail; };方向用数字表示配合一个方向增量数组代码会很紧凑static const int8_t dir_dx[4] {0, 1, 0, -1}; static const int8_t dir_dy[4] {-1, 0, 1, 0};方向防反是贪吃蛇最容易出现的低级bug。玩家在一帧时间内快速按了上和左如果处理不及时下一帧可能会从右直接变成左蛇原地掉头撞死自己。解决方法是在按键处理时判断新方向和当前有效方向不能是相反方向。static int direction_valid(uint8_t new_dir, uint8_t cur_dir) { /* 0上 1右 2下 3左相反成对(0,2) (1,3) */ if ((cur_dir 0 new_dir 2) || (cur_dir 2 new_dir 0) || (cur_dir 1 new_dir 3) || (cur_dir 3 new_dir 1)) return 0; return 1; }注意这个校验的基准是当前帧已经生效的方向而不是玩家上一个按键方向。我见过有人拿上一次按键去校验新按键结果连续快速按上、左、下时仍然出现掉头。把按键放进一个4深度的小队列每帧最多消费两个方向就能在体验上保持跟手又不会掉头。3.2 一步移动里发生了三件事贪吃蛇的每一帧移动只需要做三件事算新蛇头位置、判断是否吃到食物、更新蛇身数组。static void snake_tick(void) { uint16_t nx snake.body[0].x dir_dx[snake.dir]; uint16_t ny snake.body[0].y dir_dy[snake.dir]; int eat (nx food.x ny food.y); /* 吃到食物先加长度这样移位时旧尾巴会被保留 */ if (eat) { snake.len; score 10; } /* 整条蛇往后挪一格 */ for (uint16_t i snake.len - 1; i 0; i--) snake.body[i] snake.body[i - 1]; snake.body[0].x nx; snake.body[0].y ny; if (eat) place_food(); }这里的核心逻辑是移位覆盖天然实现收尾没吃到食物时数组整体后移一格body[len-1]就被丢掉了这正是蛇尾向前缩进的效果吃到食物时先len再移位旧尾巴就被多留了一格蛇变长。place_food生成食物时要注意效率。最简单的写法是随机坐标后判断是否被蛇身占用不行就重试。这个逻辑在小棋盘上完全够用但要加一个重试上限避免蛇快占满整个棋盘时随机数一直在碰撞static void place_food(void) { uint16_t tries 0; do { food.x (uint16_t)(rand() % BOARD_W); food.y (uint16_t)(rand() % BOARD_H); tries; } while (cell_occupied(food.x, food.y) tries 200); if (tries 200) { /* 随机找不到空位顺序扫一个 */ for (int y 0; y BOARD_H; y) for (int x 0; x BOARD_W; x) if (!cell_occupied(x, y)) { food.x x; food.y y; return; } game_over 1; /* 全满了 */ } }3.3 碰撞判定墙、身体和自己尾巴的边界碰撞判定放在移动之后做这点很关键。墙和身体都简单唯一容易错的是蛇头能不能走进尾巴当前占的格子。经典贪吃蛇的规则是不进食时尾巴会同步向前挪所以蛇头走进尾巴原本的位置是安全的。如果在移动之前就做自碰检测这个安全动作会被误判成死亡但如果先完成移位再检查身体数组中是否有节点和蛇头重合那么旧尾巴已经在移位中被挪走蛇头落进那个格子就不会被误报。static int check_collision(void) { uint8_t hx snake.body[0].x; uint8_t hy snake.body[0].y; /* 撞墙 */ if (hx BOARD_W || hy BOARD_H) return 1; /* 撞自己从body[1]开始查 */ for (uint16_t i 1; i snake.len; i) if (snake.body[i].x hx snake.body[i].y hy) return 1; return 0; }这个先移动再判定的顺序写代码时一定要刻意记住否则很可能成为只在某一个操作方向下偶现的疑难bug。3.4 用绝对节拍控制移动频率蛇的移动速度不能靠每循环跑一次逻辑决定得按时间节拍走。节拍周期还要随得分加快比如基础周期200ms每吃5个食物减少10ms最少到80ms。计时方式我推荐绝对截止时刻而不是简单累加延时。简单累加的写法是每帧rt_thread_mdelay(period)但一帧逻辑和渲染多花的时间会不断累积蛇会越走越快。正确做法是用绝对截止时刻rt_tick_t due rt_tick_get(); rt_tick_t period rt_tick_from_millisecond(move_ms); while (1) { rt_tick_t now rt_tick_get(); if (now due) { game_tick(); render(); due now period; } /* 剩下多少时间就等多少这段时间用来等按键事件 */ rt_tick_t timeout (due now) ? (due - now) : 1; if (rt_mq_recv(key_queue, evt, sizeof(evt), timeout) RT_EOK) { handle_key_event(evt); } }这个循环既是贪吃蛇的主循环也可以直接改造成俄罗斯方块的主循环只是game_tick里做的事不一样。4. 俄罗斯方块算法拆解四乘四矩阵、旋转与消行判定俄罗斯方块比贪吃蛇复杂的地方在于矩阵运算方块要旋转、要落定、还要判断整行满不满。但只要建模方式选对了代码量并不大。4.1 七种方块的建模约定我用的是4x4矩阵每个方块用一个4x4的二维数组表示1代表有格子0代表空static const uint8_t blocks[7][4][4] { /* I */ {{0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0}}, /* O */ {{0,0,0,0},{0,1,1,0},{0,1,1,0},{0,0,0,0}}, /* T */ {{0,0,0,0},{0,1,0,0},{1,1,1,0},{0,0,0,0}}, /* S */ {{0,0,0,0},{0,1,1,0},{1,1,0,0},{0,0,0,0}}, /* Z */ {{0,0,0,0},{1,1,0,0},{0,1,1,0},{0,0,0,0}}, /* J */ {{0,0,0,0},{1,0,0,0},{1,1,1,0},{0,0,0,0}}, /* L */ {{0,0,0,0},{0,0,1,0},{1,1,1,0},{0,0,0,0}}, };用4x4而不是更省内存的坐标点列表主要是为了旋转算法好写。旋转相当于一次矩阵转置加一次行反转代码极其简洁。内存上4x4的uint8_t数组才16字节七种一共112字节完全不是问题。4.2 旋转、碰撞检测与墙踢顺时针旋转的通用写法是这样static void rotate_cw(const uint8_t in[4][4], uint8_t out[4][4]) { for (int i 0; i 4; i) for (int j 0; j 4; j) out[j][3 - i] in[i][j]; }旋转本身很简单麻烦的是旋转后可能撞墙或者和已锁定方块重叠。所以任何移动、旋转操作都要经过统一的碰撞检测static int piece_collides(const uint8_t shape[4][4], int px, int py) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (!shape[i][j]) continue; int bx px j; int by py i; if (bx 0 || bx BOARD_W || by BOARD_H) return 1; if (board[by][bx]) return 1; /* 与已锁定的方块重叠 */ } } return 0; }碰撞检测通过才允许落地这是俄罗斯方块所有操作的安全阀。旋转被墙挡住时还可以做简单的墙踢尝试左移一格、右移一格再检查碰撞不行才放弃旋转。static int try_rotate(void) { uint8_t rotated[4][4]; rotate_cw(current_shape, rotated); const int8_t kick[5] {0, -1, 1, -2, 2}; for (int i 0; i 5; i) { int nx cur_x kick[i]; if (!piece_collides(rotated, nx, cur_y)) { memcpy(current_shape, rotated, sizeof(current_shape)); cur_x nx; return 1; } } return 0; }很多新手做俄罗斯方块时会把旋转逻辑写得又臭又长就是因为没有先做矩阵建模再统一碰撞检测。验证过这个方案之后你会发现旋转、左右移、下移、硬降全都复用同一个piece_collides。4.3 锁定、消行与计分加速方块没法继续往下走的瞬间要把当前方块写入棋盘然后检查消行。棋盘我建议直接存方块类型编号0表示空1~7表示不同颜色这样TFT渲染时可以按编号上色不用再额外维护颜色表。static void lock_piece(void) { for (int i 0; i 4; i) for (int j 0; j 4; j) if (current_shape[i][j]) board[cur_y i][cur_x j] current_type; int lines clear_lines(); if (lines) score score_table[lines - 1] * level; }消行判定从底往上扫遇到满行就把这一行之上的所有行整体下移一格然后顶行补零。注意下移之后要重新检查当前行位置因为原位置已经被上面的行填满了static int clear_lines(void) { int cleared 0; for (int y BOARD_H - 1; y 0; y--) { int full 1; for (int x 0; x BOARD_W; x) if (!board[y][x]) { full 0; break; } if (full) { for (int yy y; yy 0; yy--) for (int x 0; x BOARD_W; x) board[yy][x] board[yy - 1][x]; for (int x 0; x BOARD_W; x) board[0][x] 0; y; /* 重新检查这一行 */ cleared; } } return cleared; }计分我采用的是经典规则一次消多行的分数是递增的一次消行数得分1100 x 等级2300 x 等级3500 x 等级4800 x 等级等级每消10行升一级下落周期从1000ms起步每级缩短约80ms下限100ms。等级越高方块下落越快游戏手感就出来了。5. 线程分工与事件通信按键扫描、消息队列与游戏主循环线程怎么分是RTOS项目里最值得先想清楚的一件事。我的方案是两个线程输入扫描线程负责读按键游戏线程负责逻辑和渲染。这个划分看起来简单里面有不少设计取舍。5.1 两个线程的分工依据为什么不把按键扫描放到游戏线程里因为游戏线程的节奏由帧周期决定在线程内部去轮询按键会让按键响应时间变得不确定。玩家按键到游戏响应的延迟最坏情况下会叠加一个完整帧周期手感很钝。独立扫描线程的好处是按键轮询节奏稳定可以每10ms扫一次20ms消抖按键事件及时进队列。在优先级上我让扫描线程略高于游戏线程这样按键不会被低优先级的渲染卡住太久。至于为什么不把渲染也拆成一个独立线程是因为单核MCU上逻辑线程和渲染线程并行没有任何好处反而要引入复杂的帧同步。渲染是游戏循环的一部分跟着逻辑走就够了。5.2 用消息队列而不是全局变量传按键按键事件如果用一个全局变量传递会面临典型的竞态问题游戏线程正在读方向值时扫描线程刚好写了一个新方向读到的可能是个半新半旧的值。虽然单字节变量在MCU上大概率不会撕裂但谁都不能保证代码以后不会被改成多字节字段。消息队列把这个问题的解决方式变成了数据复制扫描线程只负责入队游戏线程只负责出队两边永远不会直接操作同一个游戏状态变量。RT-Thread的消息队列API用起来也很直接rt_mq_t key_queue; struct key_event { uint8_t code; uint8_t press; }; key_queue rt_mq_create(keyq, sizeof(struct key_event), 8, RT_IPC_FLAG_PRIO);初始化只做一次不要放在游戏重启逻辑里反复创建。如果介意动态内存也可以用rt_mq_init配一块静态缓冲区效果一样。队列深度我设过8实测足够游戏线程每帧消费事件的速度很快队列很少会满。5.3 单线程游戏循环为什么省心这里要分享一个我踩过之后很受益的经验千万不要轻易在游戏逻辑上加锁。刚学RTOS时很多人一听到共享数据就条件反射地想到互斥锁。但在小游戏项目里更好的方案是设计成只有游戏线程会写游戏状态。扫描线程只往队列里放按键事件根本碰不到蛇身、棋盘这些变量。既然只有一个线程在操作游戏状态锁自然就不需要了。如果非要在扫描线程里直接改游戏状态比如按下暂停键就立刻暂停那就得引入rt_mutex或者临界区保护而且还要小心别在持锁状态下调用可能阻塞的函数否则容易出现优先级反转。走消息队列路线后代码里几乎不需要这些同步原语排查问题的难度直接下降一个数量级。6. 实测踩坑与调优闪烁、内存、随机数与调试技巧这部分全是实际操作中冒出来的问题。我把印象最深的几个坑和解决办法列出来每一个都是先用错误方式跑过、再修正的。6.1 画面闪烁的根源与刷新优化项目初期我用TFT做了一个最简单粗暴的渲染每帧先把整个显示区域清成背景色再从头画一遍所有格子。结果就是画面明显闪烁尤其是蛇移动快的时候能看到一整条残影带在屏幕底部滚动。闪烁的根源不是什么硬件不行而是清屏重绘这个动作把SPI刷屏的数据量放大了好几倍。修改方法分两步第一取消整屏清屏改成记录上一帧可见的格子集合只对状态变化的格子重绘。蛇身除了头和尾中间大部分格子每帧都不变全重绘就是浪费。第二清掉一个格子时不要写背景色而是把该格子对应的像素窗口整体填充一次背景。设置一次显示窗口连续写几十个像素SPI效率远高于逐像素发命令。如果是OLED这类带全屏帧缓冲的设备做法就更简单了在内存里维护一帧Framebuffer逻辑更新后只改对应位置的字节再把整帧一次性刷出。128x64的显存才1KB双缓冲对RAM的压力也不大。6.2 小内存设备上的内存与栈取舍这个项目里最容易踩的坑是内存看起来够用跑一会儿就崩。主要原因是动态内存碎片和线程栈溢出。我的建议是第一优先把大对象全部静态化。蛇身数组、棋盘数组、方块矩阵这些都用全局变量或者static变量分配不要放在栈上更不要在运行期反复malloc。栈空间留给函数调用和局部小变量安全得多。创建线程时栈大小也别随便给。贪吃蛇和俄罗斯方块的逻辑渲染函数链并不深但如果你在渲染层里定义了比较大的局部数组栈就会悄悄膨胀。RT-Thread的FinSH里可以直接查看线程栈使用情况命令是list_thread能看到每个线程当前栈使用峰值。我给游戏线程调了两次栈大小从1KB调到2KB才稳定就是因为渲染函数里临时变量太多。另外一个很容易被忽略的点游戏结束后如果要重新开始不要杀掉线程再重新创建。反复创建线程会导致系统动态堆碎片越来越多长期跑必出问题。正确做法是游戏线程常驻用事件或标志触发reset逻辑把蛇身、棋盘的初始状态重新赋值即可。6.3 随机数、按键消抖和FinSH调试随机数种子是食物位置随机性的关键。MCU上最常见的错误是rand()之后不设置种子结果是每次上电的食物生成序列完全一样。我用的种子是开机时读取一个悬空ADC通道的噪声混上rt_tick_get()当前值srand((uint32_t)rt_tick_get() ^ adc_read_noise_channel());实际效果足够了。后面再想加变化可以在每次吃食物时再混一次时间戳。按键消抖我前面说过原则这里给一个具体的状态机思路。扫描线程每10ms读一次原始键值维护三个变量上次原始值、候选值、稳定值。只有原始值变化后持续20ms仍然保持新值才更新稳定值并在稳定值变化时生成边沿事件。这个20ms阈值对机械按键很合适太快滤不掉抖动太慢又会让快速连击失效。最后强烈推荐多用FinSH。游戏里我加了三个自定义调试命令snake_reset重置一局food_gen手动在指定位置生成食物board_dump把当前棋盘以字符形式打印出来。这些命令在排查这个位置为什么算撞墙这个方块为什么落不到底时特别有用。等逻辑稳定之后再把影响体验的调试输出关掉。尤其是rt_kprintf千万别放在每帧渲染路径里115200波特率的UART打印一帧几十个字符直接能把帧率拖到肉眼可见的卡顿。7. 给也想动手的人我的迭代顺序建议这套项目做完我最大的体会是顺序比速度重要能运行的最小版本比完美架构重要。这里分享一下我推荐的做法和最后的几点建议。7.1 从串口版贪吃蛇起步第一个目标不要定成彩色TFT上丝滑运行俄罗斯方块而是串口终端上能玩一盘贪吃蛇。用rt_kprintf把20x15的棋盘按字符画出来一瓶水的时间就能跑通。这一阶段把方向队列、碰撞判定、食物生成这些纯逻辑调对后面所有工作都只是换一个显示层。7.2 把渲染接口剥离出来在串口版里就要有意识地写成逻辑层只改格子状态渲染层只读格子状态。我定义了一个最简接口static void draw_cell(uint8_t cx, uint8_t cy, uint32_t color);串口版把draw_cell实现成往字符缓冲区写一个符号TFT版实现成按格子坐标画一个16x16色块OLED版实现成按位操作改一帧显存。三个版本的逻辑层代码一字不改这种苦一次、爽三次的抽象非常值得做。7.3 旋转逻辑一定要先打印验证俄罗斯方块的旋转是整项目里最容易写错的地方。我第一次写rotate_cw时自认为转置和反转顺序是对的结果S、Z两种方块转完形状就变了。后来我把七种方块全部通过FinSH命令打印出来逐个检查旋转前后坐标映射才发现问题出在行反转还是列反转搞反了。调试这类矩阵运算不要用眼睛在代码里猜直接在控制台打印[0][1][0][0] [0][1][0][0] [0][1][1][0] [0][0][0][0]转一次打一次肉眼确认无误再接进游戏循环。这个习惯帮我省下了至少半天排查时间。最后再分享一个小技巧这两个游戏的状态都适合用帧号来驱动动画或特效比如吃到食物后让食物格子闪烁两帧、消行时让整行反色一帧。因为游戏循环本来就是按帧走的顺手维护一个frame_count就能做出不少有手感的细节而代码量几乎可以忽略。

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

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

免费获取报价 →
↑