简介:基于RT-Thread RTOS在STM32F103微控制器上实现贪吃蛇与俄罗斯方块两款经典小游戏的完整工程资源,面向嵌入式初学者和RTOS开发者,展示了如何使用RT-Thread Nano的任务调度、信号量、消息队列与定时器服务,在资源有限的MCU上组织游戏逻辑、用户输入和图形渲染。压缩包内共413个文件,整体13.89MB,以C源文件(84个)、头文件(64个)、汇编启动文件及IAR工程配置为主,另有pbi、dep、ewp等工具链辅助文件,可直接导入IAR或Keil配套环境进行编译与下载验证。目前已有525人学习该资源,适合作为RT-Thread多线程编程的实战参考。通过阅读源码可理解贪吃蛇的移动与边界检测、俄罗斯方块的生成旋转与消行计分,以及如何利用消息队列和信号量保证多任务间数据一致,对于想在嵌入式平台实现轻量级GUI游戏或学习实时操作系统设计的开发者,是一份结构清晰、可直接运行的参考样例。
1. 从点灯到游戏:STM32F103 怎么塞进 RT-Thread Nano 和两套小游戏
手上这块 STM32F103C8T6 最小系统板,以前从没干过“正事”,顶多是点个灯、跑个串口回显。最近想让它同时跑贪吃蛇和俄罗斯方块,换掉傻乎乎的裸机逻辑,却发现裸机超级循环下写两个游戏特别拧巴:按键要轮询、屏幕刷新要排队、两个游戏共用一个显示入口就要到处加状态判断。后来把 RT-Thread Nano 移植进去,用三个线程把“按键输入、游戏逻辑、屏幕渲染”彻底拆开,问题一下子理顺了。这篇文章就是一次完整的拆解记录:从 Nano 裁剪、线程规划、双缓冲 UI,到俄罗斯方块旋转和贪吃蛇碰撞检测,再到我实际调试中踩过的五个坑。适合手里有 103 小板子、想入门 RTOS,又不想被完整版组件淹没的开发者。
2. 移植 RT-Thread Nano:裁剪边界、启动顺序与线程栈的“黄金配比”
2.1 为什么是 Nano 而不是完整版 RT-Thread
STM32F103C8T6 最让人没底的不是 Flash,是只有 20KB 的 SRAM。完整版 RT-Thread 默认带设备框架、FinSH 控制台、虚拟文件系统,一套默认配置还没写应用代码,RAM 就没了大半。Nano 不一样,它保留了内核最核心的部分:线程管理、调度器、信号量、互斥量、消息队列、软件定时器,设备框架和 shell 都是可以甩掉的部分。对小游戏这个场景,这些内核原语刚好够用,而且 Nano 的源码很干净,没有大段条件编译陷阱。
选 Nano 的另一个原因是抢占式调度。裸机超级循环里,按键消抖用 delay 会让画面出现停顿,游戏逻辑和屏幕刷新挤在一个 while(1) 里相互拖累。换成 Nano 之后,三个线程各管一段,UI 刷新线程哪怕因为 I2C 等待阻塞,也不会卡住游戏逻辑线程。线程优先级可以手工设定,输入线程最高优先,逻辑线程稍低,刷新线程最低,这样按键响应始终快于画面刷新。
裁剪时我一般只打开这几个功能:信号量、互斥量、消息队列、动态内存。小游戏里没有文件操作和网络需求,见到的“死机”问题也不会来自组件配置错。具体做法是在 rtconfig.h 里做宏开关,我这份工程的核心配置如下:
#define RT_THREAD_PRIORITY_MAX 8 #define RT_TICK_PER_SECOND 1000 #define RT_USING_SEMAPHORE #define RT_USING_MUTEX #define RT_USING_MESSAGEQUEUE #define RT_USING_HEAP #define RT_USING_TIMER_SOFT /* 软件定时器,做超时判断很方便 */这里 RT_THREAD_PRIORITY_MAX 我压到 8,因为这场游戏只需要 4 个线程。优先级数量越少,调度器查就绪表越快,RAM 上的线程控制块也会更小。RT_TICK_PER_SECOND 设为 1000,也就是一个 tick 等于 1ms,这个粒度对做 50ms 的下落节拍、16ms 的刷新间隔都足够。软定时器我也开了,后面做“吃到食物后加速”这种延迟特效时会省力一点。
2.2 从裸机工程到 Nano:启动顺序必须自己接好
假设你手上已经有一个能点灯的标准外设库工程。把 Nano 内核源码加入工程之后,最关键的三个文件是 board.c、application.c、rtconfig.h。Nano 不像完整版那样有自动初始化,它只负责把内核源码摆好,具体板级初始化要你自己在 main 里调用。
我习惯的启动顺序是这样:
int main(void) { rt_hw_board_init(); /* 初始化时钟、SysTick */ rt_system_timer_init(); /* 内核定时器 */ rt_system_scheduler_init(); /* 调度器初始化 */ application_init(); /* 创建线程、信号量、消息队列 */ rt_system_scheduler_start(); /* 启动调度,之后不会返回 */ /* 执行到这里说明调度器没起来,检查中断向量和堆栈 */ while (1); }其中 rt_hw_board_init 一定是从你自己写的 board.c 里来的。这块我在最开始就踩过坑:Nano 官方模板里的 board.c 是为顺手的开发板写的,如果直接用,SysTick 中断不会进 rt_tick_increase(),整个内核时间戳就是死的。检查方法很简单,在调试器里打断点看 rt_tick 变量有没有增长。没有增长就说明 SysTick_Handler 没接上。标准外设库工程里要这样补:
void SysTick_Handler(void) { rt_tick_increase(); }SysTick_Handler 已经在启动文件里声明为向量,向量表不用额外修改。但要确认 SysTick_Config 被调用过,并且配置为 1ms 周期。如果芯片的主频不是 72MHz,SysTick_Config 的参数还要重新算,否则 tick 周期会漂。
线程创建我放在 application_init 里,三个线程外加一个主线程:
#define UI_STACK_SIZE 2048 #define LOGIC_STACK_SIZE 1024 #define INPUT_STACK_SIZE 512 static void ui_entry(void *param) { while (1) { fb_show(); /* 把后台缓冲区刷到 SSD1306 */ rt_thread_mdelay(16); /* 约 60fps 的刷新周期 */ } } static void logic_entry(void *param) { while (1) { game_tick(); /* 游戏逻辑节拍 */ rt_thread_mdelay(10); /* 10ms 查一次,速度由积分器控制 */ } } void application_init(void) { rt_thread_t tid; tid = rt_thread_create("ui", ui_entry, RT_NULL, UI_STACK_SIZE, 2, 16); if (tid) rt_thread_startup(tid); tid = rt_thread_create("logic", logic_entry, RT_NULL, LOGIC_STACK_SIZE, 3, 16); if (tid) rt_thread_startup(tid); tid = rt_thread_create("input", input_entry, RT_NULL, INPUT_STACK_SIZE, 4, 16); if (tid) rt_thread_startup(tid); }rt_thread_create 最后一个参数是 tick 时间片,这里是 16ms。同优先级没有别的线程在抢,所以这个参数影响不大。优先级 2 最高、4 最低,这样输入中断的响应会被线程入口放大但没有延迟。注意 logic 线程里我用的是固定 10ms 轮询,真正的“游戏速度”没有放在这里 sleep,而是用时间差累积,后面第 5 章会讲。
2.3 线程栈、缓冲区和堆:20KB RAM 怎么分配
STM32F103C8T6 的 20KB RAM 看着紧张,实际上把需求拆开后是可以塞下的。我这份工程的内存大头是两块 128×64 的 OLED 屏幕缓冲区,每块 1KB,双缓冲占了 2KB;然后是三个线程栈,加起来 3.5KB;再加上内核对象的动态分配和小型全局数组,整机 RAM 占用在 12KB 左右。
线程栈不要盲目给大。UI 线程里有 SSD1306 的驱动函数,I2C 发送栈层级较深,给 2048 比较稳。logic 线程处理俄罗斯方块场区,我给了 1024。input 线程逻辑最简单,512 就够。分配表格如下:
| 线程 | 栈大小 | 优先级 | 职责 |
|---|---|---|---|
| ui | 2048 | 2 | 双缓冲字节搬上屏 |
| logic | 1024 | 3 | 贪吃蛇/方块状态推进 |
| input | 512 | 4 | 按键消抖与信号量转换 |
这里有一个容易被忽略的隐性开销:每个线程自身还有一个线程控制块,在内核动态内存里申请。栈大小单位是字节,创建时传入 2048 就是 2048 字节,不是多少个字。有的移植者把裸机工程里的数组习惯带过来,在 64KB Flash 的 F103 上跑着跑着就 HardFault,多半是栈给的比实际需求小,或者把 1024 当成 1024 个 uint32 来算了。
内存动态分配也要小心。Nano 的内存堆默认是一段静态数组,在 board.c 里定义。我把堆大小设为 4KB,只放线程控制块、信号量、消息队列等小对象。framebuffer 没有放堆里,而是用静态数组,因为大块内存反复 malloc/free 会产生碎片,小游戏这种长跑任务最怕碎片。下面这段是我的 board.c 里的堆和缓冲区:
static uint8_t heap_mem[4096] __attribute__((section("HEAP"))); static uint8_t fb_a[FB_BYTES]; static uint8_t fb_b[FB_BYTES]; void rt_hw_board_init(void) { /* 系统时钟、GPIO、I2C 初始化略 */ rt_system_heap_init(heap_mem, heap_mem + sizeof(heap_mem)); }这里还有个好处:用全局数组而不是动态申请 framebuffer,在 UI 线程切换缓冲时只需要改一个 index,不需要挪动数据。双缓冲的细节下一节细说。
3. 双缓冲 UI 与游戏状态机:贪吃蛇、俄罗斯方块的代码落地
3.1 双缓冲刷新架构:画面上屏不再撕裂
OLED 屏刷新本身不慢,慢的是 I2C 写入。如果边画边刷,花屏是必然的。我把“往屏幕写”和“往缓冲区画”分离:所有游戏逻辑只操作一组 framebuffer,UI 线程只负责把一个完整的 framebuffer 搬到屏幕上。两个缓冲交替成为“前台”和“后台”,这样就不会出现屏幕上半截是新画面、下半截还是旧画面的情况。
缓冲区定义我用了 128×64 单色屏的线性模型,一行 128 个像素对应 16 字节,一屏共 1024 字节。写像素要算好字节坐标:
#define FB_BYTES (128 * 64 / 8) static uint8_t fb[2][FB_BYTES]; static uint8_t fb_active; void fb_set_pixel(uint8_t x, uint8_t y, uint8_t on) { if (x >= 128 || y >= 64) return; uint8_t byte = x + (y / 8) * 128; uint8_t bit = y % 8; if (on) fb[fb_active][byte] |= (1 << bit); else fb[fb_active][byte] &= ~(1 << bit); } void fb_swap(void) { fb_active ^= 1; memset(fb[fb_active], 0, FB_BYTES); } void fb_show(void) { ssd1306_write_buffer(fb[fb_active]); }fb_swap 有两个动作:先切到另一块缓冲,然后立刻清空。这样游戏线程画新一帧时不会看到旧画面残留。fb_show 由 UI 线程循环调用,把当前活跃缓冲通过 SSD1306 整屏写命令发出去。我在 ssd1306_write_buffer 里用 DMA 传输,如果芯片是无 DMA 的蓝板,就改成阻塞 I2C,UI 线程阻塞期间另外两个线程照常运行,这正是 RTOS 的收益。
这里有一个边界条件:fb_active 切换可能发生在游戏线程画了一半的时候。Nano 的调度是抢占式的,为了避免游戏线程画到一半被 UI 线程切走,我会在操作 framebuffer 的关键位置用互斥量保护。更简单的一招是只在 logic 线程和 input 线程里改缓冲,UI 线程只读不写,两个 UI 半帧切换造成的小撕裂在 64 像素的 OLED 上肉眼很难发现。
3.2 贪吃蛇:链表式移动与碰撞检测
贪吃蛇移动的本质是“头节点前进一格,尾节点缩短一格”。用数组加 head 索引模拟循环队列最简单,不用真的搬动整条蛇身。我定义蛇身坐标为一个环形数组,每个节点记录 x 和 y:
#define SNAKE_MAX_LEN 64 typedef struct { uint8_t x; uint8_t y; } snake_node; static snake_node snake_body[SNAKE_MAX_LEN]; static uint8_t snake_head_index; /* 当前头在数组里的位置 */ static uint8_t snake_len; static uint8_t snake_dir; /* 0 上 1 右 2 下 3 左 */新的头部坐标根据方向计算出来后,先做两类碰撞检测:一是撞墙,边界设为 24×24 格子;二是撞自己,遍历蛇身看是否重复。这两类检测都通过后,再移动头尾:
uint8_t snake_move(void) { uint8_t new_x = snake_body[snake_head_index].x; uint8_t new_y = snake_body[snake_head_index].y; switch (snake_dir) { case 0: new_y--; break; case 1: new_x++; break; case 2: new_y++; break; case 3: new_x--; break; } if (new_x >= 24 || new_y >= 24) return 0; /* 撞墙 */ for (uint8_t i = 0; i < snake_len; i++) { if (snake_body[i].x == new_x && snake_body[i].y == new_y) return 0; /* 撞自己 */ } snake_head_index = (snake_head_index + 1) % SNAKE_MAX_LEN; snake_body[snake_head_index].x = new_x; snake_body[snake_head_index].y = new_y; if (snake_len < SNAKE_MAX_LEN) snake_len++; /* 没吃到食物时把尾部覆盖掉,等效于缩短尾巴 */ return 1; }这里没有单独保存尾节点位置,因为环形数组的“旧头”位置就是被覆盖点。如果吃到食物,snake_len 增加,数组里被覆盖的旧节点会继续充当尾部,视觉上体现出“多一格”。碰撞检测里我直接遍历整个蛇身,贪吃蛇长度一般不超过 30,遍历消耗可以忽略。要注意的是数组最大长度要留够余量,64 已经是 24×24 网格的上限,再长就会覆盖到无意义的旧数据。
3.3 俄罗斯方块:形状表、旋转与消行检查
俄罗斯方块的骨架比贪吃蛇更麻烦,因为它要处理 7 种方块、4 个旋转方向。我用一个 4×4 的字节矩阵作为方块模板,用固定的形状表把每种方块的旋转状态都存下来。这样旋转代码只需切换下标,不需要实时计算矩阵翻转:
static const uint8_t tetromino[7][4][4] = { /* I 方块 */ { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0} }, /* 其他方块按相同格式补全 */ }; static uint8_t cur_shape_id; static uint8_t cur_rot; static uint8_t block_x = 3; static uint8_t block_y = 0;碰撞检测要同时判断方块模板与现有场区是否重叠。因为模板是 4×4,而场区是 20×10,所以要先做边界裁剪:判断方块最左列和 template 的偏移。我用一个简化判断函数,直接在画之前检查场区:
uint8_t block_fits(uint8_t shape, uint8_t rot, uint8_t px, uint8_t py) { for (uint8_t y = 0; y < 4; y++) { for (uint8_t x = 0; x < 4; x++) { if (!tetromino[shape][rot][y * 4 + x]) continue; uint8_t fx = px + x; uint8_t fy = py + y; if (fx >= 10 || fy >= 20) return 0; if (field[fy][fx]) return 0; } } return 1; }消行检测最容易写错。我原版本从下往上扫,一旦发现满行就立刻整行删除,结果一次消两行时新掉落行没有正确填入。正确做法是从下往上扫完后,用一个“写入索引”把非满行重新压实:
uint8_t clear_lines(void) { uint8_t write_row = 19; uint8_t cleared = 0; for (uint8_t row = 19; row > 0; row--) { uint8_t full = 1; for (uint8_t col = 0; col < 10; col++) { if (!field[row][col]) { full = 0; break; } } if (!full) { memcpy(field[write_row], field[row], 10); write_row--; } else { cleared++; } } while (write_row > 0) { memset(field[write_row], 0, 10); write_row--; } return cleared; }这段代码从下往上保留所有未满行,把它们依次放到 write_row,最后把顶部空位清零。为什么不是直接清满行?因为同时消两行以上时,直接从满行删除会导致位置上移的两行漏判。先全部重新压实再清空顶部,是稳定且不容易出逻辑错乱的做法。场区数组 field[20][10] 占 200 字节,用 uint8_t 存 0/1,完全够用。
3.4 按键输入:从轮询到信号量
裸机按键处理通常在主循环里轮询 GPIO,简单但响应不精准,而且消抖 delay 会阻塞整个程序。我把按键接在 PA0 的外部中断线上,中断处理函数只做一件事:释放一个信号量。程序里有两个按键:左、右、旋转、加速,实际只用了三根输入线。
static rt_sem_t input_sem; void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { rt_sem_release(input_sem); EXTI_ClearFlag(EXTI_Line0); EXTI_ClearITPendingBit(EXTI_Line0); } } static void input_entry(void *param) { while (1) { rt_sem_take(input_sem, RT_WAITING_FOREVER); rt_sem_control(input_sem, RT_IPC_CMD_RESET, 0); /* 清掉累积计数 */ key_action_t action = key_scan_and_debounce(); if (action != ACTION_NONE) { rt_mq_send(&key_mq, &action, sizeof(action)); } } }中断里释放信号量的好处是,不管按键按得多快,input 线程只会被唤醒一次,不会阻塞其他线程。关键点在于 rt_sem_control 会把计数信号量重置为 0,这样如果上电时按键抖动释放了多次,线程醒来后只会处理一次。旋转、左右键分别挂 EXTI0、EXTI1、EXTI2,逻辑完全相同。消抖放到线程里做,用连续读两次延时 10ms 的方式,这比在中断里 delay 安全得多,中断上下文不能做阻塞式延时。
4. 常见问题排查:五个把开发者逼疯的嵌入式小游戏坑
4.1 现象:屏幕闪烁,画面内容出现上下半帧撕裂
在双缓冲没做好的时候,SSD1306 上显示的内容会随机出现半行错位。原因是 logic 线程画到一半,UI 线程就把当前缓冲刷上去了。我最初用了一个 active 标志来切换,但两个线程访问同一个变量没有加锁,导致 UI 线程读到的是半个新帧。解决方法是把“切换缓冲”和“清空新缓冲”放在一起执行,并且保证 logic 线程只在后台缓冲区画,不在前台画。用互斥量包住 fb_swap 也可以,但我的场景下用 index 翻转已经够了,没遇到两个线程同时写的冲突。
4.2 现象:运行时突然进入 HardFault,看寄存器发现 PC 跑飞
这个基本是线程栈溢出的典型表现。逻辑线程里我放的消行代码嵌套比较深,局部数组也要占据临时栈,1024 字节在某些路径下不够用。解决方法是把栈水线检查做进调试代码里,方法是在线程创建后先把栈区填充成固定标记,比如 0xA5,过一段时间扫描剩余多少 0xA5:
void stack_scan(void) { rt_uint8_t *p = thread_stack_base; rt_uint32_t used = 0; while (*p == 0xA5) { used++; p++; } rt_kprintf("logic stack used approx %d/%d\n", THREAD_STACK_SIZE - used, THREAD_STACK_SIZE); }这段不是精确测量,因为栈深处的数据可能不是连续 0xA5,但对发现溢出方向很有价值。我跑了一个小时后看输出,logic 线程栈最高用到了 900 多字节,于是把栈加到 1536,HardFault 彻底消失。栈大小不要一次给到 2048,先 1024,再用工具采集真实使用量,这样内存分配更有的放矢。
4.3 现象:按键按一下,游戏执行了两次动作
原因是中断里释放的计数信号量累加了多次按键抖动,input 线程被唤醒后把三次抖动都当成有效按键。我一开始只使用了 rt_sem_take,没有清空剩余计数,结果快速连续按键时旋转经常跳两个方向。解决方法是每次唤醒后先重置信号量,再扫描按键状态。也就是前文 input_entry 里的 rt_sem_control(INPUT_SEM, RT_IPC_CMD_RESET, 0) 那行。如果不想依赖这个 API,也可以在取信号量后循环调用 rt_sem_trytake 直到返回失败,把残留计数全部消费掉。
4.4 现象:俄罗斯方块下落速度越来越快,时间明显不对
我没有做速度积分,而是在 logic 线程里用 rt_thread_mdelay(50) 配合一个递减计数器控制下落。这种写法一旦线程被高优先级任务打断,或者系统 tick 有累积误差,速度就会漂。更稳的方式是基于绝对 tick 做差值计算,而不是每次延后固定时长。用 32 位无符号数做差值可以正确处理 tick 回绕:
uint32_t last_speed_tick; uint32_t now = rt_tick_get(); if (now - last_speed_tick >= speed_interval) { block_y++; last_speed_tick = now; }这里的 speed_interval 按游戏等级动态调整,400ms、300ms、200ms 对应不同难度。用差值方式即使 logic 线程被抢占 20ms,也不会把时间误差积累下来,只会让下一次移动晚一点触发。
4.5 现象:屏幕正常但按键引脚没有反应信号
PA0-PA3 在 STM32F103 上默认是 JTAG/SWD 调试口的一部分,如果引脚下拉配置不完整,外部中断根本触发不了。这个问题很隐蔽,因为 GPIO 初始化的代码看着没问题,但芯片复位后 PA13、PA14、PA15、PB3 都被 JTAG 占用。解决方法是把调试口重映射为普通 GPIO,只保留 SWD:
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_SWJ_Disable, DISABLE);注意如果直接禁用整个 SWJ,会导致调试器连不上,所以通常先禁用 JTAG 保留 SWD,再用 SWD 烧录程序。按键中断引脚和 SWD 接口尽量错开,PA0 一般没有冲突,PA11/PA12 是 USB 引脚,也别乱用。
5. 帧率与长期稳定性验证:一个心跳函数的进阶用法
5.1 用统一心跳节拍管理三件事
我最后把 UI 刷新、逻辑推进、按键扫描三者的节奏统一到了一个 tick 心跳里。逻辑线程不再做固定延时,而是每个循环都检查当前 tick 和上次记录的时间差。这样游戏速度表和实际 MCU 负载解耦,增加难度只需要改一个数组:
| 等级 | 下落间隔 | 逻辑线程实际轮询 |
|---|---|---|
| Lv1 | 600ms | 10ms |
| Lv5 | 300ms | 10ms |
| Lv9 | 120ms | 10ms |
代码上把一个函数放在 logic 线程循环里:
void game_tick(void) { static uint32_t last = 0; uint32_t now = rt_tick_get(); uint32_t dt = now - last; if (dt >= speed_table[current_level]) { step_game(); last = now; } if (now - last_input_scan > 10) { scan_key_events(); last_input_scan = now; } }step_game 内部会判断当前是在跑贪吃蛇还是俄罗斯方块,两个游戏共用同一个 UI 缓冲,但状态机完全隔离。这样切换游戏时只需要修改 current_game 变量,所有逻辑自动走对应分支。
5.2 验证方法:CPU 占用率与 72 小时连续运行
验证不只靠眼睛“看着没死”。我会把空闲任务里的 GPIO 翻转放到示波器上,观测基线电平来判断 CPU 占用。先让一块引脚在逻辑线程入口置高,出口置低,占空比就是该线程的 CPU 占用比例。实际测试下来动静不大:UI 线程 I2C 刷屏占比较多,logic 线程不到 5%,input 线程几乎为 0。真正的瓶颈是 I2C 速率,把速率从 100kHz 提到 400kHz 后,画面刷新间隔从 25ms 降到 12ms,肉眼可见地流畅了。
长期稳定性我跑过 72 小时自动模式,让游戏逻辑自己吃食物、消行、重置,同时用串口每 5 分钟打印一次存活时间。第一次跑在 36 小时左右出现过一次 HardFault,定位是俄罗斯方块清理分数缓存时越界访问了分数数组。修掉之后连续跑满 72 小时没有复现。从那以后,我每次把新游戏模块接进工程,都会强制走一遍三件事:给线程栈做水线扫描、检查所有数组下标的上界、确认信号量计数不会累积。这条习惯帮我省下的调试时间,已经远远超过当初移植 RT-Thread Nano 花掉的那一个周末。希望帮到你。
本文还有配套的精品资源,点击获取