1. 从"过时技术"到"回马枪"
你可能会觉得,在 2026 年聊串口通信,就像在智能手机发布会上教人用传呼机。我在 2021 年调试一条光伏硅片产线时就掉进了这个思维陷阱:客户要求采集 60 台切割机的温度、振动和主轴转速,我们按惯例统一部署了 Modbus TCP 转 Ethernet 方案。入场后发现现场只有一根 RS-485 总线已经跑了好几年------这台切割机是 2015 年的老机型,根本没有网口。
这不是个例。当我翻看现场设备的通信接口统计时发现:
| 设备类型 | 串口(RS-232/485) | 以太网口 | OPC UA 支持 |
|---|---|---|---|
| 变频器 | 85% | 15% | < 5% |
| 温控表 | 95% | 5% | 0% |
| 智能仪表 | 70% | 30% | < 10% |
| PLC(2015 年前) | 90% | 10% | 0% |
| PLC(2020 年后) | 30% | 70% | 40% |
这些数字说明:串口不是过时了,而是退到了现场总线的最底层------传感器和执行器层。你绕不过它。
而且串口有一种 Ethernet 永远做不到的优势:确定性。没有 TCP 重传、没有交换机缓存、没有碰撞检测。一条 RS-485 总线上的时序是完全可以数学计算的------这恰恰是 Modbus RTU 至今仍在工业实时控制领域不可替代的原因。
2. RS-232 vs RS-485------物理层的战争
很多人把 RS-232 和 RS-485 混为一谈,实际上它们是两种完全不同的电气接口,只是共用同一套软件协议(Modbus RTU)而已。
| 特性 | RS-232 | RS-485 |
|---|---|---|
| 传输方式 | 单端(不平衡) | 差分(平衡) |
| 最大距离 | ~15m(@9600 bps) | ~1200m(@9600 bps) |
| 最大节点数 | 1 发 1 收 | 32 单元负载(可扩展到 256) |
| 信号线 | TX, RX, GND | A, B 双绞线 |
| 通信模式 | 全双工 | 半双工(通常) |
| 抗干扰 | 差 | 强(共模抑制) |
| 典型用途 | 调试口、近距离配置 | 现场总线网络 |
RS-232 的"单端"意味着信号对地电压差来决定 0/1。你如果拉一根 50 米的 RS-232 线,信号已经衰减得像蚊子叫了------工业现场的电机会在上面感应出几十伏的噪声。
RS-485 的优势在于差分传输:两根线上的电压差决定信号,共模噪声在两根线上同时出现,差值不变。这也是为什么 RS-485 能在双绞线上跑 1200 米还能保持信号完整。
个人现场故事:2018 年调试一条食品包装线,西门子 S7-200(老款)和 12 台丹佛斯变频器走 Modbus RTU。业主嫌布线麻烦,用了 3 根平行线而不是双绞线。结果只要旁边一台 11kW 电机启动,总线上就出现 CRC 错误。换成屏蔽双绞线后,同样的波特率下错误率从 12% 降到 0%。物理层从来不是"能用就行"------它决定了协议层的可靠性上限。
3. Modbus RTU 帧结构------字节级别的拆解
Modbus RTU 和 Modbus TCP 共享相同的 PDU(Protocol Data Unit),但封装方式完全不同:
Modbus RTU 帧(ADU):
+----------+----------+----------+------------+
| 地址(1B) | 功能码 | 数据(NB) | CRC(2B) |
+----------+----------+----------+------------+
Modbus TCP 帧(ADU):
+----------+----------+----------+----------+------------+----------+
| 事务ID | 协议ID | 长度 | 单元ID | 功能码 | 数据 |
| (2B) | (2B) | (2B) | (1B) | (1B) | (NB) |
+----------+----------+----------+----------+------------+----------+
核心差异------Modbus RTU 没有 TCP 的 MBAP 头,用 CRC 校验替代 TCP 的可靠传输保证。
来看一个实际读 3 个保持寄存器的请求报文(读地址 1 的 PLC,从 0x0000 起始读 3 个字):
请求: 01 03 00 00 00 03 05 CB
| | | |
| | | +-- CRC 校验(低字节在前)
| | +-------- 数据长度:3 个寄存器
| +----------- 起始地址:0x0000
+-------------- 从站地址:1
+-- 功能码:03 读保持寄存器
响应:
01 03 06 00 64 00 C8 01 2C 7A B7
| | | | |
| | | | +-- CRC 校验
| | | +-------- 寄存器 2 的值 (0x012C = 300)
| | +----------------- 寄存器 1 的值 (0x00C8 = 200)
| +-------------------- 寄存器 0 的值 (0x0064 = 100)
+----------------------- 字节数:6 字节(3 个寄存器 × 2 字节)
这里的字节序是 Big-Endian(低地址存高位),值得注意------很多工程师被 Modbus TCP 端的字节序搞混后,以为 RTU 端也小端,结果读出来的数值完全不对。
CRC 校验------不是随便算算
Modbus RTU 的 CRC 采用 CRC-16(多项式 0x8005),但有个坑:低字节在前。这意味着你算出的 16 位 CRC,要先发低 8 位再发高 8 位。很多自定义实现搞反了顺序,导致通信时好时坏。
CRC-16 的 Python 实现(查表法------比逐位计算快约 10 倍):
python
# crc_table.py --- 预计算 CRC 查找表
def build_crc_table():
"""构建 Modbus CRC-16 查找表(256 项)"""
table = []
for i in range(256):
crc = i
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001 # 多项式 0x8005 的反射形式
else:
crc >>= 1
table.append(crc)
return table
CRC_TABLE = build_crc_table()
def modbus_crc(data: bytes) -> int:
"""计算 Modbus RTU CRC-16"""
crc = 0xFFFF
for byte in data:
crc = (crc >> 8) ^ CRC_TABLE[(crc ^ byte) & 0xFF]
# 返回的是已经低字节在前的格式
return crc
为什么用查表法? 在 9600 波特率下,每字节的传输时间约 1.04ms,如果你的主站在 1 秒内轮询 20 个从站,每个从站读写 10 字节------需要在 50ms 内完成 CRC 计算(含其他开销)。逐位计算在高波特率(115200 以上)下会成为瓶颈。
4. 3.5 字符间隔------Modbus RTU 的"心跳线"
这是 Modbus RTU 中最微妙也最容易被忽视的参数。
3.5 字符间隔定义了帧的边界------Modbus RTU 不像 TCP 那样有明确的帧头帧尾标记,总线上的接收方靠"静默时间"来判断一帧是否结束。
3.5 字符时间 = 3.5 × 单个字符的传输时间
单个字符时间 = (1 起始位 + 8 数据位 + 1 校验位 + 1 停止位) / 波特率
所以:
| 波特率 | 单个字符时间 | 3.5 字符间隔 | 1.5 字符间隔 |
|---|---|---|---|
| 1200 | 9.17 ms | 32.08 ms | 13.75 ms |
| 2400 | 4.58 ms | 16.04 ms | 6.88 ms |
| 4800 | 2.29 ms | 8.02 ms | 3.44 ms |
| 9600 | 1.14 ms | 4.00 ms | 1.71 ms |
| 19200 | 0.57 ms | 2.00 ms | 0.86 ms |
| 38400 | 0.29 ms | 1.00 ms | 0.43 ms |
| 115200 | 0.10 ms | 0.33 ms | 0.14 ms |
坑 1:固定写死 4ms
很多工程师在代码里写死 time.sleep(0.004) 作为帧间隔。这在 9600 波特率下碰巧是对的,但换到 19200 时就白白浪费了一半的带宽。我见过一个项目用 38400 波特率接 31 个从站,就因为写死了 4ms 间隔,实际轮询周期比设计大了 2.6 倍。
坑 2:操作系统调度延迟
Windows 非实时系统的 time.sleep(1) 精度只有约 15ms。在 115200 波特率下,3.5 字符间隔仅 0.33ms------操作系统的调度抖动就超过这个值。这意味着串口接收线程可能还没处理完上一帧,下一帧已经来了。
python
# 正确计算帧间隔
def calc_interframe_delay(baudrate: int) -> float:
"""
计算 Modbus RTU 的 3.5 字符间隔
假设 8N1 格式:1 起始 + 8 数据 + 1 停止 = 10 位
"""
bit_time = 1.0 / baudrate # 每比特时间(秒)
char_time = bit_time * 10 # 每字符时间(10 位)
return 3.5 * char_time # 3.5 字符间隔
坑 3:1.5 字符间隔的作用
很多人只知道 3.5 间隔,不知道还有一个 1.5 字符间隔。实际上:
- 3.5 字符间隔 :判断一帧开始(总线空闲超过此时间 = 新帧)
- 1.5 字符间隔 :判断一帧内部(接收过程中字节间隔超过此值 = 帧异常)
简单说:接收机检测到总线静默超过 3.5 字符,认为"上一帧结束,准备接收新帧"。接收一帧过程中,如果两个字节间隔超过 1.5 字符,认为"接收异常,丢弃此帧"。
python
# 帧超时检测思路
class ModbusRTUFrameTimeout:
def __init__(self, baudrate: int):
bit_time = 1.0 / baudrate
char_time = bit_time * 10
self.frame_gap = 3.5 * char_time # 帧间隔
self.char_gap = 1.5 * char_time # 字符间超时
def is_new_frame(self, silence_ms: float) -> bool:
"""检测是否为新帧开始"""
return silence_ms >= self.frame_gap * 1000
def is_frame_aborted(self, gap_ms: float) -> bool:
"""检测帧内是否超时"""
return gap_ms >= self.char_gap * 1000
5. RS-485 的方向切换------半双工的"换向税"
RS-485 标准上是半双工的(虽然理论上可以全双工,但工业上绝大多数是半双工)。这意味着收发不能同时进行。
从站 2 从站 1 主站(Master) 从站 2 从站 1 主站(Master) #mermaid-svg-47n8AnQImjVKGSMy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-47n8AnQImjVKGSMy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-47n8AnQImjVKGSMy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-47n8AnQImjVKGSMy .error-icon{fill:#552222;}#mermaid-svg-47n8AnQImjVKGSMy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-47n8AnQImjVKGSMy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-47n8AnQImjVKGSMy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-47n8AnQImjVKGSMy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-47n8AnQImjVKGSMy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-47n8AnQImjVKGSMy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-47n8AnQImjVKGSMy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-47n8AnQImjVKGSMy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-47n8AnQImjVKGSMy .marker.cross{stroke:#333333;}#mermaid-svg-47n8AnQImjVKGSMy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-47n8AnQImjVKGSMy p{margin:0;}#mermaid-svg-47n8AnQImjVKGSMy .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-47n8AnQImjVKGSMy text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-47n8AnQImjVKGSMy .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-47n8AnQImjVKGSMy .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-47n8AnQImjVKGSMy .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-47n8AnQImjVKGSMy .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-47n8AnQImjVKGSMy #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-47n8AnQImjVKGSMy .sequenceNumber{fill:white;}#mermaid-svg-47n8AnQImjVKGSMy #sequencenumber{fill:#333;}#mermaid-svg-47n8AnQImjVKGSMy #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-47n8AnQImjVKGSMy .messageText{fill:#333;stroke:none;}#mermaid-svg-47n8AnQImjVKGSMy .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-47n8AnQImjVKGSMy .labelText,#mermaid-svg-47n8AnQImjVKGSMy .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-47n8AnQImjVKGSMy .loopText,#mermaid-svg-47n8AnQImjVKGSMy .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-47n8AnQImjVKGSMy .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-47n8AnQImjVKGSMy .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-47n8AnQImjVKGSMy .noteText,#mermaid-svg-47n8AnQImjVKGSMy .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-47n8AnQImjVKGSMy .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-47n8AnQImjVKGSMy .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-47n8AnQImjVKGSMy .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-47n8AnQImjVKGSMy .actorPopupMenu{position:absolute;}#mermaid-svg-47n8AnQImjVKGSMy .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-47n8AnQImjVKGSMy .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-47n8AnQImjVKGSMy .actor-man circle,#mermaid-svg-47n8AnQImjVKGSMy line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-47n8AnQImjVKGSMy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 切换为发送模式 等待 3.5 字符间隔 切换为接收模式 等待从站响应延迟 等待 3.5 字符间隔(帧结束) 切换为发送模式(下一轮) 发送查询帧(~5ms @9600)发送响应帧(~6ms @9600)发送查询帧
方向切换并非瞬时完成:
#mermaid-svg-k4nBeNaydG8BaQRp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-k4nBeNaydG8BaQRp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-k4nBeNaydG8BaQRp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-k4nBeNaydG8BaQRp .error-icon{fill:#552222;}#mermaid-svg-k4nBeNaydG8BaQRp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-k4nBeNaydG8BaQRp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-k4nBeNaydG8BaQRp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-k4nBeNaydG8BaQRp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-k4nBeNaydG8BaQRp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-k4nBeNaydG8BaQRp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-k4nBeNaydG8BaQRp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-k4nBeNaydG8BaQRp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-k4nBeNaydG8BaQRp .marker.cross{stroke:#333333;}#mermaid-svg-k4nBeNaydG8BaQRp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-k4nBeNaydG8BaQRp p{margin:0;}#mermaid-svg-k4nBeNaydG8BaQRp defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-k4nBeNaydG8BaQRp g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-k4nBeNaydG8BaQRp g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-k4nBeNaydG8BaQRp g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-k4nBeNaydG8BaQRp g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-k4nBeNaydG8BaQRp g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-k4nBeNaydG8BaQRp .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-k4nBeNaydG8BaQRp .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-k4nBeNaydG8BaQRp .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-k4nBeNaydG8BaQRp .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-k4nBeNaydG8BaQRp .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-k4nBeNaydG8BaQRp .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-k4nBeNaydG8BaQRp .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-k4nBeNaydG8BaQRp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k4nBeNaydG8BaQRp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-k4nBeNaydG8BaQRp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k4nBeNaydG8BaQRp .edgeLabel .label text{fill:#333;}#mermaid-svg-k4nBeNaydG8BaQRp .label div .edgeLabel{color:#333;}#mermaid-svg-k4nBeNaydG8BaQRp .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-k4nBeNaydG8BaQRp .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-k4nBeNaydG8BaQRp .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-k4nBeNaydG8BaQRp .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-k4nBeNaydG8BaQRp .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-k4nBeNaydG8BaQRp .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-k4nBeNaydG8BaQRp .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-k4nBeNaydG8BaQRp #statediagram-barbEnd{fill:#333333;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-k4nBeNaydG8BaQRp .cluster-label,#mermaid-svg-k4nBeNaydG8BaQRp .nodeLabel{color:#131300;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-k4nBeNaydG8BaQRp .note-edge{stroke-dasharray:5;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-note text{fill:black;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram-note .nodeLabel{color:black;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagram .edgeLabel{color:red;}#mermaid-svg-k4nBeNaydG8BaQRp #dependencyStart,#mermaid-svg-k4nBeNaydG8BaQRp #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-k4nBeNaydG8BaQRp .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-k4nBeNaydG8BaQRp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 主站要发请求
发送完毕
发送完成中断
切换完成(~100μs-1ms)
等待从站应答
收到完整帧
帧结束(3.5字符间隔)
切换完成
超时无响应
发送模式
等待结束
收发切换延迟
接收模式
等待响应
接收完成
收发切换延迟2
关键延迟:收发切换
当你调用串口 API 的 write() 后,不能立即调用 read()。即便是在 RS-485 硬件自动切换的电路上,也需要等待:
- 发送 FIFO 清空:串口芯片的发送缓冲区的数据全部移到移位寄存器发出
- 硬件方向切换:RS-485 驱动芯片的 DE/RE 引脚电平翻转时间(~100ns-10μs)
- 从站响应时间:从站收到请求到发出响应的处理时间(从站相关,通常 3-10ms)
python
import serial
import time
def rs485_read_with_delay(ser: serial.Serial, response_len: int,
baudrate: int, slave_delay: float = 0.005):
"""
RS-485 半双工读响应------正确处理方向切换
Args:
ser: 已打开的串口对象(RS-485 模式)
response_len: 期望的响应字节数(已知时)
baudrate: 当前波特率
slave_delay: 从站响应延迟(典型值 3-10ms)
"""
# Step 1: 等待发送 FIFO 清空
bit_time = 1.0 / baudrate
char_time = bit_time * 10
tx_time = response_len * char_time # 发送完成等待
time.sleep(tx_time + 0.001) # 加 1ms 安全余量
# Step 2: 等待 RS-485 收发切换(硬件自动切换时通常已包含在驱动中)
time.sleep(0.0001) # 100μs 切换时间
# Step 3: 等待从站响应
time.sleep(slave_delay)
# Step 4: 读取
if ser.in_waiting:
return ser.read(ser.in_waiting)
return b''
真实案例:某次用 USB-485 转换器(FTDI 芯片)采集 30 台仪表,数据上报周期一直不稳定。用示波器抓 RS-485 的 A/B 线电压,发现 FTDI 的收发切换延迟在 Windows 驱动下高达 2.3ms,而 Linux 下只有 0.3ms。换成带硬件自动换向的隔离 RS-485 模块后,切换延迟降到 5μs,轮询周期稳定了。
6. 波特率对采集周期的影响------数学计算
假设你需要轮询 N 个从站,每个从站读 M 个保持寄存器:
单次轮询时间 =
主站发送时间 + 3.5 字符间隔 + 从站响应延迟 +
从站响应时间 + 3.5 字符间隔 + RS-485 切换开销
用公式表示:
T_poll = N × ( T_tx_request + T_frame_gap + T_slave_delay
+ T_rx_response + T_frame_gap + T_switch )
其中:
T_tx_request= (地址 1B + 功能码 1B + 数据 4B + CRC 2B) × 10 / 波特率T_rx_response= (地址 1B + 功能码 1B + 数据(M×2+1)B + CRC 2B) × 10 / 波特率T_frame_gap= 3.5 × 10 / 波特率T_slave_delay= 从站典型的 3-10msT_switch= RS-485 切换 0.1-1ms
代入一个典型场景:轮询 20 个从站,每个读 10 个寄存器:
| 波特率 | T_tx_request | T_rx_response | T_frame_gap | 单站时间 | 20 站轮询周期 |
|---|---|---|---|---|---|
| 9600 | 8.3 ms | 27.1 ms | 3.6 ms | 52.6 ms | 1.05 s |
| 19200 | 4.2 ms | 13.5 ms | 1.8 ms | 32.1 ms | 0.64 s |
| 38400 | 2.1 ms | 6.8 ms | 0.9 ms | 21.4 ms | 0.43 s |
| 115200 | 0.7 ms | 2.3 ms | 0.3 ms | 14.9 ms | 0.30 s |
结论 :从 9600 提升到 115200,轮询周期只缩短了 3.5 倍(不是 12 倍),因为从站响应延迟(3-10ms)成为新的瓶颈。当波特率超过 38400 后,从站延迟成为制约因素,再提高波特率收益递减。
这也是为什么很多工程方案会选择"分组+多串口"------用 4 个串口各接 5 个从站,并行采集:
4 串口并行: 轮询周期 ≈ 0.43s / 4 = 0.11s(理想情况)
实际周期 ≈ 0.15s(含主站 CPU 调度开销)
7. Python 实战:完整的 Modbus RTU 主站
下面实现一个完整的 Modbus RTU 主站,支持 RS-485 自动轮询、帧超时检测、CRC 校验、错误重试:
python
"""
modbus_rtu_master.py --- 完整 Modbus RTU 主站实现
支持:多从站轮询、自动重试、超时处理、CRC 校验
依赖:pip install pyserial
"""
import serial
import serial.tools.list_ports
import time
import struct
import logging
from typing import Optional, List, Tuple
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ModbusRTU")
class ModbusRTUMaster:
"""
Modbus RTU 主站,使用 RS-485 半双工模式
"""
# CRC 查找表(预计算)
CRC_TABLE = []
@classmethod
def _init_crc_table(cls):
if cls.CRC_TABLE:
return
for i in range(256):
crc = i
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
cls.CRC_TABLE.append(crc)
def __init__(self, port: str, baudrate: int = 9600,
timeout: float = 1.0, slave_delay: float = 0.005):
self._init_crc_table()
self.baudrate = baudrate
self.slave_delay = slave_delay
# 计算时序参数
bit_time = 1.0 / baudrate
char_time = bit_time * 10 # 8N1 = 10 bits per char
self.frame_gap = 3.5 * char_time # 帧间隔
self.char_timeout = 1.5 * char_time # 帧内字符超时
# 打开串口
self.ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=timeout,
rtscts=False, # RS-485 通常使用 RTS 控制方向
)
logger.info(f"串口 {port} 已打开 @ {baudrate} bps")
def _calc_crc(self, data: bytes) -> int:
"""计算 Modbus CRC-16"""
crc = 0xFFFF
for byte in data:
crc = (crc >> 8) ^ self.CRC_TABLE[(crc ^ byte) & 0xFF]
return crc
def _build_request(self, slave_addr: int, function_code: int,
data: bytes) -> bytes:
"""组装 Modbus RTU 请求帧(含 CRC)"""
pdu = bytes([slave_addr, function_code]) + data
crc = self._calc_crc(pdu)
# CRC 低字节在前
return pdu + bytes([crc & 0xFF, (crc >> 8) & 0xFF])
def _verify_response(self, response: bytes) -> Tuple[bool, int]:
"""
验证响应帧
返回:(是否有效, 从站地址)
"""
if len(response) < 5: # 最小帧长:地址+功能码+数据+CRC
return False, 0
# 验证 CRC
received_crc = response[-2] | (response[-1] << 8)
calculated_crc = self._calc_crc(response[:-2])
if received_crc != calculated_crc:
logger.warning(f"CRC 校验失败: 收到 0x{received_crc:04X}, "
f"期望 0x{calculated_crc:04X}")
return False, 0
return True, response[0]
def _wait_frame_gap(self):
"""等待 3.5 字符间隔"""
time.sleep(self.frame_gap)
def _rs485_tx_mode(self):
"""切换 RS-485 到发送模式(RTS 高电平)"""
self.ser.rts = True
def _rs485_rx_mode(self):
"""切换 RS-485 到接收模式(RTS 低电平)"""
self.ser.rts = False
def read_holding_registers(self, slave_addr: int,
start_addr: int,
count: int,
retries: int = 3) -> Optional[List[int]]:
"""
读保持寄存器(功能码 0x03)
Args:
slave_addr: 从站地址 (1-247)
start_addr: 起始地址
count: 寄存器数量 (1-125)
retries: 失败重试次数
"""
data = struct.pack('>HH', start_addr, count)
request = self._build_request(slave_addr, 0x03, data)
for attempt in range(retries + 1):
try:
# 发送请求
self._wait_frame_gap() # 帧前间隔
self._rs485_tx_mode() # 切换到发送
self.ser.write(request)
self.ser.flush() # 等待 FIFO 清空
# 切换接收 & 等待响应
self._rs485_rx_mode()
time.sleep(self.slave_delay) # 从站处理时间
# 计算期望响应长度
# 地址(1) + 功能码(1) + 字节数(1) + 数据(2*count) + CRC(2)
expected_len = 5 + 2 * count
response = self.ser.read(expected_len)
if len(response) == 0:
logger.warning(f"从站 {slave_addr}: 无响应 "
f"(尝试 {attempt+1}/{retries+1})")
continue
if len(response) < expected_len:
logger.warning(f"从站 {slave_addr}: 响应不完整 "
f"({len(response)}/{expected_len} 字节)")
continue
# 验证帧
valid, addr = self._verify_response(response)
if not valid:
logger.warning(f"从站 {slave_addr}: 帧校验失败")
continue
# 解析数据
byte_count = response[2]
values = []
for i in range(count):
offset = 3 + i * 2
val = struct.unpack('>H', response[offset:offset+2])[0]
values.append(val)
return values
except serial.SerialException as e:
logger.error(f"串口错误: {e}")
time.sleep(0.1)
return None # 所有重试失败
def close(self):
"""关闭串口"""
if self.ser and self.ser.is_open:
self.ser.close()
logger.info("串口已关闭")
# ===== 使用示例 =====
if __name__ == "__main__":
# 初始化主站(调整端口和波特率为你的实际配置)
master = ModbusRTUMaster(
port="COM3", # Windows 端口;Linux 为 "/dev/ttyUSB0"
baudrate=9600,
timeout=1.0,
slave_delay=0.005 # 5ms 从站延迟
)
try:
# 轮询 3 个从站
slaves = [1, 2, 3]
for _ in range(10): # 采集 10 轮
for addr in slaves:
values = master.read_holding_registers(
slave_addr=addr,
start_addr=0,
count=5,
retries=2
)
if values:
print(f"从站 {addr}: {values}")
else:
print(f"从站 {addr}: 采集失败")
print("--- 轮询完成 ---")
time.sleep(0.5)
finally:
master.close()
关键参数说明
| 参数 | 推荐值 | 设置不当的后果 |
|---|---|---|
timeout |
1.0-3.0s | 过小导致频繁超时,过大阻塞轮询 |
slave_delay |
3-10ms | 过小读不到响应,过大浪费带宽 |
retries |
2-3 次 | 过少丢数据,过多阻塞总线 |
count |
1-125 | 超过 125 会被从站拒绝(Modbus 协议限制) |
8. 常见深坑与根本原因分析
深坑 1:USB-转-串口适配器的兼容性
这不是玄学。不同芯片的 USB-485 转换器在收发切换延迟上差别巨大:
| 芯片 | 切换延迟 | 实测最大从站数(@9600) | 价格区间 |
|---|---|---|---|
| FTDI FT232R | ~500μs | 25 | ¥30-80 |
| CH340G | ~2ms | 15 | ¥5-15 |
| CP2102 | ~1ms | 20 | ¥10-25 |
| ADM2483(隔离) | ~5μs | 32 | ¥80-150 |
根本原因:CH340 等低成本芯片的 RTS 切换由 USB 批量传输的完成时间决定,受 USB 帧周期(1ms)影响,有天然抖动。而带硬件自动换向的隔离芯片在硬件层面处理 DE/RE 信号,延迟稳定在微秒级。
深坑 2:波特率"虚高"
我在一个项目中发现,把波特率从 9600 提升到 115200 后,采集周期不但没缩短,反而增加了。用示波器抓 RS-485 总线发现------总线上的信号已经严重畸变了:
9600 bps 时的眼图: ████████████████████████████ (完整张开)
115200 bps 时的眼图: ████▓▓▓▓▓▓░░░░░░░██████ (接近闭合)
原因:该项目的 RS-485 线缆长达 800 米,超过了 115200 波特率下的有效传输距离(约 100 米)。信号在长线缆上的衰减和反射导致误码率激增,CRC 错误频繁触发重试,最终采集周期更长了。
经验法则:RS-485 的波特率-距离乘积约为常数 10^8 bps·m。即 9600bps × 1200m ≈ 115200bps × 100m ≈ 11.5×10^6 bps·m。超过这个乘积,信号质量无法保证。
深坑 3:共模电压导致通信间歇失败
RS-485 要求通信双方的信号地(GND)之间电压差不超过 -7V 到 +12V。但在工业现场,两台设备的地电位可能相差几十伏:
#mermaid-svg-6MmyfANdEBSio5K4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6MmyfANdEBSio5K4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6MmyfANdEBSio5K4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6MmyfANdEBSio5K4 .error-icon{fill:#552222;}#mermaid-svg-6MmyfANdEBSio5K4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6MmyfANdEBSio5K4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6MmyfANdEBSio5K4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6MmyfANdEBSio5K4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6MmyfANdEBSio5K4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6MmyfANdEBSio5K4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6MmyfANdEBSio5K4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6MmyfANdEBSio5K4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6MmyfANdEBSio5K4 .marker.cross{stroke:#333333;}#mermaid-svg-6MmyfANdEBSio5K4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6MmyfANdEBSio5K4 p{margin:0;}#mermaid-svg-6MmyfANdEBSio5K4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6MmyfANdEBSio5K4 .cluster-label text{fill:#333;}#mermaid-svg-6MmyfANdEBSio5K4 .cluster-label span{color:#333;}#mermaid-svg-6MmyfANdEBSio5K4 .cluster-label span p{background-color:transparent;}#mermaid-svg-6MmyfANdEBSio5K4 .label text,#mermaid-svg-6MmyfANdEBSio5K4 span{fill:#333;color:#333;}#mermaid-svg-6MmyfANdEBSio5K4 .node rect,#mermaid-svg-6MmyfANdEBSio5K4 .node circle,#mermaid-svg-6MmyfANdEBSio5K4 .node ellipse,#mermaid-svg-6MmyfANdEBSio5K4 .node polygon,#mermaid-svg-6MmyfANdEBSio5K4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6MmyfANdEBSio5K4 .rough-node .label text,#mermaid-svg-6MmyfANdEBSio5K4 .node .label text,#mermaid-svg-6MmyfANdEBSio5K4 .image-shape .label,#mermaid-svg-6MmyfANdEBSio5K4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-6MmyfANdEBSio5K4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6MmyfANdEBSio5K4 .rough-node .label,#mermaid-svg-6MmyfANdEBSio5K4 .node .label,#mermaid-svg-6MmyfANdEBSio5K4 .image-shape .label,#mermaid-svg-6MmyfANdEBSio5K4 .icon-shape .label{text-align:center;}#mermaid-svg-6MmyfANdEBSio5K4 .node.clickable{cursor:pointer;}#mermaid-svg-6MmyfANdEBSio5K4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6MmyfANdEBSio5K4 .arrowheadPath{fill:#333333;}#mermaid-svg-6MmyfANdEBSio5K4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6MmyfANdEBSio5K4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6MmyfANdEBSio5K4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6MmyfANdEBSio5K4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6MmyfANdEBSio5K4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6MmyfANdEBSio5K4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6MmyfANdEBSio5K4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6MmyfANdEBSio5K4 .cluster text{fill:#333;}#mermaid-svg-6MmyfANdEBSio5K4 .cluster span{color:#333;}#mermaid-svg-6MmyfANdEBSio5K4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6MmyfANdEBSio5K4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6MmyfANdEBSio5K4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-6MmyfANdEBSio5K4 .icon-shape,#mermaid-svg-6MmyfANdEBSio5K4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6MmyfANdEBSio5K4 .icon-shape p,#mermaid-svg-6MmyfANdEBSio5K4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6MmyfANdEBSio5K4 .icon-shape .label rect,#mermaid-svg-6MmyfANdEBSio5K4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6MmyfANdEBSio5K4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6MmyfANdEBSio5K4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6MmyfANdEBSio5K4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 电房B
电房A
RS-485 A线
RS-485 B线
地电位差 15V
PLC
仪表
症状:通信有时正常、有时乱码、有时完全不通。用万用表量 A/B 线间电压正常(2-5V),但量 A/GND 或 B/GND 发现偏压异常。
解决:在 RS-485 总线末端加偏置电阻(将 A 上拉到 5V,B 下拉到 GND),或在一条总线两端加终端电阻(120Ω)的同时使用隔离型 RS-485 中继器。
9. RS-485 总线故障排查检查清单
当 Modbus RTU 通信异常时,按此顺序排查:
- 万用表测 A/B 线间电压 → 正常值 1.5-5V,接近 0V 说明总线短路或没有偏置
- 示波器抓 A/B 波形 → 看眼图是否张开、信号幅值是否 >200mV
- 测 GND 电位差 → 超过 ±7V 需要加隔离
- 终端电阻 → 长线(>100m)两端需要 120Ω 终端电阻
- CRC 错误率 → 在程序里统计 CRC 失败次数 / 总帧数,超过 1% 要检查物理层
- 波特率验证 → 用示波器测最小脉冲宽度是否等于 1/波特率
- 从站地址冲突 → 确保总线上的从站地址唯一
- 3.5 字符间隔验证 → 用逻辑分析仪看帧间隔是否满足要求
10. 性能调优------把 1s 轮询压到 200ms
当采集性能不够时,不要盲目提波特率。按优先级执行:
- 合并读请求 :把多个读操作合并为一个批量读(
count尽量大)。读 1 个寄存器 20 次的效率只有读 20 个寄存器 1 次的 1/10 - 去除不必要的从站延迟安全余量 :测量实际从站响应时间,把
slave_delay从保守的 10ms 降到实测值 - 分组+多串口:一个串口接 30 个从站 vs 三个串口各接 10 个,后者轮询周期是前者的 1/3
- 改造从站:部分从站支持自定义功能码,把多个数据打包在一个响应中返回
- 最后才是加波特率:确认线缆长度和品质能支撑更高的波特率
对比总结:三种协议的链路层差异
| 维度 | Modbus RTU | Modbus TCP | OPC UA |
|---|---|---|---|
| 物理层 | RS-232/485 | Ethernet | Ethernet |
| 帧定界 | 3.5 字符静默间隔 | TCP 流天然分段 | TCP 流 + 消息块 |
| 校验机制 | CRC-16 | TCP 校验和 | TCP + 应用层签名 |
| 最大节点 | 32(典型)/ 247(理论) | 理论上无限制 | 无限制 |
| 实时性 | 确定性强(可数学计算) | 受交换机影响 | 受会话管理和安全开销影响 |
| 连接开销 | 无连接,即发即收 | TCP 握手 | 安全通道 + 会话(4 RTT) |
| 最佳场景 | 传感器/执行器级采集 | 控制器级互联 | 系统级集成 |
👉 下一篇预告:PLC 数采系列 6 MQTT 与 Sparkplug B------从车间到云端的最后一公里。 当数据出了工厂围墙走向云端,MQTT 的 pub/sub 模型和 Sparkplug B 的状态管理成为关键。下一篇深度拆解 MQTT QoS 的工程代价、Sparkplug B 的 BIRTH/DEATH 状态机、Protobuf 编码带宽实测,以及如何在断线重连时保持数据一致性。