简介:这是一份面向嵌入式物联网学习者及课程设计、毕业设计需求者的项目文档,围绕基于STM32的智能鱼缸系统与配套微信小程序展开。资源以单个PDF交付,压缩包约42.7MB,正文系统梳理了以STM32F103RCT6为主控的硬件方案,涵盖DS18B20防水温度传感器、MQ137氨气传感器、光敏电阻、水质浑浊度传感器、ESP8266 WiFi模块与继电器的作用及选型依据。内容覆盖水质异常红灯提示换水、水温超限自动加热、光强低于阈值自动补光、氨气检测保障硝化环境、增氧泵定时启停等功能需求,并说明ESP8266以STA模式经MQTT接入腾讯云IoT平台上传数据、由小程序远程设置阈值与切换控制模式的实现思路。读者可据此获得从需求分析、硬件连接调试到软件编写联调的完整参考,适合作为物联网入门与课设、毕设的技术蓝本。已有146人学习。
1. 一套智能鱼缸真正的分水岭,在于本地控制闭环放在哪一侧
很多人做智能鱼缸,第一反应是买几个带 WiFi 的智能插座,把加热棒、水泵、补光灯分别挂上去,再用定时任务排班。这套方案能跑,但只要你养过一缸七彩神仙或者水晶虾,就知道它靠不住:插座只认时间不认水温,加热棒在室温 30℃ 的夏天照样按冬天的时间表工作,一次疏忽就是团灭。
把控制权交给 STM32 才是分水岭。板子本地读水温、水位、TDS,自己判定该不该通电,网络只负责把状态送上微信小程序、把阈值和人手下发的指令收回来。断网时鱼缸照常运行,联网时你在公司也能看到 25.4℃ 这个数字在跳。这也是近几年物联网毕业设计里,基于 STM32 的鱼缸类题目经久不衰的原因——它逼着你把 GPIO 操作、单总线时序、ADC 采样、定时器调度和一条完整的上下行链路全部串一遍,而不是调个现成 SDK 就交差。
这篇内容面向想把「STM32 + 物联网 + 微信小程序」这条链路真正打通的人,从外设分配讲到温控回差,再讲到小程序页面退出时该不该断开连接。
2. 智能鱼缸的外设清单与 STM32 资源分配表
2.1 先确定哪些量必须采、哪些设备必须控
做硬件盘点时,最忌讳一上来就堆传感器。先问三个问题:这个量失控会不会死鱼、这个量用户愿不愿意每天看、这个量采回来有没有对应的执行动作。按这个标准筛,必采项其实只有四类。
水温是第一优先级,因为它同时决定加热和风扇两条执行路径。水位是第二优先级,低水位必须立刻停加热棒,否则干烧是火灾级别的风险,这条保护逻辑必须在 STM32 里硬编码,不能依赖云端下发。TDS(溶解性总固体)用来判断换水时机,属于「看一眼就够了」的量,采样频率可以压到几分钟一次。光照和 pH 视养的品种决定要不要加,草缸配 pH,普通观赏鱼用 TDS 加光照基本够用。
执行侧对应四路:加热棒(继电器)、循环水泵(继电器,常开)、补光灯(继电器或 PWM 调光)、自动投喂(SG90 舵机)。如果按智能生态鱼缸管理系统设计来规划,还要预留一路备用继电器给 UV 杀菌灯或者二氧化碳电磁阀。这个清单决定了后面 GPIO 要分几组、哪几路必须光耦隔离。
2.2 GPIO 与定时器的分配原则
最常见的坑是把所有继电器直接挂在 GPIO 上。STM32 单个 IO 的灌电流/拉电流上限通常在 20mA 量级,而一路 5V 继电器模块的线圈驱动电流往往在 60~80mA,直接驱动要么带不动,要么长期工作把 IO 烧掉。稳妥做法是继电器模块用独立 5V 供电,GPIO 只走光耦输入端,串联 1kΩ 限流电阻,逻辑电平取反也在这里处理好。
下表的分配方案基于 STM32F103C8T6 这类资源,够用且留了余量。
| 功能 | 外设/引脚 | 模式 | 备注 |
|---|---|---|---|
| DS18B20 水温 | PC13 | 开漏输出/浮空输入切换 | 需 4.7kΩ 上拉到 3.3V |
| 水位(非接触式) | PA0 | ADC1_IN0 | 模拟量输出型传感器 |
| TDS 探头 | PA1 | ADC1_IN1 | 必须交流激励,见 3.2 |
| 加热棒继电器 | PB0 | 推挽输出 + 光耦 | 低电平有效 |
| 水泵继电器 | PB1 | 推挽输出 + 光耦 | 常开,掉电保持运行 |
| 补光灯 | PB5 | TIM3_CH2 PWM | 可做日出日落渐变 |
| 投喂舵机 | PA6 | TIM3_CH1 PWM | 50Hz,0.5~2.5ms |
| WiFi 模组 | PA9/PA10 | USART1 | 115200,8N1 |
| 调试串口 | PA2/PA3 | USART2 | 打印日志用 |
这张表里最容易忽略的是 USART2。调试串口看着多余,实际上它是排错时唯一能救命的东西——继电器一吸合就复位、DS18B20 偶尔读回 85℃,这些现象没有日志只能靠猜。给调试口留一路,用 USB-TTL 接电脑,比反复插拔烧录器省事得多。
2.3 GPIO 初始化里几个容易写错的地方
CubeMX 生成的初始化代码通常没问题,但手写的时候有两处高频错误。一是把继电器引脚设成开漏输出,结果上电后继电器一直半吸合、发热严重;二是忘了先把引脚电平写成「断开」状态再配置为输出,上电瞬间继电器「啪」地吸一下,加热棒在没水的情况下通电几百毫秒。
/* relay.c —— 上电先写安全电平,再配成推挽输出 */ void relay_init(void) { /* 1. 先把引脚拉到非有效电平,避免配置瞬间误动作 */ HAL_GPIO_WritePin(GPIOB, RELAY_HEAT_PIN | RELAY_PUMP_PIN, GPIO_PIN_SET); GPIO_InitTypeDef g = {0}; g.Pin = RELAY_HEAT_PIN | RELAY_PUMP_PIN | RELAY_LIGHT_PIN; g.Mode = GPIO_MODE_OUTPUT_PP; /* 推挽,不要开漏 */ g.Pull = GPIO_NOPULL; g.Speed = GPIO_SPEED_FREQ_LOW; /* 继电器不需要翻转速度 */ HAL_GPIO_Init(GPIOB, &g); HAL_GPIO_WritePin(GPIOB, RELAY_HEAT_PIN, GPIO_PIN_SET); /* 加热默认关 */ }参数上只有两个值得推敲:Speed设成 LOW 能明显降低 IO 翻转带来的电磁干扰,继电器这种秒级动作的外设完全没有理由用 HIGH;Pull保持 NOPULL,因为光耦输入端有自己的下拉,再开内部上下拉反而会拉偏逻辑电平。至于 Keil5 里写这套代码,记得通过 Pack Installer 装好 STM32F1 系列的 Device Family Pack,否则stm32f1xx_hal.h会报找不到定义——这类开发环境的问题在 stm32 项目里占掉的时间,往往比写业务代码还多。
3. STM32 端采集与控制的最小可用代码
3.1 单总线水温的时序容错
DS18B20 的时序对延时精度敏感,用HAL_Delay那种毫秒级函数是写不出来的。常见做法是用 DWT 周期计数器做微秒延时,它不受中断影响,比空循环for(i=0;i<n;i++)稳定得多。
/* bsp_ds18b20.c —— 微秒延时基于 DWT->CYCCNT */ static void ow_delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks) { } } /* 复位并检测存在脉冲:返回 0 表示器件在线 */ uint8_t ds_reset(void) { uint8_t ack; ow_set_output(); HAL_GPIO_WritePin(OW_PORT, OW_PIN, GPIO_PIN_RESET); ow_delay_us(480); /* 拉低 >480us */ ow_set_input(); /* 释放,靠 4.7k 上拉回高 */ ow_delay_us(70); /* 15~60us 内器件拉低应答 */ ack = HAL_GPIO_ReadPin(OW_PORT, OW_PIN); ow_delay_us(410); /* 补满整个 480us 时隙 */ return ack; }关键点在于释放总线后必须切回输入模式,让外部上拉电阻把电平拉高。如果一直保持推挽输出高电平,器件拉低应答时会出现短暂的电源对地短路,读回来的永远是 0。读回ack == 0才算成功,一旦连续三次失败就该把该路传感器标记为离线,而不是继续拿旧数据去控温。
读回值等于 85.0℃ 是另一个经典现象:这是 DS18B20 上电后的默认寄存器值,说明温度转换还没完成就被读走了。解决办法是发起转换后至少等 750ms(12 位分辨率),或者轮询总线直到读到非 85 的值再采信。
3.2 水位与 TDS 的 ADC 采样和滑动滤波
水位用非接触式传感器比电极式省心得多。电极式方案靠水的导电性检测,长期通直流会在电极上发生电解,几天就长满绿色藻类附着物,读数一路漂移。如果非要用电极,至少用两个 GPIO 交替极性驱动,让电极上平均电压为零。
ADC 采样本身不难,难的是让读数不跳。开启 ADC1 的连续转换加 DMA 搬运是最省 CPU 的方式,回调里再叠一层滑动均值滤波。
/* adc_filter.c —— 16 点滑动均值,等效降低约 2 位噪声 */ #define WIN 16 static uint16_t win[WIN]; static uint8_t idx; static uint32_t sum; uint16_t adc_filter(uint16_t raw) { sum -= win[idx]; /* 减去最老的一个 */ win[idx] = raw; /* 写入最新的 */ sum += raw; idx = (idx + 1) & (WIN - 1); /* WIN 必须是 2 的幂,用位与代替取模 */ return (uint16_t)(sum / WIN); }窗口长度取 16 是个折中:再长会拖慢对真实变化的响应,比如补水瞬间水位抬升,窗口太长会明显滞后;再短则对水泵启停引起的纹波压不住。采样周期上,水位 200ms 一次、TDS 几秒一次就够,TDS 的两个电极必须用交流方波激励,否则直流极化会让读数单调下降,最终稳定在一个永远不变的数字上——看起来像坏了,其实是电化学现象。
3.3 用定时器做无阻塞调度与投喂
stm32 定时器的价值在鱼缸项目里体现得很直接:把周期性任务挂在 1ms 节拍上,主循环永远不阻塞。用HAL_Delay串起来的代码在喂食时会卡住 800ms,这期间水温采样和超温保护全部停摆,属于典型的自找麻烦。
/* scheduler.c —— TIM4 配成 1kHz 中断,提供全局毫秒节拍 */ volatile uint32_t tick_ms; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM4) tick_ms++; } /* 宏式软调度:静态变量记录上次执行时间,天然支持多任务 */ #define TASK(fn, period) \ do { \ static uint32_t _last; \ if (tick_ms - _last >= (period)) { \ _last = tick_ms; \ fn(); \ } \ } while (0) void app_loop(void) { TASK(task_sample_temp, 1000); /* 水温 1s 一次 */ TASK(task_sample_level, 200); /* 水位要快,关系到干烧保护 */ TASK(task_heat_ctrl, 2000); /* 温控判定,2s 足够 */ TASK(task_feed_check, 10000); /* 10s 比对一次投喂日历 */ TASK(task_report_up, 5000); /* 5s 上行一帧 */ }温控的核心是回差,也就是滞环控制。设单点阈值会导致加热棒在临界温度上来回吸合,一天几百次,继电器触点很快烧黑。正确写法是两个阈值加一个状态标志。
/* heat_ctrl.c —— 回差控制,内置水位联动保护 */ void task_heat_ctrl(void) { float t = g_temp_c; if (g_level_low) { /* 最高优先级:低水位断电 */ relay_off(RELAY_HEAT); g_heat_on = 0; return; } if (isnan(t) || g_temp_offline) return; /* 探头失效时保持现状,不乱动 */ if (t < g_cfg.temp_low && !g_heat_on) { relay_on(RELAY_HEAT); g_heat_on = 1; } else if (t > g_cfg.temp_high && g_heat_on) { relay_off(RELAY_HEAT); g_heat_on = 0; } }回差建议取 1.5~2℃,比如目标 26℃,设temp_low = 25.0、temp_high = 27.0,小型鱼缸这个区间波动完全可接受,而继电器动作频率能降到每小时两三次以内。投喂舵机用 TIM3 的 PWM,50Hz 对应 20ms 周期,比较值 500 对应 0.5ms(挡板关闭)、1500 对应 1.5ms(转出饲料)。投喂是低频动作,用HAL_Delay阻塞几百毫秒可以接受,但要确认这期间不看门狗超时——如果开了 IWDG 且溢出时间设成 200ms,喂食动作会直接把板子复位,这个组合坑过不少人。
4. 从 ESP8266 到微信小程序的上下行协议
4.1 设备侧用 AT 指令接 MQTT 的最小流程
WiFi 模组常见做法是用 ESP8266 跑 AT 固件,串口接 USART1。需要确认固件版本在 v1.2.0 以上,否则不包含 MQTT 指令集,只能自己实现 TCP 之上的报文拼装。连接流程可以先用串口助手手动跑一遍,确认每一步的回显,再写进代码。
AT+CWMODE=1 # 单 Station 模式 AT+CWJAP="your_ssid","your_pass" # 连路由,回显 WIFI GOT IP 才算成功 AT+CIPMUX=0 # 单连接模式,MQTT 必须 AT+MQTTUSERCFG=0,1,"fish001","user","pass",0,0,"" AT+MQTTCONN=0,"broker.example.com",1883,1 # 1 表示 clean session # 上行一帧遥测数据,QoS 0,不 retain AT+MQTTPUB=0,"fish/001/telemetry",'{"t":25.4,"lv":82,"tds":310,"heat":1}',0,0每一条指令后面都要给足响应等待时间,AT+CWJAP在信号差的环境下可能要 8 秒以上,固定延时 2 秒就发下一条必然失败。稳妥写法是发指令后循环读取串口,匹配OK或者ERROR,超时 15 秒判失败并重试三次。另外,AT+MQTTUSERCFG里的用户名密码如果 broker 允许匿名,留空字符串即可,但很多云平台强制要求设备三元组,这部分要在配置文件里单独留出来,别硬编码进源文件。
4.2 主题与 JSON 报文设计
主题结构建议一次定死,后面改起来涉及小程序和服务端两处同步。设备 ID 放在主题层级里,比塞在 payload 里更方便做权限隔离。
| 主题 | 方向 | 载荷示例 | 说明 |
|---|---|---|---|
fish/{id}/telemetry | 设备 → 云端 | {"t":25.4,"lv":82,"tds":310,"heat":1} | 5s 一帧,字段名尽量短 |
fish/{id}/cmd | 云端 → 设备 | {"mode":"manual","heat":1} | 用户手动操作 |
fish/{id}/cfg | 云端 → 设备 | {"temp_low":25.0,"temp_high":27.0} | 阈值下发后写入 Flash |
fish/{id}/status | 设备 → 云端 | {"online":1,"rssi":-62} | 配合 LWT 遗嘱判断掉线 |
字段名用t/lv/tds这种短名不是偷懒。ESP8266 的 AT 固件对单条指令长度有上限,一帧报文超过 200 字节就可能被截断,而 5 秒一帧乘以一天 17280 帧,报文长度直接影响流量。至于遗嘱消息,在AT+MQTTCONN之前用AT+MQTTWILL设置,把fish/{id}/status的 payload 设成{"online":0},设备异常断电时 broker 会替你发布,小程序端就能立刻显示离线,而不是傻等超时。
4.3 小程序端的连接、单选框与导航栏处理
小程序侧有两条路。一条是设备数据先进自建服务端,小程序用wx.request轮询或走 WebSocket,好处是能加鉴权和历史数据;另一条是小程序直接连 MQTT broker,开发快但要把 broker 的 wss 端口暴露出来,且必须用去掉了 Buffer 依赖的 mqtt.js 构建版本,否则小程序编译阶段就报错。
// pages/tank/tank.js const mqtt = require('../../libs/mqtt.min.js'); // 小程序专用构建,不含 Buffer Page({ data: { temp: '--', level: '--', mode: 'auto', navH: 0 }, onLoad() { const info = wx.getWindowInfo(); // 自定义顶部导航栏高度 = 状态栏高度 + 44px 标题栏 this.setData({ navH: info.statusBarHeight + 44 }); this.client = mqtt.connect('wxs://broker.example.com:8084/mqtt', { clientId: 'wx_' + Date.now(), // 每次进页面用新 clientId,避免会话抢占 username: 'mini', password: this.data.token, clean: true, reconnectPeriod: 3000 }); this.client.on('connect', () => this.client.subscribe('fish/001/telemetry')); this.client.on('message', (topic, payload) => { const d = JSON.parse(payload.toString()); this.setData({ temp: d.t.toFixed(1), level: d.lv }); }); }, // 自动 / 手动模式切换,用单选框承载 onModeChange(e) { const mode = e.detail.value; this.setData({ mode }); this.client.publish('fish/001/cmd', JSON.stringify({ mode, ts: Date.now() })); }, onUnload() { this.client && this.client.end(true); // 页面销毁必须断开,否则后台持续重连 } });对应页面的单选框组,注意checked要绑定数据而不是写死,否则切换后回显不对:
<radio-group bindchange="onModeChange"> <label><radio value="auto" checked="{{mode === 'auto'}}" />自动</label> <label><radio value="manual" checked="{{mode === 'manual'}}" />手动</label> </radio-group>clientId每次进页面重新生成这一点值得强调。如果固定写死一个 ID,用户从后台切回小程序时新旧会话会互相顶掉,表现为数据时有时无、命令发不出去。onUnload里的end(true)同理,缺少这一句,用户退出页面后连接仍在重连,手机上耗电和流量都很明显。如果项目是用 uniapp 写的,用 HBuilderX 的「发行 → 小程序-微信」把编译产物输出到dist/dev/mp-weixin,再用微信开发者工具打开那个目录即可,AppID 需要在 manifest 里先填好。
5. 联调阶段的排错顺序与几个硬技巧
联调最容易乱的地方是同时改三端。合理的顺序是先单机、再联网、最后小程序:STM32 脱离 WiFi 单独跑够 24 小时,确认串口日志里温控动作和实际温度对得上;再接上模组,用串口助手看上行帧有没有按时发出;最后才打开小程序。跳过第一步的代价是,出问题时你根本不知道是采样错了、网络断了还是小程序解析错了。
按现象排查的对照表如下。
| 现象 | 高概率原因 | 处理方式 |
|---|---|---|
| 继电器吸合瞬间 MCU 复位 | 线圈反电动势干扰电源,共地走线过长 | 线圈并联续流二极管,继电器独立 5V 供电,光耦隔离 |
| 水温恒读 85.0℃ | 转换未完成就读取 | 转换后延时 750ms,或读到 85 时丢弃重采 |
| TDS 数值缓慢下降后不动 | 电极直流极化 | 改为交流方波激励,正负交替驱动 |
| MQTT 每几分钟断一次 | 心跳超时或路由踢连接 | keepalive 设 60s,AT+MQTTCONN加自动重连逻辑 |
| 小程序数据刷新但命令无效 | 订阅主题与发布主题写反 | 核对cmd是云到设备,telemetry是设备到云 |
| 投喂时间到了不动 | 舵机供电不足被拉低 | 舵机单独供电,PWM 信号地与 MCU 共地 |
两个不在表里但很实用的技巧。第一个是给 WiFi 连接加状态机而不是写成一串if:把「未初始化 / 已连路由 / 已连 broker / 断开重连中」四个状态显式建模,每个状态超时后只做一件事——回到上一个稳定状态。这样即使路由器重启,设备也能在几十秒内自愈,而不是卡在一个半连接状态里永远出不来。第二个是上行帧里塞一个递增的序号字段,服务端发现序号倒退就说明设备重启过,能立刻区分「设备死机重启」和「网络抖动丢包」,这两种情况的处理方式完全不同。
加热棒这类大功率负载还有个容易被忽视的细节:每次上电时先读一次水位和温度,确认水位正常、温度探头在线,再允许加热继电器动作,并且给一个 10 秒的启动延时,避开刚通电时传感器读数不稳的窗口。这条逻辑写进task_heat_ctrl之前的前置检查里,比事后加报警有用得多——鱼缸这种东西,一次深夜的干烧就够重新买一缸鱼了。
本文还有配套的精品资源,点击获取