简介:面向物联网嵌入式开发者的ESP32实战例程,基于ESP-IDF框架与VSCode环境编写,演示如何通过C语言读取DHT11数字温湿度传感器数据。压缩包共25个文件,以C源码(7个.c、7个.h)为主,配合4个json配置(VSCode调试与任务设置)、3个txt说明、sdkconfig、分区表csv及CMakeLists等构建文件,整体仅47KB,目录结构清晰,适合ESP32入门及项目原型验证。代码在ESP32-S3上运行验证,各引脚接线已在源码中定义,并附有详细注释、README及技术答疑入口,便于对照硬件调整和排查问题。目前已有675人学习浏览,对想快速上手ESP-IDF开发或集成DHT11传感器的开发者具有直接参考价值。
1. 物联网嵌入式开发里,ESP32 读 DHT11 为什么值得自己写一遍
物联网嵌入式开发中的环境监测项目,十个里有八个会选 DHT11:三根引脚、单总线、单价够低,几乎成了“入门传感器”的代名词。可一旦把主控从 Arduino 换成 ESP32,并在 ESP-IDF + VSCode 编程环境下裸写驱动,很多人第一次读到的不是温湿度,而是无穷无尽的超时和 CRC 错误。原因在于 DHT11 的数据线需要芯片在微秒级配合:先拉低 20ms 发开始信号,再释放总线,接着逐位读取 40bit 数据,0 和 1 的区别只是高电平的宽度差了几十微秒。Arduino 生态有现成库,ESP-IDF 却没有官方 DHT11 组件,自己实现一遍反而是理解单总线协议最快的方式。这篇文章就按我实际调试的顺序,把时序、硬件接线、工程模板、GPIO 驱动和验证手段讲透,给刚从点灯进阶到写驱动的人一条能照做的路径。
2. 决定成败的前置:DHT11 协议时序、上拉电阻和 ESP32 引脚电平
2.1 单总线时序:DHT11 的 40bit 数据帧和 0/1 判别
DHT11 的数据引脚是开漏结构,外部上拉电阻把总线默认拉高。主机发起一次通信要先拉低总线至少 18ms,再释放并拉高 20-40us,然后转入接收。传感器收到这个开始信号后,会先拉低约 80us 响应,再拉高 80us,之后才开始发送数据。这 40bit 的排列非常固定:8bit 湿度整数、8bit 湿度小数、8bit 温度整数、8bit 温度小数、8bit 校验和。校验和等于前四个字节相加后取低 8 位。
每一位都由 50us 左右的低电平开头,数据 0 的后续高电平只有约 26-28us,数据 1 的后续高电平则到 70us。所以判别办法可以很简单:在每位低电平结束后的第 40us 左右去采样总线,读到高就是 1,读到低就是 0。这个采样点选在 30us 和 55us 之间都安全,也是大多数软件驱动采用固定延时的依据。
下表是调试时必须记牢的时序参数:
| 阶段 | 电平 | 典型时长 | 说明 |
|---|---|---|---|
| 主机开始信号 | 低 | 18-20ms | 长度要够,不能少于 1ms |
| 主机释放后拉高 | 高 | 20-40us | 随后进入接收状态 |
| 传感器响应低 | 低 | 80us | 从机拉低总线 |
| 传感器响应高 | 高 | 80us | 之后进入数据位 |
| 数据位前导 | 低 | 50us | 每一位固定都有 |
| 数据 0 | 高 | 26-28us | 在 40us 处采样为低 |
| 数据 1 | 高 | 70us | 在 40us 处采样为高 |
| 结束位 | 低 | 50us | 之后总线恢复高 |
参数说明:不同批次 DHT11 的手册写的上下限略有差异,但采样点放在 40us 附近基本能通吃。不要试图用 GPIO 中断对每一位做精确计时,ESP32 的中断响应抖动对单总线来说并不可控,任务里轮询反而更稳定。相比把高电平宽度测出来再和 30us 比较,我更推荐固定延时采样,原因在第四章的代码里可以看到。
2.2 ESP32 GPIO 输入输出切换、上拉电阻与最低接线
引脚选择上,DHT11 的 DATA 建议接 GPIO4、GPIO5 或 GPIO16 这类普通 IO,避开 GPIO12、GPIO15 等影响启动模式或连接 flash 引脚的位置。供电直接给 3.3V,不要图省事接到 5V,DHT11 数据线的高电平和供电电压相关,5V 供电时数据线可能被拉到 5V,这会击穿 ESP32 的 GPIO。模块版通常已经带了一颗 4.7k 或 10k 上拉电阻,买裸芯片的话要在 DATA 和 3.3V 之间外接一颗 4.7k-10k 的电阻。没有上拉电阻时,ESP32 内部弱上拉勉强能读,但线一长就容易出现校验错误。
接线关系如下:
| DHT11 引脚 | 接到 ESP32 | 注意事项 |
|---|---|---|
| VCC | 3.3V | 不要用 5V |
| DATA | GPIO4 | 避开下载 boot 相关引脚 |
| GND | GND | 必须共地 |
初始化代码用 ESP-IDF 的 GPIO 驱动可以写成开漏模式:
#include "driver/gpio.h" #define DHT11_GPIO GPIO_NUM_4 void dht11_init(void) { gpio_set_direction(DHT11_GPIO, GPIO_MODE_INPUT_OUTPUT_OD); gpio_set_pull_mode(DHT11_GPIO, GPIO_PULLUP_ONLY); gpio_set_level(DHT11_GPIO, 1); }逻辑说明:GPIO_MODE_INPUT_OUTPUT_OD是开漏模式,向引脚写 0 会拉低总线,写 1 则释放总线交给上拉电阻拉高;gpio_set_pull_mode打开内部弱上拉,作为外部电阻不足时的补充。接下来所有收发都在这一个模式下完成,不需要来回切换方向和重新配置。
参数说明:如果模块上已经有 4.7k 上拉,可以把内部上拉关掉,改成GPIO_PULLUP_DISABLE。内部弱上拉只有约几十千欧,孤立使用时容易受寄生电容干扰,所以正规做法还是外部加电阻。
3. 在 VSCode 中搭建 ESP-IDF 工程并用最小模板跑通编译链路
3.1 安装 ESP-IDF 插件、设置工具链并解决 idf.py 路径问题
VSCode 里做 ESP-IDF 开发,最省事的是安装 Espressif 官方发布的 ESP-IDF 扩展。安装后按Ctrl+Shift+P,执行 “ESP-IDF: Configure ESP-IDF Extension”,选择自动下载工具链。工具链和 SDK 的安装路径不要出现中文、空格,Windows 下建议装在C:\Espressif。
新手遇到最多的报错是状态栏提示 “The path for ESP-IDF is not valid: /tools/idf.py not found.”。这通常是插件的 IDF 路径没指对,要么手动配置环境变量,要么在工程目录的.vscode/settings.json里写明路径:
{ "idf.espIdfPath": "C:/Espressif/frameworks/esp-idf-v5.3", "idf.toolsPath": "C:/Espressif", "idf.port": "COM3" }参数说明:espIdfPath指向包含idf.py的 esp-idf 根目录,toolsPath指向工具链目录,port填烧录串口。路径分隔符统一用正斜杠,Windows 下直接写反斜杠会在解析时出问题。
命令行里也可以验证:
echo $IDF_PATH ls $IDF_PATH/idf.pyWindows PowerShell 用echo $env:IDF_PATH。如果命令找不到idf.py,先执行 SDK 目录下的export.bat(Linux/Mac 是source export.sh)再开 VSCode。
3.2 用 idf.py create-project 创建工程并理解最小目录结构
打开 VSCode 集成终端,执行以下命令创建工程:
idf.py create-project esp32_dht11 cd esp32_dht11 idf.py set-target esp32创建后的目录结构如下:
esp32_dht11/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ └── main.c └── sdkconfig顶层CMakeLists.txt由create-project自动生成,正常情况下不用改。真正要动的是main/CMakeLists.txt,它通过idf_component_register描述本组件要编译哪些源文件。因为 DHT11 驱动会直接放在 main 目录,需要把dht11.c加进去:
idf_component_register(SRCS "dht11.c" "main.c" INCLUDE_DIRS ".")逻辑说明:SRCS是要编译的源文件,INCLUDE_DIRS是头文件搜索路径,填.就表示当前目录。这样main.c才能#include "dht11.h"。如果之后把驱动抽成独立组件,这里的写法还要再调整。
3.3 编译、烧录、监视:三条命令串起来
ESP-IDF 的命令行工作流非常固定:
idf.py build idf.py -p COM3 flash monitorbuild负责编译整个工程,flash把固件烧进芯片,monitor打开串口监视器,Ctrl+]退出。第一次编译会检查 ESP-IDF 版本和依赖,需要联网下载部分组件。如果 flash 时报找不到串口,先确认设备管理器的 COM 口号,再回看.vscode/settings.json里的idf.port配置。
在 VSCode 里也可以直接点击底部状态栏的芯片图标触发 build,编译日志里的错误行可以直接点击跳转到源码,比命令行查 output 更顺手。环境准备到这里,下一步就是写驱动。
4. 手写 DHT11 驱动:ESP-IDF 的 GPIO 开漏读写与精确延时
4.1 微秒延时选择:为什么用 esp_rom_delay_us 而不是 vTaskDelay
DHT11 的位宽在几十微秒级,FreeRTOS 的vTaskDelay最小粒度是一个系统 tick,默认 10ms,根本没法用。ESP-IDF 里做阻塞式微秒延时,最直接的是esp_rom_delay_us(),它来自 ROM 中的ets_delay_us函数,不依赖 FreeRTOS。
#include "esp_rom_sys.h" static inline void dht11_delay_us(uint32_t us) { esp_rom_delay_us(us); }逻辑说明:esp_rom_delay_us会占用 CPU,循环等待指定微秒,适合 DHT11 这种短时序收发。它的精度受 CPU 频率和中断影响,但 DHT11 对时序的容差足够大,固定延时采样方案里并不要求误差小于 1us。
参数说明:传入单位是微秒,不要传毫秒。dht11_delay_us(20000)才是延时 20ms 的正确写法。如果编辑器把esp_rom_sys.h标红,多半是 include path 没配置全,编译能过就是正常的。
另一个常见选择是用esp_timer_get_time()测量时间差,适合做超时判断,不适合做阻塞延时。因为轮询时间差会引入gpio_get_level的调用开销,测量起点和终点不稳。驱动里的 20ms 开始信号用esp_rom_delay_us,响应等待用esp_timer_get_time做超时,两者分工明确。
4.2 完整驱动实现:dht11.h 和 dht11.c
驱动头文件保持最小接口:
#pragma once #include "esp_err.h" typedef struct { float humidity; float temperature; } dht11_data_t; void dht11_init(void); esp_err_t dht11_read(dht11_data_t *out);接口说明:dht11_init负责配置 GPIO;dht11_read发起一次完整读取,成功返回ESP_OK,超时或校验失败返回对应的esp_err_t。
核心实现在dht11.c:
#include "dht11.h" #include "driver/gpio.h" #include "esp_rom_sys.h" #include "esp_timer.h" #define DHT11_GPIO GPIO_NUM_4 #define DHT11_TIMEOUT_US 500 static int dht11_wait_level(int level, int timeout_us) { int64_t deadline = esp_timer_get_time() + timeout_us; while (gpio_get_level(DHT11_GPIO) != level) { if (esp_timer_get_time() > deadline) { return -1; } } return 0; } void dht11_init(void) { gpio_set_direction(DHT11_GPIO, GPIO_MODE_INPUT_OUTPUT_OD); gpio_set_pull_mode(DHT11_GPIO, GPIO_PULLUP_ONLY); gpio_set_level(DHT11_GPIO, 1); } static esp_err_t dht11_start(void) { gpio_set_level(DHT11_GPIO, 0); esp_rom_delay_us(20 * 1000); gpio_set_level(DHT11_GPIO, 1); esp_rom_delay_us(30); // 等待传感器响应低电平 if (dht11_wait_level(0, DHT11_TIMEOUT_US)) return ESP_ERR_TIMEOUT; // 等待响应高电平 if (dht11_wait_level(1, DHT11_TIMEOUT_US)) return ESP_ERR_TIMEOUT; // 等待高电平结束,进入第一个数据位的低电平 if (dht11_wait_level(0, DHT11_TIMEOUT_US)) return ESP_ERR_TIMEOUT; return ESP_OK; } static uint8_t dht11_read_byte(void) { uint8_t byte = 0; for (int i = 7; i >= 0; i--) { // 等待当前位的低电平结束 if (dht11_wait_level(1, 100)) return 0xFF; // 在 40us 处采样,读到高即为 1 esp_rom_delay_us(40); if (gpio_get_level(DHT11_GPIO) == 1) { byte |= (1 << i); } // 等待当前位的高电平结束,回到低电平 if (dht11_wait_level(0, 100)) return 0xFF; } return byte; } esp_err_t dht11_read(dht11_data_t *out) { esp_err_t err = dht11_start(); if (err != ESP_OK) return err; uint8_t data[5]; for (int i = 0; i < 5; i++) { data[i] = dht11_read_byte(); } if (dht11_wait_level(1, 100)) return ESP_ERR_TIMEOUT; uint8_t sum = (data[0] + data[1] + data[2] + data[3]) & 0xFF; if (sum != data[4]) return ESP_ERR_INVALID_CRC; out->humidity = data[0] + data[1] * 0.1f; out->temperature = data[2] + data[3] * 0.1f; return ESP_OK; }逻辑说明:dht11_wait_level用esp_timer_get_time设置绝对截止时间,轮询直到引脚电平匹配,超时返回 -1。dht11_start先拉低 20ms,再释放 30us,之后三次等待分别对应响应低、响应高、响应结束后进入数据位。dht11_read_byte对每一位先等待低电平结束,然后固定延时 40us 采样;数据 0 的高电平此时已经结束,读到低电平,数据 1 的高电平还在持续,读到高电平。采样后等待位结束,确保下一轮从低电平状态开始。dht11_read读出 5 个字节后先做校验和,再换算成浮点温湿度。
参数说明:DHT11_TIMEOUT_US设为 500us,是因为响应阶段每个沿最多约 80us,500us 足够宽松;dht11_read_byte内部每步等 100us,因为每一位的周期约 100-120us,如果等待高电平超过 100us 说明协议错乱。超时返回的 0xFF 最终会因校验和不匹配被拦截,不会把脏数据放出去。
4.3 main.c 调用驱动,加日志确认读数
在主程序里,用 2 秒周期循环读取:
#include <stdio.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" #include "dht11.h" static const char *TAG = "dht11"; void app_main(void) { dht11_init(); vTaskDelay(pdMS_TO_TICKS(1000)); // 传感器上电稳定 dht11_data_t dht; while (1) { vTaskDelay(pdMS_TO_TICKS(2000)); // 两次读取间隔至少 1s esp_err_t err = dht11_read(&dht); if (err == ESP_OK) { ESP_LOGI(TAG, "hum=%.1f%% temp=%.1fC", dht.humidity, dht.temperature); } else { ESP_LOGW(TAG, "read failed: %s", esp_err_to_name(err)); } } }逻辑说明:app_main先初始化 GPIO,再延时 1 秒等 DHT11 内部上电完成。循环每 2 秒读一次,满足 DHT11 手册要求的读取间隔。失败时把ESP_ERR_TIMEOUT或ESP_ERR_INVALID_CRC打出来,方便区分问题方向。
参数说明:pdMS_TO_TICKS(2000)是 FreeRTOS 的毫秒转 tick 宏,默认 tick 频率 100Hz 时等于 200 tick。如果日志里持续出现ESP_ERR_TIMEOUT,优先检查接线和上拉电阻;出现ESP_ERR_INVALID_CRC则说明时序被干扰,可能是线太长、供电纹波大或读取过程被任务抢占。
5. 读取结果的验证:逻辑分析仪实测位宽和 FreeRTOS 下的稳定轮询
5.1 用逻辑分析仪验证数据 0 和数据 1 的采样点
如果手头有逻辑分析仪,把它接到 GPIO4 和 GND,采样率设为 10MHz 以上,触发条件选下降沿。抓一次完整的 20ms 开始信号加 40bit 数据帧,重点量每个高电平宽度。数据 0 应在 26-28us 附近,数据 1 在 70us 附近,且每一位前都有约 50us 低电平。如果测出来数据 0 的宽度超过 35us,说明总线电容大或采样点太靠后,可以缩短示例代码里的esp_rom_delay_us(40)到 35us 再试。没有逻辑分析仪时,可以在dht11_read_byte里临时把高电平宽度通过ESP_LOGI打出来,但要注意esp_timer_get_time统计本身会引入偏差,只能作为参考。
5.2 用 vTaskSuspendAll 避免任务抢占破坏接收时序
默认情况下,app_main里的循环会因为其他 FreeRTOS 任务的 tick 中断被抢占。ESP32 的 tick 中断很短,通常不影响 DHT11 读取,但如果日志里偶发ESP_ERR_INVALID_CRC,可以考虑在读取期间挂起调度器:
vTaskSuspendAll(); err = dht11_read(&dht); xTaskResumeAll();参数说明:vTaskSuspendAll只禁止任务上下文切换,不会关中断,20ms 的开始信号期间 WiFi 等系统中断仍能运行,因此不会破坏连接。读取过程约几十毫秒,挂起调度器对应用层影响可忽略。如果这样做后 CRC 错误清零,说明问题确实出在任务抢占。
如果做低功耗物联网设备,方向是延长读取周期并配合深度睡眠:每次唤醒后先等 1 秒让 DHT11 稳定再读取,读完保存数据后进入esp_light_sleep_start。DHT11 本身测量周期长,不适合做高频采集,连续读取时传感器自身发热也会让温度读数偏高,2 秒间隔已经是它的工作上限;真要追求精度,同价位选 SHT30 或 AHT20 是更合适的方案。
本文还有配套的精品资源,点击获取