1. 项目概述:C#与汇川PLC的MODBUS TCP通讯实现
在工业自动化领域,上位机与PLC的通讯是实现设备监控和数据采集的基础。这个项目展示了如何使用C#语言通过MODBUS TCP协议与汇川PLC建立稳定通讯,并在此基础上实现实时曲线显示和自定义控件开发。不同于简单的数据读写,我们更关注如何将原始数据转化为直观的可视化界面,这对设备状态监控和生产数据分析具有重要意义。
汇川PLC在国内自动化市场占有率逐年提升,而MODBUS TCP作为工业通讯的"普通话",其标准化协议保证了不同厂商设备的互联互通。C#凭借其强大的Windows窗体开发能力,成为开发上位机软件的理想选择。这个案例特别适合需要快速构建PLC监控系统、但又希望保持界面灵活性的开发者。
2. 通讯基础搭建
2.1 MODBUS TCP协议栈实现
MODBUS TCP本质上是MODBUS RTU协议通过TCP/IP网络的封装。在C#中实现时,我们需要处理以下几个核心层:
- 物理连接层:使用标准的Socket类建立TCP连接
- 协议封装层:按照MODBUS TCP规范构建报文
- 数据解析层:处理PLC返回的二进制数据
一个典型的请求报文结构如下:
csharp复制byte[] BuildReadHoldingRegistersRequest(ushort startAddress, ushort numberOfPoints)
{
byte[] frame = new byte[12];
// 事务标识符(通常递增)
frame[0] = 0x00;
frame[1] = 0x01;
// 协议标识符(MODBUS=0)
frame[2] = 0x00;
frame[3] = 0x00;
// 长度字段(后续字节数)
frame[4] = 0x00;
frame[5] = 0x06;
// 单元标识符(PLC站号)
frame[6] = 0x01;
// 功能码(读保持寄存器=3)
frame[7] = 0x03;
// 起始地址
frame[8] = (byte)(startAddress >> 8);
frame[9] = (byte)(startAddress & 0xFF);
// 寄存器数量
frame[10] = (byte)(numberOfPoints >> 8);
frame[11] = (byte)(numberOfPoints & 0xFF);
return frame;
}
注意:汇川PLC的寄存器地址有时需要偏移量调整,具体取决于PLC型号和配置。例如H3U系列通常需要将文档中的地址加1后作为实际通讯地址。
2.2 通讯连接管理
稳定的通讯需要完善的连接管理机制:
csharp复制class PlcConnection : IDisposable
{
private TcpClient _tcpClient;
private NetworkStream _stream;
private readonly string _ip;
private readonly int _port;
private readonly int _timeout;
public PlcConnection(string ip, int port = 502, int timeout = 1000)
{
_ip = ip;
_port = port;
_timeout = timeout;
}
public void Connect()
{
_tcpClient = new TcpClient();
var connectTask = _tcpClient.ConnectAsync(_ip, _port);
if (!connectTask.Wait(_timeout))
throw new TimeoutException("PLC连接超时");
_stream = _tcpClient.GetStream();
_stream.ReadTimeout = _timeout;
}
public byte[] SendRequest(byte[] request)
{
_stream.Write(request, 0, request.Length);
byte[] header = new byte[6];
_stream.Read(header, 0, 6);
int bodyLength = header[4] << 8 | header[5];
byte[] response = new byte[6 + bodyLength];
Array.Copy(header, 0, response, 0, 6);
_stream.Read(response, 6, bodyLength);
return response;
}
public void Dispose()
{
_stream?.Dispose();
_tcpClient?.Dispose();
}
}
3. 实时曲线实现
3.1 高效绘图技术
在WinForms中实现流畅的实时曲线需要考虑几个关键点:
- 双缓冲技术:消除画面闪烁
- 增量绘制:只重绘变化部分
- 时间轴处理:动态调整显示范围
csharp复制class RealTimeChart : Control
{
private BufferedGraphics _bufferedGraphics;
private List<float> _dataPoints = new List<float>();
private float _maxValue = 100;
private float _minValue = 0;
private int _maxPoints = 500;
public RealTimeChart()
{
this.DoubleBuffered = true;
ResizeRedraw = true;
}
protected override void OnPaint(PaintEventArgs e)
{
if (_bufferedGraphics == null)
_bufferedGraphics = BufferedGraphicsManager.Current.Allocate(
e.Graphics, this.ClientRectangle);
Graphics g = _bufferedGraphics.Graphics;
g.Clear(this.BackColor);
// 绘制坐标轴
using (var pen = new Pen(Color.Black, 1))
{
g.DrawLine(pen, 30, 20, 30, this.Height - 30);
g.DrawLine(pen, 30, this.Height - 30, this.Width - 20, this.Height - 30);
}
// 绘制曲线
if (_dataPoints.Count > 1)
{
float xStep = (this.Width - 50f) / (_maxPoints - 1);
float yScale = (this.Height - 50f) / (_maxValue - _minValue);
PointF[] points = _dataPoints
.Select((v, i) => new PointF(
30 + i * xStep,
this.Height - 30 - (v - _minValue) * yScale))
.ToArray();
using (var pen = new Pen(Color.Red, 1.5f))
{
g.DrawLines(pen, points);
}
}
_bufferedGraphics.Render(e.Graphics);
}
public void AddDataPoint(float value)
{
_dataPoints.Add(value);
if (_dataPoints.Count > _maxPoints)
_dataPoints.RemoveAt(0);
this.Invalidate();
}
}
3.2 数据采集线程管理
使用生产者-消费者模式处理数据采集:
csharp复制class DataCollector
{
private readonly PlcConnection _plc;
private readonly RealTimeChart _chart;
private Thread _workerThread;
private bool _isRunning;
private ushort _startAddress;
private int _interval;
public DataCollector(PlcConnection plc, RealTimeChart chart,
ushort startAddress, int interval = 100)
{
_plc = plc;
_chart = chart;
_startAddress = startAddress;
_interval = interval;
}
public void Start()
{
_isRunning = true;
_workerThread = new Thread(WorkerProc);
_workerThread.IsBackground = true;
_workerThread.Start();
}
public void Stop()
{
_isRunning = false;
_workerThread?.Join();
}
private void WorkerProc()
{
while (_isRunning)
{
try
{
byte[] request = BuildReadHoldingRegistersRequest(_startAddress, 1);
byte[] response = _plc.SendRequest(request);
if (response.Length >= 9 && response[7] == 0x03)
{
float value = (response[9] << 8 | response[10]) / 10f;
_chart.Invoke((Action)(() => _chart.AddDataPoint(value)));
}
Thread.Sleep(_interval);
}
catch (Exception ex)
{
// 记录错误并尝试重连
Debug.WriteLine($"采集错误: {ex.Message}");
Thread.Sleep(1000);
_plc.Connect();
}
}
}
}
提示:在实际项目中,建议使用CancellationToken替代_isRunning标志位,这样可以更优雅地停止线程。
4. 自定义控件开发
4.1 状态指示灯控件
工业界面常需要各种状态指示灯,我们可以创建可复用的自定义控件:
csharp复制class StatusLed : Control
{
private bool _isOn;
private Color _onColor = Color.LimeGreen;
private Color _offColor = Color.Red;
public bool IsOn
{
get => _isOn;
set { _isOn = value; this.Invalidate(); }
}
public Color OnColor
{
get => _onColor;
set { _onColor = value; this.Invalidate(); }
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
using (var brush = new SolidBrush(_isOn ? _onColor : _offColor))
{
int diameter = Math.Min(this.Width, this.Height) - 4;
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
e.Graphics.FillEllipse(brush, 2, 2, diameter, diameter);
// 添加高光效果
using (var highlight = new LinearGradientBrush(
new Rectangle(2, 2, diameter/2, diameter/2),
Color.FromArgb(100, Color.White),
Color.Transparent,
45f))
{
e.Graphics.FillEllipse(highlight, 2, 2, diameter/2, diameter/2);
}
}
// 绘制边框
using (var pen = new Pen(Color.DarkGray, 1))
{
int diameter = Math.Min(this.Width, this.Height) - 4;
e.Graphics.DrawEllipse(pen, 2, 2, diameter, diameter);
}
}
protected override void OnSizeChanged(EventArgs e)
{
base.OnSizeChanged(e);
this.Invalidate();
}
}
4.2 数值显示控件
带报警功能的数值显示控件:
csharp复制class NumericDisplay : Control
{
private float _value;
private float _highLimit = 100;
private float _lowLimit = 0;
private string _unit = "℃";
public float Value
{
get => _value;
set { _value = value; this.Invalidate(); }
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
bool isAlarm = _value > _highLimit || _value < _lowLimit;
Color backColor = isAlarm ? Color.Pink : SystemColors.Control;
Color textColor = isAlarm ? Color.Red : this.ForeColor;
using (var brush = new SolidBrush(backColor))
{
e.Graphics.FillRectangle(brush, this.ClientRectangle);
}
string displayText = $"{_value:F1}{_unit}";
using (var format = new StringFormat())
using (var font = new Font(this.Font.FontFamily, 14, FontStyle.Bold))
{
format.Alignment = StringAlignment.Far;
format.LineAlignment = StringAlignment.Center;
using (var brush = new SolidBrush(textColor))
{
e.Graphics.DrawString(
displayText,
font,
brush,
this.ClientRectangle,
format);
}
}
// 绘制边框
using (var pen = new Pen(Color.DarkGray, 1))
{
e.Graphics.DrawRectangle(pen,
0, 0,
this.Width - 1, this.Height - 1);
}
}
}
5. 系统集成与优化
5.1 界面布局技巧
工业HMI界面设计有几个关键原则:
- 重要信息突出:关键参数使用大字体、高对比色
- 操作区域明确:按钮大小适中,间距合理
- 状态一目了然:使用颜色编码表示不同状态
使用TableLayoutPanel实现响应式布局:
csharp复制private void InitializeComponent()
{
this.tableLayoutPanel1 = new TableLayoutPanel();
this.tableLayoutPanel1.Dock = DockStyle.Fill;
this.tableLayoutPanel1.ColumnCount = 3;
this.tableLayoutPanel1.RowCount = 4;
// 列设置
this.tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30F));
this.tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40F));
this.tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30F));
// 行设置
this.tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 50F));
this.tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 60F));
this.tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 40F));
this.tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Absolute, 40F));
// 添加控件
this.tableLayoutPanel1.Controls.Add(this.statusLabel, 0, 0);
this.tableLayoutPanel1.Controls.Add(this.realTimeChart, 1, 1);
this.tableLayoutPanel1.Controls.Add(this.alarmPanel, 2, 1);
// ...其他控件
this.Controls.Add(this.tableLayoutPanel1);
}
5.2 性能优化建议
-
通讯优化:
- 合并数据请求:一次性读取多个寄存器而非单个
- 合理设置轮询间隔:非关键数据可降低采集频率
- 使用异步通讯:避免阻塞UI线程
-
界面优化:
- 限制曲线点数:保持200-500个点足够
- 减少不必要的重绘:只刷新变化的部分
- 使用SuspendLayout/ResumeLayout减少布局计算
-
异常处理:
- 网络断线自动重连
- 数据校验机制
- 超时处理
csharp复制async Task<float[]> ReadMultipleRegistersAsync(ushort startAddress, ushort count)
{
try
{
byte[] request = BuildReadHoldingRegistersRequest(startAddress, count);
byte[] response = await Task.Run(() => _plc.SendRequest(request));
if (response.Length < 9 || response[7] != 0x03)
throw new InvalidDataException("无效的响应数据");
int byteCount = response[8];
if (byteCount != count * 2)
throw new InvalidDataException("数据长度不匹配");
float[] values = new float[count];
for (int i = 0; i < count; i++)
{
values[i] = (response[9 + i*2] << 8 | response[10 + i*2]) / 10f;
}
return values;
}
catch (Exception ex)
{
_logger.Error($"读取寄存器失败: {ex.Message}");
await Task.Delay(1000); // 等待后重试
return await ReadMultipleRegistersAsync(startAddress, count);
}
}
6. 常见问题与解决方案
6.1 通讯连接问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 连接超时 | 网络不通/IP错误 | 检查物理连接,确认PLC IP地址 |
| 连接被拒绝 | 端口被占用/PLC未启用MODBUS TCP | 确认PLC已启用MODBUS TCP服务,检查防火墙设置 |
| 数据不完整 | 网络延迟/PLC响应慢 | 增加超时时间,优化网络环境 |
6.2 数据解析异常
csharp复制private float ParseModbusFloat(byte[] response, int startIndex)
{
// MODBUS TCP返回的数据可能是大端或小端格式
// 需要根据PLC型号确定字节顺序
if (response.Length < startIndex + 4)
throw new ArgumentException("数据长度不足");
// 汇川PLC通常使用大端序
byte[] buffer = new byte[4];
Buffer.BlockCopy(response, startIndex, buffer, 0, 4);
if (BitConverter.IsLittleEndian)
Array.Reverse(buffer);
return BitConverter.ToSingle(buffer, 0);
}
6.3 界面卡顿处理
当界面出现卡顿时,可以采取以下措施:
-
检查UI线程阻塞:
csharp复制// 错误做法 - 在UI线程执行耗时操作 button1.Click += (s, e) => { var data = _plc.SendRequest(request); // 同步通讯 UpdateUI(data); }; // 正确做法 - 使用异步 button1.Click += async (s, e) => { var data = await Task.Run(() => _plc.SendRequest(request)); UpdateUI(data); }; -
优化绘图性能:
- 设置ControlStyles.OptimizedDoubleBuffer标志
- 使用BeginInvoke替代Invoke减少跨线程调用
- 对于复杂图形,考虑使用Direct2D或Skia等高性能绘图库
-
内存管理:
- 及时释放GDI+资源(Pen, Brush, Font等)
- 避免频繁创建/销毁对象
- 使用对象池管理常用资源
7. 项目扩展方向
这个基础框架可以进一步扩展为完整的SCADA系统:
- 数据记录功能:添加数据库存储历史数据
- 报警管理:实现上下限报警和事件记录
- 配方管理:存储和调用不同生产参数
- 用户权限:不同操作员级别设置
- Web访问:通过SignalR实现远程监控
- OPC UA支持:扩展更多工业协议
对于需要处理更复杂协议的情况,可以考虑使用专业的工业通讯库如libplctag或OPC Foundation提供的库。不过对于大多数MODBUS TCP应用,自己实现的轻量级解决方案已经足够。
