🎯 学习目标:全面掌握Modbus协议的原理、应用和实战技巧,成为工业通信领域的专家

🚀 协议概述

Modbus 是工业自动化领域使用最广泛的通信协议之一,具有简单、可靠、开放的特点。

协议特点:开放性强、实现简单、设备兼容性好、在工业控制系统中应用广泛

🏗️ 架构模式

基本概念:

  • 🖥️ 主站(Master):相当于客户端,主动发起通信请求
  • 🔧 从站(Slave):相当于服务端,被动响应主站请求
  • 📡 通信特点:半双工通信,主站轮询从站

通信流程:主站发送请求 → 从站处理并响应 → 主站接收数据

支持的网络类型:

  • 🔌 Modbus RTU:基于RS-485串行通信
  • 🌍 Modbus TCP:基于以太网TCP/IP
  • Modbus ASCII:ASCII编码的串行通信

网络特点:支持1个主站和最多247个从站设备

四种数据类型:

  • 🔘 线圈(Coils):单比特可读写数据
  • 📥 离散输入(Discrete Inputs):单比特只读数据
  • 📋 保持寄存器(Holding Registers):16位可读写数据
  • 📤 输入寄存器(Input Registers):16位只读数据

📚 基础概念理解

关键术语

● 1字节(位) = 1byte(一个字节) = 8bit

  • 1个字节可以表示0-255的数值
  • 在Modbus中,地址、功能码等都以字节为单位

● 串行链路:数据一位一位按顺序传输的方式

  • 在广域网中提供远距离传输
  • 分为”异步”和”同步”传输方式
  • 以字节为单位进行数据传输

广域网(WAN):连接不同地区局域网的远程网络,覆盖范围从几十公里到几千公里

● PDU(协议数据单元):网络通信中传输的数据单位

  • 包含控制信息和用户数据
  • 在不同网络层级有不同名称:包(Packet)、段(Segment)、帧(Frame)
  • Modbus中定义了与通信层无关的简单协议数据单元

● ASCII:美国信息交换标准代码,与UTF-8都是字符编码方式

🔧 Modbus操作对象(通用数据类型)

对象类型 地址范围 数据位数 权限 说明
线圈 00001-09999 1位 读写 PLC输出位,开关量
离散输入 10001-19999 1位 只读 PLC输入位,开关量
输入寄存器 30001-39999 16位 只读 模拟量输入
保持寄存器 40001-49999 16位 读写 模拟量输出

通用数据结构说明

  • 1位数据:线圈和离散输入,存储开关状态(0/1)
  • 16位数据:寄存器类型,存储数值(0-65535)
  • 地址编码:前缀数字区分不同类型,实际传输时使用相对地址

🔢 功能码详解

功能码 名称 操作对象 描述
01 读线圈状态 00001-09999 读取离散输出状态
02 读离散输入 10001-19999 读取离散输入状态
03 读保持寄存器 40001-49999 读取模拟输出值
04 读输入寄存器 30001-39999 读取模拟输入值
05 写单个线圈 00001-09999 写入单个线圈
06 写单个寄存器 40001-49999 写入单个寄存器
0F(15) 写多个线圈 00001-09999 写入多个线圈
10(16) 写多个寄存器 40001-49999 写入多个寄存器

📦 数据帧格式

通用PDU结构

功能码 数据
1字节 不定长

MBAP报文头(TCP专用)

事务处理标识 协议标识 长度 单元标识符
2字节 2字节 2字节 1字节

字节含义说明

  • 事务处理标识:报文序列号,每次通信后加1
  • 协议标识符:00 00表示Modbus TCP协议
  • 长度:后续数据长度(字节数)
  • 单元标识符:设备地址

1. Modbus RTU帧模式

设备地址 功能代码 数据格式 CRC16校验
1字节 1字节 N字节 2字节

读/写单个操作

设备地址 功能代码 起始地址(高位) 起始地址(低位) 数量(高位) 数量(低位) CRC16校验
1字节 1字节 1字节 1字节 1字节 1字节 2字节

读/写多个操作

设备地址 功能代码 起始地址(高位) 起始地址(低位) 数量(高位) 数量(低位) 字节数 数据 CRC16校验
1字节 1字节 1字节 1字节 1字节 1字节 1字节 N字节 2字节

2. Modbus ASCII帧模式

帧头 设备地址 功能码 数据 校验码LRC 回车 换行
1字符 2字符 2字符 N字符 2字符 1字符 1字符
: 01 03 00 00 00 01 FB \r \n

3. Modbus TCP帧模式

MBAP报文头 功能码 数据
7字节 1字节 N字节

🔧 通用数据封装思路

三种帧格式的共同点

  1. 核心数据:设备地址、功能码、起始地址、数量/数值
  2. 数据部分:根据功能码决定是否包含字节数和具体数值
  3. 校验方式:RTU用CRC16,ASCII用LRC,TCP无需校验

封装策略

  • 先构建通用的PDU部分(功能码+数据)
  • 根据传输模式添加不同的头部和校验
  • 字节序按照大端模式(高位在前)

💻 代码实现

RTU模式构建

1
2
3
4
5
6
// RTU
fun build( function: ModbusFunction, slave: Int, startAddress: Int, count: Int, value: Int? = null, values: IntArray? = null): ByteArray {
val output = buildOutput(slave, function, startAddress, count, value, values)
toCalculateCRC16(output.toByteArray(), output)
return output.toByteArray()
}

TCP模式构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// TCP
private var mTransactionId: Int = 0
private val mProtocol: Int = 0

fun build(function: ModbusFunction, slave: Int, startAddress: Int, count: Int,value: Int? = null, values: IntArray? = null, transactionId: Int = mTransactionId): ByteArray {
val pdu = buildOutput(slave, function, startAddress, count, value, values, isTcp = true)
val size = pdu.size()
val mbap = BytesOutput()
mbap.writeInt16(transactionId)
mbap.writeInt16(mProtocol)
mbap.writeInt16(size + 1)
mbap.writeInt8(slave)
mbap.write(pdu.toByteArray())
mTransactionId++
return mbap.toByteArray()
}

ASCII模式构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ASCII
private val HEAD = 0x3A
private val END = byteArrayOf(0x0d, 0x0A)

fun build( function: ModbusFunction, slave: Int, startAddress: Int, count: Int, value: Int? = null, values: IntArray? = null): ByteArray {
val bytes = BytesOutput()
val output = buildOutput(slave, function, startAddress, count, value, values).toByteArray()
val pLRC = fromAsciiInt8(toCalculateLRC(output))
val outputAscii = toAsciiHexBytes(output)
bytes.writeInt8(HEAD)
bytes.writeBytes(outputAscii, outputAscii.size)
bytes.writeInt8(pLRC.first)
bytes.writeInt8(pLRC.second)
bytes.writeBytes(END, END.size)
return bytes.toByteArray()
}

核心构建函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// 核心实现
open class KModbus protected constructor() {

/**
* ● 构造输出的数据
*
* ● 2023-10-16 16:42:18 周一 下午
* @author crowforkotlin
*/
fun buildOutput(slave: Int, function: ModbusFunction, startAddress: Int, count: Int, value: Int?, values: IntArray?, isTcp: Boolean = false): BytesOutput {

//检查参数是否符合协议规定
when {
slave !in 0..0xFF -> throw ModbusException(ModbusErrorType.ModbusInvalidArgumentError, "Invalid slave $slave")
startAddress !in 0..0xFFFF -> throw ModbusException(ModbusErrorType.ModbusInvalidArgumentError, "Invalid startAddress $startAddress")
count !in 1..0xFF -> throw ModbusException(ModbusErrorType.ModbusInvalidArgumentError, "Invalid count $count")
}

val output = BytesOutput()

if (!isTcp) {
output.writeInt8(slave)
}

when(function) {

// 读线圈寄存器 、离散输入寄存器、保持寄存器、输入寄存器
ModbusFunction.READ_COILS, ModbusFunction.READ_DISCRETE_INPUTS, ModbusFunction.READ_INPUT_REGISTERS, ModbusFunction.READ_HOLDING_REGISTERS -> {
output.writeInt8(function.mCode)
output.writeInt16(startAddress)
output.writeInt16(count)
}
ModbusFunction.WRITE_SINGLE_COIL, ModbusFunction.WRITE_SINGLE_REGISTER -> {

var valueCopy = value ?: throw ModbusException(ModbusErrorType.ModbusInvalidArgumentError, "Function Is $function\t , Data must be passed in!")

//写单个寄存器指令
if (function == ModbusFunction.WRITE_SINGLE_COIL) if (value != 0) valueCopy = 0xff00 //如果为线圈寄存器(写1时为 FF 00,写0时为00 00)

output.writeInt8(function.mCode)
output.writeInt16(startAddress)
output.writeInt16(valueCopy)
}
ModbusFunction.WRITE_HOLDING_REGISTERS -> {

//写多个保持寄存器
output.writeInt8(function.mCode)
output.writeInt16(startAddress)
output.writeInt16(count)
output.writeInt8(2 * count)

//写入数据
(values ?: throw ModbusException(ModbusErrorType.ModbusInvalidArgumentError, "Function Is $function, Data must be passed in!")).forEach { output.writeInt16(it) }
}
ModbusFunction.WRITE_COILS -> {
if (values == null || values.isEmpty()) throw ModbusException(ModbusErrorType.ModbusInvalidArgumentError, "Function Is $function, Data must be passed in and cannot be empty!")
output.writeInt8(function.mCode)
output.writeInt16(startAddress)
output.writeInt16(count)
output.writeInt8((count + 7) shr 3)
val chunkedValues = values.toList().chunked(8)
for (chunk in chunkedValues) {
output.writeInt8(toDecimal(chunk.reversed().toIntArray()))
}
}
}

return output
}

/**
* ● CRC校验
*
* ● 2023-10-16 16:06:48 周一 下午
* @author crowforkotlin
*/
fun toCalculateCRC16(output: BytesOutput): BytesOutput {

//计算CRC校验码
output.writeInt16Reversal(CRC16.compute(output.toByteArray()))
return output
}

/**
* ● LRC校验
*
* ● 2023-10-16 16:06:41 周一 下午
* @author crowforkotlin
*/
fun toCalculateLRC(data: ByteArray): Int {
var iTmp = 0
for (x in data) {
iTmp += x.toInt()
}
iTmp %= 256
iTmp = (iTmp.inv() + 1) and 0xFF // 对补码取模,确保结果在0-255范围内
return iTmp
}

/**
* ● Convert each digit component to decimal
*
* ● 2023-10-16 16:00:53 周一 下午
* @author crowforkotlin
*/
private fun toDecimal(data: IntArray): Int {
var result = 0
for (bit in data) {
if (bit != 0 && bit != 1) {
return -1 // 数据数组中包含非二进制值,返回错误
}
result = (result shl 1) + bit
}
return result
}
}

📖 开源项目及参考资料


💡 总结: 本文介绍了Modbus协议的基本概念、数据帧格式和Kotlin实现方法,适合工业自动化入门学习。