简介:面向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, // 软元件代码:0xA8=D寄存器 0x64, 0x00, 0x00, 0x00, // 起始地址D100=0x64,低字节在前 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而是X10,X10在报文里对应的十进制地址是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设太小,比如100ms,PLC还没回完帧你就抛异常了;设太大,比如3000ms,断线后要等很久才能确认链路挂了。800ms是我试过比较舒服的值,配合循环重试,既能快速感知断线,又不至于误杀慢速响应。
3.2 读单个bool和批量bool:报文一样,只是点数不同
读M、X、Y这些位软元件,请求帧结构完全相同,区别只在点数。读单个M100和读M100到M107这8个点,就是一个点数1和一个点数8的差别。PLC响应时每个位占1字节,0x00表示OFF,0x01表示ON,注意不是打包成位,是每个位一个字节。看代码:
// 批量读位元件,deviceCode=0x90(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); // 低字节在前 List<byte> frame = new List<byte> { 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) { List<byte> frame = new List<byte>(); 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 校验,dataLength=0 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 读Word:D寄存器是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位在lowAddr+1 byte[] raw = ReadWordsRaw(0xA8, lowAddr, 2); return BitConverter.ToInt32(raw, 0); // 4字节小端直接转int }等一下,很多第一次写的人会在这一步翻车:他以为D0是高16位,D1是低16位,结果读出来的数值完全不对。验证方式很简单——在PLC里写一个已知值,比如D0=1,D1=0,然后上位机读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] // lowAddr+1存高16位:valBytes[2], valBytes[3] // 请求帧里点数为2,数据区是4字节 }写Dword时最容易出的问题:你把4个字节都发到同一个D寄存器里了。比如写D0,点数是1,数据区却放了4字节,PLC会怎么处理?它只取前2字节写入D0,后面2字节忽略,然后响应一个正常确认帧。你看到写操作“成功”了,但D0里只有低16位,D1完全没动。这种问题排查起来非常讨厌,因为上位机这边返回true,PLC那边也确实收到了帧,就是数据不对。我在这种问题上卡过一下午,最后是拿串口调试助手抓包,对比报文里数据区字节数才发现的。
批量写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的八进制地址没转对。 解决:先用0x0064(D100)这样的低字节在前地址测试,打印原始字节对比。
坑三:串口打开失败,提示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写这个串口驱动时,图省事把心跳和业务读写放在同一个后台线程里,结果心跳一卡,业务读写也跟着超时,整个产线数据停了十秒钟。后来把心跳独立成定时器专属线程,业务读写单独跑一个队列,问题才消失。串口通信这种东西,越到后面越是细节决定成败,报文对得上只是开始,线程模型、超时策略、日志留痕才是让它在产线上稳定跑两三年的关键。希望帮到你。
本文还有配套的精品资源,点击获取