Modbus通信协议实战指南
🎯 学习目标:全面掌握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字节 |
🔧 通用数据封装思路
三种帧格式的共同点:
- 核心数据:设备地址、功能码、起始地址、数量/数值
- 数据部分:根据功能码决定是否包含字节数和具体数值
- 校验方式:RTU用CRC16,ASCII用LRC,TCP无需校验
封装策略:
- 先构建通用的PDU部分(功能码+数据)
- 根据传输模式添加不同的头部和校验
- 字节序按照大端模式(高位在前)
💻 代码实现
RTU模式构建
// 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模式构建
// 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模式构建
// 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()
}
核心构建函数
// 核心实现
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实现方法,适合工业自动化入门学习。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 crowforkotlin!
评论