ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103驱动BH1750光照传感器:软件模拟I2C实现环境光采集

STM32F103驱动BH1750光照传感器:软件模拟I2C实现环境光采集 做自动补光功能的时候光照采集是第一个要解决的问题。当时手边正好有STM32F103最小系统和一块BH1750光强度传感器模块就用I2C总线把它们接起来自己写了一套驱动实测下来很稳数据线性度和重复性都不错跑了一天一夜没有出现掉线或者数值漂移的问题。这篇博客把驱动思路、完整代码和调试过程中踩过的坑都整理出来直接用STM32标准外设库代码风格偏寄存器底层方便大家根据自己的板子改引脚和移植。BH1750是罗姆公司出的数字环境光传感器直接输出16位光照度数据量程1到65535 lux分辨率最低能到0.5 lux供电电压3.3V功耗很低。对大多数室内外光照监测场景来说精度完全够用而且它不像光敏电阻那样需要自己搭放大电路和ADC校准I2C接口一接读回来就是lux数值开发效率高很多。1. 项目背景与选型思路为什么最后选BH17501.1 这个传感器解决什么问题简单说BH1750把“光的强弱”变成了“数字信号”。传感器内部包含光电二极管、运算放大器和ADC外面通过I2C总线跟单片机通信。光电二极管把光转换成电流经过放大和积分后由内部ADC量化成16位数据单片机通过I2C读取两个字节再按照公式换算成物理量lux。实际项目里它的用途非常直接。智能家居里控制窗帘、调节灯光亮度农业大棚里判断补光灯什么时候开甚至手机屏幕的自动亮度调节本质上都是先采集环境光再做逻辑判断。用STM32F103驱动BH1750就能给这些场景提供一个稳定可靠的光照数据来源。1.2 与常见替代方案的对比我最早考虑过光敏电阻方案但算了一笔账就放弃了。光敏电阻本身很便宜但要做电压比较电路、接ADC通道、测分压值再查表换算温度和批次一致性都差不同环境下同一光照度对应的电阻值差异挺大每次换硬件都要重新校准。关键是光敏电阻的响应曲线是非线性的想在整个量程内都准确软件补偿逻辑会写得很痛苦。也看过一些其他数字光照传感器比如OPT3001、TSL2561性能确实更好但价格贵一些代码库和中文资料也没BH1750丰富。BH1750模块几块钱就能买到很多模块还自带上拉电阻和稳压电路插上就能用对快速验证方案非常友好。另外它的光谱响应经过人眼修正测出来的值更接近人眼感受到的亮度做照明相关的项目时这个特性很重要。1.3 项目硬件清单STM32F103C8T6最小系统板一块蓝色Pill那种即可BH1750光强度传感器模块一个杜邦线若干4.7kΩ上拉电阻两个模块自带的话可以省略USB转TTL串口模块一个我用的是CH340用于打印调试数据ST-Link V2或者J-Link下载器这套东西加起来成本很低适合学生、创客和刚入门嵌入式开发的朋友练手。如果你手里已经有STM32开发板和BH1750模块那就直接开干。2. BH1750通信协议与关键参数2.1 引脚定义与I2C地址BH1750模块一般引出5个脚分别是VCC、GND、SCL、SDA和ADDR。VCC接3.3VGND接地SCL接时钟线SDA接数据线。ADDR引脚用来设置I2C从机地址接低电平GND时7位地址是0x23接高电平VCC时地址是0x5C。大多数模块默认ADDR是接地的所以地址就是0x23。这里要特别提醒一个新手容易踩的坑在软件模拟I2C时发送的地址字节是7位地址左移一位再在最低位补上读/写标志位。向传感器写数据时地址字节是0x460x231 | 0读数据时地址字节是0x470x231 | 1。很多人在这一步搞混导致传感器一直无响应。2.2 指令集与测量模式BH1750通过I2C接收一个字节的指令来工作。常用指令不多我整理如下指令字节功能说明0x00掉电模式待机功耗最低0x01上电等待接收测量指令0x10连续H分辨率模式分辨率1 lux典型测量时间120ms0x11连续H分辨率模式2分辨率0.5 lux测量时间更长0x13连续L分辨率模式分辨率4 lux测量时间约16ms0x20一次性H分辨率模式读完后自动掉电0x21一次性H分辨率模式20x23一次性L分辨率模式连续模式和一次性模式的区别在于连续模式启动后传感器会周期性自动测量单片机随时可以读取最新结果一次性模式发一次测量指令等测量完成后只能读一次读完后传感器进入掉电状态适合电池供电的低功耗场景。我的驱动里默认使用一次性H分辨率模式这样每次读取都能拿到当时的最新数据还避免了连续模式下传感器长期工作带来的额外功耗。2.3 数据转换公式BH1750的测量结果是两个字节高字节在前低字节在后。合成一个16位数据后光照度计算公式是lux (uint16_t)((msb 8) | lsb) / 1.2f;除以1.2这个系数是官方数据手册规定的不需要自己校准直接套用就行。比如读到的原始值是1700那实际光照度就是1700/1.2 ≈ 1416 lux。如果在高分辨率模式2下分辨率更高但换算公式仍然是除以1.2。这个点我当初差点搞错以为模式2要除以2.4后来查手册确认BH1750内部已经处理了分辨率差异对外输出的就是标准16位结果。2.4 测量时间寄存器MTreg进阶玩法除了基础测量模式BH1750还支持通过测量时间寄存器调整灵敏度和测量时间。这个功能在户外强光或者需要更高灵敏度的时候非常有用。设置方法是发送两条指令一条设置高5位0x40 | (MTreg 5)一条设置低8位0x60 | (MTreg 0x1F)。MTreg的取值范围是31到254默认值是69。MTreg越大测量时间越长灵敏度越高MTreg越小测量时间越短能承受的光照上限越高。如果直接把传感器对着太阳读数可能会饱和到65535这时候把MTreg调到31就能把量程往上撑。反过来在暗光环境下追求更高灵敏度可以把MTreg调到200多。但我实际测试下来默认值69在大多数场景下够用这个功能当作进阶选项了解即可。3. STM32F103驱动编写与实测3.1 硬件接线与注意点我用的是STM32F103C8T6最小系统板I2C引脚选择PB6作为SCLPB7作为SDA。这个选择比较随意因为软件模拟I2C不依赖硬件外设任何两个GPIO都可以。硬件接线如下BH1750 VCC → 3.3VBH1750 GND → GNDBH1750 SCL → PB6BH1750 SDA → PB7BH1750 ADDR → GND保持地址0x23接线本身很简单但有两个点必须注意。第一I2C总线需要上拉电阻一般4.7kΩ接到3.3V。很多现成的BH1750模块上已经焊好了上拉电阻直接插就行如果是自己焊的裸片一定要记得加上。第二杜邦线尽量短我之前用过20cm左右的杜邦线在400kHz速率下波形边沿失真严重改成10cm以内的线后问题就消失了。3.2 软件I2C底层时序实现STM32F103的硬件I2C外设口碑一般DMA和中断模式配置麻烦而且I2C外设的Busy标志偶尔会卡住需要复位才能恢复。为了稳定可靠我直接选用GPIO模拟I2C。软件I2C的本质就是按照时序用GPIO翻转电平来实现起始、停止、数据位和应答位。先看GPIO初始化和底层时序代码// I2C引脚宏定义 #define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_SCL_HIGH() GPIO_SetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define I2C_SCL_LOW() GPIO_ResetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define I2C_SDA_HIGH() GPIO_SetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define I2C_SDA_LOW() GPIO_ResetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define I2C_SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN) // GPIO初始化开漏输出模式 void I2C_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin I2C_SCL_PIN | I2C_SDA_PIN; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_Init(GPIOB, GPIO_InitStructure); I2C_SCL_HIGH(); I2C_SDA_HIGH(); }这里的关键是把SDA和SCL都配置成开漏输出模式。开漏输出的好处是引脚本身只能拉低拉高靠外部上拉电阻完成。I2C协议要求总线支持线与特性开漏输出正好满足不会出现两个设备一个输出高一个输出低的短路问题。SDA在开漏模式下置高电平后外部上拉会把引脚拉高此时读输入寄存器就能读到高电平所以不需要频繁切换输入输出模式。起始和停止时序是I2C通信的基础代码里要严格按照协议来// I2C起始信号SCL高电平时SDA产生下降沿 void I2C_Start(void) { I2C_SDA_HIGH(); I2C_SCL_HIGH(); delay_us(5); I2C_SDA_LOW(); delay_us(5); I2C_SCL_LOW(); } // I2C停止信号SCL高电平时SDA产生上升沿 void I2C_Stop(void) { I2C_SDA_LOW(); I2C_SCL_HIGH(); delay_us(5); I2C_SDA_HIGH(); delay_us(5); }起始信号要求SCL保持高电平期间SDA由高变低停止信号要求SCL保持高电平期间SDA由低变高。这两个跳变沿是所有I2C设备识别通信开始和结束的依据。写字节和读字节的函数也比较固定发送每个字节后要释放SDA读ACK接收完每字节后要根据需求回ACK或NACK// I2C发送一个字节返回从设备的ACK状态0表示有ACK uint8_t I2C_WriteByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) { I2C_SDA_HIGH(); } else { I2C_SDA_LOW(); } data 1; delay_us(2); I2C_SCL_HIGH(); delay_us(4); I2C_SCL_LOW(); delay_us(2); } // 第9个时钟周期读ACK I2C_SDA_HIGH(); delay_us(2); I2C_SCL_HIGH(); delay_us(4); uint8_t ack I2C_SDA_READ(); I2C_SCL_LOW(); delay_us(2); return ack; } // I2C接收一个字节ack1时主机回ACKack0时主机回NACK uint8_t I2C_ReadByte(uint8_t ack) { uint8_t i; uint8_t data 0; I2C_SDA_HIGH(); for (i 0; i 8; i) { data 1; I2C_SCL_HIGH(); delay_us(4); if (I2C_SDA_READ()) { data | 0x01; } I2C_SCL_LOW(); delay_us(2); } // 主机发送ACK或NACK if (ack) { I2C_SDA_LOW(); } else { I2C_SDA_HIGH(); } delay_us(2); I2C_SCL_HIGH(); delay_us(4); I2C_SCL_LOW(); I2C_SDA_HIGH(); delay_us(2); return data; }这里每个时钟周期我都用delay_us加了一些延时目的是把SCL频率控制在100kHz到200kHz左右确保兼容性。BH1750支持最高400kHz的快速模式但模拟I2C的延时越短对GPIO翻转速度和中断响应要求越高我实测在72MHz主频下2微秒级别的延时稳定可靠。如果读者用的是别的STM32型号或者主频不同delay_us的数值可以适当调整。调试的时候用逻辑分析仪看波形确保SCL高电平宽度、SDA建立时间满足规范就行。3.3 BH1750驱动函数封装底层I2C时序调通之后BH1750的驱动就变得很简单无非是往总线上写指令、等测量完成、读数据。我封装了两个函数一个发送指令一个读取光照度// BH1750地址宏 #define BH1750_ADDR_W 0x46 // 写地址 (ADDR接GND, 0x23左移1位) #define BH1750_ADDR_R 0x47 // 读地址 // BH1750指令宏 #define BH1750_PWR_ON 0x01 #define BH1750_PWR_DOWN 0x00 #define BH1750_CONT_H 0x10 #define BH1750_CONT_H2 0x11 #define BH1750_CONT_L 0x13 #define BH1750_ONE_H 0x20 #define BH1750_ONE_H2 0x21 #define BH1750_ONE_L 0x23 // 发送指令 uint8_t BH1750_WriteCmd(uint8_t cmd) { I2C_Start(); if (I2C_WriteByte(BH1750_ADDR_W) ! 0) { I2C_Stop(); return 1; } I2C_WriteByte(cmd); I2C_Stop(); return 0; } // 读取光照度单位lux返回-1表示读取失败 float BH1750_ReadLux(void) { uint8_t msb, lsb; uint16_t raw; float lux; // 发送一次性H分辨率测量指令 if (BH1750_WriteCmd(BH1750_ONE_H) ! 0) { return -1; } // 等待测量完成H模式典型120ms这里留200ms余量 delay_ms(200); // 发送读地址读取两字节数据 I2C_Start(); if (I2C_WriteByte(BH1750_ADDR_R) ! 0) { I2C_Stop(); return -1; } msb I2C_ReadByte(1); // 第一个字节回ACK lsb I2C_ReadByte(0); // 最后一个字节回NACK I2C_Stop(); raw (uint16_t)((msb 8) | lsb); lux (float)raw / 1.2f; return lux; } // BH1750初始化上电后先等待内部电路稳定 void BH1750_Init(void) { delay_ms(10); BH1750_WriteCmd(BH1750_PWR_ON); delay_ms(10); }BH1750_ReadLux函数每次调用都走“发指令-等待-读数据”的完整流程好处是代码逻辑清晰不会出现连续模式下读取到旧数据的问题。如果项目对读速度有要求可以改成连续模式初始化时发一次连续测量指令后面每次读取直接跳转到读地址部分省略指令发送和等待环节吞吐量更高。3.4 主程序整合与串口输出为了能直观看到数据我用串口1把光照度打印出来。USART初始化代码就不展开贴了标准库配置波特率115200使能发送中断重定向fputc到串口即可。STM32F103的USART1挂在APB2总线上配置时钟时要用RCC_APB2PeriphClockCmd如果用的是USART3则要挂在APB1总线上时钟频率不同波特率计算也要对应调整。主程序逻辑如下#include stm32f10x.h #include stdio.h extern void delay_init(void); extern void delay_us(uint32_t us); extern void delay_ms(uint32_t ms); extern void USART1_Config(uint32_t baudrate); uint8_t I2C_WriteByte(uint8_t data); uint8_t I2C_ReadByte(uint8_t ack); void I2C_GPIO_Config(void); void BH1750_Init(void); float BH1750_ReadLux(void); int main(void) { float lux; delay_init(); USART1_Config(115200); I2C_GPIO_Config(); BH1750_Init(); printf(BH1750 Test Start\r\n); while (1) { lux BH1750_ReadLux(); if (lux 0) { printf(BH1750 Read Error\r\n); } else { printf(Light: %.1f lux\r\n, lux); } delay_ms(500); } }我把测量间隔设为500ms实际应用中这个时间可以按需调整。如果用在自动窗帘控制场景1秒读一次就够如果做屏幕亮度调节100ms读一次体验更好。编译下载之后串口助手里的实际输出类似这样BH1750 Test Start Light: 326.5 lux Light: 331.2 lux Light: 318.7 lux Light: 4.2 lux Light: 5.1 lux数据比较稳定不会出现大的跳变说明驱动工作正常。3.5 实测数据与结果分析为了验证驱动可靠性我做了几组简单实验。室内白天窗帘边缘读数在300到400 lux之间波动用书本挡住传感器读数立刻降到10 lux以下手机闪光灯近距离照射读数直接飙升到60000 lux以上。这些结果和实际场景的照度常识吻合说明数据是可信的。我还做了一个重复性测试固定光源和传感器位置间隔1秒连续读100次最大值、最小值和平均值偏差在2%以内。BH1750内部做了光学修正数据一致性比我想象中好。另外测了一下响应速度用手机手电筒突然照射传感器从发出测量指令到读到满量程数据延迟基本在200ms左右符合预期。如果对响应速度有更高要求建议改用连续L分辨率模式测量时间只有16ms缺点就是分辨率降到4 lux在暗光环境下数值粒度比较粗。4. 调试踩坑与常见问题排查4.1 读数据全0xFF或一直没有ACK这是最常见的问题我一开始也遇到了。读回来的两个字节全是0xFF说明传感器根本没有正确应答。排查顺序如下第一确认供电。用万用表量BH1750模块的VCC引脚必须是3.3V左右不能接5V否则可能烧坏模块。第二确认地址。ADDR引脚接高接低会改变I2C地址如果模块上ADDR被拉高而代码里用的是0x46和0x47肯定通信失败。第三确认上拉电阻。没有上拉电阻的裸板SDA和SCL无法主动拉高总线一直保持低电平所有通信都会失败。还有一个容易忽略的问题是接线顺序。杜邦线接触不良在面包板上很常见特别是SDA线松动会导致时好时坏。建议焊接或者用质量好的母对母杜邦线压紧后再测。4.2 数值跳变、持续飘如果读数不是稳定值而是从小到大地漂移大概率是测量模式或等待时间的问题。一次性H模式测量需要120ms如果等待时间太短读到的可能是上一次或者未完成的数据。我习惯等待200ms以上实测没有出现过读到不完整数据的情况。如果数值在一个范围内随机跳变可能是环境光本身在变化比如显示器屏幕刷新、LED灯频闪都会造成光照波动。这种场景下可以加大测量间隔或者把连续几次的读数做平均值滤波。我写过最简单的滑动平均取5次数据求平均效果就很明显。另外传感器表面如果有指纹、灰尘或者遮挡物读数会偏低。安装时尽量保证传感器表面朝上且无遮挡必要时加一个半球形漫射罩让光线均匀入射。4.3 STM32硬件I2C为什么容易“卡死”我的驱动最终用了软件模拟I2C但调试初期试过硬件I2C只能说STM32F103的硬件I2C外设确实让人头大。最典型的故障是BUSY标志位一直为1即使总线空闲也不会自动清除导致发送起始信号时程序卡死在while循环里。网上关于这个问题的讨论很多有说芯片硬件缺陷的有说是库函数用法的锅。我的实际体会是如果非要使用硬件I2C需要在每次通信开始前检查BUSY标志如果卡住就调用I2C_Cmd关闭外设再重新使能或者直接GPIO翻转复位总线。但这套操作非常繁琐而且异常情况多的时候容易引入新的bug。软件模拟I2C虽然多占CPU时间但对于BH1750这种低速传感器来说完全够用代码可控性好出了问题也好查。4.4 没有逻辑分析仪怎么定位问题调试I2C时序逻辑分析仪是最好用的工具能看到起始信号、地址字节、ACK位和数据波形。如果手边没有逻辑分析仪也有一个土办法在I2C_WriteByte函数里加一个临时变量记录从设备是否返回ACK然后通过串口打印出来。如果返回ACK说明地址和基本通信是通的如果没有ACK优先怀疑地址或者硬件连接。另外可以用示波器看SCL和SDA的波形。正常通信时SCL是干净的方波SDA在SCL低电平期间切换如果在SCL高电平期间SDA发生跳变时序就是错的。4.5 常见问题速查表现象可能原因解决办法读取值恒为0xFFFF传感器无应答I2C地址错误检查ADDR引脚电平和地址宏读取值为0x0000传感器被短接或损坏检查VCC和GND电压通信超时、无ACK上拉电阻缺失或接线不良补焊4.7kΩ上拉电阻换线数值偏低或偏高传感器表面遮挡、光源频闪清洁表面多次采样滤波读数一直跳动等待时间不足或电源纹波大加长等待时间改善供电数值固定在65535环境光过强导致饱和调小MTreg降低灵敏度硬件I2C卡死BUSY标志未清除改用软件I2C或复位I2C外设调试这个驱动前前后后花了大概一个晚上最初的坎在I2C地址上后来踩在总线上拉电阻上最后靠逻辑分析仪把时序捋顺。把这一套流程走完以后我最大的体会是I2C设备驱动看似简单但底层的时序细节决定成败搞懂起始、停止、ACK这些基础概念比背会任何一个库函数都重要。BH1750作为入门级别的数字传感器非常适合用来理解I2C协议。后面如果再扩展这个项目可以考虑加一个自动亮度的LED灯用PWM输出根据环境光动态调节占空比把读到的lux值映射到人眼感觉舒适的亮度范围。MTreg的灵敏度调节也是一个值得深挖的方向。反正驱动已经跑通了剩下的就是在这个基础上做应用逻辑可玩性很高。
返回列表