简介:包含两个完整可运行的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_driver、struct tty_port、struct 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_fifo 和 rx_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 里你看不到任何 ioremap、request_irq、inb/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.ko 被 insmod 加载时,内核会检查模块中引用的符号是否在 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_init 到 tty_register_driver
驱动的生命周期始于 module_init(),终于 module_exit()。tiny_serial 和 tiny_tty 的 init 函数是理解内核模块加载时序的绝佳案例。以 tiny_serial 为例,其 tiny_serial_init() 函数执行顺序如下:
-
分配并初始化
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动态创建,而非静态mknod。init_termios的设置决定了新打开设备的默认行为,比如B9600是默认波特率,CS8` 是8位数据位。 -
填充并关联
tty_operations:
c tiny_serial_driver->ops = &tiny_serial_ops;
这行代码建立了驱动与TTY子系统的契约。tiny_serial_ops结构体中的每一个函数指针,都是TTY子系统在特定时刻会调用的“钩子”。例如,当用户执行open("/dev/ttyTinyS0", O_RDWR)时,内核会最终调用tiny_serial_ops->open。 -
初始化并注册
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,导致后续操作崩溃。 -
最终注册驱动:
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_tty 的 write() 更加纯粹,它直接调用 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 实现了最基本的 TCGETS 和 TCSETS,用于获取和设置终端属性(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 有语法错误,错误会出现在 CC 或 LD 行,这时 dmesg 通常不会有帮助,你需要仔细阅读 make 的输出。
4.2 模块加载、卸载与设备节点验证
编译成功后,得到 tiny_serial.ko 和 tiny_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/ttyTinyS0或fuser -v /dev/ttyTinyS0找出占用进程并终止它。
4.3 数据流验证实验:从 echo 到 dmesg
这是学习过程中最令人兴奋的环节——亲眼见证数据在驱动中流动。
实验一: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 API | tiny_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 改为 8 或 4,这是最常见的修复方法。 |
5.2 加载与运行阶段问题排查
问题:insmod 成功,但 /dev/ttyTiny* 设备节点未创建
这是新手最常遇到的问题。根本原因几乎总是 udev 规则未生效。排查步骤如下:
- 确认驱动已注册:
cat /proc/tty/drivers应该包含tiny_serial和tiny_tty的条目。如果没有,说明tty_register_driver()失败,检查dmesg中的错误信息。 - 检查
udev日志:sudo udevadm monitor --subsystem-match=tty,然后sudo insmod tiny_tty.ko。你应该看到类似add /devices/virtual/tty/ttyTiny0 (tty)的事件。如果没有,说明驱动没有发出uevent。 - 手动触发
udev:sudo udevadm trigger --subsystem-match=tty,然后sudo udevadm settle。这会强制udev扫描所有已注册的TTY驱动并创建设备节点。 - 终极方案:手动创建:
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 深度调试技巧:利用 printk 与 dmesg
printk() 是内核驱动的“printf”,是调试的基石。但它的使用有讲究:
- 日志级别:
printk(KERN_INFO "message")中的KERN_INFO是日志级别。级别从高到低为:KERN_EMERG,KERN_ALERT,KERN_CRIT,KERN_ERR,KERN_WARNING,KERN_NOTICE,KERN_INFO,KERN_DEBUG。KERN_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_serial的timer_handler()替换为真实的request_irq(),并在中断处理函数中操作真实的串口寄存器(如UART_LSR,UART_RBR)。这需要一块开发板(如Raspberry Pi)和对应的硬件手册。 - 探索线路规程:编写一个简单的自定义线路规程(Line Discipline),例如一个只接受ASCII字母的过滤器,然后用
ldattach将其绑定到/dev/ttyTiny0上。
我个人在实际项目中,曾基于 tiny_serial 的框架,为一款定制的RS485通信模块开发了驱动。最大的收获是,当硬件出现问题时,我能迅速判断是驱动的FIFO管理有bug,还是硬件本身的时序问题。这种“心里有底”的感觉,是任何理论学习都无法给予的。这个资源包的价值,不在于它能帮你写出多么复杂的驱动,而在于它为你提供了一个可以随时“拆开、看看、改改、再装回去”的玩具引擎。当你对这个引擎的每一个螺丝钉都了如指掌时,真正的工业级驱动开发,就不再是遥不可及的梦想了。
简介:包含两个完整可运行的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符号表及构建中间文件,开箱即用,适合调试验证与教学演示。

273

被折叠的 条评论
为什么被折叠?



