DHT11温湿度传感器单总线协议详解与STM32稳定驱动实现
做嵌入式这几年跟温湿度传感器打过不少交道DHT11算是我最早接触的一类数字传感器。说实话很多新手把它当成接三根线、调个库就能读数据的简单外设结果一旦遇到读不到数据、数值乱跳、时序不稳就彻底懵了。原因在于DHT11虽然便宜、接口简单但它背后那条单总线协议是完全靠时序说话的没有时钟线没有地址帧没有ACK重传一切信息都编码在高电平持续长短里。你读不到数据往往不是硬件坏了而是时序细节没吃透。这篇文章我不打算只贴一段能跑的代码而是把DHT11的器件原理、单总线协议拆到寄存器级再给出一套在STM32上稳定运行的驱动写法最后把我这些年踩过的坑、排查思路一并梳理出来。无论你是刚接触单片机还是已经在用DHT11但偶尔被它摆一道这篇都能帮你从能跑进阶到稳跑。1. 为什么DHT11值得一聊单总线方案的定位与边界1.1 温湿度采集方案的横向对比做环境监测常见的数字温湿度传感器无非这几类I2C接口的SHT30、SHTC3SPI接口的SHT40还有单总线接口的DHT11、DHT22AM2302。我最早在学校做项目时用的是DHT11后来做产品级设计换过SHT30但对DHT11始终保留着一种入门传感器的亲切感也正因为它足够典型。传感器接口湿度精度温度精度典型价格采样周期DHT11单总线类1-Wire±5%RH±2℃1~3元1sDHT22单总线类1-Wire±2%RH±0.5℃10~15元2sSHT30I2C±2%RH±0.3℃8~15元可配置DHT11的精度放在今天看确实不算高湿度±5%RH在关键场景里甚至不够用但它的优势也很直接协议公开、驱动简单、抗干扰能力尚可、成本极低。对原型验证、教学实验、大棚监控这类对精度不敏感的场景它依然是性价比之王。1.2 单总线的核心思想用一根线传输时间信息单总线的本质是主机和从机共用一根数据线通过控制这根线上电平变化的时间长度来传递信息。它不像I2C有SCL时钟线也不像SPI有独立的SCK所有时序都靠拉低多久、释放多久来定义。这和人与人之间靠停顿长短来传递标点符号很像同样一句话没有标点符号就得靠语气的长短停顿来告诉对方哪里是句号、哪里是逗号。单总线就是这种无语标点协议。DHT11使用的正是这种思路只不过它的时序定义比标准1-Wire更简单。1.3 先厘清一个误区DHT11不是标准1-Wire很多人把DHT11当成DS18B20的温湿度版本默认它支持Maxim定义的1-Wire协议。实际不是。标准1-Wire有严格的命令体系包括初始化时序、ROM命令读ROM、匹配ROM、跳过ROM、功能命令等每个器件还有64位全球唯一序列号支持多器件挂同一总线。DHT11的协议要简单粗暴得多主机拉低总线发起一次启动信号DHT11应答后直接吐出40位数据没有地址、没有命令、没有多设备寻址能力。严格说它属于类单总线或者DHT自定义单总线协议。这解释了为什么你不能拿现成的1-Wire驱动库直接读DHT11也解释了为什么一条总线上挂两个DHT11会互相打架。理解这个区别你才能正确评估能不能用它做多点采集——答案是不能至少单总线模式下不能。2. DHT11内部结构一个传感器两个测量核心2.1 电阻式感湿元件与NTC测温的底层逻辑DHT11内部集成了两个测量单元湿敏电阻和NTC热敏电阻。湿敏电阻的阻值会随环境湿度变化NTC的阻值会随温度变化。芯片内部还有一个8位单片机负责采集这两个模拟量、做简单的校准和编码最终通过单总线接口把数字信号输出。这里有个容易被忽略的细节DHT11输出的温湿度数据已经是数字量但它内部对湿度的标定是出厂时固化的一组校准系数不是实时自校准的。所以DHT11用久了或者被污染后读数漂移是正常现象因为它压根没有重新标定的能力。如果你做的是需要长期稳定性的设备这一点必须提前考虑。2.2 40位数据格式每一位都是什么DHT11一次完整传输共40位数据按顺序是字节序号含义范围第1字节湿度整数部分0~90%RH第2字节湿度小数部分通常为0第3字节温度整数部分0~50℃可扩展至-20~60℃第4字节温度小数部分通常为0第5字节校验和前4字节相加取低8位注意DHT11的小数部分在很多批次器件上恒为0也就是说它的实际分辨率就是1%RH和1℃。即便你读到了小数位非0的数据意义也不大直接丢弃即可。数值解释上湿度就是第1字节的十进制值加上第2字节的十分位温度则是第3字节加第4字节的十分位。由于DHT11出厂就做了整数化处理我的习惯是只取第1、3字节用于显示和逻辑判断。2.3 校验和的原理与手算示例校验和是DHT11协议里最实用的设计前4字节相加取结果的低8位如果和第5字节相等说明本次通信基本可靠。之所以强调基本是因为这个校验只能发现数据错误不能纠错而且如果多个字节同时错且恰好保持校验和不变它也无法发现。不过实际使用中校验失败重新读取就可以了。举个例子湿度整数45湿度小数0温度整数26温度小数0则45026071校验字节应为71。如果你读到的不等于71直接丢弃本次数据等下一次读取周期再取。我在驱动里遇到校验失败会连续重试3次实在失败才返回错误码避免把脏数据交给上层。3. 单总线时序协议读懂四个关键电平阶段你就掌握了驱动写法DHT11的完整通信过程可以拆成四段主机发起起始信号、DHT11响应、40位数据逐位传输、通信结束。每一段都对应明确的电平时长要求。3.1 起始信号主机拉低18ms再释放主机先把数据线拉低保持至少18ms可靠做法是拉低20ms然后释放总线让上拉电阻把电平拉高。这一段低电平是告诉DHT11准备一下我马上要读你了。为什么一定要18msDHT11内部测量周期的典型值是1s传感器在空闲状态时需要一段足够长的低电平来唤醒内部逻辑。有些资料写大于1ms甚至3ms但实际测试中短拉低有时能启动有时不能可靠性很差。我之前在项目里把起始信号误写成5ms结果板子在常温下读取正常拿到稍冷的环境就开始偶发失败。排查了很久才发现是起始信号时长不足DHT11内部的准备状态机没有完全复位。这个坑的教训是起始信号宁长勿短20ms是安全值。3.2 响应信号80us低电平80us高电平主机释放总线后DHT11最快会在20~40us后主动拉低总线持续约80us表示我收到命令了然后再拉高约80us表示我要开始发数据了。这个80us80us的组合就是响应信号。这里你需要注意主机释放总线后总线会被上拉电阻拉高而DHT11拉低是一个开漏动作两者不会冲突。所以驱动代码里要做的只是等待电平变化不参与驱动。3.3 数据位的精华0和1全靠高电平时长区分DHT11发送每一位数据的时序是先拉低50us然后根据数据是0还是1决定拉高的时长。数据0的高电平持续26~28us数据1的高电平持续约70us。这里就出现了两种常见的读取策略采样法等待50us低电平结束后延时30us左右再读引脚。如果是数据0此时高电平已经结束读到的电平是低如果是数据1此时仍处于高电平期间读到高。这种方法的优点是快缺点是要求延时准确如果MCU主频不稳或者延时函数有偏差恰好落在电平翻转的临界点就会误判。计时法等低电平结束后用一个循环测量高电平持续了多长时间超过某个阈值比如50us判为1否则判为0。这种方法抗干扰能力更强但要注意计时循环本身的开销。我对稳定性要求高的项目都会用计时法。两种方法没有绝对优劣取决于你对代码体积和执行速度的要求。后面给出的驱动代码采用计时法因为它不容易受MCU主频误差影响。3.4 通信结束与总线释放40位数据发送完毕后DHT11会再拉低约50us然后彻底释放总线回到空闲状态。主机检测到这个低电平时可以认为一次读取结束了然后等待下一次启动信号。有一个细节很多人忽略DHT11整个数据帧从响应到结束大约3~4ms。这期间总线上任何毛刺、中断、抢占总线的操作都会破坏时序。所以驱动读取期间最好进入临界区关中断读完了再打开。后面代码里我会演示这个做法。3.5 为什么要用上拉电阻没有它时序就是空中楼阁DHT11的驱动方式和I2C类似是开漏/开集结构器件只能把总线拉低不能主动拉高。高电平必须依靠外部上拉电阻。没有上拉电阻总线释放后电平是悬空的读数基本就是随机数。我见过很多新手在面包板上插DHT11板载没有上拉电阻然后吐槽读数一直在跳。其实这不是传感器的问题是硬件不完整。上拉电阻的阻值一般选4.7k~10k。我实测过3.3V供电时10k也能稳定工作但5V供电时建议用4.7k因为上拉电流更大抗干扰能力更强。如果你用的是现成模块一般板子上已经焊好了上拉电阻和滤波电容这个不用担心。4. 代码实现从零手写稳定驱动以STM32F103标准库为例4.1 硬件连接与初始化我以最常见的STM32F103C8T6为例数据引脚接PB0VCC接3.3VGND接GND外接一个4.7k上拉电阻到3.3V。#define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_Pin_0 #define DHT11_RCC_PERIPH RCC_APB2Periph_GPIOB #define DHT11_HIGH() GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN) #define DHT11_LOW() GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN) #define DHT11_READ() GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN)注意PB0默认复用可能是I2C或者其他功能使用前要确保没有被他处占用。我习惯在项目里专门配一个头文件来做引脚映射换板子只改这里。4.2 GPIO模式切换输出推挽与输入浮空/上拉单总线通信要求引脚既能输出又能输入所以每次读写前要切换GPIO模式。static void DHT11_PinMode_Output(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStructure); } static void DHT11_PinMode_Input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStructure); }输出模式下我用的是推挽输出GPIO_Mode_Out_PP。有的教程推荐开漏输出理由是开漏输出更接近真实的单总线驱动方式。但我实测在标准库下开漏输出必须同时使能内部上拉否则高电平起不来反而更多坑。推挽输出在这个应用里完全够用因为它驱动的只是一个10k级别的上拉电阻不存在电平竞争问题。读模式可以用浮空输入或者上拉输入。如果你外部已经接了4.7k上拉电阻浮空输入就够了如果你直接连接DHT11裸片且外部没有上拉那就必须用GPIO_Mode_IPU上拉输入。我在定制PCB上都是外加上拉电阻浮空输入兼顾灵活和稳定。4.3 US级延时不能用for循环裸奔DHT11协议全是微秒级时序而HAL_Delay和Delay_ms都是毫秒级根本不够用。更关键的是不能用简单的for循环空转来做us延时因为编译器优化等级不同循环次数和时间不是线性关系。我在O0优化下写了一个延时循环换到O2优化后直接快了30%整个时序乱套读出来的数据一塌糊涂。最稳的方案是用内核提供的DWT计数器Cortex-M3/M4自带或者用定时器。我用DWT的写法static void DWT_Delay_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 startTick DWT-CYCCNT; uint32_t waitTicks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - startTick) waitTicks); }这串代码的原理是利用CPU计数器精确数出微秒。SystemCoreClock是72MHz时一个计数周期就是1/72us延时1us需要72个计数。F103没有DWT的极端情况极少见大部分M3/M4核都有。4.4 完整读取函数起始、响应、逐位读取、校验typedef struct { uint8_t humi_int; uint8_t humi_dec; uint8_t temp_int; uint8_t temp_dec; uint8_t checksum; } DHT11_Data; uint8_t DHT11_ReadData(DHT11_Data *data) { uint8_t buffer[5] {0, 0, 0, 0, 0}; uint8_t crc 0; uint8_t i, j; // 1. 主机拉低至少18ms发起起始信号 DHT11_PinMode_Output(); DHT11_LOW(); DWT_Delay_us(20000); // 20ms DHT11_HIGH(); // 释放总线 DWT_Delay_us(30); // 等待电平稳定 // 2. 切换为输入模式等待DHT11响应 DHT11_PinMode_Input(); // 等待DHT11拉低响应信号开始加超时保护 uint32_t timeout 1000; while (DHT11_READ() 1) { if (--timeout 0) return 1; // 无响应 } // 等待响应低电平结束 timeout 1000; while (DHT11_READ() 0) { if (--timeout 0) return 2; } // 等待响应高电平结束之后开始数据位 timeout 1000; while (DHT11_READ() 1) { if (--timeout 0) return 3; } // 3. 读取5个字节40位 for (j 0; j 5; j) { for (i 0; i 8; i) { // 跳过50us低电平 timeout 1000; while (DHT11_READ() 0) { if (--timeout 0) return 4; } // 高电平期间延时40us后再采样 DWT_Delay_us(40); if (DHT11_READ() 1) { buffer[j] | (1 (7 - i)); // 等待该位高电平结束避免把下一位的低电平误当本位的结尾 timeout 1000; while (DHT11_READ() 1) { if (--timeout 0) return 5; } } } } // 4. 校验和 crc buffer[0] buffer[1] buffer[2] buffer[3]; if (crc ! buffer[4]) return 6; if (data) { >__disable_irq(); uint8_t ret DHT11_ReadData(dht11); __enable_irq();如果你的系统里有关键任务不能长时间关中断至少读取函数内部要确保没有其他外设中断来抢占总线的5ms窗口。4.5 主循环调用两次读取间隔至少留1sDHT11的手册明确写了采集周期1s意思是两次有效读取之间的间隔不能小于1s。这不是性能瓶颈而是传感器的物理限制——它内部的湿敏电阻完成一次充放电测量需要时间。我在项目里会把DHT11的读取放到一个1s周期任务里比如用定时器标志位触发而不是在while循环里死命读。一个简单的调用示例int main(void) { uint8_t ret; DHT11_Data dht11; DWT_Delay_Init(); // 等待DHT11上电稳定 DWT_Delay_us(1000000); // 1s while (1) { __disable_irq(); ret DHT11_ReadData(dht11); __enable_irq(); if (ret 0) { printf(humidity: %d.%d, temp: %d.%d, crc: %d\n, dht11.humi_int, dht11.humi_dec, dht11.temp_int, dht11.temp_dec, dht11.checksum); } else { printf(read error: %d\n, ret); } DWT_Delay_us(1500000); // 1.5s } }这里上电后先等1s是必须的。我第一次用DHT11时没有留这个上电稳定时间结果是第一组数据永远是0xFF或者直接无响应后来查资料才知道DHT11上电后需要至少1s进入空闲状态。5. 实战中踩过的坑与完整排查链路5.1 坑一初始化后读到的全是0或255问题在前级电路现象DHT11刚接上时完全读不到数据或者读到0x00、0xFF这种极端值。排查链路先用万用表量VCC和GND确认供电正常。DHT11工作电压范围是3.3~5.5V低于3.3V时内部逻辑不稳定最容易出现读0。量数据线空闲电平。正常情况应该接近VCC高电平。如果量出来是0V或者1V左右说明上拉电阻没焊或者开路了。拔掉DHT11用跳线把数据线短接到GND再松开观察MCU引脚能否读到跳变。这一步可以排除MCU侧GPIO配置问题。通常情况下问题出现在第2步。很多人以为DHT11模块只要三根线就行其实模块板上那个上拉电阻是核心没有它传感器根本无法把数据线拉高。5.2 坑二温度正常但湿度跳动剧烈供电纹波的锅现象温度读数非常稳定湿度却每隔几秒跳5%RH以上。这条排查链路我印象很深。一开始我怀疑是湿敏电阻老化换了一个全新DHT11问题依旧。又怀疑是算法问题把读取改成连续读10次取平均波形还是不稳。最后用示波器量DHT11的VCC引脚发现上面叠加了明显的200mV左右的开关噪声。原因是我的板上DHT11和继电器驱动共用了同一个5V电源继电器动作瞬间拉低了VCC。DHT11的湿敏电阻对电压变化极其敏感供电不稳直接反映在湿度读数上。解决方法是DHT11的供电单独走线并且靠近传感器放一个100nF去耦电容。从那以后我在所有DHT11项目里都会保留这个电容的位置哪怕空间紧张也要挤出来。5.3 坑三起始信号期间GPIO模式切换太慢现象逻辑分析仪上看起始信号的低电平只有几ms但代码里明明写了20ms。原因是GPIO初始化代码被放在了循环里反复执行或者GPIO端口时钟没有提前使能。GPIO模式切换本身要写寄存器、等待总线稳定如果每读一次都重新初始化GPIO效率会非常低。正确的做法是初始化一次时钟和引脚然后在读写之间只切换模式不要反复调用GPIO_Init。我的做法是把DHT11_PinMode_Output()和DHT11_PinMode_Input()做得很轻量直接操作寄存器的CRL/CRH位#define DHT11_OUT() do { GPIOB-CRL ~(0xF (0 * 4)); \ GPIOB-CRL | (0x3 (0 * 4)); } while (0) #define DHT11_IN() do { GPIOB-CRL ~(0xF (0 * 4)); \ GPIOB-CRL | (0x4 (0 * 4)); } while (0)这样每次模式切换只改4位寄存器比调用库函数快一个数量级。5.4 坑四中断里读DHT11导致整个系统卡死现象程序运行中偶发卡死看门狗超时复位。排查链路把看门狗暂时关闭跑一阵子用调试器停在卡死现场发现卡在DHT11读取函数的while循环里。逐步检查发现卡死的while循环上没有超时机制而传感器因为某个瞬间的毛刺或者供电抖动没能发出预期的电平跳变代码就在那里死等。DHT11的时序要求读取期间不能被其他中断长时间打断。如果读取过程中被一个USART的中断拖了几百微秒后面所有时序判断全部错位传感器可能在发完数据后就释放总线了而你的代码还在等它拉低就永远等不到。我的最终方案是读取前关中断读取后开中断同时给每个while循环加超时。如果你的系统里RTOS任务切换很频繁建议把DHT11读取封装成单独的低优先级任务并且用互斥信号量防止其他任务同时读取。5.5 坑五数据线太长导致波形失真现象用2米长的杜邦线连接DHT11时读数正常但偶尔校验失败缩短导线后问题消失。单总线的数据线推荐控制在20cm以内最长不超过1m。线太长时分布电容会拖慢电平跳变的上升沿导致DHT11发出的高电平20us在末端只被识别成10us数据0被误判成数据1。如果需要长线传输建议用屏蔽双绞线或者用RS485扩展不要硬扛。6. 进阶数据平滑、任务调度与低功耗设计6.1 跳变的软件过滤中值滤波比均值滤波更合适DHT11即使供电干净读数也偶尔会有单点跳变。我对比过均值滤波和中值滤波发现中值滤波对这类偶发脉冲型噪声更有效。原因是均值滤波会被个别异常值拉偏而中值滤波只要异常值不超过窗口一半就能完全剔除。我的做法是连续读5次间隔1秒然后取中间值uint8_t temp_samples[5]; for (int i 0; i 5; i) { temp_samples[i] read_temp(); delay(1000); } // 冒泡排序取中间值注意这个方法会引入5秒的响应延迟。对温室控制这种慢变量问题不大如果用在响应要求高的场景可以改成滑动窗口每次读取都重新排序保留历史值。6.2 1秒读取周期的任务调度策略如果你用的是裸机定时器的方式建议用SysTick或者定时器产生1秒标志主循环检测到标志后才调用DHT11_ReadData而不是用阻塞延时。这样留给其他任务的时间更充裕。如果你用的是FreeRTOS可以创建一个专用任务频率1Hz任务体内做读取、校验、发布到消息队列其他任务订阅队列获取温湿度。这样DHT11的定时关系不会受其他任务影响。6.3 低功耗场景下的供电策略在电池供电的设备里DHT11的1mA典型工作电流虽然不大但长期通电仍然是浪费。更合理的做法是用MOS管或者MCU的GPIO控制DHT11的VCC读之前上电等1s稳定后读取读完立刻断电。这个策略有个细节DHT11上电后需要1s稳定如果为了省电只在上电后200ms就读取大概率会失败。所以低功耗模式下DHT11的读取周期至少要留出1.2s的上电读取窗口。我实测过这样可以把平均功耗降低到连续供电的几十分之一但代价是每次唤醒时间变长对实时性有损。关于DHT11的另一个冷门玩法是在3.3V供电下通讯协议仍然兼容5V逻辑。如果你的MCU是5V的51单片机DHT11可以直接对接如果是3.3V的MCU而DHT11用5V供电数据线最好加一个电平转换或者分压电阻否则长期运行可能损坏MCU引脚。我个人的经验是直接3.3V给DHT11供电最省事通信稳定也不需要电平转换虽然精度会略微受影响但对大多数应用足够。