市面上关于STM32驱动DHT11的教程一抓一大把,但我发现大部分教程都停在“把代码粘上去能跑出数字”这个层面。一旦你换了个开发板、换了个编译优化等级、或者把杜邦线换成PCB走线,原本好好的代码立刻翻车——读回0xFF、校验失败、甚至直接卡死在延时函数里。这篇文章我想从DHT11的通信协议本身讲起,把STM32如何用GPIO模拟单总线时序这件事彻底拆开,再把我实际调试中踩过的坑和排查思路完整还原出来。适合刚开始学STM32、想做环境监测小项目、或者已经被DHT11时序折磨过的朋友。
1. DHT11的通信原理:一根线怎么同时传温湿度
1.1 40位数据帧与校验规则
DHT11是单总线器件,DATA引脚既要向下发命令又要向上传数据。很多新手拿到手就照着网上的代码抄,抄完了也不知道读回来的5个字节分别是什么。这里先把数据帧结构说清楚。
一次完整的数据传输包含40个bit,按顺序是:
- 8 bit湿度整数部分
- 8 bit湿度小数部分
- 8 bit温度整数部分
- 8 bit温度小数部分
- 8 bit校验和
校验和的规则是:前四个字节相加,取低8位,如果等于第五个字节,说明这帧数据有效。举个例子,湿度整数0x32、湿度小数0x00、温度整数0x01、温度小数0x00,那么校验和就是 (0x32 + 0x00 + 0x01 + 0x00) & 0xFF = 0x33。接收端算出来不等于0x33,这帧数据就该直接丢弃。
DHT11实际输出时小数部分基本为0,因为它的测量分辨率就是1%RH和1℃,但数据帧格式上仍然保留了小数位。温度在零下时,温度整数部分的最高位(bit7)会被置1,所以解析的时候要留意符号位。这一点在绝大多数教程里没人提,真到了冬天做室外项目时会踩坑。
1.2 起始信号与响应时序
DHT11的通信过程分两个阶段。
第一阶段是主机发送起始信号。主机把DATA线拉低,保持至少18ms,然后释放并拉高20~40us。这个低电平时间很关键,小于18ms传感器可能不认,长了也没关系但会拖慢采样节奏。之后主机把引脚配置为输入,释放总线,等待DHT11回应。
第二阶段是DHT11响应。传感器收到起始信号后会先拉低80us,表示“我收到了”,然后拉高80us,表示“准备发数据”。这160us左右的响应波形是判断传感器是否正常工作的最直观标志。如果你用逻辑分析仪抓不到这个波形,先别检查代码,先确认传感器供电和接线。
40位数据每一位的传输方式类似:每一位前都有一个50us的低电平准备期,然后是一个高电平脉冲,高电平持续26~28us表示逻辑0,持续70us左右表示逻辑1。所以读取每一位的关键操作,就是测量高电平的持续时间,而不是单纯读取引脚电平。
1.3 为什么说DHT11"简单但不省心"
DHT11的时序精度要求其实不算苛刻,单位是微秒级,对STM32主频72MHz来说绰绰有余。但它有四个与生俱来的麻烦:
- 单总线是半双工,主机必须精准切换输入输出方向
- 位与位之间的判断依赖时间测量,任何中断打扰都会误判
- 最小采样周期是1~2秒,读太频繁数据不更新
- 对电源纹波和上拉电阻敏感,长杜邦线场景容易误码
理解了这四点,你就能明白为什么同一个代码在这个板子上好使,换个板子就废。接下来我会从工程准备和代码实现两个层面,把DHT11驱动里最容易出问题的地方全部展开。
2. 工程准备:把时序的"尺子"先校准
2.1 标准库和HAL库怎么选
针对STM32驱动DHT11,网上两种库的代码都很多,很多新手纠结到底学哪个。我的建议很直接:如果项目是全新的,从HAL库入门;如果你用的是老开发板、老工程模板,比如正点原子或野火的早期标准库例程,直接标准库也不要折腾迁移。DHT11这种微秒级时序驱动,实际上和库的关系不大,核心是靠寄存器级或短路径的GPIO操作。
标准库的好处是函数调用层级浅,GPIO_SetBits、GPIO_ResetBits、GPIO_ReadInputDataBit这几条路的寄存器操作很直接,编译出来指令数少,时序容易满足。HAL库的HAL_GPIO_WritePin和HAL_GPIO_ReadPin其实也不算慢,只是多了断言检查和一些宏展开,实际测下来在72MHz下依然能正常完成时序,但不建议在读取每一位的循环里混入串口打印等高耗时操作。
我在下文会把标准库的完整驱动逐段拆解,HAL库版本的差异我会单独开一小节说明。两者的核心逻辑完全一致,区别只在GPIO初始化和读写函数的调用方式。
2.2 微秒级延时:三种实现方式的取舍
DHT11驱动里需要20us、40us、50us、70us级别的延时,而且这些延时的准确度直接决定了读到的bit对不对。所以“定时尺子”必须先校准。很多人用的空循环延时,在Debug模式下没问题,开了-O2优化后整个延时时间会剧烈变化,甚至被编译器优化掉。这是网上大量DHT11代码偶发失败的根本原因之一。
如果只是做学习验证,最简单可靠的微秒延时是用SysTick做轮询。SysTick是24位递减计数器,配置好后从重装载值开始向下数,每过一个时钟周期减1。读取当前值VAL和重装载值LOAD的差值就能算出已经过去的时间。
void delay_us(uint32_t nus) { uint32_t ticks = nus * (SystemCoreClock / 1000000); uint32_t start = SysTick->VAL; uint32_t elapsed = 0; while (elapsed < ticks) { uint32_t cur = SysTick->VAL; if (cur <= start) { elapsed += start - cur; } else { elapsed += (SysTick->LOAD - cur) + start; } start = cur; } }这段代码的思路是把SysTick当作一个高精度计时器,不做中断,纯粹轮询VAL的变化。需要注意SystemCoreClock必须正确设置成芯片实际运行频率,这也是很多人移植代码后第一个出错的地方:明明芯片跑72MHz,SystemCoreClock还是默认的8MHz,所有延时放大了9倍,读取时序当然全乱。
还有一种更稳的方案是直接用DWT寄存器做延时,不占用SysTick。但DWT在标准库工程里需要先使能CYCCNT,代码多几行,对新手反而增加理解成本。SysTick轮询已经足够胜任DHT11场景。
2.3 GPIO配置的细节:开漏还是推挽
DHT11的数据线是双向的。最常见的写法是配置成推挽输出,然后在读数据前切换成输入模式。这种方式代码直观,但有一个隐患:推挽输出时引脚强驱动,释放总线后如果切换不及时,或者线上有寄生电容,波形边缘会不干净。
我自己的习惯是用开漏输出加上拉电阻,配合切换输入模式。开漏输出时拉低全靠NMOS导通,释放后引脚由外部上拉电阻拉高,天然适合单总线这种“线与”结构。如果开发板上没有外部上拉,STM32内部上拉也能凑合工作,但长线时建议外部加4.7k到10k的上拉电阻到VCC。
GPIO速度这个参数也容易被忽略。72MHz主频下,GPIO速度建议配置成50MHz(标准库叫GPIO_Speed_50MHz),以保证输出脉冲的边沿够陡。HAL库对应的是GPIO_SPEED_FREQ_HIGH。边沿太缓时,DHT11对高电平宽度的判断会偏离理想值,导致误码。
3. 驱动代码逐段拆解:标准库完整实现
3.1 起始信号与总线释放
先把控制引脚宏定义好。假设DHT11的数据线接在PA1:
#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_Pin_1 #define DHT11_RCC RCC_APB2Periph_GPIOA #define DHT11_OUT_LOW() GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN) #define DHT11_OUT_HIGH() GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN) static void DHT11_Pin_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); DHT11_OUT_HIGH(); } static void DHT11_Pin_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); }发送起始信号的代码:
uint8_t DHT11_Start(void) { uint8_t retry = 0; DHT11_Pin_Mode_Output(); DHT11_OUT_LOW(); delay_ms(20); DHT11_OUT_HIGH(); delay_us(30); DHT11_Pin_Mode_Input(); while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 1) { if (++retry > 100) { return 1; // 总线没有被DHT11拉低,传感器可能不在线 } delay_us(1); } // 响应低电平80us while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 0) { if (++retry > 200) { return 1; } delay_us(1); } // 响应高电平80us while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 1) { if (++retry > 200) { return 1; } delay_us(1); } return 0; }这里有个细节:响应低电平的等待如果超时,超时时间要设宽裕一点。DHT11在恶劣供电下响应时间会有波动,严格卡80us容易误判。用retry计数的方式,低电平等待最坏可以到200us,高电平同样。只要传感器确实在线,通常几十次循环内就能通过。
3.2 逐位读取:如何区分0和1
读取每一位的核心思路:先跳过50us的低电平准备期,然后测量高电平的持续时间。实现如下:
uint8_t DHT11_ReadBit(void) { uint8_t retry = 0; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 0) { if (++retry > 100) { return 0xFF; } delay_us(1); } delay_us(40); if (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 1) { return 1; } else { return 0; } }逻辑解释:当引脚从低电平跳变为高电平,说明高电平脉冲开始了。先等40us,然后判断引脚电平。如果40us后仍然是高电平,说明这个高电平脉冲长度大于40us,符合逻辑1的特征(70us左右);如果40us后已经变低,说明这是逻辑0(26~28us的高电平)。
这里为什么不直接精确测量高电平宽度?因为测量宽度要等待引脚变低,等待过程中的轮询循环本身就引入了额外延迟,实现起来反而复杂。40us判断法在时序上足够鲁棒,DHT11的0和1高低电平差异非常明显,这也是实际工程中最常用的做法。
读一个字节就是把位拼接起来:
uint8_t DHT11_ReadByte(void) { uint8_t data = 0; for (int i = 0; i < 8; i++) { data <<= 1; uint8_t bit = DHT11_ReadBit(); if (bit == 0xFF) { return 0xFF; } data |= bit; } return data; }3.3 数据校验与温湿度计算
最后的读取函数,把5个字节收集完整并校验:
uint8_t DHT11_ReadData(uint8_t *humidity, uint8_t *temperature) { uint8_t buf[5] = {0}; uint8_t checksum = 0; if (DHT11_Start() != 0) { return 1; } for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); if (buf[i] == 0xFF) { return 1; } } checksum = (buf[0] + buf[1] + buf[2] + buf[3]) & 0xFF; if (checksum != buf[4]) { return 1; } *humidity = buf[0]; *temperature = buf[2]; return 0; }温度负值处理要注意。buf[2]的bit7为1时表示零下,实际温度值等于(buf[2] & 0x7F)的数值,但符号为负。很多教程只处理正温度,北方的小伙伴冬天一测直接显示一百多度,就是因为没做符号位解析。
这里再补充一个常见需求:希望用float直接得到带小数的温湿度。DHT11数据帧里有小数位,但实测基本为0,你可以这样组织:
float hum = buf[0] + buf[1] / 10.0f; float temp = buf[2] + buf[3] / 10.0f;不过要注意buf[2]最高位是符号位,直接转float前先做掩码处理。
3.4 HAL库版的关键差异
HAL库版本的核心时序逻辑完全一样,只有GPIO操作和初始化写法不同。GPIO初始化如下:
GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_1; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);输入模式切换:
GPIO_InitStruct.Pin = GPIO_PIN_1; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);写引脚用HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET/RESET),读引脚用HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1)。
有一个HAL库特有的坑:GPIO_Mode_OUTPUT_OD模式下如果同时配置了Pull,HAL库会把上拉/下拉的设置也写入寄存器。对于开漏输出,我们期望释放电平由外部上拉决定,如果内部也配置了上拉,问题不大,但如果你用了GPIO_MODE_OUTPUT_PP推挽输出,切换输入模式时忘了改成输入模式,读引脚就会一直读到输出寄存器锁存的值,表现就是数据死活不对。HAL库不像标准库切换模式那么显眼,初学者特别容易漏掉这一步。
4. 实测中的三个坑:顺着排查链路找根因
4.1 现象一:读回全0xFF或校验永远失败
这个现象最常见,而且很多人的第一反应是“DHT11坏了”。实际上绝大多数情况是时序被破坏。
我先说一次真实的排查经历。有段时间我把DHT11接到一个同时开了串口中断和定时器中断的工程里,主频72MHz。单独跑DHT11测试程序没问题,一旦和其他外设联合工作,读回来的数据就间歇性全0xFF。用逻辑分析仪抓波形发现,DHT11正常发出了数据,但STM32读到的bit序列和示波器上的波形对不上。
根因是中断打断。DHT11每一位的高电平窗口只有几十微秒,如果读取循环进行到一半被一个优先级更高的中断抢占,等中断处理完回来,高电平已经结束或者已经被误判。解决办法有两个:
- 读取DHT11期间关闭总中断,读完再恢复。用
__disable_irq()和__enable_irq()包住DHT11_ReadData()调用。但这会让系统实时性变差,如果项目里还有对时间敏感的中断,要谨慎。 - 把DHT11读取放在一个专用的低优先级线程或主循环里,同时保证读取期间没有高频率中断干扰。如果必须用RTOS,建议给DHT11读取任务一个独立的时间片,读取期间禁止任务调度。
还有一个隐蔽的原因:GPIO输入模式配置成了无上下拉(GPIO_Mode_IN_FLOATING)。外部上拉电阻缺失时,引脚在总线释放期间会悬空,电平随机漂移,读出来自然乱七八糟。检查一下硬件上DHT11的DATA引脚到VCC是否有一个4.7k~10k的上拉电阻。
4.2 现象二:delay卡死,程序跑飞
网上搜索“stm32 delay卡死”能搜出一堆帖子,很多都涉及DHT11的18ms起始信号。程序卡死的位置通常是在delay_ms()或delay_us()里死循环出不来。
第一个常见原因:SysTick没有配置。如果你用了正点原子/野火的delay函数,它的初始化依赖delay_init(),而delay_init()需要正确的SystemCoreClock值。很多人从旧工程拷贝delay.c,没改SystemCoreClock,导致滴答定时器的重装载值计算出错,delay函数永远等不到预期的计数。
第二个常见原因:中断里也调用了延时函数。如果你的SysTick_Handler里执行了耗时操作,或者delay函数本身依赖SysTick中断累加变量,而SysTick中断优先级又被设成了最低且被其他中断持续抢占,延时计数器就永远累加不到目标值,表现为卡死。
排查链路建议:先屏蔽所有外设中断,单独跑一个LED翻转程序验证delay是否正常。如果LED频率稳定,说明delay函数本身没问题,再去查中断干扰。如果LED也不正常,检查SystemCoreClock定义和启动文件里的系统时钟初始化。
第三个可能原因:调试器在单步调试时卡死。用ST-Link单步走DHT11时序代码时,每一步之间间隔远大于时序窗口,传感器早就超时了。这不是代码问题,是调试方式问题。DHT11这类器件不适合单步调试,要么全速跑然后打印结果,要么用逻辑分析仪抓波形。
4.3 现象三:杜邦线能读,PCB上不能读
这个坑我栽过一次。面包板上杜邦线短接,程序工作完美;打样PCB回来,同样程序读不到数据,校验一直失败。排查时先怀疑是焊接问题,补焊后依旧,后来用示波器看DHT11 DATA引脚的波形,发现高电平上冲不足,上升沿明显变缓。
原因是PCB布线过长且走线经过了大电流回路附近,寄生电容和耦合噪声把信号质量搞坏了。再加上DHT11模块本身可能没有去耦电容,电源纹波直接耦合到数据线上。
解决方案:
- DATA线上加一个1k~4.7k的上拉电阻到VCC,增强上拉驱动能力
- DHT11的VCC和GND引脚旁边加100nF去耦电容,尽量靠近传感器
- DATA走线远离时钟线、电机驱动线等高频大电流走线
- 如果走线超过20cm,考虑加一个RC低通滤波,比如1k电阻加100pF电容,但要注意滤波不能把高电平宽度削太多
从此之后我做传感器小板,无论多简单的模块,都会预留去耦电容位和上拉电阻位,这是低成本换稳定性的典型做法。
4.4 排查这类问题的一个通用思路
调试任何带时序的传感器,我总结了一条比较高效的排查路径:
- 先用逻辑分析仪或示波器看传感器引脚波形。不需要高级仪器,几十块钱的逻辑分析仪就够用。重点看是否有起始信号后的响应低脉冲、数据位的高电平宽度。
- 对照数据手册确认时序参数。DHT11的0和1高电平宽度差异很大,波形上几乎一眼能分辨。
- 反查代码流程。把DHT11_Start()和DHT11_ReadData()每个阶段的GPIO方向切换列出来,确认方向切换时机是否和波形匹配。
- 逐个屏蔽外部因素。关中断、拆掉其他外设、换独立供电,每做一步就重测一次,直到定位到影响源。
这套方法不仅适用于DHT11,也适用于DS18B20、红外遥控接收等所有依赖timing的外设。遇到问题不要急着改代码,先看波形,波形会告诉你真相。
5. 从读数据到做产品:扩展几个真实场景
5.1 OLED显示温湿度
DHT11读数之后最自然的下一步就是显示。SSD1306的0.96寸OLED是STM32玩家最常用的屏,I2C接口只需要两根线,和DHT11组合在一起就是一个小型温湿度计。
注意一个调度细节:DHT11读取一次需要至少20ms的起始信号加上约5ms的数据传输时间,而OLED刷新一帧需要几十ms。如果把DHT11读取和OLED刷新放在同一个while循环里串行执行,一次循环可能要上百ms,按键响应和LED闪烁都会变得非常迟钝。
我的建议是做一个轻量级状态机,或者干脆用定时器中断按周期调度。比如用TIM2产生1ms时基,累计到2秒就触发一次DHT11读取,读取结果存到全局变量,主循环只负责把最新的温湿度刷到OLED上。这样DHT11的1~2秒最小采样周期也刚好满足。
5.2 超过阈值报警(LED/蜂鸣器)
温湿度数据有了,做个阈值报警就顺理成章。比如温度超过28℃点亮红灯,湿度低于30%点亮黄灯。
建议给阈值加一点回差(迟滞),避免温湿度在阈值附近抖动时LED频繁闪烁。比如温度上限设28℃,实际26℃就熄灭,中间的2℃就是回差区间。没有回差的话,自然环境下温湿度在小范围波动,继电器或蜂鸣器会被反复触发,这也是工业控制里很基础的抗抖设计。
另外,DHT11测量本身就有波动,连续两次读到的数据可能差1℃或1%RH。做报警判断前可以先做一次简单的滑动滤波,比如取最近三次读数的平均值,能明显减少误报警。
5.3 定时采样与低功耗思路
如果是电池供电的应用,比如一个无线温湿度节点,DHT11的功耗问题必须考虑。
DHT11本身静态功耗很低,但STM32如果一直全速跑、一直给DHT11供电,整体功耗就上去了。低功耗的常规做法是:
- 主控进入STOP模式,用RTC闹钟或外部事件每30秒唤醒一次
- 唤醒后给DHT11供电,延时50ms等传感器稳定
- 发起一次读取,得到数据后立刻断电
- 数据通过无线模块发出去之后,重新进入STOP模式
DHT11启动稳定时间通常在1秒以内,从给电到读取完成,整个活动窗口控制在100ms左右是可行的。这个模式下,两节AA电池撑几个月不是问题。当然,如果追求更低功耗或更高精度,后面可以考虑SHT40、AHT20这些I2C接口的数字传感器,它们不需要严格时序,读取逻辑更简单,平均功耗也更低。
5.4 什么情况下该换SHT30/AHT20
DHT11最大的优势是便宜(一两块钱)、单总线省引脚、资料多。但它也有明显的天花板:湿度精度±5%RH,温度精度±2℃,响应慢,长期稳定性一般。
如果你是做产品原型,或者对数据准确性有一点要求,我建议直接换AHT20或SHT30。这两个都是I2C接口,STM32的硬件I2C或软件模拟I2C都能轻松读取,不需要微秒级时序,抗干扰能力强得多。SHT30精度可以做到±2%RH和±0.3℃,价格也就十块钱上下,换来的是开发效率和可靠性的巨大提升。
做好一个项目的基本功不是背代码,而是理解每种器件适合什么场景。DHT11适合入门学习、成本敏感、精度要求不高的场合;一旦项目开始追求数据质量或长期稳定,就该毫不犹豫往SHT30、AHT20迁移。
我做DHT11驱动前后也折腾了不少时间,总结下来的体会是:这类单总线时序传感器的调试,本质上是在训练自己对“时间”的敏感度。会看波形、会校准延时、会排查中断干扰之后,再回头去看其他传感器,会发现一通百通。如果你能在自己的板子上把DHT11从头到尾调通,并且能说出每个延时的作用,嵌入式开发的大门就已经推开一半了。