
简介一份基于STM32F10X系列的DHT11温湿度传感器驱动程序资料主要面向嵌入式初学者以及需要在STM32平台上快速实现单总线通信的开发者。资源围绕DHT11单总线通信协议展开详细说明如何通过GPIO与定时器完成启动信号发送、27us/50us高低电平时序判读、40位数据捕获、校验和对比以及异常重试机制并在结尾给出实际工程中的移植与调试思路适合在Keil MDK或STM32CubeIDE中动手实践。压缩包共179个文件整体约4.49MB包含33个h头文件、31个c源代码文件、30个crf编译中间文件以及hex、map、uvprojx、sct等工程配置文件既能查看驱动实现细节也可直接编译烧录查看效果。资源已有1013人学习下载若正在学习STM32 GPIO、定时器及单总线协议这份含源码与实验指导的驱动工程可供参考。1. 项目背景与整体方案设计1.1 为什么DHT11还需要自己写驱动DHT11这颗温湿度传感器在嵌入式圈子里的地位基本等同于“Hello World”几乎每个玩STM32的朋友都绕不过它。市面上现成的例程代码确实不少但真正到了项目落地的时候你会发现直接用网上的代码往往问题一堆有的代码烧进去就是读不到数据有的设备换了批次就读数错误还有的时序参数完全靠猜。于是“DHT11驱动程序基于STM32”这个看似简单的项目反而是很多新手和老手都会卡壳的地方。这颗传感器采用的是单总线通信协议数据线只有一根既当输入又当输出而且时序要求非常严格。跟I2C、SPI这种有硬件外设支持的协议不一样STM32没有专门的单总线硬件模块所有通信都必须靠GPIO模拟并配合精确的延时来实现。这就意味着驱动程序写得好不好直接取决于你对时序的理解和延时函数的精度。我最初做这个项目是因为产品上需要低成本采集环境温湿度数据。对比了DHT11、DHT22、SHT30之后最终选了DHT11精度虽然不高湿度±5%RH温度±2℃但胜在价格便宜、体积小、功耗低对于普通的室内环境监测场景完全够用。驱动程序的开发工作本质上就是把数据手册上那几页时序图翻译成可靠的代码再经过真机验证。这篇博文我会把从原理到代码、从调试到排障的完整过程都过一遍希望能帮你在自己的板子上一次跑通。1.2 驱动方案选型阻塞、非阻塞还是中断在动手写代码之前想清楚驱动结构是很重要的一步。DHT11的驱动方案大致有三类我逐个分析过也分别踩过坑。第一种是最常见的阻塞式驱动直接通过延时函数卡时间数据位按“先拉低50us、再判断高电平宽度”的方式来读。优点是逻辑清晰、代码量小、适合学习缺点是一旦进入读传感器流程CPU就干不了别的如果系统里同时有定时器中断或串口中断在跑时序很容易被扰到。我在一次环境监测项目里主循环里同时跑了气压传感器和OLED刷新结果DHT11每次读完数据都校验失败最后定位到是OLED刷新函数里的延时把时序撑爆了。第二种是定时器轮询方案通过定时器中断来精确计时读数据的时候仍然占住CPU但它不依赖软的延时循环时序更稳定。这种方案比纯阻塞可靠一些但代码复杂度上去了而且中断里做耗时操作本身也是大忌如果定时器中断优先级设置不好很容易影响其他功能模块。第三种是外部中断捕获方案利用STM32的外部中断来捕捉Data线上的跳变沿再通过定时器测量高、低电平的持续时间完全异步处理。这种方案最专业CPU利用率最低但实现复杂度也最高还得处理DHT11的起始信号时序——因为起始信号阶段主机需要主动拉低至少18ms这个过程中数据线是主机控制的要处理好两种角色的切换。考虑到项目目标是“稳定可靠跑通”而且大部分应用场景下温湿度采集频率本身不高我是每2秒读一次我最终选择了“阻塞式驱动 临界区保护 高精度延时”的折中方案。理由有三个第一代码简洁方便维护和移植第二通过屏蔽中断可以消除绝大多数干扰第三DHT11的读取总耗时大概在5ms左右对于非实时性要求苛刻的场景这个代价完全能接受。2. DHT11通信协议核心细节与参数计算2.1 单总线协议与数据格式DHT11的数据线是开漏输出结构的单总线外部必须接一个上拉电阻一般是4.7kΩ到10kΩ之间我默认用的4.7kΩ。空闲状态下总线保持高电平通信时由主机先发起起始信号。数据格式固定是40位8位湿度整数、8位湿度小数、8位温度整数、8位温度小数、8位校验和。DHT11这颗传感器自身精度只到整数位所以小数字节实际读出是0新型号的DHT11有的能读出小数位但值不可信验证校验和后直接用整数位即可。校验和为前四个字节之和的低8位比如湿度整数0x34、湿度小数0x00、温度整数0x01、温度小数0x00那么校验和就是0x35。这里有一个非常容易踩的坑很多人在解析数据时直接按“高字节在前”的顺序拼int但DHT11的40位数据是MSB先出的也就是说先收到的第1位是湿度整数位的最高位。如果代码里逐位读取的顺序反了读出来的数据会非常离谱比如湿度显示成0x2C而不是0x34。我在调初期版本时就栽在这里一度以为是传感器坏了后来用逻辑分析仪抓了波形才发现是位序处理错了。2.2 时序约束条件拆解DHT11的时序要求我用表格整理了一下方便对照理解阶段操作持续时间容差范围起始信号主机拉低总线≥18ms18~30ms为宜起始信号结束主机释放总线拉高20~40us不能超范围传感器响应DHT11拉低80us70~90us响应结束DHT11拉高80us70~90us数据位“0”低电平50us48~55us数据位“0”高电平宽度高电平26~28us24~30us数据位“1”高电平宽度高电平70us68~75us关键技术点在于每个数据位都是从50us的低电平开始的主机判断当前读到的位是“0”还是“1”完全取决于随后高电平持续的时间。高电平在26us左右的是“0”在70us左右的是“1”。所以写驱动时只需在检测到低电平结束、进入上升沿后启动计时再在下一个下降沿到来前判断时间。延时参数的精度直接决定了读取的可靠性。STM32常见的HAL库自带的HAL_Delay只能做到1ms级别完全无法用于微秒级延时。这里我强烈建议直接用DWTData Watchpoint and Trace模块做微秒延时利用内核自带的Cycle Counter计数不占用额外定时器精度可达CPU时钟周期级。72MHz主频下一个时钟周期约13.9ns延时1us就是72个周期。2.3 上拉电阻与接线要点很多朋友用DHT11模块板子上已经自带贴片电阻和滤波电容直接用杜邦线连接即可。但如果你是自己画的PCB一定要留意下面几个细节首先是上拉电阻的取值。在3.3V供电下4.7kΩ是比较稳妥的选择如果模块供电距离STM32较远线长超过20cm可以考虑减小到2.2kΩ以提升信号边沿的陡峭程度。其次是去耦电容在DHT11的VCC和GND之间加一个100nF的陶瓷电容是标配能有效避免电机、继电器等干扰源导致的读数跳动。其次是供电电压问题。DHT11的官方规格书标注工作电压是3.3V到5.5V但在5V供电下数据线输出的高电平也是5V如果STM32的GPIO不是5V容忍引脚会直接烧坏IO口。现在主流单片机上的PA、PB口基本都支持5V容忍但最好还是查一下对应数据手册确认“FT”标识。我自己的习惯是用3.3V供电少了电平匹配的顾虑。3. 代码实现与关键环节详解3.1 引脚配置与初始化我用的主控是STM32F103C8T6数据线接在PA0上。初始化代码很简单先把引脚配成推挽输出同时复用开漏模式这样可以在输出高电平时让外部上拉电阻生效。下面直接贴出我用的GPIO初始化代码void DHT11_GPIO_Init(void) { GPIO_InitTypeDef gpio_init; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio_init.Pin GPIO_PIN_0; gpio_init.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 gpio_init.Pull GPIO_NOPULL; gpio_init.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio_init); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET); }这里我特意把引脚配置成开漏输出模式配合外部上拉电阻能够在切换输入输出时省去重新配置模式的麻烦。如果打算用推挽输出那在读取数据前必须把GPIO模式切回输入模式否则你拉低总线后无法释放总线DHT11也就无法把总线拉下来。开漏输出天生就能直接读IO状态非常适合模拟单总线。3.2 高精度微秒延时函数实现接下来是骨架中的重中之重微秒延时。HAL_Delay精度不够这里用DWT实现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 start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000U); while ((DWT-CYCCNT - start) ticks); }要注意一个细节SystemCoreClock在F103默认是72MHz但如果你开启了PLL锁相环或者改了时钟树配置这个值必须同步更新否则延时时间就不对了。我在F401和F103两套板子上都跑过这个延时函数只要系统时钟配置正确实测误差非常小完全满足DHT11的时序要求。3.3 起始信号与响应读取主机发出起始信号的过程分三步拉低总线至少18ms释放总线并延时20~40us然后检测DHT11是否拉低总线响应。如果DHT11没有正确响应通常就是电源电压不稳、上拉电阻缺失或者接线错误。我写了一个读取函数uint8_t DHT11_Start(void) { uint8_t retry 0; HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET); DWT_Delay_Us(20000); // 拉低20ms HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET); DWT_Delay_Us(30); // 拉高30us if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { if (retry 100) return 1; // 等待DHT11拉低释放 DWT_Delay_Us(1); } retry 0; while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_SET) { if (retry 100) return 1; // 等待DHT11拉高结束 DWT_Delay_Us(1); } } else { return 1; } return 0; }注意代码里我用了两个while循环来等待DHT11的80us低电平和80us高电平超时计数上限设置为100us避免传感器损坏时程序死循环。这里有一个小技巧在等待循环里每次调用DWT_Delay_Us(1)而不是直接用读取GPIO的死循环这样即使DHT11没响应代码也能在限定时间内退出不至于把整个系统卡死。3.4 数据位读取与位序拼接数据位的读取方法我采用的是“检测低电平结束然后计时高电平宽度”的思路。核心代码如下uint8_t DHT11_ReadBit(void) { uint8_t retry 0; while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { if (retry 100) return 0xFF; DWT_Delay_Us(1); } DWT_Delay_Us(40); if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_SET) { while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_SET) { if (retry 100) break; DWT_Delay_Us(1); } return 1; } else { return 0; } }这段逻辑的关键在DWT_Delay_Us(40)这一步我从上升沿开始延时40us然后采样GPIO电平。因为数据位“0”的高电平只有26us左右数据位“1”的高电平有70us左右所以40us这个采样点可以非常清晰地区分两者采样到高电平就是“1”采样到低电平就是“0”。这个延时值要比“等到下降沿再判断”的方式更简单也更稳定算是这段代码里最实用的一个经验。40位数据读取和校验的完整代码如下uint8_t DHT11_ReadData(uint8_t *humidity, uint8_t *temperature) { uint8_t buf[5] {0}; uint8_t i, j; for (i 0; i 5; i) { for (j 0; j 8; j) { uint8_t bit DHT11_ReadBit(); if (bit 0xFF) return 1; buf[i] (buf[i] 1) | bit; } } if ((uint8_t)(buf[0] buf[1] buf[2] buf[3]) ! buf[4]) return 1; *humidity buf[0]; *temperature buf[2]; return 0; }3.5 临界区保护与调用策略我在前面提到过阻塞式驱动最怕中断打断。所以在实际读取DHT11数据时建议把整个读流程包进临界区屏蔽掉所有可能抢占CPU的中断。HAL库或者是标准库都有对应的函数我常用的是__disable_irq(); err DHT11_ReadData(humidity, temperature); __enable_irq();这里需要提醒一点如果系统里跑着对实时性要求极高、中断频率很低的模块比如RF通信的时序收发屏蔽中断的时间会直接影响它们。DHT11一次完整读取耗时约5ms20ms起始信号80us响应40位数据其中真正需要中断屏蔽的部分其实只包括起始信号之后到数据读完之间约5ms左右。好在DHT11的采集频率一般都设置在1秒以上5ms的阻塞时间对绝大多数应用来说影响可以忽略。如果你要求更宽松可以只在数据位读取阶段屏蔽中断而起始信号的20ms拉低操作可以放到中断屏蔽之外。4. 调试过程、常见问题与排查经验4.1 实测时序波形验证代码写完只是起点真正验收要靠逻辑分析仪或示波器。我在调试时用逻辑分析仪抓了Data线上的波形采样率设置在2MHz以上能清晰分辨出26us和70us两种高电平宽度。第一次抓波形就发现了一个问题起始信号拉低时间实测只有15ms因为我在代码里用了HAL_Delay(20)但HAL_Delay在系统时钟不准确的情况下会有偏差。换成DWT延时之后波形立刻标准了。如果你手里只有示波器没有逻辑分析仪也可以直接在数据线读取流程里做几个GPIO翻转来辅助观察。比如在读每个数据位之前翻转一次PB5这样示波器上就能看到40个连续的方波脉冲每个方波的位置对照Data线的波形就能判断出哪个位的时序出了问题。这种“软件标记法”在调试很多STM32外设驱动时都非常好用。4.2 典型问题速查表调试过程中我遇到过不少典型问题整理成了一张速查表方便你对照排查现象可能原因解决方案读取函数一直返回超时错误接线错误或上拉电阻缺失确认DATA线接好外部4.7kΩ上拉校验和总是不通过位序拼接错误或时序被中断打断检查MSB位序屏蔽中断后重读温度读数正常湿度始终为0传感器型号是DHT11旧版无小数位直接使用整数位忽略小数字节连续读取多次才成功一次起始信号拉低时间不足18ms加大拉低时间到20ms以上读出的数据每隔几秒跳变一次电源纹波大或线缆过长加100nF去耦电容缩短信号线长度4.3 中断优先级设置对读取成功率的影响有一个细节我要单独拿出来说如果项目里同时用了串口中断和定时器中断而且它们在DHT11读取期间频繁触发那么即使你关闭了全局中断也可能还不够——因为__disable_irq()关的是所有可屏蔽中断但某些优先级极高的中断比如NMI和硬错误是关不掉的。不过这类中断很少出现所以更实际的做法是把DHT11读取放在主循环里不要放在任何中断回调函数中执行。另外一个常见做法是降频读取。DHT11数据手册建议每次读取间隔不小于1秒如果采集频率太密传感器内部还在恢复状态就会导致应答不正常。我在开发中一般固定为2秒一次既满足数据更新需求也给传感器留足余量。实际测试如果间隔小于500ms偶尔就会出现校验失败的情况这个现象和数据手册的描述是吻合的。4.4 在不同STM32型号间移植的注意事项DHT11驱动在F1系列跑通之后我又移植到了STM32G0和STM32L4系列上。不同系列的GPIO库函数命名有差异但底层逻辑完全一样。移植时主要改三点第一是GPIO时钟使能函数F1是__HAL_RCC_GPIOA_CLK_ENABLE()G0和L4的写法类似但需要确认对应总线第二是引脚号宏定义我习惯把所有引脚相关操作统一封装成DHT11_DATA_IN()和DHT11_DATA_OUT()宏这样移植时只改宏定义即可第三是SystemCoreClock的值如果你的芯片跑在80MHz或者64MHzDWT延时函数里的us * (SystemCoreClock / 1000000U)会自动适配不需要额外改动。如果要移植到ESP32或者Arduino思路是完全一致的只是把GPIO操作函数换一下而已。这也是我把驱动封装成独立c文件和h文件而不依赖具体芯片型号的原因方便日后复用。5. 最后分享一个实用经验在整个DHT11驱动开发过程中我最深的体会是代码只占了三成工作量剩下的七成都在调试和排查。尤其是当你刚开始接触这类单总线传感器时千万不要代码能读一次数据就觉得完成了一定要多验证几遍——比如用热风枪改变传感器温度看看读数是否跟随或者用手指捏住传感器观察湿度是否上升。能稳定响应环境变化驱动才算真正合格。另外一个小技巧如果条件允许给DHT11驱动写一个单元测试用例定期自动读100次数据统计校验失败次数。我自己在开发过程中就是这样做的把失败率从最初的30%降到了0.5%以内。这个数字会非常直观地告诉你代码的可靠性远比一次两次读取成功的“感觉”靠谱得多。希望这篇文章能帮你少走一些弯路一次性把DHT11驱动写得又稳又干净。本文还有配套的精品资源点击获取