聊到STM32入门,DHT11几乎是一个绕不开的传感器。便宜、体积小、单总线协议看起来也简单,网上一搜教程一大把。但真到了自己上手焊接模块、写驱动、调时序的时候,不少人还是会卡在"读回来的数据全是0"或者"偶尔跳变到255"这种问题上。这篇教程就从我的实际调试经验出发,把DHT11从选型、硬件接线、通信协议、HAL库代码实现到常见坑位完整梳理一遍,适合正在做STM32课程设计、准备电子竞赛,或者单纯想把手头这块几块钱的DHT11驱动跑通的读者。
我不会只贴代码然后让你抄,而是把每条关键指令背后的时序逻辑讲清楚。你理解了DHT11最底层的脉冲长度判断,以后换DHT22、换国产的AHT20,甚至自己用GPIO模拟其他单总线传感器,思路都是通的。
1. 为什么现在还有人选DHT11:定位、替选与接线细节
1.1 DHT11的真实定位
先说清楚,DHT11在精度上确实不算好:湿度精度正负5%RH,温度精度正负2摄氏度,测量范围也就0到50摄氏度。跟SHT30、AHT20这种I2C接口的数字传感器比起来,数据手册上的参数简直惨不忍睹。但DHT11至今没有被淘汰,核心原因就两个:一是单总线通信协议足够简单,适合刚接触单片机时序控制的人练手;二是模块化之后成本极低,几块钱就能买到带PCB板和上拉电阻的成品模块,坏了不心疼。
它的使用场景也很典型:不需要高精度的环境监测、温室大棚的粗略温湿度采集、宿舍小项目做个桌面天气站、课程设计里凑一个传感器节点。在这些场景下,DHT11的误差完全在可接受范围内。如果你做的是需要精密控制的恒温恒湿箱,那就直接看DHT22或者SHT30,没必要在DHT11上死磕。
1.2 模块与裸传感器的接线差异
市面上DHT11有两种形态:四脚直插裸传感器和三针模块板。
裸传感器的引脚定义是:1脚VCC(3.3V到5V都可以)、2脚DATA、3脚NC悬空、4脚GND。这里有一个特别容易忽略的点——DHT11的数据线是开漏输出,必须在DATA和VCC之间接一个上拉电阻,阻值选4.7k到10k。很多新手买了裸传感器,直接拿杜邦线接到STM32的GPIO上,读出来的数据永远是0xFF或者完全无响应,就是因为少了这颗上拉电阻。
模块板就省心很多,商家已经把上拉电阻、滤波电容都集成好了,三根线直接接:VCC接3.3V或5V,GND接GND,DATA接STM32的任意GPIO。我自己习惯接PA0或者PB5这类方便用杜邦线引出的引脚,因为调试的时候可能要反复插拔。另外强调一句,DHT11的数据线不要接在已经配置为复用功能的引脚上,比如串口TX/RX、I2C的SCL/SDA,这样会和外设功能冲突。选一个纯粹的普通GPIO就对了。
1.3 供电电压的选择经验
DHT11标称支持3.3V到5V供电,但在STM32F103这种3.3V逻辑电平的单片机上,我推荐直接用3.3V供电,而不是用5V。原因很简单:如果DHT11用5V供电,它输出的高电平就是5V,而STM32的GPIO容忍度虽然部分引脚支持5V,但不是所有引脚都标注了FT(5V容忍)标识。为了一个温湿度传感器去查引脚容忍表,完全不值得。用3.3V供电,电平匹配,逻辑简单,实测通信距离在20厘米以内的杜邦线下完全稳定。
2. DHT11单总线协议拆解:把时序图变成代码逻辑
2.1 单总线通信的底层逻辑
DHT11用的是单总线协议,物理上只有一根数据线,主机和传感器分时复用这根线。理解单总线通信的关键在于"谁拉低、谁释放":DHT11是开漏输出,它只能主动把总线拉低,无法主动输出高电平。所以数据线空闲时靠上拉电阻保持高电平,需要传输低电平时,设备就把引脚拉低;需要传输高电平时,设备就释放引脚,让上拉电阻把电平拉回去。
这就是为什么上拉电阻缺一不可。没有上拉电阻,总线空闲时电平是浮空的,既不是高也不是低,传感器根本没有办法表达逻辑1。
2.2 完整的通信时序流程
DHT11的一次完整数据交互分为三个阶段:
第一阶段,主机发起起始信号。主机把数据线拉低,持续至少18毫秒,然后再释放。这个18毫秒的低电平时间必须够长,DHT11才能识别为一次有效的通信请求。常见做法是延时20毫秒,留出余量。起始信号结束后,总线回到高电平,主机需要立刻把GPIO模式从输出切换到输入,准备接收DHT11的应答。
第二阶段,DHT11应答。传感器检测到起始信号后,会先拉低总线80微秒,再释放80微秒,相当于告诉主机"我准备好了,准备发数据"。主机在这个阶段要做的就是从输出模式切到输入模式,然后静静等待总线上的电平变化。
第三阶段,DHT11连续发送40位数据。每一位数据的传输格式都一样:先拉低50微秒,然后释放总线。释放后的高电平持续时长决定了这一位是逻辑0还是逻辑1——高电平持续26到28微秒判定为0,持续约70微秒判定为1。主机就是靠测量"50微秒低电平之后那个高电平的长度"来区分0和1的。
40位数据的排列顺序是:湿度整数8位、湿度小数8位、温度整数8位、温度小数8位、校验和8位。总共40位,全部发送完毕后DHT11释放总线,一次完整的通信就结束了。
2.3 校验和的计算
校验和的算法很简单:把前四个字节相加,取低8位,如果和第五个字节相等,说明数据传输正确。
比如读到的湿度整数是45(0x2D),湿度小数是0(0x00),温度整数是26(0x1A),温度小数是0(0x00),那么校验字节应该是0x2D + 0x00 + 0x1A + 0x00 = 0x47。如果DHT11发来的第五个字节不是0x47,说明这一帧数据不可信,直接丢弃,下次读取再重试。
3. 基于HAL库的STM32F103实现:从GPIO模拟到完整驱动
3.1 关键准备:微秒级延时函数
DHT11的时序关键单位是微秒,而HAL库自带的HAL_Delay函数只支持毫秒级延时,所以必须自己实现一个微秒延时工具。最推荐的方式是使用DWT(Data Watchpoint and Trace)单元,它是Cortex-M3内核自带的硬件调试组件,不需要额外占用定时器,精度高,而且不会像SysTick那样和HAL库的HAL_Delay产生冲突。
简单来说,DWT有一个CYCCNT计数器,每个CPU时钟周期加1。我们只要读出计数器的值,通过计算时钟周期差就能实现微秒延时。在STM32F103主频72MHz下,延时n微秒等于等待72乘以n个时钟周期。
DWT初始化的代码非常简洁:
static void DWT_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 / 1000000); while ((DWT->CYCCNT - start) < ticks); }这里要注意乘法溢出问题。us最大是65535,SystemCoreClock / 1000000在72MHz下等于72,65535乘以72约等于471万,没超过32位无符号整数的上限,所以在单次延时20毫秒这种场景下是安全的。但如果延时时间更长,建议改用uint64_t计算。
我为什么不用SysTick?因为HAL库的HAL_Delay本身就是基于SysTick实现的,如果用户代码里再操作SysTick,容易和HAL库的时基冲突。DWT是独立的调试组件,不会占用任何外设资源,也不会被中断影响,是STM32上做微秒延时最稳的方案。
3.2 GPIO模式切换的实现要点
DHT11通信要求主机在"输出模式"和"输入模式"之间来回切换。HAL库提供了现成的GPIO初始化结构体,但如果每次切换都调用MX_GPIO_Init重新配置,代码会非常啰嗦。实际项目中我更推荐直接在每次操作前修改GPIO的CRL或CRH寄存器,或者用HAL库的模式重配置方式:
static void DHT11_Pin_Output(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); } static void DHT11_Pin_Input(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); }输出模式我习惯用开漏输出(GPIO_MODE_OUTPUT_OD)而不是推挽输出。原因是DHT11模块上已经有上拉电阻,开漏输出配合外部上拉正好符合单总线协议"拉低/释放"的工作方式。如果用了推挽输出,高电平时GPIO主动输出高电平,虽然也能工作,但总线上相当于两个设备在抢电平控制权,逻辑上不够干净。
3.3 完整的数据读取代码
首先是复位DHT11并等待应答的函数:
static uint8_t DHT11_Start(void) { uint8_t timeout = 0; // 主机拉低总线至少18ms DHT11_Pin_Output(); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); DWT_Delay_us(20000); // 释放总线,切换到输入模式 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); DHT11_Pin_Input(); // 等待DHT11拉低总线(应答信号的低电平部分) timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET && timeout--) DWT_Delay_us(1); // 等待DHT11释放总线(应答信号的高电平部分) timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET && timeout--) DWT_Delay_us(1); // 如果超时,说明DHT11没有响应 if (timeout <= 0) return 1; return 0; }然后是读取一个字节的核心函数。关键逻辑就是把每一位拆开处理:先等引脚变成低电平(每位数据的50微秒起始低电平),再等引脚变成高电平,高电平期间用循环计数测量它的持续时间:
static uint8_t DHT11_ReadByte(void) { uint8_t data = 0; for (int i = 0; i < 8; i++) { // 等待50us低电平结束 uint32_t timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET && timeout--) DWT_Delay_us(1); // 测量高电平持续时间 timeout = 100; uint32_t high_level_time = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { DWT_Delay_us(1); high_level_time++; if (high_level_time > 100) break; } // 高电平约26~28us为0,约70us为1 if (high_level_time > 35) data |= (0x80 >> i); } return data; }这个字节读取函数是整个驱动中最重要的部分。它的核心判断依据就是高电平时间是否大于35微秒。为什么是35这个阈值?因为逻辑0的高电平大约是26到28微秒,逻辑1大约是70微秒。两个区间之间有很宽的空白地带(28到70微秒之间几乎没有信号会出现),取中间值35微秒作为分界线非常安全。
最后把40位数据读回来并做校验:
uint8_t DHT11_ReadData(float *humidity, float *temperature) { uint8_t buf[5] = {0}; if (DHT11_Start() != 0) return 1; for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } uint8_t checksum = buf[0] + buf[1] + buf[2] + buf[3]; if (checksum != buf[4]) return 2; *humidity = (float)((buf[0] << 8) | buf[1]) / 10.0f; *temperature = (float)((buf[2] << 8) | buf[3]) / 10.0f; return 0; }这里把整数和小数字节合并成一个完整数再除以10,可以得到一位小数的浮点结果。比如buf[0]=45,buf[1]=0,合并后是450,除以10就是45.0。如果buf[1]=5,表示45.5%RH。这种写法比分别输出整数和小数更方便后面直接用于显示和判断。
3.4 主函数调用示例
主函数里的逻辑很简单:初始化延时、初始化串口、然后循环读取。需要注意的是DHT11的采样周期最小是1秒,两次读取间隔不要低于这个值,否则传感器会来不及进入待机状态,读到错误数据。
int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); DWT_Init(); float humidity = 0.0f, temperature = 0.0f; uint8_t err = 0; while (1) { err = DHT11_ReadData(&humidity, &temperature); if (err == 0) { printf("Humidity: %.1f%%, Temperature: %.1f\r\n", humidity, temperature); } else { printf("DHT11 read error: %d\r\n", err); } HAL_Delay(2000); } }4. 实际调试中踩过的坑:从静默失败到疑似翻车
4.1 读到的数据一直是0或者固定值
这是DHT11调试中最常见的现象,屏幕上一行行刷着"Humidity: 0.0%, Temperature: 0.0%"或者只有第一次是正确的,后面全是0。出现这种情况通常有四个排查方向。
第一个排查方向是起始信号的拉低时间不够。DHT11数据手册要求至少18毫秒,有些代码示例里写成2毫秒甚至200微秒,传感器根本没识别到主机请求,自然不回数据。用逻辑分析仪或者示波器看一下起始信号波形,确认低电平持续时间。
第二个排查方向是GPIO没有正确切换方向。如果主机一直处于输出模式,DHT11拉低总线的时候,主机也在驱动总线,两者直接冲突。排查方法是在等待应答之前用调试器查看GPIO的配置寄存器,确认已经切到了输入模式。
第三个排查方向是上拉电阻缺失。前面已经反复强调过,这里不赘述。如果你用的是裸传感器而不是模块板,优先检查DATA引脚到VCC之间有没有4.7k到10k的电阻。
第四个排查方向是微秒延时函数精度不够。用软件循环实现的延时函数,在开优化选项后时间会失真。我遇到过在-O0优化下DHT11工作正常,打开-O2优化后完全读不到数据的情况,原因就是编译优化把空的循环语句优化掉了。
4.2 最高频翻车点:delay函数卡死
很多人在网上找的DHT11示例代码,延时函数是用SysTick实现的。如果在延迟函数执行期间恰好有SysTick中断发生,并且中断服务函数里又调用了HAL_Delay或者其他SysTick相关的函数,就会造成嵌套调用,导致SysTick的计数器状态错乱,进而让整个系统卡死在while循环里。
这个问题在使用了FreeRTOS、或者开启了某些CubeMX生成的中断回调函数时会变得更严重。我自己就曾经在把DHT11驱动移植到带操作系统的项目里时,被这个"死循环卡死"折腾了整整一个下午。排查手段是先在main函数里屏蔽掉所有中断相关的初始化,只保留DHT11读取逻辑,如果卡死问题消失,说明是SysTick或者中断抢占等级的问题。后续的解决方案就是把微秒延时统一改成DWT实现,彻底绕开SysTick。
4.3 用ST-Link调试时数据正常,脱机运行数据错误
这是另一个容易让人心态崩溃的问题。接上ST-Link单步调试,读到的温湿度数据完全正确;拔掉调试器,让板子独立运行,数据就开始乱跳甚至全部为0。
这个现象的根源在于调试器对时序的"保护"是假象。单步调试时,CPU在每条指令之间停顿,反而给DHT11的引脚电平变化提供了充足的建立时间。而脱机运行时,CPU全速执行,如果读引脚的电平和时序判断语句之间有微小的时序误差,就可能错过50微秒低电平窗口或者对高电平时间的测量产生偏差。
解决思路有两个方向。一是降低优化等级,把代码中的时序敏感部分用volatile修饰,保证编译器不会重排指令;二是在高电平测量的while循环中增加上限保护,防止因为外部干扰导致循环无法退出。这里特别提一句"no stm32 target found"这类调试器连接报错:如果你发现DHT11引脚恰好和SWDIO或者SWCLK共用了某个端口,就会导致调试器无法连接,需要先把引脚断开再烧录程序,烧录完成后再接回去。这是我在一个紧凑布局的板子上踩过的真实问题,TX/RX和SWD引脚离得太近,飞线一碰就掉调试器。
4.4 转换速率配置不当导致的波形畸变
GPIO的输出速度配置也会影响DHT11时序。在STM32F103上,GPIO的输出速度可以配置为2MHz、10MHz、50MHz三挡。如果配置为50MHz,GPIO翻转速度极快,在长线上会产生过冲和振铃;如果配置为2MHz,GPIO高电平建立时间过长,影响高电平时间的测量精度。
我实测下来,DHT11这种低速单总线设备,配置为10MHz或者50MHz都可以正常工作,但不要配成2MHz。2MHz挡位下,GPIO从低到高的翻转沿变得平缓,到达逻辑高电平阈值的时间变长,会让单片机读到的"高电平持续时间"比实际值短,导致逻辑1被误判成逻辑0。如果你发现读到的数据有明显规律性的偏差,比如湿度值总比实际值低很多,优先查一下GPIO速度配置。
5. 数据用起来之前的最后一道关卡:稳定性与异常处理
5.1 为什么读到的数据会跳变
DHT11本身输出的就是模拟感湿材料的数字量化结果,受环境波动和供电噪声影响,连续两次读取的数值有1到2个百分点的偏差是正常现象。但如果出现大幅跳变,比如湿度从40%直接跳到80%,通常不是传感器坏了,而是通信过程中某一位被误读。
应对方法最常用的是限幅滤波:保存上一次的有效读数,如果新读数与上一次的差值超过设定阈值,就认为新读数无效,继续使用旧值。对于温湿度这种缓变量,阈值设置成温度和湿度各5个单位的跳变上限比较合理。下面是一段简单的限幅滤波代码,直接在读取成功后调用:
float last_temp = 0.0f, last_humi = 0.0f; uint8_t first_valid = 1; uint8_t DHT11_ReadWithFilter(float *humi, float *temp) { float rh = 0.0f, t = 0.0f; uint8_t err = DHT11_ReadData(&rh, &t); if (err != 0) return 1; if (first_valid) { last_temp = t; last_humi = rh; first_valid = 0; } else { if (fabs(rh - last_humi) > 5.0f || fabs(t - last_temp) > 5.0f) return 2; last_temp = t; last_humi = rh; } *humi = last_humi; *temp = last_temp; return 0; }5.2 采样间隔不是随便定的
DHT11完成一次完整测量需要大约20毫秒的时间来驱动内部感湿元件,如果主机读取频率太高,传感器还没准备好,就会返回上一次的缓存数据或者直接无应答。最稳妥的做法是使用官方推荐的1秒采样周期,也就是主循环里至少间隔1秒再发起下一次读取。
有一个很常见的误区是以为读得越快数据更新越快,于是在主循环里不加延时疯狂调用读取函数。这会导致DHT11长时间处于忙碌状态,芯片内部温度略微升高,温度读数比环境温度偏高1到2摄氏度。如果你发现传感器的温度值永远比室温高几度,先检查一下读取频率是不是太高。
5.3 判断"DHT11坏了"还是"程序问题"的快速方法
遇到死活读不到数据的板子,我有一套十分钟快速定位法:先用手捏住传感器,观察数据是否有变化。如果数据能变,说明程序和通信链路都是通的,只是精度问题;如果数据纹丝不动,再用手触摸传感器的DATA引脚(相当于手动注入一个干扰电平),如果程序突然报错或者数据变化,说明GPIO配置和读取逻辑都没问题,是传感器本身没在工作。这时再检查供电和上拉电阻。这个顺序能帮你快速缩小排查范围,避免一开始就陷入"逐行调试代码"的泥潭。
6. 把DHT11接到真实项目里:串口、OLED与后续扩展
6.1 串口打印的格式化建议
STM32F103的串口重定向printf需要处理微库或者write函数重定向,这里给出一个比较干净的HAL库重定向写法:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; }重定向之后在main函数里就可以直接用printf了。调试串口波特率我习惯配115200,8位数据,无校验,1位停止位。注意DHT11的数据线上不要有任何串口相关的外设功能映射,我曾经在配置CubeMX的时候不小心把DHT11接的引脚默认配成了串口复用,导致调试串口正常输出但DHT11永远读不到数据,排查了半个小时后才发现CubeMX的Pinout配置图里那个引脚亮着绿色的USART标记。
6.2 后续可以试着做这些事
DHT11跑通之后,整个STM32的GPIO操作和时序控制能力就练出来了。可以用同样的单总线思路去驱动DHT22,DHT22的时序和DHT11几乎一样,只是数据格式变成了16位分辨率,精度更高。也可以把读取逻辑封装成一个独立驱动文件,后面做FreeRTOS多线程任务时,给温湿度采集单独分配一个任务,通过消息队列把数据送给显示任务。再往后就是给温度加个简单的PID控制逻辑,配合继电器控制加热棒,做一个恒温箱。这些扩展本质上都是在"DHT11单总线读取"这个地基上盖楼,地基稳了,上面怎么盖都不会歪。
我最后再分享一个小习惯:DHT11驱动文件里,所有时序关键函数都用static修饰,只对外开放DHT11_ReadData和DHT11_ReadWithFilter两个接口。这样模块边界清晰,别人接手代码的时候只需要知道返回值和参数含义,不需要理解内部的微秒时序细节。好的嵌入式代码不是把每个底层细节都摊开给别人看,而是把复杂度封装好,让上层调用者越省心越好。