文章目录
- 一、实模式寻址
- 二、保护模式寻址
- 三、段页式内存管理
- 四、Linux的内存寻址
- 五、进程与内存
- 1、内核空间和用户空间
- 2、内存映射
- 3、进程内存分配与回收
一、实模式寻址
在16位的8086时代,CPU为了能寻址超过16位地址能表示的最大空间(因为 8086 的地址线 20 位而数据线 16 位),引入了段寄存器。通过将内存空间划分为若干个段(段寄存器像 ds、cs、ss 这些寄存器用于存放段基址),然后采用段基地址+段内偏移的方式访问内存,这样能访问1MB的内存空间了。
使用这样的寻址方式的好处是所见即所得,程序员指定的地址就是物理地址,物理地址对程序员是可见的。但是,由此也带来两个问题:
- 无法支持多任务
- 程序的安全性无法得到保证(用户程序可以改写系统空间或者其他用户的程序内容)。
实模式将整个物理内存看成分段的区域,程序代码和数据位于不同区域,系统程序和用户程序没有区别对待,而且每一个指针都是指向"实在"的物理地址。
这样一来,用户程序的一个指针如果指向了系统程序区域或其他用户程序区域,并改变了值,那么对于这个被修改的系统程序或用户程序,其后果就很可能是灾难性的。
为了克服这种低劣的内存管理方式,处理器厂商开发出保护模式:物理内存地址不能直接被程序访问,程序内部的地址(虚拟地址)要由操作系统转化为物理地址去访问,从而保护进程地址空间,程序对此一无所知。
二、保护模式寻址
从IA-32开始,cpu有三种工作方式:实模式,保护模式和虚拟8086模式。只有在刚刚启动的时候是实模式,等操作系统运行起来以后就运行在保护模式。虚拟8086模式是运行在保护模式中的实模式,为了在32位保护模式下执行纯16位程序,它不是一个真正的CPU模式,还属于保护模式。在保护模式下,CPU 有更强的寻址能力。
两者的区别主要体现在段寄存器如CS,DS,ES,SS的解释方式不同——实模式解释为段寄存器,保护模式解释为段选择子。
在实模式(也就是16位模式)情况下,一个地址由段和偏移两部分组成,计算公式为:Segment << 4 + Offset(所以实模式下只能访问1M的内存空间)。CS (代码段寄存器) 和 DS (数据段寄存器) 主要用于存储代码段和数据段的起始地址。
在保护模式下寻址方式还是段基址+偏移地址。但是此时寄存器不再直接存储段的基地址,而是存储段描述符表中的索引即段选择子,如下:
|-----------------------------------|-----|--------|
| 索引号(13) |TI(1)| RPL(2) |
|-----------------------------------|-----|--------|
TI:表指示器,表示使用的是哪个段描述符表(GDT或LDT)
RPL:请求者特权级
段基址不直接放在段寄存器了,而是在 GDT即全局描述符表(或LDT即局部描述符表)中,如下:
此外CPU中单独添置了两个寄存器,用来指向这两个表,分别是gdtr和ldtr。在寻址的时候,CPU首先根据段寄存器的TI位和gdtr或ldtr找到描述符表,之后根据段寄存器的索引号到GDT/LDT中找出对应的段描述符,然后再取出这个段的基地址,最后再结合段内的偏移完成内存寻址。
三、段页式内存管理
x86架构的CPU(保护模式下)采用的是分段+分页的内存管理方式,上述根据段基址和偏移地址其实只是段寻址的过程,用于将逻辑地址转换为线性地址,所以还需要进行页寻址,将线性地址转换为物理地址。
要将线性地址转换为物理地址,那就得有地方记录它们之间的映射关系,这是通过页表的实现的。页表是用来记录虚拟内存页面和物理内存页面之间的映射关系的,每一个页表项记录一个页面的映射关系。但进程的地址空间很大,这样算下来需要的页表项的数量也会非常多。而实际上进程地址空间中很多页面都没有真正使用,也就没有映射关系,这样是一种浪费。为了解决这个问题,CPU引入了多级页表的机制,在32位下一般是2级页表,像下面这样:
线性地址被分为三段,页目录索引、页表索引和页内偏移:
- 页目录索引(Page Directory Index):用于在页目录中查找对应的页表入口。
- 页表索引(Page Table Index):用于在找到的页表中查找对应的页框入口。
- 页内偏移(Offset):在找到的页框内的具体地址。
用页目录索引去页目录(PGD)中拿到页表入口,接着用页表索引去页表(Page Table)中拿到页表项(Page Table Entry)。
页表项(Page Table Entry,简称PTE)是页表的组成部分,其主要目标是存储虚拟地址到物理地址的映射信息。每一个页表项对应一个虚拟页面到物理页面的映射。找到页表项后,就可以找到里面存储的物理内存块的起始地址(其实就是是物理内存编号),把它加上页内偏移就得到了最终的物理地址。我们把这个过程称作页式内存管理。
上面这些地址转换的实现,就是由 MMU 来完成的。
虚拟地址和线性地址:
其实在 Intel IA-32 手册里并没有提到虚拟地址这个术语,但是在内核的确是用到了这个概念,比如__va和__pa这两个宏定义。经过我的考证,virtual address就是linear address的别名,俩词汇是一个意思,内核代码和我们编程中喜欢用virtual address这个术语,而Intel手册里只用linear address这个术语。
引用自Linux 线性地址,逻辑地址和虚拟地址的关系?
四、Linux的内存寻址
按照 Intel 的设计,段式内存管理中的段类型分为代码段、数据段、栈段、扩展段四个段(即对应CPU中的cs、ds、ss、es四个段寄存器),实在是太麻烦了。我们只靠页式内存管理就已经可以完成Linux内核需要的所有功能,根本不需要段映射。
因此Linux为了简化处理,采用了平坦内存模型。在这个模型中,所有的段的基址都被设置为0,限长被设置为最大的地址,也就是4GB(对于32位系统)或更大(对于64位系统)。这样,每一个段都覆盖了整个线性地址空间,即从0到最大的地址。也就是说在Linux系统中虽然保留了段机制,但是进程的代码段、数据段、栈段、扩展段这四个段全部重合了,而且是整个进程地址空间共计4GB成为了一个段。虽然仍然有代码段(CS)和数据段(DS)这样的名词,但它们实际上都指向同一个,覆盖了整个地址空间的段。
所以说起来是分段,实际上等于没分了,再加上段的基地址全部是0,那进行地址翻译的时候,对于任何一个给定的逻辑地址,只需要看它的偏移量就可以知道它在内存中的位置,不需要去查找段描述符表和进行基址加偏移的运算。这大大简化了内存管理,尤其是在进行上下文切换时,因为不需要去加载不同的段表。即虽然逻辑地址和线性地址是两种不同的地址空间,但在Linux中逻辑地址就等于线性地址。
GDT、LDT是供分段式内存使用的设施,Intel/AMD的64位模式下内存并不分段(硬件保证所有段的基址都是0),所以它们就用不上了。32位模式下还是需要的,虽然当今主流32位操作系统也是平坦(flat)内存模型,但是操作系统是以软件将每个段都设置为0基址,也就是通过正确初始化GDT、LDT来实现的。
Linux的段式管理事实上只是“哄骗”了一下硬件而已,按照Intel的本意是需要去通过段描述符来拿到段基址,之后再与偏移地址相加来拿到线性地址的,但是Linux对所有的进程都使用了相同的段来对指令和数据寻址。即所有的段的基地址都是0,段长4G。所以也就是说进程使用的地址可以直接理解为是线性地址,因为段基址都是0,只需要进行页式转换即可。但这并不意味着段机制彻底没用到,CPU的任务管理TSS还是需要用到的。
五、进程与内存
1、内核空间和用户空间
Linux操作系统采用虚拟内存管理技术,使得每个进程都有各自互不干涉的进程地址空间。该空间是块大小为4G(对32位而言)的线性虚拟空间,其中内核空间占1GB,用户空间占3GB。
- 用户空间与内核空间是人为划分的,用户空间从0到3G(0xC0000000),内核空间占据3G到4G。用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间虚拟地址。只有用户进程进行系统调用(代表用户进程在内核态执行)等时刻可以访问到内核空间。
- 用户空间对应进程,所以每当进程切换,用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址有自己对应的页表(init_mm.pgd),用户进程各自有不同的页表。
- 每个进程的用户空间都是完全独立、互不相干的。
内核空间包括内核镜像、物理页面表、驱动程序等
用户空间分为五个不同的区域:
- 代码段:只读,存放可执行文件的操作指令;镜像;
- 数据段:存放可执行文件中已初始化全局变量;存放静态变量和全局变量;
- BSS段:未初始化全局变量;
- 堆:存放被动态分配的内存段;
- 栈:存放临时创建的局部变量;
这里的段和前面提到的是不同层次上的概念,可以理解为前面是操作系统的段式管理为每个进程都划分出了一个大的0-4G的段。段式管理中划分为了数据段、代码段、栈段和扩展段,但是在Linux里都不管了,全部都划到这个4G的段里。然后在这个段中再进行一个划分,按照不同属性抽象出了这五个不同的区域,将相同属性的数据集中放在一起。
2、内存映射
物理地址空间是有限的(取决于实际物理设备),虚拟地址空间可以是任意大小(受限于CPU位数),对于32位的CPU,虚拟地址空间可以为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会通过查找页表,将虚拟地址转换为对应的物理地址,然后访问对应的物理内存。
这样,页表项和物理内存基址的对应关系就是由操作系统在分配内存和更新页表时确定的。这个对应关系是动态的,可以随着内存的分配和释放,进程的创建和销毁,以及内存管理的其他活动而改变。
我们知道不同的进程之间看到的虚拟地址范围是一样的,所以多个进程下,不同进程的相同的虚拟地址可以映射不同的物理地址。这就会造成歧义问题。例如,进程A将地址0x2000映射物理地址0x4000。进程B将地址0x2000映射物理地址0x5000。当进程A执行的时候将0x2000对应0x4000的映射关系缓存到TLB中。当切换B进程的时候,B进程访问0x2000的数据,会由于命中TLB从物理地址0x4000取数据。这就造成了歧义。
如何消除这种歧义,我们可以借鉴VIVT数据cache的处理方式,在进程切换时将整个TLB无效。切换后的进程都不会命中TLB,但是会导致性能损失。
参考文献:
现代操作系统内存管理到底是分段还是分页,段寄存器还有用吗? - 知乎 (zhihu.com)
逻辑地址、物理地址、虚拟地址_虚拟地址是逻辑地址吗_闫晟的博客-CSDN博客
linux内核中 逻辑地址、虚拟地址、线性地址和物理地址大扫盲
Linux的进程地址空间[一] - 知乎 (zhihu.com)
【转】Linux内存管理(最透彻的一篇) - ralap7 - 博客园 (cnblogs.com)
操作系统中的多级页表到底是为了解决什么问题?