简介:《小型可编程序控制器通信技术的问答(六)》是一份面向通信技术、通信工程及工业自动化技术开发人员的问答式技术参考文档,聚焦欧姆龙小型PLC在实际应用中的通信疑难。内容围绕P型机、CPM型机、CQ1V/H型机、CJ1V型机四大产品分类与结构特性展开,并延伸到程序解密操作、老型号C40P借助3G2C7-u适配器读取程序、手持编程器与机型对应关系,以及SSS、CPT、CX-PROGRAMMER三类编程软件的适用范围等具体问题,逐条给出结论性解答,便于工程师按问题索引快速定位答案。压缩包内仅1个PDF文件,约103KB,篇幅紧凑、便于随取随查,适合作为日常调试与选型时的案头参考。目前已有47人学习下载,可作为通信协议调试、设备兼容性处理与编程软件选型的辅助材料。
1. 小型可编程序控制器通信到底卡在哪:从一根 RS-485 线说起
三台小型 PLC 加一块触摸屏,一条两芯屏蔽线手拉手串起来,白天在车间跑得好好的,晚上回到实验室换根线就整条链路超时。这是小型可编程序控制器通信里最常见的翻车方式:程序没改,接线没改,改的只是线材或者上电顺序。小型 PLC 的通信口大多是复用设计,同一个 DB9 或端子排既当编程口又当 Modbus 从站,波特率、数据位、校验位、站号往往塞在系统寄存器或特殊数据寄存器里,任何一项对不上,报文就被静默丢弃,连个报错都不给。接下来把 RS-485 物理层、Modbus RTU 帧结构、MBAP 封装、字节序和轮询节拍这些手册上分散、现场必须合起来用的东西,按能复现的顺序讲一遍。
2. 小型可编程序控制器通信链路怎么搭:RS-485 接线、终端电阻与协议选型
2.1 两线制 RS-485 的接线顺序与终端电阻
RS-485 是差分总线,A 接 A、B 接 B,所有节点手拉手串下去,不能接成星型,也不能在中间随便甩一根支线出来再接设备。星型接法会让每个分支末端产生反射,短距离时看不出来,波特率上到 38400 以上就开始随机丢帧,表现为"大部分时候好、偶尔超时",这种间歇故障最耗时间。
终端电阻只放两只,分别挂在总线物理上最远的两个节点,阻值 120Ω,与线缆特性阻抗匹配。中间节点一律不加。很多人图省事在每个节点都焊一只,结果总线等效负载掉到几十欧,驱动能力弱的从站直接拉不动,通信距离反而缩短。
屏蔽层单端接地,一般接在控制柜侧的 PE 上,另一头悬空,避免两端接地形成地环路。跨柜、跨楼层、旁边有变频器或伺服驱动器的场合,用带隔离的收发器或加隔离中继器,别指望普通非隔离口硬扛。
提示:接线完成后先用万用表量一下 A、B 之间的静态差分电压,空闲状态下应当有一个几百毫伏的偏置电压,接近 0V 说明偏置电阻缺失或收发器未使能,先解决这个再调软件。
波特率与距离是一组此消彼长的关系,下面的经验值可以当作选型起点,实际还要看线材和现场干扰。
| 波特率 | 最大距离(经验值) | 典型场景 |
|---|---|---|
| 9600 | 1200 m | 老式仪表、长距离温度采集 |
| 19200 | 900 m | 车间级 PLC 互联 |
| 38400 | 600 m | 中短距离多从站轮询 |
| 57600 | 400 m | 柜内设备互联 |
| 115200 | 100~200 m | 高速采集、短距离 |
2.2 小型 PLC 通信口的三种角色:编程口、自由口、协议口
同样是 RS-485 端子,扮演的角色不一样,配置方式也完全不同,先分清楚再动手。
第一种是纯编程口,只负责下载程序和监控,不支持用户自定义报文,比如一些经济型机型的 MINI-DIN 口。第二种是自由口,也叫无协议通信,PLC 把串口交给用户程序控制,收发什么字节完全由程序决定,三菱 FX 系列的 RS、RS2 指令,西门子 S7-200 SMART 的 XMT、RCV,台达 DVP 的 MODRD、MODWR 都属于这一类。自由口适合对接非标设备、扫码枪、称重仪表这类协议很土的场景。第三种是协议口,PLC 内部固化了 Modbus RTU 从站或主站,用户只要设站号、波特率、数据格式和映射区起始地址,剩下的帧解析由固件完成。
选型上有个简单判断:对端设备认 Modbus,就用协议口,别自己造帧;对端设备是私有协议且报文简单,用自由口;对端设备是另一台 PLC 且两侧都支持 Modbus,优先 Modbus,跨品牌时它是最大公约数。
串口参数在任何平台都得逐个对齐,下面是 Linux 下用 stty 直接设串口的方式,适合在没有 Python 环境的工控机上快速验证。
# 配置 ttyUSB0:9600 波特率,8 数据位,偶校验,1 停止位,无流控 stty -F /dev/ttyUSB0 9600 cs8 parenb -parodd -cstopb -crtscts raw -echo # 查看当前参数,确认与 PLC 侧系统寄存器一致 stty -F /dev/ttyUSB0 -aparenb打开校验,-parodd表示偶校验,-cstopb表示 1 位停止位,raw关掉终端的行编辑和特殊字符处理,否则0x0D、0x11这类字节会被终端吃掉,帧长对不上。参数设完必须和 PLC 侧的通信格式寄存器逐位比对,别只看波特率。
2.3 协议选型对照:什么时候用 Modbus RTU,什么时候上以太网
| 协议 | 适用场景 | 优点 | 容易踩的坑 |
|---|---|---|---|
| Modbus RTU | 仪表、变频器、多台小型 PLC 互联 | 帧结构简单,几乎所有设备都支持 | 半双工,必须由主站统一调度,冲突就丢帧 |
| 自由口 | 非标设备、私有报文 | 完全可控 | 断帧判断、校验、超时全得自己写 |
| Modbus TCP | 上位机、SCADA、跨网段 | 无主从电气冲突,并发读写方便 | 单元 ID 在网关场景下含义会变 |
| 厂商专有协议 | 同品牌设备互联、需要读写系统区 | 功能全、效率高 | 绑定品牌,跨型号兼容性要查手册 |
现场常见的组合是:底层用 Modbus RTU 把仪表和变频器串起来,小型 PLC 做 RTU 主站,再通过以太网口对上位机提供 Modbus TCP。这样两层协议各管一段,排查问题时也能快速定位是串口侧还是网口侧。
3. Modbus RTU 报文怎么造:小型 PLC 作从站时的寄存器映射与 CRC 校验
3.1 RTU 帧结构与 3.5 字符间隔的时间账
一帧 Modbus RTU 由三部分组成:1 字节从站地址、1 到 252 字节的 PDU(功能码加数据)、2 字节 CRC16 校验,低字节在前。帧与帧之间靠至少 3.5 个字符时间的总线空闲来分隔,这就是所谓的 3.5 字符间隔。
算一下这个时间:9600bps 下,一个字符按 11 位算(1 起始位、8 数据位、1 校验位、1 停止位),约 1.146ms,3.5 个字符约 4ms;到 115200bps 时只剩约 0.33ms。PC 侧的 USB 转串口芯片本身有毫秒级抖动,靠软件掐 3.5 字符几乎不可能准。稳妥做法是:发完请求后按响应长度整块读,用读超时兜底,而不是指望帧间隔把包分开。很多"偶尔多读一个字节"的问题都出在这里。
3.2 功能码与寄存器地址换算:40001 到底对应哪个协议地址
Modbus 的地址体系分四块,文档里写的 00001、10001、30001、40001 是给人看的"参考号",协议里填的是从 0 开始的偏移量,两者差 1。
| 功能码 | 操作 | 数据区 | 参考号范围 | 协议地址 |
|---|---|---|---|---|
| 01 | 读线圈 | 可读写位 | 00001~09999 | 0x0000 起 |
| 02 | 读离散输入 | 只读位 | 10001~19999 | 0x0000 起 |
| 03 | 读保持寄存器 | 可读写字 | 40001~49999 | 0x0000 起 |
| 04 | 读输入寄存器 | 只读字 | 30001~39999 | 0x0000 起 |
| 05 | 写单个线圈 | 位 | 00001 起 | 0x0000 起 |
| 06 | 写单个寄存器 | 字 | 40001 起 | 0x0000 起 |
| 0F | 写多个线圈 | 位 | 00001 起 | 0x0000 起 |
| 10 | 写多个寄存器 | 字 | 40001 起 | 0x0000 起 |
换算规则就一句:参考号减 1。文档写 40001,协议里填 0;写 40100,协议里填 99。这一步错了,PLC 不会报错,只会返回非法地址异常码 0x02,或者更糟——返回了另一段无关寄存器的值。
3.3 手写一帧读保持寄存器的完整代码
下面这段用 pyserial 手工组帧,不依赖任何 Modbus 库,适合嵌入式网关或需要精确控制收发时序的场景。
import struct, time, serial def crc16_modbus(data: bytes) -> bytes: """标准 Modbus CRC16,多项式 0xA001,初值 0xFFFF""" crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return bytes([crc & 0xFF, (crc >> 8) & 0xFF]) # 低字节在前 def read_holding(ser, slave, addr, count): """功能码 03:读保持寄存器,返回寄存器值列表""" req = struct.pack('>BBHH', slave, 0x03, addr, count) req += crc16_modbus(req) ser.reset_input_buffer() ser.write(req) head = ser.read(3) # 地址 + 功能码 + 字节数 if len(head) < 3: raise TimeoutError('no response') if head[1] & 0x80: # 异常响应:功能码最高位置 1 raise RuntimeError(f'modbus exception 0x{head[2]:02X}') n = head[2] body = ser.read(n + 2) # 数据 + CRC if len(body) < n + 2: raise TimeoutError('short frame') if crc16_modbus(head + body[:-2]) != body[-2:]: raise ValueError('crc mismatch') return list(struct.unpack('>' + 'H' * (n // 2), body[:-2])) ser = serial.Serial('/dev/ttyUSB0', 9600, timeout=0.5, parity=serial.PARITY_EVEN) print(read_holding(ser, slave=1, addr=0, count=4))几个关键点:请求帧用>BBHH大端打包,addr是已经减过 1 的协议地址;读响应先拿 3 个字节的头,从中取字节数决定后续长度,这样不用猜整帧长度;功能码最高位是异常标志,置位时第三字节是异常码;校验算的是"地址 + 功能码 + 数据",不含 CRC 自身。
3.4 PLC 侧从站配置:站号与映射区初始化
西门子 S7-200 SMART 用MBUS_INIT初始化从站,参数依次是模式(1 为从站)、从站地址、波特率编码、校验编码、端口号、延时、最大保持寄存器数、最大输入寄存器数、映射区起始地址。这些参数只在首次扫描时执行一次,之后每周期调用MBUS_SLAVE处理请求。三菱 FX 系列则把通信格式打包进 D8120,站号放进 D8121,改完必须断电重启才生效,热改不生效是新手最常抱怨的一条。台达 DVP 系列用 M1120 之类的特殊继电器切换通信模式,主站用 MODRD、MODWR 指令。
配完后有个必做动作:用串口助手手工发一帧01 03 00 00 00 02 C4 0B,看 PLC 是否回01 03 04 xx xx xx xx CRC。回了说明从站正常,不回就退回上一节查物理层和参数,别在程序里绕。
4. 小型可编程序控制器以太网通信怎么落地:MBAP 封装与上/下位机数据映射
4.1 Modbus TCP 的 MBAP 头与 RTU 的差异
以太网侧不是简单地把 RTU 帧塞进 TCP 包,前面多了 7 字节的 MBAP 头,后面去掉了 CRC,校验交给 TCP 层。
| 字段 | 长度 | 说明 |
|---|---|---|
| 事务标识 | 2 字节 | 请求和响应必须一致,用于异步匹配 |
| 协议标识 | 2 字节 | 固定 0x0000 |
| 长度 | 2 字节 | 后续字节数,含单元 ID |
| 单元 ID | 1 字节 | 网关后面对应从站地址,直连 PLC 时通常填 1 或 255 |
端口固定 502。因为 TCP 是流式的,一次recv拿到的可能是半帧,也可能是两帧粘在一起,所以包装函数必须按 MBAP 里的长度字段循环读满,不能靠单次 recv 的长度判断。
4.2 用 pymodbus 快速读写与单元 ID 的取值
库版本不同,参数名在 unit 和 slave 上有变化,报TypeError: unexpected keyword argument时按实际签名改关键字即可,不用怀疑网络。
from pymodbus.client import ModbusTcpClient client = ModbusTcpClient('192.168.1.10', port=502, timeout=1.0) client.connect() rr = client.read_holding_registers(address=0, count=4, slave=1) if not rr.isError(): print(rr.registers) # [100, 200, 300, 400] wr = client.write_registers(address=10, values=[1234], slave=1) print(wr.isError()) client.close()address同样是协议地址,不是 40001。slave在直连场景下填 PLC 的站号,经串口网关接入时填网关映射的从站号。写多个寄存器时注意单帧最多 123 个寄存器,超了要拆帧,否则从站直接返回异常码 0x03。
4.3 32 位数据的字节序与字序:ABCD、CDAB、BADC、DCBA
一个浮点数占两个寄存器,但 Modbus 协议只规定寄存器内部是大端,没规定两个寄存器谁在前谁在后,于是就有了四种排列。现场遇到的绝大多数是 ABCD 和 CDAB 两种。
import struct def from_regs(regs, order='ABCD'): """把两个寄存器拼成 float,order 指定字节和字序""" a, b = regs[0], regs[1] raw = { 'ABCD': struct.pack('>HH', a, b), 'CDAB': struct.pack('>HH', b, a), 'BADC': struct.pack('>HH', ((a & 0xFF) << 8) | (a >> 8), ((b & 0xFF) << 8) | (b >> 8)), 'DCBA': struct.pack('>HH', ((b & 0xFF) << 8) | (b >> 8), ((a & 0xFF) << 8) | (a >> 8)), }[order] return struct.unpack('>f', raw)[0] print(from_regs([0x4246, 0x0000], 'ABCD')) # 49.5 print(from_regs([0x0000, 0x4246], 'CDAB')) # 49.5判断方法很实在:给 PLC 侧强制写一个已知值,比如 49.5,读上来两个寄存器,看 0x4246 落在哪一个、高低字节怎么排,一次就能定下来。别靠猜,猜错的表现是数据有时看着像、有时差几十倍。
4.4 手搓一帧 Modbus TCP 验证链路
不装库的时候,用 socket 直接发一帧也能验证。
import socket, struct def read_tcp(ip, unit, addr, count, tid=1): pdu = struct.pack('>BHH', 0x03, addr, count) mbap = struct.pack('>HHHB', tid, 0x0000, len(pdu) + 1, unit) with socket.create_connection((ip, 502), timeout=1.0) as s: s.sendall(mbap + pdu) head = s.recv(7) # 先收 MBAP length = struct.unpack('>H', head[4:6])[0] body = b'' while len(body) < length - 1: # 按长度收满 chunk = s.recv(length - 1 - len(body)) if not chunk: break body += chunk byte_count = body[1] return list(struct.unpack('>' + 'H' * (byte_count // 2), body[2:2 + byte_count])) print(read_tcp('192.168.1.10', 1, 0, 4))tid是事务号,每次请求递增,收到响应后比对,用于在并发场景下把响应和请求配对。循环读满的那几行不能省,工控现场交换机上还挂着别的广播流量时,单次 recv 拿到部分帧是常态。
5. 通信排错与轮询节拍调优:超时、重试、丢帧怎么定位
排错按层往下走,别一上来就改程序。第一步物理层,量 A、B 差分静态电压,示波器看空闲电平和发送时的波形幅度,波形塌陷或幅度不足说明负载过重或线太长。第二步参数层,波特率、数据位、校验位、停止位、站号五项逐一对齐,PLC 侧改了通信格式寄存器记得断电重启。第三步帧层,用串口助手直接发裸帧,看请求有没有发出去、从站回不回、回的字节是不是完整,这一步能把"到底谁的问题"锁死。第四步应用层,查地址偏移是否减 1、字节序是否配对、数据区是否越界。
| 现象 | 大概率原因 | 处理 |
|---|---|---|
| 完全无响应 | 站号错、A/B 反接、参数不匹配 | 量差分电压,用串口助手发裸帧 |
| 偶发超时 | 星型接线、无终端电阻、轮询过密 | 改手拉手,两端加 120Ω,拉长间隔 |
| 返回异常码 0x02 | 地址越界或未减 1 | 核对参考号与协议地址换算 |
| 数据值离谱 | 字节序或字序不匹配 | 写已知值反推排列 |
| 长帧被截断 | 按 3.5 字符断帧、串口缓冲不足 | 按长度整块读,加读超时兜底 |
轮询节拍上,单次超时设 300~500ms 比设 50ms 更实用,因为从站的响应时间本身就有几十毫秒,超时太短会把正常响应误判为失败,反而触发无意义的重试,把总线占用率推高。重试两次仍失败就把该从站标记为离线,跳过它继续轮询其他站,避免一个掉线设备拖垮整条链路。
def poll_with_retry(ser, slave, addr, count, retries=2, gap=0.1): """带重试的轮询:失败不阻塞整条链路,返回 None 表示该站离线""" for attempt in range(retries + 1): try: return read_holding(ser, slave, addr, count) except (TimeoutError, ValueError): if attempt == retries: return None time.sleep(gap) # 重试前留出总线空闲,避免和下一帧撞车把相邻的寄存器合并成一次批量读,是降低总线占用最有效的一招:与其每 100ms 读一个寄存器,不如把同一从站的 10 个相邻寄存器一次读完,帧数降到十分之一,节拍反而更准时。最后补一句经验——调试阶段把每次收发的原始十六进制字节打进日志文件,格式按时间戳、方向、字节流三列输出,出问题时翻日志比在现场反复上电快得多。
本文还有配套的精品资源,点击获取