做自动化上位机这几年,我前后接过不少日系PLC的项目,其中欧姆龙PLC打交道最多。说到欧姆龙PLC通信协议,很多人第一反应是HostLink,第二反应是FINS,再往下就是各种读不出来、写不进去、偶发超时。这篇文章就围绕欧姆龙PLC通信协议,把我在现场踩过的坑、最后搞明白的帧结构、地址映射和调试方法整理出来,给正在调欧姆龙通信的同学做个参考。不管你是刚入行的电气工程师,还是写上位机软件的开发,只要项目里要跟欧姆龙PLC拉数据,这篇内容应该能帮你省下不少试错时间。
按欧姆龙PLC通信协议的使用深度来分,串口时代大家多半从HostLink入门,以太网时代基本都要转到FINS/TCP或FINS/UDP。协议本身不复杂,难的是细节:节点号错一个就报2101,内存区码填错就报0201,DM区字符串读回来高低字节反了全是乱码。这些坑我在现场都踩过,所以这篇文章不打算写成官方手册的翻译版,就按我实际调试的过程来讲,尽量让你看完能直接上手。
1. 别急着拼报文,先搞清楚你用的是哪一层协议
1.1 串口时代的HostLink:简单但有边界
欧姆龙的HostLink协议是串口通信时代的老将,走RS-232或RS-422/485,用ASCII码明文交换数据。主机发一条命令给PLC,PLC回复一条响应,一问一答,非常直白。典型帧格式是这样的:
@设备号 命令 正文 FCS * CR最前面是@字符,后面跟两位设备号(01~31),然后是命令码。比如读DM区用RD,写DM区用WD,读CIO区用RR。正文部分带上起始地址和长度,末尾的FCS是两个ASCII字符,表示从@之后到FCS之前所有字符的异或校验值,最后以*和回车结束。
FCS的计算方式我一开始也搞错过,后来才总结清楚:把@后面第一个字符开始,一直到FCS前面的字符为止,逐个字节做异或,得到的结果拆成高4位和低4位,再分别转成ASCII字符补上去。举个例子,帧内容是@01RD00640001,那就从0开始异或1、R、D……一直到最后一个1,最后算出来的值转成两位ASCII码接到后面。
HostLink在单机、串口、数据量小的场景下完全够用,很多老设备至今还在跑。但它的边界也很明显:串口速率撑死了115200bps,一条命令能读的数据长度有限,而且一台串口只能挂一路通信。到了以太网时代,现场动不动就是多台PLC联网、上位机集中采集,HostLink就有点吃力了。做新项目时我基本不再优先选它,除非现场设备确实只有串口。
1.2 FINS才是欧姆龙的通用语言
FINS(Factory Interface Network Service)是欧姆龙自己定义的一套报文协议,最大特点是统一。无论是CJ系列、CP系列,还是后来NJ/NX系列,FINS命令码基本通用。它既可以跑在串口上(作为HostLink的特殊命令),也可以跑在以太网上,走FINS/TCP或FINS/UDP。
FINS/TCP默认端口是9600,连接后上位机以请求-响应方式通信。和HostLink最大的区别是,FINS不再用ASCII明文,改用二进制帧,数据密度高,而且命令集更完善。读写内存区用0101和0102命令码,读写时钟用0701和0702,读系统状态也有专门命令。只要你会拼一条0101命令,其他命令的拼法都是相通的,学会一个就掌握了一大半。
FINS/UDP则是无连接版本,端口同样是9600,适合广播或多点采集场景。不过无连接意味着没有重传和顺序保证,现场调试时丢包很难排查,我一般不太推荐对可靠性要求高的项目直接用UDP,除非你对网络环境有绝对的把握。
1.3 现场选型:我的判断依据
| 协议 | 传输层 | 典型端口 | 适用场景 | 学习成本 |
|---|---|---|---|---|
| HostLink | RS-232/422/485 | 串口 | 老设备、单PLC、低速采集 | 低 |
| FINS/TCP | TCP/IP | 9600 | 多PLC联网、上位机集中监控 | 中 |
| FINS/UDP | UDP/IP | 9600 | 广播采集、对实时性要求高的场景 | 中 |
我自己做的一条产线项目,三台CP1H PLC,一台工控机统一采集,最终选了FINS/TCP。原因很直接:以太网布线方便,不用为每台PLC单独拉串口线;TCP自带连接管理和重传机制,通信稳定性比UDP好;最重要的是FINS帧结构统一,后面再加设备时,代码基本不用大改。
很多人纠结协议选型,我建议先看现场网络条件。现场有交换机、PLC带以太网口,直接FINS/TCP;只有串口或老设备,就HostLink;如果要做广播控制或者PLC数量特别多,再考虑FINS/UDP。选型不是越高级越好,是越匹配现场越好。
2. FINS报文逐字节拆解:节点号、内存区码、地址偏移全是坑
2.1 FINS/TCP包怎么组装
很多人第一次拼FINS/TCP报文时,看着手册里一堆英文缩写就懵了。其实拆开看,一个完整的请求包只有三部分:4字节的ASCII字符串FINS、4字节的报文长度、以及真正的FINS报文本体。
先解释那4个ASCII字符。它相当于帧头标识,让接收方一眼认出这是一条FINS/TCP报文。紧接着的4字节是长度字段,按大端方式存放,表示后面FINS报文本体有多少个字节。很多人在这个长度上栽过跟头,比如多算了头部的8字节,或少算了命令码的2字节,导致PLC端解析出参数长度错误。
FINS报文本体则从ICF字段开始,共9字节头:
ICF:控制标志,请求帧通常填0x80,表示需要响应。RSV:保留字段,固定填0x00。GCT:网关数,请求帧填0x02。DNA:目标网络地址,填0x00,表示本地网络。DA1:目标节点号,也就是PLC的FINS节点号。DA2:目标单元号,CPU单元填0x00。SA1:源节点号,上位机填0x00即可,但如果现场有多台上位机,建议分别设不同值。SA2:源单元号,上位机填0x00。SID:服务标识,每次请求自增,用来匹配响应和请求。
9字节头之后再跟2字节命令码。比如0101表示内存区读,0102表示内存区写。最后是命令参数区。整个FINS报文本体就是这样拼接起来的。
2.2 内存区码和地址偏移,别凭感觉填
FINS命令里,读写内存区时不是直接写个字符串名称,而是用代码表示。最常用的两个:
- DM区:0x82
- CIO区:0x80
这俩我一开始总记混,后来干脆在代码里定义常量,注释写上“DM区0x82”,再也没错过。地址部分分两部分:2字节的字地址,按大端排列;1字节的位地址。如果你读写的是整个字,位地址填0x00。这里有个特别容易犯的错:位地址不是给点位编号用的,它表示的是字里的位偏移,比如要读CIO区的某个位,需要把字地址定位到所在字,再在位地址里填位号。
举个例子,要读DM100开始的2个字,内存区码填0x82,字地址十进制100转十六进制是0x0064,大端排列就是00 64,位地址填00,长度填00 02。如果这块搞反了,PLC会报地址超范围,而且响应码种类很多,排查起来特别费劲。
2.3 完整报文示例:读DM100两个字的十六进制长啥样
我直接列出读DM100、长度为2个字的完整报文,方便你对照调试:
46 49 4E 53 00 00 00 11 80 00 02 00 20 00 00 00 01 01 01 82 00 64 00 00 02逐段看:前4字节46 49 4E 53就是ASCII的FINS;紧接着00 00 00 11是长度,0x11等于17,正好是后面FINS报文本体的总字节数。从80到01是9字节头,80是ICF,00是RSV,02是GCT,00是DNA,20是DA1目标节点号32,再两个00表示DA2和SA1,接着00是SA2,01是SID。01 01是内存区读命令。82是DM区,00 64是字地址100,00是位地址,00 02是读取长度2个字。
响应报文结构也类似:前8字节还是FINS+长度,9字节头里的SID会回显请求里的值,命令码保持0101,后面跟主响应码和次响应码两个字节。正常时是00 00,数据区从第18字节开始。判断一帧响应是否成功,第一件事就是看00 00,只要不是它,后面数据都别信。
3. 上位机写代码:从拼帧到稳定轮询
3.1 动手前先把环境跑通
写代码之前,我强烈建议先花十分钟手动验证通信链路。打开TCP调试助手,填上PLC的IP和9600端口,把上面那条读DM100的报文原样粘贴成十六进制发出去,如果能收到带00 00响应码的回复,说明网络、节点号、帧格式都没问题,可以放心开始写代码。如果这一步都收不到响应,那问题大概率在网络或PLC配置层面,用代码调试只会更混乱。
PLC这边的准备工作也要确认:FINS节点号配好,IP地址和上位机在同一网段,PLC的以太网功能处于启用状态。新机型NX/NJ我还要额外检查一下,PLC设置里是否开了FINS服务,没开的话上位机怎么连都是超时。手动验证通过之后,你的排错范围就缩小到了上位机代码内部,效率会高很多。
3.2 用C#封装一个最小可用的FINS/TCP客户端
我在Windows工控机上习惯用C#写上位机,机房环境里.NET运行时基本是标配。封装一个最小客户端不需要多少代码,重点就是拼帧、发送、解析三个动作。下面给一个能直接改用的核心片段。
public class FinsTcpClient { private TcpClient _client; private NetworkStream _stream; private byte _sid = 0; public void Connect(string ip, int port = 9600) { _client = new TcpClient(); _client.Connect(ip, port); _stream = _client.GetStream(); } public byte[] ReadDm(int startAddress, int wordCount) { var body = new List<byte>(); body.Add(0x82); // DM区 body.Add((byte)(startAddress >> 8)); body.Add((byte)(startAddress & 0xFF)); body.Add(0x00); // 位地址 body.Add((byte)(wordCount >> 8)); body.Add((byte)(wordCount & 0xFF)); var data = BuildFrame(0x0101, body); var response = SendAndReceive(data); return ParseResponse(response, body.Count); } private byte[] BuildFrame(ushort command, List<byte> parameters) { var finsBody = new List<byte>(); finsBody.Add(0x80); // ICF finsBody.Add(0x00); // RSV finsBody.Add(0x02); // GCT finsBody.Add(0x00); // DNA finsBody.Add(0x20); // DA1 PLC节点号 finsBody.Add(0x00); // DA2 finsBody.Add(0x00); // SA1 finsBody.Add(0x00); // SA2 finsBody.Add(_sid++); // SID finsBody.Add((byte)(command >> 8)); finsBody.Add((byte)(command & 0xFF)); finsBody.AddRange(parameters); var frame = new List<byte>(); frame.AddRange(System.Text.Encoding.ASCII.GetBytes("FINS")); frame.Add((byte)(finsBody.Count >> 24)); frame.Add((byte)(finsBody.Count >> 16)); frame.Add((byte)(finsBody.Count >> 8)); frame.Add((byte)(finsBody.Count)); frame.AddRange(finsBody); return frame.ToArray(); } private byte[] ParseResponse(byte[] response, int dataStart) { int offset = 4 + 4 + 9 + 2; if (response[offset] != 0x00 || response[offset + 1] != 0x00) throw new Exception($"PLC响应码异常: {response[offset]:X2} {response[offset + 1]:X2}"); int data = offset + 2; var result = new byte[response.Length - data]; Array.Copy(response, data, result, 0, result.Length); return result; } }这段代码里有一个需要重点理解的地方:ParseResponse的成功判断位置。响应报文经过8字节的FINS+长度后,是9字节的FINS头,再往后是2字节命令码,然后才是主响应码和次响应码。所以偏移量是4加4加9加2,在第18、19字节判断00 00。一开始我图省事直接找固定位置,换了PLC型号后解析全乱,最后老老实实按这个偏移来,再没出错过。
SID的处理也比较关键。每次请求我把_sid自增,发出去的帧里带上,响应回来时检查回显值是否一致。如果连接是长连接且请求频繁,SID能帮你识别是哪条请求的响应,也方便排查偶发超时。
3.3 轮询节奏:别把PLC当成数据库高并发读
代码能通之后,另一个容易忽略的问题是轮询节奏。有些新同学一上来就开个while循环,10毫秒读一次,结果PLC扫描周期被通信任务拖慢,产线设备跟着出怪问题。通信读写是需要占用PLC内部处理时间的,虽然FINS本身很轻,但高频请求依然会挤占扫描周期。
我现在的做法是:普通模拟量、状态字这类数据,100毫秒到200毫秒轮询一次;温度、压力这种变化慢的,500毫秒都够用;需要事件响应的写操作,不用轮询,按钮触发时发一条写命令即可。还有个细节,如果一张页面同时读几十个点位,不要把请求全部在同一个瞬间发出去,错开几十毫秒,PLC端的通信负载会平滑很多。
TCP连接在长时间空闲时容易被防火墙或交换机老化掉,我会在代码里加一个定时心跳,每2秒读一次PLC的系统时钟,既能维持连接,又能实时确认PLC在线。心跳命令用0701,参数比内存读写简单,学会0101之后基本触类旁通。
4. 现场调试踩过的坑:这些报错和怪现象我全遇过
4.1 FINS响应码速查表
项目做多了,我发现最省时间的做法不是翻手册,而是把常见响应码记成一张表贴在自己的笔记里。下面这些是我遇到频率最高的几个:
| 响应码 | 含义 | 排查方法 |
|---|---|---|
| 00 00 | 正常 | 无需处理 |
| 01 01 | 命令格式错误 | 检查命令码、参数数量 |
| 01 03 | 地址超范围 | 检查字地址是否超出该型号范围 |
| 01 04 | 数据长度超限 | 减小单次读写长度,分帧处理 |
| 02 01 | 内存区不存在 | 检查内存区码,比如DM区也不能填在CIO请求里 |
| 02 02 | 内存区写入保护 | 检查PLC内存保护设置 |
| 21 01 | 目标节点不存在 | 重点检查DA1节点号 |
2101是最让我头疼的,因为它经常不是帧格式问题,而是PLC那边的FINS节点号没有配对。FINS/TCP不是靠IP直接寻址的,报文里还得有节点号,PLC侧要把节点号配成和实际网络对应关系一致。出现2101时,先Ping通PLC的IP,再在PLC软件里确认节点号,最后检查报文里的DA1是否等于这个号,三步走基本能定位。
4.2 DM区字符串高低字:PLC看着正常,上位机读出来全是乱码
这个坑我在现场排查了整整一个下午。PLC里监控DM区的数据,字符串内容完全正确,但上位机读出来全是乱码。当时第一反应是编码不对,试了ASCII、GB2312、UTF-8都不行,最后静下心把原始字节打出来才发现,问题出在欧姆龙的内存存储方式上。
欧姆龙的一个字是16位,存ASCII字符串时,两个字符放进一个字里,而且低字节在前。比如上位机读到41 42,PLC监控里显示这个字的值为0x4241。如果按顺序读串,字符串是“AB”,直接按字节流转ASCII没问题;但如果按寄存器值拼字符串,就会变成“BA”。这个反直觉的地方特别容易埋雷。
双字整数也有类似的坑。32位数据占用两个连续字地址,低字放在低位地址,高字放在高位地址,组合Int32时要注意低字在前。我在代码里写了一个通用的数据解析工具类,按点位表里配置的类型把原始字节拼成对应的数值,从此再没被这个问题坑过。
4.3 偶发超时和连接断开:两个最容易忽略的外部因素
有一次客户现场反馈,产线运行一段时间后上位机就开始读不到数据,重启软件又好一阵子。排查到最后,发现是工控机的防火墙把长时间空闲的TCP连接断掉了。欧姆龙PLC那边的以太网单元也会在一段时间无数据后主动释放连接,所以长连接必须用心跳或者定期读写来维持。
还有一次偶发超时,是因为现场交换机端口协商成了半双工,丢包和冲突特别严重。上位机Ping PLC的IP一直是通的,但FINS请求经常超时。后来换了个交换机端口,强制千兆全双工,问题消失。这种网络物理层面问题,用抓包工具看会发现大量重传,抓包记录比代码日志更直观。
4.4 其它经典问题与处理清单
我再列几个短期内不会再忘的坑,你照着排能省很多时间:
- PLC处于停止或编程模式时,部分型号会对FINS读写无响应或直接超时,先确认PLC状态。
- 写DM区时数据长度和实际字节数对不上,PLC会报长度错误。
- FINS/TCP的报文长度字段是大端,C#里用
BitConverter.GetBytes前记得反转字节序。 - 请求帧和响应帧的SID必须一致,我见过有人复制请求模板没改SID,导致多线程对不上响应。
- PLC扫描周期很长时,通信请求响应会变慢,先优化PLC程序逻辑,比提高上位机请求频率更有效。
5. 让通信代码在产线上稳定跑下去的工程习惯
5.1 点表配置化:把地址和类型从代码里挪出去
写死地址是初做上位机最容易犯的错误。今天客户说把DM200改成DM300,你得改代码、重新编译、部署,万一漏了一个引用,现场就等着发呆。我现在所有项目都做点表配置化,用CSV或JSON维护一份点位文件,每一行包含名称、内存区、字地址、位号、数据类型、读写属性、轮询组。
| 名称 | 内存区 | 字地址 | 位号 | 数据类型 | 读写属性 |
|---|---|---|---|---|---|
| 一号工位温度 | DM | 100 | 0 | UInt16 | 读 |
| 设备启停状态 | CIO | 200 | 1 | Bit | 读 |
| 配方号下发 | DM | 300 | 0 | Int16 | 写 |
程序启动时加载点表,按轮询组生成读取计划,按数据类型映射解析函数。这样现场改点表只需要改配置文件,不用动代码。后期维护成本直线下降,项目交接给同事时,对方也不用在代码里翻地址对应关系。
5.2 留痕:每一个请求响应都值得记录
排查通信问题时,原始报文比任何文本日志都更有说服力。我在通信模块里统一记录时间戳、请求帧hex、响应帧hex、响应码和通信耗时,输出到日志文件。乱码问题靠日志里的原始hex一眼就能定位字节序;偶发超时靠耗时统计能看出是不是特定时期才出现;响应码变化也能从日志里找出规律。这个习惯看起来多写几行日志,实际在产线救了我无数次。
对日志文件我会设置按天滚动,保留30天,这就够覆盖大多数排查场景。注意不要在日志里记业务敏感数据,只要原帧和结果,避免数据合规风险。
5.3 通信看护与安全
通信代码跑在产线上,不能只靠单次请求成功就乐观。我会加三重看护:连续失败超过5次置通信故障状态,界面上明确提示;心跳丢失时自动重连并恢复上次读取点位;写操作增加确认弹窗或者二次握手逻辑。写命令一旦发错,比如把配方号写到一个只读区,可能会导致设备误动作,所以写操作的工程化必须谨慎。
PLC的Stop/Run状态也要联动。如果PLC在Stop状态,上位机读到的数据可能不是实时的,写操作更不应该执行。我通常通过读取PLC的CPU状态字来判断运行状态,在状态异常时自动暂停所有写操作。
5.4 多PLC并发:一个连接一套状态,别共用Socket
现场如果有多台PLC,每台PLC要单独建一个FINS/TCP连接,连接对象里各自维护SID、节点号、发送锁。千万不要把所有PLC的请求共用同一个Socket去发,因为响应包回来时你没法判断是哪台PLC的,而且一台PLC掉线会导致整个连接异常。
每台PLC的连接独立之后,还需要各自的看门狗和重连逻辑。产线上一台PLC断电重启,上位机要在几秒内自动恢复连接,其他PLC不受影响。这个架构做到位,整个通信系统才真正具备产线级别的稳定性。
最后分享一个我自己的习惯:每次做欧姆龙项目,拿到PLC的IP和节点号之后,我第一件事不是写代码,而是先打开TCP调试助手,把一条读DM区的报文原样发过去,看到00 00响应再开始动工。通信协议这东西真没什么玄学,全是格式、字节序和边界条件的细节,把每一步验证扎实了,产线上那些“偶发超时”和“读出来是乱码”基本都能提前消灭。