4. I2C总线通信:从协议到驱动SSD1306 OLED
I2C总线,在嵌入式世界里太常见了。说白了,它就是一条两根线的串行总线,用来让主控芯片跟各种外设“聊天”。我最早接触I2C是在一个温湿度采集项目里,当时觉得这东西简单,结果一上来就被时序折腾得够呛。嗯,今天咱们就把I2C彻底讲透,再拿SSD1306 OLED屏幕练练手。
4.1 I2C协议核心要点
I2C总线只有两根线:SCL(时钟线)和SDA(数据线)。所有设备都挂在这两根线上,主设备负责产生时钟,从设备响应地址。我习惯把I2C比作一个“电话会议”——主设备先拨号(发送地址),被叫到的从设备应答,然后开始通话(数据传输)。
I2C关键特性一览:
- 两根线:SCL(时钟)+ SDA(数据)
- 多主多从,但同一时刻只有一个主设备控制总线
- 7位或10位地址,标准模式100kHz,快速模式400kHz
- 开漏输出,需要上拉电阻(通常4.7kΩ)
为什么需要上拉电阻?因为I2C总线是“线与”逻辑,任何设备都可以把SDA拉低。没有上拉电阻,总线就浮空了,数据传不了。我在一个项目里就吃过这个亏——忘了焊上拉电阻,OLED死活不亮,查了半天才发现。
4.2 I2C通信时序详解
I2C的时序其实不复杂,但细节容易出错。咱们拆开来看:
- 起始条件(START):SCL高电平时,SDA从高变低
- 发送地址:7位地址 + 读写位(0写1读)
- 从设备应答(ACK):从设备拉低SDA表示收到
- 数据传输:每字节8位,MSB先行,每字节后跟ACK
- 停止条件(STOP):SCL高电平时,SDA从低变高
我的经验:调试I2C时,用逻辑分析仪抓波形是最快的。别光看代码,波形骗不了人。我曾经花了两小时查一个“设备无应答”的问题,结果发现是地址写错了——7位地址左移1位才是I2C地址字节,这个坑很多人踩过。
来看一个典型的I2C写操作时序图:
4.3 扫描I2C设备——实战第一步
拿到一块新板子,怎么知道I2C总线上挂了哪些设备?写个扫描程序,挨个地址发一下,看谁应答。我每次调试新硬件,第一件事就是跑这个扫描,确认设备地址对不对。
ESP32上扫描I2C设备的代码:
#include <Wire.h>
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // SDA=GPIO21, SCL=GPIO22
Serial.println("I2C设备扫描开始...");
byte error, address;
int deviceCount = 0;
for (address = 1; address < 127; address++) {
Wire.beginTransmission(address);
error = Wire.endTransmission();
if (error == 0) {
Serial.print("发现设备,地址: 0x");
if (address < 16) Serial.print("0");
Serial.print(address, HEX);
Serial.println();
deviceCount++;
}
}
if (deviceCount == 0) {
Serial.println("未发现任何I2C设备!");
}
}
void loop() {}
注意:地址0x00到0x07是保留地址,一般不用。扫描时从0x01开始。另外,有些设备地址是7位的,但Wire库要求传入的是左移1位后的8位地址——不过Arduino的Wire库会自动处理,你直接传7位地址就行。
运行这段代码,串口会打印出所有应答的设备地址。SSD1306 OLED的I2C地址通常是0x3C或0x3D,具体看模块的地址选择引脚。
4.4 SSD1306 OLED驱动实战
SSD1306是一款128×64像素的OLED驱动芯片,支持I2C和SPI接口。咱们用I2C方式驱动它。说白了,就是往它的显存里写数据,芯片自己负责刷新屏幕。
驱动SSD1306需要两个步骤:
- 初始化:发送一系列配置命令,设置显示参数
- 刷显存:把像素数据写入芯片的GRAM
SSD1306的显存是128×64位,共1024字节。每个字节对应8个像素点(垂直排列)。我刚开始用的时候,老搞不清像素和字节的对应关系,画图总是歪的。
| 命令 | 值 | 说明 |
|---|---|---|
| 0xAE | 显示关闭 | 初始化前先关显示 |
| 0xD5 | 0x80 | 设置显示时钟分频 |
| 0xA8 | 0x3F | 设置多路复用比(64行) |
| 0xD3 | 0x00 | 显示偏移 |
| 0x40 | — | 显示起始行 |
| 0x8D | 0x14 | 电荷泵使能 |
| 0x20 | 0x00 | 水平寻址模式 |
| 0xA1 | — | 段重映射(左右翻转) |
| 0xC8 | — | COM扫描方向(上下翻转) |
| 0xDA | 0x12 | COM引脚配置 |
| 0x81 | 0xCF | 对比度设置 |
| 0xD9 | 0xF1 | 预充电周期 |
| 0xDB | 0x40 | VCOMH取消选择级别 |
| 0xA4 | — | 全局显示开启 |
| 0xA6 | — | 正常显示(非反相) |
| 0xAF | — | 显示开启 |
初始化代码示例:
void ssd1306_init() {
Wire.beginTransmission(0x3C);
Wire.write(0x00); // 命令模式
Wire.write(0xAE); // 关闭显示
Wire.write(0xD5); Wire.write(0x80);
Wire.write(0xA8); Wire.write(0x3F);
Wire.write(0xD3); Wire.write(0x00);
Wire.write(0x40);
Wire.write(0x8D); Wire.write(0x14);
Wire.write(0x20); Wire.write(0x00);
Wire.write(0xA1);
Wire.write(0xC8);
Wire.write(0xDA); Wire.write(0x12);
Wire.write(0x81); Wire.write(0xCF);
Wire.write(0xD9); Wire.write(0xF1);
Wire.write(0xDB); Wire.write(0x40);
Wire.write(0xA4);
Wire.write(0xA6);
Wire.write(0xAF); // 开启显示
Wire.endTransmission();
}
我的习惯:初始化命令序列我一般写成数组,用循环发送,代码更简洁。另外,每次发送命令前要发0x00(命令模式),发送数据前要发0x40(数据模式),这个很容易忘。
刷显存的函数:
void ssd1306_display(uint8_t *buffer) {
// 设置列地址范围
Wire.beginTransmission(0x3C);
Wire.write(0x00);
Wire.write(0x21); // 设置列地址
Wire.write(0x00); // 起始列
Wire.write(0x7F); // 结束列
Wire.endTransmission();
// 设置页地址范围
Wire.beginTransmission(0x3C);
Wire.write(0x00);
Wire.write(0x22); // 设置页地址
Wire.write(0x00); // 起始页
Wire.write(0x07); // 结束页(共8页)
Wire.endTransmission();
// 发送显存数据
Wire.beginTransmission(0x3C);
Wire.write(0x40); // 数据模式
for (int i = 0; i < 1024; i++) {
Wire.write(buffer[i]);
}
Wire.endTransmission();
}
4.5 完整驱动框架
把上面这些拼起来,就是一个完整的SSD1306驱动。我一般会封装成几个函数:初始化、清屏、画点、显示字符串。下面这张图展示了驱动层的整体结构:
实际项目中,我建议直接用现成的库,比如Adafruit_SSD1306。但理解底层原理很重要——出了问题你能快速定位。有一次我的OLED显示花屏,排查下来是I2C速率太高(400kHz),降到100kHz就好了。如果你不懂底层,这种问题根本无从下手。
避坑指南:
- SSD1306的I2C地址:0x3C(写)/ 0x3D(读),但有些模块是0x3D
- 上拉电阻不能省,4.7kΩ是通用值
- I2C线长不要超过20cm,长了信号会畸变
- ESP32的I2C引脚可以任意指定,但别跟其他外设冲突