树莓派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 [设备名] 数据内容 ,这样对比各路数据到达顺序和延迟时一目了然。这两条算不上什么高深技巧,但做多串口项目时非常实用。

更多推荐