《操作系统真象还原》 第十章 输入输出系统
1.锁的实现
在上章实现的多线程打印字符的程序中,当一个线程在打印字符设置光标值的时候,可能会由于各种原因导致该线程下处理器,另外一个线程上处理器重新修改光标值,当该线程再次执行时,就会发生异常。因此,我们需要通过锁机制来实现线程的同步,也就是当多个线程会对同一段区域的内容修改时,只能由拥有锁的那一个线程进行操作。在上章的案例中,我们需要将打印字符put_str()函数修改成原子操作,要么一次性执行完,要么不执行。
在thread.c文件中添加以下两个函数
//将当前正在运行的线程pcb中的状态字段设定为传入的status,一般用于线程主动设定阻塞
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(); //得到当前正在运行的进程的pcb地址cur_thread->status = stat; // 置其状态为stat schedule(); // 将当前线程换下处理器
/* 待当前线程被解除阻塞后才继续运行下面的intr_set_status */intr_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)));if (pthread->status != TASK_READY) {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);
}
thread_block()函数用于阻塞线程,当该线程未能申请到锁时就会调用该函数,将该线程的状态修改为阻塞状态,然后换下处理器,等待唤醒。thread_unblock用于唤醒线程,将传进的线程添加到就绪队列的队头,等待执行。
在thread目录下创建sync.c和sync.h文件
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);
void sema_down(struct semaphore* psema);
void sema_up(struct semaphore* psema);
void lock_init(struct lock* plock);
void lock_acquire(struct lock* plock);
void lock_release(struct lock* plock);#endif
在该文件中,定义了struct semaphore和struct lock两个结构体,第一个结构体表示信号量,信号量表示资源的个数,此处用于实现锁,只能取0或1两个值,被称为二元信号量,该结构体中维护了一个双向链表,记录阻塞在该信号量上的线程,当拥有该信号量的线程执行结束后在该队列中进行唤醒,第二个结构体表示锁,记录了拥有该锁的线程、该锁的信号量以及锁被同一个线程所拥有的次数。
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); //初始化信号量的等待队列
}//用于初始化锁,传入参数是指向该锁的指针
void lock_init(struct lock* plock) {plock->holder = NULL;plock->holder_repeat_nr = 0;sema_init(&plock->semaphore, 1); //将信号量初始化为1,因为此函数一般处理二元信号量
}//信号量的down操作,也就是减1操作,传入参数是指向要操作的信号量指针。线程想要申请信号量的时候用此函数
void sema_down(struct semaphore* psema) {enum intr_status old_status = intr_disable(); //对于信号量的操作是必须关中断的//一个自旋锁,来不断判断是否信号量已经被分配出去了。为什么不用if,见书p450。while(psema->value == 0) { // 若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);
}//信号量的up操作,也就是+1操作,传入参数是指向要操作的信号量的指针。且释放信号量时,应唤醒阻塞在该信号量阻塞队列上的一个进程
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);
}//获取锁的函数,传入参数是指向锁的指针
void lock_acquire(struct lock* plock) {
//这是为了排除掉线程自己已经拿到了锁,但是还没有释放就重新申请的情况if (plock->holder != running_thread()) { sema_down(&plock->semaphore); //对信号量进行down操作plock->holder = running_thread();ASSERT(plock->holder_repeat_nr == 0);plock->holder_repeat_nr = 1; //申请了一次锁} else {plock->holder_repeat_nr++;}
}//释放锁的函数,参数是指向锁的指针
void lock_release(struct lock* plock) {ASSERT(plock->holder == running_thread());//如果>1,说明自己多次申请了该锁,现在还不能立即释放锁if (plock->holder_repeat_nr > 1) { plock->holder_repeat_nr--;return;}ASSERT(plock->holder_repeat_nr == 1); //判断现在lock的重复持有数是不是1只有为1,才能释放plock->holder = NULL; //这句必须放在up操作前,因为现在并不在关中断下运行,有可能会被切换出去,如果在up后面,就可能出现还没有置空,//就切换出去,此时有了信号量,下个进程申请到了,将holder改成下个进程,这个进程切换回来就把holder改成空,就错了plock->holder_repeat_nr = 0;sema_up(&plock->semaphore); // 信号量的V操作,也是原子操作
}
此文件中主要定义了关于信号量和锁的函数,sema_init和lock_init用于初始化,sema_down()函数用于申请信号量,即信号量减1,对信号量的操作必须是原子的,因此在执行之前和之后分别调用了关开中断的函数,通过自旋锁的形式与竞争信号量,因为该线程第一次没能竞争到信号量时,进入阻塞状态,当被唤醒时,信号量可能会被其它线程抢走,因此要通过while循环不停地去竞争信号量,当抢到信号量后,将信号量减1,sema_up()函数为释放信号量,从当前信号量的阻塞队列中选择一个线程,调用thread_unblock()去唤醒。lock_acquire()函数就是获取锁的函数,会先判断当前线程和该锁的拥有者是否是同一个线程,若是,只需在锁的拥有次数+1,否则调用sema_down()去申请信号量,将锁的拥有者设为该线程。lock_release()函数是释放锁,先判断锁的拥有次数是否为1,若不为1,减1后直接返回,还不能释放,若为1,先将锁的拥有者设为NULL,然后调用sema_up()函数释放锁的信号量。
在device目录下创建console.c文件和console.h
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_asci);
void console_put_int(uint32_t num);
#endif
console.c文件
#include "console.h"
#include "print.h"
#include "stdint.h"
#include "../thread/sync.h"
#include "../thread/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();
}
该函数主要功能就是定义一个锁,对之前的各种打印函数进行修改,使其具有原子性,也就是在调用各种打印函数之前必须先获取锁,执行结束后释放锁。
在init.c文件中的init_all函数中添加console_init()并且将main.c中的打印函数替换为console_put_str()函数,再次编译,启动虚拟机,会发现打印频率变快,之前我们在打印函数前后调用了关开中断函数,导致打印频率较低。
2.获取键盘的输入输出
键盘是一个独立的设备,在键盘里存在着键盘编码器Intel8048芯片,用于监控键盘的操作,当键盘的某个按键被按下时,就会向键盘控制器发送信号,键盘控制器位于主机的主板上,通常为Intel8042芯片,它的作用是接收键盘编码器发来的信号,解码后发送给之前提到的中断代理8059A,因此,键盘的输入实际上就是发生了一次中断,我们需要去编写对应的中断处理函数,就可以获取到键盘的输入。
在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 ;保留
在device目录中创建keyboard.c文件和keyboard.h文件
keyboard.h文件
#ifndef __DEVICE_KEYBOARD_H
#define __DEVICE_KEYBOARD_H
void keyboard_init(void);
#endif
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读走键盘8048传递过来的数据,否则8042不会接收后续8048传递过来的数据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字符,然后通过inb()从端口号读取数据,方便接收下一个数据,最后将该中断处理函数注册到中断处理函数数组中。
在init.c文件中的init_all()函数中添加keyboard_init()函数。
修改main.c函数为死循环,拥有接收键盘的操作。
main.c文件
#include "print.h"
#include "init.h"
#include "../thread/thread.h"
#include "interrupt.h"
#include "console.h"void k_thread_a(void*);
void k_thread_b(void*);int main(void) {put_str("I am kernel\n");init_all();intr_enable();while(1);return 0;
}
重新编译,启动虚拟机,按下键盘中的任意按键都会打印k字符。按下和弹起都会打印。