
简介面向C#开发人员与工业自动化工程师的串口通信示例工程演示如何基于System.IO.Ports与三菱PLC进行数据交换。资源覆盖单个bool、批量bool、Word、Dword的读写并包含心跳信号检测与多线程处理逻辑工程中MitsubshiPLC.cs与SerialPortClass.cs封装了通信核心frmDemo与frmMain提供完整界面演示方便直接运行与二次改造。整个压缩包共49个文件以C#源码12个cs、工程配置3个config、2个csproj、可执行程序3个exe、3个dll及调试文件5个pdb为主总大小仅184KB结构精简。目前已有2155人学习下载。通过阅读工程可掌握串口参数配置、二进制数据解析、连接状态监控及多线程并发读写的实战写法对快速搭建PLC上位机通信模块有直接参考价值。1. 用C#串口读写三菱PLC先说清楚这条链路到底能干什么做设备上位机的人迟早要碰一次“用C#串口读写三菱PLC”这类需求小产线上有一台用了七八年的FX3U没有网口没有触摸屏上位机接口唯一能掏数据的就是那个圆头编程口。省一台上位机网关的钱用一条USB转RS422线把PLC和工控机串起来C#直接读M、X、Y里的bool和D、R里的Word/Dword这就是整套方案的日常。它解决的是老设备数据采集、按钮状态监控、参数临时改写这三类问题适合搞设备集成、MES数据采集或者售后调试的工程师。这套链路不复杂但坑不少——地址编码、字节序、串口占用、心跳超时每个都能让调试卡上一整天。2. 三菱PLC串口通讯协议详解报文帧、软元件编码与异或校验2.1 先说物理层圆头编程口不是RS232接线错了全白搭三菱FX系列FX3U、FX3G、FX3S的编程口是Mini-DIN 8针圆头物理电气层走的是RS422不是电脑串口那种RS232。所以直接拿一根USB转232线插上去是没反应的你需要的是USB转RS422线或者用三菱原厂SC09编程电缆那头是RS232这头是圆头RS422。我一般直接买国内做的USB转RS422圆头线便宜驱动用FT232或CH343都挺稳。连接之前要把串口参数确认一遍波特率9600数据位8无校验停止位1。这是FX编程口协议的默认参数绝大多数国产兼容线和原厂线都认这个配置。如果你接的是FX3U-485-BD这种485扩展板走的就是MC协议或者Modbus协议了报文格式跟我下面讲的编程口协议不是一回事。标题既然说的是串口读写三菱PLC我默认你接的是编程口按下述报文来。这条线还有一个要注意的点USB转串口线在电脑上会虚拟成一个COM口但如果你的USB转RS422线用的是CP2102之类芯片部分型号在打开串口瞬间DTR/RTS电平变化可能把PLC的编程口状态顶乱。后面讲到SerialPort参数时我会单独说DtrEnable和RtsEnable怎么设。2.2 报文帧结构80开头命令码、软元件、地址、点数、异或校验编程口协议的请求帧格式比较固定我用C#的字节数组打出来就是下面这样// 请求帧组成以读D100起2个字为例 byte[] frame new byte[] { 0x80, // 帧起始符固定0x80 0x01, // 命令码0x01读0x02写 0xA8, // 软元件代码0xA8D寄存器 0x64, 0x00, 0x00, 0x00, // 起始地址D1000x64低字节在前 0x02, 0x00, // 读取点数2低字节在前 0x00, // 结束符固定0x00 // 校验字节从帧起始符到结束符全部异或放在帧尾 }; byte checksum 0; for (int i 0; i frame.Length; i) { checksum ^ frame[i]; // 把所有字节挨个异或 } // checksum 就是校验字节追加到 frame 末尾发送校验算法就是最简单的异或和把帧里所有字节从0x80开始一直异或到结束符0x00取结果的低8位追加在帧尾。注意是异或不是累加算错的话PLC直接不回帧而且不会给你报错表现就是发出去石沉大海。我之前调试时用串口调试助手抓包对比半天没发现报文哪里错最后一行行手算异或才发现是校验算成了累加。PLC的响应帧分两种情况。读操作的响应是0x80 0x01 数据 0x00 校验数据区的长度由你要读的点数决定——读位时每个位占1字节读字时每个字占2字节。写操作的响应更简单PLC收到合法的写请求后回一个0x80 0x02 0x00 校验三字节加校验表示写成功了。如果PLC返回的命令码是0x81或者0x82这种最高位变成1的说明PLC拒收后面跟着的错误码对应具体原因地址超界、软元件代码错、点数超出范围之类具体错误码表在你的FX编程手册里有。2.3 软元件编码表M、X、Y、D在报文里是谁软元件代码是协议里很核心的一张表我常用的就这几个做小项目完全够用软元件代码十六进制地址编号规则典型用法X输入继电器0x9C八进制编号X0、X7、X10读外部按钮、传感器信号Y输出继电器0x9D八进制编号Y0、Y7、Y10读/写气缸、指示灯、接触器状态M内部继电器0x90十进制编号M0、M100、M500读PLC内部逻辑状态做软按钮D数据寄存器0xA8十进制编号D0、D100、D500读/写数值参数、计数、温度、速度R文件寄存器0xA9十进制编号批量数据存储较少用这里有个特别坑的地方X和Y的编号是八进制的。X7后面不是X8而是X10X10在报文里对应的十进制地址是8。你在C#里写int addr 10去读X10读出来的是X10八进制没错但如果你想用程序解析用户输入的“X10”字符串必须用八进制转换不能直接int.Parse。M和D才是十进制的直接转就行。D100在报文里就是0x64 0x00 0x00 0x00低字节在前跟Java或C#里BitConverter.GetBytes((int)100)出来的排列一致。地址的4字节和点数的2字节都是低字节在前小端序。这一点跟西门子的报文习惯相反很多从西门子转过来的工程师第一次写三菱驱动地址字节反了读出来全是错的数据但报文看起来又挺正常。判断方法很简单读D100报文里如果看到64 00 00 00就是对的看到00 00 00 64就是反了。3. bool与批量bool读写从单点到位组一步一步封装成函数3.1 串口参数DtrEnable、RtsEnable和超时怎么设写C#串口上位机SerialPort初始化看着简单但参数没设对会引发各种诡异现象。我通常会写一个专门的打开方法public bool Open(string portName, int baudRate 9600) { try { _sp new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout 800, // 读响应超时800ms心跳用得上 WriteTimeout 500, DtrEnable false, // 编程口协议不需要DTR别自动拉高 RtsEnable false, // RTS同理避免电平波动影响PLC }; _sp.Open(); return _sp.IsOpen; } catch (UnauthorizedAccessException) { // COM口被别的程序占用串口调试助手没关就会这样 return false; } catch (Exception ex) { Console.WriteLine($打开串口失败: {ex.Message}); return false; } }DtrEnable和RtsEnable默认是false但有人会在代码里手动设成true以为像老式MODEM一样需要握手结果一打开PLC就被复位或者通信直接失败。编程口协议不需要硬件流控这两个保持false就行。个别国产USB转422线在RTS为false时发送不出去属于线材质量问题那就要根据实际情况试。超时参数是另一个坑。ReadTimeout设太小比如100msPLC还没回完帧你就抛异常了设太大比如3000ms断线后要等很久才能确认链路挂了。800ms是我试过比较舒服的值配合循环重试既能快速感知断线又不至于误杀慢速响应。3.2 读单个bool和批量bool报文一样只是点数不同读M、X、Y这些位软元件请求帧结构完全相同区别只在点数。读单个M100和读M100到M107这8个点就是一个点数1和一个点数8的差别。PLC响应时每个位占1字节0x00表示OFF0x01表示ON注意不是打包成位是每个位一个字节。看代码// 批量读位元件deviceCode0x90(M), 0x9C(X), 0x9D(Y) public bool[] ReadBits(byte deviceCode, int startAddr, int count) { if (count 1 || count 256) throw new ArgumentOutOfRangeException(nameof(count), 点数必须在1~256之间); // 拼请求帧80 命令 软元件 地址(4字节小端) 点数(2字节小端) 00 校验 byte[] addrBytes BitConverter.GetBytes(startAddr); // 低字节在前 byte[] countBytes BitConverter.GetBytes((short)count); // 低字节在前 Listbyte frame new Listbyte { 0x80, 0x01, deviceCode, addrBytes[0], addrBytes[1], addrBytes[2], addrBytes[3], countBytes[0], countBytes[1], 0x00 }; byte xor 0; foreach (byte b in frame) xor ^ b; frame.Add(xor); byte[] req frame.ToArray(); _sp.Write(req, 0, req.Length); // 响应80 01 数据(count字节) 00 校验 // 数据长度 count因为有count个位每个位1字节 byte[] resp ReadResponse(0x01, count); bool[] result new bool[count]; for (int i 0; i count; i) { result[i] resp[2 i] ! 0x00; // 跳过帧头80和命令01 } return result; }响应数据区的第一个字节是帧头0x80第二个是命令码0x01从第三个字节开始才是位数据。读8个位响应长度就是8个数据字节加帧头、命令、结束符和校验共12字节。批量读256个点响应就有256字节串口一次不一定能收全所以下面这个读响应的方法要处理半包的情况private byte[] ReadResponse(byte cmd, int dataLength) { // 总长度 帧头(1) 命令(1) 数据(dataLength) 结束符(1) 校验(1) int totalLen 1 1 dataLength 1 1; byte[] resp new byte[totalLen]; int offset 0; while (offset totalLen) { int n _sp.Read(resp, offset, totalLen - offset); if (n 0) throw new TimeoutException(串口读取超时); offset n; } // 校验不算校验字节从头异或到结束符结果必须等于帧尾校验字节 byte xor 0; for (int i 0; i totalLen - 1; i) xor ^ resp[i]; if (xor ! resp[totalLen - 1]) throw new InvalidDataException(响应帧校验和错误); if (resp[1] ! cmd) throw new InvalidDataException($响应命令码错误: 0x{resp[1]:X2}); return resp; }这里用了一个串口工程师必须养成的习惯任何设备通信都要做半包拼接。SerialPort的Read方法一次可能只返回2个字节你一次性想读12个字节就会抛超时。所以必须循环Read直到收满总长度。网上有些教程直接ReadExisting拿字节数组拿多拿少全靠运气调试时偶尔好用跑到产线上就间歇性抽风。这种“黑匣子”问题最难查不如从一开始就写拼接逻辑。而校验这块响应帧的校验值也必须验不然一根干扰线就能让PLC返回的数据里蹦出一个错误位影响bool判断。3.3 写bool置位和复位共用一条报文差别在数据字节写一个位元件的报文和读只有一个区别命令码从0x01变成0x02并且在结束符前塞一个数据字节0x00复位0x01置位。所以public bool WriteBit(byte deviceCode, int addr, bool on) { Listbyte frame new Listbyte(); byte[] addrBytes BitConverter.GetBytes(addr); frame.Add(0x80); // 帧头 frame.Add(0x02); // 命令码写 frame.Add(deviceCode); // 软元件代码 frame.AddRange(addrBytes); // 地址4字节 frame.Add(0x01); frame.Add(0x00); // 点数1低字节在前 frame.Add(on ? (byte)0x01 : (byte)0x00); // 数据ON/OFF frame.Add(0x00); // 结束符 byte xor 0; foreach (byte b in frame) xor ^ b; frame.Add(xor); byte[] req frame.ToArray(); _sp.Write(req, 0, req.Length); // 写响应80 02 00 校验dataLength0 byte[] resp ReadResponse(0x02, 0); return resp[1] 0x02; // 命令码是02说明PLC确认了 }这里写一个字D寄存器也是同一个套路把数据字节从1位变成2字节低字节在前点数改成1或者2命令码还是0x02。所以在封装时写bool和写Word建议共用底层的WriteCommand方法只把数据区参数化不要各写一套减少出错的面积。我习惯把是否成功的返回值直接定义成bool这也是C#里bool类型函数最自然的用法——调用方直接if(plc.WriteBit(...))判断干净。别用int返回0和1然后在外面再做判断那是C时代的习惯放到C#里全是多余的代码。4. Word与Dword读写字节序和寄存器拼接是两个大坑4.1 读WordD寄存器是16位有符号低字节在前D寄存器在三菱PLC里是16位一个Word范围从-32768到32767有符号。报文里一个字的2个字节是低字节在前这一点又跟PC内存里小端存储一致所以C#读起来反而顺手public short ReadWord(int addr) // 返回有符号16位 { byte[] data ReadWordsRaw(0xA8, addr, 1); // 读到2字节原始数据 return (short)(data[0] | (data[1] 8)); // 低字节在前拼成short } private byte[] ReadWordsRaw(byte deviceCode, int startAddr, int count) { // 拼读请求帧命令码0x01软元件代码0xA8 // 和读bits几乎一样区别是响应数据长度 count * 2 int dataLen count * 2; byte[] resp ReadResponse(0x01, dataLen); byte[] data new byte[dataLen]; Array.Copy(resp, 2, data, 0, dataLen); // 跳过帧头80和命令01 return data; }如果你想读成无符号数把返回值类型改成ushort就行但要注意后续如果这个值要参与计算C#的ushort和short混在一起加减容易出类型转换警告建议统一用short用到无符号语义时再unchecked((ushort)val)。4.2 读Dword两个连续寄存器的拼接低字在前还是高字在前Dword在FX系列PLC里没有独立的软元件它实际是两个连续的D寄存器比如D0和D1组成一个32位整数D0存低16位D1存高16位。这在报文里的排列就是D0的低字节、D0的高字节、D1的低字节、D1的高字节。用C#的BitConverter.ToInt32可以直接转换因为这在内存里完全就是小端32位public int ReadDword(int lowAddr) { // lowAddr指向低16位的D寄存器高16位在lowAddr1 byte[] raw ReadWordsRaw(0xA8, lowAddr, 2); return BitConverter.ToInt32(raw, 0); // 4字节小端直接转int }等一下很多第一次写的人会在这一步翻车他以为D0是高16位D1是低16位结果读出来的数值完全不对。验证方式很简单——在PLC里写一个已知值比如D01D10然后上位机读Dword如果得到的值是1说明你的拼接方向是对的。反过来读成65536甚至负数那就是把高低位搞反了。还有一个跟Dword相关的坑是符号位。三菱PLC的32位数值有符号范围是-2147483648到2147483647。如果你读出来是4294967295这种说明你想的是无符号UInt32但三菱的D寄存器本身不区分有符号无符号它只是存位模式。需要无符号时用BitConverter.ToUInt32(raw, 0)即可但要注意两端一致性PLC侧如果用了UDINT类型上位机就必须用UInt32读。4.3 写Word和Dword拆寄存器别拆错方向写Word是读的逆操作数据字节低字节在前public bool WriteWord(short value) { byte[] valBytes BitConverter.GetBytes(value); // short转2字节小端 // 请求帧80 02 A8 地址4字节 点数01 00 数据2字节 00 校验 // 写单个字数据区就是valBytes[0], valBytes[1] } public bool WriteDword(int value) { byte[] valBytes BitConverter.GetBytes(value); // int转4字节小端 // 写两个连续的D寄存器lowAddr存低16位valBytes[0], valBytes[1] // lowAddr1存高16位valBytes[2], valBytes[3] // 请求帧里点数为2数据区是4字节 }写Dword时最容易出的问题你把4个字节都发到同一个D寄存器里了。比如写D0点数是1数据区却放了4字节PLC会怎么处理它只取前2字节写入D0后面2字节忽略然后响应一个正常确认帧。你看到写操作“成功”了但D0里只有低16位D1完全没动。这种问题排查起来非常讨厌因为上位机这边返回truePLC那边也确实收到了帧就是数据不对。我在这种问题上卡过一下午最后是拿串口调试助手抓包对比报文里数据区字节数才发现的。批量写Word也是同一套思路点数为N数据区就是N个2字节小端排列。这里有个性能要点写100个连续的D寄存器一次写100点和循环写100次单点耗时差距能到几十倍。串口9600波特率下一次单点写请求约20字节往返要30毫秒以上100次就是3秒多批量写一次就几百字节一两百毫秒搞定。产线上的参数下发如果超过几十个字千万别循环单点写。5. 心跳信号与链路排查断线、串口占用、校验错误的现场处理5.1 心跳报文设计读M8013一秒脉冲成本最低“心跳信号”在这个项目里的角色很简单上位机周期性地发一条读指令给PLC确认链路还活着PLC还在正常运行。读哪个软元件有讲究。我习惯读M8013这是FX系列PLC自带的1秒时钟脉冲继电器常ON每秒翻转一次报文和普通读M一样。选它的理由有两条第一它不需要PLC程序里做任何配置天然存在第二读完还能顺便验证PLC扫描周期正常——如果PLC停机了M8013的响应状态会变得异常心跳就能反映出来。public bool Heartbeat() { try { bool[] vals ReadBits(0x90, 8013, 1); // M8013 return vals.Length 1; // 能读到就说明链路通 } catch (TimeoutException) { return false; // 超时 链路断了或PLC没响应 } catch (InvalidDataException) { return false; // 校验错 干扰严重或双方速率不对 } }心跳线程里我建议做“连续失败3次才判定断线”不要一超时就重连。因为串口偶尔一次超时可能只是Windows调度卡了一下或者USB转串口的驱动瞬时丢了一个字节。连续3次失败再进入重连流程能过滤掉大部分偶发问题又不会让断线判断拖太久。心跳周期我一般设1秒和M8013脉冲同步重连尝试每2秒一次最多重试5次5次还连不上就把COM口关掉提示用户检查线缆和PLC电源。5.2 串口资源释放不掉的重连处理断线重连最典型的翻车现场是这样的程序里抛异常后直接new SerialPort再Open结果报“文件被占用”或UnauthorizedAccessException。原因是前一个SerialPort对象没有释放串口句柄还握在系统手里。正确做法是先把旧对象关掉并销毁再重新创建private void Reconnect() { try { if (_sp ! null) { _sp.DataReceived - OnDataReceived; // 先摘掉事件 _sp.Dispose(); // 释放句柄 _sp null; } Open(_portName); // 重新打开 } catch (Exception ex) { Log($重连失败: {ex.Message}); } }这里有个细节SerialPort.Close()和Dispose()不是一回事。Close只是关掉串口对象还占着引用Dispose会释放非托管资源。重连场景必须Dispose否则你会发现COM口号一直被占着任务管理器里看进程还活着但串口已经打不开了。排查“串口被哪个程序占用”也是现场常事。Windows 7/10下我的习惯是开串口调试助手试开同一个COM口如果提示占用再到任务管理器里找可疑的进程或者用tasklist /m看加载了哪些Serial驱动。大多数情况是自己上一个调试程序没退出或者调试助手没关。5.3 常见坑列表现象、原因、解决坑一报文发出后PLC完全无响应串口调试助手能看到发送但收不到任何字节。原因校验和算法写错或者帧头不是0x80。我见过有人用Modbus的CRC16去算三菱编程口帧PLC根本不认。 解决逐字节异或确认帧尾校验值和自己手算的一致。坑二能收到响应但读出来的值全乱比如读D100返回0xFFFF或随机波动。原因地址字节序或数据字节序配反了也可能是X/Y的八进制地址没转对。 解决先用0x0064D100这样的低字节在前地址测试打印原始字节对比。坑三串口打开失败提示Access denied或被占用。原因另一个程序串口调试助手、旧版本上位机没释放COM口。 解决关掉所有调试工具或杀掉占用进程后重试。坑四心跳反复误报几秒钟就断一次但又重连成功。原因ReadTimeout设太短。串口驱动在USB转422线缆上响应时间可能到几百毫秒50ms超时等于每次心跳都失败。 解决超时设到500~1000ms心跳连续3次超时才判断线。坑五写操作返回成功但PLC侧数据没变。原因点数和数据区长度不匹配比如写Dword时用点数1只发了2字节PLC只写了一半。 解决打印请求帧的hex数一数数据区字节数确认点数和字节数对得上一个Word 2字节。6. 进阶给读写链路装上日志和压测判断性能够不够用链路写通只是开始真正投产前我建议做两件事帧日志和批量读压测。帧日志的作用是留后悔药——现场真出现时序问题能拿出当时的报文逐帧对。做法很简单在发送请求和收到响应的地方各打一行hex字符串private void LogFrame(string direction, byte[] data) { StringBuilder sb new StringBuilder(); sb.Append(direction); // TX 或 RX foreach (byte b in data) sb.Append(${b:X2} ); File.AppendAllText(_logPath, ${DateTime.Now:HH:mm:ss.fff} {sb}\n); }日志文件不要无限长按天分文件保留最近7天就够。注意别在生产环境把每个字节都同步写硬盘会拖慢串口响应心跳线程里的日志尤其要精简只记录失败帧。压测的思路是拿Stopwatch统计耗时对比循环读单点和批量读的差距Stopwatch sw new Stopwatch(); sw.Start(); for (int i 0; i 100; i) plc.ReadBit(0x90, 100, i); // 循环单点读 sw.Stop(); Console.WriteLine($循环读100个bool: {sw.ElapsedMilliseconds}ms); sw.Restart(); plc.ReadBits(0x90, 100, 100); // 一次批量读100点 sw.Stop(); Console.WriteLine($批量读100个bool: {sw.ElapsedMilliseconds}ms);9600波特率下批量读100个M继电器通常只要几十毫秒循环单点读要两三秒。如果产线扫描周期要求100ms以内就必须把点位分组批量读如果只是按钮状态监控一秒读一次循环也能凑合。我自己的习惯是所有能合并到同一条请求帧的点位尽量合并。D寄存器连续区域也同理一次读50个字上报的数据结构性能最好。最后说一句个人教训我最早给一台FX3U写这个串口驱动时图省事把心跳和业务读写放在同一个后台线程里结果心跳一卡业务读写也跟着超时整个产线数据停了十秒钟。后来把心跳独立成定时器专属线程业务读写单独跑一个队列问题才消失。串口通信这种东西越到后面越是细节决定成败报文对得上只是开始线程模型、超时策略、日志留痕才是让它在产线上稳定跑两三年的关键。希望帮到你。本文还有配套的精品资源点击获取