基于STM32的智能灌溉系统:从ADC采样到状态机实现
简介这是一篇面向嵌入式系统、物联网与现代农业自动化学习者的期刊论文完整呈现了基于STM32微控制器的智能灌溉系统设计过程。系统以水位传感器和土壤湿度传感器作为数据采集端通过ESP8266 Wi-Fi模块将采集到的环境数据发送至远程终端串口触摸屏并借助STM32F103ZET6与C8T6双主控结构完成通信转接与指令控制执行机构由微型水泵、继电器和电磁阀组成可实现蓄水监测、自动灌溉和阈值调节。论文还详细展开硬件选型、总体框架、串口屏人机交互设计以及多次测试中的阈值调整思路对农业种植、园林绿化等场景有应用参考。包体共1个PDF文件压缩包大小1.54MB内容紧凑却覆盖了从系统整体框架到具体引脚配置、AT指令设置的完整链路。目前已有392人学习既适合作为课程设计、毕业设计参考也可作为智能农业项目从0到1搭建的实用参考文献。1. 为什么智能灌溉系统绕不开STM32土壤湿度从 20% 跳到 60%靠人工拎水管是过去式了。智能灌溉系统的核心任务就三个定时读土壤湿度、对比阈值、决定浇不浇水外加把状态显示出来。这些活儿 STM32 一片全包——它既有足够的 ADC 通道接土壤湿度传感器又有 PWM 定时器去驱动水泵或电磁阀还能用低功耗模式在电池供电场景下撑过整个灌溉周期。相比 ArduinoSTM32 的 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[j1]) { tmp buf[j]; buf[j] buf[j1]; buf[j1] 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_LOW30%低于此值启动灌溉HUMIDITY_THRESHOLD_HIGH55%高于此值停止灌溉PUMP_MAX_RUN_TIME300 秒单次灌溉最长时间防止管道泄漏或传感器漂移导致永不停泵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 接 PB6SDA 接 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 的最后一页。这样不用重新烧录固件就能适配不同土质和不同水压的场地。本文还有配套的精品资源点击获取