操作系统核心功能(一)

发布于 2021-09-21  129 次阅读


引文

这件事还得从操作系统诞生之前说起。当时的计算机就和单片机一样,程序直接放进内存,然后就开始跑。之后随着计算机性能的增强,人们想在计算机上面同时运行多个程序,这种时候只能靠修改代码,把多个代码整合起来,并且设计了一个切换机制,在不同的程序段里头切换。

之后人们就把这个切换机制提取出来,单独变成一个程序,其他的程序由这个切换程序来进行加载运行。这个切换程序就是后来的操作系统。

这时候就有很多问题出现了,切换程序要按照什么逻辑进行切换?其他程序要怎么被加载?这些就是现代操作系统的关键设计。

内存管理

原初时代的君子协定

最开始的操作系统十分简单,只负责把程序加载进内存,相当于一个加载器,与其他程序没有任何区别。操作系统一但加载完成,就没他啥事了。之后就是这些被加载的程序干活去了。

可以说在这个时代,所有的一切对程序都是透明的,他们可以使用所有的内存而不受限制,可以尽情的使用cpu。但是程序员们为了爱与和平,不能让自己的程序一直独占着CPU,自己把活干完了就主动退出,把资源让给有需要的人。这就是所谓的君子协定。

恶性事件发生

程序员们遵守着这个君子协定,各种程序相安无事。但之后随着程序越来越多,越来越复杂,超出程序员控制的事情就发生了:

有的程序在运行的时候特别需要内存,不停地占用内存,最后把别人正在用的内存给修改了,导致其他程序直接boom。

加载程序的时候,程序会告诉加载器自己要被加载到内存的哪个位置,结果有两个程序的位置重叠了,同样直接boom。

程序之间互相不知道对方的存在,但却可以操作所有的资源,这才导致了这些恶性事件发生,有什么办法解决么?

统一的联合政府(操作系统)

在计算机领域,可以通过增加一个中间层来解决很多问题,这时候操作系统作为调节硬件资源和程序的中间层出现了。

首先是等级(权限)的改变,此时的操作系统不再是一个普通的程序了,操作系统拥有着最高的权限,对所有资源进行管理。而其他程序的权限被限制,他们依旧可以自己干自己的活而不受限制,但是当涉及到资源的分配的时候,必须去找操作系统申请,而自己是没有权限的。

其次是资源的统筹规划和隔离,此时的操作系统执掌生杀大权,它知道这个计算机上面有多少程序,知道有哪些内存是被占用的。所所以对于程序的内存分配请求,他就能准确的找出一块没有被使用的内存分配给程序。而程序只需要关系自己的那一亩三分地,即自己分配到的内存即可

操作系统对内存的管理机制就这样初具雏形了。

虚拟内存空间

由于现在操作系统全权管理内存,所以程序的加载位置已经不用程序自己去关心了。此外新分配的内存也是同理,程序只管用就行。所以这就诞生了虚拟内存的概念。

以32位系统为例,能够寻址的内存范围就是4G。每次向操作系统申请内存,操作系统就返回一个可用的内存地址。但是这个地址通常是不连续的,这对于程序来说就很头疼了。所以操作系统提供了一个服务:内存映射。

操作系统告诉程序,你还是按你原来的来,程序内部依然假定自己能够访问所有的内存,地址也按顺序分配,剩下的交给操作系统。这个时候,程序访问的地址就变成了虚拟地址,操作系统会记下每个程序的虚拟地址和实际地址的映射关系,程序实际访问的时候会经过一次转换,把虚拟地址转换成实际的物理地址。这样,如果不同程序内访问了同一个虚拟内存地址,实际上访问的是不同的物理地址,就实现了隔离。

用户态和内核态

虚拟内存空间解决了不同程序之间的隔离,但是运行权限的问题依然没有解决。程序的执行本质是一条条cpu的指令,所以操作系统和cpu联手,创建出了指令等级和保护模式。对于敏感寄存器的操作指令被限制,例如CPU中断控制,不能让普通的程序使用。但是操作系统作为统筹规划的那一位,就拥有最高权限。

当应用程序进行敏感操作的时候,例如读写磁盘。如果不经过操作系统,不同的程序就可能打架。为了强制这些敏感操作全部通过操作系统调度,CPU对运行状态也划分层级,Ring0是最高权限,Ring3是普通权限。一般的应用程序运行在Ring3下,当它尝试去进行敏感操作的时候,CPU就会检测到权限不足,不能正常执行。而操作系统运行在Ring0下,操作系统就能够之间执行这些指令。这个时候,应用程序需要写磁盘的时候,就需要告诉操作系统,我想写磁盘,操作系统内部进行相关的调度操作,之后写完磁盘再告诉应用程序运行结果。这个过程就叫做系统调用(systemCall)。

所以实际上这么一套操作流程就变成了这么一张图:

中间cpu的执行等级发生了变换,我们把用户程序执行的状态叫做用户态,在操作系统部分执行的叫做内核态。

这样就保证了敏感操作必须经过操作系统。并且实现了权限的管控。

实际的虚拟内存地址结构

前面提到的内核态,实际上就是去执行了操作系统自己的代码。既然代码要运行,就一定有自己的内存空间,那操作系统自己的内存空间在哪呢?既然程序的内存空间地址是虚拟的,那程序就不知道操作系统代码的地址,不知道地址就没法调用啊!

这些在操作系统设计的时候就考虑到了,看看这张图:

以32位系统为例,左侧的是程序的虚拟地址,他把虚拟地址分成了两部分,低位置的3G是用户态代码,高位置的1G就是操作系统代码啦~,这里同样是做了一次映射操作,把这一部分内核地址映射到了操作系统内核代码实际的地址里。

也就是说,程序同样是访问虚拟地址,如果要进行system-call,访问的也是自己的虚拟地址,只不过这部分地址也映射到了真实地址上而已。

系统里所有的程序都有这么一块内核虚拟空间,他们映射的都是同一块物理地址,即实际的操作系统代码。

至于操作系统代码自己是如何访问内存的,这就是另一个故事了,操作系统本身是没有虚拟地址空间的,但同样是通过地址映射来访问,这里就先不讨论了。相关信息

虚拟内存是如何映射的

既然操作系统是采取映射的方式做的转换,那具体是个什么映射法呢?

主要分为三种:段式、页式、段页式

段式

程序的代码可以被分为多个段被分配,映射的时候按段号映射:

此时段的内部是连续的,不同段之间可能存在空隙。

操作系统就为进程维护一个段表:

段号段长度起始地址

进程访问内存的时候只要按照段号+段内偏移量就可以了。

这种分配方法优点是安全性高(相较于页式),要用哪个部分的内存之间加载这一段就好。

但缺点就是容易出现内存碎片,当某两段之间的空间容不下新的段的时候,这段空间就被浪费了。

页式

操作系统把内存按照一定的大小,划分为若干块。而代码也是如此分块。大小与内存分块一致:

操作系统就维护一个页表

页号对应内存块号

此时访问内存就使用页号+页内偏移即可

这种方式的好处就是空间利用率大大提高,不容易出现碎片。

但当出现内存共享的时候就存在问题,程序天然是分段的,这种分页的情况可能导致某个页里头存放了两个段的信息,内存共享的是逻辑上的段,但在操作系统层面就变成了页,属实麻烦。

段页式

于是段页式管理就出现了,其综合了段式和页式的优点,具体就是程序先分段,段内再分页,而内存依然分为同样大小的块:

此时操作系统维护一个段表和一个页表:

段号页表长度起始页编号
页号对应内存块号

此时程序访问内存就是通过段号+段内页号+页内偏移量。

首先需要根据段号查到起始页的编号,之后加上段内页号得到实际的页号,之后查到内存块的地址,最后加上页内偏移量。

虚拟内存也太复杂了吧

是的,这个转换的过程很繁琐,而且和程序的运行息息相关,每时每刻都要进行这样的转换。

为了性能,CPU也专门为了地址转换添加了专用硬件,和相关的缓存,力求在软件层面无痛的享受虚拟内存机制带来的好处。

题外话:有关程序内部的内存构造

从源代码到可执行程序,需要经过编译,汇编和连接的过程

而程序的执行就是把程序的数据加载到内存的过程。

在前面我们了解了程序的数据是怎么加载的,即虚拟内存地址如何转换为实际的地址,那么虚拟内存内部又是什么构造呢?

首先源代码里头,我们可以有函数、变量、常量等等。编译器会为某些函数、常量生成符号表,为函数的入口分配地址。

编译器就会按照段来组织文件,比如把函数都放入一个段(代码段),静态的全局变量都放入一个段(data段或bss段)。之后为我们的栈预留内存空间地址(stack段),刨去为内核预留的空间、其余的就是我们的堆区空间啦。(可能还有共享库的空间啥的)

大概就长这样:

程序的栈空间是预先分配好的,处于高地址, Linux下进程栈的默认大小是10M。肯定是远小于堆区的。


当其他人都认为你要鸽的时候,你鸽了,亦是一种不鸽