DHT11温湿度传感器完全解读:单总线协议、时序分析与STM32驱动实现 📅 发布时间:2026/9/8 13:52:10 👁 浏览次数: 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%RH1s几块钱DHT22单总线±0.5℃±2%RH2s十几块SHT30I2C±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那么湿度0x2D45%RH温度0x1925℃校验和0x2D0x000x190x000x46校验通过。注意DHT11的小数位通常都是0因为它的分辨率本来就只有整数但协议中仍然保留了小数位。有些应用场景需要更高分辨率的读数建议直接换DHT22。提示很多初学者读到数据后不做校验结果偶尔出现一个明显的错误值比如湿度200%可能导致控制逻辑误动作。无论项目多简单校验这一步都不要省。3. 代码实现从延时函数到数据解析全流程3.1 准备工作GPIO模式与微秒延时方案DHT11的数据线是双向的主机要先输出起始信号然后切换成输入模式读取数据。所以在代码里GPIO的模式要在输出和输入之间来回切换。我用STM32F1 HAL库来写这套驱动主要有三个准备要点。GPIO模式怎么配我习惯把DHT11的数据引脚配置为开漏输出模式。开漏输出的好处是高电平并不靠引脚驱动而是靠外部上拉电阻这样输出高电平时引脚处于高阻态切换到输入模式非常平滑不会产生推挽冲突。如果你用的引脚恰好没有外部上拉那就得配置为推挽输出同时切换输入模式时还要重新初始化GPIO。微秒延时怎么解决HAL库自带的HAL_Delay是毫秒级根本满足不了微秒级时序的要求。常见方案有两种一是用SysTick计数自己做微秒延时但SysTick一般被HAL库占用容易冲突二是用DWTCoreSight调试单元的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_usPython或者C语言都行。Python版本虽然延时精度一般但DHT11的时序窗口有几十微秒的宽容度实际跑起来也没有太大问题。社区里还有专门的Adafruit_DHT库读取逻辑和本文的基本一致只是换成了Python语言。我自己在从STM32移植到树莓派时发现最大的坑不是代码逻辑而是上拉电阻。树莓派内置的上拉比较弱直接在面包板上用杜邦线连接DHT11模块板时没问题但如果用长线连接裸芯片最好外接一个4.7kΩ上拉电阻否则信号边沿变形严重。5. 最后再分享一个调试小技巧在做了这么多次DHT11的调试之后我最想提醒大家的一点是不要把“读出来的数据看起来合理”当成成功的标准。DHT11的输出很容易出现“看起来正常但实际校验失败”的情况比如湿度41%、温度26℃看起来没问题但校验和就是不对。这通常是时序稍微有点偏移造成的偶发问题在要求不高的场景里忍忍就过去了但在工业环境里这种不可靠是不可接受的。所以我在最后总会加一段“连续三次读取两次成功才采用数据”的逻辑。具体做法是连续读三次如果三次中至少有两次校验通过并且通过的那几次温湿度误差都在合理范围内才把数据更新到全局变量里。这个“多数表决”的思路虽然朴素但实测下来能把偶发错误率降到几乎为零。毕竟DHT11本身精度就一般我们不能让时序抖动再给它添乱了。最后再说一个容易被忽略的点DHT11的数据线在读取结束后应该恢复高电平如果代码逻辑中某一步卡住了总线可能一直处于低电平状态。遇到这种情况最简单的方法是把整个GPIO重新初始化一次让总线恢复到确定的高电平状态。这个“软复位”技巧在长时间运行的项目里非常实用。