
简介基于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-ThreadSTM32F103C8T6 最让人没底的不是 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 周期。如果芯片的主频不是 72MHzSysTick_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 就够。分配表格如下线程栈大小优先级职责ui20482双缓冲字节搬上屏logic10243贪吃蛇/方块状态推进input5124按键消抖与信号量转换这里有一个容易被忽略的隐性开销每个线程自身还有一个线程控制块在内核动态内存里申请。栈大小单位是字节创建时传入 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 写入。如果边画边刷花屏是必然的。我把“往屏幕写”和“往缓冲区画”分离所有游戏逻辑只操作一组 framebufferUI 线程只负责把一个完整的 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 的蓝板就改成阻塞 I2CUI 线程阻塞期间另外两个线程照常运行这正是 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过一段时间扫描剩余多少 0xA5void 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 多字节于是把栈加到 1536HardFault 彻底消失。栈大小不要一次给到 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只保留 SWDRCC_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 负载解耦增加难度只需要改一个数组等级下落间隔逻辑线程实际轮询Lv1600ms10msLv5300ms10msLv9120ms10ms代码上把一个函数放在 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 花掉的那一个周末。希望帮到你。本文还有配套的精品资源点击获取