ESP32手写DHT11驱动:单总线时序与ESP-IDF实战
2026/9/16 6:50:06 网站建设 项目流程

简介:面向物联网嵌入式开发者的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每一位固定都有
数据 026-28us在 40us 处采样为低
数据 170us在 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注意事项
VCC3.3V不要用 5V
DATAGPIO4避开下载 boot 相关引脚
GNDGND必须共地

初始化代码用 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.py

Windows 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.txtcreate-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 monitor

build负责编译整个工程,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_levelesp_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_TIMEOUTESP_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 是更合适的方案。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询