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

"用 Python 开发 Arduino"的准确意思是——用 Python 从外面指挥 Arduino。做法是给 Arduino 烧一份官方固件(StandardFirmata),把它变成一块"哑巴 I/O 卡":自己不做任何决定,Python 说拉高就拉高。

📝 本节和前面几节的关系

8-3 的思路是"写 C++ 进芯片",本节换了一条路:芯片里跑的代码固定不变,逻辑全搬到 Python 里。 本节踩到的三个老熟人:PEP 668(6-5 那个"pip 装不了库"的坑)、venv(6-5 学的)、电位器接模拟脚(7-5 接过一模一样的东西)。 本节与 8-5 的区别要先分清:本节用现成的标准协议(Firmata),8-5 是自己订协议(纯串口收发)。前者省事、后者更灵活也更快。

一、先破一个误解:Python 能"直接跑"在 Arduino 上吗

不能——至少 Uno 系列不能。

8-1 讲过:Uno 只有 2KB 内存,而一个 Python 解释器本体就要几百 KB。装不进去。

所以"用 Python 和 Arduino 打交道"这件事,实际上有三条路,先把它们分清楚:

方式 Python 跑在哪 芯片里跑什么 Uno 能用吗
① 串口通信(pyserial) 树莓派 你自己写的 C++ ✅ 能(8-5 讲)
② Firmata 协议(本节) 树莓派 官方固件 StandardFirmata ✅ 能
③ MicroPython 就在芯片上 Python 解释器 ❌ Uno 不行

方式 ③ 要用什么板子? 内存够大的那些:

⚠️ 所以本节标题要这样理解

"使用 Python 开发 Arduino 程序"——不是"在 Arduino 上用 Python 写程序",而是"用 Python 去指挥 Arduino"。 这个区别很关键,因为它决定了后面所有限制的来源:Python 那台机器(树莓派)必须在场。

二、思路:把 Arduino 变成一块"哑巴 I/O 卡"

打个比方:

8-3 的方式 = 你给一个工人写了张施工图,他自己照着干(Arduino 自己有逻辑)。 本节的方式 = 你把工人变成一只听话的手,你让他抬手他就抬手(逻辑全在你这边)。

技术上怎么实现:给 Arduino 烧一份官方提供的标准固件 StandardFirmata。它做的事情只有一件——一直监听串口,收到指令就照做:

Python 发指令:"把 13 脚拉高"   →   Arduino 拉高 13 脚
Python 发指令:"A0 现在多少?"   →   Arduino 读 A0 并把结果发回来

Firmata 就是一个协议:双方约定好"发这个字节代表做那个动作"。

代价是什么(三条,都要接受)

代价 说明
延迟 每次操作都要走一趟 USB 串口,实测 10~25 毫秒。点灯、转慢舵机没问题;高频操作就不行
离不开树莓派 Arduino 自己不记得任何逻辑。树莓派一断、脚本一停,它就冻在那儿
不省事在"实时"上 本来用 Arduino 就是为了微秒级实时(8-1 讲的),结果所有决定都绕到树莓派再绕回来

换来什么? 不用写一行 C++,改逻辑不用重新上传。 而且——Python 那一整套生态(摄像头、网络、数据库、画图)都能直接调用。

三、动手:三步

3.1 第一步:把 StandardFirmata 烧进 Arduino

在 Arduino IDE 里:文件 → 示例 → Firmata → StandardFirmata,然后点上传。

用 arduino-cli 的话(8-2 装的那个),先找到示例路径:

arduino-cli sketch new ~/Arduino/MyFirmata     # 先建个空程序占位
# 示例文件在核心包里,路径类似:
find ~/.arduino15 -name "StandardFirmata.ino" 2>/dev/null | head -3

找到后把那个文件复制进你的程序目录、改名成同名,再编译上传(或者干脆用 IDE 做这一步,这一步用图形界面更省事)。

看到什么算成功:上传过程无报错。烧完之后板载灯不再闪了(因为 StandardFirmata 什么也不主动做,它只是等命令)。

💡 这一步只做一次

烧进去的固件会留在芯片里(断电不丢)。之后你改 Python 脚本一百遍,都不用再碰 Arduino。 这也正是这条路最舒服的地方。

3.2 第二步:在树莓派上装 Python 库

⚠️ 先回忆 6-5 那个坑:从 Bookworm 起,系统不允许直接 pip install 往全局装包(PEP 668),会报 externally-managed-environment。

所以走 venv:

python3 -m venv ~/arduino-env            # 建一个虚拟环境
source ~/arduino-env/bin/activate        # 激活它
pip install pyfirmata2 pyserial          # 装库

看到什么算成功:提示 Successfully installed pyfirmata2-... pyserial-...

⚠️ 三件必须记住的事

① 每次新开终端都要重新激活:source ~/arduino-env/bin/activate。忘了激活就会报 ModuleNotFoundError: No module named 'pyfirmata2'——不是没装,是你没进那个环境。 ② 用 pyfirmata2 而不是 pyfirmata:原版 pyfirmata 基本已经停止维护,pyfirmata2 是活跃的现代分支(支持 Python 3.10+,异步处理与内存管理都改好了)。老教程里写 pip install pyfirmata,那是旧写法。 ③ 这个环境只管这一章:以后要装别的库,还是建议按项目各自建 venv(6-5 讲过的习惯)。

3.3 第三步:跑第一个脚本(用 Python 点灯)

新建 py_blink.py:

from pyfirmata2 import Arduino
import time

board = Arduino('/dev/ttyACM0')      # ← 端口名按你实际的改

try:
    led = board.get_pin('d:13:o')    # 数字脚 13,输出
    for i in range(5):
        led.write(1)                 # 高 → 灯亮
        time.sleep(0.5)
        led.write(0)                 # 低 → 灯灭
        time.sleep(0.5)
finally:
    board.exit()                     # 一定要收尾,否则串口不释放

跑:

python3 py_blink.py

看到什么算成功:板载 L 灯闪 5 次(快闪,每半秒一换),脚本跑完正常退出。

读结果:

值得注意的 为什么
一行 C++ 都没写 逻辑全在 Python 这边
不用重新上传 改这里的 0.5 就能改速度,Arduino 那边的固件一动不动
最后一行 board.exit() 必须调用。不调用的话,串口不释放,下次跑脚本会报"端口被占"

四、'd:13:o' 这个字符串怎么读

这是 pyfirmata 的核心写法,三段用冒号隔开:

'd'  :  13  :  'o'
 │       │      └── 模式:o = 输出 / i = 输入 / p = PWM 输出 / s = 舵机
 │       └──────── 脚号
 └──────────────── 类型:d = 数字脚 / a = 模拟脚

完整对照表:

想做什么 写法
13 脚输出(数字) 'd:13:o'
5 脚 PWM 输出(调亮度) 'd:5:p'
9 脚接舵机 'd:9:s'
读 7 脚的高低(按钮) 'd:7:i'
读 A0 的电压 'a:0:i'

用法:

led  = board.get_pin('d:13:o')
led.write(1)            # 输出:写 1=高、0=低、0.5=半亮(PWM 脚)
led.write(0.5)

btn  = board.get_pin('d:7:i')
btn.read()              # 输入:返回 True / False

pot  = board.get_pin('a:0:i')
pot.read()              # 模拟输入:返回 0.0 ~ 1.0 的浮点数
⚠️ ⚠️ 模拟输入有两条特殊规矩

① 用原版 pyfirmata 时,必须调用 pot.enable_reporting()——不调用就永远读到 None(Arduino 默认不主动上报模拟值,得先告诉它"开始报")。 pyfirmata2 会自动处理这一步,所以上面的代码能直接用。 ② 必须起一个后台线程(Iterator)——见下一节,这是本节最大的坑。

五、⚠️ 本节最大的坑:必须起 Iterator 线程

现象:读模拟量读到一堆 None/读数一直不动/脚本跑几十秒后崩掉。

原因:Arduino 会源源不断地把模拟值往串口推。如果 Python 不持续去读,数据就会在缓冲区里越堆越多——最后溢出,通信全乱。

解决:开一个后台线程专门负责"收信":

from pyfirmata2 import Arduino, util

board = Arduino('/dev/ttyACM0')
it = util.Iterator(board)      # 建这个"收信员"
it.start()                     # 让他开始工作

这两行是模拟输入的标配,忘了就等着被坑。

💡 为什么"不读会溢",8-5 会从原理上再讲一遍

串口是一个很小的缓冲区(几十到几百字节)。发得比读得快,它一定会塞满——这是所有串口程序的通病,不管是 Firmata 还是你自己写协议。 记住这条:"有人在不停地说话,你就必须有人在不停地听"。

六、动手 Demo:Python 点灯 + 读电位器

硬件:把电位器按 7-5 学的接法接到 Arduino 上(三个脚都要接:两端分别接 5V 和 GND,中间脚接 A0)。

为什么是 5V 而不是 3.3V?因为 Arduino 是 5V 逻辑的板子(8-1 讲过)。这次是接到 Arduino 上,不是接树莓派——别接错了板子。

代码(py_pot.py):

from pyfirmata2 import Arduino, util
import time

board = Arduino('/dev/ttyACM0')
it = util.Iterator(board)        # 起"收信员"
it.start()

led = board.get_pin('d:13:o')
pot = board.get_pin('a:0:i')     # A0,模拟输入

try:
    print("转一转电位器,看看读数(Ctrl+C 结束)")
    while True:
        v = pot.read()
        if v is not None:
            volt = v * 5.0        # Arduino 的满量程是 5V
            print(f"读数 {v:.3f}   约 {volt:.2f} V")
            # 顺便让灯跟着亮灭:电压超过一半就亮
            led.write(1 if v > 0.5 else 0)
        time.sleep(0.3)
except KeyboardInterrupt:
    print("\n结束")
finally:
    board.exit()

看到什么算成功:

读数 0.000   约 0.00 V
读数 0.253   约 1.27 V
读数 0.508   约 2.54 V
读数 0.997   约 4.99 V

而且转电位器时数字在变,板载灯大致在"一半以上"亮起。

读结果(逐条对):

观察 解释
读数在 0.0 ~ 1.0 之间 pot.read() 返回的是归一化后的小数(不是 0~1023 的原始值)
乘 5.0 得到电压 Arduino 的 A0 满量程是 5V(对比 7-5:树莓派那边接了 3.3V 的 MCP3008,所以是乘 3.3)
只有十几个不同的值 每次读要走一趟 USB,10~25ms 延迟——这就是"用 Python 指挥"的代价
转得很快时读数跟不上 同上,不是坏了,是这条路本来就这样
💡 这段代码其实就是"闭环控制"的雏形

转电位器 → 读电压 → 决定灯亮不亮——这就是一条完整的感知 → 判断 → 动作链路(7-5 末尾那个"电位器控灯亮度"是同一件事,只是这次"要动的那只手"变成了 Arduino)。 本章真正的价值就在这里:树莓派负责"想"(读数据、判断、以后还能联网、存数据库、跑摄像头),Arduino 负责"动手"。

七、30 秒实验:亲手量一量这条 USB 的延迟

本节一直在说"延迟 10~25 毫秒"——这个数字其实可以自己量出来。量完,你对"该不该走这条路"就有判断力了。

在 8-4 那个 venv 里跑(记得先激活):

import time
from pyfirmata2 import Arduino, util

board = Arduino('/dev/ttyACM0')
it = util.Iterator(board)
it.start()
pot = board.get_pin('a:0:i')

time.sleep(1)                    # 等它开始上报数据

t0 = time.perf_counter()
n = 0
while n < 200:
    v = pot.read()
    if v is not None:
        n += 1
    time.sleep(0.005)            # ⚠️ 别读太快(下面解释)
used = time.perf_counter() - t0

print(f"读到 200 个有效值,用了 {used:.2f} 秒")
print(f"平均每个值 {used / 200 * 1000:.1f} 毫秒")
board.exit()

你会看到类似:

读到 200 个有效值,用了 4.30 秒
平均每个值 21.5 毫秒

读结果:

观察 解释
每个值要 20 毫秒上下 这个数就是"数据绕一趟 USB 再回来"的代价
和树莓派本地 GPIO 比 7-3 里读一次按钮是微秒级的——差了一万倍
为什么加了 time.sleep(0.005) ⚠️ 这一条最反直觉:pot.read() 读的是 Python 这边的缓存值。不加延时疯狂读,你会反复读到同一个旧数——读得快 ≠ 数据新
数据到底多久更新一次 由 Arduino 上报的频率决定(Firmata 默认大约每 20 毫秒报一次),跟你 Python 读多快没关系
💡 这个数决定了"该不该走这条路"
  • 做界面、记数据、跟摄像头联动 → 20 毫秒完全无所谓,走这条路很划算。
  • 控制舵机、步进电机 → 20 毫秒是灾难(舵机靠脉冲宽度定位,抖动会让它嗡嗡响)。这类活应该在 Arduino 上写 C++ 本地干(8-3 那条路)。
  • 一句话原则:需要"快"的部分留在 Arduino 上,需要"想"的部分放到树莓派上。

八、什么时候该走这条路,什么时候别走

情况 用 Firmata(本节)合适吗
做个桌面控制台/数据记录/跟摄像头或网络联动 ✅ 非常合适(Python 生态全能用上)
只是想让灯闪、按钮有反应 ✅ 合适,比写 C++ 省事
控制舵机、步进电机(要精确定时) ❌ 不合适,延迟和抖动会让它抖个不停
要脱机独立运行(不插树莓派) ❌ 不行,逻辑不在板子上
每秒要读几千次 ❌ 不行,USB 延迟撑不住

一句话总结这条路:省事的代价是"离不开"和"慢一点"。

九、常见误区

  1. "Python 可以直接跑在 Arduino Uno 上" —— ❌ Uno 不行(2KB 内存装不下解释器)。要在板子上跑 Python,选 Nano ESP32/Nano RP2040 Connect 那类。本节讲的是"从外面指挥"。

  2. "不用给 Arduino 烧固件" —— ❌ 必须先烧 StandardFirmata。烧完之后板子就变成"哑巴执行器"了,这也是为什么烧完之后板载灯不再闪。

  3. "pyfirmata 和 pyfirmata2 装哪个都行" —— 用 pyfirmata2。原版基本停止维护,老教程里的 pip install pyfirmata 是旧写法。

  4. "直接 pip install 就行" —— ❌ 从 Bookworm 起会被 PEP 668 拦下(6-5 讲过)。必须建 venv,而且每次新开终端要重新 activate。

  5. "读模拟量直接读就行" —— ⚠️ 两个条件:起 util.Iterator 线程(不起会缓冲溢出)、原版 pyfirmata 还要 enable_reporting()。

  6. "这个延迟和本地 GPIO 差不多" —— 差得远。每次操作 10~25 毫秒(要绕一趟 USB),而树莓派本地 GPIO 是微秒级。这是本章最需要权衡的一个数。

  7. "Arduino 会记住我写的逻辑" —— ❌ 在 Firmata 模式下它什么逻辑都没有(这正是"哑巴卡"的意思)。树莓派一断,它就冻在那儿。

  8. "本节和 8-5 是一件事" —— 不是。本节用的是现成的标准协议(Firmata,规则别人订好了);8-5 是自己订协议(发什么字符、怎么分隔字段,你自己设计)。前者省事,后者灵活也更快。

  9. "电位器两端也接 3.3V 吧" —— ⚠️ 本节是接在 Arduino 上,5V 逻辑,两端接 5V 和 GND。别和 7-5 接在树莓派/ADC 上的接法混了。

自测三问(点开对答案) 1. "用 Python 开发 Arduino 程序"这句话准确的含义是什么?为什么 Uno 不能让 Python 直接跑在上面? **参考答案**:准确含义是 **"用 Python 从外面指挥 Arduino"**:给 Arduino 烧官方固件 `StandardFirmata`,把它变成一块**哑巴 I/O 卡**,逻辑全在 Python 这边。**Uno 跑不了 Python 的原因**:它只有 **2KB 内存**,而 Python 解释器本体就要几百 KB,**根本装不进去**。想在板子上跑 Python,要换成 Nano ESP32/Nano RP2040 Connect/Pico 这类。 2. 读模拟量的时候,"必须起 Iterator 线程"是为了解决什么问题? **参考答案**:Arduino 会**持续不断地把模拟值往串口推**。Python 如果不持续读,数据会在**串口缓冲区**里越积越多,最后**溢出、通信全乱**(表现为读到一堆 `None`、读数不动、脚本崩掉)。`util.Iterator(board)` + `it.start()` 就是开一个**专用后台线程不停收信**。 3. 走 Firmata 这条路,最大的三个代价是什么? **参考答案**:① **延迟**——每次操作要绕一趟 USB,实测 **10~25 毫秒**,做舵机/步进电机这类精确定时就不行;② **离不开树莓派**——Arduino 自己不存任何逻辑,树莓派一断它就不动了;③ **丢掉了 Arduino 最大的长处**——本来是冲着"微秒级实时"才用它的,结果所有决定都绕到树莓派再绕回来。换来的好处是**不用写 C++、改逻辑不用重新上传、Python 生态全能用**。

相关笔记