串口通信就是一根线上的对话。本节讲透两件事:线路怎么连(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 口
- 树莓派上看到的名字:
/dev/ttyACM0(原厂板)或/dev/ttyUSB0(CH340 兼容板) - 好处:① 完全不用管电平(USB 协议自己处理,反正是数字信号)② 顺便供电 ③ 插上就能用
- 代价:多占一根线和两个 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 路线把电平、驱动、供电三件事全帮你处理了,代价只是多一根线。 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() |
去掉行尾的换行和空格(不做的话每次打印都会多空一行) |
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
并且板载灯真的跟着亮、灭。
读结果:
- Arduino 那三行(
available/read/ 判断)跟 8-3 第三步完全一样——只不过这次"敲键盘的人"换成了 Python 脚本 ser.write(cmd.encode('utf-8')):⚠️ 串口只能发字节,不能发字符串,必须先.encode()- 发完要
flush():确保数据真的推出去了,而不是还躺在缓冲里
五、六个参数里,你只需要管一个
打开串口时可以配一堆参数,实际最常用的只有三个:
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);
}
}
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",那是"自己代码里的意外";这里是"世界的意外",处理方式一样:接住它,别让它把程序掀翻。
十、常见误区
-
"串口设备名一定是
ttyACM0" —— 原厂板是ttyACM0,CH340 兼容板是ttyUSB0。养成"拔插对比ls"的习惯,别背名字。 -
"报权限错就用
sudo跑脚本" —— ❌ 会让脚本生成的配置文件归 root。正解是加dialout组并重启(8-2 五)。 -
"打开串口不会影响板子" —— ⚠️ Uno R3 会复位约 2 秒(DTR 那一下)。不
sleep(2)就会读到一堆乱码。R4 没这个问题。 -
"
readline()一定能读到一整行" —— 不一定。超时会返回空(所以代码里要if not line: continue),复位时可能读到半行。 -
"忘了
ser.close()没关系" —— ❌ 端口会一直占着。下次跑脚本就报"端口忙",而且你在 IDE 里也会连不上。用finally保证它一定执行。 -
"收到的数据一定格式正确" —— ❌ 现实里会有半行、空行、乱码、上一条的尾巴。必须写"解析不了就丢弃"的容错。
-
"串口可以双向同时高速通信" —— 串口是半双工式的一问一答通道(同一时刻主要朝一个方向流)。发完要留时间给对方回话(本例里那个
time.sleep(0.2)就是这个意思)。 -
"用
delay简单省事" —— ❌delay期间程序收不到任何命令(8-3 误区 7)。要"一边做别的一边随时能收命令",就必须用millis()记时间戳——本节 Demo 示范了写法。 -
"GPIO 直连更简洁,还省一根线" —— ⚠️ 先看清三个代价:电平不匹配会烧树莓派、串口默认被控制台占着、老教程改的文件位置已搬走。初学一律走 USB。
-
"中文也能直接 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 为高电平没问题。- ☑ 第八章 完结 —— 教材这一章已读完五节:认清 Arduino 这块"没有操作系统的板子"、在树莓派上把开发环境装好、用 IDE 写出并上传第一个程序、用 Python 通过 Firmata 从外面指挥它,最后自己订一份串口协议把两块板子接成一条完整的双向回路
- ☑ 第九章 Raspberry Pi Pico 开发板与 MicroPython 语言 已完结:9-4 使用 MicroPython 控制 Raspberry Pi Pico 开发板(machine 三件兵器+电位器调灯闭环)
8-1 认识它(原来"没有操作系统"是这个意思)→ 8-2 把环境装上(顺手学了一套"设备组"的通用套路)→ 8-3 第一次把代码灌进芯片 → 8-4 换一种思路:芯片不变,指挥权交给 Python → 8-5 打开底层,自己订协议、自己排障。 走完这一章,你手上不再只有一台会干活的机器,而是一套分工明确的小系统: 树莓派负责"想"(读数据、判断、联网、存数据库、跑摄像头),Arduino 负责"马上动手"(精确、实时、扛得住、坏了不心疼)。 这也是本课一路走下来的必然结果——第七章让树莓派学会了"感知和改变物理世界",但它的短板(没有 ADC、Linux 定时不准)也在那里暴露了;这一章就是请来一位专门补这些短板的伙伴。 后面再遇到"摄像头、显示屏、机器人"这类综合项目,这套配合方式就是你手里最重要的工具。
相关笔记
- 认识 Raspberry Pi Pico 开发板 —— 下一章起点:家族里的另一块板,与 Uno 的对照表在这一节
- 使用 MicroPython 控制 Raspberry Pi Pico 开发板 —— 本节订的行协议三规矩,在 machine.UART 里原样适用
- 使用 Python 开发 Arduino 程序 —— 上一节:用现成协议(Firmata)指挥,本节是自己订协议
- 使用 Arduino IDE 建立 Arduino 程序 —— 本节 Arduino 端那三行"读命令控灯",原型就在那里
- 在树莓派建立 Arduino 开发环境 ——
dialout组为什么必须加、端口名怎么确认 - 认识 Arduino Uno 开发板 —— 0/1 号脚为什么"不能接别的东西",答案在这里揭晓
- Python 函数与模块 ——
apt装库为什么要带python3-前缀、import时又去掉 - 模拟输入 —— 本项目里那颗电位器的接法,7-5 已练过一遍
- 网络连接与蓝牙装置 —— "四步排障法"的原始版本,本节那张串口排障表是同一个思路
- Linux 操作系统的目录结构 ——
/boot/firmware搬家的出处(GPIO 直连要改的配置文件在那儿) - 🍓 树莓派 · 索引 —— 全课路线图