本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:在Linux系统中,串口通信是嵌入式开发、设备调试和模块数据交互的基础技术之一。本文围绕 read_seri.c write_seri.c 两个C语言源码文件,详细讲解如何在Linux下通过操作/dev/ttySx设备文件实现串口数据的读取与发送。内容涵盖串口基本概念、termios结构体配置、波特率设置及read/write系统调用的使用方法,并介绍非阻塞读取、流控制等实际应用中的关键处理机制。通过本实例学习,开发者可掌握Linux下底层串口通信的核心编程技巧,为嵌入式项目开发提供坚实基础。
串口读写

1. 串口通信基础概念与Linux设备映射(/dev/ttySx)

在嵌入式系统中,串口通信是设备间低速数据交互的核心方式之一。Linux将物理串口抽象为字符设备文件,通常命名为 /dev/ttyS0 /dev/ttyS1 等,其中 ttySx 对应标准UART接口(如COM1→ttyS0)。这些设备支持标准文件I/O操作,可通过 open() read() write() 等系统调用进行访问。

ls /dev/ttyS*
dmesg | grep tty

上述命令可用于检测系统中已识别的串口设备及其初始化信息。设备权限需确保当前用户可读写(如通过 chmod 或加入 dialout 组),否则将导致打开失败。理解设备映射机制是后续配置与编程的前提。

2. RS-232/RS-485串口标准与termios结构体配置

在嵌入式系统和工业自动化领域,串行通信协议作为底层数据传输的基石,广泛应用于传感器、PLC、仪表设备之间的互联。其中, RS-232 RS-485 是最为常见的两种物理层标准,它们在电气特性、拓扑结构以及应用场景上存在显著差异。与此同时,在 Linux 系统中,对这些串口设备的精确控制依赖于 termios 结构体及其相关系统调用。本章将深入剖析 RS-232 与 RS-485 的核心区别,并详细解析 termios 的字段组成、参数配置机制及其实现方式,为构建稳定可靠的串口通信程序提供理论支撑与实践指导。

2.1 RS-232与RS-485通信协议对比分析

尽管 RS-232 和 RS-485 都属于 EIA(Electronic Industries Alliance)制定的串行通信标准,但其设计目标和服务场景截然不同。理解两者的本质差异是合理选型和高效开发的前提。

2.1.1 电气特性与信号电平差异

RS-232 使用单端信号传输方式,即每个信号线相对于公共地(GND)进行电压比较。逻辑“1”对应负电压范围(-3V 至 -15V),而逻辑“0”则为正电压(+3V 至 +15V)。这种设计使得 RS-232 具有较强的抗干扰能力,但由于采用非平衡驱动,容易受到共模噪声影响,尤其在长距离或电磁环境复杂的场合表现不佳。

相比之下,RS-485 采用差分信号传输机制,通过一对双绞线(A 和 B)来传递差分电压。当 A 线电压高于 B 线时定义为逻辑“1”,反之为逻辑“0”。差分模式天然具备抑制共模干扰的能力,能够在恶劣工业环境中保持高可靠性。此外,RS-485 支持多点总线架构,允许多达 32 个节点挂接在同一总线上(可通过中继器扩展至更多节点),极大地提升了系统的可扩展性。

特性 RS-232 RS-485
信号类型 单端 差分
逻辑电平 ±3~±15V 差分 ±1.5V 左右
数据线数量 TXD、RXD、GND(最少3根) D+ (A)、D− (B),需终端电阻
抗干扰能力 较弱
是否支持多点通信 否(仅点对点) 是(最多32节点)

该表清晰地展示了两者在电气层面的根本分歧。例如,在一个工厂车间中部署多个温湿度传感器时,若使用 RS-232,则每个传感器都需要独立连接到主机,布线复杂且成本高昂;而采用 RS-485 总线结构,所有设备可共享同一对通信线路,大幅简化了物理连接。

graph TD
    A[主控制器] -->|TXD/RXD| B(RS-232设备1)
    A -->|TXD/RXD| C(RS-232设备2)
    A -->|TXD/RXD| D(RS-232设备3)

    E[主控制器] --> F[RJ45接头]
    F --> G[RS-485总线 A/B]
    G --> H(从机1)
    G --> I(从机2)
    G --> J(从机3)
    style G stroke:#f66,stroke-width:2px

上述流程图直观呈现了 RS-232 的点对点星型拓扑与 RS-485 的多点总线结构之间的对比。显然,在需要集中管理大量远程节点的应用中,RS-485 更具优势。

2.1.2 传输距离、速率与拓扑结构比较

传输性能是评估通信协议适用性的关键指标之一。RS-232 在理想条件下最大传输距离约为 15 米,且随着波特率升高,有效距离迅速缩短。例如,在 115200 bps 下通常只能维持几米的可靠通信。这主要受限于其单端信号易受分布电容和电磁干扰的影响。

而 RS-485 在低速下可实现长达 1200 米的传输距离,即使在 100 kbps 波特率下仍能稳定工作于千米级距离。更重要的是,其传输速率与距离呈反比关系,用户可根据实际需求灵活调整。如下图所示:

graph LR
    subgraph RS-485 Distance vs Baud Rate
        A[1200m] -->|≤ 100kbps| B
        B -->|≤ 400kbps| C[500m]
        C -->|≤ 1Mbps| D[100m]
    end

这一特性使其非常适合楼宇自动化、轨道交通信号系统等远距离分布式控制网络。

从拓扑结构来看,RS-232 只能实现一对一通信,无法构成网络;而 RS-485 支持真正的总线型拓扑,所有设备并联在同一对双绞线上,通过地址寻址实现选择性通信。典型应用如 Modbus RTU 协议就运行在 RS-485 物理层之上,利用主从轮询机制完成数据交换。

值得注意的是,RS-485 总线两端必须加装 终端匹配电阻(通常为 120Ω) ,以防止信号反射造成波形畸变。若未正确配置终端电阻,可能导致通信误码甚至完全失效。

2.1.3 应用场景选择建议(点对点 vs 多点总盘)

基于以上分析,可以总结出明确的应用选型指南:

  • RS-232 适用于
  • 设备间近距离直接通信(<15m)
  • 调试接口、控制台输出(如路由器 Console 口)
  • 不需要联网的独立外设连接(打印机、调制解调器)
  • 成本敏感的小规模系统

  • RS-485 适用于

  • 工业现场多设备联网(PLC、变频器、智能电表)
  • 远距离数据采集系统(环境监控、安防报警)
  • 需要抗强电磁干扰的恶劣环境
  • 构建主从式通信网络(Modbus、Profibus)

举例说明:某智能农业大棚项目需采集分布在 800 米范围内的 20 个土壤传感器数据。若选用 RS-232,不仅布线工程量巨大,而且信号衰减严重;而采用 RS-485 总线方案,只需铺设一条屏蔽双绞线即可完成全部通信,配合 Modbus 协议轮询各节点,极大提高了系统集成度与维护便利性。

因此,在系统设计初期应充分评估通信距离、节点数量、电磁环境等因素,优先考虑 RS-485 方案用于多点或远距场景,保留 RS-232 用于本地调试与短距通信。

2.2 termios结构体详解与串口参数模型

在 Linux 中,串口设备被视为字符设备文件(如 /dev/ttyS0 ),其行为由 termios 结构体统一描述。该结构体封装了波特率、数据格式、流控方式、输入输出处理模式等关键属性,是实现精确串口配置的核心工具。

2.2.1 termios结构体字段解析(c_cflag, c_lflag, c_iflag, c_oflag, c_cc)

termios 定义于 <termios.h> 头文件中,包含五个主要成员字段,分别控制不同的操作模式:

struct termios {
    tcflag_t c_iflag;    // 输入模式标志
    tcflag_t c_oflag;    // 输出模式标志
    tcflag_t c_cflag;    // 控制模式标志
    tcflag_t c_lflag;    // 本地模式标志
    cc_t     c_cc[NCCS]; // 特殊控制字符数组
    speed_t  c_ispeed;   // 输入波特率(非POSIX)
    speed_t  c_ospeed;   // 输出波特率(非POSIX)
};
c_cflag:控制标志位

该字段用于设置硬件参数,包括:

  • CSIZE :数据位长度掩码( CS5 , CS6 , CS7 , CS8
  • CSTOPB :是否使用两位停止位(置位表示启用)
  • PARENB :启用奇偶校验
  • PARODD :选择奇校验(否则为偶校验)
  • CRTSCTS :启用硬件流控(RTS/CTS)
  • CREAD :允许接收数据

示例代码片段:

options.c_cflag |= CS8;         // 8位数据位
options.c_cflag &= ~CSTOPB;     // 1位停止位
options.c_cflag |= PARENB;      // 启用校验
options.c_cflag &= ~PARODD;     // 偶校验
options.c_cflag |= CREAD;       // 允许接收

参数说明
- CS8 表示每次传输 8 个数据位,符合大多数现代设备要求。
- 清除 CSTOPB 设置为 1 位停止位,适用于绝大多数应用。
- PARENB 开启后需配合 PARODD 决定校验类型;关闭 PARODD 则为偶校验。

c_iflag:输入处理标志

控制输入数据的预处理方式:

  • IGNBRK :忽略断线(Break)信号
  • BRKINT :收到 Break 触发中断(可能引发 SIGINT)
  • IGNPAR :忽略帧错误或校验错误的数据
  • PARMRK :标记奇偶错误(与 IGNPAR 冲突)
  • INPCK :启用输入奇偶校验检查
  • ISTRIP :剥离第8位(仅保留低7位)
  • ICRNL :将回车(CR)映射为换行(NL)
  • IXON / IXOFF :启用软件流控 XON/XOFF

推荐配置:

options.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL);
options.c_iflag |= (INPCK | IXON);

逻辑分析
关闭自动换行转换(避免干扰二进制通信),开启奇偶校验和 XON/XOFF 流控,确保数据完整性。

c_oflag:输出处理标志

决定输出数据如何处理:

  • OPOST :启用输出处理(如换行转换)
  • ONLCR :将 NL 转换为 CR-NL(类 Unix → DOS 格式)

对于串口通信,通常禁用输出处理以避免意外修改原始字节流:

options.c_oflag &= ~OPOST;
c_lflag:本地模式标志

影响终端行为(回显、信号生成等):

  • ECHO :本地回显输入字符
  • ECHONL :即使不回显也显示换行
  • ICANON :启用规范输入模式(按行处理)
  • ISIG :特殊字符触发信号(如 Ctrl+C 发送 SIGINT)

在非交互式串口通信中,应关闭这些功能以获得原始数据流:

options.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG);
c_cc[NCCS]:特殊控制字符数组

该数组存储 19 个预定义控制字符的功能键值,最常用的是:

  • VMIN :非规范模式下期望读取的最小字节数
  • VTIME :等待时间(单位:十分之一秒)

这两个参数共同决定了 read() 的阻塞行为,将在后续章节详述。

2.2.2 输入/输出模式控制标志位作用机制

Linux 串口支持三种基本输入模式:

  1. 规范模式(Canonical Mode) :以行为单位处理输入,直到遇到换行符或 EOF 才返回。适用于人机交互。
  2. 非规范模式(Non-canonical Mode) :按字节或定时方式读取,适合实时数据采集。
  3. 原始模式(Raw Mode) :关闭几乎所有处理,直接获取原始字节流。

切换至原始模式的关键步骤是清除 ICANON 并设置 VMIN=0 , VTIME=0 或其他组合:

options.c_lflag &= ~ICANON;   // 关闭规范模式
options.c_cc[VMIN] = 1;       // 至少读1字节才返回
options.c_cc[VTIME] = 5;      // 最大等待0.5秒

此时 read() 将在收到至少 1 字节或超时后立即返回,满足实时通信需求。

2.2.3 特殊控制字符数组c_cc的功能说明(如VMIN、VTIME)

c_cc 数组中的 VMIN VTIME 是控制读取行为的核心参数,其组合效果如下表所示:

VMIN VTIME 行为描述
>0 >0 等待直到收到 VMIN 字节,或每两个字节之间间隔超过 VTIME
>0 0 阻塞等待,直到收到 VMIN 字节(无限等待)
0 >0 等待最长 VTIME 时间,期间有任何数据即返回
0 0 非阻塞读取,立即返回已接收的数据(可能为0)

应用场景举例:

  • 命令响应通信 :设 VMIN=0 , VTIME=10 (1秒),尝试读取回复,避免长时间挂起。
  • 连续数据流采集 :设 VMIN=64 , VTIME=5 ,批量读取传感器包,提升吞吐效率。
  • 心跳检测 VMIN=1 , VTIME=0 ,持续监听设备状态。
// 示例:设置非阻塞快速读取
options.c_cc[VMIN] = 0;
options.c_cc[VTIME] = 1;  // 100ms 超时

此配置常用于轮询多个串口设备,结合 select() 实现高效的多路复用监听。

2.3 使用tcgetattr()与tcsetattr()管理串口属性

配置串口前必须先获取当前设置,修改后再写回设备。这一过程由 tcgetattr() tcsetattr() 函数完成。

2.3.1 获取当前串口配置:tcgetattr()函数调用流程

#include <termios.h>

int fd = open("/dev/ttyS0", O_RDWR);
struct termios options;

if (tcgetattr(fd, &options) != 0) {
    perror("tcgetattr failed");
    return -1;
}

逐行解读
- 第 1 行包含头文件,声明 tcgetattr
- 第 3 行打开串口设备文件。
- 第 5 行调用 tcgetattr ,将当前属性复制到 options 结构体。
- 返回值判断:成功返回 0,失败返回 -1 并设置 errno。

该函数是安全修改串口参数的前提,避免覆盖未知配置。

2.3.2 应用修改后的termios设置:tcsetattr()的三种选项(TCSANOW等)

tcsetattr() 接受三个动作选项:

选项 含义
TCSANOW 立即生效
TCSADRAIN 等待当前输出完成后生效(推荐用于发送中修改)
TCSAFLUSH 清空输入输出队列后再应用新设置

典型调用:

cfsetispeed(&options, B115200);
cfsetospeed(&options, B115200);

if (tcsetattr(fd, TCSANOW, &options) != 0) {
    perror("tcsetattr failed");
    return -1;
}

参数说明
- cfsetispeed cfsetospeed 用于设置输入/输出波特率,接受 B9600 , B115200 等常量。
- 使用 TCSANOW 可立即生效,但在正在发送数据时建议使用 TCSADRAIN 防止中断。

2.3.3 实践案例:从零构建一个合法的termios配置模板

以下是一个完整的初始化函数,适用于大多数工业设备通信:

int setup_serial(int fd, int baudrate) {
    struct termios options;
    if (tcgetattr(fd, &options) < 0) return -1;

    // 清空结构体
    memset(&options, 0, sizeof(options));

    // 设置波特率
    cfsetispeed(&options, baudrate);
    cfsetospeed(&options, baudrate);

    // 8N1: 8数据位, 无校验, 1停止位
    options.c_cflag &= ~PARENB;     // 无校验
    options.c_cflag &= ~CSTOPB;     // 1位停止位
    options.c_cflag &= ~CSIZE;
    options.c_cflag |= CS8;         // 8位数据
    options.c_cflag |= CREAD;       // 允许接收
    options.c_cflag |= CLOCAL;      // 忽略调制解调器控制线

    // 禁用输入输出处理
    options.c_iflag &= ~(IXON | IXOFF | IXANY);
    options.c_iflag &= ~(INLCR | ICRNL | IGNCR);
    options.c_iflag &= ~PARMRK;

    options.c_oflag &= ~OPOST;

    options.c_lflag &= ~(ICANON | ECHO | ECHONL | ISIG);

    // 设置超时:非阻塞读取
    options.c_cc[VMIN] = 0;
    options.c_cc[VTIME] = 1;  // 100ms timeout

    // 应用设置
    if (tcsetattr(fd, TCSANOW, &options) != 0) {
        return -1;
    }

    // 清空缓冲区
    tcflush(fd, TCIOFLUSH);

    return 0;
}

扩展说明
- CLOCAL 表示忽略 CD(载波检测)信号,防止因线路问题导致 open() 阻塞。
- tcflush(fd, TCIOFLUSH) 清除输入输出缓冲区,避免残留数据干扰。
- 此模板可用于 Modbus、GPS、RFID 等多种设备接入。

2.4 波特率、数据格式与校验位的精确设置

2.4.1 常见波特率值(9600、115200等)的系统常量表示

Linux 提供标准宏定义波特率:

波特率 宏定义
9600 B9600
19200 B19200
38400 B38400
57600 B57600
115200 B115200

调用方式:

cfsetispeed(&options, B115200);

部分旧系统可能不支持高速率,需确认内核驱动支持情况。

2.4.2 数据位(5~8位)、停止位(1/2位)的c_cflag配置方法

数据位通过 CSIZE 掩码设置:

options.c_cflag &= ~CSIZE;  // 清除原设置
options.c_cflag |= CS7;     // 7位数据

停止位通过 CSTOPB 控制:

options.c_cflag |= CSTOPB;    // 2位停止位
options.c_cflag &= ~CSTOPB;   // 1位停止位

注意:并非所有设备都支持 5/6 位或双停止位,需查阅硬件手册。

2.4.3 校验位(无校验、奇校验、偶校验)的实现逻辑与代码示例

  • 无校验
    c options.c_cflag &= ~PARENB;

  • 偶校验
    c options.c_cflag |= PARENB; options.c_cflag &= ~PARODD;

  • 奇校验
    c options.c_cflag |= PARENB; options.c_cflag |= PARODD;

同时需启用输入校验检查:

options.c_iflag |= INPCK;


# 3. Linux下串口读写系统调用与I/O控制机制

在Linux系统中,串口通信本质上是基于字符设备文件的I/O操作。开发者通过标准的系统调用接口——如 `open()`、`read()`、`write()` 和 `ioctl()` 等——对 `/dev/ttySx` 设备进行访问和控制。这些系统调用构成了用户空间程序与内核串行驱动之间的桥梁,使得应用程序能够以类文件的方式实现数据的发送与接收。然而,由于串口通信具有实时性要求高、时序敏感、易受物理环境干扰等特点,单纯使用基础I/O函数并不足以构建稳定可靠的通信链路。必须深入理解每项系统调用的行为特征、返回值含义及其与termios配置参数的协同关系,才能有效应对阻塞/非阻塞模式切换、超时处理、流控配合等复杂场景。

本章将围绕Linux环境下串口读写的核心系统调用展开详细解析,重点剖析 `open()` 打开设备的权限与模式选择策略,`write()` 发送数据时的缓冲区管理机制,`read()` 在不同模式下的行为差异,以及如何结合 `VMIN` 与 `VTIME` 实现灵活的数据采集逻辑。同时,探讨超时控制与软硬件流控(XON/XOFF、CTS/RTS)的协同设计方法,帮助开发者构建具备高鲁棒性和响应能力的串口通信模块。


##3.1 open()函数打开串口设备文件

`open()` 是所有I/O操作的起点,在串口编程中承担着设备初始化的关键角色。它不仅负责建立用户进程与串口设备之间的连接通道,还决定了后续读写操作的行为模式。正确使用 `open()` 涉及到对打开标志位的精确选择、设备权限的预检以及并发访问冲突的预防。

###3.1.1 O_RDONLY、O_WRONLY、O_RDWR模式的选择依据

在调用 `open()` 时,必须指定访问模式。对于串口设备而言,三种主要模式分别为:

- `O_RDONLY`:仅允许从设备读取数据,适用于监听型应用(如传感器数据采集器);
- `O_WRONLY`:仅允许向设备写入数据,常见于命令下发终端;
- `O_RDWR`:支持双向通信,绝大多数全双工串口应用应选用此模式。

```c
#include <fcntl.h>
#include <unistd.h>

int fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY | O_NDELAY);
if (fd == -1) {
    perror("open");
    return -1;
}

代码逻辑逐行解读:

行号 说明
1-2 包含必要的头文件: fcntl.h 提供 open() 声明, unistd.h 包含系统调用接口。
4 调用 open() 打开 /dev/ttyS0 设备;使用 O_RDWR 支持读写; O_NOCTTY 防止该设备成为控制终端; O_NDELAY 设置非阻塞模式(旧式写法,推荐用 O_NONBLOCK )。
5-7 错误检查:若返回 -1 ,表示打开失败,调用 perror() 输出错误原因(如权限不足或设备不存在)。

⚠️ 注意: O_NDELAY O_NONBLOCK 在早期Unix系统中有细微区别,但在现代Linux中通常等价。建议统一使用 O_NONBLOCK 以保证可移植性。

模式选择决策流程图(Mermaid)
graph TD
    A[开始] --> B{是否需要发送数据?}
    B -- 是 --> C{是否需要接收数据?}
    B -- 否 --> D[使用 O_RDONLY]
    C -- 是 --> E[使用 O_RDWR]
    C -- 否 --> F[使用 O_WRONLY]

该流程图清晰地展示了根据通信需求自动确定打开模式的逻辑路径。例如,一个Modbus主站需同时发送查询帧并接收响应,因此必须采用 O_RDWR 模式。

3.1.2 非阻塞标志O_NONBLOCK的作用与适用场景

默认情况下, open() 以阻塞方式打开设备。这意味着当调用 read() 且无数据到达时,进程会被挂起,直到有数据可用或发生错误。这种行为在某些场景下可能导致主线程停滞,影响系统整体响应性能。

引入 O_NONBLOCK 标志后, open() 将设备置于非阻塞模式:

int fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY | O_NONBLOCK);

在此模式下:
- read() 若无数据立即返回 -1 ,并设置 errno EAGAIN EWOULDBLOCK
- write() 若发送缓冲区满也会立即返回错误而非等待。

场景 推荐模式 原因
主循环轮询多个设备 O_NONBLOCK 避免单个设备阻塞导致其他任务延迟
单线程实时采集 O_NONBLOCK 可结合 select() 或定时器实现精准采样周期
简单调试图传程序 O_NONBLOCK 使用阻塞模式更简单直观

📌 实际开发中,即使设置了 O_NONBLOCK ,仍可通过 fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) 动态关闭非阻塞属性,实现运行时切换。

3.1.3 权限检查与设备占用问题的排查策略

串口设备文件通常归属于特定用户组(如 dialout ),普通用户可能无法直接访问:

ls -l /dev/ttyS0
# crw-rw---- 1 root dialout 4, 64 Apr  5 10:20 /dev/ttyS0

若未加入 dialout 组, open() 会失败并报错 Permission denied

解决方案表格
方法 操作指令 安全性 适用阶段
添加用户到dialout组 sudo usermod -aG dialout $USER 开发部署
修改udev规则 创建 /etc/udev/rules.d/99-tty.rules 设置权限 批量设备管理
临时赋权 sudo chmod 666 /dev/ttyS0 调试阶段

此外,设备被其他进程占用也是常见问题。可通过以下命令检测:

lsof /dev/ttyS0
# 如果输出结果非空,则表明已有进程打开该设备

若发现冲突,可终止相关进程或提示用户释放资源。编程层面可在 open() 失败后判断 errno == EBUSY 来识别设备忙状态,并实施重试机制。

3.2 write()函数实现串口数据发送

write() 是向串口设备发送数据的核心系统调用。尽管其API形式与普通文件写入一致,但由于串口底层涉及异步传输、硬件缓冲区和波特率限制,实际行为远比磁盘写入复杂。

3.2.1 发送缓冲区管理与write()返回值解读

调用 write() 并不意味着数据已物理发出,而是将其复制进内核维护的 发送环形缓冲区 (Transmit FIFO),由串口控制器按设定速率逐位发送。

ssize_t n = write(fd, buffer, count);

返回值解释如下:

返回值 含义 应对措施
n > 0 成功写入 n 字节 记录已发送长度
n == 0 无数据写入(罕见) 检查缓冲区是否为空
n == -1 出错 查看 errno 判断原因

常见错误码包括:
- EAGAIN / EWOULDBLOCK :非阻塞模式下缓冲区满;
- EIO :设备通信异常;
- EBADF :文件描述符无效。

内核发送流程示意图(Mermaid)
graph LR
    App[应用层 write()] --> Kernel[内核发送缓冲区]
    Kernel --> UART[UART控制器]
    UART --> Wire[RS-232信号线]
    Wire --> Remote[远端设备]

该图揭示了数据从用户空间到物理线路的完整路径。开发者需意识到: write() 成功仅表示“进入排队”,不代表“完成送达”。

3.2.2 多字节数据写入的循环处理机制

由于 write() 可能只写出部分数据(尤其在高负载或低速波特率下),必须采用循环写入确保完整性:

int send_all(int fd, const uint8_t *data, size_t len) {
    size_t sent = 0;
    ssize_t n;

    while (sent < len) {
        n = write(fd, data + sent, len - sent);
        if (n == -1) {
            if (errno == EINTR) continue;      // 被信号中断,重试
            if (errno == EAGAIN) usleep(1000); // 缓冲区满,短暂休眠
            else return -1;                    // 其他错误,终止
        } else {
            sent += n;
        }
    }
    return sent;
}

参数说明:
- fd :已打开的串口文件描述符;
- data :待发送数据指针;
- len :总字节数;
- 返回值:成功发送字节数或 -1 表示失败。

该函数通过累加已发送量,持续调用 write() 直至全部数据入队,极大提升了传输可靠性。

3.2.3 实战:构建可重用的串口发送函数send_data()

封装一个健壮的发送函数是工程实践中的良好习惯:

#include <errno.h>
#include <string.h>
#include <sys/time.h>

int send_data(int fd, const void *buf, size_t len, int timeout_ms) {
    struct timeval start, now;
    gettimeofday(&start, NULL);

    size_t sent = 0;
    while (sent < len) {
        ssize_t n = write(fd, (const char *)buf + sent, len - sent);
        if (n > 0) {
            sent += n;
        } else if (n == -1) {
            if (errno == EINTR) continue;
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 检查超时
                gettimeofday(&now, NULL);
                long elapsed = (now.tv_sec - start.tv_sec) * 1000 +
                               (now.tv_usec - start.tv_usec) / 1000;
                if (elapsed >= timeout_ms) {
                    errno = ETIMEDOUT;
                    return -1;
                }
                usleep(1000);
            } else {
                return -1;
            }
        } else {
            break; // n == 0,不应出现
        }
    }
    return sent;
}

该函数增强点:
- 支持超时控制;
- 处理中断与暂态错误;
- 返回实际发送字节数便于上层校验。

3.3 read()函数实现数据接收控制

read() 是获取串口输入数据的主要手段,但其行为高度依赖于 termios 配置中的 VMIN VTIME 参数,以及是否启用非阻塞模式。

3.3.1 阻塞模式下的read()行为特征

在默认阻塞模式下, read() 的行为由 c_cc[VMIN] c_cc[VTIME] 共同决定:

VMIN VTIME 行为描述
>0 >0 等待至少 VMIN 字节或每字节间隔超过 VTIME(单位0.1s)
>0 0 必须收到 VMIN 字节才返回
0 >0 等待任意数据,超时即返回(可用于 polling)
0 0 不等待,立即返回现有数据或 0

示例配置(等待至少1字节,最长等5秒):

tty.c_cc[VMIN] = 1;
tty.c_cc[VTIME] = 50;  // 50 * 0.1 = 5 seconds

此时 read() 将阻塞最多5秒,若中途收到1字节即刻返回。

3.3.2 非阻塞模式与EAGAIN错误处理

开启 O_NONBLOCK 后, read() 总是立即返回:

int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // 无数据,继续轮询
    } else {
        // 真正错误
    }
}

适合用于事件驱动架构中,配合 select() poll() 实现多路复用。

3.3.3 利用VMIN和VTIME实现灵活的数据读取策略

假设我们希望接收一个固定长度为8字节的帧:

tty.c_cc[VMIN] = 8;
tty.c_cc[VTIME] = 10; // 每字节最大间隔1秒

这样 read() 会一直等到8字节全部收齐或任意两字节之间断开超过1秒才返回,避免碎片化读取。

3.4 超时机制与流控制协同设计

3.4.1 设置合理超时参数应对响应延迟

在主从通信中(如Modbus RTU),主机发送请求后需等待从机回复。若未设超时,程序可能无限等待。

建议公式:

timeout = (response_size / baudrate) * 10 + safety_margin

例如:波特率9600,预期最大响应12字节 → (12*10)/9600 ≈ 12.5ms ,加安全余量设为100ms。

3.4.2 软件流控XON/XOFF的工作原理与启用方式

XON(Ctrl-Q, 0x11)/XOFF(Ctrl-S, 0x13)通过数据通道本身传递流控信号。

启用方式:

tty.c_iflag |= IXON | IXOFF; // 启用输入/输出软件流控

适用于低速、无额外引脚的场合,但存在误判风险(数据中恰好出现0x11或0x13)。

3.4.3 硬件流控CTS/RTS的物理连接与termios配置

硬件流控利用 RTS(Request To Send)和 CTS(Clear To Send)引脚协调数据流动。

配置启用:

#ifdef CRTSCTS
tty.c_cflag |= CRTSCTS; // 启用硬件流控(Linux特有宏)
#else
tty.c_cflag |= CNEW_RTSCTS; // 替代定义
#endif

需确保两端正确接线(本地RTS→对方CTS,本地CTS←对方RTS),适合高速、大数据量传输场景。

对比维度 软件流控 硬件流控
传输介质 数据线内嵌 独立信号线
可靠性 较低(易误触发)
速度上限 ≤38400bps ≥115200bps
接线复杂度 低(只需TX/RX/GND) 高(需额外连线)
termios配置 IXON , IXOFF CRTSCTS

综上,合理组合 open() read() write() 与 termios 参数,辅以超时与流控机制,方能构建高效稳定的串口通信系统。

4. 串口通信错误检测与异常处理机制

在嵌入式系统、工业自动化和远程数据采集等应用场景中,串口通信虽然具备结构简单、稳定性高、成本低廉的优势,但其物理层特性决定了它极易受到噪声干扰、线路老化、波特率不匹配或设备异常断开等因素的影响。因此,在实际开发过程中,仅实现基本的数据收发功能远远不够,必须构建完善的 错误检测机制 异常恢复策略 ,以确保系统的鲁棒性与长期运行的可靠性。

本章将深入剖析Linux环境下串口通信中常见的硬件级与协议级错误类型,介绍如何通过标准接口获取底层状态信息,并结合信号机制、I/O多路复用技术以及程序逻辑设计来实现对异步事件的有效监控。在此基础上,进一步探讨容错架构的设计原则,包括重传机制、缓冲区保护与日志记录的最佳实践。最后,系统化阐述设备资源的安全释放流程与断线自动重连方案,帮助开发者构建具备自我修复能力的串口通信模块。

4.1 常见串口通信错误类型分析

串口通信作为一种基于UART(通用异步收发器)的低速串行传输方式,其数据完整性依赖于精确的时钟同步、稳定的电气环境和正确的配置参数。一旦出现偏差,就可能引发多种类型的通信错误。理解这些错误的本质及其产生机理,是构建健壮串口应用的前提。

4.1.1 帧错误(Framing Error)、溢出错误(Overrun Error)

帧错误(Framing Error)是最常见的串口错误之一,通常发生在接收端无法正确识别一个完整数据帧的情况下。每个UART帧由起始位、数据位、可选的校验位和停止位组成。当接收方检测到停止位不是预期的高电平(对于标准RS-232),则判定为帧错误。这往往意味着发送端与接收端之间存在严重的 波特率失配 ,或者线路受到强烈电磁干扰导致信号畸变。

例如,若发送端以115200bps发送数据,而接收端误设为9600bps,则采样时机严重错位,造成整个帧结构解析失败。此外,如果设备突然重启或电源波动引起短暂拉低TX线,也可能被误认为是一个新的起始位,从而触发错误帧。

// 示例:通过 ioctl 获取累计的帧错误次数
#include <sys/ioctl.h>
#include <linux/serial.h>

struct serial_icounter_struct icount;
if (ioctl(fd, TIOCGICOUNT, &icount) == 0) {
    printf("Frame errors: %d\n", icount.frame);
}

代码逻辑逐行解读
- 第4行:定义 serial_icounter_struct 结构体用于存储各类计数器值。
- 第6行:调用 ioctl(fd, TIOCGICOUNT, &icount) 向内核查询当前串口设备的错误统计信息。
- 第7行:打印 frame 字段,表示自打开设备以来发生的帧错误总数。

参数说明
- fd :已打开的串口文件描述符。
- TIOCGICOUNT :ioctl命令码,意为“Get Input Counter”,属于Linux特有扩展,需包含 <linux/serial.h> 头文件。
- icount 中还包括 overrun , parity , brk 等字段,分别对应不同错误类型。

另一种典型错误是 溢出错误(Overrun Error) ,即接收FIFO队列满载且新数据持续到达,导致旧数据被覆盖。这种情况多出现在高波特率下CPU处理延迟较大时,如未使用中断驱动或轮询频率不足。

错误类型 触发条件 可能原因
帧错误 停止位非高电平 波特率不匹配、线路噪声、设备故障
溢出错误 接收缓冲区满,新数据到来 CPU响应慢、中断延迟、高波特率+大数据量
奇偶校验错误 收到的数据不符合预设校验规则 数据传输干扰、接线松动、接地不良
Break条件 RX线持续低电平超过一帧时间 发送端主动发送Break信号、线路短路

上述错误均可通过 TIOCGICOUNT 接口进行量化监控,便于后期诊断与预警。

graph TD
    A[开始接收数据] --> B{是否检测到起始位?}
    B -- 是 --> C[启动位定时采样]
    C --> D{数据位采样是否一致?}
    D -- 否 --> E[标记为帧错误]
    D -- 是 --> F{校验位匹配?}
    F -- 否 --> G[奇偶校验错误]
    F -- 是 --> H{停止位为高?}
    H -- 否 --> I[帧错误]
    H -- 是 --> J[成功接收]
    K[数据写入FIFO] --> L{FIFO是否已满?}
    L -- 是 --> M[发生溢出错误]

该流程图展示了UART接收过程中的关键判断节点及错误分支路径。可以看出,任何一个环节出错都会影响最终数据的准确性。

4.1.2 奇偶校验错误(Parity Error)的产生原因

奇偶校验是一种简单的差错检测机制,广泛应用于要求不高但需一定可靠性的串口通信场景。其核心思想是在数据位后附加一位校验位,使得整个数据单元中“1”的个数满足预定规则(奇数或偶数)。接收端重新计算并比对校验结果,若不一致即报错。

假设采用偶校验模式(PARENB启用,PARODD禁用),发送字节 0x0F (二进制 00001111 ),其中“1”有四个,已是偶数,故校验位为0。若在传输过程中由于噪声翻转了一位变为 0x1F 00011111 ),此时“1”有五个,奇数,与预期不符,接收端便会报告奇偶校验错误。

这种错误并不能纠正数据,只能提示上层程序可能存在数据损坏,需要配合重传机制处理。

以下代码展示如何在termios中启用偶校验:

struct termios tty;
tcgetattr(fd, &tty);

// 设置数据位为8,启用校验位
tty.c_cflag &= ~CSIZE;                    // 清除数据位掩码
tty.c_cflag |= CS8;                       // 设置8位数据位
tty.c_cflag |= PARENB;                    // 启用奇偶校验
tty.c_cflag &= ~PARODD;                   // 设为偶校验
tty.c_iflag |= INPCK;                     // 启用输入奇偶检查
tty.c_iflag |= ISTRIP;                    // 剥离第8位(校验位)

tcsetattr(fd, TCSANOW, &tty);

逻辑分析
- 第4~5行:清除原有数据位设置,重新指定为8位。
- 第6行: PARENB 开启后,UART会在发送时添加校验位,并在接收时验证。
- 第7行: ~PARODD 表示关闭奇校验,即使用偶校验。
- 第8行: INPCK 标志使能输入端的奇偶校验检查。
- 第9行: ISTRIP 用于去除接收到字节的最高位(常用于剥离校验位)。

注意事项 :启用校验后,实际有效数据仍为7位,除非使用CS7+PARENB组合;否则CS8+PARENB可能导致部分平台兼容问题。

4.1.3 错误状态获取接口(TIOCGICOUNT等ioctl调用)

Linux内核提供了强大的调试与监控能力,尤其是针对串口设备的状态反馈。除了常规的read/write操作外,可通过 ioctl() 系统调用访问底层驱动暴露的统计信息。

最常用的命令是 TIOCGICOUNT ,它可以获取包括帧错误、溢出、奇偶错误、break中断在内的多项计数:

#include <sys/ioctl.h>
#include <linux/serial.h>

int get_uart_error_stats(int fd) {
    struct serial_icounter_struct icount = {0};

    if (ioctl(fd, TIOCGICOUNT, &icount) < 0) {
        perror("ioctl TIOCGICOUNT");
        return -1;
    }

    printf("UART Error Statistics:\n");
    printf("  Frame Errors:     %d\n", icount.frame);
    printf("  Overrun Errors:   %d\n", icount.overrun);
    printf("  Parity Errors:    %d\n", icount.parity);
    printf("  Break Signals:    %d\n", icount.brk);

    return 0;
}

参数说明
- frame : 因停止位错误导致的帧错误次数。
- overrun : FIFO溢出次数(硬件来不及处理)。
- parity : 奇偶校验失败次数。
- brk : 接收到的BREAK信号数量(RX线长时间拉低)。

此函数可用于周期性健康检查,或在发生通信异常时作为诊断依据。

ioctl命令 功能描述 是否标准 POSIX
TIOCGICOUNT 获取错误计数器 Linux 扩展
TIOCMGET 获取 modem 控制线状态(DTR/RTS) 标准
TIOCMSET 设置控制线状态 标准
TIOCMIWAIT 等待特定modem信号变化 Linux 扩展

利用这些接口,可以构建一个完整的串口健康监测子系统。例如,当连续检测到超过阈值的帧错误时,可自动降低波特率或触发告警日志。

pie
    title UART 错误分布示例
    "帧错误" : 45
    "溢出错误" : 20
    "奇偶校验错误" : 25
    "Break信号" : 10

该饼图模拟了一个现场设备一周内的错误分布情况,反映出主要问题是帧错误,提示应优先排查波特率一致性与线路质量。

综上所述,掌握各类串口错误的成因与检测手段,是提升系统稳定性的第一步。后续章节将进一步讨论如何利用这些信息进行实时响应与自动恢复。

4.2 串口中断信号与异步事件监控

传统的串口编程常采用阻塞式读取模型,即调用 read() 函数等待数据到来。然而,在复杂系统中,这种方式效率低下且难以应对并发需求。为此,Linux提供了多种异步事件监控机制,允许程序在不影响主线程的前提下感知串口状态变化。

4.2.1 SIGHUP、SIGIO等信号在串口通信中的语义

信号(Signal)是Unix/Linux系统中进程间通信的重要手段之一。在串口通信中,某些特殊事件会触发特定信号通知应用程序:

  • SIGHUP (Hang Up):当串口设备的控制终端断开连接(如拔掉USB转串口线),内核会向关联进程发送SIGHUP信号。默认行为是终止进程,但在守护进程中常被捕获以执行清理或重连。
  • SIGIO (I/O Possible):当设备上有可读/可写数据时,若启用了异步I/O通知,内核将发出SIGIO信号,提醒进程进行处理。

要启用SIGIO机制,需执行以下步骤:

#include <signal.h>
#include <fcntl.h>

void sigio_handler(int sig) {
    printf("Received SIGIO - data available!\n");
    // 此处不应调用复杂函数(非异步安全)
}

// 在主函数中注册信号处理器并启用异步通知
signal(SIGIO, sigio_handler);

int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC);           // 启用异步通知
fcntl(fd, F_SETOWN, getpid());                 // 指定接收进程

逻辑分析
- 第8行: signal() 绑定SIGIO信号处理函数。
- 第11行: F_SETFL 配合 O_ASYNC 标志启用异步I/O模式。
- 第12行: F_SETOWN 设定信号接收者为当前进程ID。

注意 :信号处理函数中只能调用 异步信号安全函数 (如 write() , sigprocmask() ),避免使用 printf() malloc() 等可能导致死锁的操作。

尽管SIGIO机制轻量高效,但由于其信号机制本身存在竞态风险,现代应用更倾向于使用 select() poll() 替代。

4.2.2 使用select()或poll()实现多路复用监听

select() poll() 是POSIX标准支持的I/O多路复用机制,非常适合同时监控多个串口或其他文件描述符的状态变化。

以下是使用 select() 监控串口可读性的示例:

#include <sys/select.h>

int wait_for_data(int fd, int timeout_ms) {
    fd_set read_fds;
    struct timeval tv;

    FD_ZERO(&read_fds);
    FD_SET(fd, &read_fds);

    tv.tv_sec = timeout_ms / 1000;
    tv.tv_usec = (timeout_ms % 1000) * 1000;

    int ret = select(fd + 1, &read_fds, NULL, NULL, &tv);
    if (ret > 0) {
        if (FD_ISSET(fd, &read_fds)) {
            return 1;  // 可读
        }
    } else if (ret == 0) {
        return 0;  // 超时
    } else {
        perror("select");
        return -1;
    }
    return 0;
}

参数说明
- fd_set :位图结构,用于存放待监控的fd集合。
- FD_ZERO/FD_SET :初始化和添加fd到集合。
- select() 返回值:>0 表示有事件就绪;==0 表示超时;<0 表示出错。
- 最后一个参数为最大等待时间,传NULL表示永久阻塞。

相比 poll() select() 跨平台兼容性更好,但受限于 FD_SETSIZE (通常1024)。 poll() 则无此限制:

#include <poll.h>

struct pollfd pfd = { .fd = fd, .events = POLLIN };
int ret = poll(&pfd, 1, timeout_ms);
if (ret > 0 && (pfd.revents & POLLIN)) {
    // 数据可读
}

两者均适用于构建高性能串口服务程序,尤其适合多设备集中管理场景。

sequenceDiagram
    participant App as 应用程序
    participant Kernel as 内核
    participant UART as UART硬件

    Kernel->>UART: 配置中断使能
    UART-->>Kernel: 数据到达 → 触发中断
    Kernel->>App: 通过select()/poll()唤醒或发送SIGIO
    App->>Kernel: 调用read()读取数据
    Kernel-->>App: 返回接收到的字节流

该序列图清晰地展现了从硬件中断到用户空间响应的完整链路。

4.2.3 异步I/O通知机制的应用场景

虽然POSIX AIO( aio_read , aio_write )理论上可用于串口,但由于大多数串口驱动并不完全支持异步I/O完成通知,实践中更多采用 epoll (Linux专属)或线程池+阻塞读取的方式。

但对于低延迟、高吞吐的工业网关系统,推荐采用如下混合模型:

  • 主线程使用 epoll 监听多个串口;
  • 每个串口绑定独立接收线程;
  • 接收线程内部使用 read() 阻塞读取,配合 VMIN=1, VTIME=10 防止无限等待;
  • 数据解包后投递至共享队列,由业务线程处理。

这种方式兼顾了效率与可维护性,是大型系统常用架构。

4.3 容错设计与稳定性增强策略

4.3.1 数据重传机制与超时重试逻辑

在不可靠信道中,引入确认-重传机制至关重要。常见做法是定义应用层协议头,包含序列号与ACK响应:

#define MAX_RETRIES 3
#define TIMEOUT_MS 1000

int send_with_retry(int fd, const uint8_t *buf, size_t len) {
    for (int i = 0; i < MAX_RETRIES; ++i) {
        write(fd, buf, len);
        if (wait_for_ack(fd, TIMEOUT_MS)) {
            return 0; // 成功
        }
    }
    return -1; // 失败
}

结合前文的 select() 超时检测,形成闭环控制。

4.3.2 缓冲区边界检查与内存安全防护

始终检查 read() 返回值,防止缓冲区溢出:

char buffer[256];
ssize_t n = read(fd, buffer, sizeof(buffer)-1);
if (n > 0) {
    buffer[n] = '\0'; // 安全加null终止
}

严禁使用固定大小缓冲区处理未知长度数据。

4.3.3 日志记录与调试信息输出规范

建议使用 syslog() 或专用日志库(如 log4c ),按等级输出:

#include <syslog.h>
syslog(LOG_ERR, "Serial read failed: %m");

便于远程运维与故障回溯。

4.4 设备异常恢复与资源释放流程

4.4.1 close()正确关闭串口避免资源泄漏

务必在退出前调用:

tcflush(fd, TCIOFLUSH);
close(fd);

否则可能导致下次打开失败。

4.4.2 断线检测与自动重连机制设计思路

结合 select() 超时与错误计数,设计心跳探测:

if (get_uart_error_stats(fd) > ERROR_THRESHOLD) {
    close(fd);
    sleep(1);
    fd = open("/dev/ttyUSB0", O_RDWR);
    configure_serial(fd);
}

4.4.3 清除输入/输出队列:tcflush()的实际应用

tcflush(fd, TCIFLUSH);  // 清输入队列
tcflush(fd, TCOFLUSH);  // 清输出队列
tcflush(fd, TCIOFLUSH); // 两者都清

防止残留数据干扰后续通信。

5. Linux下串口读写完整流程实战(基于read_seri.c与write_seri.c)

在嵌入式开发和工业通信中,实现稳定可靠的串口数据交互是系统设计的基础环节。本章将围绕两个典型C语言源码文件 read_seri.c write_seri.c 展开,全面解析从设备打开、参数配置、数据收发到资源释放的完整串口操作流程。通过实际代码演示,深入剖析每个系统调用的作用时机与上下文依赖,帮助开发者构建具备错误处理能力、可移植性强且符合POSIX标准的串口应用程序。

整个实践过程不仅涵盖基本的 open() read() write() 等系统调用使用方式,还重点讲解了如何结合 termios 结构体进行波特率、数据格式等关键参数的精确设置,并引入超时控制、非阻塞I/O以及权限管理机制以提升程序健壮性。最终目标是使读者能够独立完成串口通信模块的设计与调试,适用于如传感器采集、PLC通信、GPS定位设备对接等多种真实场景。

5.1 read_seri.c 源码解析与接收逻辑实现

5.1.1 程序结构概览与主函数流程设计

read_seri.c 是一个典型的串口数据接收程序,其主要功能是从指定的串口设备(如 /dev/ttyS0 )持续读取字节流并打印至终端。该程序采用同步阻塞模式运行,适合用于调试或低频数据采集场景。

程序整体结构清晰,包含头文件引入、串口初始化函数封装、主循环数据读取及异常退出处理四个核心部分。主函数负责命令行参数解析、串口打开与配置、循环读取数据以及资源释放。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <termios.h>
#include <errno.h>

int open_serial(const char *portname);
int configure_serial(int fd, int baudrate);

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <serial_port> <baudrate>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    const char *port = argv[1];
    int baudrate = atoi(argv[2]);

    int serial_fd = open_serial(port);
    if (serial_fd == -1) {
        perror("Failed to open serial port");
        exit(EXIT_FAILURE);
    }

    if (configure_serial(serial_fd, baudrate) == -1) {
        perror("Failed to configure serial port");
        close(serial_fd);
        exit(EXIT_FAILURE);
    }

    printf("Listening on %s at %d bps...\n", port, baudrate);

    unsigned char buffer[256];
    ssize_t bytes_read;

    while (1) {
        bytes_read = read(serial_fd, buffer, sizeof(buffer));
        if (bytes_read > 0) {
            buffer[bytes_read] = '\0';
            printf("Received: %s\n", buffer);
        } else if (bytes_read == 0) {
            printf("End of file (EOF) reached.\n");
            break;
        } else {
            if (errno == EAGAIN || errno == EWOULDBLOCK)
                continue; // Non-blocking mode timeout
            else {
                perror("Read error");
                break;
            }
        }
    }

    close(serial_fd);
    return 0;
}
逻辑逐行分析:
  • 第1–8行 :包含必要的系统头文件,提供文件I/O、串口控制、错误处理等功能支持。
  • 第10–11行 :声明外部函数 open_serial() configure_serial() ,分别用于设备打开和参数配置。
  • 第13–18行 :主函数检查命令行参数是否为三个(程序名、端口路径、波特率),否则输出用法提示并退出。
  • 第20–24行 :调用 open_serial() 打开串口设备,失败则打印错误信息并终止。
  • 第26–31行 :调用 configure_serial() 设置波特率等通信参数,若失败则关闭设备并退出。
  • 第33–35行 :输出当前监听信息,进入接收状态。
  • 第37–55行 :主循环中调用 read() 读取数据,成功则打印内容;返回0表示无数据可读(可能断开);负值则判断错误类型,EAGAIN 表示非阻塞超时,其他错误则中断。
  • 第57行 :正常或异常退出前关闭文件描述符,防止资源泄漏。

此结构体现了良好的模块化设计思想,便于后期扩展为多线程服务或集成进更大系统。

5.1.2 串口打开与非阻塞模式选择策略

串口设备的打开需使用标准 open() 系统调用,但与普通文件不同,应根据应用场景选择合适的标志位组合。

int open_serial(const char *portname) {
    int fd = open(portname, O_RDONLY | O_NOCTTY | O_NDELAY);
    if (fd == -1) {
        return -1;
    }

    if (fcntl(fd, F_SETFL, 0) == -1) {
        close(fd);
        return -1;
    }

    return fd;
}
参数说明:
标志位 含义
O_RDONLY 只读方式打开设备,适用于仅接收数据的应用
O_NOCTTY 防止该设备成为控制终端(TTY),避免意外信号干扰
O_NDELAY 忽略DTR/DSR等载波检测信号,即使设备未连接也能打开

⚠️ 注意: O_NDELAY 已被 O_NONBLOCK 替代,在现代应用中建议使用后者以获得更一致的行为。

后续通过 fcntl(fd, F_SETFL, 0) 显式清除所有标志(包括 O_NDELAY ),恢复为阻塞模式,确保 read() 调用会等待数据到来。

这一步骤非常重要——许多初学者误以为设置了 O_NDELAY 后仍能正常使用阻塞读取,实际上会导致 read() 立即返回 -1 并置 errno=EAGAIN ,必须手动重置标志才能恢复正常行为。

5.1.3 termios参数配置与波特率设置详解

串口通信质量高度依赖于参数一致性,尤其是波特率、数据位、停止位和校验方式。以下函数实现了对这些参数的标准化配置:

int configure_serial(int fd, int baudrate) {
    struct termios tty;
    memset(&tty, 0, sizeof(tty));

    if (tcgetattr(fd, &tty) != 0) {
        perror("tcgetattr");
        return -1;
    }

    cfsetispeed(&tty, B115200); // Set input speed
    cfsetospeed(&tty, B115200); // Set output speed

    tty.c_cflag &= ~PARENB;     // No parity bit
    tty.c_cflag &= ~CSTOPB;     // 1 stop bit
    tty.c_cflag &= ~CSIZE;      // Clear data size bits
    tty.c_cflag |= CS8;         // 8-bit characters
    tty.c_cflag &= ~CRTSCTS;    // Disable hardware flow control
    tty.c_cflag |= CREAD | CLOCAL; // Enable receiver, ignore modem status lines

    tty.c_iflag = IGNPAR | ICRNL; // Ignore parity errors, map CR to NL on input
    tty.c_oflag = 0;              // No output processing
    tty.c_lflag = 0;              // No local processing (echo, canonical mode off)
    tty.c_cc[VMIN]  = 1;          // Wait for at least 1 byte
    tty.c_cc[VTIME] = 5;          // Timeout: 0.5 seconds (5 deciseconds)

    tcflush(fd, TCIFLUSH); // Flush pending input data
    if (tcsetattr(fd, TCSANOW, &tty) != 0) {
        perror("tcsetattr");
        return -1;
    }

    return 0;
}
逻辑逐行解读:
  • 第3行 :清空 termios 结构体,避免残留值影响配置。
  • 第5–7行 :获取当前串口属性,失败则返回错误。
  • 第9–10行 :使用 cfsetispeed() cfsetospeed() 设置输入/输出波特率为115200bps(可根据传参动态调整)。
  • 第12–17行 :设置数据格式:
  • ~PARENB : 关闭奇偶校验;
  • ~CSTOPB : 使用1个停止位;
  • CS8 : 数据位为8位;
  • ~CRTSCTS : 禁用硬件流控;
  • CREAD | CLOCAL : 允许接收,忽略调制解调器控制线。
  • 第19–22行 :设置输入/输出模式:
  • IGNPAR : 忽略帧错误和溢出错误;
  • ICRNL : 将输入回车符映射为换行;
  • c_oflag=0 : 不做任何输出转换;
  • c_lflag=0 : 关闭本地回显和规范输入模式(原始模式)。
  • 第23–24行 :设置读取超时策略:
  • VMIN=1 : 至少等待1字节到达;
  • VTIME=5 : 最长等待0.5秒(单位:十分之一秒)。
  • 第26–28行 :刷新输入缓冲区后应用新配置, TCSANOW 表示立即生效。

该配置适用于大多数RS-232点对点通信场景,如与GPS模块、温湿度传感器通信。

5.1.4 数据接收机制与超时控制模型

read_seri.c 中的数据接收采用同步阻塞 + 超时机制,由 VMIN VTIME 控制读取行为。

VMIN VTIME 行为描述
>0 >0 至少等待VMIN字节,期间每收到一字节重启VTIME计时器
>0 0 永久阻塞直到收到VMIN字节
0 >0 最多等待VTIME时间,有数据就返回,无数据返回0
0 0 非阻塞读取,立即返回可用数据或EAGAIN

当前配置为 VMIN=1 , VTIME=5 ,意味着:

  • 若立即收到数据,则 read() 立即返回;
  • 若无数据,最多等待0.5秒;
  • 若中途收到任意字节,计时器重置,继续等待下一个字节。

这种方式平衡了实时性与CPU占用率,避免忙等待。

下面是一个 mermaid 流程图展示主循环的数据接收流程:

graph TD
    A[Start Read Loop] --> B{read() called}
    B --> C[Data available?]
    C -->|Yes| D[Copy data to buffer]
    D --> E[Print received data]
    E --> A
    C -->|No, within timeout| F[Wait up to VTIME]
    F --> B
    C -->|Timeout exceeded| G[Return 0 or -1]
    G --> H{Error?}
    H -->|Yes| I[Log error and break]
    H -->|No| J[Continue loop]
    J --> A

该流程体现了典型的串口轮询接收机制,适用于轻量级应用。对于高并发或多设备场景,应考虑使用 select() poll() 实现事件驱动架构。

5.2 write_seri.c 发送程序设计与数据发送优化

5.2.1 write_seri.c 主体结构与用户交互接口

与接收程序对应, write_seri.c 负责向串口设备发送用户输入的数据。它支持从标准输入读取字符串并通过串口发出,常用于测试远程设备响应。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <termios.h>

int open_serial(const char *portname);
int configure_serial(int fd, int baudrate);

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <serial_port> <baudrate>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    const char *port = argv[1];
    int baudrate = atoi(argv[2]);

    int serial_fd = open_serial(port);
    if (serial_fd < 0) {
        perror("open_serial");
        exit(EXIT_FAILURE);
    }

    if (configure_serial(serial_fd, baudrate) < 0) {
        perror("configure_serial");
        close(serial_fd);
        exit(EXIT_FAILURE);
    }

    printf("Enter messages to send (Ctrl+D to quit):\n");

    char buffer[256];
    ssize_t n;

    while ((n = read(STDIN_FILENO, buffer, sizeof(buffer)-1)) > 0) {
        buffer[n] = '\0';

        ssize_t written = write(serial_fd, buffer, n);
        if (written != n) {
            fprintf(stderr, "Partial write or error: %zd/%zd bytes written\n", written, n);
        } else {
            printf("Sent: %s", buffer);
        }

        usleep(100000); // Optional delay between sends
    }

    close(serial_fd);
    return 0;
}
功能特点:
  • 支持交互式输入,用户键入内容后按回车即发送;
  • 使用 STDIN_FILENO 读取标准输入,兼容管道与重定向;
  • 添加 usleep() 延迟防止发送过快导致缓冲区溢出;
  • write() 返回值进行完整性校验,检测部分写入问题。

5.2.2 write()系统调用行为与发送缓冲区管理

Linux串口写入本质上是向内核环形缓冲区(TX FIFO)复制数据,而非直接发送。因此 write() 成功返回仅代表数据已进入队列,不代表对方已接收。

ssize_t written = write(serial_fd, buffer, n);
if (written < 0) {
    perror("write failed");
} else if (written != n) {
    fprintf(stderr, "Only %zd of %zd bytes sent\n", written, n);
}
写入行为特征:
条件 write() 返回值
缓冲区有空间容纳全部数据 返回请求长度 n
缓冲区满或部分可用 返回已写入字节数(<n)
设备不可写或出错 返回 -1,设置 errno

为保证数据完整发送,应采用循环写入机制:

ssize_t total_written = 0;
while (total_written < n) {
    ssize_t result = write(serial_fd, buffer + total_written, n - total_written);
    if (result < 0) {
        if (errno == EINTR) continue; // Interrupted, retry
        else { perror("write"); break; }
    }
    total_written += result;
}

此模式确保所有数据最终写入发送缓冲区,提高可靠性。

5.2.3 波特率匹配与通信稳定性保障

发送端与接收端必须保持波特率一致,否则会出现乱码或完全无法识别。常见波特率及其对应的 termios 常量如下表所示:

波特率 termios 常量
9600 B9600
19200 B19200
38400 B38400
57600 B57600
115200 B115200
230400 B230400
460800 B460800
921600 B921600

注意:并非所有平台都支持高于115200的波特率,尤其在虚拟机或老旧硬件上。可通过以下方式验证支持情况:

stty -F /dev/ttyS0 460800 && echo "Supported" || echo "Not supported"

此外,还需确认两端数据格式一致,推荐统一使用 8N1 (8数据位、无校验、1停止位)作为默认配置。

5.2.4 错误处理与资源释放最佳实践

在程序异常退出时,必须正确关闭串口并清理资源。以下为增强版 close 处理逻辑:

// Before close, flush any pending output
tcdrain(serial_fd); // Wait until all data transmitted
close(serial_fd);
  • tcdrain() 确保所有待发送数据真正发出后再关闭设备,避免截断最后一段消息。
  • close() 自动释放文件描述符,但不会自动恢复原串口设置,必要时可保存初始 termios 并在退出时还原。

5.3 完整编译与运行验证流程

5.3.1 编译指令与权限设置

使用 GCC 编译两个程序:

gcc -o read_seri read_seri.c -Wall -Wextra
gcc -o write_seri write_seri.c -Wall -Wextra

赋予执行权限:

chmod +x read_seri write_seri

由于访问 /dev/ttyS* 通常需要 root 权限或加入 dialout 用户组,建议执行:

sudo usermod -aG dialout $USER

然后重新登录生效。

5.3.2 双机通信测试方案

假设有两台主机通过交叉串口线连接:

  • 主机A运行 ./write_seri /dev/ttyS0 115200
  • 主机B运行 ./read_seri /dev/ttyS0 115200

在A端输入任意文本,B端应能实时显示。反之亦然。

也可在同一台机器上使用 USB-to-TTL 转接器自环测试:

# 连接 TXD <-> RXD
./write_seri /dev/ttyUSB0 115200
./read_seri  /dev/ttyUSB0 115200

5.3.3 常见故障排查清单

故障现象 可能原因 解决方法
设备不存在 /dev/ttyS0 串口未启用或物理缺失 使用 dmesg | grep tty 查看内核日志
Permission denied 用户无权限访问设备 加入 dialout 组或使用 sudo
读取不到数据 波特率不匹配 双方确认一致
接收乱码 数据格式错误(如7E1 vs 8N1) 检查 c_cflag 配置
write() 返回 EAGAIN 非阻塞模式且缓冲区满 改为阻塞模式或添加重试逻辑
程序卡死 VMIN/VTIME 设置不当 调整为 VMIN=0, VTIME=10 测试

5.4 综合性能评估与改进方向

虽然 read_seri.c write_seri.c 实现了基础功能,但在生产环境中仍存在局限:

  • 单线程阻塞模型不适合多设备监控;
  • 缺乏CRC校验与协议层解析;
  • 未使用异步I/O提升效率。

未来可引入 select() poll() epoll 实现多路复用,或将核心逻辑封装为守护进程配合 systemd 管理。

通过本套实战流程的学习,开发者已掌握从零构建串口通信程序的能力,为进一步开发Modbus、自定义二进制协议等高级应用打下坚实基础。

6. 串口编程进阶应用场景与性能优化方向

6.1 Modbus协议栈集成与工业通信实践

在工业自动化领域,串口常作为Modbus RTU协议的物理层载体。将基础串口读写能力扩展为支持Modbus协议栈,是实现PLC、传感器、仪表等设备互联的关键步骤。

一个典型的Modbus RTU请求帧结构如下:

字段 长度(字节) 说明
Slave Address 1 从机地址(0x01 ~ 0xFF)
Function Code 1 功能码(如0x03读保持寄存器)
Start Addr 2 寄存器起始地址
Reg Count 2 寄存器数量
CRC 2 循环冗余校验

以下是构建Modbus RTU请求的C代码片段:

#include <stdint.h>
#include <unistd.h>

// 计算CRC16-MODBUS校验值
uint16_t modbus_crc16(uint8_t *buf, int len) {
    uint16_t crc = 0xFFFF;
    for (int i = 0; i < len; ++i) {
        crc ^= buf[i];
        for (int j = 0; j < 8; ++j) {
            if (crc & 0x0001)
                crc = (crc >> 1) ^ 0xA001;
            else
                crc >>= 1;
        }
    }
    return crc;
}

// 发送读保持寄存器指令(功能码0x03)
int send_modbus_read_holding(int fd, uint8_t slave_addr, uint16_t start_reg, uint16_t count) {
    uint8_t request[8];
    request[0] = slave_addr;
    request[1] = 0x03;
    request[2] = (start_reg >> 8) & 0xFF;
    request[3] = start_reg & 0xFF;
    request[4] = (count >> 8) & 0xFF;
    request[5] = count & 0xFF;

    uint16_t crc = modbus_crc16(request, 6);
    request[6] = crc & 0xFF;
    request[7] = (crc >> 8) & 0xFF;

    return write(fd, request, 8); // 返回写入字节数
}

执行流程说明:
1. 构造协议帧头(地址+功能码+参数)
2. 调用 modbus_crc16() 计算校验值并追加至末尾
3. 使用 write() 发送完整帧
4. 随后调用 read() 接收响应数据,并进行CRC验证和解析

此模式广泛应用于SCADA系统、远程IO模块通信中,具备良好的实时性与兼容性。

6.2 跨平台串口库对比分析与选型建议

随着项目复杂度提升,直接使用POSIX API已难以满足跨平台需求。主流C/C++串口库对比如下表所示:

库名 平台支持 语言 线程安全 特点
libserial Linux/macOS C++ 封装清晰,RAII管理资源
boost.asio 全平台 C++ 异步I/O强大,依赖Boost庞大
Poco Serial 全平台 C++ 属于Poco框架一部分,轻量级
termios(原生) Linux/Unix C 最小依赖,需手动处理平台差异
QSerialPort Qt生态全平台 C++ GUI集成方便,适合Qt应用

boost.asio 为例,异步读取串口数据的核心代码如下:

#include <boost/asio.hpp>
using namespace boost::asio;

void async_read_example() {
    io_context io;
    serial_port port(io, "/dev/ttyUSB0");

    port.set_option(serial_port_base::baud_rate(115200));

    char buffer[256];
    port.async_read_some(buffer_sequence(buffer, 256),
        [&](const error_code& ec, size_t bytes_transferred) {
            if (!ec) {
                printf("Received %zu bytes\n", bytes_transferred);
            }
        });

    io.run(); // 启动事件循环
}

优势在于:
- 支持异步非阻塞操作,避免主线程挂起
- 统一接口屏蔽底层差异(Windows用COMx,Linux用/dev/ttySx)
- 可与其他socket共用同一个 io_context ,实现多路复用

适用于需要高响应性的监控系统或网关服务。

6.3 多线程串口数据采集设计模式

当面对多个串口设备并发采集时,推荐采用“生产者-消费者”模型:

graph TD
    A[Thread 1: /dev/ttyS0] --> B[Ring Buffer 1]
    C[Thread 2: /dev/ttyS1] --> D[Ring Buffer 2]
    E[Thread 3: /dev/ttyS2] --> F[Ring Buffer 3]
    B --> G[Main Processing Thread]
    D --> G
    F --> G
    G --> H[(Data Aggregation)]

关键实现要点:
- 每个串口独占线程运行 read() 循环,防止相互阻塞
- 使用环形缓冲区(FIFO)暂存原始数据包
- 主线程统一调度解析逻辑,确保时间戳同步与一致性处理
- 配合互斥锁(pthread_mutex_t)保护共享资源

典型线程函数结构:

void* serial_reader(void* arg) {
    struct serial_port_info* info = (struct serial_port_info*)arg;
    int fd = open(info->dev_path, O_RDWR);
    configure_termios(fd); // 设置波特率等

    while (!shutdown_flag) {
        fd_set rfds;
        struct timeval tv = {0, 100000}; // 100ms超时
        FD_ZERO(&rfds);
        FD_SET(fd, &rfds);

        if (select(fd + 1, &rfds, NULL, NULL, &tv) > 0) {
            int n = read(fd, buffer, sizeof(buffer));
            if (n > 0) {
                pthread_mutex_lock(&info->buf_mutex);
                ring_buffer_write(&info->rb, buffer, n);
                pthread_mutex_unlock(&info->buf_mutex);
            }
        }
    }
    close(fd);
    return NULL;
}

该架构可支撑数十个串口设备同时运行,广泛用于环境监测站、电力监控系统等场景。

6.4 性能优化路径:epoll + mmap + FIFO协同机制

对于高性能串口服务器,传统 read/write 存在瓶颈。可通过以下方式优化:

使用 epoll 实现高并发监听

int epfd = epoll_create1(0);
struct epoll_event ev, events[MAX_EVENTS];

for (int i = 0; i < num_ports; ++i) {
    ev.events = EPOLLIN | EPOLLET; // 边缘触发
    ev.data.fd = fds[i];
    epoll_ctl(epfd, EPOLL_CTL_ADD, fds[i], &ev);
}

while (1) {
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nfds; ++i) {
        handle_serial_data(events[i].data.fd); // 快速响应
    }
}

相比 select() epoll 在大量文件描述符下性能更优,尤其适合网关类设备。

利用内核FIFO减少中断频率

通过调整UART控制器FIFO阈值(通常通过 ioctl 设置),可降低CPU中断次数。例如设置接收FIFO触发级别为14字节:

struct serial_struct ser_info;
ioctl(fd, TIOCGSERIAL, &ser_info);
ser_info.cflags |= ASYNC_FIFO_ENABLE;
ser_info.xmit_fifo_size = 16;
ioctl(fd, TIOCSSERIAL, &ser_info);

此举可在突发流量下显著提升吞吐效率。

尽管现代系统逐步转向USB/网络通信,但串口因其确定性延迟和低功耗特性,在嵌入式边缘设备中仍不可替代。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:在Linux系统中,串口通信是嵌入式开发、设备调试和模块数据交互的基础技术之一。本文围绕 read_seri.c write_seri.c 两个C语言源码文件,详细讲解如何在Linux下通过操作/dev/ttySx设备文件实现串口数据的读取与发送。内容涵盖串口基本概念、termios结构体配置、波特率设置及read/write系统调用的使用方法,并介绍非阻塞读取、流控制等实际应用中的关键处理机制。通过本实例学习,开发者可掌握Linux下底层串口通信的核心编程技巧,为嵌入式项目开发提供坚实基础。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐