简介:这份资源面向嵌入式开发初学者与需要快速搭建光照采集方案的工程师,围绕STM32F103微控制器驱动BH1750光照强度传感器(GY-30模块)展开,解决环境光测量与TFTLCD实时显示的实际问题。压缩包共140个文件,约4.53MB,以37个h头文件、34个c源文件为核心,配合16个o目标文件、16个d依赖文件及crf、lst、map等编译中间产物,另有uvprojx工程文件、sct分散加载文件、hex与axf可执行文件,以及pdf说明和bat脚本,构成一套可直接编译下载的完整Keil工程。内容覆盖I²C总线配置、BH1750多种测量模式选择、光照数据解析与Lux换算,以及TFTLCD初始化、坐标设置与界面布局等环节,读者可据此理解微控制器编程、传感器驱动、总线通信、数据处理与图形显示的完整链路。目前已有4833人学习下载,适合作为课程设计、智能家居光照监测等场景的参考范例。
1. 从一颗 GY-30 模块说起:为什么 I2C 光照采集值得单独做一遍
手头拿到一块 GY-30 模块,背面丝印写着 BH1750FVI,正面一颗小小的数字光照芯片,五块钱不到,却能直接输出以勒克斯(lx)为单位的环境光强度。很多人第一次把它接到 STM32F103 上,跑通例程读出几个数就收工了,结果一放到真实场景——比如自动调光的台灯、温室补光控制、屏幕背光自适应——立刻发现数据跳、量程不对、偶尔整个 I2C 总线卡死。问题不在传感器本身,而在于大多数人跳过了「驱动怎么写、参数怎么配、异常怎么兜」这三件事。
这篇笔记就围绕 STM32F103 驱动 BH1750(GY-30 模块)这条主线,把 I2C 时序、量程模式选择、连续采集与单次采集的取舍、数据换算、以及总线卡死的排查路径讲透。适合两类人:刚上手 STM32 标准库或 HAL 库、想找一个完整传感器驱动练手的开发者;以及已经在用 BH1750 但被数据稳定性和异常恢复折磨过的工程师。读完你应该能自己从零写出一份可复用的驱动,而不是复制一段看不懂的代码。
2. BH1750 的 I2C 通信与 STM32F103 硬件连接:先搞清楚器件怎么说话
2.1 BH1750 的器件地址与指令集
BH1750FVI 挂在 I2C 总线上,7 位从机地址由 ADDR 引脚电平决定:ADDR 接低电平时地址是 0x23,接高电平时是 0x5C。GY-30 模块通常已经把 ADDR 拉低,所以默认地址就是 0x23。注意这里说的是 7 位地址,HAL 库的HAL_I2C_Master_Transmit需要的是左移一位后的 8 位地址,也就是 0x46 写、0x47 读,这个细节后面代码里会体现。
BH1750 的指令非常简洁,一个字节就是一条命令,没有寄存器地址的概念。常用指令如下表:
| 指令字节 | 含义 | 说明 |
|---|---|---|
| 0x00 | 断电 | 芯片进入 Power Down |
| 0x01 | 上电 | 等待测量指令 |
| 0x07 | 复位 | 数据寄存器清零,需重新上电 |
| 0x10 | 连续 H 分辨率模式 | 1 lx 精度,测量约 120ms |
| 0x11 | 连续 H 分辨率模式2 | 0.5 lx 精度,约 120ms |
| 0x13 | 连续 L 分辨率模式 | 4 lx 精度,约 16ms |
| 0x20 | 单次 H 分辨率模式 | 测完自动转 Power Down |
| 0x23 | 单次 L 分辨率模式 | 低功耗场景常用 |
这里有个容易翻车的点:上电后芯片默认处于 Power Down 状态,不发 0x01 直接发测量指令,它是不会响应的。很多人第一次调试读回全 0 或全 0xFF,八成就是漏了这一步。
2.2 STM32F103 与 GY-30 的接线与上拉电阻
STM32F103 的硬件 I2C1 默认引脚是 PB6(SCL)和 PB7(SDA),也可以重映射到 PB8/PB9。GY-30 模块一般自带 4.7kΩ 上拉电阻,如果你用的是裸芯片而不是模块,必须在 SCL 和 SDA 上各接一个 4.7kΩ 到 3.3V 的上拉。上拉阻值不是随便选的:阻值太大,上升沿变缓,高速模式下波形爬不起来;阻值太小,灌电流超过器件承受能力。标准模式 100kHz 下 4.7kΩ 是稳妥值,快速模式 400kHz 可以降到 2.2kΩ 左右。
供电方面,BH1750 工作电压 2.4V 到 3.6V,GY-30 模块有的带 3.3V 稳压有的不带,接线前务必确认模块输入电压范围,直接怼 5V 有可能烧芯片。VCC 和 GND 接好,SCL、SDA 对应接上,硬件部分就齐了。
2.3 用 HAL 库初始化 I2C 外设
下面这段是 STM32F103 上用 CubeMX 生成后常见的 I2C1 初始化配置,标准模式 100kHz:
/* I2C1 初始化,标准模式 100kHz,7 位地址 */ static void MX_I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 100000; // 100kHz 标准模式 hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; // 占空比 2:1 hi2c1.Init.OwnAddress1 = 0; // 主机模式无需自身地址 hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; // 允许时钟拉伸 if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); } }ClockSpeed设成 100000 是保守选择,BH1750 支持到 400kHz,但长导线或上拉偏弱时 400kHz 容易出错,先用 100kHz 跑通再提速。NoStretchMode建议保持 DISABLE,让从机可以拉伸时钟,BH1750 在测量期间会拉低 SCL 表示忙,禁止时钟拉伸会导致通信异常。OwnAddress1主机模式下填 0 即可,不影响通信。
3. 写一份能复用的 BH1750 驱动:从初始化到读数的完整代码
3.1 驱动头文件与宏定义
先把地址、指令、量程换算系数定义清楚,后面改参数只动这一处:
/* bh1750.h */ #ifndef __BH1750_H #define __BH1750_H #include "main.h" /* 7 位地址左移一位后的 8 位地址,ADDR 接地为 0x46 */ #define BH1750_ADDR_WRITE 0x46 #define BH1750_ADDR_READ 0x47 /* 指令集 */ #define BH1750_POWER_DOWN 0x00 #define BH1750_POWER_ON 0x01 #define BH1750_RESET 0x07 #define BH1750_CONT_H_MODE 0x10 // 连续高分辨率 1lx #define BH1750_CONT_H_MODE2 0x11 // 连续高分辨率 0.5lx #define BH1750_CONT_L_MODE 0x13 // 连续低分辨率 4lx #define BH1750_ONE_H_MODE 0x20 // 单次高分辨率 #define BH1750_ONE_L_MODE 0x23 // 单次低分辨率 /* 换算系数:原始值 / 1.2 = lx(高分辨率模式) */ #define BH1750_H_RES_COEF 1.2f uint8_t BH1750_Init(I2C_HandleTypeDef *hi2c); uint8_t BH1750_ReadLight(float *lux); #endif地址宏写成左移后的 8 位形式,是为了直接喂给 HAL 库的发送函数,省得每次调用都手动移位。换算系数 1.2 来自数据手册:高分辨率模式下,原始 16 位数据除以 1.2 得到勒克斯值。低分辨率模式系数不同,这里只保留高分辨率,避免混淆。
3.2 初始化与模式设置
初始化要做三件事:上电、复位、设置测量模式。顺序不能乱:
/* bh1750.c */ #include "bh1750.h" static I2C_HandleTypeDef *bh_i2c; /* 发送单字节指令 */ static uint8_t BH1750_WriteCmd(uint8_t cmd) { return HAL_I2C_Master_Transmit(bh_i2c, BH1750_ADDR_WRITE, &cmd, 1, 100); } uint8_t BH1750_Init(I2C_HandleTypeDef *hi2c) { bh_i2c = hi2c; if (BH1750_WriteCmd(BH1750_POWER_ON) != HAL_OK) // 先上电 return 1; HAL_Delay(10); if (BH1750_WriteCmd(BH1750_RESET) != HAL_OK) // 复位数据寄存器 return 2; HAL_Delay(10); if (BH1750_WriteCmd(BH1750_CONT_H_MODE) != HAL_OK) // 连续高分辨率 return 3; HAL_Delay(180); // 首次测量需要约 180ms return 0; }HAL_Delay(180)这个等待不能省。BH1750 在收到测量指令后需要完成一次积分转换,高分辨率模式典型 120ms,最大 180ms。如果初始化后立刻读,读到的可能是上一次的残留值或者 0。返回不同错误码是为了排查时能定位是哪一步失败,比统一返回 0/1 有用得多。
3.3 读取光照数据与换算
读取是标准的两字节读操作,BH1750 输出的是大端格式,高字节在前:
uint8_t BH1750_ReadLight(float *lux) { uint8_t buf[2] = {0}; /* 连续模式下直接读,无需重新发测量指令 */ if (HAL_I2C_Master_Receive(bh_i2c, BH1750_ADDR_READ, buf, 2, 100) != HAL_OK) return 1; uint16_t raw = (uint16_t)((buf[0] << 8) | buf[1]); // 大端拼接 *lux = raw / BH1750_H_RES_COEF; // 换算成 lx return 0; }buf[0]是高字节,左移 8 位后与低字节或运算,拼成 16 位原始值。除以 1.2 得到勒克斯。注意连续模式下读完后芯片会自动开始下一次测量,不需要重新发指令;如果你用的是单次模式,每次读之前都得重新发一次 0x20,否则读到的永远是同一份数据。这是连续和单次模式最本质的区别,也是新手最容易搞混的地方。
3.4 主循环里的调用与串口输出
把驱动挂到主循环,配合串口打印,方便观察数据:
/* main.c 片段 */ float lux = 0.0f; char msg[32]; if (BH1750_Init(&hi2c1) == 0) { printf("BH1750 init ok\r\n"); } while (1) { if (BH1750_ReadLight(&lux) == 0) { sprintf(msg, "Light: %.1f lx\r\n", lux); HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 100); } else { HAL_UART_Transmit(&huart1, (uint8_t *)"read err\r\n", 10, 100); } HAL_Delay(500); // 500ms 读一次,足够观察变化 }HAL_Delay(500)是采样周期,连续模式下芯片自己按 120ms 左右刷新,你读多快都行,但没必要太频繁,500ms 对光照这种缓变量足够。串口打印用sprintf格式化,注意%.1f在部分精简版 printf 库里不支持浮点,如果打印出来是空或乱码,需要在工程设置里勾选支持浮点格式化,或者改成整数打印raw值自己换算。
4. 量程、精度与采样策略:三个必须想清楚的参数
4.1 高分辨率与低分辨率怎么选
BH1750 提供两种分辨率模式,本质是积分时间不同。高分辨率模式积分时间约 120ms,精度 1 lx;低分辨率模式积分时间约 16ms,精度 4 lx。选哪个取决于你的场景:
| 模式 | 积分时间 | 精度 | 适用场景 |
|---|---|---|---|
| 连续 H | 约 120ms | 1 lx | 室内光照监测、自动调光 |
| 连续 H2 | 约 120ms | 0.5 lx | 微弱光检测、暗室 |
| 连续 L | 约 16ms | 4 lx | 快速变化、对精度要求低 |
如果你做的是屏幕背光自适应,环境光变化本来就慢,用高分辨率模式完全没问题。如果要做快速响应的光强变化检测,比如遮挡检测,低分辨率模式的 16ms 响应更有优势,代价是精度降到 4 lx。H2 模式精度最高但测量范围和稳定性会受影响,强光下容易饱和,一般室内场景用标准 H 模式就够了。
4.2 连续模式与单次模式的取舍
连续模式芯片一直在测,你随时读都有最新数据,功耗相对高;单次模式测完自动进入 Power Down,功耗极低,适合电池供电的间歇采集场景。但单次模式每次读之前都要重新发测量指令并等待转换完成,代码逻辑更复杂。我一般的做法是:市电供电用连续模式,省心;电池设备用单次模式,配合 RTC 定时唤醒,测一次睡一次。
单次模式的读取流程要改成这样:
/* 单次模式:发指令 -> 等待 -> 读 -> 自动断电 */ BH1750_WriteCmd(BH1750_ONE_H_MODE); HAL_Delay(180); // 等待转换完成 HAL_I2C_Master_Receive(bh_i2c, BH1750_ADDR_READ, buf, 2, 100); /* 读完芯片自动进入 Power Down,下次测量重新发指令 */注意单次模式下HAL_Delay(180)是必须的,而且这个延时期间 MCU 最好进低功耗,否则省电的意义就打折扣了。
4.3 数据滤波:滑动平均还是中值
原始数据偶尔会跳一两个异常值,直接拿去控制会出问题。最简单的处理是滑动平均,取最近 N 次求均值:
#define FILTER_N 5 static float buf[FILTER_N] = {0}; static uint8_t idx = 0; float BH1750_Filter(float new_val) { buf[idx] = new_val; idx = (idx + 1) % FILTER_N; float sum = 0; for (int i = 0; i < FILTER_N; i++) sum += buf[i]; return sum / FILTER_N; }滑动平均对缓变信号效果好,但如果有偶发的尖峰干扰,均值会被拉偏。更稳的是中值滤波:取 N 次排序后取中间值,能直接干掉孤立尖峰。实际项目里我常用「中值 + 滑动平均」两级:先中值去尖峰,再平均平滑。N 取 5 比较平衡,太大响应变慢,太小滤波效果不明显。
5. 避坑与排查:BH1750 驱动最常见的五个翻车现场
5.1 读回全是 0 或 0xFF
现象:初始化返回成功,但读出来的原始值始终是 0 或 65535。
原因:最常见的是漏发上电指令 0x01,芯片还在 Power Down 状态,不会响应测量。其次是地址搞错,ADDR 引脚实际接高但代码里用了 0x46。还有一种情况是 I2C 上拉电阻缺失或阻值过大,波形爬不起来,从机根本没收到指令。
解决:先用逻辑分析仪或示波器抓 SCL/SDA 波形,确认有正确的起始条件和地址帧。没有仪器的话,用 HAL 库的返回值判断,HAL_I2C_Master_Transmit返回 HAL_ERROR 基本就是硬件连接或地址问题。确认地址:拿万用表量 ADDR 引脚对地电压,低电平用 0x46,高电平用 0x5C 左移后的 0xB8。
5.2 数据偶尔跳变或卡死
现象:大部分时间数据正常,偶尔跳出一个离谱值,严重时整个 I2C 总线卡死,后续所有通信超时。
原因:I2C 总线在干扰下可能出现从机拉低 SDA 不放的情况,主机以为总线空闲,实际被占死。BH1750 在测量期间会拉伸时钟,如果主机禁止了时钟拉伸(NoStretchMode 设成 ENABLE),就会冲突。
解决:确保NoStretchMode为 DISABLE。在读取函数里加超时判断,HAL 库的 timeout 参数设 100ms,超时后执行总线恢复:把 SCL 配置成普通 GPIO,手动发 9 个时钟脉冲,再发一个 STOP 条件,然后重新初始化 I2C。这个恢复函数建议每个用 I2C 的项目都备一份。
5.3 换算出来的 lx 值明显偏大或偏小
现象:数据能读,但数值和手机光照计对比差好几倍。
原因:换算系数用错。高分辨率模式除 1.2,低分辨率模式除的系数不同。另外 H2 模式(0.5 lx 精度)的换算系数也和标准 H 模式不一样,混用会导致偏差。
解决:确认当前模式,高分辨率标准模式用raw / 1.2。如果用了 H2 模式,系数要按数据手册调整。最稳妥的办法是拿一个已知光源做标定,记录 raw 值和参考 lx 值,反推实际系数。
5.4 初始化成功但读数一直不变
现象:第一次读到的值正常,之后无论光照怎么变,读数纹丝不动。
原因:用了单次模式但代码里只发了一次测量指令,后面一直在读同一份数据寄存器。单次模式每次测量完芯片进入 Power Down,数据寄存器保持上次的值不变。
解决:单次模式下每次读取前都要重新发测量指令并等待转换。或者干脆改用连续模式,省去反复发指令的麻烦。检查代码里是不是把测量指令放在了初始化里而没放进读取循环。
5.5 多传感器挂同一总线地址冲突
现象:总线上挂了两个 GY-30,只有一个能读到数据,另一个始终无响应。
原因:两个模块的 ADDR 都接地,地址都是 0x23,I2C 总线不允许同地址设备共存。
解决:把其中一个模块的 ADDR 引脚改接高电平,地址变成 0x5C,代码里用两套地址分别访问。如果模块的 ADDR 已经硬连到地没法改,那就只能用 I2C 多路复用器分时切换,或者换用软件 I2C 分别接不同引脚。硬件设计阶段就要规划好地址分配,别等板子打回来才发现。
6. 把 BH1750 用进真实项目:自动调光与低功耗采集的两个进阶技巧
先讲自动调光。很多台灯项目用 BH1750 读环境光,然后线性映射到 PWM 占空比,结果发现暗环境下灯闪、亮环境下反应迟钝。问题出在映射曲线:人眼对亮度感知是对数关系,线性映射在低照度区变化太剧烈。我一般用分段映射或者对数映射,把 lx 值先取对数再映射到 PWM:
/* 对数映射:lux 1~1000 映射到 PWM 0~1000 */ float lux_log = log10f(lux + 1.0f); // +1 避免 log(0) float pwm = (lux_log / 3.0f) * 1000.0f; // log10(1000)=3 if (pwm > 1000) pwm = 1000; if (pwm < 0) pwm = 0; __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, (uint16_t)pwm);log10f(lux + 1.0f)里的 +1 是防止完全黑暗时对 0 取对数得到负无穷。除以 3.0 是因为 log10(1000)=3,把 1 到 1000 lx 映射到 0 到 1 的系数。这个曲线在暗处变化平缓,亮处变化快,更符合人眼感受。实际调试时还要加一个死区,光照变化小于 5% 不调整 PWM,避免灯在临界点反复微调。
再讲低功耗采集。电池供电的光照记录仪,核心是让 MCU 和传感器大部分时间都在睡。用单次模式配合 RTC 闹钟,每 10 分钟唤醒一次:
/* RTC 唤醒后执行一次采集,然后重新进入 STOP 模式 */ void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *hrtc) { float lux; BH1750_WriteCmd(BH1750_POWER_ON); HAL_Delay(10); BH1750_WriteCmd(BH1750_ONE_H_MODE); HAL_Delay(180); BH1750_ReadLight(&lux); BH1750_WriteCmd(BH1750_POWER_DOWN); // 采完立刻断电 SaveToFlash(lux); /* 重新设置下一次闹钟,然后进 STOP 模式 */ EnterStopMode(); }这里的关键是采完立刻发 0x00 断电,BH1750 在 Power Down 下电流只有 0.01μA 级别,几乎可以忽略。MCU 进 STOP 模式后整机功耗能压到微安级,一节纽扣电池撑几个月没问题。注意HAL_Delay在 STOP 模式下不可用,唤醒后的延时要用 SysTick 正常工作时的普通延时,进 STOP 前记得重新配置时钟。
最后说一个我踩过的坑:早期做低功耗项目时,为了省事把 BH1750 一直留在连续模式,想着读的时候直接读就行,结果整机功耗始终降不下来。后来用电流表一测,BH1750 连续模式工作电流约 0.12mA,看着不大,但对纽扣电池来说就是致命的。改成单次模式后待机电流直接掉了一个数量级。做电池设备,每一个微安都要抠,传感器不用的时候一定要让它睡。希望帮到你。
本文还有配套的精品资源,点击获取