STM32F103扫雷游戏开发:Keil时钟配置与SysTick定时器实战
发布时间:2026/9/16 1:30:45来源:尧图网络
简介这是一份基于STM32F103的裸机嵌入式项目包重点围绕时钟系统配置、OLED屏幕驱动和扫雷小游戏展开。项目使用Keil ARM MDK-ARM环境兼顾STM32基础外设编程与简单游戏开发适合刚接触STM32或想学习小型嵌入式项目的开发者。压缩包共147个文件以57个.h头文件和56个.c源文件为主体搭配.bmp界面素材、.txt说明文档、.s启动文件、.hex固件以及.uvprojx工程文件整体仅468KB结构清晰。目前已有277人学习下载。读者可对照源码理解时钟树配置、预分频器设置、OLED裸机驱动原理并通过扫雷游戏掌握定时器与中断的使用方法是一份兼顾基础与趣味性的STM32入门实操案例。1. STM32F103上写扫雷先把时钟和Keil ARM的工程顺序搞清楚把一个带 clock 的 stm32 扫雷程序烧进 STM32F103最容易翻车的不是扫雷判定而是时钟与界面刷新谁先谁后。标题里同时出现 keilarm、STM32F103 和两个 clock通常说明这是一份 Keil MDK-ARM 工程目标是在 F103 这类 Cortex-M3 上跑带计时或时钟功能的扫雷游戏。F103 没有 MMU也没有现成图形库硬件资源就这么多game loop 和定时器必须自己能组织起来。这篇文章按一条能落地的路径写先把 Keil MDK-ARM 的工程和时钟弄对再写扫雷的内存布局与逻辑最后用 SysTick 驱动按键、倒计时和 LCD 局部刷新。新手能跟着复现老手能看参数取舍。2. STM32F103 的时钟树和 Keil ARM 开发环境先于扫雷逻辑建起来STM32F103 的时钟树比新系列简单但恰恰太简单很多人反而容易忽略顺序。HSE 起振后要等 PLL 锁定再把 SYSCLK 切到 PLL中间漏一步外设要么不工作要么波特率和 SPI 时序会乱。Keil ARM 工程里启动代码、外设库宏和芯片包如果没对齐连点灯都会变成玄学。2.1 在 Keil5 里创建 STM32F103 工程启动文件与芯片包先对齐用 Keil MDK-ARM 创建工程时Pack Installer 里搜索 STM32F1 系列并安装对应芯片支持包然后选择 STM32F103C8 或具体型号。启动文件必须选startup_stm32f10x_md.s它是 medium density 芯片的启动文件如果选成stm32f10x_hd.s在 F103C8 上链接时会发现中断向量表不匹配或者烧进去后代码跳飞。这个选择看似基础却是 stm32 开发环境里最常见的第一道门槛。在 C/C 预处理器里要定义两个宏让标准外设库知道自己工作在哪个型号上STM32F10X_MD USE_STDPERIPH_DRIVER前者告诉库是中等容量芯片后者启用标准外设库的驱动封装。外部晶振频率HSE_VALUE也要确认很多开发板是 8MHzstm32f10x.h 里默认就是 8000000UL但如果你的板子用 12MHz 晶振这里不改后面所有时间计算都会错。2.2 手写 72MHz 时钟配置与 SysTick 时间基准标准外设库的system_stm32f10x.c已经通过SystemInit()把 HSE-PLL-SYSCLK 这条链走了一遍。常见做法是直接改这个文件里的PLL_MULT让它等于 9也就是 8MHz*972MHz。如果不想依赖库初始化也可以自己在main()里重写一份时钟初始化static void SystemClock_Reconfigure(void) { ErrorStatus hseStatus; RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); hseStatus RCC_WaitForHSEStartUp(); if (hseStatus SUCCESS) { FLASH_PrefetchBufferCmd(FLASH_PrefetchBuffer_Enable); FLASH_SetLatency(FLASH_Latency_2); RCC_HCLKConfig(RCC_SYSCLK_Div1); /* AHB SYSCLK */ RCC_PCLK1Config(RCC_HCLK_Div2); /* APB1 36 MHz */ RCC_PCLK2Config(RCC_HCLK_Div1); /* APB2 72 MHz */ RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) RESET) {} RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() ! 0x08) {} SystemCoreClock 72000000UL; } else { while (1) {} } }这里有几个参数需要说清楚。RCC_PCLK1Config(RCC_HCLK_Div2)把 APB1 压在 36MHz因为 F103 的 APB1 最大支持 36MHzAPB2 可以到 72MHz所以不分频。PLL 倍频用RCC_PLLMul_9配合 HSE 8MHz 正好输出 72MHz。RCC_DeInit()会把启动代码配好的时钟复位所以这段代码执行后SystemCoreClock这个全局变量必须手动更新否则后面SysTick_Config(SystemCoreClock / 1000)会拿旧值算导致毫秒基准偏差。时钟初始化完成后再配置 SysTickif (SysTick_Config(SystemCoreClock / 1000)) { while (1) { /* 配置失败SystemCoreClock 值不对或 SysTick 被占用 */ } }SysTick_Config接受的是多少次 tick 产生一次中断。72MHz 除以 1000得到 72000也就是每 72000 个时钟周期进一次 SysTick 中断精确得到 1ms 时间基准。这个 1ms 是整个扫雷时钟的底座。时钟树参数表如下时钟分频/倍频输出频率HSE不分频8 MHzPLLx972 MHzSYSCLKPLLCLK72 MHzAHB/172 MHzAPB1/236 MHzAPB2/172 MHz要验证这个配置是否正确最直接的办法是看 Keil 的 Register 窗口里 RCC-CFGR 的 SWS 位如果是 0x08说明 SYSCLK 已经切到 PLL。否则就去查 HSE 是否起振或 PLL 是否锁定。2.3 中断优先级要留给 stm32 定时器SysTick 只管心跳扫雷里除了 SysTick还会用到 stm32 定时器做更精确定时。F103 的 TIM2 在 APB1 上当 APB1 分频系数不为 1 时定时器时钟是 APB1 的两倍。这里 APB136MHz所以 TIM2 输入时钟是 72MHz。我一般在扫雷工程里把 SysTick 当作心跳源TIM2 专门负责倒计时秒数两个时钟源分开避免显示刷新动作拖垮游戏计时。中断优先级建议这样分配中断抢占优先级用途SysTick3更新按键扫描和界面标志TIM21游戏倒计时保证不丢秒按键 EXTI2响应光标移动和翻开动作使用前先调用NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)把抢占优先级设为 2 位。SysTick 优先级设成 3不会抢占 TIM2 的秒更新但 SysTick 仍然能保证主循环里的扫描节奏。代码里写成NVIC_SetPriority(SysTick_IRQn, 3); NVIC_SetPriority(TIM2_IRQn, 1);把游戏时钟和界面心跳分开后即使 LCD 刷新慢也不会出现“扫雷倒计时越来越慢”这种问题。3. 扫雷逻辑在 STM32F103 上的 RAM 排布格子位域与枚举展开扫雷逻辑本身不复杂但放到 STM32F103 上要考虑 RAM。F103C8 只有 20KB SRAM如果用全屏帧缓冲加复杂二维数组很容易把内存吃紧。合理的做法是让每个格子只占一个 uint8_t用位来区分状态。3.1 坐标与掩码用 uint8_t 一桶装扫雷格定义一个 10x10 的地图整个地图占 100 字节加上辅助变量不超过 256 字节F103 完全放得下。一个格子同时要记录周围地雷数量、是否是地雷、是否翻开、是否插旗这四个信息可以压缩在 8 位里#define MAP_W 10 #define MAP_H 10 #define CELL_MINE 0x10 /* bit4: 地雷 */ #define CELL_OPEN 0x20 /* bit5: 已翻开 */ #define CELL_FLAG 0x40 /* bit6: 插旗 */ #define CELL_CURSOR 0x80 /* bit7: 光标选中 */ #define CELL_CNT_MASK 0x0F /* bit0~bit3: 周围地雷数 */ typedef struct { uint8_t grid[MAP_H][MAP_W]; uint8_t mineTotal; uint8_t opened; uint8_t flagged; } MineMap;位掩码的意义是后续判断可以只用位运算不需要比较整个结构体。比如判断一个格子能不能翻开就检查grid[y][x] (CELL_OPEN | CELL_MINE | CELL_FLAG)只要这个表达式为 0就是可翻开的安全格。这样做还有一个好处Keil ARM 编译器会把这些位运算直接翻译成 LDRB、AND、ORR 指令效率比封装函数高得多。周边格子坐标可以预定义成偏移表static const int8_t around[8][2] { {-1,-1}, {-1,0}, {-1,1}, {0,-1}, {0,1}, {1,-1}, {1,0}, {1,1} };用偏移表而不是在遍历里写八个坐标能减少写出越界条件的概率。后面计算地雷数和递归展开都依靠这张表。3.2 第一次点击后布雷LFSR 伪随机与安全邻域扫雷不能开局第一下就踩雷所以第一次点击后要排除该点周围 3x3 区域在其余格子里布雷。常见的作弊做法是“随机取下标遇到重复就跳过”这在格子数少时没问题到 16x16 时会增加循环次数。这里用剪枝后的候选数组从候选格里取固定数量static uint32_t s_rng 0x12345678UL; static uint32_t lfsr_next(void) { s_rng ^ (s_rng 13); s_rng ^ (s_rng 17); s_rng ^ (s_rng 5); return s_rng; } static void place_mines(MineMap *map, uint8_t mineTotal, uint8_t safeX, uint8_t safeY) { uint8_t candidates[MAP_W * MAP_H]; uint8_t n 0; for (uint8_t y 0; y MAP_H; y) { for (uint8_t x 0; x MAP_W; x) { if (x safeX - 1 x safeX 1 y safeY - 1 y safeY 1) { continue; } candidates[n] y * MAP_W x; } } for (uint8_t i 0; i mineTotal i n; i) { uint8_t r i (uint8_t)(lfsr_next() % (n - i)); uint8_t tmp candidates[i]; candidates[i] candidates[r]; candidates[r] tmp; uint8_t idx candidates[i]; map-grid[idx / MAP_W][idx % MAP_W] | CELL_MINE; } }lfsr_next()是一个简单的线性反馈移位寄存器不需要额外包含stdlib.h。这里没有用rand()原因是 armcc 的库实现会引入较大代码体积而且每次烧写复位后伪随机序列固定调试起来反而不稳定。可以把s_rng的初始值设为SysTick-VAL或TIM2-CNT这样每次上电比分的随机性来自上电后延时时间。布雷完成后再计算每个格子周围的地雷数static void fill_adjacent_counts(MineMap *map) { for (uint8_t y 0; y MAP_H; y) { for (uint8_t x 0; x MAP_W; x) { if (map-grid[y][x] CELL_MINE) { continue; } uint8_t cnt 0; for (int8_t i 0; i 8; i) { int8_t nx x around[i][0]; int8_t ny y around[i][1]; if (nx 0 || nx MAP_W || ny 0 || ny MAP_H) { continue; } if (map-grid[ny][nx] CELL_MINE) { cnt; } } map-grid[y][x] (map-grid[y][x] ~CELL_CNT_MASK) | cnt; } } }这里的(map-grid[y][x] ~CELL_CNT_MASK)是先把低 4 位清掉再写入新地雷数。位操作必须先清后写否则旧值会和新值做 OR导致计数错误。3.3 递归翻开与胜负判定避免 HardFault 的栈边界点到一个周围没有地雷的格子需要自动展开连通区域。最直观的写法是递归但 F103 的默认栈大小在启动文件里通常是Stack_Size EQU 0x400也就是 1KB。8x8 扫雷递归最大深度可到 64每个栈帧要保存函数参数、局部变量、返回地址深度一深就可能溢出。更稳的做法是显式队列 BFSstatic void reveal_from(MineMap *map, uint8_t x, uint8_t y) { uint8_t qx[MAP_W * MAP_H]; uint8_t qy[MAP_W * MAP_H]; uint8_t head 0; uint8_t tail 0; if (map-grid[y][x] (CELL_OPEN | CELL_FLAG)) { return; } qx[tail] x; qy[tail] y; tail; while (head tail) { uint8_t cx qx[head]; uint8_t cy qy[head]; head; uint8_t cell map-grid[cy][cx]; if (cell (CELL_OPEN | CELL_FLAG)) { continue; } map-grid[cy][cx] | CELL_OPEN; map-opened; if ((cell CELL_CNT_MASK) ! 0) { continue; } for (int8_t i 0; i 8; i) { int8_t nx cx around[i][0]; int8_t ny cy around[i][1]; if (nx 0 || nx MAP_W || ny 0 || ny MAP_H) { continue; } if ((map-grid[ny][nx] (CELL_OPEN | CELL_MINE | CELL_FLAG)) 0) { qx[tail] nx; qy[tail] ny; tail; } } } }队列大小直接取MAP_W * MAP_H最坏情况是全部格子入队不会越界。这个实现把栈消耗控制在固定大小Keil 里也不会因为递归太深触发 HardFault。三种常见尺寸的内存占用如下扫雷尺寸格子数队列占用建议栈大小8x864128 字节0x40010x10100200 字节0x40016x16256512 字节0x800胜负判定很简单已翻开格数加上地雷数等于总格数就是胜利。因为布雷时已经把地雷数存在map-mineTotal所以判定代码为uint8_t check_win(MineMap *map) { if (map-opened map-mineTotal MAP_W * MAP_H) { return 1; } return 0; }这里要注意opened是 uint8_t最大只能到 25516x16 扫雷的已翻开格数可能等于 256这个变量要换成 uint16_t否则永远无法判定胜利。4. 用 SysTick 驱动扫雷界面按键去抖与 LCD 局部刷新STM32F103 上做扫雷界面我最推荐的方式是“SysTick 定时扫按键主循环处理状态机界面只画变化区域”。不要在 SysTick 中断里直接操作 LCD也不要在主循环里用 delay 消抖否则整个游戏时钟都会被拖乱。4.1 非阻塞按键处理与去抖状态机按键接在 GPIO 上常见采用低电平有效。用一个 5ms 的 SysTick 标志驱动按键采样连续三次读到相同电平才认为按键稳定这就是一个经典的去抖状态机#define KEY_MASK (GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2) uint8_t read_key(void) { return GPIOA-IDR KEY_MASK; } uint8_t key_scan_with_debounce(void) { static uint8_t stable 0xFF; static uint8_t pending 0xFF; static uint8_t cnt 0; uint8_t level read_key(); if (level ! pending) { pending level; cnt 0; } else if (cnt 3) { stable pending; cnt 0; } return stable; }stable返回的是 GPIO IDR 里三个按键的当前稳定电平。三个按键分别对应光标上移、翻开、插旗。如果想用矩阵键盘扫描 4x4可以把read_key()改成一次行列扫描返回一个按键编号去抖逻辑不变。SysTick 中断里只置标志不做按键处理volatile uint8_t g_key_scan_need 0; volatile uint8_t g_tick_flag 0; volatile uint8_t g_render_request 0; void SysTick_Handler(void) { static uint8_t divider 0; g_tick_flag 1; if (divider 5) { divider 0; g_key_scan_need 1; } }g_tick_flag每 1ms 置一次g_key_scan_need每 5ms 置一次。中断里只写变量不调用函数这样即使 LCD 刷新占用时间也不会破坏中断执行时长。按键功能可以这样映射按键功能KEY_UP移动光标KEY_ENTER翻开格子KEY_FLAG插旗/取消插旗KEY_RESET重新开局4.2 主循环里的游戏状态机倒计时不阻塞主循环按照“时间标志 - 按键事件 - 渲染标志”的顺序执行typedef enum { ST_MENU, ST_PLAY, ST_WIN, ST_LOSE } GameState; typedef struct { GameState state; uint16_t timeLeft; } Game; int main(void) { SystemClock_Reconfigure(); SysTick_Config(SystemCoreClock / 1000); NVIC_SetPriority(SysTick_IRQn, 3); lcd_init(); key_init(); game.state ST_MENU; game.timeLeft 0; while (1) { if (g_tick_flag) { g_tick_flag 0; on_game_tick(game); } if (g_key_scan_need) { g_key_scan_need 0; uint8_t key key_scan_with_debounce(); if (key ! 0xFF) { g_render_request 1; handle_key(game, map, key); } } if (g_render_request) { g_render_request 0; render_all(map, game); } } }on_game_tick函数负责倒计时void on_game_tick(Game *g) { static uint16_t ms 0; if (g-state ! ST_PLAY) { ms 0; return; } if (ms 1000) { ms 0; if (g-timeLeft 0) { g-timeLeft--; } if (g-timeLeft 0) { g-state ST_LOSE; } g_render_request 1; } }这里把秒数放在on_game_tick里换算而不是在 SysTick 中断里累加 1000 次原因很简单SysTick 中断里如果执行g-timeLeft--一旦同时按下 RESET 键状态机切换会与中断产生竞态。主循环里处理倒计时配合g_render_request整个游戏状态是单线程访问不会出现中断把状态改了一半的问题。4.3 LCD 局部刷新为什么全屏重绘会让时钟变慢用 SPI 接口的 LCD 做扫雷时最典型的错误是每移动一次光标就Clear()清屏。以一个 160x128 的 LCD 为例清一次屏写 20KB 数据SPI 如果跑 18MHz大约也要几十毫秒。倒计时每 1 秒变一次用户每按一次键就卡顿一次体验很差。解决办法是只刷新变化的格子。移动光标只重画旧光标位置和新光标位置翻开格子只重画那一格。定义格子绘制函数#define CELL_W 12 #define CELL_H 12 #define BOARD_LEFT 20 #define BOARD_TOP 20 void draw_cell_at(MineMap *map, uint8_t row, uint8_t col, uint16_t bgColor) { uint8_t v map-grid[row][col]; uint16_t x BOARD_LEFT col * CELL_W; uint16_t y BOARD_TOP row * CELL_H; lcd_fill_rect(x, y, CELL_W, CELL_H, bgColor); if (v CELL_OPEN) { if ((v CELL_CNT_MASK) ! 0) { lcd_draw_num(x 3, y 2, v CELL_CNT_MASK, 0x0000); } else { lcd_fill_rect(x 3, y 3, CELL_W - 6, CELL_H - 6, 0xFFFF); } } else if (v CELL_FLAG) { lcd_fill_rect(x 4, y 1, 5, 10, 0xF800); } }lcd_fill_rect是底层画矩形函数只更新网格上的一小块区域。每次按键后相当于只写几十个字节而不是整帧。SPI 带宽压力小很多SysTick 的 1ms 心跳也不会被长时间卡住。5. 在 Keil MDK-ARM 里校准扫雷时钟调试器、串口和硬件定时器测量扫雷的 clock 部分最容易出的问题不是代码逻辑而是“以为 1 秒到了实际上已经过了 1.2 秒”。这是因为 F103 的时钟树配置错误或者SystemCoreClock没有同步更新。校准方法有三层从软件到硬件逐层验证。5.1 用 printf 重定向把扫雷计时抓到串口在 Keil MDK-ARM 里勾选 Use MicroLIB可以把 printf 重定向到串口。这样调试扫雷计时时不再依赖仿真器窗口直接看串口输出int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET) {} USART_SendData(USART1, (uint8_t)ch); return ch; }在倒计时处理里加一句输出每一秒打一个值if (ms 0) { printf(timeLeft%d\r\n, g-timeLeft); }然后拿秒表或 Keil 仿真器的执行时间核对。如果串口打印速度比真实秒表慢就是 HSE、PLL 或SysTick_Config三个环节有一个不对。5.2 用 DWT 和 Keil 仿真器验证毫秒基准F103 的 Cortex-M3 自带 DWT 计数器可以直接用 CPU 周期数测量一段代码的执行时间。先把调试工具打开CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk;测量时可以翻转一个 GPIO然后在 Keil 的逻辑分析仪窗口里看周期。先把某个引脚置高delay 后用串口读DWT-CYCCNT的差值uint32_t start DWT-CYCCNT; delay_loops(1000); uint32_t elapsed DWT-CYCCNT - start; printf(cycles%d\r\n, elapsed);如果系统时钟确实是 72MHzelapsed应该接近 1000 个 72MHz 周期。如果偏差超过 5%就要去看system_stm32f10x.c里的PLL_MULT是否真是 9以及HSE_VALUE是否和板载晶振一致。另一个好用的验证点是 Keil 的 Simulator。把 Options for Target - Debug 切到 Use SimulatorDialogue 外部晶振改成 8MHz然后单步执行RCC_PLLConfig之后能直接看到 SYSCLK 变化。这个方式不需要硬件适合在开始写扫雷界面之前先确认时钟链路。最后留一个可执行的技巧给关键中断加一个计数变量volatile uint32_t g_tick_count在 SysTick_Handler 里执行g_tick_count。在 Keil 的 Watch 窗口里观察它的增长比任何分频猜测都准。把这句话留在工程里先确认 SYSCLK 等于 72000000UL再让 SysTick 中断跑起来否则扫雷结束后回放时间线时你会分不清是按键问题还是 clock 问题。本文还有配套的精品资源点击获取
网站建设高端定制企业官网