4. Linux下UART驱动框架:tty子系统架构、uart_driver注册流程、串口设备节点创建

好,我们进入第四章。这一章我打算把Linux下UART驱动的骨架给你拆开看看。很多初学者一上来就翻内核源码,结果被tty层、线路规程、uart_ops这些概念绕晕。其实没那么复杂——你只要抓住一条主线:应用层读写串口,最终是怎么落到硬件寄存器上的。

我个人习惯把UART驱动分成三层来看:tty核心层、线路规程层、uart驱动层。这三层各司其职,咱们一个一个说。

4.1 tty子系统架构:三层模型

先看一张整体架构图,我画了张SVG帮你理清脉络。

Linux UART tty子系统三层架构 用户空间应用 (open/read/write) tty核心层 (tty_io.c) tty_struct / tty_driver / tty_port 线路规程层 (n_tty.c) 数据缓冲 / 行编辑 / 信号处理 uart驱动层 (serial_core.c + 具体芯片驱动) uart_driver / uart_port / uart_ops UART硬件 (寄存器/中断控制器) 数据流向 应用 → tty核心 → 线路规程 → uart → 硬件 中断上行 硬件中断 → uart → 线路规程 → tty → 应用read返回

这张图我建议你多看两眼。数据从应用层往下走,经过tty核心层、线路规程层,最后到uart驱动层操作硬件。反过来,硬件收到数据后,中断一路往上送,最终让应用层的read()返回。

4.2 各层职责速览

层级 核心文件/结构 主要职责
tty核心层 drivers/tty/tty_io.c
struct tty_driver
设备节点管理、open/close、read/write系统调用分发
线路规程层 drivers/tty/n_tty.c
struct tty_ldisc_ops
数据缓冲、行规则、信号处理(如Ctrl+C)
uart驱动层 drivers/tty/serial/serial_core.c
struct uart_driver
波特率配置、中断处理、寄存器读写、DMA管理

说白了,tty核心层管的是“文件操作”,线路规程层管的是“数据怎么加工”,uart驱动层管的是“怎么跟硬件对话”。

我的经验:调试串口驱动时,80%的问题出在uart驱动层——波特率算错了、中断没清、FIFO阈值设得不对。线路规程层基本不用动,tty核心层更是稳定得像石头。所以你的精力应该放在uart_driver和uart_ops的实现上。

4.3 uart_driver注册流程:从模块加载到设备就绪

接下来我们看看一个UART驱动是怎么注册进内核的。我以最常用的8250驱动为例,把关键步骤拆开讲。

4.3.1 第一步:定义并注册uart_driver

每个UART驱动首先要定义一个struct uart_driver,然后调用uart_register_driver()把它注册到tty核心层。这个结构体里最重要的几个字段:

static struct uart_driver serial8250_reg = {
    .owner          = THIS_MODULE,
    .driver_name    = "serial",
    .dev_name       = "ttyS",
    .major          = TTY_MAJOR,
    .minor          = 64,
    .nr             = UART_NR,      // 支持的最大端口数
    .cons           = SERIAL8250_CONSOLE,
};

这里有个细节:dev_name是"ttyS",所以设备节点就是/dev/ttyS0、/dev/ttyS1这样。如果你写的是USB转串口驱动,dev_name可能是"ttyUSB",节点就是/dev/ttyUSB0。

4.3.2 第二步:为每个端口分配并初始化uart_port

注册完uart_driver之后,每个物理串口对应一个struct uart_port。这个结构体里存放了端口号、I/O基地址、中断号、波特率、FIFO大小等信息。

struct uart_port port;
port.line    = 0;               // 对应ttyS0
port.iotype  = UPIO_MEM;       // 内存映射I/O
port.mapbase = 0x3F8;          // 物理基地址(经典COM1)
port.irq     = 4;              // 中断号
port.ops     = &serial8250_pops; // 操作函数集
port.fifosize = 16;            // 16550A FIFO深度

嗯,这里要注意:uart_port的初始化必须在探测函数(probe)里完成,而且每个端口都要单独做。我见过有人图省事,多个端口共用一个uart_port结构体,结果中断上下文里数据全乱套了。

4.3.3 第三步:添加端口到驱动

初始化好uart_port后,调用uart_add_one_port()把它挂到之前注册的uart_driver上。这一步会触发tty核心层创建设备节点。

ret = uart_add_one_port(&serial8250_reg, &port);
if (ret)
    dev_err(dev, "failed to add port\n");
关键流程总结:
  1. module_init → uart_register_driver() → 注册到tty核心
  2. probe → uart_port初始化 → uart_add_one_port() → 创建设备节点
  3. 设备节点出现后,应用层就可以open/read/write了

4.4 串口设备节点创建:udev与devtmpfs的配合

设备节点是怎么出现在/dev/ttyS0的?这背后是内核的设备模型在起作用。

当你调用uart_add_one_port()时,内核内部会做两件事:

  • 在sysfs中创建对应的设备目录,比如/sys/devices/platform/serial8250/tty/ttyS0/
  • 通过devtmpfs自动在/dev/下创建设备节点

如果你用的是udev(现在大部分发行版都用systemd-udevd),它还会根据规则设置设备节点的权限、属组、符号链接。比如:

# udev规则示例:/etc/udev/rules.d/99-serial.rules
KERNEL=="ttyS0", GROUP="dialout", MODE="0660"
KERNEL=="ttyUSB*", GROUP="dialout", MODE="0666"

我曾经被这个问题坑过:板子启动后/dev/ttyS0确实存在,但普通用户打不开,报"Permission denied"。查了半天,就是udev规则没配,默认权限是600,只有root能用。加上MODE="0666"就好了。

避坑指南:如果你在嵌入式系统里用busybox,没有udev,那就得手动mknod创建设备节点。主设备号是4(TTY_MAJOR),次设备号从64开始(ttyS0对应64,ttyS1对应65)。命令如下:
mknod /dev/ttyS0 c 4 64

4.5 uart_ops:驱动与硬件的接口契约

最后,我们看看uart驱动层最核心的东西——struct uart_ops。这个结构体定义了驱动必须实现的回调函数,tty核心层通过它来操作硬件。

回调函数 调用时机 典型实现
startup open()时调用 申请中断、复位FIFO、使能收发
shutdown close()时调用 释放中断、关闭收发、清FIFO
set_termios tcsetattr()时调用 配置波特率、数据位、校验位、停止位
start_tx 有数据要发送时调用 使能发送中断、写入发送FIFO
stop_tx 发送缓冲区空时调用 关闭发送中断
tx_empty 查询发送是否完成 读取LSR寄存器,检查THR/TEMT位
get_mctrl 查询modem信号状态 读取MSR寄存器,返回CTS/DSR等状态
set_mctrl 设置modem信号 写MCR寄存器,控制RTS/DTR

这里面set_termios是最容易出错的。波特率计算、分频系数设置、数据位/校验位/停止位的寄存器配置,全在这里。我建议你实现这个函数时,先把芯片手册的寄存器定义打印出来,对着写,别凭记忆。

static void serial8250_set_termios(struct uart_port *port,
                                   struct ktermios *termios,
                                   struct ktermios *old)
{
    unsigned int baud, quot;
    // 1. 从termios中提取波特率
    baud = uart_get_baud_rate(port, termios, old, 0, 115200);
    // 2. 计算分频系数
    quot = uart_get_divisor(port, baud);
    // 3. 写寄存器:设置波特率、数据格式
    serial_out(port, UART_LCR, UART_LCR_DLAB);  // 使能分频锁存
    serial_out(port, UART_DLL, quot & 0xff);
    serial_out(port, UART_DLM, (quot >> 8) & 0xff);
    serial_out(port, UART_LCR, lcr_value);       // 设置数据格式
    // 4. 更新硬件FIFO触发阈值
    serial8250_update_fifos(port);
}
我的调试习惯:每次写完set_termios,我都会在函数末尾加一段printk,把计算出的波特率和实际写入的分频系数打印出来。然后拿示波器量TX引脚的波形,对比实际波特率是否匹配。别问我为什么——有一次我分频系数算错了,115200变成了57600,排查了整整一个下午。

4.6 本章小结

这一章我们把Linux UART驱动的骨架过了一遍。记住三个核心点:

  • tty子系统三层架构:tty核心层管文件操作,线路规程层管数据加工,uart驱动层管硬件交互
  • uart_driver注册流程:先注册驱动,再初始化端口,最后添加端口触发设备节点创建
  • uart_ops回调函数:startup/shutdown/set_termios/start_tx是必须实现的四个核心函数