操作系统学习笔记10 | I/O、显示器与键盘

时间:2022-09-11 17:03:37

从这一部分开始介绍操作系统的设备驱动,操作系统通过文件系统的抽象驱动设备让用户能够使用显示器、键盘等交互工具。并讲解printf和scanf是如何实现敲下键盘将字符显示到屏幕上的。


参考资料:


1. 外设工作原理的主干理解

内存管理 的理解过程相似,对于 IO设备(也叫外设)的理解,我们回到计算机的工作原理-- 冯·诺依曼的存储程序、取指执行思想。

IO设备分为两类:

  1. 键盘和显示器,本文先聚焦这部分;
  2. 磁盘,这部分下一篇会详解;

后续会在磁盘驱动的基础上抽象出文件,最后所以会讲文件系统。

操作系统学习笔记10 | I/O、显示器与键盘

计算机如何让外设工作的呢?

  • 根据生活经验,每个外设都会有对应的控制设备,

    比如显示器对应显卡;磁盘对应磁盘控制器...

    这些设备内部是寄存器。

  • 核心原理向对应的 外设控制器 / 设备控制器 发指令,外设控制器根据其中的 寄存器(或是 memory)的值来操控对应硬件

    如 显卡控制显存输出到显示器、或是在其内部的计算电路执行一些计算(GPU是高效的并行计算硬件);

    具体表现为 out 指令:out xx,al

    外设部分 所有的代码落实到最后都是这个 out 指令。

  • CPU 向 外设控制器 发送指令(通过 PCI 总线)后,进入阻塞切换到其他进程。

  • 外设工作完后会向CPU发送中断,CPU再接着执行相关的中断处理程序

核心代码思路就是上面 发指令、外设工作、中断处理这几步;但为了让不同用途、不同厂家、不同型号的外设使用起来简单,还需要虚拟化、抽象化为统一的视图:文件视图。所以又加上了很多代码来进行包装。

抽象为文件视图是一个很重要的概念,这让上层用户只要想输出到显示器上,都可以统一使用 printf 函数,而不需要考虑显示器是什么型号。

总结:

  • 抽象化为统一的文件视图;
  • CPU发外设控制指令;
  • 外设工作后返回中断处理;
操作系统学习笔记10 | I/O、显示器与键盘

2. 显示器的工作理解

我们采用自顶向下的方式看看一段控制显示器输出的高级语言程序是如何被外设执行的。

2.1 文件视图

学习笔记2-系统调用 得知(当时的配图如下图1),printf 远远不是 ”输出" 的真相,它的C语言函数库中是如下代码。下面的open、write、close 都是什么意思呢?

//打开显示器对应的文件,此时显示器已经被抽象为文件
int fd = open("/dev/xxx");
for(int i = 0; i < 10; i++){
    write(fd,i,sizeof(int));
}
close(fd);
操作系统学习笔记10 | I/O、显示器与键盘

由于外设被抽象为文件,所以讲解 printf 的显示机制之前,还需要了解一下整个文件视图的全貌。

如上面代码所示,操作系统为用户操作各种外设提供了统一的文件操作接口

  • 操作函数:如上面代码中提到的 open、write、close,此外还有 read;
  • 操作对象:即"/dev/xxx",不同的设备名对应的就是不同的设备。根据这里的不同来区分设备,进而据此决定后续控制哪种硬件进行操作;
操作系统学习笔记10 | I/O、显示器与键盘

接下来,文件接口的操作函数,根据操作对象(C语言中的文件名)的不同,进行相应的控制硬件的处理。这就是下图的第二层:

如这里我们这里的代码是 printf 的展开,文件名对应的是显示器,对应就控制显示器。

在向下写控制器的指令就是 out 指令,向显示器控制器写入相应内容,控制器经过处理将指令作用到硬件上。这就是下图的最底一层。

当某些外设控制器处理完毕,就会向CPU返回中断,进行一些中断处理,再返回到 文件系统的接口层(write、close)这里。

比如键盘,按下键位后返回中断。

操作系统学习笔记10 | I/O、显示器与键盘

文件的读写的数据来源来自内存,如果是 printf,就是将数据从内存里的某段缓冲区取出字符打到显示器上,而如果是 fprintf,就是内存该区域的相关字符放到磁盘的相应块上。

2.2 从高级程序到文件接口

高级语言中,如果要输出一段字符,我们通常使用:

printf("Host Name:%s",name);

学习笔记2-系统调用中我们知道 printf 并非事情真相,它会继续展开为一段包含 write 的 函数库代码:write(1,buf,...); 意思是将 buf 这里的字符串 写到 1 这个地方。

至于这里的 1 是什么意思,见下面 2.3 sys_write。不过显而易见,write 再向下就会变成一段含有 out 指令的代码。

根据上面的文件系统,write 这个文件接口会根据操作对象不同进行分支,选择不同的第二层操作,比如操作显示器时就执行显示器分支。

2.3 内核层接口实现

上面进行到了文件接口,接口通过 int 0x80 指令中断进入内核,这在 <学习笔记2 | 操作系统接口> 详细讲过,这里就是继续向下完成内核中接口的实现,也就是内核层接口实现。

这部分综合知识太多,汇集了很多前面学到的知识,并且还有一些东西需要搜索,因此我给出了很多外链。

2.3.1 sys_write

wirte 函数调用了 int 0x80 中断,进入操作系统内核态,根据 IDT 调用 sys_write 执行具体的功能,核心代码如下:

注意,这里之前学习笔记2接口调用 讲到 sys_wirte 就没有继续向下了,这里算是续上了。

  • fd 就是上面的 1,buf 是存放格式化输出的缓存,count描述应当从这段内存向文件写入几个字节。可见,后两者都不能决定 write 向哪一个分支继续向下操作,所以提前猜测是 fd / 1 的作用;

  • file=current->filp[fd]; 如果对多进程图像还有点印象,current 就是当前进程的PCB,这里的意思就是PCB中的一个数组 flip 的1号位置处存储了一个文件。

  • 下一句 inode 就是获取 文件的信息。由于所有的外设都被抽象为文件,所以文件中一定有描述外设特征的信息。

    这样我们就拿到了分支依据。

操作系统学习笔记10 | I/O、显示器与键盘

2.3.2 sys_fork

有一个问题:flip 以及 flip[1]是哪里来的?

  • 答:既然在进程的PCB中,那么就是从进程创建(一般指从父进程创建子进程)时建立的,也就是从父进程拷贝来的。

    不要问父进程的是从哪里来的,操作系统本身算是0号进程,类似套娃。

  • 回忆 学习笔记5 的 ThreadCreate,进程创建是 fork => sys_fork 最后落实到 copy_process;

    下图代码中:

    • NR_OPEN 是一个进程可以打开的最大文件数。一个进程不能使用超过 NR_OPEN 个 文件描述符。

      详见:NR_OPEN 与 NR_FILE 的区别

    • 因为父子进程文件标识 fd 是数据,不发生写更改时是不需要 ”写时复制“ 的。这个的 ++ 操作的对象是被操作的文件,目的是文件信息更新(标志着使用者加一)。

  • flip[1]实际上是 打开文件的 指针。最开始是谁创建这个打开文件的指针的?

    • 回忆学习笔记4-开机过程中,所有的进程是从 0号进程打开 Shell 后续逐渐创建子进程开始的。(对于Linux 0.11 而言)

    • 代码如下图下侧,可见是打开了文件(dev/tty0)并拷贝了两份。而其中的 tty0 我们很熟悉,正是显示器。

      两个dup(0)的意思就是拷贝两份,具体参见:dup( )和dup2( )函数详解,此时数组 0,1,2 位置上都是这个文件,因此上文的 1 就决定要操作 tty0.

操作系统学习笔记10 | I/O、显示器与键盘

2.3.3 sys_open

到上面其实还不够,因为还有一个 open 这个系统调用 在被调用,不妨再看看 sys_open 是如何实现的。见下图代码:

  • filename 文件名,flag 文件解析目录,&inode 存放在磁盘上的文件信息;

  • 根据文件名字把文件读入进来,最核心是读入文件的inode(文件的相关信息,其中有比如设备类型和编号的信息等等)

  • open 函数建立了 如下图所示的 链:

    • 右侧链:f->f_inode = inode
    • 左侧链:current->flip[fd]=f;

    这样就完成了 文件 向 PCB 的添加,回答了2.3.1 sys_write 中的疑问。

    此处的 fd 为 1,是拷贝产生的,所以也对应 tty0,显示器;我们顺着 参数1这条链,最后找到的就是显示器。

勘误:下图PPT中的f应当都为i,或者把第二行的i改为f。

操作系统学习笔记10 | I/O、显示器与键盘

2.3.4 回到 sys_write 向屏幕输出

通过2.3.2 sys_fork 和 2.3.3 sys_open,文件信息 inode 从何而来以及如何打开文件 flip 就已经比较清楚了,下面回到 sys_write 看看如何外设分支的选择以及 sys_write 向下如何引出 out 指令的。

  • 计算机的设备分为 字符设备(char device)和块设备(block device),首先分支确定是否哪个大类的设备。

    • 块设备将信息存储在固定大小的块中,每个块都有自己的地址。数据块的大小通常在512字节到32768字节之间。块设备的基本特征是每个块都能独立于其它块而读写。磁盘是最常见的块设备。
    • 另一种基本的设备类型是字符设备。字符设备按照字符流的方式被有序访问,像串口和键盘就都属于字符设备。如果一个硬件设备是以字符流的方式被访问的话,那就应该将它归于字符设备;反过来,如果一个设备是随机(无序的)访问的,那么它就属于块设备。

    参考资料:块设备与字符设备 - 青山牧云人

    • 先从文件中读取信息 file->f_inode,然后再判定inode是不是字符设备 if(S_ISCHR(inode->i_mode))
  • 分好了大类,这里拿到的 tty 是 显示器,属于 字符设备;下面要选择是字符设备中的第几个设备:

    • 字符设备向下执行 rw_char(),读写字符设备。根据参数可知,这里的操作是 WRITE 写。

    • 选择是字符设备中的第几个设备(设备号): inode->i_zone[0]

      使用 ls -l 可以列出设备及其主设备号、从设备号,这也是 inode 中存储的设备信息。

      这里假设我们的显示器主设备号为4,从设备号为0。

  • 找到设备后,我们需要选择处理函数。

    • rw_char()向字符设备输入信息;
    • 根据主设备号 MAJOR(dev)crw_table 里查表;
    • 得到表里存放的函数指针,根据这个函数指针以及设备号,就可以找到对应的处理函数,接下来就是对应的处理函数。
操作系统学习笔记10 | I/O、显示器与键盘
  • 很显然,这里通过 函数指针数组 又实现了一层分支,看看这个 crw_table 数组的组成和工作:

    • crw_table 里第 4 个函数(主设备号为4)是 rw_ttyx。而 rw_ttyx 对应的正是向终端设备(显示器)上进行写操作,这是根据 上面的 write 一层层传下来的。

      终端设备包括 键盘和显示器,其中键盘为读操作,显示器为写操作。

  • rw_ttyx 调用 tty_write,在tty_write里实现输出:

    • 如下图1,根据 tty_table 和 channel 找到 tty,相当于找到对应的数据流,上面提到过字符设备按照字符流的形式读写。

    • 在往显示器里写之前,为了弥补CPU计算与显示器写时两种速度的不平衡,会将数据先写在缓冲区,再从缓冲区向显示器写。

    • 下面代码中的:sleep_if_full 对队列是否满进行判断,如果满了,则休眠等待。

      队列是 tty->write_q,类似于生产者消费者模型中的共享缓冲区。另一边显示器设备会有对应的消费者函数,当一份工作执行完毕,缓冲区中还有内容,则从缓冲区中读取字符。

    • 如果缓冲区没有满,则向缓冲区写,如下图2,缓冲区是在用户态内存,根据get_fs_byte 从用户态缓冲区读出,放在 tty->write_q 这个队列中。接下来就可以调用函数提取缓冲区内容进行屏幕输出了:tty->write(tty);

操作系统学习笔记10 | I/O、显示器与键盘操作系统学习笔记10 | I/O、显示器与键盘

2.4 真正的输出:out

  • 留意,tty 之所以 能够指向 write_q 缓冲队列,以及这里的 write 函数,是因为定义它是一个指向 tty_struct 结构体的指针。

  • 在 tty_struct 中查到 con_write,使用 con_write 向显示器写,这也是上面提到的那个 消费者函数。

    • con_write() 中使用 GETCH(tty->write_q, c) 从缓冲队列中取出字符 c,使用内嵌汇编编写将字符写在显示器上的指令,即写出out指令

    • 内嵌汇编讲解:

      • _attr 属性赋给 ah,将字符 c 赋给 ax,因为是字符实际上是放在 al 当中。

      • 现在的 ax 里低字节是字符,高字节是属性。

      • 然后,将 ax 赋给 1,1是 pos 显卡寄存器,最后得到的语句正是 mov ax, pos将 ax 中的值放到显存上。

        • 补充一点计算机基础知识:外部设备存储,有一部分可以和内存统一编址,此时使用 mov;另一部分独立编址,使用 out。

        • Intel x86平台普通使用了名为内存映射(MMIO)的技术,该技术是PCI规范的一部分,IO设备端口被映射到内存空间,映射后,CPU访问IO端口就如同访问内存一样。

        • 所以这里用 mov 和 out 本质上是一样的。

        参考资料:理解“统一编址与独立编址、I/O端口与I/O内存” - 板牙

        后续学习汇编和接口的时候看看会补充这部分内容。

      • while() 每循环一次在显示器上输出一个字符,直至while() 结束为止。

操作系统学习笔记10 | I/O、显示器与键盘

到这里,就完成了从高级程序到最终显示器输出的全部过程,上面的代码流程也就是平时赫赫有名的 设备驱动。开发设备驱动的过程 就是编写函数并注册到分支的表上,创建对应 dev 文件,建立 flip 链条。

注意,这里 console 就是终端的意思,这个文件里书写了键盘和显示器两方面的驱动。

2.5 显存工作过程概述

上面while循环中,每写一个字符,pos + 2。pos 的初始值在哪里?

  • 控制台的初始化在 操作系统 main.c 中的 con_init(),这里设置了光标的行号和列号
  • 注意这里的 0x90000,在 学习笔记1开机过程 中,bootsect.s 把自己和 setup.s 移动到内存 0x90000 处,setup.s 根据 bios 中断取出硬件参数,也包括了启动时光标在显存中的位置。
  • 而在 main.c 中 初始化 显存时,得到 0x90000 中存储的显存中光标的位置并赋值给 pos,后续用 pos 操纵显存,进行字符显示。
操作系统学习笔记10 | I/O、显示器与键盘

至于为什么是 pos + 2 而不是 pos + 1 呢?

  • 因为屏幕字符在显存中除了字符本身外还有字符属性(如颜色),如下图的显存字符格式所示:
  • 通过 console.c 中的设置,可以呈现如黑底白字的效果。
操作系统学习笔记10 | I/O、显示器与键盘

2.6 简单总结

高级程序 printf => 文件接口 write => 字符设备接口 crw_table 函数数组 => 生产者:tty 设备写(tty_write)=> 缓冲队列 write_q、同步机制 => 消费者:显示器写(con_write).

操作系统学习笔记10 | I/O、显示器与键盘

实验7 中按下 F12 ,此后屏幕上输出都会是 * 号,这一点在上述显示过程中不难理解,只需要在 tty_write 中的 c 字符 替换为 * 即可。下面来看看如何用键盘启动这个过程。

3. 键盘的工作理解

键盘也归属于上面 2.1的文件视图,也可以按照 第 1 部分外设工作原理的主干来进行理解,不过此处与显示器不同,键盘是典型的输出设备,可以向 CPU 发中断处理请求

对于终端设备键盘和显示器而言,有两个明线:

  • CPU 向对应的 外设控制器 / 设备控制器 发指令;
  • 外设控制器 向 CPU 返回中断请求。
操作系统学习笔记10 | I/O、显示器与键盘

3.1 21号中断与中断处理

对于键盘来说,敲下键盘就会发出中断,所以键盘的工作应当从键盘中断开始。

在操作系统初始化时( main.c 中的 con_init() ),将键盘中断 / 21号中断的处理程序设置为:keyboard_interrupt。当敲键盘产生中断时,就会调用这个中断处理函数。

当然,这里的21号中断也是硬件手册中查到的。

操作系统学习笔记10 | I/O、显示器与键盘
  • inb $0x60,%al是最核心的指令

    • inb 读入一个字节,会将 60 端口中的数据读入到 al 当中。

      60 端口:扫描码,每一个按键都对应一个扫描码

    • call key_table(,%eax,4):根据不同的码,调用key_table 来执行相应的工作,这也开始向上分支了。

3.2 处理扫描码

在 key_table 中,根据前面得到的不同的扫描码,做不同的指令,其中 do_self 为用汇编语言写的显示字符函数。do_self 会将 key_map 载入 ebx。

对于一般的 敲下a,b,c这样的键,就是调用 do_self 显示字符本身。

操作系统学习笔记10 | I/O、显示器与键盘

key_map 中是一堆 ASCII 码:

  • 将 key_map 载入 ebx 的意思是,将这个表的起始地址赋给 ebx;
  • 扫描码是 key_map 表的偏移,存放在 eax 中;
  • movb (%ebx,%eax),%al,就找到了按下的键所对应的 ASCII 码;

同理,如果是 shift 键,如下面代码所示,对应的是一些按下shift 才能显示的字符。

操作系统学习笔记10 | I/O、显示器与键盘

3.3 放入缓冲队列

拿到 ASCII 码后,放入缓冲队列,当上层进程执行如 scanf 这样的函数时,就从缓冲队列拿出字符。

  • 如下图代码;ASCII码 放到了缓冲队列当中;call put_queue,等上层进程来拿;
    • put_queue 中得到终端设备的列表和 read_q 的 head;
    • 将 ASCII 码输出到这个缓冲队列的头部。

然后,再将其回显到屏幕。

这里一个生产者就完成了,后续消费者(即上层取队列元素的程序,同样经过文件视图的封装)与第2部分 写入 write_q 队列的程序很相似,只不过上文是写,这里应当是 读取 read_q 队列。这部分不再细讲。

操作系统学习笔记10 | I/O、显示器与键盘上。

3.4 回显

可显示的字符通常需要回显到显示器上,这其实就跟第 2 部分的开头会师了:

  • 从 read_q 中得到一个字符 c;
  • 将字符 c 放入 write_q 队列中;
  • 调用 tty->wirte 将其显示到屏幕上。

3.5 简单总结

  • 键盘中断的核心就是 取出ASCII码放到read_q里面 ;

  • 再从 read_q 里面放入 secondary(进行转义等中间处理)等队列中

    一些优化技术。

  • scanf 再从 secondary 队列中取出ASCII码。

  • 回显,将这个码再放到write_q队列中,从队列中取出码回写输出到屏幕上。

操作系统学习笔记10 | I/O、显示器与键盘

4. 总结

第3部分的键盘 scanf 与第 2 部分 printf 显示器综合起来,就得到了从键盘输入,到显示器输出的全过程:

  • 使用 scanf 输入时,OS 扫描键盘上是否有所输入;
  • 如果有,调用中断处理,查找到对应的 ASCII 码;
  • 将 扫描码 放入 read_q 队列,经过一些优化技术(如放入secondary队列),此时队列中的元素可以被 scanf 正确读入。
  • 读入后,调用 回写指令,把 ASCII 码再放入 write_q 队列中,向屏幕发出 out 指令,让字符可在显示器上输出。

本部分对应实验7. 与2.6部分对应,如果要让F12按下后输出 *,则需在 3.2 部分处理扫描码的时候不调用原 func,而是重写一个函数使其得到的字符是 *

操作系统学习笔记10 | I/O、显示器与键盘