三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

DHT11温湿度传感器驱动全解析:从51单片机到STM32的时序控制与代码实现

DHT11温湿度传感器驱动全解析:从51单片机到STM32的时序控制与代码实现

1. 项目概述:从一颗“小豆子”说起温湿度感知

如果你玩过单片机,或者对物联网、智能家居有点兴趣,那你大概率见过或者听说过DHT11这个名字。它长得就像一颗黑色的“小豆子”,带着几个引脚,价格便宜到几乎可以忽略不计,是无数电子爱好者、学生和工程师入门传感器世界的“第一课”。

DHT11本质上是一个集成了温湿度传感和数字信号输出的复合传感器模块。说人话就是,你把它接上单片机,它就能告诉你当前的温度和湿度是多少,而且数据已经是处理好的数字信号,单片机直接“读”就行,省去了模拟信号采集、放大、校准等一系列麻烦事。我最早接触它是在大学做课程设计,当时需要一个环境监测装置,第一个想到的就是它。这么多年过去了,虽然市面上出现了精度更高、性能更好的传感器(比如DHT22、SHT30),但DHT11凭借其极低的成本、简单的单总线协议和广泛的资料支持,依然是教学、原型验证和小型项目中无可替代的常青树。

这篇文章,我就以一个老电子爱好者的身份,跟你彻底掰扯清楚DHT11。从它内部的“小心思”(工作原理)到怎么跟它“对话”(通信协议),再到手把手带你用经典的51单片机和更强大的STM32把它驱动起来。我会把代码里每一行关键语句背后的逻辑、调试时踩过的坑、以及如何让读数更稳定的小技巧,毫无保留地分享给你。无论你是刚拿起单片机的萌新,还是想快速验证一个想法的老手,这篇内容都能让你把DHT11玩得明明白白。

2. DHT11核心原理与通信协议深度拆解

2.1 传感器内部构造与工作逻辑

别看DHT11个头小,它内部可是一个完整的微型系统。拆开来看(当然我们不建议物理拆解,容易损坏),其核心主要由两部分构成:一个高分子湿敏电阻和一个NTC(负温度系数)热敏电阻。湿敏电阻的阻值会随着环境湿度的变化而改变,热敏电阻的阻值则随温度变化。这两个电阻的变化,被内部一个高精度的、经过校准的模拟-数字转换电路(ADC)捕捉并转换成数字信号。

但DHT11最巧妙的设计在于,它内部还集成了一颗8位的微处理器(MCU)。这颗MCU负责管理整个传感器的工作:上电初始化、定时触发温湿度测量、控制ADC进行数据采集、对原始数据进行校准补偿、最后将处理好的数据按照特定的单总线协议打包发送出去。这就是为什么DHT11输出的是数字信号,而非需要你外接ADC去读取的模拟电压。这颗内置的MCU,相当于给你配了一个免费的、专为这颗传感器优化的“数据秘书”,大大降低了外部主控(你的51或STM32)的负担和开发难度。

注意:DHT11的测量是非连续的。每次你向它请求数据,它才会启动一次完整的测量周期,从触发到数据准备好输出,大约需要2秒的时间。在此期间,总线必须保持空闲,不能频繁请求,否则会导致通信失败。这是由其内部物理传感元件的响应和稳定时间决定的。

2.2 单总线协议:如何与“小豆子”对话

DHT11与单片机之间通过一根数据线(DATA)进行通信,这就是所谓的“单总线”(1-Wire)协议。这根线既要用来传递控制命令(单片机→DHT11),也要用来回传数据(DHT11→单片机),全靠精确的时序来区分。

整个通信过程可以分为三个大阶段:主机启动信号从机响应信号数据传输

第一阶段:主机启动信号单片机作为主机,需要先发起一次“呼叫”。具体操作是:

  1. 单片机将DATA引脚设置为输出模式,并拉低(输出0)至少18毫秒(ms)。这个长时间的低电平是一个“复位”或“启动”信号,告诉DHT11:“我要开始一次数据读取了,你准备好。”
  2. 然后,单片机将DATA引脚拉高(输出1)20-40微秒(μs),并迅速将引脚切换为输入模式(准备读取)。这个短暂的高电平是“释放总线”,相当于说:“好了,我说完了,现在轮到你了。”

第二阶段:从机响应信号DHT11检测到主机启动信号结束后,会做出回应:

  1. DHT11会将DATA线拉低约80μs,表示:“收到!我听到了。”
  2. 接着,DHT11会将DATA线拉高约80μs,表示:“数据马上就来,你准备好接收。”

第三阶段:数据传输响应信号之后,DHT11开始连续发送40位(5字节)的数据。每一位数据(0或1)都用一种特定宽度的低电平加高电平的组合来表示。

  • 数字‘0’的表示:约50μs的低电平后,跟随约26-28μs的高电平。
  • 数字‘1’的表示:约50μs的低电平后,跟随约70μs的高电平。

关键在于,区分0和1,不是看低电平的宽度(它们都是50μs),而是看紧随其后的高电平的持续时间。单片机需要在检测到低电平结束(上升沿)后,开始计时,测量高电平持续了多久。如果时间较短(比如小于40μs),则判定为‘0’;如果时间较长(比如大于40μs),则判定为‘1’。

这40位数据的含义如下:

  • 字节0:湿度的整数部分(单位:%RH)。
  • 字节1:湿度的小数部分(对于DHT11,此字节恒为0,因为它只能输出整数湿度)。
  • 字节2:温度的整数部分(单位:摄氏度℃)。
  • 字节3:温度的小数部分(对于DHT11,此字节恒为0)。
  • 字节4:校验和。其值等于(字节0 + 字节1 + 字节2 + 字节3)的低8位。

实操心得:时序是驱动DHT11的灵魂,也是最容易出错的地方。不同单片机的主频不同,执行一条指令的微秒数也不同。在51上能用的延时函数,直接搬到STM32上肯定会出问题。因此,我们必须根据自己使用的单片机主频,精确地编写微秒级延时函数。这是后续所有代码的基础。

3. 基于51单片机的驱动实现与代码精讲

51单片机(比如经典的STC89C52)是学习嵌入式最经典的平台,运行速度较慢(通常12MHz或11.0592MHz),正好让我们可以清晰地理解时序控制的每一个细节。

3.1 硬件连接与工程准备

硬件连接极其简单:

  1. DHT11的VCC引脚接单片机电源(+5V)。
  2. GND引脚接电源地。
  3. DATA引脚接单片机的一个I/O口(例如P2^0)。注意,这个引脚需要接一个4.7KΩ或10KΩ的上拉电阻到VCC,以确保在总线空闲时保持高电平,这是单总线协议的硬件要求。

在代码层面,我们需要准备两个核心函数:一个精确的微秒级延时函数,和一个用于单总线通信的GPIO引脚操作函数。

// 假设使用STC89C52,晶振11.0592MHz // 微秒级延时函数(不精确,但用于DHT11基本可行,需要根据实际调试) void Delay_us(unsigned int us) { while (us--) { _nop_(); _nop_(); _nop_(); _nop_(); // 空指令,消耗约几个机器周期 // 实际中需要示波器或软件模拟精确校准 } } // 毫秒级延时函数 void Delay_ms(unsigned int ms) { unsigned int i, j; for(i=0; i<ms; i++) for(j=0; j<114; j++); // 针对11.0592MHz的粗略延时 } // 定义DHT11数据线连接的引脚 sbit DHT11_DATA = P2^0;

3.2 核心驱动函数编写与逐行解析

驱动DHT11的核心是一个读取40位数据的函数。下面我们分步骤实现并解析。

步骤1:主机启动函数

void DHT11_Start(void) { DHT11_DATA = 1; // 先拉高,确保初始状态 Delay_us(30); DHT11_DATA = 0; // 主机拉低至少18ms Delay_ms(20); // 这里延时20ms,满足要求 DHT11_DATA = 1; // 拉高20-40us Delay_us(30); // 延时30us // 之后,主机会将引脚设置为输入模式,但51单片机IO口在读取前自动为输入,所以这里不显式设置 }

这里有一个细节:51单片机的I/O口在作为输入时,需要先向端口写‘1’,才能正确读取外部电平。所以我们先DHT11_DATA = 1,再读取。在更严谨的写法中,读取前会先软件置高。

步骤2:等待从机响应函数

unsigned char DHT11_Check_Response(void) { unsigned char retry = 0; // 等待DHT11拉低总线(80us低电平响应) while (DHT11_DATA && retry < 100) { // 超时计数,防止死循环 retry++; Delay_us(1); } if (retry >= 100) return 1; // 超时,响应失败 retry = 0; // 等待DHT11拉高总线(80us高电平响应) while (!DHT11_DATA && retry < 100) { retry++; Delay_us(1); } if (retry >= 100) return 1; // 超时,响应失败 return 0; // 响应成功 }

这个函数用于检测DHT11是否给出了正确的应答信号。使用while循环加超时判断是防止程序卡死的必备技巧。如果DHT11没接好或者损坏,没有拉低总线,程序就会一直空等。加入超时机制后,等待一段时间(比如100*1us=100us)后还没等到预期电平,就返回错误,让主程序能处理异常。

步骤3:读取一位数据函数这是整个驱动的核心,需要精确判断高电平的持续时间。

unsigned char DHT11_Read_Bit(void) { unsigned char retry = 0; // 等待50us低电平开始位结束 while (!DHT11_DATA && retry < 60) { retry++; Delay_us(1); } // 低电平结束,开始计时高电平持续时间 retry = 0; while (DHT11_DATA && retry < 100) { // 计数高电平时间 retry++; Delay_us(1); // 每次循环约消耗1us+指令时间 } // 根据计数值判断是0还是1 // 需要根据实际调试确定阈值。通常,计数值小于40判定为0,大于40判定为1 if (retry > 40) { return 1; } else { return 0; } }

这里的40是一个经验阈值,并非绝对。因为Delay_us(1)并不精确等于1微秒,while循环本身也有开销。你需要通过逻辑分析仪或者串口打印出retry的值,观察传输一个0和一个1时,retry的实际范围,然后确定一个合理的中间值作为阈值。这是调试DHT11的关键一步。

步骤4:读取一个字节及完整数据函数

unsigned char DHT11_Read_Byte(void) { unsigned char i, data = 0; for (i = 0; i < 8; i++) { data <<= 1; // 左移一位,为接收下一位腾出位置 data |= DHT11_Read_Bit(); // 读取一位并拼接到data上 } return data; } unsigned char DHT11_Read_Data(unsigned char *temp, unsigned char *humi) { unsigned char buf[5]; unsigned char i, checksum; DHT11_Start(); if (DHT11_Check_Response()) { return 1; // 响应失败 } // 连续读取5个字节(40位) for (i = 0; i < 5; i++) { buf[i] = DHT11_Read_Byte(); } // 校验数据 checksum = buf[0] + buf[1] + buf[2] + buf[3]; if (checksum != buf[4]) { return 2; // 校验和错误 } // 数据赋值,DHT11小数部分为0,只取整数部分 *humi = buf[0]; *temp = buf[2]; return 0; // 读取成功 }

这个函数整合了所有步骤。它先启动通信,然后检查响应,接着循环读取5个字节存入数组buf,最后进行校验和验证。如果校验和正确,则将湿度和温度的整数部分通过指针传递给调用者。函数返回不同的值代表不同的状态(0成功,1响应失败,2校验错误),便于上层程序处理。

3.3 主程序逻辑与调试技巧

在主函数中,我们通常以大于2秒的间隔循环读取DHT11,并通过串口将数据打印到电脑上查看。

void main() { unsigned char temperature, humidity; unsigned char res; // 初始化串口(代码略) UART_Init(); Delay_ms(1000); // 上电后等待DHT11稳定 printf("DHT11 Test Start...\r\n"); while(1) { res = DHT11_Read_Data(&temperature, &humidity); if (res == 0) { printf("Humidity: %d%% RH, Temperature: %d C\r\n", humidity, temperature); } else if (res == 1) { printf("DHT11 Response Error!\r\n"); } else if (res == 2) { printf("DHT11 Checksum Error!\r\n"); } Delay_ms(2500); // 每次读取间隔至少2秒 } }

调试技巧实录

  1. 无响应或全是0:首先检查硬件连接,VCC、GND、上拉电阻是否接好。然后用万用表量一下DATA引脚电压,空闲时是否约为VCC(高电平)。如果硬件无误,重点检查DHT11_Start()函数中拉低的时间是否足够长(>=18ms),以及拉高后的延时是否在20-40us范围内。
  2. 数据乱码或校验错误:99%的原因是时序不精确,特别是DHT11_Read_Bit()函数中判断0/1的阈值设置不对。最有效的调试方法是“打印调试法”:在DHT11_Read_Bit()函数中,不要直接返回0或1,而是把retry的计数值通过串口打印出来。然后你观察,当传输一个已知数据时(比如湿度50),对应的每一位是0还是1,它的retry值分别是多少。多测几次,你就能清晰地看到0和1对应的计数值范围,从而确定一个可靠的阈值。
  3. 读数偶尔跳变:DHT11对电源纹波比较敏感。确保电源稳定,尤其在DATA引脚动作时。可以在VCC和GND之间就近并联一个100nF的瓷片电容,用于滤波。同时,确保读取间隔大于2秒。

4. 基于STM32的驱动实现与优化策略

相较于51单片机,STM32(以常见的STM32F103C8T6为例)性能强大得多,主频通常在72MHz,有更精确的定时器,并且库函数开发模式与51的寄存器操作有很大不同。我们的驱动策略也需要相应升级。

4.1 硬件连接与GPIO配置

连接方式与51类似,VCC接3.3V或5V(注意DHT11供电范围是3.3V-5.5V),GND接地,DATA接任一GPIO引脚(如PA0),同样需要上拉电阻。

在STM32上,我们使用HAL库进行开发。首先需要配置这个GPIO引脚。

  1. 在CubeMX中,将PA0设置为推挽输出模式(用于主机启动时拉低/拉高),同时不开启上拉下拉(因为外部已有上拉电阻)。
  2. 在代码中,我们需要动态切换引脚的模式:输出模式用于发送起始信号,输入模式(最好是上拉输入)用于读取数据。

我们可以封装一个函数来切换模式:

// 设置DATA引脚为输出模式(主机控制总线) void DHT11_Set_Output(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出 GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速即可 HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); } // 设置DATA引脚为上拉输入模式(读取从机数据) void DHT11_Set_Input(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // 输入模式 GPIO_InitStruct.Pull = GPIO_PULLUP; // 启用内部上拉,与外部上拉形成双重保障 HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); }

4.2 高精度延时与信号读取的实现

在72MHz的STM32上,用循环来实现微秒延时极不准确,且会受编译器优化影响。最佳实践是使用系统滴答定时器(SysTick)或者一个通用定时器(如TIM2)来产生精确的微秒延时。

这里以SysTick为例(HAL库已经提供了HAL_Delay()用于毫秒延时,但我们需要微秒):

// 基于SysTick的微秒延时函数(假设系统时钟频率为72MHz) void DHT11_Delay_us(uint16_t us) { uint32_t ticks; uint32_t told, tnow, tcnt = 0; uint32_t reload = SysTick->LOAD; // SysTick重装载值 ticks = us * 72; // 72MHz下,1us需要72个周期 told = SysTick->VAL; // 获取当前计数值 while (1) { tnow = SysTick->VAL; if (tnow != told) { if (tnow < told) { tcnt += told - tnow; // 注意:SysTick是递减计数器 } else { tcnt += reload - tnow + told; } told = tnow; if (tcnt >= ticks) { break; // 延时时间到 } } } }

这个函数通过直接操作SysTick寄存器来实现相对精确的微秒延时,比空循环可靠得多。

读取一位数据的逻辑与51类似,但得益于精确的延时和HAL库的GPIO读取函数,代码更简洁可靠:

uint8_t DHT11_Read_Bit(void) { uint32_t timeout = 0; // 等待低电平开始位结束(约50us) while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (++timeout > 100) return 0xFF; // 超时返回错误 DHT11_Delay_us(1); } // 精确延时40us,避开低电平后的固定延时,直接采样高电平中点 DHT11_Delay_us(40); // 40us后,如果还是高电平,说明是位‘1’,否则是位‘0’ if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { // 等待高电平结束 while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET); return 1; } else { return 0; } }

这里采用了一种更巧妙的“采样法”:在每一位开始的50us低电平后,我们固定延时40us,然后立即采样DATA线的电平。根据协议,如果该位是‘1’,此时高电平应仍在持续(总高电平约70us);如果是‘0’,此时高电平已经结束(总高电平约28us)。这种方法减少了对高电平持续时间的精确计时依赖,代码更健壮,受系统时序抖动的影响更小。

4.3 工程集成与错误处理机制

完整的STM32读取函数与51版本结构相似,但集成到STM32工程中时,需要考虑RTOS或中断环境的影响。

uint8_t DHT11_Read_Data(uint8_t *temp, uint8_t *humi) { uint8_t buf[5] = {0}; uint8_t i, checksum; // 1. 主机启动信号 DHT11_Set_Output(); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); // 拉低 HAL_Delay(20); // 延时20ms,使用HAL_Delay HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); // 拉高 DHT11_Delay_us(30); // 延时30us // 2. 切换为输入模式,等待从机响应 DHT11_Set_Input(); // 等待从机拉低 (超时处理) if (DHT11_Wait_Pin_State(GPIO_PIN_RESET, 100) != 0) return 1; // 等待从机拉高 (超时处理) if (DHT11_Wait_Pin_State(GPIO_PIN_SET, 100) != 0) return 1; // 3. 读取40位数据 for (i = 0; i < 5; i++) { buf[i] = DHT11_Read_Byte(); // DHT11_Read_Byte内部调用DHT11_Read_Bit } // 4. 校验与返回 checksum = buf[0] + buf[1] + buf[2] + buf[3]; if (checksum != buf[4]) return 2; *humi = buf[0]; *temp = buf[2]; return 0; } // 辅助函数:等待引脚达到指定状态,带超时 uint8_t DHT11_Wait_Pin_State(GPIO_PinState state, uint32_t timeout_us) { uint32_t tickstart = HAL_GetTick(); // 使用毫秒超时,简化处理 uint32_t wait = (timeout_us + 999) / 1000; // 微秒转毫秒,向上取整 while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) != state) { if ((HAL_GetTick() - tickstart) > wait) { return 1; // 超时 } } return 0; }

STM32专属避坑指南

  1. 中断干扰:如果在一个高优先级的定时器中断或其它中断服务函数中频繁调用HAL_DelayDHT11_Delay_us,可能会严重干扰DHT11的精确时序,导致读取失败。解决方案:在读取DHT11的整个关键时序段(从启动到40位数据读完),最好能暂时关闭全局中断(__disable_irq()),读完后再开启(__enable_irq())。或者确保读取函数不在中断中被调用。
  2. GPIO速度配置:在输出模式下,GPIO速度(GPIO_SPEED_FREQ_)配置为LOW即可。配置过高(如VERY_HIGH)可能在引脚电平快速切换时产生不必要的振铃和噪声,影响信号完整性。
  3. 电源与接地:STM32的3.3V电源如果来自线性稳压器(LDO),其带载能力和纹波可能比5V差。如果发现DHT11工作不稳定,尝试单独给DHT11供电(仍共地),或者在其VCC和GND之间并联一个更大的电容(如10uF电解电容并联100nF瓷片电容)。

5. 常见问题排查与稳定性优化实战

无论用51还是STM32,驱动DHT11都可能遇到一些共性问题。下面我把自己和朋友们常踩的坑整理成表,并提供排查思路。

问题现象可能原因排查与解决思路
完全无响应,数据全为01. 电源未接通或电压不足。
2. DATA线未接上拉电阻。
3. 主机启动信号时序错误(拉低时间不足)。
4. DHT11模块损坏。
1. 用万用表测量VCC和GND间电压,确保在3.3V-5V之间。
2. 检查DATA引脚是否有4.7K上拉电阻到VCC。
3. 用示波器或逻辑分析仪观察启动信号,看低电平是否持续18ms以上。
4. 更换一个DHT11模块测试。
能收到响应,但数据校验总是失败1. 读取位的时序判断阈值不准(最常见)。
2. 延时函数不精确,受中断或优化影响。
3. 电源纹波大,在数据传输期间产生干扰。
4. 总线受到强干扰,信号畸变。
1.使用“打印调试法”,输出每一位判断时的计数值,重新校准0/1阈值。
2. 检查延时函数,在STM32上使用定时器或SysTick实现精确延时。关闭编译器优化(-O0)测试。
3. 在DHT11的VCC和GND引脚就近并联100nF电容。
4. 缩短DATA走线,远离电机、继电器等噪声源。
数据偶尔正确,大部分时间错误1. 读取间隔小于2秒,DHT11未准备好。
2. 单片机IO口模式切换不当(STM32常见)。
3. 在中断服务程序中调用读取函数。
1. 确保两次DHT11_Read_Data调用间隔大于2秒。
2. 在STM32中,确保从输出模式切换到输入上拉模式后再读取。
3.绝对避免在中断中读取。在主循环或低优先级任务中读取。
湿度或温度值固定不变1. 读取函数逻辑错误,只读到了第一个字节并重复赋值。
2. DHT11传感器本身故障(如受潮或物理损坏)。
1. 检查DHT11_Read_Data函数中buf数组的赋值和*humi/*temp的赋值是否正确对应了字节0和字节2。
2. 对传感器哈气,观察湿度值是否有变化。若无变化,可能传感器已失效。
STM32上运行正常,移植到51出错1. 延时函数的时钟基准不同。
2. GPIO操作方式不同(51直接操作端口,STM32用库函数)。
3. 51单片机IO口驱动能力或输入阻抗差异。
1. 根据各自的主频重新编写和校准微秒延时函数。
2. 确保51单片机在读取输入前,先向端口写‘1’。
3. 检查51单片机是否需要在DATA线上加更小的上拉电阻(如2.2K)以增强驱动。

稳定性优化进阶技巧:

  1. 多次读取取中值:由于DHT11精度本身有限(湿度±5%,温度±2℃),单次读数可能有跳动。可以在程序中连续读取3-5次,去掉最大最小值,取中间值的平均,能有效滤除偶然误差。
  2. 增加互斥锁(针对RTOS):如果在FreeRTOS等多任务系统中使用,需要将整个读取函数(从启动到读完)用互斥信号量(Mutex)保护起来,防止多个任务同时操作同一个DHT11设备导致时序混乱。
  3. 硬件滤波:除了电源并联电容,可以在DATA信号线上串联一个几十欧姆的小电阻(如33Ω),并与对地之间接一个几十皮法的小电容(如47pF),构成一个简单的RC低通滤波器,能有效抑制高频毛刺噪声。
  4. 软件超时与重试:像我们代码中做的那样,在任何while循环等待特定电平时,都必须加入超时机制。并且,在主调用层,如果一次读取失败(返回非0),可以延迟几百毫秒后自动重试1-2次,提高单次读取的成功率。

驱动DHT11就像和老朋友打交道,你越了解它的脾气(时序),沟通就越顺畅。从51到STM32,平台在变,但核心的通信协议和问题排查思路是相通的。希望这篇超详细的拆解,能帮你彻底驯服这颗经典的“小豆子”,让你在项目中轻松获取环境温湿度数据。

← 返回列表