简介:这是一篇面向嵌入式系统、物联网与现代农业自动化学习者的期刊论文,完整呈现了基于STM32微控制器的智能灌溉系统设计过程。系统以水位传感器和土壤湿度传感器作为数据采集端,通过ESP8266 Wi-Fi模块将采集到的环境数据发送至远程终端串口触摸屏,并借助STM32F103ZET6与C8T6双主控结构完成通信转接与指令控制;执行机构由微型水泵、继电器和电磁阀组成,可实现蓄水监测、自动灌溉和阈值调节。论文还详细展开硬件选型、总体框架、串口屏人机交互设计以及多次测试中的阈值调整思路,对农业种植、园林绿化等场景有应用参考。包体共1个PDF文件,压缩包大小1.54MB,内容紧凑却覆盖了从系统整体框架到具体引脚配置、AT指令设置的完整链路。目前已有392人学习,既适合作为课程设计、毕业设计参考,也可作为智能农业项目从0到1搭建的实用参考文献。
1. 为什么智能灌溉系统绕不开STM32
土壤湿度从 20% 跳到 60%,靠人工拎水管是过去式了。智能灌溉系统的核心任务就三个:定时读土壤湿度、对比阈值、决定浇不浇水,外加把状态显示出来。这些活儿 STM32 一片全包——它既有足够的 ADC 通道接土壤湿度传感器,又有 PWM 定时器去驱动水泵或电磁阀,还能用低功耗模式在电池供电场景下撑过整个灌溉周期。相比 Arduino,STM32 的 12 位 ADC 分辨率更高,湿度变化曲线读得更细;相比树莓派,它没有操作系统依赖,上电就跑,适合 7x24 小时蹲在田里。
这套方案的典型硬件组合是 STM32F103C8T6 最小系统板、一个电容式土壤湿度传感器、一个 5V 继电器模块、一个小型直流水泵,再加一块 OLED 显示当前湿度和灌溉状态。软件上用标准外设库或 HAL 库都行,核心是三条线:ADC 采集、GPIO 控制、定时器做轮询。往下走,先把传感器信号调理这块讲透,因为大多数灌溉系统误动作都是坏在湿度值读不准上。
2. 土壤湿度采集与 ADC 采样:数值不准,后面全是白搭
2.1 电容式传感器为什么比电阻式更适合 STM32 方案
市面上常见两种土壤湿度传感器。电阻式(叉齿状)靠两电极间土壤导电率换算出湿度,价格便宜,但直流激励会导致电极极化,用久了铜箔腐蚀,读数漂移很厉害。电容式(比如常见的 NE555 方案或专用 IC 方案)测的是土壤介电常数,不直接接触土壤,寿命长得多,而且输出的是 0~3V 左右的模拟电压,正好落在 STM32 的 ADC 输入范围内。
从信号链角度看,电容式传感器的输出阻抗较高,STM32 的 ADC 是采样电容结构,建议在 ADC 引脚对地并联一个 0.1uF 陶瓷电容,既能滤掉高频噪声,又能提供足够的电荷注入。如果传感器输出的是 0~5V,必须用电阻分压(例如 10K 对 10K)把电压降到 3.3V 以内,否则会烧掉 STM32 的 PA0~PA7 引脚。
2.2 ADC 采样代码:单次转换还是连续转换
F103 的 ADC 支持单次、连续、扫描三种模式。灌溉系统不需要高速采样,土壤湿度变化很慢,1 秒采一次绰绰有余。下面给出标准外设库的单次采样配置,注意开启 ADC 时钟后要加延时让 ADC 稳定。
// adc_init: 初始化PA1为模拟输入,ADC1的通道1 void adc_init(void) { GPIO_InitTypeDef gpio; ADC_InitTypeDef adc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // ADC时钟 = 72MHz / 6 = 12MHz gpio.GPIO_Pin = GPIO_Pin_1; gpio.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入 GPIO_Init(GPIOA, &gpio); ADC_DeInit(ADC1); adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = DISABLE; // 单通道不需要扫描 adc.ADC_ContinuousConvMode = DISABLE; // 单次转换,省电 adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_55Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); } uint16_t adc_read_once(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); // 等转换完成 return ADC_GetConversionValue(ADC1); }采样时间这里选了 55.5 周期,比最短的 1.5 周期慢很多,换来的是更稳定的采样结果。对于变化缓慢的湿度信号,没必要追求高速采样,反而要抑制噪声。ADC 校准是必须做的:STM32 出厂时校准系数存在芯片内部,上电后调用ADC_StartCalibration才能保证转换精度。如果你发现读到的值明显偏大或偏小,先查这一步有没有执行。
2.3 多点采样 + 中值滤波:把单次跳变磨平
单次采样具备偶然性:传感器碰到小石子、根系或空气隙,读数会瞬间跳变。我常用的做法是连续采 5 次,去掉最大值和最小值,取中间 3 个的平均。下面这段滤波代码放在main的轮询循环里,每 100ms 采一次,500ms 得到一个平滑值。
uint16_t adc_read_filtered(void) { uint16_t buf[5]; uint16_t tmp; int i, j; for (i = 0; i < 5; i++) { buf[i] = adc_read_once(); delay_ms(100); // 两次采样间隔100ms,避开传感器自激振荡 } // 冒泡排序,取中间3个的平均 for (i = 0; i < 4; i++) { for (j = 0; j < 4 - i; j++) { if (buf[j] > buf[j+1]) { tmp = buf[j]; buf[j] = buf[j+1]; buf[j+1] = tmp; } } } return (buf[1] + buf[2] + buf[3]) / 3; }排序的开销在这个场景下完全可以忽略——5 个元素最多 10 次比较,对 72MHz 的 STM32 来说就是几微秒的事。注意两次采样间的延时不能太短,某些电容式传感器内部有振荡电路,连续快速读取会读到振荡建立的中间态,导致数值周期性跳动。
2.4 标定:把 ADC 原始值映射成“干”和“湿”两个端点
ADC 原始值是 0~4095,但这个值本身没有物理意义。你要做的是记两个标定点:把传感器完全插在空气中(或者干燥土壤里),记下读数为dry_val;再把传感器泡在水里(或湿透的土壤里),记下读数为wet_val。然后按下面公式换算成湿度百分比:
int humidity_percent(uint16_t adc_val) { // dry_val > wet_val,电容式传感器越湿电压越低 if (adc_val >= dry_val) return 0; if (adc_val <= wet_val) return 100; return (int)((dry_val - adc_val) * 100 / (dry_val - wet_val)); }这里有个容易搞反的点:电容式传感器湿度越大输出电压越低。因为土壤水分增加会让介电常数变大,传感器内部振荡频率变化,输出占空比或电压向低处走。所以代码里dry_val一定大于wet_val,线性映射时先用dry_val - adc_val取差。标定时建议多测几次,每次把传感器插拔重放,取稳定后的值记录。
3. 水泵控制:GPIO、继电器与定时器怎么配合
3.1 用 GPIO 控制继电器的正确接法
STM32 的 GPIO 灌电流和拉电流能力有限(F103 最多约 25mA),直接驱动继电器线圈不现实,而且继电器感性负载关断时会产生反向电动势。标准做法是 GPIO → NPN 三极管(如 S8050)→ 继电器线圈,线圈两端并联续流二极管(1N4007,负极接 VCC,正极接三极管集电极)。
// io_init: PB0 推挽输出,接三极管基极(串1K电阻) void io_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); gpio.GPIO_Pin = GPIO_Pin_0; gpio.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出 gpio.GPIO_Speed = GPIO_Speed_2MHz; // 继电器开关频率低,2MHz足够 GPIO_Init(GPIOB, &gpio); GPIO_WriteBit(GPIOB, GPIO_Pin_0, Bit_SET); // 初始化为高电平,也可以根据系统状态决定 } void pump_on(void) { GPIO_WriteBit(GPIOB, GPIO_Pin_0, Bit_SET); // 高电平导通三极管,继电器吸合 } void pump_off(void) { GPIO_WriteBit(GPIOB, GPIO_Pin_0, Bit_RESET); }三极管基极必须串一个 1K~4.7K 的限流电阻,否则基极电流过大,会把 STM32 的 GPIO 拉垮。GPIO_Speed设为 2MHz 就够了——50MHz 的翻转速度在这里没有任何意义,反而会引入更高的 EMI。上电瞬间继电器处于什么状态要想清楚:如果系统启动时土壤很干,是否要立即浇水?通常做法是pump_off(),等人确认传感器读数正常后再进入自动模式。
3.2 定时器做非阻塞轮询,别用 delay 硬等
如果主循环里用delay_ms等传感器采样、等水泵动作,整个系统就阻塞了,你要同时驱动 OLED 显示和按键扫描就非常别扭。F103 的 TIM2 是 32 位定时器,用 72MHz 时钟源分频后可以获得较长的溢出周期。以下使用 TIM2 产生 1 秒中断,在中断里置标志位,主循环读取标志位后执行灌溉状态机。
volatile uint32_t tick_1s = 0; // timer_init: TIM2 1秒中断,72MHz/7200-1 => 10kHz, 10000-1 => 1Hz void timer_init(void) { TIM_TimeBaseInitTypeDef tim; NVIC_InitTypeDef nvic; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); tim.TIM_Prescaler = 7200 - 1; // 72MHz / 7200 = 10kHz tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_Period = 10000 - 1; // 10kHz / 10000 = 1Hz tim.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, &tim); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); nvic.NVIC_IRQChannel = TIM2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); } void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); tick_1s++; } }主循环的结构变成:每次tick_1s变化时,读取一次滤波后的湿度值,更新 OLED,再根据状态机决定水泵开关。这样 OLED 刷新、按键扫描和传感器采集天然错开,整个系统反应流畅。另外要注意,中断服务函数里只做计时和置标志,不要在中断里跑adc_read_filtered()——它的排序和延时逻辑会拖垮中断响应。
3.3 水泵启停的边界参数:死区、保护时间和手动急停
灌溉控制不能写成“湿度低于阈值就开泵,高于就关”。土壤湿度传感器存在响应滞后——刚浇完水,水还没渗到传感器位置,读数仍然偏低,水泵就会持续工作,造成过度灌溉。我习惯在状态机里加三个参数:
| 参数 | 典型值 | 作用 |
|---|---|---|
HUMIDITY_THRESHOLD_LOW | 30% | 低于此值启动灌溉 |
HUMIDITY_THRESHOLD_HIGH | 55% | 高于此值停止灌溉 |
PUMP_MAX_RUN_TIME | 300 秒 | 单次灌溉最长时间,防止管道泄漏或传感器漂移导致永不停泵 |
4. 灌溉状态机:把无限循环写成看得懂的流程
4.1 四个状态:待机、灌溉、延时、故障
把灌溉逻辑写成状态机,比写一串if-else清晰得多,而且扩展方便——之后想加雨量传感器或者手机远程控制,只需增加状态和迁移条件。这里的状态划分是我认为最适合小型灌溉系统的:
STATE_IDLE:待机状态。湿度正常或偏高,水泵关闭。STATE_IRRIGATE:灌溉状态。湿度偏低,水泵开启。STATE_COOLDOWN:延时状态。刚停泵,等待水分渗透并让传感器读数恢复稳定。STATE_FAULT:故障状态。连续多次读数为空或超过最大灌溉时间。
状态机的核心是迁移条件。当系统处于STATE_IRRIGATE时,如果湿度已经达到HUMIDITY_THRESHOLD_HIGH,或者计时超过PUMP_MAX_RUN_TIME,都需要迁出去——前者迁到STATE_COOLDOWN,后者要迁到STATE_FAULT并报警。
// 状态机定义与迁移逻辑 typedef enum { STATE_IDLE, STATE_IRRIGATE, STATE_COOLDOWN, STATE_FAULT } irrig_state_t; irrig_state_t g_state = STATE_IDLE; uint32_t g_state_start_tick = 0; void irrig_state_machine(int hum, uint32_t now_tick) { switch (g_state) { case STATE_IDLE: if (hum < HUMIDITY_THRESHOLD_LOW) { g_state = STATE_IRRIGATE; g_state_start_tick = now_tick; pump_on(); } break; case STATE_IRRIGATE: if (hum >= HUMIDITY_THRESHOLD_HIGH) { g_state = STATE_COOLDOWN; g_state_start_tick = now_tick; pump_off(); } else if (now_tick - g_state_start_tick > PUMP_MAX_RUN_TIME) { g_state = STATE_FAULT; pump_off(); // 安全第一,先停泵 } break; case STATE_COOLDOWN: if (now_tick - g_state_start_tick >= COOLDOWN_SECONDS) { g_state = STATE_IDLE; } break; case STATE_FAULT: // 需要人工按键确认才能复位,或者检测到湿度恢复正常 if (hum > HUMIDITY_THRESHOLD_LOW) { g_state = STATE_IDLE; } break; } }4.2 故障状态怎么退出:自动复位还是手动确认
STATE_FAULT的退出策略值得单独说。如果是因为传感器读数长时间不变化(比如一直 4095)而进入的故障,那说明传感器线松了或者模块烧了,机器自动复位只会反复进入故障,还不如保持故障状态等人来修。但如果是因为管道堵塞导致超时,并且湿度值已经恢复正常,自动复位回STATE_IDLE是合理的。我在代码里用了一个折中:先自动复位,但如果连续 3 次在 10 分钟内又进入故障,则锁定STATE_FAULT,必须按按键才解锁。
4.3 OLED 显示状态:要能一眼看出发生了什么
调试和现场运行时,OLED 上至少显示四个信息:当前湿度百分比、系统状态(四个状态枚举之一)、水泵开关状态、已经运行的秒数。这样你站在设备前不用接串口线就知道系统在干什么。SSD1306 的驱动代码可以直接用 I2C 模式,SCL 接 PB6,SDA 接 PB7,地址一般是 0x3C:
// oled_show_status: 每1秒刷新一次 void oled_show_status(int hum, irrig_state_t state, int pump_on) { char line[16]; sprintf(line, "Hum: %d %%", hum); oled_show_string(0, 0, line); switch (state) { case STATE_IDLE: oled_show_string(0, 2, "State: IDLE"); break; case STATE_IRRIGATE: oled_show_string(0, 2, "State: WATER"); break; case STATE_COOLDOWN: oled_show_string(0, 2, "State: WAIT"); break; case STATE_FAULT: oled_show_string(0, 2, "State: FAULT"); break; } sprintf(line, "Pump: %s", pump_on ? "ON " : "OFF"); oled_show_string(0, 4, line); }建议 OLED 刷新频率不要超过 1Hz。SSD1306 是慢速 I2C 设备,刷新太高会占用主循环时间。另外,OLED 和传感器采样共用 I2C/SPI 总线时,注意不要让传感器采集的延时阻塞 OLED 刷新。
5. 低功耗与现场调试技巧:让系统真正能在田里跑起来
5.1 STM32 低功耗模式怎么选:睡眠 vs 停机
如果是电池供电的灌溉系统,主循环空转 72MHz 是很大的浪费。F103 有睡眠、停机和待机三种模式。睡眠模式只关 CPU 时钟,外设继续工作,唤醒后几乎无延迟,但功耗降低有限。停机模式关闭所有时钟,只有外部中断能唤醒,唤醒后需要重新配置系统时钟。
灌溉系统的特点是:湿度变化极慢,不需要连续工作。我可以让系统每 10 分钟醒来一次,采集湿度、决定是否浇水,然后继续睡。用停机模式配合 RTC 闹钟唤醒是功耗最优解。但要注意:停机模式下 ADC 和 I2C 都是关闭的,唤醒后要重新初始化这些外设。
5.2 串口打印:手边没有 OLED 时的调试利器
现场调参数时,OLED 刷新和 I2C 时序都会干扰你排查问题。我建议在初始化阶段用串口做一次完整自检,把 ADC 原始值、换算后的湿度、继电器 GPIO 状态都打印出来。USART1 的代码如下:
// uart_init: PA9(TX) PA10(RX), 115200 8N1 void uart_init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = 115200; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_TX | USART_Mode_RX; USART_Init(USART1, &usart); USART_Cmd(USART1, ENABLE); } void debug_print(const char *s) { while (*s) { USART_SendData(USART1, (uint8_t)(*s++)); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } }调试时打印一行RAW=%d HUM=%d%% PUMP=%d,就能快速定位问题在传感器端还是执行端。量产时用宏把debug_print编译掉,不影响运行效率。
5.3 一键自检和三参数手动校准
最后分享一个我常用的验收技巧:把系统改成上电后按住按键超过 3 秒进入自检模式。自检流程是:开泵 1 秒、关泵,检查是否有水流声音;读一次 ADC 并打印原始值;OLED 显示SELF TEST PASS/FAIL。这在铺设灌溉管道和更换传感器后特别省事。三个可调参数(低阈值、高阈值、最大运行时间)分别用三个按键循环修改,OLED 实时显示当前值和加减结果,长按保存到 Flash 的最后一页。这样不用重新烧录固件就能适配不同土质和不同水压的场地。
本文还有配套的精品资源,点击获取