从printXX看tty设备(1)tty基础
一、主题
当定位一个问题的时候,最为直观和简单的方法就是在代码的特定位置加上对我们感兴趣的特定数据的打印,这是不依赖其它外部工具(调试器类工具)最简单和直观的方法,这个方法在用户态和内核态调试中都是适用的,就连最经典的C语言程序也是一个printf(“Hello World\n”),可见这个printf是在是程序员居家旅行、杀人灭口必备工具。但是这个print函数总的最终打印显示到一个设备上去,一个电脑启动之后,它可能的显示地方还是很多的,例如嵌入式中最为常见的就是串口,而桌面电脑中则通过显示器(当然我们的AT类型PC也配置了两个串口),并且不排除有些人想把这个输出打印到打印机上,甚至到磁带上也是可以的。此时这个print最终要定位到哪个设备,这个东西可能在一个具体的系统中就要确定好了。或者说一个最为简单的问题,我们的用户态的标准输入和标准输出从哪里来?当然,最为不负责任的答案就是“从父进程继承过来的”。那么当第一个用户态程序init呱呱坠地的时候,它的标准输入又是从哪里来的。
二、终端的由来
早期的电脑是没有这个显示器的,而且电脑是作为一个大型稀缺品种,稀缺的像恐龙,当然体积也像恐龙,所以这个宝贝大家都想染指一下,当时最为简单的办法就是一个电脑让多个用户共享,那是还没有互联网,所以使用的就是串口,和串口对应的就是“终端”打字机,也就是teletype,像我们的"\r\n"就是在当时的teletype设备中就已经有了,这也是之后DOS/Unix/Mac机中不同风格的肇始。也就是一个电脑上布很多的串接口(就像我们现在电脑上的两个串口一样),然后大量的用户每人一根线链接到这个电脑上(当然之后可能还有更远距离的modem,但是也是基于字符或者说字节流)。
用户只有一根线是不行的,还要有个输入和输出设备,也就是一个当时的teletype,这个东西有一个键盘和一个显示屏,但是它不具有CPU那样强大的计算和处理功能,它事实上有两个功能,一个是把用户的输入通过串口发送给主机,另一个不言而喻就是把主机发送过来的数据显示在终端的显示屏上,也就是这样短短的一根线维系了它们之间的关系:终端在这头,主机在那头。
这个发送就没有什么说的了,大致就是一些ASCII码,但是终端的显示器就不仅仅是一个一个字符那么机械的显示了,它已经有了一些智能,也就是它不仅能接受可打印的字符,它还可以接受一些特殊的命令(其实也就是一些特殊的字节)。比方说,如果主机向串口中输入了A,那么终端就规规矩矩的在显示屏上显示一个A,但是终端并不是笨到无可救药,它还可以识别一些控制序列,我们最为感兴趣的可能就是让一些字符高亮显示了,例如在大部分的linux发行版本(fedora core)中,使用ls都会亮红显示压缩文件,其实这就是转义控制序列的功劳(这里虽然是通过VGA显卡显示的,但是这个显卡是为了模拟原始终端特征,因为我们大部分人是没有这种终端的),我们可以自己模拟这个功能:
[tsecer@Harry GccTest]$ cat Colorful.c
#include <stdio.h>
int main()
{
return printf("\e[37;41m colorful \e[m\n");
}
大家如果在linux下编译这个程序,就可以看到它运行的时候中间的colorful是亮红显示的。也就是其中的\e[37;41m 就是高亮显示序列的开始,最后的\e[m是恢复之前的显示。关于更多标准的控制序列,可以参考这个网址http://www.termsys.demon.co.uk/vtansi.htm。还有就是如果想一睹vt100的风采,可以参考wiki的照片http://en.wikipedia.org/wiki/VT100。这里的VT的V并不是Virtual,而是Video,因为这个终端当时就有这么一个霸气的名字,它能够高亮显示一些字符已经是作为图形化了。
三、第一个用户程序init的标准输入/输出设备的确定
start_kernel-->>rest_init----->>>kernel_thread(kernel_init, NULL, CLONE_FS | CLONE_SIGHAND);--->>>kernel_init
/* Open the /dev/console on the rootfs, this should never fail */
if (sys_open((const char __user *) "/dev/console", O_RDWR, 0) < 0)
printk(KERN_WARNING "Warning: unable to open an initial console.\n");
(void) sys_dup(0);
(void) sys_dup(0);
/*
在这里是初始化init标准输出的代码,可以看到,它是首先打开了用户提供的文件系统的/dev/console设备,这个明显的是一个设备文件,也就是用户可以在自己提供的文件系统中告诉内核自己系统用哪个设备作为自己的标准交互设备。然后由于这个进程从来没有打开过文件(即使曾经打开过,也都已经关闭了),所以这里的sys_open返回的文件描述符就是0,而接下来的两个dup就是让第1和2文件描述符和/dev/console相同了。但是当我们满怀期待的打开自己系统的/dev/console的时候,看到的内容是
[tsecer@Harry GccTest]$ ls /dev/console -l
crw-------. 1 root root 5, 1 2011-10-01 09:45 /dev/console
而其中的5 1设备事实上并不是一个具体的设备,而是一个虚拟设备,也就是一个虚拟的设备。可以这样认为,这个5、1就相当于说,我对这个东西不感冒,你怎么设由其它部分决定吧(那这说了不等于没说嘛。其实这里是给了一个用户选择机会,如果你不想让别人决定你的命运,那就自己设置一下呗)。那其它部分怎么设置呢?这就由有比较复杂的流程了。由于语言没有代码给力,所以先上代码(这个代码是之后tty分析的一个基本入口)
#define TTY_MAJOR 4
#define TTYAUX_MAJOR 5
static int tty_open(struct inode * inode, struct file * filp)
…………
if (device == MKDEV(TTYAUX_MAJOR,0)) {这是对/dev/tty设备的特殊处理。
tty = get_current_tty();
if (!tty) {
mutex_unlock(&tty_mutex);
return -ENXIO;
}
driver = tty->driver;
index = tty->index;
filp->f_flags |= O_NONBLOCK; /* Don't let /dev/tty block */
/* noctty = 1; */
goto got_driver;
}
#ifdef CONFIG_VT 这里是对刚才说的最原始的VT终端的模拟。
if (device == MKDEV(TTY_MAJOR,0)) {
extern struct tty_driver *console_driver;
driver = console_driver;
index = fg_console;
noctty = 1;
goto got_driver;
}
#endif
if (device == MKDEV(TTYAUX_MAJOR,1)) {这里是对console设备的处理,也就是5、1设备的处理。
driver = console_device(&index);
if (driver) {
/* Don't let /dev/console block */
filp->f_flags |= O_NONBLOCK;
noctty = 1;
goto got_driver;
}
mutex_unlock(&tty_mutex);
return -ENODEV;
}
这可以看到其中对于5、1设备的处理是通过console_device来获得的,而这个consol_device函数的功能也很简单,它就是通过一个全局变量console_drivers链表中取出第一个成员,把这个设备(驱动决定的设备)作为自己的标准输出。而这个链表是通过register_console来注册的,搜索内核中对这个接口的调用,可以发现其中有不少,大部分是一些串口,例如我们最为常用的linux-2.6.21\drivers\serial\8250.c中通过register_console(&serial8250_console);注册的serial8250_console,还有PC中默认的linux-2.6.21\drivers\char\vt.c: register_console(&vt_console_driver);注册(这里也可以看出,console的注册需要专门的特定数据结构,也就是struct console 结构)。如果你以为系统中这么多console岂不是非常绚烂,那你就错误了。在register_console函数中横亘这这么一条语句
if (!(console->flags & CON_ENABLED))
return;
也就是如果console结构的flags标志如果没有CON_ENABLED,那么这个东西是不能被成功添加到链表中的。然后看看内核中这些注册的console结构,可以发现大家都很谦虚,都没有设置这个结构。这样大家又空欢喜了一场,因为还要继续找这个设备的由来。register_console函数开始还有两段代码,这些代码看起来非常繁琐,但是大致的意思就是:如果用户在内核启动的时候通过console=xxx设置了控制台设备的话,就用这个设备,如果没有设置,那么第一个注册的console即使没有使能这个标志,也将有幸作为console设备。这么看来,这个标志是一个强制标志,而这个注册只是告诉内核:如果需要的时候,我可以。
也就是内核启动的时候,可以通过bootloader给内核添加启动参数,就像用户态程序启动的时候添加一个参数一样。内核启动之后会解析这个命令行参数,从而可以完成和bootloader的交互。由于bootloader可以和用户交互,所以相当于内核和用户交互。例如修改启动参数之类的选择、修改启动盘之类的东东。这个控制台设置是通过console=dev选项设置,例如希望通过第一个串口来作为控制台,可以通过console=/dev/ttyS0来设置内核启动参数,大家可以在运行着的内容中通过 cat /proc/cmdline 来显示bootloader给内核传递的参数。内核对这个的处理位置位于linux-2.6.21\kernel\printk.c:console_setup函数中解析处理。当然,这个选项通常在嵌入式或者一些定制的系统中使用,我们的PC一般没有设置这个选项,而是使用了默认,也就是最后成功注册的那个。在PC系统中,看一下最早注册的接口的执行路径为:
#0 visual_init (vc=0x1000000, num=-1072370742, init=-16777213) at drivers/char/vt.c:678
#1 0xc0a2cfaf in con_init () at drivers/char/vt.c:2658
#2 0xc0a2c259 in console_init () at drivers/char/tty_io.c:3906
#3 0xc09fa1be in start_kernel () at init/main.c:580
#4 0x00000000 in ?? ()
也就是这里的con_init是通过console_initcall(con_init);注册的,通过这个接口注册的函数的执行要遭遇通过init_call注册的接口。因为前者是在start_kernel--->>>console_init
while (call < __con_initcall_end) {
(*call)();
call++;
中调用的,而后者是在init--->>do_basic_setup-->>>do_initcalls中调用的,所以本在先来先得的原则,我们通过linux-2.6.21\drivers\char\vt.c注册的vt_console_driver将会作为init的控制台。但是这个东西又是一个什么设备呢?这个在接下来一篇中说明。