
1. 从零开始认识你的DHT11温湿度传感器如果你刚开始玩51单片机想做个能显示温度和湿度的小玩意儿比如一个桌面环境监测仪或者给花盆加个土壤湿度提醒那DHT11几乎是你绕不开的一个“老朋友”。这玩意儿价格便宜几块钱一个接线简单就三根线而且网上资料一大堆对新手特别友好。我第一次用它的时候感觉就像找到了一个靠谱的搭档不用折腾复杂的模拟信号转换直接就能读到数字化的温湿度值。DHT11到底是个啥你可以把它想象成一个自带“小电脑”的迷你气象站。它内部不光有感知湿度的湿敏电容和感知温度的NTC热敏电阻还集成了一个8位的微控制器。这个微控制器的作用可大了它负责把电容和电阻变化的模拟信号转换成我们单片机容易理解的数字信号然后通过一根数据线打包发送出来。所以我们单片机要做的不是去测量电压电流然后换算而是按照约定好的“暗号”也就是通信协议去这根数据线上读取已经处理好的数据包。这大大降低了我们编程的难度。它的性能参数对于日常玩玩完全够用。温度测量范围是0到50摄氏度精度在正负2度以内湿度测量范围是20%到90%RH精度在正负5%RH以内。分辨率都是1也就是说它报告温度是25度、26度不会告诉你25.3度。对于室内环境监测、仓库温湿度告警这类应用这个精度完全没问题。我实测过在室内同一位置它和那些好几百块的温湿度计读数相差很小稳定性也不错。供电方面3.3V到5V都可以正好匹配我们最常用的5V供电的51单片机开发板比如STC89C52RC。接线更是简单到令人发指VCC接电源正极5VGND接电源负极中间那根DATA数据线随便接在单片机的一个I/O口上就行比如P1.0。记得在数据线和电源之间通常还会接一个4.7K或5.1K的上拉电阻这个电阻非常重要它能保证数据线在空闲时保持稳定的高电平是单总线通信能正常工作的前提。很多开发板为了省事会把这个电阻直接做在板子上如果你的模块上没有自己加一个也不麻烦。2. 握手暗号深入理解单总线通信时序驱动DHT11最核心、也是最容易让新手卡住的地方就是它的通信时序。它用的是一种叫“单总线”的协议顾名思义就是用一根数据线既当发送又当接收还要靠它来同步时钟。这种协议节省I/O口但对时序的要求极其严格差个几微秒可能数据就读不对了。所以理解并精确实现这个“握手暗号”是成功的关键。整个通信过程可以拆解成三步单片机发起“开始信号”、DHT11回应“响应信号”、然后DHT11发送“40位数据”。我们先看第一步单片机怎么发起开始信号。这个过程是单片机作为主机主动控制的。首先单片机要把连接DHT11数据线的那个I/O口假设是P1.0设置为输出模式然后拉低这个引脚也就是输出一个低电平。这个低电平要保持至少18毫秒。我刚开始做的时候用delay_ms(20)比较稳妥。之后单片机要把这个引脚拉高并保持20到40微秒然后迅速把引脚切换成输入模式准备读取DHT11的回应。这个拉高又释放的动作就像是对DHT11说“喂醒醒我要读数据了”紧接着第二步DHT11的响应。当DHT11检测到数据线被主机拉低又拉高后它会先拉低数据线大约80微秒作为“我收到了”的应答然后再拉高数据线大约80微秒表示“我要开始发数据了你准备好”。我们在程序里就需要用while循环去等待这两个变化。具体来说当单片机释放总线并切换为输入后立刻用一个while(!dht_data)等待数据线变低DHT11拉低应答等到了之后再用一个while(dht_data)等待数据线再次变高DHT11准备发送。这两个等待必须要有超时处理否则如果传感器没接好或者坏了程序就会死在这里。我一般会加一个计数器循环等待一定次数后如果还没等到就认为超时错误返回一个错误码这样程序更健壮。最精彩的部分是第三步接收那40位数据。每一位数据无论是0还是1都是以一个50微秒左右的低电平起始位开始区别在于随后高电平的持续时间。如果高电平持续约26-28微秒那么这一位就是“0”如果高电平持续约70微秒那么这一位就是“1”。所以我们的读取策略是先等待那个50微秒的低电平起始位过去用while(!dht_data)跳过然后延时一个很短的时间比如30微秒。延时结束后立刻去检测数据线此时的电平。如果此时已经是高电平说明高电平持续时间长是“1”如果此时还是低电平说明高电平持续时间短是“0”。判断完后再用一个while(dht_data)循环等待这个位的高电平结束准备读取下一位。如此循环40次就把5个字节的数据读出来了。这里有个细节很重要延时的30微秒这个值很关键。它必须大于“0”信号的高电平时间26-28us但又小于“1”信号的高电平时间70us。这样在延时结束后采样才能准确区分0和1。这个延时可以用单片机的空循环来实现但要注意不同主频的单片机空循环的次数需要调整。我常用的STC89C52在11.0592MHz晶振下用_nop_()函数嵌套循环来微调实测下来比较稳定。3. 拆解数据包40位数据的含义与校验费了老大劲读回来的40位数据可不是直接就是温度和湿度。它是一串按照特定格式打包好的二进制数我们需要像拆快递一样把它拆开并检查一下包裹有没有在运输中损坏数据校验。这40位数据被分成了5个部分每个部分8位1个字节字节0湿度整数部分。比如读回来是十进制53就表示湿度是53%RH。字节1湿度小数部分。对于DHT11这个字节永远是0。是的你没看错DHT11的湿度分辨率是1%RH所以没有小数。这个字节主要是为了和更高精度的DHT22它的小数部分有意义保持数据格式兼容。字节2温度整数部分。比如读回来是十进制24就表示温度是24摄氏度。字节3温度小数部分。对于DHT11这个字节同样有意义分辨率是1°C。比如读回来是4就表示0.4°C。所以温度值 字节2 字节3 / 10.0。字节4校验和。这是防止数据读取出错的关键。它的值等于前面四个字节字节0到字节3相加的和然后只取低8位。我们来举个具体的例子把整个过程串起来。假设我们读回来的5个字节的十六进制值是0x35,0x00,0x18,0x04,0x51。第一步先校验。计算0x35 0x00 0x18 0x04 0x51。结果正好等于第五个字节0x51说明数据传输过程中没有出错数据可信。第二步解析数据。0x35转成十进制是53所以湿度整数是53%RH。0x00是0湿度小数是0。因此湿度 53.0%RH。第三步解析温度。0x18转成十进制是24所以温度整数是24°C。0x04是4表示0.4°C。因此温度 24 4/10.0 24.4°C。在程序里我们通常会把读到的5个字节存到一个数组里比如unsigned char dht11_data[5]。校验就是判断dht11_data[0] dht11_data[1] dht11_data[2] dht11_data[3]的结果是否等于dht11_data[4]。如果相等才去使用温湿度数据如果不相等这次读取就作废应该重新发起一次读取流程。我强烈建议你不要省略校验这一步在实际项目中电磁干扰、接线松动都可能导致数据位跳变有了校验就能及时发现错误避免显示一个明显离谱的温度比如85度而你不知道。4. 手把手代码实战从函数封装到数据处理理论懂了接下来我们撸起袖子写代码。我会用一个更工程化、更容易复用的方式来组织代码而不仅仅是把功能堆在主函数里。我们会创建两个文件dht11.c和dht11.h把DHT11相关的操作都封装起来。首先看头文件dht11.h它定义了接口和引脚#ifndef __DHT11_H__ #define __DHT11_H__ #include reg52.h // 包含51单片机寄存器定义 // 定义DHT11数据线连接的引脚这里以P1^0为例 sbit DHT11_DATA_PIN P1^0; // 函数声明 void DHT11_Init(void); unsigned char DHT11_ReadByte(void); unsigned char DHT11_ReadData(unsigned char *temperature_int, unsigned char *temperature_dec, unsigned char *humidity_int, unsigned char *humidity_dec); #endif头文件里我们用sbit关键字定义了数据线连接的具体引脚这样以后想换到P2^1只需要改这里就行。三个函数分别负责初始化、读取一个字节和读取完整的温湿度数据。然后是核心的源文件dht11.c。我们先写一个微秒级的延时函数这是精准时序的基石。51单片机没有硬件延时我们需要用空循环来模拟#include dht11.h #include intrins.h // 包含_nop_()函数 // 粗略的微秒延时函数针对11.0592MHz晶振调整 void Delay_us(unsigned int us) { while (us--) { _nop_(); _nop_(); _nop_(); // 根据实际测试调整_nop_()的数量 } } // 毫秒延时可以用已有的库函数或循环实现 void Delay_ms(unsigned int ms) { unsigned int i, j; for(i0; ims; i) for(j0; j123; j); // 针对11.0592MHz的粗略调整 }接下来是实现开始信号函数// 发送开始信号 void DHT11_Start(void) { DHT11_DATA_PIN 0; // 主机拉低 Delay_ms(20); // 保持低电平至少18ms DHT11_DATA_PIN 1; // 主机释放总线拉高 Delay_us(30); // 主机拉高20-40us // 之后主机会切换到输入模式这个在读取函数里做 }读取一个字节的函数是整个通信的精华它要循环8次拼装出一个字节// 从DHT11读取一个字节 unsigned char DHT11_ReadByte(void) { unsigned char i, byte 0; for (i 0; i 8; i) { // 等待50us低电平起始位结束 while (!DHT11_DATA_PIN); // 等待变高即起始位结束 Delay_us(35); // 延时35us后采样这个值很关键 byte 1; // 左移一位为下一位腾出空间 if (DHT11_DATA_PIN 1) { byte | 0x01; // 如果此时还是高电平说明是位1 } // 等待该位的高电平结束 while (DHT11_DATA_PIN); // 等待变低准备读取下一位 } return byte; }最后我们把所有步骤组合起来完成一次完整的温湿度读取// 读取温湿度数据成功返回1失败返回0 unsigned char DHT11_ReadData(unsigned char *temp_int, unsigned char *temp_dec, unsigned char *humi_int, unsigned char *humi_dec) { unsigned char buf[5]; unsigned char i, checksum; DHT11_Start(); // 发送开始信号 // 设置引脚为输入准备读取DHT11的响应 // 在51单片机中读引脚前要先写1这里我们之前已经拉高了所以直接读 // 更严谨的做法是操作端口的方向寄存器但51的IO口比较简单通常这样也行 // 等待DHT11的低电平响应信号~80us while (DHT11_DATA_PIN); // 先确保总线为高主机释放后 while (!DHT11_DATA_PIN); // 等待DHT11拉低 while (DHT11_DATA_PIN); // 等待DHT11拉高响应结束 // 连续读取5个字节 for (i 0; i 5; i) { buf[i] DHT11_ReadByte(); } // 校验数据 checksum buf[0] buf[1] buf[2] buf[3]; if (checksum buf[4]) { *humi_int buf[0]; *humi_dec buf[1]; // DHT11此为0 *temp_int buf[2]; *temp_dec buf[3]; return 1; // 读取成功 } return 0; // 校验失败 }在主函数main.c里调用就非常清晰了#include reg52.h #include dht11.h #include stdio.h // 如果要用printf void main() { unsigned char temp_int, temp_dec, humi_int, humi_dec; float temperature, humidity; // 初始化串口用于打印可选 // ... 串口初始化代码 ... while(1) { if (DHT11_ReadData(temp_int, temp_dec, humi_int, humi_dec)) { temperature temp_int temp_dec / 10.0; humidity humi_int humi_dec / 10.0; // humi_dec对DHT11为0 // 通过串口打印或者驱动LCD显示 printf(Temp: %.1f C, Humi: %.1f%%\r\n, temperature, humidity); } else { printf(DHT11 read error!\r\n); } Delay_ms(2000); // DHT11两次读取间隔需大于1秒 } }5. 避坑指南与性能优化让读取更稳定可靠代码写完了一烧录发现有时候能读出来有时候全是0或者错误值别急这是玩转DHT11的必经之路。我踩过不少坑这里分享几个最常见的“雷区”和解决办法。第一个大坑时序精度。这是新手最容易出问题的地方。我们代码里的Delay_us()和Delay_ms()函数其延时时间严重依赖于单片机的主频。如果你用的晶振不是11.0592MHz或者编译器优化等级不同延时时间会天差地别。解决办法有两个一是用示波器或者逻辑分析仪抓一下数据线的波形看看你的延时函数实际产生了多长的延时然后反复调整空循环的次数直到匹配DHT11要求的时序。二是如果条件允许可以使用单片机的定时器来产生精确的微秒级延时这样代码的可移植性和稳定性会好很多。第二个坑上拉电阻。单总线协议要求数据线在空闲时保持高电平必须依靠一个上拉电阻通常4.7K-10K。很多DHT11模块已经内置了这个电阻但如果你是自己用单独的传感器焊接千万别忘了加我曾经因为忘了加上拉电阻调试了一下午波形乱七八糟。第三个坑读取间隔。DHT11传感器在一次数据读取后需要一段时间进行内部模数转换和校准这个时间至少是1秒。所以你的主循环里两次调用DHT11_ReadData函数之间必须间隔1秒以上。频繁读取不仅得不到新数据还可能干扰传感器内部状态。我一般用定时器做个2秒一次的定时读取非常稳定。第四个坑电源噪声。DHT11对电源质量比较敏感。如果电源纹波太大或者单片机在读取数据时进行大电流操作比如驱动继电器、电机可能导致通信失败。解决办法是在DHT11的VCC和GND之间就近并联一个0.1uF的瓷片电容和一个10uF的电解电容用于滤波。这能有效提高在复杂电磁环境下的稳定性。性能优化方面我们可以把代码做得更健壮。比如在DHT11_ReadByte函数的while (!DHT11_DATA_PIN)和while (DHT11_DATA_PIN)等待循环里加入超时判断。如果等待超过一定时间比如100us电平还没变化就认为通信超时直接返回错误。这样可以防止因为传感器脱落或损坏导致程序死循环。另外一次读取失败是常有的事尤其是在上电初期。我们可以实现一个“读取重试”机制。在主函数里如果一次读取校验失败不要立刻报错可以延时几毫秒后重试2-3次。很多时候第二次或第三次就能成功。我的经验是连续读取三次只要有一次成功就采用成功的数据这样可以大大提高用户体验不会让设备动不动就显示“读取错误”。最后关于数据处理。虽然DHT11的分辨率是1但我们显示的时候用float类型保留了小数这是为了代码格式的统一也方便如果你以后升级到DHT22分辨率0.1时只需改传感器驱动显示部分的代码不用动。如果你需要把数据通过无线模块比如ESP8266发送到服务器可以考虑将float类型的数据放大10倍或100倍转换成整数再发送能节省带宽并避免浮点传输的复杂性。把这些坑都避开优化点都加上之后你的DHT11驱动就会变得非常皮实耐用了。我做过一个放在阳台的自动浇花系统用这套代码驱动DHT11监测空气湿度连续跑了半年多几乎没有误报过稳定性让我这个老电工都挺满意的。