1. 项目开篇:为什么选择51单片机+BH1750+LCD1602?

大家好,我是老张,在嵌入式这行摸爬滚打十几年了,从8位的51到32位的ARM都玩过不少。今天想和大家聊聊一个特别经典、也特别适合新手入门的实战项目:用51单片机读取BH1750光照传感器的数据,并实时显示在LCD1602屏幕上。这个项目别看它小,但“麻雀虽小,五脏俱全”,它几乎涵盖了嵌入式开发里最核心的几个技能点:单片机基础GPIO操作、I2C通信协议的软件模拟、传感器数据采集与处理、以及外设驱动与显示。

我见过很多初学者一上来就想搞物联网、搞复杂算法,结果在第一步硬件通信上就卡住了,信心大受打击。其实,把基础打牢才是最快的捷径。这个项目里的BH1750传感器,它本身集成了AD转换,直接输出数字量,省去了我们模拟采样和复杂校准的麻烦;LCD1602又是最经典、资料最全的字符型液晶。用它们来学习,你可以把精力完全集中在“通信”和“控制”这两个核心逻辑上,不会被外围的杂音干扰。

我当年带徒弟,第一个项目往往就是这个。做完之后,他们对时序、对数据手册、对调试流程会有一个非常直观的认识。而且,这套东西非常实用,你可以把它做成一个迷你光照仪,放在书桌上监测光线,或者作为智能台灯、花卉补光系统的感知核心。下面,我就手把手带你从零开始,把每个环节掰开揉碎了讲清楚。

2. 硬件全家福:认识你的三位“主角”

工欲善其事,必先利其器。在写代码之前,我们得先搞清楚手头的家伙事儿都是谁,该怎么连接。这里的主角有三个:STC89C52RC单片机、BH1750光照传感器模块(常以GY-30型号出现)、以及LCD1602液晶显示屏。

2.1 核心大脑:STC89C52RC单片机

咱们用的51单片机,具体型号是STC89C52RC,这是国内最流行、性价比最高的51内核芯片之一。它拥有8K的Flash程序存储器,512字节的RAM,完全够我们这个项目挥霍。最重要的是,它兼容经典的8051指令集,学习资料和社区资源极其丰富,遇到问题基本都能搜到答案。它工作电压是5V,这和我们的模块供电电压完美匹配。

2.2 环境感知之眼:BH1750(GY-30模块)

BH1750FVI是这款传感器的芯片型号,而市面上常见的GY-30则是集成了该芯片、电平转换电路和上拉电阻的完整模块,对初学者极其友好。它有几个让我特别喜欢的优点:

  • 直接数字输出:芯片内部完成了光强到数字量的转换,我们通过I2C读出来的就是一个16位的整数,范围是0~65535,单位是勒克斯(lx)。省去了我们自己设计放大电路、做AD采样和校准的繁琐过程。
  • 通信接口简单:只靠两根线(SCL时钟线和SDA数据线)就能和单片机对话,这就是I2C协议的精妙之处。
  • 光谱特性好:它的感光特性接近人眼,测出来的数据和我们实际感受到的亮度比较吻合。

模块上通常有一个ADDR地址选择引脚。这个引脚悬空或接地时,器件I2C地址是0x46(写地址)或0x47(读地址);如果接到VCC,地址则变为0xB8。我们一般让它悬空,使用默认的0x46就行。

2.3 信息展示窗口:LCD1602液晶屏

LCD1602的意思是:每行可以显示16个字符,一共可以显示2行。它是一种字符型液晶,只能显示内置字库里的字母、数字和少量符号,不能显示任意图形。但这对于显示“Light: 12580 lx”这样的信息已经绰绰有余了。它通过8位数据线(或4位模式)和3根控制线(RS, RW, EN)与单片机通信。操作它,其实就是按照严格的时序,给它发送命令和数据。

2.4 硬件连接图:一张图搞定接线

理论说再多,不如一张接线图来得实在。下面这个表格就是整个系统的连接清单,照着接,准没错:

元件引脚连接到 STC89C52RC 引脚说明
BH1750 (GY-30) 模块
VCC5V电源正极,接单片机VCC
GNDGND电源地,接单片机GND
SCLP1.0I2C时钟线,接任意IO口,我们选P1.0
SDAP1.1I2C数据线,接任意IO口,我们选P1.1
ADDR悬空地址选择,悬空即地址为0x46
LCD1602 液晶屏
VSSGND电源地
VDD5V电源正极
VO接电位器中间脚对比度调节,通过10K电位器分压
RSP2.6数据/命令选择端
RWP2.5读/写选择端,我们始终写,所以接地或置低
EP2.7使能信号端
D0-D7P0.0 - P0.78位数据总线,接单片机P0口
A (背光正极)5V(通过限流电阻)背光电源,可串联一个220欧电阻
K (背光负极)GND背光地

注意:P0口作为数据总线时,内部是开漏结构,通常需要外接10K的上拉电阻排,否则电平不稳定,LCD无法正常工作。这是很多新手容易忽略导致屏幕不显示的第一个坑。

3. 代码实战一:用软件“模拟”出I2C的脉搏

51单片机大部分型号没有硬件I2C外设,但这恰恰是学习的好机会——我们用普通IO口来软件模拟I2C时序。这个过程能让你深刻理解I2C协议的精髓:时钟线SCL由主机(单片机)绝对控制,数据线SDA在SCL低电平时变化,在高电平时保持稳定。

3.1 引脚定义与基础宏

首先,我们把要用到的引脚定义好,地址也宏定义出来,这样程序看起来清晰,改起来也方便。

#include <REG51.H>
#include <INTRINS.H> // 用于_nop_()空指令延时

// BH1750 I2C 引脚定义
sbit SCL = P1^0; // 时钟线, 接P1.0
sbit SDA = P1^1; // 数据线, 接P1.1

// BH1750 I2C 从机地址 (ADDR引脚悬空或接地)
#define BH1750_ADDR_W 0x46 // 写命令地址
#define BH1750_ADDR_R 0x47 // 读数据地址

// LCD1602 控制引脚定义 (根据你的接线修改)
sbit LCD_RS = P2^6;
sbit LCD_RW = P2^5;
sbit LCD_EN = P2^7;
#define LCD_DataPort P0 // 数据端口接P0

3.2 I2C时序的骨架:起始、停止、应答

I2C通信总是以起始信号开始,以停止信号结束。每个字节传输后,接收方必须发送一个**应答(ACK)**信号。

// 微秒级延时函数,具体周期需根据单片机主频调整
void I2C_Delay5us(void) {
    _nop_();_nop_();_nop_();_nop_();
    _nop_();_nop_();_nop_();_nop_();
}

/**************************************
 * 起始信号:SCL高电平期间,SDA产生一个下降沿
 **************************************/
void I2C_Start(void) {
    SDA = 1; // 先拉高SDA
    SCL = 1; // 再拉高SCL
    I2C_Delay5us(); // 保持一段时间
    SDA = 0; // SDA拉低,产生下降沿
    I2C_Delay5us();
    SCL = 0; // 拉低SCL,准备传输数据
}

/**************************************
 * 停止信号:SCL高电平期间,SDA产生一个上升沿
 **************************************/
void I2C_Stop(void) {
    SDA = 0; // 先确保SDA是低的
    SCL = 1; // 拉高SCL
    I2C_Delay5us();
    SDA = 1; // SDA拉高,产生上升沿
    I2C_Delay5us();
}

/**************************************
 * 发送应答信号:主机在接收完一个字节后,发送ACK或NAK
 * 参数 ack: 0-发送ACK(低电平), 1-发送NAK(高电平)
 **************************************/
void I2C_SendAck(bit ack) {
    SDA = ack; // 设置应答电平
    SCL = 1;   // 拉高SCL,让从机读取应答位
    I2C_Delay5us();
    SCL = 0;   // 拉低SCL,继续后续操作
    I2C_Delay5us();
    SDA = 1;   // 释放SDA线(置高)
}

/**************************************
 * 接收应答信号:主机发送完一个字节后,读取从机的ACK
 * 返回值:0-从机应答ACK, 1-从机无应答NAK
 **************************************/
bit I2C_RecvAck(void) {
    bit ack;
    SDA = 1; // 主机释放SDA线,由上拉电阻拉高
    SCL = 1; // 拉高SCL
    I2C_Delay5us();
    ack = SDA; // 读取SDA电平,即从机的应答信号
    SCL = 0;   // 拉低SCL
    I2C_Delay5us();
    return ack; // 返回应答位
}

3.3 字节读写的核心逻辑

有了骨架,接下来就是血肉——字节的发送和接收。这里一定要理解“时钟脉冲”的概念:SCL每产生一个高电平脉冲,就锁存一位数据。

/**************************************
 * 向I2C总线发送一个字节
 * 参数 dat: 要发送的字节数据
 **************************************/
void I2C_SendByte(unsigned char dat) {
    unsigned char i;
    for (i = 0; i < 8; i++) { // 循环8次,发送8位
        SDA = (dat & 0x80) ? 1 : 0; // 取出最高位(bit7)送到SDA
        dat <<= 1; // 左移,准备发送下一位
        SCL = 1;   // 拉高SCL,从机在此时采样SDA
        I2C_Delay5us();
        SCL = 0;   // 拉低SCL,允许SDA变化
        I2C_Delay5us();
    }
    I2C_RecvAck(); // 发送完一个字节,等待从机应答
}

/**************************************
 * 从I2C总线接收一个字节
 * 返回值:接收到的字节数据
 **************************************/
unsigned char I2C_RecvByte(void) {
    unsigned char i, dat = 0;
    SDA = 1; // 确保主机释放SDA,设置为输入模式(靠上拉)
    for (i = 0; i < 8; i++) {
        dat <<= 1; // 左移,为接收新位腾出空间
        SCL = 1;   // 拉高SCL,让从机把数据放到SDA上
        I2C_Delay5us();
        if (SDA) dat |= 0x01; // 读取SDA电平,如果是高,则最低位置1
        SCL = 0;   // 拉低SCL,告诉从机可以改变SDA了
        I2C_Delay5us();
    }
    return dat;
}

写模拟I2C代码时,我最深的体会就是时序一定要够“胖”。Delay5us只是一个例子,如果你的单片机主频不是12MHz,这个延时需要调整。可以用示波器或者逻辑分析仪看SCL和SDA的波形,确保高低电平的宽度满足BH1750数据手册的要求(通常大于几微秒即可)。没有仪器的话,就保守一点,把延时加大,先确保通信成功。

4. 代码实战二:驾驭BH1750,读取光照数据

打通了I2C这条“路”,现在就可以和BH1750“对话”了。对话需要遵循固定的“语法”,也就是BH1750的命令集。

4.1 BH1750的指令与工作模式

BH1750有几条核心指令,我们主要用这三条:

  • 0x01:上电。传感器开始工作。
  • 0x10:连续高分辨率模式。这是最常用的模式,持续测量,分辨率是1勒克斯。
  • 0x07:连续低分辨率模式。分辨率较低(0.5勒克斯),测量速度更快。

我们选择0x10模式,在发送测量命令后,需要等待传感器完成一次测量,这个时间大约是120ms(高分辨率模式)。然后才能去读取数据。

4.2 封装BH1750的读写函数

基于前面写好的I2C底层函数,我们来封装针对BH1750的操作。

/**************************************
 * 向BH1750发送一条命令
 * 参数 cmd: BH1750指令码
 **************************************/
void BH1750_SendCmd(unsigned char cmd) {
    I2C_Start();                 // 发起起始信号
    I2C_SendByte(BH1750_ADDR_W); // 发送器件地址+写位
    I2C_SendByte(cmd);           // 发送命令
    I2C_Stop();                  // 发起停止信号
}

/**************************************
 * 从BH1750读取光照强度数据
 * 返回值:16位的光照强度原始值
 **************************************/
unsigned int BH1750_ReadData(void) {
    unsigned int lux = 0;
    unsigned char high_byte, low_byte;

    I2C_Start();
    I2C_SendByte(BH1750_ADDR_R); // 发送器件地址+读位

    // 连续读取两个字节,高位在前
    high_byte = I2C_RecvByte();
    I2C_SendAck(0); // 发送ACK,继续读取下一个字节

    low_byte = I2C_RecvByte();
    I2C_SendAck(1); // 发送NAK,表示这是最后一个字节

    I2C_Stop();

    // 合成16位数据
    lux = (high_byte << 8) | low_byte;
    return lux;
}

4.3 数据转换:从原始值到勒克斯值

读回来的lux是一个16位无符号整数。根据BH1750的数据手册,在高分辨率模式(0x10)下,实际的光照强度(单位:勒克斯 lx)需要将这个原始值除以1.2。即: 实际照度 (lx) = 原始值 / 1.2

这里涉及浮点运算。51单片机进行浮点运算比较慢,如果对实时性要求高,可以优化。但对于学习和一般应用,直接用浮点没问题。

float Get_Lux(void) {
    unsigned int raw_data;
    float lux_value;

    BH1750_SendCmd(0x01); // 上电(可选,如果已上电可省略)
    BH1750_SendCmd(0x10); // 设置为连续高分辨率模式

    // 等待测量完成,约120ms。这里用简单的延时循环替代
    // 实际应用中,建议用定时器,避免阻塞
    Delay_ms(180); // 给足余量,确保测量完成

    raw_data = BH1750_ReadData(); // 读取原始数据
    lux_value = (float)raw_data / 1.2; // 转换为勒克斯值

    return lux_value;
}

5. 代码实战三:让LCD1602“开口说话”

数据拿到了,得找个地方展示出来。LCD1602是个有点“小脾气”的家伙,必须严格按照它的时序操作它。它的操作分为两类:写命令(设置显示模式、光标位置等)和写数据(显示具体字符)。

5.1 LCD1602的初始化序列

上电后,LCD内部需要一段时间复位(约15ms)。之后,我们必须发送一系列固定的命令来对它进行初始化,设置数据接口位数、显示行数、光标是否显示等。

/**************************************
 * 检查LCD1602忙状态(通过读取BF位)
 * 在写命令/数据前,应确保LCD不忙
 **************************************/
void LCD_CheckBusy(void) {
    unsigned char sta;
    LCD_DataPort = 0xFF; // 数据端口设为输入(读状态)
    LCD_RS = 0; // RS=0,选择命令寄存器
    LCD_RW = 1; // RW=1,读操作
    do {
        LCD_EN = 1; // 使能脉冲
        sta = LCD_DataPort; // 读取状态字
        LCD_EN = 0; // 关闭使能
    } while (sta & 0x80); // 判断最高位BF,为1则忙,等待
}

/**************************************
 * 向LCD1602写入命令
 * 参数 cmd: 要写入的命令
 **************************************/
void LCD_WriteCmd(unsigned char cmd) {
    LCD_CheckBusy(); // 等待LCD空闲
    LCD_RS = 0; // RS=0,写命令
    LCD_RW = 0; // RW=0,写操作
    LCD_DataPort = cmd; // 将命令码送到数据端口
    LCD_EN = 1; // 产生一个高脉冲
    Delay_us(1); // 短暂延时
    LCD_EN = 0; // 下降沿,LCD执行命令
}

/**************************************
 * 向LCD1602写入数据(一个字符)
 * 参数 dat: 要显示的字符数据(ASCII码)
 **************************************/
void LCD_WriteData(unsigned char dat) {
    LCD_CheckBusy(); // 等待LCD空闲
    LCD_RS = 1; // RS=1,写数据
    LCD_RW = 0; // RW=0,写操作
    LCD_DataPort = dat; // 将字符数据送到数据端口
    LCD_EN = 1;
    Delay_us(1);
    LCD_EN = 0;
}

/**************************************
 * LCD1602初始化函数
 * 必须严格按照此顺序发送命令
 **************************************/
void LCD_Init(void) {
    Delay_ms(15); // 上电延时,等待LCD内部复位
    LCD_WriteCmd(0x38); // 功能设置:8位数据,2行显示,5*7点阵
    Delay_ms(5);
    LCD_WriteCmd(0x38);
    Delay_ms(5);
    LCD_WriteCmd(0x38); // 连续三次,确保稳定
    LCD_WriteCmd(0x0C); // 显示控制:开显示,关光标,不闪烁
    LCD_WriteCmd(0x06); // 输入模式:写入后光标右移,整屏不移动
    LCD_WriteCmd(0x01); // 清屏命令
    Delay_ms(2); // 清屏需要一点时间
}

5.2 在指定位置显示字符串和数字

初始化好后,我们就能在屏幕上任意位置显示内容了。LCD1602的DDRAM(显示数据RAM)有80个字节,但屏幕只显示前32个(两行各16个)。第一行起始地址是0x80,第二行起始地址是0xC0。

/**************************************
 * 在LCD指定行、列显示一个字符串
 * 参数 row: 行号 (0或1)
 * 参数 col: 列号 (0-15)
 * 参数 *str: 要显示的字符串指针
 **************************************/
void LCD_ShowString(unsigned char row, unsigned char col, unsigned char *str) {
    unsigned char addr;
    if (row == 0) {
        addr = 0x80 + col; // 第一行地址
    } else {
        addr = 0xC0 + col; // 第二行地址
    }
    LCD_WriteCmd(addr); // 先发送地址命令,定位光标

    while (*str != '\0') { // 遍历字符串,直到结束符
        LCD_WriteData(*str);
        str++;
    }
}

/**************************************
 * 在LCD指定位置显示一个整数(支持0-65535)
 * 参数 row: 行号
 * 参数 col: 列号
 * 参数 num: 要显示的整数
 * 参数 len: 显示的数字总长度(不足前面补空格)
 **************************************/
void LCD_ShowInt(unsigned char row, unsigned char col, unsigned int num, unsigned char len) {
    unsigned char i, buf[6]; // 最大5位数+结束符
    unsigned char addr;
    if (row == 0) addr = 0x80 + col;
    else addr = 0xC0 + col;
    LCD_WriteCmd(addr);

    // 将整数转换为字符串,从个位开始
    for (i = 0; i < 5; i++) {
        buf[4 - i] = num % 10 + '0';
        num /= 10;
        if (num == 0) break;
    }
    buf[5] = '\0'; // 字符串结束符

    // 显示,不足长度补空格
    for (i = 0; i < (len - (5 - i)); i++) {
        LCD_WriteData(' ');
    }
    LCD_ShowString(row, col + i, &buf[5 - i]); // 显示有效数字部分
}

6. 系统集成与主循环:让整个项目跑起来

现在,所有的零部件都准备好了:I2C驱动、BH1750操作、LCD1602驱动。是时候把它们组装起来,形成一个完整的、能循环工作的系统了。

6.1 主函数的设计思路

主函数的逻辑非常清晰,就是一个永恒的循环:

  1. 系统初始化:初始化LCD,初始化BH1750(发送上电和模式设置命令)。
  2. 进入主循环: a. 触发测量:向BH1750发送测量命令。 b. 等待结果:延时足够的时间(如180ms),等待传感器完成一次测量。 c. 读取数据:通过I2C读取两个字节的原始光照数据。 d. 数据处理:将原始数据转换为带单位的浮点数值(勒克斯)。 e. 刷新显示:清屏或定点更新,将最新的光照强度显示在LCD1602上。 f. 适当延时:等待一小段时间,然后开始下一次测量,避免刷新过快。

6.2 完整的main.c代码示例

将前面的模块组合起来,主程序可以这样写:

void main(void) {
    float light_lux;
    unsigned int raw_lux;
    unsigned char display_buf[16]; // 用于格式化的缓冲区

    LCD_Init(); // 初始化液晶屏
    LCD_ShowString(0, 0, "Light Sensor"); // 显示标题
    LCD_ShowString(1, 0, "Lux:");

    // BH1750初始化:上电并设置模式(只需一次)
    BH1750_SendCmd(0x01); // Power On
    Delay_ms(10);
    BH1750_SendCmd(0x10); // Continuously H-Resolution Mode
    Delay_ms(180); // 等待第一次测量完成

    while (1) {
        // 读取原始光照数据
        raw_lux = BH1750_ReadData();

        // 转换为勒克斯值
        light_lux = (float)raw_lux / 1.2;

        // 在LCD上显示(第二行,从第5列开始)
        // 使用sprintf需要编译器支持,这里用简单转换
        // 将浮点数转为整数显示,或自己实现浮点转字符串
        // 这里演示显示整数部分
        sprintf(display_buf, "%6.1f", light_lux); // 格式化为6位宽,1位小数
        LCD_ShowString(1, 5, display_buf);
        LCD_ShowString(1, 11, " lx"); // 显示单位

        // 延时一段时间再进行下一次采集,例如500ms
        Delay_ms(500);
    }
}

注意:上面的sprintf函数会占用较多资源,对于资源紧张的51单片机,建议自己写一个浮点数转字符串的函数,或者直接显示原始数据除以1.2后的整数值。

6.3 实测中的那些“坑”与解决之道

代码写完了,烧录进去,屏幕不亮?数据全是0?别慌,我踩过的坑你可能正在经历。

  • 坑1:LCD1602只有背光亮,没有字符。

    • 排查:首先检查VO对比度引脚,一定要接一个10K电位器进行调节,调到一个合适的电压(通常是0.5V-1V左右),字符才会显现。其次,检查P0口是否接了上拉电阻排,这是必须的!最后,用万用表量一下LCD的供电电压是不是稳定的5V。
  • 坑2:BH1750读数始终为0或65535。

    • 排查:首先用万用表或示波器检查VCC和GND是否接反、接实。然后检查SCL和SDA两根线是否接对,通信是否正常。可以单独写一个I2C扫描程序,看看能否检测到地址0x46的设备。最可能的原因是时序延时不够,把I2C_Delay5us()函数里的_nop_()数量加倍试试。另外,读取数据后等待测量完成的时间(Delay_ms(180))一定要给够。
  • 坑3:显示的数字乱跳或者有残留。

    • 排查:每次更新显示时,是局部更新还是整体刷新?如果只是更新数字部分,要确保覆盖了之前可能更长的数字(比如从“12580”变成“23”,会在后面留下“0”)。可以在显示新数字前,用空格覆盖旧数字的整个区域。电源不稳定也会导致乱码,给单片机和模块的电源引脚并联一个100uF的电解电容和一个0.1uF的瓷片电容,可以有效滤波。

调试嵌入式项目,分段调试是关键。不要一下子把所有代码都写好。可以先写一个让LCD显示“Hello World”的程序,确保LCD驱动没问题。再单独写一个通过串口打印BH1750数据的程序(如果单片机有串口),确保传感器通信没问题。最后再把两者结合起来。善用软件调试(如Keil的单步、变量观察窗)和硬件调试(万用表、逻辑分析仪),问题总能定位。

更多推荐