ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32驱动DHT11温湿度传感器:时序、驱动与排坑详解

STM32驱动DHT11温湿度传感器:时序、驱动与排坑详解 前阵子帮朋友调一块基于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引脚连接到STM32VCC3.3V或5V见下文说明DATA任意GPIO本文用PA0GNDGND这里有个容易踩坑的地方如果DHT11用5V供电数据脚高电平也是5V虽然STM32F103系列部分引脚标称5V容忍但并不是所有引脚都保证最稳妥的做法是DHT11直接用3.3V供电。手册上明确写了工作电压3.3V~5.5V3.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是单总线器件主机发起读取后数据线上会依次出现几个特征明显的高低电平阶段主机先把数据线拉低持续18ms以上然后释放为高电平。DHT11检测到起始信号后主动将数据线拉低80us作为应答信号。应答结束后DHT11将数据线拉高80us然后开始输出40位数据。40位数据按高位先出的顺序逐位输出。数据输出完毕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那么0x280x000x1C0x000x44校验通过实际环境是湿度40%RH、温度28℃。这是非常标准的一组数据但也别指望湿度小数位在多数环境下都是非零值DHT11自身精度决定了小数位基本上只有0。4. STM32驱动实现从空工程到串口打出温湿度4.1 工程准备我用STM32CubeMX生成一个F103C8T6基础工程时钟配置为72MHzPA0设置为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); // 输入模式CNF10 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。这个现象背后原因不少按下面的顺序排查最高效检查接线。DATA脚是否真的接到了代码里配置的那一脚杜邦线是否接触良好GND是否共地。检查上拉电阻。模块上的上拉是否虚焊裸传感器是否没有外接上拉。文章开头说的那颗虚焊电阻就是这个原因。检查供电。DHT11如果供电不稳时序会产生漂移特别是从3.3V引脚挂多个外设时容易掉压。确认上电稳定时间。刚上电1秒内不要读取传感器内部还没进入正常状态。确认延时函数。SystemCoreClock是否和实际时钟一致DWT是否成功使能。检查GPIO速度配置。如果PA0配置成了最高速度波形边沿会产生明显振铃建议使用低速或中速。检查方向切换逻辑。发送起始信号后是否成功切回输入模式。这套排查顺序我写成了一张简单的检查表每次遇到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.3VGND接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脚亲眼看一次波形再回来对照这篇里的驱动代码很多疑惑会一下子解开。
返回列表