工业现场把爱普生机器人和三菱PLC放在同一个电控柜里配合干活,是再常见不过的场景。但机器人怎么从PLC里高效、批量、实时地拿数据,往往是整个项目里最磨人的一环。IO硬接线简单粗暴,点位一多就变成蜘蛛网;买协议转换网关,又经常被点位映射数量、寄存器块限制和黑盒调试折磨。我最终的选择,是抛开中间网关,直接基于三菱MC协议(TCP)自己封装了一套通信驱动,让爱普生机器人作为TCP客户端直连PLC,读写D区、M区的数据表。这篇博文把驱动设计思路、MC协议报文结构、源码封装方式、爱普生侧集成方法和现场踩坑记录一次性整理出来,给正在用爱普生RC+开发、又不想被网关黑盒卡住的同行做一个参考。
1. 为什么放着现成的IO和网关不用,非要自己写驱动
1.1 常见数据交互方案的短板
先摆开说说现场最常见的三条路:IO硬接线、协议转换网关、MC协议直连。我把它们的实际差别放在一张表里看,会比较直观。
| 方案 | 适合场景 | 主要问题 |
|---|---|---|
| IO硬接线 | 点位个位数、实时性要求极高、逻辑简单 | 加点位要改线改程序,信息量密度低,批量数据和坐标值根本传不了 |
| 协议转换网关 | 想快速打通,点位几十到几百 | 点位映射配置繁琐,黑盒难排查,批量读写受限,部分网关有寄存器块数量和长度上限 |
| MC协议直连 | 点位多、要传输坐标/配方/计数值、需要批量读写 | 需要自己处理帧结构、校验、连接管理,前期工作量大 |
IO方案的问题很好理解,信号少的时候它很可靠,但一旦要传十几个坐标值、几十条配方参数,用IO去拼数据会累死人。网关方案我实际用过,最大的感受是“中间加了一层不透明的东西”:明明只是读PLC一个D区,网关里要建映射表、要选字节序、要分块,出了问题你根本不知道是PLC没送出来,还是网关转换坏了,还是机器人侧读错地址。尤其当现场设备在客户那边运行时,你只能远程对着日志猜,这种体验非常痛苦。
1.2 我踩过的第一次坑:把协议当成函数库抄
最开始我也没打算自己写,而是想找一套现成的C#或Python开源库,改一改直接跑。这其实是个误区。MC协议的开源代码并不难找,但大多数代码只覆盖了“读一个D区、写一个D区”这种最基础场景。等你真把它塞进机器人任务里,就会发现帧长度计算、响应粘包、断线重连、PLC侧连接数限制,这些都没有人替你处理。
我记得第一次在实验室调试时,照着示例代码改了一个读D区的功能,用网络调试助手手动发同样的帧能读到数据,换成程序发就是超时。查来查去,最后发现是请求帧里的长度字段没按二进制帧的规则算,导致PLC认为帧不完整,直接丢弃。那一次之后我意识到,通信驱动的核心不是那几行Socket收发代码,而是对帧结构、长度计算和状态管理的理解。这也直接决定了我后来封装驱动的分层方式,后面会详细讲。
2. MC协议3E帧基础:读懂报文比会写代码更重要
2.1 3E帧长什么样
现在爱普生机器人和三菱PLC之间做TCP/IP通信,主流用的就是MC协议里的3E帧,也叫QnA兼容3E帧。这个帧格式的好处是无需像老式1E帧那样先建立连接再收发,直接一条报文一个响应,简单干净。三菱Q系列、L系列、FX5U等内置以太网口基本都支持。
3E帧从结构上可以分成几段:子头、长度、地址信息、指令和数据段。我以二进制模式为例,画一个直观的表格:
| 字段 | 长度 | 说明 |
|---|---|---|
| 子头 | 2字节 | 二进制模式固定 D0 D0;ASCII模式则是字符串 "F9F9" |
| 请求长度 | 2字节 | 从网络号开始,到数据段结束的字节数 |
| 网络号 | 1字节 | 一般填 0 |
| PC号 | 1字节 | 一般填 0xFF |
| IO编号 | 2字节 | 目标模块编号,通常填 0x03FF |
| 站号 | 1字节 | 一般填 0 |
| 数据长度 | 2字节 | 从监视定时器开始,到数据段结束的字节数 |
| 监视定时器 | 2字节 | 0x0000表示无限制,也可以设具体超时时间 |
| 指令 | 2字节 | 功能码,读是0401,写是0402 |
| 子指令 | 2字节 | 一般填0000 |
| 数据段 | 变长 | 起始地址、点数、或要写入的数据 |
| CRC | 2字节 | 二进制模式为CRC-16校验 |
举个例子,读D100开始的10个字(二进制帧),数据段需要放“起始地址0100等待”不对,这里要说明一下:三菱软元件编号在MC协议报文里是直接用十进制编号的十六进制值,D100就是0x0064。所以读D100、10个字的请求帧数据段是0064 000A。整帧拼出来大概是D0 D0 00 0F 00 FF 03 FF 00 00 0A 00 00 04 01 00 00 00 64 00 0A,最后再跟两个字节的CRC。
2.2 ASCII帧和二进制帧怎么选
MC协议的3E帧还有传输形式的区别:ASCII形式和二进制形式。ASCII形式整个报文用十六进制字符表示,比如二进制里的D0 D0,ASCII形式就是字符串"F9F9"。两者指令含义相同,但报文长度差一倍,调试便利性也差很多。
从我的实践来说,调试阶段强烈建议先用ASCII帧配合网络调试助手手动发送,因为你看到的报文就是一行能读懂的字符串,哪里少了字符、哪里校验不对一目了然。稳定之后,如果机器人侧用C#插件或者高级语言处理数据,再换成二进制帧,效率会好一些。但如果你和我一样,早期在爱普生机器人上用SPEL+语言直接处理报文,我反而建议一直用ASCII帧,因为SPEL+的字符串处理函数确实方便,后面第4章会说这个原因。
2.3 核心指令:0401和0402就够大部分场景
MC协议的指令很多,但做设备数据交换,绝大多数场景只用两个:0401批量读取,0402批量写入。
0401既能读字软元件如D区、W区,也能读位软元件如M区、X区。稍微需要注意的地方是,读位软元件时三菱PLC是按照16个位打包成一个字来返回的。假设你读M0到M15这16个点,它返回一个字数;哪怕你只读M3这一个点,返回的仍然是一个字,M3对应返回值的bit3,实际程序里还要做一次位判断。另外一个老生常谈的细节:X和Y的编号是三菱习惯的八进制,M和D是十进制,在报文里填地址的时候要按PLC侧的软元件编号规则来,别把PLC里看到的编号直接抄进报文。
3. 驱动封装的骨架:连接、构造、解析三块各司其职
3.1 连接管理比报文构造更容易翻车
报文构造虽然是整个驱动里看起来最“技术”的部分,但真正常出问题的反而是连接管理。
PLC侧作为TCP服务器,同一时刻能够接受的连接数有限,有些模块甚至严格到只允许一两路。所以机器人侧的驱动必须使用长连接,不能每次读写都重新建立Socket。一个典型的现场场景是,机器人每几十毫秒就要读一次PLC数据表,如果每次都新建连接,很快PLC就会拒绝服务。
我自己的驱动里,连接模块单独维护一个TcpClient实例,加一个互斥锁保证同一时刻只有一个请求在途。因为MC协议是请求应答式,如果你在同一个连接上并发发多个请求,响应回来时根本对应不上是哪一条。断线重连也要做,但要有退避策略,比如断开后先等500ms再重连,连续失败就逐步拉长到几秒,避免PLC一恢复就收到一堆连接请求。
3.2 一个可用的C#报文构造器
下面这段C#代码是我驱动里的核心构造逻辑,按3E帧二进制格式生成批量读请求。需要说明的是,这里实现了字软元件(D区等)的读写,这也是设备数据交换的绝大多数场景。
public static class McFrameBuilder { // 构造3E二进制帧:批量读取字软元件,指令0401 public static byte[] BuildReadWordFrame(ushort startAddr, ushort count, ushort wdt = 0x0000) { List<byte> frame = new List<byte>(); // 子头:二进制模式固定 D0 D0 frame.Add(0xD0); frame.Add(0xD0); // 请求长度占位,稍后回填 int lenPos = frame.Count; frame.Add(0x00); frame.Add(0x00); // 网络号、PC号、IO编号、站号 frame.Add(0x00); // 网络号 frame.Add(0xFF); // PC号 frame.Add(0x03); // IO编号高字节 frame.Add(0xFF); // IO编号低字节 frame.Add(0x00); // 站号 // 数据长度占位,稍后回填 int dataLenPos = frame.Count; frame.Add(0x00); frame.Add(0x00); // 监视定时器 frame.Add((byte)(wdt >> 8)); frame.Add((byte)(wdt & 0xFF)); // 指令0401,子指令0000 frame.Add(0x04); frame.Add(0x01); frame.Add(0x00); frame.Add(0x00); // 数据段:起始地址 + 点数(均按字) frame.Add((byte)(startAddr >> 8)); frame.Add((byte)(startAddr & 0xFF)); frame.Add((byte)(count >> 8)); frame.Add((byte)(count & 0xFF)); // 回填长度字段 int dataLen = frame.Count - dataLenPos - 2; int reqLen = frame.Count - lenPos - 2; frame[lenPos] = (byte)(reqLen >> 8); frame[lenPos + 1] = (byte)(reqLen & 0xFF); frame[dataLenPos] = (byte)(dataLen >> 8); frame[dataLenPos + 1] = (byte)(dataLen & 0xFF); // CRC-16校验,范围从子头后开始 ushort crc = CalcCRC16(frame, 2, frame.Count - 2); frame.Add((byte)(crc >> 8)); frame.Add((byte)(crc & 0xFF)); return frame.ToArray(); } private static ushort CalcCRC16(byte[] data, int offset, int len) { ushort crc = 0x0000; for (int i = offset; i < offset + len; i++) { crc ^= (ushort)(data[i] << 8); for (int j = 0; j < 8; j++) { if ((crc & 0x8000) != 0) crc = (ushort)((crc << 1) ^ 0x1021); else crc = (ushort)(crc << 1); } } return crc; } }注意CRC计算这个细节。上面代码的校验范围写成“从子头后开始”,也就是从长度字段开始到数据段结束,这是我实测可用的一种做法。如果你在现场遇到网络调试助手能通、程序不通的情况,第一个要排查的就是这个范围是否和PLC参数设置一致。
批量写入的帧跟读取基本一致,指令换成0402,数据段里除了起始地址、点数,还要把每个要写入的字的数据依次排列。注意MC协议报文里一个字的高字节在前、低字节在后。
3.3 响应解析与接收边界
请求发出去之后,剩下的就是等响应。响应帧结构和请求帧非常像,只是在指令之后多了两个字节的结束代码,然后才是真正的数据区。结束代码为0x0000表示正常,其他值基本都是错误信息。下面是一个简化版的响应解析方法:
public static ushort[] ParseReadWordFrame(byte[] response) { if (response.Length < 15) throw new Exception("响应帧过短"); // 结束代码在指令之后,按3E帧的字段顺序取 int endCode = (response[13] << 8) | response[14]; if (endCode != 0) throw new Exception($"PLC返回结束代码 0x{endCode:X4}"); // 数据长度字段:指令2字节 + 结束代码2字节 + 数据区 int dataLenField = (response[9] << 8) | response[10]; int dataBytes = dataLenField - 4; if (dataBytes < 0) throw new Exception("数据长度异常"); ushort[] values = new ushort[dataBytes / 2]; for (int i = 0; i < values.Length; i++) values[i] = (ushort)((response[15 + i * 2] << 8) | response[15 + i * 2 + 1]); return values; }响应接收最容易踩的坑是TCP粘包。PLC返回的响应可能会被拆成多个TCP段到达,也可能多个响应粘在一起到达。所以不能一收到数据就立刻当完整帧解析。我的做法是,先接收数据放到缓冲区,然后从缓冲区头部解析出响应长度字段,如果缓冲区剩余长度不够,就继续接收,直到收满一整帧为止。这个逻辑写在Receive函数里,别的模块完全感知不到包边界的存在。
4. 爱普生机器人侧如何接入这套驱动
4.1 两条路线:SPEL+函数库还是C#插件
爱普生机器人侧接入这套驱动,实际有两条路线,各有各的道理。
第一条是直接在EPSON RC+的SPEL+语言里写成函数库。好处是整个项目不依赖外部PC,机器人和PLC直接组网就能独立运行,符合很多出厂设备的独立性要求。缺点也很明显,SPEL+处理二进制数据不舒服,没有原生字节数组,字符串函数又不擅长拼二进制帧,所以在这条路线上建议使用ASCII帧,把报文当成普通字符串来处理。
第二条是用EPSON RC+ 7.0以后支持的C#插件方式,在PC端写一个类库,把协议驱动封装成公开方法,再供SPEL+任务调用。优点是调试方便,数据结构处理能力强,日志系统也好做,适合开发期反复验证。缺点是运行时依赖PC主机环境,如果客户现场不允许带工控机,这条路线就不太现实。
4.2 SPEL+里的实测写法(ASCII帧)
我自己在SPEL+里实际用的是ASCII帧,核心思路是先把报文手动拼成字符串,再调用Socket发送函数。以读取D100开始的10个字为例,ASCII请求帧字符串大致是:
"F9F9" + "000F" + "0000" + "FF" + "03FF" + "00" + "000A" + "0000" + "0401" + "0000" + "0064" + "000A" + 校验码注意,ASCII帧每个二进制字节都用两个十六进制字符表示,所以子头变成了F9F9,长度字段也按ASCII字符传输,但长度值本身仍然是二进制的字节数。校验码则是对整帧字符串里每个字符的ASCII码做累加,取低8位,再转成两位十六进制字符。SPEL+里可以用Asc()函数逐个字符取码值来累加,逻辑不复杂。
发送和接收在SPEL+里走的是Socket指令,不同版本RC+的函数名略有差异,大概是NetSocketOpen、NetSocketSend、NetSocketReceive这一族。核心流程是:打开Socket连接PLC的IP和端口,把拼好的字符串发过去,然后循环接收直到拿到完整响应。如果担心粘包,SPEL+这边要自己判断响应长度字段,收够字节再往下走。
因为SPEL+缺少天然的字节数组,解析响应数据段时我一般直接把十六进制字符串成对拆开,用Val("&H" + Mid$(resp$, ...))转成整数。写一个CRC校验函数、一个报文拼接函数、一个响应解析函数,机器人主任务里调用起来就很简单了。
4.3 与机器人任务的配合方式
驱动写好后,机器人侧的使用方式很简单。我在主任务里维护一个全局数组,比如DData,专门存放从PLC读回的数据表,然后在机器人需要数据的节点调用读取函数刷新数组。举一个实际例子:产线上位机通过触摸屏把不同型号产品的放置坐标写入PLC的D100到D110区域,机器人每次抓取前先读这一段数据,再根据产品型号选择对应的放置目标。
这里有一个非常容易忽略的时序问题:写完PLC的D区后立刻去读,读回来的很可能是旧值。因为PLC程序是循环扫描的,写入寄存器只是把值放进内存,PLC程序还没有来得及把它搬运到你实际业务逻辑用到的位置。我的做法是,在PLC程序里设置一个“数据版本号”,机器人写入数据后把版本号加一,PLC处理完业务后把版本号回写到另一个寄存器,机器人判断版本号处理完成之后再继续动作。用这种握手方式规避PLC扫描周期带来的数据同步问题。
5. 现场运行半年后,我整理出的六个典型坑
5.1 一次读取的字数上限
MC协议3E帧的批量读取,一次最多读960个字左右,具体数值取决于PLC模块和帧长度限制。我第一次测试时图省事,想一次把D区里2000个字全部读回来,结果PLC直接返回错误结束代码。解决办法是分块读取,比如每次读64个字,循环读完所有块,然后本地拼接。实际项目里也不要一味追求单次读最大,单帧太长会占用PLC大量处理时间,还会影响其他通信任务的实时性。
5.2 粘包和半包的应对
这个问题在第3章提过,但值得再说一遍。在爱普生机器人侧用SPEL+的时候,因为字符串接收是一次性拿一块数据,更容易踩半包和粘包。我自己遇到过的真实情况是:响应帧长度有200多个字符,但一次NetSocketReceive只到了80个字符,程序如果不做“等够长度再解析”直接截取,结果就是解析出乱码。后来我统一改成循环接收,累积到变量里,直到长度满足响应帧里的长度字段再加校验码长度,才继续往下走。
5.3 位软元件读取的“一字16位”问题
读M区、X区这类位软元件,MC协议返回的是一组16位打包数据。比如你读M0到M15这16个点,返回一个字,bit0就是M0的状态,bit1是M1,以此类推。但如果只读M5一个点,PLC会返回M5所在字的一整组16位状态,你需要自己判断bit5。这个特性在写程序时很容易想当然,以为读1个点就返回1个bit,实际上不是。建议在封装驱动时就固定好,对位软元件的读取函数统一返回整数数组,让上层去判断位。
5.4 大小端顺序的坑
MC协议二进制帧里,一个16位数值在数据段中是高字节在前、低字节在后。比如D100里的值是0x1234,在报文中显示为12 34。这个跟很多人的直觉相反,因为TCP/IP网络序也是大端,但这个“大端”应用在寄存器值上时,如果你在解析时按小端顺序转换,读回来的所有数据就全部错位。判断这个坑最快的方法,是用一个已知值去测试,比如先在PLC里把D100设为1234,然后看解析出来的值对不对。
5.5 监视定时器和超时设置
报文里的监视定时器字段,很多人直接填0x0000,表示无限等待。但实际现场不可控,机器人读数据时PLC可能会因为通信繁忙而延迟响应,如果无限等待,驱动就会一直阻塞在那。建议给每个请求设置合理的超时时间,比如500到1000毫秒,超时后重试一次,不行就报故障。另外,监视定时器字段也可以配合PLC侧参数使用,比如设0x0006表示6秒?这里不同PLC对数值单位有差异,具体以PLC手册为准,原则是“不要把自己的Socket超时设成无限”。
5.6 掉线重连与PLC侧连接数量限制
现场设备运行中最烦人的问题之一,就是PLC断电重启或者程序在线修改后,机器人侧Socket连接会断开,并且PLC可能暂时不接受新连接。处理方式不复杂,但一定不能省:驱动要监听连接状态,断开后自动重连,重连间隔要退避,另外不要在断线期间反复快速重试。还有一点,确保整个系统只建立一条连接,不要因为机器人有多个任务就各开一个Socket,很多PLC对同一来源的并发连接数量很敏感。
6. 做这件事需要哪些基础,以及我的建议学习路径
6.1 硬技能清单
很多人看到“自行封装驱动程序”就被吓住了,其实它要求的基础没有想象中那么高。我自己整理了一份清单,按重要性排:
| 技能项 | 需要掌握到什么程度 |
|---|---|
| PLC基础 | 知道D区、M区、X/Y这些软元件概念,能在GX Works里设置以太网参数、开放MC协议端口 |
| TCP/IP基础 | 明白Socket、端口、请求应答、粘包和超时的含义 |
| 编程能力 | C#、Python或其他语言至少能写Socket程序;如果走SPEL+路线,还要熟悉函数、数组、字符串处理 |
| 调试工具 | 会用网络调试助手手动发包验证,会看PLC软元件监视表,最好会一点抓包工具 |
6.2 建议的学习路径
如果你现在还没动手,我建议严格按照这个顺序走,能少走很多弯路:
- 先用网络调试助手,手动连接PLC的IP和端口,自己拼一条最简单的ASCII帧,读取PLC里一个已知的D值。这一步能帮你确认PLC侧的端口、角色、帧校验方式都配置正确,也能直观理解报文结构。
- 在电脑上用Python或C#写一个最小Demo,程序方式完成一次读取。通过这个Demo,把请求帧构造、响应解析、CRC校验这几个基础逻辑跑通。
- 再把连接管理、粘包处理、断线重连这些工程化内容加进去,形成一个可复用的类库。
- 最后才接到爱普生机器人上。可以先从一个按钮触发一次读取开始,再到任务轮询,最后再上握手逻辑和生产逻辑。
6.3 什么情况下不值得自己写
自己写驱动确实灵活,但它不是万能的,也不是所有场景都值得。我的建议是,如果项目就只有十几个点位,IO硬接线反而更可靠;如果客户现场已经买了网关,而且点位数量在网关的舒适区内,用网关也不是不行;如果项目周期紧,你又是第一次接触MC协议,那我建议至少留出两到三天的调试余量,否则别轻易尝试在交付现场边装边调。
反过来,如果你的项目注定要传输坐标值、配方表、批次统计这类批量数据,而且以后大概率还会扩展点位,那我强烈建议把驱动类库沉淀下来。一次投入,后面所有项目复用,收益会越来越高。
我自己的体会是,这套驱动真正带来的不是省了网关那几千块钱,而是把数据通道的每个环节都攥在自己手里。最后分享一个小技巧:在驱动里加一个日志开关,平时关掉,现场出问题时打开,就能看到每个时刻机器人发了什么、PLC回了什么。这个能力在客户现场排查问题时,比任何远程工具都好用。