3. Linux 串口驱动框架:从 TTY 到设备节点

串口调试,可以说是嵌入式开发里最基础也最常用的手段。但很多人只会用,一旦设备节点不出来,或者数据收发异常,就抓瞎了。这一章,我带你把 Linux 串口驱动的骨架摸清楚。搞懂了 TTY 子系统、uart_port 这些核心结构,以后遇到串口问题,你心里就有底了。

3.1 TTY 子系统:串口背后的“大管家”

你想想看,为什么我们在 Linux 下操作串口,感觉就像在操作一个终端?这背后就是 TTY 子系统的功劳。TTY 是 Teletype 的缩写,历史很久远了,但它的设计思想一直延续到今天。

TTY 子系统本质上是一个分层架构,它把硬件细节和用户空间的操作隔离开来。我画了一张图,你可以直观地看到数据是怎么从应用程序流到串口硬件的。

TTY 子系统分层架构 用户空间 (User Space) 应用程序 (如 minicom, putty, 自定义程序) 内核空间 (Kernel Space) - TTY 核心 线路规程 (Line Discipline) / N_TTY 内核空间 (Kernel Space) - UART 驱动层 uart_port / uart_ops 实现 硬件层 (Hardware) - 串口控制器

从这张图你能看到,数据从用户空间的应用程序,经过 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) 注册的。流程大致如下:

  1. 设备树匹配:内核启动时,解析设备树,找到 compatible 属性匹配的 UART 节点。
  2. 驱动 probe:匹配成功后,调用驱动的 probe 函数。在这个函数里,驱动会分配并初始化一个 uart_port 结构体。
  3. 注册到 TTY 核心:调用 uart_register_driver() 和 uart_add_one_port() 将端口注册到 TTY 子系统。
  4. 设备节点创建:TTY 核心会根据注册信息,在 /dev 目录下创建设备节点。设备号的主设备号是 TTY 驱动分配的,次设备号从 0 开始递增。这就是 /dev/ttyS0、/dev/ttyS1 的由来。

3.3.2 USB 转串口:ttyUSB* 的生成

USB 转串口芯片(比如 CH340、CP2102、FT232)的生成机制稍有不同。它涉及 USB 驱动和 TTY 驱动的配合。

  1. USB 识别:当你插入 USB 设备,USB 核心会识别到它,并根据 VID/PID 找到对应的 USB 驱动。
  2. USB 驱动 probe:USB 驱动的 probe 函数被调用。这个函数会创建一个 struct usb_serial 结构体,并调用 usb_serial_probe()。
  3. TTY 端口注册:在 usb_serial_probe() 内部,会为每个端口(一个 USB 转串口通常只有一个端口)调用 tty_port_register_device(),注册到 TTY 子系统。
  4. 设备节点创建:最终,/dev/ttyUSB0 就出现了。它的主设备号是 USB 串口驱动专用的(通常是 188),次设备号从 0 开始。

避坑指南:我曾经遇到过一个问题,插上 USB 转串口后,/dev/ttyUSB0 没有出现。用 dmesg 一看,提示 "failed to set control IN endpoint" 之类的错误。这是因为某些廉价的 USB 转串口芯片,在硬件层面就有缺陷,或者 USB 线质量太差导致枚举失败。遇到这种情况,先换个 USB 口或者换根线试试,别一上来就怀疑驱动。

3.4 一张图总结:从硬件到 /dev 节点

为了让你更清晰地理解整个流程,我画了一张完整的流程图。

设备节点生成流程 UART 硬件 设备树匹配 / USB 核心识别 驱动 probe 函数 uart_add_one_port() / tty_port_register_device() /dev/ttyS0 或 /dev/ttyUSB0 说明:原生串口走平台驱动路径,USB 转串口走 USB 驱动路径,但最终都汇聚到 TTY 核心创建设备节点。 设备节点的主次设备号由 TTY 驱动和端口号共同决定。

你看,无论是原生串口还是 USB 转串口,最终都会殊途同归,通过 TTY 核心在 /dev 下生成节点。理解了这套机制,以后遇到设备节点不出现的问题,你就知道该从哪个环节入手排查了:是硬件没识别?驱动没 probe?还是注册 TTY 失败了?

调试技巧:当设备节点不出现时,我习惯先用 ls /dev/ttyS* /dev/ttyUSB* 确认节点是否存在。如果不存在,立刻用 dmesg | grep -i tty 或 dmesg | grep -i usb 查看内核日志。这能帮你快速定位问题是在硬件枚举阶段,还是在驱动加载阶段。