简介:面向AT89C51单片机初学者与嵌入式爱好者,这份资源完整演示了如何通过BH1750光照传感器采集环境光强度,并经串口输出到上位机显示。项目涵盖硬件接线、I2C协议驱动、UART串口配置与中断处理等关键环节,非常适合配套开发板动手实践和排错练习。压缩包共42个文件,以C源码和头文件为主,附带Keil工程文件、编译生成的hex烧录文件及lst/obj中间文件,整体仅80KB,可快速打开工程查看或烧录验证。资源同时提供“网上版”与“自己编写版”两套实现,便于对照学习I2C时序和代码风格差异,理解寄存器配置与数据解析逻辑;主程序、bh1750驱动、uart驱动均已模块化,可复用核心代码移植到其他单片机平台。目前已有1545人学习,适合需要串口调试、传感器读取及工程模板参考的开发者。
1. AT89C51光照值串口显示:从光敏电阻到上位机的一整条链路
课程设计里最常见的需求之一,就是让 AT89C51 把传感器读数送到电脑屏幕上。光照值这个选题尤其典型:它逼着你同时解决两件 51 新手最头疼的事——AT89C51 片内没有 ADC,模拟电压必须先外扩芯片转成数字;串口也不是往 SBUF 塞一个字节就完事,波特率、帧格式、上位机软件任何一个环节对不上,显示出来的就是乱码或空白。这条链路从传感器选型、分压电路、ADC0809 时序、UART 配置写到上位机排错,每一段都有可直接复现的代码和参数。适合正在做单片机课程设计、毕业设计的同学,也适合想快速回顾 8051 外设接法的工程师。
2. AT89C51 测光照的硬件选型:ADC0809 接线与光敏电阻分压电路
2.1 为什么先解决“没有 ADC”的问题:三种光照采集方案对比
AT89C51 本身没有 ADC 模块。这个前提决定了选型方向,常见的做法有三条路。
第一种是外扩并行 ADC,典型就是 ADC0809。8 位分辨率、8 路模拟输入、并行数据输出,教材和开发板上资料密度最高,时序固定,出了问题容易排查,代价是多占一组 IO 口和几个控制引脚。
第二种是光敏电阻加 RC 充放电,用定时器测电容充电时间。省掉 ADC 芯片,但精度受电阻电容温漂影响,而且测出来的“光照值”本质是时间值,要再做一次映射换算,代码调试比 ADC0809 麻烦得多。
第三种是 I2C 数字光照传感器,例如 BH1750。输出直接是勒克斯照度,精度最高,但 AT89C51 没有硬件 I2C,必须用普通 IO 口软件模拟时序,对初学者来说又多一层门槛。
我的建议很直接:课程设计和毕业设计选 ADC0809,可复现性最好,网上参考设计多,出了问题容易搜到答案。
2.2 光敏电阻分压电路:极性方向决定了光照值大小
模拟量采集部分只用一个光敏电阻和一个固定电阻分压。电源 VCC 经过光敏电阻到分压点 A,再经过固定电阻到 GND,分压点 A 接到 ADC0809 的 IN0 输入。分压公式:
V_A = VCC * R_fixed / (R_photo + R_fixed)
光照越强,光敏电阻阻值越小,V_A 越低。如果你希望“光照越强 ADC 值越大”,就把光敏电阻和固定电阻的位置对调,让分压点到 VCC 的路径经过固定电阻。这个方向问题经常被忽略,做出来的系统在台灯下数值反而下降,先想清楚再焊板子。
固定电阻我一般用 5.1kΩ,配合常见的光敏电阻(暗阻 1MΩ 量级、亮阻 10kΩ 量级),室内灯光下分压点大概在 1V 到 3V 之间,落在 ADC0809 参考电压的中间区间,8 位分辨率的利用比较充分。
2.3 ADC0809 的七根信号线与 AT89C51 的对应关系
ADC0809 是 28 脚并行 ADC,数据线 D0~D7 接 AT89C51 的 P1 口。这里有个初学者常踩的坑:P0 口作为数据总线需要外接上拉电阻,P1 口内部自带弱上拉,直接接数据线最省事。控制信号建议按下表分配:
| ADC0809 引脚 | 接到 AT89C51 | 说明 |
|---|---|---|
| IN0 | 分压点 A | 模拟量输入 |
| A、B、C | GND | 地址 000,选择通道 0 |
| ALE | P3.6 | 上升沿锁存通道地址 |
| START | P3.6 | 正脉冲启动转换,与 ALE 共用一脚 |
| EOC | P3.3 | 转换结束时输出高电平 |
| OE | P3.7 | 输出使能,高电平有效 |
| CLOCK | ALE 四分频 | 500kHz,手册典型 640kHz |
| VREF+ / VREF- | 5V / GND | 模拟参考电压 |
有个细节要特别注意:ADC0809 的 CLOCK 不接时钟完全不工作。AT89C51 的 ALE 引脚在 12MHz 晶振下输出 2MHz 方波,不能直接给 ADC0809,需要用 74LS393 或两个 D 触发器做四分频到 500kHz。手册上 CLOCK 范围是 10kHz 到 1280kHz,500kHz 落在典型值附近。有些开发板直接画一个 RC 振荡器给 ADC0809 提供时钟也能跑,但频率受器件精度影响,不如 ALE 分频稳定。
EOC 引脚接 P3.3 是借用 INT1 的外部中断输入通道,但本文用查询方式读取,P3.3 只当作普通输入口用。
3. ADC0809 采集光照电压的 C51 驱动:查询 EOC 与超时保护
3.1 读数据之前先写 P1 = 0xFF:准双向 IO 口的两用逻辑
P1 口是准双向 IO,内部上拉管驱动能力很弱。当作输入口用时,外部设备输出低电平,读到的是 0;如果外部设备输出高电平,读之前必须先把 P1 寄存器写 1,引脚电平才能被外部真正驱动。这个“先写 1 再读”的细节,在 ADC0809 接 P1 口的场景里尤其关键,漏掉这一行,数据线高电平会被内部锁存器拉低,读出来全是 0。
3.2 AD 转换时序:锁存地址、启动转换、等待 EOC、打开 OE
ADC0809 的转换流程固定分四步。第一步把通道地址送到 A、B、C 引脚,本设计三根地址线直接接地,这一步省了。第二步给 ALE 一个上升沿锁存地址,同时给 START 一个正脉冲启动转换,两个引脚并接到 P3.6,一个高脉冲同时完成两件事。第三步等待 EOC,转换开始后 EOC 变低,转换完成后变高。第四步把 OE 拉高,数据从 D0~D7 输出,此时读 P1 就是 8 位转换结果。
3.3 查询方式读取一次光照值的完整 C51 代码
#include <reg51.h> sbit ADC_START = P3^6; sbit ADC_OE = P3^7; sbit ADC_EOC = P3^3; unsigned char adc_read(void) { unsigned char val; unsigned int timeout; P1 = 0xFF; // 准双向口先写1,保证数据线高电平可被驱动 ADC_START = 0; ADC_START = 1; // 锁存通道地址并启动转换 ADC_START = 0; // START 恢复低电平,等待转换 timeout = 0; while (ADC_EOC == 0) // 转换未结束,EOC 为低 { if (++timeout > 0xFFFF) { return 0xFF; // 超时返回错误值,主程序可标记异常 } } ADC_OE = 1; // 打开输出使能 val = P1; // 读 8 位转换结果 ADC_OE = 0; // 关闭输出,释放数据总线 return val; }这里唯一需要解释的是“启动转换”的脉冲宽度:START 引脚需要至少 100ns 高电平,AT89C51 在 12MHz 下的机器周期是 1μs,两条 C 语句产生的脉冲宽度足够,不需要额外延时。
EOC 用查询而不用中断,原因是转换时间大约 100μs(640kHz 时钟下 64 个时钟周期),查询等待对这个数量级的任务几乎没有浪费。超时保护是很多人不写的:如果 CLOCK 没接好或芯片损坏,EOC 永远为低,程序会死等在 while 里;加一个计数器把它变成可恢复的错误状态,调试验证时能省大量时间。
3.4 把 ADC 值换算成电压:显示“光照值”之前先分清概念
8 位 ADC 将参考电压 0~5V 量化成 0~255,换算公式:
V = adc_value * 5.0 / 255.0
但要注意,这个电压不是照度。光敏电阻的分压电压与光照强度是非线性关系,串口直接显示电压值能反映光照趋势,但要说“显示光照值”,严格做法是在固定光源下标定,把 ADC 值映射成勒克斯。标定方法放在最后一章讲。
4. AT89C51 串口发送光照值:9600 波特率计算与帧格式设计
4.1 晶振选 11.0592MHz 还是 12MHz:波特率误差的根源
AT89C51 串口工作在方式 1(8 位 UART)时,波特率由定时器 1 的溢出率和 SMOD 位共同决定:
波特率 = (2^SMOD / 32) × 定时器1溢出率 定时器1溢出率 = 晶振频率 / (12 × (256 - TH1))
很多开发板默认焊 12MHz 晶振,定时器 1 方式 2 下 TH1=0xFD 时,算出来的波特率是 10417,不是 9600,误差约 8.5%。USB 转串口芯片和 PC 端按 9600 解析,结果就是乱码。精确 9600 需要用 11.0592MHz 晶振,因为 11059200 / 12 / 32 / (256 - 253) 恰好整除。
如果手头只有 12MHz 晶振,可以把上位机波特率改到 4800,误差会小一些,但最省心的做法还是换 11.0592MHz 晶振,AT89C51 支持最高 24MHz 时钟,11.0592MHz 完全在规格内。
4.2 串口初始化:SCON、TMOD、TH1、PCON 四个寄存器一次配齐
void uart_init(void) { TMOD = 0x20; // 定时器1工作在方式2,8位自动重装 TH1 = 0xFD; // 11.0592MHz 对应 9600 波特率 TL1 = 0xFD; PCON = 0x00; // SMOD=0,波特率不倍增 SCON = 0x50; // 方式1,8位UART,REN=1 允许接收 TR1 = 1; // 启动定时器1 }SCON 的 0x50 拆开看:SM0=0、SM1=1 选方式 1;REN=1 让串口具备接收能力,后面做命令响应要用。PCON 的 SMOD 位如果设为 1,波特率翻倍到 19200,上位机要同步修改,否则又是乱码。
4.3 发送一帧“LUX: 128\r\n”:行结束符决定上位机能否按行解析
上位机要稳定按行解析,帧格式必须带行结束符。我用 \r\n 结尾,串口调试助手和 Python 的 readline 都能按行识别。
void uart_send_char(unsigned char ch) { SBUF = ch; while (!TI); // 等待发送完成 TI = 0; } void uart_send_string(unsigned char *s) { while (*s) { uart_send_char(*s++); } } void uint_to_str(unsigned int val, unsigned char *buf) { unsigned char tmp[5]; unsigned char i = 0, j = 0; if (val == 0) { buf[0] = '0'; buf[1] = '\0'; return; } while (val > 0) { tmp[i++] = val % 10 + '0'; val /= 10; } while (i > 0) { buf[j++] = tmp[--i]; } buf[j] = '\0'; }不用 sprintf 是有原因的。Keil C51 下的 sprintf 会连带送入整个 printf 家族的底层重入代码,ROM 占用大,在小 RAM 的 51 上还可能碰到栈溢出的边界问题。自己写的 uint_to_str 只有加法和除法,逻辑固定,可预测性好。
主循环组织发送:
void main(void) { unsigned char light_val; unsigned char str[6]; uart_init(); while (1) { light_val = adc_read(); uart_send_string("LUX: "); uint_to_str(light_val, str); uart_send_string(str); uart_send_string("\r\n"); delay_ms(200); // 约每秒5帧,人眼和上位机都友好 } }delay_ms 可以用两层空循环近似,12MHz 下 for (i=0; i<200; i++) for (j=0; j<1000; j++); 约 200ms,不精确但够用;后面要做数据记录时,建议换成定时器 0 中断维护的 tick。注意这里发送的 LUX 后面跟的是 0~255 的 ADC 原始值,不是严格意义上的勒克斯照度。未标定之前,显示“ADC: 128”反而更严谨,要显示“LUX”就把标定表做进去,方法见最后一章。
5. 上位机显示光照值:串口调试助手配置与四类常见故障排查
5.1 驱动先于调试:CH340 和 FTDI 串口驱动的选择
AT89C51 通过 MAX232 电平转换或 USB 转 TTL 模块连到电脑。常见的 USB 转串口芯片是 CH340 和 FTDI,Windows 10 以上对 FTDI 多数免驱,CH340 则经常要手动装驱动。设备管理器里看到“USB-SERIAL CH340”说明驱动正常,显示未知设备就先重装 CH340 驱动再插拔 USB 线。Ubuntu 等 Linux 发行版内核自带 ch341 驱动,插上后 ls /dev/ttyUSB* 能看到设备,但如果当前用户不在 dialout 组,需要先执行 sudo usermod -aG dialout $USER 再重新登录。
5.2 串口助手参数:9600、8 数据位、1 停止位、关闭 DTR/RTS
打开串口调试助手后,先确认 COM 口号,USB 转串口每次插拔后 COM 号可能变化,以设备管理器为准。参数按顺序设置:波特率 9600、数据位 8、停止位 1、校验 None。最后一项很多人不知道:部分开发板的 USB 转 TTL 电路借用 DTR/RTS 控制单片机的复位和下载模式,串口助手一打开串口就产生电平跳变,单片机瞬间复位,数据完全收不到。把 DTR/RTS 的勾选去掉,问题立刻消失。
5.3 乱码、无数据、秒复位:故障现象与排查顺序对照
| 现象 | 最常见原因 | 排查顺序 |
|---|---|---|
| 收到字符但乱码 | 波特率不匹配或晶振不是 11.0592MHz | 先核对串口助手波特率,再示波器看 TXD 波形 |
| 完全无数据 | TXD/RXD 接反或 GND 没共地 | 检查接线:TXD 接 RXD,两板共地 |
| 打开串口瞬间有数据然后空 | DTR/RTS 触发复位 | 取消串口助手的 DTR/RTS 勾选 |
| 程序完全不跑 | EA 引脚悬空 | AT89C51 的 EA 接 VCC 才能从内部 ROM 启动 |
EA 引脚这个问题在教科书实验板上经常被无视,因为开发板已经接好。自己焊最小系统时,EA 悬空会导致 CPU 从外部 ROM 取指,程序一条都不执行。它不在串口链路里,却是排障时最容易被漏掉的点。
5.4 换一个上位机视角:Python 读取光照值并打时间戳
串口调试助手用来排查问题很快,但要做数据记录,Python 更方便:
import serial import time ser = serial.Serial('COM3', 9600, timeout=1) while True: line = ser.readline().decode('utf-8', errors='ignore').strip() if line: print(time.strftime('%H:%M:%S'), line)Linux 下把 COM3 换成 /dev/ttyUSB0。运行前先保证串口调试助手已经关闭,两个程序不能同时占用同一个 COM 口。readline 能稳定切分行,依赖的就是 4.3 节设计的 \r\n 帧格式。
5.5 串口烧写失败的排查角度:AT89C51 与 STC 的下载差异
串口相关的报错里,“串口烧写失败”出现频率很高。AT89C51 本身不支持串口 ISP,烧写要用编程器;但不少开发板丝印标着 89C51,实际焊接的是 STC89C52,后者支持串口下载。用 STC-ISP 下载失败,多数是三个原因:选错芯片型号、波特率过高、冷启动时序颠倒。STC 系列要求先点击“下载”按钮,再给板子上电,顺序反了会一直停在“正在检测目标单片机”。把波特率降到 4800,或者勾选“自动波特率”,能提高一部分旧芯片的成功率。
6. 进阶:光照值串口协议与状态机接收,把单向显示变成可查询
6.1 上位机发 READ 命令,单片机回传光照值
单向定时上报在调试阶段够用,但做数据记录和传感器巡检时,“问一次答一次”更可靠。在串口中断里用状态机解析 READ\r\n 文本命令,就能实现查询式读取。
状态机思路是:每收到一个字节,根据当前状态判断是否匹配下一个期望字符,匹配则状态加一,不匹配回到初始。比起逐字节 if 嵌套,状态机天然处理字节流中的噪音,而且不阻塞中断。这与 Modbus 等帧协议里常用的接收状态机思路一致,区别在于 Modbus 用 CRC 校验帧尾,文本命令用 \r\n 判定一帧结束。
unsigned char rx_state = 0; unsigned char got_cmd = 0; void uart_isr(void) interrupt 4 { unsigned char ch; if (RI) { ch = SBUF; RI = 0; switch (rx_state) { case 0: rx_state = (ch == 'R') ? 1 : 0; break; case 1: rx_state = (ch == 'E') ? 2 : 0; break; case 2: rx_state = (ch == 'A') ? 3 : 0; break; case 3: rx_state = (ch == 'D') ? 4 : 0; break; case 4: if (ch == '\r' || ch == '\n') { got_cmd = 1; rx_state = 0; } else { rx_state = 0; } break; } } }主程序初始化时加一行 ES = 1 打开串口中断,主循环检测到 got_cmd 后清标志、读 ADC、发一帧数据。发送路径的 uart_send_char 要配合一处修改:发送前 ES = 0,while (!TI) 结束后再 ES = 1,否则 ISR 可能在发送等待期间把 TI 清掉,导致死等。临时关中断会延迟接收中断的响应,但 RI 标志不会丢,对低频命令协议足够。
6.2 从 ADC 码值到真实照度的标定方法
回到“串口中显示光照值”的最终问题:0~255 的码值如何变成勒克斯。最实用的做法是取室内暗光、台灯下、窗边直射三个点,用手机照度计 App 做参考,记下照度值和对应 ADC 码值,做分段线性映射。
| 环境 | ADC 码值 | 照度 (lux) |
|---|---|---|
| 室内暗光 | 40 | 50 |
| 台灯下 | 120 | 800 |
| 窗边直射 | 230 | 6000 |
段与段之间线性插值,码值落在两端之外直接取边界。查表映射比拟合高次曲线直观,调试点位也方便。把上报间隔从 200ms 改成 100ms,串口助手开时间戳记录模式,就能看到更平滑的光照波动曲线,这套显示链路也就从“能显示”走到了“能记录”。
本文还有配套的精品资源,点击获取