综合项目:做一个温湿度显示器
- 把 espressif/dht 读数和 u8g2 显示整合成一个完整可跑的 ESP-IDF 工程
- 在一块 ESP32-S3 上让单总线设备和 I2C 设备各跑各的、互不干扰
- 写出带 esp_err_t 读失败处理的整合代码,定时刷新,稳定到能摆桌上长期开着
- 体会"做出一个完整东西"的成就感,并知道往哪扩
| 器材 | 数量 | 参考 |
|---|---|---|
| ESP32-S3 开发板 | 1 | — |
| DHT11 或 DHT22 | 1 | — |
| 0.96寸 OLED (SSD1306, I2C) | 1 | — |
| 面包板 + 杜邦线 | 1套 | — |
价格随渠道波动,以购买页实时为准。
前面两节,你单独学了 DHT11 读温湿度和 OLED 显示文字。它们各自能跑,但都是"半成品"——一个有数据没有脸,一个有脸没有数据。这一节把它们拼起来,做出你的第一个完整作品:一块会实时刷新温湿度的小屏,插上电就独立工作,不用连着电脑看 monitor。
这件事的意义不在技术难度,而在"完整"。它有传感器、有屏、有逻辑,插上电就独立工作。做完它,你桌上就多了一个你自己造的、真能用的东西——那种"我做出来了"的实感,比任何教程都更能把你留在硬件这条路上。这是 L2 的毕业设计。
读这篇前,确认你已经能让 DHT11 单独用 ESP_LOGI 打出温湿度(DHT11 那节)、能让 OLED 单独显示出 Hello(OLED 那节)。两边都通过了,我们才好拼——尤其是 OLED,u8g2 移植组件的 HAL 回调你得先在那节接通过。
本节给两个外设供电、接 GPIO。VCC 一律接 3.3V(下面会说为什么别接 5V),动线前断开 USB,接好再上电。
第一步:接线——两套设备,井水不犯河水
这个作品里有两个外设,它们用两种完全不同的通信方式,所以接线时谁也不影响谁:
| 器件 | 接 ESP32-S3 | 用的是什么总线 |
|---|---|---|
| DHT11 DATA | GPIO4 | 单总线(一根数据线自己玩) |
| OLED SDA / SCL | GPIO8 / GPIO9 | I2C(两根线,可挂多设备) |
| 两者 VCC / GND | 3.3V / GND(共用) | —— |
这张表和前两节是一致的,没有任何改动——DHT 那节 DATA 用的就是 GPIO4,OLED 那节 SDA/SCL 用的就是 GPIO8/GPIO9。你前面怎么接的,现在原样接上来即可。关键是理解它们为什么能共存:
- DHT11 走单总线:它和 ESP32-S3 之间只有 GPIO4 这一根数据线,自成一套微秒级时序,不占用 I2C。
- OLED 走 I2C:SDA(GPIO8)、SCL(GPIO9)是一条"总线",所有 I2C 设备共用这两根线、靠各自的地址(OLED 是 0x3C)区分,理论上还能再挂别的 I2C 设备(比如 BMP280 气压计)。
- 两者唯一的交集是电源:VCC 都接 3.3V、GND 都接 GND。面包板上把 3.3V 和 GND 各引一条"电源轨",两个器件都从轨上取电,最干净。
为什么强调"互不干扰"?因为新手容易有个错觉,以为接的东西多了会"打架"。不会。单总线和 I2C 是两套独立的电气协议,跑在不同引脚上,各读各的。你之所以能把它们拼起来,正是因为这种"互不打扰"是设计好的。这也是以后你往项目里加第三、第四个器件的底气。
选脚别踩 ESP32-S3 的雷区:DHT 的 GPIO4、OLED 的 GPIO8/GPIO9 都是普通脚,安全。避开 strapping 脚(GPIO0/3/45/46)、USB 脚(GPIO19/20)、连内部 flash/PSRAM 的 GPIO26–37——这些脚拿来做单总线或 I2C 会时好时坏甚至上不了电。这两节各自的接线表已经替你避开了,照接即可。
共用电源时别图省事把 DHT11 接到 5V。整个系统统一用 3.3V 供电——ESP32-S3 的 GPIO 是 3.3V 逻辑,DHT11 的 DATA 若被 5V 拉高,长期灌进 3.3V 引脚有损伤风险(这点在 DHT11 那节讲过)。同电压域,最省心。
第二步:先把两个组件都拉进来
这个工程要同时用到两个组件:读温湿度的 espressif/dht,和上屏的 u8g2 移植组件。ESP-IDF 5.x 的组件管理器一条命令拉一个,在工程目录下分别跑:
idf.py add-dependency "espressif/dht"
idf.py add-dependency "u8g2"
# u8g2 的具体移植组件名/版本以 components.espressif.com 上你选的那个为准
跑完它会在 main/ 下生成(或更新)idf_component.yml,把两个依赖都列上,大致是:
# main/idf_component.yml ——组件管理器据此一次拉齐两个依赖
dependencies:
espressif/dht: "^1.0.0"
u8g2: "*" # 名称/版本以你实际选的 u8g2 移植组件为准
idf:
version: ">=5.0"
下面整段是参考实现:DHT 部分按 espressif/dht 写,显示部分按 u8g2 移植组件写。但社区里 DHT 还有 UncleRus 的 esp-idf-lib dht、u8g2 的 ESP-IDF 移植也有好几个分支,函数名、回调签名、初始化宏可能略有差异。请以你实际拉到的那两个组件的 README / example 为准自校一遍,别照抄就上传。
第三步:完整整合代码(拼好的一整个工程)
下面这段是完整的、能直接编译烧录的整合工程,整段放进 main/main.c。它把前两节学的东西合到一个 app_main 里:先初始化 I2C 总线 + OLED + 把 DHT 引脚备好 → 进 while(1),每 2 秒读一次温湿度、清屏重画两行、推到屏上 → 读失败时屏上显示 Reading... 而不是乱码。
// 综合项目:DHT11(GPIO4) 读温湿度 → SSD1306 OLED(I2C, GPIO8/9) 每 2 秒刷新
#include "dht.h" // 来自 espressif/dht 组件
#include "driver/i2c_master.h" // ESP-IDF 5.x 新版 I2C 主机驱动
#include "u8g2.h" // u8g2 移植组件
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <stdio.h> // snprintf
static const char *TAG = "display";
#define DHT_GPIO GPIO_NUM_4 // DHT11 DATA 脚
#define DHT_KIND DHT_TYPE_DHT11 // DHT22 改成 DHT_TYPE_AM2301
#define I2C_PORT I2C_NUM_0
#define PIN_SDA GPIO_NUM_8
#define PIN_SCL GPIO_NUM_9
#define SSD1306_ADDR 0x3C // OLED 7 位地址,少数模块是 0x3D
static i2c_master_bus_handle_t s_bus;
static i2c_master_dev_handle_t s_dev;
static u8g2_t u8g2;
// ---- I2C 总线 + 挂 OLED 从设备(同 OLED 那节第二步)----
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,
.scl_speed_hz = 400000,
};
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);
}
// ---- u8g2 的字节回调:攒满一次事务再用 i2c_master 发出去(同 OLED 那节)----
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 的延时回调:接到 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;
}
// ---- 把一行字画到屏的某个 y 上(小工具,少写重复)----
static void oled_show(const char *line1, const char *line2)
{
u8g2_ClearBuffer(&u8g2); // 擦:清空内存画布
u8g2_SetFont(&u8g2, u8g2_font_ncenB10_tr); // 选清晰的英文字库
u8g2_DrawStr(&u8g2, 0, 25, line1); // 第一行(基线 y=25)
if (line2) u8g2_DrawStr(&u8g2, 0, 50, line2); // 第二行(基线 y=50)
u8g2_SendBuffer(&u8g2); // 推:整帧一次性送屏
}
void app_main(void)
{
// 1) 初始化 I2C + OLED
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 用 8 位地址
u8g2_InitDisplay(&u8g2);
u8g2_SetPowerSave(&u8g2, 0); // 0 = 唤醒(开屏)
// 2) 主循环:读 → 判断 → 画 → 等
while (1) {
float humidity = 0, temperature = 0;
// 一次读出温湿度;返回 ESP_OK 才是真数据,否则是失败码
esp_err_t err = dht_read_float_data(DHT_KIND, DHT_GPIO,
&humidity, &temperature);
if (err == ESP_OK) {
char line1[24], line2[24];
// drawStr 只认字符串,先把浮点数拼成文字
snprintf(line1, sizeof(line1), "Temp: %.1f C", temperature);
snprintf(line2, sizeof(line2), "Humi: %.0f %%", humidity);
oled_show(line1, line2);
ESP_LOGI(TAG, "%.1fC %.0f%%", temperature, humidity);
} else {
// 读失败:屏上给提示,别把脏值/乱码画上去
oled_show("Reading...", NULL);
ESP_LOGW(TAG, "DHT 读取失败 (%s)", esp_err_to_name(err));
}
vTaskDelay(pdMS_TO_TICKS(2000)); // DHT11 至少隔 1 秒读一次,留足余量
}
}
在工程目录下一条命令编译、烧录、看日志:
idf.py build flash monitor
(第一次用别忘了先 idf.py set-target esp32s3。)
你应该看到什么
- 编译烧录完成、自动进
monitor后,OLED 上先闪一下Reading...(开机第一次读还没拿到数据是正常的)。 - 大约两秒后,屏上稳定显示两行,像这样:
Temp: 25.3 C
Humi: 60 %
- 串口里同时滚出日志,每 2 秒一行:
I (1234) display: I2C bus ready, OLED at 0x3C
I (3245) display: 25.3C 60%
I (5251) display: 25.4C 60%
- 之后每 2 秒刷新一次。对着传感器哈一口气,几秒内湿度数字会明显往上跳(常常蹿到 70%、80%),温度也会微动——这是验证它"真的在感知现实"的最直接办法。
- 这块屏现在完全独立工作了:拔掉 USB、单用一个充电头给 ESP32-S3 供电,它照样显示。这就是"摆桌上能用"的意思。按
Ctrl + ]退出监视。
如果屏一直停在 Reading...,或屏全黑——别急,后面故障排查表分两半治它。
第四步:逐段讲清楚——这个工程是怎么拼起来的
能跑只是开始,看懂才算掌握。这个工程其实就是把两节课的骨架"对齐到同一个 app_main":初始化各做一次,读和画放进同一个 while(1)。
app_main 开头:把三件准备一次性做完
i2c_bus_init(); // I2C 总线 + 挂上 OLED
u8g2_Setup_ssd1306_i2c_128x64_noname_f(...); // 告诉 u8g2:128×64/SSD1306/走 I2C
u8x8_SetI2CAddress(..., SSD1306_ADDR << 1); // 注意左移 1 位
u8g2_InitDisplay(&u8g2);
u8g2_SetPowerSave(&u8g2, 0);
while 之前这一段是"开店前的准备",只跑一次:建好 I2C 总线、把 OLED 挂上去、让 u8g2 知道屏的型号和回调、点亮屏。DHT 这边不需要单独 begin——espressif/dht 的 dht_read_float_data 每次调用时自己配置那一个数据脚,你只要在循环里调它就行。
那行 SSD1306_ADDR << 1 是 I2C 新手最常栽的坑:我们给 i2c_master 配的是"7 位地址"0x3C,而 u8g2 内部习惯用含读写位的"8 位地址",两套数法差一位。屏不亮、地址对不上时,先想想是不是这里。这点 OLED 那节细讲过。
while(1):读 → 判断 → 画 → 等,四步一循环
while(1) 是这个作品的"心跳",每 2 秒跳一次,每跳一次干四件事:
- 读:
dht_read_float_data(...)向传感器要一次数据,温湿度通过指针&humidity、&temperature写回,函数本身返回一个esp_err_t错误码。 - 判断:
err == ESP_OK才说明这次读到的是真数据。 - 画:成功就把数字拼成两行字、
oled_show上屏;失败就显示Reading...。 - 等:
vTaskDelay(pdMS_TO_TICKS(2000))留足 DHT11 的采样时间,再进下一轮。
读失败为什么要专门处理
if (err == ESP_OK) {
// 拼字符串、上屏
} else {
oled_show("Reading...", NULL);
ESP_LOGW(TAG, "DHT 读取失败 (%s)", esp_err_to_name(err));
}
物理世界的读取本来就会偶尔失败——时序被打断、校验和不过,DHT11 又是个慢性子。espressif/dht 不像 Arduino 那套返回一个 nan 让你去 isnan 判断,而是走 ESP-IDF 的惯用风格:返回值专门报告成功/失败,数据走出参。所以你判断的是 err == ESP_OK,不是去查温湿度是不是 nan。
为什么非处理不可?如果不判断,直接把一次失败读出的脏值格式化上屏,你会看到乱跳的数字甚至离谱值闪过去——作品看起来就"坏了"。判断一下,失败时显示 Reading...,下次读成功了自然恢复。读失败那条还用了 ESP_LOGW(Warning 级)+ esp_err_to_name(err),把错误码翻成可读名字(比如 ESP_ERR_TIMEOUT),串口一眼看出失败原因。这一个 if 就是"能跑的代码"和"稳定的作品"之间的差别。面向真实硬件写代码,永远要给"读不到"留条退路。
格式化:把数字拼成人能看的一行字
char line1[24];
snprintf(line1, sizeof(line1), "Temp: %.1f C", temperature);
u8g2_DrawStr 只会画文字,不认识 float 数字,所以得先把数字"拼"成一串文字。snprintf 干的就是这事:
%.1f表示"这个浮点数保留 1 位小数",于是25.34变成"25.3";湿度用%.0f不留小数,60.0变成"60"。%%是转义——想在文字里打一个真正的百分号%,得写两个。line1[24]是预留的"文字容器",snprintf比普通sprintf多了个长度上限,写不爆这个容器,更安全。
拼好的 line1、line2 交给 u8g2_DrawStr 画到基线 y=25 和 y=50 两行——上下错开 25 像素,128×64 的屏正好放下两行不挤。注意 y 是文字基线(大致字底),不是顶部,所以写 25 而不是 0,写 0 字会跑到屏外被切掉。
擦 → 画 → 推:为什么是三连
u8g2_ClearBuffer(&u8g2); // 擦:清空内存里的画布
// ... 各种 DrawStr ...
u8g2_SendBuffer(&u8g2); // 推:把整块画布一次性送屏
u8g2 用的是"缓冲区"思路:你所有的 DrawStr 都画在单片机内存里的一块画布上,屏上此刻还没变;直到 SendBuffer() 才把整块画布一次性刷到屏上。好处是画面不会一块一块地闪,整帧一起换,看着干净。每轮循环开头 ClearBuffer 抹掉上一帧,重新画,避免新旧数字叠在一起糊成一团——我把这三步连同选字、画两行打包进 oled_show,循环里调一次就完整刷一帧。
第五步:让 AI 帮你拼装——把"零件知识"交给它
逻辑你现在能自己看懂了,但真要从零拼,AI 能帮你省掉查 API 的时间。诀窍是把芯片、框架、组件、引脚、行为一次性说清楚,它给的代码才好用。比如:
ESP32-S3 + ESP-IDF 5.x。DHT11 DATA 接 GPIO4,用 espressif/dht 组件的 dht_read_float_data;SSD1306 OLED 走 I2C(SDA=GPIO8, SCL=GPIO9, 地址 0x3C),用 u8g2 移植组件显示。写一个完整 main.c:app_main 里初始化 I2C 和 u8g2,while 循环每 2 秒读一次温湿度、在 OLED 上分两行显示,温度保留一位小数,dht 返回值不是 ESP_OK 时显示 "Reading..."。
一段好提示词里,这几样缺一不可:哪块芯片 + 哪个框架、哪个传感器接哪个引脚、用哪个组件、要什么行为、失败怎么办。把这些喂给它,它能直接给你一版能试的整合代码。
这正是 AI 时代做硬件的爽点:把零件级的知识(这个组件怎么调、那个引脚接哪)交给 AI 拼装,你的精力留给"我想做个什么"。但前提是你得能看懂它给的代码、能判断对错——尤其是 ESP-IDF 下组件 API 各分支有差异,AI 给的回调签名、初始化宏未必和你装的那个对得上。看得懂,AI 才是你的加速器;看不懂,它给的 bug 你连找都没法找。这也是为什么前两节要老老实实自己跑通。
第六步:故障排查——作品不对劲,按这张表查
整合之后出问题,无非是"哪一半没拼对"。照这个顺序查:
| 现象 | 最可能的原因 | 怎么办 |
|---|---|---|
| 屏完全不亮 | I2C 地址不对 / 屏型号或分辨率选错 / SDA·SCL 接反 | 用 i2c_master_probe(bus, addr, ...) 探地址(常见 0x3C,少数 0x3D);确认是 128×64 的 SSD1306;核对 SDA→GPIO8、SCL→GPIO9;别忘 u8g2 侧地址要 << 1 |
屏亮,但一直停在 Reading... |
DHT 读不到数(dht_read_float_data 不返回 ESP_OK) |
查 DATA 是否真接 GPIO4、杜邦线插紧;裸传感器补 10kΩ 上拉到 3.3V;vTaskDelay 别低于 1 秒 |
编译报找不到 dht.h |
DHT 组件没拉进来 | 重跑 idf.py add-dependency "espressif/dht",再 idf.py reconfigure |
编译报找不到 u8g2.h |
u8g2 组件没拉进来 | 确认 idf.py add-dependency 装了 u8g2 移植组件、idf.py reconfigure 过 |
| 屏满屏雪花 / 花屏 | 驱动型号或分辨率选错 | Setup_ssd1306_i2c_128x64 要和屏真实分辨率一致;常见还有 128×32,选错就花 |
| 数字越画越乱、叠字 | 漏了 ClearBuffer / 字符串没拼好 |
确认每帧开头 ClearBuffer()、结尾 SendBuffer()(都在 oled_show 里);snprintf 别越界 |
| 温度数字明显离谱(如 -100) | 型号选错(接的是 DHT22 当 DHT11 读) | 把 DHT_KIND 改成实际型号(DHT22 用 DHT_TYPE_AM2301) |
| 刷新太快、画面跳得心慌 | vTaskDelay 太小 |
保持 pdMS_TO_TICKS(2000);DHT11 数据更新周期约 1 秒,读太快既无意义又容易失败 |
| 两个器件单独都好、合起来一个不工作 | 多半是电源没共好 | 确认 DHT11 和 OLED 的 GND 接在同一条 GND 轨、VCC 都到 3.3V |
调试整合作品有个万能心法:分而治之。哪半边出问题,就把另一半临时旁路掉,单独验证有问题的那半。屏停在 Reading...,就回去单跑 DHT11 那节的纯串口版,确认传感器本身没坏、ESP_LOGI 能打出数;屏全黑,就先用 i2c_master_probe() 探地址,再单跑 OLED 那节的 Hello 例子。两半都各自确认好了,合起来自然就对了。
第七步:怎么往上扩——这块小屏是个好底座
这个作品做完,别急着拆。它是个极好的"地基",往上加一点点就是新项目:
- 加联网:让它连上 WiFi,把温湿度发到云端或你的手机——人在公司就能看家里的温湿度。这是迈进 L3 联网与 IoT 的第一个真实场景。
- 变成智能花盆:加一个土壤湿度传感器,屏上多显示一行土壤湿度;再接个小水泵,土干了自动浇——一个会自己照顾植物的花盆就成型了。
- 加按钮切页:接一个按钮(去抖你已经在前面学过),按一下切换显示页面:第一页温湿度、第二页最高/最低记录、第三页时间。你的小屏从"只会显示一件事"升级成"有菜单的设备"。
- 加阈值报警:在读到有效数据的分支里判断一下——湿度低于 40% 就
gpio_set_level点亮一颗 LED 提醒该加湿了(这就用上了 DHT11 那节变体里学的输出能力)。屏负责显示,LED 负责醒目报警,分工就出来了。
上面这些扩展里,显示中文("温度""湿度")是很多人第一个想加的。中文要用 u8g2_DrawUTF8 而不是 DrawStr、换一个带中文的字库、源文件存 UTF-8 编码,固件会明显变大。第一块屏先用 Temp: / Humi: 英文最省事,流程顺了再加中文不迟——细节见 OLED 那节第七步。
每一个扩展都不大,但每加一个,你的作品就更像一件"产品"。
动手挑战
光跑通别人的代码不算掌握,挑一个改出来:
- 加最高温记录:用一个
static float变量记住开机以来读到过的最高温度,在屏上加第三行(基线 y≈64 或把布局收紧)显示它。提示:每次err == ESP_OK时,把temperature和已记录的最高值比一下,更大就更新。 - 给屏加个表情:温度高于某个值(比如 30°C)多画一个
:(,舒适区间画:)。这是你第一次让作品"根据数据做判断、改变显示"——离"智能"又近了一步。 - 加阈值报警 LED:照"往上扩"里的思路,湿度低于 40% 点亮 GPIO2 上的 LED,恢复了就灭。输入(读)+ 判断 + 输出(屏 + LED),凑齐一个完整设备的骨架。
卡住了,把你的完整代码、用的两个组件名和想要的效果一起发给 AI 让它帮你改。能描述清楚问题,本身就是进步。
小结 · L2 毕业了
到这一步,回头看看你走了多远:
- 你能用 ESP-IDF 把传感器组件(
espressif/dht读数)和显示组件(u8g2 上屏)整合成一个独立工作的完整工程,定时刷新。 - 你理解了单总线(GPIO4)和 I2C(GPIO8/9)两套设备为什么能在一块 ESP32-S3 上互不干扰地共存。
- 你写出了带
esp_err_t返回码判断的代码——这是从"能跑的 demo"迈向"稳定的作品"的关键一跳,也是 ESP-IDF 产品级开发的惯用法。 - 你知道了这块小屏怎么往上扩(联网、花盆、切页、报警),每个方向通向哪。
更重要的是,你的硬件现在已经能感知(传感器)、表达(显示)、执行(GPIO 输出)三件事齐活了——这是一个完整设备该有的全部基本能力。L2 这一阶梯,到此毕业。
下一步:让你的作品走出这块桌子。下一阶梯 L3 联网与 IoT 教你把它接入网络——远程看读数、远程下指令,做出真正意义上的"物联网设备"。想先看看整条学习路径走到哪了,去 学习路线图对照一下你的进度。
本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。