news 2026/9/17 6:07:24

小型PLC通信全链路:RS-485、Modbus RTU/TCP与排错调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
小型PLC通信全链路:RS-485、Modbus RTU/TCP与排错调优

简介:《小型可编程序控制器通信技术的问答(六)》是一份面向通信技术、通信工程及工业自动化技术开发人员的问答式技术参考文档,聚焦欧姆龙小型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 说明偏置电阻缺失或收发器未使能,先解决这个再调软件。

波特率与距离是一组此消彼长的关系,下面的经验值可以当作选型起点,实际还要看线材和现场干扰。

波特率最大距离(经验值)典型场景
96001200 m老式仪表、长距离温度采集
19200900 m车间级 PLC 互联
38400600 m中短距离多从站轮询
57600400 m柜内设备互联
115200100~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 -a

parenb打开校验,-parodd表示偶校验,-cstopb表示 1 位停止位,raw关掉终端的行编辑和特殊字符处理,否则0x0D0x11这类字节会被终端吃掉,帧长对不上。参数设完必须和 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~099990x0000 起
02读离散输入只读位10001~199990x0000 起
03读保持寄存器可读写字40001~499990x0000 起
04读输入寄存器只读字30001~399990x0000 起
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
单元 ID1 字节网关后面对应从站地址,直连 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 个相邻寄存器一次读完,帧数降到十分之一,节拍反而更准时。最后补一句经验——调试阶段把每次收发的原始十六进制字节打进日志文件,格式按时间戳、方向、字节流三列输出,出问题时翻日志比在现场反复上电快得多。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 6:05:43

YOLOv8-Pose实战:从零构建实时人体姿态检测与动作计数系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 6:05:40

RAG知识库问答系统实操指南:从文档解析到检索生成的完整链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 6:04:18

RK3568多屏显示开发:从DRM原子提交到Qt Wayland全链路实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 6:03:59

从零啃透12种工控协议:学习路径、抓包调试与避坑实践

1. 项目概述与整体思路1.1 为什么一个个人开发者要啃12种工控协议工控协议这玩意儿&#xff0c;说实话&#xff0c;绝大部分搞软件的人一开始是不太愿意碰的。市面上能查到的资料要么是厂商手册那种几百页的英文PDF&#xff0c;要么是论坛里零碎的帖子&#xff0c;系统性差、坑…

作者头像 李华