第二章:日志数据采集——串口日志采集原理

好,咱们进入第二章。这一章我打算聊聊日志采集中最基础、也最绕不开的一环——串口日志采集。

你想想看,嵌入式设备刚上电那会儿,网络还没起来,文件系统还没挂载,唯一能跟外界“说话”的通道,往往就是那根串口线。我做了这么多年嵌入式,调试阶段90%的问题,都是靠串口日志定位的。所以,怎么把这根线上的数据稳稳当当地收上来,是咱们整个日志分析系统的地基。

2.1 串口通信的基本原理

串口通信,说白了就是一根线发、一根线收,再加上地线,就这么简单。数据是一位一位地传,不像并口那样一次传一堆。但你别小看它,稳定可靠是它的最大优点。

串口参数就那么几个:波特率、数据位、停止位、校验位。我见过不少新手,上来就栽在波特率不匹配上。两边都说自己是115200,结果收出来全是乱码。嗯,这里要注意,有些开发板的晶振不准,实际波特率会有偏差。

串口关键参数速查表:

参数 常见值 说明
波特率 9600, 115200, 921600 每秒传输的符号数,越高越快
数据位 8(最常用) 一帧数据中有效数据的位数
停止位 1, 2 帧结束标志,1位足够
校验位 None, Even, Odd 奇偶校验,调试阶段一般不用

我个人习惯,调试阶段一律用8数据位、1停止位、无校验。省心,兼容性最好。等产品定型了,再根据需求加校验。

2.2 使用Python的pyserial库采集串口数据

Python里操作串口,pyserial是事实上的标准库。我当年第一次用它,是在一个工控项目里,设备24小时不停机,日志哗哗地往外吐。那时候我就意识到,光会打开串口读数据远远不够,还得考虑怎么读得稳、读得快。

先看一个最基础的例子:

import serial

# 打开串口
ser = serial.Serial(
    port='COM3',        # Windows下是COM口,Linux下是/dev/ttyUSB0
    baudrate=115200,
    bytesize=8,
    parity='N',
    stopbits=1,
    timeout=1           # 读超时,单位秒
)

# 读一行数据
line = ser.readline()
print(line.decode('utf-8', errors='ignore'))

ser.close()

这段代码能跑,但别高兴太早。我在项目中遇到过一个问题:设备日志里偶尔会混进一些二进制数据,直接decode('utf-8')会抛异常。所以errors='ignore'这个参数,我建议你养成习惯加上。

小技巧: 如果你不确定串口设备用的是哪种编码,可以先用 ser.read(1024) 读一包原始字节,然后打印出来看看。我遇到过用GBK编码的国产设备,也遇到过纯ASCII的老设备。先看再解码,稳得很。

实际项目中,我们往往需要持续采集。这时候就要用循环了:

import serial
import time

ser = serial.Serial('COM3', 115200, timeout=0.1)

try:
    while True:
        if ser.in_waiting > 0:
            data = ser.read(ser.in_waiting)
            # 这里可以加你的处理逻辑
            print(data.hex())  # 先以十六进制打印,方便调试
        time.sleep(0.01)  # 别让CPU空转
except KeyboardInterrupt:
    print("采集停止")
finally:
    ser.close()

为什么用time.sleep(0.01)?说白了就是给CPU喘口气。如果不加,这个循环会吃掉一个核的全部资源。你想想看,嵌入式设备本来资源就紧张,采集程序再这么搞,其他任务就别想跑了。

2.3 日志数据缓存策略

好,数据能收了。但下一个问题来了:数据收上来放哪儿?

我曾经在一个项目中,设备每秒吐200条日志,每条大概200字节。如果不做缓存,直接写文件,你会发现磁盘I/O成了瓶颈。更糟糕的是,如果这时候程序崩溃,最后几秒的日志全丢了。

缓存策略,我一般分三层:

  1. 应用层缓存——Python里的队列
  2. 文件层缓存——内存映射文件或缓冲区写入
  3. 持久化存储——最终落盘

先看应用层缓存。我推荐用queue.Queue,它是线程安全的,生产者和消费者模式用起来特别顺手:

import queue
import threading
import serial

# 全局缓存队列
log_queue = queue.Queue(maxsize=10000)

def collector(port):
    """采集线程:只管往队列里塞数据"""
    ser = serial.Serial(port, 115200, timeout=0.1)
    while True:
        if ser.in_waiting > 0:
            data = ser.read(ser.in_waiting)
            log_queue.put(data)
        threading.Event().wait(0.01)

def writer():
    """写入线程:从队列里取数据,批量写入文件"""
    buffer = []
    while True:
        try:
            data = log_queue.get(timeout=1)
            buffer.append(data)
            if len(buffer) >= 100:  # 攒够100条再写
                with open('log.bin', 'ab') as f:
                    for item in buffer:
                        f.write(item)
                buffer.clear()
        except queue.Empty:
            # 超时了,把剩下的也写进去
            if buffer:
                with open('log.bin', 'ab') as f:
                    for item in buffer:
                        f.write(item)
                buffer.clear()

注意: 队列的maxsize一定要设。如果不设,内存会被撑爆。我见过一个同事,没设上限,设备跑了三天,队列里攒了上千万条数据,直接把服务器内存干到99%。

文件层缓存,我建议用内存映射文件(mmap)。特别是日志量特别大的时候,mmap比普通write快一个数量级。不过这个有点进阶,咱们后面章节再细讲。

最后说说持久化策略。我的经验是:

  • 按时间分片:每小时一个文件,或者每天一个文件。别把所有日志塞到一个文件里,后期查起来想死的心都有。
  • 写满就换:单个文件超过100MB就自动切分。这个大小是我个人习惯,你可以根据磁盘空间调整。
  • 压缩归档:超过7天的日志,自动压缩成gzip。我一般用Python的gzip模块,写的时候直接写压缩流,省事。

避坑指南: 我曾经在一个项目里,日志文件写满了磁盘,导致系统直接挂掉。从那以后,我每次都会在采集程序里加一个磁盘空间检查。低于500MB就发告警,低于100MB就自动删除最旧的日志文件。别指望运维人员会盯着磁盘,程序自己得学会保命。

嗯,这一章的内容差不多就这些。串口采集看起来简单,但要做好、做稳,还是有不少门道的。下一章咱们聊聊日志解析,那才是真正考验功力的地方。