从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);

posted on 2019-03-06 20:45  tsecer  阅读(741)  评论(0编辑  收藏  举报

导航