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,它们都遵循同样的模式:
- 填充
uart_ops结构体,实现tx_empty、start_tx、stop_tx、startup、shutdown等函数。 - 实现中断处理函数,在
startup中注册中断。 - 调用
uart_register_driver()注册驱动。 - 在设备树匹配成功后,调用
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 驱动的完整分层结构。你可以看到数据从应用层到硬件寄存器的完整路径:
从这张图可以看得很清楚:设备树配置影响的是驱动层和硬件层之间的资源映射。而性能调优,往往发生在串口核心层和驱动层——比如调整 FIFO 阈值、优化中断处理、配置硬件流控。
总结一下
Linux 的 UART 驱动框架,本质上是一个分层解耦的设计。tty 子系统提供统一接口,串口核心层封装通用逻辑,驱动层处理硬件差异,设备树描述硬件资源。每一层各司其职,但也意味着调试时需要逐层排查。
我个人建议,遇到 UART 问题先确认设备树配置是否正确,再看驱动 probe 是否成功,最后才去查中断和数据收发。这个顺序能帮你省下大量时间。