ESP32-S3驱动DHT11温湿度传感器:单总线协议解析与移植实战

最近在做一个环境监测的小项目,需要用到温湿度传感器,DHT11这个老朋友自然就成了首选。它价格便宜、使用简单,一根数据线就能搞定,非常适合咱们嵌入式新手入门。但很多朋友在驱动DHT11时,总被它那个“单总线”时序搞得晕头转向,代码移植到ESP32-S3上也不顺利。今天我就结合自己踩过的坑,手把手带你从零开始,在ESP32-S3上搞定DHT11的驱动。

咱们的目标很明确:理解DHT11的单总线通信协议,写出一份稳定可靠的驱动代码,并把它成功移植到ESP32-S3的工程里,最后在串口上看到实时的温湿度数据。

1. 认识我们的“小伙伴”:DHT11温湿度传感器

在写代码之前,咱们得先了解要驱动的对象。DHT11是一款集成了已校准数字信号输出的温湿度复合传感器。说人话就是,它内部已经把模拟的温湿度信号转换成了数字信号,我们直接用单片机读取就行,省去了外部ADC和复杂的校准电路。

它的核心特点就是单总线通信。什么叫单总线?就是发送数据、接收数据都只用一根线(除了电源和地)。这根线既要当“话筒”又要当“耳朵”,全靠严格的时序来协调,这也是驱动它的关键所在。

先看看它的基本规格,做到心里有数:

参数规格
工作电压3.3V - 5.5V (ESP32-S3的3.3V正好)
工作电流1mA (非常省电)
温度测量范围0°C ~ 50°C
温度精度±2°C
湿度测量范围20% RH ~ 90% RH
湿度精度±5% RH
分辨率8位 (温度1°C,湿度1%RH)
通信协议单总线
引脚数量3 Pin (VCC, DATA, GND)

注意:DHT11的湿度小数部分输出恒为0,温度小数部分则有数据。所以它的湿度分辨率是1%RH,温度分辨率是0.1°C。

传感器有三个引脚,接线非常简单:

  • VCC:接ESP32-S3的3.3V引脚。
  • GND:接ESP32-S3的GND。
  • DATA:接ESP32-S3的任意一个GPIO引脚(我们后面用GPIO1为例)。

硬件连接就这么简单,接下来咱们要攻克最核心的部分——通信协议。

2. 深入核心:单总线通信协议时序全解析

DHT11的通信过程,就像你和传感器之间的一场“暗号”对话。整个过程分为四个阶段:主机(ESP32-S3)发起对话、传感器回应、传感器发送数据、对话结束。每个阶段都有严格的时间要求,差一点儿都可能读不到数据。

2.1 通信流程四步走

一次完整的读取数据过程如下:

  1. 起始信号:主机(我们)先拉低数据线至少18ms,然后拉高,告诉传感器:“我要开始读数据了!”
  2. 响应信号:传感器检测到起始信号后,会先把数据线拉低约80us作为回应,意思是“我听到了”,然后再拉高80us,表示“我准备好发数据了”。
  3. 数据传输:传感器开始发送40位数据。每一位数据都用一段低电平(54us)加一段高电平的时间长短来表示是0还是1。
  4. 结束信号:传感器发送完40位数据后,拉低数据线约54us,然后释放总线(总线由上拉电阻拉高)。主机需要把GPIO重新设置为输出模式并拉高,释放总线控制权。

2.2 数据位如何区分“0”和“1”?

这是协议里最需要理解的一点。每一位数据都以一个约54us的低电平开始,之后是一个高电平。区别就在于这个高电平的持续时间

  • 如果高电平持续约 27us,那么这一位是 0
  • 如果高电平持续约 70us,那么这一位是 1

所以,我们在代码里要做的就是:在检测到低电平结束(即高电平开始)后,等待一小段时间(比如35us),再去读取此时数据线的电平。如果还是高电平,说明高电平持续时间长,是1;如果已经变回低电平,说明高电平持续时间短,是0。

2.3 40位数据代表什么?

传感器一次会发送40位(5个字节)数据。数据格式如下:

数据内容字节说明
湿度整数部分字节0例如 35,表示湿度35%RH
湿度小数部分字节1DHT11恒为0
温度整数部分字节2例如 24,表示温度24°C
温度小数部分字节3例如 4,表示0.4°C
校验和字节4前4个字节相加结果的低8位

校验和的作用:这是为了防止数据传输过程中出错。我们收到数据后,要把前4个字节加起来,看看结果的低8位是否等于第5个字节(校验和)。如果相等,说明数据基本可靠;如果不相等,这次数据就丢弃,需要重新读取。

举个例子: 假设我们收到数据:0011 0101 (0x35), 0000 0000 (0x00), 0001 1000 (0x18), 0000 0100 (0x04), 0101 0001 (0x51)。

  • 湿度 = 0x35 = 53%RH (小数部分为0)
  • 温度 = 0x18 + 0x04*0.1 = 24 + 0.4 = 24.4°C
  • 校验和计算:0x35 + 0x00 + 0x18 + 0x04 = 0x51。计算值(0x51)等于接收到的校验和(0x51),数据有效。

理解了这些,写代码就有了清晰的路线图。下面咱们就动手把理论变成代码。

3. 手把手编写DHT11驱动代码

我们将驱动代码分成两个文件:bsp_dht11.c(源文件)和 bsp_dht11.h(头文件)。这样结构清晰,也方便在其他项目里复用。

3.1 头文件定义 (bsp_dht11.h)

头文件主要进行宏定义和函数声明。

#ifndef _BSP_DHT11_H_
#define _BSP_DHT11_H_

#include <stdio.h>
#include <inttypes.h>
#include "sdkconfig.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_rom_sys.h"
#include "esp_timer.h"

// 类型重定义,方便使用
#define uchar unsigned char
#define uint8 unsigned char
#define uint16 unsigned short

/************** 引脚修改处 ****************/
// 这里定义DHT11的数据线连接到了ESP32-S3的哪个GPIO
// 根据你的实际接线修改,这里以GPIO1为例
#define GPIO_DHT11  1

// 函数声明
void Delay_ms(uint16 ms); // 毫秒延时函数
void DHT11(void);         // 读取温湿度主函数

#endif

提示:GPIO_DHT11 这个宏定义非常重要,你实际把传感器数据线接到哪个GPIO引脚,这里就改成对应的编号。比如接在GPIO5上,就改成 #define GPIO_DHT11 5

3.2 源文件实现 (bsp_dht11.c)

这是驱动的核心,我们一步步来构建。

首先,包含必要的头文件和定义全局变量,用于存储读取到的温湿度数据。

#include "bsp_dht11.h"
#include <stdio.h>
#include "esp_system.h"
#include "esp_log.h"
#include "driver/gpio.h"
#include "rom/ets_sys.h"

// 全局变量,用于存储计算后的温湿度值
float temperature = 0;
float humidity = 0;

// 内部使用的临时变量
uchar ucharFLAG, uchartemp;
float Humi, Temp;
uchar ucharT_data_H, ucharT_data_L, ucharRH_data_H, ucharRH_data_L, ucharcheckdata;
uchar ucharT_data_H_temp, ucharT_data_L_temp, ucharRH_data_H_temp, ucharRH_data_L_temp, ucharcheckdata_temp;
uchar ucharcomdata; // 用于拼接一个字节的数据
uchar Humi_small, Temp_small; // 湿度、温度的小数部分

接下来,我们需要几个底层GPIO操作函数,用来设置引脚方向和读写电平。这是与硬件直接打交道的部分。

// 设置数据线引脚为输入模式(主机准备读取传感器数据)
static void InputInitial(void)
{
  esp_rom_gpio_pad_select_gpio(GPIO_DHT11);
  gpio_set_direction(GPIO_DHT11, GPIO_MODE_INPUT);
}

// 设置数据线引脚为输出模式,并输出高电平
static void OutputHigh(void)
{
  esp_rom_gpio_pad_select_gpio(GPIO_DHT11);
  gpio_set_direction(GPIO_DHT11, GPIO_MODE_OUTPUT);
  gpio_set_level(GPIO_DHT11, 1);
}

// 设置数据线引脚为输出模式,并输出低电平
static void OutputLow(void)
{
  esp_rom_gpio_pad_select_gpio(GPIO_DHT11);
  gpio_set_direction(GPIO_DHT11, GPIO_MODE_OUTPUT);
  gpio_set_level(GPIO_DHT11, 0);
}

// 读取数据线当前的引脚电平(输入模式下使用)
static uint8 getData()
{
  return gpio_get_level(GPIO_DHT11);
}

// 一个简单的毫秒延时函数,基于FreeRTOS的vTaskDelay
void Delay_ms(uint16 ms)
{
    vTaskDelay(ms / portTICK_PERIOD_MS);
}

核心来了!我们需要一个函数来读取一个字节(8位)的数据。这个函数会循环8次,每次读取一位,并根据高电平的持续时间判断是0还是1。

// 读取一个字节的数据
static void COM(void)
{
    uchar i;
    for(i=0; i<8; i++)
    {
        ucharFLAG = 2;
        // 等待低电平结束(即等待54us的低电平过去)
        while((getData()==0) && ucharFLAG++)
            ets_delay_us(10); // 每次等待10us

        // 低电平结束后,延时35us。此时如果数据位是1,电平应该还是高;如果是0,电平已经变低。
        ets_delay_us(35);

        uchartemp = 0;
        // 判断35us后的电平,高为1,低为0
        if(getData() == 1)
            uchartemp = 1;

        ucharFLAG = 2;
        // 等待高电平结束(等待当前数据位剩余的高电平时间过去)
        while((getData()==1) && ucharFLAG++)
            ets_delay_us(10);

        // 超时保护,如果等待时间过长则跳出循环(实际代码中应完善错误处理)
        if(ucharFLAG == 1)
            break;

        // 将读取到的位拼接到字节中,先读到的位是高位
        ucharcomdata <<= 1;
        ucharcomdata |= uchartemp;
    }
}

最后,整合所有步骤,完成一次完整的温湿度读取函数 DHT11()

// 读取温湿度主函数
void DHT11(void)
{
    // 1. 主机发送起始信号:拉低至少18ms,然后拉高
    OutputLow();
    Delay_ms(19);          // 拉低19ms,确保大于18ms
    OutputHigh();
    ets_delay_us(20);      // 主机拉高后等待20us

    // 2. 主机设置为输入模式,等待传感器响应
    InputInitial();

    // 等待传感器拉低总线(响应信号的第一段低电平)
    ucharFLAG = 2;
    while((!getData()) && ucharFLAG++)
        ets_delay_us(10);

    // 等待传感器拉高总线(响应信号的高电平)
    ucharFLAG = 2;
    while((getData()) && ucharFLAG++)
        ets_delay_us(10);

    // 3. 开始接收40位数据
    COM(); // 读取字节0: 湿度整数
    ucharRH_data_H_temp = ucharcomdata;
    ucharcomdata = 0;

    COM(); // 读取字节1: 湿度小数 (DHT11恒为0)
    ucharRH_data_L_temp = ucharcomdata;
    ucharcomdata = 0;

    COM(); // 读取字节2: 温度整数
    ucharT_data_H_temp = ucharcomdata;
    ucharcomdata = 0;

    COM(); // 读取字节3: 温度小数
    ucharT_data_L_temp = ucharcomdata;
    ucharcomdata = 0;

    COM(); // 读取字节4: 校验和
    ucharcheckdata_temp = ucharcomdata;
    ucharcomdata = 0;

    // 4. 通信结束,主机重新控制总线并拉高
    OutputHigh();

    // 5. 校验数据
    uchartemp = (ucharT_data_H_temp + ucharT_data_L_temp + ucharRH_data_H_temp + ucharRH_data_L_temp);
    if(uchartemp == ucharcheckdata_temp)
    {
        // 校验成功,保存有效数据
        ucharRH_data_H = ucharRH_data_H_temp;
        ucharRH_data_L = ucharRH_data_L_temp;
        ucharT_data_H = ucharT_data_H_temp;
        ucharT_data_L = ucharT_data_L_temp;
        ucharcheckdata = ucharcheckdata_temp;

        // 计算最终的温湿度值(湿度小数部分为0)
        Humi = ucharRH_data_H;
        Temp = ucharT_data_H + (ucharT_data_L * 0.1); // 温度小数部分需要乘以0.1

        // 赋值给全局变量,方便主程序读取
        humidity = Humi;
        temperature = Temp;
    }
    else
    {
        // 校验失败,可以设置一个错误值或保持原值
        // 这里简单地将全局变量设为0,主程序可以通过判断0值来知道读取失败
        humidity = 0;
        temperature = 0;
    }
}

驱动代码的主体部分就完成了。这里有个细节要注意,ets_delay_us() 是ESP-IDF提供的一个微秒级忙等待延时函数,在时序要求严格的地方用它比较准。而 Delay_ms() 用的是FreeRTOS的 vTaskDelay,它会让出CPU,适合在不需要精确延时的地方使用。

4. 将驱动移植到你的ESP32-S3工程

代码写好了,怎么用到你的项目里呢?跟着下面几步走:

  1. 创建驱动文件:在你的ESP-IDF项目目录下(例如在 main 文件夹同级),创建一个新的文件夹,比如叫做 components。然后在 components 里再创建一个 dht11 文件夹。把我们刚写好的 bsp_dht11.cbsp_dht11.h 两个文件放进去。

  2. 修改项目配置:打开你项目根目录下的 CMakeLists.txt 文件。在 idf_component_register 部分,添加我们驱动文件的路径。假设你的工程结构如下:

    你的项目/
    ├── main/
    │   ├── CMakeLists.txt
    │   └── main.c
    └── components/
        └── dht11/
            ├── bsp_dht11.c
            └── bsp_dht11.h
    

    那么,你需要编辑 main/CMakeLists.txt,在 idf_component_registerSRCSINCLUDE_DIRS 中添加:

    idf_component_register(SRCS "main.c"
                           "../components/dht11/bsp_dht11.c"
                           INCLUDE_DIRS "."
                           "../components/dht11")
    

    这样编译系统就能找到我们的驱动文件了。

  3. 在主程序中调用:最后,修改你的 main.c 文件,包含头文件,并在主循环中调用我们的驱动函数。

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "bsp_dht11.h" // 包含我们的驱动头文件

// 声明在bsp_dht11.c中定义的全局变量
extern float temperature;
extern float humidity;

void app_main(void)
{
    printf("DHT11 Test Start!\n");

    while(1)
    {
        // 调用函数,读取一次温湿度
        DHT11();

        // 打印读取到的数据
        if(temperature != 0 || humidity != 0) // 简单判断数据是否有效
        {
            printf("Temperature: %.1f C\n", temperature);
            printf("Humidity: %.0f %%RH\n", humidity);
        }
        else
        {
            printf("Failed to read from DHT11 sensor!\n");
        }

        // 每隔2秒读取一次
        vTaskDelay(2000 / portTICK_PERIOD_MS);
    }
}

5. 常见问题与调试心得

代码移植好了,但第一次运行很可能读不到数据或者数据全是0。别慌,这是玩嵌入式最常遇到的情况。下面分享几个我调试时总结的要点:

  1. 时序是重中之重:DHT11对时序非常敏感。ets_delay_us(35) 这个等待35us判断数据位的操作是关键。如果你的ESP32-S3主频设置得特别高,或者有其他中断干扰,可能会导致延时不准。可以尝试稍微调整这个值(比如30us或40us)。

  2. 上拉电阻不能少:DHT11的数据线需要一颗4.7KΩ - 10KΩ的上拉电阻接到VCC,以确保总线在空闲时处于高电平。很多模块已经集成了这个电阻,但如果你用的是单独的传感器,务必自己加上。

  3. 供电要稳定:DHT11对电源波动比较敏感。确保供电电压在3.3V-5V之间,并且尽量在电源引脚附近加上一个100nF的滤波电容。

  4. 读取间隔:DHT11两次测量之间至少需要间隔1秒钟。代码里我们延时了2秒,是足够的。如果连续调用读取函数,会返回错误数据。

  5. 使用逻辑分析仪:如果条件允许,用逻辑分析仪抓一下数据线上的波形是最直观的调试方法。你可以清晰地看到起始信号、响应信号以及每一位数据的波形,直接判断是不是时序出了问题。

按照上面的步骤,你应该就能在ESP32-S3上稳定地读取DHT11的温湿度数据了。这个驱动代码框架清晰,理解了之后,你完全可以把它应用到STM32、Arduino等其他平台上,只需要修改底层的GPIO操作函数即可。嵌入式开发就是这样,打通了一个传感器,后面类似的就都触类旁通了。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐