通信协议:Modbus
2026-03-07 18:19:31一、Modbus协议
**1. Modbus通信方式与分类 **
串口 RS485(一主多从),不同的报文格式:
- ModbusAscii(ASCII字符方式进行发送)
- ModbusRTU(Remote Terminal Unit)
以太网(TCP点对点),报文格式一样。RTU over TCP 以TCP的方式发送RTU的报文
- ModbusTCP
- ModbusUDP
其他连接方式:byte[] 协议
2. Modbus协议下的数据存储
- 数据存储中的位bit (bool)、字节byte(8个位)、字Word(2个字节,16位)、双字DWord(2个字,4个字节,32位),C#中的数据显示:数据类型、显示格式
- 内存分区与功能:硬盘分区、功能码
| 存储区 | 对象类型 | 访问类型(针对程序) | 存储区标识 | 说明 | 可用功能码 |
|---|---|---|---|---|---|
| 线圈状态 | 单个bit | 读写 | 0XXXX | 通过应用程序改变这种类型数据 | 读01 写 05 15 |
| 输入线圈 | 单个bit | 只读 | 1XXXX | I/O系统提供这种类型数据 | 读02 |
| 输入寄存器 | 16-位 字 | 只读 | 3XXXX | I/O系统提供这种类型数据 | 读04 |
| 保持寄存器 | 16-位 字 2个字节 | 读写 | 4XXXX | 通过应用程序改变这种类型数据 | 读03 写 06 16 |
Modbus功能码补充说明:
| 功能码 16进制 | 名称 | 功能 |
|---|---|---|
| 01 | 读线圈状态 | 读位(读N个bit)---读从机线圈寄存器,位操作 |
| 02 | 读输入离散量 | 读位(读N个bit)---读离散输入寄存器,位操作 |
| 03 | 读多个保持型寄存器 | 读整型、字符型、状态字、浮点型(读N个words)---读保持寄存器,字节操作 |
| 04 | 读多个输入寄存器 | 读整型、状态字、浮点型(读N个words)---读输入寄存器,字节操作 |
| 05 | 写单个线圈 | 写位(写一个bit)---写线圈寄存器,位操作 |
| 06 | 写单个保持寄存器 | 写整型、字符型、状态字、浮点型(写一个word)---写保持寄存器,字节操作 |
| 07 | 读取异常状态 | 取得8个内部线圈的通断状态,这8个线圈的地址由控制器决定,用户逻辑可以将这些线圈定义,以说明从机状态,短报文适宜于迅速读取状态 |
| 08 | 回送诊断校验 | 把诊断校验报文送从机,以对通信处理进行评鉴 |
| 09 | 编程(只用于484) | 使主机模拟编程器作用,修改PC从机逻辑 |
| 0A | 控询(只用于484) | 可使主机与一台正在执行长程序任务从机通信,探询该从机是否已完成其操作任务,仅在含有功能码9的报文发送后,本功能码才发送 |
| 0B | 读取事件计数 | 可使主机发出单询问,并随即判定操作是否成功,尤其是该命令或其他应答产生通信错误时 |
| 0C | 读取通讯事件记录 | 可是主机检索每台从机的ModBus事务处理通信事件记录。如果某项事务处理完成,记录会给出有关错误 |
| 0D | 编程(184/384/484/584) | 可使主机模拟编程器功能修改PC从机逻辑 |
| 0E | 探询(184/384/484/584) | 可使主机与正在执行任务的从机通信,定期控询该从机是否已完成其程序操作,仅在含有功能13的报文发送后,本功能码才得发送 |
| 0F | 写多个线圈 | 可以写多个线圈---强置一串连续逻辑线圈的通断 |
| 10 | 写多个保持寄存器 | 写多个保持寄存器---把具体的二进制值装入一串连续的保持寄存器 |
| 11 | 报告从机标识 | 可使主机判断编址从机的类型及该从机运行指示灯的状态 |
| 12 | (884和MICRO84) | 可使主机模拟编程功能,修改PC状态逻辑 |
| 13 | 重置通信链路 | 发生非可修改错误后,是从机复位于已知状态,可重置顺序字节 |
| 14 | 读取通用参数(584L) | 显示扩展存储文件中的数据信息 |
| 15 | 写入通用参数(584L) | 把通用参数写入扩展存储文件 |
| 16~40 | 保留做扩展功能备用 | |
| 41~48 | 保留以备用户功能所用 | 留作用户功能的扩展编码 |
| 49~77 | 非法功能 | |
| 78~7F | 保留 | 留作内部作用 |
| 80~FF | 保留 | 用于异常应答 |
3. 常见通信库
NModbus、NModbus4、HSL、EasyModbusTCP
//# nuget System.IO.Ports 串口通信依赖
//# EasyModbusTCP库测试
static void LibTest()
{
EasyModbus.ModbusClient client = new EasyModbus.ModbusClient("COM1");
client.Connect();
client.UnitIdentifier = 2;
int[] values = client.ReadHoldingRegisters(0, 3);
bool[] bs = client.ReadCoils(0, 10);
}
4. Modbus RTU 协议报文格式
- 读寄存器消息帧格式 - 0x03、0x04 长度表示寄存器数量
请求:
| 从站地址 | 功能码 | 起始地址 | 读取长度(2byte->16bit) | CRC16 | |||
|---|---|---|---|---|---|---|---|
| 01 | 03 | 00(Hi) | 00(Lo) | 00(Hi) | 0A(Lo) | C5 | CD |
响应:
| 从站地址 | 功能码 | 字节数 | 寄存器值1 | 寄存器值2 | …… | 寄存器值20 | CRC16 | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01 | 03 | 14 | 00(Hi) | 00(Lo) | 00(Hi) | 00(Lo) | …… | …… | 00(Hi) | 00(Lo) | XX | XX |
// ModbusRTU 读取保持型寄存器
static void RTU03Test()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
// 按照协议进行请求数据整理准备
// 请求的10个寄存器 - 10*2+5
byte[] bytes = new byte[] {
0x01,0x03,0x00,0x00,0x00,0x0A
};
// 通过CRC16方法进行校验码计算,将获取的校验码拼接到报文尾部
byte[] crc = CRC16(bytes);
var temp = bytes.ToList();
temp.AddRange(crc.Reverse()); //是否需要翻转,根据具体场景判断
bytes = temp.ToArray();
serialPort.Write(bytes, 0, bytes.Length);
//Thread.Sleep(1000);
// BytesToRead 太快可能读不到数据
//int count = serialPort.BytesToRead;
byte[] resp = new byte[25];
serialPort.Read(resp, 0, resp.Length);
//浮点型读取
//resp[3],resp[4],resp[5],resp[6]
// ABCD
// DCBA
// BADC
// CDAB
//float fff = BitConverter.ToSingle(
// //new byte[] { resp[3], resp[4], resp[5], resp[6] }.Reverse().ToArray(), 0);
// new byte[] { resp[3], resp[4], resp[5], resp[6] }, 0);
for (int i = 0; i < 10; i++)
{
int start = (i * 2) + 3;
//byte[] dataBytes = resp.ToList().GetRange(start, 2).ToArray();
byte[] dataBytes = new byte[] { resp[start], resp[start + 1] };
// Select 遍历每个字节,16进制的字符串
// Join,把集合中的每项用 01 02 03 04
Console.WriteLine(string.Join(" ", dataBytes.Select(b => b.ToString("X2"))));
// 0x00 0x6E - > 10进制数据 : 111 23 2*10+3
// 2字节 C# short/ushort/int16/uint16
// 1000 0000 0000 0000
//short value = (short)(dataBytes[0] * 256 + dataBytes[1]);
//value = -111;
//ushort vvv = (ushort)value;
// 框架提供的数据转换对象
// 这个对象,字节序 大小端 2个字节
// 300 0x01 0x2C 256
// [01] [2C] [] [] [2C] [01] [] 大端字节序
//var sss = BitConverter.IsLittleEndian;
//dataBytes.Reverse().ToArray()
//将Modbus协议的中大端数据字节序颠倒顺序,适应BitConverter的小端字节序
short value = BitConverter.ToInt16(dataBytes.Reverse().ToArray(), 0);// 小端
Console.WriteLine($">> {value}");
}
}
static byte[] CRC16(byte[] data)
{
if (data == null || !data.Any())
throw new ArgumentException("");
//运算
ushort crc = 0xFFFF;
for (int i = 0; i < data.Length; i++) //有些算法中会出现data.Length-2,那是因为data中预留了两位,用于存放CRC
{
crc = (ushort)(crc ^ (data[i]));
for (int j = 0; j < 8; j++)
{
crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
}
byte hi = (byte)((crc & 0xFF00) >> 8); //高位置
byte lo = (byte)(crc & 0x00FF); //低位置
return new byte[] { hi, lo };
}
- 写单寄存器消息帧格式 – 0x06
请求、响应一样:
| 从站地址 | 功能码 | 写入地址 | 写入值(2) | CRC16 | |||
|---|---|---|---|---|---|---|---|
| 01 | 06 | 00(Hi) | 00(Lo) | 00(Hi) | 00(Lo) | XX | XX |
// ModbusRTU 写单个寄存器
static void RTU06Test()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
// short int16 uint16
//ushort value = 40000;
//float ff = 4.5f; // 怎么转字节 能不能存下?不能 这时候就需要一次写多个寄存器
//BitConverter.GetBytes(ff);
short value = -300;
// 256进行进位
// 1 44
// 1000 0001 0010 1100 // 300
// 1111 1111 0000 0000 // 0xFF00
// 0000 0001 0000 0000 // 位&操作
// 0 00000000000 0001
List<byte> bytes = new List<byte> {
0x01,// 从站地址
0x06,// 功能码
0x00,0x00,// 起始地址
(byte)((value & 0xFF00) >> 8),
(byte)(value & 0x00FF),// 写入的值 1
};
var crc = CRC16(bytes.ToArray());
bytes.AddRange(crc.Reverse());
// 发送写单个寄存器的请求
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
// 正常/异常
byte[] resp = new byte[bytes.Count];
serialPort.Read(resp, 0, resp.Length);
}
- 写多寄存器消息帧格式 – 0x10 16
请求:
| 从站地址 | 功能码 | 写入地址 | 写入数量 | 字节数 | 写入值 | CRC16 | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01 | 10 | 00(Hi) | 00(Lo) | 00(Hi) | 0A(Lo) | 04 | 0A | AB | 00 | 01 | XX | XX |
响应:
| 从站地址 | 功能码 | 写入地址 | 写入数量 | CRC16 | |||
|---|---|---|---|---|---|---|---|
| 01 | 10 | 00(Hi) | 00(Lo) | 00(Hi) | 0A(Lo) | XX | XX |
// ModbusRTU 写多个寄存器
static void RTU16Test()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
float ff = 4.5f;
byte[] v_bytes = BitConverter.GetBytes(ff);
// 需要翻转 取决于设备
List<byte> bytes = new List<byte>
{
0x01,
0x10,
0x00,0x02,
0x00,0x02,// 写入的寄存器地址数量
0x04, // 写入的值的字节数
};
bytes.AddRange(v_bytes.Reverse());
byte[] crc = CRC16(bytes.ToArray());
bytes.AddRange(crc.Reverse());
// 发送写多个寄存器的请求
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
// 正常/异常
byte[] resp = new byte[8];
serialPort.Read(resp, 0, resp.Length);
}
static void RTUStringTest()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
string msg = "Hello yanzhi";
byte[] s_bytes = Encoding.ASCII.GetBytes(msg);
ushort r_count = (ushort)(Math.Ceiling(s_bytes.Length * 1.0 / 2));
// 需要翻转 取决于设备
List<byte> bytes = new List<byte>
{
0x01,
0x10,
0x00,0x08,
(byte)(r_count/256),
(byte)(r_count%256),// 写入的寄存器地址数量
(byte)s_bytes.Length, // 写入的值的字节数
};
bytes.AddRange(s_bytes);
byte[] crc = CRC16(bytes.ToArray());
bytes.AddRange(crc.Reverse());
// 发送写多个寄存器的请求
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
// 正常/异常
byte[] resp = new byte[8];
serialPort.Read(resp, 0, resp.Length);
bytes = new List<byte>{
0x01,0x03,0x00,0x08,0x00,0x1E
};
// 通过CRC16方法进行校验码计算,将获取的校验码拼接到报文尾部
crc = CRC16(bytes.ToArray());
bytes.AddRange(crc.Reverse());
serialPort.Write(bytes.ToArray(), 0, bytes.Count());
Encoding.ASCII.GetString(bytes.GetRange(3, 60).ToArray());
}
- 读线圈消息帧格式 - 0x01、0x02 读取长度指的是寄存器数量
请求:
| 从站地址 | 功能码 | 起始地址 | 读取长度 | CRC | |||
|---|---|---|---|---|---|---|---|
| 01 | 01 | 00(Hi) | 00(Lo) | 00(Hi) | 0A(Lo) | XX | XX |
响应:
| 从站地址 | 功能码 | 字节数 | 输出状态 7-0 | 输出状态 15-8 | CRC | |
|---|---|---|---|---|---|---|
| 01 | 01 | 02 | 00 | 00 | XX | XX |
// ModbusRTU 读取线圈状态
static void RTU01Test()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
List<byte> bytes = new List<byte>
{
0x01,
0x01,/// 功能码
0x00,0x00,
0x00,0x0B
};
byte[] crc = CRC16(bytes.ToArray());
bytes.AddRange(crc.Reverse());
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
// 读取的长度是多少
int len = (int)(Math.Ceiling(10 * 1.0 / 8));
byte[] resp = new byte[len + 5];
serialPort.Read(resp, 0, resp.Length);
int count = 0;
for (int i = 3; i < 3 + len; i++)
{
// 状态解析
// 取出每个字节中的位信息 0/1
//bool s1 = (resp[i] & 1) > 0;
//bool s2 = (resp[i] & 2) > 0;
//bool s3 = (resp[i] & 4) > 0;
//bool s4 = (resp[i] & 8) > 0;
//bool s5 = (resp[i] & 16) > 0;
//bool s6 = (resp[i] & 32) > 0;
//bool s7 = (resp[i] & 64) > 0;
//bool s8 = (resp[i] & 128) > 0;
//Console.WriteLine($"{s1} - {s2} - {s3} - {s4} - {s5} - {s6} - {s7} - {s8}");
for (int sit = 0; sit < 8; sit++)
{
// resp[i] >> sit & 1;
bool state = (resp[i] & (1 << sit)) > 0;
count++;
if (count > 11)
return;
Console.WriteLine(state);
}
}
}
- 写单线圈消息帧 - 0x05
请求、响应一样
| 从站地址 | 功能码 | 写入地址 | 写入值 | CRC | |||
|---|---|---|---|---|---|---|---|
| 01 | 05 | 00(Hi) | 00(Lo) | FF(Hi)/00(Hi) | 00(Lo) | XX | XX |
// ModbusRTU 写单个线圈状态
static void RTU05Test()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
List<byte> bytes = new List<byte>()
{
0x01,
0x05,
0x00,0x00,
0xFF,// 0xFF:on 0x00:off
0x00
// CRC
};
byte[] crc = CRC16(bytes.ToArray());
bytes.AddRange(crc.Reverse());
/// 把请求报文发送到设备
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
// 读取的长度是多少
byte[] resp = new byte[bytes.Count];
serialPort.Read(resp, 0, resp.Length);
}
- 写多线圈消息帧 – 0x0F 15
请求:
| 从站地址 | 功能码 | 写入地址 | 写入数量 | 字节数 | 写入值 | CRC | ||||
|---|---|---|---|---|---|---|---|---|---|---|
| 01 | 0F | 00(Hi) | 00(Lo) | 00(Hi) | 0A(Lo) | 02 | 0A(7 - 0) | AB (15 - 8) | XX | XX |
响应:
| 从站地址 | 功能码 | 写入地址 | 写入数量 | CRC | |||
|---|---|---|---|---|---|---|---|
| 01 | 0F | 00(Hi) | 00(Lo) | 00(Hi) | 0A(Lo) | XX | XX |
// ModbusRTU 写多个线圈状态
static void RTU15Test()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
// 这里不涉及字节序的问题
// 位状态的排序
// 10 - 2
// 1101 1111 0000 0010
List<byte> bytes = new List<byte>()
{
0x01,
0x0F,
0x00,0x00,
0x00,0x0A,
0x02,// 写的状态数据数量
0xDF, // 0-7的状态
0x02 // 8-15的状态
};
byte[] crc = CRC16(bytes.ToArray());
bytes.AddRange(crc.Reverse());
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
// 读取的长度是多少
byte[] resp = new byte[8];
serialPort.Read(resp, 0, resp.Length);
// 判断写入是否成功
}
5. Modbus协议其他处理
地址范围
从站地址:0-255 256个数字 0 广播(写入动作-》不用回复) 1—247 有效的地址范围 255 Modbus的 轮询
数据异常(数据无法正常解析:数据响应正常报文)
大小端存储问题导致数据解析不正确,字节分 试一下
异常处理原理(响应异常:正常请求 正常响应异常报文)
如果请求发生异常(设备处理不了),响应的报文会状功能码的最高位置1
0x01 1000 0001 0xx81 0x03 1000 0003 0x83
- 读取线圈的状态图

- 读保持寄存器的状态图

static void RTUException()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
List<byte> bytes = new List<byte>
{
0x01,
0x01,/// 功能码
0x00,0x00,
0x00,0x0B
};
//byte[] crc = CRC16(bytes.ToArray());
//bytes.AddRange(crc.Reverse());
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
// 读取的长度是多少
int len = (int)(Math.Ceiling(10 * 1.0 / 8));
byte[] resp = new byte[len + 5];
serialPort.Read(resp, 0, resp.Length);
// 1、检查CRC对不对
// 2、检查异常码
if (resp[1] > 0x80)
{
// 有异常
//resp[2]
}
// 通信异常
}
请求频率异常(RTU) :设备无法响应 报文组装正确的前提 间隔时间
RTU报文请求间隔:>3.5个字符时间 <1.5个字符时间
static void RTUMonitor()
{
// 通路链接起来
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
while (true)
{
Thread.Sleep(4); //频率过快就会异常
List<byte> bytes = new List<byte>{
0x01,0x03,0x00,0x00,0x00,0x0A
};
byte[] crc = CRC16(bytes.ToArray());
bytes.AddRange(crc.Reverse());
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
byte[] resp = new byte[10 * 2 + 5];
serialPort.Read(resp, 0, resp.Length);
Console.WriteLine(resp[3] * 256 + resp[4]);
}
}
- 报文长度限制
0x07D0 线圈的单次请求长度
0x007D 寄存器单次请求长度
- 读写同步问题
- 485环境 半双工
- 解决方式:加锁、队列(后面通信库的时候再封装)
6. Modbus Ascii 协议处理
报文格式
| 0x3A | Address | Modbus PDU | LRC | 0x0D | 0x0A |
|---|---|---|---|---|---|
| ":" | (ASCII) | (ASCII) | (ASCII) | CR | LF |
格式变化,基于Ascii码的报文处理
- 将RTU基础报文(包含从站地址,除CRC校验部分,PDU),利用LRC校验码计算
- 获取ASCII编码数组,对应byte[],所有字符必须大写 ABCD….
- 添加起始(: 3A)结束字符(换行回车 0D 0A)
例:
RTU: 01 03 00 00 00 0A C5 CD
ASCII:3A 30 31 30 33 30 30 30 30 30 30 30 41 46 32 0D 0A
static void Ascii03Test()
{
// 01 03 00 00 00 0A 00
// ":01030000000A00\CR\LF"
// 0x3A 0x30 0x31 0x30 0x33 .... 0x41...0x30 0x0D 0x0A
// 以ModbusASCII报文格式进行保持型寄存器的读取,从0号地址开始读10个
SerialPort serialPort = new SerialPort("COM1");
serialPort.Open();
List<byte> bytes = new List<byte>
{
0x01,0x03,0x00,0x00,0x00,0x0A
};
// 1、LRC校验
byte lrc = LRC(bytes.ToArray());
bytes.Add(lrc);
// 2、转字符串
//16.ToString("X2") -- "10"
//1.ToString("X2") -- "01"
//15.ToString("X2") -- "0F"
string str = string.Join("", bytes.Select(b => b.ToString("X2")));
// 3、拼接头和尾
str = ":" + str;
List<byte> ascii = Encoding.ASCII.GetBytes(str).ToList();
ascii.Add(0x0D);
ascii.Add(0x0A);
serialPort.Write(ascii.ToArray(), 0, ascii.Count);
// 解析的时候
// 1、移除头尾
// 2、从字符串转byte[]
// 3、做相关检查:LRC校验 异常码检查 byte[]
// RTU是25个字节,10*2+5
// 51 = (25-1)*2 + 3 校验码只有1位,所以要-1
byte[] resp = new byte[51];
serialPort.Read(resp, 0, resp.Length);
// 1\\\
ascii = resp.ToList().GetRange(1, resp.Length - 3);
str = Encoding.ASCII.GetString(ascii.ToArray());
//"010314000B00160021002C003700000000000000000063E0"
// 注意:Framework框架下,需要两两字符拼接转换
byte[] datas = Convert.FromHexString(str);
for (int i = 0; i < 10; i++)
{
int start = (i * 2) + 3;
byte[] dataBytes = new byte[] { datas[start], datas[start + 1] };
short value = BitConverter.ToInt16(dataBytes.Reverse().ToArray(), 0);// 小端
Console.WriteLine($">> {value}");
}
}
static byte LRC(byte[] value)
{
if (value == null) return 0x00;
int sum = 0;
for (int i = 0; i < value.Length; i++)
{
sum += value[i];
}
sum = sum % 256; // 只拿低位的一个字节数据
sum = 256 - sum;
return (byte)sum;
}
7. Modbus TCP 协议处理
TCP/IP:TCP ADU由Modbus应用协议(MBAP)报文头和Modbus PDU组成。MBAP是一个通用的报文头,依赖于可靠的网络层。此ADU的格式(包括报文头)如图6所示。
报文格式:
| Transaction | Protocol | Length | Unit ID | Modbus PDU |
|---|
格式变化,基于Ascii码的报文处理,将RTU基础报文(从站地址,除CRC校验部分,PDU)以前面依次添加TransactionID(2个字节 65535)、ProtocolID(2个字节 0x00 0x00)、Length(2个字节 0x00 0x06)
例:
RTU:01 03 00 00 00 0A C5 CD 不需要校验码
TCP:00 01 00 00 00 06 01 03 00 00 00 0A 请求读取1号站内的保持型寄存器 10个
00 01:TransactionID
00 00:Modbus协议ID
00 06:后续还有多少字节
01:从站地址 单元ID。基于TCP/IP协议进行通信:点对点,还涉及从站地址吗?因为IP可能是局域网IP,内部还接了很多从站设备
static void TCP03Test()
{
Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
socket.Connect("127.0.0.1", 502);
byte[] bytes = new byte[] {
0x00,0x00,// TID
0x00,0x00,// Protocol ID
0x00,0x06,// 大端字节序
0x01,0x03,0x00,0x00,0x00,0x0A
};
socket.Send(bytes);
//接收
// 10 -> 20bytes + 3(功能码头) + 6(TCP/IP头) 29bytes
byte[] header = new byte[6];
socket.Receive(header, 0, 6, SocketFlags.None);
ushort len = BitConverter.ToUInt16(new byte[] { header[5], header[4] }); //5,4存的是length,大端字节序
byte[] body = new byte[len];
int count = socket.Receive(body, 0, len, SocketFlags.None);
for (int i = 0; i < 10; i++)
{
int start = (i * 2) + 3;
byte[] dataBytes = new byte[] { body[start], body[start + 1] };
short value = BitConverter.ToInt16(dataBytes.Reverse().ToArray(), 0);
Console.WriteLine($">> {value}");
}
}
二、Modbus通信库封装
1. 功能清单
需要三种类型的协议请求
- ModbusRtu CRC
- ModbusASCII LRC
- ModbusTCP ADU
不管是哪个类型的通信需要必要的组件
- SerialPort
- Socket
不管哪个类型的协议核心处理的是相同的
- 读写的PDU报文
- 写PDU的创建
- 读PDU的创建
- 字节序调整
- 解析过程
- 读操作
- 写操作
- 读写的PDU报文
扩展功能
- 读写同步
- 地址解析40001 |
- 地址分组 125个寄存器 报错
2. 封装抽象
- 枚举类
public enum Functions
{
RCoilStatus = 0x01,
RInputCoils = 0x02,
RHoldingRegister = 0x03,
RInputRegister = 0x04,
WCoilStatus = 0x0F,
WHoldingRegister = 0x10,
}
public enum EndianType
{
ABCD, CDAB, BADC, DCBA,
ABCDEFGH, GHEFCDAB, BADCFEHG, HGFEDCBA
}
- 封装异常
public class ModbusException : Exception
{
private static readonly Dictionary<byte, string> errors = new Dictionary<byte, string>
{
{ 0x01, "非法功能码"},
{ 0x02, "非法数据地址"},
{ 0x03, "非法数据值"},
{ 0x04, "从站设备故障"},
{ 0x05, "确认,从站需要一个耗时操作"},
{ 0x06, "从站设备忙"},
{ 0x08, "存储奇偶性差错"},
{ 0x0A, "不可用网关路径"},
{ 0x0B, "网关目标设备响应失败"},
};
public ModbusException(string message) : base(message) { }
public ModbusException(byte errorCode) : base(errors[errorCode]) { }
}
- 定义接口类:接口方法需要定义链接、关闭、读取、写入、数据解析等方法
public interface IModbus
{
void Connect();
void Disconnect();
byte[] Read(byte slave, Functions func, ushort start, ushort count);
void Write(byte slave, Functions func, ushort start, ushort count, byte[] datas);
/// <summary>
/// 提供一个将 byte[] 转换为指定类型数组的方法
/// 使用者可以自行解析从 Read 方法中获取的 byte[] 数据,也可以使用该方法来解析
/// </summary>
T[] GetDatas<T>(byte[] bytes, EndianType endianType = EndianType.ABCD);
/// <summary>
/// 提供一个将指定类型数组转为 Byte[] 的方法
/// 使用者可以自行转换后再调用 Write 方法,也可以使用该方法来转换
/// </summary>
byte[] GetBytes<T>(T[] datas, EndianType endianType = EndianType.ABCD);
}
- 定义抽象基类:
- 不同的通信组件的Connect、Disconnect、Read、Write等方法都不同,我们的基类提供一个模版方法实现。
- 实现GetReadBytes、GetWriteBytes方法,因为不管使用哪种通信组件,都是使用Modbus协议来读取和写入数据的,我们统一实现一下
- 提供SendAndReceive模版方法。虽然Modbus协议规定了读取和写入的格式,但整个RTU与ASCII的报文格式并不一样,我们这里放一个抽象方法。
- GetBytes和GetDatas的作用是解析byte[],这与协议无关,我们直接在基类中实现,具体实现我们之后再处理。
public abstract class ModbusBase : IModbus
{
public abstract void Connect();
public abstract void Disconnect();
public abstract byte[] Read(byte slave, Functions func, ushort start, ushort count);
public abstract void Write(byte slave, Functions func, ushort start, ushort count, byte[] datas);
protected byte[] GetReadBytes(byte slave, byte funcCode, ushort start, ushort count)
{
return new byte[] {
slave,
funcCode,
(byte)(start / 256),
(byte)(start % 256),
(byte)(count / 256),
(byte)(count % 256)
};
}
protected byte[] GetWriteBytes(byte slave, byte funcCode, ushort start, int count, byte[] datas)
{
// 线圈/寄存器 多写功能码进行处理
List<byte> bytes = new List<byte>()
{
slave,
funcCode,
(byte)(start / 256),
(byte)(start % 256),
/// 寄存器数量 datas.lenght/2
/// 线圈数量
///
(byte)(count/256),
(byte)(count%256),
(byte)datas.Length,
};
bytes.AddRange(datas);
return bytes.ToArray();
}
/// <summary>
/// 应答模式,实现方法需要处理报文异常
/// </summary>
protected abstract byte[] SendAndReceive(byte[] bytes, int len);
public byte[] GetBytes<T>(T[] datas, EndianType endianType = EndianType.ABCD)
{
return null;
}
public T[] GetDatas<T>(byte[] bytes, EndianType endianType = EndianType.ABCD)
{
return null;
}
}
- 再次封装一个ModbusSerial抽象类,
- 无论是ModbusRTU还是ModbusASCII都是使用SerialPort来通信的,所以我们继续封装一层
- SerialPort没有实现Read和Write方法,因为RTU和ASCII的读取与写入机制是不一样的,需要去具体类中实现
- SendAndReceive时需要处理异常报文,Modbus协议的传输格式中数据异常会返回0x80以上的数据,随后可能就不能正常读取数据了。又因为Modbus是应答最小的响应周期是1.5毫秒,我们可以使用一个2毫秒的超时来做异常判断
- 提供CRC16和算法
public abstract class ModbusSerial : ModbusBase
{
public string PortName { get; set; } = "COM1";
public int BaudRate { get; set; } = 9600;
public Parity Parity { get; set; } = Parity.None;
public int DataBits { get; set; } = 8;
public StopBits StopBits { get; set; } = StopBits.One;
public int ReadTimeout { get; set; } = 2000;
public int ReadBufferSize { get; set; } = 4096;
private SerialPort _serialPort;
public ModbusSerial(string portName, int baudRate, Parity parity, int dataBits, StopBits stopBits)
{
this.PortName = portName;
this.BaudRate = baudRate;
this.Parity = parity;
this.DataBits = dataBits;
this.StopBits = stopBits;
_serialPort = new SerialPort();
}
public override void Connect()
{
_serialPort.PortName = PortName;
_serialPort.BaudRate = BaudRate;
_serialPort.DataBits = DataBits;
_serialPort.Parity = Parity;
_serialPort.StopBits = StopBits;
_serialPort.ReadTimeout = ReadTimeout;
_serialPort.ReadBufferSize = ReadBufferSize;
_serialPort.Open();
}
public override void Disconnect()
{
_serialPort.Close();
}
protected override byte[] SendAndReceive(byte[] bytes, int len)
{
if (!_serialPort.IsOpen)
throw new ModbusException("串口对象未连接");
_serialPort.Write(bytes.ToArray(), 0, bytes.Length);
// 响应数据获取
List<byte> resp = new List<byte>();
try
{
do
{
// 这里需要配合一个超时 2000
resp.Add((byte)_serialPort.ReadByte());/// 从缓冲区一个字节读
}
while (resp.Count < len);
}
catch
{
// 这里有一个超时异常
}
return resp.ToArray();
}
protected byte[] CRC16(List<byte> data)
{
if (data == null || !data.Any())
throw new ArgumentException("");
//运算
ushort crc = 0xFFFF;
for (int i = 0; i < data.Count; i++)
{
crc = (ushort)(crc ^ (data[i]));
for (int j = 0; j < 8; j++)
{
crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
}
byte hi = (byte)((crc & 0xFF00) >> 8); //高位置
byte lo = (byte)(crc & 0x00FF); //低位置
return new byte[] { hi, lo };
}
protected byte LRC(byte[] value)
{
if (value == null) return 0x00;
int sum = 0;
for (int i = 0; i < value.Length; i++)
{
sum += value[i];
}
sum = sum % 256; // 只拿低位的一个字节数据
sum = 256 - sum;
return (byte)sum;
}
}
- 同样,我们再封装一个ModbusSocket基类
public abstract class ModbusSocket : ModbusBase
{
public string IP { get; set; } = "127.0.0.1";
public int Port { get; set; }
public int ReadTimeout { get; set; } = 2000;
public int ReadBufferSize { get; set; } = 4096;
private Socket _socket;
protected ModbusSocket(ProtocolType protocolType, string ip, int port)
{
if (protocolType == ProtocolType.Tcp)
{
_socket = new Socket(SocketType.Stream, protocolType);
}
else if (protocolType == ProtocolType.Udp)
{
_socket = new Socket(SocketType.Dgram, protocolType);
}
else
{
throw new NotSupportedException("不支持的协议类型:" + protocolType);
}
IP = ip;
Port = port;
}
public override void Connect()
{
_socket.ReceiveTimeout = ReadTimeout;
_socket.ReceiveBufferSize = ReadBufferSize;
_socket.Connect(IP, Port);
}
public override void Disconnect()
{
_socket.Shutdown(SocketShutdown.Both);
_socket.Close();
_socket.Dispose();
}
protected override byte[] SendAndReceive(byte[] bytes, int len)
{
_socket.Send(bytes, 0, bytes.Length, SocketFlags.None);
byte[] resp = new byte[6];
_socket.Receive(resp, 0, 6, SocketFlags.None);
// 判断TID是否一致
if (resp[0] != bytes[0] || resp[1] != bytes[1])
{
// 清空缓冲区,可能存在一下问题:
// Receive方法默认会阻塞,直到有数据到达或超时。如果缓冲区本就是空的,线程会一直等待
// 一次性分配一个大小为 ReceiveBufferSize(通常64KB)的数组,可能远大于实际待清空的数据量,造成资源浪费
// 如果对端正常关闭连接,Receive会返回0。若将此理解为“清空完毕”,可能忽略连接已断开的事实
// 此方法会丢弃所有读取到的数据。如果缓冲区里包含有效但尚未被程序逻辑处理的消息,会造成数据丢失
// 在清空过程中,可能发生超时(SocketException)、连接重置等异常。不处理会导致程序崩溃
//_socket.Receive(new byte[_socket.ReceiveBufferSize]);
// TryClearReceiveBuffer 是优化方法
TryClearReceiveBuffer();
throw new ModbusException("TransactionID不匹配,接收数据无效");
}
len = resp[4] * 256 + resp[5];
resp = new byte[len];
int count = _socket.Receive(resp, 0, len, SocketFlags.None);
return resp;
}
/// <summary>
/// 尝试清空套接字的接收缓冲区
/// </summary>
/// <returns>是否成功清空(false表示发生超时或错误)</returns>
private bool TryClearReceiveBuffer()
{
try
{
// 设置一个合理的超时时间,例如2秒
_socket.ReceiveTimeout = 2000;
// 使用较小的缓冲区进行循环读取
byte[] buffer = new byte[1024];
// 只要还有数据可读,就继续读取并丢弃
while (_socket.Available > 0)
{
int bytesRead = _socket.Receive(buffer, 0, buffer.Length, SocketFlags.None);
// 如果连接关闭,则跳出循环
if (bytesRead == 0)
break;
// 可以在这里记录日志,例如:Console.WriteLine($"清理了 {bytesRead} 字节的残留数据。");
}
// 重置超时时间(如果需要恢复为无限等待)
// _socket.ReceiveTimeout = 0;
return true;
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut)
{
// 超时异常表示在指定时间内没有收到新数据,可以认为是清空完毕
Console.WriteLine("清空缓冲区操作超时(可能已无数据)。");
return true;
}
catch (SocketException ex)
{
// 处理其他Socket错误,如连接失败
Console.WriteLine($"清空缓冲区时发生网络错误: {ex.SocketErrorCode}");
return false;
}
catch (ObjectDisposedException)
{
// Socket已被关闭
Console.WriteLine("套接字已被关闭。");
return false;
}
}
}
3. 实现具体协议的封装
- ModbusRTU的实现
public class ModbusRTU : ModbusSerial
{
public ModbusRTU() : this("COM1") { }
public ModbusRTU(string portName) : this(portName, 9600, Parity.None, 8, StopBits.One) { }
public ModbusRTU(string portName, int baudRate, Parity parity, int dataBits, StopBits stopBits) : base(portName, baudRate, parity, dataBits, stopBits) { }
// 01 02 03 04
// 从站地址、功能码、起始地址、数量
public override byte[] Read(byte slave, Functions func, ushort start, ushort count)
{
List<byte> bytes = this.GetReadBytes(slave, (byte)func, start, count).ToList();
bytes = CreateAdu(bytes);
// 适用于03,04功能码
int len = count * 2 + 5;
// 适用01、02
if (func == Functions.RCoilStatus || func == Functions.RInputCoils)
len = (int)Math.Ceiling(count * 1.0 / 8) + 5;
// 这个逻辑 适用 RTU ASCII
byte[] resp = this.SendAndReceive(bytes.ToArray(), len);
return this.Check(resp, len);
}
public override void Write(byte slave, Functions func, ushort start, ushort count, byte[] datas)
{
List<byte> bytes = this.GetWriteBytes(slave, (byte)func, start, count, datas).ToList();
bytes = this.CreateAdu(bytes);
byte[] resp = this.SendAndReceive(bytes.ToArray(), 8);
this.Check(resp, 8);
}
private List<byte> CreateAdu(List<byte> bytes)
{
byte[] crc = CRC16(bytes);
bytes.AddRange(crc.Reverse());
return bytes;
}
private byte[] Check(byte[] resp, int len)
{
if (resp.Length == 5 || resp.Length == len)
{
List<byte> check = resp.ToList().GetRange(0, resp.Length - 2);
check = CreateAdu(check);
if (!check.SequenceEqual(resp))
{
throw new ModbusException("数据传输异常,校验码不匹配");
}
if (resp[1] > 0x80)
{
throw new ModbusException(resp[2]);// 异常码
}
return resp.ToList().GetRange(3, len - 5).ToArray();
}
else
{
throw new ModbusException("通信响应异常");
}
}
}
- ModbusASCII的实现,封装RTU后,再封装ASCII就很简单了,对照参考一下即可。
public class ModbusASCII : ModbusSerial
{
public ModbusASCII() : this("COM1") { }
public ModbusASCII(string portName) : this(portName, 9600, Parity.None, 8, StopBits.One) { }
public ModbusASCII(string portName, int baudRate, Parity parity, int dataBits, StopBits stopBits) : base(portName, baudRate, parity, dataBits, stopBits) { }
public override byte[] Read(byte slave, Functions func, ushort start, ushort count)
{
List<byte> bytes = this.GetReadBytes(slave, (byte)func, start, count).ToList();
var ascii = CreateAdu(bytes);
// 适用于03,04功能码
// 如果请求10个寄存器,正常响应51个字节,
// 异常情况 5(RTU) 01 83 01 XX 4*2+3=11 bytes
// //
int len = (count * 2 + 4) * 2 + 3;
// 适用01、02
// 如果请求10个线圈,正常响应15
if (func == Functions.RCoilStatus || func == Functions.RInputCoils)
len = ((int)Math.Ceiling(count * 1.0 / 8) + 4) * 2 + 3;
byte[] resp = this.SendAndReceive(ascii.ToArray(), len);
return this.Check(resp, len);
}
public override void Write(byte slave, Functions func, ushort start, ushort count, byte[] datas)
{
List<byte> bytes = this.GetWriteBytes(slave, (byte)func, start, count, datas).ToList();
var ascii = CreateAdu(bytes);
// 7 *2 + 3 = 17
byte[] resp = this.SendAndReceive(ascii.ToArray(), 17);
this.Check(resp, 17);
}
private List<byte> CreateAdu(List<byte> bytes)
{
byte lrc = this.LRC(bytes.ToArray());
bytes.Add(lrc);
// 转ASCII字符 加头和尾
string str = ":" + string.Join("", bytes.Select(b => b.ToString("X2")));
List<byte> ascii = Encoding.ASCII.GetBytes(str).ToList();
ascii.Add(0x0D);
ascii.Add(0x0A);
return ascii;
}
private byte[] Check(byte[] resp, int len)
{
if (resp.Length == 11 || resp.Length == len)
{
// 检查是否完整
if (resp[0] != 0x3A ||
resp[resp.Length - 2] != 0x0D ||
resp[resp.Length - 1] != 0x0A)
throw new ModbusException("响应报文数据不完整");
// 去头和尾
List<byte> ascii = resp.ToList().GetRange(1, resp.Length - 3);
// 获取对应字符串
string str = Encoding.ASCII.GetString(ascii.ToArray());
// 将字符串转变成字节数组
byte[] datas = Convert.FromHexString(str);
// LRC校验
List<byte> check = datas.ToList().GetRange(0, datas.Length - 1);
check.Add(LRC(check.ToArray()));
if (!check.SequenceEqual(datas))
{
throw new ModbusException("数据传输异常,校验码不匹配");
}
// 报文异常码检查
if (datas[1] > 0x80)
{
throw new ModbusException(datas[2]);// 异常码
}
// 截取数据字节
return datas.ToList().GetRange(3, datas.Length - 4).ToArray();
}
else
throw new ModbusException("通信响应异常");
}
}
- ModbusTCP的实现
public class ModbusTCP : ModbusSocket
{
public ModbusTCP() : this("127.0.0.1") { }
public ModbusTCP(string ip) : this(ip, 502) { }
public ModbusTCP(string ip, int port) : base(ProtocolType.Tcp, ip, port) { }
int _tid = 0;
public override byte[] Read(byte slave, Functions func, ushort start, ushort count)
{
int tid = this.CreateTID();
List<byte> bytes = new List<byte>()
{
(byte)(tid/256),
(byte)(tid%256),
0x00,0x00,
0x00,0x06
};
bytes.AddRange(this.GetReadBytes(slave, (byte)func, start, count));
/// TCP/IP协议不需要传入长度,所以给一个默认为 -1 的长度
byte[] resp = this.SendAndReceive(bytes.ToArray(), -1);
if (resp[1] > 0x80)
{
throw new ModbusException(resp[2]);// 异常码
}
return resp.ToList().GetRange(3, resp.Length - 3).ToArray();
}
public override void Write(byte slave, Functions func, ushort start, ushort count, byte[] datas)
{
int tid = this.CreateTID();
List<byte> bytes = new List<byte>()
{
(byte)(tid/256),
(byte)(tid%256),
0x00,0x00
};
byte[] write = this.GetWriteBytes(slave, (byte)func, start, count, datas);
bytes.Add((byte)(write.Length / 256));
bytes.Add((byte)(write.Length % 256));
bytes.AddRange(write);
byte[] resp = this.SendAndReceive(bytes.ToArray(), -1);
if (resp[1] > 0x80)
{
throw new ModbusException(resp[2]);// 异常码
}
}
private static readonly object _lockObj = new object();
private int CreateTID()
{
lock (_lockObj)
{
_tid++;
_tid = _tid % 65535;
return _tid;
}
}
}
4. 实现协议报文分组解析
在封装ModbusBase基类的时候,我们预留了GetBytes和GetDatas方法,当时还没有实现,现在我们实现一下。
- 解析报文内容的实现
/// <summary>
/// 根据所提供的字节数组进行数据转换,通过泛型进行数据类型的指定
/// </summary>
public T[] GetDatas<T>(byte[] bytes, EndianType endianType = EndianType.ABCD)
{
if (typeof(T) == typeof(bool))
{
List<T> values = new List<T>();
for (int i = 0; i < bytes.Length; i++)
{
// 0000 0000
// 0000 0100 &
for (int j = 0; j < 8; j++)
{
dynamic dd = (bytes[i] & (1 << j)) > 0;
values.Add(dd);
}
}
return values.ToArray();
}
else
{
List<T> values = new List<T>();
// 2字节 ushort short
// 4bytes float int
int len = Marshal.SizeOf(typeof(T));
// 传入的bytes的数量一定是T长度的倍数
if (bytes.Length % len > 0)
throw new ModbusException("需要转换的字节数量无效");
//反射出BitConverter中所有的静态公共方法
//然后找出返回类型是T,并且是2个输入参数的方法
Type tbc = typeof(BitConverter);
MethodInfo[] mis = tbc.GetMethods(BindingFlags.Public | BindingFlags.Static);
MethodInfo? method = mis.FirstOrDefault(mi => mi.ReturnType == typeof(T) && mi.GetParameters().Count() == 2);
if (method == null)
throw new ModbusException("数据转换出错!未找到匹配的数据转换方法");
for (int i = 0; i < bytes.Length; i += len)
{
// 默认是大端排序
List<byte> dataTemp = bytes.ToList().GetRange(i, len);
// 调整字节序
byte[] sb = SwitchEndianType(dataTemp, endianType);
// short/ushort
//ushort us = BitConverter.ToUInt16(dataTemp.ToArray(), 0);
//float f = BitConverter.ToSingle(dataTemp.ToArray(), 0);
// 通过对应的BitConverter方法转换Byte[]数组到数据
// BitConverter 小端
if (BitConverter.IsLittleEndian)
sb = sb.Reverse().ToArray();
// 执行BitConverter的具体方法
var value = method.Invoke(tbc, new object[] { sb, 0 });
if(value == null)
throw new ModbusException("数据类型转换出错!");
values.Add((T)value);
}
return values.ToArray();
}
}
private byte[] SwitchEndianType(List<byte> datas, EndianType endianType)
{
switch (endianType)
{
case EndianType.ABCD:
case EndianType.ABCDEFGH:
return datas.ToArray();
case EndianType.CDAB:
return new byte[] { datas[2], datas[3], datas[0], datas[1] };
case EndianType.BADC:
return new byte[] { datas[1], datas[0], datas[3], datas[2] };
case EndianType.GHEFCDAB:
return new byte[] { datas[6], datas[7], datas[4], datas[5] ,
datas[2], datas[3], datas[0], datas[1] };
case EndianType.BADCFEHG:
return new byte[] { datas[1], datas[0], datas[3], datas[2] ,
datas[5], datas[4], datas[7], datas[6] };
case EndianType.DCBA:
case EndianType.HGFEDCBA:
datas.Reverse();
return datas.ToArray();
}
return datas.ToArray();
}
- 报文内容转成byte[]
public byte[] GetBytes<T>(T[] datas, EndianType endianType = EndianType.ABCD)
{
List<byte> result = new List<byte>();
// 5个状态
// 0000 0000
// 10个状态
// 0000 0000 0000 0000
// 0000 0001
// 1
if (typeof(T) == typeof(bool))
{
byte start = 0x00;
for (int i = 0; i < datas.Length; i++)
{
var item = datas[i];
byte bit = (byte)(bool.Parse(item!.ToString()!) ? 1 : 0);
bit = (byte)(bit << (i % 8));
start |= bit;
if ((i % 8) == 7)
{
result.Add(start);
start = 0x00;
}
}
// 判断
if (datas.Length % 8 > 0)
result.Add(start);
}
else
{
foreach (dynamic? item in datas)
{
byte[] db = BitConverter.GetBytes(item);
if (BitConverter.IsLittleEndian)
db = db.Reverse().ToArray();
// ABCD
db = SwitchEndianType(db.ToList(), endianType);
result.AddRange(db);
}
}
return result.ToArray();
}
5. 串口相关协议的读写分离问题
截至到上一部,通用库的基本功能已经实现了。而在生产系统中,我们可能会有一个循环的任务,会一直监听着接口数据。下面是通信库的调用
static void LibTest()
{
try
{
var modbus = new ModbusRTU();
modbus.Connect();
#region 同步读写
//while (true)
{
byte[] result = modbus.Read(1, Functions.RCoilStatus, 0, 5);
//6个寄存器-》12个字节-》3个长整型
//byte[] result = modbus.Read(1, Functions.RHoldingRegister, 0, 8);
//ushort[] values = modbus.GetDatas<ushort>(result);
//uint[] values = modbus.GetDatas<uint>(result);
//double[] values = modbus.GetDatas<double>(result, EndianType.BADCFEHG);
bool[] values = modbus.GetDatas<bool>(result);
Console.WriteLine(string.Join("-", result.Select(b => b.ToString("X2"))));
Console.WriteLine(string.Join("-", values.Select(v => v.ToString())));
}
{
// false false true true true
// 0001 1100
// 0x1C
//List<bool> values = new List<bool> { true, false, true, false, true, false, false, false, true, true };
//byte[] vb = modbus.GetBytes<bool>(values.ToArray());
//modbus.Write(1, Functions.WCoilStatus, 0,
// (ushort)values.Count,
// vb);
//List<uint> values = new List<uint>() { 1200, 1500 };
List<float> values = new List<float>() { 1.23f, 4.56f };
byte[] vb = modbus.GetBytes<float>(values.ToArray(), EndianType.CDAB);
modbus.Write(1, Functions.WHoldingRegister, 0,
(byte)(vb.Length / 2),
vb);
}
#endregion
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
全双工和TCP/IP能支持同时有读写任务进行,而如485这种半双工串口就会遇到读写分离的问题。
你可以把485通信想象成使用一部对讲机。对讲机有一个关键特性:你无法同时说和听。当你按住通话键时,你只能说话,听不到对方的声音;松开按键,你才能听到对方说话。
- 半双工 就是这个模式:通信双方可以互相通话,但同一时间,只能有一方在说。
- 相比之下,全双工则像打电话,双方可以同时说和听,互不干扰。
要解决读写分离问题,我们可以使用加锁,或者异步队列来解析,下面我们先直接在ModbusSerial类的SendAndReceive方法中,使用lock处理一下。
private static readonly object _lock = new object();
/// <summary>
///
/// </summary>
/// <param name="bytes">请求报文</param>
/// <param name="len">正常返回报文</param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
protected override byte[] SendAndReceive(byte[] bytes, int len)
{
lock (_lock)
{
if (!_serialPort.IsOpen)
throw new Exception("串口对象未连接");
_serialPort.Write(bytes.ToArray(), 0, bytes.Length);
// 响应数据获取
List<byte> resp = new List<byte>();
try
{
do
{
// 这里需要配合一个超时 2000
resp.Add((byte)_serialPort.ReadByte());/// 从缓冲区一个字节读
}
while (resp.Count < len);
}
catch
{
// 这里有一个超时异常
}
return resp.ToArray();
}
}
6. 队列和异步读写
- 定义异步模型,引入异步概念之后,Read和Write方法将不会被卡住,我们可以使用委托回调方法来实现。
我们先定义一个异步模型,这个模型需要定义一个id,传入数据、传出数据和一个回调方法。模型代码如下:
internal class AsyncModel
{
//id
public int Handler { get; set; }
public byte[] RequestBytes { get; set; } = null!;
public int ResponseLen { get; set; }
//回调方法
public required Action<byte[], int, Exception> Completed { get; set; }
//执行时间
public long TakeTime { get; set; }
}
- 接口和抽象基类添加对应的异步方法
public interface IModbus
{
void ReadAsync(byte slave, Functions func, ushort start, ushort count, int handler, Action<byte[], int, Exception> callback);
void WriteAsync(byte slave, Functions func, ushort start, ushort count, byte[] datas, int handler, Action<byte[], int, Exception> callback);
}
public abstract class ModbusBase : IModbus
{
//异步队列
internal List<AsyncModel> asyncModels = new List<AsyncModel>();
public abstract void ReadAsync(byte slave, Functions func, ushort start, ushort count, int handler, Action<byte[], int, Exception> callback);
public abstract void WriteAsync(byte slave, Functions func, ushort start, ushort count, byte[] datas, int handler, Action<byte[], int, Exception> callback);
}
- 异步方法的实现
public class ModbusRTU : ModbusSerial
{
public override void ReadAsync(byte slave, Functions func, ushort start, ushort count, int handler, Action<byte[], int, Exception> callback)
{
List<byte> bytes = this.GetReadBytes(slave, (byte)func, start, count).ToList();
bytes = CreateAdu(bytes);
int len = count * 2 + 5;
if (func == Functions.RCoilStatus || func == Functions.RInputCoils)
len = (int)Math.Ceiling(count * 1.0 / 8) + 5;
this.asyncModels.Add(new AsyncModel
{
Handler = handler,
RequestBytes = bytes.ToArray(),
ResponseLen = len,
Completed = callback
});
}
public override void WriteAsync(byte slave, Functions func, ushort start, ushort count, byte[] datas, int handler, Action<byte[], int, Exception> callback)
{
List<byte> bytes = this.GetWriteBytes(slave, (byte)func, start, count, datas).ToList();
bytes = this.CreateAdu(bytes);
this.asyncModels.Insert(0, new AsyncModel
{
Handler = handler,
RequestBytes = bytes.ToArray(),
ResponseLen = 8, //返回长度固定是8
Completed = callback
});
}
}
public class ModbusASCII : ModbusSerial
{
public override void ReadAsync(byte slave, Functions func, ushort start, ushort count, int handler, Action<byte[], int, Exception> callback)
{
List<byte> bytes = this.GetReadBytes(slave, (byte)func, start, count).ToList();
var ascii = CreateAdu(bytes);
int len = (count * 2 + 4) * 2 + 3;
if (func == Functions.RCoilStatus || func == Functions.RInputCoils)
len = ((int)Math.Ceiling(count * 1.0 / 8) + 4) * 2 + 3;
this.asyncModels.Add(new AsyncModel
{
Handler = handler,
RequestBytes = bytes.ToArray(),
ResponseLen = len,
Completed = callback
});
}
public override void WriteAsync(byte slave, Functions func, ushort start, ushort count, byte[] datas, int handler, Action<byte[], int, Exception> callback)
{
List<byte> bytes = this.GetWriteBytes(slave, (byte)func, start, count, datas).ToList();
var ascii = CreateAdu(bytes);
this.asyncModels.Add(new AsyncModel
{
Handler = handler,
RequestBytes = bytes.ToArray(),
ResponseLen = 17, //固定值
Completed = callback
});
}
}
- 启动后台线程,循环发送数据
- 读取到的数据可能是异常报文,因此也需要check方法。我们可以把RTU和ASCII中的check方法提炼一个抽象到
public abstract class ModbusSerial : ModbusBase
{
public override void Connect()
{
_serialPort.PortName = PortName;
_serialPort.BaudRate = BaudRate;
_serialPort.DataBits = DataBits;
_serialPort.Parity = Parity;
_serialPort.StopBits = StopBits;
_serialPort.ReadTimeout = ReadTimeout;
_serialPort.ReadBufferSize = ReadBufferSize;
_serialPort.Open();
// 这里可以启动一个后台线程进行队列数据的获取和请求
Task.Run(() =>
{
while (!cts.IsCancellationRequested)
{
if (_serialPort == null || asyncModels.Count == 0) continue;
var model = asyncModels.First();
Exception? _ex = null;
byte[]? datas = null;
try
{
Console.WriteLine(string.Join("-", model.RequestBytes.Select(b => b.ToString("X2"))));
byte[] resp = this.SendAndReceive(model.RequestBytes, model.ResponseLen);
// 执行校验逻辑,返回的应该是数据部分字节,同时中间可能还有异常
// 调用RTU的Check方法
// 调用ASCII的Check方法
datas = this.Check(resp, model.ResponseLen);
}
catch (Exception ex)
{
_ex = ex;
}
finally
{
asyncModels.RemoveAll(id => id.Handler == model.Handler);
// 执行完成时,将结果通过委托方法传出
model.Completed?.Invoke(datas, model.Handler, _ex);
}
}
});
}
public override void Disconnect()
{
_serialPort.Close();
cts.Cancel();
}
}
- 通信库异步使用方法
IModbus modbus = new ModbusRTU()
static void Main(string[] args)
{
try
{
modbus.Connect();
// 读写同步 : 总线同一时刻 只能有一个报文
modbus.ReadAsync(1, Functions.RHoldingRegister, 0, 5, 123, ReadCompleted);
// 异步写
List<ushort> values = new List<ushort>() { (ushort)1200, (ushort)1500 };
byte[] reqDatas = modbus.GetBytes<ushort>(values.ToArray());
modbus.WriteAsync(1, Functions.WHoldingRegister, 0, 2, reqDatas, 456, WriteCompleted);
Console.ReadLine();
modbus.Disconnect();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
Console.ReadLine();
}
static void ReadCompleted(byte[] bytes, int id, Exception ex)
{
Console.WriteLine(id);
if (ex != null)
{
Console.WriteLine(ex.Message);
return;
}
ushort[] values = modbus.GetDatas<ushort>(bytes);
Console.WriteLine(string.Join("-", values.Select(v => v.ToString())));
Thread.Sleep(1000);
modbus.ReadAsync(1, Functions.RHoldingRegister, 0, 5, 123, ReadCompleted);
}
static void WriteCompleted(byte[] bytes, int id, Exception ex)
{
Console.WriteLine(id);
if (ex != null)
{
Console.WriteLine(ex.Message);
return;
}
}
7. 扩展功能——按地址读写
public abstract class ModbusBase : IModbus
{
// 00001
// 10001
// 30001
// 40001
public byte[] Read(byte slave, string variable, bool isBaseOne = false)
{
(Functions, int) result = this.GetAddress(variable, true, isBaseOne);
return this.Read(slave, result.Item1, (ushort)result.Item2, 1);
}
public void Write(byte slave, string variable, ushort count, byte[] datas, bool isBaseOne = false)
{
(Functions, int) result = this.GetAddress(variable, false, isBaseOne);
this.Write(slave, result.Item1, (ushort)result.Item2, count, datas);
}
/// <param name="isBaseOne">是否是按1做为起始地址</param>
private (Functions, int) GetAddress(string variable, bool isRead, bool isBaseOne)
{
// 解析地址
Functions func;
string area = variable[0].ToString();
if (area == "0")
func = isRead ? Functions.RCoilStatus : Functions.WCoilStatus;
else if (area == "1")
func = Functions.RInputCoils;
else if (area == "3")
func = Functions.RInputRegister;
else if (area == "4")
func = isRead ? Functions.RHoldingRegister : Functions.WHoldingRegister;
else
throw new Exception("地址格式不正确,无法识别");
string s_addr = variable.Substring(1);
int i_addr = int.Parse(s_addr);
if (!isBaseOne)
i_addr -= 1;
return (func, i_addr);
}
}
- 调用
//读
byte[] bytes = modbus.Read(1, "40001", true);
//写
List<ushort> vs = new List<ushort>() { 111, 222 };
byte[] dataBytes = ModbusMaster.GetBytes<ushort>(vs.ToArray());
modbus.Write(1, "40001", 2, dataBytes);
