1. 从一个“翻车”现场说起:为什么DHT11值得单独写一篇
刚接触ESP32那会儿,我接的第一个传感器就是DHT11。原因很简单——便宜、常见、教程多,几乎每个入门套件里都躺着一颗蓝壳的小方块。但真正上手之后才发现,这颗看起来人畜无害的传感器,坑比想象中多得多:读出来全是nan、数据偶尔跳变、换根杜邦线就罢工、跑一晚上第二天早上发现湿度恒定在95%……这些问题在论坛里一搜一大把,但真正把原因讲透的帖子并不多。
这篇内容就是把我用ESP-IDF驱动DHT11温湿度传感器这段时间踩过的坑、验证过的方案、以及一些不太容易在官方文档里找到的细节,完整地梳理一遍。核心关键词就三个:ESP-IDF、DHT11、温湿度传感器。我会从传感器本身的单总线时序讲起,一直讲到ESP-IDF下怎么配置工程、怎么写驱动、怎么排查读不到数据的问题,最后再聊聊数据滤波和长期运行的稳定性处理。
适合谁看?如果你手上有一块ESP32开发板、一颗DHT11,想用ESP-IDF(而不是Arduino框架)把它跑起来,并且希望读出来的数据是靠谱的、能长期跑的,那这篇内容基本能覆盖你的需求。如果你只是想快速点亮看看效果,Arduino的DHT库确实更省事,但ESP-IDF下自己写一遍时序,你会对单总线协议和ESP32的GPIO操作有完全不一样的理解。
需要提前说明的是,DHT11本身是一颗精度有限的传感器——温度±2℃、湿度±5%RH,采样率最快1Hz。它不是用来做精密测量的,拿它做室内环境趋势监测、做个小气象站、做个联动风扇的触发源,都挺合适;但如果你要拿它做数据记录仪或者对标工业级传感器,那选型阶段就该换DHT22或者SHT系列了。这个前提先摆清楚,后面的讨论才有意义。
2. DHT11到底是怎么工作的:单总线时序拆解
2.1 一颗“抠门”到只有一根数据线的传感器
DHT11最让人又爱又恨的地方,就是它只用一根数据线完成双向通信。这根线既要主机发启动信号,又要传感器回传40位数据,中间还得靠上拉电阻维持空闲高电平。这种设计叫单总线(1-Wire),但注意,它并不是标准意义上的1-Wire协议(那个是Maxim的专利协议),只是借用了“一根线”的思路,时序完全是DHT11自己定义的。
它的引脚一共就4个(模块版通常只引出3个):VCC、DATA、GND,还有一个NC空脚。供电范围3.3V到5.5V,这意味着它可以和ESP32的3.3V系统直接对接,不需要电平转换——这一点比很多5V传感器省心。但省心的代价是,它对时序极其敏感,尤其是微秒级的电平持续时间,稍微被中断打断一下,整个通信就废了。
我个人的理解是:DHT11的通信过程像两个人用摩尔斯电码对话,但节奏是固定的,谁快一点慢一点都会让对方听不懂。主机先敲一段“我要说话了”的节奏,传感器回一段“我准备好了”,然后传感器用不同长度的高电平表示0和1。整个过程大概4毫秒左右,40位数据加前后握手,时间窗口非常窄。
2.2 完整通信流程:从启动信号到40位数据
把一次完整的读取拆开,大概是这么几个阶段:
第一阶段:主机发送启动信号。主机把数据线拉低至少18毫秒,然后释放(拉高)20到40微秒。这个18毫秒的低电平是“唤醒”信号,告诉传感器“准备干活了”。为什么是18毫秒?因为DHT11内部有个电容在充电,需要足够长的低电平让它完成复位。实测下来,低于15毫秒经常读不到,所以我一般直接给20毫秒,留足余量。
第二阶段:传感器响应。传感器检测到主机释放总线后,会先拉低80微秒,再拉高80微秒,作为“我收到了”的应答。这两个80微秒是判断传感器是否在线的关键——如果主机释放后一直读到高电平,说明传感器没响应,要么没接好,要么坏了。
第三阶段:传输40位数据。每一位数据都以一个50微秒的低电平开始,然后高电平的持续时间决定这一位是0还是1:高电平持续26到28微秒表示0,持续70微秒表示1。40位数据的顺序是:湿度整数8位、湿度小数8位、温度整数8位、温度小数8位、校验和8位。DHT11的小数部分实际上恒为0,但协议里保留了这8位,校验和等于前四个字节相加取低8位。
这里有个容易忽略的点:数据位的高电平持续时间判断,不能只靠一次采样。因为ESP32跑FreeRTOS,任务调度、中断都可能让采样点偏移。所以正确的做法是,在检测到低电平结束后,用一个循环去测量高电平持续了多久,而不是在固定时间点读一次电平。这个细节后面写代码时会具体展开。
2.3 为什么时序这么容易出错
很多人第一次写DHT11驱动,读出来全是超时或者乱码,根本原因往往不在代码逻辑,而在时序精度。ESP-IDF默认的FreeRTOS tick是100Hz,也就是10毫秒一个tick,而DHT11的位判断精度要求是微秒级。如果你在读取过程中调用了任何可能引起任务切换的函数(比如vTaskDelay),时序立刻就崩了。
另一个坑是中断。ESP32的WiFi、蓝牙、系统定时器都会产生中断,如果读取DHT11的时候正好赶上中断,那几十微秒的偏差足以让数据位判断错误。所以我在实际项目里,读取DHT11的那几毫秒会临时关掉中断(用portDISABLE_INTERRUPTS()),读完再打开。这个操作有风险——关中断时间太长会影响系统实时性,但DHT11一次读取也就4到5毫秒,权衡下来是可以接受的。
还有一个隐蔽的问题是上拉电阻。DHT11模块板载通常已经带了4.7k到10k的上拉电阻,如果你用的是裸传感器(4脚那种),必须自己加一个4.7k到10k的上拉电阻到VCC。我见过有人裸传感器直接接ESP32,读出来的数据时好时坏,加了上拉电阻立刻就稳了。上拉电阻的作用是在总线释放时把电平拉高,没有它,总线会处于浮空状态,电平不确定。
3. ESP-IDF工程搭建:从零开始配置
3.1 环境准备与工具链确认
在写代码之前,先确认你的ESP-IDF环境是完整的。我用的是ESP-IDF v5.x版本,安装方式是通过官方安装器(ESP-IDF Tools Installer),Windows和Linux下都有对应的安装包。安装完成后,在终端里执行idf.py --version,能正常输出版本号就说明环境没问题。
如果你之前装过旧版本,建议先清理一下环境变量,避免多个版本冲突。我遇到过idf.py调用的Python环境和实际工程需要的版本不一致,导致编译报奇怪的错。确认方式是进入一个示例工程,跑一次idf.py set-target esp32和idf.py build,能编译通过再开始自己的工程。
新建工程可以用idf.py create-project dht11_demo,然后进入目录。工程结构里,主要关注三个文件:main/CMakeLists.txt、main/dht11_demo.c(主程序)、以及根目录的CMakeLists.txt。ESP-IDF的构建系统是基于CMake的,组件化的结构让驱动代码可以独立成组件,但为了简单,我这次直接把DHT11驱动写在main里。
3.2 引脚选择与硬件连接
ESP32的GPIO几乎都可以用作DHT11的数据线,但有几个引脚要避开:GPIO6到GPIO11通常接的是SPI Flash,用了会直接崩;GPIO34到GPIO39是输入-only,不能输出,而DHT11需要主机先输出低电平,所以这些引脚也不行。我一般选GPIO4或者GPIO5,这两个引脚在大多数开发板上都引出来了,而且没有特殊功能冲突。
接线就三根:VCC接3.3V,GND接GND,DATA接GPIO4。如果你用的是模块版,模块上通常有LED和上拉电阻,直接插杜邦线就行。如果是裸传感器,记得在DATA和VCC之间加一个4.7k到10k的电阻。线长建议不要超过20厘米,太长的线会引入寄生电容,导致边沿变缓,时序判断出错。我试过用50厘米的杜邦线,读十次错三次,换成15厘米的线之后基本一次成功。
3.3 工程配置里的关键选项
在idf.py menuconfig里,有几个配置项会影响DHT11的读取稳定性:
- FreeRTOS tick rate:默认是100Hz,可以改成1000Hz,这样任务调度的粒度更细,但会增加系统开销。我一般保持默认,因为DHT11读取期间是关中断的,tick rate影响不大。
- CPU frequency:默认160MHz,可以超到240MHz。频率越高,微秒级延时的精度越好。如果你的项目对功耗不敏感,建议设成240MHz。
- GPIO调试输出:如果读取有问题,可以临时打开GPIO的调试日志,看看电平变化是否符合预期。不过这个日志量很大,排查完记得关掉。
还有一个容易被忽略的点:编译优化等级。默认是-Og,如果改成-O2,某些延时循环可能被编译器优化掉,导致时序变化。我在写微秒延时时,会在循环里加__asm__ volatile("nop")或者用esp_rom_delay_us,避免被优化。
4. 驱动代码逐段拆解:从GPIO操作到数据校验
4.1 微秒级延时与GPIO封装
ESP-IDF提供了esp_rom_delay_us函数,可以精确延时微秒级。但要注意,这个函数在中断关闭的情况下才能保证精度。我封装了两个基础函数:
static void dht11_delay_us(uint32_t us) { esp_rom_delay_us(us); } static void dht11_set_pin_output(gpio_num_t pin) { gpio_set_direction(pin, GPIO_MODE_OUTPUT); } static void dht11_set_pin_input(gpio_num_t pin) { gpio_set_direction(pin, GPIO_MODE_INPUT); } static int dht11_read_pin(gpio_num_t pin) { return gpio_get_level(pin); }这里的关键是动态切换GPIO方向。DHT11通信过程中,主机先输出启动信号,然后要切换成输入模式去读传感器响应。如果一直保持输出模式,总线会被主机强行拉高或拉低,传感器没法回传数据。这个切换时机很关键:主机拉低18毫秒后,拉高20到40微秒,然后立刻切成输入模式,等待传感器拉低。
4.2 启动信号与响应检测
启动信号的代码大概是这样:
static esp_err_t dht11_start(gpio_num_t pin) { dht11_set_pin_output(pin); gpio_set_level(pin, 0); dht11_delay_us(20000); // 拉低20ms gpio_set_level(pin, 1); dht11_delay_us(30); // 拉高30us dht11_set_pin_input(pin); // 等待传感器拉低 uint32_t timeout = 0; while (dht11_read_pin(pin) == 1) { if (++timeout > 100) return ESP_ERR_TIMEOUT; dht11_delay_us(1); } // 等待传感器拉高 timeout = 0; while (dht11_read_pin(pin) == 0) { if (++timeout > 100) return ESP_ERR_TIMEOUT; dht11_delay_us(1); } // 等待传感器再次拉低(响应结束) timeout = 0; while (dht11_read_pin(pin) == 1) { if (++timeout > 100) return ESP_ERR_TIMEOUT; dht11_delay_us(1); } return ESP_OK; }这段代码里,三个while循环分别等待传感器拉低、拉高、再拉低。每个循环都有超时保护,避免传感器没接好时死循环。超时阈值我设的是100微秒,实际测试下来,传感器响应时间在80微秒左右,100微秒足够覆盖。
注意:这里的超时判断用的是循环计数加1微秒延时,不是精确的100微秒。因为每次循环还有函数调用开销,实际超时时间会比100微秒长一些。这个精度对DHT11来说够用,但如果你要更精确,可以用
esp_timer_get_time()来计时。
4.3 40位数据的读取与0/1判断
读取数据位的核心逻辑是:每一位先等50微秒低电平结束,然后测量高电平持续了多久。如果高电平在28微秒内结束,就是0;如果持续到70微秒左右,就是1。代码实现:
static esp_err_t dht11_read_bit(gpio_num_t pin, int *bit) { uint32_t timeout = 0; // 等待低电平结束 while (dht11_read_pin(pin) == 0) { if (++timeout > 100) return ESP_ERR_TIMEOUT; dht11_delay_us(1); } // 测量高电平持续时间 timeout = 0; while (dht11_read_pin(pin) == 1) { if (++timeout > 100) return ESP_ERR_TIMEOUT; dht11_delay_us(1); } // 高电平持续时间大于40us判为1,否则为0 *bit = (timeout > 40) ? 1 : 0; return ESP_OK; }这里的阈值40微秒是个经验值。理论上0的高电平是26到28微秒,1是70微秒,中间值大概在50微秒。我取40微秒作为分界,是因为实际测量中,由于循环开销,0的计数值会偏大一些,1的计数值会偏小一些。你可以根据自己板子的实际情况微调这个阈值,方法是把每次读取的timeout值打印出来,观察0和1的分布。
读取40位数据的循环:
static esp_err_t dht11_read_data(gpio_num_t pin, uint8_t *data) { uint8_t bytes[5] = {0}; for (int i = 0; i < 40; i++) { int bit; esp_err_t ret = dht11_read_bit(pin, &bit); if (ret != ESP_OK) return ret; bytes[i / 8] <<= 1; bytes[i / 8] |= bit; } // 校验 uint8_t checksum = bytes[0] + bytes[1] + bytes[2] + bytes[3]; if (checksum != bytes[4]) { return ESP_ERR_INVALID_CRC; } memcpy(data, bytes, 5); return ESP_OK; }校验和这一步非常重要。DHT11在时序受干扰时,偶尔会读出错误的数据,但校验和能过滤掉大部分错误。如果校验失败,直接丢弃这次数据,等下一次读取。我实测下来,不加校验的话,大概每几十次会有一次数据跳变;加了校验之后,基本没有异常数据。
4.4 关中断与临界区保护
前面提到,读取DHT11的几毫秒内要关中断。在ESP-IDF里,可以用portDISABLE_INTERRUPTS()和portENABLE_INTERRUPTS(),但这两个宏在FreeRTOS下会操作中断屏蔽寄存器,影响范围比较大。更精细的做法是用portENTER_CRITICAL()和portEXIT_CRITICAL(),它们会保存当前中断状态,退出时恢复。
portENTER_CRITICAL(&dht11_spinlock); esp_err_t ret = dht11_start(pin); if (ret == ESP_OK) { ret = dht11_read_data(pin, data); } portEXIT_CRITICAL(&dht11_spinlock);这里的dht11_spinlock是一个portMUX_TYPE变量,需要在使用前初始化。关中断的时间大概4到5毫秒,对WiFi和蓝牙的实时性会有轻微影响,但不会导致连接断开。如果你的项目对实时性要求极高,可以考虑用RMT(远程控制外设)来硬件解码DHT11时序,不过那是另一个话题了。
5. 数据滤波与长期运行稳定性
5.1 为什么原始数据不能直接用
DHT11的原始数据有两个问题:一是偶尔的跳变,即使校验通过,也可能出现湿度突然跳到95%或者温度跳到0℃的情况;二是采样率限制,最快1Hz,如果你每秒读多次,后面的读取会失败或者返回上一次的数据。
我在实际项目里,一般会做一个简单的滑动平均滤波。比如连续读5次,去掉一个最大值和一个最小值,剩下的取平均。这样能平滑掉大部分毛刺。代码实现可以用一个环形缓冲区:
#define FILTER_SIZE 5 static float temp_buffer[FILTER_SIZE]; static int buffer_index = 0; float filter_temperature(float new_temp) { temp_buffer[buffer_index] = new_temp; buffer_index = (buffer_index + 1) % FILTER_SIZE; float sum = 0, max = -100, min = 200; for (int i = 0; i < FILTER_SIZE; i++) { sum += temp_buffer[i]; if (temp_buffer[i] > max) max = temp_buffer[i]; if (temp_buffer[i] < min) min = temp_buffer[i]; } return (sum - max - min) / (FILTER_SIZE - 2); }这个滤波对温度效果不错,湿度也可以用同样的逻辑。注意,滤波窗口不要太大,否则响应会变慢。5个样本在1Hz采样下就是5秒的延迟,对于环境监测来说可以接受。
5.2 读取间隔与任务调度
DHT11的采样率是1Hz,也就是两次读取之间至少间隔1秒。如果你在FreeRTOS任务里循环读取,一定要加vTaskDelay(pdMS_TO_TICKS(2000)),给足2秒间隔。我见过有人不加延时,结果读出来的数据一直是同一个值,因为传感器还没完成上一次转换。
任务优先级也要注意。DHT11读取任务不需要太高的优先级,但也不能太低,否则会被其他任务频繁打断。我一般设成5(默认是5),和WiFi任务错开。如果发现读取失败率偏高,可以临时把优先级提到10,读完再降回来。
5.3 常见异常数据与处理策略
实际运行中,我遇到过几种典型的异常:
| 异常现象 | 可能原因 | 处理策略 |
|---|---|---|
| 读数全为0 | 传感器未响应,启动信号没被识别 | 检查接线、上拉电阻、供电 |
| 读数全为255 | 总线一直被拉高,传感器没拉低 | 检查GPIO方向切换、引脚是否被占用 |
| 湿度恒定95% | 传感器受潮或损坏 | 更换传感器,避免在高湿环境长期运行 |
| 温度跳变±5℃ | 电源纹波或中断干扰 | 加滤波电容,关中断读取 |
| 校验和频繁失败 | 时序偏差,线太长 | 缩短线长,降低CPU频率试试 |
这张表是我自己排查时总结的,基本覆盖了90%的问题。其中“读数全为255”最容易被误判为传感器坏了,其实往往是GPIO方向没切回来,主机一直在输出高电平。
6. 实操心得与避坑清单
6.1 供电与去耦电容
DHT11虽然供电范围宽,但对电源纹波比较敏感。如果和WiFi一起用,WiFi发射时的电流突变会导致DHT11读数异常。我的做法是在DHT11的VCC和GND之间并一个100nF的陶瓷电容,再并一个10uF的钽电容。这两个电容成本不到一毛钱,但能显著提升稳定性。
另外,如果你用的是ESP32开发板上的3.3V输出,要注意这个3.3V的电流能力。有些开发板的LDO只能输出500mA,WiFi一开,电压会跌到3.0V以下,DHT11就可能工作不正常。这种情况建议单独给DHT11供电,或者换一个电流能力更强的开发板。
6.2 杜邦线的坑
杜邦线看起来方便,但接触电阻和寄生电容是DHT11的大敌。我遇到过同一套代码,换一根杜邦线就从“十读九错”变成“十读十对”。后来我养成了一个习惯:调试DHT11时,先用最短的线(10厘米以内),确认代码没问题,再换长线测试。如果长线必须用,可以考虑用屏蔽线,或者把上拉电阻改小到4.7k。
还有一个细节:杜邦线的公头插在面包板上,用久了会松动。我有一次排查了半天,最后发现是面包板接触不良。所以如果你读不到数据,先换一组插孔,或者直接焊接,排除接触问题。
6.3 读取失败的重试机制
即使一切配置正确,DHT11偶尔也会读取失败,这是正常的。我的做法是在应用层加一个重试机制:连续读3次,只要有一次成功就采用,3次都失败才报错。这样能把偶发的时序干扰过滤掉,又不会因为一次失败就中断整个任务。
esp_err_t dht11_read_with_retry(gpio_num_t pin, uint8_t *data, int max_retry) { for (int i = 0; i < max_retry; i++) { esp_err_t ret = dht11_read_once(pin, data); if (ret == ESP_OK) return ESP_OK; vTaskDelay(pdMS_TO_TICKS(100)); } return ESP_FAIL; }重试间隔我设的是100毫秒,因为DHT11两次读取之间至少要1秒,但重试是在同一次读取周期内的,100毫秒足够让总线恢复空闲。
6.4 长期运行的看门狗处理
如果你的设备要7x24小时运行,DHT11任务可能会因为某种原因卡死。ESP-IDF有任务看门狗(TWDT),可以监控任务是否按时喂狗。我一般会在DHT11任务里加esp_task_wdt_reset(),但如果读取本身卡住了,喂狗也救不了。更稳妥的做法是加一个超时机制:如果DHT11任务超过10秒没有成功读取,就重启任务或者重启设备。
这个超时机制可以用一个独立的定时器实现,也可以用FreeRTOS的软件定时器。我倾向于用硬件定时器,因为软件定时器依赖任务调度,如果任务卡死,软件定时器也可能不触发。
7. 从DHT11延伸出去:还能怎么玩
DHT11跑通之后,其实可以作为一个基础节点,往上叠加更多功能。比如把温湿度数据通过MQTT上报到本地服务器,配合继电器做温控风扇;或者用LCD1602显示实时数据,做成一个桌面小气象站。这些扩展里,DHT11的驱动部分是复用的,核心还是那套单总线时序。
如果你要换DHT22,代码改动很小,主要是数据格式不同:DHT22的温度和湿度都是16位,湿度高8位在前,温度也是16位且最高位表示正负。时序上DHT22的启动信号和DHT11一样,但数据位的高电平持续时间不同,0是22到30微秒,1是68到75微秒。所以换传感器时,主要改dht11_read_bit里的阈值判断。
再往上走,如果你觉得单总线太折腾,可以换SHT30或者AHT20,这两个都是I2C接口,ESP-IDF有现成的I2C驱动,读取稳定性比DHT11高一个档次。但DHT11的价值在于便宜和简单,适合入门和批量部署。我个人的建议是:学习阶段用DHT11把单总线时序吃透,实际项目里如果对精度和稳定性有要求,直接上I2C传感器,省下来的调试时间远比传感器差价值钱。
最后分享一个我常用的调试技巧:在DHT11读取失败时,把GPIO的电平变化用逻辑分析仪抓下来,和DHT11数据手册的时序图对比。一眼就能看出是启动信号不够长、还是响应检测超时、还是数据位判断错误。没有逻辑分析仪的话,也可以用ESP32的RMT外设做简单的脉宽捕获,把每次读取的电平持续时间打印出来,效果差不多。这个习惯帮我省下了大量“盲猜”的时间。