
做农业物联网项目最怕的不是传感器读不出来而是做出来的东西“看着像课程设计用着像玩具”。我这次搭的智慧大棚系统没有图省事搞单芯片方案而是老老实实用了STM32F103配ESP32-S3两块开发板本地人机界面跑LVGL传感器采集和继电器控制全部交给STM32WiFi上云和屏幕渲染交给ESP32-S3。整套系统搭下来能本地实时监控、能自动灌溉、能远程开关设备现场跑了半个多月基本稳定。这篇文章把这套系统的架构设计、硬件选型、串口协议、LVGL界面、自动控制逻辑和上云调试的完整过程都写下来适合正在做单片机课程设计、物联网竞赛或者想认真把LVGL用于实战项目的中级开发者。你会看到的不只是“能跑通的代码”还有每一处选型背后的经验和取舍。1. 为什么智慧大棚要用双MCU架构而不是一颗芯片硬扛先说结论如果只是做一个“传感器数据能显示在屏幕上”的演示品一颗ESP32-S3确实绰绰有余。但一旦加入完整的大棚控制逻辑、可靠的人机界面、云端远程管理单芯片方案的资源冲突会非常明显。1.1 单MCU方案的三个硬伤第一是LVGL界面渲染和业务逻辑互相抢CPU时间。LVGL做动画、图表刷新、触摸响应时需要比较密集的CPU周期这时候如果同一个芯片还要轮询DHT11、刷新ADC、控制继电器很容易出现“屏幕刷着刷着卡一下传感器数据又跟不上”的体验。ESP32-S3虽然主频高但WiFi协议栈本身也会抢占CPU周期尤其在信号弱、重传频繁的时候实时控制逻辑会被明显拖慢。第二是电源和时序的相互干扰。DHT11这种单总线传感器对微秒级时序很敏感而WiFi发射瞬间的电流波动会拉低供电电压导致时序偏移、读数失败。把采集和控制放在独立的STM32F103上之后这个问题彻底消失了因为ESP32那边再折腾跟传感器采集是物理隔离的。第三是调试和迭代的粒度。双MCU方案里我改UI逻辑只动ESP32-S3改控制策略只动STM32F103两边互不牵连。项目出现异常时先看串口通信就知道是哪一端出了问题不用在一团乱麻的代码里找线索。1.2 双芯片的任务划分我最终确认的分工是这样的芯片扮演角色负责内容STM32F103下位机/执行端温湿度采集、土壤湿度与光照ADC采样、继电器控制、本地自动控制策略、传感器滤波处理ESP32-S3上位机/交互端LVGL界面渲染、触摸交互、WiFi连接、MQTT云端通信、UI告警提示两芯片之间通信链路串口TTL直连115200bps自定义帧协议这个分工的核心思路是所有跟“物理世界打交道”的活归STM32所有跟“人和网络打交道”的活归ESP32-S3。接口上STM32这端只需要留出几个GPIO和一路USARTESP32-S3那边也只需要一路USART两边互不依赖对方的外设资源后续换传感器、换屏幕都不影响对方。1.3 系统能做什么整套系统实现下来功能清单是这样的本地UI实时显示空气温湿度、土壤湿度、光照强度并用曲线展示温湿度变化趋势UI端一键切换“自动控制/手动控制”模式手动模式下可以点按钮开关水泵、风扇、补光灯自动模式下STM32根据阈值自行判断灌溉、通风、补光时机不依赖网络和上位机ESP32-S3每10秒向云平台上报一次传感器数据订阅云端指令实现远程查看和远程控制传感器数据异常、继电器故障切换时有简单告警提示这套功能覆盖了一个真实大棚远程监控系统的基本闭环后续加摄像头、加设备联动都是在这个骨架上扩展。2. 硬件搭建与传感器选型的一些门道硬件层面我没有选用特别冷门的器件全部是市面上容易买、教程多、替换方便的型号。这一节把每一部分选型的原因和接线要点讲清楚避免你照着做的时候卡在硬件细节上。2.1 STM32F103端的外设分配STM32F103我用的是一块带排针引出的开发板主控型号为普遍见到的C8T6或RCT6都可以区别只在Flash和RAM容量。我给传感器和控制执行器分配的引脚如下外设引脚说明DHT11温湿度传感器PA0单总线数据需要4.7k上拉电阻土壤湿度传感器电阻式PA1ADC采样输出模拟电压光敏电阻模块或BH1750PA2ADC采样或选用IIC接口继电器模块4路PB0~PB3低电平触发依次接水泵、风扇、补光灯、电热丝串口1PA9/PA10与ESP32-S3的UART0对接TX/RX交叉特别注意土壤湿度传感器如果用的是电阻式两根金属探针那种不要让探针长时间通电否则容易电解腐蚀。我是在ADC采样前才给传感器供电采完就关掉能明显延长探头寿命。光照部分如果追求精度建议用BH1750数字光强传感器IIC接口如果只是做阈值控制光敏电阻模块配ADC完全够用。2.2 ESP32-S3端的屏幕与触摸ESP32-S3的屏幕驱动是整个界面体验的关键。我选用的是4.3寸SPI接口屏驱动芯片为常见型号分辨率800x480触摸部分用XPT2046或FT6236电容屏。SPI屏的接线比RGB屏简单很多SCLK、MOSI、CS、DC、RST、BL一共6根信号线加电源和地。ESP32-S3的SPI外设性能足够支撑这种分辨率的LVGL刷新但要注意一点如果使用Arduino框架驱动尽量选择硬件SPI而不是软件模拟SPI否则刷图帧率会明显下降。我在Arduino环境下使用TFT_eSPI库只改了User_Setup.h里的引脚定义跑LVGL v8刷新率能稳定在30帧以上。2.3 电源设计是最容易被忽略的坑这套系统里有两个“耗电大户”继电器驱动的水泵/风扇和屏幕背光。强烈建议用双电源方案主板STM32 ESP32-S3 传感器用5V/2A的USB电源经过AMS1117稳压到3.3V继电器模块和执行器单独用12V/3A电源供电两块电源的GND必须共地否则串口通信会不稳定我一开始图省事用一个12V电源同时给主板和执行器供电结果每次水泵继电器吸合的瞬间屏幕就会闪一下偶尔还触发ESP32-S3重启。原因就是继电器吸合的浪涌电流把12V电压拉低稳压后的3.3V也跟着波动。分开供电之后问题再也没出现过。3. 串口通信协议双MCU之间的“通用语言”两块芯片之间的通信是整个系统最容易出错、也最关键的一环。STM32把传感器数据打包发出去ESP32收下来解析并刷新UI反过来ESP32收到UI点击或云端指令后也要通过串口告诉STM32“现在手动打开水泵”。这些依赖一套稳定的帧协议。3.1 帧格式设计协议设计成简单的“帧头长度数据域累加和校验”结构帧头1 帧头2 长度 数据域(变长) 校验和 0xA5 0x5A N N字节 1字节校验和的计算方式从帧头1开始到数据域最后一个字节所有字节累加后取低8位。也就是说接收方算完校验和后与最后一个字节比较一致说明帧有效。这种校验方式虽然不如CRC16强但对传感器数据和控制指令这种场景足够用代码实现也简单。数据域内部我采用固定顺序不搞JSON因为STM32处理JSON太浪费资源。上传数据帧定义如下序号内容类型说明0温度int16实际温度乘以10避免小数计算1湿度uint16实际湿度乘以102土壤uint16土壤湿度ADC原始值0~40953光照uint16光照ADC原始值0~40954继电器状态uint8按位表示4路继电器状态5工作模式uint80自动、1手动长度字段固定为11所以也可以做成定长帧。我保留了长度字段是为了后续扩展浮点型传感器或追加CO2浓度等数据时不改协议整体结构。3.2 STM32端的打包与发送STM32端在定时器中断里每500ms采集一次数据并打包发送。发送频率没有必要太高因为大棚环境变化慢UI曲线刷新、云端上报都够用。频率太高反而会导致ESP32端的串口接收压力大LVGL界面也刷不过来。typedef struct { int16_t temp; // 温度*10 uint16_t humi; // 湿度*10 uint16_t soil; // 土壤ADC值 uint16_t light; // 光照ADC值 uint8_t relay; // 继电器状态位 uint8_t mode; // 工作模式 } SensorPack; void packAndSend(SensorPack *pack) { uint8_t txBuf[20]; txBuf[0] 0xA5; txBuf[1] 0x5A; txBuf[2] 11; txBuf[3] (pack-temp 8) 0xFF; txBuf[4] pack-temp 0xFF; txBuf[5] (pack-humi 8) 0xFF; txBuf[6] pack-humi 0xFF; txBuf[7] (pack-soil 8) 0xFF; txBuf[8] pack-soil 0xFF; txBuf[9] (pack-light 8) 0xFF; txBuf[10] pack-light 0xFF; txBuf[11] pack-relay; txBuf[12] pack-mode; uint8_t sum 0; for (int i 0; i 13; i) { sum txBuf[i]; } txBuf[13] sum; HAL_UART_Transmit(huart1, txBuf, 14, 100); }需要注意的是DHT11的读取函数里不要被中断打扰否则时序会偏。我建议在读取DHT11之前关中断读完再开或者把DHT11的读写放在定时器中断里用Blocking方式完成。这属于STM32端的经验细节后面调试章节会细说。3.3 ESP32端的接收与解析ESP32端接收串口数据核心思路是一个字节一个字节地读维护一个接收缓冲区和一个状态机。每一帧数据可能被拆成多段到达也可能多帧粘在一起到达所以必须做“粘包/半包”处理。static uint8_t rxBuf[128]; static uint8_t rxIndex 0; void processSerialByte(uint8_t ch) { rxBuf[rxIndex] ch; // 长度不足继续等 if (rxIndex 3) return; // 检查帧头如果不是0xA5 0x5A开头把前面一个字节丢弃 if (rxBuf[0] ! 0xA5 || rxBuf[1] ! 0x5A) { memmove(rxBuf, rxBuf 1, rxIndex - 1); rxIndex--; return; } uint8_t len rxBuf[2]; if (len 3 sizeof(rxBuf)) { rxIndex 0; // 异常帧直接丢弃 return; } // 帧尚未收齐 if (rxIndex len 3) return; // 到达这里说明完整帧已收齐校验 uint8_t sum 0; for (int i 0; i len 2; i) { sum rxBuf[i]; } if (sum rxBuf[len 2]) { parseSensorFrame(rxBuf[3], len); } rxIndex 0; }这个处理方式的关键在于每次只要帧头不对就丢弃最老的一个字节继续等这样即使遇到垃圾数据也能自动恢复同步。串口接收可以用中断每收到一个字节就调用一次processSerialByteESP32-S3的CPU完全扛得住115200波特率。3.4 控制指令的下行通道上行是传感器数据帧下行就是控制指令帧。我定义得非常简单一个字节指令码加一个字节参数。例如指令码含义参数0x01水泵控制0关闭 1开启 2切换0x02风扇控制0关闭 1开启 2切换0x03补光灯控制0关闭 1开启 2切换0x04设置模式0自动 1手动0x05设置自动阈值参数位按顺序打包ESP32端收到UI点击或云端指令后把指令码和参数打包成同样的帧结构发给STM32。STM32收到后先解析指令码在中断回调里把指令存到一个全局结构体主循环再处理避免在中断里直接操作继电器状态。4. LVGL界面设计从“能显示”到“好操控”LVGL的界面设计是这个项目最出彩的部分。很多人以为LVGL只是“画控件”实际上把一套大棚监控界面做得直观、顺手、不卡需要认真规划布局和刷新策略。4.1 页面布局规划我把整个界面分为四个区域从上到下依次是顶部栏系统名称、WiFi连接状态、当前工作模式自动/手动数据卡片区温度、湿度、土壤湿度、光照强度每个参数一个卡片大字体显示数值配合一个状态图标趋势曲线区温湿度历史曲线用LVGL的chart控件横轴显示时间刻度底部控制区四个控制按钮水泵、风扇、补光灯、遮阳帘一个自动/手动切换开关一个“云端/本地”状态灯界面主色调用深色背景加高亮数据避免在日光直射环境下看不清屏幕。LVGL自带的主题可以直接用但我建议在主题基础上微调主色用一个绿色系跟大棚农业的语境匹配数据卡片用半透明背景增加层次感。static void ui_init(void) { lv_obj_t *scr lv_scr_act(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x101418), LV_STATE_DEFAULT); // 顶部标题 lv_obj_t *title lv_label_create(scr); lv_label_set_text(title, 智慧大棚监控系统); lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), LV_STATE_DEFAULT); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 5); // 状态标签 label_wifi lv_label_create(scr); lv_label_set_text(label_wifi, WiFi: 已连接); lv_obj_align(label_wifi, LV_ALIGN_TOP_RIGHT, -10, 5); // 模式标签 label_mode lv_label_create(scr); lv_label_set_text(label_mode, 模式: 自动); lv_obj_align(label_mode, LV_ALIGN_TOP_LEFT, 10, 5); }布局上我用了绝对定位lv_obj_align因为屏幕分辨率固定控件的相对位置可以一次性算好。如果你希望适配多种分辨率则应该用Flex或Grid布局但那样代码量会明显增加对于固定屏幕的嵌入式项目没必要。4.2 数据更新策略数据刷新的核心原则不要动不动就销毁重建控件也不要每个控件都单独定时刷新。正确做法是创建一个lv_timer每500ms触发一次在回调里统一更新所有数据label和chart曲线。static lv_timer_t *data_timer NULL; static void data_timer_cb(lv_timer_t *timer) { lv_label_set_text_fmt(label_temp, %d.%d ℃, temp_val / 10, temp_val % 10); lv_label_set_text_fmt(label_humi, %d.%d %%RH, humi_val / 10, humi_val % 10); lv_label_set_text_fmt(label_soil, %d, soil_val); lv_label_set_text_fmt(label_light, %d, light_val); // 曲线追加新数据点固定只保留最近N个点 lv_chart_set_next_value(chart_temp, temp_val); lv_chart_set_next_value(chart_humi, humi_val); lv_chart_refresh(chart_temp); lv_chart_refresh(chart_humi); } void ui_start_data_refresh(void) { data_timer lv_timer_create(data_timer_cb, 500, NULL); }如果界面里的数值变化非常快500ms刷新一版就够了。正常情况下LVGL渲染这个界面的CPU占用率不到30%不会影响触摸响应和动画。4.3 中文字体问题LVGL最大的坑之一LVGL默认只带ASCII字体中文一个都没有。如果你直接显示“温度”“湿度”这样的中文字符串屏幕上只会出现一堆方块。解决办法是使用LVGL的在线字体转换工具把需要的中文汉字生成一个自定义字体文件。我踩过的坑第一次图省事把整个全字库字体文件转换出来结果生成的C数组接近10MBESP32-S3内部Flash根本放不下。后来改用“只转换用到的字符”策略界面里出现的汉字大约40多个转出来的字库只有几十KB完美塞进Flash。工具生成字库后需要把生成的C文件放到工程里并在LVGL初始化之前配置LV_FONT_DECLARE(lv_font_source_han_32); static lv_theme_t *theme; void ui_theme_init(void) { theme lv_theme_default_init( lv_disp_get_default(), lv_palette_main(LV_PALETTE_GREEN), lv_palette_main(LV_PALETTE_BLUE), true, lv_font_source_han_32 ); lv_obj_set_theme(lv_scr_act(), theme); }还有一个隐蔽的坑LVGL对中文编码要求是UTF-8。如果在Arduino环境里写代码默认源文件可能是GB2312编码字符串一中文就乱。解决方法是统一使用UTF-8编码保存源文件或者在字符串前面加u8温度前缀显式使用UTF-8。4.4 触摸交互与指令下发控制按钮的交互逻辑很简单注册一个回调函数按钮按下后在回调里构造下行指令帧通过串口发给STM32。static void btn_pump_event_cb(lv_event_t *e) { lv_event_code_t code lv_event_get_code(e); if (code LV_EVENT_CLICKED) { // 构造并发送指令参数为2表示切换 sendCmdToSTM32(0x01, 2); lv_label_set_text(label_pump_state, 切换指令已发送); } } void ui_register_buttons(void) { lv_obj_add_event_cb(btn_pump, btn_pump_event_cb, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(btn_fan, btn_fan_event_cb, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(btn_light, btn_light_event_cb, LV_EVENT_CLICKED, NULL); }手动模式下的控制反馈不能只看按钮本身应该以STM32返回的继电器实际状态为准。所以UI在收到下行的继电器状态位发生变化后再去更新按钮显示状态我通常会用颜色高亮已打开的继电器用户一眼就能看出哪些设备在运行。5. 自动控制策略真正体现“智慧大棚”的核心UI再好看自动控制逻辑不行也撑不起“智慧”两个字。STM32F103端跑的是一套完整的阈值控制状态机不依赖任何上位机和网络。即使ESP32-S3断网或者屏幕挂了大棚的基本环境控制依然能独立运行。5.1 阈值判断与回差控制直接用一个固定阈值控制继电器有个问题设备会在阈值附近反复启停。例如土壤湿度低于30%开水泵抽到35%就关结果土壤湿度会一直在30%~35%之间来回抖动继电器频繁吸合寿命大减。解决办法是加入回差滞回区间。我给每个参数设置一个“开启阈值”和“关闭阈值”两之间保持合理间隔控制对象开启条件关闭条件水泵灌溉土壤湿度 30%土壤湿度 60%通风风扇温度 30℃温度 26℃补光灯光照 150且时间为白天时段光照 400电热丝温度 10℃温度 15℃这个回差设计对农业场景非常重要不仅保护继电器也让环境参数更平稳。实际部署时阈值也是可以远程修改的属于扩展功能。void auto_control_task(void) { if (mode ! MODE_AUTO) return; // 土壤湿度控制 if (soil_val AUTO_SOIL_THRESHOLD_OPEN) { relay_ctrl.pump ON; } else if (soil_val AUTO_SOIL_THRESHOLD_CLOSE) { relay_ctrl.pump OFF; } // 温度控制风扇 if (temp_val AUTO_TEMP_FAN_OPEN) { relay_ctrl.fan ON; } else if (temp_val AUTO_TEMP_FAN_CLOSE) { relay_ctrl.fan OFF; } // 温度控制电热丝 if (temp_val AUTO_TEMP_HEAT_OPEN) { relay_ctrl.heat ON; } else if (temp_val AUTO_TEMP_HEAT_CLOSE) { relay_ctrl.heat OFF; } // 补光灯 if (light_val AUTO_LIGHT_OPEN) { relay_ctrl.light ON; } else if (light_val AUTO_LIGHT_CLOSE) { relay_ctrl.light OFF; } }5.2 手动模式与自动模式的无缝切换模式切换必须保证安全。如果当前是自动模式水泵正在运转这时候用户通过UI切到手动模式不能立刻把水泵关掉而应该保持当前继电器的开合状态让用户手动决定下一步操作。这样不会出现“一切换模式设备突然全断电”的意外。所以模式切换指令里我加了一个“保留当前状态”的标记只有用户主动点击对应按钮改变继电器状态时才真正改变设备。另外从手动切回自动时自动控制函数会立即执行一次状态刷新把设备切换到当前环境需要的状态。5.3 传感器滤波与故障识别DHT11的读数偶尔会跳变土壤传感器也可能因为探头接触不良读到0或4095。我在STM32端做了三层处理连续性采样每次采集连续读5次去掉最大值和最小值取剩余3次的平均值超范围剔除温度超出-20℃到60℃范围的数直接丢弃土壤和光照ADC值如果连续50次都保持4095判定传感器掉线故障上报检测到传感器异常时状态位里附加一个故障标志通过串口帧传给ESP32UI提示“传感器异常”这样处理之后UI上显示的曲线非常平滑不会出现突然冒尖的伪数据。5.4 掉电与重启的状态恢复大棚现场如果意外断电恢复供电后系统最好能恢复断电前的运行状态。实现方法很简单STM32内置Flash的一个扇区保存当前阈值配置和继电器的PWM周期、工作模式每次修改配置时擦写一次上电初始化时读取。注意不要频繁擦写Flash否则寿命消耗很快。我的策略是“只有在配置变化时才写入”正常开关设备不写Flash设备是否开关则是感知传感器状态后自然恢复。6. 云端接入从本地监控走向远程管理一个叫“物联网”的系统不上云说不过去。ESP32-S3的WiFi能力正是为了干这件事。云端这里我选用了与设备端解耦的MQTT方案因为MQTT非常灵活后续不管换云平台还是换手机端App协议都不需要大改。6.1 MQTT通信的基本设计整个通信链路是ESP32-S3作为MQTT客户端连接到一个公共或者自建的MQTT Broker发布设备状态、订阅控制指令。主题命名我建议带设备唯一ID方便多个大棚设备区分上报主题greenhouse/{device_id}/status下发主题greenhouse/{device_id}/cmd回调主题greenhouse/{device_id}/result执行结果反馈payload使用JSON格式方便云端和手机端解析。示例{t:25.3,h:68.2,soil:2100,light:680,pump:1,fan:0,mode:0,ts:1715300000}6.2 ESP32-S3端的WiFi和MQTT连接ESP32-S3开发有一个容易踩的坑WiFi刚连接后就立刻启动MQTT连接大概率失败。原因是初次连接后底层网络栈还需要一点时间完成IP分配和路由表更新。我在实现里加了连接状态检测确保WiFi处于“已获取IP”的状态才尝试MQTT连接。void network_task(void) { if (!wifi_connected) { if (WiFi.status() ! WL_CONNECTED) { WiFi.begin(ssid, password); vTaskDelay(2000 / portTICK_PERIOD_MS); } return; } if (mqtt_client NULL || !mqtt_client-connected()) { mqtt_client-connect(greenhouse_device_001); if (mqtt_client-connected()) { mqtt_client-subscribe(greenhouse/{id}/cmd, 1); } } }MQTT断线重连也要做退避策略不要断线后就疯狂重试否则公共Broker可能短暂封禁你。我用的是一个简单指数退避第一次重试等1秒然后2秒、4秒、8秒最大不超过60秒。6.3 远程控制与本地控制的优先级云端下发的指令最终也是走串口协议到达STM32。这里有一个设计细节当系统处于自动模式时云端下发的手动控制指令是否应该执行我的选择是自动模式下云端可以调整阈值参数但不可以直接开关设备手动模式下云端才可以直接控制继电器。这样可以避免用户在云端误触一个按钮导致自动控制被打乱。云端下发阈值的指令帧同样走串口STM32收到后先校验参数范围比如土壤湿度阈值必须在10%~90%之间校验通过再写入Flash。这个策略在调试阶段帮我避免了好几次“参数写错导致水泵狂转”的尴尬。7. 实机调试代码能跑只是开始真正的坑都在后面这部分我挑几个排练中最折磨人的问题每个都附上完整的排查链路希望你能少走弯路。7.1 STM32串口发出来的是乱码现象ESP32端串口收到的数据经常出现“山∥、”之类的乱码或者一帧数据里面穿插着错误字符。排查过程先检查波特率确认两边都是115200且8N1没问题。然后用示波器看STM32的TX引脚波形发现数据帧之间的间隔不稳定波形的毛刺也比较多。最后定位到是共地问题STM32开发板和ESP32开发板各自用自己的USB供电两个USB口来自不同电脑或插座GND电位不一样导致串口电平参考点不一致。解决把两块开发板的GND用一根杜邦线连起来乱码彻底消失。这个坑非常常见很多新手在接线时只连TX和RX连GND都给漏了。7.2 LVGL界面刷新卡顿触摸反应迟钝现象界面能显示但滑动手感很粘曲线刷新一卡一卡的。排查过程第一步看LVGL堆内存分配。ESP32-S3虽然RAM大但LVGL默认分配的buffer只有几百KB在800x480分辨率下很容易不够。我在lv_conf.h里把LV_MEM_SIZE调到1MB并开启了PSRAM支持流畅度立刻提升。第二步检查刷新频率LVGL要求定期调用lv_timer_handler()如果在Arduino的主循环里被delay占用太久界面必然卡。我把传感器读取、MQTT轮询等都放到了独立任务里只在空闲时调用lv_timer_handler()。void loop() { lv_timer_handler(); delay(5); network_task(); }7.3 继电器吸合时屏幕闪烁甚至系统重启现象每次水泵继电器吸合的瞬间ESP32-S3屏幕亮度轻微闪一下偶尔重启。排查过程用万用表测量12V母线上的电压吸合瞬间电压跌到9V以下。虽然主板是5V供电但12V电源的跌落通过GND和共地路径反馈到了主板。上面说过这个问题的根治办法是主板和执行器分开供电并且继电器模块尽量用带光耦隔离的版本。如果条件限制必须共用电源至少要在继电器线圈两端并联一个续流二极管并且把继电器的控制线从主板引脚引到继电器模块时尽量短。7.4 中文字体显示为方块现象label显示的内容全是方框英文数字正常。排查过程刚开始以为是字体没加载后来发现字体加载了但只加载了ASCII部分中文部分没勾选。重新用字体转换工具生成字库文件时确认把所有需要的汉字都加入了字符表并勾选了“Include ranges”里的CJK统一表意文字段。第二个坑是源文件编码问题在Arduino IDE里默认保存可能是GBK导致字符串的字节流跟字库里的UTF-8索引对不上。解决方法是使用带编码切换的编辑器统一保存为UTF-8。7.5 云端数据与本地UI显示的数值不一致现象手机端看MQTT上报的温度跟UI显示的温度不一样。排查过程其实不是数值不一样而是时间差。UI是500ms刷新云端上报是10秒一次而且上报的是当时最近一次UI更新的缓存值。有时候用户看手机时刚好夹在两个上报周期之间就会感觉不一致。更关键的一点是串口上传的数据是没滤波的原始值还是滤波后的值我在协议里统一约定STM32发送的是滤波之后的值UI和云端展示的是同一份数据不存在两套口径。把上报周期改成5秒后观感好很多服务器压力也不大。8. 源码结构解析与项目扩展方向如果看完整篇还是觉得不过瘾或者你想直接基于这套框架改造成自己的项目我再把代码工程的结构和扩展方向交代一下。8.1 工程目录结构整个项目分两个固件工程我习惯这样组织smart-greenhouse/ ├── firmware_stm32/ │ ├── Core/ │ │ ├── Inc/ │ │ └── Src/ │ ├── Drivers/ │ │ ├── CMSIS/ │ │ └── STM32F1xx_HAL_Driver/ │ └── app/ │ ├── sensor_dht11.c │ ├── sensor_soil_light.c │ ├── relay_driver.c │ ├── auto_control.c │ ├── protocol_stm32.c │ └── config_store.c └── firmware_esp32/ ├── main/ │ ├── main.c │ ├── ui/ │ │ ├── ui_dashboard.c │ │ ├── ui_dashboard.h │ │ └── fonts/ │ ├── comm/ │ │ ├── serial_protocol.c │ │ └── serial_protocol.h │ └── network/ │ ├── wifi_mgr.c │ ├── mqtt_mgr.c │ └── mqtt_mgr.h └── components/ ├── lvgl/ └── arduino_tft_espi/STM32端用STM32CubeMX生成基础工程app目录下放应用层代码ESP32端用Arduino框架的ESP32开发环境LVGL仓库直接以组件形式管理。这样分层的结构非常清晰底层驱动、协议层、业务层各管各的后续把自动控制逻辑改成模糊控制或者PID都不会影响UI和串口部分。8.2 移植到其他开发板的思路如果你想移植到别的板子只要保证外设资源匹配就行STM32端至少需要一路串口、三个ADC通道、五个GPIO控制继电器ESP32端至少需要一路SPI驱动屏幕、一路串口、WiFi天线如果换用ESP32-C3这种内部资源偏少的芯片LVGL跑800x480会比较吃力建议降分辨率到480x320或切换到更轻的UI方案移植时需要重新配置的只是引脚和驱动文件头部的引脚映射应用层逻辑基本不用动。这也是当初把协议层和驱动层分开写的最大好处。8.3 后续可以扩展的方向这套系统已经是一个完整物联网项目的骨架再往外面扩展的空间很大摄像头监控ESP32-S3本身支持摄像头接口可以加OV2640或OV5640模组把图像传到云端做视频预览多节点联动多个大棚节点通过ESP-NOW或者WiFi Mesh组网数据汇总到一个主控上传感器扩展加CO2浓度传感器、土壤温度传感器、雨滴传感器丰富环境感知维度数据本地备份在STM32端挂一个MicroSD卡或EEPROM记录小时级的历史数据断网也能事后追溯手机App配套基于MQTT协议做一个小程序端远程查看和控制跟现有云端接口无缝对接用LVGL v9版本重写界面字形渲染引擎升级控件更多动画效果更流畅我在实际部署中最大的感受是基础框架稳定了扩展功能就是“按需填空”。只要串口协议设计得足够可靠后面无论加什么传感器、什么外设都是在原有骨架上长肌肉而不是推翻重来。再分享一个具体的小建议串口协议里最好把设备版本号也捎带上调试时能快速识别工程量上是否烧错固件。前段时间我就在现场吃过亏STM32烧了老版本固件新加的一个传感器数据死活不出来排查半天最后发现是固件里根本没有对应采集代码。版本号进协议这种问题看一眼状态帧就能定位。