《操作系统真象还原》第十章

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

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

同步机制–锁

上一章中遇到的字符混乱和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.h

/* 当前线程将自己阻塞, 标志其状态为 stat. */
void thread_block(enum task_status stat);/* 将线程 pthread 解除阻塞 */
void thread_unblock(struct task_struct* pthread);

thread/thread.c

增改如下内容:

/* 当前线程将自己阻塞, 标志其状态为 stat. */
void thread_block(enum task_status stat) {// stat 取值为 TASK_BLOCKED, TASK_WAITING, TASK_HANGING, 也就是只有这三种状态才不会被调度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;      // 置其状态为 statschedule();                     // 将当前线程换下处理器, 重新调度// 待当前线程被解除阻塞后才继续运行下面的 intr_set_statusintr_set_status(old_status);
}/* 将线程 pthread 解除阻塞 */
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));if (elem_find(&thread_ready_list, &pthread->general_tag)) {PANIC("thread_unblock: blocked thread in ready_list\n");}// 放在就绪队列最前面, 使其尽快得到调度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;      // 锁的持有者重复申请锁的次数
};// 初始化信号量
void sema_init(struct semaphore* psema, uint8_t value);// 信号量 down 操作
void sema_down(struct semaphore* psema);// 信号量的 up 操作
void sema_up(struct semaphore* psema);// 初始化锁 plock
void lock_init(struct lock* plock);// 获取锁 plock
void lock_acquire(struct lock* plock);// 释放锁 plock
void lock_release(struct lock* plock);#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();// value 为 0, 表示已经被别人持有while(psema->value == 0) {ASSERT(!elem_find(&psema->waiters, &running_thread()->general_tag));// 当前线程不应该已在信号量的 waiters 队列中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)) {// 获取信号量等待队列队首的线程 PCBstruct task_struct* thread_blocked = elem2entry(struct task_struct, general_tag, list_pop(&psema->waiters));// 唤醒线程thread_unblock(thread_block);}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);   // 对信号量 P 操作, 原子操作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;           // 把锁的持有者置空放在 V 操作之前, 因为释放锁时中断没有关闭plock->holder_repeat_nr = 0;sema_up(&plock->semaphore);     //信号量的 V 操作最后在执行,避免其他线程被调度抢到锁, 也是原子操作
}

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

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

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

用锁实现终端输出

终端

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

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

device/console.h

#ifndef __DEVICE_CONSOLE_H
#define __DEVICE_CONSOLE_H#include "stdint.h"void console_init(void);void console_acquire(void);void console_release(void);void console_put_str(char* str);void console_put_char(uint8_t char_acsi);void console_put_int(uint32_t num);#endif

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();
}/* 终端中输出16进制整数 */
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

#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "../device/timer.h"
#include "memory.h"
#include "../thread/thread.h"
#include "../device/console.h"/* 负责初始化所有模块 */
void init_all() {put_str("init_all\n");idt_init();         // 初始化中断mem_init();         // 初始化内存管理系统thread_init();      // 初始化线程相关结构timer_init();       // 初始化 PITconsole_init();     // 初始化终端
}

kernel/main.c

#include "print.h"
#include "init.h"
#include "debug.h"
#include "memory.h"
#include "../thread/thread.h"
#include "interrupt.h"
#include "../device/console.h"void k_thread_a(void*);
void k_thread_b(void*);int main() {put_str("I am kernel\n");init_all();// asm volatile("sti");    // 为演示中断处理, 在此临时开中断thread_start("k_thread_a", 31, k_thread_a, "argA ");thread_start("k_thread_b", 8, k_thread_b, "argB ");intr_enable();             // 打开中断, 使时钟中断起作用while (1) {console_put_str("Main ");}return 0;
}/* 在线程中运行的函数 */
void k_thread_a(void* arg) {/* 用void*来通用表示参数, 被调用的函数知道自己需要什么类型的参数, 自己转换再用 */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 的内部工作状态。

    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.asm

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   ; 保留

kernel/interrupt.c

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

增改如下内容:

#define IDT_DESC_CNT     0x30...
static void pic_init(void){...// 测试键盘, 只打开键盘中断, 其它全部关闭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.h

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

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

init.c

#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "../device/timer.h"
#include "memory.h"
#include "../thread/thread.h"
#include "../device/console.h"
#include "../device/keyboard.h"/* 负责初始化所有模块 */
void init_all() {put_str("init_all\n");idt_init();         // 初始化中断mem_init();         // 初始化内存管理系统thread_init();      // 初始化线程相关结构timer_init();       // 初始化 PITconsole_init();     // 初始化终端keyboard_init();    // 键盘初始化
}

运行 Bochs

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

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

编写键盘驱动程序

字符转义介绍

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

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

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

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

处理扫描码

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

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

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

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

device/keyboard.c

增改如下内容:

// ------------------以下定义都为 ASCII 码----------------------------
// 用转义字符定义部分控制字符
#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码,先占位,之后就知道有啥用了

继续往下:

/* 以通码 make_code 为索引的二维数组 */
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(判断是否为断码, 断码第八位为1)// 若是断码 break_code(按键弹起时产生的扫描码)if(break_code) {// 通码和断码区别在于第八位, 通码为0, 断码为1// 由于ctrl_r 和 alt_r 的 make_code 和 break_code都是两字节,// 所以可用下面的方法取 make_code(将断码第八位置0即为通码), 多字节的扫描码暂不处理uint16_t make_code = (scancode &= 0xff7f);// 若是任意以下三个键弹起了, 将状态置为 falseif (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;     // 直接返回结束此次中断处理程序} else if((scancode > 0x00 && scancode < 0x3b) ||(scancode == alt_r_make) || (scancode == ctrl_r_make)) {// 若为通码, 只处理数组中定义的键以及 alt_right 和 ctrl 键, 全是 make_codebool 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 字符'/' *******************************/// 如果同时按下了shift键if (shift_down_last) {  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

注意:

通码和断码区别在于第八位, 通码为0, 断码为1

将通码第八位置1即为断码

将断码第八位置0即为通码

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

运行 Bochs

编译,运行:

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

环形输入缓冲区

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

生产者与消费者问题简述

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

环形缓冲区的实现

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

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

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

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

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

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

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

device/ioqueue.h

#ifndef __DEVICE_IOQUEUE_H
#define __DEVICE_IOQUEUE_H#include "stdint.h"
#include "../thread/thread.h"
#include "../thread/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;       // 队尾, 数据从队尾处读出
};void ioqueue_init(struct ioqueue* ioq);bool ioq_full(struct ioqueue* ioq);char ioq_getchar(struct ioqueue* ioq);void ioq_putchar(struct ioqueue* ioq, char byte);#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);      // 初始化 io 队列的锁ioq->producer = NULL;ioq->consumer = NULL;       // 生产者和消费者置空ioq->head = 0;ioq->tail = 0;              // 队列的首尾指针指向缓冲区数组第 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;
}/* 判断队列是否已空 */
static bool ioq_empty(struct ioqueue* ioq) {ASSERT(intr_get_status() == INTR_OFF);return ioq->head == ioq->tail;
}/* 使当前生产者或消费者在此缓冲区上等待, 将在 waiter 中记录当前线程 */
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);// 若缓冲区(队列)为空, 把消费者ioq->consumer 记为当前线程自己,// 目的是将来生产者往缓冲区里装商品后, 生产者知道唤醒哪个消费者,// 也就是唤醒当前线程自己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);// 若缓冲区(队列)已经满了, 把生产者 ioq->producer 记为自己,// 为的是当缓冲区里的东西被消费者取完后让消费者知道唤醒哪个生产者,// 也就是唤醒当前线程自己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

编译,运行:

BUILD_DIR = ./build
ENTRY_POINT = 0xc0001500
AS = nasm
CC = gcc
LD = ld
LIB = -I lib/ -I lib/kernel/ -I lib/user/ -I kernel/ -I device/
ASFLAGS = -f elf
CFLAGS = -Wall -m32 -fno-stack-protector $(LIB) -c -fno-builtin -W -Wstrict-prototypes -Wmissing-prototypes
LDFLAGS = -m elf_i386 -Ttext $(ENTRY_POINT) -e main -Map $(BUILD_DIR)/kernel.map
OBJS = $(BUILD_DIR)/main.o $(BUILD_DIR)/init.o $(BUILD_DIR)/interrupt.o \$(BUILD_DIR)/timer.o $(BUILD_DIR)/kernel.o $(BUILD_DIR)/print.o \$(BUILD_DIR)/debug.o $(BUILD_DIR)/memory.o $(BUILD_DIR)/string.o \$(BUILD_DIR)/bitmap.o $(BUILD_DIR)/thread.o $(BUILD_DIR)/list.o  \$(BUILD_DIR)/switch.o $(BUILD_DIR)/sync.o $(BUILD_DIR)/console.o \$(BUILD_DIR)/keyboard.o $(BUILD_DIR)/ioqueue.o############ C 代码编译 ##############
$(BUILD_DIR)/main.o: kernel/main.c lib/kernel/print.h   \lib/stdint.h kernel/init.h lib/string.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/init.o: kernel/init.c kernel/init.h lib/kernel/print.h \lib/stdint.h kernel/interrupt.h device/timer.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/interrupt.o: kernel/interrupt.c kernel/interrupt.h \lib/stdint.h kernel/global.h lib/kernel/io.h lib/kernel/print.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/timer.o: device/timer.c device/timer.h lib/stdint.h \lib/kernel/io.h lib/kernel/print.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/debug.o: kernel/debug.c kernel/debug.h \lib/kernel/print.h lib/stdint.h kernel/interrupt.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/string.o: lib/string.c lib/string.h \kernel/debug.h kernel/global.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/memory.o: kernel/memory.c kernel/memory.h \lib/stdint.h lib/kernel/bitmap.h kernel/debug.h lib/string.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/bitmap.o: lib/kernel/bitmap.c lib/kernel/bitmap.h \lib/string.h kernel/interrupt.h lib/kernel/print.h kernel/debug.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/thread.o: thread/thread.c thread/thread.h \lib/stdint.h lib/string.h kernel/global.h kernel/memory.h \kernel/debug.h kernel/interrupt.h lib/kernel/print.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/list.o: lib/kernel/list.c lib/kernel/list.h \kernel/interrupt.h lib/stdint.h kernel/debug.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/sync.o: thread/sync.c thread/sync.h \lib/stdint.h thread/thread.h kernel/debug.h kernel/interrupt.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/console.o: device/console.c device/console.h \lib/kernel/print.h thread/sync.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/keyboard.o: device/keyboard.c device/keyboard.h \lib/kernel/print.h lib/kernel/io.h kernel/interrupt.h \kernel/global.h lib/stdint.h device/ioqueue.h$(CC) $(CFLAGS) $< -o $@$(BUILD_DIR)/ioqueue.o: device/ioqueue.c device/ioqueue.h \kernel/interrupt.h kernel/global.h kernel/debug.h$(CC) $(CFLAGS) $< -o $@##############    汇编代码编译    ###############
$(BUILD_DIR)/kernel.o: kernel/kernel.asm$(AS) $(ASFLAGS) $< -o $@$(BUILD_DIR)/print.o: lib/kernel/print.asm$(AS) $(ASFLAGS) $< -o $@$(BUILD_DIR)/switch.o: thread/switch.asm$(AS) $(ASFLAGS) $< -o $@##############    链接所有目标文件    #############
$(BUILD_DIR)/kernel.bin: $(OBJS)$(LD) $(LDFLAGS) $^ -o $@.PHONY: mk_dir hd clean allmk_dir:if [ ! -d $(BUILD_DIR) ]; then mkdir $(BUILD_DIR); fihd:sudo dd if=$(BUILD_DIR)/kernel.bin \of=/home/steven/source/os/bochs/hd60M.img \bs=512 count=200 seek=9 conv=notruncclean:cd $(BUILD_DIR) && rm -f ./*build: $(BUILD_DIR)/kernel.binall: mk_dir build hd

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

本篇总结

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

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

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

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

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

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

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

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

《操作系统真象还原》第十章相关推荐

  1. 《微观经济学》 第十章

    第十章 微观经济学的现状与前沿 10.1 新古典经济学的基本特征 微观经济学基本的研究程序: (演绎法) 实例:需求曲线向右下方延伸.     微观经济学中演绎的特点: 利用理性原理展开推理. 寻求均 ...

  2. 朱善利《微观经济学》第3版课后习题答案

    完整版在线阅读>>> http://wwxx.100xuexi.com/Ebook/4459.html 本书是朱善利<微观经济学>(第3版)教材的学习辅导书,对朱善利&l ...

  3. 数字图像处理——第十章 图像分割

    数字图像处理--第十章 图像分割 文章目录 数字图像处理--第十章 图像分割 写在前面 1 点.线和边缘检测 1.1 孤立点的检测 1.2 线检测 1.3 边缘检测 2 阈值处理 2.1 单一全局阈值 ...

  4. [k8s] 第十章 DashBoard

    第十章 DashBoard ​ 之前在kubernetes中完成的所有操作都是通过命令行工具kubectl完成的.其实,为了提供更丰富的用户体验,kubernetes还开发了一个基于web的用户界面( ...

  5. perl5 第十章 格式化输出

    第十章 格式化输出 by flamephoenix 一.定义打印格式 二.显示打印格式 三.在打印格式中显示值   1.通用的打印格式   2.格式和局域变量   3.选择值域格式   4.输出值域字 ...

  6. 第十章 Linux下RPM软件的安装与卸载

    第十章 Linux下RPM软件的安装与卸载 第一节 RPM软件包安装 rpm命名原则 如: vsftpd-3.0.2-9.el7.x86_64.rpm vsftpd 3.0.2 9 e17 x86_6 ...

  7. C Primer Plus (第五版) 第十章 数组和指针 编程练习

    第十章 数组和指针 编程练习 1.修改程序清单10.7中的程序rain,使它不使用数组下标,而是使用指针进行计算(程序中仍然需要声明并初始化数组). # include <stdio.h> ...

  8. hal库开启中断关中断_「正点原子NANO STM32开发板资料连载」第十章 外部中断实验...

    1)实验平台:ALIENTEK NANO STM32F411 V1开发板 2)摘自<正点原子STM32F4 开发指南(HAL 库版>关注官方微信号公众号,获取更多资料:正点原子 第十章 外 ...

  9. 第十章: 数据模型高级进阶

    2019独角兽企业重金招聘Python工程师标准>>> 第十章: 数据模型高级进阶 在第5章里,我们介绍了Django的数据层如何定义数据模型以及如何使用数据库API来创建.检索.更 ...

  10. 《javascript设计模式》笔记之第十章 和 第十一章:门面模式和适配器模式

    第十章:门面模式 一:门面模式的作用 简化已有的api,使其更加容易使用 解决浏览器的兼容问题 二:门面模式的本质 门面模式的本质就是包装已有的api来简化操作   三:门面模式的两个简单例子 下面这 ...

最新文章

  1. debian10 nfs简单搭建
  2. 学习笔记-小甲鱼Python3学习第九讲:了不起的分支和循环3
  3. vs2019c语言头文件的路径,vs2019设置及第三方库的使用,
  4. 互联网1分钟 | 0121 Vlog陌生人社交APP「自言」为年轻人打造生活视频分享平台;周鸿祎:智能设备要警惕“海豚音攻击”...
  5. java集合(4)-Set集合
  6. CF 1174 D. Ehab and the Expected XOR Problem 异或技巧
  7. 从数组创建ArrayList
  8. Halcon模板匹配(基于相关性)
  9. fail-fast机制
  10. python运算优先级
  11. 怎么修改PDF文件内容
  12. 微信小程序开发费用一览表
  13. Debian9.5系统DNS服务器BIND软件配置说明
  14. 面试官:说说你对 options 请求的理解
  15. 阻止计算机病毒入侵系统,入侵预防系统
  16. python的十句名言_程序员的二十句励志名言,看看你最喜欢哪句?
  17. 清朝十二帝记忆顺口溜
  18. 欢迎关注异贝!今天异贝与您一起分享:美容行业异贝引客方案设计!
  19. Hbase Schema设计与数据模型操作
  20. IP、Route相关命令基础知识

热门文章

  1. 【Arduino实验05 基于环境光的LED灯亮度感应控制】
  2. Visio 中插入的Excel 如何只显示数据部分?
  3. 上海之行,我是来要饭的
  4. Unity-面向接口编程(IOP)
  5. 克隆虚拟机 - hyperv
  6. 计算机音乐乐谱打上花火,扒完,打上花火,自扒
  7. 【XJTUSE计算机图形学】第三章 几何造型技术(1)——参数曲线和曲面
  8. 系分 - 计算机组成与体系结构
  9. 互联网产品经理(PM)的工作内容和职责
  10. 云数据库CynosDB有哪些常见问题?