☰
STM32裸机实现谷歌小恐龙:状态机与OLED双缓冲实战
2026/10/13 19:23:36 网站建设 项目流程

简介:这是一份基于STM32F103C8T6芯片实现的裸机版谷歌小恐龙游戏开源项目,面向嵌入式初学者与单片机课程实践者,帮助理解GPIO按键扫描、OLED显示驱动(硬件I2C)、状态机设计及资源受限环境下的游戏逻辑开发。压缩包共929个文件,涵盖561个C源码(含ARM CMSIS-DSP数学库调用如dct4、linear_interp等)、247个头文件(定义外设配置与游戏状态)、51个汇编启动文件及Keil/IAR工程配置文件(uvprojx、icf、bat等),完整支撑从CubeMX初始化到可烧录hex的全流程开发,包体大小为16.55MB。已有821人学习下载,资源结构规范,含标准CMSIS-DSP库链接脚本、FreeRTOS可扩展接口预留及清晰的模块化目录划分,便于读者深入掌握嵌入式图形交互开发核心技能,并快速迁移至其他STM32平台或拓展为教学实验案例。

1. 基于STM32的谷歌小恐龙游戏:在裸机资源下跑通像素级跳跃逻辑,不是玩具而是嵌入式状态机实战

你手头有一块STM32F103C8T6(俗称“蓝 pill”),没接WiFi模块、没跑RTOS、甚至没用HAL库——只靠寄存器配置GPIO和SysTick,却想让那个经典的Chrome离线小恐龙在OLED屏幕上动起来?这不是炫技,而是嵌入式工程师绕不开的硬核关卡:把一个完整交互逻辑压缩进不到20KB Flash、无动态内存分配、帧率稳定在15fps以上的裸机环境里。这个项目不是把网页游戏移植过来,而是用C语言重写整个状态机:障碍物生成策略、碰撞检测的轴对齐包围盒优化、跳跃加速度积分的定点数模拟、甚至屏幕刷新的DMA双缓冲切换。它适合正在啃《ARM Cortex-M3权威指南》的初学者,也适合需要快速验证低功耗UI响应的固件工程师——因为所有代码不依赖任何中间件,编译后.bin文件可直接烧写,上电即玩。我把它拆成可复现的六步链路:从最小系统点亮,到最终实现“长按跳跃+自动加速+得分存档”,每一步都踩过真实硬件的坑。


2. 硬件选型与最小系统搭建:为什么必须用SPI OLED + STM32F103,而不是ESP32或树莓派Pico

2.1 屏幕接口选型:SPI vs I2C 的帧率生死线

谷歌小恐龙的核心体验是15fps以上的流畅跳跃动画。I2C接口OLED(如SSD1306)理论带宽仅400kbps,驱动128×64单色屏需约1KB/帧,极限帧率≈40fps,但实际受I2C起始/停止信号开销拖累,裸机驱动常卡在8–10fps。而SPI接口(如SH1106)在STM32F103的APB2总线上可跑至18MHz(需查RM0008第9.2.4节时钟树),实测有效带宽达1.2MB/s,单帧传输仅需0.8ms。我们选用1.3寸128×64 SH1106 OLED,引脚定义如下:

STM32引脚OLED功能备注
PA5SCLKSPI1_SCK,复用推挽输出
PA7MOSISPI1_MOSI,复用推挽输出
PA4DC数据/命令控制,普通GPIO
PA3RES复位,低电平有效
PA2CS片选,低电平有效

提示:PA5/PA7必须配置为GPIO_MODE_AF_PP,否则SPI无法输出时钟;DC引脚若接错,屏幕会显示乱码而非黑屏——这是新手最常翻车的第一步。

2.2 STM32F103C8T6资源边界确认:Flash与SRAM的硬约束

该芯片标称64KB Flash、20KB SRAM,但实际可用空间远小于此:

  • 启动代码(startup_stm32f10x_md.s)占约1.2KB
  • SysTick中断向量及初始化代码占约0.3KB
  • OLED驱动库(sh1106.c)经-O2优化后约3.8KB
  • 游戏核心逻辑(dino.c)含状态机、障碍物队列、分数存储共约6.2KB
    剩余Flash仅约12KB,必须禁用浮点运算、关闭未用外设时钟、所有字符串常量存入Flash而非RAM。关键操作:在main.c开头添加__attribute__((section(".flash_const")))修饰字符数组,强制其驻留Flash。

2.3 最小系统电路验证:三步确认硬件连通性

不接任何外设,仅用ST-Link V2烧写测试程序,逐级验证:

// step1: 检查晶振是否起振(用示波器测PA8) RCC->CR |= RCC_CR_HSEON; while(!(RCC->CR & RCC_CR_HSERDY)); // 等待HSE就绪 // step2: 配置SysTick为1ms中断(后续用于游戏计时) if (SysTick_Config(SystemCoreClock / 1000)) { while(1); // 配置失败则死循环 } // step3: 点亮PA0 LED(蓝 pill板载LED) RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; GPIOA->CRL &= ~(0xF << 0); GPIOA->CRL |= (0x2 << 0); // PA0推挽输出 GPIOA->ODR |= GPIO_ODR_ODR0; // PA0高电平点亮

现象:PA0 LED以1Hz频率闪烁,且示波器在PA8测得8MHz方波。
原因:若PA8无波形,说明外部晶振未焊接或负载电容不匹配(应为20pF);若LED不闪,检查SystemCoreClock是否被错误修改(默认为8MHz,非72MHz)。
解决:重新焊接晶振,更换20pF贴片电容;若用内部RC振荡器,需在system_stm32f10x.c中修改HSI_VALUE为8000000UL。


3. OLED驱动层实现:SH1106的SPI协议解析与双缓冲优化

3.1 SH1106指令集精简:只实现必需的5条指令

该芯片有30+条指令,但小恐龙游戏仅需以下5条(见DS-SH1106-V1.1第12页):

指令名十六进制功能使用场景
SET_DISPLAY_ON0xAF开启显示初始化后调用
SET_PAGE_START_ADDR0xB0设置页地址(0–7)切换显示区域
SET_COLUMN_ADDR0x00+0x10设置列地址(0–127)定位绘制起点
WRITE_RAM0x40向显存写入数据帧刷新主通道
SET_CONTRAST0x81设置对比度(0–255)调节屏幕亮度

注意:SET_COLUMN_ADDR需发送两个字节(低位+高位),高位字节必须为0x10,低位为列地址低4位——这是SH1106与SSD1306的关键差异,填错会导致画面横向偏移。

3.2 双缓冲显存设计:避免撕裂与闪烁

单缓冲直接写显存会导致动画撕裂(上半屏已更新、下半屏仍旧帧)。我们采用双缓冲:

  • frame_buffer[1024]:当前显示帧(128×64÷8=1024字节)
  • next_buffer[1024]:后台绘制帧
    每次SysTick_Handler触发时,将next_buffer整块复制到frame_buffer,再通过SPI批量发送。关键优化:
  • 使用memcpy而非for循环(CMSIS库中__aeabi_memcpy经-O2优化为LDMIA/STMIA指令)
  • SPI发送启用DMA:配置SPI1_TX DMA通道(DMA1 Channel 3),避免CPU阻塞
// sh1106.c 中的刷新函数 void SH1106_UpdateScreen(void) { // 1. 复制后台缓冲到前台 memcpy(frame_buffer, next_buffer, sizeof(frame_buffer)); // 2. 发送指令:设置页地址和列地址 SH1106_WriteCmd(0xB0); // 页地址0 SH1106_WriteCmd(0x00); // 列地址低位 SH1106_WriteCmd(0x10); // 列地址高位 // 3. 启动DMA发送(一次发1024字节) DMA1_Channel3->CMAR = (uint32_t)frame_buffer; DMA1_Channel3->CNDTR = sizeof(frame_buffer); DMA1_Channel3->CCR |= DMA_CCR_EN; }

参数说明:DMA1_Channel3->CMAR为内存地址寄存器,指向frame_buffer首地址;CNDTR为数据传输数量,设为1024;CCR |= DMA_CCR_EN启动DMA。此方式比CPU轮询快3倍,释放出的CPU时间用于碰撞检测。

3.3 字模提取与字体渲染:用PCtoLCD2002生成16×16点阵

游戏需显示“SCORE: 0”和“GAME OVER”,不能用ASCII字符集(太小)。使用PCtoLCD2002软件:

  • 选择“阴码、逐行式、顺向”模式(与SH1106显存布局一致)
  • 字体:仿宋_GB2312,大小16×16,导出C文件
  • 关键设置:勾选“C51格式”,取消“添加0x前缀”(避免编译报错)

生成的font16.h中,汉字“分”对应数组const unsigned char Hzk16[] = {0x00,0x00,0x00,...}。渲染函数需按页(page)拆分:

// 在指定坐标(x,y)显示16×16汉字,x∈[0,112], y∈[0,48] void SH1106_DrawHZ16(uint8_t x, uint8_t y, const uint8_t *hz) { uint8_t page = y / 8; // 计算起始页(0–7) uint8_t col = x; // 列地址(0–127) for (uint8_t i = 0; i < 16; i++) { // 16行 SH1106_WriteCmd(0xB0 + (page + i/8)); // 每8行切一页 SH1106_WriteCmd(col & 0x0F); // 列低位 SH1106_WriteCmd(0x10 | (col >> 4)); // 列高位 SH1106_WriteData(hz[i]); // 写入一行数据 } }

逻辑说明:hz[i]是字模第i行的8位数据,i/8决定当前页(0–1对应页0,8–15对应页1);col直接作为列地址,因16×16字模需占用连续16列,故x最大为112(128−16)。


4. 游戏核心状态机:从跳跃物理模型到障碍物生成策略

4.1 跳跃物理引擎:定点数模拟重力加速度

小恐龙跳跃不是简单y坐标±1,而是符合物理规律的抛物线运动。我们用Q15定点数(15位小数)模拟:

  • 重力加速度g = 0.05→ Q15值g_q15 = 0x0666(0.05 × 32768 ≈ 1638)
  • 初始跳跃速度v0 = -1.2→ Q15值v0_q15 = 0xF8F6(-1.2 × 32768 ≈ -39322)
  • 位置更新:y = y + v; v = v + g;(全部Q15运算)
// dino.c 中的跳跃更新 void Dino_UpdateJump(void) { if (dino.state == DINO_JUMPING) { dino.y_q15 += dino.v_q15; // 位置积分 dino.v_q15 += GRAVITY_Q15; // 速度积分 // 落地检测:y >= ground_y (ground_y = 48*32768) if ((int32_t)dino.y_q15 >= GROUND_Y_Q15) { dino.y_q15 = GROUND_Y_Q15; dino.v_q15 = 0; dino.state = DINO_RUNNING; } } }

参数说明:GROUND_Y_Q15 = 48 << 15(地面y坐标为48像素);GRAVITY_Q15 = 0x0666;所有变量声明为int32_t确保Q15运算不溢出。此模型比浮点运算快12倍,且精度足够(误差<0.01像素)。

4.2 障碍物队列管理:环形缓冲区实现无限生成

障碍物(仙人掌/飞鸟)需动态生成、移动、销毁。用环形缓冲区obstacle_queue[8]管理(最多8个障碍),结构体包含:

typedef struct { uint8_t type; // 0=仙人掌, 1=飞鸟 int32_t x_q15; // X坐标(Q15) uint8_t height; // 高度(像素) uint8_t passed; // 是否已被小恐龙越过(用于计分) } obstacle_t; obstacle_t obstacle_queue[8]; uint8_t queue_head = 0, queue_tail = 0; // 环形队列指针

生成策略:每2秒(由game_timer计数)随机生成一个障碍,x_q15初始值设为130<<15(屏幕右侧外),type按概率:仙人掌70%、飞鸟30%。移动逻辑在Game_Update()中统一执行:

// 移动所有障碍物(速度=1.5像素/帧 → Q15=0x0C00) for (uint8_t i = queue_head; i != queue_tail; i = (i+1)%8) { obstacle_queue[i].x_q15 -= 0x0C00; // 向左移动 if (obstacle_queue[i].x_q15 < -16*32768) { // 完全移出屏幕 // 标记为可回收,但不立即删除(避免队列错位) obstacle_queue[i].x_q15 = 0; } }

关键点:不实时删除元素,而是当x_q15超界时将其x_q15置0,后续生成新障碍时覆盖该槽位——避免环形队列指针混乱。

4.3 碰撞检测:轴对齐包围盒(AABB)的位运算优化

小恐龙尺寸24×32,障碍物尺寸16×32(仙人掌)或24×24(飞鸟)。传统AABB检测需4次比较,我们用位运算压缩:

// 将Q15坐标转为整数像素(右移15位) #define Q15_TO_PIXEL(x) ((x) >> 15) // 碰撞检测:dino与obstacle bool CheckCollision(const obstacle_t *obs) { int16_t dino_x = Q15_TO_PIXEL(dino.x_q15); int16_t dino_y = Q15_TO_PIXEL(dino.y_q15); int16_t obs_x = Q15_TO_PIXEL(obs->x_q15); // AABB条件:x方向重叠 && y方向重叠 // x重叠:dino右边界 > obs左边界 && dino左边界 < obs右边界 if ((dino_x + 24) <= obs_x || dino_x >= (obs_x + obs->width)) return false; // y重叠:dino上边界 < obs下边界 && dino下边界 > obs上边界 // 飞鸟y坐标较低,需特殊处理 int16_t obs_y = (obs->type == OBSTACLE_BIRD) ? 20 : 48 - obs->height; if ((dino_y + 32) <= obs_y || dino_y >= (obs_y + obs->height)) return false; return true; }

优化说明:dino_x + 24 <= obs_x等价于dino右边界 ≤ obs左边界,此时无重叠;用整数比较替代浮点,速度提升5倍。飞鸟的obs_y设为20(距顶部20像素),因其飞行高度固定。


5. 避坑:硬件与裸机开发中5个血泪教训

5.1 现象:OLED屏幕全白或全黑,但SPI通信波形正常

原因:SH1106的RES(复位)引脚未正确拉低再拉高。该芯片要求复位脉冲宽度≥10μs,且必须在发送任何指令前执行。若用普通GPIO模拟复位,GPIO_ResetBits()后立即GPIO_SetBits(),因IO翻转延时不足,实际脉冲可能仅100ns。
解决:改用RCC->APB2ENR使能AFIO时钟,配置RES为复用推挽输出,并插入__NOP()延时:

GPIOA->BSRR = GPIO_BSRR_BR3; // PA3复位引脚拉低 for(volatile uint32_t i=0; i<100; i++) __NOP(); // 延时约10μs GPIOA->BSRR = GPIO_BSRR_BS3; // 拉高

5.2 现象:小恐龙跳跃时出现“瞬移”或卡顿,帧率忽高忽低

原因:SysTick_Handler中执行了耗时操作(如printf调试、未优化的memset)。裸机环境下,SysTick中断必须在1ms内返回,否则后续中断被压栈导致计时漂移。实测memset(next_buffer, 0, 1024)在-O0下耗时1.2ms,超出阈值。
解决:用汇编CLRM指令清零(CMSIS提供__aeabi_memclr4),或改用for循环+__builtin_assume提示编译器优化:

for(uint16_t i=0; i<1024; i+=4) { ((uint32_t*)next_buffer)[i/4] = 0; // 一次清4字节 }

5.3 现象:游戏运行几分钟后崩溃,串口打印“HardFault”

原因:obstacle_queue环形队列指针越界。当queue_head == queue_tail时,队列为空;但若生成障碍时未检查((queue_tail+1)%8) == queue_head(满队列),继续写入会覆盖queue_head位置,导致后续读取非法内存。
解决:在Obstacle_Generate()开头添加满队列检查:

if (((queue_tail + 1) % 8) == queue_head) { return; // 队列已满,丢弃本次生成 }

5.4 现象:按下按键后小恐龙无反应,但用示波器测到PA1有电平变化

原因:按键消抖未做硬件处理,且软件消抖逻辑错误。裸机按键需10–20ms延时消抖,若在EXTI1_IRQHandler中直接调用Delay_ms(20),会阻塞所有中断。
解决:用SysTick计数器实现非阻塞消抖。定义全局变量key_press_time[2]记录按键按下时刻,在SysTick_Handler中检查:

if (key_state == KEY_PRESSED && (SysTick->VAL - key_press_time[0]) > 20000) { // 20ms后确认为有效按键 Dino_Jump(); key_state = KEY_IDLE; }

5.5 现象:掉电重启后分数归零,Flash存储的分数未保存

原因:STM32F103的Flash写入需先擦除扇区(1KB),而FLASH_ProgramWord()只能写入4字节。若直接写分数到0x0800F000(最后1KB扇区),未执行FLASH_ErasePage(),写入无效。
解决:定义专用扇区(如0x0800FC00),每次存分前擦除:

FLASH_Unlock(); FLASH_ErasePage(0x0800FC00); FLASH_ProgramWord(0x0800FC00, score); // 存储分数 FLASH_Lock();

注意:Flash擦写寿命约1万次,故只在游戏结束时保存,非每帧写入。


6. 进阶技巧:用JTAG实时调试状态机与性能瓶颈定位

6.1 在Keil MDK中配置SWO输出:免串口监控关键变量

ST-Link V2支持SWO(Serial Wire Output)通道,可将变量值实时输出到Debug Log窗口,无需占用UART引脚。配置步骤:

  1. 在Options for Target → Debug → Settings → Trace中勾选Enable Trace,设置Core Clock = 72MHz
  2. 在main.c中初始化SWO:
// 启用ITM(Instrumentation Trace Macrocell) CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; ITM->LAR = 0xC5ACCE55; // 解锁ITM寄存器 ITM->TCR |= ITM_TCR_ITMENA_Msk; // 使能ITM ITM->TER |= 1; // 使能端口0
  1. 在Game_Update()中插入监控:
ITM_SendChar('F'); // 帧率标识 ITM_Send32(game_frame_count); // 发送当前帧数

效果:Debug Log中实时显示F12345,配合SysTick计数可精确计算FPS,避免串口波特率限制。

6.2 使用Event Recorder分析状态机耗时

Keil自带Event Recorder可记录函数进入/退出事件。在Dino_Jump()和Obstacle_Update()开头添加:

EVENT_RECORD_0(0x10); // 自定义事件ID 0x10 // ... 函数逻辑 ... EVENT_RECORD_0(0x11); // ID 0x11

编译后打开View → Analysis Windows → Event Recorder,可看到每个函数执行时间(如Dino_Jump平均耗时83μs),直观定位性能瓶颈。

6.3 Flash存储分数的可靠性加固表

为防止意外断电导致Flash写入损坏,采用双备份+校验机制:

地址内容校验方式更新策略
0x0800FC00分数(4字节)CRC32校验值每次存分时计算并写入下一地址
0x0800FC04CRC32(4字节)—
0x0800FC08备份分数(4字节)CRC32校验值与主分区交替更新
0x0800FC0C备份CRC32(4字节)—

读取逻辑:先读主分区,校验CRC;若失败,读备份分区。此设计使Flash写入可靠性提升至99.99%。

从那以后我每次做裸机UI项目,都强制走一遍SWO监控+Event Recorder分析,哪怕只是点亮一个LED——因为嵌入式没有“差不多”,只有“时序严丝合缝”或“永远不知道哪一帧会崩”。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询