从printXX看tty设备

从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)键盘输入处理  


一、键盘输入

根据大量资料的描述,最早的IBM XT PC标准键盘是有83个按键,键盘按键被按下的时候它们向键盘控制器发送的扫描码是合这些按键在键盘上的物理位置相联系的(从左到右,从上到下,ESC扫描码为1,Del为0x53)。我们现在的键盘外观和当时的键盘已经有较大区别,所以这些位置看起来可能不那么连续。原始键盘在http://www.quadibloc.com/comp/scan.htm的第一个图片中有说明。由于网页可能会出现不可访问情况,所以我这里直接备份一个过来作为备份。

从printXX看tty设备(3)键盘输入处理 - Tsecer - Tsecer的回音岛

 

例如,S按键的扫描码就要比A按键的扫描码大一。键盘A的扫描码为0x1E,而S的键盘扫描码为0x1F。83键盘也就是键盘上总共有83个按键,按键编号从1(对应ESC键)开始,到0x53(对应Del键)结束。当按键被按下的时候,成为Make一个按键,松开的时候成为Break,此时键盘都是直接发送自己的扫描码到CPU上。但是同一个按键在make的时候发送的是原始的扫描码,例如对于A被按下的时候发送的是0x1E,当按键放开的时候,此时键盘再次向CPU发送中断,只是此时的扫描码是在原始扫描码的基础上或上0x80,或者说扫描码的最高bit置位,所以就是0x9E。

之后键盘进行扩容,按键的个数开始增加101个,此时的键盘布局已经和我们当前看到的键盘很相似了,只是没有包含WIn key 和 Menu Key,

图片同样复制一份过来

从printXX看tty设备(3)键盘输入处理 - Tsecer - Tsecer的回音岛

 

 ---   ---------------   ---------------   ---------------   -----------  | 01| | 3B| 3C| 3D| 3E| | 3F| 40| 41| 42| | 43| 44| 57| 58| |+37|+46|+45|    ---   ---------------   ---------------   ---------------   -----------     ---------------------------------------------------------   -----------   ---------------  | 29| 02| 03| 04| 05| 06| 07| 08| 09| 0A| 0B| 0C| 0D|   0E| |*52|*47|*49| |+45|+35|+37| 4A|  |---------------------------------------------------------| |-----------| |---------------|  |   0F| 10| 11| 12| 13| 14| 15| 16| 17| 18| 19| 2B| 1A| 1B| |*53|*4F|*51| | 47| 48| 49|   |  |---------------------------------------------------------|  -----------  |-----------| 4E|  |    3A| 1E| 1F| 20| 21| 22| 23| 24| 25| 26| 27| 28|    1C|               | 4B| 4C| 4D|   |  |---------------------------------------------------------|      ---      |---------------|  | 56|  2A| 2C| 2D| 2E| 2F| 30| 31| 32| 33| 34| 35|      36|     |*4C|     | 4F| 50| 51|   |  |---------------------------------------------------------|  -----------  |-----------|-1C|  |   1D|-5B|   38|                       39|-38|-5C|-5D|-1D| |*4B|*50|*4D| |     52| 53|   |   ---------------------------------------------------------   -----------   ---------------  

这里的键盘在83键基础上增加了不少按键,此时这些增加的按键成为扩展键(例如右边的Ctrl Alt 以及4个独立的方向键等)。当这些扩展按键被按下之后,它们向CPU发送的扫描码不再是一个字节,而是两个甚至三个字节组成的序列。这些扩展键的第一个字节是0xe0,之后再加上一个扫描码,这个扫描码对于功能完全相同的不同按键(例如数字按键区的方向键和独立的四个方向键)它们的扫描码数值都是相同的,只是扩展按键的按下扫描码为 0xe0 scancode,而原始的仍然保留为scancode,断开时扩展按键为0xe0 0x80|scancode。而其它的左右位置按键则分配了新的扫描码。

操作系统能够感知的就是这些扫描码(准确的说是系统扫描码)。

二、键盘输入的流程

1、虚拟tty基本概念

下面是内核的键盘处理程序的调用连,从调用中可以看到,键盘中断使用的是1号中断

#0  kbd_event (handle=0xcf943b20, event_type=0, event_code=169, value=46)
    at drivers/char/keyboard.c:1272
#1  0xc068e598 in input_event (dev=0xcf931000, type=4, code=4, value=46)
    at drivers/input/input.c:195
#2  0xc0696140 in atkbd_interrupt (serio=0xcff77a00, data=46 '.', flags=0)
    at drivers/input/keyboard/atkbd.c:439
#3  0xc068ad00 in serio_interrupt (serio=0xcff77a00, data=46 '.', dfl=0)
    at drivers/input/serio/serio.c:988
#4  0xc068bc46 in i8042_interrupt (irq=1, dev_id=0xcff77e00) at drivers/input/serio/i8042.c:372
#5  0xc017d483 in handle_IRQ_event (irq=1, action=0xcff6e6a0) at kernel/irq/handle.c:141
#6  0xc017f2a1 in handle_edge_irq (irq=1, desc=0xc09e6e00) at kernel/irq/chip.c:494
#7  0xc010a47a in do_IRQ (regs=0xc09f3e40) at arch/i386/kernel/irq.c:140
#8  0xc0108493 in ?? ()

在kbd_event-->>kbd_keycode函数中,可以看到里面使用了一个全局变量fg_console变量,这个变量时作为一个数组下标来处理的,

 struct vc_data *vc = vc_cons[fg_console].d;
……

 kbd = kbd_table + fg_console;
其中的kbd_table 和vc_cons两个数组定义的时候都是

#define MAX_NR_CONSOLES 63 /* serial lines start at 64 */
也就是总共最多可以有64个这样的数组元素。在不少的Linux手册中,都会说明通过Alt+ F1,ALT+F6之类的来切换不同的登录界面,这些设备对对应的就是tty0--tty63共64个设备,它们对应的主设备号为4,此设备号为0到63。对于这个概念的理解,我想大概是这样样子的:一个PC系统中只有一个键盘和一个显示器,但是内核可以让这两个设备组成的控制台让不同的用户分时共享。也就是当第一个控制台被被使用的时候,其它的都不能使用,但是可以互相切换,就好像CPU的分时复用一样。当一个控制台被切回来之后,内核进行指针的切换,也就是fg_console的变化,而它对应的每个数组中保存了这个控制台的设置,比如说是否回显、波特率之类的设置(具体可以用stty -a显示tty设备的已配置内容)。

回想之前说过的原始终端的方法,这样系统有64个控制台,就可以登录64个用户,只是这些用户不同同时操作一样。比如说,同一个人可以首先用普通用户登录,然后通过ALT+F2切换到另一个终端,再以普通用户tsecer登录做正常操作,当需要管理权限的时候,再ALT+F1切换回管理员,完成相关配置之后再到普通用户tsecer控制台操作。

但是这个tty0到tty63只是一些设备,它不负责用户态权限管理及用户验证之类的事情,这里的控制台只是给用户一个登录的设备基础,系统启动的时候同样需要使用getty或者mingetty来在这个设备上进行等待用户切换(激活)这个控制台之后进行用户登录及验证。而mingetty的功能就是打开命令行中指定的终端,在该终端上调用login程序来让用户输入(关于用户管理和登录的问题之后还可以单独说明一下),例如下面的一个系统启动脚本

ttyp0:234:respawn: /usr/sbin/getty /dev/ttyp0
ttyp1:234:respawn: /usr/sbin/getty /dev/ttyp1
ttyp2:234:respawn: /usr/sbin/getty /dev/ttyp2

现在看一下用户态是如何通过按键来修改内核态的全局变量的。

2、ALT+FX组合处理

在内核的linux-2.6.21\drivers\char\defkeymap.c_shipped文件中,对应于Alt按键组合有下面一个表项

u_short alt_map[NR_KEYS] = {
 0xf200, 0xf81b, 0xf831, 0xf832, 0xf833, 0xf834, 0xf835, 0xf836,
 0xf837, 0xf838, 0xf839, 0xf830, 0xf82d, 0xf83d, 0xf87f, 0xf809,
 0xf871, 0xf877, 0xf865, 0xf872, 0xf874, 0xf879, 0xf875, 0xf869,
 0xf86f, 0xf870, 0xf85b, 0xf85d, 0xf80d, 0xf702, 0xf861, 0xf873,
 0xf864, 0xf866, 0xf867, 0xf868, 0xf86a, 0xf86b, 0xf86c, 0xf83b,
 0xf827, 0xf860, 0xf700, 0xf85c, 0xf87a, 0xf878, 0xf863, 0xf876,
 0xf862, 0xf86e, 0xf86d, 0xf82c, 0xf82e, 0xf82f, 0xf700, 0xf30c,
 0xf703, 0xf820, 0xf207, 0xf500, 0xf501, 0xf502, 0xf503, 0xf504,这里的0xf500是数组的第0x3B个元素
 0xf505, 0xf506, 0xf507, 0xf508, 0xf509, 0xf208, 0xf209, 0xf907,
 从第一个节中描述的那个扫描码看,可以看到键盘上F1的扫描码为0x3B,之后到FF10的0x44(这也说明F11和F12是扩展键,在83键盘中没有这两个功能键)。还有就是这个数组中0xf500的高bytes为0xf5,这个5将会作为k_handler数组的下表索引,对应的就是k_cons函数。我们看一下这个函数的实现

k_cons-->>set_console--->>>change_console--->>>complete_change_console--->>>switch_screen--->>>redraw_screen

  fg_console = vc->vc_num;
这里切换了全局变量fg_console的值。

这里要注意的是:

内核中的alt_map之类的map数组的下表索引都是扫描码,这是这是因为扫描码的个数并不多,并且内核得到的最为原始的数据就是扫描码,所以以扫描码为索引建立一张索引表是最为高效的。然后对于数组中的short类型,它的高byte作为特殊标志,其中的0xfX中的X是作为k_handler的下表的,用于索引不同类型的按键的处理函数,例如,如果按键按下了A,则需要发送的就是A的ASCII码0x61。这个数组的使用是在kbd_keycode函数中使用的,其中关键处理流程为

#define K(t,v)  (((t)<<8)|(v))
#define KTYP(x)  ((x) >> 8)
#define KVAL(x)  ((x) & 0xff)

……

 type = KTYP(keysym);高一byte作为类型。

 if (type < 0xf0) {
  if (down && !raw_mode)
   to_utf8(vc, keysym);
  return;
 }

 type -= 0xf0;减去0xf0,从而作为类型索引,对于0xf500,这里的type就为5.。

……

 (*k_handler[type])(vc, keysym & 0xff, !down);把低byte作为参数传递给处理函数。


static k_handler_fn *k_handler[16] = { K_HANDLERS };
#define K_HANDLERS\
 k_self,  k_fn,  k_spec,  k_pad,\
 k_dead,  k_cons,  k_cur,  k_shift,\第五项,
 k_meta,  k_ascii, k_lock,  k_lowercase,\
 k_slock, k_dead2, k_brl,  k_ignore

在redhat的发行版本(包括Fedora Core版本)中,这个组合键并没有生效。测试的办法是修改 /etc/inittab文件,设置默认系统运行级别为3,然后重启电脑,从而可以在多个虚拟终端之间切换。在图形界面下通过超级用户修改该文件

[tsecer@Harry ~]$ su -c 'vi /etc/inittab'
Password: 
[tsecer@Harry ~]$

将其中的默认运行级别修改为3
# Default runlevel. The runlevels used are:
#   0 - halt (Do NOT set initdefault to this)
#   1 - Single user mode
#   2 - Multiuser, without NFS (The same as 3, if you do not have networking)
#   3 - Full multiuser mode
#   4 - unused
#   5 - X11
#   6 - reboot (Do NOT set initdefault to this)
#
id:5:initdefault:将此处的5初始化为3,重启电脑,之后可以通过ALT+FX来切换不同的伪终端。其实键盘输入的一个核心就是:只发给前端的控制台,虽然系统中可能有多个虚拟控制台,但是同一时刻只能有一个位于前端;同样,一个会话可能有多个进程组(可能有大量处于后台运行的进程组),但是前台线程组也只有一个,这个进程组将会获得输入,获得CTRL+C信号的接受权,这个变量为

struct tty_struct {
 int magic;
 struct tty_driver *driver;
 int index;
 struct tty_ldisc ldisc;
 struct mutex termios_mutex;
 struct ktermios *termios, *termios_locked;
 char name[64];
 struct pid *pgrp;

这一点从linux-2.6.21\drivers\char\n_tty.c中可以看到

static inline void isig(int sig, struct tty_struct *tty, int flush)
{
 if (tty->pgrp)
  kill_pgrp(tty->pgrp, sig, 1);

3、sysrq处理

在这里可以看到,对于linux的系统急救类命令sysrq的处理也在这个流程中处理。

#ifdef CONFIG_MAGIC_SYSRQ        /* Handle the SysRq Hack */
 if (keycode == KEY_SYSRQ && (sysrq_down || (down == 1 && sysrq_alt))) {
  if (!sysrq_down) {
   sysrq_down = down;
   sysrq_alt_use = sysrq_alt;
  }
  return;
 }
 if (sysrq_down && !down && keycode == sysrq_alt_use)
  sysrq_down = 0;
 if (sysrq_down && down && !rep) {
  handle_sysrq(kbd_sysrq_xlate[keycode], tty);
  return;
 }
#endif
这意味着什么呢?这意味着这个是在中断处理函数中完成的。所以即使系统中出现了高优先级任务(最高优先级,实时任务99)死循环,此时这个按键同样是可以被响应的,因为中断并不考虑调度的优先级。这也就是这个应急键的意义所在。而且键盘的LED灯状态的处理也是在中断中完成的,所以即使系统负载再重,只要内核没有挂死,键盘的LED指示灯同样应该是可以根据cap number lock的按下和断开有相应的显示,这也是很多人通过键盘指示灯判断内核是否挂死的依据。

三、按键转发给特定任务

1、控制组合及信号

虽然说一些按键是在中断中处理的的,但是大部分的按键并不是在中断中处理的,而是通过workqueue队列来处理。再具体的说,就是通过k_unicode-->>put_queue--->>>con_schedule_flip--->>schedule_delayed_work(&t->buf.work, 0);

而tty设备的buf.work内容为

static void initialize_tty_struct(struct tty_struct *tty)
 INIT_DELAYED_WORK(&tty->buf.work, flush_to_ldisc);
我们看一下这个调用连

#0  n_tty_receive_char (c=48 '0', tty=0xcf955800) at drivers/char/n_tty.c:695
#1  n_tty_receive_buf (c=48 '0', tty=0xcf955800) at drivers/char/n_tty.c:936
#2  0xc03d3c11 in flush_to_ldisc (work=0xcf9558d4)
    at drivers/char/tty_io.c:3516
#3  0xc015cc56 in run_workqueue (cwq=0xc1275740) at kernel/workqueue.c:327
#4  0xc015d03d in worker_thread (__cwq=0xc1275740) at kernel/workqueue.c:390
#5  0xc0162b95 in kthread (_create=0xcfe8fee8) at kernel/kthread.c:105
#6  0xc01086d3 in ?? ()

在n_tty_receive_char函数中,里面做了很多判断,这些也就是我们一个终端可以感知到的内容,例如,当我们按下ctrl+C的时候,对应的处理就在这里。也就是在默认情况下,tty设备是可以识别CTRL+C,CTRL+S之类的组合键的,并且在内核中将其转换为信号发送给tty的前端任务。

 if (L_ISIG(tty)) {
  int signal;
  signal = SIGINT;
  if (c == INTR_CHAR(tty))
   goto send_signal;
  signal = SIGQUIT;
  if (c == QUIT_CHAR(tty))
   goto send_signal;
  signal = SIGTSTP;
  if (c == SUSP_CHAR(tty)) {
send_signal:
   isig(signal, tty, 0);
   return;
  }
 }

例如,下面是我的tty的默认配置(也即是termios的文本化输出)

[tsecer@Harry bash-4.1]$ stty -a
speed 38400 baud; rows 24; columns 80; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = M-^?; eol2 = M-^?;
swtch = M-^?; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W;
lnext = ^V; flush = ^O; min = 1; time = 0;
-parenb -parodd cs8 hupcl -cstopb cread -clocal -crtscts
-ignbrk brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff
-iuclc ixany imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt
echoctl echoke
2、回显

我们现在可能最为关心的就是回显功能,比如输入密码的时候禁止回显。从上面的输出看,我们的控制台默认都是回显输入的。但是在bash中我们输入一个tty不识别的组合键,例如 CTRL+G,发现bash并没有回显为CTRL+G,但是通过我们自定义的程序就可以。简单测试程序

[tsecer@Harry read]$ cat Read.c 
#include <stdio.h>
int main()
{
char buf[0x10];
int readed =0 ;
readed = read(0,buf,sizeof(buf));
return 0;
}
执行效果

[tsecer@Harry read]$ ./a.out 
^H^T^P^P^P^P^P^Pafsdfsfsdfsdfsdfsdfsfswaf^A^B
[tsecer@Harry read]$ dfsdfsdfsdfsfswaf
bash: dfsdfsdfsdfsfswaf: command not found
[tsecer@Harry read]$ 
可以看到,它回显了我的Ctrl+H组合键,但是在bash中按这个按键却没有回显(这不是正常现象,因为bash和a.out用得是相同的终端,应该有相同的配置和行为)。

看一下bash的内部实现读取命令的时候是通过readline库实现的,其中读取一行的处理为bash-4.1\lib\readline\readline.c:readline (prompt)


  if (rl_prep_term_function)
    (*rl_prep_term_function) (_rl_meta_flag);

#if defined (HANDLE_SIGNALS)
  rl_set_signals ();
#endif

  value = readline_internal ();
  if (rl_deprep_term_function)
    (*rl_deprep_term_function) ();

rl_vintfunc_t *rl_prep_term_function = rl_prep_terminal;
rl_voidfunc_t *rl_deprep_term_function = rl_deprep_terminal;

 

rl_prep_terminal (meta_flag)
     int meta_flag;

static void
prepare_terminal_settings (meta_flag, oldtio, tiop)
     int meta_flag;
     TIOTYPE oldtio, *tiop;
{
  _rl_echoing_p = (oldtio.c_lflag & ECHO);
#if defined (ECHOCTL)
  _rl_echoctl = (oldtio.c_lflag & ECHOCTL);
#endif

  tiop->c_lflag &= ~(ICANON | ECHO);这里bash在读入一行之前,会强制关掉tty的一行读取方式,并且关掉回显

……

  tiop->c_cc[VMIN] = 1;这里也很重要,要求tty在没收到一个字符都直接将从tty中读入字符的任务唤醒,从而可以执行即使的处理。而bash的功能键也就是这么实现的,可以使用bash的bind -p 显示bash当前所有的功能及绑定情况
  tiop->c_cc[VTIME] = 0;

然后在读入一行之后再通过前面的rl_deprep_term_function函数恢复tty设置,这样在不同的任务看来的确是不同的。也就是bash读取的时候和读取结束之后,子进程执行的时候的tty设置是不同的,是动态变化的。

关于这一点的内核处理路径:

static void n_tty_receive_buf(struct tty_struct *tty, const unsigned char *cp,
         char *fp, int count)

……

 if (!tty->icanon && (tty->read_cnt >= tty->minimum_to_wake)) { 可以看到,bash中这里的icanon是被清零的,而且其中的minimum_to_wake被设置为1,也就是每个字符都会导致从tty中读取的函数返回。然后bash自己决定自己读入的字符是否回显
  kill_fasync(&tty->fasync, SIGIO, POLL_IN);
  if (waitqueue_active(&tty->read_wait))
    wake_up_interruptible(&tty->read_wait);
 }

 3、后台任务读取/写入tty问题

后台任务就是在创建子进程的时候在命令后添加了 ‘&’ 或者bg 运行的任务,当这些任务如果犯禁尝试从tty读取或者写入数据(并且没有定义redirected_tty_write实现)时,此时它们将会受到严厉的惩罚,就是一个SIGTIN或者SIGTOUT,这个信号比较致命,默认处理是结束任务组。

读出问题

read_chan--.>>>>job_control

static int job_control(struct tty_struct *tty, struct file *file)
{
 /* Job control check -- must be done at start and after
    every sleep (POSIX.1 7.1.1.4). */
 /* NOTE: not yet done after every sleep pending a thorough
    check of the logic of this change. -- jlc */
 /* don't stop on /dev/console */
 if (file->f_op->write != redirected_tty_write &&
     current->signal->tty == tty) {
  if (!tty->pgrp)
   printk("read_chan: no tty->pgrp!\n");
  else if (task_pgrp(current) != tty->pgrp) {
   if (is_ignored(SIGTTIN) ||
       is_current_pgrp_orphaned())
    return -EIO;
   kill_pgrp(task_pgrp(current), SIGTTIN, 1);
   return -ERESTARTSYS;
  }
 }
 return 0;
}


写入问题

write_chan--->>>tty_check_change
int tty_check_change(struct tty_struct * tty)
{
 if (current->signal->tty != tty)
  return 0;
 if (!tty->pgrp) {
  printk(KERN_WARNING "tty_check_change: tty->pgrp == NULL!\n");
  return 0;
 }
 if (task_pgrp(current) == tty->pgrp)
  return 0;
 if (is_ignored(SIGTTOU))
  return 0;
 if (is_current_pgrp_orphaned())
  return -EIO;
 (void) kill_pgrp(task_pgrp(current), SIGTTOU, 1);
 return -ERESTARTSYS;
}

但是对于写入的判断,在write_chan中有一个判断前提,在write_chan函数中:

 if (L_TOSTOP(tty) && file->f_op->write != redirected_tty_write) {
  retval = tty_check_change(tty);
  if (retval)
   return retval;
 }

也就是只有termios设置了TOSTOP标志,才会尝试进行判断。在linux-2.6.21\drivers\char\tty_io.c中,默认的termios设置是没有设置这个标志的:

struct ktermios tty_std_termios = { /* for the benefit of tty drivers  */
 .c_iflag = ICRNL | IXON,
 .c_oflag = OPOST | ONLCR,
 .c_cflag = B38400 | CS8 | CREAD | HUPCL,
 .c_lflag = ISIG | ICANON | ECHO | ECHOE | ECHOK |
     ECHOCTL | ECHOKE | IEXTEN,
 .c_cc = INIT_C_CC,
 .c_ispeed = 38400,
 .c_ospeed = 38400
};

这也就是说:默认情况下,后台任务如果从tty读取的话,会收到SIGTIN,但是可以正常的向tty写入数据,除非主动设置termios的TOSTOP标志位
前段任务组设置方法

具体当前tty会话那个任务组是前段任务组,则是有shell来设置的。例如bash在派生子进程的时候会设置新的子进程的前段任务组为新设置的任务。如果是后台启动,则没有这个设置。linux-2.6.21\drivers\char\tty_io.c:tty_ioctl

 case TIOCSPGRP:
   return tiocspgrp(tty, real_tty, p);

static int tiocspgrp(struct tty_struct *tty, struct tty_struct *real_tty, pid_t __user *p)

…………

 real_tty->pgrp = get_pid(pgrp);


从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的调用连

(gdb) bt
#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的可以一次中断接受更多个字符)为单位,而是以一个帧为单位,这样的传送效率将会更高,但是明显地,也会带来更多的校验和复杂的结构处理问题。

不论如何,这个简单的串口可以作为网口编程理解的一个模型,它的范式对于网口的编程是比较有借鉴和参考价值的。之后的博客可能会分析网口的流程,所以这里可以作为一个预热。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值