基于STM32的温室环境监测闭环系统:传感器采集到执行控制的完整实现
2026/9/17 20:19:43 网站建设 项目流程

简介:面向嵌入式开发者和农业物联网研究者的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,最高72MHz,Flash 64KB,RAM 20KB,外设里正好有USART、I2C、SPI、ADC和多个定时器。温室环境控制不是高算力场景,20ms级别的采样周期配上几个阈值判断,F103的处理能力绰绰有余,而且它的ADC精度、定时器中断延迟和GPIO翻转速度都是单片机里经过大量项目验证的。

有人会问为何不直接上ESP32,Wi-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共地但不共电源。

引脚分配时留出两路串口,一路给日志调试,一路给ESP8266,I2C给OLED和BH1750,ADC给土壤湿度,普通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高电平表示0,70µs高电平表示1”的规则吐出40位数据。这套时序里最容易写歪的是拉低时间,HAL_Delay的最小单位是1ms,20ms勉强够用,但释放总线后的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 读BH1750:I2C一条命令和一个校准系数

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(); }

显示刷新的频率不要超过5Hz,OLED虽然快,但频繁全屏刷新会有残影,也会占掉一部分I2C带宽。更合理的做法是每秒刷一次,数据变化肉眼完全来得及看。snprintf里的格式字符串固定好宽度,避免数字位数变化导致整行乱跳。

5.2 远程上报:USART2串口透传ESP8266的数据帧

远程监控这套系统不走复杂的网络协议栈,STM32把数据拼成JSON字符串,通过USART2丢给ESP8266,ESP8266那边开透传模式,直接往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保留SWD

STM32F103的PB3、PB4、PA15默认复用为JTAG引脚,很多人把继电器接在PB3上发现怎么都拉不高,卡半天才反应过来是JTAG占着。系统里不需要JTAG,只用SWD下载调试就够了,启动时把JTAG关掉释放这三个引脚。

__HAL_AFIO_REMAP_SWJ_NOJTAG();

这条语句要放在GPIO初始化之前执行,调用后PB3、PB4、PA15被释放为普通GPIO,PA13和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典型频率是40kHz,64分频后约625Hz,3125个计数对应大约5秒。喂狗要放在主循环里所有传感器读取完成之后,而不是放在中断里。如果在定时器中断里喂,主循环卡死时中断还能跑,看门狗起不到兜底作用。正确的位置是整个主循环跑完一圈的最后一行。

6.3 上电前的烧机验证清单

系统联调后先别急着接220V负载,按下面的顺序过一遍:

验证项操作方法通过标准
电源纹波万用表AC档测3.3V输出纹波 < 50mV
传感器读数串口打印DHT22/BH1750/土壤值与实测仪器偏差在标称范围内
继电器动作临时改低阈值触发指示灯亮且无抖动
看门狗复位注释掉喂狗函数5秒后系统自动重启
断电恢复模拟断电再上电控制自动重启且阈值配置不丢

烧机跑48小时,记录掉线次数和传感器异常次数。有一个容易忽略的坑是继电器模块释放瞬间会在GND上产生尖峰,如果MCU偶尔重启,检查一下继电器模块的COM端和GND端之间有没有压差,最直接的解决方法是把继电器模块的GND单独走线回电源负极,不要在MCU板子上和数字地汇合再回去。这套系统做完以后,真正拉开差距的不是STM32代码写了多少行,而是传感器数据的可信度和执行机构在恶劣电网下扛不扛得住。

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

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

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

立即咨询