1. 项目缘起与核心思路拆解
手头攒了一堆7针的SPI OLED模块,SSD1306或SH1106驱动芯片的那种,0.96寸、1.3寸都有。同时手上又有一块MCU的硬件I2C引脚空着,SPI外设要么被Flash占了,要么引脚被其他功能复用了。这时候就会冒出一个很自然的想法:能不能把SPI OLED当I2C用?
先说结论:可以,但有前提。7针SPI OLED比标准的4针I2C OLED多了什么?多出来的针脚恰恰给了我们“改嫁”的可能。标准I2C OLED只有VCC、GND、SCL、SDA四根线,而7针SPI OLED通常的引脚定义是:VCC、GND、D0(SCLK)、D1(MOSI)、RES、DC、CS。多出来的RES、DC、CS三根控制线,正是SPI协议和I2C协议之间的关键差异所在。
为什么会有这个需求?我总结了几种典型场景。第一种是MCU的SPI外设资源紧张,比如STM32F103只有两个SPI,一个接了Flash,另一个接了无线模块,OLED就没地方插了,但I2C还空着。第二种是PCB已经画好了I2C的走线,结果采购回来的屏是SPI版本,硬件不想改板。第三种纯粹是手头只有SPI屏,想快速验证I2C驱动逻辑。不管是哪种情况,核心诉求都是一样的:用I2C的时序去驱动一颗本质上只认SPI的显示控制器。
这里必须把原理讲透。SSD1306这颗控制器其实是个“双协议”芯片,它内部同时集成了SPI接口逻辑和I2C接口逻辑,具体走哪条路,由芯片上电时某些引脚的电平状态决定。对于SPI模式的模块,BS0、BS1、BS2这些协议选择引脚在PCB上已经被硬件拉到了SPI对应的电平。也就是说,你拿到手的SPI OLED模块,它的控制器已经被配置成SPI模式了,不会因为你外部接法变了就自动切到I2C。
那为什么还能“当I2C用”?因为有一种操作叫软件模拟I2C,本质上是用GPIO翻转来产生I2C的时序波形,但目标芯片并不需要真正识别I2C协议——我们只是借用I2C的物理连接方式(两根线),实际发送的仍然是SPI能识别的数据格式。等等,这里容易绕晕,我换个说法。
真正可行的方案是:把SPI OLED的D0和D1当作两根普通GPIO,用软件模拟SPI时序来驱动它,但物理接口上只引出类似I2C的四根线(VCC、GND、SCL、SDA),把RES、DC、CS在模块端或转接板上固定电平。这样从外部看,你接的就是一个I2C接口的OLED,但实际上跑的是SPI协议。这个方案的本质是“接口形态的伪装”,而不是“协议的转换”。
还有一种更硬核的做法:如果MCU支持I2C,且你愿意在中间加一颗协议转换芯片(比如用一颗小MCU做桥接),把I2C命令翻译成SPI时序转发给OLED。但这增加了成本和复杂度,一般DIY场景不推荐。
所以这个项目的核心思路可以归纳为:利用SPI OLED模块上多余的控制引脚,通过硬件电平配置和软件时序模拟,将其改造成仅需两根信号线即可驱动的显示模块,从而适配I2C接口资源。下面我把整个实现过程拆开来讲,从硬件改造到软件驱动,每一步都给出可复现的操作。
2. 硬件层面的改造与引脚处理
2.1 7针SPI OLED的引脚再认识
先把手头模块的引脚搞清楚。常见的7针SPI OLED模块,丝印一般是这样的:
| 引脚编号 | 丝印 | 功能说明 | 在I2C化改造中的角色 |
|---|---|---|---|
| 1 | GND | 电源地 | 保持不变 |
| 2 | VCC | 电源正,通常3.3V | 保持不变 |
| 3 | D0 | SPI时钟线SCLK | 接MCU的SCL(模拟) |
| 4 | D1 | SPI数据线MOSI | 接MCU的SDA(模拟) |
| 5 | RES | 复位,低电平有效 | 需固定为高电平或由GPIO控制 |
| 6 | DC | 数据/命令选择 | 需固定为低或由GPIO控制 |
| 7 | CS | 片选,低电平有效 | 需固定为低电平 |
有些模块的引脚顺序可能是D0、D1、RES、DC、CS这样排列,但功能定义是一致的。你拿到模块后第一件事就是用万用表蜂鸣档确认每个引脚到底连到控制器的哪个pad,尤其是CS和DC,有些模块板上已经加了上拉或下拉电阻,这会影响你的改造方案。
2.2 关键控制引脚的电平配置
RES、DC、CS这三根线是SPI协议特有的,I2C协议里没有对应的概念。改造的核心就是让这三根线处于“默认有效”的状态,使得控制器认为SPI通信随时可以进行。
CS片选:SPI协议里CS低电平有效,所以要把CS直接接地。这样控制器始终处于被选中状态,不需要MCU额外控制。但要注意,有些模块的CS引脚内部有弱上拉,你接地后电流会略微增加,不过通常只有几十微安,可以忽略。
DC数据/命令选择:这个引脚决定了D1上传输的是命令还是数据。在SPI驱动里,我们通常用GPIO控制DC,发命令时拉低,发数据时拉高。但在I2C化改造中,我们只有两根信号线,没法单独控制DC。怎么办?有两种思路。
第一种思路是牺牲DC的灵活性,把DC固定为某个电平,然后通过数据内容来区分命令和数据。但SSD1306并不支持这种模式,DC是硬件级别的区分,没法绕过。所以这个思路行不通。
第二种思路是把DC也接到MCU的一个GPIO上,这样虽然物理接口上多了一根线,但相比完整的SPI(4根信号线)还是少了一根。实际上,很多所谓的“I2C OLED”模块内部也是用DC来区分命令和数据的,只是模块厂把DC处理好了。如果你能接受多一根DC线,那改造就简单很多:D0接SCL,D1接SDA,DC接一个GPIO,RES接一个GPIO或直接接VCC,CS接地。这样你用的其实是“三线SPI”模式,而不是真正的I2C。
但标题说的是“做I2C使用”,我理解用户想要的是只用两根信号线。那DC怎么办?答案是:用I2C协议里的“控制字节”来模拟DC的功能。具体来说,SSD1306的I2C接口协议中,每个数据传输都以一个控制字节开头,这个字节的bit6(Co)和bit7(D/C#)就是用来区分后续是命令还是数据的。也就是说,如果你能让SSD1306工作在I2C模式,它自己就会根据控制字节来切换命令和数据,根本不需要外部DC引脚。
问题绕回来了:SPI模式的模块,BS0/BS1/BS2已经被硬件配置成SPI了,怎么让它认I2C的控制字节?答案是改硬件。SSD1306的数据手册里明确写了,BS0、BS1、BS2三个引脚的电平组合决定了通信模式。I2C模式对应的组合通常是BS0=0、BS1=1、BS2=0(具体要看模块的PCB走线)。你需要找到模块PCB上这三个引脚对应的电阻或跳线,把它们改成I2C模式对应的电平。
这一步是整个改造中最关键也最需要动手能力的地方。很多模块的BS引脚是通过0欧姆电阻或直接走线连接的,你需要用热风枪或烙铁把电阻移掉,然后飞线到正确的电平。如果模块用的是QFN封装的SSD1306,引脚间距很小,操作难度不小。我建议先用放大镜确认BS引脚的位置,再决定是否值得改。
2.3 硬件改造的替代方案
如果你不想动PCB上的BS引脚,还有一个折中方案:用一颗小MCU做协议桥接。比如用一颗STM32F030或CH32V003,一边用I2C从模式接收主控发来的数据,另一边用SPI主模式转发给OLED。这样OLED模块完全不用改,主控端也只需要两根线。缺点是增加了BOM成本和PCB面积,但对于批量产品来说,有时候比改屏的硬件更可控。
还有一种更取巧的办法:买一个SPI转I2C的转接板。市面上有一些通用的转接模块,本质上就是一颗小MCU固化了转换固件。你把它插在SPI OLED和主控之间,主控端看到的就是一个I2C设备。这种方案适合不想折腾硬件的朋友,但灵活性和成本需要自己权衡。
我个人在实际操作中,对于0.96寸这种小屏,更倾向于直接改BS引脚,因为模块便宜,改坏了也不心疼。对于1.3寸或更大的屏,我会先评估PCB的走线难度,如果BS引脚引出来了就改,没引出来就考虑桥接方案。
3. 软件驱动的实现与关键代码解析
3.1 I2C模式下的SSD1306通信协议
假设你已经把模块的BS引脚改成了I2C模式,接下来就是软件驱动。SSD1306的I2C协议其实很简单,每次传输由起始条件开始,然后是从机地址(通常是0x3C或0x3D,取决于SA0引脚),接着是控制字节,最后是数据字节。
控制字节的格式是这样的:
| Bit 7 | Bit 6 | Bit 5-0 |
|---|---|---|
| D/C# | Co | 0 |
D/C#为0表示后续是命令,为1表示后续是数据。Co为0表示后续只有数据字节,没有新的控制字节;Co为1表示后续还有控制字节。通常我们发命令时用0x00,发数据时用0x40。
举个例子,要发送“设置对比度”命令(0x81)和参数(0xCF),I2C时序是:
- 起始条件
- 从机地址+写(0x78)
- 控制字节0x00(命令)
- 命令字节0x81
- 控制字节0x00(命令)
- 参数字节0xCF
- 停止条件
如果要连续写显示数据,可以这样:
- 起始条件
- 从机地址+写(0x78)
- 控制字节0x40(数据)
- 数据字节0x00到0xFF(连续多个)
- 停止条件
理解了这套协议,驱动代码就很好写了。下面我用STM32 HAL库的硬件I2C来演示,如果你用的是软件模拟I2C,把HAL_I2C_Master_Transmit换成自己的GPIO翻转函数即可。
3.2 初始化序列的移植要点
SPI OLED的初始化序列和I2C OLED的初始化序列在命令层面是完全一样的,因为都是对SSD1306发命令。区别只在于传输层。所以你可以直接拿一份SPI的初始化代码,把底层的写命令和写数据函数换成I2C版本。
下面是一段典型的初始化代码,我把它改成了I2C版本:
#define OLED_ADDR 0x78 // 0x3C左移一位 void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 100); } void OLED_WriteData(uint8_t data) { uint8_t buf[2] = {0x40, data}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 100); } void OLED_Init(void) { HAL_Delay(100); OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0xD5); // 设置时钟分频 OLED_WriteCmd(0x80); OLED_WriteCmd(0xA8); // 设置多路复用率 OLED_WriteCmd(0x3F); 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); // 开启显示 }这段代码和SPI版本的唯一区别就是底层的传输函数。如果你之前用的是SPI,现在只需要把OLED_WriteCmd和OLED_WriteData里的SPI发送替换成I2C发送,初始化序列一个字都不用改。
3.3 显存刷新效率的优化
I2C的速率通常比SPI慢,标准模式100kHz,快速模式400kHz,高速模式才能到3.4MHz。而SPI随便跑个10MHz都很轻松。所以用I2C驱动OLED时,全屏刷新的耗时会更长。0.96寸OLED的分辨率是128x64,总共1024字节显存。在400kHz的I2C下,传输1024字节加上控制字节和地址开销,大约需要25ms左右。如果每帧都全屏刷新,帧率大概40fps,对于显示文字和简单图形够用,但做动画就会卡。
优化思路有几个。第一,只刷新变化区域,不要每次全屏刷。SSD1306支持页寻址模式,你可以只更新有变化的页。第二,提高I2C速率,如果MCU和OLED都支持,把I2C时钟配到400kHz甚至1MHz。第三,用DMA传输,把CPU解放出来,但要注意DMA传输期间不能修改显存缓冲区。
我实测下来,在STM32F103上,硬件I2C跑400kHz,全屏刷新一帧大约28ms,显示传感器数据完全够用。如果你用的是软件模拟I2C,速率会受GPIO翻转速度限制,通常只能跑到100kHz左右,全屏刷新要100ms以上,这时候就必须做局部刷新了。
4. 常见问题排查与避坑经验
4.1 屏幕完全不亮的排查流程
改完硬件、烧完代码,屏幕不亮是最常见的问题。我一般按以下顺序排查:
| 排查步骤 | 检查内容 | 可能原因 | 解决方法 |
|---|---|---|---|
| 1 | 电源电压 | VCC是否3.3V,GND是否共地 | 用万用表测量,确保供电正常 |
| 2 | I2C地址 | 从机地址是否正确 | 用I2C扫描程序确认地址 |
| 3 | BS引脚 | 是否真的改成了I2C模式 | 对照数据手册测量BS0/BS1/BS2电平 |
| 4 | 上拉电阻 | SCL/SDA是否有上拉 | I2C需要上拉,通常4.7k到10k |
| 5 | 复位时序 | RES是否给了足够的低电平脉冲 | 确保上电后RES拉低至少3us再拉高 |
| 6 | 电荷泵 | 初始化里是否开启了电荷泵 | 检查0x8D命令后是否跟了0x14 |
其中最容易忽略的是上拉电阻。SPI模式下,SCL和MOSI是推挽输出,不需要上拉。但I2C是开漏输出,必须有上拉电阻才能产生高电平。如果你直接从SPI接口改过来,MCU端的引脚配置也要从推挽改成开漏,并且外部加上拉电阻。我见过有人忘了加上拉,结果波形一直是低电平,屏幕自然不亮。
另一个坑是BS引脚改错了。SSD1306的BS引脚组合有好几种,不同封装的默认状态可能不同。有些模块的BS引脚内部有下拉,你飞线拉高后可能被内部下拉拉回来。这时候需要用万用表确认实际电平,必要时割断PCB走线。
4.2 显示花屏或部分区域不显示
花屏通常和显存寻址模式有关。SSD1306支持三种寻址模式:页寻址、水平寻址、垂直寻址。SPI版本的初始化代码里可能用的是页寻址,而I2C版本如果你改成了水平寻址,但刷新函数还是按页来写,就会错位。
我的建议是统一用水平寻址模式,这样显存地址是连续的,刷新函数写起来简单。初始化时发0x20、0x00,然后设置列地址范围0x21、0x00、0x7F,页地址范围0x22、0x00、0x07。之后每次写数据,地址会自动递增,不需要手动设置页和列。
如果你发现屏幕只有上半部分显示,下半部分是雪花,大概率是多路复用率设置不对。0.96寸屏通常是64行,0x3F;1.3寸屏也是64行,但有些模块是32行,对应0x1F。这个参数要和屏幕实际分辨率匹配。
4.3 I2C通信不稳定的处理
I2C通信不稳定表现为偶尔丢数据、屏幕闪烁、或者初始化失败。常见原因有三个。
第一是上拉电阻阻值不合适。阻值太大,上升沿变缓,高速通信时容易误判;阻值太小,功耗增加,有些MCU的I2C引脚驱动能力不足。一般4.7k是折中值,如果通信距离较长(超过20cm),可以降到2.2k。
第二是电源噪声。OLED的电荷泵工作时会产生高频噪声,如果电源滤波不好,会干扰I2C信号。建议在OLED的VCC和GND之间加一个0.1uF和一个10uF的电容,越靠近模块越好。
第三是软件模拟I2C的延时不够。如果你用GPIO模拟I2C,SCL高电平和低电平的持续时间要满足I2C协议的最小要求。标准模式100kHz下,高低电平各至少4.7us。很多人的延时函数写得太短,导致从机来不及响应。我通常会在SCL翻转后加2us左右的延时,实测下来很稳。
4.4 改造后的性能对比与适用场景
最后说一下这个方案的性能边界。我用同一块0.96寸OLED,分别用原生SPI和改造后的I2C做了对比测试:
| 指标 | 原生SPI(10MHz) | 改造I2C(400kHz) | 改造I2C(100kHz) |
|---|---|---|---|
| 全屏刷新耗时 | 约1.2ms | 约28ms | 约110ms |
| 显示文字流畅度 | 极流畅 | 流畅 | 略有卡顿 |
| 动画帧率 | 60fps+ | 约35fps | 约9fps |
| 引脚占用 | 4根信号线 | 2根信号线 | 2根信号线 |
| 硬件改造难度 | 无 | 中等 | 中等 |
从表里可以看出,改造后的I2C方案在引脚占用上有明显优势,但刷新速率下降了一个数量级。所以这个方案适合显示内容变化不频繁的场景,比如传感器数据展示、菜单界面、状态指示。如果你要做游戏或视频播放,还是老老实实用SPI。
我个人在实际项目中的选择逻辑是:如果MCU的SPI资源够用,优先用SPI,省事且性能好;如果SPI被占满而I2C空闲,且显示内容以静态文字为主,那就改I2C;如果两者都紧张,考虑用桥接芯片或者换一颗IO更多的MCU。改造本身不难,难的是判断值不值得改。