简介:这份资源面向嵌入式开发初学者与需要上手OLED显示的工程师,提供STM32F407平台下通过SPI接口驱动OLED显示屏的完整工程实例,重点解决从STM32F103平台移植驱动时的时钟配置、GPIO初始化与SPI参数匹配问题。压缩包共159个文件、约9.39MB,以C源文件与头文件为主体,辅以Keil工程配置、编译中间文件及可直接烧录的hex与axf固件,涵盖HAL库外设驱动、OLED初始化、帧缓冲区管理与字符图形绘制等模块。资源围绕SSD1306类驱动IC的指令与数据传输展开,封装了清屏、坐标设置、写字符与画点线矩形等API,代码结构清晰、注释完整,便于理解SPI通信机制与显示驱动分层设计。目前已有3833人学习下载,适合作为嵌入式显示开发的参考范例与移植排错对照。
1. 一块 0.96 寸屏点亮之后,才算真正摸到 STM32F407 的边
很多人第一次拿到 STM32F407 开发板,跑通串口打印“Hello World”就以为入门了,直到把一块 0.96 寸 OLED 接上去,屏幕一片漆黑,才开始怀疑人生。这个项目就是围绕 STM32F407 驱动 OLED 显示展开的完整工程,核心走的是 SPI 接口,把屏幕点亮、字符显示、图形绘制这一整套链路跑通。它适合刚上手 F407、想搞明白“代码到底怎么把像素点送到屏上”的嵌入式新手,也适合需要给产品加一块小屏做状态显示、又不想引入复杂 GUI 框架的开发者。SPI 驱动 OLED 这件事看着简单,真拆开看,时序、显存映射、字模取模三块哪一块翻车,屏幕都不会给你好脸色。
2. 工程结构与技术选型:SPI 四线驱动 OLED 的最小闭环
2.1 为什么选 SPI 而不是 I2C
0.96 寸 OLED 常见的控制芯片是 SSD1306,它同时支持 I2C 和 SPI 两种接口。这个工程选 SPI,原因很实际:I2C 在 400kHz 标准速率下刷一整屏 128×64 的数据要几十毫秒,滚动或者动画会明显卡顿;SPI 挂到 F407 的硬件 SPI 上,时钟轻松跑到 10MHz 以上,整屏刷新压到几毫秒,视觉上就顺滑了。代价是多占两根线(DC 和 RES),但对于 F407 这种引脚富裕的片子,这点开销可以忽略。
工程里 SPI 用的是硬件 SPI1,引脚分配大致是:SCK 走 PA5,MOSI 走 PA7,DC 和 RES 用普通 GPIO 控制。这里有个容易忽略的点——SSD1306 的 SPI 是单向写,没有 MISO,所以配置 SPI 时要把方向设成单线发送模式,别傻乎乎地配成全双工等着收数据。
2.2 目录结构长什么样
一个能跑的 F407 OLED 工程,目录通常是这样组织的:
STM32F407_OLED/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── oled.h // OLED 驱动对外接口 │ │ └── oledfont.h // 字模数据 │ └── Src/ │ ├── main.c │ ├── oled.c // 驱动实现 │ └── stm32f4xx_it.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F4xx_HAL_Driver/ ├── OLED.ioc // CubeMX 工程文件 └── MDK-ARM/ // Keil 工程oled.c和oled.h是整个项目的核心,oledfont.h里放的是 ASCII 字模和常用图形点阵。这种分层的好处是驱动和业务彻底分开:main.c里只管调OLED_ShowString()这类接口,换屏幕型号时只动oled.c,上层逻辑不用碰。
2.3 驱动分层:从 GPIO 到像素的三层抽象
拆开看,这个驱动其实分三层。最底层是OLED_WR_Byte(),负责把一个字节按 SPI 时序送出去,同时根据是命令还是数据拉高拉低 DC 引脚;中间层是OLED_Set_Pos()和OLED_Refresh(),管的是 SSD1306 的页地址和列地址,以及把本地显存刷到屏幕;最上层是OLED_ShowChar()、OLED_ShowString()、OLED_DrawBMP()这些面向业务的接口。
这种分层的价值在于,底层时序只写一次,上层想画什么就画什么。我见过不少人把所有逻辑塞进一个函数里,结果想加个画圆功能就得复制粘贴一大段 SPI 发送代码,改起来是血泪经验。
3. 核心代码拆解:一个字节怎么变成屏幕上的一个像素
3.1 底层写字节:DC 引脚决定命运
SSD1306 的 SPI 协议里,每个字节前面靠 DC 引脚区分是命令还是数据:DC 拉低表示接下来是命令,拉高表示是显示数据。这个函数是整个驱动的地基:
// 向 OLED 写入一个字节 // dat: 要写入的数据 // cmd: 0 表示命令, 1 表示数据 void OLED_WR_Byte(uint8_t dat, uint8_t cmd) { if (cmd) OLED_DC_Set(); // DC 拉高, 表示数据 else OLED_DC_Clr(); // DC 拉低, 表示命令 OLED_CS_Clr(); // 片选拉低, 选中屏幕 HAL_SPI_Transmit(&hspi1, &dat, 1, 100); // 硬件 SPI 发送 OLED_CS_Set(); // 片选拉高, 结束本次传输 }HAL_SPI_Transmit的第四个参数是超时时间,单位毫秒,这里给 100 足够。如果屏幕没反应,第一件事就是拿逻辑分析仪或者示波器看 SCK 上有没有波形——没有波形说明 SPI 根本没初始化成功,有波形但屏幕不亮,多半是 DC 或者 RES 的时序不对。OLED_CS_Clr()和OLED_CS_Set()这对操作,如果屏幕的 CS 直接接地(很多模块默认如此),其实可以省掉,但保留着更通用。
3.2 显存映射:128×64 为什么是 1024 字节
SSD1306 的显存不是按“一行 128 个像素”线性排的,而是分成 8 个 Page,每个 Page 高 8 像素、宽 128 列。所以整屏显存是 8×128 = 1024 字节,每个字节的 8 个 bit 对应垂直方向的 8 个像素。这个映射关系是新手最容易懵的地方:
// 本地显存, 1024 字节对应 128x64 屏幕 uint8_t OLED_GRAM[8][128]; // 设置显示位置: x 为列(0~127), y 为页(0~7) void OLED_Set_Pos(uint8_t x, uint8_t y) { OLED_WR_Byte(0xB0 + y, OLED_CMD); // 设置页地址 OLED_WR_Byte(((x & 0xF0) >> 4) | 0x10, OLED_CMD); // 设置列地址高 4 位 OLED_WR_Byte(x & 0x0F, OLED_CMD); // 设置列地址低 4 位 }列地址要拆成高低两半分别发,这是 SSD1306 命令格式决定的,0x10是列地址高位的命令前缀,0x00是低位的。写错这里,画面会整体偏移或者错位,我踩过这个坑,调了半天以为是字模问题。
3.3 刷新函数:把本地显存推上屏
有了本地显存,画图时只改OLED_GRAM,最后统一调一次刷新,这样能避免边画边刷导致的闪烁:
// 将本地显存刷新到屏幕 void OLED_Refresh(void) { uint8_t i, n; for (i = 0; i < 8; i++) { OLED_Set_Pos(0, i); // 定位到第 i 页起始列 for (n = 0; n < 128; n++) { OLED_WR_Byte(OLED_GRAM[i][n], OLED_DATA); // 逐字节发送 } } }外层循环 8 页,内层 128 列,一共 1024 次写数据。如果 SPI 跑在 10MHz,理论上 1024 字节不到 1 毫秒就能传完,实际加上函数调用开销也就几毫秒。这里有个优化点:可以把OLED_Set_Pos提到内层循环外,因为 SSD1306 写完一个字节后列地址会自动加一,不需要每写一个字节都重设位置。工程里为了代码清晰保留了逐字节设置,追求极致刷新的可以自己改。
3.4 字符显示:字模怎么对上像素
显示一个 ASCII 字符,本质是把字模数组里的字节按列写进显存。以 8×16 字体为例,一个字符占 16 字节(8 列 × 2 页):
// 显示一个 8x16 的 ASCII 字符 // x: 列坐标(0~127), y: 页坐标(0~7), chr: 字符 void OLED_ShowChar(uint8_t x, uint8_t y, char chr) { uint8_t i; uint8_t c = chr - ' '; // 字模从空格开始编号 for (i = 0; i < 8; i++) { // 上半部分: 第 y 页 OLED_GRAM[y][x + i] = oledfont_8x16[c * 16 + i]; // 下半部分: 第 y+1 页 OLED_GRAM[y + 1][x + i] = oledfont_8x16[c * 16 + i + 8]; } }chr - ' '是因为字模表通常从空格(ASCII 32)开始存,减去偏移才能对上索引。c * 16定位到该字符字模的起始位置,前 8 字节是上半部分,后 8 字节是下半部分。如果显示出来是乱码或者上下颠倒,八成是字模取模方式不对——取模软件里要选“列行式”还是“行列式”、字节是高位在前还是低位在前,必须和代码里的写入顺序一致,这是玄学重灾区。
4. 从零跑起来:CubeMX 配置到屏幕点亮
4.1 CubeMX 里 SPI 和 GPIO 怎么配
打开OLED.ioc,SPI1 的配置要点:Mode 选 Transmit Only Master(单线发送),Data Size 8 Bits,CPOL 和 CPHA 按 SSD1306 手册选,通常 CPOL=High、CPHA=2Edge,也就是 SPI 模式 3;Prescaler 先给个 8 分频,F407 的 APB2 是 84MHz,8 分频后约 10.5MHz,稳一点。NSS 选 Software,因为片选我们自己用 GPIO 控制。
DC、RES、CS 三个引脚配成 GPIO_Output,初始电平:RES 拉高、DC 随意、CS 拉高。生成代码后,在main.c的MX_GPIO_Init()之后、while(1)之前,加上初始化和测试代码:
OLED_Init(); // 屏幕初始化 OLED_Clear(); // 清屏 OLED_ShowString(0, 0, "STM32F407", 16); // 第 0 页显示字符串 OLED_ShowString(0, 2, "OLED SPI", 16); // 第 2 页显示 OLED_Refresh(); // 刷新上屏OLED_Init()里是一串 SSD1306 的初始化命令,包括关显示、设时钟分频、设复用率、设对比度、开电荷泵等等,这些命令值直接照搬数据手册的推荐配置即可,不用自己发明。
4.2 初始化序列里几个不能省的命令
初始化命令里,0x8D后面跟0x14是开启电荷泵,这条不写屏幕永远不亮,因为 OLED 需要内部升压才能驱动像素;0xAF是开显示,写在最后;0x20, 0x00是设置内存寻址模式为水平寻址。这几条是新手最容易漏的,漏了电荷泵,现象就是“代码全对但屏幕全黑”,特别打击人。
4.3 编译下载与现象确认
Keil 里选好 F407 对应的器件型号,Debug 选 ST-Link 或 J-Link,编译下载。上电后如果屏幕先闪一下再显示字符,说明 RES 复位时序正常;如果一直黑,先量 RES 引脚有没有一个低电平脉冲,再量电荷泵相关——不过电荷泵是内部的,量不到,只能靠命令确认。显示正常后,可以试着改OLED_ShowString的坐标参数,观察字符位置变化,确认坐标映射理解无误。
5. 避坑与排查:屏幕不亮、花屏、乱码的常见原因
5.1 屏幕全黑,代码编译无报错
现象:下载后屏幕一点反应没有,串口打印正常。原因多半是初始化命令里漏了电荷泵开启(0x8D, 0x14),或者 RES 引脚没有正确复位。解决:先确认OLED_Init()里电荷泵命令存在,再用万用表量 RES 引脚上电时是否有短暂低电平,没有的话检查 GPIO 初始化和复位函数调用顺序。
5.2 显示花屏或者雪花点
现象:屏幕亮但内容是乱的,像雪花。原因通常是 SPI 速率太高导致时序不稳,或者 CPOL/CPHA 配错。解决:把 SPI 分频调大(比如 16 分频降到 5MHz 左右)先排除速率问题,再核对 SPI 模式是否与 SSD1306 要求一致。我一般会先降速验证,能显示再逐步提频,这是排查时序问题的后悔药。
5.3 字符显示上下颠倒或左右镜像
现象:字能显示但方向不对。原因在字模取模设置和代码写入顺序不匹配。解决:回到取模软件,确认取模方向(纵向取模/横向取模)、字节位序(高位在前/低位在前)与OLED_ShowChar里的写入逻辑一致。改代码比改字模快,实在对不上就调整OLED_GRAM[y][x+i]的索引方式试。
5.4 刷新后画面闪烁
现象:每次刷新整屏有明显闪动。原因是先清屏再画再刷新的顺序导致中间出现空白帧。解决:改成只改显存、最后统一OLED_Refresh(),不要在绘制过程中调用清屏;如果必须清,先清本地OLED_GRAM再重绘,别直接发清屏命令给屏幕。
6. 二次开发:把驱动改成自己的显示框架
拿到这个工程后,最值得做的改造是把“逐字符显示”升级成“带缓冲的文本层”。具体做法是维护一个字符缓冲区,业务层只往里写字符串,刷新时统一渲染,这样滚动字幕、状态栏局部刷新都好实现。另一个方向是加图形接口,基于OLED_GRAM写OLED_DrawLine()和OLED_DrawRect(),用 Bresenham 算法画线,几十行代码就能让屏幕从“只能显示字”变成“能画简单界面”。
验证改造是否成功,我习惯用三步:先画一个边框确认坐标系统正确,再在边框内显示递增数字确认刷新不闪,最后快速切换两帧不同内容看有没有残影。残影说明清屏逻辑有漏,坐标错位说明页列映射没吃透。
| 改造方向 | 改动文件 | 验证方法 |
|---|---|---|
| 文本缓冲层 | oled.c / oled.h | 滚动字幕无撕裂 |
| 图形绘制 | oled.c 新增函数 | 画矩形边框四角对齐 |
| 多字体支持 | oledfont.h | 切换字号不串位 |
有个教训我记到现在:早期我图省事,在中断里直接调OLED_Refresh(),结果主循环里的显存写入被中断打断,屏幕偶尔出现半帧旧数据。从那以后我每次加显示相关代码,都强制走一遍“显存只在一个上下文改、刷新只在主循环调”的规矩,再没出过撕裂。希望这些拆解能帮到你,把这块小屏稳稳点起来。
本文还有配套的精品资源,点击获取