尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32实战:RS485+Modbus RTU读取土壤NPK/pH传感器并在OLED显示

STM32实战:RS485+Modbus RTU读取土壤NPK/pH传感器并在OLED显示 手里正好在做一套大棚土壤监测的小项目核心需求就是把土壤的氮、磷、钾和pH值实时读回来再显示到一个OLED小屏幕上。查了一圈资料发现很多人都在用STM32配合RS485接口的土壤传感器做这件事但零散资料多能一步到位讲清楚协议、接线、代码和坑的很少。我把自己完整跑通的这套流程整理出来从Modbus RTU协议到HAL库驱动OLED全部走一遍带完整代码思路想抄作业的直接跟着做就行。这个方案适合正在做智慧农业、温室大棚、无土栽培相关项目的朋友也适合拿来做STM32毕业设计。整个链路看着复杂其实拆开就三块传感器通过RS485总线把数据送过来STM32用串口接收并解析最后用I2C把结果丢给OLED显示。三块各自独立联调的时候也方便定位问题。1. 项目整体设计与方案选型1.1 为什么放弃4-20mA模拟量选RS485走Modbus协议市面上土壤氮磷钾传感器有两种主流输出一种是4-20mA模拟量一种是RS485数字量。我用过一段时间4-20mA的发现它有个很尴尬的地方一个传感器就要占用一路模拟输入如果还想多挂几个测不同点位ADC通道根本不够用而且模拟量传输距离一长就开始漂现场排查起来血压容易上来。RS485方案完全不一样它本质上是一条差分总线两根线A和B就能挂载最多32个设备。每个传感器设置不同的从机地址主控轮流点名采集全程走数字信号不会出现模拟量那种电压衰减问题。布线也清爽很多——现场一条双绞线串过去传感器并联挂在上面就行。再者农业数据是要进数据库的数字信号比模拟量在后期换算、校准和追溯上都方便得多。Modbus RTU是工业领域最通用的协议几乎所有的工业级土壤传感器都原生支持这就意味着以后换传感器品牌只要协议一致代码改动量极小。1.2 核心器件选型清单我这次用的是下面这套组合都是很常见、资料齐全的物料照着买不会出问题。器件推荐型号/规格说明主控STM32F103C8T6最小系统板64KB Flash、20KB RAM跑这个小项目绰绰有余资料最多土壤传感器RS485接口氮磷钾pH四合一变送器默认Modbus RTU协议从机地址1波特率9600或4800RS485转TTL模块MAX485或SP3485模块和STM32的电平转换关键器件几块钱一个显示屏0.96寸I2C接口OLEDSSD1306控制器4个引脚接线方便显示清晰电源12V/2A直流电源适配器传感器供电用STM32和OLED用3.3V稳压后供电线材双绞屏蔽线RS485传输线抗干扰用1.3 系统架构与数据流整个系统的数据流向很清晰STM32通过USART1向MAX485模块发送Modbus查询帧MAX485把TTL电平转换成差分信号发到RS485总线上土壤传感器收到查询后返回一帧数据原路返回给STM32。STM32对返回帧做CRC校验、解析出氮磷钾和pH值然后通过I2C总线把结果发送给OLED屏幕刷新显示。这里有个细节要提前说STM32的串口TX/RX是TTL电平0~3.3VRS485总线是差分电平二者不能直接对接必须经过MAX485这类收发器转换。很多新手一上来就把串口直接接到传感器的A/B线上结果什么都收不到原因就在这里。OLED用的I2C总线也是类似的道理SSD1306的模块一般自带电平转换和上拉电阻直接用杜邦线连到STM32的PB8SCL和PB9SDA就行前提是这两个引脚复用了I2C功能或者使用软件模拟I2C我后面会讲代码实现。2. 硬件搭建与接线要点2.1 传感器供电与上电顺序先强调一个容易踩的坑工业级RS485土壤传感器几乎都是12V或24V供电个别甚至是宽电压范围7~30V但绝对不能用STM32的3.3V去驱动。我最初做实验时想省事直接用开发板的3.3V供电传感器指示灯不亮量了一下A/B线上也没有差分信号后来老老实实接了12V适配器才正常。传感器的电源线和通信线是分开的。以常见的四合一变送器为例通常有四根出线红色电源正、黑色电源负、黄色RS485 A、蓝色RS485 B。不同厂家颜色定义可能不同接之前一定要看说明书或者打开端子盖确认标识这是最容易烧板子的地方。电源千万不要接反RS485的A/B也不要试图去接电源否则轻则通信失败重则击穿电平转换芯片。上电顺序也有讲究。传感器上电后内部需要初始化一般需要预热几百毫秒到一两秒才能正常响应Modbus请求。我的做法是在STM32初始化完成、串口准备就绪之后延时1秒再发送第一条查询帧。如果上电立刻发请求经常会出现第一帧无响应的情况这在长期运行的设备上会被误判为传感器故障。2.2 RS485转TTL模块接线与收发控制MAX485模块的引脚不多但接线方式直接影响通信稳定性。模块上有RO、DI、RE、DE、VCC、GND和A、B端子其中RO和DI是TTL侧分别接STM32的RX和TXA和B接传感器的两根总线。RE是接收使能低电平有效DE是发送使能高电平有效。最简单粗暴的方式是把RE和DE短接在一起用一个GPIO控制。发送数据前拉高这个引脚发送完成后拉低让模块回到接收状态。我见过很多项目为了省一个GPIO用所谓的“自动收发电路”——通过三极管和电容在数据发送时自动拉高DE、发送完自动拉低。这种电路确实能省引脚但有个隐患自动收发电路对波特率和线路电容很敏感在低速4800/9600下一般没问题但如果换高速应用或者总线上挂的节点多、线缆长自动收发可能会出现最后一个字节发送完立刻切到接收导致数据没发完就被截断。我自己的习惯是宁可用一个GPIO手动控制可靠性放在第一位代码里多写两行而已。2.3 终端电阻、手拉手连接与接地问题RS485总线规范要求在总线的物理两端各接一个120欧姆终端电阻用来匹配传输线的特征阻抗减少信号反射。很多MAX485模块上已经有一个跳线或者贴片电阻可以启用终端电阻在只有一台主机和一台从机的点对点场景下在模块端把终端电阻接上就行传感器端的电阻一般内置于传感器内部不用额外处理。布线方式也要注意。RS485的标准连接方式是手拉手的菊花链也就是主机出来一根线从第一个传感器串到第二个、第三个。最忌讳的是搞成星型结构——每个传感器都拉一根独立长线回到主机这种拓扑会产生严重的信号反射轻则通讯偶尔出错重则完全不通。我见过有朋友为了现场方便三个传感器各拉了二十米线回到控制柜结果怎么调CRC都错最后改成手拉手串联才解决。还有一个容易被忽略的点是共地。RS485虽然用差分信号传输理论上不依赖公共地但实际应用中如果传感器和主机之间连地线都不接由于电源参考点不同长期运行容易出现偶发通信异常。正确做法是传感器和主机的地GND通过电源系统地连在一起必要时用屏蔽双绞线的屏蔽层单端接地不要两端都接地避免地环路电流。3. Modbus RTU协议与CRC校验实现3.1 读懂土壤NPK/PH传感器的寄存器映射我们常说的“土壤氮磷钾pH传感器”里面的传感器探头测量的是土壤溶液的离子浓度然后通过内部电路换算成数字信号。对外来看数据都保存在变送器的寄存器里我们通过Modbus协议去读这些寄存器。以我手头这款四合一传感器的通信参数为例波特率9600数据位8停止位1无校验也可以配置为8E1看厂家默认从机地址1默认可用指令修改功能码03读保持寄存器寄存器映射氮0x0000、磷0x0001、钾0x0002、pH0x0003pH值返回为无符号整数需要除以10才是实际pH值比如返回45表示pH 4.5这里必须强调不同厂商的寄存器地址和编码方式可能不一样。有的传感器把氮磷钾放在连续地址有的加个偏移量有的pH用有符号数表示。写代码之前先拿USB转485工具在电脑上发一次查询看返回的原始数据再对着说明书确认换算关系。这个动作能帮你省掉后面至少三个小时的排错时间。3.2 Modbus RTU请求帧结构Modbus RTU协议格式非常固定主机发起的读寄存器请求帧长8字节字节序号内容示例值0从机地址0x011功能码0x032起始寄存器地址高字节0x003起始寄存器地址低字节0x004寄存器数量高字节0x005寄存器数量低字节0x046CRC校验低字节0x447CRC校验高字节0x09查一下Modbus协议手册可以看到CRC计算采用的是CRC16/MODBUS算法多项式为0xA001初始值为0xFFFF。上表最后两个字节就是通过算法算出来的校验值发送时先低字节后高字节。3.3 CRC16校验函数的完整实现CRC校验在Modbus里是硬件工程师和嵌入式工程师都绕不开的东西。它的作用很简单发送方对整帧数据做一遍CRC计算把结果附在帧尾接收方对收到的数据再做一遍计算如果结果和帧尾附带的CRC一致说明数据没被篡改否则丢弃这帧数据。下面是我一直在用的CRC16 Modbus计算函数纯CPU计算不依赖任何硬件寄存器移植到任何单片机上都能跑。// 计算Modbus RTU CRC16 // 参数: data - 数据数组指针, len - 数据长度 // 返回: 16位CRC值 uint16_t CRC16_Modbus(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; uint16_t i; uint8_t j; for (i 0; i len; i) { crc ^ data[i]; for (j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; } // 将CRC填入发送帧, 低字节在前 void BuildSendFrame(uint8_t *frame, uint8_t addr, uint8_t func, uint16_t regAddr, uint16_t regNum) { uint16_t crc; frame[0] addr; frame[1] func; frame[2] (regAddr 8) 0xFF; frame[3] regAddr 0xFF; frame[4] (regNum 8) 0xFF; frame[5] regNum 0xFF; crc CRC16_Modbus(frame, 6); frame[6] crc 0xFF; // CRC低字节 frame[7] (crc 8) 0xFF; // CRC高字节 }这段代码建议直接抄进工程注意CRC校验结果填入帧时字节序是“低字节在前”搞反了传感器会一直不响应。很多人在这一步栽跟头明明发送的帧格式看着没问题寄存器地址也对就是收不到回应十有八九是CRC字节序反了。4. 核心代码实战从串口到OLED完整链路4.1 CubeMX初始化配置代码工程我用STM32CubeMX生成HAL库版本F1系列1.8.x左右都行。关键配置如下RCC选择外部晶振HSESYSDebug Serial Wire否则烧录一次后SWD被禁用会哭USART1异步模式波特率96008位数据无校验1位停止位使能串口全局中断I2C1标准模式100KHz使用PB8/PB9GPIOPB0配置为推挽输出用于控制MAX485模块的DE/RE方向引脚USART1的NVIC中断要记得勾选否则HAL_UARTEx_ReceiveToIdle_IT这类中断方式根本不会触发回调。GPIO初始状态下把485方向引脚拉低确保模块处于接收状态。4.2 串口接收不定长数据的实现传感器的应答帧长度不固定比如读取4个寄存器时返回9字节地址1 功能码1 字节数1 8字节数据 CRC2 13字节算错了让我重新算一下。读取4个寄存器时数据区为8字节总帧长 1地址 1功能码 1字节数 8数据 2CRC 13字节。为了避免固定长度接收带来的粘包和半包问题我用空闲中断加接收中断的组合即HAL_UARTEx_ReceiveToIdle_IT一帧数据发送完毕总线上出现空闲状态时回调就会触发。// 全局变量 uint8_t rs485_rx_buf[64]; volatile uint16_t rs485_rx_len 0; volatile uint8_t rs485_rx_complete 0; // 主程序中首次启动接收 HAL_UARTEx_ReceiveToIdle_IT(huart1, rs485_rx_buf, sizeof(rs485_rx_buf)); // 中断回调 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart huart1) { rs485_rx_len Size; rs485_rx_complete 1; // 重新开启下一次接收 HAL_UARTEx_ReceiveToIdle_IT(huart1, rs485_rx_buf, sizeof(rs485_rx_buf)); } }这里有个细节使用HAL_UARTEx_ReceiveToIdle_IT时回调函数是HAL_UARTEx_RxEventCallback而不是普通的HAL_UART_RxCpltCallback前者能直接拿到本次接收到的字节数Size。很多从标准库转过来的朋友会习惯性地找RCplt回调结果发现Size一直是0就是这个原因。4.3 发送查询帧与等待响应发送端逻辑比较直白。先把485方向引脚拉高调用HAL_UART_Transmit发送8字节查询帧发送完毕后拉低方向引脚切回接收状态。这里有个关键点发送完不能立刻去读接收缓冲区要给传感器留出处理时间。void RS485_SendQuery(void) { uint8_t tx_buf[8]; // 构建读取4个寄存器(氮磷钾pH)的请求 BuildSendFrame(tx_buf, 0x01, 0x03, 0x0000, 0x0004); RS485_DE_DIR(1); // 拉高DE进入发送模式 HAL_UART_Transmit(huart1, tx_buf, 8, 100); RS485_DE_DIR(0); // 拉低DE回到接收模式 }传感器的典型响应时间在50ms到200ms之间如果期间传感器正处于忙状态响应时间可能会更长。我建议在查询前先把rs485_rx_complete标志清零然后发送查询帧再等待这个标志置位。如果等待超过500ms还没有响应就判定为超时显示“N/A”避免界面卡死。4.4 校验与解析传感器返回帧拿到13字节的响应帧后先用CRC16函数重新算一遍。把收到数据中除了最后2个CRC字节以外的部分做CRC计算和帧尾的2字节对比一致才继续解析否则打印错误并丢弃。void ParseSensorData(uint8_t *buf, uint16_t len) { uint16_t crc_calc, crc_recv; uint16_t n_val, p_val, k_val, ph_raw; // 最小长度判断 if (len 11) return; // CRC校验 crc_calc CRC16_Modbus(buf, len - 2); crc_recv buf[len - 2] | (buf[len - 1] 8); if (crc_calc ! crc_recv) return; // 帧内容: 地址, 功能码, 字节数, 数据... if (buf[0] ! 0x01 || buf[1] ! 0x03) return; n_val (buf[3] 8) | buf[4]; // 氮 p_val (buf[5] 8) | buf[6]; // 磷 k_val (buf[7] 8) | buf[8]; // 钾 ph_raw (buf[9] 8) | buf[10]; // pH原始值 // 单位换算 sensor_data.n n_val; // mg/kg sensor_data.p p_val; // mg/kg sensor_data.k k_val; // mg/kg sensor_data.ph ph_raw / 10.0f; // pH值保留一位小数 }这里需要说明帧结构中buf[2]是返回的字节数0x08表示后面跟了8个数据字节。所以我直接取buf[3]和buf[4]作为氮的高低位buf[5]和buf[6]作为磷以此类推。数据在寄存器里是大端模式高字节在前所以解析时要先左移8位再加上低字节。4.5 OLED驱动与显示界面OLED部分我用的是SSD1306方案的江协科技风格驱动支持I2C接口。硬件I2C或者软件模拟I2C都能跑区别是软件I2C对GPIO要求更灵活什么引脚都能用但会占用CPU时间。在这个项目里轮询传感器的周期是1秒CPU宝贵资源并不紧张所以我直接用了HAL库的硬件I2C初始化干净利落。初始化代码其实就是标准的SSD1306初始化序列可以压缩成几行关键的void OLED_Init(void) { // 延时等待OLED上电稳定 HAL_Delay(50); // SSD1306初始化序列 // 关闭显示 - 设置显示时钟 - 设置复用率 - 设置偏移 - 开启显示 // 具体指令序列较长, 一般以命令数组形式实现 }SSD1306的I2C地址默认是0x787位地址0x3C左移一位也有少数屏是0x7A对应7位地址0x3D。如果你的OLED怎么都点不亮优先检查这两个地址。HAL库用I2C时七位地址0x3C对应的是shift后的0x78这个细节也经常让人一头雾水。显示逻辑上我做了四行布局每行一个参数。OLED不带中文字库直接用英文和数字最省事N: 0356 mg/kg P: 0128 mg/kg K: 0250 mg/kg pH: 6.8刷新策略上我建议做局部刷新而不是整屏清屏重画。具体来说每次更新数值前只把对应行的缓存区内容用OLED_ShowString和OLED_ShowNum覆盖写入即可。整屏反复清屏重画会闪烁而且白白消耗CPU。OLED_ShowNum这个函数在江协科技驱动里是现成的支持多位数和动态数值用起来很方便。4.6 主循环轮询逻辑完整代码下面是主循环的核心逻辑完整实现了一个带超时判断的轮询流程。typedef struct { uint16_t n; uint16_t p; uint16_t k; float ph; } SensorData; volatile SensorData sensor_data; volatile uint8_t sensor_data_ready 0; int main(void) { // ... CubeMX生成的初始化代码 OLED_Init(); OLED_Clear(); // 传感器上电预热 HAL_Delay(1000); while (1) { uint32_t start_time HAL_GetTick(); rs485_rx_complete 0; RS485_SendQuery(); // 等待最大超时500ms while (rs485_rx_complete 0) { if (HAL_GetTick() - start_time 500) break; } if (rs485_rx_complete) { ParseSensorData((uint8_t*)rs485_rx_buf, rs485_rx_len); OLED_ShowData(); } else { OLED_ShowNoSignal(); } // 轮询周期1秒 HAL_Delay(1000 - (HAL_GetTick() - start_time)); } }轮询周期我设置为1秒这个频率对于土壤数据来说完全够了。土壤氮磷钾和pH变化本来就是一个缓慢过程每秒刷新一次屏幕肉眼看起来就是实时更新的。如果你所在的土壤环境变化比较快可以把周期缩短到500ms但不要低于传感器的典型响应时间否则会造成请求堆积。5. 常见问题与排查心得玩RS485传感器采集问题高发区集中在通信链路和供电上。我把实战中遇到的典型问题整理成一张表新朋友可以直接对着排查。现象可能原因排查方法发送查询帧后收不到任何回应A/B线接反、传感器地址错误、波特率不匹配先用USB转485调试工具透传测试传感器确认地址和波特率偶尔能收到但CRC校验频繁失败线缆过长干扰大、布线是星型结构、共地问题改用双绞屏蔽线同时降低波特率到4800加终端电阻OLED一直无显示I2C地址配置错误、SCL/SDA接反、屏幕供电不足确认地址是0x3C还是0x3D排查接线单独给OLED供电数值显示为0寄存器地址读错、返回帧解析的字节偏移搞错用调试工具打印原始帧对照说明书再解析上电后第一帧总是超时传感器未预热完成就发请求上电后延时1秒以上再发第一条查询帧多个传感器一起挂总线只能读到第一个所有传感器默认地址都是1发生地址冲突用厂家工具把每个传感器改成不同地址除了表格里这些问题再分享几个排查心得。开发阶段务必先用USB转485工具在电脑上把传感器摸透。很多USB转485模块自带调试软件可以手动发送Hex命令。我拿到新传感器第一件事就是发一帧01 03 00 00 00 04 44 09看看返回什么。这一帧能确认地址、波特率、寄存器区、CRC算法对不对。传感器协议摸清楚之后再去写单片机代码排错范围会小很多。485方向切换的时序是一个隐蔽的坑。我遇到过发送完后马上切到接收模式结果第一帧数据经常少两个字节。原因是HAL_UART_Transmit函数返回时数据虽然已经交给了串口外设但移位寄存器可能还没把最后一个字节完全发出去这时候如果立刻把DE拉低最后一个字节或者最后两个字节就会被截断。稳妥做法是发送完成后加一个微秒级的小延时或者检查串口的TXE和TC标志位确认发送完全结束再切换方向。标准库可以直接查TC标志HAL库里面HAL_UART_Transmit返回时其实已经等TC了但为了保险还是建议加个毫秒级延时。土壤传感器的插针和土壤接触质量对数据影响很大。插针如果只是轻轻插在干土里接触电阻不稳定读数会跳得很厉害。我一般会先把土壤浇水湿润然后用工具在土壤里打一个小孔再把探头插进去压实确保插针和土壤充分接触。测pH之前探头表面要保持清洁有残留肥料或者污渍会污染测量结果。修改传感器从机地址也要留意。默认地址都是1如果一条总线上要挂多个传感器必须先把它们改成不同地址。改地址通常需要专用的配置指令各厂家格式不一样看说明书操作。改完记得做标记不然现场维护的时候根本分不清哪个传感器是哪个地址到时候抓瞎。6. 实测效果与可扩展的方向整套系统搭建完成之后实测效果非常稳定。传感器插入湿润土壤后OLED上显示的氮磷钾数值在几秒内就能稳定下来pH显示在6.8左右和手持pH计比对误差在合理范围内。RS485通信在十余米的线缆长度下没有出现CRC错误连续运行一整天数据链路都是稳的。这个项目做到这一步其实只是打开了智慧农业数据采集的大门。后续想扩展的话方向很多加一个ESP8266或者ESP32模块把采集到的数据通过MQTT协议上传到服务器实现手机远程查看加一个LoRa模块做低功耗广域网传输非常适合大田场景如果做温室大棚还可以联动继电器控制灌溉和施肥设备根据氮磷钾含量自动调节水肥比例。我个人在实际操作中的体会是嵌入式项目最怕的不是代码写不出来而是硬件链路一通就疯狂写代码最后发现传感器地址、波特率、CRC方向这些基础问题全都没确认。先把传感器在电脑上摸透再动单片机进度反而快很多。另外建议在项目中加一个通信超时看门狗如果连续多次查询超时自动重启传感器供电这个机制能避免现场出现假死状态对长期运行的农业设备尤其重要。最后再分享一个小技巧OLED显示界面不要只显示一个固定值可以在无数据时显示“NO SIGNAL”这样现场人员一眼就能看出传感器是不是掉线了排查问题会方便很多。
返回列表