2、串口驱动开发实战:Linux下tty驱动框架、termios结构体配置、非标准波特率设置、串口打开/关闭/读写API

串口驱动开发,说白了就是跟 Linux 的 tty 子系统打交道。很多刚入行的朋友觉得这玩意儿很神秘,其实拆开来看,就那么几块东西:驱动框架怎么挂上去、参数怎么配、数据怎么读写。我做了这么多年嵌入式,踩过的坑有一半都跟串口配置有关。今天咱们就把这些事掰扯清楚。

2.1 Linux tty 驱动框架概览

Linux 下的串口设备,本质上是 tty 设备的一种。整个框架分三层:

  • tty 核心层:负责设备注册、文件操作接口、线路规程管理
  • 线路规程 (line discipline):处理数据封装、协议转换,默认是 N_TTY
  • 硬件驱动层:直接操作 UART 寄存器,收发数据

我个人习惯把这三层想象成「调度中心 - 包装车间 - 生产线」的关系。应用层读写数据,经过 tty 核心调度,线路规程做格式化,最后由驱动层塞进硬件 FIFO。

关键点:用户空间操作的是 /dev/ttyS0、/dev/ttyAMA0 这类设备节点。驱动注册时,会通过 tty_port 结构体管理端口状态、缓冲区、硬件参数。

Linux tty 驱动框架分层结构 应用层 (open/read/write/ioctl) tty 核心层 (tty_register_driver / tty_port) 线路规程 (n_tty / ldisc) 硬件驱动层 (uart_ops / 中断处理) UART 硬件 (寄存器/FIFO) 数据流向 应用 → 核心 → 规程 → 驱动 → 硬件 中断反向传递数据

2.2 termios 结构体配置

termios 是串口参数配置的核心结构体。你想想看,波特率、数据位、停止位、校验位、流控,全得靠它。我见过太多人直接 memset 清零然后随便填几个值就跑,结果通信死活不对。

#include <termios.h>

struct termios options;

// 读取当前参数
tcgetattr(fd, &options);

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

// 控制标志:8数据位,无校验,1停止位
options.c_cflag |= CLOCAL | CREAD;
options.c_cflag &= ~PARENB;
options.c_cflag &= ~CSTOPB;
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8;

// 本地标志:关闭回显、规范输入
options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);

// 输出标志:原始输出
options.c_oflag &= ~OPOST;

// 输入标志:关闭软件流控
options.c_iflag &= ~(IXON | IXOFF | IXANY);

// 应用配置
tcsetattr(fd, TCSANOW, &options);

我的经验:配置完一定要用 tcgetattr 再读回来确认一遍。有些驱动会静默忽略非法参数,你以为是配上了,实际跑的可能是默认值。我曾经被这个坑过,排查了整整两天。

2.3 非标准波特率设置

标准波特率就那么几个:9600、19200、38400、115200……但实际项目中经常遇到非标值,比如 250000、500000、921600。Linux 下怎么搞?

方法有两种:

  • 使用 BOTHER 宏:在 termios 的 c_cflag 中设置 BOTHER,然后通过 c_ospeed 和 c_ispeed 直接写入数值
  • 使用 ioctl TIOCGSERIAL/TIOCSSERIAL:直接操作 serial_struct 结构体,设置 custom_divisor

我建议优先用 BOTHER 方式,代码更简洁。不过要注意,内核版本和驱动必须支持这个特性。老版本内核或者某些 USB 转串口芯片可能不支持。

#include <asm/termbits.h>  // 注意头文件

struct termios2 options2;

ioctl(fd, TCGETS2, &options2);
options2.c_cflag &= ~CBAUD;
options2.c_cflag |= BOTHER;          // 启用自定义波特率
options2.c_ospeed = 250000;          // 设置输出波特率
options2.c_ispeed = 250000;          // 设置输入波特率
ioctl(fd, TCSETS2, &options2);

注意:非标准波特率对时钟精度要求高。如果 UART 的时钟源是 24MHz,要产生 250000 波特率,分频系数算出来可能不是整数,实际误差会累积。我建议算一下实际误差,超过 2% 就可能丢包。

2.4 串口打开/关闭/读写 API

串口的打开关闭跟普通文件一样,但有几个细节要注意。

操作 API 关键标志/参数
打开 open() O_RDWR | O_NOCTTY | O_NDELAY
关闭 close() 先调用 tcdrain() 等待发送完成
读 read() 阻塞/非阻塞取决于 open 标志
写 write() 返回实际写入字节数

打开时加 O_NOCTTY 是为了防止串口成为控制终端。O_NDELAY 表示非阻塞,如果你希望阻塞等待数据,就不要加这个标志。

读操作有个坑:默认情况下,read 返回的数据量可能比你请求的少。尤其是使用规范模式时,它按行返回。所以我一般建议用原始模式,配合超时和最小字节数控制。

// 设置 VTIME 和 VMIN
options.c_cc[VTIME] = 10;   // 超时 1 秒 (单位 0.1 秒)
options.c_cc[VMIN]  = 0;    // 不等待最小字节数

// 读写示例
char buf[256];
int n = read(fd, buf, sizeof(buf));
if (n > 0) {
    // 处理数据
}

int written = write(fd, "Hello", 5);
tcdrain(fd);  // 等待发送完成再关闭

避坑指南:关闭串口前一定要调 tcdrain()。我曾经在高速发送时直接 close,结果最后几字节还在 FIFO 里没发出去,对端就收不到完整的包。加上 tcdrain 之后问题解决。

2.5 实战中的常见问题与排查思路

串口开发说难不难,说简单也不简单。我总结几个高频问题:

  • 数据乱码:先查波特率,再用示波器看波形。很多时候是波特率误差太大
  • 只能发不能收:检查 CREAD 标志有没有设置,流控引脚有没有接对
  • read 阻塞不返回:VMIN 和 VTIME 配置不合理,或者硬件没发数据
  • write 返回成功但实际没发出去:检查流控 CTS/RTS 信号,或者 FIFO 被填满

嗯,这里要注意:排查问题时别只盯着软件。我遇到过好几次,代码怎么看都没错,最后发现是杜邦线接触不良。先拿示波器看 TX/RX 引脚有没有波形,这是最快的方法。