🍓 第八章 当树莓派遇到 Arduino 开发板
📄 一句话概括

串口通信就是一根线上的对话。本节讲透两件事:线路怎么连(USB 还是 GPIO 针)和话怎么说(自己订一份协议)。8-4 用的是别人订好的协议,本节这次规则由你自己定。

📝 本节和前面几节的关系

8-4 用 Firmata——协议是别人订的,你不用管字节怎么走。本节把这一层打开:自己定字节怎么发、怎么分边界、怎么容错。 这条"线路"其实就是 8-1 里那两个不能接别的东西的 0/1 号脚,或者那个 USB 口。本节两条都讲,并说清为什么初学一律走 USB。 本节也是整章的收口:8-1 认板子 → 8-2 装环境 → 8-3 写 C++ → 8-4 用现成协议 → 8-5 自己订协议,走完就是"两块板子能正经搭伙干活"。

一、先分清"线路":两条物理路线

两块板子要说话,只有两条路:

路线 1:USB 线(本章一直用的,推荐)

树莓派 USB 口  ──USB 线──  Arduino USB 口

路线 2:GPIO 的 UART 针(省一根线,但三个坑)

树莓派 GPIO14(物理针 8,TXD) ──→ Arduino 的 RX
树莓派 GPIO15(物理针 10,RXD)──→ Arduino 的 TX
树莓派 GND                    ──→ Arduino 的 GND   ← 必须接

注意 TX 接 RX、RX 接 TX,交叉着接(两边都在"说"的线要接到对方"听"的线上)。

⚠️ ⚠️ 三个坑,一个比一个能烧板子

① 电平不匹配——会烧树莓派 Arduino 是 5V 逻辑,树莓派是 3.3V。Arduino 的 TX 会把 5V 电平直接送进树莓派的 RX 脚。 后果:可能烧掉树莓派的 SoC。 解法:加电平转换模块,或者用两颗电阻做一个分压(把 5V 分到 3.3V 左右)。 反过来不用担心:树莓派发出去的 3.3V,Arduino 认作高电平是没问题的。

② 树莓派默认把这个串口当"控制台"占着 Raspberry Pi OS 默认用这个串口输出内核启动日志、提供串口登录。 你要用它跟 Arduino 讲话,得先把这个用途关掉:

sudo raspi-config
# → Interface Options → Serial Port
# → "要不要提供串口登录 shell?"       答 【No】
# → "要不要启用串口硬件?"            答 【Yes】

关完重启,串口才空出来给你用(设备名通常变成 /dev/serial0)。

③ 老教程改的文件位置已经搬了 老教程让你改 /boot/config.txt 和 /boot/cmdline.txt——现在它们在 /boot/firmware/ 下面(4-4 讲过)。 照老教程改 /boot/config.txt,等于在改一个不生效的副本。

💡 本节的结论:初学一律走 USB

USB 路线把电平、驱动、供电三件事全帮你处理了,代价只是多一根线。 GPIO 直连那条路,留到"必须省掉 USB 口"的时候再用(比如树莓派 5 那两个口都被占用,或者要做成一块固定的成品板)。

二、装 pyserial(两种装法,各有适用场合)

装法一(推荐,最省事):

sudo apt install python3-serial

这就是 6-5 讲的那条正路——apt 装的 Python 库,系统里任何 Python 脚本都能 import,不需要进 venv。

装法二(走 venv):

source ~/arduino-env/bin/activate     # 用 8-4 建的那个环境也行
pip install pyserial

两者区别(顺手复习 6-5 那条规矩):

apt install python3-serial venv 里 pip install pyserial
装到哪 系统全局 那一个虚拟环境里
import 时写什么 import serial(包名前缀 python3- 要去掉) import serial
要不要激活环境 不用 每次新终端都要

验证:

python3 -c "import serial; print(serial.__version__)"

看到版本号就算装好。

三、最小可用:让树莓派读到 Arduino 说的话

Arduino 端(serial_report.ino):

void setup() {
  Serial.begin(9600);              // ⚠️ 记住这个数字
}

void loop() {
  Serial.println("hello from arduino");   // 发一行(println 自带换行)
  delay(1000);
}

上传之后立刻把串口监视器关掉(8-3 学的那条纪律)。

树莓派端(read_serial.py):

import serial
import time

ser = serial.Serial('/dev/ttyACM0', 9600, timeout=2)
time.sleep(2)                  # ⚠️ 关键:等板子复位完成(下面解释)
ser.reset_input_buffer()       # 清掉开机那几秒的垃圾数据

try:
    while True:
        if ser.in_waiting > 0:
            line = ser.readline().decode('utf-8', errors='replace').rstrip()
            if line:
                print("收到:", line)
except KeyboardInterrupt:
    print("\n结束")
finally:
    ser.close()
⚠️ ⚠️ 本节最反直觉的一个坑:**打开串口,板子会重启**

现象:Arduino 明明一直好好的,你的 Python 脚本一打开串口,板子就重启了——而且你可能读到一堆乱码。

原因:Uno R3 的 USB 转串口芯片(ATmega16U2)把 DTR 信号线接到主芯片的复位脚上。打开串口会让 DTR 动一下,于是主芯片被复位,进入 bootloader 大约 2 秒。

这个设计是故意的——就是为了让你"点一下上传就能烧程序",不用手按复位键。

两行代码解决:

time.sleep(2)                 # 等它复位完
ser.reset_input_buffer()      # 清掉复位期间的垃圾字节

不做这两步的症状:读到一堆 ÿ、\x00 之类的乱码,或者第一个值莫名其妙。

顺带一个好消息:Uno R4 不会这样(它的 USB 是主芯片原生的,默认不复位)。所以这类问题在 R4 上遇不到。

看到什么算成功:

收到: hello from arduino
收到: hello from arduino
收到: hello from arduino

读结果:

代码 在干什么
serial.Serial(端口, 波特率, timeout) 打开串口(这一步就是触发复位的那一下)
ser.in_waiting 问"现在缓冲区里有多少字节到了"(0 就跳过)
ser.readline() 读到换行符为止,返回原始字节
.decode('utf-8', errors='replace') 把字节变成文字。errors='replace' 是关键——碰到解码不了的字节不会让程序崩,而是显示成一个占位符
.rstrip() 去掉行尾的换行和空格(不做的话每次打印都会多空一行)
💡 为什么必须先问 `in_waiting`

ser.readline() 是"阻塞"的:没有数据的时候它会一直等(等到 timeout 秒)。 直接写在循环里,程序就会卡在那里,Ctrl+C 都不好使。 养成习惯:先问有没有(in_waiting),有了再读。

四、反方向:让树莓派发命令给 Arduino

Arduino 端(serial_cmd.ino):

void setup() {
  Serial.begin(9600);
  pinMode(LED_BUILTIN, OUTPUT);
  Serial.println("ready");
}

void loop() {
  if (Serial.available() > 0) {
    char c = Serial.read();
    if (c == '1') {
      digitalWrite(LED_BUILTIN, HIGH);
      Serial.println("LED ON");
    } else if (c == '0') {
      digitalWrite(LED_BUILTIN, LOW);
      Serial.println("LED OFF");
    }
  }
}

树莓派端(send_serial.py):

import serial
import time

ser = serial.Serial('/dev/ttyACM0', 9600, timeout=2)
time.sleep(2)
ser.reset_input_buffer()

try:
    while True:
        cmd = input("输入 1 开灯 / 0 关灯 / q 退出:").strip()
        if cmd == 'q':
            break
        if cmd in ('0', '1'):
            ser.write(cmd.encode('utf-8'))    # ⚠️ 发的是字节,不是字符串
            ser.flush()
            time.sleep(0.2)                   # 给板子一点时间回话
            while ser.in_waiting > 0:
                print("板子回:", ser.readline().decode('utf-8', errors='replace').rstrip())
finally:
    ser.close()

看到什么算成功:

输入 1 开灯 / 0 关灯 / q 退出:1
板子回: LED ON
输入 1 开灯 / 0 关灯 / q 退出:0
板子回: LED OFF

并且板载灯真的跟着亮、灭。

读结果:

五、六个参数里,你只需要管一个

打开串口时可以配一堆参数,实际最常用的只有三个:

serial.Serial(
    port='/dev/ttyACM0',   # 端口名        ← 必填
    baudrate=9600,         # 波特率        ← 必填
    timeout=2,             # 读超时(秒)   ← 强烈建议填
)

完整的六件套是:波特率、数据位(8)、校验位(无)、停止位(1)、流控(无)、超时。

后四个几乎永远是默认值,只有接老式工业设备才需要改。

⚠️ 唯一的铁律:**两端波特率必须一致**

Arduino 里写 Serial.begin(9600),Python 里就得 baudrate=9600。 不一致的症状是一屏乱码(ÿÿÿ、ÿÿ 之类)——不是板子坏了。 常用值:9600(最保险)或 115200(快,但老式软件模拟串口撑不住)。

timeout 也值得说一句:它是"最多等多久"。不填的话,readline() 可能永远等下去(程序看起来像卡死)。新手一律填 1 或 2。

六、自己订一份协议(本节的核心概念)

为什么不能"想发什么就发什么"?

因为串口是一条字节流,它自己不带边界。你发 123456,对面根本不知道这是"1、2、3、4、5、6"还是"12、345、6"还是"123456"。

所以要订三个约定:

约定什么 常用做法 为什么
① 一行的边界 用换行符 \n 结尾 Arduino 的 Serial.println() 自带换行,正好
② 字段的分隔 用逗号 , 人类可读、Python 的 .split(',') 一行搞定
③ 字段的语义 谁在前谁在后,写死 双方都得照这个来

一个完整的例子——Arduino 每秒发一行:

pot,512,2.50
pot,738,3.61
pot,1023,5.00

读法是:类型, 原始值, 电压。

Arduino 端代码:

void setup() {
  Serial.begin(9600);
}

void loop() {
  int raw = analogRead(A0);           // 0 ~ 1023
  float volt = raw * 5.0 / 1023.0;    // 换算成电压

  Serial.print("pot,");               // 不换行
  Serial.print(raw);
  Serial.print(",");
  Serial.println(volt, 2);            // 换行,保留两位小数

  delay(500);
}

树莓派端解析:

import serial
import time

ser = serial.Serial('/dev/ttyACM0', 9600, timeout=2)
time.sleep(2)
ser.reset_input_buffer()

try:
    while True:
        line = ser.readline().decode('utf-8', errors='replace').rstrip()
        if not line:
            continue

        parts = line.split(',')
        if len(parts) != 3 or parts[0] != 'pot':
            print("格式不认识,跳过:", line)      # ← 容错
            continue

        try:
            raw = int(parts[1])
            volt = float(parts[2])
        except ValueError:
            continue                              # ← 再一层容错

        print(f"原始值 {raw:4d}   电压 {volt:.2f} V")
except KeyboardInterrupt:
    print("\n结束")
finally:
    ser.close()
💡 那两层"容错"才是这段代码的精髓

初学写串口程序,最容易犯的错是"假设收到的每行都完美"。 现实是:会收到半行、会收到空行、会在复位时收到乱码、会收到上一条的尾巴。 判据只有一条:能按格式解析就认,不能解析就丢掉继续——绝不让一行坏数据把整个程序搞崩。 这就是"协议设计"里所谓鲁棒性的最小实践。

七、⚠️ 排障表(本节最值钱的一张)

症状 最可能的原因 解法
Permission denied: /dev/ttyACM0 用户不在 dialout 组 sudo usermod -aG dialout $USER + 重启(8-2 五)
ls /dev/ttyACM* 什么都没有 USB 线是充电线/板子没插好 换一根线;拔插前后各 ls 一次对比
device reports readiness to read but returned no data 串口被别的程序占着 关掉 IDE 串口监视器/minicom;CH340 板还可能是 brltty 这个盲文服务在抢(sudo systemctl disable brltty)
一屏乱码 ÿÿÿ 波特率不一致,或没等复位就读 两端统一 9600;加 time.sleep(2) + reset_input_buffer()
UnicodeDecodeError 用 decode('utf-8') 去解二进制数据 改成 decode('utf-8', errors='replace'),或按二进制读
不报错,但一直没数据 Arduino 那边根本没在发 查它有没有 Serial.begin();波特率对不对;真的插上了吗
上传时报"端口忙" 你自己的 Python 脚本还开着 sudo lsof /dev/ttyACM0 看是谁占着

手动测一把(比写脚本更快看清问题):

python3 -m serial.tools.miniterm /dev/ttyACM0 9600
# 退出:Ctrl + ]

或者用 minicom:

sudo apt install minicom
minicom -D /dev/ttyACM0 -b 9600

排障的四步顺序(和 2-5 的网络四步法是同一种思路——从最底层往上查):

① 设备在不在?(ls /dev/tty*)      ← 线、板子、供电
② 权限够不够?(groups 有没有 dialout)← 用户组
③ 端口被占了吗?(lsof)             ← 别的程序
④ 参数对吗?(波特率、有没有等复位) ← 最后才怀疑代码

八、动手 Demo:一个小项目——树莓派当"显示器"

目标:Arduino 读电位器,树莓派接过来显示;电压超过一半时,树莓派反过来命令 Arduino 点亮灯。

这是一条完整的双向闭环,本章学的所有东西都用上了。

硬件:电位器接 Arduino 的 A0(两端接 5V 与 GND,中间脚接 A0)。

Arduino 端(dial.ino):

void setup() {
  Serial.begin(9600);
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  // ① 有命令就先处理命令
  if (Serial.available() > 0) {
    char c = Serial.read();
    if (c == '1') digitalWrite(LED_BUILTIN, HIGH);
    else if (c == '0') digitalWrite(LED_BUILTIN, LOW);
  }

  // ② 每 500 毫秒报一次读数
  static unsigned long last = 0;
  if (millis() - last > 500) {
    last = millis();
    int raw = analogRead(A0);
    Serial.print("pot,");
    Serial.print(raw);
    Serial.print(",");
    Serial.println(raw * 5.0 / 1023.0, 2);
  }
}
💡 这里为什么没用 `delay(500)`

8-3 的误区 7 提过:delay 会让程序"瞎掉"——等的那 500 毫秒里,串口来的命令一个都读不到。 所以这里改用 millis() 记时间戳:主循环跑得飞快(随时能收命令),只是每超过 500 毫秒才报一次数。 这就是"灯在闪、命令随时能收"的标准写法——8-3 留的那个坑,本节补上了。

树莓派端(dial.py):

import serial
import time

ser = serial.Serial('/dev/ttyACM0', 9600, timeout=1)
time.sleep(2)
ser.reset_input_buffer()

led_on = False

try:
    print("转一转电位器(Ctrl+C 结束)")
    while True:
        line = ser.readline().decode('utf-8', errors='replace').rstrip()
        if not line:
            continue

        parts = line.split(',')
        if len(parts) != 3 or parts[0] != 'pot':
            continue
        try:
            raw = int(parts[1])
            volt = float(parts[2])
        except ValueError:
            continue

        print(f"原始值 {raw:4d}   电压 {volt:.2f} V")

        # 超过 2.5V 就命令 Arduino 亮灯,低于就灭
        want = volt > 2.5
        if want != led_on:
            ser.write(b'1' if want else b'0')
            ser.flush()
            led_on = want
            print("   → 已发指令:", "开灯" if want else "关灯")
except KeyboardInterrupt:
    print("\n结束")
finally:
    ser.close()

看到什么算成功:

转一转电位器(Ctrl+C 结束)
原始值  128   电压 0.63 V
原始值  376   电压 1.84 V
原始值  612   电压 2.99 V
   → 已发指令: 开灯
原始值  995   电压 4.86 V
原始值  243   电压 1.19 V
   → 已发指令: 关灯

读结果:

观察 解释
数字每秒滚两行左右 millis() 那套逻辑在跑,每 500 毫秒一行
只在"跨过 2.5V"那一刻才发指令 if want != led_on 这句——状态没变就不发。这样每秒只发 0~1 条命令,不浪费串口
灯真的亮灭 双向通信成功了 ✅
偶发一行被"跳过" 可能是断电/复位时的半行数据被容错逻辑丢掉了——这是设计好的行为,不是 bug

九、30 秒实验:把线拔了,看会发生什么

故意做一次故障:脚本跑着的时候,直接拔掉 Arduino 的 USB 线。

你会看到:脚本抛出一堆东西——SerialException、device disconnected 之类,然后带着一大段报错栈退出。

读结果:

try:
    while True:
        ...
except KeyboardInterrupt:
    print("\n用户结束")
except serial.SerialException as e:
    print("\n串口断了:", e)      # ← 只报一句,不抛一堆栈
finally:
    try:
        ser.close()
    except Exception:
        pass
💡 这个习惯从哪来的

你在 C++ 课里学过 try / catch,Python 里是 try / except——同一件事。 区别在于:串口是"外部世界",外部世界一定会出意外。凡是跟硬件、网络、文件打交道的代码,都该假设对方会随时消失。 6-5 讲函数时说"没写 return 会返回 None",那是"自己代码里的意外";这里是"世界的意外",处理方式一样:接住它,别让它把程序掀翻。

十、常见误区

  1. "串口设备名一定是 ttyACM0" —— 原厂板是 ttyACM0,CH340 兼容板是 ttyUSB0。养成"拔插对比 ls"的习惯,别背名字。

  2. "报权限错就用 sudo 跑脚本" —— ❌ 会让脚本生成的配置文件归 root。正解是加 dialout 组并重启(8-2 五)。

  3. "打开串口不会影响板子" —— ⚠️ Uno R3 会复位约 2 秒(DTR 那一下)。不 sleep(2) 就会读到一堆乱码。R4 没这个问题。

  4. "readline() 一定能读到一整行" —— 不一定。超时会返回空(所以代码里要 if not line: continue),复位时可能读到半行。

  5. "忘了 ser.close() 没关系" —— ❌ 端口会一直占着。下次跑脚本就报"端口忙",而且你在 IDE 里也会连不上。用 finally 保证它一定执行。

  6. "收到的数据一定格式正确" —— ❌ 现实里会有半行、空行、乱码、上一条的尾巴。必须写"解析不了就丢弃"的容错。

  7. "串口可以双向同时高速通信" —— 串口是半双工式的一问一答通道(同一时刻主要朝一个方向流)。发完要留时间给对方回话(本例里那个 time.sleep(0.2) 就是这个意思)。

  8. "用 delay 简单省事" —— ❌ delay 期间程序收不到任何命令(8-3 误区 7)。要"一边做别的一边随时能收命令",就必须用 millis() 记时间戳——本节 Demo 示范了写法。

  9. "GPIO 直连更简洁,还省一根线" —— ⚠️ 先看清三个代价:电平不匹配会烧树莓派、串口默认被控制台占着、老教程改的文件位置已搬走。初学一律走 USB。

  10. "中文也能直接 print 给串口" —— 理论上能(只要两端编码一致),但调试时最容易出乱码。调试信息一律用英文和数字,要显示中文交给 Python 或网页去做(8-4 讲过同一条)。

自测三问(点开对答案) 1. 打开串口的时候 Arduino Uno 为什么突然重启了?怎么处理?哪块板子没这个问题? **参考答案**:Uno R3 的 USB 转串口芯片(ATmega16U2)**把 DTR 信号接到了主芯片的复位脚**,打开串口会让 DTR 动一下 → **主芯片复位约 2 秒**(这个设计是故意的,为的是"点一下上传就能烧程序")。处理办法是**两行**:`time.sleep(2)` 等它复位完,再 `ser.reset_input_buffer()` 清掉复位期间的垃圾字节。**Uno R4 没有这个问题**(USB 是主芯片原生的,默认不复位)。 2. 自己订串口协议,通常要约定哪三件事? **参考答案**:① **一行的边界**——用**换行符**结尾(Arduino 的 `Serial.println()` 自带);② **字段的分隔**——通常用**逗号**(Python 的 `.split(',')` 一行就能拆);③ **字段的语义**——谁在前谁在后写死(比如 `pot,512,2.50` 表示"类型, 原始值, 电压")。另外**必须写容错**:能按格式解析就认,不能解析就丢弃继续——因为现实里一定会收到半行、空行和乱码。 3. 串口三条物理线路(GPIO 直连方案)里,哪一条最危险?为什么? **参考答案**:**Arduino 的 TX → 树莓派的 RX 这一条**。因为 **Arduino 是 5V 逻辑、树莓派是 3.3V**,Arduino 会把 5V 电平直接送进树莓派的引脚,**可能烧掉树莓派的 SoC**。解法是加**电平转换模块**或用**两颗电阻分压**。反过来(树莓派 3.3V → Arduino 的 RX)不用处理,Arduino 认 3.3V 为高电平没问题。
📄 第八章读完了,回头看这条线

8-1 认识它(原来"没有操作系统"是这个意思)→ 8-2 把环境装上(顺手学了一套"设备组"的通用套路)→ 8-3 第一次把代码灌进芯片 → 8-4 换一种思路:芯片不变,指挥权交给 Python → 8-5 打开底层,自己订协议、自己排障。 走完这一章,你手上不再只有一台会干活的机器,而是一套分工明确的小系统: 树莓派负责"想"(读数据、判断、联网、存数据库、跑摄像头),Arduino 负责"马上动手"(精确、实时、扛得住、坏了不心疼)。 这也是本课一路走下来的必然结果——第七章让树莓派学会了"感知和改变物理世界",但它的短板(没有 ADC、Linux 定时不准)也在那里暴露了;这一章就是请来一位专门补这些短板的伙伴。 后面再遇到"摄像头、显示屏、机器人"这类综合项目,这套配合方式就是你手里最重要的工具。

相关笔记