
简介基于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 不能走标准串口或者 I2CDHT22 是一颗典型的单总线传感器数据线只有一根既是电源地又是数据回传线。它和我们熟悉的 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 还是 0x46BH1750 是普通的 7 位 I2C 从机地址由 ADDR 引脚的电平决定。ADDR 接 GND7 位地址是 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 接 GND7位地址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约 120ms0x11连续高分辨率模式 20.5 lx约 120ms0x20一次高分辨率模式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 秒直接不启动任务。希望这些步骤和坑位能帮你少走一段弯路。本文还有配套的精品资源点击获取