STM32标准库驱动DHT11温湿度传感器完整教程
2026/9/8 22:29:51 网站建设 项目流程

简介:STM32F103 平台的 DHT11 温湿度驱动包,面向嵌入式入门者、电子竞赛选手及需要快速集成环境监测模块的项目开发人员,解决在最小系统板上稳定读取温湿度数据的常见难题。驱动基于 ST 标准库编写,通过 GPIO 推挽输出发起起始信号,再切换输入模式捕获单总线时序,配合微秒级延时或定时器中断完成 40 位数据帧的接收、校验与温湿度值换算,示例工程结构简洁、注释清楚,可轻易迁移到其他 STM32 系列芯片。资源共 211 个文件,包括 .c 源文件、.h 头文件、Keil 工程文件、.hex 可执行文件,以及编译过程产生的 .o、.crf 等中间文件,压缩包仅 5.75MB,小巧便于下载。当前已有 950 人学习下载,经作者实测可用。包内还保留 USART、OLED、定时器备份文件,可进一步学习串口打印、OLED 显示与定时器配置,对理解 STM32 标准库外设开发和多模块协同很有帮助。 手头正好有一块STM32F103C8T6最小系统板,加上一个DHT11温湿度传感器,花了不到半小时就把驱动跑通了。这个组合在嵌入式入门里几乎是最经典的一套——一个是一块钱出头就能买到的温湿度传感器,一个是淘宝上十几块钱包邮的最小系统板,再配上标准库工程,代码量不大,但GPIO操作、时序协议、延时控制这些基本功全都覆盖到了。

这篇文章就把我这套亲测可用的DHT11驱动完整拆开来讲,包括硬件连接、时序分析、标准库代码实现,以及我在调试过程中踩过的几个坑。不管你是刚开始玩STM32,还是想快速给项目加上温湿度采集功能,这套方案都能直接照着抄。

1. 方案设计与选型思路

1.1 为什么选DHT11而不是DHT22或SHT30

DHT11和DHT22、SHT30比起来,精度确实不算高——温度精度正负2摄氏度,湿度精度正负5%RH,测量范围也就0到50摄氏度和20%到90%RH。但选它有三个实实在在的理由。

首先是便宜,单买一两块钱,批量更便宜,开发阶段随便折腾不心疼。其次是单总线协议,只需要一根数据线就能完成读写,省IO口,对最小系统板这种引脚资源本来就不算富裕的场景非常友好。第三是资料多,网上随便一搜就能找到大量参考代码,出了问题也好排查。

当然,如果你做的是环境监测类产品,对精度有要求,直接换DHT22或者SHT30也来得及。DHT22的时序协议和DHT11基本一致,只是数据格式和精度不同,代码改动很小;SHT30走的是I2C协议,那就需要另外写驱动了。我的经验是先拿DHT11跑通整个采集流程,后面再换传感器就只是换驱动的事,应用层代码不用动。

1.2 标准库对比HAL库,哪个更适合这个场景

现在ST官方主推HAL库和STM32CubeMX,新项目很多直接用HAL。但我在这个项目里选择标准库,而且用的是V3.5版本,原因很简单:最小系统板 + DHT11这种场景,GPIO操作就是最基础的配置和读写,标准库的GPIO_Init()、GPIO_ReadInputDataBit()这几个函数完全够用,代码量比HAL库少,逻辑也更直白。

还有一个现实因素是,市面上大量DHT11的参考代码都是基于标准库写的,直接拿来改改就能用,不用在CubeMX的初始化配置上花时间。标准库虽然官方已经停止更新了,但对于F103这种老芯片,V3.5版本已经非常成熟稳定,不存在什么功能缺陷。

如果你用的是HAL库,也可以参考这篇文章的时序分析,HAL库的GPIO操作无非就是HAL_GPIO_Init()HAL_GPIO_ReadPin(),把底层函数换掉就行。关键还是要理解DHT11的时序协议本身,这个下面会详细讲。

1.3 最小系统板的核心资源盘点

STM32F103C8T6最小系统板的核心资源对于DHT11这个项目来说绰绰有余:72MHz主频、64KB Flash、20KB SRAM,GPIO口有37个。跑DHT11只需要其中一个普通GPIO,我选的是PA0,因为它在板子边缘,方便接杜邦线。

这块板子的晶振配置要特别注意——板上用的是8MHz晶振,系统时钟通过PLL倍频到72MHz。延时函数的时间基准跟系统时钟直接相关,我下面给出的延时函数就是基于72MHz算好的,如果你的板子改了晶振或者用内部HSI时钟,延时函数里的循环参数就需要重新调整。这是移植到其他板子上时最容易出问题的地方。

2. 硬件连接与工程准备

2.1 DHT11引脚定义与接线方法

DHT11传感器模块一般是三个引脚,也有四脚的,引脚定义如下:

引脚名称说明
1VCC电源正极,3.3V至5V均可
2DATA数据引脚,单总线通信
3NC / GND空脚或地线(四脚版本多为空脚)
4GND电源地

和STM32F103C8T6最小系统板的接线非常简单:VCC接3.3V,GND接GND,DATA接PA0。注意不要把VCC接到5V上,虽然DHT11支持5V供电,但PA0引脚是3.3V电平,数据线直连的话逻辑电平不匹配,会有风险。

这里要特别强调一个关键的硬件细节,DHT11的数据引脚需要接一个上拉电阻,典型值是4.7k到10k欧姆。如果你用的是DHT11模块(就是那种小板子,上面有四个引脚),模块上通常已经焊好了上拉电阻,直接接线就行。如果是裸传感器,那就必须在DATA引脚和VCC之间外接一个4.7k欧姆的上拉电阻,否则通信会不稳定,甚至完全读不到数据。我最初就因为忽略了这一点,浪费了不少时间。

2.2 标准库V3.5工程创建要点

标准库V3.5工程的创建,网上教程一大把,核心就这几步:下载标准库V3.5固件包,复制CMSIS和StdPeriph_Driver两个核心目录到工程文件夹,用Keil MDK新建工程,把启动文件(startup_stm32f10x_md.s,F103C8T6是中容量器件,用md这个)加进去,然后在C/C++选项卡里定义STM32F10X_MDUSE_STDPERIPH_DRIVER,最后把宏定义和头文件路径配置好。

我习惯的做法是把工程文件分为四个目录:USER存放main.c和stm32f10x_it.c,CORE存放启动文件和核心头文件,SYSTEM存放延时、串口等公共模块,HARDWARE存放外设驱动,DHT11的驱动文件就放在这里。这样后面加LCD、按键之类的驱动,工程结构也不会乱。

如果你懒得一步步新建工程,直接用现成的模板改也完全可以。关键是确认Keil里的Target选项设置正确——Device选择STM32F103C8,在C/C++选项卡里把宏定义和Include路径配好,然后先编译一个点亮LED的程序验证工程本身没问题,再开始移植DHT11驱动。

3. DHT11时序协议详解与代码实现

3.1 单总线协议:主机怎么发起通信

DHT11的数据引脚是开漏输出结构,平时靠上拉电阻保持高电平,通信时主机和传感器通过拉低电平来表态。整个通信过程是主机主动发起的:先把数据线拉低至少18毫秒,再释放总线让上拉电阻把电平拉高,这是给DHT11一个启动信号,告诉它“准备上报数据”。

主机释放总线后,DHT11会先拉低电平80微秒作为响应信号,然后再拉高80微秒开始准备发送数据。也就是说,总线上从起始信号到第一个数据位,要经历这么几个阶段。

注意这个起始过程有个很容易犯错的地方:主机拉低的时间必须在18毫秒以上,但也不能太长。我第一次写驱动的时候,延时函数没写好,拉低了大概50毫秒,结果DHT11直接不响应了,后来把延时调到20毫秒才正常。

起始信号发完之后,主机的角色就从发送者切换为接收者,要把GPIO配置从推挽输出改为浮空输入(或者上拉输入),接下来就是读取DHT11返回的数据。这个模式切换在标准库实现里就是重新调用一次GPIO_Init(),代价是浪费几个微秒,但DHT11的时序容差比较大,完全不影响读取。

3.2 数据位怎么识别:26微秒的高电平是0,70微秒是1

DHT11返回的数据由40个二进制位组成,每个位都是从拉低50微秒开始的,然后释放总线让电平拉高,高电平持续的时间长度决定了这个位的值是0还是1。

高电平持续26到28微秒,代表逻辑0;高电平持续70微秒,代表逻辑1。所以读取数据位的关键就是测量高电平持续了多长时间。这个判断方式在时序上非常宽容,因为0和1的高电平时间差了一倍多,用循环延时就能准确区分,不需要用定时器捕获。

40位数据的具体含义是这样的:

数据段位宽说明
湿度整数部分8位十进制数值,如45代表45%RH
湿度小数部分8位十进制数值,通常为0
温度整数部分8位十进制数值,如25代表25摄氏度
温度小数部分8位十进制数值,通常为0
校验和8位前四个字节相加的低8位

校验和的算法是前四个字节加起来,取结果的低8位,和校验字节相等就说明数据传输无误。这一步很重要,因为DHT11在恶劣环境下偶尔会传出错误数据,如果不做校验,就可能把错误数据当成真实读数用。

3.3 完整驱动代码讲解:dht11.c和dht11.h

下面是完整的DHT11驱动代码,基于标准库,GPIO使用PA0。先看头文件,定义了几个必要的数据结构。

// dht11.h #ifndef __DHT11_H #define __DHT11_H #include "stm32f10x.h" // 用户可自定义数据引脚,这里使用PA0 #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_Pin_0 #define DHT11_GPIO_CLK RCC_APB2Periph_GPIOA // DHT11返回的数据缓冲区 typedef struct { uint8_t humi_int; // 湿度整数部分 uint8_t humi_deci; // 湿度小数部分 uint8_t temp_int; // 温度整数部分 uint8_t temp_deci; // 温度小数部分 uint8_t check_sum; // 校验和 } DHT11_Data_TypeDef; uint8_t DHT11_Init(void); uint8_t DHT11_Read_Data(DHT11_Data_TypeDef *dht11_data); #endif

接下来是源文件。先说引脚模式切换的辅助函数,DHT11通信过程中需要多次切换GPIO模式和读写数据,我把这几个基本操作都封装成了内联函数,代码可读性会好很多。

// dht11.c #include "dht11.h" #include "delay.h" 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_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); } 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); } static void DHT11_DQ_High(void) { GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); } static void DHT11_DQ_Low(void) { GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); } static uint8_t DHT11_DQ_Read(void) { return GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN); }

初始化函数做两件事,一是配置GPIO时钟和引脚工作在推挽输出模式下,二是给DHT11发送一个起始信号检查它是否在线。返回1表示初始化成功,返回0表示没有检测到DHT11的响应。

uint8_t DHT11_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(DHT11_GPIO_CLK, ENABLE); GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); // 拉低18ms以上作为起始信号 DHT11_DQ_High(); delay_ms(100); // 上电稳定时间,可选 DHT11_DQ_Low(); delay_ms(20); // 起始信号,必须大于18ms // 释放总线,切换为输入模式等待响应 DHT11_DQ_High(); delay_us(40); DHT11_Pin_Mode_Input(); // 检测响应信号:低电平90us左右表示DHT11在线 if (DHT11_DQ_Read() == Bit_RESET) { // 等待低电平结束(80us) uint16_t timeout = 10000; while (DHT11_DQ_Read() == Bit_RESET) { if (--timeout == 0) return 0; } // 等待高电平结束(80us) timeout = 10000; while (DHT11_DQ_Read() == Bit_SET) { if (--timeout == 0) return 0; } return 1; } return 0; }

核心的读取函数如下,读取一帧数据。每次读取前都要重新发起一次起始信号,也就是拉低20毫秒再释放。这个函数返回0表示读取成功,返回1表示超时或校验错误。

uint8_t DHT11_Read_Data(DHT11_Data_TypeDef *dht11_data) { uint8_t i, j; uint8_t buf[5] = {0, 0, 0, 0, 0}; uint8_t timeout; DHT11_Pin_Mode_Output(); // 起始信号 DHT11_DQ_Low(); delay_ms(20); DHT11_DQ_High(); delay_us(30); // 切换为输入模式,准备接收数据 DHT11_Pin_Mode_Input(); // 等待DHT11拉低响应信号(80us低电平) timeout = 100; while (DHT11_DQ_Read() == Bit_SET) { if (--timeout == 0) return 1; } // 等待响应信号结束(80us高电平) timeout = 100; while (DHT11_DQ_Read() == Bit_RESET) { if (--timeout == 0) return 1; } while (DHT11_DQ_Read() == Bit_SET) { if (--timeout == 0) return 1; } // 读取40位数据:每字节8位,共5字节 for (j = 0; j < 5; j++) { for (i = 0; i < 8; i++) { // 每个位都以低电平50us开始 timeout = 100; while (DHT11_DQ_Read() == Bit_RESET) { if (--timeout == 0) return 1; } // 高电平期间延时40us,如果仍为高电平则此位为1 delay_us(40); if (DHT11_DQ_Read() == Bit_SET) { buf[j] |= (0x80 >> i); // 设置对应位为1 } // 如果为低电平,此位为0,继续等待当前位结束 timeout = 100; while (DHT11_DQ_Read() == Bit_SET) { if (--timeout == 0) return 1; } } } // 校验:前4字节相加的低8位应等于第5字节 if ((buf[0] + buf[1] + buf[2] + buf[3]) == buf[4]) { dht11_data->humi_int = buf[0]; dht11_data->humi_deci = buf[1]; dht11_data->temp_int = buf[2]; dht11_data->temp_deci = buf[3]; dht11_data->check_sum = buf[4]; return 0; } return 1; }

这段代码里最关键的是数据位的判断逻辑。我在每个位信号低电平结束后开始计时,延时40微秒后读取引脚状态。如果此时还是高电平,说明高电平持续时间超过40微秒,再加上前面的低电平50微秒,几乎可以肯定是逻辑1;如果已经变回低电平,说明高电平只有二十几微秒,那就是逻辑0。实测下来这个判断方法非常稳定,从来没出现过误判。

3.4 延时函数实现:基于72MHz主频

DHT11协议对时序要求比较宽松,但延时函数还是得准确。我这里用的是最经典的循环延时,基于72MHz主频。

// delay.c #include "stm32f10x.h" static uint8_t fac_us = 0; static uint16_t fac_ms = 0; void delay_init(void) { // 72MHz主频下,fac_us = 72,fac_ms = 72000 fac_us = 72; fac_ms = 72000; } void delay_us(uint32_t nus) { uint32_t i; for (i = 0; i < nus * fac_us; i++) { __NOP(); } } void delay_ms(uint32_t nms) { uint32_t i; for (i = 0; i < nms * fac_ms; i++) { __NOP(); } }

这个延时函数不是最精确的,因为__NOP()加上循环本身的开销,实际延时比理论值稍微长一点。但DHT11的时序容差足够大,这种偏差完全在允许范围内。如果你用SysTick做延时也可以,不过对于DHT11来说有点杀鸡用牛刀了,循环延时简单直接,还容易移植。

4. 主程序调用与串口打印验证

4.1 主程序逻辑与数据打印

驱动写好了,下一步就是写主程序调用。我用串口1把温湿度数据打印出来,方便在串口助手里观察。PA9是USART1的TX,PA10是RX,直接用USB转TTL模块接上就行。

// main.c #include "stm32f10x.h" #include "delay.h" #include "usart.h" #include "dht11.h" DHT11_Data_TypeDef dht11_data; void GPIO_LED_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOC, &GPIO_InitStructure); GPIO_SetBits(GPIOC, GPIO_Pin_13); // 板载LED默认熄灭 } int main(void) { delay_init(); GPIO_LED_Init(); USART1_Init(115200); if (DHT11_Init() == 0) { printf("DHT11 init failed!\r\n"); } else { printf("DHT11 init ok!\r\n"); } while (1) { if (DHT11_Read_Data(&dht11_data) == 0) { printf("Humi: %d.%d%%RH Temp: %d.%d℃\r\n", dht11_data.humi_int, dht11_data.humi_deci, dht11_data.temp_int, dht11_data.temp_deci); } else { printf("Read DHT11 failed!\r\n"); } // LED翻转,指示程序运行状态 GPIO_WriteBit(GPIOC, GPIO_Pin_13, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_13))); delay_ms(1000); // 每1秒读取一次 } }

读取频率这里要注意,DHT11的数据更新周期大约是2秒一次,意思是传感器本身每隔2秒才刷新一次内部数据。如果你读取频率太快,比如每100毫秒读一次,读到的可能是上一次的数据,而且频繁发起通信会增加传感器的工作负担。我一般把读取间隔设在1到2秒,够用又稳妥。

4.2 串口打印结果与数据校验

编译下载后,打开串口助手,波特率设置115200,正常情况下应该看到类似下面的输出:

DHT11 init ok! Humi: 54.0%RH Temp: 26.0℃ Humi: 54.0%RH Temp: 26.0℃ Humi: 55.0%RH Temp: 26.0℃

每次读取成功后,如果校验和不对,程序会打印Read DHT11 failed而不是错误数据。这样串口打印出来的数据都是经过校验的,可以直接使用。

关于数据格式,有一点值得说明:DHT11的湿度小数位和温度小数位在常规型号上一般输出0,但如果买的是升级版(DHT11的精度增强版),小数位可能会有非零值。打印的时候把小数位一起输出,就不用担心软件版本不兼容了。

4.3 移植到其他引脚或板子的方法

这套驱动代码的移植性很好,因为我把引脚相关的定义都集中放在了dht11.h的开头。要换引脚或换板子,只需要修改几个宏。

换引脚的话,修改DHT11_GPIO_PORTDHT11_GPIO_PIN即可,同时确认对应的RCC_APB2Periph_GPIOx时钟宏也改掉。比如从PA0换到PB1,就是这样:

#define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_Pin_1 #define DHT11_GPIO_CLK RCC_APB2Periph_GPIOB

换板子的话,如果还是F103系列,最需要注意的是主频。如果新板子的晶振不同,或者系统时钟不是72MHz,delay_init里的fac_usfac_ms需要重新计算。如果换到F4系列,虽然可以用标准库,但GPIO初始化结构体略有差异,需要同步调整。如果是换到其他厂商的芯片,那就需要把GPIO操作部分全部替换成新平台的API,但时序逻辑可以直接照搬。

5. 常见问题与排查技巧实录

5.1 一直显示DHT11 init failed

这个现象我在调试时遇到过几次,每次原因都不一样。最常见的是接线问题,DATA引脚和VCC引脚接反了,或者杜邦线接触不良。这时候先用万用表量一下模块引脚上的电压,确认VCC确实有3.3V。

其次是上拉电阻缺失。前面提过,如果用的是裸DHT11传感器而不是模块,必须外接4.7k到10k的上拉电阻到VCC。没有上拉电阻,总线无法回到高电平,传感器永远无法正常响应。

还有一个不太容易想到的原因是GPIO模式切换的问题。有些参考代码在初始化后一直保持输入模式,没有正确切回输出模式再发送起始信号。用我上面的代码就不会有这个问题,因为DHT11_Read_Data里第一步就把引脚配置回推挽输出了。

5.2 读到的数据一直不变或者全是零

如果程序一直读Data failed,偶尔成功一次但数据全是零,十有八九是时序问题。重点检查延时函数有没有基于实际主频正确计算。如果你改了系统主频但没改delay_init里的参数,起始信号的低电平时长可能就不足18毫秒,DHT11压根没识别到有效起始信号。

还有一种情况是循环延时函数里的NOP循环被编译器优化掉了。打开Keil的优化选项,如果在Level 3或更高优化等级下,编译器可能会把空的NOP循环优化掉导致延时失效。建议把优化等级设为Level 0或者Level 1,或者用volatile修饰循环变量。我一般习惯在调试阶段用Level 0,最终发布再调高优化等级并重新测试时序。

5.3 数据偶发跳变,时好时坏

这个问题要分几种情况讨论。如果你读取次数足够多,发现大概几百次里有一两次校验错误,这其实是正常现象,DHT11毕竟是低成本传感器,抗干扰能力有限。解决方案就是增加读取重试机制,校验失败就重读。

如果频繁失败或者数据明显异常,优先检查供电稳定性。最小系统板通过USB供电时,如果USB线质量不好或者供电电流不足,传感器可能工作在不稳定的电压下,数据自然不可靠。建议在VCC和GND之间加一个10uF左右的滤波电容,非常管用。

还有一种情况是数据线太长导致的信号质量问题。DHT11的单总线协议不适合长距离传输,数据线超过20厘米就容易出问题。如果你需要把传感器放到远处,建议换用I2C接口的传感器,或者用带屏蔽的线材,同时降低通信速率。

5.4 代码烧录后没有任何反应

这个现象很多人第一反应是代码写错了,其实很可能根本就没烧录进去或者是供电问题。我遇到过几次,最小系统板需要短接BOOT0到3.3V才能进入系统存储器模式进行串口下载,有些板子的启动配置跳线默认是错的,导致程序无法运行。

烧录完成后记得把BOOT0跳线恢复到低电平,否则板子每次上电都会进入引导区而不是运行你的程序。还有用ST-Link或J-Link下载时,确认驱动安装好了。CH340和CP2102的USB转串口驱动也容易出问题,设备管理器里看不到COM口的话,先检查驱动。

6. 几点补充与经验总结

这套DHT11驱动和验证代码放在STM32F103C8T6最小系统板上实测运行,连续跑了一个星期也没出过问题。个人体会是,DHT11这个传感器本身不复杂,真正的价值在于通过它理解单总线协议的核心思想——一根线上既要发数据又要收数据,靠的就是严格的时序控制。

最后说几个实用的小建议。代码里我保留了超时退出机制,所有等待循环都有计数器兜底,防止DHT11异常时程序死循环卡死,这个习惯建议保留。读取间隔设置在1秒以上,在main函数里加标志位切换LED状态,可以在硬件上直观看到程序是否在正常运行,排查问题时非常方便。

如果后面想扩展,可以考虑用定时器输入捕获来精确测量每个数据位的高电平时间,这种方式不依赖延时函数的精确度,抗干扰能力更强。也可以把DHT11的数据通过ESP8266上传到云平台,做成一个简单的温湿度监控系统。但不管怎么扩展,底层这套时序逻辑都是一样的,把今天这篇文章里的内容吃透就够用了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询