引言
- 操作系统写到现在,还没有实现键盘输入的功能,本章节我们就来实现键盘输入功能
键盘外部中断初步实现
- 本次相关改动代码见:keyboard.c、keyboard.h、interrupt.asm、app.c
- 首先我们回顾一下之前学习过的 8259A 芯片介绍,IRQ1 连接着键盘外设,这说明当你按下键盘上的一个按键时,会产生一个 IRQ 中断,CPU 接收到这个中断信号后才会读取具体是哪一个按键
- 回想一下前面 8259A 芯片初始化操作,IRQ1 的中断号被设置为了 0x21
- 于是我们实现一个 0x21 中断服务程序,测试一下当我们按下按键后,该中断会不会触发
- 创建 “keyboard.c” 文件放入 “drivers” 文件夹下面,“keyboard.h” 文件放到 “include” 文件夹下,“drivers” 目录下多了一个源文件,那么对应的 “BUILD.json” 配置文件中的 "src" 项中也要添加 "keyboard.c"
- 我们先在 “keyboard.c” 实现一个按键中断服务函数,函数内容暂时先不实现,仅打印提示
void KeyboardIntHandle(void) { printk("KeyboardIntHandle\n"); } • 不是你说 KeyboardIntHandle 是中断服务函数,它就是中断服务函数的,得把它放到中断入口处调用才行,于是还要修改 “interrupt.asm” 中的 0x21 号中断入口程序 extern KeyboardIntHandle Int0x21_Entry: BeginISR call KeyboardIntHandle EndISR
- 为了防止任务打印影响调试打印效果,我们把 “app.c” 中的任务给注释掉
// AppRegister(TaskA, TaskAStack, 256, "TaskA", E_APP_PRI5); // AppRegister(TaskB, TaskBStack, 256, "TaskB", E_APP_PRI5);
- 编译,运行,随便按下键盘任意一个按键,发现成功打印了 "KeyboardIntHandle"
- 虽然按键中断被触发了,但是好像只有第一次按按键有效,这个问题我们在实现 0x20 号中断时也遇到过,调用 write_m_EOI() 手动结束主 8259A 中断即可
void KeyboardIntHandle(void) { printk("KeyboardIntHandle\n"); write_m_EOI(); }
- 哈哈哈,最终发现按键还是只有第一次生效,这是为啥呢?别着急,接着往下看
键位读取
- 一个很明显的问题,键盘现在只有一个中断引脚,键盘上面一大堆的按键,我们怎么知道具体按了哪个按键呢?这就要从键盘的工作原理开始讲起
- 首先,我们使用一颗名为 8042 的芯片来专门管理按键数据,当检测按键动作后,8042 会将键位信息存储到缓冲器中
- 之后通过与 8259A 之间的 IRQ1 中断引脚发送中断请求
- 8259A 仲裁后通过 INTR 引脚向 CPU 发送中断请求
- CPU 在得到中断请求后从端口 0x60 读取 8042 中的键位信息
- 其实 8042 和 键盘之间还有一个 8048 芯片,CPU 向 8042 发送指令后,8042 会再转给8048,暂时我们可以忽略掉 8048 的存在
- 以上的流程中我们其实只剩下从端口 0x60 读取键位信息了,其实这个实现起来非常简单,内嵌一条汇编指令就可以了
void KeyboardIntHandle(void) { U08 key = 0; // in al, 0x60 ; 从 0x60 端口读一个字节数据 asm volatile("inb $0x60, %%al; nop;" :"=a"(key)); printk("%x ", key); write_m_EOI(); }
- 随便按几个按键,看看效果。从打印结果来看,键位信息都是成对出现的,分别对应着按下和释放。并且按下和释放的值相差 0x80
- 貌似只有按键只有第一次生效的问题也解决了,这说明 8042 这颗芯片只有在读取键位信息后才可以产生下一次中断
扩展按键
- 上面我们已经能读出来数值了,这个数值我们称之为键盘扫描码,通过上面的测试实验我们可以看出,每按一次按键都会触发两次中断(按下和释放),从而读到两个扫描码,事情看起了并不复杂,然而现实情况是并不是所有按键的扫描码都是一个字节,键盘上除了普通按键还有 E0、E1 等扩展按键
- 普通按键的扫描码是 1 字节
- E0 扩展按键是 2 字节(0xE0 + 按键扫描码),比如除了主键盘上有一个 'Enter' 键,小键盘上也有一个 'Enter' 键,该键的扫描码与主键盘 'Enter' 键扫码码一样,但是该键还多了一个字节 0xE0 的前缀
- E1 扩展按键是 3 字节(暂时我们只使用到 'Pause' 这个按键)
- E2 扩展按键,暂时我们不处理
- 我们可以利用上面的实验代码实际体验一些扩展按键,比如 'Pause' 键,当敲击该键后屏幕打印出 ‘0xE1 0x1D 0x45 0xE1 0x9D 0xC5’,从现象上我们可以得出,普通按键按下时触发一次中断,释放时再触发一次中断,而扩展按键按下时可能触发多次中断,同样的,释放时也可能触发多次中断
按键循环队列
- 我们知道,每敲击一个按键,那么一下子产生两个甚至多个按键扫描码,自然的我们就应该想到给按键做一定的缓冲区策略以防止由于来不及处理而导致键值丢失的情况,比较好的缓冲策略就是循环缓冲区,循环缓冲区的实现我想大家并不陌生,网上各种函数实现方式都有,但是现在我们准备在内核中实现循环缓冲区,还是需要考虑一定的效率问题,于是,我不准备使用函数的形式而是设计了下面的宏定义方式来实现循环缓冲区。创建一个 ring.h 头文件放到 “include” 目录下,其内容如下:
// 循环队列初始化 #define RING_INIT(queue) \ (void)({(queue)->head = 0; (queue)->tail = 0; (queue)->len = 0;}) // 缓冲区指针 a 前移,若已超出缓冲区右侧,则指针循环 #define RING_INC(a) ((a) = (a) < (RING_BUF_SIZE-1) ? (a)+1 : 0) // 循环队列中已存放数据个数 #define RING_NUMS(queue) ((queue)->len) // 往循环队列 queue 中放入一个数据 #define RING_PUT(c, queue) \ (void)({(queue)->buf[(queue)->head] = (c); RING_INC((queue)->head); (queue)->len < RING_BUF_SIZE ? (queue)->len++ : RING_INC((queue)->tail);}) // 从循环队列 queue 中取出一个数据 #define RING_GET(queue, c) \ (void)({c = (queue)->buf[(queue)->tail]; RING_INC((queue)->tail); (queue)->len ? (queue)->len-- : 0;})
- 使用前需要定义一下如下内容,RING_BUF_SIZE 表示循环缓冲区的容量,struct RingQueue 结构体中的 U08 buf[] 是实际数据缓冲区,数据类型可以自定义,哪怕数据类型是 struct 结构体类型也是可以的
#define RING_BUF_SIZE 32 typedef struct RingQueue { U32 head; // 循环队列缓冲区中数据头指针 U32 tail; // 循环队列缓冲区中数据尾指针 U32 len; // 循环队列缓冲区数据长度 U08 buf[RING_BUF_SIZE]; // 循环队列的缓冲区 } RingQueue;
- 接下来我们就把该循环缓冲区用在键值处理上面
- 有了上面的定义后,我们先实例化出一个对象
RingQueuekeyQueue={0};
- 用起来非常简单,只要使用 RING_PUT 宏往循环换成区里面放数据即可
void KeyboardIntHandle(void) { U08 key = 0; // in al, 0x60 ; 从 0x60 端口读一个字节数据 asm volatile("inb $0x60, %%al; nop;" :"=a"(key)); RING_PUT(key, &keyQueue); write_m_EOI(); }
测试按键循环队列
- 内核中只负责存放键值,键值的获取应当放到任务中实现,为了测试按键循环队列的功能,我们实现一个读取按键的系统调用吧,相关代码实现见:u_syscall.c、u_syscall.h、keyboard.c、keyboard.h、syscall.c、app.c
- 首先我们要实现一个读按键值的系统调用,用户层实现一个 ReadKey() 函数,内核层实现 SYS_ReadKey() 函数,系统调用的实现流程这里就不说了,前面章节中已经说了好几遍了
- “user” 用户接口层 "u_syscall.c" 中实现代码如下:
U32 ReadKey(void) { return _SYS_CALL0(_NR_ReadKey); } • 内核 “keyboard.c” 中对应的函数如下: U32 SYS_ReadKey(void) { U32 key = 0; if(RING_NUMS(&keyQueue)) RING_GET(&keyQueue, key); return key; }
- 接下来我们在 “app.c” 中实现如下任务,测试读取按键值功能
void TaskA(void) { U32 key = 0; while (1) { if(key = ReadKey()) print("%x ", key); } }
- 最终现象与在 KeyboardIntHandle() 中断服务函数中直接打印是一样的,我们仅仅只是增加了缓冲机制
解析扫描码
- 我们已经获取到按键的扫描码了,并且给扫描码也做了循环缓冲区,那么,键盘扫描码可以直接拿来使用吗?扫描码代表键位上的字符吗?
- 扫描码不是字符编码,不能直接拿来使用
- 在这里,我们要先理清楚扫描码、虚拟按键统一编码和 ASCII 码之间的区别
- 扫描码:与键盘厂商相关,不同厂商的扫描码可能不同
- 虚拟按键统一编码:键盘按键统一标准编码,与键盘厂商无关。既然不同的厂商的扫描码可能是不一致的,那么程序又应该以那个厂商为标准呢?为此,我们提出来虚拟键码的概念,所有上层程序都使用同一套虚拟键码,而不同厂商只需要建立一个映射表,将自己的扫描码映射成对应的虚拟键码就可以了
- ASCII 码:常用的字符统一编码,与键盘按键无关,它是针对字符的编码,而扫描码和虚拟键码是针对按键的编码。比如键盘上的按键 'A',该按键却对应着两个字符,一个大写的 'A',一个小写的 'a'
- 于是,接下来的工作目标就有了,那就是解析扫描码,首先我们要确定一下想把扫描码解析成什么样子
- 构思设计一种数据结构,能够有效的表示键盘操作,用 4 个字节来表示键盘操作,从高到低依次表示:| 动作 | 扫描码 | 虚拟键码 | ASCII 码 |
- 比如键盘输入 'a',按下:0x011E4161,释放:0x001E4161
- 比如键盘输入 'A',按下:0x011E4141,释放:0x001E4141
- 想要解析扫描码,先看一下我们所面临的问题
- 常规按键与 E0、E1 扩展按键字节数不同
- 特殊按键 Shift、CapsLock、Num
- 数字小键盘
- 不同键盘厂商的按键扫描码可能是不同的,上层应用程序使用的肯定是统一标准的虚拟键码,然而虚拟键码和扫描码之间并没有固定的逻辑关系,所以我们可以提前构建一张扫描码、虚拟键码、ASCII 码着三者之间的映射表。映射表实现如下:
static const KEY_CODE KeyMap[] = { /* 0x00 - none */ { 0, 0, 0, 0 }, /* 0x01 - ESC */ { 0, 0, 0x01, 0x1B }, /* 0x02 - '1' */ { '1', '!', 0x02, 0x31 }, /* 0x03 - '2' */ { '2', '@', 0x03, 0x32 }, ... };
- 这个映射表以 KEY_CODE 数据结构为基本单元,我们看一下 KEY_CODE 的格式,这里为什么有 ascii1 和 ascii2 两个元素呢?因为同一个按键可能会有两种 ascii 码值,比如按键 '1' 还有一个值 '!',按键 'a' 也有一个值 'A'
typedef struct KEY_CODE { U08 ascii1; // 常规状态下按键 ascii 码 U08 ascii2; // 按下 Shift 后 ascii 码 U08 scode; // 扫描码 U08 vcode; // 虚拟键码 } KEY_CODE;
- 我们以扫描码作为数组 KeyMap 的下标,比如当我们按下 '1' 按键时,此时的扫描码为 0x02, 于是就可以找到 KeyMap[2].ascii1 = '1'; KeyMap[2].ascii2 = '!'; KeyMap[2].scode = 0x02; KeyMap[2].vcode = 0x31;
- 接下来就按键解析处理的逻辑实现了,首先我们要理清楚主键盘、数字小键盘、还有扩展按键之间的关系。小键盘并不是扩展键盘,它的扫描码也只有一个字节,其中 '/'、'*'、'-'、'+'、'Enter'、'.' 是与主键盘上对应的按键扫描码的值是一样的,我们可以理解为这些按键只是在硬件上被复制了一份,软件处理上并不需要分开处理
- 数字小键盘上只有数字 '0' ~ '9' 与主键盘的扩展按键扫描码冲突,我们可以给这几个数字按键重新构建一个映射表,主键盘按键与扩展按键共用一个 KeyMap 映射表即可
staticconstKEY_CODENumKeyMap[]=
{
{'0', 0, 0x52, 0x2D},
{'1', 0, 0x4F, 0x23},
{'2', 0, 0x50, 0x28},
{'3', 0, 0x51, 0x22},
{'4', 0, 0x4B, 0x25},
{'5', 0, 0x4C, 0x0C},
{'6', 0, 0x4D, 0x27},
{'7', 0, 0x47, 0x24},
{'8', 0, 0x48, 0x26},
{'9', 0, 0x49, 0x21},
}
- 在逻辑上我们如下设计,先按 E1、E0 以及常规按键分类,在常规按键中又分是否时小键盘
U32 ScanCodeAnalysis(RingQueue* rq) { U32 ret = 0; // 循环队列 rq 中若没有数据,则退出 if(!RING_NUMS(rq)) return 0; // 从循环队列 rq 中取出一个数据 RING_GET(rq, key); // 先判断是否是扩展 E0、E1 按键 if(IS_E1(key)) E1 = 3; else if(IS_E0(key)) E0 = 2; // 优先处理 E0、E1 扩展按键 if(E1) { if(1 == E1) { // ret = } E1--; } else if(E0) { if(1 == E0) { // ret = } E0--; } else { if(IS_NUM_KEY(key)) // 数字小键盘按键 { // ret = } else // 常规按键 { // ret = } } return ret; }
- 有了逻辑框架下面我们就来看一下完整的解析实现
static U32 ScanCodeAnalysis(RingQueue* rq) { U32 ret = 0; // | 动作 | 扫描码 | 虚拟键码 | ASCII 码 | U08 key = 0; static U08 key2 = 0; static U08 key3 = 0; U08 keytype = 0; static U08 shift = 0; static U08 capslock = 0; static U08 numlock = 0; KEY_CODE* pKey = NULL; U08 index = 0; static U08 E0 = 0; static U08 E1 = 0; // 循环队列 rq 中若没有数据,则退出 if(!RING_NUMS(rq)) return 0; // 从循环队列 rq 中取出一个数据 RING_GET(rq, key); // 先判断是否是扩展 E0、E1 按键 if(IS_E1(key)) E1 = 3; else if(IS_E0(key)) E0 = 2; // 优先处理 E0、E1 扩展按键 if(E1) { if(2 == E1) key2 = key; else if(1 == E1) { key3 = key; keytype = KEY_TYPE(key2); if(RELEASE == keytype) key2 -= 0x80; keytype = KEY_TYPE(key3); if(RELEASE == keytype) key3 -= 0x80; if(0x1D == key2 && 0x45 == key3) ret = (keytype << 24) | (0x5E << 16) | (0x13 << 8) | 0; } E1--; } else if(E0) { if(1 == E0) { keytype = KEY_TYPE(key); if(RELEASE == keytype) key -= 0x80; pKey = (KEY_CODE*)KeyMap + key; if(pKey->scode == key) // 扫描码并不一定全部识别,有些未定义的扫描码我们不做处理 ret = (keytype << 24) | (pKey->scode << 16) | (pKey->vcode << 8) | pKey->ascii1; } E0--; } else { keytype = KEY_TYPE(key); // 同一按键的扫描码释放比按下大 0x80,统一一下按键值接下来会比较好处理 if(RELEASE == keytype) key -= 0x80; // 先处理一下几个特殊按键状态 if(IS_SHIFT(key)) shift = keytype; else if(IS_CAPSLOCK(key) && RELEASE == keytype) capslock = !capslock; else if(IS_NUMLOCKS(key) && RELEASE == keytype) numlock = !numlock; if(IS_NUM_KEY(key)) // 数字小键盘按键 { for(index = 0; index < sizeof(NumKeyMap)/sizeof(NumKeyMap[0]); index++) { pKey = (KEY_CODE*)NumKeyMap + index; if(pKey->scode == key) { if(numlock) { ret = (keytype << 24) | (pKey->scode << 16) | (pKey->vcode << 8) | pKey->ascii1; break; } } } } else // 常规按键 { pKey = (KEY_CODE*)&KeyMap[key]; if(pKey->scode == key) // 扫描码有效 { if(capslock) { if(shift) ret = (keytype << 24) | (pKey->scode << 16) | (pKey->vcode << 8) | pKey->ascii1; // 小写 else ret = (keytype << 24) | (pKey->scode << 16) | (pKey->vcode << 8) | pKey->ascii2; // 大写 } else { if(shift) ret = (keytype << 24) | (pKey->scode << 16) | (pKey->vcode << 8) | pKey->ascii2; // 大写 else ret = (keytype << 24) | (pKey->scode << 16) | (pKey->vcode << 8) | pKey->ascii1; // 小写 } } } } return ret; }
- 关于解析扫描码 ScanCodeAnalysis 函数的具体实现在这里就不做过多介绍了,简单说一下实现过程中遇到的坑吧,解析 E0、E1 扩展按键时出问题。一开始我默认为 RingQueue* rq 循环队列中有多个扫描码,然后一个一个的取出来解析,然而实际上是当遇到扩展按键时, RingQueue* rq 循环队列中一开始只有一个数据,ScanCodeAnalysis 函数本身被调用 n 次后才会触发第二次中断,然后 RingQueue* rq 循环队列中才会有下一个数据,并不是 ScanCodeAnalysis 在解析前 RingQueue* rq 循环队列中就有多个键值数据了
- 解析函数实现之后,得想办法测试一下,还是利用前面实现的 ReadKey 系统调用测试吧
// 内核中 U32 SYS_ReadKey(void) { return ScanCodeAnalysis(&keyQueue); } // 任务中 void TaskA(void) { U32 key = 0; while (1) { if(key = ReadKey()) { if((key & (0xFF << 24))) print("%c", (key & 0xFF)); // 打印按键 ASCII 码 } } }
- 完整代码见:keyboard.c、app.c
- 最终的最终,我们来看一下成功展示: