Modbus ASCII
简介
Modbus ASCII 使用ASCII字符集传递消息,方便阅读和调试。Modbus ASCII相比于Modbus RTU,协议帧中添加了起始和结束,更换了校验算法。
Modbus网络模型
这张图比较简洁清晰。Modbus网络中,只有一个Master,Master可以向Slave发起请求并获取响应,Slave只能被动发送响应而不能主动请求。
Slave最多可达247个 . 每个Slave由1到247之间的地址标识,0地址用来广播,剩余地址保留。
帧格式
字段描述
名称 | 字节数 | 描述 |
---|---|---|
Start | 1 B | 以冒号 : 开头,ASCII十六进制值为3A |
Address | 2 B | 十六进制节点地址,字符表示 |
Function | 2 B | 十六进制功能码,字符表示 |
Data | 2 * n B | n是数据字节数,取决于功能码 |
LRC | 2 B | LRC冗余校验码 |
End | 2 B | CRLF |
Modbus ASCII为了兼容Modbus RTU,是将二进制字节改用ASCII字符来表示,例如 0xFF 这个十六进制数,Modbus RTU中,使用二进制进行传输,传输的数据是 1111 1111
。Modbus ASCII中,传输数据就变成了 0100 0110 0100 0110
,共两个字节,每个字节对应十进制70,是 F 的ASCII码。
传输示例
校验码计算
网上找不到计算工具,没办法,自己算吧。
校验算法叫LRC,纵向冗余校验。就是将所有字节相加(两个数字表示一个字节),截取最低的8位(忽略进位),再取补码。注意是十六进制数字相加,不是ASCII码。两行代码就可以算出来:
var msg = "020300030002";
// 两个十六进制数字表示一个字节,因此需要做一个转换,每两位字符转换为一个十六进制数,然后再计算LRC
//
var bytes = Enumerable.Range(0, msg.Length / 2).Select(i => Convert.ToByte(msg.Substring(i * 2, 2), 16));
var lrc = ((bytes.Aggregate((a, b) => (byte)(a + b)) ^ 0xFF) + 1) & 0xFF;
Console.WriteLine(lrc.ToString("X2"));
下面的代码是NModbus库里面的:
/// <summary>
/// Converts a hex string to a byte array.
/// </summary>
/// <param name="hex">The hex string.</param>
/// <returns>Array of bytes.</returns>
public static byte[] HexToBytes(string hex)
{
if (hex == null)
{
throw new ArgumentNullException(nameof(hex));
}
if (hex.Length % 2 != 0)
{
throw new FormatException(Resources.HexCharacterCountNotEven);
}
byte[] bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}
return bytes;
}
/// <summary>
/// Calculate Longitudinal Redundancy Check.
/// </summary>
/// <param name="data">The data used in LRC.</param>
/// <returns>LRC value.</returns>
public static byte CalculateLrc(byte[] data)
{
if (data == null)
{
throw new ArgumentNullException(nameof(data));
}
byte lrc = 0;
foreach (byte b in data)
{
lrc += b;
}
lrc = (byte)((lrc ^ 0xFF) + 1);
return lrc;
}
打开Modbus Slave模拟器,打开虚拟串口工具,串口调试工具,手动构造请求消息试一下。
读取2号设备的3号和4号寄存器
功能码0x03(读保持寄存器)的格式:
功能码 | 起始地址 | 读取数量 |
---|---|---|
1 字节 / 2 字符 | 2 字节 / 4 字符 | 2 字节 / 4 字符 |
构造请求:
帧头 | 设备地址 | 功能码 | 起始地址 | 读取数量 | 校验码 | 结束符 |
---|---|---|---|---|---|---|
: | 02 | 03 | 00 03 | 00 02 | F6 | CRLF |
:020300030002F6\r\n
得到响应:
:02030400070006EA\r\n
响应格式:
功能码 | 字节数 | 寄存器值 |
---|---|---|
1 字节 / 2 字符 | 1 字节 / 2 字符 | N * 2 字节 / N * 4 字符(N 为读取数量) |
响应解析:
设备地址 | 功能码 | 字节数 | 寄存器值 | 校验码 |
---|---|---|---|---|
02 | 03 | 04 | 00 07 00 06 | EA |
一个寄存器16位,一个字节8位,一个十六进制数4位。所以我们请求了两个寄存器的值,响应给了八个字符,一共四个字节的数据,没问题。再看数据对不对:
3号寄存器值为7,4号寄存器值为6,没毛病。
将2号设备的4号和5号寄存器置为1
功能码0x10(写多个寄存器)的格式:
功能码 | 起始地址 | 寄存器数量 | 字节数 | 寄存器值 |
---|---|---|---|---|
1 字节 / 2 字符 | 2 字节 / 4 字符 | 2 字节 / 4 字符 | 1 字节 / 2 字符 | N * 2 字节 / N * 4 字符(N 为寄存器数量) |
构造请求:
设备地址 | 功能码 | 起始地址 | 寄存器数量 | 字节数 | 寄存器值 | LRC校验码 |
---|---|---|---|---|---|---|
02 | 10 | 00 04 | 00 02 | 04 | 00 01 00 01 | F5 |
:0210000400020400010001E2
获得响应:
:021000040002E8
响应格式:
功能码 | 起始地址 | 寄存器数量 |
---|---|---|
1 字节 / 2 字符 | 2 字节 / 4 字符 | 2 字节 / 4 字符 |
解析响应:
设备地址 | 功能码 | 起始地址 | 寄存器数量 | LRC校验码 |
---|---|---|---|---|
02 | 10 | 00 04 | 00 02 | E8 |
看下结果:
4号和5号寄存器的值现在都是1,没毛病。
使用NModbus
使用NModbus库,编码时除了一开始的配置,基本上无需关心用的什么协议,库都帮你封装好了。
var factory = new ModbusFactory();
// 使用NModbus RTU,传进去一个SerialPort对象
var master = factory.CreateRtuMaster(port);
// 使用Modbus ASCII,传进去一个SerialPort对象
var master = factory.CreateAsciiMaster(port);
// 使用Modbus TCP,传进去一个TcpClient对象
var master = factory.CreateMaster(tcpClient);
// 其他API都一样,直接用就行了