3. Linux 串口驱动框架:从 TTY 到设备节点
串口调试,可以说是嵌入式开发里最基础也最常用的手段。但很多人只会用,一旦设备节点不出来,或者数据收发异常,就抓瞎了。这一章,我带你把 Linux 串口驱动的骨架摸清楚。搞懂了 TTY 子系统、uart_port 这些核心结构,以后遇到串口问题,你心里就有底了。
3.1 TTY 子系统:串口背后的“大管家”
你想想看,为什么我们在 Linux 下操作串口,感觉就像在操作一个终端?这背后就是 TTY 子系统的功劳。TTY 是 Teletype 的缩写,历史很久远了,但它的设计思想一直延续到今天。
TTY 子系统本质上是一个分层架构,它把硬件细节和用户空间的操作隔离开来。我画了一张图,你可以直观地看到数据是怎么从应用程序流到串口硬件的。
从这张图你能看到,数据从用户空间的应用程序,经过 TTY 核心和线路规程的处理,最终到达 UART 驱动层,然后才跟硬件打交道。每一层都有明确的职责。
核心要点:TTY 子系统是串口驱动的“大管家”。它负责管理设备节点、处理数据缓冲、实现线路规程(比如处理回车换行、信号等)。你写的应用程序,其实是在跟 TTY 核心对话,而不是直接操作 UART 硬件。
3.2 UART 驱动核心:uart_port 与 uart_ops
好了,现在我们深入到 UART 驱动层。这里有两个最重要的数据结构,你写驱动或者调试的时候,一定会跟它们打交道。
3.2.1 uart_port:串口的“身份证”
struct uart_port 描述了一个具体的串口端口。它包含了这个端口的所有硬件信息和状态。我挑几个关键字段给你看看:
| 字段名 | 类型 | 说明 |
|---|---|---|
dev |
struct device * |
指向这个端口对应的设备结构体,用于设备模型管理 |
mapbase |
resource_size_t |
UART 寄存器的物理基地址 |
membase |
void __iomem * |
UART 寄存器映射后的虚拟地址 |
irq |
unsigned int |
这个端口使用的中断号 |
uartclk |
unsigned int |
UART 模块的时钟频率,用于计算波特率 |
fifosize |
unsigned int |
硬件 FIFO 的大小 |
ops |
struct uart_ops * |
指向操作函数集的指针,这是关键 |
我记得有一次调试一个板子,串口就是死活不工作。我检查了设备树,地址和中断号都对了,但就是没反应。后来我打印了 uart_port 里的 uartclk,发现时钟频率配置错了,导致波特率计算完全不对。嗯,这种问题,光看代码很难发现,必须得把关键字段打出来看看。
3.2.2 uart_ops:串口的“操作手册”
struct uart_ops 定义了一组函数指针,这些函数就是 UART 驱动的具体实现。你的驱动核心工作,就是实现这些函数。
struct uart_ops {
unsigned int (*tx_empty)(struct uart_port *);
void (*set_mctrl)(struct uart_port *, unsigned int);
unsigned int (*get_mctrl)(struct uart_port *);
void (*stop_tx)(struct uart_port *);
void (*start_tx)(struct uart_port *);
void (*stop_rx)(struct uart_port *);
void (*enable_ms)(struct uart_port *);
void (*break_ctl)(struct uart_port *, int);
int (*startup)(struct uart_port *);
void (*shutdown)(struct uart_port *);
void (*set_termios)(struct uart_port *, struct ktermios *new,
struct ktermios *old);
void (*pm)(struct uart_port *, unsigned int state,
unsigned int oldstate);
int (*set_ldisc)(struct uart_port *, struct ktermios *);
// ... 还有其他一些
};
这里面,startup 和 shutdown 是端口打开和关闭时调用的,用来申请中断、初始化硬件。set_termios 是设置串口参数,比如波特率、数据位、停止位、校验位。而 start_tx 和 stop_tx 是发送数据的关键。
个人经验:我建议你在实现 start_tx 函数时,一定要考虑 FIFO 的使用。如果 FIFO 深度是 16,你一次只往里面塞一个字节,那效率就太低了。我曾经见过一个驱动,发送一个 1KB 的数据包,因为没用好 FIFO,竟然花了 100ms,这在高速通信场景下是不可接受的。
3.3 设备节点生成机制:/dev/ttyS* 和 /dev/ttyUSB* 是怎么来的?
这是很多人困惑的地方。为什么我插上一个 USB 转串口,它就变成了 /dev/ttyUSB0?为什么板子上的原生串口是 /dev/ttyS0?这背后是 Linux 设备模型和驱动注册的流程。
3.3.1 原生串口:ttyS* 的生成
对于 SoC 内部的原生 UART,驱动通常是通过平台驱动 (platform driver) 注册的。流程大致如下:
- 设备树匹配:内核启动时,解析设备树,找到 compatible 属性匹配的 UART 节点。
- 驱动 probe:匹配成功后,调用驱动的 probe 函数。在这个函数里,驱动会分配并初始化一个
uart_port结构体。 - 注册到 TTY 核心:调用
uart_register_driver()和uart_add_one_port()将端口注册到 TTY 子系统。 - 设备节点创建:TTY 核心会根据注册信息,在
/dev目录下创建设备节点。设备号的主设备号是 TTY 驱动分配的,次设备号从 0 开始递增。这就是/dev/ttyS0、/dev/ttyS1的由来。
3.3.2 USB 转串口:ttyUSB* 的生成
USB 转串口芯片(比如 CH340、CP2102、FT232)的生成机制稍有不同。它涉及 USB 驱动和 TTY 驱动的配合。
- USB 识别:当你插入 USB 设备,USB 核心会识别到它,并根据 VID/PID 找到对应的 USB 驱动。
- USB 驱动 probe:USB 驱动的 probe 函数被调用。这个函数会创建一个
struct usb_serial结构体,并调用usb_serial_probe()。 - TTY 端口注册:在
usb_serial_probe()内部,会为每个端口(一个 USB 转串口通常只有一个端口)调用tty_port_register_device(),注册到 TTY 子系统。 - 设备节点创建:最终,
/dev/ttyUSB0就出现了。它的主设备号是 USB 串口驱动专用的(通常是 188),次设备号从 0 开始。
避坑指南:我曾经遇到过一个问题,插上 USB 转串口后,/dev/ttyUSB0 没有出现。用 dmesg 一看,提示 "failed to set control IN endpoint" 之类的错误。这是因为某些廉价的 USB 转串口芯片,在硬件层面就有缺陷,或者 USB 线质量太差导致枚举失败。遇到这种情况,先换个 USB 口或者换根线试试,别一上来就怀疑驱动。
3.4 一张图总结:从硬件到 /dev 节点
为了让你更清晰地理解整个流程,我画了一张完整的流程图。
你看,无论是原生串口还是 USB 转串口,最终都会殊途同归,通过 TTY 核心在 /dev 下生成节点。理解了这套机制,以后遇到设备节点不出现的问题,你就知道该从哪个环节入手排查了:是硬件没识别?驱动没 probe?还是注册 TTY 失败了?
调试技巧:当设备节点不出现时,我习惯先用 ls /dev/ttyS* /dev/ttyUSB* 确认节点是否存在。如果不存在,立刻用 dmesg | grep -i tty 或 dmesg | grep -i usb 查看内核日志。这能帮你快速定位问题是在硬件枚举阶段,还是在驱动加载阶段。