简介:C#开发者与工业自动化工程师在实现上位机与三菱FX系列PLC远程控制时,常需要打通RS232串口通讯链路。这份资源提供了一套可直接参考的完整方案,涵盖SerialPort参数配置、FX协议指令编码/解码、主动读取X/Y/M/D等软元件地址、错误检查与重试机制等关键环节。包内共37个文件,以C#源码、VS工程配置、可执行程序为主,同时附有FX编程口协议中文PDF和三菱PLC程序示例;压缩包总大小仅3.71MB,结构精简但覆盖从协议理解到代码落地的全过程,还能借助附带工程做仿真联调。目前已有1609人学习,适合具备基础C#语法、正着手开发PLC通讯模块的工程师。通过研读作者测试过的示例,可快速掌握请求报文构造、响应解析及异常处理思路,并直接移植到自己的项目中,缩短调试周期。
1. C#与FX系列PLC走RS232通讯:从“能通”到“稳定”的三个门槛
做设备数采最怕的不是PLC没有网口,而是明明有串口,却一直读不到正确的数。我接过一条FX系列PLC的生产线,现场只剩RS232口,C#上位机跑了三天才把第一帧数据稳定读回来。回头看问题不在串口本身,而在协议帧、线序和参数这三件事上。如果你是第一次做这类通讯,顺着读、写、强制、校验这条路走能少踩一半坑;如果你已经被乱码和NAK折磨过,直接跳到避坑那章对照。资源包里那份FX协议PDF文档,就是排查时的第一现场,建议打印出来放桌边。
2. FX系列PLC串口协议:帧结构、命令码与校验算法
2.1 为什么走编程口协议而不是Modbus RTU
FX系列PLC的串口通讯有两条路线。一条是Modbus RTU,需要在PLC参数里把通信协议切到Modbus模式,或者加专用模块,上位机还要处理CRC16和站号;另一条是FX编程口协议,也就是FX3U、FX3G等型号编程口默认使用的协议,C#里用SerialPort发ASCII字符帧,PLC就能直接应答。
我实际使用时偏向编程口协议,原因是设备数采场景通常一台PLC对应一台工控机,点对点、一问一答,恰恰是编程口协议最擅长的。它不需要站号、不需要广播,每次交互只有一帧请求和一帧应答,结构非常干净。代价是它不支持一主多从,但这不是这种场景关心的。
还有一点容易被忽视:FX编程口协议的地址规则和Modbus完全不同。D0在Modbus里可能是40001这类寄存器号,在编程口协议里就是"0000"这样的十六进制ASCII。所以别把两种协议的地址混着算,这是新手最容易踩的坑。网上很多代码把地址先转int再转string,最后发出去的字节完全不对,就是这个原因。
要理解这套协议,核心是抓三个关键点:帧头帧尾怎么识别、命令码到底是什么、校验和怎么算。下面逐个拆。
2.2 读命令帧拆解
先看读D0一个字的完整请求帧,十六进制表示:
02 30 30 30 30 30 31 03 32 36每一字节拆开看:
| 十六进制 | ASCII含义 | 说明 |
|---|---|---|
| 02 | STX | 帧开始 |
| 30 | '0' | 命令码,读 |
| 30 30 30 30 | "0000" | 起始地址,对应D0 |
| 30 31 | "01" | 读取1个字节 |
| 03 | ETX | 帧结束 |
| 32 36 | "26" | 校验和 |
这里最容易犯的错是把地址当成整数发。D0的地址是0x0000,但协议要求把它转成ASCII字符串"0000",也就是4个0x30字节。如果直接发送整数0,在串口上会变成0x00,是一个不可见控制符,PLC会当成非法帧丢弃,连NAK都不回。
校验和算法是从STX到ETX的每个字节直接累加,取低8位,再转成两个大写十六进制字符。例子中STX=0x02,加上5个0x30(0xF0)加上0x31和0x03,总和为0x126,低8位是0x26,所以校验和是"26"两个ASCII字符。有些资料写法是“从STX加到ETX”,同一种算法,关键是把字节值直接相加,不要做减法。我遇到过一个项目就是累加时把这个0x126的进位丢了,但保留进位的规则是保留低8位,0x26就是正确值,这个一模一样。
读命令的应答帧分两种情况,成功时是ACK开头,后面跟着数据帧:
06 02 31 32 33 34 03 43 3006是ACK,02是STX,然后"1234"是读到的数据,03是ETX,最后"C0"是校验和。失败时是NAK加错误码,后面马上讲。
2.3 写命令与错误码
写命令比读略复杂。把D0写成0x1234的请求帧:
02 31 30 30 30 30 31 32 33 34 03 43 30字段拆开:命令码'1'表示写,地址"0000"不变,中间"1234"是要写入的16位数据,没有独立的数据长度字段。0x1234按高字节在前、低字节在后的顺序转成ASCII"1234"。
写命令的应答是ACK(0x06)表示成功;失败时PLC回NAK(0x15)加一个错误码字节。下面是常见错误码:
| 错误码(十六进制) | 含义 | 常见原因 |
|---|---|---|
| 02 | 地址溢出 | 地址超出PLC范围 |
| 03 | 校验和错误 | 累加范围或转换出错 |
| 06 | 命令错误 | 命令码不在支持列表 |
| 0E | 通信超时 | PLC忙或线路干扰 |
错误码03是现场出现最多的,几乎每次都出在把STX/ETX漏算,或者把ASCII字节值算成了十进制。协议PDF文档里对校验和有一个详细的例子,照着算一遍比背公式有用。另一个高频错误是06,常见于FX3U的某些老固件不支持强制ON/OFF的扩展命令码,这时需要查PLC型号对应的协议版本。
2.4 地址映射速查:D、M、X、Y别记混
读和写帧结构清楚了,下一个关键点就是地址怎么映射到实际元件。FX系列PLC的地址规则并不完全统一,D寄存器通常按字地址连续排列:
| 元件类别 | 地址表示 | 说明 |
|---|---|---|
| D0-D9 | "0000"-"0009" | 数据寄存器,字访问 |
| D10-D99 | "000A"-"0063" | 十六进制按编号换算 |
| M0-M15 | "0000" | 位元件,16位一组 |
| X/Y | 对应独立BIT地址 | 具体以PDF为准 |
这个表只是一个快速参考,不同FX子系列存在差异。我自己的习惯是拿到协议PDF后,先把D寄存器这一类逻辑上最常用、最不容易混淆的地址测通,整套通讯链路跑通后,再处理M元件的16位分组映射。这样能把变量控制在一个最小范围内,出问题排查路径最短。
3. C#实现:SerialPort配置、命令封装与应答解析
3.1 串口参数怎么选
FX编程口默认波特率9600、数据位7位、偶校验、1位停止位。7位数据位能覆盖所有ASCII命令字符,包括0x02、0x03和0x30~0x39、大写A~F。有些老旧工程用8E1或9600,8,N,1也能通,但前提是PLC侧D8120寄存器改过参数。
SerialPort sp = new SerialPort(); sp.PortName = "COM3"; // 实际端口名以设备管理器为准 sp.BaudRate = 9600; // FX编程口默认波特率 sp.DataBits = 7; // 7位ASCII,兼容协议帧 sp.Parity = Parity.Even; // 偶校验 sp.StopBits = StopBits.One; // 1位停止位 sp.WriteTimeout = 500; // 发送超时 sp.ReadTimeout = 500; // 读取超时 sp.Open();这段代码里,DataBits设置为7后,SerialPort在发送时会自动把字节的最高位清零,正好符合7位ASCII的预期;如果你改用8位数据位、偶校验,帧内容不变,大多数FX型号也能识别。要注意WriteTimeout和ReadTimeout只是防止界面卡死的第一道防线,真正应对PLC不应答还得靠后面讲的重试逻辑。
3.2 构建可复用通讯类
把帧构造、发送、校验、应答解析放一个类里,比在按钮事件里拼串干净得多。
public class FxSerial { private SerialPort _sp; private object _lockObj = new object(); public FxSerial(string portName) { _sp = new SerialPort(portName, 9600, Parity.Even, 7, StopBits.One); _sp.Open(); } public byte[] BuildReadFrame(string address, int byteCount) { List<byte> frame = new List<byte>(); frame.Add(0x02); // STX帧头 frame.AddRange(Encoding.ASCII.GetBytes("0")); // '0'=读命令 frame.AddRange(Encoding.ASCII.GetBytes(address)); // 起始地址,比如"0000" frame.AddRange(Encoding.ASCII.GetBytes(byteCount.ToString("X2"))); // 读取字节数 frame.Add(0x03); // ETX帧尾 int sum = 0; foreach (byte b in frame) sum += b; string sumHex = (sum & 0xFF).ToString("X2"); // 低8位转大写十六进制 frame.AddRange(Encoding.ASCII.GetBytes(sumHex)); return frame.ToArray(); } public byte[] SendCommand(byte[] frame) { lock (_lockObj) { _sp.Write(frame, 0, frame.Length); return ReadResponse(); } } }BuildReadFrame里,address直接传"0000"这种4位ASCII字符串;byteCount是读取字节数,这里用X2格式化成两位大写十六进制,比如读1字节就是"01"。校验和先累加所有帧内字节,再取低8位。SendCommand用lock锁住串口对象,是因为SerialPort不是线程安全的,多个界面控件同时调用会翻车。锁的粒度是整个发送和应答过程,避免两帧交织。
3.3 应答解析与错误码处理
读命令应答帧成功时是ACK + STX + 数据 + ETX + 校验和:
06 02 31 32 33 34 03 43 30第一个字节06说明PLC已经收到并开始回复,其后是一帧与读请求同构的数据帧:STX后跟4位数据"1234",ETX后跟校验和"C0"。
private byte[] ReadResponse() { int b = _sp.ReadByte(); // 应阻塞等到首字节 if (b == 0x15) // NAK return null; if (b != 0x06) // 不是ACK,丢弃 throw new InvalidOperationException("未知应答首字节"); // 跳过ACK后的STX while (_sp.ReadByte() != 0x02) { } List<byte> payload = new List<byte>(); while ((b = _sp.ReadByte()) != 0x03) payload.Add((byte)b); // 读取两位校验和ASCII byte high = (byte)_sp.ReadByte(); byte low = (byte)_sp.ReadByte(); string receivedSum = ((char)high).ToString() + ((char)low).ToString(); int sum = 0x02; foreach (byte p in payload) sum += p; sum += 0x03; string expectSum = (sum & 0xFF).ToString("X2"); if (receivedSum != expectSum) return null; return payload.ToArray(); }这块代码核心是严格按照ACK、STX、数据、ETX、校验和的顺序解析。要注意ReadByte是阻塞的,必须配合ReadTimeout,否则PLC掉线时程序会一直卡在while里。所以完整方案一般会在ReadTimeout之外再套一层顶层超时,比如用CancellationToken或Task.WhenAny。
3.4 写命令与多寄存器批量读取
写一帧的构造和读非常像,只是命令码变成'1',原来的字节数位置替换成要写入的数据。
public byte[] BuildWriteFrame(string address, string hexData) { List<byte> frame = new List<byte>(); frame.Add(0x02); frame.AddRange(Encoding.ASCII.GetBytes("1")); // 写命令 frame.AddRange(Encoding.ASCII.GetBytes(address)); frame.AddRange(Encoding.ASCII.GetBytes(hexData)); // 要写入的十六进制字符串 frame.Add(0x03); int sum = 0; foreach (byte b in frame) sum += b; frame.AddRange(Encoding.ASCII.GetBytes((sum & 0xFF).ToString("X2"))); return frame.ToArray(); }写D0为0x1234就是BuildWriteFrame("0000", "1234")。批量读则是在BuildReadFrame里把byteCount从"01"改成"04",一次读4个字节即两个D寄存器。这样上位机可以少几次交互,也降低线缆上的错误概率。但要注意一次读取长度不要超过协议PDF里规定的上限,通常是64字节以内,超了PLC直接回错误码02。
4. 通讯不稳定怎么办:线序、电平转换与超时设计
4.1 线序和电平转换:最容易翻车的物理层
FX系列PLC的编程口是MINI-DIN 8针插座,物理信号通常是RS422。RS422是四线差分,和电脑串口的RS232单端电平不兼容,所以直接用一根RS232线插上去,大概率是没有反应的。常见做法是买一根“FX编程线”,这类线内部做了RS422与RS232/USB电平转换;如果自己焊线,至少要对清楚TXD+、TXD-、RXD+、RXD-这四根线。
现场踩坑经历:线缆上标注的TX/RX和PLC侧定义不一致很常见,交换过来就通的情况并不少见。可以用一个串口调试助手,发送读D0的那一帧:02 30 30 30 30 30 31 03 32 36,如果能收到06开头的应答,物理层就通了。这一步先做,再写C#代码,能省一大半时间。
另一个容易被忽略的点是接地。RS232是单端信号,工控机的地和PLC的地之间存在电位差时,会导致偶发乱码。遇到这种情况,可以在线缆屏蔽层两端单点接地,或者中间串一个隔离模块。血泪经验:有次现场设备一启动大电机,通讯就丢帧,排查一轮发现是地环路干扰,加了隔离转换器后才彻底解决。
4.2 PLC侧参数与C#参数联动
C#里SerialPort设置得再对,如果PLC侧的通讯格式是改过的,两边也聊不到一起。FX系列PLC的串口通讯参数由D8120寄存器控制,比如bit 0到bit 2决定数据位、停止位、校验位。默认配置下D8120=0x0000对应7位数据、偶校验、1位停止位、9600bps。
| 参数项 | C#里的设置 | D8120对应位 |
|---|---|---|
| 波特率 | 9600 | 默认值,除非PLC侧修改 |
| 数据位 | 7 | D8120低3位 |
| 校验位 | Even | D8120 bit2-bit3 |
| 停止位 | 1 | D8120 bit0-bit1 |
我之前遇到过一台FX3U的D8120被改成了8N1,上位机按9600 7E1去连,数据时通时断,重启后彻底连不上。后来通过三菱编程软件读D8120才发现问题,把C#参数改成8N1后一帧就通。
4.3 超时与重试参数不该拍脑袋
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 应答超时 | 300ms | 编程口响应通常是毫秒级,300ms足够 |
| 重试次数 | 3次 | 超过3次再报错,避免把偶发干扰放大 |
| 重试间隔 | 100ms | 给PLC和线路一个恢复窗口 |
| 连续失败阈值 | 5次 | 触发断线重连或提示用户检查线缆 |
之前项目里把重试间隔设成500ms,结果一次掉线要两秒多才能恢复,操作员暴躁指数直线上升。后来改成100ms间隔、3次重试,体感上根本没察觉掉线。但要注意,重试不能无限循环,否则PLC断电时上位机界面就像死机一样。
4.4 用日志把协议变成“可见”
通讯稳定了才敢谈效率。我的习惯是每帧数据都留一条日志,格式固定:时间、发送帧、接收帧、解析结果。遇到偶发问题直接看日志里校验和错误出现的位置,比对是线路干扰还是代码bug。反过来,如果日志里连续多次都是超时,基本能锁定物理层问题。
具体做法是在SendCommand外面包一层日志方法,把BuildReadFrame生成的byte[]用BitConverter.ToString(byte[]).Replace("-", " ")转成十六进制字符串落盘。这样即使三个月后设备出了古怪问题,翻日志还能还原当时的通讯现场。这条习惯救过我很多次,尤其当PLC程序被现场工程师改过之后。
5. 避坑指南:五个亲测踩坑记录
5.1 串口打不开,报“端口被占用”
现象:Open()抛UnauthorizedAccessException,或者打开之后半天没反应。
原因:前一个程序实例没有释放串口;或者SerialPort在using块外被GC清理,但底层句柄还占着。
解决:用单例持有SerialPort,不要每次new一个;程序退出时显式调用Close和Dispose;如果系统里确实有进程占住端口,在“设备管理器—端口”把对应的COM口暂时禁用再启用。我后来习惯在启动时先扫描GetPortNames(),再逐个试探打开,这样部署到工控机上不会因为COM号变了就直接罢工。
5.2 发命令无应答,调试助手也收不到
现象:frame发出去了,BytesToRead一直是0,PLC侧的通讯灯也不闪。
原因:线序反了,或者用的是RS232直连线而不是带电平转换的编程线。
解决:回到串口调试助手,用02 30 30 30 30 30 31 03 32 36这10个十六进制字节做测试。发出去如果收到相同字节,说明串口和线没问题;收不到就去查PLC侧通讯状态位和线缆定义。还有一种情况是PLC的D8120配置里通讯协议被改成了其他协议,这时用编程口协议发命令不会有任何响应。
5.3 应答NAK,错误码03
现象:收到0x15 0x03,说明校验和错误。
原因:累加时漏了STX或ETX,或者把累加和当成十进制字符串再转十六进制。
解决:用for循环逐字节相加,从0x02一直加到0x03,取低8位后用X2格式化;和协议PDF里的例子比对一次,对着算一遍就永远不会错。另外要注意校验和的两个字符是大写A-F,而ToString("X2")默认就是大写,不要用ToLower再转一遍。
5.4 偶发读错数据,校验和却是对的
现象:数据能读回来,偶尔某个字节会跳变,但校验和恰好又对。
原因:多为电气干扰或线缆过长;也有可能是数据位/校验位设置与PLC侧D8120不一致导致偶发误码。
解决:先查D8120的实际值,确认通信格式为7E1;然后看线缆长度,超过5米的RS232要降波特率或换RS422线缆。这类问题最玄学,但大多数时候是物理层。另一个隐蔽因素是USB转串口线芯片质量,劣质芯片在长时间传输后会丢位,换一个FTDI芯片的转换器往往不治而愈。
5.5 换台电脑就翻车
现象:在开发机上一切正常,部署到工控机后端口名变了,或者打开端口卡死。
原因:工控机上的USB转串口芯片和开发机不同,COM号分配随机。
解决:不要写死COM3,用GetPortNames()列出所有端口,再按设备描述过滤或者做成下拉框让用户选。程序启动时做两秒钟端口自检,失败就弹提示而不是闷着报错。后续项目我直接在配置文件里记录上次成功的COM口,下次启动先尝试它,失败再自动扫描,这个体验会好很多。
6. 进阶:线程安全与断线自动重连的通讯封装
6.1 让通讯层自己会重连
前面的类能完成基本交互,但生产环境还要处理拔线、PLC重启、PC休眠恢复。我的做法是在SendCommand里加一个计数器,连续失败达到阈值就自动关闭串口、重新Open,并把整个通讯层放到后台线程里。重连之后不丢历史数据,只是清空缓冲区重新同步。
public byte[] SendWithRetry(byte[] frame, int retryCount = 3) { for (int i = 0; i < retryCount; i++) { try { byte[] resp = SendCommand(frame); if (resp != null) return resp; } catch (TimeoutException) { } Thread.Sleep(100); } Reopen(); return null; }Reopen里做Close、Dispose、重新new SerialPort,比反复调Open更稳。因为串口对象在操作系统的底层句柄已经异常,复用同一个实例容易越陷越深。这个重试逻辑里,retryCount和重试间隔是暴露出来的参数,不同现场可以单独调。
6.2 验证方法:一帧回环测试
拿到资源包后别急着写业务代码,先跑通这一帧:读D0,预期返回06 02 xx xx ETX 校验。通了你对协议和线缆都有了底,后面写其他地址也只是逐渐加功能而已。从那以后我每次接新设备都强制走一遍:查端口、回环测试、试读D0、再上业务。希望帮到你。
本文还有配套的精品资源,点击获取