3、Linux串口驱动框架:tty子系统架构、uart_ops结构体、串口设备注册流程

好,咱们今天聊聊Linux串口驱动的核心框架。说实话,我刚入行那会儿,看到tty子系统那堆代码,头都是大的。但后来发现,只要抓住几个关键点,这东西其实没那么玄乎。

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

Linux的串口驱动,说白了就是tty子系统的一部分。它分三层:

  • tty核心层:对上提供统一的文件操作接口(open、read、write等),对下调用线路规程和驱动层。
  • 线路规程(line discipline):负责数据加工,比如行编辑、回显、流控。默认是N_TTY。
  • tty驱动层:直接操作硬件寄存器,发送接收数据。

我画了个结构图,你看一眼就明白了:

Linux tty子系统架构 用户空间 (应用程序) tty核心层 (tty_io.c) 线路规程 (n_tty.c) tty驱动层 (uart_ops) 硬件抽象层 (串口控制器) 硬件 (UART芯片) 数据流向 用户 → 核心 → 驱动 → 硬件

嗯,这里要注意:线路规程是个容易被忽略的环节。我遇到过好几次,应用层读写串口数据不对,查了半天发现是线路规程把数据给"加工"了。比如你发0x0D,它自动给你加个0x0A,这就是N_TTY在搞鬼。

调试技巧:如果应用层需要"裸数据",可以用TIOCSETD命令把线路规程切换为N_TTY以外的规程,或者干脆用raw模式。

3.2 uart_ops结构体:驱动的心脏

每个串口驱动都必须实现一个uart_ops结构体。说白了,这就是驱动和tty核心之间的"契约"。你实现了这些函数,核心层才能调用你。

我直接贴一段典型的定义:

static const struct uart_ops my_uart_ops = {
    .tx_empty   = my_uart_tx_empty,
    .set_mctrl  = my_uart_set_mctrl,
    .get_mctrl  = my_uart_get_mctrl,
    .stop_tx    = my_uart_stop_tx,
    .start_tx   = my_uart_start_tx,
    .stop_rx    = my_uart_stop_rx,
    .enable_ms  = my_uart_enable_ms,
    .break_ctl  = my_uart_break_ctl,
    .startup    = my_uart_startup,
    .shutdown   = my_uart_shutdown,
    .set_termios = my_uart_set_termios,
    .type       = my_uart_type,
    .release_port   = my_uart_release_port,
    .request_port   = my_uart_request_port,
    .config_port    = my_uart_config_port,
    .verify_port    = my_uart_verify_port,
};

这里面有几个关键函数,我重点说说:

函数名 作用 我的经验
startup 打开串口时调用,初始化硬件 记得检查时钟是否使能,我踩过这个坑
shutdown 关闭串口时调用,释放资源 别忘了关中断,否则系统会挂死
start_tx 启动发送 DMA模式下要小心缓冲区管理
set_termios 设置波特率、数据位、停止位等 波特率计算最容易出错,建议用查表法
避坑指南:我曾经在set_termios里直接修改硬件寄存器,没加锁。结果应用层频繁切换波特率时,系统直接panic。记住:set_termios可能被中断上下文调用,一定要用自旋锁保护。

3.3 串口设备注册流程

一个串口设备从驱动加载到用户可见,大概走这么几步:

  1. 定义uart_driver结构体:包含驱动名、设备号、端口数量等信息。
  2. 调用uart_register_driver():向系统注册这个驱动。
  3. 调用uart_add_one_port():添加具体的串口端口。
  4. 设备节点创建:udev或mdev自动创建/dev/ttySx节点。

我写个简化版的注册流程代码:

static struct uart_driver my_uart_driver = {
    .owner          = THIS_MODULE,
    .driver_name    = "my_uart",
    .dev_name       = "ttyMY",
    .major          = TTY_MAJOR,
    .minor          = 64,
    .nr             = 4,  // 支持4个串口
};

static int __init my_uart_init(void)
{
    int ret;

    // 第一步:注册驱动
    ret = uart_register_driver(&my_uart_driver);
    if (ret)
        return ret;

    // 第二步:逐个添加端口
    for (int i = 0; i < 4; i++) {
        ret = uart_add_one_port(&my_uart_driver, &my_uart_port[i]);
        if (ret) {
            // 清理已添加的端口
            while (--i >= 0)
                uart_remove_one_port(&my_uart_driver, &my_uart_port[i]);
            uart_unregister_driver(&my_uart_driver);
            return ret;
        }
    }

    return 0;
}

这里有个细节:uart_add_one_port()内部会调用你实现的config_port()和request_port()。所以这两个函数必须提前准备好。

关键点:注册顺序不能乱。先注册驱动,再添加端口。卸载时顺序相反:先移除端口,再注销驱动。

3.4 设备树与平台匹配

现在的嵌入式平台,串口设备大多通过设备树描述。举个例子:

uart1: serial@10001000 {
    compatible = "my,uart";
    reg = <0x10001000 0x1000>;
    interrupts = <0 33 4>;
    clocks = <&uart_clk>;
    status = "okay";
};

驱动里用of_match_table来匹配:

static const struct of_device_id my_uart_of_match[] = {
    { .compatible = "my,uart" },
    { },
};

static struct platform_driver my_uart_platform_driver = {
    .probe  = my_uart_probe,
    .remove = my_uart_remove,
    .driver = {
        .name = "my_uart",
        .of_match_table = my_uart_of_match,
    },
};

在probe函数里,你会拿到设备树里的资源(地址、中断号等),然后初始化硬件、注册串口驱动。我个人习惯在probe里加一句打印,确认设备树匹配成功:

dev_info(&pdev->dev, "my_uart probed at 0x%llx\n", res->start);

别小看这行打印。我调试过一块板子,串口死活不工作,最后发现是设备树里status = "disabled",驱动根本没probe。加个打印,一眼就能看出来。

调试建议:在probe和startup函数里加dev_dbg()打印,记录关键步骤。用dmesg查看,能快速定位问题出在注册阶段还是运行时。

好了,tty子系统的三层架构、uart_ops的每个回调函数、以及串口设备的完整注册流程,咱们都过了一遍。这些东西看着多,其实核心就几个结构体和函数。你写驱动时,照着这个框架填内容就行。