树莓派5六路UART实战:串口配置、GPIO扩展与传感器接入
树莓派5发布之后,我习惯性地先用老办法去配串口——改
/boot/config.txt
、加
dtoverlay=uart2
、然后满心期待地打开
/dev/ttyAMA0
。结果发现全部失效,
ls /dev/ttyAMA*
出来一串设备节点,但不管怎么试,能用的依然是默认控制台那一路。后来翻了官方文档才明白,树莓派5的UART体系已经彻底变了:BCM2712芯片内部有6个独立的PL011串口,默认console用的是
ttyAMA10
,而不再是老用户熟悉的
ttyAMA0
或
serial0
。这个认知一旦建立,整个项目的可能性一下就打开了——6路串口同时接传感器阵列、GPS、雷达测距,再分出一路做GPIO扩展,正好覆盖我当前机器人网关的所有需求。
这篇文章我会从硬件映射讲起,然后用一个真实项目为例:一台树莓派5同时完成GPIO扩展(16路继电器与按键控制)、四类串口传感器的数据采集、以及稳定的串口调试链路搭建。适合想把树莓派5当作数据采集网关、机器人主控或工控上位机的开发者参考。
1. 树莓派5的6个UART:先搞清楚它们在哪,别再用旧教程
1.1 树莓派5的UART和树莓派4差别有多大
树莓派4的时代,40pin排针上能用的串口非常有限:一个PL011串口(ttyAMA0)通常被蓝牙占用,剩下一个mini UART(ttyS0)性能差、波特率不稳定,真正可自由支配的就那么一路半。很多人最终选择USB转串口芯片堆数量,这本身没错,但USB口数量、供电、驱动稳定性都是坑。
树莓派5的BCM2712把这个问题彻底解决了:芯片内部集成了6个独立的PL011 UART,全部是硬件串口,带标准FIFO,不再依赖那个老掉牙的mini UART。代价是配置方式变化很大,老教程基本没法直接套用。
第一点是设备节点变了。树莓派5上UART0对应的Linux设备节点是
/dev/ttyAMA10
,不是传统意义上的
ttyAMA0
。第二点是overlay命名变了。树莓派4里的
dtoverlay=uart2
这种写法在树莓派5上是无效的,官方为Pi5专门提供了带
-pi5
后缀的overlay。第三点是默认状态变了。除了GPIO 14/15上那路默认给Linux console用的串口,其余5路默认全部关闭,需要手动开启。
这三个差异叠加起来,就导致大量沿用旧经验的开发者绕了不少弯路。我一开始也是栽在
ttyAMA0
上,浪费了差不多一天时间。
1.2 6路UART的引脚映射表与启用配置
这是我整理好的树莓派5的40pin排针上6路UART的映射关系,结合了官方文档和我实机验证的结果:
| 设备节点 | 默认状态 | GPIO引脚(TX/RX) | config.txt overlay配置 |
|---|---|---|---|
| /dev/ttyAMA10 | 默认console串口 | GPIO 14 / GPIO 15 | 内置启用,需改cmdline.txt释放 |
| /dev/ttyAMA0 | 默认禁用 | GPIO 0 / GPIO 1 | dtoverlay=uart1-pi5 |
| /dev/ttyAMA1 | 默认禁用 | GPIO 4 / GPIO 5 | dtoverlay=uart2-pi5 |
| /dev/ttyAMA2 | 默认禁用 | GPIO 8 / GPIO 9 | dtoverlay=uart3-pi5 |
| /dev/ttyAMA3 | 默认禁用 | GPIO 12 / GPIO 13 | dtoverlay=uart4-pi5 |
| /dev/ttyAMA4 | 默认禁用 | GPIO 16 / GPIO 17 | dtoverlay=uart5-pi5 |
注意,GPIO 0/1这一组在不同固件版本的命名有过调整,有的固件里叫
uart1-pi5
,有的需要查
dtoverlay -a
的输出确认。我建议动手前先跑一下:
dtoverlay -a | grep uart
然后在
/boot/firmware/config.txt
末尾追加。这里要特别提醒:树莓派5的启动固件分区已经变成
/boot/firmware/
,不再是老系统的
/boot/
。如果你还按习惯去编辑
/boot/config.txt
,改完重启不会有任何效果。
我当前项目用的是后4路,外加默认控制台那路,所以配置是这样的:
# /boot/firmware/config.txt 追加
dtoverlay=uart2-pi5
dtoverlay=uart3-pi5
dtoverlay=uart4-pi5
dtoverlay=uart5-pi5
保存重启后,
ls /dev/ttyAMA*
应该能看到从
ttyAMA1
到
ttyAMA4
这几个新设备。
1.3 启用后的验证方法:设备节点与符号链接
重启完不要急着接传感器,先把串口状态确认清楚:
ls -l /dev/serial*
ls /dev/ttyAMA*
cat /proc/device-tree/aliases/serial*
dmesg | grep -i uart
/dev/serial0
这个符号链接依然存在,默认指向
ttyAMA10
。如果你在代码里继续用
serial0
,打开的依然是GPIO 14/15那一路默认串口。
验证某个串口是否正常工作,最快的方法是直接把TX和RX用杜邦线短接,然后自发自收:
picocom -b 115200 /dev/ttyAMA1
在picocom里敲几个字符,如果屏幕回显说明这路串口收发正常。为什么先做这一步?因为我遇到过overlay加载成功、设备节点也在,但实际上电平配置不对的情况,这时候直接接传感器往往更难判断问题出在哪。
2. 项目总览:一台树莓派5同时干三件事的架构设计
2.1 为什么是6路UART而不是USB转串口集线器
很多人的第一反应是:树莓派5有4个USB口,插一个USB HUB再挂4个USB转TTL不也照样能接4个传感器吗?
从功能上讲确实能跑,但如果你需要长时间稳定运行,差别非常明显。USB转串口方案有几个绕不开的问题:
第一是USB带宽共享。树莓派5的USB控制器是PCIe转出来的,带宽本身够用,但如果你同时接了USB摄像头、无线键鼠接收器、M.2 SSD扩展板,再加上多路USB转串口同时全速收发,偶尔的调度延迟就会导致串口丢字节。第二是设备节点漂移。USB转串口每次重新插拔后
/dev/ttyUSB*
编号可能变化,多路同时插拔时设备名会乱跳,这对于自动启动的服务来说很致命。第三是供电问题。USB转串口模块大多直接吃USB口的5V,插4个模块就是额外的电流负担,树莓派5本身功耗就不小,长期满载供电压力更大。
原生UART走的完全是另一条链路:每个UART有独立时钟、独立FIFO、独立中断,数据不经过USB协议栈,天然不会互相争抢。GPIO 14/15那路控制台串口,即使不做任何配置,也是系统最稳定的调试通道。
所以我的结论是:固定传感器、长期跑的服务走原生UART,USB转TTL只留给临时调试和PC通信。
2.2 每个UART的职责分配表
这个项目的目标很明确:树莓派5作为机器人网关,一边控制继电器和采集按键状态,一边读取环境传感器数据,同时保留一路调试口用来随时看日志。
我最终分配如下:
| 串口节点 | 波特率 | 用途 | 对接设备 |
|---|---|---|---|
| /dev/ttyAMA10 | 115200 | 系统调试口 | USB转TTL接PC(XCOM调试) |
| /dev/ttyAMA0 | 9600 | GPS定位 | NEO-6M GPS模块 |
| /dev/ttyAMA1 | 9600 | PM2.5空气质量 | 攀藤PMS7003 |
| /dev/ttyAMA2 | 9600 | CO2浓度 | Senseair S8 |
| /dev/ttyAMA3 | 115200 | 激光测距 | TF-Luna |
| /dev/ttyAMA4 | 115200 | GPIO扩展协处理器 | STM32F103C8T6 |
这样6路全部利用起来,每路串口跑一个独立设备,互不干扰。
2.3 供电、共地与接线规划
多传感器接入最常见的低级错误是供电和共地问题,这个问题会在串口调试时反复折磨你,所以我提前讲清楚。
树莓派5的5V引脚总输出能力有限,尤其是当你外接了电机驱动、继电器、多个传感器模块时,全部从树莓派5V引脚取电很容易造成电压跌落,进而引发串口数据异常甚至系统重启。我建议的做法是:树莓派用官方电源供电,传感器阵列单独用一个5V/3A的DC-DC稳压模块供电,两套电源的GND必须可靠连接在一起。所有串口通信的电压参考都是GND,不共地你会看到一串乱码,或者干脆收不到任何数据。
信号电平方面,树莓派GPIO是3.3V逻辑,很多传感器模块是5V TTL。GPS模块和PMS7003大多兼容3.3V,但有些继电器模块的逻辑输入是5V标准,直接用3.3V可能驱动不稳定。如果需要做电平转换,我推荐用最简单的双向电平转换模块,4路一起才几块钱。或者干脆把电平转换做在协处理器那边,用3.3V供电的STM32最小板直接对接继电器,用5V电源给继电器供电,两边只通过光耦或三极管做信号转发,这样既隔离了电源又干净利落。
3. GPIO扩展:用一路UART换16路GPIO,协处理器方案完整拆解
3.1 为什么不做“树莓派GPIO直连”,而要做“串口转GPIO”
如果你只是控制几个LED,树莓派的GPIO直连完全够用。但我的项目里需要控制12路继电器,还要读4路按键状态,再加上几个状态指示灯,40pin排针上剩下的空闲引脚根本不够。更麻烦的是,继电器动作瞬间的电流冲击和电磁干扰很容易影响树莓派GPIO电平稳定性,导致误触发或者系统重启。
这时候“1路UART串口转16路GPIO扩展芯片”的思路就派上用场了。市面上确实有专门的串口转GPIO扩展模块,原理都是内部集成一颗MCU,UART接收主控指令,然后操作GPIO。这个方案的核心优势是:树莓派只负责通过串口发指令,所有实时性要求高的IO操作全部交给协处理器,两者之间是松耦合的关系。
我自己没有直接用成品模块,而是用一片STMF103C8T6最小系统板自己实现了同样功能。原因有两个:一是可以自定义协议,比如增加批量读取按键状态的命令,这是很多成品模块不支持的;二是成本低,一片STM32F103C8T6蓝色Pill板子十几块钱,却能提供比成品模块灵活得多的控制逻辑。这套思路和很多工控领域“串口协议控制远程IO”的做法完全一致,只是把距离缩短到了板级。
3.2 串口协议设计与帧格式
GPIO扩展通信协议我参考了行业里常见的串口帧设计,固定长度、帧头+命令+数据+校验,不依赖行尾,方便做状态机解析。
帧格式定义如下:
| 字节 | 内容 | 说明 |
|---|---|---|
| Byte0 | 0xA5 | 帧头1 |
| Byte1 | 0x5A | 帧头2 |
| Byte2 | CMD | 命令字 |
| Byte3 | CH | 通道号 |
| Byte4 | DATA | 数据 |
| Byte5 | CHECKSUM | 异或校验,Byte0到Byte4异或 |
命令字定义:
| 命令 | 值 | 含义 |
|---|---|---|
| CMD_WRITE_ONE | 0x01 | 写单路电平,CH=0~15,DATA=0/1 |
| CMD_READ_ONE | 0x02 | 读单路电平,CH=0~15,返回DATA=0/1 |
| CMD_WRITE_ALL | 0x03 | 批量写16路,CH=0,DATA为16位位图,这里设计成两帧发送 |
| CMD_SET_DIR | 0x04 | 设置通道方向,DATA=0输出,DATA=1输入 |
| CMD_READ_KEYS | 0x05 | 读4路按键状态,返回4字节位图 |
回复帧统一为:
0xA5 0x5A 0x80|CMD 状态 数据 校验
。状态0表示成功,非0表示参数错误。
以“让第3路继电器吸合”为例,数据帧是:
A5 5A 01 03 01 FA
校验计算:
A5 ^ 5A ^ 01 ^ 03 ^ 01 = FA
。这个校验算法非常简单,单片机上一行代码就能搞定,虽然安全性不如CRC16,但在这个场景下足够可靠。实际使用中如果发现偶发帧错误,把校验算法升级成CRC16-Modbus即可,协议骨架完全不需要改。
3.3 树莓派端控制代码与协处理器端状态机
树莓派端用Python直接写,pyserial一发一收,逻辑非常清晰:
import serial
ser = serial.Serial("/dev/ttyAMA4", 115200, timeout=0.2)
def gpio_write_one(channel, level):
frame = bytes([0xA5, 0x5A, 0x01, channel & 0x0F, level & 0x01])
checksum = 0
for b in frame:
checksum ^= b
frame += bytes([checksum])
ser.write(frame)
resp = ser.read(6)
if len(resp) == 6 and resp[2] == 0x81 and resp[3] == 0x00:
return True
return False
def gpio_read_keys():
frame = bytes([0xA5, 0x5A, 0x05, 0x00, 0x00])
checksum = 0
for b in frame:
checksum ^= b
frame += bytes([checksum])
ser.write(frame)
resp = ser.read(7)
if len(resp) == 7 and resp[2] == 0x85:
return resp[4], resp[5]
return None
协处理器端我用STM32标准库写,串口中断接收配合状态机,解析到完整一帧后执行GPIO操作:
uint8_t rx_buf[6];
uint8_t rx_idx = 0;
void USART1_IRQHandler(void) {
if (USART_GetITStatus(USART1, USART_IT_RXNE)) {
uint8_t b = USART_ReceiveData(USART1);
if (rx_idx == 0 && b != 0xA5) return;
if (rx_idx == 1 && b != 0x5A) { rx_idx = 0; return; }
rx_buf[rx_idx++] = b;
if (rx_idx >= 6) {
uint8_t sum = 0;
for (int i = 0; i < 5; i++) sum ^= rx_buf[i];
if (sum == rx_buf[5]) {
handle_frame(rx_buf);
}
rx_idx = 0;
}
}
}
状态机的核心思路是:帧头不对就直接丢弃,帧头对了就继续收,收满6字节后校验,校验通过才执行命令。这个逻辑放在中断里执行时间很短,不影响其他实时任务。
3.4 实际控制效果与常见坑
这套方案实际跑起来效果非常好。树莓派端发一条指令,STM32回复时间在1ms以内,继电器动作迅速。相比树莓派GPIO直连,这套方案最大的好处是可以把“控制逻辑”和“硬件执行”彻底分开。树莓派崩溃重启,继电器保持最后状态,不会因为系统重启瞬间GPIO抖动导致误动作。
踩坑经验也分享几个:
波特率不要用太高。STM32F103的最小系统板如果用的是内部RC振荡器,波特率115200时误差会偏大,9600就稳定得多。我这个项目用115200是因为树莓派和STM32都有外部晶振,如果你用的廉价板子没有外部晶振,老老实实降到9600。
协处理器复位时可能吃掉第一个字节。如果你的协议帧被吃了一半,状态机会永远等不到完整帧。解决办法是在树莓派端发送前先发一个0x00作为“清空”操作,或者让STM32复位后自动丢弃串口接收缓冲区的残留数据。
继电器必须加续流二极管。继电器线圈是感性负载,断电瞬间会产生反向电动势,如果不加续流二极管直接并联在驱动管两端,轻则串口数据乱码,重则打坏STM32的GPIO。这是做继电器控制最常踩的坑,没有之一。
4. 传感器阵列实战:让每个UART各司其职
4.1 传感器选型:哪些传感器天然带UART
项目里我选了四类传感器,全部是UART接口,这比I2C更不容易出地址冲突问题。最典型的有:
GPS模块(NEO-6M或ATGM336H)输出NMEA 0183协议,ASCII文本,9600波特率,获取经纬度、速度、UTC时间,解析简单直观。
PM2.5粉尘传感器(攀藤PMS7003)输出二进制帧,9600波特率,每帧32字节,包含PM1.0、PM2.5、PM10质量浓度,数据按主动模式每800ms自动推一帧,也可以切到被动模式按需读取。
CO2传感器(Senseair S8)是很多新风系统、空气质量监测设备里都在用的一款,9600波特率,支持Modbus风格命令,通过发送特定指令读取CO2浓度,测量范围400~10000ppm。
激光测距模块(TF-Luna)默认115200波特率,主动输出9字节帧,包含距离、信号强度信息,最远测距能到8米,非常适合做机器人避障和液位测量。
这些模块选UART接口都有个共同逻辑:传感器本身自带MCU,已经把原始数据解析成了结构化的数据帧,上位机只需要按照协议解析即可,比直接读ADC或I2C寄存器简单得多。
4.2 逐个接入:接法、波特率、数据帧示例
接线方面,所有传感器都是同样的套路:传感器TX接树莓派RX,传感器RX接树莓派TX,GND共地,VCC按模块要求接3.3V或5V。唯一要注意的是TX/RX别接反,接反了的结果是数据能发出去但收不回来,或者反过来。
给一个我实际验证过的数据帧示例:
PMS7003主动模式的一帧(前10字节):
42 4D 00 1C 00 01 00 02 00 03 ...
帧头固定
42 4D
,第3字节
00 1C
表示数据区长度28字节,后面就是各粒径浓度值。解析时先找帧头,再按长度字段取完整帧,最后校验。
Senseair S8读取CO2浓度的指令是:
FE 04 00 00 00 04 E5 C6
返回的6字节数据帧中,第4和第5字节分别代表CO2浓度的高8位和低8位。这个模块用9600波特率,8位数据位,无校验,1位停止位。
4.3 多路串口并发读取:用select把4路数据同时收下来
串口多了之后,一个很现实的问题是怎么同时读4路又互不阻塞。
最容易想到的方案是每个串口开一个线程,各自
read()
循环。这种方案简单直观,但Python的多线程受GIL限制,当某一路数据量大、处理逻辑复杂时,其他路的读取延迟会变大。更关键的是,如果某一路传感器暂时没有数据,线程会一直阻塞在
read()
上,虽然不占CPU但也增加了调试复杂度。
我推荐用
selectors
库做多路复用,单线程同时监听多个串口,谁有数据就处理谁:
import serial
import selectors
sel = selectors.DefaultSelector()
sers = {
"gps": serial.Serial("/dev/ttyAMA0", 9600, timeout=0),
"pm25": serial.Serial("/dev/ttyAMA1", 9600, timeout=0),
"co2": serial.Serial("/dev/ttyAMA2", 9600, timeout=0),
"tof": serial.Serial("/dev/ttyAMA3", 115200, timeout=0),
}
for name, ser in sers.items():
sel.register(ser, selectors.EVENT_READ, data=name)
def handle_data(name, ser):
data = ser.read(ser.in_waiting or 1)
print(f"[{name}] {data.hex()}")
while True:
events = sel.select(timeout=1)
for key, _ in events:
handle_data(key.data, key.fileobj)
timeout=0
表示非阻塞读取,
select
负责通知哪路串口有数据可读。这样4路传感器同时工作,CPU占用率几乎可以忽略。
4.4 半包/粘包问题的处理思路
串口本质是字节流,没有消息边界。你读到的数据可能是一帧的中间部分,也可能一次读到了两帧半。处理这个问题的标准思路是“先找帧头,再按长度收整帧”。
以PMS7003为例,帧头是
0x42 0x4D
,帧长度通过第3字节计算获得。我写了一个通用的缓冲区解析函数:
def extract_frames(buffer):
frames = []
while True:
idx = buffer.find(b"\x42\x4d")
if idx < 0:
buffer = buffer[-3:]
break
if idx > 0:
buffer = buffer[idx:]
if len(buffer) < 4:
break
frame_len = (buffer[2] << 8 | buffer[3]) + 4
if frame_len < 4 or frame_len > 64:
buffer = buffer[4:]
continue
if len(buffer) < frame_len:
break
frames.append(buffer[:frame_len])
buffer = buffer[frame_len:]
return frames, buffer
用法是:每次从串口读到的数据先拼接到一个全局缓冲区,然后调用
extract_frames
取出完整帧,剩下的残留在缓冲区等下一次数据到达。这个方案能处理所有粘包和半包场景,不管是二进制帧还是带行尾的ASCII帧都一样适用。
5. 串口调试的完整工具箱:从USB转TTL驱动到软件工具链
5.1 三种主流USB转TTL芯片怎么选
调试阶段你一定会用到USB转TTL模块,不管是用树莓派的调试串口连PC,还是想在PC上直接看传感器输出。市面最常见的方案有CH340、CP2102/CP2104、FT231X/FT232R三种,我做个对比:
| 芯片 | VID:PID | Linux驱动 | Windows驱动 | 常见问题 | 适用场景 |
|---|---|---|---|---|---|
| CH340/CH341 | 1a86:7523 | 内核自带 | 官方驱动,需下载 | 仿冒芯片装不上驱动 | 成本敏感、个人项目 |
| CP2102/CP2104 | 10c4:ea60 | 内核自带 | 官方驱动 | 老驱动Win10下异常 | 稳定可靠、推荐 |
| FT231X/FT232R | 0403:6001 | 内核自带 | 系统自带免驱 | 市面假货多 | 专业调试、工业场景 |
Linux下基本全免驱,Windows下CH340必须装官方驱动,否则只有黄色感叹号;CP2102的新驱动VCP在Win10/11下都比较稳定;FT232R确实是免驱,但假货芯片在Windows下会被识别成其他VID,导致驱动装不上。
我个人的建议是:买CP2102或者CP2104,兼容性好,驱动不容易出幺蛾子。如果只买一个模块备用,CH340也够用,毕竟便宜。
5.2 Windows和Linux下常用调试工具盘点
Windows端我主力用XCOM,这个工具的HEX模式很直观,适合看二进制传感器帧。SSCOM是老牌,功能也全,但界面比较老旧。友善串口助手适合快速测试,只是历史版本偶尔会被杀毒软件误报,介意的话换一个。
Linux端(包括树莓派系统)我推荐用小工具组合。日常调试会话用
picocom
就够,退出方式是
Ctrl+A
然后
Ctrl+Q
,很多人第一次用都卡在退出上:
picocom -b 115200 /dev/ttyAMA10
如果只想快速看一眼有没有数据,直接用
cat
加
stty
:
stty -F /dev/ttyAMA1 9600 raw -echo
cat /dev/ttyAMA1
上线后需要长时间记录日志,用
tio
或者
minicom
配合日志文件,更适合做持续数据采集。
5.3 虚拟串口、串口监听与自动化测试工具
调试串口程序时,有一个非常好用的技巧是创建虚拟串口对,让两个不相干的程序互相通信,模拟真实设备交互。
Windows下用VSPD或com0com创建虚拟串口对,比如COM3和COM4互连,程序A往COM3发数据,程序B从COM4就能收到。Linux下用
socat
创建伪终端:
socat -d -d pty,raw,echo=0 pty,raw,echo=0
运行后会输出两个
/dev/pts/N
设备,它们互相转发数据,原理和VSPD一样。
当你需要确认某个传感器模块到底发了什么数据,而模块又不能直接连PC时,用串口监听工具截获USB转串口芯片和PC之间的数据流很有帮助。Windows下的Device Monitoring Studio可以做到,Linux下可以用Wireshark配合usbmon抓USB数据包,方法虽然重但能治本。
如果做的是需要定时发送指令的自动化测试,比如压力测试GPIO扩展板,可以用按键精灵的串口插件在Windows下定时发指令,或者干脆用Python的
schedule
库。Unity里做串口可视化时要注意一点:不要在Unity主线程里阻塞读串口,必须开一个后台线程读数据,再把数据通过线程安全的方式传给主线程渲染,否则画面会卡死。
5.4 固定USB转串口设备名的udev规则
调试串口时最常见的一个坑是:USB转TTL模块今天插上去是
/dev/ttyUSB0
,明天另一个模块插上去变成了
ttyUSB1
,代码里的路径就失效了。
解决这个问题靠udev规则。先在系统里查一下这个USB转串口芯片的VID和PID:
lsusb
CH340通常是
1a86:7523
,CP2102是
10c4:ea60
,FT232是
0403:6001
。然后创建udev规则文件:
sudo nano /etc/udev/rules.d/99-usb-serial.rules
写入:
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="ttyDebug"
保存后执行
sudo udevadm control --reload-rules
,重新插拔模块,你会发现无论插在哪个USB口,系统都会在
/dev/ttyDebug
这个固定路径下创建设备节点。代码里直接写
/dev/ttyDebug
,再也不用担心设备名漂移。
6. 实测踩坑记录:数据丢失、burst、阻塞与非阻塞
6.1 树莓派5上串口数据丢失的完整排查链路
这个项目推进到一半时,我发现PM2.5数据偶尔丢帧。直接拿USB转TTL把PMS7003接电脑用XCOM看,一切正常;接回树莓派,日志里就时不时少一帧。这个问题折腾了我几乎一整天,排查链路非常有代表性,我把完整过程写出来。
首先,先排除硬件问题。把树莓派的所有串口TX和RX短接,自发自收,发送1000帧数据,统计收到多少帧。结果显示全部正常,说明树莓派6路UART的底层硬件和驱动没问题。
然后,用逻辑分析仪夹在PMS7003的TX引脚上,抓到的波形显示传感器确实完整输出了每一帧,32字节一帧,一秒一帧,没有任何缺失。这说明问题不在传感器,也不在硬件连接,而是数据到了树莓派之后在某个环节丢了。
接着,我把目光放在内核tty层。查看串口统计信息:
cat /proc/tty/driver/ttyAMA
这个文件会列出每个串口接收和发送的字节数。盯着
rx
计数器看,发现PMS7003每发一帧,
rx
计数增长的量比实际字节少,说明数据在到达应用层之前就已经在内核tty缓冲区中被丢弃了。
再翻我的代码,问题立刻暴露:我的主循环里用了一个
time.sleep(0.1)
,然后调用
ser.read(1024)
,按理说PMS7003一秒一帧根本不至于溢出。但问题出在
time.sleep(0.1)
期间,如果有两路串口同时到达数据,另一路GPS的NMEA数据量更大,解析耗时更长,PM2.5这一路就可能在内核翻转缓冲区被覆盖。
后来我做了两件事:第一,把数据采集和数据解析彻底分离,采集线程只负责从串口读取原始字节放入队列,解析线程处理队列里的数据;第二,改用
selectors
多路复用,不再每路单独sleep等待。
改完之后连续跑48小时,一帧都没有再丢过。
6.2 高波特率下的串口burst与内核缓冲区
项目里还踩过一个和burst相关的坑。TF-Luna激光测距模块默认115200波特率,上电后立即开始持续输出数据,每秒约100帧9字节。如果应用在树莓派启动后5秒才起来,这段时间内核tty层已经积压了数KB的数据,但tty翻转缓冲区的大小是有限的,新到的数据会把还没来得及被应用读取的旧数据覆盖掉。结果就是应用启动后第一次读取,能读出一堆不完整帧。
处理办法非常简单:
ser.reset_input_buffer()
在打开串口后立刻调用,把内核缓冲区里积压的旧数据清空。同样的道理也适用于传感器热插拔:模块重新上电后,串口缓冲里可能有残留数据,采集前清一下准没错。
如果某个传感器数据量特别大、持续高速输出,应用偶尔处理不过来,可以考虑用
setserial
调大内核tty缓冲区,但更稳妥的思路是让传感器工作在被动模式或者主动降低输出频率。比如TF-Luna可以配置成被动模式,只在需要时向它发送指令触发单次测距,数据量从每秒100帧降到每秒5帧,系统压力小很多。
6.3 释放默认控制台串口的注意事项
最后讲一个很容易被忽略的细节:如果你想把GPIO 14/15那路默认的Linux console串口也拿来当应用串口,不能只删config.txt里的配置,因为console的占用是在cmdline.txt里指定的。
树莓派5上需要修改
/boot/firmware/cmdline.txt
,找到类似
console=serial0,115200
或
console=ttyAMA10,115200
这一段,把它删掉,保留
console=tty1
给HDMI终端输出,然后重启。
重启后
/dev/ttyAMA10
就不会再有内核日志往外刷了,你可以在自己的程序里正常打开这个设备。但要注意:树莓派启动早期阶段,firmware console可能仍然短暂占用这路串口。如果你的应用是在系统完全启动后才启动的,完全不影响;但如果你有一个非常早期的服务想在开机瞬间就使用这路串口,需要进行更细致的调整,普通项目通常没必要纠结这个。
改
cmdline.txt
前务必备份,这个文件一旦改错,树莓派可能无法正常启动。保险起见,可以备一个SD卡镜像再动手。
另一个小提醒:LTP(文件权限)导致打不开串口的报错也很常见。树莓派OS里默认
dialout
组的成员才能访问串口设备,如果你的程序运行时提示
Permission denied
,执行:
sudo usermod -a -G dialout 你的用户名
重新登录后生效。
最后再分享两个工作中的小习惯。第一,给每一路串口所在的物理接口贴上标签,写上设备节点和对应设备名,比如“ttyAMA1-PMS7003”,调试多路数据时能省掉大量反复确认的时间。第二,在一个终端窗口里同时监视6路串口数据流时,给每路数据加上时间戳和串口名前缀,格式统一成
HH:MM:SS.mmm [设备名] 数据内容
,这样对比各路数据到达顺序和延迟时一目了然。这两条算不上什么高深技巧,但做多串口项目时非常实用。
更多推荐


所有评论(0)