概述
上一篇中完成了终端的输出封装,为了完成和内核的交流,输入也是必不可少的。键盘驱动的实现能够方便内核获取键盘的输入数据,而与内核的交流是通过指令实现的,这就需要一段空间暂存输入的字符,拼接成完整的指令后交给相关模块处理,这段空间即为环形输入缓冲区。
从键盘获取输入基本流程
通常,键盘上的按键按下或弹起时,键盘中的Intel 8048会将第二套键盘扫描码中该键位对应的通码或断码发送至Intel 8042。Intel 8042为了保证兼容性,会将收到的扫描码转换成第一套键盘扫描码,并存入自己的输出缓冲寄存器,同时向Intel 8259A可编程中断控制器的IR1引脚发送中断请求。Intel 8259A向CPU发出向量号为0x21的中断。此时操作系统执行键盘中断处理程序(键盘驱动),从Intel 8042的输出缓冲寄存器中读取扫描码,同时通过分析扫描码生成对应的ASCII码,并将ASCII码存入环形输入缓冲区。
环形输入缓冲区基本设计思路
线程同步
诠释线程同步的经典例子是“生产者与消费者问题”。在此问题中,有一个或多个生产者线程向缓冲区中存放数据,一个或多个消费者线程从缓冲区中取数据,一个有限容量的缓冲区。该问题主要探讨对于有限大小的公共缓冲区,如何同步生产者与消费者的运行,以达到对共享缓冲区的互斥访问,并且保证生产者不会过度生产,消费者不会过度消费,缓冲区不会被破坏。
若生产者与消费者的行为有如下规定:
生产者:缓冲区中有空余位置时,向缓冲区中存放数据,缓冲区满时则休眠。
消费者:缓冲区中有数据时,从缓冲区中取数据,缓冲区为空时休眠。
这样就很好地解决了生产者与消费者线程的同步问题,但仅仅如此还是不够的,因为缓冲区是多个线程共享的资源,因此对它的访问应当做到互斥,即同一时刻只能有一个生产者或消费者对缓冲区进行操作。
逻辑环
环形输入缓冲区只是形象的说法,实际的缓冲区在内存中依旧是线性的数组,只是通过取模的方式,使到达数组上边界的索引映射到了数组的下边界,在逻辑上形成了一个环形的缓冲区。
关键部分实现
环形输入缓冲区实现
- 消费者获取数据:
//消费者从ioq中获取一个字符
char ioq_getchar(ioqueue *ioq) {
//处于关中断状态
ASSERT(intr_get_status() == INTR_OFF);
//若缓冲区为空,将当前线程作为消费者阻塞于ioq
while (ioq_empty(ioq)) {
lock_acquire(&ioq->lock);
ioq_wait(&ioq->consumer);
lock_release(&ioq->lock);
}
//获取缓冲区中的数据
char byte = ioq->buf[ioq->tail];
//将队尾索引后移一位
ioq->tail = next_pos(ioq->tail);
//若ioq有睡眠的生产者,唤醒生产者
if (ioq->producer != NULL) {
wakeup(&ioq->producer);
}
return byte;
}
ioq_getchar()
函数在关中断的情况下执行,因此能够保证对缓冲区操作的互斥性。若缓冲区为空,则当前线程获取环形输入缓冲区的锁,并进入休眠,之后来的消费者线程则会阻塞在锁的等待队列。等待生产者线程将当前线程唤醒后才释放锁。
键盘驱动实现
//键盘中断处理程序
static void intr_keyboard_handler(void) {
//检测控制键是否处于按下状态
bool ctrl_down_last = ctrl_status;
bool shift_down_last = shift_status;
bool caps_lock_last = caps_lock_status;
//检测此次扫描码是否为断码
bool break_code;
//读取扫描码,可能为扩展扫描码,即0xe0xx,因此变量声明为uint16_t
uint16_t scancode = inb(KBD_BUF_PORT);
//若此次扫描码为0xe0,则为扩展扫描码
if (0xe0 == scancode) {
ext_scancode = true; //扩展扫描码标志位置1并返回
return; //等待下一次中断读取扩展扫描码的剩余字节
}
//若扩展扫描码标志位为1,则此次扫描码为扩展扫描码的后续字节
if (ext_scancode) {
scancode = (0xe000 | scancode); //合并扩展扫描码
ext_scancode = false; //扩展扫描码标志位复位
}
//检测该扫描码是否为断码
break_code = ((scancode & 0x0080) != 0);
//若该扫描码为断码,其中接受处理的只有控制键位的断码
if (break_code) {
//获取对应的通码
uint16_t make_code = (scancode &= 0xff7f);
//根据通码修改相关功能键的标志位
if (make_code == ctrl_l_make || make_code == ctrl_r_make) {
ctrl_status = false;
}
else if (make_code == shift_l_make || make_code == shift_r_make) {
shift_status = false;
}
else if (make_code == alt_l_make || make_code == alt_r_make) {
alt_status = false;
}
return;
}
//若为通码,只处理数组中存在的键位和ctrl_right与alt_right
else if ((scancode > 0x00 && scancode < 0x3b) || (scancode == ctrl_r_make) || (scancode == alt_r_make)) {
//判断是否有shift组合
bool shift = false;
if ((scancode < 0x0e) || (scancode == 0x29) || \
(scancode == 0x1a) || (scancode == 0x1b) || \
(scancode == 0x2b) || (scancode == 0x27) || \
(scancode == 0x28) || (scancode == 0x33) || \
(scancode == 0x34) || (scancode == 0x35)) {
//已登录的非字母扫描码
if (shift_down_last) {
shift = true;
}
}
else {
//默认为字母扫描码
if (shift_down_last && caps_lock_last) {
return; //shift与capslock同时按住再按字母键,直接返回
}
else if (shift_down_last || caps_lock_last) {
shift = true;
}
else {
shift = false;
}
}
//获取通码,过滤掉扩展扫描码的0xe0
uint8_t index = (scancode &= 0x00ff);
//获取对应字符ASCII
char cur_char = keymap[index][shift];
//若为可显示字符,即不为0
if (cur_char) {
//键盘缓冲区不满时响应
if (!ioq_full(&kbd_buf)) {
//字符输出至终端
//put_char(cur_char);
//字符写入键盘缓冲区
ioq_putchar(&kbd_buf, cur_char);
}
return;
}
//记录控制键是否按下
if (scancode == ctrl_l_make || scancode == ctrl_r_make) {
ctrl_status = true;
}
else if (scancode == shift_l_make || scancode == shift_r_make) {
shift_status = true;
}
else if (scancode == alt_l_make || scancode == alt_r_make) {
alt_status = true;
}
else if (scancode == caps_lock_make) {
//大写锁按下后反转状态即可
caps_lock_status = !caps_lock_status;
}
}
else {
put_str("unknown key\n");
}
}
对于控制键位来说,持续的按压并不会使Intel 8042持续地发出中断请求,控制键只会在按下与弹起时发出中断,因此控制键通码即为按下的标志,断码即为弹起标志。
实验检测
main.c
文件代码:
#include "print.h"
#include "init.h"
#include "thread.h"
#include "interrupt.h"
#include "console.h"
#include "keyboard.h"
#include "ioqueue.h"
//测试内核线程函数
void k_thread_a(void *);
void k_thread_b(void *);
int main(void) {
put_str("kernel\n\n");
init_all();
//创建内核线程执行void k_thread_*(void*)
thread_start("consumer_a", 32, k_thread_a, " A_");
thread_start("consumer_b", 32, k_thread_b, " B_");
//开中断
intr_enable();
while (1);
return 0;
}
void k_thread_a(void *arg_) {
char *arg = (char*)arg_;
while (1) {
intr_status old_status = intr_disable();
if (!ioq_empty(&kbd_buf)) {
console_put_str(arg);
//作为消费者从键盘缓冲区中获取一个字节
char byte = ioq_getchar(&kbd_buf);
console_put_char(byte);
}
intr_set_status(old_status);
}
}
void k_thread_b(void *arg_) {
char *arg = (char*)arg_;
while (1) {
intr_status old_status = intr_disable();
if (!ioq_empty(&kbd_buf)) {
console_put_str(arg);
//作为消费者从键盘缓冲区中获取一个字节
char byte = ioq_getchar(&kbd_buf);
console_put_char(byte);
}
intr_set_status(old_status);
}
}
对于此段代码来说,其运行结果是十分正常的,但我总觉得哪里还是不太清楚,就是这个if (!ioq_empty(&kbd_buf))
。不知道有没有哪位和我有一样的问题——“按理来说,没有这句代码也是能够正常运行的,为什么要做这步判断呢?”。
本着“不懂就删”的原则(我的一点个人见解,不理解功能的东西被删去时,暴露出的问题往往就是理解其功能的关键),就有了这样的结果:
暴露出的问题总共有两个:
各线程的参数 " X_" 的输出会与 byte 的输出脱节,就比如图中的 " B_k" 中的 k 实际是前面的 " A_"在被键盘驱动唤醒后获取的 byte。
后面的输出中没有再出现过 " B_"。
第一个问题的原因是很容易理解的,那么第二个问题是如何出现的呢?其实这也不难理解,仔细想想就可以得出大概的运行过程:
最开始时由 Main 线程创建 A 、B 两个线程;
当 Main 线程时间片到期时调度 A 线程上CPU执行,由于此时
kbd_buf
为空,所以 A 线程被阻塞于kbd_buf.consumer
,并调度 B 线程上CPU执行;由于当前 A 线程拥有
kbd_buf.lock
,所以 B 线程被阻塞于kbd_buf.lock.semophore.waiters
,同时调度 Main 线程上CPU执行;由于此时 A、B 线程都被阻塞,因此只有 Main 线程在CPU与就绪队列之间反复切换;
当按下按键时,键盘驱动向
kbd_buf
中存入数据,同时唤醒kbd_buf.consumer
上的 A 线程,将其添加至就绪队列队首(此时就绪队列中只有 A 线程),待 Main 线程时间片到期时调度 A 线程上CPU执行;A 线程释放锁,此时 B 线程被唤醒,插入到就绪队列队首(此时就绪队列中有 B 和 Main 线程),A 线程从
kbd_buf
中获取byte并输出,紧接着进入下一次循环,又被阻塞在kbd_buf.consumer
,同时调度 B 线程上CPU执行;B 线程此时心态是炸裂的,它又被阻塞于
kbd_buf.lock.semophore.waiters
,只能再次调度 Main 线程上CPU执行,之后就是第4步到第7步的循环。
因此可以看出,B 线程其实每次按键都会被调度上CPU,只是一直没有机会执行输出语句罢了,为了验证想法,我将函数thread_block()
修改为:
//阻塞当前运行的线程,将其状态置为stat
void thread_block(task_status stat) {
//检测将置为的状态是否符合阻塞要求
ASSERT(stat == TASK_BLOCKED || stat == TASK_WAITING || stat == TASK_HANGING);
//关闭中断,保证阻塞操作的原子性
intr_status old_status = intr_disable();
//将当前线程的状态设为stat
task_struct *cur_thread = running_thread();
//添加部分,输出当前将被阻塞线程信息
put_str(cur_thread->name);
put_char('\n');
cur_thread->status = stat;
//调用任务调度器
schedule();
//恢复中断状态(在下次该线程被调上CPU时执行)
intr_set_status(old_status);
}
能够得到结果:
和之前得到的结论相符。
但如果细想的话,其实 B 线程是有机会获取锁的,只要在 A 线程执行关中断前和开中断后的某一条指令时,时间片到期,B 线程就能够调度上CPU并获取锁。但是删去了if (!ioq_empty(&kbd_buf))
后,使得时钟中断非常玄学,因为只有 A 线程处于开中断时,收到时钟中断才会使ticks
减小,但 A 线程又基本属于关中断的状态。还是上图能清晰一些:
如上图,时钟中断是由Intel 8253按之前设定好的频率发出的,假设按住键盘上的某一个键位不放,Intel 8042发出的键盘中断也是具有周期的(这里只是大概定性说明,实际Intel 8042的中断频率应当远远低于Intel 8253),A 线程只有在键盘中断发生后很短时间内处于开中断的状态,也就是说只有图中时钟中断与键盘中断的线大概重合时,才会使 A 线程的ticks
减少。
因此综上所述,if (!ioq_empty(&kbd_buf))
的作用就是当kbd_buf
为空时,使各线程空转,至少不会全阻塞在缓冲区的消费者和锁中,能够使各线程稳定消耗时间片,使各个线程都有从缓冲区中获取 byte 的机会。