← 返回教程库

OLED 显示:给项目加一块小屏

最后更新 2026-06-22
L2 · 传感与交互 ⏱ 约 16 分钟 🟡 涉接线/强电
你将学到
  • 用 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 已不推荐新项目用)。它的思路很清楚,分两层:

  1. 建一条总线i2c_new_master_bus)——告诉芯片"我要用哪两个引脚、哪个 I2C 端口当一条 I2C 总线"。
  2. 往总线上挂从设备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 0x3Coled: 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 下显示中文,常见两条路:

  1. 用 u8g2 自带的中文字库:字体名里带 _gb2312 / _chinese 这类标识,画中文要用 u8g2_DrawUTF8(...) 而不是 DrawStr,并确保源文件存为 UTF-8 编码。代价是编译出来的固件会明显变大——小项目还好,塞了一堆功能的项目要留意 Flash 够不够。
  2. 自己取模:只显示固定的几个字(如"温度""湿度"),可以用取模软件把这几个字转成点阵数组烧进固件,最省 Flash,但加字要重新取模,灵活性差。
📌 说明

新手第一块屏,先用英文和数字最省事——Temp: 25.3CHumi: 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" 这样的文本再画(snprintfsprintf 安全,带长度上限,不会越界)。两行的 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 的原因。


动手挑战

别只看,挑一个改出来:

  1. 做一个计数器:屏上显示一个数字,每秒加一。提示:用一个变量 count,在 while(1)count++,再 snprintf 成字符串画出来,vTaskDelay(pdMS_TO_TICKS(1000))
  2. 画一个进度条:用 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 的读取真正合到一起,做成一个能装进盒子、插上电就显示的小设备。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明