参考博客
MOOC视频

1、什么是操作系统?

操作系统是计算机硬件和应用之间的一层软件

  • 方便我们使用硬件,如使用显存…
  • 高效的使用硬件,如开多个终端(窗口)

帮助我们管理哪些硬件?

主要介绍前五个内容,后三个不涉及。

学习目标:

​ 改CPU管理

​ 改屏幕输出

​ 改系统接口

​ 改内存管理

进入操作系统:能理解真实操作系统的运转!

我们要成为掌握计算机关键技术的工程师

Standford操作系统课程中的一句话:Learn OS concepts by coding them(通过编码了解操作系统概念)

计算机存储程序的主要思想:将程序和数据存放到计算机内部的存储器中,计算机在程序的控制下一步一步进行处理

计算机由五大部件组成:输入设备、输出设备、存储器、运算器、控制器

计算机怎么工作?

四个字——取指执行

先把程序放到存储器里,然后指针指向它,然后就是取指执行。控制器从存储器中取出数据后,分析指令,运算器执行逻辑运算。

所以打开电源,计算机的第一句指令是什么?PC/IP = ?

2、操作系统启动

汇编语言编写的文件以.s为结尾。【为什么使用汇编呢?汇编可以严格控制他要执行什么内容,执行顺序不会被打乱,不像c语言需要编译,执行顺序不受控制】

电脑一上电,将操作系统从硬盘读到内存里(引导扇区bootsect.s汇编代码干的事),读入setup模块(即setup.s)屏幕可输出字符,后续读入system模块。

其实操作系统启动就做了两件事情:第一件事是读入系统;第二件事就是setup完成OS启动前的设置(读取硬件信息等后还会将system模块移动到0地址,最后进入保护模式然后跳转至system模块)。

head.s是system第一个模块【head是进入之后的初始化】,head.s会跳到main.c(在汇编里面跳转到main.c)永不退出。

main.c

各种初始化,永不退出。

mem_init() 初始化页内存

操作系统启动整个过程:【bootsect.s->setup.s->head.s->main.c】

将操作系统的程序,从硬盘读到了内存中从0地址开始的地方,随后对系统进行初始化(大多与硬件关联)。而随后使用的应用程序,都将放在内存的上端。而其上层的应用如何调用硬件,这就是下节所讲的接口

3、操作系统接口

什么是接口?连接两个东西、信号转换、屏蔽细节…

什么是操作系统接口?

连接上层应用和操作系统软件

操作系统接口连接的不是用户

用户该如何使用计算机呢?

  • 命令行
  • 图形按钮
  • 应用程序

命令行是怎么回事?

首先应用程序编写的程序将编译成一个可执行文件。而与此同时,系统在刚开始的初始化完成后,会循环停留在shell里(可以理解为桌面,不断等你施加命令),当用户输入命令行指令后,系统将运行上面的那个可执行文件。

图形按钮又是怎么回事?

通过GetMessage函数从内核里把消息一个一个拿出来,根据是什么消息执行对应的函数,而应用程序是一个不断从消息队列中取消息的一个循环。【消息处理机制】

操作系统接口:连接谁? 连接操作系统和应用软件;如何连接? C语言程序

操作系统提供了很多重要的函数。

这就是操作系统的接口了;接口表现为函数调用,又由系统提供,所以称为系统调用system_call。

什么是操作系统接口?系统调用

常用的接口有:

4、系统调用的实现

不应该随意访问内核

不能随意的调用数据, 不能随意的jmp/mov(尽管都是在内存中)【是系统调用不是直接函数调用】。这会导致安全和隐私问题,如:可以看到root密码,可以修改root密码,可以通过显存的缓冲看到别人的东西等。而操作系统的调用便正好提供了能够合理进入内核的一种手段。

怎么不让你访问内核

将内核程序和用户程序隔离!!!它把非内核的和内核的东西划分成了用户态和内核态。因此对应的内存中的区域叫用户段和内核段。(通过处理器的“硬件设计”来防止你访问)

硬件提供了“主动进入内核的方法”

对于intel x86,那就是中断指令int,这是用户程序发起的调用内核代码的唯一方式

  1. 用户程序包含一段包含int 0x80指令的代码(c语言库函数)
  2. 操作系统写中断处理,获取想调程序的编号
  3. 操作系统根据编号执行相应的代码

整个过程可详细展开为:

上图调用第二个框时,CPL=3,其DPL也会初始化成3,所以才可以进入内核,随后CPL就置成0了,因为进入内核进行了。


5、学习目标任务

多进程结构是操作系统基本图谱!

对于操作系统, 实现概念远比理解概念重要!

掌握多进程图谱(CPU、内存)&文件操作视图(IO、磁盘、文件)

  • 管理硬件资源

了解系统调用的背后发生了什么。

6、CPU管理的直观想法

操作系统正是在管理CPU的时候,引出了多进程图像(操作系统中的核心图像)这个概念。

CPU怎么工作?

工作过程: 将一段程序存放在内存里,设置PC指针的地址,CPU会根据PC指针发出取址的命令,命令通过总线到达内存,内存将PC地址上的指令再通过总线传回给CPU。CPU看到该指令后,CPU便开始执行该指令。

1、CPU上电之后,会去自动的取址—执行。只需要给CPU设置一个PC初值 (一个程序的开始地址),他就会不断的去取址执行。

【但这样存在一个问题】例如,普通的sum计算和io操作的指令间耗时差别十分巨大,如上大概是六百万比一,相当于执行六百万次计算操作时间才和一次io操作耗时时间相等,这种CPU利用率才50%左右,实际中不可能有六百万次计算才有1次io操作,可能几次计算就会有一次io,那这时的利用率接近于0。

按照上述传统的取址,执行完了再取址,那么CPU等待时间长,从而利用率低。

【怎么解决上述问题呢?】

2、CPU执行不下去的时候,切到另外一个程序去执行(CPU来回切来切去,cpu就充分利用起来了,利用率就高了)

CPU就会在多道程序(多个程序在内存中)里交替执行让cpu忙碌起来。

上述某程序等待文件输出的时候,CPU不会向下执行,可以切到其他程序让CPU继续执行【多道程序同时执行】。

一个CPU上交替的执行多个程序:并发

CPU工作时是如何来回切换执行多个程序的呢(并发)?

“进程”的概念

在并发的过程时,注意不仅仅修改PC寄存器就可以(还要修改各种命令)显然这种方式不可取,需要记录信息(切出去的时候,这个程序执行到哪里,执行时刻的样子)。因此静态程序和运行的程序不一样(不运行时,就那么多字,但一旦运行起来,随时准备记录切出去时的样子)。

进程是进行(执行)中的程序 假设一个程序A在进行的时候会切换到程序B,而B执行的时候又会切换到程序A,那么代表有两个进程。

进程与线程的区别?

  • 进程有开始、有结束,程序没有

  • 进程会走走停停,走停对程序无意义

  • 进程需要记录ax,bx,…等一些寄存器,程序不用

  • ……

7、多进程图像

什么是多进程图像?

交替执行多进程的样子就是多进程图像,多进程图像是操作系统的核心图像

多进程使用CPU的图像

图中,负责记录好进程的便是PCB1,其为process control block进程控制块。

多进程图像从启动开始到关机结束

用户输入命令也会创建一个进程;每解决一个任务计算机都会启动一个进程去执行这个任务,任务执行的好与坏是通过进程推进的样子来评判的。

操作系统通过管理进程来管理用户对计算机的使用。

多进程如何组织?

多进程的组织:PCB+状态+队列(PCB+状态形成不同的队列,放在不同的位置)

操作系统对多进程的感知组织全靠PCB。

我们可以根据进程的状态把不同的进程区分开来,而利用这些状态就可以更好的管理进程:

用PCB将多个进程放在不同的队列中,用状态来推进多个进程,进行状态转化

多进程如何交替?

交替的三个部分:队列操作+调度+切换

依靠一个函数,叫做schedule(),根据调度getNext()方法,有很多调度算法:FIFO、Priority等)取出来下一个要切换的进程,随后与当前进程进行switch切换switch_to() 方法;切换时,CPU会将当前进程的信息保存在相应的PCB中)即可。

多进程如何影响?

由于多个进程会同时存在内存中,此时他们就有可能会互相影响。例如进程1访问的地址,可能存放有进程2的代码,因此有可能进程1会修改进程2的代码从而导致进程2执行时发生错误。

解决办法:限制对地址的读写(会通过每个进程的映射表,将部分地址映射出去)

多进程的地址空间分离:内存管理的主要内容

直接读取不同进程的映射表,通过映射表找到对应的访问数据。

多进程如何合作?

典型的生存者-消费者模式

程序不做干预的话,程序可以一直执行下去。

核心在于进程同步(合理的推进顺序),不能进程之间随意切换,会导致执行错误(执行顺序错乱),应该规定好合理的推进时机。
(通过上锁、开锁和检查锁)【加锁、持有锁、解锁,同一把锁只能被一个线程持有】

总结

8、用户级线程

像每个进程的映射表就是属于资源,如果进程之间切换,那么其映射表也会发生变化,而如果一个进程里面多个指令切换,那么就可以共享同一个映射表(也就是资源)。因此这属于将指令和资源执行所分开。
所以我们可以先理解好线程(thread)的切换(也就是指令的切换),之后再加上资源的切换,就顺利成章地成为了进程的切换。

线程切换

同一进程中切换指令序列

线程的出现是否实用?

【例子】:浏览器搜索会发生什么?

因为他们共享一个资源,因此是线程而不是进程的一个原因之一。多个线程不可能是顺序执行,具体执行过程如下:

其Create(需要创造出第一次切换时应该的样子)之后,就靠Yield来交替切换线程执行。那么核心其实就是Yield(要知道切换时需要是个什么样子)。【先切出去再切回来来回切换】

Yield是如何做到线程切换的?

多个线程能共享一个栈吗? 答案是不可以。

执行函数要将此时地址压栈,如下图,执行到最后会发现,栈中最后的地址是404,后面开始出栈操作,弹出404不是我们期望的204地址不就出问题了吗(也就是说,跑到人家线程里去了,这种情况就不太好回到我们切换前的线程了)。

因此,不同的线程应该有自己独立的栈,多个进程多个TCB(线程控制块,不同TCB存放不同的栈),而Yield切换要先切换栈是靠TCB(TCB和栈相互配合实现线程切换),找到我们需要执行的线程的栈,然后就直接弹栈,开始执行另一个线程。但仅仅靠这个依旧不够,如下图,当B函数继续往下执行执行完毕后,204弹出栈后,又开始执行204,这不就重复执行了吗;

TCB thread control block

因此,线程的样子应该是这样的:每个线程有自己的TCB、栈、切换的PC在自己的栈中,多个线程互不影响

再看看ThreadCreate做了什么?

ThreadCreate的核心就是用程序做出这三样东西申请栈、申请TCB、func等入栈、关联TCB和栈

Yield函数

为什么说是用户级线程——Yield是用户程序

因为Yield是用户程序。它是用户程序里面的线程不断切换(在用户态中来回切换)。有其优点也有缺点,缺点:由于线程都是在用户段的,其内核感知不到该进程有多个线程。当浏览器整个进程在调用网卡IO等待的时候,内核会自动切换到另一个进程。因此一旦内核阻塞,其用户部分的多线程根本没有并发性的效果,因为本身CPU已经不在当前进程工作了。

核心级线程便可以通过系统调用,让内核也知道TCB,从而保持并发性。

9、内核级线程

处理器的多核能工作,都依靠核心级线程。由下图也可以知道,多核处理器中共用一个MMU(MMU就是映射),这正是符合线程共享资源的定义。多核的多个CPU能够并行执行

和用户级相比,核心级线程有什么不同?

用户级线程有用户栈,而内核级线程有着一套栈。

为什么说是一套栈,因为核心要在内核中跑,因此需要内核栈,而在用户态执行代码,因此也需要用户栈!

用户级线程切换,切换TCB根据TCB找到对应线程用户栈进行切换;而核心级线程切换,根据TCB切一套栈,内核栈和用户栈都要切换

用户栈和内核栈之间的关联

一旦出现了中断(int 0x80)进入了内核,便会启用内核栈。此时计算机硬件寄存器会根据用户栈找到其对应的内核栈,并会将用户程序和用户栈的一些寄存器值压入内核栈。当结束中断时,会弹出内核栈的内容,从而恢复至用户态继续执行。

随后,内核执行时也会发生切换。但毕竟最终还是在用户态执行,因此需要最后再切到相应核心栈对应的用户栈对应的用户程序

内核线程switch_to的五段论

内核线程切换,核心栈切换

  1. 用户态执行程序时,为了切换,引发中断
  2. 启用中断后,内核栈与用户栈联动,进入内核执行
  3. 在内核中找到TCB,随后切换TCB
  4. 根据新的TCB切换内核栈
  5. 新的内核栈再根据中断返回(iret),返回到对应的用户栈。

ThreadCreate做了什么?

申请内存地址、创建TCB、创建内核栈和用户栈、关联栈和TCB。随后初始化内核栈和用户栈。

用户级线程、核心级线程的对比

用户级线程比较灵活,自己可以写业务代码,什么时候要跳到什么时候,可以自己写调度算法;而核心级线程只能用OS中规定的,无法随意改动。

10、内核级线程实现

进程里面的执行序列其实就是内核级线程

核心级线程切换,两套栈之间的切换

用户栈-》内核栈-》TCB-》TCB完成切换-》完成内核栈切换-》通过iret切到对应用户栈(五段论)

内核里如何切换用户是无法感知的,用户只能感知线程发生了切换,用户也不关注你内核做了什么事情

第一段

用户栈从中断入口进入内核找到对应的内核栈,…最后的system_call来存储各种中断退出时的状态。

第二三四段

当其状态阻塞或者时间片不够的时候,则会发生schedule,内核栈便开始发生切换。

第五段

最后一段就是中断返回,将压入内核栈和system_call中存储的各种状态进行pop弹出 返回,iret返回对应的用户态

11、CPU调度策略

getNext()方法如何得到需要切换的进程,这就涉及到CPU调度策略

引出问题

正在执行中的进程,阻塞时,如何选择切换的进程?

如何做到合理?需要折中,需要综合

各种CPU调度算法

各个任务到达时间及耗时情况

First Come,First Served(FCFS)

哪个任务先来,就先服务哪个任务。这个很简单但是同样其平均周转时间也较长。

SJK短作业优先

可以让CPU区间短的作业优先执行,短作业就可以提前结束,短作业周转时间就会变小,短作业就会比较满意,在整体系统中,有短作业也有长作业,短作业整体满意度上升,系统整体满意度也会上升。可以证明,该算法可以达到最短的周转时间。但与此同时,响应时间可能会变长(如下图),也许你一开始点了个鼠标,但由于你的任务比较耗时,因此被排到最后执行,最开始点击反而最后执行,用户体验就会很差。

如何解决上述问题?

RR(round robin): 按时间片来轮转调度

对于Word很关心响应时间,而gcc更关心周转时间,两类任务同时存在怎么办?

同时需要考虑响应时间以及周转时间。 响应时间和周转时间同时存在,怎么办?

优先级调度

首先,若根据下图这样直接根据想法定义(绝对优先,前台任务优先级高于后台任务优先级),则会出现有的后台任务一辈子也执行不了,因为可能前台任务一直是存在的。

因此为了解决上述问题,后台的任务优先级应该动态升高,不能一味的只执行前台任务需要适当的执行后台任务,并且为了照顾到各自的响应时间情况,应该前后台任务都用时间片。下文将介绍一个完整的不错的调度算法。

12、实际的schedule函数(linux 0.11调度函数)

找到next,然后switch_to(next)

TASK_RUNNING就绪态

找到counter最大的进程然后跳转执行(优先级方法)

上述算法综合了counter的优先级技术时间片技术。(两个共用一个变量)

如果是就绪态进程,设置为counter初值;如果是其他进程,counter折半后再加上初值。执行完的会再赋初值,而未执行的也累加初值,从而造成了阻塞态的counter不断变大,从而完成动态调整

counter的作用: 时间片

时钟中断中counter每次都–,当减为0时,就进行调度。所以counter是典型的时间片,所以是轮转制度,保证了响应。

counter的另一个作用: 优先级

每次在找任务时,都是找counter最大的任务调度,因此counter表示的优先级可以动态调整。IO进程优先级就会升高,I/O正是前台进程的特征。(前台进程优先级就会变高,IO时间越久,优先级就会越高)

counter作用总结

第一条:其时间片最长也就是2P,所以是可以保证有限时间的。也就是上面为什么除以2,而且右移一位对于硬件运算来说是非常快的(其实除以几都ok,但是右移动一位最快,而右移动一位正好是除以2)

第三条:时间片会不断轮转,这样还是短作业的先完成(时间片用光了就复位,肯定短作业先完成),因此近似SJF。

13、进程同步与信号量

进程合作:多进程共同完成一个任务

进程同步—让多进程之间进行地合理有序,其依靠的工具就是信号量

经典生产者-消费者实例等待是进程同步的核心

停才是进程同步的关键,找到在哪里停。

只发信号还不能解决全部问题,两个生产者对应一个消费者就会出现以下问题,导致P2不会被唤醒。因此单纯依靠counter这种语义判断是不够的,因为它仅知道有生产者在睡眠但是不知道到底有几个生产者在睡眠。

因此仅仅用信号不能解决问题,在此引入信号量的概念,不仅是信号还能用来记录一些数据,并且根据信号量来决定是唤醒还是睡眠。

在counter基础上新增一个变量sem,sem用来记录睡眠进程的数量。(信号量semaphore

sem为负几就代表有几个生产者正在睡眠等待。

看到信号量是负的,代表在等待资源,因此此时可以wakeup(包括信号为0)(产生资源,其加1)也可以sleep(消耗资源,其减1)。看到信号量是正的,便无需操作,可以运行。

看如下问题:

信号量初值为8,现在为2,说明还有两个资源可以使用故选B;

如果值为-2,说明有个两个进程在等待资源选A。

14、信号量临界区保护

为什么要保护信号量

共同修改信号量会引发问题——多个消费者同时执行可能前面消费者修改的信号量还没完毕,便因为调度算法而导致切到其他消费者处理,从而造成共享数据语义错误(即信号量混乱最后信号量不是我们期望的值)。

竞争条件:和调度有关的共享数据语义错误。

  • 错误由多个进程并发操作共享数据引起
  • 错误和调度顺序有关,难于发现和调试

如何解决竞争条件

主要思想:在访问共享变量empty时阻止其他进程也访问empty,保证只能有单个进程访问empty

上锁保证一段代码一次只允许一个进程进入,要么做完要么不做原子操作

临界区(Critical Section)

临界区: 一次只允许一个进程进入的该进程的那一段代码

一个重要的工作:找到进程中的临界区代码

读写信号量的代码一定是临界区

在进入临界区前,临界区代码执行完后,都要做一些事情。

临界区代码保护原则

  1. 互斥进入
  2. 有空让进
  3. 有限等待

如何进入临界区

轮换法

满足条件进入,不满足条件自旋

  • 满足互斥进入要求

  • P0进程执行完turn=1,P1进程完全可以不执行(例如阻塞),P0完成后不能接着再次进入,尽管进程P1不在临界区…(不满足有空让进)

标记法

当执行顺序如下图时,可能会造成无限等待

非对称标记

关键: 选择一个进程进入,另一个进程循环等待

  • 满足互斥进入
  • 同时满足有空让进
  • 也满足有限等待

多进程怎么办?

面包店算法

面包店算法——仍然是标记和轮转的结合

如何轮转: 每个进程都获得一个序号,序号最小的进入。
如何标记: 进程离开时序号为0,不为0的序号即标记

每个进程过来都会取号(choosing),并且号会越来越大(最大元素+1),如果有进程在取号就一直自旋,每次只会让最小的号进入。

简单的进入临界区方法

上面的方法有些复杂了!上面是纯软件,所以越来越麻烦,而下面介绍的两个方法,将结合硬件,使其方法尽可能简化。

临界区:只允许一个进程进入

被调度: 另一个进程只有被调度才能执行,才可能进入临界区,如何阻止调度?

关中断】因为中断,才会发生调度,从而使得出现竞争条件!

硬件提供命令关中断cli(),开中断sti()问题是在多核CPU的环境下不好用,因为关中断也只能关掉当前进程的中断,其他的CPU是不理会的,也就说其他的进程有可能再引起竞争条件!

临界区保护的硬件原子指令法

通过一个原子指令实现一个1的锁信号量,用其修改一个整型变量,根据这个变量,再来判断要不要进入临界区。因此,修改这个变量的过程要求一步完成(硬件帮忙、原子操作),中间绝不能被打断

结论

用临界区保护信号量,用信号量保证同步。

15、死锁处理

多个进程由于互相等待对方持有的资源而造成的谁都无法执行的情况叫死锁

当形成环路等待时,将会使越来越多的进程和资源都阻塞,随后没程序可执行了,导致计算机不工作了。

死锁的四个必要条件

死锁处理方法

死锁预防

破坏死锁出现的条件

方法例子:

  1. 在进程执行前,一次性申请所有需要的资源,不会占有资源再去申请其它资源(缺点:需要预知未来,编程困难;许多资源分配后很长时间后才使用,资源利用率低)
  2. 对资源类型进行排序,资源申请必须按序进行,不会出现环路等待(缺点:仍然造成资源浪费)

死锁避免

检测每个资源请求,如果造成死锁就拒绝

每次请求不做任何要求,可以随便申请,但是每次请求需要算一下判断此次请求是否会引起死锁

怎么判断呢?需要算法——银行家算法

安全状态: 如果系统中的所有进程存在一个可完成的执行序列P1,…Pn,则称系统处于安全状态

银行家算法: 找安全序列的银行家算法(Dijkstra提出)

算法过程: 其算法如下:就是不断对比工作向量Work和需要的资源量,分配后再加进程结束后归还的已分配资源,再去比较下一个需要分配的资源类,查看是否可以找到一个安全状态的可执行序列。(m是资源个数,n是进程个数,因此时间复杂度是较高的)

请求出现时: 首先假装分配,然后调用银行家算法,如果死锁,这次请求直接拒绝

死锁检测+恢复

检测到死锁出现时,让一些进程回滚,让出资源

上面的死锁检测比较繁琐,效率低。因此定时检测或者是发现资源利用率低时检测,检测效率高了。但是当发现死锁时,需要回滚,回滚是十分复杂的!

死锁忽略

就好像没有出现死锁一样

16、内存使用与分段

如何让内存用起来?

将程序放到内存中,计算机不断从内存中取值执行,内存就被用起来了。

内存使用:将程序放到内存中,PC指向开始地址

重定位:

重定位: 修改程序中的地址(是相对地址)

在内存使用过程中,当遇到指令call 40(40刚好是main函数地址)时,理论上,存放main函数的物理地址就应该是40,但是如果这样很明显可能与其他程序中的40地址相等造成冲突(而且OS放在低位的地址上),因此应该找个空闲的地址开始存放,同时把40当作一个逻辑地址,同时找到空闲的地址,将该逻辑地址修改为物理地址(即如下图将40改成1040这个过程就是重定位)。

什么时候重定位?

编译时 or 载入时

编译时(但由于编译和执行时,一个地址是否空闲不确定,因此这个时候只适用一些静态系统)、载入时(大多数机器,载入时才重定位,较灵活)

【两种重定位的区别】

  • 编译时重定位的程序只能放在内存固定位置,快速,不够灵活
  • 载入时重定位的程序一旦载入内存就不能动了,相对慢一些,较灵活

重定位最合适的时机- 运行时重定位

在运行每条指令时才完成重定位

有的时候,程序载入后还需要移动

内存中的进程并不是一成不变的,因为进程可能会阻塞,就会引起进程之间的交换,所以内存也在发生着交换。而换入换出这个过程,可能就导致之前是空闲的地址,现在又不是了。因此,程序载入后还需要移动,仅仅载入时重定位也不够!

因此,在运行每条指令时才完成重定位(地址翻译)。

那么这个base放在哪里呢?这个base应该与进程相捆绑,因此放入PCB(描述进程的一个数据结构)中,执行指令时第一步先从PCB中取出这个基地址。(进程的换入换出时便会在内存中寻找空闲的内存,并将该内存起始地址放入其PCB中的base,同时,将这段程序放入这段空闲的内存中。每次执行指令时,都需要进行地址翻译,就会找到这条指令实际使用的物理地址。因此无须担心前面中进程的换入换出导致的地址变化)
总结图

此时,系统中有两个进程,首先当PC指向进程1的mov指令时,CPU实际操作的物理地址为100 + 进程1PCB中的基指base2000为2100。

当发生进程切换时,PC指向进程2的mov指令,CPU实际操作的物理地址为100 + 进程2PCB中base1000为1100。

引入分段: 是将整个程序一起载入内存中吗?

分段——不是整个程序都放入内存

在程序员眼中,程序由若干部分(段)组成,每段程序都有各自的特点、用途:代码段只读,代码/数据段不会动态增长…我们以一种“分治”的思想来独立考虑每个段。

这么做,为了让内存更高效地使用,不是将整个程序,是将各段分别放入内存

此时怎么定位具体指令(数据): <段号, 段内偏移>

刚才的PCB放整段的base,而此时的PCB需要放各个段中的base。因此便引出进程段表的概念:

而操作系统的进程段表为GDT,而每个进程的进程段表为LDT

整个过程如下:

1、一个程序分成多个段(代码段、数据段…),每个段在内存中找到一段空闲的地方,并把该内存的初始地址(基址)放入LDT表中,同时将该段程序放入这个空闲内存中。
2、每段都搞定之后,整个程序相当于放入内存中了,随后这个LDT表赋给PCB
3、然后PC指针根据PCB设置好起始位置,然后取址执行,每次执行都查LDT表,找到程序中对应的物理地址(地址翻译)。

17、 内存分区与分页

回顾上述内容,内存管理一共三步:
1、在程序编译时,分成多个段。
2、在内存中找一段空闲区域(分区本节所讲内容)。
3、找到空闲区域后,将其读入到内存(磁盘读写)的这个地方,并做好映射关系(LDT)。

内存怎么割?——内存分区(虚拟内存常用)

这样就可以将程序的各个段载入到相应的内存分区中了

固定分区

等分,操作系统初始化时将内存等分成k个分区,段也有大有小,需求不一定,固定分区不太合适!

可变分区

可变分区的管理过程— 核心数据结构

记录两个表数据,已分配分区以及空闲分区

可变分区的管理—请求分配

记录信息更新信息,根据所需内存查找空闲分区请求内存,更新上述两表数据

可变分区的管理—释放内存

进程执行完毕后,进程退出了,所占用内存段就会释放,依旧更新上述两表数据

可变分区的管理—再次申请

此时再次申请内存,可能有多块内存满足条件,选择哪块内存呢?

例如:再次申请40k内存上述有两块内存满足条件该怎么选择呢

此时有三种适配方法(并没有谁好谁坏,各自有各自的特点,根据特点选择方法):
1、按照申请的长度和空闲的长度相差的最小,为最佳适配(200,50)【空闲分区会越来越细越来越小,会有很多细小的碎片】
2、按照申请的长度和空闲的长度相差的最大,为最差适配(350,150)【空闲分区会比较均匀】
3、找到最先出现的空闲地址,直接选择,为首先适配(350,150)【随机现象,比较快不需要比较】

内存分页(物理内存常用)

解决内存分区导致的内存效率问题

可变分区造成的问题:

内存分区导致了内存效率问题,其容易产生一堆内存碎片(总的内存也够,但都分分散散放不下一段的程序了)。此时就要移动内存,而想要移动存好的内存,腾出空来(内存紧缩),会花费大量的时间,而且内存紧缩的过程中,上层用户启动的进程一个都无法执行,这肯定是用户无法忍受的,所以要避免这种情况出现。

为了避免上述情况的发生,实际的操作系统将引入分页来解决这个问题,将连续变为离散,将内存分成

【分页的过程】操作系统启动时,将整个物理内存分成一页一页的(每一页的空间都很小),而针对每个段内存请求,系统一页一页的分配给这个段。这样即便就是出现内存碎片,也绝不会超过一页。

此时,再重定位时,就需要用到页表。以下图为例,如果有个逻辑地址为2240,每页长度为4k,因此2240/4k(右移12位)= 2,这得到页号(由硬件MMU完成)而抛出去的240就是页面里面的偏移地址。由页表和页号可知其在物理地址的页框为3,因此3再右移12位,得到对应的物理地址存放页2的地方,即3xxx,加上240的偏移,即为3240的物理地址。

18、多级页表与快表

由上可知:

为了提高内存空间利用率,页应该小,但是页小了页表就大了…

此时,页表很大放置就成了问题,会占用额外的内存空间

【第一种尝试】只存放用到的页,用到的逻辑页才有页表项,但这样会造成页表中的页号不连续,因此在查找逻辑地址对应的页号时,就需要挨个去比较查找。而顺序查找的效率太低了,即便二分查找也有些慢(如果页号是连续的,直接起始地址进行偏移就好了,根本无需比较查找)。【这种为了保证页号连续,就算页没被用到也要保存到对应页表项中,这样就会导致大页表,造成浪费

既要连续又要让页表占用内存少,怎么办?

【第二种尝试】多级页表,即页目录表(章)+页表(节)

多级页表:页目录表+页表

上述单页表如果页表每一项都保留,能达到连续,需要4M的内存(以32位地址为例,即2^32,为4G,而页面一块又为4K,因此共有4M个页号)。而采用多级页表,我们只需要将所有的目录放进去,而当目录里面没有页时,也就无需记录页表的项。以下图为例,页目录驻留
内存(4K),然而其中只有三个目录中存在页信息,而每个目录对应的一个页表也只有4K个项,因此三个4K+目录的一个4K为16K,这样是远远小于4M的。

多级页表提高了空间效率,但是多级页表增加了访存的次数,尤其是64位系统。因此,我们可以把常用的页给记录下来,记录在快表(TLB)中。先从快表查,找不到再去多级页表查,因此查找起来速度就可能提高

快表

快表是个寄存器,是一个昂贵的器件,尽管不连续也可以快速查找,但不可以存放过多页项。而该寄存器可以从硬件上做到相联的效果,即不连续的页号也可以一次找到。当页号不在快表中时,再去多级页表中查找。

要想访问速度足够快,就要真正实现“近似访存1次”,TLB的命中率应该很高

因此TLB越大越好,但TLB很贵,通常只有[64, 1024]

相比2^20个页,64很小,为什么TLB就能起作用?

主要是程序的地址访问存在局部性。因为程序多体现为循环、顺序结构。即空间局部性

总结:

操作系统利用这种局部性+硬件的支持,利用多级页表、快表达到时间和空间效率都比较高的情况

19、段页结合的实际内存管理

分段:程序员编写的程序是分段的(分段对用户内存、应用程序来说比较好)

分页:操作系统管理物理内存时,是采用分页机制,能够更高效的利用内存(分页对物理内存比较好,比较高效)

段页结合:程序员希望用段、物理内存希望用页

核心:虚拟内存

想要实现段页结合,用户程序访问真实物理内存之前中间还有一个虚拟内存,为什么呢?

首先用户程序是分段的,物理空间是分页的;不能将用户程序直接分成页直接去使用物理内存,因此借助虚拟内存,虚拟内存还是以段的形式存在,程序分段映射到对应虚拟内存,虚拟段内存分页再映射到相应的物理内存。

段、页同时存在:段面向用户、页面向硬件

详细示意图:

段、页同时存在时的重定位(地址翻译)

首先,根据段表,找到逻辑地址对应的基址,再加上段内的偏移,从而得到虚拟地址。根据虚拟地址来算出它的页号,再根据页号去查页表,找到虚拟地址对应的物理地址的页框号,最后加上它的段内偏移,得到真实的物理地址

逻辑地址->虚拟地址->物理地址

一个实际的段、页式内存管理

内存管理核心就是内存分配,所以从程序放入内存、使用内存开始…

段、页式内存下程序如何载入内存?

整个过程分为哪几步?

从进程fork中的内存分配开始,过程可以简化为五步:分配段、建段表;分配页、建页表、可以重定位具体使用内存了。

首先要在虚拟内存上用分区算法割出区域来分给程序的数据段、代码段等各类段,同时建立好段表。再把虚拟内存每一段分成多个页,映射到物理内存中的页单位上,同时建立好页表。(只要段表和页表弄好,执行指令时MMU自动完成【硬件支持速度很快】)

假设进程1fork一个进程2(父进程fork子进程)

整个过程是这样的:(c语言)

(进程1)当父进程1执行 *p=7 时,编译完后的p为300。经过LDT(段表)查到虚拟地址为400300,随后根据页表找到物理地址7300。 然后cpu将7300打到地址总线上,同时将7打到地址总线上,因此就完成了指令——把7放入了7300地址上。
(进程2)当子进程2执行 *p=8时,和父子进程执行同一套代码,因此编译完后的p还为300。但进程2的LDT算出来不再是400300了,而是800300。但800300根据其页表得到的物理地址仍为7300。
【重点】但当时在前几步复制创建子进程时的建页表中,虽然都指向同一页,但其设置子进程所复制的页表指向父进程指向的页为只读状态
因此,子进程写的时候,就要进行分离,新申请一个内存页,随后建立修改这个页表,建立一个新的映射此时把p映射到8300,随后把8放入即可!【父子进程逻辑地址相同,虚拟地址和物理地址不同】

20、内存换入换出

为什么需要内存换入换出?

主要就是为了由小的物理内存映射出大的虚拟内存。

其实是为了实现虚拟内存,所以才引申出了内存的换入换出概念。因为虚拟内存并不完完全全等于物理内存的大小,毕竟用户编程中感受到的应该是磁盘大小,而不仅仅是内存大小。
其实在用户眼里,用户可随意使用该“内存”(虚拟内存),而对这个“内存”怎么映射到实际物理内存的,全然不知。而内存的换入换出就是专门为了解决由大的虚拟内存映射至小的物理内存的。造成大小区别的原因是:可能物理内存并没有那么大,但呈现给用户的时候,用户在编写程序的时候不关心真正的物理内存是多少,此时OS通过虚拟内存方便了用户使用。下图就是换入操作:用户的感觉就是这个2G都有,都能用。其实物理内存才1G,是通过换入换出来给用户拥有2G感觉的。

请求的时候,才换入才进行映射。

内存换入-请求调页

请求调页

当一条指令访问一个地址时,但当查询页表时,发现缺页(即页表没这个地址的信息),此时硬件(MMU)将做出配合引出中断,进行调页(进行中断的页错误处理程序)。首先从磁盘找到这页,然后再找个内存中的空闲页,把磁盘的该页读进内存。最后在页表中做好映射。中断结束,此时再去执行指令即可。【发现是缺页造成的中断,MMU可以特殊处理让PC指针不动,不去+1,继续执行原指令,而不是下一条指令】

操作系统采用请求调页而不是请求调段,是因为?
请求调页的粒度更细,更能提高内存效率。

内存换出

有换入,就应该有换出!

前面讲了内存的换入,将磁盘的内容读入了内存中,并不能总是获得新的页,内存是有限的,因此换入和换出应该一起工作。因此,在上面换入中找空白页之前,应该完成换出操作(即将该淘汰页内容写出到磁盘上),而换出到底选择哪一页换出去呢,因此就涉及到了以下的换出算法。

FIFO页面置换

先入先出算法,但只适用于缺页次数少的场景。否则就会导致频繁调换(频繁磁盘IO十分耗时)。

MIN页面置换

MIN算法: 选最远将使用的页淘汰,是最优方案

执行到D时,发现缺页,要进行置换,可以看到未来需要换过来的C是距离D最远的,因此D换C。

这种算法也是有缺点的:MIN需要知道将来发生的事,但是将来发生的事,犹未可知,只有真正发生了才会知道。

LRU页面置换

用过去的历史预测将来。

LRU算法: 选最近最长一段时间没有使用的页淘汰(最近最少使用)。LRU是公认的很好的页置换算法。

效果和MIN算法类似。

LRU准确实现—时间戳

每页维护一个时间戳(time stamp),记录每页使用的时间。当需要换页时,找到时间戳最小的换掉即可。

缺点也是十分明显:每次地址访问都需要修改时间戳,需维护一个全局时钟,需找到最小值,实现代价较大。

LRU准确实现—页码栈

维护一个页码栈,每次栈顶都是最近使用的页,选栈底页淘汰!

缺点:每次地址访问都需要修改栈(修改10次左右栈指针) … 实现代价仍然较大-> LRU准确实现用的少

LRU近似实现- 将时间计数变为是和否

最近只要访问过就再给你一次机会。【最近没被使用近似LRU(最近最少使用)】

SCR这一实现方法称为Clock Algorithm

再给一次机会(Second Chance Replacement)

将时间计数变为是和否。具体原理如下图所示,每当访问该页时,将R置为1【MMU来做很快】。缺页选择淘汰页时,扫描该位,是1时清0,并继续扫描;是0时淘汰该页。

由于局部性原理,缺页出现的情况一般会比较少。缺页很少时,指针就不会转动,1就永远不会置为0,时间久了R就全变成1了。此时,再发生缺页时,这样算法就退化成了FIFO算法。而发生这一切的原因就是:记录了太长的历史信息,没法反映“最近”

为了解决上述问题,需要定时清除R位(引用位)… **再来一个扫描指针!**用来清除R位,移动速度要快!(快指针,时钟中断)用来选择淘汰页,移动速度慢!(慢指针,缺页)双指针!

给进程分配多少页框(帧frame)

内存的换入换出也都是对页进行置换操作,因此这个问题要确定好!
分配的多,请求调页导致的内存高效利用就没用了!
但当分配的少,当系统内进程增多时,每个进程的缺页率增大,而缺页率增大到一定程度,进程总等待调页完成 ,从而CPU利用率降低,至使进程进一步增多,缺页率更大。因此引发下图的颠簸现象【系统进程增多,CPU利用率上升,CPU利用率达到一定高度后,进程继续增多,此时CPU利用率会急剧下降】

应该找到满足程序局部性的那么一个分配页框大小。保证内存高效利用的同时,降低缺页率。

内存真正的换入换出

内存换入换出实现swap分区管理,分区管理实际上为了实现虚拟内存,虚拟内存又是为了段页结合,段页结合为了程序,程序运行起来又是进程,层层递进。

21、 I/O与显示器

让外设工作起来

第一步,CPU向控制器中的寄存器读写数据
第二步,控制器完成真正的工作,并向CPU发中断信号

为了让“向设备控制器的寄存器写(毕竟不同公司生产的不同硬件其格式要求都不一样)”变得简单易操作,操作系统要给用户提供一个简单视图—文件视图

【外设工作的三个步骤】形成文件视图、发出out指令、形成中断处理。

一个统一的视图-文件视图

操作系统两大视图——进程视图(CPU、MEM内存)、文件视图(磁盘、设备);

操纵外设的程序

1、不论什么设备都是open, read, write, close操作系统为用户提供统一的接口!
2、不同的设备对应不同的设备文件(/dev/xxx),**需要根据设备文件找到控制器的地址、内容格式等等!**便可得到设备属性数据,和上面的接口完成对接。

显示器输出

printf(“Host Name: %s”, name);为例

第一步,首先根据进程所分配的设备文件(对于终端设备文件【终端设备包括显示器和键盘】来说,是从系统启动时就有的,因此每次子进程都会复制这些设备文件的存在),找到显示器文件,取出其里面的信息。
第二步,随后对里面的信息,通过字符设备接口函数来找到驱动显示器显示的函数tty_write函数,然后将tty_write放在等待队列中
第三步,再找到显示器的写函数con_write来对显示器进行操作,同时当需要取出从缓冲区(write_q队列)里面的字符时,将要显示的内容移动到显存中。

22、 键盘

终端设备包括显示器(输出)和键盘(输入)

键盘对于不同对象有着不同的行为:对于使用者(人)来说: 敲键盘、看结果;对于操作系统来说: “等着”你敲键盘,敲了就中断。

键盘,从中断处理开始,从中断初始化开始

【键盘使用】通过键盘的按键,OS中断处理程序根据扫描码得到ASCII码放入等待队列中(缓冲区),随后等待scanf等函数从等待队列里取出即可。

设备处理三件事:中断处理、out、文件视图

23、生磁盘的使用

与上面输入输出设备有着相同的过程

第一步:CPU向磁盘控制器中的寄存器读写数据
第二步:磁盘控制器完成真正的工作,并向CPU发中断信号

认识磁盘

一个柱子串起一些盘面,旋转的时候,磁和电相互转化,最后磁信号变成电信号。

读写磁盘的基本单位是扇区

磁盘的I/O过程(使用磁盘)

第一步,通过向磁盘控制器读写来控制磁盘
第二步,移动磁头到相应的磁道上
第三步,旋转磁盘到相应的扇区上

总的来说就是移动磁头到相应的磁道上,旋转磁盘到相应的扇区上,和内存缓冲进行读写。【和内存缓存进行读(通过磁生电来读)/写(通过电生磁来写)】

磁盘I/O过程: 控制器->寻道->旋转->传输!

最直接的使用磁盘,只要往控制器中写柱面、磁头、扇区、缓存位置

其中,柱面指:如上图,每个盘面都分为为一圈圈的磁道,而所有盘面相同的位置的磁道组成一个柱面。
磁头就是选定哪个盘面。因此靠上面两个参数,就能确定一个圆了。而具体读圆的哪一个部分,就是靠扇区。而最后的缓存位置便是与磁盘进行交互的内存缓存位置了。

通过盘块号读写磁盘(一层抽象)

上面直接使用磁盘,需要柱面、磁头、扇区、缓存位置这些数据,直接但是比较麻烦,而通过盘块号会简单很多。用户设计的程序应该只发送一个盘块号(block),随后磁盘驱动负责通过盘块号来计算出上面的三个参数。

我们期望block相邻的盘块可以快速读出,根据下面的访问时间可以看出,主要是磁头去寻道的时间比较慢,因此设计应该尽量减少寻道时间。(寻道,磁头直线移动比较耗时)

因此,相邻的盘块应该在一个磁道上,如下图所示:

盘块号扇区,应该连续分布

扇区号计算公式:C x (Heads x Sectors) + H x Sectors + S -> 柱面 x(Heads x Sectors)+磁头 x Sectors + 扇区 = 扇区号

Heads就是盘面是固定值,而Sectors就是一个磁道可以分多少个扇区

用空间换时间从扇区到盘块: 每次读写1 K:碎片0.5K;读写速度100K/秒;每次读写1 M:碎片0.5M;读写速度约40M/秒(为了更快的速度,可以多耗一点空间)【多个扇区组成一个盘块,由于数据传输时间很快,可以同时读多个扇区,只不过会有更大的碎片空间。因此,虽然扇区大小固定,但操作系统可以每次读/写连续的几个扇区(盘块)】

用空间利用率换取时间利用率,也是无关紧要的,毕竟现在磁盘动不动就是512G、1T、2T。可以忍受使用空间换时间大部分情况也是这么做的。

每个盘块其实是连续的多个扇区,因此我们用空间换速度。而每个盘块对应几个扇区都直接写好在磁盘驱动中。(Linux0.11盘块大小是2)

多个进程通过队列使用磁盘(第二层抽象)

多个进程将盘块放到请求队列上,(磁盘中断)磁盘驱动从队列取出盘块,从而计算出CHS

实际上通过调度算法来取出盘块号,来控制磁盘。

调度算法的目标是:平均访问延迟小。
调度时主要考察什么:寻道时间尽量短。

FCFS调度算法

依旧是谁先进队列,谁就先拿出来。因此也是最直观、最公平的调度。但这个算法会导致磁头动来动去的(寻道时间长),因此为了解决该问题,我们应该在磁头移动过程中,把经过的请求处理了!

SSTF磁盘调度(Shortest-seek-time First)

短寻道优先,距离队列中谁的差值小,就先移动到谁那里,如下:

但这个也有明显的缺点,因为磁盘中间的请求相较来说比较多,因此会导致来自边缘位置的请求,一直划不过去,因此会导致边缘请求不会被处理。【存在饥饿问题】

SCAN磁盘调度

SSTF+中途不回折:每个请求都有处理机会

每次处理将同一方向请求处理完,再处理另一个方向,中间请求反而占了便宜,处理机会变多了。【磁头也不会长途奔袭】

C-SCAN磁盘调度(电梯算法)

SCAN+直接移到另一端:两端请求都能很快处理

就是找最短距离+不允许折返+遇到边界就复位。其实相当于只能下楼的电梯,先从起点下到底,然后跑到最上面,再一步步把人都带下去。

生磁盘(raw disk)总结

第一步中,得到盘块号其实是依靠“文件” 来完成的,也就是下面的内容,跟熟磁盘有关,这里生磁盘就假设已经得到了。而扇区号也不是三个参数,而是因为一个盘块号对应连续的扇区,因此是算出那个开头的扇区号。

第二步,(其实此时也需要申请缓冲内存位置等,它将提速磁盘读写,但本课程不讲述),随后使用电梯算法将其放在等待队列中。

第三步,放入队列后,就由硬件来管控了,因此进程进入sleep,就不管了。然后切换其他进程进行协同

第四、五步,磁盘中断处理,意味着开始处理上面的指令了。先根据上面的扇区号结合公式得到三个参数CHS,out发出指令,总线调用随后开始工作
第六步,当工作完成时,又会中断处理,然后唤醒进程,此时进程就会发现内存缓冲区已经有我想要的内容了,就可以继续工作了。

24、 从生磁盘到文件

核心:如何从文件得到盘块号

引入文件,对磁盘使用的第三层抽象

让普通用户使用生磁盘: 许多人连扇区都不知道是什么?怎么要求他们根据盘块号来访问磁盘…
因此需要在盘块上引入更高一层次的抽象概念! 文件

用户眼中的文件是字符序列(字符流)

磁盘上的文件:建立字符流到盘块集合的映射关系

以用户要去删除200-212字符为例,一旦发出这个请求,操作系统将解释这个请求,通过查找200-212字符对应的盘块位置,随后发出磁盘读写请求放到电梯队列上来实现这个请求。而操作系统寻找的这个过程,便是通过一个表的映射。因此这个映射可有以下几种结构实现。

1、连续结构实现文件
即连续的盘块来存放,只需要知道每个盘块能放多少个字符【初始化就已知】以及存放的初始盘块的块号就能通过相应字符流的第多少个字符算出相应的盘块。

需要下表FCB支持

但是其缺点就是:增加到一定的程度后,就需要覆盖或者整个地去挪动其他文件。因此适合直接读写,而不适合动态增长(相当于数据结构中的数组,不适合动态变化,适合顺序读取)

2、链式结构实现文件

链表实现

3、索引结构(Linux0.11所采用的方式)

专门找一个盘块来做索引(目录),因此找字符流,直接从索引盘块中计算即可。
如下面,假设一个盘块放一百个字符,那么0-99是在第9块盘符,而100-199就是在第10块盘符,以此类推。而下面的-1就是供随时扩展用的,因此解决了增减问题。

实际系统是多级索引

不同大小的文件,用不同的级数来索引(通常最多3级)。很小的文件一次读取进来即可,而中等大小的也只需要多读一次…

25、文件使用磁盘的实现

通过文件使用磁盘

过程如下:

(由文件名/路径名找到inode【下节内容】)通过inode,得到索引块,再根据索引块找到数据所在的盘块号。随后,用盘块号、缓存等形成request放入“电梯”。随后,磁盘中断时,从电梯中取出来,从盘块号得到扇区号,再通过公式算出三个参数,最后通过out发送到磁盘控制器上,磁盘控制器控制马达,电生磁,磁生电,形成数据,整个过程便结束了。

create_block算盘块,文件抽象的核心

26、目录与文件系统

文件,抽象一个磁盘块集合

磁盘文件: 建立了字符流到盘块集合的映射关系

文件系统,抽象整个磁盘(第四层抽象)

上面所讲述的,是一个文件(一个文件对应一个字符流)如何找到盘块来进行读写。而最后一层抽象,便是将磁盘抽象成一堆有组织的文件。【目录树】

在不同的机器中,通过应用结构+存储的数据可以得到那棵目录树,找到文件、读写文件,这个就是文件系统。(也因此,SSD和机械硬盘即便换机器,其内容也不掉)

最开始,所有文件放在一层(一个大集合),用户多了文件一多,找起来就会十分费事。

集合划分、分治,引入目录树,将划分后的集合再进行划分: k次划分后,每个集合中的文件数为O(logkN)

此时实现目录变成了关键问题

首先【目录怎么用?】用“/my/data/a”定位文件a,随后得到文件a的FCB(里面就有inode,就能找到盘块了)

实际上就是给我一个路径名(路径名就体现了树状结构)能够得到文件的FCB得到文件的inode,给我inode、给我读写位置就能找到盘块,给我盘块我就能放到电梯队列上…就与上节内容接上了。【路径名->FCB】

那么磁盘上应该存放什么信息来实现目录呢?由上面可知,我们应该可以由目录找到其下面的所有文件的FCB。但FCB因为对应着各个文件的盘块索引,因此通常FCB单单也是不小的,如果一次性把一个目录中的所有文件的FCB都读进来会浪费空间(磁盘操作也是比较慢的)。因此:只需要有个FCB的指针,根据文件编号,就可以找到FCB的位置。

每个目录的数据盘块存放着其文件以及子目录中FCB的位置。然后,再通过其FCB的位置,进入子目录的数据盘块,从而访问子目录下的文件或者子目录的子目录等…【从根目录开始,Linux中根目录“/”,没有人告诉我们根目录在哪,根目录必须固定好(总放在第一个位置)】

要想让整个系统能够自举,还需要存放一些消息。

引导块:扇区引导。
超级块:规定引导块,超级块,两个位图的大小,从而得到i节点的第一个位置,由此便可以得到根目录了
盘块位图:总共有多少个盘块,指哪些盘块空闲,哪些被占用。
i节点(inode)位图:新建文件时,从哪里申请inode;删除文件时,将哪些inode清除。

磁盘读写的完整过程

更多推荐

操作系统【入门篇】