OLED 显示:给项目加一块小屏
- 用 i2c_master 驱动配好 I2C 总线、把 SSD1306 当从设备挂上去
- 看懂 esp_lcd 与 u8g2 移植组件两条显示路线的取舍,跑通一条
- 真正搞懂「缓冲区 → 绘制 → 刷新」这套流程对应的 ESP-IDF API
- 把传感器数据实时画到屏上,知道显示中文要准备字库
| 器材 | 数量 | 参考 |
|---|---|---|
| 0.96寸 OLED (SSD1306, I2C) | 1 | — |
价格随渠道波动,以购买页实时为准。
到这一步,你的项目大概已经能读温湿度、能控制几个引脚了。但有个小别扭一直跟着你:想看一眼数据,就得把板子连着电脑、盯着串口 ESP_LOGI 的输出。一旦拔了线、装进盒子里,它就成了个"哑巴"——明明在工作,你却什么都看不见。
加一块 0.96 寸的 OLED 小屏,这个别扭就解决了。几块钱、I2C 两根信号线,项目从此有了"脸":温度、湿度、运行状态、甚至一个小菜单,都能直接显示出来。SSD1306 是这块尺寸里最常见的驱动芯片,乐鑫官方有现成组件、社区例子也多,是显示入门的标准选择。读完这篇,你不只是抄通一个 Hello,而是真的搞懂了"在 ESP-IDF 下一块屏是怎么被点亮、被写字的"。
读这篇前,你需要已经搭好 ESP-IDF 环境、能用 idf.py build flash monitor 跑通工程,并且读过 DHT11 那节——这篇结尾会把温湿度搬到屏上。
本节的显示驱动链涉及 esp_lcd / u8g2 移植组件,版本与回调签名各组件略有差异。下面给的是参考实现,需以你装的组件 README、官方示例为准自行核对,不要当成一字不差的"标准答案"——尤其是组件名、HAL 回调原型这类细节。
第一步:接线(I2C,只要两根信号线)
先把屏接上。SSD1306 用的是 I2C 接口,一共四根线,其中真正传数据的只有两根:
| OLED | ESP32-S3 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SDA | GPIO8 |
| SCL | GPIO9 |
VCC、GND 是供电,剩下 SDA(数据)和 SCL(时钟)就是全部的通信线。为什么这么省?因为 I2C 是一种"总线"——所有设备共用这两根线,靠各自不同的"地址"区分。这也意味着:你之前接的 BMP280 气压传感器,可以和这块屏挂在同一对 SDA/SCL 上,不用各占一套引脚。想搞清楚两根线怎么做到既传数据又分得清设备,看 I2C 总线原理。
ESP32-S3 的 I2C 引脚可以任意选普通 GPIO,这里用 GPIO8/GPIO9。避开几类不能乱用的脚:strapping 脚(GPIO0/3/45/46,上电电平有特殊含义)、USB 脚(GPIO19/20)、以及连着内部 flash/PSRAM 的 GPIO26–37。
网上的 SSD1306 模块管脚顺序不统一,有的是 GND VCC SCL SDA,有的是 VCC GND SDA SCL。接之前先看模块上印的丝印,别照图片背顺序——接反 VCC 和 GND 有烧屏风险。
第二步:先把 I2C 总线配起来
在 ESP-IDF 5.x 里,I2C 用的是新版 i2c_master 驱动(头文件 driver/i2c_master.h,老的 driver/i2c.h 已不推荐新项目用)。它的思路很清楚,分两层:
- 建一条总线(
i2c_new_master_bus)——告诉芯片"我要用哪两个引脚、哪个 I2C 端口当一条 I2C 总线"。 - 往总线上挂从设备(
i2c_master_bus_add_device)——SSD1306 是挂在这条总线上的一个设备,它的地址通常是 0x3C(少数模块是 0x3D)。
#include "driver/i2c_master.h"
#include "esp_log.h"
static const char *TAG = "oled";
#define I2C_PORT I2C_NUM_0
#define PIN_SDA GPIO_NUM_8
#define PIN_SCL GPIO_NUM_9
#define SSD1306_ADDR 0x3C
static i2c_master_bus_handle_t s_bus;
static i2c_master_dev_handle_t s_dev;
static void i2c_bus_init(void)
{
i2c_master_bus_config_t bus_cfg = {
.i2c_port = I2C_PORT,
.sda_io_num = PIN_SDA,
.scl_io_num = PIN_SCL,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true, // 板上没贴上拉电阻时靠它
};
ESP_ERROR_CHECK(i2c_new_master_bus(&bus_cfg, &s_bus));
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = SSD1306_ADDR, // OLED 的 7 位地址 0x3C
.scl_speed_hz = 400000, // 400kHz 快速模式
};
ESP_ERROR_CHECK(i2c_master_bus_add_device(s_bus, &dev_cfg, &s_dev));
ESP_LOGI(TAG, "I2C bus ready, OLED at 0x%02X", SSD1306_ADDR);
}
到这里总线就通了。但这一步还看不到屏上有任何变化——我们只是建好了"打电话的线路",还没开始往屏里灌显示命令。下一步选一个驱动把它点亮。
不确定地址对不对?i2c_master 提供了 i2c_master_probe(bus, addr, timeout)——挨个地址探一遍,能 ESP_OK 的就是在线设备。这是 ESP-IDF 下排查 I2C 设备的"第一招",等价于 Arduino 那个 I2C Scanner。先探到地址,再往代码上查,能省一半瞎试。
第三步:选显示方案——两条路,先想清楚再动手
I2C 通了,怎么把"文字"画上去?ESP-IDF 下有两条主流路线,先看清取舍再选:
| 路线 | 是什么 | 长处 | 短板 |
|---|---|---|---|
| esp_lcd(乐鑫官方) | esp_lcd_panel_io_i2c + esp_lcd_new_panel_ssd1306 |
官方维护、和 LVGL/相机/其它屏一套 API;产品化首选 | 它只管把像素推给屏,画文字常要配 LVGL 或自己往 GRAM 里摆点阵,单纯显示几行字偏重 |
| u8g2 移植组件 | 把 Arduino 圈那套 u8g2 库移植到 ESP-IDF,HAL 回调接到 i2c_master | 自带大量字库、画字一行 drawStr 就完事,还能画中文 |
不是乐鑫官方,跟版本走;引脚/回调要按组件 README 接 |
一句话选型:只是想稳稳显示几行温湿度文字、还要中文,选 u8g2 移植组件最省事;要做复杂界面、跟 LVGL 一起上、或追求纯官方栈,选 esp_lcd。
本节主路径走 u8g2 移植组件——因为我们的目标就是"显示文字",它画字最直接,也最贴近你以后看到的大量社区例子。先看它,再附一段 esp_lcd 初始化骨架供对照。
第四步:用 u8g2 移植组件显示一行字(主路径完整代码)
u8g2 本身是 Arduino 生态的库,移到 ESP-IDF 的做法是:用一个移植组件把 u8g2 的"画"逻辑保留,底层"怎么把字节发出去"的 HAL 回调改成调 i2c_master。常见组件可以用包管理器拉:
idf.py add-dependency "u8g2"
# 具体组件名/版本以 components.espressif.com 上你选的那个为准
u8g2 的 ESP-IDF 移植组件有好几个分支,HAL 回调的函数名和初始化宏可能和下面略有出入。下面这段是参考实现,落地时对照你装的那个组件的 README/example 改回调签名,别原样照抄。
核心是把 u8g2 的两个 HAL 回调接到我们第二步建好的 i2c:一个负责"发字节"(用 i2c_master_transmit 把 u8g2 攒好的数据发给从设备),一个负责"延时/控制"(用 vTaskDelay)。接好回调后,u8g2 的上层 API 就和 Arduino 上一模一样了:
#include "driver/i2c_master.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "u8g2.h"
static const char *TAG = "oled";
#define I2C_PORT I2C_NUM_0
#define PIN_SDA GPIO_NUM_8
#define PIN_SCL GPIO_NUM_9
#define SSD1306_ADDR 0x3C
static i2c_master_bus_handle_t s_bus;
static i2c_master_dev_handle_t s_dev;
static u8g2_t u8g2;
// 把 i2c 总线建好(同第二步)
static void i2c_bus_init(void) { /* …见第二步… */ }
// u8g2 的字节回调:把 u8g2 攒在缓冲里的数据,分块用 i2c_master 发出去
static uint8_t u8x8_byte_esp_i2c(u8x8_t *u8x8, uint8_t msg,
uint8_t arg_int, void *arg_ptr)
{
static uint8_t buf[32];
static uint8_t idx;
switch (msg) {
case U8X8_MSG_BYTE_START_TRANSFER:
idx = 0;
break;
case U8X8_MSG_BYTE_SEND: {
uint8_t *p = (uint8_t *)arg_ptr;
for (int i = 0; i < arg_int; i++) buf[idx++] = p[i];
break;
}
case U8X8_MSG_BYTE_END_TRANSFER:
// 一次事务攒齐再发,比每字节发一次快得多
i2c_master_transmit(s_dev, buf, idx, 1000 /*ms*/);
break;
}
return 1;
}
// u8g2 的延时/GPIO 回调:这里只需要把延时接到 FreeRTOS
static uint8_t u8x8_gpio_delay_esp(u8x8_t *u8x8, uint8_t msg,
uint8_t arg_int, void *arg_ptr)
{
if (msg == U8X8_MSG_DELAY_MILLI) {
vTaskDelay(pdMS_TO_TICKS(arg_int));
}
return 1;
}
void app_main(void)
{
i2c_bus_init();
// 1) 告诉 u8g2:128×64、SSD1306、走 I2C;把回调挂上
u8g2_Setup_ssd1306_i2c_128x64_noname_f(
&u8g2, U8G2_R0, u8x8_byte_esp_i2c, u8x8_gpio_delay_esp);
u8x8_SetI2CAddress(u8g2_GetU8x8(&u8g2), SSD1306_ADDR << 1); // u8g2 用 8 位地址
// 2) 初始化 + 上电
u8g2_InitDisplay(&u8g2);
u8g2_SetPowerSave(&u8g2, 0); // 0 = 唤醒(开屏)
// 3) 「擦 → 画 → 推」三步
u8g2_ClearBuffer(&u8g2); // 擦:清空内存画布
u8g2_SetFont(&u8g2, u8g2_font_ncenB10_tr); // 选字体
u8g2_DrawStr(&u8g2, 0, 20, "Hello!"); // 在 (0,20) 画字符串
u8g2_SendBuffer(&u8g2); // 推:把画布一次性送屏
ESP_LOGI(TAG, "drew Hello!");
}
编译烧录看效果:
idf.py build flash monitor
(第一次记得 idf.py set-target esp32s3。)
你应该看到什么
- 终端滚完编译/烧录,串口里打出
oled: I2C bus ready, OLED at 0x3C和oled: drew Hello!。 - 屏的左上角亮出一行
Hello!,字是白色像素、黑色背景——这是 OLED 的特点:每个像素自发光,不需要背光。
看到字,恭喜——你已经在用程序"画"东西了。和点灯比,这一步多了一层:你控制的不再是一根引脚的高低,而是 128×64 = 8192 个像素。
留意那行 u8x8_SetI2CAddress 里写的是 0x3C << 1。u8g2 内部习惯用"8 位地址"(含读写位),而我们在第二步给 i2c_master 配的是"7 位地址"0x3C——两套数法差一位,这是 I2C 新手最常被坑的地方,发现地址对不上先想想是不是这里。
第五步:把「擦 → 画 → 推」这套流程讲透
上面那几行 Clear/Draw/Send,是几乎所有显示程序的骨架,无论你用 u8g2 还是 esp_lcd,底层都是同一套思路。理解它,比记住函数名重要得多。
核心设计:你画的所有东西,先落在单片机内存的一块"画布"(framebuffer,缓冲区)上,而不是直接出现在屏上。 攒好了再一次性推给屏。
u8g2_ClearBuffer():把这块内存画布擦干净(全黑)。每次重画前先擦,否则新内容会叠在旧内容上。- 中间的
SetFont/DrawStr:在这块内存画布上写写画画——此时屏上还没有任何变化。 u8g2_SendBuffer():把整块画布(128×64÷8 = 1024 字节)一次性推送到屏。这一刻,屏才更新。
打个比方:ClearBuffer 是擦白板,DrawStr 是在白板上写字,SendBuffer 是把白板举起来给观众看。先写好、再一次展示,画面就不会闪、不会撕裂。记住这个"擦 → 画 → 推"三步节奏,后面所有显示都是它的重复。
换成 esp_lcd 呢? 概念完全一样,只是 API 换名:你往一块内存 framebuffer 里摆好像素,再用 esp_lcd_panel_draw_bitmap(panel, x0, y0, x1, y1, buf) 把那块区域刷到屏上——这个 draw_bitmap 就相当于 u8g2 的 SendBuffer。区别在于 esp_lcd 不替你画字,"画文字"那一层得靠 LVGL 或你自己把字模点阵摆进 framebuffer。
关于坐标:DrawStr(x, y, "...") 里 y 是文字的基线(baseline,大致是字底),不是顶部——所以上面写 20 而不是 0,写 0 的话字会跑到屏幕上方被切掉。屏左上角是 (0,0),x 向右增大,y 向下增大。
第六步:故障排查——屏不亮 / 花屏,按这个顺序查
第一次接 OLED,卡住的概率不低。绝大多数问题就这几类,照表查:
| 现象 | 最可能的原因 | 怎么办 |
|---|---|---|
| 屏全黑、毫无反应 | I2C 地址不对 | 多数模块是 0x3C,少数 0x3D。用 i2c_master_probe() 探一遍看实际地址;注意 u8g2 那侧地址要左移 1 位 |
i2c_new_master_bus 或 transmit 返回错误 |
引脚配错 / 总线没建成 | 确认 SDA/SCL 是 GPIO8/9,没踩 strapping(0/3/45/46)、USB(19/20)、flash(26–37) |
probe 扫不到任何设备 |
接线或上拉问题 | 查 SDA/SCL 接线、VCC 是否 3.3V;模块没贴上拉就把 enable_internal_pullup 打开 |
| 屏亮但满屏雪花/花屏 | 驱动型号或分辨率选错 | u8g2 的 Setup_ssd1306_i2c_128x64 要和屏真实分辨率一致;常见还有 128×32,选错就花 |
| 只显示半屏 / 内容错位 | 分辨率写成 128×64 但屏其实 128×32 | 换成对应的 128x32 setup 宏 |
| 字叠在一起、越画越乱 | 忘了 ClearBuffer() |
每帧重画前先擦画布 |
编译报 u8g2.h 找不到 |
组件没拉进来 | 确认 idf.py add-dependency 装了组件、idf.py reconfigure 过 |
i2c_master_probe() 是排查 I2C 设备的万能第一步。 它会告诉你某个地址上到底有没有设备应答。探得到 → 接线和供电没问题,往代码/分辨率上查;探不到 → 老老实实回去查那四根线和上拉。先探地址再改代码,能省掉一半瞎试。
第七步:显示中文,得多想一层
英文和数字能显示了,自然想显示"温度""湿度"。但中文不是想显就能显——它和字库直接相关。
英文 ASCII 只有 128 个字符,字库很小,u8g2 默认就带。中文一个常用字库动辄几千个汉字,占的 Flash 空间大得多。在 ESP-IDF 下显示中文,常见两条路:
- 用 u8g2 自带的中文字库:字体名里带
_gb2312/_chinese这类标识,画中文要用u8g2_DrawUTF8(...)而不是DrawStr,并确保源文件存为 UTF-8 编码。代价是编译出来的固件会明显变大——小项目还好,塞了一堆功能的项目要留意 Flash 够不够。 - 自己取模:只显示固定的几个字(如"温度""湿度"),可以用取模软件把这几个字转成点阵数组烧进固件,最省 Flash,但加字要重新取模,灵活性差。
新手第一块屏,先用英文和数字最省事——Temp: 25.3C、Humi: 60% 完全够看,不踩字库和编码的坑。等流程都顺了,再加中文不迟。
第八步:把传感器数据画到屏上
光显示固定的 Hello 没意思,真正有用的是实时数据。你在 DHT11 那节读到的温湿度,现在可以直接搬到屏上。核心就是把"擦 → 画 → 推"放进 while(1),每读一次新数据就重画一帧:
void app_main(void)
{
i2c_bus_init();
u8g2_Setup_ssd1306_i2c_128x64_noname_f(
&u8g2, U8G2_R0, u8x8_byte_esp_i2c, u8x8_gpio_delay_esp);
u8x8_SetI2CAddress(u8g2_GetU8x8(&u8g2), SSD1306_ADDR << 1);
u8g2_InitDisplay(&u8g2);
u8g2_SetPowerSave(&u8g2, 0);
while (1) {
// 这里假设已从 DHT11 读到 temp 和 humi(替换成你的真实读取代码)
float temp = 25.3f;
float humi = 60.0f;
char line1[24], line2[24];
snprintf(line1, sizeof(line1), "Temp: %.1f C", temp); // 数字拼成字符串
snprintf(line2, sizeof(line2), "Humi: %.0f %%", humi);
u8g2_ClearBuffer(&u8g2); // 擦
u8g2_SetFont(&u8g2, u8g2_font_ncenB10_tr);
u8g2_DrawStr(&u8g2, 0, 25, line1); // 画第一行
u8g2_DrawStr(&u8g2, 0, 50, line2); // 画第二行
u8g2_SendBuffer(&u8g2); // 推
vTaskDelay(pdMS_TO_TICKS(2000)); // 每 2 秒刷新一次
}
}
关键点:屏只认字符串,数字得先用 snprintf 拼成 "Temp: 25.3 C" 这样的文本再画(snprintf 比 sprintf 安全,带长度上限,不会越界)。两行的 y 坐标错开(25 和 50),上下就不会挤在一起。把假数据那两行换成你真实的 DHT11 读取,屏上的温湿度就会跟着环境实时变。
想让 AI 帮你排版?把分辨率、用的库(u8g2/esp_lcd)、要显示的内容和布局一次说全,比如:"我用 ESP32-S3 + ESP-IDF 的 u8g2 移植组件驱动 128×64 SSD1306,帮我写显示代码:第一行居中标题 'Weather',下面两行分别显示温度湿度,数值用变量 temp、humi。" 它算坐标比你一个个数快,改坐标比从零排版快——务必说清是 u8g2 还是 esp_lcd,两者 API 完全不同,混着用编译不过。
附:esp_lcd 路线初始化骨架(对照用)
如果你要走官方 esp_lcd(比如后面准备上 LVGL),初始化大致长这样——同样以官方 esp_lcd_ssd1306 组件示例为准:
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_panel_ssd1306.h"
// 1) 在已建好的 i2c 总线上挂一个 panel IO
esp_lcd_panel_io_handle_t io = NULL;
esp_lcd_panel_io_i2c_config_t io_cfg = {
.dev_addr = SSD1306_ADDR,
.scl_speed_hz = 400000,
.control_phase_bytes = 1,
.dc_bit_offset = 6,
.lcd_cmd_bits = 8,
.lcd_param_bits = 8,
};
ESP_ERROR_CHECK(esp_lcd_new_panel_io_i2c(s_bus, &io_cfg, &io));
// 2) 创建 SSD1306 panel
esp_lcd_panel_handle_t panel = NULL;
esp_lcd_panel_ssd1306_config_t ssd_cfg = { .height = 64 };
esp_lcd_panel_dev_config_t panel_cfg = {
.bits_per_pixel = 1,
.reset_gpio_num = -1, // I2C 模块通常没单独 reset 脚
.vendor_config = &ssd_cfg,
};
ESP_ERROR_CHECK(esp_lcd_new_panel_ssd1306(io, &panel_cfg, &panel));
// 3) 复位、初始化、开屏
ESP_ERROR_CHECK(esp_lcd_panel_reset(panel));
ESP_ERROR_CHECK(esp_lcd_panel_init(panel));
ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel, true));
// 之后:往 framebuffer 摆像素,再 esp_lcd_panel_draw_bitmap(panel, x0,y0,x1,y1, buf)
// 想画文字 → 接 LVGL,或自己把字模点阵摆进 buf
可以看出:esp_lcd 把"屏怎么点亮"做得很规范,但"怎么画字"留给上层——这正是单纯显示文字时多数人选 u8g2 的原因。
动手挑战
别只看,挑一个改出来:
- 做一个计数器:屏上显示一个数字,每秒加一。提示:用一个变量
count,在while(1)里count++,再snprintf成字符串画出来,vTaskDelay(pdMS_TO_TICKS(1000))。 - 画一个进度条:用
u8g2_DrawFrame(&u8g2, x, y, w, h)画空心框,再用u8g2_DrawBox(&u8g2, x, y, w*p, h)按比例p(0~1)填充——一个会动的进度条就出来了。
卡住了?把你的代码、屏的型号、想要的效果一起发给 AI,让它帮你定位。绝大多数显示问题,要么是地址(别忘 u8g2 那侧 ×2)/分辨率,要么是忘了 ClearBuffer/SendBuffer,对照第六步的表也能自查。
小结 · 你现在掌握了什么
- 你能用 i2c_master 驱动配好一条 I2C 总线、把 SSD1306 当从设备(地址 0x3C)挂上去,并知道它能和 BMP280 共用一条总线。
- 你看懂了 esp_lcd(官方、配 LVGL 画字)和 u8g2 移植组件(自带字库、画字方便)两条显示路线的取舍,跑通了 u8g2 这条。
- 你理解了"擦画布 → 画内容 → 推送"这套核心流程,知道它对应 u8g2 的
ClearBuffer/DrawStr/SendBuffer、esp_lcd 的draw_bitmap。 - 你能把传感器读数实时画到屏上,也知道显示中文要换
DrawUTF8+ 准备字库(自带库或取模),屏不亮、花屏能照顺序自己排查。
屏会用了,下一步就是把传感器 + 屏拼成一个能独立工作、不靠电脑的完整作品——综合项目:温湿度显示器。那一节会把这篇的数据显示和 DHT11 的读取真正合到一起,做成一个能装进盒子、插上电就显示的小设备。
本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。