从printXX看tty设备(6)tty框架及串口O_NONBLOCK何时丢失数据
一、内核tty实现
这个模块在内核的实现中占有浓重的一笔,我甚至经常觉得,经常搞的是串口还是网口是嵌入式工程师和网络工程师的一重要区别标志。所以作为一个嵌入式工程师,对这个tty设备接触的比较多,所以感情也比较深一些。在2011年11月份(伟大的圣光棍节月份),我在博客里对tty设备做了一个简单的总结,后来就把这一页揭过去,但是最近又遇到一些东西,感觉还是有必要再补充一下。当时对于tty设备的理解还是比较肤浅的(当然现在依然如此),所以其实没有跳出来看一下这个实现的框架及背后的一些原因。
二、tty设备与VFS接口
1、tty_fops变量中的tty_open/tty_read/tty_write接口簇
这组接口是所有tty设备所共享的一个接口簇,这个主要是对应设备文件和VFS系统接口实现,这就是tty设备和整个Linux内核中虚拟文件系统的一层接口,这组接口是和块设备(例如硬盘/dev/hda之类)的操作接口并列的一个概念;也是通过设备文件查找设备的第一次分层。
这个函数簇的主要功能就是作为所有tty设备的一个入口,它的主要功能就是根据设备号查找到该设备对应的tty_driver,然后在VFS中的struct file 变量的private_data中安装一个struct tty_struct变量,这个变量对于每个具体的tty设备有一个,可以保存会话相关内容以及tty设备终端配置信息等。
为什么说这个是所有tty设备的VFS接口呢?因为所有的tty_driver的注册都是通过tty_register_driver函数来实现的,而这个函数内通过
cdev_init(&driver->cdev, &tty_fops);
将设备的VFS接口注册为tty_fops。
在tty_open接口中,会通过
driver = get_tty_driver(device, &index);
来查找设备文件对应的驱动程序。
2、tty_driver
不同的tty设备会对应不同的tty_driver,一个tty_driver可以对应若干个tty设备。例如虚拟控制台设备可以占到主设备为1,次设备从1到63个设备,而串口则从65开始,而8250串口则可以占据主设备4,次设备从64开始的若干设备号。这些驱动通过tty_register_driver来把自己注册为驱动,在注册的过程中,在一个tty_driver中,它里面明确说明了自己覆盖的设备编号范围
int major; /* major device number */
int minor_start; /* start of minor device number */
int minor_num; /* number of *possible* devices */
int num; /* number of devices allocated */
这些都是驱动在注册的时候确定好的。
3、tty_struct结构
这个是tty对应的struct file中private_data变量指向的一个内容,这个变量的分配是在某个设备文件第一次打开的时候按需分配的。为什么这么做呢?因为很多的tty设备可能一直没有被用到,所以就不需要分配。例如我们刚才说的虚拟tty设备,它虽然占有主设备号4,次设备为1到63个设备编号,但是通常只有前几个作为系统inittab中getty设备的控制台串口,其它之后大部分都是没有使用的,所以没有必要在一开始都分配这些tty_struct,而是在需要的时候分配,这和内核中的Copy On Write的思想一致,都是lazy算法思想。这个结构的分配和初始化流程为:
tty_open--->>>init_dev--->>alloc_tty_struct & initialize_tty_struct
中进行分配,当然在init_dev函数中会判断设备对应的tty_struct是否已经被分配。然后在tty_open中通过
filp->private_data = tty;
将这个新分配的tty_struct实例安装到VFS中struct file的private_data指针中。
4、tty_ldisc
这个现在只见到了对N_TTY类型的链路层的使用,其它的不是很清楚,但是从这个名字上来看应该是一个逻辑的概念,就是说对接收和发送的数据如何额外转化的一个过程,然后将这个转换的结果发送给具体的tty驱动。例如,当我们在串口上输入CTRL+C的时候,是否要把这个组合按键转换为一个SIGINT信号发送给前台回话(而不是在tty的输入队列里放一个CTRL的按键码和一个C的按键码),比方说,是否在接收到一个回车的时候才唤醒对tty的read等待(相对于每接收到一个字符就唤醒)等。这个成员的初始化是在
tty_open--->>>init_dev--->>initialize_tty_struct
tty_ldisc_assign(tty, tty_ldisc_get(N_TTY));
这里的tty_ldisc是个内嵌在tty_struct中的结构,链路规则为console_init函数中注册的
(void) tty_register_ldisc(N_TTY, &tty_ldisc_N_TTY);
5、struct ktermios
这个结构是用户态可以配置的一个接口,对于不同的tty_struct的不同表现具有重要影响。例如,要配置一个tty设备是否回显功能(密码输入的时候是不能回显的),最少接收到多少个字符进行read唤醒等都可以配置,用户态的stty程序可以显示和配置一个tty设备的这些属性。这个结构的分配同样是在init_dev中分配并初始化,而且大部分的tty_driver设备都设置自己的初始配置为tty_std_termios结构,例如串口的初始化为uart_register_driver中
normal->init_termios = tty_std_termios;
三、tty的链路层
这个tty_read接口比较简单,其实主要工作就是转发向了链路层,这个链路层也就是前面说的tty_ldisc_N_TTY的read_chan/write_chan接口.前面可以看到,当使用initialize_tty_struct结构中默认就是用的这个N_TTY链路层。但是系统中还的确存在其他的链路层,例如鼠标消息的发送也是通过自己专用的一个链路层N_MOUSE来实现的。当然还有其他常见但是无缘拜会的一些链路结构,例如N_SLIP,N_HDLC等。如果作为了解,这些内容还是应该看一下,作为一个对比,从而理解这个层次存在的意义,可以没有这方面的应用,精力和智力都不允许,所以咱就不趟这趟水了。
但是链路层的作用还是比较重要的,很多的字符转化都是在这一层来完成的。最为常见的就是当键盘上按下Ctrl+C的时候,此时链路层会向当前tty设备的前台发送一个SIGINT信号,而这个C字符就不会放入接收队列。或者说CTRL+K可以删除当前链路层中所有输入信息,这一点有一个重要的启示和思路,那就是N_TTY链路默认是按照行来进行唤醒的,也就是说,当用户没有按下回车键的时候,所有的输入都将会放在链路层的缓冲区中。这一点对于read的行为影响是很大的,比方说,read要求读入长度为100字符,那么在用户输入下面几个字符
ls
之后回车,这个read调用会马上返回,并且read的返回值为3,缓冲区内为“ls\n”。这里有很多值得注意的事情,之后有机会再展开。
为了说明链路层,这里看一下当我们在键盘上按下CTRL+C组合键之后,这个组合是如何转换为SIGINT被我们接收到的。
对于一个CTRL+C,虽然在我们看来是一个组合键,也就是两次按键,但是在发给链路层之后是一个字符,ASCII码为3(推广情况就是,CTRL+A为1,CTRL+Z为26,那么ASCII码的0还有27到31之间的非键盘显示字符如何打印呢?这个其实从ASCII的排表顺序中也可以得到,那就是第64个字符还是的32个可打印字符和前32个非可打印字符是一一对应的,只是在这些可打印字符加上CTRL既可以得到对应的前32个非可显示字符)。
在串口中输入CTRL+S
linux-2.6.21\drivers\char\n_tty.c
static inline void n_tty_receive_char(struct tty_struct *tty, unsigned char c)
if (I_IXON(tty)) {
if (c == START_CHAR(tty)) {这里的START_CHAR就是CTRL+S,而对于开始则为CTRL+Q,当然这里说的都收默认值,用户可以修改。
start_tty(tty);
return;
}
if (c == STOP_CHAR(tty)) {
stop_tty(tty);
return;
}
}
linux-2.6.21\include\asm-i386\termios.h
/* intr=^C quit=^\ erase=del kill=^U
eof=^D vtime=\0 vmin=\1 sxtc=\0
start=^Q stop=^S susp=^Z eol=\0
reprint=^R discard=^U werase=^W lnext=^V
eol2=\0
*/
#define INIT_C_CC "\003\034\177\025\004\0\1\0\021\023\032\0\022\017\027\026\0"
void stop_tty(struct tty_struct *tty)
{
if (tty->stopped)
return;
tty->stopped = 1;
if (tty->link && tty->link->packet) {
tty->ctrl_status &= ~TIOCPKT_START;
tty->ctrl_status |= TIOCPKT_STOP;
wake_up_interruptible(&tty->link->read_wait);
}
if (tty->driver->stop)
(tty->driver->stop)(tty);
}
以我们常见的伪终端为例,它的驱动pty_ops并没有定义自己的stop方法,所以它只是执行了 tty->stopped = 1操作。当一个串口输入被终止时候,当用户通过tty_write写入数据的时候,它执行的操作为
static int pty_write(struct tty_struct * tty, const unsigned char *buf, int count)
{
struct tty_struct *to = tty->link;
int c;
if (!to || tty->stopped)
return 0;
这里判断如果tty->stopped为1,那么此时发送失败,发送返回值为零,此时就会在write_chan函数中判断这个返回值,其中有
while (nr > 0) {
c = tty->driver->write(tty, b, nr);
if (c < 0) {
retval = c;
goto break_out;
}
if (!c)
break;
b += c;
nr -= c;
}
}
if (!nr)
break;
if (file->f_flags & O_NONBLOCK) {如果串口设置了非阻塞,那么此次打印将会丢失。
retval = -EAGAIN;
break;
四、8520串口驱动丢数据
现在假设说用户没有执行CTRL+S来暂停串口,然后用户态对串口设置了非阻塞模式,然后在物理串口发送的时候何时丢失数据。对于所有的uart设备,它们将会共享一个上层tty驱动,也就是一个tty_operations uart_ops结构,这是一个虚拟的串口控制器,然后对于每个具体的设备,在这个统一的uart设备的管理之上成为一个uart_port,每个具体的端口对应一个结构,例如,对于我们通常的PC上就有两个串口,所以就会有两个对应的uart_port结构,然后每种不同的port有自己对应的uart_ops实现,这样就是一个从VFS到具体串口端口的映射和转换过程。
现在假设用户通过tty_write写入数据的时候,它大致流程为
tty_write--->>>write_chan-->>uart_write--->>>uart_start--->>__uart_start--->>>serial8250_start_tx
其中在__uart_start接口中主要就是将write_chan中传入的数据放入一个uart设备可以识别的环形缓冲区中,等待串口来发送。这里的发送并不是同步的,当从write_chan返回的时候,这些数据并没有发送,串口的发送使用的是中断方式,当缓冲区中的一个数据发送完之后,串口将会产生一个中断,然后在中断程序中再次从缓冲区摘取一个字符放入串口控制器,如此反复。
在uart_write函数中
while (1) {
c = CIRC_SPACE_TO_END(circ->head, circ->tail, UART_XMIT_SIZE);
if (count < c)
c = count;
if (c <= 0)这里如果打印非常多,那么缓冲区的空闲空间为零,此处将会直接返回。也就是在write_chan中返回零,和前一节类似。
break;
memcpy(circ->buf + circ->head, buf, c);
circ->head = (circ->head + c) & (UART_XMIT_SIZE - 1);
buf += c;
count -= c;
ret += c;
}
返回之后如果设置的是非阻塞模式,那么此时打印数据将会丢失,并且从用户空间中返回,并且返回值为-EAGAIN。
五、printk为什么不会丢数据及于write_chan的串口共享问题
当串口作为控制台接口的时候,它并不是通过uart_write这一套接口来实现的,而是通过serial8250_console中注册的绿色通道来完成,在这个结构中,其实现方法为serial8250_console_write。
/*
* First save the IER then disable the interrupts当控制台打印时,此时将会关掉中断,而tty_write使用的就是中断,所以用户打印将暂停。
*/
ier = serial_in(up, UART_IER);
if (up->capabilities & UART_CAP_UUE)
serial_out(up, UART_IER, UART_IER_UUE);
else
serial_out(up, UART_IER, 0);
uart_console_write(&up->port, s, count, serial8250_console_putchar);
static void serial8250_console_putchar(struct uart_port *port, int ch)
{
struct uart_8250_port *up = (struct uart_8250_port *)port;
wait_for_xmitr(up, UART_LSR_THRE);然后逐个字符进行轮询,从而保证发送是同步的。
serial_out(up, UART_TX, ch);
}
有时候我们看到,用户态的printf比printk的显示要慢,也正是这个原因,因为用户态的tty_write是使用中断的异步方式,而内核的printk则是同步方式。