串口采集与 Modbus RTU——字节流里的时间敏感博弈

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 硬件自动切换的电路上,也需要等待:

  1. 发送 FIFO 清空:串口芯片的发送缓冲区的数据全部移到移位寄存器发出
  2. 硬件方向切换:RS-485 驱动芯片的 DE/RE 引脚电平翻转时间(~100ns-10μs)
  3. 从站响应时间:从站收到请求到发出响应的处理时间(从站相关,通常 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-10ms
  • T_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 通信异常时,按此顺序排查:

  1. 万用表测 A/B 线间电压 → 正常值 1.5-5V,接近 0V 说明总线短路或没有偏置
  2. 示波器抓 A/B 波形 → 看眼图是否张开、信号幅值是否 >200mV
  3. 测 GND 电位差 → 超过 ±7V 需要加隔离
  4. 终端电阻 → 长线(>100m)两端需要 120Ω 终端电阻
  5. CRC 错误率 → 在程序里统计 CRC 失败次数 / 总帧数,超过 1% 要检查物理层
  6. 波特率验证 → 用示波器测最小脉冲宽度是否等于 1/波特率
  7. 从站地址冲突 → 确保总线上的从站地址唯一
  8. 3.5 字符间隔验证 → 用逻辑分析仪看帧间隔是否满足要求

10. 性能调优------把 1s 轮询压到 200ms

当采集性能不够时,不要盲目提波特率。按优先级执行:

  1. 合并读请求 :把多个读操作合并为一个批量读(count 尽量大)。读 1 个寄存器 20 次的效率只有读 20 个寄存器 1 次的 1/10
  2. 去除不必要的从站延迟安全余量 :测量实际从站响应时间,把 slave_delay 从保守的 10ms 降到实测值
  3. 分组+多串口:一个串口接 30 个从站 vs 三个串口各接 10 个,后者轮询周期是前者的 1/3
  4. 改造从站:部分从站支持自定义功能码,把多个数据打包在一个响应中返回
  5. 最后才是加波特率:确认线缆长度和品质能支撑更高的波特率

对比总结:三种协议的链路层差异

维度 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 编码带宽实测,以及如何在断线重连时保持数据一致性。

相关推荐
易舟云财务软件1 小时前
财务 AI Python 实战:从自动化报表到智能风控的应用场景
人工智能·python·自动化
武雄(小星Ai)1 小时前
一个模型干五件事:拆解 NVIDIA Cosmos 3 的物理 AI 全模态架构
人工智能·python·agent
dxxt_yy1 小时前
光伏风电组网调试优选,鼎讯信通 GN-W10A 网络综合测试仪全项检测
网络·能源·信息与通信
Mr.Daozhi1 小时前
跨境电商选品完整流水线:Google Trends筛词+Meta广告分析,CLI工具设计实战
开发语言·爬虫·python·跨境电商·工具链·选品
是枚小菜鸡儿吖1 小时前
IT技术员远程修电脑用什么软件好?低延迟高清远控工具横评
网络·智能路由器·电脑
eam0511231 小时前
BGP反射器及联邦实验
网络
小子想咋滴1 小时前
bgp联邦实验
网络·智能路由器
装不满的克莱因瓶1 小时前
掌握典型卷积神经网络的搭建
人工智能·python·深度学习·神经网络·机器学习·ai·cnn