☰
STM32F103驱动BH1750光照传感器:软件模拟I2C实现环境光采集
2026/10/5 7:51:51 网站建设 项目流程

做自动补光功能的时候,光照采集是第一个要解决的问题。当时手边正好有STM32F103最小系统和一块BH1750光强度传感器模块,就用I2C总线把它们接起来,自己写了一套驱动,实测下来很稳,数据线性度和重复性都不错,跑了一天一夜没有出现掉线或者数值漂移的问题。这篇博客把驱动思路、完整代码和调试过程中踩过的坑都整理出来,直接用STM32标准外设库,代码风格偏寄存器底层,方便大家根据自己的板子改引脚和移植。

BH1750是罗姆公司出的数字环境光传感器,直接输出16位光照度数据,量程1到65535 lux,分辨率最低能到0.5 lux,供电电压3.3V,功耗很低。对大多数室内外光照监测场景来说,精度完全够用,而且它不像光敏电阻那样需要自己搭放大电路和ADC校准,I2C接口一接,读回来就是lux数值,开发效率高很多。

1. 项目背景与选型思路:为什么最后选BH1750

1.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.3V,GND接地,SCL接时钟线,SDA接数据线。ADDR引脚用来设置I2C从机地址,接低电平(GND)时7位地址是0x23,接高电平(VCC)时地址是0x5C。大多数模块默认ADDR是接地的,所以地址就是0x23。

这里要特别提醒一个新手容易踩的坑:在软件模拟I2C时,发送的地址字节是7位地址左移一位,再在最低位补上读/写标志位。向传感器写数据时,地址字节是0x46(0x23<<1 | 0);读数据时,地址字节是0x47(0x23<<1 | 1)。很多人在这一步搞混,导致传感器一直无响应。

2.2 指令集与测量模式

BH1750通过I2C接收一个字节的指令来工作。常用指令不多,我整理如下:

指令字节功能说明
0x00掉电模式,待机功耗最低
0x01上电,等待接收测量指令
0x10连续H分辨率模式,分辨率1 lux,典型测量时间120ms
0x11连续H分辨率模式2,分辨率0.5 lux,测量时间更长
0x13连续L分辨率模式,分辨率4 lux,测量时间约16ms
0x20一次性H分辨率模式,读完后自动掉电
0x21一次性H分辨率模式2
0x23一次性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作为SCL,PB7作为SDA。这个选择比较随意,因为软件模拟I2C不依赖硬件外设,任何两个GPIO都可以。硬件接线如下:

  • BH1750 VCC → 3.3V
  • BH1750 GND → GND
  • BH1750 SCL → PB6
  • BH1750 SDA → PB7
  • BH1750 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接收一个字节,ack=1时主机回ACK,ack=0时主机回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的灵敏度调节也是一个值得深挖的方向。反正驱动已经跑通了,剩下的就是在这个基础上做应用逻辑,可玩性很高。

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

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

立即咨询