早期程序直接运行在物理内存上,直接操作物理内存,这种方式存在几个问题:
地址空间不隔离,程序操作相同地址空间会造成相互影响甚至崩溃,安全性也得不到保障
使用效率低:没有特别好的策略保证多个进程对超过物理内存大小的内存需求的满足
程序运行地址不确定:程序运行时都需要分配空闲区域,而空闲区域不确定,因而不能确定程序被放在什么位置,那么其中的数据、函数等位置都是不确定的,会带来一些重定位的问题
内存管理主要就是为了解决以上三个问题的。
计算机系统里任何问题都可以靠引入一个中间层来解决,内存管理就在程序和物理内存之间引入了虚拟内存的概念。
一、虚拟内存中的几种地址
1、逻辑地址
逻辑地址就是图中左上角的Logical Address,这是在内存分段(Segmentation)模型中使用的地址。逻辑地址由两部分组成:段选择器(Segment Selector)和偏移量(Offset)。段选择器指向内存中的一个段(位于段描述符表中),偏移量是在该段内的位置。如[CS:EIP]。
在 Intel x86 架构的早期,CS (代码段寄存器) 和 DS (数据段寄存器) 主要用于存储代码段和数据段的起始地址。然而,随着 x86 架构从实模式迁移到保护模式,并引入分段内存管理,CS 和 DS 寄存器的角色发生了变化。在保护模式下,这些寄存器不再直接存储段的物理起始地址,而是存储段描述符表(Segment Descriptor Tables)中的索引即段标识符,这些表(包括全局描述符表 GDT 和局部描述符表 LDT)含有对应段的全部信息(包括基地址、段的长度、访问权限等)。
CS(代码段寄存器)和 DS(数据段寄存器)中的值是在程序加载和执行的过程中确定的。这些值的设定由操作系统的内存管理部分来负责。
具体过程如下:
当程序被加载到内存中时,操作系统会建立全局描述符表(GDT)和局部描述符表(LDT),并在其中设定各个段(如代码段、数据段等)的描述符。
在程序开始执行时,操作系统会将对应的段描述符在描述符表中的索引加载到相应的段寄存器(CS,DS等)中。这样,这些寄存器就可以通过这些索引访问到对应段的描述符,从而得到段的各种信息(包括起始地址、长度、权限等)。
在程序运行过程中,如果需要切换到其他的段(例如由于函数调用或任务切换等),操作系统或程序会更新段寄存器中的值,从而指向新的段描述符。
全局描述符表(Global Descriptor Table,简称GDT)和局部描述符表(Local Descriptor Table,简称LDT)都是在 Intel x86 架构中实现内存保护和任务切换的重要组成部分。两者都是存储段描述符(Segment Descriptor)的表格,每个段描述符包含了一段内存的起始地址、长度、权限等信息。
GDT 和 LDT 的主要区别在于它们的作用范围和用途:
全局描述符表(GDT):如其名字所示,GDT 是全局的,意味着它对整个系统是可见的。每个处理器(在多处理器系统中)都有其自己的 GDT。在系统启动时,操作系统会为系统中的每个处理器设置一个 GDT,其中包含了系统中所有的内存段。GDT 中的一些条目也常常被用于实现任务切换(通过任务状态段,Task State Segment,简称TSS)。
局部描述符表(LDT):相比之下,LDT 是局部的,通常是特定于某个任务或进程的。每个任务或进程可以有自己的 LDT,其中包含了这个任务或进程所需要访问的内存段。LDT 允许每个任务或进程有其自己的独立的地址空间,从而实现了任务或进程间的内存保护。
所以,GDT 和 LDT 的主要区别在于作用范围和使用场景。GDT 是全局的,用于整个系统的内存管理和任务切换,而 LDT 是局部的,用于特定任务或进程的内存管理。
如果一个段是整个系统都可能需要访问的,那么它的段描述符通常会被保存在全局描述符表(GDT)中。例如,操作系统内核的代码段和数据段,以及用于实现任务切换的任务状态段(TSS)的段描述符,通常都会保存在 GDT 中。
如果一个段只属于某个特定的任务或进程,那么它的段描述符通常会被保存在这个任务或进程的局部描述符表(LDT)中。这样可以确保每个任务或进程有自己独立的地址空间,从而实现了任务或进程间的内存保护。
2、线性地址
在Intel x86架构中,线性地址通常是指通过段选择器和偏移量经过分段映射后得到的地址,即由逻辑地址转换过来的。
如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址。对于没有启用分页机制的操作系统中,线性地址可以直接作为物理地址使用。
在Linux系统中虽然保留了段机制,但是所有的程序的段地址都定死为0-4G,所以虽然逻辑地址和线性地址是两种不同的地址空间,但在Linux中逻辑地址就等于线性地址,它们的值是一样的(因为段基址为0)。
按照 Intel 的设计,段式内存管理中的段类型分为三种:代码段、数据段、系统段(TSS之类的),实在是太麻烦了。我们只靠页式内存管理就已经可以完成Linux内核需要的所有功能,根本不需要段映射。
Linux为了简化处理,采用了平坦内存模型。在这个模型中,所有的段(无论是代码段CS还是数据段DS)的基址都被设置为0,限长被设置为最大的地址,也就是4GB(对于32位系统)或更大(对于64位系统)。这样,每一个段都覆盖了整个线性地址空间,即从0到最大的地址。也就是说,逻辑地址的段部分没有实际的作用,起作用的是偏移量部分。因此,在Linux系统中,虽然我们仍然保留了代码段(CS)和数据段(DS)这样的名词,但它们实际上都指向同一个,覆盖了整个地址空间的段。这样做的结果是,对于任何一个给定的逻辑地址,只需要看它的偏移量就可以知道它在内存中的位置,不需要去查找段描述符表和进行基址加偏移的运算。这大大简化了内存管理,尤其是在进行上下文切换时,因为不需要去加载不同的段表。
平坦内存模式把全部系统内存表示为连续的地址空间。所有指令、数据和堆栈都包含在相同的地址空间中。通过称为线性地址(linear address)的特定地址访问每个内存位置。
分段内存模式把系统内存划分为独立段的组,通过位于段寄存器中的指针进行引用。每个段用于包含特定类型的数据。一个段用于包含指令码,另一个段用于包含数据元素,第三个段用于包含程序堆栈。段中的内存位置是通过逻辑地址定义的。逻辑地址由段地址(存放在段寄存器中)和偏移地址构成。处理器把逻辑地址转换为相应的线性地址位置以便访问内存的字节。
3、逻辑地址转线性地址
在 Intel 平台下,逻辑地址是 selector:offset 这种形式,selector 可以是代码段或者数据段,offset 是段内偏移。如果用 selector 去 GDT里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了线性地址。我们把这个过程称作段式内存管理。具体过程如下:
先从段选择符(selector)中得到段描述符;
从段描述符中得到段基地址;
线性地址=段基地址+段内偏移
段选择符的组成如下:
|-----------------------------------|-----|--------| | 索引号(13) |TI(1)| RPL(2) | |-----------------------------------|-----|--------|
TI:表指示器,表示使用的是哪个段描述符表(GDT或LDT)
RPL:请求者特权级
根据这个索引号就可以去对应的段描述表中找到段描述符(进程加载到内存时设置的),之后就可以拿到段基址
4、线性地址转物理地址
线性地址被分为以固定长度为单位的组,称为页(page)。例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,那么整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。
而物理内存也按同样的方式划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。
要将线性地址转换为物理地址,首先将线性地址分为三段,页目录索引、页表索引和页内偏移。
页目录索引(Page Directory Index):用于在页目录中查找对应的页表入口。
页表索引(Page Table Index):用于在找到的页表中查找对应的页框入口。
页内偏移(Offset):在找到的页框内的具体地址。
用页目录索引去页目录(PGD)中拿到页表入口,接着用页表索引去页表(Page Table)中拿到页表项(Page Table Entry)。
页表项(Page Table Entry,简称PTE)是页表的组成部分,其主要目标是存储虚拟地址到物理地址的映射信息。每一个页表项对应一个虚拟页面到物理页面的映射。
页表项的具体组成取决于使用的硬件架构和内存管理单元(MMU),但通常包含以下几个部分:
物理页面的基址:这部分存储物理内存中页面的起始地址。
标志位:这部分包含了若干个标志位,用来表示这个页面的一些状态。常见的标志位有:
P(Present):如果这个位被设置,表示这个页面是存在的(即在物理内存中)。
R/W(Read/Write):如果这个位被设置,表示这个页面是可写的;如果未被设置,表示这个页面是只读的。
U/S(User/Supervisor):如果这个位被设置,表示这个页面可以被用户模式的代码访问;如果未被设置,表示这个页面只能被内核模式的代码访问。
其他的标志位可能包括页面是否被访问(Accessed),页面是否被写入(Dirty),页面是否可以被缓存(Cacheable)等等。
其他信息:页表项还可能包含一些其他信息,比如用于页置换算法的信息(如页面最后一次被访问的时间),用于实现高级内存管理特性的信息(如页面保护)等等。
也就是说找到页表项后,就可以找到里面存储的物理内存块的起始地址(其实就是是物理内存编号),把它加上页内偏移就得到了最终的物理地址。我们把这个过程称作页式内存管理。
进程需要1M个页表项(4GB / 4KB = 1M, 2^20个页表项),即页表(每个进程都有一个页表)占用4MB(1M * 4B = 4MB)的内存空间,4MB需要1K个页才足够存放。如果使用多级页表,那么也就还需要1K个页表项(页目录,页表的页表)来进行映射,似乎占用的内存更多了,那为什么还要使用多级页表?
这是因为虽然每个进程都有4GB的虚拟地址空间,但是对于大多数程序来说,其使用到的空间远未达到4GB,何必去映射不可能用到的空间呢?
也就是说,我们只需要使用4KB(一个页)就可以覆盖了整个4GB虚拟地址空间,只需要首级页表存在主存里,二级页表可以在首次使用时再创建,这样就大大减少了常驻主存的页表数量。如果其中某个首级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了。
对于64位的处理器,由于虚拟地址空间的增大,就需要有更多级别的页目录,如 Page Upper Directory (PUD),Page Middle Directory (PMD),和 Page Table 本身。
每个级别的页目录都有相应的索引部分在虚拟地址中,用于在该级别的页目录中查找对应的项。例如,在 x86-64 架构中,一个线性地址通常由以下几个部分组成:
Page Global Directory Index (PGD Index):用于在 PGD 中查找对应的 PUD。
Page Upper Directory Index (PUD Index):用于在找到的 PUD 中查找对应的 PMD。
Page Middle Directory Index (PMD Index):用于在找到的 PMD 中查找对应的 Page Table。
Page Table Index (PT Index):用于在找到的 Page Table 中查找对应的页框(Page Frame)。
Offset:在找到的页框内的具体地址。
每个进程都有自己的页目录,当进程处于运行态的时候,其页目录地址存放在cr3寄存器中。
“问题来了,为什么没提到virtual address,这是个什么东西?其实在 Intel IA-32 手册里并没有提到这个术语,但是在内核的确是用到了这个概念,比如__va和__pa这两个宏定义。经过我的考证,virtual address就是linear address的别名,俩词汇是一个意思,内核代码和我们编程中喜欢用virtual address这个术语,而Intel手册里只用linear address这个术语。”
引用自Linux 线性地址,逻辑地址和虚拟地址的关系?
二、进程与内存
1、内核空间和用户空间
Linux操作系统采用虚拟内存管理技术,使得每个进程都有各自互不干涉的进程地址空间。该空间是块大小为4G(对32位而言)的线性虚拟空间,其中内核空间占1GB,用户空间占3GB。即这些内存都是虚拟出来的,实际上还需要经过页式转换才能找到实际上在内存中的地址(Linux的段式管理事实上只是“哄骗”了一下硬件而已,按照Intel的本意是需要去通过段描述符来拿到段基址,之后再与偏移地址相加来拿到线性地址的,但是Linux对所有的进程都使用了相同的段来对指令和数据寻址。即所有的段的基地址都是0,段长4G。所以也就是说进程使用的地址可以直接理解为是线性地址,因为段基址都是0,只需要进行页式转换即可)。
用户空间与内核空间是人为划分的,用户空间从0到3G(0xC0000000),内核空间占据3G到4G。用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间虚拟地址。只有用户进程进行系统调用(代表用户进程在内核态执行)等时刻可以访问到内核空间。
用户空间对应进程,所以每当进程切换,用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址有自己对应的页表(init_mm.pgd),用户进程各自有不同的页表。
每个进程的用户空间都是完全独立、互不相干的。
内核空间包括内核镜像、物理页面表、驱动程序等
用户空间分为五个不同的区域:
- 代码段:只读,存放可执行文件的操作指令;镜像;
- 数据段:存放可执行文件中已初始化全局变量;存放静态变量和全局变量;
- BSS段:未初始化全局变量;
- 堆:存放被动态分配的内存段;
- 栈:存放临时创建的局部变量;
这里的段和前面提到的是不同层次上的概念,可以理解为前面是操作系统的段式管理为每个进程都划分出了一个大的0-4G的段。段式管理也有数据段,代码段,但是在Linux里都不管了,全部都划到这个4G的段里。然后在这个段中再进行一个划分,按照不同属性抽象出了这五个不同的区域。这些区域只是说把相同属性的数据集中放在一起,但实际上进程使用的地址是其在这个虚拟空地址间中的地址,而不是具体的堆、段等这些分区的偏移地址(这就是前面说到的和段式管理是不同层次的概念,段式管理中进程使用的数据地址是相对于段的偏移量,而这里的五个区域是段中自行划分的,只是一个抽象的概念)。
2、内存映射
物理地址空间是有限的,虚拟地址空间可以是任意大小(受限于CPU位数),对于32位的CPU,虚拟地址空间通常为2^32即4G,其中内核空间占1GB,用户空间占3GB。如果物理内存也是4GB的大小,那么他们之间的映射关系如下图:
因为内核的虚拟地址空间只有1GB,但它需要访问整个4GB的物理空间,因此从物理地址0~896MB的部分(ZONE_DMA+ZONE_NORMAL),直接加上3GB的偏移(在Linux中用PAGE_OFFSET表示),就得到了对应的虚拟地址,这种映射方式被称为线性/直接映射(Direct Map)。
而896M-4GB的物理地址部分(ZONE_HIGHMEM)需要映射到(3G+896M)-4GB这128MB的虚拟地址空间,显然也按线性映射是不行的。采用的是做法是,ZONE_HIGHMEM中的某段物理内存和这128M中的某段虚拟空间建立映射,完成所需操作后需要断开与这部分虚拟空间的映射关系,以便ZONE_HIGHMEM中其他的物理内存可以继续往这个区域映射,即动态映射的方式。
在64位系统中,内核空间的映射变的简单了,因为这时内核的虚拟地址空间已经足够大了,即便它要访问所有的物理内存,直接映射就是,不再需要ZONE_HIGHMEM那种动态映射机制了。
3、进程内存分配与回收
进程所能直接操作的地址都为虚拟地址。当进程需要内存时,从内核获得的仅仅是虚拟的内存区域,而不是实际的物理地址,进程并没有获得物理内存(物理页面),获得的仅仅是对一个新的线性地址区间的使用权。实际的物理内存只有当进程真的去访问新获取的虚拟地址时,才会由“请求页机制”产生“缺页”异常,从而进入分配实际页面的例程。
该异常是虚拟内存机制赖以存在的基本保证——它会告诉内核去真正为进程分配物理页,并建立对应的页表,这之后虚拟地址才实实在在地映射到了系统的物理内存上(当然,如果页被换出到磁盘,也会产生缺页异常,不过这时不用再建立页表了)。这种请求页机制把页面的分配推迟到不能再推迟为止,节约了空闲内存。
当程序试图访问的内存页面不在物理内存中时(也就是说,这个页面被换出到磁盘(页表项有效位为0),或者还未被分配(没有对应页表项)),处理器会触发一个缺页异常。这个异常会导致当前的程序暂停,并且切换到操作系统的内核模式。
操作系统的缺页异常处理程序会首先检查这个访问是否有效(也就是说,程序是否有权访问这个地址)。如果这个访问是无效的(例如,程序试图访问它没有权限访问的内存),操作系统会终止这个程序。
如果访问是有效的,缺页异常处理程序会试图修复这个异常。它可能会从磁盘的交换空间中读取所需的页面,或者分配一个新的页面。然后,它会更新页表,把虚拟地址映射到新加载或新分配的物理页面上。
页表项(Page Table Entry,PTE)与物理内存基址的对应关系由内存管理单元(MMU)和操作系统的内存管理子系统共同决定。以下是这个过程的大致步骤:
内存分配:当一个进程需要更多的内存时(例如,因为程序的执行或者动态内存分配请求),操作系统会分配一个或多个物理内存页给这个进程。操作系统通常会选择一些空闲的、未被其他进程使用的内存页进行分配。
虚拟地址选择:操作系统为这些新分配的内存页选择一些虚拟地址。这些虚拟地址通常会在进程的虚拟地址空间中找到。
页表更新:操作系统在页表中创建或更新一些页表项,将新选择的虚拟地址映射到新分配的物理内存页。具体来说,操作系统会将每个页表项的物理地址部分设置为对应的物理内存页的基址。
内存访问:之后,当CPU执行该进程的代码时,如果遇到对这些虚拟地址的访问,MMU会通过查找页表,将虚拟地址转换为对应的物理地址,然后访问对应的物理内存。
这样,页表项和物理内存基址的对应关系就是由操作系统在分配内存和更新页表时确定的。这个对应关系是动态的,可以随着内存的分配和释放,进程的创建和销毁,以及内存管理的其他活动而改变。
参考资料:
逻辑地址、物理地址、虚拟地址_虚拟地址是逻辑地址吗_闫晟的博客-CSDN博客
linux内核中 逻辑地址、虚拟地址、线性地址和物理地址大扫盲
Linux的进程地址空间[一] - 知乎 (zhihu.com)
【转】Linux内存管理(最透彻的一篇) - ralap7 - 博客园 (cnblogs.com)
操作系统中的多级页表到底是为了解决什么问题?