【ESP32笔记】读取DHT11温湿度数据

本节目标:理解 DHT11 的单总线协议与微秒级时序,用 RMT 接收通道把脉冲串解析成温湿度读数

一、本节要解决的问题

温湿度采集是物联网设备最常见的感知需求。DHT11 便宜、量程够用、直接输出数字量(不需要像热敏电阻那样做 ADC 标定),很适合入门。

但它的通信方式很"抠门":只有一根数据线,主机要拉低它发起始信号,从机再用同一根线把数据回给主机;而且每一位的 0/1 不是电平本身,而是高电平持续了多久,时间尺度是微秒级。如果用 GPIO 轮询加软件计时,一次中断或任务调度就会把测量结果打乱,读数经常校验失败。

所以本节的做法是:让 RMT 接收通道的硬件去测量每个边沿的电平和时长,把它们记录成一组"符号",软件只负责解析这组符号------把最难的"测时间"交给硬件。

二、硬件基础

DHT11 有 4 个引脚,其中 1 个悬空不用:

引脚 功能
VCC 供电 3~5.5V(超过可能烧毁)
DATA 单总线数据脚,双向
NC 不接
GND 接地

接线要点:

  • DATA 与 VCC 之间接一个约 5K 的上拉电阻(买到带转接板的模块一般已经焊好);
  • 数据线空闲时靠上拉保持高电平,通信时主机和从机轮流把它拉低;
  • 开发板上信号脚接 GPIO25 ,仓库 dht11/main/main.c 里对应:
c 复制代码
#define DHT11_GPIO	25		// DHT11引脚定义

注意:GPIO25 与教程一致。但如果买的是带转接板的 DHT11,引脚顺序可能被重新排列(有的把 DATA 放中间),务必按丝印核对;VCC 接反是最常见的"完全读不出数据"原因。另外规格书要求上电后等待 1 秒以上再开始测量。

三、知识点

3.1 数据包:40 bit 的结构

DHT11 一次通信回传 5 个字节:

字节 内容
第 1 字节 湿度整数部分
第 2 字节 湿度小数部分(DHT11 恒为 0)
第 3 字节 温度整数部分
第 4 字节 温度小数部分(DHT11 恒为 0)
第 5 字节 校验和 = 前 4 字节相加后只取低 8 位

传输时高位在前、低位在后。因为两个小数字节恒为 0,DHT11 实际只有整数精度(湿度 ±5%RH、温度 ±2℃),量程是湿度 5%~95%RH、温度 -20℃~60℃。

教程勘误 :教程原文写"DHT11 的通信包数据长度为 40 个字节",正确说法是 40 个二进制位(bit)= 5 个字节。教程后面自己列的"8bit + 8bit + 8bit + 8bit + 8bit"也说明它是 40 位。

3.2 一次完整通信的时序

阶段 谁在驱动 电平与时长
起始信号 主机 拉低 >18ms,然后拉高 20~40us
响应信号 从机 先拉低 80us,再拉高 80us
数据位前导 从机 每一位数据前都固定拉低 50us
数据 0 从机 高电平约 23~27us
数据 1 从机 高电平约 68~74us
结束 从机 数据发完再拉低 50us,之后总线被上拉回高

注意这里的套路和上一节 WS2812 一模一样:0 和 1 靠高电平的持续时间区分,而不是电平本身。

3.3 为什么需要微秒级延时

  • FreeRTOS 的时基默认是 1ms,vTaskDelay / pdMS_TO_TICKS 的最小粒度就是毫秒,根本表达不了 20us、80us 这种长度;
  • 所以主机发起始信号时用的是 ets_delay_us(),它是忙等 (CPU 空转着数时间),精度在微秒级,但不会让出 CPU;
  • 代码里最长的一次忙等是 20ms(起始信号的低电平),对本例这种单任务演示无所谓,但正式产品里要意识到:这 20ms 内其他任务都得不到调度。

3.4 为什么用 RMT 接收,而不是 GPIO 轮询

  • RMT 接收通道会把总线上的每个跳变自动记录成一个符号 (rmt_symbol_word_t),时间基准就是通道配置里的 resolution_hz;
  • 代码把它设成 1MHz,即 1 tick = 1us,而 DHT11 最短的脉冲也有 20us 量级,精度绰绰有余;
  • "测量"由硬件完成,软件即使晚一点来取数据,已经测好的脉宽也不会丢;
  • 一帧 DHT11 数据大约 3~4ms,RMT 用内部接收内存块攒够一帧后通过中断通知软件,比"关中断死等"友好得多。

3.5 判决门限 35us 是怎么来的

0 码的高电平约 23~27us,1 码约 68~74us,两者中间有一大段空白。代码统一用 35us 作为分界线:duration < 35 ? 0 : 1,往两边各有约 8us 和 33us 的余量,即使有轻微抖动也不会误判。

四、代码分析

4.1 工程结构

复制代码
dht11/
├── CMakeLists.txt
├── main/
│   ├── CMakeLists.txt      # idf_component_register(SRCS "main.c" "dht11.c" INCLUDE_DIRS ".")
│   ├── dht11.c             # RMT 接收通道 + 接收回调 + 符号解析
│   ├── dht11.h             # 对外只暴露 Init 和 StartGet 两个接口
│   └── main.c              # app_main():每秒读一次并打印
├── sdkconfig               # CONFIG_IDF_TARGET="esp32"
└── README.md

4.2 初始化:DHT11_Init

c 复制代码
rmt_rx_channel_config_t rx_chan_config = {
    .clk_src = RMT_CLK_SRC_APB,   // 选择时钟源
    .resolution_hz = 1000 * 1000, 	  // 1 MHz 滴答分辨率,即 1 滴答 = 1 µs
    .mem_block_symbols = 64,          // 内存块大小,即 64 * 4 = 256 字节
    .gpio_num = dht11_pin,            // GPIO 编号
    .flags.invert_in = false,         // 不反转输入信号
    .flags.with_dma = false,          // 不需要 DMA 后端(ESP32S3才有)
};
//创建rmt接收通道
ESP_ERROR_CHECK(rmt_new_rx_channel(&rx_chan_config, &rx_chan_handle));

//新建接收数据队列
rx_receive_queue = xQueueCreate(20, sizeof(rmt_rx_done_event_data_t));
assert(rx_receive_queue);

//注册接收完成回调函数
ESP_LOGI(TAG, "register RX done callback");
rmt_rx_event_callbacks_t cbs = {
    .on_recv_done = example_rmt_rx_done_callback,
};
ESP_ERROR_CHECK(rmt_rx_register_event_callbacks(rx_chan_handle, &cbs, rx_receive_queue));

//使能RMT接收通道
ESP_ERROR_CHECK(rmt_enable(rx_chan_handle));

和上一节 WS2812 的发送通道对照着看:结构体名字从 rmt_tx_channel_config_t 变成 rmt_rx_channel_config_t,多了 invert_in(是否反相输入)和 with_dma(是否用 DMA 后端)两个标志位,少了发送队列深度。

rx_receive_queue 是"中断 → 任务"之间的桥梁:接收完成回调在中断上下文里执行,不能做耗时的事情,所以它只把数据塞进队列;主流程在任务上下文里等这个队列。

4.3 接收完成回调:只做搬运

c 复制代码
static bool IRAM_ATTR example_rmt_rx_done_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *edata, void *user_data)
{
    BaseType_t high_task_wakeup = pdFALSE;
    QueueHandle_t rx_receive_queue = (QueueHandle_t)user_data;
    // send the received RMT symbols to the parser task
    xQueueSendFromISR(rx_receive_queue, edata, &high_task_wakeup);
    return high_task_wakeup == pdTRUE;
}
  • IRAM_ATTR 把函数放进 IRAM,保证中断期间一定能执行到;
  • 函数在中断里执行,操作队列必须用 xQueueSendFromISR 这种带 FromISR 后缀的版本;
  • 返回值 high_task_wakeup 告诉内核:这次入队是否唤醒了更高优先级的任务,需要的话内核会在中断退出时立刻切换任务。

4.4 发起一次测量:DHT11_StartGet

先由主机发出起始信号,再把引脚切成输入,然后启动 RMT 接收:

c 复制代码
//发送20ms开始信号脉冲启动DHT11单总线
gpio_set_direction(DHT11_PIN, GPIO_MODE_OUTPUT);
gpio_set_level(DHT11_PIN, 1);
ets_delay_us(1000);
gpio_set_level(DHT11_PIN, 0);
ets_delay_us(20000);
//拉高20us
gpio_set_level(DHT11_PIN, 1);
ets_delay_us(20);
//信号线设置为输入准备接收数据
gpio_set_direction(DHT11_PIN, GPIO_MODE_INPUT);
gpio_set_pull_mode(DHT11_PIN,GPIO_PULLUP_ONLY);
  • 先输出高电平 1ms,是让总线在起始信号前有一个稳定的高电平起点;
  • 拉低 20ms,满足规格书">18ms"的要求;
  • 拉高 20us 后立刻切回输入(并打开内部上拉),把总线交给 DHT11 驱动。

紧接着配置接收过滤窗口并启动接收:

c 复制代码
rmt_receive_config_t receive_config = {
    .signal_range_min_ns = 100,     //最小脉冲宽度(0.1us),信号长度小于这个值,视为干扰
    .signal_range_max_ns = 1000*1000, 	//最大脉冲宽度(1000us),信号长度大于这个值,视为结束信号
};

static rmt_symbol_word_t raw_symbols[128];	//接收缓存
static rmt_rx_done_event_data_t rx_data;	//实际接收到的数据
ESP_ERROR_CHECK(rmt_receive(rx_chan_handle, raw_symbols, sizeof(raw_symbols), &receive_config));

// wait for RX done signal
if (xQueueReceive(rx_receive_queue, &rx_data, pdMS_TO_TICKS(1000)) == pdTRUE) {
    // parse the receive symbols and print the result
    return parse_items(rx_data.received_symbols, rx_data.num_symbols,humidity, temp_x10);
}
return 0;
  • signal_range_min_ns:比它还窄的脉冲被视为干扰、直接滤掉。这里设 0.1us,相当于"不过滤"(1MHz 分辨率下一个 tick 就是 1us);
  • signal_range_max_ns:超过它认为总线空闲,接收结束。DHT11 最长脉冲是 80us,设 1000us 有充分余量;
  • rmt_receive() 也是异步的,启动后马上返回,真正等结果的地方是 xQueueReceive,超时给了 1000ms------正常情况下整帧数据只有几毫秒,超时说明传感器没响应;
  • raw_symbols 和 rx_data 声明为 static,生命周期覆盖整个程序。因为一旦超时返回,RMT 通道可能还在后台往这块内存写数据,放在栈上就有被踩坏的风险。

提示(与教程不一致) :教程 PDF 里的片段是 signal_range_min_ns = 1000(1us)、signal_range_max_ns = 200*1000(200us),raw_symbols 是 64 个符号的局部数组 ;仓库改成了 0.1us / 1000us 的过滤窗口和 static 的 128 个符号。放宽滤波是为了不让任何有效边沿被当成干扰丢掉;加大缓冲是留余量------一帧的符号数并不是固定值(和 RMT 从哪个边沿开始捕获有关),教程正文说的"42 个符号"只是理想情况。以仓库代码为准。

4.5 解析:parse_items

解析函数把一串 RMT 符号还原成温湿度:

c 复制代码
if (item_num < 41){					// 检查是否有足够的脉冲数
    return 0;
}
if(item_num > 41)
    item++;								// 跳过开始信号脉冲

for (i = 0; i < 16; i++, item++)	// 提取湿度数据
{
    uint16_t duration = 0;
    if(item->level0)
        duration = item->duration0;
    else
        duration = item->duration1;
    rh = (rh << 1) + (duration < 35 ? 0 : 1);
}

三个关键点:

  1. 为什么先判断符号个数:40 位数据 + 起始/响应 + 结束,理论上 42 个符号左右;数量明显不对说明这一帧没接收完整,直接返回失败,不浪费后面的计算;
  2. 为什么用 if(item->level0) 选 duration:一个 RMT 符号包含"前半段 + 后半段",高电平究竟落在前半段还是后半段,取决于接收窗口从哪里对齐。先看哪一段的 level 是 1,就取那一段的时长作为高电平时间,这样最稳妥(这也是教程原文强调的"这样最保险");
  3. (duration < 35 ? 0 : 1) :就是 3.5 节说的 35us 判决门限,配合 rh = (rh << 1) + ... 左移一位拼出新的一 bit,16 次循环后正好拼成 16 位的湿度字。

温度和校验同理,循环次数分别是 16 次和 8 次。校验与取值部分:

c 复制代码
// 检查校验
if ((((temp >> 8) + temp + (rh >> 8) + rh) & 0xFF) != checksum){
    ESP_LOGI(TAG, "Checksum failure %4X %4X %2X\n", temp, rh, checksum);
    return 0;
}
// 返回数据

rh = rh >> 8;
temp = (temp >> 8) * 10 + (temp & 0xFF);

//判断数据合法性
if(rh <= 100)
    *humidity = rh;
if(temp <= 600)
    *temp_x10 = temp;
return 1;
  • (temp >> 8) + temp + (rh >> 8) + rh 就是"前 4 个字节相加",因为 temp、rh 都是 16 位,高位字节和低位字节正好是整数与小数两部分;& 0xFF 只取低 8 位与校验和比较;
  • 温度被组合成 0.1℃ 为单位 :整数部分 × 10 + 小数部分。DHT11 的小数字节恒为 0,实际就是整数 ×10,但这样写可以兼容以后换 DHT22;
  • rh <= 100、temp <= 600 是仓库新增的合法性判断,只有通过校验且落在合理范围内才写回输出参数,防止把明显的坏数据当成读数。

提示(与教程不一致) :教程代码是 if (item_num < 42) return 0; 然后无条件 item++;;仓库改成了 if (item_num < 41) + if(item_num > 41) item++;。原因如上所述,RMT 启动接收与从机应答之间存在时间竞争,接收到的符号数可能是 41 也可能是 42,仓库的写法两种情况都能兼容,教程的写法在 41 个符号时会直接判失败。另外教程版本解析完直接赋值,没有范围检查。

4.6 应用:main.c 主循环

c 复制代码
DHT11_Init(DHT11_GPIO);
while (1){
    if (DHT11_StartGet(&temp, &hum)){
        ESP_LOGI(TAG, "temp->%i.%i C     hum->%i%%", temp / 10, temp % 10, hum);
    }
    vTaskDelay(1000 / portTICK_PERIOD_MS);
}
  • DHT11_StartGet 返回 1 才打印,返回 0(超时或校验失败)就静默跳过,下一次再读;
  • temp / 10 和 temp % 10 打印出"25.3"这样的一位小数;
  • 间隔 1000ms 不是随便写的:DHT11 规格书要求两次采样间隔不少于 1 秒,读太快会拿到重复值甚至直接失败;
  • app_main 开头的 nvs_flash_init() 和那几行启动日志是仓库模板自带的,跟 DHT11 本身无关。

五、关键 API 速查

API 作用 所在头文件
rmt_new_rx_channel() 创建 RMT 接收通道(GPIO、分辨率、内存块) driver/rmt_rx.h
rmt_rx_register_event_callbacks() 注册接收完成回调 driver/rmt_rx.h
rmt_receive() 异步启动一次接收,指定符号缓冲、长度和过滤配置 driver/rmt_rx.h
rmt_enable() 使能通道,收发前必须调用 driver/rmt_rx.h
xQueueCreate() / xQueueReceive() 创建队列、任务上下文里等待数据 freertos/queue.h
xQueueSendFromISR() 中断上下文里往队列发数据 freertos/queue.h
gpio_set_direction() 切换引脚为输入/输出(单总线共用一根线时必用) driver/gpio.h
gpio_set_level() / gpio_set_pull_mode() 输出电平、设置上拉 driver/gpio.h
ets_delay_us() 微秒级忙等延时 esp32/rom/ets_sys.h

六、编译与现象验证

bash 复制代码
cd esp32-board/dht11
idf.py build                  # sdkconfig 中目标已是 esp32
idf.py -p COM<端口> flash monitor

烧录后串口应每秒打印一行类似:

复制代码
I (1234) DHT11_Demo: temp->25.3 C     hum->60%

对着传感器轻轻呵气,湿度数字会明显上升;如果读到坏数据,会看到 Checksum failure 日志,且这一秒不打印温湿度。另外注意:DHT11 上电后需要约 1 秒稳定时间,第一行读数偏慢或缺失属正常。

相关推荐
Xudde.1 小时前
DC-6 靶机渗透测试(Vulnhub Writeup)
笔记·学习·安全·web安全
xian_wwq1 小时前
【学习笔记-一文读懂系列】一文读懂Jev模型
笔记·学习
HRTOS1 小时前
HRTOS 4.0 硬件实测:os_delay() 延迟测试与系统时间精度分析
单片机·嵌入式硬件·51单片机
Awh-1 小时前
ARM 裸机开发 DAY8 学习笔记
arm开发·笔记·学习
带金箍的至尊宝2 小时前
系统架构设计师笔记 08:UML 用例图、类图、时序图
笔记·系统架构·uml
硅基手札2 小时前
【I2C 技术系列 11】I2C Linux 驱动框架
linux·驱动开发·stm32·单片机·嵌入式硬件·mcu·计算机外设
PC2005-cloud2 小时前
MySQL学习笔记:主从复制集群搭建以及原理详解
笔记·学习·mysql
AI工具学习测评2 小时前
低空经济入法100天:统一低空数字监管平台的技术架构与建设路径
大数据·人工智能·物联网·无人机·智慧城市
Xudde.2 小时前
Mr-Robot 靶机渗透测试(Vulnhub Writeup)
笔记·学习·安全·web安全