玩89C52单片机玩到数码管已经满足不了我的时候,我买了一块0.96寸OLED。这东西显示信息量比数码管大得多,体积还小,最关键的是IIC接口只需要两根信号线,对板子上GPIO所剩无几的情况特别友好。我折腾了两天才彻底点亮,中间踩了白屏、镜像、程序卡死不少坑。这篇文章把我最终用89C52通过IIC接口驱动0.96寸OLED的完整过程整理出来,包括原理、接线、初始化序列、完整代码和排障思路,送给手里有STC89C52这类51单片机、想快速跑通OLED的读者。
1. 拿到模块先认清SSD1306:接线与电平处理
1.1 0.96寸OLED模块的两种形态
市面上0.96寸OLED模块虽然长得很像,但接口分成两大类:一种是四针的纯IIC模块,丝印只有VCC、GND、SCL、SDA;另一种是七针模块,丝印有VCC、GND、SCL、SDA、RES、DC、CS,这种模块可以通过背部电阻配置成SPI或者IIC模式。你买的时候如果卖家没特别说明,大概率是四针版本,默认就是IIC接口,这也是写这篇教程的前提。
不管是四针还是七针,屏幕背后的驱动芯片几乎都是SSD1306。少数老款会用SH1106,两者指令大部分兼容,但页地址和显示RAM的映射方式有差异。如果你用的是SH1106,初始化序列需要做一些调整。怎么确认?看模块背面IC丝印,写着SSD1306的就是标准款,网上资料也最多,新手买屏认准这个型号最省心。
1.2 接线表和电平适配方案
我的接线方式很简单,单片机用的STC89C52RC,晶振12MHz,OLED模块是四针IIC。接线表如下:
| OLED引脚 | 接到89C52 | 说明 |
|---|---|---|
| VCC | 5V(或3.3V) | 模块板载稳压时可直接接5V |
| GND | GND | 必须共地 |
| SCL | P1.0 | 时钟线,示例代码用P1.0 |
| SDA | P1.1 | 数据线,示例代码用P1.1 |
这里有个新手最容易问的问题:OLED模块标注的是3.3V供电,89C52是5V单片机,能不能直接接?
大多数0.96寸OLED模块板载了稳压电路,VCC接5V没问题,而且SCL/SDA内部也做了电平转换,直接用5V单片机的IO口驱动是可以的。但并不是所有模块都这样设计,尤其是那种超薄小板,标注3.3V就真的只支持3.3V。稳妥的做法是:SCL和SDA各串一个1k到4.7k的电阻再接到单片机IO,这样即使模块没有电平转换,也不会烧引脚。我实测过串4.7k电阻后IIC通信依然稳定,12MHz晶振下时序完全没问题。
另外IIC总线需要上拉电阻,这个上拉一般是模块上自带的。如果你的OLED模块是“裸屏”或者卖家明确说没有上拉,那就要自己在SCL、SDA上各接一个4.7k到10k的电阻到VCC,否则通信时序会漂,表现就是屏幕偶尔亮偶尔不亮、内容错乱。这个细节我排障时遇到过,后面再细说。
2. 89C52上软件模拟IIC的时序逻辑
2.1 为什么选择软件模拟
STC89C52这个芯片本身没有硬件IIC外设,AT89C52也没有,所以想驱动IIC接口的OLED,只能用普通IO口软件模拟时序。可能有人会问,那STC12、STC15这些有硬件IIC的单片机是不是更方便?确实硬件IIC不占CPU,但对新手来说,软件模拟的好处是逻辑透明、任意两个IO都能用,而且SSD1306的IIC时序要求并不苛刻,89C52工作在12MHz时完全能跑。
软件模拟IIC的核心就是按照IIC协议,用程序控制SCL和SDA两个引脚的电平变化。很多人一听到要写时序就紧张,其实IIC时序只有三个动作:起始、停止、传数据。搞懂这三个动作,整个驱动就通了一半。
2.2 起始、停止和数据位的时序细节
IIC总线的起始信号是:SCL保持高电平,SDA从高电平跳变到低电平。停止信号是:SCL保持高电平,SDA从低电平跳变到高电平。注意这两个信号都是在SCL为高时发生的,这是IIC协议最核心的一个约束。
传输数据时,SDA上的数据必须在SCL低电平期间改变,SCL高电平期间保持稳定。也就是说,SCL上升沿把SDA上的电平“锁存”进从机。每传输一个字节是8个bit,高位先发,然后第9个时钟是应答位,从机拉低SDA表示“我收到了”。这个时序用C语言写出来非常直观:
void OLED_I2C_Start(void) { OLED_SCL = 1; OLED_SDA = 1; OLED_SDA = 0; OLED_SCL = 0; } void OLED_I2C_Stop(void) { OLED_SCL = 0; OLED_SDA = 0; OLED_SCL = 1; OLED_SDA = 1; }发送一个字节时,每次先看最高位是0还是1,然后把SDA置成对应电平,再拉高SCL产生一个上升沿,最后拉低SCL准备下一个bit。
void OLED_I2C_SendByte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { if (dat & 0x80) OLED_SDA = 1; else OLED_SDA = 0; dat <<= 1; OLED_SCL = 1; OLED_SCL = 0; } // 第9个时钟:应答位,这里不读取,只是给一个完整时钟 OLED_SCL = 1; OLED_SCL = 0; }这段代码里有一句很多人会忽略的话:第9个时钟。如果只是发送完8个bit就结束,从机根本没有机会回应,后续通信容易出问题。虽然不检测ACK也能工作,但我建议还是把第9个时钟补上,让协议完整。后面我会专门讲不补这个时钟可能导致的“卡死”现象。
2.3 设备地址0x78与应答机制
IIC总线上每个设备都有一个地址,SSD1306的7位地址默认是0x3C,左移一位变成8位写地址就是0x78。为什么默认是0x3C?因为芯片的SA0引脚被模块设计成了接地,如果SA0接高电平,地址就变成0x3D,写地址对应0x7A。因此:
| 设备7位地址 | 写地址 | 读地址 | 适用情况 |
|---|---|---|---|
| 0x3C | 0x78 | 0x79 | SA0接地,绝大多数0.96寸模块 |
| 0x3D | 0x7A | 0x7B | SA0接高,部分模块可跳线 |
发送流程是:先发起始信号,再发0x78,然后发控制字节,最后发真正的命令或数据。控制字节0x00代表后面跟的是命令,0x40代表后面跟的是显示数据,这两者的区别在SSD1306里非常重要,写错了屏幕不会有反应。
3. SSD1306为何能点阵显示:显存、页地址与初始化命令
3.1 128x64显存怎么按页组织
SSD1306内部有一块128x64的显存RAM,对应屏幕上128列、64行的像素点。每1个bit对应一个像素,1为点亮,0为熄灭。因为驱动芯片不能像单片机访问外部RAM那样直接按地址读写这块显存,它把64行平分成了8个页(Page),每页占8行,从第0页到第7页。
具体来说,第0页对应屏幕的第0到第7行,第1页对应第8到第15行,依此类推。每一页里有128个字节,每个字节的8个bit正好对应一列在垂直方向上的8个像素。也就是说,写显存的时候不是按“行”写,而是按“页”来写的。这跟数码管段选很像:给一个字节,同时控制了一列里的8个点。
SSD1306支持三种内存寻址模式,由0x20命令设置。最常用的是水平寻址模式(0x00),在这种模式下,你连续写数据,列地址会自动加1,写到第127列后自动跳到下一页的第0列继续。这样刷一整屏只需要连续写1024个字节,效率最高。页寻址模式(0x10)则是固定当前页,列加到127后回到0但页不变,适合只改某一行显示内容的场景。显示字符串时我习惯用页寻址,定位方便。
3.2 初始化序列逐条解析
网上流传的SSD1306初始化代码很多,但很多人直接抄过来不知道每条命令干嘛,出了问题也不会改。我把我用的初始化序列列出来,每条命令都解释清楚:
| 命令 | 参数 | 作用 |
|---|---|---|
| 0xAE | 无 | 关闭显示,开始配置前先关屏 |
| 0xD5 | 0x80 | 设置显示时钟分频/振荡频率,0x80是推荐值 |
| 0xA8 | 0x3F | 设置多路复用比,0x3F表示1/64占空比,匹配64行 |
| 0xD3 | 0x00 | 显示偏移为0,画面不会整体上下移位 |
| 0x40 | 无 | 显示起始行是第0行 |
| 0x8D | 0x14 | 开启电荷泵,这是很多白屏问题的根源 |
| 0x20 | 0x00 | 设置水平寻址模式 |
| 0xA1 | 无 | 段重映射,屏幕左右不镜像 |
| 0xC8 | 无 | COM扫描方向反向,屏幕上下不镜像 |
| 0xDA | 0x12 | COM引脚硬件配置,兼容常见模块的默认接法 |
| 0x81 | 0xCF | 设置对比度,0xCF亮度适中 |
| 0xD9 | 0xF1 | 设置预充电周期,0xF1是常规值 |
| 0xDB | 0x40 | 设置VCOMH电压,0x40比较稳定 |
| 0xA4 | 无 | 输出跟随RAM内容,而不是强制全亮 |
| 0xA6 | 无 | 正常显示模式,不是反色模式 |
| 0xAF | 无 | 打开显示 |
这里特别说下0x8D命令。SSD1306内部有电荷泵,用来产生驱动OLED面板所需的高压,如果电荷泵没打开,屏幕就一直是白屏或者黑屏,程序怎么刷都没用。很多人的代码把0x8D和0x14漏掉了,或者写成了0x8D 0x10,都会导致显示不亮。
另外0xA1和0xC8这两条命令决定屏幕是否镜像。0xA1是段重映射,可以让左右反向;0xC8是COM扫描方向,可以让上下反向。如果你发现字符是镜像的,问题往往不在字库,而是这里配置反了。
3.3 坐标定位命令:0xB0加0x10/0x00
要在指定位置显示内容,必须先告诉SSD1306“接下来往哪个地址写数据”。坐标定位需要两个信息:页地址和列地址。
页地址由命令0xB0到0xB7表示,0xB0对应第0页,0xB1对应第1页,依次类推。列地址是0到127,需要发两条命令:高四位和低四位。比如要定位到第32列,需要先发0x10 | (32 >> 4),也就是0x12,再发0x00 | (32 & 0x0F),也就是0x00。如果是第127列,就是0x17和0x0F。
这个先高四位后低四位的顺序不能反,很多花屏错位问题都是这里搞错了。我封装了一个简单的坐标定位函数:
void OLED_SetPos(unsigned char x, unsigned char y) { OLED_WrCmd(0xB0 + y); // 页地址,y是0~7 OLED_WrCmd(((x & 0xF0) >> 4) | 0x10); // 列高四位 OLED_WrCmd(x & 0x0F); // 列低四位 }注意这个函数里的x是以列为单位的,y是页号而不是像素行号。如果你直觉上把y当成“第几行像素”,显示位置就会对不上。
4. 完整代码实现与关键函数解析
4.1 底层IIC时序函数
底层IIC函数是整个驱动的地基,前面已经解释过原理,这里直接给出完整代码。因为89C52速度不快,IO翻转之间的延时控制在几百纳秒到几微秒都行,我用几个空操作就够了。
#include <reg52.h> #include <intrins.h> #define OLED_ADDR_W 0x78 sbit OLED_SCL = P1^0; sbit OLED_SDA = P1^1; static void OLED_I2C_Delay(void) { _nop_(); _nop_(); _nop_(); } void OLED_I2C_Start(void) { OLED_SCL = 1; OLED_SDA = 1; OLED_I2C_Delay(); OLED_SDA = 0; OLED_I2C_Delay(); OLED_SCL = 0; } void OLED_I2C_Stop(void) { OLED_SCL = 0; OLED_SDA = 0; OLED_I2C_Delay(); OLED_SCL = 1; OLED_I2C_Delay(); OLED_SDA = 1; } void OLED_I2C_SendByte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { if (dat & 0x80) OLED_SDA = 1; else OLED_SDA = 0; dat <<= 1; OLED_I2C_Delay(); OLED_SCL = 1; OLED_I2C_Delay(); OLED_SCL = 0; } OLED_SCL = 1; // 第9个时钟 OLED_I2C_Delay(); OLED_SCL = 0; }这版代码我没有读取ACK,而是直接产生第9个时钟。实测SSD1306对这个时序是接受的。如果要严格检测ACK,需要在第9个时钟的高电平期间去读SDA引脚,但51的IO口在读取外部信号前要先写1,而且如果接线有误SDA悬空,读回来的电平不稳定,反而容易让程序卡死。新手阶段不检测ACK是最稳的做法。
4.2 命令写入与数据写入封装
命令和数据的写入流程非常像,区别只在控制字节。命令用0x00,数据用0x40。每写一个字节都要走一遍“起始-发地址-发控制字-发内容-停止”的完整流程。
void OLED_WrCmd(unsigned char cmd) { OLED_I2C_Start(); OLED_I2C_SendByte(OLED_ADDR_W); OLED_I2C_SendByte(0x00); OLED_I2C_SendByte(cmd); OLED_I2C_Stop(); } void OLED_WrDat(unsigned char dat) { OLED_I2C_Start(); OLED_I2C_SendByte(OLED_ADDR_W); OLED_I2C_SendByte(0x40); OLED_I2C_SendByte(dat); OLED_I2C_Stop(); }这套封装一次只写一个字节,优点是逻辑清晰、不容易出错,缺点是刷整屏要频繁地发起始和停止,效率不高。进阶优化时可以改成“起始一次,连续发多个数据字节,最后停止”,也就是把OLED_WrDat拆成“启动传输-循环发送-停止”三个步骤。不过对89C52来说,就算一次一字节,全屏刷新时间也只有几十毫秒,做菜单和数据显示完全够用,新手不需要一上来就追求效率。
初始化函数按3.2节的命令序列写出来:
void OLED_Init(void) { OLED_WrCmd(0xAE); OLED_WrCmd(0xD5); OLED_WrCmd(0x80); OLED_WrCmd(0xA8); OLED_WrCmd(0x3F); OLED_WrCmd(0xD3); OLED_WrCmd(0x00); OLED_WrCmd(0x40); OLED_WrCmd(0x8D); OLED_WrCmd(0x14); OLED_WrCmd(0x20); OLED_WrCmd(0x00); OLED_WrCmd(0xA1); OLED_WrCmd(0xC8); OLED_WrCmd(0xDA); OLED_WrCmd(0x12); OLED_WrCmd(0x81); OLED_WrCmd(0xCF); OLED_WrCmd(0xD9); OLED_WrCmd(0xF1); OLED_WrCmd(0xDB); OLED_WrCmd(0x40); OLED_WrCmd(0xA4); OLED_WrCmd(0xA6); OLED_WrCmd(0xAF); }清屏的本质就是把显存全部写成0。注意SSD1306不支持读取显存,所以清屏不需要读,只写就行:
void OLED_Clear(void) { unsigned char i, j; for (i = 0; i < 8; i++) { OLED_WrCmd(0xB0 + i); OLED_WrCmd(0x10); OLED_WrCmd(0x00); for (j = 0; j < 128; j++) { OLED_WrDat(0x00); } } }4.3 字符显示与ASCII字模的摆放方式
SSD1306一个字节控制一列里的8个像素,那显示一个8x16字符就变成了一个“上半页加下半页”的组合:上半页8个字节控制字符顶部8行,下半页8个字节控制字符底部8行。
我这里给一个按字符索引映射的示例字库,覆盖“0.96 OLED TEST”需要用到的字符。为了文章篇幅清晰,我用了switch映射而不是完整ASCII数组。完整工程里的标准做法是准备0x20到0x7F的全套字模,每个字符16字节,按ASCII顺序排列,显示时用“(chr-0x20)*16”算偏移量。
const unsigned char code OLED_Font_O[16] = { 0x7E,0x81,0x81,0x81,0x81,0x81,0x81,0x7E, 0x7E,0x81,0x81,0x81,0x81,0x81,0x81,0x7E }; const unsigned char code OLED_Font_L[16] = { 0xFF,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0xFF,0x01,0x01,0x01,0x01,0x01,0x01,0x01 }; const unsigned char code OLED_Font_E[16] = { 0xFF,0x81,0x81,0x81,0x81,0x81,0x81,0x81, 0xFF,0x01,0x01,0x01,0x01,0x01,0x01,0x01 }; const unsigned char code OLED_Font_D[16] = { 0xFF,0x81,0x81,0x81,0x81,0x81,0x81,0xFF, 0xFF,0x81,0x81,0x81,0x81,0x81,0x81,0xFF }; const unsigned char code* OLED_GetFontPtr(unsigned char chr) { switch (chr) { case 'O': return OLED_Font_O; case 'L': return OLED_Font_L; case 'E': return OLED_Font_E; case 'D': return OLED_Font_D; default: return 0; } } void OLED_ShowChar(unsigned char x, unsigned char y, unsigned char chr) { unsigned char i; const unsigned char code* p = OLED_GetFontPtr(chr); if (p == 0) return; OLED_SetPos(x, y); for (i = 0; i < 8; i++) { OLED_WrDat(p[i]); } OLED_SetPos(x, y + 1); for (i = 0; i < 8; i++) { OLED_WrDat(p[i + 8]); } }显示字符串就是一个循环调用,连续显示时记得每个字符之间留8列间距,否则字符会粘连在一起。
void OLED_ShowString(unsigned char x, unsigned char y, char* str) { while (*str != '\0') { OLED_ShowChar(x, y, *str++); x += 8; if (x > 120) break; } }这个简化版字库是为了让示例代码能跑通而保留的最小字符集。真正做项目时,用PCtoLCD2002工具把0x20到0x7F全部生成一遍,放到一个code数组里,显示函数再改成按ASCII偏移索引,你会得到一个完整的字符串显示能力。
4.4 16x16汉字显示:从“中”字看取模原理
汉字的显示原理和ASCII一样,只是点阵从8x16变成了16x16。一个16x16汉字需要32字节,结构是16行,每行2字节,每字节对应水平8个像素。下面这个简化的“中”字字模演示了这个结构:
const unsigned char code OLED_Font_Zhong[32] = { 0xFF,0xFF, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0x81,0x81, 0xFF,0xFF };显示时同样要按页拆分。一个16x16汉字占两页:上半页写出前16字节,下半页写出后16字节。
void OLED_ShowChinese(unsigned char x, unsigned char y, const unsigned char code* font) { unsigned char i; OLED_SetPos(x, y); for (i = 0; i < 16; i++) { OLED_WrDat(font[i]); } OLED_SetPos(x, y + 1); for (i = 0; i < 16; i++) { OLED_WrDat(font[i + 16]); } }这里有个很容易踩的细节:用取模软件生成16x16汉字字模时,一定要设置成“逐行式取模”或“横向取模”,方向上通常选择“从左到右、从上到下”,高位在前。如果你用的是“列行式”,那生成出来的字节排列会和上面的显示函数不一致,显示出来就是乱码。我在4.6节会说具体取模设置。
4.5 主函数完整流程:点亮并显示“OLED TEST!”
主函数思路很简单:初始化、清屏、显示字符串,然后做一个从屏幕左侧往右侧生长的进度条效果,顺便验证底层写显存的逻辑。
void delay_ms(unsigned int ms) { unsigned int i, j; for (i = 0; i < ms; i++) for (j = 0; j < 120; j++); } void main(void) { unsigned char i; OLED_Init(); OLED_Clear(); OLED_ShowString(8, 0, "OLED TEST"); OLED_ShowString(16, 2, "0.96"); // 进度条:在页6上从左到右写0xFF for (i = 0; i < 16; i++) { OLED_SetPos(i * 8, 6); OLED_WrDat(0xFF); OLED_WrDat(0xFF); OLED_WrDat(0xFF); OLED_WrDat(0xFF); OLED_WrDat(0xFF); OLED_WrDat(0xFF); OLED_WrDat(0xFF); OLED_WrDat(0xFF); delay_ms(30); } while (1); }注意我这里OLED_ShowString使用的简化字库只包含了“O、L、E、D”几个字符,如果你想完整显示“OLED TEST”里的T、S和空格,就把这两个字符的字模数据补进自己的字库里。取模工具五秒钟就能生成,比在文章里贴一大堆数组要高效得多。
4.6 字库获取与取模软件设置
很多新手卡在“字库从哪来”这个问题上。市面上常见的工具叫PCtoLCD2002,还有字模助手、Image2Lcd等。用PCtoLCD2002生成字模时,有四个关键选项:
- 取模方式:选择“逐行式”或者“横向取模”。
- 取模走向:选择“从左到右、从上到下”。
- 输出格式:选择“C51格式”。
- 阴码还是阳码:选“阴码”,也就是1表示点亮,0表示熄灭。
生成之后,每个ASCII字符会得到16字节的数组,每两个字符合并到code数组里,按ASCII码顺序排列。中文16x16字模是32字节,可以直接复制到const unsigned char code数组里。取模工具的“预览”功能可以对照点阵确认字符形状,发现有镜像或颠倒,优先检查“取模走向”和“逐行/逐列”选项,而不是乱调整显示函数。
5. 实测排障:白屏、花屏和程序卡死的排查链路
5.1 白屏不是代码问题,先查这三个地方
驱动不亮的故障,十有八九不是程序逻辑问题。我的排查顺序固定是这样:
第一,查供电。用万用表量OLED模块的VCC和GND之间电压,四针IIC模块如果接5V,应该有5V左右;如果是3.3V模块,接5V可能会导致模块上的稳压芯片过热,摸一下就知道。如果模块没有稳压电路却接了5V,屏幕可能直接烧坏,这个只能换新。
第二,查接线顺序。IIC的两根线经常有人接反。SCL和SDA如果对调了,屏幕肯定没反应。而且不同卖家模块的丝印位置不一样,有的SDA在中间,有的在边上,不要想当然按上次那块板的颜色对应,必须一次一次对照丝印。
第三,查IIC地址。代码里写的是0x78(7位地址0x3C),如果模块的SA0被接高,实际地址是0x7A。怎么判断?看模块背部有没有标注“SA0”或者“A0”的跳线,如果有并且默认短接在高位,就把#define OLED_ADDR_W改成0x7A。还有一个小技巧,用逻辑分析仪抓一下SDA上的波形,第一个字节如果是0x78就是默认地址,抓出来是0x7A说明地址不对。
如果以上都没问题,用一个最直接的手段测试通路:只发0xAF命令打开显示,屏幕如果从全黑变成有点亮感,说明IIC通信是通的,问题在初始化序列,特别检查0x8D和0x14电荷泵命令。
5.2 加OLED函数后程序卡死,根因是IIC应答等待
我遇到过一种很诡异的情况:单独调OLED程序没问题,一旦把它加进带中断、带延时的大工程里,程序就跑飞了,表现为主循环里的LED灯不闪、数码管不动,看起来像死机。
排查了半天,最后定位在“中断导致IIC时序被打断”。89C52是单核执行,软件模拟IIC对时序连续性有要求。如果在SCL高电平期间突然来一个串口中断或者定时器中断,中断服务函数里如果操作了P1口或者执行了稍长的代码,就会把SCL高电平时间拉长,甚至改变SDA状态,SSD1306收到错误的时序,后续通信就乱了。
解决方案有两种。第一种,在进入OLED刷新函数前关闭总中断EA,刷新后再打开。第二种,把OLED的SCL/SDA放到中断服务函数绝对不会操作的IO口上,同时确保中断服务函数尽量短。我自己的习惯是给OLED做一个“临界区保护”,尤其是在显示数据量比较大的界面时:
void OLED_DisplayPage(void) { EA = 0; // 执行IIC写显存操作 EA = 1; }另外有些STC89C52在烧录时开启了看门狗。OLED整屏刷新如果耗时超过看门狗溢出时间,程序会在刷新过程中被强制复位,表现就是屏幕刚亮一点又灭、主程序反复重启。这种情况会让步进电机或者蜂鸣器出现周期性动作,非常像卡死。确认方法很简单:把看门狗关闭重新烧录,如果故障消失,就是这个原因。
5.3 花屏、乱码与镜像:页地址和扫描方向容易踩坑
花屏乱码有几种典型表现,排查方向完全不同。
第一种是显示内容整体错位,字符上半部分和下半部分不在同一列。这种一般是坐标定位函数的问题,尤其是列地址高低四位顺序反了,或者OLED_ShowChar里两次OLED_SetPos之间x坐标没保持一致。检查一下是不是在写下半页时把列地址重新计算了,导致上半页和下半页错开。
第二种是内容能显示但是左右镜像,像照镜子一样。这个不是字库问题,是初始化里的0xA1段重映射没配好。如果显示出来是镜像,把0xA1改成0xA0试试;如果上下颠倒,把0xC8改成0xC0。不少卖家给的初始化代码和模块实际装配方向不一致,这块需要自己试一下。
第三种是整个屏幕有“残影”或者“雪花点”。大概率是供电不稳定,OLED的电荷泵对电源纹波比较敏感。可以在VCC和GND之间并一个10uF电解电容和0.1uF陶瓷电容,如果手头有就尽量加上。另外对比度设得太高也容易出现拖影,把0x81后面的参数从0xCF降到0x8F,画面会干净很多。
5.4 把OLED当成调试窗口:滚动显示变量
驱动稳定之后,我强烈建议在调试阶段把OLED当成调试窗口,比串口打印直观得多。比如定时器计数、ADC采样值、状态机当前状态,都可以用OLED直接刷出来。
滚动显示变量的思路是维护一个“行号”计数,每次要打印新内容时,先把整屏向上滚动一行。程序上实现很简单:把第1页到第7页的内容复制到第0页到第6页,然后清空最后一行再写新数据。但SSD1306不支持读显存,所以滚动需要依赖一个整屏RAM缓存,或者你只在固定位置覆盖刷新。固定位置覆盖刷新对51来说最省事:
void OLED_ShowValue(unsigned char x, unsigned char y, unsigned int val) { // 先用空格覆盖旧内容,再显示新内容,避免数字变短时残留 OLED_ShowChar(x, y, ' '); OLED_ShowChar(x + 8, y, ' '); OLED_ShowChar(x + 16, y, ' '); OLED_ShowChar(x, y, '0' + val / 100 % 10); OLED_ShowChar(x + 8, y, '0' + val / 10 % 10); OLED_ShowChar(x + 16, y, '0' + val % 10); }这里有个细节:刷新变量前一定要先清掉旧内容。数字从100变成1时,如果不先把后面的两个0擦掉,屏幕上会显示成001,看起来就很奇怪。这种小问题在调试时最容易让人误判数据对错。
我对这套89C52加0.96寸OLED方案的整体感受是:软件模拟IIC比想象中简单,SSD1306的难点不在协议,而在显存组织方式和初始化命令的理解。把页地址、列地址和字模摆放这三件事理清楚,后面再移植到其他单片机,或者换成SPI接口模块,你都能很快改出来。最后再分享一个小经验:驱动代码里我所有OLED相关的GPIO都集中在宏定义和sbit声明里,换板子时只需要改这两个引脚定义,其他函数一律不动。这个小习惯救了我好多次,强烈推荐你也这样做。