ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32从站与维控屏的MODBUS RTU通信实战方案

2026/8/31 17:40:49 拓冰建站 浏览量
STM32从站与维控屏的MODBUS RTU通信实战方案 简介本资源是一套完整的STM32嵌入式项目实战代码包面向工业自动化领域初/中级开发者及高校嵌入式课程学习者聚焦STM32通过MODBUS RTU协议与维控HMI屏实现双向通信的核心需求解决LED控制、实时变量显示与IO状态监控等典型工业交互场景。压缩包共307个文件含58个头文件h定义寄存器映射与协议结构、55个C源文件c实现FreeModbus移植、USART驱动、GPIO控制逻辑及中断响应机制辅以编译中间文件o/d/crf、Keil工程配置uvproj/uVopt/sct、位图资源bmp及可执行镜像axf/hex整体4.87MB结构完整开箱即用于MDK-ARM开发环境。已有3941人学习下载提供从硬件接线逻辑、MODBUS帧解析、功能码0x01/0x05/0x03应用到维控屏变量绑定的全链路实现含大量注释与调试标记便于理解协议栈集成要点与常见CRC超时排错路径。开头做嵌入式这几年被各种屏折腾过不少迪文、昆仑通态、步科都碰过但如果你问我项目上最简单省事的方案是什么我会说——用STM32做从站通过MODBUS RTU协议接维控屏。这块组合在工控现场几乎就是“标配级”的存在维控屏成本可控、组态方便STM32资源丰富、生态成熟中间夹一层MODBUS协议稳定性非常能打。今天这篇就把我从一个实际项目里提炼出来的完整方案拆给你看包括协议原理、STM32侧的全部代码思路、维控屏的组态配置以及联调时最容易踩的坑。内容适合正在做HMI人机界面对接的嵌入式开发者也适合刚接触MODBUS、想搞懂主从通信机制的新手照着做基本就能跑通。1. 项目整体设计与方案选型1.1 为什么选MODBUS与维控屏先聊聊方案怎么来的。那会儿我手头是一个小型环境监控柜需要实时上报温度、湿度、设备开关状态还要能在触摸屏上手动控制继电器。控制器用STM32F103屏幕选了维控的7寸标准HMI。当时内部也争论过是不是用厂家私有协议或者干脆把数据全通过RS485自定义帧格式走一遍。但最后我还是坚持用标准MODBUS RTU理由很实在第一MODBUS是事实上的工控标准协议文档全、工具多、排查问题方便。拿一个串口助手配合MODBUS Poll就能模拟主站调试不需要依赖真实屏就能把从站逻辑测透。第二维控屏的组态软件里自带MODBUS驱动不需要额外写任何协议栈配置好寄存器地址映射就能用。第三如果后续要换屏或者接上位机PLC协议不用改移植成本为零。方案定下来以后整个系统的通信拓扑也很简单维控屏作为MODBUS主站主动轮询STM32从站STM32挂在RS485总线上地址设为0x01。屏上放几个数值显示控件和按钮控件操作员一按按钮屏发写寄存器指令MCU解析后翻转IO口逻辑非常清晰。1.2 系统硬件架构与通信链路硬件层面我用的是一块自研的STM32F103C8T6核心板板载SP3485做RS485收发A、B端出去直接接维控屏的COM口。这里要提醒一下选用RS485而不是RS232是考虑到工业现场的传输距离和抗干扰能力RS485差分信号能拉到几百米RS232超过15米就衰减得厉害。接线用双绞屏蔽线屏蔽层单端接地A接A、B接B千万别接反。维控屏端的COM口在组态软件里可以配置成RS485模式协议选MODBUS RTU波特率、数据位、校验位、停止位必须和STM32侧完全一致。我项目里统一用的是9600、8、N、1这个组合在绝大多数工控设备里都是默认值兼容性最好。如果想追求实时性可以上19200但注意线长和干扰会放大没把握就老老实实9600。通信链路还有一个容易被忽略的环节RS485是半双工同一时刻只能有一个设备占用总线所以STM32在发送完响应之后要立刻把收发器切回接收状态否则主站下一次轮询过来时总线会冲突。这点的代码细节后面我会专门讲。1.3 通信参数的前期规划工程上最忌讳边写代码边想参数。我在动手前先把一张通信参数表列出来贴在工位上所有协议相关的常量都从这表里来。这张表包括从站地址、波特率、数据格式、需要使用哪些功能码、每个寄存器地址对应的物理量、数据长度、读写属性等。比如我这套项目里寄存器地址数据类型读写属性对应物理量0x0000保持寄存器只读温度值0.1℃单位0x0001保持寄存器只读湿度值0.1%RH单位0x0002保持寄存器只读设备状态位0x0010保持寄存器可写继电器1控制字0x0011保持寄存器可写继电器2控制字0x0012保持寄存器可写继电器3控制字维控屏组态时也严格按照这张表去映射变量这样后续联调出了数据对不上的问题排查方向就非常清晰。还有一点经验寄存器地址规划时留出冗余段比如0x0010开始的写寄存器区如果以后要加参数设置项不用动前面的地址。2. MODBUS协议核心机制拆解2.1 MODBUS RTU数据帧结构想写好代码协议帧结构必须吃透。MODBUS RTU格式非常简洁每帧固定包含四个部分从站地址1字节、功能码1字节、数据区N字节、CRC校验2字节低字节在前。整个帧是一气呵成的连续字节流中间不能有超过1.5个字符时间的间隔否则从站会认为帧结束这一点是RTU模式和ASCII模式最核心的区别。以读取保持寄存器功能码0x03为例主站发出的请求帧是地址 0x03 寄存器起始地址高字节 起始地址低字节 寄存器数量高字节 数量低字节 CRC低字节 CRC高字节。而正确的从站响应帧是地址 0x03 字节数 数据高字节 数据低字节重复N次 CRC。读单个寄存器和读多个寄存器的数据段长度不一样所以从站在组响应时一定要按“寄存器数量 × 2字节”来计算实际数据区长度。读输入寄存器功能码0x04的帧格式和0x03完全一样唯一的区别是功能码值和从站内部读取的数据区不同。0x04读的是输入寄存器实际就是只读的模拟量采集值0x03读的是保持寄存器是4xx区。如果你和PLC打交道比较多可能会直接说3x区和4x区对应的就是这两种读操作维控屏里也是这种叫法。写单个寄存器功能码0x06的请求帧是地址 0x06 寄存器地址 数据值 CRC从站收到后需要把请求帧原样返回这样主站才能确认写入成功。写多个寄存器功能码0x10的请求帧会稍微复杂一点地址 0x10 起始地址 寄存器数量 字节数 数据区 CRC响应帧是地址 0x10 起始地址 寄存器数量 CRC。2.2 CRC16校验的原理与实现CRC校验是整个MODBUS帧里最容易写错、也是最值得花时间手写一遍的部分。MODBUS RTU使用的CRC16算法多项式是0xA001即标准CRC16-IBM初始值为0xFFFF。计算过程可以简单理解成把每个字节按位与当前的CRC寄存器异或每次异或后右移一位如果最低位是1就再异或多项式0xA001。有两种实现方式逐位运算和查表法。逐位运算代码量小、逻辑直观适合初学者理解和调试但每个字节都要跑8轮循环对MCU性能有一定消耗。查表法是预先生成一个256个元素的CRC表计算时每个字节只要两次查表和两次异或效率高得多适合轮询周期短的场景。我项目里用的是查表法因为STM32F103主频不高而且后续还要扩展多个从站轮询CPU资源要尽量节省。查表法的核心代码如下static const uint16_t crc_table[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, // ... 完整表项略网上可以找到生成代码 }; uint16_t modbus_crc16(uint8_t *buffer, uint16_t length) { uint16_t crc 0xFFFF; for (uint16_t i 0; i length; i) { uint8_t index (crc ^ buffer[i]) 0xFF; crc (crc 8) ^ crc_table[index]; } return crc; }发送时CRC低字节在前、高字节在后接收校验时整个帧从地址开始到数据区结束参与计算算出来的结果应该是0x0000这是最简单的校验手法。我在调试时专门写了一个CRC在线校验工具的函数能直接打印出每一帧计算好的CRC值排查问题效率提升明显。你也可以用网上的MODBUS CRC在线计算器交叉验证确保自己的算法没问题。2.3 寄存器模型与地址映射规则很多新手被MODBUS寄存器纠结到崩溃就是没建立起清晰的地址模型。MODBUS协议里寄存器分四类线圈0x区、离散输入1x区、输入寄存器3x区、保持寄存器4x区。其中线圈和离散输入是位操作输入寄存器和保持寄存器是16位字操作。维控屏和大多数组态软件里默认的变量地址往往显示成4x0001这种格式这里的4就是保持寄存器区后面的数字是从1开始的偏移。这里有个最容易踩的坑MODBUS协议帧里的寄存器地址是0开始的而组态软件里的地址描述是1开始的。比如你要读保持寄存器区里偏移为0的寄存器屏上显示的是40001而协议帧里填的地址是0x0000。如果你直接把40001填到协议帧里实际访问的是偏移量40000的寄存器数据必然对不上。我的做法是在代码里统一用0起始的偏移量作为内部定义维控屏组态时保持映射关系清晰。我项目里的写法是把所有数据统一放在一个结构体里通过地址偏移量直接访问typedef struct { uint16_t temperature; // 0x0000 uint16_t humidity; // 0x0001 uint16_t status_bits; // 0x0002 uint16_t relay1_ctrl; // 0x0010 uint16_t relay2_ctrl; // 0x0011 uint16_t relay3_ctrl; // 0x0012 } ModbusRegs; static ModbusRegs regs;这样修改数据时直接操作结构体字段然后在协议处理函数里根据寄存器偏移量计算指针位置代码写起来清晰维护也方便。千万别用一堆散落的全局变量来管理寄存器数据项目一复杂就乱套。3. STM32从站代码的完整实现3.1 串口与定时器的底层配置从站代码的核心是把串口中断、定时器、协议解析三块拼起来。我用的还是标准外设库不过思路完全适用于HAL库。串口配置算是最基础的USART1挂485芯片的收发控制引脚DE/REPB12配置成普通推挽输出高电平发送、低电平接收这个引脚在发送帧之前要手动拉高发送完最后一字节后延时拉低。串口中断优先级的配置有讲究USART1中断优先级要高于任何可能阻塞它的外设中断因为MODBUS的帧间隔判断依赖串口连续接收如果中间被更高优先级的中断打断太久字节间延时超过帧间隔就会把一帧数据误判成多帧。我建议USART中断优先级设为抢占优先级1、子优先级0定时器中断设为抢占优先级2、子优先级0其余外设往后排。时间基准则依赖一个3.5T定时器。以9600波特率计算1个字符时间是1/9600秒 ≈ 104.2微秒3.5个字符时间约364.6微秒1.5个字符时间约156.3微秒。实际工程中这些时间必须留足裕量我习惯把帧结束判定的定时时间设为5毫秒这样即使主站发送速度略微波动也不会误判。定时器用TIM2做基本定时在串口接收中断里每收到一个字节就重置计数值定时器溢出时认为帧结束然后置一个帧完成标志主循环里检测到标志就处理数据。void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); frame_ready 1; // 帧完成标志 TIM_Cmd(TIM2, DISABLE); // 停止定时器 } } void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t byte USART_ReceiveData(USART1); if (rx_index RX_BUFFER_SIZE) { rx_buffer[rx_index] byte; } TIM_SetCounter(TIM2, 0); TIM_Cmd(TIM2, ENABLE); } }3.2 协议解析状态机设计MODBUS RTU没有像TCP那样的包头长度字段所以从站必须靠串口接收配合定时器判断帧的结束位置。我建议用状态机来解析每一帧这个状态机的状态可以划分成空闲、接收中、帧完成。实际很多RTU从站实现并不需要一字节一字节地跑复杂状态机因为帧边界已经由定时器确定等到帧完成后再做完整的校验和功能码分发就行。帧处理的完整流程是主循环检查到frame_ready置位先把串口中断关掉防止新的数据混进来然后做几个校验——长度校验、从站地址校验、CRC校验。如果都通过根据功能码分发处理不通过直接丢弃并复位接收缓冲区。最后把所有标志位复位重新打开接收中断准备下一帧。一个常见的坑是从站响应太快导致主站还没完全从发送模式切到接收模式就收到了响应。所以我习惯在发出响应帧之后加一个大约2~3个字符时间的软件延时再释放总线。这个延时也帮助RS485收发器完成方向切换避免总线冲突。3.3 常用功能码的处理流程本项目用得最多的是03、04、06、16四个功能码下面逐个说处理细节。读保持寄存器0x03和读输入寄存器0x04的处理逻辑几乎一样。从请求帧中提取起始地址和寄存器数量判断有没有越界如果越界就返回异常响应。正常响应时先填从站地址和功能码再填字节数寄存器数量×2然后把每个寄存器的数据高字节在前、低字节在后依次填入最后附加CRC。写单个寄存器0x06的处理要特别注意维控屏的按钮控件按下时会发送写指令从站写入成功后要原样回传请求帧。如果写的是非法的寄存器地址比如我项目里如果写入的寄存器不是0x0010到0x0012范围就返回异常码0x02非法数据地址。同时还要做数据范围校验比如写入继电器控制字只允许0或1非法值返回0x03非法数据值。写多个寄存器0x10实现起来稍微复杂一点。先解析出起始地址、寄存器数量和字节数重点检查字节数是否等于寄存器数量乘以2防止错位。然后逐字写入全部成功后响应帧是地址 0x10 起始地址 数量 CRC不需要返回具体数据。异常响应的格式地址 功能码0x80 异常码 CRC。异常码1是非法功能码2是非法数据地址3是非法数据值。维控屏收到异常响应会显示通信错误这能帮你快速定位协议层的问题。3.4 响应帧的组包与发送组响应帧我统一用一个函数把地址、功能码、数据区指针、数据长度传入函数内部拼装帧数据并计算CRCvoid modbus_send_response(uint8_t func, uint8_t *data, uint16_t data_len) { uint8_t frame[256]; uint16_t index 0; frame[index] slave_addr; frame[index] func; for (uint16_t i 0; i data_len; i) { frame[index] data[i]; } uint16_t crc modbus_crc16(frame, index); frame[index] crc 0xFF; frame[index] (crc 8) 0xFF; RS485_DE(1); // 切换为发送模式 for (uint16_t i 0; i index; i) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, frame[i]); } while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); RS485_DE(0); // 切回接收模式 }发送完成后别急着返回CRC值就完事RS485方向切换需要时间我一般会加一个10微秒左右的延时。这个值根据实际硬件调整太短会切不彻底太长会拖慢轮询周期。这个细节在联调时能省很多事。4. 维控屏端组态配置与联调4.1 维控屏工程与通信参数配置维控屏的组态软件叫维控HMI Editor工程里先创建一个新画面然后在“设备窗口”里添加一个新的通信设备。选择MODBUS RTU协议串口号选对应的COM口通信参数设置成9600、8、N、1和MCU侧保持一致。从站地址填1这个地址必须和STM32代码里的slave_addr一致。维控屏的通信参数界面里还有“通讯超时时间”和“重试次数”两个参数。我建议超时时间设500毫秒重试次数设3次。如果现场通信容易受干扰可以在保证操作不卡顿的前提下适当提高重试次数。还要确认一下数据长度设置通常保持寄存器是16位不需要改动。4.2 变量组与寄存器地址的对应关系这是最容易让新手崩溃的环节。维控屏变量组里的地址格式我上面提过是4x0001这种。这里面的4代表保持寄存器区0001就是协议帧中的偏移量0。所以在组态时温度值对应4x0001湿度值对应4x0002状态位对应4x0003继电器控制字对应4x0017因为协议偏移量0x10 十进制16所以地址显示为4x0017。维控屏变量类型我统一设为“16位无符号整数”。如果你要显示负数温度就改用“16位有符号整数”同时MCU侧要用int16_t类型存储。这里要注意字节序问题MODBUS协议规定寄存器数据高字节在前维控屏默认也是高字节在前所以只要双方都遵循协议就不需要额外调字节序。但如果你在调试中碰到数值不对劲比如温度显示成0x2814而不是0x1428那就是字节序被反转了去组态软件里找“字节交换”或“字交换”选项改一下。4.3 画面控件绑定与数据监视画面控件配置相对简单放一个“数值显示”控件关联到温度变量然后设置小数点位数。比如温度实际值是0.1摄氏度单位如果寄存器里存的是255表示25.5℃那显示控件的小数位设成1位控件会自动显示25.5。湿度同理。按键开关控件绑定继电器控制字开关类型设成“切换”属性里写“ON时写入1OFF时写入0”。维控屏在按钮按下时会自动发出写寄存器指令松开发出另一条写指令。如果你只想要点动控制按下有效、松开无效可以设置成“按下置位释放复位”。这块逻辑和我代码里对控制字0/1的判断是对应的。配置完画面之后进“在线模拟”或直接下载到屏幕里运行。维控屏有一个“变量监控”窗口能实时看到每个变量当前值以及通信状态。联调时先用这个窗口观察哪些变量有值、哪些变量在跳红灯基本能快速定位问题。5. 常见问题与排查技巧实录5.1 通信超时与无响应的排查步骤联调时第一类大问题就是屏上所有变量都显示通信错误。这时候别急着改代码按下面这个顺序排查多数情况十分钟内能找到原因先用USB转485工具接到总线上打开串口助手看主站维控屏是否在持续发请求帧。如果根本没数据检查屏的通信串口是不是选对了波特率和数据格式是否一致。如果屏发了请求但从站没响应用逻辑分析仪抓一下总线上是否有从站的应答帧。抓到了就看应答帧的数据是否合法抓不到就检查STM32的RS485方向控制引脚是不是没切换。我在调试时发现一个很隐蔽的问题SP3485的DE和RE如果共用引脚控制发送完后切回接收模式的时间点必须比最后一个停止位结束晚一点点。我一开始在TXE标志置位后立刻切方向结果最后一个字节的停止位还没发完就被收发了总线直接冲突。后来改成等待TC标志置位再切方向问题立即消失。这个坑在STM32数据手册里其实有暗示但没踩过的人很难意识到。5.2 数据错位与CRC校验失败的原因通信通了、数据不对这是第二大类的烦心事。CRC校验失败的常见原因有几个波特率不匹配导致位采样错乱帧间隔超过3.5字符时间导致从站把一帧拆成两帧485总线干扰严重特别是布线时A、B线靠近动力线。用示波器或者逻辑分析仪抓波形是最直接的判断手段看波特率对不对看帧间间隔够不够。数据错位的情况大多是地址映射搞错或者字节序反了。比如温度在屏上显示成几千甚至几万先检查寄存器地址是否对应正确再查字节序。还有一种情况是维控屏变量类型设成了32位而MCU发的是16位数据两个16位寄存器被合并成一个32位数据数值当然不对。这种问题光看代码很难发现最好在屏上开一个“原始值”显示控件把16位数据直接显示出来对照MCU侧的寄存器值很快能定位。5.3 几个容易被忽略的隐性坑最后分享几个我实际项目里踩过的、不太容易想到的坑。第一个是看门狗。如果STM32开了IWDGMODBUS处理一定要在一段时间内及时喂狗。维控屏轮询周期如果比较长并且MCU侧还有其他任务占用了较多时间主循环可能来不及喂狗导致系统重启现象就是通信偶发中断然后自动恢复。解决办法是在定时器中断里喂狗或者把喂狗放在RTOS的低优先级任务里。第二个是缓冲区的完整性。我接收数据用的是一维数组如果收到的数据超过缓冲区长度会静默丢弃后面字节。如果MODBUS协议里寄存器数量比较大响应帧长这个裁剪会直接导致CRC校验失败。调试时遇到诡异的问题不妨在接收中断里加一个溢出计数变量看有没有溢出发生。第三个是和维控屏的“数据变化触发”机制相关。维控屏在数值有变化时才会刷新并发送指令如果MCU侧的寄存器值一直不变屏上的数值显示可能长时间不更新。这不是通信故障而是屏的优化机制。解决办法是在屏的“数据词典”里设置“周期采集”属性或者让MCU侧在值不变的场景下周期性地把相同值写一遍。我实际的体会是STM32和维控屏走MODBUS这套组合只要前期的寄存器映射表做好了后期联调的时间能压缩一半以上。代码框架搭一次后面换屏、换MCU、加从站都能复用这才是MODBUS这类标准协议最值钱的地方。如果你手头也要做类似的项目建议先把从站地址和寄存器映射表敲定再动手写代码能少走很多弯路。本文还有配套的精品资源点击获取