1.概述
在我的文章“C# ModbusRTU命令功能码”和"C# Modbus ASCII命令功能码"中给出了Modbus两种传输方式的主要8条功能代码,它们都是byte[]数组,都是独立的指令代码,并没有集成到串口发送中来,这样做是为了扩大它的应用范围,这些独立的功能代码即可以应用于RS232/485/422转网络传输的环境中,也可以应用于Modbus串行总线部署,本文就给出它们应用于Modbus串口通信发送和接收数据的程序代码。
2.程序代码
由于本文的代码是要使用前面说过两篇文章的功能代码,所以首先要把两篇文章中的ModbusRTU类和ModbusASCII类放入本文代码的同一命名空间中(或者引用所在的命名空间),其次这两个类的8条主要功能代码方法名和参数都一样,因此采用了反射来调用这两个类的方法(当然大家也可以声明定义这两个类去使用,但是会增加代码量)。
我们需要使用下面四个枚举,Errors枚举相比我原来两篇文章的Errors枚举有了几个新增项。
#region 枚举
/// <summary>
/// ModbusRTU类/ModbusASCII类方法名
/// </summary>
public enum ModbusSerialCMD
{
ReadCoilStatus_0x01 = 0x01,
ReadInputStatus_0x02 = 0x02,
ReadHoldingRegister_0x03 = 0x03,
ReadInputRegister_0x04 = 0x04,
WriteSingleCoil_0x05 = 0x05,
WriteSingleRegister_0x06 = 0x06,
WriteMultipleCoil_0x0F = 0x0F,
WriteMultipleRegister_0x10 = 0x10
}
/// <summary>
/// Modbus的类型
/// </summary>
public enum ModbusType
{
/// <summary>
/// Modbus serial RTU
/// </summary>
SERIAL_RTU = 0,
/// <summary>
/// Modbus serial ASCII
/// </summary>
SERIAL_ASCII = 1,
/// <summary>
/// Modbus TCP/IP
/// </summary>
TCP_IP = 2,
}
/// <summary>
/// Modbus指令代码
/// </summary>
public enum ModbusCodes
{
READ_COILS = 0x01, //读取线圈
READ_DISCRETE_INPUTS = 0x02, //读取离散量输入
READ_HOLDING_REGISTERS = 0x03, //读取保持寄存器
READ_INPUT_REGISTERS = 0x04, //读取输入寄存器
WRITE_SINGLE_COIL = 0x05, //写单个线圈
WRITE_SINGLE_REGISTER = 0x06, //写单个保持寄存器
READ_EXCEPTION_STATUS = 0x07, //读取异常状态
DIAGNOSTIC = 0x08, //回送诊断校验
GET_COM_EVENT_COUNTER = 0x0B, //读取事件计数
GET_COM_EVENT_LOG = 0x0C, //读取通信事件记录
WRITE_MULTIPLE_COILS = 0x0F, //写多个线圈
WRITE_MULTIPLE_REGISTERS = 0x10, //写多个保持寄存器
REPORT_SLAVE_ID = 0x11, //报告从机标识(ID)
READ_FILE_RECORD = 0x14, //读文件记录
WRITE_FILE_RECORD = 0x15, //写文件记录
MASK_WRITE_REGISTER = 0x16, //屏蔽写寄存器
READ_WRITE_MULTIPLE_REGISTERS = 0x17, //读写多个寄存器
READ_FIFO_QUEUE = 0x18, //读取队列
READ_DEVICE_IDENTIFICATION = 0x2B //读取设备标识
}
/// <summary>
/// 错误代码
/// </summary>
public enum Errors
{
NO_ERROR = 0,//无错误
EXCEPTION_UNKNOWN = 1,//未知异常
EXCEEDING_MODBUSCODE_RANGE = 2,//超出ModbusCode范围
UNPROCESSED_MODBUSCODE = 3,//没有处理的ModbusCode
WRONG_RESPONSE_ADDRESS = 4,//响应地址错误
WRONG_RESPONSE_REGISTERS = 5,//响应寄存器错误
WRONG_RESPONSE_VALUE = 6,//响应值错误
WRONG_CRC = 7,//CRC16校验错误
WRONG_LRC = 8,//LRC校验错误
TOO_MANY_REGISTERS_REQUESTED = 9,//请求的寄存器数量太多
ZERO_REGISTERS_REQUESTED = 10,//零寄存器请求
EXCEPTION_ILLEGAL_FUNCTION = 20,//非法的功能码
EXCEPTION_ILLEGAL_DATA_ADDRESS = 21,//非法的数据地址
EXCEPTION_ILLEGAL_DATA_VALUE = 22,//非法的数据值
EXCEPTION_SLAVE_DEVICE_FAILURE = 23,//从站(服务器)故障
EXCEPTION_SYSTEM = 30,//系统异常
SERIALPORT_NOT_OPEN = 31,//串口没有打开
EXCEPTION_SERIALPORT = 32,//串口异常
INVALID_CHAR_PACK = 33,//无效字符数据包
RX_TIMEOUT = 34,//接收超时
EXCEPTION_SERIAL_SEND_CMD = 35//串口发送指令异常
}
#endregion
下面为串口通信发送Modbus功能码指令和接收Modbus返回信息的代码。
#region ModbusRTU/ModbusASCII串口通信类
public class ModbusSerial
{
#region 内部变量
//串口
private SerialPort serialPort;
//串口默认(一般波特率为9600)请求延时时间(毫秒)
private int rxTimeout = 5000;
//字符之间的时间间隔
private int charDelay = 0;
//信息帧之间的时间间隔
private int frameDelay = 0;
//从站连接状态
private bool _connect = false;
//错误提示
private Errors _error;
//Modbus串口类型
private Type ModbusSerialClass = null;
//字符间超时计时器
private Stopwatch stopWatch = new Stopwatch();
// 接收字符时间
private long charReadTime = 0L;
#endregion
#region 属性
/// <summary>
/// 从站连接状态(只读)
/// </summary>
public bool Connected { get { return _connect; } }
/// <summary>
/// Modbus错误(只读)
/// </summary>
public Errors Error { get { return _error; } }
#endregion
#region 内部方法
/// <summary>
/// 两个字符之间的时间间隔,以1.5字符为基准(1个字符为:1起位位+7/8数据位+1位奇偶校验位+1/2停止位),单位为毫秒
/// ModbusRTU一个字符数据位是8,有奇偶校验共11位,无奇偶校验是10位
/// ModbusASCII一个字符数据位是7,有奇偶校验共10位,无奇偶校验是9位
/// </summary>
/// <param name="sp">串口数据</param>
/// <returns>1个字节 + 1.5字符时间间隔数值(从读取一个字节开始到下个字节开始)</returns>
private int CharDelay(SerialPort sp)
{
int charTime;
if (sp.BaudRate > 19200)
charTime = 1; // 固定值,0.75ms向上取整
else
{
int charBits = 1 + sp.DataBits;
charBits += sp.Parity == Parity.None ? 0 : 1;
switch (sp.StopBits)
{
case StopBits.One:
charBits += 1;
break;
// 停止位1.5按2位计算
case StopBits.OnePointFive:
case StopBits.Two:
charBits += 2;
break;
}
charTime = Convert.ToInt32(Math.Ceiling(((double)charBits * (1d + 1.5d) * 1000) / (double)sp.BaudRate));
}
return charTime;
}
/// <summary>
/// Modbus两个信息帧之间的时间间隔,3.5个字符传输时间为基准,单位为毫秒
/// </summary>
/// <param name="sp">串口数据</param>
/// <returns>3.5个字符的时间间隔</returns>
private int FrameDelay(SerialPort sp)
{
int frameTime;
if (sp.BaudRate > 19200)
frameTime = 2; // 固定值,1.75ms向上取整
else
{
int frameBits = 1 + sp.DataBits;
frameBits += sp.Parity == Parity.None ? 0 : 1;
switch (sp.StopBits)
{
case StopBits.One:
frameBits += 1;
break;
case StopBits.OnePointFive: // Ceiling
case StopBits.Two:
frameBits += 2;
break;
}
frameTime = Convert.ToInt32(Math.Ceiling(((double)frameBits * 3.5d * 1000) / (double)sp.BaudRate));
}
return frameTime;
}
#endregion
#region 对外事务处理
//向串口正确发送完数据后调用
public Action<Type, byte[]> SendFinished { get; set; }
//串口正确返回数据后调用
public Action<byte[]> ReceiveFinished { get; set; }
#endregion
#region 构造函数
/// <summary>
/// 构造函数,初始化串口
/// </summary>
/// <param name="portName">串口名称(如COM1)</param>
/// <param name="baudRate">波特率</param>
/// <param name="dataBits">数据位数</param>
/// <param name="parity">奇偶校验</param>
/// <param name="stopbits">停止位</param>
/// <param name="handshake">握手协议(XOnXOff等)</param>
public ModbusSerial(ModbusType modbusType, string portName, int baudRate, int dataBits, System.IO.Ports.Parity parity, System.IO.Ports.StopBits stopbits, System.IO.Ports.Handshake handshake)
{
try
{
//使用的Modbus类型
if (modbusType == ModbusType.SERIAL_RTU)
ModbusSerialClass = Type.GetType("FYS.ModbusRTU");//FYS是命名空间名称,ModbusRTU是该命名空间下的ModbusRTU类
else if (modbusType == ModbusType.SERIAL_ASCII)
ModbusSerialClass = Type.GetType("FYS.ModbusASCII");//FYS是命名空间名称,ModbusASCII是该命名空间下的ModbusASCII类
else
ModbusSerialClass = Type.GetType("FYS.ModbusRTU");//默认为ModbusRTU类
}
catch
{
ModbusSerialClass = null;
_error = Errors.EXCEPTION_SYSTEM;//系统异常
}
//初始化串口参数
serialPort = new SerialPort();
serialPort.PortName = portName;
serialPort.BaudRate = baudRate;
serialPort.DataBits = dataBits;
serialPort.Parity = parity;
serialPort.StopBits = stopbits;
serialPort.Handshake = handshake;
//初始化字符传输时间最小间隔
charDelay = CharDelay(serialPort);
//初始化信息帧传输时间最小间隔
frameDelay = FrameDelay(serialPort);
}
#endregion
#region 连接/断开串口
public bool Connect()
{
try
{
serialPort.Open();
if (serialPort.IsOpen)
{
serialPort.DiscardInBuffer();
serialPort.DiscardOutBuffer();
_connect = true;
}
else
_connect = false;
}
catch
{
_connect = serialPort.IsOpen;
}
return _connect;
}
public void DisConnect()
{
if (serialPort.IsOpen)
{
try
{
serialPort.Close();
_connect = false;
}
catch (System.IO.IOException)
{
_connect = serialPort.IsOpen;
}
}
}
#endregion
#region 发送Modbus指令函数
/// <summary>
/// 向串口发送8条主要的ModbusRTU/ModbusASCII功能代码,并采集ModbusSlave(从站)返回的结果
/// </summary>
/// <param name="mdCode">ModbusCodes枚举功能码</param>
/// <param name="SlaveId">从站号</param>
/// <param name="SlaveAddress">从站起始地址</param>
/// <param name="Values">对应功能的数值</param>
/// <returns>串口返回的ModbusSlave结果</returns>
public byte[] Send(ModbusCodes mdCode, byte SlaveId, ushort SlaveAddress, object Values)
{
_error = Errors.NO_ERROR;
//获取ModbusRTU/ModbusASCII类的功能码名称,即方法名称
string ModbusCMD = Enum.GetName(typeof(ModbusSerialCMD), mdCode);
//功能码方法的参数组
object[] param = null;
//接收发送指令返回的数据
List<byte> ReadBuffer = new List<byte>();
try
{
//生成功能码方法的参数组
switch (mdCode)
{
case ModbusCodes.READ_COILS://功能码0x01
case ModbusCodes.READ_DISCRETE_INPUTS://功能码0x02
case ModbusCodes.READ_HOLDING_REGISTERS://功能码0x03
case ModbusCodes.READ_INPUT_REGISTERS://功能码0x04
case ModbusCodes.WRITE_SINGLE_REGISTER://功能码0x06
//(ushort)object这样显式/强制转换会报异常,所以改成Convert.ToUint16
param = new object[] { SlaveId, SlaveAddress, Convert.ToUInt16(Values) };
break;
case ModbusCodes.WRITE_SINGLE_COIL://功能码0x05
param = new object[] { SlaveId, SlaveAddress, (bool)Values };
break;
case ModbusCodes.WRITE_MULTIPLE_COILS://功能码0x0F
param = new object[] { SlaveId, SlaveAddress, (bool[])Values };
break;
case ModbusCodes.WRITE_MULTIPLE_REGISTERS://功能码0x10
//(ushort)object这样转换会报异常。
//转换方法1:ushort[]用IEnumerabler.Cast()方法序列化object,
//然后再通过Select方法将每个元素Convert.ToUInt16,如下面语句
//ushort[] values = ((IEnumerable)Values).Cast<object>().Select(u => Convert.ToUInt16(u)).ToArray();
//转换方法2:用 as 操作符
param = new object[] { SlaveId, SlaveAddress, Values as ushort[] };
break;
default: //其他Modbus指令不做处理
_error = Errors.UNPROCESSED_MODBUSCODE;
return null;
}
//通过反射得到对应的功能码方法
MethodInfo Method = ModbusSerialClass.GetMethod(ModbusCMD);
//通过反射调用该方法,得到功能码的byte[]数组
byte[] buffer = (byte[])Method.Invoke(Activator.CreateInstance(ModbusSerialClass), param);
if (buffer != null)
{
if (!Connected)
{
_error = Errors.SERIALPORT_NOT_OPEN;
return null;
}
//ModbusRTU每帧数据需要有3.5字符时间间隔
if (ModbusSerialClass.Name.Contains("ModbusRTU"))
{
System.Threading.Thread.Sleep(frameDelay);
}
//向串口写入信息帧
serialPort.Write(buffer, 0, buffer.Length);
//复位接收字符时间
charReadTime = 0;
//发送完后的回调
SendFinished?.BeginInvoke(ModbusSerialClass, buffer, null, null);
//接收发送指令返回的数据
byte[] backBuffer = ReceiveBytes2();
if (backBuffer == null)
{
return null;
}
//返回串口采集到的数据
return backBuffer;
}
else//没有生成发送指令
{
_error = Errors.EXCEPTION_SERIAL_SEND_CMD;
return null;
}
}
catch
{
_error = Errors.EXCEPTION_SYSTEM;
return null;
}
}
#region 忽略字符间的1.5字符时间间隔,采集串口返回数据
/// <summary>
/// 读取ModbusSlave(从站)返回数据,忽略字符间1.5个字符的时间间隔
/// </summary>
/// <returns>从站返回的byte[]数据</returns>
public byte[] ReceiveBytes2()
{
_error = Errors.NO_ERROR;
//数据接收成功
bool isFinished = false;
//读取的数据
List<byte> readBuffer = new List<byte>();
//开始计时
if (!stopWatch.IsRunning)
stopWatch.Start();
//在读取数据的超时时间内及没有读完数据时循环进行取回返回数据
while (rxTimeout - stopWatch.ElapsedMilliseconds > 0 && !isFinished)
{
//串口有返回数据
if (serialPort.BytesToRead > 0)
{
int reByte = -1;
try
{
//读取一个字节
reByte = serialPort.ReadByte();
readBuffer.Add((byte)reByte);
}
catch (InvalidOperationException)
{
_error = Errors.EXCEPTION_SERIALPORT;
stopWatch.Stop();
return null;
}
}
else//串口没有数据
{
//没有收到过任何一个字符,就继续循环等待
if (readBuffer.Count == 0)
{
continue;
}
//收到过字符,已经没有字符接收了
if (readBuffer.Count != 0)
{
isFinished = true;
}
}
}
if (stopWatch.IsRunning)
stopWatch.Stop();
//接收超时
if (!isFinished)
{
_error = Errors.RX_TIMEOUT;
return null;
}
//正确采集完串口数据
ReceiveFinished?.BeginInvoke(readBuffer.ToArray(), null, null);
return readBuffer.ToArray();
}
#endregion
#region 考虑字符间有1.5字符时间间隔的方式采集串口返回数据
/// <summary>
/// 读取ModbusSlave(从站)返回数据,采用字符间1.5个字符的时间间隔
/// </summary>
/// <returns>从站返回的byte[]数据</returns>
public byte[] ReceiveBytes()
{
_error = Errors.NO_ERROR;
//数据接收成功
bool isFinished = false;
//读取的数据
List<byte> readBuffer = new List<byte>();
//开始计时
if (!stopWatch.IsRunning)
stopWatch.Start();
//在读取数据的超时时间内及没有读完数据时循环进行取回返回数据
while (rxTimeout - stopWatch.ElapsedMilliseconds > 0 && !isFinished)
{
//串口有数据
if (serialPort.BytesToRead > 0)
{
int reByte = -1;
try
{
//读取一个字节
reByte = serialPort.ReadByte();
}
catch (InvalidOperationException)
{
_error = Errors.EXCEPTION_SERIALPORT;
stopWatch.Stop();
return null;
}
//从循环开始到读取完一个字节所用时间小于读取1个字节+1.5个字符间隔时间
if (stopWatch.ElapsedMilliseconds - charReadTime < charDelay)
{
charReadTime = stopWatch.ElapsedMilliseconds;
//增加到收到数据序列中
readBuffer.Add((byte)reByte);
continue;
}
else//大于1+1.5个字符时间
{
_error = Errors.INVALID_CHAR_PACK;
stopWatch.Stop();
return null;
}
}
else//串口没有数据
{
//没有收到过任何一个字符,就继续循环等待
if (readBuffer.Count == 0)
{
charReadTime = stopWatch.ElapsedMilliseconds;
continue;
}
//收到过字符,已经没有字符接收了
if (readBuffer.Count != 0)
{
charReadTime = stopWatch.ElapsedMilliseconds;
isFinished = true;
}
}
}
if (stopWatch.IsRunning)
stopWatch.Stop();
//接收超时
if (!isFinished)
{
_error = Errors.RX_TIMEOUT;
return null;
}
//正确采集完串口数据
ReceiveFinished?.BeginInvoke(readBuffer.ToArray(), null, null);
return readBuffer.ToArray();
}
#endregion
#endregion
}
#endregion
3.代码说明
3.1、上述代码考虑了ModbusRTU串口通信规范中的要求,一是每个字节(1位起始位 + 8位数据位 + 1位奇偶校验位【无奇偶校验则为0位】 + 1位停止位)之间要小于等于1.5字节的时间间隔,二是传输的信息帧之间要有大于等于3.5字节的时间间隔,程序中用了C#的精确测量计时类Stopwatch来计算字节和信息帧的传输间隔时间。经过测试,发送ModbusRTU指令时可以保证正确,但是在接收返回结果时,程序ReceiveBytes()会有大半概率发生字节之间超过1.5字节时间间隔的现象,导致返回数据不完整。比如按常用的波特率9600来计算,传输一个字节为1.042毫秒,1.5个字节为1.563毫秒,从接收一个字节开始到接收下一个字节开始为2.5个字节时间,即2.605毫秒,向上取整为3毫秒,但实际测试时,我发现常有超过3毫秒情况,甚至有30多毫秒的,不知道是我测试的设备有问题,还是Windows系统计时器与Modbus设备计时存在差异,或者是其他什么情况,这我也不清楚了,用C语言或汇编语言编写单片机的人肯定会知道原因,而我只是用C#高级语言编写串口通信,这方面我知识欠缺,因此就不再探讨了,总之,我又写了一个不考虑字符时间间隔的ReceiveBytes2()方法,只要串口有数据(BytesToRead > 0)就去读取,直到串口没有数据返回。如果串口一直没有返回数据,会按照默认的5秒延时后返回空数据。
3.2、程序如果返回空值,可以查看错误状态Error的值来确定产生原因。
3.3、程序增加了两个外暴回调SendFinished和ReceiveFinished,可以在发送完指令和接收成功数据后,根据自己需求增加相应操作。
3.4、程序通过构造函数先初始化串口,然后再连接串口,连接成功后再发送相应指令。
3.5、程序的接收方法ReceiveBytes()或ReceiveBytes2()可以单独使用,自己根据实际环境需求按条件进行监听查询。
3.6、程序有些细节已经在注释中写明,这里不再赘述。
4.程序使用举例
假如我们使用ModbusASCII串口通讯方式向起始地址为4000(0x0FA0)的保持寄存器写入两个数据10(0x0A)和258(0x0102),语句如下:
ModbusSerial MS = new ModbusSerial(ModbusType.SERIAL_ASCII, "COM5", 9600, 7, System.IO.Ports.Parity.None, System.IO.Ports.StopBits.One, System.IO.Ports.Handshake.None);
if (MS.Connect())
{
MS.Send(FYS.ModbusCodes.WRITE_MULTIPLE_REGISTERS, 0x01, 0x0FA0, new ushort[] { 0x000A, 0x0102 });
}
MS.DisConnect();
我用的USB转串口线连接到电脑上,电脑就出现了COM5串口,所以在上面语句中端口名称是“COM5”。我们采用是ModbusASCII,这里数据位我选择了7位(也可以是8位),波特率是9600,停止位1位,无奇偶校验和流控制。
初始化完毕串口后,判断连接成功,发送了写多个保持寄存器的指令WRITE_MULTIPLE_REGISTERS,从站号是1,起始地址0x0FA0,即4000,数据为两个数值0x000A和0x0102。用Modbus虚拟助手调试,看出结果正确,如下图:
我们再用ModbusRTU方式来读取上面保持寄存器的值(Modbus虚拟助手也要由ModbusASCII改为ModbusRTU,数据位由7改成8,要和电脑端的串口配置改成一样),语句如下:
List<byte> results = new List<byte>();
ModbusSerial MS = new ModbusSerial(ModbusType.SERIAL_RTU, "COM5", 9600, 8, System.IO.Ports.Parity.None, System.IO.Ports.StopBits.One, System.IO.Ports.Handshake.None);
if (MS.Connect())
{
byte[] backValues = MS.Send(FYS.ModbusCodes.READ_HOLDING_REGISTERS, 0x01, 0x0FA0, 2);
results.AddRange(backValues);
}
MS.DisConnect();
结果运行如下:
从图可以看出,返回数据第1位是站号,第二位是功能号0x03,第三位后面数值的字节长度为4,第4位与第5位是寄存器4000的值0x000A,第6位和第7位是寄存器4001的值0x0102,最后两位是CRC16校验值。因此结果也完全正确。
对于Modbus返回的数据如何转换为具体的应用数值,在PLC或其他支持Modbus的设备一般都会有具体格式说明,比如Modicon PLC返回int和float的数据格式都为3412。如果大家有兴趣进一步了解如何转换,请参考我另一篇文章"C# ModbusRTU、ModbusASCII及ModbusTCP返回数据解析为数值,功能码0x01-0x04使用1234/2143/3412/4321等字节顺序解析值"。