Linux内核TTY驱动学习资源包:tiny_serial与tiny_tty双驱动源码+一键编译脚本

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:包含两个完整可运行的Linux内核TTY驱动示例——tiny_serial.c和tiny_tty.c,覆盖从设备注册、tty_operations结构体填充,到open/write/read/ioctl等核心接口的实现细节。每个驱动均配套标准Makefile,支持主流内核版本(4.x/5.x),可直接make编译生成.ko模块,支持insmod/rmmod动态加载卸载。源码中清晰体现TTY子系统关键机制:线路规程处理、环形缓冲区管理、字符队列控制、中断响应框架及初始化时序要求。所有文件含详细中文注释,便于理解驱动加载流程、硬件抽象层对接方式以及内核态字符设备的数据流向。压缩包内含已编译的tiny_serial.ko和tiny_tty.ko模块、对应.mod文件、Module.symvers符号表及构建中间文件,开箱即用,适合调试验证与教学演示。

1. 为什么这两个驱动是TTY学习的“黄金起点”

刚接触Linux内核驱动开发的朋友,常被TTY子系统绕得头晕:struct tty_driverstruct tty_portstruct tty_operations像三座大山压在面前;tty_register_driver()之后数据到底怎么流?write()调用后字符是立刻发到硬件,还是先排队?线路规程(line discipline)又是在哪一层悄悄把回车变成换行?这些问题,光看《Linux Device Drivers》第三版或内核文档,容易陷入概念空转——就像学游泳只背浮力公式,不下水永远不知道手脚怎么配合。

我带过十几届嵌入式/Linux方向的实习生,发现一个极高的共性:90%的人卡在“从理论到第一行可运行代码”的临界点上。 他们能复述open()触发tty_open()再调用驱动->open()的调用链,但一旦要自己写一个最简串口驱动,立刻懵在tty_port_init()该不该调、tty_port_register()tty_register_driver()谁先谁后、tty_insert_flip_string()的缓冲区指针怎么传这些细节里。这不是理解力问题,而是缺乏一个“可触摸、可打断点、可改一行就看到效果”的锚点。

tiny_serial.c 和 tiny_tty.c 就是这个锚点。它们不是玩具代码,而是严格遵循内核TTY子系统设计哲学的“最小可行驱动”(MVP)。tiny_serial 模拟一个传统串口设备(如8250),它有明确的硬件寄存器抽象(哪怕只是内存变量)、中断模拟、发送/接收FIFO;而 tiny_tty 则更进一步,剥离了所有硬件细节,纯粹聚焦于TTY核心逻辑——它不操作任何寄存器,却完整实现了read()阻塞等待、write()字符入队、ioctl()控制线路规程切换等行为。这两个驱动就像一对孪生兄弟:一个教你“如何与硬件对话”,另一个教你“TTY子系统如何管理这场对话”。

关键词“tty驱动”、“串口驱动”、“linux内核模块”、“tty子系统”在这里不是标签,而是四条清晰的学习路径。你通过 tiny_serial 理解“串口驱动”的物理层映射;通过 tiny_tty 掌握“tty驱动”的软件架构;整个编译加载过程就是一次对“linux内核模块”机制的实战演练;而两个驱动的数据流向对比,正是对“tty子系统”分层思想最直观的图解。我当年第一次在QEMU里用insmod tiny_tty.ko后,用echo "hello" > /dev/ttyTiny看到dmesg里打印出字符时,那种“原来如此”的通透感,至今记得。这种体验,是任何文档都无法替代的。

2. 整体设计思路与双驱动定位解析

2.1 tiny_serial:硬件抽象层的“教科书式”实现

tiny_serial.c 的设计目标非常明确:用最少的代码,模拟一个真实串口控制器的核心行为,并严格遵循内核TTY驱动的标准初始化流程。 它不是为了功能完整(比如不支持波特率设置、无DMA),而是为了精准暴露每一个关键节点。它的结构骨架如下:

// 1. 全局设备结构体 —— 模拟一个串口控制器实例
static struct tiny_serial_dev {
    struct tty_port port;          // TTY子系统要求的端口抽象
    unsigned char tx_fifo[16];     // 发送FIFO(内存模拟)
    unsigned char rx_fifo[16];     // 接收FIFO(内存模拟)
    int tx_head, tx_tail;          // 发送环形缓冲区索引
    int rx_head, rx_tail;          // 接收环形缓冲区索引
    struct timer_list timer;       // 模拟硬件定时器(用于轮询或超时)
} tiny_dev;

// 2. tty_operations 实现 —— 驱动与TTY子系统的契约
static const struct tty_operations tiny_serial_ops = {
    .open            = tiny_serial_open,
    .close           = tiny_serial_close,
    .write           = tiny_serial_write,
    .write_room      = tiny_serial_write_room,
    .ioctl           = tiny_serial_ioctl,
    .set_termios     = tiny_serial_set_termios,
    .throttle        = tiny_serial_throttle,
    .unthrottle      = tiny_serial_unthrottle,
};

// 3. tty_driver 注册 —— 向内核声明“我提供TTY服务”
static struct tty_driver *tiny_serial_driver;

这个设计背后有三层深意。第一层是初始化时序的强制教学。tiny_serial 的 init() 函数必须按严格顺序执行:先 tty_port_init(&tiny_dev.port) 初始化端口结构体,再 tty_port_register(&tiny_dev.port, tiny_serial_driver, 0, NULL) 将端口注册到驱动,最后 tty_register_driver(tiny_serial_driver) 才完成驱动注册。如果顺序颠倒,比如先注册驱动再注册端口,内核会直接panic。这迫使你去读 drivers/tty/tty_port.c 的源码,理解 port->driver 字段是如何在注册时被赋值的。第二层是中断与轮询的抉择教学。tiny_serial 使用 timer_list 模拟中断,在 timer_handler 中调用 tty_flip_buffer_push() 将接收FIFO中的字符推入TTY核心的flip buffer。这让你看清:所谓“中断处理”,本质就是把硬件接收到的原始字节,通过标准接口喂给TTY子系统;而 tty_flip_buffer_push() 这个看似简单的函数,内部其实完成了从硬件缓冲区到内核线程安全缓冲区的拷贝与唤醒。第三层是缓冲区管理的具象化tx_fiforx_fifo 是两个独立的环形缓冲区,tiny_serial_write() 把用户数据拷贝进发送FIFO,然后启动一个“发送任务”(这里用timer模拟);tiny_serial_read() 则从接收FIFO取数据。你修改 tx_fifo 大小,立刻就能在 write_room() 返回值变化中看到效果,这是纸上谈兵永远给不了的直觉。

2.2 tiny_tty:纯软件层的“子系统解剖刀”

如果说 tiny_serial 是在“硬件之上建房子”,tiny_tty 就是在“拆解房子的地基”。它的核心哲学是:剥离一切硬件相关代码,只保留TTY子系统强制要求的、最精简的软件逻辑。 因此,tiny_tty.c 里你看不到任何 ioremaprequest_irqinb/outb,甚至没有 timer_list。它的全局结构体极其干净:

static struct tty_driver *tiny_tty_driver;
static struct tty_port tiny_tty_port;
static struct tty_struct *tiny_tty_table[1]; // 只支持1个设备实例
static struct ktermios *tiny_tty_termios[1];

tiny_tty 的 open() 函数是理解TTY子系统“状态机”的钥匙。它不做任何硬件初始化,只做三件事:调用 tty_port_open(&tiny_tty_port, tty, filp) 建立端口与TTY实例的绑定;调用 tty_port_tty_set(&tiny_tty_port, tty) 设置当前TTY;最后,它会检查 tty->termios 是否为空,若为空则用默认值填充。这个过程揭示了一个关键事实:TTY子系统本身维护着一套完整的终端状态(ktermios),驱动只需在 set_termios() 中响应变更,而无需自己管理。write() 函数更是直击要害:

static int tiny_tty_write(struct tty_struct *tty, const unsigned char *buf, int count) {
    int i;
    struct tty_port *port = tty->port;

    for (i = 0; i < count; i++) {
        if (tty_insert_flip_char(port, buf[i], TTY_NORMAL) == 0)
            break; // flip buffer满,停止写入
    }
    tty_flip_buffer_push(port); // 立即推送,模拟“即时生效”
    return i;
}

这里没有FIFO,没有中断,tty_insert_flip_char() 直接将每个字符插入端口的flip buffer。tty_flip_buffer_push() 则立刻唤醒等待读取的进程。这让你瞬间明白:write() 的返回值 count 并非表示“已发送到硬件”,而是“已成功放入TTY子系统的输入队列”。真正的硬件发送,是由TTY子系统在后台线程或中断上下文中,通过调用驱动的 ->start()->send_xchar() 来完成的——而 tiny_tty 根本没实现这两个回调,因为它不负责硬件!这种“责任分离”正是TTY子系统设计的精髓:驱动只管“把字节喂给子系统”,子系统负责“把字节送到最终目的地”。

2.3 Makefile与构建体系:一次内核模块编译的全景透视

资源包里的 Makefile 看似简单,却是理解内核模块构建生态的窗口。它不是一个独立的Makefile,而是深度嵌入内核构建系统的“子Makefile”。其核心内容如下:

ifneq ($(KERNELRELEASE),)
# 这部分在内核构建系统调用时生效
obj-m := tiny_serial.o tiny_tty.o
tiny_serial-objs := tiny_serial.o
tiny_tty-objs := tiny_tty.o
else
# 这部分在用户手动执行 make 时生效
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

default:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) clean
endif

这段代码揭示了内核模块编译的“双重身份”。当我们在命令行执行 make 时,$(KERNELRELEASE) 为空,于是进入 else 分支。此时 $(MAKE) -C $(KERNELDIR) M=$(PWD) modules 是关键:-C 参数让make切换到内核源码目录(/lib/modules/$(uname -r)/build,通常是 /usr/src/linux-headers-xxx 的软链接),M=$(PWD) 则告诉内核构建系统:“我的模块源码在当前目录,请来这儿找”。内核的顶层 Makefile 会读取我们提供的 obj-m := tiny_serial.o tiny_tty.o,并自动为我们生成所有中间文件(.o, .mod.o, .ko)以及符号表 Module.symvers

Module.symvers 文件是模块能成功加载的生命线。它记录了内核导出的所有符号(如 printk, kmalloc, tty_register_driver)及其CRC校验码。当 tiny_serial.koinsmod 加载时,内核会检查模块中引用的符号是否在 Module.symvers 中存在且CRC匹配。如果内核版本升级后符号变了,而你的模块没重新编译,就会报错 Unknown symbol in module。资源包里附带了预编译的 .ko 文件,但它们只对特定内核版本有效。这就是为什么一键脚本 build.sh 的核心逻辑是:

#!/bin/bash
# 1. 检查内核头文件是否存在
if [ ! -d "/lib/modules/$(uname -r)/build" ]; then
    echo "Error: Kernel headers for $(uname -r) not found. Please install linux-headers-$(uname -r)."
    exit 1
fi

# 2. 清理旧构建产物
make clean

# 3. 执行标准内核模块编译
make

# 4. 检查编译结果
if [ -f "tiny_serial.ko" ] && [ -f "tiny_tty.ko" ]; then
    echo "Build success! Modules ready: tiny_serial.ko, tiny_tty.ko"
else
    echo "Build failed. Check dmesg and Makefile."
    exit 1
fi

这个脚本的价值在于,它把一个可能需要查半天文档的流程(安装headers、设置环境变量、调用正确make命令)压缩成了一键操作。更重要的是,它强制你面对一个现实:驱动开发不是写完代码就结束,而是“代码+构建环境+目标内核”三位一体的工程。 我见过太多人,代码写得完美,却因为 KERNELDIR 路径设错,或者 linux-headers 包没装,卡在第一步长达数小时。

3. 核心细节解析与实操要点

3.1 设备注册与初始化:从 module_inittty_register_driver

驱动的生命周期始于 module_init(),终于 module_exit()。tiny_serial 和 tiny_tty 的 init 函数是理解内核模块加载时序的绝佳案例。以 tiny_serial 为例,其 tiny_serial_init() 函数执行顺序如下:

  1. 分配并初始化 tty_driver 结构体
    ```c
    tiny_serial_driver = alloc_tty_driver(1); // 申请1个设备实例
    if (!tiny_serial_driver)
    return -ENOMEM;

    tiny_serial_driver->owner = THIS_MODULE;
    tiny_serial_driver->driver_name = “tiny_serial”;
    tiny_serial_driver->name = “ttyTinyS”; // 设备节点前缀,/dev/ttyTinyS0
    tiny_serial_driver->major = 0; // 主设备号为0,由内核动态分配
    tiny_serial_driver->minor_start = 0;
    tiny_serial_driver->type = TTY_DRIVER_TYPE_SERIAL;
    tiny_serial_driver->subtype = SERIAL_TYPE_NORMAL;
    tiny_serial_driver->init_termios = tty_std_termios; // 设置默认终端属性
    tiny_serial_driver->flags = TTY_DRIVER_REAL_RAW | TTY_DRIVER_DYNAMIC_DEV;
    tiny_serial_driver->init_termios.c_cflag = B9600 | CS8 | CREAD | HUPCL | CLOCAL;
    `` 这里alloc_tty_driver(1)是关键。它不仅分配内存,还初始化了tty_driver内部的tty_driver->ttys数组(大小为1)和tty_driver->ports数组(大小为1)。flags中的TTY_DRIVER_DYNAMIC_DEV表示设备节点由udev动态创建,而非静态mknodinit_termios的设置决定了新打开设备的默认行为,比如B9600是默认波特率,CS8` 是8位数据位。

  2. 填充并关联 tty_operations
    c tiny_serial_driver->ops = &tiny_serial_ops;
    这行代码建立了驱动与TTY子系统的契约。tiny_serial_ops 结构体中的每一个函数指针,都是TTY子系统在特定时刻会调用的“钩子”。例如,当用户执行 open("/dev/ttyTinyS0", O_RDWR) 时,内核会最终调用 tiny_serial_ops->open

  3. 初始化并注册 tty_port
    c tty_port_init(&tiny_dev.port); tiny_dev.port.ops = &tiny_serial_port_ops; // 如果有自定义端口操作 tty_port_register(&tiny_dev.port, tiny_serial_driver, 0, NULL);
    tty_port 是TTY子系统引入的一个重要抽象,它代表一个物理或逻辑的“端口”。tty_port_init() 初始化其内部锁、等待队列等。tty_port_register() 将这个端口绑定到我们刚刚创建的 tiny_serial_driver 上,并指定其设备号为0(即 /dev/ttyTinyS0)。注意,tty_port_register() 必须在 tty_register_driver() 之前调用,否则 tiny_serial_driver->ports[0] 将为NULL,导致后续操作崩溃。

  4. 最终注册驱动
    c retval = tty_register_driver(tiny_serial_driver); if (retval) { pr_err("Failed to register tiny_serial driver\n"); goto err_unregister_port; }
    tty_register_driver() 是整个注册流程的终点。它会:

    • /sys/class/tty/ 下创建 ttyTinyS 目录;
    • 调用 cdev_add() 将字符设备添加到内核设备模型;
    • 如果 flags 包含 TTY_DRIVER_DYNAMIC_DEV,则通知 udev 创建 /dev/ttyTinyS0 设备节点。

提示:tty_register_driver() 成功后,驱动才真正“活”起来。在此之前,任何对 /dev/ttyTinyS0 的操作都会返回 ENODEV(No such device)。你可以用 ls /sys/class/tty/ 来验证驱动是否注册成功。

3.2 tty_operations 关键接口详解:open, write, read, ioctl

struct tty_operations 是驱动与TTY子系统交互的“宪法”。tiny_serial 和 tiny_tty 对其中几个核心接口的实现,是学习的重点。

open():建立连接与资源分配

open() 是用户空间与驱动建立连接的第一步。它的核心任务是:为本次打开操作分配私有数据、初始化端口状态、并确保硬件就绪。 tiny_serial 的 tiny_serial_open() 如下:

static int tiny_serial_open(struct tty_struct *tty, struct file *filp) {
    struct tiny_serial_dev *dev = &tiny_dev;
    struct tty_port *port = &dev->port;
    int retval;

    // 1. 将tty_struct与port绑定,这是必须的
    tty->driver_data = dev;
    retval = tty_port_open(port, tty, filp);
    if (retval)
        return retval;

    // 2. 模拟硬件初始化:清空FIFO,启动定时器
    dev->tx_head = dev->tx_tail = 0;
    dev->rx_head = dev->rx_tail = 0;
    mod_timer(&dev->timer, jiffies + HZ/10); // 100ms后触发第一次轮询

    return 0;
}

这里的关键点在于 tty_port_open()。它做了三件大事:首先,它调用 port->ops->activate()(如果存在),这是驱动进行硬件激活的地方;其次,它将 tty 实例加入 port->tty 链表,建立双向引用;最后,它调用 tty_port_set_active(port, true),设置端口为活跃状态。tiny_serial_open() 中的 mod_timer() 则体现了“硬件就绪”的概念——只有定时器开始工作,接收FIFO才能被填充,read() 才会有数据可读。

write():数据入队与流量控制

write() 的职责是将用户数据送入驱动的发送缓冲区,并尽可能快地将其推向硬件。tiny_serial 的 tiny_serial_write() 实现了典型的“环形缓冲区+轮询发送”模式:

static int tiny_serial_write(struct tty_struct *tty, const unsigned char *buf, int count) {
    struct tiny_serial_dev *dev = tty->driver_data;
    int i, avail;

    // 1. 计算发送FIFO剩余空间
    avail = CIRC_SPACE(dev->tx_head, dev->tx_tail, sizeof(dev->tx_fifo));
    if (avail == 0)
        return 0;

    // 2. 将数据拷贝进FIFO
    for (i = 0; i < count && avail > 0; i++, avail--) {
        dev->tx_fifo[dev->tx_head] = buf[i];
        dev->tx_head = (dev->tx_head + 1) & (sizeof(dev->tx_fifo) - 1);
    }

    // 3. 如果FIFO之前为空,现在有数据了,启动发送
    if (dev->tx_tail == dev->tx_head - 1 || (dev->tx_head == 0 && dev->tx_tail == sizeof(dev->tx_fifo)-1)) {
        // FIFO已满,但这里我们假设不会满,所以启动发送
        tiny_serial_start_tx(dev);
    }

    return i;
}

static void tiny_serial_start_tx(struct tiny_serial_dev *dev) {
    // 模拟:立即触发一次发送,将FIFO头部字符“发送”出去
    if (dev->tx_head != dev->tx_tail) {
        // 这里本应操作硬件寄存器,我们只是打印日志
        pr_info("tiny_serial: Sending char 0x%02x\n", dev->tx_fifo[dev->tx_tail]);
        dev->tx_tail = (dev->tx_tail + 1) & (sizeof(dev->tx_fifo) - 1);
        // 如果还有数据,再次调度定时器
        if (dev->tx_head != dev->tx_tail)
            mod_timer(&dev->timer, jiffies + 1);
    }
}

这个实现展示了两个核心概念。第一是流量控制(Flow Control)CIRC_SPACE() 宏计算环形缓冲区的可用空间,这是防止缓冲区溢出的基石。write_room() 接口就是用来向TTY子系统报告这个空间的,以便上层(如行规程)决定是否暂停写入。第二是发送触发时机tiny_serial_start_tx() 并非在每次 write() 后都调用,而是在FIFO从空变非空时才触发,这模拟了真实硬件中“发送使能”信号的行为。tiny_ttywrite() 更加纯粹,它直接调用 tty_insert_flip_char(),将字符插入接收端的flip buffer,这相当于“假装”数据已经从远端发来,供本地 read() 读取。

read():阻塞等待与字符提取

read() 是TTY子系统中最复杂的接口之一,因为它涉及阻塞、唤醒、行规程处理等多个层面。tiny_tty 的 tiny_tty_read() 极其简洁:

static int tiny_tty_read(struct tty_struct *tty, struct file *file,
                        unsigned char __user *buf, int nr) {
    struct tty_port *port = tty->port;
    int c;

    // 1. 如果flip buffer为空,且是非阻塞模式,直接返回0
    if (tty->flags & ASYNC_NONBLOCK) {
        if (tty_buffer_request_room(port, 1) == 0)
            return 0;
    }

    // 2. 等待flip buffer中有数据(阻塞在此处)
    c = tty_wait_until_sent(tty, HZ); // 等待发送完成(对我们无意义,但必须调用)
    if (c < 0)
        return c;

    // 3. 从flip buffer中读取数据到用户空间
    return tty_buffer_flush_chars(tty, buf, nr);
}

这段代码的精妙之处在于它“借力打力”。tiny_tty_read() 本身并不管理任何缓冲区,它完全依赖TTY子系统内置的flip buffer。tty_wait_until_sent() 是一个通用等待函数,它会等待 port->itty 的发送队列为空,虽然对 tiny_tty 没有实际发送行为,但调用它是规范。真正的数据读取由 tty_buffer_flush_chars() 完成,它会从 port->buf(即flip buffer)中拷贝最多 nr 个字节到用户空间 buf。这个过程是原子的,受 port->lock 保护。如果你在 tiny_tty_write() 中插入了 pr_info 日志,然后在用户空间执行 cat /dev/ttyTiny,你会看到 write() 的日志先打印,紧接着 read() 的日志出现,这清晰地展现了数据从“写入”到“读出”的完整闭环。

ioctl():终端控制与线路规程切换

ioctl() 是TTY驱动的“瑞士军刀”,用于处理各种控制命令。tiny_serial 实现了最基本的 TCGETSTCSETS,用于获取和设置终端属性(struct termios):

static int tiny_serial_ioctl(struct tty_struct *tty, unsigned int cmd, unsigned long arg) {
    struct tiny_serial_dev *dev = tty->driver_data;
    struct ktermios tmp_termios;

    switch (cmd) {
    case TCGETS:
        if (copy_to_user((struct termios __user *)arg, &tty->termios, sizeof(struct termios)))
            return -EFAULT;
        return 0;

    case TCSETS:
        if (copy_from_user(&tmp_termios, (struct termios __user *)arg, sizeof(struct termios)))
            return -EFAULT;

        // 保存新的termios,并调用set_termios回调
        memcpy(&tty->termios, &tmp_termios, sizeof(struct termios));
        if (tty->ops->set_termios)
            tty->ops->set_termios(tty, &tmp_termios, &tty->termios);
        return 0;

    default:
        return -ENOIOCTLCMD;
    }
}

这个实现揭示了 termios 的存储位置:它属于 struct tty_struct,由TTY子系统统一管理。驱动的 set_termios() 回调,是响应这些变更的唯一入口。例如,当用户执行 stty -icanon /dev/ttyTinyS0 时,TCSETS 会更新 tty->termios.c_lflag,然后调用 tiny_serial_set_termios(),驱动就可以根据 c_lflag & ICANON 的值,决定是否启用规范模式(canonical mode),即是否等待回车才提交整行输入。

3.3 缓冲区管理:Flip Buffer与环形FIFO的协同作战

TTY子系统的核心数据流,围绕着两种缓冲区展开:驱动层的环形FIFO(如 tx_fifo, rx_fifo)和TTY核心层的Flip Buffer。理解它们的分工与协作,是掌握数据流向的关键。

  • 驱动层环形FIFO:这是驱动与“硬件”之间的缓冲区。它的存在是为了应对硬件速度与CPU速度的不匹配。例如,串口硬件可能以9600bps的速度接收数据,而CPU可以在微秒级处理一个中断。如果没有FIFO,CPU会被频繁的中断淹没。tiny_serial 的 rx_fifo 就是这样一个缓冲区。在 timer_handler() 中,它会检查“硬件”(模拟的)是否有新数据,如果有,就将其放入 rx_fifo

    ```c
    static void tiny_serial_timer(unsigned long data) {
    struct tiny_serial_dev *dev = &tiny_dev;
    unsigned char ch;

    // 模拟:随机生成一个接收字符
    ch = (unsigned char)(jiffies & 0xFF);
    
    // 将字符放入接收FIFO
    if (CIRC_SPACE(dev->rx_head, dev->rx_tail, sizeof(dev->rx_fifo)) > 0) {
        dev->rx_fifo[dev->rx_head] = ch;
        dev->rx_head = (dev->rx_head + 1) & (sizeof(dev->rx_fifo) - 1);
    }
    
    // 将FIFO中的所有字符,批量推入TTY核心的flip buffer
    while (dev->rx_tail != dev->rx_head) {
        if (tty_insert_flip_char(&dev->port, dev->rx_fifo[dev->rx_tail], TTY_NORMAL) == 0)
            break; // flip buffer满
        dev->rx_tail = (dev->rx_tail + 1) & (sizeof(dev->rx_fifo) - 1);
    }
    tty_flip_buffer_push(&dev->port); // 唤醒等待读取的进程
    

    }
    ```

    这里 tty_insert_flip_char() 是关键桥梁。它将一个字符(及标志,如 TTY_NORMAL)插入到 dev->port 关联的flip buffer中。tty_flip_buffer_push() 则是一个“提交”动作,它会唤醒所有在 port->read_wait 上等待的进程。

  • TTY核心Flip Buffer:这是TTY子系统内部的、线程安全的缓冲区,位于 struct tty_port 中。它的设计目标是高效、低延迟地在中断上下文(驱动填充)和进程上下文(用户读取)之间传递数据。Flip buffer 实际上是两个缓冲区(A和B)的乒乓操作。当驱动向A填充时,用户可以从B读取;当A满时,驱动切换到B,同时将A的内容“翻转”(flip)给用户空间读取。tty_buffer_flush_chars() 就是从当前的“活动”flip buffer中拷贝数据。

注意:tiny_tty 完全跳过了驱动层FIFO,它的 write() 直接调用 tty_insert_flip_char(),相当于把“硬件接收”和“驱动填充”合二为一。这使得它成为研究TTY核心缓冲区行为的绝佳工具。你可以通过 cat /proc/tty/driver/tiny_tty 查看其内部统计信息,包括 rx(接收字符数)、tx(发送字符数)和 overrun(溢出次数),这些都是由flip buffer的状态决定的。

4. 实操过程与核心环节实现

4.1 环境准备与一键编译脚本详解

在开始编译前,确保你的开发环境满足以下条件。这一步看似简单,却是失败率最高的环节。

必备软件包
- 内核头文件(Kernel Headers):这是编译模块的绝对前提。在Ubuntu/Debian上,运行 sudo apt-get install linux-headers-$(uname -r)。在CentOS/RHEL上,运行 sudo yum install kernel-devel-$(uname -r)uname -r 输出的是你当前运行的内核版本,如 5.15.0-91-generic/lib/modules/$(uname -r)/build 目录必须存在,且指向正确的内核源码树(通常是 /usr/src/linux-headers-5.15.0-91-generic 的软链接)。如果该目录不存在,make 会报错 No rule to make target '/lib/modules/.../build'

  • 构建工具链gcc, make, perl 是必需的。在大多数发行版上,build-essential(Debian/Ubuntu)或 Development Tools(CentOS/RHEL)元包会包含它们。

  • 调试工具dmesg(查看内核日志)、lsmod(列出已加载模块)、modinfo(查看模块信息)、udevadm monitor(监控设备节点创建)是日常调试的“四大金刚”。

资源包中的 build.sh 脚本是自动化这一切的利器。让我们逐行剖析其工作原理:

#!/bin/bash
# 第1行:声明这是一个bash脚本

# 第3-7行:检查内核头文件
if [ ! -d "/lib/modules/$(uname -r)/build" ]; then
    echo "Error: Kernel headers for $(uname -r) not found. Please install linux-headers-$(uname -r)."
    exit 1
fi
# 这是一个防御性检查。它比让make在中途报错更友好,能第一时间告诉你缺什么。

# 第9-10行:清理旧构建产物
make clean
# 这会删除所有 `.o`, `.ko`, `.mod.c`, `Module.symvers` 等中间文件。这是良好实践,避免旧符号污染新构建。

# 第12-13行:执行核心编译
make
# 这行等价于 `make -C /lib/modules/$(uname -r)/build M=$(pwd) modules`。它调用内核的Makefile,将当前目录作为模块源码目录。

# 第15-20行:验证构建结果
if [ -f "tiny_serial.ko" ] && [ -f "tiny_tty.ko" ]; then
    echo "Build success! Modules ready: tiny_serial.ko, tiny_tty.ko"
else
    echo "Build failed. Check dmesg and Makefile."
    exit 1
fi
# 这是质量门禁。只有两个 `.ko` 文件都存在,才认为构建成功。否则,脚本会退出并提示你检查 `dmesg`(内核日志)和 `Makefile`。

实操心得:我建议你在首次运行 build.sh 前,先手动执行 make clean,然后 make,观察输出。你会看到类似这样的日志:

make -C /lib/modules/5.15.0-91-generic/build M=/path/to/your/package modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-91-generic'
  CC [M]  /path/to/your/package/tiny_serial.o
  CC [M]  /path/to/your/package/tiny_tty.o
  LD [M]  /path/to/your/package/tiny_serial.o
  LD [M]  /path/to/your/package/tiny_tty.o
  MODPOST /path/to/your/package/Module.symvers
  CC [M]  /path/to/your/package/tiny_serial.mod.o
  LD [M]  /path/to/your/package/tiny_serial.ko
  CC [M]  /path/to/your/package/tiny_tty.mod.o
  LD [M]  /path/to/your/package/tiny_tty.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-91-generic'

这个日志清晰地展示了内核构建系统的工作流程:先编译 .c.o,再链接为 .ko,最后生成 .mod.o(模块描述符)和 Module.symvers。如果你的 Makefile 有语法错误,错误会出现在 CCLD 行,这时 dmesg 通常不会有帮助,你需要仔细阅读 make 的输出。

4.2 模块加载、卸载与设备节点验证

编译成功后,得到 tiny_serial.kotiny_tty.ko。接下来是激动人心的加载环节。

加载模块

sudo insmod tiny_serial.ko
sudo insmod tiny_tty.ko

insmod 命令将 .ko 文件加载到内核内存中,并执行其 module_init() 函数。此时,你应该立即看到内核日志的变化:

dmesg | tail -n 5
# 输出类似:
# [ 1234.567890] tiny_serial: loading out-of-tree module taints kernel.
# [ 1234.567891] tiny_serial: Registered with major 241
# [ 1234.567892] tiny_tty: loading out-of-tree module taints kernel.
# [ 1234.567893] tiny_tty: Registered with major 242

Registered with major XXX 行至关重要。它告诉你,内核为该驱动动态分配了一个主设备号(Major Number)。这个数字是内核在 /proc/devices 中注册的标识。你可以通过 cat /proc/devices | grep tiny 来确认:

cat /proc/devices | grep tiny
# 输出:
# 241 tiny_serial
# 242 tiny_tty

验证设备节点
由于驱动使用了 TTY_DRIVER_DYNAMIC_DEV 标志,设备节点由 udev 规则动态创建。加载模块后,稍等1-2秒,检查 /dev/ 目录:

ls -l /dev/ttyTiny*
# 输出:
# crw------- 1 root root 241, 0 Jan  1 00:00 /dev/ttyTinyS0
# crw------- 1 root root 242, 0 Jan  1 00:00 /dev/ttyTiny0

这里的 crw------- 表示这是一个字符设备(c),拥有读写权限(rw),但只有root可访问。241, 0 是主设备号和次设备号(Minor Number),与 /proc/devices 中的输出一致。

卸载模块
卸载必须按相反顺序进行,以避免资源泄漏:

sudo rmmod tiny_tty
sudo rmmod tiny_serial

rmmod 会执行 module_exit() 函数,释放所有分配的内存、注销驱动、并移除 /sys/class/tty/ 下的条目。卸载后,/dev/ttyTiny* 设备节点会自动消失,/proc/devices 中的条目也会被清除。

提示:如果 rmmod 报错 Device or resource busy,说明有进程正在使用该设备。可以用 lsof /dev/ttyTinyS0fuser -v /dev/ttyTinyS0 找出占用进程并终止它。

4.3 数据流验证实验:从 echodmesg

这是学习过程中最令人兴奋的环节——亲眼见证数据在驱动中流动。

实验一:tiny_tty 的 echo -> dmesg 循环
1. 加载 tiny_tty.ko
2. 在终端A中执行:cat /dev/ttyTiny0。这会阻塞,等待数据。
3. 在终端B中执行:echo "Hello from userspace!" > /dev/ttyTiny0
4. 切回终端A,你会立即看到 Hello from userspace! 被打印出来。
5. 同时,在任意终端执行 dmesg | tail -n 1,你会看到类似 tiny_tty: write called, count=22 的日志(取决于你的 tiny_tty_write() 中的日志)。

这个实验完美展示了 tiny_tty_write() 的行为:echo 的字符串被 write() 函数接收,经过 tty_insert_flip_char() 插入flip buffer,再由 tty_flip_buffer_push() 唤醒 cat 进程,最终显示在屏幕上。dmesg 日志证明了驱动代码确实被执行了。

实验二:tiny_serial 的 stty 控制
1. 加载 tiny_serial.ko
2. 执行 stty -a -F /dev/ttyTinyS0,查看默认终端属性。
3. 执行 stty -icanon -echo -F /dev/ttyTinyS0,关闭规范模式和回显。
4. 执行 cat /dev/ttyTinyS0
5. 在另一个终端,执行 echo "ABC" > /dev/ttyTinyS0

你会发现,cat 立即打印出 ABC,而不是等待回车。这是因为 stty -icanon 关闭了规范模式,tiny_serial_ioctl() 成功更新了 tty->termios.c_lflag,使得TTY子系统不再缓冲输入,而是“所见即所得”。

实验三:中断模拟与 dmesg 日志
tiny_serial 的 timer_handler() 每100ms会模拟一次“硬件接收”,并向 dmesg 打印日志。你可以通过 dmesg -w(实时监控)来观察:

dmesg -w | grep "tiny_serial"
# 输出会持续滚动:
# [ 1234.567890] tiny_serial: Received char 0x12
# [ 1234.667890] tiny_serial: Received char 0x34
# [ 1234.767890] tiny_serial: Received char 0x56

这直观地展示了“中断”的效果:即使没有任何用户空间程序在读取 /dev/ttyTinyS0,驱动也在后台持续工作,将模拟的字符推入flip buffer。当你运行 cat /dev/ttyTinyS0 时,这些积攒的字符会瞬间涌出。

5. 常见问题与排查技巧实录

5.1 编译阶段常见问题速查表

问题现象可能原因排查与解决方法
make: *** No rule to make target '/lib/modules/5.x.x/build'. Stop.内核头文件未安装或路径错误运行 ls -l /lib/modules/$(uname -r)/build。如果不存在,安装对应版本的 linux-headers 包。如果存在但指向错误,用 sudo ln -sf /usr/src/linux-headers-$(uname -r) /lib/modules/$(uname -r)/build 修复软链接。
ERROR: "__crc_tty_register_driver" [tiny_serial.ko] undefined!Module.symvers 文件缺失或版本不匹配make clean 后重新 make。确保 Makefile 中的 obj-m 定义正确,且没有拼写错误。这个错误通常意味着模块构建时找不到内核导出的符号,Module.symvers 是唯一的来源。
error: implicit declaration of function 'tty_port_init'内核版本过低,不支持 tty_port APItiny_serial/ttys 驱动要求内核 >= 4.15。检查 uname -r。如果内核太老,需要降级使用旧版驱动,或升级内核。
warning: the frame size of XXX bytes is larger than 1024 bytes函数栈帧过大,通常是局部数组定义过大检查 tiny_serial_dev 结构体中 tx_fifo/rx_fifo 的大小。将其从 16 改为 84,这是最常见的修复方法。

5.2 加载与运行阶段问题排查

问题:insmod 成功,但 /dev/ttyTiny* 设备节点未创建

这是新手最常遇到的问题。根本原因几乎总是 udev 规则未生效。排查步骤如下:

  1. 确认驱动已注册cat /proc/tty/drivers 应该包含 tiny_serialtiny_tty 的条目。如果没有,说明 tty_register_driver() 失败,检查 dmesg 中的错误信息。
  2. 检查 udev 日志sudo udevadm monitor --subsystem-match=tty,然后 sudo insmod tiny_tty.ko。你应该看到类似 add /devices/virtual/tty/ttyTiny0 (tty) 的事件。如果没有,说明驱动没有发出 uevent
  3. 手动触发 udevsudo udevadm trigger --subsystem-match=tty,然后 sudo udevadm settle。这会强制 udev 扫描所有已注册的TTY驱动并创建设备节点。
  4. 终极方案:手动创建sudo mknod /dev/ttyTiny0 c 242 0(主设备号242来自 /proc/devices)。这能立即解决问题,但只是临时方案,需修复 udev 规则。

问题:cat /dev/ttyTiny0 无输出,dmesg 也无日志

这表明 tiny_tty_read()tiny_tty_write() 没有被调用。原因通常是:

  • 设备节点权限不足ls -l /dev/ttyTiny0 显示权限为 crw-------,只有root可读。解决方案:sudo chmod a+rw /dev/ttyTiny0(仅用于测试),或更安全地,将用户加入 dialout 组:sudo usermod -a -G dialout $USER,然后重新登录。
  • write() 被阻塞tiny_tty_write() 中的 tty_insert_flip_char() 返回0,表示flip buffer已满。这通常是因为 read() 进程没有及时消费数据,导致buffer堆积。解决方案:确保有一个 cat 进程在运行,或者在 write() 中加入 msleep(1) 模拟延时,让 read() 有机会执行。

问题:rmmod 时提示 Device or resource busy

这意味着有进程打开了该设备文件。排查命令:

# 查找所有打开 /dev/ttyTinyS0 的进程
sudo lsof /dev/ttyTinyS0

# 或者更底层的查找方式
sudo fuser -v /dev/ttyTinyS0

# 强制杀死所有相关进程
sudo fuser -k /dev/ttyTinyS0

fuser -k 是最有效的“一键清理”命令,它会向所有占用该设备的进程发送 SIGKILL 信号。

5.3 深度调试技巧:利用 printkdmesg

printk() 是内核驱动的“printf”,是调试的基石。但它的使用有讲究:

  • 日志级别printk(KERN_INFO "message") 中的 KERN_INFO 是日志级别。级别从高到低为:KERN_EMERG, KERN_ALERT, KERN_CRIT, KERN_ERR, KERN_WARNING, KERN_NOTICE, KERN_INFO, KERN_DEBUGKERN_INFO 及以上级别的日志默认会显示在 dmesg 中。KERN_DEBUG 级别需要 dmesg -n 8 才能看到。
  • 格式化字符串:避免在 printk 中使用 %s 等可能导致内核崩溃的格式。对于指针,用 %p;对于 size_t,用 %zu;对于 pid_t,用 %d
  • 性能考量printk 是同步的,大量日志会严重拖慢系统。在生产代码中,应使用 pr_debug() 并配合 CONFIG_DYNAMIC_DEBUG 进行动态开关。

一个高效的调试习惯是:在每个关键函数入口和出口添加日志:

static int tiny_tty_open(struct tty_struct *tty, struct file *filp) {
    pr_info("tiny_tty: open() called\n");
    // ... 函数主体 ...
    pr_info("tiny_tty: open() returning 0\n");
    return 0;
}

然后使用 dmesg -wH(带时间戳和高亮)实时监控,可以清晰地看到函数的调用序列和耗时。

5.4 从学习到进阶:下一步可以做什么?

掌握了 tiny_serial 和 tiny_tty,你已经站在了TTY驱动开发的坚实地基上。下一步,你可以尝试这些进阶挑战:

  • 为 tiny_serial 添加波特率支持:修改 tiny_serial_set_termios(),解析 tty->termios.c_cflag 中的 CBAUD 位域,并根据不同的 Bxxx 常量,调整 timer 的触发频率,模拟不同波特率下的数据接收速率。
  • 实现 poll() 接口:为 tiny_tty 添加 ->poll() 回调,使其支持 select()epoll(),这是编写高性能服务器程序的基础。
  • 集成真实硬件:将 tiny_serialtimer_handler() 替换为真实的 request_irq(),并在中断处理函数中操作真实的串口寄存器(如 UART_LSR, UART_RBR)。这需要一块开发板(如Raspberry Pi)和对应的硬件手册。
  • 探索线路规程:编写一个简单的自定义线路规程(Line Discipline),例如一个只接受ASCII字母的过滤器,然后用 ldattach 将其绑定到 /dev/ttyTiny0 上。

我个人在实际项目中,曾基于 tiny_serial 的框架,为一款定制的RS485通信模块开发了驱动。最大的收获是,当硬件出现问题时,我能迅速判断是驱动的FIFO管理有bug,还是硬件本身的时序问题。这种“心里有底”的感觉,是任何理论学习都无法给予的。这个资源包的价值,不在于它能帮你写出多么复杂的驱动,而在于它为你提供了一个可以随时“拆开、看看、改改、再装回去”的玩具引擎。当你对这个引擎的每一个螺丝钉都了如指掌时,真正的工业级驱动开发,就不再是遥不可及的梦想了。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:包含两个完整可运行的Linux内核TTY驱动示例——tiny_serial.c和tiny_tty.c,覆盖从设备注册、tty_operations结构体填充,到open/write/read/ioctl等核心接口的实现细节。每个驱动均配套标准Makefile,支持主流内核版本(4.x/5.x),可直接make编译生成.ko模块,支持insmod/rmmod动态加载卸载。源码中清晰体现TTY子系统关键机制:线路规程处理、环形缓冲区管理、字符队列控制、中断响应框架及初始化时序要求。所有文件含详细中文注释,便于理解驱动加载流程、硬件抽象层对接方式以及内核态字符设备的数据流向。压缩包内含已编译的tiny_serial.ko和tiny_tty.ko模块、对应.mod文件、Module.symvers符号表及构建中间文件,开箱即用,适合调试验证与教学演示。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值