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 主模式配置步骤

  1. 初始化I2C控制器:选择I2C_NUM_0或I2C_NUM_1
  2. 配置引脚:指定SCL和SDA的GPIO
  3. 设置时钟频率:标准100kHz,快速400kHz
  4. 安装驱动:分配收发缓冲区
  5. 开始通信:发送起始条件、地址、数据、停止条件

我的习惯:调试阶段先用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驱动的整个链路串起来了。你可以对照着看,哪里不清楚就回头翻。

I2C驱动SSD1306知识体系 I2C协议基础 主模式配置 从模式配置 故障排查 SSD1306 OLED驱动芯片 初始化序列 命令/数据写入 显存刷新 画点/画图函数 128x64 OLED显示 从I2C协议 → 主从配置 → SSD1306初始化 → 显存刷新 → 显示输出,层层递进

好了,这一章的内容就到这儿。代码可以直接拿去用,但建议你动手改改参数,比如换个频率、改个地址,看看会出什么现象。实践出真知,踩过的坑才是你自己的经验。