第四章 实时数据读取:串口通信基础与pyserial实战
各位同学,今天我们来聊聊心电数据采集中最关键的一环——怎么把硬件设备上的数据,稳稳当当地读到电脑里来。
说实话,我刚开始做心电项目时,以为串口通信就是简单的「插上线、读数据」。结果呢?数据丢包、帧错位、缓冲区溢出……各种问题轮番上阵。那段时间我几乎每天都在跟串口较劲。今天就把这些经验分享给你们,少走点弯路。
4.1 串口通信基础:别小看这根线
串口通信,说白了就是一根线一根线地传数据。一次传一个比特,像排队过安检一样。虽然现在USB、蓝牙满天飞,但在医疗设备、工业控制领域,串口依然是老大哥。
为什么?因为它简单、可靠、实时性好。你想想看,心电信号每秒才几百个采样点,串口完全够用。
串口通信三要素:
- 波特率:每秒传输的比特数。心电设备常用115200或57600
- 数据位:通常是8位,刚好一个字节
- 停止位:1位或2位,用来分隔数据包
我在项目中遇到过最坑的事:设备说明书上写的是115200波特率,结果实际是57600。读出来的数据全是乱码。嗯,后来我养成了一个习惯——拿到新设备,先用示波器量一下实际波特率。
4.2 pyserial库使用:Python的串口瑞士军刀
Python里操作串口,pyserial是首选。安装很简单:
pip install pyserial
基本用法,我给你们写个模板:
import serial
import serial.tools.list_ports
# 第一步:找到设备
ports = serial.tools.list_ports.comports()
for port in ports:
print(f"发现设备:{port.device} - {port.description}")
# 第二步:打开串口
ser = serial.Serial(
port='COM3', # Windows下是COM口,Linux下是/dev/ttyUSB0
baudrate=115200, # 波特率
bytesize=8, # 数据位
parity='N', # 校验位:N无校验,E偶校验,O奇校验
stopbits=1, # 停止位
timeout=0.1 # 超时时间,单位秒
)
# 第三步:读取数据
data = ser.read(10) # 读取10个字节
print(data.hex()) # 打印十六进制
# 用完记得关
ser.close()
我的小技巧:timeout参数别设太大。0.1秒足够,设大了程序会卡死。我曾经设了5秒,调试时等得想砸电脑。
4.3 数据帧解析:从字节流中提取心电信号
串口传过来的是一堆字节,怎么从中提取出心电数据?这就要看数据帧格式了。
典型的心电数据帧长这样:
帧头(2字节) | 数据长度(1字节) | 数据体(N字节) | 校验和(1字节)
0xAA 0x55 0x0A 10个数据字节 CRC
解析代码:
def parse_frame(data):
"""解析心电数据帧"""
if len(data) < 4:
return None
# 检查帧头
if data[0] != 0xAA or data[1] != 0x55:
return None
# 获取数据长度
data_len = data[2]
# 检查数据完整性
if len(data) < 4 + data_len:
return None
# 提取数据体
payload = data[3:3+data_len]
# 校验和检查(简单累加校验)
checksum = sum(payload) & 0xFF
if checksum != data[-1]:
print("校验和错误,数据可能损坏")
return None
return payload
注意:帧头千万别用0x00或0xFF。我见过有人用0x00做帧头,结果数据里出现0x00就乱套了。选帧头要选数据中不常见的值。
4.4 缓冲区设计:给数据找个临时仓库
串口数据不是一口气全来的,而是一点一点地到。所以我们需要缓冲区,先把数据存起来,等凑够一帧再处理。
我常用的环形缓冲区:
class RingBuffer:
"""环形缓冲区,固定大小,自动覆盖旧数据"""
def __init__(self, size=4096):
self.buffer = bytearray(size)
self.head = 0 # 写指针
self.tail = 0 # 读指针
self.size = size
def write(self, data):
"""写入数据"""
for byte in data:
self.buffer[self.head] = byte
self.head = (self.head + 1) % self.size
# 如果写指针追上读指针,说明缓冲区满了
if self.head == self.tail:
self.tail = (self.tail + 1) % self.size
def read(self, length):
"""读取数据"""
if self.available() < length:
return None
data = bytearray()
for _ in range(length):
data.append(self.buffer[self.tail])
self.tail = (self.tail + 1) % self.size
return data
def available(self):
"""可读数据量"""
return (self.head - self.tail) % self.size
为什么用环形缓冲区?因为内存分配是性能杀手。你想想看,心电数据每秒几百个包,如果用列表不断append,Python的内存管理会疯掉的。
4.5 丢包处理策略:数据丢了怎么办?
串口通信不是100%可靠的。电磁干扰、线缆松动、缓冲区溢出……丢包是常态。
我总结了几种处理策略:
| 策略 | 原理 | 适用场景 |
|---|---|---|
| 重传机制 | 发现丢包后,请求设备重发 | 实时性要求不高的场景 |
| 插值补全 | 用前后数据估算丢失的数据 | 心电信号,短时丢包可接受 |
| 丢弃处理 | 直接丢掉损坏的数据包 | 数据量大,丢包率低 |
| 冗余编码 | 发送时加入冗余信息,接收端纠错 | 强干扰环境 |
心电项目中,我推荐「插值补全+丢弃处理」组合拳。为什么?因为心电信号是连续的,丢几个点用线性插值就能补上。但如果连续丢包超过5个,就别补了,直接标记为无效数据。
丢包检测代码:
def detect_packet_loss(packet_seq, expected_seq):
"""检测是否丢包"""
if packet_seq == expected_seq:
return 0 # 正常
elif packet_seq > expected_seq:
loss_count = packet_seq - expected_seq
print(f"检测到丢包:{loss_count}个包")
return loss_count
else:
# 序列号回绕,说明是新的周期
return 0
避坑指南:我曾经在强电磁环境下调试心电设备,丢包率高达30%。后来发现是串口线没屏蔽。换了带屏蔽的线,丢包率降到1%以下。所以,硬件问题先排查,别一上来就写复杂算法。
4.6 实战:完整的数据读取流程
把上面的知识点串起来,就是一个完整的数据读取流程:
import serial
import threading
from collections import deque
class ECGDataReader:
"""心电数据读取器"""
def __init__(self, port, baudrate=115200):
self.ser = serial.Serial(port, baudrate, timeout=0.05)
self.buffer = RingBuffer(8192)
self.data_queue = deque(maxlen=1000)
self.running = False
def start(self):
"""启动读取线程"""
self.running = True
self.thread = threading.Thread(target=self._read_loop)
self.thread.daemon = True
self.thread.start()
def _read_loop(self):
"""后台读取循环"""
while self.running:
# 读取原始字节
raw_data = self.ser.read(256)
if raw_data:
# 写入缓冲区
self.buffer.write(raw_data)
# 尝试解析帧
self._try_parse_frame()
def _try_parse_frame(self):
"""尝试从缓冲区解析数据帧"""
# 查找帧头
while self.buffer.available() >= 4:
# 检查帧头
if self.buffer.peek(2) == b'\xAA\x55':
# 读取帧长度
data_len = self.buffer.peek(3, 1)[0]
# 检查是否够一帧
if self.buffer.available() >= 4 + data_len:
frame = self.buffer.read(4 + data_len)
payload = parse_frame(frame)
if payload:
self.data_queue.append(payload)
else:
break
else:
# 跳过无效字节
self.buffer.read(1)
def stop(self):
"""停止读取"""
self.running = False
self.ser.close()
这个类封装了串口读取、缓冲区管理、帧解析的全部逻辑。你只需要调用start(),然后从data_queue里取数据就行。
好了,这一章的内容就到这里。下一章我们会讲怎么把读到的数据实时绘制成波形图。记住,串口通信是心电系统的「咽喉」,这里出问题,后面全是白搭。动手试试吧,有问题随时问我。