3. Linux内核UART驱动框架:tty子系统架构、串口核心层与底层驱动回调

好,咱们进入第三章。这一章我打算把Linux内核里UART驱动的整体框架给你拆开揉碎了讲清楚。很多工程师调了几年串口,遇到问题还是只会printk,说白了就是没搞懂这三层架构到底是怎么协作的。

我个人习惯把UART驱动分成三个层次来看:tty子系统、串口核心层(serial_core)、底层驱动(uart_ops)。这三层各司其职,又紧密耦合。你想想看,一个数据从用户态write()到串口TX引脚上,中间要经过多少层?不理解这个路径,性能调优就是瞎蒙。

核心观点:UART驱动调试的本质,就是搞清楚数据在哪一层丢了、在哪一层慢了、在哪一层被阻塞了。

3.1 tty子系统架构——用户态与内核态的桥梁

tty子系统,说白了就是Linux对终端设备的抽象。它向上给用户提供标准的文件操作接口(open/read/write/ioctl),向下对接各种串口设备。我刚开始接触这部分时,总觉得tty层是个多余的东西,直到有一次需要同时管理8路串口数据流,才发现没有tty层的缓冲和流控机制,底层驱动根本扛不住。

tty子系统的核心组件包括:

  • tty_core:负责设备注册、线路规程管理、文件操作接口
  • n_tty:默认的线路规程(line discipline),处理行编辑、回显、信号字符
  • tty_ldisc:线路规程抽象层,允许替换不同的数据处理策略
  • tty_port:端口抽象,管理打开计数、等待队列、硬件流控

这里有个容易踩的坑——线路规程。我曾经遇到一个项目,串口接收数据总是丢字节,查了两天发现是n_tty的缓冲区太小,数据量一大就溢出。后来换成raw模式,问题就解决了。

调试技巧:用stty -F /dev/ttyS0 raw可以绕过n_tty的行处理逻辑,适合调试裸数据收发场景。

3.2 串口核心层(serial_core)——驱动开发的"半成品"

serial_core是Linux内核为串口驱动提供的一个中间层。它帮你处理了大部分通用逻辑:端口管理、中断处理、DMA传输、电源管理等。你作为驱动开发者,只需要实现几个关键回调函数就行。

我经常跟团队说:serial_core就像盖房子的毛坯房,你只需要装修水电和贴瓷砖。它已经帮你做好了承重墙和楼板,你不需要从零开始。

serial_core提供的关键服务:

服务模块 功能描述 对应回调
端口管理 端口打开/关闭、引用计数、电源管理 uart_ops->startup/shutdown
中断处理 接收中断、发送中断、错误中断的统一调度 uart_ops->handle_irq
数据收发 字符发送/接收、FIFO管理、DMA传输 uart_ops->start_tx/stop_tx
参数配置 波特率、数据位、校验位、停止位设置 uart_ops->set_termios
线路控制 RTS/CTS硬件流控、DTR/DSR信号控制 uart_ops->set_mctrl/get_mctrl

嗯,这里要注意:serial_core的uart_port结构体里有个lock自旋锁,很多新手在中断上下文里调用uart_ops回调时忘了加锁,导致竞态问题。我见过最离谱的一次,是某工程师在start_tx里调用了msleep(),直接把系统锁死了。

警告:uart_ops回调函数大部分在中断上下文中执行,绝对不能调用可能导致睡眠的函数(如mutex_lock、msleep、kmalloc(GFP_KERNEL))。

3.3 底层驱动(uart_ops)回调函数解析——你真正要写代码的地方

uart_ops是serial_core定义的一组操作函数指针,你作为底层驱动开发者,需要实现这些回调。我把它称为"驱动开发的七寸",因为80%的调试问题都出在这里。

核心回调函数清单:

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 (*set_termios)(struct uart_port *port, struct ktermios *new,
                        struct ktermios *old);
    void (*pm)(struct uart_port *port, unsigned int state,
               unsigned int oldstate);
    int (*handle_irq)(struct uart_port *port);
    /* ... 还有几个不常用的 */
};

我来挑几个最关键的讲:

3.3.1 startup/shutdown——驱动的生命周期

startup在设备打开时调用,你需要在这里:申请中断、初始化FIFO、配置DMA、使能UART硬件。shutdown则相反,释放资源、关闭中断。

我曾经在startup里忘了释放之前申请的中断号,导致第二次打开设备时中断注册失败。嗯,这种低级错误,犯过一次就记住了。

3.3.2 start_tx/stop_tx——发送控制的核心

start_tx是发送引擎的启动开关。serial_core会把要发送的数据放到uart_port->state->xmit环形缓冲区里,然后调用start_tx通知你"可以开始发了"。你需要在这里把数据从环形缓冲区搬到硬件FIFO里。

性能调优的关键点:一次搬运多少字节?如果每次只搬1个字节,中断次数太多,CPU开销大。如果一次搬满FIFO,延迟又太高。我一般建议根据FIFO深度和波特率动态调整,比如115200bps、16字节FIFO,每次搬8-12个字节比较合适。

3.3.3 set_termios——参数配置的"万金油"

这个回调在用户调用tcsetattr()时触发。你需要在这里解析ktermios结构体,提取波特率、数据位、校验位、停止位,然后配置硬件寄存器。

有个坑:波特率计算。很多UART控制器的波特率发生器需要根据时钟频率和分频系数来计算。我见过有人直接用整数除法,导致波特率误差超过3%,在高波特率下(如921600bps)直接无法通信。

避坑指南:波特率误差必须控制在±2%以内。建议使用uart_get_baud_rate()和uart_update_timeout()辅助函数,它们会帮你做舍入和误差检查。

3.3.4 handle_irq——中断处理的"总调度"

这是最复杂的回调。你需要在这里判断中断类型(接收、发送、错误、线路状态),然后分别处理。我建议的写法是:

static irqreturn_t my_uart_handle_irq(struct uart_port *port)
{
    unsigned int iir = readl(port->membase + UART_IIR);
    
    if (iir & UART_IIR_RX_TIMEOUT) {
        /* 接收超时,把FIFO里的数据全部读出来 */
        my_uart_rx_chars(port);
    }
    if (iir & UART_IIR_RX_AVAIL) {
        /* 接收FIFO达到触发阈值 */
        my_uart_rx_chars(port);
    }
    if (iir & UART_IIR_TX_EMPTY) {
        /* 发送FIFO为空,继续搬运数据 */
        my_uart_tx_chars(port);
    }
    if (iir & UART_IIR_RLS) {
        /* 线路错误(溢出、帧错误、校验错误) */
        my_uart_handle_rls(port);
    }
    
    return IRQ_HANDLED;
}

这里有个性能优化点:接收中断的触发阈值。如果设置得太低(比如1字节就触发中断),高波特率下中断风暴会打爆CPU。如果设置得太高(比如FIFO满了才触发),实时性又不够。我一般建议设置为FIFO深度的一半,比如16字节FIFO就设8字节触发。

3.4 三层架构的协作流程——一张图说清楚

下面这张图展示了数据从用户态到硬件引脚的完整路径。我画这张图时特意把关键的数据结构和函数调用都标出来了,你调试时可以对照着看。

Linux UART驱动三层架构数据流图 Linux UART驱动三层架构与数据流 用户空间 应用程序:open("/dev/ttyS0") → read() / write() → ioctl() tty子系统 tty_core:设备注册、文件操作接口 n_tty线路规程:行编辑、回显、信号字符处理 tty_port:端口管理、打开计数、等待队列 串口核心层(serial_core) uart_port:端口描述符、环形缓冲区(xmit)、锁 uart_ops回调表:startup/shutdown/start_tx/stop_tx/set_termios 中断分发:接收/发送/错误/线路状态统一调度 底层驱动(uart_ops实现) 硬件寄存器操作:FIFO读写、波特率配置、中断使能 DMA传输控制、电源管理、线路信号控制 UART硬件(TX/RX引脚、FIFO、波特率发生器) 说明:数据从上往下流动(写操作),中断从下往上流动(读操作)。调试时先确认数据到了哪一层。

3.5 调试时的"三板斧"

最后,我分享三个实战中屡试不爽的调试方法:

  1. 看中断计数:cat /proc/interrupts | grep uart,如果中断数不增长,说明硬件没触发中断,问题在底层驱动或硬件连接。
  2. 看tty层统计:cat /proc/tty/driver/serial,可以看到每个串口的收发字节数、错误计数。如果收字节数为0但中断在增长,说明中断处理函数没正确读取数据。
  3. 加trace点:在uart_ops回调函数入口加trace_printk(),配合trace-cmd查看调用时序。我习惯在start_tx和handle_irq里各加一个,看调用间隔是否合理。

个人经验:遇到"能发不能收"的问题,90%是中断配置错了——要么中断号不对,要么中断触发方式(边沿/电平)不匹配。先查硬件手册,再查设备树,最后查代码。