linux tty 终端
作者: baron
个人博客: baron-z.cn
基于网站原因csdn上面的图片有压缩, 如果不是很清楚, 可以到个人博客看原图.
终端是计算机系统中重要的交互工具,从最初的机械式电传打字机到现代的虚拟终端,它承载了计算机发展的许多关键技术。本文将从终端的历史谈起,介绍其发展过程中的重要阶段,接着分析终端驱动的实现原理,最后通过代码实现一个简单的终端接口,帮助读者从理论到实践更好地理解终端的工作原理。希望这篇文章能为对终端技术感兴趣的读者提供一些启发和参考。
一、终端
终端是一种输入输出设备, 常见的终端有显示器, 键盘, 控制台等. 在老一代的机器中呢, 控制台和终端是分开的, 如下所示.
而随着时间的推移控制台已经和显示器合二为一了. 以上描述的是广义的终端设备. 而在计算机内部对于线程来说终端就是 /dev/tty* 和 /dev/consol 所描述的设备节点.
-
- 广义上来说终端是输入输出设备, 显示器键盘
-
- 狭义上来说终端就是 dev/tty* 和 /dev/consol 所描述的设备节点
为什么要反复强调这个, 因为理解这两条概念对理解终端很重要.理解了这个我们在描述终端的时候, 就可以根据上下文来确定对应场景的终端表示的是什么.
二、终端的类型
1、虚拟终端
在计算机刚发展的时候, 计算机是非常昂贵的资源, 并不是所有人都能用得起的, 而 cpu 的性能往往是过剩的. 于是人们就想一台计算机怎么个多个人用. 于是就在一台计算机上接入了多组显示器+键盘.
linux 为每一个用户(一组显示器+键盘)创建一个终端, 让他们能和计算机交互, 这个终端就是虚拟终端(/dev/tty1-/dev/tty63). 注意了, 每一个虚拟终端(狭义终端)都对应一组物理的外设(广义终端).
如图有三个用户, 他们分别使用了三个虚拟终端. 每个用户都可以通过 crtl + alt + fn 切换到对应的虚拟终端(/dev/ttyn). 这个并不是一对一的关系, 也可以多个用户使用一个虚拟终端, 这取决于驱动是否支持这个功能.
2、伪终端 pty
随着计算机网络的发展,用户希望能够远程访问计算机系统. 这种需求催生了远程登录协议和工具,如 Telnet 和 rlogin,它们允许用户通过网络连接到远程计算机.为了支持这些远程访问协议,系统需要一种机制来模拟本地终端,以便远程用户能够像在本地终端一样与计算机进行交互. 于是伪终端 pty 就诞生了. 对应的设备节点 /dev/pts/*
伪终端 PTY 的机制如下所示. 伪终端分为两个部分 master 和 slave. 他们分别对接进程. 两个进程互相不知道对方的存在.他们面对的都是伪终端 pty 设备. 于是他们只需要打开设备, 读设备, 写设备, 关闭设备就行了. 这里 “进程1” 写一个数据, 数据会从主设备发送到从设备. 然后对接从设备的 “进程2” 就能读到数据. 同理也可以反过来.
终端模拟器就是利用伪终端机制实现的的应用程序. 常见的有 xshell, mobaxterm 等等. 下图展示了终端模拟器的通讯流程.
3、串口终端
这个也是因为历史原因导致的, 在很久很久以前. 我们的终端设备显示器和键盘, 都是通过串口来传输数据的. 因此内核的 tty_drvier 就接入了 uart_driver 用来接收串口数据. 这个东西也就一直保留到了现在. 对应的设备节点就是 /dev/ttyS*
.
4、控制终端
控制终端就是当前进程持有的终端 /dev/tty
. linux 中进程都可以和终端(狭义的终端/dev/tty*
)进行交互. 而同一时刻只能有一个进程和控制终端(/dev/tty
) 进行交互. 这个持有控制终端的进程叫做控制进程. 可以通过 lsof /dev/tty
查看当前的控制进程.
lsof /dev/tty
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
zsh 1532 baron 10u CHR 5,0 0t0 9 /dev/tty
可以看出当前的控制进程为 zsh. 而 /dev/tty 关联的终端就是控制终端. 通过命令 tty
就可以知道当前被关联的终端.
tty
/dev/pts/6
当前的控制终端就是伪终端 /dev/pts/6
. 前面描述的虚拟终端(/dev/tty1
)、伪终端(/dev/pty/*
)、串口终端(/dev/ttyS0
). 都能成为控制终端.谁被控制进程持有的 /dev/tty
关联, 谁就是控制终端. 下面伪代码体现了关联的过程.
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>
#include <errno.h>
#include <sys/ioctl.h>
int main() {
int fd_ttyS0, fd_tty;
// 打开 /dev/ttyS0 设备文件
fd_ttyS0 = open("/dev/ttyS0", O_RDWR | O_NOCTTY);
if (fd_ttyS0 < 0) {
perror("Failed to open /dev/ttyS0");
exit(EXIT_FAILURE);
}
// 打开 /dev/tty 获取当前控制终端
fd_tty = open("/dev/tty", O_RDONLY);
if (fd_tty < 0) {
perror("Failed to open /dev/tty");
close(fd_ttyS0);
exit(EXIT_FAILURE);
}
// 断开当前控制终端
if (ioctl(fd_tty, TIOCNOTTY) < 0) {
perror("Failed to disconnect from current control terminal");
close(fd_tty);
close(fd_ttyS0);
exit(EXIT_FAILURE);
}
// 设置 /dev/ttyS0 作为新的控制终端
if (ioctl(fd_ttyS0, TIOCSCTTY) < 0) {
perror("Failed to set /dev/ttyS0 as control terminal");
close(fd_tty);
close(fd_ttyS0);
exit(EXIT_FAILURE);
}
// 关闭文件描述符
close(fd_tty);
close(fd_ttyS0);
// 执行其他操作...
return 0;
}
三、内核中的终端
虚拟终端(/dev/tty1
)、伪终端(/dev/pty/*
)、串口终端(/dev/ttyS0
). 等终端在内核中都是通过 tty_driver 注册进内核的.只是对应的配置略有差别如下图所示.
终端就是通过 tty_register_driver
注册进内核的字符设备, 如下图所示为 tty driver 的链路结构. 图中蓝色的双向箭头线路, 就是数据读写的流程. 整个过程可以分为三个部分, 字符设备(cdev) --> 线路规程(line discipline) --> tty 驱动(tty core) --> 外部. 他们在内核中的代码位置 driver/tty/*
.
终端的读写并不是直接与外部设备相连的。在 TTY 体系中,TTY driver(TTY 核心)负责与外部设备的交互,这个外部设备可能是串口设备, 也可能是虚拟终端(VT)对应的显示器.
写操作先把数据写入到线路规程, 等到合适的时机在发送出去(例如按下enter键). 然而读操作并不是直接从外部设备读取数据,而是从线路规程(line discipline)的缓冲区 (n_tty_data) 中读取。这个缓冲区的数据来源于 TTY driver >的 port->buf 缓冲区,它通过 flush_to_ldisc
函数将数据写入线路规程的缓冲区 n_tty_data. 而TTY driver 的 port->buf 缓冲区又是由 VT/UART/PTY 等驱动通过 tty_insert_flip_char/tty_insert_flip_string
这样的接口主动写入的.
因此, 当用户空间调用 read 时, 实际上是检查线路规程的缓冲区 n_tty_data 中是否有数据。如果有数据, 则返回给用户;如果没有数据, 则会根据 read 的调用参数决定是否阻塞等待数据的到来.
有些串口驱动只要有数据会不断地向线路规程的缓冲区写入数据, 这也解释了为什么在某些情况下, 打开串口设备时可能会出现一堆历史数据. 即使你没有主动读取数据, 驱动程序仍然在后台填充缓冲区, 导致一旦你打开设备并读取时, 这些积累的数据会一股脑地被读取出来.
为了避免这种情况,应用程序在打开串口后,可以使用 tcflush
命令清空线路规程的缓冲区,从而确保读取到的数据是最新的,而不是之前积累的旧数据.
#include <termios.h>
#include <unistd.h>
int fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY);
// 清空输入缓冲区
tcflush(fd, TCIFLUSH);
// 清空输出缓冲区
tcflush(fd, TCOFLUSH);
// 清空输入和输出缓冲区
tcflush(fd, TCIOFLUSH);
那么为什么不直接读写要在中间加个线路规程呢, 因为终端是机器和人打交道的, 在传统的终端场景中, 人们在输入时可能会出现错误, 比如输入错字,然后希望通过退格键删除错误的输入. 或者在输入完一整行数据后按下回车键, 才将数据发送出去。线路规程就负责在用户按下回车键之前,对输入的数据进行缓冲和预处理,例如处理退格键、编辑操作、信号等. 具体来说,线路规程会对输入进行以下处理:
- 字符回显:当用户输入字符时,线路规程会将字符回显在屏幕上。
- 编辑操作:支持退格键删除错误输入的字符,以及其他基本的行编辑功能。
- 信号处理:处理诸如中断信号(如 Ctrl-C)等特殊输入。
- 行缓冲:在用户按下回车键之前,输入的数据被缓冲起来,直到用户按下回车键,线路规程才将整行数据传递给应用程序
1、open
应用程序 open 某个 tty 设备节点, 会调用到对应的 tty_open, 主要调用流程如下.
tty_open()-->
/********************************************* tty_alloc_file ******************************************************************/
tty_alloc_file()--> // 分配一个 tty_file_private 并设置回调 file->private_data = priv;
tty=tty_open_current_tty()--> // 尝试获取设备对应的控制终端
if(!tty){
tty = tty_driver_lookup_tty()--> // 通过设备号返回对应的 tty_driver 和对应的索引下标 index
if(!tty){
retval = tty_reopen(tty); // 增加引用计数 tty->count++
}else{
tty_init_dev()-->
alloc_tty_struct()--> // 创建 tty_struct 并初始化
tty_driver_install_tty()--> // 设置默认的线路规程为 N_tty, 设置默认的 termios, 增加引用计数 tty->count++
tty->port = driver->ports[idx];--> // 获取 port
tty->port->itty = tty; //
tty_ldisc_setup()--> // 回调 n_tty_open,初始化线路规程的 n_tty_data 等变量
}
}
retval = tty->ops->open(tty, filp) // 最后回调 tty_driver 的 open 函数.
大体分为两种情况:
-
- 第一次打开终端会创建一个
tty_struct
设置默认的线路规程 N_tty , 增加tty_struct
的count
计数, 初始化N_tty
线路规程, 最后回调tty->ops->open(tty, filp)
- 第一次打开终端会创建一个
-
- 第二次打开则直接获取第一次创建的
tty_struct
然后增加其引用计数. 然后回调tty->ops->open(tty, filp)
- 第二次打开则直接获取第一次创建的
tty_struct
是 tty_open
时创建的, 他作为中间结构, 在 tty 操作过程中, 通过这个结构体可以找到任何我们想要的 tty 相关的数据结构. 当 count
引用计数为 0 时被销毁.
线路规程中 N_TTY
中的 N 是 NEW 的意思, 新的线路规程, 当前默认的线路规程. 对应的还有 O_TTY
, 也就是 old tty. pty 设备用的就是 o_tty.
2、write
应用程序调用 write 会回调内核的 tty_wrte, 调用流程如下.
tty_write()-->
ld = tty_ldisc_ref_wait(tty)--> // 返回线路规程结构体 tty_ldisc
do_tty_write(ld->ops->write, tty, file, buf, count)-->
ld->ops->write(tty, file, tty->write_buf, size)-->
n_tty_write(tty, file, tty->write_buf, size)-->
tty->ops->write(tty, b, nr) // 最后回调 tty_driver 中注册的回调函数.
write 的流程相对比较简单, 基本就是一路把 buffer 传下去. 果设置了 OPOST,输出字符会经过处理后再发送。如果未设置 OPOST
,则字符会原样发送到终端或设备,不经过任何特殊处理. 常见的处理包括将换行符 \n
自动转换为回车符 \r
和换行符 \n
组合(CR + NL)
,这是大多数终端默认的行为.
3、read
应用程序调用 read 会回调内核的 tty_read. read 的过程总共分为两步, 读取数据、数据返回.
1. 读取数据
这段代码比较长, 分段描述.
static ssize_t n_tty_read(struct tty_struct *tty, struct file *file,
unsigned char __user *buf, size_t nr)
{
struct n_tty_data *ldata = tty->disc_data;
unsigned char __user *b = buf;
DEFINE_WAIT_FUNC(wait, woken_wake_function); // 定义等待队列函数.
int c;
int minimum, time;
ssize_t retval = 0;
long timeout;
int packet;
size_t tail;
c = job_control(tty, file);
if (c < 0)
return c;
if (file->f_flags & O_NONBLOCK) {
if (!mutex_trylock(&ldata->atomic_read_lock))
return -EAGAIN;
} else {
if (mutex_lock_interruptible(&ldata->atomic_read_lock))
return -ERESTARTSYS;
}
down_read(&tty->termios_rwsem);
这段代码就是一些类型检查
minimum = time = 0;
timeout = MAX_SCHEDULE_TIMEOUT; // 设置超时时间
if (!ldata->icanon) { // 原始模式
minimum = MIN_CHAR(tty); // 获取要读的最小字符数 tty->termios.c_cc[VMIN]
if (minimum) {
time = (HZ / 10) * TIME_CHAR(tty); // 计算字符间的时间间隔超时设置, 0.1s * tty->termios.c_cc[VTIME]
if (time)
ldata->minimum_to_wake = 1; // 设置最小唤醒读操作的字符数, 只要读取到 1 个字符或者时间超时就可以唤醒读取操作。
else if (!waitqueue_active(&tty->read_wait) || // 如果没有进程在等待读取数据,或者之前设置的唤醒条件比当前的 VMIN 要求更严格(即,minimum_to_wake 比 minimum 大),那么就将唤醒的字符数设定为 minimum(根据 VMIN 来决定),以便能够及时唤醒等待的进程。
(ldata->minimum_to_wake > minimum))
ldata->minimum_to_wake = minimum;
} else {
// 计算字符间的时间间隔超时设置, 0.1s * tty->termios.c_cc[VTIME]
timeout = (HZ / 10) * TIME_CHAR(tty);
ldata->minimum_to_wake = minimum = 1;
}
}
packet = tty->packet;
tail = ldata->read_tail;
// 将 wait 加入等待队列.
add_wait_queue(&tty->read_wait, &wait);
这段代码会根据原始模式还是规范模式设置唤醒条件
- 原始模式:按行缓冲输入,并处理特殊字符,适合需要行编辑的场景(如命令行)
- 规范模式:不缓冲输入,字符立即传递,无特殊字符处理,适合实时响应输入的应用.
默认情况下无论是原始模式还是规范模式都是一样的设置. 即
timeout = (HZ / 10) * TIME_CHAR(tty);
ldata->minimum_to_wake = 1;
- 表示只要有超过一个字符未被读取则唤醒读取操作.
- TIME_CHAR(tty) 的值一般是 0 , 字符间的时间间隔超时为 0
- 原始模式的
minimum = tty->termios.c_cc[VMIN]
, 而规范模式minimum = 0
while (nr) {
/* First test for status change. */
if (packet && tty->link->ctrl_status) { // 如果设置了pacet且发生链路状态的变化
unsigned char cs;
if (b != buf)
break;
spin_lock_irq(&tty->link->ctrl_lock);
cs = tty->link->ctrl_status;
tty->link->ctrl_status = 0;
spin_unlock_irq(&tty->link->ctrl_lock);
if (tty_put_user(tty, cs, b++)) { // 返回链路状态
retval = -EFAULT;
b--;
break;
}
nr--;
break;
}
在 packet 模式下,如果链路状态发生了变化,则将链路状态 tty->link->ctrl_status
(例如:连接状态、信号状态等)作为数据返回给用户进程。这个用的也比较少,可以不用太过关注.
if (((minimum - (b - buf)) < ldata->minimum_to_wake) &&
((minimum - (b - buf)) >= 1))
ldata->minimum_to_wake = (minimum - (b - buf));
这段代码的目的是根据当前已经读取的数据量来调整最小唤醒字符数 minimum_to_wake
. 如果进程已经读取了一些数据,且剩余要读取的数据量 (minimum - (b - buf)
少于最初设定的唤醒条件,代码会更新 minimum_to_wake
,确保进程在读取到剩余数据后立即唤醒.
if (!input_available_p(tty, 0)) { // 如果没有足够的数据
up_read(&tty->termios_rwsem);
tty_buffer_flush_work(tty->port); // 回调 flush_to_ldisc 刷新线路规程的数据.
down_read(&tty->termios_rwsem);
if (!input_available_p(tty, 0)) { // 刷新玩之后如果数据还是不够
// 远程已经关闭 tty
if (test_bit(TTY_OTHER_CLOSED, &tty->flags)) {
retval = -EIO;
break;
}
// tty 被挂起退出
if (tty_hung_up_p(file))
break;
// 超时退出
if (!timeout)
break;
// 非阻塞退出
if (file->f_flags & O_NONBLOCK) {
retval = -EAGAIN;
break;
}
// 信号中断退出
if (signal_pending(current)) {
retval = -ERESTARTSYS;
break;
}
up_read(&tty->termios_rwsem);
// 进入休眠
timeout = wait_woken(&wait, TASK_INTERRUPTIBLE,
timeout);
down_read(&tty->termios_rwsem);
continue;
}
}
input_available_p()
这个函数用来检测是否还有数据可以读. 返回1 表示有足够的数据可以读, 返回 0 则表示没有足够的数据
- 规范模式下:只有当
canon_head
和read_tail
不相等时(即有完整的一行数据),才返回有数据可读。 - 原始模式下:检查已经提交的数据量是否满足读取的最小要求,通常是 MIN_CHAR(tty),如果满足,则返回有数据可读。
- 进程读取数据的时候如果 n_tty_data 中没有数据,进程会在 TTY 关闭、挂起、超时、非阻塞模式 或 信号中断时退出读取操作.
- 如果 n_tty_data 数据不足且允许阻塞,进程会进入休眠等待数据或超时.
if (ldata->icanon && !L_EXTPROC(tty)) { // 规范模式
retval = canon_copy_from_read_buf(tty, &b, &nr); // 读取数据
if (retval)
break;
} else { // 原始模式
int uncopied;
/* Deal with packet mode. */
if (packet && b == buf) {
if (tty_put_user(tty, TIOCPKT_DATA, b++)) {
retval = -EFAULT;
b--;
break;
}
nr--;
}
// 读取数据
uncopied = copy_from_read_buf(tty, &b, &nr);
uncopied += copy_from_read_buf(tty, &b, &nr);
if (uncopied) {
retval = -EFAULT;
break;
}
}
n_tty_check_unthrottle(tty);
// 读取的数数据大于等于 minimum 返回
if (b - buf >= minimum)
break;
if (time)
timeout = time;
} // while(nr) {
根据原始模式还是规范模式调用不同的接口读取数据, 读取的数据大于等于 minimum
返回.
if (tail != ldata->read_tail)
n_tty_kick_worker(tty);
up_read(&tty->termios_rwsem);
remove_wait_queue(&tty->read_wait, &wait);
if (!waitqueue_active(&tty->read_wait))
ldata->minimum_to_wake = minimum;
mutex_unlock(&ldata->atomic_read_lock);
if (b - buf)
retval = b - buf;
return retval;
}
这段代码是返回时的处理不再赘述.
2. 数据返回
从前面可以知道数据返回就是填充数据到 n_tty_data , 并且唤醒前面的 wait 进程返回数据. 对应接口 tty_flip_buffer_push
.
tty_flip_buffer_push()-->
tty_schedule_flip(port)-->
queue_work(system_unbound_wq, &buf->work)-->
flush_to_ldisc()-->
receive_buf(tty, head, count)-->
disc->ops->receive_buf2()--> // n_tty 默认使用这个
n_tty_receive_buf_common(tty, cp, fp, count, 1); -->
__receive_buf()-->
在 __receive_buf 里面会根据 icanon 和 real_raw 决定数据拷贝的方式, 他们由 termios
三个参数设置, 组合如下.
ICANON | ECHO | ISIG | 模式描述 | 行为 | 备注 |
---|---|---|---|---|---|
1 | 1 | 1 | 规范模式,启用回显和信号处理 | 输入按行缓冲,输入的字符显示在屏幕上,支持信号处理(如 Ctrl+C 发送 SIGINT 信号中断程序)。 | 规范模式 (ICANON = 1, REAL_RAW = 0 ) |
1 | 1 | 0 | 规范模式,启用回显,禁用信号处理 | 输入按行缓冲,字符显示在屏幕上,但 Ctrl+C 等信号无法中断程序。 | 规范模式 (ICANON = 1, REAL_RAW = 0 ) |
1 | 0 | 1 | 规范模式,禁用回显,启用信号处理 | 输入按行缓冲,输入的字符不会显示在屏幕上,但支持 Ctrl+C 等信号中断功能。 | 规范模式 (ICANON = 1, REAL_RAW = 0 ) |
1 | 0 | 0 | 规范模式,禁用回显和信号处理 | 输入按行缓冲,输入的字符不会显示在屏幕上,且 Ctrl+C 等信号无效。 | 规范模式 (ICANON = 1, REAL_RAW = 0 ) |
0 | 1 | 1 | 非规范模式,启用回显和信号处理 | 输入字符立即传递,不需要按回车,字符显示在屏幕上,且支持 Ctrl+C 信号中断功能。 | 非规范模式 (ICANON = 0, REAL_RAW = 0 ) |
0 | 1 | 0 | 非规范模式,启用回显,禁用信号处理 | 输入字符立即传递,字符显示在屏幕上,但信号处理功能(如 Ctrl+C )无效。 | 非规范模式 (ICANON = 0, REAL_RAW = 0 ) |
0 | 0 | 1 | 非规范模式,禁用回显,启用信号处理 | 输入字符立即传递,字符不显示在屏幕上,但支持 Ctrl+C 信号中断功能。 | 部分原始模式 (ICANON = 0, REAL_RAW = 0 ) |
0 | 0 | 0 | 非规范模式,禁用回显和信号处理(原始模式) | 输入字符立即传递,字符不显示在屏幕上,且 Ctrl+C 等信号无效。 | 原始模式 (ICANON = 0, REAL_RAW = 1 ) |
3. 常见的 termios
termios 就是控制线路规程的规则, 一般驱动设置一个默认值 tty_std_termios
, 应用可以根据自己的需要做修改.
标志类别 | 标志名 | 含义描述 |
---|---|---|
输入标志 (`c_iflag`) | ICRNL | 将回车 (`CR`) 转换为换行 (`NL`)。 |
IXON | 启用软件流控制(XON/XOFF)。 |
四、编程实验
有了前面的知识, 我们就来自己写一个我们的终端驱动程序. 这个终端的功能很简单就是输入字符立即传递, 但是需要能够响应 crtl + c 等操作. 因此就是 ICANON = 0, REAL_RAW = 0(非规范模式). 驱动程序如下.
#include <linux/module.h>
#include <linux/tty.h>
#include <linux/tty_flip.h>
#include <linux/init.h>
#include <linux/tty_driver.h>
#include <linux/slab.h>
#define DRIVER_NAME "my_tty" // 驱动程序名称
#define TTY_DRIVER_NAME "ttyMY" // tty 设备名, 即 dev/ttyMY0 设备节点名称
#define TTY_MINORS 1 // 设备的次设备号数量
struct my_tty {
struct tty_port *port; // tty 端口结构体
struct tty_driver *driver; // tty 驱动结构体
struct device *dev;
};
static struct my_tty *my_tty;
static int my_tty_open(struct tty_struct *tty, struct file *filp)
{
printk("my_tty: Device opened\n");
return tty_port_open(tty->port, tty, filp);
}
static void my_tty_close(struct tty_struct *tty, struct file *filp)
{
printk("my_tty: Device close, tty->count: %d\n", tty->count);
tty_port_close(tty->port, tty, filp);
}
static int my_tty_write(struct tty_struct *tty, const unsigned char *buf, int count)
{
int i;
for (i = 0; i < count; i++) {
printk("%c", buf[i]);
tty_insert_flip_char(tty->port, buf[i], TTY_NORMAL); // 插入字符到 tty 缓存
}
printk("\n%s count:%d \n", __func__, count);
if (count)
tty_flip_buffer_push(tty->port); // 上报数据
return count; // 返回写入的数据字节数
}
// 判断 tty 是否有空间写入数据
static int my_tty_write_room(struct tty_struct *tty)
{
if (tty->stopped)
return 0;
return tty_buffer_space_avail(tty->port); // 返回当前 tty 缓冲区剩余空间
}
static const struct tty_operations my_tty_ops = {
.open = my_tty_open,
.close = my_tty_close,
.write = my_tty_write,
.write_room = my_tty_write_room,
};
static const struct tty_port_operations null_ops = { };
// 注册 tty 驱动
static int my_tty_driver_register(void)
{
int retval;
my_tty = kzalloc(sizeof(*my_tty), GFP_KERNEL);
if (!my_tty)
return -ENOMEM;
my_tty->port = kzalloc(sizeof(*my_tty->port), GFP_KERNEL);
if (!my_tty->port) {
kfree(my_tty);
return -ENOMEM;
}
my_tty->driver = tty_alloc_driver(TTY_MINORS, TTY_DRIVER_RESET_TERMIOS);
if (!my_tty->driver) {
kfree(my_tty->port);
kfree(my_tty);
return -ENOMEM;
}
// 初始化 tty 驱动的各个字段
my_tty->driver->driver_name = DRIVER_NAME; // 驱动名称
my_tty->driver->name = TTY_DRIVER_NAME; // tty 设备前缀
my_tty->driver->major = 0; // 动态分配主设备号
my_tty->driver->type = TTY_DRIVER_TYPE_SERIAL; // 设置为串行设备
my_tty->driver->subtype = SERIAL_TYPE_NORMAL; // 常规串行类型
my_tty->driver->flags = TTY_DRIVER_REAL_RAW | TTY_DRIVER_DYNAMIC_DEV; // 设置设备标志
my_tty->driver->init_termios = tty_std_termios; // 设置初始终端参数
my_tty->driver->init_termios.c_cflag = B9600 | CS8 | CREAD | HUPCL | CLOCAL; // 9600 波特率,8 位数据位,启用接收,挂起时关闭线路,忽略调制解调器状态
my_tty->driver->init_termios.c_ispeed = 9600; // 输入速度
my_tty->driver->init_termios.c_ospeed = 9600; // 输出速度
tty_set_operations(my_tty->driver, &my_tty_ops); // 设置驱动的操作集
// 注册 tty 驱动
retval = tty_register_driver(my_tty->driver);
if (retval) {
put_tty_driver(my_tty->driver);
kfree(my_tty->port);
kfree(my_tty);
return retval;
}
// 初始化 tty 端口
tty_port_init(my_tty->port);
my_tty->port->ops = &null_ops; // 使用空操作集
my_tty->driver->ports[0] = my_tty->port; // 将端口绑定到驱动
my_tty->dev = tty_register_device(my_tty->driver, 0, NULL);
if (IS_ERR(my_tty->dev)) {
retval = PTR_ERR(my_tty->dev);
tty_unregister_driver(my_tty->driver);
put_tty_driver(my_tty->driver);
kfree(my_tty->port);
kfree(my_tty);
return retval;
}
printk("My TTY driver loaded. Major number: %d\n", my_tty->driver->major);
return 0;
}
static void my_tty_driver_unregister(void)
{
tty_unregister_driver(my_tty->driver);
put_tty_driver(my_tty->driver);
tty_port_destroy(my_tty->port);
kfree(my_tty->port);
kfree(my_tty);
printk("My TTY driver unloaded.\n");
}
static int __init my_tty_init(void)
{
return my_tty_driver_register();
}
static void __exit my_tty_exit(void)
{
my_tty_driver_unregister();
}
module_init(my_tty_init);
module_exit(my_tty_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("baron");
MODULE_DESCRIPTION("a simple tty driver");
应用程序编写如下:
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include <termios.h>
#define DEVICE "/dev/ttyMY0"
#define BUFFER_SIZE 1024
int main() {
int fd;
char write_buf[] = "Hello, this is a test from user space!";
char read_buf[BUFFER_SIZE];
int bytes_written, bytes_read;
struct termios tty;
// 打开设备文件
fd = open(DEVICE, O_RDWR | O_NOCTTY);
if (fd < 0) {
perror("Failed to open the device");
return errno;
}
printf("Opened device: %s\n", DEVICE);
/********************************* 关键代码设置 termios ********************************/
// 获取当前终端设置
if (tcgetattr(fd, &tty) != 0) {
perror("Failed to get terminal attributes");
close(fd);
return errno;
}
// 设置为非规范模式,保留回显和信号处理
tty.c_lflag &= ~ICANON; // 关闭规范模式(ICANON = 0)
tty.c_lflag &= ~ECHO; // 关闭回显
tty.c_lflag |= ISIG; // 启用信号处理(Ctrl+C)
// 应用新的终端设置
if (tcsetattr(fd, TCSANOW, &tty) != 0) {
perror("Failed to set terminal attributes");
close(fd);
return errno;
}
/********************************* 关键代码设置 termios end ********************************/
// 向设备写入数据
printf("Writing to the device: %s\n", write_buf);
bytes_written = write(fd, write_buf, strlen(write_buf));
if (bytes_written < 0) {
perror("Failed to write to the device");
close(fd);
return errno;
}
printf("Wrote %d bytes to the device\n", bytes_written);
// 读取设备数据
printf("Reading from the device...\n");
bytes_read = read(fd, read_buf, BUFFER_SIZE);
if (bytes_read < 0) {
perror("Failed to read from the device");
close(fd);
return errno;
}
printf("Read %d bytes: %.*s\n", bytes_read, bytes_read, read_buf);
// 关闭设备文件
close(fd);
printf("Closed device: %s\n", DEVICE);
return 0;
}
验证结果:
# tty_test
Opened device: /dev/ttyMY0
Writing to the device: Hello, this is a test from user space! // 打印写入数据
Wrote 38 bytes to the device
Reading from the device...
Read 38 bytes: Hello, this is a test from user space! // 打印读出数据
Closed device: /dev/ttyMY0