当前位置:首页>Linux>如何在Linux中,实现一个简单的串口数据采集?

如何在Linux中,实现一个简单的串口数据采集?

  • 2026-10-12 20:12:45
如何在Linux中,实现一个简单的串口数据采集?
做嵌入式 Linux 项目时,串口几乎是绕不开的基础能力。

最开始我只是想做一个“能从串口读数据的小程序”,但后面发现,事情并没有那么简单: 串口数据是异步到达的,解析有耗时,日志写文件又比较慢,如果全都堆在一个线程里,代码很快就会变得混乱,而且不利于扩展。

于是,我把这个小项目逐步做成了一个更完整的版本:基于 Embedded Linux 的串口数据采集与处理程序,实现了多线程采集、环形缓冲区解耦、按行拆包、协议解析、时间戳日志记录,以及条件变量同步与优雅退出。


一、项目要解决的问题

目标很明确:

  • 从串口读取原始数据
  • 对串口数据进行拆包和解析
  • 将解析结果输出并写入日志文件
  • 保证整个流程结构清晰、便于扩展

为了让结构更合理,我把整个程序拆成了几个功能模块:

  • uart 模块:负责串口打开、配置和读取
  • ring_buffer 模块:负责线程间数据传递
  • parser 模块:负责协议解析
  • log 模块:负责日志写入
  • worker 模块:负责线程入口和整体流程组织
  • main 模块:负责初始化、创建线程和资源回收

二、为什么这个项目不能只用一个线程?

一开始会写成这样:

  1. 读串口
  2. 解析数据
  3. 写日志
  4. 再回去继续读串口

这种写法看起来简单,但很快就会遇到问题。

因为串口读取是实时的,数据什么时候来并不由你控制。 如果这个时候程序正在做复杂解析,或者正在执行文件写入,就可能导致读取不及时,严重时甚至丢数据。

所以这个项目采用了多线程架构:

  • 一个线程负责读取串口数据
  • 一个线程负责解析数据
  • 一个线程预留给日志处理

这样做的好处是:

  • 采集、解析、日志彼此解耦
  • 慢速操作不会阻塞串口采集
  • 后续扩展更容易

一句话总结就是:

多线程不是为了“炫技”,而是为了把不同速度、不同职责的任务拆开,让系统结构更清晰。


三、项目整体架构是什么样的?

整个程序的数据流如下:

数据源 -> uart_thread -> raw ring buffer -> parse_thread -> log_write -> app.log

也就是说:

  1. uart_thread 持续获取原始数据
  2. 数据先写入 ring buffer
  3. parse_thread 从缓冲区按字节读取数据
  4. 拼成一条完整消息后调用 parse_line() 解析
  5. 解析结果输出到终端,并写入日志文件

从结构上看,这其实就是一个很典型的生产者-消费者模型。

  • 生产者:串口读取线程
  • 消费者:解析线程
  • 中间缓冲:环形缓冲区

四、为什么中间要加 ring buffer?

这是整个项目里非常关键的一点。

串口收到的数据,本质上不是“一条一条消息”,而是连续字节流。 比如你希望收到的是:

TEMP:25,HUM:60

但真实情况可能是:

  • 第一次 read() 只拿到 TEMP:2
  • 第二次 read() 又拿到 5,HUM:60\n

也可能一次读到两条甚至更多消息。

所以,线程之间不能简单地“拿字符串传来传去”,而应该先用一个更适合字节流的缓冲结构把数据存起来。

这就是 ring buffer 的意义。

环形缓冲区为什么适合这个场景?

因为它有几个天然优势:

  • 适合连续字节流
  • 内存固定,不频繁 malloc/free
  • 读写效率高
  • 非常适合嵌入式场景

这也是为什么很多串口、网络、驱动相关的程序,都会选择环形缓冲区来做中间层。


五、这个项目里 ring buffer 怎么设计的?

在这个项目里,环形缓冲区内部维护了几个关键字段:

  • buffer[]:实际存储数据的数组
  • head:写入位置
  • tail:读取位置
  • count:当前已有数据量
  • mutex:互斥锁
  • not_empty / not_full:条件变量
  • stop:退出标志

升级后的逻辑是:

  • 队列为空时,消费者线程阻塞等待
  • 队列满时,生产者线程阻塞等待
  • 当有新数据写入时,唤醒消费者
  • 当有空间可写时,唤醒生产者
  • 退出时通过 stop 标志和广播唤醒实现优雅退出

六、为什么解析线程要按字节读取?

原因很简单:串口读到的是字节流,不保证一次 read() 就刚好是一整条消息。

所以解析线程必须做两件事:

  1. 从 ring buffer 中按字节读取
  2. 遇到 \n 才认为一条消息结束

例如接收到:

TEMP:25,HUM:60\n

解析线程会:

  • 一个字节一个字节读
  • 累积到 line[]
  • 遇到换行符时,把整行作为一条完整消息处理

这种方式虽然看起来“慢一点”,但更符合真实串口场景,也更稳妥。


七、协议解析是怎么做的?

当解析线程拿到一整行字符串后,就会调用 parse_line()。

当前项目中,先实现了一个简单协议格式:

TEMP:25,HUM:60

解析完成后,结构化结果大概是这样:

  • temp = 25
  • hum = 60
  • raw = "TEMP:25,HUM:60"

在代码中,parse_line() 使用 sscanf() 完成解析。 如果格式匹配成功,就返回成功;如果格式不对,就返回失败。

所以终端最终会看到类似这样的输出:

parse ok: temp=25 hum=60 raw=TEMP:25,HUM:60

如果数据格式不合法,就会输出:

parse failed: raw=......

这样做的好处是:

  • 后续容易扩展协议字段
  • 合法和非法数据都能区分
  • 更利于日志记录和调试

八、为什么还要加日志模块?

因为终端输出只是临时可见,程序一结束,很多关键信息就没了。 而日志文件可以帮助我们:

  • 复现运行过程
  • 查看历史数据
  • 追踪某个时刻的异常
  • 方便后续调试和分析

所以这个项目专门加了 log 模块,把每次解析成功或失败的结果都写入 logs/app.log。

后来又进一步加了时间戳,日志从原来的:

parse ok: temp=21 hum=51 raw=TEMP:21,HUM:51

升级成:

[2026-04-07 17:31:55] parse ok: temp=21 hum=51 raw=TEMP:21,HUM:51

九、为什么程序退出还要专门设计?

这是多线程程序中非常容易被忽视的一点。

如果线程正在等待条件变量,而主线程直接退出,子线程就可能永远卡住,导致 pthread_join() 无法返回,程序无法干净结束。

所以这个项目增加了:

  • running 运行标志
  • stop 停止标志
  • rb_stop() 停止函数
  • pthread_cond_broadcast() 广播唤醒机制

退出流程大概是这样:

  1. 主线程设置 running = false
  2. 调用 rb_stop() 通知环形缓冲区停止
  3. rb_stop() 内部设置 stop = true
  4. 广播唤醒所有等待线程
  5. 等待线程检测到 stop 后退出循环
  6. 主线程 join 回收资源

这一步的意义在于:

避免线程永久阻塞,实现优雅退出。


十、项目目录结构长什么样?

整个项目目录结构如下:

serial_project/├── Makefile├── README.md├── include/│   ├── common.h│   ├── config.h│   ├── uart.h│   ├── ring_buffer.h│   ├── parser.h│   ├── log.h│   └── worker.h├── src/│   ├── main.c│   ├── uart.c│   ├── ring_buffer.c│   ├── parser.c│   ├── log.c│   └── worker.c├── build/└── logs/

这样的目录划分有几个好处:

  • 头文件和源文件分离
  • 模块职责清晰
  • 便于后续维护
  • 更像一个标准工程,而不是“单文件小程序”

十一、项目最终实现了什么?

目前这个项目已经完成了以下内容:

  • 串口模块实现
  • 环形缓冲区实现
  • 多线程主框架实现
  • 按行拆包实现
  • 协议解析实现
  • 日志记录实现
  • 时间戳日志实现
  • 条件变量同步实现
  • 优雅退出实现

十二、这个项目还能怎么继续优化?

虽然现在已经能跑、能讲、能展示,但如果继续扩展,还可以做很多事情,比如:

1. 接入真实串口数据源

目前为了验证流程,有一部分测试是用假数据生成器完成的。 后续可以彻底切回真实串口输入,让项目更贴近实际设备场景。

2. 增加 CRC 或校验机制

很多实际协议不会只靠字符串格式判断,还会带校验位,防止数据出错。

3. 支持配置文件

例如把串口设备名、波特率、日志路径等放到配置文件中,而不是写死在头文件里。

4. 增加独立日志线程

当前是解析线程直接写日志,后续可以真正启用第三个线程,把日志写入进一步解耦。

5. 增加日志级别

比如支持 INFO / WARN / ERROR,让日志内容更清晰。


十三、这个项目最大的收获是什么?

如果只看功能,这个项目做的是“串口采集 + 数据解析 + 日志记录”。 但如果从工程训练角度看,它真正锻炼的是下面这些能力:

  • 如何拆模块
  • 如何设计线程协作
  • 如何处理字节流
  • 如何用缓冲区解耦
  • 如何实现线程同步
  • 如何处理程序退出
  • 如何把一个 demo 做成一个可讲、可扩展的工程

这也是很多人写项目时最容易忽略的一点:

真正有价值的,不只是“功能跑起来”,而是你有没有把结构设计清楚。


十四、写在最后

这个项目从最开始的“先把目录搭起来”,一步一步做到了:

  • 先让工程能编译
  • 再把串口模块跑通
  • 再做 ring buffer
  • 再做线程框架
  • 再做拆包
  • 再做协议解析
  • 再做日志
  • 最后加入条件变量和优雅退出

整个过程其实很像真实开发: 不是一口气把所有代码写完,而是先搭骨架,再逐步补全功能,最后再做工程化优化。


源代码地址:https://gitee.com/GIT-Tammy/serial_project

运行效果:TEMP 温度   HUM 湿度

最新文章

随机文章