第一次用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和1,DHT11每个数据位固定先低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~9(DHT11恒为0) |
| 第3字节 | 温度整数部分 | 0~50 |
| 第4字节 | 温度小数部分 | 0~9(DHT11恒为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是毫秒级,精度根本不够。写驱动前必须先准备一个微秒延时函数。最简单的实现是用DWT(Data 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 | 读取引脚电平 | 读取前先写1 |
| DHT11_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 Arduino:micros()精度勉强够用,但别忘了调用开销
Arduino移植起来最容易,digitalWrite和digitalRead可以直接操作IO,micros()可以提供微秒级时间基准。不过有个细节容易踩: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_PinWrite,HAL_GPIO_ReadPin对应R_IOPORT_PinRead,GPIO配置在FSP图形配置工具里把Pin设为CMOS输出、带上拉即可。开漏输出的配置在RA系列里稍微绕一些,FSP里要选“Port Output”并额外配置N-ch Open Drain选项。如果嫌麻烦,直接配成推挽输出,在读取前用R_IOPORT_PinCfg临时切换成输入模式也行,只是代码多一些。总而言之,时序逻辑完全不用动,换的就是底层的十几个函数名。
调试DHT11读不到数据时,我最常做的排查顺序很简单:先看波形,示波器或逻辑分析仪抓起始信号和响应信号,确认DHT11到底有没有回应;再查上拉电阻和接线,别有虚焊;然后看读取间隔有没有卡在1秒红线以下;最后把原始5字节帧打印出来,看校验和差多少。按这个顺序排查,绝大多数问题都能定位到具体环节。我自己踩过的坑里,纯代码逻辑问题反而最少,大部分都是硬件层面的小毛病。