C#三菱PLC串口通讯实战:FX3U/FX2N帧解析与上位机开发 📅 发布时间:2026/9/13 14:34:53 👁 浏览次数: 简介一套面向工业自动化开发者的C#源码方案专门用于实现上位机与三菱FX3U、FX2N系列PLC的数据通讯覆盖RS-485串行通讯、以太网接入及MODBUS RTU/TCP协议封装适合从事设备监控、远程控制与数据采集的工程师学习复用。压缩包共72个文件以C#窗体与核心通讯类源码为主同时包含可运行调试程序、项目配置文件、说明文档和程序集资源等整体仅199KB结构紧凑可直接用Visual Studio打开工程查看通讯模块与界面逻辑。目前已有1012人学习下载。资料围绕串口参数配置、MODBUS报文构造、寄存器读写、异步防卡顿、异常捕获与重试机制等关键点展开代码中保留了调试思路与注释方便在此基础上快速移植到其他PLC型号或二次开发。无论是刚接触上位机与PLC通讯的初学者还是需要快速搭建原型系统的工程师都能从中获得直接可用的通讯框架与排错参考。1. 为什么我建议直接拆这套源码而不是从头抓协议第一次拿到这套 C#三菱PLC通讯源代码的时候我直接翻cFxplc.cs因为 demo 界面只是入口。这套代码对应 FX3U、FX2N 系列 PLC 的串口通讯把帧拼接、SUM 校验、寄存器读写和响应异常都集中在一个类里。FX3U 和 FX2N 至今仍是小型产线控制的高频选择上位机要采集 D 寄存器、M 继电器、X/Y 点状态最稳定的路径就是 RS-485 走三菱专用协议。与只教“如何点一个按钮读取 D100”的教程不同它值得做二次封装。适合正在做设备数据采集、远程监控和故障诊断上位机的 C# 开发人员也适合已经会 Modbus 但想切换到原生 FX 协议的人。最后你会发现多数通讯问题不在发送命令而在响应解析和地址换算这两层。2. FX3U/FX2N 串口通讯的物理层和帧格式先定协议再写代码工业现场两个看似相同的 FX3U通讯参数也可能不同原因在 PLC 的 D8120 寄存器里。三菱 FX 系列的编程口默认采用 RS-422/RS-485 半双工上位机通常通过 USB 转 RS-485 模块连接。虽然 C# 的System.IO.Ports.SerialPort不关心物理层但 RS-485 的方向切换会影响写命令后多久能读响应很多初次实现的人在这里丢弃了第一个字节。2.1 RS-485 半双工与 C# SerialPort 参数匹配FX3U 本体自带一个编程口很多项目会外扩 RS-485 通讯板FX2N 同样可以通过扩展板或编程口转接模块接入。半双工链路同一时间只能有一方发送所以 USB 转 RS-485 模块如果支持自动收发切换软件侧不需要额外控制。如果模块要求手动切换就需要在写入数据前后控制RtsEnable或DtrEnable这也是源码中Sleep.cs常见的用途之一——等待线路稳定后再切换方向。以下是初始化串口的推荐写法参数按三菱 FX 系列编程口最常见的缺省值设置using System.IO.Ports; var port new SerialPort(COM3); port.BaudRate 9600; port.DataBits 7; port.Parity Parity.Even; port.StopBits StopBits.One; port.Handshake Handshake.None; port.ReadTimeout 500; port.WriteTimeout 500; port.Open();参数里最容易错的是DataBits 7和Parity.Even。三菱 FX 系列编程口的缺省帧格式就是 7 位数据、偶校验、1 位停止位这与大部分 Modbus RTU 的 8N1 不同。若 PLC 侧被第三方组态软件改过可以用 GX Works2 打开 PLC 参数在 PLC 系统设置的“串口通讯设置”里查看 D8120 对应的配置再同步修改上位机。ReadTimeout和WriteTimeout也一定要设否则拔线后ReadByte会一直卡住界面线程。提示三菱 FX 编程口不是通用的 8N1先用默认 7E1 跑通再根据 D8120 调整。这里还需要强调一点RS-485 的 A/B 线接反发送命令时偶尔能收到数据但内容错乱或者偶尔超时。排查时先用一条短网线做回环测试再进入真实设备链路不要一上来就怀疑寄存器地址写错。2.2 三菱 FX 协议帧结构STX、CMD、EXT、SUM确定了物理层之后最关键的是协议帧。FX 系列专用串口协议和 Modbus 最大的区别是它用 ASCII 表示大部分字段并且用 SUM 累加校验而不是 CRC16。一个典型的读请求帧由以下字段组成| 字段 | 长度 | 内容 | | STX | 1 字节 | 0x02表示数据帧开始 | | CMD | 1 字节 ASCII | 0 读1 写 | | DEVICEADDRESS | 5 字节 ASCII | 例如D0064D100 的十六进制地址 | | LENGTH | 2 字节 ASCII | 十六进制点数如02表示读取两个字 | | EXT | 1 字节 | 0x03表示数据区结束 | | SUM | 2 字节 ASCII | 从 CMD 到 EXT 所有字节累加和的低 8 位再转十六进制大写 |这个帧结构是从cFxplc.cs中提炼出来的和 MC 协议 A 兼容帧的串口实现比较接近。要注意源码里 D100 在帧中不是直接出现D0100而是把寄存器编号换算成十六进制后的0064这经常让第一次读源码的人感到困惑。换算规则见 2.3但帧结构本身只有上面 6 类字段理解了它后面封装类就不难了。2.3 D/M/X/Y 地址换算表与辅助函数FX3U 和 FX2N 对软元件的命令地址做了分区。D 和 M 是十进制编号在帧内转成 4 位十六进制 ASCIIX/Y 是八进制编号本质上是位地址读写时很容易因为进制混用而报 NAK。下面这张表是位/字元件的最常用映射| 软元件 | 类型 | 用户习惯地址 | 帧内地址文本 | | D100 | 字 | D100 | 0064 | | D255 | 字 | D255 | 00FF | | M100 | 位 | M100 | 0064 | | X/Y | 位 | 按八进制编号 | 需自行换算十六进制文本 |以 D 寄存器为例换算函数可以写成这样public static string ToHexAddress(int address) { return (address 0xFFFF).ToString(X4); }这个函数的思路是把用户写的 D100 先换算成 0x0064再按 ASCII 字符串拼进帧。M 继电器同样按 16 位编码。X/Y 之所以特别是因为 FX 系列的 I/O 点编号逢八进一例如 X7 后面是 X10 而不是 X8所以不要直接Convert.ToString(input, 16)处理物理输入点。在实际工程中如果上位机只需要读 X/Y 状态我更建议在 PLC 程序里把 X/Y 点集中映射到一组 M 或 D 寄存器再让上位机只走固定地址读取。这样既能避开八进制换算坑又方便在 PLC 侧做消抖和报警逻辑。3. 把 cFxplc.cs 中的读写命令封装成可复用类源码包里最重要的文件是cFxplc.cs其余frmMain.cs只负责界面触发Sleep.cs负责延时。我建议把原始类重构成FxPlcClient对外只暴露Open、Close、ReadDeviceBlock、WriteDeviceBlock四个方法。这样无论是 WinForms、WPF 还是控制台程序都可以引用同一个类。3.1 从源码包里抽出的核心类职责划分FxPlcClient的基本结构如下public class FxPlcClient : IDisposable { private readonly SerialPort _port; private readonly object _lock new object(); public bool IsOpen _port ! null _port.IsOpen; public FxPlcClient(string portName, int baudRate 9600) { _port new SerialPort(portName, baudRate); _port.DataBits 7; _port.Parity Parity.Even; _port.StopBits StopBits.One; _port.ReadTimeout 500; _port.WriteTimeout 500; } public void Open() _port.Open(); public void Close() _port.Close(); public void Dispose() _port?.Dispose(); }这里把串口参数放到构造函数里是因为同一台电脑可能连接多台 PLC波特率、校验位必须独立配置。_lock对象用来保证读写命令串行化避免多个采集线程同时对同一个串口发送请求造成响应错位。3.2 读命令帧构造与响应解析读命令的帧构造是整段代码的核心。下面这段代码保持了源码中经典的STX CMD 地址 长度 EXT SUM顺序private byte[] BuildReadCommand(string device, string addressHex, int length) { var lengthHex length.ToString(X2); var payload 0 device addressHex lengthHex; var frame new Listbyte { 0x02 }; // STX frame.AddRange(Encoding.ASCII.GetBytes(payload)); frame.Add(0x03); // EXT int sum 0; for (int i 1; i frame.Count; i) sum frame[i]; frame.AddRange(Encoding.ASCII.GetBytes((sum 0xFF).ToString(X2))); return frame.ToArray(); }payload中的首字符0就是 CMD 的读指令随后是设备代码、地址和长度。SUM 从 STX 后面的 CMD 开始累加到 EXT 为止不包含 STX 本身。sum 0xFF表示累加和的低 8 位再转成两位大写十六进制。很多改版代码在sum上忘记做位与导致校验和总是错这是最常见的移植错误。响应的读取不能只ReadByte一次。串口是流式数据响应可能分多个包到达所以解析要完整读到一个0x03为止并校验 SUMprivate byte[] ReceiveFrame() { var head _port.ReadByte(); if (head 0x15) throw new PlcNakException(PLC 返回 NAK); if (head ! 0x02) throw new PlcFrameException($帧头错误: 0x{head:X2}); var body new Listbyte { (byte)head }; while (true) { var b _port.ReadByte(); body.Add((byte)b); if (b 0x03) break; } var sumBytes new byte[2]; _port.Read(sumBytes, 0, 2); int sumExpected Convert.ToInt32(Encoding.ASCII.GetString(sumBytes), 16); int sum 0; for (int i 1; i body.Count; i) sum body[i]; if ((sum 0xFF) ! sumExpected) throw new PlcCheckSumException($SUM 校验失败期望 {sumExpected:X2}); return body.ToArray(); }读响应时body中最后一个是 EXT数据区在 STX 和 EXT 之间。如果 PLC 返回 0x15 NAK说明请求帧本身有问题不要在超时逻辑里反复刷。ReadByte在ReadTimeout到达时抛出TimeoutException上层统一捕获即可。3.3 写命令与批量读写接口写命令和读命令几乎一样只是 CMD 变为1并且要在数据区放入待写入的 ASCII 十六进制值private byte[] BuildWriteCommand(string device, string addressHex, int length, string valueHex) { var lengthHex length.ToString(X2); var payload 1 device addressHex lengthHex valueHex; var frame new Listbyte { 0x02 }; frame.AddRange(Encoding.ASCII.GetBytes(payload)); frame.Add(0x03); int sum 0; for (int i 1; i frame.Count; i) sum frame[i]; frame.AddRange(Encoding.ASCII.GetBytes((sum 0xFF).ToString(X2))); return frame.ToArray(); }写命令成功后PLC 通常只回一个 ACK0x06不再带数据区。因此写入路径和读取路径不要共用同一个响应解析函数否则会把 0x06 当成非0x02帧头抛异常。我一般这样分离private void ReceiveAck() { var head _port.ReadByte(); if (head 0x06) return; if (head 0x15) throw new PlcNakException(写入被 PLC 拒绝); throw new PlcFrameException($响应不是 ACK: 0x{head:X2}); } private static string BuildDeviceAddress(string device, int address) { return device ToHexAddress(address); }批量读写接口是在帧构造之上再封一层public string ReadDeviceBlock(string device, int address, int length) { lock (_lock) { EnsureOpen(); var cmd BuildReadCommand(device, ToHexAddress(address), length); _port.Write(cmd, 0, cmd.Length); var frame ReceiveFrame(); var dataLen length * 4; if (frame.Length ! dataLen 2) throw new PlcFrameException(响应数据长度不符合预期); return Encoding.ASCII.GetString(frame, 1, dataLen); } } public void WriteDeviceBlock(string device, int address, int length, string valueHex) { lock (_lock) { EnsureOpen(); var cmd BuildWriteCommand(device, ToHexAddress(address), length, valueHex); _port.Write(cmd, 0, cmd.Length); ReceiveAck(); } }这里length * 4是因为每个字在 ASCII 模式下占 4 个十六进制字符。判断写成dataLen 2而不是dataLen 3是因为frame里只有 STX、数据、EXTSUM 字段已经在ReceiveFrame里被单独读出没有放进返回值。这个细节很容易写错读正常帧时多判断一个字节就会误报长度异常。3.4 通讯异常与 NAK 的区分处理把异常分类而不是所有错误都弹MessageBox是源码另一个可以借鉴的点public class PlcException : Exception { } public class PlcNakException : PlcException { } public class PlcCheckSumException : PlcException { } public class PlcFrameException : PlcException { }上层调用时只需要捕获PlcException并根据异常类型分别做重试、记录日志或暂停轮询。如果是 NAK重试大概率还是 NAK应该直接告警如果是超时可能是 RS-485 方向切换或物理链路不稳定可以做有限次重试。把这两类分开能减少现场故障排查时间。4. 上位机轮询用 async/await 替代 Sleep 解决采集卡顿源码包里Sleep.cs主要用来做延时防抖但实际现场轮询不能靠它占住界面线程。很多 c# 上位机项目里循环采集数据时 UI 刷新卡顿是最常见的问题根源就是while(true)里直接Thread.Sleep(200)。4.1 c# 循环数据采集和 UI 刷新卡顿的原因Thread.Sleep会阻塞当前线程。如果调用发生在 UI 线程窗口拖拽、按钮点击全部停止响应如果调用发生在 ThreadPool 线程虽然 UI 不卡但线程池被睡满后会影响其他任务。更麻烦的是Thread.Sleep无法通过标志位立刻停止它必须等待 sleep 结束才检查CancellationToken这在停止采集时会白白等待几百毫秒。用async/await后Task.Delay不会阻塞任何线程而是在超时后通过回调恢复执行。配合CancellationToken停止采集时能立刻中断延时不需要等 sleep 结束。另一种更彻底的做法是把串口包装成流用BaseStream.ReadAsync做全异步 IO但改动量较大暂时先保留同步读写方法。4.2 用 CancellationToken 自动停止轮询下面这个轮询方法可以作为 WinForms 或 WPF 的主循环private async Task PollingLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { // 读 D100 开始的 10 个字 var values await Task.Run(() _fx.ReadDeviceBlock(D, 100, 10), token); textBox1.Invoke(new Action(() { textBox1.Text values; })); } catch (OperationCanceledException) { break; } catch (PlcNakException ex) { statusLabel.Invoke(new Action(() statusLabel.Text ex.Message)); } catch (TimeoutException) { // 超时可重试连续 3 次后置红色状态 } try { await Task.Delay(200, token); } catch (OperationCanceledException) { break; } } }Task.Run把同步的串口读写放到线程池避免阻塞 UIInvoke把结果回发到 UI 线程Task.Delay(200, token)让轮询间隔可控。注意token从CancellationTokenSource传入关闭窗口时调用Cancel即可线程会很快退出。如果轮询间隔需要严格保持 200ms不要手动记录DateTime.Now去减直接用Task.Delay精度足够。4.3 断线重连与超时分级现场通讯不可能一直稳定。轮询中的TimeoutException应该做分级重试而不是无限重连。一个简单策略是连续 3 次超时后尝试关闭串口并重新Open如果重连失败则进入“设备离线”状态并递减采集频率。这样既不会把日志刷爆也不会把 FX3U 通讯口一直占用。需要注意三菱 FX3U/FX2N 的编程口本身不是为高频轮询设计的常见周期 100~500ms 已经足够。如果 50ms 轮询一次PLC 的响应时间加上 RS-485 半双工切换可能出现随机超时那不是代码问题而是采集周期太短。先用 500ms 跑通再慢慢调小这是比较稳妥的调优路径。5. 验收通讯串口抓帧、NAK 响应定位与 MODBUS 迁移代码写完不能直接贴到现场。我通常先做一次“空跑串口验证”再用真实 PLC 做地址检查最后才接入 UI。5.1 用串口抓帧工具验证帧和 SUM 校验连接好 PLC 后用 AccessPort 或 Serial Port Monitor 打开上位机占用的 COM 口执行一次ReadDeviceBlock(D, 100, 2)。正常抓到的帧应该是02 30 44 30 30 36 34 30 32 03 xx xx。其中44是字符 D30 30 36 34是字符串006430 32是长度02最后的xx xx是 SUM 计算结果。如果抓到的地址与预期不同问题一定出在ToHexAddress而不是 PLC。手动验算 SUM 也有一个小技巧把从 CMD 开始的十六进制字节累加后只取低 8 位转成大写十六进制。比如0x30 0x44 ... 0x03 0xFF进制转换时容易漏掉 0xFF导致显示值等于校验值的四倍。5.2 NAK 响应定位地址越界、参数不匹配、协议不匹配PLC 返回0x15时代表请求帧不合法。以下是现场最常见的三种情况| 现象 | 可能原因 | 处理方式 | | 读 D 寄存器返回 NAK | 地址超过 PLC 实际范围或写入只读区 | 核对 FX2N/FX3U 的软元件范围 | | 读写 M/X/Y 返回 NAK | 位元件按字读写 | 换成位读写命令或改用 D 寄存器存储 | | 请求无响应 | 串口参数和 D8120 不一致 | 同步 7E1/8N1 和波特率 |“按字读写位元件”是我见过最多的问题。FX 的 X/Y 本身是位软元件如果用读字设备的方式到某个扩展地址PLC 不会给出有意义的数值只会回 NAK。所以项目里要单独保留位读写函数或者像 2.3 说的在 PLC 程序里把 X/Y 映射到 M。5.3 如果需要迁移到 MODBUS RTU 的参考写法如果现场 PLC 程序已经启用了 MODBUS 从站协议也可以直接换 C# 社区常用的 NModbus4using Modbus.Device; using var port new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One); port.Open(); var master ModbusSerialMaster.CreateRtu(port); ushort[] values master.ReadHoldingRegisters(1, 0, 10);MODBUS RTU 在物理层同样是 485但帧格式是二进制且用 CRC16串口参数通常用 8N1。迁移时不要直接把地址和长度原样搬过来需要参考三菱 FX 系列的 MODBUS 地址映射表把D100映射到保持寄存器地址再设置对应的从站号。写寄存器时使用WriteMultipleRegisters从这里继续扩展即可。本文还有配套的精品资源点击获取