我现在从事的C#工控机的开发,所以接下来会写一个系列关于上位机如何和工控机/PLC/各种仪表通信。希望能帮助到有需要的人(我假设你有过windows C#编程经验的)。
1、Vs.net 版本选择
2、串口通信工具/监控工具如何使用
3、如何和三菱FXPLC通信 单个地址/连续地址读写
4、如何和西门子PLC通信
5、仪表通信(Text/Hex),和校验、CRC校验
6、仪表通讯中数字的几种表示方法
7、Modbus TCP通信
8、NI控件介绍
9、多线程处理
10、实例-电机检测软件
如果你要用win7 32位系统,直接操作IO端口的话,最好选择vs2013及之前的版本,因为之后的版本屏蔽的直接IO操作了。(题外话,可以用一个winio.dll 2.0的版本就可以在win7 32位绕过保护模式直接操作板卡的IO,64位的就必须是认证的驱动程序才能操作板块IO了)
.net framework的版本,如果不需要兼容xp的话,就选4.5.2以上版本。如果要xp的话,就只能4.0了。没办法,工控这个行业就是这么outdate, 【生成】tab里平台目标选择X86
数据库可以用Access,SQLServer2012(考虑到兼容以前的VB)
做工控项目,界面上免不了要显示按钮,仪表,图表等,这样用到一些第三方的控件,开发才能事半功倍, 比如 National Instruments 的 Measurement Studio。 这个官网下载的只有最新版本,其他旧版本得自己网上找, Ni的控件有包括仪表盘,图表,波形图,傅里叶变换等,还有ComponentOne的FlexGrid也很有用。
工控机通常都带有很多串口(10个),而且可以通过Moxa卡扩展串口. 但Moxa的串口和电脑自带的串口还是有点区别 C#里面没区别, 但之前VB6的MSComm控件有时就会有不一样的地方.
支持串口通讯的仪表,通常通讯指令分2种,一种是文本格式的,另一种是16进制格式的.
文本格式的,比如说有些仪器,查版本号发 *IDN? 就会返回文本格式的结果,例如 XXX 8905,502-H19-1449,V1.38.02.18A2
16进制通讯的,比如青智的电流表,查询电流用的命令格式 01是仪表地址, 03是读取的命令.1000是寄存器开始地址,000A是读取长度, C10D是CRC校验码(多数使用CRC,也有仪器使用和校验的)
还有一种仪器是自带MCU,就是一打开串口就自动上传数据给上位机, 这种就不需要命令了.只需要定时读取串口缓冲区的内容,按照报文的格式,分析出哪一段数据才是你需要的.
我常用的串口通讯工具有下面2个
在Github下载一个ComDBG的工具,这个是C#写的,可以自己根据代码扩展需要的功能,比如历史发送记录
另外可以用一个监控工具,串口监控精灵, 这个对于那些没有代码的exe(比如一些仪表自带有一些小软件), 我们直接监控某个串口的收发信息.就知道对应的命令是什么了.
我通常把串口通讯做成一个基类, 把打开/关闭串口,文本命令,16进制命令,CRC校验,和校验都写到基类了,方便调用
下面是部分方法的代码
public Dictionary<int,BaseModel> Signals {
get; set; }
///
/// 构造
///
/// 站号
/// 端口号
/// 波特率
/// 数据位
/// 停止位
/// 校验
public ModbusController(byte station,string port,int baundBits,int dataBits, int stop, Parity part)
{
Rtu = new ModbusRtu(station);
Rtu.SerialPortInni(sp =>
{
sp.PortName = port;
sp.BaudRate = baundBits;
sp.DataBits = dataBits;
sp.StopBits = stop == 0?StopBits.None : (stop == 1 ? StopBits.One : StopBits.Two);
sp.Parity = part;
});
Signals = new Dictionary<int, BaseModel>();
}
#region 通讯
///
/// 打开
///
///
public bool Connect()
{
bool res = false;
try
{
Rtu.Open();
IsOpen = res = Rtu.IsOpen();
}
catch (Exception)
{
return false;
}
return res;
}
///
/// 关闭
///
///
public bool Close()
{
bool res = false;
if (Rtu != null && Rtu.IsOpen())
{
try
{
Rtu.Close();
res = true;
}
catch (Exception)
{
return false;
}
}
else
{
res = true;
}
return res;
}
#endregion
工作中用的比较多的是三菱的PLC的 Fx5U和Fx3U,它们有多种通信协议,我们学习时先从1种入手,再扩展到其他的。三菱的说明书几百页,我们要把说明书读薄,只选其中通信协议部分看就好了。而上位机一开始只需要了解读写一个字元/位元就可以了。
三菱FX-3U 计算机专用协议通信方式,其通讯命令字和通讯格式介绍如下:
命令字 注释
BR 以1点为单位,读出位元件的状态
WR 以16点为单位,读出位元件的状态,或以1字为单位,读出字元件的值
BW 以1点为单位,写入位元件的状态
WW 以16点为单位,写入位元件的状态,或以1字为单位,写入值到字元件
PC发送给PLC的通信命令格式
约定说明:ENQ为请求标志,ASCII值5
ACK为正确标志,ASCII值6
STX为请求标志,ASCII值2
EXT为请求标志,ASCII值3
表格中粗体字为需要求和效验的部分;
和效验为每一项的ASCII值的总和转换成十六进制后,取其低两位;
站号、PLC号、元件数量、和效验都是以十六进制表示;
等待延时为0-150毫秒,以十六进制0H-FH表示,如100ms为AH
1) 批量读出位元件—BR指令格式
例如:要读出站号为5的PLC的X40到X44共5点的状态值,延时100毫秒,
假设PLC中X40与X43为OFF,其余为ON,则指令数据如下:
注释: 请求 站号 PLC号 命令 延时 元件首地址 元件数量 和校验
代码: ENQ 0 5FF B R A X 0 0 4 0 0 5 4 7
ASCII码: 05H 30H 35H 46H 46H 42H 52H 41H 58 30H 30H 34H 30H 30H 35H 34H 37H
只要将以上代码以字符串形式串口发送到PLC,就会有正确的回应信息,如下:
注释: 头 站号 PLC号 位元件状态值 尾 和校验
代码: STX 0 5 F F 0 1 1 0 1 EXT E 7
ASCII码: 02H 30H 35H 46H 46H 30H 31H 31H 30H 31H 03H 45H 37H
2) 批量读出字元件—WR指令格式
例如 读站号0的PLC的D10的字元值
注释: 请求 站号 PLC号 命令 延时 元件首地址 元件数量 和校验
代码: ENQ 0 0FF W R 0 D 0 0 1 0 0 2 2 C
ASCII码: 05H 30H 30H 46H 46H 57H 52H 30H 44H 30H 30H 31H 30H 30H 32H 32H 43H
3) 批量写入位元件—BW指令格式
...
4) 批量写入字元件—WW指令格式
例如 写入站号0的PLC的D10的字元值=11
注释: 请求 站号 PLC号 命令 延时 元件首地址 数量 写入值 和校验
代码: ENQ 0 0FF WW 0 D 0 0 1 0 0 1 000B 02
ASCII码: 05H 30H 30H 46H 46H 57H 57H 30H 44H 30H 30H 31H 30H 30H 31H 30H 30H 30H 42H 30H 32H
5U则是用MELSEC通讯协议(简称MC协议)通信,
但MC协议的通讯格式有很多种:3E、3C、4E,4C帧格式, 个人感觉3C比4C好用,3C是ASCII文本格式,3E是二进制格式
MC协议的读取数据的 3C帧格式如下:
请求 帧格式 站号,网络编号,PLC编号,本站站号 主指令 子指令 起始地址 读取长度 和校验
ENQ = "05 "
Frame_Flag = "46 39 " '4C帧 F8 (46H 38H), 3C帧 F9 (46H 39H)
StationNumber = "30 30 " '站号00
NetwrokNumber = "30 30 " '网络编号00
PcNumber = "46 46 " 'PLC编号FF
LocalStationNumber = "30 30 " '本站站号00
mainCmd = "30 34 30 31 " //主指令 读=0401H ,写=1401H 'ASCII格式高位在前
//0000H ? 以16位为单位,从位软元件读数据。或者 以1个字为单位,从字软元件读取数据。
//0001H? 以1位为单位,从位软元件或字软元件读取数据。
subCmd = "30 30 30 30 " //子指令 读写1位=0001H,读写16位=0000H
举个例子: 读取D10的字元值
MC协议的写入数据的 3C帧格式如下:
请求 帧格式 站号,网络编号,PLC编号,本站站号 主指令 子指令 起始地址 写入长度 写入数据 和校验
ENQ = "05 "
Frame_Flag = "46 39 " '4C帧 F8 (46H 38H), 3C帧 F9 (46H 39H)
StationNumber = "30 30 " '站号00
NetwrokNumber = "30 30 " '网络编号00
PcNumber = "46 46 " 'PLC编号FF
LocalStationNumber = "30 30 " '本站站号00
mainCmd = "30 34 30 31 " //主指令 写=1401H 'ASCII格式高位在前
//0000H ? 以16位为单位,从位软元件读数据。或者 以1个字为单位,从字软元件读取数据。
//0001H? 以1位为单位,从位软元件或字软元件读取数据。
subCmd = "30 30 30 30 " //子指令 读写1位=0001H,读写16位=0000H
发送命令给PLC,返回值看第1位数值, 02是正确的,15则是错误的,错误代码需要查说明书
比如返回: 15 46 39 30 30 30 30 46 46 30 30 37 46 32 34 => 7F24 校验错
S7协议是西门子私有协议。基于OSI模型
因为平时对S7协议研究不多,所以直接使用第三方的DLL, Snap7,可以在这个网址下载
http://snap7.sourceforge.net/
public class Snap7
{
[DllImport("Snap7.dll")]
public static extern int Cli_Create();
[DllImport("Snap7.dll")]
public static extern void Cli_Destroy(int Client);
[DllImport("Snap7.dll")]
public static extern int Cli_ConnectTo(int Client, string Address,int Rack,int Slot);
[DllImport("Snap7.dll")]
public static extern int Cli_SetConnectionParams(int Client, string Address, int LocalTSAP, int RemoteTSAP);
[DllImport("Snap7.dll")]
public static extern int Cli_Connect(int Client);
[DllImport("Snap7.dll")]
public static extern int Cli_Disconnect(int Client);
[DllImport("Snap7.dll")]
public static extern int Cli_DBRead(int Client,int DBNumber,int Start,int Size,int Buffer);
[DllImport("Snap7.dll")]
public static extern int Cli_DBWrite(int Client, int DBNumber, int Start, int Size, int Buffer);
[DllImport("Snap7.dll")]
public static extern int Cli_ErrorText(int Error, string Text, int TextLen);
}
使用方法也特别简单, 第1步,启动程序时先创建一个对象
int Client = Cli_Create()
第2步,连接到西门子PLC的IP
int Result = Cli_ConnectTo(Client, "192.168.0.1", 0, 1) //插槽1
if(Result ==0) //连接成功
{
cmdConnect.Enabled = False;
cmdClose.Enabled = True;
}
ShowResult (Result)
第3步,读写PLC, 例如DB220.DBB10读1位
Result = Cli_DBRead(Client, 220, 10, 1, Buffer)
Result = Cli_DBWrite(Client, 220, 10, 1, Buffer)
第4步,退出程序时,销毁对象
Cli_Destroy (Client)
中国国家标准委员会2004年正式把Modbus作为了国家标准,所以仪器的通讯基本都是用Modbus协议, Modbus RTU(远程 终端设备,16进制字符)和Modbus ASCII(文本命令)主要用于串行通信领域,而ModbusTCP则常用于以太网通信。
Modbus RTU 的格式是 : 地址位 功能代码 8位数据 CRC校验码
由于电磁干扰(Electromagnetic Interference )会导致仪表通讯受到干扰,而出现通信错误,所以需要一个机制来确认这个数据包是否完整的数据还是被干扰改变的数据. 最常用的是CRC校验, 还有和校验.
CRC即循环冗余校验码(Cyclic Redundancy Check),仪表通讯用的是CRC16 ModBus, 多项式值0x8005
我们举个例子
01-03-40-02-00-01 这个数据加上CRC码就是01-03-40-02-00-01-30-0A
public static byte[] CRC16(byte[] data)
{
int len = data.Length; //通讯信息帧的字节长度
if (len > 0)
{
ushort crc = 0xFFFF; //预置1个16位的寄存器为十六进制FFFF(即全为1)
for (int i = 0; i < len; i++) //通讯信息帧的第N个字节,从0开始
{
crc = (ushort)(crc ^ (data[i])); //相异或,把结果放于CRC寄存器
//(如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0)
for (int j = 0; j < 8; j++) //只操作低8位
{
//CRC寄存器检查最右边1位,假如是1,与多项式0xA001异或;假如是0,右移一位
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[] {
lo, hi }; //Modbus CRC 低位在前
}
return new byte[] {
0, 0 };
}
如果不明白可以看看我提到的串口工具,就自带有CRC校验 C#工控上位机系列(2)- 串口通信/监控工具
代码里的0xA0001和0x8005多项式的关系,看一下2个二进制
0x8005=1000 0000 0000 0101
0xA001=1010 0000 0000 0001
对比两个二进制高低位正好是完全相反的,CRC校验分为正向校验与反向校验。
正向校验高位在左,反向校验低位在左
正向校验使用左移位,反向校验使用右移位和校验,通常用于对通讯要求不高的情况, 因为和校验只有1位,根据仪器不同,有的和校验只包括数据位;有的则包括功能位,地址位。
```csharp
byte byteSum=0;
for (int i = 0; i < bytes.Length; i++)
{
bytesWithSum[i] = bytes[i];
//有的仪表的和校验,不是从0位开始的
if (i>=SumStartIndex)
byteSum += bytes[i];
}
众所周知,在电路和计算机里是按0/1来存储数据的,比如15对应二进制的1111,但是小数是怎么表示呢?
我们可以约定一个量程系数, 比如1000, 这样仪表的读数是1,则代表着1/1000=0.001
我们来看一个仪表的说明书,仪表返回数据038F 对应的是十进制的911,量程为10,则实际值为91.1
另外还有一种IEEE754 浮点数格式,是用4个字节表示一个32位的浮点数,我们找一个在线转换的网址来试试. 比如3.14 对应的16进制浮点数40 48 F5 C2
对应的C#代码就是下面:
/40 48 F5 C2 => 3.14
string value = "4048F5C2";//16进制字符串
UInt32 x = Convert.ToUInt32(value, 16);//字符串转16进制32位无符号整数
float fy = BitConverter.ToSingle(BitConverter.GetBytes(x), 0);//IEEE754 字节转换float
//3.14=>4048F5C2
var cc = BitConverter.GetBytes(fy);
string HexStr= string.Empty;
for (int i = 0; i < 4; i++)
{
HexStr = Convert.ToString(cc[i], 16).ToUpper() + HexStr;
}
客户需要一个电子看板,类似一个电视大小的, 可以显示生产的型号,单号数量等信息. 电子看板是用Modbus TCP通讯的. 生产线每完成一件产品的测试,扫码打包后, 实际产量要增加1, 所以要和生产数据库连接起来
下载一个开源的C# Modbus的工具
https://github.com/stephan1827/modbusTCP-DotNET
里面关键代码就是构建Modbus TCP的Header
报文头 报文的序列号2字节, 00 00表示ModbusTCP协议,数据长度2字节,设备地址1字节,
功能码为1字节,寄存器地址2字节,读取长度2字节
Modbus的操作对象有四种:线圈、离散输入、保持寄存器、输入寄存器。
对象
Coil线圈 可读可写
DiscreteInputs离散量 只读
InputRegister输入寄存器 只读
HoldingRegiste保持寄存器 可读可写
//功能码
private const byte fctReadCoil = 1;
private const byte fctReadDiscreteInputs = 2;
private const byte fctReadHoldingRegister = 3;
private const byte fctReadInputRegister = 4;
private const byte fctWriteSingleCoil = 5;
private const byte fctWriteSingleRegister = 6;
private const byte fctWriteMultipleCoils = 15;
private const byte fctWriteMultipleRegister = 16;
private const byte fctReadWriteMultipleRegister = 23;
// ------------------------------------------------------------------------
// Create modbus header for read action
private byte[] CreateReadHeader(ushort id, byte unit, ushort startAddress, ushort length, byte function)
{
byte[] data = new byte[12];
byte[] _id = BitConverter.GetBytes((short)id);
data[0] = _id[1]; // Slave id high byte
data[1] = _id[0]; // Slave id low byte
data[5] = 6; // Message size ,数据长度
data[6] = unit; // Slave address 设备地址
data[7] = function; // Function code 功能码
byte[] _adr = BitConverter.GetBytes((short)IPAddress.HostToNetworkOrder((short)startAddress));
data[8] = _adr[0]; // Start address 寄存器地址
data[9] = _adr[1]; // Start address
byte[] _length = BitConverter.GetBytes((short)IPAddress.HostToNetworkOrder((short)length));
data[10] = _length[0]; // Number of data to read 读取长度
data[11] = _length[1]; // Number of data to read
return data;
}
// ------------------------------------------------------------------------
// Create modbus header for write action
private byte[] CreateWriteHeader(ushort id, byte unit, ushort startAddress, ushort numData, ushort numBytes, byte function)
{
byte[] data = new byte[numBytes + 11];
byte[] _id = BitConverter.GetBytes((short)id);
data[0] = _id[1]; // Slave id high byte
data[1] = _id[0]; // Slave id low byte
byte[] _size = BitConverter.GetBytes((short)IPAddress.HostToNetworkOrder((short)(5 + numBytes)));
data[4] = _size[0]; // Complete message size in bytes
data[5] = _size[1]; // Complete message size in bytes
data[6] = unit; // Slave address
data[7] = function; // Function code
byte[] _adr = BitConverter.GetBytes((short)IPAddress.HostToNetworkOrder((short)startAddress));
data[8] = _adr[0]; // Start address
data[9] = _adr[1]; // Start address
if (function >= fctWriteMultipleCoils)
{
byte[] _cnt = BitConverter.GetBytes((short)IPAddress.HostToNetworkOrder((short)numData));
data[10] = _cnt[0]; // Number of bytes
data[11] = _cnt[1]; // Number of bytes
data[12] = (byte)(numBytes - 2);
}
return data;
}
了解了ModBus TCP协议后,我们看看怎么来读写寄存器的例子
try
{
// Create new modbus master and add event functions
MBmaster = new Master("192.168.8.1", 502, true);
MBmaster.OnResponseData += new ModbusTCP.Master.ResponseData(MBmaster_OnResponseData);
MBmaster.OnException += new ModbusTCP.Master.ExceptionData(MBmaster_OnException);
取得工人人数等信息
ReadHoldingRegister();
lblMsg.Text = "连接成功";
}
catch (SystemException error)
{
MessageBox.Show(error.Message);
lblMsg.Text = error.Message;
}
// ------------------------------------------------------------------------
// read holding register
// 工人人数,计划数量,实际产量,已耗时长,已耗时长显示保留小数位数 存在地址1到6
//
// ------------------------------------------------------------------------
private void ReadHoldingRegister()
{
ushort ID = 3; //自己定义的序号
byte unit = Convert.ToByte("1"); //Modus 地址1
ushort StartAddress = 0; //开始地址
UInt16 Length =6; //长度
if (MBmaster != null)
{
MBmaster.ReadHoldingRegister(ID, unit, StartAddress, Length); //异步方法
}
}
private void MBmaster_OnResponseData(ushort ID, byte unit, byte function, byte[] values)
{
// ------------------------------------------------------------------
// Seperate calling threads
if (this.InvokeRequired)
{
this.BeginInvoke(new Master.ResponseData(MBmaster_OnResponseData), new object[] {
ID, unit, function, values });
return;
}
// ------------------------------------------------------------------------
// Identify requested data
switch (ID)
{
case 3:
//"Read holding register";
//根据返回值数组大小,来判断哪个数据
int[] word = ConvertBytesToWords(values);
if(word.Length==6)
{
Console.WriteLine("return ActualCnt=" + ActualCnt);
WorkerCnt = word[0];
PlanCnt = word[1];
ActualCnt = word[2];
txtWorkerCnt.Text = WorkerCnt.ToString();
txtActual.Text = ActualCnt.ToString();
txtPlanCnt.Text = PlanCnt.ToString();
txtLeftCnt.Text = (PlanCnt - ActualCnt).ToString();
}
break;
case 8:
//grpData.Text = "Write multiple register";
break;
}
}
National Instruments 的 Measurement Studio。 这个官网下载的只有最新版本的试用版,其他旧版本得自己网上找, Ni的控件有包括仪表盘,图表,傅里叶变换
各个版本的功能有不少区别,建议选择企业版,里面有这些功能是超级好用,比如生成一个正弦波,做傅里叶变换,多项式拟合曲线。
1、Analysis Class Library 数据分析类库
2、Signal Generation 信号生成
3、Windowing 窗口处理
4、Array and Numeric Operations 数组和数字操作
5、Measurements 测量
6、Filters 过滤器
7、Signal Processing 信号处理
8、Linear Algebra 线性代数
9、Curve Fitting 曲线拟合
10、Statistics 统计
11、Special Functions 特殊功能
工控行业,很多时候要显示三相电流的波形图,或者霍尔信号的波形。这个时候使用CWGraph控件就派上用场了。
CWGraph属性面板选项卡,主要用到下面几个TAB
Style:图表的样式
Plots: 主要是来设置绘图曲线数量,以及每条曲线的样式
Axes:设置X、Y轴上下限(可以选择是否根据数据更新上下限Auto Scale);
Ticks: 设置xy轴显示颜色、刻度、网格填充线颜色
下面是一些例子
CWStat控件的PolyFit方法 对应就是Excel的多项式拟合趋势线。比如下图的例子是根据吹风机不同孔径的流量大小,进行拟合的曲线。
float[] xPolyFit = new float[30]
float[] yPolyFit= new float[30]
int order;
order = 2 '默认2阶
//N阶拟合,至少要N+1个点
If (pt > order)
{
CWStat1.PolyFit(xFlowData, yInputPowerData, order, z, coef, mse)
ptrWaveBox.Plots(1).PlotXvsY xPolyFit, yPolyFitx
}
生成正弦波的例子
CWDSP1.SineWave(51200 / 3, 1, 0.01, phase)
傅里叶变换
CWDSP1.ReFFT RealData, RealSpec, ImgData
工控程序中遇到多工位同时操作的,就需要用到多线程,假如不采用多线程,只采用轮询的方法,可能就很慢了. 比如这个28工位电机老化测试.
.NET里面针对多线程处理,有几个类Thread和ThreadPool, 还有BackgroundWorker.
建议使用BackgroundWorker, 它给工作线程和UI线程提供了交互的能力。
Thread和ThreadPool默认都没有提供这种交互能 力,而BackgroundWorker 默认支持报告进度、完成回调、取消任务、暂停任务等。
DoWork 例子代码:
void bw_DoWork(object sender, DoWorkEventArgs e)
{
BackgroundWorker bgWorker = sender as BackgroundWorker;
//这里的操作是在另一个线程上完成的,不应该操作UI
//在这里执行耗时的运算。
//读仪表读数,等其他操作
ScanMeter(nFix);
//请修改 WorkerReportsProgress 以声明它报告进度。
bgWorker.ReportProgress(-1, nFix);
}
界面显示进度:
//汇报进度的函数可以使用UI控件
void bw_ReportProgress(object sender, ProgressChangedEventArgs e)
{
int nFix = (int)e.UserState; //线程传递回UI的数据,这里传工位号
int nProgress = (int)e.ProgressPercentage;
System.Console.WriteLine("bw_ReportProgress nFix=" + nFix + ",Percent=" + nProgress);
ProgressBar1.Value= nProgress;
}
线程执行完毕:
void bw_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
System.Diagnostics.Debug.WriteLine("bw_RunWorkerCompleted " + e.Result.ToString());
//一个线程对应一次测试,完成后测试OK/NG,复位启动信号
int nFix = (int)e.Result; //线程传递回UI的数据,这里传工位号
btn.Text = "合格";
SaveTestData(nFix);
}