ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DHT11温湿度传感器详解:单总线时序、源码实现与排错实战

DHT11温湿度传感器详解:单总线时序、源码实现与排错实战 DHT11这东西玩过单片机的基本都碰过市面上关于它的帖子一抓一大把可大多数要么只给个库要么只贴段代码看完还是一头雾水。我做温湿度记录这种小项目少说也有十几次了今天干脆把DHT11型温湿度传感器的使用从头到尾拆一遍协议、时序、源码、排错一次讲透。这篇东西适合刚入门嵌入式、正在做课程设计或者想给家里做个温湿度显示器的朋友读完你不仅能抄我的代码还能真正搞懂这枚几块钱的传感器为什么难调、怎么调才稳。很多教程上来就让你装库装完连库里的代码看都不看出了问题完全手足无措。我建议所有刚开始接触DHT11的同学都试一次不用库、直接读寄存器操作GPIO模拟时序的写法只有亲手把起始信号、响应信号、数据位一个个读出来你才算真正会用了。文章最后会给出完整可用的C语言源码里面分了底层和上层你移植到什么板子上都方便。1. 认识DHT11以及它为什么这么受欢迎1.1 核心技术参数与常见误区DHT11是一颗数字温湿度复合传感器内部有一个电阻式感湿元件和一个NTC测温元件配合一个8位单片机做信号处理最终通过单总线把数据串行输出。它的核心参数大概是这样参数典型值备注工作电压3.3V ~ 5.5V3.3V系统也能用但5V更稳湿度测量范围20%RH ~ 90%RH上限90%RH超过后读数失真湿度精度±5%RH本质上是湿度传感器中最入门的精度温度测量范围0℃ ~ 50℃常温场景专用别拿去测高温温度精度±2℃做常规室温记录够用分辨率湿度1%RH温度1℃小数位多数型号固定为0采样周期1s ~ 2s官方建议读取间隔不低于1s很多人以为DHT11温度能测到很高其实它的测温范围只有0到50℃拿去测设备外壳发热根本不行那得换DS18B20或者热敏电阻。湿度也一样超过90%RH基本就开始乱跳想用于卫生间湿度监控效果很差。它的优势从来不在参数上而在“便宜、好买、单总线省引脚、接口简单”。DHT11还有个迷惑点新一批改善版的DHT11湿度数据的小数部分有可能不再是0个别型号会给出一位小数的读数。但绝大多数市场上流通的封装还是整数显示所以代码里看到小数部分为0是很正常的事不要当成故障。1.2 从应用场景看DHT11的能力边界DHT11最适合的用途是“环境趋势监控”比如房间温湿度记录、机房温度上报、蔬菜大棚夜间低温报警、实验室环境监测这类对绝对精度要求不高的场景。它的精度虽然一般但胜在长期稳定性尚可而且单总线通信方式非常省IO口一个GPIO就能把两个量都读出来对主控资源占用极小。真正要较真的场景比如恒温恒湿箱控制、医疗药品存储环境监测、气象站数据采集建议换SHT30、SHT31这类I2C接口的高精度传感器精度能到±2%RH以内还能选防水探头。价格虽然贵了几倍但可靠性完全不同。选型这块我的原则很简单先看量程是否能覆盖应用环境再看精度是否满足误差要求最后才看价格。DHT11在很多初学者方案里被高估了不是它不好是它被用错了地方。1.3 引脚定义、模块版与裸传感器、上拉电阻DHT11有两种常见形态四针裸传感器和三针模块板。裸传感器的引脚定义是1脚VCC2脚DATA3脚NC空脚4脚GND封装上有个小圆孔正面朝自己引脚从左到右排列很多新手把NC当数据脚接读数自然全错。模块板就不一样多数PCB上印的是VCC、DATA、GND注意有些模块板丝印顺序可能是DATA、VCC、GND所以接线前一定要看模块背面的标注。模块板通常已经焊好了一个4.7kΩ或10kΩ上拉电阻直接用就行。如果你买的是裸传感器必须在DATA和VCC之间接一个上拉电阻阻值4.7kΩ到10kΩ都行推荐4.7kΩ。为什么一定要上拉电阻因为DHT11的DATA引脚不是推挽输出它是开漏结构也就是说从机只能主动把总线拉低不能主动拉高。总线上想出现高电平必须靠外部上拉电阻把电压拉上去这和I2C总线的原理一模一样。主机在读取数据时要把引脚切到输入模式这期间总线保持高电平完全依赖上拉电阻。没有上拉电阻你读到的电平就是浮空的数据乱到没法看。2. 单总线协议一根线是怎么算出一串数据的2.1 单总线的工作机制DHT11用的这种单总线协议本质上是一种半双工、无时钟线的异步串行通信。没有时钟线意味着双方要依靠约定好的时间长度来区分数据所以时序的精确性就是生命线。对比一下I2C有SCL时钟线SPI有SCK时钟线通信双方可以看时钟边沿来采样而单总线只能靠主机这边掐着时间读电平谁延时不靠谱谁读出来的数据就是错的。整个单总线通信过程是短促的单向传输主机先发一个起始信号DHT11收到后拉低总线响应然后连续输出40位数据5个字节输出完毕后总线恢复空闲高电平。这个过程大概只要几毫秒非常快。DHT11内部在上电后会做一次数据更新更新间隔大约是1到2秒所以你在它刚上电后立刻读大概率读不到有效数据后面要专门处理这个首次读取失败的问题。2.2 数据帧格式与校验算法40位数据看起来神秘拆开就是5个字节字节顺序内容示例55%RH26.5℃Byte0湿度整数部分55Byte1湿度小数部分0Byte2温度整数部分26Byte3温度小数部分5Byte4校验和55 0 26 5 86传输顺序是从Byte0的最高位开始一位一位往下传先高位后低位。校验规则非常简单粗暴前四个字节的和取低8位如果等于第5个字节说明这一帧数据是可靠的不等则直接丢弃这一帧结果。实际温湿度计算方式是湿度 湿度整数部分 湿度小数部分 × 0.1单位%RH温度 温度整数部分 温度小数部分 × 0.1单位℃不过大多数DHT11的小数位恒为0所以直接用整数也能处理。注意这里的小数位和分辨率并不矛盾分辨率通常就是1小数部分只是部分改良版芯片才真正有效。2.3 时序名词解释起始、响应、0码、1码单总线通信中你会看到好几个时间参数我先把它们对应的物理含义说清楚。时序阶段电平变化时间长度说明主机起始信号DATA拉低≥18ms实际建议18~20ms太短DHT11反应不过来起始信号结束DATA释放由电阻拉高20~40us这之后从机开始响应从机响应低电平DATA被拉低约80us表示“收到准备传数据”从机响应高电平DATA被拉高约80us表示“开始传数据”每一位的前导DATA拉低约50us每一位的低电平都一样长数据位0高电平26~28us高电平短0数据位1高电平约70us高电平长1这里最核心的区分点是每位数据前的“低电平前导宽度”是一致的高电平时长决定0还是1。所以代码里读数据的逻辑通常就是等低电平结束、检测到高电平后延时大约40us再去采样一遍引脚电平如果此时还是高就判定为1如果已经变低就判定为0。原理就是0和1的高电平宽度差得非常远30us对70us在40us这个中间点采样最不容易误判。3. 读取时序从电平跳变看通信过程3.1 主机发起通信的四步先讲主机发起通信的标准流程这是DHT11驱动里最容易写错的地方。第一步主机把GPIO配置为输出模式拉低总线并保持至少18ms。这一步的目的是让DHT11感知到主机要开始通信时间太短它内部状态机还没准备好发出的起始信号无效。我通常延时20ms宁可多一点稳一些。第二步主机把总线释放也就是把GPIO切回输入模式此刻总线被上拉电阻拉高高电平状态维持20到40us。注意这20到40us非常关键如果主机释放总线后立刻去读响应信号可能会因为线上电容还没充完电而误判。第三步DHT11检测到起始信号结束会拉低总线约80us作为响应再拉高约80us准备输出数据。主机此时只需要等待总线被从机拉低然后等低电平结束就可以。第四步也是很多人会漏的从机开始输出第一位数据之前没有任何额外标记直接进入40位数据流。也就是说你读完响应高电平之后下一眼看到的就是第一位数据的低电平前导。有些代码在这里多做一次检测低电平和释放就是因为没分清响应信号和第一位前导的区别。3.2 40个数据位是如何一位一位读出来的当DHT11把总线从响应高电平释放后它就开始循环发送数据位了。每一位的结构非常固定先是约50us的低电平然后是一段可变宽度的高电平。代码读取逻辑是先用循环等总线变低表示一个数据位开始接着循环等总线变高表示低电平前导结束然后延时40us之后读一次引脚电平高就是1低就是0最后再等总线变低准备进入下一位。这样一个循环读8次凑满一个字节再读5个字节就得到完整的一帧数据。要特别提醒的是整个数据输入过程中总线是完完全全由DHT11主导的主机只能被动等待、掐时间采样。任何中断都有可能在某个节点打断你的延时导致这一次读到的位是错误的。这也是为什么很多单片机驱动里读时序时会暂时把全局中断关掉。具体做法后面源码部分会讲。3.3 为什么严格要求延时18ms、80us、26us、70us的含义这些时间参数并不是随便拍脑袋定的。DHT11内部靠RC振荡器计时主机这边则依赖软件延时函数。18ms起始信号是为了确保DHT11内部的状态机能被唤醒并识别一个完整的低电平80us响应低电平和高电平是它内部固件写死的动作时序26us和70us则对应0和1的脉冲宽度。主机侧的问题是软件延时并不一定准。你在A开发板上写的delay_us(40)换到B开发板上因为主频、编译器优化、循环开销差异可能实际延时只有25us或者达到60us。这种误差在别的通信协议里可能不影响但在单总线里是致命的。解决思路有两条一是用定时器输入捕获模式去测量高电平宽度二是用更稳妥的“延时到中点采样”思路并且实测不同平台下的延时准确性。3.4 数据处理整数小数与符号扩展拿到40位数据后要处理成可读的温湿度值。温度字段要注意如果测量温度低于0℃DHT11会在温度的整数部分最高位给出符号位也就是最高位为1时温度是负值。实际代码里判断一下温度整数字节的最高位是否为1如果是就要把整数部分取补码并加负号。不过绝大多数使用场景都是0℃以上环境这个情况很少触发。我自己的做法是仍然在代码里处理了负数温度避免冬天做户外记录时读出乱码。符号处理之后再按2.2节的公式拼小数位最终得到完整的浮点或字符型结果。这里的小数位在多数DHT11上恒为0但代码还是保留组合逻辑兼容性更好。4. 源码实现从底层到应用完整跑通4.1 代码结构总览我提供的是平台无关的C语言写法。你把下面几个底层函数换成自己板子上的GPIO操作代码就可以直接跑。分层思路是这样的最底层是pin_init、set_output、set_input、write、read这类IO原语中间层是单总线协议时序逻辑最上层是温湿度数据解析和错误处理。#include stdint.h #include string.h /* 平台相关原语以下函数需要根据你的开发板实现 */ void dht11_pin_init(void); // 初始化GPIO输出模式默认高 void dht11_set_output_mode(void); // 切换到推挽输出 void dht11_set_input_mode(void); // 切换到输入模式 void dht11_write_low(void); // 输出低电平 void dht11_write_high(void); // 输出高电平 uint8_t dht11_pin_read(void); // 读取引脚电平返回1/0 /* 平台无关延时 */ void delay_us(uint32_t us); void delay_ms(uint32_t ms); typedef struct { uint8_t humi_int; uint8_t humi_dec; uint8_t temp_int; uint8_t temp_dec; uint8_t checksum; } DHT11_Data; /* 返回值0成功1超时2校验失败 */ uint8_t dht11_read(DHT11_Data *out);这种分层方式的好处是你换一块开发板时只需要实现最上面6个底层函数中间时序逻辑一行都不用改。如果直接用Arduino或者某个厂商的HAL库底层函数写起来也就是十几行的事。4.2 底层IO模拟与起始信号底层封装完成后发送起始信号和读取响应的核心逻辑是这样uint8_t dht11_wait_pin_low(uint32_t timeout_us) { while (dht11_pin_read() 1) { if (timeout_us-- 0) return 1; delay_us(1); } return 0; } uint8_t dht11_wait_pin_high(uint32_t timeout_us) { while (dht11_pin_read() 0) { if (timeout_us-- 0) return 1; delay_us(1); } return 0; } uint8_t dht11_read_byte(void) { uint8_t byte 0; for (uint8_t i 0; i 8; i) { /* 等待每一位的低电平前导结束 */ if (dht11_wait_pin_high(50)) return 0xFF; delay_us(40); /* 采样点放在高电平约40us处 */ uint8_t bit dht11_pin_read(); byte (byte 1) | (bit ? 1 : 0); if (dht11_wait_pin_low(50)) break; } return byte; }注意wait函数的timeout_us参数我这里减一次延时1us实际执行时间可能不是严格1us但只要量级正确就能做超时保护。更严谨的做法是用硬件定时器计数只是代码复杂度会高一些。4.3 读取完整数据与校验完整读取函数里把起始信号、响应等待、5字节读取、校验串在一起#define DHT11_START_LOW_MS 20u #define DHT11_RESP_TIMEOUT 200u uint8_t dht11_read(DHT11_Data *out) { if (out NULL) return 1; DHT11_Data tmp; /* 1. 主机发送起始信号 */ dht11_set_output_mode(); dht11_write_low(); delay_ms(DHT11_START_LOW_MS); dht11_write_high(); dht11_set_input_mode(); /* 释放总线让上拉电阻拉高 */ delay_us(30); /* 释放后的稳定时间 */ /* 2. 等待DHT11响应先拉低约80us */ if (dht11_wait_pin_low(DHT11_RESP_TIMEOUT)) return 1; /* 3. 等待响应低电平结束然后等待响应高电平结束 */ if (dht11_wait_pin_high(DHT11_RESP_TIMEOUT)) return 1; if (dht11_wait_pin_low(DHT11_RESP_TIMEOUT)) return 1; /* 4. 读取40位数据5个字节高位在前 */ tmp.humi_int dht11_read_byte(); tmp.humi_dec dht11_read_byte(); tmp.temp_int dht11_read_byte(); tmp.temp_dec dht11_read_byte(); tmp.checksum dht11_read_byte(); /* 5. 校验和前四个字节累加取低8位 */ uint8_t sum (uint8_t)(tmp.humi_int tmp.humi_dec tmp.temp_int tmp.temp_dec); if (sum ! tmp.checksum) return 2; *out tmp; return 0; }写完这段代码你会发现一个问题读数据之前DHT11的时序里会有一个“响应高电平结束”的操作紧接着才是第一位数据。我这里的第三个等待是在等响应高电平结束后的低电平前导然后进dht11_read_byte时又等低电平结束逻辑上是顺的。4.4 主循环与串口输出主程序里每2秒读一次打印出温湿度这个间隔要长于DHT11的采样周期避免读到上一次的旧数据#include stdio.h int main(void) { dht11_pin_init(); DHT11_Data dht; while (1) { uint8_t err dht11_read(dht); if (err 0) { float humi (float)dht.humi_int (float)dht.humi_dec * 0.1f; float temp (float)dht.temp_int (float)dht.temp_dec * 0.1f; /* 处理负温度整数部分最高位为1时表示负值 */ if (dht.temp_int 0x80) { temp -(float)((~dht.temp_int 0x7F) 1) - (float)dht.temp_dec * 0.1f; } printf(Humi: %.1f%%RH Temp: %.1f°C\n, humi, temp); } else if (err 1) { printf(DHT11 timeout\n); } else { printf(DHT11 checksum error\n); } delay_ms(2000); } }这里要注意printf在嵌入式平台上需要重定向到串口或者OLED具体看你用的开发环境。Arduino环境里可以直接用Serial.println51单片机上可能要用串口中断发送函数替代printf。逻辑完全一样。4.5 读取失败时的重试机制我第一次写DHT11驱动时遇到最烦的问题就是上电后第一次读取必然超时。原因很简单DHT11刚上电需要时间完成内部数据采集主机如果立刻发起始信号它还在忙根本不会响应。解决方法是主循环里做三到五次的轮询重试间隔至少100msuint8_t dht11_read_with_retry(DHT11_Data *out, uint8_t retry_times) { for (uint8_t i 0; i retry_times; i) { if (dht11_read(out) 0) return 0; delay_ms(100); } return 1; }另外如果你在很短的间隔内反复读DHT11比如200ms一次会发现读到的值是稳定的但这不代表传感器更新了它只是在重复返回上一次的采样结果。DHT11的数据更新周期典型是1到2秒所以应用层最好把读取周期控制在不小于1秒推荐2秒。5. 一个完整的最小实例从面包板到实物5.1 硬件接线清单这里给出一套可以直接复现的最小系统方案。需要的硬件不多DHT11模块或裸传感器一个面包板、杜邦线若干主控板一个任意带GPIO的开发板都行4.7kΩ电阻一个如果用的是裸传感器接线规则很简单。以裸传感器为例VCC接电源正极3.3V或5V都行GND接电源负极DATA接一个空闲GPIO同时在DATA和VCC之间焊一个4.7kΩ电阻。如果买的是模块板不需要额外接电阻直接接三根线即可。这里有个小细节DHT11的DATA线尽量不要和电源线并行走太长距离超过20cm建议用双绞线或屏蔽线。我试过用40cm杜邦线裸接数据偶尔会校验失败把线缩到15cm以内后问题直接消失。单总线通信对线路寄生电容比较敏感走线越短越好。5.2 上拉电阻选型逻辑上拉电阻到底选4.7k还是10k很多新手会纠结。核心考虑两条一是上拉能力够不够二是阻值不能太小导致灌电流过大。DHT11的从机输出级在拉低总线时需要把总线电压拉低到逻辑0的门限以下上拉电阻越小拉低时流过的电流越大从机负担越重。好在上拉电阻的下拉方不是DHT11而是主机GPIO在输出低电平阶段承担灌电流DHT11只在响应和数据输出阶段拉低总线。实测下来4.7k比10k更抗干扰适合线稍长或者电源纹波大的场合10k功耗更低在电池供电设备里更合适。如果你用的主控是3.3V供电上拉电阻接3.3V即可不要接到5V不然引脚电平可能超过GPIO耐压。模块板上自带上拉电阻接的是模块的VCC你给模块供5V它就上拉到5V供3.3V就上拉到3.3V所以模块的VCC要和主控逻辑电平匹配。5.3 文件组织与编译注意点一个干净的工程建议分成三个文件dht11.h放数据结构和函数声明dht11.c放时序和读取逻辑main.c放主循环和打印逻辑。把所有底层函数放在dht11.c文件顶部的platform区注释写清楚每块板子怎么改这样以后换平台直接改这一个文件。编译时有个容易踩的坑优化等级开太高比如-O2或-O3程序里的空循环延时可能会被编译器优化掉导致起始信号只有几微秒而不是18ms。遇到这种问题有两种处理方式一是把延时函数声明为volatile或者用内嵌汇编实现精准延时二是把优化等级降到-O0或-O1。我自己的习惯是始终用硬件定时器做延时软件延时只做粗调。6. 常见问题与排查技巧实录6.1 问题速查表下面这些是我在实际使用中和读者反馈中筛选出的高频问题可以直接当排查手册用。现象可能原因排查顺序读到的数据全是0或255引脚没接对 / 没加上拉电阻 / GPIO模式错误先量DATA引脚静态电平一直超时起始信号太短 / 上电后立刻读取 / 中断干扰增大起始低电平到20ms做重试校验和经常失败线路太长 / 电源纹波大 / 读取间隔太短缩短杜邦线加104去耦电容湿度值固定不变DHT11采样周期未到 / 传感器损坏等2秒再读换传感器测试温度正常读数湿度明显乱传感器老化 / 湿度超出量程放到40%~60%RH环境测试上电第一次读失败DHT11内部初始化未完成上电后延时1s再开始读数值跳变厉害电源电压偏低 / 总线上有干扰换稳定电源检查地线6.2 一次真实的排障过程有一次做环境监测Demo时程序在办公室电脑上跑得好好的拿板子到实验台接上就频繁超时。一开始怀疑代码问题后来用逻辑分析仪抓波形发现起始信号之后DHT11根本没有响应。换了一个传感器又正常判断是那个传感器本身坏了。所以排障一定要分三步走先查供电和上拉再划信号波形最后才考虑代码逻辑不要一上来就重写驱动。DHT11这种低价传感器个体差异挺明显同一批次都能遇到响应时间不一致的。我在驱动里加了超时保护和重试机制之后对个体差异的容忍度大幅提升。这也是为什么我强烈建议不要在核心逻辑里用死等的方式去等响应一段代码里没有超时保护一旦DHT11不响应整个程序就卡死了。6.3 用逻辑分析仪定位时序问题没有示波器的话便宜的8通道逻辑分析仪就够用了。把DATA线接到逻辑分析仪通道上采样率设置成2MHz以上触发模式选下降沿然后读一次传感器。抓到的波形里应该能看到一段18ms以上的低电平然后是响应信号的一低一高再后面是一串每条约50us低电平加上不同宽度高电平的方波。判断0和1可以直接看高电平宽度窄的是0宽的是1。如果你抓到的波形里所有高电平宽度完全一样说明DHT11并没有正确区分输出或者你抓错了引脚。用逻辑分析仪看完一次波形你对单总线时序的理解绝对会深一个层次强烈建议有条件的朋友都试一下。6.4 长线、多传感器与电源稳定性实际项目里如果要把DHT11放在离主控两三米远的位置比如温室大棚现场采集有两个办法一是用带屏蔽的双绞线屏蔽层单端接地二是就近把传感器电源并联一个0.1uF电容用低频噪声抑制。实测下来长线场景里上拉电阻从10k换成4.7k会明显改善上升沿因为上拉电阻越小RC充电时间常数越小电平跳变更快误码率更低。接多个DHT11时每个传感器用独立的GPIO和独立上拉电阻不要共用一个上拉电阻共用一个引脚。不同传感器的内部RC振荡器漂移会导致它们的响应时序不完全一致共线风险很高。最稳妥的做法是分时读取每个传感器读取间隔错开避免总线上出现竞争。6.5 中断与RTOS环境下的特殊处理如果你用的是FreeRTOS或者裸机加中断的架构读DHT11时一定要考虑任务切换和中断抢占的问题。最简单最粗暴的办法是在读时序前关闭全局中断读完再打开整个读取过程最多也就几毫秒对系统实时性的影响通常可以忽略。如果用RTOS可以用临界区保护dht11_read调用避免数据位采样时被更高优先级任务打断。另一个经验是在主循环里不要频繁调用读取函数只保留一个任务负责周期性读DHT11其他任务通过共享变量拿最新温湿度值。这样既保证了读取时序稳定又不占用其他任务的运行时间。共享变量记得声明成volatile否则编译器可能把它优化进寄存器导致其他任务读到旧值。最后分享一个小经验也是我几轮迭代后觉得很顺手的处理方式驱动层不要直接返回“读失败”就完事最好再维护一组“最近成功数据”和“最近成功时间戳”。当某次读取失败时上层可以选择继续用旧数据展示同时把异常计数记下来。这样即使DHT11偶尔抽风你的设备界面也不会突然显示一堆问号体感会稳很多。DHT11本身就不是什么高精尖器件只要时序上心、线路短一点、电源干净一点它能在小项目里安安稳稳跑很久。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进