操作系统学习笔记
操作系统
笔记来源于b站上哈工大李治军老师的课程:https://www.bilibili.com/video/BV1d4411v7u7
操作系统简史
- 1955-1965: 上古神机IBM7094,造价在250w美元以上。批处理,只运算,无停止
- 1965-1980:
- IBM OS/360(360表示全方位服务),此时计算机需要做多种操作(multiprogramming),用批处理是不合适的(必须要先完成I/O操作后才能做计算操作)。切换和调度应运而生,例如,IO任务执行比较慢,就可以跳转到计算任务。这是多进程结构和进程管理概念萌芽。
- MULTICS,由于计算机的使用人数增加,如果每个人启动一个任务,任务之间需要快速切换,分时系统(timesharing)。核心仍然是任务切换,但是资源复用的思想对操作系统影响很大,虚拟内存就是一种复用。
- 1980-1990: 小型计算机出现,个人可以使用。在PDP-7上开发出了UNIX,是简化的MULTICS,核心概念差不多。IBM推出了IBM PC,个人计算机开始普及,很多人可以使用并接触UNIX。
- 1990-2000: 小Linux于1991年发布,1994年Linux 1.0发布并采用GPL协议,1998年以后互联网世界里展开了一场历史性的Linux产业化运动。
总结:多进程结构是操作系统的基本图谱。
- 1975年Digital Research开发了操作系统CP/M,单任务执行。1980年出现了鹅80806 16位芯片,从CP/M基础上开发出了QDOS(Quick and Dirty OS)。
- 1978年,Paul Allen 和 Bill Gates开发乐BASIC解释器,并开创了微软。1977年Bill Gates开发了FAT管理磁盘。1981年微软买下QDOS改名为MS-DOS和IBM PC打包出售。
- 1989年,MS-DOS 4.0出现,支持鼠标和键盘。后来出现Windows 3.0,XP,Win 7 …
- 1984年,苹果推出Mac机,处理器使用IBM、Intel和AMD,核心在于屏幕、能耗等。2007年发布IOS,核心仍是MAC OS。MAC OS核心是UNIX,专注于界面、文件、媒体等和用户有关的内容。
总结:对用户的使用感倍加重视:各种文件、编程环境、图形界面。
因此课程任务就是:(1)掌握、实现操作系统的多进程图谱;(2)掌握、实现操作系统的文件操作视图。
操作系统接口
连接应用程序和操作系统,表现为函数调用,又称为系统调用(System Call)。
POSIX (Portable Operating System Interface of Unix) IEEE制定的标准族。
系统调用的实现
问题:为什么应用程序不能直接调用操作系统里的数据?
回答:因为如果可以直接随意访问会造成不安全的问题,那么就可以看到root的密码可以修改它,可以通过显存看到别人Word里的内容……
系统调用:提供一种能进入内核的手段
内存
分为内核态和用户态,将内核程序和用户程序隔离。内核态可以访问任何数据而用户态不能访问内核数据。
要知道当前程序执行在哪一态,要检查CS:IP(当前指令)的最低两位:0是内核态,3是用户态。CS寄存器中存储的是CPL。
- DPL(Descriptor/Destination Privilege Level)目标特权级:要访问的目标段的特权级,默认为0。
- CPL(Curren Privilege Level)当前特权级。
- 只有当DPL大于等于CPL(CPL数值更小)时才合法。
int 0x80中断
用户可以主动进入内核调用内核代码的唯一方法:中断指令int 0x80,将CS中的CPL改为0。
- 用户程序中包含int指令代码
- 操作系统写中断处理,获取想调程序的编号
- 操作系统根据编号执行相应代码
int 0x80中断处理过程:
- 初始化时,80号中断的DPL设为3,使得CPL=3的用户代码可以进入
- CPL根据段选择符处的8(1000的最后两位为00)被设置成了0,即进入内核
CPU的工作原理
CPU根据PC指向的地址取出内存中的指令,从总线传回CPU。CPU对指令进行译码,然后执行。然后如此往复。
并发(多道程序在一个CPU上交替执行):面对IO任务执行所需时间过长的问题,可以在等待磁盘完成读写操作之前切换到另一个程序执行。为了记录切出去之前的程序执行的状况和信息(例如PC、AX、BX等),要将这些信息存入PCB(Process Control Block)。
进程:运行的程序
- 进程有开始、结束,程序没有
- 进程有走、停,程序没有
- 进程需要记录ax、bx,程序不用
多进程图像
从启动开机到关机结束
-
main中的fork()创建了第一个进程
if(!fork()) {init();} //init一号进程执行了shell(Windows桌面)
-
Shell再启动其他进程
多进程如何组织
PCB+状态+队列:用PCB放入不同队列中,用状态转移推进
多进程如何交替
队列操作+调度+切换:
-
一个进程启动磁盘读写
-
将自身状态设为“阻塞态”:pCur.state = ‘W’
-
将pCur放入DiskWaitQueue
-
运行schedule(),完成切换
schedule() { pNew = getNext(ReadyQueue);//调度,找到就绪队列中下一要执行进程的PCB switch_to(pCur,pNew);//切换当前进程PCB与即将执行进程的PCB } swich_to(pCur, pNew) { pCur.ax = CPU.ax; pCur.bx = CPU.bx; …… pCur.cs = CPU.cs; pCur,retpc = CPU.pc; CPU.ax = pNew.ax; CPU.bx = pNew.bx; …… }
多进程之间的影响
不同的进程可能对同一地址有不同操作,这样交替进行会使得处理内容有误,所以需要通过内存管理(映射表)对多进程的地址空间分离。
多进程之间的合作
多个有合作的进程需要对同一个值进行修改后并同步,如果在未对该值的修改操作完成前就切换到其他进程,会导致出错。所以需要对该值上锁,在对该值进行写操作时阻断其他进程访问。
用户级线程
进程 = 资源 + 指令执行序列
线程是程序执行的最小单位,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间,所以各个线程之间的切换只需要修改PC指令,不需要映射表上的资源切换,切换速度比较快。
线程:指令之间的切换,保留了并发的优点,避免了进程切换的代价。
每个线程都有自己的栈,用于保存返回时的地址PC。栈的地址保存在TCB中。Yield()操作切换线程时要先切换TCB,切换栈。
内核级线程
进程的实质就是内核级线程。
核心级 VS 用户级
-
用户级:多个栈;核心级:多套栈
-
内核中的切换:switch_to
switch_to通过TCB找到内核栈指针,然后通过ret切到某个内核程序,最后用内核栈中的CS:PC切到用户程序,根据IRET切换到用户栈。
CPU调度策略
需要考虑的问题
- 吞吐量和响应时间之间的矛盾:响应时间小=>切换次输多=>系统内耗大=>吞吐量小
- 前台任务和后台任务的关注点不同:前台任务关注响应时间(切换需要快速,才能够及时响应当前进程),后台任务关注周转时间(切换如果过于频繁,在切换进程时的时间损耗会变大)
- 有的任务I/O操作频率高(IO约束型),有的任务是计算等CPU任务频率高(CPU约束型任务)。IO约束型任务的优先级会比较高,因为CPU约束型任务很少会需要切换进程,这样就会较长时间内在完成当前进程,而不能很好地利用系统资源。
调度算法
-
First Come, First Served(FCFS)
(1)按照作业提交,或进程变为就绪状态的先后次序分派CPU;
(2)新作业只有当当前作业或进程执行完或阻塞才获得CPU运行
(3)被唤醒的作业或进程不立即恢复执行,通常等到当前作业或进程出让CPU。(所以,默认即是非抢占方式)
(4)有利于CPU繁忙型的作业,而不利于I/O繁忙的作业(进程)。 -
短作业优先(SJF):有效缩短周转时间
(1)平均周转时间、平均带权周转时间都有明显改善。SJF/SPF调度算法能有效的降低作业的平均等待时间,提高系统吞吐量。
平均周转时间公式:
p 1 + p 1 + p 2 + p 1 + p 2 + p 3 + … … = ∑ i = 0 n ( n + 1 − i ) p i p_1 + p_1 + p_2 + p_1 + p_2 + p_3 + …… = \sum_{i=0}^n (n+1-i)p_i p1+p1+p2+p1+p2+p3+……=i=0∑n(n+1−i)pi
可以看到长作业越靠后被加的次数越少,总和也就越少(2)未考虑作业的紧迫程度,因而不能保证紧迫性作业(进程)的及时处理、对长作业的不利、作业(进程)的长短含主观因素,不一定能真正做到短作业优先。
-
Run Robin(RR) 按时间片来轮转调度
(1)时间片大:响应时间会太长;时间片小:吞吐量小
(2)时间片轮转算法过程:
1、排成一个队列。2、每次调度时将CPU分派给队首进程。3、时间片结束时,发生时钟中断。4、暂停当前进程的执行,将其送到就绪队列的末尾,并通过上下文切换执行当前就绪的队首进程。
说明:1、进程阻塞情况发生时,未用完时间片也要出让CPU。2、能够及时响应,但没有考虑作业长短等问题。3、系统的处理能力和系统的负载状态影响时间片长度。
schedule()调度函数
while (1) {
c = -1;
next = 0;
i = NR_TASKS; //任务结构数组长度,在0.11版本内核中为64
p = &task[NR_TASKS];//任务结构数组指针
while (--i) {
if (!*--p)
continue;//跳过任务结构数组中条项为零的不做处理
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i; //找到进程表(数据结构表)中counter值最大的进程
}
if (c) break;找到则退出循环,switch到该进程
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) //程序运行到这里则所有任务的时间片均用完
// 根据每个任务的优先权值,更新每一个任务的counter值
// counter 值的计算方式为counter = counter /2 + priority。
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
- 过程:找到就绪态任务中的最大counter,大于0则切换过去,否则更新所有任务的counter(右移一位,即除以2,再加上priority)。如此往复循环
- 说明:未在就绪态的counter会一直更新,等待时间越长,counter值越大,优先级越高。
counter的作用:
- 保证了响应时间的界
- 经过IO后,counter会变大,优先级就会提高。照顾到了前台进程
- 后台进程一直按照counter轮转,近似于SJF调度
- 每个进程只用维护一个counter变量
进程同步与信号量
为什么需要信号量
用生产者和消费者的例子:
上例中,如果只用一个信号counter来记录的话,会造成如下问题
所以还需要一个信号量来记录:sem为负,表示需要消费者,为正代表需要生产者
信号量临界区保护
临界区:一次只允许一个进程进入的本进程的代码段。读写信号量的代码一定是临界区。
保护原则:互斥进入,如果一个进程在临界区中执行,则其他进程不允许进入;有空让进,若干进程要求进入空闲临界区时,应尽快使一进程进入临界区;有限等待,从晋城发出进入请求到允许进入,不能无限等待。
Peterson算法
轮换法:一个turn值,P0进程的临界区在turn=0
时才执行,执行完将其置位1,而P1则在turn=1
时执行,执行完置位0。这种方法满足互斥进入要求,但是P0完成后不能再次进入,尽管P1并不在临界区,不满足有空让进原则。
标记法:通过两个布尔值,P0进程在进入临界区前,置flag0 = true
, 在flag1为真时进入等待,为假时进入临界区,执行完后将flag0置位false。P1进程在进入临界区前,置flag1 = true
, 在flag0为真时进入等待,为假时进入临界区,执行完后将flag1置位false。如果在P0进程执行完flag0 = true
后进入P1进程执行flag1=true
,再切回P0进程会进入等待,切到P1进程依然会等待,接下来将会无限等待。
Peterson算法通过标记和轮转并用的方法,以两个进程为例:
对于多进程依然是标记和轮转的结合,可以通过面包店算法,使每个进程都获得一个序号,离开时序号为0,不为0的序号即标记。但是这种算法比较复杂,所以人们想到了下面用硬件的方法来保护临界区。
关中断法
问题:系统是如何调度各个进程的?
回答:通过中断来调度。
所以可以通过阻止在临界区执行的进程调用中断来阻断被调用。但是该方法在多核时不管用。
硬件原子指令法
对于临界区的保护说白了就是要在进入临界区前上锁,出临界区后开锁。只不过,对于锁的操作如果是多条语句的话,在各个进程之间来回切换时会导致锁变量被修改。因此有了硬件原子指令法,使操作在一条语句中完成就不会造成上述问题。
boolean TestAndSet(boolean &x) {
boolean rv = x;
x = true;
return rv;
}
在进入临界区前调用 while(TestAndSet(&lock))
,出临界区时lock = false
。由于临界区前后对于锁的操作都是只有一条语句,不会被打断。
该方法依然是在多核时不管用。
死锁处理
死锁:互相等待对方持有的资源而造成谁都无法执行的情况。
造成死锁的四个必要条件:
- 互斥使用:资源固有特性
- 不可抢占:资源只能自愿放弃
- 请求和保持:进程必须占有资源再去申请
- 循环等待:在资源分配图中存在一个环路
死锁预防
破坏死锁出现的条件
- 在进程执行前,一次性申请所有需要的资源,不会占有资源再去申请其他资源
- 缺点1:需要预知未来,编程困难
- 缺点2:许多资源分配后很长时间后才使用,资源利用率低
- 对资源类型进行排序,资源申请按照顺序进行,不会出现环路等待
- 缺点:造成资源浪费
死锁避免
检测每个资源请求,如果造成死锁就拒绝
-
用银行家算法。如果系统中所有进程存在一个可完成的执行序列P1,……,Pn,则称系统处于安全状态。
以下表为例,初始状态可以用的ABC资源分别为230,所以得出安全序列为P1,P3,P2,P4,P0。
Allocation Need A B C A B C P0 0 1 0 7 4 3 P1 3 0 2 0 2 0 P2 3 0 2 6 0 0 P3 2 1 1 0 1 0 P4 0 0 2 4 3 1 银行家算法的核心是:
int Available[1..m]; //每种资源剩余数量 int Allocation[1..n,1..m]; //已分配资源数量 int Need[1..n,1..m]; //进程还需的各种资源数量 int Work[1..m]; //工作向量 bool Finish[1..n]; //进程是否结束 while(true){ for(i = 1; i <= n; i++) { if(Finish[i] == false && Need[i] <= Work) { Work = Work + Allocation[i]; Finish[i] = true; break; else { goto end; } } } } End: for(i = 1; i <= n; i++) if(Finish[i] == false) return "deadlock";
- 时间复杂度为O(mn2),资源消耗太大
死锁检测+恢复
检测到死锁出现时,让一些进程回滚,让出资源。一般是定时检测或是发现资源利用率低时再检测。
算法和银行家算法差不多,只是在最后End处有修改。
int Available[1..m]; //每种资源剩余数量
int Allocation[1..n,1..m]; //已分配资源数量
int Need[1..n,1..m]; //进程还需的各种资源数量
int Work[1..m]; //工作向量
bool Finish[1..n]; //进程是否结束
while(true){
for(i = 1; i <= n; i++) {
if(Finish[i] == false && Need[i] <= Work) {
Work = Work + Allocation[i];
Finish[i] = true;
break;
else {
goto end;
}
}
}
}//以上和banker算法一样
End: for(i = 1; i <= n; i++)
if(Finish[i] == false) deadlock = deadlock + {i};
但是在选择哪些进程回滚(是选择优先级高的还是占用资源多的?)和如何实现回滚(已经修改的文件如何处理?)的问题上处理十分棘手。
综上,实际上许多通用操作系统,如PC机上安装的Windows和Linux,都采用死锁忽略方法。
死锁忽略
就像没有出现死锁一样。死锁出现不是确定的,且可以荣国重启动来处理死锁
内存使用与分段
计算机工作的开始是将程序放到内存中,PC指向开始地址。物理地址=基址+逻辑地址
逻辑地址是已知确定的,但是基址是会变化的,每个进程的都不一样,如何定位以及在何处定位就成为一个难题。最合适的应当是在运行时重定位,每个进程各自的基地址放在PCB中,执行指令时第一步先从PCB中取出基地址,然后再加上逻辑地址得出物理地址。
一整个程序分成很多段,例如代码段、数据段等等。每段分别独立储存在内存上的空闲区间,每一段的基地址放在LDT表中。
内存分区与分页
内存如何分割?分区?
- ?固定分区:等分内存。但是因为段有大有小,需求不一样,所以此方法不可用
- !可变分区:根据实际所需内存大小进行分割。分割方法有最先适配、最佳适配、最差适配。
可变分区造成的问题
内存不断分区后会造成内存碎片,而如果现在需要请求的内存空间大于所有的碎片的长度,导致无法使用。这时就需要将空闲分区合并,需要移动段,内存紧缩。
为了解决内存分区导致的内存效率问题,引入了分页。
分页方法
针对每个段内存请求,系统以页为单位分配给段。页面尺寸为4K。
例:mov[0x2240], %eax
,逻辑地址0x2240如何计算得出物理地址?首先用2240除以4K得到2,得知在页2,根据CR3页表找到页框号,假设此处页框号为3,则得出物理地址0x3240。
多级页表与快表
为了提高内存空间利用率,每一页应当小,但是页小,页表项变多,页表就会变大。比如,页面尺寸通常为4K,而地址是32位的,所以就有2的20次方个页面,需要4M内存,系统中并发10个进程就需要40M内存。
尝试
由于实际上许多逻辑地址并不会用到,就引出了一种想法,只存放用到的页。
- 但是这样做会导致页表中的页号并不连续,在页表里根据页号查找页框号时需要比较查找,时间开销较大。
那么如何才能让页表既连续又让它占用内存少呢?
多级页表
页目录表+页表
每张表的大小:
2
10
个
目
录
项
∗
4
字
节
地
址
=
4
K
2^{10} 个目录项*4 字节地址 = 4K
210个目录项∗4字节地址=4K
总共四张表,一张一级页表,三张二级页表,所以需要空间是16K,远小于4M。
多级页表提高了空间效率,但是增加了访存的次数。
快表
TLB是一组相联快速存储寄存器,比较昂贵,可以存储最近使用的页号与其对应的页框号。这是硬件装置,可以通过硬件电路相联直接找到页号而不需要查找多级页表。如果命中失败,再去查找多级页表。
段页结合的实际内存管理
段的管理方式是程序分段再对应到内存,页的管理方式是分页后再对应到内存,想要结合这两种方式可以通过一个虚拟内存割出段,让段对应到页,页再真正地映射到物理内存。段面向用户,页面向硬件。
重定位(地址翻译)
-
分配虚存,建段表
fork()调用sys_fork调用copy_process
//在linux/kernel/fork.c中 int copy_process(int nr, long ebp, ...) { ... copy_mem(nr,p); ... } int copy_mem(int nr, task_struct *p) //*p就是pcb表 { unsigned long new_data_base; new_data_base = nr*0x4000000; //64M*nr set_base(p->ldt[1],new_data_base); set_base(p->ldt[2],new_data_base);//每个进程占64M的虚拟地址空间,互不重叠 }
-
分配内存,建页表
//接上面的代码 int copy_mem(int nr, task_struct *p) { unsigned long old_data_base; old_data_base = get_base(current->ldt[2]); copy_page_tables(old_data_base,new_data_base,data_limit); } //这一段没听懂
为了父子进程物理内存公用,又不会相互影响,在子进程copy完父进程的内存后,写在与父进程不同的页上。
内存换入换出
虚拟内存就是将用到的资源复制进内存中处理,没用的不分配内存,这样就会减少内存的占用情况,给用户内存很大的错觉。
内存换入
- 根据逻辑地址算出虚拟地址:页号+偏移。
- 如果查找页表没有该映射,则需要调页,出现缺页中断(14号中断:Page fault,页不在内存)。找到内存中的空闲页,建立映射。
- 根据页表中的映射找到实际的物理内存地址。
内存换出
内存是有限的,必须要选择一些页淘汰,换出磁盘。这样才能有空闲的内存空间分配给新的要用的进程。
-
FIFO页面置换
举个例子:
换页过于频繁,相比较之下C的使用比较少,可以考虑将C换出,这就引出了MIN算法
-
MIN页面置换:选择最远将使用的页淘汰
同样用上面的例子,模拟后发现只导致了5次缺页。但是这种方法需要预知未来,这并不合理。
-
LRU页面置换:选最近最长一段时间没有使用或最少使用的页淘汰
同样用上面的例子,发现也只有五次缺页。
- 用时间戳:每页维护一个时间戳,选择最小时间戳的页淘汰。但是该算法会导致,每次访问地址都要修改一次时间戳,还有进行比较最小值,实现代价太大。
- 用页码栈:选择栈底页淘汰。每次访问都要修改栈,实现代价依然很大
所以LRU算法准确实现的代价太大。
-
LRU近似实现:将时间计数变为是和否。
SCR ( Second Chance Replament ):每个页加一个引用位,每次访问一页,硬件自动设置该位为1。在选择淘汰页时,挨个扫描,是1时清0,是0时淘汰该页。
上述方法也有弊端,在缺页很少的时候,所有的R=1,循环队列扫描时会旋转一圈后,将调入页插入循环一圈前的起点,这时就会退化成FIFO方法。
方法改造:定时清除R位。这时还需要一个移动速度快的扫描指针。
-
给每个进程分配多少页框(帧frame)?
分配的太多,请求调页导致的内存高效利用就失去了作用。分配的太少会导致颠簸(磁盘内不停地换入换出,所以CPU只能多次等待,利用率降低)现象,在进程数达到最大值后,CPU的利用率会突然急剧下降。所以需要找一个折中页框值。
I/O
终端设备包括显示器和键盘(其实还有鼠标等等其他的)。
显示器
让外设工作的原理:CPU给外设中(显卡、磁盘)相应的寄存器或存储发一个指令,相应的控制器操作电路设备显示在屏幕上。然后控制器会向CPU发中断信号表示已经完成,CPU再处理中断。
向设备控制器的寄存器写的过程,需要查寄存器地址、内容的格式和语义…所以操作系统要给用户提供一个文件视图。
老师在这一段讲了很多代码,但是感觉不太基础也很琐碎就不列出来了。
键盘
对于使用者:敲键盘、看结果。
对于操作系统:等待敲键盘,一敲键盘就会中断(21号中断),根据扫描码得到ASCII码,放到read_q队列中,经过转译处理放入secondary中。
磁盘管理驱动
CPU向磁盘控制器中的寄存器读写数据,盘控制器完成工作后向CPU发送中断信号。
生磁盘的使用
磁盘访问单位是扇区,每个扇区大小为512字节。磁盘的结构示意图如下:
读/写磁盘上1个字节的过程:控制器->寻道->旋转->传输。
- 将磁头移动到指定的磁道上,磁道开始旋转。
- 旋转到相应扇区位置,磁信号变成电信号,将电信号读到内存缓冲区。
- 写或修改该字节后,通过电生磁,将修改的一个字节写到磁盘中。
最直接的磁盘使用法
要确定具体的读写位置需要往控制器中写入柱面、磁头、扇区、缓存位置。
但是这种方法需要知道的信息太多了。
通过盘块号读写磁盘(一层抽象)
磁盘驱动负责从block计算出cyl©, head(H), sec(S)。扇区编号连续顺序排放。
盘块号 block = C * (Heads * Sectors) + H * Sectors + S
由上式知道扇区 S = block % Sectors
示意图:
多个进程通过队列使用磁盘(第二层抽象)
磁盘调度目标是平均访问延迟小。具体使用的磁盘调度算法应该是什么?
-
FCFS(先来先服务)
弊端:可能磁头原位置在60,移动到了一个指定位置160后又需要在下一个处理进程移动回61,这样会浪费掉很多时间。考虑是否可以先处理邻近的扇区。
-
SSTF(Shrtest-seek-time First)
弊端:一般会对磁盘中间磁道的访问比较频繁,这种算法会导致磁头反复在磁道中间部分滑动,两端的请求很有可能会被一直滞后处理,导致饥饿问题。
-
SCAN
SSTF+中途不回折:一路向磁道的端移动,一边移动一边处理相应位置的请求
-
C-SCAN(电梯算法)
SCAN+直接移到另一端:电梯也是如此,如果有下楼请求,先移动到最高楼层接乘客,再一楼一楼下降接乘客。
生磁盘的使用整理
- 进程得到盘块号block(根据文件得到),根据上面的公式算出扇区号S
- 用扇区号和需要用到的内存缓冲区地址作出磁盘请求
make req
,用电梯算法add_request
放入请求队列 - 进程
sleep_on
(接下去的动作都由硬件完成) - 磁盘中断处理
do_hd_request
,算出cyl, head, sector hd_out
调用outp
完成端口写end_request(1)
唤醒进程
从生磁盘到文件
用户在实际使用中不会使用盘块号,所以没有办法用生磁盘。所以需要在盘快上引入更高一层次的抽象概念,将生磁盘变为熟磁盘,即文件,这样使用起来更为直观使用更方便。
过程:根据文件形成映射表FCB,按顺序将字符存放到磁盘上,根据映射表得到磁盘上的位置即可取得字符。
上面按顺序存放字符流的方式适合于顺序读写,但是不适合于动态修改的文件。所以文件的映射还有不同的方法。例如,链式结构,存取慢,但是动态修改快;索引结构,读写和动态修改都比较快。
实际系统中用的是多级索引,这样既可以表示很大的文件,又可以保证读写修改的速度。
代码实现
在fs/read_write.c中调用了file_write函数。
file_write(inode, file, buf, count)
的工作过程:
- file参数中的读写指针是开始地址,count是指明字符数量。确定了是哪一段字符。
- 通过调用
create_block
函数根据inode找到要写的盘块号。 - 用盘块号、buf等形成请求根据电梯算法放入队列。
目录与文件系统
-
树状目录
-
磁盘中除了上面图中的FCB数组和数据盘块合集还需要存放
-
inode节点位图:哪些节点空闲,哪些被占用
-
盘块位图:显示哪些空闲,不同硬盘大小这个位图的大小也不同
例如:0011110011101表示磁盘块2、3、4、5、8、9、10、12空闲
-
超级块:记录节点位图和盘块位图和超级块的大小,这样就知道在哪个区间查找了。
-
引导块:其长度区间是固定的
-
目录解析代码实现
//在linux/fs/open.c中
int sys_open(const char* filname, int flag)
{
i = open_namei(filename, flag, &inode);
....
}
int open_namei(...)
{
dir = dir_namei(pathname, &namelen, &basename);
...
}
static struct m_inode *dir_namei()
{
dir = get_dir(pathname);
...
}
get_dir完成真正的目录解析:
static struct m_inode *get_dir(const char *pathname)
{
if((c = get_fs_byte(pathname)) == '/') {//如果文件路径以/开头
inode = current->root;//代表以当前目录为根
pathname++;
}else if(c) {
inode = current->pwd;
}
while(1) {
if(!c) return inode; //函数出口
bh = find_entry(&inode, thisname, namelen, &de);//从目录中读取目录项
int inr = de->inode;//inr是目录项中的索引节点号
int idev = inode->i_dev;
inode = iget(idev,inr);//读下一层目录
}
}