3. Linux 串口驱动框架:TTY子系统、串口核心层、uart_ops结构体

说实话,很多刚接触Linux串口驱动的朋友,一上来就被TTY、线路规程、串口核心层这些概念搞晕了。我当年也一样,对着内核代码看了三天,愣是没理清数据到底是怎么从用户态跑到串口硬件上的。

这一章,我们就来拆解这个框架。你把它想成一个三层快递系统:TTY子系统是前台客服,串口核心层是分拣中心,uart_ops就是最后一公里的快递员。搞清楚这三层,串口驱动对你来说就没秘密了。

3.1 TTY子系统:用户态与内核态的桥梁

TTY,全称Teletypewriter,这名字听着古老,但它的设计思想一直沿用至今。说白了,它就是一个字符设备的抽象层。

用户程序通过标准的文件操作——open、read、write——来跟串口打交道。你想想看,如果每个串口芯片都暴露不同的接口给用户,那应用层工程师得疯掉。TTY子系统就是来解决这个问题的,它提供了一套统一的接口。

核心要点: TTY子系统负责将用户空间的字节流,转换成内核空间可以处理的数据包。它不关心底层是什么硬件,只负责数据格式的整理和缓冲。

这里有个关键概念叫线路规程(Line Discipline)。它夹在TTY驱动和字符设备层之间,负责数据的加工。比如你按下一个回车键,线路规程可能会把它转换成换行符;或者你输入退格,它负责处理回显。默认的线路规程是 N_TTY,处理标准的终端协议。

我在项目中遇到过一个问题:用串口接一个GPS模块,数据总是丢包。查了半天,发现是线路规程默认开启了回显和行编辑功能,把GPS的二进制数据流给污染了。解决办法就是切换到 N_RAW 线路规程,让数据原样通过。

避坑指南: 如果你的设备传输的是二进制数据(比如固件升级、传感器数据流),记得在应用层用 tcsetattr() 关闭所有行处理选项,或者直接设置线路规程为原始模式。我曾经因为没关回显,导致升级包校验一直失败,折腾了两天。

3.2 串口核心层(Serial Core):承上启下的调度中心

串口核心层,在内核里对应 drivers/tty/serial/serial_core.c。它不直接操作硬件,而是提供了一套标准的注册、注销、中断处理机制。

它的主要工作有三件:

  • 管理UART端口:维护一个全局的端口列表,每个端口对应一个串口设备。
  • 处理中断:接收硬件中断,判断是接收、发送还是错误事件,然后调用对应的回调函数。
  • 提供操作接口:定义了一套标准的操作函数集,也就是我们马上要讲的 uart_ops。

你可以把串口核心层想象成一个项目经理。它不亲自写代码,但它知道什么时候该叫谁干活。它负责把TTY子系统传来的请求,翻译成具体的硬件操作指令。

下面这张图,能帮你理清这三层的关系:

Linux串口驱动三层架构图 Linux 串口驱动三层架构 用户空间 (User Space) open / read / write / ioctl TTY 子系统 (TTY Core + Line Discipline) 数据缓冲 / 行编辑 / 回显控制 / 信号处理 串口核心层 (Serial Core) 端口管理 / 中断分发 / 调用 uart_ops 硬件驱动层 (uart_ops 实现) 操作具体寄存器 / 控制FIFO / 管理DMA 数据流向

3.3 uart_ops 结构体:驱动工程师的战场

好了,终于到了我们真正要写代码的地方。uart_ops 是串口核心层定义的一个结构体,里面全是函数指针。你的硬件驱动,就是要把这些函数指针实现出来。

这个结构体定义在 include/linux/serial_core.h 里。我挑几个最重要的成员给你看:

函数指针 作用 我的经验
startup 启动串口端口,申请中断、初始化硬件 记得在这里检查中断号是否有效,我吃过一次共享中断没注册好的亏
shutdown 关闭串口端口,释放中断、关闭硬件 一定要确保所有DMA传输已经停止,否则会panic
set_termios 设置串口参数:波特率、数据位、停止位、校验位 这是最容易被调用的函数,每次用户用 tcsetattr 都会进来
start_tx 启动发送传输 通常在这里把数据写入发送FIFO或启动DMA
stop_tx 停止发送传输 流控时会被调用,别傻傻地把FIFO清空了
tx_empty 检查发送FIFO是否为空 关闭端口前必须调用,确保数据都送出去了
get_mctrl 获取Modem控制信号状态(CTS/DSR等) 硬件流控全靠它,读寄存器时要加锁
set_mctrl 设置Modem控制信号(RTS/DTR等) 同样要注意并发访问

来看一个简化的 start_tx 实现示例,感受一下:

static void my_uart_start_tx(struct uart_port *port)
{
    struct my_uart_data *priv = port->private_data;
    unsigned long flags;

    spin_lock_irqsave(&port->lock, flags);

    // 检查发送FIFO是否已满
    if (readl(priv->membase + UART_FSR) & FSR_TX_FULL) {
        // FIFO满了,使能发送中断,等硬件通知我们
        writel(readl(priv->membase + UART_IMR) | IMR_TX_EMPTY,
               priv->membase + UART_IMR);
    } else {
        // FIFO有空位,直接写入
        uart_xmit_advance(port, 1);
        writel(port->x_char ? port->x_char : 
               port->state->xmit.buf[port->state->xmit.tail],
               priv->membase + UART_TX);
        port->icount.tx++;
    }

    spin_unlock_irqrestore(&port->lock, flags);
}
重要提醒: 所有 uart_ops 中的函数,除了 startup 和 shutdown,都是在持有 port->lock 自旋锁的情况下被调用的。所以你在这些函数里绝对不能睡眠,也不能调用 kmalloc 等可能引起阻塞的函数。我见过有人在这里调了 msleep,结果整个系统都卡死了。

3.4 中断处理:数据流动的引擎

串口驱动里,中断处理是性能的关键。串口核心层提供了一个标准的处理函数 uart_handle_irq,但大多数时候,我们还是要自己写中断处理函数。

一个典型的中断处理流程是这样的:

  1. 硬件触发中断,CPU进入中断服务程序(ISR)。
  2. ISR读取中断状态寄存器,判断中断类型。
  3. 如果是接收中断:从FIFO读取数据,调用 uart_insert_char 放入TTY缓冲区。
  4. 如果是发送中断:从发送缓冲区取数据,写入FIFO。
  5. 如果是错误中断:记录错误统计,可能需要复位硬件。
  6. 调用 tty_flip_buffer_push 通知TTY层有数据可读。

这里有个细节:uart_insert_char 和 tty_flip_buffer_push 的配合。前者把数据放进一个叫 flip buffer 的临时缓冲区,后者把这个缓冲区的内容提交给线路规程。为什么要分两步?因为中断上下文不能直接操作TTY缓冲区,需要推迟到软中断或工作队列中处理。

性能优化建议: 如果你的硬件支持FIFO,尽量把FIFO触发阈值设高一点。比如16字节的FIFO,设成14字节触发中断,而不是1字节。这样可以减少中断频率,降低CPU负载。我在一个115200bps的项目中,把触发阈值从1改成8,CPU占用率直接从15%降到了2%。

3.5 注册与注销:让内核认识你的驱动

驱动写好了,怎么让内核用起来?通过 uart_register_driver 和 uart_add_one_port 这两个函数。

流程很简单:

  • 先定义一个 uart_driver 结构体,填上驱动名称、设备号、端口数量等信息。
  • 调用 uart_register_driver 注册到串口核心层。
  • 对于每个物理串口,分配一个 uart_port 结构体,设置好 uart_ops、iotype、mapbase 等字段。
  • 调用 uart_add_one_port 添加端口。

注销时反过来:先 uart_remove_one_port,再 uart_unregister_driver。

嗯,这里要注意:uart_port 结构体里的 private_data 指针是你的私有数据,用来存放硬件相关的上下文。别把它浪费了,我一般用它指向一个包含锁、内存基地址、DMA描述符的结构体。