STM32驱动SSD1306 OLED显示60s倒计时:SPI、定时器与显存寻址实践

发布时间:2026/9/12 16:57:36
STM32驱动SSD1306 OLED显示60s倒计时:SPI、定时器与显存寻址实践 简介一份基于STM32微控制器实现60秒倒计时显示任务的完整工程与源码包面向嵌入式初学者、单片机开发人员及电子竞赛备赛者。工程覆盖显示屏驱动、定时器计数、中断服务程序和GPIO引脚配置等核心知识点涉及LCD、OLED等常见显示设备与SPI、I2C等接口方式可帮助读者掌握外设协同调用与实时刷新实现。压缩包共140个文件以36个H头文件与34个C源文件为主体涵盖STM32定时器、串口、显示、I2C等外设驱动源码另有Keil工程配置、编译生成文件及辅助批处理脚本整体仅2.69MB目录结构清晰便于快速定位参考。目前已有4337人学习浏览。其中包含可直接编译的完整Keil工程覆盖定时器初始化、中断处理和LCD按分钟或秒格式刷新剩余时间的过程读者可在开发板上烧录验证倒计时效果也能借鉴其中的显示驱动与定时器设计思路迁移到其他嵌入式显示任务中。1. 一块屏和一个定时器STM32 倒计时项目到底在练什么做过几个带屏幕的项目之后你会发现“STM32连显示屏显示60s倒计时”这种题目的价值不在“倒计时”而在把显示驱动和定时器这两套最常被低估的代码真正理顺。点灯只能证明 GPIO 没接错让显示屏准确刷新才能证明 SPI 时序、显存寻址、中断优先级都没踩坑。适合做这个项目的人有两类一是刚接触单片机的初学者用最小代码跑通“从接线到出字”的完整链路二是在实际产品里要加屏、加倒计时模块的工程师需要一套不阻塞主循环、可移植到其它屏的方案。本文按选屏、接线、驱动、倒计时逻辑、误差校准的顺序展开代码以 STM32F103 SSD1306 SPI 屏为例但每部分都讲清参数含义换芯片换屏也能照搬思路。2. 显示屏选型与 STM32 引脚接线SPI 和 I2C 怎么选2.1 SSD1306 与 ST7735 方案对比倒计时显示对屏的要求其实很低能显示两位数、刷新不闪、接线稳定即可。当前低价显示屏里有三条主流路线0.96 寸 SSD1306 OLED、1.44 寸 ST7735 TFT LCD、LCD1602 字符屏。这三类都能做倒计时但体验差异明显。屏接口分辨率/尺寸优点注意点SSD1306 OLEDSPI / I2C128x640.96寸自发光、无背光、驱动资料极多全屏刷新要写 1KB 显存刷新速度吃 SPI 频率ST7735 TFTSPI128x1281.44寸彩色显示、适合做富 UI显存 32KBF103C8 放不下整帧缓冲需裁剪刷新LCD1602并口 / I2C16x2 字符字符显示直白无法显示图形做不了精美界面我一般会用 SSD1306 作为第一个练手屏原因是它的 GDDRAM 结构简单128x64 像素对应 1024 字节每个 bit 代表一个像素点没有颜色格式转换。SPI 版本比 I2C 版本多两根控制线但刷新速度和灵活性更好正好能把“连接显示屏”这个标题里的核心动作做完整。2.2 7PIN OLED 与 STM32 的引脚接线表0.96 寸 OLED 常见排针定义是 GND、VCC、D0、D1、RES、DC、CS其中 D0 是 SPI 时钟 SCLKD1 是数据线 MOSIRES 是硬件复位DC 是数据/命令选择CS 是片选。STM32F103C8T6 的 SPI1 外设正好把 SCK、MISO、MOSI 映射到 PA5、PA6、PA7因此这里选择硬件 SPI1配合普通 GPIO 控制 CS、DC、RES。// 以 STM32CubeMX 生成的引脚定义为基础 // SPI1_SCK PA5 - OLED D0 // SPI1_MOSI PA7 - OLED D1 // CS PA4 - OLED CS低电平有效 // DC PA6 - OLED DC0命令1数据 // RES PB0 - OLED RES低电平复位接线时务必把 OLED 的 GND 和 STM32 的 GND 连在一起VCC 接 3.3V不能接 5V否则长期工作会烧掉模块上的稳压芯片。CS 可以直接接地省一个 GPIO但保留 GPIO 控制更稳妥方便以后在同一 SPI 总线上挂多个设备。2.3 上电时序与常见接错现象SSD1306 上电后要求 RES 引脚先拉低再拉高形成一个硬件复位脉冲复位时序没做好后续所有初始化命令都可能被屏忽略。典型做法是上电后延时 50ms将 RES 拉低保持至少 10us再拉高再等待 50ms让内部电荷泵稳定后再发初始化命令。如果省略硬件复位直接发命令屏幕上可能出现花屏或完全不亮。接错线后的表现也有规律可循屏幕完全不亮但串口正常重点查 VCC 和 GND能亮但显示乱码多半是 D0/D1 接反或者 SPI 极性配置不对只有半个屏幕有内容通常是列地址或页地址设置错误。这些故障不用一个个猜逐个 GPIO 测量电平即可定位。3. 驱动初始化与“60”上屏控制 SSD1306 的最小命令集3.1 SPI 写命令/写数据子函数SSD1306 通过 DC 引脚区分当前传输的是命令还是显示数据CS 低电平期间 SPI 传输有效。使用 HAL 库时每条命令发送 1 字节SPI 时钟频率保守设置为 4MHz满足绝大多数模块的 datasheet 上限同时保证长排线场景下波形不畸变。void OLED_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_RESET); // 命令模式 HAL_SPI_Transmit(hspi1, cmd, 1, 10); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); } void OLED_WriteData(uint8_t data) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_SET); // 数据模式 HAL_SPI_Transmit(hspi1, data, 1, 10); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); }这两个函数的要点在于 CS 拉低前必须设置好 DC 电平顺序反了会导致第一字节被当作错误类型处理。SPI 工作在模式 0即 CPOL0、CPHA0空闲时钟为低、数据在上升沿采样SSD1306 和绝大多数 ST7735 模块都兼容这个模式。HAL_SPI_Transmit 的最后一个参数 10 是超时时间单位毫秒在调试时如果总卡在这句先检查 SPI 外设是否使能。3.2 初始化序列里不能省的 4 条命令SSD1306 的初始化命令有十几条但真正决定能否点亮的核心是显示关闭、电荷泵、列地址范围和 Display ON 这几项。用数组保存命令序列循环发送代码更整洁static const uint8_t init_cmds[] { 0xAE, // Display OFF先关屏再配置内部寄存器 0x20, 0x00, // 设置内存寻址模式为水平寻址 0xC8, // COM 扫描方向从下到上反接屏时可改回 0xC0 0xA1, // 段重映射列地址 0 映射到屏幕最左列 0xA8, 0x3F, // MUX 设为 64对应 128x64 分辨率 0xD5, 0x80, // 内部时钟分频与振荡频率 0xDA, 0x12, // COM 引脚硬件配置64 行屏固定值 0x81, 0x7F, // 对比度设为中间偏上 0x8D, 0x14, // 开启内部电荷泵OLED 供电的核心 0xAF // Display ON发送此命令后屏幕正式点亮 };注意 0x8D 0x14 这条命令很多模块上电后屏幕亮度极低或者完全无显示就是电荷泵没有打开导致的。MCU 不直接给 OLED 供高压而是靠 IC 内部的电荷泵把 3.3V 升压0x14 表示使能0x10 表示关闭。0xAF 放在最后如果先开显示再初始化内部寄存器屏幕会出现短暂的异常闪烁。3.3 数字点阵与显存寻址倒计时只需要数字 0 到 9把这些字模直接存放在 Flash 里节省宝贵的 RAM。8x8 字模每个数字占 8 字节10 个数字总共 80 字节static const uint8_t font8x8[10][8] { {0x3C,0x42,0x42,0x42,0x42,0x42,0x42,0x3C}, // 0 {0x10,0x30,0x10,0x10,0x10,0x10,0x10,0x38}, // 1 {0x3C,0x42,0x40,0x20,0x10,0x08,0x04,0x7E}, // 2 {0x3C,0x42,0x40,0x38,0x40,0x40,0x42,0x3C}, // 3 {0x20,0x30,0x28,0x24,0x22,0x7E,0x20,0x20}, // 4 {0x7E,0x02,0x02,0x3E,0x40,0x40,0x42,0x3C}, // 5 {0x38,0x04,0x02,0x3E,0x42,0x42,0x42,0x3C}, // 6 {0x7E,0x40,0x20,0x10,0x08,0x08,0x08,0x08}, // 7 {0x3C,0x42,0x42,0x3C,0x42,0x42,0x42,0x3C}, // 8 {0x3C,0x42,0x42,0x42,0x3C,0x40,0x20,0x1C}, // 9 };每个字节的 bit7 对应左边像素依次向右排列。SSD1306 的显存按页组织128x64 被分成 8 页每页 8 个像素行。写入显存前要设置目标列地址和页地址用 0xB0 页号设置页用 0x00 到 0x0F 和 0x10 到 0x1F 两条命令设置列地址的低 4 位和高 4 位。3.4 把“60”刷上屏的最小完整函数8x8 字模在 128x64 屏幕上显得偏小常见做法是放大成 16x16 显示。放大核心思路每个像素 bit 横向扩展为 2 位每个字模行纵向写两行总共得到 16x16 的点阵void OLED_ShowBigChar(uint8_t col, uint8_t page, uint8_t digit) { const uint8_t *glyph font8x8[digit % 10]; for (uint8_t r 0; r 8; r) { uint16_t line 0; // 横向 2 倍每 1 bit 展开为连续 2 个 1 for (uint8_t c 0; c 8; c) { if ((glyph[r] (7 - c)) 0x01) { line | 0x0003 (14 - 2 * c); } } // 纵向 2 倍同一行数据先写 page再写 page1 OLED_SetPos(col, page); OLED_WriteData((line 8) 0xFF); OLED_WriteData(line 0xFF); OLED_SetPos(col, page 1); OLED_WriteData((line 8) 0xFF); OLED_WriteData(line 0xFF); } }OLED_SetPos 负责把游标移动到目标位置内部实现就是设置列地址和页地址两条指令。col 参数是列起点每页横向 128 列16 像素宽的数字占 16 列。显示两位数字时将第一个数字放在列 48、第 2 页第二个数字放在列 64、第 2 页即可让“60”居中显示。这里 page 取值为 0 时下半部分是 page1也就是物理屏幕的第 8 到 15 行两者拼成一个完整的 16 像素高字符。4. 倒计时核心逻辑用定时器中断驱动 1s 刷新4.1 用 HAL_Delay 实现倒计时为什么会“卡死”新手最常见的写法是在主循环里调用 HAL_Delay(1000)然后递减一次变量并刷新显示。这个做法在没有任何其它任务的裸机例程里能跑但一旦程序里出现按键扫描、串口收发、传感器读取HAL_Delay 就会把整个主循环阻塞住。更严重的是stm32 的延时函数依赖 SysTick 中断如果用户代码不小心关闭了全局中断或者低优先级中断里调用延时整个系统会直接卡死在等待标志位的地方。倒计时的本质是“时间基准 状态更新”不要让显示代码参与时间长度的计算。正确做法是把秒信号做成一个事件由定时器中断产生主循环只负责消费这个事件。4.2 TIM2 定时中断产生可复用的 1s 时基TIM2 是 32 位定时器时钟源来自 APB1在 F103 上通常为 72MHz。把它配置成 1ms 中断再用软件计数累计到 1000 次产生 1s 信号这样将来扩展 500ms 或 100ms 节拍时不用改定时器配置。// CubeMX 中 TIM2 配置参数 // Prescaler 72 - 1 分频后得到 1MHz即计数周期 1us // Period 1000 - 1 计满 1000 次产生更新事件即 1ms volatile uint8_t g_1s_flag 0; volatile uint16_t g_tick_ms 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { g_tick_ms; if (g_tick_ms 1000) { g_tick_ms 0; g_1s_flag 1; // 只置标志不做显示运算 } } }Prescaler 为 72 分频得到 1MHz 的计数频率每个计数周期是 1us。Period 设为 999计数器从 0 数到 999正好 1000 次即 1ms。如果把这两个参数翻倍或减半中断周期会线性变化可用于调整倒计时快慢。回调函数里只做变量修改不做 SPI 发送因为 SPI 传输耗时毫秒级放在中断里会拖累整个系统的实时性。4.3 倒计时状态机避免无符号数下溢主循环检测到 g_1s_flag 后将当前秒数减 1 并刷新屏幕。这里最容易犯的错误是把倒计时变量声明成 uint8_t当数值从 0 减到 1 时没有问题但一旦逻辑允许 0 再减 1结果会变成 255屏幕瞬间跳到 255看起来像故障。int8_t s_countdown 60; uint8_t s_running 1; while (1) { if (g_1s_flag) { g_1s_flag 0; if (s_running) { if (s_countdown 1) { s_countdown--; } else { s_countdown 0; s_running 0; // 倒计时结束停止更新 } OLED_ShowBigChar(48, 2, s_countdown / 10); OLED_ShowBigChar(64, 2, s_countdown % 10); } } // 按键、串口等其它任务在这里执行不受倒计时阻塞 }把 s_countdown 声明为 int8_t取值范围 -128 到 127倒计时从 60 开始完全够用。显示时用 s_countdown / 10 取十位用 s_countdown % 10 取个位确保总是输出两位数字。结束条件判断用 s_countdown 1意思是当当前值为 1 时本次递减后变成 0 并停止不需要再多等一秒。4.4 在中断里做标记、在主循环做显示这个“标记 消费”模式值得单独强调。定时器中断只修改 g_1s_flag 一个 volatile 变量主循环轮询这个变量一旦发现被置位就清 0 并执行耗时的显示操作。这样做的直接收益是SPI 刷新过程不会阻塞定时器中断下一次秒信号永远不会因为上一帧还没画完而丢失。需要注意 volatile 修饰符不能省略。定时器中断对 g_tick_ms 和 g_1s_flag 的写入对主循环来说属于异步事件编译器可能优化成从寄存器读值导致主循环永远看不到更新。加上 volatile 后每次访问都从内存地址读取这是多中断裸机程序的基本要求。5. 误差校准与显示优化把 60s 倒计时做准5.1 用 GPIO 翻转验证 1s 节拍代码写完先别急着看屏幕屏幕显示正确不代表时间准确。F103 内部 HSI 振荡器精度约 1% 到 2%也就是每秒可能偏差 10ms 到 20ms。验证时可以在中断里翻转一个空闲 GPIO比如 PA8void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_8); // 验证用验证后删除 // ...原逻辑 } }用示波器或逻辑分析仪测量 PA8 上升沿间隔正常应稳定在 1.000s 左右。如果家里没有示波器用手机秒表对着屏幕跑 60 秒记录结束瞬间的偏差次数连续测三轮取平均值也能判断系统是偏快还是偏慢。5.2 晶振偏差与 Prescaler 微调实测 60s 偏差 300ms 以内可以不做处理超过 500ms 且项目对时间敏感就要校准。校准方向是实跑 600s 钟快了 1s说明每秒多计了约 1.67ms需要把 1s 的计数长度调长反之则调短。对应动作是微调 TIM2 的 Period// 假设原配置 Prescaler 71Period 999 // 实测 600s 快 1s每秒应多计 0.00167s // Period 调整为 999 1 1000Prescaler 不动 // 调整后校准 600s 重测一般两轮迭代即可收敛改 Period 数值影响的是所有基于该定时器派生的计时改动后务必重新验证显示刷新是否出现闪烁。晶振温漂属于正常物理现象消费级产品允许 100ppm 级别误差追求更精密可以换外部晶振或使用 RTC 校准。5.3 进阶秒位局部刷新与蜂鸣器提醒倒计时数字变化时两帧之间的切换如果采用整屏清空再重写1366 字节的显存搬运会让 SPI 总线上出现明显空闲期。建议只刷新数字所在的 32 列、16 行区域清空旧数字前先读当前值新值和旧值相同就跳过显示更新减少无意义的 SPI 传输。倒计时归零提醒可以复用一个 GPIO 驱动蜂鸣器。在 s_countdown 变为 0 的那个主循环分支里翻转蜂鸣器引脚并把显示内容切换成闪烁效果。闪烁通过 s_tick_ms 实现比如每 500ms 翻转一次反显标志把 0x00 和 0xFF 交替写入两个数字的显存区域不需要额外定时器。到此整个“STM32 连接显示屏显示 60s 倒计时”的链路已经完整闭环。从 SPI 接线的物理层到 SSD1306 的显存寻址再到定时器时基与主循环状态机每一层都能独立验证。把 g_1s_flag 产生逻辑换成外部 RTC 秒脉冲或者把显示函数替换成 ST7735 的 RGB565 版本这套骨架可以直接迁移到需要分钟级倒计时的产线计时器或实验室设备上。本文还有配套的精品资源点击获取