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 写数据为例,流程如下:
- 主机发送起始条件
- 主机发送从机地址(7 位地址 + 写位 0)
- 从机回复 ACK
- 主机发送控制字节(命令/数据标志)
- 从机回复 ACK
- 主机发送数据字节
- 从机回复 ACK
- 重复步骤 6-7 直到数据发完
- 主机发送停止条件
小技巧: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 的完整流程:
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 传感器(比如温湿度、加速度计)都会轻松很多。