做嵌入式这几年,我手里用得最多的环境传感器就是DHT11。它便宜、容易买、接线也就三根,网上教程一大把,但真正在STM32上把它调明白,不少人还是卡在时序、校验、上拉电阻和调试器连接这些细节上。这篇文章我就围绕STM32和DHT11温湿度传感器,把从硬件接线、原理图设计到HAL库驱动实现,再到常见报错排查的完整过程,一起梳理一遍。不管你是刚学STM32的新手,还是已经用标准库写过一遍、想切到HAL库的老手,这篇都能给你一些可以直接抄走的东西。
1. 项目整体设计与选型思路
1.1 为什么DHT11依然是入门首选
很多人在选温湿度传感器时会纠结:DHT11精度一般,DHT22更好,AHT20又是I2C接口,SHT30更是性能怪兽,为什么还要用DHT11?
我的看法是,DHT11的价值不在性能,而在“学习过程”。它是单总线协议,只有一根数据线,时序要求又比DS18B20更宽松,非常适合用来理解“主机怎么通过拉高拉低电平来和传感器对话”。而且它便宜到可以随便折腾,不需要考虑心疼的问题。
从项目角度看,DHT11的测量范围是20%-90%RH、0到50摄氏度,精度分别是±5%RH、±2摄氏度。这个指标做环境监测、智能家居、实验室记录都够用,但要拿去当精密仪器用,它确实不合格。所以选它之前,先想清楚你是“学协议”还是“做产品”。
为了直观对比,我列一下三种常见温湿度方案:
| 传感器 | 接口 | 精度 | 价格 | 适合场景 |
|---|---|---|---|---|
| DHT11 | 单总线 | ±2°C / ±5%RH | 几元钱 | 学习、低成本环境监测 |
| DHT22 | 单总线 | ±0.5°C / ±2%RH | 十几元 | 精度要求稍高的记录仪 |
| AHT20 | I2C | ±0.3°C / ±2%RH | 几元到十几元 | 量产产品、尺寸受限的设备 |
DHT11在性能上打不过后两个,但它的单总线时序就是最好的教学素材。把DHT11调通了,你再去接触DHT22几乎零成本,因为协议几乎一样。而AHT20这种I2C设备,反而没有这种“把一个电平读透”的乐趣。
1.2 单总线协议与40bit数据帧拆解
DHT11的数据线只有一个GPIO,它既承担主机发送起始信号的任务,也承担传感器返回数据的任务。这个设计在通信协议里叫半双工,就像对讲机,同一时间只能有一方说话。
整个通信过程,主机先拉低总线至少18毫秒,再释放总线,这就是起始信号。DHT11检测到这个低电平后,会拉低80微秒,再拉高80微秒,告诉主机“我准备好了”,这就是应答信号。之后DHT11开始发数据。
数据是40个比特,格式是固定的:
- 8位湿度整数
- 8位湿度小数
- 8位温度整数
- 8位温度小数
- 8位校验和
校验和的计算方式是前四个字节相加,取低8位。比如湿度是45%、温度是26度,小数位都输出0,那校验和就是0x45 + 0x00 + 0x26 + 0x00 = 0x6B,接收端收到后也做同样的计算,如果结果不等于校验字节,这组数据就直接丢弃。
很多新手第一次看DHT11数据手册,会被那几个“26-28微秒、70微秒”的时序参数绕晕。其实理解方式很简单:每位数据开始都是50微秒低电平,然后是高电平。如果高电平持续26到28微秒,就是逻辑0;如果高电平持续约70微秒,就是逻辑1。判断方式就是测量高电平的持续时间。
搞清楚这个协议之后,代码怎么写其实就已经很清楚:发送起始信号,等待应答,然后循环40次逐位读取,每次测量高电平宽度,最后校验。
2. 硬件连接与原理图设计要点
2.1 引脚定义与典型接法
DHT11常见封装有三种引脚:VCC、DATA、GND,也有些模块会引出第四个引脚(NC,悬空即可)。供电范围是3.3V到5.5V,接到STM32的3.3V供电没问题,但数据线上必须加上拉电阻。
我在嘉立创画原理图的时候,习惯用一个4.7k欧姆的电阻,把DATA引脚上拉到VCC。原因是DHT11的DATA引脚本身是开漏输出,只能拉低,不能主动输出高电平,高电平依赖外部上拉电阻。如果省掉这个电阻,你大概率会读到一堆0或者随机跳变的数据。
STM32端的GPIO配置,我推荐直接配成推挽输出或者开漏输出。
如果你配的是推挽输出,读数据时需要不断切换GPIO方向,代码里要做模式切换,稍微麻烦一点。如果你配的是开漏输出,反而更简单:输出低电平就是拉低总线,输出高电平就是释放总线,由外部上拉电阻把电平拉高。读取的时候,直接读IDR寄存器就可以获取引脚电平,不用来回切换方向。
实际项目中我会在原理图上把DHT11单独用一个2.54mm排针引出,方便插拔。数据线旁边加一个100nF去耦电容,靠近DHT11的VCC引脚放置,可以滤掉电源上的高频噪声。这个电容不加也能工作,但加上之后数据稳定性会好一些。
2.2 嘉立创画原理图时容易踩的三个坑
我在嘉立创EDA里画DHT11模块不是一次就成功的,踩过几个比较典型的坑。
第一个坑是上拉电阻类型选错。有次我选了排阻,结果布线的时候发现封装和DHT11离得太远,走线绕了一大圈。后来改成单个0603封装电阻,离DHT11近一点,信号质量明显更好。DHT11这种低速单总线对走线长度不敏感,但越短越稳总没错。
第二个坑是电源符号连接。DHT11的VCC引脚我直接用了5V网络,但STM32的GPIO是3.3V,数据线上拉电阻又接到了5V,这会导致GPIO引脚承受5V电压。虽然很多STM32引脚是容忍5V的,但这不是所有型号都支持。稳妥的做法是:模块供电用5V,数据线上拉电阻接到3.3V,或者在数据线上串一个330欧姆的限流电阻。后来我都改成统一3.3V供电,省心。
第三个坑是封装选择。DHT11有两种常见封装,一种是四针直插,一种是六针贴片模块。画原理图之前一定要确认你手里实物是哪种,否则焊接的时候会发现封装对不上,返工非常浪费时间。
3. 基于HAL库的完整驱动实现
3.1 微秒级延时的三种实现,别用HAL_Delay硬扛
STM32 HAL库里最常用的延时函数是HAL_Delay(),但它最小单位是毫秒。DHT11要求微秒级延时,比如起始信号18毫秒可以用HAL_Delay,但读每一位时的高电平判断,30到40微秒的延时HAL_Delay完全无能为力。
我见过有人用空循环做延时,像这样:
for (uint32_t i = 0; i < 100; i++);如果在O0优化级别下还能勉强工作,一开O2优化,循环可能被编译器直接优化掉,或者耗时变短几倍,程序就彻底废了。所以微秒延时必须用定时器或者内核外设。
我推荐使用DWT实现微秒延时。DWT是Cortex-M内核里的一个调试监视单元,里面有一个CYCCNT计数器,每个内核时钟周期加一。用它做延时非常精准,而且不占用定时器和SysTick中断。
static void DWT_Delay_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 startTick = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - startTick) < ticks); }用DWT延时需要注意一点:SystemCoreClock必须在系统初始化之后被正确赋值,否则算出来的ticks就是错的。在STM32F103默认72MHz主频下,1微秒对应72个时钟周期。这个函数放在HAL_Init之后调用即可。
当然了,如果你不想用DWT,也可以用基本定时器产生1微秒的时基,再用查询方式延时。但那样要额外初始化定时器,代码量更大。用DWT最省事,STM32F1、F4、F7、H7系列的内核都支持。
3.2 起始信号与应答检测代码
有了微秒延时之后,就可以写DHT11的驱动了。我把GPIO初始化配成开漏输出,这样读数据时不需要切换模式。
void DHT11_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); }我用的引脚是PB0,接的是DHT11的DATA引脚。如果你在别的引脚上,改一下宏定义就行。开漏输出模式下,GPIO输出寄存器写1代表释放总线,写0代表拉低总线,读取电平直接调用HAL_GPIO_ReadPin。
接下来是发送起始信号并检测应答:
uint8_t DHT11_Start(void) { // 主机拉低总线至少18ms HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); DWT_Delay_us(20000); // 释放总线,上拉电阻拉高 HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); DWT_Delay_us(30); // 检查应答:传感器拉低80us if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET) { // 等待低电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET); // 等待高电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET); return 0; } return 1; }这里有几个细节值得展开。
第一个细节,起始信号的低电平时间,我习惯给20毫秒,比手册要求的18毫秒稍微多留点余量。这不是为了磨蹭,而是因为DHT11上电后需要一段稳定时间,有时候前一次读取刚结束,传感器还没完全复位,20毫秒更保险。
第二个细节,释放总线后延时30微秒再检测应答,这段时间是给传感器反应用的。如果你延时太短,可能读到的是主机自己拉高的电平,误判成没有应答。延时太长也不行,有可能已经错过应答信号的低电平窗口。
第三个细节,所有while等待都建议加上超时保护。我在教程里故意先展示不带超时的版本,逻辑更清楚。但实际工程里,如果传感器没接好,程序会卡死在while里,这对产品来说是灾难。后面我会专门讲加超时的改进方法。
3.3 按位读取和完整数据帧解析
应答结束之后,DHT11会连续发送40位数据。每一位的开始是50微秒低电平,然后是高电平。判断逻辑很简单:等待低电平结束,然后延时40微秒,再读引脚。如果还是高电平,说明是1;如果已经变低,说明是0。
uint8_t DHT11_ReadBit(void) { uint8_t data = 0; // 等待50us低电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_RESET); // 延时40us,此时判断电平 DWT_Delay_us(40); if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET) { data = 1; } // 等待高电平结束,准备读取下一位 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == GPIO_PIN_SET); return data; }为什么是40微秒而不是30微秒?因为DHT11的逻辑0高电平为26到28微秒,逻辑1高电平约为70微秒。如果在高电平持续30微秒的时候去读,逻辑0可能还没结束,容易误判。延时到40微秒,逻辑0已经结束并变成低电平,而逻辑1还在高电平阶段,这样区分度最好。这个参数我在逻辑分析仪上验证过,40微秒比30微秒稳定很多。
但有一种情况要注意:如果传感器响应慢,或者总线电容太大导致边沿变缓,40微秒可能不够。这时可以自查一下上拉电阻是不是选太大了。上拉电阻越大,电平上升越慢,如果用了10k甚至更大的阻值,建议换成4.7k。
接下来是逐字节读取:
uint8_t DHT11_ReadByte(void) { uint8_t value = 0; for (int i = 0; i < 8; i++) { if (DHT11_ReadBit()) { value |= (0x80 >> i); } } return value; }完整读取函数把五个字节都收下来,然后做校验:
#define DHT11_DATA_OK 0 #define DHT11_DATA_ERROR 1 uint8_t DHT11_ReadData(uint8_t *humidity_int, uint8_t *humidity_dec, uint8_t *temp_int, uint8_t *temp_dec) { uint8_t buf[5] = {0}; if (DHT11_Start() != 0) { return DHT11_DATA_ERROR; } for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } if (buf[4] != ((buf[0] + buf[1] + buf[2] + buf[3]) & 0xFF)) { return DHT11_DATA_ERROR; } *humidity_int = buf[0]; *humidity_dec = buf[1]; *temp_int = buf[2]; *temp_dec = buf[3]; return DHT11_DATA_OK; }验证一下校验逻辑。假设buf[0]=0x2D、buf[1]=0x00、buf[2]=0x1A、buf[3]=0x00,相加得0x47,如果buf[4]也是0x47,说明这帧数据有效。任何一位在传输中出错,校验和几乎不可能凑巧匹配。
3.4 串口打印与数据校验
拿到数据之后,最直观的方式是通过串口打印到电脑上。我用的是UART1,在CubeMX里把USART1配置成异步模式,波特率115200,然后重定向printf。
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; }主函数里的调用:
int main(void) { HAL_Init(); SystemClock_Config(); DWT_Delay_Init(); DHT11_Init(); uint8_t hum_int = 0, hum_dec = 0; uint8_t temp_int = 0, temp_dec = 0; while (1) { if (DHT11_ReadData(&hum_int, &hum_dec, &temp_int, &temp_dec) == DHT11_DATA_OK) { printf("Humidity: %d.%d%%RH, Temperature: %d.%dC\r\n", hum_int, hum_dec, temp_int, temp_dec); } else { printf("DHT11 read error\r\n"); } HAL_Delay(2000); } }有一点要提醒:DHT11连续读取的最短间隔建议在2秒以上。原因很简单,传感器内部测量一次需要一定时间,连续快速读取容易得到上一次的缓存值。官方手册虽然没有写明最短间隔,但我的实测经验是2秒间隔最稳定。有些项目为了界面刷新快,用500毫秒间隔,数据也没大问题,但确实会出现前几次读到的都是旧值的情况。
4. 常见问题与排查技巧实录
4.1 最影响心态的“error: no stm32 target found!”
用ST-LINK下载程序时,St-Link Utility或者Keil里报出“error: no stm32 target found!”,这个问题我看过太多次了。它跟DHT11代码本身没有关系,而是芯片根本没连上调试器。
我排这个问题的顺序是固定的:
第一步,检查SWDIO和SWCLK两根线,这两个引脚是烧录的关键,不能接反。STM32最小系统板上一般有标号,接反的概率其实不低。
第二步,检查目标板供电。SWD接口不负责给芯片供电,目标板必须单独上电。如果供电不足或者电源没开,调试器自然找不到目标。
第三步,检查复位电路。有些开发板复位电容太大,会导致SWD连接不稳定。如果条件允许,可以在软件里设置连接方式为“Connect under Reset”,让调试器一直拉低复位脚再连接。
第四步,检查芯片是否被读保护。如果是二手芯片或者之前烧过含读保护的程序,SWD可能被锁定。这种情况用STM32CubeProgrammer连接时,需要选择连接模式为复位模式,然后解除读保护。
第五步,也是最容易被忽视的:ST-LINK的固件版本太旧。我遇到过电脑Win10系统更新之后,老版本ST-LINK驱动和固件不兼容,导致目标搜索失败的情况。用STM32CubeProgrammer更新一下ST-LINK固件就好了。
4.2 关于ST-LINK驱动与虚拟串口叹号
很多人插上ST-LINK后,发现设备管理器里ST-LINK的虚拟串口选项带黄色叹号,这种情况基本是驱动问题。WIN10、WIN11一般会自动安装驱动,但自动安装的驱动有时会和ST官方驱动冲突。
我的处理办法是,先卸载设备管理器里带叹号的设备,然后去ST官网下载STM32 ST-LINK Utility或者STM32CubeProgrammer。安装后驱动会自动装好,虚拟串口normally会显示成“STMicroelectronics Virtual COM Port”。如果还是不认,拔插USB线,换个USB口再试,尽量插主板后置USB口,不要用前置USB Hub。
还要注意,不是所有ST-LINK都带虚拟串口功能。有些精简版ST-LINK只有SWD下载功能,没有虚拟串口。如果你需要串口打印,建议买官方V2版本或明确标明带虚拟串口功能的型号。
4.3 温湿度全0/全255/固定不变的排查顺序
数据读回来全0,或者全255,是DHT11最常见的故障现象。按我经验,八成以上不是代码问题,而是硬件接线或者上拉电阻问题。
先看接线:DATA引脚是否接到了正确的GPIO。很多人软件里写的是PB0,实物却接到了PB1,数据当然不对。
再看上拉电阻:如果DATA引脚悬空或者没有上拉,开漏输出的传感器没办法输出高电平,读回的全是0。这时候用万用表量一下DATA引脚电压,应该能看到缓慢变化,正常情况下待机时是3.3V。如果是0V,大概率上拉电阻没焊好或者接错了。
然后看供电:DHT11供电低于3.3V时,传感器可能不工作或者输出异常。如果模块上带了电源指示灯,看灯是否正常点亮。
排除硬件问题之后再看代码时序。重点检查两处:一是起始信号的低电平时长是否超过了18毫秒,我通常给20毫秒;二是释放总线后的等待时间是否在20到40微秒之间。如果这里太短,可能读不到应答信号;太长则可能会漏掉应答。
还有一种情况是数据固定在某个值,比如永远是30度、50%RH不变。这通常是传感器坏了。DHT11内部用的是湿敏电容和热敏电阻,在湿度较高或者受潮过度的环境里存放,时间长了精度会漂移。我有个项目里用过一批放了两年的DHT11,三分之一读出来的湿度比实际值高10%以上,最后只能整批换掉。
4.4 读到的数据偶尔跳变怎么办
数据能读回来,但偶尔跳变,比如湿度从60%突然跳到30%,这一般不是传感器坏了,而是通信过程受到干扰。
最常见的干扰来自程序里的中断。如果定时器中断频率很高,中断服务函数占用了大量时间,就会导致DHT11时序中途被拉长,数据位判断出错。解决办法有两种:一是读取DHT11期间暂时关闭中断,读完之后再开启;二是把DHT11的读取放在一个不会被高优级中断打断的任务里。
__disable_irq(); ret = DHT11_ReadData(&hum_int, &hum_dec, &temp_int, &temp_dec); __enable_irq();这个是粗暴但有效的办法。如果项目里用了FreeRTOS,建议把DHT11读取放进临界区,但临界区时间要尽量短,不能影响系统实时性。更好的方案是改成状态机读取,把每一次电平变化当成状态迁移来解析,不过这会让代码复杂度上一个台阶,新手阶段用关中断的方式问题不大。
另外一个跳变来源是电源纹波。如果给DHT11供电的3.3V电感和芯片共用,电机、继电器一动作,电压跌落几毫秒,DHT11内部测量就会出问题。解决办法是给DHT11加一个10uF电解电容和100nF陶瓷电容并联滤波,或者干脆用单独的LDO供电。
还有一点,DHT11的线不能太长。我用1米以上杜邦线接过一个DHT11,高电平被线间电容拖慢,数据基本没法看。短线、就近安装是单总线设备使用的基本准则。
5. 实际项目里的进阶用法与个人体会
5.1 低功耗场景,别让DHT11拖着整机电流
如果你在做电池供电的设备,DHT11的功耗是个大问题。它工作的时候电流在0.5到2.5毫安左右,虽然不高,但单片机进入休眠后,如果DHT11还挂在VCC上,它会持续消耗电流。
低功耗项目的做法是,用MOS管或者单片机的GPIO直接给DHT11的VCC供电。只在需要测量的时候,先把VCC拉高,等200毫秒让传感器稳定,然后执行一次读取,读完立刻把VCC拉低,整个测量周期控制在几百毫秒内。这样平均电流可以压到非常低。
void DHT11_PowerOn(void) { HAL_GPIO_WritePin(DHT11_POWER_PORT, DHT11_POWER_PIN, GPIO_PIN_SET); HAL_Delay(300); } void DHT11_PowerOff(void) { HAL_GPIO_WritePin(DHT11_POWER_PORT, DHT11_POWER_PIN, GPIO_PIN_RESET); HAL_Delay(10); }需要注意,DHT11上电之后不能马上读,要给它一个稳定时间,至少300毫秒。否则第一次读取大概率失败,第二次读取才会正常。很多人在低功耗项目里用DHT11,第一次读失败就以为程序写错了,其实只是上电时序问题。
5.2 从DHT11到I2C传感器:什么时候该换
DHT11的定位很明确:学习、验证、低成本。实际产品如果追求稳定和精度,我更推荐AHT20或者SHT30这类I2C接口的传感器。
原因不复杂:单总线协议对时序要求严格,必须用微秒级延时,很容易受到中断影响;而I2C是标准总线协议,STM32的硬件I2C外设可以直接接管,省心很多。AHT20现在价格也不贵,精度比DHT11高一个档次,温度精度±0.3摄氏度,湿度精度±2%RH,体积还小。
如果你是从DHT11转AHT20,代码改动并不大。先学DHT11搞懂传感器通信是怎么回事,再用硬件I2C发现世界突然清净了,这个学习曲线我觉得非常合理。反过来直接上手AHT20,很多人连起始信号、ACK应答是什么都不理解,后面查问题会很痛苦。
5.3 最后一点个人体会
我在不少项目里用过DHT11,它确实不是性能最强的传感器,但每次调试它,我都能重新感受到嵌入式开发的本质:精度来自对时序和电气的尊重。
有些人写DHT11驱动,能跑起来之后就再也不管时序了。等到换一个环境温度更低、或者线的距离更长一点,程序就出问题,开始怀疑硬件、怀疑芯片。其实问题往往还是在上拉电阻、延时精度和电源噪声这些基础环节。
所以我建议你把今天这篇里的每一个解释都亲手验证一遍。用逻辑分析仪看看起始信号和应答波形,用示波器看看数据位的电平宽度。当你亲眼看到DHT11输出的方波和你代码里设想的时序一一对应,那种感觉比调通程序本身还过瘾。之后再遇到任何单总线设备,你都不会慌了。