从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的控制台。但是这个东西又是一个什么设备呢?这个在接下来一篇中说明。
从printXX看tty设备(2)VGA显示模拟
一、虚拟终端模拟的问题
前面曾经说过,所谓控制台是对tty设备的一种模拟。tty和主机之间就一根线,所有的交互都在这条串行线上一个bit一个bit的交互,可以看做是“竹筒倒豆子”--直来直去的模式。进一步说,主机不能(也没有义务)直接控制tty设备上的显示设备(比如显示设备对应的内存、显示控制寄存器等坐落于终端上等组件),虽然主机可以控制自己一端tty设备的数据发送和接收。
现在使用显卡来模拟一个终端,此时为了兼容之前的功能,当我们模拟一个终端的时候,主机要在自己的本地显示器上显示出指定的效果,比如说高亮一些字符,移动光标,滚屏的操作。一个用户态的shell使用的还是终端的协议,就是向一个串口中发送bit流。但是对于主机上的显卡来说,它并不是一个真正的终端命令解释器,甚至可以看到,在VGA显卡中,如果要在屏幕上高亮一个字符,是需要设置这个字符的属性byte。这里的编程模式和tty设备的模式有截然不同的接口和实现,用户需要且只需要将这个属性byte和ascii值写入内存中的指定区域,从而由显示器来自动的显示出来,这个内存区就是PC中著名的“BIOS空洞”。对应于qemu模拟的设备,其地址从0xb8000开始,到0xc0000结束,这么长的地址作为显卡内存。当我们需要显示器显示某个ASCII字符的时候,就向这片内存中写入该字符对应的ASCII码的值,显卡会根据自己ROM中的字模将这个字符显示到显示器上。
总之,当使用显卡模拟终端的时候,需要内核将终端协议转换为显示器内存操作指令,从而相当于将终端的解析和显示功能放在了自己的显卡上来完成。
另外一个问题就是输入的问题,当使用真正的终端的时候,用户的输入来自串口,使用PC模拟终端的时候,此时系统一般只有一个键盘,此时键盘消息需要发送给串口的读入者,从而实现和使用者的交互,这个内核同样需要考虑。
二、显示系统的初始化
linux-2.6.21\arch\i386\kernel\setup.c:setup_arch()#ifdef CONFIG_VT#if defined(CONFIG_VGA_CONSOLE)
if (!efi_enabled || (efi_mem_type(0xa0000) != EFI_CONVENTIONAL_MEMORY))
conswitchp = &vga_con ;
#elif defined(CONFIG_DUMMY_CONSOLE)
conswitchp = &dummy_con;
#endif
#endif
此处初始化了一个全局变量,也就是conswitchp指针,这个指针就是指向了控制台实现(内核成为控制台切换 console switch,因为系统的控制台可以在运行时变化)。当该变量初始化之后,在con_init函数中将会调用者这里注册的指针中对应的start_up实现:
if (conswitchp)
display_desc = conswitchp->con_startup();
反过来看上面注册的vga_con中con_startup指针指向的为
static const char *vgacon_startup(void),对于qemu的运行中,此处走的流程为
} else {
/* If not, it is color. */
vga_can_do_color = 1;
vga_vram_base = 0xb8000;
vga_video_port_reg = VGA_CRT_IC;
vga_video_port_val = VGA_CRT_DC;
if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {
int i;
vga_vram_size = 0x8000;这两个大小很重要,将会在这个文件中共享,该文件中很多函数会引用这文件静态变量。
这里设置了两个重要的全局变量,一个是vga内存的起始物理地址,一个是这个显卡区的大小,分别为0xb8000和0x8000,刚好到0xc0000结束。由于这里所说的地址都是物理地址,而内核明显都是使用逻辑地址的,所以同样要把这个物理地址转换为逻辑地址,所以在该函数中有一个转换操作
vga_vram_base = VGA_MAP_MEM(vga_vram_base, vga_vram_size);这个转换起始比较简单,直接是物理地址加上0xc0000000.
vga_vram_end = vga_vram_base + vga_vram_size;
例如,在初始化vc结构中显卡地址的时候,将会执行下面的代码
static int vgacon_set_origin(struct vc_data *c)
{
if (vga_is_gfx || /* We don't play origin tricks in graphic modes */
(console_blanked && !vga_palette_blanked)) /* Nor we write to blanked screens */
return 0;
c->vc_origin = c->vc_visible_origin = vga_vram_base;
vga_set_mem_top(c);
vga_rolled_over = 0;
return 1;
}
三、串口协议模拟
当我们通过printf向标准输出中打印一个字符串的时候,经过的调用连为
#0 do_con_trol (tty=0x296, vc=0xcf986054, c=658) at drivers/char/vt.c:1546
#1 0xc03ea1dd in do_con_write (tty=0xcf986000,
buf=0xcfe15df1 "\b\234", <incomplete sequence \317>, count=0) at drivers/char/vt.c:2135
#2 0xc03eab1c in con_put_char (tty=0xcf986000, ch=13 '\r') at drivers/char/vt.c:2449
#3 0xc03d50de in opost (c=10 '\n', tty=0xcf986000) at drivers/char/n_tty.c:277
#4 0xc03d8e75 in write_chan (tty=0xcf986000, file=0xcfea7a80,
buf=0xcf9c0800 "\nPlease press Enter to activate this console. ", nr=46)
at drivers/char/n_tty.c:1468
#5 0xc03d0485 in do_tty_write (count=46,
buf=0x81bd854 "\nPlease press Enter to activate this console. ", file=0xcfea7a80,
tty=0xcf986000, write=0xc03d8bec <write_chan>) at drivers/char/tty_io.c:1746
#6 tty_write (count=46, buf=0x81bd854 "\nPlease press Enter to activate this console. ",
file=0xcfea7a80, tty=0xcf986000, write=0xc03d8bec <write_chan>)
at drivers/char/tty_io.c:1806
#7 0xc01bf5cd in vfs_write (file=0xcfea7a80,
buf=0x81bd854 "\nPlease press Enter to activate this console. ", count=46, pos=0xcfe15f84)
at fs/read_write.c:330
#8 0xc01bf7d1 in sys_write (fd=1,
buf=0x81bd854 "\nPlease press Enter to activate this console. ", count=46)
at fs/read_write.c:383
模拟一下对于 \e[34;41m这个序列的内核解析过程:
do_con_trol中
case 27:
vc->vc_state = ESesc; 这只这个控制台当前状态为ESesc。
return;
……
switch(vc->vc_state) {
case ESesc:
vc->vc_state = ESnormal;
switch (c) {
case '[':
vc->vc_state = ESsquare;
return;
……
case ESsquare:
for (vc->vc_npar = 0; vc->vc_npar < NPAR; vc->vc_npar++)
vc->vc_par[vc->vc_npar] = 0;
vc->vc_npar = 0;
vc->vc_state = ESgetpars;
if (c == '[') { /* Function key */
vc->vc_state=ESfunckey;
return;
}
vc->vc_ques = (c == '?');
if (vc->vc_ques)
return;注意:这里并没有返回,根据case的规则,没有break将会继续执行,所以将会执行到接下来的ESgetpars序列。
case ESgetpars:
if (c == ';' && vc->vc_npar < NPAR - 1) {这里通过分号来区分不同的参数。
vc->vc_npar++;
return;
} else if (c>='0' && c<='9') {
vc->vc_par[vc->vc_npar] *= 10;
vc->vc_par[vc->vc_npar] += c - '0';均为十进制数,不识别十六进制数。
return;
} else
vc->vc_state = ESgotpars; 这里同样没有返回,继续执行接下来的ESgotpars分支。
case ESgotpars:
vc->vc_state = ESnormal;
switch(c) {
……
case 'm':
if (vc->vc_ques) {注意:这里我们来说,这个条件并不满足,这个vc_ques是在前面遇到'?'的时候设置的,由于没有这个字符,所以这里是不满足的,不会走这个分支。
clear_selection();
if (vc->vc_par[0])
vc->vc_complement_mask = vc->vc_par[0] << 8 | vc->vc_par[1];这里对应的是查询标志。
else
vc->vc_complement_mask = vc->vc_s_complement_mask;
return;
}
break;这个break将会跳转到下面的位置
……
if (vc->vc_ques) {
vc->vc_ques = 0;
return;
}
switch(c) {
……
case 'm':
csi_m(vc);
return;
在sci_m中
default:
if (vc->vc_par[i] >= 30 && vc->vc_par[i] <= 37)可以看到,30--37作为前台颜色,
vc->vc_color = color_table[vc->vc_par[i] - 30]
| (vc->vc_color & 0xf0);
else if (vc->vc_par[i] >= 40 && vc->vc_par[i] <= 47)40--47作为后台背景颜色,然后设置到字面的属性中。
vc->vc_color = (color_table[vc->vc_par[i] - 40] << 4)
| (vc->vc_color & 0x0f);
break;
}
update_attr(vc);设置入属性成员中。
static void update_attr(struct vc_data *vc)
{
vc->vc_attr = build_attr(vc, vc->vc_color, vc->vc_intensity, vc->vc_blink, vc->vc_underline, vc->vc_reverse ^ vc->vc_decscnm);
vc->vc_video_erase_char = (build_attr(vc, vc->vc_color, 1, vc->vc_blink, 0, vc->vc_decscnm) << 8) | ' ';
}
当显示一个字符的时候,在static int do_con_write(struct tty_struct *tty, const unsigned char *buf, int count)中将会向制定位置显示字符,这里的写入操作是通过scr_writew来实现的,从这里可以看到,其中有对vc->vc_attr的使用。从这里我们可以看到的是,对于显存,每个字符本身占用一个字节的ASCII码,然后紧邻的一个byte是这个字符的属性标志。
scr_writew(himask ?
((vc->vc_attr << 8) & ~himask) + ((tc & 0x100) ? himask : 0) + (tc & 0xff) :
(vc->vc_attr << 8) + tc,
(u16 *) vc->vc_pos);
最后看一下这个scr_writew的实现
#define scr_writew(val, addr) (*(addr) = (val))
由于前面调用的时候强制转换为了 u16*类型,所以这里的复制是一个short类型的赋值。结合前面的显卡初始化方法就可以知道,当前的VGA显卡显示的时候是将真正希望显示的ASCII码和对应的属性直接写入内存来显示的。
四、显卡编程的一个基础
看来intel是比较喜欢这样的一个硬件编程模型:使用两个寄存器,一个是地址寄存器,专门用来写地址,或者说用来作为寄存器选择寄存器,然后另一个地址作为数据寄存器。编程时首先向地址选择寄存器中写入将要操作的寄存器,然后从另一个数据寄存器中读出这个值。这一点在intel的IOAPIC和PCI系列中均有体现。大家可以理解为C中的指针就好了,虽然这里有点绕。
在VGA中,这两个寄存器分别为
/* VGA index register ports */
#define VGA_CRT_IC 0x3D4 /* CRT Controller Index - color emulation */
/* VGA data register ports */
#define VGA_CRT_DC 0x3D5 /* CRT Controller Data Register - color emulation */
例如
static inline void vga_set_mem_top(struct vc_data *c)
{
write_vga(12, (c->vc_visible_origin - vga_vram_base) / 2);
}
这里还没有涉及VT的另一个重要部分,就是和显示对应的就是输入,也就是PC的键盘处理,对应的就是tty的read接口,在接下来一篇中讨论。
从printXX看tty设备(3)键盘输入处理
从printXX看tty设备(4)伪终端
一、伪终端的意义
在计算机中,有很多的虚拟技术,使用纯软件的技术来模拟一个硬件设备。例如,使用一个qemu来模拟一个计算机系统、使用tun来模拟一个网卡。归根到底,这些虚拟的原因在于兼容,兼容就是后来的实现要以不修改已有实现为前提。就像intel的指令集和windows的API一样,这里的内容就只能增加不能减少。因为减少之后就以为着之前发布的一个可执行文件或者代码在新的平台上无法使用。反过来说,一个优秀的软件框架,从设计之初就应该考虑到虚拟化的实现,例如Linux内核中的VFS系统,它就是要求你只要提供某些接口,在上层就可以把这个结构当做一个文件系统来操作。所以内核中所有的socket管理可以放在一个独立的文件系统中,伪终端的从设备也可以作为一个pts文件系统,而所有的block设备则可以通过一个bdev文件系统来表示和管理。
对于伪终端来说。我们可以认为它基本是需要一个交互的操作界面,而这个操作的基础上有一个重要的概念就是termios结构,这个接口控制了终端设备输入对于接受者的特殊意义。在计算机的早期,终端就是通过串口线或者电话线连接主机和终端,此时的串口通讯还是比较直观的。但是之后出现了计算机网络,为了利用计算机的强大网络能力,摆脱串口线或者电话线的距离限制,自然而然的希望通过网络来远程操作计算机(WIndows下VNC和远程桌面也是网络远程控制,但是是基于GUI形式),这就是telnetd和ssh之类工具的由来。但是telnetd它们本身并不进行命令行的解释,把shell的功能直接和网络协议耦合明显不是一个好的主意。解决的思路就是:shell是需要一个tty设备,而且是需要把这个设备作为自己的标准输入,但是shell本身不具有网络处理能力,那么这个网络处理和tty的创建就可以由telnetd来代劳。这样,telnetd的功能就是使用socket来适配一个tty设备给shell,从而架起一座通讯的桥梁。在本机系统中,我们通过GUI界面启动的shell,它的输出已经不能直接写入显存,因为整个显存的内容已经由窗口系统管理。虽然bash以为自己独占一个终端,但是事实上它只是众多窗口(相对emacs、vi、KDE对话框中的一个)。这就相当于《霍顿与无名氏》中的市长,沉醉在自己的世界之中,事实上自己只是这个世界普通的一个花粉。
二、telnetd的busybox实现
可以看到一个busybox-1.14.2\networking\telnetd.c中对于一个telnet回话的表示结构为/* Structure that describes a session */
struct tsession {
struct tsession *next;
pid_t shell_pid;派生的shell进程的pid
int sockfd_read, sockfd_write, ptyfd;socket套接口以及伪终端对应的文件描述符。
……
};
前面说过,telnetd就是通过 套接口+伪终端 来实现一个远程控制,所以其中的socket和伪终端的创建就是必不可少的基础了
创建套接口文件,这里的套接口侦听端口号就是大名鼎鼎的23号端口,这里将会创建一个侦听套接口,用来连接客户端发起的telnet连接。
int FAST_FUNC create_and_bind_stream_or_die(const char *bindaddr, int port)
{
return create_and_bind_or_die(bindaddr, port, SOCK_STREAM);
}
伪终端的创建是通过make_new_session---->>>xgetpty--->>>>p = open("/dev/ptmx", O_RDWR);创建。之后telnetd就非常贤惠的将两个东西粘合在一起,自己负责在两个功能之间进行通讯的处理。可以认为是中美邦交正常化之前印度在之间起得调和、通话作用。这也就是“策略”工具存在的意义。设备创建之后,还需要让他们同步起来,这个同步就是通过select系统调用来实现的,在telnetd中,其实现为
count = select(maxfd + 1, &rdfdset, &wrfdset, NULL, NULL);
也就是telnetd负责串口和伪终端之间的迎来送往工作,因为设备必将只是设备,它没有策略,特别是两种不同的设备,它们之间的同步更需要有人来帮助。
通过查看文件系统可以知道这个ptmx对应的设备为
[tsecer@Harry signal]$ ls /dev/ptmx -l
crw-rw-rw-. 1 root tty 5, 2 2011-11-20 20:53 /dev/ptmx
也就是主设备号为5,此设备号为2的一个设备
三、内核中ptmx的实现
#define TTYAUX_MAJOR 5
linux-2.6.21\drivers\char\tty_io.c:tty_init
#ifdef CONFIG_UNIX98_PTYS
cdev_init(&ptmx_cdev, &ptmx_fops);
if (cdev_add(&ptmx_cdev, MKDEV(TTYAUX_MAJOR, 2), 1) ||
register_chrdev_region(MKDEV(TTYAUX_MAJOR, 2), 1, "/dev/ptmx") < 0)
panic("Couldn't register /dev/ptmx driver\n");
device_create(tty_class, NULL, MKDEV(TTYAUX_MAJOR, 2), "ptmx");
#endif
注意,register_chrdev_region(MKDEV(TTYAUX_MAJOR, 2), 1, "/dev/ptmx") 中的/dev/ptmx和用户态的文件路径没有任何必然关系,事实上,你可以在这里注册为/root/ptmx都可以,这个只是为了注记及内核维护。事实上它只显示在了/proc/devices文件中
[tsecer@Harry ~]$ cat /proc/devices
Character devices:
1 mem
4 /dev/vc/0
4 tty
4 ttyS
5 /dev/tty
5 /dev/console
5 /dev/ptmx
7 vcs
当用户态的telnetd打开ptmx的时候,也就是通过VFS之后执行了ptmx_fops--->>>ptmx_open,我为什么要在这里说经过虚拟文件系统到达这里呢?因为这个ptmx是一个比较特殊的打开,它经过的是文件系统的open系统调用,从而它会向用户返回一个文件描述符id。但是我们知道,伪终端总是成对出现的,你这里只返回了一个fd,另一个fd怎么办,代表设备另一端的描述符怎么得到呢?为什么需要两个文件描述符呢?因为telnetd需要一个,用来和shell通讯,而shell一侧同样需要一个,套接口还需要两个呢!这里和管道实现做一个对比,管道也是需要返回两个文件描述符,但是管道没有通过普通的文件系统实现,而是有一个奢侈的方法来实现,那就是它自己使用了一个专门的API,pipe系统调用,从而可以一次返回两个文件描述符。
这一点是通过万能的ioctl来实现的,虽然底层看起来有些猥琐,但是也算是剑走偏锋。具体实现为
static int pty_unix98_ioctl(struct tty_struct *tty, struct file *file,
unsigned int cmd, unsigned long arg)
{
switch (cmd) {
case TIOCSPTLCK: /* Set PT Lock (disallow slave open) */
return pty_set_lock(tty, (int __user *)arg);
case TIOCGPTN: /* Get PT Number */
return put_user(tty->index, (unsigned int __user *)arg);
}
return -ENOIOCTLCMD;
}
话不多少,收回话题继续说打开的艰辛历程。没有什么比调用连更能展现调用层次关系了,所以这里依然先放一下ptmx_open的调用连
#0 pty_open (tty=0xc09ede80, filp=0xc09ede80) at drivers/char/pty.c:198
#1 0xc03d244b in ptmx_open (inode=0xcf9d41ec, filp=0xcff44ea0) at drivers/char/tty_io.c:2677
#2 0xc01c3bce in chrdev_open (inode=0xcf9d41ec, filp=0xcff44ea0) at fs/char_dev.c:399
#3 0xc01bdc46 in __dentry_open (dentry=0xcf9e7744, mnt=0xc126c7a0, flags=32768, f=0xcff44ea0,
open=0xc01c3967 <chrdev_open>) at fs/open.c:700
#4 0xc01bdf99 in nameidata_to_filp (nd=0xcf92df04, flags=32768) at fs/open.c:826
#5 0xc01bde0a in do_filp_open (dfd=-100, filename=0xcf9d0000 "/dev/ptmx", flags=32768, mode=0)
at fs/open.c:761
#6 0xc01be324 in do_sys_open (dfd=-100, filename=0xbfd77f96 "/dev/ptmx", flags=32768, mode=0)
at fs/open.c:962
#7 0xc01be41f in sys_open (filename=0xbfd77f96 "/dev/ptmx", flags=32768, mode=0)
at fs/open.c:983
在ptmx_open函数中
mutex_lock(&tty_mutex);
retval = init_dev(ptm_driver, index, &tty);
mutex_unlock(&tty_mutex);
if (retval)
goto out;
set_bit(TTY_PTY_LOCK, &tty->flags); /* LOCK THE SLAVE */
filp->private_data = tty;
file_move(filp, &tty->tty_files);
retval = -ENOMEM;
if (devpts_pty_new(tty->link))
goto out1;
check_tty_count(tty, "tty_open");
retval = ptm_driver->open(tty, filp);
其中的(devpts_pty_new(tty->link))从pts文件系统(伪终端文件系统)中分配了一个新的inode(因此也就有了对应的一个pts设备)。关于pts文件系统。
[tsecer@Harry ~]$ mount
/dev/mapper/vg_harry-lv_root on / type ext4 (rw)
proc on /proc type proc (rw)
sysfs on /sys type sysfs (rw)
devpts on /dev/pts type devpts (rw,gid=5,mode=620)
devpts_pty_new
{
……
dev_t device = MKDEV(driver->major, driver->minor_start+number);
……
init_special_inode(inode, S_IFCHR|config.mode, device);
这个deriver->major的初始化ptmx_open--->>>init_dev
if (driver->type == TTY_DRIVER_TYPE_PTY) {
o_tty = alloc_tty_struct();
if (!o_tty)
goto free_mem_out;
initialize_tty_struct(o_tty);
o_tty->driver = driver->other;
而ptmx则在unix98_pty_init中初始化,
ptm_driver->other = pts_driver;
四、ptmx打开之后
在打开之后,可以通过C库封装的ptsname_r来得到open创建的孪生tty的另一个,C中对该函数的实现为glibc-2.7\sysdeps\unix\sysv\linux\ptsname.c
int
__ptsname_r (int fd, char *buf, size_t buflen)
{
…………
if (__ioctl (fd, TIOCGPTN, &ptyno) == 0)
之后从tty的文件操作通过unix98_pty_init中注册的
ptm_driver->other = pts_driver;
tty_set_operations(ptm_driver, &pty_ops);
……
pts_driver->other = ptm_driver;
tty_set_operations(pts_driver, &pty_ops);
两者在这里实现了最终的交汇,也就是相当于伪终端设备使用的最底层的驱动实现。主设备的驱动注册位于ptmx_open-->>init_dev(ptm_driver, index, &tty).更为详细的分析就不再继续了,因为其中繁琐而无趣。
最后需要说明的一点是,虽然它们是主从tty,但是它底层的实现是和管道不同的,因为管道的两端是共享同一个环形缓冲区,而tty的两端是各自都有自己的缓冲区,从而可以完成完全的双工操作。因为在两者相同的写操作中,其中的实现为
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;
c = to->receive_room;
if (c > count)
c = count;
to->ldisc.receive_buf(to, buf, NULL, c);
return c;
}
也就是均是向对端写入,所以它们不共享缓冲区。
顺便说管道和伪终端的另一个重要区别:管道只看到缓冲区管理,也就是只有纯粹字节流,而对于tty设备,需要对其中的每个字符做特殊处理,也就是stty -a 展示的内容。更详细的说就是:假设tty收到了一个ctrl+C,它就可能需要向读入者发送一个SIGINT,而不是简单的把这个内容返回给接受者。另一个问题依然是大家最常见的现实为题,包括控制缓冲区的删除等。还有如果termios设置了回显,那么内核还要保证将这个东西再写会到发送者等。总之,tty是比pipe更为细致和复杂的一种控制。
从printXX看tty设备(5)串口终端
一、引言
在嵌入式系统中,串口几乎是系统操作的唯一途径,所以串口的使用在嵌入式系统中有着重要作用。因为嵌入式是一个爹不亲,娘不爱的苦命娃,它一般成本比较低,当它被制造出来之后,人们就希望它这么安安静静、平平安安、兢兢业业的运行一辈子。也就是这些设备和人(human being)的交互机会比较少,但是作为一个研发或者技术维护人员,如果这个嵌入式设备万一有个三长两短、伤风感冒的话,还是要关注一下。此时串口在嵌入式中就可以作为一个基本的配置,这样当设备真的出现问题的时候,就可以通过串口连接到设备上进行诊断。当然,网口也是可以的,知识网口对硬件和软件的要求都比串口高,而且总不能为了一个调试专门为不需要网络的设备添加一个网口和网络模块吧。
在我们现在的PC系统中,默认是有一个串口的。在windows系统中,我们通过“设备管理器”中可以看到有一项“端口(COM1和LPT)”,其中的COM1就是我们的串口。在windows的命令行中,通过 echo something > COM1是可以看到没有错误,但是执行 echo otherthing>COM2会提示错误
I:\Documents and Settings\tsecer>echo 10 > COM1
I:\Documents and Settings\tsecer>echo 10 > COM2
系统找不到指定的文件。
所以可以认为我的电脑上只有一个串口,也就是COM1代表的设备文件。但是我的Vmware的虚拟机上却有两个串口,也不知道是为啥
[tsecer@Harry ~]$ cat /proc/ioports
……
01f0-01f7 : ata_piix
02f8-02ff : serial
0376-0376 : 0000:00:07.1
0376-0376 : ata_piix
0378-037a : parport0
03c0-03df : vga+
03f2-03f2 : floppy
03f4-03f5 : floppy
03f6-03f6 : 0000:00:07.1
03f6-03f6 : ata_piix
03f7-03f7 : floppy
03f8-03ff : serial
二、linux下串口的初始化
在386个人PC及很多的嵌入式设备中,使用的串口都是由linux-2.6.21\drivers\serial\8250.c提供的功能,而这个功能的初始化则是由serial8250_init函数完成,这个函数具有__init属性,也就是do_initcalls函数遍历执行的这些入口函数。从该文件的代码可以看到,这个serial8250也是一个非常常用的控制台,可能PC中是VT的天下,而嵌入式中则是这个8250的地盘吧。因为这个控制台设备的初始化和vt的初始化一样,是放在console_initcall宏中做特殊处理的,也就是会在linux-2.6.21\drivers\char\tty_io.c:console_init中遍历到,为了类比,列出两个文件的位置
static int __init serial8250_console_init(void)
{
serial8250_isa_init_ports();
register_console(&serial8250_console);
return 0;
}
console_initcall(serial8250_console_init);
console_initcall(con_init);
在serial8250_console_init-->>serial8250_isa_init_ports
for (i = 0, up = serial8250_ports;
i < ARRAY_SIZE(old_serial_port) && i < nr_uarts;
i++, up++) {
这里使用的old_serial_port变量就是在该文件中定义的数组变量,
static const struct old_serial_port old_serial_port[] = {
SERIAL_PORT_DFNS /* defined in asm/serial.h */
};
这个变量中内容的声明位于linux-2.6.21\include\asm-i386\serial.h
#define SERIAL_PORT_DFNS \
/* UART CLK PORT IRQ FLAGS */ \
{ 0, BASE_BAUD, 0x3F8, 4, STD_COM_FLAGS }, /* ttyS0 */ \
{ 0, BASE_BAUD, 0x2F8, 3, STD_COM_FLAGS }, /* ttyS1 */ \
{ 0, BASE_BAUD, 0x3E8, 4, STD_COM_FLAGS }, /* ttyS2 */ \
{ 0, BASE_BAUD, 0x2E8, 3, STD_COM4_FLAGS }, /* ttyS3 */
也就是PC中最多可以配置4个串口,由于看起来有些中断是共享的,但是大部分的电脑上只是使用了一个或者两个。进一步说,无论从vmware还是从qemu上看,其中都没有出现过4个串口。在386的默认配置文件linux-2.6.21\arch\i386\defconfig中,其中定义了CONFIG_SERIAL_8250_NR_UARTS=4,也就是默认都是有4个配置的,这也就是说,在内核的启动过程中,串口自动完成了对串口物理存在性德检测工作,只有真正存在的串口设备才会被注册到系统中。这个检测是在哪里完成的呢?
话不多说,同样是上最为直观的调用链
(gdb) bt
#0 autoconfig (up=0xd0, probeflags=3487795700) at drivers/serial/8250.c:900
#1 0xc0406f27 in serial8250_config_port (port=0xc0ac24e0, flags=1)
at drivers/serial/8250.c:2224
#2 0xc0402bc7 in uart_configure_port (drv=0xc09a1bc0, state=0xcffd94e0,
port=0xc0ac24e0) at drivers/serial/serial_core.c:2102
#3 0xc040317a in uart_add_one_port (drv=0xc09a1bc0, port=0xc0ac24e0)
at drivers/serial/serial_core.c:2294
#4 0xc0a2ea42 in serial8250_register_ports (drv=0xc09a1bc0, dev=0xcf93ca08)
at drivers/serial/8250.c:2332
#5 0xc0a2ed9d in serial8250_init () at drivers/serial/8250.c:2765
#6 0xc09fa34d in do_initcalls () at init/main.c:672
#7 0xc09fa474 in do_basic_setup () at init/main.c:712
#8 0xc09fa502 in init (unused=0x0) at init/main.c:803
#9 0xc01086d3 in ?? ()
在autoconfig函数中,其中对静态配置的串口进行了简单的检测,从而判断物理串口是否真正存在,判断的代码位于
if (!(up->port.flags & UPF_BUGGY_UART)) {
/*
* Do a simple existence test first; if we fail this,
* there's no point trying anything else.
……
if (scratch2 != 0 || scratch3 != 0x0F) {
/*
* We failed; there's nothing here
*/
DEBUG_AUTOCONF("IER test failed (%02x, %02x) ",
scratch2, scratch3);
goto out;
}
不存在的串口将无法通过这个简单校验,从而在这个位置返回。同时,跳过初始化也就意味着这个up(Uart Port)的up->port.type 成员无法初始化,用助记符来说,它的值就是
#define PORT_UNKNOWN 0
从而它不具备被系统识别的资格,相当于被剥夺了“串口权”。
三、串口中断的由来
为了让qemu使用uart作为控制台,我们在qemu的启动命令的最后添加这样的命令 console=ttyS0,从而执行串口的配置流程
(gdb) bt
#0 request_irq (irq=4, handler=0xc0405317 <serial8250_interrupt>, irqflags=0,
devname=0xc0887cce "serial", dev_id=0xc0ac1e00) at kernel/irq/manage.c:516
#1 0xc0405692 in serial_link_irq_chain (up=0xc0ac24e0)
at drivers/serial/8250.c:1452
#2 0xc0406021 in serial8250_startup (port=0xc0ac24e0)
at drivers/serial/8250.c:1752
#3 0xc03fe3b4 in uart_startup (state=0xcffd94e0, init_hw=0)
at drivers/serial/serial_core.c:179
#4 0xc0401c17 in uart_open (tty=0xcf973800, filp=0xcfe296e0)
at drivers/serial/serial_core.c:1622
#5 0xc03d207e in tty_open (inode=0xc12da2ec, filp=0xcfe296e0)
at drivers/char/tty_io.c:2577
#6 0xc01c3bce in chrdev_open (inode=0xc12da2ec, filp=0xcfe296e0)
at fs/char_dev.c:399
#7 0xc01bdc46 in __dentry_open (dentry=0xc12d8d10, mnt=0xc126c7a0, flags=2,
f=0xcfe296e0, open=0xc01c3967 <chrdev_open>) at fs/open.c:700
#8 0xc01bdf99 in nameidata_to_filp (nd=0xcfe8ff00, flags=2) at fs/open.c:826
#9 0xc01bde0a in do_filp_open (dfd=-100, filename=0xcfea6000 "/dev/console",
flags=2, mode=0) at fs/open.c:761
#10 0xc01be324 in do_sys_open (dfd=-100, filename=0xc084b3b9 "/dev/console",
flags=2, mode=0) at fs/open.c:962
#11 0xc01be41f in sys_open (filename=0xc084b3b9 "/dev/console", flags=2,
mode=0) at fs/open.c:983
---Type <return> to continue, or q <return> to quit---
#12 0xc010012b in init_post () at init/main.c:744
#13 0xc09fa542 in init (unused=0x0) at init/main.c:823
#14 0xc01086d3 in ?? ()
这里注册了串口的中断处理程序,,现在其实我们关心的是串口的数据来源,因为对于串口数据的发送,我想应该是比较直观的,通过tty_dirver->write接口直接进行写入就可以了。但是对于tty的读出接口,可能不是很直观,因为从tty_read----->>>read_chan中可以看到,其中只是简单的从自己的
c = tty->read_buf[tty->read_tail];
中来取数据,明显的,这个读取时相对于系统用户来说的,也就是用户只是从这个地方来取数据,但是这些数据是由谁放入的呢?此时就要看中断对数据的接受处理了。
四、串口数据的接收
由于我没有从HOST连接虚拟机的串口,所以我无法调试这个中断,那就只有走查一下这个代码的流程了,大致的调用关系为
serial8250_interrupt--->>>>serial8250_handle_port--->>>receive_chars--->>>uart_insert_char--->>>tty_insert_flip_char--->>>tty_insert_flip_string_flags
int tty_insert_flip_string_flags(struct tty_struct *tty,
const unsigned char *chars, const char *flags, size_t size)
{
int copied = 0;
do {
int space = tty_buffer_request_room(tty, size - copied);
struct tty_buffer *tb = tty->buf.tail;
/* If there is no space then tb may be NULL */
if(unlikely(space == 0))
break;
memcpy(tb->char_buf_ptr + tb->used, chars, space);
memcpy(tb->flag_buf_ptr + tb->used, flags, space);
tb->used += space;
copied += space;
chars += space;
flags += space;
/* There is a small chance that we need to split the data over
several buffers. If this is the case we must loop */
} while (unlikely(size > copied));
return copied;
}
receive_chars---->>>tty_flip_buffer_push--->>.schedule_delayed_work(&tty->buf.work, 1);
走入这个流程之后,也就开始了其实和键盘相同的处理流程,也就是执行
INIT_DELAYED_WORK(&tty->buf.work, flush_to_ldisc);
中设置的flush_to_ldisc函数,其中的流程为
flush_to_ldisc--->>>>disc->receive_buf(tty, char_buf, flag_buf, count)--->>>n_tty_receive_buf--->>>n_tty_receive_char
handle_newline:
spin_lock_irqsave(&tty->read_lock, flags);
set_bit(tty->read_head, tty->read_flags);
put_tty_queue_nolock(c, tty);
tty->canon_head = tty->read_head;
tty->canon_data++;
spin_unlock_irqrestore(&tty->read_lock, flags);
kill_fasync(&tty->fasync, SIGIO, POLL_IN);
if (waitqueue_active(&tty->read_wait))
wake_up_interruptible(&tty->read_wait);
在正则模式下,这里进行一次唤醒。
或者非正则模式下,则根据设置唤醒
n_tty_receive_buf
if (!tty->icanon && (tty->read_cnt >= tty->minimum_to_wake)) {
kill_fasync(&tty->fasync, SIGIO, POLL_IN);
if (waitqueue_active(&tty->read_wait))
wake_up_interruptible(&tty->read_wait);
}
五、总结
这里有一个相对比较容易混淆的概念:
用户态的/dev/console文件和用户态的struct console结构并不是等同的。用户态的/dev/console是在linux-2.6.21\drivers\char\tty_io.c:tty_init中注册的一个设备
cdev_init(&console_cdev, &console_fops);
if (cdev_add(&console_cdev, MKDEV(TTYAUX_MAJOR, 1), 1) ||
register_chrdev_region(MKDEV(TTYAUX_MAJOR, 1), 1, "/dev/console") < 0)
panic("Couldn't register /dev/console driver\n");
所以它有自己的打开(open)、写入(write)、读出(read)接口实现,而内核的struct console 大部分只是实现了write接口,,或者说内核只要求struct cosnole 实现write接口。然后这个内核的console是在cosole_fops中的tty_open中和内核的console发生联系的,然后使用内核的struc console->device()接口的tty_driver设备来进行具体设备的操作。例如vt_console的实现为vt_console_device(),而8250串口注册的则是uart_console_device()接口。
但是同样的,内核也不要求这个device返回的tty_driver实现read接口,因为对于/dev/console的read接口实现是通过tty_read来实现的,根据前面的解释,这个的读入数据可以不可见驱动的读取,而是由中断来向读取内容中放数据。串口的就是刚才看到的中断,而对于VT控制台则是键盘的中断放数据。
串口虽然简单,但是它的工作模式和更为复杂的网口的工作模式本质上是相同的,只是说网口中每次发送的数据量更大,中断的触发也不再是以单个字符(或者更高级的支持FIFO的可以一次中断接受更多个字符)为单位,而是以一个帧为单位,这样的传送效率将会更高,但是明显地,也会带来更多的校验和复杂的结构处理问题。
不论如何,这个简单的串口可以作为网口编程理解的一个模型,它的范式对于网口的编程是比较有借鉴和参考价值的。之后的博客可能会分析网口的流程,所以这里可以作为一个预热。