DHT11这颗传感器,可以说是做嵌入式、单片机入门绕不开的一颗料。一颗小小的元件,能把温度和湿度两个物理量,通过一根数据线送进单片机,靠的是一套非常精巧的单总线通信协议。这篇文章我就从实际工程的角度,把DHT11的器件原理、单总线时序、代码实现完整拆开讲清楚,适合正在做课程设计、智能家居节点、环境监测小项目的朋友参考。不管是51、STM32还是树莓派,只要真正读懂了这套协议和代码骨架,换平台只是换个寄存器操作方式而已,思路完全可以复用。
1. 项目概述与DHT11器件原理拆解
1.1 DHT11到底是什么:传感元件与内置处理器的合体
很多人第一次接触DHT11,以为它就是一个简单的电阻式传感器,跟光敏电阻一样直接读模拟量。实际上DHT11内部远比想象中复杂:它把湿敏电阻、NTC热敏电阻和一个8位单片机封装在了一起。
湿敏电阻负责感知湿度变化,NTC热敏电阻负责感知温度变化,这两个元件输出的模拟信号经过内部ADC采集后,交给内置的单片机做校准和换算,最终通过单总线协议把数字量输出给外部MCU。每个DHT11出厂前都经过校准,校准系数保存在OTP内存中,这就是为什么同型号不同批次的DHT11读数差异不大的原因。
DHT11的测量范围并不大:温度0到50摄氏度,精度正负2摄氏度;湿度20%到90%RH,精度正负5%RH。采样周期是1秒。看到这个参数,你应该明白这颗传感器定位就是低成本、入门级、精度要求不高的场景。别指望它做精密气象站,但在宿舍环境监测、智能鱼缸、大棚温度预警这类项目里完全够用。
1.2 为什么第一个温湿度传感器选DHT11
市面上的温湿度传感器有很多,DHT22、SHT30、SHT31、HDC1080等等。为什么要先学DHT11?我把它和几款常见传感器放在一起对比,你就清楚了。
| 传感器 | 接口类型 | 温度精度 | 湿度精度 | 采样周期 | 典型价格 |
|---|---|---|---|---|---|
| DHT11 | 单总线 | ±2℃ | ±5%RH | 1s | 几块钱 |
| DHT22 | 单总线 | ±0.5℃ | ±2%RH | 2s | 十几块 |
| SHT30 | I2C | ±0.3℃ | ±2%RH | 数ms | 十几块 |
| DS18B20 | 单总线 | ±0.5℃ | 无 | 数百ms | 几块钱 |
从这个表能看出来,DHT11没有一项参数是拔尖的,但它有一个巨大的优势:协议足够简单,只有一根数据线,时序逻辑非常直观。作为学习单总线通信的入门器件,再合适不过。而且它的读时序60us左右一个位,对延时精度要求不像DS18B20那么变态,用普通循环延时也能跑稳。
当然,如果你做的产品对功耗和精度有要求,DHT11并不合适。它内部有单片机,功耗比纯传感元件高;精度也摆在那里。但作为学习“单总线通信”这个知识点的载体,DHT11是性价比极高的选择。
1.3 引脚定义与最小硬件电路:嘉立创画原理图的几个要点
DHT11常见的封装有两种:四脚直插和模块板。四脚直插的引脚定义非常固定:VCC接电源正极(3.3V到5V都能工作)、DATA是数据线、NC悬空、GND接地。模块板一般集成了上拉电阻和电源指示灯,使用更方便,但学习原理时最好还是自己加上拉电阻。
最小电路其实就三样东西:DHT11本体、一个4.7kΩ上拉电阻、一个100nF去耦电容。上拉电阻接在DATA和VCC之间,去耦电容接在VCC和GND之间尽可能靠近DHT11的电源引脚。画PCB或者嘉立创画原理图时,有两点很多人会忽略:
第一,DATA引脚的走线不能太长太细,线长了分布电容变大,会影响单总线的边沿时序。第二,去耦电容一定要靠近DHT11的电源引脚,不要隔得老远接到芯片附近。DHT11在工作瞬间会有电流波动,离得太远滤波效果就差了。
注意:如果是用STM32这样的3.3V主控,DHT11可以直接用3.3V供电,不需要电平转换。但如果主控是5V的,DHT11数据返回的高电平也是5V,大多数3.3V主控引脚是容忍5V的,具体还要查主控手册确认。
2. 单总线通信协议逐位拆解
2.1 单总线的核心思想:一根线完成双向通信
单总线(1-Wire)最核心的特点是:主机和从机共用一根数据线,数据的发送和接收都在这一根线上分时完成。不像SPI有专门的MOSI和MISO线,也不像I2C有独立的SCL和SDA。单总线更像早期的电话线,同一时刻只能一个人说话,其他人只能听。
DHT11使用的就是这种单总线机制,但和Dallas公司的标准1-Wire协议又不完全一样,没有设备地址、没有ROM指令,是简化版的单总线。主机通过拉低总线产生起始信号,DHT11响应后,直接把40位数据按位发送出来。整个过程由主机控制时序节奏,从机只能在规定的时间窗口内输出电平。
这里有一个很重要的设计思想:一根线上既要让主机发命令,又要让从机发数据,就必须靠严格的时间窗口来区分方向。谁拉低总线多久、什么时候释放,都在时序规范里写死了。所以搞懂DHT11的第一步,就是把时序图刻在脑子里。
2.2 一次完整读取的全过程:从起始信号到数据输出
DHT11一次完整的数据读取,在逻辑上分为四个阶段。我用文字把时序拆开描述一遍:
- 空闲状态:总线被上拉电阻维持在3.3V或5V的高电平。
- 主机发起始信号:主机把总线拉低,持续至少18毫秒,然后释放总线。这18毫秒是告诉DHT11“准备好,我要读你了”。
- 主机释放总线后延时20到40微秒,等着DHT11响应。
- DHT11响应:DHT11把总线拉低80微秒,表示收到起始信号,然后再拉高80微秒,表示“准备发送数据”。
- 数据位传输:DHT11连续输出40位数据,每一位都以50微秒低电平开始,然后用高电平的持续时间表示0还是1。
- 总线释放:40位数据发送完毕后,DHT11释放总线,上拉电阻将总线恢复为高电平,等待下一次读取。
从这个流程能看到,整个交互过程大约需要20多毫秒(主要是起始信号的18毫秒)。很多人在循环里频繁读DHT11发现数据不对,就是因为没有注意到采样周期至少1秒这个限制。
2.3 0和1的编码规则:50微秒低电平之后见分晓
单总线最关键的编码方式,就在这里。DHT11传输每一位数据时,不管这一位是0还是1,开头都是先把总线拉低50微秒,然后再释放,让上拉电阻把总线拉高。接下来的高电平持续时间决定了这一位的值。
表示“0”:高电平持续时间只有26到28微秒,很快又变回低电平开始下一位。 表示“1”:高电平持续时间约70微秒,明显比0长。
所以判断一位是0还是1,核心就是测量“低电平结束之后,高电平持续了多长”。实际编程时常用的做法是:等到50微秒低电平结束(检测到上升沿),然后延时40到45微秒,再读总线电平。此时如果是1,高电平还在持续(70微秒 > 40微秒),读到高电平;如果是0,高电平早就结束了(28微秒 < 40微秒),读到低电平。
这个采样点的选择非常讲究。如果延时太短,读0的时候可能高电平还没结束,会误判成1;如果延时太长,读1的时候高电平已经结束,会误判成0。40到45微秒是一个比较安全的窗口。
2.4 40位数据格式与校验算法
DHT11一次输出40位数据,也就是5个字节。按先后顺序排列如下:
| 字节 | 含义 |
|---|---|
| 第1字节 | 湿度整数部分 |
| 第2字节 | 湿度小数部分 |
| 第3字节 | 温度整数部分 |
| 第4字节 | 温度小数部分 |
| 第5字节 | 校验和 |
校验和的计算规则非常朴素:把前4个字节加起来,取低8位,如果等于第5个字节,说明这次读取的数据有效。举例来说,收到的5个字节是0x2D、0x00、0x19、0x00、0x46,那么湿度=(0x2D=45)%RH,温度=(0x19=25)℃,校验和=0x2D+0x00+0x19+0x00=0x46,校验通过。
注意DHT11的小数位通常都是0,因为它的分辨率本来就只有整数,但协议中仍然保留了小数位。有些应用场景需要更高分辨率的读数,建议直接换DHT22。
提示:很多初学者读到数据后不做校验,结果偶尔出现一个明显的错误值(比如湿度200%),可能导致控制逻辑误动作。无论项目多简单,校验这一步都不要省。
3. 代码实现:从延时函数到数据解析全流程
3.1 准备工作:GPIO模式与微秒延时方案
DHT11的数据线是双向的,主机要先输出起始信号,然后切换成输入模式读取数据。所以在代码里,GPIO的模式要在输出和输入之间来回切换。我用STM32F1 + HAL库来写这套驱动,主要有三个准备要点。
GPIO模式怎么配:我习惯把DHT11的数据引脚配置为开漏输出模式。开漏输出的好处是,高电平并不靠引脚驱动,而是靠外部上拉电阻,这样输出高电平时引脚处于高阻态,切换到输入模式非常平滑,不会产生推挽冲突。如果你用的引脚恰好没有外部上拉,那就得配置为推挽输出,同时切换输入模式时还要重新初始化GPIO。
微秒延时怎么解决:HAL库自带的HAL_Delay是毫秒级,根本满足不了微秒级时序的要求。常见方案有两种,一是用SysTick计数自己做微秒延时,但SysTick一般被HAL库占用,容易冲突;二是用DWT(CoreSight调试单元)的CYCCNT计数器,这个计时器不占用任何设备,纯粹靠内核时钟周期计数,非常干净利落。
DWT延时代码如下:
static void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } static void DWT_Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }这段代码的核心原理就是利用内核计数器的累加,通过两个时刻的差值来计算经过的时间。配置好SystemCoreClock之后,延时的精度在几个微秒内,完全满足DHT11的时序要求。
3.2 DHT11驱动完整代码:STM32 + HAL库版本
驱动代码我分为初始化、起始信号、读取位、读取字节、读取完整数据五个部分。代码中每一步注释都写明时序要求,方便你对照时序图理解。
头文件部分简单定义引脚和结构体:
#ifndef __DHT11_H #define __DHT11_H #include "stm32f1xx_hal.h" #define DHT11_PORT GPIOB #define DHT11_PIN GPIO_PIN_8 typedef struct { uint8_t humidity_int; uint8_t humidity_dec; uint8_t temperature_int; uint8_t temperature_dec; uint8_t checksum; uint8_t valid; } DHT11_Data_TypeDef; void DHT11_Init(GPIO_TypeDef *port, uint16_t pin); uint8_t DHT11_ReadData(DHT11_Data_TypeDef *data); #endif驱动实现文件如下:
#include "dht11.h" #include "main.h" static GPIO_TypeDef *DHT11_Port; static uint16_t DHT11_Pin; void DHT11_Init(GPIO_TypeDef *port, uint16_t pin) { DHT11_Port = port; DHT11_Pin = pin; GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = pin; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(port, &GPIO_InitStruct); HAL_GPIO_WritePin(port, pin, GPIO_PIN_SET); } static void DHT11_SetOutputMode(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_Pin; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_Port, &GPIO_InitStruct); } static void DHT11_SetInputMode(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_Pin; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_Port, &GPIO_InitStruct); } static uint8_t DHT11_WaitLevel(uint8_t level, uint32_t timeout_us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = timeout_us * (SystemCoreClock / 1000000); while (HAL_GPIO_ReadPin(DHT11_Port, DHT11_Pin) == level) { if ((DWT->CYCCNT - start) > ticks) { return 0; // 超时 } } return 1; } static uint8_t DHT11_ReadBit(void) { // 等待50us低电平开始 if (!DHT11_WaitLevel(0, 100)) return 0xFF; // 等待上升沿,即低电平结束 if (!DHT11_WaitLevel(1, 100)) return 0xFF; // 低电平结束后延时40us DWT_Delay_us(40); // 采样总线电平 if (HAL_GPIO_ReadPin(DHT11_Port, DHT11_Pin) == GPIO_PIN_SET) { // 等待高电平结束,跳过剩余的位时间 DHT11_WaitLevel(0, 100); return 1; } else { return 0; } } static uint8_t DHT11_ReadByte(void) { uint8_t value = 0; for (int i = 0; i < 8; i++) { uint8_t bit = DHT11_ReadBit(); if (bit == 0xFF) return 0xFF; value = (value << 1) | (bit & 0x01); } return value; } uint8_t DHT11_ReadData(DHT11_Data_TypeDef *data) { if (data == NULL) return 0; // 发送起始信号 DHT11_SetOutputMode(); HAL_GPIO_WritePin(DHT11_Port, DHT11_Pin, GPIO_PIN_RESET); HAL_Delay(18); HAL_GPIO_WritePin(DHT11_Port, DHT11_Pin, GPIO_PIN_SET); DWT_Delay_us(30); // 切换输入模式,等待响应 DHT11_SetInputMode(); // 等待DHT11拉低总线(响应信号开始) if (!DHT11_WaitLevel(0, 100)) return 0; // 等待响应低电平结束 if (!DHT11_WaitLevel(1, 100)) return 0; // 等待响应高电平结束 if (!DHT11_WaitLevel(0, 100)) return 0; // 读取40位数据 uint8_t buf[5]; for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); if (buf[i] == 0xFF) { return 0; } } // 等待总线释放 DWT_Delay_us(50); // 校验 uint8_t sum = (buf[0] + buf[1] + buf[2] + buf[3]) & 0xFF; if (sum != buf[4]) { >__disable_irq(); uint8_t ret = DHT11_ReadData(&dht11_data); __enable_irq();需要注意的是,关闭中断的时间不能太长。如果系统里有时钟、通信这类对实时性要求高的外设,20多毫秒关中断会造成明显影响。更好的做法是在关中断前把已经启动的DMA传输等都处理好,或者考虑只关闭低优先级中断,保留高优先级中断。
3.4 主循环调用示例:给串口打印加上节流
完整的主函数调用其实很简单,但很多人会在循环里一直读,导致DHT11数据异常。前面说过DHT11采样周期是1秒,也就是两次读取间隔不能小于1秒,否则内部还没准备新数据,可能导致响应失败或者数据不更新。
我推荐用非阻塞的方式做延时:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); DWT_Init(); DHT11_Init(GPIOB, GPIO_PIN_8); DHT11_Data_TypeDef dht11_data; uint32_t last_read = HAL_GetTick(); while (1) { if (HAL_GetTick() - last_read >= 2000) { last_read = HAL_GetTick(); __disable_irq(); uint8_t ret = DHT11_ReadData(&dht11_data); __enable_irq(); if (ret && dht11_data.valid) { char msg[64]; sprintf(msg, "Humi: %d.%d%%RH, Temp: %d.%dC\r\n", dht11_data.humidity_int, dht11_data.humidity_dec, dht11_data.temperature_int, dht11_data.temperature_dec); HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), 100); } } } }这里每2秒读取一次,留足了余量。实际项目里如果只需要慢速环境监测,5秒甚至10秒读一次都行,功耗更低,也更稳定。
4. 常见问题与调试实战技巧
4.1 读回来全是FF或0xFF,应该从哪里排查
这是初学者遇到最多的问题。读取返回值永远是0xFF,或者读出来的数据全是FF,说明主机根本没有从总线上读到有效电平变化。我建议按下面这个顺序排查:
第一步检查硬件连接。VCC和GND有没有接反?DATA线接的是不是代码里配置的那个引脚?上拉电阻有没有焊?很多人用模块板时没接上拉电阻,但其实模块板已经自带了,这时候再外接一个贴片的也没关系。如果是纯DHT11裸芯片,漏了上拉电阻会导致总线一直悬空,读回来的数据完全是随机的。
第二步查逻辑电平。用万用表量DATA引脚,理论上空闲状态下应该是高电平。如果是0V,说明上拉没起作用或者数据线被拉低了;如果是3.3V正常,再用示波器看主机发出起始信号后有没有波形变化。
第三步查GPIO模式。开漏输出配置成推挽,切换到输入模式后又开了内部下拉,都会导致电平读不到。最容易犯的错误是初始化时只配置了输出模式,读数据时没有切到输入模式,导致引脚一直是输出状态。
4.2 能响应但数据不稳定、经常校验失败
明明能读到一些数据,一帧成功一帧失败,或者校验总是不过。这种情况十有八九不是协议理解问题,而是时序和硬件环境的问题。
供电不稳是最常见的原因。DHT11在工作时电流会变化,如果供电电源很弱,或者杜邦线太长太细,电源电压会跌落,导致内部逻辑混乱。解决办法:给VCC和GND之间就近加一个100nF电容,电源线换成短而粗的线。
第二个常见原因是读取间隔太短。DHT11采样周期是1秒,如果在1秒内频繁读取,内部单片机可能还在处理上一次的数据,这次响应会失败。代码里加一个“上次读取时间”的检查,强制两次读取间隔至少1秒,很多间歇性故障就消失了。
第三个原因在软件层面:延时函数不准。如果你的微秒延时是靠编译器优化后的空循环实现的,不同优化级别下面延时时间差异很大。我用DWT是因为它的延时完全由硬件计数决定,不受编译器影响,也不受中断影响,省心得多。
4.3 如何用示波器快速验证单总线时序
示波器是调这类时序问题的神器。把探头夹在DATA引脚上,触发方式设为下降沿触发,然后让单片机执行一次读取,就能抓到完整的交互波形。我实际调试时会重点看三个地方:
第一,主机起始信号是不是够长。规范要求至少18毫秒,很多人用HAL_Delay(18)是够的,但如果你用循环延时,实际可能只有几毫秒,DHT11根本就没醒。第二,DHT11响应信号是不是80微秒低加80微秒高。这个波形如果看不到,说明主机起始信号没发好或者上拉电阻有问题。第三,数据位的高电平宽度。展开波形后,你应该能看到两种宽度的高电平,短的大约26到28微秒,长的大约70微秒。如果高电平宽度普遍偏宽或者偏窄,就要回头检查微秒延时是否准确。
没有示波器的话,有个土办法:写一段测试代码,只循环读取DHT11的40位原始数据,然后通过串口把每一位的采样结果打印出来。如果打印出来的位模式是稳定的,说明时序基本没问题;如果是乱跳的,再去查硬件。
4.4 DHT11调试疑难问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 读回全是FF | 上拉电阻缺失、GPIO模式错误、接线错误 | 检查上拉和接线,确认输入输出模式切换 |
| 校验不过 | 供电不稳、读取间隔太短、延时不准 | 加去耦电容,保证间隔大于1秒,改用DWT延时 |
| 数据一直是0 | 读位时采样点太早 | 把采样延时从40us微调,或检查高电平宽度判断逻辑 |
| 偶尔跳变一个大数 | 数据线受干扰、线太长 | 缩短杜邦线,数据线远离电机和电源线 |
| 换开发板后不可用 | 微秒延时没适配新主频 | 检查SystemCoreClock,重新校准DWT延时 |
| 温度对但湿度偏大 | 传感器附近有水汽或手触摸 | 检查安装位置,避免紧贴发热源 |
4.5 51单片机和树莓派移植时的差异
这套代码的精髓在协议流程,不在具体平台。移植到51时,只需要把GPIO操作替换成sbit对应的引脚操作,再把微秒延时换成51的循环延时。51直接跑12MHz晶振时,用NOP指令数延时是比较常见的做法,但要注意不同编译器(Keil C51 vs SDCC)生成的指令周期不一样。
树莓派更简单,用GPIO库直接读取引脚电平,微秒延时可以用time.sleep和time.sleep_us,Python或者C语言都行。Python版本虽然延时精度一般,但DHT11的时序窗口有几十微秒的宽容度,实际跑起来也没有太大问题。社区里还有专门的Adafruit_DHT库,读取逻辑和本文的基本一致,只是换成了Python语言。
我自己在从STM32移植到树莓派时,发现最大的坑不是代码逻辑,而是上拉电阻。树莓派内置的上拉比较弱,直接在面包板上用杜邦线连接DHT11模块板时没问题,但如果用长线连接裸芯片,最好外接一个4.7kΩ上拉电阻,否则信号边沿变形严重。
5. 最后再分享一个调试小技巧
在做了这么多次DHT11的调试之后,我最想提醒大家的一点是:不要把“读出来的数据看起来合理”当成成功的标准。DHT11的输出很容易出现“看起来正常但实际校验失败”的情况,比如湿度41%、温度26℃,看起来没问题,但校验和就是不对。这通常是时序稍微有点偏移造成的偶发问题,在要求不高的场景里忍忍就过去了,但在工业环境里这种不可靠是不可接受的。
所以我在最后总会加一段“连续三次读取,两次成功才采用数据”的逻辑。具体做法是:连续读三次,如果三次中至少有两次校验通过,并且通过的那几次温湿度误差都在合理范围内,才把数据更新到全局变量里。这个“多数表决”的思路虽然朴素,但实测下来能把偶发错误率降到几乎为零。毕竟DHT11本身精度就一般,我们不能让时序抖动再给它添乱了。
最后再说一个容易被忽略的点:DHT11的数据线在读取结束后应该恢复高电平,如果代码逻辑中某一步卡住了,总线可能一直处于低电平状态。遇到这种情况,最简单的方法是把整个GPIO重新初始化一次,让总线恢复到确定的高电平状态。这个“软复位”技巧在长时间运行的项目里非常实用。