☰
STM32F103驱动DHT22与BH1750:单总线与I2C传感器实战指南
2026/10/16 13:29:01 网站建设 项目流程

简介:基于STM32F103单片机搭建的温湿度与光照强度采集工程,面向嵌入式入门及传感器应用开发者,整合DHT22单总线温湿度读取、BH1750 I2C光照采集,并将结果实时显示在TFTLCD屏幕上,适用于智能家居、环境监测等场景。压缩包共150个文件,包体3.08MB,以c/h源码为主体,覆盖LCD、定时器、I2C、SPI、USART等驱动模块,并附带编译生成的o、axf、hex文件,方便直接烧录验证。已有8865人学习/下载。资料内含完整工程源码与接线说明,重点演示单总线时序解析、I2C寄存器配置、光照量程换算以及LCD显示刷新等关键实现环节;uvprojx/uvoptx工程文件可直接用Keil打开编译,适合一边阅读一边调试,借助这份工程可以快速掌握双传感器与屏幕联调的方法,并迁移到自己的环境数据采集显示项目中。

1. 把 DHT22 和 BH1750 挂在同一条 STM32F103 上:这是一个常被低估的硬件组合

做小型环境监测站或者智能家居控制面板时,STM32F103 驱动 DHT22 温湿度传感器和 BH1750 光照强度传感器是我见过最高频的选型组合。原因很直接:DHT22 便宜、能同时出温度和湿度,BH1750 走标准 I2C、光照值直接是 lux,两个加起来成本极低,却能把「室内舒适度」这件事量化到可编程。但这里有个反直觉的地方——BH1750 看起来简单,实际踩坑点比 DHT22 还多;而 DHT22 那条单总线时序,才是真正让新手反复抓狂的源头。这套资源把两个传感器的驱动代码、CubeMX 配置和示例工程都整理好了,适合正在做环境采集类项目、希望直接照搬底层驱动的开发者。新手可以顺着代码部署,熟手则能快速定位地址、时序和换算这几个关键参数。

2. DHT22 单总线驱动:时序窗口与 40 位数据校验

2.1 为什么 DHT22 不能走标准串口或者 I2C

DHT22 是一颗典型的单总线传感器,数据线只有一根,既是电源地又是数据回传线。它和我们熟悉的 UART、I2C 不同,没有时钟线来同步双方,所有时序全靠主机和从机在数据线上拉高拉低的时间宽度来约定。HAL 库里没有单总线外设,所以只能用 GPIO 模拟时序。理解这一点是驱动 DHT22 的第一步。

DHT22 的通信帧固定为 40 位,排布是:

  • 16 位湿度数据(MSB 在前)
  • 16 位温度数据(MSB 在前,最高位为符号位)
  • 8 位校验和

数据位 0 和 1 的区分不是靠电平高低,而是靠高电平持续时间。每一位都由低电平 50us 起始,随后拉高,高电平持续 26~28us 代表逻辑 0,持续约 70us 代表逻辑 1。所以读 DHT22 本质是精确测量脉冲宽度。

2.2 主机时序与读 bit 的实现代码

资源里驱动部分用的是标准外设库加寄存器操作,这样能在微秒级延时上更可控。先看引脚初始化和微秒延时函数:

// dht22.c - 引脚初始化与us级延时 // DHT22_DATA_PIN 为 PB8,开漏输出模式,外部上拉 4.7kΩ #define DHT22_GPIO_PORT GPIOB #define DHT22_GPIO_PIN GPIO_Pin_8 // 利用 Cortex-M3 内核 DWT 计数器实现精确微秒延时 // 72MHz 主频下 DWT->CYCCNT 每周期自增,延时精度可比普通 for 循环高一个量级 static void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; } static void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = (SystemCoreClock / 1000000U) * us; while ((DWT->CYCCNT - start) < ticks); }

这里把延时做到微秒级是因为后面所有时序判断都建立在 20us~80us 这个区间内。如果用 HAL_Delay,最小粒度是 1ms,直接超过 DHT22 的响应窗口,100% 读失败。

读一帧数据的核心函数如下:

// 从 DHT22 读取 40bit 数据,成功返回 0,失败返回 -1 uint8_t DHT22_Read(float *humidity, float *temperature) { uint8_t data[5] = {0}; uint8_t i, j; // 1. 主机拉低总线至少 18ms,向 DHT22 发起启动信号 // 官方手册写的是 1ms 以上,但实测 18ms 的成功率明显更高 GPIO_SetBits(DHT22_GPIO_PORT, DHT22_GPIO_PIN); GPIO_ResetBits(DHT22_GPIO_PORT, DHT22_GPIO_PIN); delay_us(19000); GPIO_SetBits(DHT22_GPIO_PORT, DHT22_GPIO_PIN); // 2. 切换引脚为输入模式,等待 DHT22 响应 // DHT22 会先拉低约 80us,再拉高约 80us,表示准备好输出数据 GPIO_InitTypeDef gpio; gpio.GPIO_Pin = DHT22_GPIO_PIN; gpio.GPIO_Mode = GPIO_Mode_IN; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT22_GPIO_PORT, &gpio); // 等待 DHT22 拉低,超时保护防止卡死 uint16_t timeout = 10000; while (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == Bit_SET) { if (--timeout == 0) return 1; } timeout = 10000; while (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == Bit_RESET) { if (--timeout == 0) return 1; } timeout = 10000; while (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == Bit_SET) { if (--timeout == 0) return 1; } // 3. 读取 40bit 数据 for (j = 0; j < 5; j++) { for (i = 0; i < 8; i++) { // 每一位都以 50us 低电平开始 while (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == Bit_SET); while (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == Bit_RESET); // 高电平宽度决定 0 还是 1 delay_us(40); if (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == Bit_SET) data[j] |= (0x80 >> i); } } // 4. 把数据口切回输出模式,为下一次通信做准备 gpio.GPIO_Mode = GPIO_Mode_OUT; gpio.GPIO_OType = GPIO_OType_OD; GPIO_Init(DHT22_GPIO_PORT, &gpio); GPIO_SetBits(DHT22_GPIO_PORT, DHT22_GPIO_PIN); // 5. 校验:前 4 字节累加低 8 位必须等于第 5 字节 uint8_t checksum = (data[0] + data[1] + data[2] + data[3]) & 0xFF; if (checksum != data[4]) return 2; // 6. 换算,温度最高位是符号位,需要单独处理 uint16_t rawHum = ((uint16_t)data[0] << 8) | data[1]; uint16_t rawTemp = ((uint16_t)data[2] << 8) | data[3]; *humidity = rawHum / 10.0f; // 温度负数处理:看符号位,负数时以补码形式出现 if (rawTemp & 0x8000) { rawTemp &= 0x7FFF; *temperature = -(float)(~rawTemp + 1) / 10.0f; } else { *temperature = rawTemp / 10.0f; } return 0; }

这段代码里最需要关注的是读 bit 时的 40us 延时判断法。因为 DHT22 数据位的高电平,判定点定在第 40us,如果此时引脚是高,多半是 70us 宽度的逻辑 1;如果已经变低,则是 28us 宽度的逻辑 0。这个办法比死等引脚跳变可靠,因为最后一次 while 可能正好错过下降沿,导致一直阻塞。

2.3 校验与换算里容易忽略的细节

40 位数据解析完,校验别再省。DHT22 在湿度突变或者供电不稳时数据是可能出错的,校验和不匹配时直接丢弃这一帧比硬用更安全。温度符号位那一步也要小心,很多现成代码直接除以 10.0f,负温度下读出来是一个莫名的大数。用上面那种先取反再转负数的补码换算,实测能覆盖到零下场景。DHT22 的湿度分辨率是 0.1%RH,温度分辨率 0.1℃,单次精度官方给的 ±2%RH 和 ±0.5℃,如果只做室内环境参考完全够用。

3. BH1750 光照传感器:I2C 地址、测量模式与 lux 换算

3.1 I2C 地址怎么确定:0x23 还是 0x46

BH1750 是普通的 7 位 I2C 从机地址,由 ADDR 引脚的电平决定。ADDR 接 GND,7 位地址是 0x23;接 VCC,地址是 0x5C。但不少人在代码里把地址直接写成 0x23 发给 I2C 控制器,这里犯了一个低级错误:I2C 通信时发送的地址字节是 7 位地址左移一位,最低位是读写标志。因此 ADDR 接 GND 时,实际发送的写地址是 0x46,读地址是 0x47。HAL 库的HAL_I2C_Master_Transmit第二个参数要求的就是 8 位地址,所以必须传 0x46。资源里默认按 0x46 写,如果你的板子 ADDR 引脚接了 VCC,就要改成 0xB8。

初始化代码如下:

// bh1750.c - I2C 地址定义与初始化 #define BH1750_ADDR_W 0x46 // ADDR 接 GND,7位地址0x23左移一位 #define BH1750_ADDR_R 0x47 // 发送单字节命令,BH1750 的命令码都是 8 位 static void BH1750_WriteCmd(uint8_t cmd) { HAL_I2C_Master_Transmit(&hi2c1, BH1750_ADDR_W, &cmd, 1, 200); } void BH1750_Init(void) { // 先上电,再选择连续高分辨率模式 BH1750_WriteCmd(0x01); // 0x01: Power On BH1750_WriteCmd(0x10); // 0x10: Continuously H-Resolution Mode }

上电命令 0x01 不能省。传感器一出厂或者在掉电模式下,直接写测量命令会没有响应。

3.2 测量模式选不对,光照值差好几倍

BH1750 的命令码里有一组是测量模式,常见的有:

模式命令码含义分辨率测量时长
0x10连续高分辨率模式1 lx约 120ms
0x11连续高分辨率模式 20.5 lx约 120ms
0x20一次高分辨率模式1 lx约 120ms,测完自动掉电
0x13连续低分辨率模式4 lx约 24ms

多数室内场景选 0x10 就够用。模式 2 虽然分辨率翻倍,但量程上限会下降,强光直射场景下容易饱和溢出。一次性模式和连续模式的区别在于测完是否自动进入掉电状态——如果用 0x20,下次读数据前必须重新发上电命令,否则读回来的永远是旧数据。资源里默认用连续模式,省掉每次读取前的命令交互,调度起来也更方便。

3.3 读取两字节并换算成 lux 数值

BH1750 的测量结果是两字节无符号整数,高位在前,低位在后。换算公式很简单:raw / 1.2f,但要注意这个 1.2 是传感器在默认积分时间下的固定换算系数,不是随便定的。

// 从 BH1750 读取当前光照强度,返回 lux float BH1750_ReadLux(void) { uint8_t buf[2] = {0}; // 连续模式下直接读两字节,注意地址是 0x47 读地址 HAL_I2C_Master_Receive(&hi2c1, BH1750_ADDR_R, buf, 2, 200); uint16_t raw = ((uint16_t)buf[0] << 8) | buf[1]; return raw / 1.2f; }

读回来的原始值范围是 0~65535。除以 1.2 后最大约 54612 lx,日常室内光照一般在 50~500 lx 之间,晴天窗边能到 3000~8000 lx,这个量程完全覆盖。如果发现某个时间点读回 65535 或 0,多半不是环境问题,而是传感器掉电或者 I2C 总线异常,后面避坑章节会提到。

4. 双传感器整合:非对称采样调度与状态机写法

4.1 为什么 DHT22 要等两秒而 BH1750 只要 500ms

两个传感器工作节奏完全不同。DHT22 单总线协议要求完整的一次通信包含 18ms 启动低电平、80us 响应窗口、40bit 数据传输,整体耗时约 5ms,但 DHT22 内部完成一次测量需要 2 秒左右,连续两次读取之间的间隔如果低于 2 秒,读到的数据要么是上一次的缓存,要么直接校验失败。BH1750 则不同,高分辨率模式一次测量约 120ms,测量完成后数据寄存器会保持,连续模式下可以随时读取,采样间隔压到 500ms 完全没问题。

合理做法是两种采样周期独立调度,而不是用一个大循环阻塞式地先后读取。一个简单的秒表轮询写法如下:

// main.c - 非阻塞采样调度 uint32_t lastDht22Tick = 0; uint32_t lastBh1750Tick = 0; float humidity = 0, temperature = 0, lux = 0; while (1) { uint32_t now = HAL_GetTick(); // DHT22 采样间隔 2000ms,与内部测量周期对齐 if ((now - lastDht22Tick) >= 2000) { if (DHT22_Read(&humidity, &temperature) == 0) { printf("Temp: %.1f C, Hum: %.1f %%RH\r\n", temperature, humidity); } lastDht22Tick = now; } // BH1750 采样间隔 500ms,适合光照变化较快的场景 if ((now - lastBh1750Tick) >= 500) { lux = BH1750_ReadLux(); printf("Lux: %.1f\r\n", lux); lastBh1750Tick = now; } }

这段代码里最核心的是now - lastDht22Tick >= 2000的判断方式。用差值比较而不是每次把 lastDht22Tick 直接赋值为 now,可以避免在某个采样周期内其他任务耗时过长导致的时间漂移。同时 DHT22 读取失败时不要立刻重试,等下一个周期再读,否则连续失败会让 main 循环卡死在协议等待中。

4.2 状态机设计:把传感器读取拆成可扩展的任务模块

如果后续想加一个 OLED 显示或者继电器控制,上面那种裸的 while 循环会膨胀成一团。资源里工程模板推荐的做法是定义一套简单的任务结构体:

// scheduler.h - 极简任务调度结构 typedef struct { uint16_t interval; // 采样间隔 ms uint32_t lastRun; // 上一次执行时间戳 void (*taskFunc)(void); // 任务函数指针 } SchedTask;

主循环遍历任务表,逐个判断时间戳差值,命中则执行对应的回调函数:

SchedTask tasks[] = { {2000, 0, Task_DHT22_Read}, {500, 0, Task_BH1750_Read}, {1000, 0, Task_OLED_Update}, }; while (1) { for (int i = 0; i < sizeof(tasks) / sizeof(tasks[0]); i++) { if ((HAL_GetTick() - tasks[i].lastRun) >= tasks[i].interval) { tasks[i].taskFunc(); tasks[i].lastRun = HAL_GetTick(); } } }

这种结构的好处是新增传感器或执行器时只需往任务表里加一行,采样周期在表内一眼可调。调试时发现问题传感器,也可以直接把对应条目暂时注释掉,不影响其他任务运行。

5. 常见问题避坑:从 DHT22 时序翻车到 I2C 总线挂死的排查

5.1 DHT22 上电后第一次读取全部为 0 或校验失败

现象:程序烧录后,串口打印前几帧数据全是 0,或者频繁返回校验错误,等几分钟后逐渐正常。 原因:DHT22 上电后需要至少 1 秒稳定时间,期间内部还在初始化,主机如果立刻发起启动信号,传感器根本不会响应。 解决:main 函数初始化完成后,加至少 2 秒的延时再开始调度 DHT22 读取。也可以采取丢帧策略——前三次读取结果直接丢弃,只保留后续数据。

5.2 BH1750 一直读到 0xFF 0xFF

现象:BH1750 返回的原始值是 65535,换算出来的 lux 永远是 54612,比较夸张。 原因:常见是两个。一是 I2C 地址字节写错,传了 0x23 而不是 0x46;二是传感器还处于掉电模式,接到测量命令没有反应。 解决:用示波器或者逻辑分析仪抓 SCL 和 SDA 波形,确认发送的地址字节。软件上每次读数据前强制发一遍 0x01 上电命令再发 0x10 测量命令,能规避多数异常状态。如果波形正常,检查 SDA 和 SCL 上拉电阻,常见值 4.7kΩ 到 10kΩ 都可以。

5.3 DHT22 偶发跳变,温度从 25℃ 突然跳到 58℃

现象:温度曲线整体平稳,但每隔几十秒出现一次异常尖峰,湿度也有类似问题。 原因:微秒级延时被中断打断。STM32F103 的 SysTick 默认每 1ms 触发一次中断,如果读 bit 过程中 SysTick 插入,40us 的判断点会被延迟到 1ms 之后,逻辑 0 被误判成逻辑 1。 解决:读 DHT22 时关中断是最直接的办法,在DHT22_Read函数开头加__disable_irq(),读完后再__enable_irq(),代价是阻断中断约 5ms,对大多数应用可以接受。如果项目里有高优先级的中断源,可以把 DHT22 读取放到无 RTOS 环境下的主循环空闲段执行。

5.4 两个传感器共用 3.3V 电源时读数互相干扰

现象:BH1750 的光照值在 DHT22 读取瞬间出现毛刺,I2C 总线偶发 NACK。 原因:DHT22 启动信号会把数据线拉低 18ms,此时电流尖峰较大,如果和 I2C 共用一个稳压源或者同一个 3.3V 节点,电压被拉低到 BH1750 的最低工作电压以下,导致传感器复位或总线状态错乱。 解决:硬件上给 DHT22 供电串一个 100Ω 电阻,数据线上串 100Ω 到 220Ω 的限流电阻,同时 BH1750 的 I2C 上拉电阻和 DHT22 的供电不要走同一个过孔,分两个支路布线。软件上可以在 DHT22 读取期间延后 BH1750 的轮询,把这个时间窗口错开。

5.5 DHT22 在快交替读取时长期校验失败

现象:把 DHT22 采样间隔改成 500ms 后,几乎每帧都校验失败,偶发成功一次又是旧数据。 原因:DHT22 内部测量周期约 2 秒,间隔短于这个时间时,传感器处在“重新测量”状态,不会进入单总线的数据输出阶段。你读到的其实是一段无意义电平。 解决:强行把采样间隔拉回 2 秒以上是治标,更稳的做法是在调度器里做一个“失败冷却”计数,连续失败 3 次后自动把间隔扩展到 3 秒,等数据稳定后再恢复 2 秒采样。这套资源里的任务调度结构已经预留了lastRun时间戳,实现这种退避机制只需要几行代码。

6. 进阶验证:BH1750 换挡补偿与 DHT22 湿度温漂修正

两个传感器都跑通后,可以做两层进阶验证:一是强光场景下 BH1750 的溢出换挡,二是高湿环境下 DHT22 的修正。这两个点做好了,整个采集系统的数据可信度会明显提升。

BH1750 在阳光直射或靠近高亮光源时,默认 0x10 模式可能饱和,读数固定在 54612 lx。此时可以降低传感器的积分时间,修改内部测量寄存器 MTreg。MTreg 默认值是 69,通过命令 0x40 写入新的 MTreg 值后,传感器响应会变灵敏但分辨率下降。补偿方式是读取原始值后乘以69.0f / MTreg。例如把 MTreg 设为 34,换算公式就变成raw * (69.0f / 34.0f) / 1.2f。这个换挡技巧能有效扩展量程上限。

DHT22 的湿度在 80%RH 以上时误差会朝大值方向偏移,特别是在温度超过 30℃ 的环境里,湿度的正偏差更明显。常见做法是用一个经验修正系数:实际湿度约为读取值乘以 (1.0f - temperature / 1000.0f)。这个修正在 30℃ 时大约修正 3%,高湿高热的场景下实测能有效拉平偏差。资源工程里留了CAL_OFFSET宏定义,方便直接代入环境参数。

验证方法也很简单:用串口定时打印原始值和修正值,连续跑 24 小时,对比每天的曲线形态。重点看两点——湿度尖峰是否还出现,lux 读数在正午强光时段是否还有平头。确认平稳后,这套采集链路就可以放心接 OLED 显示或继电器控制逻辑。

我自己的习惯是每次调完传感器驱动后,先连续打印 200 帧原始数据再写应用层逻辑,用日志确认校验和错误率低于 1% 才继续。曾经有一次把 DHT22 采样间隔压到 1 秒,温度曲线一整天都在周期性抖动,排查到最后才发现是内部测量周期没对齐。从那以后,凡是涉及单总线传感器,我都会在任务表里强制加一条采样间隔的下限校验,低于 2 秒直接不启动任务。希望这些步骤和坑位能帮你少走一段弯路。

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

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

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

立即咨询