七、输入输出系统:用锁实现输出、编写键盘驱动程序、环形输入缓冲区

来源:互联网 发布:大数据 算法 编辑:程序博客网 时间:2024/06/05 03:39

多线程调度中出现了字符乱象,本质是访问公共资源需要很多步操作,但是这些操作不具备原子性,它被任务调度器给断开了,从而让其他线程有机会去破坏显存和光标寄存器这两类公共资源。
公共资源:公共内存、公共文件、公共硬件等,我们这里的是显存和光标寄存器
临界区:各个任务中访问公共资源的指令代码组成的区域就是临界区,,强调一下,临界区是指程序中那些访问公共资源的代码,即临界区是指令代码。
互斥:指某一时刻公共资源只能被1个任务独享,不允许多个任务同时出现在自己的临床区中,其他任务想访问临界区,必须等其他任务访问完后在去访问。
竞争条件:多个任务以非互斥的当时同时进入临界区。这里的“同时”指的是伪并行,即一个任务在自己的临界区中访问公共资源,还没有执行完代码,其他任务也进入了自己的临界区访问了同一个资源。
因此我们的目的是:实现互斥,排除竞争。我们的临界区是:put_char函数,每一个任务都调用了put_char函数,所以它是每个任务的临界区。
总结:多线程访问公共资源时出现问题是因为产生了竞争条件,即一个任务没有完全执行完临界区代码时候,通过中断,它的时间片到了,所以调度器换上另一个任务来执行。因此执行临界区时候关中断是最简单有效的方法。要保证临界区中的所有代码都是原子操作,要么一条不做,要么一气呵成。临界区中的指令多余一条时候才需要互斥。我们通过锁来实现互斥。

信号量

信号量是个计数器,它的计数值是自然数,用来记录所积累信号的数量。我们用 P来表示减少,V 来表示增加。
增加操作 up 的包括:
1,将信号量的值加1
2,唤醒在此信号量上等待的下一个线程。
减少操作 dowm 包括:
1,若信号量大于0,则信号量减1
2,若信号量等于0,当前线程将自己阻塞,等待被唤醒。
我们用二元信号量来实现锁。down 操作是获得锁,up 操作是释放锁。我们可以让线程通过 锁 进入临界区,可以保证只有一个线程进入临界区,从而实现互斥。
1,线程A进入临界区前先通过 down 操作获得锁,此时信号量变为0。但是中断还是在继续,A的时间片还是一直在减1。
2,线程B通过中断调用调度函数上了处理器,此时A还在临界区里面,想执行临界区。首先进行 down 操作,此时信号量为0,线程B 便在此信号量上等待,也就是线程B进入了睡眠状态等待被唤醒。中断还在继续,线程C、D依次执行它们各自的程序,假设C、D没有临界区。
3,线程A又经过中断上了处理器,接着执行上次被中断的代码(还在临界区内),执行完临界区后,就通过 up 操作释放锁。此时信号量变成了1,然后线程A将线程B唤醒。中断继续。
4,线程B通过中断被换上处理器,接着执行上次被中断的地址,即执行临界区:还是先 down 操作,获得锁,信号量变成0,然后开始执行临界区代码。中断还是在继续。

线程的阻塞与唤醒

阻塞:线程自己阻塞自己,并不是别人阻塞自己的。阻塞是线程主动的行为。阻塞的实现就是不让线程出现在就绪队列中,而是出现在被阻塞临界区那个锁(不同的临界区都有锁)中的阻塞队列即可。
唤醒:唤醒是需要别人唤醒的,是被动行为。

在线程阻塞自己的时候要关闭中断,如果开着中断,可能线程B正在阻塞自己就被换下了,此时线程A已经释放了锁,发现该锁的阻塞队列什么也没有也就不用唤醒什么,换到线程B后继续阻塞自己,但是临界区已经空闲了,线程B阻塞了自己就没有什么用了。所以要关中断保持原子操作:当关中断后,开始阻塞自己,阻塞完成,中断开启,然后线程A释放锁,然后唤醒线程B,B获得锁开始执行临界区。
/* 当前线程将自己阻塞,标志其状态为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; // 置其状态为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);}

锁的实现

信号量的结构

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

锁结构:这个锁目前的持有者、信号量、等待序列、持有者重复申请锁的次数

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

锁 down 操作:如果信号量为1,线程获得锁,信号量减1。如果信号量为0,线程阻塞自己,加入该锁的阻塞队列,调用调度函数换上下一个线程,等待唤醒。

同理,也要关中断,如果不关中断,中断继续,时间片减1,当还没有获得锁时候就被中断换下处理器,下一个线程也去执行临界区,因为上个线程没有down锁,此时信号量还为1,就可以获得锁,继续访问公共资源。换下处理器后,上个线程开始继续执行,它已经判断过信号量了,但是不知道公共资源被抢占了,继续运行,引起了竞争。所以要关中断。

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));      /* 当前线程不应该已在信号量的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);}

获取锁:线程对 锁 进行 down 操作

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++;}}

释放锁:

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操作,也是原子操作}

用锁实现终端输出

我们通过给临界区加锁,可以实现互斥。临界区就是 put_char、put_str、put_int,这几个程序。

/* 终端中输出字符 */void console_put_char(uint8_t char_asci) {   lock_acquire();    put_char(char_asci);    lock_release();}

从键盘获取输入

键盘是个独立的设备,在它内部有个叫做键盘编码器的芯片,通常是 Intel 8048,作用:每当键盘上发生按键操作,它就向键盘控制器报告哪个键被按下,按键是否被弹起。键盘控制器属于IO接口,它在主机内部的主板上,通常是 Intel 8042,作用:接收来自键盘编码器的按键信息,将其解码后保存,然后向中断代理发中断,之后处理保存过的按键信息。

8048必然要和8042达成一个协议:键盘上的每个物理按键都是唯一数值。因此所有的按键对应的数值便组成了一张 “ 按键-数值 ” 编码映射表--键盘扫描码。它不仅记录按键被按下时对应的编码,也记录按键被松开时的编码。 因此一个按键即有两个码:按下去时的编码叫做通码--makecode ,按键按住不动时候会持续产生相同的码,直到按键被松开时终止产生。按键被松开时产生的编码叫 断码 --breakcode。一个键的扫描码是由通码和断码组成。

无论是按下键还是松开键,当键的状态改变时,键盘的8048芯片把按键对应的扫描码发送到主板上的8042芯片,8042芯片处理后保存到自己的寄存器中,然后向8259A发送中断信号,处理器去执行键盘的中断处理程序。

扫描码

第二套键盘扫描码是目前所有键盘的标准,因此大多数键盘向8042 发送的扫描码都是第二套的。但是8042 会将第二套扫描码转换为 第一套扫描码。
大部分情况下第一套扫描码中的通码和断码都是1字节大小,关系是:断码=0x80+通码。它们都是一字节大小,最高位也就是第7位的值决定按键的状态,最高位若值为0,表示按键处于按下的状态,否则为1的话,表示按键被弹起。所以这就是通码 加上 0x80 。

完整的击键操作完整过程:按下、按下保持、弹起三个阶段。这三个阶段80488042发扫描码,8042都会向8259A发中断,即按下按键发中断、持续按着不松手持续发中断、按键弹起发中断。击键产生的扫描码是由键盘中的8048传给主板上的8042的,8042将扫描码转码处理后存入自己的 输出缓冲区寄存器(8位宽度) 中,然后向8059A发中断信号,我们的键盘中断程序读取8042的输出缓冲区寄存器,会获得键盘扫描码。


8042

很多外部设备都有自己的处理器,来分担CPU的工作,比如显卡的处理器是GPU,系欸你拍的处理器就是 8048 8042,都有自己的寄存器和内存。8048位于键盘中,负责监控按键扫描码和对键盘设置。8042位于主板的南桥芯片中,是键盘的IO接口,804248位的寄存器。


8042是连接8048和CPU的桥梁,8042相当于是数据中转站。根据数据被发送的方向,8042的作用分别是输入和输出。(输入输出都是相对于8048来说的,in和out是相对于CPU来说的)
注意:输出缓冲区寄存器中的扫描码是给处理器准备的,在处理器未读取前,8042不会在往此寄存器中存入新的扫描码。8042将状态寄存器中的第0位置成0,这就表示寄存器中扫描码数据已经被取走,可以继续处理下一个扫描码了。当再次往输出寄存器中存入新的扫描码时,8042就将状态寄存器第0位置1。所以中断处理程序要用 in 指令读取输出缓冲寄存器,否则8042无法继续响应键盘操作。因此按下一个键,8042发出了中断信号,这个信号也仅仅是个信号,里面什么信息也没有,CPU响应中断信号转去处理键盘中断程序,要想获得键码信息,就必须要去 8042 的寄存器中读取值,输出缓冲区寄存器如果没有被读取,然后中断处理程序就结束了,那么 8042 就不会再次发断码的中断信号了。必须发一次中断信号,CPU就要读取一次值,然后 8042检测到了寄存器中的值没有了,才会继续发中断。所以按下一个键,发生中断,CPU取值,松开后,会再次中断,CPU会再次取断码值。一次按键两个信号,CPU都要读取输出缓冲区寄存器中的通码值和断码值,不然8042不会发中断信号了。

键盘中断处理程序

写好的键盘中断处理函数我们要把函数地址写入idt_talbe[] 才行。键盘的中断向量号接在主片的 IR1 引脚上,也就是它所对应的中断向量为 0x21。在键盘中断处理程序中,CPU读取的是第一套键盘码,并不是 ASCII 码,但是我们的字符处理软件都是只识别 ACSII 码,所以,我们的中断处理程序要进行转换。
字符集中的字符分为:可见字符和控制字符。转义字符都是用反斜杠字符 " \ " + 单个字母的形式。因此键盘中的键也分为两类:一类按键是负责输入可见字符,一类按键负责输出控制字符。控制字符不可见,我们要对应转换为转义字符。
观察按键的通码,可以发现几乎是连续的: 0x1 - 0x58。因此我们可以创建二维数组来构建映射关系,用通码作为数组的索引。
1,在控制键中只有字符控制键才有 ASCII 码。所以esc、backspace、tab、enter、delete 这些键才有 ASCII 码,这些 控制字符的 ASCII 可以通过打印函数表现出来:比如 backspace 的 ASCII 码就是 删除一个字符。
2,ALT、CTRL、SHILT、CAPS LOCK 这些控制字符是没有 ASCII 码的。但这类控制字符是组合键,是结合其他字符来显示效果的。
3,我们只处理主键盘区的按键,主要为数字和字母,主键盘区的按键都是配合着控制字符来进行的。对于控制字符按键 shift 和 ctrl 来说,都要按下去不动,然后在按字母键或者数字键的。比如 shift +a,显示的 A。我们要一直按着 shift 键不动,然后按 a ,按着不动的时候, shift 键产生一直产生通码,没有断码,CPU一直循环取的值也是 shift 通码,然后按下 a,此时 寄存器中的值就是 a 的通码了, 所以不管shift 按了多少次,我们只关注最后一次即可。
所以我们的控制键产生了很多种组合情况:
当 Caps Lock 键打开了, shift 键一直按着不放时,按字母键:产生 无 效果,打印小写 。按其他键:产生 shift 效果,打印 上档字符
当Caps Lock 键没有打开, shift 键没有按时,按字母键:产生 shift 效果,打印大写。按其他键: 产生 shift 效果,打印上档。
所以我们要定义全局变量时刻记录着 这些控制字符的状态:当Caps Lock 变量置0时, shift 键一直按着,shift 全局变量置1,一旦松开,马上归零。当 Caps Lock 变量置1,shift 键一直按着,Shift 全局变量 置0,一旦松开,马上置1。

4,当有扩展码时,比如说 R-alt 通码为 e0,38 。但是输出缓冲寄存器只有8位,所以8042将连续两次给CPU发中断,第一次往寄存器中存入 0xe0 ,CPU取走后,第二次往寄存器中存入 38,发起第二次中断,CPU取走,这才是通码结束。所以一旦检测到了寄存器中的值是 0xe0 ,那么就要立即进行下一次中断程序来读取下一个通码。
5,CPU只负责从寄存器里面取值,并不知道是通码还是断码,所以我们用高8位是否为1来判断是否为通码,是通码就要记录下来,是断码我们要判断是不是控制字符的断码,来确定控制字符全局变量的值。

环形输入缓冲区

在键盘上操作是为了与系统进行交互,交互过程一般是键入各种 shell 命令,然后 shell 解析并执行。shell 命令是由多个字符组成的,并且要以回车键结束的。因此我们要找个缓冲区把已键入的信息存起来,当凑成完成的命令时再一并由其他模块处理。

生产者与消费者问题

诠释 线程同步 最典型的例子就是著名的 生产者与消费者问题。“同步” 是指多个线程互相协作,共同完成一个任务,属于线程间工作步调的相互制约。“互斥” 是指多个线程“分时”访问公共资源。生产者与消费者问题是描述多个线程协同工作的模型。这个模型包括一个或多个生产者,一个或多个消费者和一个固定大小的缓冲区,所有的生产者和消费者共享同一个缓冲区。生产者生产某种类型的数据,每次放一个到缓冲区中,消费者消费这种数据,每次从缓冲区中消费一个。同一时刻,缓冲区只能被一个生产者或消费者使用。当缓冲区已满时,生产者不能继续往缓冲区中添加数据,当缓冲区为空时,消费者不能在缓冲区中消费数据。
生产者的行为:当缓冲区中数据不满时就生产,当缓冲区由空变为不空时就唤醒消费者,只要缓冲区已满时就去休眠。
消费者的行为:当缓冲区中数据不空时就消费,只要缓冲区为空时就会休眠,休眠前就会唤醒生产者。
总结:对于有限大小的公共缓冲区,如何同步生产者与消费者的运行,以达到对共享缓冲区的互斥访问,并且保证生产者不会过度生成,消费者不会过度消费,缓冲区不会被破坏。

环形缓冲区的实现

缓冲区是多个线程共同使用的共享内存,问题的关键在于缓冲区操作上,设计出合理的缓冲区操作方式,就能够解决生产者与消费者问题。
环形缓冲区本质上依然是线性缓冲区,没有固定的起始地址和终止地址。我们提供两个指针,一个头指针,用于往缓冲区中写数据,另一个是尾指针,用于从缓冲区内读数据。每次通过头指针往缓冲区内写入一个数据后,使头指针加1指向缓冲区下一个可以写入数据的地址,每次通过尾指针从缓冲区中读取一个数据后,使尾指针加1指向缓冲区中下一个可读入数据的地址。我们只要控制好头指针和尾指针的位置就好了,无论怎么样变化,始终让它们落在缓冲区空间之内。
我们要对环形缓冲区加锁,每次对缓冲区操作时都要先申请这个锁,从而保证缓冲区操作互斥。



原创粉丝点击