☰
STM32驱动DHT11温湿度传感器:时序、驱动与排坑详解
2026/9/25 7:44:20 网站建设 项目流程

前阵子帮朋友调一块基于STM32的小型环境监测板,问题恰恰出在最不起眼的DHT11上。板子用STM32F103C8T6做主控,传感器是随处可见的DHT11模块,读数一会儿正常一会儿跳到0xFF,折腾到半夜才发现不是代码逻辑的问题,而是模块上那颗上拉电阻虚焊了。这件小事让我想认真写一篇DHT11详解:这传感器本身不值几个钱,但它的单总线时序恰好把嵌入式最基本也最关键的几件事——GPIO方向切换、微妙级延时、时序容错、中断影响——全都串了起来。无论你是准备拿它做STM32毕业设计、入门小项目,还是想把温湿度数据接到自己的板子上,这篇都值得从头读到尾。

1. 先摸清DHT11的"性格":精度、量程和那个要命的1秒采样周期

1.1 参数别只看便宜

DHT11温湿度传感器内部由湿敏电阻和NTC热敏电阻组成,对外只有一根数据线。官方参数并不华丽:温度测量范围0~50℃,精度±2℃;湿度测量范围20%~90%RH,精度±5%RH;分辨率1。说人话,它测的是"趋势"而不是"精确值",湿度小数位在很多环境下就是0x00,第一次读到数据的新手常常以为自己代码写错了,其实没写错,这就是DHT11的底子。

它的核心优势是便宜、耐造、协议简单,配合STM32这种主控非常容易上手。在面包板上插三根杜邦线,写个几十行的驱动,串口就能打出温度湿度,这种"从零到有"的成就感对刚入门单片机的人来说特别重要。

1.2 那1秒采样周期不是摆设

很多人忽略DHT11数据手册里的一句话:两次读取间隔建议不小于1秒,有些版本甚至建议2秒。原因是传感器内部的湿敏电容在每次测量后需要时间恢复平衡,连续调用读取函数,数据可能不更新,或者乱跳。

我见过不少朋友把DHT11读取放在while循环里,几毫秒读一次,然后抱怨数据不稳定。这其实是使用方式的问题。程序中加一道简单的节流判断就能解决:

if (HAL_GetTick() - last_read_time < 1000) return 2; // 距上次读取不足1秒,直接返回 last_read_time = HAL_GetTick();

提示:DHT11上电后还需要约1秒的稳定时间,不要在系统刚启动时就急着发读取时序,否则很容易读到全0或全1。

1.3 和DHT22、SHT30比一比

选型时经常有人问"为什么不用更好的传感器"。我把常见的三款温湿度传感器放在一起对比:

传感器温度精度湿度精度接口采样周期适用场景
DHT11±2℃±5%RH单总线1s入门学习、低成本监测、原型验证
DHT22±0.5℃±2%RH单总线2s家用环境监测、气象站
SHT30±0.3℃±2%RHI2C更灵活需要稳定可靠数据的系统

我的建议是:如果只是学习STM32驱动、做课设或低成本原型,DHT11完全够用;如果项目要长期跑、数据要用于控制逻辑,直接上SHT30更省心。但协议学习的价值是一样的,学会DHT11单总线时序后,换DHT22几乎不用改代码逻辑,因为两者读写协议非常接近。

2. 硬件连接:三根线之外,别小看那颗上拉电阻

2.1 裸传感器与模块,先分清手里是哪一种

市面上的DHT11有两种形态。一种是四脚裸传感器,引脚定义为VCC、DATA、NC、GND,需要自己在数据线上焊接上拉电阻;另一种是集成模块,板上已经焊好了上拉电阻和滤波电容,引出三根线或四根线,直接杜邦线连接即可。

无论哪种,和STM32的连接就三根线:

DHT11引脚连接到STM32
VCC3.3V(或5V,见下文说明)
DATA任意GPIO,本文用PA0
GNDGND

这里有个容易踩坑的地方:如果DHT11用5V供电,数据脚高电平也是5V,虽然STM32F103系列部分引脚标称5V容忍,但并不是所有引脚都保证,最稳妥的做法是DHT11直接用3.3V供电。手册上明确写了工作电压3.3V~5.5V,3.3V下完全能正常工作,没必要为了"稳定"去用5V,反而引入电平匹配的问题。

2.2 数据脚为什么必须上拉

DHT11的数据输出是开漏结构,你可以理解成它内部是一只只能把线往下拽的开关,主动拉低没问题,但释放后线悬空,高电平必须靠外部上拉电阻提供。

这就是为什么模块上都会焊一颗电阻,也是为什么裸传感器直接接STM32经常出问题的原因。数据线不上拉时,总线处于浮空状态,主机读到的电平随机跳动,最常见的现象就是读出0xFF或者数值剧烈跳变。

上拉电阻的取值也有讲究。推荐4.7k~10k,太小的上拉在低电平时灌电流偏大,太大的上拉又会让上升沿变慢,把本来就只有几十微秒的时序窗口进一步压缩。打个比方:一根绳子,传感器往下拽,拽完松开,要靠弹簧把它拉回原位。弹簧太软,绳子半天回不去;弹簧太硬,传感器拽着也费劲。4.7k到10k就是那个"软硬适中"的弹簧。

2.3 GPIO模式与方向切换:推挽、开漏怎么选

STM32侧GPIO配置有几种常见做法。

做法一:发送起始信号时配置为推挽输出,接收数据时切换为输入上拉模式。这种方法逻辑直观,初学者好理解,缺点是每次方向切换都要重新配置GPIO模式。

做法二:始终使用开漏输出模式。需要拉低时写0,需要释放时写1,由外部上拉电阻把总线拉高。因为开漏输出模式下读引脚电平依然是有效的,所以直接用HAL_GPIO_ReadPin读取就行,全程不用切换方向。这种方式代码简洁,也更贴近DHT11这类开漏器件的真实工作方式,但前提是外接上拉电阻一定要有。

本文后面给出的驱动示例采用推挽输出加方向切换的方式,更常规一些。如果你用模块,推挽模式直接可用;如果你自己搭传感器电路,确认上拉电阻在电路板上,否则推挽模式也会因为总线浮空而失败。

3. 单总线时序:一根线来回拉锯,40位数据是这样读出来的

3.1 一次完整读时序的五个阶段

DHT11是单总线器件,主机发起读取后,数据线上会依次出现几个特征明显的高低电平阶段:

  1. 主机先把数据线拉低,持续18ms以上,然后释放为高电平。
  2. DHT11检测到起始信号后,主动将数据线拉低80us,作为应答信号。
  3. 应答结束后,DHT11将数据线拉高80us,然后开始输出40位数据。
  4. 40位数据按高位先出的顺序逐位输出。
  5. 数据输出完毕,DHT11再将数据线拉低50us,然后释放,总线恢复空闲高电平。

整个协议中主机的作用只是发起读取,之后几乎所有节奏都由DHT11掌控。这也意味着STM32侧必须能在几十微秒的时间尺度上准确判断电平变化,靠HAL_Delay这种毫秒级延时是不可能完成的,所以第4章我会单独讲微妙级的延时实现。

3.2 数据0和数据1怎么区分:卡住40us这个判据

40位数据中每一位的起点都是从50us的低电平开始,区别在于后面跟随的高电平宽度:

  • 数据0:低电平50us后,高电平持续26~28us。
  • 数据1:低电平50us后,高电平持续约70us。

所以读一个位时,不能看低电平,而要看高电平时长。最常用的判断方法是:先等待总线从低电平跳变为高电平,然后延时40us,再读引脚状态。如果这时引脚已经是低电平,说明是数据0;如果引脚仍然是高电平,说明是数据1。

为什么40us好用?数据0的高电平最多28us,延时40us后肯定已经结束;数据1的高电平70us,延时40us后仍然保持。这个窗口足够清楚,绝大多数DHT11都能这样稳定读出。

注意:市面上确实存在个别时序差异较大的DHT11克隆芯片,如果按40us判据读出来全是错误,而且检查上拉、电源都没问题时,可以用逻辑分析仪实测高电平宽度再微调这个阈值。

3.3 40位数据排布与校验和验证

40位数据分成5个字节,从高位到低位依次为:

  • 湿度整数(8位)
  • 湿度小数(8位)
  • 温度整数(8位)
  • 温度小数(8位)
  • 校验和(8位)

校验和的计算方法是:前四个字节相加,取低8位,与第五个字节比较。相等则数据可靠,不相等则丢弃本次读数。

举个例子,如果读到湿度的整数部分是0x28,湿度小数0x00,温度整数0x1C,温度小数0x00,校验和0x44,那么0x28+0x00+0x1C+0x00=0x44,校验通过,实际环境是湿度40%RH、温度28℃。这是非常标准的一组数据,但也别指望湿度小数位在多数环境下都是非零值,DHT11自身精度决定了小数位基本上只有0。

4. STM32驱动实现:从空工程到串口打出温湿度

4.1 工程准备

我用STM32CubeMX生成一个F103C8T6基础工程,时钟配置为72MHz,PA0设置为GPIO输出用于连接DHT11数据脚,USART1使能并配置为115200-8N1,用于打印结果。

PA0其实还有一个隐蔽的优势:它恰好是TIM2_CH1的输入捕获引脚。如果后面你想用定时器输入捕获方案替代延时读取,数据线可以不用挪位置,这一点在第5章会进一步说明。对于入门阶段,普通GPIO方式就足够了。

4.2 微妙级延时:DWT方案和它的注意事项

DHT11时序的最小单位是20多微秒,所以必须用微妙级延时。HAL库自带的HAL_Delay是毫秒级的,直接用会把整个时序拉垮。SysTick虽然可以做微妙延时,但HAL库的HAL_Delay也依赖SysTick,两者混用容易互相干扰。这里推荐使用Cortex-M3内核的DWT(数据观察与跟踪单元)实现精确延时。

void delay_us(uint32_t us) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; if ((DWT->CTRL & DWT_CTRL_CYCCNTENA_Msk) == 0) { DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; } uint32_t ticks = us * (SystemCoreClock / 1000000U); uint32_t start = DWT->CYCCNT; while ((uint32_t)(DWT->CYCCNT - start) < ticks); }

这段代码的核心是利用DWT->CYCCNT这个按内核时钟递增的计数器,72MHz下1us就是72个计数。代码里做了溢出保护,因为32位计数器差值运算在模2^32下依然正确,所以短时延完全不受溢出影响。

有两个必须注意的地方。第一,SystemCoreClock变量必须和实际系统时钟一致,如果STM32实际跑72MHz但SystemCoreClock还是8MHz,整个时序会偏得一塌糊涂。第二,DWT->CYCCNT在调试复位后的初始状态可能未使能,所以代码里加了一次使能判断。实际上大多数HAL工程中SystemCoreClock已经被正确设置,这正是它方便的地方。

4.3 GPIO方向切换

在DHT11读取的完整过程中,STM32的PA0需要先作为输出拉低数据线,再作为输入读取数据。我在代码中用寄存器直接操作速度更快,避免频繁调用HAL_GPIO_Init带来的开销。PA0对应GPIOA->CRL的低4位,配置如下:

static void DHT11_Pin_Output(void) { GPIOA->CRL &= ~(0x0FUL << 0); // 清空原配置 GPIOA->CRL |= (0x02UL << 0); // 推挽输出,2MHz GPIOA->ODR |= (1UL << 0); // 初始输出高 } static void DHT11_Pin_Input(void) { GPIOA->CRL &= ~(0x0FUL << 0); // 清空原配置 GPIOA->CRL |= (0x08UL << 0); // 输入模式,CNF=10 GPIOA->ODR |= (1UL << 0); // 选择上拉 }

如果你不喜欢寄存器写法,也可以用HAL_GPIO_Init配置方向,但要注意每次调用都会重新设置一大堆结构体成员,在读取时序的循环中频繁切换可能引入额外延时误差。寄存器写法在这个场景下更合适。

4.4 完整驱动代码

我把驱动拆成几个函数:起始信号、读一位、读一个字节、读完整数据。

#define DHT11_PORT GPIOA #define DHT11_PIN GPIO_PIN_0 static void DHT11_Start(void) { DHT11_Pin_Output(); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); delay_us(19000); // 拉低至少18ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); delay_us(30); // 释放后等待20~40us DHT11_Pin_Input(); // 切换为输入,准备接收应答 } static uint8_t DHT11_ReadBit(void) { uint16_t timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (++timeout > 200) return 0xFF; // 等待50us低电平结束 delay_us(1); } delay_us(40); // 高电平起始后延时40us作为判据 if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (++timeout > 200) return 0xFF; // 等待70us高电平结束 delay_us(1); } return 1; } return 0; } static uint8_t DHT11_ReadByte(void) { uint8_t data = 0; for (int i = 0; i < 8; i++) { uint8_t bit = DHT11_ReadBit(); if (bit == 0xFF) return 0xFF; data = (data << 1) | bit; } return data; } uint8_t DHT11_ReadData(uint8_t *humi, uint8_t *temp) { uint8_t h_i, h_d, t_i, t_d, crc; uint16_t timeout; DHT11_Start(); // 等待应答:先出现80us低电平 timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (++timeout > 1000) return 1; delay_us(1); } // 再等待80us高电平结束 timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (++timeout > 1000) return 1; delay_us(1); } timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (++timeout > 1000) return 1; delay_us(1); } h_i = DHT11_ReadByte(); h_d = DHT11_ReadByte(); t_i = DHT11_ReadByte(); t_d = DHT11_ReadByte(); crc = DHT11_ReadByte(); if (0xFF == h_i || 0xFF == h_d || 0xFF == t_i || 0xFF == t_d || 0xFF == crc) return 1; if (((h_i + h_d + t_i + t_d) & 0xFF) != crc) return 1; *humi = h_i; *temp = t_i; return 0; }

这段代码里每个等待循环都加了超时保护。不加超时保护的版本一旦DHT11没接好或者损坏,程序就会卡死在死循环里,这在工程里是大忌。加超时保护是驱动代码和玩具Demo之间最重要的区别之一。

4.5 数值解析、负温与校验失败处理

读取失败时直接返回1,上层调用方可以选择丢弃这次数据、稍后重试。校验失败也需要丢弃,不要用坏数据去控制风扇、空调这类设备,否则可能出现完全错误的控制逻辑。

温度整数位的bit7表示负温度。DHT11官方量程虽然只在0℃以上,但有些克隆芯片在低温环境也能输出,稳妥做法是判断一下:

int16_t real_temp; if (t_i & 0x80) real_temp = -((t_i & 0x7F) * 10 + t_d); else real_temp = t_i * 10 + t_d;

这样温度就带有小数点后一位分辨率,打印时直接printf("%d.%d C", real_temp / 10, real_temp % 10)即可。

5. 实测排坑:0xFF、跳变、死等——问题出在哪

5.1 "一直读0xFF"的完整排查链路

我前期调试时最常遇到的就是0xFF。这个现象背后原因不少,按下面的顺序排查最高效:

  1. 检查接线。DATA脚是否真的接到了代码里配置的那一脚,杜邦线是否接触良好,GND是否共地。
  2. 检查上拉电阻。模块上的上拉是否虚焊,裸传感器是否没有外接上拉。文章开头说的那颗虚焊电阻就是这个原因。
  3. 检查供电。DHT11如果供电不稳,时序会产生漂移,特别是从3.3V引脚挂多个外设时容易掉压。
  4. 确认上电稳定时间。刚上电1秒内不要读取,传感器内部还没进入正常状态。
  5. 确认延时函数。SystemCoreClock是否和实际时钟一致,DWT是否成功使能。
  6. 检查GPIO速度配置。如果PA0配置成了最高速度,波形边沿会产生明显振铃,建议使用低速或中速。
  7. 检查方向切换逻辑。发送起始信号后是否成功切回输入模式。

这套排查顺序我写成了一张简单的检查表,每次遇到DHT11问题都按这个顺序看,能省下大量盲查时间。

5.2 波形容貌:正常时序长什么样,异常又长什么样

遇到疑难杂症,逻辑分析仪是最好的终审法官。把逻辑分析仪探头夹在DATA脚上,触发方式设为下降沿触发,抓一次完整的读取过程。

正常波形应该是这样的框架:主机拉低的一个很长低电平(18ms)后,出现一个较窄的低电平脉冲(80us应答),然后是高电平,接着是40位数据。每一位都是相似的图案:先一个50us的低电平,再一个窄高电平或宽高电平。高电平宽的是1,窄的是0,肉眼都能分辨。

如果抓到的波形高电平普遍比标称值窄很多,大概率是上拉电阻太大,上升沿来不及抬升到阈值电压就被下一次拉低打断;如果波形方波边缘全是毛刺,大概率是引脚速度配置太高或电源纹波太大。

5.3 中断抢占:26微秒的判读窗口不能被抢走

DHT11在40位数据期间,数据0的高电平只有26~28us,而我们的判据是在高电平开始后延时40us再读引脚。这40us里哪怕被一个高优先级中断插入并执行了超过十几微秒,读到的高电平状态就可能出错——数据1被误判成数据0,或者干脆超时返回0xFF。

在裸机程序里,如果只开了串口中断、定时器中断且频率不高,问题不大;但如果你同时跑着一个10ms周期的控制任务、一个串口收发、还有一个软件定时器,DHT11读取期间很可能会被抢占。最简单的处理办法是在读取DHT11期间关闭不相关中断:

__disable_irq(); uint8_t ret = DHT11_ReadData(&humi, &temp); __enable_irq();

但整段读取要几十毫秒,全局关中断太久会影响实时任务。如果你对实时性要求高,直接看下面的定时器输入捕获方案。

5.4 进阶方案:定时器输入捕获测脉宽,彻底告别阻塞

用STM32的定时器输入捕获来测量DHT11每个脉冲的宽度,是更工程化的做法。原理是:把DATA引脚配置为定时器输入捕获通道,记录每个边沿到来的计数值,通过相邻两次捕获的差值换算出脉冲宽度,再由主逻辑判断是0还是1。

PA0恰好对应TIM2_CH1,硬件上不用挪线。配置思路是:TIM2的预分频设为71,计数频率1MHz,也就是计数器每1us加1;通道1先捕获上升沿,进入中断记录当前计数值,然后切换为捕获下降沿;下降沿到来时再记录一次计数值,两次差值就是高电平宽度,之后又切回上升沿。

判定时先分辨出50us的低电平起始位,然后看高电平宽度:小于40us判为0,大于40us判为1。这样CPU不再需要阻塞等待,即使读取过程中来了中断,也只是影响中断响应时间,不会破坏已经记录下来的脉宽数据。回调函数的核心骨架大概长这样:

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { if (capture_edge == RISING) { t0 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); capture_edge = FALLING; } else { t1 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); pulse_width = (uint16_t)(t1 - t0); __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); capture_edge = RISING; } } }

这个方案对初学者友好度低一些,但如果你在做一个综合项目,比如同时有电机控制、显示刷新、通信任务,我很建议尽早掌握它。DHT11只是一个练手对象,这种"用硬件测量外部脉冲宽度"的能力在很多传感器和通信协议解析场景都能复用。

6. 把数据变成能用的东西:串口、OLED和三个部署小建议

6.1 串口打印

在CubeMX里使能USART1后,最简单的方法是通过重定向fputc让printf从串口输出:

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

然后主循环里1秒读取一次,成功就打印:

uint8_t humi, temp; if (DHT11_ReadData(&humi, &temp) == 0) { printf("Temp: %d C, Humi: %d %%\r\n", temp, humi); } else { printf("DHT11 read error\r\n"); }

这样在串口助手里就能看到稳定的温湿度输出。注意printf默认是阻塞式的,串口发送占用时间极短,不会影响DHT11的下一次读取,因为中间有1秒间隔。

6.2 OLED显示:0.96寸SSD1306的最简单接法

把数据从串口搬到屏幕上也很简单。0.96寸OLED大多使用I2C接口,STM32F103的硬件I2C1在PB6/PB7上,OLED的SCL、SDA分别接这两脚,VCC接3.3V,GND接GND。驱动代码可以直接沿用常见的SSD1306驱动,关键是显示前把数值转成字符串:

char line1[16]; sprintf(line1, "T:%d C", temp); OLED_ShowString(0, 0, line1);

这类OLED屏幕刷新速度快,显示温湿度绰绰有余,也是很多智能台灯、鱼缸监测小项目的标配显示方案。

6.3 部署中的三个容易翻车的小细节

第一个细节是探头位置。DHT11不要贴着STM32芯片放,芯片发热会让温度读数偏高,这在高集成度板子上经常被忽略。传感器尽量靠近通风处,但也不要直接暴露在风口,否则气流会让湿度读数跳动。

第二个细节是数据滤波。DHT11单次测量的随机抖动比较明显,尤其是湿度。应用中对控制精度有一定要求时,可以连续采3~5次,每次间隔1秒,去掉最大值和最小值后取平均,或者直接取中位数。效果立竿见影。

第三个细节是湿度环境。DHT11怕长期高湿环境,湿敏电阻在湿度很高时容易吸附水汽,导致后续响应变慢甚至读数虚高。如果项目长期在潮湿环境运行,要么定时给传感器断电加热除湿,要么干脆换用更适合高湿环境的传感器。

我在实际使用中的体会是,DHT11这个传感器上限确实不高,但它是一块非常好的"敲门砖"。读时序、写驱动、看波形、排查0xFF,这一整套流程你做下来,后面再遇到任何单总线器件或者需要精确延时的传感器,都会有一种"不过如此"的底气。如果你刚接触STM32,建议先把逻辑分析仪或者示波器准备好,夹上DATA脚亲眼看一次波形,再回来对照这篇里的驱动代码,很多疑惑会一下子解开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询