搞嵌入式的人迟早会在项目里碰到温湿度采集的需求而只要一搜“温湿度传感器”DHT11一定是跳出来的第一个答案——便宜、常见、资料多。但真把它接上单片机开始调代码的时候很多人会卡在同一个地方这玩意儿用的是单总线通信时序极其依赖精确延时读出来全是255、偶尔出一个正常值、或者上电后怎么都不响应都是新手村最经典的劝退场景。这篇文章我把DHT11从器件原理、单总线协议到HAL库代码完整拆了一遍内容覆盖三层单总线这根线到底怎么传数据、DHT11的40位数据帧结构怎么解析校验、以及一套能直接拿去用的STM32F103驱动代码。适合刚接触单片机不久、想搞懂时序协议本质的开发者参考。1. 项目背景与方案选型分析1.1 单总线在嵌入式系统中的生态位先聊一个最基本的问题为什么会有单总线这种东西嵌入式设备内部通信常用的就那几套——UART要两根线IIC要两根线SCLSDASPI至少三根线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也就是最常见的“蓝丸”最小系统板主频72MHz3.3V供电。DHT11是淘宝最常见的蓝色四脚封装裸传感器不是那种三脚的模块板。这个区别很重要三脚模块板自带上拉电阻和滤波电容四脚裸传感器需要自己接上拉电阻很多新手买回来直接接线读不到数据就是因为漏了上拉。软件方面我用的是STM32CubeIDE基于HAL库开发。CubeMX配置GPIO只需要几秒钟但真正写驱动的时候HAL库封装的初始化函数有个性能缺陷后面讲代码的时候会重点提。另外说一下开发环境的选择我并不是说标准外设库不好只是CubeIDEHAL库是目前主流对新手也更友好。这套驱动代码的逻辑和平台绑定不深改成标准库或者51单片机的写法也就是换个寄存器操作的事。配线说明DHT11的VCC接3.3V也可以接5V但后面会讲为什么我更推荐3.3VGND接GNDDATA接PB8。数据线上加上一个4.7kΩ上拉电阻到VCC。如果手头只有杜邦线尽量把线的长度控制在20cm以内特别是实验阶段线太长会引入电容和干扰时序稍微一偏就出问题。2. 单总线工作原理与DHT11内部机制2.1 一根线的艺术单总线到底怎么传数据单总线的物理层非常简单所有器件都挂在一条线上空闲状态时由于上拉电阻的存在总线电平是高的。通信的时候主机通过拉低总线来发起操作从机则通过主动拉低总线来给出响应。因为是半双工通信同一时刻只能有一个设备在控制总线谁拉住线谁就拥有了话语权另一方只能在边上看着。这里最关键的是引脚方向切换。主机发数据的时候GPIO要配置成输出模式等主机发完想让从机应答的时候GPIO必须立刻切回输入模式让从机能够自由拉低总线。很多人的代码卡死就卡在模式切换这一步特别是用HAL库的时候GPIO模式的切换需要调用HAL_GPIO_Init这个函数内部有大量的寄存器判断和延时逻辑切换一次就要吃掉好几个微秒放在普通逻辑里无所谓但在单总线这种对微秒级时序敏感的协议里这就是灾难。单总线没有时钟线所有的时间基准都是主机通过延时函数自己控出来的。发一个起始信号要拉低多久、释放多久收到从机的脉冲后过多久去采样电平这些时间参数全部写死在协议里主机和从机都严格按照这套时间标准对齐谁偏移了读出来的数据就是错的。顺带提一句单总线和DS18B20的协议底层非常相似都是靠主机精确控制时序完成通信。它们之间最大的区别在于命令层DS18B20有ROM搜索、读ROM、匹配ROM这些命令先发复位信号再读器件ID然后再找功能命令DHT11则简单得多一个起始信号下去从机直接开始吐数据不需要任何命令码整个协议一气呵成。所以网上有句话叫“调好DHT11DS18B20就是半天的事”道理就在这。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是毫秒级的不能用在微秒级场景而单总线协议最敏感的时间窗口就是几十微秒这个区间所以必须自己实现一个微秒级延时。最可靠的方案是用DWTData 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的但在单总线场景里最长的一次延时是20ms20ms乘以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这是新手最常遇到的问题。读出来的数据永远是255FF或者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完全调通了你对单总线协议的理解就不会只停留在概念层面以后再碰到任何靠精确时序通信的器件心里都有底。实际项目中我还习惯在驱动里预留一个调试开关需要的时候打开就能打印每一位的采样电平排查问题的时候能省大量时间。网格般的耐心和示波器上的那几条波形才是这类底层驱动调试真正的主角。