[Common] 操作系统概述

最近心血来潮趁着端午又学了一遍操作系统:https://www.icourse163.org/course/HIT-1002531008

什么是操作系统?

如果我们想让计算器print hello world经历了神马呢?需要CPU告诉内存把内存里面的字符串放到显存里面,这个过程需要各种指令非常麻烦,但我们写程序其实只是用了print指令即可。

所以操作系统就是在应用程序和计算机底层硬件(cpu、显存、内存、磁盘、文件、网络、电源、多核等)中间的一层,让我们更方便的使用硬件,不用自己实现底层的命令之类的。


开机的时候发生了啥

【图灵机】

图灵机就是从纸带上读取命令执行,例如纸带上写"3, 2, +",那么图灵机就会执行3+2然后print5在纸带上面。

【计算机如何工作的:取址执行】

计算机其实也是这样的,需要读取控制程序,放到内存里面,用一个IP指针指向当前执行的命令,放入到CPU执行,执行完以后下移IP指针载入下一条指令到CPU。

程序载入

【RAM与ROM】

可参考:https://blog.csdn.net/qq_33458228/article/details/80801811

对于电脑而言,RAM和ROM都是内存,不是硬盘外存。

随机存取存储器(random access memory,RAM)是与CPU直接交换数据的内部存储器,也叫主存/内存。它可以随时读写,而且速度很快,通常作为操作系统或其他正在运行中的程序的临时数据存储媒介。

只读存储器,英文简称ROM。ROM所存数据,一般是装入整机前事先写好的,整机工作过程中只能读出,而不像随机存储器那样能快速地、方便地加以改写。计算机中的ROM主要是用来存储一些系统信息,或者启动程序BIOS程序,这些都是非常重要的,只可以读一般不能修改,断电也不会消失

RAM和ROM相比,两者的最大区别是RAM在断电以后保存在上面的数据会自动消失,而ROM不会自动消失,可以长时间断电保存。

在手机里面,RAM就是跟电脑一样的运行内存一样,而ROM就不一样了,如果只用来存储一些系统信息和开机引导程序,ROM不需要几个G的容量的。其实手机的ROM就跟硬盘挂上钩了,手机中的ROM有一部分用来存储系统信息,还有一些装机软件,剩余的大部分容量都是就是拿来作为硬盘用的,可读可写。

【打开电源以后计算机执行的第一条指令】

① 开机的时候其实内存还有一部分是固化的,因为计算机其实就是从内存取址执行工作的,如果内存空空如也计算机就不能work了呢。

所以一开机会先从ROM的BIOS(Basic Input Output System,基本输入输出系统")的0xFFFF0的固化程序里面取址,此时的段寄存器CF=0xFFFF,偏移IP=0x0000。
(实模式寻址 = CS左移4位 + IP)

这段代码主要是检测RAM、键盘、显示器、软硬盘。然后将磁盘的0磁道0扇区(引导扇区)的512字节一个扇区的内容读到内存0x7C00的位置(此时CF=0x07c0,IP=0x0000),开始执行引导扇区的512bytes的内容。

② 引导扇区的代码就是汇编代码bootsect.s,因为汇编可以控制内存位置但是高级语言C之类的不可以哦。这段代码会再从第二扇区开始读4个扇区的setup代码,放到内存的bootsect的上面(底部地址低)。以及显示loading之类的。

磁道内容顺序

setup代码做了神马呢?获取了物理内存的大小,保存起来,因为其实操作系统就是要管理内存~ 还要读入其他的比如光标位置、显卡参数等。并且读入操作系统放入0地址开始处,所以内存中操作系统都从0开始,并且也是为了给操作系统留位置才把bootsect放入0x7C00开始处

操作系统开机其实就是两个事儿:读入系统 & setup(读了一些参数、启动保护模式、把内存指针跳到了0地址执行system代码)

setup最后一句执行了进入保护模式,因为实模式最多只能16位左移4位=20位的寻址(1M),但是计算机其实都已经4G啦,所以为了可以更有效的利用,切换到32位模式即保护模式。

保护模式寻址通过GDT,CS是table的索引,从table中取第CS个数据+IP即真正的地址,这个table就是GDT (Global Descriptor Table),全局描述表,setup会初始化这个表,这个时候通过CS拿到的段基址和IP都是32位啦。

system模块的第一个部分就是head.s,做了初始化IDT (Interrupt Descriptor Table)表,将每个异常或中断向量分别与它们的处理过程联系起来。与GDT和LDT表类似,IDT也是由8字节长描述符组成的一个数组。然后跳到了main函数。

main函数永远不会退出,初始化硬盘、键盘、内存之类的(之所以要初始化因为操作系统就是管理计算机硬件的系统),例如初始化内存就是把内存记录在memmap里面,每4K作为一个index方入memmap的表里面,记录这片儿内存用了么。

  • 拓展栈与栈帧:https://blog.csdn.net/qq_24521983/article/details/54894525

操作系统的接口

比如printf,也叫系统调用,调用这些由系统提供的函数以后,操作系统就会干活儿。(但其实printf不是系统调用,它包装了write调用哈)

系统调用举例

POSIX表示可移植操作系统接口(Portable OperatingSystem Interface of UNIX)POSIX标准定义了一个操作系统应该为应用程序提供的接口标准。这样上层程序可以在不同的底层系统上跑起来~

Q: 为什么会有系统调用呢,如果都是在内存里面为啥不能和普通函数那样直接jump呢?
A: 因为很多东西很重要例如root用户的用户名密码,这些东西不应该被随意访问即使都在内存中,都是存在内核区的,所以需要系统调用才能跳到内核区

如何做到不允许直接jump到内核区的呢,其实是通过硬件实现的~ 内存会分为内核段 & 用户段,两段的特权级别(PL)分别为0和3,数字越小特权级别越高,只能跳到PL更大的地方,所以不能从PL为3的用户段直接跳到PL为0的内存段

内核态如何不允许直接jump进入的

中断(int 0x80)是进入内核的唯一办法,系统调用其实就是包含中断的代码,不同系统调用会通过调用号区分进入后要干啥~

int 0x80的执行需要先从IDT表里面查询80号中断的内存地址,这个时候DPL也就是目标内存位置的特权级别是3,所以可以跳过去执行80号中断,一旦执行就会进入内核态并把CPL也就是当前的特权级别降为0。

sys_call_table是函数调用表,进入内核以后,可以根据调用号来查找要执行啥~

系统调用

多进程

【CPU工作原理】

CPU将需要从内存上面取的指令的地址放到地址总线,内存收到取址指令以后把对应地址的指令拿出来,放到总线上面,CPU收到以后就会解释拿到的指令并执行。然后执行完再拿下一条指令。

  • 一个小tip,I/O指令的执行时间是普通指令例如计算指令的10的6次方倍数。

那么当我们正常执行的时候遇到了I/O耗时指令会怎样呢?如果一直等待那么CPU的利用率就很低,所以应该先切出去做点别的事情~

多道程序交替执行 (DEV就是设备干活)

并发:同时出发,交替执行

在切到另外的任务的时候需要保存当前执行的现场,这就是进程控制块(PCB, Process Control Block),等切回来的时候就可以继续运行了。

交替执行切换

进程:运行中的程序,需要用PCB记录切换中的状态。

【多进程图像】

多个进程交替执行,提高CPU利用率。

系统启动的时候执行的main最后就是通过fork创建了主进程,然后init。init里面创建了shell或者启动了桌面。shell里面会等待用户输入,一旦用户输入了cmd就启动一个新的进程去执行命令。
(Windows的任务管理器里面可以看到各种进程,它本身也是一个进程哦)

schedule调用可以让出时间片,切换到其他进程。

启动创建进程

操作系统组织进程依赖于PCB,会有一个当前正在执行的进程PCB;然后会有一串儿如果拿到时间片可以立刻执行的进程PCB(就绪队列,就绪态);另外会有一串儿即使有了时间片也不能立刻执行,在等待着一些其他例如I/O之类返回的PCB(等待队列,阻塞态)

PCB的组织
【多进程内存隔离】

如果两个进程在交替执行,都在操作同一块内存,进程1把A写入了100的地址,进程2又把B写入了100的地址,这个时候进程1又去操作就会有问题。

所以多进程的内存应该分隔开,通过映射表给不同进程分配不同的内存区域。这样对进程1的100地址对应的实际地址可能是700,而进程2的100地址对应的实际地址可能是800。

如果真的类似生产者消费者的多进程矛盾问题,就用lock临界区来解决~ 例如两个进程都往一个打印队列里面塞任务,切换的时候可能会覆盖彼此的任务。

【用户级线程】

线程:就是在一个进程里面切执行的指令,但是内存映射表不切换。也就是同一个进程的多个线程共享同一内存资源。切换速度会比切进程快,因为不用切换内存映射表

举个例子说明一下线程切换的意义,例如你打开网站,会有接收数据的线程、加载显示图片的线程、UI线程吖之类的。

如果变成一条线顺序执行,就得等到全部下载文本结束,再下载图片显示,这样就非常慢了。所以才要并行执行这些任务。这些任务需要公用一处内存,毕竟下载下来的如果需要显示,就不能用不同进程做~

create可以创建线程,yield会让出当前线程的时间片给其他线程。跳转到其他位置的时候要把当前位置下一条要执行的指令的内存地址压栈,当返回的之后弹栈继续执行后面的。(related topic:尾调用优化)每个线程会有自己的栈哦~

线程切换

yield切换会先切换栈~每个线程有个TCB结构用于存放自己的栈。线程控制块(Thread Control Block,TCB)是与进程的PCB相似的子控制块,只是TCB中所保存的线程状态比PCB中保存少而已。

create就是创建了三样东西:申请了一段内存作为TCB,然后申请一段内存作为栈,将程序的初始内存位置放入栈,绑定栈与TCB。

用户级线程的是在用户态的,假设进程1有多个线程,其中有一个线程要访问网络那么就要去找硬件网卡,也就需要访问内核,于是进程x就进入了内核,这个时候如果它阻塞了,内核态是不知道进程1的其他线程的,于是它就会把时间片给进程2。如果没有进程2就会一直卡在内核里面直到网卡请求返回。

于是用户级线程的问题就是,当一个进程里面的某个线程进入内核并卡住,它里面的其他线程都无法拿到时间片,也就都卡住了。

用户级线程的问题
【核心级线程】

核心级线程的TCB和创建是在内核的,所以可以更好的切换,并发性更好。

核心级线程

多处理器:每个CPU有自己的内存映射,类似进程
多核:多个CPU共同share一套内存映射,类似线程(并行)

多核是需要支持核心级线程的,因为CPU的分配其实是硬件part,由内核态决定,如果只是在用户态切来切去,内核态是不能感知多线程并分配不同CPU给他们的,所以需要核心级线程才能充分发挥多核的优点

多个CPU可以同时执行多个任务,这就是并行。并发与并行的区别就是:并发是同时出发交替执行,并行是同一时间可以共同执行

  • 核心级线程的实现?
    核心级线程在内核也需要做函数调用,所以需要内核栈;因为它也可以运行在用户态,所以还需要一个用户栈,于是核心级线程有一套栈(用户栈+内核栈),也就是一个内核中的TCB关联了两个栈,切换核心级线程的时候是切换了一套栈。

一旦触发 INT 中断就会进入内核栈,同时把在用户栈的内存位置 & 指令运行位置之类的保存到内核栈,触发 IRET 的时候会从内核栈切换回用户栈。

内核栈与用户栈

进程的执行切换其实是内核级线程的切换哦(进程在内核,因为需要访问硬件资源)~ 所以核心级线程切换用schedule而非yield

【创建进程】

进程的创建需要先申请1页内存,就是从memmap里面找空的index,用作PCB;然后创建用户栈以及内核栈;创建TCB并绑定。

创建process
【CPU调度】

调度就是当我们schedule让出时间片的时候让给谁?

目标:同样时间完成的任务多;一个任务从开始到结束时间少;用户操作响应快。(彼此矛盾,响应快切换就要频繁,吞吐量就小)

I/O约束型任务:I/O操作耗时多,CPU任务少,优先级应该高,因为运行了一小下就要切出去给I/O了。(前台任务)

=> 几种调度方式:

  • 先来先给时间片(FIFO)
  • 短作业优先(周转时间最小)
  • RR时间片轮转(响应时间小)

final:用counter作为优先级 & 时间片,每次schedule都是找到counter最大的进程去执行。

每次schedule都会将当前的所有进程的counter / 2 + 初始优先级,这样肯定比原始值高,所以阻塞中等待时间越长,counter越大。(实现了I/O约束型先拿到时间片)

【进程同步与信号量】

例如司机线程需要等售票员进程售票结束以后的信号,才能开车到下一站,这就是进程同步

其实和iOS里面的信号量类似,会有一个信号量记录了等待的进程个数,每次需要signal的时候先看有木有等待的进程,如果有就signal。


内存管理

【内存分段】

计算机的取址执行需要先把指令放到内存里面,也就是把编译后放在磁盘上面的汇编代码挪入内存。

程序存在内存里

当程序放入内存的时候,跳转的偏移量需要根据程序在内存中的地址来修改,也就是重定位。例如call 40是想执行偏移为40的main,就需要改为call 1040,因为放入内存以后,main放到了1000。

那么什么时候做重定位呢?如果编译的时候做,那么就要在编译的时候知道哪段内存是空着的,但有可能执行的时候这段内存已经不空了,如果这么做就需要预留内存给编译的程序。(嵌入式系统经常酱紫,静态的系统);而对于动态系统,会在载入内存的时候做~

但是程序放入内存以后也可能会换位置,例如阻塞进程的时候,可能会把进程的程序换出放入磁盘,因为内存是有限的。

所以需要运行时重定位 (地址翻译),真正程序运行的时候再把偏移改为实际的内存地址。基址放入PCB,执行的时候先从PCB里面拿出基址

那么一个程序是放入一个位置的么?不是的。程序会有很多部分,例如代码、变量、栈。这些东西会分别放入内存的不同位置,对于本身而言都是0地址开始的。这样不同段的读写权限不一样更方便隔离防止误操作。

分段
分段
  • 分段优点:
    这么做的话,假设栈的位置不够了,可以单独换出栈,不用把程序代码之类的也换出去。
    所以PCB里面会有个段表,每个段都有一个基址,其实就是类似GDT表(OS对应的段表),每个进程的这个表叫LDT
【内存分区与分页】

编译时把程序分段 -> 在内存中找空闲区域 -> 读入内存 -> 填写LDT表

这个part就是如何在内存中找空闲区域~ 这里涉及两种方法:固定分区与可变分区,固定分区就是把内存分成等大小的N份;可变分区就是问对方要多少给多少。

可变分区的记录

当有内存换出去了,又有内存申请的时候要怎么做呢?
(1)首先分配:从空闲表里面第一个开始拿,不用把表里面的都遍历一遍,快
(2)最佳分配:空闲大小最接近的分配,内存会越来越碎
(3)最差分配:最不接近的分配,内存会相对均匀

实际物理内存是用分页解决分区低效的问题的

可变分区会把内存弄的非常碎,如果请求的内存大小比较大,那么可能即使加到一起空余的位置够但是没有连续的内存了就给不出去。如果这个时候移动已经被占据的内存挪到一起空出一片的话(内存紧缩),上层进程是需要等待挪动结束才能继续运行的,就会空等死机

  • 那么我们可以把需求的内存打散,分成一块儿一块的,不是一整块儿的给需求者。

将物理内存分为一页一页的,就是开机的时候的memmap,每4K一页。这种情况一个段最多也就浪费一页4K就还好,可以避免内存紧缩。

将一段分成几页存储不同的位置

现在考虑之前的跳转偏移问题,之前是只要一个基址就可以了,但现在一个段被放到了很多个不同的页,那么要怎么算出来实际需要跳到哪里呢?类似LDT的段表,这里其实就是借助了页表CR3,先算是第几页,找到页地址,然后计算实际地址(这些事情MMUMemory Management Unit的缩写,中文名是内存管理单元会自动帮忙做)

跳转计算

从用户的角度其实需求就是分段,而从内存利用率的角度,会希望分页。酱紫就形成了现在的内存管理。

【页表】

如果为了提高内存利用率,应该让每页尽量小,但这样页表就会更大,如果每页4K,假设4G内存,就需要4G/4K=1M的页数,页号最长20位,也就需要4byte,那么页表至少4M的内存才够。如果有10个进程,就要有40M内存

但事实上大多逻辑地址用不到的(这里我理解的是我们的汇编代码可能不连续,比如main只占了0-100,后面就jump到500了)。所以如果用不到的就要从页表里面删掉,这样就用不了1M项目了。

页表优化尝试1

那么查找的时候就不能O(1)直接拿到了,用二分法找的活最快也要logn的时间找到。也就是如果想找第2^20页,就要执行20次访问页表的操作,也就是每jump或取一个指令地址要访问内存20次非常的费时。所以页表还是需要连续

如何让页表连续还能占用少内存呢?
多级页表:类似章节目录,查找的时候可以先找章,再找节,减少了查询的时间。页目录项 + 页表

多级页表

页目录用10个bit,也就是2^10 = 1K个章节数,每个章节的长度是4个字节,也就是用了4K的位置。如果其中有3个章是有内容的,也就是有节表的,就会再有3个4K的位置,一共就是4个4K。这么做既保证了页表连续,体现在章表中没有用到的地方也是空着被占用的;又保证了空间少,节约了没有用到的节表。

  • 注意哦,多级会多一次的内存访问因为需要先差章数。故而会增加一些时间,如果是64位系统增加更多的级,就会造成更多的查询时间。=> 快表

TLB快表,用寄存器存储最近访问的几个页存在了哪里。因为寄存器比较昂贵,所以不能存很多,条目在64-1024比较好。(可以通过硬件保证O(1)的查询时间)但可以避免多级页表的查询了,如果不在快表再查多级页表,并且查了以后放入快表。

快表
【虚拟内存——实际内存管理】
虚拟内存——面向软件是段,面向硬件是页

虚拟地址 = 段表 + 偏移
物理地址 = 页表 + 偏移

所以现在建立一个进程的时候,需要建立段表 & 页表。程序载入的时候,先通过分区算法将虚拟内存划分出程序的各个段,放入段表。然后在物理页里面找空页,把程序放入,填充页表。重定位的时候就是先通过基址和偏移计算在虚拟内存中的位置,再找物理地址。

linux 0.12规定不同进程公用一个虚拟内存,每个进程用连续的64M的位置,互不重叠,因此虚拟地址不会重复,就可以公用一个页表。

但现在,对于每个进程的虚拟内存都是一样的4G,只是映射到不同的物理地址。

【内存的换入换出】

如果实际物理内存是1G,但是虚拟内存是4G,那么当我们最开始用3-4G,之后用0-1G的时候,就需要先把之前用的内存换出来,让物理内存空出来。

内存请求的时候再做换入,而淘汰哪页可以采用FIFO或者LRU淘汰最不经常使用的part。


设备驱动与文件系统

【显示器】

让外设工作就是给外设的寄存器(设备控制器)写入指令,例如显示器的话对应的就是显卡。

给显卡写内容显示

CPU在给外设发完指令让外设去干活以后,如果外设做完了就会给CPU发一个中断告诉它自己做完了,CPU会进行中断处理。

不同的设备会对应不同的设备文件,也就是/dev/xxx,例如printf就是给显卡文件写入内容:

printf的代码

这里的fd是不同的文件holder,也就是不同外设控制器,所以write的第一个参数区分了写入到哪个外设

设备驱动就是将"写入某个设备的函数"注册到函数表里面,这样调用write的时候其实是先把内容写到缓冲区,由设备驱动来out到真正的外设控制器里面。

【键盘】

用户敲击键盘会触发中断,将敲击的ASCII码放入缓冲区,上层应用通过scan缓冲队列读入用户的输入。

【磁盘】
磁盘的结构

磁盘的读写单位是扇区,每个扇区512个字节。读写的时候就是通过移动磁头到指定磁道,旋转磁盘到相应扇区进行读取和写入。

定位一个扇区:柱面C + 磁道H + 扇区S
为了减少寻道时间,最好相邻扇区放在同一个磁道,所以一次应该读几个连续的扇区,也就叫做盘块儿block,一个盘块=2个扇区。

扇区号 = C * (Heads * Sectors) + H * Sectors + S

因为有多个进程,所以如果他们都需要访问磁盘,就会有一个请求队列。(生磁盘就是通过盘块儿使用磁盘,熟磁盘就是通过文件访问磁盘)

多个进程

这个时候就又涉及了调度,因为会有一个队列放着需要寻道的位置,如果按照先来先找,就很容易会来回寻道费时。所以调度可以用最短的磁道变化、电梯算法单向行走不折返等方式。

【文件】

用户使用磁盘要知道盘块号是非常难的,所以引入了文件。所以文件就是从字符流映射到盘块

所以需要一个FCB存放这个文件存放的起始块号,其他位置都可以通过除以盘块大小得到实际盘块号。

FCB

那么如果一个文件不断增加到会和别的文件重叠,这个时候就需要整体移动到别的区域了,就非常费事。

那么可以用链表的形式实现映射,这样就可以灵活的用分散的盘块存储,这种就让第一块末尾写上下一块盘块号,这样寻址会很累虽然空间灵活。

那么就考虑另一种方式:索引,其实就类似段表页表也是用一个表存储盘块号,这个表就放在一个索引块里面,而索引块的block号可以放在FCB里面~ 这样就每次先读索引块,就可以找到真实的块号了~

索引映射

实际系统的文件到盘块的映射就是通过离散的盘块存储 + 多级索引实现的~

【目录与文件系统】

文件系统其实就是一个树状结构,这个树状结构是存在磁盘里面的盘块上的,所以当你把硬盘拿出来放到另一个电脑上,会发现文件结构都没有变。

那么文件树怎么实现的呢?


树状目录实现

FCB数组:就是将所有盘块的FCB信息都集中到一个数组中
数据盘块:在每个数据盘块里都包含一些目录项用来找到子目录,目录项也就是文件名+对应的FCB的”地址”,也就是去之前的FCB数组中找到相应的FCB

所以可以从根目录开始,根目录的FCB约定好放在数组的第0个(第0个的位置需要有地方存)。在磁盘进行格式化的时候,会存放一些信息用来知道一些磁盘信息和找到根目录。

磁盘结构.png
  • inode位图: 哪些inode空闲,哪些被占用
  • 超级块:记录两个位图有多大等信息
  • 盘块位图: 哪些盘块是空闲的,硬盘大小不同这个位图的大小也不同
读一个文件的过程

你可能感兴趣的:([Common] 操作系统概述)