4、I2C总线驱动:I2C协议详解,驱动SSD1306 OLED显示屏

说实话,I2C 总线是我在嵌入式开发中用得最多的通信接口之一。它只需要两根线,就能挂载多个设备,非常灵活。但很多初学者一上来就被它的时序图吓住了——什么起始条件、应答信号、从机地址……其实没那么复杂。

今天我们就拿 SSD1306 OLED 显示屏来实战。你学完这一章,不仅能看懂 I2C 协议,还能自己写驱动把数据显示到屏幕上。

4.1 I2C 总线协议核心要点

I2C 是飞利浦公司发明的同步串行总线。它只有两根线:SCL(时钟线)和 SDA(数据线)。所有设备都挂在这两根线上,通过地址来区分。

核心特点:

  • 主从架构:一个主机(通常是 MCU)控制时钟,多个从机响应
  • 7 位或 10 位地址:7 位地址最多挂 128 个设备
  • 标准模式 100kHz,快速模式 400kHz
  • 开漏输出 + 上拉电阻:线路上是“线与”逻辑

我刚开始用 I2C 时犯过一个低级错误——忘了加上拉电阻。结果 SDA 和 SCL 波形乱七八糟,数据根本读不出来。嗯,这个坑你一定要注意。

4.2 I2C 通信时序详解

I2C 的通信过程可以拆成几个关键步骤。我习惯用“握手-传输-结束”来理解它。

4.2.1 起始条件与停止条件

这两个条件由主机控制,是通信的开始和结束标志。

条件 SDA 状态 SCL 状态 说明
起始条件 高 → 低 高 SCL 高电平时,SDA 从高跳变到低
停止条件 低 → 高 高 SCL 高电平时,SDA 从低跳变到高

说白了,起始条件就是 SDA 在 SCL 高电平时“抢跑”拉低。停止条件则是“收手”拉高。

4.2.2 数据传输与应答机制

每个字节传输 8 位数据,高位在前。每发完一个字节,接收方要回一个 ACK(应答)或 NACK(非应答)。

  • ACK:接收方在第 9 个时钟周期拉低 SDA,表示“收到,继续发”
  • NACK:接收方在第 9 个时钟周期保持 SDA 高,表示“收完了,别发了”

我在项目中遇到过一个问题:从机没回 ACK,主机还在傻等。后来发现是从机地址写错了。你想想看,地址不对,从机根本不会理你。

4.2.3 完整的 I2C 写操作流程

以向 SSD1306 写数据为例,流程如下:

  1. 主机发送起始条件
  2. 主机发送从机地址(7 位地址 + 写位 0)
  3. 从机回复 ACK
  4. 主机发送控制字节(命令/数据标志)
  5. 从机回复 ACK
  6. 主机发送数据字节
  7. 从机回复 ACK
  8. 重复步骤 6-7 直到数据发完
  9. 主机发送停止条件

小技巧:SSD1306 的 I2C 地址通常是 0x3C(写)或 0x3D(读)。如果你发现屏幕没反应,先拿逻辑分析仪抓一下地址对不对。

4.3 SSD1306 OLED 驱动原理

SSD1306 是一款单芯片 OLED 驱动控制器,支持 128×64 像素分辨率。它内部有一个 1KB 的 GDDRAM(图形显示数据 RAM),每个 bit 对应一个像素点。

4.3.1 显示原理

SSD1306 把 128×64 的像素分成 8 页(Page),每页 8 个像素高。你往 GDDRAM 里写 1,对应的像素就亮;写 0 就灭。

参数 值 说明
分辨率 128 × 64 列 × 行
页数 8 每页 8 行
GDDRAM 大小 1024 字节 128 × 64 / 8
驱动电压 3.3V / 5V 模块通常带稳压

4.3.2 命令与数据区分

SSD1306 通过 I2C 通信时,第二个字节用来区分是命令还是数据:

  • 0x00:后续字节是命令
  • 0x40:后续字节是数据

这个设计很巧妙。你想想看,只用两个值就能区分控制流和数据流,省去了额外的引脚。

4.4 ESP32 驱动 SSD1306 实战

下面我带你手写一个完整的驱动。ESP32 的 I2C 外设很强大,支持硬件自动生成起始条件、应答检测等。

4.4.1 硬件连接

SSD1306 模块通常有 4 个引脚:

SSD1306 引脚 ESP32 引脚 说明
VCC 3.3V 供电
GND GND 共地
SCL GPIO 22 时钟线
SDA GPIO 21 数据线

注意:有些模块默认 I2C 地址是 0x3C,但可以通过背面电阻跳线改成 0x3D。买模块时最好确认一下。

4.4.2 初始化 I2C 外设

ESP-IDF 提供了 i2c_master 驱动 API。初始化代码如下:

#include "driver/i2c.h"

#define I2C_MASTER_SCL_IO    22
#define I2C_MASTER_SDA_IO    21
#define I2C_MASTER_NUM       I2C_NUM_0
#define I2C_MASTER_FREQ_HZ   400000

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, 0, 0, 0);
}

这里我用了 400kHz 的快速模式。如果你发现通信不稳定,可以降到 100kHz 试试。

4.4.3 写命令与写数据函数

封装两个基础函数,一个发命令,一个发数据:

#define SSD1306_ADDR  0x3C  // 7位地址左移1位后是0x78

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, 0x00, 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) {
    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, 0x40, true);  // 数据标志
    i2c_master_write_byte(cmd_handle, data, 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);
}

你看,核心就是 i2c_master_start、i2c_master_write_byte、i2c_master_stop 这三个 API。ESP32 的硬件 I2C 帮你处理了所有时序细节。

4.4.4 初始化 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);  // 段重映射(左右翻转)
    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);  // 全局显示开启
    ssd1306_write_cmd(0xA6);  // 正常显示(非反色)
    ssd1306_write_cmd(0xAF);  // 开启显示
}

这段代码我用了很多项目,基本是“万能初始化”。唯一需要调整的是对比度(0x81 后面的值),不同批次模块可能略有差异。

4.4.5 清屏与画点

清屏就是把 GDDRAM 全部写 0:

void ssd1306_clear(void) {
    for (int page = 0; page < 8; page++) {
        ssd1306_write_cmd(0xB0 + page);  // 设置页地址
        ssd1306_write_cmd(0x00);          // 设置列地址低4位
        ssd1306_write_cmd(0x10);          // 设置列地址高4位
        for (int col = 0; col < 128; col++) {
            ssd1306_write_data(0x00);
        }
    }
}

画点稍微复杂一点,因为 SSD1306 是按页组织的。你需要先计算像素所在的页和位:

void ssd1306_draw_pixel(uint8_t x, uint8_t y, uint8_t color) {
    if (x >= 128 || y >= 64) return;
    uint8_t page = y / 8;
    uint8_t bit = y % 8;
    ssd1306_write_cmd(0xB0 + page);
    ssd1306_write_cmd(0x00 + (x & 0x0F));
    ssd1306_write_cmd(0x10 + (x >> 4));
    // 这里需要先读后写,但为了简化,我们直接写
    // 实际项目中可以用局部缓存
    uint8_t data = 1 << bit;
    if (color) {
        ssd1306_write_data(data);
    } else {
        ssd1306_write_data(0x00);
    }
}

优化建议:频繁调用 ssd1306_write_data 效率很低。我一般会在内存里维护一个 1024 字节的帧缓冲,修改完一次性刷到屏幕上。

4.5 I2C 通信流程图

下面这张图展示了 ESP32 通过 I2C 驱动 SSD1306 的完整流程:

ESP32 I2C 驱动 SSD1306 流程图 ESP32 I2C 驱动 SSD1306 流程图 ESP32 主机 初始化 I2C 外设 发送起始条件 发送从机地址 (0x3C) + 写位 发送命令/数据标志 (0x00/0x40) 发送数据字节 注意 每个字节后 等待从机 ACK 无 ACK 则重试 发送停止条件

4.6 常见问题与避坑指南

驱动 SSD1306 时,我遇到过不少坑。这里列几个最常见的:

坑 1:屏幕不亮

  • 检查供电:3.3V 模块接 5V 会烧,5V 模块接 3.3V 不亮
  • 检查 I2C 地址:用 i2c_scanner 例程扫描一下
  • 检查初始化序列:漏了电荷泵开启命令(0x8D 0x14)屏幕就是黑的

坑 2:显示花屏或错位

  • 内存地址模式设置不对:建议用水平模式(0x20 0x00)
  • 列地址计算错误:SSD1306 的列地址是 0~127,不是 0~63
  • 页地址和列地址的发送顺序不能乱:先页地址,再列地址低4位,再列地址高4位

调试技巧:我习惯在代码里加一个 ssd1306_test() 函数,让所有像素交替亮灭。如果这个能跑通,说明硬件和初始化没问题,问题出在绘图逻辑上。

4.7 小结

这一章我们深入剖析了 I2C 总线协议,从起始条件到应答机制,再到完整的读写流程。然后以 SSD1306 OLED 为例,手写了完整的驱动代码。

核心要点就三个:

  • I2C 协议:两根线、主从架构、起始/停止条件、ACK 机制
  • SSD1306 驱动:命令/数据区分、页地址模式、GDDRAM 操作
  • ESP32 实现:i2c_master API、帧缓冲优化、初始化序列

说实话,I2C 驱动 OLED 是嵌入式开发里非常经典的案例。你把这个吃透了,以后驱动其他 I2C 传感器(比如温湿度、加速度计)都会轻松很多。