Linux 下 UART 驱动框架:tty 子系统、串口核心层与设备树

做嵌入式 Linux 开发,绕不开 UART。但很多人一上来就对着驱动代码发懵——结构太复杂了。我刚开始接触时也这样,感觉像在迷宫里转。后来摸清了 tty 子系统的脉络,才明白 Linux 为什么要这么设计。

说白了,Linux 把所有的串口、虚拟终端、伪终端都抽象成了 tty 设备。你写应用层代码时,打开 /dev/ttyS0 或者 /dev/ttyAMA0,背后是一整套分层架构在支撑。今天我们就把它拆开来看。

tty 子系统:一切从 tty 开始

tty 是 Teletype 的缩写,历史可以追溯到电传打字机时代。Linux 保留了这套抽象,把字符设备分成了三层:

  • tty 核心层:负责设备号管理、线路规程(line discipline)、tty 文件操作接口。
  • 线路规程:处理数据缓冲、流量控制、特殊字符转换(比如 Ctrl+C 发送 SIGINT)。
  • tty 驱动层:直接和硬件打交道,收发数据、控制 modem 信号。

你想想看,应用层调用 write() 写数据,数据先经过线路规程处理,再交给驱动层发送。反过来,硬件收到数据,驱动层上报,线路规程解析,最后应用层 read() 拿到。这个流程很清晰,但调试时容易忽略中间层。

关键点:tty 核心层和线路规程是通用的,驱动开发者只需要实现 tty 驱动层的回调函数。这就是 Linux 的设计哲学——复用共性,暴露差异。

串口核心层:serial core 的职责

在 tty 驱动层下面,Linux 又抽象了一层——串口核心层(serial core)。它位于 drivers/tty/serial/ 目录下,核心文件是 serial_core.c。

串口核心层做了几件重要的事:

  • 管理 uart_port 结构体,每个串口对应一个实例。
  • 提供 uart_ops 回调接口,驱动开发者只需实现这些函数。
  • 处理中断、DMA、硬件流控等通用逻辑。
  • 向上对接 tty 核心层,向下屏蔽硬件差异。

我记得有一次调试一个外接的 USB 转串口模块,数据总是丢字节。查了半天,发现是串口核心层的 FIFO 阈值配置不对。默认的触发深度是 1 字节,中断太频繁,CPU 来不及响应。后来改成 8 字节触发,问题就解决了。

结构体/接口 作用 我踩过的坑
uart_port 描述串口硬件资源(I/O 地址、中断号、时钟等) 忘记初始化 iotype 字段,导致 probe 失败
uart_ops 驱动回调函数集(startup、shutdown、set_termios 等) set_termios 里没更新 baud 参数,波特率不对
uart_driver 注册到系统的串口驱动实例 nr 字段设小了,多路串口注册不全

驱动层:你的代码在这里

具体到某个 SoC 的 UART 驱动,比如 i.MX 的 imx.c 或 STM32MP1 的 stm32-usart.c,它们都遵循同样的模式:

  1. 填充 uart_ops 结构体,实现 tx_empty、start_tx、stop_tx、startup、shutdown 等函数。
  2. 实现中断处理函数,在 startup 中注册中断。
  3. 调用 uart_register_driver() 注册驱动。
  4. 在设备树匹配成功后,调用 uart_add_one_port() 添加端口。

这里有个容易忽略的地方——start_tx 和 stop_tx 必须成对出现。我曾经在某个项目中,start_tx 里使能了发送中断,但 stop_tx 里忘了关,结果发送完成后中断一直触发,CPU 空转。嗯,这种问题很难查,因为系统看起来还能跑,就是功耗高得离谱。

注意:中断处理函数里不要做耗时操作。数据拷贝、DMA 提交这些事,交给 tasklet 或 workqueue 去做。否则你的系统会变得非常脆弱,尤其是在高波特率下。

设备树配置解析

设备树是 Linux 驱动和硬件信息之间的桥梁。对于 UART 来说,设备树节点通常长这样:

uart1: serial@40001000 {
    compatible = "vendor,uart";
    reg = <0x40001000 0x1000>;
    interrupts = <0 33 4>;
    clocks = <&clk UART1_CLK>;
    pinctrl-0 = <&uart1_pins>;
    pinctrl-names = "default";
    status = "disabled";
};

我来解释一下每个字段的含义:

  • compatible:驱动匹配的关键字,必须和驱动中 of_match_table 一致。
  • reg:寄存器基地址和范围。我见过有人把地址写错,结果驱动 probe 时 ioremap 返回 NULL。
  • interrupts:中断号、触发类型。注意不同 SoC 的中断控制器格式可能不同。
  • clocks:串口模块的时钟源。波特率计算依赖这个时钟频率。
  • pinctrl:引脚复用配置。这个最容易出问题——引脚没配成 UART 功能,数据发不出去。

设备树解析是在驱动 probe 函数里完成的,通过 of_property_read_u32()、of_iomap() 等 API 读取。我个人习惯在 probe 函数开头加一段调试打印,把解析到的资源信息打出来,方便确认设备树配置是否正确。

小技巧:在 arch/arm/boot/dts/ 或 arch/arm64/boot/dts/ 目录下,找到你板子的 dts 文件,用 dtc -I dtb -O dts 反编译 dtb,可以验证实际加载的设备树内容。我曾经靠这个发现了一个拼写错误——compatible 写成了 compatiable,驱动死活匹配不上。

整体架构图

下面这张图展示了 UART 驱动的完整分层结构。你可以看到数据从应用层到硬件寄存器的完整路径:

Linux UART 驱动分层架构 应用层 (open/read/write/ioctl) tty 核心层 (tty_io.c, tty_ldisc.c) 线路规程 (n_tty, raw 模式等) 串口核心层 (serial_core.c, uart_port, uart_ops) UART 驱动层 (imx.c, stm32-usart.c 等) 硬件寄存器 / FIFO / DMA 设备树提供资源信息

从这张图可以看得很清楚:设备树配置影响的是驱动层和硬件层之间的资源映射。而性能调优,往往发生在串口核心层和驱动层——比如调整 FIFO 阈值、优化中断处理、配置硬件流控。

总结一下

Linux 的 UART 驱动框架,本质上是一个分层解耦的设计。tty 子系统提供统一接口,串口核心层封装通用逻辑,驱动层处理硬件差异,设备树描述硬件资源。每一层各司其职,但也意味着调试时需要逐层排查。

我个人建议,遇到 UART 问题先确认设备树配置是否正确,再看驱动 probe 是否成功,最后才去查中断和数据收发。这个顺序能帮你省下大量时间。