☰
STM32F103标准库驱动DHT11:单总线时序与稳定读取
2026/10/30 10:21:16 网站建设 项目流程

简介:面向嵌入式初学者与STM32开发者的DHT11驱动工程,基于STM32F103最小系统板和标准库编写,重点解决单总线温湿度传感器的GPIO方向切换、微秒级延时、40位数据解析与校验等实操问题。压缩包共211个文件、约5.75MB,以50个.h头文件和45个.c源文件为核心,另含.o、.crf等编译中间文件及hex、axf、map、uvprojx等工程与烧录产物;驱动层与硬件配置相对分离,修改数据引脚和延时接口即可迁移到同类F103板卡。工程内还带有串口、OLED等常用外设代码,便于在最小系统板上直接联调。已有955人学习下载,读者可据此对照标准库GPIO配置、启动信号与位判别时序,快速搭建温湿度采集并排查通信失败问题。

1. 从最小系统板跑通 DHT11:标准库驱动先解决“能读”而不是“好看”

手里只有一块 STM32F103 最小系统板和一只四脚 DHT11 时,最怕的不是代码多,而是时序差几微秒就读回 0 或 255。这份 DHT11 驱动走标准库路线,把 GPIO 方向切换、SysTick 微秒延时、40 位数据校验压在一个很小的模块里,接线只要 VCC、GND、DATA 三根线,换引脚时改几个宏就能移植。它适合刚把最小系统板跑起来、想先把温湿度读稳的嵌入式新手,也适合需要把 DHT11 塞进旧标准库工程的单片机从业者。先跑通“能读”,再谈滤波和低功耗,顺序反了容易在时序玄学里打转。

2. 单总线时序拆到微秒:DHT11 的 40 位数据怎么从 GPIO 里抠出来

DHT11 不是 I2C,也不是 SPI,它只有一根 DATA 线,主机和从机轮流拉低拉高。标准库工程里最容易翻车的地方,是把“读数据”写成“读电平”:DHT11 的 0 和 1 不是静态电平,而是高电平持续时间不同。先把时序拆开,再落到 GPIO 读位上,代码才不会变成黑匣子。

2.1 起始信号与响应窗口:18ms、20-40us、80us 分别卡在哪

主机先拉低 DATA 线至少 18ms,常见做法是拉低 20ms,给足余量。然后释放总线,释放后 20~40us 内 DHT11 会拉低 80us 作为响应,再拉高 80us,接着进入第一位数据的 50us 低电平。这里的关键是“释放”不是“输出高电平一直顶着”,而是把 GPIO 切成输入模式,让外部上拉电阻把线拉高。很多读不到响应的现场,问题就出在释放后没有切输入,或者上拉电阻根本没焊。

阶段典型时间说明
主机起始低电平≥18ms,常用20ms主机主动拉低
主机释放高电平20~40us,常用30us切输入,靠上拉拉高
DHT11响应低电平80us从机拉低
DHT11响应高电平80us从机拉高
每位起始低电平50us每位都以低电平开头
位0高电平26~28us短高电平
位1高电平70us长高电平

提示:DHT11 的 DATA 线必须外接 4.7k~10k 上拉电阻,MCU 内部上拉只能兜底,长线或 3.3V 供电时更容易失败。

起始信号可以单独封装成一个函数,返回值只表达“有没有等到响应”,不急着读数据。这样后面排查时能区分“没响应”和“响应了但校验错”,现场定位会快很多。下面的代码用标准库的 GPIO_Init 切换方向,输出用推挽,输入用上拉,释放总线后等三次边沿:等低、等高、等低。

#define DHT11_PORT GPIOA #define DHT11_PIN GPIO_Pin_0 #define DHT11_RCC RCC_APB2Periph_GPIOA static void DHT11_SetOutput(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(DHT11_RCC, ENABLE); gpio.GPIO_Pin = DHT11_PIN; gpio.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出,主机主动拉低 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, &gpio); } static void DHT11_SetInput(void) { GPIO_InitTypeDef gpio; gpio.GPIO_Pin = DHT11_PIN; gpio.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入,释放总线 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, &gpio); } static uint8_t DHT11_WaitLevel(uint8_t level, uint16_t timeout_us) { while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) != level) { if (--timeout_us == 0) { return 1; // 超时 } delay_us(1); } return 0; }

这段代码的逻辑是先把 DATA 线切成输出,主机才有能力拉低;释放时切成上拉输入,外部上拉和内部上拉一起把线拉高。DHT11_WaitLevel 的 level 参数表示要等待的电平,timeout_us 是超时上限,单位微秒。超时值不要设得太小,80us 响应窗口加上中断抖动,给 100~200us 比较稳。delay_us 必须是真的微秒延时,不能拿空循环凑,后面第 3 章会把这个延时落实。

起始信号本身不读 40 位数据,只负责把总线从空闲状态带进响应状态。代码里先拉低 20ms,再拉高 30us,然后切输入,接着等低、等高、等低。第三次等低是在等 80us 响应高电平结束,也就是第一位数据的 50us 低电平开始。这个顺序写错,后面读出来的字节会整体错位。

static uint8_t DHT11_Start(void) { DHT11_SetOutput(); GPIO_ResetBits(DHT11_PORT, DHT11_PIN); delay_ms(20); // 至少18ms,留2ms余量 GPIO_SetBits(DHT11_PORT, DHT11_PIN); delay_us(30); // 20~40us,常用30us DHT11_SetInput(); if (DHT11_WaitLevel(0, 200) != 0) return 1; // 等DHT11拉低 if (DHT11_WaitLevel(1, 200) != 0) return 1; // 等80us低结束 if (DHT11_WaitLevel(0, 200) != 0) return 1; // 等80us高结束 return 0; }

DHT11_Start 返回 0 表示已经看到响应,返回 1 表示超时。这里没有判断“响应低电平持续多久”,因为标准库轮询很难精确测宽,先把边沿顺序等对,读位阶段再用采样点区分 0 和 1。20ms 和 30us 是两个可以调的参数:20ms 不要低于 18ms,30us 不要贴着 20us 下限,给晶振和中断留一点空间。等低、等高、等低的超时给 200us,比 80us 宽,能容忍一点中断抖动。

2.2 位判定与字节拼接:50us 低电平之后的 26us 和 70us

进入读位阶段后,每一位都以 50us 低电平开头,然后是 26~28us 的高电平表示 0,70us 的高电平表示 1。常见做法是等 50us 低电平结束,延时 40us 左右再采样:此时位 0 的高电平已经结束,读到低;位 1 的高电平还在,读到高。采样太早,0 和 1 都可能是高;采样太晚,1 也可能已经变低。40us 落在 28us 和 70us 中间,是比较稳的折中。

static uint8_t DHT11_ReadBit(uint8_t *bit) { if (DHT11_WaitLevel(1, 200) != 0) return 1; // 等50us低电平结束 delay_us(40); // 0的高电平约26~28us,1约70us *bit = GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN); if (DHT11_WaitLevel(0, 200) != 0) return 1; // 等高电平结束 return 0; }

DHT11_ReadBit 的 bit 参数是输出,读到 0 或 1。函数先等低电平结束,也就是等上升沿;延时 40us 后采样;再等高电平结束,为下一位的 50us 低电平做准备。这里的 40us 是经验值,不是 DHT11 手册规定的固定采样点。不同主频、不同优化等级、不同延时实现都会影响这个值,逻辑分析仪抓一次波形最稳。没有逻辑分析仪时,可以用 30us、40us、50us 各试一轮,看哪一组校验成功率最高。

读字节时,DHT11 是高位在前。每读一位,把结果左移一位再或进去,循环 8 次得到一个字节。读 5 个字节就是 40 位:湿度整数、湿度小数、温度整数、温度小数、校验和。DHT11 的小数部分通常为 0,但协议里仍然占一个字节,解析时不要跳过,否则校验和会对不上。

static uint8_t DHT11_ReadByte(uint8_t *data) { uint8_t i, bit, val = 0; for (i = 0; i < 8; i++) { if (DHT11_ReadBit(&bit) != 0) return 1; val = (uint8_t)((val << 1) | bit); } *data = val; return 0; }

DHT11_ReadByte 的 data 参数是输出字节,返回值 0 表示成功,1 表示某一位超时。val 先清零,每读一位左移,保证第一次读到的最高位最终落在 bit7。DHT11 的 40 位数据没有起始位和停止位,完全靠前面的响应窗口对齐;响应窗口一旦等错,后面 8 次循环读出来的就是错位字节,校验和会直接暴露问题。

2.3 校验与温湿度换算:第五字节不是“随便加的”

5 个字节读完后,前 4 个字节相加取低 8 位,应该等于第 5 个字节。校验不过时不要急着做温湿度换算,先把原始 5 个字节打印出来。现场常见的是湿度整数正常、温度整数漂移,或者校验和偶尔差 1,这类问题多半不是 DHT11 坏了,而是读位阶段被中断打断,某一位的高电平采样点偏了。

typedef enum { DHT11_OK = 0, DHT11_ERR_TIMEOUT, DHT11_ERR_CHECKSUM } DHT11_Status; typedef struct { uint8_t humi_int; uint8_t humi_dec; uint8_t temp_int; uint8_t temp_dec; } DHT11_Data; DHT11_Status DHT11_Read(DHT11_Data *out) { uint8_t buf[5], i; if (DHT11_Start() != 0) { return DHT11_ERR_TIMEOUT; } for (i = 0; i < 5; i++) { if (DHT11_ReadByte(&buf[i]) != 0) { return DHT11_ERR_TIMEOUT; } } if ((uint8_t)(buf[0] + buf[1] + buf[2] + buf[3]) != buf[4]) { return DHT11_ERR_CHECKSUM; } out->humi_int = buf[0]; out->humi_dec = buf[1]; out->temp_int = buf[2]; out->temp_dec = buf[3]; return DHT11_OK; }

这段代码把“起始、读 40 位、校验、解析”串成一次完整读取。out 参数是调用者传入的结构体指针,函数不负责分配内存。buf[0] 是湿度整数,buf[1] 是湿度小数,buf[2] 是温度整数,buf[3] 是温度小数,buf[4] 是校验和。DHT11 的温度范围通常是 0~50℃,湿度 20%~90%RH,超出这个范围的读数先怀疑时序和供电,不要直接当成真实环境值。

校验通过后,湿度值可以用 humi_int 加 humi_dec 的十分之一拼出来,温度同理。DHT11 的小数位多数时候是 0,显示时保留整数就够;如果项目要求小数点,做显示换算时再拼,不要在驱动里提前乘 10,否则后面换 DHT22 或别的传感器又要改一遍。驱动层只负责“原始字节 + 校验状态”,业务层再做滤波和展示,移植时边界清楚。

3. 标准库工程落地:GPIO、SysTick 与方向切换的五个配置点

标准库工程里,DHT11 驱动能不能稳定跑,往往不取决于读位函数写得多漂亮,而取决于五个配置点:时钟有没有开、引脚模式切得对不对、微秒延时准不准、读 40 位时有没有被中断打断、读取间隔够不够。下面按工程落地顺序拆开,每个点都给可抄的代码和参数说明。

3.1 时钟与引脚初始化:推挽输出和上拉输入的切换顺序

STM32F103 的 GPIO 挂在 APB2 上,用标准库要先开 RCC_APB2PeriphClockCmd。DHT11 的 DATA 线在输出和输入之间来回切,初始化时不用纠结“到底配输出还是输入”,因为每次读取前都会重新配。我一般会在 DHT11_Init 里先配一次上拉输入,让总线空闲时保持高电平,真正拉低前再切输出。这样上电后不会出现 DATA 线悬空、DHT11 被反复触发的现象。

void DHT11_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(DHT11_RCC, ENABLE); gpio.GPIO_Pin = DHT11_PIN; gpio.GPIO_Mode = GPIO_Mode_IPU; // 空闲时上拉输入 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, &gpio); }

DHT11_Init 只做一件事:开时钟,把 DATA 线置成上拉输入。GPIO_Mode_IPU 是内部上拉输入,外部 4.7k~10k 上拉仍然要焊。GPIO_Speed_50MHz 对 DHT11 这种低速单总线不是必须,但标准库的 GPIO_Init 需要填,统一填 50MHz 省得后面改。初始化不拉低总线,避免上电瞬间给 DHT11 发一个不完整起始信号。

输出模式的配置放在 DHT11_SetOutput 里,输入模式放在 DHT11_SetInput 里,两个函数都调用 GPIO_Init。有人为了省事直接改 GPIOx->CRL 寄存器,速度确实快,但换引脚时要算引脚号对应的 4 位配置位,移植性差。标准库的 GPIO_Init 多几条指令,对 DHT11 的毫秒级起始信号没有影响,对读位阶段的微秒级延时有一点影响,但读位延时靠 SysTick,不靠 GPIO_Init 的耗时。

注意:每次读取前都要确保 DHT11_SetOutput 里重新开时钟或至少保证时钟已开,不要假设别的模块已经开过 GPIOA 时钟。换到 GPIOB、GPIOC 时,RCC 宏也要跟着换。

引脚选择上,最小系统板常见把 DHT11 接在 PA0、PA1、PB5 这类空闲脚。避开 JTAG 占用的 PA13、PA14、PA15、PB3、PB4,也避开串口下载脚 PA9、PA10。DATA 线不要和电机、继电器、蜂鸣器共用一组电源,DHT11 对电源纹波敏感,电机一转就读 255 的现场并不少见。接线短一点,杜邦线超过 20cm 时把上拉电阻靠近 DHT11 一端放。

3.2 微秒延时:SysTick 轮询写法与中断写法的边界

DHT11 的读位窗口是几十微秒,delay_us 不准,后面全白搭。标准库工程里常见两种延时:SysTick 轮询和 SysTick 中断。轮询写法不依赖中断,读位期间不会被 SysTick 中断打断,适合 DHT11 这种时序敏感的场合。中断写法会给系统提供一个毫秒时基,但 DHT11 读位时如果正好进 SysTick 中断,40us 采样点可能被推后,校验和就会偶尔失败。

static uint32_t fac_us; void Delay_Init(void) { fac_us = SystemCoreClock / 1000000; SysTick->LOAD = 0xFFFFFF; SysTick->VAL = 0; SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk; } void delay_us(uint32_t us) { uint32_t ticks = us * fac_us; uint32_t told = SysTick->VAL; uint32_t tnow, tcnt = 0; while (1) { tnow = SysTick->VAL; if (tnow != told) { if (tnow < told) { tcnt += told - tnow; } else { tcnt += told + (SysTick->LOAD - tnow); } told = tnow; if (tcnt >= ticks) { break; } } } } void delay_ms(uint32_t ms) { while (ms--) { delay_us(1000); } }

Delay_Init 里 fac_us 是 1 微秒对应的 SysTick 计数,72MHz 下是 72。SysTick->LOAD 设成 0xFFFFFF,用最大重装载值,delay_us 自己累计跨重装载的计数。delay_us 的 us 参数是一次延时的微秒数,ticks 是目标计数;told 记录上一次 VAL,tnow 是当前 VAL,tcnt 累计走过的计数。delay_ms 用循环调 delay_us,不要直接算毫秒计数,避免一次延时超过 SysTick 的 24 位范围。

这套延时是轮询,不产生 SysTick 中断,所以不会在 DHT11 读位中间插一脚。代价是没有系统毫秒时基,如果工程里还要用 SysTick 做别的定时,可以把 DHT11 的延时改成 TIM 定时器轮询,或者读 40 位期间临时关中断。不同主频下,SystemCoreClock 必须是实际主频,改了 PLL 没更新这个变量,delay_us 会整体偏快或偏慢,DHT11 直接读回 0 或 255。

提示:用逻辑分析仪测 DHT11_Start 里拉低时间,20ms 应该看到约 20ms;如果明显偏短,先查 SystemCoreClock 和 fac_us。

3.3 读一个字节的临界区:关中断不是万能药

DHT11 读完 40 位大约 4ms 左右,这段时间如果被串口接收中断、定时器中断打断,某一位的高电平采样点就会偏。常见做法是在 DHT11_Read 前后关中断,读完再开。关中断能解决大部分抖动,但不是万能药:关太久会影响串口接收和系统响应,关太短又护不住整个读位过程。我的习惯是只包住“起始信号之后的 40 位读取”,起始的 20ms 拉低不关中断,因为那段时间不怕打断。

DHT11_Status DHT11_Read_NoIrq(DHT11_Data *out) { DHT11_Status st; __disable_irq(); st = DHT11_Read(out); __enable_irq(); return st; }

DHT11_Read_NoIrq 把 DHT11_Read 包在 __disable_irq 和 __enable_irq 之间,out 参数原样传给底层读取。这个函数适合调试阶段验证“是不是中断打断导致校验失败”。如果关中断后成功率明显上升,说明工程里有中断在抢时间,下一步不是长期关中断,而是调整中断优先级或缩短中断服务函数。长期关中断会带来串口丢字节、按键不响应,属于用后悔药换稳定,量产工程要慎重。

另一种做法是给 DHT11 读位期间提高自己的中断优先级,或者把串口接收改成 DMA。对最小系统板验证阶段,关中断最直接;对多任务工程,优先保证 DHT11 读位不被高优先级中断打断,同时把其他中断服务函数压短。临界区不是越长越好,能包住 40 位读取就够,起始信号的 20ms 和读取间隔的 1s 都不需要关中断。

4. 把驱动做成可移植模块:三个宏和一层接口就够

同一份 DHT11 代码,有人换块板子就要重写一遍,问题通常不是时序,而是引脚、时钟、延时全写死在函数里。把驱动拆成“宏配置 + 接口层 + 延时适配”三层,换 STM32F103 的最小系统板只改三行宏,换主频只改 Delay_Init,读业务不用动。下面这套结构不追求大而全,只保留 DHT11 真正需要的部分。

4.1 头文件抽象:换引脚只改三行宏

头文件里放三件事:引脚宏、状态码、数据结构。引脚宏至少包含端口、引脚号、端口时钟。有人还会把上拉电阻有无做成宏,方便在内部上拉和外部上拉之间切换。状态码把“超时”和“校验错”分开,调用者才能决定是重试还是丢弃。数据结构只放原始字节,不放浮点数,避免不同编译器对浮点格式的处理差异。

#ifndef __DHT11_H #define __DHT11_H #include "stm32f10x.h" #define DHT11_PORT GPIOA #define DHT11_PIN GPIO_Pin_0 #define DHT11_RCC RCC_APB2Periph_GPIOA #define DHT11_EXT_PULLUP 1 // 外部4.7k~10k上拉,必须为1 typedef enum { DHT11_OK = 0, DHT11_ERR_TIMEOUT, DHT11_ERR_CHECKSUM } DHT11_Status; typedef struct { uint8_t humi_int; uint8_t humi_dec; uint8_t temp_int; uint8_t temp_dec; } DHT11_Data; void DHT11_Init(void); DHT11_Status DHT11_Read(DHT11_Data *out); DHT11_Status DHT11_Read_NoIrq(DHT11_Data *out); #endif

DHT11_PORT、DHT11_PIN、DHT11_RCC 三个宏决定驱动挂在哪根脚上,换到 PB5 就把三行一起改,不要只改 PIN 不改 RCC。DHT11_EXT_PULLUP 是给人看的提醒,代码里不一定要参与编译,但它能防止后来的人把外部上拉删掉。DHT11_Status 的枚举值从 0 开始,DHT11_OK 为 0,调用者可以写 if (st != DHT11_OK) 统一处理错误。DHT11_Data 用 uint8_t 存四个原始字节,DHT11 的小数位多数为 0,仍然保留,校验和解析时不能省。

头文件不要包含 DHT11.c 里的静态函数声明,读位、读字节、等电平这些内部函数用 static 关在 .c 里。对外只暴露初始化、读取、带临界区读取三个接口。换传感器时,比如从 DHT11 换到 DHT22,接口层可以保持 DHT11_Read 的名字不变,内部换协议,业务层少改代码。名字叫 DHT11 但结构留出扩展空间,比一开始就写死“读湿度”“读温度”两个函数更好移植。

4.2 接口层设计:初始化、读取、错误码分开

接口层的核心是“一次读取返回一个明确状态”,不要在读取函数里直接 printf 或点亮 LED。初始化只负责 GPIO 和延时,读取只负责起始、40 位、校验、填结构体。错误码分三层:超时表示没等到响应或某一位没读完,校验错表示 40 位读完了但和对不上,OK 表示数据可用。调用者拿到校验错可以重试一次,拿到超时可以先查接线,处理策略不一样。

DHT11_Status DHT11_Read(DHT11_Data *out) { uint8_t buf[5], i; if (out == 0) { return DHT11_ERR_TIMEOUT; // 空指针按错误处理,避免硬 fault } if (DHT11_Start() != 0) { return DHT11_ERR_TIMEOUT; } for (i = 0; i < 5; i++) { if (DHT11_ReadByte(&buf[i]) != 0) { return DHT11_ERR_TIMEOUT; } } if ((uint8_t)(buf[0] + buf[1] + buf[2] + buf[3]) != buf[4]) { return DHT11_ERR_CHECKSUM; } out->humi_int = buf[0]; out->humi_dec = buf[1]; out->temp_int = buf[2]; out->temp_dec = buf[3]; return DHT11_OK; }

这段接口层代码先判空指针,再走起始信号,再读 5 个字节,最后校验。out 为空时直接返回超时错误,虽然不是最精确的错误码,但能避免解引用空指针。buf 是局部数组,5 个字节刚好放湿度整数、湿度小数、温度整数、温度小数、校验和。校验和用 uint8_t 强转,让加法自然溢出取低 8 位,和 DHT11 的校验规则一致。返回 DHT11_ERR_CHECKSUM 时,out 里的旧数据不要用,调用者应该丢弃这一次结果。

业务层可以按这个状态做重试:超时重试一次,校验错重试一次,连续两次失败就上报传感器故障。不要无限重试,DHT11 的读取间隔至少 1s,无限重试会把间隔压短,反而读到旧数据。重试之间至少隔 1s,或者直接等下一次周期任务。接口层不负责重试,只负责把状态说清楚,这样换到 RTOS 或裸机轮询都能接。

4.3 移植到不同主频:延时校准与超时计数

换主频是 DHT11 移植里最容易翻车的一步。72MHz 的工程搬到 48MHz,delay_us 还按 72 算,实际延时变长,读位采样点全部偏移。标准库工程里 SystemCoreClock 通常由 system_stm32f10x.c 维护,但有人手动改 PLL 后忘了更新,delay_us 就不准。移植时先确认 SystemCoreClock 打印出来和实际主频一致,再看 delay_us(1000) 在逻辑分析仪上是不是 1ms 左右。

void DHT11_PortInit(void) { RCC_APB2PeriphClockCmd(DHT11_RCC, ENABLE); } uint8_t DHT11_CheckInterval(uint32_t last_ms, uint32_t now_ms) { if ((uint32_t)(now_ms - last_ms) < 1000) { return 0; // 间隔不足1s,不读 } return 1; }

DHT11_PortInit 把端口时钟单独拎出来,移植时只改 DHT11_RCC。DHT11_CheckInterval 用无符号减法处理毫秒计数溢出,last_ms 是上次读取的毫秒时间,now_ms 是当前毫秒时间,返回 0 表示间隔不够,返回 1 表示可以读。DHT11 的数据更新频率不高,读取间隔低于 1s 容易读到同一帧或触发内部忙状态,常见做法是 2s 读一次,显示刷新用缓存值。

超时计数也不要写死。DHT11_WaitLevel 里的 timeout_us 在 72MHz 和 8MHz 下都应该按微秒算,但如果 delay_us 本身不准,超时计数只能兜住“完全没响应”,兜不住“响应了但采样点偏”。移植后先跑 100 次读取,统计超时和校验错的比例;比例明显高于原平台,先查主频和延时,再查中断。不要一上来改 DHT11 的时序参数,时序参数是协议定的,延时实现才是变量。

5. 避坑与排查:读数 0、255、校验失败的五种现场

DHT11 的坑很集中:接线、上拉、延时、中断、读取间隔。现场表现看起来都是“读不出来”,但原因不一样,排查顺序也不一样。下面五种现场按出现频率排,每条按现象、原因、解决写,遇到问题可以顺着查,不要一上来就换传感器。

5.1 现象一:一直返回超时,读数全是 0 或 255

现象是 DHT11_Read 每次都返回 DHT11_ERR_TIMEOUT,串口打印出来湿度 0、温度 0,或者原始字节全是 255。原因常见有三个:DATA 线没接对引脚、外部上拉电阻没焊、起始信号拉低时间不够。最小系统板上 PA0 和 PB0 长得像,宏里写 PA0 实际接 PB0,代码等不到响应;上拉电阻缺失时总线释放后拉不高,DHT11 不响应;起始低电平只有 1ms,DHT11 根本没识别到。

解决顺序是先拿万用表量 DATA 线空闲电压,应该是 3.3V 左右;再把 DHT11 拔掉,用代码拉低 DATA 线 20ms 再释放,量电压能不能回到 3.3V。回不去查上拉和引脚配置,回得去查 DHT11 接线和供电。起始信号用逻辑分析仪看,拉低时间必须 ≥18ms,释放后 20~40us 内应该看到 DHT11 拉低 80us。没有逻辑分析仪就把 delay_ms(20) 改成 delay_ms(30) 试一次,排除边界问题,但不要长期用 30ms 掩盖延时不准。

5.2 现象二:湿度正常,温度明显偏高或偏低

现象是湿度读数在合理范围,温度却比实际高十几度,或者温度整数偶尔跳变。原因多半是字节解析顺序错了,或者把校验和当成了温度小数。DHT11 的 5 个字节顺序是湿度整数、湿度小数、温度整数、温度小数、校验和,有人把 buf[2] 当湿度小数、buf[3] 当温度整数,温度就会离谱。另一种情况是 DHT11 的小数位恒为 0,业务层却按“温度 = temp_int + temp_dec / 10”算,看起来没错,但把校验和误读成小数时就会跳。

解决是把原始 5 个字节一起打印,校验通过后再解析。湿度整数 buf[0] 范围 20~90,湿度小数 buf[1] 通常 0;温度整数 buf[2] 范围 0~50,温度小数 buf[3] 通常 0;buf[4] 是校验和,不参与温湿度换算。DHT11 不支持负温,读到温度整数最高位为 1 的情况,先怀疑时序错位,不要当成零下温度。换 DHT22 时字节定义不同,温度支持负值,驱动要另写,不要把 DHT11 的解析直接套过去。

5.3 现象三:校验和偶尔失败,重启后又正常

现象是连续读 10 次,其中两三次返回 DHT11_ERR_CHECKSUM,重启或重新插拔杜邦线后好一阵,过一会儿又出现。原因通常是读 40 位期间被中断打断,或者电源纹波导致某一位高电平变窄。串口接收中断、定时器中断、SysTick 中断都可能落在 40us 采样点附近,把位 0 读成位 1,校验和自然对不上。杜邦线太长、DHT11 和电机共用 5V 电源,也会让波形边沿变缓。

解决分两步:先用 DHT11_Read_NoIrq 关中断读 100 次,看校验失败率有没有降到 0;如果降了,调整中断优先级或缩短中断服务函数,不要长期全局关中断。再查电源,DHT11 的 VCC 和 GND 之间加 100nF 去耦电容,Data 线上拉电阻靠近 DHT11 一端,线长控制在 20cm 以内。校验失败时不要用上一次的值硬顶,连续两次失败就上报故障,让上层知道传感器当前不可信。

5.4 现象四:上电第一次成功,后面每次都不对

现象是复位后第一次读 DHT11 正常,之后每隔 100ms 读一次,读数不变或者校验错。原因是读取间隔太短,DHT11 内部需要时间更新数据,低于 1s 的间隔可能读到同一帧,或者从机还没准备好。另一种情况是 DHT11_SetInput 之后没有重新配置,第二次读取时引脚还停在上拉输入,DHT11_Start 里虽然调了 SetOutput,但时钟或模式被别的代码改掉了。

解决是把读取间隔拉到 2s,至少不低于 1s,用 DHT11_CheckInterval 做门限。每次读取前重新走 DHT11_Start 里的 SetOutput,不要假设引脚状态。如果工程里还有别的模块操作同一个 GPIO 端口,检查那些模块有没有把整个端口重新初始化,把 DHT11 的引脚模式覆盖掉。上电第一次成功、后面失败,优先查间隔和引脚模式,再查传感器供电是否在第一次读取后被拉低。

5.5 现象五:换主频或换优化等级后时序全乱

现象是同一份代码在 72MHz 工程里能读,搬到 48MHz 或把编译器优化等级从 -O0 改成 -O2 后,读数全是 255 或校验错。原因是 delay_us 基于 SystemCoreClock,主频变了但 fac_us 没更新;或者空循环延时被优化器删掉,实际延时变短。DHT11 的 40us 采样点对延时很敏感,差 10us 就可能把 0 读成 1。

解决是统一用 SysTick 轮询延时,不靠空循环。换主频时确认 SystemCoreClock 与实际主频一致,Delay_Init 重新算 fac_us。优化等级改变后,用逻辑分析仪复测起始低电平 20ms 和读位采样点,不要只看代码有没有变。延时函数里用 volatile 或直接读 SysTick->VAL,避免循环变量被优化掉。移植到新工程时,先把 delay_us(1000) 测准,再跑 DHT11 读取,顺序不要反。

6. 进阶验证:用逻辑分析仪和滑动窗口把 DHT11 稳定性量化

DHT11 读通一次不算完,能不能连续读稳才是边界。我一般会做两步验证:先用逻辑分析仪抓 DATA 线,看起始低电平是不是 20ms、响应低/高是不是各 80us、位 0 高电平是不是 26~28us、位 1 高电平是不是 70us 左右;再跑滑动窗口,连续读 100 次,统计成功、校验错、超时各多少次。逻辑分析仪看的是单次波形,滑动窗口看的是长期稳定性,两个都过,才敢说这份驱动在最小系统板上可用。

验证项观察点合格线
起始低电平DHT11_Start 拉低段≥18ms,常见20ms
释放高电平切输入后到响应前20~40us
响应低/高DHT11 拉低再拉高各约80us
位0高电平50us低之后26~28us
位1高电平50us低之后约70us
读取间隔两次 DHT11_Read 之间≥1s,建议2s

滑动窗口代码不用复杂,把状态计数打印出来就够。下面这段放在主循环里,每 2s 读一次,读满 100

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

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

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

立即咨询