简介:这份文档围绕基于STC89C51/52单片机的坐姿矫正系统展开,面向电子信息、自动化等专业学生及51单片机初学者,也可作为毕业设计选题、课程设计和单片机综合实践的参考。系统集成自动与手动模式,利用光敏电阻和模数转换检测环境光线,结合人体感应、测距模块、LCD1602显示与蜂鸣器提醒,可在距离过近、光线不足或学习时长超限时提示纠正坐姿、注意休息。资源包仅含1个doc文件,约295KB,正文覆盖摘要、总体方案、单片机最小电路、显示模块、光线监测、测距电路、系统功能与测试总结等章节,便于读者梳理软硬件模块划分、器件选型、联调思路及文档结构。目前已有793人学习下载,适合需要搭建51单片机课程设计或撰写毕业设计文档的读者参考。
1. 一块 51 板子能管住坐姿吗:这个毕业设计到底在解决什么
前段时间帮人看一份 51 单片机毕业设计的实物,题目是坐姿矫正系统,板子焊好之后学生自己都说不清那几根线各干什么用。这类题目看着简单,实际把光敏电阻、模数转换、超声波测距、液晶显示、蜂鸣器和按键揉在一块板子上,串起来的问题比单个模块多得多。这个项目用 STC89C52 做主控,LCD1602 做显示,光敏电阻配 ADC0832 采环境光,超声波模块测人离桌面的距离,超时或者太近就蜂鸣器响,本质是一个「三路传感器 + 一个执行器 + 一个计时器」的提醒装置。适合正在做 51 单片机毕业设计、需要一份能跑起来的完整参考的人,也适合想拿个小系统练手硬件时序的开发者。
2. 方案选型:为什么最后定的是 STC89C52 + LCD1602 + ADC0832
这套组合不是拍脑袋定的,原文里对主控和显示都做了方案对比,只是写成了论文体。把对比过程还原成工程视角看,其实是在三个约束下做取舍:一是本科阶段要能在两周内把电路焊完并调通,二是所有器件在本地电子市场能一次买齐,三是代码量要控制在自己能逐行看懂的范围。这三条约束一摆,很多「性能更好」的方案就自动出局了。
2.1 主控为什么没上 STM32
方案里比较过 AVR 和 STM32F103,最后落回 STC89C52。原因不是性能,是摩擦成本。STM32 要配仿真器、装支持包、建工程模板,光是让一个 GPIO 翻转起来,环境就得折腾小半天;而这个系统真正要用到的外设只有定时器、几个普通 IO 和一个外部中断,8 位机的资源绰绰有余。
STC89C52 的具体账是这样的:8K Flash 装这个程序最多用掉一半,512 字节 RAM(原文写的 256 是老型号说法,STC89C52 实际是 512)里全局变量加缓冲区不到 100 字节,32 个 IO 口把 LCD 的 8 位数据线加 3 根控制线一占,还剩二十来个,三个定时器一个做秒基准一个做超声波计时,正好够。12MHz 晶振下机器周期 1 微秒,超声波换算距离时不用再做除法补偿,这是实打实的便利。
要提醒的是,选 51 就意味着放弃硬件浮点和硬件乘除,凡是涉及小数的运算都得自己拆成整数。这个项目里所有「计算」最后都被我改成了整数比较:光线不分 0~100 的百分比,只分「暗/正常/亮」三档;距离不做小数,直接取厘米整数。新手容易在这上面栽跟头,写个float lux = ...上去,编译能过,跑起来 Flash 直接爆。
2.2 显示为什么放弃数码管选 LCD1602
数码管和点阵的方案都被否了,理由很实在:数码管显示不了汉字和英文字符,而这个系统要显示「LIGHT」「DIST」「TIME」这类标签,纯数字根本说不清哪个是哪个;点阵屏倒是能显示,但驱动芯片和扫描逻辑一上来,代码量翻倍,对 8 位机的 IO 也是个负担。
LCD1602 的好处是内置了字符发生器和控制芯片,两行十六字符,一根 8 位数据线加 RS/RW/EN 三根控制线就能驱动,初始化序列固定,网上现成的驱动改改就能用。它的缺点也得提前知道:并行接口占 IO 多,刷新靠软件延时,没有字库所以显示不了中文,对比度引脚接不好就是一片蓝屏。这些在第 5 章会逐条展开。
2.3 光线通道的两条路:外置 ADC0832 还是用内部比较器
光敏电阻输出的是连续电压,51 单片机没有内置 ADC,所以要外挂。常见的两条路:一条是 ADC0832 这类串行 ADC,8 位分辨率,两线(CLK + DIO)就能读,成本几块钱;另一条是拿 LM393 比较器搭个阈值电路,只输出「亮/暗」两个电平,电路更简单但只能定一个死阈值,调节要拧电位器。
这个项目选了 ADC0832,因为要「按环境光分档提示」,而分档就得有数值。ADC0832 是 8 位、双通道、串行输出,转换时间约 32 微秒,DI 和 DO 可以复用一根线(因为读和写不同时进行),这样只占 3 个 IO。代价是时序必须自己用软件模拟,CLK 高低电平的间隔、起始位的发送顺序错一步,读回来的就是 0 或者 255。第 3 章会给出完整的读时序代码。
2.4 引脚分配表
把引脚先定死,后面写代码和焊板子都以这张表为准,能省掉大量「代码里是 P1^2、板子上接的是 P1^3」的返工。
| 功能 | 引脚 | 方向 | 备注 |
|---|---|---|---|
| LCD1602 数据口 D0-D7 | P0.0-P0.7 | 输出 | 必须外接 10K 排阻上拉 |
| LCD1602 RS | P2.0 | 输出 | 高电平选数据寄存器 |
| LCD1602 RW | P2.1 | 输出 | 本方案固定拉低,只写不读 |
| LCD1602 EN | P2.2 | 输出 | 下降沿锁存 |
| ADC0832 CS | P1.0 | 输出 | 低电平片选有效 |
| ADC0832 CLK | P1.1 | 输出 | 软件模拟时钟 |
| ADC0832 DIO | P1.2 | 双向 | 读之前要先释放为输入 |
| 超声波 TRIG | P1.3 | 输出 | 10 微秒以上高脉冲触发 |
| 超声波 ECHO | P1.4 | 输入 | 高电平宽度即回波时间 |
| 蜂鸣器 | P1.5 | 输出 | 经三极管驱动,低电平响 |
| 模式键 | P3.2 | 输入 | 外部中断 0,顺手可用 |
| 加/减键 | P3.3 / P3.4 | 输入 | 调阈值、调时间 |
提示:P0 口内部是开漏结构,做输出时必须外接上拉电阻,这一条几乎每个用 LCD1602 的新手都会漏。
3. 硬件链路拆解:从光敏电阻上的电压到蜂鸣器那一声响
整块板子的信号流其实只有三条:光敏分压 → ADC0832 → 单片机;超声波发射 → 回波 → 单片机;单片机 → 三极管 → 蜂鸣器。三条链路各自独立,出问题时可以分开验证,不用整板怀疑。这一章按信号顺序把每条链路的关键电路和它对应的驱动代码过一遍。
3.1 最小系统:晶振、复位、电源三件事
最小系统是所有问题的根。STC89C52 的最小系统就三个部分:晶振电路、复位电路、电源去耦。晶振用 12MHz,两只 22pF 瓷片电容接地,走线尽量短,别从 LCD 数据线底下穿过去,否则显示会莫名其妙地闪。复位用的是 10 微法电解电容加 10K 电阻的经典 RC 电路,上电瞬间 RST 保持高电平约 1 毫秒完成复位。
电源这块是新手最容易省的地方。USB 供电或者 5V 电源适配器进来之后,板子上至少要有两个 0.1 微法瓷片电容分别放在单片机 VCC 脚和 LCD 背光附近,再加一个 100 微法电解电容做储能,特别是蜂鸣器响的瞬间电流会有一个尖峰,没有这个大电容顶着,单片机会直接复位——这个坑在第 5 章还会细说。
/* 最小系统的软件侧体现:时钟与复位之后第一件事是关中断、清状态 */ #include <reg52.h> void MCU_Init(void) { EA = 0; /* 先关总中断,避免初始化期间被打断 */ TMOD = 0x11; /* T0 方式1 做秒基准,T1 方式1 做超声波计时 */ TH0 = 0x3C; /* 12MHz 下 50ms 定时初值 65536-50000=0x3CB0 */ TL0 = 0xB0; TH1 = 0x00; TL1 = 0x00; IT0 = 1; /* 外部中断0 下降沿触发,接模式按键 */ EX0 = 1; EA = 1; }这段初始化看着平淡,但顺序有讲究。TMOD一次性写0x11,是因为两个定时器都要用方式 1(16 位),分开写容易出现「改了 T0 把 T1 的高四位顺手清零」的低级错误。EA放在最后开,是为了让前面的赋值全部落地之后再允许中断进来。实际调试时我习惯在MCU_Init之后加一句点亮某个 LED,确认程序真的跑到了这里,再往下进行——比盯着屏幕猜强。
3.2 光敏电阻加 ADC0832 的模拟前端
光敏电阻和一只 10K 固定电阻串联分压,中间点接 ADC0832 的 CH0。光照强,光敏阻值小,分压点电压低;光照弱,阻值大,分压点电压高。这个方向性很容易搞反,接反了就会出现「开灯反而提示光线暗」的滑稽现象,调试时先拿手电筒照一下看数值往哪个方向走,比翻资料快。
ADC0832 的读时序是纯粹的软件模拟,核心是三步:拉低 CS 片选,串行送出起始位和通道选择位,然后把 DIO 释放成输入,逐位读回 8 位数据(高位在前)。
sbit ADC_CS = P1^0; sbit ADC_CLK = P1^1; sbit ADC_DIO = P1^2; /* 读 CH0 单端输入,返回 0~255,数值越大表示环境越暗 */ unsigned char ADC0832_Read(void) { unsigned char i; unsigned char dat = 0; ADC_CS = 0; /* 片选有效 */ ADC_CLK = 0; ADC_DIO = 1; /* 起始位 */ ADC_CLK = 1; ADC_CLK = 0; ADC_DIO = 1; /* SGL/DIF = 1,单端输入 */ ADC_CLK = 1; ADC_CLK = 0; ADC_DIO = 0; /* ODD/SIGN = 0,选 CH0 */ ADC_CLK = 1; ADC_CLK = 0; ADC_DIO = 1; /* 释放总线,交给芯片驱动,准备读 */ for (i = 0; i < 8; i++) { ADC_CLK = 1; /* 上升沿之后数据有效 */ ADC_CLK = 0; dat <<= 1; if (ADC_DIO) { dat |= 0x01; } } ADC_CS = 1; /* 释放片选 */ return dat; }ADC_DIO = 1这一句在读取之前是必须的。51 的准双向口写 1 之后才真正变成弱上拉输入,如果不写 1 直接读,读回来永远是上一次输出的电平,表现出来就是「数值纹丝不动」。这是 ADC0832 调试里出现频率最高的原因,没有之一。
采样之后不建议直接拿单次结果做判断,环境光有工频闪烁,读数会在小范围内抖。我的做法是连采 5 次去掉最高最低各一个,剩下 3 个求平均,用一个长度为 5 的unsigned char数组滚存,中位值滤波在 51 上跑起来也就几十微秒的事。
3.3 LCD1602 的八位并口接线与时序
LCD1602 走 8 位并行,数据口接 P0,控制线 RS、RW、EN 分别接 P2.0 到 P2.2。RW 直接接地是最省事的做法——只写不读,不看忙标志位,靠固定延时保证每条指令执行完毕。这个取舍在正式产品里不合适,但在这种刷新频率极低的系统里完全够用,代价只是每条命令多等 1 到 2 毫秒。
#define LCD_DATA P0 sbit LCD_RS = P2^0; sbit LCD_RW = P2^1; sbit LCD_EN = P2^2; void DelayMs(unsigned int ms) { unsigned int i, j; for (i = 0; i < ms; i++) for (j = 0; j < 120; j++); /* 12MHz 下约 1ms,按需微调 */ } void LCD_WriteCmd(unsigned char cmd) { LCD_RS = 0; /* 指令寄存器 */ LCD_RW = 0; /* 写 */ LCD_DATA = cmd; DelayMs(1); LCD_EN = 1; DelayMs(1); LCD_EN = 0; /* 下降沿锁存 */ } void LCD_WriteData(unsigned char dat) { LCD_RS = 1; /* 数据寄存器 */ LCD_RW = 0; LCD_DATA = dat; DelayMs(1); LCD_EN = 1; DelayMs(1); LCD_EN = 0; } void LCD_Init(void) { DelayMs(15); /* 上电后必须等,小于 15ms 会初始化失败 */ LCD_WriteCmd(0x38); /* 8 位数据,两行显示,5x7 点阵 */ LCD_WriteCmd(0x0C); /* 开显示,关光标,关闪烁 */ LCD_WriteCmd(0x06); /* 写入后地址自动加 1 */ LCD_WriteCmd(0x01); /* 清屏 */ DelayMs(5); }DelayMs(15)这一句千万别删。LCD1602 内部的控制芯片上电之后需要一段内部复位时间,手册上写的是大于 15 毫秒,实测在电源缓慢上升的情况下还要更久。少了这一步,屏幕大概率是只有背光、一片蓝,什么都不显示。
写字符串的函数封装一下,把「第几行第几列」折算成 DDRAM 地址,后面界面逻辑就只用关心内容:
/* x: 0~15 列,y: 0 或 1 行 */ void LCD_ShowStr(unsigned char x, unsigned char y, unsigned char *str) { LCD_WriteCmd((y ? 0xC0 : 0x80) + x); while (*str != '\0') { LCD_WriteData(*str++); } }3.4 超声波模块的距离换算
超声波模块(HC-SR04 这类)的用法是:TRIG 脚给一个大于 10 微秒的高脉冲,模块自动发 8 个 40kHz 方波,然后把 ECHO 拉高,高电平持续的宽度就是从发射到收到回波的时间。声速在常温下约 340 米每秒,也就是每微秒 0.034 厘米,往返所以除以 2,最终得到经验公式:距离(厘米)= 高电平时间(微秒)÷ 58。
12MHz 晶振下机器周期正好 1 微秒,定时器每计一个数就是 1 微秒,换算直接是整数除法,不需要任何补偿。这也是当初坚持用 12MHz 而不是 11.0592MHz 的原因——后者对串口波特率友好,但这个项目不用串口。
量程方面,模块标称 3 厘米到 90 厘米,近端盲区在 3 厘米以内测不到,远端误差会涨到 1 到 2 厘米。做坐姿提示够用了,因为真正要判断的是「人离桌面小于 30 厘米」这个区间,正好在模块的舒适区。
3.5 蜂鸣器驱动:为什么必须加一只三极管
蜂鸣器分有源和无源两类,这个项目用的是无源压电式,需要单片机输出方波才能发声。无源蜂鸣器的工作电流在 20 到 30 毫安,而 51 的单个 IO 口灌电流能力有限,直接驱动会拉低整个电源电压,表现就是「一响就复位」。
正确做法是加一只 NPN 三极管做开关,基极串 1K 电阻限流,发射极接地,集电极接蜂鸣器负极,蜂鸣器正极接 5V。单片机输出高电平时三极管导通、蜂鸣器得电。有些电路写成低电平响,那就在软件里取反,别改硬件。
sbit BEEP = P1^5; void BEEP_On(void) { BEEP = 1; /* 高电平导通 NPN 三极管 */ } void BEEP_Off(void) { BEEP = 0; } /* 短鸣 n 次,用于距离过近提示 */ void BEEP_Beep(unsigned char n) { unsigned char i; for (i = 0; i < n; i++) { BEEP_On(); DelayMs(80); BEEP_Off(); DelayMs(120); } }用DelayMs做鸣叫会阻塞主循环,蜂鸣期间不能刷新屏幕也不能测距。短促的提示音可以接受,如果要做「连续滴滴滴直到人坐正」,就得改成由定时器中断翻转电平,主循环只负责置标志位。第 6 章改造时会用到这个思路。
4. 软件结构:一个主循环加两个定时器怎么把四件事串起来
软件部分最怕写成流水账:读光线、读距离、刷屏、判超时,四段代码平铺在main里,每一段都带死等延时,结果就是屏幕刷新卡顿、超声波时不时丢一次、按键按下去没反应。这个项目的结构是「主循环轮询 + 定时器 0 出秒基准 + 定时器 1 专供超声波计时」,四件事各归各位,互不阻塞。
4.1 主循环状态机与模式切换
主循环不做具体计算,只做三件事:采样、判断、刷新。采样部分把三路传感器的原始值读进全局变量,判断部分根据当前模式决定报警标志,刷新部分按 200 毫秒节流更新屏幕。
模式分自动和手动两种。自动模式下光线暗自动提示护眼、距离近自动提示坐正;手动模式下这两项提示关掉,只保留久坐计时提醒,适合在光线本来就暗或者需要近距离看书的场景下用。
unsigned char g_mode = 1; /* 1=自动,0=手动 */ unsigned char g_light = 0; /* 光线原始值 0~255 */ unsigned int g_dist = 0; /* 距离,单位 cm */ unsigned int g_sec = 0; /* 累计秒数 */ unsigned int g_limit = 45 * 60; /* 久坐阈值,默认 45 分钟 */ bit g_alarm_dist = 0; bit g_alarm_light = 0; bit g_alarm_time = 0; void main(void) { unsigned int refresh = 0; MCU_Init(); LCD_Init(); while (1) { /* 1. 采样 */ g_light = ADC0832_Read(); /* 每 10 次主循环测一次距离,避免超声波发得太密互相干扰 */ if (++refresh >= 10) { refresh = 0; g_dist = Ultrasonic_Measure(); LCD_Refresh(); } /* 2. 判断 */ if (g_mode) { g_alarm_dist = (g_dist > 0 && g_dist < 30) ? 1 : 0; g_alarm_light = (g_light > 180) ? 1 : 0; /* 数值越大越暗 */ } else { g_alarm_dist = 0; g_alarm_light = 0; } g_alarm_time = (g_sec >= g_limit) ? 1 : 0; /* 3. 执行 */ if (g_alarm_dist || g_alarm_light || g_alarm_time) { BEEP_Beep(1); } Key_Scan(); } }g_light > 180这个阈值不是拍脑袋来的,是标定出来的:拿手机照度计放旁边,暗到需要开灯的临界点把 ADC 值记下来,再留 20 的余量。g_dist < 30里的 30 厘米也是量出来的——让被测的人按正常坐姿坐好,读几次取平均,再往下压 5 厘米作为触发线,免得稍微动一下就报警。
4.2 LCD1602 驱动层:按位置刷字符串
LCD_Refresh是界面层唯一的出口,把全局变量格式化成两行显示。第一行显示光线档位和距离,第二行显示已用时间和设定时间。数字转字符串在 51 上不能用sprintf(太占 Flash 和栈),自己写个两位整数转字符串的小函数。
/* 把 0~99 的整数转成两位 ASCII,写入 buf,末尾不带结束符 */ void UInt2Str(unsigned char val, unsigned char *buf) { buf[0] = '0' + (val / 10) % 10; buf[1] = '0' + val % 10; } void LCD_Refresh(void) { unsigned char buf[6]; /* 第一行:L:xxx D:xxCM */ LCD_ShowStr(0, 0, "L"); UInt2Str(g_light / 10, buf); /* 0~255 压缩到两位显示 */ LCD_ShowStr(2, 0, buf); LCD_ShowStr(6, 0, "D"); UInt2Str(g_dist > 99 ? 99 : g_dist, buf); LCD_ShowStr(8, 0, buf); /* 第二行:T:xx/45 */ LCD_ShowStr(0, 1, "T"); UInt2Str(g_sec / 60, buf); LCD_ShowStr(2, 1, buf); LCD_ShowStr(4, 1, "/45"); }刷新频率控制在 200 毫秒一次。太快了屏幕会闪(因为每次都是全清重写),太慢了数值跳变明显。想彻底不闪,可以在写新内容前先比较旧内容,只更新变化的字符位,代价是多占用十几字节 RAM。
4.3 光线分档与提示策略
光线值不直接显示原始 ADC 值,那样用户看不懂。分三档:0 到 90 是「亮」,90 到 180 是「正常」,180 以上是「暗」。只有「暗」档才触发报警,前两档只在屏幕上有区分。
这里有个经验:人眼对亮度的感知是对数的,ADC 值是线性的,所以单纯按数值分档会感觉「灯明明还挺亮却已经报警了」。补偿办法是把分段点往前挪,宁可多报几次也别漏报,因为漏报的后果是用眼健康,多报只是烦一下。
4.4 定时器 0 的秒基准与久坐提醒
秒基准靠定时器 0 每 50 毫秒中断一次,中断里数 20 次得到 1 秒。这个中断服务程序必须极其短,只做计数和置标志,不做任何延时和显示,否则会把主循环的时间片吃掉。
void Timer0_ISR(void) interrupt 1 { static unsigned char tick = 0; TH0 = 0x3C; /* 重装初值,方式1不会自动重装 */ TL0 = 0xB0; if (++tick >= 20) { /* 20 × 50ms = 1s */ tick = 0; if (g_sec < 60000) { g_sec++; } if (g_sec >= g_limit) { g_alarm_time = 1; } } }方式 1 是 16 位定时器,溢出后不会自动重装初值,必须在中断里手动写TH0和TL0,而且要在中断开头写,缩短从溢出到重装之间的误差累积。写晚了每次中断会多吃几十个机器周期,一天下来时间会慢出好几秒。
阈值g_limit默认 45 分钟,可以通过按键在 15 到 90 分钟之间以 5 分钟为步进调整。调整时直接改这个变量,不需要重启定时器,因为比较发生在每秒的判断里。
4.5 超声波测距的非阻塞写法
最早的写法是while(ECHO == 0);死等,结果人把手拿开、模块没收到回波的时候,程序就永远卡在那一行,屏幕不再刷新,按键也没反应。这种现象就是典型的黑匣子——外人看来板子「死机」了,其实是程序困在一个循环里。
改进思路有两个:一是用定时器 1 计时并靠溢出标志判断超时,二是设一个软件计数上限。前者更准,因为有 16 位硬件计数,后者更简单但误差大。
unsigned int Ultrasonic_Measure(void) { unsigned int t; TRIG = 0; DelayMs(1); TRIG = 1; DelayMs(1); /* 约 1ms,远大于要求的 10us,稳妥 */ TRIG = 0; TH1 = 0; TL1 = 0; TF1 = 0; TR1 = 1; /* 开始计时 */ while (ECHO == 0) { /* 等回波起始,超时则放弃本次测量 */ if (TF1) { TR1 = 0; TF1 = 0; return 999; } } TH1 = 0; /* 从回波真正拉高开始重新计 */ TL1 = 0; while (ECHO == 1) { if (TF1) { TR1 = 0; TF1 = 0; return 999; } } TR1 = 0; t = (TH1 << 8) | TL1; /* 单位微秒(12MHz) */ if (t < 580) { return 999; /* 小于 10cm,落在盲区,认为无效 */ } return t / 58; /* 换算成厘米 */ }返回 999 表示「本次测量无效」,主循环拿到这个值之后跳过距离判断,而不是当成一个真实的 999 厘米。这一点很容易写错:如果把无效值直接参与比较,g_dist < 30永远不成立,报警就永远不触发,看起来像传感器坏了。
提示:连续测距之间至少留 60 毫秒,模块的余波没散干净就发下一次,读数会乱跳。
5. 常见问题排查:调这套板子翻车最多的五个地方
硬件系统调试有个规律:九成的问题出在电源、地线和一个被忽略的上拉电阻上,真正坏芯片的概率极低。下面这五条是按我踩坑的顺序排的,每条都写清楚现象、原因和解决办法,照着排查能省掉大量换器件的时间。
5.1 屏幕只有背光,一行字符都不显示
现象:上电后背光亮着,屏幕一片蓝或者顶端一排黑方块,程序烧录正常,蜂鸣器也能响。
原因:三种可能按概率排。第一是对比度引脚 VEE(第 3 脚)悬空或者接错,这个脚需要接一个 10K 电位器分压,接地对比度最高,接 5V 最低,悬空则没有偏压,液晶分子不翻转,看起来就是一片蓝。第二是初始化时没等那 15 毫秒,控制芯片还没准备好就收到了指令,全部被丢弃。第三是 RW 接高电平却没实现读忙检测,总线一直在冲突。
解决:先把电位器拧到中间位置试,多数情况下立刻就能看到字符。如果不行,拿万用表量 VEE 脚对地电压,应该在 0 到 1V 之间可调。确认电压正常再看初始化代码,把上电延时加长到 50 毫秒再试。RW 一律接地,用固定延时替代忙检测,简单可靠。
5.2 光线值纹丝不动,永远是 0 或者 255
现象:用手电筒照光敏电阻、用手捂住,屏幕上那个数值都不变。
原因:ADC0832 的 DIO 脚在读数据前没有写成 1。51 的准双向口内部有弱上拉,写 0 之后是强下拉,写 1 之后才是弱上拉输入。如果上一次操作把它写成了 0,后面直接读引脚,读回来的永远是 0,凑满 8 位就是 0x00。反之如果外部上拉很强而芯片没输出,读回来就是 0xFF。
解决:在for循环读 8 位之前加一句ADC_DIO = 1;,把口线释放出来。另外检查片选 CS 是否真的拉低过——有的模块把 CS 直接焊死在低电平,代码里再去控制一个并不存在的引脚,时序就全乱了。用示波器看 CLK 脚,应该有 11 个脉冲(1 个起始位 + 2 个通道位 + 8 个数据位),数量不对就是时序写错了。
5.3 人坐着不动,距离读数在 20 到 200 之间乱跳
现象:屏幕上的距离数字一秒一个值,完全没法用来判断。
原因:超声波模块对供电噪声和余波特别敏感。最常见的是两次测距间隔太短,上一次的余波还在空气里荡,被这一次当成回波收回来了,算出来的时间变短,距离就偏小。第二个原因是模块的 Trig 和 Echo 走线和其他信号线捆在一起,串扰导致误触发。第三个原因是供电纹波大,模块内部比较器误判。
解决:把测距间隔拉到 60 毫秒以上;Trig 和 Echo 用双绞或者远离 LCD 数据线;模块 VCC 脚就近加一个 0.1 微法电容。软件上再做一层中位值滤波,连续测 5 次排序取中间那个,跳变立刻收敛。判断距离过近时用「连续 3 次都小于阈值」才报警,单次触发不算数。
5.4 蜂鸣器一响,单片机就复位或者屏幕花一下
现象:静置一切正常,只要蜂鸣器开始响,屏幕字符乱掉,程序从头开始跑。
原因:蜂鸣器启动瞬间的浪涌电流把 5V 电源拉下去了。无源蜂鸣器工作电流 20 到 30 毫安,如果三极管基极没串限流电阻,或者蜂鸣器直接挂在 IO 口上,这个电流会瞬间超过整板电源的供应能力,单片机电压跌破复位阈值,直接重启。
解决:确认三极管基极串了 1K 电阻,蜂鸣器两端反向并一只 IN4148 续流二极管,电源入口加 100 微法电解电容。如果板子是从电脑 USB 取电,USB 口的限流也可能触发,换一个 5V/1A 的适配器试试。软件上避免蜂鸣器长时间连续鸣叫,用间断短鸣代替长鸣,发热和电流都会小很多。
5.5 计时不准,久坐提醒提前或者推迟触发
现象:实际坐了 40 分钟,屏幕上显示才 32 分钟;或者刚坐下十分钟就报警了。
原因:定时器 0 的中断服务程序里干了不该干的事。比如在里面调用了LCD_Refresh或者DelayMs,一次中断耗时几毫秒,20 次中断才凑一秒,这期间如果被更长的任务打断,秒基准就会被拉长。另一种原因是重装初值时TH0和TL0分开写,中间被高优先级中断插了一脚,计数值错位。
解决:中断服务程序只留计数和置标志,所有显示和判断都挪到主循环。重装初值时先关TR0,写完两个寄存器再开,或者干脆换成定时器 2 的自动重装模式(STC89C52 有 T2)。核对实际时间可以拿手机秒表跑 10 分钟对比,误差在 1 秒以内就算合格,超标就说明中断里还有耗时操作。
5.6 上电自检清单
每次改完硬件重新上电,按这个顺序过一遍,能把问题定位到具体模块,不用整板怀疑。
| 顺序 | 检查项 | 正常表现 |
|---|---|---|
| 1 | 电源指示灯 | 常亮不闪 |
| 2 | 单片机复位脚电压 | 上电高电平,稳定后接近 0V |
| 3 | LCD 背光与字符 | 背光亮,清屏后无残留 |
| 4 | 光线通道 | 遮挡光敏电阻时数值明显变化 |
| 5 | 超声波通道 | 手在 10 到 40cm 移动,读数跟随变化 |
| 6 | 蜂鸣器 | 手动触发短鸣,屏幕不花、不复位 |
| 7 | 按键 | 每次按下有明确响应,不连击 |
6. 二次开发:把「会响的报警器」改成「能记账的坐姿记录仪」
这套代码跑通之后,最值得动的不是硬件,是数据。现在的系统只会「响一下」,响完就忘,用户第二天完全不知道自己昨天坐姿有多糟。加一颗 24C02(I2C 接口的 EEPROM,8 字节一页,成本很低),把阈值和每天的累计超标分钟数存进去,整套东西的性质就从「玩具」变成「有反馈的工具」。
改造的关键是把所有标定参数从代码里挪出来。现在g_limit和距离阈值都写死在main里,用户想改成 30 分钟就得重新烧一次固件,这是血泪教训。加两个读写函数,上电时读进内存,按键调整时写回,程序升级也不会丢配置。
/* 24C02 简化读写,SCL/SDA 分别接 P3^6 和 P3^7 */ #define EE_ADDR_LIMIT 0x00 /* 久坐阈值,2 字节 */ #define EE_ADDR_DIST 0x02 /* 距离阈值,1 字节 */ void EE_SaveConfig(unsigned int limit, unsigned char dist) { EE_WriteByte(EE_ADDR_LIMIT, (unsigned char)(limit >> 8)); DelayMs(5); /* 页写间隔,不能省 */ EE_WriteByte(EE_ADDR_LIMIT + 1, (unsigned char)(limit & 0xFF)); DelayMs(5); EE_WriteByte(EE_ADDR_DIST, dist); DelayMs(5); } void EE_LoadConfig(unsigned int *limit, unsigned char *dist) { unsigned char hi = EE_ReadByte(EE_ADDR_LIMIT); unsigned char lo = EE_ReadByte(EE_ADDR_LIMIT + 1); *limit = ((unsigned int)hi << 8) | lo; *dist = EE_ReadByte(EE_ADDR_DIST); /* 首次上电 EEPROM 全是 0xFF,兜底成默认值 */ if (*limit < 300 || *limit > 5400) { *limit = 45 * 60; } if (*dist < 10 || *dist > 60) { *dist = 30; } }那个「兜底」判断比读写本身更重要。全新芯片里全是 0xFF,读出来是 65535,如果直接拿去比较,用户一坐下就报警,还找不到原因。限定一个合理区间,越界就回落到默认值,这类防御性代码在长期运行的系统里必须写。
再往前一步是统计。在定时器中断的每秒递增里顺手记一个「本分钟是否超标」的标志,每满一分钟累加到当天的总超标分钟数,存进 EEPROM 的另一个地址。屏幕第二行从「T:32/45」改成「T:32 S:18」,前面是本次已坐时间,后面是今天累计不良坐姿分钟数。用户看到这个数字每天在涨,比听十次蜂鸣器都管用。
想再加通信也不是不行:把串口用起来,按固定格式(比如$LIGHT,120,DIST,35,TIME,1830*)每 10 秒往外发一帧,电脑端用串口助手就能存成 CSV 画曲线。但要注意 12MHz 晶振下串口波特率误差偏大,9600 勉强可用,115200 完全不行,要么换 11.0592MHz 晶振,要么接受偶尔丢包。我一开始图省事没换,抓了一晚上数据发现有百分之几的帧是坏的,后来老老实实换了晶振。
从那以后我每次做这类小系统,第一版就把阈值、标定值、状态统计全部走 EEPROM,哪怕只是调个参数也要能掉电保存——硬件焊死在板子上,改一次参数就要拆一次壳、插一次下载线,这种后悔药吃一次就够了。希望帮到你。
本文还有配套的精品资源,点击获取