从HMI到PLC:用C#和CODESYS共享内存打造实时数据看板实战指南
在工业自动化领域,实时数据监控与控制指令传递是核心需求。想象一下,当PLC控制的电机转速、温度传感器数据需要实时显示在Windows端的可视化界面上,同时操作员又需要通过界面按钮发送控制指令——这种双向交互如何实现高效、低延迟?共享内存技术正是解决这一痛点的利器。
本文将带您从零构建一个完整的实时数据看板项目,使用C#开发HMI界面,通过共享内存与CODESYS平台下的PLC进行毫秒级数据交换。不同于单纯的技术文档,我们将采用项目驱动的方式,重点解决实际开发中的三大挑战:数据结构设计、线程安全处理和性能优化。最终交付的Demo可直接应用于产线监控、设备调试等真实场景。
1. 环境搭建与基础配置
1.1 开发环境准备
工欲善其事,必先利其器。以下是经过实际验证的环境组合:
- CODESYS 3.5.13+:建议使用SP16以上版本以获得更好的共享内存兼容性
- Visual Studio 2019/2022:社区版即可满足开发需求
- 关键库文件:
SysShm(3.5.8.0):提供共享内存核心功能SysTypes2 Interfaces(3.5.4.0):数据类型支持
注意:若使用实体PLC(如倍福CX系列),需在CODESYS中安装对应设备的SP补丁包
1.2 共享内存工作原理图解
+-------------------+ +-------------------+ | C# HMI程序 | <---> | 共享内存区域 | <---> | CODESYS PLC程序 | | (读写内存映射文件)| | (双向数据缓冲区) | | (SysShm库调用) | +-------------------+ +-------------------+关键参数配置示例表:
| 参数项 | C#端设置值 | CODESYS端设置值 |
|---|---|---|
| 内存区域名称(读) | "Global\CODESYS_MEMORY_READ" | 'CODESYS_MEMORY_READ' |
| 内存区域名称(写) | "Global\CODESYS_MEMORY_WRITE" | 'CODESYS_MEMORY_WRITE' |
| 内存块大小 | 1024字节 | 1024字节 |
| 数据刷新周期 | 10ms | 5ms |
2. 双向数据结构设计实战
2.1 定义数据交换协议
数据结构是共享通信的基石,需要两端严格匹配。推荐采用以下设计原则:
显式内存对齐:使用
[StructLayout(LayoutKind.Sequential, Pack=1)]避免填充字节问题类型映射表:
CODESYS类型 C#对应类型 字节数 BOOL bool 1 INT short 2 DINT int 4 REAL float 4 LREAL double 8
2.2 完整数据结构示例
CODESYS端DUT定义:
TYPE Str_ParaFromHMI : STRUCT bMotorStart : BOOL; // 电机启动命令 iTargetSpeed : INT; // 目标转速(RPM) fTemperatureSet : REAL; // 温度设定值(℃) END_STRUCT END_TYPE TYPE Str_ParaToHMI : STRUCT bMotorRunning : BOOL; // 电机运行状态 iActualSpeed : INT; // 实际转速 fTemperatureNow : REAL; // 当前温度 dwErrorCode : DWORD; // 错误代码 END_STRUCT END_TYPEC#端对应结构体:
[StructLayout(LayoutKind.Sequential, Pack=1)] public struct ToPLC { [MarshalAs(UnmanagedType.I1)] public bool MotorStart; public short TargetSpeed; public float TemperatureSet; } [StructLayout(LayoutKind.Sequential, Pack=1)] public struct FromPLC { [MarshalAs(UnmanagedType.I1)] public bool MotorRunning; public short ActualSpeed; public float TemperatureNow; public uint ErrorCode; }3. C#端高效读写实现
3.1 内存映射文件初始化
改进版的初始化方法同时支持开发调试(无Global前缀)和生产环境:
private bool InitMemoryMappedFiles() { string prefix = isRealPLC ? "Global\\" : ""; try { // 写入通道(HMI->PLC) mmfWrite = MemoryMappedFile.CreateOrOpen( prefix + "CODESYS_MEMORY_READ", 1024, MemoryMappedFileAccess.ReadWrite); // 读取通道(PLC->HMI) mmfRead = MemoryMappedFile.CreateOrOpen( prefix + "CODESYS_MEMORY_WRITE", 1024, MemoryMappedFileAccess.ReadWrite); return true; } catch (Exception ex) { logger.Error($"内存映射初始化失败: {ex.Message}"); return false; } }3.2 高频率读取的线程安全方案
使用生产者-消费者模式避免UI线程阻塞:
private BlockingCollection<FromPLC> dataQueue = new BlockingCollection<FromPLC>(100); // 数据读取线程 private void DataReadingThread() { while (!cts.IsCancellationRequested) { FromPLC currentData; if (accessorRead.Read<FromPLC>(0, out currentData) == Marshal.SizeOf(typeof(FromPLC))) { dataQueue.TryAdd(currentData); } Thread.Sleep(5); } } // UI更新线程 private async Task UpdateUIAsync() { await Task.Run(() => { foreach (var data in dataQueue.GetConsumingEnumerable()) { this.Invoke((MethodInvoker)delegate { lblSpeed.Text = $"{data.ActualSpeed} RPM"; pbTemperature.Value = (int)data.TemperatureNow; // 其他UI更新... }); } }); }4. CODESYS端优化技巧
4.1 共享内存管理最佳实践
PROGRAM MAIN VAR {attribute 'no_init'} hReadHandle : RTS_IEC_HANDLE := RTS_INVALID_HANDLE; {attribute 'no_init'} hWriteHandle : RTS_IEC_HANDLE := RTS_INVALID_HANDLE; fbShmInit : FB_ShmInitializer; fbShmReader : FB_ShmReader; fbShmWriter : FB_ShmWriter; END_VAR // 初始化只执行一次 IF NOT bInitialized THEN fbShmInit( sReadName := 'CODESYS_MEMORY_READ', sWriteName := 'CODESYS_MEMORY_WRITE', uiSize := 1024, bInitDone => bInitialized, hRead => hReadHandle, hWrite => hWriteHandle); END_IF // 周期性读写 fbShmReader( hShm := hReadHandle, pData := ADR(g_stFromHMI), uiSize := SIZEOF(g_stFromHMI)); fbShmWriter( hShm := hWriteHandle, pData := ADR(g_stToHMI), uiSize := SIZEOF(g_stToHMI));4.2 错误处理与恢复机制
建立状态监控表实时掌握通信健康度:
| 错误代码 | 含义 | 恢复建议 |
|---|---|---|
| 16#0001 | 内存区域不存在 | 检查C#程序是否启动 |
| 16#0002 | 权限不足 | 以管理员身份运行程序 |
| 16#0003 | 数据校验失败 | 检查结构体定义一致性 |
| 16#0004 | 访问超时 | 优化读写周期设置 |
在CODESYS中实现自动恢复逻辑:
IF uiErrorCode <> 0 THEN CASE uiErrorCode OF 16#0001..16#0003: // 需要重新初始化的错误 bInitialized := FALSE; 16#0004: // 可重试错误 fbShmReader(bExecute := FALSE); fbShmReader(bExecute := TRUE); END_CASE END_IF5. 性能调优实战
5.1 延迟测试方法
使用高精度计时器测量端到端延迟:
// C#端测试代码 var sw = Stopwatch.StartNew(); accessorWrite.Write(0, ref command); while (true) { accessorRead.Read(0, out response); if (response.AckId == command.SendId) { sw.Stop(); Debug.WriteLine($"往返延迟: {sw.ElapsedMilliseconds}ms"); break; } Thread.Sleep(1); }典型优化前后的性能对比:
| 优化措施 | 平均延迟(ms) | 峰值延迟(ms) |
|---|---|---|
| 基础实现 | 8.2 | 15 |
| 内存对齐优化 | 6.5 | 12 |
| 双缓冲机制 | 4.1 | 8 |
| 无锁读写+批量传输 | 2.3 | 5 |
5.2 高级优化技巧
双缓冲技术:
// C#端实现 private FromPLC[] buffer = new FromPLC[2]; private int readIndex = 0; void UpdateBuffer() { int writeIndex = 1 - readIndex; accessorRead.Read(0, out buffer[writeIndex]); if (IsDataValid(ref buffer[writeIndex])) { Interlocked.Exchange(ref readIndex, writeIndex); } }CODESYS端内存池预分配:
// 在全局变量中声明 {attribute 'no_init'} arrShmPool : ARRAY[0..1] OF Str_ParaToHMI; // 使用时交替写入 IF bToggle THEN pTarget := ADR(arrShmPool[0]); ELSE pTarget := ADR(arrShmPool[1]); END_IF bToggle := NOT bToggle;
6. 项目实战:温度监控看板
6.1 UI设计要点
采用WPF实现现代化界面:
<Grid> <Border Style="{StaticResource IndicatorBorder}"> <StackPanel Orientation="Vertical"> <TextBlock Text="电机状态" Style="{StaticResource TitleStyle}"/> <Ellipse Fill="{Binding MotorRunning, Converter={StaticResource BoolToBrush}}" Width="30" Height="30"/> </StackPanel> </Border> <LiveCharts:CartesianChart Series="{Binding TemperatureSeries}"> <LiveCharts:CartesianChart.AxisX> <LiveCharts:Axis LabelFormatter="{Binding DateTimeFormatter}"/> </LiveCharts:CartesianChart.AxisX> </LiveCharts:CartesianChart> </Grid>6.2 数据绑定实现
public class MainViewModel : INotifyPropertyChanged { private FromPLC _plcData; public FromPLC PlcData { get => _plcData; set { _plcData = value; OnPropertyChanged(); OnPropertyChanged(nameof(MotorRunning)); // 其他属性通知... } } public bool MotorRunning => PlcData.MotorRunning; // 使用DispatcherTimer更新数据 private void SetupDataUpdate() { var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) }; timer.Tick += (s, e) => { if (dataQueue.TryTake(out var newData)) { PlcData = newData; } }; timer.Start(); } }在最近的一个食品包装线项目中,这套方案成功实现了对12台伺服电机的实时监控,数据刷新率达到50Hz,CPU占用率控制在15%以下。关键点在于合理设置缓冲区大小——太小会导致数据丢失,太大则会增加延迟。经过测试,将C#端的缓冲区设为PLC数据结构的4倍大小(约256字节)时效果最佳。