基于STM32的温室环境监测闭环系统:传感器采集到执行控制的完整实现

发布时间:2026/9/17 20:19:43
基于STM32的温室环境监测闭环系统:传感器采集到执行控制的完整实现 简介面向嵌入式开发者和农业物联网研究者的STM32温室环境监测与控制系统方案紧密围绕温度、湿度、光照强度、CO2浓度等关键参数的实时采集与自动调控展开呈现了从农业智能化需求分析、国内外现状对比到ZigBee/NB-IoT通信技术选型、嵌入式软硬件设计的完整闭环。资源为1个PDF格式硕士学位论文压缩包大小3.07MB内容覆盖系统功能分析与总体架构、ZigBee网络协议及星型/树形/网状拓扑、NB-IoT技术特点及与LoRa的横向比较、嵌入式系统与μCOS操作系统介绍以及基于STM32的采集节点硬件电路和单片机选型设计等模块。已有1962人学习下载特别适合正在做温室监控类毕业设计、嵌入式课程设计或农业物联网产品预研的读者。通过这份资料可以快速掌握温室环境监测项目的设计思路、低功耗无线通信方案的取舍依据以及传感器节点硬件电路的实际搭建方法具有较强的工程参考与复用价值。1. 基于STM32的温室环境监测和控制系统从传感器到执行机构的完整闭环同样是种一棚草莓老把式靠看天新农人靠看数据。基于STM32的温室环境监测和控制系统就是把温湿度、光照、土壤湿度这些环境参数变成可量化的采样值再由MCU根据预设阈值自动打开风机、水泵和补光灯。整个闭环都在本地完成不依赖云平台一块Cortex-M3加几路传感器就能跑通。系统每隔几秒读一次DHT22和BH1750土壤湿度走ADC采样继电器直接接在GPIO上控制220V设备。对刚入门的人它覆盖GPIO、定时器、中断、串口和I2C五类外设对做农业物联网的工程师它提供了节点端完整的采集入口和执行出口。下文按硬件选型、数据采集、控制策略、远程监控、调试排错展开代码以STM32F103系列HAL库为基准标准库用户改掉底层接口即可复用。2. 一块STM32F103C8T6怎么带起整套温室闭环2.1 主控选型为什么F103比ESP32更稳这类项目最常见的主控是STM32F103C8T6也叫“蓝 pills”那颗芯片。它的内核是Cortex-M3最高72MHzFlash 64KBRAM 20KB外设里正好有USART、I2C、SPI、ADC和多个定时器。温室环境控制不是高算力场景20ms级别的采样周期配上几个阈值判断F103的处理能力绰绰有余而且它的ADC精度、定时器中断延迟和GPIO翻转速度都是单片机里经过大量项目验证的。有人会问为何不直接上ESP32Wi-Fi都省了。理由是温室里最怕的不是缺网而是断电和MCU假死。ESP32的启动时间、Wi-Fi协议栈占用的中断优先级在恶劣供电环境下会比裸机F103多出很多不确定因素。F103的定位就是把“采集-判断-输出”这条硬实时链路固定下来网络上报只是它串口上的一路外围设备。另一个隐形优势是好买、便宜、参考代码多Keil和IAR的老工程直接能迁移遇到问题搜“STM32”几乎都能找到对应案例。2.2 传感链路DHT22、BH1750和土壤探头的选择口径温湿度传感器在五六十块钱的预算内有三个可选DHT11、DHT22和SHT30。DHT11精度太粗湿度±5%RH做温室控湿会频繁误动作不建议。DHT22湿度±2%RH温度±0.5°C性价比高缺点是单总线协议需要自己抠时序且采样间隔至少2秒。SHT30走I2C精度更高还有CRC校验适合对数据质量较真的场景。考虑到教程覆盖面这套系统默认用DHT22它能把单总线时序这个知识点完整练一遍换SHT30时只需要替换驱动函数。光照传感器选BH1750是行业默认做法I2C接口、量程0~65535 lx、内部带16位ADC无需校零给补光灯控制足够用。土壤湿度的选择有个老坑电阻式探头用久了铜极会电腐蚀读数越漂越离谱最好用电容式或频域反射式探头输出0~3.3V模拟信号直接接ADC引脚。如果预算紧张非要选电阻式至少降低供电电压并减少通电时间别让探头一直带电。2.3 执行链路继电器、水泵、风机的接线与引脚分配执行侧最常见的是带光耦隔离的继电器模块输入端接STM32的GPIO和GND输出端COM接220V火线NO接风机或水泵的火线。模块上已经焊好了续流二极管和LED指示用起来比裸继电器省心但要注意模块的JD-VCC跳线需要外部供电时把跳线帽拔掉否则继电器线圈电流会倒灌进MCU的3.3V严重时直接把芯片打死。我一般习惯把继电器模块单独用5V供电和MCU共地但不共电源。引脚分配时留出两路串口一路给日志调试一路给ESP8266I2C给OLED和BH1750ADC给土壤湿度普通GPIO给DHT22和继电器。下面这段引脚映射头文件是每次新工程都要改的第一处焊板前先在这里对一遍#define DHT_GPIO_Port GPIOA #define DHT_Pin GPIO_PIN_1 #define BH1750_I2C hi2c1 #define SOIL_ADC hadc1 #define SOIL_CHANNEL ADC_CHANNEL_0 #define FAN_GPIO_Port GPIOB #define FAN_Pin GPIO_PIN_0 #define PUMP_GPIO_Port GPIOB #define PUMP_Pin GPIO_PIN_1 #define LIGHT_GPIO_Port GPIOB #define LIGHT_Pin GPIO_PIN_2所有控制输出全部集中在一个PORTB的低三位好处是现场排障时可以直接量PB0、PB1、PB2的电平三个继电器模块的输入指示灯亮灭一目了然。这个习惯能省下不少用万用表对线的功夫尤其是系统接上220V后谁也不想带电乱捅。3. 数据采集STM32怎么从传感器读出能用的真实值3.1 读DHT22单总线时序里的20ms低电平起点DHT22的单总线通讯是从主机的起始信号开始的主机先把总线拉低至少18ms再释放总线20~40µs然后从机回一个80µs的低电平和80µs的高电平之后按“50µs低电平26~28µs高电平表示070µs高电平表示1”的规则吐出40位数据。这套时序里最容易写歪的是拉低时间HAL_Delay的最小单位是1ms20ms勉强够用但释放总线后的20~40µs必须用微秒级延时或直接操作SysTick。uint8_t DHT22_Read(float *temp, float *humi) { uint8_t data[5] {0}; HAL_GPIO_WritePin(DHT_GPIO_Port, DHT_Pin, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(DHT_GPIO_Port, DHT_Pin, GPIO_PIN_SET); delay_us(30); GPIO_InitTypeDef gpio {0}; gpio.Pin DHT_Pin; gpio.Mode GPIO_MODE_INPUT; gpio.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT_GPIO_Port, gpio); while (HAL_GPIO_ReadPin(DHT_GPIO_Port, DHT_Pin) GPIO_PIN_SET); while (HAL_GPIO_ReadPin(DHT_GPIO_Port, DHT_Pin) GPIO_PIN_RESET); while (HAL_GPIO_ReadPin(DHT_GPIO_Port, DHT_Pin) GPIO_PIN_SET); for (int i 0; i 40; i) { while (HAL_GPIO_ReadPin(DHT_GPIO_Port, DHT_Pin) GPIO_PIN_RESET); delay_us(40); if (HAL_GPIO_ReadPin(DHT_GPIO_Port, DHT_Pin) GPIO_PIN_SET) { data[i / 8] (data[i / 8] 1) | 1; while (HAL_GPIO_ReadPin(DHT_GPIO_Port, DHT_Pin) GPIO_PIN_SET); } else { data[i / 8] (data[i / 8] 1) | 0; } } GPIO_InitTypeDef out {0}; out.Pin DHT_Pin; out.Mode GPIO_MODE_OUTPUT_PP; out.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DHT_GPIO_Port, out); if ((data[0] data[1] data[2] data[3]) ! data[4]) return 1; *humi ((data[0] 8) | data[1]) / 10.0f; *temp (((data[2] 0x7F) 8) | data[3]) / 10.0f; if (data[2] 0x80) *temp -*temp; return 0; }这段代码最后把GPIO模式从输入改回推挽输出否则下一轮起始信号拉不低总线。校验和放在最后一位前四个字节求和再与第五字节比对出错时直接丢弃本次读数温室控制宁可用上一帧数据也不要喂垃圾值给执行逻辑。数据解析时要注意DHT22的高位是符号位零下温度时第3字节的最高位置1。3.2 读BH1750I2C一条命令和一个校准系数BH1750比DHT22友好得多上电后发一条0x10命令让它进入连续高分辨率模式等180ms测量完成后连续读两个字节组合出来的16位数值除以1.2就是光照强度。uint8_t BH1750_Read(uint16_t *lux) { uint8_t cmd 0x10; if (HAL_I2C_Master_Transmit(hi2c1, 0x46, cmd, 1, 100) ! HAL_OK) return 1; HAL_Delay(180); uint8_t buf[2]; if (HAL_I2C_Master_Receive(hi2c1, 0x46, buf, 2, 100) ! HAL_OK) return 1; *lux (buf[0] 8) | buf[1]; *lux (uint16_t)((*lux) / 1.2f); return 0; }注意0x46这个地址已经包含了读写位它是BH1750默认地址0x23左移一位的结果。HAL库的I2C地址参数要求的是8位地址很多刚从51转过来的人会在这里卡住把0x23直接传进去结果总线上一直没有人应答。连续高分辨率模式下芯片自己会持续采样不需要每次读取前重新发命令只是第一次上电时等待时间要留足。3.3 读土壤湿度ADC加滑动平均滤掉瞬时噪声土壤湿度探头输出的模拟电压本身波动不大但浇水瞬间水滴溅到探头上会让读数瞬间跳变。如果控制逻辑直接拿这个跳变值去开电磁阀就会出现“刚浇完水又浇一次”的抖动。常见做法是加一个8点或16点滑动平均算出来的值再参与阈值判断。#define SOIL_FILTER_N 8 uint16_t Soil_Read_Filtered(void) { static uint16_t buf[SOIL_FILTER_N] {0}; static uint8_t idx 0; HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { buf[idx] HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); idx (idx 1) % SOIL_FILTER_N; uint32_t sum 0; for (uint8_t i 0; i SOIL_FILTER_N; i) { sum buf[i]; } return (uint16_t)(sum / SOIL_FILTER_N); }滤波窗口长度不是越大越好浇水动作通常在几百毫秒内完成窗口太大反而会把“确实需要停止浇水”的真实信号也抹平。8点窗口在10Hz采样率下对应0.8秒的响应时间足够滤掉水滴溅射又不会耽误关阀。ADC分辨率用12位即可STM32F103的ADC输入阻抗不高探头输出端最好串一个1kΩ电阻再进引脚并加100nF电容到地这样读数会很稳。4. 控制策略从阈值表到增量式PID的温室调温4.1 先定阈值温度上下限、湿度、补光条件写控制代码前先把作物的环境需求翻译成一张阈值表这是整套系统里唯一需要种田经验的地方。以普通叶菜棚为例白天温度控制在22~28°C超过28°C开风机低于22°C关风机空气湿度低于50%开加湿器高于70%关土壤湿度低于35%开水泵高于55%关光照低于3000 lx且是白天时开补光灯。操作参数一览控制对象使能条件停止条件采样周期风机温度 28°C温度 24°C5s加湿器湿度 50%湿度 65%5s水泵土壤湿度 35%土壤湿度 55%10s补光灯光照 3000 lx光照 5000 lx30s注意风机停止条件写成24°C而不是28°C这是滞回区间。没有滞回的话温度在28°C附近抖动时继电器会啪啪啪来回吸合继电器寿命和电网都会遭殃。补光灯的滞回区间更大因为BH1750在阳光穿过云层时变化极快频繁开关灯对LED驱动电源冲击很大。4.2 继电器驱动带滞回和最小间隔的GPIO控制每个继电器的控制代码结构都一样但要加两个保护一是上面说的滞回二是两次状态切换之间的最小时间间隔。用HAL_GetTick()做毫秒级时间戳任何一路继电器刚切换完3秒内不允许再切同一路。void Relay_Control(GPIO_TypeDef *port, uint16_t pin, uint8_t *state, uint32_t *last_change, uint8_t on, uint32_t now) { if ((*state) on) return; if ((now - (*last_change)) 3000) return; HAL_GPIO_WritePin(port, pin, on ? GPIO_PIN_SET : GPIO_PIN_RESET); *state on; *last_change now; } void Env_Control(float temp, float humi, uint16_t soil) { static uint8_t fan 0, pump 0; static uint32_t fan_last 0, pump_last 0; uint32_t now HAL_GetTick(); Relay_Control(FAN_GPIO_Port, FAN_Pin, fan, fan_last, temp 28.0f, now); Relay_Control(FAN_GPIO_Port, FAN_Pin, fan, fan_last, temp 24.0f, now); Relay_Control(PUMP_GPIO_Port, PUMP_Pin, pump, pump_last, soil 35, now); Relay_Control(PUMP_GPIO_Port, PUMP_Pin, pump, pump_last, soil 55, now); }这段代码把“开风机”和“关风机”当成两个独立的控制动作由滞回阈值天然隔开。最小间隔3秒的参数不是拍脑袋定的电磁继电器从吸合到触点稳定大约需要10ms留3000ms是为了避免逻辑抖动和电网浪涌叠加。如果换成了固态继电器这个间隔可以缩到500ms。4.3 温控升级增量式PID在风机调速上的落地阈值控制只能开关风机温度超过28°C就全速转降到23°C就停夏天中午会看到风机频繁启停。想让风机跟着温差线性调速就得把继电器换成可控硅调压模块或PWM调速风机控制逻辑换成PID。typedef struct { float kp, ki, kd; float integral, last_err; } Pid_t; float Pid_Incremental(Pid_t *pid, float err, float dt) { float d_err err - pid-last_err; pid-integral err * dt; float out pid-kp * err pid-ki * pid-integral pid-kd * (d_err / dt); pid-last_err err; if (out 0.0f) out 0.0f; if (out 100.0f) out 100.0f; return out; }温室这种大惯性对象纯P控制就会有小幅稳态误差加一点I项能消掉D项作用不大因为温度变化率本身就很慢。串口调试PID时把设定温度、实际温度、输出占空比三个值周期打印出来观察曲线调kp和ki先kp给10稳态误差回不来再加ki这是最稳定的调参路径。增量式PID比位置式更适合这里它输出的是增量误动作影响小而且不需要做复杂的积分限幅初始化。5. OLED显示、ESP8266上报与RTC计时的三种人机出口5.1 本地看板SSD1306驱动OLED显示实时环境温室现场需要一块屏直接看到当前环境。SSD1306控制的0.96寸OLED是标准选择I2C接口只要两根线网上现成的驱动库一大把把库移植过来后实际使用只需要一个刷新函数。void Display_Env(float temp, float humi, uint16_t light, uint16_t soil) { char line[24]; ssd1306_Clear(); snprintf(line, sizeof(line), T:%5.1fC H:%4.0f%%, temp, humi); ssd1306_SetCursor(0, 0); ssd1306_String(line); snprintf(line, sizeof(line), L:%4dlx S:%3d%%, light, soil); ssd1306_SetCursor(0, 16); ssd1306_String(line); ssd1306_UpdateScreen(); }显示刷新的频率不要超过5HzOLED虽然快但频繁全屏刷新会有残影也会占掉一部分I2C带宽。更合理的做法是每秒刷一次数据变化肉眼完全来得及看。snprintf里的格式字符串固定好宽度避免数字位数变化导致整行乱跳。5.2 远程上报USART2串口透传ESP8266的数据帧远程监控这套系统不走复杂的网络协议栈STM32把数据拼成JSON字符串通过USART2丢给ESP8266ESP8266那边开透传模式直接往MQTT服务器发。STM32侧只需要维护一个发送缓冲区。uint8_t report_buf[128]; void Env_Report(float temp, float humi, uint16_t light, uint16_t soil) { int len snprintf((char *)report_buf, sizeof(report_buf), {\t\:%.1f,\h\:%.1f,\l\:%u,\s\:%u}\r\n, temp, humi, light, soil); HAL_UART_Transmit(huart2, report_buf, len, 200); }ESP8266不参与任何控制判断只做串口到Wi-Fi的桥。这样断电重启后STM32可以在没有任何网络配置的情况下先把温室控制跑起来ESP8266慢了或者连不上网都不影响本地闭环。数据帧末尾的CRLF是给串口对端做分包用的ESP8266侧用官方AT固件时先发ATO1进入透传之后所有串口数据自动走TCP链路无需再拼AT指令。5.3 时间和落盘RTC时间戳和关键事件记录温室告警日志需要时间戳不然事后回溯“昨晚两点温度报警”根本对不上号。STM32F103的RTC可以用外部32.768kHz晶振也可以用内部LSI。内部RC振荡器温漂大热词里常搜到“STM32内部32kHz做RTC”我的结论是只要不是对时间精度有硬性要求内部LSI加软件校准也能忍但断电时间长了必须重新对时。void Get_Time_String(char *buf, size_t len) { RTC_DateTypeDef d; RTC_TimeTypeDef t; HAL_RTC_GetDate(hrtc, d, RTC_FORMAT_BIN); HAL_RTC_GetTime(hrtc, t, RTC_FORMAT_BIN); snprintf(buf, len, %04d-%02d-%02d %02d:%02d:%02d, 2000 d.Year, d.Month, d.Date, t.Hours, t.Minutes, t.Seconds); }读RTC时先读日期再读时间HAL库手册里明确写了要先调用HAL_RTC_GetDate再调用HAL_RTC_GetTime否则时间可能出现边界跳变。事件记录写到片内Flash时不要把整页擦除用尾部追加方式记录条数够了再统一擦一页这样能显著延长Flash寿命。6. 排错与稳定运行JTAG、看门狗和烧机测试6.1 释放PB3/PB4关闭JTAG保留SWDSTM32F103的PB3、PB4、PA15默认复用为JTAG引脚很多人把继电器接在PB3上发现怎么都拉不高卡半天才反应过来是JTAG占着。系统里不需要JTAG只用SWD下载调试就够了启动时把JTAG关掉释放这三个引脚。__HAL_AFIO_REMAP_SWJ_NOJTAG();这条语句要放在GPIO初始化之前执行调用后PB3、PB4、PA15被释放为普通GPIOPA13和PA14继续作为SWD的SWDIO和SWCLK。千万别用SWJ_DISABLE把SWD也关了否则下次烧录只能拉BOOT0进ISP模式很麻烦。6.2 主循环喂狗IWDG复位时间怎么整温室系统一旦跑起来就是7×24小时随便一个死循环都会让风机或水泵卡在错误状态。独立看门狗用芯片内部的LSI时钟跑飞了能强制复位。void IWDG_Init(void) { IWDG_HandleTypeDef hiwdg {0}; hiwdg.Instance IWDG; hiwdg.Init.Prescaler IWDG_PRESCALER_64; hiwdg.Init.Reload 3125; HAL_IWDG_Init(hiwdg); }LSI典型频率是40kHz64分频后约625Hz3125个计数对应大约5秒。喂狗要放在主循环里所有传感器读取完成之后而不是放在中断里。如果在定时器中断里喂主循环卡死时中断还能跑看门狗起不到兜底作用。正确的位置是整个主循环跑完一圈的最后一行。6.3 上电前的烧机验证清单系统联调后先别急着接220V负载按下面的顺序过一遍验证项操作方法通过标准电源纹波万用表AC档测3.3V输出纹波 50mV传感器读数串口打印DHT22/BH1750/土壤值与实测仪器偏差在标称范围内继电器动作临时改低阈值触发指示灯亮且无抖动看门狗复位注释掉喂狗函数5秒后系统自动重启断电恢复模拟断电再上电控制自动重启且阈值配置不丢烧机跑48小时记录掉线次数和传感器异常次数。有一个容易忽略的坑是继电器模块释放瞬间会在GND上产生尖峰如果MCU偶尔重启检查一下继电器模块的COM端和GND端之间有没有压差最直接的解决方法是把继电器模块的GND单独走线回电源负极不要在MCU板子上和数字地汇合再回去。这套系统做完以后真正拉开差距的不是STM32代码写了多少行而是传感器数据的可信度和执行机构在恶劣电网下扛不扛得住。本文还有配套的精品资源点击获取