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 屏幕闪烁问题

如果出现屏幕闪烁,检查以下几点:

  1. 刷新频率是否过低(应保持在30fps以上)
  2. 是否采用了局部刷新而非全屏刷新
  3. 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 游戏卡顿优化

如果游戏运行卡顿,可以从以下几个方面优化:

  1. 降低刷新频率到10fps(贪吃蛇不需要太高帧率)
  2. 优化碰撞检测算法
  3. 使用硬件加速绘制(如果OLED控制器支持)
  4. 将复杂计算移到空闲时段处理

在我的实现中,将游戏更新和画面刷新分离,使用定时器中断确保游戏逻辑以固定频率更新,避免了因渲染耗时导致的操作延迟。

经过这些优化,最终的游戏运行稳定流畅,即使蛇长达到最大值也没有出现卡顿现象。整个项目不仅实现了贪吃蛇的基本功能,更深入优化了嵌入式环境下的内存管理和显示效率,为后续开发更复杂的游戏打下了坚实基础。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐