基于STM32与OLED的贪吃蛇游戏:链表实现与动态刷新优化
1. 项目概述与硬件选型
最近在折腾STM32和OLED屏幕,想着做个经典的小游戏来练手,最终选择了贪吃蛇。这个项目特别适合嵌入式入门,既能学习硬件驱动,又能掌握数据结构。我用的是STM32F103C8T6最小系统板,搭配0.96寸OLED屏幕(128×64分辨率),成本不到30块钱,但实现的效果非常有趣。
贪吃蛇游戏的核心难点在于如何高效管理蛇身的移动和增长。传统数组方式会频繁移动大量数据,效率低下。我选择了双向链表来存储蛇身节点,每次移动只需操作头尾指针,效率大幅提升。另一个关键是解决OLED刷新时的闪烁问题,通过局部刷新技术只更新变化的像素点,让游戏画面流畅稳定。
这个项目适合有一定STM32基础的开发者,需要熟悉I2C通信和基本的内存管理。如果你刚接触STM32,建议先点亮OLED屏幕并显示基本图形,再逐步实现游戏逻辑。
2. OLED驱动与屏幕优化
2.1 基础驱动配置
OLED驱动我直接用了经典的SSD1306库,通过I2C通信。初始化序列比较固定,重点是设置对比度、扫描方向和显示模式。以下是关键初始化代码:
void OLED_Init(void) {
Delay_ms(100);
OLED_WriteCommand(0xAE); // 关闭显示
OLED_WriteCommand(0xD5); // 设置时钟分频
OLED_WriteCommand(0x80);
OLED_WriteCommand(0xA8); // 多路复用率
OLED_WriteCommand(0x3F);
OLED_WriteCommand(0xD3); // 显示偏移
OLED_WriteCommand(0x00);
OLED_WriteCommand(0x40); // 起始行
OLED_WriteCommand(0x8D); // 电荷泵
OLED_WriteCommand(0x14);
OLED_WriteCommand(0x20); // 内存模式
OLED_WriteCommand(0x00);
OLED_WriteCommand(0xA1); // 段重映射
OLED_WriteCommand(0xC8); // 扫描方向
OLED_WriteCommand(0xDA); // COM引脚配置
OLED_WriteCommand(0x12);
OLED_WriteCommand(0x81); // 对比度
OLED_WriteCommand(0xCF);
OLED_WriteCommand(0xD9); // 预充电周期
OLED_WriteCommand(0xF1);
OLED_WriteCommand(0xDB); // VCOMH电平
OLED_WriteCommand(0x40);
OLED_WriteCommand(0xA4); // 整体显示开启
OLED_WriteCommand(0xA6); // 非反色
OLED_WriteCommand(0xAF); // 开启显示
}
实际测试中发现,直接调用厂商提供的初始化代码有时会遇到兼容性问题,特别是某些廉价OLED模块。如果出现花屏或显示错位,可以尝试调整扫描方向(0xA0/0xA1)和COM引脚配置(0xDA参数)。
2.2 屏幕缓冲与局部刷新
最开始的方案是维护一个8×128的全局缓冲区数组,每次更新全屏刷新:
uint8_t OLED_Buffer[8][128] = {0};
void OLED_Refresh_Full() {
for(uint8_t page=0; page<8; page++) {
OLED_SetCursor(page, 0);
for(uint8_t col=0; col<128; col++) {
OLED_WriteData(OLED_Buffer[page][col]);
}
}
}
但这种方案在蛇移动时会产生明显闪烁,因为整个屏幕都在重绘。后来改为局部刷新,只更新变化的像素点:
void OLED_Refresh_Partial(uint8_t x, uint8_t y) {
uint8_t page = y / 8;
uint8_t bit_mask = 1 << (y % 8);
OLED_SetCursor(page, x);
if(OLED_Buffer[page][x] & bit_mask) {
OLED_WriteData(OLED_Buffer[page][x]);
} else {
OLED_WriteData(0x00);
}
}
实测局部刷新将每帧耗时从15ms降低到2ms以内,完全消除了闪烁现象。关键是要精确跟踪哪些像素点发生了变化,在蛇移动时只刷新头部新位置和尾部旧位置。
3. 贪吃蛇数据结构设计
3.1 链表节点定义
采用双向链表结构,每个节点保存坐标信息和前后指针:
typedef struct SnakeNode {
uint8_t x;
uint8_t y;
struct SnakeNode* prev;
struct SnakeNode* next;
} SnakeNode;
typedef struct {
SnakeNode* head;
SnakeNode* tail;
uint16_t length;
uint8_t direction; // 0:上, 1:右, 2:下, 3:左
} Snake;
双向链表的优势在于删除尾部节点时效率极高,直接通过tail指针找到前一个节点即可,不需要遍历整个链表。每个节点占用12字节内存(STM32下指针为4字节),按最大长度20计算,只需240字节内存。
3.2 移动与增长算法
蛇的移动分为三个步骤:在头部添加新节点、检查是否吃到食物、如果没吃到则删除尾部节点:
void Snake_Move(Snake* snake, uint8_t* eat_food) {
// 计算新头部位置
uint8_t new_x = snake->head->x;
uint8_t new_y = snake->head->y;
switch(snake->direction) {
case 0: new_y--; break; // 上
case 1: new_x++; break; // 右
case 2: new_y++; break; // 下
case 3: new_x--; break; // 左
}
// 创建新头部节点
SnakeNode* new_head = malloc(sizeof(SnakeNode));
new_head->x = new_x;
new_head->y = new_y;
new_head->prev = NULL;
new_head->next = snake->head;
if(snake->head != NULL) {
snake->head->prev = new_head;
}
snake->head = new_head;
// 检查是否吃到食物
if(new_x == food_x && new_y == food_y) {
*eat_food = 1;
snake->length++;
} else {
// 没吃到食物,删除尾部
SnakeNode* old_tail = snake->tail;
snake->tail = old_tail->prev;
if(snake->tail != NULL) {
snake->tail->next = NULL;
}
free(old_tail);
}
}
这个算法的巧妙之处在于,无论蛇是否吃到食物,头部添加新节点的操作都是必需的,区别只在于是否删除尾部节点。这样保证了代码的简洁性和执行效率。
4. 动态内存管理实战
4.1 malloc在嵌入式中的陷阱
最初直接使用malloc分配节点内存,但当蛇长度达到15左右时程序就卡死了。原因是默认堆空间太小,在STM32F103中只有0x200字节(512字节)。
通过修改启动文件(startup_stm32f10x_md.s)中的堆大小设置:
Heap_Size EQU 0x400 ; 原来可能是0x200
同时要修改链接脚本(.ld文件)中的堆栈配置:
_Min_Heap_Size = 0x400; /* 1KB heap */
_Min_Stack_Size = 0x400; /* 1KB stack */
但仅仅增大堆空间还不够,频繁malloc/free会产生内存碎片。更好的方案是使用内存池预分配节点。
4.2 内存池优化方案
预先分配固定数量的节点,使用时从池中取用,释放时放回池中:
#define MAX_SNAKE_LENGTH 20
SnakeNode node_pool[MAX_SNAKE_LENGTH];
uint8_t node_used[MAX_SNAKE_LENGTH] = {0};
SnakeNode* allocate_node() {
for(int i=0; i<MAX_SNAKE_LENGTH; i++) {
if(!node_used[i]) {
node_used[i] = 1;
return &node_pool[i];
}
}
return NULL;
}
void free_node(SnakeNode* node) {
uint32_t index = node - node_pool;
if(index < MAX_SNAKE_LENGTH) {
node_used[index] = 0;
}
}
内存池方案完全避免了内存碎片,分配和释放都是O(1)时间复杂度。实测即使运行数小时,内存使用依然稳定。
5. 游戏逻辑与用户交互
5.1 控制输入处理
使用四个物理按键控制方向,通过中断方式检测按键:
// 按键引脚定义
#define KEY_UP_GPIO_Port GPIOA
#define KEY_UP_Pin GPIO_PIN_0
// 其他按键类似...
// 中断处理函数
void EXTI0_IRQHandler(void) {
if(__HAL_GPIO_EXTI_GET_IT(KEY_UP_Pin) != RESET) {
if(snake.direction != 2) { // 防止直接反向
snake.direction = 0;
}
__HAL_GPIO_EXTI_CLEAR_IT(KEY_UP_Pin);
}
}
为了防止误操作,设置了方向反转保护:不能直接从向上变为向下,或向左变为向右。这是贪吃蛇游戏的基本规则,避免玩家因操作失误立即死亡。
5.2 碰撞检测与游戏状态
碰撞检测包括边界检测和自身碰撞检测:
uint8_t check_collision(Snake* snake) {
// 边界检测
if(snake->head->x >= 128 || snake->head->y >= 64) {
return 1;
}
// 自身碰撞检测(从第二个节点开始检查)
SnakeNode* current = snake->head->next;
while(current != NULL) {
if(snake->head->x == current->x &&
snake->head->y == current->y) {
return 1;
}
current = current->next;
}
return 0;
}
游戏状态机管理:
typedef enum {
GAME_SPLASH, // 开始界面
GAME_PLAYING, // 游戏中
GAME_PAUSED, // 暂停
GAME_OVER // 结束
} GameState;
GameState game_state = GAME_SPLASH;
每个状态都有相应的处理函数和显示内容,通过状态机使代码结构清晰,易于扩展。
6. 性能优化技巧
6.1 刷新策略优化
采用差异刷新策略,只更新发生变化的部分:
void update_display(Snake* snake, uint8_t old_tail_x, uint8_t old_tail_y) {
// 清除旧尾部
OLED_DrawPixel(old_tail_x, old_tail_y, 0);
// 绘制新头部
OLED_DrawPixel(snake->head->x, snake->head->y, 1);
// 如果吃到食物,绘制新食物
if(food_updated) {
OLED_DrawPixel(food_x, food_y, 1);
}
}
相比全屏刷新,差异刷新将单帧绘制时间从15ms降低到0.5ms以内,提升了30倍的效率。
6.2 算法复杂度优化
蛇身碰撞检测的朴素算法是O(n)复杂度,通过空间换时间可以优化到O(1):
uint8_t collision_map[128][8] = {0}; // 128x64位图,用8个uint8_t表示64行
void update_collision_map(uint8_t x, uint8_t y, uint8_t set) {
uint8_t row = y / 8;
uint8_t bit = y % 8;
if(set) {
collision_map[x][row] |= (1 << bit);
} else {
collision_map[x][row] &= ~(1 << bit);
}
}
uint8_t check_collision_fast(uint8_t x, uint8_t y) {
uint8_t row = y / 8;
uint8_t bit = y % 8;
return (collision_map[x][row] >> bit) & 1;
}
这样碰撞检测就变成了直接查表,代价是额外占用128×8=1KB内存。在STM32F103中这是可以接受的,因为还有充足的内存空间。
7. 常见问题与调试经验
7.1 屏幕闪烁问题
如果出现屏幕闪烁,检查以下几点:
- 刷新频率是否过低(应保持在30fps以上)
- 是否采用了局部刷新而非全屏刷新
- I2C通信速率是否足够快(400kHz比较合适)
可以通过示波器检查I2C波形,确保时序正确。如果使用软件I2C,要优化延时函数保证通信稳定。
7.2 内存泄漏检测
由于使用了动态内存分配,要特别注意内存泄漏。可以通过以下方法检测:
// 在内存分配函数中添加计数
uint32_t malloc_count = 0;
uint32_t free_count = 0;
void* my_malloc(size_t size) {
malloc_count++;
return malloc(size);
}
void my_free(void* ptr) {
free_count++;
free(ptr);
}
// 定期检查是否泄漏
if(malloc_count != free_count) {
printf("Memory leak detected!\n");
}
在调试阶段,我建议使用静态分配或内存池方案,避免动态内存管理的复杂性。
7.3 游戏卡顿优化
如果游戏运行卡顿,可以从以下几个方面优化:
- 降低刷新频率到10fps(贪吃蛇不需要太高帧率)
- 优化碰撞检测算法
- 使用硬件加速绘制(如果OLED控制器支持)
- 将复杂计算移到空闲时段处理
在我的实现中,将游戏更新和画面刷新分离,使用定时器中断确保游戏逻辑以固定频率更新,避免了因渲染耗时导致的操作延迟。
经过这些优化,最终的游戏运行稳定流畅,即使蛇长达到最大值也没有出现卡顿现象。整个项目不仅实现了贪吃蛇的基本功能,更深入优化了嵌入式环境下的内存管理和显示效率,为后续开发更复杂的游戏打下了坚实基础。
更多推荐
所有评论(0)