2. Linux 串口驱动框架:TTY 子系统、uart_ops 与设备注册

好,我们正式开始啃 Linux 串口驱动这块硬骨头。很多刚接触嵌入式的朋友,一看到 tty、uart_port、uart_ops 这些结构体就头大。别急,我带你一层层剥开它。

说白了,Linux 串口驱动就是一套“用户空间 -> TTY 核心 -> 串口核心 -> 具体硬件”的层层封装。你写应用层代码时调用的 open、read、write,最终会通过这套框架,落到你写的底层硬件操作函数上。

2.1 TTY 子系统:串口驱动的“交通枢纽”

TTY 子系统是整个串口通信的调度中心。它负责把用户空间的请求,分发给对应的底层驱动。我刚开始做驱动时,总以为直接操作寄存器就完事了。后来才发现,不搞懂 TTY 层,你连数据流怎么走的都理不清。

它的核心架构可以概括为三层:

层次 角色 关键文件/结构体
TTY 核心 对上提供统一文件操作接口,对下调度线路规程 tty_driver, tty_operations
线路规程 (Line Discipline) 数据加工厂,负责协议封装、流量控制、回显等 n_tty (默认), tty_ldisc_ops
串口核心 抽象 UART 硬件共性,管理端口、时钟、DMA uart_driver, uart_port, uart_ops

你想想看,用户发一个字节,流程是这样的:

  1. 用户调用 write(fd, buf, 1)。
  2. 进入 TTY 核心的 tty_write。
  3. 线路规程 n_tty_write 处理特殊字符、缓冲区。
  4. 最终调用串口核心的 uart_write,触发你的硬件发送函数。

嗯,这里要注意:线路规程是容易被忽略的一环。我曾在项目里遇到一个诡异问题——发送特定数据时总是丢字节。查了两天,最后发现是线路规程的缓冲区溢出导致的。从那以后,我每次调试串口都会先检查 n_tty 的缓冲区大小。

核心要点: TTY 子系统不是直接操作硬件的。它通过线路规程做了一层“数据加工”。你写驱动时,大部分时间是在跟串口核心层打交道。

2.2 uart_ops 结构体:你的硬件操作“函数表”

这是你写驱动时最常接触的结构体。它定义了一组回调函数,串口核心在需要操作硬件时,就会调用你实现的这些函数。

我个人习惯把 uart_ops 看作“硬件抽象层接口”。你只要填好这张表,核心层就能自动管理你的串口了。

struct uart_ops {
    unsigned int    (*tx_empty)(struct uart_port *port);
    void            (*set_mctrl)(struct uart_port *port, unsigned int mctrl);
    unsigned int    (*get_mctrl)(struct uart_port *port);
    void            (*stop_tx)(struct uart_port *port);
    void            (*start_tx)(struct uart_port *port);
    void            (*stop_rx)(struct uart_port *port);
    void            (*enable_ms)(struct uart_port *port);
    void            (*break_ctl)(struct uart_port *port, int ctl);
    int             (*startup)(struct uart_port *port);
    void            (*shutdown)(struct uart_port *port);
    void            (*flush_buffer)(struct uart_port *port);
    int             (*ioctl)(struct uart_port *port, unsigned int cmd, unsigned long arg);
    void            (*throttle)(struct uart_port *port);
    void            (*unthrottle)(struct uart_port *port);
    int             (*request_port)(struct uart_port *port);
    void            (*release_port)(struct uart_port *port);
    int             (*config_port)(struct uart_port *port, int type);
    int             (*verify_port)(struct uart_port *port, struct serial_struct *ser);
    void            (*pm)(struct uart_port *port, unsigned int state, unsigned int oldstate);
};

这里我挑几个关键函数说说:

  • startup / shutdown:串口打开/关闭时调用。我在 startup 里通常会做三件事:使能 UART 时钟、申请中断、初始化 DMA 描述符。千万别忘了检查时钟是否已经使能,否则中断来了也白搭。
  • start_tx:核心层要你开始发送数据。你在这里要做的就是把 uart_port 的 circ_buf 里的数据搬进硬件 FIFO 或 DMA 缓冲区。我曾经犯过一个错——在 start_tx 里直接死循环发送,结果把整个系统卡死了。记住,发送要基于中断或 DMA 完成回调来驱动,不能阻塞。
  • tx_empty:查询发送 FIFO 是否为空。这个函数在关闭串口时会被调用,用来确保数据都发完了再关。我建议你在这里读取硬件状态寄存器,而不是依赖软件计数。
  • set_mctrl / get_mctrl:控制 RTS/CTS 等硬件流控引脚。如果你的板子用了流控,这两个函数必须实现。我见过有人偷懒不实现,结果高速通信时数据错乱。

我的经验: 刚开始写 uart_ops 时,别急着把所有函数都填满。先实现 startup、shutdown、start_tx、tx_empty 这四个,让串口能收发数据。其他函数可以先用空实现或返回 0。等基本功能跑通了,再逐步完善。

2.3 串口设备注册流程:从模块加载到设备就绪

这部分是实操的关键。你写好的驱动,怎么让内核知道并管理起来?流程大致分三步:

  1. 分配并初始化 uart_driver:这是你的串口驱动“身份证”。
  2. 注册 uart_driver:告诉内核“我来了”。
  3. 添加 uart_port:告诉内核“我的硬件在这里”。

我们来看一个典型的注册代码片段:

static struct uart_driver my_uart_drv = {
    .owner          = THIS_MODULE,
    .driver_name    = "my_uart",
    .dev_name       = "ttyMYU",
    .major          = 0,        // 动态分配主设备号
    .minor          = 0,        // 起始次设备号
    .nr             = 4,        // 支持4个串口
};

static int __init my_uart_init(void)
{
    int ret;

    // 1. 注册 uart_driver
    ret = uart_register_driver(&my_uart_drv);
    if (ret) {
        pr_err("Failed to register uart driver\n");
        return ret;
    }

    // 2. 为每个端口添加 uart_port
    for (int i = 0; i < 4; i++) {
        my_uart_ports[i].line = i;
        ret = uart_add_one_port(&my_uart_drv, &my_uart_ports[i]);
        if (ret) {
            pr_err("Failed to add port %d\n", i);
            uart_unregister_driver(&my_uart_drv);
            return ret;
        }
    }

    return 0;
}

module_init(my_uart_init);

这里有几个坑,我帮你指出来:

  • dev_name 的命名:它会决定设备节点名。比如 dev_name = "ttyMYU",那么第一个端口就是 /dev/ttyMYU0。我建议你遵循 tty + 厂商缩写 + 序号 的命名方式,方便用户识别。
  • nr 字段:它告诉内核你最多支持几个串口。这个值不能随便填,要跟你实际硬件端口数一致。我曾经填了个 8,但板子上只有 2 个串口,结果内核为 8 个端口都分配了资源,浪费内存不说,还导致其他驱动申请资源失败。
  • uart_add_one_port 的调用时机:必须在 uart_register_driver 之后调用。而且,如果你的硬件支持热插拔(比如 USB 转串口),这个函数应该在设备插入时调用,移除时调用 uart_remove_one_port。

注意: 在 uart_add_one_port 内部,核心层会调用你的 uart_ops->config_port 和 uart_ops->request_port。所以,在调用这个函数之前,你的 uart_port 结构体必须已经初始化好,特别是 ops、iotype、mapbase、membase 等字段。

2.4 知识结构总览

为了让你更直观地理解整个框架,我画了一张图。它展示了从用户空间到硬件寄存器的完整调用链。

Linux 串口驱动框架调用链 用户空间 (open/read/write) TTY 核心 (tty_driver/tty_operations) 线路规程 (n_tty / tty_ldisc_ops) 串口核心 (uart_driver/uart_port) uart_ops (你的硬件操作函数) 硬件寄存器 (UART FIFO / DMA) 说明:箭头方向表示数据流或控制流。你写的驱动主要工作在 uart_ops 层。

从这张图你可以看到,你的驱动代码(uart_ops)处于整个调用链的底层。上层所有的请求,最终都会通过串口核心,调用到你实现的这些函数。所以,你写驱动时,重点就是填好 uart_ops 这张表。

总结一下:

  • TTY 子系统是串口驱动的骨架,负责分层调度。
  • uart_ops 是你与硬件交互的接口,必须实现核心函数。
  • 设备注册流程是 uart_register_driver -> uart_add_one_port,顺序不能乱。

下一节,我们会深入 uart_port 结构体,看看怎么配置硬件参数、管理 DMA 缓冲区。到时候我会拿一个实际项目中的例子来拆解。