1. 项目概述与方案定位
这块1.3寸OLED屏幕,想必很多做嵌入式、玩单片机的人手里都有一片。它体积小、分辨率高、不需要背光、亮度足、功耗还低,特别适合做状态显示、菜单界面、波形绘制这类任务。我这次把STM32和这块屏通过IIC接口(I2C)连起来,完整的驱动代码经过几轮重构后,现在拿出来讲讲整个思路和踩过的坑,给后来者一个相对少走弯路的参考。
这套驱动方案适合谁?刚入门STM32想玩转OLED但又不想照抄稀里糊涂的网上的模板代码的人;做项目需要在有限引脚约束下(IIC只占SCL和SDA两根线)驱动屏幕的人;以及已经跑通过但总觉得代码不够规范化、想重构出清晰驱动结构的人。它解决的问题很直接:在没有屏幕内置字库的前提下,搭建一套可复用、可移植、不依赖特定开发板的OLED驱动框架,涵盖初始化、像素操作、字符显示、图片显示四个层次。
先说结论性的东西。1.3寸OLED上用的驱动芯片通常是两家的方案,一个是SH1106,一个是SSD1306,两者在指令集上高度兼容,最大的区别在于内存映射方式:SSD1306的显存是128x64,SH1106显存是132x64(横向多了4个无效列)。这块1.3寸屏默认分辨率是128x64,IIC模式下驱动芯片从哪一个偏移地址开始显示,取决于厂商默认的列偏移配置,通常SH1106需要手动设置偏移,而SSD1306不需要。这一点如果一开始没搞清楚,后面显示出现“画面整体右移/左移半列”的问题时,排查会浪费不少时间。
我在项目中选用的是某块国产开发板上集成的1.3寸OLED模块,接口引出VCC、GND、SCL、SDA四根排针,模块上拉了上拉电阻。从一开始我就确定用软件模拟I2C的方式来做驱动,而不是STM32自带的硬件I2C外设。原因后面详细展开,这里先声明我的倾向:对于玩屏幕这种速率要求不高的外设,软件模拟时序足够,兼容性还更强。
硬件环境:某开发板(主控STM32F103RCT6),1.3寸OLED模块(SH1106驱动IC,IIC接口) 软件环境:标准外设库(SPL),Keil MDK,代码整体采用模块化分层设计2. IIC接口与OLED的通信机理
2.1 IIC总线的基本工作时序
IIC总线本质上就两条线:时钟线SCL和数据线SDA。所有的设备都挂在这两条线上,通过设备地址区分谁和谁通信。通信起始时主机拉低SDA(此时SCL为高电平),发送一个起始信号;结束时主机释放SDA让线上拉高(同样SCL为高),产生一个停止信号。
每次传输一个字节时,数据在SCL高电平期间必须保持稳定,只有在SCL低电平期间允许SDA变化。这个相位关系是IIC时序的核心,写代码时最需要小心的点就是这里——如果你在SCL还为高电平时就把SDA的电平给变了,从机就会把这次变化误判成起始或停止信号,通信随即出错。
实际驱动中我用了标准外设库的GPIO翻转方式来模拟时序,速率并没有跑满。根据驱动芯片的数据手册,IIC模式下最大时钟频率一般是400kHz。我软件模拟出来的时序实测大概在100~200kHz之间,完全够用。屏幕本身不要求高刷新率,人眼也看不出和硬件I2C有什么区别。
提示:OLED模块上通常已经自带上拉电阻,如果你的模块比较简陋或者你正在用自己画的板子,请记得在SCL和SDA上各接一个4.7kΩ左右的上拉电阻到VCC,否则通信会不稳定。
2.2 OLED设备的从机地址与寄存器映射
这个1.3寸OLED在IIC总线上扮演的是从机角色,地址取决于模块上SA0引脚的电平。常见的模块引出的IIC地址有两种:0x78(写地址)和0x7A(写地址),对应7位地址0x3C和0x3D。我的模块上SA0默认接地,写地址就是0x78。
IIC通信中每次写操作流程是:起始信号,发送设备地址字节(含读写标志位),发送控制字节,随后发送若干个数据字节。这里有一个非常关键的细节:OLED使用的是控制字节机制,而不是裸传数据。控制字节的高位决定后续数据是命令还是显存数据:0x00表示后续字节是命令,0x40表示后续字节是显存数据。
很多第一次接触的人在这里会犯迷糊:为什么我发了地址之后屏幕没反应?因为你漏了控制字节。从机在收到地址后,得先等一个控制字节来区分接下来的字节是命令还是数据,如果你直接把命令字节丢上去,从机没法识别。
2.3 SSD1306与SH1106的差异要点
这两颗IC在指令上几乎通用,但显存结构不一样。SSD1306内部是128x64的GRAM,直接对应屏幕的每一列每一页;SH1106则是132x64的GRAM,多出来的4列在屏幕显示范围之外。我用的1.3寸屏是SH1106方案,因此初始化时机需要在显示范围设置相关命令上稍作调整。
翻阅芯片手册,SH1106有一条专用命令0xD4(Set Display Offset)结合列地址范围设置,可以修正起始列偏移。如果你初始化后文字整体偏右几个像素,或者左侧有一条垂直的条纹,通常就是这块偏移没设对。针对驱动为SH1106芯片的1.3寸OLED,要在初始化序列中加入段重映射和列偏移修正,才能保证64列到128列的每个像素点都正确定位。
SSD1306的同类屏幕则不需要折腾这些,初始化按常规序列就可以了。所以拿到屏幕先看主控丝印或者问卖家要规格书,是第一步要做的功课。
3. STM32驱动层的架构设计与代码实现
3.1 为什么选软件模拟IIC而不是硬件I2C
很多教材喜欢教人用STM32的硬件I2C外设,但我实测下来,标准外设库版本下的硬件I2C在主频配置不当、中断优先级错乱、或者总线上有其他设备的情况下,容易出现Busy Flag卡死的现象。这个现象特别烦人——表现为程序运行一段时间后,写入函数就永远停在那里,不报错也不超时,复位后才能恢复。
软件模拟IIC虽然代码看起来多几句,但可控性极高:时钟极性、速率、时序延时的每一步都在自己手里掌握着,出了问题可以直接用逻辑分析仪量波形,定位速度快得多。对于OLED这种低速写入设备,根本没必要用硬件I2C的DMA加中断那种高级玩法,反而给自己增加排查难度。
另外一点就是移植性。我经常在不同型号的单片机之间切换,软件模拟IIC只需要改两个GPIO的宏定义,不需要动协议层代码。硬件I2C则要根据不同的MCU库去调整结构体配置和中断函数,麻烦得多。
3.2 IIC驱动的代码结构
驱动代码我分成了三层:
第一层是底层IO模拟层,定义SCL和SDA引脚对应的GPIO端口和引脚号,通过宏来实现高低电平翻转和输入读取。
#define OLED_SCL_PORT GPIOC #define OLED_SCL_PIN GPIO_Pin_6 #define OLED_SDA_PORT GPIOC #define OLED_SDA_PIN GPIO_Pin_7 #define OLED_SCL_1() GPIO_SetBits(OLED_SCL_PORT, OLED_SCL_PIN) #define OLED_SCL_0() GPIO_ResetBits(OLED_SCL_PORT, OLED_SCL_PIN) #define OLED_SDA_1() GPIO_SetBits(OLED_SDA_PORT, OLED_SDA_PIN) #define OLED_SDA_0() GPIO_ResetBits(OLED_SDA_PORT, OLED_SDA_PIN) #define OLED_SDA_READ() GPIO_ReadInputDataBit(OLED_SDA_PORT, OLED_SDA_PIN)第二层是协议层,实现起始、停止、发送一个字节、接收应答等IIC基本操作。
static void IIC_Start(void) { OLED_SDA_1(); OLED_SCL_1(); delay_us(5); OLED_SDA_0(); delay_us(5); OLED_SCL_0(); } static void IIC_Stop(void) { OLED_SDA_0(); OLED_SCL_1(); delay_us(5); OLED_SDA_1(); delay_us(5); } static void IIC_SendByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) OLED_SDA_1(); else OLED_SDA_0(); OLED_SCL_1(); delay_us(2); OLED_SCL_0(); dat <<= 1; } }第三层是OLED应用层,封装了写命令、写数据、初始化、清屏、显示字符、显示图片等API。应用层的代码不直接操作GPIO,而是通过调用第二层的IIC函数来完成与OLED的交互。
这种分层带来的好处是显而易见的:如果以后要把驱动移植到别的MCU上,只需要改第一层的GPIO映射;如果以后想用硬件I2C,只需要重写第二层,保证第三层的API签名不变,上层代码完全不用动。
3.3 初始化序列的深度解读
OLED上电后并不会自动进入可显示状态,需要主机发送一系列初始化命令,让它开启内部DC-DC电荷泵、设置显示时钟分频比、调整对比度等。很多人直接把网上抄来的初始化数组贴进去,也不管是哪颗IC,最后显示不正常了也不知道怎么排查。
我用的SH1106初始化序列经过反复验证,每一段的用处都比较清楚:
static void OLED_Init_Commands(void) { IIC_Start(); IIC_SendByte(OLED_ADDR_WRITE); IIC_SendByte(0x00); // 控制字节:后续全部是命令 IIC_SendByte(0xAE); // 关闭显示 IIC_SendByte(0xD5); // 设置显示时钟分频比 IIC_SendByte(0x80); IIC_SendByte(0xA8); // 设置多路复用比 1/64 IIC_SendByte(0x3F); IIC_SendByte(0xD3); // 设置显示偏移 IIC_SendByte(0x00); IIC_SendByte(0x40); // 设置显示起始行 IIC_SendByte(0x8D); // 电荷泵使能 IIC_SendByte(0x14); IIC_SendByte(0x20); // 设置内存寻址模式为页寻址 IIC_SendByte(0x02); IIC_SendByte(0xA1); // 段重映射(列地址127映射到SEG0) IIC_SendByte(0xC0); // 扫描方向正常 IIC_SendByte(0xDA); // COM引脚配置 IIC_SendByte(0x12); IIC_SendByte(0x81); // 对比度设置 IIC_SendByte(0xCF); IIC_SendByte(0xD9); // 预充电周期 IIC_SendByte(0xF1); IIC_SendByte(0xDB); // VCOMH 电平 IIC_SendByte(0x40); IIC_SendByte(0xA6); // 正常显示(非反显) IIC_SendByte(0xA4); // 显示全部点亮关闭 IIC_SendByte(0xAF); // 开启显示 IIC_Stop(); }一个容易被忽略的坑:0x8D命令设置电荷泵,在SH1106上如果设成禁止(0x10),屏幕不会亮,只会显示非常淡的残影。在SSD1306上同样如此。很多屏幕不亮的案例最后追查到这个命令上。对比度0xCF在高亮度环境下可能太亮,可以视情况调整,这个值不是固定的,根据你的供电电压和现场看感光环境来改。
3.4 寻址模式与绘图的底层逻辑
这块屏幕内置了64KB的GRAM,分8页,每页有128个字节空间,对应屏幕上一整行的8个像素点(一列一字节)。以页寻址模式为例,你写数据的时候,写入地址由两个维度决定:当前页地址(0~7)和当前列地址(0~127)。每写完一个字节,列地址自动加1,加满128后不自动跳页,而是回到当前页的0列。
理解这个结构是写驱动的基础,因为你的像素操作本质上是在操作一个和屏幕像素一一对应的内存映射区。画一个点时,你需要知道这个点落在第几页、该页的第几位(BIT0~BIT7对应屏幕上的第0行到第7行),然后把这个点的位与对应页的字节数据进行“或”操作,最后整页刷新到屏幕上。
void OLED_SetPixel(uint8_t x, uint8_t y, uint8_t flag) { uint8_t page, bit_mask; uint8_t old_data; if (x >= 128 || y >= 64) return; page = y / 8; bit_mask = 1 << (y % 8); // 读取当前页该列的数据(在本地缓存中操作) old_data = display_buffer[page][x]; if (flag) { display_buffer[page][x] = old_data | bit_mask; } else { display_buffer[page][x] = old_data & ~bit_mask; } }这里我引入了一个显示缓冲区display_buffer,在RAM里维护一份完整的显存拷贝。所有绘图操作先修改缓冲区,然后通过一次全量刷新把缓冲区内容写入OLED的GRAM。这个做法的好处是:避免频繁的页地址切换和列地址设置,减少IIC通信次数,同时避免在整屏刷新过程中出现闪烁。
如果RAM吃紧,也可以选择去掉缓冲区直接写屏幕,但那样画图效率会降低不少,每次改一个点都要发一遍地址命令,性能差距是数量级的。
4. 显示功能的实现与扩展
4.1 字符与字符串显示的实现
字符显示是所有显示功能的基础。得益于显存缓冲区方案,字符显示的底层逻辑变成了:把字模数组中的每个BIT映射到display_buffer对应的像素位置上。
英文字符一般用8x16或者6x12的点阵。我使用8x16规格,汉字用16x16规格。字模提取工具把字体转换成C语言数组,每个字符占用16个字节(8x16)或者32个字节(16x16)。
字符显示的代码结构如下:
void OLED_ShowChar(uint8_t x, uint8_t y, uint8_t ch, uint8_t size) { uint8_t i, j; uint8_t char_temp; if (x > 128 - size / 2 || y > 64 - size) return; // 根据字体大小选择字模数据来源 for (j = 0; j < size / 2; j++) { if (size == 16) { char_temp = F8x16_Table[ch - ' '][j]; } else { char_temp = F6x12_Table[ch - ' '][j]; } for (i = 0; i < size / 2; i++) { if (char_temp & (0x80 >> i)) { OLED_SetPixel(x + i, y + j, 1); } else { OLED_SetPixel(x + i, y + j, 0); } } } }当然这只是字模写入缓冲区的那一步,缓冲区修改完了还要调用OLED_RefreshBuffer把缓冲区的数据整体发给屏幕。有个细节可以留意:如果你想做光标闪烁效果,也可以在缓冲区上创建一个低优先级的“光标层”,每次刷新前把光标位置对应的数据或操作到缓冲区里。
字符串显示则是以空格为结束符,循环调用字符显示,同时根据size和坐标做换行处理。这里有一个容易踩的坑:中英混排时width的计算逻辑。我最终实现的是:ASCII字符占half宽度,中文按全角计算,这样排版才不会乱。
4.2 图片显示的实现
图片显示的输入是取模软件生成的数据:通常是横向取模还是竖向取模,需要跟你的绘图代码匹配起来。我用的是BMP图片取模,竖直扫描取模方式,代码里直接读取const数组并按页写入缓冲区。
void OLED_ShowImage(uint8_t x, uint8_t y, uint8_t width, uint8_t height, const uint8_t *image_data) { uint8_t i, j, k; uint16_t img_index = 0; for (j = 0; j < height / 8; j++) { // 页循环 for (i = 0; i < width; i++) { // 列循环 for (k = 0; k < 8; k++) { // 位循环 if (image_data[img_index] & (1 << k)) { OLED_SetPixel(x + i, y + j * 8 + k, 1); } else { OLED_SetPixel(x + i, y + j * 8 + k, 0); } } img_index++; } } }图像数据量比较大,一个128x64的图片全屏显示需要1KB的Flash空间。const修饰符在这里非常重要,一方面是把数据放在Flash区不从RAM拷贝,另一方面是防止代码意外修改数组内容。另外要注意取模时的方向设置:如果取模时选择横向取模,代码里的位映射方式就得反过来写。
4.3 动态数据的实时刷新策略
在项目上我遇到这样一个应用场景:不断采集传感器的电压值,并把数值实时刷新到OLED上显示。避不开的问题是快速刷新时数字重影或者闪烁。这是因为旧数字没有被擦除,新数字叠加上去,就会出现视觉上的“鬼影”。
我采用的方案是在显示新数字之前,先用背景色填充该数字所占的区域(清除旧内容),再绘制新数字。CPU对缓冲区的改写速度快,一次性写完再刷新,就不会有闪烁了。实现上类似这样:
void OLED_ClearArea(uint8_t x, uint8_t y, uint8_t width, uint8_t height) { uint8_t i, j; for (j = 0; j < height; j++) { for (i = 0; i < width; i++) { OLED_SetPixel(x + i, y + j, 0); } } }清屏和显示必须成对使用:清屏函数操作缓冲区,显示函数也操作缓冲区,最后统一调用一次刷新。如果你清了缓冲区又忘了刷新,屏幕上的旧内容当然不会消失;反过来,如果你直接改GRAM而不经过缓冲区,则会出现局部数据错乱。这些问题在调试时很容易让人怀疑是IIC通信出了问题,实际往往只是缓冲区同步的问题。
4.4 显示界面上的菜单与光标导航
做到这一步,驱动已经可以承载实用的交互界面。我又实现了一个极简的菜单框架:菜单项以字符串数组保存在Flash里,通过一个全局索引来控制当前光标位置。按键改变索引,然后调用菜单重绘函数。重绘时先把整个菜单区域清空,再把当前光标光标闪烁显示在对应菜单项前面。
实现核心是明白渲染的过程本质是“清空旧画面、绘制新画面、刷新”。屏幕刷新本身也就一二十毫秒的时间,如果不要求动画效果,不用做什么复杂的重绘优化。
5. 实测效果与性能记录
代码写完接好线,上电实测的显示效果和预期基本一致。这里记录一些数据供参考。
- 静态显示时,整屏刷新一次所需时间:调用OLED_RefreshBuffer函数,发送1KB数据,在软件IIC约200kHz时钟下,实测耗时在45ms左右。这个速度对普通状态显示绰绰有余。
- 若需要局部刷新,只更新变化的区域,耗时可以压缩到10ms以内。这个优化在做动态数值显示时尤其有价值。
- 字符显示层面,显示一个8x16字符的耗时几乎可以忽略不计,瓶颈全在刷新环节。
实际测试过程中我用逻辑分析仪抓了IIC波形,确认应答位正常,数据帧格式正确。看波形还有一个很重要的好处:可以检查时序是否满足IIC协议的最小保持时间。虽然软件模拟IIC不像硬件外设那么严格,但如果SCL高电平时长过短(小于设备手册要求),从机可能采样不到SDA上的正确数据。
性能数据如下表所示:
| 操作内容 | 发送字节数 | 实测耗时 | 备注 |
|---|---|---|---|
| 设置像素(单点) | 约8字节 | 约1ms | 主流耗时在IIC通信 |
| 整屏图片刷新 | 约1024字节 | 45ms | 适合静态画面 |
| 局部区域刷新(64x16) | 约128字节 | 6ms | 适合动态数据更新 |
| 显示字符串(16字符) | 约200字节 | 约10ms | 含字符映射计算 |
整体看来,单色OLED在STM32上的驱动负担非常小,即使软件模拟IIC也能轻松跑到人眼感知不到的刷新延迟。如果想进一步提高刷新率,有三个方向:提高IIC速率(把延时缩小到1us)、改为硬件I2C+DMA(直接把缓冲区通过DMA搬运,完全不用CPU介入)、或者直接改用SPI接口(如果屏幕硬件支持)。
6. 常见问题与排查技巧
6.1 屏幕完全不亮
这是出现频率最高的问题。先别急着调代码,首先用万用表确认供电电压:1.3寸OLED一般支持3.3V或5V供电,但必须确认模块上有没有稳压芯片。我的模块是3.3V的,接到5V上虽然没有烧坏,但部分模块可能会损坏。然后是确认SCL/SDA有没有接反,这两个接反了的表现往往是完全没反应。再测一下上拉电阻是否正常,很多卖家模块是内部不带上拉的。
软件方面,依次排查设备地址是否写对、控制字节是否有发送、电荷泵是否开启(0x8D 0x14)。这三个是最容易出错的点。如果你的地址是0x7A(SA0接高),记得把OLED_ADDR_WRITE改成0x7A,我在一些示例代码里见到过有人把地址写死成0x78,换了模块就黑屏的情况。
注意:排查问题时最好把初始化中0xAF(开显示)提到最前面临时试一下,如果开显示后能看到淡淡的影子,说明命令通路是好的,问题大概率在电荷泵或者对比度设置。
6.2 显示错位、乱码、字符重复
显示乱码通常是控制字节和数据字节没有严格区分导致的。检查每一帧数据是不是在命令模式下只发命令、在数据模式下只发数据。有人图省事把命令和数据混在同一帧里发,这在页寻址模式下非常容易错乱。
字符显示乱码还可能是因为字模数据与取模方式不匹配。例如字模是行扫描方式生成(从上到下逐行取8个点),而代码但是按列扫描去读,就会得到完全错位的图形。我建议统一使用列行式扫描(每列从上到下取8个点,再右移一列),和OLED的GRAM结构最吻合。
6.3 只显示半屏或者下半屏是黑的
这个问题常见原因有两个:一是初始化时多路复用比设置错误,0xA8命令的参数应该是0x3F(64路),如果设成0x1F就只有32行显示;二是页地址范围设置不对,在页寻址模式下如果需要连续写完所有页,必须在写下一页前重新发送页地址命令,不能指望页地址自动递增。
我调试时遇到过一次下半屏完全无显示的情况,最后发现是初始化里漏了0xA8这条命令。屏幕默认的上电值是异常值(有些芯片是0x1F),所以屏幕只亮了上半部分,下半部分全黑。这类问题用逻辑分析仪看不出波形问题,必须回查命令序列。
6.4 屏幕上有残留影像或者关屏后还有影子
这个和电荷泵关闭顺序以及对比度设置有关。如果直接发0xAE命令关屏,部分屏幕会有短暂残影,正常的做法是先发0x8D 0x10(关闭电荷泵),再发0xAE(关闭显示),但要注意关闭电荷泵会让屏幕彻底掉电,你要确认后续打开时重新使能电荷泵。
另外,如果调高对比度(0x81设置的值),关屏后的残影也会更明显。在低功耗应用场景中,屏幕睡眠模式比单纯的黑屏更省电,可以查阅数据手册中的Sleep Mode指令。
6.5 画面整体偏移几个像素
如前所述,SH1106的GRAM宽度是132像素,屏幕显示区域从第0列到第127列,多出的4列需要通过段重映射和偏移命令来对齐。如果你设置段重映射为0xA0(镜像),画面会左右翻转。0xA1是正常的非镜像模式。如果画面右移了4列,请检查初始化里是否包含了列偏移修正序列。
我实测过一种情况:初始化序列里没有设置列地址偏移时,SH1106能显示但最右侧会露出一条竖线,左侧缺失一条竖线的内容。加上偏移命令后恢复正常。某些模组出厂时已经在硬件上处理了偏移,这时候再手动加偏移反而会错乱,所以如果问题不是这种表现,不要盲目改动。
7. 迁移与扩展:驱动的可移植性设计
驱动的可移植性是我比较看重的一点。拿到一个新的STM32型号或者换用其他牌子的MCU(比如GD32、N32等),理论上只需要改引脚映射宏定义,核心协议层和应用层代码不需要改动。
引脚分配方面,建议选用两个空闲的GPIO,避开JTAG/SWD复用脚、避开I2C专用引脚,如果选用硬件I2C外设引脚,还需要考虑上拉配置。我用的是PC6和PC7,这两个引脚在F103上属于普通IO,不与其他功能冲突,方便单独操作。
代码层面需要特别注意的地方是延时函数的实现。在F103上我用的是简单的循环延时,但如果换到更高主频的芯片(比如F4系列168MHz),原来的循环延时时间会显著缩短,IIC时序可能会不合格。最好的做法是改用定时器延时或者SysTick延时,确保时序的绝对时间与主频无关。
typedef struct { void (*init)(void); void (*display_on)(void); void (*display_off)(void); void (*clear)(void); void (*show_string)(uint8_t x, uint8_t y, const char *str, uint8_t size); void (*show_image)(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t *img); void (*show_pixel)(uint8_t x, uint8_t y, uint8_t color); void (*refresh)(void); } OLED_Driver;封装成这样一个驱动结构体之后,上层应用代码只需要持有这个结构体指针,调用其中的函数指针即可。如果想要接入GUI框架(比如LittlevGL/LVGL),只需要在底层实现画点函数和刷新函数,剩下的交给GUI库处理。
有朋友问我为什么不用现成的开源OLED库,我的看法是:如果你只是调通一个例程,直接用开源库确实快;但如果你是做项目,需要深度定制(比如常用的字库裁剪、自定义图标、局部刷新优化),自己掌控驱动代码反而更靠谱。所谓“黑盒”,只有在一切顺利时才黑,出问题时你能做的只有解刨它。
8. 一些实实在在的体会
驱动这个1.3寸OLED的过程,回头来看其实没有多复杂的技术难点,真正的收获是让我把IIC协议、显存映射、底层绘图这几块知识串成了一个完整的体系。以前只是会用别人的库,知道调用OLED_ShowString能显示字符串,但不知道为什么它能显示,以及为什么有时候不能显示。
实际调试中最有用的工具是逻辑分析仪,价格不贵,十几块钱的就能用。抓到波形后,对照数据手册一条条看,基本没有排查不出来的通信问题。比对着代码发呆高效很多。
还有一个细节值得说一下:如果你发现屏幕在低温环境下显示变淡、响应迟钝,不要怀疑驱动代码写错了,物理属性的变化。适当提高对比度可以缓解,但如果户外环境恶劣,建议换用带温度补偿设计的显示方案。
这套驱动做出来后,我在另一个项目里又把同样的代码迁移到了一块0.96寸的OLED上,因为0.96寸的驱动IC也是SSD1306/SH1106兼容方案,改动量很小。从此手里有了一个万能驱动框架,遇到同类型的屏幕都不慌了。如果你正在折腾类似的东西,希望这篇记录能帮你省下一些时间。