1. 项目缘起与整体设计思路
1.1 为什么选择SPI驱动12864 OLED
手头有一块12864的OLED屏,驱动芯片是SSD1306,分辨率128x64,单色。这种屏在嵌入式项目里出镜率极高,做小设备的状态显示、菜单界面、波形绘制都很合适。它支持I2C和SPI两种接口,我这次选了SPI,原因很直接:I2C在标准模式下只有100kHz,快速模式也就400kHz,刷一整屏1024字节的数据,光传输就要二十多毫秒,遇到需要频繁刷新的场景会明显卡顿。SPI随便配一下就是几MHz起步,刷屏时间能压到毫秒级,流畅度完全不是一个量级。
另一个考虑是引脚占用。I2C只要两根线,看起来省引脚,但实际项目里I2C总线上往往还挂着其他器件,地址冲突、总线锁死这些问题排查起来很烦。SPI虽然多占几根线,但它是点对点的,独占总线,时序干净,调试的时候用逻辑分析仪一抓就能看清楚每一位数据,省心。
STM32CubeIDE这套工具链我用得比较顺手,图形化配置时钟树和引脚复用,底层初始化代码自动生成,能把精力集中在业务逻辑上。这次的目标很明确:用硬件SPI把SSD1306驱动起来,封装出一套好用的绘图API,能画点、画线、画矩形、显示字符和数字,最后做一个实时刷新的小界面验证效果。
1.2 整体方案架构拆解
整个方案分成三层来设计,这样分层的好处是耦合度低,换屏或者换MCU的时候改动量小。
最底层是硬件抽象层,负责SPI外设的初始化、GPIO控制(DC、RST、CS引脚)、以及最基础的字节收发函数。这一层直接跟STM32的寄存器和HAL库打交道,把“怎么把一字节数据送出去”这件事封装好。
中间层是SSD1306驱动层,实现命令写入、数据写入、初始化序列、页地址设置、显存刷新这些跟具体屏幕芯片相关的操作。这一层不关心上层要显示什么内容,只负责把显存里的数据正确送到屏幕上。
最上层是图形与文本层,维护一块1024字节的显存缓冲区,提供画点、画线、画矩形、显示ASCII字符、显示数字等API。所有绘图操作先改缓冲区,最后统一调用刷新函数一次性推送到屏幕。
这种“显存缓冲+统一刷新”的模式是驱动OLED的标准做法。为什么不每画一个点就直接写屏?因为SPI传输本身有开销,频繁的小数据量传输会把总线利用率拉得很低,而且屏幕会出现撕裂感。先在内存里把一帧画面拼好,再一次性刷过去,视觉上稳定得多。
提示:显存缓冲区占1024字节,对于RAM只有20KB的STM32F103C8T6来说占比不小,但完全可接受。如果RAM实在紧张,可以只缓冲部分区域,或者改用直接写屏模式,代价是刷新时会有闪烁。
2. 硬件连接与CubeIDE配置细节
2.1 引脚分配与接线方案
我用的是一块常见的STM32F103C8T6最小系统板,SPI1的默认引脚是PA5(SCK)、PA6(MISO)、PA7(MOSI)。驱动OLED其实只需要MOSI和SCK,MISO用不上,但SPI外设配置成全双工也无妨,不影响使用。
除了SPI的三根线,还需要三根控制线:DC(数据/命令选择)、RST(复位)、CS(片选)。我分配的是PB0接DC,PB1接RST,PB2接CS。CS如果只有一个SPI从设备,其实可以直接接地,省一个引脚,但保留CS更规范,以后总线上加其他器件也方便。
接线的时候有个细节要注意:OLED模块的VCC一般接3.3V,不要接5V,虽然有些模块标称支持5V,但长期跑在5V下对屏寿命有影响。GND一定要共地,这个不用多说。另外SCK和MOSI的走线尽量短,如果排线超过10厘米,建议在SCK上串一个22欧姆的电阻,抑制过冲和振铃,信号质量会好很多。
| 引脚 | 功能 | 连接对象 |
|---|---|---|
| PA5 | SPI1_SCK | OLED SCL |
| PA7 | SPI1_MOSI | OLED SDA |
| PB0 | GPIO输出 | OLED DC |
| PB1 | GPIO输出 | OLED RST |
| PB2 | GPIO输出 | OLED CS |
| 3.3V | 电源 | OLED VCC |
| GND | 地 | OLED GND |
2.2 CubeIDE中的SPI参数配置
打开CubeIDE,在Pinout视图里把PA5和PA7配置为SPI1的复用功能。然后进Connectivity里的SPI1配置面板,几个关键参数需要仔细设置。
Mode选Full-Duplex Master,虽然我们只用发送,但全双工模式下硬件会自动处理时钟,配置简单。Data Size选8 Bits,SSD1306一次接收一个字节。**Clock Polarity(CPOL)和Clock Phase(CPHA)**这两个参数决定了SPI的采样时刻,SSD1306要求的是CPOL=High、CPHA=2Edge,也就是空闲时时钟为高,在第二个边沿采样。这个如果配错了,屏幕要么不亮,要么显示乱码,是新手最容易踩的坑。
Prescaler分频系数决定了SPI时钟频率。STM32F103的APB2总线是72MHz,分频系数选8的话,SPI时钟就是9MHz。SSD1306的SPI最高支持10MHz,9MHz在安全范围内。如果发现显示不稳定,可以降到4.5MHz(分频16)试试,先保证能用再追求速度。
First Bit选MSB First,SSD1306是高位先出。NSS Signal Type选Software,因为我们用普通GPIO控制CS,不用硬件NSS。
配置完这些,生成代码,CubeIDE会自动生成SPI1的初始化函数和GPIO初始化函数。接下来就可以在生成的工程基础上写驱动了。
注意:生成代码前记得把PB0、PB1、PB2配置为GPIO_Output,初始电平都设为High。DC和CS默认拉高,RST也拉高(复位是低电平有效,正常工作时保持高电平)。
3. SSD1306驱动核心实现
3.1 底层字节收发与命令数据区分
SSD1306通过DC引脚来区分收到的是命令还是数据:DC拉低表示命令,DC拉高表示数据。所以底层需要封装两个函数,一个写命令,一个写数据,它们内部都调用同一个SPI发送函数,区别只在于DC引脚的电平。
static void OLED_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); } static void OLED_WriteData(uint8_t data) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &data, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); }这里每次传输都拉低再拉高CS,看起来有点浪费,但对于单从设备来说没问题。如果要优化,可以在连续传输一批数据时把CS一直拉低,传完再拉高,能减少GPIO翻转开销。不过HAL_SPI_Transmit本身有函数调用开销,单字节传输的效率瓶颈不在这。
批量写数据的时候,用HAL_SPI_Transmit一次性发送整个缓冲区,比循环调用单字节函数快得多。因为HAL库的单次传输有固定的软件开销,传1024字节如果分1024次调用,光函数开销就占了大头。
static void OLED_WriteMultiData(uint8_t *buf, uint16_t len) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, buf, len, HAL_MAX_DELAY); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); }3.2 初始化序列与关键命令解析
SSD1306的初始化序列是一串命令,每条命令都有特定作用。我挑几个关键的说一下,理解了这些,调试的时候心里有底。
void OLED_Init(void) { HAL_GPIO_WritePin(OLED_RST_PORT, OLED_RST_PIN, GPIO_PIN_RESET); HAL_Delay(100); HAL_GPIO_WritePin(OLED_RST_PORT, OLED_RST_PIN, GPIO_PIN_SET); HAL_Delay(100); 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); // 设置起始行 OLED_WriteCmd(0x8D); // 电荷泵设置 OLED_WriteCmd(0x14); // 开启电荷泵 OLED_WriteCmd(0x20); // 内存寻址模式 OLED_WriteCmd(0x00); // 水平寻址 OLED_WriteCmd(0xA1); // 段重映射 OLED_WriteCmd(0xC8); // 扫描方向 OLED_WriteCmd(0xDA); // COM引脚配置 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); // 开启显示 }0x8D这条命令控制电荷泵,必须写0x14开启,否则屏幕不亮。这是很多新手遇到的第一个坑:初始化序列都发了,屏幕就是黑的,八成是电荷泵没开。0xA1和0xC8这两条控制显示方向,如果发现显示是镜像的或者上下颠倒的,就是这两条配反了,互换一下就好。0x20设置寻址模式,0x00是水平寻址,适合整屏刷新;0x02是页寻址,适合局部更新。我整屏刷新用水平寻址,效率更高。
3.3 显存刷新与页地址管理
SSD1306的显存组织方式是8页,每页8行,共64行。水平寻址模式下,设置好起始页和结束页、起始列和结束列,然后连续写入数据,芯片会自动按列递增,写到页尾自动跳到下一页。
void OLED_Refresh(void) { OLED_WriteCmd(0x21); // 设置列地址 OLED_WriteCmd(0x00); // 起始列0 OLED_WriteCmd(0x7F); // 结束列127 OLED_WriteCmd(0x22); // 设置页地址 OLED_WriteCmd(0x00); // 起始页0 OLED_WriteCmd(0x07); // 结束页7 OLED_WriteMultiData(OLED_Buffer, 1024); }这段代码把整个1024字节的缓冲区一次性推送到屏幕。0x21和0x22是水平寻址模式下的列地址和页地址设置命令。注意结束列是0x7F(127),结束页是0x07(7),一共128列8页,正好1024字节。
实操心得:如果屏幕出现部分区域不刷新或者花屏,先检查列地址和页地址的范围设置对不对。另外,OLED_WriteMultiData里的HAL_SPI_Transmit超时参数用HAL_MAX_DELAY没问题,但如果你在中断里调用刷新,要改成有限超时,避免死等。
4. 图形与文本API的封装
4.1 画点、画线与画矩形
所有图形的基础是画点。显存里每一位对应屏幕上的一个像素,字节的bit0对应最上面一行,bit7对应最下面一行。画点函数需要根据坐标算出在缓冲区里的字节偏移和位偏移。
void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t mode) { if (x >= 128 || y >= 64) return; uint16_t idx = x + (y / 8) * 128; uint8_t bit = y % 8; if (mode) OLED_Buffer[idx] |= (1 << bit); else OLED_Buffer[idx] &= ~(1 << bit); }画线用Bresenham算法,这个算法只用整数加减和移位,没有浮点运算,在MCU上跑很快。画矩形就是画四条线,填充矩形则是逐行画水平线。这些算法都是图形学里的经典内容,网上资料很多,我就不逐行展开了。重点说一下画线时的边界处理:如果起点或终点超出屏幕范围,要么裁剪要么直接返回,我选择直接返回,因为超范围绘制通常是逻辑bug,早点暴露比默默裁剪好。
4.2 ASCII字符显示与字库设计
显示字符需要字库。我用的是8x16的ASCII字库,每个字符16字节,前8字节是上半部分,后8字节是下半部分。字库数据可以自己用取模软件生成,也可以用现成的。取模方式选“列行式”,高位在前,这样跟SSD1306的显存组织方式匹配。
void OLED_ShowChar(uint8_t x, uint8_t y, char ch) { uint8_t i, j; uint8_t idx = ch - ' '; for (i = 0; i < 8; i++) { OLED_Buffer[x + i + (y / 8) * 128] = ASCII_8x16[idx * 16 + i]; OLED_Buffer[x + i + (y / 8 + 1) * 128] = ASCII_8x16[idx * 16 + i + 8]; } }这个函数把字符的上半部分写到当前页,下半部分写到下一页。注意y坐标必须是8的倍数,否则会跨页错位。显示字符串就是循环调用ShowChar,x坐标每次加8。显示数字的话,先把数字转成字符串再显示,或者写一个专门的数字显示函数,效率更高。
注意:字库数组如果放在Flash里,记得加const修饰,否则会占用宝贵的RAM。STM32F103C8T6只有20KB RAM,一个完整的8x16 ASCII字库是95个字符乘以16字节,共1520字节,放RAM里就去了7.6%,不划算。
4.3 显存缓冲区的管理策略
缓冲区是一块1024字节的全局数组,所有绘图操作都改它,改完调用OLED_Refresh推送到屏幕。这种双缓冲机制的好处是画面不会撕裂,坏处是需要额外的RAM和一次完整传输。
如果RAM紧张,可以改成单缓冲:绘图操作直接写屏幕。但这样每次画点都要发SPI命令,速度慢且闪烁。折中方案是只缓冲变化区域,用脏矩形标记,刷新时只更新脏区域。这个优化在菜单界面很有用,因为大部分区域是不变的。
我实测下来,9MHz SPI时钟下,刷新1024字节大约需要1.1毫秒(1024字节乘以8位除以9MHz,再加上命令开销)。这个速度足够跑30帧以上的动画了。如果只刷新局部区域,比如一行文字,时间可以降到几十微秒。
5. 常见问题排查与避坑指南
5.1 屏幕不亮或全亮
这是最常见的问题。排查顺序如下:先量VCC和GND,确认供电正常;再量RST引脚,确认复位时序正确(低电平至少10微秒,然后拉高);然后检查电荷泵命令0x8D后面是否跟了0x14;最后检查SPI的CPOL和CPHA配置。如果屏幕全亮而不是全黑,通常是初始化序列里0xA4和0xA6没配对,或者对比度设置0x81后面的值太大。
还有一个隐蔽的坑:有些OLED模块的CS引脚是低电平使能,有些是高电平使能,虽然绝大多数是低电平使能,但遇到不亮的屏,不妨把CS直接接地试试,排除CS控制的问题。
5.2 显示内容错位或镜像
显示镜像通常是0xA1和0xA0配错,或者0xC8和0xC0配错。0xA1是段重映射,0xC8是COM扫描方向反转。如果显示左右镜像,把0xA1改成0xA0;如果上下镜像,把0xC8改成0xC0。两个都镜像就是旋转180度,两个都改就行。
显示错位一般是列地址或页地址设置不对。水平寻址模式下,0x21设置列范围,0x22设置页范围,如果范围设错了,数据就会写到错误的位置。另外,如果用了页寻址模式(0x20后面跟0x02),每次写数据前都要先设置页地址和列地址,不能像水平寻址那样连续写。
5.3 SPI通信不稳定
SPI通信不稳定的表现是屏幕偶尔花屏、部分区域不刷新、或者随机出现噪点。原因通常有三个:时钟太快、走线太长、电源不稳。
时钟太快的话,把分频系数调大,降到4.5MHz试试。走线太长的话,缩短排线或者加串阻。电源不稳的话,在OLED的VCC和GND之间并一个100nF的陶瓷电容,再并一个10uF的钽电容,能滤掉大部分高频噪声。
还有一个容易忽略的点:HAL_SPI_Transmit的超时参数。如果设得太短,传输没完成就返回了,数据会丢失。我一般用HAL_MAX_DELAY,但在中断或RTOS任务里要改成有限值,比如100毫秒,避免阻塞其他任务。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 屏幕全黑 | 电荷泵未开、复位异常 | 检查0x8D命令、量RST电平 |
| 屏幕全亮 | 对比度设置异常 | 检查0x81后的值 |
| 显示镜像 | 重映射命令配错 | 调整0xA1/0xC8 |
| 显示错位 | 地址范围设置错误 | 检查0x21/0x22参数 |
| 随机花屏 | SPI时钟过快、电源噪声 | 降频、加滤波电容 |
| 部分不刷新 | 缓冲区未更新、地址越界 | 检查绘图坐标范围 |
5.4 刷新速度优化技巧
如果觉得刷新慢,有几个优化方向。第一,提高SPI时钟,SSD1306支持到10MHz,可以试着把分频系数降到4,跑18MHz,但要看屏幕能不能稳定工作,不行再降回来。第二,用DMA传输,把CPU解放出来,HAL_SPI_Transmit_DMA配合传输完成回调,能在刷屏的同时处理其他逻辑。第三,只刷新变化区域,用脏矩形标记,这个优化效果最明显,但实现起来也最复杂。
我个人的经验是,对于大多数应用,9MHz加整屏刷新已经够用了。如果跑UI动画觉得卡,先试试局部刷新,实在不行再上DMA。DMA虽然快,但配置复杂,调试也麻烦,杀鸡用牛刀不一定划算。
6. 实际项目中的扩展思路
6.1 多级菜单界面的实现
有了这套绘图API,做一个多级菜单就很顺手了。我的做法是定义一个菜单结构体数组,每个菜单项包含显示文本、子菜单指针、回调函数。按键中断里改变当前选中项,主循环里根据选中项刷新显示。菜单的绘制用反色高亮当前项,就是画一个填充矩形再在上面写反色文字,视觉效果很清晰。
菜单的刷新策略是只重绘变化的部分:选中项变化时,只重绘旧选中项和新选中项两行,不用整屏刷新。这样即使主循环跑得慢,菜单响应也很跟手。
6.2 实时波形显示
OLED做波形显示很实用,比如显示传感器数据。我的做法是维护一个128字节的环形缓冲区,每次新数据来了就替换最旧的数据,然后重绘整个波形区域。重绘的时候先清空波形区域,再逐列画点连线。128个点画下来,加上刷新,在9MHz SPI下大概2毫秒,跑100Hz刷新率没问题。
如果要显示更长的波形,可以用滚动模式:新数据画在最右边,整个缓冲区左移一列。这样视觉上是波形在滚动,很直观。左移操作可以用memmove实现,1024字节的缓冲区左移一列是128次memmove,每次移动8字节,开销不大。
6.3 低功耗场景下的显示策略
电池供电的设备对功耗敏感。SSD1306本身功耗不高,全亮大概20毫安,但加上MCU和SPI的功耗就上去了。低功耗策略有几个:不用的时候发0xAE关闭显示,功耗降到微安级;降低刷新率,比如从30帧降到5帧;降低对比度,0x81后面的值从0xCF降到0x3F,亮度降一半,功耗也降。
还有一个技巧是分页刷新:只刷新有变化的页,其他页不动。比如只更新第一页的状态栏,其他页保持不动,SPI传输量从1024字节降到128字节,功耗和耗时都降到八分之一。
7. 个人实操体会与建议
这套SPI驱动12864 OLED的方案,我从第一次点亮到封装成可复用的驱动库,前后折腾了大概一周。踩过的坑主要集中在SPI时序配置和初始化序列上,这两个地方一旦搞对,后面就是一马平川。
我的建议是,新手先不要急着写图形API,先把屏幕点亮,能全屏点亮和全屏熄灭,说明底层通了。然后再写画点函数,画一个点在屏幕上看到,说明显存映射对了。再写刷新函数,能整屏刷新,说明地址设置对了。最后再往上堆图形和文本。这个自底向上的调试顺序,每一步都有明确的验证标准,出问题容易定位。
另外,逻辑分析仪是调试SPI的利器。没有逻辑分析仪的话,用示波器看SCK和MOSI也行,至少能确认有没有波形、时钟频率对不对。纯靠猜和改代码,效率太低了。
最后说一句,SSD1306这颗驱动芯片虽然老,但资料全、生态好,各种现成的驱动库很多。不过我还是建议自己从头写一遍,写完之后对SPI、对显存映射、对图形算法的理解会上一个台阶。以后换其他屏,比如ST7789或者ILI9341,套路是一样的,迁移成本很低。