简介:该资源面向嵌入式开发初学者与进阶工程师,提供在STM32F407平台上通过SPI接口驱动OLED显示屏的完整工程实例,可用于小型便携设备显示方案的学习与二次开发。压缩包共159个文件,约9.39MB,以C源文件与头文件为核心,辅以Keil工程配置、编译中间文件及可直接烧录的hex与axf固件,覆盖从源码到可执行程序的完整链路。资源围绕SPI通信配置、SSD1306类驱动IC指令交互、帧缓冲区管理及从STM32F103平台移植时的时钟树与GPIO适配展开,并包含初始化、清屏、字符写入与图形绘制等封装函数,代码结构清晰、注释完整,便于理解驱动分层与移植要点。目前已有3833人学习下载,适合对照工程排查SPI通信与OLED初始化问题,快速掌握STM32F407显示驱动的开发流程。
1. 一块 0.96 寸屏点亮之前,先想清楚这三件事
手头有块 STM32F407 的板子,想接一块 0.96 寸 OLED 做状态显示,结果翻遍资料发现要么是 HAL 库版本对不上,要么是 SPI 时序调不通,屏幕要么全黑要么花屏——这大概是很多人第一次碰 STM32F407 OLED SPI 组合时的真实处境。这份资源就是围绕这个场景整理的:以 STM32F407 为主控,通过 SPI 接口驱动常见的 SSD1306 类 OLED 模块,把初始化、显存管理、字符与图形绘制、刷新逻辑串成一套能直接跑起来的代码。它解决的不是“OLED 是什么”这种概念问题,而是“接线怎么接、SPI 参数怎么配、为什么屏幕不亮、字库怎么放”这些落地问题。适合已经能点亮 LED、会用 Keil 或 CubeMX 建工程,但还没把显示模块跑通的人;也适合想回头把驱动结构理一遍、把裸机驱动改成可复用模块的熟手。
2. 硬件接线与 SPI 模式选型:先把物理层钉死
2.1 四线 SPI 与 I2C 的取舍
市面上 0.96 寸 OLED 模块常见两种接口:四线 SPI 和 I2C。资源走的是 SPI 路线,原因很实际——SPI 刷全屏明显快,128×64 的显存一次推完在 4 线 SPI 下几十毫秒级别,I2C 在 400kHz 下刷整屏会有肉眼可见的拖影。代价是引脚多两根。四线 SPI 的引脚定义要记牢:SCL(时钟)、SDA(数据,有的模块标 MOSI)、RES(复位)、DC(数据/命令选择),加上 VCC 和 GND 共六根。DC 这根线是 SPI 方案的关键,它决定当前发过去的一个字节被屏幕当成命令还是显存数据,I2C 方案里这个信息藏在控制字节里,SPI 方案必须单独拉一根 GPIO。
接线时容易翻车的是 RES 和 DC 的分配。RES 建议接一个普通 GPIO,上电后先拉低再拉高做硬复位,比只靠软件命令复位稳。DC 也接普通 GPIO,输出模式。SCL 和 SDA 必须接到 F407 的 SPI 外设引脚上,比如 SPI1 的 PA5(SCK)和 PA7(MOSI),这两个是复用功能,不能随便找两个 GPIO 软件模拟——虽然软件模拟 SPI 也能跑,但资源里用的是硬件 SPI,时序更稳,CPU 占用也低。
| 模块引脚 | 接到 F407 | 说明 |
|---|---|---|
| VCC | 3.3V | 不要接 5V,多数模块无电平转换 |
| GND | GND | 共地必须可靠 |
| SCL | PA5(SPI1_SCK) | 复用推挽 |
| SDA | PA7(SPI1_MOSI) | 复用推挽 |
| RES | 任意 GPIO,如 PB0 | 推挽输出 |
| DC | 任意 GPIO,如 PB1 | 推挽输出 |
2.2 SPI 参数怎么定:模式 0、8 位、先高后低
SSD1306 这类控制器对 SPI 的要求很固定:时钟极性 CPOL=0、时钟相位 CPHA=0,也就是常说的 SPI 模式 0;数据位宽 8 位;高位先发(MSB First);时钟频率别一上来就拉满,先按 4MHz 到 8MHz 跑通,稳定后再往上试。F407 的 SPI1 挂在 APB2 上,分频系数要按实际主频算,比如 168MHz 主频下用 16 分频得到约 10.5MHz,多数模块能吃住,但排线长、杜邦线质量差的时候容易出错,这时候降到 32 分频更保险。
用 CubeMX 配置时,SPI1 选 Full-Duplex Master,虽然 OLED 只收不发,但全双工模式配置最简单,MISO 引脚留空不接即可。NSS 选 Software,因为片选在多数 OLED 模块上直接接地常有效,不需要单独控制。配置完生成代码后,重点检查hspi1.Init.CLKPolarity和CLKPhase是不是SPI_POLARITY_LOW和SPI_PHASE_1EDGE,这两个值错了屏幕基本不会亮。
提示:如果手上模块背面标的是“7 针 SPI”,多出来的那根通常是 CS,接低电平即可;标“6 针”的一般已经内部拉低,不用管。
3. 驱动分层:把命令、数据、显存拆开写
3.1 命令与数据两个底层函数
整个驱动的地基只有两个函数:写命令和写数据。区别仅在于发送前把 DC 拉低还是拉高。资源里把这两个函数做成静态函数,上层不直接碰 SPI,这样换引脚或换 SPI 实例时只改这一层。
/* DC 引脚宏定义,换板子只改这里 */ #define OLED_DC_PORT GPIOB #define OLED_DC_PIN GPIO_PIN_1 #define OLED_RES_PORT GPIOB #define OLED_RES_PIN GPIO_PIN_0 static void OLED_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_RESET); /* DC=0 表示命令 */ HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); /* 超时 100ms 兜底 */ } static void OLED_WriteData(uint8_t dat) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); /* DC=1 表示显存数据 */ HAL_SPI_Transmit(&hspi1, &dat, 1, 100); }这两个函数看着简单,但有两个参数值得说。HAL_SPI_Transmit的第四个参数是超时时间,单位毫秒,给 100 是防止 SPI 总线异常时死等;如果追求刷屏速度,可以把单字节发送改成批量发送,把一整页 128 字节拼进缓冲区一次Transmit,速度能提升一截,资源里初始化阶段用单字节、刷屏阶段用批量,是折中的写法。DC 引脚用GPIO_PIN_RESET和GPIO_PIN_SET而不是直接写 0/1,是为了跟 HAL 的枚举保持一致,换库时不容易漏改。
3.2 初始化序列不能省,也不能乱序
SSD1306 上电后必须按手册给一串初始化命令,关显示、设时钟分频、设复用率、设偏移、设起始行、设电荷泵、设寻址模式、开显示,顺序错了轻则花屏重则全黑。资源里的初始化函数大致是这样组织的:
void OLED_Init(void) { HAL_GPIO_WritePin(OLED_RES_PORT, OLED_RES_PIN, GPIO_PIN_RESET); HAL_Delay(20); /* 硬复位保持 20ms */ HAL_GPIO_WritePin(OLED_RES_PORT, OLED_RES_PIN, GPIO_PIN_SET); HAL_Delay(20); OLED_WriteCmd(0xAE); /* 关显示 */ OLED_WriteCmd(0xD5); /* 时钟分频 */ OLED_WriteCmd(0x80); OLED_WriteCmd(0xA8); /* 复用率 */ OLED_WriteCmd(0x3F); /* 64 行 */ OLED_WriteCmd(0xD3); /* 显示偏移 */ OLED_WriteCmd(0x00); OLED_WriteCmd(0x40); /* 起始行 0 */ OLED_WriteCmd(0x8D); /* 电荷泵 */ OLED_WriteCmd(0x14); /* 开启,缺这条屏幕不亮 */ OLED_WriteCmd(0x20); /* 寻址模式 */ OLED_WriteCmd(0x00); /* 水平寻址 */ OLED_WriteCmd(0xA1); /* 段重映射,决定左右方向 */ OLED_WriteCmd(0xC8); /* 行扫描方向,决定上下方向 */ OLED_WriteCmd(0xDA); OLED_WriteCmd(0x12); OLED_WriteCmd(0x81); /* 对比度 */ OLED_WriteCmd(0xCF); OLED_WriteCmd(0xD9); /* 预充电周期 */ OLED_WriteCmd(0xF1); OLED_WriteCmd(0xDB); /* VCOMH */ OLED_WriteCmd(0x40); OLED_WriteCmd(0xA4); /* 显示跟随显存 */ OLED_WriteCmd(0xA6); /* 正常显示,非反色 */ OLED_WriteCmd(0xAF); /* 开显示 */ OLED_Clear(); }这段里最容易被忽略的是0x8D后面跟0x14的电荷泵开启命令。很多模块不带外部升压,电荷泵不开,屏幕就是黑的,而且不报任何错,属于典型的“代码没错但就是不亮”。另一个是0xA1和0xC8这两条方向命令,不同批次模块的排线方向可能相反,显示出来上下颠倒或左右镜像时,改这两条命令的值就能翻回来,不用改显存写入逻辑。
3.3 显存组织:页寻址与 1KB 缓冲区
128×64 的单色屏,显存按页组织,8 页、每页 128 列,每列 8 个像素纵向排列,总共 1024 字节。资源在 RAM 里开一个uint8_t OLED_GRAM[8][128]作为影子显存,所有画点、画线、写字都先改这块内存,最后统一刷到屏幕。这样做的好处是绘制过程不碰 SPI,不会出现画到一半屏幕闪一下的撕裂感,也方便做局部刷新。
uint8_t OLED_GRAM[8][128]; /* 8 页 × 128 列,每字节纵向 8 像素 */ void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); /* 设置页地址 */ OLED_WriteCmd(0x00); /* 列低 4 位 */ OLED_WriteCmd(0x10); /* 列高 4 位 */ for (uint8_t col = 0; col < 128; col++) { OLED_WriteData(OLED_GRAM[page][col]); } } }0xB0 + page是页地址命令,0x00和0x10组合把列地址归零,然后连续写 128 字节,控制器会自动列递增。这里如果用了水平寻址模式,其实可以省掉每页重设地址,但页寻址模式兼容性更好,老模块也能跑。刷一屏 1024 字节,按 8MHz SPI 算大约 1ms 出头,加上函数调用开销,实测在几毫秒量级,做状态显示完全够用。画点函数就是把坐标换算成页号和位号,对OLED_GRAM做位操作,这里不展开,但要注意 y 坐标除以 8 取页、对 8 取余定位,写反了就是经典的“点画到隔壁行”。
4. 字库与图形:显示内容从哪来
4.1 ASCII 字库的取模方式
显示英文和数字需要字库。资源里带的是 8×16 和 6×8 两套 ASCII 点阵,取模方式为“列行式、逆向”,也就是每个字符按列取字节、低位在前。这个参数必须和取模软件设置一致,否则显示出来是乱码或者镜像。常见做法是用 PC 端取模工具生成const unsigned char F6x8[][6]这样的二维数组,索引直接用字符 - ' '偏移。字库放 Flash 里,别放 RAM,F407 的 Flash 够大,1KB 显存加几 KB 字库毫无压力。
写字符函数的核心是把字库里的字节按列填进OLED_GRAM对应位置,注意跨页问题:16 高的字符会占两页,写的时候要分别处理上半页和下半页,页边界算错就会出现字符下半截跑到屏幕另一头。资源里用OLED_ShowChar(x, y, ch, size)统一入口,内部按 size 分支处理 8×16 和 6×8,调用方不用关心底层。
4.2 画点、画线、画框的坐标边界
图形部分提供画点、画线、画矩形、画圆几个基础函数,全部基于OLED_GRAM操作。画点函数第一件事是边界检查,x 超过 127、y 超过 63 直接返回,不做任何写入。这一步看着多余,但少了它,坐标算错时会越界写到相邻数组,表现是屏幕某块区域莫名其妙常亮,排查起来很费时间。画线用 Bresenham 算法,整数运算,不涉及浮点,F407 带 FPU 但没必要在这里用。
注意:画圆函数里如果半径传 0 或负数,循环条件会异常,调用前自己保证参数合法,驱动层不做兜底。
5. 避坑与排查:屏幕不亮时按这个顺序查
5.1 现象:上电全黑,代码跑飞不了但没显示
原因通常是三类:电荷泵没开、复位时序不对、SPI 根本没发出去。解决顺序是先量 RES 引脚上电后有没有一个低到高的跳变,没有就是复位代码没执行到;再确认初始化里0x8D/0x14这组命令在;最后用逻辑分析仪或示波器看 SCK 上有没有波形,没有就回头查 SPI 初始化和引脚复用配置。血泪经验是别一上来就怀疑屏幕坏了,九成是软件。
5.2 现象:屏幕亮但花屏、雪花点
多半是 SPI 模式配错或时钟太快。先把分频加大到 64 甚至 128,看花屏是否消失,消失就是时序裕量不够,换短一点的杜邦线或降频。如果降频还花,检查 CPOL/CPHA 是不是模式 0,以及 DC 引脚在发命令和发数据时电平有没有真的翻转——用万用表测 DC 在初始化阶段应该频繁跳变。
5.3 现象:显示内容上下颠倒或左右镜像
不是代码 bug,是模块排线方向差异。改初始化里的0xA1(段重映射)和0xC8(行扫描方向),把0xA1换成0xA0或0xC8换成0xC0试,四种组合总有一种正。改完记得重新上电,软复位不一定生效。
5.4 现象:字符显示缺笔画或错位
字库取模方式和驱动解析方式不匹配。确认取模软件的“阴码/阳码”“逆向/顺向”“列行式/行列式”三个选项,和驱动里移位方向对得上。另一个可能是字符跨页时上下半页写入偏移算错,单独显示一个 16 高字符在 y=0 和 y=8 两个位置对比,能快速定位。
5.5 现象:刷屏时画面撕裂、闪烁
说明绘制和刷新没分离,边画边推。确认所有绘制函数只改OLED_GRAM,只有OLED_Refresh碰 SPI。如果已经分离还闪,检查是不是在中断里调用了刷新,SPI 传输被高优先级中断打断会导致时序错乱,刷新放主循环里做。
6. 进阶:局部刷新与帧率验证的一个笨办法
把整屏刷新改成局部刷新,是这套驱动从“能显示”到“好用”的分水岭。做法不难:在OLED_GRAM之外再开一块同样大小的OLED_GRAM_OLD,每次刷新前逐页逐列比较,只把变化的页推给屏幕。对于只刷新一行数字或一个图标的场景,SPI 传输量能从 1024 字节降到几十字节,主循环里刷新的时间开销几乎可以忽略。
void OLED_RefreshPartial(void) { for (uint8_t page = 0; page < 8; page++) { uint8_t dirty = 0; for (uint8_t col = 0; col < 128; col++) { if (OLED_GRAM[page][col] != OLED_GRAM_OLD[page][col]) { dirty = 1; break; /* 该页有变化才整页重推 */ } } if (!dirty) continue; OLED_WriteCmd(0xB0 + page); OLED_WriteCmd(0x00); OLED_WriteCmd(0x10); for (uint8_t col = 0; col < 128; col++) { OLED_WriteData(OLED_GRAM[page][col]); OLED_GRAM_OLD[page][col] = OLED_GRAM[page][col]; } } }这里按页做脏检查而不是按字节,是因为页寻址模式下重设列地址有开销,整页推比逐字节判断再推更划算。如果显示内容集中在某一页的某几列,还可以进一步把脏检查粒度降到列区间,但代码复杂度上升,收益有限,我一般到页级就停。
验证刷新是否真的变快,别靠肉眼。用一个空闲定时器在刷新前后各读一次计数器,算出微秒数,串口打出来。整屏刷新在 8MHz SPI 下大约几毫秒,局部刷新单页大约几百微秒,数字对得上说明脏检查生效了;如果局部刷新时间和整屏差不多,八成是OLED_GRAM_OLD没同步更新,每次都判定为脏。这个笨办法比看示波器省事,也比“感觉快了”靠谱。
从那以后我每次接新屏幕,都先把刷新耗时打出来再谈优化,不然改了半天不知道有没有效果。希望帮到你。
本文还有配套的精品资源,点击获取