简介:压缩包提供了一套C#与CoDeSys共享内存通信的完整例程,面向自动化、物联网领域开发者,用于解决跨语言进程间高速低延迟数据交换问题,相比串口或网络通信,可明显降低延迟和复杂度,尤其适合PLC控制程序与上位机软件实时交互。资源共含37个文件,以C#源码、CoDeSys工程文件和配置文件为核心,附带解决方案、编译后的可执行文件与调试符号,整体体积仅146KB,目录划分明确,便于直接对照学习与二次修改。已有3857人学习该资源,参考价值得到了验证。通过完整例程,读者能掌握基于Marshal类操作共享内存、结构体与CoDeSys变量块匹配映射、以及读写同步和内存对齐的实践经验,并可直接运行Demo验证效果,非常适合希望快速上手跨语言进程间通信的开发者,也可作为相关课程设计或项目初期的参考原型。
1. 为什么C#和CoDeSys之间要共享内存
一条产线设备的上位机是C#,控制器是CoDeSys软PLC,两者跑在同一台Windows工控机上。界面每秒要刷新上千个点位,历史曲线还要往前端推大块数据。最开始走TCP,延迟不稳;换OPC UA,点位多了以后抖动明显。后来我把两者之间的数据通道换成共享内存,往返耗时直接降到微秒级,CPU占用还比TCP低了一大截。这件事解决的是同一台机器上两个进程之间的高频、大批量数据交换,核心做法是Windows内存映射文件加指针读写,C#负责建通道,CoDeSys调用系统API去打开。如果你也在做上位机与软PLC的高频数据交互,且两者同机部署,这篇文章能给你一套能直接照着改的例程,以及比例程更值钱的踩坑清单。
2. 共享内存方案:原理与选型判断
2.1 内存映射文件的机制:两个进程为什么要通过内存碰头
共享内存在Windows上的正式名称是内存映射文件(Memory-Mapped File)。操作系统把一段物理内存(或页文件支撑的映射区域)映射到进程的虚拟地址空间里,两个进程各自拿到一个指向同一块物理内存的地址。C#里MemoryMappedFile对象就是内核里那个文件映射对象的封装,CoDeSys侧通过kernel32的OpenFileMappingW拿到句柄,再MapViewOfFile得到指针。关键在于:两个进程的虚拟地址可以完全不同,但背后指向同一块物理页面,所以任何一方写入,另一方直接可见。
这个机制带来的最直接好处是零拷贝。对比TCP或共享文件,数据在进程间传递时不需要经过协议栈的多次缓冲拷贝,也不依赖网络组件的调度。代价也很明确:通信双方必须跑在同一台机器上,因为映射对象的作用域就是本机内核对象命名空间。跨机器不行,跨平台也不行——好在CoDeSys软PLC与C#上位机同机部署的场景在设备行业非常常见。
用进程模型来理解更清楚。C#进程和CoDeSys运行时进程彼此隔离,正常情况下不可能互相访问内存地址。内存映射文件相当于在两者之间开了一扇门,门牌号就是那个命名对象。C#把数据写进映射视图,CoDeSys把自己的指针指向同一块区域,读写就是普通内存操作。整个过程不存在网络帧的组装和解析,也没有socket缓冲的延迟,所以在高频小数据块交换场景下,它的延迟表现天然优于TCP类方案。
2.2 是不是非它不可:TCP、OPC UA、共享内存的参数对比
我在选型时常用下面这张表做判断,省去了很多纠结。
| 对比项 | TCP Socket | OPC UA | 共享内存 |
|---|---|---|---|
| 典型往返延迟 | 100微秒~毫秒级 | 亚毫秒~毫秒级 | 1~10微秒级 |
| 延迟抖动 | 依赖系统调度,抖动明显 | 依赖UA栈线程,点位多时加剧 | 极小,接近内存访问 |
| 大批量传输 | 需要分包,序列化开销大 | 数据量大时编码开销高 | 天然适合,无序列化 |
| 跨机器能力 | 支持 | 支持 | 不支持 |
| 实现复杂度 | 中 | 高 | 低 |
| 调试手段 | 网络抓包 | UA客户端工具 | 内存查看器、句柄工具 |
我的经验是:点位数量在几百个以上且刷新周期在几十毫秒以内,共享内存的收益最明显。如果只是几十个开关量和模拟量,TCP或Modbus反而更省事,因为不需要处理指针和结构体对齐。换一个场景,如果上位机和PLC不在同一台机器上,共享内存直接出局,老老实实走OPC UA或者TCP。
还有一个很容易被忽略的维度:调试的确定性。共享内存的数据没有协议格式,出了问题就是一坨字节,必须有结构体定义和可视化工具兜底。所以我的建议是:共享内存适合有固定结构、固定长度的数据块交互,不适合零散的、动态拼接的消息。通信双方一旦需要灵活的消息语义,还是TCP更合适。
2.3 CoDeSys侧能不能碰共享内存:外部函数调用的边界
很多人第一次听说CoDeSys也能操作共享内存时,第一反应是PLC里哪有这种库函数。确实,标准IEC 61131-3库里没有内存映射相关的功能块,但这不妨碍我们实现。CoDeSys Control Win运行时本身就是一个Windows用户态进程,它支持通过外部函数声明直接调用系统DLL导出函数。这意味着kernel32.dll里的OpenFileMappingW、MapViewOfFile、UnmapViewOfFile、CloseHandle都能被结构化文本直接声明调用。
边界条件有三条,实操中必须确认。第一,CoDeSys运行时位数必须与C#进程位数一致,或者至少都装在64位系统且CoDeSys是64位版本,指针长度不一致时传递出来的地址会直接错位。第二,调用外部函数传递指针时,CoDeSys侧要用匹配的地址类型变量保存MapViewOfFile返回值,不能塞进一个普通的DINT里再转换。第三,CoDeSys的扫描周期会影响读写时序,共享内存本身没有同步机制,必须自己在协议层用心跳和状态字兜底。
3. C#侧建立共享内存:最小可运行工程
3.1 创建命名共享内存:一个可以直接抄的C#类
C#侧的核心是System.IO.MemoryMappedFiles命名空间,不需要P/Invoke就能完成映射。我一般把整个通道封装成一个类,构造函数接收通道名、容量和读写模式,调用方只需要关心Read和Write两个方法。
using System; using System.IO.MemoryMappedFiles; using System.Runtime.InteropServices; public class SharedMemoryChannel : IDisposable { private MemoryMappedFile _mmf; private MemoryMappedViewAccessor _view; private readonly string _mapName; private readonly long _capacity; public SharedMemoryChannel(string mapName, long capacity, bool createIfMissing) { _mapName = mapName; _capacity = capacity; if (createIfMissing) { // 创建或打开:服务端先创建,客户端后打开 _mmf = MemoryMappedFile.CreateOrOpen( mapName, capacity, MemoryMappedFileAccess.ReadWrite); } else { // 客户端模式:只打开不创建,避免误建出空的映射对象 _mmf = MemoryMappedFile.OpenExisting( mapName, MemoryMappedFileAccess.ReadWrite); } _view = _mmf.CreateViewAccessor(0, capacity, MemoryMappedFileAccess.ReadWrite); } public void Write<T>(long offset, ref T data) where T : struct { _view.Write(offset, ref data); } public void Read<T>(long offset, out T data) where T : struct { _view.Read(offset, out data); } public void Dispose() { _view?.Dispose(); _mmf?.Dispose(); } }这段代码里有两个参数值得展开说。第一个是CreateOrOpen与OpenExisting的选择:服务端用前者,因为首次启动要创建对象;客户端用后者,避免C#服务还没起来时客户端自己把一个空映射对象建出来,等会儿服务端反而打不开。第二个是容量,Windows内存映射按页对齐,页大小一般是4KB,我习惯直接把容量设成结构体的整数倍并对齐到4096,避免视图尾部出现无法访问的碎片页。
创建视图时还有一个小细节:CreateViewAccessor的第一个参数是偏移量,第二个是视图大小。我通常直接给完整容量,省去按偏移管理的麻烦。如果只映射部分区域,后续读写时offset就要相对视图起点计算,容易绕晕。
3.2 结构体布局与封送:避免C#和CoDeSys各说各话
共享内存里的数据本质就是一串字节,两边如果对字段顺序、类型大小理解不一致,读出来的数据就是乱的。C#里用StructLayout明确控制布局,CoDeSys侧用相同顺序定义结构体。下面这个结构体是我在项目里常用的一套协议字段。
using System; using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct PlcExchangeData { public byte Command; // 命令字:0=空闲, 1=启动, 2=停止 public byte Status; // 状态字:0=空闲, 1=运行中, 2=故障 public uint SeqCounter; // 序号:每次写入+1 public uint Heartbeat; // 心跳:CoDeSys每周期+1 [MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)] public byte[] DataBuffer; // 数据区:按需定义长度 }这里最关键的三个坑是:不要用bool,C#的bool占1字节但封送时可能被当作整型处理,CoDeSys侧对不上,统一用byte代替。MarshalAs的SizeConst必须与CoDeSys侧数组长度一致,差一个字节整个结构体就错位。Pack = 1表示不填充对齐字节,C#默认按平台对齐,如果不指定,结构体中间可能被塞进填充字节,两边定义不一致。
判断结构体大小是否一致,C#侧可以用Marshal.SizeOf ()直接算出来。CoDeSys侧用SIZEOF也可以拿到字节数。我在写完结构体后第一件事就是两边打印大小,对不上再调整,不靠肉眼数。
3.3 生命周期与访问权限:服务、会话隔离、重连
如果C#上位机以Windows服务方式运行,共享内存的命名空间就要特别注意。服务进程运行在Session 0,交互式桌面程序在Session 1及以上,两者对内核对象的默认访问策略不同。共享内存命名带Global前缀可以跨会话可见,但需要服务拥有SeCreateGlobalPrivilege权限,普通服务默认有,普通应用程序在非交互会话里不一定有。
还有一个常见问题是进程退出后的对象清理。命名内存映射对象在内核里的生命周期由句柄引用计数决定,不是由创建它的进程决定。一旦C#进程崩溃,只要还有别的进程持有句柄,对象就不会消失。CoDeSys侧如果已经MapViewOfFile成功,拿到的是自己的地址映射,C#进程退不退出都不影响已有地址的读写,问题在于重新创建后内容被清零,CoDeSys侧需要检测序号跳变来感知对端重启。
给共享内存命名时建议直接用Global开头,比如Global\PlcExchangeData。别嫌名字长,这一步能省掉后面大量Session隔离的排查。如果遇到打不开的情况,用进程管理器确认一下映射对象是否存在,再确认当前进程权限,比在代码里猜更高效。
4. CoDeSys侧实现:从声明API到完整读写功能块
4.1 在结构化文本里声明kernel32函数
CoDeSys里调用外部DLL函数不需要额外安装库,直接在POU里声明FUNCTION就可以。但声明必须严格匹配Windows API原型,参数类型和顺序错一个都调用失败。下面是四个核心函数的声明写法。
FUNCTION OpenFileMappingW : __PVOID VAR_INPUT dwDesiredAccess : DWORD; bInheritHandle : BOOL; lpName : __PVOID; // LPCWSTR, 指向UTF-16字符串 END_VARFUNCTION MapViewOfFile : __PVOID VAR_INPUT hFileMappingObject : __PVOID; dwDesiredAccess : DWORD; dwFileOffsetHigh : DWORD; dwFileOffsetLow : DWORD; dwNumberOfBytesToMap : DWORD; END_VARFUNCTION UnmapViewOfFile : BOOL VAR_INPUT lpBaseAddress : __PVOID; END_VARFUNCTION CloseHandle : BOOL VAR_INPUT hObject : __PVOID; END_VAR声明外部函数时,返回类型和指针参数一律用__PVOID,不要用DINT代替。__PVOID在CoDeSys里是平台相关的指针类型,64位环境下占64位,32位环境下占32位。如果你声明成DINT,64位环境下高32位被截断,MapViewOfFile返回的地址直接变成无效指针,一读写就崩。字符串参数也要注意:OpenFileMappingW接收的是宽字符串,CoDeSys里的STRING默认是ANSI,需要先把通道名转成UTF-16字节数组,再把数组首地址塞给lpName,这一步不知道卡了多少人。
4.2 把共享内存封装成功能块FbSharedMemClient
直接散着调用API函数后期维护成本高,我习惯把它封装成功能块,对外只暴露两个方法:Open和Close,内部管理句柄和指针状态。下面这段是一个最小实现。
FUNCTION_BLOCK FbSharedMemClient VAR hMapping : __PVOID; pBase : __PVOID; bOpened : BOOL; END_VAR METHOD Open : BOOL VAR_INPUT bCreate : BOOL; // TRUE=创建者, FALSE=打开者 sName : STRING(64); // 共享内存名称 END_VAR VAR wszName : ARRAY[0..127] OF BYTE; dwNameLen : DINT; pName : __PVOID; i : DINT; END_VAR打开逻辑分四步:把STRING转成UTF-16字节数组,调用OpenFileMappingW或CreateFileMappingW,判断返回值,成功则调用MapViewOfFile。关闭逻辑反着来,先UnmapViewOfFile再CloseHandle,顺序错了会先释放句柄导致进程冲突。
CoDeSys的FB实例如果在一个任务里周期调用,Open方法必须在首次调用时完成初始化,后面周期只做读写。我通常加一个内部状态机:Init、Opening、Opened、Closed。Open成功后才允许读写方法执行,Open失败时返回错误码而不是直接崩掉。错误码用GetLastError获取需要再声明一个kernel32函数,定义成FUNCTION GetLastError : DWORD即可,这个值能直接对微软错误码文档,排查效率高很多。
4.3 协议层:心跳、序号与状态标志位
共享内存最大的隐患是数据一致性问题。CoDeSys任务周期比如2ms,C#侧可能是10ms写一次,两边读写没有锁。结构体里的某个DWORD如果跨字节拆分成多次内存访问,对端可能读到一半被改写的新值。解决办法就是在协议层做握手机制,而不是依赖操作系统给锁。
我的做法是设计三件套:SeqCounter序号,每次写入方写完数据后自增;Heartbeat心跳,由对端周期递增用于检测存活;Status状态字,表示当前数据区是否处于可读状态。写方可遵循先写数据区,再翻转状态字,最后增加序号的顺序;读方先读序号和状态字,再读数据区,最后再读一次序号校验两次序号一致才认可这批数据。这个方案叫作双缓冲校验,比加锁简单得多,实测在2ms任务周期下万次读取未出现一次错读。
// 读侧帧校验伪代码 IF status = 1 THEN dwSeq1 := data.SeqCounter; // 此时读取数据区字段 fValue := data.DataBuffer[0]; dwSeq2 := data.SeqCounter; IF dwSeq1 = dwSeq2 THEN // 两次序号一致,数据有效 ELSE // 数据被写方更新打断,丢弃本次 END_IF END_IF注意这个方案不能彻底解决并发问题,但它把错读概率压到了极低,因为内存映射的写入是原子性相对较强的单指令操作。如果对一致性要求更严格,比如机器人实时轨迹数据,我会改成分区双缓冲方案:写方写A区时读方读B区,写完后交换标志。代价是内存尺寸翻倍,但彻底消除了读写竞争。
5. 避坑与排查:共享内存对接常见的5个实测问题
5.1 现象:CoDeSys侧OpenFileMappingW一直返回空指针
原因:最常见的是C#进程和CoDeSys进程位数不一致,导致对象句柄类型不匹配;其次是共享内存名称没有加Global前缀,CoDeSys运行在Session 0或不同用户上下文时访问不到。
解决:先检查两个进程的位数,用任务管理器确认。再检查命名是否统一,C#和CoDeSys两侧的字符串必须一字不差,包括大小写。最后调用GetLastError拿错误码,返回2表示系统找不到指定文件,返回5表示拒绝访问,前者查命名,后者查权限。
5.2 现象:结构体字段读出来错位,数值明显不对
原因:C#侧没有指定Pack对齐,结构体中间被插入了填充字节;或者CoDeSys侧数组长度和C#侧不一致。
解决:C#侧所有共享结构体都加[StructLayout(LayoutKind.Sequential, Pack = 1)],两边各打印一次SIZEOF确认字节数相同。CoDeSys侧用SIZEOF(MyStruct),C#侧用Marshal.SizeOf,两边值必须严格相等。
5.3 现象:C#服务里能写,桌面调试程序读不到
原因:服务跑在Session 0,桌面程序跑在Session 1,内核对象默认使用Local命名空间,不同会话隔离导致不可见。
解决:映射名称统一用Global\开头,且服务进程需要具备创建全局对象的权限。如果权限受限,可以在服务启动时显式设置ACL,或者在部署时给服务账户授予相应权限。
5.4 现象:C#崩溃重启后,CoDeSys侧指针没变但数据全是零
原因:C#进程退出后内核对象引用计数归零,映射区域内容被系统清零,新进程重新创建时是一块干净内存。CoDeSys侧老指针还在,但映射已经被系统层面释放。
解决:CoDeSys侧必须监控SeqCounter和心跳,连续几个周期心跳不再增长就判定对端离线,主动Unmap并重新执行Open。不要试图在对方重启期间继续写数据,都是白写。
5.5 现象:数据整体正常,但偶发一次值跳变
原因:CoDeSys任务周期和C#写入周期没有同步,读取时正好赶上写入方写入多字节数据的中间状态。
解决:采用4.3节的双缓冲校验,读数据前记录序号,读完再校验序号一致。如果跳变出现在高频率写场景,可以考虑把数据类型改成原子长度,比如4字节对齐的DWORD拆开写两次。
6. 上产线前验证吞吐量测试与长期稳定性的三个习惯
把共享内存跑通不代表能交付,我在这个方向上栽过跟头,后来养成了一套验证习惯。第一件事是压测:C#侧用Stopwatch循环写100万次结构体,统计平均单次耗时和总耗时,CoDeSys侧在线监视SeqCounter递增速率。正常在同一台工控机上,100万次小结构体写入应在几秒内完成,平均往返在微秒级。如果出现毫秒级耗时,优先检查是否有触发了磁盘交换的大镜像,或者CoDeSys任务周期设置过长。
第二个习惯是故意杀进程测试。C#侧写一个定时重启逻辑,每隔几分钟重启进程,观察CoDeSys侧能否在三个周期内自动重连,重连后序号是否连续。抓不到这个现场的话,以后产线上掉一次电就是灾难。
第三个习惯是把心跳和错误码全部暴露到界面上的调试页。我习惯在C#上位机加一个诊断窗口,显示心跳计数、序号、最近一次错误码、重连次数和帧丢弃次数。这些数据对比TCP方案里的粘包重传统计更直观,因为共享内存没有协议栈中间层,出问题就是底层指针或地址问题,数据一眼就能定位到是写入方还是读取方。
整个方案做完后,我最大的教训是别省掉协议层的心跳序号设计。共享内存太简单了,简单到让人误以为不需要协议,实际上没有序号和状态字,出问题连定位都无从下手。双缓冲校验那十几行代码,换来的是现场半年没出现一次错读数据。
希望帮到你。
本文还有配套的精品资源,点击获取