
简介一份面向 STM32 开发者的 Modbus 通信参考资源包适合正在学习工业现场总线协议、或需要快速在嵌入式项目中集成 Modbus 功能的工程师。资源以 C 源码为核心共 107 个文件、4.69MB包含 48 个 C 源文件、46 个头文件与 2 个汇编启动文件覆盖 STM32F10x 标准外设库、FreeModbus 1.6 主从协议栈移植层以及用户应用层另附 KEIL 工程配置文件、批处理编译脚本、CRC 计算助手可执行工具及 PDF/TXT 说明文档方便直接导入工程并验证通信时序。作者表示程序经过多轮测试并配有配套博客教程可减少环境搭建与协议调试中的弯路。已有 3453 人学习下载适合作为 Modbus RTU 实现、协议栈裁剪移植以及 CRC 校验调试的实用参考。 前阵子整理移动硬盘翻出一个老压缩包文件名就叫“stm32 for modbus.zip”。这包东西是我早年在STM32上做Modbus通信时攒下来的全套家当——协议栈源码、寄存器映射表、串口调试笔记、还有几个调了几天才跑通的工程备份。做嵌入式的朋友都有同感Modbus这协议听起来平易近人可真要在STM32上把每一帧数据解析得干干净净、让设备7x24小时跑着不丢帧不卡死中间全是文档里查不到的细节。这篇文章就把我从这个压缩包里翻出来的经验重新梳理一遍从协议选型、STM32资源分配、协议栈移植到modbus poll/slave和LabVIEW联调再到实战中真正踩过的坑一次性说清楚给正准备在STM32上落地Modbus的同学做参考。1. Modbus协议到底解决什么问题动手前的全局认识1.1 二十多年还在用的通信规矩核心就一条“一问一答”Modbus能活到今天不是因为协议本身有多“高级”恰恰是因为它足够简单、足够皮实。它的通信模型本质上就是一个“一问一答”的会话主站Master发请求帧从站Slave回响应帧谁也不许抢话通信过程永远由主站发起。这种设计在现代总线协议看起来有点“憨”但在工业现场它就是稳——不需要仲裁、不需要冲突检测、时序天然确定。我把Modbus帧结构写在这里做嵌入式的人必须能背出来RTU模式下设备地址(1字节) 功能码(1字节) 数据(N字节) CRC16低字节 CRC16高字节TCP模式下事务标识符(2字节) 协议标识符(2字节) 长度(2字节) 设备地址(1字节) 功能码(1字节) 数据(N字节)这里有个容易忽略的点RTU帧里CRC是“低字节在前、高字节在后”输出的而很多CRC算法库默认输出顺序是高字节在前移植的时候一定要确认字节序。我第一次写驱动就在这上面翻了车从站一直回错误帧排查了整整一晚才发现是CRC高低字节挂反了。功能码这块初学者最容易把“读寄存器”和“写寄存器”记混。常用的一组功能码不需要全背但下面这几个必须刻在脑子里0x01读线圈读DO0x02读离散输入读DI0x03读保持寄存器读AO/AI通用寄存器0x04读输入寄存器0x05写单个线圈0x06写单个保持寄存器0x0F写多个线圈0x10写多个保持寄存器从站收到主站请求后正常的响应是把功能码原样返回。但如果从站检测到错误比如寄存器地址越界、值超出范围返回的响应帧功能码要置最高位也就是“原始功能码 0x80”后面跟一个异常码字节。比如主站发0x03读保持寄存器从站发现地址非法就回0x83 异常码0x02。这就是后文要讲到的“modbus poll报02 illegal index”的根源。1.2 RTU与TCP两种最常见的载体怎么选很多人在项目开始前纠结用Modbus RTU还是Modbus TCP我的判断标准很简单——看物理链路和实时性需求。RTU跑在串口/RS-485上一帧报文加CRC校验波特率9600到115200都有。它的优点是抗干扰能力强485总线用双绞线能拉几百米甚至上千米非常适合工业现场分布式采集。缺点是半双工一问一答总线上挂几十个从站时轮询周期会变长。TCP跑在以太网上天然全双工速度远高于RTU。TCP模式不需要CRC16校验因为底层以太网帧已经有CRC32了。TCP适合做设备间高速数据交换比如STM32通过网口接入PLC或上位机。但TCP对硬件有要求需要外接以太网PHY芯片或者用带MAC的STM32系列比如STM32F407、F429这类。还有一个折中方案是Modbus RTU over TCP本质是把RTU帧封装进TCP报文里帧里保留CRC和地址字节。这个做法在工业网关里很常见老设备换新上位机的时候不用改从站固件。选型的时候我建议优先级这样排链路远且干扰强选RTU485数据量大且布线方便选TCP设备要和已有的RTU老系统对接就别搞特殊直接选RTU。从“stm32 for modbus.zip”这个包的内容看我当时做的是一个RTU从站设备但TCP和RTU的帧解析逻辑在协议栈上层是共通的换载体只动串口/网口这一层抽象。1.3 寄存器地图写驱动前必须画清楚的那张表我见过太多人拿到项目就一头扎进代码结果Modbus地址和实际物理量对不上最后在现场被甲方追着改。做Modbus从站第一件事不是敲键盘而是先把寄存器地图画出来。寄存器地图说白了就是一份“地址-含义-读写权限-数据类型”的对照表。保持寄存器Holding Register和输入寄存器Input Register是16位单位一条报文最多读写125个寄存器。线圈和离散输入是位单位一条报文最多读2000位。我习惯在工程里建一个modbus_regs.h用结构体或者宏定义把寄存器地址全部声明清楚#define REG_DEVICE_ID 0x0000 // 设备编号只读 #define REG_RUN_STATUS 0x0001 // 运行状态只读 #define REG_TEMP_SET 0x0002 // 温度设定值读写 #define REG_TEMP_ACT 0x0003 // 当前温度只读 #define REG_BRIGHTNESS 0x0004 // 亮度值读写 #define REG_FAULT_CODE 0x0005 // 故障码只读这一步做扎实了后面写功能码处理函数、写LabVIEW上位机、写modbus poll测试脚本全部拿着这张表对齐沟通成本极低。表里还要标好Modbus地址和协议地址的换算关系——上位机看到的寄存器地址是1开始的而协议帧里传输的地址是0开始的LabVIEW控件和modbus poll里填地址时很容易差1这是排除报错时最先要检查的点。2. STM32端资源分配与通信架构设计2.1 超时定时器怎么选SysTick、TIM还是DWTModbus RTU是异步串行通信没有DMA的字节间隔判断的话从站必须靠定时器来判断“一帧报文结束了”。协议规定帧内两个字符的间隔不能超过1.5个字符时间帧结束则要等待3.5个字符时间。实际工程里我很少严格按1.5倍去卡因为太容易受中断抖动影响。我测试过三种定时器方案直接说结论SysTick做超时计数不推荐。SysTick很容易被别的代码占用比如RTOS的时基、延时函数一旦被抢占超时判断就飘了。通用定时器TIM推荐。专门配置一个TIM来做接收超时时钟源和预分频算好设置成一段时间后溢出并产生中断在中断里关闭接收窗口。这个方案干净利落推荐默认选它。DWT循环计数器适合做短时间测量但做Modbus超时管理需要自己维护一个状态标志不如TIM直接。用TIM做超时判断核心参数是溢出时间。以波特率9600为例1字节约1.04ms3.5字符时间约3.6ms我把TIM的溢出时间配成5ms左右既不会把两个连续请求误判成一帧也不会因为主站响应稍慢而帧被切碎。我做过的一个从站工程里串口是USART1TIM4专门做接收超时定时器时钟84MHz预分频8400-1重装载值50-1这样每个计数周期1ms50ms后触发溢出中断。实际调下来对于9600波特率50ms窗口远大于一帧的发送时间但小于主站轮询间隙实测非常稳定。当然这个值要看波特率调整115200波特率下我把窗口缩到5ms保证响应延迟低。2.2 串口、DMA和空闲中断的三层配合STM32接收Modbus RTU报文最优雅的方式是“串口空闲中断 DMA 数据超时定时器”三层配合。思路是这样的USART配置DMA接收模式数据直接进内存缓冲区不打断CPU。总线上一帧数据发完后串口会检测到空闲状态触发空闲中断IDLE interrupt。在空闲中断里读取DMA剩余传输量算出本次实际接收的字节数然后把解析标志置位交给Modbus状态机处理。用HAL库的话开启空闲中断的关键一句是__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);然后在中断回调里处理DMA搬运void USART1_IRQHandler(void) { if (RESET ! __HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); rx_len RX_BUF_SIZE - remain; rx_complete_flag 1; } HAL_UART_IRQHandler(huart1); }这里有个初学者很容易踩的坑开启DMA接收时最好把DMA循环模式Circular Mode打开同时把串口接收超时定时器作为兜底。因为如果主站发送一半断线了空闲中断可能迟到或者DMA缓冲区恰好处于满临界状态这时超时定时器能强制把缓冲区里攒下来的数据当作一帧处理。为什么不用纯中断接收一帧Modbus RTU报文最多256字节115200波特率下大约22ms收完逐字节进中断CPU还能扛得住但9600波特率下一帧可能跨200ms再加上还有别的外设要处理CPU被频繁打断不是好事。DMA方案能把这部分开销压到接近零。2.3 CRC16校验中断里别算也别用查表法之外的东西CRC16是Modbus RTU的命根子主站发的每一帧、从站回的每一帧都要校验CRC。CRC参数是多项式0x8005、初值0xFFFF、输入输出都不反转。实现方式有两种我建议直接上查表法查表法适合所有MCU平台。把256项CRC表存到Flash计算时逐字节查表速度快到可以忽略不计。STM32的Flash按256字节对齐完全没问题。位运算法省Flash但不省时间。每字节要循环8次一帧报文几十字节算下来白白浪费几百微秒在115200波特率下会影响响应时序。用HAL库的硬件CRC外设也可以但Modbus的CRC参数和STM32硬件CRC外设默认的CRC32参数不一致配起来比较绕收益也就那点没必要非用它。我自己的CRC例程是这样的核心写法uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *data; for (uint8_t i 0; i 8; i) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }0xA001是0x8005的反向多项式这是Modbus RTU的标准参数。有人会问为什么不用查表法但代码里还是位运算这里我是为了帖子里方便演示。实际工程里我用的是预生成256项表查表函数只有三行大家移植的时候直接生成一张表存到const数组里就行。还有一点从站收到请求帧后先校验CRCCRC错直接丢弃连错误响应都不回。因为CRC错的帧可能是干扰造成的如果回了错误响应主站反而会被错误信息扰乱。3. 协议栈是抄还是写移植与自研的取舍3.1 FreeModbus适合什么场景又卡在哪里网上搜“STM32Modbus”十有八九会被推荐FreeModbus协议栈。FreeModbus确实是一款不错的开源协议栈支持RTU/ASCII/TCP功能码覆盖也全。但它是一个“通用协议栈”要适配具体硬件得改串口底层接口而它改编的接口设计得偏老派跟现在的HAL库代码风格融合度一般。我的看法是如果项目时间紧、帧格式不需要特殊定制、就想赶紧把事情跑起来那直接用FreeModbus没毛病。它帮你把状态机、超时处理、功能码分发都写好了你只需要提供串口发送接收和定时器时基大概两三天能跑通。但如果项目里需要一些“非常规操作”——比如私有功能码、多个从站地址支持、特殊寄存器写保护逻辑、或者协议栈要和某个RTOS的队列机制深度集成——FreeModbus就有点绑手绑脚。我在一个支持双从站地址的设备上就踩了坑FreeModbus的地址过滤逻辑是写死在协议栈里的要改得动源码改完还得重新测回归成本不低。3.2 自研从站协议栈状态机、寄存器与串口接收解耦我自己后来更倾向于自研一个轻量级从站协议栈原因很简单Modbus从站的核心逻辑其实不复杂1000行C代码内能写得很清楚而且完全掌握在自己手里调试、扩展、裁剪都方便。自研协议栈我推荐把系统拆成三个独立的模块串口/物理层模块负责收帧、发帧、CRC校验向上提供modbus_frame_received(uint8_t *buf, uint16_t len)的回调。协议解析模块一个状态机解析功能码和寄存器地址调用下层寄存器回调。寄存器模块真正读写物理量的地方用函数指针表关联寄存器地址和读写函数。解析帧的状态机核心逻辑可以这样抽象状态INIT校验CRC不通过就丢弃并返回IDLE。状态ANALYZE解析设备地址。本机地址不符合且不是广播地址0返回IDLE符合则继续。状态EXECUTE按功能码分发。读类功能码走读寄存器回调写类功能码先写后应答。状态BUILD_RESPONSE拼响应帧加上功能码和地址再算一次CRC。这个状态机的好处是每个状态都只操作缓冲区不直接访问硬件资源串口和协议栈完全解耦。我在写寄存器回调时用了一个通用接口typedef struct { uint16_t addr; uint16_t (*read_cb)(void); void (*write_cb)(uint16_t value); } modbus_reg_item_t;寄存器表就是一个数组Modbus的地址查表比对没匹配到就返回非法地址异常码。这种方式比在协议栈里写满switch-case清晰得多加一个寄存器只需要在表里加一行。移植到不同STM32型号很轻松换串口驱动时物理层模块单独抽出来改就行。实测在我自研的协议栈上一帧03功能码请求从收到DMA数据到响应发出总耗时在50微秒以内比FreeModbus的通用实现更可控。3.3 Keil、芯片包与工程模板环境搭建里最折磨人的几个细节聊天记录里搜到“stm32芯片包官网下载流程”“keil5兼容c51和stm32安装”“stm32固件库模板下载搭建”这三个问题相信每个用过Keil的人都被坑过。现在STM32开发基本都用STM32CubeMX生成HAL库工程。芯片包的安装路径要特别注意Keil默认的Pack安装目录是C:\Keil_v5\ARM\PACK下载完的.pack文件直接双击就能装但如果你电脑上Keil安装在C:\Keil_v5且没有管理员权限双击装Pack经常报错。我的经验是右键Keil图标“以管理员身份运行”再打开Pack Installer去安装。还有一个反直觉的点keil5兼容C51和STM32这两套环境是可以共存的但C51的Pack和ARM的Pack是分开管理的。装完C51再装ARM MDK默认安装目录不同不会冲突。但如果你直接拿一个C51工程目录去打开STM32工程Keil会报“not a valid project file”这是工程文件后缀和工具链不匹配不是安装坏了。固件库模板方面现在CubeMX生成工程已经能自动把HAL库、启动文件、链接脚本全部配好不需要再手动复制固件库。但生成的工程有时会丢USE_HAL_DRIVER和STM32F407xx这种预定义宏导致编译报几百个“找不到stm32f4xx_hal_conf.h”之类的错。排查方法就是去Options for Target - C/C - Preprocessor Symbols里检查那两个宏在不在。4. 联调工具链modbus poll、slave和LabVIEW怎么配合4.1 先用上位机把帧拆透再动手写STM32代码我在这个压缩包里最值钱的部分不是协议栈源码而是一批用modbus poll和modbus slave做联调的测试截图和抓包记录。我的开发流程有个铁律先通过上位机工具把通信机制跑通、把帧格式理解透再回嵌入式侧写代码。这样做的原因很直接——上位机工具能实时显示每一帧的内容和错误原因嵌入式里打印日志费时费力还经常影响时序。用modbus poll模拟主站直接连STM32从站开发板配置好串口号、波特率、数据位/停止位/校验位、功能码和寄存器地址点“Connect”就能看到寄存器值周期性更新。如果从站有问题界面上会显示异常码比如02、03。这个时候我一般同时打开串口抓包工具对照modbus poll界面里的读写记录把主站发出来的原始帧和从站回应的原始帧都抓下来手工按帧结构解析一遍。这个过程虽然原始但对理解Modbus协议帮助极大——你会很快发现很多问题是自己帧拼错了而不是寄存器逻辑错。4.2 modbus poll/slave的授权管理与基本操作modbus poll是主站调试软件modbus slave是从站模拟软件两个软件配合使用可以完全模拟一套完整的Modbus主从通信链路。先说授权问题。这两个软件都是商业软件官网提供试用版但有功能限制比如连接时间或地址数量限制。我以前用的是老版本网上能找到序列号或者key文件。这里我得多说一句从合规角度公司项目里尽量用正版授权费用不高学生在实验室学习用试用版也够把协议跑通了。技术学习的关键不在软件破解而在于理解帧结构和调试方法。modbus poll的常用操作其实就三块Serial Settings里设串口和速率功能码面板选03读保持寄存器或06写单个寄存器寄存器地址栏输入十进制的寄存器地址注意很多软件界面里地址是“1-based”而协议帧里是“0-based”差一问题前面说过。modbus slave作为从站模拟器可以开多个从站ID、设置每个从站的寄存器初值、异常码模拟等。我用它来验证STM32做主站时发送的请求帧是否正确——STM32发一帧请求modbus slave这边能看到它读寄存器还能故意配置异常码看STM32主站能不能正确解析异常响应。4.3 LabVIEW读RTU从站信息配置与常见报错热搜里有好几条关于“LabVIEW读Modbus RTU从站信息”。LabVIEW里读Modbus RTU最常用的是内置的“Modbus”库或者在LabVIEW 2015以后用“LabVIEW Datalogging and Supervisory Control Module”里封装的Modbus函数。用LabVIEW做主站时有几个坑必须提第一串口配置必须和STM32从站完全一致。LabVIEW里打开串口时默认的“终止符”设置往往带一个\n这会让Modbus RTU的二进制帧被截断。要在VISA配置里把终止符关闭否则读回来的数据永远是残缺的。第二Modbus RTU的地址和功能码要用数字控件传入。很多人第一次用LabVIEW查资料看到“Mode”控件就懵其实对应关系就是Mode0表示读保持寄存器对应03Mode1表示写单个保持寄存器对应06Mode2表示写多个保持寄存器对应16Mode3表示读输入寄存器对应04Mode4表示读线圈对应01Mode5表示读离散输入对应02Mode6表示写单线圈对应05。第三LabVIEW轮询周期要设置合理。RTU是半双工如果LabVIEW里的“间隔毫秒”设成0会以极快的速度不停请求STM32从站处理不过来就会丢帧。我一般设50ms以上的轮询间隔实测很稳。5. 实战中踩过的坑从功能码错误到Pin不通5.1 modbus poll报02 illegal寄存器地址还是功能码的问题“modbus poll 02 illegal”是新手最常碰到的错误提示。这个“02”是Modbus标准异常码含义是“Illegal Data Address非法数据地址”不是指功能码02。当时我调试一个STM32从站modbus poll里填了寄存器地址0功能码03结果一连接就报02。排查过程是先看从站返回的原始响应帧发现是从站主动回了0x83加0x02那问题就出在从站侧不是poll配置问题。然后我在从站协议栈里加日志打印解析到的寄存器地址发现它收到的地址是0x0000但我的寄存器表是从0x0001开始登记的查表时自然就“未匹配”了。根因就是地址标准化问题。Modbus协议规定协议帧里寄存器地址从0开始编号而大多数上位机软件包括modbus poll习惯从1开始显示。所以我正确的做法是在STM32从站里把modbus poll传进来的Modbus地址减去1再作为寄存器表的查找下标。或者干脆约定寄存器表下标从0开始上位机地址从1开始两边自动对齐。这个坑的本质是理解“协议地址”和“用户地址”两套编号体系的偏移。所有做Modbus的人都会在某一天撞上它越早理解越好。5.2 485总线上的电平、隔离和终端电阻STM32做Modbus RTU通信时一般要外接RS-485收发器芯片最常见的是MAX3485或者SP3485。很多人觉得这是硬件工程师的事但调试时如果完全不懂就会遇到“明明代码没问题通信就是不稳定”的尴尬。485是差分信号A、B两线电平差决定逻辑值。A高B低是逻辑1A低B高是逻辑0。比较常见的坑有三个第一收发器方向切换。MAX3485的DE/RE是方向控制脚发送时要拉高接收时要拉低。如果代码里方向切换时机不对——发送完马上切回接收但字节还没完全发出去最后一两个字符会被掐断。正确做法是等发送完成中断TC标志触发后再拉低DE。第二终端电阻。485总线两端分别需要120欧终端电阻匹配传输线阻抗。如果只有两块板子短距离直连不接也能跑但超过几十米或者挂多个从站不接终端电阻会出现回波导致数据错乱。这个坑我在实验室没遇到过是在现场接几十米线缆时遇到的通信一开始是好的运行几小时后莫名卡死最后给总线两端加上120欧电阻解决。第三共地问题。485虽然是差分信号但通信双方仍需要共地。两个设备如果分别用两套隔离电源供电A、B线之间可能产生较大共模电压严重时会损坏收发器。正规做法是加隔离电源和光耦隔离。低成本方案是两块板子之间连接一根地线也能大幅减少共模干扰。5.3 modbus tcp能ping通但mod scan不通一步步定位“modbus tcp 能pin通但mod scan不通什么原因”这条热搜词很典型。先说结论能ping通说明链路层和网络层没问题问题一定在Modbus TCP的应用层解析或端口上。Modbus TCP默认端口是502。第一步先确认你用modbus scan扫描时填的端口号是不是502如果填成别的端口就算ping通也连不上。第二步是确认Modbus TCP从站的设备ID是不是0xFF或1不同从站默认值不一样很多扫描工具默认填0或255会导致设备无响应。第三步最重要Modbus TCP本质上是把Modbus RTU帧去掉CRC包在TCP的MBAP报文头里。很多初学者直接用modbus poll的“TCP/IP”模式结果Modbus Poll默认会把功能码和地址打包成RTU帧再套TCP而你的STM32写的是RTU over TCP两边帧格式不一致自然不通。我处理这类问题时最有效的工具是Wireshark抓包过滤tcp.port502直接看TCP负载里第一个字节是不是0x00第二位是0x00第三位是0x00第四位是0x06这样的MBAP头结构然后再看第五个字节是不是设备ID、第六个字节是不是功能码。从这个链路看下去问题通常5分钟内定位。这个压缩包里的项目我当时是用STM32F407接LAN8720跑Modbus TCP第一次调试也是卡在设备ID不匹配上。对端PLC默认发广播地址255我的从站只响应地址1直到我抓包观察到设备ID后把从站地址过滤规则改成同时接受255和1问题立刻解决。最后再分享一个这个压缩包里留下的习惯我现在每做一个Modbus相关项目都会在工程里保留一份frame_diagram.png画好RTU和TCP两种帧格式的字节排布图联调出问题先对着图看报文。通信协议这东西靠记不如靠图靠猜不如抓包。希望这篇从老压缩包里翻出来的经验能帮你少走几段弯路。本文还有配套的精品资源点击获取