51单片机智能鱼缸监控系统:传感-控制-通讯闭环设计与实现
2026/9/13 13:12:12 网站建设 项目流程

简介:基于单片机的智能鱼缸监控系统设计完整项目,面向自动化、物联网、电子信息等相关专业的学生、老师及企业开发者,既适合课程设计、毕业设计,也可作为单片机综合实践与他人交流学习的参考。压缩包共包含六十八个文件,大小约五点零六兆字节,覆盖C语言源码、Proteus仿真工程、Keil工程文件、电路原理图和实物效果图,以及详细的说明文档,既能在Keil中阅读程序,也能在Proteus中运行仿真,便于前后对照验证。目前已有157人浏览学习,该作品获导师认可,答辩评审分达到95分,代码经过实测运行成功,整体完成度较高。内容涉及温度采集、LCD显示、电机控制等典型外设应用,并配有完整的说明文档和电路图,适合从中学习单片机软硬件设计思路,掌握排错与调试方法,也可在此项目基础上继续扩展功能,满足不同的应用场景。

1. 拆开标题:这是一套传感-控制-通讯闭环,不是一条鱼缸

出差一周回来,加热棒上结着水垢,LCD 显示 18℃,鱼的状态已经不对。伸手摸水却是温的——这不是传感器坏了,而是把“监控”做成了“采集”。标题这套智能鱼缸监控系统,拆开看是一套闭环:DS18B20 测水温,水位和光敏传感器做环境判断,单片机算完温度后驱动继电器和舵机,LCD 显示现场数据,串口再把数据送出去。它是单片机原理及应用课程设计和单片机毕业设计里的高频选题,拿高分靠的不是炫技代码,而是“每条线、每个参数、每个故障判断都能讲清楚”。

这套系统对三类人有用:课设学生要一套经得起答辩追问的方案;备赛蓝桥杯单片机的要任务拆分的范本;想改自家鱼缸的工程师要的是掉电不丢参数、故障能自恢复。下面按最小系统、软件框架、控制策略、通讯协议、调试验证逐层展开,给出可直接落地的参数和代码。

2. 硬件选型:最小系统、传感器接口和驱动电路一次定死

2.1 主控选型:51 资源刚好够用,接口分配要先算账

鱼缸监控这个体量,传统 51 的资源刚好覆盖,这也是绝大多数课设选它的原因:一个定时器做时基,一个串口做下载和通讯,DS18B20 占一根 IO,水位光敏各占一根,LCD 用 4 线模式占 6 根,矩阵键盘占 8 根,继电器和蜂鸣器占 4 根,总共 22 个 IO 左右。40 脚封装剩下十来根引脚,但一旦加上 RTC、独立报警灯或多路 ADC,余量立刻见底,所以选型时先列接口分配表,比先列传感器清单更可靠。

如果换 STC15 系列增强型 51,内置 10 位 ADC、EEPROM 和硬件看门狗,外部 ADC0832 和复位芯片都能省掉;用 STM32 则资源过剩,只有题目里明确要接云平台、触摸屏或多路 PID 时才值得换,否则纯 51 方案在成本、代码复杂度和答辩讲解上反而是最优解。下面是常用的接口分配,LCD 数据线放 P0 口是为了避开串口和按键。

模块单片机引脚说明
DS18B20 温度P1.0单总线,4.7kΩ 上拉到 VCC
水位检测(数字)P1.1比较器输出,高/低电平
光敏分压(ADC)P1.2STC15 内置 ADC 或外接 ADC0832
加热棒继电器P2.0低电平触发,光耦隔离
气泵/水泵继电器P2.1低电平触发,光耦隔离
舵机信号P2.250Hz PWM,脉宽 0.5ms~2.5ms
蜂鸣器P2.3NPN 三极管驱动
LCD1602 数据 D4~D7P0.0~P0.3P0 口开漏,必须加上拉排阻
LCD1602 控制 RS/RW/EP2.5、P2.6、P2.74 线模式
矩阵键盘 4x4P3.0~P3.7行线输入,列线输出

这个分配里最容易翻车的是 P0 口上拉。89C52 的 P0 是开漏输出,不接排阻,LCD 数据线上的电平会随机跳,显示花屏;STC15 可以把 P0 设成推挽模式,省掉排阻,但初始化代码里必须写 P0M0/P0M1。另一个坑是串口下载用的 P3.0/P3.1 与矩阵键盘共用,下载瞬间键盘行列状态不确定,不影响烧录,但别在下载时用继电器接强电。

2.2 DS18B20 单总线接口:4.7k 上拉和 3 个常见坑

DS18B20 三根线:VCC、GND、DQ。DQ 必须接 4.7kΩ 上拉电阻到 VCC,STC 单片机 IO 的内部上拉阻值偏大(几十 kΩ),不足以稳定驱动单总线;连线超过 30cm 或继电器动作频繁的环境,改 2.2kΩ 上拉更稳。

第一个坑是寄生供电。很多教程说“两根线就能跑”,寄生供电确实能省 VCC,但温度转换期间器件电流需求大,如果 IO 口的推挽能力不足或线路上有压降,转换结果会整体偏移,继电器吸合的瞬间还可能直接复位。做课设和实际鱼缸时用外部供电,一根信号线加一个上拉,成本已经很低,没必要省。第二个坑是 12 位精度下温度转换要 750ms 左右,转换未完成不能读暂存器,否则读到的还是上一次的数据,程序里要用超时等待而不是裸延时。第三个坑是软件里 us 级延时要小心被 Keil 优化掉,空循环变量要加 volatile,或者用_nop_()组合,这点在最后一章调试验证里还会再讲。

2.3 水位、光敏和执行器:数字量优先,继电器和达林顿要分场合

水位检测有三针式、浮球式和光电式。三针式利用水的导电性分压,输出是模拟量,直接接单片机 IO 会在水面波动时频繁跳变,常见做法是接 LM393 比较器,加 0.1V 左右的滞回,输出干净的开关量。用 STC15 内置 ADC 直接读模拟量时,软件上要做均值滤波:

unsigned int adc_read_filter(unsigned char ch) { unsigned int sum = 0; unsigned char i; for (i = 0; i < 8; i++) { sum += adc_read(ch); /* STC15 单次采样,10 位结果 */ } return sum >> 3; /* 8 次平均,右移 3 位代替除法 */ }

这个滤波能压住水面轻微晃动带来的噪声,但压不住继电器吸合瞬间的电源跌落,所以水位传感器供电要和继电器驱动分开走线。

执行器部分常被问到一个问题:3.3V 的单片机能不能驱动达林顿模块。要分两种情况看。ULN2003 这类达林顿阵列的输入高电平门槛约 2.1V,3.3V 高电平能开启,但 ULN2003 是反相驱动,输出低电平时灌入电流最大 500mA,驱动 5V 继电器线圈够用;带光耦隔离的达林顿模块(常见输入侧 5V~24V),3.3V 直接给往往电流不足,中间要加一级三极管放大到 5V,或者把模块的“信号侧电源”接 5V。继电器驱动感性负载(加热棒、水泵)时必须反向并联续流二极管,否则关断瞬间的反向电动势会拉掉单片机电源。

3. 软件架构:定时器 tick 与状态机组织传感器采集

3.1 10ms 定时器时基,把 delay_ms 从主流程里赶走

很多 51 教材的主循环是“读按键→延时消抖→显示→延时刷新”这种流水账,放到监控系统里会出问题:DS18B20 温度转换要 750ms,如果这一步用 delay 阻塞,中间过来的串口帧全部丢失,按键按下去没反应,用户第一反应是死机。常见做法是建立一个 10ms 的定时器时基,主循环里只做轮询和状态推进。

volatile unsigned char sys_tick; /* 10ms 递增 */ volatile unsigned char tick_flag; /* 每次中断置 1 */ void Timer0_ISR(void) interrupt 1 { TH0 = 0xD8; /* 12MHz 晶振,方式1,10ms 重装值 */ TL0 = 0xF0; sys_tick++; tick_flag = 1; } void main(void) { init_all(); while (1) { if (tick_flag) { tick_flag = 0; key_scan(); /* 矩阵键盘消抖 */ sensor_poll(); /* 温度、水位、光敏状态机 */ controller_run(); /* 温度滞回、继电器控制 */ lcd_refresh(); /* 刷新显示 */ modbus_poll(); /* 串口 Modbus 解析 */ } } }

定时器初值 0xD8F0 对应 10000 个机器周期,12MHz 晶振 12 分频后正好 10ms。这个 tick 是所有时间基准的来源:按键消抖按两次 tick 判断,传感器轮询按 N 个 tick 判定超时,喂食周期按分钟数换算。中断里只置标志,不做耗时的传感器读写,具体采集放到主循环,避免中断嵌套把单总线时序打乱。

3.2 矩阵键盘消抖:状态机写法代替延时等待

矩阵键盘最常见的误用是在while(1)里读到键值后delay_ms(10)消抖,这段延时会让整个系统停顿 10ms。用状态机消抖,每个 10ms tick 只做一次采样,两次采样一致才认定有效键值,松手后才恢复到空闲态,不占用额外死等:

#define KEY_IDLE 0 #define KEY_PRESS 1 #define KEY_RELEASE 2 static unsigned char key_state = KEY_IDLE; static unsigned char key_tick; unsigned char key_scan(void) { unsigned char k = matrix_key_read(); /* 读矩阵,0xFF 表示无键 */ switch (key_state) { case KEY_IDLE: if (k != 0xFF) { key_tick = 0; key_state = KEY_PRESS; } break; case KEY_PRESS: if (++key_tick >= 2) { /* 连续两次 10ms 采样一致 */ key_state = KEY_RELEASE; return k; /* 按下有效,返回一次 */ } if (k == 0xFF) key_state = KEY_IDLE; /* 抖动,回到空闲 */ break; case KEY_RELEASE: if (k == 0xFF) key_state = KEY_IDLE; /* 松手才算完成 */ break; } return 0xFF; }

matrix_key_read()是逐列拉低、逐行读取的 4x4 扫描函数,返回值是键位编码或 0xFF。状态机的好处是“无阻塞、不丢键”:用户按住键不放,系统不会反复触发;抖动期间松手,状态回退到 IDLE,不会误判。这个写法比传统 51 矩阵键盘教程里的双重 for 循环加延时更耐看,答辩时还能顺便讲出“有限状态机”的设计思想。

3.3 DS18B20 温度读取:负温度、故障值和 LCD 小数显示

DS18B20 的读温度流程四步:复位、发跳过 ROM 命令 0xCC、发启动转换命令 0x44、等待转换完成后复位并读暂存器前两个字节。把“等待转换完成”做成带超时的轮询,避免器件脱离时程序卡死:

int ds18b20_read_temp(void) { unsigned char lo, hi; unsigned int wait = 0; if (ds18b20_reset() != 0) return 0x7FFF; /* 复位失败,返回异常值 */ ds18b20_write_byte(0xCC); /* 跳过 ROM 匹配 */ ds18b20_write_byte(0x44); /* 启动温度转换 */ while (ds18b20_read_byte() != 0xFF) { /* 转换中总线为低 */ if (++wait > 1000) { ds18b20_reset(); return 0x7FFF; /* 超时保护,防死循环 */ } } ds18b20_reset(); ds18b20_write_byte(0xCC); ds18b20_write_byte(0xBE); /* 读暂存器 */ lo = ds18b20_read_byte(); hi = ds18b20_read_byte(); return (int)((hi << 8) | lo); /* 单位 0.0625℃,见下方说明 */ }

返回值的单位不是摄氏度,而是 0.0625℃ 的倍数,比如 160 表示 10.0℃。这里有个容易踩的坑:Keil C51 的 int 是 16 位,负温度 0xFFA1 直接就是 -95,位模式天然正确,不需要再减 0x10000 做补码修正;如果移植到 32 位环境,就必须先判符号位再修正。LCD 显示一位小数时用整数运算t * 10 / 16,避免引入浮点库,这对 51 的代码大小和运行速度都友好。连续三次返回 0x7FFF 时,程序应判定传感器断线,显示 ERR 并触发蜂鸣器报警,而不是继续用上次的旧温度驱动加热棒。

4. 控制策略:滞回控温、定时喂食和 EEPROM 参数保存

4.1 水温对象是大惯性:开关、滞回、PID 的取舍

鱼缸水温是典型的大惯性对象,加热棒功率相对水体偏小,温度变化很慢。直接 on/off 控制会让继电器在设定点附近频繁吸合,触点寿命大幅下降,所以至少要加滞回区间:低于设定值减滞回量就开,高于设定值加滞回量就关,中间区间保持原状态不动。

void heater_control(void) { int temp = read_temp_x10(); /* 0.1℃ 精度,整数运算 */ int set = g_cfg.temp_set * 10; /* 设定温度 */ int diff = g_cfg.temp_diff * 10; /* 滞回差值 */ if (temp < set - diff) { HEATER_ON; } else if (temp > set + diff) { HEATER_OFF; } /* 处于 [set-diff, set+diff] 之间时保持原状态 */ }

设定 26℃、滞回 0.5℃ 时,温度低于 25.5℃ 开加热,高于 26.5℃ 关加热,继电器动作间隔通常能拉到几分钟以上。这个滞回量不能太小,水温传感器位置离加热棒太近时,0.2℃ 的滞回仍会频繁动作;也不能太大,超过 1℃ 会让水温波动明显,鱼容易生病。

PID 在毕业设计里属于加分项,但要注意水温对象纯滞后大,位置式 PID 直接套容易超调。经验参数是采样周期 1s,Kp 取输出量程的 5%~10%,Ki 约 Kp/60,Kd 对这类慢对象作用有限,想用就往 2 倍 Kp 试探。只要调参时观察到输出反复振荡,优先降 Kp,而不是动 Ki。闭环温度控制实验里常见的“温度过冲后回落再冲”现象,就是积分饱和在作祟,解决办法是偏差超过 2℃ 时暂停积分累加,输出限幅在 0~100%。

4.2 喂食舵机与照明定时:PWM 脉宽和电源隔离

舵机喂食是整套系统里唯一涉及机械运动的部分,也是最容易烧硬件的地方。SG90 这类模拟舵机的控制信号是 50Hz(周期 20ms)的 PWM,脉宽 0.5ms 对应 0°,1.5ms 对应 90°,2.5ms 对应 180°,喂食时让舵机在 0° 和 90° 之间往复即可。用 STC15 的 PCA 硬件 PWM 时,设置 CMOD 的时钟源和溢出周期,CCAPM0 打开 PWM 模式,再往 CCAP0H/CCAP0L 写脉宽值;不要用软件延时翻转 IO 模拟 PWM,中断抖动会让舵机发出“滋滋”声并影响角度精度。

舵机的堵转电流峰值接近 1A,和单片机共用同一个 5V LDO 时,堵转瞬间电压跌落会直接让单片机复位。常见做法是舵机单独用一个 5V 电源(甚至可以复用旧的充电器),地线跟主板共地;正极千万不能接到单片机板载稳压之后。喂食次数和角度做进配置结构体里,由按键设定,默认值每天 2 次,角度 45°,这样实机调试时不用反复改代码重新烧录。

照明定时可以不加 RTC,直接用 10ms tick 累计秒和分钟,数据结构里保存“开灯时刻”和“关灯时刻”,主循环里比较当前分钟数。注意分钟计数器用 unsigned int 会在约 45 天溢出,要么用 unsigned long,要么每天零点归零重算,别留着这个隐患答辩时被问住。

4.3 STC 的 EEPROM 当配置存储:擦写寿命要省着用

设定温度、滞回量、喂食参数如果只存在 RAM 里,断电全丢,监控系统就显得不完整。STC15 系列自带 EEPROM(IAP),不需要外挂 24C02,中颖单片机的 EEPROM 库也是同样原理。写入流程是“擦扇区再写字节”,一个扇区 512 字节,擦除后数据全变 0xFF。

typedef struct { unsigned char temp_set; /* 设定温度 */ unsigned char temp_diff; /* 滞回温差 */ unsigned char feed_count; /* 每日喂食次数 */ unsigned int feed_time[4]; /* 喂食时刻,自零点起的分钟数 */ unsigned char chk; /* 校验和 */ } SYS_CONFIG; void eeprom_write_bytes(unsigned int addr, unsigned char *buf, unsigned char len) { unsigned char i; IAP_CONTR = 0x80; /* 使能 IAP 电源 */ for (i = 0; i < len; i++) { IAP_CMD = 0x02; /* 字节写命令 */ IAP_ADDRL = (addr + i) & 0xFF; IAP_ADDRH = (addr + i) >> 8; IAP_DATA = buf[i]; IAP_TRIG = 0x5A; /* STC 触发时序 */ IAP_TRIG = 0xA5; _nop_(); IAP_CONTR = 0x00; /* 关闭 IAP,防误写 */ } } unsigned char config_checksum(SYS_CONFIG *c) { unsigned char i, s = 0; unsigned char *p = (unsigned char *)c; for (i = 0; i < sizeof(SYS_CONFIG) - 1; i++) s += p[i]; /* 累加和,不含 chk 字段 */ return s; }

写入前要先发扇区擦除命令(IAP_CMD = 0x03,地址取扇区首地址),否则写不进 0 位。EEPROM 寿命约 10 万次擦写,所以只能在“配置变更”时写,不能把温度采样值每小时往里存,那是日志该干的活。上电时读出配置,校验和不对就写回默认值,这是最容易被忽视的细节:第一次烧录时 EEPROM 是随机值,不判断直接使用,加热棒可能按 0℃ 设定拼命加热。

提示:IAP 命令字和触发时序在不同系列(STC15/STC8/中颖)里存在差异,换型号务必查对应数据手册,别直接照抄寄存器名。

5. 串口与 Modbus RTU:监控数据真正走出单片机

5.1 Modbus 帧接收:帧间隔判定和串口中断状态机

鱼缸监控如果只在本机显示,也就是个“电子温度计”,串口把数据送出去才算监控。工厂和实验室常见的协议是 Modbus RTU,帧格式为“从站地址 + 功能码 + 数据 + CRC16”,帧和帧之间至少间隔 3.5 个字符时间。在 9600 波特率 8N1 下,一个字符 11 位,3.5 字符约 4ms,所以帧结束判定通常用“超过 4ms 没有新字节”来实现。

51 上做帧接收最容易犯的错是在串口中断里做复杂判断。中断里只做两件事:收字节进缓冲区、清零帧间隔计数器:

volatile unsigned int rx_gap; /* 帧间隔计数,10ms 累加一次 */ volatile unsigned char rx_len; volatile unsigned char rx_buf[48]; volatile unsigned char rx_done; void Uart1_ISR(void) interrupt 4 { if (RI) { RI = 0; rx_buf[rx_len++] = SBUF; rx_gap = 0; /* 收到字节就清零间隔 */ if (rx_len >= sizeof(rx_buf)) rx_len = 0; /* 溢出,丢弃整帧 */ } }

帧结束判定放到 10ms 定时中断里,rx_gap每 10ms 加一次,超过 12ms 没有新数据就认为一帧收完。严格 4ms 判定在 10ms 时基下做不到,但这个简化完全可用:Modbus 主机是“发请求→等响应→再发请求”的问答模式,两帧之间间隔远大于时序要求,只要一帧内部的字节间隔远小于 10ms(9600 波特率下单字节约 1.14ms),就不会拆错帧。这个取舍在工程上是划算的,省掉一个 1ms 定时器。

5.2 功能码 03/06 的解析:寄存器映射决定上位机复杂度

Modbus 功能码 03 读保持寄存器、06 写单个寄存器,足够覆盖鱼缸监控全部交互。难点不在协议本身,而在寄存器映射的设计。把“温度、水位、光敏”映射到只读地址,把“设定温度、滞回量、喂食参数”映射到读写地址,上位机(或者串口调试助手)只需要按表操作,不需要知道每个变量在单片机里叫什么名字:

寄存器地址内容读写属性
0x0000水温 ×10只读
0x0001水位状态 0/1只读
0x0002光敏 ADC 原始值只读
0x0003设备状态位只读
0x0010设定温度 ×10读写
0x0011滞回温度 ×10读写
0x0012每日喂食次数读写

请求帧示例:01 03 00 00 00 08 CRC_L CRC_H,含义是“从站 1,读从 0x0000 开始的 8 个寄存器”。单片机端解析时先校验从站地址,再按功能码分发:

void modbus_poll(void) { unsigned char fcode; if (!rx_done) return; rx_done = 0; if (rx_buf[0] != MODBUS_ADDR) return; /* 从站地址不符,丢弃 */ fcode = rx_buf[1]; switch (fcode) { case 0x03: mb_read_holding(); break; case 0x06: mb_write_single(); break; default: mb_exception(1); break; /* 非法功能码 */ } }

mb_read_holding()按请求里的起始地址和数量组织数据,拼出响应帧后清空发送缓冲;mb_write_single()把寄存器的值写进配置结构体,并立刻调用 EEPROM 保存。寄存器地址不用 1:1 对应真实内存,中间加一层 switch 映射,上位机和单片机各自演进互不影响。

5.3 CRC16 实现:多项式 0xA001 和低字节在前

Modbus RTU 的 CRC16 初始值是 0xFFFF,多项式反射后是 0xA001,按位计算适合 51 这种没有硬件 CRC 的芯片,一帧 8 字节在 9600 波特率下计算开销基本可忽略:

unsigned int crc16_modbus(unsigned char *p, unsigned char len) { unsigned int crc = 0xFFFF; unsigned char i; while (len--) { crc ^= *p++; for (i = 0; i < 8; i++) { if (crc & 1) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }

发送 CRC 时时序是低字节在前、高字节在后,这和很多单片机教程里习惯的“高字节在前”相反,第一次对接上位机时八成栽在这里。校验范围从地址字节开始,一直覆盖到数据字节的最后一个,CRC 本身不参与计算。

6. 调试验证:示波器看时序,Modbus 主机工具回读寄存器

6.1 DS18B20 复位波形:先区分硬件还是程序问题

DS18B20 调不通,八成在复位时序:主机拉低 480us 以上,释放后等待 60us,再读存在脉冲。用逻辑分析仪抓 DQ 引脚,正常波形是“一个长低电平,然后一个约 60us 的低电平脉冲”。看不到存在脉冲,先查上拉电阻和接线,再查程序里 us 延时是不是被 Keil 优化掉了。空循环变量不声明 volatile,-O1 及以上优化会直接把循环体删掉,延时变成 0us,时序全乱。用_nop_()组合延时最保险,一个_nop_()在 12MHz 下约 1us。

6.2 Keil5 调试的两个边界:单步别碰单总线,仿真器别碰电源

Keil5 的调试器看变量和寄存器很方便,但单步执行会破坏 DS18B20 的微妙时序——PC 控制单片机单步时,us 级延时的实际时间被拉长到毫秒级。做法是在ds18b20_read_temp()函数入口和出口各设一个断点,全速运行到断点,再检查返回值;函数内部的每一步不要单步。Modbus 解析部分则相反,可以放心单步走,因为串口数据已经在缓冲区里了,时序已经过去。

6.3 Proteus 仿真只验证逻辑与 UI,干扰和电源问题它给不了答案

Proteus 仿真 51 单片机对验证状态机、按键、LCD 显示这类逻辑问题非常高效,但它的传感器模型和时序模型都比较理想,DS18B20 在 Proteus 里几乎不会出现上拉不足或时序竞争的问题,真机上却可能一接就出错。仿真里也别指望它能模拟继电器吸合瞬间的电源跌落——那是实物调试才看得到的现象。所以流程上先 Proteus 跑逻辑,再上实物调时序,答辩时如实说“仿真验证了程序框架,硬件验证了接口时序”反而是加分表达。

6.4 验收技巧:用 Modbus 主机工具回读全部寄存器

上电前先断开加热棒强电,只接 5V 控制板。用 ModbusPoll 或串口助手按 9600 8N1 连接,从站地址设 1,读取 0x0000 开始的 8 个寄存器。温度值应当和 LCD 显示一致,差 1 个字(0.1℃)以内算正常。然后用功能码 06 写 0x0010 为 260(26.0℃),断电重启再读一次,如果仍然是 260,说明 EEPROM 保存链路完整。

最后一步验证帧间隔:ModbusPoll 会连续轮询请求,观察单片机响应是否稳定。把帧间隔判定阈值从 12ms 逐步调小,找到临界值,再调回两倍安全余量——这是判断单片机端帧超时逻辑是否健壮的最直接方法。

本文还有配套的精品资源,点击获取

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

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

立即咨询