操作系统将内存分为两部分:一部分供操作系统使用(驻留内核进程和重要的数据结构等),另一部分供用户进程使用,必须将内存进一步的细分给不同的用户进程以满足多个进程的需求。操作系统完成这一“细分”的任务的过程就叫做内存管理。
加载和链接
地址绑定时间 | 功能 |
---|---|
程序设计时 | 程序员直接在程序中确定所有实际的物理地址 |
编译或汇编时 | 程序包含符号地址访问,由编译器在加载程序时把它们转换为实际的绝对地址 |
加载时 | 编译器或汇编器产生相对地址,加载器在加载程序时把它们转换为实际的绝对地址 |
运行时 | 被加载的程序保持相对地址,处理器硬件在执行时把它们动态的转换为绝对地址 |
链接时间 | 功能 |
---|---|
程序设计时 | 不允许外部程序或数据访问。程序员必须把所有引用到的子程序源代码放入程序中 |
编译或汇编时 | 汇编器必须取到每个引用的子程序的源代码,并把它们作为一个部件来进行汇编 |
加载模块产生时 | 所有目标模块都使用相对地址汇编。这些模块被链接在一起,所有访问都相对于最后加载的模块的地点重新声明 |
加载时 | 直到加载模块被加载到内存时才解析外部访问,此时被访问的动态链接模块附加到加载模块后,整个软件包被加载到内存或虚存 |
运行时 | 直到处理器执行外部调用时才解析外部访问,此时该进程被中断,需要的模块被链接到调用程序中 |
加载
创建活动进程的第一步是把程序装入内存,并创建一个进程映像。应用程序由许多已编译过或汇编过的模块组成,这些模块以目标代码的形式存在,并被链接起来以解析模块间的任何访问和对库例程的访问。库例程可以合并到程序中,或作为操作系统在运行时提供的共享访问代码。
加载器把加载的模块放置在内存中从x
开始的位置。一般而言,可以采用三种方法:
绝对加载
绝对加载器要求给定加载模块总被加载到内存中的同一位置。因此,在提供给加载器的加载模块中,所有的地址访问必须是确定的,或者说是绝对的内存地址。给程序中的内存访问指定具体的地址值既可以由程序员完成,也可以在编译时或汇编时完成,
这种方法存在许多缺点:首先,程序员必须知道在内存中放置模块时预定的分配策略;其次,如果在程序的模块体中进行了任何涉及插入或删除的修改,则所有地址都需要更改。
因此,更可取的方法是允许用符号表示程序中的内存访问,然后在编译或汇编时解析这些符号引用。对指令或数据项的引用最初被表示成一个符号。在准备输入到一个绝对加载器的模块时,汇编器或编译器将把所有这些引用转换为具体地址。
可重定位加载
在加载之前就把内存访问绑定到具体的地址的缺点是,会使得加载模块只能放置到内存中的一个区域。但是,当多个程序共享内存时,不可能事先确定哪块区域用于加载哪个特定的模块,最好是在加载时确定。因此,需要一个可分配到内存中任何地方的加载模块。
为满足这个新需求,汇编器或编译器不产生实际的内存地址(绝对地址),而是使用相对于某些已知点的地址,如相对于程序的起点。加载模块中的所有其他内存访问都用与该模块起点的相对值来表示。
既然所有内存访问都以相对形式表示,那么加载器就可以很容易地把模块放置在期望的位置。如果该模块要加载到从
x
位置开始的地方,则当加载器把该模块加载到内存中时,只需简单地给每个内存访问都加上x
。为完成这一任务,加载模块必须包含一些需要告诉加载器的信息,如地址访问在哪里、如何解释它们(通常相对于程序的起点)。由编译器或汇编器准备这些信息,通常称这些信息为重定位地址库。动态运行时加载
动态运行时加载可重定位加载器非常普遍,且相对于绝对加载器具有明显的优点。但是,在多道程序设计环境中,即使不依赖于虚存,可重定位的加载方案仍是不够的。由于需要把进程换入或换出内存来增大处理器的利用率,而为最大程度地利用内存,又希望能在不同的时刻把一个进程映像换回到不同的位置,因此,程序被加载后,可能被换出到磁盘,然后又被换回到内存中不同的位置。如果在开始加载时,内存访问就被绑足到绝对地址,那么前面提到的情况是不可能实现的。
一种替代方案是在运行时真正在使用某个绝对地址时再计算它。为达到这一目的,加载模块被加载到内存中时,其所有内存访问都以相对形式表示,一条指令只有在真正被执行时才计算其绝对地址。为确保该功能不会降低性能,这些工作必须由特殊的处理器硬件完成,而不用软件实现。
动态地址计算提供了很大的灵活性。一个程序可以加载到内存中的任何区域,程序的执行可以中断,程序还可换出内存,以后再换回到不同的位置。
链接
链接器的功能是把一组目标模块作为输入,产生一个包含完整程序和数据模块的加载模块,并传递给加载器。在每个目标模块中,可能有到其他模块的地址访问,每个这样的访问可以在未链接的目标模块中用符号表示。链接器会创建一个单独的加载模块,它把所有目标模块逐个链接起来。每个模块内的引用必须从符号地址转换为对整个加载模块中的一个位置的引用。
产生可重定位加载模块的链接器通常称为链接编辑程序。地址链接的性质取决于链接发生时要创建的加载模块的类型。通常情况下需要可重定位的加载模块,然后链接按以下方式完成:同时创建每个已编译或汇编的目标模块及相对于该目标模块开始处的引用。所有这些模块,连同相对于该加载模块起点的所有引用,一起放进一个可重定位的加载模块中。该模块可以作为可重定位加载或动态运行时加载的输入。
像加载一样,可以推迟某些链接功能。动态链接是指把某些外部模块的链接推迟到创建加载模块之后。因此,加载模块包含到其他程序的未解析的引用,这些引用可以在加载时或运行时解析。
加载时的动态链接分为如下步骤:
- 待加载的加载模块(应用模块)读入内存。
- 应用模块中到一个外部模块(目标模块)的任何引用都将导致加载程序查找目标模块,加载它,并把这些引用修改为相对于应用程序模块开始处的相对地址。
该方法与静态加载相比,有以下优点:
- 能更容易地并入已改变或已升级的目标模块,如操作系统工具,或某些其他的通用例程。而对于静态链接,这类支持模块的变化需要重新链接全部应用程序模块。
- 在动态链接文件中的目标代码可以很方便的进行共享。因为操作系统加载并链接了该代码,所以可以识别出有多个应用程序使用相同的目标代码。操作系统可以使用此信息,然后只加载目标代码的一个副本,并把这个被加载的目标副本链接到所有使用该目标代码的应用程序,而不是为每个应用程序都分别加载一个副本。
使用运行时动态链接时,某些链接工作被推迟到执行时。这样一些对目标模块的外部引用保留在被加载的程序中,当调用的模块不存在时,操作系统定位该模块,加载它,并把它链接到调用模块中。这些模块一般是共享的。在 Windows
环境下,这些模块称为动态链接库(DLL
)。也就是说,如果一个进程已使用动态链接共享模块,该模块就位于内存中,新的进程就可以简单地链接上已加载好的模块。
内存管理的需求
操作系统将内存分为两部分:一部分供操作系统使用(驻留内核进程和重要的数据结构等),另一部分供用户进程使用,必须将内存进一步的细分给不同的用户进程以满足多个进程的需求。操作系统完成这一“细分”的任务的过程就叫做内存管理。
术语 | 解释 |
---|---|
页框 | 内存中固定长度的块 |
页 | 固定长度的数据块。一般和页框的长度相等,数据页可临时复制到内存的页框中 |
段 | 变长的数据块。整个段可以临时复制到内存中的一个可用区域中,或者将一个段划分为许多页,然后将每页单独复制到内存中(分段和分页相结合) |
内存管理的需求如下:
重定位
可用的有限内存空间通常被多个进程共享。程序换出到磁盘后,下次换入内存时并不一定被放回原来的区域。也就是说我们需要把进程重定位到不同的内存区域。同时,我们必须允许程序通过交换技术在内存中移动,所以处理器硬件和操作系统软件必须能够以某种方式将程序代码中写死的内存访问地址转换为实际的物理内存地址。
首次加载一个进程时,重定位将代码中的相对内存访问被绝对内存地址代替,这个绝对地址由进程被加载到的基地址确定。
一个进程在其生命周期中可能占据不同的分区。首次创建一个进程映像时,它被装入内存中的某个分区。以后,该进程可能被换出,当它再次被换入时,可能被指定到与上一次不同的分区中。
进程被换入或在内存中移动时,指令和数据单元的位置会发生改变。为解决这个问题,需要区分几种地址类型。逻辑地址是指与当前数据在内存中的物理分配地址无关的访问地址,在执行对内存的访问之前必须把它转换为物理地址。相对地址是逻辑地址的一个特例,它是相对于某些已知点(通常是程序的开始处)的存储单元。物理地址或绝对地址是数据在内存中的实际位置。
进程处于运行态时,有一个特殊处理器寄存器(称为基址寄存器),其内容是程序在内存中的起始地址。还有一个界限寄存器指明程序的终止位置。当程序被装入内存或当该进程的映像被换入时,必须设置这两个寄存器。在进程的执行过程中会遇到相对地址,包括指令寄存器的内容、跳转或调用指令中的指令地址,以及加载和存储指令中的数据地址。每个这样的相对地址都经过处理器的两步操作。首先,基址寄存器中的值加上相对地址产生一个绝对地址;然后,将得到的结果与界限寄存器的值进行比较,如果这个地址在界限范围内,则继续该指令的执行;否则,向操作系统发出一个中断信号,操作系统必须以某种方式对这个错误做出响应。
保护
一个进程的内存空间不能被其它进程未经授权的访问,满足重定位的需求增大了满足保护需求的难度。由于程序在内存中的位置通常会变化,因此,不可能通过在编译时检查绝对地址来保护。必须在运行时检查进程的所有内存访问,以确保它们只访问分配给自己的内存空间。
注意,内存保护必须由硬件(处理器)而非软件(操作系统)来满足。
共享
例如,多个进程在执行同一个程序时,允许每个进程访问该程序的同一个副本,以节省内存空间。这意味着操作系统允许进程对共享内存区域的受控访问。
逻辑组织
计算机系统中的内存总是被组织成线性(或一维)的地址空间,且地址空间由一系列字节或字组成。然而,大多数程序被组织成模块,某些模块是不可修改的(只读、只执行),某些模块包含可以修改的数据。若操作系统和计算机硬件能够有效地处理以某种模块形式组织的用户程序与数据,则会带来很多好处:
可以独立地编写和编译模块,系统在运行时解析从一个模块到其他模块的所有引用。
通过适度的额外开销,可以为不同的模块提供不同的保护级别。
可以引入某种机制,使得模块可被多个进程共享。
最易于满足这些需求的工具是分段。
物理组织
计算机存储器至少要组织成两级,即内存和外存。内存提供快速的访问,成本也相对较高。此外,内存是易失性的,即它不能提供永久性存储。外存比内存慢而且便宜,且通常是非易失性的。因此,大容量的外存可用于长期存储程序和数据,而较小的内存则用于保存当前使用的程序和数据。
在两级存储器间移动信息的任务应由系统负责。这一任务恰好是存储管理的本质。
固定分区和动态分区
内存管理的主要操作是处理器把程序装入内存中执行。虚存基于分页和分段两种技术。
内存管理技术 | 说明 |
---|---|
固定分区 | 在系统生成阶段,内存被划分成许多静态分区。进程可装入大于等于自身大小的分区中 |
动态分区 | 分区是动态创建的,因而每个进程可装入与自身大小正好相等的分区中 |
简单分页 | 内存被划分成许多大小相等的页框;每个进程被划分成许多大小与页框相等的页;要装入一个进程,需要把进程包含的所有页都装入内存内不一定连续的某些页框中 |
简单分段 | 每个进程被划分成许多段;要装入一个进程,需要把进程包含的所有段都装入内存内不一定连续的某些动态分区中 |
虚存分页 | 除了不需要装入一个进程的所有页外,与简单分页一样;非驻留页在以后需要时自动调入内存 |
虚存分段 | 除了不需要装入一个进程的所有段外,与简单分段一样;非驻留段在以后需要时自动调入内存 |
固定分区
使用大小相等的分区:
- 程序可能太大而不能放到一个分区中,此时必须使用覆盖技术。
- 内存的利用率很低。会产生大量内部碎片。
使用大小不等的分区可缓解这两个问题。
对于大小相等的分区,放置算法将进程放入内存的哪个分区都没有关系。
对于大小不等的分区,放置算法将每个进程分配到能够容纳它的最小分区中。每个分区需要维护一个调度队列,用于保存从这个分区换出的进程。
动态分区
对于动态分区,分区长度和数量是可变的。进程装入内存时,系统会给它分配一块与其所需容量完全相等的内存空间。动态分区方法最初不错,但它最终在内存中形成了许多小空洞。随着时间的推移,内存中形成了越来越多的外部碎片,内存的利用率随之下降。
放置算法把一个进程装入或换入内存时,如果内存中有多个足够大的空闲块,那么操作系统必须确定要为此进程分配哪个空闲块。可供考虑的放置算法有三种:最佳适配、首次适配和下次适配。首次适配算法是最简单,最好和最快的。
置换算法使得操作系统将一个阻塞的进程换出内存,给新进程或处于就绪-挂起态的进程让出空间。因此,操作系统必须选择要替换哪个进程。
伙伴系统
简单分页
大小不等的固定分区和大小可变的分区技术在内存的使用上都是低效的,前者会产生内部碎片,后者会产生外部碎片。但是,如果内存被划分成大小固定、相等的块,且块相对比较小,每个进程也被分成同样大小的小块,那么进程中称为页的块可以分配到内存中称为页框的可用块。使用分页技术时,每个进程在内存中浪费的空间,仅是进程最后一页的一小部分形成的内部碎片,没有任何外部碎片。
在某个给定时刻,内存中的某些页框正被使用,某些页框是空闲的,操作系统维护空闲页框的列表。
这时仅有一个简单的基址寄存器是不够的,操作系统需要为每个进程维护一个页表。页表给出了该进程的每页所对应页框的位置。在程序中,每个逻辑地址包括一个页号和在该页中的偏移量。在简单分区的情况下,逻辑地址是一个字相对于程序开始处的位置,处理器把它转换为一个物理地址。在分页中,逻辑地址到物理地址的转换仍然由处理器硬件完成,且处理器必须知道如何访问当前进程的页表。给出逻辑地址(页号,偏移量)后,处理器使用页表产生物理地址(页框号,偏移量)。
进程的每页在页表中都有一项,因此页表很容易按页号对进程的所有页进行索引(从0
页开始)。每个页表项包含内存中用于保存相应页的页框的页框号。此外,操作系统为当前内存中未被占用、可供使用的所有页框维护一个空闲页框列表。
总之,采用简单的分页技术,内存可分成许多大小相等且很小的页框,每个进程可划分成同样大小的页;较小的进程需要较少的页,较大的进程需要较多的页;装入一个进程时,其所有页都装入可用页框中,并建立一个页表。
简单分段
把程序和与其相关的数据划分到几个段中。并不要求所有程序的所有段的长度都相等。和分页一样,采用分段技术时的逻辑地址也由两部分组成:段号和偏移量。
一般情况下,程序员或编译器会把程序和数据指定到不同的段。为了实现模块化程序设计的目的,程序或数据可能会进一步分成多个段。
采用大小不等的段的另一个结果是,逻辑地址和物理地址间不再是简单的对应关系。类似于分页,在简单的分段方案中,每个进程都有一个段表,系统也会维护一个内存中的空闲块列表。每个段表项必须给出相应段在内存中的起始地址,还必须指明段的长度,以确保不会使用无效地址。当进程进入运行状态时,系统会把其段表的地址装载到一个寄存器中,由内存管理硬件来使用这个寄存器。
总之,采用简单的分段技术,进程可划分为许多段,段的大小无须相等;调入一个进程时,其所有段都装入内存的可用区域,并建立一个段表。
虚拟内存概念
术语 | 解释 |
---|---|
虚拟内存 | 被定义成一个连续完整的地址空间,而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换 |
虚拟地址 | 在虚拟内存中分配给某一位置的地址,它使得该位置可被访问,就好像是主内的一部分那样 |
虚拟地址空间 | 分配给进程的虚拟存储 |
地址空间 | 用于某进程的内存地址范围 |
实地址 | 内存中存储位置的地址 |
分页和分段的两个特点:
- 进程中的所有内存访问都是逻辑地址,这些逻辑地址会在运行时动态的转换为物理地址。这意味着一个进程可被换入和换出内存,进程可在执行过程中占据不同时刻内存中的不同区域。
- 一个进程可被划分为许多块(页和段),在执行过程中,这些快不需要连续的位于内存中。
假设需要把一个新进程放入内存,此时操作系统仅读取包含程序开始处的一个或几个块。进程执行的任何时候都在内存的部分称为进程的常驻集。进程执行时,只要所有内存访问都是访问常驻集中的单元,执行就可以顺利进行;使用段表或页表,处理器总可以确定是否如此。
处理器需要访问一个不在内存中的逻辑地址时,会产生一个中断,这表明出现了内存访问故障(缺页故障)。操作系统会把被中断的进程置于阻塞态。要继续执行这个进程,操作系统必须把包含引发访问故障的逻辑地址的进程块(所在的页)读入内存。为此,操作系统产生一个磁盘IO
读请求。产生I/O
请求后,在执行磁盘IO
期间,操作系统可以调度另一个进程运行。需要的块读入内存后,产生一个I/O
中断,控制权交回给操作系统,而操作系统则把由于缺少该块而被阻塞的进程置为就绪态。
在内存中保留多个进程。
由于对任何特定的进程都仅装入它的某些块,因此有足够的空间来放置更多的进程。这样,在任何时刻这些进程中至少有一个处于就绪态,于是处理器得到了更有效的利用。
进程可以比内存的全部空间还大。
操作系统在需要时会自动地把进程块装入内存。
由于进程只能在内存中执行,因此这个存储器称为实存储器,简称实存。但程序员或用户感觉到的是一个更大的内存,且通常分配在磁盘上,这称为虚拟内存,简称虚存。虚存支持更有效的系统并发度,并能解除用户与内存之间没有必要的紧密约束。
考虑一个由很长的程序和多个数据数组组成的大进程。在任何一段很短的时间内,执行可能会局限在很小的一段程序中(如一个子程序),且可能仅会访问一个或两个数据数组。因此,若在程序被挂起或被换出前仅使用了一部分进程块,则为该进程给内存装入太多的块显然会带来巨大的浪费。仅装入这一小部分块可更好地使用内存。然后,若程序转移到或访问到不在内存中的某个块中的指令或数据,就会引发一个错误,告诉操作系统读取需要的块。
当操作系统读取一块时,它必须把另一块换出。如果一块正好在将要用到之前换出,操作系统就不得不很快地把它取回。这类操作通常被称为系统抖动。
局部性原理表明虚拟内存方案是可行的。要使虚存比较实用并且有效,需要两方面的因素:
- 首先,必须有对所采用分页或分段方案的硬件支持;
- 其次,操作系统必须有管理页或段在内存和辅助存储器之间移动的软件。
虚拟分页
每个进程都有自己的页表,当它的所有页都装入内存时,将创建页表并装入内存。页表项(Page Table Entry
,PTE
)包含有与内存中的页框相对应的页框号。由于一个进程可能只有一些页在内存中,因而每个页表项需要有一位P
来表示它所对应的页当前是否在内存中。若这一位表示该页在内存中,则这个页表项还包括该页的页框号。
页表项中所需要的另一个控制位是修改位M
,它表示相应页的内容从上次装入内存到现在是否已改变。若未改变,则在需要把该页换出时,无须用页框中的内容更新该页。页表项还须提供其他一些控制位,例如,保护位和共享位。
页表结构
从内存中读取一个字的基本机制包括使用页表从虚拟地址到物理地址的转换。虚拟地址又称为逻辑地址,它由页号和偏移量组成,而物理地址由页框号和偏移量组成。由于页表的长度可基于进程的长度而变化,因而不能期望在寄存器中保存它,它须在内存中且可以访问。当某个特定的进程正运行时,一个寄存器保存该进程页表的起始地址。虚拟地址的页号用于检索页表、查找相应的页框号,并与虚拟地址的偏移量结合起来形成需要的实地址。一般来说,页号域长于页框号域(n > m
)。
每个进程可以占据大量的虚存空间,因而一个进程会有大量的页表项,这会导致保存页表的内存空间太大。大多数虚拟内存方案都在虚存而非实存中保存页表。这意味着页表和其他页一样都服从分页管理。一个进程正在运行时,它的页表至少有一部分须在内存中,这一部分包括正在运行的页的页表项。一些处理器使用两级方案来组织大型页表。在这类方案中有一个页目录,其中的每项指向一个页表。
假设采用字节级的寻址,页尺寸为4KB
(2^12^),则4GB
(2^32^)虚拟地址空间由2^20^页组成。若这些页中的每页都由一个4
字节的页表项映射,则可创建由2^20^个页表项组成的一个页表,这时需要4MB
(2^22^)的内存空间。这个由2^10^页组成的巨大用户页表可以保留在虚存中,并由一个包括2^10^个页表项的根页表映射,根页表占据的内存为4KB
(2^12^)。
转换检测缓冲区TLB
原则上,每次虚存访问都可能会引起两次物理内存访问:一次取相应的页表项,另一次取需要的数据(进程页)。因此,虚拟内存方案都为页表项使用了一个特殊的高速缓存,通常称为转换检测缓冲区(Translation Lookaside Buffer
,TLB
)。TLB
中包含有最近用过的页号和完整的页表项。给定一个虚拟地址,处理器首先检查TLB
,若需要的页表项在其中(TLB
命中),则检索页框号并形成实地址。若未找到需要的页表项(TLB
未命中),则处理器用页号检索进程页表,并检查相应的页表项。若“存在位”已置位,则该页在内存中,处理器从页表项中检索页框号以形成实地址。处理器同时更新TLB
,使其包含这个新页表项。最后,若“存在位”未置位,则表示需要的页不在内存中,这时会产生一次内存访问故障,称为缺页(page fault
)故障。此时离开硬件作用范围,调用操作系统,由操作系统负责装入所需要的页,并更新页表。
页尺寸对缺页中断发生概率的影响使得这些问题变得更为复杂。一般而言,基于局部性原理,如果页尺寸非常小,那么每个进程在内存中就有较多数量的页。一段时间后,内存中的页都包含有最近访问的部分,因此缺页率较低。当页尺寸增加时,每页包含的单元和任何一个最近访问过的单元越来越远。因此局部性原理的影响被削弱,缺页率开始增长。
更为复杂的是,缺页率还取决于分配给一个进程的页框的数量。对固定的页尺寸,当内存中的页数量增加时,缺页率会下降。因此,软件策略(分配给每个进程的内存总量)影响着硬件设计决策(页尺寸)。
虚拟分段和段页式
分段允许程序员把内存视为由多个地址空间或段组成,段的大小不等,并且是动态的。内存访问以段号和偏移量的形式组成地址。其优点:
- 允许程序独立地改变或重新编译,而不要求整个程序集重新链接和重新加载。同样,这也是使用多个段实现的。
- 有助于进程间的共享。程序员可以在段中放置一个实用工具程序或一个有用的数据表,供其他进程访问。
- 有助于保护。由于一个段可被构造成包含一个明确定义的程序或数据集,因而程序员或系统管理员可以更方便地指定访问权限。
从内存中读一个字的基本机制,涉及使用段表来将段号和偏移量组成的虚拟地址(或逻辑地址)转换为物理地址。根据进程的大小,段表长度可变,无法在寄存器中保存,因此访问段表时它必须在内存中。当某个特定的进程正在运行时,有一个寄存器为该进程保存段表的起始地址。虚拟地址中的段号用于检索这个表,并查找该段起点的相应内存地址。这个地址加上虚拟地址中的偏移量部分,就形成了需要的实地址。
段页式系统
在段页式系统中,用户的地址空间被程序员划分为许多段。每段依次划分为许多固定大小的页,页的长度等于内存中的页框大小。若某段的长度小于一页,则该段只占据一页。从程序员的角度看,逻辑地址仍然由段号和段偏移量组成:从系统的角度看,段偏移量可视为指定段中的一个页号和页偏移量。
每个进程都使用一个段表和一些页表,且每个进程段使用一个页表。某个特定的进程运行时,使用一个寄存器记录该进程段表的起始地址。对每个虚拟地址,处理器使用段号部分来检索进程段表以寻找该段的页表。然后虚拟地址的页号部分用于检索页表并查找相应的页框号。这种方式结合虚拟地址的偏移部分,就形成了需要的实地址。
分段有助于实现保护与共享机制。实际上,操作系统要求的保护和共享功能通常在段一级处理。由于每个段表项包括一个长度和一个基地址,因而程序不会不经意地访问超出该段的内存单元。为实现共享,一个段可能会在多个进程的段表中引用。
操作系统软件的支持
这里主要涉及的是操作系统为虚存方案提供的算法。在段页式虚存系统中,操作系统所面临的内存管理问题大多数都与分页有关。
在各种情况下,最重要的都是与性能相关的问题:由于缺页中断会带来巨大的软件开销,所以希望使缺页中断发生的频率最小。这类开销至少包括决定置换哪个或哪些驻留页,以及交换这些页所需要的IO
操作。此外,在这个页IO
操作的过程中,操作系统还须调度另一个进程运行,即导致一次进程切换。因此,希望能通过适当的安排,使得在一个进程正在执行时,访问一个未命中的页中的字的概率最小。
读取策略和清除策略
读取策略
当一个进程首次启动时,会在一段时间出现大量的缺页故障;取入越来越多的页后,局部性原理表明大多数将来访问的页都是最近读取的页。因此,在一段时间后错误会逐渐减少,缺页率会降到很低。
读取策略决定某页何时取入内存,常用的两种方法是请求分页和预先分页。
对于请求分页,只有当访问到某页中的一个单元时才将该页取入内存。对于预先分页,读取的页并不是缺页中断请求的页。若一个进程的页连续存储在辅存中,则一次读取许多连续的页要比隔一段时间读取一页有效。
进程首先启动时或者发生缺页中断时,都可采用预先分配策略。
某个进程被换出内存并置于挂起态时,它的所有驻留页都会被换出。当该进程被唤醒时,所有以前在内存中的页都会被重新读回内存。
清除策略
与读取策略相反,清除策略用于确定何时将已修改的一页写回辅存。通常有两种选择:请求式清除和预约式清除。
对于请求式清除,只有当一页被选择用于置换时才被写回辅存;而预约式清除策略则将这些已修改的多页在需要使用它们所占据的页框之前成批写回辅存。
一种较好的方法是结合下一节介绍的页缓冲技术,这种技术允许采用下面的策略:只清除可用于置换的页。通过页缓冲,被置换页可放置在两个链表中:修改链表和未修改链表。修改链表中的页可以周期性地成批写出,并移到未修改链表中。未修改链表中的一页要么因为被访问到而被回收,要么在其页框分配给另一页时被淘汰。
放置策略
放置策略决定一个进程块驻留在实存中的什么位置。在段页式系统中,此策略无关紧要,因为地址转换硬件和内存访问硬件能以相同的效率为任何页框组合执行相应的功能。
置换策略和页缓冲
置换策略决定在必须读取一个新页时,应该置换内存中的哪一页。需要明确三个问题:
- 给每个活动进程分配多少页框。
- 计划被置换的页集局限于那些产生缺页故障的进程,还是局限于所有页框都在内存中的进程。
上面两个问题属于驻留集管理。置换策略专指第三个问题。
- 在计划被置换的页集,选择换出哪一页。
所有置换策略的目标都是移出最近最不可能访问的页。根据局部性原理,最近的访问历史和最近将要访问的模式间有很大的相关性。因此,大多数策略都基于过去的行为来预测将来的行为。
页框锁定
内存中的某些页框可能是被锁定的。被锁定的页框中的页不能被置换。操作系统内核和重要的控制结构就需要保存在被锁定的页框中。锁定是通过给每个页框关联一个“锁定”位实现的,这一位可以包含在页框表和当前页表中。
基本算法
最近最少使用(
LRU
,Least Recently Used
)该策略选择置换内存中最长时间未被引用的页。根据局部性原理,这也是最近最不可能访问到的页。
这种方法的问题是比较难以实现。一种实现方法是给每页添加一个最后一次访问的时间戳,并在每次访问内存时更新这个时间戳。另一种方法是维护一个关于访问页的栈,但开销同样很大。
先进先出(
FIFO
,First In First Out
)FIFO 策略把分配给进程的页框视为一个循环缓冲区,并按循环方式移动页。它需要的只是一个指针,该指针在进程的页框中循环。
这种方法所隐含的逻辑是置换驻留在内存中时间最长的页:很久以前取入内存的页,现在可能不会再用到。这一推断通常是错误的,因为经常会出现一部分程序或数据在整个程序的生命周期中使用频率都很高的情况,若使用 FIFO算法,则这些页需要被反复地换入和换出。
时钟(
Clock
)
页缓冲(Page Cache
)
页缓冲能够提高分页的性能并允许使用较简单的页面置换策略。
使用页缓冲的置换算法不丢弃置换出的页。若该页未被修改,则将它分配到空闲页链表中;若已被修改,则分配到修改页链表中。注意,该页在内存中并不会物理性移动,移动的只是该页所对应的页表项,移动后的页表项放置在空闲页链表中或修改页链表中。
空闲页链表中包含有可被置换页的一系列页框,需要从磁盘中读取一页到内存中时,都将其放到空闲页链表头节点所指向的页框中,随后将头节点移除。注意,系统记录的被置换的页并不是链表头节点所指向的页。
比如说,头节点当前指向内存中的页a
,系统通过置换策略决定出要用磁盘中的页b
置换内存中的页c
。实际执行的操作是,将页b
放置在页a
中,如果页c
未被修改,就将其加入到空闲页链表尾部,如果页c
已被修改,就将其加入到修改页链表中。
这一骚操作的一个重要特点是,被置换的页仍然留在内存中。因此,若进程访问该页,则可迅速返回该进程的驻留集,且代价很小。实际上,空闲页链表和修改页链表充当着页的高速缓存的角色。
修改页链表还有另外―种很有用的功能:已修改的页按簇写回,而不是一次只写一页,因此大大减少了I/O
操作的数量,进而减少了磁盘访问时间。
Page Cache
和块缓冲(Buffer Cache
)的区别
首先要明确一点,cache
是位于内存中的,是为了提高磁盘设备的IO
性能。程序读磁盘设备中的数据时,首先把需要访问的数据页及其相邻页面统一读到内存(预读取策略),然后从内存中读取数据。程序向磁盘设备中的文件写数据时,先将数据写入内存,然后再将内存中的脏数据页定时统一的刷新到磁盘中。
这个用作磁盘数据缓存的内存就是所谓的Buffer Cache
。主要是针对写数据场景的性能优化。也就是说Buffer Cache
是作为磁盘数据读写而存在的。
然而,文件系统层为了提高文件读写的性能,也提供了文件系统级别的Page Cache
。更多的是针对读数据场景的性能优化。也就是说Page Cache
是作为文件读写而存在的。
其实针对的都是磁盘中的数据,有两个缓存显得没有必要了,因此,现有的linux
系统版本已经将二者合并了,统称为Page Cache
。其是通过radix
树(基数树)数据结构实现的。
驻留集管理
驻留集是指一个进程被读取到内存中的页集。
- 分配给一个进程的内存越少,在任何时候驻留在内存中的进程数就越多。这增加了操作系统至少找到一个就绪进程的可能性,减少了由于交换而消耗的处理器时间。
- 若一个进程在内存中的页数较少,尽管有局部性原理,缺页率仍相对较高。
- 然而,给进程分配的内存空间大到一定程度时,由于局部性原理,缺页率也不会有明显的降低。
固定分配策略为一个进程分配固定数量的页框,以供执行时使用。这个数量由进程创建时确定。对于这种策略,一旦在进程的执行过程中发生缺页中断,该进程的一页就必须被它所需要的页面置换。
可变分配策略允许分配给一个进程的页框在该进程的生命周期中不断地发生变化。其大小可根据当前进程的缺页率大小来实时调整。理论上,若一个进程的缺页率一直比较高,则表明在该进程中局部性原理表现较弱,应给它多分配一些页框以减小缺页率;而若一个进程的缺页率特别低,则表明从局部性的角度看该进程的表现非常好,可在不明显增大缺页率的前提下减少分配给它的页框。
置换范围
局部置换策略在产生这次缺页的进程的驻留页中选择,而全局置换策略则把内存中所有未被锁定的页都作为置换的候选页,而不管它们属于哪个进程。
Linux
内存管理
虚存寻址
Linux
使用三级页表结构(最新版本已经使用四级页表了),它由下面几种类型的表组成(每个表的大小都是一页):
- 顶级页表是页全局目录(
PGD
),它包含了一个pgd_t
类型数组,多数体系结构中pgd_t
类型等同于无符号长整型类型。PGD
中的表项指向二级页目录中的表项:PMD
。每个活动进程的页目录都必须在内存中。 - 二级页表是中间页目录(
PMD
),它是个pmd_t
类型数组,其中的表项指向PTE
中的表项。页中间目录可能跨越多个页。页中间目录中的每项指向页表中的一页。 - 最后一级的页表简称页表,其中包含了
pte_t
类型的页表项,该页表项指向物理页面。页表也可跨越多个页。每个页表项指向该进程的一个虚拟页。
页面分配
页面分配为提升向内存中读入和从内存中写出页的效率,Linux
定义了一种机制,用于把连续的页映射到连续的页框中。基于这一目的,它使用了伙伴系统。内核维护一系列大小固定的连续页框组,一组可以包含1、2、4、8、16、32
个页框。当一页在内存中被分配或被解除分配时,可用的页框组使用伙伴算法来分裂或合并。
Linux
引入了一种新的分割LRU
算法。
新算法给每个页表项添加了两个有效位:PG_active
和PG_referenced
。Linux
的所有物理内存均基于它们的地址分配到两块“区域”,“激活”和“非激活”两个链表通过内存管理器来进行各区域的页面回收。内核驻留进程kswapd
在后台周期性地执行各区域的页面回收,它扫描那些与系统页框对应的页表项。对于所有标记为访问过的页表项,启用PG_referenced
有效位。处理器首次访问一个页面时,会启用这个标志位。kswapd
每次迭代时,都会检查页表项中的页面访问过标志位是否被启用。kswapd
在每次读取页面访问有效位后即将其清除。具体步骤如下:
- 访问非激活链表中的一页时,
PG_referenced
有效位启用。 - 页面下次被访问时,
PG_active
被置位,并将其移动到激活链表。也就是说,页面经访问两次后被声明为激活。更准确地讲,两次不同扫描的访问才使得一个页面变为激活状态。 - 若第二次访问并未很快发生,则重置
PG_referenced
。 - 同样,激活的页面在两次超时之后也需要移动到非激活链表中。
非激活链表中的页面然后可通过LRU
算法被置换。
页(page
)
内核把物理页作为内存管理的基本单位。尽管处理器的最小可寻址单位通常为字,但是,内存管理单元(MMU
,管理内存并把虚拟地址转换为物理地址的硬件)通常以页为单位进行处理。正因为如此,MMU
以页大小为单位来管理系统中的页表。从虚拟内存的角度来看,页就是最小单位。
内核用struct page
结构来表示系统中的每一个物理页,该结构位于<linux/mm_types.h>
:
1 | struct page |
flag
域用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。这些标志被定义在<linux/page-flags.h>
中。
count
域存放页的引用计数——也就是这一页被引用了多少次。当计数值变为-1
时,就说明当前内核并没有引用这一页,于是,在新的分配中就可以使用它。内核代码不应当直接检查该域,而是调用page_count()
函数进行检查,该函数唯一的参数就是page
结构。当页空闲时,尽管该结构内部的_count
值是负的,但是对page_count()
函数而言,返回0
表示页空闲,返回一个正整数表示页在使用。一个页可以由页缓存使用(这时,mapping
域指向和这个页关联的addresss_space
对象),或者作为私有数据(由private
指向),或者作为进程页表中的映射。
virtual
域是页的虚拟地址。通常情况下,它就是页在虚拟内存中的地址。
必须要理解的一点是page
结构与物理页相关,而并非与虚拟页相关。因此,该结构对页的描述只是短暂的。即使页中所包含的数据继续存在,由于交换等原因,它们也可能并不再和同一个page
结构相关联。内核仅仅用这个数据结构来描述当前时刻在相关的物理页中存放的东西。这种数据结构的目的在于描述物理内存本身,而不是描述包含在其中的数据。
内核用这一结构来管理系统中所有的页,系统中的每个物理页都要分配一个这样的结构体。因为内核需要知道一个页是否空闲(也就是页有没有被分配)。如果页已经被分配,内核还需要知道谁拥有这个页。拥有者可能是用户空间进程、动态分配的内核数据、静态内核代码或页高速缓存(Page Cache
)等。
区(zone
)
有些页位于内存中特定的物理地址上,所以不能将其用于一些特定的任务。由于存在这种限制,所以内核把页划分为不同的区。内核使用区对具有相似特性的页进行分组。
Linux
主要使用了三种区:
ZONE_DMA
:这个区包含的页能用来执行DMA
操作。ZONE_NORMAL
:这个区包含的都是能正常映射的页。ZONE_HIGHEM
:这个区包含“高端内存”,其中的页并不能永久地映射到内核地址空间。
这些区在<linux/mmzone.h>
中定义。
区的实际使用和分布是与体系结构相关的。例如,某些体系结构在内存的任何地址上执行DMA
都没有问题。在这些体系结构中,ZONE_DMA
为空,ZONE_NORMAL
就可以直接用于分配。与此相反,在x86
体系结构上,ISA
设备就不能在整个32
位的地址空间中执行DMA
,因为ISA
设备只能访问物理内存的前16MB
。因此,ZONE_DMA
在x86
上包含的页都在0-16MB
的内存范围里。
Linux
把系统的页划分为区,形成不同的内存池,这样就可以根据用途进行分配了。例如,ZONE_DMA
内存池让内核有能力为DMA
分配所需的内存。如果需要这样的内存,那么,内核就可以从ZONE_DMA
中按照请求的数目取出页。
有些64
位的体系结构,如Intel
的x86-64
体系结构可以映射和处理64
位的内存空间,所以x86-64
没有ZONE_HIGHMEM
区,所有的物理内存都处于ZONE_DMA
和ZONE_NORMAL
区。
注意,区的划分没有任何物理意义,这只不过是内核为了管理页而采取的一种逻辑上的分组。
每个区都用struct zone
表示,在<linux/mmzone.h>
中定义。
这个结构体很大,但是,系统中只有三个区,因此,也只有三个这样的结构。
lock
域是一个自旋锁,它防止该结构被并发访问。注意,这个域只保护结构,而不保护驻留在这个区中的所有页。没有特定的锁来保护单个页。watermark
数组持有该区的最小值、最低和最高水位值。内核使用水位为每个内存区设置合适的内存消耗基准。该水位随空闲内存的多少而变化。name
域是一个以NULL
结束的字符串表示这个区的名字。内核启动期间初始化这个值,其代码位于mm/page_alloc.c
中。分别为“DMA
“,“Normal
”和“HighMem
”。
获得与释放页
内核接口 | 解释 |
---|---|
struct page* alloc_pages(gfp_t gfp_mask, unsigned int order) |
该函数分配2^order^ (1 << order )个连续的物理页,并返回一个指针,该指针指向第一个页的page 结构体,如果出错,就返回NULL 。 |
void free_pages(unsigned long addr, unsigned int order) |
释放页,释放页时要谨慎,只能释放属于你的页。传递了错误的struct page 或地址,用了错误的order 值,这些都可能导致系统崩溃。 |
void kmalloc(size_t size, gfp_t flags) |
这个函数返回一个指向以字节为单位内存块的指针。所分配的内存区在物理上是连续的。出错,返回NULL 。它确保页在物理地址上是连续的。 |
void kfree(const void *ptr) |
释放由kmalloc() 分配出来的内存块。如果释放的内存不是由kmalloc() 分配的,或者释放的内存早就被释放了,调用这个函数就会导致严重的后果。调用kfree(NULL) 是安全的。 |
void* vmalloc(unsigned long size) |
分配的内存虚拟地址是连续的,而物理地址则无须连续。它通过分配非连续的物理内存块,再“修正”页表,把内存映射到逻辑地址空间的连续区域中。 |
void vfree(const void* addr) |
要释放通过vmalloc() 获得的内存。 |
大多数情况下,只有硬件设备需要得到物理地址连续的内存。在很多体系结构上,硬件设备存在于内存管理单元以外,它根本不理解什么是虚拟地址。因此,硬件设备用到的任何内存区都必须是物理上连续的块,而不仅仅是虚拟地址连续上的块。而仅供软件使用的内存块(例如与进程相关的缓冲区)就可以使用只有虚拟地址连续的内存块。对内核而言,所有内存看起来都是逻辑上连续的。
进程地址空间
内核除了管理本身的内存外,还必须管理用户空间中进程的内存。我们称这个内存为进程地址空间,也就是系统中每个用户空间进程所看到的内存。Linux
系统中的所有进程之间以虚拟方式共享内存。对一个进程而言,它好像都可以访问整个系统的所有物理内存。即使单独一个进程,它拥有的地址空间也可以远远大于系统物理内存。
进程地址空间由进程可寻址的虚拟内存组成,内核允许进程使用这种虚拟内存中的地址。每个进程都有一个32
位或64
位的平坦地址空间。术语“平坦”指的是地址空间范围是一个独立的连续区间。一个进程的地址空间与另一个进程的地址空间即使有相同的内存地址,实际上也彼此互不相干。这样的进程也就是Linux
中所谓的线程。
内存地址是一个给定的值,它要在地址空间范围之内,比如4021F000
。这个值表示的是进程32位地址空间中的一个特定的字节。尽管一个进程可以寻址4GB
的虚拟内存(在32位的地址空间中),但这并不代表它就有权访问所有的虚拟地址。在地址空间中,我们更为关心的是一些虚拟内存的地址区间,比如08048000 - 0804C000
,它们可被进程访问。这些可被访问的合法地址空间称为内存区域。通过内核,进程可以给自己的地址空间动态地添加或减少内存区域。
进程只能访问有效内存区域内的内存地址。每个内存区域也具有相关权限如对相关进程有可读、可写、可执行属性。如果一个进程访问了不在有效范围中的内存区域,或以不正确的方式访向了有效地址,那么内核就会终止该进程,并返回“段错误”信息。
内存描述符
Linux
内核使用内存描述符来表示进程的地址空间,该描述符表示着进程所有地址空间的信息。内存描述符由mm_struct
结构体表示,下面给出内存描述符结构中各个域的描述,请大家结合前面的进程内存段布局图一起看:
1 | struct mm_struct |
mm_count
代表了对mm_strcut
本身的引用,而mm_users
代表对mm_struct
相关资源的引用,分了两个层次。mm_count
类似于以进程为单位。 mm_users
类似于以线程为单位。内核线程在运行时会借用其他进程的mm_struct
,这样的线程叫anonymous users
,因为他们不关心mm_struct
指向的用户空间,也不会去访问这个用户空间,他们只是临时借用(比如说当进程执行系统调用陷入到内核中,内核会借用该进程的地址空间)。mm_count
记录这样的进程数。mm_users
是对mm_struct
所指向的用户空间进行共享的所有进程的计数。也就是说,会有多个进程共享同一个用户空间, 这些进程也就是所谓的线程。
mmap
和mm_rb
这两个不同数据结构体描述的对象是相同的:该地址空间中的全部内存区域。但是前者以链表形式存放而后者以红黑树的形式存放。mmap
结构体作为链表,利于简单、高效地遍历所有元素;而mm_rb
结构体作为红黑树,更适合搜索定位指定元素。
所有的mm_struct
结构体都通过自身的mmlist
域连接在一个双向链表中,该链表的首元素是init_mm
内存描述符,它代表init
进程的地址空间。另外要注意,操作该链表的时候需要使用mmlist_lock
锁来防止并发访问,该锁定义在文件kernel/fork.c
中。
每个进程都有自己的页表(当然,线程会共享页表)。内存描述符的pgd
域指向的就是进程的页全局目录。
内存描述符的分配与撤销
在进程的进程描述符(即task_struct
结构体)中,mm
域存放着该进程使用的内存描述符,所以current->mm
便指向当前进程的内存描述符。fork()
函数利用copy_mm()
函数复制父进程的内存描述符,也就是current->mm
域给其子进程,而子进程中的mm_struct
结构体实际是通过文件kernel/fork.c
中的allocate_mm()
宏从mm_cachep slab
缓存中分配得到的。通常,每个进程都有唯一的mm_struct
结构体,即唯一的进程地址空间。
如果父进程希望和其子进程共享地址空间,可以在调用clone()
时,设置CLONE_VM
标志。我们把这样的进程称作线程。当CLONE_VM
被指定后,内核就不再需要调用allocate_mm()
函数了,而仅仅需要调用copy_mm()
函数中将mm
域指向其父进程的内存描述符就可以了:
1 | if(clone_flags & CLONE_vM) |
当进程退出时,内核会调用定义在kernel/exit.c
中的exit_mm()
函数,该函数执行一些常规的撤销工作,同时更新一些统计量。其中,该函数会调用mmput()
函数减少内存描述符中的mm_users
用户计数,如果用户计数降到零,将调用mmdrop()
函数,减少mm_count
使用计数。如果mm_count
也等于零了,说明该内存描述符不再有任何使用者了,那么调用free_mm()
宏通过kmem_cache_free()
函数将mm_struct
结构体归还到mm_cachep slab
缓存中。
内核线程没有进程地址空间,也没有相关的内存描述符。所以内核线程对应的进程描述符中mm
域为空。事实上,这也正是内核线程的真实含义——它们没有用户上下文。当一个进程被调度时,该进程的mm
域指向的地址空间被装载到内存,进程描述符中的active_mm
域会被更新,指向新的地址空间。内核线程没有地址空间,所以mm
域为NULL
。于是,当一个内核线程被调度时,内核发现它的mm
域为NULL
,就会保留前一个进程的地址空间,随后内核更新内核线程对应的进程描述符中的active_mm
域,使其指向前一个进程的内存描述符。
所以在需要时,内核线程便可以使用前一个进程的页表。因为内核线程不访问用户空间的内存,所以它们仅仅使用地址空间中和内核内存相关的信息,这些信息的含义和普通进程完全相同(要知道用户进程的地址空间是包含分配给内核的那1G
的空间的,只不过是不允许访问而已,所以内核线程借用前一个进程的地址空间是用来访问属于内核的那1G
空间的)。
虚拟内存区域
每个和进程相关的内存区域都对应于一个vm_area_struct
结构体。vm_area_struct
结构体描述了指定地址空间内连续区间上的一个独立内存范围。内核将每个内存区域作为一个单独的内存对象管理,每个内存区域都拥有一致的属性,比如访问权限等,另外,相应的操作也都一致。按照这样的方式,每一个VMA
就可以代表不同类型的内存区域(比如内存映射文件或者进程用户空间栈),下面给出该结构定义和各个域的描述:
1 | struct vm_area_struct |
每个内存描述符都对应于进程地址空间中的唯一区间。vm_start
域指向区间的首地址(最低地址),vm_end
域指向区间的尾地址(最高地址)之后的第一个字节。注意,在同一个地址空间内的不同内存区间不能重叠。
vm_mm
域指向和VMA
相关的mm_struct
结构体,注意,每个VMA
对其相关的mm_struct
结构体来说都是唯一的,所以即使两个独立的进程将同一个文件映射到各自的地址空间,它们分别都会有一个vm_area_struct
结构体来标志自己的内存区域;反过来,如果两个线程共享一个地址空间,那么它们也同时共享其中的所有vm_area_struct
结构体。
上文讨论过,可以通过内存描述符中的mmap
和mm_rb
域之一访问内存区域。mmap
域使用单独链表连接所有的内存区域对象。每一个vm_area_struet
结构体通过自身的vm_next
域被连入链表,所有的区域按地址增长的方向排序,mmap
域指向链表中第一个内存区域,链中最后一个结构体指针指向空。mm_rb
域使用红黑树连接所有的内存区域对象。mm_rb
域指向红黑树的根节点,地址空间中每一个vm_area_struct
结构体通过自身的vm_rb
域连接到树中。
创建和删除地址空间
内核使用do_mmap()
函数创建一个新的线性地址区间。如果创建的地址区间和一个已存在的地址区间相邻,并且它们具有相同的访问权限的话,两个区间将合并为一个。映射时,从vm_area_cachep
长字节(slab
)缓存中分配一个vm_area_struct
结构体,并且使用vma_link()
函数将新分配的内存区域添加到地址空间的内存区域链表和红黑树中,随后还要更新内存描述符中的total_vm
域,然后才返回新分配的地址区间的初始地址。
do_mmap()
函数定义在文件<linux/mm.h>
中。
1 | unsigned long do_mmap(struct file *file, unsigned long addr, |
该函数映射由file
指定的文件,具体映射的是文件中从偏移offset
处开始,长度为len
字节的范围内的数据。如果file
参数是NULL
并且offset
参数也是0
,那么就代表这次映射没有和文件相关,该情况称作匿名映射(anonymous mapping
)。如果指定了文件名和偏移量,那么该映射称为文件映射(file-backed mapping
)。
addr
是可选参数,它指定搜索空闲区域的起始位置。prot
参数指定内存区域中页面的访问权限。访问权限标志定义在文件<asm/mman.h>
中。
在用户空间可以通过mmap()
系统调用获取内核函数do_mmap()
的功能。mmap()
系统调用
1 | void* mmap2(void* start, size_t length, int prot, int flags, int fd, off_t pgoff); |
由于该系统调用是mmap()
调用的第二种变种,所以起名为mmap2()
。最原始的mmap()
调用中最后一个参数是字节偏移量,而目前这个mmap2()
使用页面偏移作最后一个参数。使用页面偏移量可以映射更大的文件和更大的偏移位置。原始的mmap()
调用由POSIX
定义,仍然在C
库中作为mmap()
方法使用,但是内核中已经没有对应的实现了,而实现的是新方法mmap2()
。虽然C
库仍然可以使用原始版本的映射方法,但是它其实还是基于函数mmap2
进行的,对原始mmap()
方法的调用是通过将字节偏移转化为页面偏移,从而转化为对mmap2()
函数的调用来实现的。
do_mummap()
函数从特定的进程地址空间中删除指定地址区间,该函数定义在文件<linux/mm.h>
中:
1 | int do_mummap(struct mm_struct *mm , unsigned long start, size_t len); |
第一个参数指定要删除区域所在的地址空间,删除从地址start
开始,长度为len
字节的地址区间。如果成功,返回零。否则,返回负的错误码。
系统调用munmap
()给用户空间程序提供了一种从自身地址空间中删除指定地址区间的方法,它和系统调用mmap()
的作用相反,该系统调用定义在文件mm/mmap.c
中,它是对do_mummap()
函数的一个简单的封装。
1 | int munmap(void *start, size_t length); |
进程用户栈、线程栈、进程内核栈、中断栈
进程用户栈
进程栈是属于用户态栈,和进程虚拟地址空间 (Virtual Address Space
) 密切相关。那我们先了解下什么是虚拟地址空间:在32
位机器下,虚拟地址空间大小为4G
。这些虚拟地址通过页表 (Page Table
) 映射到物理内存,页表由操作系统维护,并被处理器的内存管理单元 (MMU
) 硬件引用。每个进程都拥有一套属于它自己的页表,因此对于每个进程而言都好像独享了整个虚拟地址空间。
Linux
内核将这4G
字节的空间分为两部分,将最高的1G
字节(0xC0000000
- 0xFFFFFFFF
)供内核使用,称为内核空间。而将较低的3G
字节(0x00000000
- 0xBFFFFFFF
)供各个进程使用,称为用户空间。每个进程可以通过系统调用陷入内核态,因此内核空间是由所有进程共享的。虽然说内核和用户态进程占用了这么大地址空间,但是并不意味它们使用了这么多物理内存,仅表示它可以支配这么大的地址空间。它们是根据需要,将物理内存映射到虚拟地址空间中使用。
Linux
对进程地址空间有个标准布局,地址空间中由各个不同的内存段组成 (Memory Segment
),主要的内存段如下:
- 程序段 (
Text Segment
):可执行文件代码的内存映射。 - 数据段 (
Data Segment
):可执行文件的已初始化全局变量的内存映射。 BSS
段 (BSS Segment
):未初始化的全局变量或者静态变量(映射到零页)。- 堆区 (
Heap
) : 存储动态内存分配,匿名的内存映射(malloc
分配的内存区域)。 - 栈区 (
Stack
) : 进程用户空间栈,由编译器自动分配释放,存放函数的参数值、局部变量的值等。 - 映射段(
Memory Mapping Segment
):任何内存映射文件,如每一个诸如C库或动态连接程序等共享库的代码段、数据段和BSS
也会被载入进程的地址空间。
可执行文件的BSS
段未存储在磁盘上,内核将零页面映射到BSS
范围。
因为在创建新进程时预期BSS
段将被初始化为零,并且仅在可执行文件中存储一堆零浪费了空间,所以可执行文件仅指示BSS
段应从何处开始以及应该从多大。
当内核从可执行文件构建新进程时,它将为BSS
范围创建到零页面的映射,该页面是全零的静态(虚拟)页面。该映射设置有写时复制功能,因此,新进程首次向其中一个BSS
页进行写入时,在允许写入完成之前,静态零页的实际副本将分配到另一个内存页中。 这样做有如下几点好处:
- 节省了可执行文件中的空间。
- 避免了实际分配可能永远不会被该进程触及的
BSS
页面,从而节省了内存使用量。 - 同时仍然提供了确保每个进程中的
BSS
段看起来都是零初始化的保证。
而上面进程虚拟地址空间中的栈区,正指的是我们所说的进程栈。进程栈的初始化大小是由编译器和链接器计算出来的,但是栈的实时大小并不是固定的,Linux
内核会根据入栈情况对栈区进行动态增长(其实也就是添加新的页表)。但是并不是说栈区可以无限增长,它也有最大限制RLIMIT_STACK
(一般为8M
),我们可以通过ulimit
来查看或更改RLIMIT_STACK
的值。
如何确认进程栈的大小
我们要知道栈的大小,那必须得知道栈的起始地址和结束地址。栈起始地址获取很简单,只需要嵌入汇编指令获取栈指针esp
寄存器的值即可。栈结束地址的获取有点麻烦,我们需要先利用递归函数把栈搞溢出了,然后再GDB
中把栈溢出的时候把栈指针esp
寄存器的值打印出来即可。代码如下:
1 | /* file name: stacksize.c */ |
进程栈的动态增长实现
进程在运行的过程中,通过不断向栈区压入数据,当超出栈区容量时,就会耗尽栈所对应的内存区域,这将触发一个 缺页异常 (page fault
)。通过异常陷入内核态后,异常会被内核的expand_stack()
函数处理,进而调用 acct_stack_growth()
来检查是否还有合适的地方用于栈的增长。
如果栈的大小低于RLIMIT_STACK
(通常为8MB
),那么一般情况下栈会被加长,程序继续执行,感觉不到发生了什么事情,这是一种将栈扩展到所需大小的常规机制。然而,如果达到了最大栈空间的大小,就会发生栈溢出(stack overflow
),进程将会收到内核发出的段错误(segmentation fault
)信号。
动态栈增长是唯一一种访问未映射内存区域而被允许的情形,其他任何对未映射内存区域的访问都会触发页错误,从而导致段错误。一些被映射的区域是只读的,因此企图写这些区域也会导致段错误。
线程栈
从Linux
内核的角度来说,其实它并没有线程的概念。Linux
把所有线程都当做进程来实现,它将线程和进程不加区分的统一到了task_struct
中。线程仅仅被视为一个与其他进程共享某些资源的进程,而是否共享地址空间几乎是进程和Linux
中所谓线程的唯一区别。线程创建的时候,加上了CLONE_VM
标记,这样 线程的内存描述符 将直接指向父进程的内存描述符。
虽然线程的地址空间和进程一样,但是对待其地址空间的栈stack
还是有些区别的。对于Linux
进程或者说主线程,其stack
是在fork
的时候生成的,实际上就是复制了父亲的stack
空间地址,然后写时拷贝 (cow
) 以及动态增长。然而对于主线程生成的子线程而言,其stack
将不再是这样的了,而是事先固定下来的,使用mmap
系统调用,它不带有VM_STACK_FLAGS
标记。
这个可以从glibc
的nptl/allocatestack.c
中的allocate_stack()
函数中看到:
1 | mem = mmap (NULL, size, prot, MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); |
由于线程的mm->start_stack
栈地址和所属进程相同,所以线程栈的起始地址并没有存放在task_struct
中,应该是使用pthread_attr_t
中的stackaddr
来初始化task_struct->thread->sp
(sp
指向struct pt_regs
对象,该结构体用于保存用户进程或者线程的寄存器现场)。这些都不重要,重要的是,线程栈不能动态增长,一旦用尽就没了,这是和生成进程的fork
不同的地方。由于线程栈是从进程的地址空间中map
出来的一块内存区域,原则上是线程私有的。
进程内核栈
在每一个进程的生命周期中,必然会通过到系统调用陷入内核。在执行系统调用陷入内核之后,这些内核代码所使用的栈并不是原先进程用户空间中的栈,而是一个单独内核空间的栈,这个称作进程内核栈。进程内核栈在进程创建的时候,通过slab
分配器从thread_info_cache
缓存池中分配出来,其大小为THREAD_SIZE
,一般来说是一个页大小4K
:
1 | union thread_union |
thread_union
进程内核栈和task_struct
进程描述符有着紧密的联系。由于内核经常要访问task_struct
,高效获取当前进程的描述符是一件非常重要的事情。因此内核将进程内核栈的头部一段空间,用于存放thread_info
结构体,而此结构体中则记录了对应进程的描述符,两者关系如下图:
有了上述关联结构后,内核可以先获取到栈顶指针esp
,然后通过esp
来获取thread_info
。这里有一个小技巧,直接将esp
的地址与上~(THREAD_SIZE - 1)
后即可直接获得thread_info
的地址。由于thread_union
结构体是从thread_info_cache
的Slab
缓存池中申请出来的,而thread_info_cache
在kmem_cache_create
创建的时候,保证了地址是THREAD_SIZE
对齐的。因此只需要对栈指针进行THREAD_SIZE
对齐,即可获得thread_union
的地址。成功获取到thread_info
后,直接取出它的task
成员就成功得到了task_struct
。其实上面这段描述,也就是current
宏的实现方法:
1 | register unsigned long current_stack_pointer asm ("sp"); |
中断栈
进程陷入内核态的时候,需要内核栈来支持内核函数调用。中断也是如此,当系统收到中断事件后,进行中断处理的时候,也需要中断栈来支持函数调用。由于系统中断的时候,系统当然是处于内核态的,所以中断栈是可以和内核栈共享的。但是具体是否共享,这和具体处理架构密切相关。
x86
上中断栈就是独立于内核栈的;独立的中断栈所在内存空间的分配发生在arch/x86/kernel/irq_32.c
的irq_ctx_init()
函数中(如果是多处理器系统,那么每个处理器都会有一个独立的中断栈),函数使用__alloc_pages
在低端内存区分配2
个物理页面,也就是8KB
大小的空间。有趣的是,这个函数还会为softirq
分配一个同样大小的独立堆栈。如此说来,softirq
将不会在hardirq
的中断栈上执行,而是在自己的上下文中执行。
为什么需要单独的进程内核栈?
(为什么需要单独的线程栈?进程和线程是否共享一个内核栈?)
所有进程运行的时候,都可能通过系统调用陷入内核态继续执行。假设第一个进程A
陷入内核态执行的时候,需要等待读取网卡的数据,主动调用schedule()
让出CPU
;此时调度器唤醒了另一个进程B
,碰巧进程B
也需要系统调用进入内核态。那问题就来了,如果内核栈只有一个,那进程B
进入内核态的时候产生的压栈操作,必然会破坏掉进程A
已有的内核栈数据;一但进程A
的内核栈数据被破坏,很可能导致进程A
的内核态无法正确返回到对应的用户态了。
**这三个问题很好理解,只要可被调度执行的对象之间共用一个栈(无论进程栈,内核栈还是线程栈),那必然会有出错的机会,所以不能共用。进程和同一个进程的线程都是可被调度执行的对象!