尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32F103时钟树到72MHz实战:RCC、OLED驱动与Timer2扫雷

STM32F103时钟树到72MHz实战:RCC、OLED驱动与Timer2扫雷 简介这是一份基于STM32F103微控制器的裸机嵌入式学习资源聚焦时钟系统配置、OLED屏幕驱动并实现了一个基于定时器与中断的扫雷小游戏适合希望借助完整工程掌握Keil MDK开发的初学者。资源共147个文件压缩包仅468KB主体为56个C源文件和57个头文件另含BMP图片、TXT说明、启动汇编文件、Hex烧录文件及Keil工程配置uvprojx/uvoptx方便直接打开编译学习工程内还提供辅助清理脚本便于保持工作区整洁。已有277人学习下载。资源通过OLED显示实战串起GPIO、I2C/SPI、时钟预分频、定时器与中断等知识点解决裸机开发中时钟配置不直观、OLED驱动无从下手、缺少综合练习项目的常见痛点涉及AHB/APB预分频器设置、时钟源选择等关键细节扫雷小游戏部分给出完整图形界面与交互逻辑有助于快速外设驱动向综合项目的过渡。1. 翻开 clock-stm32 工程前先想清楚 STM32F103 的 72MHz 从哪来把 clock-stm32 这个 Keil ARM 工程解开之后第一眼看到的往往是keilkilll.bat和一堆 BMP 图标很容易把它当成一个普通 OLED Demo。真正跑起来才会发现里面既有基于 STM32F103 的时钟显示又混进了一个 8×8 的 STM32 扫雷小游戏。对刚接触标准外设库的人来说最劝退的不是扫雷算法而是 RCC 时钟树、SSD1306 显存和定时器中断全挤在一个裸机工程里牵一发动全身。这篇就以这套源码为主线把 HSE 倍频到 72MHz、OLED 刷图标、扫雷计时状态机这三段拆开讲适合正在用 Keil uVision 调 F103 的入门者也适合想快速搭建 OLED 菜单加小游戏的实战派。2. Keil ARM 里的 RCC 初始化与时钟树排序很多 STM32 工程第一版都能编译过但 OLED 刷新慢、串口波特率偏问题大多不在外设而是 APB1/APB2 分频没按手册来。F103 最高主频是 72MHz但这个频率不是上电默认值——上电后 CPU 先跑内部 HSI 8MHz。只有通过 PLL 倍频、再切换到 PLLCLK才能拿到完整的性能。2.1 为什么 8MHz HSE 要一路倍频成 72MHz外部晶振一般是 8MHzSTM32F103 的内部 PLL 可以把它倍频到最高 72MHz。这个数值不是随便填的FLASH 的等待周期、AHB/APB1/APB2 的分频系数都跟着它走。一旦 APB1 上挂了 I2C、USART2、TIM2而 APB1 给了 72MHz外设就可能异常。总线最高频率这个工程常用配置说明AHB72 MHzHCLK SYSCLK 72MHzGPIO、Flash 接口、DMA 挂在 AHBAPB136 MHzHCLK / 2 36MHzUSART2/3、I2C1、TIM2/3/4、RTCAPB272 MHzHCLK / 1 72MHzUSART1、SPI1、ADC、TIM1有一个容易被忽略的规则只要 APB1 预分频系数不是 1挂在 APB1 上的定时器时钟会自动变成 PCLK1 的 2 倍。所以 APB136MHz 时TIM2 的实际时钟是 72MHz后面做 1ms tick 时 PSC 和 ARR 都要按 72MHz 算而不是 36MHz。2.2 用标准外设库在 main 之前把 PLL 点亮标准外设库 V3.5 里system_stm32f10x.c的SystemInit()会执行一次默认时钟设置。我的习惯是不完全依赖它而是在 main 最前面显式调用自己的时钟函数方便改晶振频率和调试。void RCC_ClockConfig(void) { RCC_DeInit(); // 打开外部高速晶振 HSE RCC_HSEConfig(RCC_HSE_ON); while (RCC_WaitForHSEStartUp() ! SUCCESS); // 真机调试要加超时否则晶振没起会一直卡住 // 72MHz 主频必须配 2 个 Flash 等待周期 FLASH_PrefetchBufferCmd(ENABLE); FLASH_SetLatency(FLASH_Latency_2); // 总线分频AHB 不分频APB1 二分频APB2 不分频 RCC_HCLKConfig(RCC_SYSCLK_Div1); RCC_PCLK1Config(RCC_HCLK_Div2); RCC_PCLK2Config(RCC_HCLK_Div1); // HSE 8MHz x 9 72MHz RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) RESET); // 切换到 PLL 作为系统时钟 RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() ! 0x08); // 0x08 表示 SWS10时钟源是 PLL }FLASH_Latency_2对应 48~72MHz 主频如果主频降到 36MHz 以下可以改成FLASH_Latency_1。RCC_PLLSource_HSE_Div1表示 HSE 不分频直接进 PLL最后的RCC_GetSYSCLKSource() ! 0x08是确认切换完成。这套配置同时决定了后面 OLED 用 I2C 的位宽、串口波特率、Timer2 的计数频率所以先调禁区这一步省不了。2.3 顺手处理掉 keilkilll.bat 和 Keil 中间文件仓库根目录里的keilkilll.bat是个很实用的小脚本作用是把 Keil 编译产生的临时目录和崩溃恢复文件清掉方便备份和上传。它的内容一般长这样echo off echo 正在清理 STM32 Keil 工程... if exist Objects rd /s /q Objects if exist Listing rd /s /q Listing del /f /q *.uvguix.* *.bak *.dep *.crf *.d 2nul echo 清理完成 pauserd /s /q是强制递归删除目录2nul把删除不存在的文件时报错压掉。*.uvguix.*是 uVision 窗口布局文件删掉后重新打开工程会恢复默认视图*.dep是依赖文件会在下次编译时重建。要注意如果工程正在 Keil 里打开最好先关闭 IDE 再执行否则某些文件被占用会删不掉。3. 裸机驱动 SSD1306把 doge.bmp 塞进 OLED 显存STM32F103 驱动 0.96 寸 OLED 最常见的控制器是 SSD1306接口有 I2C 和 SPI 两种。这个工程里能看到doge.bmp、闹钟图标.bmp、播放器.bmp等资源说明界面不是简单字符串而是把图标转成 C 数组后直接写显存。3.1 SSD1306 的页式显存与 I2C 接线SSD1306 的显存是 128×64 位按 8 行一页组织总共 8 页PAGE0~PAGE7。写数据时先设置页地址和列地址然后连续写入。I2C 总线上的每个数据包结构是从机地址 控制字节 数据字节。控制字节0x00表示后面跟的是命令0x40表示后面跟的是显示数据。参数典型值驱动 ICSSD1306分辨率128×64I2C 地址0x3CSA0 接地/ 0x3D通讯速率100~400 kHz驱动电压3.3V这里我建议用 GPIO 模拟 I2C而不是 STM32F103 的硬件 I2C。标准外设库下的硬件 I2C 在连续读写时容易出现BUSY标志清不掉的问题GPIO 模拟反而稳定而且引脚可以随意换。3.2 GPIO 模拟 I2C 的上屏流程先把引脚宏定义好比如 SCL 接 PB8、SDA 接 PB9#define OLED_SCL PBout(8) #define OLED_SDA PBout(9) static void I2C_Start(void) { OLED_SDA 1; OLED_SCL 1; delay_us(2); OLED_SDA 0; // SDA 在高电平期间拉低代表起始信号 delay_us(2); OLED_SCL 0; } static void I2C_Stop(void) { OLED_SDA 0; OLED_SCL 1; // SCL 高电平期间 SDA 拉高代表停止信号 delay_us(2); OLED_SDA 1; } static void I2C_SendByte(uint8_t dat) { for (uint8_t i 0; i 8; i) { OLED_SCL 0; if (dat 0x80) OLED_SDA 1; else OLED_SDA 0; dat 1; delay_us(1); OLED_SCL 1; delay_us(1); } OLED_SCL 0; }上面没有读 ACK因为 OLED 作为从机写数据时响应很快单向写足够。命令写入函数再封一层void OLED_WrCmd(uint8_t cmd) { I2C_Start(); I2C_SendByte(0x3C 1); // 7 位地址左移一位最低位是写方向 I2C_SendByte(0x00); // 控制字节命令 I2C_SendByte(cmd); I2C_Stop(); }上电后要发一串初始化命令比较关键的是0xAF开显、0x20设置寻址模式、0xC8设置扫描方向。完整序列这样写void OLED_Init(void) { OLED_WrCmd(0xAE); // 关显示 OLED_WrCmd(0x20); OLED_WrCmd(0x00); // 水平寻址 OLED_WrCmd(0xB0); // PAGE0 OLED_WrCmd(0xC8); // 扫描方向重映射 OLED_WrCmd(0x81); OLED_WrCmd(0xCF); // 对比度 OLED_WrCmd(0xA1); // 段重映射 OLED_WrCmd(0xA6); // 正常显示 OLED_WrCmd(0xA8); OLED_WrCmd(0x3F); // 64 行复用 OLED_WrCmd(0xAF); // 开显示 }显示一张位图时按页拆分发送。假设图片宽度是w高度是h图片数据已经按“逐列式”排好void OLED_ShowImage(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t *img) { uint8_t pages (h 7) / 8; // 超过 8 像素高的图分成多页 for (uint8_t p 0; p pages; p) { OLED_WrCmd(0xB0 y / 8 p); // 设置页地址 OLED_WrCmd(0x00 (x 0x0F)); // 列地址低 4 位 OLED_WrCmd(0x10 ((x 4) 0x0F)); // 列地址高 4 位 I2C_Start(); I2C_SendByte(0x3C 1); I2C_SendByte(0x40); // 控制字节数据 for (uint8_t i 0; i w; i) { I2C_SendByte(img[p * w i]); } I2C_Stop(); } }OLED_ShowImage每次都重建 I2C 起始和停止信号是为了防止数据量大时从机接受过载。实际项目里如果刷新频繁可以把整个img一次性连发只在开头和结尾各做一次 Start/Stop速度会快很多。3.3 把 BMP 转成 C 数组并避开取模方向项目里的 BMP 不是给 OLED 直接读的文件而是取模工具生成的原始输出。常见做法是用 Image2Lcd 打开doge.bmp输出格式选“C 语言数组”颜色选“单色”最大宽度设成 128高度按实际图片缩到 64。Image2Lcd 参数推荐值输出类型C 数组最大宽度/高度128 / 64颜色单色扫描方式逐列式字节内位序低位在前如果烧进去发现图片左右反了一般是段重映射没配对上下反了要改扫描方向或者把图片数组从最后一行往前推。更快的验证方法是先烧一个整屏 128×64 的doge.bmp转出来的数组是 1024 字节显示正确后再缩小成小图标这样能快速定位是取模问题还是驱动问题。4. 扫雷逻辑与 Timer2 中断时间显示和游戏共用一个 tick扫雷游戏要有计时时钟页面也要走秒。如果两个功能各写一个 delayOLED 刷新会被阻塞。这个工程的合理做法是把 Timer2 配置成 1ms 中断所有时间都从一个全局 tick 里取。4.1 用 Timer2 生成 1ms 基准STM32F103 的 TIM2 挂在 APB1 上。前面 APB1 分频为 2所以 TIM2 的输入时钟是 72MHz。要得到 1ms 中断先让PSC71把计数时钟降成 1MHz再让ARR999计数 1000 次后溢出一次。void TIM2_Init(void) { TIM_TimeBaseInitTypeDef tim; NVIC_InitTypeDef nvic; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); tim.TIM_Prescaler 72 - 1; // 72MHz / 72 1MHz tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period 1000 - 1; // 1MHz 计 1000 次 1ms tim.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, tim); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); nvic.NVIC_IRQChannel TIM2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 0; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); TIM_Cmd(TIM2, ENABLE); }如果想把中断周期改成 10ms可以直接换一组参数PSCARR中断周期719991ms71999910ms71999910ms注意PSC和ARR的代数值都是“寄存器值 目标值 - 1”。这个细节初学容易写错一旦 Timer2 不进中断先查这里。4.2 扫雷的布雷、邻居统计和翻开扫雷网格在这个工程里可以做得比较小比如 8×8 的格子放 10 颗雷。数据结构用两个二维数组就够了一个记雷的位置一个记玩家看到的状态。#define ROWS 8 #define COLS 8 uint8_t mine_map[ROWS][COLS]; // 0 安全1 雷 uint8_t disp_map[ROWS][COLS]; // 0 未翻开1 翻开2 插旗 void GenerateMines(uint8_t mine_count) { uint8_t placed 0; srand(g_msTick ^ TIM_GetCounter(TIM2)); // 用系统运行时间和硬件计数器做随机种子 while (placed mine_count) { uint8_t x rand() % COLS; uint8_t y rand() % ROWS; if (mine_map[y][x] 0) { mine_map[y][x] 1; placed; } } }用g_msTick ^ TIM_GetCounter(TIM2)做种子是因为玩家按键进入游戏时两个值都有随机性避免每局雷的位置一模一样。翻开一个格子的核心是“邻居统计”。扫雷规则是统计周围 8 个格子里有多少雷const int8_t dx[8] {-1, 0, 1, -1, 1, -1, 0, 1}; const int8_t dy[8] {-1, -1, -1, 0, 0, 1, 1, 1}; uint8_t CountNeighbors(uint8_t y, uint8_t x) { uint8_t cnt 0; for (uint8_t k 0; k 8; k) { int8_t nx x dx[k]; int8_t ny y dy[k]; if (nx 0 || ny 0 || nx COLS || ny ROWS) continue; cnt mine_map[ny][nx]; } return cnt; }如果翻开的格子周围雷数是 0扫雷游戏会自动扩散翻开周围的格子。这个用递归实现最直观8×8 的网格深度很小不用担心栈溢出void OpenCell(uint8_t y, uint8_t x) { if (disp_map[y][x] ! 0) return; // 已翻开或已插旗 if (mine_map[y][x]) return; // 雷不应该从这进入 disp_map[y][x] 1; if (CountNeighbors(y, x) ! 0) return; for (uint8_t k 0; k 8; k) { int8_t nx x dx[k]; int8_t ny y dy[k]; if (nx 0 ny 0 nx COLS ny ROWS) { OpenCell(ny, nx); } } }这里把雷判断放在OpenCell开头是因为扩散时随时可能碰到边缘格子统一从入口拦截更安全。玩家踩雷后的处理单独写不要混进OpenCell。4.3 主循环轮询按键ISR 只置标志嵌入式里最常见的实时性问题是把耗时操作写进中断服务函数。OLED 刷一块 128×64 的图片要传输 1024 个字节在 I2C 上用软件模拟时大约几毫秒如果直接塞进 Timer2 中断1ms 的 tick 会立刻乱掉。正确做法是中断里只累加时间和置刷新标志volatile uint32_t g_msTick 0; volatile uint8_t g_needRefresh 0; static uint16_t s_refresh 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); g_msTick; if (s_refresh 50) { // 50ms 刷一帧避免 OLED 闪烁 s_refresh 0; g_needRefresh 1; } } }主循环里按键扫描也要按时间片抽稀直接用g_msTick做间隔判断uint32_t lastKeyTime 0; if (g_msTick - lastKeyTime 10) { // 10ms 去抖 lastKeyTime g_msTick; if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 0) { // 处理按键事件 } }这样扫雷计时、时钟走秒、按键去抖全部依赖同一个g_msTick不会出现游戏界面计时和时钟界面秒数不一致的问题。5. 时钟页面、设置页面和扫雷游戏收进同一个状态机裸机程序一旦页面多了很容易变成一坨 if-else。把界面切成状态每次只刷新当前状态是成本最低的架构。5.1 页面状态机与返回路径这个工程至少有三个状态时钟显示、设置闹钟、扫雷游戏。定义一个枚举用 switch 分发typedef enum { PAGE_CLOCK, PAGE_SETTING, PAGE_GAME } PAGE_T; PAGE_T g_page PAGE_CLOCK; void Page_Task(void) { switch (g_page) { case PAGE_CLOCK: OLED_ShowTime(); break; case PAGE_SETTING: OLED_ShowSetting(); break; case PAGE_GAME: Game_Task(); break; } }按键负责切页。我的习惯是用一个短按进入游戏、长按回时钟这样不容易误触。状态切换只改g_page渲染函数每次都完整重绘虽然慢一点但逻辑简单可靠。如果以后要加倒计时器只需要在枚举里多列一个PAGE_TIMER再补一个 case。5.2 用 32.768kHz 晶振校准秒差验证 1Hz 输出时钟页面如果只靠 Timer2 走秒误差来自 HSE 晶振本身的精度和负载电容匹配。STM32F103 的 RTC 可以外接 32.768kHz 晶振负载电容的计算公式是CL (C1 * C2) / (C1 C2) CstrayCstray是 PCB 走线和引脚寄生电容大概 1~3pF。如果晶振标称负载电容是 12.5pFC1 和 C2 各取 22pF 时22*22/44 1.5 ≈ 12.5pF这组参数比较稳。标称 6pF 的晶振一般配 8~10pF 电容标称 20pF 的配 33pF 左右。验证时建议把某个 GPIO 配置成每秒翻转一次直接用逻辑分析仪量 60 秒。Timer2 模式下通过改变 ARR 可以微调中断周期但更干净的方案是每秒做一个软件补偿统计实际走过的 1ms tick 数和理论值差多少差到一定量后跳一拍或补一拍。改完晶振匹配电容后不要急着收工把 1Hz 测试口接上看 60 秒内误差小于 1 秒才算合格。本文还有配套的精品资源点击获取
返回列表