ESP32-S3 IDF编程第九课:I2C通信与OLED显示 —— 让数据”看得见”

上节课我们用ADC采集了电压值,但只能对着串口调试助手看数字。这节课让数据”跳”到屏幕上——I2C通信 + OLED显示,从此告别”盲调”。

你可能会问:串口打印不也能看数据吗?为什么还要学I2C和OLED?

来,看个真实场景:

  • 你做了个电池电量监测仪,总不能一直插着电脑看串口吧?
  • 你做了个温湿度传感器,想挂在墙上实时显示,没屏幕怎么行?
  • 你做了个小型示波器,要把波形画出来,串口可画不了图

这时候,你就需要I2C通信OLED显示屏——I2C是连接传感器的”标准语言”,OLED是让数据”可视化”的最佳选择。128×64的分辨率,能显示8行文字,功耗还低,简直是嵌入式开发的神器!


一、I2C协议是什么?

1.1 从生活类比理解I2C

想象I2C是一条双向单车道马路

  • SDA(数据线):运送数据的”货车”
  • SCL(时钟线):指挥交通的”红绿灯”
  • 上拉电阻:确保没车的时候路面是”高电平”(空载状态)
  • 设备地址:每个设备的”门牌号”,主机靠这个找到从机

关键特性

  • 只需要2根线(SDA + SCL),省IO口
  • 多设备共享一条总线,每个设备有独立地址
  • 主从架构:主机(ESP32)发起通信,从机(OLED)被动响应

1.2 I2C通信时序

一次完整的I2C通信就像”打电话”:

1
2
3
4
5
主机:喂,是0x3C吗?(发送起始信号 + 设备地址)
OLED:在的!(ACK应答)
主机:我要写数据0xAE(发送数据)
OLED:收到了!(ACK应答)
主机:挂了(发送停止信号)

时序图解

单片机I2C时序介绍_C语言中文网

时序分段解析

1. 起始信号

  • 动作:SCL 保持高电平期间,SDA 由高电平变为低电平。
  • 意义:这标志着一次 I2C 通信的开始,所有从机都会被唤醒并准备接收数据。

2. 数据传输 (bit7 到 bit0)

I2C 协议规定数据是以 字节(8位) 为单位传输的,且 高位先行

  • bit7 ~ bit0

    :图中展示了 8 个数据位。

    • 在 SCL 的每一个时钟脉冲(高电平期间),SDA 线上的电平状态代表当前的数据位是 1 还是 0。
    • 注意:SDA 上的数据必须在 SCL 为高电平时保持稳定,只能在 SCL 为低电平时改变,以确保接收方能正确采样。
  • 中间省略 (3-6):图中虚线框表示 bit5 到 bit2 的传输过程与前后类似,为了简化图表被省略了。

3. 应答信号

  • 动作:在第 9 个时钟脉冲期间,SDA 线被拉低。

  • 意义

    :这是从机发给主机的

    应答信号

    • 低电平:表示从机成功接收到了前面的 8 位数据,并准备好了接收下一个字节。
    • 高电平:如果 SDA 保持高电平(非应答),则表示接收失败或从机忙。

4. 停止信号

  • 动作:SCL 保持高电平期间,SDA 由低电平变为高电平。
  • 意义:这标志着本次 I2C 通信的结束,总线被释放。

MBXY-CR-6ebe2713f9b0207e61fcb0fceaa2ec32

1. 基础信号:通信的”开场”与”收尾”

无论读还是写,都离不开这几个物理信号:

  • 起始位 (Start Condition): SCL 高电平时,SDA 由高变低。这表示”大家注意,我要开始讲话了”。
  • 停止位 (Stop Condition): SCL 高电平时,SDA 由低变高。这表示”通信结束,释放总线”。
  • 应答位 (ACK/NACK): 接收方在第 9 个时钟周期将 SDA 拉低表示 ACK(收到),保持高电平则表示 NACK(没收到或出错)。

2. 写数据流程 (Master Write)

写数据通常用于设置寄存器或发送指令。流程如下:

  1. 发送起始位
  2. 发送设备地址 + 写位 (0):主机发送 7 位从机地址,第 8 位设为 0(代表 Write)。
  3. 接收 ACK:对应的从机发出应答。
  4. 发送寄存器地址:告诉从机你要往哪个”抽屉”里写东西。
  5. 接收 ACK
  6. 发送数据字节:发送你要写入的内容(可以连续发送多个字节)。
  7. 接收 ACK:每发一个字节,从机都要回一个应答。
  8. 发送停止位

3. 读数据流程 (Master Read)

读数据稍微复杂一点,因为你必须先告诉从机”我想读哪个寄存器”,这涉及到一个**”复合格式”**:

  1. 发送起始位
  2. 发送设备地址 + 写位 (0):注意,这里先用写模式
  3. 接收 ACK
  4. 发送寄存器地址:告诉从机你想读哪个位置。
  5. 接收 ACK
  6. 重复起始位 (Restart):不发停止位,直接再次发送起始信号。这是为了切换模式。
  7. 发送设备地址 + 读位 (1):这次第 8 位设为 1(代表 Read)。
  8. 接收 ACK
  9. 读取数据:此时 SDA 的控制权交给从机,主机每读一个字节要回传一个 ACK
  10. 发送 NACK:读到最后一个字节时,主机发送 NACK,告诉从机”够了,别发了”。
  11. 发送停止位

1.3 SSD1306 OLED 简介

SSD1306 是一款单芯片CMOS OLED驱动器,特性:

参数 数值
分辨率 128 × 64 像素
颜色 单色(黑/白)
通信接口 I2C(默认)/ SPI
I2C地址 0x3C(SA0=0)或 0x3D(SA0=1)
显存 128 × 64 = 8192 bit = 1KB

显存结构(页寻址模式)

  • 把64行分成8个”页”(Page),每页8行
  • 每页128列,每列1个字节(8位)
  • 总共 8页 × 128字节 = 1024字节显存
1
2
3
4
5
6
7
        列 0 ~ 127
┌─────────────────┐
页 0 │ 第 0-7 行 │ ← 字节0 ~ 字节127
页 1 │ 第 8-15 行 │ ← 字节128 ~ 字节255
... │ ... │
页 7 │ 第 56-63 行 │ ← 字节896 ~ 字节1023
└─────────────────┘

二、硬件准备

2.1 所需材料

  • ESP32-S3开发板 × 1
  • SSD1306 OLED显示屏(128×64,I2C接口)× 1
  • 杜邦线 × 4

2.2 接线图

1
2
3
4
5
6
7
ESP32-S3          OLED SSD1306
┌─────────┐ ┌──────────┐
│ 3.3V ├──────┤ VCC │
│ GND ├──────┤ GND │
│ GPIO40 ├──────┤ SDA │ ← 数据线
│ GPIO41 ├──────┤ SCL │ ← 时钟线
└─────────┘ └──────────┘

💡 注意

  • SDA和SCL需要上拉电阻(4.7kΩ),但ESP32-S3内部已集成,可以省略
  • 部分OLED模块已经板载了上拉电阻
  • I2C地址通常是0x3C,如果不行试试0x3D

三、SSD1306驱动原理详解

3.1 驱动架构概览

SSD1306驱动分为三层:

1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────────────┐
│ 应用层 (Application) │
│ ssd1306_draw_string(), draw_rect() │
├─────────────────────────────────────┤
│ 绘图层 (Graphics) │
│ oled_buffer[] 显存操作 │
├─────────────────────────────────────┤
│ 硬件层 (Hardware) │
│ I2C通信: ssd1306_command(), data() │
└─────────────────────────────────────┘

3.2 显存缓冲区设计

1
2
// 显存缓冲区: 128列 × 8页 = 1024字节
static uint8_t oled_buffer[OLED_WIDTH * OLED_PAGES];

显存寻址公式

1
2
3
4
5
6
buffer[page * 128 + column] = data;

其中:
- page = y / 8 (0-7, 共8页)
- column = x (0-127, 共128列)
- bit = y % 8 (0-7, 每字节8位对应8行)

发送命令

1
2
3
4
5
6
7
void ssd1306_command(uint8_t cmd) {
// Control Byte: 0x00 = Co=0, D/C#=0 (命令)
// Co=0: 后续字节都是命令
// D/C#=0: 命令模式
uint8_t command_buffer[2] = {0x00, cmd};
i2c_master_transmit(oled_handle, command_buffer, 2, 100);
}

I2C数据包结构

1
2
3
4
[Control Byte: 0x00] [Command Byte 1] [Command Byte 2] ...
│ │
│ └── 实际的命令值
└── 0x00 = 命令模式

发送数据

1
2
3
4
5
6
7
8
9
10
11
void ssd1306_data(uint8_t *data, size_t len) {
uint8_t *buffer = malloc(len + 1);
if (!buffer) return;

// Control Byte: 0x40 = Co=0, D/C#=1 (数据)
buffer[0] = 0x40;
memcpy(buffer + 1, data, len);

i2c_master_transmit(oled_handle, buffer, len + 1, 1000);
free(buffer);
}

数据 vs 命令的区别

  • 命令 (0x00):控制屏幕行为(开关显示、设置对比度等)
  • 数据 (0x40):写入显存,显示内容

3.4 SSD1306初始化序列详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void ssd1306_init() {
// 1. 关闭显示(初始化期间不显示杂讯)
ssd1306_command(0xAE); // Display OFF

// 2. 设置MUX比率(多路复用率)
ssd1306_command(0xA8); // Set MUX Ratio
ssd1306_command(0x3F); // 64MUX (0x3F = 63, 即64行)

// 3. 设置显示偏移
ssd1306_command(0xD3); // Set Display Offset
ssd1306_command(0x00); // 无偏移

// 4. 设置起始行
ssd1306_command(0x40 | 0x00); // Set Start Line = 0

// 5. 使能电荷泵(⚠️ 必须!否则屏幕不亮)
ssd1306_command(0x8D); // Charge Pump Setting
ssd1306_command(0x14); // 0x14 = 使能, 0x10 = 禁用

// 6. 设置内存寻址模式
ssd1306_command(0x20); // Set Memory Addressing Mode
ssd1306_command(0x00); // 0x00 = 水平模式, 0x01 = 垂直模式, 0x02 = 页模式

// 7. 段重映射(左右翻转控制)
ssd1306_command(0xA1); // 0xA0 = 正常, 0xA1 = 反转

// 8. COM扫描方向(上下翻转控制)
ssd1306_command(0xC8); // 0xC0 = 正常(从上到下), 0xC8 = 反转(从下到上)

// 9. 设置COM引脚配置
ssd1306_command(0xDA); // Set COM Pins Hardware Configuration
ssd1306_command(0x12); // 0x12 = 交替COM引脚, 禁用重映射

// 10. 设置对比度(亮度)
ssd1306_command(0x81); // Set Contrast Control
ssd1306_command(0xCF); // 0x00-0xFF, 越大越亮

// 11. 设置预充电周期
ssd1306_command(0xD9); // Set Pre-charge Period
ssd1306_command(0xF1); // Phase 1: 15 DCLKs, Phase 2: 1 DCLK

// 12. 设置VCOMH取消选择电平
ssd1306_command(0xDB); // Set VCOMH Deselect Level
ssd1306_command(0x30); // ~0.83 x VCC

// 13. 打开显示
ssd1306_command(0xAF); // Display ON
}

初始化命令速查表

命令 代码 参数 作用
Display OFF 0xAE - 关闭显示
Set MUX Ratio 0xA8 0x3F 64行复用
Set Display Offset 0xD3 0x00 显示偏移
Set Start Line 0x40n - 起始行
Charge Pump 0x8D 0x14 使能电荷泵
Memory Mode 0x20 0x00 水平寻址
Segment Remap 0xA1 - 段重映射
COM Scan Direction 0xC8 - COM扫描方向
Set COM Pins 0xDA 0x12 COM引脚配置
Set Contrast 0x81 0xCF 对比度
Set Pre-charge 0xD9 0xF1 预充电
Set VCOMH 0xDB 0x30 VCOMH电平
Display ON 0xAF - 打开显示

⚠️ 重要0x8D 0x14(电荷泵使能)是屏幕能亮起来的关键!

3.5 显存刷新机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void ssd1306_refresh() {
// 按页刷新显存
for (int page = 0; page < 8; page++) {
// 1. 设置页地址
ssd1306_command(0xB0 | page); // Page Start Address

// 2. 设置列地址低4位
ssd1306_command(0x00 | 0x00); // Lower Nibble = 0

// 3. 设置列地址高4位
ssd1306_command(0x10 | 0x00); // Higher Nibble = 0

// 4. 发送该页的所有列数据(128字节)
ssd1306_data(&oled_buffer[page * 128], 128);
}
}

刷新原理

  • SSD1306有8页,每页128列
  • 必须先设置页地址和列地址,才能写入数据
  • 一次性发送128字节,比逐字节发送高效得多

3.6 绘图函数实现

画点函数(核心)

1
2
3
4
5
6
7
8
9
10
11
12
void ssd1306_draw_pixel(uint8_t x, uint8_t y) {
// 边界检查
if (x >= 128 || y >= 64) return;

// 计算页号和位位置
uint8_t page = y / 8; // 0-7
uint8_t bit = y % 8; // 0-7

// 设置对应位
// 注意:SSD1306的bit0对应最上面一行
oled_buffer[page * 128 + x] |= (1 << bit);
}

画点原理图解

1
2
3
4
5
6
7
8
9
10
要在坐标 (10, 17) 画点:

y = 17
page = 17 / 8 = 2 (第2页,对应行16-23)
bit = 17 % 8 = 1 (第1位)

显存位置: buffer[2 * 128 + 10] 的第1位

修改前: buffer[266] = 0x00
修改后: buffer[266] = 0x02 (bit1被置1)

字符绘制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void ssd1306_draw_char(uint8_t x, uint8_t y, char c) {
// 查找字符在字库中的索引
const char *pos = strchr(charset, c);
if (!pos) return;
uint8_t char_index = pos - charset;

// 获取字模数据指针
// 每个字符9字节:9列 × 8行
const uint8_t *font_ptr = &font_5x7[char_index * 9];

// 遍历9列
for (int col = 0; col < 9; col++) {
uint8_t screen_x = x + col;
if (screen_x >= 128) break;

uint8_t font_byte = font_ptr[col];

// 遍历8行
for (int row = 0; row < 8; row++) {
// 检查该位是否为1
if (font_byte & (1 << row)) {
uint8_t screen_y = y + row;
if (screen_y >= 64) continue;

// 设置像素
uint8_t page = screen_y / 8;
uint8_t bit = screen_y % 8;
oled_buffer[page * 128 + screen_x] |= (1 << bit);
}
}
}
}

3.7 完整驱动代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "driver/i2c_master.h"
#include <stdint.h>

// --- 配置部分 ---
#define SSD1306_I2C_ADDR 0x3C
#define OLED_WIDTH 128
#define OLED_HEIGHT 64
#define OLED_PAGES (OLED_HEIGHT / 8)

static const char *TAG = "SSD1306";

// 显存缓冲区
static uint8_t oled_buffer[OLED_WIDTH * OLED_PAGES];
static i2c_master_dev_handle_t oled_handle = NULL;

// 字库声明(由 image2display 生成)
extern const uint8_t font_5x7[];
extern const char charset[];

// --- SSD1306 命令定义 ---
#define SSD1306_CMD_DISPLAY_OFF 0xAE
#define SSD1306_CMD_DISPLAY_ON 0xAF
#define SSD1306_CMD_SET_MUX_RATIO 0xA8
#define SSD1306_CMD_SET_DISP_OFFSET 0xD3
#define SSD1306_CMD_SET_START_LINE 0x40
#define SSD1306_CMD_CHARGE_PUMP 0x8D
#define SSD1306_CMD_MEMORY_MODE 0x20
#define SSD1306_CMD_SEG_REMAP 0xA1
#define SSD1306_CMD_COM_SCAN_DEC 0xC8
#define SSD1306_CMD_SET_COM_PINS 0xDA
#define SSD1306_CMD_SET_CONTRAST 0x81
#define SSD1306_CMD_SET_PRECHARGE 0xD9
#define SSD1306_CMD_SET_VCOMH 0xDB
#define SSD1306_CMD_SET_PAGE_ADDR 0xB0
#define SSD1306_CMD_SET_COL_ADDR_LOW 0x00
#define SSD1306_CMD_SET_COL_ADDR_HIGH 0x10

// --- I2C底层通信 ---
void ssd1306_command(uint8_t cmd) {
uint8_t buf[2] = {0x00, cmd}; // Control=0x00 (命令)
i2c_master_transmit(oled_handle, buf, 2, 100);
}

void ssd1306_data(uint8_t *data, size_t len) {
uint8_t *buf = malloc(len + 1);
if (!buf) return;
buf[0] = 0x40; // Control=0x40 (数据)
memcpy(buf + 1, data, len);
i2c_master_transmit(oled_handle, buf, len + 1, 1000);
free(buf);
}

// --- 初始化 ---
void ssd1306_init() {
ESP_LOGI(TAG, "Initializing I2C...");

// 配置I2C总线
i2c_master_bus_config_t bus_config = {
.clk_source = I2C_CLK_SRC_DEFAULT,
.i2c_port = 0,
.sda_io_num = 40,
.scl_io_num = 41,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
i2c_master_bus_handle_t bus_handle = NULL;
ESP_ERROR_CHECK(i2c_new_master_bus(&bus_config, &bus_handle));

// 添加OLED设备
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = SSD1306_I2C_ADDR,
.scl_speed_hz = 400000,
};
ESP_ERROR_CHECK(i2c_master_bus_add_device(bus_handle, &dev_cfg, &oled_handle));

ESP_LOGI(TAG, "Initializing SSD1306...");

// 初始化序列
ssd1306_command(SSD1306_CMD_DISPLAY_OFF);
ssd1306_command(SSD1306_CMD_SET_MUX_RATIO); ssd1306_command(0x3F);
ssd1306_command(SSD1306_CMD_SET_DISP_OFFSET); ssd1306_command(0x00);
ssd1306_command(SSD1306_CMD_SET_START_LINE | 0x00);
ssd1306_command(SSD1306_CMD_CHARGE_PUMP); ssd1306_command(0x14);
ssd1306_command(SSD1306_CMD_MEMORY_MODE); ssd1306_command(0x00);
ssd1306_command(SSD1306_CMD_SEG_REMAP);
ssd1306_command(SSD1306_CMD_COM_SCAN_DEC);
ssd1306_command(SSD1306_CMD_SET_COM_PINS); ssd1306_command(0x12);
ssd1306_command(SSD1306_CMD_SET_CONTRAST); ssd1306_command(0xCF);
ssd1306_command(SSD1306_CMD_SET_PRECHARGE); ssd1306_command(0xF1);
ssd1306_command(SSD1306_CMD_SET_VCOMH); ssd1306_command(0x30);
ssd1306_command(SSD1306_CMD_DISPLAY_ON);

ESP_LOGI(TAG, "SSD1306 Ready");
}

// --- 绘图函数 ---
void ssd1306_clear() {
memset(oled_buffer, 0x00, sizeof(oled_buffer));
}

void ssd1306_refresh() {
for (int page = 0; page < OLED_PAGES; page++) {
ssd1306_command(SSD1306_CMD_SET_PAGE_ADDR | page);
ssd1306_command(SSD1306_CMD_SET_COL_ADDR_LOW | 0x00);
ssd1306_command(SSD1306_CMD_SET_COL_ADDR_HIGH | 0x00);
ssd1306_data(&oled_buffer[page * OLED_WIDTH], OLED_WIDTH);
}
}

void ssd1306_draw_pixel(uint8_t x, uint8_t y) {
if (x >= OLED_WIDTH || y >= OLED_HEIGHT) return;
uint8_t page = y / 8;
uint8_t bit = y % 8;
oled_buffer[page * OLED_WIDTH + x] |= (1 << bit);
}

void ssd1306_draw_char(uint8_t x, uint8_t y, char c) {
const char *pos = strchr(charset, c);
if (!pos) return;
uint8_t idx = pos - charset;
const uint8_t *font = &font_5x7[idx * 9];

for (int col = 0; col < 9; col++) {
if (x + col >= OLED_WIDTH) break;
uint8_t byte = font[col];
for (int row = 0; row < 8; row++) {
if (byte & (1 << row)) {
ssd1306_draw_pixel(x + col, y + row);
}
}
}
}

void ssd1306_draw_string(uint8_t x, uint8_t y, const char *str) {
while (*str && x < OLED_WIDTH) {
ssd1306_draw_char(x, y, *str);
x += 10;
str++;
}
}

// --- 主程序 ---
void app_main(void) {
ssd1306_init();
ssd1306_clear();

ssd1306_draw_string(0, 0, "ESP32-S3");
ssd1306_draw_string(0, 16, "OLED OK!");

ssd1306_refresh();
ESP_LOGI(TAG, "Display Done");
}

四、字库生成与使用

4.1 使用 image2display 生成字库

推荐使用 image2display 生成自定义字库:

配置参数

  • 字体大小:12
  • 宽度:9,高度:8
  • 遍历顺序:如图所示
  • 字符集:选择大小写字母和标点符号

image-20260416001353770

4.2 字库文件修改

生成的 font_data.c 需要修改:

1
2
3
4
#include <stdint.h>  // 添加头文件

const char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()-_+=[]{};:\'\",.<>/?\\|`~";
// 注意转义双引号: \"

4.3 CMakeLists.txt 配置

1
2
3
4
5
idf_component_register(
SRCS "main.c" "font_data.c"
PRIV_REQUIRES spi_flash driver
INCLUDE_DIRS ""
)

五、常见问题与解决

5.1 OLED不亮

检查清单

  1. 接线是否正确(SDA、SCL是否接反)
  2. I2C地址是否正确(0x3C或0x3D)
  3. 是否发送了电荷泵使能命令(0x8D 0x14
  4. 最后是否发送了显示开启命令(0xAF

5.2 显示花屏/乱码

原因:显存数据未正确初始化

解决

1
2
ssd1306_clear();      // 清空显存
ssd1306_refresh(); // 刷新到屏幕

5.3 显示方向不对

解决:修改初始化命令

1
2
ssd1306_command(0xA0);  // 段重映射(水平翻转)
ssd1306_command(0xC0); // COM扫描方向(垂直翻转)

5.4 刷新闪烁

原因:每次刷新整个屏幕

优化:只刷新变化的部分(局部刷新)