本篇博客主要记录C#上位机开发的相关内容。

在公司的实践项目为橡胶缠绕机控制系统的设计与开发,下位机主要采用的是Rockwell的PLC和三个伺服电机以及海康威视的3D线激光扫描仪。

基于OPC服务器通信

C#开发主要是用于检测使用OPC服务器对PLC进行数据读写的时间差方便与基于Ethernet/IP协议自主编写的通信库读取PLC数据进行比较。

开发平台:Visual Studio

语言:C#

以下是OPC客户端的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using OPCAutomation;
using System.Diagnostics;

namespace OPCtest4
{
public partial class Form1 : Form
{
OPCServer KepServer;
OPCGroups KepGroups;
OPCGroup KepGroup;
OPCItems KepItems;
OPCItem KepItem;
bool opc_connected = false;//连接状态
int itmHandleClient = 0;//客户端的句柄,句柄即控件名称,如“张三”,用来识别是哪个具体的对象,此处可理解为每个节点的编号
int itmHandleServer = 0;//服务器的句柄
Stopwatch stopwatch = new Stopwatch();
public Form1()
{
InitializeComponent();
}

private void Form1_Load(object sender, EventArgs e)
{
GetLocalServer();
}

/// <summary>
/// 获取本地的OPC服务器名称
/// </summary>
public void GetLocalServer()
{
IPHostEntry host = Dns.GetHostEntry("LAPTOP-U84ALVBN");
var strHostName = host.HostName;
try
{
KepServer = new OPCServer();
object serverList = KepServer.GetOPCServers(strHostName);

foreach (string turn in (Array)serverList)
{
cmbServerName.Items.Add(turn);
}

cmbServerName.SelectedIndex = 0;
btnConnServer.Enabled = true;
}
catch (Exception err)
{
MessageBox.Show("枚举本地OPC服务器出错:" + err.Message, "提示信息", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}


/// <summary>
/// "连接"按钮点击事件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnConnServer_Click(object sender, EventArgs e)
{
try
{
if (!ConnectRemoteServer(txtRemoteServerIP.Text, cmbServerName.Text))
{
return;
}

btnSetGroupPro.Enabled = true;

opc_connected = true;

GetServerInfo();

RecurBrowse(KepServer.CreateBrowser());

if (!CreateGroup())
{
return;
}
}
catch (Exception err)
{
MessageBox.Show("初始化出错:" + err.Message, "提示信息", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}


/// <summary>
/// 连接服务器
/// </summary>
/// <param name="remoteServerIP">服务器IP</param>
/// <param name="remoteServerName">服务器名称</param>
/// <returns></returns>
public bool ConnectRemoteServer(string remoteServerIP, string remoteServerName)
{
try
{
KepServer.Connect(remoteServerName, remoteServerIP);

if (KepServer.ServerState == (int)OPCServerState.OPCRunning)
{
tsslServerState.Text = "已连接到-" + KepServer.ServerName + " ";
}
else
{
//这里你可以根据返回的状态来自定义显示信息,请查看自动化接口API文档
tsslServerState.Text = "状态:" + KepServer.ServerState.ToString() + " ";
}
}
catch (Exception err)
{
MessageBox.Show("连接远程服务器出现错误:" + err.Message, "提示信息", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return false;
}
return true;
}

/// <summary>
/// 获取服务器信息,并显示在窗体状态栏上
/// </summary>
public void GetServerInfo()
{
tsslServerStartTime.Text = "开始时间:" + KepServer.StartTime.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss");
tsslversion.Text = "版本:" + KepServer.MajorVersion.ToString() + "." + KepServer.MinorVersion.ToString() + "." + KepServer.BuildNumber.ToString();
}

/// <summary>
/// 展开树枝和叶子
/// </summary>
/// <param name="oPCBrowser">opc浏览器</param>
public void RecurBrowse(OPCBrowser oPCBrowser)
{
//展开分支
oPCBrowser.ShowBranches();
//展开叶子
oPCBrowser.ShowLeafs(true);
foreach (object turn in oPCBrowser)
{
listBox1.Items.Add(turn.ToString());
}
}


/// <summary>
/// 创建组,将本地组和服务器上的组对应
/// </summary>
/// <returns></returns>
public bool CreateGroup()
{
try
{
KepGroups = KepServer.OPCGroups;//将服务端的组集合复制到本地
KepGroup = KepGroups.Add("S");//添加一个组
SetGroupProperty();//设置组属性

KepItems = KepGroup.OPCItems;//将组里的节点集合复制到本地节点集合

KepGroup.DataChange += KepGroup_DataChange;
KepGroup.AsyncWriteComplete += KepGroup_AsyncWriteComplete;
}
catch (Exception err)
{
MessageBox.Show("创建组出现错误:" + err.Message, "提示信息", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return false;
}
return true;
}


/// <summary>
/// 设置组的属性,从对应的控件里获取
/// </summary>
public void SetGroupProperty()
{
KepServer.OPCGroups.DefaultGroupIsActive = Convert.ToBoolean(txtGroupIsActive.Text);//激活组
KepServer.OPCGroups.DefaultGroupDeadband = Convert.ToInt32(txtGroupDeadband.Text);// 死区值,设为0时,服务器端该组内任何数据变化都通知组
KepGroup.UpdateRate = Convert.ToInt32(txtUpdateRate.Text);//服务器向客户程序提交数据变化的刷新速率
KepGroup.IsActive = Convert.ToBoolean(txtIsActive.Text);//组的激活状态标志
KepGroup.IsSubscribed = Convert.ToBoolean(txtIsSubscribed.Text);//是否订阅数据
}


/// <summary>
/// 异步写方法
/// </summary>
/// <param name="TransactionID">处理ID</param>
/// <param name="NumItems">项个数</param>
/// <param name="ClientHandles">OPC客户端的句柄</param>
/// <param name="Errors">错误个数</param>
private void KepGroup_AsyncWriteComplete(int TransactionID, int NumItems, ref Array ClientHandles, ref Array Errors)
{
lblState.Text = "";
for (int i = 1; i <= NumItems; i++)
{
lblState.Text += "Tran:" + TransactionID.ToString() + " CH:" + ClientHandles.GetValue(i).ToString() + " Error:" + Errors.GetValue(i).ToString();
}
stopwatch.Stop();
lblState.Text += " 耗时:" + stopwatch.ElapsedMilliseconds.ToString() + "ms";
}


/// <summary>
/// 数据订阅方法
/// </summary>
/// <param name="TransactionID">处理ID</param>
/// <param name="NumItems">项个数</param>
/// <param name="ClientHandles">OPC客户端的句柄</param>
/// <param name="ItemValues">节点的值</param>
/// <param name="Qualities">节点的质量</param>
/// <param name="TimeStamps">时间戳</param>
private void KepGroup_DataChange(int TransactionID, int NumItems, ref Array ClientHandles, ref Array ItemValues, ref Array Qualities, ref Array TimeStamps)
{
for (int i = 1; i <= NumItems; i++)//下标一定要从1开始,NumItems参数是每次事件触发时Group中实际发生数据变化的Item的数量,而不是整个Group里的Items
{
this.txtTagValue.Text = ItemValues.GetValue(i).ToString();
this.txtQualities.Text = Qualities.GetValue(i).ToString();
this.txtTimeStamps.Text = TimeStamps.GetValue(i).ToString();
}
}


/// <summary>
/// 选择列表时触发的事件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void ListBox1_SelectedIndexChanged(object sender, EventArgs e)
{
try
{
if (itmHandleClient != 0)
{
this.txtTagValue.Text = "";
this.txtQualities.Text = "";
this.txtTimeStamps.Text = "";

Array Errors;
OPCItem bItem = KepItems.GetOPCItem(itmHandleServer);
//注:OPC中以1为数组的基数
int[] temp = new int[2] { 0, bItem.ServerHandle };
Array serverHandle = (Array)temp;
//移除上一次选择的项
KepItems.Remove(KepItems.Count, ref serverHandle, out Errors);


itmHandleClient = 1;//节点编号为1
KepItem = KepItems.AddItem(listBox1.SelectedItem.ToString(), itmHandleClient);//第一个参数为ItemID,第二个参数为节点编号,节点编号可自定义
itmHandleServer = KepItem.ServerHandle;//获取该节点的服务器句柄
}
else
{
itmHandleClient = 1;//节点编号为1
KepItem = KepItems.AddItem(listBox1.SelectedItem.ToString(), itmHandleClient);//第一个参数为ItemID,第二个参数为节点编号,节点编号可自定义
itmHandleServer = KepItem.ServerHandle;//获取该节点的服务器句柄

}

}
catch (Exception err)
{
//没有任何权限的项,都是OPC服务器保留的系统项,此处可不做处理。
itmHandleClient = 0;
txtTagValue.Text = "Error ox";
txtQualities.Text = "Error ox";
txtTimeStamps.Text = "Error ox";
MessageBox.Show("此项为系统保留项:" + err.Message, "提示信息");
}
}


/// <summary>
/// 设置组属性的按钮点击事件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnSetGroupPro_Click(object sender, EventArgs e)
{
SetGroupProperty();
}


/// <summary>
/// “写入”按钮点击事件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnWrite_Click(object sender, EventArgs e)
{
OPCItem bItem = KepItems.GetOPCItem(itmHandleServer);
int[] temp = new int[2] { 0, bItem.ServerHandle };
Array serverHandles = (Array)temp;
object[] valueTemp = new object[2] { "", txtWriteTagValue.Text };
Array values = (Array)valueTemp;
Array Errors;
int cancelID;
stopwatch.Restart();
KepGroup.AsyncWrite(1, ref serverHandles, ref values, out Errors, 2009, out cancelID);
//KepItem.Write(txtWriteTagValue.Text);//这句也可以写入,但并不触发写入事件
lblState.Text = "正在写入...";
GC.Collect();
}


/// <summary>
/// 关闭窗口事件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
if (!opc_connected)
{
return;
}

if (KepGroup != null)
{
KepGroup.DataChange -= new DIOPCGroupEvent_DataChangeEventHandler(KepGroup_DataChange);
}

if (KepServer != null)
{
KepServer.Disconnect();
KepServer = null;
}

opc_connected = false;
}

private void txtTimeStamps_TextChanged(object sender, EventArgs e)
{

}

private void cmbServerName_SelectedIndexChanged(object sender, EventArgs e)
{

}

private void lblState_Click(object sender, EventArgs e)
{

}
}
}

在OPC协议中采用的是客户端和服务器的模式,日常学习使用的服务器可以是Kepserver,在实际使用中,客户端是通信服务的发起方,用来读取数据。

OPC协议主要分为DA、A&E、HDA、UA四个规范,日常使用访问数据主要使用的是DA协议,现在UA的使用也开始增多。

OPC服务主要有三类对象,OPC server对象、OPC group对象、OPC item对象,每一类对象都包含一系列的接口。

OPC Server对象主要是功能创建和管理OPC Group对象、管理服务器内部的状态信息。

OPC Group对象主要管理该对象的内容状态信息、创建和管理Item对象以及服务器内部的实时数据的存取服务(同步与异步);通常分为私有组和公有组。公有组有多个客户共享、私有组只属于某个客户,大多数的服务器均未实现公有组。

OPC Item对象主要用来描述实时数据,一个Item对象不能单独被OPC客户端访问,所有的对象的访问必须通过OPC Group访问。

以上所有与OPC相关的对象及其调用都来自OPCAutomation的库。

基于Ethernet/IP协议通信

相较于传统的基于OPC服务器的通信方式,虽然协议的集成度较高,但是其缺点在需要高速数据传输的工业应用场景缺还是延迟略微有些许高,为了解决这个问题,基于Ethernet/IP协议,准备使用C语言自主构建一个通信库。

开发平台:Visual Studio

开发语言:C

以下是通信库部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#ifdef _WIN32
#include <WinSock2.h>
#endif
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
#pragma warning(disable : 4996)

#define GET_RESULT(ret) { if (ret != 0) failed_count++;}

#include "ab_cip.h"
extern uint32 g_session;

int main(int argc, char** argv)
{
#ifdef _WIN32
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
{
return -1;
}
#endif

char* plc_ip = "192.168.1.2";
int plc_port = 44818;
LARGE_INTEGER frequency, start, end;
QueryPerformanceFrequency(&frequency);
if (argc > 1)
{
plc_ip = argv[1];
plc_port = atoi(argv[2]);
}

int fd = -1;
int slot = 0;
bool ret = ab_cip_connect(plc_ip, plc_port, 0, &fd);
printf("Connect to PLC: %s:%d, ret: %d\n", plc_ip, plc_port, ret);
printf("session : %x\n", g_session);
if (ret && fd > 0)
{
cip_error_code_e ret = CIP_ERROR_CODE_FAILED;

const int TEST_COUNT = 50;
const int TEST_SLEEP_TIME = 1000;
int failed_count = 0;
char address[50] = { 0 };
int i = 0;
bool val = false;

strcpy(address, "Angel");

int i_val = 0;
QueryPerformanceCounter(&start);
ret = ab_cip_write_int32(fd, address, 89);
QueryPerformanceCounter(&end);
ret = ab_cip_read_int32(fd, address, &i_val);
printf("Read\t %s \tint32:\t %d\n", address, i_val);
GET_RESULT(ret);
double elapsedTime = (double)(end.QuadPart - start.QuadPart) / frequency.QuadPart;
printf("写入操作耗时:%f 秒\n", elapsedTime);

strcpy(address, "low1");
ret = ab_cip_write_bool(fd, address, 0);
ret = ab_cip_read_bool(fd, address, &val);
GET_RESULT(ret);
printf("Read low bool: %d, ret: %d\n", val, ret);
printf("All Failed count: %d\n", failed_count);

ab_cip_disconnect(fd);
system("pause");
}

#ifdef _WIN32
WSACleanup();
#endif
}

以上是用于测试通信延迟的例程,主要采用的是基于Ethernet/IP协议的通信方式,整个通信均是使用底层报文的格式进行数据的读写和采集。

两种通信方式对比

两种通信方式的对比,主要是采用读写相同字节的数据所消耗的时间,在实际测试中可以看出因为自建通信库不用通过OPC服务器的转发,数据量较小时差异略微领先,但是在工业现场数据量庞大的情况,所节省的时间就较为可观了。