DHT11单总线通信原理与STM32驱动实现详解
2026/9/7 3:06:50 网站建设 项目流程

搞嵌入式的人迟早会在项目里碰到温湿度采集的需求,而只要一搜“温湿度传感器”,DHT11一定是跳出来的第一个答案——便宜、常见、资料多。但真把它接上单片机开始调代码的时候,很多人会卡在同一个地方:这玩意儿用的是单总线通信,时序极其依赖精确延时,读出来全是255、偶尔出一个正常值、或者上电后怎么都不响应,都是新手村最经典的劝退场景。这篇文章我把DHT11从器件原理、单总线协议到HAL库代码完整拆了一遍,内容覆盖三层:单总线这根线到底怎么传数据、DHT11的40位数据帧结构怎么解析校验、以及一套能直接拿去用的STM32F103驱动代码。适合刚接触单片机不久、想搞懂时序协议本质的开发者参考。

1. 项目背景与方案选型分析

1.1 单总线在嵌入式系统中的生态位

先聊一个最基本的问题:为什么会有单总线这种东西?嵌入式设备内部通信,常用的就那几套——UART要两根线,IIC要两根线(SCL+SDA),SPI至少三根线(MOSI、MISO、SCK),这些协议各有优势,但共同点是都不止一根线。在很多简单的传感器应用场景里,我们只想用最少的引脚、最便宜的MCU完成功能,这时候单总线(也叫1-Wire)就有了用武之地。

单总线的核心特点一句话就能讲明白:主机和从机共用一根数据线,既传数据又传时序,省掉独立的时钟线。代价是什么?所有时序全靠主机精确控制延时来模拟,协议层必须把每一位的持续时间做严格约定。这跟IIC的区别非常明显,IIC至少有一个SCL时钟信号在做同步基准,从机被时钟牵着手走,就算主机延时不太准,只要时序在一个合理范围内都能读对。单总线没有这个时钟基准,高低电平该持续多少微秒就是多少微秒,差太多这数据就读不出来了。

实际工程中,单总线最有存在感的两个器件就是DHT11和DS18B20,一个测温湿度,一个测温度,都是低成本环境监测的主力。这类应用的特点是速率要求极低——你不可能拿它传音频传图片,每秒读一两个数据就够了,单总线最高确实也就几十kbps的速率,但用在传感器采集上绰绰有余。另一个优势是省引脚,一个传感器占用一个GPIO,如果你用IIC,那挂在同一条总线上的所有传感器都要设备地址,地址冲突就很麻烦;单总线虽然也可以多挂器件,但DHT11这类传感器一般一个引脚就带一个器件,简单粗暴,逻辑也清晰。

特性UARTIICSPI单总线
信号线数量2根2根3根+1根
时钟基准独立波特率SCL时钟线SCK时钟线主机延时模拟
通信速率可达数Mbps标准100k/400k可达数十Mbps几十kbps级别
典型成本极低

DHT11用的就是最后一列方案,引脚数量压到极致,通信速度又刚好够用,这就是单总线在低成本传感场景里长期占据一席之地的核心原因。

1.2 为什么选DHT11而不是DHT22或SHT30

做选型的时候,几乎所有人都在DHT11和DHT22之间犹豫过,网上也吵得很凶。我的观点是:如果你做的不是专业气象站或者对湿度精度有硬性要求的设备,DHT11够用,而且它是学习单总线协议最好的教材,没有之一。

为什么这么说?DHT22的精度确实更好,湿度±2%RH,温度±0.5℃,量程也更大,可以测-40℃到80℃。但它的代价是价格大概是DHT11的三到四倍,而且DHT22的时序和DHT11基本一致,协议复杂度没有本质区别。SHT30则更高级,它用的是IIC接口,精度和稳定性都远超DHT系列,但它属于数字型传感器,走的是另一套玩法,没有单总线这种底层时序的挑战性。

从学习曲线看,DHT11的时序足够让你理解单总线的核心套路——起始信号、从机响应、位时隙、CRC校验,这些概念学完之后,反向去读DS18B20就是顺手的事。从实际项目看,DHT11的精度虽然是±5%RH和±2℃,但家用环境监测、大棚温度预警、机房监控这类场景,这个精度完全够用。你不可能用DHT11去做医疗级的温湿度记录,但你也犯不着为了一颗传感器把成本拉高三倍。选型的本质就是把精度需求和成本预算摆到桌面上,各自对号入座。

1.3 本文使用的硬件与软件环境

讲实战之前把环境交代清楚,方便大家对照复现。我这次用的主控是STM32F103C8T6,也就是最常见的“蓝丸”最小系统板,主频72MHz,3.3V供电。DHT11是淘宝最常见的蓝色四脚封装裸传感器,不是那种三脚的模块板。这个区别很重要:三脚模块板自带上拉电阻和滤波电容,四脚裸传感器需要自己接上拉电阻,很多新手买回来直接接线读不到数据,就是因为漏了上拉。

软件方面我用的是STM32CubeIDE,基于HAL库开发。CubeMX配置GPIO只需要几秒钟,但真正写驱动的时候,HAL库封装的初始化函数有个性能缺陷,后面讲代码的时候会重点提。另外说一下开发环境的选择,我并不是说标准外设库不好,只是CubeIDE+HAL库是目前主流,对新手也更友好。这套驱动代码的逻辑和平台绑定不深,改成标准库或者51单片机的写法也就是换个寄存器操作的事。

配线说明:DHT11的VCC接3.3V(也可以接5V,但后面会讲为什么我更推荐3.3V),GND接GND,DATA接PB8。数据线上加上一个4.7kΩ上拉电阻到VCC。如果手头只有杜邦线,尽量把线的长度控制在20cm以内,特别是实验阶段,线太长会引入电容和干扰,时序稍微一偏就出问题。

2. 单总线工作原理与DHT11内部机制

2.1 一根线的艺术:单总线到底怎么传数据

单总线的物理层非常简单,所有器件都挂在一条线上,空闲状态时由于上拉电阻的存在,总线电平是高的。通信的时候,主机通过拉低总线来发起操作,从机则通过主动拉低总线来给出响应。因为是半双工通信,同一时刻只能有一个设备在控制总线,谁拉住线谁就拥有了话语权,另一方只能在边上看着。

这里最关键的是引脚方向切换。主机发数据的时候,GPIO要配置成输出模式;等主机发完想让从机应答的时候,GPIO必须立刻切回输入模式,让从机能够自由拉低总线。很多人的代码卡死就卡在模式切换这一步,特别是用HAL库的时候,GPIO模式的切换需要调用HAL_GPIO_Init,这个函数内部有大量的寄存器判断和延时逻辑,切换一次就要吃掉好几个微秒,放在普通逻辑里无所谓,但在单总线这种对微秒级时序敏感的协议里,这就是灾难。

单总线没有时钟线,所有的时间基准都是主机通过延时函数自己控出来的。发一个起始信号要拉低多久、释放多久,收到从机的脉冲后过多久去采样电平,这些时间参数全部写死在协议里,主机和从机都严格按照这套时间标准对齐,谁偏移了读出来的数据就是错的。

顺带提一句,单总线和DS18B20的协议底层非常相似,都是靠主机精确控制时序完成通信。它们之间最大的区别在于命令层:DS18B20有ROM搜索、读ROM、匹配ROM这些命令,先发复位信号,再读器件ID,然后再找功能命令;DHT11则简单得多,一个起始信号下去,从机直接开始吐数据,不需要任何命令码,整个协议一气呵成。所以网上有句话叫“调好DHT11,DS18B20就是半天的事”,道理就在这。

2.2 DHT11的完整数据帧结构

DHT11的数据帧是固定的40位,一次完整传输包含5个字节。这5个字节的含义如下:

  • 第1字节:湿度整数部分
  • 第2字节:湿度小数部分
  • 第3字节:温度整数部分
  • 第4字节:温度小数部分
  • 第5字节:校验和

这里必须说清楚一个几乎人人都踩过的坑:DHT11的湿度分辨率和温度分辨率都是1,也就是说小数部分正常情况下永远是0。你读到的湿度整数是60,小数是0,那湿度就是60.0%RH;温度整数是26,小数是0,那温度就是26.0℃。那有的人会问,既然小数位恒为0,为什么协议里还要定义这两位?答案是历史包袱兼容。DHT11这个传感器后续迭代和同系列的DHT22就是靠这两个字节来扩展精度的,DHT22的湿度小数位能到8位精度,温度小数位也是实打实的数据。所以读数据的时候千万不要把小数位丢弃,要养成完整解析的习惯,万一哪天你换了DHT22,驱动代码还能接着用。

校验和的算法很简单:前4个字节相加,然后取低8位,如果和最后一个字节相等,说明这一帧数据是完整的。举个例子,读回来的5个字节是0x32 0x00 0x1A 0x00 0x4C,你计算一下0x32 + 0x00 + 0x1A + 0x00 = 0x4C,跟校验字节对上了,那湿度就是0x32也就是50%RH,温度就是0x1A也就是26℃。这也就是为什么单总线协议可以不依赖CRC硬件模块,一个加法加一个与运算就实现了错误检测,低成本器件的设计思路就是这样朴素而高效。

2.3 上拉电阻与线缆的工程细节

这个环节看着不起眼,但在实际调试中影响极大。上拉电阻的作用是保证总线在空闲状态下处于高电平,给所有设备一个默认的“安静”状态。DHT11的数据输出本质是开漏结构,器件本身只能把总线拉低,不能主动输出高电平,高电平全靠外部上拉电阻提供。如果你没加上拉电阻,总线在从机释放后会处于浮空状态,电平不确定,读出来的数据自然就是乱的。

上拉电阻的阻值一般选4.7kΩ到10kΩ之间。阻值越大,总线功耗越低,但上升沿就越平缓,在长线传输或者总线挂载设备多的情况下容易出问题;阻值越小,上升沿越陡峭,抗干扰能力越强,但相应的功耗会大一点。我实测下来,4.7kΩ是比较均衡的选择,既保证了信号质量,功耗上也完全可以接受。如果是面包板实验,直接用10kΩ也没问题,短距离下两者没有感知上的差异。

线缆长度也必须提一下。单总线协议本身没有规定最大线长,但实际应用中线长了,分布电容会变大,导致电平上升沿变缓,主机的采样点可能采到错误的电平。实验阶段20cm以内的杜邦线最稳,如果项目里需要拉长线到几米甚至十几米,建议降速读取,并把上拉电阻适当调小到1kΩ到2.2kΩ,优先保证上升沿质量。还有一个容易被忽略的点:DHT11的VCC和GND之间最好加一个100nF的退耦电容,器件在内部翻转时会产生电流尖峰,没有电容的话这些噪声会耦合到数据线上。

3. 时序协议实战拆解

3.1 起始信号与从机响应

现在进入正题——时序。DHT11的读取流程可以用一句话概括:主机发一个起始信号,从机拉低总线响应,然后噼里啪啦吐40位数据。我们一步步拆。

主机发送起始信号的过程是:先把GPIO配置为输出模式,拉低总线,保持至少18ms,然后释放总线(拉高),再保持20到40us。为什么要拉低18ms这么久?因为DHT11内部有自己的采样周期,它需要这段时间来检测到主机的呼叫意图,时间太短它根本反应不过来。实测中有人用10ms也偶尔能读成功,但稳定起见,代码里直接延时20ms最稳妥,这个延时稍微长一点没有任何副作用,短了反而可能导致从机不响应。

主机释放总线之后的20到40us窗口期,是通信角色切换的过渡阶段。主机把GPIO从输出模式切回输入模式,然后等待从机的响应。这里有一个时序细节要特别注意:主机拉高总线之后的这20到40us,不是让你傻等的,而是给从机一个准备时间,让它有机会把总线拉低来宣告自己的存在。从机在检测到起始信号结束后,会在大约80us的时间内把总线拉低,并保持80us,然后释放总线,再拉高80us,之后开始传输数据。整个过程如下图所示:

  • 主机输出低电平:20ms(起始信号)
  • 主机释放总线:约30us(模式切换+等待)
  • 从机拉低:80us(响应信号开始)
  • 从机释放:80us(响应信号结束)
  • 从机再次拉高:80us(准备发数据)

如果主机在合理时间窗口内没有检测到从机拉低响应,基本可以断定是硬件连接有问题或者DHT11损坏。调试时可以用示波器或者逻辑分析仪挂在DATA线上看波形,第一个尖峰就是起始信号,紧接着第二个低电平脉冲就是从机的响应,看不到这两个脉冲,就说明问题出在物理层,而不是代码逻辑。

3.2 数据0和数据1的判别临界点

起始信号和响应信号都完成之后,DHT11开始输出40位数据。每一位数据的传输过程都是同一套模板:从机先把总线拉低约50us,表示“一位数据要开始了”,然后释放总线,根据这一位的值是0还是1,决定总线拉高的持续时间。

如果是数据0,总线拉高的时间大约是26到28us;如果是数据1,总线拉高的时间大约是70us。主机要做的就是在从机释放总线后的某个时间点检查电平高低:在这个时间点,总线还是高电平,那就说明是数据1;总线已经恢复成低电平,说明是数据0。这个采样时间点就是整个单总线协议的精髓所在。

关键问题来了:采样点应该放在多久之后?我们把数据0的高电平时间和数据1的高电平时间摆在一起看,一个是约27us,一个是约70us,中间有大约40us的间隔区。所以最稳妥的采样点就是延时40us之后读电平。为什么不用50us或者60us?因为数据0的27us持续时间本来就短,你延时太久,总线可能已经被从机拉低甚至进入下一位的开始阶段了,那你读到的肯定是0,数据1也读成了0,整帧数据就全错乱了。相反,采样点太早,比如20us,数据0的高电平还没结束,你也会误判成1。40us正好卡在两类数据的中间地带,是理论上的最佳妥协点。

我用生活场景来类比一下这个机制:这就好比你在操场上看到运动员起跑,发令枪响(从机拉低50us)之后,短跑选手(数据1)会往前冲刺70米,而原地休息的人(数据0)只往前走27米。你在40米的位置架一台相机拍照,能拍到人往前走的是1,拍不到人的是0。原理是一样的。

3.3 完整读取时序时间线

把所有环节串起来,一次DHT11读取的完整时间线如下:

阶段操作方电平持续时间
起始信号主机拉低≥18ms(推荐20ms)
释放总线主机拉高20-40us
响应信号从机拉低80us
响应结束从机拉高80us
数据位0从机低50us + 高约27us约77us
数据位1从机低50us + 高约70us约120us
整帧40位从机交替约3.5ms-5ms

从时间线上可以看到,最坏情况下(40位全是1),一次完整读取需要大约20ms加5ms,也就是25ms左右。这个时长放在任何系统里都是可以接受的,但你也要意识到一个现实约束:DHT11的采样频率是1Hz,也就是每秒钟最多对外提供一次有效数据。连续读取间隔如果小于1秒,第二次读取大概率失败或者返回上一次的缓存值。这个特性不是协议规定的,而是器件内部传感器响应速度决定的,在代码里必须做读取间隔控制,否则你的循环里读到的数据会忽好忽坏。

有个细节值得补充:虽然协议要求读取间隔不小于1秒,但实际代码里我们更常做的是“失败重试机制”——如果校验失败,等几百毫秒再试一次,连续试几次都在校验收不上,才判定硬件故障。这样用户体验会好很多,不会因为一帧数据偶发错误就直接罢工。

4. 代码实现:HAL库驱动的完整落地

4.1 GPIO引脚选型与初始化

选引脚的时候有个基本原则:避开单片机专门用于调试或者特殊功能的引脚。我选了PB8,因为它在STM32F103C8T6上是普通IO,不占用I2C1的默认引脚,也不跟USART1冲突,就非常合适。如果你用PB6或者PB7的话,这两个引脚默认被I2C1占用,除非你确定不用I2C,否则很容易在后续扩展功能时发生冲突。另外PB3、PB4、PA13、PA14、PA15这类引脚默认是JTAG调试口,虽然可以改复用,但新手不建议折腾,老老实实选个干净的引脚最省心。

HAL库初始化GPIO有两种思路。第一种是用CubeMX图形化配置,生成代码。第二种是直接在代码里用GPIO_InitTypeDef结构体初始化。不管哪种方式,最终效果都是一样的,就是把PB8配成推挽输出模式,同时重新初始化时支持切换成输入模式。这里我给出一个同时支持两种模式的初始化方式:

void DHT11_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // 先配置为推挽输出模式,用于发送起始信号 GPIO_InitStruct.Pin = DHT11_GPIO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); // 默认输出高电平,让总线处于空闲状态 HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); }

然后是模式切换函数。前面提过,HAL库的HAL_GPIO_Init执行速度太慢,不适合在时序中频繁切换。更高效的做法是直接操作寄存器,把摧残引脚模式切换的代价压到最低:

#define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_PIN_8 // 获取ODR、IDR寄存器地址 #define DHT11_OUT_1 HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET) #define DHT11_OUT_0 HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET) #define DHT11_IN_GET HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) // 寄存器级别快速切换模式 static void DHT11_Set_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_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStruct); }

严格来说,上面的DHT11_Set_Mode_Output依然调用了HAL_GPIO_Init,它的切换耗时在微秒级,短距离下其实也能用。如果你追求极致稳定,可以像我这样直接操作寄存器:

// GPIOB CRL寄存器控制低8位引脚,PB8引脚对应CRH寄存器 #define DHT11_MODE_OUTPUT() (GPIOB->CRH &= ~(0xF << 0), GPIOB->CRH |= (0x3 << 0)) // PB8推挽输出50MHz #define DHT11_MODE_INPUT() (GPIOB->CRH &= ~(0xF << 0), GPIOB->CRH |= (0x4 << 0)) // PB8浮空输入

这里用到了GPIO配置寄存器的底层知识:CRH寄存器每4位控制一个引脚的模式和速率。PB8是端口B的高8位引脚,所以由CRH控制,偏移量为0。这种方式切换一次只用了两三条汇编指令,速度是HAL库方式的几十倍。说实话,在项目量产后,我一直倾向这种寄存器直操作的方式写底层驱动,HAL库开发效率高,但驱动层还是越底层越踏实。

4.2 微秒级延时函数的实现

代码里另一个绕不开的环节就是延时。HAL_Delay是毫秒级的,不能用在微秒级场景,而单总线协议最敏感的时间窗口就是几十微秒这个区间,所以必须自己实现一个微秒级延时。

最可靠的方案是用DWT(Data Watchpoint and Trace)单元,它是Cortex-M3内核自带的调试组件,里面有一个CYCCNT周期计数器。只要打开DWT->CYCCNT的使能位,它就会每个时钟周期加1,我们读出当前的计数值,再配合主频换算,就能实现精确的微秒延时。具体代码如下:

static volatile uint32_t *DWT_CYCCNT = (uint32_t *)0xE0001004; static volatile uint32_t *DWT_CTRL = (uint32_t *)0xE0001000; static volatile uint32_t *DEMCR = (uint32_t *)0xE000EDFC; void DWT_Delay_Init(void) { *DEMCR |= 0x01000000; // 使能DWT访问 *DWT_CYCCNT = 0; // 计数器清零 *DWT_CTRL |= 1; // 使能CYCCNT计数 } void delay_us(uint32_t us) { uint32_t start = *DWT_CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((*DWT_CYCCNT - start) < ticks); }

这段代码的精髓在于:72MHz主频下,1us正好等于72个时钟周期。SystemCoreClock变量存放的是当前系统主频,除以1000000之后得到的就是每微秒对应的周期数。延迟的时候循环等待计数器差值到达目标值就可以了。这里要注意一个坑:us乘以频率得到的ticks是有可能溢出uint32_t的,但在单总线场景里,最长的一次延时是20ms,20ms乘以72只有1440000,离溢出远得很,所以可以放心用。

为什么不推荐用定时器做延时?不是说不行,而是DWT的代码量最少、配置最轻,也不占用定时器资源。定时器方案还得初始化TIM、配置预分频、写回调,代码复杂度高,性能上也没有明显优势。如果你用的是非Cortex-M系列的单片机,比如51单片机,那就老老实实写几个空循环的软件延时,或者用定时器中断打时间片。只要记住,精度能达到微秒级就行。

4.3 位读取、字节读取与数据解析

万事俱备,接下来就是核心驱动代码。我按照之前拆解的时序,把每个环节都做成了独立的函数,方便理解也方便复用。

首先是起始信号和等待从机响应的实现:

uint8_t DHT11_Start(void) { // 模式切换为输出 DHT11_MODE_OUTPUT(); DHT11_OUT_0(); delay_us(20000); // 拉低至少18ms,这里用20ms更稳 DHT11_OUT_1(); // 释放总线 delay_us(30); // 等待20-40us // 切回输入模式,准备接收从机响应和数据 DHT11_MODE_INPUT(); // 等待从机拉低总线(响应信号) uint16_t timeout = 10000; while (DHT11_IN_GET == GPIO_PIN_SET) { if (--timeout == 0) return 0; // 超时返回失败 } // 从机响应低电平持续约80us timeout = 10000; while (DHT11_IN_GET == GPIO_PIN_RESET) { if (--timeout == 0) return 0; } // 从机响应高电平持续约80us timeout = 10000; while (DHT11_IN_GET == GPIO_PIN_SET) { if (--timeout == 0) return 0; } return 1; // 响应正常,可以开始读数据 }

接着是读一个位和读一个字节的函数。读位的逻辑要反复看几遍,这是全代码最核心的地方:

uint8_t DHT11_Read_Bit(void) { uint16_t timeout = 10000; // 每个数据位都会先由从机拉低总线约50us while (DHT11_IN_GET == GPIO_PIN_SET) { if (--timeout == 0) return 0xFF; } // 检测到低电平开始,等待低电平结束释放 timeout = 10000; while (DHT11_IN_GET == GPIO_PIN_RESET) { if (--timeout == 0) return 0xFF; } // 释放后延时40us,到达0和1的判定临界点 delay_us(40); // 此时总线仍为高,说明是数据1;总线已经变低,说明是数据0 if (DHT11_IN_GET == GPIO_PIN_SET) { // 等待高电平结束,回到空闲状态,准备下一位 timeout = 10000; while (DHT11_IN_GET == GPIO_PIN_SET) { if (--timeout == 0) return 0xFF; } return 1; } else { return 0; } } uint8_t DHT11_Read_Byte(void) { uint8_t data = 0; for (int i = 0; i < 8; i++) { data <<= 1; uint8_t bit = DHT11_Read_Bit(); if (bit > 1) return 0xFF; // 读位超时,返回错误 data |= bit; } return data; }

读字节函数的逻辑很简单,左移一位,读到一个位就塞进去,读满8位就得到一个字节。DHT11的数据是高字节先出,所以先读到的位要往data的最高位移,这个要注意。

最后是完整的读取函数,把5个字节读出来并做校验:

uint8_t DHT11_Read_Data(uint8_t *humidity_int, uint8_t *temperature_int) { uint8_t buf[5] = {0}; if (!DHT11_Start()) return 0; // 从机无响应 // 连续读取5个字节 for (int i = 0; i < 5; i++) { buf[i] = DHT11_Read_Byte(); if (buf[i] == 0xFF && i == 0) { // 如果第一个字节就是0xFF,大概率时序出了问题,直接返回失败 } } // 校验和计算 uint8_t checksum = buf[0] + buf[1] + buf[2] + buf[3]; if (checksum != buf[4]) return 0; // 校验失败 // 解析温湿度(DHT11小数部分恒为0,直接舍弃) *humidity_int = buf[0]; *temperature_int = buf[2]; return 1; }

这段代码包含了一个实战经验:读数据的过程中一旦出现超时,立即返回错误,不要继续硬读。因为单总线的时序是一环扣一环的,中间只要有一步错位,后面所有位的采样点都会漂移,读出来的数据质量会越来越差,与其浪费时间去解析垃圾数据,不如直接宣布本次读取失败,让上层逻辑稍后重试。

4.4 连续读取与稳定性处理

驱动代码写完了,但直接丢到主循环里死循环读还是会有问题。我前面提过DHT11的采样周期是1秒,所以上层调用的时候必须控制读取频率。一个比较稳妥的主循环代码如下:

int main(void) { HAL_Init(); SystemClock_Config(); DWT_Delay_Init(); DHT11_GPIO_Config(); uint8_t humi, temp; uint8_t ret; while (1) { ret = DHT11_Read_Data(&humi, &temp); if (ret == 1) { printf("温湿度读取成功: %d.%d%%RH, %d.%d℃\n", humi, 0, temp, 0); } else { printf("读取失败,准备重试...\n"); } HAL_Delay(2000); // 读取间隔2秒,大于DHT11的1秒采样周期 } }

这里读取间隔用了2秒,比最低要求的1秒多了余量。还有一个细节:重试机制虽然重要,但不要在一个循环里疯狂重试,否则连续失败时日志会刷屏,CPU也空转。最好的做法是“失败一次,等2秒再试”,连续失败超过一定次数后再报硬件错误。

中断问题也值得一提。单总线协议对时序极其敏感,读取过程中一旦被高优先级中断打断,延时就失真了,读回来的数据很可能就是错的。所以完整的驱动代码里,主函数调用DHT11_Read_Data之前应该关闭中断,读完之后再恢复。在STM32上可以用__disable_irq()__enable_irq(),但从系统响应角度来看,关中断时间越短越好。我实测过,单次读取加校验全部跑完不超过30ms,这个时间窗口关掉中断,对于一个环境监测设备来说完全可接受。不过如果项目里还有其他实时性要求高的外设(比如PWM控制、通信协议栈),就需要权衡一下,可以用临界区保护或者定时器任务调度的方式来解决,不要让驱动独占整个CPU。

5. 常见问题排查与实战经验

5.1 读取数据全为FF或00

这是新手最常遇到的问题。读出来的数据永远是255(FF)或者0,几乎可以断定是逻辑问题,不是DHT11坏了。排查思路按优先级来:

第一,检查GPIO模式切换。我们发送起始信号之后必须把引脚从输出模式切回输入模式,如果忘记切换,从机拉升总线时,主机的输出引脚还在强拉高电平,总线电平被主机拽着不动,从机的信号根本传不上来,读到的自然就是乱码或者全FF。

第二,检查延时函数是否准确。很多人用HAL_Delay去实现40us的等待,但HAL_Delay是毫秒级的,传入40us可能会直接变成40ms甚至更久,时序早就跑飞了。一定要用精准的微秒级延时函数,用之前先验证一下:在延时函数里让LED翻转,用示波器看波形周期是否准确,或者用逻辑分析仪直接抓DHT11的时序波形对比。

第三,检查上拉电阻。裸DHT11没有内部上拉,如果数据线上没有接4.7kΩ到10kΩ的上拉电阻,总线空闲时电平是浮空的,读什么都是错的。模块版DHT11自带上拉,但也要确认模块上的丝印和引脚定义,别把DATA和VCC接反了。

现象可能原因处理措施
读回全FF引脚未切输入模式检查DHT11_MODE_INPUT调用
读回全00采样点过早或总线低电平检查40us延时是否正确
偶发错误线缆过长或上拉过大缩短线缆,改用4.7kΩ上拉
完全不响应起始信号太短拉低时间增加至20ms以上
校验总是失败供电不稳或干扰加100nF退耦电容

5.2 偶尔读到错误数据的抖动问题

如果读取结果大多数时候正常,但隔几十秒就出现一个明显不对的数值,这种跳变问题排查起来比全错还要烦人。我在调试中就遇到过湿度从53%突变到15%的怪事,排查了半天发现是主循环里的printf打印耗时太长,导致实际读取间隔变成了不到1秒,DHT11还没准备好新数据就被强读了,拿到了半截旧数据。

这种抖动问题的根源一般有两个。一个是读取频率过快,DHT11内部采样跟不上。解决办法很简单,读取间隔至少控制在1秒以上,我这里给2秒。另一个是中断干扰,如果读取过程中有一个串口中断恰好来了,中断服务函数执行了十几微秒,刚好跨过了你采样电平的那个时间点,那就等于采样点时间偏移了,0读成1或者反过来都是可能的。遇到这个问题,最快的解决方案就是在读取函数的前后加关中断保护,实测下来能解决90%以上的偶发错误。

还有一个容易被忽视的因素是电源噪声。如果DHT11跟电机、继电器共用一个电源,继电器吸合瞬间的电流尖峰很容易把3.3V电压拉出毛刺,数据线上的信号自然也就不干净了。解决办法是DHT11的VCC和GND之间加一个100nF陶瓷电容,靠近传感器引脚放置,效果立竿见影。

5.3 多传感器共用一条总线的问题

单总线通信的一大特点是理论上允许一条总线上挂多个设备。但DHT11并没有像DS18B20那样的器件ID和搜索机制——你发一次起始信号,总线上所有DHT11都会响应,数据会互相冲突。所以严格来说,多个DHT11不能直接挂同一条单总线上。如果你想采集多个位置的温湿度,要么每个传感器用独立的GPIO引脚,要么引入外部模拟开关做通道切换。

我这里有个常用方案:如果主控引脚不够用,用CD4051模拟多路复用器,一个GPIO接DHT11数据线,另外三个GPIO控制通道选择,8个引脚地址可选,一个复用器就能挂8个DHT11,成本还不到一块钱。但要注意,CD4051或者74HC4051也有自己的导通电阻和切换时间,数据线上的上拉电阻可能要适当调小到2.2kΩ,保证信号上升沿足够陡峭。

5.4 从DHT11到DS18B20的代码复用

最后聊一个拓展方向。既然你已经彻底理解了单总线协议,那DS18B20的事情就变得顺理成章了。DS18B20和DHT11的物理层非常相似,都是靠主机拉低总线发起对话,然后从机响应,然后传输数据。区别主要在命令层:DS18B20需要先发送复位脉冲,然后发ROM操作指令(跳过ROM是0xCC),之后再发功能指令(启动温度转换是0x44,读暂存器是0xBE),功能指令发完了才能读到温度和配置数据。

电平时序上,DS18B20的数据位定义和DHT11不太一样,它的位时隙是由主机主动拉低总线来标志位开始的,然后根据拉高的持续时间区分0和1,传输方向是双向的。但总体的“精确延时+高低电平采样”的套路一模一样。学完DHT11再去调DS18B20,大概只需要研究一下命令序列就能上手,这就是底层协议学通了之后带来的复利效应。

通信速率方面,DS18B20在高速模式下可以在一条总线上挂多个设备,通过器件唯一的64位ROM码区分,这和DHT11有本质区别。如果项目从单点采集变成多点分布式测温度,DS18B20会是比DHT11更合适的选择,这也是很多大棚监控和冷链物流场景选用DS18B20而不是DHT11的原因。


最后分享一个个人体会:调DHT11这类器件,很多人第一反应是“这传感器便宜,坏了就换”,但我更建议在调不通的时候先确认是硬件问题还是时序问题。拿逻辑分析仪抓一下波形是最快的方式,没有逻辑分析仪就写一个简单的GPIO翻转函数,用示波器看翻转间隔来验证延时精度。把DHT11完全调通了,你对单总线协议的理解就不会只停留在概念层面,以后再碰到任何靠精确时序通信的器件,心里都有底。实际项目中我还习惯在驱动里预留一个调试开关,需要的时候打开就能打印每一位的采样电平,排查问题的时候能省大量时间。网格般的耐心和示波器上的那几条波形,才是这类底层驱动调试真正的主角。

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

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

立即咨询