
做嵌入式这几年我手里用得最多的环境传感器就是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十几元精度要求稍高的记录仪AHT20I2C±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输出的方波和你代码里设想的时序一一对应那种感觉比调通程序本身还过瘾。之后再遇到任何单总线设备你都不会慌了。