这份笔记,是我看哈尔滨工业大学李治军老师讲《操作系统》这门课所记的第四部分,设备驱动与文件系统。关于这份笔记的说明,可查看前面一篇 (一)操作系统基础 的前言部分。
L26 I/O与显示器
从这节课开始,我们学习操作系统的第 4 个部分,设备的驱动,I/O 设备的驱动。今天我们讲的是第 26 讲,I/O 设备中的第一个,也是大家最关心的一个设备,显示器。今天我们讲显示器是如何被驱动的。
实际上,就是操作系统如何让用户来使用显示器的,最终的表现就是 printf。从一开始我们就用了 printf 这个例子,今天我们就来看 printf 到底是怎么打在屏幕上,打在显示器上,这就是今天的故事。
从今天开始,我们要开始学习设备驱动,这是操作系统的第 4 个部分。那么从哪里开始讲呢?仍然是从那台计算机开始。
操作系统是管理硬件的一层软件,前面我们讲了操作系统如何管理 CPU,我们说过从上层要切到里面,切进去,要看操作系统是具体如何实现。我们讲清楚了 fork,讲清楚了进程管理是什么意思。接着我们也讲了内存,这是计算机另一个非常重要的硬件。我们讲清楚了内存是如何管理的,如何使用的,如何分段,如何分页,如何引出虚拟内存,最后 *p = 7
是如何最终写到物理内存上。所以对这台机器来说的话,CPU 和内存都明白了。
那么从今天开始,我们要学习计算机硬件中的另外一个部分,计算机硬件中另一个主要组成部分, I/O 设备。所以从今天开始,我们讲 I/O 设备。其中一种 I/O 就是显示器和键盘,这是这一讲和下一讲的主要内容。
然后我们再讲另外一种重要的 I/O 设备,磁盘。因为还要在磁盘的基础上抽象出一个文件来,所以到时候我们会讲文件系统。但是,不管是怎么抽象出来一个文件,核心还是为了驱动一个磁盘设备而引出来的。所以这两部分实际上本质是一样的,都是 I/O 设备驱动。今天我们讲的就是终端设备中的显示器是如何驱动的。
无论是显示器、还是键盘、还是磁盘,都是属于 I/O 设备,都是属于外设。所以我们首先必须要明确计算机是如何让外设工作的。就像前面我们讲 CPU 的时候,我们首先从 CPU 是如何工作开始;讲内存,从内存如何使用开始。这个地方也是一样的,首先讲外设如何工作。
这个学过计算机原理会知道,实际上这也是计算机最基本的一种常识,那么怎么使用外设呢?这个我们都应该知道。我们使用外设的时候,每一个外设都有一个东西,比如说显示器有个显卡,CPU 只需要发出一条指令,这条指令给,比如说显卡的话,就给显卡中的一个寄存器,键盘的话就给键盘的一个寄存器,显卡比如说显存,磁盘也是一样的,给磁盘控制器中的寄存器。这个是计算机硬件原理的一种常识。
也就是说,我们都是给外设对应的那个控制器,卡,磁盘卡就是磁盘控制器,显卡,网卡 … ,给这种卡上的寄存器往里写内容。然后这个卡,这个控制器就会根据寄存器里面的内容,实际地来操控硬件,这就是它的基本思想。
总的来说的话,我们让外设工作起来的原理很简单,只要给相应的这个控制器中的寄存器,往里面写一个东西、发一个指令。然后这个控制器就会进行处理(控制器里面也有相应的计算电路),根据 CPU 发出的指令来具体操控设备,比如在显示器上显示出内容,一旦显示完了,控制器会向 CPU 发一个中断,表示我做完了。接着 CPU 再处理这个中断,在处理的过程中可能要传输数据等这些事情。
所以大家可以看到,这里面最核心的就两块内容,第一块内容,我们要向这些控制器发指令,要使用外设实际上就是向控制器发指令。而向控制器发指令,往往实际上都是只要查一查硬件手册,控制器无非就是对应哪个端口或对应个什么东西,所以实际上最终表现为都是 out xx .al
。
所以也就是说,我们对外设的使用实际上非常简单。虽然后面我们会看到很多代码,包括操作键盘,操作显示器,操作磁盘(我们这里不操作网络,没有这一部分内容)。在后面讲操作键盘,操作显示器,操作磁盘的时候,大家会看到有很多很多的代码,但实际上所有的代码最终落实到最后,就是这么一条指令 out xx .al
。
而这条指令呢,可能不只一条,外设控制器的寄存器可能有多个,有命令寄存器,状态寄存器等等,有这些内容,但核心的指令就是类似这条指令。
那么也就是说,无论外面包裹了多少代码,它的真相只有一个,就是使用这条指令。待会儿马上就会讲,为什么外面会包裹那么多代码,而写 out xx .al
这句的根本原因,就是因为这样能让外设使用起来。
所以总结起来就是说,操作系统让外设使用起来无非最终就落实成 out xx .al
这条指令。再加上一些包裹性的代码,让使用外设变得简单,你马上就能看到。
所以,它的核心无非就是用这样一条指令,来实现发出让外设工作的指令。当外设工作完事后,再向 CPU 发出中断,接着 CPU 再进行中断处理。所以也就是说,操作系统让外设工作实际上它的核心原理非常简单,用 out xx .al
这样的指令让外设工作,等外设工作完事后,执行中断处理程序。这就是操作系统让外设工作起来的秘密。
所以大家可以看到,操作系统让外设工作起来的这个任务实际上比较简单、很简单,就是发一些 out 指令,等外设工作完了再中断处理。但是,为什么外设那么多代码?学完这个地方大家就清楚了。
实际上,最终讲的外设驱动就这么三件事情:
- 第一件事,就是让外设执行的、最核心的一些 out 指令;
- 第二件事,外设工作完了以后要进行中断处理;
- 第三件事,为了让使用外设变得简单,要提供一种统一的视图。
实际上,第三个部分就是要形成统一的视图。那为什么要形成统一的视图呢?因为直接向设备控制器的寄存器里写比较麻烦,不同的设备它的控制器不一样,你需要查到控制器的地址,需要查到向控制器写的格式、语义,这些是非常繁琐的。而且不同的公司有不同的硬件,这是非常繁琐的,比如同样是显示器,三星的和其它公司生产的显示器就不一样。
所以也就是说,不同公司设计的硬件的这个控制器可能不太一样,这就造成了很多很多的麻烦。如果直接这么使用,上层用户实际上是受不了的。而我们知道,无论我们写到什么样的显示器上,无论使用什么样的显卡,我们要往显示器上写,都是 printf。
所以最终实际上是要形成统一的接口。当然,这个接口就是我们讲的操作系统中的两大视图中的另一个视图,文件视图。这样做起来方便,隐藏了很多信息。实际上除了方便,它内部还可以做一些高效化的处理,但是用户都不用关心。
我们把故事整理起来。大家可以看到,要想进行外设的驱动,也就是操作系统的第 4 个部分,无非就三件事,最核心的就是 out 发出指令,向设备控制器的寄存器发出指令 out ,最终往往就体现在那么三两句话;第二个,为了让 out 做得统一,让用户使用起来方便,要形成统一的文件接口、文件视图,所以第二个就是形成文件视图;第三个,进行设备中断的中断处理。
所以无非就三件事,形成文件视图,发出 out 指令,形成中断处理。那么把这三件事做好了,一个设备的驱动就明白了。对于所有的操作系统,基本上我们现在用的操作系统,都采用这样一种套路。所以大家如果掌握这种特点,你在开发设备驱动程序,至少在思路上就变得非常清晰,非常清楚。
实际上到这里原理已经讲清楚了,也就是说,外设驱动无非就这么几件事,原理很简单。那么接下来我们就通过实际的例子看一看,它真是这样。大家把实际的例子弄明白了,这一套东西自然而然就出来了。
下面这个实际的例子就是我们需要处理显示器,就是要往显示器上显示 printf。实际上 printf 最后对应的是什么呢?它就是一段操作外设的程序。这段操作外设的程序是统一的,大家都是类似的,printf 也不例外。
一段操纵外设的程序
无论是 printf 还是什么,最终表现成的都是打开一个文件。当然,printf 要打开的就是显示器所对应的文件。
无论什么设备,都是 open,read,write,close。操作系统为用户提供统一的接口。不同的设备对应不同的设备文件(/dev/xxx),根据设备文件找到控制器的地址、内容格式等等。
根据这个图大家不难想象,printf 的故事将会从哪里开始呢?肯定要从 open,read,write,close,这些系统调用接口开始。
概念有了,开始给显示器输出。
write 要进行分支,分支来,分支去,最终落实到的就是 out 到一个显示器上。我相信大家根据我的这段论述,你大概就会觉得好像有点明白了,看起来设备驱动还是很简单。那么接下来跟着我一起去走一遍,你就更加清楚。
fd = 1
,1 这个地方对应了一个当前进程 PCB 的一个数组里面 1 这一项对应的文件,实际上就意味着打开了一个文件,而这个文件的 f_inode,看起来像是这个文件的一个信息,将这个信息给取出来。
fd = 1
的 flip 从哪来?
所有的进程创建,不都是由 0 号进程创建 1 号进程做出 shell,然后再做的吗?所以应该分析 shell。
在系统初始化的时候,我们可以看到,果然打开了一个文件,而这个文件拷贝了两份。这个细节我们不再展开看了,大家可以上网查一查 dup 是什么含义。总之,是打开了一份文件然后又拷贝了两份。C 语言的数组都是从 0 开始,所以数组中 1 对应的也是打开一个文件。
所以大家由此可以看到,write 函数中 1 对应的那个文件,设备文件,就是 dev/tty0。
open 系统调用完成了什么?
open 调用的是 sys_open,首先根据传递进来的文件名字,将这个文件读入进来。后面讲文件系统的时候会略微地说一说,就是根据文件的名字把这个文件读进来。实际上,最终主要是读进文件的 inode,inode 就是这个文件存放在磁盘上的一些信息。对于设备文件的话,那文件里面就有这个设备对应的是哪个设备,什么类型等这些信息。
核心就是建立这样一个链。open 无非就是形成这样一个链。
讲到这里我们可以稍微总结一下。大家可以看到,果然是文件视图,write(1) 。这里 1 是谁产生的?open “dev/tty0” 产生的,核心就是要把 dev/tty0 这个设备文件所对应的设备的信息放在 inode 上,把设备的信息读进来。
准备好了,真正向屏幕输出。
看看 crw_table
往显示器上写是先写到缓冲区里面,这也是设备驱动中非常常见的一种技术。我们不是直接往显示器上写,而是先写到缓冲区里,进行缓冲,而这个缓冲就有点像生产者-消费者的共享缓冲区。
继续 tty_write 这一核心函数
看看 tty->write
mov ax, pos
,这个 pos 就是显卡的那个寄存器。实际上我们学计算机原理时知道,有的这个外设控制器中的东西可以统一和内存编址,这个时候寻址用 mov。而如果是独立编址的话,要用 out。因为显存比较大,所以通常是和内存独立编址的。
这里 mov 和 out 没有本质区别。这里 mov,就是往 I/O 设备的控制器里面的那一段存储里面,去写。但是因为这里它是统一编址,所以它用 mov,用这种寻址方法。如果是独立编址,用 out。我说的这一段,在学明白了计算机组成原理后,我想那么理解起来非常容易。
根据刚才这个过程,大家体会一下是不是从文件接口开始,从 write 开始。write 里面用到 open 打开的那个 fd,就是文件描述符,open 打开的时候用到的是文件设备名、inode 里面存放的信息。然后根据 inode 的信息一点一点往下捋,捋到最后一点一点往下选择,你到底是对应什么设备。到了最后,就是一句 out 指令完成真正的输出,这就是 I/O 工作的基本原理。当然,这些代码形成的就是设备驱动。
由此可以看到,设备驱动无非就是在驱动的时候,要根据你那个设备对应的信息,比如存放在设备里面的设备号等,根据这些信息注册相应的函数。
所以实际上写设备驱动,如果写过设备驱动的人,大家都知道就是写一些函数,然后注册上去。注册不就是放在这些表里面吗?然后将来具体执行的时候,就从这些表里面拿出来,根据你要读写的那个设备文件里面的信息,找一个分支,调用你的函数,最终 out 出去,不就完事了吗?
所以大家想想,写设备驱动,怎么写啊?写出核心 out 指令,然后将响应的函数注册在这些表上,创建一个 dev/xxx 文件,这个文件和你注册的表刚好对上,是不是就 ok 了?
所以大家想想,做设备驱动很 easy ,比起 CPU 的管理,比起内存,这一部分还是简单得多。所以操作系统最核心的 Kernel 还是 CPU 跟内存管理,即进程管理跟内存管理。外围的文件,外围的设备驱动、文件系统还是比较简单的。
mov pos 是什么呢?我们刚才说了,就是往显存上写,这里再验证一下。
ORIG_X 这个宏中有著名的 0x90000,什么时候用过 0x90000 呢?这就是操作系统,讲到今天我们还要用到第一堂课讲到的内容,就是在系统启动的时候,在 setup 的时候会怎么样呢?会取出硬件参数,根据当时的 BIOS 中断取出硬件参数,其中包括光标的位置,就是刚一开始启动完了以后,打出来的光标的位置。将那个光标的位置放在 0x90000 这个地方,然后在初始化的时候,根据 0x90000 这个位置,将显存的位置得到,赋给 pos,接下来这个 pos 会一直操作显存,这个故事就完事。
pos 的修改
每次加 2,pos += 2
。
printf 的整个过程
我们最后再稍微整理一下这个 printf 的整个过程。大家可以看到,无非就是从系统调用 write 开始,使用统一的文件接口,然后一顿地找这些函数,最后对应到了、找到了驱动终端设备,就是驱动显示器对应的那个函数,tty_write,然后将 tty_write 放在缓冲区里面。接着再根据显示器的那个函数 con_write,从缓冲区里面,就是 write_q 这个队列里面,用 mov pos c
这句最核心的话,实际上就是 out pos c
,输出到显存就完事。
由此大家可以看出来,对于终端设备,核心就是这样几句话,并将这几句话包装成统一的文件视图。当然,在这里面用到了一些优化的技术,比如说用到了缓冲技术。当然,用到缓冲技术的时候,又有了生产者-消费者,用到了一些同步的机制,这是自然而然诞生的。
大家可以看到,果然就是如同我们所说的,核心就是 CPU 向外设控制器的寄存器里面或者是一段存储里面,发指令进行读写,从而把它形成统一的文件视图。
关于这个地方,我们安排了一个实验,实验七。实验七我们要做什么呢?还要跟下一讲合在一起才能做这件事。做一件什么事呢?当按下键盘上 F12 的时候,以后再 printf 输出的字符全是星号。
那么,关于按下 F12 怎么启动一段程序呢?那是下次课我们要讲的内容。但是到了现在我们都知道,如果让你做这样一件事,不管按不按 F12 都要让 printf 打出星号,大家会怎么做呢?很简单,只要在 tty_write 的时候,在 write_q 队列中,写的时候不是有写一个 c 吗?将 c 放进去。我们只要在那个地方将 c 改成星号就可以了。就是在 PPT 12 页中的 while 循环里将 c 改成星号( c = *
),是不是就能将 printf 输出的东西全部变成星号?这个时候你再执行 ls 命令,你试一试。
大家一定要做做实验七,来体会一下对设备驱动的控制。往往大家学到后来,将来到了实际岗位的时候,会发现编写设备驱动是一件很重要的技能。大家学完操作系统应该要会干这些事情。
L27 键盘
这一讲我们讲键盘,上一讲我们讲的是显示器。显示器和键盘合在一起属于终端设备,显示器属于终端设备的输出,键盘是输入。这一节我们要讲键盘是怎么驱动的,操作系统是怎么使用键盘的。
这个故事从哪里开始呢?仍然还要从外设工作的基本原理开始,从上次课讲显示器开始,包括今天要讲的内容,还有下次课开始讲的关于磁盘的使用和管理,都离不开这张图,这张让外设工作的基本原理图。
上次我们说了,外设工作的基本原理非常简单,就是 CPU 发一条指令出去,读或写的指令,当外设收到指令,工作完以后向 CPU 发出一个中断。上节课我们讲了,对于终端设备、外设,它们的基本思路都是这样的,要想实现外设的驱动,需要做三件事:
第一件事,核心就是向外设的设备控制器的某些寄存器端口或者是某些存储,要进行发出读、写指令。所以第一个部分就是通过 out 向外设发指令,而这个是计算机使用外设、CPU 使用外设最核心的东西,往往就表现为那么几条指令。所以计算机实现外设驱动往往就那么几条指令,但是要想写出这几条指令来,必须要对硬件非常了解,需要知道硬件的端口、硬件指令的格式,含义等等,往往这些细节非常麻烦。
也就是说,它原理比较简单,但是细节比较麻烦,操作系统为了隐蔽这些细节,就做了一个统一的视图,这就是第二个。操作系统进行外设管理的时候,第二个方面就是统一的文件视图。也就是说,操作系统将所有的外设都做成文件,而根据文件的设备,根据文件所对应的文件名,根据文件所对应的文件描述性的结构,上节课我们讲过,就是 inode,根据文件描述那个结构中的那些信息,决定了调用哪些函数来执行、走哪条路,最终到了一段 out 代码。所以,第二个部分就是把一段 out 代码采用文件的方式,向上封装起来。这就是操作系统对设备管理的第二个部分。
第三个部分就是进行中断处理,通常是在完成设备读写以后,完成后续的一些工作。所以这就是操作系统进行外设驱动的三大部分,把这三个部分弄明白了,任何外设的管理基本上都明白了。
我们再整理一下,第一部分就是通过 out 指令向外设发命令;第二个,通过文件形成统一的文件视图;第三个,进行中断处理。那么就这三个部分,把这三个部分弄明白了,整个外设工作就明白了。
那么对于今天要讲的键盘,实际上正好对应的是这里的中断这一部分。因为键盘是用户发起,按下键以后操作系统就进行中断,然后进行中断处理,最后回到文件系统中。所以大家可以看到,弄明白了外设驱动的这三个部分,那么学习任何的外设都是同样的、类似的,原理都一样。
接下来我们就开始讲键盘的故事,实际上讲完今天的故事,也就弄清楚了输入、输出。上次课讲的显示器是产生输出,今天讲的键盘是产生输入,而这两个设备一旦弄清楚了,实际上一台基本的计算机就可以使用了。磁盘可以不驱动,可以没有磁盘,但一个计算机有 CPU ,有内存,再有最基本的键盘和显示器,这个机器就可以用了。
关于键盘的故事从哪里开始呢?用户按下键以后要出结果,按下键以后要做些什么事情呢?中断,所以很显然,这个故事应该从中断处理开始。
所以有两条线:一条线就是从 CPU 发起的 out 指令;另一条是从硬件出来的,向 CPU 进行中断。这两条线都要回归到文件这一统一的视图上。
这些 out、in,即 CPU 和设备控制器中的寄存器进行交互的指令,是最核心的指令,其它的都是一些封装。通过 inb $0x60 %al
这个最核心的指令,把 60 端口里的内容读到 al 里面,而 60 端口里面是什么呢?这个需要知道硬件的一些知识。所以,要做设备驱动肯定需要知道硬件的一些知识。
查硬件手册去明白端口对应的含义,这个 60 端口对应的含义是什么呢?扫描码。我们要把扫描码赋给 al,每一个键盘的按键都对应一个码。接着根据不同的码,要调用一个 table 来执行相应的工作,很显然,就是根据扫描码来决定要干什么。如果扫描码是按键 A 产生的,那么就产生 A 的 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)向上传就完事。当 ASCII 有了,根据键盘产生的 ASCII 就开始相应的工作。
调用 key_table,来决定对不同的按键所产生的扫描码来做什么,绝大多数都是做 do_self。do_self 是一个汇编的标号,实际上就是一个地址,就是一个程序的地址,这和函数的入口没有任何区别。所以,do_self 实际上就是一个函数。对一般的显示字符,如 a、A、b、c 等,都要执行 do_self,而像一些功能键,如 F12 等,由 func 这个函数来执行。
key_map 里面存放有一堆 ASCII,如果是按下大写键的话,shift_map 里有存放相关的 ASCII。
在得到按键的相应 ASCII 后,将 ASCII 放到缓冲队列中,等着上面的进程去拿,call put_queue
。
在显示器那一讲,我们用的是 write_q,这次用的是 read_q。这两个都是 tty ,都是终端设备,终端设备分成输入、输出设备。对于输出设备,如显示器,用的是 write_q,而输入设备,如键盘,用的是 read_q。
接着得到 read_q 的 head,然后将 al(存放的是 ASCII),输出到相应队列的头部,即缓冲队列的头部。这样的话,就把得到的 ASCII 已经放到队列里面了。
那么还差什么呢?我们通常还要回显。实际上讲到这个地方,我们可以不往下讲了,现在已经弄明白了这个键盘的驱动,就是写个中断处理程序,根据扫描码得到 ASCII,或者根据扫描码进行相应的函数处理。在得到 ASCII 后,放入 read_q 的队列中就完事。
然后,其它的上层应用就跟文件接在一起了。scanf 实际上就是 read,在执行 read 的时候,就从队列中去取就完事。至于从队列中去取的那一段过程,和写 write_q 的队列是完全一样的,printf 对应的那个分支和 scanf 对应的分支完全一样,只是把所有的 write 都变成 read 就完事。所以那一部分我们就不再讲了,和上次的正好接上。
大家可以看到,正好和文件视图又一次地接在一起。这就回应了我们之前说过的:无非就是 out、in 那些核心指令;无非要么就是 CPU 发出指令,要么就是从接收中断开始;无非就是最终和文件接在一起。这就是设备驱动最核心的内容,其它东西都是在外面包裹而已。
我们总结一下键盘处理。键盘处理就是从硬件开始,从中断开始。因为硬件要切入到计算中,就要开始中断。中断处理的核心,就是取出 ASCII 放在 read_q 里面,然后再从 read_q 放到 secondary 队列中,这样的话,经过中间的一些处理,比如转义处理等一些细节我们就不再说了。
因为我们是讲操作系统的课程,不是在完整地讲键盘,所以我们这个地方就不再讲这些细节。如果大家感兴趣的话,再去剖析一下这些代码。从 read_q 中取出,经过一些转义处理再放入 secondary,而 secondary 才是真正 scanf 去取的队列。放在 read_q 的时候,再加一部分回显处理,放在 write_q 里面,回显的时候再调用 con_write 打到屏幕上,就完事儿。这就是对键盘的处理。
在这里,我们把键盘的处理和上次讲的显示器的处理合在一起。大家可以看到,都是文件接口(这个图看得更清楚),如果写的话是 tty_write,如果读的话是 tty_read,然后都是操作 write_q 队列和 read_q 队列。一个是从上面发下来的,最终 con_write,mov pos
,往显存里去写。而流程图的右边呢?无非就是处理键盘中断,根据扫描码得到 ASCII 再进行处理。
大家可以看到,果然是印证了我们一开始所说的那三个部分,形成了一个统一的文件视图,然后通过文件视图最终落实到了,如果要往下写的话,最终落实到了 out xxx
;如果是从输入设备中来的信息的话,那么最终体现为中断处理。这就是操作系统进行设备处理的那三件事,中断处理、out 再加文件视图。
讲到这里,实际上键盘驱动就讲完了。大家想一下,如果按下 F12,再回到我们上次的话题,实验七,大家一定要做做实验七,非常有意思。做完以后对操作系统这一部分的认识,将会上升一个层次。
如果按下 F12 以后,让输出的都是星号,怎么做呢?上次我们已经知道,在 tty_write 的时候,把那个 c 变成星号。这次我们知道,按下 F12 的时候,得调用这个函数、这段程序,怎么调用呢?只需要在刚才相应的那个扫描码处理的时候,在那里改变一下对 F12 的处理。F12 肯定使用默认的 func 去处理,现在只需要把 func 改成 myfunc 或其它名字,然后在 myfunc 里面可以写相应的代码,来实现这一小功能。
L28 生磁盘的使用
从这讲开始,我们讲设备驱动、设备管理的最后一个部分的管理,磁盘。而磁盘的管理实际上也是我们这个课程,操作系统课程的最后一块内容。
当然,磁盘的管理、磁盘的驱动,它仍然是一种设备驱动。它的原理不变,仍然是文件视图、out、加上磁盘的中断处理,仍然是这三部分内容,核心不变。但是磁盘的管理,要比前面讲的显示器和键盘的管理复杂得多。也就是说,它经过包装的层次,也就是抽象层次,比前面的键盘和显示器,要复杂得多。
那么接下来我们就开始学习磁盘它是怎么处理?在操作系统中磁盘是怎么驱动的、怎么处理的?将磁盘驱动、处理明白了,那么整个操作系统,我们这个课程,就全部结束了。
那么将磁盘处理明白了,对设备的驱动,这种比较复杂的,确切地说,这种很复杂的设备驱动,我们也就明白了。那么再遇到了别的,比如说网卡(这种设备驱动也很复杂)、打印机,遇到别的这种复杂的设备驱动,我们也不害怕。
所以我们接下来就来开始学习,一步一步地,总共大概有 4 讲,一步一步来学习、一层一层来学习磁盘是怎么被驱动起来的。
那么首先,我们从今天要讲的第一部分,从生磁盘开始,也就是从磁盘的使用开始。这和前面的思路是一样的,我们首先得让磁盘转起来,然后再让磁盘更好地转起来。怎么一层层抽象,最后怎么落实到我们的文件视图中,怎么一层层抽象让磁盘能够更好地用起来。
所以今天的话题是让磁盘用起来,我们今天要做两层抽象,后面还要做两层抽象,这样的话整个磁盘驱动就结束了。
我们今天讲的是这种让磁盘直接驱动起来,直接用起来,叫做生磁盘。后面我们会看到怎么引出文件来,叫做熟磁盘。今天讲的是生磁盘,也就是说磁盘是怎么工作的?怎么让磁盘工作起来?这是今天的核心话题。
故事仍然是从硬件开始。要驱动磁盘的话,首先往磁盘控制器写一些指令,读写磁盘,磁盘工作完以后向 CPU 发出中断,再做些后续处理。所以整个故事无非就是 out 类指令,加上中断处理。而最后它是一步步怎么抽象成文件视图的,这个是我们后面的内容,今天我们讲的内容是首先让磁盘工作起来。
使用磁盘从认识磁盘开始。我们首先必须知道磁盘长什么样。磁盘就是一根柱子连着一堆盘片、盘面,然后通过旋转,这里面每个盘面上都有一堆磁道,磁道里面有磁,盘面上面当然还有磁头,根据旋转的时候,磁头里面有电嘛,磁和电相互转化,最后磁信号就变成电信号,从而读出了磁盘。
这是对磁盘的一个基本认识。读写磁盘的基本单位是扇区,这是一个常识。也就是说,磁盘每次读写的时候,一个扇区一个扇区地读进来,一个扇区是 512 字节。
对磁盘真正读写过程,我们再用一个示意图来看看这个读写过程。比如说,我们要读写磁盘的一个字节,它的工作是首先将这个磁头移动在指定的磁道上,然后这个磁道开始旋转,转到相应的扇区以后,再一转,磁生电,磁信号就变为电信号,然后就读回去。
在读回去时,读到哪里呢?读到内存的缓冲区。那么要写到磁盘呢?我们将这个内存的缓冲区修改一个字节,然后再通过磁道旋转,定位到特定扇区,这个时候是电生磁,电信号变成磁信号,再把这一字节写到磁盘里面。
所以无论是读还是写,它的核心是移动磁头,移动到磁道上,然后转动磁道,移动到扇区上,一边旋转进行磁生电、电生磁,和内存缓冲区进行数据的交互,是读了,还是写了,都一样。
所以总共就是三步。第一步移动磁头;第二步旋转磁盘;移动磁头到相应的磁道上,旋转磁盘到相应的扇区上,然后进行读写,和内存缓冲区进行读写,就这三步。所以这是磁盘的基本过程,寻道 → 旋转 → 数据传输。
明白了磁盘的基本过程后,我们就开始使用磁盘。怎么使用磁盘呢?无非就是刚才那个过程,移动磁头、旋转磁盘、进行读写。所以你只要告诉计算机,只要告诉磁盘控制器这几个参数,怎么移动磁头、怎么旋转,只要告诉这几个东西,当然肯定是几个数字,知道这几个数字以后,磁盘控制器就会驱动它的马达,移动磁臂、旋转磁盘来进行工作。
所以核心就是需要告诉磁盘控制器这几个参数,是哪一个柱面、哪一个磁头、哪一个扇区、要读写到哪个缓存里面。
什么是柱面呢?实际上就是刚才移动的磁道。因为我们第一部分需要将磁头移动到相应的磁道上,由于不是一张盘,一张盘就是磁道,而这个是一堆盘,一堆盘就会形成一个柱面。所以首先要告诉柱面是什么,知道柱面以后磁臂就会移动到相应的柱面(C)上。当移动到某一柱面上,那到底是在第一个盘面上读写,还是第二个,还是第三个呢?这就需要根据磁头来决定到底是哪一个,所以第二个需要告诉是哪一个磁头(H)。一旦知道磁头以后,接下来到底是读写这个磁道上的哪个扇区呢?因为一个盘面上的磁道是一个圆,在这个圆环上到底是读哪一块呢?所以需要告诉扇区,接着再旋转到相应的扇区(S)上。
因此传递了这 3 个值后,磁盘就知道怎么移动磁臂,让哪个磁头上电,怎么旋转磁盘。在告诉缓冲区以后,利用总线盗用技术 DMA ,盗用总线,偷摸着用总线。这样的话,就可以将磁盘上这个扇区上的数据放到内存上,或者将内存上的数据写到这个扇区上,这个过程就完事。
所以最直接地使用磁盘,无非就是让这几个值,将这几个值写在磁盘控制器上。那么大家说用什么写呢?out 指令。所以大家可以看到,最后真正写磁盘的,无非就是一堆 out 指令。将 cyl 、head 、sect 放在端口(port)上,当然,这些 cyl、head、sect 参数的值要拼接成它要的格式,所以需要一些左移、右移等操作。当然,这里还要说明你读写几个扇区,即你要连续读写几个扇区?这个也需要告诉磁盘控制器。
学到这里,磁盘已经可以开始使用。但是,这样使用是最直接的,当然也是最麻烦的,需要知道太多的东西。操作系统能不能封装一下,对使用者说:“你不需要知道这么多东西,你只要告诉我一个事情,比如说只需要告诉我一个数字,这个数字表示第多少个磁盘块,剩下的怎么转换成磁头号?怎么转换成柱面?怎么转换成扇区?这都是我的事,我帮你做就可以了。”
这样使用起来不就方便了吗?所以要从最直接使用的方法进行抽象,这一层层抽象,最后抽象到全部抽象就完事,实际上一个文件系统就立起来了。而最后讲完文件系统立起来,整个磁盘的驱动和管理就全部结束。
所以我们现在要讲清楚第一层抽象,通过盘块号来读写磁盘。也就是说,我们程序只需要发一个盘块号,当然,发盘块号也比较麻烦,所以再往前我们发的就不是盘块号,我们要访问的是文件的读写位置,当然,那个抽象在后面讲,我们一层一层来。
现在我们的程序可以发一个盘块号,总比发 cyl、head、sec 这些要容易得多。用户根据盘块号(block)来读写磁盘,而磁盘驱动负责将盘块号(block)转化成 cyl、head、sec。这是这一层抽象的核心,根据盘块号来计算 C、H、S。
block 是一维编址,而 cyl、head、sec 是三维编址,所以,这里面就存在从一维地址到三维地址的编址过程。这实际上,操作系统就需要在这里发挥它的作用。我们不仅仅希望用户使用磁盘更加简单,而且也希望使用磁盘更加高效。整个操作系统,都是希望上层用户使用计算机变得更加简单、高效,这就是操作系统为什么给硬件包裹这样一层软件的核心原因。
从 block 到 cyl、head、sec,如何编址?block 相邻的盘块可以快速读出。
比如一个磁道旋转一圈后,从 0 号扇区到 6 号扇区,再到 0 号扇区,那 7 号扇区在哪里呢?不要放在下一个柱面,放下一个柱面的话,就需要移动磁臂,移动磁臂的话就需要寻道了,这时速度就慢了。在磁盘结构中,虽然有多个磁头,但是绑在一个磁臂上,对应不同的盘片。所以当磁盘旋转一圈后,这个磁头移动到这个位置了,下一个磁头也同时移动到这个位置,移到这个位置后,正好就可以往下读了。所以下一个,7 号扇区,应该放在 0 号扇区的下面。
在磁盘访问的时间中,寻道时间和旋转时间占据大头,传输时间相对来说可忽略不计。
现在磁盘的空间大小越做越大,所以完全可以用空间去换一点时间。操作系统这里做了很漂亮的一件事情,就是用你的空间,通过故意把读写的单位变大,现在读写的单位就不再是扇区了,上层用户发出的是盘块,用盘块来读写。一个盘块完全可以是连续的几个扇区,让操作系统把连续的几个扇区认定成是一个盘块。
每次读写,上层应用程序发出来的单位就是一个盘块,即连续的几个扇区,读写的单位就被操作系统从一个扇区变成一个盘块。虽然空间是浪费了,但是磁盘的读写速度快了。操作系统用磁盘的多余空间、空间的浪费,来换取时间效率的提升。对用户来说,觉得这个磁盘读得真快,这个操作系统真不错。
操作系统在这里做了一件非常聪明的事,从扇区变成盘块,用空间利用率的下降,来换取读写速度的上升。所以应用程序、上层程序,读写磁盘的基本单位不再是扇区了,而是由操作系统变成盘块,一个盘块就是连续的几个扇区。
这样的话大家可以看到,这就从扇区变成盘块。讲到这个地方,第一层抽象就全明白了。上层应用程序完全可以发出一个盘块号,操作系统会进行解释这个盘块是由多少个连续扇区组成,得到连续的扇区了,就可以根据公式算出 cyl、head、sec。
这些参数一旦有了,再通过 out 发给磁盘控制器,现在应用程序就可以根据盘块号来访问磁盘了。这个时候就隐藏了很多细节,而且通过刚才的分析,可以看到读写效率得到了一定的提升。操作系统在这里做得非常聪明、非常漂亮,这层抽象既简单又高效。
所以现在使用磁盘,就可以通过盘块号来使用。在处理磁盘请求的时候,我们要根据这个 block 号来算出扇区号,然后再根据扇区号,根据相关的计算,得出 cyl、head、sec。这样就清楚了、明白了,磁盘使用的第一层抽象就有了。根据盘块号就可以访问磁盘,既提高了效率,又方便了使用,开始一点一点地让用户使用磁盘变得简单。这里就引出了盘块号。
讲完了这部分,我们接下来要讲第二个抽象。在讲第二个抽象之前,老师在这里问大家一个问题,根据这些代码大家可以看一看,Linux 0.11 的盘块有多大?2 个 sec 。
多个进程通过队列使用磁盘(第二层抽象)
多个进程将盘块号放在请求队列上,然后磁盘驱动时从请求队列中取出,进行 cyl、head、sec 的换算,进行 out 指令。磁盘驱动什么时候从请求队列中取出盘块号呢?肯定是干完了上一件事就取出来。而干完了上一件事,正好就是磁盘中断的时候。每做完一件事情,磁盘就会中断一次。在磁盘中断时,从请求队列中取出,所以就形成了这样一种结构。
当然,这个时候使用磁盘仍然是生磁盘。因为它是根据盘块号来使用磁盘,而不是根据文件来使用磁盘,所以还是生磁盘。一旦根据文件来使用磁盘,那就变成熟磁盘。怎么把磁盘煮熟,那是我们下一讲,后面的内容,现在讲使用生磁盘的方式。
即使使用生磁盘,也存在多进程使用的问题。多个进程都要通过 block 来使用,那就变成这样一种结构,每个进程的 block 都放在请求队列中,然后在磁盘中断的时候,由磁盘驱动从队列中取出 block,再进行 cyl、head、sec 的换算,然后 out 输出就完事。
所以第二层抽象的核心,就是多个磁盘访问请求应该怎么放入请求队列,应该怎么做?这里实际上就是磁盘调度问题。这个调度的目标是什么呢?主要考察什么呢?怎么合理调度磁盘请求,让磁盘工作的速度快,让磁盘读写尽快完成?而磁盘的读写时间中,寻道时间是主要矛盾。
关于调度算法,我们仍然从一个最基本的、最直观的算法开始,即谁先来谁先服务算法(FCFS)。然后再分析这个算法,从中发现什么问题,再一步一步地改进。
接下来我们就来看看 FCFS 磁盘调度算法。
大家在做算法的时候,尤其是在做操作系统的算法的时候,操作系统的算法都不复杂,这些调度算法都不复杂,所以首先你可以从最基本的、最直观的一种调度算法开始,然后再分析其中的毛病。当然,在分析毛病的时候,就要分析我这个算法的目标是什么?让系统中的什么指标变好?在分析的时候看它有什么样的缺点,然后再提出一些改进方案,自然而然下一个算法就出来了。
前面我们看到的,比如说 CPU 调度的轮转调度、短作业优先,都是这样自然而然出来的;页面置换中的 LRU、Clock 算法也都是这样一点一点推出来的。所以大家不要去记这些算法,记住也没有多大用处。自己能够善于改进,能够写出,善于改进、善于分析这些算法,会更有用。
短寻道优先磁盘调度算法,SSTF(Shortest-seek-time First)
存在饥饿问题,磁道两端的请求可能得不到处理,磁头总是频繁在磁道的中间附近。
SCAN 磁盘调度,扫描调度,实际上这就是我们现实生活中常见的电梯算法。在前面我们讲 CPU 调度的时候,最核心的算法是提出一个折中的、基于优先级和轮转合在一起的调度算法;在讲页面置换的时候,最终提出的是 Clock 算法;而在磁盘调度算法上,最终提出的是 SCAN 磁盘调度算法,就是一个电梯算法。
这种调度算法既公平,磁头移动也比较少。所以这种算法是一种很不错的算法。SCAN 算法还有个小毛病就是位于磁道中间的请求还占便宜。从中间出发,移动到一端后再移动到另一端时,还是先从中间移动过去。
如果磁头从一端移动到另一端时,不读取位于磁道中间请求,马上滑动到另一端,那么中间的请求就不再占便宜了,并且磁臂的这种复位,移动的速度是非常快的。磁臂的运动轨迹就是从起始位置滑到磁道的一端,然后复位,接着再滑过去,然后再重复这样的轨迹。这种算法就是著名的 C-SCAN 磁盘调度算法(电梯算法)。
讲到这个地方,多进程请求磁盘的这个故事基本上就完事了。它具体处理的方法是什么呢?多进程将自己的请求放在队列中,磁盘通过电梯算法去调度。多个进程访问磁盘的时候,产生的请求放在请求队列上,然后在磁盘中断的时候,从请求队列中取出这个内容来、取出盘块号来,换算出 cyl、head、sec,再通过 out 发给磁盘控制器,这就是使用生磁盘的完整故事。将这个完整的故事,再接上后面的文件以及目录、文件系统,那么整个使用磁盘的完整故事就全有了。
到目前为止,我们对生磁盘的使用,不是根据文件来使用磁盘,而是用盘块号来使用磁盘。
一个进程得到盘块号,接着算出扇区号,那怎么得到盘块号呢?在这里必须要自己写程序来得到盘块号。比如说我们写程序,这个程序说了,我这个就用第 10、第 15、第 17 的这些盘块号,但这是不符合实际情况的。我们知道,我们写的都是文件,我们直接写文件就完事儿。
所以,这个部分实际上埋下个伏笔,要和下次课接在一起,和后面的内容接在一起。怎么得到盘块号呢?就是根据文件得到盘块号。如果能根据文件得到盘块号,那么这里的进程得到盘块号的问题就解决了,同时也是磁盘 “煮熟” 的过程。
make req:作出磁盘请求。这个磁盘请求中,实际上包括要使用哪一个缓存,内存缓存。因为最后要读到内存里,所以要先读到内存缓冲区里。要得到这个磁盘请求的时候,有一段内存缓冲区申请的代码和内存缓冲区管理的代码,这个代码在我们的课程中没有涉及,因为我觉得它离这个抽象的过程离得有点远,而且这里面的细节又特别多,感兴趣的同学可以再去看看这部分内容。
在磁盘读写的时候,这个缓冲区又是一次将磁盘读写提速的过程,而且它的提速实际上是非常大的。所以,这一部分内容对于真实磁盘的读写、真实磁盘的驱动,实际上是非常重要的。
add_request:在 make req 得到内存缓冲区以后,用电梯算法把这个磁盘请求放到请求队列中。
sleep_on:放到请求队列中以后,这个进程就可以 sleep_on,可以进行睡眠。因为放入请求后,接下来的工作是由硬件做的。这个时候通过 sleep_on,来实现典型的生产者-消费者、典型的进程之间的合作、多个执行序列的同步。
进程是个执行序列,中断也是个执行序列,所以进程到这里就停止了,放到请求队列就完事,开始睡眠。接下来就是磁盘中断。磁盘中断的时候,就会处理这个请求。怎么处理这个请求呢?就会进行中断处理,调用 do_hd_request 来算出 cyl、head、sec,并且根据 out 来发出这些指令。
发出指令以后,通过总线盗用开始一段工作。到了工作完事了以后,又会进行中断处理。这个时候中断处理就会将这个请求变成 end_request(1),结束这个请求。当然,结束这个请求里面就有唤醒进程的内容。因为先前放入磁盘请求的那个进程,在等待这个请求,而这个请求现在磁盘处理完了,那么就要将这个等待的进程唤醒。在唤醒以后,唤醒的进程就会发现,在内存缓冲区里面已经有我要读写的内容了,进程就可以继续再往下执行了。
到这就将生磁盘的使用,完整地讲出来了。我们讲清楚了从磁盘的直接使用,到一步一步抽象,最后变成多进程,而且是高效地、方便地使用磁盘这样的一个过程。其中,还涉及到了多个执行序列、进程之间的合作,用临界区去保护进程之间的同步。另外还有中断,进程的 sleep_on、wake_up。
大家可以看到,这就是操作系统。从一个最基本的东西开始,一层层抽象,高效化,引出一些概念,折中、综合,最后形成一个完整的系统。实际上这里面还有一个伏笔,如何得到盘块号呢?加上后面的文件,把那个部分全部合在一起,把后面的故事再加在这个里面,就可以看出最终操作系统是如何真正读写磁盘的完整故事。那个故事是真正的一个磁盘管理、磁盘驱动,访问磁盘的过程,多进程带动磁盘的一个样子、一个过程。
L29 从生磁盘到文件
在这讲中,我们将讲如何从生磁盘到文件,也就是说从生磁盘到文件的这层抽象。上节课我们讲了一些抽象,讲了生磁盘是如何使用的,操作系统是如何驱动的,也就是说,如何从一个盘块号,最后能真正地读写到磁盘上,而且多个进程都发出盘块号。
上节课我们也留了个伏笔,盘块号是从哪里来的呢?怎么会得到盘块号呢?我们使用磁盘真的是用盘块号吗?这不符合我们的直观想法。我们使用磁盘往往是通过文件,用文件来使用磁盘更加自然、更加符合用户的直观认识,也就是说,让用户使用起来更加地方便。
所以,这节课我们就要开始讲,如何从生磁盘最后抽象出文件。那么反过来就是说,如何从文件得到盘块号?实际上,这里的核心主题就是如何从文件得到盘块号。因为一旦从文件得到盘块号,再和上节课的故事接在一起,形成一个整体,那么就可以把这个文件放在磁盘上,读写在磁盘上。所以,这一讲的核心、关键就在于从文件怎么得到盘块号?那么,反过来就是从盘块号怎么抽象出文件的这一过程。
用文件的方式使用磁盘,人们也起了个名字,上节课讲的叫生磁盘(raw disk),这一讲叫熟磁盘(cooked disk)。
为什么引入文件呢?因为这是对磁盘使用的第三层抽象。为什么呢?因为引入更高一层的抽象以后,使用起来就更方便了。因为如果让普通用户使用生磁盘,他还要知道盘块号,盘块号是什么概念呢?为什么会有盘块号呢?这些概念让一个普通用户来理解操作系统没这个必要,所以干脆就把它隐藏起来。
操作系统引入了一个更高一层的概念,文件。有了文件以后,用户再去读写磁盘上的信息时,就变得更加直观、自然。当然,这个故事要从哪里开始呢?要从文件到底是个什么东西?用户眼里的文件到底是个什么样子?从这个故事开始。
文件本身的核心是什么,是一个字符流。人们不关心这个文件放哪里,怎么来的,在我们眼里抽象成一个字符流,就是一堆字符。当然,这堆字符如果按照行和列的方式组织,那就是放在一张纸上。所以在用户眼里,文件的本质就是字符流(字符序列),一堆字符,形成一个流。
用户眼里的文件是一个抽象的概念,在计算机里只能将这个文件存在磁盘上,因为文件最终要落实到磁盘上。那么,磁盘上的文件是什么样呢?磁盘是由一堆盘块组成,所以磁盘上的文件是由这堆盘块接在一起。也就是说,用户的眼里是一堆字符流,而磁盘上的信息是一块块组织的。所以,无非就是要建立一个从字符流到盘块的一个映射。
之前我们说过,这一讲的核心就是从文件得到盘块号。因为一旦得到盘块号,再和前面的故事接在一起,那么就可以把信息写到磁盘上。所以,这一讲的核心就是如何从字符流,如何根据字符流算出到底是属于哪一个盘块,就这样的一个过程。
在经过从字符流到盘块的映射以后,我们不管它是多少个盘块、放在哪里,在我们的眼里磁盘上的信息最后就变成了一个序列,不断向后读的一个字符流。一个字符流不就是一个文件吗?所以核心就在于建立这个字符流和盘块集合的这种映射关系,有了这种映射关系以后,根据字符流就能算出盘块。
映射的作用
这张图体现了我们对文件的使用,和操作系统将我们对文件的这种使用,具体是如何通过这个映射,而实施出来的。也就是说,你对文件的这种使用,通过映射具体到某个盘块,在那里一折腾,折腾出来的就是你想要的样子。好了,这就是这个映射的作用。
操作系统负责维护这个映射。在用户眼里,操作系统提供这层映射,确切地说是操作系统封装了这个映射,也就是操作系统将这个映射隐藏起来。所以在用户看来,对这个字符流可以随意地折腾,删除、拷贝等,而对这个字符流的折腾,最终要落实到磁盘上。
操作系统建立了这个映射关系以后,操作系统负责维护这个映射关系。上层用户只要发起这个请求,比如将 200-212 字符删去,接着操作系统就会负责解释你这个请求,怎么解释呢?就要去查找 200-212 字符到底对应在哪个盘块上,然后再发出磁盘读写请求,放到电梯队列上来实现这个请求。大家可以看到,这个故事就比较完整了,也比较清晰了。
用连续结构来实现文件。当然,我们这里也会讲用另外几种结构,实际上也不仅仅是这几种结构,总之无论是哪种结构,这种结构代表的就是一种映射关系。一旦确定好了这种结构,我们就能够唯一地计算出 200-212 字符所对应的盘块,这样的话操作系统才知道怎么去把你的这个请求具体落实。
使用连续结构来实现文件时,应该在 test.c 的 FCB(File Control Block,一种数据结构)里面存放它所在磁盘的盘块上的起始块。看到这个 FCB 大家是不是联想起 PCB,每个进程需要一个数据结构来表示,那么每个文件不也一样吗?而这个文件要表示的信息只要记录它的起始块就可以。因为一旦记录起始块以后,剩下的你无论给个字符流中的什么值,用这个值除以盘块的大小(取整),再加上起始块号的值,就可以算出给出的这个字符流中的值,所对应的盘块号。那么,能算出来,这个映射关系不就建立了吗?所以,在这个映射表中只需要存放起始块的数字就可以了。
这样的话我们就清楚了文件是怎么存放的。存放完了以后,操作系统需要形成这样一个数据结构(FCB),用来作为映射的表。有了这个映射表了以后,用户再随便输入一个文件字符流中的数字,也就是第多少个字符,那么就可以根据这个表,查这个表,经过一定的换算以后,就能得到所对应的盘块号。
大家可以看到,有了这样的映射,这个时候我们读写文件不就变成了读写字符流中的那些字符了吗?操作系统负责将我们读写的字符流中的第多少个字符,变换成对应的盘块上的操作。而在得到盘块号后,再和我们上次课讲的那个使用生磁盘的故事合在一起,那么,这个故事就全部完整了。
讲到这个地方,实际上已经讲完了这次课的内容。那就是说,从文件是如何得到盘块号的?总结起来就这几样东西:要根据文件形成这样一个表,而这个表是怎么形成的呢?是这个文件按照连续结构存放在磁盘上形成的。有了这个表以后,对文件读写的字符流中的第多少个字符,就可以通过查这个表和一定的换算关系,最后就能得到这个盘块号。既然已经得到盘块号了,这节课的故事就完事了,这也就建立了从文件字符流到盘块的这个映射。
操作系统一旦提供了这个映射以后,用户在文件上的使用,完全就是文件名再加上所要读写的字符流中的位置。这个 FCB 是根据文件名找到的,这个时候用户对磁盘的使用,就变得简单多了,也符合人的直观,很自然。那也就是说,一个文件就是一个字符流,用户处理这段、处理那段等。
这个课程到这,虽然主体就完事了,但在这里还没有真的讲完,还要再讲点用其它结构来实现文件。刚才讲的是使用连续结构来实现,每一种结构实现的方式都有各自的特点。使用连续结构来实现有什么优缺点呢?比较适合静态读写,类似于数组。但如果动态地增加,比如在文件中添加一些东西,由于要在磁盘中找一段连续的盘块去存放,因此可能会引起其它盘块的挪动,这样的话就非常耗费时间。
所以,采用连续结构来实现文件,不太适合于动态地变化,但它适合于顺序地读取。比如说像有些东西,比如有些词典,这些词典文件几乎是不再变的,那么可能用这种连续结构来实现比较好,而且适合于查找。当然,这就意味着不同的文件在存储的时候,选择不同的映射方式,是有不同特点的。比如词典文件可能用连续结构比较好,而像一些不断动态变化的文件,比如说你写的 word 文档,就不太适合使用连续结构,因为它总是在不断地增、删、改。
用链式结构来实现文件
数据结构中,链表可以在内存上使用,也可以在磁盘上使用。
采用链式结构来实现文件,读写文件的速度相对于连续结构来说,会变慢。因为要从起始位置开始,往后逐个查找盘块。但是,这种结构的动态变化比较快。所以采用连续结构的特点是,读取起来比较快,而动态变化比较慢;采用链式结构是读取起来比较慢,而动态变化比较快。
文件实现的第三种结构,索引结构
操作系统应对不同的方面、应对不同的系统,可以选择不同的结构来实现文件,甚至可以组合,甚至大家可以自己发明创造,这都是可以的。
操作系统里面没有正确之分,只有适合不适合,这一点大家要注意。所以操作系统在不断地改变,不断地变化再产生出各种各样的操作系统。我们大家应该具备这样的能力,去做出新的东西,比如说做出新的文件实现的结构。
索引结构既适合于文件大小的变化,并且读取起来速度也不慢。所以索引结构在现实生活中用得非常常见,所有的 Unix 系统、Linux 系统用的文件系统,基本上全是基于索引结构改造的,以它作为基础。
实际系统是多级索引
最后再做一道题,这一讲就完事儿。在做这道题的时候,我们稍微整理一下这节课讲的内容。
这节课就讲了一层映射,我们对磁盘的使用不再使用生磁盘,不再使用盘块号,而是使用字符流。那么操作系统负责要干什么呢?操作系统设计了一个结构,设计了一个映射表,根据映射表可以找到文件字符流中某字符所对应的盘块号。
也就是说,现在可以算出盘块号,用户不用关心盘块号的概念,操作系统又一次隐藏了细节,让我们使用起来更加方便,这就是它的特点。所以这节讲的就是,如何从字符流中的位置得到盘块号。那么在这过程中要做什么呢?要使用顺序,或链式,或索引,或其它结构来实现文件。
通过某种结构来实现文件,操作系统负责维护一些信息,主要是维护 FCB 信息。操作系统根据这样的信息,可以将文件字符流中任一位置的字符,换算出在磁盘上所对应的盘块号。一旦算出盘块号了,再和使用生磁盘的那个故事合在一起,放在电梯队列中。在磁盘中断的时候,从电梯队列中取出来,算出 cyl、head、sec,然后再通过 out 发到具体驱动马达的磁盘控制器上,整个读写就完事了。
那么来看下这个题,像词霸这样的词典设计文件存储,采用哪种结构最好?实际上老师说过,采用顺序(连续)结构比较好,所以选 A 。
L30 文件使用磁盘的实现
这节课讲用文件使用磁盘,是具体怎么用代码实现的。上节课我们讲了如何从生磁盘变到文件,核心就是从字符流的位置算出盘块号。那么,怎么算出来的呢?需要用顺序结构,或者链式结构,或者索引结构等其中一种结构,提供相应的映射表。这节课我们就要看看实际代码,那些最核心的实际代码展开是什么样,这一节比较简单。
那么大家来看一下,我们正式通过文件使用磁盘。通过文件来使用磁盘的话,我们现在用 write 来写磁盘。在 sys_write 函数传入的 3 个参数中,我们可以看出来,没有涉及到盘块号。当然,在这个函数处理的内部肯定会换算出盘块号,因为真正读写磁盘时,是不关心字符流的,关心的就是哪个扇区,要根据盘块号算出扇区来。所以大家可以看到,果然是这个样子,也就是实际中对文件的使用、对磁盘的读写,就是处理字符流中的一段。
那么具体是怎么做的呢?我们就来看一下。当然,write 到了内核中就变为 sys_write ,因为这是系统调用。sys_write 首先要获得 inode,获得的这个 inode 就是 FCB。很显然接下来是要利用 FCB,根据你要写的字符流中的位置,算出所对应的盘块。一旦算出这个盘块以后,接下来去磁盘上写就完事。所以我们再跟踪下这个代码,接下来是 file_write 。
file_write 的整个工作过程
要将 200-212 字符删去,这个工作的过程应该是先取出 200,和 count 形成 200-212,通过 inode 得到一个信息,根据前面我们讲的可以想到,得到的是索引块。根据索引块,然后再根据字符流中的位置 200,找到所对应的盘块号。一旦找到了盘块号了,再启动后面的过程,将磁盘请求放入电梯队列等。
这一系列过程是容易想到的,实际上这也是老师想传达的信息。只有在头脑中把这个故事捋清楚了,你自己能想到这样的过程,你也可以自己写出来。这样你看这个代码才能不懵、才能看懂、才能改造、才能自己实现。否则,这个过程不清晰,要是没有自己的一个构想就去写程序、读程序,那几乎是不可能的。操作系统是人类创造出来的最复杂的一个事物,它里面糅合的东西非常多,思路不清晰是弄不懂的。
所以我们来看看是不是这样呢?首先要根据 file,file 里面要找到 200-212。
一旦这个故事,这样的一个思路非常清晰了以后,一个文件系统看起来非常容易,你完全可以自己做出文件系统来。
接下来我们就来看下它是怎么得到 200-212 的。
在 file_write 的这几行代码中,第一件事情就已经弄好了,得到了字符流中的读写位置。那么接下来第二步要干什么呢?要根据读写位置找到盘块号。别忘了,这一部分是关于文件的核心,就是根据读写位置找到盘块号,inode 在其中发挥重要的作用。
先根据 file 得到读写字符中的哪一项,然后算出对应的盘块,放入到电梯队列中,之后这个读写位置 pos 要不断地修改,使之(这个读写位置)形成一个字符流。
接下来就是怎么算出盘块号,怎么算呢?这是文件抽象的核心。
create_block 调用了 _bmap,_bmap 怎么算呢?你读一下就明白了。
Linux 0.11 虽然小,是个小 Linux,但也是个实际的系统,也是有多级索引的。前 7 个,0-6 是直接块,所以 block < 7
的话,因为是要写嘛,new_block,新申请一个空闲块,然后将这个空闲块塞在 i_zone[block] 中,i_zone 表示在 FCB、inode 中的索引项。如果小于7 的话,直接对应的就是数据块,这个语句就是新获得一个数据块,然后塞进去,因为要写嘛。
如果不是小于 7 的话,block -= 7
,这时就到了一重间接,在一重间接里面再判断下到底是一重间接还是二重间接。if(block < 512)
,为什么小于 512 呢?一个盘块号大小是 2 个字节。索引里面放的是什么呢?就是索引块,索引块里面放的是每一个盘块号。一个盘块号用 2 个字节表示,2 个字节能表示多少个盘块呢?你就可以算出来这个系统,Linux 0.11,总共能支持的磁盘大小是多少,这里就可以算出来,怎么算呢?2 个字节就是 16 位,最多能表示 65536 个盘块,65536 再乘以每个盘块 1k 的大小,所以是 65536k,即 64 M。
1024 / 2 = 512
,索引块里有多少个数据块呢?(个人注释:这里的数据块应该是表示盘块号)512 个数据块。如果是 512 个数据块的话,还需要先把索引块读进来,i_zone[7],而 i_zone[7] 不就对应的是一重索引吗?先把索引块读进来,索引块读进来以后,再添加相应的盘块,new_block。
大家看是不是这样的过程,那么其它的老师就不再讲了。再展开细节的话,细节说来说去无非就是查这个索引块。如果是直接索引的话,直接从索引块中读出 i_zone[],对应的就是那个盘块;如果不是直接索引的话,先把索引块读进来,然后在这里面再去找,就完事儿。
是不是就是一个映射表啊,是不是现在就可以根据 pos,计算出逻辑块 block(根据读写的位置,比如字符流的位置,除以块的大小,就算出来这个字符流的位置在哪一段上,然后根据这个段的位置再查映射表,就可以找到对应的盘块号。一旦有了对应的盘块号,再用 bread 去发出磁盘读写的请求)。讲到这个地方,关于从文件到盘块这个故事就全讲完了。
但是,这个 inode 除了作为映射表来从文件对应到盘块号以外,inode 实际上它也是文件抽象。因为我们前面讲过文件视图,不光一个普通的文件、有数据的文件叫文件,一个设备文件也是文件。如果是设备文件的话,它不用 inode 去完成那个映射关系;如果是普通文件,就是存放内容,比如一个电影文件,那就要根据 inode 里面存放的映射表,来找到文件中的读写位置、字符流位置到盘块号的一个映射。
而这个 inode 如果是特殊文件,比如说是设备文件的话,那么 inode 里面要存放一些信息,比如说主设备号、次设备号。所以,这个 inode 同时承担了这个角色。所以我们这里再说一句题外话,为了提供统一的文件视图,这个 inode 已经跟前面用来从读写位置算出盘块号不一样了。
那既然同样是 inode,提供统一的视图,这个 inode 就可以存放这些信息。所以在 inode 里,不光有 i_zone[] 还有 i_mode,用来表示是哪种类型的文件。还记不记得字符文件可以通过 i_mode 来做,如果是字符文件的话,那么本来这个 i_zone[] 存放的是映射关系,但是现在不需要了。那么,用这个我们可以存放主设备号、次设备号,然后再进行处理,这就完事了。
所以通过 inode 可以形成一个文件视图,到这里就可以出现伟大的文件视图。如果是读写普通的磁盘文件,即磁盘上的数据文件,那么根据 fd,根据打开文件列表来进行。至于这个文件是什么时候打开的,怎么用的?这是关于文件抽象,整个文件系统中的最后一个部分,是我们下一讲要讲的内容。
什么叫打开文件?实际上打开文件是从文件名到 inode 的映射。实际上这给我们埋下一个伏笔,大家可以想象得到,我们最终使用磁盘是怎么使用的?fd、open()、read() 。今天我们实际上讲清楚了 read 是什么,就是有了 fd ,并且给出了代码,有了 fd 怎么找到读写的位置,字符流中的位置,然后再根据 fd 所对应的 inode 找到盘块号,来读写盘块号。
那么在这里面,fd 的核心、关键在于这个 inode ,那么怎么得到 fd 的呢?根据文件的名字得到 fd。最后一步就是,如何根据 test.c 找到这个 inode。那就是我们这个课程的最后一个部分,从文件名找到 inode。
所以整个故事就是从文件名找到 inode,这个文件名不光是文件名,它可能是根目录下的 XX,是整个路径名。根据路径名找到 inode,根据 inode,找到盘块号,根据盘块号往电梯队列中放,根据电梯队列中的盘块号算出 cyl、head、sec,然后使用 out 发到磁盘控制器上,最后磁盘控制器驱动马达,电生磁、磁生电,形成数据的传输。
这整个过程,我不想再重复了。但是大家应该在头脑中,把整个过程反复地琢磨。如果大家有幸将来去做这样的系统的话,做文件系统的话,那么你最后做出来的,一定是这样一个完整的故事。
引入 inode 后,我们就可以得到盘块号。有了 200-212 字符所对应的盘块号 789,我们就可以把这个盘块的磁盘请求放在电梯队列上,然后在磁盘控制器进行中断处理的时候,从电梯队列中取出来,转换成 cyl、head、sec。
很显然,这条路径就是正常文件走的处理路线。文件视图是不管你是否在操纵普通磁盘,磁盘也是一种外设嘛,操纵磁盘就走这样一条处理路线就可以了。那操纵别的设备呢?也走这样一条路,但这个时候,不用 inode 里面的映射表,去找到所对应的盘块号。而是根据 inode 里面的信息,找到一些函数,根据这些函数,最后执行的也是一堆 out 指令。
所以这就是伟大的文件系统,从用户的角度出发,从一个连续的字符序列往出弄。至于你说是否往显示器上放呢?操作系统会进行判断:走下面这条路往显示器上放,走上面这条路往磁盘的那个扇区里放。这就是伟大的文件视图。
讲到这个地方,实际上就应该结束了。但是大家回去应该做实验八,这是我们这个操作系统课程给大家安排的八个实验中,最后一个实验,关于文件的实验。
我们要做一些什么事呢?实现 proc 文件。在 Linux 0.11 上是没有这个文件的,根据这个文件充分体会到在 PPT 第 7 页中,输出到显示器上的第二条路线是怎么回事。你会明白文件视图是个什么概念,你自己要做出这样一条路来。它的核心,我这里说两句,至于怎么具体去做,大家回去认真去做实验八。
学这个操作系统课程,我再说一遍,不做那八个实验,如果不动手去编写代码,学操作系统,可以大胆地说,没有任何用途,你也学不会操作系统,必须要去做。
cat 是个什么呢?cat 是个命令。这个命令就是打开一个文件,然后进行不断地读,while 循环,读出来打到屏幕上。因此就要把它形成一个文件,也就是说要和文件接在一起。那么怎么做到的呢?很显然还是这条路,还是这个视图、这条路。只是当发现这个 inode,当然,这个文件的 inode 和其它的不一样,这个 inode 它的 i_mode 应该是一个 proc 设备,所以需要做一个 proc 设备。
在这里如果发现它是 proc 的话,当然也是 sys_write、sys_read。sys_read 如果发现是 proc 的话,已知 proc,这个 inode 就调用 proc_read,而这 proc_read 就从 task_struct 中取出信息,赋给 buf。一旦赋给 buf,你 printf(buf) 出来的不就是你要的在内存里、在内核里面的这些信息吗?这就出来了,回去认真做。
那么怎么做到这个 inode?怎么走这条路呢?这个在实验指导书上都有,大家回去看一下。
那我们就创建这样的设备,这个设备就设成是 proc。一旦设成以后,在 sys_read 的时候,就可以做出这个判断。如果判断是 proc,就调用 proc_read,在 proc_read 里面就做这些事情:把从 task_struct 里面取出的信息赋给 buf,然后将这个 buf 再传给用户态内存。
最后别忘了修改这个 pos 指针。因为必须要把它形成一个字符流(后台的这些数据、这些信息,无论是从磁盘上来的,还是从内存中来的,无论是在显示器上,还是键盘上,最后都要把它形成一个统一的字符流,用 pos 不断地往后移动,形成一个字符流,向上层用户提供,就完事了)。
到了这个地方,我们就讲清楚了从字符流到盘块号的映射,从字符流到其它设备的那个映射。也就是说,有了这样统一的文件视图以后,用户再看所有的文件,不管是设备文件、普通文件,还是什么其它文件,各种文件都是一个连续的字符流。至于背后发生了什么事情,操作系统做了很多映射,这个映射的关键在于 FCB。体会这个过程,去做一做实验,来真正地把它学会。
L31 目录与文件系统
这一讲将完成对磁盘使用的最后一层抽象。那么对磁盘使用的最后一层抽象,抽象出来的是什么呢?实际上我们在使用磁盘时,大家应该有这样的认识:不管这个磁盘是在 Windows 上、Linux 上,还是其它什么系统,不管它里面是什么样的格式,最后给用户一种感觉就是一棵目录树,这棵目录树从根目录开始。
也就是说,不管什么样的磁盘,这个磁盘在哪里,最后给用户展现出来的,就是这样一棵目录树。那么,既然是一棵目录树,这棵目录树放在 Linux 上应该好使,应该形成这样一棵树;把它放在 Windows 上,也应该形成这样的树。所以,最后磁盘就形成了一个文件系统,是一个子系统,它可以独立地工作。
那么在这个磁盘上面,一旦抽象出了这样的系统以后,在 Windows 上对这个磁盘的操作使用了文件系统,而把这个磁盘拔下来,放到 Linux 上面,同样能把这个目录树展开,并且能在这个目录树中,去找到相应的文件进行读写。
那么,这就是操作系统对磁盘使用的最后一层抽象,就是整个磁盘变成一棵目录树。那么既然是一棵目录树,这一棵目录树里面放的是什么呢?就一堆文件嘛,这棵目录树是用来组织一堆文件的。
那么,这个又和前面我们讲的课程正好呼应。我们前面讲的课程是从一个文件,怎么找到对应的那些盘块来读写它。我们前面讲过,一个文件对应的是一个盘块的集合。我们使用这个文件,把文件当成一个字符流,我们在这个字符流上随意使用,随便去找到一段字符流,然后进行读写。而对于这段字符流,是操作系统最后把它映射成一堆盘块。所以,一个文件是一堆盘块的集合。
而今天要讲的文件系统,那不是一个文件到一堆盘块的集合,因为整个磁盘上不可能只存放一个文件,整个磁盘上存放的是一堆文件,所以最终形成的是一堆文件。当然,这一堆文件也有一定的组织方式。将一堆文件,目标是形成这样一种树状结构、形成这样的一种组织方式。当然,这种组织方式,这种产生出来的样子,是给用户的一种感觉,实际上就是抽象。
然而在磁盘里面就是一堆盘块,所以要完成对整个磁盘的所有盘块进行抽象组织。所谓抽象,就是用数据结构进行组织。前面讲文件的时候,用 inode 进行组织,组织这些盘块形成一个字符序列。那么这里,就是将整个磁盘按照一定的方式,在磁盘上存放一定的信息,最后形成这样一个,给用户的这样一种抽象的结构。
所以对文件系统来说,就是将整个磁盘抽象成这样一个样子。也就是说,在整个磁盘上要存放一些信息,比如说要存放一些位图啦、要存放一些什么内容啦,这个你现在不用了解,我们待会儿马上就会展开来说。为什么要存放这些信息?在这里给出来就是说,在磁盘块上(磁盘就是一堆盘块嘛),在这一堆盘块上存放各种各样的信息,这些信息经过操作系统读取、维护以后,就形成了这样一个样子。
那反过来就是说,用户给出来这样一个样子,比如说我要使用 my 下面的 data ,用户给出来这样一个使用的方式,操作系统负责将这个使用方式,根据在磁盘块上维护的这些数据结构、维护的这些抽象关系,实际上就是映射关系,维护这些映射关系,来把用户的这种抽象地使用方式,最后落实为盘块的读写。因为整个磁盘的使用,最后必须是扇区的使用,用 out 发出 cyl、head、sec,最终才能使用磁盘。
所以,文件系统就是完成了,从下往上说,就是将整个磁盘的盘块重组,映射关系、维护结构。使得在用户眼里形成这样一个基于树状的目录树,一堆文件的一种组织结构。所以,文件系统就是给用户的一种组织结构;从上往下来说,就是用户按照这种结构去存取文件、去访问文件、去管理文件,那么操作系统就把用户的这种读写,通过磁盘上存放的映射关系,最后转换成对扇区的读写,换算出 cyl、head、sec 来读写磁盘,这就是文件系统。
既然放在磁盘上了,那么底层的这种结构就是对上层的使用一种实现,而上层的使用就是对底层的一种抽象,但在用户眼里看到的,就是这棵目录树。既然用户眼里看到的,就是这样的目录树,那么这棵目录树可以放在机器 1 上,也可以放在机器 2 上。也就是说,这个磁盘组织成这样以后,可以插在第一台机器上,展开来用户看到的就是这样;插在第二台机器上,展开来用户看到的也应该是这样。而且可以插在不同的操作系统上,Linux、Windows 都可以,都可以展开来看。
这就是我们最常见的样子,比如说一台机器坏了,我们把硬盘拔下来,放在另外一台机器上。那么,因为它里面的结构已经形成了一个文件系统了,已经形成了完整的一个结构,所以就可以完成这样一个抽象:在另外一台机器上,通过程序就可以建立出这样的一个视图(个人注释:文件视图)。
也就是说,我们现在再使用,可以按照这个目录树来使用这个磁盘。我想听到这里大家应该明白,什么是文件系统?就是将整个磁盘的盘块最后抽象成这样一个目录树结构。因为这个目录树就是我们使用磁盘的基本样子,所以我们才不关心磁盘它插在哪个地方、它是怎么弄的,我们只关心磁盘里有哪些文件,分别放在哪些目录下等等。
由此可以看出来,那么整个故事应该从哪里开始讲起呢?我们刚才讲的是,什么是文件系统?为什么要这么干?而今天讲的是,在这里怎么完成这个映射。操作系统需要写程序、需要做事情,来完成这个映射,来完成这个抽象,来完成这个解释、对抽象的解释。所以整个故事应该从映射开始。上次我们讲了一个文件怎么映射到一堆盘块,那么今天讲的是一堆文件怎么映射成整个磁盘。
核心的故事就是,多个文件怎么处理。那么,多个文件怎么处理呢?这里引点题外话,为什么我们总是说树状结构呢?当然,这是种常识,我们现在用的,打开计算机,计算机用的都是一堆目录,这是一个常识,从现在来看。但是,在操作系统的发展史上,它有个演变的过程。
最开始的操作系统对这一堆文件,就放成一层目录,当然,一层的话也不能叫目录,没有目录的概念,就放成一层。那放一层的话有什么问题呢?如果一个系统中有成千上万个文件,那么在这里面要找点什么,就会非常困难,所以不方便用户使用。后来又改进,能不能每个用户弄一个呢?大家想想,系统中用户毕竟不多,几十万个文件除以 10,10 个用户,那么这里面用一层的结构来表示文件,还是非常多。
所以才引出了一个目录树的结构。目录树是典型的分治,分治在计算机中非常常见,它也是一种基本的算法策略。比如在这个图中,data 文件都放在 data 目录下,email 文件都放在 email 目录下面,这样分开来组织,将来查起来就非常容易。
当然,这样就可以形成一个目录树状结构。既然是树状结构,那么总共 N 个文件,分成 k 层,经过 k 次划分以后,我们用数学简单地算下,每个集合中的文件数是 O(log~k~N) 。经过这样的划分,比如最后在 data 目录下面的文件数目就很小。因此在这个目录下浏览、列举、查找文件都会变得非常容易。
所以,这样一种结构清晰、可扩展性好(无论多少个文件,一取对数 log 后都没多少)的树状结构最常用。这里面也产生了目录的概念,用目录形成了一种树状结构,称为目录树。这个树状结构实际上就是对多个文件的一种组织结构。
那还记不记得我们这节主题:操作系统是如何做出映射结构,来将多个文件映射到磁盘上。那现在多个文件不就组织成这样一种结构了吗?而组织成这样一种结构的关键就在于目录。所以将目录实现了,是这一层抽象最核心的话题。因为将目录实现了,这个结构不就有了吗?因此这里的核心就是目录这个东西是什么?目录这个东西用来形成目录树,即多个文件的组织结构。后面再说明,具体在盘块中存放什么信息来形成这个目录。
所以实现目录、形成目录就成了关键。那么首先就要明白目录怎么用。用户看到的这种文件组织的树状结构,肯定对应的是一些程序。它这个程序是怎么使用这个目录的?我们在操作系统中需要把它使用的这种方式,给它解析明白。
什么叫完成映射,不就是你给我一个 X ,我能用我的方式,用我的表、我的信息能得到一个唯一的 Y,然后再把 Y 给你,这样就完事。所以目录这个故事到底从哪里开始讲呢?就是从用户怎么使用这个东西开始。实际上就是用户在使用时,从上面发下来的是什么开始。是什么呢?一个路径名,open 这个路径名。路径名就是从树根到某个节点的一条路,当然,不一定是叶子节点,而各种各样的路就形成了树。
所以也就是说,实际上我们要做到的事情,就是这里面引出的这一层抽象最核心的东西:根据这个路径名找到文件 a。当然,要找到 a 的什么呢?这就要和上次的话题接上,上次的话题是已知 a 的什么最后我们就可以访问、读写这个文件。a 是一个字符流,那么已知 a 的什么我们就能读写文件呢?已知 a 的 FCB,inode。一旦知道 a 的 inode 就知道那个映射表了,再根据字符流中的位置,我们就可以读写文件。
而现在呢?是给出一个路径,能够把 a 的 inode 找到。把 a 的 inode 找到了之后,再和前面的故事接在一起,就可以访问整个目录树了、可以访问目录树中的任一位置了。因为目录树中任一位置不都是这样一个路径,给我们定义的吗?所以,讲到这个地方大家就可以看到,最关键的是什么?经过这样拨来拨去、分析来分析去,最终的关键是什么呢?就是给出一个路径名,这个路径名体现了树状结构,根据树状结构产生的这个路径名,能够得到这个文件的 FCB,即这个文件的 inode。
那么再和前面的故事接上,给出 inode、给出读写的位置,就能找到对应的盘块;而再和前面的接上,给出盘块号,就能放到电梯队列中;再和前面的接上,给出电梯队列,在磁盘中断的时候,就可以把盘块号读出来,换算出 cyl、head、sec,就可以发给控制器;再和前面的接上,控制器一旦有了 cyl、head、sec,就可以驱动马达,去读写那个盘块。
那么最终不就落实到了,给出一个目录树,根据目录树发一个这样的接口,有了这个接口以后,操作系统就展开这个接口,得到对应的 FCB (inode),然后再变成盘块号,再变成 cyl、head、sec,实现对磁盘的操作。所以大家可以看到,整个的故事就全接上了。
那么,多个文件的组织不也就清楚了吗?任何一个文件,就是目录树中的一个位置。目录树中的一个位置,就可以用这种方式形成一个路径名。那么,完成了这个路径名到 FCB 的映射,再通过 FCB 映射到后面,不就最后完成了一个路径名到盘块的,到最终落实到磁盘扇区的,这样一个映射。
操作系统能完成这样的一个映射,那么反过来,这样一堆盘块,加这样一些代码,在用户眼里不就是实现了一棵目录树,可以在这目录树上随便使用。使用的时候按照这种方式,例如 “/my/data/a” 。你画成这样的图,当然好看啦,你写成这样的式子,它不也是一个目录树吗?
那么,讲到这个地方我们就可以清楚,我们这节课最终落实到,具体是讲什么?虽然引出的话题这么长,但核心就是使用目录树的样子,将多个文件实现在磁盘上。最终表现为就是如何根据这个路径名找到 FCB,把这件事情做完了,这一部分就完事了,那么第四层抽象也就完事了。
也就是说,从目录树中的路径名找到了相应的 FCB。再和前面的接上,由 FCB 找到盘块等后续一系列过程。那么,大家可以看到,这样的话从整个磁盘扇区一层层抽象出来,最后不就抽象成这样一棵目录树了吗?这也正是我们想要的。
我们就讲这最关键的问题:怎么根据这个路径名找到 FCB?是怎么做到的?关键就在于,这个目录中存放的到底是什么?目录是怎么实现的?所以这一部分核心就是,操作系统在磁盘上存放什么信息来实现目录。
树状目录的完整实现
一个目录项中存放的是一个字符串加一个数字,通过这个数字可以在 FCB 数组中找到这个目录项的 FCB。
首先从根目录开始,因为没有其它东西可以用来告诉根目录在 FCB 数组中具体的位置,所以根目录必须固定好。它就在 FCB 数组中 0 的位置,最开始的位置,这是固定的,根目录的 FCB 一直放在这里。
FCB 数组中第一个位置在哪里呢?在磁盘格式化的时候需要记录下来这个位置。而记录这个信息的位置又在哪里呢?可以放在磁盘固定的位置,总之就是读这个磁盘,所以这个磁盘才能从一个机器插到另外一个机器上。计算机就读这个磁盘的信息,不管这个磁盘是 500 G 还是 1T ,还是其它大小,只要读这个磁盘的信息,就能够找到根目录在哪,一旦根目录的 FCB 找到了,就可以找到根目录的数据块。
根目录的数据块里存放的内容,就是要找到根目录下各个文件或目录存放的位置。对于根目录中包含的每个目录项,它存放了每个目录项所对应的文件名和对应的 FCB 的地址,这两个内容。
要想整个文件系统自举(就是自己能好使),操作系统不就是从磁盘读进来,自举的吗?那么文件系统,在磁盘从一个机器上拔下,插到另一个机器上时也能自举。当然,为了实现文件系统能自举,还需要存一些信息,关键就在于,这根目录的信息从哪里来。
那根目录的信息从哪里来呢?这样的话整个磁盘就得被格式化成这个样子。实际上现在讲的,就是这个磁盘经过这样的格式化以后,整个磁盘就可以形成那样一棵目录树的结构。也就是说,为了完成刚才那个目录树的解析,即根据路径名,从根目录开始,最后找到 a 所对应的 FCB。要完成这个事情,只要格式化的时候形成这样的信息,那么,它就一定能够完成这个找到的过程。一旦完成以后,目录树的这个结构就被抽象出来了。
如果不考虑引导块,假定引导块是固定的,那么接下来就是超级块,磁盘地址从 0 开始,引导块的大小是固定的,所以就能知道它末尾的位置,比如说是 2,从 2 开始就到了超级块。而根据超级块里面的信息,一旦读出来就可以得到 i 节点位图多长、盘块位图多长,同时也能获得超级块本身是多大。
经过这样读进来以后,例如就可以根据 4+x+y 来算出 i 节点(inode)起始的位置。一旦有了这个位置,根目录就能进来了,而根据前面的故事,一旦根目录进来了,后面的东西就都可以进来了,目录树就有了。这也符合常识,树中一旦拿到根的位置了,这棵树就可以拽来使用。
这样大家就可以看到,一个磁盘格式化成这样一个样子以后,将这个磁盘插到任何地方,通过读固定位置的超级块,一旦读出来,解析里面的内容,就可以找到根目录,并且还可以读写文件,修改维护这些数据结构,申请新的空闲块,申请释放 inode 等等,这就完事了。所以大家可以看到,这就形成了这样一个基本的结构,也就是说,这样格式化的磁盘就能够自举了。
实际上,一旦明白了整个磁盘长成这个样子,什么 mout 、什么 amout 、什么格式化,都应该清楚了,自己做一个文件系统也是没有任何问题的。
到现在为止,我们就讲完了关于一堆文件、一个目录树是怎么实现在磁盘上的。这里的核心就是目录的实现,目录的解析。那么最后又说了,为了完成根目录的找到,需要将整个磁盘格式化成那个样子,需要有超级块、需要有 inode 位图、需要有盘块位图。在超级块里应该放一些信息来找到根目录,就这样的一个完整的过程。
那么实际上这个完整的过程一旦讲完,下一讲是我们最后一讲,讲目录是怎么用代码实现的。当然,实际上理论的部分、概念上的部分,到此为止就全部结束,也就是说这就完成了全部映射。
在这个全部映射下,磁盘是怎么使用的呢?比如说用户要读 test.c 202-212 个字节,这是用户的使用,我们做的工作是首先 open 这个文件,open 函数从根目录开始,找到 test.c,就是从格式化的磁盘中,首先从超级块里面找到根目录在哪,把根目录读进来再找到这棵树下的其它目录,最后找到 test.c。
所以,这一阶段是根据目录的实现,根据磁盘格式化出来的样子,最后找到 test.c 的 FCB,这是今天这节课中所讲的抽象。这一层抽象加上上次讲的,open 执行完后就要 read,read 的时候就要根据所找到的这个文件的 inode 和文件字符流中要读取字符的起始地址 202,根据这两个信息,read 把要读取的字符所对应的盘块号找到,即 789 盘块号。
一旦找到盘块号 789 了,那么就用前面讲的第三层抽象,用电梯算法将盘块号 789 放在电梯队列中,然后紧接着根据后面的抽象,在磁盘中断的时候,把电梯队列中的 789 取出来,接着算出 cyl、head、sec,之后再通过 out 把这三个参数的值,发给磁盘控制器,磁盘控制器就驱动马达,去读就完事了。
因此,这就找到了 test.c 中 202-212 个字节所对应的磁盘上的位置,接着电生磁、磁生电就可以把信息读取到内存中。到此为止,磁盘驱动的全部故事就结束了。下次课讲一讲关于目录这一部分的代码实现,把这所有代码实现全部完整起来,整个文件系统,一个小的文件系统就能编写出来。
那么大家回去做做实验八,但实验八不是文件系统,它是一种特殊文件,proc 文件,大家感兴趣的话,实际上我们前面说过,八个实验加四个大型实验,其中有一个大型实验就是要做一个小型的文件系统,大家感兴趣的话去做一做。好,到此为止关于磁盘的全部故事就结束了。
L32 目录解析代码实现
这一讲是我们这个课程的最后一讲。在这讲中我们大概要完成两件事情,第一件事情就是我们将上次课留下的一点内容讲完。上次课我们讲的是,在磁盘上操作系统是怎么抽象出那棵目录树的?也就是说,用户对目录树的使用,最终是怎么落实到在整个磁盘上的一种使用。上节课我们讲清楚了这一概念,我们说过要看一段代码,要看看目录解析的代码,这是第一个内容,主要是讲它。
最后我们再稍微花一点时间,来将整个课程总结一下。实际上是要看一看操作系统的全图,非常概括地来看一看它的全图。但是你会发现在这张全图中,当最后概括完了以后你会发现,操作系统真的也不难,在这张全图中涵盖了我们这个课程的全部内容。
我们首先来开始讲第一点内容,怎么进行目录解析。
用户要读 test.c 202-212 个字节,第一步,首先是使用目录树,在目录树上找到这个文件,具体表现为 open。根据在磁盘上存放的、组织好的这些结构,经过一顿读写,找到 test.c 的 inode 。今天讲的就是这段代码是怎么做的、这段代码怎么 code。这体现了我们这个课程一开始的主旨,Learn OS by coding. Coding them.
所以我们今天要讲这一段代码。然后紧接着 read,read 的时候是根据 FCB(inode),根据读写的字符位置找到盘块号 789,这一段代码我们在前面讲过。接着 789 盘块号是怎么放到电梯队列上的,这个在前面的课程中也讲过。然后在磁盘中断时,取出电梯队列中 789 盘块号,根据盘块号怎么换算出 cyl、head、sec,这个在前面也讲过。最后通过 out 指令将 cyl、head、sec 发给磁盘控制器。
接下来我们就来看一看一个实际运转的文件系统。
就是将 open 弄明白,理解了 open 所对应的代码,能写出 open 所对应的代码,那么关于目录解析这一层抽象,就没有任何问题。
前面我们也看过,open 要干什么呢?根据 PCB,最后形成一条链,即把一个文件对应的 inode 读进来,放在 PCB 所对应的 file 数组里面,然后 PCB 里面不有个数组吗?和那个数组接起来,最后返回 fd,fd 就是 PCB 中那个数组的下标( 个人注释:是 PCB 中 filp 数组,L30,p2,current -> filp[fd] )。
至于形成这个链的过程很简单,没什么好说的。open 的核心就在于读入 inode,而读入 inode 核心就是在磁盘上找到 inode。所以大家可以想象得到,整个 open 的核心就是找到 inode。找到 inode 就可以和前面的内容合在一起,一旦合完了以后,再 read(fd)、write(fd),就可以根据 inode,发出去,去读写。
无论是读普通文件,还是设备文件,还是目录文件,都一样的。那么,在前面我们讲过那个代码,和那个地方接上就可以了。所以今天的 open 讲的就是形成那个链,而前面的 read(fd)、write(fd) 是利用那个链,利用 fd、inode 来做。而今天是将磁盘上的 inode 读进来,形成这个链,不再说了。那么核心就是找到 inode ,把它读进来。
get_dir 完成真正的目录解析
目录解析,从根目录开始
读取 inode ,iget
开始目录解析,find_entry
while(i < entries)
到现在为止,到这个地方为止,我们就可以看出来一个目录是怎么解析的,目录是怎么做的。
首先用 iget 得到根目录的 inode,并把它放在 PCB 上。然后在进程创建的时候,所有的 PCB 拷贝自父进程。在目录解析的时候,如果遇到文件路径从 “/” 开始,那么就把根目录的 inode(FCB)取出,根据 inode 得到根目录的数据块。得到根目录的数据块后,就从中取出了根目录中的目录项,而每个目录项都是由一个文件名字符串和 FCB 数组中的一个索引组成。接着挨个去解析这些目录项,while(i < entries)
,解析完了以后,根据得到的数据进行匹配,一旦匹配上就会返回那个目录项,就完成第一层的解析了。
比如,找到了根目录下 var 的目录项,得到其中的索引号 13。然后再用 iget 去读,读 FCB 数组中索引号 13 所对应的 inode。接着再循环,就可以从目录项 var 出发,搜索匹配 var 数据块中所对应的目录项。因此通过这种方式,就可以从树的根目录开始,往下解析用户所输入的路径名。
那么,是不是和我们上次课所学的,从目录的诞生以及操作系统如何去处理目录,是完全一样的?到这为止,open 就讲清楚了,open 讲清楚了,就可以根据目录路径名,得到所对应的 inode(FCB)。然后前面讲清楚了 read、write,那么就可以根据 inode、字符流的起始位置,找到所对应的盘块号。而得到盘块号后,就可以换算成 cyl、head、sec,再通过 out 指令发给磁盘控制器,读取完成,整个故事的代码全有了。
好,到此为止,我们这个课程的操作系统全部内容就结束了。
我们最后花几分钟,简单地给大家勾画一下操作系统的整体轮廓、操作系统的全图。从中你可以看到,虽然老师讲了那么多内容,大家要做那么多事情,要学习、要看书、要做实验,但最终凝结在、停留在脑子里的,无非就这几分钟的东西。
最后将整个事情放在一个很高的角度,将整个操作系统看得非常凝练,这就是操作系统的全图。
那么操作系统是个什么东西呢?是一个管理硬件的软件,所以操作系统首先是管理了 CPU。那么操作系统怎么管理 CPU 呢?我们当时花了很长的篇幅,引出了一个多进程图像。所以首先是一种多进程结构。所以操作系统全图中第一个非常重要的视图,就是多进程视图,实际上我们前面也讲过。
操作系统管理多个进程,然后让它们交替向前推进,让 CPU 充分忙碌。现在 CPU 不就工作起来了吗?那么程序也就可以开始执行了。程序执行的时候,CPU 取指、执行,取指、执行… 。CPU 开始工作了,多个程序交替执行,CPU 就可以充分忙碌。
而在执行程序的时候,又遇到这样的问题了。执行程序的时候,遇到计算指令,那么在 CPU 里完成计算就可以了。但是,当遇到类似 *p = 7
这样的指令,我们需要将 7 放在内存中。执行程序的时候,就要进行重定位、地址翻译,涉及段表、页表等那些内容,全部就在头脑中浮现出来了。
所以,在程序执行的时候,为了完成 *p = 7
这件事,做了很多、很多工作。最终完成的,是让 7 这个值,真的放在 p 所对应的物理内存中。因此,现在内存也跟着使用起来了。所以在程序执行的过程中,为了让程序执行,内存做了那样一些事情,主要就是分段、分页,形成段页合作,有虚拟内存,甚至在执行的时候,还有请求页的换入、换出。那么,这些事情完成的最终工作,就是让 7 能够放在内存中。程序在执行 *p = 7
这条指令时,内存不也就跟着使用了吗?
然后在执行程序时,还会发现有这样一些程序,open(XXX)、read(XXX)。如果这个 XXX 表示普通磁盘文件,那么在执行这些 open(XXX)、read(XXX) 的时候,open(XXX) 进行目录解析,read(XXX) 算出盘块号。然后紧接着发到电梯队列上,后续等一系列内容就全有了。当 XXX 是普通磁盘文件的时候,就是要读写磁盘,发出 cyl、head、sec 给磁盘控制器,磁盘就开始工作了。
如果 XXX 是一些特殊的文件,比如说是显示器的时候,那么就在显示器上打出一些字符。这个时候 open(XXX)、read(XXX)、write(XXX) 也是要进行目录解析,解析完得到 inode,把得到的 inode 一看,是字符设备文件,它的主设备号是 4。然后 read(XXX)、write(XXX) 就会启动相应的一套程序,形成一个函数序列,接着调来调去,最后执行 mov pos
,就把一些字符打到显示器上。
总之,和多进程图像的故事一接上,那么在执行程序的时候,当执行 open(XXX)、read(XXX) 这些指令的时候,取指、执行,根据形成的文件视图,进行相应的选择分支,于是让磁盘能够使用了,让显示器也能使用了,让键盘也能使用了,让打印机等各种外设都能使用了,那么 I/O 也就跟着使用起来了。
那么,把这个话题再拉回来,大家想想,操作系统要干什么来着?操作系统要管理计算机的硬件。计算机有哪些硬件呢?CPU 、内存、外设。通过形成这样一套结构以后,是不是整个计算机就全部工作起来了?整个计算机的硬件全部工作起来,而且是在操作系统的监控下、管理下,合理有序地工作起来。
CPU 在这里忙碌,一会儿忙这个,一会儿忙那个;内存在这里换入、换出,这里放着这个进程的数据,这个进程的代码,那里放着那个进程的数据等等。然后在执行的过程中,启动 I/O 让磁盘工作,这个进程的磁盘在那里工作,这时 CPU 已经切到另外一个进程上,在另外一个进程上进行相应的运算,在内存中取程序,写数据。整个计算机就是在多进程的图像上,多进程这个轮子下,这样转来转去。多进程的轮子转来转去,带动着程序执行,带动着内存被使用起来,带动着 I/O 被使用起来。
而且通常是 I/O、CPU 和内存,在很多情况下都是并行地、一起在向前推进,计算机的设备高效地被使用。所以我们说过,操作系统视图分为两大视图:一个是多进程视图,多进程视图是核心。多个进程这样地转来转去,实现了 CPU、内存的运转。多个进程转来转去,加另一个文件视图,调用这个文件的使用、这些基本接口,让整个 I/O 用起来了。
而多进程视图和 I/O、CPU、内存,往往都是并行地、更加高效地向前推进。所以操作系统有两大视图,多进程视图是 1 号视图,最重要;2 号视图,访问 I/O 的视图,都长成这个样子。把这两个视图深入理解,对操作系统就有了完全的认识。
这就是操作系统的全图,最后画出来也非常 easy。那么将这个视图最后用代码去实现,一个完整的操作系统就有了。将这个操作系统,再加上系统接口、系统启动,那么这个系统就可以从磁盘上,自己载入进来,打出 Logo,然后在内存中展开,创建多个进程,用户敲命令来启动进程,从而让 PPT 好使、MP3 好使、可以让后台编译程序、可以让计算机的显示器也在工作、键盘也在工作、内存也在工作、CPU 也在工作。一切设备都在向前工作,完成你提交给它的各种任务,用计算机来解决各种实际问题,这不就是我们想要的吗?好,到此为止。
附录
- 实验部分可参考:实验楼中配套的实验;hoverwinter/HIT-OSLab 。
- 课程的辅助教材:《Linux内核完全剖析》0.11 版(豆瓣链接 ;oldlinux.org-clk011c-3.0.pdf)
版权声明:本文采用 CC BY-NC-SA 4.0 许可协议,若转载请注明出处。