显示屏与桌面作品 / 显示实践发布 内容更新

ESP32 OLED 第一屏:显示传感器数值和异常状态

固定SSD1306 128×64与SHT40模块,显示温湿度或明确错误;用单独I2C任务隔离整帧发送,主循环继续给按钮消抖。附完整依赖锁和断线/恢复记录模板。

读完能做什么
在128×64 OLED看到有单位与有效状态的温湿度;传感器失败不保留旧数字为正常值,按键可切页。
开始之前
已运行P06扫描与S01温湿度计,会给按钮消抖并按丝印接线。
所需器材
官方DevKitC-1 v1.1 N8R8与USB数据线、Adafruit PID326 SSD1306 128×64 STEMMA QT自动复位修订、Adafruit PID4885 SHT40-AD1B、常开按钮、短导线;故障检查可用预装低压SPST开关
版本组合
开发板:ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8工具/框架:Arduino CLI1.5.1 / Arduino-ESP32 3.3.12(IDF5.5.5)FQBN:esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc显示模块:Adafruit PID326 · 2020-08后STEMMA QT/自动复位 · 128×64 · 0x3D显示依赖:SSD1306 2.5.17 / GFX 1.12.4 / BusIO 1.17.4传感器依赖:Sensirion I2C SHT4x 1.1.2 / Sensirion Core 0.7.3
适用范围

官方ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8;Arduino CLI1.5.1、Arduino-ESP32 3.3.12、USB-to-UART口;未实板。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/displays.md验证日期:
构建 / 配置已通过
docs/esp32-evidence/arduino-builds.json;实际Exit0,源码SHA匹配;日志logs/esp32-oled-first-display-esp32-oled-first-display.log对象:ESP32-S3 / N8R8版本:Arduino CLI1.5.1 / core3.3.12验证日期:
仿真不适用
未运行芯片/外设仿真;软件逻辑检查如执行将另列范围
本站实板待验证
未在本站实板执行;正文与下载模板保留逐场景真实记录要求

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 显示实践

第一屏要回答“现在读到了什么,失败时显示什么”

串口温湿度计已经能告诉你一条样本是否有效,现在把它放到独立可看的OLED上。屏幕不能只显示两个数字:传感器断开后继续展示上一条数据,会让读者误以为房间仍是那个温湿度。本篇把有效性与数值一起送到画面,读失败立即改为横线和SENSOR ERROR。

这次作品包含正常页和诊断页,按钮在两页间切换。参考板仍是官方DevKitC-1 v1.1 N8R8,传感器沿用S01的Adafruit PID4885。没有本站实板的温湿度、断线或画面结果,所有检查都必须保存实际记录才能勾选。

做到这里,你应该看到
确认屏的控制器、128×64分辨率、PCB修订和0x3D地址;不能只说“0.96寸OLED”。

参考:[1] 固定DevKitC-1 v1.1文档:排针与供电[2] Adafruit PID326:分辨率、地址与2020/2024修订[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回[9] Adafruit SHT40:供电、上拉与0x44

02 / 显示实践

固定两个模块,共用SDA/SCL但不混淆地址

OLED只支持本例指定的Adafruit PID326:2020年8月之后STEMMA QT、默认I2C且自动复位的修订;2024年只改丝印。RST不接,地址默认0x3D。旧SPI默认修订、SH1106与匿名128×32不能直接用这个驱动和构造参数。

两模块VIN都接3V3,地共用,GPIO8为SDA、GPIO9为SCL,100kHz。先分别扫描0x3D和0x44,再合接。模块自带上拉,合接后电阻会并联;不要无依据再并上拉。信号与电源必须在3.3V域,先用短线,距离和稳定性要在实际布置中复验。

常开按钮接GPIO5与GND,内部上拉,四脚按钮先断电辨认两组。需要制造传感器故障时,断电预装一个低压开关在SHT40的SDA支路,运行中只操作开关。不要切公共SDA后要求屏仍能报告新故障。

DevKitC-1 v1.1OLED PID326SHT40 PID4885
3V3VINVIN
GNDGNDGND
GPIO8(J1-12)Data/SDASDA
GPIO9(J1-15)Clk/SCLSCL
留空RST(自动复位)3Vo(电源输出)

参考:[1] 固定DevKitC-1 v1.1文档:排针与供电[2] Adafruit PID326:分辨率、地址与2020/2024修订[3] Adafruit:128×64 OLED的I2C跳线与自动复位[9] Adafruit SHT40:供电、上拉与0x44

03 / 显示实践

1024字节缓冲和整帧发送,是两个不同成本

128×64单色像素至少需要128×64÷8=1024字节帧缓冲,此外还有对象、队列和任务栈。clearDisplay、print、drawLine主要改RAM,调用display之后才把缓冲发送到OLED。不是每写一个字符就自动显示。

固定SSD1306 2.5.17的display函数返回void,逐包I2C错误没有作为返回值传给调用方。begin返回true也不能证明像素真的亮起;本例先单独探测地址,再记录allocated与frames_submitted,但仍需要眼看/拍照确认画面。

100kHz时只算1024字节及ACK的9位时钟量级就约92毫秒,另有命令和分段开销。这个计算说明为什么不能把整帧发送称为立即返回,并不声称测到了这块屏的耗时。程序用last_tx_ms保存实际调用持续时间,留待实板填记录。

参考:[2] Adafruit PID326:分辨率、地址与2020/2024修订[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现

04 / 显示实践

一个任务独占总线,主循环只处理输入和消息

所有Wire、SHT40采样和屏幕函数放在唯一I2C worker中,避免多个任务同时操作总线或帧缓冲。主loop每轮扫描按钮,30毫秒稳定下降沿产生页号,通过一个单槽队列发送给worker;队列只保留最新页状态,不保证每个中间页都形成一帧动画。

worker每2秒采样、每500毫秒提交画面。每次读入使用新的NAN变量,错误或非有限值就把valid清掉、数值改为NAN;5秒没有新的有效样本也不再当成当前值。OLED失去ACK后标记未初始化,ACK恢复时重新初始化,复用已有缓冲。

后台任务让屏幕等待不占住同一个loop,但它不是硬实时承诺。任务创建和队列分配都检查返回值;分配失败时输入仍继续,串口报告UI不可用。Serial输出与系统调度也会影响按钮,必须同时记录max_input_gap_ms与30次实际按键。

参考:[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回[8] IDF5.5.5 / S3:任务、字节单位栈与队列

05 / 显示实践

锁住五个库,再编译完整应用

依赖固定Sensirion Core0.7.3、Sensirion I2C SHT4x1.1.2、Adafruit BusIO1.17.4、GFX1.12.4与SSD1306 2.5.17。它沿用S01的Sensirion驱动,不混入名字相近的Adafruit SHT4x。按下方命令逐个安装固定版本,并用--no-deps阻止自动补出另一套未记录版本。

代码 / 配置
arduino-cli lib install --no-deps 'Sensirion Core@0.7.3'
arduino-cli lib install --no-deps 'Sensirion I2C SHT4x@1.1.2'
arduino-cli lib install --no-deps 'Adafruit BusIO@1.17.4'
arduino-cli lib install --no-deps 'Adafruit GFX Library@1.12.4'
arduino-cli lib install --no-deps 'Adafruit SSD1306@2.5.17'

参考:[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回[6] GFX 1.12.4:版本与BusIO依赖[7] BusIO 1.17.4:版本与依赖

06 / 显示实践

完整程序:有效状态与绘图走同一条路径

下载包含此完整.ino、sketch.yaml、README、MIT许可与空白observations.csv。图形默认字体用ASCII状态词,避免在第一屏引入中文字体依赖;这不限制后续中文教程另选字库。sensor_error为驱动原码,-1/-2是本例总线失败/非有限结果的本地标记。

代码 / 配置
// SPDX-License-Identifier: MIT
// D01: DevKitC-1 v1.1 N8R8; Adafruit PID326 + PID4885, 3.3V I2C.
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <SensirionI2cSht4x.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <math.h>

constexpr uint8_t SDA_PIN = 8, SCL_PIN = 9, BUTTON_PIN = 5;
constexpr uint8_t OLED_ADDRESS = 0x3D;
constexpr uint32_t SAMPLE_MS = 2000, FRAME_MS = 500, STALE_MS = 5000;
Adafruit_SSD1306 screen(128, 64, &Wire, -1, 100000UL, 100000UL);
SensirionI2cSht4x sensor;
struct Snapshot {
  bool busReady, screenAck, screenReady, valid;
  uint8_t page;
  int16_t sensorError;
  uint32_t samples, frames, lastGoodAt, frameDuration;
  float temperature, humidity;
};
void drawFrame(const Snapshot &s, uint32_t now);
QueueHandle_t pageQueue = nullptr, snapshotQueue = nullptr;
bool uiTaskReady = false;

uint8_t probe(uint8_t address) {
  Wire.beginTransmission(address);
  return Wire.endTransmission(true);
}
void drawFrame(const Snapshot &s, uint32_t now) {
  screen.clearDisplay();
  screen.setTextColor(SSD1306_WHITE);
  screen.setTextSize(1);
  screen.setCursor(0, 0);
  screen.print(s.page == 0 ? "D01  SHT40" : "D01  DIAGNOSTICS");
  screen.drawLine(0, 10, 127, 10, SSD1306_WHITE);
  screen.setCursor(0, 15);
  if (!s.valid) {
    screen.println(s.sensorError != 0 ? "SENSOR ERROR" : "STALE / NO SAMPLE");
    screen.println("T: -- C");
    screen.println("RH: -- %");
  } else if (s.page == 0) {
    screen.print("T: "); screen.print(s.temperature, 1); screen.println(" C");
    screen.print("RH: "); screen.print(s.humidity, 1); screen.println(" %");
    screen.print("age: "); screen.print(uint32_t(now - s.lastGoodAt)); screen.println(" ms");
  } else {
    screen.print("samples: "); screen.println(s.samples);
    screen.print("frames: "); screen.println(s.frames);
    screen.print("last TX: "); screen.print(s.frameDuration); screen.println(" ms");
  }
  screen.setCursor(0, 56);
  screen.print("button GPIO5: page "); screen.print(s.page);
  screen.display();  // synchronous I2C, isolated in this worker task
}
void i2cUiTask(void *) {
  Snapshot s{};
  s.temperature = s.humidity = NAN;
  s.busReady = Wire.begin(SDA_PIN, SCL_PIN, 100000);
  Wire.setTimeOut(20);
  sensor.begin(Wire, SHT40_I2C_ADDR_44);
  uint32_t sampledAt = millis() - SAMPLE_MS;
  uint32_t framedAt = millis() - FRAME_MS, busTriedAt = millis();
  for (;;) {
    uint32_t now = millis();
    uint8_t page;
    if (xQueueReceive(pageQueue, &page, 0) == pdTRUE) s.page = page;
    if (!s.busReady && uint32_t(now - busTriedAt) >= SAMPLE_MS) {
      busTriedAt = now;
      s.busReady = Wire.begin(SDA_PIN, SCL_PIN, 100000);
      Wire.setTimeOut(20);
    }
    if (uint32_t(now - sampledAt) >= SAMPLE_MS) {
      sampledAt = now;
      ++s.samples;
      float temperature = NAN, humidityRaw = NAN;
      s.sensorError = s.busReady ? sensor.measureHighPrecision(temperature, humidityRaw) : -1;
      s.valid = s.sensorError == 0 && isfinite(temperature) && isfinite(humidityRaw);
      if (s.sensorError == 0 && !s.valid) s.sensorError = -2;
      if (s.valid) {
        s.temperature = temperature;
        s.humidity = fminf(100.0f, fmaxf(0.0f, humidityRaw));
        s.lastGoodAt = millis();
      } else {
        s.temperature = s.humidity = NAN;  // no stale number rendered as current
      }
    }
    now = millis();
    if (s.valid && uint32_t(now - s.lastGoodAt) > STALE_MS) s.valid = false;
    if (uint32_t(now - framedAt) >= FRAME_MS) {
      framedAt = now;
      s.screenAck = s.busReady && probe(OLED_ADDRESS) == 0;
      if (!s.screenAck) s.screenReady = false;
      if (s.screenAck && !s.screenReady) {
        // PID326 auto-reset revision: no reset pin. Wire is already configured.
        s.screenReady = screen.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS, false, false);
      }
      if (s.screenAck && s.screenReady) {
        const uint32_t startedAt = millis();
        drawFrame(s, startedAt);
        s.frameDuration = millis() - startedAt;
        ++s.frames;  // submitted, not optically verified or I2C payload-ack verified
      }
      xQueueOverwrite(snapshotQueue, &s);
    }
    vTaskDelay(pdMS_TO_TICKS(5));
  }
}
void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pageQueue = xQueueCreate(1, sizeof(uint8_t));
  snapshotQueue = xQueueCreate(1, sizeof(Snapshot));
  if (pageQueue && snapshotQueue)
    uiTaskReady = xTaskCreate(i2cUiTask, "d01-i2c", 8192, nullptr, 1, nullptr) == pdPASS;
  Serial.printf("D01 core=3.3.12 task=%u OLED=128x64@0x3D SDA=8 SCL=9 100kHz\n", uiTaskReady);
}
void loop() {
  static bool initialized = false, candidate, stable;
  static uint8_t page = 0;
  static uint32_t changedAt = 0, presses = 0, reportedAt = 0, lastLoopAt = 0, maxGap = 0;
  const uint32_t now = millis();
  const bool raw = digitalRead(BUTTON_PIN);
  if (!initialized) {
    initialized = true; candidate = stable = raw; changedAt = lastLoopAt = now;
  }
  const uint32_t gap = now - lastLoopAt;
  lastLoopAt = now;
  if (gap > maxGap) maxGap = gap;
  if (raw != candidate) { candidate = raw; changedAt = now; }
  if (candidate != stable && uint32_t(now - changedAt) >= 30) {
    stable = candidate;
    if (stable == LOW) {
      page ^= 1;
      if (uiTaskReady) xQueueOverwrite(pageQueue, &page);
      Serial.printf("BUTTON press=%lu accepted=%lu page=%u task=%u\n",
        (unsigned long)++presses, (unsigned long)now, page, uiTaskReady);
    }
  }
  if (uint32_t(now - reportedAt) >= 2000) {
    reportedAt = now;
    Snapshot s{};
    if (uiTaskReady && xQueuePeek(snapshotQueue, &s, 0) == pdTRUE) {
      Serial.printf("UI bus=%u oled_ack=%u allocated=%u valid=%u sensor_error=%d page=%u samples=%lu frames_submitted=%lu last_tx_ms=%lu max_input_gap_ms=%lu\n",
        s.busReady, s.screenAck, s.screenReady, s.valid, s.sensorError, s.page,
        (unsigned long)s.samples, (unsigned long)s.frames, (unsigned long)s.frameDuration, (unsigned long)maxGap);
    } else Serial.printf("UI unavailable task=%u; input still runs\n", uiTaskReady);
    maxGap = 0;
  }
  delay(1);
}

参考:[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回[8] IDF5.5.5 / S3:任务、字节单位栈与队列

07 / 显示实践

从编译到三组观察,逐项留证

按F05固定CLI/core,在仓库根目录执行下面命令,PORT替换为实际桥接串口。Flash8MB/QIO、OPI PSRAM、8M with spiffs,CDC关闭。profile没有默认启用;显式--profile可另走隔离重建,首次会另装工具链。

  1. 正常:记录至少10次传感器采样与屏幕照片,确认单位、温湿度与样本年龄;对照串口valid和OLED ACK,不用ACK替代读数/画面验证。
  2. 传感器失联:断开预装的SHT40 SDA支路开关,下一轮失败后画面必须显示横线和ERROR;闭合后有效读取恢复数值,记录时间与真实错误码。
  3. 输入:在正常与失联状态下各按30次并做长按,编号应一一对应,画面最终页号与最后一次按键一致;记录max_input_gap和last_tx。
  4. 错屏地址:实物保持0x3D,把程序临时改0x3C,确认oled_ack=0但按钮日志继续;恢复0x3D重新编译,留下恢复画面。
代码 / 配置
arduino-cli compile --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' examples/esp32/esp32-oled-first-display
arduino-cli upload --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' -p PORT --upload-property upload.speed=115200 examples/esp32/esp32-oled-first-display
arduino-cli monitor -p PORT --config baudrate=115200
做到这里,你应该看到
源码构建、数据有效性、按钮事件和物理显示分别有证据;空CSV不计为实板验收。
08 / 显示实践

屏空白与数据错误,沿不同证据恢复

屏空白时先核对控制器/分辨率/修订,再查供电、0x3D、SDA/SCL及task/allocated/ACK。allocated=0表示应先排内存问题;只有旧画面时frames_submitted也不能证明像素收到新帧,需检查总线和屏。

传感器ERROR先单独运行S01排供电与CRC读数,再合接总线。按键无日志先用P02排四脚按钮和电平,再看任务创建与loop间隔。整条SDA被拉低时屏也无法更新,靠串口保留故障,不让“无法显示错误”又被写成数据有效。

恢复默认地址、固定依赖与接线,断电重启,保留失败和恢复两段记录。不要把重装库、擦Flash或更换芯片作为第一步。下一篇让设备自己连WiFi,继续保留这种输入与后台等待分开的观察方法。

参考:[1] 固定DevKitC-1 v1.1文档:排针与供电[2] Adafruit PID326:分辨率、地址与2020/2024修订[4] SSD1306 2.5.17:begin分配缓冲与display同步发送实现[5] Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回

BACK TO THE SOURCE

资料来源与版本说明

固定厂商模块与库源码核对;allocated/ACK/提交帧与物理显示分开验收。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 固定DevKitC-1 v1.1文档:排针与供电
  2. Adafruit PID326:分辨率、地址与2020/2024修订
  3. Adafruit:128×64 OLED的I2C跳线与自动复位
  4. SSD1306 2.5.17:begin分配缓冲与display同步发送实现
  5. Sensirion I2C SHT4x 1.1.2:高精度测量与错误返回
  6. GFX 1.12.4:版本与BusIO依赖
  7. BusIO 1.17.4:版本与依赖
  8. IDF5.5.5 / S3:任务、字节单位栈与队列
  9. Adafruit SHT40:供电、上拉与0x44