3. 串口驱动开发(一):Linux串口驱动框架、tty子系统、设备树配置

说到串口驱动,很多刚入行的朋友第一反应就是「打开设备、配置波特率、读写数据」。嗯,从应用层看确实是这样。但如果你要自己写一个串口驱动,或者调试一个串口设备死活不工作的情况,那就得深入理解 Linux 内核里那套 tty 子系统 了。

我个人习惯把串口驱动分成三层来看:tty 核心层、线路规程层、硬件驱动层。这三层各司其职,又互相配合。今天我们先从整体框架入手,把设备树配置也一并讲清楚。

核心观点:Linux 串口驱动本质上是 tty 驱动的一种。你写的串口驱动,最终要注册到 tty 核心,让用户空间通过 /dev/ttyXXX 来访问。

3.1 Linux 串口驱动整体框架

先看一张我画的框架图,把整个脉络理清楚:

Linux 串口驱动三层框架 用户空间应用 (open/read/write/ioctl) tty 核心层 (tty_io.c / tty_ldisc.c) 设备号管理 / 打开/关闭 / 数据分发 / 线路规程接口 线路规程层 (n_tty.c / 自定义 ldisc) 数据缓冲 / 行编辑 / 信号处理 / 协议转换 硬件驱动层 (uart_ops / serial_core) startup / shutdown / set_termios / tx / rx 中断处理 UART 硬件 (寄存器 / FIFO / 中断控制器) 物理串口芯片 (16550 / 内部 UART) 数据流向:用户空间 → tty核心 → 线路规程 → 硬件驱动 → 物理UART

这张图我画了好几次才满意。你想想看,数据从用户空间的 write() 到最终从串口发出去,中间要经过三层处理。每一层都有它存在的理由。

3.2 tty 子系统核心概念

tty 是 Teletype 的缩写,历史包袱很重。但 Linux 把它抽象成了一个非常优雅的框架。我简单梳理一下关键点:

组件 职责 对应文件/结构体
tty 核心 设备号管理、打开/关闭、数据路由 drivers/tty/tty_io.c
线路规程 数据缓冲、行编辑、协议转换 drivers/tty/n_tty.c
tty 驱动 硬件操作接口(收发、配置) struct tty_driver
uart 驱动 串口专用封装,简化驱动开发 drivers/tty/serial/serial_core.c

我的经验:大多数嵌入式串口驱动都基于 serial_core 框架。你只需要实现 struct uart_ops 里的几个回调函数,剩下的 tty 注册、设备节点创建都由框架帮你搞定。

3.3 uart_ops 回调函数详解

这是驱动开发的核心。你写的串口驱动,本质上就是填充这个结构体:

struct uart_ops {
    unsigned int    (*tx_empty)(struct uart_port *);
    void            (*set_mctrl)(struct uart_port *, unsigned int);
    unsigned int    (*get_mctrl)(struct uart_port *);
    void            (*stop_tx)(struct uart_port *);
    void            (*start_tx)(struct uart_port *);
    void            (*stop_rx)(struct uart_port *);
    void            (*enable_ms)(struct uart_port *);
    void            (*break_ctl)(struct uart_port *, int);
    int             (*startup)(struct uart_port *);
    void            (*shutdown)(struct uart_port *);
    void            (*set_termios)(struct uart_port *, struct ktermios *, struct ktermios *);
    void            (*pm)(struct uart_port *, unsigned int, unsigned int);
    const char      *(*type)(struct uart_port *);
    void            (*release_port)(struct uart_port *);
    int             (*request_port)(struct uart_port *);
    void            (*config_port)(struct uart_port *, int);
    int             (*verify_port)(struct uart_port *, struct serial_struct *);
};

这里我重点说几个你一定会碰到的回调:

  • startup:打开串口时调用。你要在这里申请中断、初始化 FIFO、使能收发。我在项目中遇到过忘记清空 FIFO 导致收到乱码的情况,嗯,后来加了一行 uart_port->serial_in(port, UART_RX) 把残留数据读掉就好了。
  • shutdown:关闭串口时调用。释放中断、关闭 FIFO、复位硬件。注意要处理好并发,别在中断还在跑的时候就把寄存器清了。
  • set_termios:配置波特率、数据位、停止位、校验位。这是最容易被忽略的回调。我曾经调试一个 115200 波特率的设备,死活不通,最后发现是 set_termios 里波特率计算公式写错了,算出来实际是 9600。
  • start_tx / stop_tx:控制发送启停。一般配合环形缓冲区使用,发送完一字节后判断是否还有数据要发。

3.4 设备树中的串口配置

现在大部分 ARM 平台都用设备树来描述硬件。串口节点长什么样?我拿一个实际项目中的例子:

&uart2 {
    status = "okay";
    pinctrl-names = "default";
    pinctrl-0 = <&uart2_pins_a>;
    clocks = <&clk_uart2>;
    clock-names = "uart";
    interrupts = <GIC_SPI 36 IRQ_TYPE_LEVEL_HIGH>;
    interrupt-names = "uart";
    dmas = <&dma 12 0 0x400>, <&dma 13 0 0x400>;
    dma-names = "tx", "rx";
    fifo-size = <64>;
};

这里有几个关键属性,我解释一下:

属性 含义 常见坑
status 使能状态,okay 或 disabled 很多人忘了写 okay,驱动加载了但设备不使能
pinctrl-0 引脚复用配置 引脚冲突是最常见的串口不工作原因
clocks 时钟源,波特率发生器依赖它 时钟频率不对,波特率会成倍偏差
interrupts 中断号与触发方式 中断号写错会导致 startup 失败
dmas DMA 通道,可选 DMA 配置复杂,建议先轮询模式调通
fifo-size 硬件 FIFO 深度 不匹配会导致 FIFO 溢出或利用率低

注意:设备树中的 compatible 属性必须与驱动中的 of_match_table 完全匹配,大小写和逗号都不能错。我曾经因为多了一个空格,驱动死活不 probe,查了整整半天。

3.5 驱动与设备树的绑定流程

驱动怎么和设备树节点对上号的?流程其实很清晰:

  1. 内核启动时解析设备树,为每个节点创建 struct platform_device。
  2. 驱动注册时,内核遍历设备树,匹配 compatible 属性。
  3. 匹配成功,调用驱动的 probe 函数。
  4. 在 probe 中,通过 of_property_read_* 读取设备树属性。
  5. 调用 uart_register_driver 和 uart_add_one_port 注册串口。

说白了,设备树就是驱动和硬件之间的「翻译官」。你驱动里要的时钟频率、中断号、DMA 通道,都从设备树里读。

3.6 一个最小串口驱动的骨架

我写一个最简的驱动骨架,帮你把上面的概念串起来:

static const struct of_device_id my_uart_of_match[] = {
    { .compatible = "mycompany,my-uart" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, my_uart_of_match);

static struct uart_ops my_uart_ops = {
    .tx_empty   = my_uart_tx_empty,
    .startup    = my_uart_startup,
    .shutdown   = my_uart_shutdown,
    .set_termios = my_uart_set_termios,
    .start_tx   = my_uart_start_tx,
    .stop_tx    = my_uart_stop_tx,
    .type       = my_uart_type,
};

static int my_uart_probe(struct platform_device *pdev)
{
    struct uart_port *port;
    struct resource *res;

    port = devm_kzalloc(&pdev->dev, sizeof(*port), GFP_KERNEL);
    port->ops = &my_uart_ops;
    port->type = PORT_MY_UART;
    port->iotype = UPIO_MEM32;

    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    port->membase = devm_ioremap_resource(&pdev->dev, res);
    port->mapbase = res->start;

    port->irq = platform_get_irq(pdev, 0);
    if (port->irq < 0)
        return port->irq;

    uart_add_one_port(&my_uart_driver, port);
    platform_set_drvdata(pdev, port);

    return 0;
}

static struct platform_driver my_uart_driver = {
    .probe  = my_uart_probe,
    .remove = my_uart_remove,
    .driver = {
        .name = "my_uart",
        .of_match_table = my_uart_of_match,
    },
};
module_platform_driver(my_uart_driver);

建议:刚开始写串口驱动时,先别急着加 DMA、流控这些高级功能。把 startup、set_termios、start_tx 这三个回调调通,能收发数据了,再逐步加功能。我每次都是这么干的,稳得很。

3.7 调试串口驱动的常用手段

驱动写好了,怎么验证?我分享几个实战中常用的方法:

  • 查看 dmesg:驱动 probe 成功与否,中断有没有注册,都能看到。我习惯在 startup 和 shutdown 里加 dev_dbg() 打印。
  • 检查设备节点:ls -l /dev/tty* 看看你的串口设备有没有生成。主设备号 4 是 tty,204 是 ttyS。
  • 用 stty 测试:stty -F /dev/ttyS1 115200 cs8 -cstopb -parenb 配置参数,然后用 cat /dev/ttyS1 & 和 echo "hello" > /dev/ttyS1 测试收发。
  • 逻辑分析仪:这是终极大法。我曾经遇到一个 TX 引脚始终为高的问题,用逻辑分析仪一看,发现是 pinctrl 配置把 TX 配成了输入模式。

一句话总结:串口驱动开发,框架是骨架,设备树是血肉,uart_ops 是灵魂。把这三样搞明白,剩下的就是填代码了。