Spiga

通信协议: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 协议报文格式

  1. 读寄存器消息帧格式 - 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 };
}
  1. 写单寄存器消息帧格式 – 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);
}
  1. 写多寄存器消息帧格式 – 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());
}
  1. 读线圈消息帧格式 - 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);
        }
    }
}
  1. 写单线圈消息帧 - 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);
}
  1. 写多线圈消息帧 – 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协议其他处理

  1. 地址范围

    从站地址:0-255 256个数字 0 广播(写入动作-》不用回复) 1—247 有效的地址范围 255 Modbus的 轮询

  2. 数据异常(数据无法正常解析:数据响应正常报文)

    大小端存储问题导致数据解析不正确,字节分 试一下

  3. 异常处理原理(响应异常:正常请求 正常响应异常报文)

    如果请求发生异常(设备处理不了),响应的报文会状功能码的最高位置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]
    }

    // 通信异常
}
  1. 请求频率异常(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]);
    }
}
  1. 报文长度限制
  • 0x07D0 线圈的单次请求长度

  • 0x007D 寄存器单次请求长度

  1. 读写同步问题
  • 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的创建
      • 字节序调整
    • 解析过程
    • 读操作
    • 写操作
  • 扩展功能

    • 读写同步
    • 地址解析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);