DHT11单总线通信时序与裸机驱动实现详解
第一次用DHT11的时候我被“单总线通信”这几个字坑了一把。手头正好有DS18B20的1-Wire库寻思都是单总线直接把库搬过来用总行了吧结果读回来的数据全是0xFF折腾了大半天才反应过来DHT11的时序和Dallas的1-Wire协议只是长得像本质上是两套完全不同的东西。这篇文章就把DHT11的单总线通信从物理层时序、数据帧格式到裸机代码一次讲透适合刚接触传感器通信、或者已经在GPIO模拟时序上卡过壳的人看完能自己写出一份能跑的驱动而不是只会复制粘贴别人的库。1. 单总线通信到底是什么DHT11为什么选择这么“抠门”的通信方式1.1 一条数据线加一条地线如何把温湿度“挤”出来单总线通信英文常写作Single-Bus核心思想很朴素主机和从机之间只靠一根数据线传输信息。和UART的TX/RX两根线、SPI的四根线、IIC的两根线比起来DHT11这种模式确实“抠门”——但它省GPIO、省PCB走线空间在温湿度采集这种低速场景里完全够用。DHT11实际封装有4个引脚VCC、DATA、NC、GND。NC脚是悬空的真正电气连接只有三根线其中通信走线只有DATA一根。很多模块版会在板上集成一个上拉电阻和一个滤波电容买模块比自己焊裸传感器省心但裸传感器也没多复杂自己接一个4.7kΩ到10kΩ的上拉电阻到VCC就能工作。单总线通信的代价也很直观速度慢、时序要求严格、抗干扰能力不如差分信号。DHT11的数据传输速率大约在30kbps以下每次完整传输40位数据要花大概4ms左右。这个速度测温湿度绰绰有余你要拿它传音频视频那肯定不现实。选通信方式前先想清楚需求低速传感器场景下单总线就是“够用且简单”的代表。1.2 分清DHT11的“单总线”和DS18B20的1-Wire长得像不是一回事这个坑我必须单拎出来说一遍。DHT11虽然经常被归类进“单总线”传感器但它并不是标准1-Wire设备。Dallas半导体现在归Maxim的1-Wire协议是有完整规范的包括设备ROM地址、命令集、CRC校验等每一颗DS18B20都有唯一64位序列号可以一条总线上挂多个设备。DHT11完全不一样它没有任何设备地址概念一帧数据就是固定40位没有读ROM、写EEPROM这些命令一条总线上也只能挂一个DHT11。两份协议连时序都不一样。1-Wire的复位脉冲是480μs以上DHT11的起始信号要拉低18ms以上1-Wire的数据位用15μs内的高低电平时序区分0和1DHT11每个数据位固定先低50μs再用高电平持续时间区分。拿1-Wire库去读DHT11数据错乱是必然的。后面写代码的时候要彻底忘掉1-Wire那套库函数老老实实按DHT11的时序手写GPIO翻转。1.3 传感器内部其实藏着一颗单片机协议是它“翻译”出来的看DHT11的原理图会发现一件事传感器内部除了一个湿敏电阻和一个NTC热敏电阻或者集成式的温敏元件还有一颗8位MCU。这意味着DHT11并不是直接把电阻值输出到引脚上而是内部单片机完成模拟量采集、校准、编码之后按单总线协议把数字化结果“讲”出来。这颗内部MCU的存在解释了很多现象。比如DHT11的采样周期固定是1秒因为内部单片机每隔1秒才完成一次完整测量你在数据手册上看到的“采样周期1秒”就是这个原因。再比如DHT11出厂前已经在校准实验室做过温湿度校准校准系数烧录在OTP内存里每次上电后内部MCU计算数值时会自动带入这些系数你不必自己做标定。搞懂这一点后面调代码时遇到“读取太快读不到数据”“刚上电读第一帧失败”这种问题就明白是器件本身的行为不是你的代码写错了。2. 从时序图到代码单总线上的一问一答是怎么完成的2.1 主机起始信号先拉低18ms把这个“休眠”的从机叫醒单总线通信的第一步永远由主机发起DHT11从不会主动开口说话。主机要做的第一件事是把数据线拉低至少18ms然后再释放拉高这组动作叫做“起始信号”也有人叫“复位信号”。为什么偏偏是18ms因为DHT11上电后或者两次通信的间隙内部单片机会进入一个相对低功耗的状态需要足够长的低电平才能触发它的唤醒判断。我实测过起始低电平时间缩短到10ms左右时DHT11会间歇性不响应做到20ms就非常稳定。但也不是越长越好太长了会压缩整个采样周期内的有效读取时间。通常取18到30ms我在代码里固定用20ms留出了一点余量。起始信号发完后主机不能立刻去读数据需要释放总线并等待20到40μs这个窗口是给DHT11做“接线判断”的。它会在这段时间内检测总线状态确认主机确实发起了一次有效的握手然后拉低总线约80μs作为响应再拉高约80μs作为数据发送准备。这两个80μs的组合信号就是经典的“响应信号”。2.2 响应信号与40位数据帧每一位都靠高电平的宽度“说话”DHT11完成响应信号后会连续输出40位数据。这40位的排列顺序是固定的字节序号含义取值范围第1字节湿度整数部分0~99第2字节湿度小数部分0~9DHT11恒为0第3字节温度整数部分0~50第4字节温度小数部分0~9DHT11恒为0第5字节校验和前4字节之和的低8位每一位数据的编码规则非常统一先是约50μs的低电平然后是一个变长的高电平。高电平持续26到28μs代表逻辑0高电平持续约70μs代表逻辑1。也就是说DHT11用同一个低电平开头配合不同长度的“高电平尾巴”来区分0和1这个编码方式和红外遥控的脉宽调制有几分相似。收到5个字节后要做校验把前4个字节相加取低8位看是否等于第5字节。校验通过解析出的温湿度才可信校验失败整帧丢掉重新读。串口、蓝牙这些自带CRC校验的传输方式还能靠协议栈兜底GPIO模拟的时序通信完全裸奔校验步是最后一道防线不能省。2.3 DHT11的“分辨率骗局”小数位其实恒为0很多人第一次读到DHT11数据会对着湿度的“小数位”和温度的“小数位”发半天呆。这里必须说清楚DHT11的温度分辨率是1℃湿度分辨率是1%RH标称精度温度±2℃、湿度±5%RH它根本不具备小数级别精度。手册上所谓的小数位在DHT11出厂时就写死为0除非你买的是DHT12或者AM2301这类改进型才会真的返回小数。所以看到网上有人说“DHT11读了半天小数一直是0是不是坏的”答案很简单不是DHT11就是这个特性。代码里面把小数位拼进去也不会有问题乘上0.1之后就是整数本身但你要知道这个数没有意义。如果想读小数位、想要更高的分辨率老老实实升级DHT22或者SHT30想在DHT11上“抠”出小数点纯属白费力气。3. 裸机代码实战STM32下从头写一份DHT11驱动3.1 GPIO配置开漏输出加外部上拉比反复切换方向省事先说一个很多人翻车的地方GPIO模式。DHT11的数据线是双向的主机要输出起始信号时是输出模式之后要读DHT11的响应和数据时是输入模式。如果你用推挽输出去驱动读数据前必须把GPIO从输出模式切到输入模式读完再切回来不仅代码啰嗦切换方向那几十纳秒还可能影响时序。更干净的方案是把GPIO配置成开漏输出Open-Drain然后外部加一个4.7kΩ到10kΩ的上拉电阻。开漏输出模式下寄存器写0时引脚真正输出低电平写1时引脚呈现高阻态由外部上拉电阻把电平拉高。也就是说开漏输出天然具备“释放总线”的能力写1就成了高阻输入读引脚电平用输入寄存器直接读就行全程不需要切换GPIO方向。/* 以STM32F103 HAL库为例PB8作为DHT11数据线 */ #define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_PIN_8 #define DHT11_SET_HIGH() HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET) #define DHT11_SET_LOW() HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET) #define DHT11_READ() HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) void DHT11_GPIO_Init(void) { GPIO_InitTypeDef gpio_init {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio_init.Pin DHT11_GPIO_PIN; gpio_init.Mode GPIO_MODE_OUTPUT_OD; /* 开漏输出 */ gpio_init.Pull GPIO_NOPULL; /* 外部上拉不用内部上拉 */ gpio_init.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, gpio_init); DHT11_SET_HIGH(); /* 初始状态释放总线 */ }这里有个小细节Pull选项我建议设成GPIO_NOPULL靠外部上拉电阻。STM32的内部上拉电阻典型值在30kΩ到50kΩ之间偏大信号边沿变缓长距离传输或高温环境下容易误码。外部4.7kΩ上拉更干脆边沿陡峭实测误码率明显更低。3.2 微秒级延时DHT11时序是μs粒度HAL_Delay救不了你DHT11时序的最小单位是微秒HAL_Delay是毫秒级精度根本不够。写驱动前必须先准备一个微秒延时函数。最简单的实现是用DWTData Watchpoint and Trace模块的CYCCNT计数器它是ARM Cortex-M内核自带的周期计数器精度就是CPU主频的倒数在72MHz下每计数一次约13.9ns完全够用。void DHT11_Delay_us(uint32_t us) { uint32_t start, ticks; CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; start DWT-CYCCNT; ticks us * (SystemCoreClock / 1000000); while (DWT-CYCCNT - start ticks); }没有DWT的芯片怎么办退一步可以用SysTick做微秒延时但要注意SysTick通常被HAL库用作tick定时器直接改会有冲突建议单独用一个基本定时器来做延时或者查阅芯片参考手册找合适的计数器。最土的办法是空循环加示波器校准但换一个编译器优化等级时序就不对了非常折腾能不碰尽量不碰。3.3 起始信号与响应检测一个函数搞定握手用上面的延时函数先写主机起始信号和DHT11响应检测。这个函数放在读取流程最前面返回1表示握手成功返回0表示设备无响应或超时。uint8_t DHT11_Start(void) { uint8_t retry 0; /* 主机拉低总线至少18ms */ DHT11_SET_LOW(); DHT11_Delay_us(20000); /* 释放总线等待20-40us */ DHT11_SET_HIGH(); DHT11_Delay_us(30); /* 等待DHT11拉低总线响应信号开始 */ retry 0; while (DHT11_READ() 1) { if (retry 100) return 0; DHT11_Delay_us(1); } /* 响应低电平约80us等它结束 */ retry 0; while (DHT11_READ() 0) { if (retry 100) return 0; DHT11_Delay_us(1); } /* 响应高电平约80us等它结束 */ retry 0; while (DHT11_READ() 1) { if (retry 100) return 0; DHT11_Delay_us(1); } return 1; }这里在等待电平变化时每次循环都延时1μs再加超时计数用意是防止总线异常时程序死等。如果DHT11没接好、或者上拉电阻没焊DHT11_READ永远读到高电平没有超时保护的话程序会卡死在while循环里整个主任务被拖住。超时值100μs在实际测试中足够宽裕DHT11的响应信号虽然有80μs但不会超过这个值太多。3.4 40位数据读取与校验高电平时间一量0和1就分开了数据位的读取逻辑核心就一句话先等低电平结束然后在高电平开始后延时40μs左右再读引脚电平。如果此时引脚还是高说明高电平持续超过40μs是逻辑1如果已经变低说明高电平只有26到28μs是逻辑0。阈值取40μs正好卡在28μs和70μs中间容错空间最大。uint8_t DHT11_ReadBit(void) { uint8_t retry 0; /* 等待低电平开始 */ retry 0; while (DHT11_READ() 1) { if (retry 100) return 0xFF; DHT11_Delay_us(1); } /* 低电平约50us等它结束 */ retry 0; while (DHT11_READ() 0) { if (retry 100) return 0xFF; DHT11_Delay_us(1); } /* 关键高电平开始后延时40us再判断电平 */ DHT11_Delay_us(40); if (DHT11_READ() 1) { return 1; } else { return 0; } } uint8_t DHT11_ReadByte(void) { uint8_t data 0; int8_t i; for (i 7; i 0; i--) { uint8_t bit DHT11_ReadBit(); if (bit 0xFF) return 0xFF; data | bit i; } return data; }完整读取函数负责把起始信号、5字节读取、校验、结果解析串起来。校验失败返回0调用方可以根据返回值决定重试还是报错。uint8_t DHT11_Read(float *humidity, float *temperature) { uint8_t buf[5]; uint8_t sum; int8_t i; if (!DHT11_Start()) { return 0; } for (i 0; i 5; i) { buf[i] DHT11_ReadByte(); if (buf[i] 0xFF) { return 0; } } /* 校验和前4字节相加的低8位 */ sum buf[0] buf[1] buf[2] buf[3]; if (sum ! buf[4]) { return 0; } /* DHT11的小数位恒为0这里保留小数计算是为了兼容DHT22 */ *humidity buf[0] buf[1] / 10.0f; *temperature buf[2] buf[3] / 10.0f; return 1; }调用这段代码时要注意DHT11每次完整读取耗时大约4到5ms加上20ms的起始低电平一轮要25ms左右。如果你在主循环里每100ms读一次会发现问题不大但如果你在一个时间片只有10ms的RTOS任务里调这个函数任务会严重超时。DHT11这种传感器就适合慢节奏采集别往高频任务里塞。4. 实测中不容易注意到的几个坑上拉、采样间隔与误码处理4.1 上拉电阻不是可选项阻值大小直接影响误码率网上买到的DHT11模块基本都带了上拉电阻和滤波电容但如果你手里是四脚裸传感器千万别省这颗电阻。没有上拉电阻时总线空闲状态是浮空或者被引脚内部弱上拉勉强拉高电平边缘像钝刀子一样DHT11输出宽高电平信号时主机读到的沿会有明显延迟逻辑1的70μs高电平被拉成40多μs直接过不了40μs阈值判断误码率飙升。我实测过一组数据4.7kΩ上拉时读1000次失败3次10kΩ时失败7次100kΩ时失败80多次而且失败的帧全是校验和错误。建议用4.7kΩ到10kΩ走线超过20cm的话优先选4.7kΩ。另外要留意如果你把IO配置成内部弱上拉、外部又不加上拉电阻虽然偶尔能读到数据但温升后内部上拉阻值漂移会间歇性失灵很难排查别这么干。4.2 采样节奏1秒是硬件限制两次读取之间别太心急前面提过DHT11内部单片机每1秒完成一次测量这意味着两次发起起始信号的间隔太短DHT11会来不及更新数据甚至对起始信号不响应。最典型的表现是主循环读得太快第一次成功第二次返回超时第三次成功第四次又超时数据像打摆子一样。最佳实践是每次读取间隔至少1秒读取失败后重试也要等上1秒再发起下一次握手。不要失败后立刻疯狂重试那样只会连续挂掉因为DHT11根本没有新数据可发。还有一个细节也容易被忽略模块刚上电的前几百毫秒内部MCU还在初始化这时发起始信号大概率失败等1秒再开读就稳了。4.3 校验和失败时整帧丢弃但更值得关注的是错误分布如果校验和通过温湿度基本可信校验和失败说明这一帧在传输过程中某一位被干扰了整帧丢掉重新读。但这里有一个容易忽视的点校验和本身只有8位理论上存在前4字节与校验和同时被干扰但仍然“凑巧”通过校验的概率。好在DHT11数据是温湿度范围有限的数值湿度不会超过99%、温度不会超过50℃从业务层再判断一下范围超范围的数据直接判无效可以进一步压缩错误帧漏网的概率。常见错误分布我也会顺手记录一下起始信号阶段失败多半是线没接好或上拉有问题5字节读取中间某字节返回0xFF大概率是读取过程中被更高优先级中断打断导致时序超时全部读完但校验失败多半是布线太长收到干扰。排查思路完全不一样遇到问题先分清是哪一类再动手。5. 换一颗MCU怎么移植51、Arduino、瑞萨的相同逻辑与不同写法5.1 抽出一层底把GPIO读写延时封装成5个函数跨平台移植DHT11驱动最省事的做法是把底层操作抽出来。只要完成5个函数主逻辑可以原封不动搬走底层函数说明51单片机示例思路DHT11_GPIO_Init初始化数据引脚引脚设为准双向IO即可DHT11_SET_HIGH / DHT11_SET_LOW控制引脚电平sbit定义后直接赋值DHT11_READ读取引脚电平读取前先写1DHT11_Delay_us微秒延时12MHz时钟下1个机器周期是1μs用空循环即可主逻辑也就是Start、ReadBit、ReadByte、ReadData、CheckSum这一套跟具体芯片没有任何关系。我移植过51、STM32、Arduino、瑞萨RA系列流程都是一样的区别只在底层封装。5.2 51单片机准双向IO口的先天约束和高主频陷阱51单片机的P1、P2、P3口是准双向IO输出低电平能力强输出高电平却很弱读输入前必须先写1。因为没有开漏模式上拉电阻必须靠外部10kΩ以下的地线到VCC上拉最稳。延时方面12MHz晶振下51的一个机器周期正好是1μs空循环的NOP数可以很精确地控制微秒级延时但要注意不同型号的51特别是增强型51指令周期不同比如STC某些型号默认1T模式机器周期不是1μs了延时计算会差好几倍不校准的话时序会乱套。5.3 Arduinomicros()精度勉强够用但别忘了调用开销Arduino移植起来最容易digitalWrite和digitalRead可以直接操作IOmicros()可以提供微秒级时间基准。不过有个细节容易踩micros()本身是个函数调用有时间开销如果你在精确计时40μs这种阶段调用它建议用如下写法提前把起止时间算好uint32_t t micros(); while (micros() - t 40);这种写法比反复调用delayMicroseconds更稳因为delayMicroseconds内部是先算空循环次数再执行参数的粒度已经够。测量高电平时我习惯用下面的方式uint32_t start micros(); while (digitalRead(pin) HIGH) { if (micros() - start 100) break; } if (micros() - start 40) { // bit 1 } else { // bit 0 }直接量高电平宽度比固定延时40μs再读电平更直观也更不容易受micros时间抖动影响。5.4 瑞萨RA系列把HAL库函数名换掉就行瑞萨RA系列最近问的人很多主要是FSP库和STM32 HAL库的API名字不一样而已。STM32的HAL_GPIO_WritePin对应瑞萨的R_IOPORT_PinWriteHAL_GPIO_ReadPin对应R_IOPORT_PinReadGPIO配置在FSP图形配置工具里把Pin设为CMOS输出、带上拉即可。开漏输出的配置在RA系列里稍微绕一些FSP里要选“Port Output”并额外配置N-ch Open Drain选项。如果嫌麻烦直接配成推挽输出在读取前用R_IOPORT_PinCfg临时切换成输入模式也行只是代码多一些。总而言之时序逻辑完全不用动换的就是底层的十几个函数名。调试DHT11读不到数据时我最常做的排查顺序很简单先看波形示波器或逻辑分析仪抓起始信号和响应信号确认DHT11到底有没有回应再查上拉电阻和接线别有虚焊然后看读取间隔有没有卡在1秒红线以下最后把原始5字节帧打印出来看校验和差多少。按这个顺序排查绝大多数问题都能定位到具体环节。我自己踩过的坑里纯代码逻辑问题反而最少大部分都是硬件层面的小毛病。