
STM32玩到进阶阶段很多人会开始捣鼓温度湿度这类环境传感器DHT11就是绕不开的第一站。它便宜、接线简单、不依赖I2C或SPI这类标准总线靠一根DATA线就能把温度和湿度都送出来。但也正因为这套单总线协议完全靠时序说话我才见过太多人——包括我自己早期——读出来的温度一会儿正常一会儿乱跳或者干脆全是0。这篇文章就把DHT11从协议原理、硬件接线到手写驱动的完整步骤再到实测阶段最常见的几种翻车现场和排查思路一次性讲清楚。适合已经熟悉STM32基础外设GPIO、串口、定时器、想真正弄懂DHT11而不是只会抄例程的朋友。1. DHT11在玩什么把戏单总线协议与40位数据帧1.1 一根线怎么把温度和湿度送出来DHT11内部其实有两块核心一块电容式湿度感应元件一块NTC热敏电阻测温元件外面再配一颗信号处理芯片把模拟量转成数字量然后通过单总线协议把数据串行输出。所谓单总线就是主机STM32和传感器共享一根双向数据线这根线既要能输出起始信号又要能读回温湿度数据。很多人第一次看时序图会懵其实它的通信机制很像对讲机平时总线空闲由上拉电阻维持高电平主机先喊一嗓子拉低一段时间传感器回应一声我在拉低再拉高各80微秒然后开始报数据报完40位数据后闭嘴总线重新回到高电平空闲状态。这里有个前提必须记住DHT11内部采样频率很低数据手册上写的是1Hz也就是每秒最多更新一次数据。你如果像读I2C设备那样毫秒级不停轮询传感器根本忙不过来表现就是偶尔不回握手、数据错乱甚至总线被它一直拉低。后面讲到坑的时候这个根源会反复出现。1.2 一次完整通信的时间线整个通信过程由主机主导严格分四步第一步主机发起起始信号。把DATA拉低持续至少18毫秒然后释放总线拉高。低电平时间如果不够长传感器可能根本不识别。第二步传感器回应握手。主机释放总线后约20到40微秒DHT11会主动把总线拉低80微秒再拉高80微秒意思是我在准备发数据。第三步连续发送40位数据。每一位都由50微秒的低电平开头后面跟着一段高电平。关键区别就在高电平的宽度高电平持续约26到28微秒表示逻辑0持续约70微秒表示逻辑1。换句话说读出每一位的窍门不是等低电平而是量高电平到底持续了多久。第四步校验和验证。传感器发完40位数据后主机要自己算一遍校验和确认这帧数据没被干扰。我把关键时序参数整理成一张表方便写代码时对照通信阶段电平与时长说明主机起始信号DATA拉低至少18ms然后释放工程上建议用20ms以上稳妥传感器响应低80us后高80us握手信号表示准备发送发送数据位0低50us 高26~28us高电平宽度短发送数据位1低50us 高70us高电平宽度长整帧结束总线恢复高电平等待下一次主机发起一次完整通信大概耗时4毫秒左右之后总线空闲。整个过程主机必须陪着传感器同步走完不能中途去干别的太久不然时序就断了。1.3 40位数据到底装了什么40位数据帧的排列是固定的湿度整数部分8位湿度小数部分8位温度整数部分8位温度小数部分8位校验和8位校验和的计算方式也很直白前四个字节相加取低8位应该等于第五个字节。比如读到湿度40%、温度25.0度那前四字节就是0x28、0x00、0x19、0x00相加得到0x41校验字节就应该是0x41。这里要提醒一个很多人踩过的认知坑正经的DHT11分辨率为1小数部分默认固定为0。如果你读出来的小数位有值要么是某些兼容芯片的额外赠送要么说明你的数据解析出了问题别急着当精度提升。它的量程也是有限制的温度0到50摄氏度、湿度20%到90%RH超出范围读到异常值先想想是不是环境本身超出了传感器能力而不是代码写错了。2. 硬件连接里的隐形雷区上拉电阻、供电与引脚选型2.1 你手上的是元件还是模块市面上的DHT11有两种常见形态这个区别直接决定你的接线方式。一种是裸传感器通常4个引脚有的板子标VCC、DATA、GND、NC拿到手就一个胶囊状的小东西上面没有外围电路。这种接法必须自己外接上拉电阻到数据脚否则根本读不到稳定数据。另一种是模块商家已经把上拉电阻、滤波电容甚至电平转换电路焊好了引出来一般是三针VCC、DATA、GND直接杜邦线插上去就能用。买模块省事但是也容易让人忽略掉上拉电阻的作用一旦换成裸传感器就直接傻眼。我的习惯是学习阶段用模块跑通逻辑真正做产品或者要长期稳定运行的项目用裸传感器自己设计外围这样心里有数。2.2 上拉电阻不是玄学是协议的物理基础DHT11的DATA引脚内部是开漏结构也就是说传感器自己只能把总线拉低想恢复高电平必须靠外部电路。这个恢复高电平的任务就是上拉电阻干的。没有上拉电阻的话总线在传感器释放之后会处于浮空状态电平不确定时序自然全乱。你看到的现象可能是读回来的字节随机跳变也可能是传感器回应的低电平之后主机读高电平时读到的是不能确定的杂散信号导致判0判1完全靠运气。上拉电阻取值一般选4.7k到10k之间。导线越长、分布电容越大就选小一点的电阻比如4.7k让上升沿更快短距离、追求省电可以用10k。STM32内部虽然也有上拉但阻值大概在30到50k偏大短距离勉强能跑稍微加长线缆或者靠近干扰源就很容易误码。所以我的原则是裸传感器一律外接4.7k电阻模块自带就不用管。还有个细节数据脚如果接了100nF左右的滤波电容能帮助滤掉高频噪声但电容太大会把信号上升沿拖慢反而破坏时序。滤波电容建议加在传感器VCC和GND之间不要加在DATA线上。2.3 供电与引脚选择的实操建议DHT11工作电压范围是3.3V到5.5V和STM32共用3.3V是最省事的方案。如果你给传感器单独供5V而MCU是3.3V系统就要小心DATA线上的高电平会接近5V可能超出STM32引脚的耐压范围。稳妥做法是大家都用3.3V省掉电平转换的麻烦。引脚选择上DATA脚优先选同时具备外部中断和定时器输入捕获复用功能的引脚比如PA0这种这样后面如果想从延时测量升级到定时器捕获测量不用重新改PCB。另外注意避开已经被占用或者作为调试接口的引脚免得后面调试时互相干扰。典型接线如下表DHT11引脚接STM32说明VCC3.3V共用电源必要时加100nF退耦电容DATAPA0举例开漏模式 外部4.7k上拉到VCCGNDGND共地必须接好NC悬空不需要连接3. 驱动代码逐行拆解微秒延时、起始信号和40位读取3.1 先解决微秒延时DWT循环计数器的用法写DHT11驱动之前必须先有一个可靠的微秒级延时。很多人直接用HAL_Delay但它是毫秒级的量不了几十微秒的高电平。也有人用for循环空转来做微秒延时这在一开始可能碰巧能跑但编译器一旦开启优化空循环可能被优化掉或者时间完全变样时序就毁了。我推荐用Cortex-M内核自带的DWT循环计数器来做微秒延时。它每个CPU时钟周期加1不依赖外部定时器只管数时钟周期确定性很好。代码如下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变量的准确性。使用HAL库时必须在SystemClock_Config()正确执行之后再调用DWT_Delay_Init()否则如果系统时钟配置变了而SystemCoreClock没更新延迟时间就会整体偏大或偏小DHT11时序就会出问题。这也是很多人在不同开发板之间移植代码时明明代码一模一样却时好时坏的原因之一。3.2 GPIO初始化开漏模式省去来回切换的麻烦DHT11的DATA脚主机自己也要驱动最常见的做法是发起始信号时配置成推挽输出读数据时切换成输入模式。这种来回切换本身没什么问题但每次切换都要重新配置GPIO结构体代码啰嗦而且切换瞬间的电平状态不好控制。我习惯用开漏输出模式。开漏输出的特点是把引脚拉低就是输出0写高电平其实是关断输出晶体管让引脚靠外部上拉电阻回到高电平。这样一来同一个引脚既能当作输出拉低总线又能直接读引脚电平当作输入用不需要模式切换。配合外部4.7k上拉电阻这是最顺手的方案。#define DHT11_PORT GPIOA #define DHT11_PIN GPIO_PIN_0 void DHT11_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin DHT11_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull GPIO_NOPULL; // 外部已有4.7k上拉 GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, GPIO_InitStruct); // 初始状态释放总线让上拉电阻把电平拉高 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); }这里Pull选GPIO_NOPULL是因为外部已经有4.7k上拉内部上拉反而会跟外部电阻并联影响阻值。如果你用的是模块且板上没有上拉电阻也可以把内部上拉打开应急但不建议长期依赖。3.3 起始信号与握手检测先写发送起始信号的部分static void DHT11_Start(void) { // 拉低总线持续至少18ms工程上取20ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); DWT_Delay_us(20000); // 释放总线恢复高电平 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); // 等传感器回应主机释放后20~40us传感器才会动作 DWT_Delay_us(30); }接下来是检测传感器的握手响应。这个过程里最怕的就是传感器没接好或者已经损坏然后程序死等在一个while循环里。所以所有等待电平变化的循环都必须加超时保护static uint8_t DHT11_Wait_Level(GPIO_PinState level, uint32_t timeout_us) { uint32_t cnt 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) level) { if (cnt timeout_us) return 1; // 超时返回错误 DWT_Delay_us(1); } return 0; } static uint8_t DHT11_Check_Response(void) { // 等待响应低电平结束约80us if (DHT11_Wait_Level(GPIO_PIN_RESET, 100)) return 1; // 等待响应高电平结束约80us if (DHT11_Wait_Level(GPIO_PIN_SET, 100)) return 1; return 0; }超时时间我给了100微秒的量级足够覆盖80微秒的响应电平又不会让程序卡死太久。这个超时保护看起来不起眼但它是让整个驱动不死锁的关键强烈建议保留。3.4 逐位读取与校验读一位的核心逻辑是先等低电平结束然后数高电平持续了多少个微秒超过40就算1否则算0。代码如下static uint8_t DHT11_Read_Bit(void) { // 每个bit开头都是50us低电平先等它结束 if (DHT11_Wait_Level(GPIO_PIN_RESET, 100)) return 0xFF; // 数高电平持续时间步进1us uint32_t high_cnt 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET) { if (high_cnt 100) return 0xFF; // 异常超过100us DWT_Delay_us(1); } // 高电平约26~28us为0约70us为1阈值取40 return (high_cnt 40) ? 1 : 0; }然后把这40位组装成5个字节并校验uint8_t DHT11_Read(float *temperature, float *humidity) { uint8_t buf[5] {0}; uint8_t sum; // 先强制释放总线防止上一次读取失败后总线被传感器占着 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); DWT_Delay_us(1); DHT11_Start(); if (DHT11_Check_Response()) return 1; // 握手失败 for (int i 0; i 40; i) { uint8_t bit DHT11_Read_Bit(); if (bit 0xFF) return 2; // 读位超时 buf[i / 8] (buf[i / 8] 1) | bit; } sum buf[0] buf[1] buf[2] buf[3]; if (sum ! buf[4]) return 3; // 校验和错误 *humidity buf[0] buf[1] / 10.0f; *temperature buf[2] buf[3] / 10.0f; return 0; }有一点值得解释我在函数开头先把总线拉高一下。因为DHT11如果上一轮读到一半被打断或者传感器异常它可能把总线一直占在低电平。下次读取前主动释放一次总线相当于给总线一个复位的机会能减少连续失败的情况。这个细节是我在实际调板子时用逻辑分析仪抓出来的代码虽小价值不小。3.5 主循环怎么调用驱动写好之后主循环的调用也有讲究int main(void) { HAL_Init(); SystemClock_Config(); DWT_Delay_Init(); DHT11_GPIO_Init(); float temp 0, humi 0; while (1) { uint8_t ret DHT11_Read(temp, humi); if (ret 0) { printf(temp: %.1f, humi: %.1f\r\n, temp, humi); } HAL_Delay(2000); // 记住DHT11采样周期1秒读取间隔别低于2秒 } }注意这里读取间隔我用了至少2秒。前面说过DHT11内部采样只有1Hz如果1秒内连续读两次第二次很可能是传感器还没准备好新数据表现为校验失败或不回应。第一次读取失败后想立刻重试也要忍住等1到2秒再读成功率会高很多。4. 实测阶段一定会遇到的坑数据全零、温度乱跳、程序卡死4.1 现象一永远读到0这是最常见的翻车现场。代码照抄了串口打印出来温度和湿度永远是0或者一直是校验失败。这类问题九成出在硬件和GPIO配置上我一般按这个顺序排查第一检查接线和供电。DATA有没有接错引脚VCC是不是真的给了3.3VGND共地了吗这些基础问题最容易让人忽略尤其是杜邦线接触不良万用表一量又好像是通的但实际电流通路不可靠。第二检查上拉电阻。裸传感器没接上拉电阻或者模块的上拉电阻焊掉了总线就会浮空。用一个简单办法验证上电后用万用表量DATA脚应该稳定在3.3V附近如果电压很低或者乱跳上拉肯定有问题。第三检查GPIO配置。如果用推挽输出模式驱动读取之前忘了切回输入模式那ReadPin读到的其实是自己输出寄存器的状态永远都是同一个电平数据当然读不出来。这就是我推荐开漏模式的原因之一从配置上杜绝这类问题。第四怀疑传感器本身。DHT11虽然便宜但也可能出厂就是坏的。换一颗传感器测试通常能快速定位。4.2 现象二温度值乱跳、偶尔爆表如果你的数据能出来但数值时不时乱跳到离谱程度大概率是时序精度不够。最常见的原因是微秒延时用的for循环。编译器优化级别一开空循环要么被缩短要么被优化掉本来70微秒的高电平可能被数成20微秒本来26微秒的数成70微秒结果就是0和1颠倒校验和倒是能凑巧通过一部分数据自然乱跳。解决办法就是换成DWT延时同时确认主频和SystemCoreClock匹配。第二个原因是读取频率太高。前面反复提到1Hz采样限制如果你把HAL_Delay(2000)改成HAL_Delay(100)短时间内连续读取很容易读到传感器处于忙状态的半成品数据表现为数值跳动。先恢复到2秒一次试试。第三个原因是干扰和时序被拖慢。数据线太长、上拉电阻太大或者DATA线旁边走过强干扰源比如继电器、电机驱动都会让电平跳变沿变缓微秒级的判断就会出错。处理办法是缩短线缆、改用4.7k电阻、把信号线跟动力线分开走或者在传感器电源脚加100nF退耦电容。我还遇到过一种情况模块电路板上自带上拉电阻但个别商家用的电阻值偏大导致上升沿变慢读出来的数据时好时坏。这种只能靠实测抓波形定位后面会讲。4.3 现象三程序卡死在读取函数里如果程序跑到DHT11_Read之后就再也没出来多半是某个while等待循环没有超时保护。传感器没响应或者把总线拉低不放程序就永远等在那里。前面代码里的DHT11_Wait_Level就是针对这个问题的。自己写代码时任何涉及等待引脚电平变化的循环都必须加超时上限这是嵌入式驱动开发的基本素养。超时时间可以宽松但不能没有。另外还有个小坑如果程序里开了全局中断而在中断服务函数里调用了DHT11_Read这个函数本身耗时约4毫秒加上等待响应可能更长会严重影响中断实时性。DHT11这种慢速传感器只适合在主循环或者低优先级任务里读取不要在中断里做。4.4 排查手段逻辑分析仪和串口原始数据排查时序问题最直接的工具是逻辑分析仪。把探头夹在DATA脚抓一次通信波形你能直观看到握手信号的80微秒低电平和后续40个位的高电平宽度。一个位的高电平究竟是26微秒还是70微秒一目了然。没有逻辑分析仪的话用示波器也可以。如果手头只有串口也有一个笨但有效的办法修改驱动把每次读到的原始40位5个字节直接通过串口打印出来。如果数据基本稳定但偶尔翻车打印原始字节对比校验和你能看出是哪一位出错是硬件的边沿问题还是延时问题。这种把中间结果暴露出来的调试思路比盯着最终温湿度数值猜原因要高效得多。关于坑我做了一张速查表方便以后直接对照故障现象最常见原因处理建议全读0或校验失败接线错误、无上拉电阻、GPIO模式不对按顺序检查硬件和GPIO配置数值乱跳爆表for循环微秒延时不可靠、读取太频繁换DWT延时拉长读取间隔程序卡死等待循环没超时保护所有while加超时计数数据偶尔错位线缆过长、干扰、上拉阻值偏大缩短线缆、换4.7k、电源加退耦5. 进阶优化从能读到数据到稳定可靠地读数据5.1 用定时器输入捕获替代循环测量前面那种用循环数微秒的方式虽然能用但有两个天生短板一是CPU全程参与一次4毫秒的读取过程中干不了别的二是测量精度取决于GPIO读操作和延时的综合开销误差相对大。进阶做法是用定时器输入捕获来测量每个位的高电平脉宽。思路是把DATA脚接到定时器的输入捕获通道上配置成上升沿启动计时、下降沿停止计时然后根据两次捕获的差值算出高电平持续时间。因为定时器是硬件计数不占用CPU精度能精确到定时器时钟周期。以72MHz主频为例1微秒就是72个计数26微秒的高电平可以数出1872个左右的时钟周期和70微秒的5040个形成明显区分判0判1几乎不会错。实现方式简单说三步把引脚复用映射到定时器输入通道比如PA0对应TIM2_CH1配置输入捕获为双沿捕获上升沿记录当前计数值下降沿再记录一次两次差值换算成时间后按阈值判断0或1。中断里把脉宽结果写进缓冲区主循环轮询消费。这个方案的代价是配置略复杂但它把DHT11读取变成了硬件定时器在背后干活、CPU只负责取结果的模式对后续要跑多任务、要低功耗的场景帮助很大。5.2 失败重试、数据滤波与防御性设计不管用什么方式读取驱动层面我都建议加上几条防御策略。第一失败自动重试。单次读取失败不要慌等1秒后再试一次。因为DHT11采样的固有周期在那里连读只会连续失败间隔1秒重试成功率很高。我一般在应用层做连续失败3次才报错的逻辑避免偶发干扰导致一次误报。第二数据滤波。如果对环境抖动敏感可以连续读5次去掉最大值和最小值剩下的取平均。对于室内环境监测这种应用这个简单的滑动平均就能让温湿度曲线好看很多。注意滤波不能解决协议层的错误必须在校验通过之后再滤波。第三防御性恢复。前面代码里每次读取前先拉高总线释放一次这个习惯保留。一旦发现传感器异常占住总线这也是最简单的恢复手段。如果恢复不了多次再判定传感器故障。5.3 多传感器、低功耗和传感器选型DHT11是一颗低速、低精度传感器官方标称温度精度正负2摄氏度、湿度精度正负5%RH量程也比较窄。它在室内环境大致监测这个定位上是够用的但如果项目需要精确温湿度控制直接换更高精度的数字传感器更省心比如DHT22或者I2C接口的温湿度传感器后者读取逻辑更简单精度也更高。多路DHT11扩展时每个传感器独立占一个GPIO逐路读取即可。但要注意所有传感器共用一个电源时瞬时电流会叠加建议电源走线稍微加粗每个传感器旁都放一个100nF退耦电容。低功耗场景下DHT11本身功耗不高但它的1Hz采样周期意味着你不能随时唤醒MCU去读最好让MCU进入低功耗模式每隔10秒到30秒醒来读一次读完成马上再休眠。这时候用定时器输入捕获方案的优势就体现出来了读取过程中CPU可以把更多时间维持在低功耗状态而不是一直空转数微秒。最后再说一点个人体会DHT11这种东西代码难度其实不在读出来而在读得稳。我后来不管换多少块开发板、多少种传感器写这类单总线驱动都坚持三件事微秒延时用硬件计数、所有等待循环带超时、读取之后校验和必须验证。养成这三个习惯之后DHT11基本不会再给你惹麻烦。如果你也正在被这颗小传感器折磨先把这三条检查完剩下的就是数据好不好看的问题了。