4. I2C总线通信:从协议到驱动SSD1306实战
I2C总线,说实话,是嵌入式开发里最常用的串行通信之一。两根线,搞定一堆外设。但很多新手上来就踩坑——要么设备不应答,要么数据乱飞。这一章,我带你从协议细节到实际驱动,把SSD1306 OLED屏幕点亮。
4.1 I2C协议核心要点
I2C只有两条线:SCL(时钟)和SDA(数据)。所有设备挂在同一总线上,靠地址区分。主设备发起通信,从设备响应。
关键概念速查表
| 术语 | 说明 |
|---|---|
| 起始条件 | SCL高电平时,SDA从高变低 |
| 停止条件 | SCL高电平时,SDA从低变高 |
| 应答位(ACK) | 接收方在第9个时钟拉低SDA |
| 非应答位(NACK) | 接收方在第9个时钟释放SDA |
| 7位地址 | 标准模式,可挂127个设备 |
| 时钟拉伸 | 从设备拉低SCL,要求主设备等待 |
我刚开始学I2C时,总觉得时序图看着复杂。其实说白了,就是主设备控制时钟,在时钟的每个周期里搬运一个bit数据。数据在SCL低电平时变化,高电平时采样。嗯,记住这个原则,后面写代码就顺了。
4.2 ESP32的I2C主从模式配置
ESP-IDF里,I2C驱动封装得挺完善。主模式和从模式都支持,但咱们最常用的是主模式——去读传感器、写屏幕。
4.2.1 主模式配置步骤
- 初始化I2C控制器:选择I2C_NUM_0或I2C_NUM_1
- 配置引脚:指定SCL和SDA的GPIO
- 设置时钟频率:标准100kHz,快速400kHz
- 安装驱动:分配收发缓冲区
- 开始通信:发送起始条件、地址、数据、停止条件
我的习惯:调试阶段先用100kHz,稳定后再提频。我曾经在400kHz下遇到信号反射问题,降频到100kHz就正常了。别小看这个,很多干扰问题都是频率太高引起的。
4.2.2 代码示例:初始化I2C主模式
#include "driver/i2c.h"
#define I2C_MASTER_SCL_IO 22 // GPIO22
#define I2C_MASTER_SDA_IO 21 // GPIO21
#define I2C_MASTER_NUM I2C_NUM_0
#define I2C_MASTER_FREQ_HZ 100000 // 100kHz
#define I2C_MASTER_TX_BUF 0 // 主模式不需要缓冲区
#define I2C_MASTER_RX_BUF 0
void i2c_master_init(void)
{
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = I2C_MASTER_SDA_IO,
.scl_io_num = I2C_MASTER_SCL_IO,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = I2C_MASTER_FREQ_HZ,
};
i2c_param_config(I2C_MASTER_NUM, &conf);
i2c_driver_install(I2C_MASTER_NUM, conf.mode,
I2C_MASTER_TX_BUF, I2C_MASTER_RX_BUF, 0);
}
注意看,我开了内部上拉。但实际项目中,如果总线长度超过10cm,我建议外部加4.7kΩ上拉电阻。内部上拉太弱,长线时波形会变差。
4.2.3 从模式配置(了解即可)
从模式用得少,但ESP32也能做从设备。配置时设置自己的从机地址,然后等待主设备来读写。我做过一个项目,用ESP32从模式接收另一个MCU的数据,省掉了一颗UART芯片。不过说实话,大部分场景下ESP32都是当主设备用。
4.3 驱动SSD1306 OLED屏幕
SSD1306是128x64像素的OLED驱动芯片,支持I2C和SPI接口。咱们用I2C版本,地址通常是0x3C(写)或0x3D(读)。
4.3.1 SSD1306初始化流程
驱动这块屏幕,核心就两步:初始化配置 + 刷新显存。初始化时发一串命令,告诉芯片怎么工作。之后往显存写数据,芯片自动刷新到屏幕上。
SSD1306关键命令
| 命令 | 值 | 作用 |
|---|---|---|
| 设置显示关闭 | 0xAE | 初始化前关闭显示 |
| 设置显示开启 | 0xAF | 完成配置后开启 |
| 设置对比度 | 0x81 + 值 | 调节亮度,范围0~255 |
| 设置段重映射 | 0xA1 | 左右镜像,看PCB布局 |
| 设置COM扫描方向 | 0xC8 | 上下镜像 |
| 设置显示偏移 | 0xD3 + 偏移 | 垂直滚动偏移 |
| 设置时钟分频 | 0xD5 + 值 | 调节刷新率 |
4.3.2 写命令与写数据的区别
I2C通信时,SSD1306通过控制字节区分命令和数据:
- 写命令:控制字节 = 0x00,后面跟命令字节
- 写数据:控制字节 = 0x40,后面跟数据字节
这个细节容易搞混。我记得第一次调SSD1306时,屏幕死活不亮,查了半天发现是控制字节写反了。你想想看,命令和数据走同一个地址,全靠这个控制字节区分,错了就全乱套。
4.3.3 完整驱动代码
#define SSD1306_ADDR 0x3C
#define SSD1306_CTRL_CMD 0x00
#define SSD1306_CTRL_DATA 0x40
// 写一个命令
void ssd1306_write_cmd(uint8_t cmd)
{
i2c_cmd_handle_t cmd_handle = i2c_cmd_link_create();
i2c_master_start(cmd_handle);
i2c_master_write_byte(cmd_handle, SSD1306_ADDR << 1 | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd_handle, SSD1306_CTRL_CMD, true);
i2c_master_write_byte(cmd_handle, cmd, true);
i2c_master_stop(cmd_handle);
i2c_master_cmd_begin(I2C_MASTER_NUM, cmd_handle, 1000 / portTICK_PERIOD_MS);
i2c_cmd_link_delete(cmd_handle);
}
// 写数据(连续写多个字节)
void ssd1306_write_data(uint8_t *data, size_t len)
{
i2c_cmd_handle_t cmd_handle = i2c_cmd_link_create();
i2c_master_start(cmd_handle);
i2c_master_write_byte(cmd_handle, SSD1306_ADDR << 1 | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd_handle, SSD1306_CTRL_DATA, true);
i2c_master_write(cmd_handle, data, len, true);
i2c_master_stop(cmd_handle);
i2c_master_cmd_begin(I2C_MASTER_NUM, cmd_handle, 1000 / portTICK_PERIOD_MS);
i2c_cmd_link_delete(cmd_handle);
}
// 初始化SSD1306
void ssd1306_init(void)
{
ssd1306_write_cmd(0xAE); // 关闭显示
ssd1306_write_cmd(0xD5); // 设置时钟分频
ssd1306_write_cmd(0x80); // 默认值
ssd1306_write_cmd(0xA8); // 设置多路复用比
ssd1306_write_cmd(0x3F); // 64行
ssd1306_write_cmd(0xD3); // 设置显示偏移
ssd1306_write_cmd(0x00); // 无偏移
ssd1306_write_cmd(0x40); // 设置显示起始行
ssd1306_write_cmd(0x8D); // 启用电荷泵
ssd1306_write_cmd(0x14); // 开启
ssd1306_write_cmd(0x20); // 设置内存地址模式
ssd1306_write_cmd(0x00); // 水平模式
ssd1306_write_cmd(0xA1); // 段重映射(列0对应SEG127)
ssd1306_write_cmd(0xC8); // COM扫描方向(从下到上)
ssd1306_write_cmd(0xDA); // 设置COM引脚配置
ssd1306_write_cmd(0x12); // 交替模式
ssd1306_write_cmd(0x81); // 设置对比度
ssd1306_write_cmd(0xCF); // 中等亮度
ssd1306_write_cmd(0xD9); // 设置预充电周期
ssd1306_write_cmd(0xF1); // 默认值
ssd1306_write_cmd(0xDB); // 设置VCOMH电压
ssd1306_write_cmd(0x40); // 默认值
ssd1306_write_cmd(0xA4); // 全局显示开启(不忽略RAM)
ssd1306_write_cmd(0xA6); // 正常显示(非反色)
ssd1306_write_cmd(0xAF); // 开启显示
}
注意:初始化顺序不能乱。特别是电荷泵命令(0x8D + 0x14)必须在显示开启之前。我曾经把顺序搞反了,屏幕一直黑着,还以为是硬件坏了。
4.3.4 刷新显存
SSD1306内部有128x64 bit的显存,对应1024字节。写满这1024字节,屏幕就显示完整画面了。我一般维护一个本地缓冲区,修改完再整块刷过去。
uint8_t framebuffer[128 * 64 / 8]; // 1024字节
void ssd1306_refresh(void)
{
// 设置列地址范围
ssd1306_write_cmd(0x21);
ssd1306_write_cmd(0); // 起始列
ssd1306_write_cmd(127); // 结束列
// 设置页地址范围
ssd1306_write_cmd(0x22);
ssd1306_write_cmd(0); // 起始页
ssd1306_write_cmd(7); // 结束页(共8页)
// 发送显存数据
ssd1306_write_data(framebuffer, sizeof(framebuffer));
}
4.4 I2C通信故障排查
这部分是我最想分享的。调I2C时,90%的问题都出在下面几个地方:
常见问题与解决
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 设备无应答 | 地址错误、上拉电阻缺失、电源不稳 | 用逻辑分析仪看波形,确认地址位 |
| 数据错位 | 时钟频率太高、总线电容过大 | 降频到50kHz试一下 |
| 偶尔通信失败 | 中断干扰、任务优先级问题 | 加互斥锁,关中断保护 |
| 屏幕闪烁 | 刷新率太低、电荷泵配置不对 | 检查时钟分频寄存器 |
我曾经遇到一个特别诡异的bug:屏幕有时候亮有时候不亮。查了两天,最后发现是I2C总线上挂了另一个设备,地址冲突了。SSD1306的地址是0x3C,那个传感器也是0x3C。嗯,改一个地址就好了。所以,设计电路时一定要确认地址不冲突。
4.5 本章知识体系
下面这张图,把I2C通信到SSD1306驱动的整个链路串起来了。你可以对照着看,哪里不清楚就回头翻。
好了,这一章的内容就到这儿。代码可以直接拿去用,但建议你动手改改参数,比如换个频率、改个地址,看看会出什么现象。实践出真知,踩过的坑才是你自己的经验。