简介:面向工业自动化和上位机开发的C#与三菱CNC数控系统通讯Demo工程,基于WinForms框架,适用于机床数据采集、设备监控等自动化场景。核心封装了MitCom通讯类,借助EZNCAUTLib.DispEZNcCommunication组件完成与数控系统的连接初始化、系统类型参数设置及结果状态判断,并通过AlarmData事件机制将报警信息实时推送到界面层,整套调用逻辑清晰完整,开发者可在此基础上直接扩展业务功能,免去从零摸索通讯协议的周折。资源包共145个文件,涵盖cs源码、dll运行库、exe可执行程序、config配置、resources资源、resx界面资源及pdb调试符号,另附完整解决方案、项目工程文件和Git版本管理配置;整体压缩包仅771KB,轻量紧凑,便于快速导入Visual Studio编译调试并开展二次开发。当前已有1128人学习下载,适合具备一定C#基础、希望缩短三菱CNC通讯模块开发周期的工业软件工程师,可据此灵活搭建报警推送、状态监控等上位机应用。
1. 用WinForm拉起三菱CNC通信:这个DEMO源码解决什么问题
做车间设备联网时,C#与三菱CNC通讯DEMO源码、WINFORM这个标题几乎每天都会被人搜到。它讲的白话就是:用Windows桌面程序,通过以太网口把三菱CNC机床的数据读出来,再发一些简单指令回去。这个DEMO不是玩具,它解决的是真正的生产问题——刀具坐标、主轴转速、运行状态、报警号这些数据,过去靠人在面板上抄,现在要交给上位机自动读。适合正在做设备数据采集、产线MES对接、刀具寿命管理这类项目的开发者,也想给刚入门的同事一条不用从零抓瞎的路径。它的价值在于把“能通”和“可靠通”之间的坑先填掉一半。
2. 认识三菱CNC通信协议:EZSocket、站号与字节序
2.1 通信方式选型:EZSocket还是MC Binary
三菱CNC对外提供的以太网通信接口并不只有一种。常见的有EZSocket,也有面向PLC生态的MC Binary协议。对WinForm上位机开发来说,EZSocket是更常见的起点,因为它就是为上位机软件与CNC直接对话设计的,报文结构相对清晰,而且很多老项目、老资料都是基于它做的。MC Binary虽然功能也能覆盖,但它面对的是PLC地址体系,语义更底层,做数据采集时要把地址映射关系理一遍,上手成本明显高一截。
选型判断我一般看三个条件:第一是机床的系统版本和参数里开放了哪个端口,第二是项目里有没有PLC,第三是团队有没有协议经验。没有PLC介入的纯上位机读数、写指令,优先EzSocket;项目里已经有PLC且要联动产线逻辑,才考虑MC Binary。不要因为一个是“新协议”就盲目换,车间里稳定比新潮重要。
| 对比项 | EZSocket | MC Binary |
|---|---|---|
| 适用场景 | WinForm上位机直连CNC | PLC体系联动、MES下沉采集 |
| 报文复杂度 | 相对简单,调试快 | 地址概念强,映射麻烦 |
| 常见端口 | 3001 / 2001 | 视PLC配置而定 |
| 上手成本 | 低 | 较高 |
2.2 EZSocket报文结构:怎么拼帧、怎么定字节序
EZSocket的报文核心由几部分组成:命令类型(读还是写)、站号、数据类型、数据长度、数据区。不同固件版本的命令码和子类型可能不一样,这是最容易翻车的地方。网上流传的报文大多来自M70/M80时代的样例,放在M800上不一定通用,所以我做项目时的习惯是:先把手册里的报文帧格式截图存下来,再按帧格式写构造代码,而不是直接抄别人工程里的十六进制串。
拼报文时要注意字节序。三菱的以太网报文一般按大端排在TCP层,但部分老固件在数据区内又按小端存放数值,这个混合字节序是数据错乱的常见原因。稳妥的方式是统一用BinaryWriter手动按帧结构写,而不是用BitConverter直接转,避免在不同机器上读出不同结果。
// 构造一个EZSocket请求帧,命令码由协议表传入 public byte[] BuildRequest(byte commandCode, int stationNo, byte[] payload) { using var ms = new MemoryStream(); using var bw = new BinaryWriter(ms); // 注意:这里按网络字节序(大端)写入 bw.Write(CommandPreamble); // 帧头,不同机型可能不同,从协议表取 bw.Write((byte)commandCode); // 命令码:读坐标/读状态/写指令 bw.Write((byte)stationNo); // 站号:机床参数里配的那个 bw.Write((ushort)(payload?.Length ?? 0)); // 数据区长度 if (payload != null) { bw.Write(payload); } return ms.ToArray(); }上面这段代码的重点在CommandPreamble和commandCode两个字段。它们不应该硬编码在方法里,而是从协议配置类中读取,因为不同系统版本会把命令码错开一位。数据长度用ushort是因为报文长度字段通常只有两个字节,超过65535字节的请求在CNC上会被直接丢弃。站号不要默认填0,很多时候连接不上的原因就是站号和机床参数对不上。
2.3 连接前必须确认的三项参数:IP、端口、站号
做现场联调时,我一般会先和机床管理员确认三件事:IP地址、端口号、站号。IP地址就是CNC的以太网配置页面里看到的那个地址;端口号常见是3001或2001,也有改过的情况;站号则在一些多轴、多系统配置下尤其重要。这三个参数缺任何一个,或者说错任何一个,后面所有调试都是白费。
| 参数 | 常见取值 | 配置位置 | 影响 |
|---|---|---|---|
| IP地址 | 如192.168.3.30 | CNC网络设置页 | 网络不通直接连不上 |
| 端口号 | 3001 / 2001 | CNC以太网参数 | 连上但无响应 |
| 站号 | 0 / 1 / 2 | CNC系统参数 | 返回数据错位或超时 |
这里有一个现场经验:网段必须通,但很多车间数控机床和办公网不在一个段里,上位机电脑要加一个静态IP到机床同网段,而不是依赖DHCP。还有,机床侧防火墙、网线端口是不是千兆,都会影响连接稳定性。参数确认这一步看着琐碎,但能省下至少半天排查时间。
3. 搭建C# WinForm通信DEMO:从TcpClient到数据上界面
3.1 项目结构:通信类与界面分层
拿到源码包后第一件事不是看Form1.cs,而是看有没有拆分通信层。一个能长期维护的WinForm通讯DEMO,至少应该分出三个文件:主窗体、通信客户端类、协议报文类。窗体只负责显示和用户操作,通信类负责收发、超时、重连,协议报文类负责拼帧和解帧。这个分层不是教条,而是当你把同一个通信类接到其他窗体时,不用复制粘贴几百行代码。
// 项目文件结构示例 // FormMain.cs —— 主窗体,定时器与按钮事件 // MitsubishiClient.cs —— 以太网通信封装,连接/断开/发送/接收 // ProtocolMap.cs —— 命令码与字段映射表,集中管理协议差异这样的好处是:协议字段有变动时,只改ProtocolMap;网络策略有变动时,只改MitsubishiClient;界面要重做时,FormMain随便换。后续接MES、接数据库也都从这里延伸。不要图省事把所有代码堆在窗体里,那是DEMO阶段能跑、上线阶段就改不动的大坑。
3.2 核心通信类:连接、发送、接收与超时处理
通信类是整个DEMO的心脏。连接要能设超时,不能卡死界面;发送要带锁,避免定时器和按钮同时写流;接收要有完整的读循环,一次响应可能分多个TCP包到达。这里我把超时时间设为2000毫秒,针对车间环境这个值比较稳妥——太短容易误判,太长会把界面卡住。
public class MitsubishiClient : IDisposable { private TcpClient _client; private NetworkStream _stream; private readonly object _sendLock = new object(); public bool Connect(string ip, int port, int timeoutMs = 2000) { _client = new TcpClient(); var task = _client.ConnectAsync(ip, port); if (Task.WaitAny(new[] { task }, TimeSpan.FromMilliseconds(timeoutMs)) == -1) { throw new TimeoutException("连接机床超时,请检查IP与端口"); } _stream = _client.GetStream(); _stream.ReadTimeout = timeoutMs; _stream.WriteTimeout = timeoutMs; return _client.Connected; } public byte[] SendAndReceive(byte[] request) { lock (_sendLock) { _stream.Write(request, 0, request.Length); _stream.Flush(); // 帧头长度固定,先读到完整帧头再解析返回长度 var header = ReadExactly(HeaderLength); int payloadLen = BitConverter.ToUInt16(header, HeaderLength - 2); var payload = ReadExactly(payloadLen); return header.Concat(payload).ToArray(); } } private byte[] ReadExactly(int length) { var buffer = new byte[length]; int offset = 0; while (offset < length) { int read = _stream.Read(buffer, offset, length - offset); if (read == 0) throw new IOException("连接已断开"); offset += read; } return buffer; } }这段代码有几个关键点:ConnectAsync配合Task.WaitAny是为了不阻塞UI线程,也能在指定时间内给出“连不上”的明确反馈;发送加锁是因为WinForm里有多个控件可能同时触发命令,比如定时器读坐标和按钮发指令,不加锁会让两个命令的响应在流里错乱;ReadExactly循环是因为TCP没有消息边界,一次Read可能只拿到半个帧,必须按长度读完。
3.3 读取坐标与状态:命令字选择、解析与单位换算
连上以后,读取坐标与状态是第一个要跑通的功能。三菱CNC返回的坐标值一般以机床参数设定的单位输出,常见是0.001毫米或0.001英寸。也就是说报文里返回“12345000”,实际坐标是12345.000毫米,要除以1000。这个换算是新手最容易遗漏的地方,做界面显示时务必把单位换算做进解析层。
public CncCoordinate ReadCoordinate() { // 从ProtocolMap取读坐标命令,帧结构由协议表保证 byte[] request = _protocol.BuildReadCoordinateRequest(); byte[] response = SendAndReceive(request); // 响应数据区内依次存放X/Y/Z三轴值,每轴4字节 float x = BitConverter.ToSingle(response, OffsetX) / 1000f; float y = BitConverter.ToSingle(response, OffsetY) / 1000f; float z = BitConverter.ToSingle(response, OffsetZ) / 1000f; return new CncCoordinate { X = Math.Round(x, 3), Y = Math.Round(y, 3), Z = Math.Round(z, 3) }; }解析时还有两个坑:一是BitConverter.ToSingle把4字节转成float,但字节序在不同固件上有区别,如果读出来是天文数字,就要手工调整字节顺序再转;二是返回值不一定是XYZ顺序,有些系统先返回XYZ,另一些返回的是XYZ+第四轴,要对照手册确认偏移量,否则看到的就是轴对错的荒谬结果。
3.4 WinForm界面上接数据:定时器、异步与日志显示
通信类和解析层就绪后,界面反而是最简单的部分。我建议用System.Windows.Forms.Timer做周期读取,间隔设300毫秒以上,不要低于200毫秒。机床数据本身更新周期有限,读太快除了增加总线负载,还会让界面控件刷新闪烁,看不出实际增益。
private async void timerRead_Tick(object sender, EventArgs e) { try { var coord = await Task.Run(() => _client.ReadCoordinate()); txtX.Text = coord.X.ToString("F3"); txtY.Text = coord.Y.ToString("F3"); txtZ.Text = coord.Z.ToString("F3"); AddLog($"坐标 X={coord.X} Y={coord.Y} Z={coord.Z}"); } catch (Exception ex) { AddLog("读取失败:" + ex.Message); } }用await Task.Run把耗时的网络读写放到线程池,避免阻塞UI。有人会问:直接用定时器同步读不行吗?短期看行,一旦机床响应慢200毫秒,界面就会卡一下,操作员反复点击按钮就会导致事件堆积。日志显示我用DataGridView而不是TextBox,因为批量数据时DataGridView滚动和筛选更方便。注意日志条目要做好上限控制,比如超过500行就移除最旧的,免得长时间挂机撑爆内存。
4. 从读取到写入:M代码、程序启停与报警号读取
4.1 写入M代码:报文格式与前置条件
读功能跑通后,下一步往往是“能不能远程发指令”。写入M代码是最常见的验证方式。写操作的关键是:报文里命令类型要改成“写”,并且一般需要在CNC侧开启远程写入许可,否则机床会直接拒绝。很多DEMO源码只做了读,没做写,原因就是写操作的报文和许可门禁更容易暴露问题。
public void WriteMCode(int mCode) { // 组装写入数据区:M代码数值,按2字节长度写入 byte[] payload = BitConverter.GetBytes((short)mCode); if (!BitConverter.IsLittleEndian) { Array.Reverse(payload); } byte[] request = _protocol.BuildWriteRequest(CommandMCode, payload); byte[] response = SendAndReceive(request); // 判断响应中的状态位 if (response[StatusOffset] != SuccessCode) { throw new InvalidOperationException("机床拒绝写入M代码,请检查远程写入许可"); } }BitConverter.GetBytes在不同机器上字节序不统一,所以先判断IsLittleEndian再翻转,这个防护比直接写固定字节顺序更稳妥。我碰到过一种情况:代码发送成功返回正常,但机床没有任何动作——最后发现是M代码数值在写入时需要先加10000偏移,不同系统对M代码的编码范围有不同的映射规则,这也是DEMO源码里最容易“能通但不能用”的地方。
4.2 程序启动与停止:命令差异和执行状态确认
启动和停止程序看起来像两个对称指令,实际处理方式不一样。启动(Cycle Start)指令发出后,机床会自己去判断当前是否满足启动条件;停止也有分“暂停”和“急停”两种,报文状态位不同。刚接触时会以为发送一个指令就完事,但可靠的实现必须回读状态,确认执行结果。
从状态回读的角度,我一般实现两个方法:IsProgramRunning和IsAutoMode。在发启动命令前先确认机床处于自动模式,在发停止命令后轮询运行状态直到确实停下来。这不是多此一举,而是防止“指令发出去了但程序还在跑”的安全隐患,尤其是切削加工场景。
public void SafeCycleStop() { // 防止在手动模式下误发停止指令 if (!ReadMode().IsAutoMode) { throw new InvalidOperationException("机床不在自动模式,无法执行停止"); } byte[] request = _protocol.BuildWriteRequest(CommandFeedHold, null); SendAndReceive(request); // 等待机床确认停止,最多等待10秒 for (int i = 0; i < 20; i++) { if (!IsProgramRunning()) { AddLog("程序已安全停止"); return; } Thread.Sleep(500); } throw new TimeoutException("停止指令已发送,但10秒内机床未确认停止"); }要注意的是:上位机不是安全控制器,不能把停止指令当成急停用。真出了危险状况,应该靠机床自身的急停按钮和门锁逻辑。上位机程序里做这些检查,是正常流程用的,别越俎代庖。
4.3 报警号读取:从报文到可读信息
报警读取是设备监控里很实用的功能。三菱CNC通过命令读取到的是报警号列表,每个报警号通常对应一条文本描述。这个文本存在哪、能不能一次取回,取决于机床系统的存储设计。我一般先设法把报警号读出来,再和本地维护的报警字典做映射,字典没有的用“未定义报警xx”兜底显示。
public List<int> ReadAlarmNumbers() { byte[] request = _protocol.BuildReadAlarmRequest(); byte[] response = SendAndReceive(request); var alarms = new List<int>(); int count = response[DataOffset]; // 报警条数 for (int i = 0; i < count; i++) { int code = BitConverter.ToUInt16(response, DataOffset + 1 + i * 2); alarms.Add(code); } return alarms; }报警号读取要特别处理“无报警”状态——很多系统返回的不是0条,而是固定填充值,直接解析会得到65535之类的数字。处理方式是先看响应里的状态字段,再决定解析数据区。报警字典的维护也可以在DEMO里做成一个Dictionary,之后要接MES、做报警统计,这个字典还可以落进数据库。
5. 三菱CNC通信常见翻车点与排查路径
5.1 连接不上:先分清网络不通还是协议不通
现象:点击“连接”按钮后长时间无响应,最终报超时或连接失败。
原因排查:这个问题一半出在网络层,一半出在协议层。网络层常见的是上位机和机床不在同一网段、机床网口没插好、防火墙拦了TCP端口;协议层常见的是端口配置不对,比如参数页写的是3001但实际监听在2001。
解决:先在本机命令行ping机床IP,能通再看协议。不要跳过ping直接改代码。ping能通但连接超时,基本可以确定是端口或协议问题,这时才去查机床的以太网参数页。现场加端口时,要同时检查PC端防火墙的入站规则,车间电脑上装的安全软件经常静默拦端口。
5.2 连上就断:TCP保活与重连机制缺失
现象:连接成功后能读几次数据,几分钟后开始超时,重连又能恢复,如此反复。
原因:机床侧不会主动保活,PC端也没有维持连接的心跳,某次通信间隔长了,中间的交换机或机床侧就把空闲连接清掉了。另一个常见原因是机床侧固件对连接数有限制,之前调试时残留的连接没释放,新连接被拒绝。
解决:在通信类中打开TCP KeepAlive,并设置合理的保活时间;同时在每次读失败后主动关闭旧连接、短暂退避再重连。重连要带退避策略,不能每100毫秒重试一次,会加重机床侧负担,一般间隔3到5秒重试一次。
5.3 数据全是零或乱码:字节序、数据类型与单位换算
现象:读取坐标成功,但X/Y/Z值要么全是0,要么是几千几万很离谱的数字。
原因:最常见的是设备返回的数据不是float,而是int或BCD格式;其次是字节序不对,直接按大端读到交换序;还有的就是漏了单位换算,把12345000当成12345000毫米来显示。
解决:先拿一个已知坐标,手动把报文数据区打出来,人工按大端、小端、int、float四种组合试算,通常马上就能对上。这种排错比反复改代码靠谱。确认格式后在解析层统一转换,不要在每个调用点各转一次,避免不一致。
5.4 写指令没反应:站号、写许可和运行模式三座大山
现象:发送M代码或启动指令没有报错,但机床纹丝不动。
原因:第一是站号不对——报文发给了别的系统,当前机床根本没收到。第二是远程写入许可未开启。第三是运行模式不对,比如在手动模式下发了自动启动指令。
解决:先回读站号和当前模式,把这两个值显示到界面上,再点“发送”按钮。确认站号、模式都对,仍然没反应,再去查写入许可。有些机床要求先发一个解锁指令,再发真正的写指令,这个顺序在协议表里通常会有标注,翻译时容易忽略。
5.5 UI卡死:同步收发堵住了界面线程
现象:点击连接或读取后,整个窗体现在“无响应”,CPU不高但就是拖不动。
原因:网络同步方法直接在UI线程执行,机床端刷参数或断网时,ReadTimeout再长两秒,界面就假死两秒。多个定时器叠加时就会变成肉眼可见的卡顿。
解决:所有耗时网络调用都走异步包装,参考前文的await Task.Run模式。界面按钮点击后要里立刻置灰并提示“通信中”,防止用户在卡顿时反复点按钮,把请求队列堆出一串异常。
6. 把DEMO向前推一步:做一个不阻塞UI的批量坐标采样器
DEMO跑通后,大多数人的下一步是“连续记录坐标变化”,用来做轨迹分析或稼动率统计。这个功能看起来就是在定时器里把坐标存文件,但直接把采样写到UI线程里会互相拖累。我习惯的改进方式是引入数据缓冲队列,定时器只负责周期性读取坐标,写入任务把坐标按固定间隔落盘,两者通过队列解耦。
Channel<CncSample> channel = Channel.CreateUnbounded<CncSample>(); int sampleCount = 0; async Task SamplingLoop(CancellationToken ct) { while (!ct.IsCancellationRequested) { var coord = _client.ReadCoordinate(); await channel.Writer.WriteAsync(new CncSample { Time = DateTime.Now, X = coord.X, Y = coord.Y, Z = coord.Z }); await Task.Delay(200, ct); } } async Task FileSaverLoop(CancellationToken ct) { using var sw = new StreamWriter("samples.csv", append: true); await foreach (var sample in channel.Reader.ReadAllAsync(ct)) { sw.WriteLine($"{sample.Time:yyyy-MM-dd HH:mm:ss.fff},{sample.X},{sample.Y},{sample.Z}"); if (++sampleCount % 100 == 0) sw.Flush(); } }这套写法的要点是织入CancellationToken,让两个循环可以在窗体关闭时干净退出。Flush不要每写一行就做,大量小文件写会拖慢采样节奏,100行刷一次在SSD上足够安全。采样间隔200毫秒对应的是5Hz数据频率,做轨迹轮廓分析够用,做高频伺服分析就要考虑协议侧和机床本身的输出能力,不是上位机快就有用的。
采样文件落盘后,我一般用Excel或简单的图表控件直接加载CSV看轨迹。做这个功能时我踩过一个教训:一开始直接用File.AppendAllText在采样循环里写,界面不卡但采样数据有明显的时间抖动,因为磁盘开销不均匀。改成队列加独立写线程后,采样间隔的稳定性好了很多。现在只要是连续采集场景,我都默认用这个结构,不再图省事直接写文件。都是踩出来的经验,希望帮到你。
本文还有配套的精品资源,点击获取