2. 通信基础回顾
串口驱动开发,说白了就是跟 Linux 的 tty 子系统打交道。很多刚入行的朋友觉得这玩意儿很神秘,其实拆开来看,就那么几块东西:驱动框架怎么挂上去、参数怎么配、数据怎么读写。我做了这么多年嵌入式,踩过的坑有一半都跟串口配置有关。今天咱们就把这些事掰扯清楚。
2.1 Linux tty 驱动框架概览
Linux 下的串口设备,本质上是 tty 设备的一种。整个框架分三层:
- tty 核心层:负责设备注册、文件操作接口、线路规程管理
- 线路规程 (line discipline):处理数据封装、协议转换,默认是 N_TTY
- 硬件驱动层:直接操作 UART 寄存器,收发数据
我个人习惯把这三层想象成「调度中心 - 包装车间 - 生产线」的关系。应用层读写数据,经过 tty 核心调度,线路规程做格式化,最后由驱动层塞进硬件 FIFO。
关键点:用户空间操作的是 /dev/ttyS0、/dev/ttyAMA0 这类设备节点。驱动注册时,会通过 tty_port 结构体管理端口状态、缓冲区、硬件参数。
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 引脚有没有波形,这是最快的方法。