
1. 为什么在STM32CubeIDE里折腾SPI驱动OLED值得单独写一篇嵌入式开发里点亮一块屏幕往往是项目从“能跑”到“好用”的分水岭。串口打印调试信息谁都会但要把数据实时可视化、做本地菜单、显示传感器波形一块小尺寸OLED几乎是成本最低、效果最直接的选择。12864这个规格指的是128×64像素的点阵屏常见尺寸0.96寸到1.3寸单色显示对比度高阳光下也能看清功耗还低特别适合电池供电的便携设备。我这次用的是SPI接口的12864 OLED主控是SSD1306或SH1106这类常见驱动芯片。为什么选SPI而不是I2CI2C接线少两根线就能搞定但速率上限摆在那里标准模式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、D0SCK、D1MOSI、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也就是CPOL0、CPHA0。意思是时钟空闲时为低电平数据在时钟的第一个边沿上升沿被采样。这个必须配对配错了数据位会整体错位显示出来就是乱码或者花屏。Prescaler分频系数决定SPI时钟频率。假设你的STM32主频是72MHzSPI挂在APB2上也是72MHz分频系数选8得到9MHz的SPI时钟。SSD1306的SPI最高支持到10MHz左右9MHz是安全且够快的选择。想再快可以试4分频到18MHz但有些模块走线长了会不稳定需要实测。Data Size8位这个没悬念。First BitMSB FirstSSD1306要求高位先发。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.9ms18MHz下约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长时间显示固定画面会有烧屏风险尤其是高对比度的静态内容。如果产品是长期运行的建议加个屏保或者定期微移显示位置。这个在消费类产品里可能不明显但工业设备上跑几个月就能看出来。