☰
欧姆龙PLC通信实战:HostLink与FINS协议详解与踩坑经验
2026/11/6 4:47:31 网站建设 项目流程

前阵子接手了一条包装线的上位机改造,客户要求上位机直接读欧姆龙CP1H的D区数据。我一开始觉得,PLC通信嘛,无非是组帧、发送、解析,难不到哪去。结果从串口参数到FCS校验,再到内存区地址映射,我硬是折腾了两个晚上才全部打通。现在回头看,欧姆龙的通信协议并不算复杂,但坑确实不少,很多细节手册里写得很散,不自己踩一遍根本记不住。这篇就把我在HostLink和FINS协议上踩过的坑、以及最后彻底搞明白的关键点整理出来,给正要和欧姆龙PLC打交道的人一个参考。

1. 先别急着撸代码,把欧姆龙的通信协议家族认清楚

1.1 HostLink:串口上位机最常用的老将

欧姆龙PLC最经典、也最容易接触到的协议就是HostLink,也叫上位机链接协议。它跑在RS-232C或RS-422/485串口上,格式是ASCII文本帧,帧头是@,帧尾是星号加回车。CX-Programmer通过串口给PLC下载程序时,底层用的就是HostLink协议,所以只要是个欧姆龙PLC,基本没有不支持HostLink的。

HostLink最大的优点是简单,没有复杂的包头和数据封装,一条命令就是一串字符,串口调试助手里都能直接手动发。但简单不等于好使,它有两个特别容易栽跟头的点:一个是FCS帧校验的计算范围,另一个是地址字段到底是十六进制字地址还是BCD地址。这两个点我在后面会单独展开讲。

另外要注意,HostLink协议里PLC是纯从站,上位机必须主动发命令,PLC才会响应,而且同一时刻只能处理一条命令,必须等收到上一条命令的响应后才能发下一条。如果上位机连续发了几条命令而没有等待响应,PLC会直接把后面的命令丢掉,这一点在写上位机时一定要做“同步请求-响应”的逻辑,不能像UDP那样只管发。

1.2 FINS:贯穿串口和以太网的“正统”协议

如果说HostLink是功能机时代的产物,那FINS就是体格完整、面向未来的协议。FINS的全称是Factory Interface Network Service,欧姆龙自家的工厂接口网络服务。它不绑定传输介质,既可以跑在串口上,又可以跑在以太网上,甚至可以通过Controller Link、DeviceNet这些现场总线转发,帧格式完全统一。

FINS和HostLink最大的区别在于它多了一个10字节的FINS帧头。这个帧头里包含了源网络号、源节点号、目标网络号、目标节点号这些路由信息,所以FINS天然支持多级网络转发。打个比方,HostLink像是你站在车间里直接朝对面工位喊一嗓子,FINS则是写好了收件人和寄件人的快递单,中途有中转站也知道往哪送。

正因为有这层路由信息,初学FINS的人常常会被一堆编号搞晕:网络号是多少、节点号是多少、单元号是多少,完全不知道填什么。我后面会专门解释这三个号码的关系。FINS还有一个别的好处——它区分内存区代码,读DM区和读CIO区的命令格式里有明确标识,不会因为地址撞车而读错区域。

1.3 Modbus-RTU:跨厂商兼容的“逃生通道”

很多新一代欧姆龙PLC,比如CP1H-E、NJ/NX系列的部分机型,本身就内置了Modbus-RTU从站功能,可以通过串口被支持Modbus的主站设备直接读取。如果你的上位机、HMI或者触摸屏本身不支持HostLink和FINS,但支持Modbus,那用Modbus-RTU去读取欧姆龙PLC是最省事的路。

但这里要泼一盆冷水:欧姆龙的Modbus地址映射不是你想的那么直观。比如Modbus的保持寄存器地址和PLC里D区地址之间有一个固定的偏移或映射关系,每个系列还不完全一样。千万别拿西门子或者三菱的Modbus映射经验直接套到欧姆龙上,必须先查你那个具体型号的通信手册里的Modbus地址映射表。我见过有人用Modbus地址40001去读D0,结果读回来的是CIO一个莫名其妙的值,原因就是地址映射没查手册。

所以做方案选型时我的建议是:如果只是自家上位机读欧姆龙PLC,优先用FINS/TCP或者HostLink,这两种协议是欧姆龙的“母语”,地址映射最自然;如果有第三方设备来读,才考虑Modbus-RTU,并且一定要把型号手册里的映射表打印出来放桌上。

2. HostLink协议拆解:帧格式与FCS校验的硬骨头

2.1 一条完整命令帧长什么样

以CP1H通过RS-232C读取D100这一个字的数据为例。要把这条命令发给PLC,完整的帧是这样的:

@00RR0064000103*

拆开看是这样几个部分:

字段内容说明
帧起始符@ (0x40)固定,HostLink每帧必须以@开头
单元号00两位ASCII,范围00-31,对应PLC的单元号,一般CPU单元是00
命令码RR大写,RR代表Read,读一个或多个字
起始地址0064四位十六进制,D100对应的字地址是0x0064
读取个数0001四位十六进制,0001表示读1个字
FCS03帧校验,两位十六进制ASCII字符
终止符* CR星号加回车,0x2A 0x0D

注意这里的地址字段,D100写的是0064,不是0100。这是当时折磨我很久的一个坑。HostLink地址字段用的是十六进制字地址,D100就是十进制100,换算成十六进制就是0064。而很多资料上写“D0100”,那是PLC软件里显示的内存区编号,不能直接当成协议地址用。你要是按0100去发,读到的其实是D256的数据,而且可能还在CIO区撞了地址而不自知。

还有命令码大小写不能搞错,RR是读,WR是写,FR是错误响应帧里的错误标识,这都是固定大写。发帧的时候@和终止符都不能省略,但是FCS的计算范围恰好又要把@和终止符排除在外,很多人恰恰在这里算错。

2.2 FCS校验的两种计算方式

FCS,全称Frame Check Sum,是HostLink协议里最让人头疼的一步。它的计算规则其实很简单:把@之后、FCS之前的所有字符逐字节异或,得到一个字节的结果,然后把结果拆成两位十六进制大写字符,作为FCS字段放到帧尾。

拿上面那条帧举例,要参与计算的是:

00RR00640001

也就是ASCII字节序列:0x30 0x30 0x52 0x52 0x30 0x30 0x36 0x34 0x30 0x30 0x30 0x31。

把它们逐个异或,过程不复杂,但手算容易眼花,我强烈建议用脚本算。举个Python示例:

def hostlink_fcs(payload: bytes) -> str: fcs = 0 for b in payload: fcs ^= b return f"{fcs:02X}" def build_read_dm(unit: int, addr: int, count: int) -> bytes: body = f"{unit:02d}RR{addr:04X}{count:04X}".encode("ascii") fcs = hostlink_fcs(body) frame = b"@" + body + fcs.encode("ascii") + b"*\r" return frame

这段代码里,build_read_dm(0, 100, 1)生成的就是@00RR0064000103*回车。FCS的常见错误有两种:一种是把@也拿来异或了,这样算出来的校验码永远不对;另一种是异或结果小于0x10时,比如结果是0x03,有人直接发ASCII字符0x03(不可见字符),而正确做法是发两个ASCII字符‘0’和‘3’,也就是0x30和0x33。响应帧的FCS计算范围完全一样,解析时先提取帧尾倒数第三个字符之前的帧体部分,再算一遍FCS做比对。

还有个小细节,有些PLC对终止符只认CR,不认LF,有的则要求CR+LF才算完整。多数串口调试软件里设置成“仅发CR”就行,如果加了LF导致PLC无响应,优先怀疑这里。

2.3 响应帧格式与错误码识别

PLC对一条HostLink命令的响应分两种:正常时返回和命令码相同的帧,比如读命令返回的就是@00RR后跟数据;出错时返回的是@00FR后跟两位十六进制错误码。很多人写上位机解析时只管“收到RR就解析数据”,一旦PLC返回错误码,帧头是FR,程序就懵了。

错误码是个好东西。比如CPU单元号写错时,PLC会返回错误码13(单元号错误);地址超范围时,返回错误码0C或19一类。具体错误码含义每个型号手册都有表,我这里只提醒一件事:上位机程序里要把FR响应当作正常业务分支来处理,不能当垃圾包丢掉。你只有把错误码解析出来,才能知道自己的帧到底哪里写错了,这是最快定位问题的手段。

3. FINS协议:从串口到以太网,一定要啃下这块硬骨头

3.1 FINS帧头:十个字节一个都不能少

FINS协议和HostLink最大的不同,就是自带10字节的“邮递面单”——FINS帧头。这10个字节依次是:

ICF、RSV、GCT、DNA、DA1、DA2、SNA、SA1、SA2、SID

  • ICF:命令控制位,一般是0x80,表示这个帧需要PLC返回响应;如果你把这一位设成0,那就是“发完不管”,PLC不会回复,很多初学者在这儿踩坑,收不到数据第一反应是网络不通,其实是你自己没要回应。
  • RSV:保留字节,固定0x00。
  • GCT:网关计数,通常是0x02,如果一个FINS帧要通过多级网络转发,那里程表会累计,但大多数直连场景下固定0x02没问题。
  • DNA和SNA:目标网络号和源网络号。直连时都是0x00。
  • DA1和SA1:目标节点号和源节点号。这是最容易被搞混的一对,后面单独说。
  • DA2和SA2:目标单元号和源单元号。单元号指的是PLC CPU单元在机架上的编号,单CPU系统就是0x00。
  • SID:服务标识,相当于一次会话的编号。请求和响应里带同一个SID,方便上位机把响应对应到具体的请求上。每发一条命令换一个SID是很好的习惯,可以避免并发时串包。

你只要把这一屏绕清楚,FINS的帧结构就掌握了一半。

3.2 内存区代码与起始地址:最容易错的两个点

FINS命令中,读内存区的命令码是0x0101,写内存区的命令码是0x0102。命令码后面紧跟着内存区代码、起始地址和长度。内存区代码是按区域区分的:

区域内存区代码(字访问)说明
CIO0x30输入输出继电器区
WR0x31工作区
HR0x32保持区
AR0x33辅助区
DM0x02数据内存区,最常用

起始地址是2字节大端序的十六进制字地址,这一点和HostLink的道理是一样的。读D100这个字,起始地址就是0x0064;如果要连续读10个字,长度字段写0x000A。

踩坑点在这里:很多人会把内存区代码和地址搞混。比如读CIO区想读CIO0,内存区代码是0x30,地址是0x0000,但有的人把地址写成了0x3000,结果PLC直接报地址超范围错误。正确的理解是:内存区代码负责“挑选区域”,起始地址负责“在这个区域里找位置”,两者是独立的两个字段。

还有一个坑是区域代码不是万能的。读CIO区的位和读CIO区的字,在FINS里用的内存区代码不一样,字访问走0x30,位访问要另查内存区代码表,我用0x30去读位数据就会读回一个按字组织的值。所以明确你要读的是位还是字,别混着来。

3.3 FINS/TCP:连接、节点登记、请求三步走

FINS跑在以太网上时,使用的TCP端口是9600,这一点基本是欧姆龙默认的。但FINS/TCP和HostLink的“一帧一应答”不同,它要先建立TCP连接,然后做一次节点数据地址登记,之后才能发正式的FINS命令。

节奏大概是这样:

  1. 上位机主动连接PLC的9600端口。
  2. 连接建立后,上位机发送一条节点登记命令,把本机的FINS节点号告诉PLC。这条命令的命令码是0x0001。
  3. PLC返回节点登记响应后,再发送像0101读内存这样的正式FINS命令。
  4. 一个TCP连接可以连续收发多条FINS命令,不用反复登记;但如果断开重连,必须重新走一遍节点登记。

这里牵扯出最容易搞混的节点号问题。PLC侧在CX-Programmer里配置IP地址时,会同时有一个FINS节点号,通常默认为IP地址的末位,比如IP是192.168.0.10,FINS节点号默认就是10(十六进制0x0A)。上位机侧的节点号要另外设一个和PLC不冲突的值,比如0x40(64)或0x60(96),不要随便填0。

我一开始没登记节点就直接发读命令,PLC没任何反应,抓包抓了半天才发现少了一步。还有一次是上位机节点号设成了和PLC一样的10,结果PLC直接拒绝服务。记住:目标节点号填PLC的FINS节点号,源节点号填上位机自己的FINS节点号,这两者不能相同。

4. 实操代码:用Python把HostLink和FINS/TCP都打通

4.1 HostLink读取D100的完整示例

不管用什么语言,HostLink通信的套路都是先算FCS,再组帧,然后串口发送并等待响应。下面是完整的Python示例,用pyserial实现:

import serial import time def hostlink_fcs(payload: bytes) -> str: fcs = 0 for b in payload: fcs ^= b return f"{fcs:02X}" def build_read_dm(unit: int, addr: int, count: int) -> bytes: body = f"{unit:02d}RR{addr:04X}{count:04X}".encode("ascii") fcs = hostlink_fcs(body) return b"@" + body + fcs.encode("ascii") + b"*\r" def read_dm(ser: serial.Serial, addr: int, count: int, unit: int = 0): cmd = build_read_dm(unit, addr, count) ser.write(cmd) resp = ser.read_until(b"\r", timeout=2) if len(resp) < 11: raise TimeoutError("PLC无响应,检查串口参数、单元号和接线") # 响应格式: @ unit cmd data fcs * CR head = resp[:4] # @00RR data_part = resp[4:-3] # 去掉FCS和*CR if data_part.startswith(b"FR"): err_code = data_part[2:4] raise RuntimeError(f"PLC返回错误响应: {err_code}") # 每个字占4个ASCII字符,Read命令返回的文字数*2字节 if len(resp[4:-3]) == count * 4: return resp # 实际解析时再转十进制 return None if __name__ == "__main__": ser = serial.Serial(port="COM3", baudrate=9600, bytesize=7, parity="E", stopbits=2, timeout=2) cmd = build_read_dm(0, 100, 1) ser.write(cmd) resp = ser.read_until(b"\r") print("发送:", cmd) print("收到:", resp)

这里串口参数我用的是7E2,也就是7数据位、偶校验、2停止位。重点提醒:欧姆龙不同型号、不同介质的串口默认参数可能不一样,有的默认8N2。别默认猜,先通过CX-Programmer的PLC设置确认当前串口格式,再改上位机串口参数。我在这上面浪费了大半个晚上,就是因为我按8N1发完全没反应,而PLC实际是7E2。

解析响应时要特别注意每个字在ASCII帧里占4个字符(2字节十六进制),比如D100里存的是0x1234,响应数据段就是“1234”。如果PLC数据是BCD格式,你还要把0x1234按BCD解释成1234,这个完全取决于PLC程序里怎么存储的,协议层不做转换。

4.2 FINS/TCP读取DM区的完整示例

FINS/TCP的Python实现比HostLink多一个节点登记的步骤。下面是一个最小可用的例子:

import socket import struct def fins_header(da1: int, sa1: int, sid: int) -> bytes: # ICF=0x80, RSV=0x00, GCT=0x02 # DNA=0x00, DA1, DA2=0x00, SNA=0x00, SA1, SA2=0x00 return bytes([0x80, 0x00, 0x02, 0x00, da1, 0x00, 0x00, sa1, 0x00, sid]) def node_reg_frame(sa1: int) -> bytes: # 节点登记:命令码0x0001 + FINS节点号1字节 + 0x0000 return b"\x00\x01" + bytes([sa1]) + b"\x00\x00" def memory_read_frame(area: int, addr: int, count: int, da1: int, sa1: int, sid: int) -> bytes: head = fins_header(da1, sa1, sid) body = struct.pack(">HBBHH", 0x0101, area, addr, count) return head + body def read_dm_tcp(plc_ip: str, plc_node: int, pc_node: int, dm_addr: int, count: int): sock = socket.create_connection((plc_ip, 9600), timeout=3) # 第一步:节点登记 sock.sendall(node_reg_frame(pc_node)) node_resp = sock.recv(1024) # 第二步:发送读命令 sid = 0x01 cmd = memory_read_frame(0x02, dm_addr, count, plc_node, pc_node, sid) sock.sendall(cmd) resp = sock.recv(1024) sock.close() if len(resp) < 14: raise RuntimeError("响应帧过短") # 前10字节FINS头,然后命令码2字节,完成码2字节,后面是数据 if resp[12:14] == b"\x00\x00": return resp[14:] else: err = resp[12:14].hex() raise RuntimeError(f"FINS错误码: {err}") if __name__ == "__main__": # PLC IP 192.168.0.10,节点号10,上位机节点号64 data = read_dm_tcp("192.168.0.10", 0x0A, 0x40, 0x0064, 1) print("D100 =", data.hex())

这段代码的关键点是FINS头里ICF写0x80表示需要响应,GCT写0x02。节点登记帧的细节在不同手册里画法有细微差别,如果连接后PLC不回任何包,优先去对照官方手册的节点登记章节。但整个“三步走”的框架是固定的,绝不会错。

收到响应后,前10字节是FINS头,紧接着的2字节是命令码0x0101,然后是2字节完成码。完成码是0x0000代表正常,其他值就是FINS错误码。数据从第14字节开始,每个字2字节大端序。解析成int的时候要自己乘256加低位,别直接按本地字节序去读。

4.3 CX-Programmer的通信配置核对清单

自己写代码之前,我强烈建议先用CX-Programmer把PLC在线连上,确认三件事:CPU单元号、串口通信参数、以太网的IP和FINS节点号。很多人跳过这一步直接写上位机程序,结果排查半天发现PLC的串口模式根本不是HostLink,而是被人在后台改成Modbus了。

用CX-Programmer在线连接时,如果走USB线,我遇到过驱动不识别的问题。欧姆龙的USB驱动要单独装,Windows有时只会识别成“未知USB设备”,这个时候去设备管理器里手动更新驱动,指定到CX-Programmer安装目录的驱动文件夹里。装好之后,设备管理器里才能看到OMRON的USB接口。

走以太网时,在工程里新建PLC要选对型号,然后在“PLC”菜单下选择“在线工作”,通信设置里填PLC的IP地址。如果连接失败,CX-Programmer会提示“不能与PLC通信”,这时候先ping一下PLC的IP,通了再看节点号和网络号是否一致。在CX-Programmer的设置里,PLC的FINS节点号和IP地址低8位是否一致并不强制,但默认是一致的,除非有人改过。

还有一个容易被忽略的点:PLC如果处于运行模式下,有的地址区是可以读不可以写的。你在上位机里发写命令,如果PLC返回错误码,先看看PLC模式是不是允许写这个区。CX-Programmer里能看到当前PLC模式,Run/Monitor/PROGRAM三个状态,有些写操作在PROGRAM模式反而更宽松。

5. 排查实录:串口没反应、报错码、数据错乱怎么办

5.1 串口没反应的五个排查方向

串口发帧后PLC完全没反应,这是最崩溃的。按下面这个顺序排查,基本能定位90%的问题。

一是看串口参数。欧姆龙HostLink默认参数经常被误传,不同PLC默认值不同,一定要以CX-Programmer里确认到的参数为准,尤其是数据位和校验位。用串口调试助手直接发,先用回环测试确认串口线没断,再确认PLC侧COM口的DIP开关或者软件设置真的在HostLink模式。

二是看单元号。帧里的单元号要和PLC的CPU单元号对上,单CPU一般是00,但是如果机架上有多单元,就要写对应的单元号。PLC收到单元号不对的帧不会响应,也不会报错,基本就是石沉大海。

三是看FCS。把帧复制出来,自己写个Python脚本重算一遍FCS,看看和你发的是不是一致。我见过很多“没反应”其实纯粹是FCS多算了@这一个字节,一改就好。

四是看终止符。* CR这两种字符一个都不能少,有的上位机在printf时把回车写成了\n(LF),PLC就是不认。

五是看接线。RS-232C的2、3脚要交叉,RS-422/485的A、B端子不能接反。这个听着低级,但现场调试时真遇到不少。

5.2 响应帧里的错误码怎么解读

当你收到PLC的响应但内容是FR开头时,千万别慌,那说明PLC已经收到你的帧了,是命令本身有问题。FR后面的两位就是错误码,比如:

错误码含义常见原因
14格式错误帧长度不对、命令码大小写错
13单元号错误帧里的单元号和PLC不匹配
0C地址超范围地址字段超出该区域实际范围
0B写保护对只读区或写保护区执行写操作
19数据错误数据长度或参数不合法

FINS协议的错误码是2字节十六进制,比如0x1101表示命令长度太长,0x1103表示命令长度和参数不匹配,0x110B表示地址超范围。收到错误码后,先看是长度问题还是地址问题,不要一头扎进网线排查。

我当时排查一个FINS错误码0x110B,整整折腾了两小时,最后发现D区地址写成了0x1000,但那个型号的PLC D区最大只有0x0FFF。地址超范围这个错误码救了我,因为一开始根本没看懂错误码含义,以为是不会编。

5.3 数据总是错乱的隐蔽原因

串口通信通了,数据也收到了,但数值总是对不上。这种情况十有八九是字节序或者进制问题。欧姆龙PLC内存区里存的数据,从协议层读出来就是一串字节,PLC程序如果写的十进制,串口读回来是十六进制的存储格式,比如PLC里D100放的是十进制1234,用HostLink读到的是“04D2”,而不是ASCII的“1234”。你要么在PLC程序里用指令转成BCD,要么在上位机里把0x04D2按十进制解释成1234。

另一个隐蔽坑是PLC程序里用到的数据格式是单精度浮点数还是整数。一个32位浮点数占两个字,如果你只读了一个字,解析出来永远是半个数据。遇到D区数据怎么都对不上时,先在CX-Programmer的在线内存视图里看这个地址的原始值,确认它到底是什么类型、多少位,再回头改上位机的读取长度和解析方式。

字节序问题也很经典。FINS读回来的一个16位字是高位在前,也就是大端序,比如0x04D2在字节流里是0x04, 0xD2。如果你按PC本机的小端序去解析,就变成0xD204,数值差了十万八千里。我在C#里用BitConverter.ToInt16踩过这个坑,后来改成手动拼字节才解决。

6. 踩坑心得与工程落地建议

6.1 通信健壮性比“能通”更重要

我见过很多人用串口助手手动发了几个帧,能读到数据就算完事了。但工程现场的PLC通信,上位机程序要长期稳定运行,需要考虑通信超时、响应校验、错误重试和异常恢复。

上位机发一条指令后,PLC没有响应是常态,尤其是有干扰或者PLC负担大的时候。超时时间不要设得太短,串口300ms到500ms比较合适,以太网1秒左右。超时后要先重发两三次,连续失败再报故障,不要一失败就报警,否则半夜误报能把人折腾死。

响应帧一定要做FCS校验,不能只检查帧头和帧尾。我有一段时间收到的数据偶尔错乱,后来发现是RS-485布线没做终端电阻,波形反射导致数据位错误。如果没有FCS校验,错数据就会直接进数据库,很难发现。

6.2 建议把协议文档里的表打印出来

现在大家电脑里都存着PDF手册,但真到调试时,电子文档翻起来效率很低。我的习惯是把每个型号通信手册里的三张表打印出来贴在现场:内存区域地址映射表、FINS命令码和区域代码表、错误码表。排查问题时直接抬头看纸,比逐个标签页切换快太多。

特别是地址映射表,每个PLC型号的CIO、WR、HR、DM以及特殊继电器区地址范围都不一样,光靠记忆一定会出问题。打印出来还有一个好处:写上位机程序时,对照着表逐条核对,不容易把D地址和CIO地址写串。

6.3 如果让我重新来一次,我会先做这三件事

第一件事,先用CX-Programmer在线连上PLC,把串口格式、节点号、单元号全部确认并截图存档。这一步把所有“猜”的环节都消掉了。

第二件事,用脚本或工具先手动发一条最简单的读命令,比如读D0,逐步看响应,确认FCS和地址映射没问题后,再扩展到多字读取、写操作、批量循环。

第三件事,把上位机的通信模块单独封装成一个类或者独立服务,输入输出用统一的约定,比如地址统一用“D100”这样的字符串,底层负责转成不同协议的地址。这样即使后面把HostLink换成FINS/TCP,上位机业务层一句都不用改。我现在的项目里,通信层就是按这个思路写的,后面帮客户兼容了另一台不同协议的PLC,只改了底层适配,业务逻辑完全没动。

欧姆龙PLC通信这件事,说难不难,说简单也不简单。绝大多数坑都集中在帧格式细节和参数确认上,把这两块啃下来,剩下的无非是多写几行代码的问题。希望这篇踩坑实录能让你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询