键盘如何工作的前文曾经说过,当时是以 Linux 0.11 为基础讲的但不系统,本文以 xv6 的键盘驱动程序为例来系统地讲述键盘是如何工作的。关于驱动程序前文磁盘那一篇说过了,它就是硬件物理接口的封装,所以了解键盘驱动程序,同样的还是先来了解键盘的一些物理接口。
与键盘相关的芯片有两个,一个是键盘编码器 i8048,另一个是键盘控制器 i8042,分别来看。
键盘编码器键盘编码器位于键盘,它的作用主要是监测键的按下和弹起,然后将两种状态编码,发送给键盘控制器。
上述说的码叫做键盘扫描码,编码方式一共有三种,相应的也就有三套键盘扫描码,各套键盘扫描码具体怎么编码的就不说了,见后面的链接。现今的键盘大多数都是用的第二套键盘扫描码,但也不排除使用第一套和第三套的,所以为了兼容,键盘控制器会统统地转换为第一套扫描码。当然这是默认的情况,具体使用哪一套扫描码,控制器是否转化,还是要看硬件是否支持与具体怎么设置,有兴趣的详见文末链接。
因此第一套键盘扫描码还是得说道说道,一个键有按下就会有弹起,所以每个键会有两个状态,即每个键将会对应两个扫描码,键被按下时的编码叫做通码(),弹起时的编码叫做断码()。
大部分键的通码和断码都是 8 位 1 字节,但有些操作控制键如 Ctrl、Alt,附加键如Insert,小键盘区如/ ,方向键等是 2 字节甚至多个字节。有多个字节的扫描码通常都是以 开头。只有 一个键是以 开头。
断码与通码的关系:断码通码。 二进制表示为 ,所以对于断码和通码可以这样理解,它们由 8 位比特组成,最高位第 7 位表示按键状态,1 表示按下,0 表示弹起。
键盘控制器键盘控制器(i8042),不在键盘内部,被集成在南桥芯片上。主要接收键盘编码器发来的键盘扫描码,做一些处理(比如第二套扫描码转第一套),然后触发中断通知 CPU 来读取扫描码。
键盘控制器有 4 个 8 bits 寄存器,Status Register 和 Control Register,两者共用一个端口 0x64,读的时候是状态寄存器,写的时候是控制寄存器。Input Buffer 和 Output Buffer,两者共用一个端口 0x60,读的时候是输出缓冲器,写的时候是输入缓冲器。
状态寄存器:bit0:1 表示输出缓存器满,CPU 读取后清零。从编码器发过来的扫描码就放在这里。
bit1:1 表示输入缓存器满,控制器读取后清零。
控制寄存器:通过写 0x64 端口来向控制器发送命令,注意是向控制器本身发命令而不是向硬件设备键盘发命令,对于键盘的控制就是通过控制器来间接控制,所以只需要操作键盘就是了。
命令控制器就是将命令字节写入 0x64 端口,一般命令就是一字节,如果有两字节,则将第二个字节写入 0x60 端口。因为要写 0x60 端口表示的缓存区,所以要先判断该缓存区是否为空。
比如进入保护模式设置 时,先判断输入缓存区是否为空,空的话表示控制器已取走数据,可以继续进行,否则不空的话循环等待:
inb $0x64,%al # Wait for not busy 等待i8042缓冲区为空
testb $0x2,%al
jnz seta20.1
再向 0x64 端口写入命令 ,表示准备写 Output 端口,随后写入 0x60 端口的字节将放入 Output 端口。
inb $0x64,%al # Wait for not busy 同上
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60 向端口0x60写入0xdf,打开A20
outb %al,$0x60
同样的先判断输入缓存区是否为空,然后写入命令第二字节 ,这个字节会被送到 Output 端口,这个端口也是一个控制端口,bit2 控制着 的开关,所以如果是命令字节 表示关闭 。
关于键盘控制器就说这么多,只讲述了与 xv6 相关的部分,其他部分同样的感兴趣的见文末的链接。
XV6驱动程序就是硬件物理接口的封装,键盘驱动程序也是如此,它的主要功能就是将读取扫描码转换成计算机所需要的信息,比如说转换成字符,信号等等。xv6 在这方面实现的比较简单,只实现了字符转化,一些功能控制键,我们来看看。
首先在 头文件中定义了端口号,控制键如 Ctrl,特殊键如 UP,以及最重要的映射表,来看个普通情况下的映射表:
static uchar normalmap[256] =
{
NO, 0x1B, '1', '2', '3', '4', '5', '6', // 0x00
'7', '8', '9', '0', '-', '=', '', ' ',
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', // 0x10
'o', 'p', '[', ']', '
', NO, 'a', 's',
'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', // 0x20
''', '`', NO, '\', 'z', 'x', 'c', 'v',
'b', 'n', 'm', ',', '.', '/', NO, '*', // 0x30
NO, ' ', NO, NO, NO, NO, NO, NO,
NO, NO, NO, NO, NO, NO, NO, '7', // 0x40
'8', '9', '-', '4', '5', '6', ' ', '1',
'2', '3', '0', '.', NO, NO, NO, NO, // 0x50
[0x9C] '
', // KP_Enter
[0xB5] '/', // KP_Div
[0xC8] KEY_UP, [0xD0] KEY_DN,
[0xC9] KEY_PGUP, [0xD1] KEY_PGDN,
[0xCB] KEY_LF, [0xCD] KEY_RT,
[0x97] KEY_HOME, [0xCF] KEY_END,
[0xD2] KEY_INS, [0xD3] KEY_DEL
};
键盘扫描码是一个键的代表,但不是我们想要的,我们想要的是这个键表示的意义,比如数字键 1 的通码是 , 显然不是我们想要的,我们想要的是数字 1,所以需要一个映射关系来转换,将所有键的映射关系集合在一起就是上述的映射表。是个大数组,下标就是这个键的扫描码,内容就是所表达的意思。
上述是一般情况,那当然还有非一般的情况,比如有按下 Shift,CapsLock,Ctrl 等控制键,当按下这些控制键后,其他键按下之后表达的意义就不一样了,所以还需要另外的映射表,这里就不列出来了,太多了,可以直接参考代码。举个例子,当按下 Shift 键之后再按下数字键 1,通码 则应该映射成 !而不是1。
有了这些了解之后来看 里面的源码:
int kbdgetc(void)
{
static uint shift; //shift用bit来记录控制键,比如shift,ctrl
static uchar *charcode[4] = {
normalmap, shiftmap, ctlmap, ctlmap
}; //映射表
uint st, data, c;
st = inb(KBSTATP);
if((st & KBS_DIB) == 0) //输出缓冲区未满,没法用指令in读取
return -1;
data = inb(KBDATAP); //从输出缓冲区读数据
if(data == 0xE0){ //通码以e0开头的键
shift |= E0ESC; //记录e0
return 0;
} else if(data & 0x80){ //断码,表键弹起
// Key released
data = (shift & E0ESC ? data : data & 0x7F);
shift &= ~(shiftcode[data] | E0ESC);
return 0;
} else if(shift & E0ESC){ //紧接着0xE0后的扫描码
// Last character was an E0 escape; or with 0x80
data |= 0x80;
shift &= ~E0ESC;
}
shift |= shiftcode[data]; //记录控制键状态,如Shift,Ctrl,Alt
shift ^= togglecode[data]; //记录控制键状态,如CapsLock,NumLock,ScrollLock
c = charcode[shift & (CTL | SHIFT)][data]; //获取映射表的内容,也就是该键表示的意义
if(shift & CAPSLOCK){
if('a' <= c && c <= 'z')
c = 'A' - 'a';
else if('A' <= c && c <= 'Z')
c = 'a' - 'A';
}
return c;
}
这个程序就可以看作极简的键盘驱动程序,也是键盘中断的服务程序的主体,完成键盘扫描码到所需信息的转化。下面就来仔细分析分析:
前面说过有多张映射表多种映射方式,那怎么知道用哪张?用哪张得看看有没有相应的控制键按下,所以得有个东西来记录控制键的按下与否,这个东西就是变量 ,虽然变量名是 ,但不代表只记录 Shift 键的状态,记录的信息有:
#define SHIFT (1<<0)
#define CTL (1<<1)
#define ALT (1<<2)
#define CAPSLOCK (1<<3)
#define NUMLOCK (1<<4)
#define SCROLLLOCK (1<<5)
#define E0ESC (1<<6) //通码断码以E0开头
从这种定义控制键的方式就可以看出使用 来记录控制键的方式应该是使用位运算。
表示一个二维数组,可以看作是映射表的集合,根据 的记录信息来选择映射表,后面用到的时候就明白了。
st = inb(KBSTATP);
if((st & KBS_DIB) == 0) //输出缓冲区为空,没法用指令in读取
return -1;
data = inb(KBDATAP); //从输出缓冲区读数据
这几句用来读取键盘扫描码,从键盘发过来的扫描码就放在输出缓冲区中。要读取扫描码首先从状态寄存器读取当前状态到 ,再做与运算取出第 0 位,表示输出缓冲区的状态,如果为 0 表示输出缓冲区寄存器为空,没法读取返回 -1。如果为 1 表示输出缓冲区寄存器已满有内容,可以读取,所以接着从端口 0x60 输出缓冲区读出扫描码到 。
if(data == 0xE0){ //通码以e0开头的键
shift |= E0ESC; //记录e0
return 0;
}
如果这个扫描码为 0xE0,说明按下的键是特殊键,扫描码不止 8 字节,这种情况在 变量中做好标记就可以直接返回了,等待下一个数据的到来再做具体处理
else if(data & 0x80){ //断码,表键弹起
// Key released
data = (shift & E0ESC ? data : data & 0x7F);
shift &= ~(shiftcode[data] | E0ESC);
return 0;
}
为 1 的话,表示第 7 位为 1,说明此数据为断码,收到断码不需要额外的做什么事,但如果这个断码是某个控制键的断码,则应该将该控制键在 里面的记录信息给清除掉。
所以得知道读出的 表示哪一个控制键,所以有了 映射:
static uchar shiftcode[256] =
{
[0x1D] CTL,
[0x2A] SHIFT,
[0x36] SHIFT,
[0x38] ALT,
[0x9D] CTL,
[0xB8] ALT,
};
私以为这个定义方式很不对头啊,实在不太明白一些控制键用通码,一些用断码,这也就导致了那条使用了条件表达式的 赋值语句必须存在,因为 中映射 Shift 键的时候没有用断码,所以得转换成通码。私以为这么映射很混乱,导致后面 中有些语句意义也不太明确,要么就应该将映射关系给补全,然后可以省掉那句 赋值语句,使后面的语句书写变得更明确一点。当然这不是重点,能理解这过程意思就行,总而言之如果 是个断码,不需要干其他的事,如果是控制键的断码,将记录在 中的控制键信息给清除掉就行。
else if(shift & E0ESC){
// Last character was an E0 escape; or with 0x80
data |= 0x80;
shift &= ~E0ESC;
}
这种情况对应的是紧接着 后面的键盘扫描码,键盘扫描码有多个字节的,都是成对存在也就是 这种形式,每次收到 ,都要将 键中记录的 信息给清除掉。至于前面还有一句 还是与 xv6 设计的映射表有关,键盘上有着许多相同意义的键,xv6 将一些键的映射关系用断码来映射,比如除号键 /。
shift |= shiftcode[data]; //记录控制键状态,如Shift,Ctrl,Alt
shift ^= togglecode[data]; //记录控制键状态,如CapsLock,NumLock,ScrollLock
这两句来记录控制键的状态,分了两种情况,两种运算方式。应该能看出它们之间的区别吧,实现组合键的时候,Shift,Ctrl,Alt 需要按住不放才能生效,弹起后不再生效。而像 CapsLock 之类的控制键,只需要按下一次即可,即便弹起之后同样生效。所以一个使用或运算,一个使用异或运算,自己模拟一下过程应该很容易明白。
c = charcode[shift & (CTL | SHIFT)][data]; //获取映射表的内容,也就是该键表示的意义
if(shift & CAPSLOCK){ //如果有 CapsLock 存在
if('a' <= c && c <= 'z') //小写变大写
c = 'A' - 'a';
else if('A' <= c && c <= 'Z') //大写变小写
c = 'a' - 'A';
}
根据 中记录的控制键信息,来选取映射表,根据 去获取该键盘扫描码所表示的意义。因为 CapsLock 和 Shift 键的功能有相同之处,所以如果 c 就是个普通 26 个英文字母字符的话,需要额外判断大小写。
关于 xv6 的键盘驱动程序差不多就是这么多,当然还有一些功能没说,比如 Ctrl 组合键功能,键盘的缓冲区等等,这在另一个文件里面涉及到了另外的知识,咱们放在后面再详述吧。
在此再聊聊常见的一些问题,在第一篇键盘里也说过,再来看看:
使用组合键时需要先按下控制键。键盘的中断程序为这些控制键设置了标识()。先按下控制键,程序为控制键设置好按下状态,再处理后到来的键时会检查这些标识,是否有控制键按下,以便做出不同的操作。
组合键按键时有顺序,但弹起无顺序要求。由上面的键处理程序可知,只有通码的键处理程序在做事,而断码的键处理程序除了控制键的标识位需要复位之外其他键都是直接返回的。所以使用键盘控制输入时重要的是按键,而不是键弹起,所以只要按键对了,怎样弹起并不重要。
一直按着某个键时会一直触发键盘中断,若是普通的字符键,电脑屏幕可能会出现一直打印某个字符的现象。若是一些控制键,则驱动程序可能会不停地将这个键设为按下状态。当然,驱动程序是否记录上次按键取决于具体实现,大多是不记录的,xv6 也是如此,触发一次键盘中断就处理一个扫描码。
最后总结一番,键盘驱动程序同样的是封装键盘的物理接口使用,比如读取状态,读取扫描码等等。键盘本身使用的是键盘扫描码,每个键都有自己的键盘扫描码,一个是通码表按下,一个表断码表弹起。这个键盘扫描码只是唯一标识一个键,可以将键盘扫描码看作是一个键的物理意义,但这不是我们想要的,我们想要的是这个键代表的逻辑意义。所以物理意义和逻辑之间需要一个转化,这就是映射表存在的意义。
键盘上有各种各样的键,还能组合使用,它们所代表的意义、具有的功能很多也很杂,xv6 只实现了其中一部分,但也足以让我们明白其中的本质。不论要通过按键实现什么功能,还是就只是简单的使用一个键所代表的逻辑意义,都是要先获取键的扫描码,再通过映射表转化成所需要的信息,后续什么功能再在其上做文章。
好了本文就到这里结束,有什么错误还请批评指正,也欢迎大家来同我讨论交流一起学习进步。
参考:
http://www.win.tue.nl/~aeb/linux/kbd/scancodes-11.html
http://wiki.osdev.org/"8042"_PS/2_Controller#Command_Register
,