本篇对应书籍第十章的内容
本篇内容介绍了同步机制--锁的原理和实现、使用锁重新封装了之前的打印函数;介绍了键盘输入的原理,编写键盘驱动程序实现键盘输入,介绍了环形缓冲区,用作键盘输入缓冲区。
本篇难点:
- 理解同步机制--锁和信号量
同步机制--锁
上一章中遇到的字符混乱和GP异常的问题,原因是临界区代码的资源竞争,需要一些互斥的方法保证操作的原子性
排查 GP 异常,理解原子操作
上一章中出现的字符丢失、大片空缺、GP异常问题,是由于字符串写入操作没有使用原子操作所导致的
字符串写入分为3个步骤:
- 获取光标值
- 将光标值转为字节地址,在地址中写入字符
- 更新光标值
线程调度工作的核心是线程的上下文保护与还原
这里访问的公共资源是显存,任务调度的时候,如果线程A执行到了获取光标值被中断,当线程A还原执行的时候,此时光标值已经被改变了,而线程A会从第二个步骤开始执行,所以导致字符丢失、字符出现的位置不对的问题
GP异常则是在写入光标值的时候发生中断所导致的,导致光标被赋予了错误的值,甚至超出了边界,导致了GP异常
根本原因就是访问公共资源需要多个操作,而这多个操作执行不具有原子性,导致被任务调度器断开了,从而让其他线程有机会破坏显存和光标寄存器这两类公共资源现场
找出代码中的临界区、互斥、竞争条件
这里要先介绍几个术语:
- 公共资源:被所有任务共享的一套资源
- 临界区:各任务中访问公共资源的指令代码组成的区域
- 互斥:指某一时刻公共资源只能被1个任务独享
- 竞争条件:多个任务以非互斥的方式同时进入临界区,大家对公共资源的访问时以竞争的方式进行的
多线程访问公共资源时出现问题的根本原因就是产生了竞争条件,多个任务同时处于自己的临界区,为避免产生竞争条件,必须保证互斥
这里的临界区函数是put_char,非互斥导致该函数不能执行完成从而产生了竞争条件
信号量
这里我们的互斥机制--锁,是通过信号量来实现的
信号量是0以上的整数,是个计数器,信号量仅仅是一个程序设计构造的方法
如果信号量的值为1,则表示这是个二元信号量,信号量的值表示信号资源的积累量,是全局变量
对信号量的操作分为两种,up、down:
up操作:
- 信号量+1
- 唤醒在此信号量上等待的线程
down操作:
- 判断信号量是否大于0
- 大于0则信号量-1
- 等于0则当前线程将自己堵塞,在此信号量上等待
在二元信号量中,让线程通过锁进入临界区,大致流程如下:
- 线程A进入临界区先通过 down 操作获得锁,此时信号量为0
- 线程B进入临界区也通过 down 操作获得锁,但是信号量是0,则在此信号量上等待
- 线程A从临界区出来后执行 up 操作释放锁,信号量值变成1,之后线程A将线程B唤醒
- 线程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的封装
基本上原理就是,先获取终端锁:
- 获取终端锁
- 获取到终端锁
- 信号量 down 操作
- 没获取到终端锁
- 挂起,进入等待队列
- 等到终端锁,执行信号量 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
编译,运行:
这样就好了,都有序运行,多线程输出不会再有竞争条件了
从键盘获取输入
键盘输入原理
键盘操作涉及两个独立的芯片配合
键盘是个独立的设备,内部有个芯片叫键盘编码器,通常是 Intel 8048 或兼容芯片,用于当键盘上发送按键操作后,向键盘控制器报告哪个键被按下,或被弹起
键盘控制器位于主板内部,通常是 Intel 8042 或兼容芯片,用于将键盘编码器发送过来的编码进行解码,解码后保存发给中断代理发中断,之后处理器处理中断处理程序读取按键信息
关系如图所示:
所有按键都对应一个数值,叫键盘扫描码
一个按键有两种状态,也就有两个码:按键处于按下状态,叫通码,断开状态叫断码,按住不松手的情况下,会持续产生通码
一个键的扫描码由通码和断码组成
扫描码是硬件提供的编码集,和ASCII不同,所以需要一个字符处理程序来将扫描码替换成ASCII码,可使用中断处理程序来完成
键盘扫描码
键盘扫描码由键盘编码器决定,不同的编码方案便是不同的键盘扫描码,键的扫描码和键的物理位置无关
键盘扫描码有三套,其中第二套几乎是目前所使用键盘的标准
不管用哪套键盘扫描码,为了兼容,都会在 Intel 8042 中转换成第一套扫描码然后发送给中断代理 Intel 8259A 来用
大多数情况下,第一套扫描码中的通码和断码都是 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 进行的
输出缓冲区寄存器:
- 8位宽度,只读,键盘驱动程序通过 in (必须用 in 读取,不然 8042 无法继续响应)读取来自8048的扫描码、 来自8048的命令应答以及对8042本身设置时,8042自身的应答也从该寄存器中获取。
输入缓冲区寄存器:
- 8位宽度,只写,键盘驱动程序通过 out指令向此寄存器写入对8048的控制命令、参数等,对于8042本身的控制命令也是写入此寄存器。
状态寄存器:
- 8位宽度,只读,反映 8048 和 8042 的内部工作状态。
- 位0:置1时表示输出缓冲区寄存器已满, 处理器通过 in指令读取后该位自动置0。
- 位1:置1时表示输入缓冲区寄存器已满,8042将值读取后该位自动置 0。
- 位2:系统标志位, 最初加电时为0, 自检通过后置为1。
- 位3:置1时, 表示输入缓冲区中的内容是命令, 置0时, 输入缓冲区中的内容是普通数据。
- 位4:置1时表示键盘启用, 置0时表示键盘禁用。
- 位5:置1 时表示发送超时。
- 位6:置1时表示接收超时。
- 位7:来自8048的数据在奇偶校验时出错。
控制寄存器:
- 8位宽度,只写,用于写入命令控制字
- 位0:置1时启用键盘中断。
- 位1:置1时启用鼠标中断。
- 位2:设置状态寄存器的位2。
- 位3:置1时, 状态寄存器的位4无效。
- 位4:置1时禁止键盘。
- 位5:置1时禁止鼠标。
- 位6:将第二套键盘扫描码转换为第一套键盘扫描码。
- 位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,编译,运行:
按一下键,会触发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");
}
}
程序逻辑如下:
-
先获取当前控制键Ctrl,shift、capslock的状态,获取扫描码,如果扫描码是0xe0则直接返回等待下一个扫描码出现
- 如果上次扫描码是0xe0,则将扫描码合并,得到扫描码
-
根据扫描码判断是否是断码:
- 如果是断码,则获得通码,如果是控制键断开,则把控制键状态设置为false,如果是普通键则不管
- 如果是通码,只处理数组中定义的键以及alt_right和ctrl键
- 先处理双字符键,如果按下的是双字符键,判断shift是否按下,获取shift状态
- 再处理普通按键,判断shift是否按下,获取shift状态
-
将扫描码高字节置零(针对高字节是0xe0的扫描码),用低字节索引数组获得映射字符
- 如果映射字符ASCII不为0,则输出打印字符
- 如果按下的是控制键,则设置控制键状态为true
- 否则则打印unknown key
简单来说,就是将按下的字符打印到屏幕上来,如果按下shift会进行字符转换,如果是双字符按键,则会转换成另一个字符,如果是字母按键,则会变成大写,如果松开按键,就判断是不是控制键,如果是就设置控制键状态,如果不是就算了
运行 Bochs
编译,运行:
我在底下输入了 <回车>Hello Selph!
环形输入缓冲区
当前的键盘驱动只能用来显示键入的按键字符,没有其他什么实际作用,一般用户与系统交互的shell命令由多个字符组成,并且要以回车键结束,因此在键入命令的过程中,必须找个缓冲区把已键入的信息存起来,当凑成完整的命令名时再一并由其它模块处理。
生产者与消费者问题简述
简述就是:对于有限大小的公共缓冲区,如何同步生产者与消费者的运行,以达到对共享缓冲区的互斥访问,并且保证生产者不会过度生产,消费者不会过度消费,缓冲区不会被破坏。
环形缓冲区的实现
缓冲区是多个线程共同使用的共享内存,要保证对缓冲区是互斥访问,不会使用过度,从而确保缓冲区不被破坏
环形缓冲区本质上依然是线性缓冲区:
有一个头指针,一个尾指针:
- 头指针写数据,每写一个就往后移动一个
- 尾指针读数据,每读取一个就往后移动一个
缓冲区就相当于一个队列,在头被写入,在尾被读出
用线性空间来实现只需要控制好指针的位置就好,当指针指到上边界,就将指针设置到下边界
简便起见,可以用数组来定义队列实现
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
编译,运行:
最多只能键入63个字符,然后缓冲区就满了
本篇总结
本篇讲了两部分内容,一个是输出,一个是输入
上一章使用多线程输出的时候出现了竞争条件问题,这里使用锁成功的解决了这个问题,使用锁实现了互斥操作,从而使输出不再混乱
线程得到了锁才能执行临界区代码,其他线程没得到锁(信号量的值为0时),将自己挂起,加入到该锁的等待队列中,当得到锁的线程执行完毕了,则将当前信号量还原,唤醒该锁等待队列中的线程
至于为什么要用锁结构,而不是只用信号量来进行判断,主要是为了防止死锁,自己请求自己已经有的锁,那就会导致自己被挂起,得不到锁也解不了锁
后面介绍了键盘输入的原理,键盘 Intel 8048 芯片配合 Intel 8042 芯片向中断代理发送按键信息,编写键盘驱动程序,处理从中断代理发来的键盘中断请求
曾经觉得很神秘的驱动程序也就此揭开面纱,这里写的第一个驱动,键盘驱动程序,将获得到的扫描码进行判断,是否是断键,如果不是,那就判断是否是控制键,然后如果是字符按键,则根据当前控制按键的状态来输出相应的字符到屏幕上来
其实驱动程序就是用来将外设发来的信息进行处理的
为了让输入的内容是一系列字符,而不是一个一个的字符,使用了环形缓冲区进行实现,键入的值都会被存入缓冲区
参考资料
- 《操作系统真象还原》Chapter 10
- 操作系统真象还原第十章笔记:https://blog.csdn.net/zhwenx3/article/details/110789450