4. 串口驱动开发(二):用户空间 termios 编程、原始模式与规范模式
上一章我们把串口驱动框架搭起来了,能收发数据了。但很多朋友会发现:为什么我发一个字节,系统给我回一堆东西?为什么我按回车键,程序才响应?
这背后就是 termios 在起作用。说白了,Linux 默认把串口当终端来管,加了很多“贴心”的缓冲和行编辑功能。但做嵌入式传感器采集,这些贴心反而是灾难。
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)则完全相反:来一个字节就给你一个字节,不做任何加工。
两种模式的核心差异:
| 特性 | 规范模式 | 原始模式 |
|---|---|---|
| 行缓冲 | 有,直到收到 \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);
4.6 知识体系:串口用户空间编程全景
下面这张图是我自己总结的串口用户空间编程流程,每次做新项目都对照着走一遍:
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 编程的核心内容就讲完了。原始模式和规范模式的选择,说白了就是一句话:跟人交互用规范模式,跟设备交互用原始模式。记住这个原则,能省掉很多调试时间。