☰
STM32F103驱动DHT22与BH1750:环境监测传感器组合实战指南
2026/10/16 7:44:33 网站建设 项目流程

简介:这是一套面向STM32F103单片机学习者的实战项目包,围绕DHT22温湿度传感器和BH1750光照强度传感器展开,演示了单总线与I2C两种通信协议的处理流程,最终把实时数据展示在液晶显示屏上,对于想理解传感器驱动、准备课程设计或环境监测类小项目的开发者具有直接参考价值。压缩包共150个文件,整体约3.08MB,主要包含C语言源文件与头文件、Keil工程配置文件、链接脚本和编译生成的AXF、HEX烧录文件,另有TXT说明和HTM文档辅助阅读,源码、工程与固件齐全,可对应学习从初始化到上板运行的完整链路。已有8865人学习下载,热度较高。通过研读工程,读者能掌握GPIO模拟时序、定时器脉冲宽度解析、I2C读写光强芯片、LCD绘图显示等关键操作;作者提供的接线说明还能帮助排查常见硬件错误,适合边看边练,为后续接入更多传感器打下基础。

1. STM32F103驱动DHT22和BH1750:这套环境监测组合值得直接抄作业

做嵌入式项目最烦的不是写业务逻辑,而是传感器数据读不准。DHT22温湿度传感器和BH1750光照强度传感器是低成本环境监测里最常见的搭配,前者用单总线协议,后者走I2C,正好覆盖了两种最典型的低速通信方式。这套组合能解决小型气象站、智能家居中控、大棚监测这类场景里的核心数据采集问题,而且STM32F103的普通GPIO就能同时搞定两个协议,不必额外买硬件I2C模块。我拆过不少用这两颗传感器的项目,发现大部分翻车都发生在时序细节上,比如DHT22的起始信号宽度、BH1750的测量模式选择,这些在数据手册里写得很含糊,实际调试时全靠示波器和反复试错。这篇文章把从选型、接线、驱动到排错的完整链路拉通,给出可以直接编译运行的代码框架,你会很清楚每一行在干什么,以及改哪些参数能适配自己的板子。

2. 硬件选型与接线:为什么是DHT22而不是DHT11,光照用BH1750的I2C优势

2.1 传感器选型对比:DHT22的精度边界与BH1750的量程设计

很多入门教程首选DHT11,因为它便宜、代码网上到处都是。但DHT11的湿度精度只有±5%RH,温度精度±2℃,而且采样周期必须大于1秒,这对需要做趋势判断的场合完全不够用。DHT22(又称AM2302)的湿度精度做到±2%RH,温度精度±0.5℃,分辨率是0.1℃,采样周期可以缩短到2秒一次。价格大概贵一倍,换来的是数据可用性大幅提升。我做环境监测项目时,DHT11的数据波动大到没法直接画曲线,必须做滑动平均才能看;DHT22的读数就比较平稳,原始数据稍微滤波就能用。

BH1750这颗光照传感器有个很大的优点:内部集成ADC和光敏二极管,输出直接是勒克斯(lux)值,不需要自己做线性校准。它的量程分三档,通过指令寄存器切换测量模式,最低分辨率可以做到0.5 lux,最高量程可达65535 lux。相比之下,用光敏电阻分压再查表换算的方案虽然更便宜,但每个器件的一致性差,批量生产时每个都要单独标定,很不划算。BH1750还有一个特性是光谱响应接近人眼,这个特性对智能照明控制特别重要,因为我们要的是“人感觉有多亮”,而不是红外光有多强。

从通信接口角度选型也值得说。DHT22的单总线协议对时序要求苛刻,但GPIO模拟完全可行;BH1750的I2C接口电平兼容3.3V和5V,STM32F103的PB6、PB7正好是硬件I2C1引脚,不过很多人宁愿用普通GPIO模拟I2C,后面我会解释为什么。这两颗传感器组合在一起,数字接口都不需要ADC外设,所以哪怕用最便宜的STM32F103C6T6也能跑得动。

2.2 接线表与原理图理解:上拉电阻和电源去耦怎么处理

接线本身并不复杂,但有两个容易忽略的点:上拉电阻和电源去耦。DHT22的数据线是开漏输出,必须在数据线和VCC之间接一个4.7kΩ到10kΩ的上拉电阻,否则高电平根本拉不起来。BH1750的I2C数据线SDA和时钟线SCL同样需要上拉,一般接4.7kΩ。如果这两颗传感器同时接到同一个3.3V电源轨,建议在它们的VCC和GND之间各加一个100nF的陶瓷电容,位置尽量靠近传感器引脚,用来滤除电机、继电器这类负载带来的电源毛刺。

下面这张接线表是我常用的一套分配,用的是STM32F103C8T6最小系统板,PB6和PB7留给硬件I2C的备用方案,但驱动代码里用的是GPIO模拟,所以随便选两个推挽输出引脚就行:

传感器引脚STM32F103引脚说明
DHT22 VCC3.3V供电范围3.3V~5V,但5V供电时输出高电平可能超过MCU耐压,建议统一3.3V
DHT22 GNDGND共地
DHT22 DATAPA0配置为开漏输出或推挽输出+外部上拉
BH1750 VCC3.3V供电2.4V~3.6V
BH1750 GNDGND共地
BH1750 SCLPB10GPIO模拟时钟线,推挽输出
BH1750 SDAPB11GPIO模拟数据线,开漏输出+外部上拉

这里特别提一下DHT22的数据引脚配置。很多人的代码把DHT22的DATA引脚设置成推挽输出,这个能工作,但存在风险:如果传感器内部上拉到VCC,而你输出低电平,外部电路倒灌电流可能超出GPIO的灌电流能力。我一般用开漏输出模式,在外部加上拉电阻,这样读数据时把引脚切换成输入模式,逻辑上跟标准单总线协议完全一致,而且更安全。STM32的GPIO配置成开漏输出时需要把输出数据寄存器置1,否则引脚会一直保持低电平。

2.3 硬件清单与注意事项

除了传感器和单片机,还需要准备面包板、杜邦线若干、一个10kΩ电位器(用来调试ADC时可选的,但这里不一定用得上)、以及一个逻辑分析仪或示波器。逻辑分析仪是强烈建议的,价格不高,但能直接看到DHT22的脉冲宽度和I2C波形,排查时序问题比靠猜快十倍。我最初调DHT22时就是靠逻辑分析仪发现起始信号宽度差了10微秒,导致传感器不响应。

接线时还要注意总线长度。DHT22的单总线协议规定线缆长度不要超过20米,但实际使用中超过2米就可能因为寄生电容导致边沿变缓。BH1750的I2C总线在100kHz标准模式下,总电容最好控制在400pF以内,所以传感器到单片机的连线越短越好。如果项目需要远距离布置传感器,建议改用RS485透传模块,而不是硬拉长杜邦线。

3. 驱动层实现:单总线时序和I2C读取的代码级拆解

3.1 DHT22单总线驱动:时序、校验和与超时处理

DHT22通信协议是单总线,只有一根数据线,所有通信由主机发起。一次完整的读时序分三个阶段:主机发送起始信号、传感器响应、传感器发送40位数据。每个位由一段低电平加一段高电平组成,高电平持续26~28微秒表示“0”,持续70微秒表示“1”。难点在于起始信号和读取时序里,延时必须要精确到微秒级。

先给出最常用的阻塞式读取代码,基于STM32标准外设库:

// DHT22_Read: 从DHT22读取一次温湿度数据,成功返回0,失败返回-1 // data数组中:data[0]湿度高字节 data[1]湿度低字节 data[2]温度高字节 data[3]温度低字节 data[4]校验和 // 使用GPIOA Pin0,需要先初始化 uint8_t DHT22_Read(uint8_t data[5]) { uint8_t i, j; uint8_t last_state = 1; uint8_t counter = 0; // 主机拉低起始信号,至少18ms GPIO_ResetBits(GPIOA, GPIO_Pin_0); Delay_Ms(20); // 拉高并延时20~40us,等待传感器响应 GPIO_SetBits(GPIOA, GPIO_Pin_0); Delay_Us(30); // 切换为输入模式 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure); // 等待传感器拉低(响应信号开始) // 超时判断:循环100000次大约等于几十毫秒 for (i = 0; i < 100000; i++) { if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0) break; } // 等待传感器拉高(响应信号结束) for (i = 0; i < 100000; i++) { if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 1) break; } // 开始读取40位数据 for (j = 0; j < 5; j++) { data[j] = 0; for (i = 0; i < 8; i++) { // 等待低电平结束 while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0); // 延时40us,如果仍为高电平则记为1 Delay_Us(40); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 1) { data[j] |= (uint8_t)(0x80 >> i); } // 等待高电平结束 while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 1); } } // 恢复输出模式,用于下次通信 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 校验和:前四个字节相加取低8位应等于第五字节 if ((uint8_t)(data[0] + data[1] + data[2] + data[3]) != data[4]) { return 1; // 校验失败 } return 0; }

这段代码的逻辑是经典的主机读时序。Start信号拉低至少18ms,这个时间必须足够长,DHT22才能识别为一次新的读取请求。拉高后延时30us,正好避开传感器响应信号的前沿抖动。之后传感器会主动拉低约80us,再拉高约80us,表示“我准备好发数据了”。接下来40个数据位,每个位都以50us低电平开始,然后是高电平的持续时间决定0还是1。

这里最关键的是Delay_Us(40)。我在多个型号的单片机上实测过,这个40us不能减到35us以下,否则容易把时间长的“1”误判成“0”;也不能加大到50us,否则“1”的高电平还没走完就变成读取下一个位。STM32F103主频72MHz下,这个延时用简单的for循环就能得到较好精度,但如果你换了其他主频的MCU,必须重新校准Delay_Us函数,最好用定时器或者SysTick来实现。

另外注意这个代码用了浮空输入模式。因为外部已经有上拉电阻,DHT22拉低时能可靠读到0,不拉时靠上拉读到1,所以用浮空输入没问题。

3.2 BH1750的I2C驱动:地址、测量模式与连续读取

BH1750的I2C地址由ADDR引脚决定。ADDR接地时地址是0x23(7位地址),接VCC时是0x5C。很多模块上ADDR引脚已经有默认拉低处理,所以初始代码一般用0x23。I2C通信时,7位地址左移一位得到8位地址,写操作是0x46,读操作是0x47。

BH1750支持多种测量模式,区别在于分辨率、测量时间和功耗。我常用的是连续高分辨率模式,指令码0x10,分辨率1 lux,测量时间典型值120ms。如果需要更低的功耗,可以用一次测量模式,指令码0x20,每次测量完自动掉电。要得到0.5 lux的分辨率,用连续高分辨率模式2,指令码0x11,但测量时间会翻倍。

下面是用GPIO模拟I2C的读取代码,只展示核心部分。完整代码里需要I2C_Start、I2C_Stop、I2C_WriteByte、I2C_ReadByte四个基础函数。

// BH1750_ReadLux: 读取光照强度,返回lux值 // 返回-1表示通信失败,否则返回实际光照值 int32_t BH1750_ReadLux(void) { uint8_t buf[2]; uint16_t raw; // 发送连续高分辨率测量指令 I2C_Start(); if (I2C_WriteByte(0x46)) { // 写地址0x23<<1 = 0x46 I2C_Stop(); return -1; } if (I2C_WriteByte(0x10)) { // 连续H-分辨率模式 I2C_Stop(); return -1; } I2C_Stop(); // 等待测量完成,典型120ms,这里等待150ms确保稳定 Delay_Ms(150); // 读取2字节测量结果 I2C_Start(); if (I2C_WriteByte(0x47)) { // 读地址 I2C_Stop(); return -1; } buf[0] = I2C_ReadByte(1); // 第一个字节,后面还有数据 buf[1] = I2C_ReadByte(0); // 第二个字节,主机发送NACK I2C_Stop(); raw = ((uint16_t)buf[0] << 8) | buf[1]; // 分辨率为1 lux/LSB,所以直接除以1 return (int32_t)raw; }

这里有一点要解释:操作指令是写操作,地址后面跟的0x10是命令,不是数据。BH1750没有寄存器地址的概念,它通过命令码来切换模式。读取时,先发读地址,然后连续读两个字节,第一个字节是结果的高8位,第二个是低8位。I2C读取最后一个字节时主机要回NACK,否则传感器会继续发送数据,导致总线冲突。

为什么不用STM32硬件I2C?硬件I2C确实更省CPU,但STM32F103的硬件I2C在总线繁忙恢复、错误标志处理上经常出问题,尤其遇到传感器模块上的滤波电容影响边沿时,容易出现超时或忙等待。GPIO模拟I2C代码量不大,时序完全可控,出问题也好查逻辑分析仪波形。如果你的项目里I2C设备很多,或者需要高速传输,再考虑硬件I2C,并善用超时机制。

3.3 主循环调度:两个传感器不互相拖累的轮询策略

两个传感器的最大问题在于读取时间差。DHT22一次读取需要约20ms的起始信号加上数据位的读取,整个读时序大约25ms;而且DHT22要求两次读取间隔至少2秒,否则传感器数据不更新。BH1750在连续模式下,每次读取前需要等待测量完成,至少120ms。如果把两个传感器放在同一个主循环里顺序调用,那么总耗时大约是150ms左右,对于环境监测来说完全够用,但最好加一个简单的调度策略,让DHT22严格保持2秒间隔。

我一般用状态机方式管理采集任务,而不是在主循环里用Delay阻塞:

// 简单的非阻塞调度结构 typedef struct { uint32_t next_time; // 下次采集时间戳,单位ms uint32_t interval; // 采集间隔 void (*task_func)(void); // 采集函数 } Task_t; Task_t tasks[] = { {0, 2000, DHT22_Task}, // DHT22每2秒读一次 {0, 1000, BH1750_Task}, // BH1750每1秒读一次 }; void Main_Loop(void) { uint32_t now = Get_TickMs(); for (int i = 0; i < sizeof(tasks)/sizeof(tasks[0]); i++) { if (now >= tasks[i].next_time) { tasks[i].task_func(); tasks[i].next_time = now + tasks[i].interval; } } // 其他业务代码 }

这个调度器的核心在于用Get_TickMs()获取系统毫秒时间戳,而不是靠delay死等。DHT22内部传感器采样周期是2秒,如果你读得太频繁,它会返回上一次的数据,造成同样的值被重复读取,但至少不会报错。BH1750则是每次测量需要时间,还没等转换完成就读取的话,得到的是上一次的值。所以这里的间隔设置宁可偏大一点。

要注意的是状态机不能解决DHT22读时序里的阻塞问题,因为读DHT22的40个位需要微秒级的精确延时,这个过程中CPU不能被打断去做别的,否则时序就乱了。所以严谨的做法是读DHT22时关中断,或者用后面第6章讲的DMA加定时器的非阻塞方案。对于简单项目,关中断几十微秒是可以接受的。

4. 数据处理与显示:从原始寄存器到可用的温度湿度光照值

4.1 DHT22数据转换:16位温湿度拼接与符号位处理

DHT22的40位数据中,湿度占16位,温度占16位,校验和占8位。湿度数据最高位是湿度整数部分的最高位,但实际上DHT22内部把湿度放大10倍输出,所以原始值除以10才是实际湿度百分比。例如原始值0x0258,十进制600,实际湿度60.0%RH。温度同样放大10倍,且最高位为1表示零下温度。

温度数据处理时需要注意负数情况。数据手册说温度是16位有符号数,但实际发送的是二进制补码,最高位为1时表示负温度。有的人直接把高字节赋值给有符号char,这样会得到错误的负数,正确做法是先拼成uint16_t,再进行符号扩展:

// DHT22数据解析 // data数组来自DHT22_Read,包含5字节 float DHT22_GetHumidity(uint8_t data[5]) { uint16_t raw_humi = ((uint16_t)data[0] << 8) | data[1]; return (float)raw_humi / 10.0f; } float DHT22_GetTemperature(uint8_t data[5]) { uint16_t raw_temp = ((uint16_t)data[2] << 8) | data[3]; // 处理零下温度:最高位为1 if (raw_temp & 0x8000) { raw_temp &= 0x7FFF; // 去掉符号位 return -(float)raw_temp / 10.0f; } return (float)raw_temp / 10.0f; }

这段代码把温度高位字节左移8位拼上低位,得到一个无符号数,然后检查最高位。如果为1,先去掉符号位,再取负值。这个处理方式比直接强制转换成int16_t更稳妥,因为在不同编译器下,从uint16_t转int16_t是标准行为,但并不是所有人都记得补码规则。

很多项目里还会对湿度做饱和处理。DHT22的湿度测量范围是0~100%RH,但有时读出来会略超过100,比如100.1%,这通常是传感器个体差异,可以直接用if (humi > 100.0f) humi = 100.0f;截断。

4.2 BH1750光照换算:分辨率模式与光强计算公式

BH1750的原始值是一个16位整数,它跟实际光照的关系取决于当前测量模式。高分辨率模式下,每个LSB等于1 lux;高分辨率模式2下,每个LSB等于0.5 lux;低分辨率模式下,每个LSB约等于4 lux(实际是3.6左右,但手册中把它定义为4 lux)。如果使用默认的连续高分辨率模式,不需要任何换算,原始值就是勒克斯数。

但BH1750还有一个人为可调的“测量时间寄存器”。通过改变测量时间,可以调整灵敏度和测量范围。指令码0x40可以设置测量时间。测量时间缩短,分辨率降低,但测量范围变大;测量时间加长,分辨率提高,但测量范围变小。这个参数在实际项目中很少调整,但如果你的应用环境有强光(比如太阳直射超过60000 lux),原始值可能饱和在65535,这时可以缩短测量时间或切换为低分辨率模式来扩大量程。

换算代码很简单:

// 根据模式计算光照值 // mode: 0=高分辨率1x,1=高分辨率2x,2=低分辨率 uint32_t BH1750_CalculateLux(uint16_t raw, uint8_t mode, uint8_t meas_time) { float lux; switch (mode) { case 0: lux = (float)raw; break; // 1 lux/LSB case 1: lux = (float)raw * 0.5f; break; // 0.5 lux/LSB case 2: lux = (float)raw * 4.0f; break; // 约4 lux/LSB default: lux = (float)raw; break; } // 测量时间修正:默认69,如果改了meas_time要按比例修正 lux *= (69.0f / meas_time); return (uint32_t)lux; }

注意最后一行,如果调整了测量时间,光照值会跟测量时间成反比。比如你设定测量时间为138(默认的2倍),那么同样的亮度下原始值翻倍,所以要以默认时间69为基准做比例缩放。这个特性很多人忽视,导致换了测量时间后读出的lux值偏大一倍。

4.3 串口输出与OLED显示的可选接入

数据解析完之后,最直接的验证方式是通过串口把数据打印出来。DHT22的数据帧是5字节,建议打印时带上时间戳和校验状态。BH1750是2字节原始值,加上解析后的lux。串口波特率我一般用115200,环境监测数据每秒一次完全不会拥堵。

如果需要OLED显示,最常见的方案是0.96寸I2C接口OLED模块,SSD1306驱动,也挂在I2C总线上。这时要注意地址冲突:BH1750默认地址0x23,SSD1306默认地址0x3C,两者地址不同,可以共享同一条I2C总线,各用一个GPIO模拟即可,或者用一个模拟I2C轮询访问两个设备。OLED刷新频率建议5Hz以下,太快可能导致DHT22单总线时序被打断,因为OLED刷新一屏需要发送1KB大小的缓冲区,耗时几十毫秒,这期间不能关中断太久。

下面是一个简单的串口输出拼接示例,用sprintf格式化浮点数:

char buf[64]; sprintf(buf, "Temp: %.1f C, Humi: %.1f %%, Lux: %d lx\r\n", temp, humi, lux); UART_SendString(USART1, buf);

注意单片机里用sprintf带浮点会增大代码体积,如果flash紧张,可以把浮点乘以10转成整数后再拆分输出。我一般习惯用整数方式格式化成字符串,既省flash又避免了某些编译器下浮点printf库不支持的坑。

5. 避坑与常见问题排查:我调这两颗传感器时踩过的五个坑

5.1 现象:DHT22老是读到0或超时

典型现象是代码执行到读时序时一直卡在while(GPIO_ReadInputDataBit(...)==0)死循环,或者读出的数据全部为0,偶尔校验通过但值明显不对。

原因通常是起始信号宽度不够或者上拉电阻太大。DHT22要求起始低电平至少18ms才能唤醒,有些人的代码只延时了2ms,传感器根本没响应。另外如果上拉电阻用了100kΩ,数据线上升沿会非常缓慢,传感器输出高电平的脉冲宽度被拉宽,导致位判断错乱。

解决方法是先用万用表确认传感器供电正常,然后用逻辑分析仪看数据线波形。如果抓不到任何波形,说明传感器没被唤醒,检查起始信号。如果有波形但数据乱,检查上拉电阻是否在4.7kΩ~10kΩ范围内。另外有一种隐蔽问题:有些DHT22模块板上已经带了上拉电阻,如果你外部又加了一个,实际阻值变成两个并联,可能小于2kΩ,加重了传感器输出级负担。我后来养成习惯,模块上自带上拉的就不再外接。

5.2 现象:BH1750读出来固定为65535

读回原始值一直是0xFFFF,lux计算出来是65535。一般不是传感器坏了,而是I2C读取时序有问题。

常见原因是主机在读取第二个字节时没有发NACK。BH1750的规则是主机读最后一个字节后必须发送NACK,然后发STOP。如果发送了ACK,传感器会认为你还想继续读,总线状态异常,导致后续读错或总线卡死。另一个原因是在发送测量指令后等待时间不够。有些快速代码只等了50ms,但高分辨率模式需要120ms,还没转换完就读,读到的是旧值或者上电默认值0xFFFF。解决办法是严格按手册等待,或者实现一个“测量完成”查询,但BH1750没有状态寄存器,只能等固定时间。

还有一个很少被提到的问题:SDA和SCL接反。如果模块上丝印模糊,容易把两个线接反,I2C地址和所有命令都会失效。用万用表量一下模块上的引脚定义,或者换两块模块交叉验证。

5.3 现象:温湿度数据跳动超过规格书标称

DHT22在静止空气中的湿度读数应该有较好的稳定性,但实际使用中振幅超过±3%RH,温度波动超过±0.5℃。

原因之一是传感器距离发热元件太近,或者封装外壳导热点到了电路板。DHT22对气流比较敏感,空调风口、手呼出的气、甚至人的体温辐射都会影响读数。另一个常见原因是STM32的GPIO配置问题。如果读时序中把引脚设置成带上拉输入模式,而外部上拉电阻又存在,读到的低电平阈值可能不准确。但更隐蔽的是,DHT22输出数据时电流很小,如果信号线走线过长且旁边有大电流线路,会受到干扰。

解决办法是硬件上让传感器离开MCU板至少几厘米,用短的杜邦线连接,传感器感应部分朝向空气流通稳定的一侧。软件上可以做滑动滤波,比如连续取5次,去掉最大值最小值后取平均。但要注意,DHT22本身采样周期2秒,滤波窗口不能设置太短,否则平均来平均去全是同一个值。

5.4 现象:两个传感器挂同一个I2C总线上互相干扰

BH1750和OLED或者其他I2C设备共用一个总线时,读取其中一个会导致另一个通信异常,尤其是加了GPIO模拟I2C后,容易出现总线忙死。

原因多半是I2C通信过程中被中断打断。GPIO模拟I2C的时序依赖精准延时,如果中断里做了耗时操作(比如OLED刷新、DHT22读取),I2C的时钟高电平和低电平时间会被拉长,但大部分从设备对时钟延时不敏感,问题在于如果时间过长超过了从设备超时阈值,从设备会复位,导致总线状态错乱。解决方法是GPIO模拟I2C的每个字节之间检查一下SDA线状态,如果为低说明从设备还想继续传输,需要发送额外的时钟脉冲让它释放总线。

另一个办法是将BH1750和OLED放在不同的“虚拟I2C总线”上,即用两对GPIO分别驱动,物理上不共用。这样虽然多占两个引脚,但排查问题简单,两个设备之间的电气影响几乎为零。我在原型阶段就是这么做的,成本多两根杜邦线。

5.5 现象:单片机复位后传感器偶发无响应

DHT22在上电后需要1~2秒稳定时间,BH1750也一样。如果复位后立刻执行第一次读取,DHT22可能响应正常,但BH1750可能返回全0或者超时,因为它的内部状态机还没初始化完成。

解决方法是上电后先延时500ms再初始化传感器,第一次读取尽量只做一次空读(丢弃结果),让传感器进入正常状态。很多BSP代码里都有这个习惯。另外,如果单片机频繁复位,建议在驱动层加一个“连续失败计数器”,连续失败3次后重新初始化GPIO,而不是一直卡在while循环里。这可以避免因为传感器故障导致整个系统挂死。

还有一个细节:DHT22读时序里,如果芯片在读取过程中发生中断,中断处理时间过长,会导致位采样点偏移。如果你开了多个串口中断或者定时器中断,建议在DHT22读取的整个函数里关中断,或者至少把优先级设到最低。我见有人把DHT22读取放在定时器中断里,这是大忌,因为DHT22一次读取需要几十微秒,在中断里阻塞太长时间,会破坏系统实时性。

6. 进阶:用DMA加定时器把单总线读时序变成不阻塞的完整方案

6.1 核心思路:状态机替代延时等待

在上面所有代码里,DHT22读取都是阻塞的,CPU在采样脉冲时要一直死等。这在简单轮询系统里没问题,但只要项目里还有电机控制、无线通信或者界面响应,阻塞式读取就会造成明显的卡顿。进阶方案是用输入捕获+DMA一次性记录整段波形,然后离线解析。

思路是这样:让STM32的定时器工作在输入捕获模式,捕获引脚DHT22_DATA的每个上升沿和下降沿,用DMA把捕获到的计数器值搬运到内存数组。每当GPIO产生一次跳变,定时器捕获当前计数值,DMA自动存储。这样采集完一整个40位数据帧后,CPU再去解析数组里相邻两个计数值的差值,换算成时间宽度,从而判断每位是0还是1。

这样做的优势是CPU真正占用时间从几十微秒级降到几个微秒,只要启动DMA采集后就可以去干别的事,等数据采集完成产生中断后再处理。缺点是占用的定时器和DMA通道资源较多,而且代码复杂度上升。但在一套正式的嵌入式系统里,这个方案才算“稳”。

6.2 实现要点:输入捕获与脉冲宽度解析

以STM32F103的TIM2为例,通道1捕获PA0引脚的上升沿和下降沿。配置要点如下:

// 配置TIM2输入捕获,两个通道分别捕获上升沿和下降沿 // 这里使用同一个通道的映射,通过CCER配置极性切换 TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; // 先捕获上升沿 TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0; // 不滤波,保证精度 TIM_ICInit(TIM2, &TIM_ICInitStructure);

捕获到的每次跳变,对应的定时器计数器值会被DMA搬到一个数组里,比如volatile uint16_t capture_buf[64]。DMA配置为循环模式,每次捕获产生DMA请求。解析时,需要先把这次捕获序列中的第一个下降沿之前的数据丢掉,然后识别起始信号、响应信号,最后对每个数据位测量高电平宽度。

高电平宽度的计算方法是:对每一对相邻捕获值,后一个计数器值减前一个计数器值,乘以定时器时钟周期就是时间。TIM2挂载在APB1上,时钟为72MHz,所以计数器每计数一次是13.89ns。如果测得高电平宽度在50us~100us之间,判定为“1”;在20us~40us之间,判定为“0”。通过这种方式,比延时采样更抗干扰,而且不会受中断影响。

解析函数大致如下:

// 解析DMA捕获数组,返回温湿度数据 // cap_buf: 捕获计数器值数组,cap_cnt: 有效捕获次数 uint8_t DHT22_ParseCapture(uint16_t *cap_buf, uint16_t cap_cnt, uint8_t data[5]) { uint32_t prev = cap_buf[0]; uint32_t diff, high_time; uint8_t bit_val; int bit_count = 0; for (int i = 1; i < cap_cnt; i++) { diff = cap_buf[i] - prev; // 相邻两次跳变的时间差 // 交替上升/下降沿,这里取高电平的时间差 // 因为捕获极性在初始化时固定,所以偶数是上升沿到下降沿的时间 if (i % 2 == 1) { // 高电平持续时间 = diff * (1/72MHz) us,diff单位是计数器刻度 high_time = diff * 1000000 / 72000000; // 单位us,近似计算 if (high_time > 50 && high_time < 100) { bit_val = 1; } else { bit_val = 0; } data[bit_count / 8] |= (bit_val << (7 - (bit_count % 8))); bit_count++; if (bit_count == 40) break; } prev = cap_buf[i]; } // 校验逻辑与阻塞版本相同 ... }

注意这个解析代码里high_time的计算方式,是用计数器差值乘以1us对应的计数个数来近似。72MHz下1us是72个计数,所以diff / 72就是微秒数。如果直接用diff * 1000000 / 72000000,在整数运算下精度足够。如果定时器时钟不是72MHz,这个参数要替换。

这个方案的另一个好处是,如果DHT22数据线上的毛刺导致捕获数量异常,DMA的循环模式会自动覆盖旧数据,不会造成内存溢出。解析前先检查捕获次数是否在合理范围,比如40位数据需要大约40次高电平捕获,加上起始和响应的几次,总数应该在60以内,超过就丢弃。

6.3 验证方法:连续运行24小时的数据漂移检查

驱动写完,怎么验证是真的稳定?我习惯把设备放在一个固定环境中,连续运行24小时,记录每5分钟打印一次温湿度和光照值,最后对比参考仪表(比如水银温度计和标准湿度计)。如果DHT22读数与参考值偏差在±0.5℃和±2%RH以内,且24小时内的波动没有呈现周期性突变,说明驱动时序基本正确。更重要的测试是复位稳定性:人为用看门狗复位单片机100次,每次复位后传感器数据都能恢复正确,才算过关。

对于BH1750,验证时可以用手机手电筒在不同距离照射,观察lux值是否可以用照度计对比,或者用已知的灯泡功率和距离粗略估算。BH1750的测量值一般会比较准确,但要注意玻璃遮挡对光线影响很大,测试时不要隔着玻璃。

从那以后我每次做环境传感器驱动,都强制走一遍“逻辑分析仪抓波形 → 关中断拉高优先级 → 连续运行看漂移”这三步,看似麻烦,但能省掉后面现场调试的无数个夜晚。希望这篇拆解能帮你在自己的项目里少踩几个坑。

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

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

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

立即咨询