
简介该演示源码基于C# WinForm编写面向工控上位机与数控系统开发人员演示如何借助三菱官方通讯库EZNCAUTLib实现与三菱CNC机床的数据交互解决设备数据采集、状态读取与指令下发等常见问题。包内共145个文件以24个C#源文件与Visual Studio工程文件为核心附带三菱通讯依赖库、可运行exe、资源配置及少量说明文档压缩后仅771KB结构紧凑适合直接打开调试。当前已有1128人学习/下载可帮助需要快速理解三菱CNC通讯协议封装或进行二次开发的工程师缩短预研周期。源码重点设计了MitCom通讯封装类通过事件机制向外传递告警等实时数据可直接参考初始化流程、事件绑定与Winform界面交互写法并可在实际项目中复用其通讯框架。1. C# 与三菱 CNC 通讯WinForm 上位机 Demo 到底能解决什么做上位机的人都知道C# 加 WinForm 是三菱 CNC 数据采集场景里最常见的一套组合但真正把通讯 Demo 跑通卡住的往往是协议细节而不是代码本身。这套源码解决的核心问题很直接让一台普通 PC 或工控机通过以太网与三菱 CNC 交换数据——读坐标、读运行状态、下发启动停止指令全部落到 WinForm 界面上。适合三类人来复用正在做产线数据采集却不知道协议怎么开口的 C# 开发者要给 CNC 增加远程监控画面而没有现成通讯代码的电气工程师以及想弄明白 MC 协议报文长什么样的自动化新人。下面前几章先把选型讲清楚再给可运行的代码最后把现场常见的坑逐个拆掉。2. 通讯选型EZSocket、MC 协议与透传哪条路更适合你的现场2.1 三菱 CNC 对外通讯的三条通道三菱 CNC 开放给上位机的数据通道实际能落地的就三类。第一类是官方工具链比如 EZSocket 这类 ActiveX 控件它把底层报文封装好了上位机通过数据 ID 直接读坐标、读报警、读程序号。优点是不用操心帧格式缺点也很明显COM 组件注册、x86/x64 位数、.NET 版本兼容任何一个环节没对上就起不来。第二类是用 MC 协议直接走 TCP 端口发报文这是很多 DEMO 源码的首选也是本章重点。MC 协议本质上是访问 CNC 内部的数据寄存器空间上位机自己拼帧、自己解析响应没有任何中间依赖部署就是一把裸 exe但代价是地址映射得自己对着手册和设备侧梯形图确认。第三类是文件级交互通过 FTP、共享目录或者 CF 卡导入导出加工程序适合批量上下载程序不适合做实时的坐标和状态采集。很多第一次接触三菱 CNC 的人会默认官方控件是唯一方案真的到现场就会发现老设备上控件版本和系统不匹配反而是常态。我一般建议先看 CNC 型号和系统版本再看现场有没有 PLC 梯形图的修改权限如果两者都受限制EZSocket 依然是第一选择如果设备是近几年交付的以太网功能选项齐全MC 协议直连更省心这也是这份 DEMO 源码主走的路线。2.2 方案对比EZSocket、MC 协议、文件级交互怎么选对比项EZSocket 官方控件MC 协议 TCP 直连文件级交互开发量小控件封装了数据 ID中需要拼报文和解析小但只适合传文件实时性好好差秒级以上部署依赖需要注册 COM注意位数无依赖一把 exe 即可依赖 FTP 服务或共享目录数据范围官方定义的数据 ID寄存器地址取决于梯形图映射程序文件不涉及点位典型坑注册失败、Embed Interop 冲突地址映射不准、字节序颠倒不适合读坐标、状态从上表能看出来MC 协议是投入产出比最均衡的方案。它不依赖第三方组件协议帧结构在网上和三菱的通讯手册里都能查到对照只要 CNC 侧把网络参数打开就能建立连接。EZSocket 适合时间紧、数据点多、不想碰协议的场景但每次换现场机器都可能被 COM 注册坑一次。文件级交互基本排除在实时监控类需求之外。2.3 最小通讯单元MC 协议 3E 帧的读取请求怎么拼MC 协议里常用的是 3E 帧二进制模式读字请求的帧结构不算复杂理解它比背下来更重要。帧头占据前两个字节然后是网络号、PC 号、IO 编号和站号这四段在单机直连场景下用默认值即可。从监视定时器之后才是真正跟业务相关的字段。下面给出一段 C# 构建读取请求帧的代码private byte[] BuildReadFrame(ushort startAddr, ushort count) { using var ms new MemoryStream(); // 帧头3E 帧二进制模式固定为 D0 00 ms.Write(new byte[] { 0xD0, 0x00 }, 0, 2); ms.WriteByte(0x00); // 网络号 ms.WriteByte(0xFF); // PC 号 ms.Write(new byte[] { 0x03, 0xFF }, 0, 2); // IO 编号固定值 ms.WriteByte(0x00); // 站号 // 请求数据长度从监视定时器到帧尾的字节数读字请求固定 11 ms.Write(new byte[] { 0x0B, 0x00 }, 0, 2); ms.Write(new byte[] { 0x10, 0x00 }, 0, 2); // 监视定时器0x10 * 250ms 4 秒 ms.Write(new byte[] { 0x01, 0x04 }, 0, 2); // 指令0x0401 读字 ms.Write(new byte[] { 0x00, 0x00 }, 0, 2); // 子指令按字访问 // 起始地址 3 字节低字节在前 ms.WriteByte((byte)(startAddr 0xFF)); ms.WriteByte((byte)((startAddr 8) 0xFF)); ms.WriteByte(0x00); // 扩展位D 设备置 0 // 读取点数 2 字节低字节在前 ms.WriteByte((byte)(count 0xFF)); ms.WriteByte((byte)((count 8) 0xFF)); return ms.ToArray(); }这段代码里请求数据长度算作 11 是很多人容易算错的地方监视定时器占 2 字节指令占 2 字节子指令占 2 字节起始地址占 3 字节读取点数占 2 字节加起来正好 11不含帧头。监视定时器 0x0010 表示 16 个时间单位每个单位 250 毫秒也就是 4 秒这个值在长响应场景下可以调大单位换算关系不变。startAddr 是字地址比如要读 D100就传 100按字地址而不是字节地址计算。count 是读取的字数建议控制在 64 字以内部分系列批量读取上限是 256 字超了会直接返回错误码这个以 CNC 配套手册为准。扩展位字节在 D 设备访问时填 0如果以后要访问文件寄存器 R 或扩展继电器这里可能不同遇到再说。3. 连接与读取C# 里把坐标、状态读回界面的具体写法3.1 工程骨架与连接管理类 CncClient拿到这份 DEMO 源码先别急着跑界面把通讯层剥出来看。核心类 CncClient 承担了连接、读写、断开的全部职责UI 层只跟它打交道。工程结构上建议拆成三个文件CncClient.cs 管通讯AddressMap.cs 管地址映射MainForm.cs 管界面。这样后面换机型只需要改地址映射不用动通讯代码。public class CncClient : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private readonly object _lock new object(); private readonly int _timeoutMs; public CncClient(int timeoutMs 3000) { _timeoutMs timeoutMs; } public void Connect(string ip, int port) { Dispose(); _tcp new TcpClient(); var task _tcp.ConnectAsync(ip, port); if (!task.Wait(_timeoutMs)) throw new TimeoutException($连接 {ip}:{port} 超时); _stream _tcp.GetStream(); _stream.ReadTimeout _timeoutMs; _stream.WriteTimeout _timeoutMs; } public void Dispose() { _stream?.Dispose(); _tcp?.Dispose(); } }Connect 方法里用 ConnectAsync 加 Wait 的方式实现超时控制比直接调 Connect 好用后者在网络不通时会卡住几十秒。连接超时时间默认 3 秒现场如果是跨交换机跨网段可以调到 5 秒但不要太大不然断线后恢复会很慢。_lock 对象在读写方法里会用到确保轮询线程和写入线程不会同时操作流。3.2 读坐标按字读取、字节序与比例尺坐标数据在 CNC 侧通常是字单位1 个字对应 16 位有符号整数。很多 DEMO 第一次跑出来的坐标是乱码不是协议问题而是把无符号读出来了。负坐标在 -0.001mm 量级下会被读成 65535 之类的正数看着就像脱缰了一样。读取并解析坐标的完整方法如下public short[] ReadWords(ushort startAddr, ushort count) { byte[] frame BuildReadFrame(startAddr, count); lock (_lock) { _stream.Write(frame, 0, frame.Length); // 响应头帧头2 网络号1 PC号1 IO号2 站号1 7 字节 byte[] header ReadExact(7); byte[] lenBuf ReadExact(2); int bodyLen BitConverter.ToUInt16(lenBuf, 0); byte[] body ReadExact(bodyLen); ushort endCode BitConverter.ToUInt16(body, 0); if (endCode ! 0x0000) throw new IOException($MC 返回错误码 0x{endCode:X4}); int dataLen bodyLen - 2; short[] result new short[dataLen / 2]; for (int i 0; i result.Length; i) { result[i] BitConverter.ToInt16(body, 2 i * 2); } return result; } }ReadExact 是一个循环读取的辅助方法因为 NetworkStream.Read 一次未必读完指定长度必须循环。响应里 bodyLen 包含了结束代码的 2 字节所以要减掉才能得到数据区长度。读取的数据按小端字节序解析成 short三菱的数据寄存器就是小端BitConverter.ToInt16 在 x86 和 x64 平台上都符合。得到的 short 数组需要乘以比例尺才是真实物理量常见 CNC 最小输入单位是 0.001mmD 寄存器里存的是 12345 就代表 12.345mm比例尺就是 0.001。这个值不要写死放进配置里不同轴可能不同。3.3 读状态状态字的位解析运行状态和坐标不一样坐标是多字数据状态一般是一个字里打包了多个开关量。比如 bit0 代表运行中bit1 代表报警bit2 代表自动模式这种位定义通常靠 CNC 侧梯形图从系统变量搬运到 D 寄存器。状态字解析用枚举加位逻辑最清晰[Flags] public enum CncStatus { None 0, Running 1 0, // bit0: 主轴运行中 Alarm 1 1, // bit1: 有报警 AutoMode 1 2, // bit2: 自动模式 HomeComplete 1 3, // bit3: 回零完成 } public static CncStatus ParseStatus(short raw) { return (CncStatus)(raw 0x000F); }掩码 0x000F 的意思是只取低 4 位高位可能在现场设备里另有定义不能贸然全量转换。实际项目里位定义可能更多规范做法是先拉到 CNC 面板的 PLC 监控页看 D 寄存器每个 bit 的实际变化确认之后再往枚举里补。这种位解析代码本身不复杂麻烦的是确认 bit 定义的过程所以 DEMO 里一定要保留这份枚举定义和现场对照记录。3.4 界面刷新定时器轮询与跨线程安全WinForm 界面用 System.Windows.Forms.Timer 做定时轮询是最省事的做法但坑也集中在 UI 线程上。网络 IO 是阻塞的如果直接在 Tick 事件里同步 ReadWords界面会卡到通讯完成才恢复一旦断线就可能白屏好几秒。轮询代码要防重入并借助 BeginInvoke 回到 UI 线程更新控件private bool _busy; private void timer_Tick(object sender, EventArgs e) { if (_busy) return; _busy true; try { short[] data _client.ReadWords(0x0010, 6); BeginInvoke(new Action(() { lblX.Text (data[0] * 0.001m).ToString(F3); lblY.Text (data[1] * 0.001m).ToString(F3); lblZ.Text (data[2] * 0.001m).ToString(F3); lblStatus.Text CncStatus.ParseStatus(data[3]).ToString(); })); } catch (Exception ex) { BeginInvoke(new Action(() lblStatus.Text 通讯异常 ex.Message)); } finally { _busy false; } }_busy 标志位是为了防止上一次周期还没结束下一次 Tick 又进来导致网络流同时被多个线程操作。BeginInvoke 的作用是把控件更新排到 UI 线程队列不在后台线程直接操作 Label。轮询间隔一般取 200 到 500 毫秒太短会增加 CNC 侧通讯负担太长界面上看着不实时。还要注意 Timer 的 Interval 在界面失去焦点时不会停下窗体最小化时建议主动停止轮询省得无谓占网。4. 写入与下发程序号、启停指令与报文日志的落地细节4.1 写请求帧与读请求帧的三处不对称写操作比读操作复杂的地方在于帧结构调整了三处指令码从 0x0401 换成 0x1401请求数据长度不再是固定 11而是要在末尾追加要写入的数据。构建写帧的代码如下private byte[] BuildWriteFrame(ushort startAddr, short[] values) { int bodyLen 11 values.Length * 2; using var ms new MemoryStream(); ms.Write(new byte[] { 0xD0, 0x00 }, 0, 2); ms.WriteByte(0x00); ms.WriteByte(0xFF); ms.Write(new byte[] { 0x03, 0xFF }, 0, 2); ms.WriteByte(0x00); ms.Write(new byte[] { (byte)(bodyLen 0xFF), (byte)(bodyLen 8) }, 0, 2); ms.Write(new byte[] { 0x10, 0x00 }, 0, 2); ms.Write(new byte[] { 0x01, 0x14 }, 0, 2); // 指令 0x1401 写字 ms.Write(new byte[] { 0x00, 0x00 }, 0, 2); // 子指令仍按字 ms.WriteByte((byte)(startAddr 0xFF)); ms.WriteByte((byte)((startAddr 8) 0xFF)); ms.WriteByte(0x00); ms.WriteByte((byte)(values.Length 0xFF)); ms.WriteByte((byte)((values.Length 8) 0xFF)); foreach (short v in values) { ms.WriteByte((byte)(v 0xFF)); ms.WriteByte((byte)((v 8) 0xFF)); } return ms.ToArray(); }第一处不对称是请求数据长度跟随写入数据变化忘了改就会导致 CNC 端解析错位。第二处是响应解析逻辑不同写响应只有结束代码没有数据区所以读完 bodyLen 后直接判断结束代码即可。第三处是写入操作必须确认结束代码为 0x0000 才能算成功有些设备在自动运行模式下拒绝写入会返回错误码这一层异常必须向上抛不能静默吞掉。4.2 下发程序号与启停指令的时序MC 协议本身只能写寄存器不能直接发「启动加工」这种语义指令。现场常见做法是在 CNC 内置 PLC 梯形图里定义好控制字比如 D200 写入 1 表示请求自动运行梯形图读到后触发启动信号。这才解释得通为什么很多 DEMO 源码里下发程序号和一个控制字是分开写的。通用时序如下// 第一步切换到编辑或自动模式 _client.WriteWords(0x00C8, new short[] { 1 }); // 模式控制字置 1 Thread.Sleep(50); // 第二步写入目标程序号 _client.WriteWords(0x00CA, new short[] { 100 }); // 程序号 O0100 // 第三步给出启动脉冲置 1 再置 0 _client.WriteWords(0x00CC, new short[] { 1 }); Thread.Sleep(100); _client.WriteWords(0x00CC, new short[] { 0 });这里 D 区地址是示意值真实项目必须根据现场梯形图定义来定。我把模式切换、程序号、启动分三个步骤的目的就是让每一步可验证在 CNC 面板上观察当前模式是否切换、程序号是否正确、启动是否有动作。三步之间加延时是缓解总线竞争不是必须但加了能减少偶发失败。如果发完启动指令后 CNC 没有动先看模式是否为自动再看机床是否处于复位状态报警状态下启动指令会被忽略。4.3 报文日志把黑匣子变成可回放的记录通讯联调最怕的是现场复现不了问题。每次收发都留报文日志后面排查能少走很多弯路。一个朴素的日志记录方法把 TX 和 RX 的十六进制报文连同时间戳写进文本文件public void LogExchange(byte[] tx, byte[] rx, string remark) { var sb new StringBuilder(); sb.AppendLine($[{DateTime.Now:HH:mm:ss.fff}] {remark}); sb.AppendLine($TX: {Convert.ToHexString(tx)}); if (rx ! null rx.Length 0) sb.AppendLine($RX: {Convert.ToHexString(rx)}); File.AppendAllText(_logPath, sb.ToString()); }Convert.ToHexString 是 .NET 5 以上的写法如果 DEMO 工程跑在 .NET Framework 4.x 上要用 BitConverter.ToString 然后去掉中间的横线。日志记录放到 CncClient 内部调用每次 ReadWords 和 WriteWords 都自动记不要等出问题再去加。有了报文日志回放比对就很简单把出问题时的 TX 帧和正常时的 TX 帧放在一起逐字节看往往就是请求长度的低字节写错或者地址移位一个字节。5. 避坑与排查连接失败、数值紊乱、UI 卡死的五个真实场景5.1 连接超时但 CNC 侧无响应现象PC ping 得通 CNC 的 IP但程序 Connect 每次都超时CNC 面板上网络状态看着也是正常的。原因ping 通只代表 IP 层可达不代表业务端口开放。CNC 的以太网功能一般分好几个服务MC 协议服务如果没有单独开启端口就是关闭状态。另外 PC 的防火墙也会拦截入站响应尤其是 Windows 10 以上系统默认阻止外部连接。解决三步走。第一步用 Test-NetConnection 指令验证端口通不通第二步到 CNC 面板的网络参数画面确认 MC 协议服务处于启用状态端口号和程序里一致第三步临时关闭 PC 防火墙测试通了再放行相关端口。从那以后我每次联机都强制走一遍 IP、端口、服务开关三层确认没再被这个问题绊过脚。5.2 坐标数值是负的或者被放大了一百倍现象读出来的 X 坐标显示 -32768或者明明在原点附近却显示 64000 多和面板实际值完全对不上。原因这两个现象根因不同。负数偏置通常是把有符号数按无符号解析比如 -0.5 在 short 里是 0xFFFE按 ushort 读出来就是 65534再乘比例尺自然离谱。放大一百倍通常是比例尺搞错CNC 最小输入单位是 0.001mm但有人用 0.01 去乘或者反过来。解决解析 short 时用 BitConverter.ToInt16不要用 ToUInt16比例尺先写死 0.001 验证一个已知坐标准了再改成配置值。还有极少数设备里坐标用 BCD 码存储这时候不能直接 short 转换要额外写 BCD 转十进制函数。判断方法很笨但是有效在 CNC 面板上手动摇到一个整数位置比如 12.000mm读出来如果是 0x1200 这类形态八成是 BCD。5.3 程序号永远读到 0现象连接正常其他寄存器都读得出唯独程序号这一项一直是 0。原因程序号不在普通的 D 寄存器区很多型号要通过系统变量或者在特定条件下才能读取比如 CNC 处于复位状态时程序号缓冲区是空的。还有可能读的 D 地址虽然存在但梯形图根本没有把程序号搬运到那里读到的只是上电默认值 0。解决先去 CNC 面板的手动页面确认当前程序号确实有值再到 PLC 监控页面看目标 D 地址的内容。如果面板有值而 D 地址是 0问题就在梯形图侧需要找电气程序确认搬运逻辑如果 D 地址有值而程序读到 0才轮到排查通讯解析。顺序不能反过来否则会被错误地带偏方向。5.4 轮询一多 UI 就卡死现象界面跑十几分钟后操作变得迟钝点按钮半天没反应偶尔出现「未响应」标题。原因最常见的是在 UI 线程同步执行了网络读TCP 超时期间界面完全阻塞。另一种隐蔽情况是 Timer 间隔太短网络慢的时候上次请求还没返回下个 Tick 又进来导致多个请求堆积在流里响应错位最后抛异常异常逻辑里又同步写文件进一步卡 UI。解决同步读改成 Task.Run 包裹或者把读操作放到独立的后台线程队列里只放最新一帧数据。Timer 间隔不要低于 200 毫秒并且保持 _busy 防重入。写日志改为异步线程或者限制频率不要在异常处理里做文件追加这种高延迟操作。5.5 EZSocket COM 组件注册与位数翻车现象方案选型用了 EZSocket代码编译没问题运行时却报「未在注册表中标记为允许交互操作」或者找不到 COM 类工厂。原因EZSocket 是 32 位 ActiveX 控件如果程序以 x64 编译进程无法加载 32 位 COM 组件。另外每次换机器部署目标机没有执行过 regsvr32 注册组件根本不存在。还有 Visual Studio 引用了互操作程序集但没有正确设置 Embed Interop Types运行时互操作解析失败。解决项目平台目标强制改 x86AnyCPU 在 64 位系统上会踩雷部署时用管理员权限执行 regsvr32 注册控件路径引用属性里把 Embed Interop Types 设为 False让互操作程序集作为独立 DLL 输出。如果现场不允许装 COM 组件老老实实切回 MC 协议方案不用在这条路上硬耗。6. 进阶收尾把轮询改成事件驱动这个 Demo 才算真正能上线6.1 用 Channel 替代 Timer 的轮询Timer 轮询在 30 分钟内的联调没问题但跑到一个通宵就容易暴露问题周期漂移、偶发重入、CPU 空转。稍微往上走一步用 System.Threading.Channel 做生产者和消费者模型。读线程往 Channel 里丢数据界面线程订阅后更新控件通讯频率和界面刷新频率完全解耦ChannelCncFrame _channel Channel.CreateBoundedCncFrame(16); async Task ReadLoopAsync() { while (!_ct.IsCancellationRequested) { var frame await Task.Run(() _client.ReadWords(_addr, 6)); await _channel.Writer.WriteAsync(frame, _ct); await Task.Delay(200, _ct); } } async Task UILoopAsync() { await foreach (var frame in _channel.Reader.ReadAllAsync(_ct)) { lblX.Text (frame[0] * 0.001m).ToString(F3); } }有界 Channel 容量设 16写入超过容量时生产端自动等待防止 CNC 响应快而界面刷新慢时数据无限堆积吃掉内存。6.2 断线重连与心跳让程序隔夜不跑飞上线之后遇到最多的不是首次连接失败而是半夜网络闪断后程序一直停在异常状态。断线重连不要无脑 while 循环间隔做指数退避从 1 秒起步翻倍到 30 秒封顶避免断线期间疯狂刷连接请求把 CNC 侧通讯模块打满。同时每隔几秒发一次读取请求当心跳连续失败三次触发重连逻辑。这个机制加上后基本上能做到第二天早上打开界面数据依然是新的不用人工干预。6.3 一个收尾习惯先验证协议再写界面这份 DEMO 我已经反复拆过好几轮最想嘱咐一句的是拿到源码后先把 CncClient 单独拎出来写一个控制台小工程只读坐标跑通了再往 WinForm 上拖控件。界面和通讯混在一起调试时你很难分清是界面事件写错了还是报文拼错了。从那以后我每次做设备通讯都强制走一遍这个步骤先把协议这层黑匣子验证透再考虑按钮和表格怎么摆。希望帮到你。本文还有配套的精品资源点击获取