STM32F103C8T6驱动DHT11温湿度传感器:单总线协议与HAL库实战解析
2026/9/9 6:42:26 网站建设 项目流程

简介:这是一份基于STM32F103C8T6与DHT11温湿度传感器的完整嵌入式工程代码,面向正在学习STM32标准库、GPIO与单总线通信的电子类学生或开发人员,可用于快速掌握DHT11的驱动编写与数据解析思路。压缩包内共有138个文件,其中包含33个H头文件与32个C源文件,覆盖stm32f10x系列标准外设库、定时器、串口、GPIO配置等模块,另有Keil工程配置文件、链接脚本、编译生成的axf/hex以及一键清理脚本,整体大小约2.57MB,目录结构完整,可直接打开工程查看或烧录验证。目前已有4930人学习下载。通过阅读代码可明确DHT11的单总线时序、启动信号发送、40位数据脉冲读取与校验过程,并能借助串口发送函数将温湿度实时打印出来,为后续接入OLED显示或物联网云平台提供可复用基础。

1. 项目整体设计与思路拆解

1.1 这个经典组合为什么经久不衰

先说说STM32F103C8T6这块芯片。Cortex-M3内核,主频最高72MHz,64KB Flash、20KB SRAM,LQFP48封装。这个配置放在今天看算不上豪华,但它的定位极其精准:入门学习、小项目原型验证、成本敏感型量产方案,它都能覆盖。加上“最小系统板”这种形态的出现,十几块钱就能拿到一块板子,焊接排针插面包板就能干活,直接拉低了嵌入式开发的门槛。

而DHT11这颗温湿度传感器,单总线协议,一根数据线就能完成双向通信,数字信号输出,不需要ADC采样,数据手册上明确写着湿度测量范围20%-90%RH、精度±5%RH,温度范围0-50℃、精度±2℃,采样周期1秒。从参数上看它确实“不够高级”,但架不住它便宜、稳定、协议简单——一颗DHT11只要几块钱,而且几乎不会烧毁,特别适合用来理解“时序通信”这个概念。

这个项目解决了什么问题呢?说白了就是一件事:让STM32通过一根GPIO引脚,按照DHT11规定的时序,把温湿度数据读出来。这个看似简单的过程,背后涉及GPIO模式切换、微秒级延时控制、时序竞争、校验和验证等好几个嵌入式核心技能点。学会了DHT11,后面再去玩DS18B20、单总线器件、甚至自己定义一个简单通信协议,思路都是相通的。所以这篇内容适合嵌入式入门者、正在做课设的在校生、以及想快速搭建环境监测节点的创客。

1.2 方案选型要提前想明白的问题

我见过不少新手一上来就纠结“为什么不用SHT30或AHT20”。我的观点很简单:每个传感器都有自己的定位。SHT30用I2C接口,数据更精准,但引脚多两根(SCL和SDA),协议复杂度也更高;AHT20也是I2C,性能和DHT11不是一个量级。但如果你的目标处理器是STM32F103C8T6这种入门级MCU,第一优先级是“跑通整个流程”,那DHT11的单总线协议反而是最好的教学素材——因为它逼着你学会精确控制GPIO输出时序、学会用延时函数丈量时间,而不是依赖硬件I2C外设把一切都“自动”搞定。

另外还要考虑实际应用场景。如果是要做精确的农业大棚监测,DHT11确实不够用;但如果是做一个桌面小气象站、智能家居温湿度采集节点,DHT11完全能胜任。选型逻辑从来不是“越贵越好”,而是“够用、稳定、成本合理”。STM32F103C8T6 + DHT11这个组合,恰好就是低成本温湿度采集方案里最经典也最耐打的一个。

2. 硬件准备与开发环境配置

2.1 硬件清单与引脚规划

先说需要准备的硬件,非常简单:

  • STM32F103C8T6最小系统板一块
  • DHT11传感器模块一个(推荐买模块,自带上拉电阻和滤波电容,省事很多)
  • 面包板一块、杜邦线若干
  • ST-Link V2下载器一个(或USB转TTL串口模块用于查看调试输出)
  • 电脑上装好Keil MDK或STM32CubeIDE,配合STM32CubeMX生成初始化代码

关于引脚规划,DHT11只需要一个GPIO引脚作为数据线。我习惯把它接到PA0上,原因很朴素:PA0靠近板子的丝印标识,接线不容易看错。你也可以用PB0、PB1、PC13等任意普通GPIO,只要代码里的宏定义跟着改就行。具体的接线方式见下表:

DHT11模块引脚STM32F103C8T6引脚说明
VCC(电源正)3.3V(板上引脚)供电范围3.3V~5V,建议先接3.3V,跟IO电平匹配
GND(电源负)GND共地是必须的
DATA(数据)PA0数据线,需要外接4.7kΩ~10kΩ上拉电阻到VCC

需要注意的是,如果你买的是单独的DHT11裸传感器(四根引脚,塑料封装带透气孔的那种),片内没有上拉电阻,必须在DATA线上加一个4.7kΩ到10kΩ的上拉电阻到VCC。如果你买的是模块(小板子带三个引脚,上面通常有标注),大多数模块已经集成好了4.7kΩ上拉电阻,直接接线即可。这个问题不处理好,会直接导致后面读取数据时电平漂移,时序完全乱掉。

2.2 CubeMX工程初始化要点

我用STM32CubeMX来生成工程,版本不限,选芯片型号时搜索“STM32F103C8Tx”即可。有几个配置项是必须注意的:

第一,SYS选项卡里的Debug一定要选择Serial Wire,否则程序第一次下载进去之后,第二次就下不进去了,只能通过Bootloader跳线或串口ISP方式擦除Flash才能恢复。这个坑几乎是STM32新手必踩的,我早期就吃过这个亏,下载器报错“No target connected”,排查了半天才发现是SWD引脚被禁用掉了。

第二,时钟树配置。STM32F103C8T6默认使用内部HSI 8MHz时钟,但CubeMX会推荐你配置HSE外部晶振。市面上的最小系统板一般焊了8MHz晶振,在RCC选项卡里选Crystal/Ceramic Resonator,然后时钟树配置界面里把HCLK设置到72MHz,CubeMX会自动计算PLL倍频系数。如果你用的板子没有外部晶振,那就选内部时钟,但串口波特率和DWT微秒延时的准确性会差一些,建议还是用带晶振的板子。

第三,GPIO配置。在Pinout视图里点击PA0,选择GPIO_Output,这里先设置成推挽输出,因为DHT11通信的起始信号需要主机拉低数据线;初始电平设为High,代表空闲状态下数据线被上拉到高电平。这个“空闲高电平”的概念在后面理解单总线协议时会非常关键。

第四,串口配置。为了调试方便,我把USART1的TX(PA9)和RX(PA10)启用,模式选择Asynchronous,波特率设115200,8位数据,无校验,1位停止位——这就是最常用的115200-8-N-1配置。后面会把温湿度数据通过串口打印到电脑上,用串口助手查看结果,验证读取是否成功。

2.3 从标准库到HAL库的迁移思考

很多网上教程还在用标准库写DHT11驱动,老代码喜欢直接操作寄存器(GPIOA->CRL、GPIOA->ODR),代码短但可读性差。在HAL库环境下,标准库的GPIO配置宏全部不可用,需要重新组织。不过HAL库有一个好处:GPIO模式的切换封装成了简单的结构体配置函数,逻辑更清晰。后面在写代码的时候我会把标准库和HAL库的对应关系顺便点一下,方便你读老教程时能对上号。

3. DHT11单总线协议深度剖析

3.1 单总线通信的本质

要驱动DHT11,必须先把它的通信协议吃透。DHT11用的是单总线协议,物理层只有一根数据线,主机和从机都挂在线上,通过控制引脚输出和输入状态的切换来完成双向通信。这根线平时处于“空闲状态”,由上拉电阻拉到高电平。所有通信动作都表现为高电平与低电平的交替切换,时序严格要求到微秒级别。

这里用生活化的方式打个比方:想象一根电话线,通话双方约定好了“先响三声、对方接起、然后按一长一短的节奏说话”。DHT11的通信就是这么规定的——主机先“打电话”(发出起始信号),DHT11“接听”(响应信号),然后按固定节奏把40位数据逐个“说”出来。整个过程中,节奏(时序)比内容本身更重要,因为每一位数据的含义是靠高低电平持续的时间长度来区分的。

3.2 完整通信时序拆解

整个通信过程分三个阶段:起始信号、响应信号、数据输出。

第一阶段,主机拉低数据线至少18毫秒,然后释放,这是告诉DHT11“我要开始读你了”。为什么非得18毫秒?因为DHT11的内部是低速RC振荡器,需要一个足够长的低电平来触发它的唤醒逻辑。实测下来20毫秒比较保险,超过30毫秒也没问题。

第二阶段,DHT11检测到起始信号后,会先拉低数据线约80微秒,然后拉高约80微秒,这是一个“应答脉冲”,意思是“我准备好了”。主机在这段时间要做的就是把GPIO切换成输入模式,然后开始监测电平变化。

第三阶段,DHT11连续输出40位数据。每一位的格式都相同:先拉低约50微秒,然后拉高,高电平持续的时间长短决定这一位是0还是1。数据手册上规定,高电平持续26~28微秒表示数据位“0”,持续70微秒表示数据位“1”。实际测量中,DHT11输出的0位高电平典型值在27微秒左右,1位高电平在70微秒左右,容差范围比较大,这给代码实现留了不少余地。

3.3 数据格式与校验原理

DHT11输出的40位数据具体组成是:8位湿度整数数据 + 8位湿度小数数据 + 8位温度整数数据 + 8位温度小数数据 + 8位校验和。对于这种只传整数的低端传感器,小数位通常读出来是0,所以实际有效数据就是湿度和温度的整数部分。

这里很多人有个误区,觉得40位数据很难处理,其实就是把8位作为一个字节,连续读5个字节。第1字节是湿度整数,第2字节是湿度小数,第3字节是温度整数,第4字节是温度小数,第5字节是校验和。校验规则很简单:前4个字节相加,取低8位,如果和第5字节相等,说明这次通信没被干扰,数据有效。这个校验思路非常经典,在很多通信协议里都能看到“累加和校验”的变体。

4. 核心代码实现(HAL库实战)

4.1 微秒级延时方案

HAL库自带的HAL_Delay只能精确到毫秒级,而DHT11的时序要求是微秒级,所以必须自己实现一个微秒延时。常见方案有两种:一是用SysTick定时器重写延时函数,二是用DWT(Data Watchpoint and Trace)模块。我推荐DWT方式,因为实现起来最简洁,不干扰SysTick,也不会影响HAL_Delay的正常工作。

// dwt_delay.c #include "dwt_delay.h" void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNT_EN_Msk; } void DWT_Delay_us(uint32_t us) { DWT->CYCCNT = 0; while (DWT->CYCCNT < us * (SystemCoreClock / 1000000U)); }

原理很直观:DWT->CYCCNT是CPU周期计数器,72MHz主频下1微秒就是72个周期。先把计数器清零,然后循环等待计数到us乘以每微秒周期数。为什么选DWT而不是简单空循环?因为C编译器在优化时会“自作聪明”地删掉没有副作用的空循环代码,而DWT计数循环依赖硬件寄存器状态,不容易被优化掉。

4.2 GPIO模式切换

DHT11通信过程中,GPIO引脚需要在推挽输出和上拉输入之间来回切换。在HAL库中,每次切换都要调用HAL_GPIO_Init重新配置GPIO结构体。频繁调用确实有轻微的延时开销,但DHT11的时序容差较大,约1~2微秒的开销不影响结果,我实测下来很稳。

#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_0 void DHT11_Pin_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); } void DHT11_Pin_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); }

GPIO速度为什么要选LOW不是HIGH?这里有个经验点:DHT11的单总线通常工作在低速状态,推挽输出的翻转速率没必要追求极速。如果设置成HIGH,反而容易在长线上产生振铃干扰,影响调试。嵌入式开发里有一个很重要的原则——够用就好,不要盲目堆高速配置。

4.3 DHT11驱动完整实现

下面是完整的DHT11读取函数,包含起始信号、响应检测、40位数据读取和校验。这个代码我整理过很多次,可读性和稳定性折中得比较好。

// dht11.c #include "dht11.h" uint8_t DHT11_Data[5] = {0, 0, 0, 0, 0}; void DHT11_Pin_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); } void DHT11_Pin_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); } uint8_t DHT11_ReadData(void) { uint8_t i, j; uint8_t temp = 0; // 主机起始信号 DHT11_Pin_Mode_Output(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 拉低至少18ms HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); DWT_Delay_us(30); // 释放后等待30us,让DHT11感知到上升沿 // 切换输入模式 DHT11_Pin_Mode_Input(); // 检测响应信号:先低后高各80us if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { return 0; // 数据线仍为高,DHT11没响应 } while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET); // 等待80us低电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET); // 等待80us高电平结束 // 读取40位数据 for (j = 0; j < 5; j++) { temp = 0; for (i = 0; i < 8; i++) { while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET); // 等待50us低电平结束 DWT_Delay_us(40); // 高电平持续40us后采样 if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { temp |= (0x80 >> i); // 高电平仍为高 → 数据位1 } while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET); // 等待高电平结束 } DHT11_Data[j] = temp; } // 校验和验证 if (DHT11_Data[4] == (DHT11_Data[0] + DHT11_Data[1] + DHT11_Data[2] + DHT11_Data[3])) { return 1; // 校验通过 } return 0; }

这段代码里最关键的判断逻辑在数据位读取部分。每一位的50微秒低电平结束后,我用DWT_Delay_us(40)先等40微秒,然后再采样电平。如果此时数据线还是高电平,说明这一位的高电平持续时间在40微秒以上,那必然是数据位“1”(因为“0”的高电平只有26~28微秒,等40微秒后早就变回低电平了)。这种先延时再采样的做法,规避了精确测量脉冲宽度的复杂度,是处理低速单总线设备时的常用技巧。

4.4 主程序与串口输出

读取逻辑写好后,主程序就很简单了。把温湿度数据通过串口打印出来,核心代码如下:

// main.c 片段 #include "main.h" #include "usart.h" #include "gpio.h" #include "dht11.h" #include "dwt_delay.h" #include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); DWT_Delay_Init(); while (1) { if (DHT11_ReadData() == 1) { printf("Humidity: %d.%d%%RH, Temperature: %d.%dC\r\n", DHT11_Data[0], DHT11_Data[1], DHT11_Data[2], DHT11_Data[3]); } else { printf("DHT11 read failed\r\n"); } HAL_Delay(1000); // DHT11采样周期1秒,读取间隔至少大于1秒 } }

注意读取间隔必须大于1秒。DHT11数据手册里明确写了“采样周期为1秒”,也就是说传感器每秒钟最多更新一次内部数据。如果读取间隔小于1秒,你读到的很可能还是上一次的数据,看起来就是“数据不变”或者“偶尔读失败”。我见过不少人在这上面犯迷糊,代码本身没毛病,单纯是读得太勤了。

关于printf重定向,上面的fputc函数在ARMCC编译器下有效。如果你用的是GCC编译器(比如STM32CubeIDE环境),需要换成:

int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }

我用表格把两种编译器的差异列出来,方便你对照:

项目ARMCC(Keil MDK)GCC(STM32CubeIDE)
重定向函数int fputc(int ch, FILE *f)int _write(int fd, char *ptr, int len)
需要包含头文件stdio.hsys/unistd.h
MicroLIB需要在魔术棒里勾选Use MicroLIB无需额外设置

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

5.1 问题速查表

我把这几年实操中遇到的高频问题整理成了一个表格,因为DHT11是低速器件,很多问题的根源都很类似,排查起来有迹可循。

现象可能原因解决办法
读到的数据全是0引脚没有上拉,DHT11无法拉高电平在DATA线上加4.7kΩ~10kΩ上拉电阻到VCC
一直返回read failed起始信号低电平时间不够,DHT11没被唤醒HAL_Delay(20)确保低电平维持20ms以上
数据校验和不通过时序采样点偏移,或线上干扰检查DWT延时准确性(SystemCoreClock是否等于72MHz),缩短杜邦线长度
温湿度读数不变读取频率大于1秒/次,读到的是缓存数据读取间隔至少保证1秒,用HAL_Delay(1000)
板子下载不了程序CubeMX里Debug没有选Serial WireBoot0跳线拉高后再擦除,或短接NRST复位引脚触发下载
偶尔读出来的温湿度跳变杜邦线接触不良或电源纹波大检查面包板插孔连接,尝试VCC接5V(DHT11模块支持3.3V~5V)
数据位识别错误输入模式没有配置上拉,引脚浮空GPIO_InitStruct.Pull设为GPIO_PULLUP

5.2 时序采样点的实战心得

关于那个40微秒的采样延时,我还想多说两句。DHT11数据位时序的判定核心是高电平宽度,理论上只要在两个时间点做判断就能区分0和1:高电平持续超过40微秒就按1处理,否则按0处理。为什么我在代码里用的是“延时40微秒再采样”而不是“循环测量高电平宽度直到变低,然后用计数器判断”?因为第一种方式更稳定,耗时固定,逻辑简单。第二种方式虽然也能用,但在中断密集或系统负载高的环境中,宽度的测量值容易受干扰。

实测下来,GPIO切换大约消耗1~2微秒,DWT_Delay_us函数本身也有几纳秒的误差,这些在DHT11宽裕的容差范围内完全不是问题。但有两点必须提醒:第一,读取DHT11时不要开抢占式的中断(尤其是SysTick中断),因为时序的关键在于连续电平监测,中间如果插入一段长中断处理,很容易错过边缘跳变;第二,不要在多个地方同时调用DWT_Delay_us,DWT->CYCCNT只有一个,如果你在另一个中断里也用DWT延时并清零计数器,会互相干扰。

5.3 硬件排查三板斧

代码逻辑没问题但就是读不出来的时候,我习惯按“先看电平、再看供电、最后看连接”的顺序排查。首先是电平——用示波器或逻辑分析仪抓DATA引脚的波形,起始信号有没有拉低20毫秒?响应信号的两段80微秒电平看不看得到?数据位的高电平宽度是27微秒还是70微秒?如果连起始信号都看不到,问题在主机端;如果起始信号正常但没有响应信号,问题在传感器端或上拉电阻。其次是供电——DHT11供电不足是很多“疑难杂症”的根源,特别是面包板上同时带多个模块时,VCC电压可能被拉到3V以下,有条件的话用万用表实测一下芯片电源引脚对地的电压。最后是连接——检查杜邦线是不是插歪了,面包板内部是不是有断路,这些看似低级的问题在快速原型验证阶段反而发生频率最高。

6. 进一步扩展的方向

DHT11只是温度采集的一个起点,跑通这个项目之后,想往哪个方向发展都会顺畅很多。如果你对显示感兴趣,可以接一块0.96寸OLED或1602LCD,把温湿度实时显示出来,这就是一个标准的桌面气象站了。如果你想做物联网,可以把数据通过ESP8266或ESP01S发送到云端平台,STM32作为采集节点上报温湿度,这是智能家居安防系统里最常见的场景之一。如果你觉得DHT11精度不够用,还可以无缝切换到DHT22(AM2302),两者通信协议兼容,代码几乎不用改,只是数据格式上温度的分辨率从1℃变成0.1℃。

另外,热词里提到了“国产替代”,原厂STM32F103C8T6这几年价格波动较大,很多项目已经切换到GD32F103C8T6等同封装兼容芯片。如果你的工程是用CubeMX生成的标准HAL工程,切换到GD32时基本可以直接编译烧录,这也是这套生态的一个红利——代码的硬件抽象层做得好,芯片替换带来的改动量非常小。

最后再分享一个小技巧:如果想把DHT11的读取放到中断或定时器里,建议在数据读取函数中临时关闭其他高优先级中断,读完再恢复。虽然DHT11时序容差大,但40微秒内的采样点如果被打断两次以上,误判概率会明显上升。我这里处理了十几年单片机,踩过的坑不少,但DHT11确实是我见过最“皮实”的传感器之一——只要你给的时序不离谱,它基本都会老实响应。跑通这个项目之后你会发现,所谓“驱动”其实没那么玄乎,无非就是按约定的节奏,把电平高低按时序走一遍而已。

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

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

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

立即咨询