1. 为什么嵌入式项目里到处都能见到MODBUS
我第一次接触MODBUS是在一个厂房改造项目里,对方要求把十几台旧设备的运行状态全部汇总到中控室。翻开旧设备手册,通信接口清一色写着RS485,协议全是MODBUS RTU。那时候我就意识到一个事实,在工业现场摸爬滚打这么多年,MODBUS几乎是绕不开的坎。
很多刚入门嵌入式开发的朋友会有个疑问:现在有CAN、有MQTT、有各种花里胡哨的高速总线,为什么还要学MODBUS这种上世纪70年代末诞生的老协议?答案其实很现实:MODBUS不追求极致性能,它追求的是"够用、好实现、不出错"。它的报文结构简单到可以用一张A4纸写完,从机逻辑可以在任何一颗8位单片机上跑起来,连STC89C52这种老古董都能轻松应付。这种极低的实现门槛,决定了它在工业设备、传感器、PLC、仪表、电力监控等领域拥有极其恐怖的存量市场。
另一个现实原因是:MODBUS的调试工具链非常成熟。Modbus Poll、Modbus Slave、串口调试助手,随便一抓一大把,网上教程遍地都是。相比CAN协议需要逻辑分析仪、需要了解总线仲裁、需要处理各种错误帧,MODBUS的调试体验简直可以用"轻松"来形容。你只需要一根USB转485的线,打开串口助手,就能把通信过程看得明明白白。
这篇笔记我打算从协议本身讲起,先把帧格式和寄存器模型掰开揉碎,再聊聊我在实际项目中遇到的坑和一些调试技巧。适合正在做嵌入式通信开发、准备接工业设备的读者参考,也适合那些对MODBUS只有模糊概念、想系统理一遍的初学者。学到的东西不是纸上谈兵,都是我在真实项目里验证过的经验。
2. MODBUS协议的核心:寄存器模型和功能码体系
2.1 四种数据对象:线圈、离散输入、保持寄存器、输入寄存器
MODBUS协议里最容易被新手绕晕的,就是这四种数据对象。很多人背了概念不会用,到了实际写代码的时候还是分不清哪个地址对应哪个数据。我换个角度帮大家理解:这四种对象本质上就是把设备内部的数据分成了"可读可写"和"只读"两大类,每一类再按"位数据"和"字数据"拆开。
看这张表应该就清楚了:
| 数据对象 | 位/字 | 读写属性 | 常用场景 | 地址范围(PLC惯例) |
|---|---|---|---|---|
| 线圈 | 位 | 可读可写 | 继电器输出、开关控制 | 000001-065536 |
| 离散输入 | 位 | 只读 | 按钮状态、限位开关 | 100001-165536 |
| 保持寄存器 | 字 | 可读可写 | 设定参数、PID目标值 | 400001-465536 |
| 输入寄存器 | 字 | 只读 | 采集到的温度、压力、电流 | 300001-365536 |
这里有个关键点:MODBUS协议本身的数据地址是从0开始的,比如"保持寄存器地址0"对应PLC惯例里的400001。很多国产仪表说明书上写的"寄存器地址40001",翻译过来就是"协议地址0"。如果直接拿说明书上的40001去发报文,主站会认为你要访问地址40001,那绝对会报非法地址错误。
我在实际项目里处理这个问题的办法是:做协议栈的时候,对外统一用"功能码+协议地址"的形式,说明书上的地址编号只是给人看的,代码里一定要做一次映射。比如说明书上写"温度寄存器地址=40001",那在代码里就是:
#define REG_TEMPERATURE_ADDR 0x0000 /* 对应说明书40001 */ #define REG_HUMIDITY_ADDR 0x0001 /* 对应说明书40002 */2.2 常用功能码:什么时候用哪个
功能码是MODBUS报文里的"操作指令",告诉从机"我要干什么"。虽然协议里定义了几十个功能码,但日常开发真正用到的就那几个:
- 0x01:读线圈,一次可以读多个连续的线圈状态
- 0x02:读离散输入
- 0x03:读保持寄存器,最常用,读参数、读状态都靠它
- 0x04:读输入寄存器,常用在读实时采集数据
- 0x05:写单个线圈,控制单路开关
- 0x06:写单个寄存器,修改单个参数
- 0x0F:写多个线圈,批量控制
- 0x10:写多个寄存器,批量修改参数
我自己的习惯是:凡是"测量值"(温度、电压、电流)统统放输入寄存器,用04功能码读;凡是"设定值"(报警阈值、控制目标)统统放保持寄存器,用03读、06或10写。这样做的好处是区分清晰,主站一看功能码就知道要操作的是测量值还是设定值,不容易搞混。
2.3 帧格式拆解:RTU模式下的每个字节都有讲究
MODBUS RTU的帧格式不复杂,但每个字段都值得注意。我以一个实际报文为例拆开讲。
从机地址:就是一个字节,范围1-247,0是广播地址,248-255是保留地址。一个RS485总线上可以挂多个从机,每个从机分配一个唯一地址,主站发出请求时带目标从机的地址,只有地址匹配的从机才会响应。
功能码:一个字节,告诉从机执行什么操作,上面已经列过。
数据区:长度不定,具体格式由功能码决定。比如功能码03读保持寄存器,数据区就是"起始地址2字节 + 寄存器数量2字节";功能码06写单个寄存器,数据区就是"寄存器地址2字节 + 寄存器值2字节"。
CRC校验:两个字节,从从机地址开始到数据区结束,整段做CRC16计算。CRC算法是MODBUS专用的多项式0x8005,初始值0xFFFF。这个校验很重要,RS485链路上干扰多了,没有CRC基本就是废的。
我贴一段CRC16的C语言实现,这是标准MODBUS算法,直接抄就能用:
uint16_t mb_crc16(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; uint16_t i; for (uint16_t n = 0; n < len; n++) { crc ^= data[n]; for (i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }注意一点:发送时CRC是低字节在前,比如计算出的CRC是0x1234,发送顺序是0x34、0x12。很多新手栽在这个字节序上,用串口助手对比报文的时候怎么看都不对,其实就是高低字节顺序搞反了。
2.4 RTU、ASCII、TCP三种传输模式的取舍
MODBUS有三种传输模式:RTU、ASCII、TCP,很多人搞不清区别。
RTU模式用二进制传输,报文紧凑,效率高,是工业现场的主流选择。它的帧间隔要求比较严格:一帧数据内部字节间隔不能超过1.5个字符时间,整帧接收完毕后需要等待3.5个字符时间才能开始下一帧。这些时间参数由波特率决定,波特率9600时3.5个字符时间大概是4毫秒左右。如果主站发的报文字节之间间隔太长,从机就会认为这是两帧数据,直接丢弃。
ASCII模式是把每个字节拆成两个十六进制字符发送,报文膨胀一倍,但好处是对时序要求宽松,帧头帧尾用冒号和回车换行标识,调试起来眼睛看报文比较容易。现在用得很少了,除非是和老式的PLC通信才会碰到。
TCP模式就是把MODBUS报文封装在TCP/IP里,去掉了CRC校验(因为TCP本身有校验),报文通过端口502传输。它的格式比RTU多了一个MBAP头,占了7个字节,包含事务处理标识、协议标识、长度和单元标识。TCP模式的好处是可以跨设备跨网络,很多远程监控系统用的就是MODBUS TCP,一台边缘网关采集传感器数据,然后通过网口转发到上位机。
我在实际项目中三种模式都玩过,RTU用得最多,TCP次之,ASCII基本是偶尔调试老设备才会碰到。如果你在做一个新设备,建议优先支持RTU,因为现场接线方便、抗干扰能力也强。
3. 物理层和通信参数:RS232、RS485和波特率设置的经验
MODBUS协议是应用层协议,它能跑在串口上,也能跑在以太网上。但嵌入式设备用到最多的还是RS232和RS485这两种物理层。
RS232是全双工,发送和接收各用一根线,只能点对点通信,距离一般也就15米左右。PC上的COM口大部分是RS232电平,但很多开发板上的串口是TTL电平,直接接电脑需要转换器。RS485是半双工,靠两根差分线(A和B)传输,支持最多挂32个节点(标准负载下),距离可以到1200米,工业现场几乎全是RS485。
这两者有个非常重要的区别:RS232全双工,收发互不干扰;RS485半双工,同一时刻只能发送或者只能接收,必须切换方向。这个切换由谁来做?一般是通过控制DE/RE引脚的电平来实现。很多用RS485收发器(比如SP3485、MAX485)的工程师会在这个切换上踩坑——发送完数据后没有及时把方向切回接收,导致收不到从机的响应。
我的习惯是:发送数据之前先拉高DE,把数据发完,然后延时一段时间(一般1个字符时间就够了),再拉低DE切回接收。这个延时很关键,如果没有任何延时马上切回接收,最后一个字节可能还没发完就被掐断了。
波特率设置也有讲究。9600和115200是工业现场最常用的两个波特率,但有些老设备只支持2400、4800这种低速。通信双方波特率必须一致,否则收到的数据全是乱码。还有个容易被忽略的点:校验位和停止位。MODBUS RTU默认是8位数据位、无校验、1位停止位(8N1),但有的设备会配置成偶校验,调试之前一定要确认清楚。我碰到过一个客户现场,从机是8E1,主站却按8N1配置,结果通信时好时坏,排查了半天才发现是校验位不匹配。
总结一下常用的参数组合:
- 8N1 + 9600:最保守的组合,基本所有设备都支持
- 8N1 + 115200:传输速度快,适合数据量大、距离短的场合
- 8E1 + 9600:偶尔碰到,多见于西门子PLC配套的仪表
- 8N2 + 9600:老式设备偶尔用
参数不对,协议栈写得再漂亮也是白搭。我见过不少同事一上来就查代码逻辑,折腾大半天,最后发现就是串口参数没配对。
4. 调试MODBUS的完整工具链:从Modbus Poll到串口助手的正确用法
4.1 工具选型:主站模拟器和从站模拟器要搭配使用
调试MODBUS最常用的两个工具是Modbus Poll和Modbus Slave,这俩都是商业软件,功能强大,网上能找到试用版。Modbus Poll模拟主站,主动发起请求;Modbus Slave模拟从站,响应主站请求。
这两个工具是配套使用的:当你手上只有一个从站设备的时候,用Modbus Poll去读它,验证设备响应是否正确;当你手上只有一个主站软件(比如上位机组态软件)的时候,用Modbus Slave模拟一个从站,验证主站配置是否正确。我有一次帮朋友排查一个触摸屏连仪表的问题,仪表还没来得及送到现场,我就用Modbus Slave模拟了仪表的寄存器数据,把触摸屏的通信配置、地址映射全部验证了一遍,等仪表到了直接接上去就通了。
如果不想用商业软件,还有一个开源选择:QModMaster。这是源码开放的MODBUS主站模拟器,支持RTU和TCP,界面虽然朴素但功能完整。另外串口调试助手也是必备工具,像sscom这类免费的就行,主要用来抓原始报文、验证CRC、排查物理层问题。
4.2 Modbus Poll的使用要点:轮询周期和从站地址别搞错
在Modbus Poll里新建一个连接,无非是选串口、选RTU/TCP模式、填波特率和从站地址(Slave ID)。有一个高频问题:很多人把Slave ID填成IP地址,在TCP模式下填成了192.168.1.10这种格式,结果连接永远失败。TCP模式下从站地址是"单元标识符",一般填1就行,IP地址是在"Remote Server"里的"IP Address"字段填的。
连接配置好之后,就要配置读取的寄存器范围。双击某个单元格,会弹出一个对话框,里面要填"起始地址"和"长度"。这里又是个坑:地址填的是协议地址,不是PLC地址。比如要读保持寄存器40001,起始地址要填0;要读40002,就填1。你要是直接填40001,主站发过去的请求会变成访问地址40001,从机绝对会回一个"非法数据地址"异常。
轮询周期(Poll Rate)我一般设成500ms或者1000ms就够了,设得太快会给从机造成压力,有些从机处理慢的还会丢掉响应。测试的时候可以先手动单次读取,确认没有问题了再开启循环轮询。
4.3 用Modbus Slave模拟从站:把从站逻辑说清楚
Modbus Slave的操作逻辑和Poll差不多:选择RTU或TCP模式,配置串口参数,然后设置寄存器区,往对应的地址里填你想要的数据。它会把收到的请求和发出的响应都显示在日志窗口里,这是调试时最有用的信息。
模拟从站时要注意:从站软件响应主站请求的速度比较快,几乎是无延迟的。但真实设备的从机固件运行时,从收到请求到发出响应需要一定的处理时间,通常在5-100毫秒之间。所以用模拟从站调试主站时,如果发现主站有超时重发,多半是主站的超时时间设置得太短了,在真实设备上会更严重。调试时建议把主站的超时时间设置成500毫秒以上,给自己多一点余量。
4.4 串口助手的正确用法:抓帧而不是靠猜
串口助手最核心的用途是抓原始报文。把USB转485接到总线上,串口助手设置为和通信双方一样的波特率,选择HEX显示,你就能看到主站发出的请求和从站返回的响应,像这样:
发送: 01 03 00 00 00 02 C4 0B 接收: 01 03 04 00 64 00 C8 7A 35第一行是主站发给从站1的请求,功能码03,起始地址0,读2个寄存器,CRC是C4 0B。第二行是从站1的响应,功能码03,后面跟4个字节数据,0x0064是100,0x00C8是200,最后两个字节是CRC。
看到这帧报文,你要能立刻判断出这个从站的响应是否正常。如果从站返回的是:
01 83 02 C0 F1其中0x83是0x03功能码加0x80,最高位置1表示返回的是异常响应。0x02是异常码,代表"非法数据地址"。这说明你请求的地址不在从站支持的范围内,检查一下地址映射。
串口助手还有一个很多人不知道的用法:作为"偷听"工具。总线上主站和从站在正常通信的时候,把串口助手并联上去(RS485是总线结构,直接挂上去就行),就能看到总线上所有的报文而不影响正常通信。这比接在主站和从站之间方便多了,尤其适合排查"主站为什么收不到从站响应"这种问题——先确认从站有没有发出响应,如果从站根本没响应,问题就在从站侧;如果从站有响应但主站收不到,那就要查方向切换、接线、A/B是否接反了。
5. 从零实现一个MODBUS RTU从机:寄存器映射和状态机的设计思路
工具用顺手了,接下来聊聊正经开发。用STM32F103这种芯片实现一个MODBUS RTU从机,是嵌入式工程师很典型的需求。我从寄存器映射、串口接收状态机、请求处理和响应发送几个方面展开,分享一套可以直接抄作业的设计思路。
5.1 寄存器表:用结构体把散落的变量组织起来
设备里有很多变量:温度、湿度、开关状态、报警阈值、设备地址、波特率等。这些变量最好是集中管理,而不是散落在各个模块里。我的做法是定义一个寄存器表结构体,每个成员对应一个寄存器:
typedef struct { uint16_t temperature; /* 输入寄存器0,只读 */ uint16_t humidity; /* 输入寄存器1,只读 */ uint16_t switch_state; /* 线圈0,可读可写 */ uint16_t alarm_threshold; /* 保持寄存器0,可读可写 */ uint16_t slave_addr; /* 保持寄存器1,可读可写 */ } modbus_regs_t; modbus_regs_t g_regs;然后根据MODBUS功能码,把寄存器地址映射到结构体成员。比如功能码03读保持寄存器,地址0对应g_regs.alarm_threshold,地址1对应g_regs.slave_addr。如果是功能码04读输入寄存器,地址0对应g_regs.temperature。
寄存器映射表可以用一个数组实现,这样代码写起来干净不少:
static const uint16_t *const holding_regs[REG_HOLDING_COUNT] = { &g_regs.alarm_threshold, &g_regs.slave_addr, }; uint16_t read_holding_reg(uint16_t addr) { if (addr < REG_HOLDING_COUNT) { return *holding_regs[addr]; } return 0xFFFF; /* 非法地址 */ }读写函数都走这张表,要增加寄存器的时候,只需要扩充数组和结构体,不需要改动核心的协议处理逻辑。这个设计在项目迭代中能省不少事。
5.2 串口接收状态机:判断帧完成比解析更重要
接收一帧完整的MODBUS RTU报文,最忌讳的是"收到一个字节就处理一个字节"。为什么?RTU的帧没有帧头帧尾,从机判断一帧是否接收完毕,靠的是时间间隔:接收到一个字节后,如果在3.5个字符时间内没有收到下一个字节,就认为这一帧结束了。
所以正确的做法是用状态机管理接收过程:
typedef enum { MB_IDLE, MB_RECEIVING, } mb_rx_state_t; void uart_rx_isr(uint8_t byte) { if (mb_rx_state == MB_IDLE) { /* 第一个字节到达,启动帧超时定时器 */ mb_rx_len = 0; mb_rx_buffer[mb_rx_len++] = byte; mb_rx_state = MB_RECEIVING; start_frame_timer(3.5_CHAR_TIME); } else if (mb_rx_state == MB_RECEIVING) { /* 后续字节到达,重置帧超时定时器 */ mb_rx_buffer[mb_rx_len++] = byte; if (mb_rx_len >= MB_BUFFER_SIZE) { mb_rx_state = MB_IDLE; /* 溢出,丢弃 */ } else { restart_frame_timer(); } } }帧超时定时器溢出时,就把mb_rx_state切回MB_IDLE,同时置一个"帧接收完成"标志,主循环检测到这个标志就调用MODBUS解析函数。
这个定时器的时长怎么算?3.5个字符时间=3.5 x 11比特时间(1个起始位+8个数据位+1个停止位+可选校验位)。波特率9600时,11比特时间是1.145毫秒,3.5个字符时间约4毫秒。波特率越高,这个时间越短,所以高速通信时对定时器精度要求更高。我用TIM定时器做这个帧超时,配置成1毫秒中断一次,9600波特率时超时设为4次中断;115200波特率时设为1次中断就够了。
5.3 请求处理和异常响应:从机的"礼貌"也很重要
收到一帧完整报文后,按顺序校验:从站地址是否匹配、CRC是否正确。这些都通过后,再根据功能码分发给对应的处理函数。
处理函数要做三件事:解析请求参数、执行操作、构造响应报文。任何一个环节出问题,都要回一个异常响应。MODBUS的异常响应格式是:从机地址 + (功能码|0x80) + 异常码 + CRC。常见的异常码有三个:
- 0x01:非法功能码
- 0x02:非法数据地址
- 0x03:非法数据值
注意:不是所有错误都要回异常。CRC校验失败,直接丢弃,不要回任何东西。为什么?因为CRC都错了,根本不知道这帧数据是谁发的、发给谁的,贸然回响应只会增加总线冲突。
请求处理里有个细节:从机对广播地址(0)做出响应吗?协议规定广播请求从机要执行操作但不回响应。所以写代码的时候要加个判断:地址为0时,只处理请求不发响应;地址等于本地地址时,处理并回响应;其他情况一律忽略。
5.4 发送响应:RS485方向切换的时序问题
前面提到RS485半双工的方向切换问题,这里再强调一次。发送之前先把DE引脚置高,然后通过串口发送数据,发送完毕之后,要等最后一位真正发送完成(可以查询USART的TC标志位),再延时一个短暂时间,最后把DE拉低。
为什么不能发完立即拉低?因为串口的发送寄存器是带缓存的,你把数据写入DR寄存器并不代表已经发送完成,只是挪到了移位寄存器。如果马上拉低DE,最后一个字节可能只发了一半。正确做法是等待发送完成标志,加上小延时,确保最后一帧已经完整输出。
void uart_send_frame(const uint8_t *data, uint16_t len) { RS485_DE_HIGH(); for (uint16_t i = 0; i < len; i++) { while (!(USART1->SR & USART_SR_TXE)); USART1->DR = data[i]; } while (!(USART1->SR & USART_SR_TC)); delay_us(50); RS485_DE_LOW(); }这个延时50微秒在9600波特率下够用吗?一个字节要发约1.145毫秒,50微秒足够保证最后一个字节已经在物理线上传输完成。如果总线负载重,可以适当加大到100-200微秒。
6. 调试实战:三个真实故障的完整排查链路
6.1 故障一:从站能收到请求但响应总是收不到
现象:用Modbus Poll读一个带RS485接口的温湿度传感器,Poll软件的收发指示灯一闪一闪的,但数据区始终显示超时错误。
排查过程:
第一步,先用串口助手并联到总线上抓帧。结果发现主站的请求帧发送正常,从站也确实响应了一帧数据,但响应帧后面半截被截断了。
第二步,用逻辑分析仪看波形。发现从站响应帧的最后一个字节只发了一半,数据线就被拉回了空闲电平。问题出在从机固件的RS485方向切换上,DE引脚切回接收太早了。
第三步,定位到代码。从机在发送完数据后,没有等待串口TC标志位就直接把DE拉低。修改为等待TC标志后再拉低DE,问题解决。
这里有个经验:RS485从站如果响应帧被截断,90%以上的原因就是方向切换时序不对。从站是半双工总线上响应的发起点,它的一切发送控制都要以"串口真正发完"为基准。
6.2 故障二:CRC校验值看起来对,但PLC就是不认
现象:一个设备用MODBUS RTU和PLC通信,PLC偶尔能读到数据,但大部分时间报超时。用串口助手抓帧,发现从站响应的报文格式、地址、功能码都正确,CRC的值看起来也算得对,可PLC就是接收不正常。
排查过程:
第一步,用Modbus Poll去读从站,结果也是偶发成功。奇怪的是,软件日志上显示的从站响应报文,和我用CRC计算工具算出来的结果完全一致,所以CRC本身没问题。
第二步,怀疑是字节间隔问题。用逻辑分析仪抓波形,逐字节比对时间间隔,发现从站的响应帧内部,某些字节之间的间隔超过了1.5个字符时间。MCU在处理别的中断时,串口发送被延迟了几个毫秒。
第三步,问题的根因是在从站的发送流程中,每发完一个字节,发送函数就被一个耗时的传感器采集任务抢占了CPU,导致字节间隔过大。主站按照RTU协议规则,把这帧响应判定为"超时中断",直接丢弃。
解决:把发送函数放到中断里做,先把整个响应帧通过DMA发送出去,发送期间禁止高优先级任务抢占;或者把传感器采集任务挪到响应发送完成之后再执行。我最终采用了DMA发送方式,从此再没出现过这个问题。
这个故障提醒我们:写RTU协议栈时,必须保证"发送过程不被打断"。如果你用查询方式逐字节发送,OS任务调度、定时器中断都可能导致字节间隔离散。DMA是最可靠的选择。
6.3 故障三:两台设备地址相同,总线上数据疯狂错乱
现象:一套系统里挂了三台表,1号、2号、3号地址都配置好了,但运行一段时间后,3号表会突然掉线,重新上电又正常。
排查过程:
第一步,抓总线报文。发现系统运行中总线上偶尔会出现两个从站同时响应的情况,导致总线数据冲突,主站收到的是一堆乱码。
第二步,分析冲突的特征。冲突只发生在读取某个特定型号的仪表时,手动读取相同时刻不会冲突,但当主站广播报文(地址0)轮询时,1号和3号表的响应帧会撞在一起。
第三步,查1号表的地址配置,发现它的地址是0。原来1号表掉电后寄存器数据丢失,地址恢复成了出厂默认值0,而0是广播地址。广播请求一发出,作为地址0从站的1号表响应了;而3号表正常响应广播请求(广播请求不响应,但地址0也是从机地址),于是两帧响应急撞。
解决过程:把所有从机的地址配置做成掉电不丢失,存到EEPROM里,每次上电从EEPROM加载;同时更新主机逻辑,禁止把从机地址配成0。
这个故障的深层教训是:MODBUS的地址0是广播地址,不能分配给任何从机。但在实际项目里,有些设备出厂默认就是从机地址0,接入总线前必须改成1-247之间的有效值。我后来做设备协议栈的时候,把地址0也当成合法的"本地地址"来处理响应,唯独不响应广播请求中的写操作(因为广播的写操作不需要回执)。
7. 从机移植经验:裸机方案和FreeModbus的取舍
7.1 是手写还是移植:一个工程师的现实选择
实现MODBUS从机,有两条路:手写一个轻量协议栈,或者移植FreeModbus这种现成开源实现。我两个方案都干过,结论是:看项目复杂度。
如果设备只有几个寄存器,没有复杂的寄存器映射需求,主站也不会高频轮询,手写系统往往更合适。裸机环境下,一个串口中断、一个定时器、一个解析函数就够用了,整个代码量不超过300行,出问题也好排查。
FreeModbus则适合寄存器数量多、数据类型复杂、需要支持多个功能码等场景。它把协议解析、异常处理、CRC校验、帧超时处理这些事都给你做了,还提供了一些回调接口,你要做的就是实现寄存器读写回调函数。
以STM32F103标准库移植FreeModbus v1.6为例,大致过程是:
- 把FreeModbus的源码目录拷贝进工程,包括port文件夹和mb文件夹。
- 实现串口相关接口:xMBPortSerialInit用USART1初始化,vMBPortSerialEnable控制收发使能,xMBPortSerialPutByte/xMBPortSerialGetByte读写单个字节。
- 实现定时器接口:xMBPortTimersInit给TIM2初始化,vMBPortTimersEnable开帧超时定时器,vMBPortTimersDisable关定时器。
- 实现事件管理接口:xMBPortEventInit/vMBPortEventPost/vMBPortEventGet,实际上就是用一个FIFO队列在中断和主循环之间传递MODBUS事件。
- 在USART接收中断里调用prvvUARTRxISR,发送完成中断里调用prvvUARTTxReadyISR,定时器溢出中断里调用prvvTIMERExpiredISR。
- 主函数里初始化MB,调用eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE),最后调用eMBEnable()。
- 编写寄存器读写回调:eMBRegHoldingCB、eMBRegInputCB、eMBRegCoilsCB、eMBRegDiscreteCB。
FreeModbus的好处是稳定,毕竟被无数个项目验证过;缺点是你要花时间理解它的状态机。移植起来细节很多,比如串口收到第一个字节后要立即启动帧超时定时器,这个定时器是3.5字符时间,但用2.5字符时间也不会出大问题——我们对时序宽容一点,免得在临界情况上来回跳变。
7.2 寄存器回调函数里的常见错误:阻塞、越界、读写混用
移植FreeModbus时最容易出错的点是回调函数。我以eMBRegHoldingCB为例,说说常见问题。
这个回调函数的原型是:
eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode);其中usAddress是主机请求的寄存器地址偏移量,usNRegs是请求的寄存器个数,eMode是MB_REG_READ还是MB_REG_WRITE。如果你把寄存器数组定义成holding_regs[16],回调里如果不检查usAddress+usNRegs是否大于16,就会数组越界。主机非法请求可以轻松让你的MCU跑飞。
我的实现里有个习惯:回调开头先做合法性检查,非法就返回MB_ENOREG。这样主机发一个超出范围的请求,从机就回一个"非法数据地址"异常,而不是死机。
还有一个坑:eMode判断用错。有的新人实现的时候忘了判断读写模式,读和写操作混用同一个分支,结果主机写参数的时候,代码在读数组,把地址翻译得乱七八糟。写成两个分支逻辑清晰得多:
if (eMode == MB_REG_WRITE) { /* 把pucRegBuffer里的数据写入寄存器数组 */ for (i = 0; i < usNRegs; i++) { regs[usAddress + i] = (pucRegBuffer[i * 2] << 8) | pucRegBuffer[i * 2 + 1]; } } else { /* 把寄存器数组值填入pucRegBuffer传给协议栈 */ for (i = 0; i < usNRegs; i++) { pucRegBuffer[i * 2] = (regs[usAddress + i] >> 8) & 0xFF; pucRegBuffer[i * 2 + 1] = regs[usAddress + i] & 0xFF; } }注意MODBUS的大端字节序:寄存器值高字节在前。很多从ARM转过来的工程师习惯了小端,在这个地方容易漏掉高低字节交换,导致上位机读到的数据"数字是对的但值大了几百倍"。
8. 报文分析进阶:从字节流中快速定位问题
8.1 手工造帧和手工验帧,是调试的基本功
虽然工具有自动校验,但手工造帧和验帧的能力,我认为是每个做MODBUS开发的人都要掌握的。它能帮你快速判断一个问题是在物理层、数据层还是应用层。
举个例子,我要读从站地址1的2个保持寄存器,起始地址0:
请求帧手工构成:
- 从站地址:01
- 功能码:03
- 起始地址:0000(高字节在前)
- 寄存器数量:0002
- 未包含CRC,先算CRC16:对01 03 00 00 00 02这5个字节算CRC,得到C40B,低字节在前发送0B C4。
最终发送:01 03 00 00 00 02 0B C4(有的资料写作C4 0B是存储顺序,发送顺序是0B C4,这个细节要注意区分)。
响应帧解析:01 03 04 00 64 00 C8 7A 35
- 01:从站地址
- 03:功能码03
- 04:后续数据长度4字节
- 00 64:第一个寄存器值,=100
- 00 C8:第二个寄存器值,=200
- 7A 35:CRC
手工验CRC的方法:把01 03 04 00 64 00 C8这7个字节用CRC算法重算一遍,如果结果等于7A 35,帧就是完整的。如果CRC不匹配,说明这帧在传输过程中被干扰了,从机正确的做法是直接丢弃。
8.2 功能码与数据格式的常见坑:32位浮点数怎么读
很多设备的寄存器里存的不是16位整数,而是32位浮点数。MODBUS标准里没有明确规定32位浮点数的字节序,这就导致不同厂家的设备之间经常打架。
常见的格式有四种:
- 大端模式:寄存器顺序ABCD,每个寄存器内高字节在前
- 小端模式:寄存器顺序CDAB,每个寄存器内低字节在前
- 字序交换:寄存器顺序CDAB,每个寄存器内高字节在前
- 字节序交换:寄存器顺序BADC
如果主站和从站字节序不一致,读出来的浮点数就会是天文数字,或者特别小。我碰到过一个案例,上位机读到的温度值是-107374176.0,后来才发现是主站用了小端解析,从站存的是大端。
解决办法是:阅读设备手册时,专门找"数据格式"这一节。大多数主流设备遵循"大端模式",即寄存器内高字节在前,寄存器顺序也是高字在前。如果确实不一致,只能写一个字节序转换函数,按手册的规则解析。
这里也顺带分享一个小工具:Modbus Poll有专门的Data Format设置,Byte Order可以切换"Big Endian"和"Little Endian",Word Order可以切换"High Word First"和"Low Word First"。调试有浮点数显示的从站设备时,先试最常用的"Big Endian + High Word First",不对就换其他组合,基本一次能试出来。
8.3 用轮询周期与超时设置规避干扰问题
总线上偶尔出现一帧乱码不可怕,可怕的是主站超时时间设得太短,导致正常的数据还没收完就被判定为超时,触发重发,反而加重了总线负载。
我的推荐配置是:主站响应超时设置为200-300毫秒,轮询周期500-1000毫秒。这样即使总线上出现偶发错误帧,主站还有充足的时间等待从站重发。如果从站响应本来就慢(比如内部有EEPROM写操作或者ADC采集),超时时间要适当加大到1秒。
另外,RTU模式有个特殊机制叫"静默间隔":主站在发送请求之前,要保证总线空闲了至少3.5个字符时间。如果主站不问青红皂白连发两帧,从机就会把它们合并成一帧,然后解析失败。在轮询循环里,建议发送完一帧之后,至少等一帧完整的响应后再发下一帧,不要做一个"抢话"的主站。
9. 走向工程化的几个进阶建议
协议能跑通是第一步,要做到设备能长期稳定运行在现场,还需要在一些细节上多花心思。
第一,寄存器读写操作加互斥。如果你在裸机上写代码,主循环在解析MODBUS请求的同时,ADC采集模块也在更新温度寄存器,就会存在数据竞争。解决办法是:所有寄存器读写操作关中断或加临界区保护。FreeModbus的做法是eMBPoll会在主循环中逐个事件处理,天然避免了重入,如果你手写协议栈,就要自己注意这个点。
第二,设备参数掉电保存。从机地址、波特率、校准值这些关键参数,不能只存在RAM里,不然断电就丢。我一般用EEPROM存储参数,每次上电读取,如果读到0xFF或不在合法范围内,就恢复默认值。同时支持通过功能码06或10写入这些寄存器时,自动保存到EEPROM。
void save_params_to_eeprom(void) { uint8_t buf[PARAM_SIZE]; uint16_t idx = 0; buf[idx++] = g_regs.slave_addr & 0xFF; buf[idx++] = g_regs.baudrate_hi & 0xFF; buf[idx++] = g_regs.baudrate_lo & 0xFF; eeprom_write_bytes(PARAM_OFFSET, buf, idx); } void load_params_from_eeprom(void) { uint8_t buf[PARAM_SIZE]; uint16_t idx = 0; eeprom_read_bytes(PARAM_OFFSET, buf, sizeof(buf)); if (buf[idx] >= 1 && buf[idx] <= 247) { g_regs.slave_addr = buf[idx]; } else { g_regs.slave_addr = 1; } idx++; /* 读取波特率参数,非法则恢复默认 */ }第三,从机要记录通信状态。我在协议栈里加了一个"通信计数器":每收到一帧CRC正确的请求就加1。现场排查问题时,这个计数器可以快速判断"从站到底有没有收到过合法请求"。有些设备上还加了空闲超时判断:如果超过10秒没有收到任何请求,就自动退出某种配置模式,防止设备一直停在调试模式里出不来。
第四,全链路测试。设备出厂前建议用真实的Modbus主站软件连续轮询几百个小时,最好在高温或高湿度环境下做老化测试。我见过一个项目,设备在常温下通信一切正常,进了高温箱后每隔几十分钟就掉一次线,最后定位到是主控芯片内部基准电压漂移导致串口接收误码率上升。这种问题在实验室里常规测试根本发现不了,只能靠长时间压力测试逼出来。
10. 最后再分享两个我踩过的坑
兜兜转转聊了一大堆,最后说两个我在实际项目中记忆最深的细节,都不涉及复杂原理,纯粹是经验教训。
第一个是示波器测RS485波形的时候,A、B两个探头正反接错了。RS485的A对的是差分信号的同相端,B对的是反相端,从设备说明书上看清楚A和B的定义,别想当然认为"A就是A"。有些设备的A、B定义和常规是反的,用示波器看波形时差分信号反相,主站就收不到任何数据。电源和地的共地问题也要注意,USB转485模块和被测设备之间如果不共地,很容易损坏接口芯片。
第二个是主机软件里设置的"读超时"时间不要小于从机的处理时间。看起来这是常识,但在实际项目里就是有人会把超时设成50毫秒。从机收到请求后要在50毫秒内完成寄存器读取、组包、发送,其中还涉及I2C读取传感器数据、Flash读取等可能产生几十毫秒延迟的操作。把超时时间放宽到500毫秒,通信稳定性立刻上一个台阶。
MODBUS这个协议,说简单确实简单,但要在真实工业现场稳定跑起来,需要考虑的细节一点也不少。希望这篇笔记能给正在做或准备做MODBUS开发的朋友一些帮助,少走几步弯路。