STM32驱动DHT11温湿度传感器:单总线时序控制与代码实现 第一次拿到DHT11模块的时候其实我心里是有点嘀咕的。一颗看起来只有三个引脚、连个I2C地址都没有的小传感器能有多大讲究后来真把它接到STM32上才发现这个“便宜到像白送”的温湿度传感器藏着嵌入式开发里最值得反复琢磨的一门基本功——时序控制。STM32的开发学习绕不开外设驱动而DHT11恰恰是理解“MCU如何与外部芯片精确对话”的绝佳样本它只有一根数据线所有通信全靠电平高低和延时长度来传递信息没有任何时钟线兜底。这篇教程我会从硬件连接、单总线协议、代码实现到排查经验完整走一遍用STM32驱动DHT11的流程适合刚学会GPIO控制、正准备迈向传感器实战的读者也适合那些“跟着视频调通了但换个引脚就翻车”的卡壳选手。1. 先说清楚DHT11到底是个什么东西1.1 一颗传感器为什么要单独写一篇教程如果你只用过DS18B20、BH1750这类带明确通信协议的传感器第一次接触DHT11大概率会困惑为什么一根线既能供电、又能发数据其实DHT11用的是一种叫“单总线”的通信方式全称Single-Wire Bus意思是主机和从机之间只靠一根数据线完成双向通信。这跟I2C的两根线、SPI的四根线相比硬件开销省到了极致但代价就是时序要求极其严格几微秒的偏差都可能导致整帧数据错乱。DHT11能测湿度和温度两个参数湿度测量范围是20%到90%RH温度是0到50摄氏度分辨率都是1精度分别是±5%RH和±2摄氏度。说实话这个精度放在工业级产品里不够看但放在家庭环境监测、大棚温控、机房告警、毕设演示这些场景里完全够用关键是它模块便宜拆机件甚至一两块钱就能拿下坏了也不心疼。用一句话概括它的定位它是“练手级”传感器里最能把时序通信讲明白的同时也是低成本环境监测方案里最皮实耐造的选择。1.2 弄懂DHT11的性能边界就不会用错地方在动手之前建议先把DHT11的数据手册参数过一遍很多翻车现场都源于对参数的理解不到位。参数DHT11数值说明供电电压3.3V~5.5V模块通常兼容5V裸传感器建议5V供电更稳定测量范围湿度20%~90%RH温度0~50℃超出范围读数不可靠精度湿度±5%RH温度±2℃只能说“测个大概趋势没问题”分辨率1%RH1℃输出数据本身就是整数采样周期1秒两次读取间隔必须大于1秒接口单总线双向主机需控制收发切换数据长度40bit含校验8bit湿度整数8bit湿度小数8bit温度整数8bit温度小数8bit校验和这里面最容易踩坑的就是“采样周期1秒”。很多人第一次调通后兴致勃勃地在主循环里毫秒级地狂读DHT11结果发现数据时灵时不灵甚至读到一堆0xFF。原因就是DHT11的上一次读取还没准备好新数据你这边又开始发起始信号它干脆就罢工了。所以代码层面最少要保证两次读取间隔不小于1秒我自己的写法通常是1.5秒读一次留出裕量。2. 硬件连接不翻车从原理图开始2.1 模块和裸传感器的接线差别很大DHT11在淘宝上能买到两种形态一种是裸传感器四个引脚直接露在外面另一种是已经焊好上拉电阻、滤波电容的模块通常有三个引脚VCC、DATA、GND。这两种接线的差别直接决定了你的代码要不要加内部上拉、要不要额外配置。裸传感器的引脚顺序是VCC、DATA、NC空脚、GND中间的NC脚别接任何东西。模块版则简单很多三根线对应接好即可。我建议新手直接买模块版少踩很多焊接和上拉的坑。如果你手头是裸传感器那就要注意数据线必须外接一个4.7kΩ到10kΩ的上拉电阻到VCC否则通信时的高电平会很不稳定时好时坏。这个上拉电阻不是可选项而是单总线协议的基本要求——因为DHT11的数据线是开漏结构只能主动拉低拉高靠外部电阻完成。2.2 上拉电阻和去耦电容一个都不能少很多从面包板玩起的朋友会问我的模块上已经有上拉电阻了是不是不用管了模块上那个上拉电阻阻值通常是5.1kΩ或10kΩ确实够用但你别忘了在电源引脚旁边放一个100nF的去耦电容。这个电容的作用是滤掉电源线上的高频噪声DHT11在通信瞬间会有微小的电流波动如果供电本身纹波很大时序就会跟着抖动。实测经验是当我们用STM32的3.3V给DHT11模块供电时如果数据线直接接GPIO最好把STM32那边的输入模式配置为上拉输入相当于和模块上的上拉电阻并联。但要注意两个上拉并联等于阻值减半上升沿会变快反而可能导致电平翻转过快、时序不稳定。所以我更推荐的做法是模块用5V供电数据线上串一个1kΩ电阻再接STM32引脚这样既保证了电平可靠又避免5V直接灌进3.3V的IO口。当然如果你用的是3.3V供电的模块版本那就直接接GPIO配输入上拉模式问题也不大。2.3 DC特性与电平匹配问题STM32的GPIO虽然标称“容忍5V”很多型号是FT引脚但最稳妥的方案还是不要直接用5V驱动3.3V引脚。DHT11模块工作在5V时数据线高电平是5V这超出了STM32的3.3V逻辑电平范围。虽然很多FT引脚声称兼容5V但长期使用后IO口老化、甚至烧毁的案例并不是没有。我调试时用的折中方案是给DHT11模块单独供5V数据线上串联一个1kΩ到2.2kΩ的电阻再接STM32引脚。这个电阻有两个作用一是限制电流二是和DHT11内部的开漏结构配合把高电平稍微分压到安全范围。如果你不想加电阻那就只能用3.3V给模块供电虽然DHT11手册标称3.3V也能工作但实测发现它的测量精度和稳定性在3.3V下会略差一些尤其在低温环境下这个差距会更明显。2.4 布线细节长线干扰是真坑还有一个很隐蔽的坑是线长。DHT11的单总线协议是靠微妙级的电平宽度来区分0和1的所以数据线的分布电容、导线电阻都会影响电平的上升时间。如果你用20厘米以上的杜邦线把DHT11和STM32连起来大概率会遇到“手放上去数据就变手拿开就好”的诡异问题。这是因为人体接近改变了线路的寄生电容导致上升沿变缓STM32在高电平窗口内采不到正确的逻辑值。我的建议是实验阶段数据线尽量控制在10厘米以内如果非要拉长线可以在DHT11的数据引脚旁边对地加一个100pF的小电容来抑制干扰但千万别加太大否则上升沿会被拖慢到无法识别。如果你要在实际项目里把DHT11装在远处那就要考虑用屏蔽线或者换I2C接口的传感器比如SHT30、AHT20它们的抗干扰能力强得多。3. 单总线协议一根线怎么把话说清楚3.1 起始信号主机先说“我要数据”DHT11的通信是严格由主机发起的传感器永远不会主动说话。完整的读取流程是这样的STM32先把数据线上配置为输出模式拉低电平至少18毫秒再释放总线拉高这个低电平脉冲就是起始信号相当于主机在喊“DHT11把数据交出来”。为什么要18毫秒这么长因为DHT11内部的测量电路启动需要时间如果起始信号太短它可能还在休眠根本不知道主机在喊它。这里有一个细节释放总线之后主机要立刻把引脚从输出模式切换到输入模式开始监听DHT11的回应。这个切换要尽量快因为DHT11在检测到起始信号结束后的20到40微秒内就会开始拉低总线。如果切换太慢就会漏掉响应信号的前半段后面读到的数据自然就乱了。3.2 响应信号传感器说“我准备好了”DHT11收到起始信号后会把总线拉低大约80微秒然后再拉高80微秒这个低-高组合就是响应信号意思是“我已经准备好了下面开始发数据”。在代码层面我们通常不关心这80微秒的精确值只需要检测到低电平出现再等到高电平到来就认为通信握手成功。但这里有个细节容易让人困惑响应信号的低电平和高电平时长都是80微秒左右而后续数据位里的“0”和“1”也包含低电平和长短不同的高电平。所以不能只凭“先低后高”就判定是响应信号必须配合后续40个数据位的解析才能完整确认。我的做法是在代码里先等低电平如果等不到或超时就认为这次读取失败直接返回错误码不去读后面的数据位避免用脏数据污染业务逻辑。3.3 数据0和数据1其实是用高电平长短来区分的DHT11发送40bit数据时每一位的格式都是“50微秒低电平 高电平”高电平的宽度决定了这一位是0还是1。具体来说位类型低电平时长高电平时长判定方式数据0约50us26~28us高电平短采样时读到低数据1约50us70us高电平长采样时读到高所以读位的核心逻辑是先等待引脚变为高电平然后延时40微秒再采样引脚电平。如果40微秒后引脚还是高说明这是数据1如果40微秒后引脚已经变成低说明这是数据0。因为数据0的高电平只有26到28微秒延时40微秒后肯定已经回落而数据1的高电平有70微秒延时40微秒后还在高位。这个“延时再采样”的思路其实就是单总线协议的胜负手。很多从I2C转过来的同学总想着用边沿触发中断去量高电平宽度但在单总线上完全没必要那么麻烦延时采样法既简单又稳定只要延时函数误差不超过±5微秒基本不会判错。3.4 校验位最后一个8bit是保险丝40bit数据的排列顺序是16bit湿度 16bit温度 8bit校验和。注意湿度的高8位是整数部分、低8位是小数部分温度也一样。但DHT11的分辨率是1所以小数部分实测通常全是0。校验和的计算方式很简单把湿度整数 湿度小数 温度整数 温度小数得到的8位结果如果等于校验位这帧数据就合法否则丢弃重读。校验这块一定不能省。我之前图省事跳过校验直接显示数据结果某天屏幕上突然跳出一个“湿度240%”的离谱值排查半天才发现是校验没做DHT11在供电不稳的时候完全可能发出错帧。加上校验之后错误的帧会被直接丢弃宁可显示上一次的正确值也不能让垃圾数据显示给用户。4. 代码实现手写一份DHT11驱动4.1 开发环境与GPIO配置思路这次示例我用STM32CubeIDE HAL库来写选型定位为STM32F103C8T6蓝板但代码逻辑对其他型号同样适用无非是时钟频率不同导致延时要做调整。工程里我分配了一个引脚给DHT11使用PB0作为双向数据线配置方式为推挽输出和浮空输入之间动态切换。GPIO初始化时先设为输出模式写一个低电平延时18ms再切换为输入模式。这个切换在HAL库里要通过GPIO_InitTypeDef结构体重新初始化。#include dht11.h #define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_PIN_0 #define DHT11_GPIO_CLK_ENABLE() __HAL_RCC_GPIOB_CLK_ENABLE() static void DHT11_Pin_Output(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); } static void DHT11_Pin_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); }输出模式用推挽、速度设为HIGH是为了让起始信号的下降沿足够陡峭输入模式开内部上拉是为了配合外部上拉电阻一起保证高电平可靠。这里再说一次如果你用的是5V供电的DHT11模块且串了电阻Pull上拉可以和外部上拉并联感观上逻辑电平会偏高但只要引脚容忍5V就没有问题。4.2 微秒级延时的正确打开方式DHT11时序里最敏感的是微秒级延时但HAL_Delay()函数只支持毫秒级所以必须自己实现一个delay_us()。最简单粗暴的方法是空循环但空循环的性能和编译器优化强相关换个优化等级可能就翻车。更稳妥的方式用DWT也就是Cortex-M内核里的Data Watchpoint and Trace单元它自带一个CYCCNT周期计数器精度高、几乎不占用额外资源。static void DWT_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } static void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }这个实现的原理是DWT-CYCCNT每过一个内核时钟周期就加1只要知道当前SystemCoreClock是多少就能算出1微秒对应多少个周期。以72MHz主频为例1微秒就是72个周期延时40微秒就是2880个周期。注意上面代码里我把DWT-CYCCNT的差值做了无符号减法这样即使计数器发生回绕也不会出错。如果你用的是G0系列或者某些低功耗系列SystemCoreClock可能不是72MHz一定要根据实际主频重新算一遍否则整个时序都会偏。4.3 起始信号和读位核心代码时序的核心在下面这段代码里。我先写一个通用的读位函数然后基于它读整帧数据。读位函数有一个while循环等待低电平结束如果在循环里加了超时保护就能避免DHT11没响应时程序卡死在里面。我一直觉得这个超时保护是DHT11驱动最重要的部分没有之一。static uint8_t DHT11_ReadBit(void) { uint16_t timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET) { if (timeout 1000) return 0xFF; } delay_us(40); if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { if (timeout 1000) return 0xFF; } return 1; } else { return 0; } }这个函数里藏着两个细节。第一等低电平结束的那个while循环兼顾了“跳过每一位开始时的50us低电平”的功能第二判断数据位时延时40us再采样能区分0和1。那个return 0xFF是超时错误标记因为正常数据位只会返回0或1。如果你读到的字节里有0xFF那这帧数据基本可以判定为无效直接走校验失败分支就行。4.4 读40bit数据并解析读完整帧的逻辑是先发起始信号然后等待响应再连续读40个位拼出5个字节。每读一个字节之前先清零缓冲区注意次高位在前也就是MSB First。这个顺序不要搞反我第一次移植时就是在这里栽了跟头把位序搞反导致湿度温度永远是乱码。#define DHT11_TIMEOUT 10000 uint8_t DHT11_ReadData(uint8_t *humidity, uint8_t *temperature) { uint8_t data[5] {0}; uint16_t timeout 0; DHT11_Pin_Output(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); delay_us(19000); // 起始信号大于18ms HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); delay_us(30); // 释放总线等待传感器响应 DHT11_Pin_Input(); // 等待DHT11拉低响应信号低电平 timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { if (timeout DHT11_TIMEOUT) return 1; } // 响应信号低电平约80us timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET) { if (timeout DHT11_TIMEOUT) return 2; } // 响应信号高电平约80us timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { if (timeout DHT11_TIMEOUT) return 3; } for (int i 0; i 40; i) { uint8_t bit DHT11_ReadBit(); if (bit 0xFF) return 4; data[i / 8] (data[i / 8] 1) | bit; } // 校验 if ((uint8_t)(data[0] data[1] data[2] data[3]) ! data[4]) { return 5; } *humidity data[0]; // 湿度整数 *temperature data[2]; // 温度整数 return 0; }这段代码的返回值我特意用了不同数字表示不同的错误阶段方便调试时快速定位问题出在握手还是读位还是校验。实际调用时主程序只要判断返回值是否为0即可。这里还有一个小细节读取结束后最好把引脚重新配置为输入模式并且保持高电平。这样如果以后还要接别的外设不会因为引脚残留的低电平影响其他功能。4.5 主程序调用示例主程序就简单了串口初始化和GPIO初始化之后在循环里每隔1.5秒读一次然后把温湿度通过串口打印出来。这里要特别重视读取间隔我见过很多人在主循环里加个HAL_Delay(100)就以为够了其实DHT11的采样周期是1秒100ms间隔明显不够轻则读出旧数据重则导致通信失败。#include main.h #include dht11.h #include stdio.h UART_HandleTypeDef huart1; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); DWT_Delay_Init(); uint8_t humidity 0; uint8_t temperature 0; char buf[64]; while (1) { uint8_t ret DHT11_ReadData(humidity, temperature); if (ret 0) { sprintf(buf, Humidity: %d%%, Temperature: %d C\r\n, humidity, temperature); HAL_UART_Transmit(huart1, (uint8_t*)buf, strlen(buf), 1000); } else { sprintf(buf, DHT11 read error: %d\r\n, ret); HAL_UART_Transmit(huart1, (uint8_t*)buf, strlen(buf), 1000); } HAL_Delay(1500); } }5. 进阶场景RTOS、中断和低功耗环境下的使用5.1 死等时序会不会卡死系统前边的实现是阻塞式的也就是说在读取DHT11的那几十毫秒里CPU全程在跑while循环等电平变化。在裸机环境下如果你的主循环就是单纯读传感器然后发串口那完全没问题但如果你同时还驱动着屏幕刷新、电机控制甚至跑着FreeRTOS那就要小心了。DHT11的整个读取过程大约要耗时20到30毫秒其中大部分是起始信号的18毫秒低电平这段时间其他任务全被卡住如果你用FreeRTOS高优先级任务都会被阻塞系统调度就废了。所以遇到RTOS场景我建议把DHT11的读取放到一个独立任务里任务优先级设低一点并且只在真正需要刷新温湿度的时候才去读不要在循环里疯狂调用。另外如果读取失败最好加上重试机制但重试次数不要太多我一般最多重试2次再失败就直接返回错误等下一个周期再读避免DHT11长期占据总线导致其他传感器也被拖累。5.2 能不能改成非阻塞式读取严格来说DHT11这种没有时钟线的单总线协议很难做到完全非阻塞因为你必须精确地把握每一位的时序没法像I2C那样用硬件外设自动处理。但有一种折中做法是把起始信号和读取数据分成两个阶段。起始信号阶段可以先发一个18ms的低电平这个期间你可以让MCU干别的事情然后再回来切换输入模式读取数据。不过这样做的复杂度非常高代码可读性也差对于大多数项目来说并不划算。我自己的实践结论是DHT11适合放在对响应时间要求不高的场景里。如果你需要频繁、快速地读取温湿度那DHT11从协议层面就不合适直接上I2C接口的SHT30或者AHT20更靠谱。DHT11的价值在于“能跑通单总线、能理解时序”而不是“性能最强”。这个定位想清楚了很多进阶难题自然就不存在了。5.3 低功耗模式下的坑如果你在做一个电池供电的设备想把STM32在空闲时进入STOP模式那DHT11会给你带来两个头疼问题。第一DHT11本身没有休眠引脚它上电后就会持续工作待机电流虽然不大但也有几百微安对于电池供电来说是不可忽略的漏电流。第二如果你在进入低功耗前已经把DHT11的GPIO拉低了唤醒后再读数据时起始信号的低电平长度可能会受到低功耗唤醒时间的影响导致时序混乱。有一种办法是给DHT11的供电单独接一个MOS管需要测量时才给它上电。但要注意DHT11上电后需要至少1秒的稳定时间才能首次读取数据所以“需要测量时才上电”这个思路并不现实除非你每次测量前先等1秒。综合考虑下来DHT11真的不太适合严格的低功耗设计。低功耗项目建议选SHT40、SHTC3这类带真正休眠模式的传感器。6. 常见问题与排查技巧6.1 完全读不到数据返回错误码这是最典型的新手问题我把它拆成几条排查路径先检查GPIO配置。DHT11的数据线必须能在输出和输入之间切换如果你的代码忘了在发送起始信号后切回输入模式那引脚一直保持输出状态DHT11拉低总线时你读到的永远是自己的输出电平自然读不到任何有效数据。这个坑我踩过不止一次代码里也特别加了注释提醒。再检查上拉电阻。模块版一般自带裸传感器没有上拉的话数据线在空闲时是浮空的电平不稳定通信大概率失败。可以先用万用表量一下引脚对VCC的电压如果空闲时不是高电平那就说明上拉有问题。最后排查接线顺序。DHT11裸传感器的引脚排列在不同批次里可能有差异有些是VCC、DATA、NC、GND有些是DATA、VCC、NC、GND买回来先对着丝印和卖家图看清楚别上来就怼。接反导致芯片烧了的情况也不少见。6.2 数据全0xFF或间歇性错乱如果你能读到数据但内容明显不对大概率是延时不准或者线材干扰。先检查你的delay_us实现如果你用的是空循环延时试着手动把循环次数往上调一点看数据是否恢复正常。我遇到过编译器开-O2优化后空循环被精简掉延时直接变成0us导致所有时序全部错乱的情况。解决方案就是换成DWT或定时器延时不要靠空循环。如果延时没问题再检查电源。DHT11对供电纹波很敏感尤其是当STM32和传感器共用一个电源而电机、继电器这类感性负载同时动作时电源尖峰非常容易干扰DHT11的通信。这时候数据线两端加一个10nF电容通常能改善很多但如果你发现稳压输出本身纹波就大那就必须换电源方案了。6.3 调试时遇到error: no stm32 target found怎么处理这个错误确实和DHT11没有直接关系但很多人在开发板上折腾传感器时会因为接线短路、误触复位按键、调试器没连接好而遇到它。经验是遇到这个错误先别动代码断电后检查ST-Link和板子的连接线确认SWDIO、SWCLK、GND三根线都牢靠然后再重新上电尝试连接。如果依然不行按住板子的复位键不放点击下载程序的同时松开复位键很多时候能救回来。另外如果之前用串口下载过程序干扰了SWD引脚也会导致无法连接这时需要用BOOT0拉高进入系统存储器模式先擦除Flash再说。6.4 DHT11的精度上限与替代方案最后我想强调一下如果你在做的项目对温湿度精度有要求不要指望DHT11。它的湿度精度±5%RH、温度精度±2℃在校准和标定上基本没有调整空间单纯一个“误差”可能就覆盖了你要监测的整个安全阈值。遇到这类需求老老实实换传感器成本也就贵几块钱但效果天差地别。传感器接口温度精度湿度精度采样周期成本区间DHT11单总线±2℃±5%RH1s1~3元DHT22单总线±0.5℃±2%RH2s8~12元SHT30I2C±0.3℃±2%RH可配置10~15元AHT20I2C±0.5℃±2%RH可配置4~8元如果你是做毕设或者产品原型验证DHT11完全可以撑起整个演示流程如果是要交付给客户或者量产建议至少用DHT22起步预算充足直接上SHT30省心程度完全不一样。写在最后的调试心得回头再看DHT11这颗传感器它教会我的不是“怎么把温湿度读出来”而是“当通信协议没有硬件辅助时软件时序的每一微秒都得自己扛”。每次看到有人把DHT11接上STM32读不出数据我第一反应永远是让他查延时、查上拉、查GPIO切换这三个点解决之后绝大多数问题都会迎刃而解。最后的最后再分享一个小技巧如果你手头有逻辑分析仪把DHT11的数据线夹上去用8MHz采样率抓一段波形你会比我上面写的所有文字都更直观地理解单总线协议到底在说什么。有些知识真的是看一眼波形就通了。