selph
selph
Published on 2021-02-09 / 877 Visits
0
0

操作系统真象还原 学习笔记11--输入输出系统

本篇对应书籍第十章的内容

本篇内容介绍了同步机制--锁的原理和实现、使用锁重新封装了之前的打印函数;介绍了键盘输入的原理,编写键盘驱动程序实现键盘输入,介绍了环形缓冲区,用作键盘输入缓冲区。

本篇难点:

  • 理解同步机制--锁和信号量

同步机制--锁

上一章中遇到的字符混乱和GP异常的问题,原因是临界区代码的资源竞争,需要一些互斥的方法保证操作的原子性

排查 GP 异常,理解原子操作

上一章中出现的字符丢失、大片空缺、GP异常问题,是由于字符串写入操作没有使用原子操作所导致的

字符串写入分为3个步骤:

  1. 获取光标值
  2. 将光标值转为字节地址,在地址中写入字符
  3. 更新光标值

线程调度工作的核心是线程的上下文保护与还原

这里访问的公共资源是显存,任务调度的时候,如果线程A执行到了获取光标值被中断,当线程A还原执行的时候,此时光标值已经被改变了,而线程A会从第二个步骤开始执行,所以导致字符丢失、字符出现的位置不对的问题

GP异常则是在写入光标值的时候发生中断所导致的,导致光标被赋予了错误的值,甚至超出了边界,导致了GP异常

根本原因就是访问公共资源需要多个操作,而这多个操作执行不具有原子性,导致被任务调度器断开了,从而让其他线程有机会破坏显存和光标寄存器这两类公共资源现场

找出代码中的临界区、互斥、竞争条件

这里要先介绍几个术语:

  • 公共资源:被所有任务共享的一套资源
  • 临界区:各任务中访问公共资源的指令代码组成的区域
  • 互斥:指某一时刻公共资源只能被1个任务独享
  • 竞争条件:多个任务以非互斥的方式同时进入临界区,大家对公共资源的访问时以竞争的方式进行的

多线程访问公共资源时出现问题的根本原因就是产生了竞争条件,多个任务同时处于自己的临界区,为避免产生竞争条件,必须保证互斥

这里的临界区函数是put_char,非互斥导致该函数不能执行完成从而产生了竞争条件

信号量

这里我们的互斥机制--锁,是通过信号量来实现的

信号量是0以上的整数,是个计数器,信号量仅仅是一个程序设计构造的方法

如果信号量的值为1,则表示这是个二元信号量,信号量的值表示信号资源的积累量,是全局变量

对信号量的操作分为两种,up、down:

up操作:

  1. 信号量+1
  2. 唤醒在此信号量上等待的线程

down操作:

  1. 判断信号量是否大于0
  2. 大于0则信号量-1
  3. 等于0则当前线程将自己堵塞,在此信号量上等待

在二元信号量中,让线程通过锁进入临界区,大致流程如下:

  1. 线程A进入临界区先通过 down 操作获得锁,此时信号量为0
  2. 线程B进入临界区也通过 down 操作获得锁,但是信号量是0,则在此信号量上等待
  3. 线程A从临界区出来后执行 up 操作释放锁,信号量值变成1,之后线程A将线程B唤醒
  4. 线程B醒来后获得了锁,进入临界区

线程的堵塞与唤醒

通过二元信号量实现锁的功能之前,我们需要先实现线程的堵塞与唤醒功能

线程的堵塞是线程的行为而不是调度器的行为,堵塞发生在线程运行的时候,所以发生堵塞的时候,线程的时间片没用完

唤醒是被动的行为,唤醒需要由锁的持有者进行,接下来看看实现吧:

thread/thread.c

增改如下内容:

// 当前线程将自己阻塞, 标志其状态为 stat
void thread_block(enum task_status stat) {
    ASSERT(stat == TASK_BLOCKED || 
           stat == TASK_WAITING || 
           stat == TASK_HANGING);
    enum intr_status old_status = intr_disable();
    struct task_struct* cur_thread = running_thread();
    cur_thread->status = stat;
    schedule(); // 将当前线程换下处理器
    intr_set_status(old_status);
}

// 将线程解除阻塞
void thread_unblock(struct task_struct* pthread) {
    enum intr_status old_status = intr_disable();
    ASSERT(pthread->status == TASK_BLOCKED || 
           pthread->status == TASK_WAITING || 
           pthread->status == TASK_HANGING);
    ASSERT(!elem_find(&thread_ready_list, &pthread->general_tag));
    // 放在就绪队列最前面, 使其尽快得到调度
    list_push(&thread_ready_list, &pthread->general_tag);
    pthread->status = TASK_READY;
    intr_set_status(old_status);
}

堵塞功能函数的参数只有一个,就是非运行的状态,实现则是关中断获取当前状态,然后修改当前状态为非运行的状态,然后换下处理器,等待恢复之后开中断

唤醒功能函数的参数是被堵塞线程,关中断获取当前状态之后,将堵塞线程放到就绪队列首,然后修改状态为READY,然后恢复中断

那这里就有一个问题,线程是怎么知道其他线程堵塞了,要去唤醒其他线程呢?以及当前线程自己堵塞了之后,关掉了中断,那么下一个调度的线程如果不使用该锁,岂不是可以无限执行下去了吗?先接着往下看吧

锁的实现

thread/sync.h

#ifndef __THREAD_SYNC_H
#define __THREAD_SYNC_H
#include "list.h"
#include "stdint.h"
#include "thread.h"

// 信号量结构
struct semaphore {
    uint8_t value;
    struct list waiters;
};

// 锁结构
struct lock {
    struct task_struct* holder; // 锁的持有者
    struct semaphore semaphore; // 用二元信号量实现锁
    uint32_t holder_repeat_nr;  // 锁的持有者重复申请锁的次数
};

#endif

看这个结构就知道堵塞唤醒是怎么一回事了,锁结构中除了有锁的持有者,还有信号量结构,信号量结构里有一个链表,这个链表记录的是等待的线程,当前锁的持有者用完临界区函数之后,恢复等待的线程队列中的线程即可

thread/sync.c

#include "sync.h"
#include "list.h"
#include "global.h"
#include "debug.h"
#include "interrupt.h"

// 初始化信号量
void sema_init(struct semaphore* psema, uint8_t value) {
    psema->value = value;
    list_init(&psema->waiters);
}

// 初始化锁 plock
void lock_init(struct lock* plock) {
    plock->holder = NULL;
    plock->holder_repeat_nr = 0;
    sema_init(&plock->semaphore, 1); // 锁的信号量初值为 1
}

// 信号量 down 操作
void sema_down(struct semaphore* psema) {
    // 关中断来保证原子操作
    enum intr_status old_status = intr_disable();
    while(psema->value == 0) { // value 为0, 表示已经被别人持有
        ASSERT(!elem_find(&psema->waiters, &running_thread()->general_tag));
        if(elem_find(&psema->waiters, &running_thread()->general_tag)) {
            PANIC("sema_down: thread blocked has been in waiters_list\n");
        }
        // 若信号量等于 0, 则当前线程把自己加入该锁的等待队列, 然后阻塞自己
        list_append(&psema->waiters, &running_thread()->general_tag);
        thread_block(TASK_BLOCKED); // 阻塞线程, 直到被唤醒
    }
    // 若 value 为 1 或被唤醒后, 会执行下面的代码, 也就是获得了锁
    psema->value--;
    ASSERT(psema->value==0);
    intr_set_status(old_status);
}

down 操作是核心函数,关中断后,通过while判断信号量是否可用:

  • 如果可用,就把信号量值-1,恢复中断
  • 如果不可用,则当前线程把自己加入该锁的等待队列,然后堵塞自己

这里如果信号量不可用,则循环判断是否可用,把自己加入到该信号量的等待队列中

// 信号量 up 操作
void sema_up(struct semaphore* psema) {
    // 关中断保证原子操作
    enum intr_status old_status = intr_disable();
    ASSERT(psema->value == 0);
    if(!list_empty(&psema->waiters)) {
        struct task_struct* thread_blocked = elem2entry(struct task_struct, general_tag, list_pop(&psema->waiters));
        thread_unblock(thread_blocked);
    }
    psema->value++;
    ASSERT(psema->value == 1);
    intr_set_status(old_status);
}

// 获取锁 plock
void lock_acquire(struct lock* plock) {
    // 排除曾经自己已经持有锁但还未将其释放的情况
    if(plock->holder != running_thread()) {
        sema_down(&plock->semaphore);
        plock->holder = running_thread();
        ASSERT(plock->holder_repeat_nr == 0);
        plock->holder_repeat_nr = 1;
    } else {
        plock->holder_repeat_nr++;
    }
}

// 释放锁 plock
void lock_release(struct lock* plock) {
    ASSERT(plock->holder == running_thread());
    if(plock->holder_repeat_nr > 1) {
        plock->holder_repeat_nr--;
        return;
    }
    ASSERT(plock->holder_repeat_nr == 1);
    plock->holder = NULL;
    plock->holder_repeat_nr = 0;
    sema_up(&plock->semaphore);
}

up 操作比 down 要简单一些,直接判断等待队列是否是空的,如果不是,则取出来一个线程,并进行唤醒(加入回就绪队列中),然后再将信号量还原

获取锁函数则需要先判断自己是否已经得到锁了,以防死锁,就是自己等待自己释放锁

释放锁函数则是判断自己用了几次这个锁,如果只有1次,则释放,反之则减一然后返回

用锁实现终端输出

终端

多用户访问同一个系统的终端的时候,之所以会看见不同的内容,是因为不同登录的用户会使用不同的显存区域,所以可以多个虚拟中断公用一个显示器。

这里实现的不是真正的终端,而是通过封装锁操作,实现互斥打印输出,来让输出信息更整洁

device/console.c

#include "console.h"
#include "print.h"
#include "stdint.h"
#include "sync.h"
#include "thread.h"

static struct lock console_lock; // 控制台锁

// 初始化终端
void console_init() {
    lock_init(&console_lock);
}

// 获取终端
void console_acquire() {
    lock_acquire(&console_lock);
}

// 释放终端
void console_release() {
    lock_release(&console_lock);
}

// 终端中输出字符串
void console_put_str(char* str) {
    console_acquire();
    put_str(str);
    console_release();
}

// 终端中输出字符
void console_put_char(uint8_t char_asci) {
    console_acquire();
    put_char(char_asci);
    console_release();
}

// 终端中输出十六进制整数
void console_put_int(uint32_t num) {
    console_acquire();
    put_int(num);
    console_release();
}

console_lock控制台锁是全局唯一变量,所以用static

前三个函数是初始化终端、获取终端、释放终端,后三个函数是对put_int/str/char的封装

基本上原理就是,先获取终端锁:

  • 获取终端锁
    • 获取到终端锁
      1. 信号量 down 操作
    • 没获取到终端锁
      1. 挂起,进入等待队列
      2. 等到终端锁,执行信号量 down 操作
  • 执行功能函数
  • 释放终端锁

接下来进行应用:

kernel/init.c

...
/* 负责初始化所有模块 */
void init_all(){
	put_str("init_all\n");
	idt_init();		// 初始化 中断
	mem_init();		// 初始化内存池
	thread_init();	// 初始化线程
	timer_init();	// 初始化 PIT
	console_init();	// 初始化终端
}

kernel/main.c

int main(){
	put_str("\nI am kernel\n");
	init_all();

    intr_enable(); // 打开中断, 使时钟中断起作用


	while(1){
		console_put_str("Main ");
	}
	return 0;
}

void k_thread_a(void* arg){
	char* para = arg;
	while(1){
		console_put_str(para);
	}
}

void k_thread_b(void* arg) {
    char* para = arg;
    while(1) {
        console_put_str(para);
    }
}

运行 Bochs

编译,运行:

image-20210208204721100

这样就好了,都有序运行,多线程输出不会再有竞争条件了

从键盘获取输入

键盘输入原理

键盘操作涉及两个独立的芯片配合

键盘是个独立的设备,内部有个芯片叫键盘编码器,通常是 Intel 8048 或兼容芯片,用于当键盘上发送按键操作后,向键盘控制器报告哪个键被按下,或被弹起

键盘控制器位于主板内部,通常是 Intel 8042 或兼容芯片,用于将键盘编码器发送过来的编码进行解码,解码后保存发给中断代理发中断,之后处理器处理中断处理程序读取按键信息

关系如图所示:

image-20210209100653843

所有按键都对应一个数值,叫键盘扫描码

一个按键有两种状态,也就有两个码:按键处于按下状态,叫通码,断开状态叫断码,按住不松手的情况下,会持续产生通码

一个键的扫描码由通码断码组成

扫描码是硬件提供的编码集,和ASCII不同,所以需要一个字符处理程序来将扫描码替换成ASCII码,可使用中断处理程序来完成

键盘扫描码

键盘扫描码由键盘编码器决定,不同的编码方案便是不同的键盘扫描码,键的扫描码和键的物理位置无关

键盘扫描码有三套,其中第二套几乎是目前所使用键盘的标准

不管用哪套键盘扫描码,为了兼容,都会在 Intel 8042 中转换成第一套扫描码然后发送给中断代理 Intel 8259A 来用

image-20210209103518803

大多数情况下,第一套扫描码中的通码和断码都是 1 字节大小,断码 = 0x80 + 通码

第二套扫描码一般通码是 1 字节大小,断码在通码前再加 1 字节的 0xF0,共 2 字节

Intel 8042 负责将第二套扫描码转换成第一套扫描码


对于通码和断码,通码的最高位为0,表示按下,断码的最高位为1,表示松开,所以通码和断码之间差了 0x80

有些键是 0xe0 作为前缀,不为1字节,那这个键是后来扩展进来的按键


对于 Ctrl+a 这样的组合键

按下的控制键(Ctrl)会被先保存到全局变量中,等下一个常规键按下之后,算作组合键,进行组合键的按键处理


Intel 8042 的输出缓冲区寄存器只有 8 位宽度,所以每收到1字节扫描码就会向中断代理发送中断信号

Intel 8042 简介

与键盘相关的芯片 Intel 8042 和 8048 是独立的处理器,都有自己的寄存器和内存

Intel 8042 位于主板南桥芯片上,是键盘的 IO 接口,读写 8048 的数据,以及对 8048 进行设置都是通过 8042 进行的

image-20210209105653589

输出缓冲区寄存器:

  • 8位宽度,只读,键盘驱动程序通过 in (必须用 in 读取,不然 8042 无法继续响应)读取来自8048的扫描码、 来自8048的命令应答以及对8042本身设置时,8042自身的应答也从该寄存器中获取。

输入缓冲区寄存器:

  • 8位宽度,只写,键盘驱动程序通过 out指令向此寄存器写入对8048的控制命令、参数等,对于8042本身的控制命令也是写入此寄存器。

状态寄存器:

  • 8位宽度,只读,反映 8048 和 8042 的内部工作状态。
    1. 位0:置1时表示输出缓冲区寄存器已满, 处理器通过 in指令读取后该位自动置0。
    2. 位1:置1时表示输入缓冲区寄存器已满,8042将值读取后该位自动置 0。
    3. 位2:系统标志位, 最初加电时为0, 自检通过后置为1。
    4. 位3:置1时, 表示输入缓冲区中的内容是命令, 置0时, 输入缓冲区中的内容是普通数据。
    5. 位4:置1时表示键盘启用, 置0时表示键盘禁用。
    6. 位5:置1 时表示发送超时。
    7. 位6:置1时表示接收超时。
    8. 位7:来自8048的数据在奇偶校验时出错。

控制寄存器:

  • 8位宽度,只写,用于写入命令控制字
    1. 位0:置1时启用键盘中断。
    2. 位1:置1时启用鼠标中断。
    3. 位2:设置状态寄存器的位2。
    4. 位3:置1时, 状态寄存器的位4无效。
    5. 位4:置1时禁止键盘。
    6. 位5:置1时禁止鼠标。
    7. 位6:将第二套键盘扫描码转换为第一套键盘扫描码。
    8. 位7:保留位, 默认为0。

测试键盘中断处理程序

注册中断向量号:

kernel/kernel.S

VECTOR 0x20,ZERO	;时钟中断对应的入口
VECTOR 0x21,ZERO	;键盘中断对应的入口
VECTOR 0x22,ZERO	;级联用的
VECTOR 0x23,ZERO	;串口2对应的入口
VECTOR 0x24,ZERO	;串口1对应的入口
VECTOR 0x25,ZERO	;并口2对应的入口
VECTOR 0x26,ZERO	;软盘对应的入口
VECTOR 0x27,ZERO	;并口1对应的入口

VECTOR 0x28,ZERO	;实时时钟对应的入口
VECTOR 0x29,ZERO	;重定向
VECTOR 0x2a,ZERO	;保留
VECTOR 0x2b,ZERO	;保留
VECTOR 0x2c,ZERO	;ps/2鼠标
VECTOR 0x2d,ZERO	;fpu浮点单元异常
VECTOR 0x2e,ZERO	;硬盘
VECTOR 0x2f,ZERO	;保留

这里我发现了之前为啥时钟中断触发的中断向量号是0x21了,因为我标号写错了。。。尴尬

kernel/interrupt.c

为了方便测试,先将时钟中断给屏蔽了,只开启键盘中断:

增改如下内容:

#define IDT_DESC_CNT 	0x30
。。。
static void pic_init(void){
。。。
	//打开主片上的 IR0 也就是目前只接受时钟产生的中断
	outb (PIC_M_DATA, 0xfd);
	outb (PIC_S_DATA, 0xff);

	put_str("    pic init done\n");
}

这样一来,就只开启了 8259A 的键盘中断

kernel/main.c

主程序把多线程部分删掉,不循环输出任何东西:

int main(){
	put_str("\nI am kernel\n");
	init_all();

    intr_enable(); // 打开中断, 使时钟中断起作用


	while(1);
	return 0;
}

device/keyboard.c

准备好中断设置了,准备好中断向量号了,现在该写中断处理程序了:

#include "keyboard.h"
#include "print.h"
#include "interrupt.h"
#include "io.h"
#include "global.h"

#define KBD_BUF_PORT 0x60 // 键盘 buffer 寄存器端口号为 0x60

// 键盘中断处理程序
static void intr_keyboard_handler(void) {
    put_char('k');
    // 必须要读取输出缓冲区寄存器,否则 8042 不再继续响应键盘中断
    inb(KBD_BUF_PORT);
    return;
}
// 键盘初始化
void keyboard_init() {
    put_str("keyboard init start\n");
    register_handler(0x21, intr_keyboard_handler);
    put_str("keyboard init done\n");
}

这里的中断处理程序很简单,就是触发中断就打印一次 k

device/keyboard.h

#ifndef __DEVICE_KEYBOARD_H
#define __DEVICE_KEYBOARD_H
void keyboard_init(void);
#endif

运行 Bochs

将键盘初始化程序加入到init.c里,然后编写makefile,编译,运行:

image-20210209113409442

按一下键,会触发2次中断,一次是按下中断,一次是弹起中断,所以会触发两次中断处理程序,打印2个k

编写键盘驱动程序

字符转义介绍

字符集中的字符分为两大类:可见字符和不可见字符(控制字符)

键盘驱动的工作就是将扫描码转换成ASCII码,转换工作就是建立源到目标的映射关系,几乎都是硬编码

转换出来的控制字符没法显示,可通过转义的方式进行:

  • \字母,如\n,用转移字符表示
  • \x十六进制数字,用十六进制表示

处理扫描码

按键有两种情况,一种是按下字符按键,一种是按下控制按键:

  • 当按下控制按键的时候,需要与其他键一起考虑,然后做出具体的行为,可直接在驱动中处理
  • 当按下字符相关按键时,需要先将扫描码转换成ASCII码

在这里插入图片描述

第一套扫描码集如图所示,通码几乎是连续的,0x1--0x58,其中0x54--0x56不存在,可以用二维数组来建立映射关系

这里为简单处理,只支持主键盘区按键,所以数组范围就支持到0x1~0x3A

device/keyboard.c

增改如下内容:

// 用转义字符定义部分控制字符
#define esc       '\033'
#define backspace '\b'
#define tab       '\t'
#define enter     '\r'
#define delete    '\177'
// 不可见字符
#define char_invisible	0
#define ctrl_l_char	    char_invisible
#define ctrl_r_char	    char_invisible
#define shift_l_char	char_invisible
#define shift_r_char	char_invisible
#define alt_l_char	    char_invisible
#define alt_r_char	    char_invisible
#define caps_lock_char	char_invisible
// 控制字符的通码和断码
#define shift_l_make	0x2a
#define shift_r_make 	0x36 
#define alt_l_make   	0x38
#define alt_r_make   	0xe038
#define alt_r_break   	0xe0b8
#define ctrl_l_make  	0x1d
#define ctrl_r_make  	0xe01d
#define ctrl_r_break 	0xe09d
#define caps_lock_make 	0x3a

// 记录相应键是否按下的状态, ext_scancode用于记录makecode是否以0xe0开头
static bool ctrl_status, shift_status, alt_status, caps_lock_status, ext_scancode;

这里先预定义控制字符,控制字符没有ASCII码,先占位,之后就知道有啥用了

继续往下:

// 以通码 makecode 为索引的二维数组
static char keymap[][2] = {
/* 扫描码   未与shift组合  与shift组合*/
/* ---------------------------------- */
/* 0x00 */	{0,	0},		
/* 0x01 */	{esc,	esc},		
/* 0x02 */	{'1',	'!'},		
/* 0x03 */	{'2',	'@'},		
/* 0x04 */	{'3',	'#'},		
/* 0x05 */	{'4',	'$'},		
/* 0x06 */	{'5',	'%'},		
/* 0x07 */	{'6',	'^'},		
/* 0x08 */	{'7',	'&'},		
/* 0x09 */	{'8',	'*'},		
/* 0x0A */	{'9',	'('},		
/* 0x0B */	{'0',	')'},		
/* 0x0C */	{'-',	'_'},		
/* 0x0D */	{'=',	'+'},		
/* 0x0E */	{backspace, backspace},	
/* 0x0F */	{tab,	tab},		
/* 0x10 */	{'q',	'Q'},		
/* 0x11 */	{'w',	'W'},		
/* 0x12 */	{'e',	'E'},		
/* 0x13 */	{'r',	'R'},		
/* 0x14 */	{'t',	'T'},		
/* 0x15 */	{'y',	'Y'},		
/* 0x16 */	{'u',	'U'},		
/* 0x17 */	{'i',	'I'},		
/* 0x18 */	{'o',	'O'},		
/* 0x19 */	{'p',	'P'},		
/* 0x1A */	{'[',	'{'},		
/* 0x1B */	{']',	'}'},		
/* 0x1C */	{enter,  enter},
/* 0x1D */	{ctrl_l_char, ctrl_l_char},
/* 0x1E */	{'a',	'A'},		
/* 0x1F */	{'s',	'S'},		
/* 0x20 */	{'d',	'D'},		
/* 0x21 */	{'f',	'F'},		
/* 0x22 */	{'g',	'G'},		
/* 0x23 */	{'h',	'H'},		
/* 0x24 */	{'j',	'J'},		
/* 0x25 */	{'k',	'K'},		
/* 0x26 */	{'l',	'L'},		
/* 0x27 */	{';',	':'},		
/* 0x28 */	{'\'',	'"'},		
/* 0x29 */	{'`',	'~'},		
/* 0x2A */	{shift_l_char, shift_l_char},	
/* 0x2B */	{'\\',	'|'},		
/* 0x2C */	{'z',	'Z'},		
/* 0x2D */	{'x',	'X'},		
/* 0x2E */	{'c',	'C'},		
/* 0x2F */	{'v',	'V'},		
/* 0x30 */	{'b',	'B'},		
/* 0x31 */	{'n',	'N'},		
/* 0x32 */	{'m',	'M'},		
/* 0x33 */	{',',	'<'},		
/* 0x34 */	{'.',	'>'},		
/* 0x35 */	{'/',	'?'},
/* 0x36	*/	{shift_r_char, shift_r_char},	
/* 0x37 */	{'*',	'*'},    	
/* 0x38 */	{alt_l_char, alt_l_char},
/* 0x39 */	{' ',	' '},		
/* 0x3A */	{caps_lock_char, caps_lock_char}
/*其它按键暂不处理*/
};

建立keymap二维数组用作映射,以通码所做数组下标索引,二维数组左右分别为按下shift按键前和后映射出来的按键

其中没有通码为 0 的按键

继续往下看:

// 键盘中断处理程序
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;
    uint16_t scancode = inb(KBD_BUF_PORT);

    //若扫描码是e0开头的,表示此键的按下将产生多个扫描码
    // 所以马上结束此次中断处理函数,等待下一个扫描码进来
    if (scancode == 0xe0) { 
        ext_scancode = true;    // 打开e0标记
        return;
    }

    // 如果上次是以0xe0开头,将扫描码合并
    if (ext_scancode) {
        scancode = ((0xe000) | scancode);
        ext_scancode = false;   // 关闭e0标记
    }   

    break_code = ((scancode & 0x0080) != 0);   // 获取break_code

    if (break_code) {   // 若是断码break_code(按键弹起时产生的扫描码)

    // 由于ctrl_r 和alt_r的make_code和break_code都是两字节,
    // 所以可用下面的方法取make_code,多字节的扫描码暂不处理
        uint16_t make_code = (scancode &= 0xff7f);   // 得到其make_code(按键按下时产生的扫描码)

    // 若是任意以下三个键弹起了,将状态置为false
        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;
        } // 由于caps_lock不是弹起后关闭,所以需要单独处理

        return;   // 直接返回结束此次中断处理程序

    } 
    // 若为通码,只处理数组中定义的键以及alt_right和ctrl键,全是make_code
    else if ((scancode > 0x00 && scancode < 0x3b) || \
            (scancode == alt_r_make) || \
            (scancode == ctrl_r_make)) {
        bool shift = false;  // 判断是否与shift组合,用来在一维数组中索引对应的字符
        if ((scancode < 0x0e) || (scancode == 0x29) || \
        (scancode == 0x1a) || (scancode == 0x1b) || \
        (scancode == 0x2b) || (scancode == 0x27) || \
        (scancode == 0x28) || (scancode == 0x33) || \
        (scancode == 0x34) || (scancode == 0x35)) {  
        /****** 代表两个字母的键 ********
             0x0e 数字'0'~'9',字符'-',字符'='
                0x29 字符'`'
                0x1a 字符'['
                0x1b 字符']'
                0x2b 字符'\\'
                0x27 字符';'
                0x28 字符'\''
                0x33 字符','
                0x34 字符'.'
                0x35 字符'/' 
        *******************************/
        if (shift_down_last) {  // 如果同时按下了shift键
            shift = true;
        }
        } 
        else {	  // 默认为字母键
            if (shift_down_last && caps_lock_last) {  // 如果shift和capslock同时按下
                shift = false;
            } else if (shift_down_last || caps_lock_last) { // 如果shift和capslock任意被按下
                shift = true;
            } else {
                shift = false;
            }
        }

        uint8_t index = (scancode &= 0x00ff);  // 将扫描码的高字节置0,主要是针对高字节是e0的扫描码.
        char cur_char = keymap[index][shift];  // 在数组中找到对应的字符

        // 只处理ascii码不为0的键
        if (cur_char) {
            put_char(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键,当再次按下时则状态取反,
            // 即:已经开启时,再按下同样的键是关闭。关闭时按下表示开启
            caps_lock_status = !caps_lock_status;
        }
    } else {
        put_str("unknown key\n");
    }
}

程序逻辑如下:

  1. 先获取当前控制键Ctrl,shift、capslock的状态,获取扫描码,如果扫描码是0xe0则直接返回等待下一个扫描码出现

    • 如果上次扫描码是0xe0,则将扫描码合并,得到扫描码
  2. 根据扫描码判断是否是断码:

    • 如果是断码,则获得通码,如果是控制键断开,则把控制键状态设置为false,如果是普通键则不管
    • 如果是通码,只处理数组中定义的键以及alt_right和ctrl键
      • 先处理双字符键,如果按下的是双字符键,判断shift是否按下,获取shift状态
      • 再处理普通按键,判断shift是否按下,获取shift状态
  3. 将扫描码高字节置零(针对高字节是0xe0的扫描码),用低字节索引数组获得映射字符

    • 如果映射字符ASCII不为0,则输出打印字符
    • 如果按下的是控制键,则设置控制键状态为true
    • 否则则打印unknown key

简单来说,就是将按下的字符打印到屏幕上来,如果按下shift会进行字符转换,如果是双字符按键,则会转换成另一个字符,如果是字母按键,则会变成大写,如果松开按键,就判断是不是控制键,如果是就设置控制键状态,如果不是就算了

运行 Bochs

编译,运行:

image-20210209130031370

我在底下输入了 <回车>Hello Selph!

环形输入缓冲区

当前的键盘驱动只能用来显示键入的按键字符,没有其他什么实际作用,一般用户与系统交互的shell命令由多个字符组成,并且要以回车键结束,因此在键入命令的过程中,必须找个缓冲区把已键入的信息存起来,当凑成完整的命令名时再一并由其它模块处理。

生产者与消费者问题简述

简述就是:对于有限大小的公共缓冲区,如何同步生产者与消费者的运行,以达到对共享缓冲区的互斥访问,并且保证生产者不会过度生产,消费者不会过度消费,缓冲区不会被破坏。

环形缓冲区的实现

缓冲区是多个线程共同使用的共享内存,要保证对缓冲区是互斥访问,不会使用过度,从而确保缓冲区不被破坏

环形缓冲区本质上依然是线性缓冲区:

image-20210209132143625

有一个头指针,一个尾指针:

  • 头指针写数据,每写一个就往后移动一个
  • 尾指针读数据,每读取一个就往后移动一个

缓冲区就相当于一个队列,在头被写入,在尾被读出

用线性空间来实现只需要控制好指针的位置就好,当指针指到上边界,就将指针设置到下边界

简便起见,可以用数组来定义队列实现

device/ioqueue.h

#ifndef __DEVICE_IOQUEUE_H
#define __DEVICE_IOQUEUE_H
#include "stdint.h"
#include "thread.h"
#include "sync.h"

#define bufsize 64

// 环形队列
struct ioqueue {
    // 生产者消费者问题
    struct lock lock;
    struct task_struct* producer;
    struct task_struct* consumer;
    char buf[bufsize];
    int32_t head; // 队首, 数据从队首写入
    int32_t tail; // 队尾, 数据从队尾处读出
};
#endif

device/ioqueue.c

#include "ioqueue.h"
#include "interrupt.h"
#include "global.h"
#include "debug.h"

// 初始化 io 队列 ioq
void ioqueue_init(struct ioqueue* ioq) {
    lock_init(&ioq->lock);
    ioq->producer = ioq->consumer = NULL;
    ioq->head = ioq->tail = 0;
}

// 返回 pos 在缓冲区中的下一个位置值
static int32_t next_pos(int32_t pos) {
    return (pos + 1) % bufsize;
}

// 判断队列是否已满
bool ioq_full(struct ioqueue* ioq) {
    ASSERT(intr_get_status() == INTR_OFF);
    return next_pos(ioq->head) == ioq->tail;
}

// 判断队列是否已空
bool ioq_empty(struct ioqueue* ioq) {
    ASSERT(intr_get_status() == INTR_OFF);
    return ioq->head == ioq->tail;
}

// 使当前生产者或消费者在此缓冲区上等待
static void ioq_wait(struct task_struct** waiter) {
    ASSERT(*waiter == NULL && waiter != NULL);
    *waiter = running_thread();
    thread_block(TASK_BLOCKED);
}

// 唤醒 waiter
static void wakeup(struct task_struct** waiter) {
    ASSERT(*waiter != NULL);
    thread_unblock(*waiter);
    *waiter = NULL;
}

// 消费者从 ioq 队列中获取一个字符
char ioq_getchar(struct ioqueue* ioq) {
    ASSERT(intr_get_status() == INTR_OFF);
    
    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);

    if(ioq->producer != NULL) {
        wakeup(&ioq->producer);
    }

    return byte;
}

// 生产者往 ioq 队列中写入一个字符 byte
void ioq_putchar(struct ioqueue* ioq, char byte) {
    ASSERT(intr_get_status() == INTR_OFF);

    while(ioq_full(ioq)) {
        lock_acquire(&ioq->lock);
        ioq_wait(&ioq->producer);
        lock_release(&ioq->lock);
    }

    ioq->buf[ioq->head] = byte;
    ioq->head = next_pos(ioq->head);

    if(ioq->consumer != NULL) {
        wakeup(&ioq->consumer);
    }
}

添加键盘输入缓冲区

device/keyboard.c

增改如下内容:

// 键盘缓冲区
struct ioqueue kbd_buf;

...
    
        // 只处理ascii码不为0的键
        if (cur_char) {
            if(!ioq_full(&kbd_buf)) {
                put_char(cur_char);
                ioq_putchar(&kbd_buf, cur_char);
            }
            return;
        }

...


// 键盘初始化
void keyboard_init() {
    put_str("keyboard init start\n");
    ioqueue_init(&kbd_buf);
    register_handler(0x21, intr_keyboard_handler);
    put_str("keyboard init done\n");
}
  • 增加了环形缓冲区

  • 在按下按键的时候,会先判断缓冲区是否满了,没满就打印字符并添加到缓冲区

  • 在初始化的时候,将环形缓冲区进行初始化

运行 Bochs

编译,运行:

image-20210209155926113

最多只能键入63个字符,然后缓冲区就满了

本篇总结

本篇讲了两部分内容,一个是输出,一个是输入

上一章使用多线程输出的时候出现了竞争条件问题,这里使用锁成功的解决了这个问题,使用锁实现了互斥操作,从而使输出不再混乱

线程得到了锁才能执行临界区代码,其他线程没得到锁(信号量的值为0时),将自己挂起,加入到该锁的等待队列中,当得到锁的线程执行完毕了,则将当前信号量还原,唤醒该锁等待队列中的线程

至于为什么要用锁结构,而不是只用信号量来进行判断,主要是为了防止死锁,自己请求自己已经有的锁,那就会导致自己被挂起,得不到锁也解不了锁

后面介绍了键盘输入的原理,键盘 Intel 8048 芯片配合 Intel 8042 芯片向中断代理发送按键信息,编写键盘驱动程序,处理从中断代理发来的键盘中断请求

曾经觉得很神秘的驱动程序也就此揭开面纱,这里写的第一个驱动,键盘驱动程序,将获得到的扫描码进行判断,是否是断键,如果不是,那就判断是否是控制键,然后如果是字符按键,则根据当前控制按键的状态来输出相应的字符到屏幕上来

其实驱动程序就是用来将外设发来的信息进行处理的

为了让输入的内容是一系列字符,而不是一个一个的字符,使用了环形缓冲区进行实现,键入的值都会被存入缓冲区

参考资料


Comment