4、串口通信基础:RS-232/RS-485电气特性、波特率、数据位、校验位、停止位配置,以及串口编程API(如pyserial)的使用
Modbus RTU 协议在工业现场最底层的物理承载,往往就是串行通信链路。无论是连接 PLC、传感器、变频器还是智能仪表,驱动层工程师都必须先与串口打交道。本章将聚焦于串口通信的物理层与数据链路层基础,从 RS-232 与 RS-485 的电气特性差异出发,深入解析波特率、数据位、校验位、停止位这四大参数的工程含义,并基于 Python 的 pyserial 库演示如何在实际代码中完成串口配置与数据收发。理解这些内容,是后续实现 Modbus RTU 帧解析、CRC 校验与超时重试机制的基石。
上图展示了从应用层到物理层的完整串口通信链路。在驱动层实现中,我们不仅要关注 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 台从站(变频器)。在物理布线时,需要注意以下几点:
- 终端电阻:在总线两端(最远的两台设备)各并联一个 120Ω 的终端电阻,用于匹配传输线阻抗,减少信号反射。
- 接地处理:RS-485 虽然使用差分信号,但 A/B 线对地仍存在共模电压。建议使用屏蔽双绞线,并将屏蔽层单点接地(通常在主站侧)。
- 极性确认:A 线接设备的 A 或 D+,B 线接设备的 B 或 D-,不能接反。接反会导致通信完全失败。
- 分支长度:从总线到每个从站的分支线(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,但通信时好时坏,偶尔出现超时。
排查过程:
- 首先检查接线和终端电阻,确认无误。
- 使用示波器观察 A/B 线波形,发现波形幅值正常,但位宽略有偏差。
- 进一步测量实际波特率:用示波器测量起始位到第一个数据位的宽度,计算实际波特率约为 19100 bps,误差约 0.5%。
- 检查 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 上使用串口调试助手验证硬件链路,再编写驱动代码,这样可以快速定位问题所在。