Raspberry Pi 与 Arduino 的可靠 Python 串口通信

可靠的串口连接不只是让两端波特率相同。编写应用命令前,应先定义物理连接、帧边界、编码、最大消息长度、超时、确认响应和复位行为。

这份 2026 年维护层把原来五行的笔记扩展为一套完整的 USB 串口流程,采用 Arduino 官方 Serial API 和 pySerial。文末保留完整 2019 年导出作为来源档案。

1. 选择 USB 串口或 GPIO UART

首次实现时,建议把 Arduino 的普通 USB 数据口连接到 Raspberry Pi USB 口。Arduino 会显示为 USB 串口设备,常见名称是 /dev/ttyACM0/dev/ttyUSB0。这条路线不需要启用 Raspberry Pi 的 GPIO UART,也不用连接 TX/RX 引脚。打开串口可能通过 DTR 让部分 Arduino 开发板复位,因此软件必须容忍短暂重启。

直接 TTL UART 属于另一种电气设计,需要 TX/RX 交叉连接并共地。Raspberry Pi UART 引脚使用 3.3 V 逻辑;官方文档警告,接入 5 V 信号会造成损坏。如果对端不兼容 3.3 V,请使用合适的电平转换器或 USB 转 3.3 V 串口适配器。采用直接 UART 时,应通过 raspi-config 启用 UART 硬件并禁用串口登录控制台,再确认该型号对应的 /dev/serial* 映射。

除非完整电路和开发板行为已经评审,否则不要同时连接 USB 串口和直接 TX/RX 接线。

2. 先定义一个小协议

本例采用刻意收窄的协议:

属性约定
传输USB 串口,115200 波特,8 数据位,无校验,1 停止位
帧边界每行一条 ASCII 命令,以 LF(n)结束;忽略 CR
最大命令结束符之前最多 63 字节
请求PING <十进制请求 ID>
成功OK <相同请求 ID>
失败ERR bad_commandERR invalid_byteERR line_too_long
启动通知READY 1;它可能在主机打开串口前发出,因此不能依赖

请求 ID 让主机能够匹配响应。PING 没有副作用,所以可以安全重试。移动硬件、收费或写入状态的命令必须单独设计幂等性;盲目重试可能重复执行动作。

协议关键字和数字 ID 使用 ASCII 已经足够。如果后续命令携带人类文本,应明确指定 UTF-8,限制其字节长度,并规定如何拒绝无效输入。不要依赖任一设备当时碰巧启用的编码。

3. 上传有边界的 Arduino 解析器

下面的 sketch 不使用无边界 String,也不会在 loop() 中等待。它缓存一行,拒绝非 ASCII 控制/数据字节,并在帧过长后持续丢弃,直到收到换行。

#include <Arduino.h>
#include <string.h>

constexpr unsigned long BAUD_RATE = 115200;
constexpr size_t MAX_LINE = 64;

enum class DropReason {
  none,
  invalid_byte,
  line_too_long
};

char line_buffer[MAX_LINE];
size_t line_length = 0;
DropReason drop_reason = DropReason::none;

bool is_request_id(const char *text) {
  if (*text == '') {
    return false;
  }

  while (*text != '') {
    if (*text < '0' || *text > '9') {
      return false;
    }
    ++text;
  }
  return true;
}

void handle_line(const char *line) {
  if (strncmp(line, "PING ", 5) == 0 && is_request_id(line + 5)) {
    Serial.print(F("OK "));
    Serial.println(line + 5);
  } else {
    Serial.println(F("ERR bad_command"));
  }
}

void finish_line() {
  if (drop_reason == DropReason::invalid_byte) {
    Serial.println(F("ERR invalid_byte"));
  } else if (drop_reason == DropReason::line_too_long) {
    Serial.println(F("ERR line_too_long"));
  } else {
    line_buffer[line_length] = '';
    handle_line(line_buffer);
  }

  line_length = 0;
  drop_reason = DropReason::none;
}

void setup() {
  Serial.begin(BAUD_RATE);
  Serial.println(F("READY 1"));
}

void loop() {
  while (Serial.available() > 0) {
    const int raw = Serial.read();
    if (raw < 0) {
      break;
    }

    const char value = static_cast<char>(raw);
    if (value == 'r') {
      continue;
    }
    if (value == 'n') {
      finish_line();
      continue;
    }
    if (drop_reason != DropReason::none) {
      continue;
    }
    if (raw < 0x20 || raw > 0x7e) {
      drop_reason = DropReason::invalid_byte;
      continue;
    }
    if (line_length + 1 >= MAX_LINE) {
      drop_reason = DropReason::line_too_long;
      continue;
    }

    line_buffer[line_length++] = value;
  }
}

Serial.available() 报告已经收到的字节数,Serial.read() 返回下一个字节;没有数据时返回 -1。解析器仍显式检查返回值。波特率必须与主机一致,而避免局部读取变成局部命令的是帧协议。

4. 安装 pySerial 并识别设备

在 Raspberry Pi OS 上,可以让系统 Python 使用发行版软件包:

sudo apt update
sudo apt install --yes python3-serial
python3 -c 'import serial; print(serial.VERSION)'

也可以把依赖隔离在项目虚拟环境中:

python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pyserial
python -c 'import serial; print(serial.VERSION)'

连接 Arduino 前后分别列出串口:

python -m serial.tools.list_ports --verbose
ls -l /dev/serial/by-id/ 2>/dev/null || true

如果存在,/dev/serial/by-id/... 链接通常比假定开发板永远是 /dev/ttyACM0 更稳定。应记录厂商、产品和序列号标识,而不是盲目选择第一个串口。

5. 验证串口权限

检查所选设备节点,以及真正会打开它的进程的有效用户组:

serial_port="/dev/ttyACM0"
ls -l "$serial_port"
id
id -nG

在 Raspberry Pi OS 上,USB 串口设备通常向 dialout 组开放。如果目标账号不在该组,只添加该账号,然后开启真正的新登录会话:

serial_user="$(id -un)"
sudo usermod -a -G dialout "$serial_user"

完全退出后重新登录,再运行 id -nG。不要用 root 运行应用,也不要把设备改成所有人可写。systemd 服务或容器可能使用与交互终端不同的账号和设备策略,应直接诊断其运行上下文。

6. 运行能够容忍复位的 Python 客户端

把下面代码保存为 serial_ping.py。它会重复发送同一个幂等请求,直到收到匹配响应,因此能处理打开 USB 串口时复位的开发板,不依赖一个猜测出来的固定等待时间。

import argparse
import time

import serial


BAUD_RATE = 115200
MAX_RESPONSE = 80


class ProtocolError(RuntimeError):
    pass


def read_ascii_line(port):
    raw = port.read_until(b"n", size=MAX_RESPONSE)
    if not raw:
        return None
    if not raw.endswith(b"n"):
        raise ProtocolError("response exceeded the frame limit")

    try:
        return raw.rstrip(b"rn").decode("ascii")
    except UnicodeDecodeError as error:
        raise ProtocolError("response was not ASCII") from error


def ping(port_name, request_id):
    if not request_id.isdecimal() or len(request_id) > 32:
        raise ValueError("request ID must contain 1 to 32 decimal digits")

    request = f"PING {request_id}n".encode("ascii")
    expected = f"OK {request_id}"

    with serial.Serial(
        port=port_name,
        baudrate=BAUD_RATE,
        timeout=0.4,
        write_timeout=1.0,
        exclusive=True,
    ) as port:
        port.reset_input_buffer()

        for _ in range(12):
            port.write(request)
            port.flush()

            deadline = time.monotonic() + 0.6
            while time.monotonic() < deadline:
                response = read_ascii_line(port)
                if response is None:
                    break
                if response == "READY 1":
                    continue
                if response == expected:
                    return response
                if response.startswith("ERR "):
                    raise ProtocolError(response)

        raise TimeoutError(f"no matching response from {port_name}")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("port")
    parser.add_argument("--request-id", default="1")
    arguments = parser.parse_args()
    print(ping(arguments.port, arguments.request_id))


if __name__ == "__main__":
    main()

使用稳定设备链接或已经确认的设备节点运行:

python -m py_compile serial_ping.py
python serial_ping.py /dev/ttyACM0 --request-id 42

预期输出:

OK 42

exclusive=True 会在 POSIX 上请求:本客户端持有端口期间拒绝其他打开者。它不能替代同一进程中多个线程之间的协调。如果安装平台或驱动不支持独占模式,应记录并测试应用采用的所有权机制。

7. 不用硬件测试字节帧

pySerial 包含 loop:// URL handler。它会回显字节,可以在本机验证主机端帧边界:

import serial


frame = b"PING 42n"
with serial.serial_for_url("loop://", timeout=1) as port:
    written = port.write(frame)
    reply = port.read_until(b"n", size=64)

assert written == len(frame)
assert reply == frame
print("loop framing test passed")

这项测试只证明 Python 环境能打开 pySerial loop handler 并往返完全相同的字节。它不测试 USB 线、Arduino 固件、复位行为、UART 电气特性或真实设备权限。

8. 明确区分字节、文本和整数

pySerial 的 write() 接受 bytes-like 数据,读取结果则是 bytes。只在协议边界进行编码和解码:

text = "hello"
frame = (text + "n").encode("utf-8")
decoded = frame.rstrip(b"n").decode("utf-8")

one_byte = bytes([65])
integer_value = one_byte[0]

chr(65) 返回文本字符 "A",并不会创建串口字节帧。bytes([value]) 要求 0 <= value <= 255,意图更明确。对于多字节整数,应在两端使用 int.to_bytes()/int.from_bytes()struct 定义字节序和宽度。

不要把变量命名为 strbytesserialtime,否则会遮蔽内置类型或导入模块。控制协议应采用 errors="strict" 解码,让损坏成为可见证据,而不是悄悄变成替换字符。

9. 理解复位、超时和缓冲区

  • 许多 Arduino USB 开发板会在主机打开串口时复位。有界重试的握手比固定 sleep(2) 更可靠。
  • pySerial 的正数 timeout 可以限制读取时间;如果没有超时,缺失换行可能导致永久阻塞。
  • 除非配置写入超时,write() 默认阻塞。flush() 等待排队输出写完;它不会清空输入或输出缓冲区。
  • reset_input_buffer() 会故意丢弃已收到的字节。只在已知会话边界使用,不能在有效响应可能到达时调用。
  • 串口传输是字节流。一次 write() 不保证对应设备端的一次 read;只有帧协议定义消息。
  • Arduino 接收缓冲区容量有限。避免在 loop() 中长时间阻塞,限制帧长度,并在增加流量前设计流控或应用层背压。

10. 按层排查故障

现象常见层次应收集的证据
没有出现串口USB 线只能供电、设备未供电、内核驱动未绑定,或 USB 供电不足python -m serial.tools.list_ports --verbosedmesg、另一条已知数据线/端口
Permission denied有效账号缺少设备组,或服务/容器策略阻止访问节点的 ls -lid -nG、服务/容器配置
端口打开后断开开发板复位、USB 电源/线缆不稳定或固件重启内核日志、开发板 LED/复位行为、握手时间戳
出现随机字符波特率/配置不一致,或电平/噪声问题两端设置、物理接线;直接 UART 时可用逻辑分析仪
超时且没有响应端口错误、缺少换行、固件未运行、复位延迟或请求被拒绝原始发送帧、Arduino sketch/版本、有界调试日志
响应总是错开一个请求有陈旧输入、缺少请求 ID,或存在多个客户端端口所有权、协议追踪、响应 ID
Device or resource busy另一个进程占用串口获得授权时用 lsof/fuser,并检查 IDE 串口监视器和服务列表

运行 Python 前关闭 Arduino IDE Serial Monitor;这个简单协议端点应由单一进程持有。

11. 有意识地扩展协议

如果命令不止几个,应给协议设版本,并为每个请求写明响应、单位、范围、副作用、超时和重试规则。在并发操作前加入请求 ID。有状态修改的请求需要去重或其他幂等机制。

二进制数据帧应包含版本、消息类型、明确长度、载荷和 checksum/CRC。在分配或写入前先验证长度。校验和可以发现损坏,但不能验证命令来源;暴露在外或涉及安全的链路需要独立安全设计。

提高波特率前先测量吞吐量和最差延迟。USB 串口、Arduino 主循环、传感器工作和 Raspberry Pi 调度都会产生影响。如果错过时限会损坏设备,应把安全控制环移到合适的控制器上,而不是依赖桌面式串口进程。

12. 完整 2019 年导出档案

以下是完整的 2019 年 WordPress 导出,包括元数据和短正文。除规范化行尾空白外,作为来源保留。原代码中的 bytesstr 会遮蔽 Python 内置类型,而 chr(int) 创建文本而不是字节帧;当前项目请使用上方维护版指南。

---
id: 1933
title: 'RaspberryPi and Arduino Talk with Serial Port Using Python'
slug: 'raspberrypi-and-arduino-talk-with-serial-port'
date: '2019-05-09T13:37:55'
modified: '2019-05-09T13:57:59'
status: 'publish'
link: 'https://blog.lazying.art/en/html/computer_internet/hardware_system/raspberry-pi/1933/raspberrypi-and-arduino-talk-with-serial-port.html'
author: 'Lachlan Chen'
categories:
  - 'Raspberry Pi'
---

char, str; bytes, unicode, string

bytes = b"string"
str = chr(int)


Arduino

Raspberry Pi

主要参考资料

Leave a Reply