资讯动态

STM32F1单总线驱动DHT11:时序、GPIO与微秒延时实战

发布时间:2026/10/14 2:13:48 来源:尧图企业网站定制
1. 从一个“读不出数”的温湿度采集说起DHT11这颗传感器玩STM32的人几乎人手一个。便宜、单总线、两根线就能跑看起来是入门级外设里最没门槛的一个。但我见过太多人卡在这里代码烧进去串口打印出来的湿度永远是0温度永远是0或者第一次读对了第二次就变成255。问题不在DHT11本身而在于它的时序要求比大多数人想象的要苛刻得多。这篇笔记围绕STM32F1平台驱动DHT11展开把单总线协议的时序本质、GPIO模式切换的坑、微秒级延时的实现方式、数据校验的逻辑以及实际调试中遇到的各种“玄学”问题全部拆开讲一遍。适合刚接触STM32外设驱动的新手也适合那些代码能跑但说不清为什么能跑的人。读完你应该能做到不依赖任何现成库自己从零写出一份稳定的DHT11驱动并且知道每一行代码在干什么。DHT11的核心难点从来不是“怎么读”而是“怎么在正确的时间点读”。它没有时钟线全靠主机拉低总线来发起通信然后传感器用自己的内部时钟依次输出40个bit。这40个bit里每一个bit的“0”和“1”区别只在于高电平持续的时间长短——26微秒左右是070微秒左右是1。STM32F1跑在72MHz下一个机器周期大约13.9纳秒也就是说你需要在这个尺度上去分辨两种脉宽。这不是随便写个delay就能搞定的事情。2. DHT11单总线协议的本质拆解2.1 为什么DHT11不用I2C或SPI先想一个问题DHT11为什么设计成单总线而不是标准的I2C或SPI答案其实很直接——成本和引脚数。DHT11面向的是低成本场景比如农业大棚、仓库监测、家用温湿度计。这类场景对采样率要求极低DHT11本身采样周期不低于1秒但对成本和布线复杂度极其敏感。单总线只需要一根信号线加一根电源线PCB走线简单接插件便宜对于大批量生产的产品来说每省一个引脚都是钱。但代价也很明显单总线没有独立的时钟线主机和从机之间的同步完全依赖双方对时序的默契。DHT11的协议规定主机拉低总线至少18毫秒作为起始信号然后释放总线传感器检测到这个起始信号后等待20到40微秒再拉低总线80微秒作为响应接着拉高80微秒之后才开始逐位输出数据。整个过程没有任何握手确认机制你只能靠时序去“猜”传感器在干什么。这就引出了第一个关键认知DHT11的驱动代码本质上是一段精确的时序模拟程序而不是传统意义上的“读写寄存器”。你写的每一行代码最终都对应着总线上的一个电平变化。2.2 40个bit的数据帧结构DHT11一次完整传输返回40个bit分成5个字节字节序号含义说明第1字节湿度整数部分范围0-100单位%RH第2字节湿度小数部分DHT11固定为0保留给更高精度型号第3字节温度整数部分范围0-50单位摄氏度第4字节温度小数部分DHT11固定为0第5字节校验和前四字节相加取低8位校验逻辑很简单把前四个字节相加取结果的低8位和第五字节比较。相等就认为数据有效不等就丢弃。这个校验机制只能发现单字节错误对于多字节同时出错的情况无能为力但在DHT11这种低速、短距离的场景下已经够用了。注意DHT11的温度小数部分虽然固定为0但不要因此在代码里直接忽略它。有些兼容型号比如DHT12会用到这个字节保持代码的通用性没坏处。2.3 每一位数据的“0”和“1”怎么区分这是整个驱动里最核心的部分。DHT11的每一位数据都以一个50微秒的低电平开始然后是高电平。高电平持续26到28微秒表示这一位是“0”持续70微秒表示这一位是“1”。所以区分0和1的关键就是在低电平结束之后等待大约40微秒然后去读总线电平如果这时候还是高电平说明高电平还没结束这一位是1如果已经是低电平了说明高电平已经过去了这一位是0。这个“等待40微秒再采样”的策略本质上是在高电平脉冲的中间偏后位置进行采样。26微秒的脉冲在40微秒时早就结束了而70微秒的脉冲在40微秒时还处于高电平状态。这个时间窗口的选择很巧妙既避开了信号边沿的抖动又留出了足够的容错空间。但这里有个隐藏的坑如果你用HAL_Delay或者普通的循环延时40微秒的精度可能根本达不到。STM32F1的SysTick默认1毫秒中断一次HAL_Delay的最小分辨率就是1毫秒完全没法用。你需要自己实现微秒级延时或者用定时器来做输入捕获。3. STM32F1上的GPIO模式切换与微秒延时实现3.1 GPIO为什么要频繁切换模式DHT11的数据线是双向的。主机发起起始信号时需要把引脚配置成推挽输出主动拉低总线释放总线后需要把引脚切换成浮空输入或上拉输入让传感器来控制总线电平。这个切换过程在每一次通信中至少发生两次一次是主机拉低之前切输出一次是释放总线之后切输入。很多人的代码在这里出问题是因为切换模式之后没有给硬件足够的稳定时间。STM32的GPIO模式切换是通过写CRL/CRH寄存器实现的写入之后下一个时钟周期就生效但外部的上拉电阻和线路电容需要时间把电平拉高。如果你切换成输入模式后立刻去读电平可能读到的还是低电平导致误判。我的做法是在切换成输入模式之后插入一个短暂的延时大约1到2微秒让总线电平稳定下来再开始检测。这个延时不需要很精确但必须有。3.2 微秒级延时的三种实现方式在STM32F1上实现微秒级延时常见的有三种方案方案一SysTick重装载法。把SysTick的重装载值设成一个很小的数比如72这样每72个时钟周期产生一次计数溢出对应1微秒。通过读取当前计数值来判断延时是否结束。这种方法的优点是精度高不占用额外定时器缺点是需要修改SysTick的配置可能影响HAL库的时基。方案二DWT周期计数器。Cortex-M3内核自带DWTData Watchpoint and Trace单元其中的CYCCNT寄存器会记录CPU运行的时钟周期数。你可以读取这个寄存器来实现纳秒级延时。这种方法精度最高代码也最简洁但需要先使能DWT。方案三普通定时器。用一个空闲的定时器比如TIM4配置成1微秒计数一次通过轮询计数器来实现延时。这种方法最直观但占用一个硬件定时器资源。我个人的选择是DWT方案因为代码量最少精度也足够。初始化只需要几行// 使能DWT CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; // 微秒延时函数 void DWT_Delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }这段代码在72MHz下ticks us * 72。由于CYCCNT是32位计数器最大能计到约59秒对于微秒级延时完全够用。提示DWT方案在调试器连接时工作正常但有些情况下调试器断开后DWT可能不工作。如果发现延时函数卡死检查一下DWT-CTRL寄存器的使能位。3.3 起始信号的时序实现起始信号的流程是主机拉低总线至少18毫秒然后拉高20到40微秒然后切换成输入模式等待传感器响应。18毫秒的延时可以用HAL_Delay(18)来实现因为毫秒级延时对精度要求不高。但拉高之后的20到40微秒就需要微秒级延时了。我一般用30微秒作为释放时间这个值在DHT11的容忍范围内。传感器检测到起始信号后会在20到40微秒内拉低总线80微秒作为响应。主机在切换成输入模式后需要等待这个响应信号。如果超过100微秒还没检测到低电平说明传感器没有响应应该直接返回错误。// 主机发起起始信号 DHT11_Set_Output(); DHT11_LOW(); HAL_Delay(18); DHT11_HIGH(); DWT_Delay_us(30); DHT11_Set_Input(); // 等待传感器响应 if (DHT11_Wait_Level(0, 100) ! OK) return ERROR; if (DHT11_Wait_Level(1, 100) ! OK) return ERROR;这里的Wait_Level函数负责在指定超时时间内等待总线达到目标电平是整個驱动里最容易被忽视但最重要的辅助函数。4. 完整驱动代码的逐段实现与注释4.1 宏定义与引脚配置先定义好引脚操作宏这样换引脚的时候只需要改这几行#define DHT11_PORT GPIOA #define DHT11_PIN GPIO_Pin_0 #define DHT11_RCC RCC_APB2Periph_GPIOA #define DHT11_HIGH() GPIO_SetBits(DHT11_PORT, DHT11_PIN) #define DHT11_LOW() GPIO_ResetBits(DHT11_PORT, DHT11_PIN) #define DHT11_READ() GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN)模式切换函数void DHT11_Set_Output(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, GPIO_InitStructure); } void DHT11_Set_Input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, GPIO_InitStructure); }这里用上拉输入而不是浮空输入是因为DHT11的总线在空闲时应该保持高电平。虽然DHT11模块上通常自带4.7k到10k的上拉电阻但STM32内部的上拉电阻可以作为备份增强抗干扰能力。4.2 等待电平变化的超时机制这个函数是整个驱动的安全网。没有它一旦传感器没接好或者坏了程序就会死在while循环里uint8_t DHT11_Wait_Level(uint8_t level, uint16_t timeout_us) { while (DHT11_READ() ! level) { if (timeout_us-- 0) return 1; // 超时 DWT_Delay_us(1); } return 0; // 成功 }每次循环延时1微秒超时时间用微秒为单位。对于等待80微秒的响应信号给100微秒的超时足够了。对于等待每一位数据的50微秒低电平给60微秒超时。注意这个函数的超时参数不要设得太大否则传感器故障时程序会卡很久。但也不能太小否则正常通信时可能误判超时。我的经验值是理论时间的1.5到2倍。4.3 读取一个字节的完整流程读取一个字节就是循环8次每次读一位uint8_t DHT11_Read_Byte(void) { uint8_t byte 0; uint8_t i; for (i 0; i 8; i) { // 等待50us低电平结束 if (DHT11_Wait_Level(1, 60) ! 0) return 0; // 低电平结束后延时40us再采样 DWT_Delay_us(40); byte 1; if (DHT11_READ() 1) { byte | 1; // 如果是1还需要等待剩余的高电平结束 if (DHT11_Wait_Level(0, 80) ! 0) return 0; } } return byte; }这里有个细节当读到“1”的时候高电平还有大约30微秒才结束需要等它变成低电平才能开始下一位的检测。如果读到“0”高电平已经结束了总线已经是低电平不需要额外等待。4.4 完整的读取流程与校验uint8_t DHT11_Read_Data(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; uint8_t i; // 发起起始信号 DHT11_Set_Output(); DHT11_LOW(); HAL_Delay(18); DHT11_HIGH(); DWT_Delay_us(30); DHT11_Set_Input(); // 等待响应 if (DHT11_Wait_Level(0, 100) ! 0) return 1; if (DHT11_Wait_Level(1, 100) ! 0) return 1; // 读取40个bit for (i 0; i 5; i) { buf[i] DHT11_Read_Byte(); } // 校验 if (buf[0] buf[1] buf[2] buf[3] ! buf[4]) return 2; *humi buf[0]; *temp buf[2]; return 0; }返回值设计成0表示成功1表示通信失败2表示校验失败。这样调用者可以根据返回值判断问题出在硬件连接还是数据质量。5. 调试中遇到的典型问题与排查思路5.1 读数一直是0或者255这是最常见的问题。原因通常有三个第一起始信号的低电平时间不够DHT11没检测到第二GPIO模式切换后没有给总线稳定时间第三微秒延时不准导致采样点偏移。排查方法用示波器或者逻辑分析仪抓一下数据线的波形。如果没有仪器可以用一个简单的办法——在起始信号之后把总线状态通过串口打印出来。正常情况下你应该能看到主机拉低18毫秒拉高30微秒然后传感器拉低80微秒拉高80微秒接着是40个bit的数据。如果传感器完全没有响应检查硬件连接VCC是否接3.3V或5VDHT11支持3.3V到5.5VGND是否共地数据线是否有上拉电阻。DHT11模块通常自带上拉电阻但如果是裸传感器必须外接一个4.7k到10k的上拉电阻。5.2 第一次读成功第二次失败这个问题通常和DHT11的采样周期有关。DHT11的 datasheet 明确写了采样周期不低于1秒。如果你连续快速读取第二次读取时传感器还在处理上一次的数据不会响应新的起始信号。解决办法很简单在两次读取之间加至少1秒的延时。如果你需要更快的采样率DHT11不适合你的场景应该换DHT22或者SHT30。5.3 数据偶尔校验失败偶发的校验失败通常是时序抖动导致的。可能的原因包括中断打断了时序、微秒延时被其他高优先级任务影响、电源纹波导致传感器输出不稳定。我的处理策略是读取失败后重试最多重试3次。如果3次都失败才向上层报告错误。这样既能容忍偶发的干扰又不会无限重试导致程序卡死。uint8_t retry 3; while (retry--) { if (DHT11_Read_Data(temp, humi) 0) break; HAL_Delay(1000); } if (retry 0) { /* 报告错误 */ }提示在读取DHT11的过程中最好关闭全局中断。因为任何中断都可能导致时序偏差超过容忍范围。读取一次DHT11的总时间大约在5毫秒左右关闭中断这么久对大多数应用来说是可以接受的。5.4 常见问题速查表现象可能原因排查方向读数全0起始信号未发出检查GPIO输出模式配置读数全1总线未释放检查模式切换为输入校验失败时序抖动关闭中断检查延时精度无响应硬件连接问题检查电源、地线、上拉电阻偶发失败采样周期不足增加读取间隔至1秒以上温度正常湿度异常传感器损坏更换传感器测试6. 从DHT11驱动延伸出的通用单总线思维DHT11的驱动写完之后你会发现这套思路可以迁移到很多其他单总线设备上比如DS18B20温度传感器、WS2812灯带、红外遥控接收头。它们的共同特点是没有独立的时钟线靠精确的时序来区分数据位。写这类驱动的核心方法论是先画时序图再写代码。把每一个电平变化的时间要求标注清楚然后逐段实现。不要一上来就抄别人的代码因为别人的硬件平台、主频、编译器优化等级都可能和你不一致抄过来的延时参数不一定适用。另一个经验是超时机制比精确延时更重要。精确延时保证正常通信超时机制保证异常情况下程序不会卡死。我见过太多人的驱动代码在传感器拔掉之后直接死循环整个系统瘫痪。加上超时判断最多浪费几毫秒但系统还能继续跑。最后说一个实际项目中的做法如果DHT11只是用来做环境监测不需要很高的实时性我会把它放在一个低优先级的任务里每2秒读取一次读取失败就沿用上一次的有效值连续失败5次才报警。这样既避免了偶发干扰导致的误报又保证了数据的连续性。这套逻辑在温室监控、仓库环境记录这类场景里跑了很久稳定性完全够用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑