
老早之前第一次把DHT11接到STM32上玩的时候我以为这东西跟DS18B20差不多找个例程调一下就完事了。结果卡在时序上折腾了一整晚读出来的数据要么全是0要么偶尔蹦出几个看起来合理但仔细一看根本不对的值。后来挂了逻辑分析仪才彻底看明白这颗只有一根数据线的传感器脾气其实不小。网上关于DHT11的教程确实多如牛毛但很多都是直接把代码贴出来告诉你这么写就行至于为什么这么写、时序哪里容易出问题、踩了坑怎么排查讲得反而不多。这篇教程就把STM32以F103系列HAL库为例驱动DHT11的完整内容捋一遍从通信协议到代码实现从常见坑位到排查思路希望能让你少走点弯路。无论是做课堂作业、毕业设计还是实际项目里加个环境监测功能只要你想在STM32上快速拿到温湿度数据这篇文章都适用。我尽量把每个关键步骤都讲透而不是扔一段代码让你自己猜。1. DHT11这颗传感器到底特殊在哪1.1 先认清它的定位便宜、够用、但别指望高精度DHT11是广州奥松电子Aosong推出的一款复合温湿度传感器内部集成了一个电阻式湿度感应元件和一个NTC热敏电阻测温元件经过ADC采样后通过单总线接口输出数字信号。它的测量范围是湿度20%到90%RH、温度0到50摄氏度精度分别是±5%RH和±2摄氏度。说实话这个精度放到今天的传感器市场里并不算出色同门的DHT22也叫AM2302精度就好不少SHT30、SHT31这些更是甩它好几条街。但DHT11胜在便宜、功耗低、接线简单、资料多几十个引脚的单片机项目里给它留一个GPIO口就够了。在环境监测、温室大棚、小型气象站、机房告警这类对精度要求不苛刻的场景里它依然是性价比很高的选择。另一个经常被忽略的点是DHT11的采样周期。数据手册明确写了一次完整的温湿度采集至少需要1秒的间隔也就是说同一颗传感器两次读取之间最好等1到2秒。实际测试中如果你连续快速读取很容易读到上次采样的旧数据甚至直接拿不到有效响应。这个特性在写主循环逻辑的时候要特别注意不要每几百毫秒就去读一次纯属浪费时间和心情。1.2 引脚就这么几个接线也不复杂DHT11常见的有三种封装形态四针直插、三针模块、PCB贴片。最常用的三针模块也就是面包板专用的那种小板子引脚定义是VCC、DATA、GND板上已经集成了一颗上拉电阻和稳压滤波电容接法非常简单VCC接3.3V或者5V都可以模块一般做了电平兼容处理裸片则建议3.3V系统用3.3V供电DATA接STM32任意一个GPIO配置为开漏输出带上拉或者推挽输出切换输入模式GND接GND如果是裸DHT11四针封装引脚顺序是1脚VCC、2脚DATA、3脚NC、4脚GND这种情况下数据线上必须自己加一颗4.7k到10k欧姆的上拉电阻。因为DHT11的数据引脚是真正的开漏结构没有内置上拉不加上拉电阻的话读数据的时候引脚会一直处于低电平什么都读不出来。很多人一开始用STM32的推挽输出模式驱动DHT11也能正常工作但老实说最稳妥的方式还是把GPIO配置成开漏输出同时外部加上拉电阻。开漏输出模式下输出低电平时引脚真正拉低输出高电平时引脚就释放高阻状态由上拉电阻把电平拉到高这样和DHT11的通信逻辑天然吻合也不容易出现电平冲突。2. DHT11通信协议深度拆解一根线怎么传40位数据2.1 40位数据帧里到底装了什么DHT11采用单总线1-Wire-ish通信协议但与Maxim的1-Wire协议细节上有明显差异不能直接用现成的1-Wire库。每次传感器向主机输出40位数据格式是固定的数据段位宽含义湿度整数部分8bit实际湿度整数单位%RH湿度小数部分8bit实际湿度小数DHT11固定为0温度整数部分8bit实际温度整数单位摄氏度温度小数部分8bit实际温度小数通常为0校验和8bit前四个字节之和的低8位温度还有一个细节如果温度是零下温度整数部分的最高位bit7会被置1表示负号。比如-10度温度整数部分存储的是0x8A二进制10001010实际温度值是0x0A也就是10再根据符号位判断为负。读取的时候要主动做一次符号判断否则会把-10度读成138度。校验和的计算也很直白校验和 湿度整数 湿度小数 温度整数 温度小数全部按8位无符号处理超过8位的进位直接丢弃。做数据校验时把这四个字节加起来取低8位和第五个字节比对一致就认为数据可靠不一致说明这次通信出了问题果断丢掉重新读。2.2 起始信号和响应时序谁先动谁后动DHT11的通信发起方是主机STM32传感器从来不会主动上报数据你问它才说。完整的握手流程分三个阶段主机拉低数据线。这一步要持续至少18毫秒推荐18到30ms。18ms不是随便定的DHT11内部是用一个低频晶振配合逻辑电路来检测起始信号的太短了它反应不过来。这里直接调用HAL_Delay(20)就行20ms在安全范围内。主机释放数据线变成输入状态。此时数据线由上拉电阻拉高高电平持续20到40微秒。这个时间窗口比较短延时函数精度必须到微秒级后面专门讲怎么实现。DHT11检测到起始信号后会主动把数据线拉低80微秒再释放拉高80微秒这就是响应信号。主机在这个阶段需要等待先检测到低电平表示传感器开始响应再等引脚变高表示响应信号结束。响应信号结束后DHT11立刻开始向主机发送40位数据位。每一位的传输方式和起始信号类似先拉低50微秒这个固定的低电平时间很重要它是每一位数据的“起始标记”然后释放数据线。释放后高电平持续的时间决定了这一位是0还是1。2.3 读0和读1的区别说白了就是高电平持续多久DHT11传每一位数据时低电平都是50微秒这是固定不变的。区别在于高电平的持续时间如果这一位是0高电平持续26到28微秒如果这一位是1高电平持续70微秒左右为什么采样点要选在40微秒左右因为0和1的高电平时长差异很大40微秒刚好处于“0已经结束、1还在继续”的中间地带。这样在40微秒处读电平读到低就是0读高就是1判断可靠。理解了这一点写读取代码的核心思路就清晰了每一位都等待引脚从高变低注意是每位数据的开始50us低电平然后延时约40微秒在采样窗口处读取电平值再等待高电平结束进入下一位。40微秒这个数不需要太精确35到45微秒之间都行关键是要躲开0信号的高电平结束点和1信号的高电平提前变低的时间。实际调试的时候很多人以为用while循环检测引脚变低再读取就行但忽略了采样点的摆放。如果等引脚变低了才去读0和1已经没有区别了。我见过不少新手写的代码是先等引脚拉低再读一次读到的永远是0。正确逻辑是“先检测到一个高电平的起始然后定时采样”或者更直白一点等待引脚从低变高再延时40us采样再等待引脚从高变低如此循环。3. STM32驱动DHT11核心代码一步步拆开看3.1 引脚初始化输入输出模式怎么切换才不打架DHT11的数据引脚需要在通信过程中不断切换输入/输出方向主机发起始信号时当输出用读数据时当输入用。在HAL库里我们通过改变GPIO初始化结构体并重新调用HAL_GPIO_Init()来实现切换#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_4 static void DHT11_Pin_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); } static void DHT11_Pin_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); }注意我特意用了开漏输出模式。如果配置成推挽输出在读取阶段不小心把引脚拉低了会和DHT11的驱动产生总线冲突轻则数据错乱重则可能损伤传感器。开漏模式下没有这个风险输出高电平实际是“释放”引脚让上拉电阻把电平拉上去。对STM32F103来说GPIO翻转频率完全跟得上DHT11的时序要求但为了保险起见还是把速度设为GPIO_SPEED_FREQ_HIGH让IO口内部的驱动能力更充沛边沿更陡峭采样的时候信号更干净。3.2 微秒级延时HAL_Delay在这里不好使读DHT11的时序里需要微秒级的延时比如起始信号后的20-40us、数据位里的40us采样点。HAL_Delay()是基于SysTick的毫秒级延时最小单位是1ms完全没法用在微秒级别。那么有哪些替代方案最常用的有四种for循环空转、SysTick重配置微秒延时、DWTData Watchpoint and Trace单元延时、定时器延时。我的建议是优先用DWT延时因为DWT是Cortex-M3内核自带的调试组件很多型号的STM32都有不需要额外占用定时器代码也简单。static void DWT_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } static void DWT_Delay_us(uint32_t us) { uint32_t startTick DWT-CYCCNT; uint32_t delayTicks us * (SystemCoreClock / 1000000U); while ((DWT-CYCCNT - startTick) delayTicks); }原理很简单DWT_CYCCNT是一个32位的自由运行计数器每个CPU时钟周期加1。72MHz主频下1微秒就是72个计数。SystemCoreClock / 1000000算出来的就是1微秒对应的计数值乘上你要延时的微秒数就是总等待周期数。这个延时函数在无中断打扰的情况下精度很高完全满足DHT11的时序要求。另外注意DWT_Delay_Init()要在系统时钟配置完成后调用并且最好在main函数早段执行一次。如果系统主频后面发生了改变比如从72MHz切到48MHz延时时间就会偏移需要重新初始化或重新计算。3.3 读取函数完整实现从握手到校验一气呵成下面是完整的DHT11读取代码注释写得很详细照着抄基本能跑uint8_t DHT11_Read_Data(uint8_t *humidity, uint8_t *temperature) { uint8_t buf[5] {0}; uint8_t checksum 0; uint8_t i, j; // 1. 主机发送起始信号拉低至少18ms DHT11_Pin_Mode_Output(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 2. 释放总线拉高20-40us HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); DWT_Delay_us(30); // 3. 切换输入模式读取DHT11响应信号 DHT11_Pin_Mode_Input(); // 等待低电平DHT11拉低80us表示响应开始 if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) return 1; // 没有响应可能传感器未接或损坏 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET); // 等待高电平80us响应高电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET); // 4. 读取40位数据 for (i 0; i 5; i) { for (j 0; j 8; j) { // 跳过低电平的50us起始标记 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET); // 在采样窗口等待40us再读 DWT_Delay_us(40); if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { buf[i] | (0x80 j); } // 等待高电平结束为下一位做准备 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET); } } // 5. 校验和验证 checksum buf[0] buf[1] buf[2] buf[3]; if (checksum ! buf[4]) return 2; // 校验失败数据不可靠 *humidity buf[0]; *temperature buf[2]; return 0; }这个代码的逻辑顺序是起始信号 → 切换输入 → 检测响应 → 按位读取 → 校验。每一步之间不要随意加延时尤其是读数据部分不要引入不必要的HAL_Delay毫秒级的停顿会直接把时序撕碎。有几个细节值得强调第一个是数据位读取时的while (读引脚 RESET)等待循环。如果通信真的卡住了这个while会一直等下去程序就死在这里。实际产品里建议加一个超时退出机制比如用一个计数变量超过一定次数就返回错误。我见过不少人在调试时程序“卡死”其实就是这里永远等不到引脚变高加上超时判断是必须的。第二个是buf[i] | (0x80 j)的位拼接方式。第1位数据放在bit7第2位放在bit6依此类推这正好符合DHT11先发高位再发低位的时序。好多人用buf[i] 1; buf[i] | value;也能实现相同效果两种写法都可以。第三个是读取温度时不要忘了处理负温度。buf[2]的最高位如果是1代表零下温度实际温度值应该是buf[2] 0x7F再取负号。具体业务里需要根据应用场景决定要不要处理。3.4 主循环调用和数据处理示例实际项目里DHT11的读取频率建议控制在1Hz以内。配合标志位或者定时器做节流避免每次while循环都去读一次。下面是个简单的调用示例int main(void) { uint8_t hum, temp; uint8_t ret; HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); DWT_Delay_Init(); while (1) { ret DHT11_Read_Data(hum, temp); if (ret 0) { printf(Humidity: %d.%d%%RH, Temp: %d.%dC\r\n, hum, 0, temp 0x7F, 0); } else { printf(DHT11 read failed, error code: %d\r\n, ret); } HAL_Delay(1000); } }这里有个很多人容易踩的坑printf输出串口重定向如果用的是微库MicroLIB打印一次可能耗时好几毫秒甚至更久。这个时间在主循环里无所谓但如果你的项目还有别的实时任务串口打印很容易成为时间上的“黑洞”。建议调试阶段用串口看数据没问题正式跑业务时把打印关掉或降低频率。另外一个注意点是DHT11读到的湿度小数部分恒为0所以printf里我直接写了%d.0没有用buf[1]和buf[3]。不同批次不同版本的DHT11可能有细微差异如果你发现小数位偶尔有值也正常按实际数据输出即可。4. 实际调试中高频踩坑点每个问题都附解决办法4.1 微秒延时不准代码逻辑看着没问题数据就是不对这是最典型的问题没有之一。很多人拿例程的延时函数跑发现读出来全是最低位为1的乱码或者湿度温度数值跳来跳去没有规律。核心原因就是微秒延时延多了或者延少了导致40微秒的采样点落在了错误位置。排查方法是把延时代码换成DWT延时或者用逻辑分析仪抓一下数据线上的波形对比标准时序图。如果波形里高电平的宽度普遍偏宽或者偏窄说明延时偏了。还有一个容易忽略的问题开了编译优化比如-O2、-O3后for循环空转延时会严重失真编译器会把循环优化掉延时时间变得毫无意义。DWT延时因为是读取硬件计数器编译优化不影响精度这也是推荐DWT的另一个原因。另外要注意如果用的是HAL库HAL_Init()里会配置SysTick而SysTick的中断优先级默认不是最高的。如果在读取DHT11的过程中任何一个优先级更高的中断触发并且执行时间超过几十微秒时序就被打乱了。解决方案有两种读取DHT11期间临时屏蔽中断__disable_irq()和__enable_irq()或者把SysTick的优先级调到最高并且在DHT11读取期间禁止其他高频中断。第一种方案最简单粗暴但如果你在RTOS环境里关中断可能会引起任务调度问题需要更谨慎地设计临界区保护。4.2 程序跑着跑着卡死了问题往往在while等待循环我在调试DHT11时好几次遇到这种情况程序运行一会儿就停在那里不动了用调试器暂停一看停在while (HAL_GPIO_ReadPin(...) GPIO_PIN_RESET);这个循环里面。原因很简单——通信过程中DHT11因为某种原因比如供电不稳、干扰、读取频率过高没有按预期拉高或拉低引脚程序就死等在这个while里。解决办法是给所有等待加超时保护。比如uint16_t timeout 10000; while (HAL_GPIO_ReadPin(...) GPIO_PIN_RESET) { if (--timeout 0) return 1; }每次等待最多循环一定次数超时就退出并返回错误码不耽误主流程。这个改动成本极低但对系统稳定性的提升是决定性的。特别是如果你的STM32项目里还接了RTOS一个死循环直接卡死一个任务甚至可能触发看门狗复位。4.3 引脚模式和硬件接线问题开漏还是推挽上拉是否到位之前说过用开漏输出模式最稳妥但有些例程用推挽模式也能跑。我自己的经验是推挽输出模式下如果是3.3V供电的DHT11模块在STM32F1上问题不大但如果你用的是5V供电的裸DHT11数据引脚输出的高电平是5V逻辑STM32F1的引脚是5V容忍的还好换了F4系列就要小心了很多F4的引脚不是5V容忍的直接接可能损伤GPIO。最好的做法是DHT11用3.3V供电数据线加一个4.7k上拉到3.3VGPIO配置为开漏输出。这样无论模块还是裸传感器电平都匹配开漏释放后由上拉电阻拉到3.3V逻辑清晰也不会损毁引脚。还有一个容易被忽略的点DHT11的电源和STM32的电源最好共地。如果传感器单独供电两边GND没有接在一起读回来的数据一定是不稳定的乱码。这个问题在面包板实验里特别常见接线看起来都对就是没共地排查半天最后发现是地线没连。4.4 调试器一打断就出错以及ST-Link/USB转串口的那些小毛病如果你用ST-Link在线调试单步执行时发现DHT11读出来的数据全不对别怀疑传感器坏了。DHT11的时序是微秒级的单步调试时程序停住传感器不会等你等你继续跑的时候时序早就错位了。这种情况下要么把读取过程一次性跑完再打断点看结果要么干脆用串口打印数据不要用单步去追DHT11。另外使用ST-Link的虚拟串口Virtual COM Port时Windows设备管理器里有时候会出现一个带黄色感叹号的设备这个通常不是STM32本身的问题而是驱动没装好或USB枚举失败。解决办法是重新安装ST-Link驱动或者换一根USB线排查顺序从硬件到驱动再到程序。这一节虽然不是DHT11的直接问题但很多人第一次用STM32开发板做DHT11实验时恰好卡在这里串口看不到数据就以为是传感器出了问题结果绕了一大圈才发现是虚拟串口驱动的问题。所以我会建议调试阶段先确保串口能正常输出固定字符再去读传感器能省很多排查时间。5. 实测数据与个人经验5.1 实际测量表现和数据处理建议用STM32F103C8T6蓝色Pill板 DHT11模块做了连续24小时监测放在室内环境温度20到26度之间波动湿度40%到60%RH之间波动读出来的数据稳定性还可以。连续读取1000次人为制造干扰的条件下校验失败率大约在1%到3%之间正常环境下基本低于0.5%。对于DHT11的读数我建议做两级处理第一级是简单滤波连续读5次去掉最大值和最小值取中间3次的平均值。DHT11本身精度就不高滤波的意义不在于提升精度而在于剔除偶发的跳变值让显示数据更平滑。第二级是合理限幅如果相邻两次读数的温度差超过5摄氏度或者湿度差超过20%RH直接丢弃这次数据。DHT11本身是慢变传感器不可能在1秒内出现那么大的跳变一旦读到这种数值基本可以断定通信受到了干扰数据不可信。5.2 几个提升稳定性的细节线长要控制。DHT11和STM32之间的数据线最好不要超过20厘米。线一旦长了分布电容变大波形边沿变缓很容易导致采样点读数出错。如果需要远距离传输建议改用I2C接口的传感器比如SHT30或者加电平转换/驱动芯片。电源要干净。DHT11对电源纹波不算特别敏感但如果你的STM32板子是USB供电而USB电源本身噪声很大DHT11的数据偶尔会跳变。实测中USB供电加上一个100uF电解电容滤波稳定性会有明显改善。读取频率要克制。DHT11自身采样周期是1秒一次你读得再勤也没有用反而增加总线上的无效通信提高出错概率。用定时器每2秒触发一次读取是性能和稳定性比较平衡的选择。如果是做产品而不是做实验我建议在DHT11外围加一个RC滤波比如数据线上串一个100欧姆电阻并联一个100pF电容到地既能滤掉一部分高频干扰又不会把沿拖得太慢。这个在工业环境或长线场景下作用很明显。5.3 从DHT11到更高阶的传感器选择如果你在做项目过程中发现DHT11的精度不够用最容易的升级路径是换DHT22AM2302。DHT22的通信协议和DHT11完全兼容时序几乎一样代码改动很小但精度提升到±2%RH和±0.5摄氏度采样周期也从1秒缩短到2秒价格只贵一两块钱。如果还需要更高的可靠性和更快的读取速度或者你的主控引脚比较紧张可以考虑I2C接口的SHT30。它不需要自研时序直接用硬件I2C就能读而且支持多个传感器挂同一条总线上。不过SHT30的寄存器配置比DHT11复杂一些需要花点时间看数据手册。最后说点切身体会。如果你是个刚接触单片机的新手DHT11是一个非常好的练手项目它能把GPIO操作、时序设计、延时精度、数据校验、异常处理这些基本功全部串起来。如果你是个老手DHT11的时序分析和抗干扰设计也能提醒你最简单的传感器里依然藏着不少值得认真对待的细节。把这个模块吃透了后面再玩DS18B20、DHT22、SHT30乃至各种自己写协议的传感器思路都是相通的——先吃透数据手册的时序图再用逻辑分析仪验证波形最后才谈得上一行一行调代码。