
简介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或SHT30DHT11和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 SRAMGPIO口有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.3VGND接GNDDATA接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.sF103C8T6是中容量器件用md这个加进去然后在C/C选项卡里定义STM32F10X_MD和USE_STDPERIPH_DRIVER最后把宏定义和头文件路径配置好。我习惯的做法是把工程文件分为四个目录USER存放main.c和stm32f10x_it.cCORE存放启动文件和核心头文件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微秒的高电平是070微秒是1DHT11返回的数据由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 72fac_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的TXPA10是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_PORT和DHT11_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系列最需要注意的是主频。如果新板子的晶振不同或者系统时钟不是72MHzdelay_init里的fac_us和fac_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上传到云平台做成一个简单的温湿度监控系统。但不管怎么扩展底层这套时序逻辑都是一样的把今天这篇文章里的内容吃透就够用了。本文还有配套的精品资源点击获取