4. 串口驱动开发(二):用户空间 termios 编程、原始模式与规范模式

上一章我们把串口驱动框架搭起来了,能收发数据了。但很多朋友会发现:为什么我发一个字节,系统给我回一堆东西?为什么我按回车键,程序才响应?

这背后就是 termios 在起作用。说白了,Linux 默认把串口当终端来管,加了很多“贴心”的缓冲和行编辑功能。但做嵌入式传感器采集,这些贴心反而是灾难。

核心认知: 串口驱动开发,本质就是跟 termios 结构体打交道。你控制它,它就听你的;你不控制它,它就按默认终端模式处理,数据流会被严重干扰。

4.1 termios 结构体:串口配置的“控制面板”

termios 定义在 <termios.h> 里。我刚开始接触时觉得它字段太多,后来发现其实就三大类:

成员 作用 我常用的标志
c_cflag 控制硬件参数:波特率、数据位、停止位、校验 CLOCAL | CREAD
c_iflag 输入处理:是否做奇偶校验、是否转换回车换行 原始模式全清 0
c_oflag 输出处理:是否做输出转换 原始模式全清 0
c_lflag 本地模式:规范/原始、回显、信号生成 原始模式清 ICANON ECHO
c_cc[NCCS] 控制字符数组:超时、最小字节数 VMIN VTIME

嗯,这里要注意:c_cc 数组里的 VMIN 和 VTIME 是原始模式下最关键的参数,后面我会细讲。

4.2 规范模式 vs 原始模式:选错模式,数据全乱

规范模式(Canonical Mode)是默认的。它把串口当键盘用:只有收到换行符 \n 或回车 \r 时,read() 才会返回。中间还能做行编辑(退格、删除)。

原始模式(Raw Mode)则完全相反:来一个字节就给你一个字节,不做任何加工。

避坑指南: 我曾经接手过一个温湿度传感器项目,前任工程师用规范模式读数据,结果传感器每帧末尾没有换行符,read() 一直阻塞,系统三天没更新数据。换成原始模式后,问题立刻解决。

两种模式的核心差异:

特性 规范模式 原始模式
行缓冲 有,直到收到 \n 或 \r 无,立即返回
行编辑 支持退格、删除 不支持
信号生成 Ctrl+C 可中断 关闭
回显 默认开启 关闭
适用场景 人机交互终端 传感器、模块数据采集

4.3 原始模式配置实战:一步步来

我个人习惯把原始模式配置封装成一个函数,每次打开串口后直接调用。这样代码干净,也方便复用。

#include <termios.h>
#include <unistd.h>
#include <fcntl.h>

int set_raw_mode(int fd) {
    struct termios options;

    // 1. 先读取当前配置
    if (tcgetattr(fd, &options) != 0) {
        return -1;
    }

    // 2. 设置控制模式标志
    options.c_cflag |= (CLOCAL | CREAD);   // 启用接收,忽略调制解调器控制
    options.c_cflag &= ~CSIZE;             // 清除数据位掩码
    options.c_cflag |= CS8;                // 8 位数据位
    options.c_cflag &= ~PARENB;            // 无校验
    options.c_cflag &= ~CSTOPB;            // 1 位停止位

    // 3. 关闭所有输入处理
    options.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP
                         | INLCR | IGNCR | ICRNL | IXON);

    // 4. 关闭所有输出处理
    options.c_oflag &= ~OPOST;

    // 5. 关闭规范模式、回显、信号
    options.c_lflag &= ~(ICANON | ECHO | ECHOE | ECHONL | ISIG);

    // 6. 设置 VMIN 和 VTIME
    options.c_cc[VMIN] = 1;    // 至少读 1 个字节才返回
    options.c_cc[VTIME] = 0;   // 不等待

    // 7. 清空缓冲区并应用配置
    tcflush(fd, TCIOFLUSH);
    if (tcsetattr(fd, TCSANOW, &options) != 0) {
        return -1;
    }

    return 0;
}
我的经验: tcflush() 这步很多人会忘。如果不清空缓冲区,之前残留的数据会混在新配置的第一次读取里,导致帧错位。我吃过这个亏,调试了整整一个下午。

4.4 VMIN 和 VTIME:控制 read() 行为的两个旋钮

这两个参数是原始模式下最灵活、也最容易搞混的地方。你想想看,read() 到底什么时候返回?是等够一定字节数?还是等够一定时间?

VMIN 和 VTIME 的组合决定了这个行为:

VMIN VTIME read() 行为 典型场景
0 0 立即返回,有数据就返回数据,没有返回 0 非阻塞轮询
1 0 阻塞直到收到至少 1 个字节 逐字节读取
0 5 等待最多 0.5 秒,超时返回已收到的数据 超时保护
5 10 等待至少 5 个字节,或 1 秒超时 定长帧读取

我建议做传感器驱动时,先用 VMIN=1, VTIME=0 调试,确保能收到数据。稳定后再根据帧结构调整。

4.5 波特率设置:别被 B115200 这种写法骗了

termios 里波特率不是直接写数字,而是用宏定义。比如 115200 要写成 B115200。设置时用 cfsetispeed() 和 cfsetospeed() 两个函数。

// 设置波特率 115200
cfsetispeed(&options, B115200);
cfsetospeed(&options, B115200);
避坑指南: 有些平台(比如某些 ARM 开发板)不支持所有标准波特率。我曾经在 i.MX6 平台上用 250000 波特率,结果实际速率偏差了 3%,导致数据偶尔错位。后来换成 230400 才稳定。遇到奇怪问题时,先拿示波器量一下实际波形。

4.6 知识体系:串口用户空间编程全景

下面这张图是我自己总结的串口用户空间编程流程,每次做新项目都对照着走一遍:

串口用户空间编程流程 串口用户空间编程全景流程 1. open() 打开设备 2. tcgetattr() 读配置 3. 设置原始模式 c_cflag 硬件参数 c_iflag 输入处理 c_oflag 输出处理 c_lflag 本地模式 c_cc 控制 4. cfsetispeed/setospeed 5. tcsetattr() 应用 6. tcflush() 清空 7. read() / write() 读写 8. close() 关闭 💡 关键提醒: • 步骤 3-6 的顺序不要颠倒,尤其是 tcflush() 必须在 tcsetattr() 之后 • VMIN 和 VTIME 的组合决定了 read() 的阻塞行为,根据帧结构灵活调整

4.7 完整示例:读取 GPS 模块数据

最后给一个实际例子。假设我们要读取一个 GPS 模块,它每秒输出一次 NMEA 0183 格式的语句,每句以 \r\n 结尾。

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

int main() {
    int fd = open("/dev/ttyS1", O_RDWR | O_NOCTTY);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    // 设置原始模式
    struct termios options;
    tcgetattr(fd, &options);

    options.c_cflag |= (CLOCAL | CREAD);
    options.c_cflag &= ~CSIZE;
    options.c_cflag |= CS8;
    options.c_cflag &= ~PARENB;
    options.c_cflag &= ~CSTOPB;

    options.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP
                         | INLCR | IGNCR | ICRNL | IXON);
    options.c_oflag &= ~OPOST;
    options.c_lflag &= ~(ICANON | ECHO | ECHOE | ECHONL | ISIG);

    // 这里用 VMIN=1, VTIME=0,逐字节读取
    options.c_cc[VMIN] = 1;
    options.c_cc[VTIME] = 0;

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

    tcflush(fd, TCIOFLUSH);
    tcsetattr(fd, TCSANOW, &options);

    // 读取数据
    char buf[256];
    int idx = 0;
    char ch;

    printf("GPS 数据开始接收...\n");

    while (1) {
        if (read(fd, &ch, 1) == 1) {
            buf[idx++] = ch;
            // 检测到换行符,输出一行
            if (ch == '\n') {
                buf[idx] = '\0';
                printf("%s", buf);
                idx = 0;
            }
            // 防止缓冲区溢出
            if (idx >= 255) {
                idx = 0;
            }
        }
    }

    close(fd);
    return 0;
}
我的习惯: 实际项目中我不会用逐字节读取,效率太低。我会根据帧最大长度设置 VMIN=128, VTIME=10,一次读一批数据,然后自己解析。但调试阶段逐字节读取更容易定位问题。

嗯,到这里 termios 编程的核心内容就讲完了。原始模式和规范模式的选择,说白了就是一句话:跟人交互用规范模式,跟设备交互用原始模式。记住这个原则,能省掉很多调试时间。