☰
STM32CubeIDE实战:SPI驱动12864 OLED从配置到DMA优化
2026/10/17 10:03:17 网站建设 项目流程

1. 为什么在STM32CubeIDE里折腾SPI驱动OLED值得单独写一篇

嵌入式开发里,点亮一块屏幕往往是项目从“能跑”到“好用”的分水岭。串口打印调试信息谁都会,但要把数据实时可视化、做本地菜单、显示传感器波形,一块小尺寸OLED几乎是成本最低、效果最直接的选择。12864这个规格指的是128×64像素的点阵屏,常见尺寸0.96寸到1.3寸,单色显示,对比度高,阳光下也能看清,功耗还低,特别适合电池供电的便携设备。

我这次用的是SPI接口的12864 OLED,主控是SSD1306或SH1106这类常见驱动芯片。为什么选SPI而不是I2C?I2C接线少,两根线就能搞定,但速率上限摆在那里,标准模式100kHz,快速模式400kHz,刷一整屏数据要等得让人着急。SPI就不一样了,STM32的硬件SPI随便配到8MHz、16MHz甚至更高,刷屏几乎无感,做动画或者滑动菜单的时候差距非常明显。代价是多占两根线(MOSI和SCK),外加一根DC和一根RES,总共五根信号线,对于引脚资源不紧张的STM32来说完全划算。

这篇文章面向的是已经会用STM32CubeIDE建工程、配置GPIO和时钟的开发者,但还没把SPI和OLED串起来的同学。我会从CubeMX的配置讲起,把SPI参数怎么算、DMA怎么加、显存怎么组织、刷屏怎么优化,一步步拆开。中间会穿插我实际调试时踩过的坑,比如SPI模式选错导致花屏、DMA传输没开中断导致卡死、显存刷新策略不对导致闪烁等等。看完你应该能直接抄作业,把一块12864 OLED稳稳跑起来。

2. 硬件连接与CubeMX配置:先把地基打牢

2.1 引脚分配与硬件连线

先把手头的模块看清楚。市面上常见的SPI OLED模块,引脚标注一般是GND、VCC、D0(SCK)、D1(MOSI)、RES、DC、CS,有的还多一个CS片选。12864屏的驱动芯片SSD1306支持4线SPI模式,所谓4线就是SCK、MOSI、DC、CS,加上复位RES一共五根控制线。如果你买的是7针模块,通常就是这五根加电源两根。

我用的STM32型号这里不具体点名,随便一块带硬件SPI的F1、F4、G0、L4都行。连线思路是这样的:SCK接STM32的SPI时钟脚,MOSI接SPI主输出脚,这两个必须用硬件SPI对应的复用引脚,不能随便找两个GPIO模拟,否则速率上不去。DC(数据/命令选择)和RES(复位)用普通GPIO就行,CS如果模块上有,也接一个普通GPIO,或者直接接地让片选一直有效——单从机场景下这么干没问题。

这里有个细节值得说:DC引脚的作用是告诉OLED当前发的是命令还是数据。DC拉低表示接下来SPI上传输的是命令字节,拉高表示是显示数据。很多新手搞混这个,结果屏幕要么全亮要么全黑,其实就是命令和数据发反了。RES复位脚在上电后需要拉低至少3微秒再拉高,给驱动芯片一个明确的复位脉冲,之后延时一段时间等内部初始化完成。

注意:VCC供电要确认模块是否自带稳压和电平转换。有些模块标称3.3V和5V兼容,但实际内部没有电平转换芯片,直接接5V可能烧掉驱动。稳妥起见先用3.3V,确认模块规格书再决定。

2.2 CubeMX里SPI参数怎么配

打开CubeMX,找到对应的SPI外设,模式选“Full-Duplex Master”或者“Transmit Only Master”都行。OLED只接收数据,不需要MISO,所以Transmit Only更省一个引脚。但有些老版本的CubeMX对Transmit Only支持不够顺滑,选Full-Duplex然后把MISO留空不接也完全可以。

关键参数是这几个:

  • Clock Polarity (CPOL)和Clock Phase (CPHA):SSD1306的SPI模式是Mode 0,也就是CPOL=0、CPHA=0。意思是时钟空闲时为低电平,数据在时钟的第一个边沿(上升沿)被采样。这个必须配对,配错了数据位会整体错位,显示出来就是乱码或者花屏。
  • Prescaler:分频系数决定SPI时钟频率。假设你的STM32主频是72MHz,SPI挂在APB2上也是72MHz,分频系数选8,得到9MHz的SPI时钟。SSD1306的SPI最高支持到10MHz左右,9MHz是安全且够快的选择。想再快可以试4分频到18MHz,但有些模块走线长了会不稳定,需要实测。
  • Data Size:8位,这个没悬念。
  • First Bit:MSB First,SSD1306要求高位先发。
  • NSS:如果用软件控制CS,就选“Software”或者直接禁用硬件NSS管理。单从机场景下CS常拉低也行。

配置完SPI,再把DC和RES两个GPIO配成输出模式,初始电平都设低。推挽输出、无上拉下拉、低速就行,这两个脚不需要高速翻转。

2.3 时钟树与延时函数

时钟树这块,确保SPI所在的总线时钟使能了,主频配置正确。很多人忽略的是延时函数——OLED初始化过程中需要若干毫秒级的延时,比如复位后等20ms,命令之间等几毫秒。我习惯用HAL库的HAL_Delay(),但要注意它依赖SysTick中断,如果在中断里调用会卡死。初始化阶段在主循环之前调用没问题。

如果你打算用DMA刷屏,还要把SPI的DMA请求使能,选一个空闲的DMA通道,方向是Memory to Peripheral,数据宽度都是Byte,模式选Normal还是Circular看需求。刷整屏用Normal就行,每次传输完重新配置。

3. OLED驱动原理与显存管理:搞懂它才能玩得转

3.1 SSD1306的显存结构

SSD1306内部有一块GDDRAM,大小是128×64位,也就是1024字节。它按页(Page)组织,一共8页,每页8行,每页有128列。一个字节的8个bit对应某一页某一列的8个像素,bit0在最上面,bit7在最下面。这种组织方式叫“页寻址模式”,是SSD1306默认也是最常用的寻址方式。

理解这个结构非常关键,因为它决定了你在MCU端怎么组织显存。最自然的做法是在STM32的RAM里开一个1024字节的数组,和GDDRAM一一对应。你想画一个点,就找到对应的页和列,把那个字节的对应bit置1或清0。想刷整屏,就把这1024字节通过SPI一次性推过去。

为什么要在MCU端开显存而不是直接往屏上写?因为OLED不支持读回显存(至少SPI模式下读回很麻烦),如果你直接写屏,想改一个像素就得知道原来那个字节是什么值,没法读就只能靠MCU端自己维护一份。所以标准做法就是MCU端维护一份显存镜像,所有绘图操作在RAM里完成,最后统一刷到屏上。

3.2 初始化命令序列解析

SSD1306上电后不能直接显示,需要发一串初始化命令。这些命令在数据手册里有,但手册写得比较散,我整理一份常用的、经过实测的序列,并解释每条命令在干什么:

// 关闭显示 OLED_WriteCmd(0xAE); // 设置显示时钟分频和振荡频率 OLED_WriteCmd(0xD5); OLED_WriteCmd(0x80); // 设置多路复用率,对应64行 OLED_WriteCmd(0xA8); OLED_WriteCmd(0x3F); // 设置显示偏移 OLED_WriteCmd(0xD3); OLED_WriteCmd(0x00); // 设置显示起始行 OLED_WriteCmd(0x40); // 电荷泵设置,必须开,否则屏不亮 OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); // 内存寻址模式,0x00是水平,0x02是页 OLED_WriteCmd(0x20); OLED_WriteCmd(0x00); // 设置段重映射 OLED_WriteCmd(0xA1); // 设置COM扫描方向 OLED_WriteCmd(0xC8); // 设置对比度 OLED_WriteCmd(0x81); OLED_WriteCmd(0xCF); // 设置预充电周期 OLED_WriteCmd(0xD9); OLED_WriteCmd(0xF1); // 设置COM硬件引脚配置 OLED_WriteCmd(0xDA); OLED_WriteCmd(0x12); // 设置VCOMH电压 OLED_WriteCmd(0xDB); OLED_WriteCmd(0x40); // 整个显示开启(非反色) OLED_WriteCmd(0xA4); // 正常显示(非反色) OLED_WriteCmd(0xA6); // 开启显示 OLED_WriteCmd(0xAF);

这里面有几个命令特别容易出问题。0x8D电荷泵那条,第二个参数必须是0x14开启,0x10是关闭。很多人初始化完屏幕不亮,十有八九是电荷泵没开。0xA1和0xC8这两条控制显示方向,如果你发现显示内容上下颠倒或者左右镜像,就是这两条配反了,互换一下就行。0xDA的0x12是针对128×64屏的COM配置,如果是128×32的屏要改成0x02。

3.3 显存刷新策略:全刷还是局部刷

最粗暴的刷屏方式是把1024字节全部通过SPI发一遍。9MHz时钟下,1024字节加上命令开销大概1毫秒出头,对于大多数应用完全够用。但如果你要做流畅的动画或者频繁更新的界面,全刷会有两个问题:一是浪费带宽,二是如果刷新和显示不同步可能看到撕裂。

局部刷新的思路是只更新变化了的页。比如你只改了一行文字,那就只刷那一页的128字节。实现上可以维护一个“脏页”标记,哪页的数据变了就标记哪页,刷屏时只发脏页。这个优化在菜单切换、数值更新场景下效果很明显。

还有一种更精细的做法是记录脏矩形,只刷变化的列范围。但SSD1306的页寻址模式下,设置列地址和页地址有开销,如果变化区域很碎,反而不如整页刷。我的经验是:文字界面用脏页刷新,图形动画用全刷加DMA,基本能覆盖大部分场景。

实操心得:刷屏前先发设置列地址和页地址的命令,把GDDRAM的写指针定位到起始位置,然后再连续发数据。如果地址没设对,数据会从错误的位置开始写,显示就错位了。

4. 代码实现:从底层驱动到上层绘图

4.1 底层SPI收发封装

先写最底层的两个函数,一个发命令,一个发数据。它们的区别只在DC引脚的电平:

void OLED_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); } void OLED_WriteData(uint8_t data) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); HAL_SPI_Transmit(&hspi1, &data, 1, HAL_MAX_DELAY); }

批量发数据的时候不要一个字节一个字节调HAL_SPI_Transmit,那样每次都有函数调用开销和状态检查,效率很低。正确做法是把要发的数据准备好,一次性调用:

void OLED_WriteDataBuffer(uint8_t *buf, uint16_t len) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); HAL_SPI_Transmit(&hspi1, buf, len, HAL_MAX_DELAY); }

如果要上DMA,就把HAL_SPI_Transmit换成HAL_SPI_Transmit_DMA,然后等传输完成回调或者查询标志位。DMA的好处是CPU不用管搬运,刷屏的同时可以干别的。但要注意DMA传输期间不能再往同一个SPI发数据,否则会冲突。

4.2 显存操作与基本绘图

在RAM里开一个全局数组:

static uint8_t OLED_GRAM[8][128];

为什么用二维数组?因为按页访问更直观,OLED_GRAM[page][col]一眼就知道在哪一页哪一列。画点的函数这样写:

void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t mode) { uint8_t page = y / 8; uint8_t bit = y % 8; if (mode) OLED_GRAM[page][x] |= (1 << bit); else OLED_GRAM[page][x] &= ~(1 << bit); }

有了画点,画线、画矩形、画圆都是在此基础上做数学运算。画线用Bresenham算法,画圆用中点画圆法,这些是图形学基础,网上资料很多,不展开。重点说一下字符显示。

4.3 字库与字符显示

12864屏显示字符,本质是把字符的点阵数据按页写到显存对应位置。常用的是8×16像素的ASCII字库,每个字符占16字节,高8字节是上半部分,低8字节是下半部分。取模方式要选“列行式”还是“行列式”取决于你的取模软件设置,常见的是纵向取模、字节倒序。

我一般用PC端取模软件生成一个字库数组,比如:

const uint8_t ASCII_8x16[][16] = { {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // 空格 // ... 其他字符 };

显示一个字符的函数:

void OLED_ShowChar(uint8_t x, uint8_t y, char ch) { uint8_t i; uint8_t page = y / 8; uint8_t index = ch - ' '; for (i = 0; i < 8; i++) { OLED_GRAM[page][x + i] = ASCII_8x16[index][i]; OLED_GRAM[page + 1][x + i] = ASCII_8x16[index][i + 8]; } }

这里假设字符高度16像素,跨两页。如果y不是8的倍数,就需要做位移处理,把点阵数据移位后或到显存里,稍微麻烦一点。我的建议是字符显示尽量对齐到页边界,省去移位运算,速度也快。

4.4 刷屏函数与DMA优化

刷屏就是把显存推到OLED。先设置写指针到左上角,然后连续发1024字节:

void OLED_Refresh(void) { OLED_WriteCmd(0x21); // 设置列地址 OLED_WriteCmd(0x00); OLED_WriteCmd(0x7F); OLED_WriteCmd(0x22); // 设置页地址 OLED_WriteCmd(0x00); OLED_WriteCmd(0x07); OLED_WriteDataBuffer((uint8_t *)OLED_GRAM, 1024); }

用DMA的话,把最后一行换成HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)OLED_GRAM, 1024)。但要注意,DMA传输期间OLED_GRAM不能被修改,否则发出去的数据会不一致。解决办法是双缓冲:开两个1024字节的数组,一个用于显示,一个用于绘图,刷屏时交换。这样CPU绘图和DMA传输互不干扰。

双缓冲的代价是多占1KB RAM,对于RAM紧张的型号(比如某些G0系列只有8KB RAM)要权衡。如果RAM不够,就单缓冲加传输完成等待,牺牲一点实时性。

5. 调试实录:那些让我熬夜的坑

5.1 屏幕完全不亮

这是最常见的问题。排查顺序应该是:先查供电,万用表量VCC和GND之间是不是3.3V;再查复位,用示波器或者逻辑分析仪看RES脚有没有正确的低-高脉冲;然后查电荷泵命令有没有发;最后查SPI波形,看SCK和MOSI有没有数据出来。

我遇到过一次,所有命令都发了,波形也对,就是不亮。折腾半天发现是模块背面的一个电阻虚焊,属于硬件问题。所以软件查完没问题,要怀疑硬件。

5.2 显示花屏或错位

花屏通常和SPI模式有关。CPOL和CPHA配错,数据采样边沿不对,收到的字节就是乱的。确认CubeMX里是Mode 0。另一个可能是SPI速率太高,线太长或者没有屏蔽,导致信号完整性差。把分频系数调大,降到4MHz试试,如果好了就是速率问题。

错位的话检查列地址和页地址设置。水平寻址模式下,列地址0到127,页地址0到7。如果起始地址设错,整个画面会偏移。还有可能是显存数组和屏幕的对应关系搞反了,比如x和y写反了。

5.3 DMA传输卡死

用DMA刷屏时,如果传输完成中断没开,或者DMA通道配置错误,程序会卡在等待传输完成的循环里。检查DMA配置:方向是Memory to Peripheral,外设地址是SPI的DR寄存器,内存地址是显存数组,数据宽度都是Byte,模式Normal。还要确保SPI的DMA请求使能了(在SPI配置里勾选TX DMA)。

另一个坑是DMA传输和SPI的BSY标志。HAL库的HAL_SPI_Transmit_DMA会检查SPI是否忙,如果上一次传输还没完成就调用,会返回HAL_BUSY。所以要么等上一次完成,要么用双缓冲错开。

5.4 刷新闪烁

全刷的时候如果刷新频率和屏幕扫描不同步,会看到闪烁。SSD1306内部有自己的扫描时钟,我们控制不了。减少闪烁的办法是提高刷新率,让闪烁频率高到人眼察觉不到,或者用局部刷新减少每次刷新的数据量。另外,刷屏前先关显示(0xAE),刷完再开(0xAF),也能消除撕裂,但会有一瞬间的黑屏,适合不频繁刷新的场景。

5.5 常见问题速查表

现象可能原因排查方法
完全不亮供电、复位、电荷泵量电压、看RES波形、确认0x8D后跟0x14
花屏SPI模式错、速率过高确认Mode 0、降低分频
显示错位地址设置错、x/y搞反检查0x21/0x22参数、核对绘图坐标
DMA卡死中断未开、通道配置错检查DMA配置和SPI DMA使能
闪烁刷新率低、全刷数据量大提高刷新率、改局部刷新
部分像素不亮显存对应bit没置位检查画点函数的页和bit计算

避坑技巧:调试SPI屏的时候,逻辑分析仪比示波器好用。它能直接解码SPI协议,把每个字节的内容显示出来,一眼就能看出命令和数据发对没有。没有逻辑分析仪的话,至少用示波器看SCK有没有波形,MOSI有没有变化。

6. 性能优化与进阶玩法

6.1 提高SPI速率的边界在哪

SSD1306数据手册标称SPI时钟最高10MHz,但实际测试中很多模块能跑到20MHz以上不出错。这取决于模块的PCB走线、排线长度和电源质量。我的做法是从9MHz开始,逐步提高分频,每次刷一整屏测试,看有没有花屏。找到稳定上限后留20%余量使用。

提高速率最直接的收益是刷屏时间缩短。9MHz下1024字节约0.9ms,18MHz下约0.45ms。对于做动画来说,这意味着帧率可以翻倍。

6.2 局部刷新与脏页标记

实现脏页刷新很简单,加一个8位的标记变量,每一位对应一页:

static uint8_t dirty_pages = 0xFF; void OLED_MarkDirty(uint8_t page) { dirty_pages |= (1 << page); } void OLED_RefreshDirty(void) { for (uint8_t page = 0; page < 8; page++) { if (dirty_pages & (1 << page)) { OLED_WriteCmd(0x21); OLED_WriteCmd(0x00); OLED_WriteCmd(0x7F); OLED_WriteCmd(0x22); OLED_WriteCmd(page); OLED_WriteCmd(page); OLED_WriteDataBuffer(OLED_GRAM[page], 128); } } dirty_pages = 0; }

每次绘图操作后调用OLED_MarkDirty标记受影响的页,刷屏时只刷脏页。在菜单界面下,通常只有一两页在变,刷屏数据量降到原来的八分之一甚至更少。

6.3 用DMA加双缓冲做流畅动画

动画场景下,每帧都要全刷。用DMA加双缓冲可以让CPU在DMA传输的同时准备下一帧数据:

static uint8_t OLED_GRAM_A[8][128]; static uint8_t OLED_GRAM_B[8][128]; static uint8_t (*OLED_GRAM)[128] = OLED_GRAM_A; static uint8_t (*OLED_GRAM_Next)[128] = OLED_GRAM_B; void OLED_SwapBuffer(void) { uint8_t (*tmp)[128] = OLED_GRAM; OLED_GRAM = OLED_GRAM_Next; OLED_GRAM_Next = tmp; }

绘图操作画到OLED_GRAM,画完后交换指针,把刚画好的那帧用DMA发出去,同时下一帧画到另一个缓冲。这样CPU和DMA并行工作,帧率能明显提升。

6.4 显示中文字符的思路

中文显示和ASCII类似,只是字库更大。常用16×16点阵,每个汉字32字节。全字库太大放不下,一般只取项目用到的汉字,用取模软件生成。显示时和ASCII一样,按页写入显存。注意汉字宽度是16像素,占两列,坐标计算要相应调整。

如果汉字数量多,可以考虑把字库放到外部Flash,用的时候读出来。但12864屏本身显示内容有限,通常几十个汉字够用了,直接放内部Flash就行。

7. 我实际调试下来的一些体会

这块屏我从最早用GPIO模拟SPI,到后来换硬件SPI,再到加DMA和双缓冲,每一步都踩过坑。最深的体会是:SPI模式和数据手册一定要对着看,不能凭感觉配。我见过太多人CPOL和CPHA配错,然后花几个小时查代码逻辑,其实问题就在CubeMX的一个下拉框里。

另一个体会是显存管理要尽早规划。一开始图省事直接写屏,后来想加局部刷新发现改不动,只能推倒重来。如果一开始就用显存镜像加脏页标记,后面加什么功能都从容。

还有一点,逻辑分析仪真的是嵌入式调试的利器。SPI、I2C这种同步串行总线,用逻辑分析仪抓一次波形,比看十遍代码都管用。它能把时序和字节内容直接解出来,命令发没发对、数据有没有错位,一目了然。如果预算有限,买个几十块的入门款也够用。

最后说个细节:OLED长时间显示固定画面会有烧屏风险,尤其是高对比度的静态内容。如果产品是长期运行的,建议加个屏保或者定期微移显示位置。这个在消费类产品里可能不明显,但工业设备上跑几个月就能看出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询