恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C#与三菱PLC串口通讯实战:从协议解析到源码实现
首页
资讯中心
/
C#与三菱PLC串口通讯实战:从协议解析到源码实现
C#与三菱PLC串口通讯实战:从协议解析到源码实现
发布时间:2026/8/27 6:33:58
简介在工业自动化和上位机开发领域串口通讯一直是连接PC与PLC设备的经典方案。RS485、RS232等物理接口以其接线简单、抗干扰能力强、传输距离远等优势在产线数据采集和设备控制场景中广泛使用。而C#凭借高效的WinForm/WPF界面开发能力、完善的System.IO.Ports类库支持成为上位机通讯编程的主流选择。要实现上位机与三菱PLC的稳定通讯关键在于理解串口报文结构、软元件地址映射规则、校验和算法以及正确的帧解析逻辑。通过掌握这些底层原理开发者可以灵活应对FX系列、Q系列等不同型号的通讯需求完成寄存器读写、状态监控、远程控制等典型操作。本文以三菱PLC编程口协议为例详细拆解协议细节、C#封装方法和常见工程问题并结合实际项目经验给出调试与排查技巧帮助工程师快速构建可靠的上位机通讯系统。 做上位机开发的兄弟尤其是刚接触工控这块的应该都经历过这种场面设备厂家扔过来一台三菱PLC让你用C#写个串口通讯程序去读数据、写参数你打开Visual Studio拖了个SerialPort控件进来然后盯着属性面板发呆——波特率设多少校验位怎么选发什么命令PLC才会理我返回的数据怎么解析成十进制我当年在这个环节上卡了整整两天后来拿着串口调试助手一帧一帧抓报文才算把整个链路彻底跑通。这篇博客就把C#和三菱PLC串口通讯的完整方案摊开讲包括协议报文格式、地址映射规则、校验和计算、C#源码封装、硬件接线以及一堆我在现场踩过的坑。项目本身不复杂但细节多任何一个参数不对结果就是通讯超时或者数据完全错乱。适合刚入门C#上位机开发的工程师也适合需要在产线上快速实现PLC数据采集的老手做参考。1. 项目概述与通讯方案选型1.1 这个项目到底在做什么先用一句话说清楚这个项目是用C#写一个上位机程序通过串口RS232/RS422/RS485和一台三菱PLC建立通讯实现数据的读取和写入。读取的数据比如设备运行状态、产量计数、温度压力这些模拟量写入的比如启动停止指令、变频器频率设定值、报警复位信号。典型的应用场景有两类。第一类是数据采集把PLC里的D寄存器数据定时读上来存到数据库或者显示在WinForm界面做产线看板、MES系统对接。第二类是远程控制上位机下发指令让PLC执行动作比如码垛机的启停、步进电机的运行参数切换。很多项目是两者混合既采集又控制。这个场景下C#的优势很明显WinForm/WPF写界面快SerialPort类开箱即用LINQ处理批量数据方便后续对接数据库、WebService也顺手。这也是工控上位机圈子里C#占了大半江山的原因。1.2 为什么首选串口通讯有人会问现在三菱PLC基本都带网口走TCP/IP不是更香吗实际上串口在工控现场依然是刚需。首先老设备存量很大。FX系列很多老型号比如FX2N、FX3U标配只有编程口和扩展通讯口没有网口。你不想额外花几千块换模块就得老老实实走串口。其次串口接线简单、成本低。一根RS485屏蔽双绞线两个终端电阻最远的通讯距离能到1200米普通车间环境够用。网口虽然快但铺设网线、配置IP、处理交换机故障在恶劣电磁环境下并不省心。第三也是很多人忽略的一点串口通讯协议简单透明。用串口调试助手就能看到每一帧的原始字节排查问题非常直观。而TCP通讯虽然底层可靠但你要面对连接管理、重连机制、报文粘包拆包出问题的时候反而更头疼。所以这个项目选择串口通讯不是为了炫技就是冲着稳定、简单、通用这六个字去的。1.3 C#上位机通讯的几种常见路子确定了串口方案之后还需要确定协议层面怎么走。我整理了三菱PLC串口通讯的几种常见做法按照实现难度从低到高排一下。第一种走MODBUS-RTU协议。新型号FX3U、FX5U通过RS485扩展模块可以在PLC程序里用MODBUS指令或者直接把PLC侧设定成MODBUS从站。上位机这边用现成的C#库比如NModbus几行代码就能读写。省事但前提是PLC侧支持且需要预先配置。第二种使用三菱的MC协议4C帧/4E帧。这是三菱官方定义的通讯协议可以走串口也可以走以太网。格式规范地址直接写成“D100”“M50”这种字符串可读性好。但报文封装比较复杂帧头、子帧头、网络编号、站号一大堆字段实现起来代码量不小。第三种使用FX系列编程口协议。也就是PLC编程口上运行的那套协议很多原厂编程线就是基于它。报文结构相对简单命令只有一个字节地址用十六进制字符串校验和也是简单累加。我最推荐自己动手写这套协议因为逻辑清晰、逻辑链路短特别适合学习串口通讯的底层原理。本文的源码就是基于第三种方式实现的。它不需要PLC侧做任何特殊配置只要编程口能连上编程软件就能用同样的通讯参数和上位机交互。一台老FX系列PLC一根USB转串口线马上就能跑起来。方案协议复杂度PLC侧要求适合场景编程口协议低无特殊要求快速实现、学习调试MC协议中高部分型号支持官方标准化、跨型号MODBUS-RTU低需协议转换或专用指令已有MODBUS设备联调2. 三菱PLC串口通讯协议核心细节2.1 通讯帧基本结构明白了为什么选串口、选编程口协议之后接下来必须啃协议本身。这部分是整套源码的核心不理解帧结构代码写出来也是云里雾里。FX系列编程口协议的一帧请求数据格式是这样的字段长度说明STX1字节帧起始符固定0x02CMD2字节ASCII命令字读为“30”写为“31”起始地址4字节ASCII软元件地址的十六进制表示单元数2字节ASCII读取/写入的软元件个数ETX1字节帧结束符固定0x03校验和2字节ASCII累加和取低8位转十六进制注意命令字、地址、个数、校验和在串口上实际发送的都是ASCII字符不是二进制数值。比如要读D100这一个字起始地址是十六进制数0064发送时不是发一个0x00、0x64而是发两个ASCII字符‘0’、‘0’、‘6’、‘4’也就是字节序列0x30 0x30 0x36 0x34。这个细节是很多初学者的第一个坑。用SerialPort.Write发送字符串倒是没问题但如果你试图用byte数组去构建又拿字符串拼接的方式去算校验和很容易搞混。2.2 软元件地址与命令格式三菱PLC的软元件有很多种D是数据寄存器16位字M是内部继电器位X是输入继电器位Y是输出继电器位还有S状态继电器、R文件寄存器等。编程口协议里不同软元件的地址换算规则不同。D区最简单编号直接转十六进制。D100就换算成0064D1000换算成03E8。读取一个D区的字返回的数据是4个ASCII字符代表一个16位无符号数值比如返回“1388”就是十六进制0x1388换算成十进制是5000。M区和X/Y区是位元件处理的时候要格外小心。以FX3U为例M区的地址映射并非直接使用编号需要按手册给出的偏移量计算。比如M0到M255对应地址空间0x0000到0x0100的位M256到M511又在另一个区间。很多现成代码里不管位区只读写D区这是最省事也最安全的用法。如果项目必须操作M区我的建议是先下载对应型号的通讯手册把地址映射表啃明白再对照串口抓帧验证别凭感觉写。对于写入操作CMD变成“31”数据部分直接放在ETX之前。写入D100一个值为100报文就是STX 31 0064 01 00000064 ETX 校验和。这里写入的数据同样以ASCII十六进制表示每个字需要4个ASCII字符。2.3 校验和的计算原理校验和Check Sum是通讯协议里最容易被忽略又最容易出错的地方。FX编程口协议要求的累加和是把“从CMD到ETX为止的所有ASCII字符的ASCII码值”逐个累加取累加结果低8位再把这个8位数值转换成两位大写十六进制ASCII字符追加在帧尾。举个例子。假设请求是读D100命令字“30”起始地址“0064”单元数“01”。累加范围3(0x33) 0(0x30) 0(0x30) 0(0x30) 6(0x36) 4(0x34) 0(0x30) 1(0x31) ETX(0x03) 0x1B1取低8位是0xB1。0xB1转换成十六进制字符串就是B1发送时发送字符B和1也就是0x42和0x31。这里有个特别容易搞混的地方累加的是ASCII码值不是字符所代表的十六进制数本身。如果你用int.Parse去把每个字符转成0x30这种数字再去加结果就差之千里了。C#里直接对byte数组做加法就行因为字符在byte数组里存的就是ASCII码值。2.4 返回帧的解析规则PLC收到命令后会返回两种不同类型的帧。正常处理完成的响应帧以ACK0x06开头后面跟具体数据以ETX0x03结束再跟2字节校验和。请求有误或者通讯异常时以NAK0x15开头后面跟2字节错误码。以读取D100为例如果D100里存的是十进制5000也就是十六进制0x1388返回帧就是ACK 31 33 38 38 ETX 校验和。注意数据段是“31333838”这8个ASCII字符也就是“1388”需要把它解析成两个字节0x13、0x88再组成ushort。字节序方面三菱PLC字软元件高位在前所以0x13是高字节0x88是低字节拼起来就是0x1388。解析返回帧时强烈建议做三件事判断第一个字节是ACK还是NAK校验ETX位置重新计算一遍校验和。我在实际项目中就遇到过因为线被干扰导致数据帧出错的场景如果不做帧校验界面上的数值会瞬间跳成奇怪的大数还会往下游系统传脏数据。3. C#串口通讯源码实现3.1 串口参数配置与初始化协议手写方案确定之后就到了C#代码层面。用System.IO.Ports.SerialPort类配置其实很简单但参数必须和PLC侧完全一致。三菱FX系列编程口的默认通讯参数是波特率9600数据位7停止位1偶校验。这个组合很多程序员会觉得陌生因为平时用串口调试传感器基本都是8N1怎么到三菱就变7E1了这跟在电脑上用USB转串口线连PLC编程口时的历史习惯有关三菱编程口默认就是7位ASCII通讯所以必须按这个配置来。C#初始化的代码大致是这个样子using System.IO.Ports; var serialPort new SerialPort { PortName COM3, BaudRate 9600, DataBits 7, Parity Parity.Even, StopBits StopBits.One, ReadTimeout 1000, WriteTimeout 1000 }; serialPort.Open();注意要是在PLC侧的D8120里改过通讯格式比如把D8120配成了8位数据无校验那上位机这边的DataBits、Parity也必须跟着改。D8120的设定和三菱的通讯格式字是绑定的不匹配的话PLC永远不给你回包。初始化这块还有一个容易被忽略的点打开串口前先检查COM口号是否存在打开失败要捕获UnauthorizedAccessException和IOException。现场电脑USB口供电不稳USB转串口线驱动异常很容易出现端口打不开的情况。3.2 通讯帧构建代码理解了协议格式代码实现就是机械工作了。我把构建请求帧写成一个独立方法便于复用。/// summary /// 构建读取请求帧 /// /summary /// param nameaddress起始地址如D100传100/param /// param namecount读取单元数/param /// returns待发送的字节数组/returns public static byte[] BuildReadFrame(int address, int count) { string cmd 30; // 读命令 string addr address.ToString(X4); // 4位十六进制地址 string len count.ToString(X2); // 2位十六进制个数 string body cmd addr len; byte[] frame new byte[body.Length 4]; frame[0] 0x02; // STX for (int i 0; i body.Length; i) frame[i 1] (byte)body[i]; frame[body.Length 1] 0x03; // ETX int sum 0; for (int i 1; i body.Length 1; i) sum frame[i]; string checksum (sum 0xFF).ToString(X2); frame[body.Length 2] (byte)checksum[0]; frame[body.Length 3] (byte)checksum[1]; return frame; }构建写入帧的方法同理只多了个数据段。注意写入时数据个数和实际写入数据的长度要对得上PLC校验到长度不匹配会直接回NAK。/// summary /// 构建写入请求帧value为写入的ushort值 /// /summary public static byte[] BuildWriteFrame(int address, ushort value) { string cmd 31; // 写命令 string addr address.ToString(X4); string len 01; string data value.ToString(X4); // 写入数据4位十六进制 string body cmd addr len data; byte[] frame new byte[body.Length 4]; frame[0] 0x02; for (int i 0; i body.Length; i) frame[i 1] (byte)body[i]; frame[body.Length 1] 0x03; int sum 0; for (int i 1; i body.Length 1; i) sum frame[i]; string checksum (sum 0xFF).ToString(X2); frame[body.Length 2] (byte)checksum[0]; frame[body.Length 3] (byte)checksum[1]; return frame; }3.3 数据读取与写入实现帧构建好之后读写核心就是发送、接收、校验、解析四步。这一步很多人会踩一个坑发送完立刻读响应结果读到的是上一次残留的数据或者还没等PLC反应完就超时了。我的做法是每次发送前先清空输入缓冲区再发送然后用ReadByte循环读取直到收到ETX或者超时。这样能有效避免脏数据干扰。public ushort[] ReadD(int startAddress, int count) { byte[] request BuildReadFrame(startAddress, count); _serialPort.DiscardInBuffer(); _serialPort.Write(request, 0, request.Length); // 读取返回帧 Listbyte response new Listbyte(); int timeout Environment.TickCount 1000; while (Environment.TickCount timeout) { try { int b _serialPort.ReadByte(); if (b 0) break; response.Add((byte)b); if (response.Count 2 response[response.Count - 1] 0x03) break; } catch { break; } } if (response.Count 5) throw new TimeoutException(PLC响应超时或响应帧不完整); if (response[0] 0x15) throw new Exception(PLC返回错误 BitConverter.ToString(response.ToArray())); // 解析数据区 int dataStart 1; int dataLen response.Count - 4; // 去掉ACK、ETX和2字节校验和 ushort[] result new ushort[dataLen / 4]; for (int i 0; i result.Length; i) { string hexStr Encoding.ASCII.GetString(response.ToArray(), dataStart i * 4, 4); result[i] Convert.ToUInt16(hexStr, 16); } return result; }解析写入响应就简单多了。正常写入后PLC只返回ACK 校验和不需要解析数据区。判断第一个字节是不是0x06就行。3.4 线程安全与轮询策略串口通讯是半双工模式一个请求对应一个响应顺序不能乱。如果程序里有多个地方同时调用读写方法比如界面刷新线程和后台采集线程同时发命令串口上就会出现“请求A发出还没收到响应请求B又发出去”的情况。PLC收到的报文乱掉返回的自然也是错误。解决办法很简单给读写操作加一把锁。最简单的做法是lock一个私有对象。private readonly object _commLock new object(); public ushort[] ReadDWithLock(int startAddress, int count) { lock (_commLock) { return ReadD(startAddress, count); } }在WinForm里定时刷新PLC数据建议用System.Windows.Forms.Timer或者System.Threading.Timer轮询周期一般设置在200到500毫秒。太短了PLC和串口都忙不过来太长了数据实时性又不够。具体视PLC扫描周期和现场工艺要求来定。我习惯在后台线程里做轮询用CancellationToken控制退出把数据刷到界面时再通过BeginInvoke或者Task回到UI线程。这样做的好处是界面不会卡顿同时所有串口操作都被lock保护不会并发冲突。4. 实操过程与关键环节实现4.1 硬件连接与PLC侧配置软件代码有了就必须把硬件链路打通。拿最常见的FX3U系列来说有编程口和RS485扩展板两种接法。用编程口接线相对最简单。FX3U上有一个圆形的编程口走的是RS422电气标准买一条USB转RS422的编程线比如三菱SC09-FX或者国产兼容线电脑上装好驱动就能识别成一个COM口。接线就是一头插PLC编程口一头插电脑USB口注意这种线有些是需要外接电源的买的时候问清楚。用RS485通信板的接法更贴近工业现场。FX3U-485-BD扩展板上有四个接线端子SDA、SDB、RDA、RDB。SDA和SDB是一对发送差分信号RDA和RDB是一对接收差分信号接电脑侧的USB转RS485时需要把485转换器的A接到SDA和RDAB接到SDB和RDB。这里没有统一的颜色标准很多说明书也不写清楚只能靠万用表量电阻或者看模块丝印判断。现场如果通讯没反应十有八九是这两对线接反了。PLC侧要确认两件事。一是编程口模式下不需要额外配置但RS485模式下要检查D8120寄存器的通讯格式设置。D8120是十六位寄存器每一位代表不同的协议参数在没有特殊指令的情况下D8120的默认值要匹配你实际使用的波特率和校验。二是PLC程序里有没有使用RS/RS2指令如果占了485口上位机再发消息就会冲突。遇到这种情况要么改PLC程序要么换用编程口通道。4.2 用串口助手验证通讯写C#代码之前强烈建议先用串口调试助手把通讯链路验证一遍。这一步能帮你确认硬件接线、串口参数、协议报文是否都正确也能在源码调试时提供对照。打开串口助手选择对应的COM口波特率9600、数据位7、停止位1、偶校验打开串口。然后手动发送读D100的请求帧用十六进制格式发送02 30 30 30 36 34 30 31 03 42 31如果接线和参数都对PLC会返回类似06 31 33 38 38 03 xx xx如果返回的是15开头说明报文格式或者地址有问题这时候把报文拿过来和手册逐字节对照。如果什么都不返回优先排查线序和串口参数。这个验证过程看着麻烦实际上能省掉后面大量调试时间。我每次做新项目都会先花五分钟做这个动作把链路验证完了再写C#代码就非常踏实。4.3 完成一次真实读写演示用代码演示一次从读取到写入的完整动作方便直接套用到自己的项目里。假设现场有三菱FX3U工艺数据存放在D100到D109这10个寄存器中用定时器每秒刷新一次控制命令放在D200写入500表示“运行”写入0表示“停止”。// 打开串口 PlcSerialClient plc new PlcSerialClient(COM3, 9600); plc.Open(); // 每隔500ms读取一次D100开始的10个字 var timer new System.Threading.Timer(_ { try { ushort[] values plc.ReadD(100, 10); Console.WriteLine($当前产量: {values[0]}, 温度: {values[1] / 10.0:F1}); } catch (Exception ex) { Console.WriteLine($采集异常: {ex.Message}); } }, null, 0, 500); // 在某个按钮事件里写入D200 500控制设备启动 plc.WriteD(200, 500);这段代码是典型的上位机采集控制骨架。实际项目中你还需要把采集到的数据塞进DataGridView或者曲线控件写入操作改为通过按钮事件触发并在界面关闭时释放串口资源。写入的细节也需要再说一遍WriteD传入的是ushort值内部会转成十六进制字符串。如果你要写的是负数比如D寄存器存的是有符号数-100对应的16位二进制是0xFF9C这时候Convert.ToUInt16(FF9C,16)是可以转换的但C#的checked环境可能溢出需要你自己处理一下符号转换逻辑。5. 常见问题与排查技巧实录5.1 通讯失败排查清单串口通讯失败先不要怀疑代码从物理层开始一层层查。我在现场帮人排查的问题大概80%都是硬件和参数引起的真正代码本身出问题的反而少。查线序是第一步。RS485的A/B反接PLC根本收不到有效数据。这个问题最典型的特征是串口能打开发送有波形但PLC没有任何响应。用万用表量一下485转换器输出端和PLC端子的对应关系就能确认。查串口参数是第二步。很多同事习惯性用9600、8、N、1去连三菱结果只有7E1能通。反过来也有有人配了PLC的D8120改成8N1但上位机还按7E1发一样不通。通讯参数不一致时PLC收到的是乱七八糟的电平组合大概率不回包或者回错数据。查端口占用是第三步。如果你开着GX Works正连着PLC上位机再去打开同一个串口Windows会提示COM口被占用或者虽然打开成功但通讯完全无响应。因为三菱编程软件独占串口。我是习惯调试完PLC程序先把GX Works断开连接再跑上位机。5.2 数据错乱的坑接上PLC了返回值也有但数值不对这一块的问题往往出在解析上。最常见的是十六进制和十进制转换错误。比如PLC返回“1388”Convert.ToInt32(1388, 16)出来的才是5000如果你直接Convert.ToInt32(1388)得到的就是1388差了个数量级。这个问题在新手代码里出现频率极高看一眼解析代码就能发现。第二个坑是字节序问题。读32位浮点数时三菱PLC用两个字保存一个浮点数低地址放的是浮点数的低位字高地址放高位字。而C#的BitConverter.ToSingle默认是小端序直接把4个字节丢进去会得到完全错误的数值。正确做法是先把两个ushort按PLC顺序组合成4字节数组再调用BitConverter.ToSingle。第三个坑是数据类型宽度。D寄存器是16位最大只能表示65535。如果PLC程序里用的是32位计数器你读一个字肯定不够必须把连续的两个字拼起来。拼的时候注意高位字和低位字的顺序搞反了数值会完全对不上。现象常见原因处理方向无任何响应线序错误、参数不符检查接线与串口参数返回NAK报文错误、地址越界对照手册检查报文数值不对进制转换、字节序错误用调试助手对照原始帧偶发超时干扰、线程冲突加校验和、加锁、优化轮询5.3 线程与性能问题串口通讯项目上了规模以后最容易出问题的反而是线程调度。比如我做的一个设备数据采集项目后台上位机有十几个窗口都要读PLC数据大家都在调ReadD串口上命令乱成一锅粥。这时候光靠lock还不够更合理的做法是做一个统一的数据采集服务。后台单独开一个线程用队列管理读写请求或者干脆把所有读操作合并成一批一次读完所有D区地址再在内存里分发。比如要在界面上显示D100、D200、D300分三次读不如拼一次“读D100开始的201个字”省下的时间非常可观。虽然会多读一些不用的D区但串口通讯快很多。轮询周期也要注意合理性。PLC的串口处理速度远没有网口快一次往返基本在几十毫秒到上百毫秒。如果轮询周期设定为50毫秒很多请求会积压最终导致通讯越来越慢。我一般的做法是500毫秒起步需要实时性高的场景压到200毫秒再低就不推荐了。5.4 排查技巧速查表最后整理一份我在实际项目中沉淀下来的排查顺序可以复制到自己的维护文档里。第一用串口调试助手做“桥梁”。不经过C#代码直接发一帧读指令确认PLC有没有响应。这一步能精准圈出问题在硬件层、协议层还是代码层。第二仔细读原始返回帧。不要只看界面上最终数值要把串口收到的字节逐个打出来对照ACK、数据区、ETX、校验和。我遇到过一个怪问题返回帧中间莫名多了两个0x20空格字符一看就是通讯干扰后来排查发现是屏蔽层接地不良导致的。第三善用异常捕获。C#串口读写会抛很多奇怪的异常IOException、TimeoutException、InvalidOperationException都要单独捕获把异常信息记录到日志文件里。我在现场维护的软件有一个专门的log文件夹每个通讯异常都会记录时间、发送帧、接收帧、异常类型。这样出问题的时候翻日志就好而不是蹲在设备前面等复现。第四做通讯状态自检。PLC在运行中偶尔会进入“忙碌”状态或者程序被修改重新下载后串口连接中断。上位机最好能自动重连。我的做法是连续三次读写超时后主动关掉串口再重新打开重试三次如果还不行就在界面上弹出红色告警。第五注意USB转串口的兼容性。工控机上用的USB转串口线质量参差不齐一些芯片在7位数据、偶校验模式下会不稳定。我做过一个项目用某国产芯片的转接线怎么都通讯不稳定换了一条FTDI芯片的线问题立刻消失。做项目时别在这上面省钱现场出问题排查的时间成本远超一根线的差价。第六软件退出时释放串口。C#的SerialPort在程序异常退出时可能不会立刻释放端口重新打开时就会报“端口被占用”。稳妥的做法是重写OnFormClosing事件或者用try-finally保证Close方法一定执行。我习惯把串口生命周期管理封装在IDisposable的类里配合using块使用逻辑干净又可靠。最后说一点我在PLC通讯项目里最深的体会串口通讯看似是个很“老”的技术但它稳定、直接、可控。只要你把协议细节吃透把通讯链路每一层验证过用C#做上位机和三菱PLC通讯真的可以做到开箱即用。希望这篇源码拆解能帮你少走一些弯路至少在这个环节上不需要再像我当年那样拿着串口调试助手一帧一帧对着手册熬到半夜。本文还有配套的精品资源点击获取