🎖️Modbus简易不简单的教程
文章目录
- 🎖️Modbus简易不简单的教程
- 🎫一、简介
- 1.1 Modbus:工业通信的革命
- 1.2 理解标准化通信
- 1.3 Modbus协议的变体
- 🎀二、例程引入
- 2.1 示例:使用01功能码读取灯的开关状态
- 2.2 校验和的重要性
- 🎏三、概念解析
- 3.1 ADU & PDU
- 3.2 什么是线圈
- 3.3 OSI七层协议
- 3.4 Modbus与485
- ⭐四、指令解析
- 4.1 功能码划分
- 自定义功能码范围
- 4.2 理解0x01命令
- 4.2.1 例子:灯、温度计和窗帘
- 4.2.2 使用功能码 0x01 读取状态
- 4.2.3 响应
- 4.2.4 响应实例
- 4.3 理解0x02指令
- 4.3.1 什么是离散输入
- 4.3.2 例子:灯、温度计和窗帘
- 4.3.3 使用功能码 0x02 读取状态
- 4.3.4 响应
- 4.3.5 响应实例
- 4.4 理解0x03指令
- 4.4.1 智能家居系统中的应用
- 4.4.2 使用功能码 0x03 读取数据
- 4.4.3 响应实例
- 4.5 理解0x04指令
- 4.5.1 保持和输入寄存器的区分
- 4.5.2 智能家居系统中的输入寄存器
- 4.5.3 使用功能码 0x04 读取数据
- 4.5.4 响应实例
- 4.6 理解指令0x05
- 4.6.1 智能家居系统中的线圈
- 4.6.2 使用功能码 0x05 更改状态
- 4.6.3 响应实例
- 4.7 理解指令0x06
- 4.7.1 智能家居系统中的保持寄存器
- 4.7.2 使用功能码 0x06更改设置
- 4.7.3 响应实例
- 🐖五、常用功能码及其数据包格式
- 5.1 读取线圈状态(功能码 0x01)
- 5.2 读取离散输入状态(功能码 0x02)
- 5.3 读取保持寄存器(功能码 0x03)
- 5.4 读取输入寄存器(功能码 0x04)
- 5.5 写单个线圈(功能码 0x05)
- 5.6 写单个寄存器(功能码 0x06)
- 🐗六、自定义指令
- 6.1 请求数据包格式
- 6.3 响应数据包格式
- 6.3 示例
- 🐭七、异常响应
- 7.1 异常的概念
- 7.2 异常码列表
- 🥏八、编程示例
- 8.1 发送端
- 8.1.1 服务函数
- 8.1.2 使用示例
- 8.2 接收端
- 8.3 整理流程
- 🏈九、实战
- 9.1 设备准备
- 9.2 fire
- 🦇十、CRC的计算
- 10.1 自学指引
- 10.2 编程示例
- 10.3 小端发送
- 尾声
- 日志
🎫一、简介
1.1 Modbus:工业通信的革命
在探索工业自动化和数据通信的世界时,了解Modbus协议是不可或缺的。作为最经典的工业通信标准之一,Modbus自1979年以来,一直是连接各种电子设备的关键。
Modbus的诞生背景
在20世纪70年代,随着工业自动化的兴起,出现了对可靠和标准化数据交换方法的迫切需求。那时,不同厂商的设备间缺乏一种统一的、简单易用的通信协议。这不仅增加了系统集成的复杂性,也限制了设备间的互操作性。正是在这样的背景下,Modicon公司开发了Modbus协议,旨在简化工业设备之间的通信,提高其灵活性和效率。
为何制定Modbus
Modbus的制定主要是为了解决以下几个核心问题:
- 标准化通信:在众多设备和控制系统之间建立一个通用的语言,使不同厂商的设备能够轻松沟通。
- 简易性与可靠性:提供一个简单而可靠的方法来传输数据,无论是简单的开关信号还是复杂的数据结构。
- 灵活性和可扩展性:允许系统根据需要轻松扩展或修改,无需更换整个系统。
Modbus带来的好处
采用Modbus协议带来的好处包括:
- 降低成本:通过标准化通信减少了对专有系统的依赖,从而降低了成本。
- 提高互操作性:不同设备之间的无缝沟通大大提高了系统的整体效率和性能。
- 简化系统集成:Modbus的简单性使得集成多种设备变得更加容易,加快了项目的实施速度。
1.2 理解标准化通信
想象一下,如果在一个工业设施中,每个设备制造商都使用自己独特的通信协议。设备A使用“协议A”,而设备B使用“协议B”。这两种协议在数据格式、速率、错误处理等方面都不相同。现在,考虑一下以下几个问题:
- 系统集成:如果您需要让设备A和设备B相互通信,您将不得不开发一个转换器,将“协议A”转换为“协议B”,反之亦然。这不仅增加了复杂性,还可能导致数据传输效率降低。
- 扩展性和维护:随着系统扩展,引入更多使用不同协议的设备,您的转换器需要不断更新和维护,以适应新设备。这会导致维护成本和时间的大幅增加。
- 错误和兼容性问题:每次数据从一个协议转换到另一个协议时,都有可能出现错误。此外,某些协议可能无法完全兼容,导致信息丢失或错误解读。
现在,让我们引入Modbus协议作为这个场景中的解决方案。由于Modbus是一个广泛接受和使用的标准,大多数设备制造商都支持它。这意味着:
- 简化的系统集成:所有设备都使用相同的Modbus协议,意味着它们可以直接相互通信,无需任何转换器。
- 易于扩展和维护:由于所有设备遵循同一标准,添加新设备变得简单,且维护成本大大降低。
- 提高数据准确性和可靠性:使用统一的协议减少了转换错误的可能性,确保数据的准确传输。
通过这个例子,我们可以看到,一个统一的、标准化的通信协议如Modbus在工业自动化中的重要性是不可估量的。它不仅简化了系统的设计和维护,还提高了整体效率和可靠性。
1.3 Modbus协议的变体
Modbus协议包含几种不同的变体,主要包括Modbus RTU、Modbus TCP/IP和Modbus ASCII,它们分别适用于不同的通信环境:
- Modbus RTU:基于二进制的串行通信协议,适用于工业设备间的高速通信。
- Modbus TCP/IP:结合了Modbus和TCP/IP协议,适用于网络化的环境。
- Modbus ASCII:通过ASCII字符表示数据,适用于数据易读性更重要的场合。
🎀二、例程引入
如果看完这个例子还是不大明白,建议直接往后看。后面的内容会让你逐渐领悟什么是modbus通信协议。
2.1 示例:使用01功能码读取灯的开关状态
我们通过一个实际应用的例子来说明Modbus RTU协议的工作原理:读取一个灯的开关状态。
- 设备地址:“01”,代表目标灯具地址,这个地址是由设备自己定的。
- 功能码:“01”,用于读取线圈状态,这里代表灯的开关状态。
- 数据域:包含要读取的线圈的起始地址(比如,“00 13”)和数量(比如,“00 01”)。
- 校验和:通过CRC算法计算得出,用于验证数据帧的完整性和准确性。
在这个例子中,主设备(如控制系统)发送包含上述信息的数据帧到从设备(灯)。灯接收到请求后,会返回包含其当前开关状态(开或关)的数据帧。
线圈起始地址(00 13)
在Modbus协议中,线圈被用作表示设备的二进制状态,例如开/关。每个线圈在从设备内有一个唯一的地址。这个地址用于在数据帧中指定要读取或写入的具体线圈。
- 地址表示方式:地址通常以十六进制表示。
- 例子中的“00 13”:这个地址表示的是从设备中的第19个线圈(十六进制的13等于十进制的19)。这意味着数据帧中的请求是要读取或控制这个特定的线圈。
数量(00 01)
在数据帧中,除了指定线圈的起始地址外,还需要指明要操作的线圈数量。
- 数量的意义:这告诉从设备我们要读取或写入多少个连续的线圈。
- 例子中的“00 01”:这表示我们只对一个线圈(位于“00 13”地址)感兴趣。如果这个值是“00 02”,那么我们将会读取或控制从“00 13”开始的两个连续线圈。
将起始地址和数量结合起来看,我们可以这样理解:如果Modbus请求中的起始地址是“00 13”且数量是“00 01”,那么请求的是对从设备中地址为19的那个线圈的读取或控制。如果数量是“00 02”,则意味着操作从地址19开始的两个连续线圈。
2.2 校验和的重要性
Modbus RTU数据帧中的校验和是通过CRC算法计算的。CRC校验是一种用于检测数据传输过程中的错误的常用方法。它能够确保数据在从发送设备到接收设备的过程中保持完整性和准确性。后续的内容中,会对校验和部分详细介绍,此处不深究。
🎏三、概念解析
3.1 ADU & PDU
想象你要通过邮寄方式发送一封信。这封信由信封(ADU)和信件内容(PDU)组成。
-
ADU(信封):
在 Modbus RTU 中,ADU 就像是信封,它保护并包含着信件内容(即PDU),以及其他必要的信息,如寄信人和收信人的地址(在 Modbus RTU 中是设备地址),和邮戳(在 Modbus RTU 中是CRC校验)。
- 设备地址:就像是信封上的寄信人和收信人地址,用来标识消息是发给谁的,以及来自谁。
- PDU:如下。
- CRC校验:相当于邮戳,确保信件在传输过程中没有被篡改。
-
PDU(信件内容):
PDU 类似于信封内的信件内容。它包含了具体的信息和指令,就像你在信件中写的文字。
- 功能码:告诉接收方这封信(消息)的目的,比如“读取温度”或“开灯”。
- 数据字段:如果你在信中具体说明了某些细节,比如温度设置为 22 度,这就类似于 Modbus RTU 消息中的数据字段。
3.2 什么是线圈
简单的说,这是一个历史遗留问题,把线圈理解为开关就可以。
Modbus 协议最初是为了工业应用而设计的,尤其是用于控制和监测继电器这样的机电设备。在这些系统中,继电器的线圈被用来控制电流的流动,进而控制设备的开关状态。
随着技术的发展,物理继电器被数字化控制系统所取代,但“线圈”这个术语被保留下来,用来表示可以被数字化控制的开/关状态。
3.3 OSI七层协议
只做了解就可以,入门和后续使用中基本用不到这里的概念。
- 物理层(Physical Layer)
- 功能:处理通过物理媒介(如电缆、光纤、无线电波)的原始数据传输。
- 例子:电缆、光纤、RS-232、RS-485。
- 数据链路层(Data Link Layer)
- 功能:确保物理层传输的数据无误,负责帧的传输,包括错误检测和纠正。
- 例子:以太网(Ethernet)、PPP(点对点协议)、MAC(媒体访问控制)。
- 网络层(Network Layer)
- 功能:处理数据在网络中的路径选择和转发,包括寻址和路由。
- 例子:IP(互联网协议)、ICMP(互联网控制消息协议)。
- 传输层(Transport Layer)
- 功能:为两个设备之间的数据传输提供端到端的通信。
- 例子:TCP(传输控制协议)、UDP(用户数据报协议)。
- 会话层(Session Layer)
- 功能:管理设备间的会话,控制建立、维护和终止会话。
- 例子:NetBIOS(网络基本输入输出系统)、RPC(远程过程调用)。
- 表示层(Presentation Layer)
- 功能:确保从一个设备发送的数据能被另一个设备理解,处理数据格式化、加密和解密。
- 例子:ASCII、JPEG、MPEG。
- 应用层(Application Layer)
- 功能:为最终用户或应用程序提供网络服务。
- 例子:HTTP(超文本传输协议)、FTP(文件传输协议)、SMTP(简单邮件传输协议)。
modbus
- 层级:应用层
- 描述:Modbus 是一个应用层协议,用于在设备之间建立通信规则。它定义了消息结构、设备如何响应特定请求、数据如何组织以及如何进行错误检测等。Modbus 协议可以通过多种类型的物理层和数据链路层实现,比如 RS-485 或 TCP/IP。
RS-485与Modbus之间的关系主要是在于通信接口和协议层面。RS-485是一种硬件标准,定义了电器特性和物理连接的规范,而Modbus是一种应用于这些物理接口上的通信协议。下面详细解释二者的关系:
3.4 Modbus与485
如果看不明白的话,详细去学习下什么是485,明白485的概念后,回过来看着一部分就会豁然开朗。
RS-485
- 物理层标准:RS-485是一个电气标准,用于串行通信。它定义了多点系统中的电气特性,包括电压水平、电气连接和信号传输方式。
- 特点:RS-485支持长距离通信(高达4000英尺),并且可以连接多达32个设备(扩展型甚至更多)在同一网络上。它能够抵抗电气噪声和信号衰减,这使其非常适合于工业环境。
Modbus
- 通信协议:Modbus是一个应用层的协议,用于控制网络上设备之间的信息交换。它定义了数据的封装格式、功能码(指示操作类型)、数据结构等。
- 灵活性:Modbus协议可以应用于多种类型的物理层和链路层标准,包括RS-485、RS-232和以太网。
二者关系
不严谨的说,485规定了,电路之间是如何传输数字1和0的。modbus规定了,一帧的数据里面,每个字节、每一片字节,分别表达了什么含义。
RS-485提供了Modbus协议通信所需的物理接口和电气标准。在很多工业应用中,Modbus协议经常与RS-485接口一起使用,形成了一个完善的通信系统。同时RS-485作为一种强大的串行通信标准,为Modbus协议提供了可靠的物理基础,而Modbus协议则在RS-485提供的物理网络上实现了高效、灵活的数据交换和控制。
⭐四、指令解析
这一部分,我们会依据具体的应用场景:家居控制。来逐个演示基本的功能码指令功能。对于每个功能码,都会提供指令帧格式解析、功能解释、实例展示等。
4.1 功能码划分
功能码分为三大类:
- 基本功能码(如 0x01 到 0x06):
- 涉及基础的读写操作。
- 诊断和文件操作功能码(如 0x07、0x14、0x15):
- 用于更高级的操作,如设备诊断、文件记录的读写等。
- 其他或自定义功能码:
- 可以用于特定设备的自定义操作或特殊需求。
- 1 到 127(0x01 到 0x7F):这个范围的功能码用于正常的请求和响应。它们代表标准的操作,如读写寄存器、读写线圈等。
- 128 到 255(0x80 到 0xFF):这个范围的功能码被用于表示异常响应。当从站无法正常执行主站的请求时,它会使用对应的异常功能码来响应。这个异常功能码通常是原始请求的功能码加上 0x80。例如,如果主站发送的是功能码 0x03 的请求,而从站由于某种错误无法执行该请求,则它可能会发送功能码 0x83 的异常响应。
自定义功能码范围
- 功能码 65 到 72(0x41 到 0x48):这些功能码通常被保留用于用户自定义的功能。它们可以用于实现非标准的Modbus操作,这些操作是特定于某些设备或应用的。
- 功能码 100 到 110(0x64 到 0x6E):这是另一组常用于自定义的功能码。
4.2 理解0x01命令
4.2.1 例子:灯、温度计和窗帘
假设我们有以下设备和对应的线圈地址:
- 灯:线圈地址
0x0000
(用来表示灯的开/关状态) - 温度计:线圈地址
0x0001
(用来表示温度计的开/关状态,尽管温度计可能不需要开关,这里仅作为示例) - 窗帘:线圈地址
0x0002
(用来表示窗帘的开/关状态)
4.2.2 使用功能码 0x01 读取状态
当主设备(比如智能家居中心)想要读取这些设备的状态时,它可以发送一个功能码为 0x01 的 Modbus RTU 请求。假设我们想一次性读取所有这些设备的状态,请求数据包可能如下所示:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 指定目标从设备的地址 |
功能码 | 0x01 | 指示读取线圈状态的操作 |
起始地址 | 0x0000 | 指定要读取的第一个线圈的地址(灯) |
数量 | 0x0003 | 指定要读取的线圈数量(3个,包括灯、温度计和窗帘) |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
4.2.3 响应
从设备(智能家居控制器)接收到请求后,将根据各个设备的当前状态来构造响应。响应数据包可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 与请求中的设备地址相同 |
功能码 | 0x01 | 与请求中的功能码相同 |
字节数 | N | 表示状态数据的字节数 |
状态数据 | 状态值 | 表示灯、温度计和窗帘的 ON/OFF 状态 |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
这里的“状态值”将以字节的形式表示,每个位对应一个线圈的状态(1 表示 ON,0 表示 OFF)。例如,如果灯是 ON、温度计是 OFF、窗帘是 ON,状态数据可能是 0x05
(二进制 00000101
,从右到左分别对应灯、温度计和窗帘的状态),字节数是1,一个字节最多可以表示8个设备的状态。
4.2.4 响应实例
假设当前的设备开关状态如下:
- 灯开着(线圈 ON)
- 温度计关着(线圈 OFF)
- 窗帘开着(线圈 ON)
基于这些状态,智能家居控制器的响应数据包可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 与请求中的设备地址相同 |
功能码 | 0x01 | 与请求中的功能码相同 |
字节数 | 0x01 | 表示状态数据的字节数 |
状态数据 | 0x05 | 表示灯、温度计和窗帘的线圈状态 |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
在这个例子中,“状态数据” 0x05
在二进制中表示为 00000101
,其中每位对应一个线圈的状态:
- 灯的开关:第一位(从右到左),值为 1(ON),表示灯是开着的。
- 温度计的电源开关:第二位,值为 0(OFF),表示温度计是关着的。
- 窗帘的开关:第三位,值为 1(ON),表示窗帘是开着的。
4.3 理解0x02指令
4.3.1 什么是离散输入
在 Modbus 协议中,离散输入类似于一种只读的信号源,它们可以告诉你某件事情是发生了还是没有发生。不过,与功能码 0x01(读取线圈状态)控制的开关不同,离散输入通常用于传感器或指示器,它们只能提供信息,但不能被远程控制。再来个例子吧。
我们考虑图书馆门口的智能感应门,他有电源开关可以控制它是否上电工作,又有传感器检测是否有人靠近,靠近就自动打开。
- **0x01(线圈状态)**是读取这个感应门的电源开关有没有被打开。
- 0x02(离散输入状态) 读取智能门附件是否有人在靠近。它只负责报告状态,但是你不能去改变它。
4.3.2 例子:灯、温度计和窗帘
假设我们有如下设备:
- 灯的运行指示器:离散输入地址
0x0000
(表示灯是否在正常工作状态,而非开关状态) - 温度计的电池低指示:离散输入地址
0x0001
(表示温度计的电池是否需要更换) - 窗帘的位置传感器:离散输入地址
0x0002
(表示窗帘是否完全关闭)
4.3.3 使用功能码 0x02 读取状态
当智能家居系统想要检查这些设备的特定情况时,它可以发送一个功能码为 0x02 的 Modbus RTU 请求。例如,检查所有这些特定情况的请求数据包可能如下所示:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 指定目标从设备的地址 |
功能码 | 0x02 | 指示读取离散输入状态的操作 |
起始地址 | 0x0000 | 指定要读取的第一个离散输入的地址 |
数量 | 0x0003 | 指定要读取的离散输入数量 |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
4.3.4 响应
从设备(智能家居控制器)接收到请求后,将根据这些特定情况的当前状态来构造响应。响应数据包可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 与请求中的设备地址相同 |
功能码 | 0x02 | 与请求中的功能码相同 |
字节数 | N | 表示状态数据的字节数 |
状态数据 | 状态值 | 表示灯、温度计和窗帘的特定情况的状态 |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
在这个例子中,“状态值”可能会告诉你灯是否在正常工作,温度计的电池是否需要更换,以及窗帘是否完全关闭。
4.3.5 响应实例
让我们假设当前的设备状态如下:
- 灯的运行指示器(离散输入地址
0x0000
):灯正常工作(状态为 ON) - 温度计的电池低指示(离散输入地址
0x0001
):电池电量正常(状态为 OFF) - 窗帘的位置传感器(离散输入地址
0x0002
):窗帘未完全关闭(状态为 ON)
基于上述状态,智能家居控制器将构造以下响应数据包:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 指定目标从设备的地址 |
功能码 | 0x02 | 指示读取离散输入状态的操作 |
字节数 | 0x01 | 表示状态数据的字节数(因为只读取了 3 个输入) |
状态数据 | 0x05 | 表示灯、温度计和窗帘的特定情况的状态 |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
状态数据 0x05
在二进制中表示为 00000101
。每一位对应一个离散输入的状态:
- 灯的运行指示器:第一位(从右到左),值为 1(ON),表示灯正常工作。
- 温度计的电池低指示:第二位,值为 0(OFF),表示电池电量正常。
- 窗帘的位置传感器:第三位,值为 1(ON),表示窗帘未完全关闭。
4.4 理解0x03指令
功能码 0x03 用于读取一系列保持寄存器中的数据。保持寄存器可以存储更复杂的信息,比如温度值、亮度级别或任何需要长期保存的数值。这些寄存器不仅可以读取,还可以通过其他 Modbus 指令进行写入。
4.4.1 智能家居系统中的应用
在智能家居系统中,我们可以假设以下设备和它们对应的保持寄存器地址:
- 灯的亮度级别:保持寄存器地址
0x0000
(存储灯的目标亮度,可能是从 0(关闭)到 100(最亮)的值) - 温度计的当前读数:保持寄存器地址
0x0001
(存储当前目标温度值,例如 22°C) - 窗帘的位置:保持寄存器地址
0x0002
(存储窗帘的目标位置,可能是从 0(完全关闭)到 100(完全打开)的值)
4.4.2 使用功能码 0x03 读取数据
智能家居系统可以通过发送功能码为 0x03 的 Modbus RTU 请求来读取这些设备的具体数据。例如,读取所有这些设备的数据的请求数据包可能如下所示:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 指定目标从设备的地址 |
功能码 | 0x03 | 指示读取保持寄存器的操作 |
起始地址 | 0x0000 | 指定要读取的第一个保持寄存器的地址 |
数量 | 0x0003 | 指定要读取的保持寄存器数量 |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
4.4.3 响应实例
假设当前的设备数据如下:
- 灯的目标亮度为 50%
- 温度计目标显示温度为 22°C
- 窗帘目标位置为 75% 打开
基于这些数据,智能家居控制器的响应数据包可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 与请求中的设备地址相同 |
功能码 | 0x03 | 与请求中的功能码相同 |
字节数 | 0x06 | 表示状态数据的字节数(每个寄存器 2 字节,总共 3 个寄存器) |
灯亮度数据 | 0x00 0x32 | 表示灯的亮度(0x32 是 50 的十六进制表示) |
温度计读数 | 0x00 0x16 | 表示温度计的读数(0x16 是 22 的十六进制表示) |
窗帘位置数据 | 0x00 0x4B | 表示窗帘的位置(0x4B 是 75 的十六进制表示) |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
4.5 理解0x04指令
功能码 0x04 在 Modbus 协议中用于“读取输入寄存器”,这些寄存器通常用于存储来自于传感器或其他测量设备的数据。与保持寄存器(功能码 0x03)不同,输入寄存器是只读的,意味着它们的数据可以被主设备读取,但不能通过 Modbus 命令被更改。
4.5.1 保持和输入寄存器的区分
**保持寄存器(Function Code 0x03)**
保持寄存器是可读写的,通常用于存储和修改设备的设置或状态信息。在智能家居系统中,保持寄存器可以用于存储用户可配置或可调整的参数。
例子
- 温度计的设定温度:保持寄存器可以用来存储用户设定的目标温度。例如,如果用户想要室内温度维持在 22°C,这个值就可以被写入保持寄存器,并且可以在需要时被读取或修改。
- 窗帘的目标位置:保持寄存器可以存储窗帘应该移动到的位置。比如,用户可能想要窗帘开到 50% 的位置,这个信息可以写入寄存器,并且随时可以更改或查询。
**输入寄存器(Function Code 0x04)**
输入寄存器是只读的,通常用于存储来自传感器的测量数据或设备的实时状态。这些数据通常是由设备自身生成的,并不由用户直接设置。
例子
- 温度计的实时温度:输入寄存器可以用来存储温度计实时测量的室内温度。这个数据是由温度计自动更新的,用户或智能家居系统只能读取,不能更改。
- 窗帘的实际位置:输入寄存器可以显示窗帘当前的实际位置。例如,如果窗帘因为某些原因没有达到预期的位置,这个寄存器会反映出它的当前位置。
4.5.2 智能家居系统中的输入寄存器
让我们依然用智能家居系统中的灯、温度计和窗帘为例来说明这个功能码的应用。
在这个系统中,我们可以假设以下设备和它们对应的输入寄存器地址:
- 灯的能耗计量:输入寄存器地址
0x0000
(可能存储灯在一定时间内的能耗数据) - 温度计的实时温度:输入寄存器地址
0x0001
(存储实时测量的室内温度) - 窗帘的位置传感器:输入寄存器地址
0x0002
(存储窗帘当前的精确位置)
4.5.3 使用功能码 0x04 读取数据
为了获取这些设备的实时数据,智能家居系统可以发送功能码为 0x04 的 Modbus RTU 请求。假设我们想要同时读取所有这些数据,请求数据包可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 指定目标从设备的地址 |
功能码 | 0x04 | 指示读取输入寄存器的操作 |
起始地址 | 0x0000 | 指定要读取的第一个输入寄存器的地址 |
数量 | 0x0003 | 指定要读取的输入寄存器数量 |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
4.5.4 响应实例
假设当前的设备数据如下:
- 灯的能耗为 150 单位
- 温度计显示的温度为 23°C
- 窗帘的位置为 80% 打开
基于这些数据,智能家居控制器的响应数据包可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 与请求中的设备地址相同 |
功能码 | 0x04 | 与请求中的功能码相同 |
字节数 | 0x06 | 表示状态数据的字节数(每个寄存器 2 字节,总共 3 个寄存器) |
灯能耗数据 | 0x00 0x96 | 表示灯的能耗(0x96 是 150 的十六进制表示) |
温度计读数 | 0x00 0x17 | 表示温度计的读数(0x17 是 23 的十六进制表示) |
窗帘位置数据 | 0x00 0x50 | 表示窗帘的位置(0x50 是 80 的十六进制表示) |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
4.6 理解指令0x05
功能码 0x05 在 Modbus 协议中用于“写单个线圈”,它允许主设备(如控制系统)更改从设备(如传感器或执行器)中单个线圈的状态。这通常涉及将线圈设置为 ON(1)或 OFF(0),相当于在远程控制一个开关。
4.6.1 智能家居系统中的线圈
让我们继续用智能家居系统中的灯、温度计和窗帘作为例子来说明这个功能码的应用。
在这个系统中,我们可以假设以下设备和它们对应的线圈地址:
- 灯的开关:线圈地址
0x0000
(控制灯的开/关状态) - 温度计的电源开关:线圈地址
0x0001
(控制温度计的开/关状态) - 窗帘的自动控制开关:线圈地址
0x0002
(控制窗帘的自动开/关)
4.6.2 使用功能码 0x05 更改状态
智能家居系统可以通过发送功能码为 0x05 的 Modbus RTU 请求来更改这些设备的状态。例如,打开灯的请求数据包可能如下所示:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 指定目标从设备的地址 |
功能码 | 0x05 | 指示写单个线圈的操作 |
线圈地址 | 0x0000 | 指定要更改的线圈地址(灯的开关) |
线圈值 | 0xFF00 | 指示将线圈设置为 ON(打开灯) |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
- 打开线圈:要打开(激活)线圈,线圈值通常为
0xFF00
。 - 关闭线圈:要关闭(去激活)线圈,线圈值通常为
0x0000
。
4.6.3 响应实例
从设备接收到请求后,将根据请求更改相应的线圈状态,并发送一个确认响应。响应数据包通常会回复原请求的内容,确认更改已被执行。例如,打开灯的响应可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 与请求中的设备地址相同 |
功能码 | 0x05 | 与请求中的功能码相同 |
线圈地址 | 0x0000 | 与请求中的线圈地址相同 |
线圈值 | 0xFF00 | 与请求中的线圈值相同,确认灯已打开 |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
4.7 理解指令0x06
功能码 0x06 在 Modbus 协议中用于“写单个保持寄存器”。这个功能码允许主设备(如控制系统)向从设备(如传感器或执行器)中的单个保持寄存器写入一个数值。保持寄存器是可读写的,通常用于存储可以被修改的设备参数或状态。
4.7.1 智能家居系统中的保持寄存器
我们继续以智能家居系统为例,考虑灯、温度计和窗帘。
在这个系统中,以下设备可能与保持寄存器相关联:
- 灯的亮度设置:保持寄存器地址
0x0000
(可以存储灯的亮度级别,例如从 0(关闭)到 100(最亮)) - 温度计的目标温度:保持寄存器地址
0x0001
(可以存储期望的室内温度,例如 22°C) - 窗帘的目标位置:保持寄存器地址
0x0002
(可以存储窗帘应达到的位置,例如 50% 开启)
4.7.2 使用功能码 0x06更改设置
智能家居系统可以通过发送功能码为 0x06 的 Modbus RTU 请求来更改这些设备的设置。例如,将灯的亮度设置为 75% 的请求数据包可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 指定目标从设备的地址 |
功能码 | 0x06 | 指示写单个保持寄存器的操作 |
寄存器地址 | 0x0000 | 指定要更改的保持寄存器地址 |
寄存器值 | 0x004B | 指示要写入的值(0x004B 是 75 的十六进制表示) |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
4.7.3 响应实例
从设备接收到请求后,将更改相应的保持寄存器中的值,并发送一个确认响应。响应数据包通常会回复原请求的内容,确认设置更改已被执行。例如,更改灯亮度的响应可能如下:
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0x01 | 与请求中的设备地址相同 |
功能码 | 0x06 | 与请求中的功能码相同 |
寄存器地址 | 0x0000 | 与请求中的寄存器地址相同 |
寄存器值 | 0x004B | 与请求中的寄存器值相同,确认亮度已设置为 75% |
CRC 校验 | CRC值 | 用于错误检测的循环冗余校验值 |
🐖五、常用功能码及其数据包格式
本节是对第四章的梳理。
5.1 读取线圈状态(功能码 0x01)
用于读取一系列线圈的 ON/OFF 状态。
数据包类型 | 字节序列 | 描述 |
---|---|---|
请求 | 设备地址 | 1 字节,指定目标从设备的地址 |
功能码 0x01 | 1 字节,指示读取线圈状态的操作 | |
起始地址 | 2 字节,指定要读取的第一个线圈的地址 | |
数量 | 2 字节,指定要读取的线圈数量 | |
CRC 校验 | 2 字节,用于错误检测 | |
响应 | 设备地址 | 1 字节,与请求中的设备地址相同 |
功能码 0x01 | 1 字节,与请求中的功能码相同 | |
字节数 | 1 字节,指示随后的状态数据的字节数 | |
状态数据 | N 字节,表示线圈的 ON/OFF 状态 | |
CRC 校验 | 2 字节,用于错误检测 |
5.2 读取离散输入状态(功能码 0x02)
用于读取一系列离散输入的 ON/OFF 状态。
数据包类型 | 字节序列 | 描述 |
---|---|---|
请求 | 设备地址 | 1 字节,指定目标从设备的地址 |
功能码 0x02 | 1 字节,指示读取离散输入状态的操作 | |
起始地址 | 2 字节,指定要读取的第一个离散输入的地址 | |
数量 | 2 字节,指定要读取的离散输入数量 | |
CRC 校验 | 2 字节,用于错误检测 | |
响应 | 设备地址 | 1 字节,与请求中的设备地址相同 |
功能码 0x02 | 1 字节,与请求中的功能码相同 | |
字节数 | 1 字节,指示随后的状态数据的字节数 | |
状态数据 | N 字节,表示离散输入的 ON/OFF 状态 | |
CRC 校验 | 2 字节,用于错误检测 |
5.3 读取保持寄存器(功能码 0x03)
用于读取一系列保持寄存器中的数据。
数据包类型 | 字节序列 | 描述 |
---|---|---|
请求 | 设备地址 | 1 字节,指定目标从设备的地址 |
功能码 0x03 | 1 字节,指示读取保持寄存器的操作 | |
起始地址 | 2 字节,指定要读取的第一个寄存器的地址 | |
数量 | 2 字节,指定要读取的寄存器数量 | |
CRC 校验 | 2 字节,用于错误检测 | |
响应 | 设备地址 | 1 字节,与请求中的设备地址相同 |
功能码 0x03 | 1 字节,与请求中的功能码相同 | |
字节数 | 1 字节,指示随后的寄存器数据的字节数 | |
寄存器值 | N×2 字节,表示读取到的寄存器数据 | |
CRC 校验 | 2 字节,用于错误检测 |
5.4 读取输入寄存器(功能码 0x04)
用于读取一系列输入寄存器中的数据。
数据包类型 | 字节序列 | 描述 |
---|---|---|
请求 | 设备地址 | 1 字节,指定目标从设备的地址 |
功能码 0x04 | 1 字节,指示读取输入寄存器的操作 | |
起始地址 | 2 字节,指定要读取的第一个寄存器的地址 | |
数量 | 2 字节,指定要读取的寄存器数量 | |
CRC 校验 | 2 字节,用于错误检测 | |
响应 | 设备地址 | 1 字节,与请求中的设备地址相同 |
功能码 0x04 | 1 字节,与请求中的功能码相同 | |
字节数 | 1 字节,指示随后的寄存器数据的字节数 | |
寄存器值 | N×2 字节,表示读取到的寄存器数据 | |
CRC 校验 | 2 字节,用于错误检测 |
5.5 写单个线圈(功能码 0x05)
用于写入单个线圈的 ON/OFF 状态。
数据包类型 | 字节序列 | 描述 |
---|---|---|
请求 | 设备地址 | 1 字节,指定目标从设备的地址 |
功能码 0x05 | 1 字节,指示写入单个线圈的操作 | |
输出地址 | 2 字节,指定要写入的线圈的地址 | |
输出值 | 2 字节,0x0000 表示 OFF,0xFF00 表示 ON | |
CRC 校验 | 2 字节,用于错误检测 | |
响应 | 设备地址 | 1 字节,与请求中的设备地址相同 |
功能码 0x05 | 1 字节,与请求中的功能码相同 | |
输出地址 | 2 字节,与请求中的输出地址相同 | |
输出值 | 2 字节,与请求中的输出值相同 | |
CRC 校验 | 2 字节,用于错误检测 |
5.6 写单个寄存器(功能码 0x06)
用于写入单个保持寄存器的数据。
数据包类型 | 字节序列 | 描述 |
---|---|---|
请求 | 设备地址 | 1 字节,指定目标从设备的地址 |
功能码 0x06 | 1 字节,指示写入单个寄存器的操作 | |
寄存器地址 | 2 字节,指定要写入的寄存器的地址 | |
寄存器值 | 2 字节,指定要写入的数据 | |
CRC 校验 | 2 字节,用于错误检测 | |
响应 | 设备地址 | 1 字节,与请求中的设备地址相同 |
功能码 0x06 | 1 字节,与请求中的功能码相同 | |
寄存器地址 | 2 字节,与请求中的寄存器地址相同 | |
寄存器值 | 2 字节,与请求中的寄存器值相同 | |
CRC 校验 | 2 字节,用于错误检测 |
🐗六、自定义指令
我们依然以智能家居为背景,通过例子展示下自定义指令如何运用。在使用自定义功能码时,Modbus 协议的基本数据包格式仍然需要遵循,但具体的数据结构和解释可以根据自定义功能的需求来设计。
- 设备地址:数据包的开始部分是设备地址,标识了消息的目标或来源。
- 功能码:自定义的功能码位于设备地址之后。即使是自定义功能码,也需要在这个位置。
- 数据字段:数据字段跟随功能码,其结构和长度取决于自定义功能的具体实现。这部分可以包含命令、参数、配置数据等。
- 错误检查:数据包的末尾是用于错误检查的CRC(循环冗余校验)值。
假设我们定义功能码 0x65(101)来获取或设置房间的温度和湿度。
6.1 请求数据包格式
-
设备地址(1字节):标识目标设备。
-
功能码(1字节):0x65,表示这是一个自定义操作。
-
操作类型
(1字节):
- 0x01:查询环境参数。
- 0x02:设置环境参数。
-
数据长度(1字节):后续数据字段的长度。
-
数据
(可变长度):
- 如果操作类型是查询(0x01),这部分可以为空。
- 如果操作类型是设置(0x02),则包含以下内容:
- 温度(2字节,单位:摄氏度,16位整数)
- 湿度(2字节,单位:百分比,16位整数)
-
CRC校验(2字节):对前面所有字节进行CRC校验。
6.3 响应数据包格式
-
设备地址(1字节)。
-
功能码(1字节):0x65。
-
操作类型(1字节):与请求相同。
-
数据长度(1字节):后续数据字段的长度。
-
数据
(可变长度):
- 如果操作类型是查询(0x01),则包含以下内容:
- 当前温度(2字节,单位:摄氏度,16位整数)
- 当前湿度(2字节,单位:百分比,16位整数)
- 如果操作类型是设置(0x02),这部分可以确认设置操作成功或包含错误代码。
- 如果操作类型是查询(0x01),则包含以下内容:
-
CRC校验(2字节)。
6.3 示例
请求示例:查询环境参数
设备地址 | 功能码 | 操作类型 | 数据长度 | 数据(空) | CRC校验 |
---|---|---|---|---|---|
0x01 | 0x65 | 0x01 | 0x00 | - | [CRC值] |
响应示例:查询环境参数
设备地址 | 功能码 | 操作类型 | 数据长度 | 数据(温度,湿度) | CRC校验 |
---|---|---|---|---|---|
0x01 | 0x65 | 0x01 | 0x04 | [当前温度,当前湿度] | [CRC值] |
- 温度:假设为 23°C,表示为16位整数,例如 0x0017。
- 湿度:假设为 45%,表示为16位整数,例如 0x002D。
🐭七、异常响应
7.1 异常的概念
假设在智能家居系统中,主设备(比如中央控制器)向从设备(比如灯控制器)发送一个读取线圈状态的请求,以检查客厅的灯是否开启。数据传输过程中出现了错误,导致从设备接收到的数据CRC校验失败。
主设备发送的请求(假设为读线圈状态,功能码 0x01)
字节序号 | 内容 | 说明 |
---|---|---|
1 | 设备地址 | 比如 0x01 |
2 | 功能码 | 0x01 |
3-4 | 起始地址 | 比如 0x0000 |
5-6 | 数量 | 比如 0x0001 |
7-8 | CRC校验 | 计算得到的CRC |
从设备返回的异常响应(假设因为CRC错误)
字节序号 | 内容 | 说明 |
---|---|---|
1 | 设备地址 | 与请求中的设备地址相同,比如 0x01 |
2 | 异常功能码 | 原功能码 + 0x80,即 0x81 |
3 | 异常码 | 指示错误的类型,比如 0x04 |
4-5 | CRC校验 | 计算得到的CRC |
有没有注意到,功能码加上0x80后,范围正好是128到255。正好应证了我们功能码划分部分,现在回看,是不是更加理解了功能码的划分?
7.2 异常码列表
modbus返回的异常码如下,此处本教程只做简单介绍,不深入。
异常码 | 十六进制表示 | 描述 |
---|---|---|
1 | 0x01 | 非法功能(Illegal Function) 主设备请求了从设备不支持的功能,可能是因为功能码不存在或该从设备不支持该功能。 |
2 | 0x02 | 非法数据地址(Illegal Data Address) 主设备请求了一个不存在的地址,或该地址不可被当前从设备访问。 |
3 | 0x03 | 非法数据值(Illegal Data Value) 主设备在请求中发送了不合法的值,如超出了从设备能处理的范围。 |
4 | 0x04 | 从设备故障(Slave Device Failure) 从设备在尝试执行请求时遇到了错误,导致无法完成操作,可能是硬件故障或其他严重问题。 |
5 | 0x05 | 确认(Acknowledge) 从设备已接收请求但需要较长时间来处理,通常用于长时间操作的场景。 |
6 | 0x06 | 从设备忙(Slave Device Busy) 从设备目前正忙于处理其他请求,无法立即处理当前请求。 |
7 | 0x07 | 否定确认(Negative Acknowledge) 从设备无法执行请求的特定功能,但接收到了请求。 |
8 | 0x08 | 内存奇偶校验错误(Memory Parity Error) 从设备在访问其内存时检测到奇偶校验错误。 |
10 | 0x0A | 网关路径不可用(Gateway Path Unavailable) 在Modbus网关中使用时,指示网关无法为请求分配内部通信路径。 |
11 | 0x0B | 网关目标设备响应失败(Gateway Target Device Failed to Respond) 网关未能从目标设备获取有效响应,可能是因为目标设备未响应或通信失败。 |
🥏八、编程示例
我们现在自己做一个智能家居系统,同时又做一个控制器进行控制,他们之间通过modbus协议。下面写一个简单的示例代码,方便大家理解接收端和发送端的逻辑。两边都理解了才叫深入。
在选程序示例的时候,我发现开源的代码非常“健硕”,但是丢失掉了易读性,新手直接读会消化不良,因此展示用的代码我均是自己从0开始写的。
8.1 发送端
8.1.1 服务函数
#include "stdio.h"
#include "stdint.h"
#include "stdbool.h"
/**
* 此函数用于生成控制窗帘开关的 Modbus RTU 指令(功能码 0x05)。
*
* @param device_addr 窗帘控制器的设备地址。
* @param coil_addr 窗帘控制线圈的地址。
* @param open true 表示打开窗帘,false 表示关闭窗帘。
* @param request_buffer 用于存储生成的请求指令的缓冲区。
*
* @return 生成的请求指令的长度。
*/
int send_write_reg_req(uint8_t device_addr, uint16_t coil_addr, bool open, uint8_t *request_buffer) {
uint16_t coil_value = open ? 0xFF00 : 0x0000; // 打开窗帘为 0xFF00,关闭为 0x0000
request_buffer[0] = device_addr; // 设备地址
request_buffer[1] = 0x05; // 功能码 0x05
request_buffer[2] = (uint8_t)(coil_addr >> 8); // 线圈地址高字节
request_buffer[3] = (uint8_t)(coil_addr & 0xFF); // 线圈地址低字节
request_buffer[4] = (uint8_t)(coil_value >> 8); // 线圈值高字节
request_buffer[5] = (uint8_t)(coil_value & 0xFF); // 线圈值低字节
// 计算CRC并填充(假设存在 calculate_crc 函数)
uint16_t crc = calculate_crc(request_buffer, 6);
request_buffer[6] = (uint8_t)(crc & 0xFF); // CRC低字节
request_buffer[7] = (uint8_t)(crc >> 8); // CRC高字节
return 8; // 请求指令长度
}
/**
* 此函数用于生成读取窗帘开关状态的 Modbus RTU 指令(功能码 0x01)。
*
* @param device_addr 窗帘控制器的设备地址。
* @param coil_addr 窗帘控制线圈的地址。
* @param request_buffer 用于存储生成的请求指令的缓冲区。
*
* @return 生成的请求指令的长度。
*/
int send_read_coil_req(uint8_t device_addr, uint16_t coil_addr, uint8_t *request_buffer) {
request_buffer[0] = device_addr; // 设备地址
request_buffer[1] = 0x01; // 功能码 0x01
request_buffer[2] = (uint8_t)(coil_addr >> 8); // 线圈地址高字节
request_buffer[3] = (uint8_t)(coil_addr & 0xFF); // 线圈地址低字节
request_buffer[4] = 0x00; // 读取数量高字节(1)
request_buffer[5] = 0x01; // 读取数量低字节
// 计算CRC并填充(假设存在 calculate_crc 函数)
uint16_t crc = calculate_crc(request_buffer, 6);
request_buffer[6] = (uint8_t)(crc & 0xFF); // CRC低字节
request_buffer[7] = (uint8_t)(crc >> 8); // CRC高字节
return 8; // 请求指令长度
}
/**
* 此函数用于生成设置窗帘位置的 Modbus RTU 指令(功能码 0x06)。
*
* @param device_addr 窗帘控制器的设备地址。
* @param reg_addr 窗帘位置的保持寄存器地址。
* @param position 窗帘的目标位置。
* @param request_buffer 用于存储生成的请求指令的缓冲区。
*
* @return 生成的请求指令的长度。
*/
int send_write_coil_req(uint8_t device_addr, uint16_t reg_addr, uint16_t position, uint8_t *request_buffer) {
request_buffer[0] = device_addr; // 设备地址
request_buffer[1] = 0x06; // 功能码 0x06
request_buffer[2] = (uint8_t)(reg_addr >> 8); // 寄存器地址高字节
request_buffer[3] = (uint8_t)(reg_addr & 0xFF); // 寄存器地址低字节
request_buffer[4] = (uint8_t)(position >> 8); // 寄存器值高字节
request_buffer[5] = (uint8_t)(position & 0xFF); // 寄存器值低字节
// 计算CRC并填充(假设存在 calculate_crc 函数)
uint16_t crc = calculate_crc(request_buffer, 6);
request_buffer[6] = (uint8_t)(crc & 0xFF); // CRC低字节
request_buffer[7] = (uint8_t)(crc >> 8); // CRC高字节
return 8; // 请求指令长度
}
8.1.2 使用示例
#include <stdbool.h>
#include <stdint.h>
// 假设这些函数已经定义
int modbus_build_read_curtain_state_request(uint8_t device_addr, uint16_t coil_addr, uint8_t *request_buffer);
int modbus_build_control_curtain_request(uint8_t device_addr, uint16_t coil_addr, bool open, uint8_t *request_buffer);
int modbus_build_set_curtain_position_request(uint8_t device_addr, uint16_t reg_addr, uint16_t position, uint8_t *request_buffer);
int modbus_build_read_curtain_position_request(uint8_t device_addr, uint16_t reg_addr, uint8_t *request_buffer);
int main() {
uint8_t request_buffer[8];
uint8_t device_addr = 0x01; // 窗帘控制器的设备地址
uint16_t coil_addr = 0x0000; // 窗帘开关控制的线圈地址
uint16_t position_reg_addr = 0x0002; // 窗帘位置的保持寄存器地址
// 发送读取窗帘当前状态请求
send_read_coil_req(device_addr, coil_addr, request_buffer);
// 发送请求数据...
// 发送打开窗帘请求
send_write_coil_req(device_addr, coil_addr, true, request_buffer);
// 发送请求数据...
// 发送读取窗帘的当前位置请求
send_write_reg_req(device_addr, position_reg_addr, 50, request_buffer);
// 发送请求数据...
// 发送设置窗帘的新位置请求(例如 50%)
send_write_reg_req(device_addr, position_reg_addr, 50, request_buffer);
// 发送请求数据...
return 0;
}
8.2 接收端
#include "stdio.h"
#include "stdint.h"
#include "stdbool.h"
/**
* 处理Modbus响应数据。
* 根据功能码调用相应的解析函数,并执行相应的后续操作。
*
* @param buffer 响应数据缓冲区。
* @param length 缓冲区长度。
* @param dev_addr 预期的设备地址,用于验证响应。
*/
void handle_response(const uint8_t *buffer, uint16_t length, uint8_t dev_addr)
{
if (length < 5)
{
return; // 响应长度不足,无法处理
}
switch (buffer[1])
{ // 根据功能码进行不同的处理
case 0x01:
{
bool coil_status = parse_coil_response(buffer, length, dev_addr);
// 根据coil_status进行后续操作
break;
}
case 0x05:
{
bool write_coil_ok = parse_write_coil_response(buffer, length, dev_addr);
// 根据write_coil_ok进行后续操作
break;
}
case 0x06:
{
bool write_reg_ok = parse_write_reg_response(buffer, length, dev_addr);
// 根据write_reg_ok进行后续操作
break;
}
// 其他功能码的处理...
}
}
/**
* 解析写单个保持寄存器的响应数据。
* 这个函数检查响应数据包是否符合预期格式,并确认操作是否成功。
*
* @param buffer 响应数据缓冲区,包含完整的Modbus响应。
* @param length 缓冲区长度,应至少为8字节。
* @param dev_addr 预期的设备地址,用于验证响应是否来自正确的设备。
* @return 如果操作成功并且响应数据与请求匹配,则返回true;否则返回false。
*/
bool parse_write_reg_response(const uint8_t *buffer, uint16_t length, uint8_t dev_addr)
{
// 检查响应长度是否足够
if (length < 8)
{
return false; // 长度不足,响应无效
}
// 检查设备地址和功能码是否匹配
if (buffer[0] != dev_addr || buffer[1] != 0x06)
{
return false; // 设备地址或功能码不匹配
}
// 具体的写保持寄存器操作
// 回应确认响应
return true;
}
/**
* 解析写单个线圈的响应数据。
* 这个函数检查响应数据包是否符合预期格式,特别是是否与发送的请求匹配。
*
* @param buffer 响应数据缓冲区,包含完整的Modbus响应。
* @param length 缓冲区长度,应至少为8字节。
* @param dev_addr 预期的设备地址,用于验证响应是否来自正确的设备。
* @return 如果操作成功并且响应数据与请求匹配,则返回true;否则返回false。
*/
bool parse_write_coil_response(const uint8_t *buffer, uint16_t length, uint8_t dev_addr)
{
// 检查响应长度是否足够
if (length < 8)
{
return false; // 长度不足,响应无效
}
// 检查设备地址和功能码是否匹配
if (buffer[0] != dev_addr || buffer[1] != 0x05)
{
return false; // 设备地址或功能码不匹配
}
// 具体的写线圈操作
// 回应确认响应
return true;
}
/**
* 解析读取线圈状态的响应数据。
* 这个函数检查响应数据包是否符合预期格式,并解析线圈的状态。
*
* @param buffer 响应数据缓冲区,包含完整的Modbus响应。
* @param length 缓冲区长度,应至少为5字节。
* @param dev_addr 预期的设备地址,用于验证响应是否来自正确的设备。
* @return 如果线圈处于打开状态,则返回true;如果处于关闭状态或有错误,则返回false。
*/
bool parse_coil_response(const uint8_t *buffer, uint16_t length, uint8_t dev_addr)
{
// 检查响应长度是否足够
if (length < 5)
{
return false; // 长度不足,响应无效
}
// 检查设备地址是否匹配
if (buffer[0] != dev_addr)
{
return false; // 设备地址不匹配
}
// 解析线圈状态
bool coil_status = (buffer[3] & 0x01) != 0; // 读取第4个字节的最低位
// 具体的读线圈操作
// 回应确认响应
return coil_status;
}
8.3 整理流程
🏈九、实战
9.1 设备准备
为了写这篇教程,我在淘宝上自掏腰包,花费了20块大洋,买了个modbus驱动的继电器给大家实战演示。大家看在我这份诚意上,给个点赞关注不过分吧哈哈哈,如果可以打赏那真是感激不尽。
下图是modbus通信接口的继电器控制指令集,我从淘宝店铺上截图过来的。(因为没有赞助费,就不打广告啦)。大家仔细看看每个指令,是不是跟我们在第四章介绍的能够对应上?
这边带大家再次解读下第一个打开1号继电器指令。
数据包部分 | 十六进制值 | 描述 |
---|---|---|
设备地址 | 0xff | 设备地址为255(十六进制0xff) |
功能码 | 0x05 | 指示写单个线圈的操作 |
线圈地址 | 0x0000 | 指定要更改的线圈地址(继电器的开关) |
线圈值 | 0xFF00 | 指示将线圈设置为 ON(打开灯) |
CRC 校验 | 0x99e4 | 用于错误检测的循环冗余校验值 |
在串口助手端,我们先预先准备好要发送的命令。
下面是我的实物连接图。电脑通过USB转485模块连接到继电器上,旁边那个全身针脚像刺猬一样的,是电源(宿舍不是实验室,拿他凑合一下)。介绍以下这个继电器,如果打开的话,上面会有个红色小灯亮起来。关闭就不亮。
9.2 fire
打开1号继电器指令
可以看到,继电器板子上面的小灯亮了,表明我们打开成功。
关闭1号继电器指令
🦇十、CRC的计算
CRC的计算还是有点小复杂的,建议大家去网上寻求教程学习,自觉才疏学浅,不能写出更好的教程,能学懂,但是教不明白,虽然网上不少教程写的也跟💩一样。
这边为大家写一个C语言的编程示例和注意事项。
10.1 自学指引
modbus使用的CRC协议是叫做:CRC-16 MODBUS。说真的,我在学习的时候,看到过大家给他了各种各样的称呼,有些混乱。但是标准是确定的,如下表:
CRC算法名称 | CRC-16/IBM-3740 |
---|---|
多项式公式 | x16 + x15 + x2 + 1 |
宽度width | 16 |
多项式poly | 0x8005 |
初始值init | 0xFFFF |
输入反转refin | false |
输出反转refout | false |
输出结果异或值xorout | 0x0000 |
check | 0x29B1 |
residue | 0x0000 |
给大家推荐一个方便计算CRC的网站吧,里面可以直接计算出来CRC-16 MODBUS。
参数模型里面,选择CRC-16 MODBUS就可以。
CRC(循环冗余校验)在线计算_ip33.com
10.2 编程示例
#include <stdint.h>
#include <stdio.h>
// 计算Modbus CRC16的函数
uint16_t modbus_crc16(uint8_t *data, uint16_t length) {
uint16_t crc = 0xFFFF; // 初始CRC值为0xFFFF
for (uint16_t i = 0; i < length; i++) {
crc ^= data[i]; // 将数据与CRC寄存器进行异或
for (uint8_t j = 0; j < 8; j++) { // 处理每个bit
if (crc & 0x0001) {
crc = (crc >> 1) ^ 0xA001; // 如果最低位为1,向右移位后与多项式0xA001异或
} else {
crc >>= 1; // 最低位为0,直接向右移位
}
}
}
return crc;
}
// 测试函数
int main() {
uint8_t test_data[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x10};
uint16_t crc = modbus_crc16(test_data, sizeof(test_data));
printf("CRC16: %04X\n", crc);
return 0;
}
10.3 小端发送
计算机系统存储有大小端之分,而modbus协议规定,CRC的两字节数据,必须按照小端发送。意思是如果算出来CRC是0X1234,0X12是高字节,0X34是低字节。我们要先发送0X34字节再发送0X12字节。
我以stm32进行举例。
stm32系列单片机使用的是ARM处理器,而ARM处理器通常是小端(little-endian)模式的。在小端模式中,数据的低位字节存储在低地址,高位字节存储在高地址。这在处理数据时是个很重要的考虑因素,特别是在进行网络通信或与其他使用不同字节序的系统交互时。
不过,需要注意的是,某些ARM处理器支持在大端(big-endian)和小端模式之间切换,但默认情况下,它们通常都是配置为小端模式的。当然,为了确保100%正确,最好的办法还是查阅所使用的具体STM32型号的参考手册。
如果你是用数组的方式存储两个字节的CRC,要非常注意大小端,但是吧,我们更多的是使用uint16_t的数据类型来进行存储的,因此可以不用管大小端,使用下面的方法就可以直接提取出来高低字节。然后按照小端发送
uint16_t value = 0xABCD;
uint8_t low_byte = value & 0xFF; // 0xCD
uint8_t high_byte = value >> 8; // 0xAB
// 重组
uint16_t reassembled = (high_byte << 8) | low_byte;
尾声
网页版是不是看起来不太方便。可以关注我的公众号,后台回复:modbus。获取
- PDF文稿
- 编程示例程序
声明:本博客内容(包括文字、图片、音频、视频等)均为原创,版权归属作者所有。未经作者书面许可,严禁抄袭、转载或用于任何商业用途。侵权必究。
如您需要转载或使用本博客内容,请务必联系作者获取授权。谢谢您的理解与支持。
博客作者:四臂西瓜
日期:2024-1-25
日志
2024-1-25:完成V1版本,并发布。