内功修炼-操作系统之内存管理

计算机为什么会有内存

在计算机中, 内存作为CPU与外部存储器之间的桥梁,可以临时存放CPU中的运算数据以及与硬盘等外部存储器交换的数据。通过将常用数据存储在内存中,计算机可以更快地访问和操作这些数据,从而提高运行效率。因此内存的主要作用就是缓和CPU与硬盘之间的速度矛盾

微信截图_20240108161140.jpg

从图中可知, 存储器层次之间的作用和关联为金字塔形状,CPU不可以直接操控磁盘,是通过操控内存/缓存来进行工作的,因为磁盘的速度远远小于CPU的速度,跟不上,需要中间的内存层进行缓冲。

程序运行过程

  • 编译: 把高级语言翻译为机器语言;
  • 链接: 由链接程序将编译后形成的一组目标模块,以及所需库函数链接在一起,形成一个完整的装入模块(exe);
  • 装入(装载): 由装入程序将装入模块装入内存运行;

这里的编译我们就不多说了, 写过代码的基本都知道是什么意思~

链接的3种方式

  • 静态链接:装入前链接成一个完整的装入模块
  • 装入时动态链接:运行前变装入边链接
  • 运行时动态链接:运行时目标模块才装入并链接

装入的3种方式

  • 绝对装入:编译产生绝对地址。
  • 静态重定位装入:装入时将逻辑地址转为物理地址。
  • 动态重定位装入:运行时将逻辑地址转为物理地址,需设置重新定位寄存器。

内存管理的职责

内存被分为系统区用户区,系统区通常位于内存的低地址部分,用于存放操作系统的数据,用户区用于用户进程的相关数据。

内部碎片:分配给某进程的内存区域中,有些部分没有用上。
外部碎片:是指内存中的某些空闲分区由于太小而难以利用。

连续分配管理方式

连续分配:为用户进程分配的必须是一个连续的内存空间

单一连续分配

内存中只能有一道程序,用户程序独占整个用户空间。

  • 优点:实现简单,无外部碎片,可以用覆盖技术扩充内存。
  • 缺点:只能用于单用户,单任务的操作系统中,有内部碎片,存储器利用率极低。

固定分区分配

将用户空间分为若干个固定大小的分区,在每个分区中知装入一道作业。又分为分区大小相等和分区大小不等的。

分区大小相等的:缺乏灵活性,适合于一台计算机控制多个相同的对象的场合。
分区大小不等的:增加了灵活性,可以满足不同大小进程的需求。

操作系统需要建立一个数据结构——分区说明表,来实现各个分区的分配和回收。

  • 优点:实现简单,无外部碎片。
  • 缺点:当用户程序太大时,所有的分区都不能满足需求。会产生内部碎片。

动态分区分配

不会预先划分内存分区,根据进程的大小动态的建立分区,并使分区的大小正好适合进程的需要,系统分区的大小和数目是可变的。

两种常用的数据结构:空闲分区表空闲分区链

动态分区分配没有内部碎片,但是有外部碎片。

动态分区分配算法

20240110220423.png

非连续分配管理方式

非连续分配:为用户进程分配的可以是一些分散的内存空间

基本分页存储

页框(物理内存划分的块):将内存空间分为一个个大小相等的分区(比如:每个分区 4KB),每个分区就是一个“页框”(页框=页帧=内存块=物理块=物理页面)。每个页框有一个编号,即“页框号”(页框号=页帧号=内存块号=物理块号=物理页号),页框号从0开始。

页面(逻辑内存划分的块):将进程的逻辑地址空间也分为与页框大小相等的一个个部分,每个部分称为一个页面 。每个页面也有一个编号,即页号,页号也是从0开始。

页表:进程的页面与内存的页框一一对应的关系。各个页面不必连续存放,可以放到不相邻的各个页框中。对于每一个进程,可以维护一张页表,用它来记录页面号(页号)与页框号(块号)的映射关系,可以根据页号找到页号指代页面在内存中对应页框的编号。页表通常存在PCB(进程控制块)中。

5.png

20240111145701.png

页号 = 逻辑地址 / 页面长度 (取除法的整数部分)
页内偏移量 = 逻辑地址 % 页面长度(取除法的余数部分)

快表:又叫做联想寄存器,它是一种访问速度比内存快很多的高速缓冲存储器,用以存放访问过的页表项的副本,从而加快地址转换的过程。也就是说,引入快表后,地址转换可以不需要经历第一次访存,而是直接从快表中拿到需要的页表项。与之对应的,内存中原本的页表,叫做慢表。

引入快表的原因:

  • 每次存取数据都需要访问内存两次:第一次访问内存中的页表,找到块号,并将块号与偏移量拼接得到物理地址;第二次,根据物理地址访问内存中存放的数据。第二次访存肯定是不能避免的,但是第一次访存其实可以想办法避免
  • 若多条指令涉及到的逻辑地址的页号都相同,则每次都得经历第一次访存,找到该页号对应的块号

前面说的是单极页表, 但是其存在如下问题:

  • 页表占用过大的、连续的内存空间
  • 页表常驻内存

针对第一个问题, 可以引入两级页表, 可以考虑对页表本身进行拆分, 将长长的页表分为多个子页表,再将每一个子页表离散地存放到各个内存块中。同样的,我们需要一张表用来记录子页表页号和块号之间的映射关系。

针对第二个问题, 可以引入虚拟存储技术, 事实上,没有必要在一开始就把所有的页表项都调入内存中,我们可以借助虚拟存储技术,在需要访问页面的时候才把对应的页表项调入内存。

基本分段存储

而在基本分段存储管理中,我们倾向于从逻辑功能的角度去考虑,将程序分为多个逻辑功能段,每个段都有自己的段名,并且都是从 0 开始编址的。在分配的时候是以段为单位进行分配的,在内存中,段内所占空间是连续的,但是各个段之间可以不相邻。

段表(段号, 段长, 基质): 记录某个编号段与实际物理存放位置之间的映射关系。在分段存储管理中,程序被分为大小不等的多个段,我们没办法像之前一样只需要块号即可推导出块的初始地址,为了准确地找出段存放在内存中的位置,我们要将段号、段长、基址这三者作为段表的三列。这样,根据段号可以在段表中找到对应段在内存中的初始地址(基址),再结合段长,可以知道这个段具体占用了哪里的空间。

12.png

20240111145220.png

基本段页式存储

- 优点 缺点
分页管理 内存空间利用率高,不会产生外部碎片,只会产生少量内部碎片 不方便按照逻辑模块实现信息的共享和保护
分段管理 很方便按照逻辑模块实现信息的共享和保护 若逻辑过多则会导致段过长,另外,这种方式也会产生外部碎片

所以结合二者之长,出现了段页式存储管理方式。

段页存储管理会首先将进程按照逻辑模块划分为多个段,针对每个段再划分为多个页;同时也把内存划分为多个页框。分配内存的时候,一个页面就对应了一个页框。

20240111152806.png

20240111152845.png

内存空间的扩充

很多游戏的大小超过 60GB,按理来说这个游戏程序运行之前需要把 60GB 数据全部放入内存。然而,实际我的电脑内存才 8GB,我还要开着微信浏览器等别的进程,但为什么这个游戏可以顺利运行呢?

地址转换

为了使编程更方便,程序员写程序时应该只需要关注指令、数据的逻辑地址。而逻辑地址到物理地址的转换(这个过程称为地址重定位)应该由操作系统负责,这样就保证了程序员写程序时不需要关注物理内存的实际情况。

存储保护

操作系统需要提供内存保护功能。保证各进程在各自存储空间内运行,互不干扰。

方法一: 在CPU中设置一对上下限寄存器, 存放进程的上下限地址, 进程的指令要访问某个地址时, CPU检测是否越界.
方法二: 采用重定位寄存器(又称基址寄存器)和界地址寄存器(又称限长寄存器)进行越界检查, 重定位寄存器中存放的是进程的起始物理地址, 界地址寄存器中存放的是进程的最大逻辑地址

为什么有堆栈

注意: 这里说的堆栈不是数据结构中的堆栈, 而是程序运行中的不同内存空间

缘起:动态数据分配

内存分配一般有三种方式:静态存储区(根对象、静态变量、常量)、栈(函数中的临时局部变量)、堆(malloc、new等)

最开始的计算机内存采取的是静态分配策略,

在 1958 年,Algol-58 语言首次提出了块结构(block-structured),块结构语言通过在内存中申请帧来实现按需分配的动态策略。栈限制了栈帧的生命周期不能超过其调用者,而且由于每个栈帧是固定大小,所以一个过程的返回值也必须在编译期确定。所以诞生了新的内存管理策略——堆(heap)管理。

分配运行程序员按任意顺序分配/释放程序所需的数据结构——动态分配的数据结构可以脱离其调用者生命周期的限制,这种便利性带来的问题是垃圾对象的回收管理

应用程序的内存划分

操作系统把磁盘上的可执行文件加载到内存运行之前,会做很多工作,其中很重要的一件事情就是把可执行文件中的代码,数据放在内存中合适的位置,并分配和初始化程序运行过程中所必须的堆/栈,所有准备工作完成后操作系统才会调度程序起来运行

程序运行时在内存(虚拟地址空间)中的布局图:
v2-2c36669377c877e90ec3e73bcad82a28_1440w.png

  • 代码区,包括能被CPU执行的机器代码(指令)和只读数据比如字符串常量,程序一旦加载完成代码区的大小就不会再变化了。
  • 数据区,包括程序的全局变量和静态变量,程序加载完毕后数据区的大小也不会发生改变。
  • 堆,程序运行时动态分配的内存都位于堆中,这部分内存由内存分配器负责管理。该区域的大小会随着程序的运行而变化。
  • 函数调用栈,简称栈。

不管是函数的执行还是函数调用,栈都起着非常重要的作用

  • 保存函数的局部变量
  • 向被调用函数传递参数
  • 返回函数的返回值
  • 保存函数的返回地址

每个函数在执行过程中都需要使用一块栈内存用来保存上述这些值,我们称这块栈内存为某函数的栈帧(stack frame)。当发生函数调用时,因为调用者还没有执行完,其栈内存中保存的数据还有用,所以被调用函数不能覆盖调用者的栈帧,只能把被调用函数的栈帧“push”到栈上,等被调函数执行完成后再把其栈帧从栈上“pop”出去,这样,栈的大小就会随函数调用层级的增加而生长,随函数的返回而缩小。

栈的生长和收缩都是自动的,由编译器插入的代码自动完成

因此位于栈内存中的函数局部变量所使用的内存随函数的调用而分配,随函数的返回而自动释放,所以程序员不管是使用有垃圾回收还是没有垃圾回收的高级编程语言都不需要自己释放局部变量所使用的内存,这一点与堆上分配的内存截然不同。

都是编程语言里的虚拟概念,并不是说在物理内存上有堆和栈之分,两者的主要区别是栈是每个线程或者协程独立拥有的,从栈上分配内存时不需要加锁。而整个程序在运行时只有一个堆,从堆中分配内存时需要加锁防止多个线程造成冲突,同时回收堆上的内存块时还需要运行可达性分析、引用计数等算法来决定内存块是否能被回收,所以从分配和回收内存的方面来看栈内存效率更高。

为什么需要栈?

是支持程序运行的基本数据结构,它跟计算机硬件(call、ret、push、pop 等指令),比如 CPU 的栈指针寄存器、操作系统息息相关,还跟编译器关系密切(我们在函数中定义的局部变量,就是放在栈中的,函数的调用和返回,也是依赖于栈)。

  • 函数内部会定义局部变量,变量要占内存,由于位于同一函数中,很自然的想法是将其内存空间放在一起,有利于整体申请和释放。所以栈帧首先是一个容器。
  • 一个函数一帧,多个函数多个帧。如何组织呢? PS: 散列不行,所以选择了线性,队列数据结构不合适,选择了栈数据结构。
  • 操作系统明显的将程序空间区分为堆和栈,堆栈的增长方向不同(相向), 如果方向相同, 那么栈实际内存空间的连续性就被破坏了。
  • 系统每次保存栈顶和栈底地址比较麻烦, 因此硬件上使用SP 和BP 寄存器来加速这个过程。
  • 栈不是线程共享, 在操作系统环境下,操作系统会为一个进程/线程单独划分一个栈空间。

为什么需要栈?为了支持函数。没有函数,代码会重复,有了函数,才有局部变量一说,有了局部变量才有了数据的动态申请与分配一说。

GC不负责回收栈中的内存,为什么呢?主要原因是栈是一块专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈。除此以外,栈中的数据都有一个特点——简单,比如局部变量不能被函数外访问,这块内存用完就可以直接释放。正是因为这个特点,栈中的数据可以通过简单的编译器指令自动清理,也就不需要通过GC来回收了。

  • 栈上的分配极为简单,移动一下栈指针而已。
  • 栈上的释放也极为简单,函数执行结束时移动一下栈指针即可。
  • 由于后进先出的执行过程,不可能出现内存碎片。

栈切换

线程是什么?进程切换、线程切换、协程切换、函数切换,cpu只有一个,寄存器也只有一批,想要并发,就得对硬件(cpu和寄存器)分时使用,分时就得save/load,切换时保留现场,返回时恢复现场。线程调度时操作系统需要保存和恢复的寄存器除了通用寄存器之外,还包括指令指针寄存器rip以及与栈相关的栈顶寄存器rsp和栈基址寄存器rbp,rip寄存器决定了线程下一条需要执行的指令,2个栈寄存器确定了线程执行时需要使用的栈内存。所以恢复CPU寄存器的值就相当于改变了CPU下一条需要执行的指令,同时也切换了函数调用栈,因此从调度器的角度来说,线程至少包含以下3个重要内容:

  • 一组通用寄存器的值(切换时涉及到save和load)
  • 将要执行的下一条指令的地址
- 寄存器 栈等资源 -
函数调用 PC和栈寄存器 如果有参数或返回值的话需要几次用户栈操作 不到1ns
协程切换 少数几个寄存器 协程栈切换 -
系统调用 SS、ESP、EFLAGS、CS和EIP寄存器 同一进程用户态和内核态栈切换 1-10us
线程切换 寄存器 用户态和内核态栈切换到另一线程 -
进程切换 几乎所有寄存器 用户态和内核态栈切换到另一线程,切换虚拟内存,全局变量等资源 -

系统调用:在内核栈的最高地址端,存放的是一个结构pt_regs,这个结构的作用是:当系统调用从用户态到内核态的时候,首先要做的第一件事情,就是将用户态运行过程中的CPU上下文也即各种寄存器保存在这个结构里。这里两个重要的两个寄存器SP和IP,SP里面是用户态程序运行到的栈顶位置,IP里面是用户态程序运行到的某行代码。

所以操作系统对线程的调度可以简单的理解为内核调度器对不同线程所使用的寄存器和栈的切换。

为什么需要堆?

其实是虚拟内存空间中一个段,由堆的开始地址和结束地址控制其大小,有一个堆指针指向未分配的第一个字节。所以,堆在本质上是指应用程序在运行过程中进行动态分配的内存区域,堆的管理通常在库函数中完成。

有了栈, 对于面向过程的程序设计还远远不够,因为栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部,只能通过不断拷贝的方式保持其“存活”。

是由操作系统或内存管理器库自动管理的动态分配内存区域。堆上的内存在程序执行过程中会定期分配、释放和调整大小,这可能会导致一个称为碎片的问题。

任何线程都可以在堆上申请空间(全局的),堆是线程共享,因此每次申请堆内存都必须进行同步处理,竞争十分激烈,必然会出现线程阻塞。

扩展阅读

操作系统之内存管理,啃完的人都超神了!!!
为什么要有堆栈
操作系统学习笔记-11:内存分配(一):连续分配
操作系统学习笔记-12:内存分配(二):非连续分配

此处评论已关闭