4、UART串口通信:串口参数配置、发送与接收数据、数据帧解析、奇偶校验实战

UART串口,说白了就是嵌入式设备之间最常用的“对话”方式。我做了这么多年硬件,几乎每个项目都离不开它。从传感器数据采集,到与上位机通信,再到固件升级,串口始终是那个最可靠的老伙计。

这一章,咱们就把它彻底搞明白。不只是调个库发个数据那么简单,我会把参数配置的坑、数据帧解析的套路、还有奇偶校验那些容易被忽略的细节,都掰开揉碎了讲给你听。

4.1 串口参数配置:别让“波特率”坑了你

配置串口,第一件事就是对齐参数。两边对不上,神仙也救不了。我见过太多新手,代码写得飞起,结果就是收不到数据,最后发现是波特率设错了。

参数 常见值 说明
波特率 9600, 115200, 460800 每秒传输的符号数,越高越快,但受线长和干扰影响
数据位 7, 8 一帧中有效数据的位数,通常用8位
停止位 1, 1.5, 2 帧结束标志,1位最常用
校验位 None, Even, Odd 奇偶校验,用于简单检错
流控制 None, RTS/CTS 硬件流控,一般不用
我曾经踩过的坑: 有一次调试一个老款工业仪表,手册上写的是9600波特率,结果死活收不到正确数据。折腾了半天,用示波器一量,发现实际是19200。原来那批仪表出厂时固件版本不同,波特率被改过了。从那以后,我养成了一个习惯:拿到新设备,先用示波器或逻辑分析仪抓一下实际波特率,别完全相信手册。

在Python里,用pyserial库配置串口非常简单。我个人习惯把参数写在一个字典里,方便维护。

import serial

# 我个人习惯这样组织参数,清晰明了
serial_config = {
    'port': '/dev/ttyUSB0',      # Linux下常见,Windows是COM3
    'baudrate': 115200,
    'bytesize': serial.EIGHTBITS,
    'parity': serial.PARITY_NONE,
    'stopbits': serial.STOPBITS_ONE,
    'timeout': 1                 # 读超时,单位秒
}

ser = serial.Serial(**serial_config)

# 检查是否打开成功
if ser.is_open:
    print(f"串口 {ser.port} 已打开,波特率 {ser.baudrate}")
小技巧: timeout参数很关键。设为None会一直阻塞等待,设为0是非阻塞模式。我一般设1秒,既不会卡死,也不会漏数据。

4.2 发送与接收数据:基础操作与常见陷阱

发送数据很简单,ser.write()就行。但接收就有讲究了。你想想看,数据是源源不断流过来的,你怎么知道什么时候读完?

这里有几种常见策略:

  • 固定长度读取: 知道每次会来多少字节,直接用ser.read(10)。
  • 按行读取: 数据以换行符\n结尾,用ser.readline()。很多AT指令模块就是这么干的。
  • 超时读取: 不知道长度,就设个超时,读不到新数据就认为结束了。
  • 帧头帧尾检测: 这是最可靠的方式,后面会细讲。
# 发送数据
data_to_send = b"Hello, UART!\n"
ser.write(data_to_send)

# 接收数据 - 按行读取
response = ser.readline()
print(f"收到: {response}")

# 接收数据 - 固定长度
response = ser.read(10)
print(f"收到10字节: {response.hex()}")

# 接收数据 - 超时读取所有可用数据
import time
time.sleep(0.1)  # 等一会儿,让数据收完
response = ser.read(ser.in_waiting)  # 读取缓冲区所有数据
print(f"收到 {len(response)} 字节: {response}")
注意: ser.in_waiting返回的是接收缓冲区中当前可读的字节数。但要注意,这个值随时在变。如果你读得太快,可能只读到一部分数据。我一般会在发送后加一个time.sleep(0.05),给设备一点响应时间。

4.3 数据帧解析:从原始字节到结构化数据

原始字节流就像一堆散落的珠子,你得用线把它们串起来。这根“线”,就是数据帧协议。

一个典型的数据帧结构长这样:

帧头(2字节) + 数据长度(1字节) + 数据(N字节) + 校验(1字节) + 帧尾(1字节)

举个例子,我做过的一个温湿度传感器项目,它的协议是这样的:

字节偏移 内容 说明
0 0xAA 帧头1
1 0x55 帧头2
2 数据长度 从本字节之后到校验之前的数据长度
3 ~ 3+N-1 有效数据 比如温度、湿度值
3+N 校验和 前面所有字节的累加和取低8位
4+N 0x0D 0x0A 帧尾 (CR+LF)

解析这种帧,我的套路是这样的:

  1. 不断从串口读取字节,存入一个环形缓冲区。
  2. 在缓冲区里搜索帧头0xAA 0x55。
  3. 找到帧头后,读取数据长度字段。
  4. 检查缓冲区是否已经有足够的数据(帧头 + 长度 + 数据 + 校验 + 帧尾)。
  5. 提取完整帧,校验校验和和帧尾。
  6. 如果校验通过,解析数据;否则丢弃这一帧,继续搜索下一帧。
def parse_frame(buffer):
    """从缓冲区中解析一帧数据,成功返回(帧数据, 剩余缓冲区),失败返回(None, 缓冲区)"""
    # 搜索帧头 0xAA 0x55
    header = bytes([0xAA, 0x55])
    idx = buffer.find(header)
    if idx == -1:
        return None, buffer  # 没找到帧头

    # 检查是否有足够的数据来读取长度字段
    if len(buffer) - idx < 3:
        return None, buffer  # 数据不够

    data_len = buffer[idx + 2]  # 数据长度
    frame_len = 2 + 1 + data_len + 1 + 2  # 帧头+长度+数据+校验+帧尾

    if len(buffer) - idx < frame_len:
        return None, buffer  # 完整帧还没到

    frame = buffer[idx:idx + frame_len]

    # 校验帧尾
    if frame[-2:] != bytes([0x0D, 0x0A]):
        # 帧尾错误,丢弃这一帧,继续搜索
        return None, buffer[idx + 1:]

    # 校验校验和
    checksum = sum(frame[2:-3]) & 0xFF  # 从长度字段到校验前一个字节
    if checksum != frame[-3]:
        print(f"校验和错误: 计算值 {checksum:#x}, 接收值 {frame[-3]:#x}")
        return None, buffer[idx + 1:]

    # 解析成功
    payload = frame[3:-3]  # 有效数据
    return payload, buffer[idx + frame_len:]

# 使用示例
buffer = b''
while True:
    # 从串口读取数据
    chunk = ser.read(ser.in_waiting or 1)
    if chunk:
        buffer += chunk
        payload, buffer = parse_frame(buffer)
        if payload:
            # 解析成功,处理数据
            print(f"收到一帧数据: {payload.hex()}")
核心思路: 帧解析的核心是状态机。你脑子里要有一个状态:当前是在搜索帧头,还是在读取数据,还是在校验。把这个逻辑理清了,代码自然就写出来了。

4.4 奇偶校验实战:简单但有效的检错手段

奇偶校验,说白了就是给每个字节加一个“标签”,告诉接收方这个字节里1的个数是奇数还是偶数。它不能纠错,但能发现单比特错误。

有几种模式:

  • 无校验 (None): 不校验,最快。
  • 偶校验 (Even): 保证每个字节(包括校验位)中1的个数为偶数。
  • 奇校验 (Odd): 保证每个字节(包括校验位)中1的个数为奇数。
  • 标记校验 (Mark): 校验位始终为1。
  • 空格校验 (Space): 校验位始终为0。

在pyserial里配置奇偶校验很简单:

# 偶校验
ser = serial.Serial(
    port='/dev/ttyUSB0',
    baudrate=115200,
    parity=serial.PARITY_EVEN,
    stopbits=serial.STOPBITS_ONE,
    bytesize=serial.EIGHTBITS
)

# 奇校验
ser = serial.Serial(
    port='/dev/ttyUSB0',
    baudrate=115200,
    parity=serial.PARITY_ODD,
    stopbits=serial.STOPBITS_ONE,
    bytesize=serial.EIGHTBITS
)

但要注意,奇偶校验只能检测奇数个比特错误。如果两个比特同时翻转,校验位反而对了,错误就漏过去了。所以,在干扰严重的环境里,我建议用CRC校验,而不是奇偶校验。

我的经验: 在短距离、低干扰的场景下(比如板级通信),奇偶校验完全够用。但在工业现场,电机、变频器一开,干扰大得很,这时候就别省那点计算量了,上CRC吧。

如果你想自己实现奇偶校验,也很简单:

def calculate_parity(data, parity_type='even'):
    """计算数据的奇偶校验位"""
    ones_count = bin(data).count('1')
    if parity_type == 'even':
        return 0 if ones_count % 2 == 0 else 1
    elif parity_type == 'odd':
        return 1 if ones_count % 2 == 0 else 0
    else:
        return 0

# 示例:计算字节 0x53 的偶校验位
data = 0x53  # 二进制 0101 0011,有4个1
parity_bit = calculate_parity(data, 'even')
print(f"0x{data:02X} 的偶校验位是: {parity_bit}")  # 输出 0,因为4是偶数

4.5 本章知识体系

下面这张图,把本章的核心知识点串起来了。你可以把它当成一个思维导图,看看自己是不是都掌握了。

UART串口通信知识体系 UART串口通信 串口参数配置 波特率 / 数据位 / 停止位 校验位 / 流控制 / 超时 发送与接收数据 write() / read() / readline() 固定长度 / 按行 / 超时读取 数据帧解析 帧头 / 长度 / 数据 / 校验 / 帧尾 状态机解析 / 环形缓冲区 奇偶校验实战 Even / Odd / None / Mark / Space 局限性:只能检测奇数个错误

嗯,这一章的内容就到这里。串口通信看似基础,但里面的门道不少。参数配置、数据收发、帧解析、奇偶校验,每一个环节都可能成为你项目中的“绊脚石”。希望我分享的这些经验和代码,能帮你少走一些弯路。