多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

DHT11单总线温湿度传感器驱动入门:时序、数据格式与实现

DHT11单总线温湿度传感器驱动入门:时序、数据格式与实现 很多人第一次接触嵌入式传感器驱动都是从一颗 DHT11 温湿度传感器开始的。这颗看似不起眼的小器件背后却藏着单总线通信协议最典型的应用场景一根数据线同时完成双向通信既有严格的主从时序要求又涉及到电平转换、延时精度和数据校验。可以说把 DHT11 彻底吃透了单总线这一类协议的驱动你基本就能举一反三后面再看 DS18B20、甚至一些自定义单总线器件套路都是相通的。这篇文章我会从单总线协议的底层逻辑讲起把 DHT11 的器件原理、40 位数据格式、完整的时序参数逐个拆开再配合可直接移植的 C 语言代码把从初始化到数据校验的每个环节都过一遍。不论你是刚接触单片机的学生还是想在 STM32、51 上快速落地温湿度采集的开发者这篇文章都能让你少走不少弯路。1. 单总线通信协议一根线的艺术1.1 单总线到底省了什么单总线1-Wire通信最大的特点就是省引脚。传统的 IIC 协议需要 SCL 和 SDA 两根线SPI 更是要四根线起步而单总线只需要一根数据线外加一根地线就能完成通信。DHT11 就是典型的单总线器件VCC、GND、DATA 三根引脚实际通信全靠 DATA 这一根线来回折腾。这里有个关键点要理解单总线是半双工通信同一时刻数据线上只能有一个方向的数据传输。主机发指令的时候从机只能听着从机回数据的时候主机必须闭嘴。这和 UART 的全双工不同USART 收发可以同时进行但单总线做不到。所以在写驱动的时候必须严格区分“主机占用总线”和“从机占用总线”这两个时间段否则数据就乱了。另一个容易忽略的点是上拉电阻。单总线的数据线是开漏输出的默认情况下引脚处于高阻态必须外接一个上拉电阻典型值 4.7kΩ把电平拉高。通信时主机和从机通过拉低引脚来发送低电平信号释放引脚后靠上拉电阻恢复高电平。这个设计和 IIC 总线的电气结构很像好处是多个器件可以挂在同一根线上只要地址不同就能共存。1.2 与 IIC、SPI 协议的区别很多人刚学 DHT11 的时候会把单总线和 IIC 搞混因为它们都是开漏、都有起始条件的概念。但它们的本质区别在于IIC 有独立的时钟线 SCL数据线 SDA 上的电平变化是跟随时钟的时序容差相对宽松。单总线没有时钟线时序完全靠双方“约定好的延时”来同步对微秒级的延时精度要求非常高。SPI 则是主从设备共用时钟全双工通信速度更快但要占用更多引脚。这意味着写单总线驱动时延时函数准不准直接决定了通信能不能成功。特别是 DHT11 这种低速器件它用 26 到 70 微秒的高电平宽度来区分数据 0 和数据 1如果主机的微秒延时函数偏差超过 10 微秒很容易误判。提示: 如果项目里同时用到了多路传感器优先给单总线器件分配外部中断引脚不要用软件轮询的方式长时间占用 CPU。1.3 为什么 DHT11 选择单总线回到 DHT11 本身它是 8 位单片机时代的产物设计目标就是低成本、低引脚占用。单总线协议正好满足这个需求一根线既能给主机发数据又能接收主机的触发指令控制逻辑简单非常适合这种低速、低功耗的传感器场景。另外DHT11 的测量周期本身就比较慢典型情况下每秒最多读 1 到 2 次读一次完整数据需要约 4ms。这种低速场景完全不需要 SPI 那种高速总线单总线的带宽绰绰有余。这也解释了为什么像 AM2301、SHT30 这些升级产品里有的仍然保留了单总线兼容模式就是为了照顾老客户原有的布线设计和代码框架。2. 读懂 DHT11 的数据手册从引脚到数据格式2.1 引脚定义与典型电路DHT11 最常见的封装是 4 针单排直插引脚定义如下引脚序号名称说明1VCC电源3.3V~5.5V2DATA单总线数据引脚3NC悬空不要连接任何东西4GND接地注意第三脚 NC 必须悬空我见过有人把 NC 接到地线或电源上导致传感器直接损坏的案例。DATA 引脚和 VCC 之间必须接一个 4.7kΩ 左右的上拉电阻有的模块板上已经集成了这个电阻买模块的话可以直接接单片机引脚使用买裸片就必须自己加。供电方面DHT11 支持 3.3V 和 5V 两种电平但要注意如果用 3.3V 供电数据线的高电平就是 3.3V此时单片机引脚如果配置为开漏输出需要确保上拉电阻接到 3.3V 而不是 5V否则会通过上拉电阻反向灌电流长期运行可能损坏传感器。2.2 40 位数据帧逐位拆解DHT11 一次完整的数据传输会发送 40 位数据分为 5 个字节第 1 字节湿度整数部分单位是 %RH第 2 字节湿度小数部分DHT11 的小数部分始终为 0第 3 字节温度整数部分单位是 ℃第 4 字节温度小数部分同样为 0第 5 字节校验和等于前 4 个字节之和的低 8 位举个例子如果读取到 40 位数据为0000 0110 0000 0000 0001 1001 0000 0000 0110 0101拆解后就是湿度 6%RH温度 25℃校验和为06 00 19 00 0x1F与接收到的0110 01010x65不符说明数据有误应该丢弃本次读数。这里有个坑有些资料里给出的校验和算法是“前 4 个字节相加取低 8 位”但实际编程时要注意字节相加时的溢出问题。用 uint8_t 类型的变量累加就只用保留低 8 位如果用 int 类型就必须手动 0xFF。2.3 分辨率与精度边界DHT11 的分辨率是 1%RH 和 1℃精度是 ±5%RH 和 ±2℃。这意味着它本身就是个“够用就行”的传感器不适合做精密测量。如果你需要 ±1℃ 以内的精度建议直接考虑 SHT30 或者 AHT20。但 DHT11 的优势在于价格便宜、驱动简单、环境适应性尚可在温室大棚、机房温湿度监测、智能家居等对精度要求不高的场景里它依然有很强的生命力。理解了它的精度边界你就不会在项目完成后才发现量程不够、精度不达标这种基础问题。3. 单总线时序详解那些微秒级的博弈3.1 主机发起起始信号一次完整通信的开始主机必须先把总线拉低至少 18ms然后释放总线。这个 18ms 的低电平是 DHT11 的“起床号”它收到这个信号后会从低功耗待机状态唤醒准备发送数据。为什么要 18ms 这么久因为 DHT11 内部没有晶振它依靠内部的 RC 振荡器工作唤醒时间比较长。如果你把低电平时间缩到 5ms传感器很可能根本没醒来也就不会有后续的响应。释放总线后主机要立刻把引脚从输出模式切换为输入模式。这个切换要快最好在 1 到 2 微秒内完成。因为紧接着 DHT11 就会把总线拉低来回应主机如果你还停留在输出模式甚至错误地输出高电平总线电平就会冲突导致通信失败。在我常用的代码模板里起始信号是这样处理的void DHT11_Start(void) { DHT11_PIN_MODE_OUTPUT(); // 配置为输出模式 DHT11_PIN_LOW(); // 拉低总线 delay_ms(20); // 保证大于18ms DHT11_PIN_HIGH(); // 释放总线上拉电阻拉高电平 delay_us(30); // 给电平上升留出时间 DHT11_PIN_MODE_INPUT(); // 切换为输入模式等待从机响应 }3.2 从机响应信号主机释放总线后的 20 到 40 微秒内DHT11 会主动拉低总线 80 微秒然后再拉高 80 微秒表示“我准备好了开始发数据”。判断响应信号是否成功的关键是在切换为输入模式后等待数据线变为低电平。如果在规定时间内没有等到说明传感器没有响应可能是接线问题、上电时间不足或者传感器已损坏。一个常见的错误是等待响应时用了阻塞式死等代码里写while (DHT11_PIN_READ() HIGH);。万一传感器不响应程序就会卡死在这里。实际项目中应该加超时退出机制例如用循环计数或定时器超过 100 微秒还没等到低电平就自动退出并返回错误码。这个习惯非常重要尤其是在跑 RTOS 的项目里一个死循环可能拖垮整个系统。3.3 数据位 0 和 1 的识别方法DHT11 发送每一位数据时都是先把总线拉低 50 微秒然后拉高。区别在高电平持续的时间数据位 0高电平持续 26 到 28 微秒数据位 1高电平持续约 70 微秒这个识别过程是单总线驱动里最容易出错的地方。如果不能精确测量高电平的持续时间无论你把它当成 0 还是 1都会得到错误的数据。实用的判断方法是在数据线拉低的时刻开始计时等待它变为高电平后再延时 40 微秒然后读取此时引脚的电平。如果读到的还是高电平说明这一位是 1如果已经变成低电平说明是 0。这个方法比单纯测量高电平宽度更稳定因为它在电平变化后再做一次延迟确认对延时的精度要求没那么苛刻。具体代码可以这样写uint8_t DHT11_ReadBit(void) { uint8_t level 0; while (DHT11_PIN_READ() LOW); // 等待数据线由低变高 delay_us(40); // 关键延时在40us后采样 if (DHT11_PIN_READ() HIGH) { level 1; } else { level 0; } while (DHT11_PIN_READ() HIGH); // 等待该位结束回到低电平 return level; }3.4 时序容差与延时精度分析DHT11 数据手册里给出的时序参数是有容差的例如低电平时间 50 微秒允许上下浮动 20% 到 30%。但对于主机这边的延时函数精度要求却比较高尤其是在识别数据位的那 40 微秒延时上。如果你用的是 51 单片机12MHz 晶振下一条空指令约为 1 微秒直接写几个 NOP 就能凑出来。但如果你用的是 STM32HAL 库里的HAL_Delay()只能做毫秒级延时微秒级必须自己写。千万不要用HAL_Delay(1)然后缩减参数因为HAL_Delay底层基于 SysTick1 毫秒是它最小的时间单位达不到微秒级精度。我一般用 DWT 硬件定时器实现微秒延时这是 Cortex-M 内核自带的调试组件不需要额外占用定时器外设。具体代码参考void delay_us(uint32_t us) { DWT-CYCCNT 0; // 清零计数器 DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; // 使能周期计数 uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while (DWT-CYCCNT - start ticks); }注意: 如果你把 DHT11 驱动从 51 移植到 STM32最需要关注的就是延时函数。同样的逻辑延时不对输出值就是错的。4. 从零写 DHT11 驱动完整代码拆解4.1 硬件抽象层设计编写驱动时我建议先把硬件相关的操作抽象出来这样换一颗单片机只用改这一部分。DHT11 需要三个最基本的接口引脚初始化配置为输出或输入引脚输出高/低电平引脚读取电平在 STM32 HAL 库环境下可以这样定义宏#define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_PIN_8 #define DHT11_PIN_SET() HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET) #define DHT11_PIN_RESET() HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET) #define DHT11_PIN_READ() HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN)同时需要两个引脚模式切换函数void DHT11_PIN_MODE_OUTPUT(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); } void DHT11_PIN_MODE_INPUT(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; // 内部上拉作为补充 HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); }这里特别说明一下开漏输出的选择。如果单片机引脚配置为推挽输出直接输出低电平没有任何问题但输出高电平时会强制把引脚拉到 VCC此时 DHT11 如果也在驱动总线就可能发生短路。使用开漏输出加外部上拉才能保证“谁都不驱动总线时电平由电阻决定”符合单总线的电气约定。4.2 起始信号与响应判断完整的读取流程可以拆成几个子函数方便单步调试。首先是启动通信uint8_t DHT11_Start(void) { uint8_t retry 0; DHT11_PIN_MODE_OUTPUT(); DHT11_PIN_RESET(); delay_ms(20); DHT11_PIN_SET(); delay_us(30); DHT11_PIN_MODE_INPUT(); // 等待DHT11拉低总线作为响应超时约100us while (DHT11_PIN_READ() GPIO_PIN_SET) { if (retry 100) return 1; // 返回1表示无响应 delay_us(1); } // 响应低电平持续80us然后拉高80us delay_us(80); if (DHT11_PIN_READ() GPIO_PIN_RESET) { return 1; // 拉低时间异常判定通信失败 } delay_us(80); // 跳过80us的高电平准备读取数据 return 0; }这里的delay_us(80)两次总共覆盖了 160 微秒的响应信号如果时序算得不准进入读位阶段时可能已经错过了第一个数据位。这也是为什么我建议先把响应信号的波形用逻辑分析仪抓出来确认后再调后面的代码。4.3 读取 40 位数据的核心代码读取 40 位数据的过程本身就体现了单总线的特点每一位都是“低电平 50us 高电平 26/70us”的模式。一个字节的读取函数可以这样写uint8_t DHT11_ReadByte(void) { uint8_t i, data 0; for (i 0; i 8; i) { // 等待数据线由低变高代表一个数据位开始 while (DHT11_PIN_READ() GPIO_PIN_RESET); delay_us(40); if (DHT11_PIN_READ() GPIO_PIN_SET) { data | (1 (7 - i)); // 高位在前 } while (DHT11_PIN_READ() GPIO_PIN_SET); // 等待该位结束 } return data; }循环读取 5 个字节后把前 4 个字节相加与校验字节比对。校验通过才返回正常结果否则读取失败uint8_t DHT11_ReadTempAndHumi(int16_t *temp, uint16_t *humi) { uint8_t buf[5] {0}; if (DHT11_Start() ! 0) return 1; for (int i 0; i 5; i) { buf[i] DHT11_ReadByte(); } uint8_t checksum buf[0] buf[1] buf[2] buf[3]; if ((checksum 0xFF) ! buf[4]) { return 2; // 校验失败 } *humi (buf[0] 8) | buf[1]; *temp (buf[2] 8) | buf[3]; // 如果小数部分为0可以简单返回整数部分 *humi buf[0]; *temp buf[2]; return 0; }这里有一个容易踩的坑DHT11 输出的是无符号整数温湿度。低于 0℃ 时数据手册说最高位表示符号位。实际使用中如果环境温度会低于 0℃读取到温度值大于 128 时要减去 256 得到真实负温度。不过 DHT11 标称测量范围是 0 到 50℃正常使用基本不会涉及负温度的情况。4.4 主循环调用示例void main(void) { int16_t temperature 0; uint16_t humidity 0; SystemClock_Config(); MX_GPIO_Init(); delay_init(); // 初始化微秒延时基准 while (1) { if (DHT11_ReadTempAndHumi(temperature, humidity) 0) { printf(Temp: %d.%d C, Humi: %d.%d %%RH\r\n, temperature / 10, temperature % 10, humidity / 10, humidity % 10); } else { printf(DHT11 read failed\r\n); } HAL_Delay(1000); // DHT11建议读取间隔不小于1秒 } }如果你发现读取频率太快导致数据不更新很可能是没满足“每次读取间隔至少 1 秒”的要求。DHT11 的测量周期约为 2 秒一次频繁读取时它会把上次缓存的数据发给你看起来就是数据一直不变。5. 常见问题与排查技巧实录5.1 读到的数据永远是 0xFF 或 0x00这是新手最常见的现象。先检查 GPIO 配置是不是正确的开漏输出加外部上拉其次检查引脚模式切换函数是否真的生效。我有一次就是漏写了DHT11_PIN_MODE_INPUT()导致引脚始终处于输出模式读回来的电平永远等于自己输出的值。另一个隐蔽的原因是接线松动。DHT11 的引脚间距是 2.54mm如果插在面包板上接触不良会导致间歇性通信失败。建议直接用杜邦线焊接或者用转接板固定。5.2 数据偶尔跳变、时好时坏这种问题多半是时序不稳定或者干扰导致的。排查顺序用逻辑分析仪抓取 DHT11 的波形确认每一位的高电平宽度是否正常。0 位应该在 26 微秒附近1 位应该在 70 微秒附近。检查数据线是否过长。DHT11 单总线通信时数据线长度建议控制在 20 米以内超过这个长度信号衰减和反射会导致误码。确认延时函数的精度。用示波器对比实际延时和理论值误差超过 10% 就很可能误判。检查电源是否稳定。DHT11 对电源纹波比较敏感如果和电机驱动共用一个电源最好加一个 100nF 去耦电容。5.3 读取间隔不够导致数据不变化很多人在写主循环时没有加延时导致毫秒级连续读取 DHT11。DHT11 内部测量频率很低每次真正测量大约需要 2 秒中间读到的都是旧数据。解决办法就是在两次读取之间添加至少 1 秒的延时。如果你需要更高的采样率应该换用 SHT30 这类 IIC 接口的传感器而不是通过“多读几次”硬撑。5.4 从 51 移植到 STM32 后完全不能用一位读者曾给我发来他移植后的代码引脚配置、读取逻辑全都对但读回来的始终是 0。后来发现他把 STM32 的 GPIO 配置成了推挽输出而不是开漏输出。51 单片机是准双向口不需要配置开漏STM32 的 GPIO 默认是推挽如果输出低电平后切回输入模式内部上下拉配置不对就会导致电平状态异常。所以移植时一定要记住单总线器件在 STM32 上最好使用开漏输出模式外部再接 4.7kΩ 上拉电阻。这样和 51 的环境最接近后续问题也会少很多。5.5 常见问题速查表现象可能原因快速排查方法数据全为 0x00引脚模式配置错误、无上拉电阻检查 GPIO 配置和外部上拉电阻数据全为 0xFF传感器未响应、供电异常测量 VCC 引脚电压检查接线校验经常失败数据线过长、时序延时不准缩短杜邦线抓取波形分析高电平宽度温度、湿度数值不更新读取间隔小于 1 秒主循环增加 1 秒以上延时偶尔读一次成功一次失败电源纹波过大、外部干扰电源端加去耦电容数据线远离强干扰源STM32 上始终读不出数据GPIO 模式不是开漏输出改用开漏输出并确认上拉电阻连接6. 工程化扩展建议6.1 从 51 到 HAL 库的移植路径如果你已经在 51 上调试通了 DHT11迁移到 STM32 HAL 库上时要分三步走第一步用宏封装 GPIO 初始化和电平读写操作。第二步实现微秒级延时推荐 DWT 或 TIM 定时器。第三步复制核心读取函数修改引脚宏定义即可。不需要改动的是起始信号、响应判断、数据位读取那几段核心逻辑。因为单总线协议本身和单片机型号无关只和时序参数有关。6.2 考虑数据缓冲与超时机制在实际产品中DHT11 驱动一定不能裸奔。建议增加以下机制读取超时保护避免死循环占用 CPU。连续多次读取失败时切换到备用传感器或输出错误状态。对温湿度数据做简单滤波例如最近 5 次取平均值修掉偶发毛刺。这些策略在开发板上无所谓但换到量产设备上就是稳定性和质保问题的分水岭。6.3 从 DHT11 走出来的能力迁移单总线协议学明白之后你可以很自然地迁移到 DS18B20 温度传感器、甚至一些定制的单总线 EEPROM 器件上。它们的基本框架一模一样区别只在指令集和时序参数上。如果再往深走IIC 和 SPI 的驱动也遵循类似的逻辑先读数据手册画出时序图再用逻辑分析仪验证实际波形最后写代码。这套“协议分析 时序验证 驱动封装”的方法论是嵌入式开发里最值钱的通用能力。我自己在调试 DHT11 的时候曾为一段读不出来数据的代码折腾了整整一天最后发现是杜邦线太长导致信号反射。从那时起我养成了一个习惯凡是单总线器件第一次调试一定要用逻辑分析仪看波形别光盯着代码。另外如果你用的是 STM32务必把微秒延时函数写好、验证好再往下调。延时不准引发的故障特别隐蔽时灵时不灵排查起来非常费时间。反过来只要时序对、电平对、校验对DHT11 这颗器件就能稳定跑很久这也是它至今仍被大量项目选用的原因。
返回列表