4、串口通信基础:RS-232/RS-485电气特性、波特率、数据位、校验位、停止位配置,以及串口编程API(如pyserial)的使用

Modbus RTU 协议在工业现场最底层的物理承载,往往就是串行通信链路。无论是连接 PLC、传感器、变频器还是智能仪表,驱动层工程师都必须先与串口打交道。本章将聚焦于串口通信的物理层与数据链路层基础,从 RS-232 与 RS-485 的电气特性差异出发,深入解析波特率、数据位、校验位、停止位这四大参数的工程含义,并基于 Python 的 pyserial 库演示如何在实际代码中完成串口配置与数据收发。理解这些内容,是后续实现 Modbus RTU 帧解析、CRC 校验与超时重试机制的基石。

串口通信物理层与参数配置链路 RS-232 / RS-485 电气特性 RS-232: 单端信号, ±3~±15V 全双工, 点对点, 15m RS-485: 差分信号, ±1.5~±6V 半双工, 多点(32节点), 1200m → 工业现场首选 RS-485 UART 异步帧结构 起始 数据位(5-8) 校验 停止 LSB 先发, 空闲态为高电平 波特率 = 每秒传输的符号数 → 双方必须完全一致 pyserial 核心配置 serial.Serial(port, baudrate, bytesize, parity, stopbits) timeout: 读超时(秒) write_timeout: 写超时 → 打开后需异常处理 Modbus RTU 驱动层串口通信链路 应用层 读写寄存器 Modbus RTU 协议 帧封装/CRC/解析 串口参数配置 波特率/校验/停止位 pyserial API read/write/close RS-485 收发器 MAX3485/SP3485 波特率误差 < 2% 时通信可靠;RS-485 需注意 A/B 线极性、终端电阻与接地 驱动层核心:将上层指令正确编码为串口字节流,并可靠接收解析

上图展示了从应用层到物理层的完整串口通信链路。在驱动层实现中,我们不仅要关注 pyserial 的 API 调用,更要理解底层 RS-232/RS-485 的电气特性对通信质量的影响。下面将从电气特性、参数配置、编程实践三个维度展开详细讲解。

4.1 RS-232 与 RS-485 电气特性对比

核心原理:单端与差分信号的本质差异

RS-232 是最早的串行通信标准之一,采用单端信号传输方式。发送端将逻辑 1 表示为 -3V ~ -15V 的负电压,逻辑 0 表示为 +3V ~ +15V 的正电压。这种电压摆幅较大,抗干扰能力相对较弱,且地线是共用的,容易受到地电位差的影响。RS-232 的最大传输距离通常限制在 15 米以内,且只能实现点对点通信(一个发送端对应一个接收端)。

RS-485 则采用差分信号传输,使用两根线(A 和 B)来传输一路信号。逻辑 1 表示为 A 线电压高于 B 线电压(A-B > +0.2V),逻辑 0 表示为 A 线电压低于 B 线电压(A-B < -0.2V)。由于接收端检测的是两线之间的电压差,而非对地电压,因此共模干扰被有效抑制。RS-485 的传输距离可达 1200 米,且支持多点通信(同一总线上最多可挂接 32 个标准节点)。

工程经验:在 Modbus RTU 工业现场,几乎无一例外地选择 RS-485 作为物理层。原因有三:一是传输距离远,适合工厂车间级布线;二是差分信号抗干扰能力强,能在电机、变频器等强电设备附近稳定工作;三是支持多点组网,一条总线可挂载多台从站设备,节省布线成本。

判断方法:如何选择 RS-232 还是 RS-485

对比维度 RS-232 RS-485
信号方式 单端信号(对地) 差分信号(A/B 线)
电压范围 ±3V ~ ±15V ±1.5V ~ ±6V(差分)
通信模式 全双工(TXD/RXD 独立) 半双工(共享 A/B 线)
最大节点数 1 对 1 最多 32 个标准节点
最大传输距离 约 15 米 约 1200 米
抗干扰能力 较弱 强(共模抑制)
典型应用 近距离调试、PC 串口 工业总线、Modbus RTU 组网

在实际选型时,如果通信距离在 10 米以内且只有两个设备,可以考虑 RS-232;但只要是工业现场或需要多点通信,应直接选择 RS-485。需要注意的是,RS-485 是半双工通信,同一时刻只能有一个节点发送数据,因此驱动层需要控制发送/接收方向的切换(通过 DE/RE 引脚控制)。

实战场景:RS-485 总线布线要点

假设我们要搭建一条 Modbus RTU 总线,包含 1 台主站(工控机+USB-485 转换器)和 5 台从站(变频器)。在物理布线时,需要注意以下几点:

  1. 终端电阻:在总线两端(最远的两台设备)各并联一个 120Ω 的终端电阻,用于匹配传输线阻抗,减少信号反射。
  2. 接地处理:RS-485 虽然使用差分信号,但 A/B 线对地仍存在共模电压。建议使用屏蔽双绞线,并将屏蔽层单点接地(通常在主站侧)。
  3. 极性确认:A 线接设备的 A 或 D+,B 线接设备的 B 或 D-,不能接反。接反会导致通信完全失败。
  4. 分支长度:从总线到每个从站的分支线(stub)应尽量短,一般不超过 1 米,否则会引起信号反射。

风险提示:如果总线上没有终端电阻,且通信距离超过 50 米,可能会出现数据帧偶发错误、CRC 校验失败等问题。此时不要盲目怀疑程序逻辑,先用示波器或万用表检查 A/B 线之间的波形质量。

4.2 波特率、数据位、校验位、停止位配置

核心原理:UART 异步帧格式

UART(通用异步收发器)是串口通信的核心硬件模块。在空闲状态下,TX 线保持高电平。发送数据时,首先拉低电平一个位时间(起始位),然后依次发送数据位(LSB 在前),接着是可选的校验位,最后是停止位(1 或 2 个高电平位)。接收端通过检测起始位的下降沿来同步时钟,然后按约定的波特率采样后续数据位。

波特率(Baud Rate)表示每秒传输的符号数,单位是 bps(bit per second)。常见的波特率有 9600、19200、38400、115200 等。波特率越高,传输速度越快,但对线路质量的要求也越高。在 Modbus RTU 中,常用的波特率是 9600 和 19200。

参数配置详解

参数 可选值 Modbus RTU 推荐 说明
波特率 1200 ~ 115200 9600 / 19200 双方必须一致,误差 < 2%
数据位 5 / 6 / 7 / 8 8 Modbus RTU 固定为 8 位
校验位 None / Even / Odd None(无校验) Modbus RTU 使用 CRC 校验,不再需要奇偶校验
停止位 1 / 1.5 / 2 1 1 位停止位最常用

在 Modbus RTU 协议中,帧格式固定为 8 数据位 + 无校验 + 1 停止位(8N1)。这是因为 Modbus RTU 已经在报文尾部附加了 16 位 CRC 校验,无需再使用奇偶校验。如果配置为 8E1(偶校验),则与标准 Modbus RTU 不兼容,可能导致通信失败。

案例拆解:波特率误差导致的通信故障

背景:某项目中使用 USB-485 转换器与温控器通信,温控器设置为 19200 波特率,8N1。主站程序使用 pyserial 配置为 19200,但通信时好时坏,偶尔出现超时。

排查过程:

  1. 首先检查接线和终端电阻,确认无误。
  2. 使用示波器观察 A/B 线波形,发现波形幅值正常,但位宽略有偏差。
  3. 进一步测量实际波特率:用示波器测量起始位到第一个数据位的宽度,计算实际波特率约为 19100 bps,误差约 0.5%。
  4. 检查 USB-485 转换器的晶振精度,发现其使用了廉价晶振,误差较大。

解决方案:更换为高精度晶振的 USB-485 转换器,或将波特率降低到 9600(此时误差比例不变,但误差减小,容忍度更高)。最终通信恢复正常。

经验总结:当通信偶发失败时,优先检查波特率误差。可以使用示波器测量实际位宽,或者用逻辑分析仪抓取波形。对于长距离、高波特率场景,建议使用带隔离的 RS-485 转换器,并确保晶振精度在 ±0.1% 以内。

4.3 pyserial 串口编程 API 实战

核心原理:pyserial 的对象模型

pyserial 是 Python 中最常用的串口编程库,封装了底层操作系统对串口的访问。核心类是 serial.Serial,通过实例化该类并传入端口、波特率等参数,即可打开串口。常用方法包括 read()、write()、flush()、close() 等。pyserial 还支持通过 serial.tools.list_ports 枚举系统可用串口。

操作流程:pyserial 标准配置步骤

import serial
import serial.tools.list_ports

# 1. 枚举可用串口
ports = serial.tools.list_ports.comports()
for p in ports:
    print(f"端口: {p.device}, 描述: {p.description}")

# 2. 打开串口(以 /dev/ttyUSB0 为例)
ser = serial.Serial(
    port='/dev/ttyUSB0',
    baudrate=9600,
    bytesize=serial.EIGHTBITS,
    parity=serial.PARITY_NONE,
    stopbits=serial.STOPBITS_ONE,
    timeout=1.0,          # 读超时 1 秒
    write_timeout=1.0     # 写超时 1 秒
)

# 3. 检查串口是否打开
if ser.is_open:
    print(f"串口已打开: {ser.name}")

# 4. 发送数据(Modbus RTU 请求帧示例)
request_frame = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xC5, 0xCD])
ser.write(request_frame)
ser.flush()  # 等待数据发送完成

# 5. 读取响应
response = ser.read(256)  # 最多读取 256 字节
print(f"收到 {len(response)} 字节: {response.hex()}")

# 6. 关闭串口
ser.close()

案例拆解:完整的 Modbus RTU 读取保持寄存器函数

下面是一个封装好的函数,用于通过 Modbus RTU 读取从站的保持寄存器。该函数考虑了超时、异常处理和资源释放。

import serial
import struct
import time

def read_holding_registers(port, slave_id, start_addr, quantity, baudrate=9600, timeout=1.0):
    """
    读取 Modbus RTU 从站的保持寄存器
    :param port: 串口设备名
    :param slave_id: 从站地址 (1-247)
    :param start_addr: 起始寄存器地址 (0-based)
    :param quantity: 读取数量 (1-125)
    :param baudrate: 波特率
    :param timeout: 超时时间
    :return: 寄存器值列表,失败返回 None
    """
    ser = None
    try:
        # 打开串口
        ser = serial.Serial(
            port=port,
            baudrate=baudrate,
            bytesize=serial.EIGHTBITS,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            timeout=timeout
        )
        
        # 构建请求帧: [从站地址, 功能码, 起始地址高, 起始地址低, 数量高, 数量低, CRC低, CRC高]
        request = struct.pack('>B B H H', slave_id, 0x03, start_addr, quantity)
        crc = modbus_crc(request)
        request += struct.pack('<H', crc)
        
        # 发送请求
        ser.write(request)
        ser.flush()
        
        # 计算预期响应长度: 从站地址(1) + 功能码(1) + 字节数(1) + 数据(2*quantity) + CRC(2)
        expected_len = 5 + 2 * quantity
        
        # 读取响应
        response = ser.read(expected_len)
        if len(response) < expected_len:
            print(f"响应不完整: 期望 {expected_len} 字节, 实际 {len(response)} 字节")
            return None
        
        # 验证从站地址和功能码
        if response[0] != slave_id or response[1] != 0x03:
            print(f"异常响应: 从站地址={response[0]}, 功能码={response[1]}")
            return None
        
        # 验证 CRC
        recv_crc = struct.unpack('<H', response[-2:])[0]
        calc_crc = modbus_crc(response[:-2])
        if recv_crc != calc_crc:
            print(f"CRC 校验失败: 接收={recv_crc:04X}, 计算={calc_crc:04X}")
            return None
        
        # 解析寄存器值 (16位无符号整数)
        values = []
        for i in range(quantity):
            val = struct.unpack('>H', response[3 + 2*i : 5 + 2*i])[0]
            values.append(val)
        
        return values
        
    except serial.SerialException as e:
        print(f"串口异常: {e}")
        return None
    finally:
        if ser and ser.is_open:
            ser.close()

def modbus_crc(data):
    """计算 Modbus CRC16"""
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return crc

# 使用示例
if __name__ == "__main__":
    # 读取从站 1 的 10 个保持寄存器,起始地址 0
    regs = read_holding_registers('/dev/ttyUSB0', 1, 0, 10, baudrate=9600)
    if regs:
        for i, val in enumerate(regs):
            print(f"寄存器 {i}: {val}")

常见误区与总结复盘

  • 误区一:打开串口后不检查 is_open 属性。如果串口被其他程序占用,serial.Serial() 会抛出异常,必须用 try/except 捕获。
  • 误区二:读取时使用 read(1) 逐字节读取。在 Modbus RTU 中,帧与帧之间需要间隔 3.5 个字符时间,逐字节读取容易导致帧解析错误。建议一次性读取完整帧长度。
  • 误区三:忽略 flush() 调用。在发送后立即读取时,如果数据还在发送缓冲区,可能导致读取到不完整的数据。
  • 误区四:忘记关闭串口。长时间运行的程序如果频繁打开串口而不关闭,会导致资源泄漏,最终无法打开新串口。

重要提醒:在 Linux 系统中,访问串口需要相应的权限。如果遇到 PermissionError,需要将用户加入 dialout 组(sudo usermod -a -G dialout $USER),或者使用 sudo 运行程序。在 Windows 系统中,需要确保安装了正确的 USB-485 驱动。

通过本章的学习,我们掌握了 RS-232/RS-485 的电气特性差异,理解了 UART 帧格式中各个参数的含义,并能够使用 pyserial 完成串口的配置与数据收发。这些知识为后续实现 Modbus RTU 协议栈打下了坚实的物理层基础。在实际开发中,建议先在 PC 上使用串口调试助手验证硬件链路,再编写驱动代码,这样可以快速定位问题所在。